feat: 完成错误原因合同与付款就绪提示(09-05-error-explainability-payment-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-09-05 19:00:35 +08:00
父节点 ab82fe30c0
当前提交 a837912638
共修改 42 个文件,包含 2867 行新增和 876 行删除
@@ -182,6 +182,7 @@ describe("invoicePool travel-order bind routes", () => {
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({
linkedItemId: "required_when_invoice_guard_enabled",
reasonCode: "reimbursement.invoice_item_required",
});
expect(state.invoice.status).toBe("available");
expect(prisma.reimbursementItem.create).not.toHaveBeenCalled();
@@ -356,7 +357,7 @@ describe("invoicePool travel-order bind routes", () => {
});
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ linkedItemId: "item_not_in_reimbursement" });
expect(response.json().error.details).toEqual({ linkedItemId: "item_not_in_reimbursement", reasonCode: "reimbursement.linked_item_id_item_not_in_reimbursement" });
await app.close();
});
@@ -315,7 +315,7 @@ describe("invoicePool verification routes", () => {
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details).toEqual({ verification: "too_frequent" });
expect(response.json().error.details).toEqual({ verification: "too_frequent", reasonCode: "invoice_verification.too_frequent" });
expect(tx.invoiceVerificationRecord.create).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
@@ -433,7 +433,7 @@ describe("invoicePool verification routes", () => {
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details).toEqual({ verification: "attempts_exceeded" });
expect(response.json().error.details).toEqual({ verification: "attempts_exceeded", reasonCode: "invoice_verification.attempts_exceeded" });
expect(tx.invoiceVerificationRecord.create).not.toHaveBeenCalled();
expect(app.boss.send).not.toHaveBeenCalled();
} finally {
@@ -293,6 +293,7 @@ function buildPrisma(options = {}) {
const tx = {
paymentAccount: {
findFirst: vi.fn(async () => ({ ...state.paymentAccount })),
findMany: vi.fn(async ({ where }) => where.legal_entity_id.in.includes(state.paymentAccount.legal_entity_id) && state.paymentAccount.enabled && !state.paymentAccount.archived_at ? [{ ...state.paymentAccount }] : []),
},
paymentBatch: {
findUnique: vi.fn(async ({ where }) => {
@@ -431,12 +432,12 @@ function buildPrisma(options = {}) {
count: vi.fn(async () =>
options.candidateCount ?? (state.reservedEntityIds.includes(state.reimbursement.id) ? 0 : 1),
),
findMany: vi.fn(async ({ select } = {}) => {
findMany: vi.fn(async ({ select, where } = {}) => {
if (select?.owner?.select?.employee_no) {
return state.reservedEntityIds.includes(state.reimbursement.id) ? [] : [{ ...state.reimbursement }];
return [state.reimbursement, ...state.additionalReimbursements].filter((item) => item.status === "approved" && !item.paid_at).map((item) => ({ ...item }));
}
const reimbursements = [state.reimbursement, ...state.additionalReimbursements];
return reimbursements.map((item) => ({ ...item }));
return reimbursements.filter((item) => !where?.id?.in || where.id.in.includes(item.id)).map((item) => ({ ...item }));
}),
findUnique: vi.fn(async ({ where }) => {
if (where.id !== state.reimbursement.id) {
@@ -488,6 +489,7 @@ function buildPrisma(options = {}) {
findFirst: vi.fn(async () => null),
updateMany: vi.fn(async () => ({ count: 0 })),
create: vi.fn(async ({ data }) => ({ id: "risk-1", ...data })),
update: vi.fn(async ({ where, data }) => ({ id: where.id, ...data })),
},
auditLog: {
create: vi.fn(async () => ({ id: "audit-1" })),
@@ -648,6 +650,112 @@ afterAll(() => {
});
describe("payment batch reimbursement routes", () => {
it("preserves internal failure summaries while refreshing safe reason metadata on an existing risk", async () => {
const { app, tx, state } = await buildApp({ reimbursement: { status: "reviewing" } });
const risk = {
id: "existing-risk", entity_type: "reimbursement", entity_id: REIMBURSEMENT_ID,
risk_code: "payment_batch_execute_failed", level: "high", title: "支付批次执行失败",
detail: "旧失败摘要", source: "payment_batch", status: "open",
payload_json: { batchId: "previous-batch", itemId: "previous-item", failureCode: "VALID_001" },
};
tx.entityRiskFlag.findFirst.mockResolvedValue(risk);
tx.entityRiskFlag.findMany.mockImplementation(async () => [risk]);
tx.entityRiskFlag.update.mockImplementation(async ({ data }) => Object.assign(risk, data));
const response = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/execute`, headers: { authorization: "Bearer test" }, payload: {} });
expect(response.statusCode).toBe(200);
expect(state.batch.status).toBe("failed");
expect(state.batch.items[0]).toMatchObject({ failure_code: "VALID_001", failure_message: "参数校验失败" });
expect(risk.status).toBe("open");
expect(risk.payload_json).toEqual({ batchId: BATCH_ID, itemId: ITEM_ID, failureCode: "VALID_001", reasonCode: "payment_batch.source_status_invalid" });
expect(risk.detail).toBe("来源单据尚未满足付款条件或已经付款,请刷新并核实审批与付款状态。");
expect(tx.entityRiskFlag.create).not.toHaveBeenCalled();
expect(response.json().data.item.items[0].openRiskFlags[0].payloadJson.reasonCode).toBe("payment_batch.source_status_invalid");
await app.close();
});
it("projects synthetic 0014 omissions in candidates and preflight without writes, and recomputes after repair", async () => {
const { app, tx, state } = await buildApp();
const completeOwner = { ...state.reimbursement.owner };
state.reimbursement.owner = { ...completeOwner, legal_entity_id: null, bank_institution: null, bank_account_encrypted: null };
const candidates = await app.inject({ method: "GET", url: "/api/payment-batches/candidates", headers: { authorization: "Bearer test" } });
expect(candidates.statusCode).toBe(200);
const expected = ["payment_batch.legal_entity_required", "payment_batch.payee_bank_required", "payment_batch.payee_account_required"];
expect(candidates.json().data.items[0].readiness.blockers.map((blocker) => blocker.reasonCode)).toEqual(expected);
const payload = { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID], defaultPaymentChannel: "bank" };
const preflight = await app.inject({ method: "POST", url: "/api/payment-batches/readiness", headers: { authorization: "Bearer test" }, payload });
expect(preflight.statusCode).toBe(200);
expect(preflight.json().data.blockers.map((blocker) => blocker.reasonCode)).toEqual(expected);
for (const blocker of preflight.json().data.blockers) expect(Object.keys(blocker)).toEqual(["reasonCode", "entityId", "entityCode"]);
expect(tx.paymentBatch.create).not.toHaveBeenCalled();
expect(tx.paymentBatchItem.create).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
expect(createNotificationsWithRecordsMock).not.toHaveBeenCalled();
state.reimbursement.owner = completeOwner;
const repaired = await app.inject({ method: "POST", url: "/api/payment-batches/readiness", headers: { authorization: "Bearer test" }, payload });
expect(repaired.json().data).toEqual({ ready: true, blockers: [] });
await app.close();
});
it.each([
["source_status_invalid", (state) => { state.reimbursement.status = "paid"; }],
["active_batch_exists", (state) => { state.reservedEntityIds.push(REIMBURSEMENT_ID); }],
["legal_entity_required", (state) => { state.reimbursement.owner.legal_entity_id = null; }],
["payee_bank_unsupported", (state) => { state.reimbursement.owner.bank_institution.code = "OTHER"; }],
["payee_bank_required", (state) => { state.reimbursement.owner.bank_institution = null; }],
["payee_account_required", (state) => { state.reimbursement.owner.bank_account_encrypted = null; }],
["payment_account_required", (state) => { state.paymentAccount.enabled = false; }],
["payment_account_unavailable", (state) => { state.paymentAccount.account_number_encrypted = "synthetic-invalid"; }],
["reviewer_required", (state) => { state.employees = []; }],
])("rechecks %s after locking and refuses all creation side effects", async (reason, change) => {
const { app, tx, state } = await buildApp();
const payload = { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID], defaultPaymentChannel: "bank" };
const preflight = await app.inject({ method: "POST", url: "/api/payment-batches/readiness", headers: { authorization: "Bearer test" }, payload });
expect(preflight.statusCode).toBe(200);
expect(preflight.json().data).toEqual({ ready: true, blockers: [] });
tx.$queryRaw.mockImplementationOnce(async () => { change(state); return []; });
const response = await app.inject({ method: "POST", url: "/api/payment-batches", headers: { authorization: "Bearer test" }, payload });
expect(response.statusCode).toBe(409);
expect(response.json().error.code).toBe("VALID_001");
expect(response.json().error.details.reasonCode).toBe(`payment_batch.${reason}`);
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
expect(tx.paymentBatch.create).not.toHaveBeenCalled();
expect(tx.paymentBatchItem.create).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
expect(createNotificationsWithRecordsMock).not.toHaveBeenCalled();
expect(flushNotificationDispatchTasksMock).not.toHaveBeenCalled();
await app.close();
});
it("loads candidate account and reviewer facts in batches and rejects a cross-company combination", async () => {
const secondId = "a1111111-1111-4111-8111-111111111111";
const { app, tx, state } = await buildApp({ additionalReimbursements: [{ id: secondId }] });
state.additionalReimbursements[0].owner.legal_entity_id = "second-company";
tx.paymentAccount.findMany.mockResolvedValue([state.paymentAccount, { ...state.paymentAccount, id: "second-account", legal_entity_id: "second-company" }]);
const candidates = await app.inject({ method: "GET", url: "/api/payment-batches/candidates", headers: { authorization: "Bearer test" } });
expect(candidates.statusCode).toBe(200);
expect(candidates.json().data.items.map((item) => item.readiness)).toEqual([{ ready: true, blockers: [] }, { ready: true, blockers: [] }]);
expect(tx.paymentAccount.findMany).toHaveBeenCalledTimes(1);
expect(tx.employee.findMany).toHaveBeenCalledTimes(1);
expect(tx.paymentBatchItem.findMany).toHaveBeenCalledTimes(1);
const payload = { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID, secondId], defaultPaymentChannel: "bank" };
const preflight = await app.inject({ method: "POST", url: "/api/payment-batches/readiness", headers: { authorization: "Bearer test" }, payload });
expect(preflight.json().data).toEqual({ ready: false, blockers: [{ reasonCode: "payment_batch.multiple_legal_entities" }] });
const create = await app.inject({ method: "POST", url: "/api/payment-batches", headers: { authorization: "Bearer test" }, payload });
expect(create.statusCode).toBe(409);
expect(create.json().error.details).toEqual({ legalEntity: "single_legal_entity_required", reasonCode: "payment_batch.multiple_legal_entities" });
expect(tx.paymentBatch.create).not.toHaveBeenCalled();
await app.close();
});
it("rejects unexpected preflight fields without touching creation state", async () => {
const { app, tx } = await buildApp();
const response = await app.inject({ method: "POST", url: "/api/payment-batches/readiness", headers: { authorization: "Bearer test" }, payload: { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID], unexpected: true } });
expect(response.statusCode).toBe(400);
expect(response.json().error.details.reasonCode).toBe("payment_batch.invalid_input");
expect(tx.paymentBatch.create).not.toHaveBeenCalled();
await app.close();
});
it("executes reimbursement batch payment and queues downstream follow-ups", async () => {
createNotificationsWithRecordsMock.mockResolvedValueOnce([
{ id: "notif-1", recipient_employee_id: OWNER_ID },
@@ -838,7 +946,7 @@ describe("payment batch reimbursement routes", () => {
await app.close();
});
it("omits reimbursements already reserved by a non-cancelled payment batch", async () => {
it("keeps reserved candidates visible with the shared blocking reason", async () => {
const { app } = await buildApp({ reservedEntityIds: [REIMBURSEMENT_ID] });
const response = await app.inject({
@@ -848,12 +956,12 @@ describe("payment batch reimbursement routes", () => {
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toMatchObject({ total: 0, items: [] });
expect(response.json().data).toMatchObject({ total: 1, items: [{ readiness: { ready: false, blockers: [{ reasonCode: "payment_batch.active_batch_exists" }] } }] });
await app.close();
});
it("omits reimbursement candidates outside the payment rollout department allowlist", async () => {
it("keeps department-blocked candidates visible but not ready", async () => {
const { app } = await buildApp({ paymentBatchAllowedDepartmentIds: ["dept-2"] });
const response = await app.inject({
@@ -863,7 +971,7 @@ describe("payment batch reimbursement routes", () => {
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toMatchObject({ total: 0, items: [] });
expect(response.json().data).toMatchObject({ total: 1, items: [{ readiness: { ready: false, blockers: [{ reasonCode: "payment_batch.department_unavailable" }] } }] });
await app.close();
});
@@ -1933,7 +2041,7 @@ describe("payment batch reimbursement routes", () => {
headers: { authorization: "Bearer test" },
});
expect(missingReasonResponse.statusCode).toBe(400);
expect(missingReasonResponse.json().error.details).toEqual({ reason: "required" });
expect(missingReasonResponse.json().error.details).toEqual({ reason: "required", reasonCode: "payment_batch.download_reason_required" });
const previewResponse = await app.inject({
method: "GET",
@@ -381,7 +381,7 @@ describe("reimbursement attachment routes", () => {
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details).toEqual({ status: "invalid_attachment_edit_status" });
expect(response.json().error.details).toEqual({ status: "invalid_attachment_edit_status", reasonCode: "reimbursement.status_invalid_attachment_edit_status" });
expect(prisma.reimbursementAttachment.create).not.toHaveBeenCalled();
} finally {
await app.close();
@@ -665,9 +665,9 @@ describe("reimbursement attachment routes", () => {
expect(unauthenticated.statusCode).toBe(401);
expect(missingReason.statusCode).toBe(400);
expect(missingReason.json().error.details).toEqual({ reason: "required" });
expect(missingReason.json().error.details).toEqual({ reason: "required", reasonCode: "reimbursement.reason_required" });
expect(missingBundleReason.statusCode).toBe(400);
expect(missingBundleReason.json().error.details).toEqual({ reason: "required" });
expect(missingBundleReason.json().error.details).toEqual({ reason: "required", reasonCode: "reimbursement.reason_required" });
expect(prisma.auditLog.create).not.toHaveBeenCalled();
} finally {
await app.close();
@@ -317,7 +317,7 @@ describe("reimbursements routes expense standard integration", () => {
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(response.json().error.details).toEqual({ items: "at_least_one_required", reasonCode: "reimbursement.items_at_least_one_required" });
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
await app.close();
});
@@ -336,6 +336,7 @@ describe("reimbursements routes expense standard integration", () => {
expect(response.json().error.details).toEqual({
items: "positive_amount_required",
itemIds: ["item-zero"],
reasonCode: "reimbursement.items_positive_amount_required",
});
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
await app.close();
@@ -416,7 +417,7 @@ describe("reimbursements routes expense standard integration", () => {
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ invoices: "bound_invoice_required" });
expect(response.json().error.details).toEqual({ invoices: "bound_invoice_required", reasonCode: "reimbursement.invoices_bound_invoice_required" });
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
await app.close();
});