实现报销费用政策与票据明细门禁(08-29-reimbursement-expense-policy-invoice-guard)

这个提交包含在:
2026-08-29 23:47:22 +08:00
父节点 9e2aea5f70
当前提交 6e999d5bf9
共修改 34 个文件,包含 1458 行新增和 92 行删除
@@ -42,7 +42,7 @@ describe("environment recovery classification contract", () => {
it("classifies every env contract key exactly once", async () => {
const result = await validate();
expect(result.stdout).toContain("环境恢复分类键数: 165");
expect(result.stdout).toContain("环境恢复分类键数: 166");
});
it("skips tracked paths deleted in the current worktree before reading metadata", async () => {
@@ -0,0 +1,31 @@
const fs = require("node:fs");
const path = require("node:path");
const MIGRATION_PATH = path.resolve(
__dirname,
"../../../prisma/migrations/20260829120000_reimbursement_expense_policy_invoice_guard/migration.sql",
);
describe("reimbursement expense policy migration", () => {
it("adds backward-compatible defaults before applying allowance overrides", () => {
const source = fs.readFileSync(MIGRATION_PATH, "utf8");
const requiresInvoiceColumn = source.indexOf(
'ADD COLUMN "requires_invoice" BOOLEAN NOT NULL DEFAULT true',
);
const approvalBasisColumn = source.indexOf(
'ADD COLUMN "asset_prior_approval_basis" TEXT',
);
const allowanceOverride = source.indexOf(
'SET "requires_invoice" = false',
);
expect(requiresInvoiceColumn).toBeGreaterThanOrEqual(0);
expect(approvalBasisColumn).toBeGreaterThanOrEqual(0);
expect(allowanceOverride).toBeGreaterThan(approvalBasisColumn);
expect(source).toContain("'travel_meal'");
expect(source).toContain("'travel_local_transport_allowance'");
expect(source).not.toMatch(/DROP\s+(TABLE|COLUMN)/i);
expect(source).not.toMatch(/ALTER\s+COLUMN\s+[^;]+NOT\s+NULL/i);
});
});
@@ -48,6 +48,49 @@ describe("default travel expense types", () => {
});
});
describe("reimbursement expense policy defaults", () => {
it("keeps only the two allowances invoice-free and replaces ambiguous selectable types", () => {
expect(ENABLED_EXPENSE_TYPES).toEqual(expect.arrayContaining([
expect.objectContaining({ code: "business_client_meal", name: "业务招待费" }),
expect.objectContaining({ code: "business_meeting", name: "会议费" }),
expect.objectContaining({ code: "business_training_fee", name: "培训费" }),
expect.objectContaining({ code: "assets_office_electronics", name: "办公电子设备" }),
expect.objectContaining({ code: "travel_meal", requiresInvoice: false }),
expect.objectContaining({ code: "travel_local_transport_allowance", requiresInvoice: false }),
]));
expect(ENABLED_EXPENSE_TYPES.some((item) => item.code === "business_training")).toBe(false);
expect(ENABLED_EXPENSE_TYPES.some((item) => item.code === "assets_it_hardware")).toBe(false);
});
it("provides employee-readable guidance for every selectable expense type", () => {
for (const expenseType of ENABLED_EXPENSE_TYPES) {
expect(expenseType.description, expenseType.code).toEqual(expect.any(String));
expect(expenseType.description.trim().length, expenseType.code).toBeGreaterThan(0);
}
});
it("seeds the invoice guard disabled by default", () => {
expect(DEFAULT_FEATURE_FLAGS).toContainEqual({
flag_key: "reimbursement_invoice_guard_v1",
override_mode: "force_off",
rollout_type: "global",
department_ids: [],
note: "bootstrap default: force_off",
});
});
it("preserves finance-maintained account mappings during repeated init", async () => {
const upsert = vi.fn().mockResolvedValue({});
await seedExpenseTypes({ expenseType: { upsert } });
for (const call of upsert.mock.calls) {
expect(call[0].update).not.toHaveProperty("account_code_default");
expect(call[0].create).toEqual(expect.objectContaining({ account_code_default: null }));
}
});
});
describe("small-team default reimbursement approval rules", () => {
it("uses finance for every owner fallback and finance review node", () => {
expect(DEFAULT_APPROVAL_RULES).toEqual([