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; initialEvidence?: Array>; generateRequests?: Array>; updateRequests?: Array>; recordRequests?: Array>; verifyRequests?: Array>; reverseRequests?: Array>; reopenRequests?: Array>; } 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 = {}) { 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> = [], resultActions: Array> = [], ) { 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> = options.initialEvidence || []; const resultActions: Array> = []; 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); 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; 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; 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; 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; 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; 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> = []; 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> = []; 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>; 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> = []; const verifyRequests: Array> = []; 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> = []; const reopenRequests: Array> = []; 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); });