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 行删除
+155
查看文件
@@ -0,0 +1,155 @@
const ERROR_REASONS = [
{"code":"reimbursement.material_preview_limit","domain":"reimbursement","audience":"user","defaultMessage":"报销材料较多,暂时无法连续预览,请分批查看材料或联系管理员协助。","recovery":"view_individually"},
{"code":"purchase_attachment.unsupported_type","domain":"purchase_attachment","audience":"user","defaultMessage":"仅支持 PDF、JPG、PNG、WEBP 文件,请重新选择。","recovery":"choose_file"},
{"code":"reimbursement.invoice_amount_required","domain":"reimbursement","audience":"user","defaultMessage":"票据金额尚未确认,无法自动生成明细,请先核对票据金额或手动选择明细。","recovery":"follow_guidance"},
{"code":"reimbursement.expense_type_required","domain":"reimbursement","audience":"user","defaultMessage":"当前没有可用费用类型,请联系管理员维护后再添加票据。","recovery":"follow_guidance"},
{"code":"reimbursement.invoice_not_bound","domain":"reimbursement","audience":"user","defaultMessage":"该票据已不再关联业务单据,请刷新票据列表后确认。","recovery":"follow_guidance"},
{"code":"reimbursement.invoice_unbind_status_invalid","domain":"reimbursement","audience":"user","defaultMessage":"报销单当前不能移出票据,请先核实审批和付款状态。","recovery":"follow_guidance"},
{"code":"reimbursement.invoice_bind_status_invalid","domain":"reimbursement","audience":"user","defaultMessage":"报销单当前不能添加票据,请刷新并确认单据状态。","recovery":"follow_guidance"},
{"code":"reimbursement.invoice_owner_mismatch","domain":"reimbursement","audience":"user","defaultMessage":"票据所属人与报销人不一致,请选择本人对应的票据。","recovery":"follow_guidance"},
{"code":"reimbursement.invoice_item_required","domain":"reimbursement","audience":"user","defaultMessage":"请为该票据选择对应的报销明细后再添加。","recovery":"follow_guidance"},
{"code":"reimbursement.travel_invoice_bind_status_invalid","domain":"reimbursement","audience":"user","defaultMessage":"差旅订单当前不支持添加票据,请核实订单状态。","recovery":"follow_guidance"},
{"code":"invoice_verification.too_frequent","domain":"invoice_verification","audience":"user","defaultMessage":"验真触发过于频繁,请稍后重试。","recovery":"follow_guidance"},
{"code":"invoice_verification.attempts_exceeded","domain":"invoice_verification","audience":"user","defaultMessage":"验真重试次数已达上限,请联系财务人工核验。","recovery":"follow_guidance"},
{"code":"reimbursement.invalid_input","domain":"reimbursement","audience":"user","defaultMessage":"报销内容不完整或格式不正确,请核对页面必填项和日期后重试。","recovery":"follow_guidance"},
{"code":"reimbursement.invalid_uuid","domain":"reimbursement","audience":"user","defaultMessage":"记录标识已失效,请刷新页面后重新选择。","recovery":"follow_guidance"},
{"code":"reimbursement.body_empty","domain":"reimbursement","audience":"user","defaultMessage":"尚未填写修改内容,请补充后再保存。","recovery":"follow_guidance"},
{"code":"reimbursement.date_to_must_be_after_date_from","domain":"reimbursement","audience":"user","defaultMessage":"结束日期不能早于开始日期,请调整查询日期范围。","recovery":"follow_guidance"},
{"code":"reimbursement.amount_must_be_positive","domain":"reimbursement","audience":"user","defaultMessage":"明细金额必须大于 0。","recovery":"follow_guidance"},
{"code":"reimbursement.template","domain":"reimbursement","audience":"user","defaultMessage":"模板中的明细格式已不适用,请联系管理员修正模板后重试。","recovery":"follow_guidance"},
{"code":"reimbursement.owner_employee_id_invalid_owner","domain":"reimbursement","audience":"user","defaultMessage":"所选报销人不可用,请重新选择有效员工。","recovery":"follow_guidance"},
{"code":"reimbursement.total_amount_invalid","domain":"reimbursement","audience":"user","defaultMessage":"报销合计金额不正确,请核对各项明细金额。","recovery":"follow_guidance"},
{"code":"reimbursement.total_amount_negative_not_allowed","domain":"reimbursement","audience":"user","defaultMessage":"报销合计不能为负数,请核对各项明细金额。","recovery":"follow_guidance"},
{"code":"reimbursement.workflow_missing_approval_rule","domain":"reimbursement","audience":"user","defaultMessage":"尚未配置适用的报销审批规则,请联系管理员维护后重试。","recovery":"follow_guidance"},
{"code":"reimbursement.precheck","domain":"reimbursement","audience":"user","defaultMessage":"报销提交检查未通过,请按页面检查清单逐项处理后重试。","recovery":"follow_guidance"},
{"code":"reimbursement.template_id_invalid_template","domain":"reimbursement","audience":"user","defaultMessage":"所选报销模板已失效,请重新选择有效模板。","recovery":"follow_guidance"},
{"code":"reimbursement.template_id_contains_disabled_expense_type","domain":"reimbursement","audience":"user","defaultMessage":"模板包含已停用的费用类型,请联系管理员修正模板后重试。","recovery":"follow_guidance"},
{"code":"reimbursement.linked_travel_request_id_not_found","domain":"reimbursement","audience":"user","defaultMessage":"所选出差申请已不存在,请刷新后重新选择。","recovery":"follow_guidance"},
{"code":"reimbursement.linked_travel_request_id_not_approved","domain":"reimbursement","audience":"user","defaultMessage":"所选出差申请尚未获批,请先完成出差审批。","recovery":"follow_guidance"},
{"code":"reimbursement.linked_travel_request_id_owner_mismatch","domain":"reimbursement","audience":"user","defaultMessage":"出差申请与报销人不一致,请选择本人对应的出差申请。","recovery":"follow_guidance"},
{"code":"reimbursement.linked_travel_request_id_already_linked","domain":"reimbursement","audience":"user","defaultMessage":"该出差申请已关联其他报销单,请核对已有报销记录。","recovery":"follow_guidance"},
{"code":"reimbursement.linked_travel_request_id_missing_expense_type_mapping","domain":"reimbursement","audience":"user","defaultMessage":"该出差项目尚未配置对应费用类型,请联系管理员维护后重试。","recovery":"follow_guidance"},
{"code":"reimbursement.status_invalid_edit_status","domain":"reimbursement","audience":"user","defaultMessage":"当前报销单状态不允许编辑,请刷新并确认审批进度。","recovery":"follow_guidance"},
{"code":"reimbursement.travel_end_date_invalid_date_range","domain":"reimbursement","audience":"user","defaultMessage":"出差结束日期不能早于开始日期,请调整日期。","recovery":"follow_guidance"},
{"code":"reimbursement.status_invalid_delete_status","domain":"reimbursement","audience":"user","defaultMessage":"当前报销单状态不允许删除,请核实审批和付款进度。","recovery":"follow_guidance"},
{"code":"reimbursement.status_invalid_attachment_edit_status","domain":"reimbursement","audience":"user","defaultMessage":"当前报销单不能修改附件,请刷新并确认单据状态。","recovery":"follow_guidance"},
{"code":"reimbursement.file_unsupported_mime_type","domain":"reimbursement","audience":"user","defaultMessage":"附件类型不支持,请选择允许的图片或文档格式。","recovery":"follow_guidance"},
{"code":"reimbursement.file_file_too_large","domain":"reimbursement","audience":"user","defaultMessage":"附件超过大小限制,请压缩或更换文件后重试。","recovery":"follow_guidance"},
{"code":"reimbursement.file_empty_file","domain":"reimbursement","audience":"user","defaultMessage":"所选附件为空,请重新选择有效文件。","recovery":"follow_guidance"},
{"code":"reimbursement.file_too_many_files","domain":"reimbursement","audience":"user","defaultMessage":"一次最多上传 5 个附件,请减少数量后重试。","recovery":"follow_guidance"},
{"code":"reimbursement.file_missing","domain":"reimbursement","audience":"user","defaultMessage":"请先选择需要上传的附件。","recovery":"follow_guidance"},
{"code":"reimbursement.reason_required","domain":"reimbursement","audience":"user","defaultMessage":"读取该材料需要填写用途,请补充访问原因。","recovery":"follow_guidance"},
{"code":"reimbursement.status_invalid_item_edit_status","domain":"reimbursement","audience":"user","defaultMessage":"当前报销单不能修改明细,请刷新并确认单据状态。","recovery":"follow_guidance"},
{"code":"reimbursement.expense_type_id_invalid_or_disabled","domain":"reimbursement","audience":"user","defaultMessage":"所选费用类型已失效或停用,请重新选择。","recovery":"follow_guidance"},
{"code":"reimbursement.reimbursement_invalid_status_for_invoice_item_link","domain":"reimbursement","audience":"user","defaultMessage":"当前报销单状态已变化,不能再关联发票,请刷新页面。","recovery":"follow_guidance"},
{"code":"reimbursement.invoice_invalid_unlinked_item_state","domain":"reimbursement","audience":"user","defaultMessage":"这张发票的关联状态已变化,请刷新后重试。","recovery":"follow_guidance"},
{"code":"reimbursement.linked_item_id_item_not_in_reimbursement","domain":"reimbursement","audience":"user","defaultMessage":"所选明细已不存在或不属于本单,请刷新后重新选择。","recovery":"follow_guidance"},
{"code":"reimbursement.status_invalid_submit_status","domain":"reimbursement","audience":"user","defaultMessage":"当前报销单状态不允许提交,请刷新并确认是否已提交。","recovery":"follow_guidance"},
{"code":"reimbursement.items_at_least_one_required","domain":"reimbursement","audience":"user","defaultMessage":"请先添加至少 1 条报销明细。","recovery":"follow_guidance"},
{"code":"reimbursement.items_positive_amount_required","domain":"reimbursement","audience":"user","defaultMessage":"所有报销明细金额都必须大于 0,请修改后再提交。","recovery":"follow_guidance"},
{"code":"reimbursement.items_invoice_item_guard_failed","domain":"reimbursement","audience":"user","defaultMessage":"票据检查未通过,请核对每条明细与发票的关联和金额后重试。","recovery":"follow_guidance"},
{"code":"reimbursement.travel_summary_incomplete","domain":"reimbursement","audience":"user","defaultMessage":"出差汇总信息尚未填写完整,请补充页面提示的出差信息后提交。","recovery":"follow_guidance"},
{"code":"reimbursement.submit_already_in_review","domain":"reimbursement","audience":"user","defaultMessage":"该报销单已经在审批中,请勿重复提交,可查看审批进度。","recovery":"follow_guidance"},
{"code":"reimbursement.invoices_bound_invoice_required","domain":"reimbursement","audience":"user","defaultMessage":"报销单需要有效发票,请先添加并关联发票后提交。","recovery":"follow_guidance"},
{"code":"reimbursement.budget_exceeded_block_mode","domain":"reimbursement","audience":"user","defaultMessage":"本次报销超出可用预算,请调整金额或联系预算管理员处理。","recovery":"follow_guidance"},
{"code":"reimbursement.workflow_missing_definition","domain":"reimbursement","audience":"user","defaultMessage":"尚未配置可用报销审批流程,请联系管理员维护后重试。","recovery":"follow_guidance"},
{"code":"reimbursement.workflow_no_actionable_approver","domain":"reimbursement","audience":"user","defaultMessage":"当前没有可处理该报销的审批人,请联系管理员核实审批配置。","recovery":"follow_guidance"},
{"code":"reimbursement.status_invalid_withdraw_status","domain":"reimbursement","audience":"user","defaultMessage":"当前报销单不能撤回,请刷新并核实审批进度。","recovery":"follow_guidance"},
{"code":"reimbursement.withdraw_no_pending_instance","domain":"reimbursement","audience":"user","defaultMessage":"当前报销单没有可撤回的审批,请刷新并核实进度。","recovery":"follow_guidance"},
{"code":"reimbursement.withdraw_already_processed_by_approver","domain":"reimbursement","audience":"user","defaultMessage":"审批人已处理该报销单,不能撤回,请联系审批人确认后续处理。","recovery":"follow_guidance"},
{"code":"reimbursement.feature_flag_project_linking_v1_disabled","domain":"reimbursement","audience":"user","defaultMessage":"项目关联暂未开放,请联系管理员确认处理方式。","recovery":"follow_guidance"},
{"code":"reimbursement.reimbursement_invalid_status_for_project_link","domain":"reimbursement","audience":"user","defaultMessage":"当前报销单不能修改关联项目,请刷新并确认单据状态。","recovery":"follow_guidance"},
{"code":"reimbursement.payment_payment_batch_v1_required","domain":"reimbursement","audience":"user","defaultMessage":"该报销单须通过付款批次安排付款,请前往付款批次查看或创建付款安排。","recovery":"follow_guidance"},
{"code":"reimbursement.status_invalid_pay_status","domain":"reimbursement","audience":"user","defaultMessage":"当前报销单尚不满足付款条件,请核实审批和已有付款记录。","recovery":"follow_guidance"},
{"code":"reimbursement.payment_already_paid_conflict","domain":"reimbursement","audience":"user","defaultMessage":"该报销单已有不同的付款记录,请核实原付款,勿重复登记。","recovery":"follow_guidance"},
{"code":"payment_batch.invalid_identifier","domain":"payment_batch","audience":"user","defaultMessage":"付款记录标识已失效,请刷新后重新选择。","recovery":"follow_guidance"},
{"code":"payment_batch.invalid_input","domain":"payment_batch","audience":"user","defaultMessage":"付款操作内容不完整或格式不正确,请核对页面必填项后重试。","recovery":"follow_guidance"},
{"code":"payment_batch.bank_receipt_required","domain":"payment_batch","audience":"user","defaultMessage":"银行付款登记前必须上传有效银行回单,请先补充回单。","recovery":"follow_guidance"},
{"code":"payment_batch.department_unavailable","domain":"payment_batch","audience":"user","defaultMessage":"当前单据所属部门暂不能使用付款批次,请联系管理员确认处理方式。","recovery":"follow_guidance"},
{"code":"payment_batch.source_not_found","domain":"payment_batch","audience":"user","defaultMessage":"部分来源单据已不存在或不可用,请刷新后重新选择。","recovery":"follow_guidance"},
{"code":"payment_batch.daily_sequence_exhausted","domain":"payment_batch","audience":"user","defaultMessage":"今日付款批次编号已用完,请联系管理员处理,勿重复提交。","recovery":"follow_guidance"},
{"code":"payment_batch.payee_account_unavailable","domain":"payment_batch","audience":"user","defaultMessage":"收款账户资料暂不可用,请联系管理员检查后重试。","recovery":"follow_guidance"},
{"code":"payment_batch.source_status_invalid","domain":"payment_batch","audience":"user","defaultMessage":"来源单据尚未满足付款条件或已经付款,请刷新并核实审批与付款状态。","recovery":"follow_guidance"},
{"code":"payment_batch.already_paid_conflict","domain":"payment_batch","audience":"user","defaultMessage":"来源单据已有不同的付款记录,请核实已有记录,勿重复付款。","recovery":"follow_guidance"},
{"code":"payment_batch.source_amount_changed","domain":"payment_batch","audience":"user","defaultMessage":"来源单据金额已变化,请核对单据后重新安排付款。","recovery":"follow_guidance"},
{"code":"payment_batch.settled_transaction_required","domain":"payment_batch","audience":"user","defaultMessage":"尚未找到已结算的交易,请确认结算记录后再登记付款。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_security_failed","domain":"payment_batch","audience":"user","defaultMessage":"付款回单未通过安全检查,请联系管理员核实并重新上传有效回单。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_field_invalid","domain":"payment_batch","audience":"user","defaultMessage":"请通过回单上传入口重新选择文件。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_too_large","domain":"payment_batch","audience":"user","defaultMessage":"回单文件超过大小限制,请压缩后重试。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_too_many","domain":"payment_batch","audience":"user","defaultMessage":"上传回单数量超过限制,请减少文件数量后重试。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_upload_limit","domain":"payment_batch","audience":"user","defaultMessage":"回单上传超过请求限制,请减少文件数量或大小后重试。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_file_invalid","domain":"payment_batch","audience":"user","defaultMessage":"回单文件不符合上传要求,请核对格式、大小和文件内容后重新上传。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_required","domain":"payment_batch","audience":"user","defaultMessage":"请先选择有效的付款回单文件。","recovery":"follow_guidance"},
{"code":"payment_batch.travel_payment_frozen","domain":"payment_batch","audience":"user","defaultMessage":"差旅付款组批已暂停,请联系财务确认处理方式。","recovery":"follow_guidance"},
{"code":"payment_batch.account_reveal_unavailable","domain":"payment_batch","audience":"user","defaultMessage":"当前批次不支持查看收款账户,请核实批次类型和状态。","recovery":"follow_guidance"},
{"code":"payment_batch.payee_snapshot_missing","domain":"payment_batch","audience":"user","defaultMessage":"历史批次缺少收款资料快照,请联系管理员核实,不要据此重复付款。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_not_editable","domain":"payment_batch","audience":"user","defaultMessage":"当前付款状态不允许修改回单,请刷新并确认批次进度。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_voided","domain":"payment_batch","audience":"user","defaultMessage":"该回单已作废,请选择有效回单。","recovery":"follow_guidance"},
{"code":"payment_batch.download_reason_required","domain":"payment_batch","audience":"user","defaultMessage":"下载该回单需要填写用途,请补充下载原因。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_void_required","domain":"payment_batch","audience":"user","defaultMessage":"回单不能直接删除,请使用作废操作并说明原因。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_already_voided","domain":"payment_batch","audience":"user","defaultMessage":"该回单已经作废,请刷新列表,勿重复操作。","recovery":"follow_guidance"},
{"code":"payment_batch.metadata_not_editable","domain":"payment_batch","audience":"user","defaultMessage":"当前批次状态不允许修改付款信息,请刷新并核实进度。","recovery":"follow_guidance"},
{"code":"payment_batch.item_not_editable","domain":"payment_batch","audience":"user","defaultMessage":"只能修改尚未完成的付款明细,请核实该明细状态。","recovery":"follow_guidance"},
{"code":"payment_batch.batch_not_reviewable","domain":"payment_batch","audience":"user","defaultMessage":"当前批次状态不允许复核,请刷新并确认是否已处理。","recovery":"follow_guidance"},
{"code":"payment_batch.items_not_pending","domain":"payment_batch","audience":"user","defaultMessage":"部分明细已处理,不能复核该批次,请刷新并核实明细状态。","recovery":"follow_guidance"},
{"code":"payment_batch.maker_required","domain":"payment_batch","audience":"user","defaultMessage":"该批次缺少制单人信息,请联系管理员核实后再复核。","recovery":"follow_guidance"},
{"code":"payment_batch.maker_checker_conflict","domain":"payment_batch","audience":"user","defaultMessage":"制单人不能复核自己的付款批次,请由另一位有权限的经理或负责人复核。","recovery":"follow_guidance"},
{"code":"payment_batch.risk_resolution_required","domain":"payment_batch","audience":"user","defaultMessage":"还有未处理的风险提示,请逐项确认并处理后再复核。","recovery":"follow_guidance"},
{"code":"payment_batch.batch_not_executable","domain":"payment_batch","audience":"user","defaultMessage":"只有已复核或待重试的批次才能登记付款,请先确认批次状态。","recovery":"follow_guidance"},
{"code":"payment_batch.review_required","domain":"payment_batch","audience":"user","defaultMessage":"该批次尚未完成有效复核,请先由独立复核人完成复核。","recovery":"follow_guidance"},
{"code":"payment_batch.batch_not_cancellable","domain":"payment_batch","audience":"user","defaultMessage":"该批次当前不能取消,请核实是否已有付款结果并联系财务处理。","recovery":"follow_guidance"},
{"code":"leave.invalid_uuid","domain":"leave","audience":"user","defaultMessage":"记录标识已失效,请刷新页面后重新选择。","recovery":"follow_guidance"},
{"code":"leave.invalid_input","domain":"leave","audience":"user","defaultMessage":"提交内容不完整或格式不正确,请核对必填项和日期后重试。","recovery":"follow_guidance"},
{"code":"leave.date_from_invalid","domain":"leave","audience":"user","defaultMessage":"开始日期不正确,请重新选择。","recovery":"follow_guidance"},
{"code":"leave.date_to_invalid","domain":"leave","audience":"user","defaultMessage":"结束日期不正确,请重新选择。","recovery":"follow_guidance"},
{"code":"leave.date_to_must_be_after_date_from","domain":"leave","audience":"user","defaultMessage":"结束日期不能早于开始日期,请调整日期范围。","recovery":"follow_guidance"},
{"code":"leave.department_id_scope_all_only","domain":"leave","audience":"user","defaultMessage":"当前查询范围不支持指定部门,请调整查询范围。","recovery":"follow_guidance"},
{"code":"leave.date_invalid","domain":"leave","audience":"user","defaultMessage":"日期格式不正确,请重新选择日期。","recovery":"follow_guidance"},
{"code":"leave.file_only_single_file_supported","domain":"leave","audience":"user","defaultMessage":"每次只能上传一个证明文件,请重新选择。","recovery":"follow_guidance"},
{"code":"leave.file_file_too_large","domain":"leave","audience":"user","defaultMessage":"文件超过上传大小限制,请压缩或更换文件后重试。","recovery":"follow_guidance"},
{"code":"leave.leave_type_id_missing_active_policy","domain":"leave","audience":"user","defaultMessage":"当前假种尚未配置有效策略,请联系人事维护后重试。","recovery":"follow_guidance"},
{"code":"leave.file_required","domain":"leave","audience":"user","defaultMessage":"该假种需要证明材料,请上传后再提交。","recovery":"follow_guidance"},
{"code":"leave.file_unsupported_mime_type","domain":"leave","audience":"user","defaultMessage":"文件类型不支持,请选择允许的证明材料格式。","recovery":"follow_guidance"},
{"code":"leave.status_invalid_edit_status","domain":"leave","audience":"user","defaultMessage":"当前单据状态不允许编辑,请刷新并确认审批进度。","recovery":"follow_guidance"},
{"code":"leave.status_invalid_submit_status","domain":"leave","audience":"user","defaultMessage":"当前单据状态不允许提交,请刷新并确认是否已提交。","recovery":"follow_guidance"},
{"code":"leave.submit_already_in_review","domain":"leave","audience":"user","defaultMessage":"该申请已经在审批中,请勿重复提交,可查看审批进度。","recovery":"follow_guidance"},
{"code":"leave.leave_conflict_range","domain":"leave","audience":"user","defaultMessage":"该时段已有请假申请,请调整日期或先处理已有申请。","recovery":"follow_guidance"},
{"code":"leave.leave_insufficient_balance","domain":"leave","audience":"user","defaultMessage":"可用假期余额不足,请调整天数或联系人事核实。","recovery":"follow_guidance"},
{"code":"leave.workflow_missing_definition","domain":"leave","audience":"user","defaultMessage":"尚未配置可用审批流程,请联系管理员维护后重试。","recovery":"follow_guidance"},
{"code":"leave.workflow_no_actionable_approver","domain":"leave","audience":"user","defaultMessage":"当前没有可处理该申请的审批人,请联系管理员核实审批配置。","recovery":"follow_guidance"},
{"code":"leave.status_invalid_withdraw_status","domain":"leave","audience":"user","defaultMessage":"当前单据状态不允许撤回,请刷新并确认审批进度。","recovery":"follow_guidance"},
{"code":"leave.withdraw_no_pending_instance","domain":"leave","audience":"user","defaultMessage":"当前没有可撤回的审批,请刷新并确认审批进度。","recovery":"follow_guidance"},
{"code":"leave.withdraw_already_processed_by_approver","domain":"leave","audience":"user","defaultMessage":"审批人已处理该申请,不能再撤回,请联系审批人确认后续处理。","recovery":"follow_guidance"},
{"code":"leave.session_invalid","domain":"leave","audience":"user","defaultMessage":"请选择有效的上午或下午时段。","recovery":"follow_guidance"},
{"code":"leave.leave_invalid_range","domain":"leave","audience":"user","defaultMessage":"请假结束时间早于开始时间,请调整请假时段。","recovery":"follow_guidance"},
{"code":"leave.leave_non_working_range","domain":"leave","audience":"user","defaultMessage":"所选时段没有可请假的工作日,请调整请假日期。","recovery":"follow_guidance"},
{"code":"leave.leave_missing_reserve_ledger","domain":"leave","audience":"user","defaultMessage":"请假额度预留记录缺失,请联系人事核实后处理。","recovery":"follow_guidance"},
{"code":"leave.file_empty","domain":"leave","audience":"user","defaultMessage":"所选文件为空,请重新选择有效文件。","recovery":"follow_guidance"},
{"code":"attendance_correction.invalid_input","domain":"attendance_correction","audience":"user","defaultMessage":"提交内容不完整或格式不正确,请核对必填项和日期后重试。","recovery":"follow_guidance"},
{"code":"attendance_correction.invalid_uuid","domain":"attendance_correction","audience":"user","defaultMessage":"记录标识已失效,请刷新页面后重新选择。","recovery":"follow_guidance"},
{"code":"attendance_correction.datetime_invalid","domain":"attendance_correction","audience":"user","defaultMessage":"时间格式不正确,请重新选择发生时间。","recovery":"follow_guidance"},
{"code":"attendance_correction.feature_flag_attendance_unified_v1_disabled","domain":"attendance_correction","audience":"user","defaultMessage":"考勤修正暂未开放,请联系管理员确认处理方式。","recovery":"follow_guidance"},
{"code":"attendance_correction.workflow_missing_definition","domain":"attendance_correction","audience":"user","defaultMessage":"尚未配置可用审批流程,请联系管理员维护后重试。","recovery":"follow_guidance"},
{"code":"attendance_correction.workflow_no_actionable_approver","domain":"attendance_correction","audience":"user","defaultMessage":"当前没有可处理该申请的审批人,请联系管理员核实审批配置。","recovery":"follow_guidance"},
{"code":"attendance_correction.status_invalid_cancel_status","domain":"attendance_correction","audience":"user","defaultMessage":"当前申请已不能取消,请刷新并确认审批结果。","recovery":"follow_guidance"},
{ code: "payment_batch.legal_entity_required", domain: "payment_batch", audience: "user", defaultMessage: "报销人尚未维护所属公司,请联系管理员补充后再安排付款。", recovery: "contact_admin" },
{ code: "payment_batch.multiple_legal_entities", domain: "payment_batch", audience: "user", defaultMessage: "所选单据属于不同公司,请按公司分别创建付款安排。", recovery: "change_selection" },
{ code: "payment_batch.payee_bank_required", domain: "payment_batch", audience: "user", defaultMessage: "收款人尚未维护开户银行,请联系管理员核实真实开户行后补充。", recovery: "contact_admin" },
{ code: "payment_batch.payee_account_required", domain: "payment_batch", audience: "user", defaultMessage: "收款人尚未维护银行卡,请联系管理员补充真实收款资料。", recovery: "contact_admin" },
{ code: "payment_batch.payee_bank_unsupported", domain: "payment_batch", audience: "user", defaultMessage: "当前银行付款仅支持建行收款账户,请停止本次银行付款并联系财务确认处理方式,不要修改真实开户行。", recovery: "contact_finance" },
{ code: "payment_batch.payment_account_required", domain: "payment_batch", audience: "user", defaultMessage: "所属公司尚无可用的建行付款账户,请联系账户管理员维护后重试。", recovery: "contact_admin" },
{ code: "payment_batch.payment_account_unavailable", domain: "payment_batch", audience: "user", defaultMessage: "公司付款账户暂不可用,请联系管理员检查账户配置后重试。", recovery: "contact_admin" },
{ code: "payment_batch.active_batch_exists", domain: "payment_batch", audience: "user", defaultMessage: "所选单据已进入其他付款安排,请刷新列表并查看已有批次。", recovery: "refresh" },
{ code: "payment_batch.reviewer_required", domain: "payment_batch", audience: "user", defaultMessage: "当前没有可复核的经理或负责人,制单人不能复核自己的批次,请联系管理员安排独立复核人。", recovery: "contact_admin" },
];
const reasonsByCode = new Map(ERROR_REASONS.map((reason) => [reason.code, Object.freeze(reason)]));
Object.freeze(ERROR_REASONS);
function getErrorReason(code) {
const reason = reasonsByCode.get(code);
if (!reason) throw new TypeError("Unregistered business error reason");
return reason;
}
module.exports = { ERROR_REASONS, getErrorReason };
+9 -18
查看文件
@@ -1,3 +1,4 @@
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { z } = require("zod");
const { hasPermission } = require("../constants/permissions");
@@ -60,19 +61,12 @@ const LIST_QUERY_SCHEMA = z.object({
pageSize: z.coerce.number().int().min(1).max(100).optional(),
});
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function parseInput(schema, value) {
const parsed = schema.safeParse(value ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
throw buildBusinessValidationError("attendance_correction.invalid_input", parsed.error.flatten(), 400);
}
return parsed.data;
}
@@ -80,7 +74,7 @@ function parseInput(schema, value) {
function parseUuid(value, key = "id") {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) {
throw buildValidationError({ [key]: "invalid_uuid" });
throw buildBusinessValidationError("attendance_correction.invalid_uuid", { [key]: "invalid_uuid" }, 400);
}
return parsed.data;
}
@@ -93,7 +87,7 @@ function parseOptionalDateTime(value) {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw buildValidationError({ datetime: "invalid" });
throw buildBusinessValidationError("attendance_correction.datetime_invalid", { datetime: "invalid" }, 400);
}
return date;
}
@@ -119,10 +113,7 @@ async function ensureUnifiedFeatureEnabled(fastify, request) {
strictDepartment: false,
});
if (!gate.enabled) {
throw buildValidationError(
{ featureFlag: "attendance_unified_v1_disabled", reason: gate.reason },
409,
);
throw buildBusinessValidationError("attendance_correction.feature_flag_attendance_unified_v1_disabled", { featureFlag: "attendance_unified_v1_disabled", reason: gate.reason }, 409);
}
}
@@ -289,7 +280,7 @@ async function attendanceCorrectionsRoutes(fastify) {
select: { id: true, definition_json: true },
});
if (!workflowDefinition) {
throw buildValidationError({ workflow: "missing_definition" }, 409);
throw buildBusinessValidationError("attendance_correction.workflow_missing_definition", { workflow: "missing_definition" }, 409);
}
const workflowSteps = parseWorkflowSteps(workflowDefinition.definition_json);
const { actionable, skipped } = await resolveActionableStep({
@@ -301,7 +292,7 @@ async function attendanceCorrectionsRoutes(fastify) {
blockOnUnresolvedAssignee: true,
});
if (!actionable) {
throw buildValidationError({ workflow: "no_actionable_approver" }, 409);
throw buildBusinessValidationError("attendance_correction.workflow_no_actionable_approver", { workflow: "no_actionable_approver" }, 409);
}
const workflowInstance = await tx.workflowInstance.create({
@@ -512,7 +503,7 @@ async function attendanceCorrectionsRoutes(fastify) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
if (correction.status !== "reviewing") {
throw buildValidationError({ status: "invalid_cancel_status" }, 409);
throw buildBusinessValidationError("attendance_correction.status_invalid_cancel_status", { status: "invalid_cancel_status" }, 409);
}
const workflowInstance = await tx.workflowInstance.findFirst({
+13 -72
查看文件
@@ -1,3 +1,4 @@
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const crypto = require("node:crypto");
const path = require("node:path");
@@ -273,12 +274,7 @@ async function resolveInvoiceLinkedItem(tx, { reimbursementId, reimbursementDate
select: { id: true },
});
if (!linkedItem) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { linkedItemId: "item_not_in_reimbursement" },
});
throw buildBusinessValidationError("reimbursement.linked_item_id_item_not_in_reimbursement", { linkedItemId: "item_not_in_reimbursement" }, 400);
}
return { linkedItemId: linkedItem.id, createdItem: false };
}
@@ -305,22 +301,12 @@ async function resolveInvoiceLinkedItem(tx, { reimbursementId, reimbursementDate
}
if (invoice.total_amount == null) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { invoice: "missing_total_amount_for_auto_item" },
});
throw buildBusinessValidationError("reimbursement.invoice_amount_required", { invoice: "missing_total_amount_for_auto_item" }, 409);
}
const expenseType = await resolveDefaultExpenseType(tx);
if (!expenseType) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { expenseTypeId: "missing_enabled_expense_type" },
});
throw buildBusinessValidationError("reimbursement.expense_type_required", { expenseTypeId: "missing_enabled_expense_type" }, 409);
}
const expenseDate =
@@ -1023,12 +1009,7 @@ async function invoicePoolRoutes(fastify) {
async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
throw buildBusinessValidationError("reimbursement.invalid_uuid", { id: "invalid_uuid" }, 400);
}
const invoiceId = parsedInvoiceId.data;
@@ -1052,12 +1033,7 @@ async function invoicePoolRoutes(fastify) {
const invoice = invoiceRows[0];
if (invoice.status !== "bound" || (!invoice.linked_reimbursement_id && !invoice.travel_order_id)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { invoice: "not_bound" },
});
throw buildBusinessValidationError("reimbursement.invoice_not_bound", { invoice: "not_bound" }, 409);
}
if (invoice.linked_reimbursement_id) {
@@ -1089,12 +1065,7 @@ async function invoicePoolRoutes(fastify) {
}
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { reimbursement: "invalid_status_for_unbind" },
});
throw buildBusinessValidationError("reimbursement.invoice_unbind_status_invalid", { reimbursement: "invalid_status_for_unbind" }, 409);
}
} else {
if (!fastify.hasPermission(request.employee, "travel.payment.manage")) {
@@ -1457,22 +1428,12 @@ async function invoicePoolRoutes(fastify) {
async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
throw buildBusinessValidationError("reimbursement.invalid_uuid", { id: "invalid_uuid" }, 400);
}
const parsedBody = bindBodySchema.safeParse(request.body ?? {});
if (!parsedBody.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsedBody.error.flatten(),
});
throw buildBusinessValidationError("reimbursement.invalid_input", parsedBody.error.flatten(), 400);
}
const invoiceId = parsedInvoiceId.data;
@@ -1600,21 +1561,11 @@ async function invoicePoolRoutes(fastify) {
}
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { reimbursement: "invalid_status_for_bind" },
});
throw buildBusinessValidationError("reimbursement.invoice_bind_status_invalid", { reimbursement: "invalid_status_for_bind" }, 409);
}
if (invoice.owner_employee_id !== reimbursement.owner_id) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { invoice: "owner_mismatch" },
});
throw buildBusinessValidationError("reimbursement.invoice_owner_mismatch", { invoice: "owner_mismatch" }, 409);
}
const invoiceGuardGate = await evaluateFeatureFlag({
@@ -1625,12 +1576,7 @@ async function invoicePoolRoutes(fastify) {
strictDepartment: true,
});
if (invoiceGuardGate.enabled && !linkedItemId) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { linkedItemId: "required_when_invoice_guard_enabled" },
});
throw buildBusinessValidationError("reimbursement.invoice_item_required", { linkedItemId: "required_when_invoice_guard_enabled" }, 400);
}
const resolvedLink = await resolveInvoiceLinkedItem(tx, {
@@ -1683,12 +1629,7 @@ async function invoicePoolRoutes(fastify) {
const travelOrder = travelOrderRows[0];
if (!["confirmed", "paid"].includes(travelOrder.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { travelOrder: "invalid_status_for_bind" },
});
throw buildBusinessValidationError("reimbursement.travel_invoice_bind_status_invalid", { travelOrder: "invalid_status_for_bind" }, 409);
}
await tx.invoicePool.update({
+4 -18
查看文件
@@ -1,3 +1,4 @@
const { buildBusinessValidationError } = require("../../utils/businessValidationError");
const { z } = require("zod");
const { AppError } = require("../../utils/AppError");
@@ -199,12 +200,7 @@ function registerInvoiceVerificationRoutes(fastify) {
const cooldownMs =
Math.max(1, Number(fastify.config.verificationRetryCooldownSeconds ?? 60)) * 1000;
if (diffMs < cooldownMs) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "too_frequent" },
});
throw buildBusinessValidationError("invoice_verification.too_frequent", { verification: "too_frequent" }, 409);
}
}
@@ -447,22 +443,12 @@ function registerInvoiceVerificationRoutes(fastify) {
Math.max(1, Number(fastify.config.verificationRetryCooldownSeconds ?? 60)) * 1000;
const diffMs = Date.now() - new Date(latest.requested_at).getTime();
if (diffMs < cooldownMs) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "too_frequent" },
});
throw buildBusinessValidationError("invoice_verification.too_frequent", { verification: "too_frequent" }, 409);
}
const attempts = Number(latest.attempts ?? 1) + 1;
if (attempts > Number(fastify.config.verificationMaxAttempts ?? 3)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "attempts_exceeded" },
});
throw buildBusinessValidationError("invoice_verification.attempts_exceeded", { verification: "attempts_exceeded" }, 409);
}
const createdRecord = await tx.invoiceVerificationRecord.create({
+30 -39
查看文件
@@ -1,3 +1,4 @@
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
@@ -134,19 +135,12 @@ const LEAVE_WORKFLOW_SELECT = {
},
};
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function parseLeaveId(value) {
const parsed = z.string().uuid().safeParse(value);
if (!parsed.success) {
throw buildValidationError({ id: "invalid_uuid" });
throw buildBusinessValidationError("leave.invalid_uuid", { id: "invalid_uuid" }, 400);
}
return parsed.data;
}
@@ -154,22 +148,22 @@ function parseLeaveId(value) {
function parseLeaveListQuery(query) {
const parsed = leaveListQuerySchema.safeParse(query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
throw buildBusinessValidationError("leave.invalid_input", parsed.error.flatten(), 400);
}
const dateFrom = parsed.data.dateFrom ? new Date(`${parsed.data.dateFrom}T00:00:00.000Z`) : null;
const dateTo = parsed.data.dateTo ? new Date(`${parsed.data.dateTo}T00:00:00.000Z`) : null;
if (dateFrom && Number.isNaN(dateFrom.getTime())) {
throw buildValidationError({ dateFrom: "invalid" });
throw buildBusinessValidationError("leave.date_from_invalid", { dateFrom: "invalid" }, 400);
}
if (dateTo && Number.isNaN(dateTo.getTime())) {
throw buildValidationError({ dateTo: "invalid" });
throw buildBusinessValidationError("leave.date_to_invalid", { dateTo: "invalid" }, 400);
}
if (dateFrom && dateTo && dateFrom > dateTo) {
throw buildValidationError({ dateTo: "must_be_after_dateFrom" });
throw buildBusinessValidationError("leave.date_to_must_be_after_date_from", { dateTo: "must_be_after_dateFrom" }, 400);
}
if (parsed.data.departmentId && parsed.data.scope !== "all") {
throw buildValidationError({ departmentId: "scope_all_only" });
throw buildBusinessValidationError("leave.department_id_scope_all_only", { departmentId: "scope_all_only" }, 400);
}
return {
@@ -186,7 +180,7 @@ function parseLeaveListQuery(query) {
function parseLeaveBalanceQuery(query) {
const parsed = leaveBalanceQuerySchema.safeParse(query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
throw buildBusinessValidationError("leave.invalid_input", parsed.error.flatten(), 400);
}
return parsed.data;
@@ -195,7 +189,7 @@ function parseLeaveBalanceQuery(query) {
function parseWithdrawBody(body) {
const parsed = withdrawBodySchema.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
throw buildBusinessValidationError("leave.invalid_input", parsed.error.flatten(), 400);
}
return {
@@ -206,14 +200,14 @@ function parseWithdrawBody(body) {
function parseCreateBody(body) {
const parsed = createLeaveSchema.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
throw buildBusinessValidationError("leave.invalid_input", parsed.error.flatten(), 400);
}
const startDate = new Date(`${parsed.data.startDate}T00:00:00.000Z`);
const endDate = new Date(`${parsed.data.endDate}T00:00:00.000Z`);
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
throw buildValidationError({ date: "invalid" });
throw buildBusinessValidationError("leave.date_invalid", { date: "invalid" }, 400);
}
const startSession = parsed.data.startSession ?? "am";
@@ -239,14 +233,14 @@ function parseCreateBody(body) {
function parseWorkingDaysPreviewQuery(query) {
const parsed = workingDaysPreviewQuerySchema.safeParse(query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
throw buildBusinessValidationError("leave.invalid_input", parsed.error.flatten(), 400);
}
const startDate = new Date(`${parsed.data.startDate}T00:00:00.000Z`);
const endDate = new Date(`${parsed.data.endDate}T00:00:00.000Z`);
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
throw buildValidationError({ date: "invalid" });
throw buildBusinessValidationError("leave.date_invalid", { date: "invalid" }, 400);
}
const requestedDays = calculateRequestedDays({
@@ -351,7 +345,7 @@ async function collectCreatePayload(request) {
for await (const part of request.parts()) {
if (part.type === "file") {
if (filePart) {
throw buildValidationError({ file: "only_single_file_supported" });
throw buildBusinessValidationError("leave.file_only_single_file_supported", { file: "only_single_file_supported" }, 400);
}
filePart = part;
} else {
@@ -360,7 +354,7 @@ async function collectCreatePayload(request) {
}
} catch (error) {
if (error?.code === "FST_REQ_FILE_TOO_LARGE") {
throw buildValidationError({ file: "file_too_large" });
throw buildBusinessValidationError("leave.file_file_too_large", { file: "file_too_large" }, 400);
}
throw error;
}
@@ -482,7 +476,7 @@ async function loadLeaveTypeOrThrow(prisma, leaveTypeId) {
});
if (!leaveType || !leaveType.enabled || !leaveType.leave_policy || !leaveType.leave_policy.enabled) {
throw buildValidationError({ leaveTypeId: "missing_active_policy" }, 409);
throw buildBusinessValidationError("leave.leave_type_id_missing_active_policy", { leaveTypeId: "missing_active_policy" }, 409);
}
return leaveType;
@@ -500,14 +494,14 @@ async function resolveLeaveTypeAttachment(
if (!filePart) {
if (requireWhenMissing && requiresAttachment) {
throw buildValidationError({ file: "required" }, 422);
throw buildBusinessValidationError("leave.file_required", { file: "required" }, 422);
}
return null;
}
const mimeAllowlist = new Set(allowedMimeTypes.length ? allowedMimeTypes : DEFAULT_ATTACHMENT_MIME_TYPES);
if (!mimeAllowlist.has(filePart.mimetype)) {
throw buildValidationError({ file: "unsupported_mime_type" });
throw buildBusinessValidationError("leave.file_unsupported_mime_type", { file: "unsupported_mime_type" }, 400);
}
return persistLeaveAttachment({
@@ -807,7 +801,7 @@ async function leaveRoutes(fastify) {
}
if (!["draft", "rejected"].includes(leave.status)) {
throw buildValidationError({ status: "invalid_edit_status" }, 409);
throw buildBusinessValidationError("leave.status_invalid_edit_status", { status: "invalid_edit_status" }, 409);
}
const leaveType = await loadLeaveTypeOrThrow(tx, body.leaveTypeId);
@@ -1007,7 +1001,7 @@ async function leaveRoutes(fastify) {
}
if (!["draft", "rejected"].includes(leave.status)) {
throw buildValidationError({ status: "invalid_submit_status" }, 409);
throw buildBusinessValidationError("leave.status_invalid_submit_status", { status: "invalid_submit_status" }, 409);
}
const activeInstance = await tx.workflowInstance.findFirst({
@@ -1019,12 +1013,12 @@ async function leaveRoutes(fastify) {
select: { id: true },
});
if (activeInstance) {
throw buildValidationError({ submit: "already_in_review" }, 409);
throw buildBusinessValidationError("leave.submit_already_in_review", { submit: "already_in_review" }, 409);
}
const leaveType = await loadLeaveTypeOrThrow(tx, leave.leave_type_id);
if (leaveType.requires_attachment && !leave.attachment_storage_path) {
throw buildValidationError({ file: "required" }, 422);
throw buildBusinessValidationError("leave.file_required", { file: "required" }, 422);
}
const overlappingRequests = await tx.leaveRequest.findMany({
@@ -1061,10 +1055,7 @@ async function leaveRoutes(fastify) {
),
);
if (conflicting) {
throw buildValidationError(
{ leave: "conflict_range", conflictLeaveRequestId: conflicting.id },
422,
);
throw buildBusinessValidationError("leave.leave_conflict_range", { leave: "conflict_range", conflictLeaveRequestId: conflicting.id }, 422);
}
await lockEmployeeForLeave(tx, leave.owner_id);
@@ -1078,7 +1069,7 @@ async function leaveRoutes(fastify) {
if (
toHalfDayUnits(balanceSnapshot.summary.available) < toHalfDayUnits(leave.requested_days)
) {
throw buildValidationError({ leave: "insufficient_balance" }, 422);
throw buildBusinessValidationError("leave.leave_insufficient_balance", { leave: "insufficient_balance" }, 422);
}
const workflowDefinition = await tx.workflowDefinition.findFirst({
@@ -1087,7 +1078,7 @@ async function leaveRoutes(fastify) {
select: { id: true },
});
if (!workflowDefinition) {
throw buildValidationError({ workflow: "missing_definition" });
throw buildBusinessValidationError("leave.workflow_missing_definition", { workflow: "missing_definition" }, 400);
}
const { actionable, skipped } = await resolveActionableStep({
@@ -1099,7 +1090,7 @@ async function leaveRoutes(fastify) {
blockOnUnresolvedAssignee: true,
});
if (!actionable) {
throw buildValidationError({ workflow: "no_actionable_approver" }, 409);
throw buildBusinessValidationError("leave.workflow_no_actionable_approver", { workflow: "no_actionable_approver" }, 409);
}
await tx.leaveRequest.update({
@@ -1244,7 +1235,7 @@ async function leaveRoutes(fastify) {
}
if (leave.status !== "reviewing") {
throw buildValidationError({ status: "invalid_withdraw_status" }, 409);
throw buildBusinessValidationError("leave.status_invalid_withdraw_status", { status: "invalid_withdraw_status" }, 409);
}
const activeInstance = await tx.workflowInstance.findFirst({
@@ -1258,7 +1249,7 @@ async function leaveRoutes(fastify) {
});
if (!activeInstance) {
throw buildValidationError({ withdraw: "no_pending_instance" }, 409);
throw buildBusinessValidationError("leave.withdraw_no_pending_instance", { withdraw: "no_pending_instance" }, 409);
}
const processedTaskCount = await tx.workflowTask.count({
@@ -1268,7 +1259,7 @@ async function leaveRoutes(fastify) {
},
});
if (processedTaskCount > 0) {
throw buildValidationError({ withdraw: "already_processed_by_approver" }, 409);
throw buildBusinessValidationError("leave.withdraw_already_processed_by_approver", { withdraw: "already_processed_by_approver" }, 409);
}
const ledger = await loadLeaveLedgerSummaryForRequest(tx, leaveId);
+151 -196
查看文件
@@ -1,3 +1,6 @@
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { ERROR_REASONS } = require("../constants/errorReasons");
const { resolvePaymentBatchCreationReadiness, paymentReadinessError } = require("../services/paymentBatchReadiness");
const crypto = require("node:crypto");
const path = require("node:path");
@@ -55,6 +58,7 @@ const LIST_QUERY_SCHEMA = z.object({
});
const CANDIDATES_QUERY_SCHEMA = z.object({
paymentChannel: PAYMENT_CHANNEL_SCHEMA.default("bank"),
q: z.string().trim().max(100).optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
@@ -91,19 +95,12 @@ const RECEIPT_FILE_QUERY_SCHEMA = z.object({
const HIGH_RISK_BATCH_THRESHOLD_AMOUNT = 50000;
const PAYMENT_BATCH_DAILY_SEQUENCE_MAX = 999;
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function parseId(value, key = "id") {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) {
throw buildValidationError({ [key]: "invalid_uuid" });
throw buildBusinessValidationError("payment_batch.invalid_identifier", { [key]: "invalid_uuid" }, 400);
}
return parsed.data;
}
@@ -111,7 +108,7 @@ function parseId(value, key = "id") {
function parseBody(schema, body) {
const parsed = schema.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
throw buildBusinessValidationError("payment_batch.invalid_input", parsed.error.flatten(), 400);
}
return parsed.data;
}
@@ -157,7 +154,7 @@ async function ensureBankReceiptForExecutableBatch(tx, batch) {
where: { payment_batch_id: batch.id, archived_at: null },
});
if (receiptCount <= 0) {
throw buildValidationError({ receiptAttachments: "bank_receipt_required" }, 409);
throw buildBusinessValidationError("payment_batch.bank_receipt_required", { receiptAttachments: "bank_receipt_required" }, 409);
}
}
@@ -170,7 +167,7 @@ async function ensureFeatureEnabled(fastify) {
});
if (!gate.enabled) {
throw buildValidationError({ featureFlag: "payment_batch_v1_disabled", reason: gate.reason }, 409);
throw buildBusinessValidationError("payment_batch.department_unavailable", { featureFlag: "payment_batch_v1_disabled", reason: gate.reason }, 409);
}
return gate;
@@ -200,7 +197,7 @@ async function evaluatePaymentBatchDepartments(fastify, prisma, departmentIds) {
async function ensurePaymentBatchDepartmentsEnabled(fastify, prisma, departmentIds) {
const result = await evaluatePaymentBatchDepartments(fastify, prisma, departmentIds);
if (!result.enabled) {
throw buildValidationError({
throw buildBusinessValidationError("payment_batch.department_unavailable", {
featureFlag: "payment_batch_v1_disabled",
reason: result.gate?.reason ?? "department_not_allowed",
}, 409);
@@ -221,7 +218,7 @@ async function loadBatchSourceDepartmentIds(tx, batch) {
select: { id: true, department_id: true },
});
if (sources.length !== entityIds.length) {
throw buildValidationError({ entityIds: "contains_not_found" }, 404);
throw buildBusinessValidationError("payment_batch.source_not_found", { entityIds: "contains_not_found" }, 404);
}
return sources.map((source) => source.department_id ?? null);
}
@@ -421,7 +418,7 @@ async function allocatePaymentBatchNumber(tx, timeZone) {
`;
const nextSequence = parsePaymentBatchDailySequence(rows?.[0]?.batch_no, compactDate) + 1;
if (nextSequence > PAYMENT_BATCH_DAILY_SEQUENCE_MAX) {
throw buildValidationError({ batchNo: "daily_sequence_exhausted" }, 409);
throw buildBusinessValidationError("payment_batch.daily_sequence_exhausted", { batchNo: "daily_sequence_exhausted" }, 409);
}
const sequence = String(nextSequence).padStart(3, "0");
@@ -520,15 +517,20 @@ function buildPayeeSnapshot(config, owner, options = {}) {
let bankAccount = null;
try {
bankAccount = encryptedAccount ? decryptStringOrThrow(config, encryptedAccount) : null;
if (encryptedAccount && !bankAccount) throw new Error("Payee bank account could not be decrypted");
} catch (error) {
if (options.forReadiness) {
return {
bankInstitutionCode: owner?.bank_institution?.code ?? null,
bankAccountEncrypted: encryptedAccount ?? null,
bankAccountUnavailable: true,
};
}
if (options.includeEncrypted) {
throw buildValidationError({ payeeBankAccount: "decrypt_failed" }, 409);
throw buildBusinessValidationError("payment_batch.payee_account_unavailable", { payeeBankAccount: "decrypt_failed" }, 409);
}
throw error;
}
if (encryptedAccount && !bankAccount) {
throw new Error("Payee bank account could not be decrypted");
}
return {
name: owner?.name ?? null,
bankInstitutionId: owner?.bank_institution?.id ?? null,
@@ -539,7 +541,7 @@ function buildPayeeSnapshot(config, owner, options = {}) {
};
}
async function loadEntitySnapshots(tx, entityType, entityIds, config) {
async function loadEntitySnapshots(tx, entityType, entityIds, config, options = {}) {
if (entityType === "reimbursement") {
const items = await tx.reimbursementForm.findMany({
where: { id: { in: entityIds } },
@@ -582,7 +584,7 @@ async function loadEntitySnapshots(tx, entityType, entityIds, config) {
paymentDate: item.payment_date?.toISOString?.()?.slice(0, 10) ?? null,
paymentChannel: item.payment_channel ?? null,
paidAt: item.paid_at?.toISOString?.() ?? null,
payee: buildPayeeSnapshot(config, item.owner, { includeEncrypted: true }),
payee: buildPayeeSnapshot(config, item.owner, { includeEncrypted: true, ...options }),
},
}));
}
@@ -620,19 +622,60 @@ async function loadEntitySnapshots(tx, entityType, entityIds, config) {
}));
}
function ensureBatchEntityStatus(entityType, snapshot) {
if (
entityType === "reimbursement" &&
(snapshot.status !== "approved" || snapshot.snapshotJson?.paidAt)
) {
throw buildValidationError({ entity: `${snapshot.entityCode}:invalid_pay_status` }, 409);
async function loadPaymentCreationFacts(fastify, tx, { entityType, entityIds, paymentChannel, makerId }) {
const snapshots = await loadEntitySnapshots(tx, entityType, entityIds, fastify.config, { forReadiness: true });
const departmentReadiness = new Map();
const flagReads = new Map();
const gatePrisma = { featureFlag: { findUnique(query) {
const key = query.where.flag_key;
if (!flagReads.has(key)) flagReads.set(key, tx.featureFlag.findUnique(query));
return flagReads.get(key);
} } };
for (const departmentId of new Set(snapshots.map((snapshot) => snapshot.departmentId ?? null))) {
departmentReadiness.set(departmentId, await evaluatePaymentBatchDepartments(fastify, gatePrisma, [departmentId]));
}
if (
entityType === "travel_order" &&
(snapshot.status !== "confirmed" || snapshot.snapshotJson?.paidAt)
) {
throw buildValidationError({ entity: `${snapshot.entityCode}:invalid_pay_status` }, 409);
const companyIds = [...new Set(snapshots.map((snapshot) => snapshot.legalEntityId).filter(Boolean))];
const accounts = paymentChannel === "bank" && companyIds.length ? await tx.paymentAccount.findMany({
where: {
legal_entity_id: { in: companyIds }, enabled: true, archived_at: null,
bank_institution: { code: "CCB", enabled: true, archived_at: null },
},
include: PAYMENT_ACCOUNT_INCLUDE,
orderBy: { id: "asc" },
}) : [];
const accountReadiness = new Map();
for (const paymentAccount of accounts) {
if (accountReadiness.has(paymentAccount.legal_entity_id)) continue;
let paymentAccountSnapshot = null;
try {
const accountNumber = decryptStringOrThrow(fastify.config, paymentAccount.account_number_encrypted);
if (accountNumber) {
paymentAccountSnapshot = {
paymentAccountId: paymentAccount.id,
legalEntityId: paymentAccount.legal_entity_id,
accountName: paymentAccount.account_name,
bankInstitutionId: paymentAccount.bank_institution.id,
bankInstitutionCode: paymentAccount.bank_institution.code,
bankName: paymentAccount.bank_institution.name,
accountNumberEncrypted: paymentAccount.account_number_encrypted,
accountNumberMasked: maskBankAccount(accountNumber),
};
}
} catch {}
accountReadiness.set(paymentAccount.legal_entity_id, { paymentAccount, paymentAccountSnapshot });
}
const reservedItems = await tx.paymentBatchItem.findMany({
where: {
entity_id: { in: entityIds },
payment_batch: { entity_type: entityType, status: { not: "cancelled" } },
},
select: { entity_id: true },
});
const reviewerIds = await resolvePaymentReviewRecipientIds(tx, makerId);
return {
entityType, entityIds, paymentChannel, snapshots, departmentReadiness, accountReadiness,
reservedEntityIds: new Set(reservedItems.map((item) => item.entity_id)), reviewerIds,
};
}
function normalizeSnapshotText(value) {
@@ -972,13 +1015,13 @@ async function executeReimbursementPayment(tx, { batch, item, actorId, index, re
throw buildNotFoundError();
}
if (current.status === "paid") {
throw buildValidationError({ payment: "already_paid_conflict" }, 409);
throw buildBusinessValidationError("payment_batch.already_paid_conflict", { payment: "already_paid_conflict" }, 409);
}
if (current.status !== "approved") {
throw buildValidationError({ status: "invalid_pay_status" }, 409);
throw buildBusinessValidationError("payment_batch.source_status_invalid", { status: "invalid_pay_status" }, 409);
}
if (toMoneyString(current.total_amount) !== toMoneyString(item.amount)) {
throw buildValidationError({ payment: "source_amount_changed" }, 409);
throw buildBusinessValidationError("payment_batch.source_amount_changed", { payment: "source_amount_changed" }, 409);
}
const voucher = normalizeNullableString(item.payment_voucher_no_override) ??
@@ -1186,13 +1229,13 @@ async function executeTravelOrderPayment(tx, { batch, item, actorId, index, time
throw buildNotFoundError();
}
if (current.status === "paid") {
throw buildValidationError({ payment: "already_paid_conflict" }, 409);
throw buildBusinessValidationError("payment_batch.already_paid_conflict", { payment: "already_paid_conflict" }, 409);
}
if (current.status !== "confirmed") {
throw buildValidationError({ status: "invalid_pay_status" }, 409);
throw buildBusinessValidationError("payment_batch.source_status_invalid", { status: "invalid_pay_status" }, 409);
}
if (toMoneyString(current.amount) !== toMoneyString(item.amount) || current.currency !== item.currency_code) {
throw buildValidationError({ payment: "source_amount_changed" }, 409);
throw buildBusinessValidationError("payment_batch.source_amount_changed", { payment: "source_amount_changed" }, 409);
}
const voucher = normalizeNullableString(item.payment_voucher_no_override) ??
@@ -1213,7 +1256,7 @@ async function executeTravelOrderPayment(tx, { batch, item, actorId, index, time
select: { id: true },
});
if (!matchedTransaction) {
throw buildValidationError({ payment: "missing_settled_transaction" }, 409);
throw buildBusinessValidationError("payment_batch.settled_transaction_required", { payment: "missing_settled_transaction" }, 409);
}
await tx.travelOrder.update({
@@ -1293,12 +1336,7 @@ async function readBoundedStream(stream, maxBytes) {
}
function buildReceiptAttachmentBlockedError(reasonCode) {
return new AppError({
code: "VALID_001",
message: "付款回单未通过安全校验",
statusCode: 409,
details: { receiptAttachment: "security_validation_failed", reason: reasonCode },
});
return buildBusinessValidationError("payment_batch.receipt_security_failed", { receiptAttachment: "security_validation_failed", reason: reasonCode }, 409);
}
async function auditBlockedReceiptDownload({ fastify, request, batchId, attachment, reasonCode, downloadReason }) {
@@ -1351,7 +1389,7 @@ async function readReceiptUploadFiles(fastify, request) {
}
const buffer = await readBoundedStream(part.file, maxBytes);
if (part.fieldname !== "files") {
throw buildValidationError({ file: "invalid_field" });
throw buildBusinessValidationError("payment_batch.receipt_field_invalid", { file: "invalid_field" }, 400);
}
files.push(await validateAndNormalizePurchaseAttachment({
buffer,
@@ -1363,22 +1401,22 @@ async function readReceiptUploadFiles(fastify, request) {
}
} catch (error) {
if (error instanceof ReceiptAttachmentSizeLimitError || error?.code === "FST_REQ_FILE_TOO_LARGE") {
throw buildValidationError({ file: "file_too_large" });
throw buildBusinessValidationError("payment_batch.receipt_too_large", { file: "file_too_large" }, 400);
}
if (error?.code === "FST_FILES_LIMIT") {
throw buildValidationError({ file: "too_many_files" });
throw buildBusinessValidationError("payment_batch.receipt_too_many", { file: "too_many_files" }, 400);
}
if (error?.code === "FST_FIELDS_LIMIT" || error?.code === "FST_PARTS_LIMIT") {
throw buildValidationError({ file: "multipart_limit_exceeded" });
throw buildBusinessValidationError("payment_batch.receipt_upload_limit", { file: "multipart_limit_exceeded" }, 400);
}
if (error instanceof PurchaseAttachmentPolicyError) {
throw buildValidationError(error.details ?? { file: error.reasonCode });
throw buildBusinessValidationError("payment_batch.receipt_file_invalid", error.details ?? { file: error.reasonCode }, 400);
}
throw error;
}
if (!files.length) {
throw buildValidationError({ file: "missing" });
throw buildBusinessValidationError("payment_batch.receipt_required", { file: "missing" }, 400);
}
return files;
@@ -1389,24 +1427,12 @@ async function paymentBatchesRoutes(fastify) {
await ensureFeatureEnabled(fastify);
ensureBatchManagePermission(fastify, request);
const parsed = CANDIDATES_QUERY_SCHEMA.safeParse(request.query ?? {});
if (!parsed.success) throw buildValidationError(parsed.error.flatten());
const { q, page, pageSize } = parsed.data;
if (!parsed.success) throw buildBusinessValidationError("payment_batch.invalid_input", parsed.error.flatten(), 400);
const { q, page, pageSize, paymentChannel } = parsed.data;
const reservedItems = await fastify.prisma.paymentBatchItem.findMany({
where: {
payment_batch: {
entity_type: "reimbursement",
status: { not: "cancelled" },
},
},
distinct: ["entity_id"],
select: { entity_id: true },
});
const reservedIds = reservedItems.map((item) => item.entity_id);
const where = {
status: "approved",
paid_at: null,
...(reservedIds.length ? { id: { notIn: reservedIds } } : {}),
...(q
? {
OR: [
@@ -1433,26 +1459,15 @@ async function paymentBatchesRoutes(fastify) {
},
orderBy: [{ reimbursement_date: "desc" }, { id: "desc" }],
});
const departmentGateCache = new Map();
const eligibleCandidates = [];
for (const candidate of allCandidates) {
const departmentId = candidate.department_id ?? null;
const cacheKey = departmentId ?? "__missing_department__";
let enabled = departmentGateCache.get(cacheKey);
if (enabled == null) {
const result = await evaluatePaymentBatchDepartments(fastify, fastify.prisma, [departmentId]);
enabled = result.enabled;
departmentGateCache.set(cacheKey, enabled);
}
if (enabled) {
eligibleCandidates.push(candidate);
}
}
const total = eligibleCandidates.length;
const candidates = eligibleCandidates.slice((page - 1) * pageSize, page * pageSize);
const total = allCandidates.length;
const candidates = allCandidates.slice((page - 1) * pageSize, page * pageSize);
const facts = await loadPaymentCreationFacts(fastify, fastify.prisma, {
entityType: "reimbursement", entityIds: candidates.map((candidate) => candidate.id), paymentChannel, makerId: request.employee.id,
});
return sendData(reply, request, {
items: candidates.map((item) => ({
readiness: resolvePaymentBatchCreationReadiness({ ...facts, entityIds: [item.id] }),
entityId: item.id,
entityType: "reimbursement",
businessNo: item.form_number ?? "待编号报销单",
@@ -1472,11 +1487,22 @@ async function paymentBatchesRoutes(fastify) {
});
});
fastify.post("/payment-batches/readiness", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureBatchManagePermission(fastify, request);
const body = parseBody(CREATE_SCHEMA.pick({ entityType: true, entityIds: true, defaultPaymentChannel: true }), request.body);
const entityIds = [...new Set(body.entityIds)];
const facts = await loadPaymentCreationFacts(fastify, fastify.prisma, {
entityType: body.entityType, entityIds, paymentChannel: body.defaultPaymentChannel ?? "bank", makerId: request.employee.id,
});
return sendData(reply, request, resolvePaymentBatchCreationReadiness(facts));
});
fastify.get("/payment-batches", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureBatchReadPermission(fastify, request);
const queryParsed = LIST_QUERY_SCHEMA.safeParse(request.query ?? {});
if (!queryParsed.success) throw buildValidationError(queryParsed.error.flatten());
if (!queryParsed.success) throw buildBusinessValidationError("payment_batch.invalid_input", queryParsed.error.flatten(), 400);
const query = queryParsed.data;
const items = await fastify.prisma.paymentBatch.findMany({
where: {
@@ -1510,97 +1536,20 @@ async function paymentBatchesRoutes(fastify) {
ensureBatchManagePermission(fastify, request);
const body = parseBody(CREATE_SCHEMA, request.body);
if (body.entityType !== "reimbursement") {
throw buildValidationError({ entityType: "travel_payment_frozen" }, 409);
throw buildBusinessValidationError("payment_batch.travel_payment_frozen", { entityType: "travel_payment_frozen" }, 409);
}
const entityIds = Array.from(new Set(body.entityIds)).sort();
const { batch: created, notificationTask } = await fastify.prisma.$transaction(async (tx) => {
await lockPaymentEntities(tx, body.entityType, entityIds);
const snapshots = await loadEntitySnapshots(tx, body.entityType, entityIds, fastify.config);
if (snapshots.length !== entityIds.length) {
throw buildValidationError({ entityIds: "contains_not_found" }, 404);
}
await ensurePaymentBatchDepartmentsEnabled(
fastify,
tx,
snapshots.map((snapshot) => snapshot.departmentId ?? null),
);
for (const snapshot of snapshots) {
ensureBatchEntityStatus(body.entityType, snapshot);
}
const isBankBatch = (body.defaultPaymentChannel ?? "bank") === "bank";
let paymentAccount = null;
let paymentAccountSnapshot = null;
let legalEntityId = null;
if (isBankBatch) {
const legalEntityIds = Array.from(
new Set(snapshots.map((snapshot) => snapshot.legalEntityId).filter(Boolean)),
);
if (legalEntityIds.length !== 1 || snapshots.some((snapshot) => !snapshot.legalEntityId)) {
throw buildValidationError({ legalEntity: "single_legal_entity_required" }, 409);
}
legalEntityId = legalEntityIds[0];
const invalidPayee = snapshots.find((snapshot) => {
const payee = snapshot.snapshotJson?.payee;
return payee?.bankInstitutionCode !== "CCB" || !payee?.bankAccountEncrypted;
});
if (invalidPayee) {
throw buildValidationError({
payeeBankAccount: `${invalidPayee.entityCode}:ccb_account_required`,
}, 409);
}
paymentAccount = await tx.paymentAccount.findFirst({
where: {
legal_entity_id: legalEntityId,
enabled: true,
archived_at: null,
bank_institution: { code: "CCB", enabled: true, archived_at: null },
},
include: PAYMENT_ACCOUNT_INCLUDE,
});
if (!paymentAccount) {
throw buildValidationError({ paymentAccount: "active_ccb_account_required" }, 409);
}
let paymentAccountNumber;
try {
paymentAccountNumber = decryptStringOrThrow(
fastify.config,
paymentAccount.account_number_encrypted,
);
} catch (error) {
throw buildValidationError({ paymentAccount: "decrypt_failed" }, 409);
}
paymentAccountSnapshot = {
paymentAccountId: paymentAccount.id,
legalEntityId,
accountName: paymentAccount.account_name,
bankInstitutionId: paymentAccount.bank_institution.id,
bankInstitutionCode: paymentAccount.bank_institution.code,
bankName: paymentAccount.bank_institution.name,
accountNumberEncrypted: paymentAccount.account_number_encrypted,
accountNumberMasked: maskBankAccount(paymentAccountNumber),
};
}
const reservedItems = await tx.paymentBatchItem.findMany({
where: {
entity_id: { in: entityIds },
payment_batch: {
entity_type: body.entityType,
status: { not: "cancelled" },
},
},
select: { entity_id: true },
const facts = await loadPaymentCreationFacts(fastify, tx, {
entityType: body.entityType, entityIds, paymentChannel: body.defaultPaymentChannel ?? "bank", makerId: request.employee.id,
});
if (reservedItems.length) {
throw buildValidationError({ entityIds: "contains_active_payment_batch" }, 409);
}
const recipientEmployeeIds = await resolvePaymentReviewRecipientIds(tx, request.employee.id);
if (!recipientEmployeeIds.length) {
throw buildValidationError({ review: "no_eligible_reviewer" }, 409);
}
const readiness = resolvePaymentBatchCreationReadiness(facts);
if (!readiness.ready) throw paymentReadinessError(readiness, facts.departmentReadiness);
const { snapshots, reviewerIds: recipientEmployeeIds } = facts;
const legalEntityId = facts.paymentChannel === "bank" ? snapshots[0].legalEntityId : null;
const { paymentAccount = null, paymentAccountSnapshot = null } = facts.accountReadiness.get(legalEntityId) ?? {};
const allocatedNumber = await allocatePaymentBatchNumber(tx, fastify.config.appTimezone);
@@ -1786,19 +1735,19 @@ async function paymentBatchesRoutes(fastify) {
const batch = await loadBatchWithItems(fastify.prisma, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, fastify.prisma, batch);
if (!["reviewed", "failed"].includes(batch.status)) {
throw buildValidationError({ status: "payee_bank_reveal_not_available" }, 409);
throw buildBusinessValidationError("payment_batch.account_reveal_unavailable", { status: "payee_bank_reveal_not_available" }, 409);
}
const item = batch.items.find((candidate) => candidate.id === itemId);
if (!item) throw buildNotFoundError();
const encryptedAccount = item.snapshot_json?.payee?.bankAccountEncrypted;
if (!encryptedAccount) {
throw buildValidationError({ payeeBankAccount: "legacy_snapshot_missing" }, 409);
throw buildBusinessValidationError("payment_batch.payee_snapshot_missing", { payeeBankAccount: "legacy_snapshot_missing" }, 409);
}
let bankAccount;
try {
bankAccount = decryptStringOrThrow(fastify.config, encryptedAccount);
} catch (error) {
throw buildValidationError({ payeeBankAccount: "decrypt_failed" }, 409);
throw buildBusinessValidationError("payment_batch.payee_account_unavailable", { payeeBankAccount: "decrypt_failed" }, 409);
}
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
@@ -1860,7 +1809,7 @@ async function paymentBatchesRoutes(fastify) {
const batch = await loadBatchWithItems(fastify.prisma, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, fastify.prisma, batch);
if (!["reviewed", "failed"].includes(batch.status)) {
throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409);
throw buildBusinessValidationError("payment_batch.receipt_not_editable", { status: "receipt_attachment_not_editable" }, 409);
}
const files = await readReceiptUploadFiles(fastify, request);
@@ -1886,7 +1835,7 @@ async function paymentBatchesRoutes(fastify) {
const lockedBatch = await lockBatchWithItems(tx, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, lockedBatch);
if (!["reviewed", "failed"].includes(lockedBatch.status)) {
throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409);
throw buildBusinessValidationError("payment_batch.receipt_not_editable", { status: "receipt_attachment_not_editable" }, 409);
}
const created = [];
@@ -1970,7 +1919,7 @@ async function paymentBatchesRoutes(fastify) {
const batchId = parseId(request.params?.id);
const attachmentId = parseId(request.params?.attachmentId, "attachmentId");
const queryParsed = RECEIPT_FILE_QUERY_SCHEMA.safeParse(request.query ?? {});
if (!queryParsed.success) throw buildValidationError(queryParsed.error.flatten());
if (!queryParsed.success) throw buildBusinessValidationError("payment_batch.invalid_input", queryParsed.error.flatten(), 400);
const previewMode = queryParsed.data.mode === "preview";
const batch = await loadBatchWithItems(fastify.prisma, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, fastify.prisma, batch);
@@ -1998,13 +1947,13 @@ async function paymentBatchesRoutes(fastify) {
throw buildNotFoundError();
}
if (attachment.archived_at) {
throw buildValidationError({ receiptAttachment: "voided" }, 409);
throw buildBusinessValidationError("payment_batch.receipt_voided", { receiptAttachment: "voided" }, 409);
}
const downloadReason = previewMode
? "付款批次内在线预览"
: sanitizeDownloadReason(queryParsed.data.reason);
if (!previewMode && requiresDownloadReason(attachment) && !downloadReason) {
throw buildValidationError({ reason: "required" });
throw buildBusinessValidationError("payment_batch.download_reason_required", { reason: "required" }, 400);
}
if (attachment.governance_status === "quarantined") {
@@ -2146,7 +2095,7 @@ async function paymentBatchesRoutes(fastify) {
await ensureFeatureEnabled(fastify);
parseId(request.params?.id);
parseId(request.params?.attachmentId, "attachmentId");
throw buildValidationError({ receiptAttachment: "void_required" }, 409);
throw buildBusinessValidationError("payment_batch.receipt_void_required", { receiptAttachment: "void_required" }, 409);
});
fastify.post("/payment-batches/:id/receipt-attachments/:attachmentId/void", { preHandler: fastify.authenticate }, async (request, reply) => {
@@ -2160,7 +2109,7 @@ async function paymentBatchesRoutes(fastify) {
const batch = await lockBatchWithItems(tx, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
if (!["reviewed", "failed"].includes(batch.status)) {
throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409);
throw buildBusinessValidationError("payment_batch.receipt_not_editable", { status: "receipt_attachment_not_editable" }, 409);
}
const attachment = await tx.paymentBatchReceiptAttachment.findFirst({
where: { id: attachmentId, payment_batch_id: batchId },
@@ -2169,7 +2118,7 @@ async function paymentBatchesRoutes(fastify) {
throw buildNotFoundError();
}
if (attachment.archived_at) {
throw buildValidationError({ receiptAttachment: "already_voided" }, 409);
throw buildBusinessValidationError("payment_batch.receipt_already_voided", { receiptAttachment: "already_voided" }, 409);
}
const voidedAt = new Date();
@@ -2212,14 +2161,14 @@ async function paymentBatchesRoutes(fastify) {
const batch = await lockBatchWithItems(tx, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
if (!["reviewed", "failed"].includes(batch.status)) {
throw buildValidationError({ status: "payment_metadata_not_editable" }, 409);
throw buildBusinessValidationError("payment_batch.metadata_not_editable", { status: "payment_metadata_not_editable" }, 409);
}
const item = batch.items.find((entry) => entry.id === itemId);
if (!item) {
throw buildNotFoundError();
}
if (!["pending", "failed"].includes(item.status)) {
throw buildValidationError({ item: "only_open_item_editable" }, 409);
throw buildBusinessValidationError("payment_batch.item_not_editable", { item: "only_open_item_editable" }, 409);
}
const before = {
@@ -2276,16 +2225,16 @@ async function paymentBatchesRoutes(fastify) {
const isFirstReview = batch.status === "draft" && !batch.reviewed_at;
if (!isFirstReview) {
throw buildValidationError({ review: "batch_not_reviewable" }, 409);
throw buildBusinessValidationError("payment_batch.batch_not_reviewable", { review: "batch_not_reviewable" }, 409);
}
if (batch.items.some((item) => item.status !== "pending")) {
throw buildValidationError({ items: "all_items_must_be_pending" }, 409);
throw buildBusinessValidationError("payment_batch.items_not_pending", { items: "all_items_must_be_pending" }, 409);
}
if (!batch.created_by) {
throw buildValidationError({ review: "maker_identity_missing" }, 409);
throw buildBusinessValidationError("payment_batch.maker_required", { review: "maker_identity_missing" }, 409);
}
if (batch.created_by === request.employee.id) {
throw buildValidationError({ review: "maker_checker_conflict" }, 409);
throw buildBusinessValidationError("payment_batch.maker_checker_conflict", { review: "maker_checker_conflict" }, 409);
}
const openRiskFlags = await tx.entityRiskFlag.findMany({
where: {
@@ -2297,7 +2246,7 @@ async function paymentBatchesRoutes(fastify) {
const resolveIds = new Set(body.resolveRiskFlagIds ?? []);
const unresolved = openRiskFlags.filter((item) => !resolveIds.has(item.id));
if (unresolved.length) {
throw buildValidationError({ resolveRiskFlagIds: "open_risk_flags_must_be_resolved" }, 409);
throw buildBusinessValidationError("payment_batch.risk_resolution_required", { resolveRiskFlagIds: "open_risk_flags_must_be_resolved" }, 409);
}
if (openRiskFlags.length) {
@@ -2377,10 +2326,10 @@ async function paymentBatchesRoutes(fastify) {
const batch = await lockBatchWithItems(tx, batchId);
const batchRisk = summarizeBatchRisk(batch);
if (!["reviewed", "failed"].includes(batch.status)) {
throw buildValidationError({ status: "only_reviewed_or_failed_executable" }, 409);
throw buildBusinessValidationError("payment_batch.batch_not_executable", { status: "only_reviewed_or_failed_executable" }, 409);
}
if (batchRisk.reviewRequirement.completedReviewCount < batchRisk.reviewRequirement.requiredReviewCount) {
throw buildValidationError({ review: "batch_review_required" }, 409);
throw buildBusinessValidationError("payment_batch.review_required", { review: "batch_review_required" }, 409);
}
await lockPaymentEntities(
tx,
@@ -2448,6 +2397,7 @@ async function paymentBatchesRoutes(fastify) {
}
const failureCode = error.code;
const failureMessage = error.message;
const failureReason = ERROR_REASONS.find((reason) => reason.code === error.details?.reasonCode);
await tx.paymentBatchItem.update({
where: { id: item.id },
data: {
@@ -2457,21 +2407,26 @@ async function paymentBatchesRoutes(fastify) {
},
});
finalItemStatuses.set(item.id, "failed");
await ensureRiskFlag(tx, {
const riskDetail = failureReason?.defaultMessage ?? "付款登记未完成,请核对单据状态和付款资料后重试,仍无法完成请联系管理员。";
const riskPayload = {
batchId: batch.id, itemId: item.id, failureCode,
...(failureReason ? { reasonCode: failureReason.code } : {}),
};
const riskFlag = await ensureRiskFlag(tx, {
entityType: batch.entity_type,
entityId: item.entity_id,
riskCode: "payment_batch_execute_failed",
level: "high",
title: "支付批次执行失败",
detail: `${item.entity_code} 执行失败:${failureMessage}`,
detail: riskDetail,
source: "payment_batch",
actorId: request.employee.id,
payloadJson: {
batchId: batch.id,
itemId: item.id,
failureCode,
},
payloadJson: riskPayload,
});
if (riskFlag.detail !== riskDetail || riskFlag.payload_json?.batchId !== batch.id
|| riskFlag.payload_json?.itemId !== item.id || riskFlag.payload_json?.reasonCode !== failureReason?.code) {
await tx.entityRiskFlag.update({ where: { id: riskFlag.id }, data: { detail: riskDetail, payload_json: riskPayload } });
}
attemptFailedCount += 1;
}
}
@@ -2552,7 +2507,7 @@ async function paymentBatchesRoutes(fastify) {
items: batch.items.filter((item) => ["pending", "failed"].includes(item.status)),
});
if (["executed", "cancelled"].includes(batch.status)) {
throw buildValidationError({ status: "batch_not_cancellable" }, 409);
throw buildBusinessValidationError("payment_batch.batch_not_cancellable", { status: "batch_not_cancellable" }, 409);
}
const openItemIds = batch.items
.filter((item) => ["pending", "failed"].includes(item.status))
+2
查看文件
@@ -1,4 +1,5 @@
const { z } = require("zod");
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
@@ -2400,6 +2401,7 @@ async function purchaseRequestRoutes(fastify, options = {}) {
throw buildValidationError({ file: "multipart_limit_exceeded" });
}
if (error instanceof PurchaseAttachmentPolicyError) {
if (error.reasonCode === "unsupported_type") throw buildBusinessValidationError("purchase_attachment.unsupported_type", error.details ?? { file: error.reasonCode });
throw buildValidationError(error.details ?? { file: error.reasonCode });
}
throw error;
文件差异内容过多而无法显示 加载差异
+10 -17
查看文件
@@ -1,7 +1,7 @@
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const crypto = require("node:crypto");
const path = require("node:path");
const { AppError } = require("../utils/AppError");
const { compactDateString } = require("../utils/date");
const { writeBuffer } = require("./storage");
@@ -17,14 +17,7 @@ const SESSION_ORDER = {
pm: 1,
};
function buildLeaveValidationError(details, statusCode = 422) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function normalizeDateOnly(value) {
if (value instanceof Date) {
@@ -35,7 +28,7 @@ function normalizeDateOnly(value) {
return new Date(`${value}T00:00:00.000Z`);
}
throw buildLeaveValidationError({ date: "invalid" }, 400);
throw buildBusinessValidationError("leave.date_invalid", { date: "invalid" }, 400);
}
function formatDateOnly(value) {
@@ -64,7 +57,7 @@ function toHalfDaySlot(dateValue, session) {
const sessionIndex = SESSION_ORDER[session];
if (sessionIndex == null) {
throw buildLeaveValidationError({ session: "invalid" }, 400);
throw buildBusinessValidationError("leave.session_invalid", { session: "invalid" }, 400);
}
const dayIndex = Math.floor(date.getTime() / 86400000);
@@ -76,7 +69,7 @@ function calculateRequestedDays({ startDate, endDate, startSession, endSession }
const endSlot = toHalfDaySlot(endDate, endSession);
if (endSlot < startSlot) {
throw buildLeaveValidationError({ leave: "invalid_range" });
throw buildBusinessValidationError("leave.leave_invalid_range", { leave: "invalid_range" }, 422);
}
return fromHalfDayUnits(endSlot - startSlot + 1);
@@ -186,7 +179,7 @@ function calculateRequestedDaysPreviewByWorkingCalendar({
if (requestedUnits <= 0) {
if (!allowNonWorkingRange) {
throw buildLeaveValidationError({ leave: "non_working_range" });
throw buildBusinessValidationError("leave.leave_non_working_range", { leave: "non_working_range" }, 422);
}
return {
@@ -313,7 +306,7 @@ async function lockEmployeeForLeave(tx, employeeId) {
async function ensureAnnualLeaveGrant(tx, { employeeId, leaveType, actorId, referenceDate = new Date() }) {
const policy = leaveType?.leave_policy;
if (!leaveType || !leaveType.enabled || !policy || !policy.enabled) {
throw buildLeaveValidationError({ leaveTypeId: "missing_active_policy" }, 409);
throw buildBusinessValidationError("leave.leave_type_id_missing_active_policy", { leaveTypeId: "missing_active_policy" }, 409);
}
const { periodStart, periodEnd } = currentLeavePeriod(referenceDate);
@@ -532,7 +525,7 @@ async function consumeReservedLeave(tx, {
const requestedUnits = toHalfDayUnits(requestedDays);
if (reservedUnits < requestedUnits || requestedUnits <= 0) {
throw buildLeaveValidationError({ leave: "missing_reserve_ledger" }, 409);
throw buildBusinessValidationError("leave.leave_missing_reserve_ledger", { leave: "missing_reserve_ledger" }, 409);
}
await createLeaveLedgerEntry(tx, {
@@ -571,13 +564,13 @@ async function persistLeaveAttachment({ config, employeeId, filePart }) {
for await (const chunk of filePart.file) {
fileSize += chunk.length;
if (fileSize > config.maxUploadSizeBytes) {
throw buildLeaveValidationError({ file: "file_too_large" }, 400);
throw buildBusinessValidationError("leave.file_file_too_large", { file: "file_too_large" }, 400);
}
chunks.push(chunk);
}
if (fileSize <= 0) {
throw buildLeaveValidationError({ file: "empty" }, 400);
throw buildBusinessValidationError("leave.file_empty", { file: "empty" }, 400);
}
const buffer = Buffer.concat(chunks);
@@ -0,0 +1,60 @@
const { getErrorReason } = require("../constants/errorReasons");
const { buildBusinessValidationError } = require("../utils/businessValidationError");
function resolvePaymentBatchCreationReadiness({ entityType, entityIds, paymentChannel = "bank", snapshots, departmentReadiness, accountReadiness, reservedEntityIds, reviewerIds }) {
const blockers = [];
const addPaymentReadinessBlocker = (reasonCode, snapshot) => {
getErrorReason(reasonCode);
blockers.push({ reasonCode, ...(snapshot ? { entityId: snapshot.entityId, entityCode: snapshot.entityCode } : {}) });
};
if (entityType !== "reimbursement") addPaymentReadinessBlocker("payment_batch.travel_payment_frozen");
const selected = snapshots.filter((snapshot) => entityIds.includes(snapshot.entityId));
if (!entityIds.length || selected.length !== entityIds.length) addPaymentReadinessBlocker("payment_batch.source_not_found");
const companies = new Set();
for (const snapshot of selected) {
if (snapshot.status !== "approved" || snapshot.snapshotJson?.paidAt) addPaymentReadinessBlocker("payment_batch.source_status_invalid", snapshot);
if (!departmentReadiness.get(snapshot.departmentId ?? null)?.enabled) addPaymentReadinessBlocker("payment_batch.department_unavailable", snapshot);
if (reservedEntityIds.has(snapshot.entityId)) addPaymentReadinessBlocker("payment_batch.active_batch_exists", snapshot);
const payee = snapshot.snapshotJson?.payee;
if (payee?.bankAccountUnavailable) addPaymentReadinessBlocker("payment_batch.payee_account_unavailable", snapshot);
if (paymentChannel !== "bank") continue;
if (!snapshot.legalEntityId) addPaymentReadinessBlocker("payment_batch.legal_entity_required", snapshot);
else companies.add(snapshot.legalEntityId);
if (!payee?.bankInstitutionCode) addPaymentReadinessBlocker("payment_batch.payee_bank_required", snapshot);
else if (payee.bankInstitutionCode !== "CCB") addPaymentReadinessBlocker("payment_batch.payee_bank_unsupported", snapshot);
if (!payee?.bankAccountEncrypted) addPaymentReadinessBlocker("payment_batch.payee_account_required", snapshot);
}
if (paymentChannel === "bank") {
if (companies.size > 1) addPaymentReadinessBlocker("payment_batch.multiple_legal_entities");
for (const companyId of companies) {
const account = accountReadiness.get(companyId);
if (!account?.paymentAccount) addPaymentReadinessBlocker("payment_batch.payment_account_required");
else if (!account.paymentAccountSnapshot) addPaymentReadinessBlocker("payment_batch.payment_account_unavailable");
}
}
if (!reviewerIds.length) addPaymentReadinessBlocker("payment_batch.reviewer_required");
return { ready: blockers.length === 0, blockers };
}
function paymentReadinessError(readiness, departmentReadiness) {
const blocker = readiness.blockers[0];
const detailsByReason = {
"payment_batch.travel_payment_frozen": { entityType: "travel_payment_frozen" },
"payment_batch.source_not_found": { entityIds: "contains_not_found" },
"payment_batch.source_status_invalid": { entity: `${blocker.entityCode}:invalid_pay_status` },
"payment_batch.department_unavailable": { featureFlag: "payment_batch_v1_disabled", reason: [...departmentReadiness.values()].find((value) => !value.enabled)?.gate?.reason ?? "department_not_allowed" },
"payment_batch.active_batch_exists": { entityIds: "contains_active_payment_batch" },
"payment_batch.payee_account_unavailable": { payeeBankAccount: "decrypt_failed" },
"payment_batch.legal_entity_required": { legalEntity: "single_legal_entity_required" },
"payment_batch.multiple_legal_entities": { legalEntity: "single_legal_entity_required" },
"payment_batch.payee_bank_required": { payeeBankAccount: `${blocker.entityCode}:ccb_account_required` },
"payment_batch.payee_account_required": { payeeBankAccount: `${blocker.entityCode}:ccb_account_required` },
"payment_batch.payee_bank_unsupported": { payeeBankAccount: `${blocker.entityCode}:ccb_account_required` },
"payment_batch.payment_account_required": { paymentAccount: "active_ccb_account_required" },
"payment_batch.payment_account_unavailable": { paymentAccount: "decrypt_failed" },
"payment_batch.reviewer_required": { review: "no_eligible_reviewer" },
};
return buildBusinessValidationError(getErrorReason(blocker.reasonCode).code, detailsByReason[blocker.reasonCode], blocker.reasonCode === "payment_batch.source_not_found" ? 404 : 409);
}
module.exports = { resolvePaymentBatchCreationReadiness, paymentReadinessError };
@@ -0,0 +1,14 @@
const { AppError } = require("./AppError");
const { getErrorReason } = require("../constants/errorReasons");
function buildBusinessValidationError(reasonCode, details = {}, statusCode = 400) {
const reason = getErrorReason(reasonCode);
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details: { ...details, reasonCode: reason.code },
});
}
module.exports = { buildBusinessValidationError };
@@ -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);
});
});