fix: 完善动态错误指引与发票日期校验(09-05-error-explainability-payment-readiness)

这个提交包含在:
2026-09-16 12:56:27 +08:00
父节点 b7d2285d8d
当前提交 3516d7ec04
共修改 28 个文件,包含 791 行新增和 616 行删除
+73
查看文件
@@ -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 = [];
+8 -42
查看文件
@@ -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 {
+7 -42
查看文件
@@ -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;
+13 -11
查看文件
@@ -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({
@@ -1,4 +1,77 @@
{
"workflow.task_already_processed": "此审批任务已处理,请刷新待办查看最新进度。",
"workflow.entity_unavailable": "审批对应的单据不存在或当前不可处理,请联系管理员核查。",
"workflow.self_approval_forbidden": "不能审批本人申请或代提交的单据,请由符合资格的其他审批人处理。",
"workflow.transfer_target_unavailable": "转审人不存在或已停用,请重新选择在职审批人。",
"workflow.transfer_target_ineligible": "所选人员没有这类业务的审批资格,请重新选择审批人。",
"workflow.self_remind_forbidden": "当前待办由你处理,无需催办自己,请直接处理待办。",
"workflow.step_missing": "审批流程节点信息异常,请联系管理员核查,不要重复提交。",
"workflow.transfer_unsupported": "此类业务暂不支持转审,请按原审批流程处理。",
"workflow.transfer_same_assignee": "不能转审给自己,请选择其他符合资格的审批人。",
"workflow.task_list_query_invalid": "待办筛选或分页条件无效,请重新选择筛选条件后重试。",
"workflow.approver_query_invalid": "审批人查询条件无效,请缩短关键词并检查分页条件后重试。",
"workflow.batch_entity_unsupported": "批量审批仅支持当前指定业务类型,请取消其他类型任务的选择。",
"workflow.remind_cooldown": "此任务24小时内已由你催办,请勿重复催办,稍后再试。",
"invoice_pool.pdf_required": "请上传 PDF 发票文件",
"invoice_pool.ocr_pdf_too_large": "PDF 发票不能超过 7 MiB",
"invoice_verification.list_query_invalid": "查验记录查询数量无效,请选择1至50条后重试。",
"invoice_pool.single_file_required": "请每次上传一份PDF发票,可分次添加多张发票。",
"invoice_pool.empty_file": "发票文件为空,请重新选择有效的PDF文件。",
"invoice_pool.upload_too_large": "发票文件超过当前上传大小限制,请压缩PDF后重试;仍失败请联系管理员。",
"invoice_pool.file_required": "请先选择要上传的PDF发票。",
"invoice_pool.owner_unavailable": "发票所属员工不存在或已停用,请重新选择在职员工。",
"invoice_pool.ocr_type_invalid": "发票识别类型无效,请重新选择票据类型后上传。",
"invoice_pool.edit_empty": "没有可保存的修改,请修改发票信息后再保存。",
"invoice_pool.not_editable": "此发票当前不能编辑,请刷新查看状态;已关联的发票需先按报销流程解除占用。",
"invoice_pool.date_invalid": "开票日期无效,请填写真实有效的日期。",
"invoice_pool.amount_invalid": "发票金额格式无效,请填写有效金额后重试。",
"invoice_pool.tax_amount_invalid": "发票税额格式无效,请填写有效税额后重试。",
"invoice_pool.total_amount_invalid": "价税合计格式无效,请填写有效金额后重试。",
"invoice_pool.status_transition_invalid": "此发票不能按当前方式更改状态,请刷新页面后重试。",
"invoice_pool.archive_bound": "发票已关联业务单据,不能归档;请先完成业务处理或解除关联。",
"invoice_pool.archive_status_invalid": "此发票当前不能归档,请等待识别完成或刷新查看状态。",
"invoice_pool.ocr_retry_status_invalid": "只有识别失败的发票可以重新识别,请刷新查看当前结果。",
"invoice_pool.ocr_unavailable": "发票OCR识别服务当前不可用,请联系管理员恢复服务后重试。",
"invoice_pool.override_bound": "发票已关联业务单据,不能设置重复票据例外;请先处理原关联。",
"invoice_pool.override_status_invalid": "此发票当前不能设置重复票据例外,请刷新查看状态。",
"invoice_pool.dedup_identity_missing": "发票缺少查重标识,请补全发票代码和号码;仍无法处理请联系管理员。",
"invoice_pool.edit_input_invalid": "发票信息格式无效,请检查日期、金额及必填字段后保存。",
"invoice_pool.override_input_invalid": "请填写有效的关联报销编号和重复票据例外原因。",
"invoice_pool.ocr_fields_missing": "发票识别信息不完整,请补全票种要求的代码、号码、日期、金额及销售方等信息后保存。",
"workflow.comment_invalid": "请填写处理意见,长度为1至500字。",
"workflow.transfer_input_invalid": "请选择有效的转审人,并填写10至500字的转审原因。",
"workflow.batch_approve_input_invalid": "请选择1至50条有效审批任务,审批意见不超过500字。",
"workflow.batch_reject_input_invalid": "请选择1至50条有效审批任务,并填写1至500字的退回意见。",
"workflow.remind_input_invalid": "催办说明不能超过200字,请修改后重试。",
"workflow.summary_query_invalid": "待办统计范围无效,请从待办页面重新进入。",
"workflow.task_id_invalid": "审批任务标识无效,请从待办列表重新打开。",
"invoice_verification.disabled": "发票查验服务暂未启用,请联系管理员确认;发票OCR识别是独立服务。",
"invoice_verification.already_pending": "发票正在查验,请稍后刷新结果,不要重复提交。",
"invoice_verification.record_missing": "此发票还没有查验记录,请先发起查验,再按需重试。",
"auth.login_input_invalid": "请填写登录账号和密码后重试。",
"auth.email_input_invalid": "请输入格式正确的邮箱地址后重试。",
"auth.email_code_input_invalid": "请检查邮箱地址和验证码的填写格式后重试。",
"auth.password_change_input_invalid": "请完整填写原密码和新密码后重试。",
"auth.password_reset_input_invalid": "请完整填写邮箱、验证码和新密码后重试。",
"auth.device_context_missing": "登录设备信息缺失,请刷新登录页面后重试;仍无法登录请联系管理员。",
"auth.password_policy_failed": "新密码需至少12位,包含大小写字母和数字,并避免常见弱密码。",
"auth.deeplink_target_invalid": "此入口的目标地址无效,请从系统首页重新进入对应业务。",
"auth.code_invalid_or_expired": "验证码无效或已过期,请重新获取后重试。",
"profile.notification_preferences_invalid": "通知设置无效,请通过页面开关选择是否接收通知后重试。",
"invoice_pool.list_query_invalid": "票据筛选或分页参数无效,请重置筛选后重试。",
"invoice_pool.selection_query_invalid": "选择发票的报销单或查询条件无效,请返回报销单重新打开“我的发票”。",
"invoice_pool.status_query_invalid": "待查询票据列表无效,请刷新后重试;一次最多查询50张票据。",
"invoice_pool.reimbursement_not_editable": "该报销单当前不可添加发票,请刷新确认状态;仅草稿或退回待修改的单据可继续选票。",
"invoice_pool.id_invalid": "票据标识无效,请返回“我的发票”重新选择。",
"payment_account.reveal_reason_required": "请填写2至500字的查看理由后再查看完整付款账号。",
"payment_account.bank_input_invalid": "请检查银行名称;新增银行还需填写2至16位字母、数字或下划线组成的银行编码。",
"payment_account.account_input_invalid": "请检查所属公司、开户银行、户名及账号;账号需为6至32位数字。",
"payment_account.id_invalid": "账户或银行标识无效,请返回列表重新选择。",
"payment_account.legal_entity_unavailable": "所选公司不存在或已停用,请刷新后选择可用公司;如无可选公司,请联系管理员。",
"payment_account.bank_unavailable": "所选银行不存在或已停用,请刷新后选择真实开户银行,不要为通过检查改填其他银行。",
"payment_account.bank_code_exists": "该银行编码已存在,请使用已有银行或核实编码后重试。",
"payment_account.bank_in_use": "该银行仍有启用的付款账户,暂不能停用;请先核实相关账户的使用安排。",
"payment_account.legal_entity_change_forbidden": "已有付款账户不能直接更换所属公司,请保留原公司,并为其他公司另行维护账户。",
"reimbursement.finance_owner_ceo_unavailable": "财务本人报销需要非申请人的总经理审批,请联系管理员核实审批配置。",
"reimbursement.finance_owner_ceo_required": "财务本人报销只能由非申请人的在职总经理审批或接收转审,不能委托其他角色代批。",
"reimbursement.invoice_prefill_incomplete": "票据日期或金额尚未完整识别,请先处理该票据;其他已识别票据可继续添加。",
+41 -6
查看文件
@@ -214,6 +214,41 @@ async function buildApp(passwordHash) {
}
describe("auth login route", () => {
it.each([
["/login", {}, "login_input_invalid"],
["/email-login/code", { email: "invalid" }, "email_input_invalid"],
["/email-login/verify", {}, "email_code_input_invalid"],
["/change-password", {}, "password_change_input_invalid"],
["/forgot-password", { email: "invalid" }, "email_input_invalid"],
["/reset-password", {}, "password_reset_input_invalid"],
["/change-password", { oldPassword: "SyntheticOld1234", newPassword: "aaaaaaaaaaaa" }, "password_policy_failed"],
])("explains invalid input at %s without authentication side effects", async (url, payload, reason) => {
const state = await buildApp("unused");
try {
const response = await state.app.inject({ method: "POST", url: `/api/auth${url}`, payload,
headers: { "x-device-fingerprint": "synthetic-input-validation" } });
expect(response.statusCode).toBe(400);
expect(response.json().error.details.reasonCode).toBe(`auth.${reason}`);
expect(state.employeeUpdate).not.toHaveBeenCalled();
expect(state.authSessionCreate).not.toHaveBeenCalled();
expect(state.emailLoginCodeCreate).not.toHaveBeenCalled();
expect(state.passwordResetCodeCreate).not.toHaveBeenCalled();
expect(state.auditCreate).not.toHaveBeenCalled();
expect(JSON.stringify(response.json())).not.toContain("SyntheticOld1234");
} finally { await state.app.close(); }
});
it("explains missing device context before looking up an account", async () => {
const state = await buildApp("unused");
try {
const response = await state.app.inject({ method: "POST", url: "/api/auth/login",
payload: { identifier: "[email protected]", password: "SyntheticPassword1234" } });
expect(response.statusCode).toBe(400);
expect(response.json().error.details.reasonCode).toBe("auth.device_context_missing");
expect(state.employeeFindFirst).not.toHaveBeenCalled();
expect(state.authSessionCreate).not.toHaveBeenCalled();
} finally { await state.app.close(); }
});
it("keeps signed deeplink usable after legacy exp and redirects normal browsers to target", async () => {
const passwordHash = await hashPassword("AdminPassword1234");
const { app } = await buildApp(passwordHash);
@@ -268,7 +303,7 @@ describe("auth login route", () => {
});
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ targetPath: "invalid_target_path" });
expect(response.json().error.details).toEqual({ targetPath: "invalid_target_path", reasonCode: "auth.deeplink_target_invalid" });
await app.close();
});
@@ -293,7 +328,7 @@ describe("auth login route", () => {
});
expect(absoluteUrlResponse.statusCode).toBe(400);
expect(absoluteUrlResponse.json().error.details).toEqual({ targetPath: "invalid_target_path" });
expect(absoluteUrlResponse.json().error.details).toEqual({ targetPath: "invalid_target_path", reasonCode: "auth.deeplink_target_invalid" });
await app.close();
});
@@ -566,7 +601,7 @@ describe("auth login route", () => {
});
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ code: "invalid_or_expired" });
expect(response.json().error.details).toEqual({ code: "invalid_or_expired", reasonCode: "auth.code_invalid_or_expired" });
expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({
where: { id: "email-code-1", used_at: null },
data: { attempts_count: { increment: 1 } },
@@ -870,7 +905,7 @@ describe("auth login route", () => {
});
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ code: "invalid_or_expired" });
expect(response.json().error.details).toEqual({ code: "invalid_or_expired", reasonCode: "auth.code_invalid_or_expired" });
expect(employeeUpdate).not.toHaveBeenCalled();
expect(emailLoginCodeUpdateMany).not.toHaveBeenCalled();
expect(auditCreate).not.toHaveBeenCalled();
@@ -966,7 +1001,7 @@ describe("auth login route", () => {
});
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ code: "invalid_or_expired" });
expect(response.json().error.details).toEqual({ code: "invalid_or_expired", reasonCode: "auth.code_invalid_or_expired" });
expect(employeeUpdate).toHaveBeenCalledWith({
where: { id: EMPLOYEE_ID },
data: expect.objectContaining({
@@ -1008,7 +1043,7 @@ describe("auth login route", () => {
});
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ code: "invalid_or_expired" });
expect(response.json().error.details).toEqual({ code: "invalid_or_expired", reasonCode: "auth.code_invalid_or_expired" });
expect(employeeUpdate).not.toHaveBeenCalled();
expect(passwordResetCodeUpdateMany).not.toHaveBeenCalled();
expect(auditCreate).not.toHaveBeenCalled();
@@ -106,6 +106,23 @@ function buildApp({
}
describe("GET /api/invoice-pool/bindable", () => {
it.each([
["GET", "/invoice-pool?page=0", "list_query_invalid"],
["GET", "/invoice-pool/bindable?reimbursementId=invalid", "selection_query_invalid"],
["GET", "/invoice-pool/status", "status_query_invalid"],
["GET", "/invoice-pool/status?ids=invalid", "status_query_invalid"],
["GET", "/invoice-pool/invalid", "id_invalid"],
["POST", "/invoice-pool/invalid/signed-url", "id_invalid"],
])("explains invalid invoice request %s %s before data access", async (method, url, reason) => {
const { app, prisma } = await buildApp();
try {
const response = await app.inject({ method, url: `/api${url}`, headers: { authorization: "Bearer test" } });
expect(response.statusCode).toBe(400);
expect(response.json().error.details.reasonCode).toBe(`invoice_pool.${reason}`);
expect(prisma.invoicePool.findMany).not.toHaveBeenCalled();
expect(prisma.reimbursementForm.findUnique).not.toHaveBeenCalled();
} finally { await app.close(); }
});
it("returns bindable invoices for reimbursement submitter and uses reimbursement owner for filtering", async () => {
const bindableItems = [
{
@@ -199,7 +216,7 @@ describe("GET /api/invoice-pool/bindable", () => {
});
it("rejects reimbursements not in draft or rejected status", async () => {
const { app } = await buildApp({
const { app, prisma } = await buildApp({
reimbursement: {
id: REIMBURSEMENT_ID,
owner_id: OWNER_ID,
@@ -215,7 +232,8 @@ describe("GET /api/invoice-pool/bindable", () => {
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details).toEqual({ reimbursement: "invalid_status_for_bind" });
expect(response.json().error.details).toEqual({ reimbursement: "invalid_status_for_bind", reasonCode: "invoice_pool.reimbursement_not_editable" });
expect(prisma.invoicePool.findMany).not.toHaveBeenCalled();
await app.close();
});
@@ -84,7 +84,7 @@ function buildPrisma(invoiceOverrides = {}) {
return { prisma, state };
}
async function buildApp(invoiceOverrides = {}) {
async function buildApp(invoiceOverrides = {}, options = {}) {
const { prisma, state } = buildPrisma(invoiceOverrides);
const app = Fastify();
@@ -94,7 +94,7 @@ async function buildApp(invoiceOverrides = {}) {
appOrigin: "http://localhost:5173",
maxUploadSizeBytes: 10 * 1024 * 1024,
invoiceArchiveUnusedDays: 30,
ocrProvider: "mock",
ocrProvider: options.ocrProvider ?? "mock",
});
app.decorate("authenticate", async (request) => {
if (!request.headers.authorization) {
@@ -102,7 +102,7 @@ async function buildApp(invoiceOverrides = {}) {
}
request.employee = {
id: OWNER_ID,
permissionCodes: ["invoice.upload"],
permissionCodes: options.permissionCodes ?? ["invoice.upload"],
roleCodes: ["employee"],
};
});
@@ -141,6 +141,53 @@ async function buildApp(invoiceOverrides = {}) {
}
describe("PATCH /api/invoice-pool/:id", () => {
it.each(["2024-02-29", "2026-04-30"])("preserves valid calendar date %s", async (invoiceDate) => {
const { app, state } = await buildApp();
try {
const response = await app.inject({ method: "PATCH", url: `/api/invoice-pool/${INVOICE_ID}`, headers: { authorization: "Bearer test" }, payload: { invoiceDate } });
expect(response.statusCode).toBe(200);
expect(state.invoice.invoice_date.toISOString().slice(0, 10)).toBe(invoiceDate);
} finally {
await app.close();
}
});
it.each([
["PATCH", "", {}, {}, "edit_empty", 400],
["PATCH", "", { amount: 12 }, {}, "edit_input_invalid", 400],
["PATCH", "", { invoiceDate: "2026-02-30" }, {}, "date_invalid", 400],
["PATCH", "", { invoiceDate: "2026-02-29" }, {}, "date_invalid", 400],
["PATCH", "", { invoiceDate: "2026-13-01" }, {}, "date_invalid", 400],
["PATCH", "", { invoiceDate: "2026-04-31" }, {}, "date_invalid", 400],
["PATCH", "", { amount: "invalid" }, {}, "amount_invalid", 400],
["PATCH", "", { taxAmount: "invalid" }, {}, "tax_amount_invalid", 400],
["PATCH", "", { totalAmount: "invalid" }, {}, "total_amount_invalid", 400],
["PATCH", "", { remarks: "synthetic" }, { status: "bound" }, "not_editable", 409],
["PATCH", "", { status: "available" }, { status: "available" }, "status_transition_invalid", 409],
["PATCH", "", { status: "available" }, {}, "ocr_fields_missing", 409],
["POST", "/archive", {}, { linked_reimbursement_id: "occupied" }, "archive_bound", 409],
["POST", "/archive", {}, { status: "pending_ocr" }, "archive_status_invalid", 409],
["POST", "/retry-ocr", {}, { status: "available" }, "ocr_retry_status_invalid", 409],
["POST", "/retry-ocr", {}, {}, "ocr_unavailable", 409],
["POST", "/override-duplicate", {}, {}, "override_input_invalid", 400],
])("explains %s %s %s without writes", async (method, suffix, payload, invoiceOverrides, reason, statusCode) => {
const { app, prisma, state } = await buildApp(invoiceOverrides, {
ocrProvider: "off",
permissionCodes: ["invoice.upload", "expense.finance.override_duplicate"],
});
const before = JSON.stringify(state.invoice);
try {
const response = await app.inject({ method, url: `/api/invoice-pool/${INVOICE_ID}${suffix}`, headers: { authorization: "Bearer test" }, payload });
expect(response.statusCode).toBe(statusCode);
expect(response.json().error.details.reasonCode).toBe(`invoice_pool.${reason}`);
expect(prisma.invoicePool.update).not.toHaveBeenCalled();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
expect(JSON.stringify(state.invoice)).toBe(before);
} finally {
await app.close();
}
});
it("accepts valid invoiceDate and allows marking OCR-failed VAT invoice as available", async () => {
const { app, state } = await buildApp();
@@ -130,6 +130,28 @@ async function buildApp({
}
describe("invoicePool upload route", () => {
it.each([
["", [], 1024, "empty_file"],
["%PDF-1.4 synthetic", [["ocr_type", "unsupported"]], 1024, "ocr_type_invalid"],
["%PDF-1.4 synthetic", [], 8, "upload_too_large"],
])("explains invalid upload without files, records or jobs: %s", async (content, fields, maxUploadSizeBytes, reason) => {
const bossSend = vi.fn();
const { app, prisma, tempDir } = await buildApp({ ocrProvider: "mock", jobDriver: "pgboss", bossSend, maxUploadSizeBytes });
try {
const input = buildMultipartRequest({ filename: "synthetic.pdf", contentType: "application/pdf", content, fields });
const response = await app.inject({ method: "POST", url: "/api/invoice-pool/upload", headers: { authorization: "Bearer test", ...input.headers }, payload: input.payload });
expect(response.statusCode).toBe(400);
expect(response.json().error.details.reasonCode).toBe(`invoice_pool.${reason}`);
expect(prisma.invoicePool.create).not.toHaveBeenCalled();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
expect(bossSend).not.toHaveBeenCalled();
expect(await fs.readdir(tempDir)).toEqual([]);
} finally {
await app.close();
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("consumes the file stream before reading trailing multipart fields", async () => {
const { app, prisma, tempDir } = await buildApp();
@@ -261,7 +283,7 @@ describe("invoicePool upload route", () => {
expect.objectContaining({
code: "VALID_001",
message: "请上传 PDF 发票文件",
details: { file: "pdf_invoice_required" },
details: { file: "pdf_invoice_required", reasonCode: "invoice_pool.pdf_required" },
}),
);
expect(prisma.invoicePool.create).not.toHaveBeenCalled();
@@ -302,6 +324,7 @@ describe("invoicePool upload route", () => {
message: "PDF 发票不能超过 7 MiB",
details: {
file: "ocr_pdf_too_large",
reasonCode: "invoice_pool.ocr_pdf_too_large",
maxBytes: 7 * 1024 * 1024,
},
}),
@@ -204,6 +204,58 @@ async function buildApp(options = {}) {
}
describe("invoicePool verification routes", () => {
it("explains invalid verification limits without reading records", async () => {
const { app, tx } = await buildApp();
try {
const response = await app.inject({ method: "GET", url: `/api/invoice-pool/${INVOICE_ID}/verification?limit=51`, headers: { authorization: "Bearer test" } });
expect(response.statusCode).toBe(400);
expect(response.json().error.details.reasonCode).toBe("invoice_verification.list_query_invalid");
expect(tx.invoiceVerificationRecord.findMany).not.toHaveBeenCalled();
expect(app.boss.send).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
it.each([
[null, "invoice_verification.record_missing"],
[buildVerificationRecord({ status: "pending" }), "invoice_verification.already_pending"],
])("explains blocked retry without writing or enqueueing: %s", async (latestRecord, reasonCode) => {
const { app, tx } = await buildApp({ latestRecord });
try {
const response = await app.inject({
method: "POST",
url: `/api/invoice-pool/${INVOICE_ID}/verification/retry`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details.reasonCode).toBe(reasonCode);
expect(tx.invoiceVerificationRecord.create).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
expect(app.boss.send).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
it("explains invalid history identifiers before reading records", async () => {
const { app, tx } = await buildApp();
try {
const response = await app.inject({
method: "GET",
url: "/api/invoice-pool/invalid/verification",
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ id: "invalid_uuid", reasonCode: "invoice_pool.id_invalid" });
expect(tx.invoicePool.findUnique).not.toHaveBeenCalled();
expect(tx.invoiceVerificationRecord.findMany).not.toHaveBeenCalled();
expect(app.boss.send).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
beforeEach(() => {
vi.clearAllMocks();
});
@@ -268,7 +320,7 @@ describe("invoicePool verification routes", () => {
expect(response.statusCode).toBe(409);
expect(response.json().error).toMatchObject({
code: "VALID_001",
details: { verification: "disabled", featureFlag: "env_hard_off" },
details: { verification: "disabled", featureFlag: "env_hard_off", reasonCode: "invoice_verification.disabled" },
});
expect(tx.invoiceVerificationRecord.create).not.toHaveBeenCalled();
expect(app.boss.send).not.toHaveBeenCalled();
@@ -290,7 +342,7 @@ describe("invoicePool verification routes", () => {
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details).toEqual({ verification: "already_pending" });
expect(response.json().error.details).toEqual({ verification: "already_pending", reasonCode: "invoice_verification.already_pending" });
expect(tx.invoiceVerificationRecord.create).not.toHaveBeenCalled();
} finally {
await app.close();
+13
查看文件
@@ -188,4 +188,17 @@ describe('me routes', () => {
await app.close();
});
it('explains invalid notification settings without changing preferences', async () => {
const { prisma, auditEntries } = createPrismaState();
const app = await buildApp(prisma);
try {
const response = await app.inject({ method: 'PATCH', url: '/api/me/notification-preferences',
headers: { authorization: 'Bearer test-token' }, payload: { notificationPushEnabled: 'false' } });
expect(response.statusCode).toBe(400);
expect(response.json().error.details.reasonCode).toBe('profile.notification_preferences_invalid');
expect(prisma.employee.update).not.toHaveBeenCalled();
expect(auditEntries).toHaveLength(0);
} finally { await app.close(); }
});
});
@@ -65,10 +65,45 @@ async function buildApp(permissionCodes) {
});
app.setErrorHandler((error, request, reply) => reply.status(error.statusCode ?? 500).send({ requestId: request.id, error: { code: error.code ?? "SYS_001", message: error.message, details: error.details ?? {} } }));
await app.register(paymentAccountsRoutes, { prefix: "/api" });
return { app, auditCreate, notificationCreateMany, paymentAccountCreate };
return { app, tx, auditCreate, notificationCreateMany, paymentAccountCreate };
}
describe("payment account routes", () => {
const validBody = { legalEntityId: LEGAL_ENTITY_ID, bankInstitutionId: BANK_ID, accountName: "合成测试公司", accountNumber: SYNTHETIC_ACCOUNT };
it.each([
["account_input_invalid", "/payment-accounts", { ...validBody, accountNumber: "invalid" }, 400],
["bank_input_invalid", "/bank-institutions", { code: "!", name: "" }, 400],
["reveal_reason_required", `/payment-accounts/${ACCOUNT_ID}/reveal`, { reason: "" }, 400],
["id_invalid", "/payment-accounts/invalid/reveal", { reason: "核对资料" }, 400],
["legal_entity_unavailable", "/payment-accounts", validBody, 409],
["bank_unavailable", "/payment-accounts", validBody, 409],
["legal_entity_change_forbidden", `/payment-accounts/${ACCOUNT_ID}/replace`, { ...validBody, legalEntityId: ACTOR_ID }, 400],
["bank_code_exists", "/bank-institutions", { code: "CCB", name: "合成银行" }, 409],
["bank_in_use", `/bank-institutions/${BANK_ID}/disable`, {}, 409],
])("explains %s without successful mutation or sensitive output", async (reason, url, payload, status) => {
const testApp = await buildApp(["*"]);
try {
if (reason === "legal_entity_unavailable") testApp.tx.legalEntity.findFirst.mockResolvedValue(null);
if (reason === "bank_unavailable") testApp.tx.bankInstitution.findFirst.mockResolvedValue(null);
if (reason === "bank_code_exists") testApp.tx.bankInstitution.create = vi.fn().mockRejectedValue({ code: "P2002" });
if (reason === "bank_in_use") {
testApp.tx.bankInstitution.findUnique = vi.fn().mockResolvedValue({ id: BANK_ID });
testApp.tx.paymentAccount.count = vi.fn().mockResolvedValue(1);
}
const response = await testApp.app.inject({ method: "POST", url: `/api${url}`, payload });
expect(response.statusCode).toBe(status);
expect(response.json().error.code).toBe("VALID_001");
expect(response.json().error.details.reasonCode).toBe(`payment_account.${reason}`);
const { getErrorReason } = require("../../../src/constants/errorReasons");
const expected = require("../fixtures/error-reason-messages.json");
expect(getErrorReason(`payment_account.${reason}`).defaultMessage).toBe(expected[`payment_account.${reason}`]);
expect(JSON.stringify(response.json())).not.toContain(SYNTHETIC_ACCOUNT);
expect(testApp.paymentAccountCreate).not.toHaveBeenCalled();
expect(testApp.tx.paymentAccount.update).not.toHaveBeenCalled();
expect(testApp.auditCreate).not.toHaveBeenCalled();
expect(testApp.notificationCreateMany).not.toHaveBeenCalled();
} finally { await testApp.app.close(); }
});
it("returns only a masked account on normal reads", async () => {
const testApp = await buildApp(["payment.account.read"]);
const response = await testApp.app.inject({ method: "GET", url: "/api/payment-accounts" });
@@ -196,6 +196,32 @@ async function buildApp(options = {}) {
}
describe("workflow batch routes", () => {
it.each([
["POST", "/workflow/tasks/invalid/approve", {}, "workflow.task_id_invalid"],
["POST", "/workflow/tasks/11111111-1111-4111-8111-111111111111/reject", { comment: " " }, "workflow.comment_invalid"],
["POST", "/workflow/tasks/11111111-1111-4111-8111-111111111111/transfer", {}, "workflow.transfer_input_invalid"],
["POST", "/workflow/batch-approve", { taskIds: [] }, "workflow.batch_approve_input_invalid"],
["POST", "/workflow/batch-reject", { taskIds: [] }, "workflow.batch_reject_input_invalid"],
["POST", "/workflow/tasks/11111111-1111-4111-8111-111111111111/remind", { comment: "长".repeat(201) }, "workflow.remind_input_invalid"],
["GET", "/workflow/tasks/summary?scope=invalid", undefined, "workflow.summary_query_invalid"],
["GET", "/workflow/tasks?page=0", undefined, "workflow.task_list_query_invalid"],
["GET", "/workflow/approvers?page=0", undefined, "workflow.approver_query_invalid"],
])("rejects malformed %s %s before starting a business transaction", async (method, path, payload, reasonCode) => {
const { app, prisma, state } = await buildApp({
actor: { id: ACTOR_ID, roleCodes: ["manager"], permissionCodes: ["expense.approve", "expense.transfer"] },
});
const before = JSON.stringify([...state.tasks]);
try {
const response = await app.inject({ method, url: `/api${path}`, ...(payload === undefined ? {} : { payload }) });
expect(response.statusCode).toBe(400);
expect(response.json().error.details.reasonCode).toBe(reasonCode);
expect(prisma.$transaction).not.toHaveBeenCalled();
expect(JSON.stringify([...state.tasks])).toBe(before);
} finally {
await app.close();
}
});
it("rejects self approval on a single task action", async () => {
const { app, state } = await buildApp({
reimbursementOwnerId: ACTOR_ID,
@@ -212,6 +238,7 @@ describe("workflow batch routes", () => {
code: "VALID_001",
details: expect.objectContaining({
workflow: "self_approval_not_allowed",
reasonCode: "workflow.self_approval_forbidden",
}),
}),
);
@@ -271,6 +271,7 @@ describe("workflow transfer route", () => {
code: "VALID_001",
details: expect.objectContaining({
toApproverId: "target_not_approver",
reasonCode: "workflow.transfer_target_ineligible",
}),
}),
);
@@ -306,6 +307,7 @@ describe("workflow transfer route", () => {
expect(response.json().error.details).toEqual(
expect.objectContaining({
toApproverId: "target_not_approver",
reasonCode: "workflow.transfer_target_ineligible",
}),
);
expect(tx.workflowTask.update).not.toHaveBeenCalled();
@@ -341,6 +343,7 @@ describe("workflow transfer route", () => {
code: "VALID_001",
details: expect.objectContaining({
workflow: "self_approval_not_allowed",
reasonCode: "workflow.self_approval_forbidden",
}),
}),
);
@@ -34,6 +34,29 @@ describe("client business error projection", () => {
.toBe("报销人尚未维护所属公司,请联系管理员补充后再安排付款。");
});
it.each([
[{ reasonCode: "invoice_pool.ocr_fields_missing", ocr: "missing_required_fields", missingFields: ["invoiceCode", "sellerName"] }, "票据仍缺少关键字段:发票代码、销售方,补齐后才能标记为可用。"],
[{ reasonCode: "workflow.remind_cooldown", workflow: "remind_too_frequent", nextAllowedAt: "2026-09-16T08:30:00.000Z" }, "24 小时内已催办过,请在 2026-09-16 08:30:00 后再试。"],
[{ reasonCode: "auth.password_policy_failed", password: ["密码长度必须 ≥ 12", "密码必须包含数字"] }, "新密码不符合要求:密码长度必须 ≥ 12;密码必须包含数字。"],
])("preserves scoped dynamic guidance alongside reasonCode: %j", (details, expected) => {
expect(resolveErrorMessage(new ApiError({ status: 400, code: "VALID_001", message: "private payload", details }))).toBe(expected);
});
it.each([
{ reasonCode: "invoice_pool.ocr_fields_missing", missingFields: ["/private/secret", "__proto__"], password: ["密码必须包含数字"] },
{ reasonCode: "workflow.remind_cooldown", nextAllowedAt: "/private/secret", password: ["密码必须包含数字"] },
{ reasonCode: "auth.password_policy_failed", password: ["/private/secret"], ocr: "missing_required_fields", missingFields: ["invoiceCode"] },
])("falls back safely for malformed or cross-domain details: %j", (details) => {
expect(resolveErrorMessage(new ApiError({ status: 400, code: "VALID_001", message: "private payload", details })))
.toBe(expectedMessages[details.reasonCode]);
});
it("does not expose unknown fields mixed with known OCR labels", () => {
const details = { reasonCode: "invoice_pool.ocr_fields_missing", missingFields: ["invoiceCode", "/private/secret"], password: ["private payload"] };
expect(resolveErrorMessage(new ApiError({ status: 409, code: "VALID_001", message: "private payload", details })))
.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);
});