1363 行
46 KiB
JavaScript
1363 行
46 KiB
JavaScript
const crypto = require("node:crypto");
|
|
const path = require("node:path");
|
|
|
|
const { z } = require("zod");
|
|
|
|
const { AppError } = require("../utils/AppError");
|
|
const { sendData } = require("../utils/reply");
|
|
const { buildAuditLogData } = require("../utils/audit");
|
|
const { sha256Hex } = require("../utils/crypto");
|
|
const { compactDateString, parseDateOnlyString } = require("../utils/date");
|
|
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
|
const { createNotificationsWithRecords } = require("../services/notifications");
|
|
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
|
|
const { sendNotificationEmailSafe } = require("../services/email");
|
|
const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher");
|
|
const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal");
|
|
const { ensureRiskFlag, buildPersistedRiskFlag, toMoneyNumber, toMoneyString } = require("../services/feeControl");
|
|
const { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
|
|
const {
|
|
ATTACHMENT_TYPE,
|
|
DOWNLOAD_POLICY,
|
|
SENSITIVITY_LEVEL,
|
|
governanceCreateData,
|
|
mapGovernanceFields,
|
|
parseMultipartGovernanceField,
|
|
requiresDownloadReason,
|
|
sanitizeDownloadReason,
|
|
} = require("../services/businessAttachmentGovernance");
|
|
|
|
const UUID_SCHEMA = z.string().uuid();
|
|
const ENTITY_TYPE_SCHEMA = z.enum(["reimbursement", "travel_order"]);
|
|
const PAYMENT_CHANNEL_SCHEMA = z.enum(["bank", "alipay", "wechat", "cash", "other"]);
|
|
|
|
const LIST_QUERY_SCHEMA = z.object({
|
|
entityType: ENTITY_TYPE_SCHEMA.optional(),
|
|
status: z.enum(["draft", "reviewed", "executed", "failed", "cancelled"]).optional(),
|
|
});
|
|
|
|
const CREATE_SCHEMA = z.object({
|
|
entityType: ENTITY_TYPE_SCHEMA,
|
|
name: z.string().trim().min(1).max(120),
|
|
entityIds: z.array(UUID_SCHEMA).min(1).max(200),
|
|
defaultPaymentDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
|
defaultPaymentChannel: PAYMENT_CHANNEL_SCHEMA.optional(),
|
|
voucherPrefix: z.string().trim().min(1).max(32).optional(),
|
|
}).strict();
|
|
|
|
const PATCH_ITEM_SCHEMA = z.object({
|
|
paymentVoucherNoOverride: z.string().trim().max(50).nullable().optional(),
|
|
paymentDateOverride: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
|
|
paymentChannelOverride: PAYMENT_CHANNEL_SCHEMA.nullable().optional(),
|
|
}).strict().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
|
|
|
const REVIEW_SCHEMA = z.object({
|
|
comment: z.string().trim().max(500).optional(),
|
|
resolveRiskFlagIds: z.array(UUID_SCHEMA).optional(),
|
|
}).strict();
|
|
|
|
const HIGH_RISK_BATCH_THRESHOLD_AMOUNT = 50000;
|
|
const RECEIPT_ATTACHMENT_MIME_ALLOWLIST = new Set([
|
|
"application/pdf",
|
|
"image/jpeg",
|
|
"image/png",
|
|
"image/webp",
|
|
]);
|
|
|
|
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" });
|
|
}
|
|
return parsed.data;
|
|
}
|
|
|
|
function parseBody(schema, body) {
|
|
const parsed = schema.safeParse(body ?? {});
|
|
if (!parsed.success) {
|
|
throw buildValidationError(parsed.error.flatten());
|
|
}
|
|
return parsed.data;
|
|
}
|
|
|
|
function normalizeNullableString(value) {
|
|
if (value == null) {
|
|
return null;
|
|
}
|
|
const trimmed = String(value).trim();
|
|
return trimmed || null;
|
|
}
|
|
|
|
function sanitizeAttachmentFilename(fileName) {
|
|
if (!fileName) return "file";
|
|
return fileName.replace(/[\r\n"]/g, "_");
|
|
}
|
|
|
|
function mapReceiptAttachment(item) {
|
|
return {
|
|
id: item.id,
|
|
originalName: item.original_name,
|
|
mimeType: item.mime_type,
|
|
fileSize: item.file_size,
|
|
fileSha256: item.file_sha256,
|
|
...mapGovernanceFields(item, {
|
|
governanceStatus: "approved",
|
|
sensitivityLevel: SENSITIVITY_LEVEL.confidential,
|
|
attachmentType: ATTACHMENT_TYPE.receipt,
|
|
downloadPolicy: DOWNLOAD_POLICY.reasonRequired,
|
|
}),
|
|
uploadedBy: item.uploader
|
|
? {
|
|
id: item.uploader.id,
|
|
employeeNo: item.uploader.employee_no,
|
|
name: item.uploader.name,
|
|
}
|
|
: null,
|
|
createdAt: item.created_at?.toISOString?.() ?? null,
|
|
};
|
|
}
|
|
|
|
function resolveItemPaymentChannel(batch, item) {
|
|
return item.payment_channel_override ?? batch.default_payment_channel ?? null;
|
|
}
|
|
|
|
async function ensureBankReceiptForExecutableBatch(tx, batch) {
|
|
const requiresBankReceipt = (batch.items ?? []).some((item) =>
|
|
["pending", "failed"].includes(item.status) && resolveItemPaymentChannel(batch, item) === "bank",
|
|
);
|
|
if (!requiresBankReceipt) {
|
|
return;
|
|
}
|
|
|
|
const receiptCount = await tx.paymentBatchReceiptAttachment.count({
|
|
where: { payment_batch_id: batch.id },
|
|
});
|
|
if (receiptCount <= 0) {
|
|
throw buildValidationError({ receiptAttachments: "bank_receipt_required" }, 409);
|
|
}
|
|
}
|
|
|
|
async function ensureFeatureEnabled(fastify) {
|
|
const gate = await evaluateFeatureFlag({
|
|
prisma: fastify.prisma,
|
|
config: fastify.config,
|
|
flagKey: FEATURE_FLAG_KEYS.paymentBatch,
|
|
strictDepartment: false,
|
|
});
|
|
|
|
if (!gate.enabled) {
|
|
throw buildValidationError({ featureFlag: "payment_batch_v1_disabled", reason: gate.reason }, 409);
|
|
}
|
|
|
|
return gate;
|
|
}
|
|
|
|
function ensureBatchManagePermission(fastify, request) {
|
|
if (!fastify.hasPermission(request.employee, "payment.batch.manage")) {
|
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
|
}
|
|
}
|
|
|
|
function ensureBatchReadPermission(fastify, request) {
|
|
if (
|
|
!fastify.hasPermission(request.employee, "payment.batch.manage") &&
|
|
!fastify.hasPermission(request.employee, "risk.review.manage")
|
|
) {
|
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
|
}
|
|
}
|
|
|
|
function ensureBatchReviewPermission(fastify, request) {
|
|
if (!fastify.hasPermission(request.employee, "risk.review.manage")) {
|
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
|
}
|
|
}
|
|
|
|
function buildBatchNo() {
|
|
const now = new Date();
|
|
const date = now.toISOString().slice(0, 10).replace(/-/g, "");
|
|
const time = now.toISOString().slice(11, 19).replace(/:/g, "");
|
|
return `PB-${date}-${time}-${String(now.getMilliseconds()).padStart(3, "0")}`;
|
|
}
|
|
|
|
function summarizeBatchRisk(batch) {
|
|
const totalAmountValue = (batch.items ?? []).reduce((sum, item) => sum + toMoneyNumber(item.amount), 0);
|
|
const totalAmount = toMoneyString(totalAmountValue);
|
|
const highRisk = totalAmountValue >= HIGH_RISK_BATCH_THRESHOLD_AMOUNT;
|
|
const completedReviewCount = batch.secondary_reviewed_at ? 2 : batch.reviewed_at ? 1 : 0;
|
|
const requiredReviewCount = highRisk ? 2 : 1;
|
|
const nextStep = completedReviewCount >= requiredReviewCount
|
|
? "ready_for_execute"
|
|
: completedReviewCount === 0
|
|
? "first_review"
|
|
: "second_review";
|
|
|
|
return {
|
|
totalAmount,
|
|
highRisk,
|
|
reviewRequirement: {
|
|
thresholdAmount: toMoneyString(HIGH_RISK_BATCH_THRESHOLD_AMOUNT),
|
|
requiredReviewCount,
|
|
completedReviewCount,
|
|
nextStep,
|
|
makerCheckerRequired: true,
|
|
reviewerPermission: "risk.review.manage",
|
|
},
|
|
};
|
|
}
|
|
|
|
async function loadBatchWithItems(tx, batchId) {
|
|
const batch = await tx.paymentBatch.findUnique({
|
|
where: { id: batchId },
|
|
include: {
|
|
items: {
|
|
orderBy: [{ created_at: "asc" }, { id: "asc" }],
|
|
},
|
|
},
|
|
});
|
|
if (!batch) {
|
|
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
|
}
|
|
return batch;
|
|
}
|
|
|
|
async function loadEntitySnapshots(tx, entityType, entityIds) {
|
|
if (entityType === "reimbursement") {
|
|
const items = await tx.reimbursementForm.findMany({
|
|
where: { id: { in: entityIds } },
|
|
select: {
|
|
id: true,
|
|
form_number: true,
|
|
description: true,
|
|
total_amount: true,
|
|
status: true,
|
|
payment_voucher_no: true,
|
|
payment_date: true,
|
|
payment_channel: true,
|
|
paid_at: true,
|
|
},
|
|
});
|
|
return items.map((item) => ({
|
|
entityId: item.id,
|
|
entityCode: item.form_number ?? item.id,
|
|
entityTitle: item.description || item.form_number || item.id,
|
|
amount: toMoneyString(item.total_amount),
|
|
currencyCode: "CNY",
|
|
status: item.status,
|
|
snapshotJson: {
|
|
formNumber: item.form_number ?? null,
|
|
description: item.description ?? null,
|
|
paymentVoucherNo: item.payment_voucher_no ?? null,
|
|
paymentDate: item.payment_date?.toISOString?.()?.slice(0, 10) ?? null,
|
|
paymentChannel: item.payment_channel ?? null,
|
|
paidAt: item.paid_at?.toISOString?.() ?? null,
|
|
},
|
|
}));
|
|
}
|
|
|
|
const items = await tx.travelOrder.findMany({
|
|
where: { id: { in: entityIds } },
|
|
select: {
|
|
id: true,
|
|
source: true,
|
|
external_order_no: true,
|
|
amount: true,
|
|
currency: true,
|
|
status: true,
|
|
payment_voucher_no: true,
|
|
payment_date: true,
|
|
payment_channel: true,
|
|
paid_at: true,
|
|
},
|
|
});
|
|
return items.map((item) => ({
|
|
entityId: item.id,
|
|
entityCode: item.external_order_no,
|
|
entityTitle: `${item.source}:${item.external_order_no}`,
|
|
amount: toMoneyString(item.amount),
|
|
currencyCode: item.currency,
|
|
status: item.status,
|
|
snapshotJson: {
|
|
source: item.source,
|
|
externalOrderNo: item.external_order_no,
|
|
paymentVoucherNo: item.payment_voucher_no ?? null,
|
|
paymentDate: item.payment_date?.toISOString?.()?.slice(0, 10) ?? null,
|
|
paymentChannel: item.payment_channel ?? null,
|
|
paidAt: item.paid_at?.toISOString?.() ?? null,
|
|
},
|
|
}));
|
|
}
|
|
|
|
function ensureBatchEntityStatus(entityType, snapshot) {
|
|
if (entityType === "reimbursement" && !["approved", "paid"].includes(snapshot.status)) {
|
|
throw buildValidationError({ entity: `${snapshot.entityCode}:invalid_pay_status` }, 409);
|
|
}
|
|
if (entityType === "travel_order" && !["confirmed", "paid"].includes(snapshot.status)) {
|
|
throw buildValidationError({ entity: `${snapshot.entityCode}:invalid_pay_status` }, 409);
|
|
}
|
|
}
|
|
|
|
function mapBatch(batch, openRiskFlags = []) {
|
|
const batchRisk = summarizeBatchRisk(batch);
|
|
const riskMap = new Map();
|
|
for (const item of openRiskFlags) {
|
|
const entityId = item.entityId;
|
|
if (!riskMap.has(entityId)) {
|
|
riskMap.set(entityId, []);
|
|
}
|
|
riskMap.get(entityId).push(item);
|
|
}
|
|
|
|
return {
|
|
id: batch.id,
|
|
batchNo: batch.batch_no,
|
|
entityType: batch.entity_type,
|
|
name: batch.name,
|
|
status: batch.status,
|
|
defaultPaymentDate: batch.default_payment_date?.toISOString?.()?.slice(0, 10) ?? null,
|
|
defaultPaymentChannel: batch.default_payment_channel ?? null,
|
|
voucherPrefix: batch.voucher_prefix ?? null,
|
|
totalAmount: batchRisk.totalAmount,
|
|
highRisk: batchRisk.highRisk,
|
|
reviewRequirement: batchRisk.reviewRequirement,
|
|
reviewComment: batch.review_comment ?? null,
|
|
secondaryReviewComment: batch.secondary_review_comment ?? null,
|
|
executionSummaryJson: batch.execution_summary_json ?? null,
|
|
createdAt: batch.created_at?.toISOString?.() ?? null,
|
|
createdBy: batch.created_by ?? null,
|
|
reviewedAt: batch.reviewed_at?.toISOString?.() ?? null,
|
|
reviewedBy: batch.reviewed_by ?? null,
|
|
secondaryReviewedAt: batch.secondary_reviewed_at?.toISOString?.() ?? null,
|
|
secondaryReviewedBy: batch.secondary_reviewed_by ?? null,
|
|
executedAt: batch.executed_at?.toISOString?.() ?? null,
|
|
executedBy: batch.executed_by ?? null,
|
|
cancelledAt: batch.cancelled_at?.toISOString?.() ?? null,
|
|
cancelledBy: batch.cancelled_by ?? null,
|
|
items: (batch.items ?? []).map((item) => ({
|
|
id: item.id,
|
|
entityId: item.entity_id,
|
|
entityCode: item.entity_code,
|
|
entityTitle: item.entity_title,
|
|
amount: toMoneyString(item.amount),
|
|
currencyCode: item.currency_code,
|
|
paymentVoucherNoOverride: item.payment_voucher_no_override ?? null,
|
|
paymentDateOverride: item.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null,
|
|
paymentChannelOverride: item.payment_channel_override ?? null,
|
|
status: item.status,
|
|
failureCode: item.failure_code ?? null,
|
|
failureMessage: item.failure_message ?? null,
|
|
snapshotJson: item.snapshot_json,
|
|
createdAt: item.created_at?.toISOString?.() ?? null,
|
|
openRiskFlags: riskMap.get(item.entity_id) ?? [],
|
|
})),
|
|
};
|
|
}
|
|
|
|
async function listOpenRiskFlagsForBatch(tx, batch) {
|
|
const entityIds = batch.items.map((item) => item.entity_id);
|
|
if (!entityIds.length) {
|
|
return [];
|
|
}
|
|
const items = await tx.entityRiskFlag.findMany({
|
|
where: {
|
|
entity_type: batch.entity_type,
|
|
entity_id: { in: entityIds },
|
|
status: "open",
|
|
},
|
|
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
|
});
|
|
return items.map(buildPersistedRiskFlag);
|
|
}
|
|
|
|
async function executeReimbursementPayment(tx, { batch, item, actorId, index, request }) {
|
|
const current = await tx.reimbursementForm.findUnique({
|
|
where: { id: item.entity_id },
|
|
select: {
|
|
id: true,
|
|
status: true,
|
|
form_number: true,
|
|
owner_id: true,
|
|
submitter_id: true,
|
|
department_id: true,
|
|
payment_voucher_no: true,
|
|
payment_date: true,
|
|
payment_channel: true,
|
|
paid_at: true,
|
|
paid_by: true,
|
|
},
|
|
});
|
|
if (!current) {
|
|
throw buildNotFoundError();
|
|
}
|
|
if (!["approved", "paid"].includes(current.status)) {
|
|
throw buildValidationError({ status: "invalid_pay_status" }, 409);
|
|
}
|
|
|
|
const voucher = normalizeNullableString(item.payment_voucher_no_override) ??
|
|
(batch.voucher_prefix ? `${batch.voucher_prefix}-${String(index + 1).padStart(3, "0")}` : null);
|
|
const paymentDate = item.payment_date_override?.toISOString?.()?.slice(0, 10)
|
|
?? batch.default_payment_date?.toISOString?.()?.slice(0, 10)
|
|
?? null;
|
|
const paymentChannel = item.payment_channel_override ?? batch.default_payment_channel ?? null;
|
|
const existingPaymentDate = current.payment_date?.toISOString?.()?.slice(0, 10) ?? null;
|
|
|
|
if (current.status === "paid") {
|
|
const conflict =
|
|
(voucher != null && voucher !== (current.payment_voucher_no ?? null)) ||
|
|
(paymentDate != null && paymentDate !== existingPaymentDate) ||
|
|
(paymentChannel != null && paymentChannel !== (current.payment_channel ?? null));
|
|
if (conflict) {
|
|
throw buildValidationError({ payment: "already_paid_conflict" }, 409);
|
|
}
|
|
await tx.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId,
|
|
action: "REIMBURSEMENT_PAYMENT_MARKED",
|
|
targetType: "reimbursement_form",
|
|
targetId: current.id,
|
|
extra: {
|
|
formNumber: current.form_number ?? null,
|
|
paymentVoucherNo: current.payment_voucher_no ?? null,
|
|
paymentDate: existingPaymentDate,
|
|
paymentChannel: current.payment_channel ?? null,
|
|
paymentNote: null,
|
|
idempotent: true,
|
|
archivedInvoiceCount: 0,
|
|
source: "payment_batch",
|
|
paymentBatchId: batch.id,
|
|
paymentBatchItemId: item.id,
|
|
},
|
|
}),
|
|
});
|
|
return { idempotent: true };
|
|
}
|
|
|
|
const now = new Date();
|
|
const archivedResult = await tx.invoicePool.updateMany({
|
|
where: { linked_reimbursement_id: item.entity_id, status: "bound" },
|
|
data: {
|
|
status: "archived",
|
|
archived_reason: "used_complete",
|
|
archived_at: now,
|
|
updated_by: actorId,
|
|
},
|
|
});
|
|
await tx.reimbursementForm.update({
|
|
where: { id: item.entity_id },
|
|
data: {
|
|
status: "paid",
|
|
payment_voucher_no: voucher,
|
|
payment_date: paymentDate ? parseDateOnlyString(paymentDate) : null,
|
|
payment_channel: paymentChannel,
|
|
paid_at: now,
|
|
paid_by: actorId,
|
|
updated_by: actorId,
|
|
},
|
|
});
|
|
await tx.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId,
|
|
action: "REIMBURSEMENT_PAYMENT_MARKED",
|
|
targetType: "reimbursement_form",
|
|
targetId: current.id,
|
|
extra: {
|
|
formNumber: current.form_number ?? null,
|
|
paymentVoucherNo: voucher,
|
|
paymentDate,
|
|
paymentChannel,
|
|
paymentNote: null,
|
|
idempotent: false,
|
|
archivedInvoiceCount: archivedResult.count,
|
|
source: "payment_batch",
|
|
paymentBatchId: batch.id,
|
|
paymentBatchItemId: item.id,
|
|
},
|
|
}),
|
|
});
|
|
return { idempotent: false };
|
|
}
|
|
|
|
async function fetchPaidReimbursementForFollowUp(prisma, reimbursementId) {
|
|
return prisma.reimbursementForm.findUnique({
|
|
where: { id: reimbursementId },
|
|
select: {
|
|
id: true,
|
|
status: true,
|
|
form_number: true,
|
|
owner_id: true,
|
|
submitter_id: true,
|
|
department_id: true,
|
|
total_amount: true,
|
|
payment_voucher_no: true,
|
|
payment_date: true,
|
|
payment_channel: true,
|
|
paid_at: true,
|
|
owner: {
|
|
select: {
|
|
legal_entity: {
|
|
select: {
|
|
code: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
submitter: {
|
|
select: {
|
|
legal_entity: {
|
|
select: {
|
|
code: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
async function processPaidReimbursementFollowUps({ fastify, request, reimbursementId }) {
|
|
const reimbursement = await fetchPaidReimbursementForFollowUp(fastify.prisma, reimbursementId);
|
|
if (!reimbursement || reimbursement.status !== "paid") {
|
|
return;
|
|
}
|
|
|
|
const departmentId = reimbursement.department_id ?? request.employee.department_id ?? null;
|
|
const title = `【已付款】报销单 ${reimbursement.form_number ?? reimbursementId}`;
|
|
const body = `报销单 ${reimbursement.form_number ?? reimbursementId} 已完成付款。`;
|
|
const targetPath = `/reimbursements/${reimbursementId}`;
|
|
|
|
try {
|
|
const erpGate = await evaluateFeatureFlag({
|
|
prisma: fastify.prisma,
|
|
config: fastify.config,
|
|
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
|
departmentId,
|
|
strictDepartment: true,
|
|
});
|
|
|
|
if (erpGate.enabled) {
|
|
await enqueueReimbursementJournalExport(fastify.prisma, fastify.config, {
|
|
reimbursement,
|
|
actorId: request.employee.id,
|
|
});
|
|
} else {
|
|
await fastify.prisma.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "ERP_SYNC_SKIPPED_BY_FEATURE_FLAG",
|
|
targetType: "reimbursement_form",
|
|
targetId: reimbursement.id,
|
|
extra: {
|
|
featureFlag: FEATURE_FLAG_KEYS.erpSync,
|
|
departmentId,
|
|
reason: erpGate.reason,
|
|
source: "payment_batch",
|
|
},
|
|
}),
|
|
});
|
|
}
|
|
} catch (error) {
|
|
fastify.log.error(
|
|
{ err: error, reimbursementId },
|
|
"Failed to enqueue reimbursement ERP follow-up after payment batch execution",
|
|
);
|
|
}
|
|
|
|
try {
|
|
const recipients = Array.from(new Set([reimbursement.owner_id, reimbursement.submitter_id].filter(Boolean)));
|
|
const notifications = await createNotificationsWithRecords(fastify.prisma, {
|
|
recipientEmployeeIds: recipients,
|
|
type: NOTIFICATION_TYPES.reimbursementPaid,
|
|
title,
|
|
body,
|
|
targetType: "reimbursement_form",
|
|
targetId: reimbursementId,
|
|
});
|
|
|
|
for (const recipientEmployeeId of recipients) {
|
|
void sendNotificationEmailSafe({
|
|
prisma: fastify.prisma,
|
|
config: fastify.config,
|
|
logger: fastify.log,
|
|
requestId: request.id,
|
|
type: NOTIFICATION_TYPES.reimbursementPaid,
|
|
recipientEmployeeIds: [recipientEmployeeId],
|
|
subject: title,
|
|
body,
|
|
targetPath,
|
|
});
|
|
}
|
|
|
|
await flushNotificationDispatchTasks({
|
|
prisma: fastify.prisma,
|
|
config: fastify.config,
|
|
logger: fastify.log,
|
|
requestId: request.id,
|
|
tasks: [{
|
|
notifications,
|
|
type: NOTIFICATION_TYPES.reimbursementPaid,
|
|
title,
|
|
body,
|
|
targetType: "reimbursement_form",
|
|
targetId: reimbursementId,
|
|
targetPath,
|
|
actorId: request.employee.id,
|
|
}],
|
|
});
|
|
} catch (error) {
|
|
fastify.log.error(
|
|
{ err: error, reimbursementId },
|
|
"Failed to dispatch reimbursement paid notifications after payment batch execution",
|
|
);
|
|
}
|
|
}
|
|
|
|
async function executeTravelOrderPayment(tx, { batch, item, actorId, index }) {
|
|
const current = await tx.travelOrder.findUnique({
|
|
where: { id: item.entity_id },
|
|
select: {
|
|
id: true,
|
|
status: true,
|
|
amount: true,
|
|
currency: true,
|
|
payment_voucher_no: true,
|
|
payment_date: true,
|
|
payment_channel: true,
|
|
paid_at: true,
|
|
paid_by: true,
|
|
},
|
|
});
|
|
if (!current) {
|
|
throw buildNotFoundError();
|
|
}
|
|
if (!["confirmed", "paid"].includes(current.status)) {
|
|
throw buildValidationError({ status: "invalid_pay_status" }, 409);
|
|
}
|
|
|
|
const voucher = normalizeNullableString(item.payment_voucher_no_override) ??
|
|
(batch.voucher_prefix ? `${batch.voucher_prefix}-${String(index + 1).padStart(3, "0")}` : null);
|
|
const paymentDate = item.payment_date_override?.toISOString?.()?.slice(0, 10)
|
|
?? batch.default_payment_date?.toISOString?.()?.slice(0, 10)
|
|
?? null;
|
|
const paymentChannel = item.payment_channel_override ?? batch.default_payment_channel ?? null;
|
|
const existingPaymentDate = current.payment_date?.toISOString?.()?.slice(0, 10) ?? null;
|
|
|
|
if (current.status === "paid") {
|
|
const conflict =
|
|
voucher !== (current.payment_voucher_no ?? null) ||
|
|
paymentDate !== existingPaymentDate ||
|
|
paymentChannel !== (current.payment_channel ?? null);
|
|
if (conflict) {
|
|
throw buildValidationError({ payment: "already_paid_conflict" }, 409);
|
|
}
|
|
return { idempotent: true };
|
|
}
|
|
|
|
const matchedTransaction = await tx.cardTransaction.findFirst({
|
|
where: {
|
|
travel_order_id: item.entity_id,
|
|
txn_type: "charge",
|
|
status: "settled",
|
|
amount: current.amount,
|
|
currency: current.currency,
|
|
},
|
|
orderBy: [{ occurred_at: "desc" }, { created_at: "desc" }],
|
|
select: { id: true },
|
|
});
|
|
if (!matchedTransaction) {
|
|
throw buildValidationError({ payment: "missing_settled_transaction" }, 409);
|
|
}
|
|
|
|
await tx.travelOrder.update({
|
|
where: { id: item.entity_id },
|
|
data: {
|
|
status: "paid",
|
|
payment_voucher_no: voucher,
|
|
payment_date: paymentDate ? parseDateOnlyString(paymentDate) : null,
|
|
payment_channel: paymentChannel,
|
|
paid_at: new Date(),
|
|
paid_by: actorId,
|
|
updated_by: actorId,
|
|
},
|
|
});
|
|
return { idempotent: false };
|
|
}
|
|
|
|
function buildNotFoundError() {
|
|
return new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
|
}
|
|
|
|
async function readReceiptUploadFiles(fastify, request) {
|
|
const files = [];
|
|
let governanceFields = {};
|
|
try {
|
|
for await (const part of request.parts()) {
|
|
if (part.type === "field") {
|
|
governanceFields = parseMultipartGovernanceField(part, governanceFields);
|
|
continue;
|
|
}
|
|
if (part.type !== "file") {
|
|
continue;
|
|
}
|
|
|
|
const originalName = part.filename ?? "receipt";
|
|
const mimeType = part.mimetype;
|
|
if (!RECEIPT_ATTACHMENT_MIME_ALLOWLIST.has(mimeType)) {
|
|
throw buildValidationError({ file: "unsupported_mime_type" });
|
|
}
|
|
|
|
const chunks = [];
|
|
let fileSize = 0;
|
|
for await (const chunk of part.file) {
|
|
fileSize += chunk.length;
|
|
if (fileSize > fastify.config.maxUploadSizeBytes) {
|
|
throw buildValidationError({ file: "file_too_large" });
|
|
}
|
|
chunks.push(chunk);
|
|
}
|
|
|
|
if (fileSize === 0) {
|
|
throw buildValidationError({ file: "empty_file" });
|
|
}
|
|
|
|
files.push({
|
|
buffer: Buffer.concat(chunks),
|
|
originalName,
|
|
mimeType,
|
|
fileSize,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
if (error?.code === "FST_REQ_FILE_TOO_LARGE") {
|
|
throw buildValidationError({ file: "file_too_large" });
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
if (!files.length) {
|
|
throw buildValidationError({ file: "missing" });
|
|
}
|
|
|
|
return files.map((file) => ({ ...file, governanceFields }));
|
|
}
|
|
|
|
async function paymentBatchesRoutes(fastify) {
|
|
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());
|
|
const query = queryParsed.data;
|
|
const items = await fastify.prisma.paymentBatch.findMany({
|
|
where: {
|
|
...(query.entityType ? { entity_type: query.entityType } : {}),
|
|
...(query.status ? { status: query.status } : {}),
|
|
},
|
|
include: { items: { orderBy: [{ created_at: "asc" }, { id: "asc" }] } },
|
|
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
|
});
|
|
|
|
const mapped = [];
|
|
for (const batch of items) {
|
|
const openRiskFlags = await listOpenRiskFlagsForBatch(fastify.prisma, batch);
|
|
mapped.push(mapBatch(batch, openRiskFlags));
|
|
}
|
|
return sendData(reply, request, { items: mapped });
|
|
});
|
|
|
|
fastify.post("/payment-batches", { preHandler: fastify.authenticate }, async (request, reply) => {
|
|
await ensureFeatureEnabled(fastify);
|
|
ensureBatchManagePermission(fastify, request);
|
|
const body = parseBody(CREATE_SCHEMA, request.body);
|
|
const entityIds = Array.from(new Set(body.entityIds));
|
|
|
|
const created = await fastify.prisma.$transaction(async (tx) => {
|
|
const snapshots = await loadEntitySnapshots(tx, body.entityType, entityIds);
|
|
if (snapshots.length !== entityIds.length) {
|
|
throw buildValidationError({ entityIds: "contains_not_found" }, 404);
|
|
}
|
|
for (const snapshot of snapshots) {
|
|
ensureBatchEntityStatus(body.entityType, snapshot);
|
|
}
|
|
|
|
const batch = await tx.paymentBatch.create({
|
|
data: {
|
|
batch_no: buildBatchNo(),
|
|
entity_type: body.entityType,
|
|
name: body.name,
|
|
status: "draft",
|
|
default_payment_date: body.defaultPaymentDate ? parseDateOnlyString(body.defaultPaymentDate) : null,
|
|
default_payment_channel: body.defaultPaymentChannel ?? null,
|
|
voucher_prefix: normalizeNullableString(body.voucherPrefix),
|
|
created_by: request.employee.id,
|
|
updated_by: request.employee.id,
|
|
},
|
|
});
|
|
|
|
for (const snapshot of snapshots) {
|
|
await tx.paymentBatchItem.create({
|
|
data: {
|
|
payment_batch_id: batch.id,
|
|
entity_id: snapshot.entityId,
|
|
entity_code: snapshot.entityCode,
|
|
entity_title: snapshot.entityTitle,
|
|
amount: snapshot.amount,
|
|
currency_code: snapshot.currencyCode,
|
|
status: "pending",
|
|
snapshot_json: snapshot.snapshotJson,
|
|
},
|
|
});
|
|
}
|
|
|
|
await tx.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "PAYMENT_BATCH_CREATED",
|
|
targetType: "payment_batch",
|
|
targetId: batch.id,
|
|
extra: {
|
|
entityType: body.entityType,
|
|
entityCount: snapshots.length,
|
|
totalAmount: toMoneyString(snapshots.reduce((sum, item) => sum + toMoneyNumber(item.amount), 0)),
|
|
highRisk: snapshots.reduce((sum, item) => sum + toMoneyNumber(item.amount), 0) >= HIGH_RISK_BATCH_THRESHOLD_AMOUNT,
|
|
},
|
|
}),
|
|
});
|
|
|
|
return loadBatchWithItems(tx, batch.id);
|
|
});
|
|
|
|
return reply.code(201).send({ requestId: request.id, data: { item: mapBatch(created, []) } });
|
|
});
|
|
|
|
fastify.get("/payment-batches/:id", { preHandler: fastify.authenticate }, async (request, reply) => {
|
|
await ensureFeatureEnabled(fastify);
|
|
ensureBatchReadPermission(fastify, request);
|
|
const batchId = parseId(request.params?.id);
|
|
const batch = await loadBatchWithItems(fastify.prisma, batchId);
|
|
const openRiskFlags = await listOpenRiskFlagsForBatch(fastify.prisma, batch);
|
|
return sendData(reply, request, { item: mapBatch(batch, openRiskFlags) });
|
|
});
|
|
|
|
fastify.get("/payment-batches/:id/receipt-attachments", { preHandler: fastify.authenticate }, async (request, reply) => {
|
|
await ensureFeatureEnabled(fastify);
|
|
ensureBatchReadPermission(fastify, request);
|
|
const batchId = parseId(request.params?.id);
|
|
await loadBatchWithItems(fastify.prisma, batchId);
|
|
|
|
const items = await fastify.prisma.paymentBatchReceiptAttachment.findMany({
|
|
where: { payment_batch_id: batchId },
|
|
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
|
select: {
|
|
id: true,
|
|
original_name: true,
|
|
mime_type: true,
|
|
file_size: true,
|
|
file_sha256: true,
|
|
governance_status: true,
|
|
sensitivity_level: true,
|
|
attachment_type: true,
|
|
reviewed_by: true,
|
|
reviewed_at: true,
|
|
review_note: true,
|
|
archived_by: true,
|
|
archived_at: true,
|
|
archive_note: true,
|
|
download_policy: true,
|
|
created_at: true,
|
|
uploader: {
|
|
select: {
|
|
id: true,
|
|
employee_no: true,
|
|
name: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
return sendData(reply, request, { items: items.map(mapReceiptAttachment) });
|
|
});
|
|
|
|
fastify.post("/payment-batches/:id/receipt-attachments", { preHandler: fastify.authenticate }, async (request, reply) => {
|
|
await ensureFeatureEnabled(fastify);
|
|
ensureBatchManagePermission(fastify, request);
|
|
const batchId = parseId(request.params?.id);
|
|
const batch = await loadBatchWithItems(fastify.prisma, batchId);
|
|
if (["executed", "cancelled"].includes(batch.status)) {
|
|
throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409);
|
|
}
|
|
|
|
const files = await readReceiptUploadFiles(fastify, request);
|
|
const datePrefix = compactDateString(fastify.config.appTimezone);
|
|
const createdItems = [];
|
|
|
|
for (const file of files) {
|
|
const fileSha256 = sha256Hex(file.buffer);
|
|
const extension = path.extname(file.originalName ?? "").toLowerCase();
|
|
const objectKey = path.posix.join("payment-batch-receipts", datePrefix, `${batchId}-${crypto.randomUUID()}${extension}`);
|
|
const storagePath = await writeBuffer({
|
|
config: fastify.config,
|
|
key: objectKey,
|
|
buffer: file.buffer,
|
|
contentType: file.mimeType,
|
|
});
|
|
|
|
const created = await fastify.prisma.paymentBatchReceiptAttachment.create({
|
|
data: {
|
|
payment_batch_id: batchId,
|
|
storage_path: storagePath,
|
|
original_name: file.originalName,
|
|
mime_type: file.mimeType,
|
|
file_size: file.fileSize,
|
|
file_sha256: fileSha256,
|
|
uploaded_by: request.employee.id,
|
|
...governanceCreateData(file.governanceFields, {
|
|
governanceStatus: "approved",
|
|
sensitivityLevel: SENSITIVITY_LEVEL.confidential,
|
|
attachmentType: ATTACHMENT_TYPE.receipt,
|
|
downloadPolicy: DOWNLOAD_POLICY.reasonRequired,
|
|
}),
|
|
},
|
|
select: {
|
|
id: true,
|
|
original_name: true,
|
|
mime_type: true,
|
|
file_size: true,
|
|
file_sha256: true,
|
|
governance_status: true,
|
|
sensitivity_level: true,
|
|
attachment_type: true,
|
|
reviewed_by: true,
|
|
reviewed_at: true,
|
|
review_note: true,
|
|
archived_by: true,
|
|
archived_at: true,
|
|
archive_note: true,
|
|
download_policy: true,
|
|
created_at: true,
|
|
uploader: {
|
|
select: {
|
|
id: true,
|
|
employee_no: true,
|
|
name: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
createdItems.push(created);
|
|
}
|
|
|
|
await fastify.prisma.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "PAYMENT_BATCH_RECEIPT_UPLOADED",
|
|
targetType: "payment_batch",
|
|
targetId: batchId,
|
|
extra: {
|
|
attachmentIds: createdItems.map((item) => item.id),
|
|
attachmentCount: createdItems.length,
|
|
attachmentTypes: [...new Set(createdItems.map((item) => item.attachment_type))],
|
|
},
|
|
}),
|
|
});
|
|
|
|
return sendData(reply, request, { items: createdItems.map(mapReceiptAttachment) });
|
|
});
|
|
|
|
fastify.get("/payment-batches/:id/receipt-attachments/:attachmentId/file", { preHandler: fastify.authenticate }, async (request, reply) => {
|
|
await ensureFeatureEnabled(fastify);
|
|
ensureBatchReadPermission(fastify, request);
|
|
const batchId = parseId(request.params?.id);
|
|
const attachmentId = parseId(request.params?.attachmentId, "attachmentId");
|
|
|
|
const attachment = await fastify.prisma.paymentBatchReceiptAttachment.findFirst({
|
|
where: {
|
|
id: attachmentId,
|
|
payment_batch_id: batchId,
|
|
},
|
|
select: {
|
|
id: true,
|
|
storage_path: true,
|
|
original_name: true,
|
|
mime_type: true,
|
|
governance_status: true,
|
|
sensitivity_level: true,
|
|
attachment_type: true,
|
|
download_policy: true,
|
|
},
|
|
});
|
|
if (!attachment) {
|
|
throw buildNotFoundError();
|
|
}
|
|
const downloadReason = sanitizeDownloadReason(request.query?.reason);
|
|
if (requiresDownloadReason(attachment) && !downloadReason) {
|
|
throw buildValidationError({ reason: "required" });
|
|
}
|
|
|
|
let stream = null;
|
|
try {
|
|
const opened = await openReadStream({
|
|
config: fastify.config,
|
|
storagePath: attachment.storage_path,
|
|
});
|
|
stream = opened.stream;
|
|
} catch (error) {
|
|
if (error instanceof StorageNotFoundError) {
|
|
throw buildNotFoundError();
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
await fastify.prisma.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "PAYMENT_BATCH_RECEIPT_DOWNLOADED",
|
|
targetType: "payment_batch",
|
|
targetId: batchId,
|
|
extra: {
|
|
attachmentId: attachment.id,
|
|
attachmentType: attachment.attachment_type ?? ATTACHMENT_TYPE.receipt,
|
|
sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.confidential,
|
|
governanceStatus: attachment.governance_status ?? "approved",
|
|
reason: downloadReason,
|
|
},
|
|
}),
|
|
});
|
|
|
|
reply.type(attachment.mime_type || "application/octet-stream");
|
|
reply.header(
|
|
"content-disposition",
|
|
`inline; filename=\"${sanitizeAttachmentFilename(attachment.original_name)}\"`,
|
|
);
|
|
return reply.send(stream);
|
|
});
|
|
|
|
fastify.delete("/payment-batches/:id/receipt-attachments/:attachmentId", { preHandler: fastify.authenticate }, async (request, reply) => {
|
|
await ensureFeatureEnabled(fastify);
|
|
ensureBatchManagePermission(fastify, request);
|
|
const batchId = parseId(request.params?.id);
|
|
const attachmentId = parseId(request.params?.attachmentId, "attachmentId");
|
|
|
|
const result = await fastify.prisma.$transaction(async (tx) => {
|
|
const batch = await loadBatchWithItems(tx, batchId);
|
|
if (["executed", "cancelled"].includes(batch.status)) {
|
|
throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409);
|
|
}
|
|
|
|
const deleted = await tx.paymentBatchReceiptAttachment.deleteMany({
|
|
where: {
|
|
id: attachmentId,
|
|
payment_batch_id: batchId,
|
|
},
|
|
});
|
|
if (deleted.count === 0) {
|
|
throw buildNotFoundError();
|
|
}
|
|
|
|
await tx.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "PAYMENT_BATCH_RECEIPT_DELETED",
|
|
targetType: "payment_batch",
|
|
targetId: batchId,
|
|
extra: { attachmentId },
|
|
}),
|
|
});
|
|
|
|
return { attachmentId };
|
|
});
|
|
|
|
return sendData(reply, request, { attachmentId: result.attachmentId, outcome: "deleted" });
|
|
});
|
|
|
|
fastify.patch("/payment-batches/:id/items/:itemId", { preHandler: fastify.authenticate }, async (request, reply) => {
|
|
await ensureFeatureEnabled(fastify);
|
|
ensureBatchManagePermission(fastify, request);
|
|
const batchId = parseId(request.params?.id);
|
|
const itemId = parseId(request.params?.itemId, "itemId");
|
|
const body = parseBody(PATCH_ITEM_SCHEMA, request.body);
|
|
|
|
const batch = await loadBatchWithItems(fastify.prisma, batchId);
|
|
if (!["draft", "reviewed", "failed"].includes(batch.status)) {
|
|
throw buildValidationError({ status: "only_open_batch_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);
|
|
}
|
|
|
|
const updated = await fastify.prisma.paymentBatchItem.update({
|
|
where: { id: itemId },
|
|
data: {
|
|
...(body.paymentVoucherNoOverride !== undefined
|
|
? { payment_voucher_no_override: normalizeNullableString(body.paymentVoucherNoOverride) }
|
|
: {}),
|
|
...(body.paymentDateOverride !== undefined
|
|
? { payment_date_override: body.paymentDateOverride ? parseDateOnlyString(body.paymentDateOverride) : null }
|
|
: {}),
|
|
...(body.paymentChannelOverride !== undefined
|
|
? { payment_channel_override: body.paymentChannelOverride ?? null }
|
|
: {}),
|
|
},
|
|
});
|
|
return sendData(reply, request, {
|
|
item: {
|
|
id: updated.id,
|
|
paymentVoucherNoOverride: updated.payment_voucher_no_override ?? null,
|
|
paymentDateOverride: updated.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null,
|
|
paymentChannelOverride: updated.payment_channel_override ?? null,
|
|
},
|
|
});
|
|
});
|
|
|
|
fastify.post("/payment-batches/:id/review", { preHandler: fastify.authenticate }, async (request, reply) => {
|
|
await ensureFeatureEnabled(fastify);
|
|
ensureBatchReviewPermission(fastify, request);
|
|
const batchId = parseId(request.params?.id);
|
|
const body = parseBody(REVIEW_SCHEMA, request.body);
|
|
|
|
const result = await fastify.prisma.$transaction(async (tx) => {
|
|
const batch = await loadBatchWithItems(tx, batchId);
|
|
const batchRisk = summarizeBatchRisk(batch);
|
|
const isFirstReview = batch.status === "draft" && !batch.reviewed_at;
|
|
const isSecondReview = batchRisk.highRisk && batch.status === "reviewed" && !batch.secondary_reviewed_at;
|
|
|
|
if (!isFirstReview && !isSecondReview) {
|
|
throw buildValidationError({ review: "batch_not_reviewable" }, 409);
|
|
}
|
|
if (batch.items.some((item) => item.status !== "pending")) {
|
|
throw buildValidationError({ items: "all_items_must_be_pending" }, 409);
|
|
}
|
|
if (batch.created_by && batch.created_by === request.employee.id) {
|
|
throw buildValidationError({ review: "maker_checker_conflict" }, 409);
|
|
}
|
|
if (isSecondReview && batch.reviewed_by && batch.reviewed_by === request.employee.id) {
|
|
throw buildValidationError({ review: "second_reviewer_conflict" }, 409);
|
|
}
|
|
|
|
const openRiskFlags = await tx.entityRiskFlag.findMany({
|
|
where: {
|
|
entity_type: batch.entity_type,
|
|
entity_id: { in: batch.items.map((item) => item.entity_id) },
|
|
status: "open",
|
|
},
|
|
});
|
|
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);
|
|
}
|
|
|
|
if (openRiskFlags.length) {
|
|
await tx.entityRiskFlag.updateMany({
|
|
where: { id: { in: openRiskFlags.map((item) => item.id) } },
|
|
data: {
|
|
status: "resolved",
|
|
resolved_at: new Date(),
|
|
resolved_by: request.employee.id,
|
|
},
|
|
});
|
|
}
|
|
|
|
const reviewedAt = new Date();
|
|
const reviewed = await tx.paymentBatch.update({
|
|
where: { id: batchId },
|
|
data: isFirstReview
|
|
? {
|
|
status: "reviewed",
|
|
review_comment: normalizeNullableString(body.comment),
|
|
reviewed_at: reviewedAt,
|
|
reviewed_by: request.employee.id,
|
|
updated_by: request.employee.id,
|
|
}
|
|
: {
|
|
secondary_review_comment: normalizeNullableString(body.comment),
|
|
secondary_reviewed_at: reviewedAt,
|
|
secondary_reviewed_by: request.employee.id,
|
|
updated_by: request.employee.id,
|
|
},
|
|
include: { items: true },
|
|
});
|
|
|
|
await tx.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: isFirstReview ? "PAYMENT_BATCH_REVIEWED" : "PAYMENT_BATCH_SECOND_REVIEWED",
|
|
targetType: "payment_batch",
|
|
targetId: batchId,
|
|
extra: {
|
|
highRisk: batchRisk.highRisk,
|
|
resolvedRiskFlagCount: openRiskFlags.length,
|
|
reviewStage: isFirstReview ? "first_review" : "second_review",
|
|
},
|
|
}),
|
|
});
|
|
|
|
return reviewed;
|
|
});
|
|
|
|
return sendData(reply, request, { item: mapBatch(result, []) });
|
|
});
|
|
|
|
fastify.post("/payment-batches/:id/execute", { preHandler: fastify.authenticate }, async (request, reply) => {
|
|
await ensureFeatureEnabled(fastify);
|
|
ensureBatchManagePermission(fastify, request);
|
|
const batchId = parseId(request.params?.id);
|
|
const successfulReimbursementIds = [];
|
|
|
|
const result = await fastify.prisma.$transaction(async (tx) => {
|
|
const batch = await loadBatchWithItems(tx, batchId);
|
|
const batchRisk = summarizeBatchRisk(batch);
|
|
if (!["reviewed", "failed"].includes(batch.status)) {
|
|
throw buildValidationError({ status: "only_reviewed_or_failed_executable" }, 409);
|
|
}
|
|
if (batchRisk.reviewRequirement.completedReviewCount < batchRisk.reviewRequirement.requiredReviewCount) {
|
|
throw buildValidationError({
|
|
review: batchRisk.highRisk ? "high_risk_second_review_required" : "batch_review_required",
|
|
}, 409);
|
|
}
|
|
await ensureBankReceiptForExecutableBatch(tx, batch);
|
|
|
|
let successCount = 0;
|
|
let failedCount = 0;
|
|
for (const [index, item] of batch.items.entries()) {
|
|
if (!["pending", "failed"].includes(item.status)) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
if (batch.entity_type === "reimbursement") {
|
|
const paymentResult = await executeReimbursementPayment(tx, {
|
|
batch,
|
|
item,
|
|
actorId: request.employee.id,
|
|
index,
|
|
request,
|
|
});
|
|
if (!paymentResult.idempotent) {
|
|
successfulReimbursementIds.push(item.entity_id);
|
|
}
|
|
} else {
|
|
await executeTravelOrderPayment(tx, { batch, item, actorId: request.employee.id, index });
|
|
}
|
|
|
|
await tx.paymentBatchItem.update({
|
|
where: { id: item.id },
|
|
data: {
|
|
status: "executed",
|
|
failure_code: null,
|
|
failure_message: null,
|
|
},
|
|
});
|
|
successCount += 1;
|
|
} catch (error) {
|
|
const failureCode = error instanceof AppError ? error.code : "SYS_001";
|
|
const failureMessage = error instanceof AppError ? error.message : error instanceof Error ? error.message : "支付执行失败";
|
|
await tx.paymentBatchItem.update({
|
|
where: { id: item.id },
|
|
data: {
|
|
status: "failed",
|
|
failure_code: failureCode,
|
|
failure_message: failureMessage,
|
|
},
|
|
});
|
|
await ensureRiskFlag(tx, {
|
|
entityType: batch.entity_type,
|
|
entityId: item.entity_id,
|
|
riskCode: "payment_batch_execute_failed",
|
|
level: "high",
|
|
title: "支付批次执行失败",
|
|
detail: `${item.entity_code} 执行失败:${failureMessage}`,
|
|
source: "payment_batch",
|
|
actorId: request.employee.id,
|
|
payloadJson: {
|
|
batchId: batch.id,
|
|
itemId: item.id,
|
|
failureCode,
|
|
},
|
|
});
|
|
failedCount += 1;
|
|
}
|
|
}
|
|
|
|
const refreshed = await tx.paymentBatch.update({
|
|
where: { id: batchId },
|
|
data: {
|
|
status: failedCount > 0 ? "failed" : "executed",
|
|
execution_summary_json: {
|
|
successCount,
|
|
failedCount,
|
|
attemptedCount: successCount + failedCount,
|
|
},
|
|
executed_at: new Date(),
|
|
executed_by: request.employee.id,
|
|
updated_by: request.employee.id,
|
|
},
|
|
include: { items: true },
|
|
});
|
|
|
|
await tx.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "PAYMENT_BATCH_EXECUTED",
|
|
targetType: "payment_batch",
|
|
targetId: batchId,
|
|
extra: {
|
|
highRisk: batchRisk.highRisk,
|
|
totalAmount: batchRisk.totalAmount,
|
|
successCount,
|
|
failedCount,
|
|
},
|
|
}),
|
|
});
|
|
|
|
return refreshed;
|
|
});
|
|
|
|
for (const reimbursementId of new Set(successfulReimbursementIds)) {
|
|
await processPaidReimbursementFollowUps({
|
|
fastify,
|
|
request,
|
|
reimbursementId,
|
|
});
|
|
}
|
|
|
|
const openRiskFlags = await listOpenRiskFlagsForBatch(fastify.prisma, result);
|
|
return sendData(reply, request, { item: mapBatch(result, openRiskFlags) });
|
|
});
|
|
|
|
fastify.post("/payment-batches/:id/cancel", { preHandler: fastify.authenticate }, async (request, reply) => {
|
|
await ensureFeatureEnabled(fastify);
|
|
ensureBatchManagePermission(fastify, request);
|
|
const batchId = parseId(request.params?.id);
|
|
const batch = await loadBatchWithItems(fastify.prisma, batchId);
|
|
if (["executed", "cancelled"].includes(batch.status)) {
|
|
throw buildValidationError({ status: "batch_not_cancellable" }, 409);
|
|
}
|
|
const cancelled = await fastify.prisma.paymentBatch.update({
|
|
where: { id: batchId },
|
|
data: {
|
|
status: "cancelled",
|
|
cancelled_at: new Date(),
|
|
cancelled_by: request.employee.id,
|
|
updated_by: request.employee.id,
|
|
},
|
|
include: { items: true },
|
|
});
|
|
return sendData(reply, request, { item: mapBatch(cancelled, []) });
|
|
});
|
|
}
|
|
|
|
module.exports = paymentBatchesRoutes;
|