Files
kaidi/frontend/e2e/accounting.e2e.ts
2026-08-17 14:08:40 +08:00

572 lines
22 KiB
TypeScript

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);
});