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 行删除
+10 -17
查看文件
@@ -1,7 +1,7 @@
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const crypto = require("node:crypto");
const path = require("node:path");
const { AppError } = require("../utils/AppError");
const { compactDateString } = require("../utils/date");
const { writeBuffer } = require("./storage");
@@ -17,14 +17,7 @@ const SESSION_ORDER = {
pm: 1,
};
function buildLeaveValidationError(details, statusCode = 422) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function normalizeDateOnly(value) {
if (value instanceof Date) {
@@ -35,7 +28,7 @@ function normalizeDateOnly(value) {
return new Date(`${value}T00:00:00.000Z`);
}
throw buildLeaveValidationError({ date: "invalid" }, 400);
throw buildBusinessValidationError("leave.date_invalid", { date: "invalid" }, 400);
}
function formatDateOnly(value) {
@@ -64,7 +57,7 @@ function toHalfDaySlot(dateValue, session) {
const sessionIndex = SESSION_ORDER[session];
if (sessionIndex == null) {
throw buildLeaveValidationError({ session: "invalid" }, 400);
throw buildBusinessValidationError("leave.session_invalid", { session: "invalid" }, 400);
}
const dayIndex = Math.floor(date.getTime() / 86400000);
@@ -76,7 +69,7 @@ function calculateRequestedDays({ startDate, endDate, startSession, endSession }
const endSlot = toHalfDaySlot(endDate, endSession);
if (endSlot < startSlot) {
throw buildLeaveValidationError({ leave: "invalid_range" });
throw buildBusinessValidationError("leave.leave_invalid_range", { leave: "invalid_range" }, 422);
}
return fromHalfDayUnits(endSlot - startSlot + 1);
@@ -186,7 +179,7 @@ function calculateRequestedDaysPreviewByWorkingCalendar({
if (requestedUnits <= 0) {
if (!allowNonWorkingRange) {
throw buildLeaveValidationError({ leave: "non_working_range" });
throw buildBusinessValidationError("leave.leave_non_working_range", { leave: "non_working_range" }, 422);
}
return {
@@ -313,7 +306,7 @@ async function lockEmployeeForLeave(tx, employeeId) {
async function ensureAnnualLeaveGrant(tx, { employeeId, leaveType, actorId, referenceDate = new Date() }) {
const policy = leaveType?.leave_policy;
if (!leaveType || !leaveType.enabled || !policy || !policy.enabled) {
throw buildLeaveValidationError({ leaveTypeId: "missing_active_policy" }, 409);
throw buildBusinessValidationError("leave.leave_type_id_missing_active_policy", { leaveTypeId: "missing_active_policy" }, 409);
}
const { periodStart, periodEnd } = currentLeavePeriod(referenceDate);
@@ -532,7 +525,7 @@ async function consumeReservedLeave(tx, {
const requestedUnits = toHalfDayUnits(requestedDays);
if (reservedUnits < requestedUnits || requestedUnits <= 0) {
throw buildLeaveValidationError({ leave: "missing_reserve_ledger" }, 409);
throw buildBusinessValidationError("leave.leave_missing_reserve_ledger", { leave: "missing_reserve_ledger" }, 409);
}
await createLeaveLedgerEntry(tx, {
@@ -571,13 +564,13 @@ async function persistLeaveAttachment({ config, employeeId, filePart }) {
for await (const chunk of filePart.file) {
fileSize += chunk.length;
if (fileSize > config.maxUploadSizeBytes) {
throw buildLeaveValidationError({ file: "file_too_large" }, 400);
throw buildBusinessValidationError("leave.file_file_too_large", { file: "file_too_large" }, 400);
}
chunks.push(chunk);
}
if (fileSize <= 0) {
throw buildLeaveValidationError({ file: "empty" }, 400);
throw buildBusinessValidationError("leave.file_empty", { file: "empty" }, 400);
}
const buffer = Buffer.concat(chunks);
@@ -0,0 +1,60 @@
const { getErrorReason } = require("../constants/errorReasons");
const { buildBusinessValidationError } = require("../utils/businessValidationError");
function resolvePaymentBatchCreationReadiness({ entityType, entityIds, paymentChannel = "bank", snapshots, departmentReadiness, accountReadiness, reservedEntityIds, reviewerIds }) {
const blockers = [];
const addPaymentReadinessBlocker = (reasonCode, snapshot) => {
getErrorReason(reasonCode);
blockers.push({ reasonCode, ...(snapshot ? { entityId: snapshot.entityId, entityCode: snapshot.entityCode } : {}) });
};
if (entityType !== "reimbursement") addPaymentReadinessBlocker("payment_batch.travel_payment_frozen");
const selected = snapshots.filter((snapshot) => entityIds.includes(snapshot.entityId));
if (!entityIds.length || selected.length !== entityIds.length) addPaymentReadinessBlocker("payment_batch.source_not_found");
const companies = new Set();
for (const snapshot of selected) {
if (snapshot.status !== "approved" || snapshot.snapshotJson?.paidAt) addPaymentReadinessBlocker("payment_batch.source_status_invalid", snapshot);
if (!departmentReadiness.get(snapshot.departmentId ?? null)?.enabled) addPaymentReadinessBlocker("payment_batch.department_unavailable", snapshot);
if (reservedEntityIds.has(snapshot.entityId)) addPaymentReadinessBlocker("payment_batch.active_batch_exists", snapshot);
const payee = snapshot.snapshotJson?.payee;
if (payee?.bankAccountUnavailable) addPaymentReadinessBlocker("payment_batch.payee_account_unavailable", snapshot);
if (paymentChannel !== "bank") continue;
if (!snapshot.legalEntityId) addPaymentReadinessBlocker("payment_batch.legal_entity_required", snapshot);
else companies.add(snapshot.legalEntityId);
if (!payee?.bankInstitutionCode) addPaymentReadinessBlocker("payment_batch.payee_bank_required", snapshot);
else if (payee.bankInstitutionCode !== "CCB") addPaymentReadinessBlocker("payment_batch.payee_bank_unsupported", snapshot);
if (!payee?.bankAccountEncrypted) addPaymentReadinessBlocker("payment_batch.payee_account_required", snapshot);
}
if (paymentChannel === "bank") {
if (companies.size > 1) addPaymentReadinessBlocker("payment_batch.multiple_legal_entities");
for (const companyId of companies) {
const account = accountReadiness.get(companyId);
if (!account?.paymentAccount) addPaymentReadinessBlocker("payment_batch.payment_account_required");
else if (!account.paymentAccountSnapshot) addPaymentReadinessBlocker("payment_batch.payment_account_unavailable");
}
}
if (!reviewerIds.length) addPaymentReadinessBlocker("payment_batch.reviewer_required");
return { ready: blockers.length === 0, blockers };
}
function paymentReadinessError(readiness, departmentReadiness) {
const blocker = readiness.blockers[0];
const detailsByReason = {
"payment_batch.travel_payment_frozen": { entityType: "travel_payment_frozen" },
"payment_batch.source_not_found": { entityIds: "contains_not_found" },
"payment_batch.source_status_invalid": { entity: `${blocker.entityCode}:invalid_pay_status` },
"payment_batch.department_unavailable": { featureFlag: "payment_batch_v1_disabled", reason: [...departmentReadiness.values()].find((value) => !value.enabled)?.gate?.reason ?? "department_not_allowed" },
"payment_batch.active_batch_exists": { entityIds: "contains_active_payment_batch" },
"payment_batch.payee_account_unavailable": { payeeBankAccount: "decrypt_failed" },
"payment_batch.legal_entity_required": { legalEntity: "single_legal_entity_required" },
"payment_batch.multiple_legal_entities": { legalEntity: "single_legal_entity_required" },
"payment_batch.payee_bank_required": { payeeBankAccount: `${blocker.entityCode}:ccb_account_required` },
"payment_batch.payee_account_required": { payeeBankAccount: `${blocker.entityCode}:ccb_account_required` },
"payment_batch.payee_bank_unsupported": { payeeBankAccount: `${blocker.entityCode}:ccb_account_required` },
"payment_batch.payment_account_required": { paymentAccount: "active_ccb_account_required" },
"payment_batch.payment_account_unavailable": { paymentAccount: "decrypt_failed" },
"payment_batch.reviewer_required": { review: "no_eligible_reviewer" },
};
return buildBusinessValidationError(getErrorReason(blocker.reasonCode).code, detailsByReason[blocker.reasonCode], blocker.reasonCode === "payment_batch.source_not_found" ? 404 : 409);
}
module.exports = { resolvePaymentBatchCreationReadiness, paymentReadinessError };