diff --git a/src/modules/overseas-warehouse/api/warehouseServices.ts b/src/modules/overseas-warehouse/api/warehouseServices.ts index b9c35da..0a4e357 100644 --- a/src/modules/overseas-warehouse/api/warehouseServices.ts +++ b/src/modules/overseas-warehouse/api/warehouseServices.ts @@ -28,6 +28,7 @@ export interface RelabelDetail { export interface DiscardSummary { id: string; discardNo: string; outboundNo?: string; inboundCodes: string; overseasWarehouseId: string; overseasWarehouseName: string; totalPackages: number; totalFee: number; currency: string; status: string; createdAt: string; completedAt?: string } export interface DiscardCandidate { orderId: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON'; returnCartonId?: string; cartonMark?: string; inboundCode: string; inboundOrderNo: string; overseasWarehouseId: string; overseasWarehouseName: string; availablePackages: number; skus: Array<{ skuCode: string; productName: string; availablePackages: number }> } export interface RelabelInventoryCandidate { + cartonSkuItems: Array<{ skuCode: string; quantity: number }>; cartonAvailablePackages: number | string skuConversions?: import('./returnInventory').ReturnSkuConversion[] stockQuantity?: number; inventoryStatus?: string; occupiedRelabelNo?: string; mergedIntoCartonMarks?: string[]; hasOutbound?: boolean inventoryBalanceId: string; inboundOrderId: string; inboundOrderNo?: string; inboundCode?: string diff --git a/src/modules/overseas-warehouse/utils/returnRelabelCandidates.test.mjs b/src/modules/overseas-warehouse/utils/returnRelabelCandidates.test.mjs new file mode 100644 index 0000000..81f6d73 --- /dev/null +++ b/src/modules/overseas-warehouse/utils/returnRelabelCandidates.test.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import ts from 'typescript' + +const source = readFileSync(new URL('./returnRelabelCandidates.ts', import.meta.url), 'utf8') +const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } }).outputText +const { returnRelabelCandidate } = await import(`data:text/javascript;base64,${Buffer.from(js).toString('base64')}`) +const converted = { + returnCartonId: '20', cartonMark: 'NEW-BOX', availablePackages: 0, skuItems: [], + cartonAvailablePackages: 1, cartonSkuItems: [{ skuCode: 'NEW-A', quantity: 10 }, { skuCode: 'NEW-B', quantity: 5 }], +} + +test('carton labeling selects the current box and both converted SKUs', () => { + const candidate = returnRelabelCandidate(converted, 'carton') + assert.equal(candidate.cartonMark, 'NEW-BOX') + assert.equal(candidate.availablePackages, 1) + assert.equal(candidate.skuCode, 'NEW-A,NEW-B') + assert.deepEqual(candidate.skuItems, converted.cartonSkuItems) +}) + +test('SKU and combined labeling still exclude completed SKU quantities', () => { + for (const mode of ['sku', 'carton_and_sku']) { + assert.equal(returnRelabelCandidate(converted, mode).availablePackages, 0) + assert.deepEqual(returnRelabelCandidate(converted, mode).skuItems, []) + } +}) + +test('an occupied box cannot be selected for carton labeling', () => { + assert.equal(returnRelabelCandidate({ ...converted, cartonAvailablePackages: 0 }, 'carton').availablePackages, 0) +}) diff --git a/src/modules/overseas-warehouse/utils/returnRelabelCandidates.ts b/src/modules/overseas-warehouse/utils/returnRelabelCandidates.ts new file mode 100644 index 0000000..6dace76 --- /dev/null +++ b/src/modules/overseas-warehouse/utils/returnRelabelCandidates.ts @@ -0,0 +1,11 @@ +import type { RelabelInventoryCandidate } from '../api/warehouseServices' + +export function returnRelabelCandidate(candidate: RelabelInventoryCandidate, mode: string): RelabelInventoryCandidate { + if (!candidate.returnCartonId || mode !== 'carton') return candidate + return { + ...candidate, + availablePackages: candidate.cartonAvailablePackages, + skuItems: candidate.cartonSkuItems, + skuCode: candidate.cartonSkuItems.map(item => item.skuCode).join(','), + } +} diff --git a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue index 2284e71..3f75140 100644 --- a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue +++ b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue @@ -7,6 +7,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/components/com import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue' import type { TableAction } from '@/components/common/tableActions' import { useAuthStore } from '@/modules/auth/public' +import { returnRelabelCandidate } from '../utils/returnRelabelCandidates' import { createDiscard, createRelabel, createTransfer, getDiscard, getRelabel, getTransfer, resubmitRelabel, getTransferCartonLabel, submitTransfer, updateTransfer, @@ -199,7 +200,8 @@ const selectedTransferInventory = ref('') const selectedTransferSourceOrderIds = ref([]) const isReturnRelabelSource = computed(() => props.service === 'labeling' && selectedRelabelInboundOrderId.value.startsWith('RETURN_ORDER:')) const selectedReturnOrderNo = computed(() => isReturnRelabelSource.value ? selectedRelabelInboundOrderId.value.slice('RETURN_ORDER:'.length) : '') -const selectedReturnCandidates = computed(() => relabelCandidates.value.filter(item => Boolean(item.returnCartonId) && returnSourceNos(item).includes(selectedReturnOrderNo.value))) +const currentRelabelCandidates = computed(() => relabelCandidates.value.map(item => returnRelabelCandidate(item, labelingMode.value))) +const selectedReturnCandidates = computed(() => currentRelabelCandidates.value.filter(item => Boolean(item.returnCartonId) && returnSourceNos(item).includes(selectedReturnOrderNo.value))) const availableReturnCandidates = computed(() => selectedReturnCandidates.value.filter(item => inventoryQuantity(item.availablePackages) > 0).map(item => ({ ...item, skuItems: (item.skuItems || []).filter(sku => inventoryQuantity(sku.quantity) > 0) }))) const returnBoxCandidates = computed(() => availableReturnCandidates.value.filter(item => item.sourceType === 'RETURN_CARTON')) const returnLooseCandidates = computed(() => availableReturnCandidates.value.filter(item => item.sourceType === 'RETURN_PARCEL')) @@ -222,7 +224,7 @@ function returnInventoryState(candidate: RelabelInventoryCandidate) { } const relabelInboundOptions = computed(() => { const grouped = new Map() - for (const candidate of relabelCandidates.value) { + for (const candidate of currentRelabelCandidates.value) { const sources = candidate.returnCartonId ? returnSourceNos(candidate) : [String(candidate.inboundOrderId)] for (const sourceNo of sources) { const value = candidate.returnCartonId ? 'RETURN_ORDER:' + sourceNo : sourceNo @@ -1419,11 +1421,10 @@ async function selectOperationWarehouse() { } function selectRelabelInventory(inboundOrderId: string) { const rows: CargoRow[] = [] - const selectedCandidates = relabelCandidates.value.filter((item) => { + const selectedCandidates = currentRelabelCandidates.value.filter((item) => { return item.returnCartonId ? returnSourceNos(item).includes(inboundOrderId.slice('RETURN_ORDER:'.length)) : String(item.inboundOrderId) === inboundOrderId }) if (inboundOrderId.startsWith('RETURN_ORDER:')) { - labelingMode.value = 'sku' for (const candidate of selectedCandidates) rows.push({ id: ++cargoSequence, sku: candidate.skuItems?.[0]?.skuCode || candidate.skuCode || '', name: candidate.productNameZh || '', included: false, quantity: 1, availableQuantity: 1, diff --git a/src/modules/overseas-warehouse/views/returnStockDisplay.test.mjs b/src/modules/overseas-warehouse/views/returnStockDisplay.test.mjs index f954180..8c17e95 100644 --- a/src/modules/overseas-warehouse/views/returnStockDisplay.test.mjs +++ b/src/modules/overseas-warehouse/views/returnStockDisplay.test.mjs @@ -7,7 +7,7 @@ const source = view.slice(view.indexOf('function returnSourceNos'), view.indexOf function setup(candidates) { const output = ts.transpileModule(source + '\nexports.options = relabelInboundOptions; exports.visible = visibleRelabelInboundOptions; exports.label = relabelOptionLabel', { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText const exports = {}, show = { value: false }, search = { value: '' }, selected = { value: '' } - new Function('exports', 'computed', 'relabelCandidates', 'showOccupiedRelabelInventory', 'relabelInventorySearch', 'selectedRelabelInboundOrderId', 'inventoryQuantity', output)(exports, fn => ({ get value() { return fn() } }), { value: candidates }, show, search, selected, value => Math.max(0, Number(value) || 0)) + new Function('exports', 'computed', 'currentRelabelCandidates', 'showOccupiedRelabelInventory', 'relabelInventorySearch', 'selectedRelabelInboundOrderId', 'inventoryQuantity', output)(exports, fn => ({ get value() { return fn() } }), { value: candidates }, show, search, selected, value => Math.max(0, Number(value) || 0)) return { ...exports, show, search, selected } } test('occupied return keeps stock facts but remains hidden when searched', () => {