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()
|
||||
})
|
||||
@@ -25,6 +25,9 @@ async function fillingPage(platform) {
|
||||
useUserStore: () => ({ admin: true }), useTestBatchStore: () => ({ isTestMode: false }),
|
||||
onShow() {}, createIdempotencyKey: () => `date-command-${++key}`,
|
||||
listFilling: async () => ({ rows: [], total: 0 }),
|
||||
listFillingBatches: async () => ({ rows: [], total: 0 }),
|
||||
getFillingSummary: async () => ({ data: null }),
|
||||
getFillingBatchDetail: async () => ({ data: null }),
|
||||
addFilling: async payload => { submissions.push(payload); throw new Error('网络暂不可用') },
|
||||
})
|
||||
vm.runInContext(`${script}\nglobalThis.page = {form, scannedPallet, submitFilling, formError};`, context)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { groupCodeRanges, parsePasteTokens, splitPalletCode } from '../src/utils/palletRanges.js'
|
||||
|
||||
test('托盘号拆分为前缀与末尾数字,无数字结尾时返回空', () => {
|
||||
assert.deepEqual(splitPalletCode(' TP-10501 '), { prefix: 'TP-', digits: '10501', number: 10501 })
|
||||
assert.deepEqual(splitPalletCode('PLT-007'), { prefix: 'PLT-', digits: '007', number: 7 })
|
||||
assert.equal(splitPalletCode('TP-A'), null)
|
||||
assert.equal(splitPalletCode(''), null)
|
||||
})
|
||||
|
||||
test('连续编号合并成段,乱序与重复不影响结果', () => {
|
||||
const ranges = groupCodeRanges(['TP-10503', 'TP-10501', 'TP-10502', 'TP-10502', 'TP-10520', 'TP-10521'])
|
||||
assert.deepEqual(
|
||||
ranges.map(({ label, count }) => [label, count]),
|
||||
[['TP-10501 – 10503', 3], ['TP-10520 – 10521', 2]]
|
||||
)
|
||||
})
|
||||
|
||||
test('补零位数保持连续,不同前缀与无数字托盘号单独成段', () => {
|
||||
const ranges = groupCodeRanges(['PLT-0999', 'PLT-1000', 'A-9', 'A-10', 'B-001', 'TP-X'])
|
||||
assert.deepEqual(
|
||||
ranges.map(({ label, count }) => [label, count]),
|
||||
[['A-9 – 10', 2], ['B-001', 1], ['PLT-0999 – 1000', 2], ['TP-X', 1]]
|
||||
)
|
||||
// 位数不同且带补零的编号不是同一段
|
||||
assert.equal(groupCodeRanges(['C-09', 'C-010']).length, 2)
|
||||
})
|
||||
|
||||
test('粘贴内容按换行、空格与中英文标点切分并去重', () => {
|
||||
const result = parsePasteTokens('TP-10501\nTP-10502,TP-10503、TP-10501;TP-10504 TP-10505')
|
||||
assert.deepEqual(result.codes, ['TP-10501', 'TP-10502', 'TP-10503', 'TP-10504', 'TP-10505'])
|
||||
assert.equal(result.duplicates, 1)
|
||||
assert.deepEqual(result.errors, [])
|
||||
})
|
||||
|
||||
test('区间按起始托盘号的前缀与位数展开', () => {
|
||||
assert.deepEqual(parsePasteTokens('TP-10512-10514').codes, ['TP-10512', 'TP-10513', 'TP-10514'])
|
||||
assert.deepEqual(parsePasteTokens('10512-10513').codes, ['10512', '10513'])
|
||||
assert.deepEqual(parsePasteTokens('PLT-008 ~ PLT-010').codes, ['PLT-008', 'PLT-009', 'PLT-010'])
|
||||
assert.deepEqual(parsePasteTokens('PLT-008–010').codes, ['PLT-008', 'PLT-009', 'PLT-010'])
|
||||
})
|
||||
|
||||
test('普通带连字符的托盘号不会被误当作区间', () => {
|
||||
assert.deepEqual(parsePasteTokens('TP-10501').codes, ['TP-10501'])
|
||||
// 右侧不大于左侧时不构成区间
|
||||
assert.deepEqual(parsePasteTokens('A1-0001').codes, ['A1-0001'])
|
||||
})
|
||||
|
||||
test('超长区间与无法识别的波浪号区间给出错误且不展开', () => {
|
||||
const result = parsePasteTokens('TP-1-300 TP-5~TP-3 TP-9', { rangeLimit: 200 })
|
||||
assert.deepEqual(result.codes, ['TP-9'])
|
||||
assert.equal(result.errors.length, 2)
|
||||
assert.match(result.errors[0].message, /最多 200 个/)
|
||||
assert.match(result.errors[1].message, /无法识别/)
|
||||
})
|
||||
|
||||
test('总数超过上限时截断并标记', () => {
|
||||
const result = parsePasteTokens('T-1-10', { totalLimit: 4 })
|
||||
assert.deepEqual(result.codes, ['T-1', 'T-2', 'T-3', 'T-4'])
|
||||
assert.equal(result.truncated, true)
|
||||
})
|
||||
@@ -0,0 +1,361 @@
|
||||
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/return/index.vue'))
|
||||
const script = compileScript(descriptor, { id: 'pc-return-test', genDefaultAs: '__component' })
|
||||
const template = compileTemplate({ source: descriptor.template.content, filename: 'return/index.vue', id: 'pc-return-test', compilerOptions: { bindingMetadata: script.bindings } })
|
||||
assert.deepEqual(template.errors, [])
|
||||
const apiSource = await read('api/business/return.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 docNo = await importText('utils/docNo.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/filling' }] } },
|
||||
'@/utils/palletPassport': passport
|
||||
})
|
||||
const flush = async () => { for (let index = 0; index < 4; index += 1) await new Promise(resolve => setImmediate(resolve)); await Vue.nextTick() }
|
||||
|
||||
const ROW = {
|
||||
returnId: 13,
|
||||
returnNo: 'RE-3f8a6d1c0b9e42f7a5d3c8e1b06fc21d',
|
||||
status: 'REQUESTED',
|
||||
sourceSiteId: 9,
|
||||
targetWarehouseId: 7,
|
||||
sourceSiteName: '南厂',
|
||||
customerName: '金禾菌业',
|
||||
targetWarehouseName: '成品一仓',
|
||||
palletCount: 3,
|
||||
packageSum: 60,
|
||||
filledCount: 1,
|
||||
requestedAt: '2026-10-17T10:20:00',
|
||||
requesterName: '赵强'
|
||||
}
|
||||
const DETAIL = {
|
||||
...ROW,
|
||||
items: [
|
||||
{ returnItemId: 1, palletId: 101, palletCode: 'TP-10466', remainingPackagesSnapshot: 60, tunnelIdSnapshot: 31, tunnelName: '2 号隧道' },
|
||||
{ returnItemId: 2, palletId: 102, palletCode: 'TP-10468', remainingPackagesSnapshot: 0, tunnelIdSnapshot: null, tunnelName: null },
|
||||
{ returnItemId: 3, palletId: 103, palletCode: 'TP-10471', remainingPackagesSnapshot: 0, tunnelIdSnapshot: null, tunnelName: null }
|
||||
]
|
||||
}
|
||||
const SITES = [
|
||||
{ id: 9, code: 'JH-S', name: '南厂', customerName: '金禾菌业' },
|
||||
{ id: 10, code: 'LY-1', name: '一号基地', customerName: '绿源农业' }
|
||||
]
|
||||
const WAREHOUSES = [{ id: 7, code: 'WH-NB01', name: '成品一仓' }, { id: 8, code: 'WH-NB02', name: '成品二仓' }]
|
||||
const SUMMARY = {
|
||||
pendingCount: 1, pendingPalletCount: 3, pendingFilledCount: 1, pendingPackageSum: 60, oldestPendingAt: '2026-10-17T10:20:00',
|
||||
monthStart: '2026-10-01', monthConfirmedCount: 2, monthConfirmedPalletCount: 26, monthFilledPalletCount: 4, monthPackageSum: 210, monthAvgConfirmHours: 20
|
||||
}
|
||||
const CONFIRM_PALLETS = Object.fromEntries(DETAIL.items.map(item => [item.palletCode, { palletId: item.palletId, palletCode: item.palletCode, flowState: 'RETURNING', siteId: 9, remainingPackages: item.remainingPackagesSnapshot }]))
|
||||
const REQUEST_PALLETS = {
|
||||
'TP-10475': { palletId: 201, palletCode: 'TP-10475', flowState: 'AT_SITE_PARTIAL', siteId: 9, remainingPackages: 30, tunnelId: 31, traceNo: 'TR-0042' },
|
||||
'TP-10474': { palletId: 202, palletCode: 'TP-10474', flowState: 'AT_SITE_EMPTY', siteId: 9, remainingPackages: 0, tunnelId: null, traceNo: null },
|
||||
'TP-10520': { palletId: 203, palletCode: 'TP-10520', flowState: 'AT_SITE_EMPTY', siteId: 10, remainingPackages: 0, tunnelId: null, traceNo: null }
|
||||
}
|
||||
|
||||
function defaultScan(token, params) {
|
||||
if (params.mode === 'confirm') {
|
||||
const pallet = CONFIRM_PALLETS[token]
|
||||
return pallet ? Promise.resolve({ data: pallet }) : Promise.reject(new Error('扫码托盘不属于该退回单'))
|
||||
}
|
||||
if (token === 'TP-10288') return Promise.reject(new Error('托盘当前状态不允许发起退回'))
|
||||
const pallet = REQUEST_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 } = {}) {
|
||||
const requests = [], messages = []
|
||||
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/return/list') return Promise.resolve({ rows: [structuredClone(ROW)], total: 1 })
|
||||
if (config.url === '/business/return/summary') return Promise.resolve({ data: SUMMARY })
|
||||
if (config.url === '/business/return/site-options') return Promise.resolve({ data: SITES })
|
||||
if (config.url === '/business/return/warehouse-options') return Promise.resolve({ data: WAREHOUSES })
|
||||
if (config.url === '/business/return/13') return Promise.resolve({ data: structuredClone(DETAIL) })
|
||||
if (config.url.startsWith('/business/return/scan/')) return defaultScan(decodeURIComponent(config.url.split('/scan/')[1]), config.params)
|
||||
if (config.url === '/business/return/13/confirm') return Promise.resolve({ msg: '退回确认成功' })
|
||||
if (config.url === '/business/return/request') 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: msg => messages.push(msg), msgWarning: msg => messages.push(msg) },
|
||||
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/docNo': docNo,
|
||||
'@/utils/masterDisplay': masterDisplay,
|
||||
'@/utils/palletPassport': passport,
|
||||
'@/store/modules/user': { default: () => user },
|
||||
'@/store/modules/testBatch': { default: () => batch },
|
||||
'@/api/business/return': api
|
||||
}
|
||||
// PC 的 Vue API 由 Vite 自动导入;测试以相同名字提供真实响应式 API。
|
||||
const globals = ['ref', 'reactive', 'computed', '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, messages,
|
||||
listCalls: () => requests.filter(item => item.url === '/business/return/list' && item.params.pageSize !== 1),
|
||||
async confirmScan(value) { state.handleConfirmScan(value); await flush() },
|
||||
async requestScan(value) { state.handleRequestScan(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 openConfirm(page) {
|
||||
await flush()
|
||||
page.state.startConfirmFromRow(page.state.returnList.value[0])
|
||||
await flush()
|
||||
}
|
||||
|
||||
async function scanAll(page) {
|
||||
for (const code of ['TP-10466', 'TP-10468', 'TP-10471']) await page.confirmScan(code)
|
||||
}
|
||||
|
||||
test('到仓核对:全部对上前禁止确认,非本单托盘不计入,提交前重新读取详情', async () => {
|
||||
const page = createPage()
|
||||
await openConfirm(page)
|
||||
assert.equal(page.state.jobOpen.value, true)
|
||||
assert.equal(page.state.jobKind.value, 'confirm')
|
||||
assert.deepEqual(page.state.confirmExpectedPalletIds.value, [101, 102, 103])
|
||||
assert.deepEqual(page.state.confirmOutcome.value, { filled: 1, packages: 60, empty: 2, inTunnel: 1 })
|
||||
const confirmButton = () => button(page, '确认退回 ·')
|
||||
assert.equal(confirmButton().props.disabled, true)
|
||||
|
||||
await page.confirmScan('TP-10466')
|
||||
await page.confirmScan('TP-10468')
|
||||
assert.equal(confirmButton().props.disabled, true)
|
||||
assert.equal(page.state.confirmTodoCount.value, 1)
|
||||
assert.equal(page.state.confirmFeedback.value.message, '核对通过 · 还差 1 托')
|
||||
|
||||
await page.confirmScan('TP-10999')
|
||||
assert.equal(page.state.confirmScannedPallets.value.length, 2, '非本单托盘不能计入')
|
||||
assert.deepEqual(page.state.confirmStrays.value.map(item => [item.code, item.message]), [['TP-10999', '扫码托盘不属于该退回单']])
|
||||
await page.confirmScan('TP-10466')
|
||||
assert.equal(page.state.confirmFeedback.value.type, 'info')
|
||||
assert.equal(page.state.confirmScannedPallets.value.length, 2, '重复扫码不追加')
|
||||
|
||||
await page.confirmScan('TP-10471')
|
||||
assert.equal(page.state.confirmComplete.value, true)
|
||||
assert.equal(confirmButton().props.disabled, false)
|
||||
|
||||
const before = page.requests.length
|
||||
page.state.submitReturnConfirm()
|
||||
await flush()
|
||||
const writes = page.requests.slice(before, before + 2)
|
||||
assert.deepEqual(writes.map(item => `${item.method} ${item.url}`), ['get /business/return/13', 'put /business/return/13/confirm'])
|
||||
assert.deepEqual(Object.keys(writes[1].data).sort(), ['idempotencyKey', 'scannedPalletIds'])
|
||||
assert.deepEqual([...writes[1].data.scannedPalletIds].sort(), [101, 102, 103])
|
||||
assert.equal(page.state.jobOpen.value, false)
|
||||
assert.match(page.state.jobResultText.value, /RE-3F8A…C21D 已确认 · 1 托带料(60 包)已回到成品一仓有料库存,2 托空托盘回到成品一仓,可再次装填。/)
|
||||
page.close()
|
||||
})
|
||||
|
||||
test('到仓核对:确认失败后重试复用幂等键,撤销一托后重新锁定确认', async () => {
|
||||
let attempts = 0
|
||||
const page = createPage({
|
||||
request: config => {
|
||||
if (config.url !== '/business/return/13/confirm') return undefined
|
||||
attempts += 1
|
||||
return attempts === 1 ? Promise.reject(new Error('网络异常')) : Promise.resolve({ msg: '退回确认成功' })
|
||||
}
|
||||
})
|
||||
await openConfirm(page)
|
||||
await scanAll(page)
|
||||
|
||||
page.state.removeConfirmPallet(102)
|
||||
await flush()
|
||||
assert.equal(page.state.confirmComplete.value, false)
|
||||
assert.equal(button(page, '确认退回 ·').props.disabled, true)
|
||||
assert.equal(page.state.confirmFeedback.value.message, '已撤销核对,需要重新扫码')
|
||||
await page.confirmScan('TP-10468')
|
||||
|
||||
page.state.submitReturnConfirm()
|
||||
await flush()
|
||||
assert.match(page.state.confirmError.value, /复用本次幂等键/)
|
||||
assert.equal(page.state.jobOpen.value, true, '失败时保留作业区与已核对集合')
|
||||
assert.equal(page.state.confirmScannedPallets.value.length, 3)
|
||||
page.state.submitReturnConfirm()
|
||||
await flush()
|
||||
const keys = page.requests.filter(item => item.method === 'put').map(item => item.data.idempotencyKey)
|
||||
assert.equal(keys.length, 2)
|
||||
assert.equal(keys[0], keys[1])
|
||||
assert.equal(page.state.jobOpen.value, false)
|
||||
page.close()
|
||||
})
|
||||
|
||||
test('到仓核对:详情已变化时不提交确认', async () => {
|
||||
let reads = 0
|
||||
const page = createPage({
|
||||
request: config => {
|
||||
if (config.url !== '/business/return/13') return undefined
|
||||
reads += 1
|
||||
const detail = structuredClone(DETAIL)
|
||||
if (reads > 1) detail.items = detail.items.slice(0, 2)
|
||||
return Promise.resolve({ data: detail })
|
||||
}
|
||||
})
|
||||
await openConfirm(page)
|
||||
await scanAll(page)
|
||||
page.state.submitReturnConfirm()
|
||||
await flush()
|
||||
assert.equal(page.requests.filter(item => item.method === 'put').length, 0)
|
||||
assert.match(page.state.confirmError.value, /明细已变化/)
|
||||
page.close()
|
||||
})
|
||||
|
||||
test('发起申请:先选仓库再扫码,首托锁定来源现场,跨现场与后端拒绝原因进入被拒记录', async () => {
|
||||
const page = createPage()
|
||||
await flush()
|
||||
page.state.startRequestJob()
|
||||
await flush()
|
||||
assert.equal(page.state.jobKind.value, 'request')
|
||||
assert.deepEqual(page.state.warehouseOptions.value, WAREHOUSES)
|
||||
assert.equal(scanInput(page).props.disabled, true, '未选目标仓库时扫码框禁用')
|
||||
|
||||
await page.requestScan('TP-10475')
|
||||
assert.equal(page.requests.filter(item => item.url.includes('/scan/')).length, 0)
|
||||
assert.equal(page.state.requestFeedback.value.type, 'warn')
|
||||
|
||||
page.state.requestForm.value.targetWarehouseId = 7
|
||||
await flush()
|
||||
assert.equal(scanInput(page).props.disabled, false)
|
||||
await page.requestScan('TP-10475')
|
||||
assert.deepEqual(page.requests.filter(item => item.url.includes('/scan/')).at(-1).params, { mode: 'request' })
|
||||
assert.equal(page.state.requestForm.value.sourceSiteId, 9)
|
||||
assert.equal(page.state.requestSiteText.value, '金禾菌业 · 南厂')
|
||||
assert.equal(page.state.requestFeedback.value.message, '已添加 · 现场部分使用 · 余 30 包')
|
||||
|
||||
await page.requestScan('TP-10520')
|
||||
assert.equal(page.state.requestRejects.value[0].message, '同一退回申请只能包含同一客户现场的托盘:该托盘在绿源农业 · 一号基地')
|
||||
await page.requestScan('TP-10288')
|
||||
assert.deepEqual([page.state.requestRejects.value[0].code, page.state.requestRejects.value[0].message], ['TP-10288', '托盘当前状态不允许发起退回'])
|
||||
assert.equal(page.state.requestRejectCount.value, 2)
|
||||
await page.requestScan('TP-10474')
|
||||
await page.requestScan('TP-10474')
|
||||
assert.equal(page.state.requestFeedback.value.message, '已在本单中,未重复添加')
|
||||
assert.deepEqual(page.state.requestScannedPallets.value.map(item => item.palletCode), ['TP-10474', 'TP-10475'])
|
||||
assert.deepEqual(page.state.requestSummary.value, { count: 2, packages: 30, filled: 1, empty: 1 })
|
||||
|
||||
page.state.submitReturnRequest()
|
||||
await flush()
|
||||
const post = page.requests.find(item => item.method === 'post')
|
||||
assert.deepEqual(Object.keys(post.data).sort(), ['idempotencyKey', 'palletIds', 'sourceSiteId', 'targetWarehouseId'])
|
||||
assert.deepEqual([...post.data.palletIds].sort(), [201, 202])
|
||||
assert.equal(post.data.sourceSiteId, 9)
|
||||
assert.equal(post.data.targetWarehouseId, 7)
|
||||
assert.equal(page.state.jobOpen.value, false)
|
||||
assert.equal(page.state.jobResultText.value, '退回申请已提交 · 2 托已变为「退回中」,现场不能再使用。成品一仓收到实物后逐托核对确认。')
|
||||
assert.equal(page.state.activeStatusTab.value, 'requested')
|
||||
assert.equal(page.listCalls().at(-1).params.status, 'REQUESTED')
|
||||
page.close()
|
||||
})
|
||||
|
||||
test('移除最后一托后释放来源现场,可改扫其他现场', async () => {
|
||||
const page = createPage()
|
||||
await flush()
|
||||
page.state.startRequestJob()
|
||||
await flush()
|
||||
page.state.requestForm.value.targetWarehouseId = 7
|
||||
await flush()
|
||||
await page.requestScan('TP-10475')
|
||||
page.state.removeRequestPallet(201)
|
||||
await flush()
|
||||
assert.equal(page.state.requestForm.value.sourceSiteId, undefined)
|
||||
await page.requestScan('TP-10520')
|
||||
assert.equal(page.state.requestForm.value.sourceSiteId, 10)
|
||||
page.close()
|
||||
})
|
||||
|
||||
test('列表:关键字、页签与概况来自真实接口,名称与余量按行展示', async () => {
|
||||
const page = createPage()
|
||||
await flush()
|
||||
assert.deepEqual(page.requests.slice(0, 3).map(item => item.url), ['/business/return/summary', '/business/return/site-options', '/business/return/list'])
|
||||
assert.equal(page.requests.some(item => item.url === '/business/return/warehouse-options'), false, '启动阶段不请求 query 权限接口')
|
||||
assert.equal(page.state.sourceLabel(ROW), '金禾菌业 · 南厂')
|
||||
assert.equal(page.state.targetLabel(ROW), '成品一仓')
|
||||
assert.equal(page.state.packagesText(ROW), '余 60 包')
|
||||
assert.equal(page.state.packagesText({ packageSum: 0 }), '均已用完')
|
||||
assert.equal(page.state.returningHint.value, '其中带料 1 托 · 共 60 包')
|
||||
assert.equal(page.state.monthEmptyCount.value, 22)
|
||||
assert.equal(page.state.monthHint.value, '平均申请到确认 20 小时')
|
||||
assert.equal(page.state.statusCounts.all, 1)
|
||||
|
||||
page.state.queryParams.value.keyword = 'TP-104'
|
||||
page.state.selectStatusTab('confirmed')
|
||||
await flush()
|
||||
const last = page.listCalls().at(-1)
|
||||
assert.equal(last.params.keyword, 'TP-104')
|
||||
assert.equal(last.params.status, 'CONFIRMED')
|
||||
assert.equal(last.params.pageNum, 1)
|
||||
page.close()
|
||||
})
|
||||
|
||||
test('无确认权限时详情面板与行内都不提供到仓核对入口', async () => {
|
||||
const page = createPage({ permissions: perms => !perms.includes('business:return:confirm') })
|
||||
await flush()
|
||||
page.state.openReturnPanel(page.state.returnList.value[0])
|
||||
await flush()
|
||||
assert.equal(page.state.panelItems.value.length, 3)
|
||||
assert.equal(button(page, '开始到仓核对'), undefined)
|
||||
page.state.startConfirmFromRow(page.state.returnList.value[0])
|
||||
await flush()
|
||||
assert.equal(page.state.jobOpen.value, false)
|
||||
assert.equal(page.requests.some(item => item.url.includes('/scan/')), false)
|
||||
page.close()
|
||||
|
||||
const allowed = createPage()
|
||||
await flush()
|
||||
allowed.state.openReturnPanel(allowed.state.returnList.value[0])
|
||||
await flush()
|
||||
assert.ok(button(allowed, '开始到仓核对'))
|
||||
allowed.close()
|
||||
})
|
||||
@@ -13,8 +13,13 @@ const template = compileTemplate({ source: descriptor.template.content, filename
|
||||
assert.deepEqual(template.errors, [])
|
||||
const apiSource = await read('api/business/usage.js')
|
||||
const scanUtils = await import(`data:text/javascript,${encodeURIComponent(await read('utils/palletScan.js'))}`)
|
||||
const passport = await import(`data:text/javascript,${encodeURIComponent(await read('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)}];`)
|
||||
const masterDisplaySource = await read('utils/masterDisplay.js')
|
||||
const masterDisplay = new Function('__modules', `${bindImports(masterDisplaySource).replace(/export (function|const) /g, '$1 ')}\nreturn { ${[...masterDisplaySource.matchAll(/export (?:function|const) (\w+)/g)].map(match => match[1]).join(',')} };`)({ '@/router': { default: { getRoutes: () => [] } }, '@/utils/palletPassport': passport })
|
||||
// 看板默认不含隧道和产线,避免看板名称掩盖托盘摘要中的隧道断言
|
||||
const siteBoard = { siteId: 21, siteCode: 'XC-21', siteName: '一号现场', customerName: '客户A', palletCount: 0, packageSum: 0, unusedPalletCount: 0, partialPalletCount: 0, emptyPalletCount: 0, todayUsageCount: 0, todayUsedPackages: 0, todayByLine: [], tunnelCount: 0, activeTunnelCount: 0, todayFifoSkipCount: 0, weekFifoSkipCount: 0, tunnels: [], lines: [] }
|
||||
const flush = async () => { await new Promise(resolve => setImmediate(resolve)); await Vue.nextTick() }
|
||||
function deferred() {
|
||||
let resolve, reject
|
||||
@@ -46,6 +51,9 @@ function createPage({ scan = () => pallet(), request: override, storage = new Ma
|
||||
if (config.url.endsWith('/line-options')) return Promise.resolve({ data: lines })
|
||||
if (config.url.endsWith('/recommendation')) return Promise.resolve({ data: { recommended: null, unknownArrivalCount: 0, message: '请按实际接收时间先到先用' } })
|
||||
if (config.url.endsWith('/list')) return Promise.resolve({ rows: [], total: 0 })
|
||||
if (config.url === '/business/usage/site-options') return Promise.resolve({ data: [{ id: 21, code: 'XC-21', name: '一号现场', customerName: '客户A', tunnelCount: 1, lineCount: 1 }] })
|
||||
if (config.url === '/business/usage/site-board') return Promise.resolve({ data: siteBoard })
|
||||
if (config.url === '/business/usage-fifo/queue') return Promise.resolve({ data: { siteId: 21, strains: [] } })
|
||||
if (config.url.endsWith('/enter-tunnel')) return Promise.resolve({ data: { palletId: 11, tunnelId: config.data.tunnelId, traceNo: 'INTERNAL-TRACE' } })
|
||||
if (config.url === '/business/usage') return Promise.resolve({ data: { usageId: 71, remainingPackages: 83, flowState: 'AT_SITE_PARTIAL' } })
|
||||
throw new Error(`未处理请求 ${config.url}`)
|
||||
@@ -63,7 +71,7 @@ function createPage({ scan = () => pallet(), request: override, storage = new Ma
|
||||
confirm(message) { confirmations.push(message); return confirm(message) }
|
||||
}, resetForm() {}, $refs: { usageRef: { validate: callback => callback(true) } } }
|
||||
const vue = { ...Vue, getCurrentInstance: () => ({ proxy }), ...Object.fromEntries(Object.keys(hooks).map(name => [name, fn => hooks[name].push(fn)])), resolveComponent: name => ({ name }), resolveDirective: () => ({}) }
|
||||
const modules = { vue, '@/components/PalletScanInput': { default: { name: 'PalletScanInput' } }, '@/components/TestBatchAdoptionDialog': { default: { name: 'TestBatchAdoptionDialog' } }, '@/store/modules/user': { default: () => user }, '@/store/modules/testBatch': { default: () => batch }, '@/api/business/usage': api, '@/utils/palletScan': { ...scanUtils, createIdempotencyKey: () => scanUtils.createIdempotencyKey(webcrypto) } }
|
||||
const modules = { vue, '@/components/PalletScanInput': { default: { name: 'PalletScanInput' } }, '@/components/ScanFeedback': { default: { name: 'ScanFeedback' } }, '@/components/SfIcon': { default: { name: 'SfIcon' } }, '@/utils/masterDisplay': masterDisplay, '@/utils/palletPassport': passport, '@/components/TestBatchAdoptionDialog': { default: { name: 'TestBatchAdoptionDialog' } }, '@/store/modules/user': { default: () => user }, '@/store/modules/testBatch': { default: () => batch }, '@/api/business/usage': api, '@/utils/palletScan': { ...scanUtils, createIdempotencyKey: () => scanUtils.createIdempotencyKey(webcrypto) } }
|
||||
// PC 的 Vue API 由 Vite 自动导入;测试以相同名字提供真实响应式 API。
|
||||
const globals = ['ref', 'reactive', 'computed', 'toRefs', 'watch', 'getCurrentInstance', 'onActivated', 'onDeactivated', 'onBeforeUnmount']
|
||||
const component = new Function('__modules', 'localStorage', ...globals, `${bindImports(script.content)}\nreturn __component;`)(modules, localStorage, ...globals.map(name => vue[name]))
|
||||
@@ -95,7 +103,8 @@ async function retry(page) {
|
||||
test('扫码 API 编码路径,FIFO 使用独立 GET 与规范 qrToken', async () => {
|
||||
const page = createPage()
|
||||
await page.api.scanUsagePallet('托盘/A?#')
|
||||
assert.equal(page.requests.at(-1).url, '/business/usage/scan/%E6%89%98%E7%9B%98%2FA%3F%23')
|
||||
// 页面启动后还会读取现场看板,按路径取最近一次扫码请求再核对编码
|
||||
assert.equal(page.requests.filter(item => item.url.startsWith('/business/usage/scan/')).at(-1).url, '/business/usage/scan/%E6%89%98%E7%9B%98%2FA%3F%23')
|
||||
await page.scan('印刷托盘号')
|
||||
assert.deepEqual(page.requests.find(item => item.url.endsWith('/recommendation')), { url: '/business/usage-fifo/recommendation', method: 'get', params: { qrToken: 'QR-001' } })
|
||||
page.close()
|
||||
@@ -753,3 +762,121 @@ test('F3 真正更换隧道可清除原目标必填,旧推荐不能恢复它',
|
||||
assert.equal(page.state.tunnelLocked.value, true)
|
||||
page.close()
|
||||
})
|
||||
|
||||
const twoSites = config => config.url === '/business/usage/site-options'
|
||||
? Promise.resolve({ data: [{ id: 21, code: 'XC-21', name: '一号现场', customerName: '客户A', tunnelCount: 1, lineCount: 1 }, { id: 22, code: 'XC-22', name: '二号现场', customerName: '客户A', tunnelCount: 0, lineCount: 0 }] })
|
||||
: undefined
|
||||
|
||||
test('现场看板默认选第一个可访问现场,只有主动切换才记住,记住的现场用于首次查询', async () => {
|
||||
const storage = new Map()
|
||||
const page = createPage({ storage, request: twoSites })
|
||||
await flush()
|
||||
assert.deepEqual(page.requests.find(item => item.url === '/business/usage/site-board').params, { siteId: 21 })
|
||||
assert.deepEqual(page.requests.find(item => item.url === '/business/usage-fifo/queue').params, { siteId: 21 })
|
||||
assert.equal(page.requests.filter(item => item.url === '/business/usage/list').at(-1).params.siteId, 21)
|
||||
assert.equal(storage.size, 0, '自动选择不写入本机偏好')
|
||||
page.state.handleSiteChange(22); await flush()
|
||||
assert.equal(storage.get('business:usage:site:v1:7'), '22')
|
||||
assert.deepEqual(page.requests.filter(item => item.url === '/business/usage/site-board').at(-1).params, { siteId: 22 })
|
||||
page.close()
|
||||
|
||||
const again = createPage({ storage, request: twoSites })
|
||||
assert.equal(again.requests.find(item => item.url === '/business/usage/list').params.siteId, 22, '首次查询直接使用记住的现场')
|
||||
await flush()
|
||||
assert.equal(again.state.selectedSiteId.value, 22)
|
||||
again.close()
|
||||
const otherUser = createPage({ storage, userId: 8, request: twoSites })
|
||||
await flush()
|
||||
assert.equal(otherUser.state.selectedSiteId.value, 21, '偏好按账号隔离')
|
||||
otherUser.close()
|
||||
})
|
||||
|
||||
test('偏好写入失败不影响切换,读取到已无权访问的现场时回到第一个现场', async () => {
|
||||
const storage = new Map([['business:usage:site:v1:7', '99']])
|
||||
const page = createPage({ storage, storageWriteError: true })
|
||||
assert.equal(page.requests.find(item => item.url === '/business/usage/list').params.siteId, 99)
|
||||
await flush()
|
||||
assert.equal(page.state.selectedSiteId.value, 21)
|
||||
assert.equal(page.requests.filter(item => item.url === '/business/usage/list').at(-1).params.siteId, 21)
|
||||
page.state.handleSiteChange(21); await flush()
|
||||
assert.equal(storage.get('business:usage:site:v1:7'), '99')
|
||||
assert.equal(page.state.storageError.value, '')
|
||||
page.close()
|
||||
})
|
||||
|
||||
test('隧道卡片切换记录筛选,看板只展示隧道在用情况不展示溯源号', async () => {
|
||||
const board = { ...siteBoard, tunnelCount: 1, activeTunnelCount: 1, tunnels: [{ tunnelId: 51, tunnelCode: 'KB-01', tunnelName: '看板隧道', inUse: true, palletCount: 2, remainingPackages: 150, startedAt: '2026-10-17T08:00:00', lastUsedAt: '2026-10-17T10:00:00', lastLineName: '看板产线' }] }
|
||||
const page = createPage({ request: config => config.url === '/business/usage/site-board' ? Promise.resolve({ data: board }) : undefined })
|
||||
await flush()
|
||||
assert.match(text(page), /看板隧道/)
|
||||
assert.doesNotMatch(text(page), /INTERNAL-TRACE|溯源号/)
|
||||
assert.equal(page.state.tunnelFill(board.tunnels[0]), '75%')
|
||||
page.state.toggleTunnelFilter(51); await flush()
|
||||
assert.equal(page.requests.filter(item => item.url === '/business/usage/list').at(-1).params.tunnelId, 51)
|
||||
page.state.toggleTunnelFilter(51); await flush()
|
||||
assert.equal(page.requests.filter(item => item.url === '/business/usage/list').at(-1).params.tunnelId, undefined)
|
||||
page.close()
|
||||
})
|
||||
|
||||
test('无查询权限不读取看板,无列表权限不读取现场选项', async () => {
|
||||
const page = createPage({ canOperate: false })
|
||||
await flush()
|
||||
assert.equal(page.requests.some(item => ['/business/usage/site-options', '/business/usage/site-board', '/business/usage-fifo/queue'].includes(item.url)), false)
|
||||
page.state.openWork()
|
||||
assert.equal(page.state.workOpen.value, false)
|
||||
page.close()
|
||||
})
|
||||
|
||||
test('作业步骤由扫码、入隧和登记结果推导,快捷包数与预览按当前余量计算', async () => {
|
||||
const page = createPage()
|
||||
await flush()
|
||||
page.state.openWork()
|
||||
assert.equal(page.state.workOpen.value, true)
|
||||
assert.equal(page.state.workStep.value, 1)
|
||||
await page.scan()
|
||||
assert.equal(page.state.workStep.value, 2)
|
||||
page.state.selectTunnel(31)
|
||||
assert.equal(page.state.form.value.tunnelId, 31)
|
||||
assert.equal(page.state.selectedTunnelName.value, '一号隧道')
|
||||
await page.act('enterTunnel')
|
||||
assert.equal(page.state.workStep.value, 3)
|
||||
page.state.selectTunnel(32)
|
||||
assert.equal(page.state.form.value.tunnelId, 31, '入隧后不能再切换隧道')
|
||||
assert.deepEqual(page.state.quickAmounts.value.map(item => item.label), ['10', '20', '50', '全部 100'])
|
||||
page.state.form.value.usedPackages = 30
|
||||
assert.deepEqual({ ...page.state.usagePreview.value }, { text: '提交后余 70 包', bad: false })
|
||||
page.state.form.value.usedPackages = 120
|
||||
assert.equal(page.state.usagePreview.value.bad, true)
|
||||
page.state.form.value.usedPackages = 17
|
||||
const boards = page.requests.filter(item => item.url === '/business/usage/site-board').length
|
||||
await page.act('submitUsage')
|
||||
assert.equal(page.state.workStep.value, 4)
|
||||
assert.equal(page.requests.filter(item => item.url === '/business/usage/site-board').length, boards + 1, '登记成功后刷新看板')
|
||||
page.state.continueSamePallet(); await flush()
|
||||
assert.equal(page.requests.filter(item => item.url.startsWith('/business/usage/scan/')).at(-1).url, '/business/usage/scan/QR-001')
|
||||
page.state.closeWork(); await flush()
|
||||
assert.equal(page.state.workOpen.value, false)
|
||||
assert.equal(page.state.scannedPallet.value, null)
|
||||
page.close()
|
||||
})
|
||||
|
||||
test('先到先用:可一键改用建议托盘,快捷原因写入同一原因字段', async () => {
|
||||
const page = createPage({ request: config => config.url.endsWith('/recommendation')
|
||||
? Promise.resolve({ data: { recommended: { palletId: 9, palletCode: '较早托盘', batchNo: '较早批次', remainingPackages: 25, siteArrivedAt: '2026-10-01 09:00:00' }, unknownArrivalCount: 0, requiresSkipReason: true, message: '请填写跳过原因' } })
|
||||
: undefined })
|
||||
await page.scan()
|
||||
assert.equal(page.state.recommendedOther.value, true)
|
||||
assert.ok(button(page, '改用 较早托盘'))
|
||||
page.state.pickFifoReason('建议托盘取不出')
|
||||
assert.equal(page.state.form.value.fifoSkipReason, '建议托盘取不出')
|
||||
assert.equal(page.state.isReasonActive('建议托盘取不出'), true)
|
||||
page.state.pickFifoReason('其他')
|
||||
assert.equal(page.state.form.value.fifoSkipReason, '')
|
||||
page.state.form.value.fifoSkipReason = '叉车暂时取不出'
|
||||
assert.equal(page.state.isReasonActive('其他'), true)
|
||||
page.state.pickFifoReason('其他')
|
||||
assert.equal(page.state.form.value.fifoSkipReason, '叉车暂时取不出', '自填原因不被「其他」清空')
|
||||
page.state.useRecommendedPallet(); await flush()
|
||||
assert.equal(page.requests.filter(item => item.url.startsWith('/business/usage/scan/')).at(-1).url, `/business/usage/scan/${encodeURIComponent('较早托盘')}`)
|
||||
page.close()
|
||||
})
|
||||
|
||||
@@ -91,13 +91,15 @@ const expectedRequests = {
|
||||
{ url: "'/master/site'", method: 'post' },
|
||||
{ url: "'/master/site'", method: 'put' },
|
||||
{ url: "'/master/site/' + siteId + '/users'", method: 'get' },
|
||||
{ url: "'/master/site/' + siteId + '/users'", method: 'put' }
|
||||
{ url: "'/master/site/' + siteId + '/users'", method: 'put' },
|
||||
{ url: "'/master/site/customer-options'", method: 'get' }
|
||||
],
|
||||
'src/api/master/line.js': [
|
||||
{ url: "'/master/line/list'", method: 'get' },
|
||||
{ url: "'/master/line/' + lineId", method: 'get' },
|
||||
{ url: "'/master/line'", method: 'post' },
|
||||
{ url: "'/master/line'", method: 'put' },
|
||||
{ url: "'/master/line/site-options'", method: 'get' }
|
||||
],
|
||||
'src/api/master/warehouse.js': [
|
||||
{ url: "'/master/warehouse/list'", method: 'get' },
|
||||
@@ -119,9 +121,11 @@ const expectedRequests = {
|
||||
{ url: "'/master/tunnel/' + tunnelId", method: 'get' },
|
||||
{ url: "'/master/tunnel'", method: 'post' },
|
||||
{ url: "'/master/tunnel'", method: 'put' },
|
||||
{ url: "'/master/tunnel/site-options'", method: 'get' }
|
||||
],
|
||||
'src/api/business/inbound.js': [
|
||||
{ url: "'/business/inbound/list'", method: 'get' },
|
||||
{ url: "'/business/inbound/summary'", method: 'get' },
|
||||
{ url: "'/business/inbound/warehouse-options'", method: 'get' },
|
||||
{ url: "'/business/inbound/scan/' + encodeURIComponent(qrToken)", method: 'get' },
|
||||
{ url: "'/business/inbound/' + inboundId", method: 'get' },
|
||||
@@ -131,6 +135,9 @@ const expectedRequests = {
|
||||
],
|
||||
'src/api/business/filling.js': [
|
||||
{ url: "'/business/filling/list'", method: 'get' },
|
||||
{ url: "'/business/filling/summary'", method: 'get' },
|
||||
{ url: "'/business/filling/batches'", method: 'get' },
|
||||
{ url: "'/business/filling/batches/detail'", method: 'get' },
|
||||
{ url: "'/business/filling/strain-options'", method: 'get' },
|
||||
{ url: "'/business/filling/scan/' + encodeURIComponent(qrToken)", method: 'get' },
|
||||
{ url: "'/business/filling/' + fillingId", method: 'get' },
|
||||
@@ -151,6 +158,9 @@ const expectedRequests = {
|
||||
{ url: "'/business/usage/list'", method: 'get' },
|
||||
{ url: "'/business/usage/line-options'", method: 'get' },
|
||||
{ url: "'/business/usage/tunnel-options'", method: 'get' },
|
||||
{ url: "'/business/usage/site-options'", method: 'get' },
|
||||
{ url: "'/business/usage/site-board'", method: 'get' },
|
||||
{ url: "'/business/usage-fifo/queue'", method: 'get' },
|
||||
{ url: "'/business/usage/enter-tunnel'", method: 'post' },
|
||||
{ url: "'/business/usage/scan/' + encodeURIComponent(qrToken)", method: 'get' },
|
||||
{ url: "'/business/usage-fifo/recommendation'", method: 'get' },
|
||||
@@ -159,6 +169,8 @@ const expectedRequests = {
|
||||
],
|
||||
'src/api/business/return.js': [
|
||||
{ url: "'/business/return/list'", method: 'get' },
|
||||
{ url: "'/business/return/summary'", method: 'get' },
|
||||
{ url: "'/business/return/site-options'", method: 'get' },
|
||||
{ url: "'/business/return/warehouse-options'", method: 'get' },
|
||||
{ url: "'/business/return/scan/' + encodeURIComponent(qrToken)", method: 'get' },
|
||||
{ url: "'/business/return/request'", method: 'post' },
|
||||
@@ -211,14 +223,14 @@ const masterPageContracts = {
|
||||
},
|
||||
site: {
|
||||
relativePath: 'src/views/master/site/index.vue',
|
||||
apiNames: ['addSite', 'getSite', 'listSite', 'updateSite'],
|
||||
apiNames: ['addSite', 'getSite', 'getSiteCustomerOptions', 'listSite', 'updateSite'],
|
||||
permissions: ['add', 'assign', 'edit', 'list', 'query'],
|
||||
addPayloadKeys: ['address', 'customerId', 'remark', 'siteCode', 'siteName', 'status'],
|
||||
editPayloadKeys: ['address', 'customerId', 'remark', 'siteCode', 'siteId', 'siteName', 'status']
|
||||
},
|
||||
line: {
|
||||
relativePath: 'src/views/master/line/index.vue',
|
||||
apiNames: ['addLine', 'getLine', 'listLine', 'updateLine'],
|
||||
apiNames: ['addLine', 'getLine', 'getLineSiteOptions', 'listLine', 'updateLine'],
|
||||
permissions: ['add', 'edit', 'list', 'query'],
|
||||
addPayloadKeys: ['lineCode', 'lineName', 'remark', 'siteId', 'status'],
|
||||
editPayloadKeys: ['lineCode', 'lineId', 'lineName', 'remark', 'siteId', 'status']
|
||||
@@ -246,6 +258,7 @@ const businessPageContracts = {
|
||||
'addInbound',
|
||||
'confirmInbound',
|
||||
'getInbound',
|
||||
'getInboundSummary',
|
||||
'getInboundWarehouseOptions',
|
||||
'listInbound',
|
||||
'scanInboundPallet',
|
||||
@@ -260,7 +273,15 @@ const businessPageContracts = {
|
||||
},
|
||||
filling: {
|
||||
relativePath: 'src/views/business/filling/index.vue',
|
||||
apiNames: ['addFilling', 'getFillingStrainOptions', 'listFilling', 'scanFillingPallet'],
|
||||
apiNames: [
|
||||
'addFilling',
|
||||
'getFillingBatchDetail',
|
||||
'getFillingStrainOptions',
|
||||
'getFillingSummary',
|
||||
'listFilling',
|
||||
'listFillingBatches',
|
||||
'scanFillingPallet'
|
||||
],
|
||||
permissions: ['add', 'list', 'query'],
|
||||
payloads: {
|
||||
addFilling: [
|
||||
@@ -298,7 +319,18 @@ const businessPageContracts = {
|
||||
},
|
||||
usage: {
|
||||
relativePath: 'src/views/business/usage/index.vue',
|
||||
apiNames: ['addUsage', 'enterUsageTunnel', 'getUsageFifoRecommendation', 'getUsageLineOptions', 'getUsageTunnelOptions', 'listUsage', 'scanUsagePallet'],
|
||||
apiNames: [
|
||||
'addUsage',
|
||||
'enterUsageTunnel',
|
||||
'getUsageFifoQueue',
|
||||
'getUsageFifoRecommendation',
|
||||
'getUsageLineOptions',
|
||||
'getUsageSiteBoard',
|
||||
'getUsageSiteOptions',
|
||||
'getUsageTunnelOptions',
|
||||
'listUsage',
|
||||
'scanUsagePallet'
|
||||
],
|
||||
permissions: ['add', 'list', 'query'],
|
||||
// 使用请求需原样持久化以安全重试,载荷边界由 usage-behavior 的真实请求断言验证。
|
||||
payloads: {}
|
||||
@@ -308,6 +340,8 @@ const businessPageContracts = {
|
||||
apiNames: [
|
||||
'confirmReturn',
|
||||
'getReturn',
|
||||
'getReturnSiteOptions',
|
||||
'getReturnSummary',
|
||||
'getReturnWarehouseOptions',
|
||||
'listReturn',
|
||||
'requestReturn',
|
||||
@@ -1538,7 +1572,7 @@ test('首次入库确认失败后取消重试不丢弃原幂等键', async () =>
|
||||
|
||||
test('18 个 API 文件逐一匹配硬编码请求契约', async (t) => {
|
||||
assert.equal(Object.keys(expectedRequests).length, 18)
|
||||
assert.equal(Object.values(expectedRequests).flat().length, 74)
|
||||
assert.equal(Object.values(expectedRequests).flat().length, 86)
|
||||
|
||||
for (const [relativePath, expected] of Object.entries(expectedRequests)) {
|
||||
await t.test(relativePath, async () => {
|
||||
@@ -2923,3 +2957,20 @@ test('三个业务菜单公共样式统一使用 Element Plus 主题变量', asy
|
||||
assert.doesNotMatch(styles, /background:\s*#f7f9fc\s*!important/)
|
||||
assert.doesNotMatch(styles, /background:\s*#fbfcfe\s*;/)
|
||||
})
|
||||
|
||||
test('业务页面导入的组件不被同名响应式状态遮蔽', async (t) => {
|
||||
// 非 .vue 路径导入的组件按 maybe-ref 绑定,模板标签会优先解析为同名 camelCase 状态(如 scanFeedback 遮蔽 ScanFeedback)
|
||||
const { compileScript, parse } = await import('@vue/compiler-sfc')
|
||||
for (const [resource, contract] of Object.entries(businessPageContracts)) {
|
||||
await t.test(resource, async () => {
|
||||
const source = await readFile(path.join(frontendRoot, contract.relativePath), 'utf8')
|
||||
const { descriptor } = parse(source)
|
||||
const compiled = compileScript(descriptor, { id: `contract-${resource}`, inlineTemplate: true }).content
|
||||
const components = [...source.matchAll(/^import\s+([A-Z]\w*)\s+from\s+['"]@\/components\/[^'"]+['"]/gm)].map((match) => match[1])
|
||||
for (const component of components) {
|
||||
const shadow = component[0].toLowerCase() + component.slice(1)
|
||||
assert.doesNotMatch(compiled, new RegExp(`_create(?:VNode|Block)\\(_unref\\(${shadow}\\)`), `${component} 被同名状态 ${shadow} 遮蔽`)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -8,27 +8,27 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const dashboardPath = path.resolve(scriptDir, '../src/views/dashboard/index.vue')
|
||||
const dashboardSource = fs.readFileSync(dashboardPath, 'utf8')
|
||||
|
||||
test('首页包含仓储运营总览的核心模块', () => {
|
||||
test('首页工作台包含扫码入口、流转看板与核心运营模块', () => {
|
||||
for (const label of [
|
||||
'仓储运营总览',
|
||||
'当前库存托盘',
|
||||
'剩余包数',
|
||||
'今日入库单',
|
||||
'今日出库单',
|
||||
'待确认单据',
|
||||
'新增入库',
|
||||
'托盘填充',
|
||||
'扫描托盘码',
|
||||
'快捷作业',
|
||||
'新建作业',
|
||||
'在库托盘',
|
||||
'客户现场在用',
|
||||
'今日发货',
|
||||
'待处理',
|
||||
'托盘流转',
|
||||
'装填与发货',
|
||||
'临期预警',
|
||||
'仓库库存',
|
||||
'最近动态',
|
||||
'仓库入库',
|
||||
'工厂装填',
|
||||
'发货出库',
|
||||
'托盘使用',
|
||||
'托盘退回',
|
||||
'托盘追踪',
|
||||
'近 7 天入库 / 出库趋势',
|
||||
'托盘流转状态分布',
|
||||
'仓库库存排行',
|
||||
'最近入库单',
|
||||
'最近出库单',
|
||||
'扫码异常',
|
||||
'待确认业务'
|
||||
'现场使用',
|
||||
'退回仓库',
|
||||
'托盘追溯',
|
||||
'扫码异常'
|
||||
]) {
|
||||
assert.match(dashboardSource, new RegExp(label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user