完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)
汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。 统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。 纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。 关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。 预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。 完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
这个提交包含在:
@@ -1,274 +1,7 @@
|
||||
import { test, expect } from "./helpers/fixtures";
|
||||
import {
|
||||
ADMIN,
|
||||
apiGetData,
|
||||
createAuthenticatedApiClient,
|
||||
EMPLOYEE,
|
||||
FINANCE,
|
||||
FINANCE_MGR,
|
||||
login,
|
||||
logout,
|
||||
MANAGER,
|
||||
} from "./helpers/auth";
|
||||
import { ensureFeatureFlagOn } from "./helpers/featureFlags";
|
||||
import { openNavLink } from "./helpers/navigation";
|
||||
import { TEST_IDS } from "./helpers/testIds";
|
||||
import { test } from "@playwright/test";
|
||||
import { reimbursementPaymentFlow } from "./helpers/reimbursementPaymentFlow";
|
||||
|
||||
test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复核 -> 执行 -> 下游收口", async ({
|
||||
page,
|
||||
reimbursementDetail,
|
||||
workflowDetail,
|
||||
}) => {
|
||||
test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复核 -> 执行 -> 下游收口", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
|
||||
const adminApi = await createAuthenticatedApiClient(ADMIN);
|
||||
const employeeApi = await createAuthenticatedApiClient(EMPLOYEE);
|
||||
const financeMgrApi = await createAuthenticatedApiClient(FINANCE_MGR);
|
||||
|
||||
try {
|
||||
await ensureFeatureFlagOn(adminApi.api, "payment_batch_v1", "playwright reimbursement payment batch flow");
|
||||
await ensureFeatureFlagOn(adminApi.api, "erp_sync", "playwright reimbursement payment batch flow");
|
||||
|
||||
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();
|
||||
const reimbursementAfterSubmit = await apiGetData<{
|
||||
formNumber: string | null;
|
||||
status: string;
|
||||
totalAmount: string | null;
|
||||
}>(
|
||||
employeeApi.api,
|
||||
`/api/reimbursements/${reimbursementId}`,
|
||||
);
|
||||
expect(reimbursementAfterSubmit.status).toBe("reviewing");
|
||||
await expect(page.getByText("审批中", { exact: true }).first()).toBeVisible();
|
||||
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(page.getByText("已完成", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText(`通过 · ${MANAGER.name}`, { exact: true })).toBeVisible();
|
||||
|
||||
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(page.getByText("已完成", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText(`通过 · ${secondApprovalUser.name}`, { exact: true })).toBeVisible();
|
||||
|
||||
await logout(page, secondApprovalUser.name);
|
||||
await login(page, FINANCE);
|
||||
|
||||
await page.goto("/payment-batches");
|
||||
await expect(page).toHaveURL(/\/payment-batches/);
|
||||
|
||||
const createCard = page.getByTestId("payment-batch-create-panel");
|
||||
await expect(createCard).toBeVisible();
|
||||
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.getByTestId("payment-batch-candidate-search").fill(reimbursementDisplayCode);
|
||||
const candidateRow = createCard.getByTestId("payment-batch-candidate-row").filter({ hasText: reimbursementDisplayCode }).first();
|
||||
await expect(candidateRow).toBeVisible();
|
||||
await candidateRow.getByRole("checkbox").check();
|
||||
await createCard.getByTestId("payment-batch-default-channel").selectOption("bank");
|
||||
await createCard.getByRole("button", { name: "创建并提交复核" }).click();
|
||||
|
||||
const createBatchResponse = await createBatchResponsePromise;
|
||||
expect(createBatchResponse.request().postDataJSON()).toMatchObject({
|
||||
entityType: "reimbursement",
|
||||
entityIds: [reimbursementId],
|
||||
defaultPaymentChannel: "bank",
|
||||
});
|
||||
const createBatchPayload = (await createBatchResponse.json()) as {
|
||||
data?: { item?: { id?: string; name?: string } };
|
||||
};
|
||||
const batchId = createBatchPayload.data?.item?.id;
|
||||
const batchName = createBatchPayload.data?.item?.name;
|
||||
if (!batchId || !batchName) {
|
||||
throw new Error(`创建付款批次后未返回 id: ${JSON.stringify(createBatchPayload)}`);
|
||||
}
|
||||
await page.goto(`/payment-batches?id=${batchId}`);
|
||||
const batchDetailCard = page.getByTestId("payment-batch-detail-panel").filter({ hasText: batchName });
|
||||
await expect(batchDetailCard).toBeVisible();
|
||||
await expect(batchDetailCard).toContainText("待付款复核");
|
||||
await expect(batchDetailCard).toContainText("已提交付款复核,等待负责人处理");
|
||||
await expect(batchDetailCard).toContainText("0/1");
|
||||
const draftItemsPanel = page.getByTestId("payment-batch-items-panel");
|
||||
await expect(draftItemsPanel).toContainText(EMPLOYEE.name);
|
||||
await expect(draftItemsPanel).toContainText("开户银行");
|
||||
await expect(draftItemsPanel).toContainText("银行账户");
|
||||
await expect(draftItemsPanel).toContainText("银行转账");
|
||||
await expect(draftItemsPanel.getByTestId("payment-batch-item-payment-fields")).toHaveCount(0);
|
||||
const draftReceiptPanel = page.getByTestId("payment-batch-receipt-panel");
|
||||
await expect(draftReceiptPanel).toContainText("付款复核通过后,由财务在线下完成付款并上传银行回单");
|
||||
await expect(draftReceiptPanel.getByTestId("payment-batch-receipt-file-input")).toHaveCount(0);
|
||||
await expect(draftReceiptPanel.getByRole("button", { name: "上传银行回单" })).toHaveCount(0);
|
||||
|
||||
await logout(page, FINANCE.name);
|
||||
|
||||
await login(page, MANAGER);
|
||||
await page.goto(`/payment-batches?id=${batchId}`);
|
||||
const managerBatchDetail = page.getByTestId("payment-batch-detail-panel").filter({ hasText: batchName });
|
||||
await expect(managerBatchDetail).toBeVisible();
|
||||
await expect(page.getByTestId("payment-batch-items-panel")).toContainText(EMPLOYEE.name);
|
||||
await page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "复核通过" }).click();
|
||||
await expect(page.getByTestId("action-confirm-dialog")).toContainText("独立复核");
|
||||
await page.getByTestId("action-confirm-submit").click();
|
||||
await expect(managerBatchDetail).toContainText("1/1");
|
||||
|
||||
await logout(page, MANAGER.name);
|
||||
|
||||
await login(page, FINANCE);
|
||||
await page.goto(`/payment-batches?id=${batchId}`);
|
||||
const executedBatchDetailCard = page.getByTestId("payment-batch-detail-panel").filter({ hasText: batchName });
|
||||
await expect(executedBatchDetailCard).toBeVisible();
|
||||
await expect(executedBatchDetailCard).toContainText("1/1");
|
||||
const receiptCard = page.getByTestId("payment-batch-receipt-panel");
|
||||
await expect(receiptCard).toBeVisible();
|
||||
await expect(page.getByTestId("payment-batch-item-payment-fields")).toBeVisible();
|
||||
await expect(page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "上传银行回单" })).toBeVisible();
|
||||
await expect(page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "登记付款完成" })).toHaveCount(0);
|
||||
const receiptUploadResponsePromise = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return response.request().method() === "POST" &&
|
||||
url.pathname === `/api/payment-batches/${batchId}/receipt-attachments` &&
|
||||
response.ok();
|
||||
});
|
||||
await receiptCard.getByTestId("payment-batch-receipt-file-input").setInputFiles({
|
||||
name: "r78-bank-receipt.pdf",
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from(`%PDF-1.4\npayment receipt ${marker}\n%%EOF\n`, "utf8"),
|
||||
});
|
||||
await receiptUploadResponsePromise;
|
||||
await expect(receiptCard).toContainText("r78-bank-receipt.pdf");
|
||||
await expect(page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "登记付款完成" })).toBeVisible();
|
||||
|
||||
const executeBatchResponsePromise = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return response.request().method() === "POST" &&
|
||||
url.pathname === `/api/payment-batches/${batchId}/execute` &&
|
||||
response.ok();
|
||||
});
|
||||
await page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "登记付款完成" }).click();
|
||||
await expect(page.getByTestId("action-confirm-dialog")).toContainText("登记付款完成");
|
||||
await page.getByTestId("action-confirm-submit").click();
|
||||
await executeBatchResponsePromise;
|
||||
await expect(executedBatchDetailCard).toContainText("已付款", { timeout: 30_000 });
|
||||
await expect(page.getByTestId("payment-batch-item-payment-fields")).toHaveCount(0);
|
||||
await expect(page.getByTestId("payment-batch-item-payment-readonly")).toBeVisible();
|
||||
await expect(receiptCard.getByTestId("payment-batch-receipt-file-input")).toHaveCount(0);
|
||||
await expect(receiptCard.getByRole("button", { name: "上传银行回单" })).toHaveCount(0);
|
||||
await expect(receiptCard.getByRole("button", { name: "作废" })).toHaveCount(0);
|
||||
await expect(receiptCard.getByRole("button", { name: "下载" })).toBeVisible();
|
||||
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
await reimbursementDetail.assertLoaded();
|
||||
const reimbursementAfterPayment = await apiGetData<{ status: string }>(
|
||||
employeeApi.api,
|
||||
`/api/reimbursements/${reimbursementId}`,
|
||||
);
|
||||
expect(reimbursementAfterPayment.status).toBe("paid");
|
||||
await expect(page.getByText("已支付", { exact: true }).first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("reimbursement-payment-status")).toContainText("已付款");
|
||||
await expect(page.getByTestId(TEST_IDS.reimbursement.pay)).toHaveCount(0);
|
||||
await expect(page.getByText("执行支付")).toHaveCount(0);
|
||||
|
||||
const paymentAudit = await apiGetData<{
|
||||
items: Array<{ action: string; targetId: string | null; extra: Record<string, unknown> }>;
|
||||
}>(
|
||||
adminApi.api,
|
||||
"/api/audit-logs?action=REIMBURSEMENT_PAYMENT_MARKED&page=1&pageSize=20",
|
||||
);
|
||||
expect(
|
||||
paymentAudit.items.some((item) => item.targetId === reimbursementId && item.extra?.idempotent === false),
|
||||
"付款动作应写入报销付款审计",
|
||||
).toBe(true);
|
||||
const receiptAudit = await apiGetData<{
|
||||
items: Array<{ action: string; targetId: string | null; extra: Record<string, unknown> }>;
|
||||
}>(
|
||||
adminApi.api,
|
||||
"/api/audit-logs?action=PAYMENT_BATCH_RECEIPT_UPLOADED&page=1&pageSize=20",
|
||||
);
|
||||
expect(
|
||||
receiptAudit.items.some((item) => item.targetId === batchId && Array.isArray(item.extra?.attachmentIds)),
|
||||
"银行回单上传应写入付款批次审计",
|
||||
).toBe(true);
|
||||
|
||||
await page.goto("/ap-operations");
|
||||
await page.getByRole("tab", { name: /财务台账/ }).click();
|
||||
await expect(page.getByTestId("finance-ledger-table")).toContainText(reimbursementDisplayCode);
|
||||
await expect(page.getByTestId("finance-ledger-table")).toContainText("已付款");
|
||||
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()]);
|
||||
}
|
||||
await reimbursementPaymentFlow(page);
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户