收口付款批次创建与复核(08-01-reimbursement-notification-progress-clarity)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-08-02 00:29:47 +08:00
父节点 78b80f88d7
当前提交 7c39f8b0f9
共修改 17 个文件,包含 591 行新增和 227 行删除
+4 -4
查看文件
@@ -422,10 +422,10 @@ function paymentBatchEntityTypeLabel(value) {
}
function paymentBatchStatusLabel(value) {
if (value === "draft") return "草稿";
if (value === "reviewed") return "已复核";
if (value === "executed") return "已执行";
if (value === "failed") return "执行失败";
if (value === "draft") return "待付款复核";
if (value === "reviewed") return "待财务付款";
if (value === "executed") return "已付款";
if (value === "failed") return "付款登记失败";
if (value === "cancelled") return "已取消";
return value ?? "";
}
+38 -16
查看文件
@@ -7,7 +7,7 @@ const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { parseDateOnlyString } = require("../utils/date");
const { currentDateString, parseDateOnlyString } = require("../utils/date");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { createNotificationsWithRecords } = require("../services/notifications");
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
@@ -50,7 +50,7 @@ const CANDIDATES_QUERY_SCHEMA = z.object({
const CREATE_SCHEMA = z.object({
entityType: ENTITY_TYPE_SCHEMA,
name: z.string().trim().min(1).max(120),
name: z.string().trim().min(1).max(120).optional(),
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(),
@@ -132,7 +132,8 @@ function mapReceiptAttachment(item) {
}
function resolveItemPaymentChannel(batch, item) {
return item.payment_channel_override ?? batch.default_payment_channel ?? null;
return item.payment_channel_override ?? batch.default_payment_channel ??
(batch.entity_type === "reimbursement" ? "bank" : null);
}
async function ensureBankReceiptForExecutableBatch(tx, batch) {
@@ -270,9 +271,15 @@ async function resolvePaymentReviewRecipientIds(tx, makerId) {
},
});
return candidates
.filter((employee) => employeeHasRolePermission(employee, "payment.batch.review"))
.map((employee) => employee.id);
const eligible = candidates.filter((employee) =>
employee.id !== makerId && employeeHasRolePermission(employee, "payment.batch.review"),
);
const chiefExecutive = eligible.find((employee) => roleCodesOf(employee).includes("ceo"));
if (chiefExecutive) {
return [chiefExecutive.id];
}
const manager = eligible.find((employee) => roleCodesOf(employee).includes("manager"));
return manager ? [manager.id] : [];
}
async function resolvePaymentExecutionRecipientIds(tx, { makerId, reviewerId }) {
@@ -368,6 +375,10 @@ function buildBatchNo() {
return `PB-${date}-${time}-${String(now.getMilliseconds()).padStart(3, "0")}-${suffix}`;
}
function buildDefaultBatchName(timeZone) {
return `${currentDateString(timeZone ?? "Asia/Shanghai")} 付款批次`;
}
function summarizeBatchRisk(batch) {
const totalAmountValue = (batch.items ?? []).reduce((sum, item) => sum + toMoneyNumber(item.amount), 0);
const totalAmount = toMoneyString(totalAmountValue);
@@ -550,7 +561,7 @@ function mapBatch(batch, openRiskFlags = []) {
name: batch.name,
status: batch.status,
defaultPaymentDate: batch.default_payment_date?.toISOString?.()?.slice(0, 10) ?? null,
defaultPaymentChannel: batch.default_payment_channel ?? null,
defaultPaymentChannel: batch.default_payment_channel ?? (batch.entity_type === "reimbursement" ? "bank" : null),
voucherPrefix: batch.voucher_prefix ?? null,
totalAmount: batchRisk.totalAmount,
highRisk: batchRisk.highRisk,
@@ -606,7 +617,7 @@ async function listOpenRiskFlagsForBatch(tx, batch) {
return items.map(buildPersistedRiskFlag);
}
async function executeReimbursementPayment(tx, { batch, item, actorId, index, request }) {
async function executeReimbursementPayment(tx, { batch, item, actorId, index, request, timeZone }) {
const current = await tx.reimbursementForm.findUnique({
where: { id: item.entity_id },
select: {
@@ -641,8 +652,8 @@ async function executeReimbursementPayment(tx, { batch, item, actorId, index, re
(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;
?? currentDateString(timeZone ?? "Asia/Shanghai");
const paymentChannel = item.payment_channel_override ?? batch.default_payment_channel ?? "bank";
const now = new Date();
const archivedResult = await tx.invoicePool.updateMany({
@@ -823,7 +834,7 @@ async function processPaidReimbursementFollowUps({ fastify, request, reimburseme
}
}
async function executeTravelOrderPayment(tx, { batch, item, actorId, index }) {
async function executeTravelOrderPayment(tx, { batch, item, actorId, index, timeZone }) {
const current = await tx.travelOrder.findUnique({
where: { id: item.entity_id },
select: {
@@ -855,7 +866,7 @@ async function executeTravelOrderPayment(tx, { batch, item, actorId, index }) {
(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;
?? currentDateString(timeZone ?? "Asia/Shanghai");
const paymentChannel = item.payment_channel_override ?? batch.default_payment_channel ?? null;
const matchedTransaction = await tx.cardTransaction.findFirst({
where: {
@@ -1199,14 +1210,19 @@ async function paymentBatchesRoutes(fastify) {
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 batch = await tx.paymentBatch.create({
data: {
batch_no: buildBatchNo(),
entity_type: body.entityType,
name: body.name,
name: body.name ?? buildDefaultBatchName(fastify.config.appTimezone),
status: "draft",
default_payment_date: body.defaultPaymentDate ? parseDateOnlyString(body.defaultPaymentDate) : null,
default_payment_channel: body.defaultPaymentChannel ?? null,
default_payment_channel: body.defaultPaymentChannel ?? "bank",
voucher_prefix: normalizeNullableString(body.voucherPrefix),
created_by: request.employee.id,
updated_by: request.employee.id,
@@ -1243,7 +1259,6 @@ async function paymentBatchesRoutes(fastify) {
}),
});
const recipientEmployeeIds = await resolvePaymentReviewRecipientIds(tx, request.employee.id);
const nextNotificationTask = await createPaymentResponsibilityNotification(tx, {
recipientEmployeeIds,
type: NOTIFICATION_TYPES.paymentBatchReviewRequested,
@@ -1850,12 +1865,19 @@ async function paymentBatchesRoutes(fastify) {
actorId: request.employee.id,
index,
request,
timeZone: fastify.config.appTimezone,
});
if (!paymentResult.idempotent) {
successfulReimbursementIds.push(item.entity_id);
}
} else {
await executeTravelOrderPayment(tx, { batch, item, actorId: request.employee.id, index });
await executeTravelOrderPayment(tx, {
batch,
item,
actorId: request.employee.id,
index,
timeZone: fastify.config.appTimezone,
});
}
await tx.paymentBatchItem.update({
@@ -6,6 +6,7 @@ const Fastify = require("fastify");
const multipart = require("@fastify/multipart");
const { AppError } = require("../../../src/utils/AppError");
const { currentDateString } = require("../../../src/utils/date");
const featureFlags = require("../../../src/services/featureFlags");
const notifications = require("../../../src/services/notifications");
const email = require("../../../src/services/email");
@@ -1033,8 +1034,60 @@ describe("payment batch reimbursement routes", () => {
await app.close();
});
it("creates no payment review notification when no eligible non-maker reviewer exists", async () => {
const { app } = await buildApp({
it("generates the batch title and bank defaults when the client only selects reimbursements", async () => {
const { app, tx } = await buildApp();
const response = await app.inject({
method: "POST",
url: "/api/payment-batches",
headers: { authorization: "Bearer test" },
payload: {
entityType: "reimbursement",
entityIds: [REIMBURSEMENT_ID],
},
});
expect(response.statusCode).toBe(201);
expect(tx.paymentBatch.create).toHaveBeenCalledWith({
data: expect.objectContaining({
name: `${currentDateString("Asia/Shanghai")} 付款批次`,
default_payment_date: null,
default_payment_channel: "bank",
voucher_prefix: null,
status: "draft",
}),
});
await app.close();
});
it("notifies one active chief executive before falling back to managers", async () => {
const chiefExecutiveId = "99999999-1111-4111-8111-111111111111";
const { app, tx } = await buildApp({
employees: [
{ id: REVIEWER_ID, employee_no: "MGR001", status: "active", roles: [{ role_code: "manager" }] },
{ id: chiefExecutiveId, employee_no: "CEO001", status: "active", roles: [{ role_code: "ceo" }] },
],
});
const response = await app.inject({
method: "POST",
url: "/api/payment-batches",
headers: { authorization: "Bearer test" },
payload: { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID] },
});
expect(response.statusCode).toBe(201);
expect(createNotificationsWithRecordsMock).toHaveBeenCalledWith(
tx,
expect.objectContaining({ recipientEmployeeIds: [chiefExecutiveId] }),
);
await app.close();
});
it("rejects creation without an eligible non-maker reviewer and leaves no side effects", async () => {
const { app, tx } = await buildApp({
employees: [
{ id: ACTOR_ID, employee_no: "FIN001", status: "active", roles: [{ role_code: "finance" }] },
],
@@ -1046,21 +1099,39 @@ describe("payment batch reimbursement routes", () => {
headers: { authorization: "Bearer test" },
payload: {
entityType: "reimbursement",
name: "无人复核批次",
entityIds: [REIMBURSEMENT_ID],
},
});
expect(response.statusCode).toBe(201);
expect(createNotificationsWithRecordsMock).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ recipientEmployeeIds: [] }),
);
expect(response.statusCode).toBe(409);
expect(response.json().error.details.review).toBe("no_eligible_reviewer");
expect(tx.paymentBatch.create).not.toHaveBeenCalled();
expect(tx.paymentBatchItem.create).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
expect(createNotificationsWithRecordsMock).not.toHaveBeenCalled();
expect(flushNotificationDispatchTasksMock).not.toHaveBeenCalled();
await app.close();
});
it("uses the company-local registration date when no payment date was entered", async () => {
const { app, state } = await buildApp({
batch: { default_payment_date: null },
});
const response = await app.inject({
method: "POST",
url: `/api/payment-batches/${BATCH_ID}/execute`,
headers: { authorization: "Bearer test" },
payload: {},
});
expect(response.statusCode).toBe(200);
expect(state.reimbursement.payment_date?.toISOString().slice(0, 10)).toBe(currentDateString("Asia/Shanghai"));
await app.close();
});
it("keeps a created batch when payment review notification delivery fails", async () => {
createNotificationsWithRecordsMock.mockResolvedValueOnce([
{ id: "notif-review-failed", recipient_employee_id: REVIEWER_ID },
@@ -1197,6 +1268,35 @@ describe("payment batch reimbursement routes", () => {
await app.close();
});
it("treats a legacy reimbursement batch without a channel as bank without rewriting it", async () => {
const { app, state } = await buildApp({
batch: { default_payment_channel: null },
receiptAttachments: [],
});
const detail = await app.inject({
method: "GET",
url: `/api/payment-batches/${BATCH_ID}`,
headers: { authorization: "Bearer test" },
});
expect(detail.statusCode).toBe(200);
expect(detail.json().data.item.defaultPaymentChannel).toBe("bank");
expect(state.batch.default_payment_channel).toBeNull();
const execute = await app.inject({
method: "POST",
url: `/api/payment-batches/${BATCH_ID}/execute`,
headers: { authorization: "Bearer test" },
payload: {},
});
expect(execute.statusCode).toBe(409);
expect(execute.json().error.details.receiptAttachments).toBe("bank_receipt_required");
expect(state.batch.default_payment_channel).toBeNull();
expect(state.reimbursement.status).toBe("approved");
await app.close();
});
it("rejects execution after the source department leaves the rollout with zero payment side effects", async () => {
const { app, state, tx } = await buildApp({ paymentBatchAllowedDepartmentIds: ["dept-2"] });