fix: 完善动态错误指引与发票日期校验(09-05-error-explainability-payment-readiness)
这个提交包含在:
+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 = [];
|
||||
|
||||
在新工单中引用
屏蔽一个用户