汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。 统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。 纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。 关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。 预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。 完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
165 行
11 KiB
JavaScript
165 行
11 KiB
JavaScript
const { BUSINESS_NUMBER_FAMILIES, businessDateAt, resolveNumberingContext, issueBusinessNumber,
|
|
reserveProjectNumber, setNumberingPolicy } = require("../../src/services/businessNumbering");
|
|
const { issueDocumentNumber } = require("../../src/services/documentSequence");
|
|
const NOW = new Date("2026-09-30T16:00:00Z");
|
|
const ACTOR = "11111111-1111-4111-8111-111111111111";
|
|
function fixture({ policy = null, maximum = 0n } = {}) {
|
|
const counters = new Map();
|
|
const tx = {
|
|
$executeRaw: vi.fn(async () => 0),
|
|
$queryRaw: vi.fn(async () => [{ maximum }]),
|
|
businessNumberPolicy: {
|
|
findUnique: vi.fn(async () => policy),
|
|
create: vi.fn(async ({ data }) => (policy = { ...data })),
|
|
update: vi.fn(async ({ data }) => (policy = { ...policy, ...data })),
|
|
},
|
|
businessNumberCounter: {
|
|
findUnique: vi.fn(async ({ where }) => counters.get(where.key) ?? null),
|
|
upsert: vi.fn(async ({ where, create, update }) => {
|
|
const value = { ...(counters.get(where.key) ?? create), ...update, updated_at: NOW };
|
|
counters.set(where.key, value); return value;
|
|
}),
|
|
},
|
|
documentSequence: { findUnique: vi.fn(async () => null), upsert: vi.fn(async ({ create }) => create) },
|
|
documentCategory: { findUnique: vi.fn(async () => null), findFirst: vi.fn(async () => null) },
|
|
auditLog: { create: vi.fn(async ({ data }) => data) },
|
|
};
|
|
return { tx, counters };
|
|
}
|
|
const issue = (tx, family, now = NOW, rest = {}) => issueBusinessNumber(tx, { family, now, ...rest });
|
|
describe("business numbering v2", () => {
|
|
it.each([
|
|
["reimbursement", "BX-202610-0001"], ["payment_batch", "PB-2026-0001"],
|
|
["company_loan", "JK-2026-0001"], ["loan_repayment", "HK-2026-0001"],
|
|
["purchase_request", "CG-2026-0001"], ["inventory_check", "PD-2026-0001"],
|
|
["project", "PRJ-2026-0001"], ["asset_card", "FA-0001"],
|
|
])("issues %s with its approved period", async (family, expected) => {
|
|
const { tx } = fixture();
|
|
expect((await issue(tx, family)).documentNumber).toBe(expected);
|
|
expect((await issue(tx, family)).documentNumber).toBe(expected.replace("0001", "0002"));
|
|
expect(tx.$executeRaw.mock.invocationCallOrder[0]).toBeLessThan(tx.$queryRaw.mock.invocationCallOrder[0]);
|
|
expect(tx.$executeRaw.mock.calls[0].slice(1)).toEqual([1004, BUSINESS_NUMBER_FAMILIES[family].lockKey]);
|
|
});
|
|
it("uses one instant and configured timezone at month and year boundaries", async () => {
|
|
const { tx } = fixture();
|
|
expect((await issue(tx, "reimbursement", new Date("2026-09-30T15:59:59Z"))).documentNumber).toBe("BX-202609-0001");
|
|
expect((await issue(tx, "reimbursement")).documentNumber).toBe("BX-202610-0001");
|
|
expect((await issue(tx, "reimbursement", NOW, { timeZone: "Pacific/Honolulu" })).documentNumber).toBe("BX-202609-0002");
|
|
expect((await issue(tx, "payment_batch", new Date("2026-12-31T15:59:59Z"))).documentNumber).toBe("PB-2026-0001");
|
|
expect((await issue(tx, "payment_batch", new Date("2026-12-31T16:00:00Z"))).documentNumber).toBe("PB-2027-0001");
|
|
expect((await issue(tx, "asset_card", new Date("2027-01-01T00:00:00Z"))).documentNumber).toBe("FA-0001");
|
|
expect((await issue(tx, "asset_card", new Date("2028-01-01T00:00:00Z"))).documentNumber).toBe("FA-0002");
|
|
expect(businessDateAt(NOW)).toBe("2026-10-01");
|
|
});
|
|
it("keeps EA categories independent and counters shared for the same prefix", async () => {
|
|
const { tx } = fixture();
|
|
for (const [prefix, serial] of [["EA-DOC", "0001"], ["EA-SYS", "0001"], ["EA-DOC", "0002"]]) {
|
|
expect((await issue(tx, "asset_card", NOW, { prefix })).documentNumber).toBe(`${prefix}-${serial}`);
|
|
}
|
|
});
|
|
it("continues after matching occupied numbers and expands beyond four digits", async () => {
|
|
const { tx } = fixture({ maximum: 9999n });
|
|
expect((await issue(tx, "payment_batch")).documentNumber).toBe("PB-2026-10000");
|
|
tx.$queryRaw.mockResolvedValue([{ maximum: 0n }]);
|
|
expect((await issue(tx, "payment_batch")).documentNumber).toBe("PB-2026-10001");
|
|
});
|
|
it("never rounds large counters and fails at bigint capacity", async () => {
|
|
const { tx } = fixture({ maximum: 9007199254740992n });
|
|
const result = await issue(tx, "asset_card");
|
|
expect(result.documentNumber).toBe("FA-9007199254740993");
|
|
expect(result.config.currentNumber).toBe("9007199254740993");
|
|
tx.$queryRaw.mockResolvedValue([{ maximum: 9223372036854775807n }]);
|
|
await expect(issue(tx, "asset_card")).rejects.toThrow("capacity exhausted");
|
|
expect(tx.businessNumberCounter.upsert).toHaveBeenCalledTimes(1);
|
|
});
|
|
it("reserves manual project codes without renaming them or activating policy", async () => {
|
|
const { tx } = fixture();
|
|
await reserveProjectNumber(tx, "CLIENT-FORMAL-A");
|
|
expect(tx.$executeRaw).not.toHaveBeenCalled();
|
|
await reserveProjectNumber(tx, "PRJ-2026-0010");
|
|
await reserveProjectNumber(tx, "PRJ-2026-0002");
|
|
expect((await issue(tx, "project")).documentNumber).toBe("PRJ-2026-0011");
|
|
expect(tx.businessNumberPolicy.findUnique).not.toHaveBeenCalled();
|
|
});
|
|
it.each([null, { enabled: false, effective_at: NOW }, { enabled: true, effective_at: new Date(NOW.getTime() + 1) }])("keeps legacy without an effective active policy: %j", async (policy) => {
|
|
const { tx } = fixture({ policy });
|
|
expect((await resolveNumberingContext(tx, { now: NOW })).version).toBe(1);
|
|
expect(tx.$executeRaw.mock.invocationCallOrder[0]).toBeLessThan(tx.businessNumberPolicy.findUnique.mock.invocationCallOrder[0]);
|
|
});
|
|
it("activates at the exact boundary but a pinned legacy or v2 draft keeps its version", async () => {
|
|
const { tx } = fixture({ policy: { enabled: true, effective_at: NOW } });
|
|
expect((await resolveNumberingContext(tx, { now: NOW })).version).toBe(2);
|
|
expect((await resolveNumberingContext(tx, { version: 1, now: NOW })).version).toBe(1);
|
|
tx.businessNumberPolicy.findUnique.mockResolvedValue(null);
|
|
expect((await resolveNumberingContext(tx, { version: 2, now: NOW })).version).toBe(2);
|
|
await expect(resolveNumberingContext(tx, { version: 3 })).rejects.toThrow("Invalid numbering version");
|
|
});
|
|
it("keeps historical missing reimbursement numbers legacy and leaves old counters untouched for new drafts", async () => {
|
|
const { tx } = fixture({ policy: { enabled: true, effective_at: NOW } });
|
|
const old = await issueDocumentNumber(tx, { documentType: "reimbursement", numberingVersion: 1, now: NOW });
|
|
expect(old.documentNumber).toMatch(/^BX-\d{8}-0001$/);
|
|
expect(tx.businessNumberCounter.upsert).not.toHaveBeenCalled();
|
|
tx.documentSequence.upsert.mockClear();
|
|
const fresh = await issueDocumentNumber(tx, { documentType: "reimbursement", now: NOW });
|
|
expect(fresh.documentNumber).toBe("BX-202610-0001");
|
|
expect(tx.documentSequence.upsert).not.toHaveBeenCalled();
|
|
});
|
|
it("read-only config context acquires no locks or writes", async () => {
|
|
const { tx } = fixture(); await resolveNumberingContext(tx, { readOnly: true, now: NOW });
|
|
expect(tx.$executeRaw).not.toHaveBeenCalled(); expect(tx.businessNumberPolicy.create).not.toHaveBeenCalled();
|
|
});
|
|
it("fails closed on a policy read failure rather than silently taking legacy", async () => {
|
|
const { tx } = fixture(); tx.businessNumberPolicy.findUnique.mockRejectedValue(new Error("synthetic"));
|
|
await expect(resolveNumberingContext(tx)).rejects.toThrow("synthetic");
|
|
});
|
|
it("persists the activation time through pause/resume and audits each transition", async () => {
|
|
const { tx } = fixture();
|
|
const input = { actorId: ACTOR, reference: "synthetic-change-1", writersCompatible: true, now: NOW };
|
|
const effectiveAt = new Date(NOW.getTime() + 1000);
|
|
await setNumberingPolicy(tx, { ...input, action: "activate", effectiveAt });
|
|
await setNumberingPolicy(tx, { ...input, action: "pause" });
|
|
const resumed = await setNumberingPolicy(tx, { ...input, action: "resume" });
|
|
expect(resumed.effective_at).toEqual(effectiveAt); expect(resumed.enabled).toBe(true);
|
|
expect(tx.auditLog.create).toHaveBeenCalledTimes(3);
|
|
await expect(setNumberingPolicy(tx, { ...input, action: "activate", effectiveAt })).rejects.toThrow();
|
|
});
|
|
it("requires forward-only explicit activation and compatible writers", async () => {
|
|
const { tx } = fixture();
|
|
const input = { action: "activate", actorId: ACTOR, reference: "synthetic-1", writersCompatible: true, now: NOW };
|
|
await expect(setNumberingPolicy(tx, { ...input, effectiveAt: new Date(NOW.getTime() - 1) })).rejects.toThrow();
|
|
await expect(setNumberingPolicy(tx, { ...input, effectiveAt: NOW, writersCompatible: false })).rejects.toThrow();
|
|
expect(tx.businessNumberPolicy.create).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it("rejects activation whose effective time elapsed while waiting for the policy lock", async () => {
|
|
vi.useFakeTimers(); vi.setSystemTime(NOW);
|
|
try {
|
|
const { tx } = fixture();
|
|
tx.$executeRaw.mockImplementation(async () => { vi.setSystemTime(new Date(NOW.getTime() + 2000)); return 0; });
|
|
await expect(setNumberingPolicy(tx, { action: "activate", effectiveAt: new Date(NOW.getTime() + 1000),
|
|
actorId: ACTOR, reference: "synthetic-wait", writersCompatible: true })).rejects.toThrow("future effective time");
|
|
expect(tx.businessNumberPolicy.create).not.toHaveBeenCalled();
|
|
expect(tx.auditLog.create).not.toHaveBeenCalled();
|
|
} finally { vi.useRealTimers(); }
|
|
});
|
|
|
|
it.each([["reimbursement", "YYYYMM"], ["purchase_request", "YYYY"], ["inventory_check", "YYYY"]])("refuses to enable independent counters over an active legacy period for %s", async (documentType, dateFormat) => {
|
|
const { tx } = fixture();
|
|
tx.documentSequence.findUnique.mockImplementation(async ({ where }) => where.document_type === documentType ? { date_format: dateFormat } : null);
|
|
await expect(setNumberingPolicy(tx, { action: "activate", effectiveAt: new Date(NOW.getTime() + 1000), now: NOW,
|
|
actorId: ACTOR, reference: "synthetic-overlap", writersCompatible: true })).rejects.toThrow("compatibility review");
|
|
expect(tx.businessNumberPolicy.create).not.toHaveBeenCalled();
|
|
expect(tx.documentSequence.upsert).not.toHaveBeenCalled();
|
|
expect(tx.auditLog.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows pause but refuses resume after an overlapping legacy configuration is introduced", async () => {
|
|
const { tx } = fixture({ policy: { enabled: true, effective_at: NOW } });
|
|
tx.documentSequence.findUnique.mockResolvedValue({ date_format: "YYYYMM" });
|
|
const input = { actorId: ACTOR, reference: "synthetic-overlap", writersCompatible: true };
|
|
expect((await setNumberingPolicy(tx, { ...input, action: "pause" })).enabled).toBe(false);
|
|
await expect(setNumberingPolicy(tx, { ...input, action: "resume" })).rejects.toThrow("compatibility review");
|
|
expect(tx.businessNumberPolicy.update).toHaveBeenCalledTimes(1);
|
|
});
|