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 行删除
@@ -0,0 +1,144 @@
{
"reimbursement.material_preview_limit": "报销材料较多,暂时无法连续预览,请分批查看材料或联系管理员协助。",
"purchase_attachment.unsupported_type": "仅支持 PDF、JPG、PNG、WEBP 文件,请重新选择。",
"reimbursement.invoice_amount_required": "票据金额尚未确认,无法自动生成明细,请先核对票据金额或手动选择明细。",
"reimbursement.expense_type_required": "当前没有可用费用类型,请联系管理员维护后再添加票据。",
"reimbursement.invoice_not_bound": "该票据已不再关联业务单据,请刷新票据列表后确认。",
"reimbursement.invoice_unbind_status_invalid": "报销单当前不能移出票据,请先核实审批和付款状态。",
"reimbursement.invoice_bind_status_invalid": "报销单当前不能添加票据,请刷新并确认单据状态。",
"reimbursement.invoice_owner_mismatch": "票据所属人与报销人不一致,请选择本人对应的票据。",
"reimbursement.invoice_item_required": "请为该票据选择对应的报销明细后再添加。",
"reimbursement.travel_invoice_bind_status_invalid": "差旅订单当前不支持添加票据,请核实订单状态。",
"invoice_verification.too_frequent": "验真触发过于频繁,请稍后重试。",
"invoice_verification.attempts_exceeded": "验真重试次数已达上限,请联系财务人工核验。",
"reimbursement.invalid_input": "报销内容不完整或格式不正确,请核对页面必填项和日期后重试。",
"reimbursement.invalid_uuid": "记录标识已失效,请刷新页面后重新选择。",
"reimbursement.body_empty": "尚未填写修改内容,请补充后再保存。",
"reimbursement.date_to_must_be_after_date_from": "结束日期不能早于开始日期,请调整查询日期范围。",
"reimbursement.amount_must_be_positive": "明细金额必须大于 0。",
"reimbursement.template": "模板中的明细格式已不适用,请联系管理员修正模板后重试。",
"reimbursement.owner_employee_id_invalid_owner": "所选报销人不可用,请重新选择有效员工。",
"reimbursement.total_amount_invalid": "报销合计金额不正确,请核对各项明细金额。",
"reimbursement.total_amount_negative_not_allowed": "报销合计不能为负数,请核对各项明细金额。",
"reimbursement.workflow_missing_approval_rule": "尚未配置适用的报销审批规则,请联系管理员维护后重试。",
"reimbursement.precheck": "报销提交检查未通过,请按页面检查清单逐项处理后重试。",
"reimbursement.template_id_invalid_template": "所选报销模板已失效,请重新选择有效模板。",
"reimbursement.template_id_contains_disabled_expense_type": "模板包含已停用的费用类型,请联系管理员修正模板后重试。",
"reimbursement.linked_travel_request_id_not_found": "所选出差申请已不存在,请刷新后重新选择。",
"reimbursement.linked_travel_request_id_not_approved": "所选出差申请尚未获批,请先完成出差审批。",
"reimbursement.linked_travel_request_id_owner_mismatch": "出差申请与报销人不一致,请选择本人对应的出差申请。",
"reimbursement.linked_travel_request_id_already_linked": "该出差申请已关联其他报销单,请核对已有报销记录。",
"reimbursement.linked_travel_request_id_missing_expense_type_mapping": "该出差项目尚未配置对应费用类型,请联系管理员维护后重试。",
"reimbursement.status_invalid_edit_status": "当前报销单状态不允许编辑,请刷新并确认审批进度。",
"reimbursement.travel_end_date_invalid_date_range": "出差结束日期不能早于开始日期,请调整日期。",
"reimbursement.status_invalid_delete_status": "当前报销单状态不允许删除,请核实审批和付款进度。",
"reimbursement.status_invalid_attachment_edit_status": "当前报销单不能修改附件,请刷新并确认单据状态。",
"reimbursement.file_unsupported_mime_type": "附件类型不支持,请选择允许的图片或文档格式。",
"reimbursement.file_file_too_large": "附件超过大小限制,请压缩或更换文件后重试。",
"reimbursement.file_empty_file": "所选附件为空,请重新选择有效文件。",
"reimbursement.file_too_many_files": "一次最多上传 5 个附件,请减少数量后重试。",
"reimbursement.file_missing": "请先选择需要上传的附件。",
"reimbursement.reason_required": "读取该材料需要填写用途,请补充访问原因。",
"reimbursement.status_invalid_item_edit_status": "当前报销单不能修改明细,请刷新并确认单据状态。",
"reimbursement.expense_type_id_invalid_or_disabled": "所选费用类型已失效或停用,请重新选择。",
"reimbursement.reimbursement_invalid_status_for_invoice_item_link": "当前报销单状态已变化,不能再关联发票,请刷新页面。",
"reimbursement.invoice_invalid_unlinked_item_state": "这张发票的关联状态已变化,请刷新后重试。",
"reimbursement.linked_item_id_item_not_in_reimbursement": "所选明细已不存在或不属于本单,请刷新后重新选择。",
"reimbursement.status_invalid_submit_status": "当前报销单状态不允许提交,请刷新并确认是否已提交。",
"reimbursement.items_at_least_one_required": "请先添加至少 1 条报销明细。",
"reimbursement.items_positive_amount_required": "所有报销明细金额都必须大于 0,请修改后再提交。",
"reimbursement.items_invoice_item_guard_failed": "票据检查未通过,请核对每条明细与发票的关联和金额后重试。",
"reimbursement.travel_summary_incomplete": "出差汇总信息尚未填写完整,请补充页面提示的出差信息后提交。",
"reimbursement.submit_already_in_review": "该报销单已经在审批中,请勿重复提交,可查看审批进度。",
"reimbursement.invoices_bound_invoice_required": "报销单需要有效发票,请先添加并关联发票后提交。",
"reimbursement.budget_exceeded_block_mode": "本次报销超出可用预算,请调整金额或联系预算管理员处理。",
"reimbursement.workflow_missing_definition": "尚未配置可用报销审批流程,请联系管理员维护后重试。",
"reimbursement.workflow_no_actionable_approver": "当前没有可处理该报销的审批人,请联系管理员核实审批配置。",
"reimbursement.status_invalid_withdraw_status": "当前报销单不能撤回,请刷新并核实审批进度。",
"reimbursement.withdraw_no_pending_instance": "当前报销单没有可撤回的审批,请刷新并核实进度。",
"reimbursement.withdraw_already_processed_by_approver": "审批人已处理该报销单,不能撤回,请联系审批人确认后续处理。",
"reimbursement.feature_flag_project_linking_v1_disabled": "项目关联暂未开放,请联系管理员确认处理方式。",
"reimbursement.reimbursement_invalid_status_for_project_link": "当前报销单不能修改关联项目,请刷新并确认单据状态。",
"reimbursement.payment_payment_batch_v1_required": "该报销单须通过付款批次安排付款,请前往付款批次查看或创建付款安排。",
"reimbursement.status_invalid_pay_status": "当前报销单尚不满足付款条件,请核实审批和已有付款记录。",
"reimbursement.payment_already_paid_conflict": "该报销单已有不同的付款记录,请核实原付款,勿重复登记。",
"payment_batch.invalid_identifier": "付款记录标识已失效,请刷新后重新选择。",
"payment_batch.invalid_input": "付款操作内容不完整或格式不正确,请核对页面必填项后重试。",
"payment_batch.bank_receipt_required": "银行付款登记前必须上传有效银行回单,请先补充回单。",
"payment_batch.department_unavailable": "当前单据所属部门暂不能使用付款批次,请联系管理员确认处理方式。",
"payment_batch.source_not_found": "部分来源单据已不存在或不可用,请刷新后重新选择。",
"payment_batch.daily_sequence_exhausted": "今日付款批次编号已用完,请联系管理员处理,勿重复提交。",
"payment_batch.payee_account_unavailable": "收款账户资料暂不可用,请联系管理员检查后重试。",
"payment_batch.source_status_invalid": "来源单据尚未满足付款条件或已经付款,请刷新并核实审批与付款状态。",
"payment_batch.already_paid_conflict": "来源单据已有不同的付款记录,请核实已有记录,勿重复付款。",
"payment_batch.source_amount_changed": "来源单据金额已变化,请核对单据后重新安排付款。",
"payment_batch.settled_transaction_required": "尚未找到已结算的交易,请确认结算记录后再登记付款。",
"payment_batch.receipt_security_failed": "付款回单未通过安全检查,请联系管理员核实并重新上传有效回单。",
"payment_batch.receipt_field_invalid": "请通过回单上传入口重新选择文件。",
"payment_batch.receipt_too_large": "回单文件超过大小限制,请压缩后重试。",
"payment_batch.receipt_too_many": "上传回单数量超过限制,请减少文件数量后重试。",
"payment_batch.receipt_upload_limit": "回单上传超过请求限制,请减少文件数量或大小后重试。",
"payment_batch.receipt_file_invalid": "回单文件不符合上传要求,请核对格式、大小和文件内容后重新上传。",
"payment_batch.receipt_required": "请先选择有效的付款回单文件。",
"payment_batch.travel_payment_frozen": "差旅付款组批已暂停,请联系财务确认处理方式。",
"payment_batch.account_reveal_unavailable": "当前批次不支持查看收款账户,请核实批次类型和状态。",
"payment_batch.payee_snapshot_missing": "历史批次缺少收款资料快照,请联系管理员核实,不要据此重复付款。",
"payment_batch.receipt_not_editable": "当前付款状态不允许修改回单,请刷新并确认批次进度。",
"payment_batch.receipt_voided": "该回单已作废,请选择有效回单。",
"payment_batch.download_reason_required": "下载该回单需要填写用途,请补充下载原因。",
"payment_batch.receipt_void_required": "回单不能直接删除,请使用作废操作并说明原因。",
"payment_batch.receipt_already_voided": "该回单已经作废,请刷新列表,勿重复操作。",
"payment_batch.metadata_not_editable": "当前批次状态不允许修改付款信息,请刷新并核实进度。",
"payment_batch.item_not_editable": "只能修改尚未完成的付款明细,请核实该明细状态。",
"payment_batch.batch_not_reviewable": "当前批次状态不允许复核,请刷新并确认是否已处理。",
"payment_batch.items_not_pending": "部分明细已处理,不能复核该批次,请刷新并核实明细状态。",
"payment_batch.maker_required": "该批次缺少制单人信息,请联系管理员核实后再复核。",
"payment_batch.maker_checker_conflict": "制单人不能复核自己的付款批次,请由另一位有权限的经理或负责人复核。",
"payment_batch.risk_resolution_required": "还有未处理的风险提示,请逐项确认并处理后再复核。",
"payment_batch.batch_not_executable": "只有已复核或待重试的批次才能登记付款,请先确认批次状态。",
"payment_batch.review_required": "该批次尚未完成有效复核,请先由独立复核人完成复核。",
"payment_batch.batch_not_cancellable": "该批次当前不能取消,请核实是否已有付款结果并联系财务处理。",
"leave.invalid_uuid": "记录标识已失效,请刷新页面后重新选择。",
"leave.invalid_input": "提交内容不完整或格式不正确,请核对必填项和日期后重试。",
"leave.date_from_invalid": "开始日期不正确,请重新选择。",
"leave.date_to_invalid": "结束日期不正确,请重新选择。",
"leave.date_to_must_be_after_date_from": "结束日期不能早于开始日期,请调整日期范围。",
"leave.department_id_scope_all_only": "当前查询范围不支持指定部门,请调整查询范围。",
"leave.date_invalid": "日期格式不正确,请重新选择日期。",
"leave.file_only_single_file_supported": "每次只能上传一个证明文件,请重新选择。",
"leave.file_file_too_large": "文件超过上传大小限制,请压缩或更换文件后重试。",
"leave.leave_type_id_missing_active_policy": "当前假种尚未配置有效策略,请联系人事维护后重试。",
"leave.file_required": "该假种需要证明材料,请上传后再提交。",
"leave.file_unsupported_mime_type": "文件类型不支持,请选择允许的证明材料格式。",
"leave.status_invalid_edit_status": "当前单据状态不允许编辑,请刷新并确认审批进度。",
"leave.status_invalid_submit_status": "当前单据状态不允许提交,请刷新并确认是否已提交。",
"leave.submit_already_in_review": "该申请已经在审批中,请勿重复提交,可查看审批进度。",
"leave.leave_conflict_range": "该时段已有请假申请,请调整日期或先处理已有申请。",
"leave.leave_insufficient_balance": "可用假期余额不足,请调整天数或联系人事核实。",
"leave.workflow_missing_definition": "尚未配置可用审批流程,请联系管理员维护后重试。",
"leave.workflow_no_actionable_approver": "当前没有可处理该申请的审批人,请联系管理员核实审批配置。",
"leave.status_invalid_withdraw_status": "当前单据状态不允许撤回,请刷新并确认审批进度。",
"leave.withdraw_no_pending_instance": "当前没有可撤回的审批,请刷新并确认审批进度。",
"leave.withdraw_already_processed_by_approver": "审批人已处理该申请,不能再撤回,请联系审批人确认后续处理。",
"leave.session_invalid": "请选择有效的上午或下午时段。",
"leave.leave_invalid_range": "请假结束时间早于开始时间,请调整请假时段。",
"leave.leave_non_working_range": "所选时段没有可请假的工作日,请调整请假日期。",
"leave.leave_missing_reserve_ledger": "请假额度预留记录缺失,请联系人事核实后处理。",
"leave.file_empty": "所选文件为空,请重新选择有效文件。",
"attendance_correction.invalid_input": "提交内容不完整或格式不正确,请核对必填项和日期后重试。",
"attendance_correction.invalid_uuid": "记录标识已失效,请刷新页面后重新选择。",
"attendance_correction.datetime_invalid": "时间格式不正确,请重新选择发生时间。",
"attendance_correction.feature_flag_attendance_unified_v1_disabled": "考勤修正暂未开放,请联系管理员确认处理方式。",
"attendance_correction.workflow_missing_definition": "尚未配置可用审批流程,请联系管理员维护后重试。",
"attendance_correction.workflow_no_actionable_approver": "当前没有可处理该申请的审批人,请联系管理员核实审批配置。",
"attendance_correction.status_invalid_cancel_status": "当前申请已不能取消,请刷新并确认审批结果。",
"payment_batch.legal_entity_required": "报销人尚未维护所属公司,请联系管理员补充后再安排付款。",
"payment_batch.multiple_legal_entities": "所选单据属于不同公司,请按公司分别创建付款安排。",
"payment_batch.payee_bank_required": "收款人尚未维护开户银行,请联系管理员核实真实开户行后补充。",
"payment_batch.payee_account_required": "收款人尚未维护银行卡,请联系管理员补充真实收款资料。",
"payment_batch.payee_bank_unsupported": "当前银行付款仅支持建行收款账户,请停止本次银行付款并联系财务确认处理方式,不要修改真实开户行。",
"payment_batch.payment_account_required": "所属公司尚无可用的建行付款账户,请联系账户管理员维护后重试。",
"payment_batch.payment_account_unavailable": "公司付款账户暂不可用,请联系管理员检查账户配置后重试。",
"payment_batch.active_batch_exists": "所选单据已进入其他付款安排,请刷新列表并查看已有批次。",
"payment_batch.reviewer_required": "当前没有可复核的经理或负责人,制单人不能复核自己的批次,请联系管理员安排独立复核人。"
}
@@ -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();
});
@@ -0,0 +1,66 @@
const fs = require("node:fs");
const path = require("node:path");
const ts = require("../../../../client/node_modules/typescript");
const expectedMessages = require("../fixtures/error-reason-messages.json");
const { getErrorReason } = require("../../../src/constants/errorReasons");
function loadClientModule(relativePath, cache = new Map()) {
const filename = path.resolve(__dirname, "../../../../client/src", relativePath);
if (cache.has(filename)) return cache.get(filename).exports;
const module = { exports: {} };
cache.set(filename, module);
const output = ts.transpileModule(fs.readFileSync(filename, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
}).outputText;
const localRequire = (specifier) => loadClientModule(path.relative(path.resolve(__dirname, "../../../../client/src"), path.resolve(path.dirname(filename), `${specifier}.ts`)), cache);
new Function("require", "module", "exports", output)(localRequire, module, module.exports);
return module.exports;
}
describe("client business error projection", () => {
const cache = new Map();
const { resolveErrorMessage } = loadClientModule("lib/resolveErrorMessage.ts", cache);
const { ApiError } = loadClientModule("api/types.ts", cache);
const { paymentItemFailureMessage, paymentRiskFlagsForDisplay } = loadClientModule("lib/paymentBatchDisplay.ts", cache);
const fallback = "操作未完成,请核对页面提示;如仍无法继续,请联系管理员协助处理。";
it.each(Object.entries(expectedMessages))("keeps exact registered and displayed content for %s", (reasonCode, expected) => {
expect(getErrorReason(reasonCode).defaultMessage).toBe(expected);
expect(resolveErrorMessage(new ApiError({ status: 409, code: "VALID_001", message: "private payload", details: { reasonCode } }))).toBe(expected);
});
it("shows exact actionable payment reason rather than arbitrary server text", () => {
expect(resolveErrorMessage(new ApiError({ status: 409, code: "VALID_001", message: "private payload", details: { reasonCode: "payment_batch.legal_entity_required" } })))
.toBe("报销人尚未维护所属公司,请联系管理员补充后再安排付款。");
});
it.each([undefined, "unknown.reason", "__proto__", "constructor"])("fails safely for unknown reason %s", (reasonCode) => {
expect(resolveErrorMessage(new ApiError({ status: 400, code: "VALID_001", message: "private payload", details: { reasonCode, internal: "/private/secret" } }))).toBe(fallback);
});
it("adds only a valid diagnostic identifier, never arbitrary request text", () => {
const error = new ApiError({ status: 409, code: "VALID_001", message: "参数校验失败", requestId: "req-test-1" });
expect(resolveErrorMessage(error)).toBe(`${fallback} 排障编号:req-test-1`);
error.requestId = "/private/secret";
expect(resolveErrorMessage(error)).toBe(fallback);
});
it("does not surface generic validation or internal identifiers from plain errors", () => {
expect(resolveErrorMessage(new Error("VALID_001: 参数校验失败"))).toBe(fallback);
expect(resolveErrorMessage(new Error("记录失败 a1111111-1111-4111-8111-111111111111"), "请刷新后重试。")).toBe("请刷新后重试。");
});
it("separates persisted failure text from item and risk-card UI projection", () => {
const item = { id: "item", status: "failed", failureCode: "VALID_001", failureMessage: "参数校验失败 /private/hidden", openRiskFlags: [] };
expect(paymentItemFailureMessage(item, "batch")).toBe(fallback);
const risk = { riskCode: "payment_batch_execute_failed", detail: "参数校验失败 /private/hidden", payloadJson: { batchId: "batch", itemId: "item", reasonCode: "payment_batch.source_status_invalid" } };
item.openRiskFlags = [risk];
const expected = "来源单据尚未满足付款条件或已经付款,请刷新并核实审批与付款状态。";
expect(paymentItemFailureMessage(item, "batch")).toBe(expected);
expect(paymentItemFailureMessage(item, "different-batch")).toBe(fallback);
expect(paymentRiskFlagsForDisplay([risk])[0].detail).toBe(expected);
expect(risk.detail).toBe("参数校验失败 /private/hidden");
risk.payloadJson.reasonCode = "unknown.reason";
expect(paymentRiskFlagsForDisplay([risk])[0].detail).toBe(fallback);
});
});
+2 -2
查看文件
@@ -47,7 +47,7 @@ describe("Leave Service", () => {
startSession: "pm",
endSession: "am",
}),
).toThrow(/参数校验失败/);
).toThrow(expect.objectContaining({ code: "VALID_001", statusCode: 422, details: { leave: "invalid_range", reasonCode: "leave.leave_invalid_range" } }));
});
});
@@ -95,7 +95,7 @@ describe("Leave Service", () => {
endSession: "pm",
calendarDays: [],
}),
).toThrow(/参数校验失败/);
).toThrow(expect.objectContaining({ code: "VALID_001", statusCode: 422, details: { leave: "non_working_range", reasonCode: "leave.leave_non_working_range" } }));
});
});
@@ -0,0 +1,65 @@
const { resolvePaymentBatchCreationReadiness, paymentReadinessError } = require("../../../src/services/paymentBatchReadiness");
function facts() {
return {
entityType: "reimbursement", entityIds: ["synthetic-item"], paymentChannel: "bank",
snapshots: [{ entityId: "synthetic-item", entityCode: "SYN-0014", status: "approved", departmentId: "department", legalEntityId: "company", snapshotJson: { payee: { bankInstitutionCode: "CCB", bankAccountEncrypted: "synthetic-present" } } }],
departmentReadiness: new Map([["department", { enabled: true }]]),
accountReadiness: new Map([["company", { paymentAccount: { id: "account" }, paymentAccountSnapshot: { available: true } }]]),
reservedEntityIds: new Set(), reviewerIds: ["independent-reviewer"],
};
}
describe("shared payment creation readiness", () => {
it("accepts a complete synthetic bank payment without success noise", () => {
expect(resolvePaymentBatchCreationReadiness(facts())).toEqual({ ready: true, blockers: [] });
});
it.each([
["source_not_found", (input) => { input.snapshots = []; }],
["source_status_invalid", (input) => { input.snapshots[0].status = "reviewing"; }],
["source_status_invalid", (input) => { input.snapshots[0].snapshotJson.paidAt = "synthetic-paid"; }],
["department_unavailable", (input) => { input.departmentReadiness.clear(); }],
["active_batch_exists", (input) => { input.reservedEntityIds.add("synthetic-item"); }],
["legal_entity_required", (input) => { input.snapshots[0].legalEntityId = null; }],
["payee_bank_required", (input) => { input.snapshots[0].snapshotJson.payee.bankInstitutionCode = null; }],
["payee_account_required", (input) => { input.snapshots[0].snapshotJson.payee.bankAccountEncrypted = null; }],
["payee_bank_unsupported", (input) => { input.snapshots[0].snapshotJson.payee.bankInstitutionCode = "OTHER"; }],
["payee_account_unavailable", (input) => { input.snapshots[0].snapshotJson.payee.bankAccountUnavailable = true; }],
["payment_account_required", (input) => { input.accountReadiness.clear(); }],
["payment_account_unavailable", (input) => { input.accountReadiness.get("company").paymentAccountSnapshot = null; }],
["reviewer_required", (input) => { input.reviewerIds = []; }],
])("returns precise safe blocker for %s", (reason, change) => {
const input = facts();
change(input);
const result = resolvePaymentBatchCreationReadiness(input);
expect(result.ready).toBe(false);
expect(result.blockers.map((blocker) => blocker.reasonCode)).toEqual([`payment_batch.${reason}`]);
for (const blocker of result.blockers) expect(Object.keys(blocker).every((key) => ["reasonCode", "entityId", "entityCode"].includes(key))).toBe(true);
const error = paymentReadinessError(result, input.departmentReadiness);
expect(error.code).toBe("VALID_001");
expect(error.statusCode).toBe(reason === "source_not_found" ? 404 : 409);
expect(error.details.reasonCode).toBe(`payment_batch.${reason}`);
});
it("recomputes all three synthetic 0014 omissions immediately after correction", () => {
const input = facts();
input.snapshots[0].legalEntityId = null;
input.snapshots[0].snapshotJson.payee = {};
expect(resolvePaymentBatchCreationReadiness(input).blockers.map((blocker) => blocker.reasonCode)).toEqual([
"payment_batch.legal_entity_required", "payment_batch.payee_bank_required", "payment_batch.payee_account_required",
]);
input.snapshots = facts().snapshots;
expect(resolvePaymentBatchCreationReadiness(input)).toEqual({ ready: true, blockers: [] });
});
it("does not infer combination readiness from individually valid rows", () => {
const input = facts();
input.snapshots.push({ ...input.snapshots[0], entityId: "second-item", legalEntityId: "second-company" });
input.accountReadiness.set("second-company", input.accountReadiness.get("company"));
expect(resolvePaymentBatchCreationReadiness({ ...input, entityIds: ["second-item"] }).ready).toBe(true);
expect(resolvePaymentBatchCreationReadiness(input).ready).toBe(true);
input.entityIds.push("second-item");
expect(resolvePaymentBatchCreationReadiness(input)).toEqual({ ready: false, blockers: [{ reasonCode: "payment_batch.multiple_legal_entities" }] });
});
});
@@ -0,0 +1,22 @@
const { ERROR_REASONS, getErrorReason } = require("../../../src/constants/errorReasons");
const { buildBusinessValidationError } = require("../../../src/utils/businessValidationError");
describe("business validation reason contract", () => {
it("preserves status, top-level code and legacy fields without allowing reason override", () => {
const error = buildBusinessValidationError("payment_batch.legal_entity_required", {
legalEntity: "single_legal_entity_required", reasonCode: "not_registered",
}, 409);
expect(error).toMatchObject({
code: "VALID_001", statusCode: 409, message: "参数校验失败",
details: { legalEntity: "single_legal_entity_required", reasonCode: "payment_batch.legal_entity_required" },
});
expect(getErrorReason(error.details.reasonCode).defaultMessage).toBe("报销人尚未维护所属公司,请联系管理员补充后再安排付款。");
});
it("rejects unknown reasons and protects registered metadata", () => {
expect(() => buildBusinessValidationError("payment_batch.typo")).toThrow(TypeError);
expect(new Set(ERROR_REASONS.map((reason) => reason.code)).size).toBe(ERROR_REASONS.length);
expect(Object.isFrozen(ERROR_REASONS)).toBe(true);
expect(ERROR_REASONS.every(Object.isFrozen)).toBe(true);
});
});