文件
hyb-oa/client/tests/e2e/reimbursement-payment-batch-flow.spec.ts
T

217 行
9.3 KiB
TypeScript

import { test, expect } from "./helpers/fixtures";
import {
ADMIN,
apiGetData,
createAuthenticatedApiClient,
EMPLOYEE,
FINANCE,
FINANCE_MGR,
login,
logout,
MANAGER,
} from "./helpers/auth";
import { openNavLink } from "./helpers/navigation";
import { TEST_IDS } from "./helpers/testIds";
type FeatureFlagItem = {
flagKey: string;
overrideMode: "inherit" | "force_on" | "force_off";
rolloutType: "global" | "department_allowlist";
departmentIds: string[];
note: string;
version: number;
effectiveEnabled: boolean;
};
async function ensureFeatureFlagOn(api: Awaited<ReturnType<typeof createAuthenticatedApiClient>>["api"], flagKey: string) {
const list = await apiGetData<{ items: FeatureFlagItem[] }>(api, "/api/system/feature-flags");
const item = list.items.find((flag) => flag.flagKey === flagKey);
if (!item) {
throw new Error(`Missing feature flag: ${flagKey}`);
}
if (item.overrideMode === "force_on" && item.effectiveEnabled) {
return;
}
const response = await api.put(`/api/system/feature-flags/${flagKey}`, {
data: {
overrideMode: "force_on",
rolloutType: "global",
departmentIds: [],
note: "playwright reimbursement payment batch flow",
expectedVersion: item.version,
},
});
if (!response.ok()) {
throw new Error(`Failed to enable feature flag ${flagKey}: ${response.status()} ${await response.text()}`);
}
}
test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复核 -> 执行 -> 下游收口", async ({
page,
reimbursementDetail,
workflowDetail,
}) => {
test.setTimeout(300_000);
const adminApi = await createAuthenticatedApiClient(ADMIN);
const employeeApi = await createAuthenticatedApiClient(EMPLOYEE);
const financeMgrApi = await createAuthenticatedApiClient(FINANCE_MGR);
const paymentDate = new Date().toISOString().slice(0, 10);
try {
await ensureFeatureFlagOn(adminApi.api, "payment_batch_v1");
await ensureFeatureFlagOn(adminApi.api, "erp_sync");
await login(page, EMPLOYEE);
await (await openNavLink(page, "我的报销")).click();
await expect(page).toHaveURL(/\/reimbursements/);
const createResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/reimbursements" && response.ok();
});
await page.getByTestId("reimbursements-create").click();
await page.getByRole("menuitem", { name: "空白报销" }).click();
const createPayload = (await (await createResponsePromise).json()) as { data?: { id?: string } };
const reimbursementId = createPayload.data?.id;
if (!reimbursementId) {
throw new Error(`创建报销单后未返回 id: ${JSON.stringify(createPayload)}`);
}
await page.goto(`/reimbursements/${reimbursementId}`);
await reimbursementDetail.assertLoaded();
const marker = `R78_BATCH_${Date.now()}_${Math.random().toString(16).slice(2)}`;
const invoiceId = await reimbursementDetail.uploadInvoicePdf(
`r78-${Date.now()}.pdf`,
Buffer.from(`%PDF-1.4\n${marker}\n%%EOF\n`, "utf8"),
);
await page.goto(`/reimbursements/${reimbursementId}`);
await reimbursementDetail.assertLoaded();
await reimbursementDetail.submitReimbursement();
await expect(page.getByText(/状态:(reviewing|审批中)/)).toBeVisible();
const reimbursementAfterSubmit = await apiGetData<{ formNumber: string | null; totalAmount: string | null }>(
employeeApi.api,
`/api/reimbursements/${reimbursementId}`,
);
const reimbursementDisplayCode = reimbursementAfterSubmit.formNumber ?? reimbursementId;
const secondApprovalUser = Number(reimbursementAfterSubmit.totalAmount ?? 0) > 500 ? FINANCE_MGR : FINANCE;
await logout(page, EMPLOYEE.name);
await login(page, MANAGER);
await (await openNavLink(page, "待我处理")).click();
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
const managerTaskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: reimbursementDisplayCode }).first();
await expect(managerTaskRow).toBeVisible();
await managerTaskRow.getByTestId(TEST_IDS.workflow.detailLink).click();
await workflowDetail.assertLoaded();
await workflowDetail.approve();
await expect(workflowDetail.summary).toContainText(/状态:(已处理|completed|已完成)/);
await logout(page, MANAGER.name);
await login(page, secondApprovalUser);
await (await openNavLink(page, "待我处理")).click();
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
const financeTaskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: reimbursementDisplayCode }).first();
await expect(financeTaskRow).toBeVisible();
await financeTaskRow.getByTestId(TEST_IDS.workflow.detailLink).click();
await workflowDetail.assertLoaded();
await workflowDetail.approve();
await expect(workflowDetail.summary).toContainText(/状态:(已处理|completed|已完成)/);
await logout(page, secondApprovalUser.name);
await login(page, FINANCE);
const batchName = `R78-报销付款批次-${Date.now()}`;
await page.goto("/payment-batches");
await expect(page).toHaveURL(/\/payment-batches/);
const createCard = page.locator(".card-std").filter({ hasText: "新建批次" }).first();
const createBatchResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/payment-batches" && response.ok();
});
await createCard.locator("select").nth(0).selectOption("reimbursement");
await createCard.getByPlaceholder("例如:2026Q2-财务付款批次-01").fill(batchName);
await createCard.getByPlaceholder("用逗号或换行分隔 reimbursement / travel_order ID").fill(reimbursementId);
await createCard.locator('input[type="date"]').fill(paymentDate);
await createCard.getByPlaceholder("例如 PBX").fill("R78PB");
await createCard.getByRole("button", { name: "创建批次" }).click();
const createBatchPayload = (await (await createBatchResponsePromise).json()) as {
data?: { item?: { id?: string } };
};
const batchId = createBatchPayload.data?.item?.id;
if (!batchId) {
throw new Error(`创建支付批次后未返回 id: ${JSON.stringify(createBatchPayload)}`);
}
await page.goto(`/payment-batches?id=${batchId}`);
const batchDetailCard = page.locator(".card-std").filter({ hasText: batchName }).filter({ hasText: "复核进度:" }).last();
await expect(batchDetailCard).toBeVisible();
await expect(batchDetailCard).toContainText("状态:草稿");
await expect(batchDetailCard).toContainText("复核进度:0/1");
await logout(page, FINANCE.name);
await login(page, FINANCE_MGR);
await page.goto("/ap-operations");
await expect(page.getByRole("tab", { name: /待付款/ })).toBeVisible();
const pendingRow = page.locator("tr").filter({ hasText: batchName }).first();
await expect(pendingRow).toBeVisible();
await pendingRow.getByRole("button", { name: "快捷复核" }).click();
await expect(page.getByRole("dialog")).toContainText(batchName);
await page.getByPlaceholder("请输入复核意见").fill("R78 focused regression review");
await page.getByRole("button", { name: "提交复核" }).click();
await expect(pendingRow).toContainText("复核 1/1");
await logout(page, FINANCE_MGR.name);
await login(page, FINANCE);
await page.goto(`/payment-batches?id=${batchId}`);
const executedBatchDetailCard = page.locator(".card-std").filter({ hasText: batchName }).filter({ hasText: "复核进度:" }).last();
await expect(executedBatchDetailCard).toBeVisible();
await expect(executedBatchDetailCard).toContainText("复核进度:1/1");
await executedBatchDetailCard.getByRole("button", { name: "执行" }).click();
await expect(executedBatchDetailCard).toContainText("状态:已执行", { timeout: 30_000 });
await page.goto(`/reimbursements/${reimbursementId}`);
await reimbursementDetail.assertLoaded();
await expect(page.getByText(/状态:(paid|已支付)/)).toBeVisible({ timeout: 30_000 });
await page.goto("/ap-operations");
await expect(page.getByRole("tab", { name: /待付款/ })).toBeVisible();
await expect(page.getByText(batchName)).toHaveCount(0);
await logout(page, FINANCE.name);
const invoiceDetail = await apiGetData<{
status: string;
archivedAt: string | null;
linkedReimbursementId: string | null;
}>(
employeeApi.api,
`/api/invoice-pool/${invoiceId}`,
);
expect(invoiceDetail.status).toBe("archived");
expect(invoiceDetail.archivedAt).toBeTruthy();
expect(invoiceDetail.linkedReimbursementId).toBe(reimbursementId);
const erpPreview = await apiGetData<{
exportState: {
lastOutboxId: string | null;
lastOutboxStatus: string | null;
};
}>(
financeMgrApi.api,
`/api/integrations/erp/journals/reimbursements/${reimbursementId}/preview`,
);
expect(erpPreview.exportState.lastOutboxId).toBeTruthy();
expect(erpPreview.exportState.lastOutboxStatus).toBe("pending");
} finally {
await Promise.all([adminApi.dispose(), employeeApi.dispose(), financeMgrApi.dispose()]);
}
});