This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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('请稍后重试');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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`));
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user