You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/packages/domain/src/doc-series.ts

80 lines
3.7 KiB
TypeScript

/**
* Per-counter, per-FY document series (02-ARCHITECTURE §3 rule 3): counters
* number documents offline with zero coordination. GST caps a document number
* at 16 chars from the set [A-Z a-z 0-9 - /].
*/
export interface DocSeries {
tenantId: string
storeId: string
counterId: string
docType: string
fy: string
prefix: string
nextSeq: number
}
const GST_DOC_NO = /^[A-Za-z0-9/-]{1,16}$/
export function assertGstDocNo(docNo: string): void {
if (!GST_DOC_NO.test(docNo)) {
throw new Error(`Document number "${docNo}" violates the GST 16-char [A-Za-z0-9/-] rule`)
}
}
export function formatDocNo(prefix: string, seq: number, width = 6): string {
const docNo = `${prefix}-${String(seq).padStart(width, '0')}`
assertGstDocNo(docNo)
return docNo
}
/**
* The per-counter series prefix. It embeds the FULL 4-digit FY START YEAR (M9 collision fix):
* a 2-digit marker made FY 2026-27 and 2126-27 share the prefix "ST1C2/26", so a century apart
* their doc numbers collided on the bill's UNIQUE(tenant, doc_no) and billing on that prefix was
* blocked. The 4-digit start year ("2026" vs "2126") cannot collide within a millennium.
*
* GST caps a doc number at 16 chars from [A-Za-z0-9/-]. At a 6-digit sequence,
* store(3)+counter(2)+"/"+FY(4)+"-"+seq(6) = 17 would BREAK that rule — so the counter↔FY "/"
* separator is dropped (the sequence "-" from formatDocNo stays for readability):
* seriesPrefix("ST1", "C2", "2026-27") → "ST1C22026" ⇒ doc no "ST1C22026-000123" (16 chars).
* This keeps the 16-char rule for store+counter codes totalling ≤ 5 chars (the demo ST1+C2). A
* store with a 3-char counter code (C10+) plus a 4-digit FY would need a shorter store code — a
* documented onboarding constraint, the same tightness the 2-digit format already had at C10.
*
* `fy` is the full FY label ("2026-27"); only its 4-digit start year is used.
*/
export function seriesPrefix(storeCode: string, counterCode: string, fy: string): string {
return `${storeCode}${counterCode}${fy.slice(0, 4)}`
}
/**
* Per-counter credit-note (SALE_RETURN) series prefix. A credit note lives in the same
* document table as sales under the SAME UNIQUE(tenant, doc_no), so its number must never
* collide with a sale's — the leading "CN" marker guarantees that (a sale never starts "CN").
* It reuses M9's 4-digit-FY scheme (store + counter + 4-digit FY start year) so credit notes
* of FY 2026-27 and 2126-27 can't collide either.
*
* The "CN" marker costs two characters, so the sequence is 4 digits (`CREDIT_NOTE_SEQ_WIDTH`)
* rather than the sale's 6, keeping the GST 16-char [A-Za-z0-9/-] rule at the demo's ST1+C2
* sizing: `creditNotePrefix("ST1","C2","2026-27")` → "CNST1C22026" ⇒ "CNST1C22026-0001" (16).
* That caps a counter at 9,999 credit notes per FY — far beyond any real counter — and, as for
* sales, needs store+counter codes to total ≤ 5 chars (a documented onboarding constraint).
*/
export function creditNotePrefix(storeCode: string, counterCode: string, fy: string): string {
return `CN${storeCode}${counterCode}${fy.slice(0, 4)}`
}
/** Credit-note sequence width — 4 digits so the "CN"-marked doc no stays within 16 chars. */
export const CREDIT_NOTE_SEQ_WIDTH = 4
export function nextDocNo(series: DocSeries): { docNo: string; series: DocSeries } {
const docNo = formatDocNo(series.prefix, series.nextSeq)
return { docNo, series: { ...series, nextSeq: series.nextSeq + 1 } }
}
/** FY rollover starts a fresh series; the old one is retained for returns lookups. */
export function rolloverForFy(series: DocSeries, fy: string, prefix: string): DocSeries {
if (fy === series.fy) return series
return { ...series, fy, prefix, nextSeq: 1 }
}