fix: 完善动态错误指引与发票日期校验(09-05-error-explainability-payment-readiness)
这个提交包含在:
@@ -1,4 +1,77 @@
|
||||
const ERROR_REASONS = [
|
||||
{"code":"workflow.task_already_processed","domain":"workflow","audience":"user","defaultMessage":"此审批任务已处理,请刷新待办查看最新进度。","recovery":"follow_guidance"},
|
||||
{"code":"workflow.entity_unavailable","domain":"workflow","audience":"user","defaultMessage":"审批对应的单据不存在或当前不可处理,请联系管理员核查。","recovery":"follow_guidance"},
|
||||
{"code":"workflow.self_approval_forbidden","domain":"workflow","audience":"user","defaultMessage":"不能审批本人申请或代提交的单据,请由符合资格的其他审批人处理。","recovery":"follow_guidance"},
|
||||
{"code":"workflow.transfer_target_unavailable","domain":"workflow","audience":"user","defaultMessage":"转审人不存在或已停用,请重新选择在职审批人。","recovery":"follow_guidance"},
|
||||
{"code":"workflow.transfer_target_ineligible","domain":"workflow","audience":"user","defaultMessage":"所选人员没有这类业务的审批资格,请重新选择审批人。","recovery":"follow_guidance"},
|
||||
{"code":"workflow.self_remind_forbidden","domain":"workflow","audience":"user","defaultMessage":"当前待办由你处理,无需催办自己,请直接处理待办。","recovery":"follow_guidance"},
|
||||
{"code":"workflow.step_missing","domain":"workflow","audience":"user","defaultMessage":"审批流程节点信息异常,请联系管理员核查,不要重复提交。","recovery":"follow_guidance"},
|
||||
{"code":"workflow.transfer_unsupported","domain":"workflow","audience":"user","defaultMessage":"此类业务暂不支持转审,请按原审批流程处理。","recovery":"follow_guidance"},
|
||||
{"code":"workflow.transfer_same_assignee","domain":"workflow","audience":"user","defaultMessage":"不能转审给自己,请选择其他符合资格的审批人。","recovery":"follow_guidance"},
|
||||
{"code":"workflow.task_list_query_invalid","domain":"workflow","audience":"user","defaultMessage":"待办筛选或分页条件无效,请重新选择筛选条件后重试。","recovery":"follow_guidance"},
|
||||
{"code":"workflow.approver_query_invalid","domain":"workflow","audience":"user","defaultMessage":"审批人查询条件无效,请缩短关键词并检查分页条件后重试。","recovery":"follow_guidance"},
|
||||
{"code":"workflow.batch_entity_unsupported","domain":"workflow","audience":"user","defaultMessage":"批量审批仅支持当前指定业务类型,请取消其他类型任务的选择。","recovery":"follow_guidance"},
|
||||
{"code":"workflow.remind_cooldown","domain":"workflow","audience":"user","defaultMessage":"此任务24小时内已由你催办,请勿重复催办,稍后再试。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_pool.pdf_required","domain":"invoice_pool","audience":"user","defaultMessage":"请上传 PDF 发票文件","recovery":"follow_guidance"},
|
||||
{"code":"invoice_pool.ocr_pdf_too_large","domain":"invoice_pool","audience":"user","defaultMessage":"PDF 发票不能超过 7 MiB","recovery":"follow_guidance"},
|
||||
{"code":"invoice_verification.list_query_invalid","domain":"invoice_verification","audience":"user","defaultMessage":"查验记录查询数量无效,请选择1至50条后重试。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_pool.single_file_required","domain":"invoice_pool","audience":"user","defaultMessage":"请每次上传一份PDF发票,可分次添加多张发票。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_pool.empty_file","domain":"invoice_pool","audience":"user","defaultMessage":"发票文件为空,请重新选择有效的PDF文件。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_pool.upload_too_large","domain":"invoice_pool","audience":"user","defaultMessage":"发票文件超过当前上传大小限制,请压缩PDF后重试;仍失败请联系管理员。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_pool.file_required","domain":"invoice_pool","audience":"user","defaultMessage":"请先选择要上传的PDF发票。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_pool.owner_unavailable","domain":"invoice_pool","audience":"user","defaultMessage":"发票所属员工不存在或已停用,请重新选择在职员工。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_pool.ocr_type_invalid","domain":"invoice_pool","audience":"user","defaultMessage":"发票识别类型无效,请重新选择票据类型后上传。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_pool.edit_empty","domain":"invoice_pool","audience":"user","defaultMessage":"没有可保存的修改,请修改发票信息后再保存。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_pool.not_editable","domain":"invoice_pool","audience":"user","defaultMessage":"此发票当前不能编辑,请刷新查看状态;已关联的发票需先按报销流程解除占用。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_pool.date_invalid","domain":"invoice_pool","audience":"user","defaultMessage":"开票日期无效,请填写真实有效的日期。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_pool.amount_invalid","domain":"invoice_pool","audience":"user","defaultMessage":"发票金额格式无效,请填写有效金额后重试。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_pool.tax_amount_invalid","domain":"invoice_pool","audience":"user","defaultMessage":"发票税额格式无效,请填写有效税额后重试。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_pool.total_amount_invalid","domain":"invoice_pool","audience":"user","defaultMessage":"价税合计格式无效,请填写有效金额后重试。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_pool.status_transition_invalid","domain":"invoice_pool","audience":"user","defaultMessage":"此发票不能按当前方式更改状态,请刷新页面后重试。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_pool.archive_bound","domain":"invoice_pool","audience":"user","defaultMessage":"发票已关联业务单据,不能归档;请先完成业务处理或解除关联。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_pool.archive_status_invalid","domain":"invoice_pool","audience":"user","defaultMessage":"此发票当前不能归档,请等待识别完成或刷新查看状态。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_pool.ocr_retry_status_invalid","domain":"invoice_pool","audience":"user","defaultMessage":"只有识别失败的发票可以重新识别,请刷新查看当前结果。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_pool.ocr_unavailable","domain":"invoice_pool","audience":"user","defaultMessage":"发票OCR识别服务当前不可用,请联系管理员恢复服务后重试。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_pool.override_bound","domain":"invoice_pool","audience":"user","defaultMessage":"发票已关联业务单据,不能设置重复票据例外;请先处理原关联。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_pool.override_status_invalid","domain":"invoice_pool","audience":"user","defaultMessage":"此发票当前不能设置重复票据例外,请刷新查看状态。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_pool.dedup_identity_missing","domain":"invoice_pool","audience":"user","defaultMessage":"发票缺少查重标识,请补全发票代码和号码;仍无法处理请联系管理员。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_pool.edit_input_invalid","domain":"invoice_pool","audience":"user","defaultMessage":"发票信息格式无效,请检查日期、金额及必填字段后保存。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_pool.override_input_invalid","domain":"invoice_pool","audience":"user","defaultMessage":"请填写有效的关联报销编号和重复票据例外原因。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_pool.ocr_fields_missing","domain":"invoice_pool","audience":"user","defaultMessage":"发票识别信息不完整,请补全票种要求的代码、号码、日期、金额及销售方等信息后保存。","recovery":"follow_guidance"},
|
||||
{"code":"workflow.comment_invalid","domain":"workflow","audience":"user","defaultMessage":"请填写处理意见,长度为1至500字。","recovery":"follow_guidance"},
|
||||
{"code":"workflow.transfer_input_invalid","domain":"workflow","audience":"user","defaultMessage":"请选择有效的转审人,并填写10至500字的转审原因。","recovery":"follow_guidance"},
|
||||
{"code":"workflow.batch_approve_input_invalid","domain":"workflow","audience":"user","defaultMessage":"请选择1至50条有效审批任务,审批意见不超过500字。","recovery":"follow_guidance"},
|
||||
{"code":"workflow.batch_reject_input_invalid","domain":"workflow","audience":"user","defaultMessage":"请选择1至50条有效审批任务,并填写1至500字的退回意见。","recovery":"follow_guidance"},
|
||||
{"code":"workflow.remind_input_invalid","domain":"workflow","audience":"user","defaultMessage":"催办说明不能超过200字,请修改后重试。","recovery":"follow_guidance"},
|
||||
{"code":"workflow.summary_query_invalid","domain":"workflow","audience":"user","defaultMessage":"待办统计范围无效,请从待办页面重新进入。","recovery":"follow_guidance"},
|
||||
{"code":"workflow.task_id_invalid","domain":"workflow","audience":"user","defaultMessage":"审批任务标识无效,请从待办列表重新打开。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_verification.disabled","domain":"invoice_verification","audience":"user","defaultMessage":"发票查验服务暂未启用,请联系管理员确认;发票OCR识别是独立服务。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_verification.already_pending","domain":"invoice_verification","audience":"user","defaultMessage":"发票正在查验,请稍后刷新结果,不要重复提交。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_verification.record_missing","domain":"invoice_verification","audience":"user","defaultMessage":"此发票还没有查验记录,请先发起查验,再按需重试。","recovery":"follow_guidance"},
|
||||
{"code":"auth.login_input_invalid","domain":"auth","audience":"user","defaultMessage":"请填写登录账号和密码后重试。","recovery":"follow_guidance"},
|
||||
{"code":"auth.email_input_invalid","domain":"auth","audience":"user","defaultMessage":"请输入格式正确的邮箱地址后重试。","recovery":"follow_guidance"},
|
||||
{"code":"auth.email_code_input_invalid","domain":"auth","audience":"user","defaultMessage":"请检查邮箱地址和验证码的填写格式后重试。","recovery":"follow_guidance"},
|
||||
{"code":"auth.password_change_input_invalid","domain":"auth","audience":"user","defaultMessage":"请完整填写原密码和新密码后重试。","recovery":"follow_guidance"},
|
||||
{"code":"auth.password_reset_input_invalid","domain":"auth","audience":"user","defaultMessage":"请完整填写邮箱、验证码和新密码后重试。","recovery":"follow_guidance"},
|
||||
{"code":"auth.device_context_missing","domain":"auth","audience":"user","defaultMessage":"登录设备信息缺失,请刷新登录页面后重试;仍无法登录请联系管理员。","recovery":"follow_guidance"},
|
||||
{"code":"auth.password_policy_failed","domain":"auth","audience":"user","defaultMessage":"新密码需至少12位,包含大小写字母和数字,并避免常见弱密码。","recovery":"follow_guidance"},
|
||||
{"code":"auth.deeplink_target_invalid","domain":"auth","audience":"user","defaultMessage":"此入口的目标地址无效,请从系统首页重新进入对应业务。","recovery":"follow_guidance"},
|
||||
{"code":"auth.code_invalid_or_expired","domain":"auth","audience":"user","defaultMessage":"验证码无效或已过期,请重新获取后重试。","recovery":"follow_guidance"},
|
||||
{"code":"profile.notification_preferences_invalid","domain":"profile","audience":"user","defaultMessage":"通知设置无效,请通过页面开关选择是否接收通知后重试。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_pool.list_query_invalid","domain":"invoice_pool","audience":"user","defaultMessage":"票据筛选或分页参数无效,请重置筛选后重试。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_pool.selection_query_invalid","domain":"invoice_pool","audience":"user","defaultMessage":"选择发票的报销单或查询条件无效,请返回报销单重新打开“我的发票”。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_pool.status_query_invalid","domain":"invoice_pool","audience":"user","defaultMessage":"待查询票据列表无效,请刷新后重试;一次最多查询50张票据。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_pool.reimbursement_not_editable","domain":"invoice_pool","audience":"user","defaultMessage":"该报销单当前不可添加发票,请刷新确认状态;仅草稿或退回待修改的单据可继续选票。","recovery":"follow_guidance"},
|
||||
{"code":"invoice_pool.id_invalid","domain":"invoice_pool","audience":"user","defaultMessage":"票据标识无效,请返回“我的发票”重新选择。","recovery":"follow_guidance"},
|
||||
{"code":"payment_account.reveal_reason_required","domain":"payment_account","audience":"user","defaultMessage":"请填写2至500字的查看理由后再查看完整付款账号。","recovery":"follow_guidance"},
|
||||
{"code":"payment_account.bank_input_invalid","domain":"payment_account","audience":"user","defaultMessage":"请检查银行名称;新增银行还需填写2至16位字母、数字或下划线组成的银行编码。","recovery":"follow_guidance"},
|
||||
{"code":"payment_account.account_input_invalid","domain":"payment_account","audience":"user","defaultMessage":"请检查所属公司、开户银行、户名及账号;账号需为6至32位数字。","recovery":"follow_guidance"},
|
||||
{"code":"payment_account.id_invalid","domain":"payment_account","audience":"user","defaultMessage":"账户或银行标识无效,请返回列表重新选择。","recovery":"follow_guidance"},
|
||||
{"code":"payment_account.legal_entity_unavailable","domain":"payment_account","audience":"user","defaultMessage":"所选公司不存在或已停用,请刷新后选择可用公司;如无可选公司,请联系管理员。","recovery":"follow_guidance"},
|
||||
{"code":"payment_account.bank_unavailable","domain":"payment_account","audience":"user","defaultMessage":"所选银行不存在或已停用,请刷新后选择真实开户银行,不要为通过检查改填其他银行。","recovery":"follow_guidance"},
|
||||
{"code":"payment_account.bank_code_exists","domain":"payment_account","audience":"user","defaultMessage":"该银行编码已存在,请使用已有银行或核实编码后重试。","recovery":"follow_guidance"},
|
||||
{"code":"payment_account.bank_in_use","domain":"payment_account","audience":"user","defaultMessage":"该银行仍有启用的付款账户,暂不能停用;请先核实相关账户的使用安排。","recovery":"follow_guidance"},
|
||||
{"code":"payment_account.legal_entity_change_forbidden","domain":"payment_account","audience":"user","defaultMessage":"已有付款账户不能直接更换所属公司,请保留原公司,并为其他公司另行维护账户。","recovery":"follow_guidance"},
|
||||
{"code":"reimbursement.finance_owner_ceo_unavailable","domain":"reimbursement","audience":"user","defaultMessage":"财务本人报销需要非申请人的总经理审批,请联系管理员核实审批配置。","recovery":"follow_guidance"},
|
||||
{"code":"reimbursement.finance_owner_ceo_required","domain":"reimbursement","audience":"user","defaultMessage":"财务本人报销只能由非申请人的在职总经理审批或接收转审,不能委托其他角色代批。","recovery":"follow_guidance"},
|
||||
{"code":"reimbursement.invoice_prefill_incomplete","domain":"reimbursement","audience":"user","defaultMessage":"票据日期或金额尚未完整识别,请先处理该票据;其他已识别票据可继续添加。","recovery":"follow_guidance"},
|
||||
|
||||
+16
-90
@@ -1,6 +1,7 @@
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const { z } = require("zod");
|
||||
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const {
|
||||
@@ -251,21 +252,11 @@ function hashEmployeeAuthCode(config, employeeId, code, purpose) {
|
||||
}
|
||||
|
||||
function emailLoginCodeError() {
|
||||
return new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { code: "invalid_or_expired" },
|
||||
});
|
||||
return buildBusinessValidationError("auth.code_invalid_or_expired", { code: "invalid_or_expired" });
|
||||
}
|
||||
|
||||
function passwordResetCodeError() {
|
||||
return new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { code: "invalid_or_expired" },
|
||||
});
|
||||
return buildBusinessValidationError("auth.code_invalid_or_expired", { code: "invalid_or_expired" });
|
||||
}
|
||||
|
||||
function isFutureDate(value, now) {
|
||||
@@ -579,22 +570,12 @@ async function authRoutes(fastify, options = {}) {
|
||||
fastify.post("/login", { preHandler: authRateLimit }, async (request, reply) => {
|
||||
const parsed = loginBodySchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
throw buildBusinessValidationError("auth.login_input_invalid", parsed.error.flatten());
|
||||
}
|
||||
|
||||
const deviceFingerprint = getHeaderString(request, "x-device-fingerprint");
|
||||
if (!deviceFingerprint) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { "x-device-fingerprint": "missing" },
|
||||
});
|
||||
throw buildBusinessValidationError("auth.device_context_missing", { "x-device-fingerprint": "missing" });
|
||||
}
|
||||
|
||||
const { identifier, password } = parsed.data;
|
||||
@@ -797,12 +778,7 @@ async function authRoutes(fastify, options = {}) {
|
||||
fastify.post("/email-login/code", { preHandler: authRateLimit }, async (request, reply) => {
|
||||
const parsed = emailLoginCodeRequestBodySchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
throw buildBusinessValidationError("auth.email_input_invalid", parsed.error.flatten());
|
||||
}
|
||||
|
||||
assertEmailDeliveryReady(fastify.config, resolveEmailDeliveryStatus);
|
||||
@@ -889,22 +865,12 @@ async function authRoutes(fastify, options = {}) {
|
||||
fastify.post("/email-login/verify", { preHandler: authRateLimit }, async (request, reply) => {
|
||||
const parsed = emailLoginVerifyBodySchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
throw buildBusinessValidationError("auth.email_code_input_invalid", parsed.error.flatten());
|
||||
}
|
||||
|
||||
const deviceFingerprint = getHeaderString(request, "x-device-fingerprint");
|
||||
if (!deviceFingerprint) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { "x-device-fingerprint": "missing" },
|
||||
});
|
||||
throw buildBusinessValidationError("auth.device_context_missing", { "x-device-fingerprint": "missing" });
|
||||
}
|
||||
|
||||
const { email, code } = parsed.data;
|
||||
@@ -1036,12 +1002,7 @@ async function authRoutes(fastify, options = {}) {
|
||||
|
||||
const deviceFingerprint = getHeaderString(request, "x-device-fingerprint");
|
||||
if (!deviceFingerprint) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { "x-device-fingerprint": "missing" },
|
||||
});
|
||||
throw buildBusinessValidationError("auth.device_context_missing", { "x-device-fingerprint": "missing" });
|
||||
}
|
||||
|
||||
const refreshTokenHash = sha256Hex(refreshToken);
|
||||
@@ -1243,12 +1204,7 @@ async function authRoutes(fastify, options = {}) {
|
||||
const payload = verified.payload && typeof verified.payload === "object" ? verified.payload : {};
|
||||
const targetPath = typeof payload.targetPath === "string" ? payload.targetPath.trim() : "";
|
||||
if (!targetPath || !targetPath.startsWith("/") || targetPath.startsWith("//")) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { targetPath: "invalid_target_path" },
|
||||
});
|
||||
throw buildBusinessValidationError("auth.deeplink_target_invalid", { targetPath: "invalid_target_path" });
|
||||
}
|
||||
|
||||
const origin = String(fastify.config.appOrigin || "").split(",")[0].trim().replace(/\/+$/, "");
|
||||
@@ -1496,34 +1452,19 @@ async function authRoutes(fastify, options = {}) {
|
||||
fastify.post("/change-password", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
const parsed = changePasswordBodySchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
throw buildBusinessValidationError("auth.password_change_input_invalid", parsed.error.flatten());
|
||||
}
|
||||
|
||||
const deviceFingerprint = getHeaderString(request, "x-device-fingerprint");
|
||||
if (!deviceFingerprint) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { "x-device-fingerprint": "missing" },
|
||||
});
|
||||
throw buildBusinessValidationError("auth.device_context_missing", { "x-device-fingerprint": "missing" });
|
||||
}
|
||||
|
||||
const { oldPassword, newPassword } = parsed.data;
|
||||
|
||||
const policyErrors = validatePassword(newPassword);
|
||||
if (policyErrors.length) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { password: policyErrors },
|
||||
});
|
||||
throw buildBusinessValidationError("auth.password_policy_failed", { password: policyErrors });
|
||||
}
|
||||
|
||||
const employee = await fastify.prisma.employee.findUnique({
|
||||
@@ -1655,12 +1596,7 @@ async function authRoutes(fastify, options = {}) {
|
||||
fastify.post("/forgot-password", { preHandler: authRateLimit }, async (request, reply) => {
|
||||
const parsed = forgotPasswordBodySchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
throw buildBusinessValidationError("auth.email_input_invalid", parsed.error.flatten());
|
||||
}
|
||||
|
||||
assertEmailDeliveryReady(fastify.config, resolveEmailDeliveryStatus);
|
||||
@@ -1796,24 +1732,14 @@ async function authRoutes(fastify, options = {}) {
|
||||
fastify.post("/reset-password", { preHandler: authRateLimit }, async (request, reply) => {
|
||||
const parsed = resetPasswordBodySchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
throw buildBusinessValidationError("auth.password_reset_input_invalid", parsed.error.flatten());
|
||||
}
|
||||
|
||||
const { email, code, newPassword } = parsed.data;
|
||||
|
||||
const policyErrors = validatePassword(newPassword);
|
||||
if (policyErrors.length) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { password: policyErrors },
|
||||
});
|
||||
throw buildBusinessValidationError("auth.password_policy_failed", { password: policyErrors });
|
||||
}
|
||||
|
||||
const outcome = await fastify.prisma.$transaction(async (tx) => {
|
||||
|
||||
+48
-192
@@ -201,7 +201,11 @@ function parseDateOnlyString(value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return new Date(`${trimmed}T00:00:00.000Z`);
|
||||
const parsed = new Date(`${trimmed}T00:00:00.000Z`);
|
||||
if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function isPdfInvoiceUpload(fileName, mimeType) {
|
||||
@@ -381,55 +385,37 @@ async function invoicePoolRoutes(fastify) {
|
||||
for await (const part of request.parts()) {
|
||||
if (part.type === "file") {
|
||||
if (fileBuffer) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { file: "only_single_file_supported" },
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_pool.single_file_required", { file: "only_single_file_supported" }, 400);
|
||||
}
|
||||
fileName = part.filename ?? "upload";
|
||||
fileMimeType = part.mimetype;
|
||||
|
||||
if (!isPdfInvoiceUpload(fileName, fileMimeType)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "请上传 PDF 发票文件",
|
||||
statusCode: 400,
|
||||
details: { file: "pdf_invoice_required" },
|
||||
});
|
||||
const error = buildBusinessValidationError("invoice_pool.pdf_required", { file: "pdf_invoice_required" });
|
||||
error.message = "请上传 PDF 发票文件";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
for await (const chunk of part.file) {
|
||||
fileSize += chunk.length;
|
||||
if (fileSize > invoiceUploadMaxBytes) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message:
|
||||
invoiceUploadMaxBytes === OCR_PDF_MAX_BYTES
|
||||
? "PDF 发票不能超过 7 MiB"
|
||||
: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: {
|
||||
file:
|
||||
invoiceUploadMaxBytes === OCR_PDF_MAX_BYTES
|
||||
? "ocr_pdf_too_large"
|
||||
: "file_too_large",
|
||||
maxBytes: invoiceUploadMaxBytes,
|
||||
},
|
||||
if (invoiceUploadMaxBytes === OCR_PDF_MAX_BYTES) {
|
||||
const error = buildBusinessValidationError("invoice_pool.ocr_pdf_too_large", {
|
||||
file: "ocr_pdf_too_large", maxBytes: invoiceUploadMaxBytes,
|
||||
});
|
||||
error.message = "PDF 发票不能超过 7 MiB";
|
||||
throw error;
|
||||
}
|
||||
throw buildBusinessValidationError("invoice_pool.upload_too_large", {
|
||||
file: "file_too_large", maxBytes: invoiceUploadMaxBytes,
|
||||
});
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
if (fileSize === 0) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { file: "empty_file" },
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_pool.empty_file", { file: "empty_file" }, 400);
|
||||
}
|
||||
|
||||
fileBuffer = Buffer.concat(chunks);
|
||||
@@ -440,23 +426,13 @@ async function invoicePoolRoutes(fastify) {
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code === "FST_REQ_FILE_TOO_LARGE") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { file: "file_too_large" },
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_pool.upload_too_large", { file: "file_too_large" }, 400);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!fileBuffer) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { file: "missing" },
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_pool.file_required", { file: "missing" }, 400);
|
||||
}
|
||||
|
||||
const ownerEmployeeId =
|
||||
@@ -503,23 +479,13 @@ async function invoicePoolRoutes(fastify) {
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (!ownerEmployee || ownerEmployee.status !== "active") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { owner_employee_id: "invalid_owner" },
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_pool.owner_unavailable", { owner_employee_id: "invalid_owner" }, 409);
|
||||
}
|
||||
}
|
||||
|
||||
const ocrType = fields.ocr_type ?? "general";
|
||||
if (!OCR_TYPE_ALLOWLIST.has(ocrType)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { ocr_type: "invalid" },
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_pool.ocr_type_invalid", { ocr_type: "invalid" }, 400);
|
||||
}
|
||||
|
||||
const fileSha256 = sha256Hex(fileBuffer);
|
||||
@@ -633,31 +599,16 @@ 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("invoice_pool.id_invalid", { id: "invalid_uuid" }, 400);
|
||||
}
|
||||
|
||||
const parsedBody = invoiceEditBodySchema.safeParse(request.body ?? {});
|
||||
if (!parsedBody.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsedBody.error.flatten(),
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_pool.edit_input_invalid", parsedBody.error.flatten());
|
||||
}
|
||||
|
||||
if (Object.keys(parsedBody.data).length === 0) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { body: "empty_patch" },
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_pool.edit_empty", { body: "empty_patch" }, 400);
|
||||
}
|
||||
|
||||
const invoiceId = parsedInvoiceId.data;
|
||||
@@ -704,12 +655,7 @@ async function invoicePoolRoutes(fastify) {
|
||||
}
|
||||
|
||||
if (!["available", "ocr_failed"].includes(current.status)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { status: "invalid_status_for_edit" },
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_pool.not_editable", { status: "invalid_status_for_edit" }, 409);
|
||||
}
|
||||
|
||||
const input = parsedBody.data;
|
||||
@@ -730,12 +676,7 @@ async function invoicePoolRoutes(fastify) {
|
||||
if ("invoiceDate" in input) {
|
||||
const parsedDate = parseDateOnlyString(input.invoiceDate);
|
||||
if (parsedDate === undefined) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { invoiceDate: "invalid_date" },
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_pool.date_invalid", { invoiceDate: "invalid_date" }, 400);
|
||||
}
|
||||
updateData.invoice_date = parsedDate;
|
||||
}
|
||||
@@ -751,12 +692,7 @@ async function invoicePoolRoutes(fastify) {
|
||||
if ("amount" in input) {
|
||||
const parsedAmount = normalizeMoneyInput(input.amount);
|
||||
if (parsedAmount === undefined) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { amount: "invalid_money" },
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_pool.amount_invalid", { amount: "invalid_money" }, 400);
|
||||
}
|
||||
updateData.amount = parsedAmount;
|
||||
}
|
||||
@@ -764,12 +700,7 @@ async function invoicePoolRoutes(fastify) {
|
||||
if ("taxAmount" in input) {
|
||||
const parsedAmount = normalizeMoneyInput(input.taxAmount);
|
||||
if (parsedAmount === undefined) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { taxAmount: "invalid_money" },
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_pool.tax_amount_invalid", { taxAmount: "invalid_money" }, 400);
|
||||
}
|
||||
updateData.tax_amount = parsedAmount;
|
||||
}
|
||||
@@ -777,12 +708,7 @@ async function invoicePoolRoutes(fastify) {
|
||||
if ("totalAmount" in input) {
|
||||
const parsedAmount = normalizeMoneyInput(input.totalAmount);
|
||||
if (parsedAmount === undefined) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { totalAmount: "invalid_money" },
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_pool.total_amount_invalid", { totalAmount: "invalid_money" }, 400);
|
||||
}
|
||||
updateData.total_amount = parsedAmount;
|
||||
}
|
||||
@@ -804,15 +730,10 @@ async function invoicePoolRoutes(fastify) {
|
||||
};
|
||||
const missingFields = getMissingRequiredOcrFields(current.ocr_type, nextSnapshot);
|
||||
if (missingFields.length) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: {
|
||||
ocr: "missing_required_fields",
|
||||
missingFields,
|
||||
},
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_pool.ocr_fields_missing", {
|
||||
ocr: "missing_required_fields",
|
||||
missingFields,
|
||||
}, 409);
|
||||
}
|
||||
|
||||
updateData.status = "available";
|
||||
@@ -821,22 +742,12 @@ async function invoicePoolRoutes(fastify) {
|
||||
updateData.ocr_locked_at = null;
|
||||
updateData.ocr_lock_owner = null;
|
||||
} else {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { status: "invalid_status_transition" },
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_pool.status_transition_invalid", { status: "invalid_status_transition" }, 409);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { body: "empty_patch" },
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_pool.edit_empty", { body: "empty_patch" }, 400);
|
||||
}
|
||||
|
||||
updateData.updated_by = request.employee.id;
|
||||
@@ -913,12 +824,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("invoice_pool.id_invalid", { id: "invalid_uuid" }, 400);
|
||||
}
|
||||
|
||||
const invoiceId = parsedInvoiceId.data;
|
||||
@@ -973,12 +879,7 @@ async function invoicePoolRoutes(fastify) {
|
||||
}
|
||||
|
||||
if (current.linked_reimbursement_id || current.travel_order_id) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { invoice: "cannot_archive_bound_invoice" },
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_pool.archive_bound", { invoice: "cannot_archive_bound_invoice" }, 409);
|
||||
}
|
||||
|
||||
if (current.status === "archived" && current.archived_reason === "manual") {
|
||||
@@ -989,12 +890,7 @@ async function invoicePoolRoutes(fastify) {
|
||||
}
|
||||
|
||||
if (!["available", "ocr_failed"].includes(current.status)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { status: "invalid_status_for_archive" },
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_pool.archive_status_invalid", { status: "invalid_status_for_archive" }, 409);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
@@ -1142,12 +1038,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("invoice_pool.id_invalid", { id: "invalid_uuid" }, 400);
|
||||
}
|
||||
|
||||
const invoiceId = parsedInvoiceId.data;
|
||||
@@ -1202,21 +1093,11 @@ async function invoicePoolRoutes(fastify) {
|
||||
}
|
||||
|
||||
if (current.status !== "ocr_failed") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { status: "invalid_status_for_retry" },
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_pool.ocr_retry_status_invalid", { status: "invalid_status_for_retry" }, 409);
|
||||
}
|
||||
|
||||
if (!isOcrEnabledForType(fastify.config, current.ocr_type)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { ocr: "disabled" },
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_pool.ocr_unavailable", { ocr: "disabled" }, 409);
|
||||
}
|
||||
|
||||
const maxAttempts = Number(fastify.config.ocrMaxAttempts ?? 3);
|
||||
@@ -1289,22 +1170,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("invoice_pool.id_invalid", { id: "invalid_uuid" }, 400);
|
||||
}
|
||||
|
||||
const parsedBody = overrideDuplicateBodySchema.safeParse(request.body ?? {});
|
||||
if (!parsedBody.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsedBody.error.flatten(),
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_pool.override_input_invalid", parsedBody.error.flatten());
|
||||
}
|
||||
|
||||
const invoiceId = parsedInvoiceId.data;
|
||||
@@ -1348,30 +1219,15 @@ async function invoicePoolRoutes(fastify) {
|
||||
}
|
||||
|
||||
if (current.linked_reimbursement_id || current.travel_order_id) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { invoice: "already_bound" },
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_pool.override_bound", { invoice: "already_bound" }, 409);
|
||||
}
|
||||
|
||||
if (!["available", "ocr_failed"].includes(current.status)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { status: "invalid_status_for_override" },
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_pool.override_status_invalid", { status: "invalid_status_for_override" }, 409);
|
||||
}
|
||||
|
||||
if (!current.file_sha256 && !(current.invoice_code && current.invoice_number)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { invoice: "missing_dedup_key" },
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_pool.dedup_identity_missing", { invoice: "missing_dedup_key" }, 409);
|
||||
}
|
||||
|
||||
const dedupFilters = [];
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const { z } = require("zod");
|
||||
const { buildBusinessValidationError } = require("../../utils/businessValidationError");
|
||||
|
||||
const { AppError } = require("../../utils/AppError");
|
||||
const { sendData } = require("../../utils/reply");
|
||||
@@ -36,12 +37,7 @@ const invoiceStatusQuerySchema = z.object({
|
||||
function parseInvoiceListQuery(query) {
|
||||
const parsed = invoiceListQuerySchema.safeParse(query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_pool.list_query_invalid", parsed.error.flatten());
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -55,12 +51,7 @@ function parseInvoiceListQuery(query) {
|
||||
function parseBindableInvoiceQuery(query) {
|
||||
const parsed = bindableInvoiceQuerySchema.safeParse(query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_pool.selection_query_invalid", parsed.error.flatten());
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -73,12 +64,7 @@ function parseBindableInvoiceQuery(query) {
|
||||
function parseInvoiceStatusQuery(query) {
|
||||
const parsed = invoiceStatusQuerySchema.safeParse(query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_pool.status_query_invalid", parsed.error.flatten());
|
||||
}
|
||||
|
||||
const ids = Array.from(
|
||||
@@ -91,12 +77,7 @@ function parseInvoiceStatusQuery(query) {
|
||||
);
|
||||
|
||||
if (!ids.length || ids.length > 50 || ids.some((id) => !z.string().uuid().safeParse(id).success)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { ids: "invalid_uuid_list" },
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_pool.status_query_invalid", { ids: "invalid_uuid_list" });
|
||||
}
|
||||
|
||||
return { ids };
|
||||
@@ -291,12 +272,7 @@ function registerInvoicePoolReadRoutes(fastify) {
|
||||
}
|
||||
|
||||
if (!["draft", "rejected"].includes(reimbursement.status)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { reimbursement: "invalid_status_for_bind" },
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_pool.reimbursement_not_editable", { reimbursement: "invalid_status_for_bind" }, 409);
|
||||
}
|
||||
|
||||
const where = {
|
||||
@@ -431,12 +407,7 @@ function registerInvoicePoolReadRoutes(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("invoice_pool.id_invalid", { id: "invalid_uuid" });
|
||||
}
|
||||
|
||||
const invoice = await fastify.prisma.invoicePool.findUnique({
|
||||
@@ -477,12 +448,7 @@ function registerInvoicePoolReadRoutes(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("invoice_pool.id_invalid", { id: "invalid_uuid" });
|
||||
}
|
||||
|
||||
const invoice = await fastify.prisma.invoicePool.findUnique({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../../../utils/AppError");
|
||||
const { buildBusinessValidationError } = require("../../../utils/businessValidationError");
|
||||
|
||||
const verificationListQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(50).optional(),
|
||||
@@ -9,12 +9,7 @@ const verificationListQuerySchema = z.object({
|
||||
function parseVerificationListQuery(query) {
|
||||
const parsed = verificationListQuerySchema.safeParse(query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_verification.list_query_invalid", parsed.error.flatten());
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -33,12 +33,7 @@ const RETRY_RESPONSE_SCHEMA = {
|
||||
};
|
||||
|
||||
function throwVerificationDisabled(reason) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { verification: "disabled", featureFlag: reason },
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_verification.disabled", { verification: "disabled", featureFlag: reason }, 409);
|
||||
}
|
||||
|
||||
async function resolveVerificationDepartmentContext(tx, request, invoiceId) {
|
||||
@@ -141,12 +136,7 @@ function registerInvoiceVerificationRoutes(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("invoice_pool.id_invalid", { id: "invalid_uuid" }, 400);
|
||||
}
|
||||
|
||||
const invoiceId = parsedInvoiceId.data;
|
||||
@@ -187,12 +177,7 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
});
|
||||
|
||||
if (latest?.status === "pending") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { verification: "already_pending" },
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_verification.already_pending", { verification: "already_pending" }, 409);
|
||||
}
|
||||
|
||||
if (latest?.requested_at) {
|
||||
@@ -284,12 +269,7 @@ function registerInvoiceVerificationRoutes(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("invoice_pool.id_invalid", { id: "invalid_uuid" }, 400);
|
||||
}
|
||||
|
||||
const query = parseVerificationListQuery(request.query);
|
||||
@@ -391,12 +371,7 @@ function registerInvoiceVerificationRoutes(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("invoice_pool.id_invalid", { id: "invalid_uuid" }, 400);
|
||||
}
|
||||
|
||||
const invoiceId = parsedInvoiceId.data;
|
||||
@@ -422,21 +397,11 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
});
|
||||
|
||||
if (!latest) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { verification: "not_found" },
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_verification.record_missing", { verification: "not_found" }, 409);
|
||||
}
|
||||
|
||||
if (latest.status === "pending") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { verification: "already_pending" },
|
||||
});
|
||||
throw buildBusinessValidationError("invoice_verification.already_pending", { verification: "already_pending" }, 409);
|
||||
}
|
||||
|
||||
const cooldownMs =
|
||||
|
||||
+2
-10
@@ -1,4 +1,5 @@
|
||||
const { z } = require("zod");
|
||||
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
@@ -11,19 +12,10 @@ const NOTIFICATION_PREFERENCES_SCHEMA = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
function buildValidationError(details) {
|
||||
return new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
function parseNotificationPreferences(body) {
|
||||
const parsed = NOTIFICATION_PREFERENCES_SCHEMA.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
throw buildBusinessValidationError("profile.notification_preferences_invalid", parsed.error.flatten());
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const { z } = require("zod");
|
||||
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||
|
||||
const { decryptStringOrThrow, encryptStringOrThrow, maskBankAccount } = require("../services/crypto");
|
||||
const { createNotifications } = require("../services/notifications");
|
||||
@@ -22,14 +23,15 @@ const BANK_BODY_SCHEMA = z.object({
|
||||
}).strict();
|
||||
const BANK_RENAME_BODY_SCHEMA = z.object({ name: z.string().trim().min(1).max(100) }).strict();
|
||||
|
||||
function validationError(details, statusCode = 400) {
|
||||
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
|
||||
}
|
||||
|
||||
function parse(schema, value) {
|
||||
const result = schema.safeParse(value ?? {});
|
||||
if (!result.success) {
|
||||
throw validationError(result.error.flatten());
|
||||
const details = result.error.flatten();
|
||||
if (schema === REVEAL_BODY_SCHEMA) throw buildBusinessValidationError("payment_account.reveal_reason_required", details);
|
||||
if (schema === BANK_BODY_SCHEMA || schema === BANK_RENAME_BODY_SCHEMA) {
|
||||
throw buildBusinessValidationError("payment_account.bank_input_invalid", details);
|
||||
}
|
||||
throw buildBusinessValidationError("payment_account.account_input_invalid", details);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
@@ -37,7 +39,7 @@ function parse(schema, value) {
|
||||
function parseId(params) {
|
||||
const result = UUID_SCHEMA.safeParse(params?.id);
|
||||
if (!result.success) {
|
||||
throw validationError({ id: "invalid_uuid" });
|
||||
throw buildBusinessValidationError("payment_account.id_invalid", { id: "invalid_uuid" });
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
@@ -96,8 +98,8 @@ async function ensureReferences(tx, body) {
|
||||
select: { id: true, code: true, name: true },
|
||||
}),
|
||||
]);
|
||||
if (!legalEntity) throw validationError({ legalEntityId: "not_available" }, 409);
|
||||
if (!bankInstitution) throw validationError({ bankInstitutionId: "not_available" }, 409);
|
||||
if (!legalEntity) throw buildBusinessValidationError("payment_account.legal_entity_unavailable", { legalEntityId: "not_available" }, 409);
|
||||
if (!bankInstitution) throw buildBusinessValidationError("payment_account.bank_unavailable", { bankInstitutionId: "not_available" }, 409);
|
||||
return { legalEntity, bankInstitution };
|
||||
}
|
||||
|
||||
@@ -177,7 +179,7 @@ async function paymentAccountsRoutes(fastify) {
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.code === "P2002") {
|
||||
throw validationError({ code: "already_exists" }, 409);
|
||||
throw buildBusinessValidationError("payment_account.bank_code_exists", { code: "already_exists" }, 409);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -231,7 +233,7 @@ async function paymentAccountsRoutes(fastify) {
|
||||
const inUse = await tx.paymentAccount.count({
|
||||
where: { bank_institution_id: bankId, enabled: true, archived_at: null },
|
||||
});
|
||||
if (inUse > 0) throw validationError({ bankInstitution: "active_payment_account_exists" }, 409);
|
||||
if (inUse > 0) throw buildBusinessValidationError("payment_account.bank_in_use", { bankInstitution: "active_payment_account_exists" }, 409);
|
||||
const now = new Date();
|
||||
await tx.bankInstitution.update({
|
||||
where: { id: bankId },
|
||||
@@ -319,7 +321,7 @@ async function paymentAccountsRoutes(fastify) {
|
||||
});
|
||||
if (!current) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
if (current.legal_entity_id !== body.legalEntityId) {
|
||||
throw validationError({ legalEntityId: "must_match_current_account" });
|
||||
throw buildBusinessValidationError("payment_account.legal_entity_change_forbidden", { legalEntityId: "must_match_current_account" });
|
||||
}
|
||||
await ensureReferences(tx, body);
|
||||
const now = new Date();
|
||||
|
||||
+26
-124
@@ -180,12 +180,7 @@ const APPROVER_ROLE_CODES = BUSINESS_APPROVAL_ROLE_CODES.filter((roleCode) =>
|
||||
function parseRequiredComment(body) {
|
||||
const parsed = REQUIRED_COMMENT_SCHEMA.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
throw buildBusinessValidationError("workflow.comment_invalid", parsed.error.flatten());
|
||||
}
|
||||
|
||||
return parsed.data.comment;
|
||||
@@ -194,12 +189,7 @@ function parseRequiredComment(body) {
|
||||
function parseTransferBody(body) {
|
||||
const parsed = TRANSFER_BODY_SCHEMA.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
throw buildBusinessValidationError("workflow.transfer_input_invalid", parsed.error.flatten());
|
||||
}
|
||||
|
||||
const reason = parsed.data.reason.trim();
|
||||
@@ -221,12 +211,7 @@ function parseTransferBody(body) {
|
||||
function parseTaskId(taskId) {
|
||||
const parsed = TASK_ID_SCHEMA.safeParse(taskId);
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { taskId: "invalid_uuid" },
|
||||
});
|
||||
throw buildBusinessValidationError("workflow.task_id_invalid", { taskId: "invalid_uuid" });
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
@@ -235,12 +220,7 @@ function parseTaskId(taskId) {
|
||||
function parseBatchApproveBody(body) {
|
||||
const parsed = BATCH_APPROVE_BODY_SCHEMA.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
throw buildBusinessValidationError("workflow.batch_approve_input_invalid", parsed.error.flatten());
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -252,12 +232,7 @@ function parseBatchApproveBody(body) {
|
||||
function parseBatchRejectBody(body) {
|
||||
const parsed = BATCH_REJECT_BODY_SCHEMA.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
throw buildBusinessValidationError("workflow.batch_reject_input_invalid", parsed.error.flatten());
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -269,12 +244,7 @@ function parseBatchRejectBody(body) {
|
||||
function parseRemindTaskBody(body) {
|
||||
const parsed = REMIND_TASK_BODY_SCHEMA.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
throw buildBusinessValidationError("workflow.remind_input_invalid", parsed.error.flatten());
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -285,12 +255,7 @@ function parseRemindTaskBody(body) {
|
||||
function parseTaskSummaryQuery(query) {
|
||||
const parsed = TASK_SUMMARY_QUERY_SCHEMA.safeParse(query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
throw buildBusinessValidationError("workflow.summary_query_invalid", parsed.error.flatten());
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -384,12 +349,7 @@ async function handlePurchaseApprovalErpSync({
|
||||
function parseTaskListQuery(query) {
|
||||
const parsed = TASK_LIST_QUERY_SCHEMA.safeParse(query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
throw buildBusinessValidationError("workflow.task_list_query_invalid", parsed.error.flatten(), 400);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -403,12 +363,7 @@ function parseTaskListQuery(query) {
|
||||
function parseApproverListQuery(query) {
|
||||
const parsed = APPROVER_LIST_QUERY_SCHEMA.safeParse(query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
throw buildBusinessValidationError("workflow.approver_query_invalid", parsed.error.flatten(), 400);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -734,12 +689,7 @@ async function lockWorkflowTask(tx, taskId) {
|
||||
|
||||
function ensurePendingTask(task) {
|
||||
if (task.status !== "pending") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { task: "already_processed" },
|
||||
});
|
||||
throw buildBusinessValidationError("workflow.task_already_processed", { task: "already_processed" }, 409);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1049,12 +999,7 @@ async function loadWorkflowApplicantIds(tx, workflowInstance) {
|
||||
);
|
||||
|
||||
if (!record) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { workflow: "entity_not_processable" },
|
||||
});
|
||||
throw buildBusinessValidationError("workflow.entity_unavailable", { workflow: "entity_not_processable" }, 409);
|
||||
}
|
||||
|
||||
return collectWorkflowApplicantIds(workflowInstance, record);
|
||||
@@ -1065,12 +1010,7 @@ function ensureNoSelfApproval(actorIds, applicantIds) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { workflow: "self_approval_not_allowed" },
|
||||
});
|
||||
throw buildBusinessValidationError("workflow.self_approval_forbidden", { workflow: "self_approval_not_allowed" }, 409);
|
||||
}
|
||||
|
||||
async function ensureFinanceOwnerCeoActors(tx, workflowInstance, actorIds) {
|
||||
@@ -1364,22 +1304,12 @@ async function ensureTransferTargetEligible(tx, toApproverId, approvePermission)
|
||||
});
|
||||
|
||||
if (!target || target.status !== "active") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { toApproverId: "invalid_target" },
|
||||
});
|
||||
throw buildBusinessValidationError("workflow.transfer_target_unavailable", { toApproverId: "invalid_target" }, 409);
|
||||
}
|
||||
|
||||
const roleCodes = target.roles.map((item) => item.role_code);
|
||||
if (!roleCodes.some((roleCode) => roleHasBusinessApprovePermission(roleCode, approvePermission))) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { toApproverId: "target_not_approver" },
|
||||
});
|
||||
throw buildBusinessValidationError("workflow.transfer_target_ineligible", { toApproverId: "target_not_approver" }, 409);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1408,16 +1338,11 @@ function ensureWorkflowEntityType(workflowInstance, entityType) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: {
|
||||
throw buildBusinessValidationError("workflow.batch_entity_unsupported", {
|
||||
task: "unsupported_entity_type",
|
||||
entityType: workflowInstance.entity_type,
|
||||
expectedEntityType: entityType,
|
||||
},
|
||||
});
|
||||
}, 409);
|
||||
}
|
||||
|
||||
async function ensureWorkflowReminderAllowed(tx, workflowInstance, task, request) {
|
||||
@@ -1436,12 +1361,7 @@ async function ensureWorkflowReminderAllowed(tx, workflowInstance, task, request
|
||||
assigneeRole: task.assignee_role,
|
||||
});
|
||||
if (recipientEmployeeIds.includes(request.employee.id)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { workflow: "self_remind_not_allowed" },
|
||||
});
|
||||
throw buildBusinessValidationError("workflow.self_remind_forbidden", { workflow: "self_remind_not_allowed" }, 409);
|
||||
}
|
||||
|
||||
return applicantIds;
|
||||
@@ -1468,15 +1388,12 @@ async function ensureWorkflowReminderCooldown(tx, taskId, actorId, now) {
|
||||
}
|
||||
|
||||
const nextAllowedAt = new Date(existing.created_at.getTime() + WORKFLOW_REMINDER_COOLDOWN_MS);
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "催办过于频繁",
|
||||
statusCode: 409,
|
||||
details: {
|
||||
workflow: "remind_too_frequent",
|
||||
nextAllowedAt: nextAllowedAt.toISOString(),
|
||||
},
|
||||
});
|
||||
const error = buildBusinessValidationError("workflow.remind_cooldown", {
|
||||
workflow: "remind_too_frequent",
|
||||
nextAllowedAt: nextAllowedAt.toISOString(),
|
||||
}, 409);
|
||||
error.message = "催办过于频繁";
|
||||
throw error;
|
||||
}
|
||||
|
||||
async function executeRemindWorkflowTask({
|
||||
@@ -1586,12 +1503,7 @@ async function executeApproveWorkflowTask({
|
||||
const steps = getWorkflowStepsSnapshot(workflowInstance);
|
||||
const currentStepIndex = steps.findIndex((step) => step.id === workflowInstance.current_step_id);
|
||||
if (currentStepIndex < 0) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { workflow: "current_step_not_found" },
|
||||
});
|
||||
throw buildBusinessValidationError("workflow.step_missing", { workflow: "current_step_not_found" }, 409);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
@@ -3215,12 +3127,7 @@ async function workflowRoutes(fastify) {
|
||||
"approve",
|
||||
);
|
||||
if (!approvePermission) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { transfer: "unsupported_entity_type" },
|
||||
});
|
||||
throw buildBusinessValidationError("workflow.transfer_unsupported", { transfer: "unsupported_entity_type" }, 409);
|
||||
}
|
||||
const applicantIds = await ensureWorkflowApprovalAllowed(tx, workflowInstance, request, assignment);
|
||||
const transferCount = await ensureTransferChainNotExceeded(tx, taskId);
|
||||
@@ -3229,12 +3136,7 @@ async function workflowRoutes(fastify) {
|
||||
await ensureFinanceOwnerCeoActors(tx, workflowInstance, [toApproverId]);
|
||||
|
||||
if (toApproverId === request.employee.id) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { transfer: "same_assignee_not_allowed" },
|
||||
});
|
||||
throw buildBusinessValidationError("workflow.transfer_same_assignee", { transfer: "same_assignee_not_allowed" }, 409);
|
||||
}
|
||||
|
||||
await tx.workflowTask.update({
|
||||
|
||||
在新工单中引用
屏蔽一个用户