diff --git a/AGENTS.md b/AGENTS.md
index 31693af..ea0a386 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -44,6 +44,7 @@
| 中英西语言设置 | `src/i18n/index.js`、`src/i18n/locales/`、`src/pages/profile/profile.vue` | 个人中心可选择并持久化 `zh-CN`、`en`、`es`;当前业务翻译资源仍为空,页面业务文案继续使用中文,不得把语言选择入口误报为全量翻译完成 |
| 柜号签收、照片与入仓真实作业 | `src/pages/operation/operation.vue`、`src/components/ReceivingSessionSummary.vue`、`src/api/inbound.js`、`src/api/receiving-adapter.js`、`src/api/overseas.js`、`src/stores/receiving.js` | 签收前展开显示国内空仓、半仓、满仓、锁仓和带锁码五类整柜只读照片,收货中折叠查看,单图加载失败只允许重试且不阻断拆柜;海外三个固定照片角色各一张,支持现场拍照、鉴权预览及更换失败保留原图;多计划同柜直接展示服务端 `assigned/mergeAllowed/urgent/mergeHint`,合托明确为现场可选而非完成门禁;扫码实收确认计划生成阶段已预留的真实托盘和库位,校验卡展示预留库位编码及 `A0` 共享溢出位提示,不在设备端重新分配。Backend `lastleg-service/inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,公网前缀 `/api/pda/v1/last-leg/receiving-containers/**` |
| 退货入库签收与合箱 | `src/pages/return-receipt/return-receipt.vue`、`src/pages/return-packing/return-packing.vue`、`src/api/return-receipt.js`、`src/api/return-packing.js`、`src/stores/warehouse-work.js` | 签收优先扫描退货单号,也可扫描 SKU 后选择候选退货单,按本包实际 SKU 数量生成退货箱与计费批次;合箱只选择同客户已完成退货单的可用库存并录入新箱箱规;公网前缀 `/api/pda/v1/last-leg/return-receipts/**`、`/api/pda/v1/last-leg/return-inventory/**` |
+| 退货箱内清点 | `src/pages/return-counting/return-counting.vue`、`src/api/return-counting.js` | 扫描箱号或托盘号定位清点任务;各箱 SKU 汇总与签收数量核平,支持恢复当前任务;页面注册见 `src/pages.json` |
| 仓储转运测量、打托与标签 | `src/pages/warehouse-transfer/warehouse-transfer.vue`、`src/api/warehouse-transfers.js` | 工作台键 `warehouseTransfer`;候选→详情→逐托测量草稿→生成新托盘号→扫码绑定→照片与完成打托。API 模块路径 `/pda/v1/last-leg/warehouse-transfers` 经统一海外请求层处理;不是 Admin 转运审核或排车转派送。回归 `tests/warehouse-transfer-api.test.mjs` |
| 托盘管理真实作业 | `src/pages/pallet/pallet.vue`、`src/api/pallet.js` | 扫码查询托盘内容,按服务端返回的托盘/库存版本执行组托、拆托、换托和解绑;公网前缀 `/api/pda/v1/last-leg/pallet-operations/**` |
| 库存盘点(入口隐藏) | `src/pages/stocktake/stocktake.vue`、`src/api/warehouse-operations.js`、`src/stores/warehouse-work.js` | 底层创建、恢复、计数与提交实现仍保留;当前 `src/data/overseas-workbench.js` 不注册库存盘点,不向现场开放入口 |
diff --git a/output/gray-h5-scan/gray-release-candidate.json b/output/gray-h5-scan/gray-release-candidate.json
new file mode 100644
index 0000000..d2c4f50
--- /dev/null
+++ b/output/gray-h5-scan/gray-release-candidate.json
@@ -0,0 +1,16 @@
+{
+ "base": "3133aac01953a2b7034517171cb098148407cd6e",
+ "commit": "a7388914cdd1ae654efa08867edb51fc4e0b6e5c",
+ "files": [
+ "src/pages/dispatch/dispatch.vue",
+ "src/utils/dispatch-error.js",
+ "tests/fulfillment-workflows.test.mjs"
+ ],
+ "supersedes": "cf984ab4812d64b33e1ad2c98bc2804a272c6c77",
+ "deployed": false,
+ "checks": {
+ "tests": 215,
+ "h5Build": "PASS",
+ "browser": "local corrected H5 with gray API"
+ }
+}
diff --git a/src/api/fulfillment.js b/src/api/fulfillment.js
index e558403..82cb71a 100644
--- a/src/api/fulfillment.js
+++ b/src/api/fulfillment.js
@@ -1,4 +1,4 @@
-import { overseasRequest } from './overseas.js'
+import { overseasRequest, overseasUpload, overseasDownload } from './overseas.js'
const DISPATCH_BASE = '/pda/v1/last-leg/dispatches'
const OUTBOUND_BASE = '/pda/v1/last-leg/outbound-tasks'
@@ -43,6 +43,7 @@ export function normalizeDispatchTask(value) {
warehouseRouteLabel: String(item?.warehouseRouteLabel || ''),
scheduledPackages: count(item?.scheduledPackages),
pickedPackages: count(item?.pickedPackages),
+ returnCartons: asList(item?.returnCartons).map((carton) => ({ ...carton, id: String(carton.id || ''), cartonMark: String(carton.cartonMark || ''), productQuantity: count(carton.productQuantity) })),
positions: asList(item?.positions).map((position) => ({
...position,
id: String(position?.id || ''),
@@ -137,6 +138,7 @@ export function outboundPickPayload(item, position, packages, scannedCode = '')
outboundPalletItemId: item.outboundPalletItemId,
inventoryPositionId: position.inventoryPositionId || null,
packages: Number(packages),
+ ...(item.returnSkuQuantity != null ? { productQuantity: Number(item.returnSkuQuantity) } : {}),
cartonMarks: skuScan ? [] : position.cartonMark ? [position.cartonMark] : [],
...(skuScan ? { skuCode: item.skuCode } : {})
}
@@ -173,6 +175,11 @@ export async function resolveCargoDiscardPallet(palletNo) {
return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/resolve`, { palletNo }))
}
-export async function confirmCargoDiscard(discardNo, palletNo, lines) {
- return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/confirm`, { lines }))
+export async function confirmCargoDiscard(discardNo, palletNo, lines, photoFileIds) {
+ return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/confirm`, { lines, photoFileIds }))
}
+
+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 uploadCargoDiscardPhoto = (discardNo, palletNo, filePath) => overseasUpload(discardPhotoPath(discardNo, palletNo), filePath)
+export const downloadCargoDiscardPhoto = (discardNo, palletNo, fileId) => overseasDownload(`${discardPhotoPath(discardNo, palletNo)}/${encoded(fileId)}/download`, { expectedContentTypePrefix: 'image/' })
diff --git a/src/api/pallet.js b/src/api/pallet.js
index d90081f..1677fd4 100644
--- a/src/api/pallet.js
+++ b/src/api/pallet.js
@@ -4,6 +4,8 @@ const BASE = '/pda/v1/last-leg/pallet-operations'
export const findRelabelSources = (relabelNo) => overseasRequest('POST', `${BASE}/relabel-sources`, { relabelNo })
export const createRelabelPallet = (payload) => overseasRequest('POST', `${BASE}/relabel-pallet-label`, payload)
export const bindRelabelPallet = (payload) => overseasRequest('POST', `${BASE}/relabel-disassemble`, payload)
+export const listRelabelPalletMeasurements = (relabelNo) => overseasRequest('POST', `${BASE}/relabel-pallet-measurements`, { relabelNo })
+export const measureRelabelPallet = (payload) => overseasRequest('POST', `${BASE}/relabel-pallet-measure`, payload)
export function resolvePallet(code) {
return overseasRequest('POST', `${BASE}/pallets/resolve`, { code: String(code || '').trim() })
diff --git a/src/api/relabel.js b/src/api/relabel.js
index a04e836..117342b 100644
--- a/src/api/relabel.js
+++ b/src/api/relabel.js
@@ -4,6 +4,7 @@ const BASE = '/pda/v1/last-leg/relabel-tasks'
const encoded = (value) => encodeURIComponent(String(value || '').trim())
export const scanRelabelSku = async (no, payload) => normalizeRelabel(await overseasRequest('POST', `${BASE}/${encoded(no)}/skus/scan`, payload))
export const completeReturnRelabelSku = async (no, payload) => normalizeRelabel(await overseasRequest('POST', `${BASE}/${encoded(no)}/return-skus/complete`, payload))
+export const packReturnRelabelCartons = async (no, payload) => normalizeRelabel(await overseasRequest('POST', `${BASE}/${encoded(no)}/return-cartons/pack`, payload))
export const completeReturnRelabelSkuTotal = async (no, payload) => normalizeRelabel(await overseasRequest('POST', `${BASE}/${encoded(no)}/return-skus/complete-total`, payload))
function asList(value) { return Array.isArray(value) ? value : [] }
@@ -11,6 +12,7 @@ function asList(value) { return Array.isArray(value) ? value : [] }
export function normalizeRelabel(value = {}) {
const items = asList(value.items).map((item) => ({
...item, itemId: String(item.itemId || ''), skuCode: String(item.skuCode || ''),
+ returnCartonId: item.returnCartonId == null ? null : String(item.returnCartonId), returnQuantity: Number(item.returnQuantity || 0),
newSkuCode: String(item.newSkuCode || ''), operationType: String(item.operationType || 'CARTON_LABEL'), expectedPackages: Number(item.expectedPackages || 0),
relabelledPackages: Number(item.relabelledPackages || 0), skippedPackages: Number(item.skippedPackages || 0),
exceptionPackages: Number(item.exceptionPackages || 0)
diff --git a/src/api/return-counting.js b/src/api/return-counting.js
new file mode 100644
index 0000000..93705e4
--- /dev/null
+++ b/src/api/return-counting.js
@@ -0,0 +1,20 @@
+import { overseasRequest } from './overseas.js'
+const BASE = '/pda/v1/last-leg/return-inventory/counting'
+export function getCurrentReturnCountTask() {
+ return overseasRequest('GET', `${BASE}/current`)
+}
+export function beginReturnCountTask(receiptNo) {
+ return overseasRequest('POST', `${BASE}/receipts/${encodeURIComponent(receiptNo)}/begin`)
+}
+export function scanReturnCountTasks(code) {
+ return overseasRequest('GET', `${BASE}/scan/${encodeURIComponent(String(code).trim())}`)
+}
+export function listReturnCountTasks({ page = 1, size = 10, keyword = '' } = {}) {
+ return overseasRequest('GET', `${BASE}/tasks?page=${page}&size=${size}&keyword=${encodeURIComponent(keyword.trim())}`)
+}
+export function scanReturnCountCarton(mark) {
+ return overseasRequest('GET', `${BASE}/cartons/${encodeURIComponent(String(mark).trim())}`)
+}
+export function confirmReturnCount(receiptNo, request) {
+ return overseasRequest('POST', `${BASE}/receipts/${encodeURIComponent(receiptNo)}`, request)
+}
diff --git a/src/api/return-packing.js b/src/api/return-packing.js
index 9b43e69..40197a4 100644
--- a/src/api/return-packing.js
+++ b/src/api/return-packing.js
@@ -17,7 +17,17 @@ function normalizeItems(items) {
}
export function normalizePackingPreview(value = {}) {
- return {
+ value = value && typeof value === 'object' ? value : {}
+ const normalizeCustomer = (customer = {}) => ({
+ customerId: customer.customerId == null ? '' : String(customer.customerId),
+ orders: Array.isArray(customer.orders) ? customer.orders.map((item) => ({
+ returnNo: String(item.returnNo || ''), customerReferenceNo: String(item.customerReferenceNo || ''), status: String(item.status || '')
+ })) : [],
+ skus: Array.isArray(customer.skus) ? customer.skus.map((item) => ({
+ skuCode: String(item.skuCode || ''), productNameZh: String(item.productNameZh || ''), availableQuantity: count(item.availableQuantity)
+ })) : []
+ })
+ const result = {
customerKey: value.customerId == null ? '' : String(value.customerId),
orders: Array.isArray(value.orders) ? value.orders.map((item) => ({
returnNo: String(item.returnNo || ''), customerReferenceNo: String(item.customerReferenceNo || ''), status: String(item.status || '')
@@ -26,6 +36,13 @@ export function normalizePackingPreview(value = {}) {
skuCode: String(item.skuCode || ''), productNameZh: String(item.productNameZh || ''), availableQuantity: count(item.availableQuantity)
})) : []
}
+ if (Array.isArray(value.customers)) result.customers = value.customers.map(normalizeCustomer).filter((item) => item.customerId)
+ else if (result.customerKey && result.orders.length) {
+ // Older service versions returned the single-customer shape. Keep the
+ // PDA behavior consistent by exposing it through the grouped contract.
+ result.customers = [{ customerId: result.customerKey, orders: result.orders, skus: result.skus }]
+ }
+ return result
}
export function normalizeReturnLabel(value = {}) {
@@ -37,16 +54,53 @@ export function normalizeReturnLabel(value = {}) {
}
}
-export async function findPackingOrders(returnNo = '') {
+export async function findPackingOrders(returnNo = '', mode = 'RETURN_NO') {
const query = String(returnNo || '').trim()
try {
- return normalizePackingPreview(await overseasRequest('GET', `${BASE}/packing/orders${query ? `?returnNo=${encoded(query)}` : ''}`))
+ return normalizePackingPreview(await overseasRequest('GET', `${BASE}/packing/orders${query ? `?${mode === 'RETURN_NO' ? 'returnNo' : 'skuCode'}=${encoded(query)}` : ''}`))
} catch (error) {
if (isNotFound(error)) return normalizePackingPreview()
throw error
}
}
+export async function listPackingPlans() {
+ const value = await overseasRequest('GET', `${BASE}/packing/plans`)
+ return Array.isArray(value) ? value.map(normalizePackingPlan) : []
+}
+
+export async function completePackingPlan(planNo, request) {
+ const value = await overseasRequest('POST', `${BASE}/packing/plans/${encoded(planNo)}/complete`, request)
+ return { planNo: String(value?.planNo || planNo), labels: Array.isArray(value?.labels) ? value.labels.map(normalizeReturnLabel) : [] }
+}
+
+export function normalizePackingPlan(value = {}) {
+ value = { ...value, completionCartons: Array.isArray(value.completionCartons) ? value.completionCartons.map(normalizeReturnLabel) : [], transfers: Array.isArray(value.transfers) ? value.transfers : [], completedAt: value.completedAt || null }
+ return { completionCartons: value.completionCartons, transfers: value.transfers, completedAt: value.completedAt, planNo: String(value.planNo || ''), customerId: String(value.customerId || ''), returnNos: Array.isArray(value.returnNos) ? value.returnNos.map(String) : [], sourceCartonMarks: Array.isArray(value.sourceCartonMarks) ? value.sourceCartonMarks.map(String) : [], sources: Array.isArray(value.sources) ? value.sources : [], mode: String(value.mode || ''), status: String(value.status || ''), boxes: Array.isArray(value.boxes) ? value.boxes : [], remark: String(value.remark || '') }
+}
+
+export async function getPackingPlan(planNo) {
+ return normalizePackingPlan(await overseasRequest('GET', `${BASE}/packing/plans/${encoded(planNo)}`))
+}
+
+export async function scanReturnCarton(mark) {
+ return overseasRequest('GET', `${BASE}/cartons/${encoded(mark)}`)
+}
+
+export async function mergeReturnBoxes(request) {
+ const value = await overseasRequest('POST', `${BASE}/cartons/merge-boxes`, request)
+ return { labels: [normalizeReturnLabel(value?.label)] }
+}
+
+export async function packReturnSkus(request) {
+ const value = await overseasRequest('POST', `${BASE}/cartons/pack`, request)
+ return { labels: Array.isArray(value?.labels) ? value.labels.map(normalizeReturnLabel) : [] }
+}
+
+export async function putawayReturnCarton(mark, request) {
+ return overseasRequest('POST', `${BASE}/cartons/${encoded(mark)}/putaway`, request)
+}
+
export async function mergeReturnCartons(request) {
const value = await overseasRequest('POST', `${BASE}/cartons/merge`, request)
return { cartonMark: String(value?.cartonMark || ''), skuCode: String(value?.skuCode || ''), totalQuantity: count(value?.totalQuantity), label: normalizeReturnLabel(value?.label) }
@@ -55,3 +109,7 @@ export async function mergeReturnCartons(request) {
export async function getReturnCartonLabel(cartonMark) {
return normalizeReturnLabel(await overseasRequest('GET', `${BASE}/cartons/${encoded(cartonMark)}/label`))
}
+
+export async function confirmReturnLabel(cartonMark) {
+ return normalizeReturnLabel(await overseasRequest('POST', `${BASE}/cartons/${encoded(cartonMark)}/confirm-label`))
+}
diff --git a/src/components/RelabelPalletWork.vue b/src/components/RelabelPalletWork.vue
index bd24a41..9661914 100644
--- a/src/components/RelabelPalletWork.vue
+++ b/src/components/RelabelPalletWork.vue
@@ -6,20 +6,30 @@
已绑定箱唛:选择后扫描新目标托盘可改绑(已排车或出库不可直接改绑)。
-
+
+
+ {{ pallet.palletCode }} · {{ pallet.totalPackages }} 箱 · 体积 {{ pallet.volumeCbm == null ? '待补测' : pallet.volumeCbm + ' m³' }} · 重量 {{ pallet.grossWeightKg == null ? '待补测' : pallet.grossWeightKg + ' kg' }}
+
+ {{ pallet.palletCode }} · {{ pallet.packages }} 箱
+ {{ pallet.inherited ? '原托盘货物未变,沿用原测量;包装变化请重新测量。' : '拆托、合托后请确认本托最终尺寸和毛重,原托盘剩余货物也需补测。' }}
+ 已确认:{{ pallet.volumeCbm }} m³ · {{ pallet.grossWeightKg }} kg
+
+
+
+
正在改绑:{{ rebindSelection.mark }},当前托盘:{{ rebindSelection.pallet }}
- 已记录组托:{{ entry.cartonMarks.join('、') }} → {{ entry.targetPallets.join('、') }}(含历史轮次)
+
- 托盘号:{{ row.palletCode }} · 入仓码:{{ row.inboundCode }}
- {{ isReturnBulk ? '件数' : '箱数' }}:{{ row.stockPackages }} · 可拆{{ isReturnBulk ? '件数' : '箱数' }}:{{ row.movablePackages }}
+ 托盘号{{ row.palletCode }}来源箱{{ (row.cartonMarks || []).join('、') }}
+ {{ isReturnBulk ? '件数' : '箱数' }}:{{ row.stockPackages }} · 可拆{{ isReturnBulk ? '件数' : '箱数' }}:{{ row.movablePackages }}
- 当前没有可操作的来源托盘
-
-
- 已确认:{{ source.palletCode }}
+ 当前没有可操作的来源托盘或来源箱
+
+
+ 来源已确认:{{ source.palletCode }};尚未拆出,请完成贴标登记、核对拆出数量并扫描目标托盘,绑定成功后才保存拆托明细。
- 本次{{ isReturnBulk && wholeSource ? '整托换标数量' : `拆出${isReturnBulk ? '件数' : '箱数'}` }}(可分多个来源托盘,逐批提交)
+ 本次{{ wholeSource ? '整托换标数量' : '拆出件数' }}(可分多个来源托盘,逐批提交)
{{ selectedSource.palletCode }}:{{ wholeSource ? '整托换标' : '部分拆箱' }} {{ quantity || 0 }} 件 → {{ selectedDestination || '目的仓待维护' }}
贴标数量等于本托盘全部 {{ selectedSource.stockPackages }} 件,无需填写拆箱数量;直接新增带目的仓的托盘标签并绑定。
@@ -27,26 +37,41 @@
退货换标按整托绑定,不支持拆箱;托盘仍会展示用于核对。
退货换标无需扫描箱唛。
- 选择本批新目的仓请核对下面勾选的箱与现场搬动一致;数量不符时不能提交。此处确认的是新目标托盘绑定,不推断旧箱唛位置。{{ marks.includes(item.cartonMark) ? '☑' : '☐' }} {{ item.cartonMark }} → {{ item.fbaCartonMark || item.destinationFbaWarehouseCode }}本批确认 {{ marks.length }} 个箱唛 / 拆出 {{ quantity || 0 }} 箱
+ 本次拆出箱数箱
+ 本次需换标拆 {{ requiredSplitPackages }} 箱已拆 {{ splitPackages }} 箱
+
+
-
+
+
+ {{ labelBlockReason }}
已识别目标:{{ target.palletCode }},尚未绑定成功
- 新托盘号:{{ newPallet.palletCode }},请贴好托盘标签后扫描确认。
+ 托盘 {{ newPallet.palletCode }} 待绑定
+
{{ message }}{{ error }}
diff --git a/src/components/ReturnLabelConfirmation.vue b/src/components/ReturnLabelConfirmation.vue
new file mode 100644
index 0000000..e41609e
--- /dev/null
+++ b/src/components/ReturnLabelConfirmation.vue
@@ -0,0 +1,31 @@
+
+
+ 标签已扫码确认,可按该包裹/箱号出库
+
+
+
+ {{ error }}
+
+
+
+
diff --git a/src/components/ReturnRelabelPackWork.vue b/src/components/ReturnRelabelPackWork.vue
new file mode 100644
index 0000000..1ecb4f2
--- /dev/null
+++ b/src/components/ReturnRelabelPackWork.vue
@@ -0,0 +1,41 @@
+
+
+ 换标商品独立合箱部分换标须装入独立新箱;来源余量留在原包裹/箱。所有来源登记成功后才可生成新箱。
+ {{ row.cartonMark }} · {{ row.skuCode }} · {{ row.quantity }} 件
+
+ 新箱:{{ fact.cartonMark }} · {{ fact.quantity }} 件此标签用于识别仓库包裹/箱;商品目标 SKU 标签请按上方换标明细核对。
+
+
+
+
diff --git a/src/data/overseas-workbench.js b/src/data/overseas-workbench.js
index d99c613..ec649be 100644
--- a/src/data/overseas-workbench.js
+++ b/src/data/overseas-workbench.js
@@ -4,7 +4,8 @@ export const MODULES = [
{ 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: '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' },
+ { key: 'returnCounting', name: '退货箱内清点', icon: '清', iconType: 'list', description: '逐箱清点托盘签收货物,核对 SKU 和数量', available: true, route: '/pages/return-counting/return-counting' }
] },
{ key: 'outbound', name: '出库履约', nameKey: 'modules.outbound.name', icon: '出', description: '排车拣货、出库任务与发车闭环', descriptionKey: 'modules.outbound.description', operations: [
{ key: 'dispatchPicking', name: '排车拣货', nameKey: 'modules.dispatchPicking.name', icon: '车', iconType: 'paperplane', description: '按排车任务扫描托盘拣货,完成门禁后执行发车', descriptionKey: 'modules.dispatchPicking.description', available: true, route: '/pages/dispatch/dispatch' },
diff --git a/src/pages.json b/src/pages.json
index 31803c6..efd3a9a 100644
--- a/src/pages.json
+++ b/src/pages.json
@@ -6,6 +6,7 @@
{ "path": "pages/operation/operation", "style": { "navigationStyle": "custom", "navigationBarTitleText": "柜号入库与现场作业", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/warehouse-transfer/warehouse-transfer", "style": { "navigationStyle": "custom", "navigationBarTitleText": "仓储转运测量", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/return-receipt/return-receipt", "style": { "navigationStyle": "custom", "navigationBarTitleText": "退货入库", "backgroundColor": "#F5F7FA" } },
+ { "path": "pages/return-counting/return-counting", "style": { "navigationStyle": "custom", "navigationBarTitleText": "退货箱内清点", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/return-packing/return-packing", "style": { "navigationStyle": "custom", "navigationBarTitleText": "退货合箱", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/pallet/pallet", "style": { "navigationStyle": "custom", "navigationBarTitleText": "托盘管理", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/stocktake/stocktake", "style": { "navigationStyle": "custom", "navigationBarTitleText": "库存盘点", "backgroundColor": "#F5F7FA" } },
diff --git a/src/pages/dispatch/dispatch.vue b/src/pages/dispatch/dispatch.vue
index ba9b125..723da3c 100644
--- a/src/pages/dispatch/dispatch.vue
+++ b/src/pages/dispatch/dispatch.vue
@@ -5,14 +5,14 @@
排车拣货与发车
- 客户与当前海外仓由登录令牌和服务端确定;扫描排车单号或任务中的托盘码开始作业
+ 扫描排车单号打开任务,再扫描任务中的托盘码进行拣货
当前任务排车拣货详情
-
+
- 网络失败不会清空草稿,再次提交会沿用同一幂等键。
+ 网络中断会保留当前作业,恢复后可重试。
正在加载排车任务…
@@ -21,7 +21,7 @@
错误码:{{ error.code }}
requestId:{{ error.requestId }}
traceId:{{ error.traceId }}
- 网络连接失败,未完成作业与幂等键已保留。
+ 网络连接失败,当前作业已保留,请恢复网络后重试。
@@ -47,9 +47,10 @@
-
+
{{ item.palletNo || '未关联托盘码' }}{{ item.pickedPackages }}/{{ item.scheduledPackages }} 箱
{{ routeLabel(item) }}
+ {{ carton.cartonMark }} · {{ carton.status === 'OUTBOUNDED' ? '已拣货' : '待扫描' }}
@@ -57,7 +58,8 @@
本次拣货:{{ selectedItem.palletNo }}
- 拣货箱数最多 {{ selectedRemaining }} 箱
+ {{ selectedReturnCarton ? `已扫描 ${selectedReturnCarton.cartonMark}` : '请扫描此托盘上的来源箱唛,每次拣货一箱' }}
+ 拣货箱数最多 {{ selectedRemaining }} 箱
@@ -105,16 +107,17 @@
computed: {
recentTaskNo() { return fulfillment.dispatch.currentNo || '' },
packages() { return fulfillment.dispatch.packages },
- selectedItem() { return this.current?.items?.find((item) => item.palletNo === fulfillment.dispatch.palletCode) || null },
+ selectedItem() { return this.current?.items?.find((item) => fulfillment.dispatch.dispatchItemId ? item.dispatchItemId === fulfillment.dispatch.dispatchItemId : item.palletNo === fulfillment.dispatch.palletCode) || null },
+ selectedReturnCarton() { return this.selectedItem?.returnCartons?.find((carton) => carton.cartonMark === fulfillment.dispatch.cartonCode && carton.status === 'OUTBOUND_PENDING') || null },
selectedPosition() { return this.selectedItem?.positions?.find((item) => item.availablePackages > 0) || this.selectedItem?.positions?.[0] || null },
- selectedRemaining() { return Math.max(0, Math.min(this.selectedItem?.scheduledPackages - this.selectedItem?.pickedPackages || 0, this.selectedPosition?.availablePackages || 0)) },
+ selectedRemaining() { const remaining = this.selectedItem?.scheduledPackages - this.selectedItem?.pickedPackages || 0; return Math.max(0, this.selectedItem?.returnCartons?.length ? remaining : Math.min(remaining, this.selectedPosition?.availablePackages || 0)) },
plannedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.scheduledPackages, 0) },
pickedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.pickedPackages, 0) },
remainingPackages() { return Math.max(0, this.plannedPackages - this.pickedPackages) },
allPicked() { return this.plannedPackages > 0 && this.remainingPackages === 0 },
pickingCompleted() { return finishedStatuses.includes(this.current?.status) },
departed() { return this.current?.status === 'DEPARTED' },
- canPick() { return this.current?.canPick === true && this.selectedPosition && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining && !this.pickingCompleted },
+ canPick() { return this.current?.canPick === true && (this.selectedItem?.returnCartons?.length ? Boolean(this.selectedReturnCarton) : Boolean(this.selectedPosition) && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining) && !this.pickingCompleted },
canCompleteStage() { return Boolean(this.current) && !this.pickingCompleted },
canComplete() { return this.current?.canPick === true && this.plannedPackages > 0 && this.remainingPackages === 0 },
canDepart() { return this.pickingCompleted && this.allPicked && this.current?.canDepart === true && !this.departed }
@@ -144,26 +147,36 @@
async loadTasks() { await this.run('加载排车任务', async () => { this.tasks = await listDispatchTasks(); this.current = null }, true) },
async openTask(dispatchNo) {
const code = String(dispatchNo || '').trim(); if (!code) return
- await this.run('恢复排车任务', async () => { const previousNo = fulfillment.dispatch.currentNo; this.current = await getDispatchTask(code); updateFulfillmentDraft('dispatch', { currentNo: this.current.dispatchNo, palletCode: previousNo === this.current.dispatchNo ? fulfillment.dispatch.palletCode : '' }); this.scanCode = '' }, true)
+ await this.run('恢复排车任务', async () => { const previousNo = fulfillment.dispatch.currentNo; this.current = await getDispatchTask(code); const same = previousNo === this.current.dispatchNo; updateFulfillmentDraft('dispatch', { currentNo: this.current.dispatchNo, palletCode: same ? fulfillment.dispatch.palletCode : '', dispatchItemId: same ? fulfillment.dispatch.dispatchItemId : '', cartonCode: same ? fulfillment.dispatch.cartonCode : '' }); this.scanCode = '' }, true)
},
- closeTask() { this.current = null; this.scanCode = ''; this.error = null; this.successMessage = ''; updateFulfillmentDraft('dispatch', { currentNo: '', palletCode: '', packages: 1 }); this.loadTasks() },
+ closeTask() { this.current = null; this.scanCode = ''; this.error = null; this.successMessage = ''; updateFulfillmentDraft('dispatch', { currentNo: '', palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1 }); this.loadTasks() },
handleScan(raw) {
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim(); if (!code || this.busy || this.loading) return
console.info('[Overseas PDA dispatch] scan', { code, task: this.current?.dispatchNo || '' })
this.scanCode = code
+ this.error = null; this.successMessage = ''
+ updateFulfillmentDraft('dispatch', { palletCode: '', dispatchItemId: '', 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)
- const item = this.current.items.find((value) => [value.palletNo, value.fbaWarehouseCode].includes(code))
- if (!item) return this.setError(new Error('扫码未匹配当前排车任务的托盘或稳定业务码'), '扫码未匹配')
+ const cartonItem = this.current.items.find((item) => item.returnCartons?.some((carton) => carton.cartonMark === code))
+ if (cartonItem) {
+ if (cartonItem.returnCartons.find((carton) => carton.cartonMark === code)?.status !== 'OUTBOUND_PENDING') return this.setError(new Error('该箱已拣货或不可出库'), '扫码未匹配')
+ this.selectPallet(cartonItem); updateFulfillmentDraft('dispatch', { cartonCode: code, packages: 1 }); this.scanCode = ''; playSuccessSound(); return
+ }
+ const matches = this.current.items.filter((value) => value.palletNo === code && value.scheduledPackages > value.pickedPackages)
+ if (matches.length > 1) return this.setError(new Error('此托盘包含多条待拣明细,请选择对应货物卡片'), '请选择拣货明细')
+ const item = matches[0]
+ if (!item) return this.setError(new Error(this.current.items.some((value) => value.palletNo === code) ? '该托盘已完成拣货,请勿重复操作' : '扫码未匹配当前任务的托盘码或来源箱唛'), '扫码未匹配')
this.selectPallet(item); this.scanCode = ''; playSuccessSound()
},
- selectPallet(item) { if (!item?.palletNo || item.scheduledPackages <= item.pickedPackages) return; updateFulfillmentDraft('dispatch', { palletCode: item.palletNo, packages: Math.max(1, Math.min(item.scheduledPackages - item.pickedPackages, item.positions?.[0]?.availablePackages || 1)) }) },
+ selectPallet(item) { if (!item?.palletNo || item.scheduledPackages <= item.pickedPackages) return; this.error = null; this.successMessage = ''; updateFulfillmentDraft('dispatch', { palletCode: item.palletNo, dispatchItemId: item.dispatchItemId, cartonCode: '', packages: Math.max(1, Math.min(item.scheduledPackages - item.pickedPackages, item.positions?.[0]?.availablePackages || 1)) }) },
async submitPick() {
if (!this.canPick) return
- const payload = { dispatchNo: this.current.dispatchNo, palletNo: this.selectedItem.palletNo, positionId: this.selectedPosition.id, packages: Number(this.packages), productQuantity: 0 }
+ const pick = this.selectedReturnCarton ? { positionId: null, cartonMark: this.selectedReturnCarton.cartonMark, packages: 1, productQuantity: this.selectedReturnCarton.productQuantity } : { positionId: this.selectedPosition.id, packages: Number(this.packages), productQuantity: 0 }
+ const payload = { dispatchNo: this.current.dispatchNo, dispatchItemId: this.selectedItem.dispatchItemId, ...pick }
const key = fulfillmentIdempotencyKey('dispatch', 'pick', payload)
await this.run('提交排车拣货', async () => {
- await pickDispatchItem(this.current.dispatchNo, this.selectedItem.dispatchItemId, { positionId: this.selectedPosition.id, packages: Number(this.packages), productQuantity: 0, idempotencyKey: key.value })
- this.current = await getDispatchTask(this.current.dispatchNo); releaseFulfillmentKey('dispatch', key.signature); updateFulfillmentDraft('dispatch', { palletCode: '', packages: 1 }); this.successMessage = '本次排车拣货已记录'; playSuccessSound()
+ await pickDispatchItem(this.current.dispatchNo, this.selectedItem.dispatchItemId, { ...pick, idempotencyKey: key.value })
+ this.current = await getDispatchTask(this.current.dispatchNo); releaseFulfillmentKey('dispatch', key.signature); updateFulfillmentDraft('dispatch', { palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1 }); this.successMessage = '本次排车拣货已记录'; playSuccessSound()
})
},
async completePicking() {
diff --git a/src/pages/outbound/outbound.vue b/src/pages/outbound/outbound.vue
index ee1cf5c..60097e7 100644
--- a/src/pages/outbound/outbound.vue
+++ b/src/pages/outbound/outbound.vue
@@ -6,7 +6,7 @@
出库任务整托扫描托盘码,散箱扫描箱唛;扫描排车业务号会转入“排车拣货”
当前任务出库作业详情
-
+
任务号直接恢复;退货整托出库扫托盘码,散箱出库扫箱唛。
@@ -27,18 +27,18 @@
{{ current.taskNo || '—' }}{{ statusLabel(current.status) }}出库单 {{ current.outboundNo || '—' }} · {{ taskType(current) }}{{ plannedPackages }}计划箱数{{ pickedPackages }}已拣箱数{{ remainingPackages }}待拣箱数
-
+
{{ item.palletNo || '散件出库' }}{{ item.pickedPackages }}/{{ item.plannedPackages }} 箱
{{ lineBusinessCode(position) }}{{ position.inboundCode || '无入仓码' }} · 已拣 {{ position.pickedPackages }} 箱 · 可拣 {{ position.availablePackages }} 箱
- 整托出库请扫描托盘码 {{ item.palletNo }}
+ 整托出库请扫描托盘号 {{ item.palletNo }}
当前明细缺少可扫码库存业务码,已禁止拣货;请刷新或联系平台确认任务下发。
任务详情尚无可拣货明细请刷新;若持续为空,请联系平台确认出库货物是否已下发。
- 本次拣货:{{ lineBusinessCode(selectedPosition) }}拣货箱数最多 {{ selectedRemaining }} 箱
+ 本次拣货:{{ lineBusinessCode(selectedPosition) }}本来源计划出库 {{ selectedItem.returnSkuQuantity }} 件,请清点这些件数后确认;余数保留原箱。拣货箱数最多 {{ selectedRemaining }} 箱
完成出库任务尚有 {{ remainingPackages }} 箱未拣,不能完成出库。出库任务已完成,不可重复提交。全部计划货物已扫描核平,服务端将再次校验库存门禁。
@@ -46,7 +46,7 @@
废弃订单{{ discardCurrent.discardNo }}
扫描该废弃订单对应的托盘,再填写每个 SKU 的实际废弃数量。
- 托盘 {{ discardTask.palletNo }}{{ discardTask.status === 'COMPLETED' ? '已完成' : '待废弃' }}{{ sku.skuCode || '—' }}{{ sku.productName || '—' }} · 计划 {{ sku.plannedPackages }} 箱实际废弃数量最多 {{ sku.plannedPackages }} 箱
+ 托盘 {{ discardTask.palletNo }}{{ discardTask.status === 'COMPLETED' ? '已完成' : '待废弃' }}{{ sku.skuCode || '—' }}{{ sku.productName || '—' }} · 计划 {{ sku.plannedPackages }} 箱实际废弃数量最多 {{ sku.plannedPackages }} 箱废弃现场照片(至少 1 张)照片关联当前托盘,完成出库后可在废弃单详情查看。
{{ successMessage }}
@@ -58,7 +58,7 @@
-
+
diff --git a/src/pages/return-counting/return-counting.vue b/src/pages/return-counting/return-counting.vue
new file mode 100644
index 0000000..335375f
--- /dev/null
+++ b/src/pages/return-counting/return-counting.vue
@@ -0,0 +1,93 @@
+
+
+
+
+
+ {{ error.message }}错误码:{{ error.code }}请求号:{{ error.requestId }}跟踪号:{{ error.traceId }}
+
+ 有未完成的清点任务{{ activeTask.returnNo }} · {{ activeTask.palletCode }}
+ {{ row.palletCode }}{{ row.returnNo }}{{ row.boxes.length }} 箱 · {{ row.status === 'COUNTING' ? '清点中' : '待清点' }}{{ row.status === 'COUNTING' ? '继续清点' : '开始清点' }} ›
+ {{ scanResult ? '无需清点' : '暂无待清点的托盘签收任务' }}
+
+
+
+ {{ task.palletCode }}{{ task.status === 'COUNTING' ? '清点中' : '已完成' }}{{ task.returnNo }} · {{ task.boxes.length }} 箱{{ row.skuCode }}签收 {{ row.quantity }} / 已填 {{ row.actual }}
+
+ 第 {{ index + 1 }} 箱{{ box.cartonMark }}体积{{ boxVolume(box) }} cm³
+ {{ line.skuCode || '选择 SKU' }} ▾
+
+ 各箱 SKU 数量汇总必须与本托签收数量一致,确认后这些箱子才能换标、合箱或创建出库单。箱内清点已完成,可以继续换标或出库。
+
+
+
+
+
+
diff --git a/src/pages/return-packing/return-packing.vue b/src/pages/return-packing/return-packing.vue
index 835c67b..4a84e7f 100644
--- a/src/pages/return-packing/return-packing.vue
+++ b/src/pages/return-packing/return-packing.vue
@@ -2,18 +2,37 @@
- 添加退货单
+
+
+ 仓库现场直接合箱扫描 SKU 或箱号开始;系统会自动锁定客户和当前仓,跨客户货物不能合箱。
+ 平台合箱计划审核通过后推送按计划锁定的来源箱和数量作业,后续合箱规则不变。
- 没有匹配的已完成退货单或可用库存
+ 选择客户该 SKU 在多个客户有可用退货库存,请先选择客户
+ 没有匹配的已完成退货单或可用库存
-
- 已选退货单不同客户的退货单不能合箱;选择时会自动校验。
- 新箱 SKU 数量{{ item.skuCode }}{{ item.productNameZh || '商品名称未填写' }}可用 {{ item.availableQuantity }}装入数量
- 新箱箱规
+
+ {{ activePlan ? `合箱计划 ${activePlan.planNo}` : '客户散件库存' }}{{ activePlan ? '数量已由计划锁定,请分配到目标箱。' : '已锁定一个客户;可用数量来自该客户同仓全部散件库存,后台自动追溯来源退货单。' }}
+ 新箱 SKU 数量{{ item.skuCode }}{{ item.productNameZh || '商品名称未填写' }}可用 {{ item.availableQuantity }}装入数量
+
+
+ 目标箱 {{ index + 1 }}
+ {{ item.skuCode }}
+
+ 体积 {{ boxVolume(box) }} m³
+
+
- 退货合箱成功{{ result.label.cartonMark || result.cartonMark }}来源退货单:{{ result.label.sourceReturnNo || '—' }}箱规:{{ sizeText(result.label) }}{{ item.skuCode }}{{ item.quantity }} 件{{ item.productNameZh || '商品名称未填写' }}
+ 合箱完成 · {{ result.labels.length }} 箱
+
+ {{ label.cartonMark }}来源退货单:{{ label.sourceReturnNo }}
+ {{ item.skuCode }} · {{ item.quantity }} 件
+
+
+
+
+
{{ error.message }}错误码:{{ error.code }}requestId:{{ error.requestId }}traceId:{{ error.traceId }}
@@ -22,40 +41,99 @@
')[0].replace(/^\s*impo
function setup(confirm) {
const calls = []
const uni = { showModal(options) { calls.push(options); options.success({ confirm }) }, redirectTo(options) { calls.push(options) } }
- const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'uni', body)({}, {}, {}, uni)
+ const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'uni', body)({}, {}, {}, {}, uni)
const context = { busy: false, stage: 'receive', taskCode: 'PREVIOUS', error: null, candidates: [{ relabelNo: 'RL-ACTIVE', taskStatus: 'PROCESSING', claimedByCurrentOperator: true }] }
context.promptActiveTask = component.methods.promptActiveTask.bind(context)
return { calls, context, open: () => component.methods.openExecution.call(context, { relabelNo: 'RL-OTHER', taskStatus: 'AVAILABLE' }) }
@@ -34,7 +34,7 @@ test('cancel stays on list and preserves existing task selection', async () => {
test('new relabel task asks whether to start before it can be claimed', async () => {
const calls = []
const uni = { showModal(options) { calls.push(options); options.success({ confirm: false }) } }
- const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'uni', body)({}, {}, {}, uni)
+ const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'uni', body)({}, {}, {}, {}, uni)
const context = { busy: false }
const confirmed = await component.methods.confirmStartTask.call(context, { relabelNo: 'RL-NEW' })
diff --git a/tests/relabel-destination.test.mjs b/tests/relabel-destination.test.mjs
index 2884823..0a73723 100644
--- a/tests/relabel-destination.test.mjs
+++ b/tests/relabel-destination.test.mjs
@@ -6,7 +6,8 @@ const page = readFileSync(new URL('../src/pages/relabel/relabel.vue', import.met
test('PDA 箱唛换标明确展示每箱的新标签和 FBA 目的仓', () => {
assert.match(page, /item\.cartonMark[^\n]+item\.fbaCartonMark/)
- assert.match(page, /FBA 目的仓:\{\{ item\.destinationFbaWarehouseCode/)
+ assert.match(page, /class="destination-summary"[^\n]+FBA 目的仓:[^\n]+v-for="destination in fbaDestinations"/)
+ assert.match(page, /v-if="fbaDestinations.length > 1"[^\n]+item\.destinationFbaWarehouseCode/)
assert.match(page, /confirmedItem\?\.destinationFbaWarehouseCode \|\| item\.destinationFbaWarehouseCode/)
assert.match(page, /请贴 FBA 箱唛[^`]+FBA 目的仓:\$\{this\.lastCarton\.destinationFbaWarehouseCode/s)
})
diff --git a/tests/relabel-error.test.mjs b/tests/relabel-error.test.mjs
new file mode 100644
index 0000000..2abf2af
--- /dev/null
+++ b/tests/relabel-error.test.mjs
@@ -0,0 +1,12 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { isRelabelFundsBlocked, relabelErrorMessage } from '../src/utils/relabel-error.js'
+
+test('wallet block receives actionable Chinese guidance without version-conflict recovery', () => {
+ const error = { code: 'A0409', message: 'Relabel funds must be frozen before processing' }
+ assert.equal(isRelabelFundsBlocked(error), true)
+ assert.match(relabelErrorMessage(error), /后台检查客户余额或授信/)
+ assert.doesNotMatch(relabelErrorMessage(error), /刷新任务版本/)
+ assert.equal(isRelabelFundsBlocked({ code: 'A0409', message: 'Task version changed' }), false)
+ assert.equal(relabelErrorMessage({ message: 'Task version changed' }), 'Task version changed')
+})
diff --git a/tests/relabel-pallet-measurement.test.mjs b/tests/relabel-pallet-measurement.test.mjs
new file mode 100644
index 0000000..943c891
--- /dev/null
+++ b/tests/relabel-pallet-measurement.test.mjs
@@ -0,0 +1,35 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+const source = await readFile(new URL('../src/components/RelabelPalletWork.vue', import.meta.url), 'utf8')
+const script = source.split('')[0].replace(/^import .*$/gm, '').replace('export default', 'return')
+function setup() {
+ const calls = []
+ const component = new Function('ScanTargetInput', 'measureRelabelPallet', 'warehouseIdempotencyKey', 'releaseWarehouseKey', script)(
+ {}, async payload => { calls.push(payload); return [{ palletCode: payload.palletCode, volumeCbm: 0.25, grossWeightKg: payload.grossWeightKg }] },
+ () => ({ value: 'stable-key', signature: 'signature' }), () => {})
+ const value = { ...component.data(), task: { relabelNo: 'RL-1' }, $emit() {} }
+ for (const [key, method] of Object.entries(component.methods)) value[key] = method.bind(value)
+ return { component, value, calls }
+}
+test('pallet measurement submits actual dimensions, weight and current stock version and refreshes results', async () => {
+ const { value, calls } = setup()
+ await value.saveMeasurement({ palletCode: 'NEW', stockVersion: 'position:version:count', lengthCm: '100', widthCm: '50', heightCm: '50', grossWeightKg: '40' })
+ assert.deepEqual(calls, [{ relabelNo: 'RL-1', palletCode: 'NEW', stockVersion: 'position:version:count', lengthCm: 100, widthCm: 50, heightCm: 50, grossWeightKg: 40, idempotencyKey: 'stable-key' }])
+ assert.equal(value.measurements[0].volumeCbm, 0.25)
+ assert.equal(value.busy, false)
+ await value.saveMeasurement({ palletCode: 'NEW', lengthCm: 0, widthCm: 50, heightCm: 50, grossWeightKg: 40 })
+ assert.equal(calls.length, 1)
+ assert.match(value.error, /有效/)
+})
+test('only whole physical pallet quantities offer original pallet reuse', () => {
+ const { component } = setup()
+ const canRetain = component.computed.canRetainOriginal
+ const ctx = { source: { totalPackages: 10 }, selectedSource: {}, plannedPackages: 10, isReturnBulk: false, unboundSourceMode: false }
+ assert.equal(canRetain.call(ctx), true)
+ ctx.plannedPackages = 4
+ assert.equal(canRetain.call(ctx), false)
+ ctx.plannedPackages = 10;ctx.isReturnBulk = true
+ assert.equal(canRetain.call(ctx), false)
+})
diff --git a/tests/relabel-pallet-work.test.mjs b/tests/relabel-pallet-work.test.mjs
index ca1f068..6b489af 100644
--- a/tests/relabel-pallet-work.test.mjs
+++ b/tests/relabel-pallet-work.test.mjs
@@ -14,6 +14,7 @@ function context() {
] } }
for (const [key, method] of Object.entries(component.methods)) value[key] = method.bind(value)
for (const [key, method] of Object.entries(component.computed)) Object.defineProperty(value, key, { get: method.bind(value) })
+ value.confirmBindingChoice = async () => true
value.selectSource({ positionId: '1', palletId: '20', palletCode: 'SOURCE', movablePackages: 2, cartonMarks: ['A', 'B', 'C'] })
return value
}
@@ -58,7 +59,8 @@ test('pallet comparison tolerates mixed API versions and canonical code formatti
test('quantity disassembly has no extra carton scanner', () => {
assert.doesNotMatch(source, /relabel-move-carton/)
assert.match(source, /actualSource:/)
- assert.match(source, /仍归属/)
+ assert.match(source, /来源已确认:.*尚未拆出.*绑定成功后才保存拆托明细/)
+ assert.match(source, /this\.message = `托盘 \$\{this\.target\.palletCode\} 已绑定`/)
})
test('source pallet list is concise and hides rows with no movable packages', () => {
@@ -70,7 +72,8 @@ test('source pallet list is concise and hides rows with no movable packages', ()
assert.deepEqual(value.operableSourceRows.map(row => row.palletCode), ['PALLET-1'])
assert.match(source, />托盘)
assert.match(source, />刷新)
- assert.match(source, /托盘号:\{\{ row\.palletCode \}\} · 入仓码:\{\{ row\.inboundCode \}\}/)
+ assert.match(source, /class="source-caption">托盘号<\/text>\{\{ row\.palletCode \}\}/)
+ assert.doesNotMatch(template, /入仓码|row\.inboundCode/)
assert.match(source, /可拆\{\{ isReturnBulk \? '件数' : '箱数' \}\}:\{\{ row\.movablePackages \}\}/)
assert.doesNotMatch(template, /刷新来源托盘/)
assert.doesNotMatch(template, /按原入仓单拆托/)
@@ -104,10 +107,75 @@ test('target confirmation submits binding directly, with no second submit button
value.bind = async () => { binds++ }
await value.confirmTargetAndBind()
assert.equal(binds, 1)
+ assert.match(source, /@submit="confirmTargetAndBind"/)
assert.match(source, /@tap="confirmTargetAndBind"/)
assert.doesNotMatch(source, /@tap="bind"/)
})
+test('mixed carton scan alone explains missing SKU and does not bind; completing SKU allows binding', async () => {
+ const value = context()
+ value.task.items = [{ cartonMark: 'A', operationType: 'CARTON_AND_SKU', expectedPackages: 1, relabelledPackages: 0, cartonProcessedPackages: 1, skuProcessedPackages: 0, destinationFbaWarehouseCode: 'ONT8' }]
+ value.source = { palletCode: 'SOURCE' }; value.targetCode = 'TARGET'; value.quantity = '1'
+ let message = ''; let binds = 0
+ value.showBindingMessage = (title, content) => { message = content }
+ value.resolve = async () => { value.target = { palletCode: 'TARGET' } }
+ value.bind = async () => { binds++ }
+ await value.confirmTargetAndBind()
+ assert.match(message, /SKU 贴标登记/)
+ assert.equal(binds, 0)
+ assert.equal(value.target, null)
+ value.task.items[0].skuProcessedPackages = 1
+ value.task.items[0].relabelledPackages = 1
+ assert.equal(value.labelBlockReason, '')
+ await value.confirmTargetAndBind()
+ assert.equal(binds, 1)
+})
+
+test('source scan asks before accepting a listed pallet and rejects unknown pallets', async () => {
+ const value = context()
+ value.sourceRows = [{ positionId: '1', palletCode: 'SOURCE', movablePackages: 2 }]
+ value.sourceCode = 'SOURCE'
+ let prompts = 0
+ value.resolve = async () => { value.source = { palletCode: 'SOURCE' } }
+ value.confirmBindingChoice = async () => { prompts++; return false }
+ await value.confirmSourceScan()
+ assert.equal(value.source, null)
+ value.confirmBindingChoice = async () => { prompts++; return true }
+ await value.confirmSourceScan()
+ assert.equal(value.source.palletCode, 'SOURCE')
+ value.sourceCode = 'OTHER'
+ value.showBindingMessage = () => {}
+ value.resolve = async () => assert.fail('unknown pallet must not resolve')
+ await value.confirmSourceScan()
+ assert.equal(value.source, null)
+ assert.equal(prompts, 2)
+})
+
+test('declining target confirmation does not move stock', async () => {
+ const value = context()
+ value.quantity = '1'; value.source = { palletId: '20', palletCode: 'SOURCE' }; value.targetCode = 'TARGET'
+ value.resolve = async () => { value.target = { palletId: '30', palletCode: 'TARGET' } }
+ value.confirmBindingChoice = async () => false
+ value.bind = async () => assert.fail('cancelled target must not bind')
+ await value.confirmTargetAndBind()
+ assert.equal(value.target, null)
+})
+
+test('simplified split automatically fills a batch and counts saved bindings without repeats', async () => {
+ const value = context()
+ value.task.items = ['A', 'B', 'C'].map(cartonMark => ({ cartonMark, operationType: 'CARTON_LABEL', expectedPackages: 1, relabelledPackages: 1, destinationFbaWarehouseCode: 'ONT8' }))
+ value.task.history = [{ action: 'PALLET_BIND', detail: JSON.stringify({ cartonMarks: ['A'], targetPallets: ['OLD-TARGET'] }) }]
+ assert.equal(value.requiredSplitPackages, 3)
+ assert.equal(value.splitPackages, 1)
+ value.quantity = '1'; value.marks = []; value.source = { palletId: '20', palletCode: 'SOURCE' }; value.targetCode = 'TARGET'
+ value.resolve = async () => { value.target = { palletId: '30', palletCode: 'TARGET' } }
+ value.bind = async () => { assert.deepEqual(value.marks, ['B']); assert.equal(value.readyToBind, true) }
+ await value.confirmTargetAndBind()
+ assert.doesNotMatch(template, /按箱数填入本批换标箱|选择本批新目的仓|本批确认|@tap="toggleMark/)
+ value.task.history.push({ action: 'REWORK_ARCHIVE', detail: '{}' })
+ assert.equal(value.splitPackages, 0)
+})
+
test('missing source scan shows modal and never submits a binding', async () => {
const value = context(); value.quantity = '1'; value.fillBatch(); value.targetCode = 'TARGET'
let message = ''
diff --git a/tests/relabel-quick-sku.test.mjs b/tests/relabel-quick-sku.test.mjs
index 89df0a0..35de5cb 100644
--- a/tests/relabel-quick-sku.test.mjs
+++ b/tests/relabel-quick-sku.test.mjs
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const source = await readFile(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
const body = source.split('')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
-const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', body)({}, {}, {})
+const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', body)({}, {}, {}, {})
function context() {
const value = { current: { reviewedAt: null, history: [], items: [{ itemId: '1', operationType: 'CARTON_AND_SKU', expectedPackages: 1, skippedPackages: 0, exceptionPackages: 0, skuProcessedPackages: 0, cartonProcessedPackages: 1, skuItems: [{ skuCode: 'OLD', newSkuCode: 'NEW', quantity: 3 }, { skuCode: 'OTHER', newSkuCode: 'NEW2', quantity: 2 }] }] }, taskTab: 'sku' }
for (const name of ['skuDone', 'skuDetails', 'remaining']) value[name] = component.methods[name].bind(value)
@@ -24,6 +24,35 @@ test('normal SKU input is always visible in SKU tab and never requests the new l
assert.match(source, /matches.length > 1/)
assert.match(source, /scanRelabelSku/)
})
+
+test('return SKU follows ordinary single scan and requires explicit choice when the same SKU spans boxes', async () => {
+ const calls = []
+ let value
+ const subject = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'scanRelabelSku', 'warehouseIdempotencyKey', 'releaseWarehouseKey', 'uni', body)(
+ {}, {}, {}, {}, async (no, payload) => { calls.push(payload); return value.current },
+ () => ({ value: 'KEY', signature: 'SIG' }), () => {}, { showModal: options => options.complete() }
+ )
+ value = context()
+ Object.assign(value, { busy: false, quickSku: '', skuChoices: [], verifiedReturnCarton: '', returnCartonCode: '',
+ async run(action, work) { await work() }, applyCurrent(task) { this.current = task },
+ setError(error) { throw error }
+ })
+ for (const name of ['submitSku', 'commitSku']) value[name] = subject.methods[name].bind(value)
+ value.current.relabelNo = 'RL'; value.current.version = 3
+ value.current.items = [{ itemId: '1', returnCartonId: 'R1', cartonMark: 'BOX-A', operationType: 'SKU_LABEL', expectedPackages: 1, relabelledPackages: 0, skippedPackages: 0, exceptionPackages: 0, skuItems: [{ skuCode: 'OLD', newSkuCode: 'NEW', quantity: 20 }] }]
+ await value.submitSku('OLD')
+ assert.deepEqual(calls, [{ itemId: '1', cartonMark: 'BOX-A', newSkuCode: 'NEW', quantity: 20, skuIndex: 0, oldSku: 'OLD', expectedVersion: 3, idempotencyKey: 'KEY' }])
+ calls.length = 0
+ value.current.items.push({ ...value.current.items[0], itemId: '2', returnCartonId: 'R2', cartonMark: 'BOX-B' })
+ await value.submitSku('OLD')
+ assert.equal(calls.length, 0)
+ assert.equal(value.skuChoices.length, 2)
+ await value.commitSku(value.skuChoices[1])
+ assert.equal(calls.length, 1)
+ assert.equal(calls[0].itemId, '2')
+ assert.equal(calls[0].cartonMark, 'BOX-B')
+ assert.doesNotMatch(source.split('')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
+function setup(confirm = false) {
+ const calls = [], errors = []
+ const uni = { showModal(options) { options.success({ confirm }) } }
+ const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'requiresReturnPack', 'returnPackFacts', 'completeReturnRelabelSku', 'warehouseIdempotencyKey', 'releaseWarehouseKey', 'uni', body)({}, {}, {}, {}, helpers.requiresReturnPack, helpers.returnPackFacts, async (no, payload) => { calls.push(payload); return value.current }, () => ({ value: 'key', signature: 'signature' }), () => {}, uni)
+ const item = (id, mark) => ({ itemId: id, returnCartonId: id, cartonMark: mark, returnQuantity: 10, operationType: 'RETURN_BULK_SKU_LABEL', skuCode: 'SKU', newSkuCode: 'TARGET', expectedPackages: 4, relabelledPackages: 0, skippedPackages: 0, exceptionPackages: 0 })
+ const value = { current: { relabelNo: 'RL', version: 1, items: [item('1', 'BOX-A'), item('2', 'BOX-B')], history: [] }, busy: false, allProcessed: true, remark: '', photoFileIds: ['photo'], returnCartonCode: '', verifiedReturnCarton: '', returnSkuCode: '', bulkQuantities: {}, returnTargetLabels: {}, setError(error) { errors.push(error.message) }, applyCurrent(result) { this.current = result }, async run(action, work) { await work() } }
+ for (const [name, fn] of Object.entries(component.methods)) if (!value[name]) value[name] = fn.bind(value)
+ for (const name of ['isReturnTask', 'isReturnBulk', 'returnBulkSources', 'returnBulkSkuGroups', 'requiresPalletBinding', 'palletTask', 'completionBlockReason']) Object.defineProperty(value, name, { get: component.computed[name].bind(value) })
+ return { value, calls, errors }
+}
+test('SKU alone cannot register or allocate unscanned sources', async () => {
+ const { value, calls, errors } = setup()
+ value.verifyReturnSku('SKU'); assert.match(errors.pop(), /先确认来源/)
+ value.verifyReturnCarton('BOX-A'); value.verifyReturnSku('SKU')
+ assert.deepEqual(value.returnBulkSources.map(item => item.cartonMark), ['BOX-A'])
+ value.returnTargetLabels['2'] = 'TARGET'; value.bulkQuantities['2'] = '4'
+ await value.completeBulkSku(value.current.items[1]); assert.equal(calls.length, 0)
+})
+test('quantity is explicit, integral and source bounded; wrong target cannot write', async () => {
+ const { value, calls } = setup(); value.verifyReturnCarton('BOX-A'); value.verifyReturnSku('SKU')
+ const item = value.current.items[0]; value.returnTargetLabels['1'] = 'TARGET'
+ for (const quantity of ['', '1.5', '5', '0']) { value.bulkQuantities['1'] = quantity; await value.completeBulkSku(item) }
+ assert.equal(calls.length, 0)
+ value.bulkQuantities['1'] = '2'; value.returnTargetLabels['1'] = 'WRONG'; await value.completeBulkSku(item); assert.equal(calls.length, 0)
+ value.returnTargetLabels['1'] = 'TARGET'; await value.completeBulkSku(item)
+ assert.deepEqual(calls, [{ itemId: '1', cartonMark: 'BOX-A', skuCode: 'SKU', quantity: 2, expectedVersion: 1, idempotencyKey: 'key' }])
+})
+test('all action needs confirmation and rescanning clears quantity and target', async () => {
+ for (const confirm of [false, true]) { const { value } = setup(confirm); value.verifyReturnCarton('BOX-A'); await value.fillAllReturn(value.current.items[0]); assert.equal(value.bulkQuantities['1'], confirm ? '4' : undefined); value.verifyReturnCarton('BOX-B'); assert.deepEqual(value.bulkQuantities, {}); assert.deepEqual(value.returnTargetLabels, {}) }
+})
+test('normal return SKU rejects incomplete approved target before server scan', async () => {
+ const { value, errors } = setup(); const item = { ...value.current.items[0], operationType: 'SKU_LABEL', expectedPackages: 1, skuItems: [{ skuCode: 'SKU', newSkuCode: 'TARGET', quantity: 3 }] }
+ value.current.items = [item]; value.pendingSkuRows = [{ item, sku: item.skuItems[0], index: 0 }]
+ item.skuItems[0].newSkuCode = ''; value.quickSku = 'SKU'; await value.commitSku(value.pendingSkuRows[0]); assert.match(errors.pop(), /申请件数缺失/)
+})
+test('packing uses SKU pieces instead of box count and preserves both sources', () => {
+ const task = { items: [{ returnCartonId: '1', cartonMark: 'A', operationType: 'SKU_LABEL', expectedPackages: 1, returnQuantity: 8, skuItems: [{ skuCode: 'SKU', quantity: 3 }] }, { returnCartonId: '2', cartonMark: 'B', operationType: 'SKU_LABEL', expectedPackages: 1, returnQuantity: 2, skuItems: [{ skuCode: 'SKU', quantity: 2 }] }] }
+ assert.deepEqual(helpers.returnDeductions(task), [{ cartonMark: 'A', skuCode: 'SKU', quantity: 3 }, { cartonMark: 'B', skuCode: 'SKU', quantity: 2 }]); assert.equal(helpers.requiresReturnPack(task), true)
+ task.items[0].returnQuantity = 3; assert.equal(helpers.requiresReturnPack(task), false)
+})
+test('only truck requires return pallet binding and counts physical new boxes', () => {
+ const { value } = setup(); for (const method of [null, 'EXPRESS', 'EXPRESS_PICKUP', 'TRUCK']) { value.current.deliveryMethod = method; assert.equal(value.requiresPalletBinding, method === 'TRUCK') }
+ value.current.items.forEach(item => { item.cartonMark = 'NEW'; item.relabelledPackages = item.expectedPackages })
+ assert.equal(value.palletTask.items.length, 1); assert.equal(value.palletTask.items[0].expectedPackages, 1)
+})
+test('pack recovery ignores previous rework round', () => {
+ const task = { reviewedAt: 'ROUND2', history: [{ action: 'RETURN_PACK', detail: JSON.stringify({ round: 'INITIAL', cartonMark: 'OLD' }) }, { action: 'RETURN_PACK', detail: JSON.stringify({ round: 'ROUND2', cartonMark: 'NEW' }) }] }
+ assert.deepEqual(helpers.returnPackFacts(task).map(row => row.cartonMark), ['NEW'])
})
-test('return relabel pallet binding validates pieces while binding physical carton marks', () => {
- assert.match(palletWork, /isReturnBulk\(\).*RETURN_BULK_SKU_LABEL/)
- assert.match(palletWork, /count === packages && this\.completedItems\(\)\.length === this\.eligibleItems\.length && this\.marks\.length === this\.eligibleItems\.length/)
- assert.match(palletWork, /this\.isReturnBulk \? this\.eligibleItems : this\.eligibleItems\.slice\(0, count\)/)
- assert.match(page, /requiredMarks[\s\S]*some\(\(mark\) => !marks\.has\(mark\)\)/)
-})
-
-test('return relabel mode works by SKU remaining quantity without carton-mark scanning', () => {
- assert.match(page, /current\?\.operationType === 'RETURN_BULK_SKU_LABEL'/)
- assert.match(page, /退货贴标 SKU 明细/)
- assert.match(page, /name="returnSku"[\s\S]*@submit="verifyReturnSku"/)
- assert.match(page, /activeTarget === 'return-sku'[\s\S]*this\.verifyReturnSku\(value\)/)
- assert.match(page, /verifiedReturnSkuKey !== group\.key/)
- assert.match(page, /Number\.isSafeInteger\(quantity\)[\s\S]*quantity > group\.remaining/)
- assert.match(page, /returnBulkSkuGroups/)
- assert.match(page, /来源托盘拆箱数量/)
- assert.match(page, /登记本次贴标.*group\.remaining/)
- assert.match(page, /completeReturnRelabelSkuTotal/)
- assert.match(page, /taskTab === 'carton' && !isReturnBulk/)
-})
-
-test('new relabel pallet is immediately copied into the target scan field', () => {
- assert.match(palletWork, /this\.targetCode = this\.newPallet\.palletCode \|\| ''/)
- assert.match(palletWork, /退货换标无需扫描箱唛/)
-})
-
-test('return relabel distinguishes a whole pallet from a partial unpack', () => {
- assert.match(palletWork, /wholeSource\(\).*plannedPackages === Number\(this\.selectedSource\.stockPackages/)
- assert.match(palletWork, /eligibleItems\(\).*this\.isReturnBulk \? this\.sourceItems\(\) : this\.completedItems\(\)/)
- assert.match(palletWork, /this\.selectedDestination = this\.sourceItems\(\)\[0\]/)
- assert.match(palletWork, /this\.completedItems\(\)\.length !== this\.eligibleItems\.length/)
- assert.match(palletWork, /贴标数量等于本托盘全部/)
- assert.match(palletWork, /贴标数量少于本托盘库存/)
- assert.match(palletWork, /v-if="!isReturnBulk"[\s\S]*name="relabel-source"/)
- assert.match(palletWork, /await this\.run\(async \(\) => \{ const value = await resolvePallet\(row\.palletCode\)/)
+test('partial return completion requires current-round independent box then truck binding and photo', () => {
+ const { value } = setup()
+ value.current.items.forEach(item => { item.relabelledPackages = item.expectedPackages })
+ value.current.relabelledPackages = 8
+ value.current.deliveryMethod = 'EXPRESS'
+ assert.match(value.completionBlockReason, /独立新箱/)
+ value.current.history.push({ action: 'RETURN_PACK', detail: JSON.stringify({ round: 'INITIAL', cartonMark: 'NEW' }) })
+ value.current.items.forEach(item => { item.cartonMark = 'NEW'; item.returnQuantity = 8 })
+ assert.equal(value.completionBlockReason, '')
+ value.current.deliveryMethod = 'TRUCK'
+ assert.match(value.completionBlockReason, /组托/)
+ value.current.history.push({ action: 'PALLET_BIND', detail: JSON.stringify({ round: 'INITIAL', cartonMarks: ['NEW'] }) })
+ assert.equal(value.completionBlockReason, '')
+ value.photoFileIds = []
+ assert.match(value.completionBlockReason, /照片/)
})
diff --git a/tests/relabel-return-carton-mixed.test.mjs b/tests/relabel-return-carton-mixed.test.mjs
new file mode 100644
index 0000000..e0827e3
--- /dev/null
+++ b/tests/relabel-return-carton-mixed.test.mjs
@@ -0,0 +1,54 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+const page = await readFile(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
+const helper = await readFile(new URL('../src/utils/return-relabel.js', import.meta.url), 'utf8')
+const { returnDeductions, requiresReturnPack, returnPackFacts } = await import('data:text/javascript;base64,' + Buffer.from(helper).toString('base64'))
+const body = page.split('')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
+const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'requiresReturnPack', 'returnPackFacts', body)({}, {}, {}, {}, requiresReturnPack, returnPackFacts)
+test('return carton and normal SKU use single scans; explicit source scan is reserved for bulk quantities', () => {
+ assert.doesNotMatch(page, /退货箱唛换标必须先确认同一来源包裹\/箱/)
+ assert.doesNotMatch(page, /v-if="isReturnTask && taskTab !== 'carton'"/)
+ assert.match(page, /v-if="isReturnBulk" class="section-gap">先扫描来源/)
+ assert.match(page, /this.activeTarget = value === 'carton' \? 'quick-carton'/)
+ assert.match(page, /this\.taskTab === 'carton' \? 'quick-carton' : 'quick-sku'/)
+})
+
+test('whole carton and full SKU relabel hide new box dimensions; partial SKU keeps isolation form', () => {
+ const value = { isReturnTask: true, current: { items: [{ returnCartonId: '1', cartonMark: 'C1', operationType: 'CARTON_LABEL', returnQuantity: 15 }], history: [] } }
+ assert.equal(component.computed.showReturnPack.call(value), false)
+ value.current.items[0].operationType = 'SKU_LABEL'
+ value.current.items[0].skuItems = [{ skuCode: 'A', quantity: 15 }]
+ assert.equal(component.computed.showReturnPack.call(value), false)
+ value.current.items[0].skuItems[0].quantity = 5
+ assert.equal(component.computed.showReturnPack.call(value), true)
+ assert.match(page, /ReturnRelabelPackWork v-if="showReturnPack"/)
+})
+
+test('one return carton scan saves label stage without a prior source scan; unrelated carton is rejected', async () => {
+ const calls = []
+ const item = { itemId: 'I1', cartonMark: 'C1', operationType: 'CARTON_LABEL', fbaCartonMark: 'FBA1' }
+ const result = { relabelNo: 'RL1', version: 1, items: [{ ...item, relabelledPackages: 1 }] }
+ const subject = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'scanRelabelCarton', 'warehouseIdempotencyKey', 'releaseWarehouseKey', 'uni', body)(
+ {}, {}, {}, {},
+ async (no, payload) => { calls.push({ no, payload }); return result },
+ () => ({ value: 'KEY', signature: 'SIG' }), () => {},
+ { showModal: options => options.complete() }
+ )
+ const value = {
+ busy: false, current: { relabelNo: 'RL1', version: 0, items: [item] }, verifiedReturnCarton: '',
+ run: async (action, work) => work(), applyCurrent(task) { this.current = task },
+ setError(error) { this.error = error.message }
+ }
+ await subject.methods.submitCarton.call(value, ' C1\n')
+ assert.deepEqual(calls, [{ no: 'RL1', payload: { cartonMark: 'C1', expectedVersion: 0, idempotencyKey: 'KEY' } }])
+ assert.equal(value.current.version, 1)
+ assert.equal(value.lastCarton.fbaCartonMark, 'FBA1')
+ await subject.methods.submitCarton.call(value, 'OTHER')
+ assert.equal(calls.length, 1)
+ assert.match(value.error, /不属于本单/)
+})
+test('mixed return carton keeps one physical package while SKU deductions use pieces', () => {
+ const task={items:[{returnCartonId:'1',cartonMark:'P1',operationType:'CARTON_AND_SKU',returnQuantity:5,skuItems:[{skuCode:'S',quantity:5}]}]}
+ assert.deepEqual(returnDeductions(task),[{cartonMark:'P1',skuCode:'S',quantity:5}])
+})
diff --git a/tests/relabel-return-pack.test.mjs b/tests/relabel-return-pack.test.mjs
new file mode 100644
index 0000000..1d3c11c
--- /dev/null
+++ b/tests/relabel-return-pack.test.mjs
@@ -0,0 +1,31 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+const helper = await readFile(new URL('../src/utils/return-relabel.js', import.meta.url), 'utf8')
+const { requiresReturnPack, returnDeductions, returnPackFacts, returnSelections } = await import('data:text/javascript;base64,' + Buffer.from(helper).toString('base64'))
+const source = await readFile(new URL('../src/components/ReturnRelabelPackWork.vue', import.meta.url), 'utf8')
+const body = source.split('')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
+function setup() {
+ const calls = [], events = []
+ const component = new Function('requiresReturnPack','returnDeductions','returnPackFacts','returnSelections','packReturnRelabelCartons','warehouseIdempotencyKey','releaseWarehouseKey',body)(requiresReturnPack,returnDeductions,returnPackFacts,returnSelections,async(no,payload)=>{calls.push(payload);return {relabelNo:no}},()=>({value:'stable',signature:'s'}),()=>{})
+ const item = (mark,done) => ({returnCartonId:mark,cartonMark:mark,operationType:'SKU_LABEL',expectedPackages:1,relabelledPackages:done,skippedPackages:0,exceptionPackages:0,skuItems:[{skuCode:'SKU',quantity:3}]})
+ const value = {...component.data(),task:{relabelNo:'RL',items:[item('A',1),item('B',0)],history:[]},$emit:(...args)=>events.push(args)}
+ for(const[name,fn]of Object.entries(component.methods))value[name]=fn.bind(value)
+ for(const[name,fn]of Object.entries(component.computed))Object.defineProperty(value,name,{get:fn.bind(value)})
+ return {value,calls,events}
+}
+test('packing cannot run with an unfinished source, missing measurement, skipped or exception fact',async()=>{
+ const {value,calls}=setup(); await value.pack();assert.equal(calls.length,0)
+ value.dimensions={length:'20',width:'30',height:'40',weight:'2'}
+ assert.equal(value.ready,false);value.task.items[1].relabelledPackages=1;assert.equal(value.ready,true)
+ value.task.items[0].skippedPackages=1;assert.equal(value.ready,false);value.task.items[0].skippedPackages=0
+ value.task.items[0].exceptionPackages=1;assert.equal(value.ready,false)
+})
+test('pack sends every source with exact SKU piece deductions and generated target; saved history prevents repeat',async()=>{
+ const {value,calls,events}=setup();value.task.items[1].relabelledPackages=1;value.dimensions={length:'20',width:'30',height:'40',weight:'2'}
+ await value.pack()
+ assert.deepEqual(calls,[{sourceCartonMarks:['A','B'],targetCartonMark:null,sourceDeductions:[{cartonMark:'A',skuCode:'SKU',quantity:3},{cartonMark:'B',skuCode:'SKU',quantity:3}],length:20,width:30,height:40,weight:2,idempotencyKey:'stable'}])
+ assert.ok(events.some(row=>row[0]==='saved'))
+ value.task.history=[{action:'RETURN_PACK',detail:JSON.stringify({round:'INITIAL',cartonMark:'NEW',quantity:6,label:{printableHtml:''}})}]
+ assert.equal(value.ready,false);assert.equal(value.facts[0].cartonMark,'NEW')
+})
diff --git a/tests/return-box-merge-work.test.mjs b/tests/return-box-merge-work.test.mjs
new file mode 100644
index 0000000..5a4a1ec
--- /dev/null
+++ b/tests/return-box-merge-work.test.mjs
@@ -0,0 +1,90 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import vm from 'node:vm'
+import { parse, compileScript, compileTemplate } from 'vue/compiler-sfc'
+
+const filename = 'src/components/ReturnBoxMergeWork.vue'
+const source = readFileSync(new URL('../' + filename, import.meta.url), 'utf8')
+const { descriptor } = parse(source)
+test('box merge component compiles both its script and template', () => {
+ compileScript(descriptor, { id: 'box-merge' })
+ assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename, id: 'box-merge' }).errors, [])
+})
+function instance() {
+ const boxes = [{cartonMark:'A',customerId:'C1',items:[{skuCode:'SKU1',quantity:2},{skuCode:'SKU2',quantity:3}]},{cartonMark:'B',customerId:'C1',items:[{skuCode:'SKU1',quantity:2}]}]
+ const component = vm.runInNewContext(descriptor.script.content.replace(/^import .*$/gm, '').replace('export default', 'component ='), { ScanTargetInput: {}, ReturnLabelConfirmation: {}, component: null, scanReturnCarton: async mark => boxes.find(box => box.cartonMark === mark) })
+ const work = { ...component.data(), plan: {customerId:'C1',sources:[{cartonMark:'A',items:[{skuCode:'SKU1',quantity:2},{skuCode:'SKU2',quantity:3}]},{cartonMark:'B',items:[{skuCode:'SKU1',quantity:2}]}]}, $emit(){} }
+ for (const [name, method] of Object.entries(component.methods)) work[name] = method.bind(work)
+ work.save = () => {}
+ return {work,component}
+}
+test('new plan requires scanning each source and preserves locked SKU quantities', async () => {
+ const {work,component}=instance(); work.choose('NEW')
+ assert.equal(work.sources.length,0)
+ assert.equal(work.active,'source')
+ await work.acceptScan('A'); await work.acceptScan('B')
+ work.setDeduction('A','SKU1',1); work.setDeduction('A','SKU2',2); work.setDeduction('B','SKU1',2)
+ work.dimensions={length:10,width:20,height:30,weight:1}
+ assert.equal(work.sources.length,2)
+ assert.equal(work.plannedItems().reduce((sum,item)=>sum+item.quantity,0),7)
+ assert.equal(component.computed.canComplete.call(work),true)
+})
+
+test('planned source carton marks are visible before scanning', () => {
+ const {work, component} = instance()
+ work.plan = { sourceCartonMarks: ['A', 'B'], sources: [] }
+ assert.deepEqual(component.computed.plannedSourceRows.call(work).map(box => box.cartonMark), ['A', 'B'])
+})
+test('existing box uses another box as target and never removes target inventory', async () => {
+ const {work,component}=instance(); work.choose('EXISTING'); await work.scanSource('A'); await work.scanTarget('B')
+ assert.equal(work.target.cartonMark,'B'); assert.equal(work.sources.length,1)
+ work.setDeduction('A','SKU1',1)
+ assert.equal(work.plannedItems().length,2)
+ assert.equal(work.target.items[0].quantity,2)
+ assert.equal(component.computed.canComplete.call(work),true)
+ work.choose('NEW'); assert.equal(work.target,null); assert.equal(work.sources.length,0)
+ assert.equal(work.plannedItems().length,0)
+})
+
+test('existing source can be replaced before merging, echoing the mark and clearing old deductions and target', async () => {
+ const {work,component}=instance()
+ work.plan=null
+ work.choose('EXISTING'); await work.scanSource('A'); await work.scanTarget('B')
+ work.setDeduction('A','SKU1',1)
+ assert.equal(work.sourceCode,'A')
+ work.sourceCode='B'
+ assert.equal(component.computed.canComplete.call(work),false)
+ await work.scanSource('B')
+ assert.equal(work.sources.length,1)
+ assert.equal(work.sources[0].cartonMark,'B')
+ assert.equal(work.sourceCode,'B')
+ assert.equal(work.target,null)
+ assert.equal(work.targetCode,'')
+ assert.equal(work.plannedItems().length,0)
+ await work.scanTarget('A'); work.setDeduction('B','SKU1',1)
+ assert.equal(component.computed.canComplete.call(work),true)
+})
+
+test('BOX plan requires every source and accumulates multiple scanned sources', async () => {
+ const {work,component}=instance()
+ work.plan.mode='BOX'
+ work.plan.sources.push({cartonMark:'C',items:[{skuCode:'SKU1',quantity:4}]})
+ work.choose('EXISTING'); await work.scanSource('A'); await work.scanTarget('B')
+ assert.equal(component.computed.canComplete.call(work),false)
+ await work.scanSource('C')
+ assert.equal(work.sources.length,2)
+ assert.equal(component.computed.canComplete.call(work),true)
+ assert.equal(work.plannedItems().reduce((sum,item)=>sum+item.quantity,0),9)
+})
+
+test('a directly scanned box survives switching between existing and new targets', async () => {
+ const {work}=instance()
+ const box=work.plan.sources[0];work.plan=null;work.initialSource=box
+ work.choose('EXISTING')
+ assert.equal(work.sources[0].cartonMark,'A');assert.equal(work.sourceCode,'A')
+ work.choose('NEW')
+ assert.equal(work.sources[0].items.length,2)
+ work.choose('EXISTING')
+ assert.equal(work.sourceCode,'A');assert.equal(work.active,'target')
+})
diff --git a/tests/return-counting-api.test.mjs b/tests/return-counting-api.test.mjs
new file mode 100644
index 0000000..a3946c0
--- /dev/null
+++ b/tests/return-counting-api.test.mjs
@@ -0,0 +1,43 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { listReturnCountTasks, scanReturnCountCarton, scanReturnCountTasks, getCurrentReturnCountTask, beginReturnCountTask, confirmReturnCount } from '../src/api/return-counting.js'
+import { session } from '../src/stores/session.js'
+
+test('counting preserves box contents and retry key without accepting client warehouse scope', async () => {
+ session.apiBase = '/api'; session.token = 'counting-test'
+ const calls = []
+ globalThis.uni = { request(options) { calls.push(options); options.success({ statusCode: 200, data: { code: '200', data: { status: 'PENDING' } }, header: {} }) } }
+ await listReturnCountTasks({ page: 2, keyword: ' RC#2-1 ' })
+ await scanReturnCountCarton('RC#2-1')
+ const request = { idempotencyKey: 'count-retry', boxes: [{ cartonMark: 'RC#2-1', items: [{ skuCode: 'SKU-1', quantity: 3 }] }, { cartonMark: 'RC#2-2', items: [{ skuCode: 'SKU-1', quantity: 7 }] }] }
+ await confirmReturnCount('RR/1', request)
+ assert.deepEqual(calls.map(row => [row.method, row.url]), [
+ ['GET', '/api/pda/v1/last-leg/return-inventory/counting/tasks?page=2&size=10&keyword=RC%232-1'],
+ ['GET', '/api/pda/v1/last-leg/return-inventory/counting/cartons/RC%232-1'],
+ ['POST', '/api/pda/v1/last-leg/return-inventory/counting/receipts/RR%2F1']
+ ])
+ assert.deepEqual(calls[2].data, request)
+ assert.ok(calls.every(row => row.header.Authorization === 'Bearer counting-test'))
+})
+
+test('unified scan supports box and pallet codes and preserves the no-count empty result', async () => {
+ session.apiBase = '/api'; session.token = 'counting-test'
+ const urls = []
+ globalThis.uni = { request(options) { urls.push(options.url); options.success({ statusCode: 200, data: { code: '200', data: [] }, header: {} }) } }
+ assert.deepEqual(await scanReturnCountTasks(' RC#2-1 '), [])
+ assert.deepEqual(await scanReturnCountTasks('PLT/1'), [])
+ assert.deepEqual(urls, ['/api/pda/v1/last-leg/return-inventory/counting/scan/RC%232-1', '/api/pda/v1/last-leg/return-inventory/counting/scan/PLT%2F1'])
+})
+
+test('current task preserves null and begin uses the operator-scoped claim route', async () => {
+ session.apiBase = '/api'; session.token = 'counting-test'
+ const calls = []
+ globalThis.uni = { request(options) { calls.push(options); options.success({ statusCode: 200, data: { code: '200', data: options.method === 'GET' ? null : { receiptNo: 'RR/1', status: 'COUNTING' } }, header: {} }) } }
+ assert.equal(await getCurrentReturnCountTask(), null)
+ assert.equal((await beginReturnCountTask('RR/1')).status, 'COUNTING')
+ assert.deepEqual(calls.map(row => [row.method, row.url]), [
+ ['GET', '/api/pda/v1/last-leg/return-inventory/counting/current'],
+ ['POST', '/api/pda/v1/last-leg/return-inventory/counting/receipts/RR%2F1/begin']
+ ])
+ assert.equal(calls[1].data, undefined)
+})
diff --git a/tests/return-counting-page.test.mjs b/tests/return-counting-page.test.mjs
new file mode 100644
index 0000000..577c226
--- /dev/null
+++ b/tests/return-counting-page.test.mjs
@@ -0,0 +1,187 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import { runInNewContext } from 'node:vm'
+
+const source = readFileSync(new URL('../src/pages/return-counting/return-counting.vue', import.meta.url), 'utf8')
+const script = source.match(/