优化
This commit is contained in:
@@ -49,3 +49,11 @@ export function downloadWarehouseTransferPalletLabel(labelDownloadPath) {
|
||||
if (!path) return Promise.reject(new Error('服务端未返回托盘标签下载地址'))
|
||||
return overseasDownload(path)
|
||||
}
|
||||
|
||||
export function countWarehouseTransferCartons(transferNo, payload) {
|
||||
return overseasRequest('POST', `${BASE}/${encoded(transferNo)}/cartons/count`, payload)
|
||||
}
|
||||
|
||||
export function getWarehouseTransferCartonLabel(transferNo) {
|
||||
return overseasRequest('GET', `${BASE}/${encoded(transferNo)}/carton-label`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<template><view><view class="measure-grid"><label><text>长(cm)</text><input v-model="box.length" type="digit" class="measure-input" :disabled="disabled" placeholder="长度" /></label><label><text>宽(cm)</text><input v-model="box.width" type="digit" class="measure-input" :disabled="disabled" placeholder="宽度" /></label><label><text>高(cm)</text><input v-model="box.height" type="digit" class="measure-input" :disabled="disabled" placeholder="高度" /></label><label><text>重量(kg)</text><input v-model="box.weight" type="digit" class="measure-input" :disabled="disabled" placeholder="重量" /></label></view><view class="volume-chip"><text>体积</text><strong>{{ boxVolume(box) }} cm³</strong></view>
|
||||
<view v-for="(line, lineIndex) in box.items" :key="lineIndex" class="count-line"><picker class="sku-picker" :disabled="disabled || !allowSkuChange" :range="skuCodes" :value="Math.max(0, skuCodes.indexOf(line.skuCode))" @change="line.skuCode = skuCodes[Number($event.detail.value)]"><text>{{ line.skuCode || '选择 SKU' }} ▾</text></picker><input v-model="line.quantity" type="number" class="number-input quantity" :disabled="disabled" placeholder="数量" /><button v-if="!disabled && allowSkuChange && box.items.length > 1" class="link-button" :disabled="disabled" @tap="box.items.splice(lineIndex, 1)">删除</button></view></view></template>
|
||||
<script>
|
||||
export default {
|
||||
props: { box: { type: Object, required: true }, skuCodes: { type: Array, default: () => [] }, disabled: Boolean, allowSkuChange: { type: Boolean, default: true } },
|
||||
methods: { boxVolume(box) { const value = Number(box.length) * Number(box.width) * Number(box.height); return Number.isFinite(value) && value > 0 ? value.toFixed(2) : '—' } }
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.count-line{display:flex;align-items:center;gap:8px;margin-top:8px}.sku-picker{flex:1;min-width:0;font-size:12px;word-break:break-all}.quantity{box-sizing:border-box;flex:none;width:90px;height:34px;text-align:center;font-size:13px;border:1px solid #cbd9e8;border-radius:8px;background:#fbfdff}.link-button{width:auto;font-size:12px;color:#135ee8}
|
||||
.measure-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:7px;margin:10px 0 6px}.measure-grid label text{display:block;margin-bottom:4px;color:#65768b;font-size:10px}.measure-input{box-sizing:border-box;width:100%;height:34px;padding:0 8px;border:1px solid #cbd9e8;border-radius:8px;color:#203750;font-size:13px;text-align:center;background:#fbfdff}.volume-chip{display:flex;align-items:baseline;justify-content:flex-end;gap:4px;margin:4px 0 8px;color:#728197;font-size:10px}.volume-chip strong{color:#135ee8;font-size:14px}
|
||||
</style>
|
||||
@@ -5,7 +5,7 @@
|
||||
<view class="portal-content fulfillment-content">
|
||||
<view class="fulfillment-heading"><text class="fulfillment-title">出库任务</text><text class="fulfillment-subtitle">整托扫描托盘码,散箱扫描箱唛;扫描排车业务号会转入“排车拣货”</text></view>
|
||||
<view v-if="current" class="detail-toolbar"><view><text class="detail-kicker">当前任务</text><text class="detail-title">出库作业详情</text></view><button :disabled="busy" @tap="closeTask">‹ 返回列表</button></view>
|
||||
<view class="fulfillment-card">
|
||||
<view v-if="!discardCurrent" class="fulfillment-card">
|
||||
<ScanTargetInput name="outbound" :model-value="scanCode" :label="scanLabel" :placeholder="scanPlaceholder" :active="true" @update:modelValue="updateScanCode" @submit="handleScan" />
|
||||
<button class="secondary-button" :disabled="!scanCode || loading || busy || discardLoading || discardBusy" @tap="handleScan(scanCode)">{{ discardCurrent ? (discardTask?.scanMode === 'CARTON' ? '核对废弃箱唛' : '打开废弃托盘') : current ? '匹配出库货物' : '打开出库任务' }}</button>
|
||||
<text class="scan-help">任务号直接恢复;退货整托出库扫托盘码,散箱出库扫箱唛。</text>
|
||||
|
||||
@@ -13,8 +13,7 @@
|
||||
<template v-else>
|
||||
<view class="work-card"><text class="card-title">{{ task.palletCode }}</text><text class="hint">{{ task.status === 'COUNTING' ? '清点中' : '已完成' }}</text><text class="hint">{{ task.returnNo }} · {{ task.boxes.length }} 箱</text><view v-for="row in totals" :key="row.skuCode" class="total-row" :class="{ matched: row.actual === row.quantity }"><text>{{ row.skuCode }}</text><text>签收 {{ row.quantity }} / 已填 {{ row.actual }}</text></view></view>
|
||||
<view v-for="(box, index) in task.boxes" :id="`box-${index}`" :key="box.cartonMark" class="work-card box-card" :class="{ selected: box.cartonMark === selectedMark }">
|
||||
<view class="row-head"><text class="card-title">第 {{ index + 1 }} 箱</text><button v-if="task.status === 'COUNTING'" class="link-button" :disabled="busy" @tap="addSku(box)">添加 SKU</button></view><text class="box-mark">{{ box.cartonMark }}</text><view class="measure-grid"><label><text>长(cm)</text><input v-model="box.length" type="digit" class="measure-input" :disabled="busy || task.status !== 'COUNTING'" placeholder="长度" /></label><label><text>宽(cm)</text><input v-model="box.width" type="digit" class="measure-input" :disabled="busy || task.status !== 'COUNTING'" placeholder="宽度" /></label><label><text>高(cm)</text><input v-model="box.height" type="digit" class="measure-input" :disabled="busy || task.status !== 'COUNTING'" placeholder="高度" /></label><label><text>重量(kg)</text><input v-model="box.weight" type="digit" class="measure-input" :disabled="busy || task.status !== 'COUNTING'" placeholder="重量" /></label></view><view class="volume-chip"><text>体积</text><strong>{{ boxVolume(box) }} cm³</strong></view>
|
||||
<view v-for="(line, lineIndex) in box.items" :key="lineIndex" class="count-line"><picker class="sku-picker" :disabled="busy || task.status !== 'COUNTING'" :range="skuCodes" :value="Math.max(0, skuCodes.indexOf(line.skuCode))" @change="line.skuCode = skuCodes[Number($event.detail.value)]"><text>{{ line.skuCode || '选择 SKU' }} ▾</text></picker><input v-model="line.quantity" type="number" class="number-input quantity" :disabled="busy || task.status !== 'COUNTING'" placeholder="数量" /><button v-if="task.status === 'COUNTING' && box.items.length > 1" class="link-button" :disabled="busy" @tap="box.items.splice(lineIndex, 1)">删除</button></view>
|
||||
<view class="row-head"><text class="card-title">第 {{ index + 1 }} 箱</text><button v-if="task.status === 'COUNTING'" class="link-button" :disabled="busy" @tap="addSku(box)">添加 SKU</button></view><text class="box-mark">{{ box.cartonMark }}</text><CartonCountFields :box="box" :sku-codes="skuCodes" :disabled="busy || task.status !== 'COUNTING'" />
|
||||
</view>
|
||||
<view class="work-card"><text class="hint">各箱 SKU 数量汇总必须与本托签收数量一致,确认后这些箱子才能换标、合箱或创建出库单。</text><button v-if="task.status === 'COUNTING'" class="primary" :disabled="busy || !ready" @tap="confirm">{{ busy ? '提交中…' : '确认全部箱内清点' }}</button><text v-else class="success">箱内清点已完成,可以继续换标或出库。</text></view>
|
||||
</template>
|
||||
@@ -22,13 +21,14 @@
|
||||
</view>
|
||||
</template>
|
||||
<script>
|
||||
import CartonCountFields from '@/components/CartonCountFields.vue'
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
import { listReturnCountTasks, scanReturnCountTasks, getCurrentReturnCountTask, beginReturnCountTask, confirmReturnCount } from '@/api/return-counting.js'
|
||||
export default {
|
||||
components: { PortalHeader, ScanTargetInput },
|
||||
components: { PortalHeader, ScanTargetInput, CartonCountFields },
|
||||
data() { return { user: session.currentUser, busy: false, error: null, scanCode: '', scanResult: '', page: 1, total: 0, tasks: [], task: null, activeTask: null, selectedMark: '', requestKey: '', signature: '', scannerStop: null } },
|
||||
computed: {
|
||||
skuCodes() { return this.task?.expectedItems.map(row => row.skuCode) || [] },
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@ import { runInNewContext } from 'node:vm'
|
||||
|
||||
const source = readFileSync(new URL('../src/pages/return-counting/return-counting.vue', import.meta.url), 'utf8')
|
||||
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^import .+$/gm, '').replace('export default', 'page =')
|
||||
const scope = { PortalHeader: {}, ScanTargetInput: {} }
|
||||
const scope = { PortalHeader: {}, ScanTargetInput: {}, CartonCountFields: {} }
|
||||
runInNewContext(script, scope)
|
||||
|
||||
function state(a = '2', b = '2', c = '2') {
|
||||
|
||||
@@ -54,12 +54,12 @@ test('warehouse transfer removes completed work from the pending list', async ()
|
||||
assert.match(page, /this\.selected = null; this\.pendingKey = '';[\s\S]*await this\.loadCandidates\(\)/)
|
||||
})
|
||||
|
||||
test('warehouse transfer shows forecast and actual variance without blocking short or over receipt', async () => {
|
||||
test('warehouse transfer shows forecast and actual variance and requires counted box reconciliation', async () => {
|
||||
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
|
||||
for (const label of ['客户预报', '当前实收', '短收差异', '超收差异']) assert.match(page, new RegExp(label))
|
||||
assert.match(page, /cartonDifference/)
|
||||
assert.doesNotMatch(page, /全部托盘箱数必须等于订单计划箱数/)
|
||||
assert.doesNotMatch(page, /本托 SKU 箱数|itemCounts|transferItemId/)
|
||||
assert.doesNotMatch(page, /本托 SKU 箱数|itemCounts/)
|
||||
})
|
||||
|
||||
test('warehouse transfer retries reuse the pending idempotency key', async () => {
|
||||
@@ -71,13 +71,13 @@ test('warehouse transfer retries reuse the pending idempotency key', async () =>
|
||||
|
||||
test('warehouse transfer measurement inputs retain explicit placeholders', async () => {
|
||||
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
|
||||
for (const placeholder of ['长度', '宽度', '高度', '毛重']) assert.match(page, new RegExp(`placeholder="${placeholder}"`))
|
||||
assert.doesNotMatch(page, /v-model.number="pallet\.(lengthCm|widthCm|heightCm|grossWeightKg)"/)
|
||||
})
|
||||
|
||||
test('warehouse transfer measurements are grouped and summarized per pallet', async () => {
|
||||
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
|
||||
assert.match(page, /v-for="\(pallet, index\) in pallets"/)
|
||||
for (const label of ['本托实测', '当前托重量', '当前托箱数', '自动体积']) assert.match(page, new RegExp(label))
|
||||
for (const label of ['本托箱子汇总', '实收箱数', '箱子重量 kg', '箱子体积 m³']) assert.match(page, new RegExp(label))
|
||||
assert.match(page, /palletVolume\(pallet\)/)
|
||||
assert.match(page, /rawPalletVolume\(pallet\)/)
|
||||
})
|
||||
@@ -138,7 +138,7 @@ test('warehouse transfer pallet cards prepare generated and server-bound states'
|
||||
assert.match(page, /扫描或手输新托盘标签号/)
|
||||
assert.match(page, /已绑定实物托盘/)
|
||||
assert.match(page, /!pallet\.generated.*removePallet/)
|
||||
assert.match(page, /:disabled="pallet\.generated"/)
|
||||
assert.match(page, /请先生成托盘号,再填写签收箱子/)
|
||||
assert.match(page, /collapsed: false/)
|
||||
assert.match(page, /pallet\.collapsed = !pallet\.collapsed/)
|
||||
assert.match(page, /v-show="!pallet\.collapsed"/)
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
const source = readFileSync(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue',import.meta.url),'utf8')
|
||||
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^import .+$/gm,'').replace('export default','page =')
|
||||
function setup() {
|
||||
const storage = new Map()
|
||||
const scope = { CartonCountFields:{},PortalHeader:{},ScanTargetInput:{},session:{apiBase:'LOCAL',currentUser:{id:'OP',warehouseId:'WH'}},uni:{getStorageSync:key=>storage.get(key),setStorageSync:(key,value)=>storage.set(key,JSON.parse(JSON.stringify(value))),removeStorageSync:key=>storage.delete(key)} }
|
||||
runInNewContext(script,scope)
|
||||
const value = scope.page.data()
|
||||
for(const [key,method] of Object.entries(scope.page.methods)) value[key]=method.bind(value)
|
||||
for(const [key,get] of Object.entries(scope.page.computed)) Object.defineProperty(value,key,{get:()=>get.call(value)})
|
||||
value.selected={transferNo:'WT1',packageCount:1,status:'WAIT_PDA_RECEIVING',items:[{id:'I1',skuCode:'A'}],cartons:[{cartonMark:'BOX1',transferItemId:'I1',status:'EXPECTED'}]}
|
||||
value.pallets=[{key:'P1',draftId:'P1',palletNo:'PALLET1',generated:true,bound:false}];value.restoreCartons();Object.assign(value.cartonBoxes[0],{length:10,width:20,height:30,weight:2});value.cartonBoxes[0].items[0].quantity=4
|
||||
return {scope,value,storage}
|
||||
}
|
||||
test('transfer counts require valid pieces and measurements and record variance reason',()=>{
|
||||
const {value}=setup();assert.equal(value.cartonCountReady,true)
|
||||
value.cartonBoxes[0].items[0].quantity=0;assert.equal(value.cartonCountReady,false)
|
||||
value.cartonBoxes[0].items[0].quantity=1.5;assert.equal(value.cartonCountReady,false)
|
||||
value.cartonBoxes[0].items[0].quantity=4;value.cartonBoxes[0].height=0;assert.equal(value.cartonCountReady,false)
|
||||
value.cartonBoxes[0].height=30;value.addExtraCarton();Object.assign(value.cartonBoxes[1],{length:10,width:20,height:30,weight:2});value.cartonBoxes[1].items[0].quantity=5
|
||||
assert.equal(value.cartonCountReady,false);value.countDiscrepancyReason='多一箱';assert.equal(value.cartonCountReady,true)
|
||||
assert.equal(value.cartonQuantity,9);assert.equal(value.cartonVolume,'0.012000')
|
||||
})
|
||||
test('network retry preserves request key and changed content uses a new request',async()=>{
|
||||
const {value,scope}=setup();const requests=[]
|
||||
scope.countWarehouseTransferCartons=async(no,request)=>{requests.push(request);throw new Error('offline')}
|
||||
await value.confirmCartons();await value.confirmCartons()
|
||||
assert.equal(requests.length,2);assert.equal(requests[0].idempotencyKey,requests[1].idempotencyKey)
|
||||
value.cartonBoxes[0].items[0].quantity=8;await value.confirmCartons();assert.notEqual(requests[1].idempotencyKey,requests[2].idempotencyKey)
|
||||
assert.equal(value.countingCartons,false);assert.equal(value.error.message,'offline')
|
||||
})
|
||||
test('draft restores additional cartons and discrepancy reason and scanning rejects foreign box',()=>{
|
||||
const {value}=setup();value.addExtraCarton();value.countDiscrepancyReason='超收';value.saveCartonDraft();value.restoreCartons()
|
||||
assert.equal(value.cartonBoxes.length,2);assert.equal(value.countDiscrepancyReason,'超收')
|
||||
value.locateCarton('FOREIGN');assert.match(value.error.message,/不属于/)
|
||||
value.locateCarton('BOX1');assert.equal(value.selectedCartonMark,'BOX1');assert.equal(value.cartonScrollTarget,'transfer-box-0')
|
||||
value.selected={...value.selected,transferNo:'WT2'};value.restoreCartons();assert.equal(value.cartonBoxes.length,1)
|
||||
})
|
||||
test('received count gates pallet completion and saving cannot switch active order',()=>{
|
||||
const {value}=setup();assert.match(value.validationMessage,/先完成全部箱子/)
|
||||
value.selected.cartons[0].countedAt='2026-10-15';value.cartonBoxes[0].transferPalletId='FOREIGN';assert.match(value.validationMessage,/已清点实收箱数/)
|
||||
value.countingCartons=true;value.backToCandidates();assert.equal(value.selected.transferNo,'WT1')
|
||||
})
|
||||
|
||||
test('batch applies only selected received boxes of one SKU and preserves independent drafts',()=>{
|
||||
const {value}=setup()
|
||||
value.addExtraCarton();value.addExtraCarton();value.addExtraCarton()
|
||||
value.cartonBoxes[2].items[0].skuCode='B'
|
||||
value.cartonBoxes[3].received=false
|
||||
value.selectBatchCartons(true)
|
||||
assert.equal(value.batchTargetBoxes.length,2)
|
||||
value.toggleBatchCarton(0)
|
||||
Object.assign(value.batchBox,{length:32,width:24,height:18,weight:3.2})
|
||||
value.batchBox.items[0].quantity=6
|
||||
assert.equal(value.batchTotalQuantity,6)
|
||||
value.applyBatchCartons()
|
||||
assert.equal(value.cartonBoxes[0].items[0].quantity,4)
|
||||
assert.equal(value.cartonBoxes[1].items[0].quantity,6)
|
||||
assert.equal(value.cartonBoxes[2].items[0].quantity,'')
|
||||
assert.equal(value.cartonBoxes[3].items[0].quantity,'')
|
||||
value.batchBox.items[0].quantity=8
|
||||
assert.equal(value.cartonBoxes[1].items[0].quantity,6)
|
||||
value.restoreCartons()
|
||||
assert.equal(value.cartonBoxes[1].items[0].quantity,6)
|
||||
assert.equal(value.batchTargetBoxes.length,0)
|
||||
value.changeBatchSku(1)
|
||||
assert.equal(value.batchSku,'B')
|
||||
assert.equal(value.batchBox.items[0].quantity,'')
|
||||
})
|
||||
|
||||
test('batch guards invalid quantities, busy state, submitted facts and stale mixed SKU selections',()=>{
|
||||
const {value}=setup();value.selectBatchCartons(true)
|
||||
Object.assign(value.batchBox,{length:32,width:24,height:18,weight:3.2})
|
||||
for(const quantity of [0,1.5,2147483648]){
|
||||
value.batchBox.items[0].quantity=quantity;value.applyBatchCartons()
|
||||
assert.equal(value.cartonBoxes[0].items[0].quantity,4)
|
||||
}
|
||||
value.batchBox.items[0].quantity=6;value.countingCartons=true;value.applyBatchCartons()
|
||||
assert.equal(value.cartonBoxes[0].items[0].quantity,4)
|
||||
value.countingCartons=false;value.cartonBoxes[0].items[0].skuCode='B'
|
||||
assert.equal(value.batchTargetBoxes.length,0);value.applyBatchCartons()
|
||||
assert.equal(value.cartonBoxes[0].items[0].quantity,4)
|
||||
value.cartonBoxes[0].items[0].skuCode='A';value.selected.cartons[0].countedAt='now';value.applyBatchCartons()
|
||||
assert.equal(value.cartonBoxes[0].items[0].quantity,4)
|
||||
})
|
||||
|
||||
test('copy uses preceding received same SKU without sharing quantity objects or changing box identity',()=>{
|
||||
const {value}=setup();value.addExtraCarton();value.addExtraCarton()
|
||||
value.cartonBoxes[1].items[0].skuCode='B'
|
||||
value.copyPreviousCarton(1);assert.equal(value.cartonBoxes[1].items[0].quantity,'')
|
||||
const identity=value.cartonBoxes[2].localKey
|
||||
value.copyPreviousCarton(2)
|
||||
assert.equal(value.cartonBoxes[2].length,10)
|
||||
assert.equal(value.cartonBoxes[2].items[0].quantity,4)
|
||||
assert.equal(value.cartonBoxes[2].localKey,identity)
|
||||
value.cartonBoxes[2].items[0].quantity=9
|
||||
assert.equal(value.cartonBoxes[0].items[0].quantity,4)
|
||||
value.cartonBoxes[0].received=false;assert.equal(value.previousMatchingCarton(2),undefined)
|
||||
})
|
||||
|
||||
|
||||
test('pallet totals follow actual box assignments and exclude missing boxes',()=>{
|
||||
const {value}=setup();value.pallets.push({key:'P2',draftId:'P2',palletNo:'PALLET2',generated:true})
|
||||
value.addExtraCarton();Object.assign(value.cartonBoxes[1],{length:40,width:20,height:10,weight:3,transferPalletId:'P2'});value.cartonBoxes[1].items[0].quantity=6
|
||||
assert.equal(value.palletSummary(value.pallets[0]).cartonCount,1)
|
||||
assert.equal(value.palletSummary(value.pallets[1]).volume,0.008)
|
||||
assert.equal(value.totalWeight,'5.00')
|
||||
value.assignCartonPallet(value.cartonBoxes[1],0)
|
||||
assert.equal(value.palletSummary(value.pallets[0]).cartonCount,2)
|
||||
assert.equal(value.palletSummary(value.pallets[1]).cartonCount,0)
|
||||
value.cartonBoxes[1].received=false
|
||||
assert.equal(value.totalWeight,'2.00')
|
||||
value.cartonBoxes[0].transferPalletId='FOREIGN';assert.equal(value.cartonCountReady,false)
|
||||
})
|
||||
|
||||
test('pallet number can be created before measurements and new boxes receive that identity',async()=>{
|
||||
const {value,scope}=setup();const requests=[]
|
||||
value.pallets=[{key:'NEW',generated:false,busy:false}];value.cartonBoxes[0].transferPalletId=''
|
||||
const pallet={id:'CREATED',sequenceNo:1,status:'GENERATED',palletNo:'NEWPALLET'}
|
||||
scope.saveWarehouseTransferPalletDraft=async(no,request)=>{requests.push(request);return {pallets:[{...pallet,status:'DRAFT'}]}}
|
||||
scope.generateWarehouseTransferPallet=async()=>({pallets:[pallet]})
|
||||
await value.generatePallet(value.pallets[0])
|
||||
assert.equal(requests.length,1);assert.equal('lengthCm' in requests[0],false);assert.equal('grossWeightKg' in requests[0],false)
|
||||
assert.equal(value.cartonBoxes[0].transferPalletId,'CREATED')
|
||||
assert.equal(value.pallets[0].palletNo,'NEWPALLET')
|
||||
})
|
||||
Reference in New Issue
Block a user