diff --git a/src/modules/overseas-warehouse/api/relabelQuantities.ts b/src/modules/overseas-warehouse/api/relabelQuantities.ts index 77827ef..8be26bc 100644 --- a/src/modules/overseas-warehouse/api/relabelQuantities.ts +++ b/src/modules/overseas-warehouse/api/relabelQuantities.ts @@ -1,12 +1,19 @@ import { apiRequest } from '@/api/http' export interface RelabelQuantity { + sourcePalletCount?: number | string | null + targetPalletCount?: number | string | null + sourceUnitCounts?: Record | null labelPieceCount: number | string | null cartonCount: number | string | null skuCount: number | string duplicateCarton: boolean } export interface RelabelQuantityTotals { + knownSourcePalletCount?: number | string + unknownSourcePalletOrders?: number | string + knownTargetPalletCount?: number | string + unknownTargetPalletOrders?: number | string orderCount: number | string skuCount: number | string knownLabelPieceCount: number | string diff --git a/src/modules/overseas-warehouse/api/returnOrders.ts b/src/modules/overseas-warehouse/api/returnOrders.ts index e08bfca..2a3ce3b 100644 --- a/src/modules/overseas-warehouse/api/returnOrders.ts +++ b/src/modules/overseas-warehouse/api/returnOrders.ts @@ -2,6 +2,10 @@ import { apiRequest, apiBlobRequest } from '@/api/http' import type { PageResult } from './warehouseServices' export interface ReturnOrderSummary { + remainingQuantity?: number + receivedBoxCount?: number + receivedParcelCount?: number + receivedPalletCount?: number relabelApplications?: Array<{ relabelNo: string; status: string; quantity: number; quantityUnit?: string; createdAt: string }> returnNo: string; overseasWarehouseId: string; warehouseContactName?: string; warehouseContactPhone?: string; customerReferenceNo?: string; status: string skuTypes: number; expectedQuantity: number; receivedQuantity: number; needRelabel: boolean; relabelNo?: string; relabelStatus?: string; createdAt: string @@ -30,6 +34,13 @@ export interface ReturnOrderCreate { export function listReturnOrders(params: Record) { return apiRequest>({ method: 'GET', url: '/merchant/v1/last-leg/return-orders', params }) } +export interface ReturnOrderStatistics { + totals: { orderCount: number; skuTypes: number; expectedQuantity: number; receivedQuantity: number; remainingQuantity: number; receivedBoxCount: number; receivedParcelCount: number; receivedPalletCount: number } + pageDimensions: Array<{ returnNo: string; remainingQuantity: number; receivedBoxCount: number; receivedParcelCount: number; receivedPalletCount: number }> +} +export function getReturnOrderStatistics(params: Record) { + return apiRequest({ method: 'GET', url: '/merchant/v1/last-leg/return-orders/statistics', params }) +} export function getReturnOrder(returnNo: string) { return apiRequest({ method: 'GET', url: `/merchant/v1/last-leg/return-orders/${encodeURIComponent(returnNo)}` }) } diff --git a/src/modules/overseas-warehouse/api/transferQuantities.ts b/src/modules/overseas-warehouse/api/transferQuantities.ts new file mode 100644 index 0000000..1b315a2 --- /dev/null +++ b/src/modules/overseas-warehouse/api/transferQuantities.ts @@ -0,0 +1,23 @@ +import { apiRequest } from '@/api/http' + +export interface TransferQuantityTotals { + orderCount: number | string + declaredPieceCount: number | string | null + knownDeclaredPieceCount: number | string + unknownDeclaredOrderCount: number | string + receivedPieceCount: number | string | null + knownReceivedPieceCount: number | string + unknownReceivedOrderCount: number | string + declaredCartonCount: number | string + receivedCartonCount: number | string + receivedPalletCount: number | string + availableCartonCount: number | string + shippedCartonCount: number | string + remainingPalletCount: number | string + shippedPalletCount: number | string +} + +/** 客户范围由会话决定,汇总使用本次列表完整筛选。 */ +export function getTransferQuantities(params: Record) { + return apiRequest({ method: 'GET', url: '/merchant/v1/last-leg/warehouse-transfers/quantities', params }) +} diff --git a/src/modules/overseas-warehouse/api/warehouseServices.ts b/src/modules/overseas-warehouse/api/warehouseServices.ts index 1f2d938..e7a2da2 100644 --- a/src/modules/overseas-warehouse/api/warehouseServices.ts +++ b/src/modules/overseas-warehouse/api/warehouseServices.ts @@ -25,8 +25,8 @@ export interface RelabelDetail { tailOrders?: Array<{ id: string; orderNo: string; overseasWarehouseId: string; packageCount: number; status: string }> completionEvidence?: { taskId: string; photoFileIds: string[]; completionRemark?: string; reviewerName?: string; reviewRemark?: string; completedAt?: string; customerConfirmedAt?: string } } -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; customerConfirmedAt?: string } -export interface DiscardBox { cartonId: string; cartonMark: string; palletNo?: string; skus: Array<{ skuCode: string; productName: string; availablePackages: number }> } +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; customerConfirmedAt?: string; skuCodes: string[]; skuCount: number; requestedPackages: number; actualPackages: number | null } +export interface DiscardBox { cartonId: string; cartonMark: string; palletNo?: string; skus: Array<{ skuCode: string; productName: string; availablePackages: number; availablePieces?: number | null }> } export interface DiscardCandidate { cartons?: DiscardBox[]; cartonSelectionSupported?: boolean; cartonSelectionBlockReason?: string; orderId: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON' | 'INVENTORY'; inventoryPositionId?: string; palletNo?: string; 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 diff --git a/src/modules/overseas-warehouse/components/RelabelQuantityPanel.vue b/src/modules/overseas-warehouse/components/RelabelQuantityPanel.vue index 8eb7fe3..4f1131d 100644 --- a/src/modules/overseas-warehouse/components/RelabelQuantityPanel.vue +++ b/src/modules/overseas-warehouse/components/RelabelQuantityPanel.vue @@ -26,6 +26,8 @@ watch(() => [props.query, props.relabelNo], async () => { {{ data.orderCount }} 票{{ data.skuCount }} 种 SKU 贴标件数:{{ dimension(Number(data.knownLabelPieceCount), Number(data.unknownPieceOrders), '件') }} 箱数:{{ dimension(Number(data.knownCartonCount), Number(data.unknownCartonOrders), '箱') }} + 原托盘:{{ dimension(Number(data.knownSourcePalletCount ?? 0), Number(data.unknownSourcePalletOrders ?? data.orderCount), '托') }} + 本轮目标托盘:{{ dimension(Number(data.knownTargetPalletCount ?? 0), Number(data.unknownTargetPalletOrders ?? data.orderCount), '托') }}

存在重复箱唛关联,箱数已去重,贴标件数待核查。

数量加载中… diff --git a/src/modules/overseas-warehouse/components/TransferQuantityPanel.vue b/src/modules/overseas-warehouse/components/TransferQuantityPanel.vue new file mode 100644 index 0000000..e44d8d9 --- /dev/null +++ b/src/modules/overseas-warehouse/components/TransferQuantityPanel.vue @@ -0,0 +1,30 @@ + + + diff --git a/src/modules/overseas-warehouse/utils/discardDocuments.test.mjs b/src/modules/overseas-warehouse/utils/discardDocuments.test.mjs index 7b11798..69a3108 100644 --- a/src/modules/overseas-warehouse/utils/discardDocuments.test.mjs +++ b/src/modules/overseas-warehouse/utils/discardDocuments.test.mjs @@ -49,3 +49,14 @@ test('相同仓位重复关联只计一次,零值与未知可用量不进入 assert.equal(groupDiscardDocuments([source, source])[0].availablePackages, 2) for (const availablePackages of [0, null, undefined, 'invalid']) assert.equal(groupDiscardDocuments([{ ...source, availablePackages }]).length, 0) }) + +test('相同来源的混箱分片保留全部SKU和实箱,且不修改接口原始对象', () => { + const a = { ...returned('1'), sourceType: 'INVENTORY', inventoryPositionId: 'P', skus: [{ skuCode: 'A', availablePackages: 1 }] } + const b = { ...a, skus: [{ skuCode: 'B', availablePackages: 1 }], cartons: [{ ...a.cartons[0], skus: [{ skuCode: 'B', availablePackages: 1 }] }] } + const doc = groupDiscardDocuments([a, b])[0] + assert.equal(doc.availablePackages, 1) + assert.equal(doc.sources.length, 1) + assert.deepEqual(doc.sources[0].skus.map(sku => sku.skuCode), ['A', 'B']) + assert.deepEqual(doc.sources[0].cartons[0].skus.map(sku => sku.skuCode), ['A', 'B']) + assert.equal(a.skus.length, 1) +}) diff --git a/src/modules/overseas-warehouse/utils/discardDocuments.ts b/src/modules/overseas-warehouse/utils/discardDocuments.ts index c094516..f378944 100644 --- a/src/modules/overseas-warehouse/utils/discardDocuments.ts +++ b/src/modules/overseas-warehouse/utils/discardDocuments.ts @@ -7,15 +7,28 @@ export interface DiscardDocument extends DiscardCandidate { /** 下拉按来源单据和操作仓汇总;原始库存身份保留给选箱与提交。 */ export function groupDiscardDocuments(candidates: DiscardCandidate[]): DiscardDocument[] { const groups = new Map() - const identities = new Set() + const identities = new Map() for (const candidate of candidates) { const available = Number(candidate.availablePackages) if (candidate.availablePackages == null || !Number.isFinite(available) || available <= 0) continue // 按真实来源身份去重;缺身份的旧接口行仍独立保留,不合并不同库存。 const identity = candidate.sourceType === 'RETURN_CARTON' ? candidate.returnCartonId : candidate.inventoryPositionId || candidate.orderId const sourceKey = identity ? JSON.stringify([candidate.overseasWarehouseId, candidate.sourceType, identity]) : null - if (sourceKey && identities.has(sourceKey)) continue - if (sourceKey) identities.add(sourceKey) + const existing = sourceKey ? identities.get(sourceKey) : undefined + if (existing) { + // 同一仓位的分片响应合并 SKU 和箱,重复关联不会丢掉箱内商品。 + existing.skus = [...new Map([...existing.skus, ...candidate.skus].map(sku => [sku.skuCode, sku])).values()] + const cartons = new Map((existing.cartons || []).map(box => [box.cartonId, box])) + for (const box of candidate.cartons || []) { + const old = cartons.get(box.cartonId) + cartons.set(box.cartonId, old ? { ...old, skus: [...new Map([...old.skus, ...box.skus].map(sku => [sku.skuCode, sku])).values()] } : { ...box }) + } + existing.cartons = [...cartons.values()] + existing.availablePackages = Math.max(Number(existing.availablePackages), available) + continue + } + const source = { ...candidate, skus: [...candidate.skus], cartons: candidate.cartons?.map(box => ({ ...box, skus: [...box.skus] })) } + if (sourceKey) identities.set(sourceKey, source) const documentNo = candidate.inboundOrderNo || candidate.inboundCode const key = JSON.stringify([candidate.overseasWarehouseId, candidate.sourceType, documentNo || candidate.orderId]) let document = groups.get(key) @@ -23,7 +36,7 @@ export function groupDiscardDocuments(candidates: DiscardCandidate[]): DiscardDo document = { ...candidate, orderId: key, sources: [], availablePackages: 0 } groups.set(key, document) } - document.sources.push(candidate) + document.sources.push(source) } for (const document of groups.values()) { const boxes = new Set() diff --git a/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs b/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs index d397cc0..a9def86 100644 --- a/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs +++ b/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs @@ -6,6 +6,45 @@ import ts from 'typescript' const view = readFileSync(new URL('./ReturnOrdersView.vue', import.meta.url), 'utf8') const api = readFileSync(new URL('../api/returnOrders.ts', import.meta.url), 'utf8') +function listHarness(list, summary) { + const state = { query: { page: 1, size: 1, skuCode: 'FILTER' }, loading: { value: false }, listError: { value: '' }, statistics: { value: undefined }, rows: { value: [] }, total: { value: 0 } } + const source = view.slice(view.indexOf('async function load()'), view.indexOf('\nfunction changePage(')) + const js = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText + const load = new Function(...Object.keys(state), 'listReturnOrders', 'getReturnOrderStatistics', `let listRequest=0; ${js}; return load`)(...Object.values(state), list, summary) + return { ...state, load } +} + +test('全筛选统计使用服务器总计,分页行只按单号映射载体,不重算SKU', async () => { + const calls = [] + const totals = { orderCount: 3, skuTypes: 3, expectedQuantity: 19, receivedQuantity: 14, remainingQuantity: 5 } + const h = listHarness(async p => { calls.push(p); return { total: '3', records: [{ returnNo: 'RT-1', receivedQuantity: 8 }] } }, async p => { calls.push(p); return { totals, pageDimensions: [{ returnNo: 'RT-1', receivedParcelCount: 1, receivedBoxCount: 1 }] } }) + await h.load() + assert.deepEqual(h.statistics.value, totals) + assert.equal(h.rows.value[0].receivedParcelCount, 1) + h.query.page = 2 + await h.load() + assert.equal(h.statistics.value.receivedQuantity, 14) + assert.deepEqual(calls[2], calls[3]) + assert.equal(calls[3].page, 2) +}) + +test('统计失败清空旧列表和汇总,并忽略较旧查询的迟到结果', async () => { + let fail = false, finish + const h = listHarness(async () => ({ total: 1, records: [{ returnNo: 'RT-1' }] }), async p => { + if (fail) throw Error('统计服务不可用') + if (p.page === 1) return new Promise(resolve => { finish = resolve }) + return { totals: { orderCount: 2 }, pageDimensions: [] } + }) + const old = h.load(); h.query.page = 2; await h.load() + finish({ totals: { orderCount: 999 }, pageDimensions: [] }); await old + assert.equal(h.statistics.value.orderCount, 2) + fail = true; await h.load() + assert.equal(h.statistics.value, undefined) + assert.deepEqual(h.rows.value, []) + assert.equal(h.listError.value, '统计服务不可用') + assert.equal(h.loading.value, false) +}) + function submitHarness(failure) { const source = view.slice(view.indexOf('async function submit()'), view.indexOf('\nfunction statusType(')) const state = { requests: [], loads: 0, routes: [], errors: [], successes: [] } diff --git a/src/modules/overseas-warehouse/views/ReturnOrdersView.vue b/src/modules/overseas-warehouse/views/ReturnOrdersView.vue index 81fc567..a51b649 100644 --- a/src/modules/overseas-warehouse/views/ReturnOrdersView.vue +++ b/src/modules/overseas-warehouse/views/ReturnOrdersView.vue @@ -11,6 +11,7 @@ import { getMerchantWarehouses, type MerchantWarehouse } from '@/modules/first-l import { createIdempotencyKey } from '@/utils/idempotencyKey' import { createReturnOrder, downloadReturnPhoto, getReturnOrder, listReturnOrders, updateReturnOrder, type ReturnOrderDetail, type ReturnOrderSummary, type ReturnReceipt } from '../api/returnOrders' import ReviewRejectionNotice from '../components/ReviewRejectionNotice.vue' +import { getReturnOrderStatistics, type ReturnOrderStatistics } from '../api/returnOrders' import { getReturnInventoryLabel } from '../api/returnInventory' import { formatReturnVolume, receiptBoxDetails, receiptPhysicalDetails, receiptPrintableBoxCount, receiptTrackingNo } from '../utils/returnReceiptDisplay' @@ -40,6 +41,9 @@ const printableBoxCount = computed(() => receiptPrintableBoxCount(printableRecei const photoUrls = reactive>({}) const rows = ref([]) const total = ref(0) +const statistics = ref() +const listError = ref('') +let listRequest = 0 const operationWarehouses = ref([]) const operationWarehousesLoading = ref(false) const operationWarehousesError = ref('') @@ -62,8 +66,13 @@ const tableColumns: DataTableColumn[] = [ { key: 'actions', label: '操作', slotName: 'actions', width: 220, fixed: 'left' }, { prop: 'returnNo', label: '退货单号', minWidth: 190 }, { prop: 'customerReferenceNo', label: '客户参考号', minWidth: 160 }, { key: 'warehouse', label: '操作仓', slotName: 'warehouse', minWidth: 200 }, { key: 'relabel', label: '换标', slotName: 'relabel', width: 100 }, - { prop: 'skuTypes', label: 'SKU 种类', width: 105 }, { prop: 'expectedQuantity', label: '预报数量', width: 110 }, - { prop: 'receivedQuantity', label: '实收数量', width: 110 }, { key: 'status', label: '状态', slotName: 'status', width: 150 }, + { prop: 'skuTypes', label: 'SKU 种类', width: 105 }, { prop: 'expectedQuantity', label: '预报(件)', width: 110 }, + { prop: 'receivedQuantity', label: '实收(件)', width: 110 }, + { prop: 'remainingQuantity', label: '待收(件)', width: 110 }, + { prop: 'receivedBoxCount', label: '签收箱数', width: 110 }, + { prop: 'receivedParcelCount', label: '签收包裹数', width: 115 }, + { prop: 'receivedPalletCount', label: '签收托数', width: 110 }, + { key: 'status', label: '状态', slotName: 'status', width: 150 }, { key: 'relabelApplications', label: '换标子单', slotName: 'relabelApplications', minWidth: 340 }, { key: 'createdAt', label: '创建时间', slotName: 'createdAt', width: 180 }, ] @@ -147,9 +156,21 @@ async function openEdit(returnNo: string) { function addItem() { form.items.push(emptySku()) } function removeItem(index: number) { if (form.items.length > 1) form.items.splice(index, 1) } async function load() { + const request = ++listRequest loading.value = true - try { const response = await listReturnOrders({ ...query, returnNo: query.returnNo || undefined, trackingNo: query.trackingNo || undefined, skuCode: query.skuCode || undefined, status: query.status || undefined }); rows.value = response.records; total.value = Number(response.total); if (!Number.isFinite(total.value)) total.value = response.records.length } - finally { loading.value = false } + listError.value = ''; statistics.value = undefined + try { + const params = { ...query, returnNo: query.returnNo || undefined, trackingNo: query.trackingNo || undefined, skuCode: query.skuCode || undefined, status: query.status || undefined } + const [response, summary] = await Promise.all([listReturnOrders(params), getReturnOrderStatistics(params)]) + if (request !== listRequest) return + const dimensions = new Map(summary.pageDimensions.map(row => [row.returnNo, row])) + rows.value = response.records.map(row => ({ ...row, ...dimensions.get(row.returnNo) })) + total.value = Number(response.total); statistics.value = summary.totals + } catch (error) { + if (request !== listRequest) return + rows.value = []; total.value = 0 + listError.value = error instanceof Error ? error.message : '退货列表及汇总加载失败' + } finally { if (request === listRequest) loading.value = false } } function changePage(page: number) { if (page === query.page) return @@ -323,6 +344,11 @@ onBeforeUnmount(clearPhotoUrls)