fix: 增加回单核对与付款登记门禁(09-08-reimbursement-payment-closeout)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-09-15 07:21:55 +08:00
父节点 229867f5fd
当前提交 8d08d46d16
共修改 33 个文件,包含 3691 行新增和 11 行删除
@@ -303,6 +303,9 @@ async function mockSystemActionApis(
batchStatus?: SyntheticPaymentBatchStatus;
hasReceipt?: boolean;
receiptQueryError?: boolean;
receiptOcrState?: "ocr_failed" | "queued" | "system_failed" | "key_failed" | "snapshot_failed";
receiptManualReview?: boolean;
receiptReady?: boolean;
highRisk?: boolean;
paymentChannel?: "bank" | "cash";
canExportPaymentOrder?: boolean;
@@ -319,6 +322,7 @@ async function mockSystemActionApis(
const currentMe = { ...me, ...options.meOverride };
const batchStatus = options.batchStatus ?? "reviewed";
let hasReceipt = options.hasReceipt ?? true;
let receiptOcrState = options.receiptOcrState;
let paymentOrderExportCount = options.paymentOrderExportCount ?? 0;
let currentReimbursementStatus = options.reimbursementStatus ?? "draft";
const calls = {
@@ -331,6 +335,9 @@ async function mockSystemActionApis(
previewPaymentReceipt: 0,
uploadPaymentReceipt: 0,
voidPaymentReceipt: 0,
retryReceiptOcr: 0,
manualReceiptSaves: [] as Array<Record<string, unknown>>,
manualReceiptReviews: [] as Array<Record<string, unknown>>,
attachmentDeleteReasons: [] as string[],
receiptVoidReasons: [] as string[],
reimbursementUpdates: [] as Array<Record<string, unknown>>,
@@ -568,6 +575,39 @@ async function mockSystemActionApis(
return;
}
if (path === `/api/payment-batches/${paymentBatchId}/receipt-readiness` && request.method() === "GET") {
await fulfillJson(route, { item: { ready: options.receiptReady ?? true } });
return;
}
const verificationPath = `/api/payment-batches/${paymentBatchId}/receipt-attachments/${paymentReceiptId}/manual-verification`;
if (path === verificationPath && request.method() === "GET") {
const facts = { pageNumber: 1, amount: "88.00", currency: "CNY", payerAccountMasked: "0000****2222",
payeeAccountMasked: "0000****4444", reference: "SYN-MANUAL-UI", transactionStatus: "success" };
await fulfillJson(route, { item: { version: 3, state: options.receiptManualReview ? "pending_review" : "needs_review",
canReview: Boolean(options.receiptManualReview), pagesExpected: 1, pages: [facts],
proposal: options.receiptManualReview ? { id: "synthetic-proposal", status: "pending_review",
proposedBy: "another-finance", reviewedBy: null, reason: "合成核验说明",
changedFields: [{ pageNumber: 1, fields: ["amount"] }],
pages: [{ ...facts, itemId: "payment-batch-item-action-confirm" }] } : null } });
return;
}
if (path === verificationPath && request.method() === "POST") {
calls.manualReceiptSaves.push(request.postDataJSON());
await fulfillJson(route, { item: { id: "synthetic-proposal", version: 4, status: "pending_review" } });
return;
}
if (path === `${verificationPath}/synthetic-proposal/review` && request.method() === "POST") {
calls.manualReceiptReviews.push(request.postDataJSON());
await fulfillJson(route, { item: { id: "synthetic-proposal", version: 3, status: "approved" } });
return;
}
if (path === `/api/payment-batches/${paymentBatchId}/receipt-attachments/${paymentReceiptId}/ocr-retry` && request.method() === "POST") {
expect(request.postDataJSON()).toEqual({ expectedVersion: 3 });
calls.retryReceiptOcr += 1;
receiptOcrState = "queued";
await fulfillJson(route, { item: { id: paymentReceiptId, ocrState: "queued", ocrVersion: 4 } });
return;
}
if (path === `/api/payment-batches/${paymentBatchId}/receipt-attachments` && request.method() === "GET") {
if (options.receiptQueryError) {
await route.fulfill({
@@ -585,6 +625,9 @@ async function mockSystemActionApis(
{
id: paymentReceiptId,
originalName: "合成银行回单.pdf",
ocrState: receiptOcrState,
ocrVersion: receiptOcrState === "queued" ? 4 : 3,
ocrRetryAvailable: receiptOcrState === "ocr_failed",
mimeType: "application/pdf",
fileSize: 2048,
fileSha256: "synthetic-receipt-hash",
@@ -1312,6 +1355,94 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await expect(dialog).not.toBeVisible();
});
for (const width of [1440, 390]) {
for (const state of ["system_failed", "key_failed", "snapshot_failed"] as const) {
test(`回单系统失败引导管理员且不重传 ${width} ${state}`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
const calls = await mockSystemActionApis(page, { receiptOcrState: state, receiptReady: false });
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
const receipt = page.locator(".detail-evidence-card").filter({ hasText: "合成银行回单.pdf" });
await expect(receipt).toContainText("请联系管理员");
await expect(receipt.getByRole("button", { name: "重新识别", exact: true })).toHaveCount(0);
await expect(receipt.getByRole("button", { name: "核验详情", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "登记付款完成", exact: true })).toHaveCount(0);
expect(calls.retryReceiptOcr).toBe(0);
expect(calls.executePaymentBatch).toBe(0);
});
}
test(`回单未核验不能登记付款 ${width}`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
const calls = await mockSystemActionApis(page, { receiptOcrState: "ocr_failed", receiptReady: false });
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
await expect(page.getByText("回单尚未满足付款登记条件", { exact: false })).toBeVisible();
await expect(page.getByRole("button", { name: "登记付款完成", exact: true })).toHaveCount(0);
expect(calls.executePaymentBatch).toBe(0);
});
for (const correction of [false, true]) {
test(`回单人工核验只提交关联或更正且不付款 ${width} ${correction}`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
const calls = await mockSystemActionApis(page, { receiptOcrState: "ocr_failed" });
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
await page.getByRole("button", { name: "核验详情", exact: true }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toContainText("0000****2222");
await dialog.getByRole("combobox", { name: "第1页对应付款明细" }).selectOption("payment-batch-item-action-confirm");
if (correction) {
await dialog.getByRole("button", { name: "更正识别", exact: true }).click();
await dialog.getByRole("textbox", { name: "第1页更正小写金额", exact: true }).fill("88.00");
await expect(dialog.getByRole("button", { name: "保存核验", exact: true })).toBeDisabled();
await dialog.getByRole("textbox", { name: "更正说明(仅关联可留空)", exact: true }).fill("核对原件更正金额");
}
await expect(dialog).toBeInViewport();
await dialog.getByRole("button", { name: "保存核验", exact: true }).click();
await expect.poll(() => calls.manualReceiptSaves.length).toBe(1);
expect(calls.manualReceiptSaves[0]).toEqual({
expectedVersion: 3, ...(correction ? { reason: "核对原件更正金额" } : {}),
pages: [{ pageNumber: 1, itemId: "payment-batch-item-action-confirm",
...(correction ? { fields: [{ Name: "小写金额", Value: "88.00" }] } : {}) }],
});
expect(calls.executePaymentBatch).toBe(0);
expect(calls.uploadPaymentReceipt).toBe(0);
await expect(dialog).not.toBeVisible();
});
}
test(`回单独立复核查看更正且不触发付款 ${width}`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
const calls = await mockSystemActionApis(page, { receiptOcrState: "ocr_failed", receiptManualReview: true,
meOverride: { id: "independent-receipt-reviewer" } });
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
await page.getByRole("button", { name: "核验详情", exact: true }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toContainText("人工核验结果");
await expect(dialog).toContainText("更正字段:金额");
await expect(dialog.getByRole("button", { name: "确认复核", exact: true })).toBeDisabled();
await dialog.getByRole("textbox", { name: "复核说明", exact: true }).fill("已核对合成原回单");
await dialog.getByRole("button", { name: "确认复核", exact: true }).click();
await expect.poll(() => calls.manualReceiptReviews.length).toBe(1);
expect(calls.manualReceiptReviews[0]).toEqual({ expectedVersion: 3, decision: "approve", reason: "已核对合成原回单" });
expect(calls.executePaymentBatch).toBe(0);
expect(calls.manualReceiptSaves).toHaveLength(0);
});
test(`回单OCR失败可重试且不触发付款登记 ${width}`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
const calls = await mockSystemActionApis(page, { receiptOcrState: "ocr_failed" });
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
const receipt = page.locator(".detail-evidence-card").filter({ hasText: "合成银行回单.pdf" });
await expect(receipt).toContainText("识别未完成,可重试");
await receipt.getByRole("button", { name: "重新识别", exact: true }).click();
await expect(receipt).toContainText("等待识别");
await expect(receipt.getByRole("button", { name: "重新识别", exact: true })).toHaveCount(0);
expect(calls.retryReceiptOcr).toBe(1);
expect(calls.executePaymentBatch).toBe(0);
expect(calls.uploadPaymentReceipt).toBe(0);
});
}
test("付款回单作废要求对象、后果和理由,取消与理由不足均为零请求", async ({ page }) => {
const calls = await mockSystemActionApis(page);
await login(page, user);