收口付款批次创建与复核(08-01-reimbursement-notification-progress-clarity)
这个提交包含在:
@@ -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"] });
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户