R78 A2:补齐报销支付批次付款后回归
这个提交包含在:
@@ -5,6 +5,11 @@ const { sendData } = require("../utils/reply");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { 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 UUID_SCHEMA = z.string().uuid();
|
||||
@@ -371,6 +376,140 @@ async function executeReimbursementPayment(tx, { batch, item, actorId, index })
|
||||
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 },
|
||||
@@ -680,6 +819,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
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);
|
||||
@@ -702,7 +842,10 @@ async function paymentBatchesRoutes(fastify) {
|
||||
|
||||
try {
|
||||
if (batch.entity_type === "reimbursement") {
|
||||
await executeReimbursementPayment(tx, { batch, item, actorId: request.employee.id, index });
|
||||
const paymentResult = await executeReimbursementPayment(tx, { batch, item, actorId: request.employee.id, index });
|
||||
if (!paymentResult.idempotent) {
|
||||
successfulReimbursementIds.push(item.entity_id);
|
||||
}
|
||||
} else {
|
||||
await executeTravelOrderPayment(tx, { batch, item, actorId: request.employee.id, index });
|
||||
}
|
||||
@@ -780,6 +923,14 @@ async function paymentBatchesRoutes(fastify) {
|
||||
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) });
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户