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