feat: 选票自动生成报销明细并支持集中分类(08-29-reimbursement-expense-policy-invoice-guard)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-09-09 23:19:02 +08:00
父节点 b87cc55aca
当前提交 e5685f18b2
共修改 28 个文件,包含 1616 行新增和 121 行删除
@@ -76,6 +76,7 @@ function buildPrisma({
},
expenseType: {
findFirst: vi.fn().mockResolvedValue(expenseType),
findMany: vi.fn().mockResolvedValue([{ id: "train-type", code: "travel_train", enabled: true }]),
},
auditLog: {
create: vi.fn().mockResolvedValue({ id: "audit-1" }),
@@ -147,6 +148,110 @@ async function buildApp({ prisma, permissionCodes = ["travel.payment.manage"], e
}
describe("invoicePool travel-order bind routes", () => {
const explicitFormId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const explicitInvoiceId = "11111111-1111-4111-8111-111111111111";
function explicitInput(overrides = {}) {
return {
invoice: {
id: explicitInvoiceId, status: "available", linked_reimbursement_id: null,
linked_item_id: null, travel_order_id: null, total_amount: "168.00",
...overrides.invoice,
},
reimbursement: {
id: explicitFormId, owner_id: "actor-1", submitter_id: "actor-1", status: "draft",
...overrides.reimbursement,
},
reimbursementItems: [{ id: "existing-same-amount", amount: "168.00" }],
};
}
function explicitRequest(payload = {}) {
return {
method: "POST", url: `/api/invoice-pool/${explicitInvoiceId}/bind`,
headers: { authorization: "Bearer test" },
payload: { reimbursementId: explicitFormId, createItem: true, ...payload },
};
}
it.each([false, true])("explicitly creates an unclassified item without guessing or matching (guard=%s)", async (invoiceGuardEnabled) => {
const { prisma } = buildPrisma(explicitInput());
const app = await buildApp({ prisma, permissionCodes: ["invoice.upload"], invoiceGuardEnabled });
try {
const response = await app.inject(explicitRequest());
expect(response.statusCode, response.body).toBe(200);
expect(prisma.reimbursementItem.create).toHaveBeenCalledWith({
data: {
reimbursement_id: explicitFormId, expense_type_id: null,
expense_date: new Date("2026-05-08T00:00:00Z"), amount: "168.00", description: "",
created_by: "actor-1", updated_by: "actor-1",
}, select: { id: true },
});
expect(prisma.reimbursementItem.findMany).not.toHaveBeenCalled();
expect(prisma.expenseType.findFirst).not.toHaveBeenCalled();
expect(response.json().data.linkedItemId).toBe("item-auto-1");
} finally { await app.close(); }
});
it("uses persisted train metadata and keeps same-form retries write-free", async () => {
const { prisma, state } = buildPrisma(explicitInput({ invoice: {
ocr_result: { result_json: { documentCategory: "electronic_train_ticket" } },
} }));
const app = await buildApp({ prisma, permissionCodes: ["invoice.upload"], invoiceGuardEnabled: true });
try {
const first = await app.inject(explicitRequest());
expect(first.statusCode, first.body).toBe(200);
expect(prisma.reimbursementItem.create.mock.calls[0][0].data).toMatchObject({
expense_type_id: "train-type", description: "铁路交通费用",
});
prisma.reimbursementItem.findFirst.mockResolvedValue({ id: state.invoice.linked_item_id });
const retry = await app.inject(explicitRequest());
expect(retry.statusCode, retry.body).toBe(200);
expect(prisma.reimbursementItem.create).toHaveBeenCalledTimes(1);
expect(prisma.invoicePool.update).toHaveBeenCalledTimes(1);
expect(prisma.reimbursementForm.update).toHaveBeenCalledTimes(1);
state.reimbursement.status = "reviewing";
expect((await app.inject(explicitRequest())).statusCode).toBe(409);
} finally { await app.close(); }
});
it.each([
[{ invoice_date: null }, 409],
[{ total_amount: null }, 409],
[{ total_amount: "0.00" }, 409],
[{ status: "pending_ocr" }, 409],
[{ owner_employee_id: "other-owner" }, 409],
[{ status: "bound", linked_reimbursement_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" }, 409],
])("rejects invalid or unavailable evidence without creating an item: %j", async (invoice, expectedStatus) => {
const { prisma } = buildPrisma(explicitInput({ invoice }));
const app = await buildApp({ prisma, permissionCodes: ["invoice.upload"] });
try {
expect((await app.inject(explicitRequest())).statusCode).toBe(expectedStatus);
expect(prisma.reimbursementItem.create).not.toHaveBeenCalled();
expect(prisma.invoicePool.update).not.toHaveBeenCalled();
} finally { await app.close(); }
});
it("rejects conflicting create and existing-item intents before querying", async () => {
const { prisma } = buildPrisma(explicitInput());
const app = await buildApp({ prisma, permissionCodes: ["invoice.upload"] });
try {
expect((await app.inject(explicitRequest({ linkedItemId: explicitInvoiceId }))).statusCode).toBe(400);
expect(prisma.$transaction).not.toHaveBeenCalled();
} finally { await app.close(); }
});
it("rejects a nonparticipant retry even when the invoice already points to this form", async () => {
const { prisma } = buildPrisma(explicitInput({
invoice: { status: "bound", linked_reimbursement_id: explicitFormId, linked_item_id: explicitInvoiceId },
reimbursement: { owner_id: "other", submitter_id: "other" },
}));
const app = await buildApp({ prisma, permissionCodes: ["invoice.upload"] });
try {
expect((await app.inject(explicitRequest())).statusCode).toBe(403);
expect(prisma.reimbursementItem.create).not.toHaveBeenCalled();
expect(prisma.invoicePool.update).not.toHaveBeenCalled();
} finally { await app.close(); }
});
it("requires an explicit reimbursement item while the invoice guard is enabled", async () => {
const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const { prisma, state } = buildPrisma({
@@ -229,6 +334,22 @@ describe("invoicePool travel-order bind routes", () => {
await app.close();
});
it("refuses to unbind when the form changed after the binding snapshot", async () => {
const { prisma } = buildPrisma(explicitInput({ invoice: {
status: "bound", linked_reimbursement_id: explicitFormId, linked_item_id: "item-1",
} }));
prisma.invoicePool.findUnique.mockResolvedValueOnce({ linked_reimbursement_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" });
const app = await buildApp({ prisma, permissionCodes: ["invoice.upload"] });
try {
const response = await app.inject({ method: "POST", url: `/api/invoice-pool/${explicitInvoiceId}/unbind`, headers: { authorization: "Bearer test" }, payload: {} });
expect(response.statusCode).toBe(409);
expect(response.json().error.details.reasonCode).toBe("reimbursement.invoice_unbind_status_invalid");
expect(prisma.invoicePool.update).not.toHaveBeenCalled();
const firstLock = prisma.$queryRaw.mock.calls[0][0].join("?");
expect(firstLock).toContain('FROM "reimbursement_forms"');
} finally { await app.close(); }
});
it("rejects a view-all nonparticipant before unbinding a reimbursement invoice", async () => {
const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const { prisma, state } = buildPrisma({