feat(detail-page-structure-progress-actions): 统一详情处理进度与操作区

这个提交包含在:
2026-08-03 16:00:42 +08:00
父节点 8d428bd51d
当前提交 b7d5761067
共修改 31 个文件,包含 1453 行新增和 383 行删除
@@ -80,21 +80,50 @@ function reimbursementListItem() {
};
}
function reimbursementDetail() {
function reimbursementDetail(status: "draft" | "reviewing" | "paid" = "draft") {
const workflowTaskId = "workflow-task-action-confirm";
return {
...reimbursementListItem(),
status,
submitterId: me.id,
ownerId: me.id,
departmentId: "department-action-confirm",
department: { id: "department-action-confirm", name: "合成部门" },
submittedAt: null,
travelSummary: { isTravel: false, purpose: null, location: null, startDate: null, endDate: null },
projectId: null,
linkedTravelRequestId: null,
linkedTravelRequest: null,
owner: { id: me.id, employee_no: me.employeeNo, name: me.name },
submitter: { id: me.id, employee_no: me.employeeNo, name: me.name },
items: [],
invoices: [],
items: [
{
id: "reimbursement-item-action-confirm",
expenseTypeId: "expense-type-action-confirm",
expenseDate: "2026-05-15",
amount: "88.00",
description: "合成报销动作回归",
expenseType: { id: "expense-type-action-confirm", code: "office", name: "办公费", category: "office", enabled: true },
},
],
invoices: [
{
id: "invoice-action-confirm",
status: "bound",
invoiceCode: "SYNTHETIC",
invoiceNumber: "ACTION001",
invoiceDate: "2026-05-15",
sellerName: "合成供应方",
buyerName: "合成购买方",
documentCategory: "vat_invoice",
documentSubtype: null,
totalAmount: "88.00",
originalName: "合成动作发票.pdf",
mimeType: "application/pdf",
fileSize: 1024,
linkedItemId: "reimbursement-item-action-confirm",
},
],
attachments: [
{
id: reimbursementAttachmentId,
@@ -117,14 +146,53 @@ function reimbursementDetail() {
},
],
budgetHint: null,
pendingWorkflow: null,
pendingWorkflow: status === "reviewing" ? {
instanceId: "workflow-instance-action-confirm",
stepId: "workflow-step-action-confirm",
task: {
id: workflowTaskId,
step_name: "负责人复核",
assignee_id: "another-manager",
assignee_role: "manager",
},
} : null,
workflowProgress: null,
processingProgress: { stages: [] },
approvalHistory: [],
riskFlags: [],
};
}
function paymentBatch(status: "draft" | "reviewed" | "executed" | "cancelled" = "reviewed", highRisk = false) {
type SyntheticPaymentBatchStatus = "draft" | "reviewed" | "executed" | "failed" | "cancelled";
function paymentProcessingProgress(status: SyntheticPaymentBatchStatus, hasReceipt: boolean, requiresBankReceipt = true) {
const reviewState = status === "draft" ? "current" : status === "cancelled" ? "exception" : "completed";
const paymentState = status === "executed"
? "completed"
: status === "failed"
? "exception"
: status === "cancelled"
? "terminated"
: status === "reviewed"
? "current"
: "pending";
return {
stages: [
{ key: "payment_batch_created", label: "批次创建", state: "completed", actorName: me.name, occurredAt: "2026-05-15T08:00:00.000Z", result: "已创建", comment: null, detail: null, targetHref: null },
{ key: "payment_batch_review", label: "付款复核", state: reviewState, actorName: status === "draft" ? null : me.name, occurredAt: status === "draft" ? null : "2026-05-15T08:10:00.000Z", result: status === "draft" ? "待复核" : status === "cancelled" ? "批次已取消" : "通过", comment: status === "cancelled" ? "合成取消原因" : null, detail: status === "draft" ? "待非制单人复核付款" : null, targetHref: null },
{ key: "payment_and_receipt", label: "付款完成", state: paymentState, actorName: ["executed", "failed"].includes(status) ? me.name : null, occurredAt: ["executed", "failed"].includes(status) ? "2026-05-15T08:30:00.000Z" : null, result: status === "executed" ? "已付款" : status === "failed" ? "付款失败" : status === "cancelled" ? "已终止" : "待付款", comment: null, detail: status === "reviewed" ? (!requiresBankReceipt ? "等待财务登记付款完成,非审批环节" : hasReceipt ? "银行回单已上传,等待财务登记付款完成,非审批环节" : "等待财务线下付款并上传回单,非审批环节") : null, targetHref: null },
],
};
}
function paymentBatch(
status: SyntheticPaymentBatchStatus = "reviewed",
highRisk = false,
hasReceipt = true,
paymentChannel: "bank" | "cash" = "bank",
canExportPaymentOrder = false,
paymentOrderExportCount = 0,
) {
return {
id: paymentBatchId,
batchNo: "PAY-ACTION-001",
@@ -132,10 +200,14 @@ function paymentBatch(status: "draft" | "reviewed" | "executed" | "cancelled" =
name: "R7E 高风险付款确认",
status,
defaultPaymentDate: "2026-05-15",
defaultPaymentChannel: "bank",
defaultPaymentChannel: paymentChannel,
voucherPrefix: "R7E",
totalAmount: "88.00",
highRisk,
paymentAccount: null,
paymentOrderExportCount,
canExportPaymentOrder,
paymentOrderExportBlockers: [],
reviewRequirement: {
thresholdAmount: "1000.00",
requiredReviewCount: 1,
@@ -158,6 +230,7 @@ function paymentBatch(status: "draft" | "reviewed" | "executed" | "cancelled" =
cancelledAt: null,
cancelledBy: null,
cancelledReason: null,
processingProgress: paymentProcessingProgress(status, hasReceipt, paymentChannel === "bank"),
items: [
{
id: "payment-batch-item-action-confirm",
@@ -172,7 +245,7 @@ function paymentBatch(status: "draft" | "reviewed" | "executed" | "cancelled" =
payeeName: "合成收款员工",
bankName: "合成银行",
bankAccountMasked: "6222********5678",
status: "pending",
status: status === "executed" ? "executed" : status === "failed" ? "failed" : status === "cancelled" ? "cancelled" : "pending",
failureCode: null,
failureMessage: null,
createdAt: "2026-05-15T08:00:00.000Z",
@@ -185,16 +258,21 @@ function paymentBatch(status: "draft" | "reviewed" | "executed" | "cancelled" =
async function mockSystemActionApis(
page: Page,
options: {
batchStatus?: "draft" | "reviewed" | "executed" | "cancelled";
batchStatus?: SyntheticPaymentBatchStatus;
hasReceipt?: boolean;
receiptQueryError?: boolean;
highRisk?: boolean;
paymentChannel?: "bank" | "cash";
canExportPaymentOrder?: boolean;
paymentOrderExportCount?: number;
reimbursementStatus?: "draft" | "reviewing" | "paid";
meOverride?: Partial<typeof me>;
} = {},
) {
const currentMe = { ...me, ...options.meOverride };
const batchStatus = options.batchStatus ?? "reviewed";
let hasReceipt = options.hasReceipt ?? true;
let currentReimbursementStatus = options.reimbursementStatus ?? "draft";
const calls = {
deleteReimbursement: 0,
deleteReimbursementAttachment: 0,
@@ -205,6 +283,10 @@ async function mockSystemActionApis(
voidPaymentReceipt: 0,
attachmentDeleteReasons: [] as string[],
receiptVoidReasons: [] as string[],
submitReimbursement: 0,
withdrawReimbursement: 0,
remindWorkflow: 0,
withdrawComments: [] as string[],
};
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
@@ -288,7 +370,45 @@ async function mockSystemActionApis(
}
if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "GET") {
await fulfillJson(route, reimbursementDetail());
await fulfillJson(route, reimbursementDetail(currentReimbursementStatus));
return;
}
if (path === `/api/reimbursements/${reimbursementId}/precheck` && request.method() === "POST") {
await fulfillJson(route, {
entityType: "reimbursement",
entityId: reimbursementId,
decision: "pass",
matchedBudgetControlRule: null,
budgetHint: null,
standardChecks: [],
warnings: [],
blockingReasons: [],
riskFlags: [],
nextActions: ["submit"],
});
return;
}
if (path === `/api/reimbursements/${reimbursementId}/submit` && request.method() === "POST") {
calls.submitReimbursement += 1;
currentReimbursementStatus = "reviewing";
await fulfillJson(route, { reimbursementId, status: "reviewing" });
return;
}
if (path === `/api/reimbursements/${reimbursementId}/withdraw` && request.method() === "POST") {
calls.withdrawReimbursement += 1;
const body = request.postDataJSON() as { comment?: string };
calls.withdrawComments.push(body.comment ?? "");
currentReimbursementStatus = "draft";
await fulfillJson(route, { reimbursementId, status: "draft" });
return;
}
if (path === "/api/workflow/tasks/workflow-task-action-confirm/remind" && request.method() === "POST") {
calls.remindWorkflow += 1;
await fulfillJson(route, { taskId: "workflow-task-action-confirm", remindedAt: "2026-05-15T09:00:00.000Z" });
return;
}
@@ -333,7 +453,14 @@ async function mockSystemActionApis(
}
if (path === "/api/payment-batches" && request.method() === "GET") {
const listItem = paymentBatch(batchStatus, options.highRisk);
const listItem = paymentBatch(
batchStatus,
options.highRisk,
hasReceipt,
options.paymentChannel,
options.canExportPaymentOrder,
options.paymentOrderExportCount,
);
await fulfillJson(route, {
items: [{
...listItem,
@@ -350,7 +477,16 @@ async function mockSystemActionApis(
if (path === `/api/payment-batches/${paymentBatchId}` && request.method() === "GET") {
calls.paymentBatchDetail += 1;
await fulfillJson(route, { item: paymentBatch(batchStatus, options.highRisk) });
await fulfillJson(route, {
item: paymentBatch(
batchStatus,
options.highRisk,
hasReceipt,
options.paymentChannel,
options.canExportPaymentOrder,
options.paymentOrderExportCount,
),
});
return;
}
@@ -412,7 +548,7 @@ async function mockSystemActionApis(
if (path === `/api/payment-batches/${paymentBatchId}/execute` && request.method() === "POST") {
calls.executePaymentBatch += 1;
await fulfillJson(route, { item: paymentBatch("reviewed", options.highRisk) });
await fulfillJson(route, { item: paymentBatch("reviewed", options.highRisk, hasReceipt, options.paymentChannel) });
return;
}
@@ -455,6 +591,59 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await expect.poll(() => calls.exportReimbursements).toBe(1);
});
test("报销详情迁移后保留提交与删除的状态、确认和刷新合同", async ({ page }) => {
const calls = await mockSystemActionApis(page, {
reimbursementStatus: "draft",
meOverride: { permissions: [...me.permissions, "expense.submit"] },
});
await login(page, user);
await page.goto(`/reimbursements/${reimbursementId}`);
const actionPanel = page.getByTestId("reimbursement-action-panel");
await expect(actionPanel.getByRole("button", { name: "提交报销" })).toBeVisible();
await actionPanel.getByRole("button", { name: "提交报销" }).click();
await expect.poll(() => calls.submitReimbursement).toBe(1);
await expect(page.getByText("审批中", { exact: true }).first()).toBeVisible();
await page.unrouteAll({ behavior: "wait" });
const deleteCalls = await mockSystemActionApis(page, { reimbursementStatus: "draft" });
await page.goto(`/reimbursements/${reimbursementId}`);
await page.getByTestId("reimbursement-more-toggle").click();
await page.getByTestId("reimbursement-delete-draft").click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("删除后无法恢复");
expect(deleteCalls.deleteReimbursement).toBe(0);
await page.getByTestId("action-confirm-submit").click();
await expect.poll(() => deleteCalls.deleteReimbursement).toBe(1);
await expect(page).toHaveURL(/\/reimbursements\?status=draft/);
});
test("报销详情迁移后保留催办与撤回理由及刷新合同", async ({ page }) => {
const calls = await mockSystemActionApis(page, { reimbursementStatus: "reviewing" });
await login(page, user);
await page.goto(`/reimbursements/${reimbursementId}`);
const actionPanel = page.getByTestId("reimbursement-action-panel");
await actionPanel.getByRole("button", { name: "催办审批" }).click();
await expect.poll(() => calls.remindWorkflow).toBe(1);
await page.getByTestId("reimbursement-withdraw-comment").fill("合成撤回原因");
await page.getByTestId("reimbursement-withdraw").click();
await expect.poll(() => calls.withdrawReimbursement).toBe(1);
expect(calls.withdrawComments).toEqual(["合成撤回原因"]);
await expect(page.getByText("草稿", { exact: true }).first()).toBeVisible();
});
test("报销详情迁移后已支付状态仍可进入打印页", async ({ page }) => {
await mockSystemActionApis(page, { reimbursementStatus: "paid" });
await login(page, user);
await page.goto(`/reimbursements/${reimbursementId}`);
const printLink = page.getByTestId("reimbursement-action-panel").getByRole("link", { name: "打印 / PDF" });
await expect(printLink).toBeVisible();
await printLink.click();
await expect(page).toHaveURL(new RegExp(`/reimbursements/${reimbursementId}/print$`));
});
test("付款批次执行必须确认后才调用接口", async ({ page }) => {
const calls = await mockSystemActionApis(page);
page.on("dialog", (dialog) => {
@@ -478,12 +667,15 @@ test.describe("R7E 全站高风险操作二次确认", () => {
});
test("银行回单上传前后只显示一个付款主动作", async ({ page }) => {
const calls = await mockSystemActionApis(page, { hasReceipt: false });
const calls = await mockSystemActionApis(page, { hasReceipt: false, canExportPaymentOrder: true });
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
await expect(page.getByRole("button", { name: "上传银行回单" }).first()).toBeVisible();
await expect(page.getByRole("button", { name: "上传银行回单" })).toHaveCount(1);
const actionPanel = page.getByTestId("payment-batch-action-panel");
await expect(actionPanel.getByRole("button", { name: "上传银行回单" })).toBeVisible();
await actionPanel.getByText("更多", { exact: true }).click();
await expect(actionPanel.getByRole("button", { name: "下载银行制单" })).toBeVisible();
await expect(page.getByTestId("payment-batch-mobile-actions")).toBeHidden();
await expect(page.getByTestId("payment-batch-receipt-panel").getByRole("button", { name: "上传银行回单" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "登记付款完成" })).toHaveCount(0);
@@ -494,32 +686,102 @@ test.describe("R7E 全站高风险操作二次确认", () => {
});
await expect.poll(() => calls.uploadPaymentReceipt).toBe(1);
await expect(page.getByRole("button", { name: "登记付款完成" })).toBeVisible();
await expect(page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "登记付款完成" })).toBeVisible();
await expect(page.getByTestId("payment-batch-detail-panel").getByRole("button", { name: "上传银行回单" })).toHaveCount(0);
});
test("移动端固定操作区复用同一动作模型并在上传后原地切换", async ({ page }) => {
const calls = await mockSystemActionApis(page, { hasReceipt: false, canExportPaymentOrder: true });
await page.setViewportSize({ width: 390, height: 844 });
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
const mobileActions = page.getByTestId("payment-batch-mobile-actions");
await expect(mobileActions).toBeVisible();
await expect(page.getByTestId("payment-batch-action-panel")).toBeHidden();
await expect(mobileActions.getByRole("button", { name: "上传银行回单" })).toBeVisible();
await mobileActions.getByRole("button", { name: "更多" }).click();
await expect(page.getByRole("menuitem", { name: "下载银行制单" })).toBeVisible();
await page.keyboard.press("Escape");
await page.getByTestId("payment-batch-receipt-file-input").setInputFiles({
name: "移动端合成银行回单.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF-1.4\nmobile synthetic receipt\n%%EOF\n", "utf8"),
});
await expect.poll(() => calls.uploadPaymentReceipt).toBe(1);
await expect(mobileActions.getByRole("button", { name: "登记付款完成" })).toBeVisible();
await expect(mobileActions.getByRole("button", { name: "上传银行回单" })).toHaveCount(0);
});
test("付款详情按处理顺序展示并统一人民币金额格式", async ({ page }) => {
await mockSystemActionApis(page, { hasReceipt: false, highRisk: true });
await page.setViewportSize({ width: 1024, height: 768 });
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
await expect(page.getByTestId("payment-batch-detail-panel")).toBeVisible();
const sectionOrder = await page.locator([
'[data-testid="payment-batch-detail-panel"]',
'[data-testid="payment-batch-current-processing"]',
'[data-testid="payment-batch-processing-panel"]',
'[data-testid="payment-batch-items-panel"]',
'[data-testid="payment-batch-receipt-panel"]',
].join(",")).evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")));
expect(sectionOrder).toEqual([
"payment-batch-detail-panel",
"payment-batch-current-processing",
"payment-batch-processing-panel",
"payment-batch-items-panel",
"payment-batch-receipt-panel",
]);
await expect(page.getByTestId("payment-batch-detail-panel")).toContainText("¥88.00");
await expect(page.getByTestId("payment-batch-current-processing")).toContainText("¥1,000.00");
await expect(page.getByTestId("payment-batch-items-panel")).toContainText("¥88.00");
await expect(page.getByTestId("payment-batch-current-processing")).toContainText("线下付款并上传银行回单");
await expect(page.getByTestId("payment-batch-processing-panel")).toContainText("等待财务线下付款并上传回单");
const responsiveContract = await page.locator(".detail-workspace-grid").evaluate((workspace) => {
const actionPanel = workspace.querySelector(".detail-action-panel");
return {
gridColumns: getComputedStyle(workspace).gridTemplateColumns,
actionPosition: actionPanel ? getComputedStyle(actionPanel).position : null,
};
});
expect(responsiveContract.gridColumns.split(" ").filter(Boolean)).toHaveLength(2);
expect(responsiveContract.actionPosition).toBe("sticky");
});
test("付款三阶段时间线覆盖五种状态并正确终止", async ({ page }) => {
const cases: Array<[SyntheticPaymentBatchStatus, string]> = [
["draft", "待复核"],
["reviewed", "待付款"],
["executed", "已付款"],
["failed", "付款失败"],
["cancelled", "已终止"],
];
for (const [status, expected] of cases) {
await page.unrouteAll({ behavior: "wait" });
await mockSystemActionApis(page, { batchStatus: status, hasReceipt: status === "executed" });
await page.context().clearCookies();
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
const progress = page.getByTestId("payment-batch-processing-progress");
await expect(progress).toBeVisible();
await expect(progress).toContainText("批次创建");
await expect(progress).toContainText("付款复核");
await expect(progress).toContainText("付款完成");
await expect(progress).toContainText(expected);
}
});
test("非银行渠道直接等待登记完成且不引导回单或制单", async ({ page }) => {
await mockSystemActionApis(page, { batchStatus: "reviewed", hasReceipt: false, paymentChannel: "cash" });
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
await expect(page.getByTestId("payment-batch-processing-progress")).toContainText("等待财务登记付款完成");
await expect(page.getByTestId("payment-batch-processing-progress")).not.toContainText("银行制单");
await expect(page.getByTestId("payment-batch-processing-progress")).not.toContainText("上传回单");
await expect(page.getByTestId("payment-batch-receipt-panel")).toContainText("当前付款渠道不要求银行回单");
await expect(page.getByRole("button", { name: "上传银行回单" })).toHaveCount(0);
await expect(page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "登记付款完成" })).toBeVisible();
});
test("付款详情按批次状态渐进展示收款依据和财务动作", async ({ page }) => {
@@ -626,7 +888,7 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
await expect(page.getByText("制单人不能复核自己的批次")).toBeVisible();
await expect(page.getByTestId("payment-batch-processing-progress")).toContainText("待非制单人复核付款");
await expect(page.getByRole("button", { name: "复核通过", exact: true })).toHaveCount(0);
});