Compare commits
11
Commits
main
..
release/test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fadef06191 | ||
|
|
868f18d7df | ||
|
|
c726df3007 | ||
|
|
a74f0a1e36 | ||
|
|
859630fbbe | ||
|
|
fcba6b6f25 | ||
|
|
70367afef8 | ||
|
|
493fa7c2e9 | ||
|
|
105c7825b4 | ||
|
|
9408e9e5f5 | ||
|
|
3934ad57f8 |
@@ -6,7 +6,7 @@
|
|||||||
2. 登录/Token/统一请求先读下表登录API/会话、`src/config/runtime.js`、`../../docs/统一认证与服务通信契约.md`。
|
2. 登录/Token/统一请求先读下表登录API/会话、`src/config/runtime.js`、`../../docs/统一认证与服务通信契约.md`。
|
||||||
3. 沿实际 API 读直接服务指引:入仓/库存 `../../baiyue-firstleg/lastleg-service/AGENTS.md`,资源 `../../baiyue-firstleg/warehouse-overseas-service/AGENTS.md`;网关路径/鉴权问题才读 `../../baiyue-firstleg/gateway/AGENTS.md`。跨模块或入口不明才读 `../../docs/海外仓业务/README.md`。
|
3. 沿实际 API 读直接服务指引:入仓/库存 `../../baiyue-firstleg/lastleg-service/AGENTS.md`,资源 `../../baiyue-firstleg/warehouse-overseas-service/AGENTS.md`;网关路径/鉴权问题才读 `../../baiyue-firstleg/gateway/AGENTS.md`。跨模块或入口不明才读 `../../docs/海外仓业务/README.md`。
|
||||||
|
|
||||||
仅读适用指引/目标页面/API;纯页面不读后端,跨服务状态/鉴权不明才扩读。遵根预算,先规则后命中段,不全文读海外仓 README。
|
按根预算先规则后命中,不通读海外仓 README;纯页面不读后端,跨服务状态/鉴权不明才扩读。样式读[规范](../../docs/海外PDA界面样式规范.md)。
|
||||||
|
|
||||||
## 项目定位
|
## 项目定位
|
||||||
|
|
||||||
|
|||||||
@@ -63,3 +63,5 @@
|
|||||||
- 海外业务 Token、PDA 权限和可信身份最终由 Gateway 与服务端校验;前端不得用工作台 `available`、本地客户 ID 或仓库 ID 代替授权和资源归属校验。
|
- 海外业务 Token、PDA 权限和可信身份最终由 Gateway 与服务端校验;前端不得用工作台 `available`、本地客户 ID 或仓库 ID 代替授权和资源归属校验。
|
||||||
- 排车和出库入口只调用专用 PDA 接口;客户与绑定海外仓由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不展示内部数据库 ID 作为业务编号。动作资格、版本和失败处理见本文件排车条目,装车中可同步上传五类照片,每类1至5张,装车确认与资料齐全两项均完成后服务端自动生成派送并发车,装完但资料未齐时留在装车后;照片API为 `/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents`,前端不补派送费用。完成成功后不得重复提交;PDA 不直接创建派送单,平台与客户的读取范围遵循现行契约。
|
- 排车和出库入口只调用专用 PDA 接口;客户与绑定海外仓由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不展示内部数据库 ID 作为业务编号。动作资格、版本和失败处理见本文件排车条目,装车中可同步上传五类照片,每类1至5张,装车确认与资料齐全两项均完成后服务端自动生成派送并发车,装完但资料未齐时留在装车后;照片API为 `/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents`,前端不补派送费用。完成成功后不得重复提交;PDA 不直接创建派送单,平台与客户的读取范围遵循现行契约。
|
||||||
- 生产仓租功能尚未配套发布,当前 main 的排车拣货、普通出库、整托出库和废弃确认直接调用既有库存命令,不请求 `/storage-rent/preparations`。保留原始货源参数、箱数、版本和 `idempotencyKey`;实际库存接口的业务失败与网络错误照常上报,不吞掉 404、不自动重试或伪造成功。未来启用仓租须以后端接口、数据库及资金链路配套上线为前提。定向回归为 `tests/fulfillment-stock-commands.test.mjs`,覆盖四类命令单次提交、参数保留、版本/防重校验与失败传播;离线测试不代替生产实货验收。
|
- 生产仓租功能尚未配套发布,当前 main 的排车拣货、普通出库、整托出库和废弃确认直接调用既有库存命令,不请求 `/storage-rent/preparations`。保留原始货源参数、箱数、版本和 `idempotencyKey`;实际库存接口的业务失败与网络错误照常上报,不吞掉 404、不自动重试或伪造成功。未来启用仓租须以后端接口、数据库及资金链路配套上线为前提。定向回归为 `tests/fulfillment-stock-commands.test.mjs`,覆盖四类命令单次提交、参数保留、版本/防重校验与失败传播;离线测试不代替生产实货验收。
|
||||||
|
|
||||||
|
- 废弃出库展示客户废弃备注。无关联箱唛库存扫描本单托盘后扫描现场箱唛,自动去重计数;多SKU先选择本次扫描货物,不手填实际数。每条 lines.scannedCartonMarks 与 actualPackages 一并提交,失败留箱唛/照片供刷新重试;后台保存实际扫描清单并回显废弃详情。有箱唛关联任务仍核对本单所选箱唛。
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function slotPath(no, slot) {
|
|||||||
return `${path(no)}/${slot}`
|
return `${path(no)}/${slot}`
|
||||||
}
|
}
|
||||||
export function normalizeLoadingDocuments(value, no) {
|
export function normalizeLoadingDocuments(value, no) {
|
||||||
if (value?.dispatchNo !== no || !/^\d+$/.test(String(value.recordVersion ?? '')) || !Array.isArray(value.photos)
|
if (value?.dispatchNo !== no || !/^\d+$/.test(String(value.recordVersion ?? '')) || !/^[1-9]\d*$/.test(String(value.reviewRound ?? '')) || !Array.isArray(value.photos)
|
||||||
|| !['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(value.stage)) throw new Error('装车资料与当前排车事实不一致')
|
|| !['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(value.stage)) throw new Error('装车资料与当前排车事实不一致')
|
||||||
const files = new Set(), counts = {}
|
const files = new Set(), counts = {}
|
||||||
for (const photo of value.photos) {
|
for (const photo of value.photos) {
|
||||||
@@ -24,6 +24,10 @@ export function normalizeLoadingDocuments(value, no) {
|
|||||||
return { ...value, editable: value.editable === true, complete: value.complete === true }
|
return { ...value, editable: value.editable === true, complete: value.complete === true }
|
||||||
}
|
}
|
||||||
export async function getLoadingDocuments(no) { return normalizeLoadingDocuments(await overseasRequest('GET', path(no)), no) }
|
export async function getLoadingDocuments(no) { return normalizeLoadingDocuments(await overseasRequest('GET', path(no)), no) }
|
||||||
export function uploadLoadingPhoto(no, slot, filePath, key) { return overseasUpload(`${slotPath(no, slot)}/uploads?idempotencyKey=${encodeURIComponent(key)}`, filePath) }
|
export function uploadLoadingPhoto(no, slot, filePath, key, reviewRound) { return overseasUpload(`${slotPath(no, slot)}/uploads?idempotencyKey=${encodeURIComponent(key)}&reviewRound=${encodeURIComponent(reviewRound)}`, filePath) }
|
||||||
export async function bindLoadingPhoto(no, slot, body) { return normalizeLoadingDocuments(await overseasRequest('PUT', slotPath(no, slot), body), no) }
|
export async function bindLoadingPhoto(no, slot, body) { return normalizeLoadingDocuments(await overseasRequest('PUT', slotPath(no, slot), body), no) }
|
||||||
export function readLoadingPhoto(no, photo) { return overseasDownload(`${slotPath(no, photo.slot)}/files/${encodeURIComponent(photo.fileId)}/content`, { expectedContentTypePrefix: 'image/' }) }
|
export function readLoadingPhoto(no, photo) {
|
||||||
|
const pdf = photo.slot === 'LOADING_SCENE' && photo.contentType === 'application/pdf'
|
||||||
|
if (!pdf && !['image/jpeg', 'image/png'].includes(photo.contentType)) throw new Error('装车资料格式无效')
|
||||||
|
return overseasDownload(`${slotPath(no, photo.slot)}/files/${encodeURIComponent(photo.fileId)}/content`, { expectedContentTypePrefix: pdf ? 'application/pdf' : photo.contentType })
|
||||||
|
}
|
||||||
|
|||||||
+31
-1
@@ -157,7 +157,10 @@ export function normalizeCargoDiscardTask(value) {
|
|||||||
const task = value || {}
|
const task = value || {}
|
||||||
return {
|
return {
|
||||||
...task,
|
...task,
|
||||||
discardNo: String(task?.discardNo || ''),
|
remark: String(task?.remark || ''),
|
||||||
|
requestedPackages: task?.requestedPackages == null ? null : count(task.requestedPackages),
|
||||||
|
remainingPackages: task?.remainingPackages == null ? null : count(task.remainingPackages),
|
||||||
|
discardNo: String(task?.discardNo || ''),
|
||||||
outboundNo: String(task?.outboundNo || ''),
|
outboundNo: String(task?.outboundNo || ''),
|
||||||
palletNo: String(task?.palletNo || ''),
|
palletNo: String(task?.palletNo || ''),
|
||||||
scanMode: task?.scanMode === 'CARTON' ? 'CARTON' : 'PALLET',
|
scanMode: task?.scanMode === 'CARTON' ? 'CARTON' : 'PALLET',
|
||||||
@@ -188,9 +191,36 @@ export async function resolveCargoDiscardPallet(palletNo, discardNo) {
|
|||||||
export async function confirmCargoDiscard(discardNo, palletNo, lines, photoFileIds, scannedCartonMarks = [], confirmAllCartons = false, request = {}) {
|
export async function confirmCargoDiscard(discardNo, palletNo, lines, photoFileIds, scannedCartonMarks = [], confirmAllCartons = false, request = {}) {
|
||||||
const body = { lines, photoFileIds, scannedCartonMarks, confirmAllCartons, idempotencyKey: request.idempotencyKey }
|
const body = { lines, photoFileIds, scannedCartonMarks, confirmAllCartons, idempotencyKey: request.idempotencyKey }
|
||||||
requireStockCommandIdentity(request)
|
requireStockCommandIdentity(request)
|
||||||
|
// 出库前按同一份货物、照片和幂等键校验仓租,资金就绪后才提交库存扣减。
|
||||||
|
await prepareDiscardRent({ mode: 'DISCARD', businessNo: discardNo, palletNo, ...body })
|
||||||
return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/confirm`, body))
|
return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/confirm`, body))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 本次点击内只查询资金回执,不缓存或自动重放失败的出库命令。 */
|
||||||
|
async function prepareDiscardRent(body) {
|
||||||
|
const base = '/pda/v1/last-leg/storage-rent/preparations'
|
||||||
|
let preparationId
|
||||||
|
try {
|
||||||
|
let preparation = await overseasRequest('POST', base, body)
|
||||||
|
preparationId = preparation?.preparationId
|
||||||
|
for (let attempt = 0; preparation?.ready !== true && preparation?.status === 'FREEZE_PENDING' && preparationId && attempt < 20; attempt++) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500))
|
||||||
|
preparation = await overseasRequest('GET', `${base}/${encoded(preparationId)}`)
|
||||||
|
}
|
||||||
|
if (preparation?.ready !== true) {
|
||||||
|
const message = preparation?.message ? `仓租校验未通过:${preparation.message}` : '仓租资金预校验尚未通过,请稍后重新提交'
|
||||||
|
throw Object.assign(new Error(message), { code: 'A0409' })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 尚未发送出库命令时释放已知的未使用预占,释放失败保留原错误诊断。
|
||||||
|
if (preparationId) {
|
||||||
|
try { await overseasRequest('DELETE', `${base}/${encoded(preparationId)}`) }
|
||||||
|
catch (releaseError) { console.error('[Overseas PDA discard] rent preparation release failed', { code: releaseError?.code, requestId: releaseError?.requestId, traceId: releaseError?.traceId }) }
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const discardPhotoPath = (discardNo, palletNo) => `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/photos/files`
|
const discardPhotoPath = (discardNo, palletNo) => `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/photos/files`
|
||||||
export const listCargoDiscardPhotos = (discardNo, palletNo) => overseasRequest('GET', discardPhotoPath(discardNo, palletNo))
|
export const listCargoDiscardPhotos = (discardNo, palletNo) => overseasRequest('GET', discardPhotoPath(discardNo, palletNo))
|
||||||
export const uploadCargoDiscardPhoto = (discardNo, palletNo, filePath) => overseasUpload(discardPhotoPath(discardNo, palletNo), filePath)
|
export const uploadCargoDiscardPhoto = (discardNo, palletNo, filePath) => overseasUpload(discardPhotoPath(discardNo, palletNo), filePath)
|
||||||
|
|||||||
@@ -115,7 +115,9 @@ export function isReceivingConflict(error) {
|
|||||||
export function toReceivingError(error, fallback = '海外仓作业失败') {
|
export function toReceivingError(error, fallback = '海外仓作业失败') {
|
||||||
const code = error?.code
|
const code = error?.code
|
||||||
const rawMessage = String(error?.message || '').trim()
|
const rawMessage = String(error?.message || '').trim()
|
||||||
const palletConflictMessages = {
|
const receivingMessages = {
|
||||||
|
'Carton mark must contain a non-empty inbound code prefix': '请扫描箱码或国内DP码',
|
||||||
|
'Pallet code is not part of the current FBA destination group': '托盘不属于当前目的仓,请重扫',
|
||||||
'Reserved pallet binding is incomplete': '预留托盘资料不完整,请联系管理员检查入仓计划',
|
'Reserved pallet binding is incomplete': '预留托盘资料不完整,请联系管理员检查入仓计划',
|
||||||
'Reserved pallet customer binding is invalid': '预留托盘与当前客户不匹配,请联系管理员检查入仓计划',
|
'Reserved pallet customer binding is invalid': '预留托盘与当前客户不匹配,请联系管理员检查入仓计划',
|
||||||
'Scanned pallet does not match the reserved inbound plan resource': '扫描托盘与入仓计划预留资源不一致,请扫描已预留的托盘',
|
'Scanned pallet does not match the reserved inbound plan resource': '扫描托盘与入仓计划预留资源不一致,请扫描已预留的托盘',
|
||||||
@@ -131,13 +133,13 @@ export function toReceivingError(error, fallback = '海外仓作业失败') {
|
|||||||
'Pallet FBA destination does not match the inbound code': '该托盘的FBA目的仓与当前入仓码不一致,请更换托盘',
|
'Pallet FBA destination does not match the inbound code': '该托盘的FBA目的仓与当前入仓码不一致,请更换托盘',
|
||||||
'FBA-designated pallet cannot receive an OVERSEAS destination carton': 'FBA专用托盘不能接收海外仓目的箱,请更换托盘'
|
'FBA-designated pallet cannot receive an OVERSEAS destination carton': 'FBA专用托盘不能接收海外仓目的箱,请更换托盘'
|
||||||
}
|
}
|
||||||
const conflictMessage = ['409', 'A0409', 'B0409'].includes(String(code || ''))
|
const businessMessage = ['404', 'A0404', 'B0404', '409', 'A0409', 'B0409'].includes(String(code || ''))
|
||||||
? palletConflictMessages[rawMessage]
|
? receivingMessages[rawMessage]
|
||||||
: ''
|
: ''
|
||||||
const palletUnavailable = String(code || '') === 'S0503'
|
const palletUnavailable = String(code || '') === 'S0503'
|
||||||
&& rawMessage === 'Warehouse pallet service returned no usable response'
|
&& rawMessage === 'Warehouse pallet service returned no usable response'
|
||||||
const mapped = Object.assign(new Error(
|
const mapped = Object.assign(new Error(
|
||||||
conflictMessage || (palletUnavailable
|
businessMessage || (palletUnavailable
|
||||||
? '该托盘当前不可用于收货,请更换可用托盘或联系管理员检查托盘状态'
|
? '该托盘当前不可用于收货,请更换可用托盘或联系管理员检查托盘状态'
|
||||||
: rawMessage || fallback)
|
: rawMessage || fallback)
|
||||||
), {
|
), {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { listReceivingCandidates } from './inbound.js'
|
||||||
|
import { assertReceivingWarehouse } from './receiving-adapter.js'
|
||||||
|
import { listWarehouseTransferCandidates } from './warehouse-transfers.js'
|
||||||
|
import { listReturnCountTasks } from './return-counting.js'
|
||||||
|
import { listPackingPlans } from './return-packing.js'
|
||||||
|
import { listDispatchTasks, listOutboundTasks, listCargoDiscardTasks } from './fulfillment.js'
|
||||||
|
import { listRelabelCandidates } from './relabel.js'
|
||||||
|
|
||||||
|
export const WORKBENCH_COUNT_KEYS = ['receiving', 'warehouseTransferList', 'warehouseTransferWork', 'returnCounting', 'returnPacking', 'dispatchPicking', 'outboundPicking', 'relabelReceive', 'relabelExecute', 'relabelReview']
|
||||||
|
|
||||||
|
const ready = (count, unit = '单') => ({ status: 'ready', count, unit })
|
||||||
|
const distinctCount = (rows, key) => {
|
||||||
|
if (!Array.isArray(rows)) throw new Error('待办列表返回结构无效')
|
||||||
|
const codes = rows.map((row) => String(row?.[key] || '').trim())
|
||||||
|
if (codes.some((code) => !code)) throw new Error('待办列表缺少业务编号')
|
||||||
|
return new Set(codes).size
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadWorkbenchCounts({ profile, activeTransferNo = '' }) {
|
||||||
|
const sources = [
|
||||||
|
{ keys: ['receiving'], load: async () => {
|
||||||
|
const rows = await listReceivingCandidates()
|
||||||
|
rows.forEach((row) => assertReceivingWarehouse(row, profile, '待收货柜'))
|
||||||
|
if (rows.some((row) => !row.sourceContainerBookingId || !row.containerNo)) throw new Error('待收货柜缺少业务定位信息')
|
||||||
|
const keys = rows.map((row) => ({ key: `${row.sourceContainerBookingId}:${row.containerNo}` }))
|
||||||
|
return { receiving: ready(distinctCount(keys, 'key'), '柜') }
|
||||||
|
} },
|
||||||
|
{ keys: ['warehouseTransferList', 'warehouseTransferWork'], load: async () => {
|
||||||
|
const rows = (await listWarehouseTransferCandidates()).filter((row) => ['WAIT_PDA_RECEIVING', 'PARTIALLY_RECEIVED'].includes(row.status))
|
||||||
|
const active = rows.filter((row) => String(row.transferNo).trim() === String(activeTransferNo).trim())
|
||||||
|
return { warehouseTransferList: ready(distinctCount(rows, 'transferNo')), warehouseTransferWork: ready(distinctCount(active, 'transferNo'), '单进行中') }
|
||||||
|
} },
|
||||||
|
{ keys: ['returnCounting'], load: async () => {
|
||||||
|
const value = await listReturnCountTasks({ page: 1, size: 1 })
|
||||||
|
const total = value?.total
|
||||||
|
if ((typeof total !== 'number' && !(typeof total === 'string' && /^\d+$/.test(total))) || !Number.isSafeInteger(Number(total)) || Number(total) < 0) throw new Error('退货清点待办总数无效')
|
||||||
|
return { returnCounting: ready(Number(total), '托') }
|
||||||
|
} },
|
||||||
|
{ keys: ['returnPacking'], load: async () => ({ returnPacking: ready(distinctCount((await listPackingPlans()).filter((row) => row.status === 'READY'), 'planNo'), '单计划') }) },
|
||||||
|
{ keys: ['dispatchPicking'], load: async () => {
|
||||||
|
const rows = (await listDispatchTasks()).filter((row) => row.status !== 'CANCELLED' && ['BEFORE_LOADING', 'LOADING', 'AFTER_LOADING'].includes(row.stage))
|
||||||
|
return { dispatchPicking: ready(distinctCount(rows, 'dispatchNo'), '单未发车') }
|
||||||
|
} },
|
||||||
|
{ keys: ['outboundPicking'], load: async () => {
|
||||||
|
const [outbounds, discards] = await Promise.all([listOutboundTasks('PENDING'), listCargoDiscardTasks()])
|
||||||
|
return { outboundPicking: { ...ready(distinctCount(outbounds, 'taskNo')), discardCount: distinctCount(discards.filter((row) => row.status !== 'COMPLETED'), 'discardNo') } }
|
||||||
|
} },
|
||||||
|
{ keys: ['relabelReceive', 'relabelExecute', 'relabelReview'], load: async () => {
|
||||||
|
const rows = await listRelabelCandidates()
|
||||||
|
return {
|
||||||
|
relabelReceive: ready(distinctCount(rows.filter((row) => !row.taskStatus || row.taskStatus === 'AVAILABLE'), 'relabelNo'), '单待接'),
|
||||||
|
relabelExecute: ready(distinctCount(rows.filter((row) => row.taskStatus === 'PROCESSING' && row.claimedByCurrentOperator === true), 'relabelNo'), '单进行中'),
|
||||||
|
relabelReview: ready(distinctCount(rows.filter((row) => row.taskStatus === 'WAIT_REVIEW'), 'relabelNo'), '单待复核')
|
||||||
|
}
|
||||||
|
} }
|
||||||
|
]
|
||||||
|
const results = await Promise.allSettled(sources.map((source) => source.load()))
|
||||||
|
return Object.assign({}, ...results.map((result, index) => result.status === 'fulfilled' ? result.value : Object.fromEntries(sources[index].keys.map((key) => [key, { status: 'error', error: result.reason }]))))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function workbenchBadge(key, value) {
|
||||||
|
if (!WORKBENCH_COUNT_KEYS.includes(key)) return { label: key.endsWith('Query') ? '扫码查询' : key === 'returnReceipt' ? '扫码签收' : '扫码作业', tone: 'is-static' }
|
||||||
|
if (!value) return { label: '加载中…', tone: 'is-loading' }
|
||||||
|
if (value.status === 'error') return { label: '获取失败', tone: 'is-error' }
|
||||||
|
const hasWork = value.count > 0 || value.discardCount > 0
|
||||||
|
let label = `${value.count} ${value.unit}`
|
||||||
|
if (key === 'warehouseTransferWork' && value.count === 0) label = '未开始'
|
||||||
|
if (key === 'outboundPicking' && value.discardCount > 0) label = `${value.count} 出库 · ${value.discardCount} 废弃`
|
||||||
|
return { label, tone: hasWork ? 'has-work' : 'is-empty' }
|
||||||
|
}
|
||||||
@@ -3,9 +3,10 @@
|
|||||||
<view class="heading">
|
<view class="heading">
|
||||||
<view class="heading-main"><text class="heading-title">装车照片</text></view>
|
<view class="heading-main"><text class="heading-title">装车照片</text></view>
|
||||||
<text class="heading-progress">{{ busy ? '同步中' : snapshot ? `${completedCategories}/5 类已保存` : '待同步' }}</text>
|
<text class="heading-progress">{{ busy ? '同步中' : snapshot ? `${completedCategories}/5 类已保存` : '待同步' }}</text>
|
||||||
<button class="heading-refresh" :disabled="busy" @tap="load">刷新</button>
|
<button class="heading-refresh" :disabled="busy" @tap="refresh">刷新</button>
|
||||||
</view>
|
</view>
|
||||||
<text class="section-hint">每类至少 1 张、最多 5 张,上传后自动保存,可逐张更换。</text>
|
<text class="section-hint">每类至少 1 张、最多 5 张,上传后自动保存,可逐张更换。</text>
|
||||||
|
<text class="section-hint">司机驾驶证、车牌照片在理货审核通过后自动带入,可补拍或更换;PDF 驾驶证可点击查看。</text>
|
||||||
<text v-if="needsRefresh" class="section-hint">保存状态尚未核实,请先刷新资料后再上传。</text>
|
<text v-if="needsRefresh" class="section-hint">保存状态尚未核实,请先刷新资料后再上传。</text>
|
||||||
<view v-if="error" class="error" role="alert"><text>{{ error.message }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text></view>
|
<view v-if="error" class="error" role="alert"><text>{{ error.message }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text></view>
|
||||||
<view class="photo-list">
|
<view class="photo-list">
|
||||||
@@ -19,7 +20,8 @@
|
|||||||
</view>
|
</view>
|
||||||
<view v-if="snapshot?.photos.some(item => item.slot === slot)" class="photo-preview-list">
|
<view v-if="snapshot?.photos.some(item => item.slot === slot)" class="photo-preview-list">
|
||||||
<view v-for="photo in snapshot.photos.filter(item => item.slot === slot)" :key="photo.fileId" class="photo-preview">
|
<view v-for="photo in snapshot.photos.filter(item => item.slot === slot)" :key="photo.fileId" class="photo-preview">
|
||||||
<image v-if="images[photo.fileId]" class="photo-thumbnail" :src="images[photo.fileId]" mode="aspectFill" :aria-label="`预览${slotLabels[slot]}`" @tap="preview(images[photo.fileId])" />
|
<button v-if="photo.contentType === 'application/pdf'" class="photo-read-retry" :disabled="busy" @tap="viewPdf(photo)">查看驾驶证 PDF</button>
|
||||||
|
<image v-else-if="images[photo.fileId]" class="photo-thumbnail" :src="images[photo.fileId]" mode="aspectFill" :aria-label="`预览${slotLabels[slot]}`" @tap="preview(images[photo.fileId])" />
|
||||||
<button v-else class="photo-read-retry" :aria-label="`重新读取${slotLabels[slot]}`" :disabled="busy" @tap="load">图片读取失败,重试</button>
|
<button v-else class="photo-read-retry" :aria-label="`重新读取${slotLabels[slot]}`" :disabled="busy" @tap="load">图片读取失败,重试</button>
|
||||||
<text v-if="images[photo.fileId]" class="photo-preview-caption" :aria-label="`查看${slotLabels[slot]}大图`" @tap="preview(images[photo.fileId])">查看大图</text>
|
<text v-if="images[photo.fileId]" class="photo-preview-caption" :aria-label="`查看${slotLabels[slot]}大图`" @tap="preview(images[photo.fileId])">查看大图</text>
|
||||||
<button v-if="editable" class="photo-replace" :disabled="busy" :aria-label="`更换${slotLabels[slot]}图片`" @tap="choose(slot, photo.fileId)">更换</button>
|
<button v-if="editable" class="photo-replace" :disabled="busy" :aria-label="`更换${slotLabels[slot]}图片`" @tap="choose(slot, photo.fileId)">更换</button>
|
||||||
@@ -48,6 +50,7 @@ export default {
|
|||||||
key() { return `loading-${Date.now()}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}` },
|
key() { return `loading-${Date.now()}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}` },
|
||||||
async previews() {
|
async previews() {
|
||||||
await Promise.allSettled((this.snapshot?.photos || []).map(async photo => {
|
await Promise.allSettled((this.snapshot?.photos || []).map(async photo => {
|
||||||
|
if (photo.contentType === 'application/pdf') return
|
||||||
if (this.images[photo.fileId]) return
|
if (this.images[photo.fileId]) return
|
||||||
const image = await readLoadingPhoto(this.number, photo); this.ensureScope(); this.images[photo.fileId] = image
|
const image = await readLoadingPhoto(this.number, photo); this.ensureScope(); this.images[photo.fileId] = image
|
||||||
}))
|
}))
|
||||||
@@ -58,8 +61,10 @@ export default {
|
|||||||
this.snapshot = value; this.needsRefresh = false; await this.previews()
|
this.snapshot = value; this.needsRefresh = false; await this.previews()
|
||||||
},
|
},
|
||||||
async load() { await this.run(() => this.refreshFacts()) },
|
async load() { await this.run(() => this.refreshFacts()) },
|
||||||
|
async refresh() { await this.run(async () => { await this.refreshFacts(); return true }, true) },
|
||||||
async choose(slot, replaceFileId = null) {
|
async choose(slot, replaceFileId = null) {
|
||||||
if (this.busy || !this.editable) return
|
if (this.busy || !this.editable) return
|
||||||
|
const reviewRound = this.snapshot.reviewRound
|
||||||
const capacity = replaceFileId ? 1 : this.limit - this.photosFor(slot).length
|
const capacity = replaceFileId ? 1 : this.limit - this.photosFor(slot).length
|
||||||
if (capacity <= 0) return
|
if (capacity <= 0) return
|
||||||
let saved = 0, reconciled = false
|
let saved = 0, reconciled = false
|
||||||
@@ -70,12 +75,14 @@ export default {
|
|||||||
if (chosen.tempFiles?.some(file => file.size > 10 * 1024 * 1024)) throw new Error('请选择不超过10MiB的图片')
|
if (chosen.tempFiles?.some(file => file.size > 10 * 1024 * 1024)) throw new Error('请选择不超过10MiB的图片')
|
||||||
// 逐张以最新版本绑定;部分失败保留前面已保存照片,不重放失败上传。
|
// 逐张以最新版本绑定;部分失败保留前面已保存照片,不重放失败上传。
|
||||||
for (const filePath of paths) {
|
for (const filePath of paths) {
|
||||||
const photo = await uploadLoadingPhoto(this.number, slot, filePath, this.key()); this.ensureScope()
|
const uploadKey = this.key()
|
||||||
|
const photo = await uploadLoadingPhoto(this.number, slot, filePath, uploadKey, reviewRound); this.ensureScope()
|
||||||
if (photo?.slot !== slot || !photo.fileId) throw new Error('上传结果与装车照片框不一致')
|
if (photo?.slot !== slot || !photo.fileId) throw new Error('上传结果与装车照片框不一致')
|
||||||
const latest = await getLoadingDocuments(this.number); this.ensureScope(); this.snapshot = latest
|
const latest = await getLoadingDocuments(this.number); this.ensureScope(); this.snapshot = latest
|
||||||
|
if (latest.reviewRound !== reviewRound) { reconciled = true; throw new Error('审核轮次已变化,请核对新车辆、司机后重新选择资料') }
|
||||||
if (!this.editable) throw new Error('当前任务不可修改装车照片,请刷新任务查看')
|
if (!this.editable) throw new Error('当前任务不可修改装车照片,请刷新任务查看')
|
||||||
try {
|
try {
|
||||||
const value = await bindLoadingPhoto(this.number, slot, { recordVersion: latest.recordVersion, idempotencyKey: this.key(), fileId: photo.fileId, ...(replaceFileId ? { replaceFileId } : {}) }); this.ensureScope()
|
const value = await bindLoadingPhoto(this.number, slot, { recordVersion: latest.recordVersion, idempotencyKey: this.key(), fileId: photo.fileId, uploadKey, ...(replaceFileId ? { replaceFileId } : {}) }); this.ensureScope()
|
||||||
this.snapshot = value
|
this.snapshot = value
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
// 保存可能已成功;只重读最新事实,失败时必须先刷新再继续。
|
// 保存可能已成功;只重读最新事实,失败时必须先刷新再继续。
|
||||||
@@ -92,6 +99,19 @@ export default {
|
|||||||
if ((saved || reconciled) && this.active) this.$emit('saved')
|
if ((saved || reconciled) && this.active) this.$emit('saved')
|
||||||
},
|
},
|
||||||
preview(url) { uni.previewImage({ current: url, urls: [url] }) },
|
preview(url) { uni.previewImage({ current: url, urls: [url] }) },
|
||||||
|
async viewPdf(photo) {
|
||||||
|
await this.run(async () => {
|
||||||
|
const browser = typeof window !== 'undefined' && typeof document !== 'undefined'
|
||||||
|
const popup = browser ? window.open('', '_blank') : null
|
||||||
|
if (browser && !popup) throw new Error('请允许打开驾驶证预览窗口')
|
||||||
|
if (popup) popup.opener = null
|
||||||
|
try {
|
||||||
|
const path = await readLoadingPhoto(this.number, photo); this.ensureScope()
|
||||||
|
if (popup) popup.location.href = path
|
||||||
|
else await new Promise((resolve, reject) => uni.openDocument({ filePath: path, fileType: 'pdf', showMenu: true, success: resolve, fail: reject }))
|
||||||
|
} catch (cause) { if (popup) popup.close(); throw cause }
|
||||||
|
})
|
||||||
|
},
|
||||||
async run(work, notify = false) {
|
async run(work, notify = false) {
|
||||||
if (this.busy) return
|
if (this.busy) return
|
||||||
const revision = ++this.revision
|
const revision = ++this.revision
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="receiving-summary">
|
<view class="receiving-summary">
|
||||||
<view class="receiving-summary-head">
|
<view class="receiving-summary-head">
|
||||||
<view><text class="receiving-summary-label">当前柜号</text><text class="receiving-summary-container">{{ session?.containerNo || '—' }}</text></view>
|
<view class="receiving-summary-identity"><text class="receiving-summary-label">当前柜号</text><text class="receiving-summary-container">{{ session?.containerNo || '—' }}</text></view>
|
||||||
<text class="receiving-summary-status">{{ statusLabel }}</text>
|
<text class="receiving-summary-status">{{ statusLabel }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="receiving-summary-context">
|
<view class="receiving-summary-context">
|
||||||
@@ -45,8 +45,11 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.receiving-summary{box-sizing:border-box;margin-bottom:10px;padding:10px 12px;border:1px solid #b8d2f3;border-radius:13px;background:linear-gradient(145deg,#fff,#f2f7ff);box-shadow:0 3px 12px rgba(31,74,125,.08)}
|
.receiving-summary{box-sizing:border-box;width:100%;min-width:0;max-width:100%;margin-bottom:10px;padding:10px 12px;border:1px solid #b8d2f3;border-radius:13px;background:linear-gradient(145deg,#fff,#f2f7ff);box-shadow:0 3px 12px rgba(31,74,125,.08)}
|
||||||
.receiving-summary-head,.receiving-summary-progress>view:first-child{display:flex;align-items:center;justify-content:space-between;gap:10px}
|
.receiving-summary-head{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start;gap:10px}
|
||||||
|
.receiving-summary-progress>view:first-child{display:flex;align-items:center;justify-content:space-between;gap:10px}
|
||||||
|
.receiving-summary-identity{min-width:0;max-width:100%}
|
||||||
|
.receiving-summary-container{width:100%;min-width:0;max-width:100%;white-space:normal;overflow-wrap:anywhere;word-break:break-all}
|
||||||
.receiving-summary-label,.receiving-summary-container,.metric-value,.metric-label{display:block}.receiving-summary-label{color:#718095;font-size:9px;line-height:13px}.receiving-summary-container{margin-top:1px;color:#17324d;font-size:17px;font-weight:850;line-height:22px}
|
.receiving-summary-label,.receiving-summary-container,.metric-value,.metric-label{display:block}.receiving-summary-label{color:#718095;font-size:9px;line-height:13px}.receiving-summary-container{margin-top:1px;color:#17324d;font-size:17px;font-weight:850;line-height:22px}
|
||||||
.receiving-summary-status{flex-shrink:0;padding:4px 8px;border-radius:99px;color:#1668dc;font-size:9px;font-weight:700;background:#e3efff}
|
.receiving-summary-status{flex-shrink:0;padding:4px 8px;border-radius:99px;color:#1668dc;font-size:9px;font-weight:700;background:#e3efff}
|
||||||
.receiving-summary-context{display:flex;flex-direction:column;gap:4px;margin-top:7px;color:#617389;font-size:9px;line-height:14px}.receiving-summary-destinations{display:flex;min-width:0;align-items:flex-start}.destination-label{flex-shrink:0}.destination-tags{display:flex;min-width:0;flex-wrap:wrap;gap:3px 4px}.destination-tags text{padding:0 5px;border-radius:99px;color:#42617f;line-height:14px;background:#e8f0fa;white-space:nowrap}
|
.receiving-summary-context{display:flex;flex-direction:column;gap:4px;margin-top:7px;color:#617389;font-size:9px;line-height:14px}.receiving-summary-destinations{display:flex;min-width:0;align-items:flex-start}.destination-label{flex-shrink:0}.destination-tags{display:flex;min-width:0;flex-wrap:wrap;gap:3px 4px}.destination-tags text{padding:0 5px;border-radius:99px;color:#42617f;line-height:14px;background:#e8f0fa;white-space:nowrap}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const MODULES = [
|
export const MODULES = [
|
||||||
{ key: 'inbound', name: '入仓作业', nameKey: 'modules.inbound.name', icon: '入', description: '处理拆柜收货与客户退货实收入仓', descriptionKey: 'modules.inbound.description', operations: [
|
{ key: 'inbound', name: '入仓作业', nameKey: 'modules.inbound.name', icon: '入', description: '处理拆柜收货与客户退货实收入仓', descriptionKey: 'modules.inbound.description', operations: [
|
||||||
{ key: 'receiving', name: '拆柜收货', nameKey: 'modules.receiving.name', icon: '卸', iconType: 'download', description: '三类照片签收,箱码定位入仓码后按目的类型收货', descriptionKey: 'modules.receiving.description', available: true },
|
{ key: 'receiving', name: '拆柜收货', nameKey: 'modules.receiving.name', icon: '卸', iconType: 'download', description: '三类照片签收,箱码定位入仓码后按目的类型收货', descriptionKey: 'modules.receiving.description', available: true },
|
||||||
{ key: 'warehouseTransferList', name: '仓储转运测量列表', nameKey: 'modules.warehouseTransferList.name', icon: '列', iconType: 'list', description: '查看待测量的仓储转运单', descriptionKey: 'modules.warehouseTransferList.description', available: true, route: '/pages/warehouse-transfer/warehouse-transfer?mode=list' },
|
{ key: 'warehouseTransferList', name: '仓储转运测量', nameKey: 'modules.warehouseTransferList.name', icon: '列', iconType: 'list', description: '查看待测量的仓储转运单', descriptionKey: 'modules.warehouseTransferList.description', available: true, route: '/pages/warehouse-transfer/warehouse-transfer?mode=list' },
|
||||||
{ key: 'warehouseTransferWork', name: '转运测量', nameKey: 'modules.warehouseTransferWork.name', icon: '转', iconType: 'compose', description: '进入转运单详情完成测量、打托和拍照', descriptionKey: 'modules.warehouseTransferWork.description', available: true, route: '/pages/warehouse-transfer/warehouse-transfer?mode=work' },
|
{ key: 'warehouseTransferWork', name: '转运测量', nameKey: 'modules.warehouseTransferWork.name', icon: '转', iconType: 'compose', description: '进入转运单详情完成测量、打托和拍照', descriptionKey: 'modules.warehouseTransferWork.description', available: true, route: '/pages/warehouse-transfer/warehouse-transfer?mode=work' },
|
||||||
{ key: 'returnReceipt', name: '退货入库', nameKey: 'modules.returnReceipt.name', icon: '退', iconType: 'undo', description: '扫描退货单或 SKU,按本包实收数量生成退货库存', descriptionKey: 'modules.returnReceipt.description', available: true, route: '/pages/return-receipt/return-receipt' },
|
{ key: 'returnReceipt', name: '退货入库', nameKey: 'modules.returnReceipt.name', icon: '退', iconType: 'undo', description: '扫描退货单或 SKU,按本包实收数量生成退货库存', descriptionKey: 'modules.returnReceipt.description', available: true, route: '/pages/return-receipt/return-receipt' },
|
||||||
{ key: 'returnPacking', name: '合箱', nameKey: 'modules.returnPacking.name', icon: '合', iconType: 'folder-add', description: '选择同客户已签收退货单,按 SKU 数量生成新箱', descriptionKey: 'modules.returnPacking.description', available: true, route: '/pages/return-packing/return-packing' },
|
{ key: 'returnPacking', name: '合箱', nameKey: 'modules.returnPacking.name', icon: '合', iconType: 'folder-add', description: '选择同客户已签收退货单,按 SKU 数量生成新箱', descriptionKey: 'modules.returnPacking.description', available: true, route: '/pages/return-packing/return-packing' },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<view v-if="user" class="page-shell portal-page fulfillment-page">
|
<view v-if="user" class="page-shell portal-page fulfillment-page">
|
||||||
<PortalHeader title="排车作业" :user="user" />
|
<PortalHeader title="排车作业" :user="user" />
|
||||||
<scroll-view class="fulfillment-scroll" scroll-y><view class="portal-content fulfillment-content">
|
<scroll-view class="fulfillment-scroll" scroll-y><view class="portal-content fulfillment-content" :class="{ 'pda-task-list': !current }">
|
||||||
<view class="fulfillment-heading"><text class="fulfillment-title">理货审核、真实拣货与装车</text></view>
|
<view class="fulfillment-heading"><text class="fulfillment-title">理货审核、真实拣货与装车</text></view>
|
||||||
<view v-if="current" class="detail-toolbar"><text class="detail-title">{{ current.dispatchNo }}</text><button :disabled="busy" @tap="closeTask">‹ 返回列表</button></view>
|
<view v-if="current" class="detail-toolbar"><text class="detail-title">{{ current.dispatchNo }}</text><button :disabled="busy" @tap="closeTask">‹ 返回列表</button></view>
|
||||||
<view v-if="current" class="stage-switch" role="tablist" aria-label="排车作业">
|
<view v-if="current" class="stage-switch" role="tablist" aria-label="排车作业">
|
||||||
@@ -16,10 +16,14 @@
|
|||||||
<view v-if="loading" class="fulfillment-state"><text>正在加载排车事实…</text></view>
|
<view v-if="loading" class="fulfillment-state"><text>正在加载排车事实…</text></view>
|
||||||
<view v-if="error" class="fulfillment-error" role="alert"><text class="state-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><text v-if="error.network">网络中断,请稍后重新操作。</text><button :disabled="busy || loading" @tap="retry">重新核对</button></view>
|
<view v-if="error" class="fulfillment-error" role="alert"><text class="state-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><text v-if="error.network">网络中断,请稍后重新操作。</text><button :disabled="busy || loading" @tap="retry">重新核对</button></view>
|
||||||
<template v-if="!current && !loading">
|
<template v-if="!current && !loading">
|
||||||
<view class="list-toolbar"><text class="list-title">排车任务</text><button :disabled="busy" @tap="loadTasks">刷新</button></view>
|
<view class="list-toolbar pda-list-toolbar"><text>排车任务<text v-if="!error"> · {{ tasks.length }} 单</text></text><button :disabled="busy" @tap="loadTasks"><uni-icons type="refresh" color="#135ee8" size="18" /><text>刷新</text></button></view>
|
||||||
<view v-if="!tasks.length && !error" class="fulfillment-state">暂无排车任务,可扫描排车单号读取。</view>
|
<view v-if="!tasks.length && !error" class="fulfillment-state">暂无排车任务,可扫描排车单号读取。</view>
|
||||||
<view v-if="tasks.length" class="task-list">
|
<view v-if="tasks.length" class="task-list pda-list-cards">
|
||||||
<button v-for="task in tasks" :key="task.dispatchNo" class="task-card" @tap="openTask(task.dispatchNo)"><text class="business-code">{{ task.dispatchNo }}</text><text v-if="task.dispatchNo === recentTaskNo" class="recent-badge">上次作业</text><text class="status-badge">{{ stageLabel(task) }}</text><text class="task-meta">任务号 {{ task.taskNo || '—' }}</text></button>
|
<button v-for="task in tasks" :key="task.dispatchNo" class="task-card pda-task-card" hover-class="pda-task-card-pressed" @tap="openTask(task.dispatchNo)">
|
||||||
|
<view class="pda-task-head"><text class="pda-task-no">{{ task.dispatchNo }}</text><text class="status-badge pda-task-status">{{ stageLabel(task) }}</text></view>
|
||||||
|
<text v-if="String(task.taskNo || '').trim() && String(task.taskNo || '').trim() !== String(task.dispatchNo || '').trim()" class="pda-task-meta">任务号 {{ task.taskNo }}</text>
|
||||||
|
<view class="pda-task-footer"><text v-if="task.dispatchNo === recentTaskNo" class="recent-badge">上次作业</text><view class="pda-task-action"><text>详情</text><uni-icons type="right" color="#135ee8" size="14" /></view></view>
|
||||||
|
</button>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="current">
|
<template v-if="current">
|
||||||
@@ -81,6 +85,15 @@
|
|||||||
</view>
|
</view>
|
||||||
</template><view v-if="successMessage" class="fulfillment-success" role="status">{{ successMessage }}</view>
|
</template><view v-if="successMessage" class="fulfillment-success" role="status">{{ successMessage }}</view>
|
||||||
</view></scroll-view>
|
</view></scroll-view>
|
||||||
|
<view v-if="scanNotice" class="loading-overlay scan-notice-overlay">
|
||||||
|
<view class="loading-dialog scan-notice-dialog" role="status" aria-live="polite">
|
||||||
|
<text class="loading-dialog-title">托盘扫描成功</text>
|
||||||
|
<text class="scan-notice-pallet">{{ scanNotice.palletNo }}</text>
|
||||||
|
<text class="scan-notice-label">本次可拣箱数</text>
|
||||||
|
<text class="scan-notice-count">{{ scanNotice.packages }}<text class="loading-count-unit"> 箱</text></text>
|
||||||
|
<text class="scan-notice-hint">3 秒后自动关闭</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
<view v-if="loadingPreview" class="loading-overlay">
|
<view v-if="loadingPreview" class="loading-overlay">
|
||||||
<view class="loading-dialog" role="dialog" aria-modal="true" aria-labelledby="loading-confirm-title">
|
<view class="loading-dialog" role="dialog" aria-modal="true" aria-labelledby="loading-confirm-title">
|
||||||
<view class="loading-dialog-heading"><text id="loading-confirm-title" class="loading-dialog-title">确认装车</text></view>
|
<view class="loading-dialog-heading"><text id="loading-confirm-title" class="loading-dialog-title">确认装车</text></view>
|
||||||
@@ -103,7 +116,7 @@ import { toDispatchError } from '@/utils/dispatch-error.js'
|
|||||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||||
export default {
|
export default {
|
||||||
components: { PortalHeader, ScanTargetInput, DispatchHandoverStatus, DispatchLoadingDocuments },
|
components: { PortalHeader, ScanTargetInput, DispatchHandoverStatus, DispatchLoadingDocuments },
|
||||||
data() { return { user: session.currentUser, tasks: [], current: null, activeTab: 'picking', scanCode: '', loading: false, requestBusy: false, photoBusy: false, error: null, successMessage: '', bootstrapped: false, resumeOnEntry: false, generation: 0, loadingPreview: null, loadingRows: [], sourceChoices: [], choiceCartonCode: '', reviewReason: '' } },
|
data() { return { user: session.currentUser, tasks: [], current: null, activeTab: 'picking', scanCode: '', scanNotice: null, loading: false, requestBusy: false, photoBusy: false, error: null, successMessage: '', bootstrapped: false, resumeOnEntry: false, generation: 0, loadingPreview: null, loadingRows: [], sourceChoices: [], choiceCartonCode: '', reviewReason: '' } },
|
||||||
computed: {
|
computed: {
|
||||||
busy() { return this.requestBusy || this.photoBusy },
|
busy() { return this.requestBusy || this.photoBusy },
|
||||||
recentTaskNo() { return fulfillment.dispatch.currentNo || '' }, packages() { return fulfillment.dispatch.packages },
|
recentTaskNo() { return fulfillment.dispatch.currentNo || '' }, packages() { return fulfillment.dispatch.packages },
|
||||||
@@ -121,9 +134,15 @@ export default {
|
|||||||
},
|
},
|
||||||
onLoad(options) { this.resumeOnEntry = options?.resume === '1' },
|
onLoad(options) { this.resumeOnEntry = options?.resume === '1' },
|
||||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.startScanner(); if (!this.bootstrapped) { this.bootstrapped = true; if (this.resumeOnEntry && fulfillment.dispatch.currentNo) this.openTask(fulfillment.dispatch.currentNo); else this.loadTasks() } else if (this.current?.dispatchNo) this.openTask(this.current.dispatchNo); else this.loadTasks() },
|
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.startScanner(); if (!this.bootstrapped) { this.bootstrapped = true; if (this.resumeOnEntry && fulfillment.dispatch.currentNo) this.openTask(fulfillment.dispatch.currentNo); else this.loadTasks() } else if (this.current?.dispatchNo) this.openTask(this.current.dispatchNo); else this.loadTasks() },
|
||||||
onHide() { this.stopScanner(); this.generation++ }, beforeUnmount() { this.stopScanner(); this.generation++ },
|
onHide() { this.stopScanner(); this.clearScanNotice(); this.generation++ }, beforeUnmount() { this.stopScanner(); this.clearScanNotice(); this.generation++ },
|
||||||
methods: {
|
methods: {
|
||||||
selectTab(tab) { if (this.busy || this.loading || !['picking', 'photos'].includes(tab) || (tab === 'photos' && !this.canShowPhotos)) return; this.activeTab = tab },
|
clearScanNotice() { clearTimeout(this._scanNoticeTimer); this._scanNoticeTimer = null; this.scanNotice = null },
|
||||||
|
showScanNotice(item) {
|
||||||
|
this.clearScanNotice()
|
||||||
|
this.scanNotice = { palletNo: item.palletNo, packages: this.selectedRemaining }
|
||||||
|
this._scanNoticeTimer = setTimeout(() => this.clearScanNotice(), 3000)
|
||||||
|
},
|
||||||
|
selectTab(tab) { if (this.busy || this.loading || !['picking', 'photos'].includes(tab) || (tab === 'photos' && !this.canShowPhotos)) return; this.clearScanNotice(); this.activeTab = tab },
|
||||||
gate(action) { return this.current?.actions?.[action]?.allowed === true },
|
gate(action) { return this.current?.actions?.[action]?.allowed === true },
|
||||||
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: value => this.handleScan(value), onError: error => this.setError(error, '扫码广播注册失败') }) }, stopScanner() { this._stopScanner?.(); this._stopScanner = null },
|
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: value => this.handleScan(value), onError: error => this.setError(error, '扫码广播注册失败') }) }, stopScanner() { this._stopScanner?.(); this._stopScanner = null },
|
||||||
updatePackages(event) { const value = Number(event.detail.value); updateFulfillmentDraft('dispatch', { packages: Number.isFinite(value) ? value : 0 }) },
|
updatePackages(event) { const value = Number(event.detail.value); updateFulfillmentDraft('dispatch', { packages: Number.isFinite(value) ? value : 0 }) },
|
||||||
@@ -133,6 +152,7 @@ export default {
|
|||||||
handleScan(raw) {
|
handleScan(raw) {
|
||||||
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim()
|
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim()
|
||||||
if (!code || this.busy || this.loading || this.activeTab !== 'picking') return
|
if (!code || this.busy || this.loading || this.activeTab !== 'picking') return
|
||||||
|
this.clearScanNotice()
|
||||||
this.error = null; this.successMessage = ''; this.sourceChoices = []; this.choiceCartonCode = ''
|
this.error = null; this.successMessage = ''; this.sourceChoices = []; this.choiceCartonCode = ''
|
||||||
updateFulfillmentDraft('dispatch', { palletCode: '', dispatchItemId: '', sourceKey: '', cartonCode: '', packages: 1 })
|
updateFulfillmentDraft('dispatch', { palletCode: '', dispatchItemId: '', sourceKey: '', cartonCode: '', packages: 1 })
|
||||||
if (!this.current || code === this.current.dispatchNo || code === this.current.taskNo) return this.openTask(code === this.current?.taskNo ? this.current.dispatchNo : code)
|
if (!this.current || code === this.current.dispatchNo || code === this.current.taskNo) return this.openTask(code === this.current?.taskNo ? this.current.dispatchNo : code)
|
||||||
@@ -151,6 +171,7 @@ export default {
|
|||||||
this.selectPallet(item)
|
this.selectPallet(item)
|
||||||
if (fulfillment.dispatch.dispatchItemId !== item.dispatchItemId) return
|
if (fulfillment.dispatch.dispatchItemId !== item.dispatchItemId) return
|
||||||
if (cartonCode) updateFulfillmentDraft('dispatch', { cartonCode, packages: 1 })
|
if (cartonCode) updateFulfillmentDraft('dispatch', { cartonCode, packages: 1 })
|
||||||
|
else this.showScanNotice(item)
|
||||||
this.scanCode = ''; playSuccessSound()
|
this.scanCode = ''; playSuccessSound()
|
||||||
},
|
},
|
||||||
pickablePackages(item) {
|
pickablePackages(item) {
|
||||||
@@ -206,8 +227,8 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
async retry() { if (this.current?.dispatchNo) await this.openTask(this.current.dispatchNo); else await this.loadTasks() },
|
async retry() { if (this.current?.dispatchNo) await this.openTask(this.current.dispatchNo); else await this.loadTasks() },
|
||||||
async run(work, loading = false) { if (this.busy || this.loading) return; this.error = null; this.successMessage = ''; if (loading) this.loading = true; else this.requestBusy = true; try { await work() } catch (cause) { this.setError(cause, '排车操作失败') } finally { this.loading = false; this.requestBusy = false } },
|
async run(work, loading = false) { if (this.busy || this.loading) return; this.clearScanNotice(); this.error = null; this.successMessage = ''; if (loading) this.loading = true; else this.requestBusy = true; try { await work() } catch (cause) { this.setError(cause, '排车操作失败') } finally { this.loading = false; this.requestBusy = false } },
|
||||||
setError(cause, fallback) { this.error = toDispatchError(cause, fallback); playErrorSound() },
|
setError(cause, fallback) { this.clearScanNotice(); this.error = toDispatchError(cause, fallback); playErrorSound() },
|
||||||
stageLabel(task) { if (task.status === 'CANCELLED') return '已取消'; if (task.stage === 'BEFORE_LOADING') { if (task.actions?.LOADING_REVIEW?.allowed === true) return '待理货审核'; if ([task.reviews?.dispatchReview, task.reviews?.loadingReview].some(review => review?.status === 'REJECTED')) return '已退回客服处理' }; return ({ BEFORE_LOADING: '装车前', LOADING: '装车中', AFTER_LOADING: '装车后', DELIVERING: '派送中', DELIVERED: '派送完成' })[task.stage] || '状态待核实' },
|
stageLabel(task) { if (task.status === 'CANCELLED') return '已取消'; if (task.stage === 'BEFORE_LOADING') { if (task.actions?.LOADING_REVIEW?.allowed === true) return '待理货审核'; if ([task.reviews?.dispatchReview, task.reviews?.loadingReview].some(review => review?.status === 'REJECTED')) return '已退回客服处理' }; return ({ BEFORE_LOADING: '装车前', LOADING: '装车中', AFTER_LOADING: '装车后', DELIVERING: '派送中', DELIVERED: '派送完成' })[task.stage] || '状态待核实' },
|
||||||
reviewTime(value) { if (!value) return '未填写'; const date = new Date(new Date(value).getTime() + 8 * 60 * 60 * 1000); return Number.isNaN(date.getTime()) ? '时间无效' : date.toISOString().slice(0, 16).replace('T', ' ') },
|
reviewTime(value) { if (!value) return '未填写'; const date = new Date(new Date(value).getTime() + 8 * 60 * 60 * 1000); return Number.isNaN(date.getTime()) ? '时间无效' : date.toISOString().slice(0, 16).replace('T', ' ') },
|
||||||
reviewLabel(review) { return ({ NOT_SUBMITTED: '未提交', PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' })[review?.status] || '待核对' },
|
reviewLabel(review) { return ({ NOT_SUBMITTED: '未提交', PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' })[review?.status] || '待核对' },
|
||||||
@@ -273,5 +294,15 @@ export default {
|
|||||||
.loading-cancel { color: #52657d; background: #f0f3f8; }
|
.loading-cancel { color: #52657d; background: #f0f3f8; }
|
||||||
.loading-confirm { color: #fff; background: #1764ec; }
|
.loading-confirm { color: #fff; background: #1764ec; }
|
||||||
.loading-dialog-actions button[disabled] { opacity: .5; }
|
.loading-dialog-actions button[disabled] { opacity: .5; }
|
||||||
|
.scan-notice-overlay { pointer-events: none; }
|
||||||
|
.scan-notice-dialog { text-align: center; }
|
||||||
|
.scan-notice-pallet { display: block; margin-top: 20rpx; color: #243c58; font-size: 30rpx; overflow-wrap: anywhere; }
|
||||||
|
.scan-notice-label { display: block; margin-top: 28rpx; color: #536982; font-size: 28rpx; }
|
||||||
|
.scan-notice-count { display: block; margin-top: 12rpx; color: #1764ec; font-size: 72rpx; font-weight: 700; line-height: 1.3; }
|
||||||
|
.scan-notice-hint { display: block; margin-top: 24rpx; color: #72839b; font-size: 24rpx; }
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@import '../../styles/task-list.scss';
|
||||||
|
</style>
|
||||||
|
|||||||
+52
-7
@@ -8,15 +8,15 @@
|
|||||||
<text class="home-overview-title">{{ greeting }},{{ displayName }}</text>
|
<text class="home-overview-title">{{ greeting }},{{ displayName }}</text>
|
||||||
<text class="home-overview-warehouse">{{ warehouseLabel }}</text>
|
<text class="home-overview-warehouse">{{ warehouseLabel }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="home-overview-status"><text class="home-overview-status-value">{{ availableCount }}</text><text class="home-overview-status-label">项可处理</text></view>
|
|
||||||
</view>
|
</view>
|
||||||
|
<view class="home-workload-toolbar"><text>作业待办</text><button :disabled="countsLoading" @tap="refreshCounts"><uni-icons type="refresh" color="#135ee8" size="18" /><text>{{ countsLoading ? '刷新中…' : '刷新' }}</text></button></view>
|
||||||
<view v-for="group in groups" :key="group.key" class="home-group">
|
<view v-for="group in groups" :key="group.key" class="home-group">
|
||||||
<view class="home-group-title"><view class="home-group-mark" :class="`home-group-mark-${group.key}`"></view><text>{{ group.name }}</text><text class="home-group-count">{{ group.operations.length }}</text></view>
|
<view class="home-group-title"><view class="home-group-mark" :class="`home-group-mark-${group.key}`"></view><text>{{ group.name }}</text></view>
|
||||||
<view class="home-operation-grid">
|
<view class="home-operation-grid">
|
||||||
<button v-for="operation in group.operations" :key="operation.name" class="home-operation" :class="{ 'home-operation-primary': operation.available, 'home-operation-disabled': !operation.available }" :disabled="!operation.available" @tap="openOperation(operation)">
|
<button v-for="operation in group.operations" :key="operation.name" class="home-operation" :class="{ 'home-operation-primary': operation.available, 'home-operation-disabled': !operation.available }" :disabled="!operation.available" @tap="openOperation(operation)">
|
||||||
<view class="home-operation-icon" aria-hidden="true"><uni-icons :type="operation.iconType" :color="operation.available ? '#135ee8' : '#9aa6b2'" size="28" /></view>
|
<view class="home-operation-icon" aria-hidden="true"><uni-icons :type="operation.iconType" :color="operation.available ? '#135ee8' : '#9aa6b2'" size="28" /></view>
|
||||||
<text class="home-operation-name">{{ operation.name }}</text>
|
<text class="home-operation-name">{{ operation.name }}</text>
|
||||||
<text v-if="operation.available" class="home-operation-pending">可处理</text>
|
<text v-if="operation.available" class="home-operation-pending" :class="countBadge(operation).tone">{{ countBadge(operation).label }}</text>
|
||||||
<text v-else class="home-operation-unavailable">暂未开放</text>
|
<text v-else class="home-operation-unavailable">暂未开放</text>
|
||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
@@ -28,8 +28,10 @@
|
|||||||
<script>
|
<script>
|
||||||
import PortalHeader from '@/components/PortalHeader.vue'
|
import PortalHeader from '@/components/PortalHeader.vue'
|
||||||
import { getCurrentWarehouseProfile } from '@/api/inbound.js'
|
import { getCurrentWarehouseProfile } from '@/api/inbound.js'
|
||||||
|
import { loadWorkbenchCounts, workbenchBadge } from '@/api/workbench.js'
|
||||||
import { MODULES } from '@/data/overseas-workbench.js'
|
import { MODULES } from '@/data/overseas-workbench.js'
|
||||||
import { applyWarehouseProfile, session, requireSession } from '@/stores/session.js'
|
import { applyWarehouseProfile, session, requireSession } from '@/stores/session.js'
|
||||||
|
import { restoreWarehouseWork, warehouseWork } from '@/stores/warehouse-work.js'
|
||||||
|
|
||||||
const HOME_GROUPS = MODULES.map((module) => ({
|
const HOME_GROUPS = MODULES.map((module) => ({
|
||||||
...module,
|
...module,
|
||||||
@@ -39,9 +41,8 @@
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { PortalHeader },
|
components: { PortalHeader },
|
||||||
data() { return { user: session.currentUser, groups: HOME_GROUPS, menuOpen: false } },
|
data() { return { user: session.currentUser, groups: HOME_GROUPS, menuOpen: false, counts: {}, countsLoading: false, countsGeneration: 0 } },
|
||||||
computed: {
|
computed: {
|
||||||
availableCount() { return this.groups.reduce((total, group) => total + group.operations.filter((operation) => operation.available).length, 0) },
|
|
||||||
displayName() {
|
displayName() {
|
||||||
const name = String(this.user?.name || '').trim()
|
const name = String(this.user?.name || '').trim()
|
||||||
return name && !/^[a-z0-9._-]+$/i.test(name) ? name : '仓库作业员'
|
return name && !/^[a-z0-9._-]+$/i.test(name) ? name : '仓库作业员'
|
||||||
@@ -57,17 +58,48 @@
|
|||||||
return '晚上好'
|
return '晚上好'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onShow() {
|
async onShow() {
|
||||||
if (!requireSession()) return
|
if (!requireSession()) return
|
||||||
|
const generation = ++this.countsGeneration
|
||||||
this.user = session.currentUser
|
this.user = session.currentUser
|
||||||
if (!this.user?.warehouseCode || !this.user?.warehouseName) this.loadWarehouseProfile()
|
this.counts = {}; this.countsLoading = true
|
||||||
|
if (!this.user?.warehouseCode || !this.user?.warehouseName) await this.loadWarehouseProfile()
|
||||||
|
if (generation !== this.countsGeneration) return
|
||||||
|
this.countsLoading = false
|
||||||
|
if (!session.currentUser) { this.user = null; return }
|
||||||
|
this.user = session.currentUser
|
||||||
|
await this.refreshCounts()
|
||||||
},
|
},
|
||||||
|
onHide() { this.invalidateCounts() },
|
||||||
|
onUnload() { this.invalidateCounts() },
|
||||||
|
beforeUnmount() { this.invalidateCounts() },
|
||||||
onBackPress() {
|
onBackPress() {
|
||||||
if (!this.menuOpen) return false
|
if (!this.menuOpen) return false
|
||||||
this.$refs.portalHeader.closeMenu()
|
this.$refs.portalHeader.closeMenu()
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
countBadge(operation) { return workbenchBadge(operation.key, this.counts[operation.key]) },
|
||||||
|
countsScope() { const user = session.currentUser; return user?.id && user?.warehouseId ? `${session.apiBase}:${user.id}:${user.warehouseId}` : '' },
|
||||||
|
invalidateCounts() { this.countsGeneration += 1; this.countsLoading = false; this.counts = {} },
|
||||||
|
async refreshCounts() {
|
||||||
|
if (this.countsLoading || !this.countsScope()) return
|
||||||
|
const generation = ++this.countsGeneration
|
||||||
|
const scope = this.countsScope()
|
||||||
|
const profile = { ...session.currentUser }
|
||||||
|
restoreWarehouseWork(`${profile.id}:${profile.warehouseId}`)
|
||||||
|
const activeTransferNo = warehouseWork.warehouseTransfer.currentNo
|
||||||
|
this.counts = {}; this.countsLoading = true
|
||||||
|
try {
|
||||||
|
const counts = await loadWorkbenchCounts({ profile, activeTransferNo })
|
||||||
|
if (generation === this.countsGeneration && scope === this.countsScope()) this.counts = counts
|
||||||
|
} finally {
|
||||||
|
if (generation === this.countsGeneration) {
|
||||||
|
this.countsLoading = false
|
||||||
|
if (scope !== this.countsScope()) { this.counts = {}; this.user = session.currentUser }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
async loadWarehouseProfile() {
|
async loadWarehouseProfile() {
|
||||||
try { this.user = applyWarehouseProfile(await getCurrentWarehouseProfile()) } catch (_) { this.user = session.currentUser }
|
try { this.user = applyWarehouseProfile(await getCurrentWarehouseProfile()) } catch (_) { this.user = session.currentUser }
|
||||||
},
|
},
|
||||||
@@ -79,3 +111,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.home-overview{margin-bottom:12px}
|
||||||
|
.home-workload-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px;color:#526174;font-size:14px;font-weight:700}
|
||||||
|
.home-workload-toolbar button{display:flex;align-items:center;justify-content:center;gap:5px;width:auto;height:36px;margin:0;padding:0 14px;border:1px solid #b8d1f5;border-radius:20px;color:#135ee8;font-size:13px;font-weight:700;line-height:normal;background:#fff}
|
||||||
|
.home-workload-toolbar button[disabled]{opacity:.55}
|
||||||
|
.home-operation{height:auto;min-height:104px;padding:10px 6px}
|
||||||
|
.home-operation-name{min-height:17px}
|
||||||
|
.home-operation-pending{box-sizing:border-box;max-width:100%;font-size:10px;line-height:16px;white-space:normal}
|
||||||
|
.home-operation-pending.has-work{color:#9a5300;background:#fff0d6}
|
||||||
|
.home-operation-pending.is-empty,.home-operation-pending.is-loading,.home-operation-pending.is-static{color:#6f8194;background:#e7edf4}
|
||||||
|
.home-operation-pending.is-error{color:#b42318;background:#fff1f0}
|
||||||
|
</style>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+134
-19
@@ -2,7 +2,7 @@
|
|||||||
<view v-if="user" class="page-shell portal-page fulfillment-page">
|
<view v-if="user" class="page-shell portal-page fulfillment-page">
|
||||||
<PortalHeader title="出库作业" :user="user" />
|
<PortalHeader title="出库作业" :user="user" />
|
||||||
<scroll-view class="fulfillment-scroll" scroll-y>
|
<scroll-view class="fulfillment-scroll" scroll-y>
|
||||||
<view class="portal-content fulfillment-content">
|
<view class="portal-content fulfillment-content" :class="{ 'pda-task-list': !current && !discardCurrent }">
|
||||||
<view class="fulfillment-heading"><text class="fulfillment-title">出库任务</text></view>
|
<view class="fulfillment-heading"><text class="fulfillment-title">出库任务</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 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 v-if="!discardCurrent" class="fulfillment-card">
|
<view v-if="!discardCurrent" class="fulfillment-card">
|
||||||
@@ -11,12 +11,16 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="loading" class="fulfillment-state"><text>正在加载出库任务…</text></view>
|
<view v-if="loading" class="fulfillment-state"><text>正在加载出库任务…</text></view>
|
||||||
<view v-else-if="error" class="fulfillment-error" role="alert"><text class="state-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><text v-if="error.network" class="offline-note">网络连接失败,当前任务、扫码草稿和幂等键均已保留。</text><button class="secondary-button" :disabled="loading || busy || discardLoading || discardBusy" @tap="retry">重试</button></view>
|
<view v-else-if="error" class="fulfillment-error" role="alert"><text class="state-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><text v-if="error.network" class="offline-note">网络连接失败,请刷新核对任务状态后重试。</text><button class="secondary-button" :disabled="loading || busy || discardLoading || discardBusy" @tap="retry">重试</button></view>
|
||||||
|
|
||||||
<template v-if="!loading && !current && !discardCurrent">
|
<template v-if="!loading && !current && !discardCurrent">
|
||||||
<view class="list-toolbar"><view><text class="list-title">待处理出库任务</text><text class="list-count">{{ tasks.length }} 个任务</text></view><button :disabled="loading" @tap="loadTasks">刷新</button></view>
|
<view class="list-toolbar pda-list-toolbar"><text>待处理出库任务<text v-if="!error"> · {{ tasks.length }} 项</text></text><button :disabled="loading" @tap="loadTasks"><uni-icons type="refresh" color="#135ee8" size="18" /><text>刷新</text></button></view>
|
||||||
<view v-if="tasks.length" class="task-list">
|
<view v-if="tasks.length" class="task-list pda-list-cards">
|
||||||
<button v-for="task in tasks" :key="task.taskNo" class="task-card" @tap="openTask(task.taskNo)"><view class="task-head"><view class="task-code-row"><text class="business-code">{{ task.outboundNo || '—' }}</text><text v-if="task.taskNo === recentTaskNo" class="recent-badge">上次作业</text></view><text class="status-badge">{{ statusLabel(task.status) }}</text></view><text class="task-meta">{{ taskType(task) }} · 任务号 {{ task.taskNo || '—' }}</text><text class="task-enter">查看并开始出库 ›</text></button>
|
<button v-for="task in tasks" :key="task.taskNo" class="task-card pda-task-card" hover-class="pda-task-card-pressed" @tap="openTask(task.taskNo)">
|
||||||
|
<view class="pda-task-head"><text class="pda-task-no">{{ task.outboundNo || '—' }}</text><text class="status-badge pda-task-status">{{ statusLabel(task.status) }}</text></view>
|
||||||
|
<view class="pda-task-meta"><text>{{ taskType(task) }}</text><text v-if="String(task.taskNo || '').trim() && String(task.taskNo || '').trim() !== String(task.outboundNo || '').trim()"> · 任务号 {{ task.taskNo }}</text></view>
|
||||||
|
<view class="pda-task-footer"><text v-if="task.taskNo === recentTaskNo" class="recent-badge">上次作业</text><view class="pda-task-action"><text>查看并开始出库</text><uni-icons type="right" color="#135ee8" size="14" /></view></view>
|
||||||
|
</button>
|
||||||
</view>
|
</view>
|
||||||
<view v-else-if="!error && !discardTasks.length" class="fulfillment-state"><text class="state-title">暂无出库任务</text><text>可刷新列表,或扫描任务号恢复未完成作业。</text></view>
|
<view v-else-if="!error && !discardTasks.length" class="fulfillment-state"><text class="state-title">暂无出库任务</text><text>可刷新列表,或扫描任务号恢复未完成作业。</text></view>
|
||||||
<view v-if="discardTasks.length" class="discard-section"><view class="list-toolbar"><view><text class="list-title">废弃订单</text><text class="list-count">{{ discardOrderCount }} 个订单</text></view></view><view class="task-list"><button v-for="order in discardOrders" :key="order.discardNo" class="task-card" @tap="openDiscardOrder(order.discardNo)"><view class="task-head"><view class="task-code-row"><text class="business-code">{{ order.discardNo || '—' }}</text></view><text class="status-badge">废弃订单</text></view><text v-if="order.outboundNo" class="task-meta">出库单 {{ order.outboundNo }} · 废弃出库</text><text class="task-meta">托盘 {{ order.palletCount }} 个 · 按箱废弃 {{ order.cartonCount }} 箱 · 待作业 {{ order.pendingPalletCount }} 个</text><text class="task-enter">点击进入废弃出库作业 ›</text></button></view></view>
|
<view v-if="discardTasks.length" class="discard-section"><view class="list-toolbar"><view><text class="list-title">废弃订单</text><text class="list-count">{{ discardOrderCount }} 个订单</text></view></view><view class="task-list"><button v-for="order in discardOrders" :key="order.discardNo" class="task-card" @tap="openDiscardOrder(order.discardNo)"><view class="task-head"><view class="task-code-row"><text class="business-code">{{ order.discardNo || '—' }}</text></view><text class="status-badge">废弃订单</text></view><text v-if="order.outboundNo" class="task-meta">出库单 {{ order.outboundNo }} · 废弃出库</text><text class="task-meta">托盘 {{ order.palletCount }} 个 · 按箱废弃 {{ order.cartonCount }} 箱 · 待作业 {{ order.pendingPalletCount }} 个</text><text class="task-enter">点击进入废弃出库作业 ›</text></button></view></view>
|
||||||
@@ -39,17 +43,29 @@
|
|||||||
|
|
||||||
<view v-if="selectedPosition && selectedRemaining > 0" class="fulfillment-card"><text class="card-title">本次拣货:{{ lineBusinessCode(selectedPosition) }}</text><text v-if="selectedItem.returnSkuQuantity != null" class="scan-help">本来源计划出库 {{ selectedItem.returnSkuQuantity }} 件,请清点这些件数后确认;余数保留原箱。</text><view class="count-row"><text>拣货箱数</text><input type="number" :value="packages" :placeholder="`请输入 1-${selectedRemaining} 箱`" @input="updatePackages" /><text>最多 {{ selectedRemaining }} 箱</text></view><button class="primary-button" :disabled="!canPick || busy" @tap="submitPick">{{ busyAction === 'pick' ? '提交中…' : '确认出库拣货' }}</button></view>
|
<view v-if="selectedPosition && selectedRemaining > 0" class="fulfillment-card"><text class="card-title">本次拣货:{{ lineBusinessCode(selectedPosition) }}</text><text v-if="selectedItem.returnSkuQuantity != null" class="scan-help">本来源计划出库 {{ selectedItem.returnSkuQuantity }} 件,请清点这些件数后确认;余数保留原箱。</text><view class="count-row"><text>拣货箱数</text><input type="number" :value="packages" :placeholder="`请输入 1-${selectedRemaining} 箱`" @input="updatePackages" /><text>最多 {{ selectedRemaining }} 箱</text></view><button class="primary-button" :disabled="!canPick || busy" @tap="submitPick">{{ busyAction === 'pick' ? '提交中…' : '确认出库拣货' }}</button></view>
|
||||||
|
|
||||||
<view class="fulfillment-card gate-card"><text class="card-title">完成出库任务</text><text v-if="remainingPackages > 0" class="gate-warning">尚有 {{ remainingPackages }} 箱未拣,不能完成出库。</text><text v-else-if="current.status === 'COMPLETED'" class="gate-success">出库任务已完成,不可重复提交。</text><text v-else class="gate-note">全部计划货物已扫描核平,服务端将再次校验库存门禁。</text><button v-if="current.status !== 'COMPLETED'" class="primary-button" :disabled="!canComplete || busy" @tap="completeTask">{{ busyAction === 'complete' ? '完成中…' : '完成出库任务' }}</button></view>
|
<view class="fulfillment-card gate-card"><text class="card-title">完成出库任务</text><text v-if="remainingPackages > 0" class="gate-warning">尚有 {{ remainingPackages }} 箱未拣,不能完成出库。</text><text v-else-if="current.status === 'COMPLETED'" class="gate-success">出库任务已完成,不可重复提交。</text><button v-if="current.status !== 'COMPLETED'" class="primary-button" :disabled="!canComplete || busy" @tap="completeTask">{{ busyAction === 'complete' ? '完成中…' : '完成出库任务' }}</button></view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="discardCurrent">
|
<template v-else-if="discardCurrent">
|
||||||
<view class="detail-toolbar"><view><text class="detail-kicker">废弃订单</text><text class="detail-title">{{ discardCurrent.discardNo }}</text><text v-if="discardCurrent.outboundNo" class="task-meta">出库单 {{ discardCurrent.outboundNo }}</text></view><button :disabled="discardBusy" @tap="closeDiscard">‹ 返回列表</button></view>
|
<view class="detail-toolbar"><view><text class="detail-kicker">废弃订单</text><text class="detail-title">{{ discardCurrent.discardNo }}</text><text v-if="discardCurrent.outboundNo" class="task-meta">出库单 {{ discardCurrent.outboundNo }}</text></view><button :disabled="discardBusy" @tap="closeDiscard">‹ 返回列表</button></view>
|
||||||
<view class="fulfillment-card"><text class="card-title">本单相关托盘及废弃范围</text><view v-for="task in discardCurrentTasks" :key="task.palletNo" class="outbound-line"><view class="task-head"><text class="business-code">{{ task.palletNo === '散箱' ? '散箱' : task.palletNo }}</text><text>{{ task.status === 'COMPLETED' ? '已完成' : '待废弃' }}</text></view><text v-for="mark in task.cartonMarks" :key="mark" class="line-code">{{ mark }}</text><text v-if="!task.cartonMarks.length" class="scan-help">缺少箱唛与托盘对应关系,请核对相关托盘,以仓库实际废弃箱数为准。</text></view></view>
|
<view v-if="discardOrderRemark" class="fulfillment-card discard-remark"><text class="card-title">废弃备注</text><text class="task-meta">{{ discardOrderRemark }}</text></view>
|
||||||
<view class="fulfillment-card"><ScanTargetInput name="cargo-discard" :model-value="discardPalletCode" :label="discardTask?.scanMode === 'CARTON' ? '废弃箱唛' : '废弃托盘码'" :placeholder="discardTask?.scanMode === 'CARTON' ? '扫描本单所选箱唛' : '扫描对应托盘码'" :active="true" @update:modelValue="updateDiscardPalletCode" @submit="resolveDiscardPallet" /></view>
|
<view v-if="discardOrderRemaining != null" class="fulfillment-card"><text>申请废弃 {{ discardCurrentTasks[0]?.requestedPackages ?? discardTask?.requestedPackages }} 箱 · 剩余 {{ discardOrderRemaining }} 箱</text></view>
|
||||||
<view v-if="discardTask" class="fulfillment-card item-card"><view class="task-head"><text class="business-code">{{ discardTask.scanMode === 'CARTON' ? (discardTask.palletNo === '散箱' ? '散箱废弃' : '按箱废弃 · ' + discardTask.palletNo) : '托盘 ' + discardTask.palletNo }}</text><text class="status-badge">{{ discardTask.status === 'COMPLETED' ? '已完成' : '待废弃' }}</text></view><text class="task-meta">本次废弃 {{ discardQuantitySummary }}</text><view v-for="sku in discardTask.skus" :key="sku.itemId" class="outbound-line"><text class="line-code">{{ sku.skuCode || '—' }}</text><text class="line-meta">{{ sku.productName || '—' }} · 申请 {{ sku.plannedPackages }} {{ sku.quantityUnit || '箱' }}</text><view v-if="sku.actualQuantityEditable" class="quantity-row"><text>实际废弃箱数</text><input type="number" placeholder="请输入实际箱数" :value="discardActuals[sku.itemId] ?? ''" :disabled="discardTask.status === 'COMPLETED' || discardBusy || discardLoading || discardPhotoBusy" @input="updateDiscardActual(sku.itemId, $event)" /><text>箱(0–{{ sku.plannedPackages }})</text></view><text v-else-if="sku.actualPackages != null" class="line-meta">实际废弃 {{ sku.actualPackages }} {{ sku.quantityUnit || '箱' }}</text><text v-if="sku.actualQuantityEditable" class="scan-help">按现场实点填写,未废弃的预留库存将释放。</text></view><view v-if="discardTask.scanMode === 'CARTON'"><text class="scan-help">已核对 {{ discardScannedMarks.length }}/{{ discardTask.cartonMarks.length }} 箱</text><view v-for="mark in discardTask.cartonMarks" :key="mark" class="outbound-line"><text>{{ mark }}</text><text>{{ discardAllCartons ? '全部废弃' : discardScannedMarks.includes(mark) ? '已扫描' : '待扫描' }}</text></view><button v-if="discardTask.status !== 'COMPLETED'" class="secondary-button" :disabled="discardBusy || discardLoading || discardPhotoBusy" @tap="discardAllCartons = true">一键废弃本单所选箱子({{ discardTask.cartonMarks.length }} 箱)</button><text v-if="discardAllCartons" class="scan-help">已确认本单所选箱子全部废弃,拍照后点击废弃出库完成。</text></view><view class="discard-photo-section"><text class="business-code">废弃现场照片(至少 1 张)</text><text class="scan-help">照片关联当前废弃作业,完成出库后可在废弃单详情查看。</text><view class="discard-photo-grid"><view v-for="photo in discardPhotos" :key="photo.fileId" class="discard-photo-tile"><image v-if="discardPhotoUrls[photo.fileId]" :src="discardPhotoUrls[photo.fileId]" mode="aspectFill" @tap="previewDiscardPhoto(photo.fileId)" /><button v-else class="secondary-button" @tap="loadDiscardPhoto(photo.fileId)">{{ discardPhotoErrors[photo.fileId] ? '加载失败,点击重试' : '加载照片' }}</button></view></view><button v-if="discardTask.status !== 'COMPLETED'" class="secondary-button" :disabled="discardLoading || discardBusy || discardPhotoBusy || !discardPhotoReady || discardPhotos.length >= 20" @tap="takeDiscardPhoto">{{ discardPhotoBusy ? '照片上传中…' : '拍摄废弃现场照片' }}({{ discardPhotos.length }}/20)</button><button v-if="!discardPhotoReady" class="secondary-button" :disabled="discardLoading || discardPhotoBusy" @tap="restoreDiscardPhotos">重试读取照片</button></view><button class="primary-button" :disabled="!canConfirmDiscard || discardBusy" @tap="confirmDiscard">{{ discardBusy ? '提交中…' : '废弃出库' }}</button></view>
|
<view v-if="successMessage" class="fulfillment-success" role="status"><text>{{ successMessage }}</text></view>
|
||||||
|
<view v-if="!discardTask && discardPendingTasks.length" class="fulfillment-card"><text class="card-title">废弃箱关联托盘</text><view v-for="task in discardPendingTasks" :key="task.palletNo" class="outbound-line"><view class="task-head"><text class="business-code">{{ task.palletNo === '散箱' ? '散箱' : task.palletNo }}</text><text>待废弃</text></view></view></view>
|
||||||
|
<view v-if="!discardTask && discardPendingTasks.length" class="fulfillment-card"><ScanTargetInput name="cargo-discard" :model-value="discardPalletCode" label="废弃托盘码" placeholder="扫描对应托盘码" :active="discardTask?.scanMode !== 'CARTON'" @update:modelValue="updateDiscardPalletCode" @submit="resolveDiscardPallet" /></view>
|
||||||
|
<view v-if="discardTask" class="fulfillment-card"><ScanTargetInput v-if="discardNeedsRefresh" name="cargo-discard-retry" :model-value="discardPalletCode" label="重新扫描废弃托盘" placeholder="重新扫描当前托盘码" :active="true" @update:modelValue="updateDiscardPalletCode" @submit="resolveDiscardPallet" /><view v-else class="task-head"><text class="business-code">{{ discardTask.palletNo }}</text><text>{{ discardTask.status === 'COMPLETED' ? '已完成' : '待废弃' }}</text></view></view>
|
||||||
|
<view v-if="discardTask" class="fulfillment-card item-card">
|
||||||
|
<text v-if="discardQuantityLines.length" class="discard-scanned-count">已扫描 {{ discardUnboundScans.length }} 箱</text>
|
||||||
|
<view v-if="discardQuantityLines.length > 1" class="quantity-row"><button v-for="sku in discardQuantityLines" :key="sku.itemId" class="secondary-button" :disabled="discardBusy" @tap="discardScanItemId = sku.itemId">{{ sku.skuCode || '—' }}{{ discardScanItemId === sku.itemId ? '(当前扫描)' : '' }}</button></view>
|
||||||
|
<view v-if="discardQuantityLines.length"><text class="business-code">扫描箱号出库</text><ScanTargetInput name="cargo-discard-count" :model-value="discardCartonCode" label="废弃箱唛" placeholder="扫描现场已有箱唛" :active="true" @update:modelValue="discardCartonCode = $event" @submit="scanDiscardCarton" /><view v-if="discardUnboundScans.length" class="discard-carton-heading" role="button" :aria-expanded="discardScannedExpanded" :aria-label="discardScannedExpanded ? '收起已扫描箱唛' : '展开已扫描箱唛'" @tap="discardScannedExpanded = !discardScannedExpanded"><text class="scan-help">已扫描 {{ discardUnboundScans.length }} 箱</text><view class="discard-carton-chevron" :class="{ expanded: discardScannedExpanded }" aria-hidden="true"></view></view><view v-if="discardScannedExpanded"><view v-for="scan in discardUnboundScans" :key="scan.mark" class="outbound-line"><text>{{ scan.mark }}</text><button class="discard-scan-remove" :disabled="discardBusy || discardPhotoBusy" :aria-label="`移除箱唛 ${scan.mark}`" @tap="removeDiscardScan(scan.mark)">移除</button></view></view></view><view v-if="discardTask.scanMode === 'CARTON'"><view class="discard-carton-heading" role="button" :aria-expanded="discardCartonsExpanded" :aria-label="discardCartonsExpanded ? '收起箱号核对' : '展开箱号核对'" @tap="discardCartonsExpanded = !discardCartonsExpanded"><text class="business-code">箱号核对</text><view class="discard-carton-heading-end"><text class="scan-help">待扫描 {{ discardPendingMarks.length }} 箱</text><view class="discard-carton-chevron" :class="{ expanded: discardCartonsExpanded }" aria-hidden="true"></view></view></view>
|
||||||
|
<text class="scan-help">已核对 {{ discardScannedMarks.length }}/{{ discardTask.cartonMarks.length }} 箱</text>
|
||||||
|
<view v-if="discardCartonsExpanded">
|
||||||
|
<view v-for="mark in discardPendingMarks" :key="mark" class="outbound-line"><text>{{ mark }}</text></view>
|
||||||
|
<text v-if="!discardPendingMarks.length" class="scan-help">箱号已全部核对</text>
|
||||||
|
</view><view v-if="discardTask.status !== 'COMPLETED'"><text class="business-code">扫描箱号出库</text><ScanTargetInput name="cargo-discard-carton" :model-value="discardCartonCode" label="废弃箱号" placeholder="扫描本次废弃的系统箱号" :active="true" @update:modelValue="discardCartonCode = $event" @submit="scanDiscardCarton" /></view></view><view class="discard-photo-section"><text class="business-code">废弃现场照片(至少 1 张)</text><view class="discard-photo-grid"><view v-for="photo in discardPhotos" :key="photo.fileId" class="discard-photo-tile"><image v-if="discardPhotoUrls[photo.fileId]" :src="discardPhotoUrls[photo.fileId]" mode="aspectFill" @tap="previewDiscardPhoto(photo.fileId)" /><button v-else class="secondary-button" @tap="loadDiscardPhoto(photo.fileId)">{{ discardPhotoErrors[photo.fileId] ? '加载失败,点击重试' : '加载照片' }}</button></view></view><button v-if="discardTask.status !== 'COMPLETED'" class="secondary-button" :disabled="discardLoading || discardBusy || discardPhotoBusy || !discardPhotoReady || discardPhotos.length >= 20" @tap="takeDiscardPhoto">{{ discardPhotoBusy ? '照片上传中…' : '拍摄废弃现场照片' }}({{ discardPhotos.length }}/20)</button><button v-if="!discardPhotoReady" class="secondary-button" :disabled="discardLoading || discardPhotoBusy" @tap="restoreDiscardPhotos">重试读取照片</button></view><button class="primary-button" :disabled="!canConfirmDiscard || discardBusy" @tap="confirmDiscard">{{ discardBusy ? '提交中…' : '废弃出库' }}</button></view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<view v-if="successMessage" class="fulfillment-success" role="status"><text>{{ successMessage }}</text></view>
|
<view v-if="successMessage && !discardCurrent" class="fulfillment-success" role="status"><text>{{ successMessage }}</text></view>
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
</view>
|
</view>
|
||||||
@@ -66,8 +82,13 @@
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { PortalHeader, ScanTargetInput },
|
components: { PortalHeader, ScanTargetInput },
|
||||||
data() { return { user: session.currentUser, tasks: [], discardTasks: [], current: null, discardCurrent: null, discardTask: null, discardActuals: {}, discardScannedMarks: [], discardAllCartons: false, discardPhotos: [], discardPhotoUrls: {}, discardPhotoErrors: {}, discardPhotoBusy: false, discardPhotoReady: false, discardPalletCode: '', discardLoading: false, discardBusy: false, selectedItem: null, selectedPosition: null, selectedScanCode: '', scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '', bootstrapped: false } },
|
data() { return { user: session.currentUser, tasks: [], discardTasks: [], current: null, discardCurrent: null, discardTask: null, discardScannedExpanded: false, discardUnboundScans: [], discardScanItemId: '', discardActuals: {}, discardScannedMarks: [], discardCartonsExpanded: true, discardCartonCode: '', discardPhotos: [], discardPhotoUrls: {}, discardPhotoErrors: {}, discardPhotoBusy: false, discardPhotoReady: false, discardPalletCode: '', discardLoading: false, discardBusy: false, discardNeedsRefresh: false, selectedItem: null, selectedPosition: null, selectedScanCode: '', scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '', bootstrapped: false } },
|
||||||
computed: {
|
computed: {
|
||||||
|
// 已扫描箱号保留提交凭据,列表只显示尚未核对的箱子。
|
||||||
|
discardOrderRemark() { return this.discardTask?.remark || this.discardCurrentTasks.find(task => task.remark)?.remark || '' },
|
||||||
|
discardOrderRemaining() { return this.discardTask?.remainingPackages ?? this.discardCurrentTasks[0]?.remainingPackages ?? null },
|
||||||
|
discardPendingMarks() { return (this.discardTask?.cartonMarks || []).filter(mark => !this.discardScannedMarks.includes(mark)) },
|
||||||
|
discardQuantityLines() { return (this.discardTask?.skus || []).filter(sku => sku.actualQuantityEditable) },
|
||||||
displayItems() {
|
displayItems() {
|
||||||
const items = this.current?.items || []
|
const items = this.current?.items || []
|
||||||
if (this.current?.outboundType !== 'RETURN_OUTBOUND') return items
|
if (this.current?.outboundType !== 'RETURN_OUTBOUND') return items
|
||||||
@@ -101,6 +122,7 @@
|
|||||||
canComplete() { return this.plannedPackages > 0 && this.remainingPackages === 0 && this.taskOperable },
|
canComplete() { return this.plannedPackages > 0 && this.remainingPackages === 0 && this.taskOperable },
|
||||||
discardOrders() { const groups = new Map(); for (const task of this.discardTasks) { const current = groups.get(task.discardNo) || { discardNo: task.discardNo, outboundNo: task.outboundNo, palletCount: 0, cartonCount: 0, pendingPalletCount: 0 }; if (task.scanMode === 'CARTON') current.cartonCount += (task.cartonMarks || []).length; else current.palletCount += 1; if (task.status !== 'COMPLETED') current.pendingPalletCount += 1; groups.set(task.discardNo, current) } return [...groups.values()] },
|
discardOrders() { const groups = new Map(); for (const task of this.discardTasks) { const current = groups.get(task.discardNo) || { discardNo: task.discardNo, outboundNo: task.outboundNo, palletCount: 0, cartonCount: 0, pendingPalletCount: 0 }; if (task.scanMode === 'CARTON') current.cartonCount += (task.cartonMarks || []).length; else current.palletCount += 1; if (task.status !== 'COMPLETED') current.pendingPalletCount += 1; groups.set(task.discardNo, current) } return [...groups.values()] },
|
||||||
discardOrderCount() { return this.discardOrders.length },
|
discardOrderCount() { return this.discardOrders.length },
|
||||||
|
discardPendingTasks() { return this.discardCurrentTasks.filter(task => task.status !== 'COMPLETED') },
|
||||||
discardCurrentTasks() { return this.discardTasks.filter(task => task.discardNo === this.discardCurrent?.discardNo) },
|
discardCurrentTasks() { return this.discardTasks.filter(task => task.discardNo === this.discardCurrent?.discardNo) },
|
||||||
discardQuantitySummary() {
|
discardQuantitySummary() {
|
||||||
const task = this.discardTask
|
const task = this.discardTask
|
||||||
@@ -109,7 +131,7 @@
|
|||||||
const boxes = new Set(task.cartonMarks || []).size || task.skus.filter(sku => sku.quantityUnit !== '件').reduce((total, sku) => total + sku.plannedPackages, 0)
|
const boxes = new Set(task.cartonMarks || []).size || task.skus.filter(sku => sku.quantityUnit !== '件').reduce((total, sku) => total + sku.plannedPackages, 0)
|
||||||
return `${boxes} 箱${pieces ? ` · ${pieces} 件` : ''}`
|
return `${boxes} 箱${pieces ? ` · ${pieces} 件` : ''}`
|
||||||
},
|
},
|
||||||
canConfirmDiscard() { const task = this.discardTask; return Boolean(this.discardPhotoReady && this.discardPhotos.length > 0 && !this.discardPhotoBusy && !this.discardLoading && !this.discardBusy && task && task.status !== 'COMPLETED' && task.skus.length && task.skus.every(sku => this.validDiscardActual(sku)) && (task.scanMode !== 'CARTON' || (task.cartonMarks?.length && (this.discardAllCartons || task.cartonMarks.every(mark => this.discardScannedMarks.includes(mark)))))) }
|
canConfirmDiscard() { const task = this.discardTask; return Boolean(this.discardPhotoReady && this.discardPhotos.length > 0 && !this.discardPhotoBusy && !this.discardLoading && !this.discardBusy && task && task.status !== 'COMPLETED' && (task.remainingPackages == null || task.scanMode === 'CARTON' || task.skus.reduce((sum, sku) => sum + Number(this.discardActuals[sku.itemId] || 0), 0) <= task.remainingPackages) && task.skus.length && task.skus.every(sku => this.validDiscardActual(sku)) && (task.scanMode !== 'CARTON' || (task.cartonMarks?.length && task.cartonMarks.every(mark => this.discardScannedMarks.includes(mark))))) }
|
||||||
},
|
},
|
||||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.startScanner(); if (this.discardPhotoBusy || this.discardBusy) return; if (this.discardCurrent) { if (this.discardTask) this.restoreDiscardPhotos(); return } if (!this.bootstrapped) { this.bootstrapped = true; this.scanCode = ''; this.loadTasks(); return } if (this.current?.taskNo) this.openTask(this.current.taskNo); else this.loadTasks() },
|
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.startScanner(); if (this.discardPhotoBusy || this.discardBusy) return; if (this.discardCurrent) { if (this.discardTask) this.restoreDiscardPhotos(); return } if (!this.bootstrapped) { this.bootstrapped = true; this.scanCode = ''; this.loadTasks(); return } if (this.current?.taskNo) this.openTask(this.current.taskNo); else this.loadTasks() },
|
||||||
onHide() { this.stopScanner() },
|
onHide() { this.stopScanner() },
|
||||||
@@ -137,27 +159,85 @@
|
|||||||
}, true)
|
}, true)
|
||||||
},
|
},
|
||||||
closeTask() { this.current = null; this.selectedItem = null; this.selectedPosition = null; this.error = null; this.successMessage = ''; updateFulfillmentDraft('outbound', { currentTaskNo: '', scanCode: '', packages: 1 }); this.loadTasks() },
|
closeTask() { this.current = null; this.selectedItem = null; this.selectedPosition = null; this.error = null; this.successMessage = ''; updateFulfillmentDraft('outbound', { currentTaskNo: '', scanCode: '', packages: 1 }); this.loadTasks() },
|
||||||
openDiscardOrder(discardNo) { if (this.discardPhotoBusy) return; this.resetDiscardPhotos(); const code = String(discardNo || '').trim(); if (!code || this.discardBusy || this.busy || this.loading) return; this.current = null; this.discardLoading = false; this.discardCurrent = { discardNo: code, outboundNo: this.discardOrders.find(order => order.discardNo === code)?.outboundNo || '' }; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.discardScannedMarks = []; this.discardAllCartons = false; this.error = null; const loose = this.discardTasks.find(task => task.discardNo === code && task.scanMode === 'CARTON' && task.status !== 'COMPLETED'); if (loose) { this.discardTask = normalizeCargoDiscardTask(loose); this.discardActuals = Object.fromEntries(this.discardTask.skus.map(sku => [sku.itemId, sku.plannedPackages])); this.restoreDiscardPhotos() } },
|
openDiscardOrder(discardNo) { if (this.discardPhotoBusy) return; this.resetDiscardPhotos(); const code = String(discardNo || '').trim(); if (!code || this.discardBusy || this.busy || this.loading) return; this.current = null; this.discardLoading = false; this.discardCurrent = { discardNo: code, outboundNo: this.discardOrders.find(order => order.discardNo === code)?.outboundNo || '' }; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.discardUnboundScans = []; this.discardScannedExpanded = false; this.discardScanItemId = ''; this.discardScannedMarks = []; this.discardCartonsExpanded = true; this.discardCartonCode = ''; this.discardNeedsRefresh = false; this.error = null; this.successMessage = ''; },
|
||||||
closeDiscard() { if (this.discardBusy || this.discardPhotoBusy) return; this.resetDiscardPhotos(); this.discardLoading = false; this.discardCurrent = null; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.loadTasks() },
|
closeDiscard() { if (this.discardBusy || this.discardPhotoBusy) return; this.resetDiscardPhotos(); this.discardLoading = false; this.discardCurrent = null; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.loadTasks() },
|
||||||
updateDiscardPalletCode(value) { this.discardPalletCode = value },
|
updateDiscardPalletCode(value) { this.discardPalletCode = value },
|
||||||
|
// 下方扫描框只核对当前任务的真实箱号,不切换托盘或清空照片。
|
||||||
|
async scanDiscardCarton(raw) {
|
||||||
|
if (!this.discardTask || this.discardTask.status === 'COMPLETED' || this.discardBusy || this.discardLoading || this.discardPhotoBusy) return
|
||||||
|
const code = String(typeof raw === 'string' ? raw : raw?.detail?.value ?? this.discardCartonCode).trim()
|
||||||
|
if (!code) return
|
||||||
|
if (this.discardNeedsRefresh && !await this.refreshDiscardTask()) return
|
||||||
|
if (this.discardTask.status === 'COMPLETED') return
|
||||||
|
if (this.discardQuantityLines.length) {
|
||||||
|
if ((this.discardCurrentTasks || []).some(task => task.palletNo === code)) { this.setError(new Error('请扫描箱唛,不能把托盘码作为箱唛'), '箱唛核对失败'); return }
|
||||||
|
const sku = this.discardQuantityLines.length === 1 ? this.discardQuantityLines[0] : this.discardQuantityLines.find(line => line.itemId === this.discardScanItemId)
|
||||||
|
if (!sku) { this.setError(new Error('请先选择当前扫描的货物'), '箱唛核对失败'); return }
|
||||||
|
if (this.discardUnboundScans.some(scan => scan.mark === code)) { this.discardCartonCode = ''; return }
|
||||||
|
if (code.length > 128 || this.discardUnboundScans.filter(scan => scan.itemId === sku.itemId).length >= sku.plannedPackages || this.discardUnboundScans.length >= (this.discardTask.remainingPackages ?? Infinity)) { this.setError(new Error('扫描箱数不能超过本托预留或申请剩余箱数'), '箱唛核对失败'); return }
|
||||||
|
this.discardUnboundScans = [...this.discardUnboundScans, { itemId: sku.itemId, mark: code }]
|
||||||
|
this.discardActuals = { ...this.discardActuals, [sku.itemId]: this.discardUnboundScans.filter(scan => scan.itemId === sku.itemId).length }
|
||||||
|
this.discardCartonCode = ''; this.error = null; playSuccessSound(); return
|
||||||
|
}
|
||||||
|
if (!this.discardTask.cartonMarks.includes(code)) { this.setError(new Error('箱号不属于当前废弃任务'), '箱号核对失败'); return }
|
||||||
|
if (!this.discardScannedMarks.includes(code)) this.discardScannedMarks = [...this.discardScannedMarks, code]
|
||||||
|
this.discardCartonCode = ''; this.error = null; playSuccessSound()
|
||||||
|
},
|
||||||
|
/** 删除误扫的现场箱唛,并同步扣回对应明细实扫数量。 */
|
||||||
|
removeDiscardScan(mark) {
|
||||||
|
if (this.discardBusy || this.discardPhotoBusy) return
|
||||||
|
const removed = this.discardUnboundScans.find(scan => scan.mark === mark)
|
||||||
|
if (!removed) return
|
||||||
|
this.discardUnboundScans = this.discardUnboundScans.filter(scan => scan.mark !== mark)
|
||||||
|
this.discardActuals = { ...this.discardActuals, [removed.itemId]: this.discardUnboundScans.filter(scan => scan.itemId === removed.itemId).length }
|
||||||
|
this.error = null; this.successMessage = ''
|
||||||
|
},
|
||||||
async resolveDiscardPallet(raw) {
|
async resolveDiscardPallet(raw) {
|
||||||
const code = String(typeof raw === 'string' ? raw : raw?.detail?.value ?? this.discardPalletCode).trim()
|
const code = String(typeof raw === 'string' ? raw : raw?.detail?.value ?? this.discardPalletCode).trim()
|
||||||
if (!code || !this.discardCurrent || this.discardBusy || this.discardLoading || this.discardPhotoBusy) return
|
if (!code || !this.discardCurrent || this.discardBusy || this.discardLoading || this.discardPhotoBusy) return
|
||||||
if (this.discardTask?.scanMode === 'CARTON' && this.discardTask.cartonMarks.includes(code)) { if (!this.discardScannedMarks.includes(code)) this.discardScannedMarks = [...this.discardScannedMarks, code]; this.error = null; playSuccessSound(); return }
|
if ((this.discardQuantityLines.length && !this.discardCurrentTasks.some(task => task.palletNo === code) && this.discardTask?.palletNo !== code) || (this.discardTask?.scanMode === 'CARTON' && this.discardTask.cartonMarks.includes(code))) { await this.scanDiscardCarton(code); return }
|
||||||
|
if (this.discardTask?.palletNo === code) { await this.refreshDiscardTask(); return }
|
||||||
// 当前托盘可重扫,同一废弃单内也可通过托盘或所选箱唛切换任务。
|
// 当前托盘可重扫,同一废弃单内也可通过托盘或所选箱唛切换任务。
|
||||||
if (this.discardTask?.scanMode === 'CARTON' && !this.discardTasks.some(task => task.discardNo === this.discardCurrent.discardNo && (task.palletNo === code || (task.cartonMarks || []).includes(code)))) { this.setError(new Error('托盘或箱唛不属于当前废弃单所选货物'), '箱唛核对失败'); return }
|
if (this.discardTask?.scanMode === 'CARTON' && !this.discardTasks.some(task => task.discardNo === this.discardCurrent.discardNo && (task.palletNo === code || (task.cartonMarks || []).includes(code)))) { this.setError(new Error('托盘或箱唛不属于当前废弃单所选货物'), '箱唛核对失败'); return }
|
||||||
|
// 有托盘的任务须先扫描本单托盘,箱号仅用于展开后的逐箱核对。
|
||||||
|
const selectedTasks = this.discardTasks.filter(task => task.discardNo === this.discardCurrent.discardNo)
|
||||||
|
if (selectedTasks.length && !selectedTasks.some(task => task.palletNo !== '散箱' ? task.palletNo === code : (task.cartonMarks || []).includes(code))) { this.setError(new Error('请先扫描当前废弃单对应的托盘码'), '托盘核对失败'); return }
|
||||||
const order = this.discardCurrent
|
const order = this.discardCurrent
|
||||||
this.resetDiscardPhotos(); this.discardScannedMarks = []; this.discardAllCartons = false; this.discardPalletCode = code; this.discardLoading = true; this.discardTask = null; this.discardActuals = {}; this.error = null
|
this.resetDiscardPhotos(); this.discardScannedMarks = []; this.discardCartonsExpanded = true; this.discardCartonCode = ''; this.discardPalletCode = code; this.discardLoading = true; this.discardNeedsRefresh = false; this.discardTask = null; this.discardUnboundScans = []; this.discardScannedExpanded = false; this.discardScanItemId = ''; this.discardActuals = {}; this.error = null; this.successMessage = ''
|
||||||
try {
|
try {
|
||||||
const task = await resolveCargoDiscardPallet(code, order.discardNo)
|
const task = await resolveCargoDiscardPallet(code, order.discardNo)
|
||||||
if (this.discardCurrent !== order) return
|
if (this.discardCurrent !== order) return
|
||||||
if (task.discardNo !== order.discardNo) throw new Error('扫描的托盘不属于当前废弃订单')
|
if (task.discardNo !== order.discardNo) throw new Error('扫描的托盘不属于当前废弃订单')
|
||||||
this.discardTask = normalizeCargoDiscardTask(task)
|
this.discardTask = normalizeCargoDiscardTask(task)
|
||||||
if (this.discardTask.scanMode === 'CARTON' && this.discardTask.cartonMarks.includes(code)) this.discardScannedMarks = [code]
|
if (this.discardTask.scanMode === 'CARTON' && this.discardTask.cartonMarks.includes(code)) this.discardScannedMarks = [code]
|
||||||
this.discardActuals = Object.fromEntries(this.discardTask.skus.map((sku) => [sku.itemId, sku.actualPackages == null ? (sku.actualQuantityEditable ? '' : sku.plannedPackages) : sku.actualPackages])); await this.restoreDiscardPhotos()
|
this.discardActuals = Object.fromEntries(this.discardTask.skus.map((sku) => [sku.itemId, sku.actualPackages == null ? (sku.actualQuantityEditable ? 0 : sku.plannedPackages) : sku.actualPackages])); await this.restoreDiscardPhotos()
|
||||||
} catch (error) { if (this.discardCurrent === order) this.setError(error, '废弃托盘读取失败') }
|
} catch (error) { if (this.discardCurrent === order) this.setError(error, '废弃托盘读取失败') }
|
||||||
finally { if (this.discardCurrent === order) this.discardLoading = false }
|
finally { if (this.discardCurrent === order) this.discardLoading = false }
|
||||||
},
|
},
|
||||||
|
/** 重扫与重试先读取当前权威任务,只保留仍匹配的箱号和实点数;照片仍归当前托盘。 */
|
||||||
|
async refreshDiscardTask() {
|
||||||
|
if (!this.discardCurrent || !this.discardTask || this.discardLoading || this.discardPhotoBusy) return false
|
||||||
|
const order = this.discardCurrent; const previous = this.discardTask
|
||||||
|
this.discardLoading = true
|
||||||
|
try {
|
||||||
|
const latest = await resolveCargoDiscardPallet(previous.palletNo, order.discardNo)
|
||||||
|
if (this.discardCurrent !== order || this.discardTask !== previous) return false
|
||||||
|
if (latest.discardNo !== order.discardNo || latest.palletNo !== previous.palletNo) throw new Error('刷新结果不属于当前废弃托盘')
|
||||||
|
this.discardTask = normalizeCargoDiscardTask(latest)
|
||||||
|
this.discardScannedMarks = this.discardScannedMarks.filter(mark => (this.discardTask.cartonMarks || []).includes(mark))
|
||||||
|
this.discardUnboundScans = this.discardUnboundScans.filter(scan => this.discardTask.skus.some(sku => sku.actualQuantityEditable && sku.itemId === scan.itemId))
|
||||||
|
this.discardActuals = Object.fromEntries(this.discardTask.skus.map(sku => {
|
||||||
|
const old = previous.skus.find(line => line.itemId === sku.itemId)
|
||||||
|
const unchanged = old && old.plannedPackages === sku.plannedPackages && old.actualQuantityEditable === sku.actualQuantityEditable
|
||||||
|
return [sku.itemId, sku.actualPackages ?? (unchanged ? this.discardActuals[sku.itemId] : sku.actualQuantityEditable ? 0 : sku.plannedPackages)]
|
||||||
|
}))
|
||||||
|
this.discardPalletCode = latest.palletNo; this.discardNeedsRefresh = false; this.error = null
|
||||||
|
if (!this.discardPhotoReady) await this.restoreDiscardPhotos()
|
||||||
|
if (latest.status === 'COMPLETED') this.successMessage = '当前托盘已完成废弃,请返回列表核对本单剩余任务'
|
||||||
|
return true
|
||||||
|
} catch (error) { if (this.discardCurrent === order) { this.discardNeedsRefresh = true; this.setError(error, '废弃任务刷新失败,请重新扫描') }; return false }
|
||||||
|
finally { if (this.discardCurrent === order) this.discardLoading = false }
|
||||||
|
},
|
||||||
resetDiscardPhotos() { this.discardPhotos = []; this.discardPhotoUrls = {}; this.discardPhotoErrors = {}; this.discardPhotoReady = false },
|
resetDiscardPhotos() { this.discardPhotos = []; this.discardPhotoUrls = {}; this.discardPhotoErrors = {}; this.discardPhotoReady = false },
|
||||||
async restoreDiscardPhotos() {
|
async restoreDiscardPhotos() {
|
||||||
if (!this.discardTask || !this.discardCurrent) return
|
if (!this.discardTask || !this.discardCurrent) return
|
||||||
@@ -193,9 +273,31 @@
|
|||||||
}, fail: () => { this.discardPhotoBusy = false } })
|
}, fail: () => { this.discardPhotoBusy = false } })
|
||||||
},
|
},
|
||||||
// 未填写、负数、小数或超出预留数均阻止提交,保留输入供仓库修正。
|
// 未填写、负数、小数或超出预留数均阻止提交,保留输入供仓库修正。
|
||||||
validDiscardActual(sku) { const raw = this.discardActuals[sku.itemId]; if (raw == null || String(raw).trim() === '') return false; const actual = Number(raw); return Number.isSafeInteger(actual) && actual >= 0 && (sku.actualQuantityEditable === true ? actual <= sku.plannedPackages : actual === sku.plannedPackages) },
|
validDiscardActual(sku) { const raw = this.discardActuals[sku.itemId]; if (raw == null || String(raw).trim() === '') return false; if (sku.actualQuantityEditable && Number(raw) !== this.discardUnboundScans.filter(scan => scan.itemId === sku.itemId).length) return false; const actual = Number(raw); return Number.isSafeInteger(actual) && actual >= 0 && (sku.actualQuantityEditable === true ? actual <= sku.plannedPackages : actual === sku.plannedPackages) },
|
||||||
updateDiscardActual(itemId, event) { this.discardActuals = { ...this.discardActuals, [itemId]: event.detail.value } },
|
updateDiscardActual(itemId, event) { this.discardActuals = { ...this.discardActuals, [itemId]: event.detail.value } },
|
||||||
async confirmDiscard() { if (this.discardBusy || this.discardLoading || !this.canConfirmDiscard || !this.discardTask || !this.discardCurrent) return; this.error = null; this.successMessage = ''; this.discardBusy = true; try { const lines = this.discardTask.skus.map((sku) => ({ itemId: sku.itemId, actualPackages: Number(this.discardActuals[sku.itemId]) })); const photoFileIds = this.discardPhotos.map(photo => String(photo.fileId)); const key = fulfillmentIdempotencyKey('outbound', 'DISCARD', { discardNo: this.discardCurrent.discardNo, palletNo: this.discardTask.palletNo, lines, photoFileIds, scannedCartonMarks: this.discardScannedMarks, confirmAllCartons: this.discardAllCartons }); await confirmCargoDiscard(this.discardCurrent.discardNo, this.discardTask.palletNo, lines, photoFileIds, this.discardScannedMarks, this.discardAllCartons, { idempotencyKey: key.value }); this.resetDiscardPhotos(); this.discardPalletCode = ''; this.discardTask = null; this.discardTasks = await listCargoDiscardTasks(); this.successMessage = '废弃出库作业及照片已提交,已完成'; playSuccessSound() } catch (error) { this.setError(error, '废弃出库失败') } finally { this.discardBusy = false } },
|
/** 单托完成后刷新同单待办,仍有托盘时明确提示继续扫码。 */
|
||||||
|
async confirmDiscard() {
|
||||||
|
if (this.discardBusy || this.discardLoading || !this.canConfirmDiscard || !this.discardTask || !this.discardCurrent) return
|
||||||
|
this.error = null; this.successMessage = ''; this.discardBusy = true
|
||||||
|
const palletNo = this.discardTask.palletNo; const discardNo = this.discardCurrent.discardNo
|
||||||
|
try {
|
||||||
|
if (this.discardNeedsRefresh) {
|
||||||
|
if (!await this.refreshDiscardTask() || this.discardTask.status === 'COMPLETED' || !this.discardPhotoReady || !this.discardTask.skus.every(sku => this.validDiscardActual(sku)) || (this.discardTask.scanMode === 'CARTON' && !this.discardTask.cartonMarks.every(mark => this.discardScannedMarks.includes(mark)))) return
|
||||||
|
}
|
||||||
|
const lines = this.discardTask.skus.map(sku => ({ itemId: sku.itemId, actualPackages: Number(this.discardActuals[sku.itemId]), ...(sku.actualQuantityEditable ? { scannedCartonMarks: this.discardUnboundScans.filter(scan => scan.itemId === sku.itemId).map(scan => scan.mark) } : {}) }))
|
||||||
|
const photoFileIds = this.discardPhotos.map(photo => String(photo.fileId))
|
||||||
|
const key = fulfillmentIdempotencyKey('outbound', 'DISCARD', { discardNo, palletNo, lines, photoFileIds, scannedCartonMarks: this.discardScannedMarks, confirmAllCartons: false })
|
||||||
|
await confirmCargoDiscard(discardNo, palletNo, lines, photoFileIds, this.discardScannedMarks, false, { idempotencyKey: key.value })
|
||||||
|
this.resetDiscardPhotos(); this.discardPalletCode = ''; this.discardTask = null; this.discardScannedMarks = []; this.discardCartonCode = ''
|
||||||
|
// 确认已成功时,刷新失败不能被显示为出库失败或宣告整单完成。
|
||||||
|
try { this.discardTasks = await listCargoDiscardTasks() }
|
||||||
|
catch (error) { this.discardTasks = this.discardTasks.filter(task => task.discardNo !== discardNo || task.palletNo !== palletNo); this.successMessage = '托盘 ' + palletNo + ' 废弃已完成,其他托盘待办刷新失败,请返回列表重新加载'; this.setError(error, '待办刷新失败'); return }
|
||||||
|
const remaining = this.discardPendingTasks.map(task => task.palletNo)
|
||||||
|
this.successMessage = remaining.length ? '托盘 ' + palletNo + ' 废弃已完成,本单还有 ' + remaining.length + ' 个待废弃任务:' + remaining.join('、') + ',请继续扫描对应托盘' : '本单全部废弃出库作业及照片已提交,已完成'
|
||||||
|
playSuccessSound()
|
||||||
|
} catch (error) { this.discardNeedsRefresh = true; this.setError(error, '废弃出库失败') }
|
||||||
|
finally { this.discardBusy = false }
|
||||||
|
},
|
||||||
handleScan(raw) {
|
handleScan(raw) {
|
||||||
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim(); if (!code || this.busy || this.loading || this.discardBusy || this.discardLoading) return
|
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim(); if (!code || this.busy || this.loading || this.discardBusy || this.discardLoading) return
|
||||||
console.info('[Overseas PDA outbound] scan', { code, task: this.current?.taskNo || '', discard: this.discardCurrent?.discardNo || '' }); this.scanCode = code; updateFulfillmentDraft('outbound', { scanCode: code })
|
console.info('[Overseas PDA outbound] scan', { code, task: this.current?.taskNo || '', discard: this.discardCurrent?.discardNo || '' }); this.scanCode = code; updateFulfillmentDraft('outbound', { scanCode: code })
|
||||||
@@ -226,7 +328,7 @@
|
|||||||
const key = fulfillmentIdempotencyKey('outbound', 'complete', { taskNo: this.current.taskNo })
|
const key = fulfillmentIdempotencyKey('outbound', 'complete', { taskNo: this.current.taskNo })
|
||||||
await this.run('完成出库任务', async () => { await completeOutboundTask(this.current.taskNo, { idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); this.successMessage = '出库任务已完成'; playSuccessSound() })
|
await this.run('完成出库任务', async () => { await completeOutboundTask(this.current.taskNo, { idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); this.successMessage = '出库任务已完成'; playSuccessSound() })
|
||||||
},
|
},
|
||||||
async retry() { if (this.discardCurrent) { if (this.discardPalletCode) await this.resolveDiscardPallet(this.discardPalletCode); return } if (this.current?.taskNo) await this.openTask(this.current.taskNo); else if (fulfillment.outbound.currentTaskNo) await this.openTask(fulfillment.outbound.currentTaskNo); else if (this.scanCode) await this.openScannedTask(this.scanCode); else await this.loadTasks() },
|
async retry() { if (this.discardCurrent) { if (this.discardTask) await this.refreshDiscardTask(); else if (this.discardPalletCode) await this.resolveDiscardPallet(this.discardPalletCode); return } if (this.current?.taskNo) await this.openTask(this.current.taskNo); else if (fulfillment.outbound.currentTaskNo) await this.openTask(fulfillment.outbound.currentTaskNo); else if (this.scanCode) await this.openScannedTask(this.scanCode); else await this.loadTasks() },
|
||||||
async run(action, work, loading = false) { if (this.busy || this.loading) return; this.error = null; this.successMessage = ''; if (loading) this.loading = true; else { this.busy = true; this.busyAction = action.includes('完成') ? 'complete' : 'pick' } try { await work() } catch (error) { this.setError(error, `${action}失败`) } finally { this.loading = false; this.busy = false; this.busyAction = '' } },
|
async run(action, work, loading = false) { if (this.busy || this.loading) return; this.error = null; this.successMessage = ''; if (loading) this.loading = true; else { this.busy = true; this.busyAction = action.includes('完成') ? 'complete' : 'pick' } try { await work() } catch (error) { this.setError(error, `${action}失败`) } finally { this.loading = false; this.busy = false; this.busyAction = '' } },
|
||||||
setError(error, fallback) { const forbidden = ['403', 'A0403'].includes(String(error?.code || '')); this.error = { message: forbidden ? '当前账号没有海外仓 PDA 作业权限,请联系管理员授权' : error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId, network: Boolean(error?.network) }; console.error('[Overseas PDA outbound] failed', this.error); playErrorSound() },
|
setError(error, fallback) { const forbidden = ['403', 'A0403'].includes(String(error?.code || '')); this.error = { message: forbidden ? '当前账号没有海外仓 PDA 作业权限,请联系管理员授权' : error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId, network: Boolean(error?.network) }; console.error('[Overseas PDA outbound] failed', this.error); playErrorSound() },
|
||||||
lineBusinessCode(position) { return position?.cartonMark || position?.palletNo || position?.skuCode || position?.inboundCode || '业务码缺失' },
|
lineBusinessCode(position) { return position?.cartonMark || position?.palletNo || position?.skuCode || position?.inboundCode || '业务码缺失' },
|
||||||
@@ -239,6 +341,13 @@
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@import '../dispatch/fulfillment.scss';
|
@import '../dispatch/fulfillment.scss';
|
||||||
|
.discard-carton-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 40px; cursor: pointer; }
|
||||||
|
.discard-carton-heading > .business-code { flex-shrink: 0; white-space: nowrap; }
|
||||||
|
.discard-carton-heading-end { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.discard-carton-heading-end .scan-help { margin: 0; }
|
||||||
|
.discard-carton-chevron { flex-shrink: 0; width: 8px; height: 8px; margin-right: 3px; border-right: 2px solid #718095; border-bottom: 2px solid #718095; transform: rotate(45deg); }
|
||||||
|
.discard-carton-chevron.expanded { transform: rotate(225deg); }
|
||||||
|
.discard-scan-remove { margin-left: auto; padding: 4px 10px; border: 0; border-radius: 4px; background: #fef2f2; color: #dc2626; font-size: 12px; }
|
||||||
.discard-photo-section{margin:14px 0;padding-top:12px;border-top:1px solid #d9e3ee}.discard-photo-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin:12px 0}.discard-photo-tile image{display:block;width:100%;height:120px;border-radius:8px}
|
.discard-photo-section{margin:14px 0;padding-top:12px;border-top:1px solid #d9e3ee}.discard-photo-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin:12px 0}.discard-photo-tile image{display:block;width:100%;height:120px;border-radius:8px}
|
||||||
.outbound-line { margin-top: 9px; padding: 10px; border: 1px solid #e0e7ef; border-radius: 8px; background: #f8fafc; }
|
.outbound-line { margin-top: 9px; padding: 10px; border: 1px solid #e0e7ef; border-radius: 8px; background: #f8fafc; }
|
||||||
.outbound-line.selected { border: 2px solid #135ee8; background: #edf5ff; }
|
.outbound-line.selected { border: 2px solid #135ee8; background: #edf5ff; }
|
||||||
@@ -246,4 +355,10 @@
|
|||||||
.line-code { color: #263b55; font-size: 12px; font-weight: 800; overflow-wrap: anywhere; }
|
.line-code { color: #263b55; font-size: 12px; font-weight: 800; overflow-wrap: anywhere; }
|
||||||
.line-meta { margin-top: 4px; color: #718095; font-size: 10px; }
|
.line-meta { margin-top: 4px; color: #718095; font-size: 10px; }
|
||||||
.missing-scan-contract { display: block; margin-top: 9px; padding: 9px; border-radius: 7px; color: #a56800; font-size: 10px; line-height: 16px; background: #fff6df; }
|
.missing-scan-contract { display: block; margin-top: 9px; padding: 9px; border-radius: 7px; color: #a56800; font-size: 10px; line-height: 16px; background: #fff6df; }
|
||||||
|
.discard-remark .card-title, .discard-remark .task-meta { color: #dc2626; }
|
||||||
|
.discard-scanned-count { display: block; margin-bottom: 8px; color: #dc2626; font-size: 15px; font-weight: 700; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@import '../../styles/task-list.scss';
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<view v-if="user" class="page-shell portal-page work-page">
|
<view v-if="user" class="page-shell portal-page work-page">
|
||||||
<PortalHeader :title="title" :user="user" />
|
<PortalHeader :title="title" :user="user" />
|
||||||
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
|
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content" :class="{ 'pda-task-list': !showCurrent && stage !== 'review' }">
|
||||||
<view v-if="showCurrent" class="detail-toolbar"><button :disabled="busy" @tap="closeCurrent">‹ 返回列表</button></view>
|
<view v-if="showCurrent" class="detail-toolbar"><button :disabled="busy" @tap="closeCurrent">‹ 返回列表</button></view>
|
||||||
|
|
||||||
<template v-if="!showCurrent">
|
<template v-if="!showCurrent">
|
||||||
<view class="row-head candidate-toolbar"><text class="card-title">{{ candidateHeading }}</text><button v-if="stage !== 'review'" class="link-button" :disabled="loading" @tap="bootstrap">{{ loading ? '刷新中…' : '刷新' }}</button></view>
|
<view class="row-head candidate-toolbar" :class="{ 'pda-list-toolbar': stage !== 'review' }"><text class="card-title">{{ candidateHeading }}<text v-if="stage !== 'review' && !error"> · {{ visibleCandidates.length }} 单</text></text><button v-if="stage !== 'review'" class="link-button" :disabled="loading" @tap="bootstrap"><uni-icons type="refresh" color="#135ee8" size="18" /><text>{{ loading ? '刷新中…' : '刷新' }}</text></button></view>
|
||||||
<view v-if="loading && !visibleCandidates.length" class="empty-box"><uni-load-more status="loading" /><text class="empty-copy">正在加载换标任务…</text></view>
|
<view v-if="loading && !visibleCandidates.length" class="empty-box"><uni-load-more status="loading" /><text class="empty-copy">正在加载换标任务…</text></view>
|
||||||
<view v-else-if="!visibleCandidates.length" class="empty-box"><uni-icons type="info" color="#8090a3" size="32" /><text class="empty-copy">{{ emptyText }}</text></view>
|
<view v-else-if="!visibleCandidates.length" class="empty-box"><uni-icons type="info" color="#8090a3" size="32" /><text class="empty-copy">{{ emptyText }}</text></view>
|
||||||
<view v-else-if="stage !== 'review'" class="task-list">
|
<view v-else-if="stage !== 'review'" class="task-list pda-list-cards">
|
||||||
<view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card relabel-candidate" :class="{ current: current?.relabelNo === item.relabelNo, 'rework-card': item.reviewDecision === 'REWORK' }" @tap="openExecution(item)">
|
<view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card relabel-candidate pda-task-card" :class="{ current: current?.relabelNo === item.relabelNo, 'rework-card': item.reviewDecision === 'REWORK' }" hover-class="pda-task-card-pressed" @tap="openExecution(item)">
|
||||||
<view class="row-head"><text class="business-code">{{ item.relabelNo }}</text><text class="status-pill">{{ statusLabel(item.taskStatus || item.applicationStatus) }}</text></view>
|
<view class="pda-task-head"><text class="pda-task-no">{{ item.relabelNo }}</text><text class="status-pill pda-task-status">{{ statusLabel(item.taskStatus || item.applicationStatus) }}</text></view>
|
||||||
<view class="candidate-kind"><text>{{ relabelTypeName(item.operationType) }}</text><text v-if="item.reviewDecision === 'REWORK'" class="rework-tag">退回重做</text></view>
|
<view class="candidate-kind pda-task-flags"><text>{{ relabelTypeName(item.operationType) }}</text><text v-if="item.reviewDecision === 'REWORK'" class="rework-tag">退回重做</text></view>
|
||||||
<view class="candidate-footer"><text class="candidate-progress">已换标 <text class="candidate-progress-value">{{ item.relabelledPackages }} / {{ item.expectedPackages }}</text></text><text v-if="item.skippedPackages + item.exceptionPackages > 0" class="candidate-warning">跳过/异常 {{ item.skippedPackages + item.exceptionPackages }}</text><text class="candidate-enter">进入 ›</text></view>
|
<view class="pda-task-metrics" :class="{ 'has-three': item.skippedPackages + item.exceptionPackages > 0 }"><view><strong>{{ item.expectedPackages }}</strong><text>应处理</text></view><view><strong>{{ item.relabelledPackages }}</strong><text>已换标</text></view><view v-if="item.skippedPackages + item.exceptionPackages > 0" class="pda-task-metric-warning"><strong>{{ item.skippedPackages + item.exceptionPackages }}</strong><text>跳过/异常</text></view></view>
|
||||||
|
<view class="pda-task-footer"><view class="pda-task-action"><text>进入</text><uni-icons type="right" color="#135ee8" size="14" /></view></view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-else class="task-list">
|
<view v-else class="task-list">
|
||||||
@@ -391,3 +392,7 @@
|
|||||||
.summary-metrics{display:flex;align-items:center;justify-content:flex-end;flex-wrap:wrap;gap:4px;margin-left:auto;font-size:9px;line-height:18px}.summary-metric{display:inline-block;padding:1px 4px;border-radius:4px;background:#dcfce7;color:#16a34a;font-weight:700;white-space:nowrap}
|
.summary-metrics{display:flex;align-items:center;justify-content:flex-end;flex-wrap:wrap;gap:4px;margin-left:auto;font-size:9px;line-height:18px}.summary-metric{display:inline-block;padding:1px 4px;border-radius:4px;background:#dcfce7;color:#16a34a;font-weight:700;white-space:nowrap}
|
||||||
.candidate-toolbar{align-items:center;margin-bottom:12px}.candidate-toolbar .card-title{margin-bottom:0;font-size:16px;line-height:24px}.candidate-toolbar .link-button{display:flex;align-items:center;justify-content:center;box-sizing:border-box;min-width:88px;height:44px;min-height:44px;margin:0;padding:0 16px;border:1px solid #9dc0f4;border-radius:8px;color:#135ee8;font-size:16px;font-weight:700;line-height:normal;background:#eaf2ff}.candidate-toolbar .link-button::after{border:0}.candidate-toolbar .link-button[disabled]{color:#718095;border-color:#dce5f0;background:#e8edf3}
|
.candidate-toolbar{align-items:center;margin-bottom:12px}.candidate-toolbar .card-title{margin-bottom:0;font-size:16px;line-height:24px}.candidate-toolbar .link-button{display:flex;align-items:center;justify-content:center;box-sizing:border-box;min-width:88px;height:44px;min-height:44px;margin:0;padding:0 16px;border:1px solid #9dc0f4;border-radius:8px;color:#135ee8;font-size:16px;font-weight:700;line-height:normal;background:#eaf2ff}.candidate-toolbar .link-button::after{border:0}.candidate-toolbar .link-button[disabled]{color:#718095;border-color:#dce5f0;background:#e8edf3}
|
||||||
.relabel-candidate .business-code{min-width:0;font-size:18px;line-height:24px;overflow-wrap:anywhere}.relabel-candidate .status-pill{font-size:12px;line-height:18px}.candidate-kind{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:8px;color:#52647c;font-size:14px;line-height:22px}.candidate-footer{display:flex;align-items:center;flex-wrap:wrap;gap:6px 12px;margin-top:10px}.candidate-progress{color:#52647c;font-size:14px;line-height:24px}.candidate-progress-value{color:#135ee8;font-size:18px;font-weight:700}.candidate-warning{color:#b54708;font-size:12px;line-height:20px}.candidate-enter{margin-left:auto;color:#135ee8;font-size:14px;font-weight:600;line-height:24px}</style>
|
.relabel-candidate .business-code{min-width:0;font-size:18px;line-height:24px;overflow-wrap:anywhere}.relabel-candidate .status-pill{font-size:12px;line-height:18px}.candidate-kind{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:8px;color:#52647c;font-size:14px;line-height:22px}.candidate-footer{display:flex;align-items:center;flex-wrap:wrap;gap:6px 12px;margin-top:10px}.candidate-progress{color:#52647c;font-size:14px;line-height:24px}.candidate-progress-value{color:#135ee8;font-size:18px;font-weight:700}.candidate-warning{color:#b54708;font-size:12px;line-height:20px}.candidate-enter{margin-left:auto;color:#135ee8;font-size:14px;font-weight:600;line-height:24px}</style>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@import '../../styles/task-list.scss';
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<template v-else>
|
<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 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 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><CartonCountFields :box="box" :sku-codes="skuCodes" :disabled="busy || task.status !== 'COUNTING'" />
|
<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 volume-unit="m3" :box="box" :sku-codes="skuCodes" :disabled="busy || task.status !== 'COUNTING'" />
|
||||||
</view>
|
</view>
|
||||||
<view class="work-card"><button v-if="task.status === 'COUNTING'" class="primary" :disabled="busy || !ready" @tap="confirm">{{ busy ? '提交中…' : '确认全部箱内清点' }}</button><text v-else class="success">箱内清点已完成,可以继续换标或出库。</text></view>
|
<view class="work-card"><button v-if="task.status === 'COUNTING'" class="primary" :disabled="busy || !ready" @tap="confirm">{{ busy ? '提交中…' : '确认全部箱内清点' }}</button><text v-else class="success">箱内清点已完成,可以继续换标或出库。</text></view>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,89 @@
|
|||||||
|
.pda-task-list {
|
||||||
|
.pda-list-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 36px;
|
||||||
|
margin: 12px 0 11px;
|
||||||
|
color: #526174;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pda-list-toolbar > text { min-width: 0; margin: 0; color: inherit; font-size: inherit; font-weight: inherit; line-height: inherit; }
|
||||||
|
|
||||||
|
.pda-list-toolbar button {
|
||||||
|
display: flex;
|
||||||
|
box-sizing: border-box;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 5px;
|
||||||
|
width: auto;
|
||||||
|
min-width: 72px;
|
||||||
|
height: 36px;
|
||||||
|
min-height: 36px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid #b8d1f5;
|
||||||
|
border-radius: 20px;
|
||||||
|
color: #135ee8;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: normal;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pda-list-toolbar button[disabled] { opacity: .55; }
|
||||||
|
.pda-list-cards { display: flex; flex-direction: column; gap: 11px; }
|
||||||
|
|
||||||
|
.pda-task-card {
|
||||||
|
display: block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid #dce5f0;
|
||||||
|
border-radius: 13px;
|
||||||
|
color: #1f2937;
|
||||||
|
line-height: normal;
|
||||||
|
text-align: left;
|
||||||
|
white-space: normal;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pda-task-card-pressed { border-color: #93baf0; background: #f4f8ff; }
|
||||||
|
.pda-task-card.current { border-color: #8eb8f5; background: #f7faff; }
|
||||||
|
.pda-task-card.rework-card { border-color: #f29b38; background: #fffaf2; box-shadow: none; }
|
||||||
|
.pda-task-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||||
|
|
||||||
|
.pda-task-no {
|
||||||
|
display: block;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 750;
|
||||||
|
line-height: 26px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pda-task-status { flex-shrink: 0; max-width: 45%; padding: 6px 10px; border-radius: 9px; font-size: 13px; line-height: 16px; white-space: normal; overflow-wrap: anywhere; }
|
||||||
|
.pda-task-meta { display: block; margin-top: 8px; color: #718095; font-size: 12px; line-height: 20px; overflow-wrap: anywhere; }
|
||||||
|
.pda-task-flags { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 8px; color: #526174; font-size: 12px; line-height: 20px; }
|
||||||
|
|
||||||
|
.pda-task-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: 12px; padding: 9px 0; border-top: 1px solid #e1e7f0; border-bottom: 1px solid #e1e7f0; }
|
||||||
|
.pda-task-metrics.has-three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
.pda-task-metrics > view { min-width: 0; padding: 0 6px; border-right: 1px solid #e1e7f0; text-align: center; }
|
||||||
|
.pda-task-metrics > view:last-child { border-right: 0; }
|
||||||
|
.pda-task-metrics strong { display: block; color: #1f2937; font-size: 22px; font-weight: 750; line-height: 26px; overflow-wrap: anywhere; }
|
||||||
|
.pda-task-metrics text { display: block; margin-top: 3px; color: #6f8194; font-size: 12px; line-height: 18px; }
|
||||||
|
.pda-task-metric-warning strong { color: #a35b0c; }
|
||||||
|
.pda-task-footer { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 8px; min-height: 28px; margin-top: 10px; }
|
||||||
|
.pda-task-action { display: flex; align-items: center; gap: 2px; margin-left: auto; color: #135ee8; font-size: 13px; font-weight: 700; line-height: 22px; white-space: nowrap; }
|
||||||
|
.pda-task-footer .recent-badge { font-size: 11px; line-height: 16px; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { confirmCargoDiscard } from '../src/api/fulfillment.js'
|
||||||
|
import { session } from '../src/stores/session.js'
|
||||||
|
|
||||||
|
const rentPath = '/api/pda/v1/last-leg/storage-rent/preparations'
|
||||||
|
const stockPath = '/api/pda/v1/last-leg/cargo-discards/CD%2F1/pallets/P%2F1/confirm'
|
||||||
|
const body = { lines: [{ itemId: '9007199254740993', actualPackages: 2 }], photoFileIds: ['101'], scannedCartonMarks: ['BOX-1', 'BOX-2'], confirmAllCartons: false, idempotencyKey: 'discard-key' }
|
||||||
|
const ready = { data: { preparationId: '9007199254740995', ready: true, status: 'READY' } }
|
||||||
|
const confirmed = { data: { discardNo: 'CD/1', palletNo: 'P/1', status: 'COMPLETED' } }
|
||||||
|
const pending = { data: { preparationId: '9007199254740995', ready: false, status: 'FREEZE_PENDING' } }
|
||||||
|
const submit = () => confirmCargoDiscard('CD/1', 'P/1', body.lines, body.photoFileIds, body.scannedCartonMarks, false, { idempotencyKey: body.idempotencyKey })
|
||||||
|
|
||||||
|
function transport(responses) {
|
||||||
|
const calls = []; session.apiBase = '/api'; session.token = 'overseas-token'
|
||||||
|
globalThis.uni = { request(options) {
|
||||||
|
calls.push({ method: options.method, url: options.url, data: options.data })
|
||||||
|
const response = responses.shift(); assert.ok(response, 'unexpected additional request')
|
||||||
|
if (response.network) return options.fail({ errMsg: 'network down' })
|
||||||
|
options.success({ statusCode: 200, data: { code: '200', ...response }, header: {} })
|
||||||
|
} }
|
||||||
|
return calls
|
||||||
|
}
|
||||||
|
|
||||||
|
test('出库先预校验仓租,再用相同明细、照片、箱号和幂等键确认一次', async () => {
|
||||||
|
const calls = transport([ready, confirmed]); await submit()
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
{ method: 'POST', url: rentPath, data: { mode: 'DISCARD', businessNo: 'CD/1', palletNo: 'P/1', ...body } },
|
||||||
|
{ method: 'POST', url: stockPath, data: body }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('免费或不计仓租的服务端资格可以直接继续出库', async () => {
|
||||||
|
const calls = transport([{ data: { status: 'NOT_REQUIRED', ready: true, amount: 0 } }, confirmed])
|
||||||
|
await submit(); assert.equal(calls.length, 2); assert.equal(calls[1].url, stockPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('等待财务回执期间只读状态,就绪后只提交一次出库', async t => {
|
||||||
|
t.mock.method(globalThis, 'setTimeout', callback => { callback(); return 0 })
|
||||||
|
const calls = transport([pending, ready, confirmed]); await submit()
|
||||||
|
assert.deepEqual(calls.map(call => call.method), ['POST', 'GET', 'POST'])
|
||||||
|
assert.equal(calls[1].url, rentPath + '/9007199254740995')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('余额授信不足时显示财务原因,释放未使用预占且不发出库请求', async () => {
|
||||||
|
const calls = transport([{ data: { ...pending.data, status: 'FAILED', message: '可用余额和授信额度不足' } }, { data: {} }])
|
||||||
|
await assert.rejects(submit(), error => error.code === 'A0409' && /仓租校验未通过.*余额和授信额度不足/.test(error.message))
|
||||||
|
assert.deepEqual(calls.map(call => call.method), ['POST', 'DELETE'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('缺失、失败或断网的预校验不降级绕过,保留诊断信息', async () => {
|
||||||
|
for (const response of [{ code: 'A0404', message: 'missing', requestId: 'req', traceId: 'trace' }, { code: 'A0409', message: 'conflict' }, { network: true }]) {
|
||||||
|
const calls = transport([response])
|
||||||
|
await assert.rejects(submit(), error => response.network ? error.network : error.code === response.code && error.requestId === response.requestId && error.traceId === response.traceId)
|
||||||
|
assert.equal(calls.length, 1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('回执超时释放预占,阻止出库并提示稍后重新提交', async t => {
|
||||||
|
t.mock.method(globalThis, 'setTimeout', callback => { callback(); return 0 })
|
||||||
|
const calls = transport([pending, ...Array(20).fill(pending), { data: {} }])
|
||||||
|
await assert.rejects(submit(), /仓租资金预校验尚未通过/)
|
||||||
|
assert.equal(calls.filter(call => call.method === 'GET').length, 20)
|
||||||
|
assert.equal(calls.at(-1).method, 'DELETE')
|
||||||
|
assert.ok(calls.every(call => call.url.includes('/storage-rent/')))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('状态查询失败仍释放原预占;释放失败不覆盖原始诊断', async t => {
|
||||||
|
t.mock.method(globalThis, 'setTimeout', callback => { callback(); return 0 })
|
||||||
|
const calls = transport([pending, { code: 'S0503', message: 'read failed', requestId: 'req', traceId: 'trace' }, { code: 'S0503', message: 'release failed' }])
|
||||||
|
await assert.rejects(submit(), error => error.message === 'read failed' && error.requestId === 'req' && error.traceId === 'trace')
|
||||||
|
assert.deepEqual(calls.map(call => call.method), ['POST', 'GET', 'DELETE'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('就绪标记必须为布尔真,异常响应不能放行出库', async () => {
|
||||||
|
const calls = transport([{ data: { ...ready.data, ready: 'true' } }, { data: {} }])
|
||||||
|
await assert.rejects(submit(), /预校验/); assert.equal(calls.at(-1).method, 'DELETE')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('出库请求已发送后,业务错误或结果不明不重发、不取消原预占', async () => {
|
||||||
|
for (const failure of [{ code: 'A0409', message: 'stock changed' }, { network: true }]) {
|
||||||
|
const calls = transport([ready, failure])
|
||||||
|
await assert.rejects(submit(), error => failure.network ? error.network : error.code === failure.code)
|
||||||
|
assert.equal(calls.length, 2); assert.ok(calls.every(call => call.method === 'POST'))
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -4,7 +4,7 @@ import { readFile } from 'node:fs/promises'
|
|||||||
import { loadingPhotoLabels, loadingPhotoLimit, loadingPhotoSlots, normalizeLoadingDocuments, getLoadingDocuments, bindLoadingPhoto, uploadLoadingPhoto } from '../src/api/dispatch-loading-documents.js'
|
import { loadingPhotoLabels, loadingPhotoLimit, loadingPhotoSlots, normalizeLoadingDocuments, getLoadingDocuments, bindLoadingPhoto, uploadLoadingPhoto } from '../src/api/dispatch-loading-documents.js'
|
||||||
import { session } from '../src/stores/session.js'
|
import { session } from '../src/stores/session.js'
|
||||||
const source = (await readFile(new URL('../src/components/DispatchLoadingDocuments.vue', import.meta.url), 'utf8')).match(/<script>([\s\S]*?)<\/script>/)[1]
|
const source = (await readFile(new URL('../src/components/DispatchLoadingDocuments.vue', import.meta.url), 'utf8')).match(/<script>([\s\S]*?)<\/script>/)[1]
|
||||||
const data = extra => ({ dispatchNo: 'PC/1', recordVersion: '0', dispatchRecordVersion: '3', stage: 'AFTER_LOADING', editable: true, complete: false, photos: [], ...extra })
|
const data = extra => ({ dispatchNo: 'PC/1', recordVersion: '0', dispatchRecordVersion: '3', reviewRound: '1', stage: 'AFTER_LOADING', editable: true, complete: false, photos: [], ...extra })
|
||||||
const photo = { slot: 'LOADING_SCENE', fileId: '1001', fileName: 'loading.png', contentType: 'image/png' }
|
const photo = { slot: 'LOADING_SCENE', fileId: '1001', fileName: 'loading.png', contentType: 'image/png' }
|
||||||
function component(overrides = {}) {
|
function component(overrides = {}) {
|
||||||
const values = new Map(), events = []
|
const values = new Map(), events = []
|
||||||
@@ -30,22 +30,64 @@ test('five existing slots carry their business names into upload, replace and pr
|
|||||||
assert.match(template, /:aria-label="`查看\$\{slotLabels\[slot\]\}大图`"/)
|
assert.match(template, /:aria-label="`查看\$\{slotLabels\[slot\]\}大图`"/)
|
||||||
assert.doesNotMatch(template, /装车照片 \{\{ index \+ 1 \}\}/)
|
assert.doesNotMatch(template, /装车照片 \{\{ index \+ 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 },
|
||||||
|
uploadLoadingPhoto: async () => { writes++; return photo }, bindLoadingPhoto: async () => { writes++; return latest } })
|
||||||
|
await state.load()
|
||||||
|
latest = data({ recordVersion: '26', stage: 'DELIVERING', complete: true, photos: loadingPhotoSlots.flatMap((slot, category) =>
|
||||||
|
Array.from({ length: 5 }, (_, index) => ({ ...photo, slot, fileId: `${category + 2}${index}` }))) })
|
||||||
|
await state.refresh()
|
||||||
|
assert.deepEqual(state.snapshot.photos, latest.photos)
|
||||||
|
assert.equal(state.snapshot.stage, 'DELIVERING'); assert.equal(state.completedCategories, 5)
|
||||||
|
assert.ok(!state.snapshot.photos.some(item => item.fileId === photo.fileId))
|
||||||
|
assert.equal(events.filter(event => event[0] === 'saved').length, 1)
|
||||||
|
fail = true; await state.refresh()
|
||||||
|
assert.deepEqual(state.snapshot.photos, latest.photos)
|
||||||
|
assert.equal(state.needsRefresh, true); assert.equal(state.busy, false)
|
||||||
|
assert.equal(events.filter(event => event[0] === 'saved').length, 1)
|
||||||
|
assert.equal(writes, 0)
|
||||||
|
})
|
||||||
|
|
||||||
test('PDA reads and binds through its own namespace without customer or warehouse input', async () => {
|
test('PDA reads and binds through its own namespace without customer or warehouse input', async () => {
|
||||||
const calls = []; session.apiBase = '/gateway-api'; session.token = 'test-token'
|
const calls = []; session.apiBase = '/gateway-api'; session.token = 'test-token'
|
||||||
globalThis.uni = { request(options) { calls.push(options); options.success({ statusCode: 200, data: { code: '200', data: data() } }) } }
|
globalThis.uni = { request(options) { calls.push(options); options.success({ statusCode: 200, data: { code: '200', data: data() } }) } }
|
||||||
await getLoadingDocuments('PC/1')
|
await getLoadingDocuments('PC/1')
|
||||||
const body = { recordVersion: '9007199254740993', idempotencyKey: 'stable-key', fileId: '1001' }
|
const body = { recordVersion: '9007199254740993', idempotencyKey: 'stable-key', uploadKey: 'original-upload-key', fileId: '1001' }
|
||||||
await bindLoadingPhoto('PC/1', 'LOADING_SCENE', body)
|
await bindLoadingPhoto('PC/1', 'LOADING_SCENE', body)
|
||||||
assert.match(calls[0].url, /\/pda\/v1\/last-leg\/dispatches\/PC%2F1\/loading-documents$/)
|
assert.match(calls[0].url, /\/pda\/v1\/last-leg\/dispatches\/PC%2F1\/loading-documents$/)
|
||||||
assert.equal(calls[1].method, 'PUT'); assert.deepEqual(calls[1].data, body)
|
assert.equal(calls[1].method, 'PUT'); assert.deepEqual(calls[1].data, body)
|
||||||
})
|
})
|
||||||
|
test('imported PDF license is opened as a document on demand, never as a photo', async () => {
|
||||||
|
const license = { ...photo, fileName: 'license.pdf', contentType: 'application/pdf' }
|
||||||
|
let downloads = 0, opened
|
||||||
|
const { state } = component({ getLoadingDocuments: async () => data({ photos: [license] }), readLoadingPhoto: async () => { downloads++; return 'private-license.pdf' } })
|
||||||
|
uni.openDocument = options => { opened = options; options.success() }
|
||||||
|
await state.load()
|
||||||
|
assert.equal(downloads, 0); assert.equal(state.completedCategories, 1)
|
||||||
|
await state.viewPdf(license)
|
||||||
|
assert.equal(downloads, 1); assert.equal(opened.filePath, 'private-license.pdf'); assert.equal(opened.fileType, 'pdf')
|
||||||
|
assert.equal(state.busy, false)
|
||||||
|
})
|
||||||
test('PDA upload preserves the original request key through the authenticated upload helper', async () => {
|
test('PDA upload preserves the original request key through the authenticated upload helper', async () => {
|
||||||
const calls = []; session.apiBase = '/gateway-api'; session.token = 'test-token'
|
const calls = []; session.apiBase = '/gateway-api'; session.token = 'test-token'
|
||||||
globalThis.uni = { uploadFile(options) { calls.push(options); options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: photo }) }) } }
|
globalThis.uni = { uploadFile(options) { calls.push(options); options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: photo }) }) } }
|
||||||
await uploadLoadingPhoto('PC/1', 'LOADING_SCENE', 'local-image', 'same-key')
|
await uploadLoadingPhoto('PC/1', 'LOADING_SCENE', 'local-image', 'same-key', '1')
|
||||||
assert.match(calls[0].url, /loading-documents\/LOADING_SCENE\/uploads\?idempotencyKey=same-key$/)
|
assert.match(calls[0].url, /loading-documents\/LOADING_SCENE\/uploads\?idempotencyKey=same-key&reviewRound=1$/)
|
||||||
assert.equal(calls[0].filePath, 'local-image')
|
assert.equal(calls[0].filePath, 'local-image')
|
||||||
})
|
})
|
||||||
|
test('re-review during photo upload stops old files instead of adopting the latest round', async () => {
|
||||||
|
let latest = data(), uploads = 0, binds = 0, originalRound
|
||||||
|
const { state, events } = component({ getLoadingDocuments: async () => latest,
|
||||||
|
uploadLoadingPhoto: async (_no, _slot, _path, _key, round) => {
|
||||||
|
uploads++; originalRound = round; latest = data({ recordVersion: '8', reviewRound: '2' }); return photo
|
||||||
|
}, bindLoadingPhoto: async () => { binds++; return latest } })
|
||||||
|
uni.chooseImage = async () => ({ tempFilePaths: ['old-photo', 'not-uploaded'] })
|
||||||
|
await state.load(); await state.choose(photo.slot)
|
||||||
|
assert.equal(originalRound, '1'); assert.equal(uploads, 1); assert.equal(binds, 0)
|
||||||
|
assert.match(state.error.message, /审核轮次已变化/); assert.equal(state.busy, false)
|
||||||
|
assert.equal(events.filter(event => event[0] === 'saved').length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
test('wrong dispatch, duplicate slots and invalid versions never become saved facts', () => {
|
test('wrong dispatch, duplicate slots and invalid versions never become saved facts', () => {
|
||||||
assert.throws(() => normalizeLoadingDocuments(data(), 'PC/2'), /不一致/)
|
assert.throws(() => normalizeLoadingDocuments(data(), 'PC/2'), /不一致/)
|
||||||
|
|||||||
@@ -43,6 +43,43 @@ function page(dependencies = {}) {
|
|||||||
}
|
}
|
||||||
const proof = (command, version = 1) => ({ dispatchNo: command.dispatchNo, action: command.action, idempotencyKey: command.body.idempotencyKey, recordVersion: version, stage: command.action === 'LOADING_COMPLETE' ? 'AFTER_LOADING' : 'LOADING', result: { confirmed: true } })
|
const proof = (command, version = 1) => ({ dispatchNo: command.dispatchNo, action: command.action, idempotencyKey: command.body.idempotencyKey, recordVersion: version, stage: command.action === 'LOADING_COMPLETE' ? 'AFTER_LOADING' : 'LOADING', result: { confirmed: true } })
|
||||||
|
|
||||||
|
test('pallet scan notice reports the current pickable quantity and closes after three seconds without submitting', t => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||||
|
storage(); let writes = 0, sounds = 0
|
||||||
|
const state = page({ pickDispatchItem: async () => { writes++ }, playSuccessSound: () => { sounds++ } })
|
||||||
|
state.current = task({ items: [item({ scheduledPackages: 12, pickedPackages: 4, positions: [{ positionId: 'position-1', availablePackages: 6, allocatedPackages: 0, pickedPackages: 0 }] })] })
|
||||||
|
state.handleScan('PALLET-1')
|
||||||
|
assert.deepEqual(state.scanNotice, { palletNo: 'PALLET-1', packages: 6 })
|
||||||
|
assert.equal(sounds, 1)
|
||||||
|
t.mock.timers.tick(2999); assert.ok(state.scanNotice)
|
||||||
|
t.mock.timers.tick(1); assert.equal(state.scanNotice, null)
|
||||||
|
assert.equal(writes, 0); assert.equal(state.packages, 6)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('consecutive pallet scans replace the notice and restart its full display time', t => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||||
|
storage(); const state = page()
|
||||||
|
state.current = task({ items: [item(), item({ dispatchItemId: 'item-2', sourceKey: 'source-2', palletNo: 'PALLET-2', scheduledPackages: 1 })] })
|
||||||
|
state.handleScan('PALLET-1'); t.mock.timers.tick(2000)
|
||||||
|
state.handleScan('PALLET-2')
|
||||||
|
assert.deepEqual(state.scanNotice, { palletNo: 'PALLET-2', packages: 1 })
|
||||||
|
t.mock.timers.tick(1000); assert.ok(state.scanNotice)
|
||||||
|
t.mock.timers.tick(2000); assert.equal(state.scanNotice, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('scan errors, carton scans and switching tabs cannot retain a stale pallet notice', t => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||||
|
storage(); const state = page()
|
||||||
|
state.handleScan('PALLET-1'); state.handleScan('UNKNOWN')
|
||||||
|
assert.equal(state.scanNotice, null); assert.ok(state.error)
|
||||||
|
state.handleScan('PALLET-1'); state.selectTab('photos')
|
||||||
|
assert.equal(state.scanNotice, null)
|
||||||
|
state.selectTab('picking'); state.handleScan('PALLET-1')
|
||||||
|
state.current = task({ items: [item({ returnCartons: [{ returnCartonId: 'rc-1', cartonMark: 'RC-1', status: 'OUTBOUND_PENDING', productQuantity: 5 }] })] })
|
||||||
|
state.handleScan('RC-1'); assert.equal(state.scanNotice, null)
|
||||||
|
t.mock.timers.tick(3000); assert.equal(state.scanNotice, null)
|
||||||
|
})
|
||||||
|
|
||||||
test('real picking requires the native gate, physical source and current stage; empty or merely counted tasks cannot complete loading', async () => {
|
test('real picking requires the native gate, physical source and current stage; empty or merely counted tasks cannot complete loading', async () => {
|
||||||
storage(); const state = page(); state.selectPallet(state.current.items[0]); assert.equal(state.canPick, true)
|
storage(); const state = page(); state.selectPallet(state.current.items[0]); assert.equal(state.canPick, true)
|
||||||
for (const allowed of [false, 'true', undefined]) { state.current.actions.PICK.allowed = allowed; assert.equal(state.canPick, false) }
|
for (const allowed of [false, 'true', undefined]) { state.current.actions.PICK.allowed = allowed; assert.equal(state.canPick, false) }
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ const modes = [
|
|||||||
{ name: 'dispatch', path: '/dispatches/DSP%2F1/items/9007199254740995/pick', body: dispatch, submit: () => pickDispatchItem('DSP/1', '9007199254740995', dispatch) },
|
{ name: 'dispatch', path: '/dispatches/DSP%2F1/items/9007199254740995/pick', body: dispatch, submit: () => pickDispatchItem('DSP/1', '9007199254740995', dispatch) },
|
||||||
{ name: 'outbound', path: '/outbound-tasks/TASK%2F1/pick', body: outbound, submit: () => pickOutboundTask('TASK/1', outbound) },
|
{ name: 'outbound', path: '/outbound-tasks/TASK%2F1/pick', body: outbound, submit: () => pickOutboundTask('TASK/1', outbound) },
|
||||||
{ name: 'pallet', path: '/outbound-tasks/TASK%2F1/pallets/DP%2F1/pick', body: pallet, submit: () => pickOutboundPallet('TASK/1', 'DP/1', pallet) },
|
{ name: 'pallet', path: '/outbound-tasks/TASK%2F1/pallets/DP%2F1/pick', body: pallet, submit: () => pickOutboundPallet('TASK/1', 'DP/1', pallet) },
|
||||||
{ name: 'discard', path: '/cargo-discards/CD%2F1/pallets/P%2F1/confirm', body: discard, submit: () => confirmCargoDiscard('CD/1', 'P/1', discard.lines, discard.photoFileIds, discard.scannedCartonMarks, false, { idempotencyKey: discard.idempotencyKey }) }
|
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const mode of modes) {
|
for (const mode of modes) {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ function page(overrides = {}) {
|
|||||||
const deps = { PortalHeader: {}, ScanTargetInput: {}, session: { currentUser: {} }, fulfillment: { outbound: { packages: 1, currentTaskNo: 'OLD-TASK' } },
|
const deps = { PortalHeader: {}, ScanTargetInput: {}, session: { currentUser: {} }, fulfillment: { outbound: { packages: 1, currentTaskNo: 'OLD-TASK' } },
|
||||||
updateFulfillmentDraft() {}, fulfillmentIdempotencyKey: () => ({ value: 'key' }),
|
updateFulfillmentDraft() {}, fulfillmentIdempotencyKey: () => ({ value: 'key' }),
|
||||||
playErrorSound() {}, playSuccessSound() {}, pickOutboundPallet: async () => calls.push('pick'), completeOutboundTask: async () => calls.push('complete'),
|
playErrorSound() {}, playSuccessSound() {}, pickOutboundPallet: async () => calls.push('pick'), completeOutboundTask: async () => calls.push('complete'),
|
||||||
listCargoDiscardPhotos: async () => [], getOutboundTask: async () => ({ taskNo: 'TASK', status: 'COMPLETED', items: [] }), normalizeCargoDiscardTask: value => value,
|
listCargoDiscardPhotos: async () => [], getOutboundTask: async () => ({ taskNo: 'TASK', status: 'COMPLETED', items: [] }), normalizeCargoDiscardTask: value => value, resolveCargoDiscardPallet: async code => vm.discardTasks.find(task => task.palletNo === code || task.cartonMarks?.includes(code)),
|
||||||
...overrides }
|
...overrides }
|
||||||
const definition = new Function(...Object.keys(deps), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(deps))
|
const definition = new Function(...Object.keys(deps), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(deps))
|
||||||
const vm = definition.data()
|
const vm = definition.data()
|
||||||
@@ -79,10 +79,13 @@ test('同单按箱废弃允许扫描当前托盘及其他托盘所选箱唛,
|
|||||||
await vm.resolveDiscardPallet('P-1')
|
await vm.resolveDiscardPallet('P-1')
|
||||||
assert.equal(vm.error, null)
|
assert.equal(vm.error, null)
|
||||||
await vm.resolveDiscardPallet('BOX-2')
|
await vm.resolveDiscardPallet('BOX-2')
|
||||||
|
assert.equal(vm.discardTask.palletNo, 'P-1')
|
||||||
|
await vm.resolveDiscardPallet('P-2')
|
||||||
|
await vm.resolveDiscardPallet('BOX-2')
|
||||||
assert.equal(vm.discardTask.palletNo, 'P-2')
|
assert.equal(vm.discardTask.palletNo, 'P-2')
|
||||||
assert.deepEqual(vm.discardScannedMarks, ['BOX-2'])
|
assert.deepEqual(vm.discardScannedMarks, ['BOX-2'])
|
||||||
await vm.resolveDiscardPallet('BOX-3')
|
await vm.resolveDiscardPallet('BOX-3')
|
||||||
assert.deepEqual(requests, ['P-1', 'BOX-2'])
|
assert.deepEqual(requests, ['P-1', 'P-2'])
|
||||||
assert.equal(vm.discardTask.palletNo, 'P-2')
|
assert.equal(vm.discardTask.palletNo, 'P-2')
|
||||||
assert.deepEqual(vm.discardScannedMarks, ['BOX-2'])
|
assert.deepEqual(vm.discardScannedMarks, ['BOX-2'])
|
||||||
})
|
})
|
||||||
@@ -182,7 +185,7 @@ test('discard confirmation is guarded against double submission', async () => {
|
|||||||
pending.resolve(); await Promise.all([first, second]); assert.equal(submits, 1)
|
pending.resolve(); await Promise.all([first, second]); assert.equal(submits, 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('无精确箱唛对应的托盘必须填写实际数并按实点提交,不能超量或用空值代替零', async () => {
|
test('无关联库存按现场箱唛去重计数,提交扫描清单而非手填数量', async () => {
|
||||||
let payload
|
let payload
|
||||||
const { vm } = page({ confirmCargoDiscard: async (...args) => { payload = args }, listCargoDiscardTasks: async () => [] })
|
const { vm } = page({ confirmCargoDiscard: async (...args) => { payload = args }, listCargoDiscardTasks: async () => [] })
|
||||||
vm.discardCurrent = { discardNo: 'CD-A' }
|
vm.discardCurrent = { discardNo: 'CD-A' }
|
||||||
@@ -192,20 +195,35 @@ test('无精确箱唛对应的托盘必须填写实际数并按实点提交,
|
|||||||
vm.discardActuals = { sku: value }; assert.equal(vm.canConfirmDiscard, false)
|
vm.discardActuals = { sku: value }; assert.equal(vm.canConfirmDiscard, false)
|
||||||
}
|
}
|
||||||
vm.discardActuals = { sku: '0' }; assert.equal(vm.canConfirmDiscard, true)
|
vm.discardActuals = { sku: '0' }; assert.equal(vm.canConfirmDiscard, true)
|
||||||
vm.discardActuals = { sku: '6' }; assert.equal(vm.canConfirmDiscard, true)
|
vm.discardActuals = { sku: '6' }; assert.equal(vm.canConfirmDiscard, false)
|
||||||
|
vm.discardActuals = { sku: 0 }; for (let i=0;i<6;i++) await vm.scanDiscardCarton('FIELD-'+i)
|
||||||
|
await vm.scanDiscardCarton('FIELD-0'); assert.equal(vm.discardActuals.sku, 6); assert.equal(vm.canConfirmDiscard, true)
|
||||||
await vm.confirmDiscard()
|
await vm.confirmDiscard()
|
||||||
assert.equal(payload[0], 'CD-A'); assert.equal(payload[1], 'P-A')
|
assert.equal(payload[0], 'CD-A'); assert.equal(payload[1], 'P-A')
|
||||||
assert.deepEqual(payload[2], [{ itemId: 'sku', actualPackages: 6 }])
|
assert.deepEqual(payload[2], [{ itemId: 'sku', actualPackages: 6, scannedCartonMarks: Array.from({ length: 6 }, (_, i) => 'FIELD-'+i) }])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('无箱唛对应托盘扫码后实际数保持未填,必须由仓库实点填写', async () => {
|
test('无关联托盘扫码后从零开始,等待扫描现场箱唛', async () => {
|
||||||
const { vm } = page({ resolveCargoDiscardPallet: async () => ({ discardNo: 'CD-A', palletNo: 'P-A', cartonMarks: [], scanMode: 'PALLET', skus: [{ itemId: 'sku', plannedPackages: 10, actualQuantityEditable: true }] }) })
|
const { vm } = page({ resolveCargoDiscardPallet: async () => ({ discardNo: 'CD-A', palletNo: 'P-A', cartonMarks: [], scanMode: 'PALLET', skus: [{ itemId: 'sku', plannedPackages: 10, actualQuantityEditable: true }] }) })
|
||||||
vm.discardCurrent = { discardNo: 'CD-A' }
|
vm.discardCurrent = { discardNo: 'CD-A' }
|
||||||
await vm.resolveDiscardPallet('P-A')
|
await vm.resolveDiscardPallet('P-A')
|
||||||
assert.equal(vm.discardActuals.sku, '')
|
assert.equal(vm.discardActuals.sku, 0)
|
||||||
assert.equal(vm.canConfirmDiscard, false)
|
assert.equal(vm.canConfirmDiscard, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('无关联任务先扫托盘,再扫现场箱唛自动计数', async () => {
|
||||||
|
const { vm } = page()
|
||||||
|
vm.discardTasks = [{ discardNo: 'CD-NO-BOX', palletNo: 'P-1', status: 'PENDING', scanMode: 'PALLET', cartonMarks: [], skus: [{ itemId: 'sku', plannedPackages: 2, actualQuantityEditable: true }] }]
|
||||||
|
vm.openDiscardOrder('CD-NO-BOX'); await Promise.resolve()
|
||||||
|
assert.equal(vm.discardTask, null)
|
||||||
|
await vm.resolveDiscardPallet('P-1')
|
||||||
|
assert.equal(vm.discardTask.palletNo, 'P-1')
|
||||||
|
assert.equal(vm.discardActuals.sku, 0)
|
||||||
|
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
|
||||||
|
await vm.scanDiscardCarton('FIELD-A'); await vm.scanDiscardCarton('FIELD-B')
|
||||||
|
assert.equal(vm.discardActuals.sku, 2); assert.equal(vm.canConfirmDiscard, true)
|
||||||
|
})
|
||||||
|
|
||||||
test('discard retry stays in the discard workflow instead of restoring an old outbound task', async () => {
|
test('discard retry stays in the discard workflow instead of restoring an old outbound task', async () => {
|
||||||
const { vm } = page(); const calls = []
|
const { vm } = page(); const calls = []
|
||||||
vm.discardCurrent = { discardNo: 'DISCARD' }; vm.discardPalletCode = 'PALLET'
|
vm.discardCurrent = { discardNo: 'DISCARD' }; vm.discardPalletCode = 'PALLET'
|
||||||
@@ -224,11 +242,12 @@ test('closing or switching discard order ignores the previous resolve response',
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
test('loose disposal opens all marks, blocks partial scans and accepts explicit bulk confirmation', async () => {
|
test('loose disposal opens all marks and requires every carton scan', async () => {
|
||||||
const { vm } = page()
|
const { vm } = page()
|
||||||
vm.discardTasks = [{ discardNo: 'CD-L', palletNo: '散箱', scanMode: 'CARTON', cartonMarks: ['BOX-1', 'BOX-2'], status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 2 }] }]
|
vm.discardTasks = [{ discardNo: 'CD-L', palletNo: '散箱', scanMode: 'CARTON', cartonMarks: ['BOX-1', 'BOX-2'], status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 2 }] }]
|
||||||
vm.openDiscardOrder('CD-L')
|
vm.openDiscardOrder('CD-L')
|
||||||
await Promise.resolve()
|
assert.equal(vm.discardTask, null)
|
||||||
|
await vm.resolveDiscardPallet('BOX-1')
|
||||||
assert.equal(vm.discardTask.scanMode, 'CARTON')
|
assert.equal(vm.discardTask.scanMode, 'CARTON')
|
||||||
assert.deepEqual(vm.discardTask.cartonMarks, ['BOX-1', 'BOX-2'])
|
assert.deepEqual(vm.discardTask.cartonMarks, ['BOX-1', 'BOX-2'])
|
||||||
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
|
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
|
||||||
@@ -240,7 +259,7 @@ test('loose disposal opens all marks, blocks partial scans and accepts explicit
|
|||||||
assert.equal(vm.canConfirmDiscard, true)
|
assert.equal(vm.canConfirmDiscard, true)
|
||||||
vm.discardActuals.sku = 1; assert.equal(vm.canConfirmDiscard, false)
|
vm.discardActuals.sku = 1; assert.equal(vm.canConfirmDiscard, false)
|
||||||
vm.discardActuals.sku = 2; vm.discardScannedMarks = []; vm.discardAllCartons = true
|
vm.discardActuals.sku = 2; vm.discardScannedMarks = []; vm.discardAllCartons = true
|
||||||
assert.equal(vm.canConfirmDiscard, true)
|
assert.equal(vm.canConfirmDiscard, false)
|
||||||
vm.discardPhotos = []; assert.equal(vm.canConfirmDiscard, false)
|
vm.discardPhotos = []; assert.equal(vm.canConfirmDiscard, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -250,6 +269,8 @@ test('partial bound pallet confirmation sends only selected marks and preserves
|
|||||||
const { vm } = page({ confirmCargoDiscard: async (...args) => { payload = args }, listCargoDiscardTasks: async () => [] })
|
const { vm } = page({ confirmCargoDiscard: async (...args) => { payload = args }, listCargoDiscardTasks: async () => [] })
|
||||||
vm.discardTasks = [{ discardNo: 'CD-P', outboundNo: 'OB-P', palletNo: 'P-1', scanMode: 'CARTON', cartonMarks: ['BOX-2'], status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }]
|
vm.discardTasks = [{ discardNo: 'CD-P', outboundNo: 'OB-P', palletNo: 'P-1', scanMode: 'CARTON', cartonMarks: ['BOX-2'], status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }]
|
||||||
vm.openDiscardOrder('CD-P'); await Promise.resolve()
|
vm.openDiscardOrder('CD-P'); await Promise.resolve()
|
||||||
|
assert.equal(vm.discardTask, null)
|
||||||
|
await vm.resolveDiscardPallet('P-1')
|
||||||
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
|
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
|
||||||
await vm.resolveDiscardPallet('BOX-2'); assert.equal(vm.canConfirmDiscard, true)
|
await vm.resolveDiscardPallet('BOX-2'); assert.equal(vm.canConfirmDiscard, true)
|
||||||
await vm.resolveDiscardPallet('BOX-OTHER'); assert.deepEqual(vm.discardScannedMarks, ['BOX-2']); assert.equal(vm.discardTask.palletNo, 'P-1')
|
await vm.resolveDiscardPallet('BOX-OTHER'); assert.deepEqual(vm.discardScannedMarks, ['BOX-2']); assert.equal(vm.discardTask.palletNo, 'P-1')
|
||||||
@@ -267,6 +288,7 @@ test('discard confirmation preserves inputs after stock command failure and star
|
|||||||
})
|
})
|
||||||
vm.discardCurrent = { discardNo: 'CD-RETRY' }
|
vm.discardCurrent = { discardNo: 'CD-RETRY' }
|
||||||
vm.discardTask = { palletNo: 'P-1', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }
|
vm.discardTask = { palletNo: 'P-1', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }
|
||||||
|
vm.discardTasks = [{ ...vm.discardTask, discardNo: 'CD-RETRY' }]
|
||||||
vm.discardActuals = { sku: 1 }; vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
|
vm.discardActuals = { sku: 1 }; vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
|
||||||
await vm.confirmDiscard()
|
await vm.confirmDiscard()
|
||||||
assert.equal(vm.discardBusy, false)
|
assert.equal(vm.discardBusy, false)
|
||||||
@@ -283,3 +305,159 @@ test('discard confirmation preserves inputs after stock command failure and star
|
|||||||
assert.equal(vm.error, null)
|
assert.equal(vm.error, null)
|
||||||
assert.match(vm.successMessage, /已完成/)
|
assert.match(vm.successMessage, /已完成/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('dedicated scanner validates carton scope and preserves photos', () => {
|
||||||
|
const { vm } = page()
|
||||||
|
vm.discardTask = { palletNo: 'P-1', scanMode: 'CARTON', cartonMarks: ['BX-1', 'BX-2'], status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 2 }] }
|
||||||
|
vm.discardActuals = { sku: 2 }; vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
|
||||||
|
assert.deepEqual(vm.discardPendingMarks, ['BX-1', 'BX-2'])
|
||||||
|
assert.deepEqual(vm.discardQuantityLines, [])
|
||||||
|
vm.discardCartonCode = ' BX-1 '; vm.scanDiscardCarton()
|
||||||
|
assert.equal(vm.discardCartonCode, '')
|
||||||
|
vm.scanDiscardCarton('BX-1'); vm.scanDiscardCarton('OTHER')
|
||||||
|
assert.deepEqual(vm.discardScannedMarks, ['BX-1']); assert.equal(vm.canConfirmDiscard, false)
|
||||||
|
assert.deepEqual(vm.discardPendingMarks, ['BX-2'])
|
||||||
|
assert.match(vm.error.message, /箱号不属于/)
|
||||||
|
vm.discardCartonsExpanded = false
|
||||||
|
vm.scanDiscardCarton('BX-2'); assert.equal(vm.canConfirmDiscard, true)
|
||||||
|
assert.deepEqual(vm.discardPendingMarks, [])
|
||||||
|
assert.equal(vm.discardCartonsExpanded, false)
|
||||||
|
assert.deepEqual(vm.discardPhotos, [{ fileId: '101' }]); assert.equal(vm.discardTask.palletNo, 'P-1')
|
||||||
|
vm.discardTask.status = 'COMPLETED'; vm.scanDiscardCarton('OTHER'); assert.equal(vm.error, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('bound discard opens only after its pallet scan and summary hides carton numbers', async () => {
|
||||||
|
const { vm } = page()
|
||||||
|
vm.discardTasks = [{ discardNo: 'CD-1', palletNo: 'P-1', status: 'PENDING', scanMode: 'CARTON', cartonMarks: ['BX-1'], skus: [{ itemId: 'sku', plannedPackages: 1 }] }]
|
||||||
|
vm.openDiscardOrder('CD-1'); assert.equal(vm.discardTask, null)
|
||||||
|
await vm.resolveDiscardPallet('BX-1'); assert.equal(vm.discardTask, null)
|
||||||
|
await vm.resolveDiscardPallet('OTHER'); assert.equal(vm.discardTask, null)
|
||||||
|
await vm.resolveDiscardPallet('P-1'); assert.equal(vm.discardTask.palletNo, 'P-1')
|
||||||
|
assert.deepEqual(vm.discardScannedMarks, [])
|
||||||
|
assert.deepEqual(vm.discardTask.cartonMarks, ['BX-1'])
|
||||||
|
const template = readFileSync(new URL('../src/pages/outbound/outbound.vue', import.meta.url), 'utf8')
|
||||||
|
const summary = template.slice(template.indexOf('废弃箱关联托盘'), template.indexOf('<ScanTargetInput name="cargo-discard"'))
|
||||||
|
assert.doesNotMatch(summary, /task.cartonMarks/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('multi-pallet discard prompts remaining pallet numbers and requires a new pallet scan', async () => {
|
||||||
|
const first = { discardNo: 'CD-M', palletNo: 'P-1', status: 'PENDING', scanMode: 'CARTON', cartonMarks: ['BX-1'], skus: [{ itemId: 'sku1', plannedPackages: 1 }] }
|
||||||
|
const second = { ...first, palletNo: 'P-2', cartonMarks: ['BX-2'], skus: [{ itemId: 'sku2', plannedPackages: 1 }] }
|
||||||
|
const other = { ...first, discardNo: 'CD-OTHER', palletNo: 'P-OTHER' }
|
||||||
|
let completed = 0
|
||||||
|
const { vm } = page({ confirmCargoDiscard: async () => { completed++ }, listCargoDiscardTasks: async () => [{ ...first, status: 'COMPLETED' }, { ...second, status: completed === 2 ? 'COMPLETED' : 'PENDING' }, other] })
|
||||||
|
vm.discardTasks = [first, second, other]; vm.openDiscardOrder('CD-M')
|
||||||
|
await vm.resolveDiscardPallet('P-1'); vm.scanDiscardCarton('BX-1')
|
||||||
|
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
|
||||||
|
await vm.confirmDiscard()
|
||||||
|
assert.equal(vm.discardTask, null); assert.equal(vm.discardPalletCode, '')
|
||||||
|
assert.deepEqual(vm.discardPendingTasks.map(task => task.palletNo), ['P-2'])
|
||||||
|
assert.match(vm.successMessage, /P-1.*已完成.*P-2.*继续扫描/)
|
||||||
|
assert.doesNotMatch(vm.successMessage, /P-OTHER|全部/)
|
||||||
|
assert.equal(vm.canConfirmDiscard, false)
|
||||||
|
await vm.resolveDiscardPallet('P-2'); assert.equal(vm.successMessage, '')
|
||||||
|
assert.deepEqual(vm.discardPendingMarks, ['BX-2']); assert.deepEqual(vm.discardPhotos, [])
|
||||||
|
vm.scanDiscardCarton('BX-2'); vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '102' }]
|
||||||
|
await vm.confirmDiscard()
|
||||||
|
assert.deepEqual(vm.discardPendingTasks, []); assert.match(vm.successMessage, /本单全部.*已完成/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('successful discard followed by refresh failure does not claim the whole order completed', async () => {
|
||||||
|
let confirms = 0
|
||||||
|
const { vm } = page({ confirmCargoDiscard: async () => { confirms++ }, listCargoDiscardTasks: async () => { throw new Error('offline') } })
|
||||||
|
const first = { discardNo: 'CD-M', palletNo: 'P-1', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }
|
||||||
|
vm.discardTasks = [first, { ...first, palletNo: 'P-2' }]; vm.openDiscardOrder('CD-M')
|
||||||
|
await vm.resolveDiscardPallet('P-1'); vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
|
||||||
|
await vm.confirmDiscard(); await vm.confirmDiscard()
|
||||||
|
assert.equal(confirms, 1); assert.equal(vm.discardTask, null)
|
||||||
|
assert.match(vm.successMessage, /P-1.*已完成.*刷新失败/); assert.doesNotMatch(vm.successMessage, /全部/)
|
||||||
|
assert.deepEqual(vm.discardPendingTasks.map(task => task.palletNo), ['P-2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('仓租余额不足显示原因并保留当前托盘、已扫箱号、实际数与现场照片', async () => {
|
||||||
|
const { vm } = page({ confirmCargoDiscard: async () => { throw Object.assign(new Error('仓租校验未通过:可用余额和授信额度不足'), { code: 'A0409' }) } })
|
||||||
|
vm.discardCurrent = { discardNo: 'CD-1' }
|
||||||
|
vm.discardTask = { palletNo: 'P-1', scanMode: 'CARTON', status: 'PENDING', cartonMarks: ['BX-1'], skus: [{ itemId: 'sku', plannedPackages: 1 }] }
|
||||||
|
vm.discardActuals = { sku: 1 }; vm.discardScannedMarks = ['BX-1']
|
||||||
|
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
|
||||||
|
await vm.confirmDiscard()
|
||||||
|
assert.match(vm.error.message, /余额和授信额度不足/); assert.equal(vm.error.code, 'A0409')
|
||||||
|
assert.equal(vm.discardTask.palletNo, 'P-1'); assert.deepEqual(vm.discardScannedMarks, ['BX-1'])
|
||||||
|
assert.deepEqual(vm.discardActuals, { sku: 1 }); assert.deepEqual(vm.discardPhotos, [{ fileId: '101' }])
|
||||||
|
assert.equal(vm.successMessage, ''); assert.equal(vm.discardBusy, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const action of ['pallet', 'carton', 'retry']) {
|
||||||
|
test(`出库资金不足后通过 ${action} 重新核对并再次手动提交`, async () => {
|
||||||
|
let submits = 0; let reads = 0; let key = 0; const keys = []
|
||||||
|
const task = { discardNo: 'CD-1', palletNo: 'P-1', status: 'PENDING', scanMode: 'CARTON', cartonMarks: ['BX-1'], skus: [{ itemId: 'sku', plannedPackages: 1 }] }
|
||||||
|
const { vm } = page({
|
||||||
|
resolveCargoDiscardPallet: async () => { reads++; return { ...task } },
|
||||||
|
confirmCargoDiscard: async (...args) => { keys.push(args.at(-1).idempotencyKey); if (++submits === 1) throw new Error('可用余额和授信额度不足') },
|
||||||
|
fulfillmentIdempotencyKey: () => ({ value: `key-${++key}` }), listCargoDiscardTasks: async () => []
|
||||||
|
})
|
||||||
|
vm.discardCurrent = { discardNo: 'CD-1' }; vm.discardTask = task; vm.discardTasks = [task]
|
||||||
|
vm.discardActuals = { sku: 1 }; vm.discardScannedMarks = ['BX-1']
|
||||||
|
vm.discardPhotos = [{ fileId: '101' }]; vm.discardPhotoReady = true
|
||||||
|
await vm.confirmDiscard(); assert.equal(vm.discardNeedsRefresh, true)
|
||||||
|
if (action === 'pallet') await vm.resolveDiscardPallet('P-1')
|
||||||
|
else if (action === 'carton') await vm.scanDiscardCarton('BX-1')
|
||||||
|
else await vm.retry()
|
||||||
|
assert.equal(reads, 1); assert.equal(submits, 1); assert.equal(vm.error, null)
|
||||||
|
assert.equal(vm.discardNeedsRefresh, false); assert.deepEqual(vm.discardScannedMarks, ['BX-1'])
|
||||||
|
assert.deepEqual(vm.discardActuals, { sku: 1 }); assert.deepEqual(vm.discardPhotos, [{ fileId: '101' }])
|
||||||
|
await vm.confirmDiscard(); assert.equal(submits, 2); assert.notEqual(keys[0], keys[1])
|
||||||
|
assert.match(vm.successMessage, /全部.*已完成/)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('结果不明后刷新发现已完成,不再重复出库', async () => {
|
||||||
|
let submits = 0
|
||||||
|
const task = { discardNo: 'CD-1', palletNo: 'P-1', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }
|
||||||
|
const { vm } = page({ confirmCargoDiscard: async () => { submits++; throw Object.assign(new Error('网络中断'), { network: true }) }, resolveCargoDiscardPallet: async () => ({ ...task, status: 'COMPLETED' }) })
|
||||||
|
vm.discardCurrent = { discardNo: 'CD-1' }; vm.discardTask = task
|
||||||
|
vm.discardActuals = { sku: 1 }; vm.discardPhotos = [{ fileId: '101' }]; vm.discardPhotoReady = true
|
||||||
|
await vm.confirmDiscard(); await vm.confirmDiscard()
|
||||||
|
assert.equal(submits, 1); assert.equal(vm.discardTask.status, 'COMPLETED'); assert.equal(vm.canConfirmDiscard, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('失败后刷新仍报错,保留草稿且不再发送出库', async () => {
|
||||||
|
let submits = 0
|
||||||
|
const { vm } = page({ confirmCargoDiscard: async () => { submits++; throw new Error('出库失败') }, resolveCargoDiscardPallet: async () => { throw new Error('任务读取失败') } })
|
||||||
|
vm.discardCurrent = { discardNo: 'CD-1' }; vm.discardTask = { palletNo: 'P-1', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }
|
||||||
|
vm.discardActuals = { sku: 1 }; vm.discardPhotos = [{ fileId: '101' }]; vm.discardPhotoReady = true
|
||||||
|
await vm.confirmDiscard(); await vm.confirmDiscard()
|
||||||
|
assert.equal(submits, 1); assert.match(vm.error.message, /任务读取失败/)
|
||||||
|
assert.equal(vm.discardNeedsRefresh, true); assert.equal(vm.discardBusy, false); assert.equal(vm.discardLoading, false)
|
||||||
|
assert.deepEqual(vm.discardPhotos, [{ fileId: '101' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('无关联扫码显示备注,不能扫托盘或超过目标,失败刷新后保留现场箱唛再提交', async () => {
|
||||||
|
let attempts = 0
|
||||||
|
const task = { discardNo: 'CD-FIELD', palletNo: 'PALLET-FIELD', status: 'PENDING', scanMode: 'PALLET', cartonMarks: [], remark: '只废弃破损外箱', remainingPackages: 2, skus: [{ itemId: 'sku', plannedPackages: 8, actualQuantityEditable: true }] }
|
||||||
|
const { vm } = page({ confirmCargoDiscard: async () => { if (++attempts === 1) throw new Error('余额不足') }, listCargoDiscardTasks: async () => [] })
|
||||||
|
vm.discardTasks = [task]; vm.openDiscardOrder(task.discardNo); await vm.resolveDiscardPallet(task.palletNo)
|
||||||
|
assert.equal(vm.discardOrderRemark, '只废弃破损外箱')
|
||||||
|
await vm.scanDiscardCarton(task.palletNo); assert.equal(vm.discardActuals.sku, 0)
|
||||||
|
await vm.scanDiscardCarton('FIELD-A'); await vm.scanDiscardCarton('FIELD-B'); await vm.scanDiscardCarton('FIELD-C')
|
||||||
|
assert.equal(vm.discardActuals.sku, 2); assert.deepEqual(vm.discardUnboundScans.map(scan => scan.mark), ['FIELD-A', 'FIELD-B'])
|
||||||
|
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
|
||||||
|
await vm.confirmDiscard(); assert.equal(vm.discardNeedsRefresh, true)
|
||||||
|
await vm.resolveDiscardPallet(task.palletNo)
|
||||||
|
assert.equal(vm.discardActuals.sku, 2); assert.equal(vm.discardUnboundScans.length, 2)
|
||||||
|
assert.equal(vm.discardPhotos.length, 1); await vm.confirmDiscard(); assert.equal(attempts, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('已扫描现场箱唛可移除并同步回减数量,确认只提交剩余箱唛', async () => {
|
||||||
|
let payload
|
||||||
|
const task = { discardNo: 'CD-REMOVE', palletNo: 'P-REMOVE', status: 'PENDING', scanMode: 'PALLET', cartonMarks: [], remainingPackages: 4, skus: [{ itemId: 'sku', plannedPackages: 8, actualQuantityEditable: true }] }
|
||||||
|
const { vm } = page({ confirmCargoDiscard: async (...args) => { payload = args }, listCargoDiscardTasks: async () => [] })
|
||||||
|
vm.discardTasks = [task]; vm.openDiscardOrder(task.discardNo); await vm.resolveDiscardPallet(task.palletNo)
|
||||||
|
await vm.scanDiscardCarton('BOX-1'); await vm.scanDiscardCarton('BOX-2')
|
||||||
|
vm.removeDiscardScan('BOX-1')
|
||||||
|
assert.equal(vm.discardActuals.sku, 1)
|
||||||
|
assert.deepEqual(vm.discardUnboundScans.map(scan => scan.mark), ['BOX-2'])
|
||||||
|
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
|
||||||
|
await vm.confirmDiscard()
|
||||||
|
assert.deepEqual(payload[2], [{ itemId: 'sku', actualPackages: 1, scannedCartonMarks: ['BOX-2'] }])
|
||||||
|
})
|
||||||
|
|||||||
@@ -22,6 +22,21 @@ test('reservation mismatch asks for the reserved pallet and retains diagnostics'
|
|||||||
assert.equal(result.traceId, 'trace-1')
|
assert.equal(result.traceId, 'trace-1')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('cargo and destination-pallet scan errors use concise Chinese and retain diagnostics', () => {
|
||||||
|
for (const [code, message, expected] of [
|
||||||
|
['A0409', 'Carton mark must contain a non-empty inbound code prefix', '请扫描箱码或国内DP码'],
|
||||||
|
['A0404', 'Pallet code is not part of the current FBA destination group', '托盘不属于当前目的仓,请重扫'],
|
||||||
|
['B0404', 'Pallet code is not part of the current FBA destination group', '托盘不属于当前目的仓,请重扫'],
|
||||||
|
[404, 'Pallet code is not part of the current FBA destination group', '托盘不属于当前目的仓,请重扫'],
|
||||||
|
]) {
|
||||||
|
const result = toReceivingError({ code, message, requestId: 'scan-request', traceId: 'scan-trace' })
|
||||||
|
assert.equal(result.message, expected)
|
||||||
|
assert.equal(result.code, code)
|
||||||
|
assert.equal(result.requestId, 'scan-request')
|
||||||
|
assert.equal(result.traceId, 'scan-trace')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test('first scan and receipt requests confirm server reservations without resource assignment commands', async () => {
|
test('first scan and receipt requests confirm server reservations without resource assignment commands', async () => {
|
||||||
const source = await readFile(new URL('../src/api/inbound.js', import.meta.url), 'utf8')
|
const source = await readFile(new URL('../src/api/inbound.js', import.meta.url), 'utf8')
|
||||||
const calls = []
|
const calls = []
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import test from 'node:test'
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import * as store from '../src/stores/receiving.js'
|
import * as store from '../src/stores/receiving.js'
|
||||||
import { normalizeReceivingSession } from '../src/api/receiving-adapter.js'
|
import { normalizeReceivingSession, toReceivingError } from '../src/api/receiving-adapter.js'
|
||||||
|
|
||||||
const source = readFileSync(new URL('../src/pages/operation/operation.vue', import.meta.url), 'utf8').match(/<script>([\s\S]*?)<\/script>/)[1]
|
const source = readFileSync(new URL('../src/pages/operation/operation.vue', import.meta.url), 'utf8').match(/<script>([\s\S]*?)<\/script>/)[1]
|
||||||
const defer = () => { let resolve, reject; const promise = new Promise((a, b) => { resolve = a; reject = b }); return { promise, resolve, reject } }
|
const defer = () => { let resolve, reject; const promise = new Promise((a, b) => { resolve = a; reject = b }); return { promise, resolve, reject } }
|
||||||
@@ -37,6 +37,133 @@ test('same current readback retains three signed previews; collapsed unversioned
|
|||||||
await vm.applyCurrentReceiving(sessionValue()); assert.equal(calls.length, 18)
|
await vm.applyCurrentReceiving(sessionValue()); assert.equal(calls.length, 18)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('editing and rejecting a pallet cannot submit using the previous successful validation', async t => {
|
||||||
|
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
|
||||||
|
store.receiving.resolvedCarton = { inboundCode: 'IN-A', cartonMark: 'IN-A#22-1', destinationType: 'FBA' }
|
||||||
|
store.receiving.resolvedPallet = { palletCode: 'PALLET-OLD', compatible: true, version: 1 }
|
||||||
|
let writes = 0
|
||||||
|
const { vm } = page({ toReceivingError,
|
||||||
|
resolveCurrentPallet: async () => { throw Object.assign(new Error('Pallet code is not part of the current FBA destination group'), { code: 'A0404', requestId: 'scan-request', traceId: 'scan-trace' }) },
|
||||||
|
receiveCurrentBulk: async () => { writes++ },
|
||||||
|
})
|
||||||
|
t.after(() => vm.suspendReceiving())
|
||||||
|
vm.palletInput = 'PALLET-OLD'; vm.packageCount = 3
|
||||||
|
assert.equal(vm.canSubmitReceipt, true)
|
||||||
|
vm.palletInput = 'PALLET-WRONG'
|
||||||
|
assert.equal(vm.canSubmitReceipt, false)
|
||||||
|
const toasts = []; vm.showToast = (message, type) => toasts.push({ message, type })
|
||||||
|
await vm.resolvePallet(vm.palletInput)
|
||||||
|
assert.equal(vm.resolvedPallet, null)
|
||||||
|
assert.equal(vm.packageCount, '')
|
||||||
|
assert.equal(vm.activeTarget, 'pallet')
|
||||||
|
assert.equal(vm.error.message, '托盘不属于当前目的仓,请重扫')
|
||||||
|
assert.equal(vm.error.requestId, 'scan-request')
|
||||||
|
assert.equal(vm.error.traceId, 'scan-trace')
|
||||||
|
assert.deepEqual(toasts, [{ message: '托盘不属于当前目的仓,请重扫', type: 'error' }])
|
||||||
|
await vm.submitReceipt()
|
||||||
|
assert.equal(writes, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an incompatible pallet never reports success and a corrected scan can be validated normally', async t => {
|
||||||
|
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
|
||||||
|
store.receiving.resolvedCarton = { inboundCode: 'IN-A', cartonMark: 'IN-A#22-1', destinationType: 'FBA' }
|
||||||
|
const calls = []; let successes = 0
|
||||||
|
const { vm } = page({ toReceivingError, playSuccessSound: () => successes++,
|
||||||
|
resolveCurrentPallet: async (palletCode, inboundCode) => {
|
||||||
|
calls.push({ palletCode, inboundCode })
|
||||||
|
return { palletCode, compatible: palletCode === 'PALLET-VALID', version: 2, currentReceivedPackages: 3 }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
t.after(() => vm.suspendReceiving())
|
||||||
|
const toasts = []; vm.showToast = (message, type) => toasts.push({ message, type })
|
||||||
|
await vm.resolvePallet('PALLET-WRONG')
|
||||||
|
assert.equal(vm.resolvedPallet, null)
|
||||||
|
assert.equal(vm.canSubmitReceipt, false)
|
||||||
|
assert.equal(successes, 0)
|
||||||
|
assert.deepEqual(toasts, [{ message: '托盘不匹配,请重扫', type: 'error' }])
|
||||||
|
await vm.resolvePallet('PALLET-VALID')
|
||||||
|
assert.deepEqual(calls, [{ palletCode: 'PALLET-WRONG', inboundCode: 'IN-A' }, { palletCode: 'PALLET-VALID', inboundCode: 'IN-A' }])
|
||||||
|
assert.equal(vm.resolvedPallet.palletCode, 'PALLET-VALID')
|
||||||
|
assert.equal(vm.error, null)
|
||||||
|
assert.equal(vm.packageCount, 3)
|
||||||
|
assert.equal(vm.canSubmitReceipt, true)
|
||||||
|
assert.equal(vm.activeTarget, 'quantity')
|
||||||
|
assert.equal(successes, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('late pallet validation cannot replace edited input and only the corrected pallet can be received', async t => {
|
||||||
|
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
|
||||||
|
store.receiving.resolvedCarton = { inboundCode: 'IN-A', cartonMark: 'IN-A#22-1', destinationType: 'FBA' }
|
||||||
|
const pending = defer(), receipts = [], calls = [], toasts = []; let successes = 0
|
||||||
|
const { vm } = page({ toReceivingError, playSuccessSound: () => successes++,
|
||||||
|
resolveCurrentPallet: (palletCode, inboundCode) => {
|
||||||
|
calls.push({ palletCode, inboundCode })
|
||||||
|
return palletCode === 'PALLET-A' ? pending.promise : Promise.resolve({ palletCode, compatible: true, version: 2, currentReceivedPackages: 3 })
|
||||||
|
},
|
||||||
|
receiveCurrentBulk: async payload => { receipts.push(payload); return {} },
|
||||||
|
getCurrentReceiving: async () => sessionValue(),
|
||||||
|
})
|
||||||
|
t.after(() => vm.suspendReceiving())
|
||||||
|
vm.showToast = (message, type) => toasts.push({ message, type }); vm.applyCurrentReceiving = async () => {}
|
||||||
|
const validation = vm.resolvePallet('PALLET-A')
|
||||||
|
vm.palletInput = 'PALLET-B'
|
||||||
|
pending.resolve({ palletCode: 'PALLET-A', compatible: true, version: 1, currentReceivedPackages: 3 })
|
||||||
|
await validation
|
||||||
|
assert.equal(vm.palletInput, 'PALLET-B')
|
||||||
|
assert.equal(vm.resolvedPallet, null)
|
||||||
|
assert.equal(vm.packageCount, '')
|
||||||
|
assert.equal(vm.canSubmitReceipt, false)
|
||||||
|
assert.equal(vm.busy, false)
|
||||||
|
assert.equal(successes, 0)
|
||||||
|
assert.deepEqual(toasts, [])
|
||||||
|
await vm.submitReceipt(); assert.equal(receipts.length, 0)
|
||||||
|
await vm.resolvePallet('PALLET-B')
|
||||||
|
assert.equal(vm.canSubmitReceipt, true)
|
||||||
|
assert.equal(successes, 1)
|
||||||
|
await vm.submitReceipt()
|
||||||
|
assert.deepEqual(calls, [{ palletCode: 'PALLET-A', inboundCode: 'IN-A' }, { palletCode: 'PALLET-B', inboundCode: 'IN-A' }])
|
||||||
|
assert.deepEqual(receipts.map(({ palletCode }) => palletCode), ['PALLET-B'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('obsolete pallet failures cannot replace current input or feedback', async t => {
|
||||||
|
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
|
||||||
|
store.receiving.resolvedCarton = { inboundCode: 'IN-A', cartonMark: 'IN-A#22-1', destinationType: 'FBA' }
|
||||||
|
const pending = defer(), toasts = []; let failures = 0
|
||||||
|
const { vm } = page({ toReceivingError, resolveCurrentPallet: () => pending.promise, playErrorSound: () => failures++ })
|
||||||
|
t.after(() => vm.suspendReceiving()); vm.showToast = (message, type) => toasts.push({ message, type })
|
||||||
|
const validation = vm.resolvePallet('PALLET-A')
|
||||||
|
vm.palletInput = 'PALLET-B'
|
||||||
|
pending.reject(Object.assign(new Error('Pallet code is not part of the current FBA destination group'), { code: 'A0404' }))
|
||||||
|
await validation
|
||||||
|
assert.equal(vm.palletInput, 'PALLET-B'); assert.equal(vm.error, null)
|
||||||
|
assert.equal(vm.resolvedPallet, null); assert.equal(vm.canSubmitReceipt, false)
|
||||||
|
assert.equal(vm.busy, false); assert.equal(failures, 0); assert.deepEqual(toasts, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('late pallet validation cannot cross a receiving context or page lifetime', async t => {
|
||||||
|
for (const [name, change] of [
|
||||||
|
['carton', () => { store.receiving.resolvedCarton = { inboundCode: 'IN-B', cartonMark: 'IN-B#1', destinationType: 'FBA' } }],
|
||||||
|
['container', () => store.setCurrentReceiving(sessionValue({ containerNo: 'CAB-B' }))],
|
||||||
|
['operator', ({ deps }) => { deps.session.currentUser = { id: 'another-operator', warehouseId: '900' } }],
|
||||||
|
['warehouse', ({ deps }) => { deps.session.currentUser.warehouseId = '901' }],
|
||||||
|
['hide and return', ({ vm, definition }) => { definition.onHide.call(vm); definition.onShow.call(vm) }],
|
||||||
|
]) await t.test(name, async t => {
|
||||||
|
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
|
||||||
|
store.receiving.resolvedCarton = { inboundCode: 'IN-A', cartonMark: 'IN-A#22-1', destinationType: 'FBA' }
|
||||||
|
const pending = defer(), toasts = []; let successes = 0
|
||||||
|
const context = page({ toReceivingError, resolveCurrentPallet: () => pending.promise, playSuccessSound: () => successes++ })
|
||||||
|
const { vm } = context
|
||||||
|
t.after(() => vm.suspendReceiving()); vm.bootstrap = async () => {}; vm.showToast = (message, type) => toasts.push({ message, type })
|
||||||
|
const validation = vm.resolvePallet('PALLET-A')
|
||||||
|
change(context)
|
||||||
|
pending.resolve({ palletCode: 'PALLET-A', compatible: true, version: 1, currentReceivedPackages: 3 })
|
||||||
|
await validation
|
||||||
|
assert.equal(vm.resolvedPallet, null); assert.equal(vm.packageCount, '')
|
||||||
|
assert.equal(vm.canSubmitReceipt, false); assert.equal(vm.busy, false)
|
||||||
|
assert.equal(successes, 0); assert.deepEqual(toasts, [])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test('signed photo changes, account/customer/cabinet changes and image failure cannot inherit stale previews', async () => {
|
test('signed photo changes, account/customer/cabinet changes and image failure cannot inherit stale previews', async () => {
|
||||||
store.resetReceivingSession(); let calls = 0
|
store.resetReceivingSession(); let calls = 0
|
||||||
const { vm, deps } = page({ downloadCurrentUnloadPhoto: async () => `local:${++calls}`, downloadCurrentDomesticLoadingPhoto: async () => '' })
|
const { vm, deps } = page({ downloadCurrentUnloadPhoto: async () => `local:${++calls}`, downloadCurrentDomesticLoadingPhoto: async () => '' })
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ test('domestic cabinet photos are read-only, previewable and non-blocking in sig
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('fixed receiving action leaves enough scroll-safe space above the PDA bottom bar', () => {
|
test('fixed receiving action leaves enough scroll-safe space above the PDA bottom bar', () => {
|
||||||
assert.match(page, /v-if="canFinishOnsite" class="sticky-action one"/)
|
assert.match(page, /v-if="canFinishOnsite" class="sticky-action-dock"><view class="sticky-action one"/)
|
||||||
assert.match(page, /allInboundsReconciled \? '完成整柜入库' : '现场收货结束(标记差异)'/)
|
assert.match(page, /allInboundsReconciled \? '完成整柜入库' : '现场收货结束(标记差异)'/)
|
||||||
assert.match(page, /receiving-content-with-action\{[^}]*padding-bottom:calc\(152px \+ env\(safe-area-inset-bottom,0px\)\)/)
|
assert.match(page, /receiving-content-with-action\{[^}]*padding-bottom:calc\(152px \+ env\(safe-area-inset-bottom,0px\)\)/)
|
||||||
assert.match(page, /primary-button,\.secondary-button,\.ghost-button\{[^}]*align-items:center;justify-content:center[^}]*line-height:normal;text-align:center/)
|
assert.match(page, /primary-button,\.secondary-button,\.ghost-button\{[^}]*align-items:center;justify-content:center[^}]*line-height:normal;text-align:center/)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ test('warehouse transfer page loads detail without exposing internal identifiers
|
|||||||
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
|
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
|
||||||
assert.match(page, /getWarehouseTransferDetail\(item\.transferNo\)/)
|
assert.match(page, /getWarehouseTransferDetail\(item\.transferNo\)/)
|
||||||
assert.match(page, /待测转运单/)
|
assert.match(page, /待测转运单/)
|
||||||
assert.match(page, /仓储转运测量列表/)
|
assert.match(page, /仓储转运测量/)
|
||||||
assert.match(page, /转运测量作业/)
|
assert.match(page, /转运测量作业/)
|
||||||
assert.match(page, /!selected && entryMode === 'list'/)
|
assert.match(page, /!selected && entryMode === 'list'/)
|
||||||
assert.match(page, /entryMode === 'work' && taskNo/)
|
assert.match(page, /entryMode === 'work' && taskNo/)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
|
|||||||
const enabled = operations.filter((operation) => operation.available)
|
const enabled = operations.filter((operation) => operation.available)
|
||||||
assert.deepEqual(enabled.map(({ module, name }) => ({ module, name })), [
|
assert.deepEqual(enabled.map(({ module, name }) => ({ module, name })), [
|
||||||
{ module: 'inbound', name: '拆柜收货' },
|
{ module: 'inbound', name: '拆柜收货' },
|
||||||
{ module: 'inbound', name: '仓储转运测量列表' }, { module: 'inbound', name: '转运测量' },
|
{ module: 'inbound', name: '仓储转运测量' }, { module: 'inbound', name: '转运测量' },
|
||||||
{ module: 'inbound', name: '退货入库' },
|
{ module: 'inbound', name: '退货入库' },
|
||||||
{ module: 'inbound', name: '合箱' },
|
{ module: 'inbound', name: '合箱' },
|
||||||
{ module: 'inbound', name: '退货清点' },
|
{ module: 'inbound', name: '退货清点' },
|
||||||
@@ -28,7 +28,7 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
|
|||||||
assert.equal(operations.find((item) => item.name === '退货入库')?.route, '/pages/return-receipt/return-receipt')
|
assert.equal(operations.find((item) => item.name === '退货入库')?.route, '/pages/return-receipt/return-receipt')
|
||||||
assert.equal(operations.find((item) => item.name === '合箱')?.route, '/pages/return-packing/return-packing')
|
assert.equal(operations.find((item) => item.name === '合箱')?.route, '/pages/return-packing/return-packing')
|
||||||
assert.equal(operations.find((item) => item.name === '退货清点')?.route, '/pages/return-counting/return-counting')
|
assert.equal(operations.find((item) => item.name === '退货清点')?.route, '/pages/return-counting/return-counting')
|
||||||
assert.equal(operations.find((item) => item.name === '仓储转运测量列表')?.route, '/pages/warehouse-transfer/warehouse-transfer?mode=list')
|
assert.equal(operations.find((item) => item.name === '仓储转运测量')?.route, '/pages/warehouse-transfer/warehouse-transfer?mode=list')
|
||||||
assert.equal(operations.find((item) => item.name === '转运测量')?.route, '/pages/warehouse-transfer/warehouse-transfer?mode=work')
|
assert.equal(operations.find((item) => item.name === '转运测量')?.route, '/pages/warehouse-transfer/warehouse-transfer?mode=work')
|
||||||
|
|
||||||
assert.equal(operations.some((item) => item.name === '库存盘点'), false)
|
assert.equal(operations.some((item) => item.name === '库存盘点'), false)
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { parse, compileScript, compileTemplate, compileStyle } from 'vue/compiler-sfc'
|
||||||
|
import { assertReceivingWarehouse } from '../src/api/receiving-adapter.js'
|
||||||
|
import { MODULES } from '../src/data/overseas-workbench.js'
|
||||||
|
|
||||||
|
const apiSource = readFileSync(new URL('../src/api/workbench.js', import.meta.url), 'utf8')
|
||||||
|
const homeSource = readFileSync(new URL('../src/pages/home/home.vue', import.meta.url), 'utf8')
|
||||||
|
const profile = { id: 'OP', warehouseId: 'WH', warehouseCode: 'WH-CODE', warehouseName: '测试仓' }
|
||||||
|
const emptySources = () => ({
|
||||||
|
assertReceivingWarehouse,
|
||||||
|
listReceivingCandidates: async () => [], listWarehouseTransferCandidates: async () => [],
|
||||||
|
listReturnCountTasks: async () => ({ records: [], total: 0 }), listPackingPlans: async () => [],
|
||||||
|
listDispatchTasks: async () => [], listOutboundTasks: async () => [], listCargoDiscardTasks: async () => [],
|
||||||
|
listRelabelCandidates: async () => []
|
||||||
|
})
|
||||||
|
function api(overrides = {}) {
|
||||||
|
const dependencies = { ...emptySources(), ...overrides }
|
||||||
|
return new Function(...Object.keys(dependencies), apiSource.replace(/^import .*$/gm, '').replace(/^export /gm, '') + '\nreturn { loadWorkbenchCounts, workbenchBadge, WORKBENCH_COUNT_KEYS }')(...Object.values(dependencies))
|
||||||
|
}
|
||||||
|
function home(loadWorkbenchCounts) {
|
||||||
|
const session = { currentUser: { ...profile }, apiBase: 'LOCAL' }
|
||||||
|
const scopes = []
|
||||||
|
const dependencies = { PortalHeader: {}, MODULES, session, requireSession: () => true, applyWarehouseProfile: value => value, getCurrentWarehouseProfile: async () => session.currentUser, loadWorkbenchCounts, workbenchBadge: api().workbenchBadge, restoreWarehouseWork: scope => scopes.push(scope), warehouseWork: { warehouseTransfer: { currentNo: 'T2' } } }
|
||||||
|
const page = new Function(...Object.keys(dependencies), homeSource.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(dependencies))
|
||||||
|
const value = page.data()
|
||||||
|
for (const [key, method] of Object.entries(page.methods)) value[key] = method.bind(value)
|
||||||
|
return { page, value, session, scopes }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('workbench counts separate queues, current work and paginated totals without duplicate business records', async () => {
|
||||||
|
const container = { sourceContainerBookingId: 'B1', containerNo: 'C1', overseasWarehouseCode: profile.warehouseCode, overseasWarehouseName: profile.warehouseName }
|
||||||
|
const source = api({
|
||||||
|
listReceivingCandidates: async () => [container, container, { ...container, sourceContainerBookingId: 'B2', containerNo: 'C2', operable: false }],
|
||||||
|
listWarehouseTransferCandidates: async () => [{ transferNo: 'T1', status: 'WAIT_PDA_RECEIVING' }, { transferNo: 'T2', status: 'PARTIALLY_RECEIVED' }, { transferNo: 'T2', status: 'PARTIALLY_RECEIVED' }, { transferNo: 'T3', status: 'COMPLETED' }],
|
||||||
|
listReturnCountTasks: async options => { assert.deepEqual(options, { page: 1, size: 1 }); return { total: 37, records: [{ receiptNo: 'RR1' }] } },
|
||||||
|
listPackingPlans: async () => [{ planNo: 'P1', status: 'READY' }, { planNo: 'P1', status: 'READY' }, { planNo: 'P2', status: 'COMPLETED' }],
|
||||||
|
listDispatchTasks: async () => [{ dispatchNo: 'D1', stage: 'LOADING' }, { dispatchNo: 'D1', stage: 'LOADING' }, { dispatchNo: 'D2', stage: 'BEFORE_LOADING' }, { dispatchNo: 'D3', stage: 'DELIVERING' }, { dispatchNo: 'D4', stage: 'DELIVERED' }, { dispatchNo: 'D5', stage: 'LOADING', status: 'CANCELLED' }],
|
||||||
|
listOutboundTasks: async status => { assert.equal(status, 'PENDING'); return [{ taskNo: 'O1' }, { taskNo: 'O1' }] },
|
||||||
|
listCargoDiscardTasks: async () => [{ discardNo: 'CD1', status: 'PENDING' }, { discardNo: 'CD1', status: 'PENDING' }, { discardNo: 'CD2', status: 'COMPLETED' }],
|
||||||
|
listRelabelCandidates: async () => [{ relabelNo: 'R1', taskStatus: 'AVAILABLE' }, { relabelNo: 'R1', taskStatus: 'AVAILABLE' }, { relabelNo: 'R2', taskStatus: 'PROCESSING', claimedByCurrentOperator: true }, { relabelNo: 'R3', taskStatus: 'PROCESSING', claimedByCurrentOperator: false }, { relabelNo: 'R4', taskStatus: 'WAIT_REVIEW' }, { relabelNo: 'R5', taskStatus: 'COMPLETED' }]
|
||||||
|
})
|
||||||
|
const counts = await source.loadWorkbenchCounts({ profile, activeTransferNo: 'T2' })
|
||||||
|
assert.deepEqual(Object.fromEntries(Object.entries(counts).map(([key, value]) => [key, value.count])), { receiving: 2, warehouseTransferList: 2, warehouseTransferWork: 1, returnCounting: 37, returnPacking: 1, dispatchPicking: 2, outboundPicking: 1, relabelReceive: 1, relabelExecute: 1, relabelReview: 1 })
|
||||||
|
assert.equal(counts.outboundPicking.discardCount, 1)
|
||||||
|
assert.equal(source.workbenchBadge('outboundPicking', counts.outboundPicking).label, '1 出库 · 1 废弃')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a failed source stays unknown while other queue counts remain available', async () => {
|
||||||
|
const error = Object.assign(new Error('网络异常'), { requestId: 'REQ-COUNT', traceId: 'TRACE-COUNT' })
|
||||||
|
const source = api({ listWarehouseTransferCandidates: async () => { throw error } })
|
||||||
|
const counts = await source.loadWorkbenchCounts({ profile })
|
||||||
|
assert.equal(counts.warehouseTransferList.status, 'error')
|
||||||
|
assert.equal(counts.warehouseTransferWork.error, error)
|
||||||
|
assert.equal(counts.warehouseTransferList.count, undefined)
|
||||||
|
assert.equal(counts.receiving.count, 0)
|
||||||
|
assert.equal(source.workbenchBadge('warehouseTransferList', counts.warehouseTransferList).label, '获取失败')
|
||||||
|
assert.equal(source.workbenchBadge('receiving', counts.receiving).label, '0 柜')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('foreign receiving warehouses and absent pagination totals cannot produce a success count', async () => {
|
||||||
|
const source = api({ listReceivingCandidates: async () => [{ overseasWarehouseCode: 'OTHER', overseasWarehouseName: '其他仓' }], listReturnCountTasks: async () => ({ records: [] }) })
|
||||||
|
const counts = await source.loadWorkbenchCounts({ profile })
|
||||||
|
assert.equal(counts.receiving.status, 'error')
|
||||||
|
assert.equal(counts.receiving.error.code, 'RECEIVING_WAREHOUSE_MISMATCH')
|
||||||
|
assert.equal(counts.returnCounting.status, 'error')
|
||||||
|
assert.equal(counts.returnCounting.count, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a completed or missing local transfer does not count as current measurement work', async () => {
|
||||||
|
const source = api({ listWarehouseTransferCandidates: async () => [{ transferNo: 'DONE', status: 'COMPLETED' }, { transferNo: 'OTHER', status: 'WAIT_PDA_RECEIVING' }] })
|
||||||
|
const counts = await source.loadWorkbenchCounts({ profile, activeTransferNo: 'DONE' })
|
||||||
|
assert.equal(counts.warehouseTransferWork.count, 0)
|
||||||
|
assert.equal(counts.warehouseTransferList.count, 1)
|
||||||
|
assert.equal(source.workbenchBadge('warehouseTransferWork', counts.warehouseTransferWork).label, '未开始')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('scan tools and queries do not pretend to have task counts', () => {
|
||||||
|
const { workbenchBadge } = api()
|
||||||
|
assert.equal(workbenchBadge('returnReceipt').label, '扫码签收')
|
||||||
|
assert.equal(workbenchBadge('move').label, '扫码作业')
|
||||||
|
assert.equal(workbenchBadge('inventoryQuery').label, '扫码查询')
|
||||||
|
assert.equal(workbenchBadge('receiving').label, '加载中…')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returning home refreshes counts and ignores a response from a hidden page', async () => {
|
||||||
|
let resolveFirst, calls = 0
|
||||||
|
const { page, value, scopes } = home(async ({ activeTransferNo }) => { assert.equal(activeTransferNo, 'T2'); calls++; return calls === 1 ? new Promise(resolve => { resolveFirst = resolve }) : { receiving: { status: 'ready', count: 4, unit: '柜' } } })
|
||||||
|
const first = page.onShow.call(value)
|
||||||
|
assert.equal(value.countsLoading, true)
|
||||||
|
page.onHide.call(value)
|
||||||
|
resolveFirst({ receiving: { status: 'ready', count: 99, unit: '柜' } })
|
||||||
|
await first
|
||||||
|
assert.deepEqual(value.counts, {})
|
||||||
|
await page.onShow.call(value)
|
||||||
|
assert.equal(value.counts.receiving.count, 4)
|
||||||
|
assert.equal(calls, 2)
|
||||||
|
assert.deepEqual(scopes, ['OP:WH', 'OP:WH'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('warehouse or account changes invalidate pending home counts', async () => {
|
||||||
|
let resolve
|
||||||
|
const { page, value, session } = home(() => new Promise(done => { resolve = done }))
|
||||||
|
const pending = page.onShow.call(value)
|
||||||
|
session.currentUser = { ...profile, id: 'OTHER-OP', warehouseId: 'OTHER-WH' }
|
||||||
|
resolve({ receiving: { status: 'ready', count: 99, unit: '柜' } })
|
||||||
|
await pending
|
||||||
|
assert.deepEqual(value.counts, {})
|
||||||
|
assert.equal(value.countsLoading, false)
|
||||||
|
assert.equal(value.user.id, 'OTHER-OP')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('home compiles with per-entry badges and no function-count summary', () => {
|
||||||
|
const { descriptor, errors } = parse(homeSource, { filename: 'home.vue' })
|
||||||
|
assert.deepEqual(errors, [])
|
||||||
|
compileScript(descriptor, { id: 'home-counts' })
|
||||||
|
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename: 'home.vue', id: 'home-counts' }).errors, [])
|
||||||
|
for (const style of descriptor.styles) assert.deepEqual(compileStyle({ source: style.content, filename: 'home.vue', id: 'data-v-home-counts', scoped: style.scoped }).errors, [])
|
||||||
|
assert.doesNotMatch(descriptor.template.content, /availableCount|项可处理|group\.operations\.length/)
|
||||||
|
assert.match(descriptor.template.content, /countBadge\(operation\)\.label/)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user