Em dashes (—) and en dashes (–) had spread across comments, docs, tests, and a few UI strings, reading as AI-generated boilerplate rather than house style. Replaced each with punctuation matching its context: colon for explanatory clauses, comma for asides, plain hyphen for numeric/legal ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for paired-dash asides. messages/en.json and messages/sv.json were fixed by hand together to keep sv/en in sync. Left untouched where the dash is the functional subject rather than decorative punctuation: date-range-parser.ts's separator regex, charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the agent system-prompt files that already instruct against em dashes, and a golden iXBRL test fixture compared byte-for-byte. Also fixes two bugs surfaced along the way: an off-by-one in ApiKeysPanel's scope-label split (a leftover from an earlier partial pass), and a charset-repair test that had lost the literal en-dash it exists to verify. Regenerated the agent atom seed migration (skills:generate) since 27 SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes, with an explicit carve-out for the functional-dash cases above. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
124 lines
4.5 KiB
TypeScript
124 lines
4.5 KiB
TypeScript
/**
|
|
* Voucher series resolver: pure helpers for mapping journal_entries.source_type
|
|
* to a default voucher_series per company_settings, formatting voucher labels
|
|
* for display, and parsing them back.
|
|
*
|
|
* Source-type → series mapping lives in
|
|
* company_settings.default_voucher_series_per_source_type (JSONB).
|
|
*
|
|
* Defaults to 'A' when:
|
|
* - the settings row is null/undefined
|
|
* - the JSONB is missing the source_type key
|
|
* - the configured value is not a single uppercase letter A-Z
|
|
*
|
|
* These functions are pure; no I/O. Engine call-sites read the settings row
|
|
* once and pass it in.
|
|
*/
|
|
import type { JournalEntrySourceType } from '@/types'
|
|
|
|
export type VoucherSeriesMap = Partial<Record<JournalEntrySourceType, string>> &
|
|
Record<string, string>
|
|
|
|
const SERIES_LETTER_RE = /^[A-Z]$/
|
|
|
|
/**
|
|
* Resolve the default voucher_series letter for a given source_type from a
|
|
* company_settings row. Returns 'A' as a safe fallback when no mapping is
|
|
* configured for that source_type.
|
|
*
|
|
* @param settings - Either a full CompanySettings row or just the per-source
|
|
* map. `null`/`undefined` is allowed (returns 'A').
|
|
* @param sourceType - The journal_entries.source_type value.
|
|
*/
|
|
export function resolveDefaultSeriesForSource(
|
|
settings:
|
|
| { default_voucher_series_per_source_type?: VoucherSeriesMap | null }
|
|
| VoucherSeriesMap
|
|
| null
|
|
| undefined,
|
|
sourceType: JournalEntrySourceType,
|
|
): string {
|
|
if (!settings) return 'A'
|
|
|
|
// Accept both the full settings row and a bare map. Both shapes are
|
|
// narrowed via duck-typing on the column key: when present, treat it as
|
|
// the settings row; otherwise treat the argument itself as the map.
|
|
const raw = settings as {
|
|
default_voucher_series_per_source_type?: VoucherSeriesMap | null
|
|
} & VoucherSeriesMap
|
|
const mapCandidate =
|
|
raw.default_voucher_series_per_source_type !== undefined
|
|
? raw.default_voucher_series_per_source_type
|
|
: (settings as VoucherSeriesMap)
|
|
|
|
if (!mapCandidate || typeof mapCandidate !== 'object') return 'A'
|
|
|
|
const value = (mapCandidate as VoucherSeriesMap)[sourceType]
|
|
if (typeof value === 'string' && SERIES_LETTER_RE.test(value)) {
|
|
return value
|
|
}
|
|
return 'A'
|
|
}
|
|
|
|
/**
|
|
* Propagate a change to the global default voucher series across the
|
|
* per-source-type map. Source types that were still following the previous
|
|
* default move to the new default; explicit overrides (values that differ from
|
|
* the previous default) are preserved untouched.
|
|
*
|
|
* The booking engine resolves series from the per-source-type map, not from the
|
|
* global default, so the bookkeeping settings form calls this when the user
|
|
* changes the "Standardserie" dropdown, otherwise that control would be a
|
|
* no-op for bookkeeping. Pure; returns the next map (input is not mutated).
|
|
*/
|
|
export function applyDefaultSeriesToMap(
|
|
currentMap: VoucherSeriesMap | null | undefined,
|
|
prevDefault: string,
|
|
nextDefault: string,
|
|
): VoucherSeriesMap {
|
|
const out: VoucherSeriesMap = {}
|
|
for (const [key, value] of Object.entries(currentMap || {})) {
|
|
out[key] = value === prevDefault ? nextDefault : value
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* Format a voucher (series + number) for UI display. Returns "-" when the
|
|
* voucher number is null (e.g. a draft entry that has not been committed yet).
|
|
*
|
|
* Always lifts the series to uppercase. Falls back to 'A' when the series is
|
|
* null/empty for forward-compat with legacy rows. Accepts partial inputs so
|
|
* callsites can pass through API responses without re-shaping them.
|
|
*/
|
|
export function formatVoucher(entry: {
|
|
voucher_series?: string | null
|
|
voucher_number?: number | null
|
|
}): string {
|
|
if (entry.voucher_number == null || entry.voucher_number === 0) {
|
|
return '-'
|
|
}
|
|
const series =
|
|
entry.voucher_series && typeof entry.voucher_series === 'string'
|
|
? entry.voucher_series.toUpperCase()
|
|
: 'A'
|
|
return `${series}${entry.voucher_number}`
|
|
}
|
|
|
|
/**
|
|
* Parse a formatted voucher label back into its parts. Returns null when the
|
|
* input does not match the expected shape (single uppercase letter followed
|
|
* by a positive integer). Use for filter inputs / search.
|
|
*/
|
|
export function parseVoucher(
|
|
formatted: string,
|
|
): { series: string; number: number } | null {
|
|
if (typeof formatted !== 'string') return null
|
|
const trimmed = formatted.trim().toUpperCase()
|
|
const match = trimmed.match(/^([A-Z])(\d+)$/)
|
|
if (!match) return null
|
|
const number = parseInt(match[2], 10)
|
|
if (!Number.isFinite(number) || number <= 0) return null
|
|
return { series: match[1], number }
|
|
}
|