Feat: 前端改版 Web 端界面与业务作业流程
按设计稿改版登录后的全部页面,登录页与 Logo 资源未改动。 - 全局外壳:浅色侧边栏与分组菜单、左下角用户卡片、Ctrl/⌘K 搜索面板、 测试模式提示条、深色模式配色、窄屏适配 - 共用组件:页头 PageHeader、托盘流程条 FlowStepper、扫码反馈 ScanFeedback、 缩短单号 DocNo、托盘档案链接 PalletCodeLink、图标 SfIcon、上级资料选择 MasterRefSelect,以及编号段解析等工具 - 工作台重做;新增托盘档案页;托盘追溯增加单托盘入口,报表托盘号可跳转档案 - 基础资料维护:各页加指标条、名称代替 ID、详情面板;三步式批量导入预览; 分配用户对话框标注增减;7 个新增/编辑对话框统一布局 - 业务管理:发货出库、仓库入库、工厂装填、客户现场使用、退回仓库改为 列表 + 详情面板 + 全屏作业区;写入流程、幂等键与防串单逻辑保持不变 - 修复入库页扫码反馈条因同名状态变量遮蔽组件而不显示的问题,并新增契约检查 - 新增入库粘贴解析、退回、装填的行为测试与现场使用页新流程测试, 同步更新业务与工作台契约脚本 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
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()
|
||||
})
|
||||
Reference in New Issue
Block a user