feat(detail-page-structure-progress-actions): 统一详情处理进度与操作区
这个提交包含在:
@@ -263,8 +263,8 @@ function buildPrisma(options = {}) {
|
||||
...(options.paymentAccount ?? {}),
|
||||
},
|
||||
employees: options.employees ?? [
|
||||
{ id: MAKER_ID, employee_no: "FIN001", status: "active", roles: [{ role_code: "finance" }] },
|
||||
{ id: REVIEWER_ID, employee_no: "MGR001", status: "active", roles: [{ role_code: "manager" }] },
|
||||
{ id: MAKER_ID, employee_no: "FIN001", name: "制单财务", status: "active", roles: [{ role_code: "finance" }] },
|
||||
{ id: REVIEWER_ID, employee_no: "MGR001", name: "复核负责人", status: "active", roles: [{ role_code: "manager" }] },
|
||||
],
|
||||
invoice: {
|
||||
linked_reimbursement_id: REIMBURSEMENT_ID,
|
||||
@@ -983,6 +983,7 @@ describe("payment batch reimbursement routes", () => {
|
||||
bankAccountMasked: null,
|
||||
});
|
||||
expect(listResponse.json().data.items[0].items[0]).not.toHaveProperty("snapshotJson");
|
||||
expect(listResponse.json().data.items[0]).not.toHaveProperty("processingProgress");
|
||||
expect(tx.reimbursementForm.findMany).toHaveBeenCalledTimes(1);
|
||||
|
||||
const detailResponse = await app.inject({
|
||||
@@ -1002,6 +1003,38 @@ describe("payment batch reimbursement routes", () => {
|
||||
{ code: "legacy_payee_snapshot_missing", entityCode: "BX-20260429-0001" },
|
||||
]));
|
||||
expect(detailResponse.json().data.item.items[0]).not.toHaveProperty("snapshotJson");
|
||||
expect(detailResponse.json().data.item.processingProgress.stages).toEqual([
|
||||
expect.objectContaining({ key: "payment_batch_created", label: "批次创建", state: "completed", actorName: "制单财务" }),
|
||||
expect.objectContaining({ key: "payment_batch_review", label: "付款复核", state: "completed", actorName: "复核负责人" }),
|
||||
expect.objectContaining({ key: "payment_and_receipt", label: "付款完成", state: "current", detail: expect.stringContaining("回单已上传") }),
|
||||
]);
|
||||
expect(JSON.stringify(detailResponse.json().data.item.processingProgress)).not.toContain("snapshot");
|
||||
expect(JSON.stringify(detailResponse.json().data.item.processingProgress)).not.toContain(MAKER_ID);
|
||||
expect(JSON.stringify(detailResponse.json().data.item.processingProgress)).not.toContain(REVIEWER_ID);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("does not ask a reviewed cash batch for a bank receipt or payment order", async () => {
|
||||
const { app } = await buildApp({
|
||||
batch: {
|
||||
default_payment_channel: "cash",
|
||||
payment_order_export_count: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/payment-batches/${BATCH_ID}`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const paymentStage = response.json().data.item.processingProgress.stages[2];
|
||||
expect(paymentStage).toMatchObject({ state: "current", result: "待付款" });
|
||||
expect(paymentStage.detail).toContain("等待财务登记付款完成");
|
||||
expect(paymentStage.detail).not.toContain("回单");
|
||||
expect(paymentStage.detail).not.toContain("制单");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
@@ -599,16 +599,20 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
|
||||
|
||||
it.each([
|
||||
["draft", "current", "pending", "待付款复核通过后由财务登记,非审批环节"],
|
||||
["reviewed", "completed", "current", "待财务完成付款并登记回单,非审批环节"],
|
||||
["reviewed", "completed", "current", "等待财务线下付款并上传回单,非审批环节"],
|
||||
["executed", "completed", "completed", "付款和回单已留存"],
|
||||
["failed", "completed", "exception", "付款失败,待财务重新处理,非审批环节"],
|
||||
["cancelled", "exception", "pending", "待财务重新安排付款,非审批环节"],
|
||||
["failed", "completed", "exception", "付款登记失败,待财务核对后重试,非审批环节"],
|
||||
["cancelled", "exception", "terminated", null],
|
||||
])("maps %s payment batch without exposing a batch link to ordinary applicants", async (status, reviewState, paymentState, detail) => {
|
||||
const paymentBatchItem = {
|
||||
id: "batch-item-1",
|
||||
payment_batch: {
|
||||
id: "batch-1",
|
||||
status,
|
||||
entity_type: "reimbursement",
|
||||
default_payment_channel: "bank",
|
||||
payment_order_export_count: 0,
|
||||
created_at: new Date("2026-04-11T07:00:00.000Z"),
|
||||
created_by: "maker-1",
|
||||
reviewed_at: status === "draft" || status === "cancelled" ? null : new Date("2026-04-11T08:00:00.000Z"),
|
||||
reviewed_by: status === "draft" || status === "cancelled" ? null : "reviewer-1",
|
||||
@@ -617,6 +621,7 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
|
||||
cancelled_at: status === "cancelled" ? new Date("2026-04-11T09:00:00.000Z") : null,
|
||||
cancelled_by: status === "cancelled" ? "reviewer-1" : null,
|
||||
cancelled_reason: status === "cancelled" ? "批次资料需调整" : null,
|
||||
items: [{ status: status === "executed" ? "executed" : "pending", payment_channel_override: null }],
|
||||
_count: { receipt_attachments: status === "executed" ? 1 : 0 },
|
||||
},
|
||||
};
|
||||
@@ -650,6 +655,10 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
|
||||
payment_batch: {
|
||||
id: "batch-1",
|
||||
status: "draft",
|
||||
entity_type: "reimbursement",
|
||||
default_payment_channel: "bank",
|
||||
payment_order_export_count: 0,
|
||||
created_at: new Date("2026-04-11T07:00:00.000Z"),
|
||||
created_by: "maker-1",
|
||||
reviewed_at: null,
|
||||
reviewed_by: null,
|
||||
@@ -658,6 +667,7 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
|
||||
cancelled_at: null,
|
||||
cancelled_by: null,
|
||||
cancelled_reason: null,
|
||||
items: [{ status: "pending", payment_channel_override: null }],
|
||||
_count: { receipt_attachments: 0 },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
const {
|
||||
PAYMENT_PROCESSING_STAGE_DEFINITIONS,
|
||||
buildPaymentProcessingSemantics,
|
||||
requiresBankReceipt,
|
||||
} = require("../../../src/services/paymentProcessingProgress");
|
||||
|
||||
const BASE = {
|
||||
createdAt: new Date("2026-08-03T01:00:00.000Z"),
|
||||
reviewedAt: new Date("2026-08-03T02:00:00.000Z"),
|
||||
executedAt: new Date("2026-08-03T03:00:00.000Z"),
|
||||
cancelledAt: new Date("2026-08-03T04:00:00.000Z"),
|
||||
cancelledReason: "合成取消原因",
|
||||
};
|
||||
|
||||
describe("paymentProcessingProgress", () => {
|
||||
it("owns the three payment stage labels", () => {
|
||||
expect(PAYMENT_PROCESSING_STAGE_DEFINITIONS).toEqual({
|
||||
created: { key: "payment_batch_created", label: "批次创建" },
|
||||
review: { key: "payment_batch_review", label: "付款复核" },
|
||||
payment: { key: "payment_and_receipt", label: "付款完成" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["draft", "current", "pending"],
|
||||
["reviewed", "completed", "current"],
|
||||
["executed", "completed", "completed"],
|
||||
["failed", "completed", "exception"],
|
||||
])("maps %s without a second route-level state matrix", (status, reviewState, paymentState) => {
|
||||
const stages = buildPaymentProcessingSemantics({ ...BASE, status });
|
||||
expect(stages.review.state).toBe(reviewState);
|
||||
expect(stages.payment.state).toBe(paymentState);
|
||||
});
|
||||
|
||||
it("terminates a cancelled draft at review", () => {
|
||||
const stages = buildPaymentProcessingSemantics({ ...BASE, status: "cancelled", reviewedAt: null });
|
||||
expect(stages.review).toMatchObject({
|
||||
state: "exception",
|
||||
result: "批次已取消",
|
||||
actorSource: "canceller",
|
||||
comment: "合成取消原因",
|
||||
});
|
||||
expect(stages.payment).toMatchObject({ state: "terminated", result: "已终止", detail: null });
|
||||
});
|
||||
|
||||
it("terminates a reviewed cancellation at payment", () => {
|
||||
const stages = buildPaymentProcessingSemantics({ ...BASE, status: "cancelled" });
|
||||
expect(stages.review).toMatchObject({ state: "completed", actorSource: "reviewer" });
|
||||
expect(stages.payment).toMatchObject({
|
||||
state: "exception",
|
||||
result: "批次已取消",
|
||||
actorSource: "canceller",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[false, 0, 0, "等待财务登记付款完成"],
|
||||
[true, 0, 0, "等待财务线下付款并上传回单"],
|
||||
[true, 0, 1, "已生成银行制单"],
|
||||
[true, 1, 1, "银行回单已上传"],
|
||||
])("uses the receipt contract (%s, %s, %s)", (bankReceiptRequired, receiptCount, exportCount, text) => {
|
||||
const stages = buildPaymentProcessingSemantics({
|
||||
...BASE,
|
||||
status: "reviewed",
|
||||
requiresBankReceipt: bankReceiptRequired,
|
||||
receiptCount,
|
||||
paymentOrderExportCount: exportCount,
|
||||
});
|
||||
expect(stages.payment.detail).toContain(text);
|
||||
});
|
||||
|
||||
it("derives receipt requirements from the same effective-channel rule", () => {
|
||||
expect(requiresBankReceipt({
|
||||
entity_type: "reimbursement",
|
||||
default_payment_channel: "cash",
|
||||
items: [{ status: "pending", payment_channel_override: null }],
|
||||
})).toBe(false);
|
||||
expect(requiresBankReceipt({
|
||||
entity_type: "reimbursement",
|
||||
default_payment_channel: "cash",
|
||||
items: [{ status: "failed", payment_channel_override: "bank" }],
|
||||
})).toBe(true);
|
||||
expect(requiresBankReceipt({
|
||||
entity_type: "reimbursement",
|
||||
default_payment_channel: null,
|
||||
items: [{ status: "pending", payment_channel_override: null }],
|
||||
})).toBe(true);
|
||||
expect(requiresBankReceipt({
|
||||
entity_type: "reimbursement",
|
||||
default_payment_channel: "bank",
|
||||
items: [{ status: "executed", payment_channel_override: null }],
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps route assembly layers free of a second payment status matrix", () => {
|
||||
const reimbursementsSource = fs.readFileSync(
|
||||
path.resolve(__dirname, "../../../src/routes/reimbursements.js"),
|
||||
"utf8",
|
||||
);
|
||||
const paymentBatchesSource = fs.readFileSync(
|
||||
path.resolve(__dirname, "../../../src/routes/paymentBatches.js"),
|
||||
"utf8",
|
||||
);
|
||||
const reimbursementAssembler = reimbursementsSource.slice(
|
||||
reimbursementsSource.indexOf("function buildProcessingProgress("),
|
||||
reimbursementsSource.indexOf("function ensureApplicantOrAdmin("),
|
||||
);
|
||||
const paymentBatchAssembler = paymentBatchesSource.slice(
|
||||
paymentBatchesSource.indexOf("function buildPaymentBatchProcessingProgress("),
|
||||
paymentBatchesSource.indexOf("async function mapBatchForResponse("),
|
||||
);
|
||||
|
||||
for (const assembler of [reimbursementAssembler, paymentBatchAssembler]) {
|
||||
expect(assembler).toContain("buildPaymentProcessingSemantics(");
|
||||
expect(assembler).not.toMatch(/batch\.status\s*===|\.includes\(batch\.status\)/);
|
||||
expect(assembler).not.toContain('label: "付款复核"');
|
||||
expect(assembler).not.toContain('label: "付款完成"');
|
||||
}
|
||||
});
|
||||
});
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
在新工单中引用
屏蔽一个用户