feat(detail-page-structure-progress-actions): 统一详情处理进度与操作区
这个提交包含在:
@@ -19,6 +19,11 @@ const { enqueueReimbursementJournalExport } = require("../services/integrations/
|
||||
const { ensureRiskFlag, buildPersistedRiskFlag, toMoneyNumber, toMoneyString } = require("../services/feeControl");
|
||||
const { decryptStringOrThrow, maskBankAccount } = require("../services/crypto");
|
||||
const { generatePaymentOrderXls, PaymentOrderXlsError } = require("../services/paymentOrderXls");
|
||||
const {
|
||||
buildPaymentProcessingSemantics,
|
||||
requiresBankReceipt,
|
||||
resolveItemPaymentChannel,
|
||||
} = require("../services/paymentProcessingProgress");
|
||||
const { deleteObject, openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
|
||||
const {
|
||||
buildAttachmentContentDisposition,
|
||||
@@ -138,16 +143,8 @@ function mapReceiptAttachment(item) {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveItemPaymentChannel(batch, item) {
|
||||
return item.payment_channel_override ?? batch.default_payment_channel ??
|
||||
(batch.entity_type === "reimbursement" ? "bank" : null);
|
||||
}
|
||||
|
||||
async function ensureBankReceiptForExecutableBatch(tx, batch) {
|
||||
const requiresBankReceipt = (batch.items ?? []).some((item) =>
|
||||
["pending", "failed"].includes(item.status) && resolveItemPaymentChannel(batch, item) === "bank",
|
||||
);
|
||||
if (!requiresBankReceipt) {
|
||||
if (!requiresBankReceipt(batch)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -817,9 +814,64 @@ function mapBatch(batch, openRiskFlags = [], legacyPayees = new Map(), options =
|
||||
};
|
||||
}
|
||||
|
||||
function isoDateTimeOrNull(value) {
|
||||
if (!value) return null;
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
function buildPaymentBatchProcessingProgress(batch, actorNames) {
|
||||
const semantics = buildPaymentProcessingSemantics({
|
||||
status: batch.status,
|
||||
createdAt: batch.created_at,
|
||||
reviewedAt: batch.reviewed_at,
|
||||
executedAt: batch.executed_at,
|
||||
cancelledAt: batch.cancelled_at,
|
||||
cancelledReason: batch.cancelled_reason,
|
||||
requiresBankReceipt: requiresBankReceipt(batch),
|
||||
receiptCount: batch._count?.receipt_attachments ?? 0,
|
||||
paymentOrderExportCount: batch.payment_order_export_count ?? 0,
|
||||
});
|
||||
const actorIds = {
|
||||
creator: batch.created_by,
|
||||
reviewer: batch.reviewed_by,
|
||||
executor: batch.executed_by,
|
||||
canceller: batch.cancelled_by,
|
||||
};
|
||||
return {
|
||||
stages: [semantics.created, semantics.review, semantics.payment].map((stage) => ({
|
||||
key: stage.key,
|
||||
label: stage.label,
|
||||
state: stage.state,
|
||||
actorName: stage.actorSource ? actorNames.get(actorIds[stage.actorSource]) ?? null : null,
|
||||
occurredAt: isoDateTimeOrNull(stage.occurredAt),
|
||||
result: stage.result,
|
||||
comment: stage.comment,
|
||||
detail: stage.detail,
|
||||
targetHref: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function mapBatchForResponse(tx, batch, openRiskFlags, config) {
|
||||
const legacyPayees = await loadLegacyPayees(tx, batch, config);
|
||||
return mapBatch(batch, openRiskFlags, legacyPayees, { includePayee: true });
|
||||
const actorIds = Array.from(new Set([
|
||||
batch.created_by,
|
||||
batch.reviewed_by,
|
||||
batch.executed_by,
|
||||
batch.cancelled_by,
|
||||
].filter(Boolean)));
|
||||
const actors = actorIds.length
|
||||
? await tx.employee.findMany({
|
||||
where: { id: { in: actorIds } },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
: [];
|
||||
const actorNames = new Map(actors.map((actor) => [actor.id, actor.name]));
|
||||
return {
|
||||
...mapBatch(batch, openRiskFlags, legacyPayees, { includePayee: true }),
|
||||
processingProgress: buildPaymentBatchProcessingProgress(batch, actorNames),
|
||||
};
|
||||
}
|
||||
|
||||
async function listOpenRiskFlagsForBatch(tx, batch) {
|
||||
@@ -2183,7 +2235,10 @@ async function paymentBatchesRoutes(fastify) {
|
||||
reviewed_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
include: { items: true },
|
||||
include: {
|
||||
items: true,
|
||||
_count: { select: { receipt_attachments: { where: { archived_at: null } } } },
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
@@ -2359,7 +2414,10 @@ async function paymentBatchesRoutes(fastify) {
|
||||
executed_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
include: { items: true },
|
||||
include: {
|
||||
items: true,
|
||||
_count: { select: { receipt_attachments: { where: { archived_at: null } } } },
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
|
||||
@@ -33,6 +33,11 @@ const {
|
||||
resolveEntityPrecheck,
|
||||
} = require("../services/feeControl");
|
||||
const { issueDocumentNumber } = require("../services/documentSequence");
|
||||
const {
|
||||
PAYMENT_PROCESSING_STAGE_DEFINITIONS,
|
||||
buildPaymentProcessingSemantics,
|
||||
requiresBankReceipt,
|
||||
} = require("../services/paymentProcessingProgress");
|
||||
const { openReadStream, readFileBuffer, StorageNotFoundError, writeBuffer } = require("../services/storage");
|
||||
const {
|
||||
MaterialPreviewError,
|
||||
@@ -333,8 +338,8 @@ const PROCESSING_STAGE_DEFINITIONS = Object.freeze([
|
||||
{ key: "submitted", label: "申请提交" },
|
||||
{ key: "manager_approval", label: "主管审批" },
|
||||
{ key: "finance_review", label: "财务复核" },
|
||||
{ key: "payment_batch_review", label: "付款复核" },
|
||||
{ key: "payment_and_receipt", label: "付款完成" },
|
||||
PAYMENT_PROCESSING_STAGE_DEFINITIONS.review,
|
||||
PAYMENT_PROCESSING_STAGE_DEFINITIONS.payment,
|
||||
]);
|
||||
|
||||
function isoDateTimeOrNull(value) {
|
||||
@@ -514,68 +519,48 @@ function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatch
|
||||
? { state: "current", result: "待安排", detail: "待财务安排付款" }
|
||||
: { detail: "待财务安排付款" });
|
||||
paymentStage.detail = "待付款复核后由财务登记,非审批环节";
|
||||
} else if (batch.status === "draft") {
|
||||
Object.assign(batchReviewStage, {
|
||||
state: "current",
|
||||
result: "待复核",
|
||||
detail: "待非制单人复核付款",
|
||||
targetHref: batchTargetHref,
|
||||
} else {
|
||||
const semantics = buildPaymentProcessingSemantics({
|
||||
status: batch.status,
|
||||
createdAt: batch.created_at,
|
||||
reviewedAt: batch.reviewed_at,
|
||||
executedAt: batch.executed_at,
|
||||
cancelledAt: batch.cancelled_at,
|
||||
cancelledReason: batch.cancelled_reason,
|
||||
requiresBankReceipt: requiresBankReceipt(batch),
|
||||
receiptCount: batch._count?.receipt_attachments ?? 0,
|
||||
paymentOrderExportCount: batch.payment_order_export_count ?? 0,
|
||||
});
|
||||
Object.assign(paymentStage, {
|
||||
detail: "待付款复核通过后由财务登记,非审批环节",
|
||||
targetHref: batchTargetHref,
|
||||
});
|
||||
} else if (["reviewed", "executed", "failed"].includes(batch.status)) {
|
||||
Object.assign(batchReviewStage, {
|
||||
state: "completed",
|
||||
actorName: paymentActorNames.get(batch.reviewed_by) ?? null,
|
||||
occurredAt: isoDateTimeOrNull(batch.reviewed_at),
|
||||
result: "通过",
|
||||
detail: batch.status === "reviewed" ? "付款复核已通过" : null,
|
||||
targetHref: batchTargetHref,
|
||||
});
|
||||
if (batch.status === "reviewed") {
|
||||
Object.assign(paymentStage, {
|
||||
state: "current",
|
||||
result: "待付款",
|
||||
detail: "待财务完成付款并登记回单,非审批环节",
|
||||
targetHref: batchTargetHref,
|
||||
});
|
||||
} else if (batch.status === "failed") {
|
||||
Object.assign(paymentStage, {
|
||||
state: "exception",
|
||||
actorName: paymentActorNames.get(batch.executed_by) ?? null,
|
||||
occurredAt: isoDateTimeOrNull(batch.executed_at),
|
||||
result: "付款失败",
|
||||
detail: "付款失败,待财务重新处理,非审批环节",
|
||||
const actorIds = {
|
||||
reviewer: batch.reviewed_by,
|
||||
executor: batch.executed_by,
|
||||
canceller: batch.cancelled_by,
|
||||
};
|
||||
for (const [target, source] of [
|
||||
[batchReviewStage, semantics.review],
|
||||
[paymentStage, semantics.payment],
|
||||
]) {
|
||||
Object.assign(target, {
|
||||
state: source.state,
|
||||
actorName: source.actorSource
|
||||
? paymentActorNames.get(actorIds[source.actorSource]) ?? null
|
||||
: null,
|
||||
occurredAt: isoDateTimeOrNull(source.occurredAt),
|
||||
result: source.result,
|
||||
comment: source.comment,
|
||||
detail: source.detail,
|
||||
targetHref: batchTargetHref,
|
||||
});
|
||||
}
|
||||
} else if (batch.status === "cancelled") {
|
||||
Object.assign(batchReviewStage, {
|
||||
state: "exception",
|
||||
actorName: paymentActorNames.get(batch.cancelled_by) ?? null,
|
||||
occurredAt: isoDateTimeOrNull(batch.cancelled_at),
|
||||
result: "批次已取消",
|
||||
comment: batch.cancelled_reason ?? null,
|
||||
detail: "付款安排已取消,待财务重新处理",
|
||||
targetHref: batchTargetHref,
|
||||
});
|
||||
Object.assign(paymentStage, {
|
||||
detail: "待财务重新安排付款,非审批环节",
|
||||
targetHref: batchTargetHref,
|
||||
});
|
||||
}
|
||||
|
||||
if (batch?.status === "executed" || reimbursement.status === "paid") {
|
||||
const receiptCount = batch?._count?.receipt_attachments ?? 0;
|
||||
if (!batch && reimbursement.status === "paid") {
|
||||
Object.assign(paymentStage, {
|
||||
state: "completed",
|
||||
actorName: reimbursement.paid_by_employee?.name ?? paymentActorNames.get(batch?.executed_by) ?? null,
|
||||
occurredAt: isoDateTimeOrNull(reimbursement.paid_at ?? batch?.executed_at),
|
||||
actorName: reimbursement.paid_by_employee?.name ?? null,
|
||||
occurredAt: isoDateTimeOrNull(reimbursement.paid_at),
|
||||
result: "已付款",
|
||||
detail: receiptCount > 0 ? "付款和回单已留存" : "付款已完成,回单待留存",
|
||||
targetHref: batchTargetHref,
|
||||
detail: "付款已完成,回单待留存",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2591,6 +2576,10 @@ async function reimbursementRoutes(fastify) {
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
entity_type: true,
|
||||
default_payment_channel: true,
|
||||
payment_order_export_count: true,
|
||||
created_at: true,
|
||||
created_by: true,
|
||||
reviewed_at: true,
|
||||
reviewed_by: true,
|
||||
@@ -2599,6 +2588,12 @@ async function reimbursementRoutes(fastify) {
|
||||
cancelled_at: true,
|
||||
cancelled_by: true,
|
||||
cancelled_reason: true,
|
||||
items: {
|
||||
select: {
|
||||
status: true,
|
||||
payment_channel_override: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { receipt_attachments: { where: { archived_at: null } } },
|
||||
},
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
const PAYMENT_PROCESSING_STAGE_DEFINITIONS = Object.freeze({
|
||||
created: Object.freeze({ key: "payment_batch_created", label: "批次创建" }),
|
||||
review: Object.freeze({ key: "payment_batch_review", label: "付款复核" }),
|
||||
payment: Object.freeze({ key: "payment_and_receipt", label: "付款完成" }),
|
||||
});
|
||||
|
||||
function resolveItemPaymentChannel(batch, item) {
|
||||
return item?.payment_channel_override ?? batch?.default_payment_channel ??
|
||||
(batch?.entity_type === "reimbursement" ? "bank" : null);
|
||||
}
|
||||
|
||||
function requiresBankReceipt(batch) {
|
||||
return (batch?.items ?? []).some((item) =>
|
||||
["pending", "failed"].includes(item.status) && resolveItemPaymentChannel(batch, item) === "bank",
|
||||
);
|
||||
}
|
||||
|
||||
function buildStage(definition, overrides = {}) {
|
||||
return {
|
||||
...definition,
|
||||
state: "pending",
|
||||
result: null,
|
||||
detail: null,
|
||||
comment: null,
|
||||
actorSource: null,
|
||||
occurredAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPaymentProcessingSemantics({
|
||||
status,
|
||||
createdAt = null,
|
||||
reviewedAt = null,
|
||||
executedAt = null,
|
||||
cancelledAt = null,
|
||||
cancelledReason = null,
|
||||
requiresBankReceipt: bankReceiptRequired = false,
|
||||
receiptCount = 0,
|
||||
paymentOrderExportCount = 0,
|
||||
}) {
|
||||
const created = buildStage(PAYMENT_PROCESSING_STAGE_DEFINITIONS.created, {
|
||||
state: "completed",
|
||||
result: "已创建",
|
||||
actorSource: "creator",
|
||||
occurredAt: createdAt,
|
||||
});
|
||||
const review = buildStage(PAYMENT_PROCESSING_STAGE_DEFINITIONS.review);
|
||||
const payment = buildStage(PAYMENT_PROCESSING_STAGE_DEFINITIONS.payment, {
|
||||
detail: "待付款复核通过后由财务登记,非审批环节",
|
||||
});
|
||||
|
||||
if (status === "draft") {
|
||||
Object.assign(review, {
|
||||
state: "current",
|
||||
result: "待复核",
|
||||
detail: "待非制单人复核付款",
|
||||
});
|
||||
} else if (["reviewed", "executed", "failed"].includes(status)) {
|
||||
Object.assign(review, {
|
||||
state: "completed",
|
||||
result: "通过",
|
||||
detail: status === "reviewed" ? "付款复核已通过" : null,
|
||||
actorSource: "reviewer",
|
||||
occurredAt: reviewedAt,
|
||||
});
|
||||
|
||||
if (status === "reviewed") {
|
||||
const hasReceipt = Number(receiptCount) > 0;
|
||||
let detail = "付款复核已通过,等待财务登记付款完成,非审批环节";
|
||||
if (bankReceiptRequired && !hasReceipt) {
|
||||
detail = Number(paymentOrderExportCount) > 0
|
||||
? "已生成银行制单,等待付款完成并上传回单,非审批环节"
|
||||
: "等待财务线下付款并上传回单,非审批环节";
|
||||
} else if (bankReceiptRequired) {
|
||||
detail = "银行回单已上传,等待财务登记付款完成,非审批环节";
|
||||
}
|
||||
Object.assign(payment, {
|
||||
state: "current",
|
||||
result: "待付款",
|
||||
detail,
|
||||
});
|
||||
} else if (status === "executed") {
|
||||
Object.assign(payment, {
|
||||
state: "completed",
|
||||
result: "已付款",
|
||||
detail: Number(receiptCount) > 0 ? "付款和回单已留存" : "付款已登记完成",
|
||||
actorSource: "executor",
|
||||
occurredAt: executedAt,
|
||||
});
|
||||
} else {
|
||||
Object.assign(payment, {
|
||||
state: "exception",
|
||||
result: "付款失败",
|
||||
detail: "付款登记失败,待财务核对后重试,非审批环节",
|
||||
actorSource: "executor",
|
||||
occurredAt: executedAt,
|
||||
});
|
||||
}
|
||||
} else if (status === "cancelled") {
|
||||
const cancellation = {
|
||||
state: "exception",
|
||||
result: "批次已取消",
|
||||
detail: "付款安排已取消,流程已终止",
|
||||
comment: cancelledReason,
|
||||
actorSource: "canceller",
|
||||
occurredAt: cancelledAt,
|
||||
};
|
||||
if (reviewedAt) {
|
||||
Object.assign(review, {
|
||||
state: "completed",
|
||||
result: "通过",
|
||||
actorSource: "reviewer",
|
||||
occurredAt: reviewedAt,
|
||||
});
|
||||
Object.assign(payment, cancellation);
|
||||
} else {
|
||||
Object.assign(review, cancellation);
|
||||
Object.assign(payment, {
|
||||
state: "terminated",
|
||||
result: "已终止",
|
||||
detail: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { created, review, payment };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PAYMENT_PROCESSING_STAGE_DEFINITIONS,
|
||||
buildPaymentProcessingSemantics,
|
||||
requiresBankReceipt,
|
||||
resolveItemPaymentChannel,
|
||||
};
|
||||
@@ -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");
|
||||
在新工单中引用
屏蔽一个用户