feat(reimbursement-small-team-form-validation): 完善小团队报销填报校验

这个提交包含在:
2026-08-04 23:28:56 +08:00
父节点 f33c67eb20
当前提交 50c14c1b85
共修改 28 个文件,包含 1095 行新增和 30 行删除
@@ -131,6 +131,27 @@ describe("auth security configuration", () => {
expect(() => getConfig()).toThrow(`Invalid ${name}`);
});
it.each([
[undefined, 365],
["180", 180],
["365", 365],
])("accepts the configured reimbursement age policy", (value, expected) => {
stubBaseEnv();
vi.stubEnv("REIMBURSEMENT_MAX_AGE_DAYS", value);
expect(getConfig().reimbursementMaxAgeDays).toBe(expected);
});
it.each(["0", "181", "366", "invalid"])(
"rejects unsupported reimbursement age policy %s",
(value) => {
stubBaseEnv();
vi.stubEnv("REIMBURSEMENT_MAX_AGE_DAYS", value);
expect(() => getConfig()).toThrow("Invalid REIMBURSEMENT_MAX_AGE_DAYS");
},
);
it("ignores a forged X-Forwarded-For header by default", async () => {
stubBaseEnv();
const app = await buildIpProbeApp();
@@ -101,6 +101,12 @@ async function buildApp(options = {}) {
},
reimbursementItem: {
findFirst: vi.fn(async () => (options.travelItem ? { id: "travel-item" } : null)),
findMany: vi.fn(async () => options.reimbursementItems ?? [
{ id: "item-1", amount: reimbursement.total_amount },
]),
aggregate: vi.fn(async () => ({
_sum: { amount: options.authoritativeTotal ?? reimbursement.total_amount },
})),
},
budgetLedgerEntry: {
create: vi.fn(async () => ({ id: "budget-ledger-1" })),
@@ -245,6 +251,52 @@ describe("reimbursements routes expense standard integration", () => {
await app.close();
});
it("rejects submit when the reimbursement has no items", async () => {
const { app, tx } = await buildApp({ reimbursementItems: [] });
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ items: "at_least_one_required" });
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
await app.close();
});
it("rejects submit and identifies items with non-positive amounts", async () => {
const { app, tx } = await buildApp({
reimbursementItems: [
{ id: "item-zero", amount: "0.00" },
{ id: "item-valid", amount: "20.00" },
],
});
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({
items: "positive_amount_required",
itemIds: ["item-zero"],
});
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
await app.close();
});
it("recomputes a stale total before selecting the approval rule", async () => {
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
const { app, tx } = await buildApp({ authoritativeTotal: "320.00" });
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
expect(response.statusCode).toBe(200);
expect(tx.reimbursementForm.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ total_amount: "320.00" }),
}));
expect(tx.approvalRule.findFirst).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ min_amount: { lte: "320.00" } }),
}));
await app.close();
});
it("returns standardChecks from reimbursement precheck", async () => {
resolveEntityPrecheckMock.mockResolvedValue(
buildPrecheck({
@@ -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("环境恢复分类键数: 164");
expect(result.stdout).toContain("环境恢复分类键数: 165");
});
it("skips tracked paths deleted in the current worktree before reading metadata", async () => {
@@ -0,0 +1,88 @@
const {
buildReimbursementOperationalChecks,
} = require("../../../src/services/feeControl");
const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111";
function buildTx() {
return {
reimbursementForm: {
findUnique: vi.fn(async () => ({
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", () => {
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,
}));
});
});