feat: 完成错误原因合同与付款就绪提示(09-05-error-explainability-payment-readiness)
这个提交包含在:
@@ -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 };
|
||||
@@ -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({
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
+89
-467
文件差异内容过多而无法显示
加载差异
+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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户