feat(T+): 完成数据贯通与自动接入闭环

打通 T+ 快照、业务、发货、回款和核算准备链路,保留来源差异并提供待补状态。
新增业务自动接入任务、跨平台读取服务托管、权限复核、来源关联和前端核对页面。
补充迁移、隔离集成测试、浏览器模拟验收及角色授权边界修复。
This commit is contained in:
liuwenxin committed 2026-11-05 18:47:13 +08:00
1 parent 3efe72c344
commit 1e53792ad6
151 files changed
+8842 -401

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
/** 使用隔离模拟接口验证真实页面的批量分类交互。 */
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'C:/Users/Administrator/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
const base = process.env.OPS_UI_URL || 'http://127.0.0.1:5173'
const calls = []
const state = { customers: null, products: null, owner: null }
const browser = await chromium.launch({ headless: true, channel: 'msedge' })
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
const errors = []
page.on('pageerror', error => errors.push(error.message))
await page.addInitScript(() => localStorage.setItem('ys_token', 'synthetic-batch-ui'))
await page.route(url => url.pathname.startsWith('/api/'), async route => {
const req = route.request(), url = new URL(req.url()), path = url.pathname.replace('/api/v1', '')
let data = []
if (path === '/auth/me') data = { id: 1, username: 'ui-test', roles: ['admin'], permissions: ['base:customers:view', 'base:customers:update', 'base:customers:ownership', 'base:products:view', 'base:products:update'] }
else if (path.endsWith('/batch-ownership')) {
const payload = req.postDataJSON(); calls.push({kind: 'ownership', ...payload}); state.owner = payload.new_owner_personnel_id; data = {updated: payload.ids.length}
}
else if (path.endsWith('/batch-category')) {
const payload = req.postDataJSON(), kind = path.includes('customers') ? 'customers' : 'products'
calls.push({kind, ...payload}); state[kind] = payload.category; data = { updated: payload.ids.length }
} else if (path === '/base/customers') {
data = {list: [{id: 11, company_name: '测试待分类客户', owner_name: '', customer_type: state.customers, owner_personnel_id: state.owner, owner_personnel_name: state.owner ? '测试归属人' : null, rating_score: null}], total: 1, pageNum: 1, pageSize: 20}
} else if (path === '/base/products') {
data = {list: [{id: 12, product_name: '测试待分类产品', spec_model: '', unit: '件', product_category: state.products, status: 1, stock_quantity: null, sale_price: null}], total: 1, pageNum: 1, pageSize: 20}
} else if (path.includes('/meta/')) data = url.searchParams.get('resource') === 'personnel' ? [{id: 21, personnel_name: '测试归属人', status: 1}] : []
await route.fulfill({ json: { code: 200, message: 'success', data } })
})
for (const [kind, label, category, id] of [['customers', '测试待分类客户', '企业户', 11], ['products', '测试待分类产品', '市政水利', 12]]) {
await page.goto(`${base}/base/${kind}`)
const row = page.getByRole('row').filter({hasText: label})
await row.waitFor()
await row.locator('label.el-checkbox').click()
await page.locator('.toolbar .el-select').first().click()
await page.getByRole('option', { name: category, exact: true }).click()
await page.getByRole('button', {name: '批量补充分类(1)'}).click()
await page.getByRole('dialog').getByRole('button', {name: '确定'}).click()
await row.getByText(category, {exact: true}).waitFor()
assert.equal(calls.at(-1).kind, kind)
assert.deepEqual(calls.at(-1).ids, [id])
}
await page.goto(`${base}/base/customers`)
const customer = page.getByRole('row').filter({hasText: '测试待分类客户'})
await customer.waitFor()
await customer.locator('label.el-checkbox').click()
await page.locator('.toolbar .el-select').nth(1).click()
await page.getByRole('option', {name: '测试归属人', exact: true}).click()
await page.getByRole('button', {name: '批量补充归属(1)'}).click()
await page.getByRole('dialog').getByRole('button', {name: '确定'}).click()
await customer.getByText('测试归属人', {exact: true}).waitFor()
assert.equal(calls.at(-1).kind, 'ownership')
assert.deepEqual(calls.at(-1).ids, [11])
assert.equal(calls.at(-1).new_owner_personnel_id, 21)
assert.deepEqual(errors, [])
process.stdout.write('客户和产品批量分类及客户批量归属页面交互通过(模拟接口)。\n')
} finally {
await browser.close()
}
+63
View File
@@ -0,0 +1,63 @@
/** 接入日期草稿与设置刷新的浏览器回归,接口全部隔离。 */
import assert from 'node:assert/strict'
import path from 'node:path'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import vue from '@vitejs/plugin-vue'
import { createServer } from 'vite'
const require = createRequire(import.meta.url)
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'C:/Users/Administrator/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
const root = fileURLToPath(new URL('../', import.meta.url))
const id = 'virtual:business-sync-date'
const harness = `import {createApp,h} from 'vue';import {createPinia} from 'pinia';import ElementPlus from 'element-plus';import 'element-plus/dist/index.css';import Panel from '/src/views/system/tplus/BusinessSyncPanel.vue';import {useUserStore} from '/src/store/user.ts';const pinia=createPinia();createApp({setup(){useUserStore().userInfo={permissions:['tplus:view','tplus:import']};return()=>h(Panel)}}).use(pinia).use(ElementPlus).mount('#app')`
const server = await createServer({ configFile: false, root,
resolve: { alias: { '@': path.join(root, 'src') } },
define: { 'import.meta.env.VITE_API_BASE_URL': JSON.stringify('/api') },
cacheDir: path.resolve(root, '../.ai/business-sync-date-browser/vite-cache'),
plugins: [vue(), { name: 'business-sync-date-proof', resolveId: value => value === id ? '\0' + id : undefined,
load: value => value === '\0' + id ? harness : undefined,
configureServer(instance) { instance.middlewares.use('/__business-sync-date', async (_request, response) => {
response.setHeader('Content-Type', 'text/html; charset=utf-8')
response.end(await instance.transformIndexHtml('/__business-sync-date', '<!doctype html><html lang="zh-CN"><meta charset="UTF-8"><div id="app"></div><script type="module" src="/@id/__x00__virtual:business-sync-date"></script></html>'))
}) },
}], server: { host: '127.0.0.1', port: 0 }, optimizeDeps: { include: ['vue', 'element-plus', 'pinia'] } })
let browser
let settings = { enabled: false, start_date: '2026-03-01', version: 1, editable: true, last_error: null }
try {
await server.listen()
browser = await chromium.launch({ headless: true, channel: 'msedge' })
const page = await browser.newPage()
const errors = []
page.on('pageerror', error => errors.push(error.message))
await page.route(url => url.pathname.startsWith('/api/'), async route => {
const pathname = new URL(route.request().url()).pathname
const data = pathname.endsWith('/business-sync') ? settings : pathname.endsWith('/work-runs') ? { list: [], total: 0 } : null
assert.ok(data, '未预期接口:' + pathname)
await route.fulfill({ json: { code: 200, message: 'success', data } })
})
await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/__business-sync-date`)
const input = page.locator('.el-date-editor input')
await input.waitFor()
await page.getByText('未开启', { exact: true }).waitFor()
await input.fill('2026-04-01')
await input.press('Tab')
await Promise.all([
page.waitForResponse(response => new URL(response.url()).pathname.endsWith('/work-runs')),
page.getByRole('button', { name: '刷新', exact: true }).click(),
])
assert.equal(await input.inputValue(), '2026-04-01', '刷新应保留正在填写的日期')
settings = { ...settings, enabled: true, start_date: '2026-05-01', version: 2 }
await Promise.all([
page.waitForResponse(response => new URL(response.url()).pathname.endsWith('/work-runs')),
page.getByRole('button', { name: '刷新', exact: true }).click(),
])
await page.getByText('已开启', { exact: true }).waitFor()
assert.equal(await input.inputValue(), '2026-05-01', '远程开启后应显示生效日期')
assert.equal(await input.isDisabled(), true)
assert.deepEqual(errors, [])
console.log('接入日期草稿、刷新与远程启用验证通过')
} finally {
if (browser) await browser.close()
await server.close()
}
+90
View File
@@ -0,0 +1,90 @@
/** 隔离渲染真实更正单组件,合成接口仅用于交互与布局验证。 */
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import vue from '@vitejs/plugin-vue'
import { createServer } from 'vite'
const require = createRequire(import.meta.url)
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'C:/Users/Administrator/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
const root = fileURLToPath(new URL('../', import.meta.url))
const output = path.resolve(root, '../.ai/correction-pairs-browser')
fs.mkdirSync(output, { recursive: true })
const id = 'virtual:correction-pairs'
const harness = `
import {createApp,h,ref} from 'vue'
import {createPinia} from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import '/src/assets/styles/index.css'
import Dialog from '/src/views/system/tplus/CorrectionPairDialog.vue'
import {useUserStore} from '/src/store/user.ts'
const pinia=createPinia()
createApp({setup(){const open=ref(false);const user=useUserStore();user.userInfo={permissions:['tplus:facts']};return ()=>h('main',[
h('button',{onClick:()=>open.value=true},'打开更正处理'),
h('button',{onClick:()=>user.userInfo={permissions:[]}},'切换只读'),
h(Dialog,{modelValue:open.value,'onUpdate:modelValue':v=>open.value=v})])}}).use(pinia).use(ElementPlus).mount('#app')`
const server = await createServer({ configFile: false, root,
resolve: { alias: { '@': path.join(root, 'src') } },
define: { 'import.meta.env.VITE_API_BASE_URL': JSON.stringify('/api') },
cacheDir: path.join(output, 'vite-cache'),
plugins: [vue(), { name: 'correction-pair-proof', resolveId: value => value === id ? '\0' + id : undefined,
load: value => value === '\0' + id ? harness : undefined,
configureServer(instance) { instance.middlewares.use('/__correction-proof', async (_request, response) => {
response.setHeader('Content-Type', 'text/html; charset=utf-8')
response.end(await instance.transformIndexHtml('/__correction-proof', '<!doctype html><html lang="zh-CN"><meta charset="UTF-8"><div id="app"></div><script type="module" src="/@id/__x00__virtual:correction-pairs"></script></html>'))
}) },
}], server: { host: '127.0.0.1', port: 0 }, optimizeDeps: { include: ['vue', 'element-plus', 'pinia'] } })
let browser
const errors = [], saves = [], checks = []
try {
await server.listen()
browser = await chromium.launch({ headless: true, channel: 'msedge' })
const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, reducedMotion: 'reduce' })
page.on('pageerror', error => errors.push(error.message))
await page.route(url => url.pathname.startsWith('/api/'), async route => {
const request = route.request(), url = new URL(request.url())
let data
if (url.pathname.endsWith('/correction-pairs')) { saves.push(request.postDataJSON()); data = { pair_key: 'synthetic' } }
else if (url.pathname.endsWith('/facts')) data = { list: [{ fact_key: 'original', data: { customer_id: '8' } }], total: 1 }
else if (url.pathname.endsWith('/source-records')) data = { list: url.searchParams.get('entity') === 'receipts'
? [{ source_id: '30', source_no: 'B-CASH', date: '2026-03-10' }]
: [{ source_id: '30', source_no: 'A-NEG', date: '2026-03-10' }, { source_id: '40', source_no: 'B-POS', date: '2026-03-10' }], total: 2 }
else throw new Error('未预期接口:' + url.pathname)
return route.fulfill({ json: { code: 200, message: 'success', data } })
})
await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/__correction-proof`)
await page.getByRole('button', { name: '打开更正处理' }).click()
const dialog = page.getByRole('dialog', { name: '转客户更正单处理' })
await dialog.waitFor()
await dialog.getByRole('button', { name: '确认成对处理' }).click()
assert.equal(saves.length, 0)
checks.push('必填资料不完整时阻止提交')
for (const width of [1440, 768, 390]) {
await page.setViewportSize({ width, height: 900 })
await page.screenshot({ path: path.join(output, `pair-${width}.png`), fullPage: true })
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false)
}
checks.push('1440、768、390 宽度无页面横向溢出')
await page.setViewportSize({ width: 1440, height: 900 })
for (const [label, option] of [['负数更正单', 'A-NEG'], ['正数更正单', 'B-POS'], ['原业务平账组', 'original'], ['实际回款', 'B-CASH']]) {
await dialog.getByRole('combobox', { name: label, exact: true }).locator('xpath=ancestor::div[contains(@class,"el-select__wrapper")][1]').click()
await page.getByRole('option').filter({ hasText: option }).click()
await page.keyboard.press('Escape')
}
await dialog.getByRole('textbox', { name: '更正处理依据' }).fill('合成转客户例单已核对')
await dialog.getByRole('button', { name: '确认成对处理' }).click()
await dialog.waitFor({ state: 'hidden' })
assert.deepEqual(saves[0], { negative_shipment_id: '30', positive_shipment_id: '40', receipt_ids: ['30'], target_group_key: 'original', evidence: '合成转客户例单已核对' })
checks.push('成对选单、同 ID 不同实体、实际回款和原业务完整提交')
await page.getByRole('button', { name: '切换只读' }).click()
await page.getByRole('button', { name: '打开更正处理' }).click()
await dialog.waitFor()
assert.equal(await dialog.getByRole('button', { name: '确认成对处理' }).isDisabled(), true)
checks.push('无事实维护权限时禁用提交')
assert.deepEqual(errors, [])
fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify({ checks, errors }, null, 2))
console.log(JSON.stringify({ checks, errors }))
} finally { await browser?.close(); await server.close() }
+92
View File
@@ -0,0 +1,92 @@
/** 人工关系页面使用模拟接口验证交互,不修改业务库。 */
import assert from 'node:assert/strict'
import path from 'node:path'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import vue from '@vitejs/plugin-vue'
import { createServer } from 'vite'
const require = createRequire(import.meta.url)
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'C:/Users/Administrator/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
const root = fileURLToPath(new URL('../', import.meta.url))
const id = 'virtual:sales-relations-proof'
const harness = `import {createApp,h} from 'vue';import {createPinia,setActivePinia} from 'pinia';import ElementPlus from 'element-plus';import 'element-plus/dist/index.css';import Review from '/src/views/business/businesses/SalesRelationReview.vue';import {useUserStore} from '/src/store/user';const pinia=createPinia();setActivePinia(pinia);useUserStore().userInfo={permissions:location.search?[]:['business:businesses:update','payroll:prepare']};createApp({render:()=>h(Review,{businessId:12})}).use(pinia).use(ElementPlus).mount('#app')`
const server = await createServer({ configFile: false, root,
resolve: { alias: { '@': path.join(root, 'src') } },
define: { 'import.meta.env.VITE_API_BASE_URL': JSON.stringify('/api') },
plugins: [vue(), { name: 'sales-relations-proof', resolveId: value => value === id ? '\0' + id : undefined,
load: value => value === '\0' + id ? harness : undefined,
configureServer(instance) { instance.middlewares.use('/__relations-proof', async (_request, response) => {
response.setHeader('Content-Type', 'text/html; charset=utf-8')
response.end(await instance.transformIndexHtml('/__relations-proof', '<!doctype html><html lang="zh-CN"><meta charset="UTF-8"><div id="app"></div><script type="module" src="/@id/__x00__virtual:sales-relations-proof"></script></html>'))
}) },
}], server: { host: '127.0.0.1', port: 0 } })
let browser, confirmation = null, version = 0, saved = null, failNext = false
try {
await server.listen()
browser = await chromium.launch({ headless: true, channel: 'msedge' })
const page = await browser.newPage({ viewport: { width: 1024, height: 768 } })
await page.route(url => url.pathname.startsWith('/api/'), async route => {
const url = new URL(route.request().url())
if (route.request().method() === 'PUT') {
const input = route.request().postDataJSON()
saved = input
version++
const released = url.pathname.endsWith('/release')
confirmation = { disposition: released ? 'RELEASED' : input.disposition, evidence: input.evidence, current: !released,
shipment: released ? null : { source_id: 'SHIP1', source_no: '发货1' },
order: released ? null : { source_id: 'ORDER2', source_no: '订单2' } }
return route.fulfill({ json: { code: 200, message: 'success', data: { item_id: 1, version } } })
}
if (failNext) {
failNext = false
return route.fulfill({ status: 500, json: { code: 500, message: '加载失败' } })
}
const shipPage = Number(url.searchParams.get('shipment_page'))
const orderPage = Number(url.searchParams.get('order_page'))
const item = { item_id: 1, product_name: '测试产品', spec: '规格', quantity: '2', amount: '200', item_revision: 'a'.repeat(64), version, confirmation,
shipments: [{ document_id: shipPage, source_id: `SHIP${shipPage}`, source_no: `发货${shipPage}`, date: '2026-03-10', revision: 'b'.repeat(64) }],
shipments_truncated: shipPage === 1,
orders: [{ document_id: 100 + orderPage, source_id: `ORDER${orderPage}`, quantity: '2', amount: '200', revision: 'c'.repeat(64) }], orders_truncated: orderPage === 1 }
await route.fulfill({ json: { code: 200, message: 'success', data: { list: [item], total: 1 } } })
})
await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/__relations-proof`)
await page.getByText('测试产品').waitFor()
const row = page.locator('.relation-row')
await row.locator('.el-select').nth(0).click()
await page.getByRole('option', { name: /发货1/ }).click()
await row.locator('.el-select').nth(1).click()
await page.getByRole('option', { name: '对应订单发货' }).click()
await row.getByRole('button', { name: '下一页' }).nth(1).click()
await page.getByText('订单候选第 2 页').waitFor()
await row.locator('.el-select').nth(2).click()
await page.getByRole('option', { name: /ORDER2/ }).click()
await row.locator('input[placeholder="填写依据"]').fill('原单核对')
await row.getByRole('button', { name: '保存核对' }).click()
await page.getByText(/已确认/).waitFor()
assert.match(await row.innerText(), /发货1.*SHIP1.*订单2.*ORDER2/)
assert.equal(saved.shipment_document_id, 1)
assert.equal(saved.order_document_id, 102)
assert.equal(saved.evidence, '原单核对')
await row.getByRole('button', { name: '撤销核对' }).click()
await page.getByPlaceholder('填写撤销依据').fill('重新核实')
await page.getByRole('button', { name: '撤销核对' }).last().click()
await page.getByText(/已撤销/).waitFor()
assert.equal(saved.expected_version, 1)
assert.equal(saved.evidence, '重新核实')
await page.setViewportSize({ width: 375, height: 812 })
assert.ok(await page.locator('.relation-row').evaluate(el => el.getBoundingClientRect().right <= innerWidth))
await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/__relations-proof?no-permission=1`)
await page.getByText('测试产品').waitFor()
assert.equal(await page.getByRole('button', { name: '保存核对' }).isDisabled(), true)
failNext = true
await page.reload()
await page.getByText('列表加载失败,请重试').waitFor()
await page.getByRole('button', { name: '重新加载' }).click()
await page.getByText('测试产品').waitFor()
process.stdout.write('人工关系跨页选择、确认、撤销、权限、失败重试及窄屏的模拟浏览器回归通过。\n')
} finally {
await browser?.close()
await server.close()
}
+133
View File
@@ -0,0 +1,133 @@
/** 来源业务详情加载、错误及重试;接口为隔离模拟。 */
import assert from 'node:assert/strict'
import path from 'node:path'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import vue from '@vitejs/plugin-vue'
import { createServer } from 'vite'
const require = createRequire(import.meta.url)
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'C:/Users/Administrator/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
const root = fileURLToPath(new URL('../', import.meta.url))
const id = 'virtual:source-documents-proof'
const harness = `import {createApp,h} from 'vue';import {createPinia} from 'pinia';import ElementPlus from 'element-plus';import 'element-plus/dist/index.css';import Source from '/src/views/business/businesses/SourceDocuments.vue';import {useUserStore} from '/src/store/user.ts';const pinia=createPinia();createApp({setup(){window.__sourceUser=useUserStore();window.__sourceUser.userInfo={permissions:['business:businesses:update','payroll:prepare']};return()=>h(Source,{businessId:12})}}).use(pinia).use(ElementPlus).mount('#app')`
const server = await createServer({ configFile: false, root,
resolve: { alias: { '@': path.join(root, 'src') } },
define: { 'import.meta.env.VITE_API_BASE_URL': JSON.stringify('/api') },
plugins: [vue(), { name: 'source-documents-proof', resolveId: value => value === id ? '\0' + id : undefined,
load: value => value === '\0' + id ? harness : undefined,
configureServer(instance) { instance.middlewares.use('/__source-proof', async (_request, response) => {
response.setHeader('Content-Type', 'text/html; charset=utf-8')
response.end(await instance.transformIndexHtml('/__source-proof', '<!doctype html><html lang="zh-CN"><meta charset="UTF-8"><div id="app"></div><script type="module" src="/@id/__x00__virtual:source-documents-proof"></script></html>'))
}) },
}], server: { host: '127.0.0.1', port: 0 } })
let browser, failNext = false, failDetail = false, failQuantity = false, repaired = false, repairRequests = 0
try {
await server.listen()
browser = await chromium.launch({ headless: true, channel: 'msedge' })
const page = await browser.newPage()
await page.route(url => url.pathname.startsWith('/api/'), async route => {
const url = new URL(route.request().url())
if (url.pathname.endsWith('/missing-price')) {
repairRequests++
const body = route.request().postDataJSON()
assert.equal(body.revision, 'a'.repeat(64))
assert.equal(body.evidence, '已核对原单')
repaired = true
return route.fulfill({ json: { code: 200, message: 'success', data: { document_id: 2, parent_status: 'APPLIED' } } })
}
if (url.pathname.endsWith('/source-quantities') && failQuantity) {
failQuantity = false
return route.fulfill({ status: 500, json: { code: 500, message: '加载失败' } })
}
if (url.pathname.endsWith('/details') && failDetail) {
failDetail = false
return route.fulfill({ status: 500, json: { code: 500, message: '加载失败' } })
}
if (url.pathname.endsWith('/source-documents') && failNext) {
failNext = false
return route.fulfill({ status: 500, json: { code: 500, message: '加载失败' } })
}
const data = url.pathname.endsWith('/details')
? { list: [
{ source_id: '12', state: 'CHANGED', before: { product_id: '9', quantity: '2', amount: '100', price: '50', price_type: '含税' }, after: { product_id: '9', quantity: '3', amount: '150', price: '50', price_type: '未税' }, changes: [{ field: 'quantity', label: '数量', before: '2', after: '3' }] },
{ source_id: '13', state: 'ADDED', before: null, after: { product_id: '9', quantity: '1', amount: '50', price: '10.12345', price_type: '未税' }, changes: [] },
], total: 2 }
: url.pathname.endsWith('/readiness')
? { status: 'PENDING', reasons: [], customer_type: 'personal', products: [], products_total: 0, eligible_receipts_snapshot: null }
: url.pathname.endsWith('/source-quantities')
? { list: [{ product_id: 9, product_name: '测试管材', spec_model: 'DN300', unit: '根', order_quantity: '10.00', shipment_quantity: '4.00' }], total: 1 }
: { list: [{ id: 1, entity: 'businesses', source_no: 'SO-001', current_source_no: 'SO-002', status: 'CONFLICT',
changes: [{ field: 'amount', label: '金额', before: '100', after: '200' }] },
{ id: 2, entity: 'shipment_items', source_no: 'L-1', snapshot_state: 'CHANGED', price: '10.1234', price_type: 'TAX_EXCLUDED', price_repair_revision: repaired ? null : 'a'.repeat(64), changes: [] },
{ id: 3, entity: 'shipment_items', source_no: 'L-2', snapshot_state: 'MISSING', price: '12.3400', price_type: 'TAX_INCLUDED', changes: [] },
{ id: 4, entity: 'shipment_items', source_no: 'L-3', snapshot_state: 'UNAVAILABLE', price: '5.00', changes: [] },
{ id: 5, entity: 'receipts', source_no: 'RC-001', status: 'CONFLICT', reason: 'PENDING_SOURCE_CUSTOMER_CHANGED',
changes: [{ field: 'customer_id', label: '客户标识', before: '8', after: '10' }] },
{ id: 6, entity: 'receipts', source_no: 'RC-002', status: 'CONFLICT', reason: 'PENDING_SOURCE_OUT_OF_RANGE',
changes: [{ field: 'date', label: '日期', before: '2026-03-14', after: '2026-02-28' }] }], total: 21 }
await route.fulfill({ json: { code: 200, message: 'success', data } })
})
await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/__source-proof`)
await page.getByText('SO-001').waitFor()
await page.getByText('SO-002').waitFor()
await page.getByText('10.1234(未税)').waitFor()
await page.getByText('12.3400(含税)').waitFor()
await page.getByText('5.00(税别待确认)').waitFor()
await page.evaluate(() => { window.__sourceUser.userInfo.permissions = ['business:businesses:update'] })
assert.equal(await page.getByRole('button', { name: '核对单价' }).count(), 0)
await page.evaluate(() => { window.__sourceUser.userInfo.permissions = ['business:businesses:update', 'payroll:prepare'] })
await page.getByRole('button', { name: '核对单价' }).click()
await page.getByRole('button', { name: '取消' }).click()
assert.equal(repairRequests, 0)
await page.getByRole('button', { name: '核对单价' }).click()
await page.getByPlaceholder('填写核对依据').fill('已核对原单')
await page.getByRole('button', { name: '确认补齐' }).click()
await page.getByText('来源单价已补齐').waitFor()
assert.equal(repairRequests, 1)
const quantityRow = page.getByRole('row').filter({ hasText: '测试管材' })
await quantityRow.getByText('10.00', { exact: true }).waitFor()
await quantityRow.getByText('4.00', { exact: true }).waitFor()
await page.getByText('金额:100 → 200').waitFor()
await page.getByText('待核对单据的来源客户已变化,原关联已保留,请核对原单').waitFor()
await page.getByText('客户标识:8 → 10').waitFor()
await page.getByText('待核对单据的来源日期已早于接入起点,原关联已保留,请核对原单').waitFor()
await page.getByText('日期:2026-03-14 → 2026-02-28').waitFor()
failQuantity = true
await page.reload()
await page.getByText('SO-001').waitFor()
await page.locator('.quantity-section').getByText('列表加载失败,请重试').waitFor()
await page.locator('.quantity-section').getByRole('button', { name: '重新加载' }).click()
await page.getByRole('row').filter({ hasText: '测试管材' }).getByText('10.00', { exact: true }).waitFor()
await page.getByText('来源明细已变化,待核对').waitFor()
await page.getByText('最新完整快照中没有此明细').waitFor()
await page.getByText('完整快照不可用,显示已接入明细').waitFor()
await page.getByRole('button', { name: '查看明细' }).first().click()
const dialog = page.getByRole('dialog', { name: '来源明细对照' })
await dialog.getByText('数量:2 → 3').waitFor()
await dialog.getByText('产品 9,数量 1,单价 10.12345(未税),金额 50').waitFor()
await dialog.getByText('新增', { exact: true }).waitFor()
await dialog.locator('.el-dialog__headerbtn').click()
failDetail = true
await page.setViewportSize({ width: 375, height: 800 })
await page.getByText('客户标识:8 → 10').waitFor()
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true)
await page.getByRole('button', { name: '查看明细' }).first().click()
await dialog.getByText('列表加载失败,请重试').waitFor()
await dialog.getByRole('button', { name: '重新加载' }).click()
await dialog.getByText('数量:2 → 3').waitFor()
await dialog.locator('.el-dialog__headerbtn').click()
assert.match(await page.locator('.source-section').innerText(), /可参与平账回款.*待确认/)
failNext = true
await page.locator('.el-pagination button.btn-next').click()
await page.getByText('列表加载失败,请重试').waitFor()
assert.equal(await page.getByText('SO-001').count(), 0)
await page.getByRole('button', { name: '重新加载' }).click()
await page.getByText('SO-001').waitFor()
assert.equal(await page.getByText('列表加载失败,请重试').count(), 0)
process.stdout.write('来源详情加载失败、清除旧数据和重试通过。\n')
} finally {
await browser?.close()
await server.close()
}
+153
View File
@@ -0,0 +1,153 @@
/** 来源业务核算准备组件回归;所有接口均为隔离模拟。 */
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import vue from '@vitejs/plugin-vue'
import { createServer } from 'vite'
const require = createRequire(import.meta.url)
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'C:/Users/Administrator/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
const root = fileURLToPath(new URL('../', import.meta.url))
const output = path.resolve(root, '../.ai/tplus-payroll-browser')
fs.mkdirSync(output, { recursive: true })
const id = 'virtual:tplus-payroll-proof'
const harness = `import {createApp,h,ref} from 'vue';import {createPinia} from 'pinia';import ElementPlus from 'element-plus';import 'element-plus/dist/index.css';import '/src/assets/styles/index.css';import Dialog from '/src/views/business/businesses/PayrollDialog.vue';import {useUserStore} from '/src/store/user.ts';const pinia=createPinia();createApp({setup(){useUserStore().userInfo={permissions:['payroll:view','payroll:prepare','payroll:calculate']};const open=ref(false);setTimeout(()=>open.value=true,0);return()=>h(Dialog,{modelValue:open.value,'onUpdate:modelValue':v=>open.value=v,businessId:12,sourceManaged:true})}}).use(pinia).use(ElementPlus).mount('#app')`
const server = await createServer({ configFile: false, root,
resolve: { alias: { '@': path.join(root, 'src') } },
define: { 'import.meta.env.VITE_API_BASE_URL': JSON.stringify('/api') },
cacheDir: path.join(output, 'vite-cache'),
plugins: [vue(), { name: 'tplus-payroll-proof', resolveId: value => value === id ? '\0' + id : undefined,
load: value => value === '\0' + id ? harness : undefined,
configureServer(instance) { instance.middlewares.use('/__payroll-proof', async (_request, response) => {
response.setHeader('Content-Type', 'text/html; charset=utf-8')
response.end(await instance.transformIndexHtml('/__payroll-proof', '<!doctype html><html lang="zh-CN"><meta charset="UTF-8"><div id="app"></div><script type="module" src="/@id/__x00__virtual:tplus-payroll-proof"></script></html>'))
}) },
}], server: { host: '127.0.0.1', port: 0 }, optimizeDeps: { include: ['vue', 'element-plus', 'pinia'] } })
let browser, snapshot = null, submitted = null, large = false, failLargePage = false
const largePeople = Array.from({length: 205}, (_, index) => ({id: index + 1, personnel_name: `分页业务员${index + 1}`}))
const largeShipments = Array.from({length: 205}, (_, index) => ({id: index + 101, shipment_no: `SH-${index + 101}`,
product_name: `分页产品${index + 1}`, ship_date: '2026-03-10', amount: '100.00'}))
const errors = []
try {
await server.listen()
browser = await chromium.launch({ headless: true, channel: 'msedge' })
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, reducedMotion: 'reduce' })
page.on('pageerror', error => errors.push(error.message))
await page.route(url => url.pathname.startsWith('/api/'), async route => {
const req = route.request(), pathname = new URL(req.url()).pathname
let data
if (pathname.endsWith('/input') && req.method() === 'POST') {
submitted = req.postDataJSON()
snapshot = { id: 1, version: 1, source: 'test_source', fingerprint: 'test', data: {
group: { opening_balance: submitted.opening_balance }, evidence: submitted.evidence,
facts: { handovers: submitted.handovers ?? snapshot?.data?.facts?.handovers ?? [] },
shipments: submitted.shipment_ownerships.map(row => ({ line_id: String(row.shipment_item_id),
personnel_id: String(row.personnel_id), manager_id: null, unit_cost: row.unit_cost, freight_amount: row.freight_amount })) } }
data = { version: 1 }
} else if (pathname.endsWith('/input')) data = snapshot
else if (pathname.includes('/meta/payroll-input/') && large) {
const pageNumber = Number(new URL(req.url()).searchParams.get('pageNum') || 1)
if (pageNumber === 2 && failLargePage) return route.fulfill({status: 500, json: {code: 500, message: '加载失败'}})
const start = (pageNumber - 1) * 200
data = { business_key: 'customer:8', personnel: largePeople.slice(start, start + 200), personnel_total: largePeople.length,
shipments: largeShipments.slice(start, start + 200), shipments_total: largeShipments.length }
} else if (pathname.includes('/meta/payroll-input/')) data = { business_key: 'customer:8', personnel: [{id: 1, personnel_name: '测试业务员'}, {id: 2, personnel_name: '接手业务员'}], shipments: [
{id: 101, shipment_no: 'SH-001', product_name: '测试管材', ship_date: '2026-03-10', amount: '100.00', source_personnel_name: '来源员工', matched_personnel_id: 1, guidance_status: 'FINALIZED_UNIT', guidance_unit_cost: '25'},
...(snapshot ? [{id: 102, shipment_no: 'SH-002', product_name: '追加发货', ship_date: '2026-03-11', amount: '50.00', source_personnel_name: '未匹配员工', matched_personnel_id: null}] : []),
] }
else if (pathname.includes('/months/')) data = { month: '2026-03', allocation_locked: false, maker_name: null }
else throw new Error('未预期接口:' + pathname)
await route.fulfill({ json: { code: 200, message: 'success', data } })
})
await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/__payroll-proof`)
const dialog = page.getByRole('dialog', { name: '正式绩效核算' })
await dialog.getByText('测试管材', { exact: false }).first().waitFor()
await dialog.getByText('来源员工', { exact: true }).waitFor()
await dialog.locator('tr').filter({hasText: '测试管材'}).getByText('测试业务员', {exact: true}).waitFor()
await dialog.locator('.el-date-editor input').fill('2026-03')
await dialog.locator('.el-date-editor input').press('Enter')
await page.waitForResponse(response => response.url().includes('/months/2026-03'))
await dialog.locator('.el-form-item').filter({hasText: '期初应收'}).locator('input').fill('0')
await dialog.locator('.el-form-item').filter({hasText: '核对依据'}).locator('input').fill('已核实来源')
await dialog.getByRole('combobox', {name: '发货归属人员'}).first().click()
await page.getByRole('option', {name: '测试业务员'}).click()
assert.equal(await dialog.getByRole('textbox', {name: '含税单位指导成本'}).first().inputValue(), '25')
assert.equal(await dialog.getByRole('textbox', {name: '含税单位指导成本'}).first().isDisabled(), true)
await dialog.getByText('使用已封存的月度指导单价', {exact: true}).waitFor()
await dialog.getByRole('textbox', {name: '发货明细运费'}).first().fill('0')
await dialog.getByRole('button', {name: '添加交接'}).click()
await dialog.locator('.el-form-item').filter({hasText: '交接类型'}).locator('.el-select').click()
await page.getByRole('option', {name: '在职交接'}).click()
await dialog.getByRole('combobox', {name: '移交人员'}).click()
await page.getByRole('option', {name: '测试业务员'}).click()
await dialog.getByRole('combobox', {name: '接手人员'}).click()
await page.getByRole('option', {name: '接手业务员'}).click()
await dialog.locator('.el-form-item').filter({hasText: '生效日期'}).locator('input').fill('2026-03-20')
await dialog.locator('.el-form-item').filter({hasText: '生效日期'}).locator('input').press('Enter')
await dialog.locator('label.el-checkbox').filter({hasText: '已核实期初'}).click()
await dialog.getByRole('button', {name: '保存核算资料'}).click()
assert.equal(submitted, null)
await dialog.locator('label.el-checkbox').filter({hasText: '已逐次核实发货归属'}).click()
await dialog.getByRole('button', {name: '保存核算资料'}).click()
await dialog.getByText('追加发货', {exact: false}).first().waitFor()
assert.equal(submitted.shipment_ownerships[0].freight_amount, '0')
assert.equal(submitted.shipment_ownerships[0].personnel_id, 1)
assert.deepEqual(submitted.handovers, [{business_key: 'customer:8', mode: 'ACTIVE', from_personnel_id: '1',
to_personnel_id: '2', effective_date: '2026-03-20', shipping_ownership_confirmed: true}])
await dialog.locator('.el-form-item').filter({hasText: '移交人员'}).getByText('测试业务员').waitFor()
const freight = dialog.getByRole('textbox', {name: '发货明细运费'})
assert.equal(await freight.nth(0).isDisabled(), true)
assert.equal(await freight.nth(1).isEnabled(), true)
await dialog.getByText('未匹配员工(待匹配)', {exact: true}).waitFor()
await dialog.getByRole('combobox', {name: '发货归属人员'}).nth(1).click()
await page.getByRole('option', {name: '测试业务员'}).click()
await dialog.getByRole('textbox', {name: '含税单位指导成本'}).nth(1).fill('25')
await freight.nth(1).fill('0')
submitted = null
const secondSave = page.waitForResponse(response => response.url().endsWith('/input') && response.request().method() === 'POST')
await dialog.getByRole('button', {name: '保存核算资料'}).click()
await secondSave
assert.equal(Object.hasOwn(submitted, 'handovers'), false)
assert.equal(snapshot.data.facts.handovers.length, 1)
for (const width of [1440, 768, 390]) {
await page.setViewportSize({ width, height: 900 })
await page.screenshot({ path: path.join(output, `payroll-${width}.png`), fullPage: true })
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false)
}
snapshot.data.shipments[0].personnel_id = '2'
await page.reload()
await dialog.locator('tr').filter({hasText: '测试管材'}).getByText('接手业务员', {exact: true}).waitFor()
assert.equal(await dialog.getByRole('combobox', {name: '发货归属人员'}).first().isDisabled(), true)
large = true
failLargePage = true
snapshot = { id: 1, version: 2, source: 'test_source', fingerprint: 'test', data: {
group: { opening_balance: '0' }, evidence: '分页依据',
shipments: largeShipments.map((row, index) => ({line_id: String(row.id), personnel_id: String(index + 1),
manager_id: null, unit_cost: '25', freight_amount: '0'})) } }
submitted = null
await page.reload()
await dialog.getByText('资料加载失败,请重试。').waitFor()
assert.equal(submitted, null)
failLargePage = false
await dialog.getByRole('button', {name: '重新加载'}).click()
await dialog.getByText('分页产品1', {exact: false}).first().waitFor()
await dialog.locator('.el-pagination .number').getByText('5', {exact: true}).click()
await dialog.getByText('分页产品205', {exact: false}).waitFor()
await dialog.getByText('分页业务员205', {exact: true}).waitFor()
await dialog.locator('.el-date-editor input').fill('2026-03')
await dialog.locator('.el-date-editor input').press('Enter')
await page.waitForResponse(response => response.url().includes('/months/2026-03'))
await dialog.locator('label.el-checkbox').filter({hasText: '已核实期初'}).click()
await dialog.getByRole('button', {name: '保存核算资料'}).click()
await page.waitForFunction(() => document.body.innerText.includes('核算资料已保存为新版本'))
assert.equal(submitted.shipment_ownerships.length, 205)
assert.equal(submitted.shipment_ownerships[204].personnel_id, 205)
assert.deepEqual(errors, [])
process.stdout.write('来源核算分页、加载失败、完整提交及响应式检查通过。\n')
} finally {
await browser?.close()
await server.close()
}