完善借款、编号、付款与预算并发及验收(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项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
这个提交包含在:
2026-09-19 13:11:04 +08:00
父节点 729d90576f
当前提交 1f8d5b658a
共修改 367 个文件,包含 21079 行新增和 12287 行删除
+136 -115
查看文件
@@ -1,4 +1,4 @@
import { expect, test, type APIRequestContext, type APIResponse, type Browser } from "@playwright/test";
import { expect, test, type APIRequestContext, type APIResponse, type Browser, type Page } from "@playwright/test";
import {
ADMIN,
@@ -169,16 +169,14 @@ async function openAs(browser: Browser, user: E2EUser) {
return { context, page };
}
async function expectForbiddenRoutes(browser: Browser, user: E2EUser, paths: string[]) {
const { context, page } = await openAs(browser, user);
try {
for (const path of paths) {
await page.goto(path);
await assertForbiddenPage(page);
}
} finally {
await context.close();
}
async function assertAllowedRoute(page: Page, user: E2EUser, path: string) {
// A negative URL assertion can pass before auth bootstrap and permission
// routing finish. Wait for the real page before another document navigation.
await expect(page).toHaveURL(url => url.pathname === path);
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
await expect(page.getByRole("heading", { name: /^(无权限访问|页面不存在)$/ })).toHaveCount(0);
await expect(page.getByTestId("login-identifier")).toHaveCount(0);
await expect(page, `${user.name} should not be routed to /403 for ${path}`).not.toHaveURL(/\/403/);
}
async function expectDirectRoutesNotForbidden(browser: Browser, user: E2EUser, paths: string[]) {
@@ -186,7 +184,7 @@ async function expectDirectRoutesNotForbidden(browser: Browser, user: E2EUser, p
try {
for (const path of paths) {
await page.goto(path);
await expect(page, `${user.name} should not be routed to /403 for ${path}`).not.toHaveURL(/\/403/);
await assertAllowedRoute(page, user, path);
}
} finally {
await context.close();
@@ -212,60 +210,62 @@ function expectArray(value: unknown, context: string) {
return value as JsonObject[];
}
async function expectPcNavMatrix(browser: Browser, user: E2EUser, input: {
async function expectPcNavMatrix(page: Page, user: E2EUser, input: {
visible?: string[];
hidden?: string[];
managementVisible?: string[];
managementHidden?: string[];
}) {
const { context, page } = await openAs(browser, user);
try {
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto("/portal");
for (const href of input.visible ?? []) {
await expect
.poll(() => page.locator(`nav a[href="${href}"]`).count(), {
message: `${user.name} should have entry ${href}`,
})
.toBeGreaterThan(0);
await page.setViewportSize({ width: 1440, height: 960 });
await expect(page).toHaveURL(/\/portal/);
for (const href of input.visible ?? []) {
await expect
.poll(() => page.locator(`nav a[href="${href}"]`).count(), {
message: `${user.name} should have entry ${href}`,
})
.toBeGreaterThan(0);
}
for (const href of input.hidden ?? []) {
await expect(page.locator(`nav a[href="${href}"]`), `${user.name} should not see ${href}`).toHaveCount(0);
}
if (input.managementVisible) {
await page.goto("/system/settings");
await expect(page.getByTestId("system-settings-summary")).toBeVisible();
for (const href of input.managementVisible) {
await expect(page.locator(`main a[href="${href}"]`)).toBeVisible();
}
for (const href of input.hidden ?? []) {
await expect(page.locator(`nav a[href="${href}"]`), `${user.name} should not see ${href}`).toHaveCount(0);
for (const href of input.managementHidden ?? []) {
await expect(page.locator(`main a[href="${href}"]`)).toHaveCount(0);
}
} finally {
await context.close();
}
}
async function expectMobileApprovalEntry(browser: Browser, user: E2EUser, canApprove: boolean) {
const { context, page } = await openAs(browser, user);
try {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/mobile");
await expect(page.getByTestId("mobile-bottom-nav")).toBeVisible();
const approvalTab = page.getByTestId("mobile-nav-approvals");
if (canApprove) {
await expect(approvalTab).toHaveAttribute("href", "/mobile/approvals");
await expect(page.getByTestId("mobile-nav-approvals-label")).toHaveText("待我审批");
await expect(page.getByTestId("mobile-action-grid").locator('a[href="/mobile/approvals"]')).toHaveCount(0);
await page.goto("/mobile/approvals");
await expect(page).not.toHaveURL(/\/403/);
} else {
await expect(page.getByTestId("mobile-action-grid").locator('a[href="/mobile/approvals"]')).toHaveCount(0);
await expect(page.getByTestId("mobile-nav-approvals-label")).toHaveText("进度");
await page.goto("/mobile/approvals");
await expect(page).not.toHaveURL(/\/403/);
await expect
.poll(
async () =>
(await page.getByTestId("mobile-approvals-progress").count()) +
(await page.getByTestId("mobile-approvals-list").count()),
{ timeout: 30_000 },
)
.toBeGreaterThan(0);
await expect(page.getByTestId("mobile-approvals-approve")).toHaveCount(0);
await expect(page.getByTestId("mobile-approvals-reject")).toHaveCount(0);
}
} finally {
await context.close();
async function expectMobileApprovalEntry(page: Page, user: E2EUser, canApprove: boolean) {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/mobile");
await expect(page.getByTestId("mobile-bottom-nav")).toBeVisible();
const approvalTab = page.getByTestId("mobile-nav-approvals");
if (canApprove) {
await expect(approvalTab).toHaveAttribute("href", "/mobile/approvals");
await expect(page.getByTestId("mobile-nav-approvals-label")).toHaveText("待我审批");
await expect(page.getByTestId("mobile-action-grid").locator('a[href="/mobile/approvals"]')).toHaveCount(0);
await page.goto("/mobile/approvals");
await expect(page).not.toHaveURL(/\/403/);
} else {
await expect(page.getByTestId("mobile-action-grid").locator('a[href="/mobile/approvals"]')).toHaveCount(0);
await expect(page.getByTestId("mobile-nav-approvals-label")).toHaveText("进度");
await page.goto("/mobile/approvals");
await expect(page).not.toHaveURL(/\/403/);
await expect
.poll(
async () =>
(await page.getByTestId("mobile-approvals-progress").count()) +
(await page.getByTestId("mobile-approvals-list").count()),
{ timeout: 30_000 },
)
.toBeGreaterThan(0);
await expect(page.getByTestId("mobile-approvals-approve")).toHaveCount(0);
await expect(page.getByTestId("mobile-approvals-return")).toHaveCount(0);
}
}
@@ -391,60 +391,77 @@ async function expectAuditAction(api: APIRequestContext, action: string, predica
test.describe("R3 权限直连审计", () => {
test("前端直连备用路径按角色拦截", async ({ browser }) => {
await expectForbiddenRoutes(browser, EMPLOYEE, [
"/budgets",
"/ap-operations",
"/system/integrations/erp",
"/payment-batches",
"/assets/new",
]);
await expectForbiddenRoutes(browser, MANAGER, [
"/ap-operations",
"/system/integrations/erp",
"/payment-batches",
]);
await expectForbiddenRoutes(browser, FINANCE, [
"/system/integrations/erp",
]);
await expectForbiddenRoutes(browser, ADMIN, [
"/workflow/tasks",
const forbiddenByRole = new Map<E2EUser, string[]>([
[EMPLOYEE, ["/budgets", "/ap-operations", "/payment-batches", "/assets/new"]],
[MANAGER, ["/ap-operations"]],
[ADMIN, ["/workflow/tasks"]],
]);
// Each role has an isolated session; reuse it for all of that role's URLs.
for (const user of [EMPLOYEE, MANAGER, FINANCE, FINANCE_MGR, ADMIN]) {
const { context, page } = await openAs(browser, user);
try {
await test.step(`${user.name} 直连路径`, async () => {
for (const path of forbiddenByRole.get(user) ?? []) {
await page.goto(path);
await assertForbiddenPage(page);
}
if (user === MANAGER) {
await page.goto("/payment-batches");
await assertAllowedRoute(page, user, "/payment-batches");
}
await page.goto("/system/integrations/erp");
await expect(page.getByRole("heading", { name: "页面不存在", exact: true })).toBeVisible();
});
} finally { await context.close(); }
}
});
test("PA6 角色入口矩阵:PC 与移动入口按正式业务角色裁剪", async ({ browser }) => {
await expectPcNavMatrix(browser, EMPLOYEE, {
async function checkRole(user: E2EUser, matrix: Parameters<typeof expectPcNavMatrix>[2], canApprove: boolean | null) {
const { context, page } = await openAs(browser, user);
try {
await test.step(`${user.name} PC 与移动入口`, async () => {
await expectPcNavMatrix(page, user, matrix);
if (canApprove !== null) {
await expectMobileApprovalEntry(page, user, canApprove);
} else {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/mobile/me");
await expect(page.getByText("管理入口", { exact: true })).toHaveCount(0);
await expect(page.getByTestId("mobile-me-action-link").filter({ hasText: "审批" }).first()).toBeVisible();
}
});
} finally { await context.close(); }
}
await checkRole(EMPLOYEE, {
visible: ["/portal", "/reimbursements"],
hidden: ["/workflow/tasks", "/ap-operations", "/payment-batches", "/audit-logs", "/system/settings"],
});
await expectPcNavMatrix(browser, MANAGER, {
visible: ["/workflow/tasks"],
hidden: ["/ap-operations", "/payment-batches", "/audit-logs", "/system/settings"],
});
await expectPcNavMatrix(browser, FINANCE, {
visible: ["/invoice-pool"],
hidden: ["/audit-logs", "/system/settings", "/system/integrations/erp"],
});
await expectPcNavMatrix(browser, ADMIN, {
visible: ["/audit-logs", "/system/settings"],
hidden: ["/workflow/tasks", "/ap-operations", "/payment-batches"],
});
}, false);
await checkRole(MANAGER, {
visible: ["/workflow/tasks", "/payment-batches", "/system/settings"],
hidden: ["/ap-operations", "/audit-logs"],
managementVisible: ["/organization"],
managementHidden: ["/system/master-data", "/system/payment-accounts", "/system/users", "/system/approval-rules", "/system/workflow-definitions", "/audit-logs"],
}, true);
await checkRole(FINANCE, {
visible: ["/invoice-pool", "/system/settings"],
hidden: ["/audit-logs", "/system/integrations/erp"],
managementVisible: ["/organization", "/system/master-data", "/system/payment-accounts"],
managementHidden: ["/system/users", "/system/approval-rules", "/system/workflow-definitions", "/audit-logs"],
}, null);
await checkRole(ADMIN, {
visible: ["/system/settings", "/ap-operations", "/payment-batches"],
hidden: ["/workflow/tasks", "/audit-logs"],
managementVisible: ["/system/users", "/system/approval-rules", "/system/workflow-definitions", "/audit-logs"],
}, false);
await expectMobileApprovalEntry(browser, EMPLOYEE, false);
await expectMobileApprovalEntry(browser, MANAGER, true);
await expectMobileApprovalEntry(browser, ADMIN, false);
const { context, page } = await openAs(browser, FINANCE);
// Legacy admin navigation compatibility does not grant a business reviewer role.
const admin = await createAuthenticatedApiClient(ADMIN);
try {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/mobile/me");
await expect(page.getByText("管理入口", { exact: true })).toHaveCount(0);
await expect(page.getByTestId("mobile-me-action-link").filter({ hasText: "审批" }).first()).toBeVisible();
} finally {
await context.close();
}
const response = await admin.api.post("/api/payment-batches/00000000-0000-4000-8000-000000000099/review", { data: {} });
expect(response.status()).toBe(403);
} finally { await admin.dispose(); }
});
test("PA6 portal 角色能力字段不把 admin-only 当业务审批人", async () => {
@@ -496,12 +513,16 @@ test.describe("R3 权限直连审计", () => {
}
});
test("员工自助直连页面不进入管理态 403", async ({ browser }) => {
await expectDirectRoutesNotForbidden(browser, EMPLOYEE, [
"/travel-requests",
"/invoice-pool",
"/contracts-registry",
]);
test("员工自助直连页面可用且冻结差旅拒绝进入", async ({ browser }) => {
const { context, page } = await openAs(browser, EMPLOYEE);
try {
for (const path of ["/invoice-pool", "/contracts-registry"]) {
await page.goto(path);
await assertAllowedRoute(page, EMPLOYEE, path);
}
await page.goto("/travel-requests");
await assertForbiddenPage(page);
} finally { await context.close(); }
});
test("资产创建路由只允许 asset.manage 角色直连", async ({ browser }) => {
@@ -522,7 +543,7 @@ test.describe("R3 权限直连审计", () => {
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/ap-operations", statuses: [403, 409], note: "员工不得直连 AP;409 表示功能门禁关闭且未泄露数据" });
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/ap-operations/reimbursement-ledger", statuses: [403, 409], note: "员工不得直连财务台账;409 表示功能门禁关闭且未泄露数据" });
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/payment-batches", statuses: [403, 409], note: "员工不得直连付款批次;409 表示功能门禁关闭且未泄露数据" });
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/integrations/erp/master-data/status", statuses: [403], note: "员工不得直连 ERP 管理" });
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/integrations/erp/master-data/status", statuses: [404], note: "ERP 已退役,员工接口不存在" });
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/invoice-pool?ownerScope=all", statuses: [403], note: "员工不得查看全量票据" });
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/audit-logs", statuses: [403], note: "员工不得查看审计日志" });
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/notifications/deliveries", statuses: [403], note: "员工不得查看通知投递审计" });
@@ -543,11 +564,11 @@ test.describe("R3 权限直连审计", () => {
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/ap-operations", statuses: [403, 409], note: "主管不得直连 AP" });
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/ap-operations/reimbursement-ledger", statuses: [403, 409], note: "主管不得直连财务台账" });
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/payment-batches", statuses: [200, 409], note: "主管可读取待复核付款批次;409 表示功能门禁关闭" });
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/integrations/erp/master-data/status", statuses: [403], note: "主管不得直连 ERP 管理" });
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/integrations/erp/master-data/status", statuses: [404], note: "ERP 已退役,主管接口不存在" });
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/audit-logs", statuses: [403], note: "主管不得查看审计日志" });
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/budgets", statuses: [200], note: "主管可直连预算" });
await expectApiStatus({ role: "finance", api: finance.api, path: "/api/integrations/erp/master-data/status", statuses: [403], note: "财务不得直连 ERP 管理" });
await expectApiStatus({ role: "finance", api: finance.api, path: "/api/integrations/erp/master-data/status", statuses: [404], note: "ERP 已退役,财务接口不存在" });
await expectApiStatus({ role: "finance", api: finance.api, path: "/api/audit-logs", statuses: [403], note: "普通财务不得查看审计日志" });
await expectApiStatus({ role: "finance", api: finance.api, path: "/api/system/feature-flags", statuses: [403], note: "普通财务不得管理系统开关" });
await expectApiStatus({ role: "finance", api: finance.api, path: "/api/notifications/deliveries", statuses: [403], note: "普通财务不得查看通知渠道投递审计" });
@@ -559,8 +580,8 @@ test.describe("R3 权限直连审计", () => {
role: "finance_mgr",
api: financeMgr.api,
path: "/api/integrations/erp/master-data/status",
statuses: [200],
note: "财务主管可进入 ERP 管理状态",
statuses: [404],
note: "ERP 已退役,财务主管也不能调用",
});
expectNoSensitiveKeys(erpStatus.body?.data);