fix: 统一报销付款状态与进度(09-04-reimbursement-state-gap-finance-view)

这个提交包含在:
2026-09-11 09:43:36 +08:00
父节点 fe025e65cf
当前提交 f9aee4c932
共修改 19 个文件,包含 549 行新增和 32 行删除
+32
查看文件
@@ -278,6 +278,38 @@ async function main() {
assert.ok(task, `pending task for ${role}`);
if (role === "manager") assert.equal(task.assignee_id, manager.id);
else { assert.equal(task.assignee_id, null); assert.equal(task.assignee_role, role); }
if (role === "finance" && browser && preview) {
await prisma.workflowHistory.updateMany({ where: { instance_id: newWorkflowId, action: "approve" }, data: { comment: "synthetic historical comment ".repeat(12) } });
const clientRequire = createRequire(path.join(root, "client/package.json"));
const { expect } = clientRequire("@playwright/test");
const uiOrigin = `http://127.0.0.1:${preview.httpServer.address().port}`;
for (const mobile of [false, true]) {
const context = await browser.newContext({ serviceWorkers: "block", viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 } });
try {
const fingerprint = `synthetic-approval-layout-${mobile}`;
await context.addCookies([{ name: "hyb_oa_device_fingerprint", value: fingerprint, url: uiOrigin }]);
const login = await context.request.post(`${uiOrigin}/api/auth/login`, { headers: { "x-device-fingerprint": fingerprint }, data: { identifier: approvers.finance.email, password: loginPassword } });
assert.equal(login.status(), 200);
const page = await context.newPage();
await page.goto(`${uiOrigin}${mobile ? "/mobile/approvals" : "/workflow/tasks"}/${task.id}`);
const progress = page.getByTestId("reimbursement-approval-processing-progress");
await expect(progress).toHaveCount(1);
await expect(page.getByTestId("reimbursement-workflow-history")).toContainText("synthetic historical comment");
if (!mobile) {
const actions = page.getByTestId("workflow-task-action-panel");
const actionBounds = await actions.boundingBox();
const progressBounds = await progress.boundingBox();
assert.ok(progressBounds.y > actionBounds.y + actionBounds.height);
assert.ok(Math.abs(progressBounds.x - actionBounds.x) < 35);
assert.notEqual(await actions.locator("..").evaluate((element) => getComputedStyle(element).position), "sticky");
} else {
await expect(page.getByTestId("mobile-approvals-sticky-actions")).toBeVisible();
}
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2), true);
} finally { await context.close(); }
}
passed.push("approval: real finance desktop/mobile evidence, current progress and synthetic long-history fixture; non-overlapping aside and safe-area action");
}
const approval = await post(`/workflow/tasks/${task.id}/approve`, {}, approvers[role].id);
assert.equal(approval.status, 200, `${role} approval: ${approval.status} ${approval.body.error?.code ?? ""}`);
assert.equal((await post(`/workflow/tasks/${task.id}/approve`, {}, approvers[role].id)).status, 409);
+33 -2
查看文件
@@ -23,11 +23,22 @@ module.exports = async function verifyPaymentExperience({
account_number_encrypted: encryptStringOrThrow(app.config, `6217${"0".repeat(8)}1234`),
} });
await prisma.featureFlag.create({ data: { flag_key: "payment_batch_v1", override_mode: "force_on", rollout_type: "global", department_ids: [] } });
const readProgress = async (actor) => {
const response = await fetch(`${origin}/api/reimbursements/${form.id}`, {
headers: { authorization: `Bearer ${tokens.get(actor.id)}` }, signal: AbortSignal.timeout(15000),
});
assert.equal(response.status, 200);
return (await response.json()).data.processingProgress;
};
assert.equal((await readProgress(approvers.finance)).stages.find((stage) => stage.key === "payment_batch_created").state, "current");
const created = await post("/payment-batches", {
entityType: "reimbursement", entityIds: [form.id], defaultPaymentChannel: "bank",
}, approvers.finance.id);
assert.equal(created.status, 201, `create payment batch: ${created.body.error?.details?.reasonCode ?? created.status}`);
const batch = created.body.data.item;
const reviewStage = (await readProgress(approvers.finance)).stages.find((stage) => stage.key === "payment_batch_review");
assert.equal(reviewStage.state, "current");
assert.equal(reviewStage.detail, "待非制单人复核付款");
const download = (actor) => fetch(`${origin}/api/payment-batches/${batch.id}/payment-order`, {
method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${tokens.get(actor.id)}` },
body: "{}", signal: AbortSignal.timeout(15000),
@@ -35,6 +46,7 @@ module.exports = async function verifyPaymentExperience({
assert.equal((await download(approvers.finance)).status, 409);
assert.equal((await post(`/payment-batches/${batch.id}/review`, {}, approvers.finance.id)).status, 403);
assert.equal((await post(`/payment-batches/${batch.id}/review`, {}, approvers.ceo.id)).status, 200);
assert.equal((await readProgress(approvers.finance)).stages.find((stage) => stage.key === "payment_and_receipt").state, "current");
assert.equal((await download(approvers.ceo)).status, 403);
assert.equal((await download(employee)).status, 403);
for (const count of [1, 2]) {
@@ -50,7 +62,10 @@ module.exports = async function verifyPaymentExperience({
const source = await prisma.reimbursementForm.findUnique({ where: { id: form.id } });
assert.equal(source.status, "approved");
assert.equal(source.paid_at, null);
assert.equal((await readProgress(approvers.finance)).stages.find((stage) => stage.key === "payment_and_receipt").detail,
"已生成银行制单,等待付款完成并上传回单,非审批环节");
}
assert.equal((await readProgress(employee)).stages.some((stage) => stage.targetHref), false);
const audits = await prisma.auditLog.findMany({ where: { target_id: batch.id, action: { in: ["PAYMENT_ORDER_EXPORTED", "PAYMENT_ORDER_REEXPORTED"] } } });
assert.equal(audits.length, 2);
for (const audit of audits) {
@@ -70,7 +85,13 @@ module.exports = async function verifyPaymentExperience({
const login = await context.request.post(`${uiOrigin}/api/auth/login`, { headers: { "x-device-fingerprint": fingerprint }, data: { identifier: approvers.finance.email, password: loginPassword } });
assert.equal(login.status(), 200);
const page = await context.newPage();
await page.goto(`${uiOrigin}/payment-batches?id=${batch.id}`);
await page.goto(`${uiOrigin}${mobile ? "/mobile" : ""}/reimbursements/${form.id}`);
const sourceActions = page.getByTestId(mobile ? "mobile-reimbursement-payment-actions" : "reimbursement-action-panel");
await expect(sourceActions).toContainText("已生成银行制单");
await expect(page.getByText("去组建付款批次", { exact: true })).toHaveCount(0);
const sourceLink = page.getByRole("link", { name: "查看所在付款批次", exact: true });
await expect(sourceLink).toHaveCount(1);
await sourceLink.click();
const actions = page.getByTestId(mobile ? "payment-batch-mobile-actions" : "payment-batch-action-panel");
await expect(actions.getByRole("button", { name: "上传银行回单" })).toBeVisible();
await actions.getByText("更多", { exact: true }).click();
@@ -86,12 +107,22 @@ module.exports = async function verifyPaymentExperience({
assert.equal(readFileSync(await downloaded.path()).subarray(0, 8).toString("hex"), "d0cf11e0a1b11ae1");
await expect(dialog).toBeHidden();
await expect(page.getByTestId("payment-batch-processing-panel")).toBeVisible();
await page.goBack();
await expect(sourceActions).toContainText("已生成银行制单");
await expect(page.getByText("去组建付款批次", { exact: true })).toHaveCount(0);
await expect(sourceLink).toHaveCount(1);
const sourceProgress = page.getByTestId(mobile ? "mobile-reimbursement-processing-progress" : "reimbursement-workflow-progress");
await expect(sourceProgress).toHaveCount(1);
const actionBounds = await sourceActions.boundingBox();
const progressBounds = await sourceProgress.boundingBox();
assert.ok(progressBounds.y > actionBounds.y + actionBounds.height);
if (!mobile) assert.ok(Math.abs(progressBounds.x - actionBounds.x) < 35);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true);
} finally {
await context.close();
}
}
passed.push("payment: real authenticated desktop/mobile browser downloads Chinese BIFF8 with no reason input; repeat warning and receipt action retained");
passed.push("payment: real authenticated desktop/mobile reimbursement-to-batch download and return agree on stage, unique link, action-before-progress; no reason input; repeat warning retained");
}
passed.push("payment: real finance create, CEO review, unauthorized export refused, Chinese BIFF8 first/re-download without reason, audited and still unpaid");
};