110 行
3.7 KiB
JavaScript
110 行
3.7 KiB
JavaScript
const {
|
|
buildReimbursementOperationalChecks,
|
|
} = require("../../../src/services/feeControl");
|
|
|
|
const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111";
|
|
|
|
function buildTx() {
|
|
return {
|
|
employee: {
|
|
findUnique: vi.fn(async () => ({ roles: [{ role_code: "employee" }] })),
|
|
},
|
|
employeeRole: { findFirst: vi.fn(async () => null) },
|
|
reimbursementForm: {
|
|
findUnique: vi.fn(async () => ({
|
|
id: REIMBURSEMENT_ID,
|
|
owner_id: REIMBURSEMENT_ID,
|
|
submitter_id: REIMBURSEMENT_ID,
|
|
items: [
|
|
{
|
|
id: "item-office",
|
|
expense_date: new Date("2026-02-05T00:00:00.000Z"),
|
|
expense_type: { category: "office" },
|
|
},
|
|
{
|
|
id: "item-asset",
|
|
expense_date: new Date("2025-08-03T00:00:00.000Z"),
|
|
expense_type: { category: "assets" },
|
|
},
|
|
],
|
|
})),
|
|
},
|
|
invoicePool: {
|
|
findMany: vi.fn(async () => [
|
|
{ linked_item_id: "item-office" },
|
|
{ linked_item_id: "item-office" },
|
|
{ linked_item_id: null },
|
|
]),
|
|
},
|
|
reimbursementAttachment: {
|
|
count: vi.fn(async () => 2),
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("buildReimbursementOperationalChecks", () => {
|
|
it("reports missing CEO as an operational blocker independently of the budget feature", async () => {
|
|
const tx = buildTx();
|
|
tx.employee.findUnique.mockResolvedValue({ roles: [{ role_code: "finance" }] });
|
|
const result = await buildReimbursementOperationalChecks(tx, {
|
|
reimbursementId: REIMBURSEMENT_ID, config: { appTimezone: "Asia/Shanghai" },
|
|
});
|
|
expect(result.blockingReasons).toEqual([expect.objectContaining({ code: "finance_owner_ceo_unavailable" })]);
|
|
expect(tx.employeeRole.findFirst).toHaveBeenCalledWith(expect.objectContaining({
|
|
where: {
|
|
role_code: "ceo", employee_id: { notIn: [REIMBURSEMENT_ID] },
|
|
employee: { status: "active", archived_at: null },
|
|
},
|
|
}));
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-08-04T04:00:00.000Z"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("summarizes evidence, timeliness and asset candidates without blocking", async () => {
|
|
const result = await buildReimbursementOperationalChecks(buildTx(), {
|
|
reimbursementId: REIMBURSEMENT_ID,
|
|
config: { appTimezone: "Asia/Shanghai", reimbursementMaxAgeDays: 365 },
|
|
});
|
|
|
|
expect(result.evidenceSummary).toEqual({
|
|
invoiceCount: 3,
|
|
attachmentCount: 2,
|
|
items: [
|
|
{ itemId: "item-office", linkedInvoiceCount: 2, invoiceState: "linked" },
|
|
{ itemId: "item-asset", linkedInvoiceCount: 0, invoiceState: "unlinked" },
|
|
],
|
|
});
|
|
expect(result.timelinessChecks).toEqual([
|
|
expect.objectContaining({ itemId: "item-office", ageDays: 180, overdue: false, crossCalendarYear: false }),
|
|
expect.objectContaining({ itemId: "item-asset", ageDays: 366, overdue: true, crossCalendarYear: true }),
|
|
]);
|
|
expect(result.assetHint).toEqual({ required: true, itemCount: 1 });
|
|
expect(result.warnings.map((item) => item.code)).toEqual(expect.arrayContaining([
|
|
"expense_date_overdue_item-asset",
|
|
"expense_date_cross_year_item-asset",
|
|
"reimbursement_items_without_linked_invoice",
|
|
"reimbursement_asset_registration_required",
|
|
]));
|
|
});
|
|
|
|
it("uses the 180-day policy and keeps the boundary day eligible", async () => {
|
|
const result = await buildReimbursementOperationalChecks(buildTx(), {
|
|
reimbursementId: REIMBURSEMENT_ID,
|
|
config: { appTimezone: "Asia/Shanghai", reimbursementMaxAgeDays: 180 },
|
|
});
|
|
|
|
expect(result.timelinessChecks[0]).toEqual(expect.objectContaining({
|
|
ageDays: 180,
|
|
maxAgeDays: 180,
|
|
overdue: false,
|
|
}));
|
|
});
|
|
});
|