feat: 完成错误原因合同与付款就绪提示(09-05-error-explainability-payment-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-09-05 19:00:35 +08:00
父节点 ab82fe30c0
当前提交 a837912638
共修改 42 个文件,包含 2867 行新增和 876 行删除
+9 -18
查看文件
@@ -1,3 +1,4 @@
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { z } = require("zod");
const { hasPermission } = require("../constants/permissions");
@@ -60,19 +61,12 @@ const LIST_QUERY_SCHEMA = z.object({
pageSize: z.coerce.number().int().min(1).max(100).optional(),
});
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function parseInput(schema, value) {
const parsed = schema.safeParse(value ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
throw buildBusinessValidationError("attendance_correction.invalid_input", parsed.error.flatten(), 400);
}
return parsed.data;
}
@@ -80,7 +74,7 @@ function parseInput(schema, value) {
function parseUuid(value, key = "id") {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) {
throw buildValidationError({ [key]: "invalid_uuid" });
throw buildBusinessValidationError("attendance_correction.invalid_uuid", { [key]: "invalid_uuid" }, 400);
}
return parsed.data;
}
@@ -93,7 +87,7 @@ function parseOptionalDateTime(value) {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw buildValidationError({ datetime: "invalid" });
throw buildBusinessValidationError("attendance_correction.datetime_invalid", { datetime: "invalid" }, 400);
}
return date;
}
@@ -119,10 +113,7 @@ async function ensureUnifiedFeatureEnabled(fastify, request) {
strictDepartment: false,
});
if (!gate.enabled) {
throw buildValidationError(
{ featureFlag: "attendance_unified_v1_disabled", reason: gate.reason },
409,
);
throw buildBusinessValidationError("attendance_correction.feature_flag_attendance_unified_v1_disabled", { featureFlag: "attendance_unified_v1_disabled", reason: gate.reason }, 409);
}
}
@@ -289,7 +280,7 @@ async function attendanceCorrectionsRoutes(fastify) {
select: { id: true, definition_json: true },
});
if (!workflowDefinition) {
throw buildValidationError({ workflow: "missing_definition" }, 409);
throw buildBusinessValidationError("attendance_correction.workflow_missing_definition", { workflow: "missing_definition" }, 409);
}
const workflowSteps = parseWorkflowSteps(workflowDefinition.definition_json);
const { actionable, skipped } = await resolveActionableStep({
@@ -301,7 +292,7 @@ async function attendanceCorrectionsRoutes(fastify) {
blockOnUnresolvedAssignee: true,
});
if (!actionable) {
throw buildValidationError({ workflow: "no_actionable_approver" }, 409);
throw buildBusinessValidationError("attendance_correction.workflow_no_actionable_approver", { workflow: "no_actionable_approver" }, 409);
}
const workflowInstance = await tx.workflowInstance.create({
@@ -512,7 +503,7 @@ async function attendanceCorrectionsRoutes(fastify) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
if (correction.status !== "reviewing") {
throw buildValidationError({ status: "invalid_cancel_status" }, 409);
throw buildBusinessValidationError("attendance_correction.status_invalid_cancel_status", { status: "invalid_cancel_status" }, 409);
}
const workflowInstance = await tx.workflowInstance.findFirst({
+13 -72
查看文件
@@ -1,3 +1,4 @@
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const crypto = require("node:crypto");
const path = require("node:path");
@@ -273,12 +274,7 @@ async function resolveInvoiceLinkedItem(tx, { reimbursementId, reimbursementDate
select: { id: true },
});
if (!linkedItem) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { linkedItemId: "item_not_in_reimbursement" },
});
throw buildBusinessValidationError("reimbursement.linked_item_id_item_not_in_reimbursement", { linkedItemId: "item_not_in_reimbursement" }, 400);
}
return { linkedItemId: linkedItem.id, createdItem: false };
}
@@ -305,22 +301,12 @@ async function resolveInvoiceLinkedItem(tx, { reimbursementId, reimbursementDate
}
if (invoice.total_amount == null) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { invoice: "missing_total_amount_for_auto_item" },
});
throw buildBusinessValidationError("reimbursement.invoice_amount_required", { invoice: "missing_total_amount_for_auto_item" }, 409);
}
const expenseType = await resolveDefaultExpenseType(tx);
if (!expenseType) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { expenseTypeId: "missing_enabled_expense_type" },
});
throw buildBusinessValidationError("reimbursement.expense_type_required", { expenseTypeId: "missing_enabled_expense_type" }, 409);
}
const expenseDate =
@@ -1023,12 +1009,7 @@ async function invoicePoolRoutes(fastify) {
async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
throw buildBusinessValidationError("reimbursement.invalid_uuid", { id: "invalid_uuid" }, 400);
}
const invoiceId = parsedInvoiceId.data;
@@ -1052,12 +1033,7 @@ async function invoicePoolRoutes(fastify) {
const invoice = invoiceRows[0];
if (invoice.status !== "bound" || (!invoice.linked_reimbursement_id && !invoice.travel_order_id)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { invoice: "not_bound" },
});
throw buildBusinessValidationError("reimbursement.invoice_not_bound", { invoice: "not_bound" }, 409);
}
if (invoice.linked_reimbursement_id) {
@@ -1089,12 +1065,7 @@ async function invoicePoolRoutes(fastify) {
}
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { reimbursement: "invalid_status_for_unbind" },
});
throw buildBusinessValidationError("reimbursement.invoice_unbind_status_invalid", { reimbursement: "invalid_status_for_unbind" }, 409);
}
} else {
if (!fastify.hasPermission(request.employee, "travel.payment.manage")) {
@@ -1457,22 +1428,12 @@ async function invoicePoolRoutes(fastify) {
async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
throw buildBusinessValidationError("reimbursement.invalid_uuid", { id: "invalid_uuid" }, 400);
}
const parsedBody = bindBodySchema.safeParse(request.body ?? {});
if (!parsedBody.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsedBody.error.flatten(),
});
throw buildBusinessValidationError("reimbursement.invalid_input", parsedBody.error.flatten(), 400);
}
const invoiceId = parsedInvoiceId.data;
@@ -1600,21 +1561,11 @@ async function invoicePoolRoutes(fastify) {
}
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { reimbursement: "invalid_status_for_bind" },
});
throw buildBusinessValidationError("reimbursement.invoice_bind_status_invalid", { reimbursement: "invalid_status_for_bind" }, 409);
}
if (invoice.owner_employee_id !== reimbursement.owner_id) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { invoice: "owner_mismatch" },
});
throw buildBusinessValidationError("reimbursement.invoice_owner_mismatch", { invoice: "owner_mismatch" }, 409);
}
const invoiceGuardGate = await evaluateFeatureFlag({
@@ -1625,12 +1576,7 @@ async function invoicePoolRoutes(fastify) {
strictDepartment: true,
});
if (invoiceGuardGate.enabled && !linkedItemId) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { linkedItemId: "required_when_invoice_guard_enabled" },
});
throw buildBusinessValidationError("reimbursement.invoice_item_required", { linkedItemId: "required_when_invoice_guard_enabled" }, 400);
}
const resolvedLink = await resolveInvoiceLinkedItem(tx, {
@@ -1683,12 +1629,7 @@ async function invoicePoolRoutes(fastify) {
const travelOrder = travelOrderRows[0];
if (!["confirmed", "paid"].includes(travelOrder.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { travelOrder: "invalid_status_for_bind" },
});
throw buildBusinessValidationError("reimbursement.travel_invoice_bind_status_invalid", { travelOrder: "invalid_status_for_bind" }, 409);
}
await tx.invoicePool.update({
+4 -18
查看文件
@@ -1,3 +1,4 @@
const { buildBusinessValidationError } = require("../../utils/businessValidationError");
const { z } = require("zod");
const { AppError } = require("../../utils/AppError");
@@ -199,12 +200,7 @@ function registerInvoiceVerificationRoutes(fastify) {
const cooldownMs =
Math.max(1, Number(fastify.config.verificationRetryCooldownSeconds ?? 60)) * 1000;
if (diffMs < cooldownMs) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "too_frequent" },
});
throw buildBusinessValidationError("invoice_verification.too_frequent", { verification: "too_frequent" }, 409);
}
}
@@ -447,22 +443,12 @@ function registerInvoiceVerificationRoutes(fastify) {
Math.max(1, Number(fastify.config.verificationRetryCooldownSeconds ?? 60)) * 1000;
const diffMs = Date.now() - new Date(latest.requested_at).getTime();
if (diffMs < cooldownMs) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "too_frequent" },
});
throw buildBusinessValidationError("invoice_verification.too_frequent", { verification: "too_frequent" }, 409);
}
const attempts = Number(latest.attempts ?? 1) + 1;
if (attempts > Number(fastify.config.verificationMaxAttempts ?? 3)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "attempts_exceeded" },
});
throw buildBusinessValidationError("invoice_verification.attempts_exceeded", { verification: "attempts_exceeded" }, 409);
}
const createdRecord = await tx.invoiceVerificationRecord.create({
+30 -39
查看文件
@@ -1,3 +1,4 @@
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
@@ -134,19 +135,12 @@ const LEAVE_WORKFLOW_SELECT = {
},
};
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function parseLeaveId(value) {
const parsed = z.string().uuid().safeParse(value);
if (!parsed.success) {
throw buildValidationError({ id: "invalid_uuid" });
throw buildBusinessValidationError("leave.invalid_uuid", { id: "invalid_uuid" }, 400);
}
return parsed.data;
}
@@ -154,22 +148,22 @@ function parseLeaveId(value) {
function parseLeaveListQuery(query) {
const parsed = leaveListQuerySchema.safeParse(query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
throw buildBusinessValidationError("leave.invalid_input", parsed.error.flatten(), 400);
}
const dateFrom = parsed.data.dateFrom ? new Date(`${parsed.data.dateFrom}T00:00:00.000Z`) : null;
const dateTo = parsed.data.dateTo ? new Date(`${parsed.data.dateTo}T00:00:00.000Z`) : null;
if (dateFrom && Number.isNaN(dateFrom.getTime())) {
throw buildValidationError({ dateFrom: "invalid" });
throw buildBusinessValidationError("leave.date_from_invalid", { dateFrom: "invalid" }, 400);
}
if (dateTo && Number.isNaN(dateTo.getTime())) {
throw buildValidationError({ dateTo: "invalid" });
throw buildBusinessValidationError("leave.date_to_invalid", { dateTo: "invalid" }, 400);
}
if (dateFrom && dateTo && dateFrom > dateTo) {
throw buildValidationError({ dateTo: "must_be_after_dateFrom" });
throw buildBusinessValidationError("leave.date_to_must_be_after_date_from", { dateTo: "must_be_after_dateFrom" }, 400);
}
if (parsed.data.departmentId && parsed.data.scope !== "all") {
throw buildValidationError({ departmentId: "scope_all_only" });
throw buildBusinessValidationError("leave.department_id_scope_all_only", { departmentId: "scope_all_only" }, 400);
}
return {
@@ -186,7 +180,7 @@ function parseLeaveListQuery(query) {
function parseLeaveBalanceQuery(query) {
const parsed = leaveBalanceQuerySchema.safeParse(query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
throw buildBusinessValidationError("leave.invalid_input", parsed.error.flatten(), 400);
}
return parsed.data;
@@ -195,7 +189,7 @@ function parseLeaveBalanceQuery(query) {
function parseWithdrawBody(body) {
const parsed = withdrawBodySchema.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
throw buildBusinessValidationError("leave.invalid_input", parsed.error.flatten(), 400);
}
return {
@@ -206,14 +200,14 @@ function parseWithdrawBody(body) {
function parseCreateBody(body) {
const parsed = createLeaveSchema.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
throw buildBusinessValidationError("leave.invalid_input", parsed.error.flatten(), 400);
}
const startDate = new Date(`${parsed.data.startDate}T00:00:00.000Z`);
const endDate = new Date(`${parsed.data.endDate}T00:00:00.000Z`);
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
throw buildValidationError({ date: "invalid" });
throw buildBusinessValidationError("leave.date_invalid", { date: "invalid" }, 400);
}
const startSession = parsed.data.startSession ?? "am";
@@ -239,14 +233,14 @@ function parseCreateBody(body) {
function parseWorkingDaysPreviewQuery(query) {
const parsed = workingDaysPreviewQuerySchema.safeParse(query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
throw buildBusinessValidationError("leave.invalid_input", parsed.error.flatten(), 400);
}
const startDate = new Date(`${parsed.data.startDate}T00:00:00.000Z`);
const endDate = new Date(`${parsed.data.endDate}T00:00:00.000Z`);
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
throw buildValidationError({ date: "invalid" });
throw buildBusinessValidationError("leave.date_invalid", { date: "invalid" }, 400);
}
const requestedDays = calculateRequestedDays({
@@ -351,7 +345,7 @@ async function collectCreatePayload(request) {
for await (const part of request.parts()) {
if (part.type === "file") {
if (filePart) {
throw buildValidationError({ file: "only_single_file_supported" });
throw buildBusinessValidationError("leave.file_only_single_file_supported", { file: "only_single_file_supported" }, 400);
}
filePart = part;
} else {
@@ -360,7 +354,7 @@ async function collectCreatePayload(request) {
}
} catch (error) {
if (error?.code === "FST_REQ_FILE_TOO_LARGE") {
throw buildValidationError({ file: "file_too_large" });
throw buildBusinessValidationError("leave.file_file_too_large", { file: "file_too_large" }, 400);
}
throw error;
}
@@ -482,7 +476,7 @@ async function loadLeaveTypeOrThrow(prisma, leaveTypeId) {
});
if (!leaveType || !leaveType.enabled || !leaveType.leave_policy || !leaveType.leave_policy.enabled) {
throw buildValidationError({ leaveTypeId: "missing_active_policy" }, 409);
throw buildBusinessValidationError("leave.leave_type_id_missing_active_policy", { leaveTypeId: "missing_active_policy" }, 409);
}
return leaveType;
@@ -500,14 +494,14 @@ async function resolveLeaveTypeAttachment(
if (!filePart) {
if (requireWhenMissing && requiresAttachment) {
throw buildValidationError({ file: "required" }, 422);
throw buildBusinessValidationError("leave.file_required", { file: "required" }, 422);
}
return null;
}
const mimeAllowlist = new Set(allowedMimeTypes.length ? allowedMimeTypes : DEFAULT_ATTACHMENT_MIME_TYPES);
if (!mimeAllowlist.has(filePart.mimetype)) {
throw buildValidationError({ file: "unsupported_mime_type" });
throw buildBusinessValidationError("leave.file_unsupported_mime_type", { file: "unsupported_mime_type" }, 400);
}
return persistLeaveAttachment({
@@ -807,7 +801,7 @@ async function leaveRoutes(fastify) {
}
if (!["draft", "rejected"].includes(leave.status)) {
throw buildValidationError({ status: "invalid_edit_status" }, 409);
throw buildBusinessValidationError("leave.status_invalid_edit_status", { status: "invalid_edit_status" }, 409);
}
const leaveType = await loadLeaveTypeOrThrow(tx, body.leaveTypeId);
@@ -1007,7 +1001,7 @@ async function leaveRoutes(fastify) {
}
if (!["draft", "rejected"].includes(leave.status)) {
throw buildValidationError({ status: "invalid_submit_status" }, 409);
throw buildBusinessValidationError("leave.status_invalid_submit_status", { status: "invalid_submit_status" }, 409);
}
const activeInstance = await tx.workflowInstance.findFirst({
@@ -1019,12 +1013,12 @@ async function leaveRoutes(fastify) {
select: { id: true },
});
if (activeInstance) {
throw buildValidationError({ submit: "already_in_review" }, 409);
throw buildBusinessValidationError("leave.submit_already_in_review", { submit: "already_in_review" }, 409);
}
const leaveType = await loadLeaveTypeOrThrow(tx, leave.leave_type_id);
if (leaveType.requires_attachment && !leave.attachment_storage_path) {
throw buildValidationError({ file: "required" }, 422);
throw buildBusinessValidationError("leave.file_required", { file: "required" }, 422);
}
const overlappingRequests = await tx.leaveRequest.findMany({
@@ -1061,10 +1055,7 @@ async function leaveRoutes(fastify) {
),
);
if (conflicting) {
throw buildValidationError(
{ leave: "conflict_range", conflictLeaveRequestId: conflicting.id },
422,
);
throw buildBusinessValidationError("leave.leave_conflict_range", { leave: "conflict_range", conflictLeaveRequestId: conflicting.id }, 422);
}
await lockEmployeeForLeave(tx, leave.owner_id);
@@ -1078,7 +1069,7 @@ async function leaveRoutes(fastify) {
if (
toHalfDayUnits(balanceSnapshot.summary.available) < toHalfDayUnits(leave.requested_days)
) {
throw buildValidationError({ leave: "insufficient_balance" }, 422);
throw buildBusinessValidationError("leave.leave_insufficient_balance", { leave: "insufficient_balance" }, 422);
}
const workflowDefinition = await tx.workflowDefinition.findFirst({
@@ -1087,7 +1078,7 @@ async function leaveRoutes(fastify) {
select: { id: true },
});
if (!workflowDefinition) {
throw buildValidationError({ workflow: "missing_definition" });
throw buildBusinessValidationError("leave.workflow_missing_definition", { workflow: "missing_definition" }, 400);
}
const { actionable, skipped } = await resolveActionableStep({
@@ -1099,7 +1090,7 @@ async function leaveRoutes(fastify) {
blockOnUnresolvedAssignee: true,
});
if (!actionable) {
throw buildValidationError({ workflow: "no_actionable_approver" }, 409);
throw buildBusinessValidationError("leave.workflow_no_actionable_approver", { workflow: "no_actionable_approver" }, 409);
}
await tx.leaveRequest.update({
@@ -1244,7 +1235,7 @@ async function leaveRoutes(fastify) {
}
if (leave.status !== "reviewing") {
throw buildValidationError({ status: "invalid_withdraw_status" }, 409);
throw buildBusinessValidationError("leave.status_invalid_withdraw_status", { status: "invalid_withdraw_status" }, 409);
}
const activeInstance = await tx.workflowInstance.findFirst({
@@ -1258,7 +1249,7 @@ async function leaveRoutes(fastify) {
});
if (!activeInstance) {
throw buildValidationError({ withdraw: "no_pending_instance" }, 409);
throw buildBusinessValidationError("leave.withdraw_no_pending_instance", { withdraw: "no_pending_instance" }, 409);
}
const processedTaskCount = await tx.workflowTask.count({
@@ -1268,7 +1259,7 @@ async function leaveRoutes(fastify) {
},
});
if (processedTaskCount > 0) {
throw buildValidationError({ withdraw: "already_processed_by_approver" }, 409);
throw buildBusinessValidationError("leave.withdraw_already_processed_by_approver", { withdraw: "already_processed_by_approver" }, 409);
}
const ledger = await loadLeaveLedgerSummaryForRequest(tx, leaveId);
+151 -196
查看文件
@@ -1,3 +1,6 @@
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { ERROR_REASONS } = require("../constants/errorReasons");
const { resolvePaymentBatchCreationReadiness, paymentReadinessError } = require("../services/paymentBatchReadiness");
const crypto = require("node:crypto");
const path = require("node:path");
@@ -55,6 +58,7 @@ const LIST_QUERY_SCHEMA = z.object({
});
const CANDIDATES_QUERY_SCHEMA = z.object({
paymentChannel: PAYMENT_CHANNEL_SCHEMA.default("bank"),
q: z.string().trim().max(100).optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
@@ -91,19 +95,12 @@ const RECEIPT_FILE_QUERY_SCHEMA = z.object({
const HIGH_RISK_BATCH_THRESHOLD_AMOUNT = 50000;
const PAYMENT_BATCH_DAILY_SEQUENCE_MAX = 999;
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function parseId(value, key = "id") {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) {
throw buildValidationError({ [key]: "invalid_uuid" });
throw buildBusinessValidationError("payment_batch.invalid_identifier", { [key]: "invalid_uuid" }, 400);
}
return parsed.data;
}
@@ -111,7 +108,7 @@ function parseId(value, key = "id") {
function parseBody(schema, body) {
const parsed = schema.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
throw buildBusinessValidationError("payment_batch.invalid_input", parsed.error.flatten(), 400);
}
return parsed.data;
}
@@ -157,7 +154,7 @@ async function ensureBankReceiptForExecutableBatch(tx, batch) {
where: { payment_batch_id: batch.id, archived_at: null },
});
if (receiptCount <= 0) {
throw buildValidationError({ receiptAttachments: "bank_receipt_required" }, 409);
throw buildBusinessValidationError("payment_batch.bank_receipt_required", { receiptAttachments: "bank_receipt_required" }, 409);
}
}
@@ -170,7 +167,7 @@ async function ensureFeatureEnabled(fastify) {
});
if (!gate.enabled) {
throw buildValidationError({ featureFlag: "payment_batch_v1_disabled", reason: gate.reason }, 409);
throw buildBusinessValidationError("payment_batch.department_unavailable", { featureFlag: "payment_batch_v1_disabled", reason: gate.reason }, 409);
}
return gate;
@@ -200,7 +197,7 @@ async function evaluatePaymentBatchDepartments(fastify, prisma, departmentIds) {
async function ensurePaymentBatchDepartmentsEnabled(fastify, prisma, departmentIds) {
const result = await evaluatePaymentBatchDepartments(fastify, prisma, departmentIds);
if (!result.enabled) {
throw buildValidationError({
throw buildBusinessValidationError("payment_batch.department_unavailable", {
featureFlag: "payment_batch_v1_disabled",
reason: result.gate?.reason ?? "department_not_allowed",
}, 409);
@@ -221,7 +218,7 @@ async function loadBatchSourceDepartmentIds(tx, batch) {
select: { id: true, department_id: true },
});
if (sources.length !== entityIds.length) {
throw buildValidationError({ entityIds: "contains_not_found" }, 404);
throw buildBusinessValidationError("payment_batch.source_not_found", { entityIds: "contains_not_found" }, 404);
}
return sources.map((source) => source.department_id ?? null);
}
@@ -421,7 +418,7 @@ async function allocatePaymentBatchNumber(tx, timeZone) {
`;
const nextSequence = parsePaymentBatchDailySequence(rows?.[0]?.batch_no, compactDate) + 1;
if (nextSequence > PAYMENT_BATCH_DAILY_SEQUENCE_MAX) {
throw buildValidationError({ batchNo: "daily_sequence_exhausted" }, 409);
throw buildBusinessValidationError("payment_batch.daily_sequence_exhausted", { batchNo: "daily_sequence_exhausted" }, 409);
}
const sequence = String(nextSequence).padStart(3, "0");
@@ -520,15 +517,20 @@ function buildPayeeSnapshot(config, owner, options = {}) {
let bankAccount = null;
try {
bankAccount = encryptedAccount ? decryptStringOrThrow(config, encryptedAccount) : null;
if (encryptedAccount && !bankAccount) throw new Error("Payee bank account could not be decrypted");
} catch (error) {
if (options.forReadiness) {
return {
bankInstitutionCode: owner?.bank_institution?.code ?? null,
bankAccountEncrypted: encryptedAccount ?? null,
bankAccountUnavailable: true,
};
}
if (options.includeEncrypted) {
throw buildValidationError({ payeeBankAccount: "decrypt_failed" }, 409);
throw buildBusinessValidationError("payment_batch.payee_account_unavailable", { payeeBankAccount: "decrypt_failed" }, 409);
}
throw error;
}
if (encryptedAccount && !bankAccount) {
throw new Error("Payee bank account could not be decrypted");
}
return {
name: owner?.name ?? null,
bankInstitutionId: owner?.bank_institution?.id ?? null,
@@ -539,7 +541,7 @@ function buildPayeeSnapshot(config, owner, options = {}) {
};
}
async function loadEntitySnapshots(tx, entityType, entityIds, config) {
async function loadEntitySnapshots(tx, entityType, entityIds, config, options = {}) {
if (entityType === "reimbursement") {
const items = await tx.reimbursementForm.findMany({
where: { id: { in: entityIds } },
@@ -582,7 +584,7 @@ async function loadEntitySnapshots(tx, entityType, entityIds, config) {
paymentDate: item.payment_date?.toISOString?.()?.slice(0, 10) ?? null,
paymentChannel: item.payment_channel ?? null,
paidAt: item.paid_at?.toISOString?.() ?? null,
payee: buildPayeeSnapshot(config, item.owner, { includeEncrypted: true }),
payee: buildPayeeSnapshot(config, item.owner, { includeEncrypted: true, ...options }),
},
}));
}
@@ -620,19 +622,60 @@ async function loadEntitySnapshots(tx, entityType, entityIds, config) {
}));
}
function ensureBatchEntityStatus(entityType, snapshot) {
if (
entityType === "reimbursement" &&
(snapshot.status !== "approved" || snapshot.snapshotJson?.paidAt)
) {
throw buildValidationError({ entity: `${snapshot.entityCode}:invalid_pay_status` }, 409);
async function loadPaymentCreationFacts(fastify, tx, { entityType, entityIds, paymentChannel, makerId }) {
const snapshots = await loadEntitySnapshots(tx, entityType, entityIds, fastify.config, { forReadiness: true });
const departmentReadiness = new Map();
const flagReads = new Map();
const gatePrisma = { featureFlag: { findUnique(query) {
const key = query.where.flag_key;
if (!flagReads.has(key)) flagReads.set(key, tx.featureFlag.findUnique(query));
return flagReads.get(key);
} } };
for (const departmentId of new Set(snapshots.map((snapshot) => snapshot.departmentId ?? null))) {
departmentReadiness.set(departmentId, await evaluatePaymentBatchDepartments(fastify, gatePrisma, [departmentId]));
}
if (
entityType === "travel_order" &&
(snapshot.status !== "confirmed" || snapshot.snapshotJson?.paidAt)
) {
throw buildValidationError({ entity: `${snapshot.entityCode}:invalid_pay_status` }, 409);
const companyIds = [...new Set(snapshots.map((snapshot) => snapshot.legalEntityId).filter(Boolean))];
const accounts = paymentChannel === "bank" && companyIds.length ? await tx.paymentAccount.findMany({
where: {
legal_entity_id: { in: companyIds }, enabled: true, archived_at: null,
bank_institution: { code: "CCB", enabled: true, archived_at: null },
},
include: PAYMENT_ACCOUNT_INCLUDE,
orderBy: { id: "asc" },
}) : [];
const accountReadiness = new Map();
for (const paymentAccount of accounts) {
if (accountReadiness.has(paymentAccount.legal_entity_id)) continue;
let paymentAccountSnapshot = null;
try {
const accountNumber = decryptStringOrThrow(fastify.config, paymentAccount.account_number_encrypted);
if (accountNumber) {
paymentAccountSnapshot = {
paymentAccountId: paymentAccount.id,
legalEntityId: paymentAccount.legal_entity_id,
accountName: paymentAccount.account_name,
bankInstitutionId: paymentAccount.bank_institution.id,
bankInstitutionCode: paymentAccount.bank_institution.code,
bankName: paymentAccount.bank_institution.name,
accountNumberEncrypted: paymentAccount.account_number_encrypted,
accountNumberMasked: maskBankAccount(accountNumber),
};
}
} catch {}
accountReadiness.set(paymentAccount.legal_entity_id, { paymentAccount, paymentAccountSnapshot });
}
const reservedItems = await tx.paymentBatchItem.findMany({
where: {
entity_id: { in: entityIds },
payment_batch: { entity_type: entityType, status: { not: "cancelled" } },
},
select: { entity_id: true },
});
const reviewerIds = await resolvePaymentReviewRecipientIds(tx, makerId);
return {
entityType, entityIds, paymentChannel, snapshots, departmentReadiness, accountReadiness,
reservedEntityIds: new Set(reservedItems.map((item) => item.entity_id)), reviewerIds,
};
}
function normalizeSnapshotText(value) {
@@ -972,13 +1015,13 @@ async function executeReimbursementPayment(tx, { batch, item, actorId, index, re
throw buildNotFoundError();
}
if (current.status === "paid") {
throw buildValidationError({ payment: "already_paid_conflict" }, 409);
throw buildBusinessValidationError("payment_batch.already_paid_conflict", { payment: "already_paid_conflict" }, 409);
}
if (current.status !== "approved") {
throw buildValidationError({ status: "invalid_pay_status" }, 409);
throw buildBusinessValidationError("payment_batch.source_status_invalid", { status: "invalid_pay_status" }, 409);
}
if (toMoneyString(current.total_amount) !== toMoneyString(item.amount)) {
throw buildValidationError({ payment: "source_amount_changed" }, 409);
throw buildBusinessValidationError("payment_batch.source_amount_changed", { payment: "source_amount_changed" }, 409);
}
const voucher = normalizeNullableString(item.payment_voucher_no_override) ??
@@ -1186,13 +1229,13 @@ async function executeTravelOrderPayment(tx, { batch, item, actorId, index, time
throw buildNotFoundError();
}
if (current.status === "paid") {
throw buildValidationError({ payment: "already_paid_conflict" }, 409);
throw buildBusinessValidationError("payment_batch.already_paid_conflict", { payment: "already_paid_conflict" }, 409);
}
if (current.status !== "confirmed") {
throw buildValidationError({ status: "invalid_pay_status" }, 409);
throw buildBusinessValidationError("payment_batch.source_status_invalid", { status: "invalid_pay_status" }, 409);
}
if (toMoneyString(current.amount) !== toMoneyString(item.amount) || current.currency !== item.currency_code) {
throw buildValidationError({ payment: "source_amount_changed" }, 409);
throw buildBusinessValidationError("payment_batch.source_amount_changed", { payment: "source_amount_changed" }, 409);
}
const voucher = normalizeNullableString(item.payment_voucher_no_override) ??
@@ -1213,7 +1256,7 @@ async function executeTravelOrderPayment(tx, { batch, item, actorId, index, time
select: { id: true },
});
if (!matchedTransaction) {
throw buildValidationError({ payment: "missing_settled_transaction" }, 409);
throw buildBusinessValidationError("payment_batch.settled_transaction_required", { payment: "missing_settled_transaction" }, 409);
}
await tx.travelOrder.update({
@@ -1293,12 +1336,7 @@ async function readBoundedStream(stream, maxBytes) {
}
function buildReceiptAttachmentBlockedError(reasonCode) {
return new AppError({
code: "VALID_001",
message: "付款回单未通过安全校验",
statusCode: 409,
details: { receiptAttachment: "security_validation_failed", reason: reasonCode },
});
return buildBusinessValidationError("payment_batch.receipt_security_failed", { receiptAttachment: "security_validation_failed", reason: reasonCode }, 409);
}
async function auditBlockedReceiptDownload({ fastify, request, batchId, attachment, reasonCode, downloadReason }) {
@@ -1351,7 +1389,7 @@ async function readReceiptUploadFiles(fastify, request) {
}
const buffer = await readBoundedStream(part.file, maxBytes);
if (part.fieldname !== "files") {
throw buildValidationError({ file: "invalid_field" });
throw buildBusinessValidationError("payment_batch.receipt_field_invalid", { file: "invalid_field" }, 400);
}
files.push(await validateAndNormalizePurchaseAttachment({
buffer,
@@ -1363,22 +1401,22 @@ async function readReceiptUploadFiles(fastify, request) {
}
} catch (error) {
if (error instanceof ReceiptAttachmentSizeLimitError || error?.code === "FST_REQ_FILE_TOO_LARGE") {
throw buildValidationError({ file: "file_too_large" });
throw buildBusinessValidationError("payment_batch.receipt_too_large", { file: "file_too_large" }, 400);
}
if (error?.code === "FST_FILES_LIMIT") {
throw buildValidationError({ file: "too_many_files" });
throw buildBusinessValidationError("payment_batch.receipt_too_many", { file: "too_many_files" }, 400);
}
if (error?.code === "FST_FIELDS_LIMIT" || error?.code === "FST_PARTS_LIMIT") {
throw buildValidationError({ file: "multipart_limit_exceeded" });
throw buildBusinessValidationError("payment_batch.receipt_upload_limit", { file: "multipart_limit_exceeded" }, 400);
}
if (error instanceof PurchaseAttachmentPolicyError) {
throw buildValidationError(error.details ?? { file: error.reasonCode });
throw buildBusinessValidationError("payment_batch.receipt_file_invalid", error.details ?? { file: error.reasonCode }, 400);
}
throw error;
}
if (!files.length) {
throw buildValidationError({ file: "missing" });
throw buildBusinessValidationError("payment_batch.receipt_required", { file: "missing" }, 400);
}
return files;
@@ -1389,24 +1427,12 @@ async function paymentBatchesRoutes(fastify) {
await ensureFeatureEnabled(fastify);
ensureBatchManagePermission(fastify, request);
const parsed = CANDIDATES_QUERY_SCHEMA.safeParse(request.query ?? {});
if (!parsed.success) throw buildValidationError(parsed.error.flatten());
const { q, page, pageSize } = parsed.data;
if (!parsed.success) throw buildBusinessValidationError("payment_batch.invalid_input", parsed.error.flatten(), 400);
const { q, page, pageSize, paymentChannel } = parsed.data;
const reservedItems = await fastify.prisma.paymentBatchItem.findMany({
where: {
payment_batch: {
entity_type: "reimbursement",
status: { not: "cancelled" },
},
},
distinct: ["entity_id"],
select: { entity_id: true },
});
const reservedIds = reservedItems.map((item) => item.entity_id);
const where = {
status: "approved",
paid_at: null,
...(reservedIds.length ? { id: { notIn: reservedIds } } : {}),
...(q
? {
OR: [
@@ -1433,26 +1459,15 @@ async function paymentBatchesRoutes(fastify) {
},
orderBy: [{ reimbursement_date: "desc" }, { id: "desc" }],
});
const departmentGateCache = new Map();
const eligibleCandidates = [];
for (const candidate of allCandidates) {
const departmentId = candidate.department_id ?? null;
const cacheKey = departmentId ?? "__missing_department__";
let enabled = departmentGateCache.get(cacheKey);
if (enabled == null) {
const result = await evaluatePaymentBatchDepartments(fastify, fastify.prisma, [departmentId]);
enabled = result.enabled;
departmentGateCache.set(cacheKey, enabled);
}
if (enabled) {
eligibleCandidates.push(candidate);
}
}
const total = eligibleCandidates.length;
const candidates = eligibleCandidates.slice((page - 1) * pageSize, page * pageSize);
const total = allCandidates.length;
const candidates = allCandidates.slice((page - 1) * pageSize, page * pageSize);
const facts = await loadPaymentCreationFacts(fastify, fastify.prisma, {
entityType: "reimbursement", entityIds: candidates.map((candidate) => candidate.id), paymentChannel, makerId: request.employee.id,
});
return sendData(reply, request, {
items: candidates.map((item) => ({
readiness: resolvePaymentBatchCreationReadiness({ ...facts, entityIds: [item.id] }),
entityId: item.id,
entityType: "reimbursement",
businessNo: item.form_number ?? "待编号报销单",
@@ -1472,11 +1487,22 @@ async function paymentBatchesRoutes(fastify) {
});
});
fastify.post("/payment-batches/readiness", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureBatchManagePermission(fastify, request);
const body = parseBody(CREATE_SCHEMA.pick({ entityType: true, entityIds: true, defaultPaymentChannel: true }), request.body);
const entityIds = [...new Set(body.entityIds)];
const facts = await loadPaymentCreationFacts(fastify, fastify.prisma, {
entityType: body.entityType, entityIds, paymentChannel: body.defaultPaymentChannel ?? "bank", makerId: request.employee.id,
});
return sendData(reply, request, resolvePaymentBatchCreationReadiness(facts));
});
fastify.get("/payment-batches", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureBatchReadPermission(fastify, request);
const queryParsed = LIST_QUERY_SCHEMA.safeParse(request.query ?? {});
if (!queryParsed.success) throw buildValidationError(queryParsed.error.flatten());
if (!queryParsed.success) throw buildBusinessValidationError("payment_batch.invalid_input", queryParsed.error.flatten(), 400);
const query = queryParsed.data;
const items = await fastify.prisma.paymentBatch.findMany({
where: {
@@ -1510,97 +1536,20 @@ async function paymentBatchesRoutes(fastify) {
ensureBatchManagePermission(fastify, request);
const body = parseBody(CREATE_SCHEMA, request.body);
if (body.entityType !== "reimbursement") {
throw buildValidationError({ entityType: "travel_payment_frozen" }, 409);
throw buildBusinessValidationError("payment_batch.travel_payment_frozen", { entityType: "travel_payment_frozen" }, 409);
}
const entityIds = Array.from(new Set(body.entityIds)).sort();
const { batch: created, notificationTask } = await fastify.prisma.$transaction(async (tx) => {
await lockPaymentEntities(tx, body.entityType, entityIds);
const snapshots = await loadEntitySnapshots(tx, body.entityType, entityIds, fastify.config);
if (snapshots.length !== entityIds.length) {
throw buildValidationError({ entityIds: "contains_not_found" }, 404);
}
await ensurePaymentBatchDepartmentsEnabled(
fastify,
tx,
snapshots.map((snapshot) => snapshot.departmentId ?? null),
);
for (const snapshot of snapshots) {
ensureBatchEntityStatus(body.entityType, snapshot);
}
const isBankBatch = (body.defaultPaymentChannel ?? "bank") === "bank";
let paymentAccount = null;
let paymentAccountSnapshot = null;
let legalEntityId = null;
if (isBankBatch) {
const legalEntityIds = Array.from(
new Set(snapshots.map((snapshot) => snapshot.legalEntityId).filter(Boolean)),
);
if (legalEntityIds.length !== 1 || snapshots.some((snapshot) => !snapshot.legalEntityId)) {
throw buildValidationError({ legalEntity: "single_legal_entity_required" }, 409);
}
legalEntityId = legalEntityIds[0];
const invalidPayee = snapshots.find((snapshot) => {
const payee = snapshot.snapshotJson?.payee;
return payee?.bankInstitutionCode !== "CCB" || !payee?.bankAccountEncrypted;
});
if (invalidPayee) {
throw buildValidationError({
payeeBankAccount: `${invalidPayee.entityCode}:ccb_account_required`,
}, 409);
}
paymentAccount = await tx.paymentAccount.findFirst({
where: {
legal_entity_id: legalEntityId,
enabled: true,
archived_at: null,
bank_institution: { code: "CCB", enabled: true, archived_at: null },
},
include: PAYMENT_ACCOUNT_INCLUDE,
});
if (!paymentAccount) {
throw buildValidationError({ paymentAccount: "active_ccb_account_required" }, 409);
}
let paymentAccountNumber;
try {
paymentAccountNumber = decryptStringOrThrow(
fastify.config,
paymentAccount.account_number_encrypted,
);
} catch (error) {
throw buildValidationError({ paymentAccount: "decrypt_failed" }, 409);
}
paymentAccountSnapshot = {
paymentAccountId: paymentAccount.id,
legalEntityId,
accountName: paymentAccount.account_name,
bankInstitutionId: paymentAccount.bank_institution.id,
bankInstitutionCode: paymentAccount.bank_institution.code,
bankName: paymentAccount.bank_institution.name,
accountNumberEncrypted: paymentAccount.account_number_encrypted,
accountNumberMasked: maskBankAccount(paymentAccountNumber),
};
}
const reservedItems = await tx.paymentBatchItem.findMany({
where: {
entity_id: { in: entityIds },
payment_batch: {
entity_type: body.entityType,
status: { not: "cancelled" },
},
},
select: { entity_id: true },
const facts = await loadPaymentCreationFacts(fastify, tx, {
entityType: body.entityType, entityIds, paymentChannel: body.defaultPaymentChannel ?? "bank", makerId: request.employee.id,
});
if (reservedItems.length) {
throw buildValidationError({ entityIds: "contains_active_payment_batch" }, 409);
}
const recipientEmployeeIds = await resolvePaymentReviewRecipientIds(tx, request.employee.id);
if (!recipientEmployeeIds.length) {
throw buildValidationError({ review: "no_eligible_reviewer" }, 409);
}
const readiness = resolvePaymentBatchCreationReadiness(facts);
if (!readiness.ready) throw paymentReadinessError(readiness, facts.departmentReadiness);
const { snapshots, reviewerIds: recipientEmployeeIds } = facts;
const legalEntityId = facts.paymentChannel === "bank" ? snapshots[0].legalEntityId : null;
const { paymentAccount = null, paymentAccountSnapshot = null } = facts.accountReadiness.get(legalEntityId) ?? {};
const allocatedNumber = await allocatePaymentBatchNumber(tx, fastify.config.appTimezone);
@@ -1786,19 +1735,19 @@ async function paymentBatchesRoutes(fastify) {
const batch = await loadBatchWithItems(fastify.prisma, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, fastify.prisma, batch);
if (!["reviewed", "failed"].includes(batch.status)) {
throw buildValidationError({ status: "payee_bank_reveal_not_available" }, 409);
throw buildBusinessValidationError("payment_batch.account_reveal_unavailable", { status: "payee_bank_reveal_not_available" }, 409);
}
const item = batch.items.find((candidate) => candidate.id === itemId);
if (!item) throw buildNotFoundError();
const encryptedAccount = item.snapshot_json?.payee?.bankAccountEncrypted;
if (!encryptedAccount) {
throw buildValidationError({ payeeBankAccount: "legacy_snapshot_missing" }, 409);
throw buildBusinessValidationError("payment_batch.payee_snapshot_missing", { payeeBankAccount: "legacy_snapshot_missing" }, 409);
}
let bankAccount;
try {
bankAccount = decryptStringOrThrow(fastify.config, encryptedAccount);
} catch (error) {
throw buildValidationError({ payeeBankAccount: "decrypt_failed" }, 409);
throw buildBusinessValidationError("payment_batch.payee_account_unavailable", { payeeBankAccount: "decrypt_failed" }, 409);
}
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
@@ -1860,7 +1809,7 @@ async function paymentBatchesRoutes(fastify) {
const batch = await loadBatchWithItems(fastify.prisma, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, fastify.prisma, batch);
if (!["reviewed", "failed"].includes(batch.status)) {
throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409);
throw buildBusinessValidationError("payment_batch.receipt_not_editable", { status: "receipt_attachment_not_editable" }, 409);
}
const files = await readReceiptUploadFiles(fastify, request);
@@ -1886,7 +1835,7 @@ async function paymentBatchesRoutes(fastify) {
const lockedBatch = await lockBatchWithItems(tx, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, lockedBatch);
if (!["reviewed", "failed"].includes(lockedBatch.status)) {
throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409);
throw buildBusinessValidationError("payment_batch.receipt_not_editable", { status: "receipt_attachment_not_editable" }, 409);
}
const created = [];
@@ -1970,7 +1919,7 @@ async function paymentBatchesRoutes(fastify) {
const batchId = parseId(request.params?.id);
const attachmentId = parseId(request.params?.attachmentId, "attachmentId");
const queryParsed = RECEIPT_FILE_QUERY_SCHEMA.safeParse(request.query ?? {});
if (!queryParsed.success) throw buildValidationError(queryParsed.error.flatten());
if (!queryParsed.success) throw buildBusinessValidationError("payment_batch.invalid_input", queryParsed.error.flatten(), 400);
const previewMode = queryParsed.data.mode === "preview";
const batch = await loadBatchWithItems(fastify.prisma, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, fastify.prisma, batch);
@@ -1998,13 +1947,13 @@ async function paymentBatchesRoutes(fastify) {
throw buildNotFoundError();
}
if (attachment.archived_at) {
throw buildValidationError({ receiptAttachment: "voided" }, 409);
throw buildBusinessValidationError("payment_batch.receipt_voided", { receiptAttachment: "voided" }, 409);
}
const downloadReason = previewMode
? "付款批次内在线预览"
: sanitizeDownloadReason(queryParsed.data.reason);
if (!previewMode && requiresDownloadReason(attachment) && !downloadReason) {
throw buildValidationError({ reason: "required" });
throw buildBusinessValidationError("payment_batch.download_reason_required", { reason: "required" }, 400);
}
if (attachment.governance_status === "quarantined") {
@@ -2146,7 +2095,7 @@ async function paymentBatchesRoutes(fastify) {
await ensureFeatureEnabled(fastify);
parseId(request.params?.id);
parseId(request.params?.attachmentId, "attachmentId");
throw buildValidationError({ receiptAttachment: "void_required" }, 409);
throw buildBusinessValidationError("payment_batch.receipt_void_required", { receiptAttachment: "void_required" }, 409);
});
fastify.post("/payment-batches/:id/receipt-attachments/:attachmentId/void", { preHandler: fastify.authenticate }, async (request, reply) => {
@@ -2160,7 +2109,7 @@ async function paymentBatchesRoutes(fastify) {
const batch = await lockBatchWithItems(tx, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
if (!["reviewed", "failed"].includes(batch.status)) {
throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409);
throw buildBusinessValidationError("payment_batch.receipt_not_editable", { status: "receipt_attachment_not_editable" }, 409);
}
const attachment = await tx.paymentBatchReceiptAttachment.findFirst({
where: { id: attachmentId, payment_batch_id: batchId },
@@ -2169,7 +2118,7 @@ async function paymentBatchesRoutes(fastify) {
throw buildNotFoundError();
}
if (attachment.archived_at) {
throw buildValidationError({ receiptAttachment: "already_voided" }, 409);
throw buildBusinessValidationError("payment_batch.receipt_already_voided", { receiptAttachment: "already_voided" }, 409);
}
const voidedAt = new Date();
@@ -2212,14 +2161,14 @@ async function paymentBatchesRoutes(fastify) {
const batch = await lockBatchWithItems(tx, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
if (!["reviewed", "failed"].includes(batch.status)) {
throw buildValidationError({ status: "payment_metadata_not_editable" }, 409);
throw buildBusinessValidationError("payment_batch.metadata_not_editable", { status: "payment_metadata_not_editable" }, 409);
}
const item = batch.items.find((entry) => entry.id === itemId);
if (!item) {
throw buildNotFoundError();
}
if (!["pending", "failed"].includes(item.status)) {
throw buildValidationError({ item: "only_open_item_editable" }, 409);
throw buildBusinessValidationError("payment_batch.item_not_editable", { item: "only_open_item_editable" }, 409);
}
const before = {
@@ -2276,16 +2225,16 @@ async function paymentBatchesRoutes(fastify) {
const isFirstReview = batch.status === "draft" && !batch.reviewed_at;
if (!isFirstReview) {
throw buildValidationError({ review: "batch_not_reviewable" }, 409);
throw buildBusinessValidationError("payment_batch.batch_not_reviewable", { review: "batch_not_reviewable" }, 409);
}
if (batch.items.some((item) => item.status !== "pending")) {
throw buildValidationError({ items: "all_items_must_be_pending" }, 409);
throw buildBusinessValidationError("payment_batch.items_not_pending", { items: "all_items_must_be_pending" }, 409);
}
if (!batch.created_by) {
throw buildValidationError({ review: "maker_identity_missing" }, 409);
throw buildBusinessValidationError("payment_batch.maker_required", { review: "maker_identity_missing" }, 409);
}
if (batch.created_by === request.employee.id) {
throw buildValidationError({ review: "maker_checker_conflict" }, 409);
throw buildBusinessValidationError("payment_batch.maker_checker_conflict", { review: "maker_checker_conflict" }, 409);
}
const openRiskFlags = await tx.entityRiskFlag.findMany({
where: {
@@ -2297,7 +2246,7 @@ async function paymentBatchesRoutes(fastify) {
const resolveIds = new Set(body.resolveRiskFlagIds ?? []);
const unresolved = openRiskFlags.filter((item) => !resolveIds.has(item.id));
if (unresolved.length) {
throw buildValidationError({ resolveRiskFlagIds: "open_risk_flags_must_be_resolved" }, 409);
throw buildBusinessValidationError("payment_batch.risk_resolution_required", { resolveRiskFlagIds: "open_risk_flags_must_be_resolved" }, 409);
}
if (openRiskFlags.length) {
@@ -2377,10 +2326,10 @@ async function paymentBatchesRoutes(fastify) {
const batch = await lockBatchWithItems(tx, batchId);
const batchRisk = summarizeBatchRisk(batch);
if (!["reviewed", "failed"].includes(batch.status)) {
throw buildValidationError({ status: "only_reviewed_or_failed_executable" }, 409);
throw buildBusinessValidationError("payment_batch.batch_not_executable", { status: "only_reviewed_or_failed_executable" }, 409);
}
if (batchRisk.reviewRequirement.completedReviewCount < batchRisk.reviewRequirement.requiredReviewCount) {
throw buildValidationError({ review: "batch_review_required" }, 409);
throw buildBusinessValidationError("payment_batch.review_required", { review: "batch_review_required" }, 409);
}
await lockPaymentEntities(
tx,
@@ -2448,6 +2397,7 @@ async function paymentBatchesRoutes(fastify) {
}
const failureCode = error.code;
const failureMessage = error.message;
const failureReason = ERROR_REASONS.find((reason) => reason.code === error.details?.reasonCode);
await tx.paymentBatchItem.update({
where: { id: item.id },
data: {
@@ -2457,21 +2407,26 @@ async function paymentBatchesRoutes(fastify) {
},
});
finalItemStatuses.set(item.id, "failed");
await ensureRiskFlag(tx, {
const riskDetail = failureReason?.defaultMessage ?? "付款登记未完成,请核对单据状态和付款资料后重试,仍无法完成请联系管理员。";
const riskPayload = {
batchId: batch.id, itemId: item.id, failureCode,
...(failureReason ? { reasonCode: failureReason.code } : {}),
};
const riskFlag = await ensureRiskFlag(tx, {
entityType: batch.entity_type,
entityId: item.entity_id,
riskCode: "payment_batch_execute_failed",
level: "high",
title: "支付批次执行失败",
detail: `${item.entity_code} 执行失败:${failureMessage}`,
detail: riskDetail,
source: "payment_batch",
actorId: request.employee.id,
payloadJson: {
batchId: batch.id,
itemId: item.id,
failureCode,
},
payloadJson: riskPayload,
});
if (riskFlag.detail !== riskDetail || riskFlag.payload_json?.batchId !== batch.id
|| riskFlag.payload_json?.itemId !== item.id || riskFlag.payload_json?.reasonCode !== failureReason?.code) {
await tx.entityRiskFlag.update({ where: { id: riskFlag.id }, data: { detail: riskDetail, payload_json: riskPayload } });
}
attemptFailedCount += 1;
}
}
@@ -2552,7 +2507,7 @@ async function paymentBatchesRoutes(fastify) {
items: batch.items.filter((item) => ["pending", "failed"].includes(item.status)),
});
if (["executed", "cancelled"].includes(batch.status)) {
throw buildValidationError({ status: "batch_not_cancellable" }, 409);
throw buildBusinessValidationError("payment_batch.batch_not_cancellable", { status: "batch_not_cancellable" }, 409);
}
const openItemIds = batch.items
.filter((item) => ["pending", "failed"].includes(item.status))
+2
查看文件
@@ -1,4 +1,5 @@
const { z } = require("zod");
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
@@ -2400,6 +2401,7 @@ async function purchaseRequestRoutes(fastify, options = {}) {
throw buildValidationError({ file: "multipart_limit_exceeded" });
}
if (error instanceof PurchaseAttachmentPolicyError) {
if (error.reasonCode === "unsupported_type") throw buildBusinessValidationError("purchase_attachment.unsupported_type", error.details ?? { file: error.reasonCode });
throw buildValidationError(error.details ?? { file: error.reasonCode });
}
throw error;
文件差异内容过多而无法显示 加载差异