fix(pda): 修复海外作业兼容性并完善扫码与装车收尾
This commit is contained in:
@@ -25,6 +25,16 @@ test('all registered user-facing pages avoid rendering internal identifiers', as
|
||||
]) assert.doesNotMatch(visibleExpressions, new RegExp(field.replace('.', '\\.')))
|
||||
})
|
||||
|
||||
test('receiving lists, summaries and completion display the formal cabinet number', async () => {
|
||||
const [operation, summary] = await Promise.all([
|
||||
read('../src/pages/operation/operation.vue'), read('../src/components/ReceivingSessionSummary.vue')
|
||||
])
|
||||
for (const binding of ['item.cabinetNo', 'selected?.cabinetNo', 'current?.cabinetNo']) {
|
||||
assert.ok(operation.includes(`柜次号 {{ ${binding} || '—' }}`))
|
||||
}
|
||||
assert.ok(summary.includes("柜次号 {{ session?.cabinetNo || '—' }}"))
|
||||
})
|
||||
|
||||
test('operator and warehouse cards never fall back to internal IDs', async () => {
|
||||
const [home, profile, session] = await Promise.all([
|
||||
read('../src/pages/home/home.vue'),
|
||||
|
||||
@@ -30,6 +30,21 @@ test('five existing slots carry their business names into upload, replace and pr
|
||||
assert.match(template, /:aria-label="`查看\$\{slotLabels\[slot\]\}大图`"/)
|
||||
assert.doesNotMatch(template, /装车照片 \{\{ index \+ 1 \}\}/)
|
||||
})
|
||||
test('saving the final loading photo reports the confirmed complete snapshot after releasing busy state', async () => {
|
||||
const completed = data({ recordVersion: '1', complete: true, stage: 'DELIVERING', editable: false,
|
||||
photos: loadingPhotoSlots.map((slot, index) => ({ ...photo, slot, fileId: String(1001 + index) })) })
|
||||
let uploads = 0
|
||||
const { state, events } = component({ uploadLoadingPhoto: async () => { uploads++; return photo }, bindLoadingPhoto: async () => completed })
|
||||
uni.chooseImage = async () => ({ tempFilePaths: ['final-photo', 'extra-photo'] })
|
||||
await state.load()
|
||||
await state.choose(photo.slot)
|
||||
const savedIndex = events.findIndex(event => event[0] === 'saved')
|
||||
assert.ok(savedIndex > 0)
|
||||
assert.deepEqual(events[savedIndex - 1], ['busy-change', false])
|
||||
assert.deepEqual(events[savedIndex], ['saved', completed])
|
||||
assert.equal(uploads, 1)
|
||||
})
|
||||
|
||||
test('refresh reads all photos saved by PC and refreshes the task stage without another write', async () => {
|
||||
let latest = data({ photos: [photo] }), writes = 0, fail = false
|
||||
const { state, events } = component({ getLoadingDocuments: async () => { if (fail) throw new Error('刷新失败'); return latest },
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fulfillment, restoreFulfillmentDraft, updateFulfillmentDraft, resetFulfillmentSession } from '../src/stores/fulfillment.js'
|
||||
import { requireDispatchVersion, dispatchVersionAtLeast, normalizeDispatchTask } from '../src/api/fulfillment.js'
|
||||
import { formatDispatchReviewTime } from '../src/utils/dispatch-time.js'
|
||||
const source = (await readFile(new URL('../src/pages/dispatch/dispatch.vue', import.meta.url), 'utf8')).match(/<script>([\s\S]*?)<\/script>/)[1]
|
||||
const item = (overrides = {}) => ({ dispatchItemId: 'item-1', sourceKey: 'source-1', sourceBusinessType: 'RELABEL_ORDER', sourceBusinessNo: 'RL-1', customerId: 'customer-1', customerName: '客户甲', palletNo: 'PALLET-1', scheduledPackages: 2, pickedPackages: 0, positions: [{ positionId: 'position-1', availablePackages: 2, allocatedPackages: 0, pickedPackages: 0 }], returnCartons: [], ...overrides })
|
||||
test('native string quantities normalize without changing Long identity or version', () => {
|
||||
@@ -25,7 +26,7 @@ const task = (overrides = {}) => normalizeDispatchTask({ taskNo: 'TASK-1', dispa
|
||||
function storage() { const values = new Map(); globalThis.uni = { setStorageSync: (key, value) => values.set(key, structuredClone(value)), getStorageSync: key => structuredClone(values.get(key)), removeStorageSync: key => values.delete(key), showModal: async () => ({ confirm: true }) }; resetFulfillmentSession(); restoreFulfillmentDraft('operator:warehouse'); return values }
|
||||
let lastCommand
|
||||
function page(dependencies = {}) {
|
||||
const deps = { PortalHeader: {}, ScanTargetInput: {}, DispatchHandoverStatus: {}, DispatchLoadingDocuments: {}, session: { currentUser: {} }, fulfillment, requireDispatchVersion, dispatchVersionAtLeast, updateFulfillmentDraft, playSuccessSound() {}, playErrorSound() {}, toDispatchError: error => ({ message: error.message }), ...dependencies }
|
||||
const deps = { PortalHeader: {}, ScanTargetInput: {}, DispatchHandoverStatus: {}, DispatchLoadingDocuments: {}, session: { currentUser: {} }, fulfillment, requireDispatchVersion, dispatchVersionAtLeast, updateFulfillmentDraft, formatDispatchReviewTime, playSuccessSound() {}, playErrorSound() {}, toDispatchError: error => ({ message: error.message }), ...dependencies }
|
||||
for (const [api, action] of [['pickDispatchItem', 'PICK'], ['completeDispatchLoading', 'LOADING_COMPLETE'], ['reviewDispatchLoading', 'LOADING_REVIEW']]) {
|
||||
const original = deps[api]; if (!original) continue
|
||||
deps[api] = (...args) => { lastCommand = { dispatchNo: args[0], action, body: args.at(-1) }; return original(...args) }
|
||||
@@ -296,7 +297,10 @@ test('missing appointment blocks approval while preserving rejection to customer
|
||||
await state.submitReview('REJECTED'); assert.equal(sent.length, 1); assert.equal(sent[0][1].decision, 'REJECTED')
|
||||
})
|
||||
|
||||
for (const [instant, expected] of [
|
||||
const reviewTimeExamples = [
|
||||
['2006-03-20T15:00:00Z', '美东 2006-03-20 10:00\n美西 2006-03-20 07:00'],
|
||||
['2040-07-01T15:00:00Z', '美东 2040-07-01 11:00\n美西 2040-07-01 08:00'],
|
||||
['2026-01-01T05:00:00Z', '美东 2026-01-01 00:00\n美西 2025-12-31 21:00'],
|
||||
['2026-11-06T00:00:00+08:00', '美东 2026-11-05 11:00\n美西 2026-11-05 08:00'],
|
||||
['2026-11-05T16:00:00Z', '美东 2026-11-05 11:00\n美西 2026-11-05 08:00'],
|
||||
['2026-07-01T15:00:00Z', '美东 2026-07-01 11:00\n美西 2026-07-01 08:00'],
|
||||
@@ -306,10 +310,64 @@ for (const [instant, expected] of [
|
||||
['2026-11-01T05:30:00Z', '美东 2026-11-01 01:30\n美西 2026-10-31 22:30'],
|
||||
['2026-11-01T07:30:00Z', '美东 2026-11-01 02:30\n美西 2026-11-01 00:30'],
|
||||
['2026-11-01T10:30:00Z', '美东 2026-11-01 05:30\n美西 2026-11-01 02:30'],
|
||||
]) test(`review times use eastern and pacific daylight-saving rules for ${instant}`, () => {
|
||||
]
|
||||
for (const [instant, expected] of reviewTimeExamples) test(`review times use eastern and pacific daylight-saving rules for ${instant}`, () => {
|
||||
assert.equal(page().reviewTime(instant), expected)
|
||||
})
|
||||
|
||||
test('review times render without Intl using Android timezone offsets across daylight-saving changes', t => {
|
||||
const intlDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'Intl')
|
||||
const plusDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'plus')
|
||||
const DateTimeFormat = Intl.DateTimeFormat
|
||||
t.after(() => {
|
||||
Object.defineProperty(globalThis, 'Intl', intlDescriptor)
|
||||
if (plusDescriptor) Object.defineProperty(globalThis, 'plus', plusDescriptor)
|
||||
else delete globalThis.plus
|
||||
})
|
||||
delete globalThis.Intl
|
||||
const calls = []
|
||||
globalThis.plus = { android: { invoke(target, method, argument) {
|
||||
calls.push({ target, method, argument })
|
||||
if (method === 'getTimeZone') {
|
||||
assert.equal(target, 'java.util.TimeZone')
|
||||
return { timeZone: argument }
|
||||
}
|
||||
assert.equal(method, 'getOffset')
|
||||
const parts = new DateTimeFormat('en-GB', { timeZone: target.timeZone, timeZoneName: 'longOffset' }).formatToParts(argument)
|
||||
const offset = parts.find(part => part.type === 'timeZoneName').value.match(/^GMT([+-])(\d{2}):(\d{2})$/)
|
||||
return (offset[1] === '-' ? -1 : 1) * (Number(offset[2]) * 60 + Number(offset[3])) * 60000
|
||||
} } }
|
||||
const state = page()
|
||||
state.current = reviewTask()
|
||||
assert.equal(state.canReview, true)
|
||||
for (const [instant, expected] of reviewTimeExamples) assert.equal(state.reviewTime(instant), expected)
|
||||
assert.equal(calls.filter(call => call.method === 'getOffset').length, reviewTimeExamples.length * 2)
|
||||
assert.equal(calls.find(call => call.method === 'getOffset').argument, Date.parse(reviewTimeExamples[0][0]))
|
||||
})
|
||||
|
||||
test('review times show explicit UTC when Intl and the Android timezone bridge are unavailable', t => {
|
||||
const intlDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'Intl')
|
||||
const plusDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'plus')
|
||||
t.after(() => {
|
||||
Object.defineProperty(globalThis, 'Intl', intlDescriptor)
|
||||
if (plusDescriptor) Object.defineProperty(globalThis, 'plus', plusDescriptor)
|
||||
else delete globalThis.plus
|
||||
})
|
||||
delete globalThis.Intl
|
||||
delete globalThis.plus
|
||||
const state = page()
|
||||
assert.equal(state.reviewTime('2026-11-06T00:00:00+08:00'), 'UTC 2026-11-05 16:00')
|
||||
globalThis.plus = { android: { invoke() { return undefined } } }
|
||||
assert.equal(state.reviewTime('2026-07-01T15:00:00Z'), 'UTC 2026-07-01 15:00')
|
||||
globalThis.plus.android.invoke = () => { throw new Error('Native bridge unavailable') }
|
||||
assert.equal(state.reviewTime('2026-07-01T15:00:00Z'), 'UTC 2026-07-01 15:00')
|
||||
delete globalThis.plus
|
||||
globalThis.Intl = { DateTimeFormat: function () { return {} } }
|
||||
assert.equal(state.reviewTime('2026-07-01T15:00:00Z'), 'UTC 2026-07-01 15:00')
|
||||
assert.equal(state.reviewTime(null), '未填写')
|
||||
assert.equal(state.reviewTime('2026-11-06T00:00:00'), '时间无效')
|
||||
})
|
||||
|
||||
test('review times preserve missing values and reject invalid or timezone-free timestamps', () => {
|
||||
const state = page()
|
||||
for (const missing of [null, undefined, '']) assert.equal(state.reviewTime(missing), '未填写')
|
||||
@@ -381,6 +439,44 @@ test('photo tab preserves the selected pallet and quantity and ignores picking b
|
||||
state.selectTab('picking'); assert.equal(state.selectedItem.palletNo, 'PALLET-1'); assert.equal(state.packages, 1)
|
||||
})
|
||||
|
||||
test('saving a complete set of loading photos returns to the refreshed task list', async () => {
|
||||
storage(); let reads = 0
|
||||
const rows = [task({ dispatchNo: 'PC/NEXT' })]
|
||||
const state = page({ listDispatchTasks: async () => { reads++; return rows } })
|
||||
state.selectTab('photos')
|
||||
await state.onLoadingDocumentsSaved({ dispatchNo: 'PC/1', complete: true })
|
||||
assert.equal(state.current, null)
|
||||
assert.equal(state.activeTab, 'picking')
|
||||
assert.equal(fulfillment.dispatch.currentNo, '')
|
||||
assert.deepEqual(state.tasks, rows)
|
||||
assert.equal(reads, 1)
|
||||
})
|
||||
|
||||
test('partial photo saves stay on photos and callbacks from another task are ignored', async () => {
|
||||
storage(); let reads = 0
|
||||
const state = page({ getDispatchTask: async no => { reads++; return task({ dispatchNo: no, recordVersion: 2 }) } })
|
||||
state.selectTab('photos')
|
||||
await state.onLoadingDocumentsSaved({ dispatchNo: 'PC/OLD', complete: true })
|
||||
assert.equal(reads, 0)
|
||||
assert.equal(state.current.dispatchNo, 'PC/1')
|
||||
await state.onLoadingDocumentsSaved({ dispatchNo: 'PC/1', complete: false })
|
||||
assert.equal(reads, 1)
|
||||
assert.equal(state.activeTab, 'photos')
|
||||
assert.equal(state.current.dispatchNo, 'PC/1')
|
||||
})
|
||||
|
||||
test('a failed task-list refresh after completing photos stays on the list with a retry', async () => {
|
||||
storage(); let reads = 0
|
||||
const state = page({ listDispatchTasks: async () => { if (++reads === 1) throw new Error('列表刷新失败'); return [] } })
|
||||
await state.onLoadingDocumentsSaved({ dispatchNo: 'PC/1', complete: true })
|
||||
assert.equal(state.current, null)
|
||||
assert.equal(state.error.message, '列表刷新失败')
|
||||
assert.equal(state.loading, false)
|
||||
await state.retry()
|
||||
assert.equal(state.error, null)
|
||||
assert.equal(reads, 2)
|
||||
})
|
||||
|
||||
test('same-task photo refresh stays on photos while a new task starts on picking', async () => {
|
||||
storage(); const state = page({ getDispatchTask: async no => task({ dispatchNo: no, recordVersion: 2 }) })
|
||||
state.selectTab('photos'); await state.openTask('PC/1'); assert.equal(state.activeTab, 'photos')
|
||||
|
||||
@@ -25,6 +25,7 @@ const inbounds = Array.from({ length: 15 }, (_, index) => ({
|
||||
test('one container candidate aggregates fifteen independent one-inbound plans', () => {
|
||||
const candidate = normalizeReceivingCandidate({
|
||||
sourceContainerBookingId: '2093166483653849089', containerNo: 'C10000000', status: 'WAIT_SIGN',
|
||||
cabinetNo: ' GC202611060001 ',
|
||||
overseasWarehouseCode: 'US-LAX-RCV', overseasWarehouseName: '洛杉矶接柜仓',
|
||||
plannedArrivalAt: '2026-08-29T09:30:00', totalOrderCount: 15, planCount: 15, inboundCodeCount: 15,
|
||||
missingInboundCodeCount: 0, plannedInboundCodeCount: 15, pendingPlanCount: 0, allPlansReady: true, operable: true, blockReason: '',
|
||||
@@ -33,6 +34,7 @@ test('one container candidate aggregates fifteen independent one-inbound plans',
|
||||
})
|
||||
assert.deepEqual(candidate, {
|
||||
sourceContainerBookingId: '2093166483653849089', containerNo: 'C10000000', status: 'WAIT_SIGN',
|
||||
cabinetNo: 'GC202611060001',
|
||||
overseasWarehouseCode: 'US-LAX-RCV', overseasWarehouseName: '洛杉矶接柜仓',
|
||||
plannedArrivalAt: '2026-08-29T09:30:00', totalOrderCount: 15, planCount: 15, inboundCodeCount: 15,
|
||||
missingInboundCodeCount: 0, plannedInboundCodeCount: 15, pendingPlanCount: 0, allPlansReady: true, operable: true, blockReason: '',
|
||||
|
||||
@@ -104,6 +104,15 @@ test('domestic loading photos remain cabinet-level read-only references without
|
||||
assert.equal('fileId' in session.domesticLoadingPhotos[0], false)
|
||||
})
|
||||
|
||||
test('receiving cabinet numbers use only the formal business field and normalize missing values', () => {
|
||||
for (const normalize of [normalizeReceivingCandidate, normalizeReceivingSession]) {
|
||||
assert.equal(normalize({ cabinetNo: ' GC202611060001 ' }).cabinetNo, 'GC202611060001')
|
||||
for (const cabinetNo of [undefined, null, 2093166483653849089, ' ']) {
|
||||
assert.equal(normalize({ cabinetNo, sourceContainerBookingId: '2093166483653849089', containerNo: 'C10000000' }).cabinetNo, '')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('receiving adapter exposes calculable candidate facts and workflow errors', () => {
|
||||
const candidate = normalizeReceivingCandidate({ containerNo: ' C10000000 ', status: 'WAIT_SIGN', planCount: 15, inboundCodeCount: 15, destinationCodes: ['ONT8', 'LAX'], plannedPalletCount: 4, currentAccount: true })
|
||||
assert.equal(candidate.containerNo, 'C10000000')
|
||||
|
||||
@@ -25,6 +25,31 @@ function page(overrides = {}) {
|
||||
return { vm, definition, deps }
|
||||
}
|
||||
|
||||
test('carton resolution keeps the selected broadcast target until the operator changes it', async t => {
|
||||
store.resetReceivingSession()
|
||||
store.setCurrentReceiving(sessionValue())
|
||||
const { vm } = page({ resolveCurrentCarton: async cartonMark => ({ cartonMark, inboundCode: 'IN-A', destinationType: 'FBA' }) })
|
||||
t.after(() => vm.suspendReceiving())
|
||||
vm.activeTarget = 'carton'
|
||||
store.receiving.resolvedPallet = { palletCode: 'OLD-PALLET', compatible: true, version: 1 }
|
||||
await vm.resolveCarton('IN-A#1')
|
||||
assert.equal(vm.activeTarget, 'carton')
|
||||
assert.equal(vm.resolvedPallet, null)
|
||||
assert.equal(store.receiving.resolvedCarton.cartonMark, 'IN-A#1')
|
||||
vm.activeTarget = 'pallet'
|
||||
await vm.resolveCarton('IN-A#2')
|
||||
assert.equal(vm.activeTarget, 'pallet')
|
||||
})
|
||||
|
||||
test('receiving broadcast targets use larger tap areas and show an inactive selection hint', () => {
|
||||
const operation = readFileSync(new URL('../src/pages/operation/operation.vue', import.meta.url), 'utf8')
|
||||
const input = readFileSync(new URL('../src/components/ScanTargetInput.vue', import.meta.url), 'utf8')
|
||||
for (const name of ['carton', 'pallet']) assert.match(operation, new RegExp(`<ScanTargetInput name="${name}"[^>]* large `))
|
||||
assert.match(input, /@tap="\$emit\('activate', name\)"/)
|
||||
assert.match(input, /scan-target-input-large \{ height: 64px/)
|
||||
assert.match(input, /active \? '当前扫码' : '点按选中'/)
|
||||
})
|
||||
|
||||
test('camera resume retains all unsigned uploads by role and clears them across session or identity changes', async () => {
|
||||
store.resetReceivingSession()
|
||||
const unsigned = sessionValue({ status: 'WAIT_SIGN', photos: [], domesticLoadingPhotos: [] })
|
||||
|
||||
Reference in New Issue
Block a user