收口付款批次创建与复核(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 行删除
@@ -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"] });