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 };