feat(reimbursement-small-team-form-validation): 完善付款批次核对与回单预览
这个提交包含在:
@@ -80,7 +80,7 @@ function reimbursementListItem() {
|
||||
};
|
||||
}
|
||||
|
||||
function reimbursementDetail(status: "draft" | "reviewing" | "paid" = "draft") {
|
||||
function reimbursementDetail(status: "draft" | "reviewing" | "paid" = "draft", isTravel = false) {
|
||||
const workflowTaskId = "workflow-task-action-confirm";
|
||||
return {
|
||||
...reimbursementListItem(),
|
||||
@@ -90,7 +90,9 @@ function reimbursementDetail(status: "draft" | "reviewing" | "paid" = "draft") {
|
||||
departmentId: "department-action-confirm",
|
||||
department: { id: "department-action-confirm", name: "合成部门" },
|
||||
submittedAt: null,
|
||||
travelSummary: { isTravel: false, purpose: null, location: null, startDate: null, endDate: null },
|
||||
travelSummary: isTravel
|
||||
? { isTravel: true, purpose: "拜访客户讨论需求", location: "西安", startDate: "2026-05-14", endDate: "2026-05-15" }
|
||||
: { isTravel: false, purpose: null, location: null, startDate: null, endDate: null },
|
||||
projectId: null,
|
||||
linkedTravelRequestId: null,
|
||||
linkedTravelRequest: null,
|
||||
@@ -103,7 +105,9 @@ function reimbursementDetail(status: "draft" | "reviewing" | "paid" = "draft") {
|
||||
expenseDate: "2026-05-15",
|
||||
amount: "88.00",
|
||||
description: "合成报销动作回归",
|
||||
expenseType: { id: "expense-type-action-confirm", code: "office", name: "办公费", category: "office", enabled: true },
|
||||
expenseType: isTravel
|
||||
? { id: "expense-type-action-confirm", code: "travel_train", name: "火车票", category: "travel", enabled: true }
|
||||
: { id: "expense-type-action-confirm", code: "office", name: "办公费", category: "office", enabled: true },
|
||||
},
|
||||
],
|
||||
invoices: [
|
||||
@@ -192,11 +196,12 @@ function paymentBatch(
|
||||
paymentChannel: "bank" | "cash" = "bank",
|
||||
canExportPaymentOrder = false,
|
||||
paymentOrderExportCount = 0,
|
||||
entityType: "reimbursement" | "travel_order" = "reimbursement",
|
||||
) {
|
||||
return {
|
||||
id: paymentBatchId,
|
||||
batchNo: "PAY-ACTION-001",
|
||||
entityType: "reimbursement",
|
||||
entityType,
|
||||
name: "R7E 高风险付款确认",
|
||||
status,
|
||||
defaultPaymentDate: "2026-05-15",
|
||||
@@ -250,6 +255,17 @@ function paymentBatch(
|
||||
failureMessage: null,
|
||||
createdAt: "2026-05-15T08:00:00.000Z",
|
||||
openRiskFlags: [],
|
||||
expenseItems: [
|
||||
{
|
||||
id: "expense-item-action-confirm",
|
||||
expenseTypeName: "办公用品",
|
||||
expenseDate: "2026-05-14",
|
||||
description: "项目讨论资料",
|
||||
amount: "88.00",
|
||||
},
|
||||
],
|
||||
expenseItemsTotal: "88.00",
|
||||
expenseItemsAmountMatches: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -265,7 +281,9 @@ async function mockSystemActionApis(
|
||||
paymentChannel?: "bank" | "cash";
|
||||
canExportPaymentOrder?: boolean;
|
||||
paymentOrderExportCount?: number;
|
||||
paymentEntityType?: "reimbursement" | "travel_order";
|
||||
reimbursementStatus?: "draft" | "reviewing" | "paid";
|
||||
travelReimbursement?: boolean;
|
||||
meOverride?: Partial<typeof me>;
|
||||
} = {},
|
||||
) {
|
||||
@@ -279,10 +297,12 @@ async function mockSystemActionApis(
|
||||
exportReimbursements: 0,
|
||||
executePaymentBatch: 0,
|
||||
paymentBatchDetail: 0,
|
||||
previewPaymentReceipt: 0,
|
||||
uploadPaymentReceipt: 0,
|
||||
voidPaymentReceipt: 0,
|
||||
attachmentDeleteReasons: [] as string[],
|
||||
receiptVoidReasons: [] as string[],
|
||||
reimbursementUpdates: [] as Array<Record<string, unknown>>,
|
||||
submitReimbursement: 0,
|
||||
withdrawReimbursement: 0,
|
||||
remindWorkflow: 0,
|
||||
@@ -370,7 +390,17 @@ async function mockSystemActionApis(
|
||||
}
|
||||
|
||||
if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "GET") {
|
||||
await fulfillJson(route, reimbursementDetail(currentReimbursementStatus));
|
||||
await fulfillJson(route, reimbursementDetail(currentReimbursementStatus, options.travelReimbursement));
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "PATCH") {
|
||||
const body = request.postDataJSON() as Record<string, unknown>;
|
||||
calls.reimbursementUpdates.push(body);
|
||||
await fulfillJson(route, {
|
||||
...reimbursementDetail(currentReimbursementStatus, options.travelReimbursement),
|
||||
reimbursementDate: body.reimbursementDate ?? "2026-05-14",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -460,6 +490,7 @@ async function mockSystemActionApis(
|
||||
options.paymentChannel,
|
||||
options.canExportPaymentOrder,
|
||||
options.paymentOrderExportCount,
|
||||
options.paymentEntityType,
|
||||
);
|
||||
await fulfillJson(route, {
|
||||
items: [{
|
||||
@@ -485,6 +516,7 @@ async function mockSystemActionApis(
|
||||
options.paymentChannel,
|
||||
options.canExportPaymentOrder,
|
||||
options.paymentOrderExportCount,
|
||||
options.paymentEntityType,
|
||||
),
|
||||
});
|
||||
return;
|
||||
@@ -528,6 +560,21 @@ async function mockSystemActionApis(
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
path === `/api/payment-batches/${paymentBatchId}/receipt-attachments/${paymentReceiptId}/file` &&
|
||||
request.method() === "GET" &&
|
||||
url.searchParams.get("mode") === "preview"
|
||||
) {
|
||||
calls.previewPaymentReceipt += 1;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/pdf",
|
||||
headers: { "content-disposition": "inline; filename=synthetic-receipt.pdf" },
|
||||
body: Buffer.from("%PDF-1.4\nsynthetic preview\n%%EOF\n", "utf8"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === `/api/payment-batches/${paymentBatchId}/receipt-attachments` && request.method() === "POST") {
|
||||
calls.uploadPaymentReceipt += 1;
|
||||
hasReceipt = true;
|
||||
@@ -644,6 +691,26 @@ test.describe("R7E 全站高风险操作二次确认", () => {
|
||||
await expect(page).toHaveURL(new RegExp(`/reimbursements/${reimbursementId}/print$`));
|
||||
});
|
||||
|
||||
test("差旅报销在 PC 和移动详情只展示出差事由", async ({ page }) => {
|
||||
const calls = await mockSystemActionApis(page, { travelReimbursement: true });
|
||||
await login(page, user);
|
||||
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
await expect(page.getByText("出差事由", { exact: true })).toHaveCount(1);
|
||||
await expect(page.getByText("拜访客户讨论需求", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("报销事由", { exact: true })).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "编辑报销日期" }).click();
|
||||
await page.getByTestId("reimbursement-header-editor").locator('input[type="date"]').fill("2026-05-13");
|
||||
await page.getByTestId("reimbursement-header-editor").getByRole("button", { name: "保存" }).click();
|
||||
await expect.poll(() => calls.reimbursementUpdates).toHaveLength(1);
|
||||
expect(calls.reimbursementUpdates[0]).toEqual({ reimbursementDate: "2026-05-13" });
|
||||
|
||||
await page.goto(`/mobile/reimbursements/${reimbursementId}`);
|
||||
await expect(page.getByText("出差事由", { exact: true })).toHaveCount(1);
|
||||
await expect(page.getByText("拜访客户讨论需求", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("报销事由", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("付款批次执行必须确认后才调用接口", async ({ page }) => {
|
||||
const calls = await mockSystemActionApis(page);
|
||||
page.on("dialog", (dialog) => {
|
||||
@@ -748,6 +815,45 @@ test.describe("R7E 全站高风险操作二次确认", () => {
|
||||
expect(responsiveContract.actionPosition).toBe("sticky");
|
||||
});
|
||||
|
||||
test("付款详情展示费用明细并支持回单在线查看,下载仍要求用途", async ({ page }) => {
|
||||
const calls = await mockSystemActionApis(page, { hasReceipt: true });
|
||||
await login(page, user);
|
||||
await page.goto(`/payment-batches?id=${paymentBatchId}`);
|
||||
|
||||
const itemsPanel = page.getByTestId("payment-batch-items-panel");
|
||||
await expect(itemsPanel).toContainText("本批次报销单");
|
||||
await expect(itemsPanel).toContainText("办公用品");
|
||||
await expect(itemsPanel).toContainText("项目讨论资料");
|
||||
await expect(itemsPanel).toContainText("明细小计 ¥88.00");
|
||||
await expect(itemsPanel.getByRole("link", { name: "查看报销单" })).toHaveAttribute("href", `/reimbursements/${reimbursementId}`);
|
||||
|
||||
const receipt = page.locator(".detail-evidence-card").filter({ hasText: "合成银行回单.pdf" });
|
||||
await expect(receipt).toContainText("下载需说明用途");
|
||||
await receipt.getByRole("button", { name: "在线查看" }).click();
|
||||
await expect.poll(() => calls.previewPaymentReceipt).toBe(1);
|
||||
const preview = page.getByTestId("payment-batch-receipt-preview-dialog");
|
||||
await expect(preview).toBeVisible();
|
||||
await expect(preview).toContainText("本次查看已自动写入审计记录");
|
||||
await expect(preview.locator("iframe")).toHaveAttribute("title", /银行回单/);
|
||||
await preview.getByRole("button", { name: "关闭" }).click();
|
||||
await expect(preview).toBeHidden();
|
||||
|
||||
await receipt.getByRole("button", { name: "下载" }).click();
|
||||
await expect(page.getByRole("dialog")).toContainText("填写下载用途");
|
||||
});
|
||||
|
||||
test("历史差旅付款批次不冒充报销明细或生成错误报销入口", async ({ page }) => {
|
||||
await mockSystemActionApis(page, { paymentEntityType: "travel_order" });
|
||||
await login(page, user);
|
||||
await page.goto(`/payment-batches?id=${paymentBatchId}`);
|
||||
|
||||
const itemsPanel = page.getByTestId("payment-batch-items-panel");
|
||||
await expect(itemsPanel).toContainText("本批次差旅订单");
|
||||
await expect(itemsPanel.getByTestId("payment-batch-expense-items")).toHaveCount(0);
|
||||
await expect(itemsPanel.getByRole("link", { name: "查看报销单" })).toHaveCount(0);
|
||||
await expect(itemsPanel).toContainText("付款信息");
|
||||
});
|
||||
|
||||
test("付款三阶段时间线覆盖五种状态并正确终止", async ({ page }) => {
|
||||
const cases: Array<[SyntheticPaymentBatchStatus, string]> = [
|
||||
["draft", "待复核"],
|
||||
|
||||
在新工单中引用
屏蔽一个用户