Files
ShiErFeng/shierfeng-fastapi-frontend/scripts/filling-behavior.test.mjs
yujiangandClaude Opus 5.5 167df4adb8 Feat: 前端改版 Web 端界面与业务作业流程
按设计稿改版登录后的全部页面,登录页与 Logo 资源未改动。

- 全局外壳:浅色侧边栏与分组菜单、左下角用户卡片、Ctrl/⌘K 搜索面板、
  测试模式提示条、深色模式配色、窄屏适配
- 共用组件:页头 PageHeader、托盘流程条 FlowStepper、扫码反馈 ScanFeedback、
  缩短单号 DocNo、托盘档案链接 PalletCodeLink、图标 SfIcon、上级资料选择
  MasterRefSelect,以及编号段解析等工具
- 工作台重做;新增托盘档案页;托盘追溯增加单托盘入口,报表托盘号可跳转档案
- 基础资料维护:各页加指标条、名称代替 ID、详情面板;三步式批量导入预览;
  分配用户对话框标注增减;7 个新增/编辑对话框统一布局
- 业务管理:发货出库、仓库入库、工厂装填、客户现场使用、退回仓库改为
  列表 + 详情面板 + 全屏作业区;写入流程、幂等键与防串单逻辑保持不变
- 修复入库页扫码反馈条因同名状态变量遮蔽组件而不显示的问题,并新增契约检查
- 新增入库粘贴解析、退回、装填的行为测试与现场使用页新流程测试,
  同步更新业务与工作台契约脚本

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 05:03:22 -07:00

351 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { webcrypto } from 'node:crypto'
import { test } from 'node:test'
import * as Vue from 'vue'
import { compileScript, compileTemplate, parse } from '@vue/compiler-sfc'
// 执行真实 setup、模板和 API,仅替换网络与组件宿主边界。
const read = path => readFile(new URL(`../src/${path}`, import.meta.url), 'utf8')
const { descriptor } = parse(await read('views/business/filling/index.vue'))
const script = compileScript(descriptor, { id: 'pc-filling-test', genDefaultAs: '__component' })
const template = compileTemplate({ source: descriptor.template.content, filename: 'filling/index.vue', id: 'pc-filling-test', compilerOptions: { bindingMetadata: script.bindings } })
assert.deepEqual(template.errors, [])
const apiSource = await read('api/business/filling.js')
const importText = async path => import(`data:text/javascript,${encodeURIComponent(await read(path))}`)
const scanUtils = await importText('utils/palletScan.js')
const passport = await importText('utils/palletPassport.js')
const bindImports = source => source.replace(/import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"];?/g, (_, names, module) =>
`const ${names.startsWith('{') ? names.replace(/\s+as\s+/g, ': ') : `{ default: ${names} }`} = __modules[${JSON.stringify(module)}];`)
function loadModule(source, modules) {
const names = [...source.matchAll(/export (?:function|const) (\w+)/g)].map(match => match[1])
return new Function('__modules', `${bindImports(source).replace(/export (function|const) /g, '$1 ')}\nreturn { ${names.join(',')} };`)(modules)
}
const masterDisplay = loadModule(await read('utils/masterDisplay.js'), {
'@/router': { default: { getRoutes: () => [{ path: '/businessOperation/shipment' }] } },
'@/utils/palletPassport': passport
})
const flush = async () => { for (let index = 0; index < 4; index += 1) await new Promise(resolve => setImmediate(resolve)); await Vue.nextTick() }
const isoDate = offset => {
const now = new Date()
const day = new Date(now.getFullYear(), now.getMonth(), now.getDate() + offset)
return `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`
}
const STRAINS = [{ id: 3, code: 'A15', name: '平菇 A15' }]
const SUMMARY = {
todayPalletCount: 6, todayPackageSum: 600, todayBatchCount: 1, monthStart: '2026-10-01', monthPalletCount: 99, monthBatchCount: 8,
monthTopStrains: [{ strainName: '平菇 A15', palletCount: 31 }, { strainName: '白灵菇 B22', palletCount: 27 }],
emptyPalletCount: 13, emptyByWarehouse: [{ warehouseId: 4, warehouseName: '成品一仓', palletCount: 13 }],
nearExpiryDays: 14, nearExpiryPalletCount: 4, nearExpiryBatchCount: 1, nearestExpiryBatchNo: 'B260923-01', nearestExpiryDate: isoDate(7)
}
const BATCH = {
strainId: 3, strainCode: 'A15', strainName: '平菇 A15', batchNo: 'B261017-01', productionDate: isoDate(-1), expiryDate: isoDate(44),
packageSpec: '1kg/包', palletCount: 6, packageSum: 600, lastOperatedAt: '2026-10-17T09:10:00', lastOperatorName: '年糕'
}
const NEAR_BATCH = { ...BATCH, batchNo: 'B260923-01', expiryDate: isoDate(7) }
const DETAIL = {
...BATCH,
distribution: [{ state: 'FILLED_IN_WAREHOUSE', palletCount: 4 }, { state: 'IN_TRANSIT', palletCount: 1 }, { state: 'CONSUMED', palletCount: 1 }],
recent: [{ fillingId: 90, fillingNo: 'FL-90', palletId: 501, palletCode: 'TP-10501', warehouseName: '成品一仓', operatorName: '年糕', operatedAt: '2026-10-17T09:10:00' }]
}
const EMPTY_PALLETS = {
'TP-10501': { palletId: 501, palletCode: 'TP-10501', qrToken: 'QR-501', flowState: 'EMPTY_IN_WAREHOUSE', warehouseId: 4, capacityPackages: 100 },
'TP-10502': { palletId: 502, palletCode: 'TP-10502', qrToken: 'QR-502', flowState: 'EMPTY_IN_WAREHOUSE', warehouseId: 4, capacityPackages: 100 }
}
function defaultScan(token) {
const pallet = EMPTY_PALLETS[token]
return pallet ? Promise.resolve({ data: pallet }) : Promise.reject(new Error('托盘不符合仓库空托盘装填条件'))
}
const renderer = Vue.createRenderer({ createElement: () => ({}), createText: () => ({}), createComment: () => ({}), insert() {}, remove() {}, setText() {}, setElementText() {}, patchProp() {}, parentNode: () => null, nextSibling: () => null })
function flatten(vnode) {
if (Array.isArray(vnode)) return vnode.flatMap(flatten)
if (!vnode || typeof vnode !== 'object') return []
const children = vnode.children
const slots = children && !Array.isArray(children) && typeof children === 'object'
? Object.entries(children).filter(([key, value]) => !key.startsWith('_') && typeof value === 'function').flatMap(([, slot]) => flatten(slot({ row: {} }))) : []
return [vnode, ...flatten(children), ...slots]
}
function createPage({ request: override, permissions = () => true, batches = [BATCH] } = {}) {
const requests = []
const user = Vue.reactive({ admin: false }), batch = Vue.reactive({ isTestMode: false })
const request = config => {
requests.push(structuredClone(config))
const custom = override?.(config)
if (custom !== undefined) return custom
if (config.url === '/business/filling/summary') return Promise.resolve({ data: SUMMARY })
if (config.url === '/business/filling/batches') return Promise.resolve({ rows: structuredClone(batches), total: batches.length })
if (config.url === '/business/filling/batches/detail') return Promise.resolve({ data: structuredClone({ ...DETAIL, ...batches.find(item => item.batchNo === config.params.batchNo) }) })
if (config.url === '/business/filling/list') return Promise.resolve({ rows: [], total: 0 })
if (config.url === '/business/filling/strain-options') return Promise.resolve({ data: STRAINS })
if (config.url.startsWith('/business/filling/scan/')) return defaultScan(decodeURIComponent(config.url.split('/scan/')[1]))
if (config.url === '/business/filling' && config.method === 'post') return Promise.resolve({ msg: '装填成功' })
throw new Error(`未处理请求 ${config.url}`)
}
const names = [...apiSource.matchAll(/export function (\w+)/g)].map(match => match[1])
const api = new Function('__modules', `${bindImports(apiSource).replace(/export function /g, 'function ')}\nreturn { ${names.join(',')} };`)({ '@/utils/request': { default: request } })
const proxy = {
$auth: { hasPermiAnd: perms => permissions(perms) },
$modal: { msgSuccess() {}, msgWarning() {} },
$refs: { fillingRef: { validate: callback => callback(true), clearValidate() {} } },
resetForm() {}
}
const vue = { ...Vue, getCurrentInstance: () => ({ proxy }), resolveComponent: name => ({ name }), resolveDirective: () => ({}) }
const stub = name => ({ default: { name } })
const modules = {
vue,
'@/components/PalletScanInput': stub('PalletScanInput'),
'@/components/ScanFeedback': stub('ScanFeedback'),
'@/components/SfIcon': stub('SfIcon'),
'@/components/TestBatchAdoptionDialog': stub('TestBatchAdoptionDialog'),
'@/utils/palletScan': { ...scanUtils, createIdempotencyKey: () => scanUtils.createIdempotencyKey(webcrypto) },
'@/utils/masterDisplay': masterDisplay,
'@/utils/palletPassport': passport,
'@/store/modules/user': { default: () => user },
'@/store/modules/testBatch': { default: () => batch },
'@/api/business/filling': api
}
// PC 的 Vue API 由 Vite 自动导入;测试以相同名字提供真实响应式 API。
const globals = ['ref', 'reactive', 'computed', 'toRefs', 'watch', 'getCurrentInstance']
const component = new Function('__modules', ...globals, `${bindImports(script.content)}\nreturn __component;`)(modules, ...globals.map(name => vue[name]))
const scope = Vue.effectScope(), state = scope.run(() => component.setup({}, { expose() {} }))
const render = new Function('__modules', `${bindImports(template.code).replace('export function render', 'function render')}\nreturn render;`)(modules)
return {
state, requests,
posts: () => requests.filter(item => item.method === 'post'),
async scan(value) { state.handleFillingScan(value); await flush() },
nodes() {
let nodes
const app = renderer.createApp({ render() { nodes = flatten(render({}, [], {}, Vue.proxyRefs(state), {}, {})); return null } })
app.mount({}); app.unmount()
return nodes
},
close() { scope.stop() }
}
}
const label = node => flatten(node).filter(child => typeof child.children === 'string').map(child => child.children).join('')
const button = (page, text) => page.nodes().find(node => node.type?.name === 'el-button' && label(node).includes(text))
const scanInput = page => page.nodes().find(node => node.type?.name === 'PalletScanInput')
async function openStation(page, batch) {
await flush()
page.state.handleOpenFilling(batch)
await flush()
}
async function lockParams(page, overrides = {}) {
Object.assign(page.state.form.value, {
strainId: 3, batchNo: ' B261018-01 ', productionDate: isoDate(0), expiryDate: isoDate(45), packageSpec: ' 1kg/包 ', ...overrides
})
await flush()
page.state.lockFillingParams()
await flush()
}
test('装填工位:未锁定参数不能扫码,锁定前校验必填与日期顺序', async () => {
const page = createPage()
await openStation(page)
assert.equal(page.state.open.value, true)
assert.equal(scanInput(page).props.disabled, true)
await page.scan('TP-10501')
assert.equal(page.requests.some(item => item.url.includes('/scan/')), false)
assert.equal(page.state.scanNotice.value.type, 'warn')
page.state.lockFillingParams()
assert.equal(page.state.paramError.value, '请选择菌种')
await lockParams(page, { expiryDate: isoDate(-1) })
assert.equal(page.state.paramError.value, '有效期不能早于生产日期')
assert.equal(page.state.paramsLocked.value, false)
await lockParams(page)
assert.equal(page.state.paramsLocked.value, true)
assert.deepEqual(
{ ...page.state.lockedParams.value },
{ strainId: 3, strainLabel: '平菇 A15', batchNo: 'B261018-01', productionDate: isoDate(0), expiryDate: isoDate(45), packageSpec: '1kg/包', initialPackages: 100 }
)
assert.equal(scanInput(page).props.disabled, false)
page.close()
})
test('扫码后确认:载荷由扫码结果与锁定参数组成,成功后进入本班记录且参数保持锁定', async () => {
const page = createPage()
await openStation(page)
await lockParams(page)
await page.scan('TP-10501')
assert.equal(page.posts().length, 0, '默认模式扫码后等待确认')
assert.equal(page.state.scannedPallet.value.palletCode, 'TP-10501')
assert.equal(page.state.form.value.qrToken, 'QR-501')
assert.equal(page.state.form.value.warehouseId, 4)
assert.equal(button(page, '确认装填').props.disabled, false)
page.state.submitFilling()
await flush()
const [post] = page.posts()
assert.deepEqual(post.data, {
qrToken: 'QR-501', warehouseId: 4, strainId: 3, batchNo: 'B261018-01', productionDate: isoDate(0), expiryDate: isoDate(45),
packageSpec: '1kg/包', initialPackages: 100, idempotencyKey: post.data.idempotencyKey
})
assert.ok(post.data.idempotencyKey)
assert.equal(page.state.shiftRecords.value.length, 1)
assert.deepEqual(
[page.state.shiftRecords.value[0].palletCode, page.state.shiftRecords.value[0].warehouseName, page.state.shiftRecords.value[0].batchNo],
['TP-10501', '成品一仓', 'B261018-01']
)
assert.equal(page.state.scannedPallet.value, null)
assert.equal(page.state.paramsLocked.value, true)
assert.equal(page.state.scanNotice.value.type, 'ok')
await page.scan('TP-10502')
page.state.submitFilling()
await flush()
const keys = page.posts().map(item => item.data.idempotencyKey)
assert.equal(keys.length, 2)
assert.notEqual(keys[0], keys[1], '不同托盘使用不同幂等键')
page.close()
})
test('扫码即装填:校验通过立即提交,失败后重试复用同一幂等键', async () => {
let attempts = 0
const page = createPage({
request: config => {
if (config.method !== 'post') return undefined
attempts += 1
return attempts === 1 ? Promise.reject(new Error('历史非 100 包托盘不允许新增装填')) : Promise.resolve({ msg: '装填成功' })
}
})
await openStation(page)
await lockParams(page)
page.state.fillMode.value = 'auto'
await page.scan('TP-10501')
assert.equal(page.posts().length, 1)
assert.match(page.state.formError.value, /直接重试将复用原键/)
assert.equal(page.state.scanNotice.value.message, '历史非 100 包托盘不允许新增装填')
assert.ok(page.state.scannedPallet.value, '失败后保留待装填托盘以便重试')
assert.ok(button(page, '重试装填'))
page.state.submitFilling()
await flush()
const keys = page.posts().map(item => item.data.idempotencyKey)
assert.equal(keys[0], keys[1])
assert.equal(page.state.shiftRecords.value.length, 1)
page.close()
})
test('后端拒绝与非空托盘进入未装填记录,本班已装填的托盘提示无需重复', async () => {
const filled = new Set()
const page = createPage({
request: config => {
if (config.method === 'post') {
filled.add(config.data.qrToken)
return Promise.resolve({ msg: '装填成功' })
}
if (config.url.endsWith('/scan/TP-10288')) {
return Promise.resolve({ data: { palletId: 288, palletCode: 'TP-10288', flowState: 'FILLED_IN_WAREHOUSE', warehouseId: 4 } })
}
// 已装填的托盘不再是空托盘,服务端扫码会拒绝
const token = decodeURIComponent(config.url.split('/scan/')[1] || '')
if (filled.has(EMPTY_PALLETS[token]?.qrToken)) return Promise.reject(new Error('托盘不符合仓库空托盘装填条件'))
return undefined
}
})
await openStation(page)
await lockParams(page)
await page.scan('TP-10999')
assert.deepEqual([page.state.scanRejects.value[0].code, page.state.scanRejects.value[0].message], ['TP-10999', '托盘不符合仓库空托盘装填条件'])
await page.scan('TP-10288')
assert.equal(page.state.scanRejects.value[0].message, '该托盘为「仓库有料」,只有仓库空托盘可以装填')
assert.equal(page.state.scannedPallet.value, null)
await page.scan('TP-10501')
page.state.submitFilling()
await flush()
await page.scan('TP-10501')
assert.equal(page.state.scanNotice.value.type, 'info')
assert.match(page.state.scanNotice.value.message, /^本班 \d{2}:\d{2} 已装填,无需重复$/)
assert.equal(page.state.scanRejects.value.length, 2, '重复扫本班托盘不计入未装填记录')
page.close()
})
test('修改参数会放弃待装填托盘,沿用批次只带入参数仍需锁定', async () => {
const page = createPage()
await openStation(page, BATCH)
assert.equal(page.state.paramsLocked.value, false)
assert.deepEqual(
[page.state.form.value.strainId, page.state.form.value.batchNo, page.state.form.value.packageSpec],
[3, 'B261017-01', '1kg/包']
)
page.state.lockFillingParams()
await flush()
await page.scan('TP-10501')
assert.ok(page.state.scannedPallet.value)
page.state.unlockFillingParams()
await flush()
assert.equal(page.state.paramsLocked.value, false)
assert.equal(page.state.scannedPallet.value, null)
assert.equal(page.state.form.value.batchNo, 'B261017-01', '解锁后参数回到表单供修改')
assert.equal(page.state.form.value.qrToken, '')
page.state.applyShelfLife(30)
assert.equal(page.state.form.value.expiryDate, isoDate(29))
page.close()
})
test('结束本班后显示装填结果并刷新列表与概况,再次打开工位从空白开始', async () => {
const page = createPage()
await openStation(page, BATCH)
page.state.lockFillingParams()
await flush()
await page.scan('TP-10502')
page.state.submitFilling()
await flush()
const before = page.requests.length
page.state.cancelFilling()
await flush()
assert.equal(page.state.open.value, false)
assert.match(page.state.shiftResultText.value, /^本班已装填 1 托 · 100 包(B261017-01)/)
const refreshed = page.requests.slice(before).map(item => item.url)
assert.ok(refreshed.includes('/business/filling/batches'))
assert.ok(refreshed.includes('/business/filling/summary'))
await openStation(page)
assert.equal(page.state.shiftRecords.value.length, 0)
assert.equal(page.state.form.value.batchNo, '')
assert.equal(page.state.lockedParams.value, null)
page.close()
})
test('列表:默认按生产批次,切到装填明细使用同一筛选;临期批次不能沿用', async () => {
const page = createPage({ batches: [BATCH, NEAR_BATCH] })
await flush()
const urls = page.requests.map(item => item.url)
assert.deepEqual(urls.slice(0, 2), ['/business/filling/summary', '/business/filling/batches'])
assert.equal(urls.includes('/business/filling/strain-options'), false, '启动阶段不请求 query 权限接口')
assert.equal(page.state.todayHint.value, '600 包 · 1 个批次')
assert.equal(page.state.monthHint.value, '8 个批次 · 平菇 A15 / 白灵菇 B22')
assert.equal(page.state.nearExpiryHint.value, 'B260923-01 · 7 天后到期,请优先发货')
assert.deepEqual(page.state.specPresets.value, ['1kg/包'])
page.state.openBatchPanel(page.state.batchList.value[0])
await flush()
assert.deepEqual(page.state.distributionRows.value.map(item => [item.label, item.palletCount]), [['仓库有料', 4], ['在途待收货', 1], ['已用完', 1]])
assert.equal(page.state.reuseBlocked.value, false)
assert.equal(button(page, '沿用此批参数').props.disabled, false)
page.state.openBatchPanel(page.state.batchList.value[1])
await flush()
assert.equal(page.state.reuseBlocked.value, true)
assert.equal(button(page, '沿用此批参数').props.disabled, true)
page.state.queryParams.value.keyword = 'TP-105'
page.state.viewMode.value = 'record'
page.state.handleViewChange()
await flush()
const last = page.requests.filter(item => item.url === '/business/filling/list').at(-1)
assert.equal(last.params.keyword, 'TP-105')
assert.equal(last.params.pageNum, 1)
assert.equal(page.state.panelBatch.value, null)
page.close()
})