|
|
|
@@ -293,6 +293,7 @@ function buildPrisma(options = {}) {
|
|
|
|
|
const tx = {
|
|
|
|
|
paymentAccount: {
|
|
|
|
|
findFirst: vi.fn(async () => ({ ...state.paymentAccount })),
|
|
|
|
|
findMany: vi.fn(async ({ where }) => where.legal_entity_id.in.includes(state.paymentAccount.legal_entity_id) && state.paymentAccount.enabled && !state.paymentAccount.archived_at ? [{ ...state.paymentAccount }] : []),
|
|
|
|
|
},
|
|
|
|
|
paymentBatch: {
|
|
|
|
|
findUnique: vi.fn(async ({ where }) => {
|
|
|
|
@@ -431,12 +432,12 @@ function buildPrisma(options = {}) {
|
|
|
|
|
count: vi.fn(async () =>
|
|
|
|
|
options.candidateCount ?? (state.reservedEntityIds.includes(state.reimbursement.id) ? 0 : 1),
|
|
|
|
|
),
|
|
|
|
|
findMany: vi.fn(async ({ select } = {}) => {
|
|
|
|
|
findMany: vi.fn(async ({ select, where } = {}) => {
|
|
|
|
|
if (select?.owner?.select?.employee_no) {
|
|
|
|
|
return state.reservedEntityIds.includes(state.reimbursement.id) ? [] : [{ ...state.reimbursement }];
|
|
|
|
|
return [state.reimbursement, ...state.additionalReimbursements].filter((item) => item.status === "approved" && !item.paid_at).map((item) => ({ ...item }));
|
|
|
|
|
}
|
|
|
|
|
const reimbursements = [state.reimbursement, ...state.additionalReimbursements];
|
|
|
|
|
return reimbursements.map((item) => ({ ...item }));
|
|
|
|
|
return reimbursements.filter((item) => !where?.id?.in || where.id.in.includes(item.id)).map((item) => ({ ...item }));
|
|
|
|
|
}),
|
|
|
|
|
findUnique: vi.fn(async ({ where }) => {
|
|
|
|
|
if (where.id !== state.reimbursement.id) {
|
|
|
|
@@ -488,6 +489,7 @@ function buildPrisma(options = {}) {
|
|
|
|
|
findFirst: vi.fn(async () => null),
|
|
|
|
|
updateMany: vi.fn(async () => ({ count: 0 })),
|
|
|
|
|
create: vi.fn(async ({ data }) => ({ id: "risk-1", ...data })),
|
|
|
|
|
update: vi.fn(async ({ where, data }) => ({ id: where.id, ...data })),
|
|
|
|
|
},
|
|
|
|
|
auditLog: {
|
|
|
|
|
create: vi.fn(async () => ({ id: "audit-1" })),
|
|
|
|
@@ -648,6 +650,112 @@ afterAll(() => {
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("payment batch reimbursement routes", () => {
|
|
|
|
|
it("preserves internal failure summaries while refreshing safe reason metadata on an existing risk", async () => {
|
|
|
|
|
const { app, tx, state } = await buildApp({ reimbursement: { status: "reviewing" } });
|
|
|
|
|
const risk = {
|
|
|
|
|
id: "existing-risk", entity_type: "reimbursement", entity_id: REIMBURSEMENT_ID,
|
|
|
|
|
risk_code: "payment_batch_execute_failed", level: "high", title: "支付批次执行失败",
|
|
|
|
|
detail: "旧失败摘要", source: "payment_batch", status: "open",
|
|
|
|
|
payload_json: { batchId: "previous-batch", itemId: "previous-item", failureCode: "VALID_001" },
|
|
|
|
|
};
|
|
|
|
|
tx.entityRiskFlag.findFirst.mockResolvedValue(risk);
|
|
|
|
|
tx.entityRiskFlag.findMany.mockImplementation(async () => [risk]);
|
|
|
|
|
tx.entityRiskFlag.update.mockImplementation(async ({ data }) => Object.assign(risk, data));
|
|
|
|
|
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.batch.status).toBe("failed");
|
|
|
|
|
expect(state.batch.items[0]).toMatchObject({ failure_code: "VALID_001", failure_message: "参数校验失败" });
|
|
|
|
|
expect(risk.status).toBe("open");
|
|
|
|
|
expect(risk.payload_json).toEqual({ batchId: BATCH_ID, itemId: ITEM_ID, failureCode: "VALID_001", reasonCode: "payment_batch.source_status_invalid" });
|
|
|
|
|
expect(risk.detail).toBe("来源单据尚未满足付款条件或已经付款,请刷新并核实审批与付款状态。");
|
|
|
|
|
expect(tx.entityRiskFlag.create).not.toHaveBeenCalled();
|
|
|
|
|
expect(response.json().data.item.items[0].openRiskFlags[0].payloadJson.reasonCode).toBe("payment_batch.source_status_invalid");
|
|
|
|
|
await app.close();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("projects synthetic 0014 omissions in candidates and preflight without writes, and recomputes after repair", async () => {
|
|
|
|
|
const { app, tx, state } = await buildApp();
|
|
|
|
|
const completeOwner = { ...state.reimbursement.owner };
|
|
|
|
|
state.reimbursement.owner = { ...completeOwner, legal_entity_id: null, bank_institution: null, bank_account_encrypted: null };
|
|
|
|
|
const candidates = await app.inject({ method: "GET", url: "/api/payment-batches/candidates", headers: { authorization: "Bearer test" } });
|
|
|
|
|
expect(candidates.statusCode).toBe(200);
|
|
|
|
|
const expected = ["payment_batch.legal_entity_required", "payment_batch.payee_bank_required", "payment_batch.payee_account_required"];
|
|
|
|
|
expect(candidates.json().data.items[0].readiness.blockers.map((blocker) => blocker.reasonCode)).toEqual(expected);
|
|
|
|
|
const payload = { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID], defaultPaymentChannel: "bank" };
|
|
|
|
|
const preflight = await app.inject({ method: "POST", url: "/api/payment-batches/readiness", headers: { authorization: "Bearer test" }, payload });
|
|
|
|
|
expect(preflight.statusCode).toBe(200);
|
|
|
|
|
expect(preflight.json().data.blockers.map((blocker) => blocker.reasonCode)).toEqual(expected);
|
|
|
|
|
for (const blocker of preflight.json().data.blockers) expect(Object.keys(blocker)).toEqual(["reasonCode", "entityId", "entityCode"]);
|
|
|
|
|
expect(tx.paymentBatch.create).not.toHaveBeenCalled();
|
|
|
|
|
expect(tx.paymentBatchItem.create).not.toHaveBeenCalled();
|
|
|
|
|
expect(tx.auditLog.create).not.toHaveBeenCalled();
|
|
|
|
|
expect(createNotificationsWithRecordsMock).not.toHaveBeenCalled();
|
|
|
|
|
state.reimbursement.owner = completeOwner;
|
|
|
|
|
const repaired = await app.inject({ method: "POST", url: "/api/payment-batches/readiness", headers: { authorization: "Bearer test" }, payload });
|
|
|
|
|
expect(repaired.json().data).toEqual({ ready: true, blockers: [] });
|
|
|
|
|
await app.close();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it.each([
|
|
|
|
|
["source_status_invalid", (state) => { state.reimbursement.status = "paid"; }],
|
|
|
|
|
["active_batch_exists", (state) => { state.reservedEntityIds.push(REIMBURSEMENT_ID); }],
|
|
|
|
|
["legal_entity_required", (state) => { state.reimbursement.owner.legal_entity_id = null; }],
|
|
|
|
|
["payee_bank_unsupported", (state) => { state.reimbursement.owner.bank_institution.code = "OTHER"; }],
|
|
|
|
|
["payee_bank_required", (state) => { state.reimbursement.owner.bank_institution = null; }],
|
|
|
|
|
["payee_account_required", (state) => { state.reimbursement.owner.bank_account_encrypted = null; }],
|
|
|
|
|
["payment_account_required", (state) => { state.paymentAccount.enabled = false; }],
|
|
|
|
|
["payment_account_unavailable", (state) => { state.paymentAccount.account_number_encrypted = "synthetic-invalid"; }],
|
|
|
|
|
["reviewer_required", (state) => { state.employees = []; }],
|
|
|
|
|
])("rechecks %s after locking and refuses all creation side effects", async (reason, change) => {
|
|
|
|
|
const { app, tx, state } = await buildApp();
|
|
|
|
|
const payload = { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID], defaultPaymentChannel: "bank" };
|
|
|
|
|
const preflight = await app.inject({ method: "POST", url: "/api/payment-batches/readiness", headers: { authorization: "Bearer test" }, payload });
|
|
|
|
|
expect(preflight.statusCode).toBe(200);
|
|
|
|
|
expect(preflight.json().data).toEqual({ ready: true, blockers: [] });
|
|
|
|
|
tx.$queryRaw.mockImplementationOnce(async () => { change(state); return []; });
|
|
|
|
|
const response = await app.inject({ method: "POST", url: "/api/payment-batches", headers: { authorization: "Bearer test" }, payload });
|
|
|
|
|
expect(response.statusCode).toBe(409);
|
|
|
|
|
expect(response.json().error.code).toBe("VALID_001");
|
|
|
|
|
expect(response.json().error.details.reasonCode).toBe(`payment_batch.${reason}`);
|
|
|
|
|
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
|
|
|
|
|
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("loads candidate account and reviewer facts in batches and rejects a cross-company combination", async () => {
|
|
|
|
|
const secondId = "a1111111-1111-4111-8111-111111111111";
|
|
|
|
|
const { app, tx, state } = await buildApp({ additionalReimbursements: [{ id: secondId }] });
|
|
|
|
|
state.additionalReimbursements[0].owner.legal_entity_id = "second-company";
|
|
|
|
|
tx.paymentAccount.findMany.mockResolvedValue([state.paymentAccount, { ...state.paymentAccount, id: "second-account", legal_entity_id: "second-company" }]);
|
|
|
|
|
const candidates = await app.inject({ method: "GET", url: "/api/payment-batches/candidates", headers: { authorization: "Bearer test" } });
|
|
|
|
|
expect(candidates.statusCode).toBe(200);
|
|
|
|
|
expect(candidates.json().data.items.map((item) => item.readiness)).toEqual([{ ready: true, blockers: [] }, { ready: true, blockers: [] }]);
|
|
|
|
|
expect(tx.paymentAccount.findMany).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(tx.employee.findMany).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(tx.paymentBatchItem.findMany).toHaveBeenCalledTimes(1);
|
|
|
|
|
const payload = { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID, secondId], defaultPaymentChannel: "bank" };
|
|
|
|
|
const preflight = await app.inject({ method: "POST", url: "/api/payment-batches/readiness", headers: { authorization: "Bearer test" }, payload });
|
|
|
|
|
expect(preflight.json().data).toEqual({ ready: false, blockers: [{ reasonCode: "payment_batch.multiple_legal_entities" }] });
|
|
|
|
|
const create = await app.inject({ method: "POST", url: "/api/payment-batches", headers: { authorization: "Bearer test" }, payload });
|
|
|
|
|
expect(create.statusCode).toBe(409);
|
|
|
|
|
expect(create.json().error.details).toEqual({ legalEntity: "single_legal_entity_required", reasonCode: "payment_batch.multiple_legal_entities" });
|
|
|
|
|
expect(tx.paymentBatch.create).not.toHaveBeenCalled();
|
|
|
|
|
await app.close();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rejects unexpected preflight fields without touching creation state", async () => {
|
|
|
|
|
const { app, tx } = await buildApp();
|
|
|
|
|
const response = await app.inject({ method: "POST", url: "/api/payment-batches/readiness", headers: { authorization: "Bearer test" }, payload: { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID], unexpected: true } });
|
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
|
|
|
expect(response.json().error.details.reasonCode).toBe("payment_batch.invalid_input");
|
|
|
|
|
expect(tx.paymentBatch.create).not.toHaveBeenCalled();
|
|
|
|
|
await app.close();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("executes reimbursement batch payment and queues downstream follow-ups", async () => {
|
|
|
|
|
createNotificationsWithRecordsMock.mockResolvedValueOnce([
|
|
|
|
|
{ id: "notif-1", recipient_employee_id: OWNER_ID },
|
|
|
|
@@ -838,7 +946,7 @@ describe("payment batch reimbursement routes", () => {
|
|
|
|
|
await app.close();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("omits reimbursements already reserved by a non-cancelled payment batch", async () => {
|
|
|
|
|
it("keeps reserved candidates visible with the shared blocking reason", async () => {
|
|
|
|
|
const { app } = await buildApp({ reservedEntityIds: [REIMBURSEMENT_ID] });
|
|
|
|
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
@@ -848,12 +956,12 @@ describe("payment batch reimbursement routes", () => {
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
|
expect(response.json().data).toMatchObject({ total: 0, items: [] });
|
|
|
|
|
expect(response.json().data).toMatchObject({ total: 1, items: [{ readiness: { ready: false, blockers: [{ reasonCode: "payment_batch.active_batch_exists" }] } }] });
|
|
|
|
|
|
|
|
|
|
await app.close();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("omits reimbursement candidates outside the payment rollout department allowlist", async () => {
|
|
|
|
|
it("keeps department-blocked candidates visible but not ready", async () => {
|
|
|
|
|
const { app } = await buildApp({ paymentBatchAllowedDepartmentIds: ["dept-2"] });
|
|
|
|
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
@@ -863,7 +971,7 @@ describe("payment batch reimbursement routes", () => {
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
|
expect(response.json().data).toMatchObject({ total: 0, items: [] });
|
|
|
|
|
expect(response.json().data).toMatchObject({ total: 1, items: [{ readiness: { ready: false, blockers: [{ reasonCode: "payment_batch.department_unavailable" }] } }] });
|
|
|
|
|
|
|
|
|
|
await app.close();
|
|
|
|
|
});
|
|
|
|
@@ -1933,7 +2041,7 @@ describe("payment batch reimbursement routes", () => {
|
|
|
|
|
headers: { authorization: "Bearer test" },
|
|
|
|
|
});
|
|
|
|
|
expect(missingReasonResponse.statusCode).toBe(400);
|
|
|
|
|
expect(missingReasonResponse.json().error.details).toEqual({ reason: "required" });
|
|
|
|
|
expect(missingReasonResponse.json().error.details).toEqual({ reason: "required", reasonCode: "payment_batch.download_reason_required" });
|
|
|
|
|
|
|
|
|
|
const previewResponse = await app.inject({
|
|
|
|
|
method: "GET",
|
|
|
|
|