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

这个提交包含在:
2026-09-16 12:56:27 +08:00
父节点 b7d2285d8d
当前提交 3516d7ec04
共修改 28 个文件,包含 791 行新增和 616 行删除
+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 = [];