feat: deliver first finance preview with setup wizard
Release / release (push) Failing after 18s

This commit is contained in:
Qiufeng
2026-08-17 14:08:40 +08:00
parent 47403fd337
commit c8b0151769
558 changed files with 140774 additions and 3 deletions
+407
View File
@@ -0,0 +1,407 @@
import { readFileSync } from 'node:fs';
import AxeBuilder from '@axe-core/playwright';
import type { Page, Route } from '@playwright/test';
import { expect, test } from '@playwright/test';
interface ContractRoute {
pageId: string;
path: string;
pageType: 'system' | 'business';
requiresAuth: boolean;
breadcrumb: string[];
roles: string[];
permission: string | string[] | null;
}
const routes = (
JSON.parse(readFileSync(new URL('../contracts/routes.json', import.meta.url), 'utf8')) as {
routes: ContractRoute[];
}
).routes;
const projectId = '01KZRXMHB04HR8Y23HTH1F7DXV';
const visibleHeadingPageIds = new Set(['PAGE-01', 'PAGE-02', 'PAGE-08', 'PAGE-10', 'PAGE-23']);
const roleNames: Record<string, string> = {
PROJECT_MANAGER: '项目管理人员',
FINANCE_MANAGER: '财务管理人员',
ARCHIVE_MANAGER: '资料管理人员',
SYSTEM_ADMIN: '系统管理员',
};
function concretePath(route: ContractRoute) {
if (route.pageId === 'PAGE-08') return `/projects/${projectId}`;
if (route.pageId === 'PAGE-10') return '/forms/OA-01';
return route.path;
}
function expectedHeading(route: ContractRoute) {
if (route.pageId === 'PAGE-01') return '登录系统';
if (route.pageId === 'PAGE-02') return '选择工作身份';
if (route.pageId === 'PAGE-03') return '个人中心';
if (route.pageId === 'PAGE-08') return '自动化验收项目';
if (route.pageId === 'PAGE-17') return '项目档案包';
return route.breadcrumb.at(-1) || '';
}
function sessionFor(route: ContractRoute) {
const roleCode = route.roles[0] || 'PROJECT_MANAGER';
const permissions = Array.isArray(route.permission) ? route.permission : route.permission ? [route.permission] : [];
return {
authenticated: true,
user: {
publicId: '01M00000000000000000000001',
username: 'playwright',
displayName: '自动化验收用户',
departmentName: '测试组',
mustChangePassword: false,
},
roles: [
{
code: roleCode,
name: roleNames[roleCode],
workbenchRoute:
roleCode === 'SYSTEM_ADMIN'
? '/governance/settings'
: `/workbench/${roleCode === 'PROJECT_MANAGER' ? 'project' : roleCode === 'FINANCE_MANAGER' ? 'finance' : 'archive'}`,
},
],
activeRole: route.pageId === 'PAGE-02' ? null : roleCode,
permissions,
};
}
function listEnvelope() {
return {
data: [],
meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 },
requestId: '01M00000000000000000000002',
};
}
function projectDetail() {
return {
publicId: projectId,
businessNo: 'P-E2E-001',
name: '自动化验收项目',
company: { publicId: '01M00000000000000000000003', name: '自动化验收公司' },
customer: null,
owner: { publicId: '01M00000000000000000000004', name: '自动化验收负责人' },
contractAmount: '0.00',
receivedAmount: '0.00',
paidAmount: '0.00',
currency: 'CNY',
stage: 'IN_PROGRESS',
financeStatus: 'NORMAL',
archiveStatus: 'PREPARING',
status: 'ACTIVE',
startDate: null,
endDate: null,
version: 0,
createdAt: '2026-08-15T00:00:00Z',
updatedAt: '2026-08-15T00:00:00Z',
overview: {
contractAmount: '0.00',
receivedAmount: '0.00',
paidAmount: '0.00',
availableBalance: '0.00',
sourceFormCount: 0,
contractCount: 0,
receiptCount: 0,
invoiceCount: 0,
paymentCount: 0,
progressCount: 0,
peopleCount: 0,
accountingCount: 0,
voucherCount: 0,
pendingTaskCount: 0,
fileCount: 0,
activeRiskCount: 0,
documentCompleteness: 0,
},
riskFlags: [],
timeline: [],
allowedActions: [],
};
}
function projectWorkbench() {
return {
roleCode: 'PROJECT_MANAGER',
title: '项目管理工作台',
refreshedAt: '2026-08-17T00:00:00Z',
metrics: [
{ code: 'PROJECTS', label: '全部进行中项目', value: 2, targetRoute: '/projects' },
{ code: 'TASKS', label: '全部待处理', value: 1, targetRoute: '/tasks?view=todo' },
{ code: 'RETURNED', label: '全部退回表单', value: 0, targetRoute: '/forms?status=RETURNED' },
{ code: 'DUE', label: '全部即将到期待办', value: 1, targetRoute: '/tasks?dueSoon=true' },
],
todos: [],
projects: [],
issues: [
{
code: 'RETURNED_FORM',
label: '全部退回表单',
severity: 'DANGER',
count: 0,
detail: '需要补充资料后重新提交',
targetRoute: '/forms?status=RETURNED',
},
{
code: 'MISSING_ARCHIVE_ITEMS',
label: '缺资料',
severity: 'WARNING',
count: 40,
detail: '请在档案包中补充缺失资料',
targetRoute: '/archives/projects?missing=true',
},
{
code: 'OVERDUE_TASK',
label: '已超期待办',
severity: 'DANGER',
count: 0,
detail: '请优先处理已超过时限的审批任务',
targetRoute: '/tasks?overdue=true',
},
{
code: 'DRAFT_FORM',
label: '全部未提交草稿',
severity: 'PRIMARY',
count: 0,
detail: '继续编辑并完成校验后提交',
targetRoute: '/forms?status=DRAFT',
},
],
funds: [],
archiveItems: [],
activities: [],
};
}
function mockData(pathname: string) {
if (pathname === `/api/v1/projects/${projectId}`) return projectDetail();
if (pathname === '/api/v1/workbenches/project') return projectWorkbench();
if (pathname === '/api/v1/admin/users') {
return [
{
resource: 'users',
publicId: '01M00000000000000000000007',
username: 'playwright',
displayName: '自动化验收用户',
departmentName: '测试组',
roleCodes: ['SYSTEM_ADMIN'],
enabled: true,
updatedAt: '2026-08-17T00:00:00Z',
},
];
}
if (pathname.startsWith('/api/v1/workbenches/')) return {};
if (pathname.includes('/summary') || pathname.includes('/detail')) return {};
return [];
}
async function fulfillApi(route: Route, contractRoute: ContractRoute) {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (pathname === '/api/v1/setup/status') {
await route.fulfill({
json: {
data: {
required: contractRoute.pageId === 'PAGE-23',
locked: contractRoute.pageId !== 'PAGE-23',
supportedDatabaseTypes: ['MYSQL'],
message: contractRoute.pageId === 'PAGE-23' ? '请完成安装' : '系统已完成安装',
},
requestId: '01M00000000000000000000000',
},
});
return;
}
if (pathname === '/api/v1/auth/session') {
await route.fulfill({ json: { data: sessionFor(contractRoute), requestId: '01M00000000000000000000004' } });
return;
}
if (pathname === '/api/v1/auth/profile') {
await route.fulfill({ json: { data: sessionFor(contractRoute).user, requestId: '01M00000000000000000000005' } });
return;
}
if (pathname === '/api/v1/auth/roles') {
await route.fulfill({ json: { data: sessionFor(contractRoute).roles, requestId: '01M00000000000000000000006' } });
return;
}
if (request.method() === 'GET') {
const envelope = listEnvelope();
await route.fulfill({ json: { ...envelope, data: mockData(pathname) } });
return;
}
await route.fulfill({ status: 204 });
}
async function installRouteFixture(page: Page, route: ContractRoute) {
await page.route('**/api/v1/**', (requestRoute) => fulfillApi(requestRoute, route));
}
for (const route of routes) {
test(`${route.pageId} ${route.path} has no serious accessibility or page-overflow regression`, async ({ page }) => {
const consoleErrors: string[] = [];
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text());
});
page.on('pageerror', (error) => consoleErrors.push(error.message));
await installRouteFixture(page, route);
await page.goto(concretePath(route));
const heading = page.getByRole('heading', { name: expectedHeading(route), exact: true });
await expect(heading).toHaveCount(1);
if (visibleHeadingPageIds.has(route.pageId)) {
await expect(heading).toBeVisible();
} else {
await expect(page.locator('h1.page-title-sr-only')).toHaveCount(1);
await expect(page.locator('h1:not(.page-title-sr-only)')).toHaveCount(0);
}
if (!['PAGE-01', 'PAGE-02', 'PAGE-23'].includes(route.pageId)) {
const breadcrumb = page.locator('.tdesign-breadcrumb');
await expect(breadcrumb).toBeVisible();
for (const label of route.breadcrumb) {
await expect(breadcrumb.getByText(label, { exact: true })).toBeVisible();
}
}
await page.waitForLoadState('networkidle');
if (route.pageId === 'PAGE-09') {
await expect(page.locator('.t-input-number__decrease, .t-input-number__increase')).toHaveCount(0);
}
const layout = await page.evaluate(() => ({
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
}));
expect(
layout.scrollWidth,
`${route.pageId} document overflowed by ${layout.scrollWidth - layout.clientWidth}px`,
).toBeLessThanOrEqual(layout.clientWidth);
const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
const blockingViolations = axe.violations.filter(
(violation) => violation.impact === 'critical' || violation.impact === 'serious',
);
expect(blockingViolations, JSON.stringify(blockingViolations, null, 2)).toEqual([]);
expect(consoleErrors, consoleErrors.join('\n')).toEqual([]);
});
}
test('SYSTEM_ADMIN can open every contracted business page without explicit page permissions', async ({ page }) => {
test.setTimeout(90_000);
const systemRoute = routes.find((route) => route.pageId === 'PAGE-21');
if (!systemRoute) throw new Error('PAGE-21 contract is missing');
const adminRoute: ContractRoute = {
...systemRoute,
roles: ['SYSTEM_ADMIN'],
permission: null,
};
await installRouteFixture(page, adminRoute);
for (const route of routes.filter((item) => item.pageType === 'business')) {
await page.goto(concretePath(route));
await expect(page).not.toHaveURL(/\/result\/403$/);
const heading = page.getByRole('heading', { name: expectedHeading(route), exact: true });
await expect(heading).toHaveCount(1);
if (visibleHeadingPageIds.has(route.pageId)) {
await expect(heading).toBeVisible();
} else {
await expect(page.locator('h1.page-title-sr-only')).toHaveCount(1);
}
}
});
test('mobile settings table fixed columns do not overlap', async ({ page }) => {
const systemRoute = routes.find((route) => route.pageId === 'PAGE-21');
if (!systemRoute) throw new Error('PAGE-21 contract is missing');
await page.setViewportSize({ width: 390, height: 844 });
await installRouteFixture(page, systemRoute);
await page.goto(systemRoute.path);
await expect(page.locator('.t-table tbody tr')).toHaveCount(1);
const layout = await page.evaluate(() => {
const cells = [
...document.querySelectorAll<HTMLElement>('.t-table__cell--fixed-left, .t-table__cell--fixed-right'),
].filter((cell) => cell.getBoundingClientRect().width > 0);
const overlaps = cells.flatMap((left, index) => {
const a = left.getBoundingClientRect();
return cells.slice(index + 1).filter((right) => {
const b = right.getBoundingClientRect();
const sameRow = Math.abs(a.top - b.top) < 1;
return sameRow && a.left < b.right && b.left < a.right;
});
});
return {
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
overlaps: overlaps.length,
fixedPositions: cells.map((cell) => getComputedStyle(cell).position),
};
});
expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth);
expect(layout.overlaps).toBe(0);
expect(layout.fixedPositions).not.toContain('sticky');
});
test('project workbench issue rows keep counts, copy and arrows aligned', async ({ page }) => {
const projectRoute = routes.find((route) => route.pageId === 'PAGE-04');
if (!projectRoute) throw new Error('PAGE-04 contract is missing');
await installRouteFixture(page, projectRoute);
for (const viewport of [
{ width: 1100, height: 900 },
{ width: 390, height: 844 },
]) {
await page.setViewportSize(viewport);
await page.goto(projectRoute.path);
await expect(page.locator('.issue-row')).toHaveCount(4);
const layout = await page.evaluate(() => {
const rows = [...document.querySelectorAll<HTMLElement>('.issue-row')].map((row) => {
const count = row.querySelector<HTMLElement>('.issue-count');
const copy = row.querySelector<HTMLElement>('.issue-copy');
const arrow = row.querySelector<HTMLElement>('.issue-arrow');
if (!count || !copy || !arrow) throw new Error('Issue row layout element is missing');
const rowRect = row.getBoundingClientRect();
const countRect = count.getBoundingClientRect();
const copyRect = copy.getBoundingClientRect();
const arrowRect = arrow.getBoundingClientRect();
return {
rowX: Math.round(rowRect.x),
rowRight: Math.round(rowRect.right),
rowHeight: Math.round(rowRect.height),
copyX: Math.round(copyRect.x),
copyRight: Math.round(copyRect.right),
arrowX: Math.round(arrowRect.x),
arrowRight: Math.round(arrowRect.right),
countRight: Math.round(countRect.right),
countCenterOffset: Math.abs(countRect.y + countRect.height / 2 - (rowRect.y + rowRect.height / 2)),
arrowCenterOffset: Math.abs(arrowRect.y + arrowRect.height / 2 - (rowRect.y + rowRect.height / 2)),
};
});
return {
rows,
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
};
});
expect(new Set(layout.rows.map((row) => row.rowX)).size).toBe(1);
expect(new Set(layout.rows.map((row) => row.copyX)).size).toBe(1);
expect(new Set(layout.rows.map((row) => row.arrowX)).size).toBe(1);
expect(Math.min(...layout.rows.map((row) => row.rowHeight))).toBeGreaterThanOrEqual(64);
expect(layout.rows.every((row) => row.countRight <= row.copyX)).toBe(true);
expect(layout.rows.every((row) => row.copyRight <= row.arrowX)).toBe(true);
expect(layout.rows.every((row) => row.arrowRight <= row.rowRight)).toBe(true);
expect(Math.max(...layout.rows.map((row) => row.countCenterOffset))).toBeLessThanOrEqual(1);
expect(Math.max(...layout.rows.map((row) => row.arrowCenterOffset))).toBeLessThanOrEqual(1);
expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth);
}
});
+571
View File
@@ -0,0 +1,571 @@
import { Buffer } from 'node:buffer';
import type { Page, Route } from '@playwright/test';
import { expect, test } from '@playwright/test';
const eventId = '01M00000000000000000000160';
const voucherId = '01M00000000000000000000161';
const companyId = '01M00000000000000000000162';
const projectId = '01M00000000000000000000163';
const counterpartyId = '01M00000000000000000000164';
const debitAccountId = '01M00000000000000000000165';
const creditAccountId = '01M00000000000000000000166';
interface AccountingFixtureOptions {
initialTab?: 'pending' | 'draft';
initialVoucher?: ReturnType<typeof voucher>;
initialEvidence?: Array<Record<string, unknown>>;
generateRequests?: Array<Record<string, unknown>>;
updateRequests?: Array<Record<string, unknown>>;
recordRequests?: Array<Record<string, unknown>>;
verifyRequests?: Array<Record<string, unknown>>;
reverseRequests?: Array<Record<string, unknown>>;
reopenRequests?: Array<Record<string, unknown>>;
}
function accountingEvent() {
return {
publicId: eventId,
businessNo: 'EVT-E2E-001',
eventType: 'PAYABLE',
sourceType: 'PAYABLE',
sourcePublicId: '01M00000000000000000000167',
sourceVersion: '1',
eventSequence: 1,
sourceEventKey: 'PAYABLE:E2E:1',
company: { publicId: companyId, code: 'GS-E2E-001', name: '凯迪测试公司' },
project: { publicId: projectId, code: 'XM-E2E-001', name: '记账验收项目' },
counterparty: { publicId: counterpartyId, code: 'GY-E2E-001', name: '验收供应商' },
businessDate: '2026-08-15',
period: '2026-08',
amount: '1000.00',
currency: 'CNY',
status: 'PENDING',
voucherPublicId: null,
version: 0,
createdAt: '2026-08-15T08:00:00Z',
allowedActions: ['GENERATE_DRAFT'],
};
}
function voucher(overrides: Record<string, unknown> = {}) {
return {
publicId: voucherId,
businessNo: 'VCH-202608-E2E001',
eventPublicId: eventId,
eventBusinessNo: 'EVT-E2E-001',
company: { publicId: companyId, code: 'GS-E2E-001', name: '凯迪测试公司' },
project: { publicId: projectId, code: 'XM-E2E-001', name: '记账验收项目' },
period: '2026-08',
businessDate: '2026-08-15',
summary: '应付确认 EVT-E2E-001',
debitTotal: '1000.00',
creditTotal: '1000.00',
balanceStatus: 'BALANCED',
status: 'DRAFT',
submittedByName: null,
reviewedByName: null,
exportedAt: null,
exportBatchNo: null,
exportSha256: null,
exportFileId: null,
ruleVersion: 'ACCOUNTING-RULE-V1',
externalVoucherNo: null,
resultAt: null,
resultCycleNo: 0,
evidenceFileIds: [],
resultRecordedByName: null,
resultRecordedAt: null,
resultRecordRemark: null,
resultVerifiedByName: null,
resultVerifiedAt: null,
resultVerifyOpinion: null,
voidReason: null,
version: 1,
createdAt: '2026-08-15T08:01:00Z',
updatedAt: '2026-08-15T08:01:00Z',
allowedActions: ['EDIT', 'SUBMIT', 'VOID'],
...overrides,
};
}
function entries() {
const auxiliaryJson = JSON.stringify({ projectId, counterpartyId });
return [
{
publicId: '01M00000000000000000000168',
lineNo: 1,
direction: 'DEBIT',
account: { publicId: debitAccountId, code: '6601', name: '项目成本' },
amount: '1000.00',
summary: '应付确认 EVT-E2E-001',
auxiliaryJson,
},
{
publicId: '01M00000000000000000000169',
lineNo: 2,
direction: 'CREDIT',
account: { publicId: creditAccountId, code: '2202', name: '应付账款' },
amount: '1000.00',
summary: '应付确认 EVT-E2E-001',
auxiliaryJson,
},
];
}
function voucherDetail(
currentVoucher = voucher(),
evidenceFiles: Array<Record<string, unknown>> = [],
resultActions: Array<Record<string, unknown>> = [],
) {
return { voucher: currentVoucher, event: accountingEvent(), entries: entries(), evidenceFiles, resultActions };
}
function userSession(reviewer = false) {
return {
authenticated: true,
user: {
publicId: reviewer ? '01M00000000000000000000183' : '01M00000000000000000000170',
username: reviewer ? 'accounting-reviewer-e2e' : 'accounting-recorder-e2e',
displayName: reviewer ? '记账复核员' : '记账登记员',
departmentName: '财务部',
mustChangePassword: false,
},
roles: [{ code: 'FINANCE_MANAGER', name: '财务管理人员', workbenchRoute: '/workbench/finance' }],
activeRole: 'FINANCE_MANAGER',
permissions: [
'accounting:event:view',
'accounting:event:generate',
'accounting:voucher:view',
'accounting:voucher:edit',
'accounting:voucher:reverse-result',
'accounting:voucher:reopen-result',
],
};
}
async function installAccountingFixture(page: Page, options: AccountingFixtureOptions = {}) {
let reviewerSession = false;
let currentVoucher = options.initialVoucher || voucher();
let currentEvidence: Array<Record<string, unknown>> = options.initialEvidence || [];
const resultActions: Array<Record<string, unknown>> = [];
await page.route('**/api/v1/**', async (route: Route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
const session = userSession(reviewerSession);
if (pathname === '/api/v1/auth/session') {
await route.fulfill({ json: { data: session, requestId: '01M00000000000000000000171' } });
return;
}
if (pathname === '/api/v1/auth/profile') {
await route.fulfill({ json: { data: session.user, requestId: '01M00000000000000000000172' } });
return;
}
if (pathname === '/api/v1/auth/roles') {
await route.fulfill({ json: { data: session.roles, requestId: '01M00000000000000000000173' } });
return;
}
if (pathname === '/api/v1/auth/csrf') {
await route.fulfill({
headers: { 'set-cookie': 'XSRF-TOKEN=accounting-e2e-csrf; Path=/' },
json: { data: { token: 'accounting-e2e-csrf' }, requestId: '01M00000000000000000000174' },
});
return;
}
if (request.method() === 'GET' && pathname === '/api/v1/accounting/accounts') {
await route.fulfill({
json: {
data: [
{
publicId: debitAccountId,
code: '6601',
name: '项目成本',
accountType: 'EXPENSE',
auxiliaryRequired: true,
},
{
publicId: creditAccountId,
code: '2202',
name: '应付账款',
accountType: 'LIABILITY',
auxiliaryRequired: true,
},
],
requestId: '01M00000000000000000000175',
},
});
return;
}
if (request.method() === 'GET' && pathname === '/api/v1/masterdata/references') {
await route.fulfill({
json: {
data: {
companies: [accountingEvent().company],
projects: [accountingEvent().project],
counterparties: [accountingEvent().counterparty],
costCategories: [],
},
requestId: '01M00000000000000000000176',
},
});
return;
}
if (request.method() === 'GET' && pathname === '/api/v1/accounting/events') {
await route.fulfill({
json: {
data: [accountingEvent()],
meta: { page: 1, size: 20, totalElements: 1, totalPages: 1 },
requestId: '01M00000000000000000000177',
},
});
return;
}
if (request.method() === 'GET' && pathname === '/api/v1/accounting/vouchers') {
await route.fulfill({
json: {
data: [currentVoucher],
meta: { page: 1, size: 20, totalElements: 1, totalPages: 1 },
requestId: '01M00000000000000000000178',
},
});
return;
}
if (request.method() === 'GET' && pathname === `/api/v1/accounting/vouchers/${voucherId}`) {
await route.fulfill({
json: {
data: voucherDetail(currentVoucher, currentEvidence, resultActions),
requestId: '01M00000000000000000000179',
},
});
return;
}
if (request.method() === 'POST' && pathname === `/api/v1/accounting/events/${eventId}/generate-draft`) {
options.generateRequests?.push(request.postDataJSON() as Record<string, unknown>);
await route.fulfill({
json: { data: voucherDetail(currentVoucher), requestId: '01M00000000000000000000180' },
});
return;
}
if (request.method() === 'PATCH' && pathname === `/api/v1/accounting/vouchers/${voucherId}`) {
const payload = request.postDataJSON() as Record<string, unknown>;
options.updateRequests?.push(payload);
currentVoucher = voucher({ summary: payload.summary, version: 2, updatedAt: '2026-08-15T08:02:00Z' });
await route.fulfill({
json: { data: voucherDetail(currentVoucher), requestId: '01M00000000000000000000181' },
});
return;
}
if (request.method() === 'POST' && pathname === '/api/v1/files') {
currentEvidence = [
{
publicId: '01M00000000000000000000184',
originalName: 'accounting-result.pdf',
mediaType: 'application/pdf',
sizeBytes: 20,
sha256: 'c'.repeat(64),
scanStatus: 'AVAILABLE',
},
];
await route.fulfill({
json: { data: currentEvidence[0], requestId: '01M00000000000000000000185' },
});
return;
}
if (request.method() === 'POST' && pathname === `/api/v1/accounting/vouchers/${voucherId}/record-result`) {
const payload = request.postDataJSON() as Record<string, unknown>;
options.recordRequests?.push(payload);
currentVoucher = voucher({
status: 'RESULT_RECORDED',
version: 4,
allowedActions: ['VERIFY_RESULT'],
externalVoucherNo: payload.externalVoucherNo,
resultAt: payload.resultAt,
evidenceFileIds: payload.evidenceFileIds,
resultRecordedByName: '记账登记员',
resultRecordedAt: '2026-08-15T08:10:00Z',
resultRecordRemark: payload.remark,
});
reviewerSession = true;
await route.fulfill({
json: {
data: voucherDetail(currentVoucher, currentEvidence, resultActions),
requestId: '01M00000000000000000000186',
},
});
return;
}
if (request.method() === 'POST' && pathname === `/api/v1/accounting/vouchers/${voucherId}/verify-result`) {
const payload = request.postDataJSON() as Record<string, unknown>;
options.verifyRequests?.push(payload);
currentVoucher = voucher({
...currentVoucher,
status: 'COMPLETED',
version: 5,
allowedActions: [],
resultVerifiedByName: '记账复核员',
resultVerifiedAt: '2026-08-15T08:15:00Z',
resultVerifyOpinion: payload.opinion,
});
await route.fulfill({
json: {
data: voucherDetail(currentVoucher, currentEvidence, resultActions),
requestId: '01M00000000000000000000187',
},
});
return;
}
if (request.method() === 'POST' && pathname === `/api/v1/accounting/vouchers/${voucherId}/reverse-result`) {
const payload = request.postDataJSON() as Record<string, unknown>;
options.reverseRequests?.push(payload);
resultActions.unshift({
publicId: '01M00000000000000000000188',
sequenceNo: 1,
resultCycleNo: 0,
actionType: 'REVERSE',
reason: payload.reason,
externalVoucherNo: currentVoucher.externalVoucherNo,
resultAt: currentVoucher.resultAt,
resultRecordedByName: currentVoucher.resultRecordedByName,
resultRecordedAt: currentVoucher.resultRecordedAt,
resultRecordRemark: currentVoucher.resultRecordRemark,
resultVerifiedByName: currentVoucher.resultVerifiedByName,
resultVerifiedAt: currentVoucher.resultVerifiedAt,
resultVerifyOpinion: currentVoucher.resultVerifyOpinion,
evidenceFiles: currentEvidence,
operatedByName: '记账复核员',
operatedAt: '2026-08-15T08:20:00Z',
versionBefore: 5,
versionAfter: 6,
});
currentVoucher = voucher({
...currentVoucher,
status: 'RESULT_REVERSED',
version: 6,
allowedActions: ['REOPEN_RESULT'],
});
reviewerSession = false;
await route.fulfill({
json: {
data: voucherDetail(currentVoucher, currentEvidence, resultActions),
requestId: '01M00000000000000000000189',
},
});
return;
}
if (request.method() === 'POST' && pathname === `/api/v1/accounting/vouchers/${voucherId}/reopen-result`) {
const payload = request.postDataJSON() as Record<string, unknown>;
options.reopenRequests?.push(payload);
resultActions.unshift({
...resultActions[0],
publicId: '01M00000000000000000000190',
sequenceNo: 2,
actionType: 'REOPEN',
reason: payload.reason,
operatedByName: '记账登记员',
operatedAt: '2026-08-15T08:25:00Z',
versionBefore: 6,
versionAfter: 7,
});
currentVoucher = voucher({
...currentVoucher,
status: 'EXPORTED',
version: 7,
resultCycleNo: 1,
allowedActions: ['RECORD_RESULT'],
externalVoucherNo: null,
resultAt: null,
evidenceFileIds: [],
resultRecordedByName: null,
resultRecordedAt: null,
resultRecordRemark: null,
resultVerifiedByName: null,
resultVerifiedAt: null,
resultVerifyOpinion: null,
});
currentEvidence = [];
await route.fulfill({
json: {
data: voucherDetail(currentVoucher, currentEvidence, resultActions),
requestId: '01M00000000000000000000191',
},
});
return;
}
if (request.method() === 'GET') {
await route.fulfill({
json: {
data: [],
meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 },
requestId: '01M00000000000000000000182',
},
});
return;
}
await route.fulfill({ status: 204 });
});
}
test('PAGE-16 generates one voucher draft with the fixed rule version', async ({ page }) => {
const generateRequests: Array<Record<string, unknown>> = [];
await installAccountingFixture(page, { generateRequests });
await page.goto('/finance/accounting?tab=pending');
await expect(page.getByRole('heading', { name: '记账准备', exact: true })).toBeVisible();
await expect(page.getByText('EVT-E2E-001', { exact: true }).first()).toBeVisible();
await page.getByRole('button', { name: '生成草稿', exact: true }).click();
await expect.poll(() => generateRequests.length).toBe(1);
expect(generateRequests[0]).toEqual({ version: 0, ruleVersion: 'ACCOUNTING-RULE-V1' });
await expect(page.getByText('凭证草稿已生成', { exact: true })).toBeVisible();
await expect(page.getByText('借贷平衡', { exact: true })).toBeVisible();
});
test('PAGE-16 edits an auxiliary voucher directly from the list without raw JSON input', async ({ page }) => {
const updateRequests: Array<Record<string, unknown>> = [];
await installAccountingFixture(page, { initialTab: 'draft', updateRequests });
await page.goto('/finance/accounting?tab=draft');
await expect(page.getByText('VCH-202608-E2E001', { exact: true }).first()).toBeVisible();
await page.getByRole('button', { name: '更多操作', exact: true }).click();
await page.getByText('编辑草稿', { exact: true }).click();
const dialog = page.locator('.t-dialog').filter({ hasText: '编辑凭证草稿' });
await expect(dialog.getByText('项目:XM-E2E-001 记账验收项目', { exact: true }).first()).toBeVisible();
await expect(dialog.getByText('往来单位:GY-E2E-001 验收供应商', { exact: true }).first()).toBeVisible();
await expect(dialog.getByText('辅助核算 JSON', { exact: true })).toHaveCount(0);
await dialog.locator('textarea').fill('核对来源维度后保存');
await dialog.getByRole('button', { name: '保存草稿', exact: true }).click();
await expect.poll(() => updateRequests.length).toBe(1);
const payload = updateRequests[0];
const payloadEntries = payload.entries as Array<Record<string, unknown>>;
expect(payloadEntries).toHaveLength(2);
for (const entry of payloadEntries) {
expect(JSON.parse(String(entry.auxiliaryJson))).toEqual({ projectId, counterpartyId });
}
expect(payload.changeReason).toBe('核对来源维度后保存');
await expect(dialog).toBeHidden();
await expect(page.getByText('凭证草稿已保存', { exact: true })).toBeVisible();
});
test('PAGE-16 records evidence and preserves separate record and verification notes', async ({ page }) => {
const recordRequests: Array<Record<string, unknown>> = [];
const verifyRequests: Array<Record<string, unknown>> = [];
await installAccountingFixture(page, {
initialVoucher: voucher({ status: 'EXPORTED', version: 3, allowedActions: ['RECORD_RESULT'] }),
recordRequests,
verifyRequests,
});
await page.goto('/finance/accounting?tab=result');
await expect(page.getByText('VCH-202608-E2E001', { exact: true }).first()).toBeVisible();
await page.getByRole('button', { name: '更多操作', exact: true }).click();
await page.getByText('登记记账结果', { exact: true }).click();
const recordDialog = page.locator('.t-dialog').filter({ hasText: '登记记账结果' });
const resultDate = await page.evaluate(() => {
const now = new Date();
return new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 10);
});
await expect(recordDialog.locator('.t-date-picker input')).toHaveValue(resultDate);
await recordDialog.getByPlaceholder('请输入', { exact: true }).fill('TPLUS-E2E-001');
await recordDialog.locator('textarea').fill('人工导入成功');
await recordDialog.locator('input[type="file"]').setInputFiles({
name: 'accounting-result.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('accounting-result-evidence'),
});
await recordDialog.getByRole('button', { name: '上传已选文件', exact: true }).click();
await expect(recordDialog.getByText('可用', { exact: true })).toBeVisible();
await recordDialog.getByRole('button', { name: '确认登记记账结果', exact: true }).click();
await expect.poll(() => recordRequests.length).toBe(1);
expect(recordRequests[0]).toEqual({
version: 3,
externalVoucherNo: 'TPLUS-E2E-001',
resultAt: resultDate,
evidenceFileIds: ['01M00000000000000000000184'],
remark: '人工导入成功',
});
await expect(recordDialog).toBeHidden();
await page.reload();
await page.getByRole('button', { name: '更多操作', exact: true }).click();
await page.getByText('复核记账结果', { exact: true }).click();
const verifyDialog = page.locator('.t-dialog').filter({ hasText: '复核记账结果' });
await verifyDialog.locator('textarea').fill('凭证号和导入结果一致');
await verifyDialog.getByRole('button', { name: '确认复核记账结果', exact: true }).click();
await expect.poll(() => verifyRequests.length).toBe(1);
expect(verifyRequests[0]).toEqual({ version: 4, opinion: '凭证号和导入结果一致' });
await expect(verifyDialog).toBeHidden();
await page.getByText('VCH-202608-E2E001', { exact: true }).first().click();
await expect(page.getByText('人工导入成功', { exact: true })).toBeVisible();
await expect(page.getByText('凭证号和导入结果一致', { exact: true })).toBeVisible();
});
test('PAGE-16 reverses and reopens a completed result from server allowed actions', async ({ page }) => {
const reverseRequests: Array<Record<string, unknown>> = [];
const reopenRequests: Array<Record<string, unknown>> = [];
const evidence = {
publicId: '01M00000000000000000000184',
originalName: 'accounting-result.pdf',
mediaType: 'application/pdf',
sizeBytes: 20,
sha256: 'c'.repeat(64),
scanStatus: 'AVAILABLE',
};
await installAccountingFixture(page, {
initialVoucher: voucher({
status: 'COMPLETED',
version: 5,
allowedActions: ['REVERSE_RESULT'],
externalVoucherNo: 'TPLUS-E2E-REVERSED',
resultAt: '2026-08-15',
evidenceFileIds: [evidence.publicId],
resultRecordedByName: '记账登记员',
resultRecordedAt: '2026-08-15T08:10:00Z',
resultRecordRemark: '原记账结果',
resultVerifiedByName: '记账复核员',
resultVerifiedAt: '2026-08-15T08:15:00Z',
resultVerifyOpinion: '原结果复核通过',
}),
initialEvidence: [evidence],
reverseRequests,
reopenRequests,
});
await page.goto('/finance/accounting?tab=completed');
await page.getByRole('button', { name: '更多操作', exact: true }).click();
await page.getByText('冲销记账结果', { exact: true }).click();
const reverseDialog = page.locator('.t-dialog').filter({ hasText: '冲销记账结果' });
await reverseDialog.getByRole('button', { name: '确认冲销记账结果', exact: true }).click();
await expect(reverseDialog.getByText('处理意见或原因至少填写 2 个字符。', { exact: true })).toBeVisible();
await reverseDialog.locator('textarea').fill('外部账套凭证有误');
await reverseDialog.getByRole('button', { name: '确认冲销记账结果', exact: true }).click();
await expect.poll(() => reverseRequests.length).toBe(1);
expect(reverseRequests[0]).toEqual({ version: 5, reason: '外部账套凭证有误' });
await expect(reverseDialog).toBeHidden();
await page.getByRole('button', { name: '更多操作', exact: true }).click();
await page.getByText('重开记账结果', { exact: true }).click();
const reopenDialog = page.locator('.t-dialog').filter({ hasText: '重开记账结果' });
await reopenDialog.locator('textarea').fill('更正后重新登记');
await reopenDialog.getByRole('button', { name: '确认重开记账结果', exact: true }).click();
await expect.poll(() => reopenRequests.length).toBe(1);
expect(reopenRequests[0]).toEqual({ version: 6, reason: '更正后重新登记' });
await expect(reopenDialog).toBeHidden();
await page.getByText('VCH-202608-E2E001', { exact: true }).first().click();
await expect(page.getByRole('region', { name: '记账结果冲销与重开记录' })).toBeVisible();
await expect(page.getByText('TPLUS-E2E-REVERSED', { exact: true })).toHaveCount(2);
await expect(page.getByText('外部账套凭证有误', { exact: true })).toBeVisible();
await expect(page.getByText('更正后重新登记', { exact: true })).toBeVisible();
await expect(page.getByText('1 份证据', { exact: true })).toHaveCount(2);
await expect(page.getByRole('region', { name: '记账结果证据' })).toHaveCount(0);
});
+176
View File
@@ -0,0 +1,176 @@
import type { Page, Route } from '@playwright/test';
import { expect, test } from '@playwright/test';
const borrowId = '01M00000000000000000000401';
const fileId = '01M00000000000000000000402';
const projectId = '01M00000000000000000000403';
function envelope(data: unknown, totalElements?: number) {
return {
data,
...(typeof totalElements === 'number'
? { meta: { page: 1, size: 20, totalElements, totalPages: totalElements ? 1 : 0 } }
: {}),
requestId: '01M00000000000000000000499',
};
}
function session() {
return {
authenticated: true,
user: {
publicId: '01M00000000000000000000404',
username: 'archive-e2e',
displayName: '资料管理员',
departmentName: '档案部',
mustChangePassword: false,
},
roles: [{ code: 'ARCHIVE_MANAGER', name: '资料管理员', workbenchRoute: '/workbench/archive' }],
activeRole: 'ARCHIVE_MANAGER',
permissions: ['archive:file:view', 'archive:borrow:approve', 'archive:borrow:reject', 'archive:borrow:return'],
};
}
function borrow(overrides: Record<string, unknown> = {}) {
return {
publicId: borrowId,
businessNo: 'BOR-E2E-018',
filePublicId: fileId,
fileName: '项目合同原件.pdf',
projectPublicId: projectId,
projectName: '档案借阅验收项目',
purpose: '项目审计核对原件',
dueAt: '2099-12-31T12:00:00Z',
status: 'APPLIED',
applicantName: '财务申请员',
approvedByName: null,
approvedAt: null,
reviewOpinion: null,
returnCondition: null,
returnedByName: null,
returnedAt: null,
version: 0,
allowedActions: ['APPROVE', 'REJECT'],
...overrides,
};
}
interface FixtureOptions {
approveRequests: Array<Record<string, unknown>>;
returnRequests: Array<Record<string, unknown>>;
commandHeaders: Array<Record<string, string>>;
borrowQueries: URLSearchParams[];
}
async function installFixture(page: Page, options: FixtureOptions) {
const userSession = session();
let currentBorrow = borrow();
await page.route('**/api/v1/**', async (route: Route) => {
const request = route.request();
const url = new URL(request.url());
const pathname = url.pathname;
if (pathname === '/api/v1/auth/session') return route.fulfill({ json: envelope(userSession) });
if (pathname === '/api/v1/auth/profile') return route.fulfill({ json: envelope(userSession.user) });
if (pathname === '/api/v1/auth/roles') return route.fulfill({ json: envelope(userSession.roles) });
if (pathname === '/api/v1/auth/csrf') {
return route.fulfill({
headers: { 'set-cookie': 'XSRF-TOKEN=archive-e2e-csrf; Path=/' },
json: envelope({ token: 'archive-e2e-csrf' }),
});
}
if (request.method() === 'GET' && pathname === '/api/v1/archives/borrows') {
options.borrowQueries.push(new URLSearchParams(url.searchParams));
return route.fulfill({ json: envelope([currentBorrow], 1) });
}
if (request.method() === 'POST' && pathname === `/api/v1/archives/borrows/${borrowId}/approve`) {
options.approveRequests.push(request.postDataJSON() as Record<string, unknown>);
options.commandHeaders.push(request.headers());
currentBorrow = borrow({
status: 'BORROWED',
approvedByName: '资料管理员',
approvedAt: '2026-08-16T09:00:00Z',
reviewOpinion: '用途和期限符合要求',
version: 1,
allowedActions: ['RETURN'],
});
return route.fulfill({ json: envelope(currentBorrow) });
}
if (request.method() === 'POST' && pathname === `/api/v1/archives/borrows/${borrowId}/return`) {
const payload = request.postDataJSON() as Record<string, unknown>;
options.returnRequests.push(payload);
options.commandHeaders.push(request.headers());
currentBorrow = borrow({
status: 'RETURNED',
approvedByName: '资料管理员',
approvedAt: '2026-08-16T09:00:00Z',
reviewOpinion: '用途和期限符合要求',
returnCondition: payload.returnCondition,
returnedByName: '资料管理员',
returnedAt: '2026-08-16T10:00:00Z',
version: 2,
allowedActions: [],
});
return route.fulfill({ json: envelope(currentBorrow) });
}
if (request.method() === 'GET') return route.fulfill({ json: envelope([], 0) });
return route.fulfill({ status: 204 });
});
}
test('PAGE-18 approves and returns an archive borrow with versioned commands', async ({ page }) => {
const approveRequests: Array<Record<string, unknown>> = [];
const returnRequests: Array<Record<string, unknown>> = [];
const commandHeaders: Array<Record<string, string>> = [];
const borrowQueries: URLSearchParams[] = [];
await installFixture(page, { approveRequests, returnRequests, commandHeaders, borrowQueries });
await page.goto('/archives/files?resource=borrows');
await expect(page.getByRole('heading', { name: '文件检索与借阅', exact: true })).toBeVisible();
await expect(page.getByText('BOR-E2E-018', { exact: true }).first()).toBeVisible();
await expect.poll(() => borrowQueries.length).toBeGreaterThan(0);
expect(borrowQueries[0].get('page')).toBe('1');
expect(borrowQueries[0].get('size')).toBe('20');
const initialListRequests = borrowQueries.length;
await page.getByRole('button', { name: '批准', exact: true }).click();
const approveDialog = page.locator('.t-dialog').filter({ hasText: '批准借阅' });
await approveDialog.getByRole('button', { name: '确认', exact: true }).click();
await expect(approveDialog.getByText('请填写至少 2 个字符的处理意见', { exact: true })).toBeVisible();
expect(approveRequests).toHaveLength(0);
await approveDialog.locator('textarea').fill('用途和期限符合要求');
await approveDialog.getByRole('button', { name: '确认', exact: true }).click();
await expect.poll(() => approveRequests.length).toBe(1);
expect(approveRequests[0]).toEqual({
version: 0,
opinion: '用途和期限符合要求',
});
expect(commandHeaders[0]['x-xsrf-token']).toBe('archive-e2e-csrf');
expect(commandHeaders[0]['idempotency-key']).toBeTruthy();
await expect(approveDialog).toBeHidden();
await expect.poll(() => borrowQueries.length).toBeGreaterThan(initialListRequests);
await expect(page.getByText('借出中', { exact: true })).toBeVisible();
const afterApprovalListRequests = borrowQueries.length;
await page.getByRole('button', { name: '登记归还', exact: true }).click();
const returnDialog = page.locator('.t-dialog').filter({ hasText: '登记归还' });
await returnDialog.getByRole('button', { name: '确认', exact: true }).click();
await expect(returnDialog.getByText('请填写至少 2 个字符的处理意见', { exact: true })).toBeVisible();
expect(returnRequests).toHaveLength(0);
await returnDialog.locator('textarea').fill('纸质原件完好,页数一致');
await returnDialog.getByRole('button', { name: '确认', exact: true }).click();
await expect.poll(() => returnRequests.length).toBe(1);
expect(returnRequests[0]).toEqual({
version: 1,
returnCondition: '纸质原件完好,页数一致',
});
expect(commandHeaders[1]['x-xsrf-token']).toBe('archive-e2e-csrf');
expect(commandHeaders[1]['idempotency-key']).toBeTruthy();
await expect(returnDialog).toBeHidden();
await expect.poll(() => borrowQueries.length).toBeGreaterThan(afterApprovalListRequests);
await expect(page.getByText('已归还', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '登记归还', exact: true })).toHaveCount(0);
});
+269
View File
@@ -0,0 +1,269 @@
import { Buffer } from 'node:buffer';
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import type { Page, Route } from '@playwright/test';
import { expect, test } from '@playwright/test';
const auditId = '01N00000000000000000000001';
const actorId = '01N00000000000000000000002';
const companyId = '01N00000000000000000000003';
const projectId = '01N00000000000000000000004';
const exportContent =
'\uFEFFeventSequence,occurredAt,username,activeRole,actionCode,objectType,objectPublicId,resultCode,requestId\n' +
'"101","2026-08-15T08:30:00Z","审计管理员","SYSTEM_ADMIN","AUDIT_LOG_EXPORT","AUDIT_LOG","","SUCCESS","01N00000000000000000000005"\n';
const exportSha256 = createHash('sha256').update(exportContent, 'utf8').digest('hex');
interface AuditFixtureOptions {
permissions?: string[];
listQueries?: URL[];
exportRequests?: Array<Record<string, unknown>>;
failListAt?: number;
}
function auditLog() {
return {
publicId: auditId,
eventSequence: 101,
requestId: '01N00000000000000000000005',
userPublicId: actorId,
username: '审计管理员',
activeRole: 'SYSTEM_ADMIN',
companyPublicId: companyId,
projectPublicId: projectId,
actionCode: 'AUDIT_LOG_EXPORT',
objectType: 'AUDIT_LOG',
objectPublicId: null,
resultCode: 'SUCCESS',
reason: '导出当前查询结果',
beforeJson: JSON.stringify({ phone: '138****0000', visible: 'before' }),
afterJson: JSON.stringify({ accountNo: '****2020', visible: 'after' }),
occurredAt: '2026-08-15T08:30:00Z',
allowedActions: ['VIEW'],
};
}
function session(permissions: string[]) {
return {
authenticated: true,
user: {
publicId: actorId,
username: 'audit-e2e',
displayName: '审计管理员',
departmentName: '系统管理部',
mustChangePassword: false,
},
roles: [{ code: 'SYSTEM_ADMIN', name: '系统管理员', workbenchRoute: '/admin/users' }],
activeRole: 'SYSTEM_ADMIN',
permissions,
};
}
async function installAuditFixture(page: Page, options: AuditFixtureOptions = {}) {
const userSession = session(options.permissions || ['audit:log:view', 'audit:log:export']);
let listRequestCount = 0;
await page.route('**/api/v1/**', async (route: Route) => {
const request = route.request();
const url = new URL(request.url());
const { pathname } = url;
if (pathname === '/api/v1/auth/session') {
await route.fulfill({ json: { data: userSession, requestId: '01N00000000000000000000006' } });
return;
}
if (pathname === '/api/v1/auth/profile') {
await route.fulfill({ json: { data: userSession.user, requestId: '01N00000000000000000000007' } });
return;
}
if (pathname === '/api/v1/auth/roles') {
await route.fulfill({ json: { data: userSession.roles, requestId: '01N00000000000000000000008' } });
return;
}
if (pathname === '/api/v1/auth/csrf') {
await route.fulfill({
headers: { 'set-cookie': 'XSRF-TOKEN=audit-e2e-csrf; Path=/' },
json: { data: { token: 'audit-e2e-csrf' }, requestId: '01N00000000000000000000009' },
});
return;
}
if (request.method() === 'GET' && pathname === '/api/v1/audit/logs') {
listRequestCount += 1;
options.listQueries?.push(url);
if (options.failListAt === listRequestCount) {
await route.fulfill({
status: 503,
contentType: 'application/problem+json',
json: {
title: '服务暂时不可用',
status: 503,
code: 'SERVICE_UNAVAILABLE',
detail: '请稍后重试',
requestId: '01N0000000000000000000000F',
},
});
return;
}
const pageNumber = Number(url.searchParams.get('page') || 1);
const pageSize = Number(url.searchParams.get('size') || 20);
await route.fulfill({
json: {
data: [auditLog()],
meta: { page: pageNumber, size: pageSize, totalElements: 1, totalPages: 1 },
requestId: '01N0000000000000000000000A',
},
});
return;
}
if (request.method() === 'GET' && pathname === `/api/v1/audit/logs/${auditId}`) {
await route.fulfill({ json: { data: auditLog(), requestId: '01N0000000000000000000000B' } });
return;
}
if (request.method() === 'POST' && pathname === '/api/v1/audit/exports') {
options.exportRequests?.push(request.postDataJSON() as Record<string, unknown>);
await route.fulfill({
json: {
data: {
exportId: '01N0000000000000000000000C',
rowCount: 1,
sha256: exportSha256,
fileName: 'audit-logs.csv',
content: exportContent,
},
requestId: '01N0000000000000000000000D',
},
});
return;
}
if (request.method() === 'GET') {
await route.fulfill({
json: {
data: [],
meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 },
requestId: '01N0000000000000000000000E',
},
});
return;
}
await route.fulfill({ status: 204 });
});
}
test('PAGE-20 keeps the published filters in the URL and uses Beijing date boundaries', async ({ page }) => {
const listQueries: URL[] = [];
await installAuditFixture(page, { listQueries });
await page.goto(
`/governance/audit?keyword=${encodeURIComponent('导出')}&actorId=${actorId}&identityCode=SYSTEM_ADMIN` +
'&result=SUCCESS&occurredFrom=2026-08-15&occurredTo=2026-08-15&sort=eventSequence%2Casc&page=2&size=50',
);
await expect.poll(() => listQueries.length).toBeGreaterThan(0);
const initial = listQueries.at(-1)?.searchParams;
expect(initial?.get('keyword')).toBe('导出');
expect(initial?.get('actorId')).toBe(actorId);
expect(initial?.get('identityCode')).toBe('SYSTEM_ADMIN');
expect(initial?.get('result')).toBe('SUCCESS');
expect(initial?.get('occurredFrom')).toBe('2026-08-14T16:00:00.000Z');
expect(initial?.get('occurredTo')).toBe('2026-08-15T16:00:00.000Z');
expect(initial?.get('sort')).toBe('eventSequence,asc');
expect(initial?.get('page')).toBe('2');
expect(initial?.get('size')).toBe('50');
expect(initial?.has('_t')).toBe(false);
await expect
.poll(() => {
const url = new URL(page.url());
return {
occurredFrom: url.searchParams.get('occurredFrom'),
occurredTo: url.searchParams.get('occurredTo'),
sort: url.searchParams.get('sort'),
page: url.searchParams.get('page'),
size: url.searchParams.get('size'),
};
})
.toEqual({
occurredFrom: '2026-08-15',
occurredTo: '2026-08-15',
sort: 'eventSequence,asc',
page: '2',
size: '50',
});
await page.getByRole('button', { name: '重置', exact: true }).click();
await expect.poll(() => listQueries.length).toBeGreaterThan(1);
const reset = listQueries.at(-1)?.searchParams;
expect(reset?.has('keyword')).toBe(false);
expect(reset?.has('actorId')).toBe(false);
expect(reset?.has('identityCode')).toBe(false);
expect(reset?.has('result')).toBe(false);
expect(reset?.has('occurredFrom')).toBe(false);
expect(reset?.has('occurredTo')).toBe(false);
expect(reset?.get('sort')).toBe('occurredAt,desc');
expect(reset?.get('page')).toBe('1');
expect(reset?.get('size')).toBe('50');
});
test('PAGE-20 shows scoped masked snapshots and downloads exactly the verified bytes', async ({ page }) => {
const listQueries: URL[] = [];
const exportRequests: Array<Record<string, unknown>> = [];
await installAuditFixture(page, { listQueries, exportRequests });
await page.goto(`/governance/audit?keyword=${encodeURIComponent('导出')}&actorId=${actorId}`);
await expect(page.getByRole('heading', { name: '审计日志', exact: true })).toBeVisible();
await expect(page.getByText(`公司 ${companyId} / 项目 ${projectId}`, { exact: true })).toBeVisible();
await page.getByRole('button', { name: '查看快照', exact: true }).click();
const drawer = page.locator('.t-drawer').filter({ hasText: '审计记录详情' });
await expect(drawer.getByText('101', { exact: true })).toBeVisible();
await expect(drawer.getByText('已脱敏', { exact: true })).toHaveCount(2);
await expect(drawer.getByText('138****0000', { exact: false })).toBeVisible();
await expect(drawer.getByText('****2020', { exact: false })).toBeVisible();
await expect(drawer.getByText('13800000000', { exact: false })).toHaveCount(0);
await expect(drawer.getByText(`公司 ${companyId} / 项目 ${projectId}`, { exact: true })).toBeVisible();
await drawer.locator('.t-drawer__mask').click({ position: { x: 8, y: 8 } });
await expect(drawer).toBeHidden();
const listCountBeforeExport = listQueries.length;
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: '导出当前结果', exact: true }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('audit-logs.csv');
await expect.poll(() => exportRequests.length).toBe(1);
expect(exportRequests[0]).toEqual({
keyword: '导出',
actorId,
sort: 'occurredAt,desc',
});
expect(exportRequests[0]).not.toHaveProperty('page');
expect(exportRequests[0]).not.toHaveProperty('size');
const downloadPath = await download.path();
expect(downloadPath).not.toBeNull();
const bytes = await readFile(downloadPath as string);
expect(bytes.equals(Buffer.from(exportContent, 'utf8'))).toBe(true);
expect(createHash('sha256').update(bytes).digest('hex')).toBe(exportSha256);
await expect.poll(() => listQueries.length).toBeGreaterThan(listCountBeforeExport);
});
test('PAGE-20 keeps export available for SYSTEM_ADMIN when the session permission list is stale', async ({ page }) => {
await installAuditFixture(page, { permissions: ['audit:log:view'] });
await page.goto('/governance/audit');
await expect(page.getByRole('heading', { name: '审计日志', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '导出当前结果', exact: true })).toBeVisible();
});
test('PAGE-20 keeps submitted filters in the URL when the list request fails', async ({ page }) => {
const listQueries: URL[] = [];
await installAuditFixture(page, { listQueries, failListAt: 2 });
await page.goto('/governance/audit');
await expect.poll(() => listQueries.length).toBe(1);
await page.getByPlaceholder('用户、动作、对象或请求编号').fill('失败后保留');
await page.getByRole('button', { name: '查询', exact: true }).click();
await expect.poll(() => listQueries.length).toBe(2);
await expect.poll(() => new URL(page.url()).searchParams.get('keyword')).toBe('失败后保留');
await expect(page.locator('.t-alert')).toContainText('请稍后重试');
});
+272
View File
@@ -0,0 +1,272 @@
import type { Page, Route } from '@playwright/test';
import { expect, test } from '@playwright/test';
const contractId = '01M00000000000000000000301';
const payableId = '01M00000000000000000000302';
const projectId = '01M00000000000000000000303';
const sourceDocumentId = '01M00000000000000000000304';
const costCategoryId = '01M00000000000000000000305';
const companyId = '01M00000000000000000000306';
function envelope(data: unknown, totalElements?: number) {
return {
data,
...(typeof totalElements === 'number'
? { meta: { page: 1, size: 20, totalElements, totalPages: totalElements ? 1 : 0 } }
: {}),
requestId: '01M00000000000000000000399',
};
}
function session() {
return {
authenticated: true,
user: {
publicId: '01M00000000000000000000307',
username: 'payable-e2e',
displayName: '应付登记员',
departmentName: '财务部',
mustChangePassword: false,
},
roles: [{ code: 'FINANCE_MANAGER', name: '财务管理人员', workbenchRoute: '/workbench/finance' }],
activeRole: 'FINANCE_MANAGER',
permissions: ['contractcost:ledger:view', 'contractcost:payable:create', 'contractcost:payable:confirm'],
};
}
function contract(overrides: Record<string, unknown> = {}) {
return {
publicId: contractId,
businessNo: 'HT-E2E-013',
name: '合同成本验收合同',
company: { publicId: companyId, code: 'GS-E2E', name: '凯迪测试公司' },
project: { publicId: projectId, code: 'XM-E2E', name: '合同成本验收项目' },
counterparty: { publicId: '01M00000000000000000000308', code: 'GYS-E2E', name: '测试供应商' },
originalAmount: '1000.00',
approvedChangeAmount: '0.00',
settlementAmount: '0.00',
invoicedAmount: '0.00',
pendingPayableAmount: '0.00',
payableAmount: '0.00',
paidAmount: '0.00',
availableAmount: '1000.00',
currency: 'CNY',
status: 'ACTIVE',
version: 7,
updatedAt: '2026-08-16T08:00:00Z',
allowedActions: ['CREATE_PAYABLE'],
...overrides,
};
}
function payable(status: 'PENDING' | 'CONFIRMED' = 'PENDING') {
return {
publicId: payableId,
businessNo: 'PAY-E2E-013',
businessDate: '2026-08-16',
costCategory: { publicId: costCategoryId, code: 'CC-E2E', name: '测试成本类别' },
amount: '128.50',
invoicedAmount: '0.00',
paidAmount: '0.00',
unpaidAmount: '128.50',
status,
version: status === 'PENDING' ? 0 : 1,
updatedAt: '2026-08-16T08:05:00Z',
allowedActions: status === 'PENDING' ? ['CONFIRM'] : [],
};
}
function summary(currentContract: ReturnType<typeof contract>) {
return {
contractCount: 1,
originalAmount: currentContract.originalAmount,
approvedChangeAmount: currentContract.approvedChangeAmount,
settlementAmount: currentContract.settlementAmount,
invoicedAmount: currentContract.invoicedAmount,
pendingPayableAmount: currentContract.pendingPayableAmount,
payableAmount: currentContract.payableAmount,
paidAmount: currentContract.paidAmount,
availableAmount: currentContract.availableAmount,
};
}
interface FixtureOptions {
createRequests: Array<Record<string, unknown>>;
confirmRequests: Array<Record<string, unknown>>;
commandHeaders: Array<Record<string, string>>;
sourceQueries: URLSearchParams[];
}
async function installFixture(page: Page, options: FixtureOptions) {
const userSession = session();
let currentContract = contract();
let currentPayables: Array<ReturnType<typeof payable>> = [];
const detail = () => ({
contract: currentContract,
changes: [],
settlements: [],
payables: currentPayables,
invoices: [],
payments: [],
});
await page.route('**/api/v1/**', async (route: Route) => {
const request = route.request();
const url = new URL(request.url());
const pathname = url.pathname;
if (pathname === '/api/v1/auth/session') return route.fulfill({ json: envelope(userSession) });
if (pathname === '/api/v1/auth/profile') return route.fulfill({ json: envelope(userSession.user) });
if (pathname === '/api/v1/auth/roles') return route.fulfill({ json: envelope(userSession.roles) });
if (pathname === '/api/v1/auth/csrf') {
return route.fulfill({
headers: { 'set-cookie': 'XSRF-TOKEN=payable-e2e-csrf; Path=/' },
json: envelope({ token: 'payable-e2e-csrf' }),
});
}
if (request.method() === 'GET' && pathname === '/api/v1/contract-costs/references') {
return route.fulfill({
json: envelope({
companies: [currentContract.company],
projects: [currentContract.project],
counterparties: [currentContract.counterparty],
costCategories: [{ publicId: costCategoryId, code: 'CC-E2E', name: '测试成本类别' }],
}),
});
}
if (request.method() === 'GET' && pathname === '/api/v1/contract-costs/contracts') {
return route.fulfill({
json: {
...envelope({ items: [currentContract], summary: summary(currentContract) }, 1),
},
});
}
if (request.method() === 'GET' && pathname === `/api/v1/contract-costs/contracts/${contractId}`) {
return route.fulfill({ json: envelope(detail()) });
}
if (request.method() === 'GET' && pathname === '/api/v1/source/forms') {
options.sourceQueries.push(new URLSearchParams(url.searchParams));
return route.fulfill({
json: envelope(
[
{
publicId: sourceDocumentId,
businessNo: 'OA04-E2E-013',
sourceNo: 'OA-04-SRC-013',
formType: 'OA-04',
formName: '应付资金确认单',
templateVersion: 1,
sourceSystem: 'OA',
sourceVersion: '1',
company: currentContract.company,
project: currentContract.project,
applicantName: '应付申请员',
businessDate: '2026-08-16',
status: 'APPROVED',
version: 1,
createdByName: '应付申请员',
createdAt: '2026-08-16T07:00:00Z',
updatedAt: '2026-08-16T07:30:00Z',
allowedActions: [],
},
],
1,
),
});
}
if (request.method() === 'POST' && pathname === `/api/v1/contract-costs/contracts/${contractId}/payables`) {
options.createRequests.push(request.postDataJSON() as Record<string, unknown>);
options.commandHeaders.push(request.headers());
currentContract = contract({
pendingPayableAmount: '128.50',
availableAmount: '871.50',
version: 8,
updatedAt: '2026-08-16T08:05:00Z',
});
currentPayables = [payable()];
return route.fulfill({ json: envelope(detail()) });
}
if (request.method() === 'POST' && pathname === `/api/v1/contract-costs/payables/${payableId}/confirm`) {
options.confirmRequests.push(request.postDataJSON() as Record<string, unknown>);
options.commandHeaders.push(request.headers());
currentContract = contract({
payableAmount: '128.50',
availableAmount: '871.50',
version: 9,
updatedAt: '2026-08-16T08:10:00Z',
});
currentPayables = [payable('CONFIRMED')];
return route.fulfill({ json: envelope(detail()) });
}
if (request.method() === 'GET') return route.fulfill({ json: envelope([], 0) });
return route.fulfill({ status: 204 });
});
}
test('PAGE-13 registers an approved OA-04 payable and confirms it with separate versions', async ({ page }) => {
const createRequests: Array<Record<string, unknown>> = [];
const confirmRequests: Array<Record<string, unknown>> = [];
const commandHeaders: Array<Record<string, string>> = [];
const sourceQueries: URLSearchParams[] = [];
await installFixture(page, { createRequests, confirmRequests, commandHeaders, sourceQueries });
await page.goto('/finance/contracts-costs');
await expect(page.getByRole('heading', { name: '合同与成本台账', exact: true })).toBeVisible();
await expect(page.getByText('HT-E2E-013', { exact: true }).first()).toBeVisible();
await page.getByRole('button', { name: '查看', exact: true }).click();
const drawer = page.locator('.t-drawer').filter({ hasText: 'HT-E2E-013' });
await drawer.getByRole('button', { name: '登记应付', exact: true }).click();
const dialog = page.locator('.t-dialog').filter({ hasText: '登记应付' });
const businessDate = await page.evaluate(() => {
const now = new Date();
return new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 10);
});
await expect(dialog.locator('.t-date-picker input')).toHaveValue(businessDate);
await dialog.getByPlaceholder('请输入应付业务编号', { exact: true }).fill('PAY-E2E-013');
await dialog.getByPlaceholder('请选择成本类别', { exact: true }).click();
await page.getByText('CC-E2E 测试成本类别', { exact: true }).click();
await dialog.getByPlaceholder('请输入金额', { exact: true }).fill('128.50');
await dialog.getByRole('button', { name: '提交', exact: true }).click();
await expect(dialog.getByText('请选择已批准的 OA-04 来源审批单', { exact: true })).toBeVisible();
expect(createRequests).toHaveLength(0);
await dialog.getByPlaceholder('请选择已批准的应付资金确认单', { exact: true }).click();
await page.getByText('OA04-E2E-013 · OA-04-SRC-013 · 应付资金确认单', { exact: true }).click();
await dialog.getByRole('button', { name: '提交', exact: true }).click();
await expect.poll(() => createRequests.length).toBe(1);
expect(createRequests[0]).toEqual({
businessNo: 'PAY-E2E-013',
costCategoryId,
sourceDocumentId,
businessDate,
amount: '128.50',
contractVersion: 7,
});
expect(commandHeaders[0]['x-xsrf-token']).toBe('payable-e2e-csrf');
expect(commandHeaders[0]['idempotency-key']).toBeTruthy();
await expect(dialog).toBeHidden();
await expect(drawer.getByText('PAY-E2E-013', { exact: true })).toBeVisible();
await expect(drawer.getByText('待处理', { exact: true })).toBeVisible();
expect(sourceQueries).toHaveLength(1);
expect(sourceQueries[0].get('formType')).toBe('OA-04');
expect(sourceQueries[0].get('projectId')).toBe(projectId);
expect(sourceQueries[0].get('status')).toBe('APPROVED');
expect(sourceQueries[0].get('page')).toBe('1');
expect(sourceQueries[0].get('size')).toBe('100');
await drawer.getByRole('button', { name: '确认', exact: true }).click();
const popconfirm = page.locator('.t-popconfirm').filter({ hasText: '确认这笔应付已完成财务核对?' });
await popconfirm.locator('.t-popconfirm__confirm').click();
await expect.poll(() => confirmRequests.length).toBe(1);
expect(confirmRequests[0]).toEqual({ version: 0 });
expect(commandHeaders[1]['x-xsrf-token']).toBe('payable-e2e-csrf');
expect(commandHeaders[1]['idempotency-key']).toBeTruthy();
await expect(drawer.getByText('已确认', { exact: true })).toBeVisible();
await expect(drawer.getByRole('button', { name: '确认', exact: true })).toHaveCount(0);
await expect(page.getByText('应付已确认', { exact: true })).toBeVisible();
});
+220
View File
@@ -0,0 +1,220 @@
import type { Page, Route } from '@playwright/test';
import { expect, test } from '@playwright/test';
const templateId = '01M00000000000000000000210';
const readonlyTemplateId = '01M00000000000000000000211';
const documentId = '01M00000000000000000000212';
function template(overrides: Record<string, unknown> = {}) {
return {
publicId: templateId,
formType: 'OA-01',
templateVersion: 1,
name: '项目立项申请',
groupCode: 'PROJECT_PREPARATION',
groupName: '项目准备',
purpose: '形成项目基础资料',
initiatorRoles: ['PROJECT_MANAGER'],
status: 'ACTIVE',
publishedAt: '2026-08-15T08:00:00Z',
myDraftCount: 1,
myPendingCount: 0,
canCreate: true,
fields: [],
...overrides,
};
}
function sourceForm() {
return {
publicId: documentId,
businessNo: 'OA01-E2E-001',
formType: 'OA-01',
formName: '项目立项申请',
templateVersion: 1,
sourceSystem: 'MANUAL',
sourceNo: 'SRC-OA01-E2E-001',
sourceVersion: '1',
company: { publicId: '01M00000000000000000000213', name: '凯迪测试公司' },
project: { publicId: '01M00000000000000000000214', name: '表单中心验收项目' },
applicantName: '项目经办人',
businessDate: '2026-08-15',
status: 'DRAFT',
currentNodeCode: null,
currentNodeName: null,
version: 0,
createdByName: '项目经办人',
createdAt: '2026-08-15T08:00:00Z',
updatedAt: '2026-08-15T08:00:00Z',
allowedActions: ['EDIT', 'VALIDATE', 'SUBMIT', 'VOID'],
};
}
function session() {
return {
authenticated: true,
user: {
publicId: '01M00000000000000000000215',
username: 'forms-e2e',
displayName: '表单验收用户',
departmentName: '项目部',
mustChangePassword: false,
},
roles: [{ code: 'PROJECT_MANAGER', name: '项目管理人员', workbenchRoute: '/workbench/project' }],
activeRole: 'PROJECT_MANAGER',
permissions: ['source:template:view', 'source:form:view', 'source:form:create', 'source:form:validate'],
};
}
async function installFormCenterFixture(
page: Page,
validateRequests: Array<Record<string, unknown>>,
formQueries: URLSearchParams[] = [],
) {
const userSession = session();
await page.route('**/api/v1/**', async (route: Route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (pathname === '/api/v1/auth/session') {
await route.fulfill({ json: { data: userSession, requestId: '01M00000000000000000000216' } });
return;
}
if (pathname === '/api/v1/auth/profile') {
await route.fulfill({ json: { data: userSession.user, requestId: '01M00000000000000000000217' } });
return;
}
if (pathname === '/api/v1/auth/roles') {
await route.fulfill({ json: { data: userSession.roles, requestId: '01M00000000000000000000218' } });
return;
}
if (pathname === '/api/v1/auth/csrf') {
await route.fulfill({
headers: { 'set-cookie': 'XSRF-TOKEN=forms-e2e-csrf; Path=/' },
json: { data: { token: 'forms-e2e-csrf' }, requestId: '01M00000000000000000000219' },
});
return;
}
if (request.method() === 'GET' && pathname === '/api/v1/source/templates') {
await route.fulfill({
json: {
data: [
template(),
template({
publicId: readonlyTemplateId,
formType: 'OA-02',
name: '供应商准入申请',
purpose: '形成供应商准入资料',
initiatorRoles: ['FINANCE_MANAGER'],
myDraftCount: 0,
canCreate: false,
}),
template({
publicId: '01M00000000000000000000224',
formType: 'OA-05',
templateVersion: 2,
name: '收款确认申请',
groupCode: 'INCOME_EXPENSE',
groupName: '收支执行',
}),
],
requestId: '01M00000000000000000000220',
},
});
return;
}
if (request.method() === 'GET' && pathname === '/api/v1/source/forms') {
formQueries.push(new URL(request.url()).searchParams);
await route.fulfill({
json: {
data: [sourceForm()],
meta: { page: 1, size: 20, totalElements: 1, totalPages: 1 },
requestId: '01M00000000000000000000221',
},
});
return;
}
if (request.method() === 'POST' && pathname === `/api/v1/source/forms/${documentId}/validate`) {
validateRequests.push((request.postDataJSON() || {}) as Record<string, unknown>);
await route.fulfill({
json: {
data: {
documentId,
version: 0,
valid: true,
canSubmit: true,
errors: [],
warnings: [],
validatedAt: '2026-08-15T08:01:00Z',
},
requestId: '01M00000000000000000000222',
},
});
return;
}
if (request.method() === 'GET') {
await route.fulfill({
json: {
data: [],
meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 },
requestId: '01M00000000000000000000223',
},
});
return;
}
await route.fulfill({ status: 204 });
});
}
test('PAGE-09 shows initiator roles, respects create rights and validates a draft', async ({ page }) => {
const validateRequests: Array<Record<string, unknown>> = [];
await installFormCenterFixture(page, validateRequests);
await page.goto('/forms');
await expect(page.getByRole('heading', { name: '表单中心', exact: true })).toBeVisible();
const editableCard = page.locator('.t-card').filter({ hasText: 'OA-01' });
await expect(editableCard.getByText('项目管理人员', { exact: true })).toBeVisible();
await expect(editableCard.getByRole('button', { name: '在线填写', exact: true })).toBeVisible();
await expect(editableCard.getByRole('button', { name: '导入', exact: true })).toBeVisible();
const readonlyCard = page.locator('.t-card').filter({ hasText: 'OA-02' });
await expect(readonlyCard.getByText('财务管理人员', { exact: true })).toBeVisible();
await expect(readonlyCard.getByRole('button', { name: '在线填写', exact: true })).toHaveCount(0);
await expect(readonlyCard.getByRole('button', { name: '导入', exact: true })).toHaveCount(0);
await page.getByText('校验', { exact: true }).click();
await expect.poll(() => validateRequests.length).toBe(1);
expect(validateRequests[0]).toEqual({});
await expect(page.getByText('表单校验通过', { exact: true })).toBeVisible();
await editableCard.getByRole('button', { name: '在线填写', exact: true }).click();
await expect(page).toHaveURL(/\/forms\/OA-01\?mode=online/);
});
test('PAGE-09 sends group, version, creation date and sort filters and keeps them in the URL', async ({ page }) => {
const formQueries: URLSearchParams[] = [];
await installFormCenterFixture(page, [], formQueries);
await page.goto(
'/forms?groupCode=INCOME_EXPENSE&formType=OA-05&templateVersion=2' +
'&createdDateFrom=2026-08-01&createdDateTo=2026-08-15&sort=createdAt%2Casc',
);
await expect(page.getByRole('heading', { name: '表单中心', exact: true })).toBeVisible();
await expect.poll(() => formQueries.length).toBeGreaterThan(0);
const initial = formQueries.at(-1)!;
expect(initial.get('groupCode')).toBe('INCOME_EXPENSE');
expect(initial.get('formType')).toBe('OA-05');
expect(initial.get('templateVersion')).toBe('2');
expect(initial.get('createdDateFrom')).toBe('2026-08-01');
expect(initial.get('createdDateTo')).toBe('2026-08-15');
expect(initial.get('sort')).toBe('createdAt,asc');
await expect(page).toHaveURL(/groupCode=INCOME_EXPENSE/);
await expect(page).toHaveURL(/templateVersion=2/);
const requestCount = formQueries.length;
await page.getByText('项目准备', { exact: true }).click();
await expect.poll(() => formQueries.length).toBeGreaterThan(requestCount);
expect(formQueries.at(-1)!.get('groupCode')).toBe('PROJECT_PREPARATION');
await expect(page).toHaveURL(/groupCode=PROJECT_PREPARATION/);
});
+423
View File
@@ -0,0 +1,423 @@
import { Buffer } from 'node:buffer';
import type { Page, Route } from '@playwright/test';
import { expect, test } from '@playwright/test';
const documentId = '01M00000000000000000000230';
const companyId = '01M00000000000000000000231';
const projectId = '01M00000000000000000000232';
const nestedFileId = '01M00000000000000000000247';
function fields() {
return [
{
code: 'projectName',
label: '项目名称',
sectionCode: 'BASE',
sectionName: '项目基础信息',
dataType: 'TEXT',
required: true,
repeatable: false,
maxLength: 200,
options: [],
sensitivity: 'NORMAL',
sortOrder: 10,
},
{
code: 'budgetDetails',
label: '预算明细',
sectionCode: 'BUDGET',
sectionName: '预算信息',
dataType: 'TABLE',
required: true,
repeatable: true,
options: [
{ code: 'costType', label: '费用类型' },
{ code: 'amount', label: '金额', dataType: 'DECIMAL' },
],
sensitivity: 'INTERNAL',
sortOrder: 20,
},
{
code: 'supportingFiles',
label: '立项附件',
sectionCode: 'ATTACHMENT',
sectionName: '附件信息',
dataType: 'FILE',
required: true,
repeatable: true,
options: [],
sensitivity: 'INTERNAL',
sortOrder: 30,
},
{
code: 'attachmentMatrix',
label: '条件附件矩阵',
sectionCode: 'ATTACHMENT',
sectionName: '附件信息',
dataType: 'TABLE',
required: true,
repeatable: true,
options: [
{ code: 'attachmentType', label: '附件类型', dataType: 'TEXT', required: true, maxLength: 100 },
{ code: 'required', label: '是否必需', dataType: 'BOOLEAN', required: true },
{ code: 'fileId', label: '文件', dataType: 'FILE', required: false },
{ code: 'notApplicableReason', label: '不涉及原因', dataType: 'LONG_TEXT', required: false, maxLength: 500 },
],
sensitivity: 'INTERNAL',
sortOrder: 40,
},
];
}
function summary(overrides: Record<string, unknown> = {}) {
return {
publicId: documentId,
businessNo: 'OA01-E2E-PREVIEW',
formType: 'OA-01',
formName: '项目立项申请',
templateVersion: 1,
sourceSystem: 'MANUAL',
sourceNo: 'SRC-OA01-PREVIEW',
sourceVersion: '1',
company: { publicId: companyId, name: '凯迪测试公司' },
project: { publicId: projectId, name: '预览验收项目' },
applicantName: '项目经办人',
businessDate: '2026-08-15',
status: 'DRAFT',
currentNodeCode: null,
currentNodeName: null,
version: 1,
createdByName: '项目经办人',
createdAt: '2026-08-15T08:00:00Z',
updatedAt: '2026-08-15T08:00:00Z',
allowedActions: ['EDIT', 'VALIDATE', 'SUBMIT', 'VOID'],
...overrides,
};
}
function validation(valid: boolean) {
return {
documentId,
version: 1,
valid,
canSubmit: valid,
errors: valid
? []
: [{ level: 'ERROR', sectionCode: 'BASE', fieldCode: 'projectName', message: '项目名称为必填项' }],
warnings: [],
validatedAt: '2026-08-15T08:01:00Z',
};
}
function detail(valid: boolean, submitted = false, valuesOverride: Record<string, unknown> = {}) {
return {
summary: summary(
submitted
? {
status: 'PROJECT_REVIEW',
currentNodeCode: 'PROJECT_REVIEW',
currentNodeName: '项目复核',
version: 2,
allowedActions: ['WITHDRAW', 'VOID'],
}
: {},
),
documentVersion: 2,
snapshotSha256: 'b'.repeat(64),
values: {
projectName: '预览验收项目',
budgetDetails: [{ costType: '材料', amount: 1000 }],
supportingFiles: ['01M00000000000000000000233'],
attachmentMatrix: [{ attachmentType: '发票及验收资料', required: false, fileId: '' }],
...valuesOverride,
},
fields: fields(),
approvals: [],
validation: validation(valid),
};
}
function template() {
return {
publicId: '01M00000000000000000000234',
formType: 'OA-01',
templateVersion: 1,
name: '项目立项申请',
groupCode: 'PROJECT_PREPARATION',
groupName: '项目准备',
purpose: '形成项目基础资料',
initiatorRoles: ['PROJECT_MANAGER'],
status: 'ACTIVE',
publishedAt: '2026-08-15T08:00:00Z',
myDraftCount: 1,
myPendingCount: 0,
canCreate: true,
fields: fields(),
};
}
function session() {
return {
authenticated: true,
user: {
publicId: '01M00000000000000000000235',
username: 'editor-e2e',
displayName: '项目经办人',
departmentName: '项目部',
mustChangePassword: false,
},
roles: [{ code: 'PROJECT_MANAGER', name: '项目管理人员', workbenchRoute: '/workbench/project' }],
activeRole: 'PROJECT_MANAGER',
permissions: ['source:template:view', 'source:form:view', 'source:form:edit', 'source:form:submit'],
};
}
async function installEditorFixture(
page: Page,
validateRequests: Array<Record<string, unknown>>,
submitRequests: Array<Record<string, unknown>>,
updateRequests: Array<Record<string, unknown>> = [],
uploadRequests: string[] = [],
uploadGate?: Promise<void>,
) {
const userSession = session();
let isValid = false;
let isSubmitted = false;
let currentValues: Record<string, unknown> = detail(false).values;
await page.route('**/api/v1/**', async (route: Route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (pathname === '/api/v1/auth/session') {
await route.fulfill({ json: { data: userSession, requestId: '01M00000000000000000000236' } });
return;
}
if (pathname === '/api/v1/auth/profile') {
await route.fulfill({ json: { data: userSession.user, requestId: '01M00000000000000000000237' } });
return;
}
if (pathname === '/api/v1/auth/roles') {
await route.fulfill({ json: { data: userSession.roles, requestId: '01M00000000000000000000238' } });
return;
}
if (pathname === '/api/v1/auth/csrf') {
await route.fulfill({
headers: { 'set-cookie': 'XSRF-TOKEN=editor-e2e-csrf; Path=/' },
json: { data: { token: 'editor-e2e-csrf' }, requestId: '01M00000000000000000000239' },
});
return;
}
if (request.method() === 'GET' && pathname === '/api/v1/source/templates') {
await route.fulfill({ json: { data: [template()], requestId: '01M00000000000000000000240' } });
return;
}
if (request.method() === 'GET' && pathname === '/api/v1/source/templates/OA-01/versions/1') {
await route.fulfill({ json: { data: template(), requestId: '01M00000000000000000000241' } });
return;
}
if (request.method() === 'GET' && pathname === '/api/v1/source/references') {
await route.fulfill({
json: {
data: {
companies: [{ publicId: companyId, name: '凯迪测试公司' }],
projects: [{ publicId: projectId, name: '预览验收项目' }],
suppliers: [],
customers: [],
contracts: [],
costCategories: [],
},
requestId: '01M00000000000000000000242',
},
});
return;
}
if (request.method() === 'GET' && pathname === `/api/v1/source/forms/${documentId}`) {
await route.fulfill({
json: { data: detail(isValid, isSubmitted, currentValues), requestId: '01M00000000000000000000243' },
});
return;
}
if (request.method() === 'PATCH' && pathname === `/api/v1/source/forms/${documentId}`) {
const payload = request.postDataJSON() as Record<string, unknown>;
updateRequests.push(payload);
currentValues = payload.values as Record<string, unknown>;
await route.fulfill({
json: { data: detail(false, false, currentValues), requestId: '01M00000000000000000000248' },
});
return;
}
if (request.method() === 'POST' && pathname === '/api/v1/files') {
uploadRequests.push((await request.headerValue('content-type')) || '');
if (uploadGate) await uploadGate;
await route.fulfill({
json: {
data: {
publicId: nestedFileId,
originalName: 'matrix-evidence.pdf',
mediaType: 'application/pdf',
extension: 'pdf',
sizeBytes: 24,
sha256: 'd'.repeat(64),
scanStatus: 'AVAILABLE',
createdAt: '2026-08-15T08:02:00Z',
},
requestId: '01M00000000000000000000249',
},
});
return;
}
if (request.method() === 'POST' && pathname === `/api/v1/source/forms/${documentId}/validate`) {
validateRequests.push((request.postDataJSON() || {}) as Record<string, unknown>);
isValid = true;
await route.fulfill({ json: { data: validation(true), requestId: '01M00000000000000000000244' } });
return;
}
if (request.method() === 'POST' && pathname === `/api/v1/source/forms/${documentId}/submit`) {
submitRequests.push(request.postDataJSON() as Record<string, unknown>);
isSubmitted = true;
await route.fulfill({
json: { data: detail(true, true, currentValues), requestId: '01M00000000000000000000245' },
});
return;
}
if (request.method() === 'GET') {
await route.fulfill({
json: {
data: [],
meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 },
requestId: '01M00000000000000000000246',
},
});
return;
}
await route.fulfill({ status: 204 });
});
}
test('PAGE-10 validates, previews the saved snapshot and confirms submission', async ({ page }) => {
const validateRequests: Array<Record<string, unknown>> = [];
const submitRequests: Array<Record<string, unknown>> = [];
await installEditorFixture(page, validateRequests, submitRequests);
await page.goto(`/forms/OA-01/${documentId}`);
await expect(page.getByRole('heading', { name: '项目立项申请', exact: true })).toBeVisible();
await page.getByRole('button', { name: '执行校验', exact: true }).click();
await expect(page.getByRole('button', { name: '预览并提交', exact: true })).toBeVisible();
await page.getByRole('button', { name: '预览并提交', exact: true }).click();
const dialog = page.locator('.t-dialog').filter({ hasText: '提交预览' });
await expect(dialog.getByText('OA01-E2E-PREVIEW', { exact: true })).toBeVisible();
await expect(dialog.getByText('预览验收项目', { exact: true }).first()).toBeVisible();
await expect(dialog.getByText('第 1 行:费用类型:材料;金额:1000', { exact: true })).toBeVisible();
await expect(dialog.getByText('项目复核', { exact: true })).toBeVisible();
await expect(submitRequests).toHaveLength(0);
await dialog.getByRole('button', { name: '确认提交', exact: true }).click();
await expect.poll(() => submitRequests.length).toBe(1);
expect(submitRequests[0]).toEqual({ version: 1 });
expect(validateRequests).toHaveLength(3);
await expect(dialog).toBeHidden();
await expect(page.getByText('已提交审批', { exact: true })).toBeVisible();
});
test('PAGE-10 saves current edits before validating the server snapshot', async ({ page }) => {
const updateRequests: Array<Record<string, unknown>> = [];
const validateRequests: Array<Record<string, unknown>> = [];
await installEditorFixture(page, validateRequests, [], updateRequests);
await page.goto(`/forms/OA-01/${documentId}`);
const projectNameInput = page.locator('.field-section').first().locator('input').first();
await projectNameInput.fill('已更新的预览验收项目');
await page.getByRole('button', { name: '执行校验', exact: true }).click();
await expect.poll(() => updateRequests.length).toBe(1);
await expect.poll(() => validateRequests.length).toBe(1);
expect((updateRequests[0].values as Record<string, unknown>).projectName).toBe('已更新的预览验收项目');
});
test('PAGE-10 edits boolean matrix cells and uploads nested evidence files', async ({ page }) => {
const updateRequests: Array<Record<string, unknown>> = [];
const uploadRequests: string[] = [];
await installEditorFixture(page, [], [], updateRequests, uploadRequests);
await page.goto(`/forms/OA-01/${documentId}`);
const matrixRow = page.locator('.detail-row').filter({ hasText: '是否必需' });
await expect(matrixRow).toBeVisible();
const requiredSwitch = matrixRow.locator('.t-switch');
await expect(requiredSwitch).toContainText('否');
await requiredSwitch.click();
await expect(requiredSwitch).toContainText('是');
await matrixRow.locator('input[type="file"]').setInputFiles({
name: 'matrix-evidence.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('nested matrix evidence'),
});
await matrixRow.getByRole('button', { name: '上传附件', exact: true }).click();
await expect.poll(() => uploadRequests.length).toBe(1);
expect(uploadRequests[0]).toContain('multipart/form-data');
await expect(matrixRow.getByText(nestedFileId, { exact: true })).toBeVisible();
await page.getByRole('button', { name: '保存草稿', exact: true }).first().click();
await expect.poll(() => updateRequests.length).toBe(1);
const savedValues = updateRequests[0].values as Record<string, unknown>;
expect(updateRequests[0]).not.toHaveProperty('formType');
expect(updateRequests[0]).not.toHaveProperty('templateVersion');
expect(updateRequests[0]).not.toHaveProperty('sourceSystem');
expect(savedValues.attachmentMatrix).toEqual([
{
attachmentType: '发票及验收资料',
required: true,
fileId: nestedFileId,
},
]);
});
test('PAGE-10 keeps concurrent upload states isolated and blocks stale draft saves', async ({ page }) => {
const updateRequests: Array<Record<string, unknown>> = [];
const uploadRequests: string[] = [];
let releaseUploads: () => void = () => undefined;
const uploadGate = new Promise<void>((resolve) => {
releaseUploads = resolve;
});
await installEditorFixture(page, [], [], updateRequests, uploadRequests, uploadGate);
await page.goto(`/forms/OA-01/${documentId}`);
const topLevelFile = page.locator('.file-field').first();
const matrixRow = page.locator('.detail-row').filter({ hasText: '是否必需' });
await topLevelFile.locator('input[type="file"]').setInputFiles({
name: 'project-support.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('project support'),
});
await matrixRow.locator('input[type="file"]').setInputFiles({
name: 'matrix-evidence.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('nested matrix evidence'),
});
const topLevelUpload = topLevelFile.getByRole('button', { name: '上传文件', exact: true });
const matrixUpload = matrixRow.getByRole('button', { name: '上传附件', exact: true });
await topLevelUpload.click();
await matrixUpload.click();
await expect.poll(() => uploadRequests.length).toBe(2);
await expect(topLevelUpload).toBeDisabled();
await expect(matrixUpload).toBeDisabled();
await expect(page.getByRole('button', { name: '保存草稿', exact: true }).first()).toBeDisabled();
expect(updateRequests).toHaveLength(0);
releaseUploads();
await expect(matrixRow.getByText(nestedFileId, { exact: true })).toBeVisible();
await expect(topLevelFile.getByText(nestedFileId, { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '保存草稿', exact: true }).first()).toBeEnabled();
await page.getByRole('button', { name: '保存草稿', exact: true }).first().click();
await expect.poll(() => updateRequests.length).toBe(1);
expect(updateRequests[0]).not.toHaveProperty('formType');
expect(updateRequests[0]).not.toHaveProperty('templateVersion');
expect(updateRequests[0]).not.toHaveProperty('sourceSystem');
});
+375
View File
@@ -0,0 +1,375 @@
import type { Page, Route } from '@playwright/test';
import { expect, test } from '@playwright/test';
const departmentId = '01M00000000000000000000220';
const legacyMappingId = '01M00000000000000000000250';
interface DepartmentFixtureOptions {
createRequests: Array<Record<string, unknown>>;
submitRequests: Array<Record<string, unknown>>;
reviewRequests: Array<Record<string, unknown>>;
disableRequests: Array<Record<string, unknown>>;
versionRequests: string[];
legacyCreateRequests: Array<Record<string, unknown>>;
legacyDisableRequests: Array<Record<string, unknown>>;
legacyListRequests: string[];
}
function session() {
return {
authenticated: true,
user: {
publicId: '01M00000000000000000000221',
username: 'masterdata-e2e',
displayName: '主数据经办人',
departmentName: '财务部',
mustChangePassword: false,
},
roles: [{ code: 'FINANCE_MANAGER', name: '财务管理人员', workbenchRoute: '/workbench/finance' }],
activeRole: 'FINANCE_MANAGER',
permissions: [
'masterdata:company:view',
'masterdata:dictionary:view',
'masterdata:dictionary:create',
'masterdata:dictionary:edit',
'masterdata:dictionary:submit',
'masterdata:dictionary:review',
'masterdata:dictionary:disable',
],
};
}
function department(status: string, version: number, allowedActions: string[]) {
return {
publicId: departmentId,
resource: 'departments',
code: 'DEPT-E2E-001',
name: '财务管理部',
parent: null,
accountType: null,
auxiliaryRequired: null,
rate: null,
status,
version,
createdAt: '2026-08-15T08:00:00Z',
updatedAt: '2026-08-15T08:00:00Z',
allowedActions,
};
}
function departmentSnapshot(value: ReturnType<typeof department>) {
const { allowedActions: _allowedActions, ...snapshot } = value;
return snapshot;
}
function legacyMapping(status: 'ACTIVE' | 'DISABLED', version: number, allowedActions: string[]) {
return {
publicId: legacyMappingId,
resource: 'departments',
sourceSystem: 'OLD_ERP',
legacyCode: 'DEPT-LEGACY-001',
targetPublicId: departmentId,
status,
version,
createdAt: '2026-08-15T09:00:00Z',
updatedAt: '2026-08-15T09:00:00Z',
allowedActions,
};
}
async function installDepartmentFixture(page: Page, options: DepartmentFixtureOptions) {
const userSession = session();
let currentDepartment: ReturnType<typeof department> | null = null;
const versionHistory: Array<Record<string, unknown>> = [];
let currentLegacyMapping: ReturnType<typeof legacyMapping> | null = null;
await page.route('**/api/v1/**', async (route: Route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (pathname === '/api/v1/auth/session') {
await route.fulfill({ json: { data: userSession, requestId: '01M00000000000000000000222' } });
return;
}
if (pathname === '/api/v1/auth/profile') {
await route.fulfill({ json: { data: userSession.user, requestId: '01M00000000000000000000223' } });
return;
}
if (pathname === '/api/v1/auth/roles') {
await route.fulfill({ json: { data: userSession.roles, requestId: '01M00000000000000000000224' } });
return;
}
if (pathname === '/api/v1/auth/csrf') {
await route.fulfill({
headers: { 'set-cookie': 'XSRF-TOKEN=masterdata-e2e-csrf; Path=/' },
json: { data: { token: 'masterdata-e2e-csrf' }, requestId: '01M00000000000000000000225' },
});
return;
}
if (request.method() === 'GET' && pathname === '/api/v1/masterdata/references') {
await route.fulfill({
json: {
data: { companies: [], projects: [], counterparties: [], costCategories: [] },
requestId: '01M00000000000000000000226',
},
});
return;
}
if (request.method() === 'GET' && pathname === '/api/v1/masterdata/companies') {
await route.fulfill({
json: {
data: [],
meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 },
requestId: '01M00000000000000000000227',
},
});
return;
}
if (request.method() === 'GET' && pathname === '/api/v1/masterdata/departments') {
await route.fulfill({
json: {
data: currentDepartment ? [currentDepartment] : [],
meta: { page: 1, size: 20, totalElements: currentDepartment ? 1 : 0, totalPages: currentDepartment ? 1 : 0 },
requestId: '01M00000000000000000000228',
},
});
return;
}
if (request.method() === 'GET' && pathname === `/api/v1/masterdata/departments/${departmentId}`) {
await route.fulfill({
json: { data: currentDepartment, requestId: '01M00000000000000000000229' },
});
return;
}
if (request.method() === 'GET' && pathname === `/api/v1/masterdata/departments/${departmentId}/versions`) {
options.versionRequests.push(new URL(request.url()).search);
await route.fulfill({
json: {
data: versionHistory,
meta: { page: 1, size: 20, totalElements: versionHistory.length, totalPages: 1 },
requestId: '01M00000000000000000000235',
},
});
return;
}
if (
request.method() === 'GET' &&
pathname === `/api/v1/masterdata/departments/${departmentId}/legacy-identifiers`
) {
options.legacyListRequests.push(new URL(request.url()).search);
await route.fulfill({
json: {
data: currentLegacyMapping ? [currentLegacyMapping] : [],
meta: {
page: 1,
size: 20,
totalElements: currentLegacyMapping ? 1 : 0,
totalPages: currentLegacyMapping ? 1 : 0,
allowedActions: ['CREATE'],
},
requestId: '01M00000000000000000000251',
},
});
return;
}
if (
request.method() === 'POST' &&
pathname === `/api/v1/masterdata/departments/${departmentId}/legacy-identifiers`
) {
options.legacyCreateRequests.push(request.postDataJSON() as Record<string, unknown>);
currentLegacyMapping = legacyMapping('ACTIVE', 0, ['DISABLE']);
await route.fulfill({
json: { data: currentLegacyMapping, requestId: '01M00000000000000000000252' },
});
return;
}
if (
request.method() === 'POST' &&
pathname === `/api/v1/masterdata/departments/${departmentId}/legacy-identifiers/${legacyMappingId}/disable`
) {
options.legacyDisableRequests.push(request.postDataJSON() as Record<string, unknown>);
currentLegacyMapping = legacyMapping('DISABLED', 1, []);
await route.fulfill({
json: { data: currentLegacyMapping, requestId: '01M00000000000000000000253' },
});
return;
}
if (request.method() === 'POST' && pathname === '/api/v1/masterdata/departments') {
options.createRequests.push(request.postDataJSON() as Record<string, unknown>);
currentDepartment = department('DRAFT', 0, ['EDIT', 'SUBMIT']);
versionHistory.unshift({
auditId: '01M00000000000000000000240',
version: 0,
status: 'DRAFT',
actionCode: 'MASTERDATA_DICTIONARY_CREATE',
actorName: 'masterdata-e2e',
changedAt: '2026-08-15T08:00:00Z',
before: null,
after: departmentSnapshot(currentDepartment),
});
await route.fulfill({
json: { data: currentDepartment, requestId: '01M00000000000000000000230' },
});
return;
}
if (request.method() === 'POST' && pathname === `/api/v1/masterdata/departments/${departmentId}/submit`) {
options.submitRequests.push(request.postDataJSON() as Record<string, unknown>);
const before = currentDepartment ? departmentSnapshot(currentDepartment) : null;
currentDepartment = department('REVIEWING', 1, ['REVIEW']);
versionHistory.unshift({
auditId: '01M00000000000000000000241',
version: 1,
status: 'REVIEWING',
actionCode: 'MASTERDATA_DICTIONARY_SUBMIT',
actorName: 'masterdata-e2e',
changedAt: '2026-08-15T08:10:00Z',
before,
after: departmentSnapshot(currentDepartment),
});
await route.fulfill({
json: { data: currentDepartment, requestId: '01M00000000000000000000231' },
});
return;
}
if (request.method() === 'POST' && pathname === `/api/v1/masterdata/departments/${departmentId}/review`) {
options.reviewRequests.push(request.postDataJSON() as Record<string, unknown>);
const before = currentDepartment ? departmentSnapshot(currentDepartment) : null;
currentDepartment = department('ACTIVE', 2, ['DISABLE']);
versionHistory.unshift({
auditId: '01M00000000000000000000242',
version: 2,
status: 'ACTIVE',
actionCode: 'MASTERDATA_DICTIONARY_REVIEW',
actorName: 'masterdata-reviewer',
changedAt: '2026-08-15T08:20:00Z',
before,
after: departmentSnapshot(currentDepartment),
});
await route.fulfill({
json: { data: currentDepartment, requestId: '01M00000000000000000000232' },
});
return;
}
if (request.method() === 'POST' && pathname === `/api/v1/masterdata/departments/${departmentId}/disable`) {
options.disableRequests.push(request.postDataJSON() as Record<string, unknown>);
const before = currentDepartment ? departmentSnapshot(currentDepartment) : null;
currentDepartment = department('DISABLED', 3, []);
versionHistory.unshift({
auditId: '01M00000000000000000000243',
version: 3,
status: 'DISABLED',
actionCode: 'MASTERDATA_DICTIONARY_DISABLE',
actorName: 'masterdata-reviewer',
changedAt: '2026-08-15T08:30:00Z',
before,
after: departmentSnapshot(currentDepartment),
});
await route.fulfill({
json: { data: currentDepartment, requestId: '01M00000000000000000000233' },
});
return;
}
if (request.method() === 'GET') {
await route.fulfill({
json: {
data: [],
meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 },
requestId: '01M00000000000000000000234',
},
});
return;
}
await route.fulfill({ status: 404 });
});
}
test('PAGE-12 creates and advances a department using server allowed actions', async ({ page }) => {
const createRequests: Array<Record<string, unknown>> = [];
const submitRequests: Array<Record<string, unknown>> = [];
const reviewRequests: Array<Record<string, unknown>> = [];
const disableRequests: Array<Record<string, unknown>> = [];
const versionRequests: string[] = [];
const legacyCreateRequests: Array<Record<string, unknown>> = [];
const legacyDisableRequests: Array<Record<string, unknown>> = [];
const legacyListRequests: string[] = [];
await installDepartmentFixture(page, {
createRequests,
submitRequests,
reviewRequests,
disableRequests,
versionRequests,
legacyCreateRequests,
legacyDisableRequests,
legacyListRequests,
});
await page.goto('/finance/master-data');
await page.locator('.resource-tabs').getByText('部门', { exact: true }).click();
await page.locator('.page-header').getByRole('button', { name: '新建部门', exact: true }).click();
const createDialog = page.locator('.t-dialog').filter({ hasText: '新建部门' });
await createDialog.locator('.t-form-item__code input').fill('DEPT-E2E-001');
await createDialog.locator('.t-form-item__name input').fill('财务管理部');
await createDialog.getByRole('button', { name: '创建部门', exact: true }).click();
await expect.poll(() => createRequests.length).toBe(1);
expect(createRequests[0]).toEqual({ code: 'DEPT-E2E-001', name: '财务管理部' });
await expect(page.getByText('草稿', { exact: true })).toBeVisible();
await page.getByText('提交', { exact: true }).click();
const submitDialog = page.locator('.t-dialog').filter({ hasText: '提交复核' });
await submitDialog.getByRole('button', { name: '确认提交', exact: true }).click();
await expect.poll(() => submitRequests.length).toBe(1);
expect(submitRequests[0]).toEqual({ version: 0 });
await expect(page.getByText('复核中', { exact: true })).toBeVisible();
await page.getByText('复核', { exact: true }).click();
const reviewDialog = page.locator('.t-dialog').filter({ hasText: '复核主数据' });
await reviewDialog.locator('textarea').fill('部门资料核对无误');
await reviewDialog.getByRole('button', { name: '确认复核', exact: true }).click();
await expect.poll(() => reviewRequests.length).toBe(1);
expect(reviewRequests[0]).toEqual({ version: 1, decision: 'APPROVE', opinion: '部门资料核对无误' });
await expect(page.getByText('已生效', { exact: true })).toBeVisible();
await page.getByText('停用', { exact: true }).click();
const disableDialog = page.locator('.t-dialog').filter({ hasText: '停用主数据' });
await disableDialog.getByRole('button', { name: '确认停用', exact: true }).click();
await expect.poll(() => disableRequests.length).toBe(1);
expect(disableRequests[0]).toEqual({ version: 2 });
await expect(page.locator('.table-panel').getByText('已停用', { exact: true })).toBeVisible();
await expect(page.getByText('停用', { exact: true })).toHaveCount(0);
await page.getByText('版本记录', { exact: true }).click();
const historyDialog = page.locator('.t-dialog').filter({ hasText: '部门版本记录' });
await expect(historyDialog).toBeVisible();
await expect.poll(() => versionRequests.length).toBe(1);
expect(versionRequests[0]).toContain('page=1');
expect(versionRequests[0]).toContain('size=20');
await expect(historyDialog.locator('.history-section tbody tr')).toHaveCount(4);
const statusDifference = historyDialog.locator('.comparison-section tbody tr').filter({ hasText: '状态' });
await expect(statusDifference).toContainText('草稿');
await expect(statusDifference).toContainText('已停用');
await expect(historyDialog.locator('.comparison-section tbody tr').filter({ hasText: '版本' })).toContainText('v3');
await historyDialog.getByRole('button', { name: '关闭', exact: true }).click();
await page.getByText('查看', { exact: true }).click();
const detailDialog = page.locator('.t-dialog').filter({ hasText: '部门详情' });
await detailDialog.getByRole('button', { name: '旧编号', exact: true }).click();
const legacyDialog = page.locator('.t-dialog').filter({ hasText: '部门旧编号映射' });
await expect(legacyDialog).toBeVisible();
await expect.poll(() => legacyListRequests.length).toBe(1);
await legacyDialog.locator('.t-form-item__sourceSystem input').fill('OLD_ERP');
await legacyDialog.locator('.t-form-item__legacyCode input').fill('DEPT-LEGACY-001');
await legacyDialog.getByRole('button', { name: '新增映射', exact: true }).click();
await expect.poll(() => legacyCreateRequests.length).toBe(1);
expect(legacyCreateRequests[0]).toEqual({ sourceSystem: 'OLD_ERP', legacyCode: 'DEPT-LEGACY-001' });
await expect(legacyDialog.getByText('DEPT-LEGACY-001', { exact: true })).toBeVisible();
await legacyDialog.getByText('停用', { exact: true }).click();
const legacyDisableDialog = page.locator('.t-dialog').filter({ hasText: '停用旧编号映射' });
await legacyDisableDialog.getByRole('button', { name: '停用', exact: true }).click();
await expect.poll(() => legacyDisableRequests.length).toBe(1);
expect(legacyDisableRequests[0]).toEqual({ version: 0 });
await expect(legacyDialog.getByText('已停用', { exact: true })).toBeVisible();
await expect(legacyDialog.getByText('停用', { exact: true })).toHaveCount(0);
});
+206
View File
@@ -0,0 +1,206 @@
import { Buffer } from 'node:buffer';
import type { Page, Route } from '@playwright/test';
import { expect, test } from '@playwright/test';
const paymentId = '01M00000000000000000000201';
const companyId = '01M00000000000000000000202';
const projectId = '01M00000000000000000000203';
const contractId = '01M00000000000000000000204';
const supplierId = '01M00000000000000000000205';
const bankAccountId = '01M00000000000000000000206';
const fileId = '01M00000000000000000000207';
function envelope(data: unknown, totalElements?: number) {
return {
data,
...(typeof totalElements === 'number'
? { meta: { page: 1, size: 20, totalElements, totalPages: totalElements ? 1 : 0 } }
: {}),
requestId: '01M00000000000000000000299',
};
}
function session() {
return {
authenticated: true,
user: {
publicId: '01M00000000000000000000208',
username: 'payment-e2e',
displayName: '付款结果登记员',
departmentName: '财务部',
mustChangePassword: false,
},
roles: [{ code: 'FINANCE_MANAGER', name: '财务管理人员', workbenchRoute: '/workbench/finance' }],
activeRole: 'FINANCE_MANAGER',
permissions: ['payment:request:view', 'payment:request:record-result'],
};
}
function payment(overrides: Record<string, unknown> = {}) {
return {
publicId: paymentId,
businessNo: 'PAY-E2E-001',
company: { publicId: companyId, code: 'GS-E2E', name: '凯迪测试公司' },
project: { publicId: projectId, code: 'XM-E2E', name: '付款结果项目' },
contract: { publicId: contractId, code: 'HT-E2E', name: '测试合同' },
payable: null,
supplier: { publicId: supplierId, code: 'GYS-E2E', name: '测试供应商' },
bankAccount: {
publicId: bankAccountId,
supplierId,
accountName: '测试账户',
bankName: '测试银行',
maskedAccountNo: '****0001',
versionNo: 1,
},
sourceDocumentId: null,
originalPaymentId: null,
repaymentSequence: 0,
requestedAmount: '1000.00',
approvedAmount: '1000.00',
frozenAmount: '1000.00',
paidAmount: '0.00',
currency: 'CNY',
requestedDate: '2026-08-15',
purpose: '付款结果登记验收',
status: 'OFFLINE_PENDING',
submittedByName: null,
approvedByName: '财务审批员',
resultRecordedByName: null,
resultVerifiedByName: null,
version: 2,
createdAt: '2026-08-15T08:00:00Z',
updatedAt: '2026-08-15T08:00:00Z',
allowedActions: ['RECORD_SUCCESS'],
...overrides,
};
}
interface FixtureOptions {
paymentQueries: URLSearchParams[];
recordRequests: Array<Record<string, unknown>>;
recordHeaders: Array<Record<string, string>>;
}
async function installFixture(page: Page, options: FixtureOptions) {
const userSession = session();
let currentPayment = payment();
await page.route('**/api/v1/**', async (route: Route) => {
const request = route.request();
const url = new URL(request.url());
const pathname = url.pathname;
if (pathname === '/api/v1/auth/session') return route.fulfill({ json: envelope(userSession) });
if (pathname === '/api/v1/auth/profile') return route.fulfill({ json: envelope(userSession.user) });
if (pathname === '/api/v1/auth/roles') return route.fulfill({ json: envelope(userSession.roles) });
if (pathname === '/api/v1/auth/csrf') {
return route.fulfill({
headers: { 'set-cookie': 'XSRF-TOKEN=payment-e2e-csrf; Path=/' },
json: envelope({ token: 'payment-e2e-csrf' }),
});
}
if (request.method() === 'GET' && pathname === '/api/v1/payments/references') {
return route.fulfill({
json: envelope({
companies: [],
projects: [],
suppliers: [],
contracts: [],
payables: [],
bankAccounts: [],
attachmentRules: [],
}),
});
}
if (request.method() === 'GET' && pathname === '/api/v1/payments') {
options.paymentQueries.push(new URLSearchParams(url.searchParams));
return route.fulfill({ json: envelope([currentPayment], 1) });
}
if (request.method() === 'POST' && pathname === '/api/v1/files') {
return route.fulfill({
json: envelope({
publicId: fileId,
originalName: 'payment-receipt.pdf',
mediaType: 'application/pdf',
sizeBytes: 24,
sha256: 'd'.repeat(64),
scanStatus: 'AVAILABLE',
}),
});
}
if (request.method() === 'POST' && pathname === `/api/v1/payments/${paymentId}/confirm-offline-result`) {
options.recordRequests.push(request.postDataJSON() as Record<string, unknown>);
options.recordHeaders.push(request.headers());
currentPayment = payment({
status: 'RESULT_RECORDED',
version: 3,
resultRecordedByName: '付款结果登记员',
allowedActions: [],
updatedAt: '2026-08-16T08:05:00Z',
});
return route.fulfill({ json: envelope(currentPayment) });
}
if (request.method() === 'GET') return route.fulfill({ json: envelope([], 0) });
return route.fulfill({ status: 204 });
});
}
test('PAGE-15 blocks a result without evidence and records a successful offline payment', async ({ page }) => {
const paymentQueries: URLSearchParams[] = [];
const recordRequests: Array<Record<string, unknown>> = [];
const recordHeaders: Array<Record<string, string>> = [];
await installFixture(page, { paymentQueries, recordRequests, recordHeaders });
await page.goto('/finance/payments?tab=offline');
await expect(page.getByRole('heading', { name: '进度款与付款', exact: true })).toBeVisible();
await expect(page.getByText('PAY-E2E-001', { exact: true }).first()).toBeVisible();
await expect.poll(() => paymentQueries.length).toBeGreaterThan(0);
const query = paymentQueries.at(-1)!;
expect(query.get('tab')).toBe('offline');
expect(query.get('sort')).toBe('updatedAt,desc');
expect(query.get('page')).toBe('1');
expect(query.get('size')).toBe('20');
const initialListRequests = paymentQueries.length;
await page.getByRole('button', { name: '更多操作', exact: true }).click();
await page.getByText('登记付款成功', { exact: true }).click();
const dialog = page.locator('.t-dialog').filter({ hasText: '登记付款成功' });
const resultDate = await page.evaluate(() => {
const now = new Date();
return new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 10);
});
await expect(dialog.locator('.t-date-picker input')).toHaveValue(resultDate);
await dialog.getByRole('button', { name: '确认', exact: true }).click();
await expect(dialog.getByText('请上传回单或结果附件。', { exact: true })).toBeVisible();
expect(recordRequests).toHaveLength(0);
await dialog.getByPlaceholder('请输入金额', { exact: true }).fill('1000');
await dialog.locator('textarea').fill('线下转账已完成');
await dialog.locator('input[type="file"]').setInputFiles({
name: 'payment-receipt.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('payment-result-evidence'),
});
await dialog.getByRole('button', { name: '上传已选文件', exact: true }).click();
await expect(dialog.getByText('可用', { exact: true })).toBeVisible();
await dialog.getByRole('button', { name: '确认', exact: true }).click();
await expect.poll(() => recordRequests.length).toBe(1);
expect(recordRequests[0]).toEqual({
version: 2,
resultAt: resultDate,
amount: '1000.00',
receiptFileIds: [fileId],
remark: '线下转账已完成',
});
expect(recordHeaders[0]['x-xsrf-token']).toBe('payment-e2e-csrf');
expect(recordHeaders[0]['idempotency-key']).toBeTruthy();
await expect(dialog).toBeHidden();
await expect.poll(() => paymentQueries.length).toBeGreaterThan(initialListRequests);
await expect(page.getByText('结果待复核', { exact: true })).toBeVisible();
await expect(page.getByText('登记付款成功已完成', { exact: true })).toBeVisible();
});
+293
View File
@@ -0,0 +1,293 @@
import type { Page, Route } from '@playwright/test';
import { expect, test } from '@playwright/test';
const packageId = '01M00000000000000000000301';
const createdPackageId = '01M00000000000000000000302';
const companyId = '01M00000000000000000000303';
const projectId = '01M00000000000000000000304';
const itemId = '01M00000000000000000000305';
interface ArchiveFixtureOptions {
permissions?: string[];
packageActions?: string[];
listQueries?: URL[];
createRequests?: Array<Record<string, unknown>>;
checkRequests?: Array<Record<string, unknown>>;
}
function archivePackage(allowedActions: string[] = []) {
return {
publicId: packageId,
businessNo: 'ARC-E2E-001',
company: { publicId: companyId, businessNo: 'COMP-E2E-001', name: '凯迪档案测试公司' },
project: { publicId: projectId, businessNo: 'P-E2E-001', name: '档案验收项目' },
packageVersion: 1,
rootPackagePublicId: packageId,
supersedesPackagePublicId: null,
revisionReason: null,
ruleVersion: 'ARCHIVE-RULE-V1',
completeness: '80.00',
missingCount: 1,
retentionYears: 10,
retentionExpiresOn: '2026-10-31',
retentionStatus: 'DUE_SOON',
physicalLocation: 'A-01-03',
frozen: false,
status: 'PREPARING',
manifestSha256: 'a'.repeat(64),
submittedByName: null,
reviewedByName: null,
submittedAt: null,
archivedAt: null,
updatedAt: '2026-08-15T08:00:00Z',
version: 3,
allowedActions,
items: [
{
publicId: itemId,
itemType: 'CONTRACT',
itemName: '合同文件',
required: true,
status: 'MISSING',
missingReason: '尚未上传合同文件',
allowedActions: ['UPLOAD'],
},
],
objectSnapshots: [],
actions: [
{
sequenceNo: 1,
actionCode: 'GENERATE',
fromStatus: null,
toStatus: 'PREPARING',
opinion: '生成项目档案包',
actorName: '财务归档员',
occurredAt: '2026-08-15T08:00:00Z',
manifestSha256: 'a'.repeat(64),
},
],
};
}
function session(permissions: string[]) {
return {
authenticated: true,
user: {
publicId: '01M00000000000000000000306',
username: 'archive-e2e',
displayName: '财务归档员',
departmentName: '财务部',
mustChangePassword: false,
},
roles: [{ code: 'FINANCE_MANAGER', name: '财务管理人员', workbenchRoute: '/workbench/finance' }],
activeRole: 'FINANCE_MANAGER',
permissions,
};
}
async function installArchiveFixture(page: Page, options: ArchiveFixtureOptions = {}) {
const userSession = session(options.permissions || ['archive:package:view']);
let currentPackage = archivePackage(options.packageActions || []);
await page.route('**/api/v1/**', async (route: Route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (pathname === '/api/v1/auth/session') {
await route.fulfill({ json: { data: userSession, requestId: '01M00000000000000000000307' } });
return;
}
if (pathname === '/api/v1/auth/profile') {
await route.fulfill({ json: { data: userSession.user, requestId: '01M00000000000000000000308' } });
return;
}
if (pathname === '/api/v1/auth/roles') {
await route.fulfill({ json: { data: userSession.roles, requestId: '01M00000000000000000000309' } });
return;
}
if (pathname === '/api/v1/auth/csrf') {
await route.fulfill({
headers: { 'set-cookie': 'XSRF-TOKEN=archive-e2e-csrf; Path=/' },
json: { data: { token: 'archive-e2e-csrf' }, requestId: '01M00000000000000000000310' },
});
return;
}
if (request.method() === 'GET' && pathname === '/api/v1/archives/packages') {
options.listQueries?.push(new URL(request.url()));
await route.fulfill({
json: {
data: [currentPackage],
meta: { page: 1, size: 20, totalElements: 1, totalPages: 1 },
requestId: '01M00000000000000000000311',
},
});
return;
}
if (request.method() === 'GET' && pathname === `/api/v1/archives/packages/${currentPackage.publicId}`) {
await route.fulfill({ json: { data: currentPackage, requestId: '01M00000000000000000000312' } });
return;
}
if (request.method() === 'POST' && pathname === '/api/v1/archives/packages') {
options.createRequests?.push(request.postDataJSON() as Record<string, unknown>);
currentPackage = { ...archivePackage(['CHECK']), publicId: createdPackageId, businessNo: 'ARC-E2E-002' };
await route.fulfill({ json: { data: currentPackage, requestId: '01M00000000000000000000313' } });
return;
}
if (request.method() === 'POST' && pathname === `/api/v1/archives/packages/${currentPackage.publicId}/check`) {
options.checkRequests?.push(request.postDataJSON() as Record<string, unknown>);
currentPackage = {
...currentPackage,
status: 'CHECKED',
version: currentPackage.version + 1,
allowedActions: ['SUBMIT'],
};
await route.fulfill({ json: { data: currentPackage, requestId: '01M00000000000000000000314' } });
return;
}
if (request.method() === 'GET') {
await route.fulfill({
json: {
data: [],
meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 },
requestId: '01M00000000000000000000315',
},
});
return;
}
await route.fulfill({ status: 204 });
});
}
test('PAGE-17 keeps list filters in the URL and reset removes optional conditions', async ({ page }) => {
const listQueries: URL[] = [];
await installArchiveFixture(page, { listQueries });
await page.goto(
`/archives/projects?view=archived&projectId=${projectId}&status=ARCHIVED&frozen=true&sort=archivedAt%2Cdesc`,
);
await expect.poll(() => listQueries.length).toBeGreaterThan(0);
const initialQuery = listQueries.at(-1)?.searchParams;
expect(initialQuery?.get('view')).toBe('archived');
expect(initialQuery?.get('projectId')).toBe(projectId);
expect(initialQuery?.get('status')).toBe('ARCHIVED');
expect(initialQuery?.get('frozen')).toBe('true');
expect(initialQuery?.get('sort')).toBe('archivedAt,desc');
await expect
.poll(() => {
const url = new URL(page.url());
return {
pathname: url.pathname,
view: url.searchParams.get('view'),
projectId: url.searchParams.get('projectId'),
status: url.searchParams.get('status'),
frozen: url.searchParams.get('frozen'),
sort: url.searchParams.get('sort'),
};
})
.toEqual({
pathname: '/archives/projects',
view: 'archived',
projectId,
status: 'ARCHIVED',
frozen: 'true',
sort: 'archivedAt,desc',
});
await expect(page.getByText('档案验收项目', { exact: true })).toBeVisible();
await expect(page.getByText('即将到期', { exact: true })).toBeVisible();
await page.getByRole('button', { name: '重置', exact: true }).click();
await expect.poll(() => listQueries.length).toBeGreaterThan(1);
const resetQuery = listQueries.at(-1)?.searchParams;
expect(resetQuery?.get('view')).toBe('archived');
expect(resetQuery?.has('projectId')).toBe(false);
expect(resetQuery?.has('status')).toBe(false);
expect(resetQuery?.has('frozen')).toBe(false);
expect(resetQuery?.get('sort')).toBe('updatedAt,desc');
await expect
.poll(() => {
const url = new URL(page.url());
return {
pathname: url.pathname,
view: url.searchParams.get('view'),
projectId: url.searchParams.get('projectId'),
status: url.searchParams.get('status'),
frozen: url.searchParams.get('frozen'),
sort: url.searchParams.get('sort'),
};
})
.toEqual({
pathname: '/archives/projects',
view: 'archived',
projectId: null,
status: null,
frozen: null,
sort: 'updatedAt,desc',
});
});
test('PAGE-17 hides archive generation without create permission and opens scoped detail', async ({ page }) => {
await installArchiveFixture(page);
await page.goto('/archives/projects');
await expect(page.getByRole('heading', { name: '项目档案包', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '生成档案包', exact: true })).toHaveCount(0);
await page.getByText('ARC-E2E-001', { exact: true }).click();
const drawer = page.locator('.t-drawer').filter({ hasText: 'ARC-E2E-001' });
await expect(drawer.getByText('保管到期', { exact: true })).toBeVisible();
await expect(
drawer.getByText('档案将于 2026-10-31 到期。系统仅提示,不会自动删除或销毁。', { exact: true }),
).toBeVisible();
await expect(drawer.getByRole('button', { name: '检查', exact: true })).toHaveCount(0);
});
test('PAGE-17 validates and sends the archive package creation payload', async ({ page }) => {
const createRequests: Array<Record<string, unknown>> = [];
await installArchiveFixture(page, {
permissions: ['archive:package:view', 'archive:package:create'],
createRequests,
});
await page.goto('/archives/projects');
await page.getByRole('button', { name: '生成档案包', exact: true }).click();
const dialog = page.locator('.t-dialog').filter({ hasText: '生成项目档案包' });
await dialog.getByRole('button', { name: '生成', exact: true }).click();
await expect(dialog.getByText('请填写项目公开编号', { exact: true })).toBeVisible();
expect(createRequests).toHaveLength(0);
await dialog.getByPlaceholder('请输入项目公开编号', { exact: true }).fill(projectId);
await dialog.getByPlaceholder('可选,例如 A-01-03', { exact: true }).fill('B-02-08');
await dialog.getByRole('button', { name: '生成', exact: true }).click();
await expect.poll(() => createRequests.length).toBe(1);
expect(createRequests[0]).toEqual({ projectId, retentionYears: 10, physicalLocation: 'B-02-08' });
await expect(page.getByText('档案包已生成', { exact: true })).toBeVisible();
await expect(dialog).toBeHidden();
await expect(page.getByText('ARC-E2E-002', { exact: true })).toBeVisible();
});
test('PAGE-17 executes only server-provided package actions and refreshes detail', async ({ page }) => {
const checkRequests: Array<Record<string, unknown>> = [];
await installArchiveFixture(page, {
permissions: ['archive:package:view', 'archive:package:submit'],
packageActions: ['CHECK'],
checkRequests,
});
await page.goto('/archives/projects');
await page.getByText('ARC-E2E-001', { exact: true }).click();
const drawer = page.locator('.t-drawer').filter({ hasText: 'ARC-E2E-001' });
await drawer.getByRole('button', { name: '检查', exact: true }).click();
await expect(drawer.getByRole('button', { name: '提交审核', exact: true })).toHaveCount(0);
const dialog = page.locator('.t-dialog').filter({ hasText: '检查' });
await dialog.getByRole('button', { name: '确认检查', exact: true }).click();
await expect.poll(() => checkRequests.length).toBe(1);
expect(checkRequests[0]).toEqual({ version: 3 });
await expect(page.getByText('检查已完成', { exact: true })).toBeVisible();
await expect(dialog).toBeHidden();
await expect(drawer.getByRole('button', { name: '提交审核', exact: true })).toBeVisible();
await expect(drawer.getByRole('button', { name: '检查', exact: true })).toHaveCount(0);
});
@@ -0,0 +1,354 @@
import type { Page, Route } from '@playwright/test';
import { expect, test } from '@playwright/test';
const projectId = '01M00000000000000000000001';
const formId = '01M00000000000000000000002';
const receivableId = '01M00000000000000000000009';
const accountingEventId = '01M00000000000000000000013';
const voucherId = '01M00000000000000000000014';
const permissions = [
'project:project:view',
'source:form:view',
'contractcost:ledger:view',
'receivable:receipt:view',
'receivable:invoice:view',
'payment:request:view',
'accounting:event:view',
'accounting:voucher:view',
'archive:package:view',
'archive:file:view',
];
function envelope(data: unknown, totalElements = Array.isArray(data) ? data.length : 0) {
return {
data,
meta: { page: 1, size: 20, totalElements, totalPages: totalElements ? 1 : 0 },
requestId: '01M00000000000000000000099',
};
}
function session() {
return {
authenticated: true,
user: {
publicId: '01M00000000000000000000003',
username: 'finance',
displayName: '财务测试用户',
departmentName: '财务部',
mustChangePassword: false,
},
roles: [{ code: 'FINANCE_MANAGER', name: '财务管理人员', workbenchRoute: '/workbench/finance' }],
activeRole: 'FINANCE_MANAGER',
permissions,
};
}
function project() {
return {
publicId: projectId,
businessNo: 'P-DRILL-001',
name: '穿透验证项目',
description: '',
company: { publicId: '01M00000000000000000000004', name: '测试公司' },
customer: null,
owner: { publicId: '01M00000000000000000000005', name: '项目负责人' },
contractAmount: '100.00',
receivedAmount: '50.00',
paidAmount: '20.00',
currency: 'CNY',
stage: 'EXECUTION',
financeStatus: 'IN_PROGRESS',
archiveStatus: 'PREPARING',
status: 'ACTIVE',
startDate: '2026-08-01',
endDate: null,
version: 1,
createdAt: '2026-08-01T00:00:00Z',
updatedAt: '2026-08-15T00:00:00Z',
overview: {
contractAmount: '100.00',
receivedAmount: '50.00',
paidAmount: '20.00',
availableBalance: '30.00',
sourceFormCount: 1,
contractCount: 0,
receiptCount: 0,
invoiceCount: 0,
paymentCount: 1,
progressCount: 0,
peopleCount: 0,
accountingCount: 1,
voucherCount: 1,
pendingTaskCount: 0,
fileCount: 1,
activeRiskCount: 0,
documentCompleteness: 80,
},
riskFlags: [],
timeline: [],
allowedActions: [],
};
}
async function fulfillApi(route: Route) {
const request = route.request();
const url = new URL(request.url());
const pathname = url.pathname;
if (pathname === '/api/v1/auth/session') return route.fulfill({ json: envelope(session()) });
if (pathname === '/api/v1/auth/profile') return route.fulfill({ json: envelope(session().user) });
if (pathname === '/api/v1/auth/roles') return route.fulfill({ json: envelope(session().roles) });
if (pathname === `/api/v1/projects/${projectId}`) return route.fulfill({ json: envelope(project()) });
if (pathname === `/api/v1/projects/${projectId}/forms`) {
return route.fulfill({
json: envelope(
[
{
publicId: formId,
businessNo: 'OA01-DRILL-001',
formType: 'OA-01',
formName: '项目立项表',
applicantName: '申请人',
status: 'APPROVED',
businessDate: '2026-08-01',
updatedAt: '2026-08-15T00:00:00Z',
},
],
1,
),
});
}
if (pathname === `/api/v1/projects/${projectId}/payments`) {
return route.fulfill({
json: envelope(
[
{
publicId: '01M00000000000000000000006',
businessNo: 'PAY-DRILL-001',
supplier: { publicId: '01M00000000000000000000007', name: '测试供应商' },
requestedAmount: '20.00',
purpose: '项目付款',
status: 'OFFLINE_PENDING',
requestedDate: '2026-08-15',
},
],
1,
),
});
}
if (pathname === `/api/v1/projects/${projectId}/timeline`) {
const current = Number(url.searchParams.get('page') || '1');
const item = {
publicId: current === 1 ? '01M00000000000000000000011' : '01M00000000000000000000012',
actionCode: current === 1 ? 'PROJECT_CREATE' : 'PROJECT_UPDATE',
resultCode: 'SUCCESS',
reason: current === 1 ? '第一页操作记录' : '第二页操作记录',
username: '项目操作人',
activeRole: 'PROJECT_MANAGER',
occurredAt: current === 1 ? '2026-08-15T08:00:00Z' : '2026-08-14T08:00:00Z',
};
return route.fulfill({
json: {
data: [item],
meta: { page: current, size: 20, totalElements: 21, totalPages: 2 },
requestId: '01M00000000000000000000098',
},
});
}
if (pathname === `/api/v1/projects/${projectId}/receipts`) {
if (url.searchParams.get('view') === 'receivables') {
return route.fulfill({
json: envelope(
[
{
publicId: receivableId,
businessNo: 'REC-DRILL-001',
counterparty: { publicId: '01M00000000000000000000010', name: '测试客户' },
businessDate: '2026-08-15',
amount: '50.00',
settledAmount: '20.00',
outstandingAmount: '30.00',
currency: 'CNY',
status: 'PARTIALLY_SETTLED',
},
],
1,
),
});
}
return route.fulfill({ json: envelope([]) });
}
if (pathname === `/api/v1/projects/${projectId}/archives`) {
if (url.searchParams.get('view') === 'files') {
return route.fulfill({
json: envelope(
[
{
publicId: '01M00000000000000000000008',
displayName: '付款审批单.pdf',
originalType: 'PAYMENT_APPROVAL',
archiveStatus: 'ACTIVE',
uploadedAt: '2026-08-15T00:00:00Z',
},
],
1,
),
});
}
return route.fulfill({ json: envelope([]) });
}
if (pathname === `/api/v1/projects/${projectId}/accounting`) {
if (url.searchParams.get('view') === 'vouchers') {
return route.fulfill({
json: envelope(
[
{
publicId: voucherId,
businessNo: 'VCH-DRILL-001',
eventPublicId: accountingEventId,
eventBusinessNo: 'EV-DRILL-001',
period: '2026-08',
businessDate: '2026-08-15',
debitTotal: '50.00',
creditTotal: '50.00',
status: 'COMPLETED',
updatedAt: '2026-08-16T09:00:00Z',
},
],
1,
),
});
}
return route.fulfill({
json: envelope(
[
{
publicId: accountingEventId,
businessNo: 'EV-DRILL-001',
sourceType: 'RECEIPT',
sourcePublicId: receivableId,
sourceBusinessNo: 'REC-DRILL-001',
voucherPublicId: voucherId,
voucherStatus: 'COMPLETED',
period: '2026-08',
businessDate: '2026-08-15',
amount: '50.00',
status: 'COMPLETED',
createdAt: '2026-08-15T09:00:00Z',
},
],
1,
),
});
}
if (pathname === '/api/v1/accounting/vouchers') {
return route.fulfill({
json: envelope(
[
{
publicId: voucherId,
businessNo: 'VCH-DRILL-001',
eventPublicId: accountingEventId,
eventBusinessNo: 'EV-DRILL-001',
company: { publicId: '01M00000000000000000000004', code: 'GS-DRILL', name: '测试公司' },
project: { publicId: projectId, code: 'P-DRILL-001', name: '穿透验证项目' },
period: '2026-08',
businessDate: '2026-08-15',
summary: '项目记账完成结果',
debitTotal: '50.00',
creditTotal: '50.00',
balanceStatus: 'BALANCED',
status: 'COMPLETED',
resultCycleNo: 0,
evidenceFileIds: [],
version: 5,
createdAt: '2026-08-15T09:00:00Z',
updatedAt: '2026-08-16T09:00:00Z',
allowedActions: [],
},
],
1,
),
});
}
if (request.method() === 'GET') return route.fulfill({ json: envelope([]) });
return route.fulfill({ status: 204 });
}
async function openProject(page: Page, tab: string) {
await page.route('**/api/v1/**', fulfillApi);
await page.goto(`/projects/${projectId}?tab=${tab}`);
await expect(page.getByRole('heading', { name: '穿透验证项目', exact: true })).toBeVisible();
}
test('source form row opens the exact PAGE-10 document and keeps the project return path', async ({ page }) => {
await openProject(page, 'forms');
await page.getByRole('link', { name: '查看OA01-DRILL-001', exact: true }).click();
await expect(page).toHaveURL(new RegExp(`/forms/OA-01/${formId}\\?`));
const url = new URL(page.url());
expect(url.searchParams.get('from')).toBe(`/projects/${projectId}?tab=forms`);
});
test('payment row opens PAGE-15 with project, business number and status tab', async ({ page }) => {
await openProject(page, 'payments');
await page.getByRole('link', { name: '查看PAY-DRILL-001', exact: true }).click();
await expect(page).toHaveURL(/\/finance\/payments\?/);
const url = new URL(page.url());
expect(url.searchParams.get('projectId')).toBe(projectId);
expect(url.searchParams.get('keyword')).toBe('PAY-DRILL-001');
expect(url.searchParams.get('tab')).toBe('offline');
});
test('receivable row opens the PAGE-14 receivable ledger instead of the receipt tab', async ({ page }) => {
await openProject(page, 'receipts');
await page.getByRole('link', { name: '查看REC-DRILL-001', exact: true }).click();
await expect(page).toHaveURL(/\/finance\/receipts-invoices\?/);
const url = new URL(page.url());
expect(url.searchParams.get('projectId')).toBe(projectId);
expect(url.searchParams.get('keyword')).toBe('REC-DRILL-001');
expect(url.searchParams.get('tab')).toBe('receivables');
});
test('archive file row opens PAGE-18 with project and exact file name', async ({ page }) => {
await openProject(page, 'archives');
await page.getByRole('link', { name: '查看付款审批单.pdf', exact: true }).click();
await expect(page).toHaveURL(/\/archives\/files\?/);
const url = new URL(page.url());
expect(url.searchParams.get('resource')).toBe('files');
expect(url.searchParams.get('projectId')).toBe(projectId);
expect(url.searchParams.get('fileName')).toBe('付款审批单.pdf');
});
test('completed voucher remains visible in the project lifecycle and opens its exact PAGE-16 tab', async ({ page }) => {
await openProject(page, 'accounting');
await expect(page.getByRole('link', { name: '查看VCH-DRILL-001', exact: true })).toBeVisible();
await page.getByRole('link', { name: '查看VCH-DRILL-001', exact: true }).click();
await expect(page).toHaveURL(/\/finance\/accounting\?/);
const url = new URL(page.url());
expect(url.searchParams.get('projectId')).toBe(projectId);
expect(url.searchParams.get('keyword')).toBe('VCH-DRILL-001');
expect(url.searchParams.get('tab')).toBe('completed');
expect(url.searchParams.get('from')).toBe(`/projects/${projectId}?tab=accounting`);
});
test('accounting project link returns to PAGE-08 and preserves the accounting return path', async ({ page }) => {
await page.route('**/api/v1/**', fulfillApi);
await page.goto('/finance/accounting?tab=completed');
await expect(page.getByText('VCH-DRILL-001', { exact: true })).toBeVisible();
await page.getByRole('link', { name: 'P-DRILL-001 穿透验证项目', exact: true }).click();
await expect(page).toHaveURL(new RegExp(`/projects/${projectId}\\?`));
const url = new URL(page.url());
expect(url.searchParams.get('tab')).toBe('accounting');
expect(url.searchParams.get('from')).toContain('/finance/accounting');
await expect(page.getByRole('heading', { name: '穿透验证项目', exact: true })).toBeVisible();
});
test('timeline uses the paged PAGE-08 subresource beyond the first 20 records', async ({ page }) => {
await openProject(page, 'timeline');
await expect(page.getByText('共 21 条操作记录', { exact: true })).toBeVisible();
await expect(page.getByText('第一页操作记录', { exact: true })).toBeVisible();
await page.locator('.t-pagination__number').filter({ hasText: /^2$/ }).click();
await expect(page.getByText('第二页操作记录', { exact: true })).toBeVisible();
await expect(page).toHaveURL(new RegExp(`/projects/${projectId}\\?tab=timeline`));
});
+249
View File
@@ -0,0 +1,249 @@
import { Buffer } from 'node:buffer';
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import type { Page, Route } from '@playwright/test';
import { expect, test } from '@playwright/test';
const companyId = '01M00000000000000000000501';
const customerId = '01M00000000000000000000502';
const ownerId = '01M00000000000000000000503';
const projectId = '01M00000000000000000000504';
const importedProjectId = '01M00000000000000000000505';
const sourceFileId = '01M00000000000000000000506';
const importId = '01M00000000000000000000507';
const sourceFileSha256 = 'a'.repeat(64);
function envelope(data: unknown, totalElements?: number) {
return {
data,
...(typeof totalElements === 'number'
? { meta: { page: 1, size: 20, totalElements, totalPages: totalElements ? 1 : 0 } }
: {}),
requestId: '01M00000000000000000000599',
};
}
function session() {
return {
authenticated: true,
user: {
publicId: '01M00000000000000000000508',
username: 'project-e2e',
displayName: '项目管理员',
departmentName: '项目部',
mustChangePassword: false,
},
roles: [{ code: 'PROJECT_MANAGER', name: '项目管理人员', workbenchRoute: '/workbench/project' }],
activeRole: 'PROJECT_MANAGER',
permissions: ['project:project:view', 'project:project:create', 'project:project:export', 'file:upload'],
};
}
function project(publicId = projectId, businessNo = 'XM-E2E-007', name = '筛选项目验收') {
return {
publicId,
businessNo,
name,
company: { publicId: companyId, name: '凯迪测试公司' },
customer: { publicId: customerId, name: '测试客户' },
owner: { publicId: ownerId, name: '项目负责人' },
contractAmount: '1000.00',
receivedAmount: '800.00',
paidAmount: '300.00',
currency: 'CNY',
stage: 'EXECUTION',
financeStatus: 'IN_PROGRESS',
archiveStatus: 'PREPARING',
status: 'ACTIVE',
pendingTaskCount: 2,
documentCompleteness: 75,
startDate: '2026-08-01',
endDate: '2026-12-31',
version: 1,
updatedAt: '2026-08-16T08:00:00Z',
allowedActions: ['EDIT'],
};
}
interface FixtureOptions {
projectQueries: URLSearchParams[];
exportRequests: Array<Record<string, unknown>>;
importRequests: Array<Record<string, unknown>>;
confirmRequests: Array<Record<string, unknown>>;
commandHeaders: Array<Record<string, string>>;
}
async function installFixture(page: Page, options: FixtureOptions) {
const userSession = session();
let projects = [project()];
const exportContent = '项目编号,项目名称\nXM-E2E-007,筛选项目验收\n';
const exportSha256 = createHash('sha256').update(exportContent).digest('hex');
await page.route('**/api/v1/**', async (route: Route) => {
const request = route.request();
const url = new URL(request.url());
const pathname = url.pathname;
if (pathname === '/api/v1/auth/session') return route.fulfill({ json: envelope(userSession) });
if (pathname === '/api/v1/auth/profile') return route.fulfill({ json: envelope(userSession.user) });
if (pathname === '/api/v1/auth/roles') return route.fulfill({ json: envelope(userSession.roles) });
if (pathname === '/api/v1/auth/csrf') {
return route.fulfill({
headers: { 'set-cookie': 'XSRF-TOKEN=project-e2e-csrf; Path=/' },
json: envelope({ token: 'project-e2e-csrf' }),
});
}
if (request.method() === 'GET' && pathname === '/api/v1/projects/references') {
return route.fulfill({
json: envelope({
companies: [{ publicId: companyId, name: '凯迪测试公司' }],
customers: [{ publicId: customerId, name: '测试客户' }],
owners: [{ publicId: ownerId, name: '项目负责人' }],
}),
});
}
if (request.method() === 'GET' && pathname === '/api/v1/projects') {
options.projectQueries.push(new URLSearchParams(url.searchParams));
return route.fulfill({ json: envelope(projects, projects.length) });
}
if (request.method() === 'POST' && pathname === '/api/v1/projects/exports') {
options.exportRequests.push(request.postDataJSON() as Record<string, unknown>);
options.commandHeaders.push(request.headers());
return route.fulfill({
json: envelope({
exportId: 'EXP-E2E-007',
rowCount: 1,
sha256: exportSha256,
filterHash: 'b'.repeat(64),
columns: ['businessNo', 'name'],
fileName: '项目导出-E2E.csv',
content: exportContent,
}),
});
}
if (request.method() === 'POST' && pathname === '/api/v1/files') {
return route.fulfill({
json: envelope({
publicId: sourceFileId,
originalName: 'projects.csv',
mediaType: 'text/csv',
extension: 'csv',
sizeBytes: 160,
sha256: sourceFileSha256,
scanStatus: 'AVAILABLE',
createdAt: '2026-08-16T08:10:00Z',
}),
});
}
if (request.method() === 'POST' && pathname === '/api/v1/projects/imports') {
options.importRequests.push(request.postDataJSON() as Record<string, unknown>);
options.commandHeaders.push(request.headers());
return route.fulfill({
json: envelope({
publicId: importId,
batchNo: 'IMP-E2E-007',
sourceFileId,
fileSha256: sourceFileSha256,
totalRows: 1,
validRows: 1,
errorRows: 0,
status: 'READY',
version: 0,
createdAt: '2026-08-16T08:11:00Z',
updatedAt: '2026-08-16T08:11:00Z',
issues: [],
}),
});
}
if (request.method() === 'POST' && pathname === `/api/v1/projects/imports/${importId}/confirm`) {
options.confirmRequests.push(request.postDataJSON() as Record<string, unknown>);
options.commandHeaders.push(request.headers());
const imported = project(importedProjectId, 'XM-IMP-007', '筛选项目批量导入');
projects = [project(), imported];
return route.fulfill({ json: envelope([imported]) });
}
if (request.method() === 'GET') return route.fulfill({ json: envelope([], 0) });
return route.fulfill({ status: 204 });
});
}
test('PAGE-07 filters, verifies an export, and confirms a validated CSV import', async ({ page }) => {
const projectQueries: URLSearchParams[] = [];
const exportRequests: Array<Record<string, unknown>> = [];
const importRequests: Array<Record<string, unknown>> = [];
const confirmRequests: Array<Record<string, unknown>> = [];
const commandHeaders: Array<Record<string, string>> = [];
await installFixture(page, { projectQueries, exportRequests, importRequests, confirmRequests, commandHeaders });
await page.goto('/projects');
await expect(page.getByRole('heading', { name: '项目列表', exact: true })).toBeVisible();
await expect(page.getByText('XM-E2E-007', { exact: true }).first()).toBeVisible();
await page.getByPlaceholder('项目编号或名称', { exact: true }).fill('筛选项目');
await page.getByPlaceholder('全部公司', { exact: true }).click();
await page.locator('.t-select-option').filter({ hasText: '凯迪测试公司' }).click();
await page.getByPlaceholder('全部阶段', { exact: true }).click();
await page.locator('.t-select-option').filter({ hasText: '执行' }).click();
await page.getByRole('textbox', { name: '全部状态', exact: true }).nth(2).click();
await page.locator('.t-select-option').filter({ hasText: '进行中' }).click();
await page.getByRole('button', { name: '查询', exact: true }).click();
await expect.poll(() => projectQueries.length).toBeGreaterThan(1);
const filteredQuery = projectQueries.at(-1)!;
expect(filteredQuery.get('keyword')).toBe('筛选项目');
expect(filteredQuery.get('companyId')).toBe(companyId);
expect(filteredQuery.get('stage')).toBe('EXECUTION');
expect(filteredQuery.get('status')).toBe('ACTIVE');
expect(filteredQuery.get('sort')).toBe('updatedAt,desc');
expect(filteredQuery.get('page')).toBe('1');
expect(filteredQuery.get('size')).toBe('20');
await expect(page).toHaveURL(/keyword=%E7%AD%9B%E9%80%89%E9%A1%B9%E7%9B%AE/);
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: '导出当前结果', exact: true }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('项目导出-E2E.csv');
const downloadPath = await download.path();
expect(downloadPath).toBeTruthy();
expect(await readFile(downloadPath!, 'utf8')).toBe('项目编号,项目名称\nXM-E2E-007,筛选项目验收\n');
await expect.poll(() => exportRequests.length).toBe(1);
expect(exportRequests[0]).toMatchObject({
keyword: '筛选项目',
companyId,
stage: 'EXECUTION',
status: 'ACTIVE',
sort: 'updatedAt,desc',
});
expect(exportRequests[0]).not.toHaveProperty('page');
expect(exportRequests[0]).not.toHaveProperty('size');
expect(exportRequests[0].columns).toHaveLength(11);
expect(commandHeaders[0]['x-xsrf-token']).toBe('project-e2e-csrf');
const initialListRequests = projectQueries.length;
await page.getByRole('button', { name: '导入项目', exact: true }).click();
const dialog = page.locator('.t-dialog').filter({ hasText: '批量导入项目' });
await dialog.locator('input[type="file"]').setInputFiles({
name: 'projects.csv',
mimeType: 'text/csv',
buffer: Buffer.from(
'项目编号,项目名称,公司名称或公共ID,负责人名称或公共ID,币种\nXM-IMP-007,筛选项目批量导入,凯迪测试公司,项目负责人,CNY\n',
),
});
await dialog.getByRole('button', { name: '上传并校验', exact: true }).click();
await expect(dialog.getByText(/批次 IMP-E2E-007:共 1 行,有效 1 行,错误 0 行。/)).toBeVisible();
await expect.poll(() => importRequests.length).toBe(1);
expect(importRequests[0]).toEqual({ sourceFileId, fileSha256: sourceFileSha256 });
expect(commandHeaders[1]['x-xsrf-token']).toBe('project-e2e-csrf');
expect(commandHeaders[1]['idempotency-key']).toBeTruthy();
await dialog.getByRole('button', { name: '确认创建 1 个项目', exact: true }).click();
await expect.poll(() => confirmRequests.length).toBe(1);
expect(confirmRequests[0]).toEqual({ version: 0 });
expect(commandHeaders[2]['x-xsrf-token']).toBe('project-e2e-csrf');
expect(commandHeaders[2]['idempotency-key']).toBeTruthy();
expect(commandHeaders[2]['idempotency-key']).not.toBe(commandHeaders[1]['idempotency-key']);
await expect(dialog).toBeHidden();
await expect.poll(() => projectQueries.length).toBeGreaterThan(initialListRequests);
await expect(page.getByText('XM-IMP-007', { exact: true }).first()).toBeVisible();
await expect(page.getByText('已创建 1 个项目草稿', { exact: true })).toBeVisible();
});
+264
View File
@@ -0,0 +1,264 @@
import type { Page, Route } from '@playwright/test';
import { expect, test } from '@playwright/test';
const projectId = '01M00000000000000000000101';
const receivableId = '01M00000000000000000000102';
const invoiceId = '01M00000000000000000000103';
const sourceDocumentId = '01M00000000000000000000108';
function envelope(data: unknown, totalElements?: number) {
return {
data,
...(typeof totalElements === 'number'
? { meta: { page: 1, size: 20, totalElements, totalPages: totalElements ? 1 : 0 } }
: {}),
requestId: '01M00000000000000000000199',
};
}
function session() {
return {
authenticated: true,
user: {
publicId: '01M00000000000000000000104',
username: 'finance-e2e',
displayName: '财务验收用户',
departmentName: '财务部',
mustChangePassword: false,
},
roles: [{ code: 'FINANCE_MANAGER', name: '财务管理人员', workbenchRoute: '/workbench/finance' }],
activeRole: 'FINANCE_MANAGER',
permissions: [
'receivable:receipt:view',
'receivable:receipt:create',
'receivable:receipt:export',
'receivable:invoice:view',
'receivable:invoice:create',
'receivable:invoice:export',
'receivable:invoice:review',
'source:form:view',
],
};
}
function receivable() {
return {
publicId: receivableId,
businessNo: 'REC-E2E-001',
company: { publicId: '01M00000000000000000000105', code: 'C-E2E', name: '凯迪测试公司' },
project: { publicId: projectId, code: 'P-E2E', name: '应收穿透项目' },
contract: { publicId: '01M00000000000000000000106', code: 'HT-E2E', name: '测试合同' },
counterparty: { publicId: '01M00000000000000000000107', code: 'KH-E2E', name: '测试客户' },
businessDate: '2026-08-15',
amount: '125000.00',
settledAmount: '25000.00',
outstandingAmount: '100000.00',
currency: 'CNY',
status: 'PARTIALLY_SETTLED',
sourceInvoiceId: invoiceId,
version: 1,
createdAt: '2026-08-15T08:00:00Z',
updatedAt: '2026-08-16T08:00:00Z',
};
}
function invoice(overrides: Record<string, unknown> = {}) {
return {
publicId: invoiceId,
businessNo: 'INV-E2E-001',
company: receivable().company,
project: receivable().project,
contract: receivable().contract,
counterparty: receivable().counterparty,
invoiceType: 'SPECIAL',
amount: '125000.00',
currency: 'CNY',
taxRate: '0.13',
requestedDate: '2026-08-15',
invoiceNo: 'FP-E2E-001',
issuedDate: '2026-08-15',
status: 'ISSUED',
contractLimit: '500000.00',
contractCommittedAmount: '125000.00',
contractAvailableAmount: '375000.00',
version: 2,
createdAt: '2026-08-15T08:00:00Z',
updatedAt: '2026-08-16T08:00:00Z',
allowedActions: [],
...overrides,
};
}
async function installFixture(page: Page, receivableQueries: URLSearchParams[]) {
const userSession = session();
await page.route('**/api/v1/**', async (route: Route) => {
const request = route.request();
const url = new URL(request.url());
const pathname = url.pathname;
if (pathname === '/api/v1/auth/session') return route.fulfill({ json: envelope(userSession) });
if (pathname === '/api/v1/auth/profile') return route.fulfill({ json: envelope(userSession.user) });
if (pathname === '/api/v1/auth/roles') return route.fulfill({ json: envelope(userSession.roles) });
if (pathname === '/api/v1/receivables/references') {
return route.fulfill({
json: envelope({
companies: [receivable().company],
projects: [receivable().project],
counterparties: [receivable().counterparty],
contracts: [receivable().contract],
receivables: [],
}),
});
}
if (pathname === '/api/v1/receivables') {
receivableQueries.push(url.searchParams);
return route.fulfill({ json: envelope([receivable()], 1) });
}
if (pathname === `/api/v1/invoices/${invoiceId}`) return route.fulfill({ json: envelope(invoice()) });
if (request.method() === 'GET') return route.fulfill({ json: envelope([], 0) });
return route.fulfill({ status: 204 });
});
}
async function installInvoiceReviewFixture(
page: Page,
invoiceQueries: URLSearchParams[],
reviewRequests: Array<Record<string, unknown>>,
reviewHeaders: Array<Record<string, string>>,
) {
const userSession = session();
let currentInvoice = invoice({
businessNo: 'INV-E2E-RETURN-001',
sourceDocumentId,
sourceVersionId: '01M00000000000000000000109',
invoiceNo: null,
issuedDate: null,
status: 'REVIEWING',
version: 7,
allowedActions: ['REVIEW'],
});
await page.route('**/api/v1/**', async (route: Route) => {
const request = route.request();
const url = new URL(request.url());
const pathname = url.pathname;
if (pathname === '/api/v1/auth/session') return route.fulfill({ json: envelope(userSession) });
if (pathname === '/api/v1/auth/profile') return route.fulfill({ json: envelope(userSession.user) });
if (pathname === '/api/v1/auth/roles') return route.fulfill({ json: envelope(userSession.roles) });
if (pathname === '/api/v1/auth/csrf') {
return route.fulfill({
headers: { 'set-cookie': 'XSRF-TOKEN=page14-e2e; Path=/' },
json: envelope({ token: 'page14-e2e' }),
});
}
if (request.method() === 'GET' && pathname === '/api/v1/receivables/references') {
return route.fulfill({
json: envelope({
companies: [receivable().company],
projects: [receivable().project],
counterparties: [receivable().counterparty],
contracts: [receivable().contract],
receivables: [],
}),
});
}
if (request.method() === 'GET' && pathname === '/api/v1/invoices') {
invoiceQueries.push(new URLSearchParams(url.searchParams));
return route.fulfill({ json: envelope([currentInvoice], 1) });
}
if (request.method() === 'GET' && pathname === `/api/v1/invoices/${invoiceId}`) {
return route.fulfill({ json: envelope(currentInvoice) });
}
if (request.method() === 'POST' && pathname === `/api/v1/invoices/${invoiceId}/review`) {
const payload = request.postDataJSON() as Record<string, unknown>;
reviewRequests.push(payload);
reviewHeaders.push(request.headers());
currentInvoice = invoice({
...currentInvoice,
status: 'RETURNED',
version: 8,
reviewOpinion: payload.opinion,
allowedActions: [],
});
return route.fulfill({ json: envelope(currentInvoice) });
}
if (request.method() === 'GET') return route.fulfill({ json: envelope([], 0) });
return route.fulfill({ status: 204 });
});
}
test('PAGE-14 shows the project receivable and drills into its exact source invoice', async ({ page }) => {
const receivableQueries: URLSearchParams[] = [];
await installFixture(page, receivableQueries);
await page.goto(`/finance/receipts-invoices?tab=receivables&projectId=${projectId}&keyword=REC-E2E-001`);
await expect(page.getByRole('heading', { name: '收款与发票', exact: true })).toBeVisible();
await expect.poll(() => receivableQueries.length).toBeGreaterThan(0);
const query = receivableQueries.at(-1)!;
expect(query.get('projectId')).toBe(projectId);
expect(query.get('keyword')).toBe('REC-E2E-001');
expect(query.get('sort')).toBe('updatedAt,desc');
await expect(page.getByRole('button', { name: '导出', exact: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: '登记收款', exact: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: '新建开票申请', exact: true })).toHaveCount(0);
await expect(page.getByText('100,000.00', { exact: true })).toBeVisible();
await expect(page.getByText('部分核销', { exact: true })).toBeVisible();
await page.getByRole('button', { name: '查看', exact: true }).click();
const drawer = page.locator('.t-drawer');
await expect(drawer.getByText('应收金额', { exact: true })).toBeVisible();
await expect(drawer.getByText('未收金额', { exact: true })).toBeVisible();
await drawer.getByRole('button', { name: '查看来源发票', exact: true }).click();
await expect(drawer.locator('.t-drawer__header')).toHaveText('INV-E2E-001');
await expect(drawer.getByText('FP-E2E-001', { exact: true })).toBeVisible();
await expect(drawer.getByText(/13%/)).toBeVisible();
});
test('PAGE-14 returns an OA-08 invoice with exact correction fields', async ({ page }) => {
const invoiceQueries: URLSearchParams[] = [];
const reviewRequests: Array<Record<string, unknown>> = [];
const reviewHeaders: Array<Record<string, string>> = [];
await installInvoiceReviewFixture(page, invoiceQueries, reviewRequests, reviewHeaders);
await page.goto('/finance/receipts-invoices?tab=invoices');
await expect(page.getByText('INV-E2E-RETURN-001', { exact: true }).first()).toBeVisible();
await expect.poll(() => invoiceQueries.length).toBeGreaterThan(0);
const initialListRequests = invoiceQueries.length;
await page.getByRole('button', { name: '更多操作', exact: true }).click();
await page.getByText('审核申请', { exact: true }).click();
const dialog = page.locator('.t-dialog').filter({ hasText: '审核申请' });
await dialog.getByText('退回补件', { exact: true }).click();
await expect(dialog.getByText('需要更正的来源字段', { exact: true })).toBeVisible();
await expect(dialog.getByText('驳回', { exact: true })).toHaveCount(0);
await dialog.getByRole('button', { name: '确认', exact: true }).click();
await expect(page.locator('.t-message').filter({ hasText: '请填写审核意见' })).toBeVisible();
await dialog.locator('textarea').fill('请更正本次开票金额和开票要求');
await dialog.getByRole('button', { name: '确认', exact: true }).click();
await expect(page.locator('.t-message').filter({ hasText: '请选择至少一个需要更正的来源字段' })).toBeVisible();
await dialog.locator('label.t-checkbox').filter({ hasText: '本次开票金额' }).click();
await dialog.locator('label.t-checkbox').filter({ hasText: '开票要求' }).click();
await dialog.getByRole('button', { name: '确认', exact: true }).click();
await expect.poll(() => reviewRequests.length).toBe(1);
expect(reviewRequests[0]).toEqual({
version: 7,
decision: 'RETURN',
opinion: '请更正本次开票金额和开票要求',
returnPoints: ['invoiceAmount', 'invoiceRequirements'],
});
expect(reviewHeaders[0]['x-xsrf-token']).toBe('page14-e2e');
expect(reviewHeaders[0]['idempotency-key']).toBeTruthy();
await expect(dialog).toBeHidden();
await expect.poll(() => invoiceQueries.length).toBeGreaterThan(initialListRequests);
await expect(page.getByText('已退回', { exact: true })).toBeVisible();
await page.getByRole('button', { name: '查看', exact: true }).click();
const drawer = page.locator('.t-drawer');
await expect(drawer.getByText('该候选已退回来源表单,需由原申请人更正并重新完成审批。')).toBeVisible();
await drawer.getByRole('button', { name: '查看来源表单', exact: true }).click();
await expect(page).toHaveURL(`/forms/OA-08/${sourceDocumentId}`);
});
+76
View File
@@ -0,0 +1,76 @@
import { expect, test } from '@playwright/test';
test('first-run wizard tests MySQL, creates the administrator and stays responsive', async ({ page }) => {
let completed = false;
await page.route('**/api/v1/setup/**', async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (pathname === '/api/v1/setup/status') {
await route.fulfill({
json: {
data: {
required: !completed,
locked: completed,
supportedDatabaseTypes: ['MYSQL'],
message: completed ? '系统已完成安装' : '请完成安装',
},
},
});
return;
}
const body = request.postDataJSON() as Record<string, unknown>;
expect(body.databaseType).toBe('MYSQL');
expect(body.database).toBe('kaidi_finance');
expect(body.setupCode).toBe('fixture-setup-code');
if (pathname.endsWith('/test-connection')) {
await route.fulfill({
json: {
data: {
successful: true,
databaseType: 'MYSQL',
serverVersion: '8.4.6',
schemaReady: true,
message: 'MySQL 8.4 连接和 DDL/DML 权限验证通过',
},
},
});
return;
}
expect(body.adminUsername).toBe('admin');
expect(body.adminDisplayName).toBe('系统管理员');
completed = true;
await route.fulfill({
json: {
data: {
completed: true,
username: 'admin',
message: '安装完成,系统正在切换到正式模式',
restartAfterSeconds: 10,
},
},
});
});
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/setup');
await expect(page.getByRole('heading', { name: '安装向导', exact: true })).toBeVisible();
await page.getByLabel('数据库密码').fill('fixture-db-password');
await page.getByLabel('安装码').fill('fixture-setup-code');
await page.getByRole('button', { name: '测试连接' }).click();
await expect(page.getByText('MySQL 8.4 连接和 DDL/DML 权限验证通过')).toBeVisible();
await page.getByRole('button', { name: '下一步' }).click();
await page.getByLabel('管理员密码').fill('SetupAdmin@2026Strong');
await page.getByLabel('确认密码').fill('SetupAdmin@2026Strong');
await page.getByRole('button', { name: '完成安装' }).click();
await page.getByRole('button', { name: '确定', exact: true }).click();
await expect(page.getByText('安装完成', { exact: true })).toBeVisible();
await expect(page.getByText('安装完成,系统正在切换到正式模式')).toBeVisible();
const layout = await page.evaluate(() => ({
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
}));
expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth);
});
+218
View File
@@ -0,0 +1,218 @@
import type { Page, Route } from '@playwright/test';
import { expect, test } from '@playwright/test';
const permissions = ['admin:user:view', 'admin:user:create', 'admin:update:view', 'admin:update:execute'];
function envelope(data: unknown) {
return { data, requestId: '01M00000000000000000000090' };
}
function session() {
return {
authenticated: true,
user: {
publicId: '01M00000000000000000000001',
username: 'admin',
displayName: '系统管理员',
departmentName: '信息中心',
mustChangePassword: false,
},
roles: [{ code: 'SYSTEM_ADMIN', name: '系统管理员', workbenchRoute: '/governance/settings' }],
activeRole: 'SYSTEM_ADMIN',
permissions,
};
}
function updateView(state: string, checked: boolean, enabled = true, recoveryPending = false) {
const busy = [
'DOWNLOAD_QUEUED',
'INSTALL_QUEUED',
'VERIFYING',
'DOWNLOADING',
'BACKING_UP',
'INSTALLING',
'RUNNING',
].includes(state);
return {
enabled,
currentVersion: '1.0.0-preview.1',
latestVersion: checked ? '1.0.0-preview.2' : null,
updateAvailable: checked && !['CURRENT', 'SUCCEEDED'].includes(state),
state,
message: !enabled
? '在线更新未配置'
: recoveryPending
? '回滚未完成,更新服务将重试'
: state === 'DOWNLOAD_QUEUED'
? '下载请求已排队'
: state === 'INSTALL_QUEUED'
? '安装请求已排队'
: state === 'READY'
? '更新包已下载并通过签名校验'
: state === 'SUCCEEDED'
? '新版本已通过健康检查'
: checked
? '发现新版本'
: '尚未执行在线更新',
releaseNotes: checked ? 'Preview update' : null,
publishedAt: checked ? '2026-08-16T00:00:00Z' : null,
checkedAt: checked ? '2026-08-16T00:01:00Z' : null,
statusUpdatedAt: null,
allowedActions:
!enabled || recoveryPending || busy
? []
: state === 'READY'
? ['CHECK', 'INSTALL']
: checked
? ['CHECK', 'DOWNLOAD']
: ['CHECK'],
};
}
async function installFixture(
page: Page,
downloadBodies: unknown[],
installBodies: unknown[],
enabled = true,
recoveryPending = false,
completeAfterInstall = false,
initialState?: string,
completeBusyAfterFirstRead = false,
) {
let checked = recoveryPending || Boolean(initialState);
let state = recoveryPending ? 'FAILED' : initialState || 'IDLE';
const history: Array<Record<string, unknown>> = [];
const recordHistory = (actionCode: string, targetVersion = '1.0.0-preview.2') => {
history.unshift({
publicId: `01M00000000000000000000${String(history.length + 1).padStart(3, '0')}`,
eventSequence: history.length + 1,
requestId: '01M00000000000000000000092',
username: 'admin',
activeRole: 'SYSTEM_ADMIN',
actionCode,
objectType: 'SYSTEM_UPDATE',
objectPublicId: 'SYSTEM_UPDATE',
resultCode: 'SUCCESS',
reason: actionCode === 'SYSTEM_UPDATE_REQUEST' ? '管理员确认立即重启' : null,
beforeJson: null,
afterJson: JSON.stringify({ targetVersion }),
occurredAt: '2026-08-16T00:02:00Z',
allowedActions: [],
});
};
await page.route('**/api/v1/**', async (route: Route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (pathname === '/api/v1/auth/session') return route.fulfill({ json: envelope(session()) });
if (pathname === '/api/v1/auth/profile') return route.fulfill({ json: envelope(session().user) });
if (pathname === '/api/v1/auth/roles') return route.fulfill({ json: envelope(session().roles) });
if (pathname === '/api/v1/auth/csrf') {
return route.fulfill({
headers: { 'set-cookie': 'XSRF-TOKEN=update-csrf; Path=/; SameSite=Lax' },
json: envelope({ token: 'update-csrf' }),
});
}
if (pathname === '/api/v1/admin/users') {
return route.fulfill({
json: {
data: [],
meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 },
requestId: '01M00000000000000000000091',
},
});
}
if (pathname === '/api/v1/admin/system-update' && request.method() === 'GET') {
const response = updateView(state, checked, enabled, recoveryPending);
if (completeBusyAfterFirstRead && state === 'INSTALLING') state = 'SUCCEEDED';
return route.fulfill({ json: envelope(response) });
}
if (pathname === '/api/v1/admin/system-update/check' && request.method() === 'POST') {
checked = true;
recordHistory('SYSTEM_UPDATE_CHECK');
return route.fulfill({ json: envelope(updateView(state, checked, enabled, recoveryPending)) });
}
if (pathname === '/api/v1/admin/system-update/download' && request.method() === 'POST') {
downloadBodies.push(request.postDataJSON());
recordHistory('SYSTEM_UPDATE_DOWNLOAD_REQUEST');
state = 'READY';
return route.fulfill({ json: envelope(updateView('DOWNLOAD_QUEUED', true, enabled)) });
}
if (pathname === '/api/v1/admin/system-update/install' && request.method() === 'POST') {
installBodies.push(request.postDataJSON());
recordHistory('SYSTEM_UPDATE_REQUEST');
const queued = updateView('INSTALL_QUEUED', true, enabled);
state = completeAfterInstall ? 'SUCCEEDED' : 'INSTALL_QUEUED';
return route.fulfill({ json: envelope(queued) });
}
if (pathname === '/api/v1/audit/logs' && request.method() === 'GET') {
return route.fulfill({
json: {
data: history,
meta: { page: 1, size: 20, totalElements: history.length, totalPages: history.length ? 1 : 0 },
requestId: '01M00000000000000000000093',
},
});
}
if (request.method() === 'GET') return route.fulfill({ json: envelope([]) });
return route.fulfill({ status: 204 });
});
}
test('system administrator checks and queues a signed online update', async ({ page }) => {
const downloadBodies: unknown[] = [];
const installBodies: unknown[] = [];
await installFixture(page, downloadBodies, installBodies);
await page.goto('/governance/update');
await expect(page.locator('h1.page-title-sr-only')).toHaveText('系统更新');
await expect(page.locator('.version-item').filter({ hasText: '当前版本' })).toContainText('1.0.0-preview.1');
await page.getByRole('button', { name: '获取更新', exact: true }).click();
await expect(page.locator('.version-item').filter({ hasText: '最新版本' })).toContainText('1.0.0-preview.2');
await expect(page.getByRole('button', { name: '立即重启', exact: true })).toBeVisible({ timeout: 5_000 });
expect(downloadBodies).toEqual([{ version: '1.0.0-preview.2' }]);
await expect(page.getByText('下载更新包', { exact: true })).toBeVisible();
await page.getByRole('button', { name: '立即重启', exact: true }).click();
await expect(page.getByText('安装请求已排队', { exact: true })).toBeVisible();
expect(installBodies).toEqual([{ version: '1.0.0-preview.2', reason: '管理员确认立即重启' }]);
});
test('disabled online updates expose status without an executable check action', async ({ page }) => {
await installFixture(page, [], [], false);
await page.goto('/governance/update');
await expect(page.getByText('在线更新未配置', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '获取更新', exact: true })).toBeDisabled();
await expect(page.getByRole('button', { name: '立即重启', exact: true })).toHaveCount(0);
});
test('rollback recovery keeps the failure visible without an install action', async ({ page }) => {
await installFixture(page, [], [], true, true);
await page.goto('/governance/update');
await expect(page.getByText('回滚未完成,更新服务将重试', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '立即重启', exact: true })).toHaveCount(0);
});
test('successful installation starts the ten-second automatic refresh countdown', async ({ page }) => {
await installFixture(page, [], [], true, false, true);
await page.goto('/governance/update');
await page.getByRole('button', { name: '获取更新', exact: true }).click();
await page.getByRole('button', { name: '立即重启', exact: true }).click({ timeout: 5_000 });
await expect(page.getByText('新版本已通过健康检查,页面将在 10 秒后自动刷新。', { exact: true })).toBeVisible({
timeout: 5_000,
});
});
test('reloading during installation resumes polling and starts the refresh countdown', async ({ page }) => {
await installFixture(page, [], [], true, false, false, 'INSTALLING', true);
await page.goto('/governance/update');
await expect(page.getByText('安装中', { exact: true })).toBeVisible();
await expect(page.getByText('新版本已通过健康检查,页面将在 10 秒后自动刷新。', { exact: true })).toBeVisible({
timeout: 6_000,
});
});
+368
View File
@@ -0,0 +1,368 @@
import type { Page, Route } from '@playwright/test';
import { expect, test } from '@playwright/test';
const taskId = '01M00000000000000000000190';
const instanceId = '01M00000000000000000000191';
const formId = '01M00000000000000000000192';
const companyId = '01M00000000000000000000193';
const projectId = '01M00000000000000000000194';
interface TaskFixtureOptions {
view?: 'TODO' | 'CREATED';
taskActions?: string[];
formActions?: string[];
approveRequests?: Array<Record<string, unknown>>;
returnRequests?: Array<Record<string, unknown>>;
withdrawRequests?: Array<Record<string, unknown>>;
voidRequests?: Array<Record<string, unknown>>;
detailRequests?: string[];
detailTaskVersion?: number;
detailField?: { code: string; label: string; dataType?: string };
taskQueries?: URL[];
}
function sourceForm(allowedActions: string[] = []) {
return {
publicId: formId,
businessNo: 'OA05-E2E-001',
formType: 'OA-05',
formName: '收款确认单',
templateVersion: 2,
sourceSystem: 'ONLINE',
sourceNo: 'SRC-E2E-001',
sourceVersion: '1',
company: { publicId: companyId, name: '凯迪测试公司' },
project: { publicId: projectId, name: '审批验收项目' },
applicantName: '项目申请人',
businessDate: '2026-08-15',
status: 'PROJECT_REVIEW',
currentNodeCode: 'PROJECT_REVIEW',
currentNodeName: '项目复核',
version: 1,
createdByName: '项目申请人',
createdAt: '2026-08-15T08:00:00Z',
updatedAt: '2026-08-15T08:00:00Z',
allowedActions,
};
}
function workflowTask(taskActions: string[], formActions: string[]) {
return {
publicId: taskId,
instancePublicId: instanceId,
nodeCode: 'PROJECT_REVIEW',
nodeName: '项目复核',
sequenceNo: 1,
status: 'PENDING',
version: 0,
assigneeName: '项目审批人',
arrivedAt: '2026-08-15T08:01:00Z',
dueAt: '2026-08-18T08:01:00Z',
updatedAt: '2026-08-15T08:01:00Z',
keyAmount: '1000.00',
form: sourceForm(formActions),
allowedActions: taskActions,
};
}
function taskDetail(
taskActions: string[],
formActions: string[],
taskVersion = 0,
detailField: { code: string; label: string; dataType?: string } = {
code: 'receiptAmount',
label: '收款金额',
dataType: 'DECIMAL',
},
) {
const task = workflowTask(taskActions, formActions);
task.version = taskVersion;
return {
task,
form: {
summary: task.form,
documentVersion: 1,
snapshotSha256: 'a'.repeat(64),
values: { receiptAmount: '1000.00' },
fields: [
{
code: detailField.code,
label: detailField.label,
sectionCode: 'base',
sectionName: '基本信息',
dataType: detailField.dataType || 'DECIMAL',
required: true,
repeatable: false,
options: [],
sensitivity: 'NORMAL',
sortOrder: 1,
},
],
approvals: [],
validation: {
documentId: formId,
version: 1,
valid: true,
canSubmit: false,
errors: [],
warnings: [],
validatedAt: '2026-08-15T08:00:30Z',
},
},
timeline: [],
};
}
function session() {
return {
authenticated: true,
user: {
publicId: '01M00000000000000000000195',
username: 'task-e2e',
displayName: '审批验收用户',
departmentName: '项目部',
mustChangePassword: false,
},
roles: [{ code: 'PROJECT_MANAGER', name: '项目管理人员', workbenchRoute: '/workbench/project' }],
activeRole: 'PROJECT_MANAGER',
permissions: ['workflow:task:view', 'workflow:task:approve', 'source:form:withdraw', 'source:form:void'],
};
}
async function installTaskFixture(page: Page, options: TaskFixtureOptions = {}) {
const userSession = session();
let taskActions = options.taskActions || [];
let formActions = options.formActions || [];
const currentTaskDetail = () => taskDetail(taskActions, formActions, options.detailTaskVersion, options.detailField);
await page.route('**/api/v1/**', async (route: Route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (pathname === '/api/v1/auth/session') {
await route.fulfill({ json: { data: userSession, requestId: '01M00000000000000000000196' } });
return;
}
if (pathname === '/api/v1/auth/profile') {
await route.fulfill({ json: { data: userSession.user, requestId: '01M00000000000000000000197' } });
return;
}
if (pathname === '/api/v1/auth/roles') {
await route.fulfill({ json: { data: userSession.roles, requestId: '01M00000000000000000000198' } });
return;
}
if (pathname === '/api/v1/auth/csrf') {
await route.fulfill({
headers: { 'set-cookie': 'XSRF-TOKEN=task-e2e-csrf; Path=/' },
json: { data: { token: 'task-e2e-csrf' }, requestId: '01M00000000000000000000199' },
});
return;
}
if (request.method() === 'GET' && pathname === '/api/v1/source/references') {
await route.fulfill({
json: {
data: {
companies: [{ publicId: companyId, name: '凯迪测试公司' }],
projects: [{ publicId: projectId, name: '审批验收项目' }],
suppliers: [],
customers: [],
contracts: [],
costCategories: [],
},
requestId: '01M00000000000000000000206',
},
});
return;
}
if (request.method() === 'GET' && pathname === '/api/v1/workflow/tasks') {
options.taskQueries?.push(new URL(request.url()));
await route.fulfill({
json: {
data: [workflowTask(taskActions, formActions)],
meta: { page: 1, size: 20, totalElements: 1, totalPages: 1 },
requestId: '01M00000000000000000000200',
},
});
return;
}
if (request.method() === 'GET' && pathname === `/api/v1/workflow/tasks/${taskId}`) {
options.detailRequests?.push(taskId);
await route.fulfill({
json: { data: currentTaskDetail(), requestId: '01M00000000000000000000201' },
});
return;
}
if (request.method() === 'POST' && pathname === `/api/v1/workflow/tasks/${taskId}/approve`) {
options.approveRequests?.push(request.postDataJSON() as Record<string, unknown>);
taskActions = [];
await route.fulfill({
json: { data: currentTaskDetail(), requestId: '01M00000000000000000000202' },
});
return;
}
if (request.method() === 'POST' && pathname === `/api/v1/workflow/tasks/${taskId}/return`) {
options.returnRequests?.push(request.postDataJSON() as Record<string, unknown>);
taskActions = [];
await route.fulfill({
json: { data: currentTaskDetail(), requestId: '01M00000000000000000000207' },
});
return;
}
if (request.method() === 'POST' && pathname === `/api/v1/source/forms/${formId}/withdraw`) {
options.withdrawRequests?.push(request.postDataJSON() as Record<string, unknown>);
formActions = [];
await route.fulfill({
json: { data: currentTaskDetail().form, requestId: '01M00000000000000000000203' },
});
return;
}
if (request.method() === 'POST' && pathname === `/api/v1/source/forms/${formId}/void`) {
options.voidRequests?.push(request.postDataJSON() as Record<string, unknown>);
formActions = [];
await route.fulfill({
json: { data: currentTaskDetail().form, requestId: '01M00000000000000000000204' },
});
return;
}
if (request.method() === 'GET') {
await route.fulfill({
json: {
data: [],
meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 },
requestId: '01M00000000000000000000205',
},
});
return;
}
await route.fulfill({ status: 204 });
});
}
test('PAGE-11 applies project, arrival date, overdue and sort filters and renders the required columns', async ({
page,
}) => {
const taskQueries: URL[] = [];
await installTaskFixture(page, { taskQueries });
await page.goto(
`/tasks?view=TODO&projectId=${projectId}&arrivedDateFrom=2026-08-15&arrivedDateTo=2026-08-16&overdue=true&sort=dueAt%2Casc`,
);
await expect.poll(() => taskQueries.length).toBeGreaterThan(0);
const initialQuery = taskQueries.at(-1)?.searchParams;
expect(initialQuery?.get('projectId')).toBe(projectId);
expect(initialQuery?.get('arrivedDateFrom')).toBe('2026-08-15');
expect(initialQuery?.get('arrivedDateTo')).toBe('2026-08-16');
expect(initialQuery?.get('overdue')).toBe('true');
expect(initialQuery?.get('sort')).toBe('dueAt,asc');
const table = page.locator('.t-table');
await expect(table.getByText('审批验收项目', { exact: true })).toBeVisible();
await expect(table.getByText('项目申请人', { exact: true })).toBeVisible();
await expect(table.getByText('1,000.00 CNY', { exact: true })).toBeVisible();
await expect(table.getByText('当前节点', { exact: true })).toBeVisible();
await page.getByRole('button', { name: '重置', exact: true }).click();
await expect.poll(() => taskQueries.length).toBeGreaterThan(1);
const resetQuery = taskQueries.at(-1)?.searchParams;
expect(resetQuery?.has('projectId')).toBe(false);
expect(resetQuery?.has('arrivedDateFrom')).toBe(false);
expect(resetQuery?.has('arrivedDateTo')).toBe(false);
expect(resetQuery?.has('overdue')).toBe(false);
expect(resetQuery?.get('sort')).toBe('arrivedAt,asc');
});
test('PAGE-11 requires and sends the approver opinion', async ({ page }) => {
const approveRequests: Array<Record<string, unknown>> = [];
await installTaskFixture(page, { taskActions: ['APPROVE', 'RETURN', 'REJECT'], approveRequests });
await page.goto('/tasks?view=TODO');
await expect(page.getByRole('heading', { name: '待办审批', exact: true })).toBeVisible();
await page.getByText('通过', { exact: true }).click();
const dialog = page.locator('.t-dialog').filter({ hasText: '通过审批' });
await dialog.getByRole('button', { name: '确认通过', exact: true }).click();
await expect(dialog.getByText('请填写审批意见', { exact: true })).toBeVisible();
await dialog.locator('textarea').fill('资料与金额核对无误,同意通过');
await dialog.getByRole('button', { name: '确认通过', exact: true }).click();
await expect.poll(() => approveRequests.length).toBe(1);
expect(approveRequests[0]).toEqual({
version: 0,
opinion: '资料与金额核对无误,同意通过',
returnPoints: [],
});
await expect(dialog).toBeHidden();
await expect(page.getByText('审批已通过', { exact: true })).toBeVisible();
});
test('PAGE-11 loads fresh detail and sends selected return points from the list action', async ({ page }) => {
const detailRequests: string[] = [];
const returnRequests: Array<Record<string, unknown>> = [];
await installTaskFixture(page, {
taskActions: ['RETURN'],
detailRequests,
detailTaskVersion: 7,
detailField: { code: 'supportingFile', label: '补充附件', dataType: 'FILE' },
returnRequests,
});
await page.goto('/tasks?view=TODO');
await page.getByText('退回', { exact: true }).click();
await expect.poll(() => detailRequests.length).toBe(1);
const dialog = page.locator('.t-dialog').filter({ hasText: '退回补件' });
const returnPoint = dialog.locator('label.t-checkbox').filter({ hasText: '补充附件(附件)' });
await expect(returnPoint).toBeVisible();
await returnPoint.click();
await dialog.locator('textarea').fill('请补充可核验的原始附件');
await dialog.getByRole('button', { name: '确认退回', exact: true }).click();
await expect.poll(() => returnRequests.length).toBe(1);
expect(returnRequests[0]).toEqual({
version: 7,
opinion: '请补充可核验的原始附件',
returnPoints: ['supportingFile'],
});
await expect.poll(() => detailRequests.length).toBe(2);
await expect(page.getByText('已退回补件', { exact: true })).toBeVisible();
});
test('PAGE-11 exposes and sends withdraw only from form allowed actions', async ({ page }) => {
const withdrawRequests: Array<Record<string, unknown>> = [];
await installTaskFixture(page, {
view: 'CREATED',
formActions: ['WITHDRAW', 'VOID'],
withdrawRequests,
});
await page.goto('/tasks?view=CREATED');
await expect(page.getByText('撤回', { exact: true })).toBeVisible();
await expect(page.getByText('作废', { exact: true })).toBeVisible();
await page.getByText('撤回', { exact: true }).click();
const dialog = page.locator('.t-dialog').filter({ hasText: '撤回申请' });
await dialog.locator('textarea').fill('申请内容需要补充后重新提交');
await dialog.getByRole('button', { name: '确认撤回', exact: true }).click();
await expect.poll(() => withdrawRequests.length).toBe(1);
expect(withdrawRequests[0]).toEqual({ version: 1, opinion: '申请内容需要补充后重新提交' });
await expect(page.getByText('申请已撤回', { exact: true })).toBeVisible();
});
test('PAGE-11 sends void with an explicit reason', async ({ page }) => {
const voidRequests: Array<Record<string, unknown>> = [];
await installTaskFixture(page, { view: 'CREATED', formActions: ['VOID'], voidRequests });
await page.goto('/tasks?view=CREATED');
await expect(page.getByText('撤回', { exact: true })).toHaveCount(0);
await page.getByText('作废', { exact: true }).click();
const dialog = page.locator('.t-dialog').filter({ hasText: '作废申请' });
await dialog.locator('textarea').fill('该申请已由新版本替代');
await dialog.getByRole('button', { name: '确认作废', exact: true }).click();
await expect.poll(() => voidRequests.length).toBe(1);
expect(voidRequests[0]).toEqual({ version: 1, opinion: '该申请已由新版本替代' });
await expect(page.getByText('申请已作废', { exact: true })).toBeVisible();
});