4 Commits
Author SHA1 Message Date
ouyangxq d89830fe41 fix(order): show authoritative progress and billing in customer detail 2026-10-15 19:39:35 +08:00
liuyihe 4d7b6eb6dc 优化 2026-10-15 18:25:32 +08:00
liuyihe 5b320a3293 优化 2026-10-15 15:45:22 +08:00
liuyihe 63e169b051 优化 2026-10-14 17:41:04 +08:00
24 changed files with 751 additions and 155 deletions
@@ -151,6 +151,7 @@ export interface MerchantTransportFragment {
} }
export interface MerchantUnifiedOrderDetail { export interface MerchantUnifiedOrderDetail {
progress: MerchantOrderProgress
transportFragments: MerchantTransportFragment[] transportFragments: MerchantTransportFragment[]
summary: MerchantUnifiedOrderRow summary: MerchantUnifiedOrderRow
order: MerchantFirstLegOrderDetail order: MerchantFirstLegOrderDetail
@@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs'
import ts from 'typescript' import ts from 'typescript'
import * as vue from 'vue' import * as vue from 'vue'
import * as pinia from 'pinia' import * as pinia from 'pinia'
import { requireOrderProgress } from '../utils/orderProgress.ts'
function compile(path, dependencies) { function compile(path, dependencies) {
const source = readFileSync(new URL(path, import.meta.url), 'utf8') const source = readFileSync(new URL(path, import.meta.url), 'utf8')
@@ -31,10 +32,11 @@ function harness(t) {
vue, pinia, vue, pinia,
'@/modules/first-leg/api/firstLeg': api, '@/modules/first-leg/api/firstLeg': api,
'@/modules/first-leg/api/unifiedOrders': { '@/modules/first-leg/api/unifiedOrders': {
getMerchantUnifiedOrder: async () => ({ summary: { orderId: 'one', mainStatus: null }, order: { order: { id: 'one', status: behavior.status, version: behavior.version, receivingWarehouseId: 'warehouse', receivingWarehouseAddress: behavior.receivingWarehouseAddress } }, timeline: [] }), getMerchantUnifiedOrder: async () => ({ progress: { orderId: 'one', stage: 'none', partial: false }, summary: { orderId: 'one', mainStatus: null }, order: { order: { id: 'one', status: behavior.status, version: behavior.version, receivingWarehouseId: 'warehouse', receivingWarehouseAddress: behavior.receivingWarehouseAddress } }, timeline: [] }),
}, },
'@/modules/first-leg/utils/firstLegOrder': { canClientEditFirstLegOrder: status => status === 'DRAFT', getFirstLegStatusLabel: value => value }, '@/modules/first-leg/utils/firstLegOrder': { canClientEditFirstLegOrder: status => status === 'DRAFT', getFirstLegStatusLabel: value => value },
'@/modules/first-leg/utils/warehousePagination': {}, '@/modules/first-leg/utils/warehousePagination': {},
'@/modules/first-leg/utils/orderProgress': { requireOrderProgress },
}) })
const store = module.useFirstLegOrdersStore() const store = module.useFirstLegOrdersStore()
store.warehouses = [{ id: 'warehouse', name: '国内仓', warehouseType: 'DOMESTIC' }] store.warehouses = [{ id: 'warehouse', name: '国内仓', warehouseType: 'DOMESTIC' }]
@@ -8,6 +8,32 @@ const storeSource = readFileSync(new URL('./firstLegOrders.ts', import.meta.url)
const apiSource = readFileSync(new URL('../api/firstLeg.ts', import.meta.url), 'utf8') const apiSource = readFileSync(new URL('../api/firstLeg.ts', import.meta.url), 'utf8')
const unifiedApiSource = readFileSync(new URL('../api/unifiedOrders.ts', import.meta.url), 'utf8') const unifiedApiSource = readFileSync(new URL('../api/unifiedOrders.ts', import.meta.url), 'utf8')
test('detail fetches authoritative progress on direct entry and refresh, and clears stale progress on failure', async () => {
const functionSource = storeSource.slice(storeSource.indexOf(' async function loadDetail('), storeSource.indexOf(' async function loadWarehouseDetail('))
const progressSource = readFileSync(new URL('../utils/orderProgress.ts', import.meta.url), 'utf8')
const validationSource = progressSource.slice(progressSource.indexOf('export function requireOrderProgress(')).replace('export ', '')
const javascript = ts.transpileModule(`${validationSource}\n${functionSource}`, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
const id = '2110663764278312961'
const orderProgress = { value: {} }
let calls = 0
let response = { progress: { orderId: id, stage: 'scheduled', partial: true } }
const loadDetail = runInNewContext(`${javascript}; loadDetail`, {
orderProgress, warehouses: { value: [] },
getMerchantUnifiedOrder: async requested => { assert.equal(requested, id); calls++; return response },
mapUnifiedOrderDetail: detail => ({ id, detail }), upsertOrder: order => order,
})
await loadDetail(id)
assert.equal(orderProgress.value[id].stage, 'scheduled')
assert.equal(orderProgress.value[id].partial, true)
response = { progress: { orderId: id, stage: 'delivering', partial: false } }
await loadDetail(id)
assert.equal(calls, 2)
assert.equal(orderProgress.value[id].stage, 'delivering')
response = { progress: { orderId: 'another-order', stage: 'delivered', partial: false } }
await assert.rejects(loadDetail(id), /订单履约进度不完整/)
assert.equal(orderProgress.value[id], undefined)
})
test('renders estimated sailing from the current API without substituting actual departure', () => { test('renders estimated sailing from the current API without substituting actual departure', () => {
const mapper = storeSource.slice(storeSource.indexOf('function businessSummary('), storeSource.indexOf('\nconst unifiedActionLabels')) const mapper = storeSource.slice(storeSource.indexOf('function businessSummary('), storeSource.indexOf('\nconst unifiedActionLabels'))
const javascript = ts.transpileModule(mapper, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText const javascript = ts.transpileModule(mapper, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
@@ -32,6 +32,7 @@ import type { FirstLegPartySnapshot } from '@/modules/first-leg/data/firstLegPar
import type { FirstLegOrder, FirstLegOrderDraft, FirstLegOrderTimelineItem } from '@/modules/first-leg/types/firstLegOrder' import type { FirstLegOrder, FirstLegOrderDraft, FirstLegOrderTimelineItem } from '@/modules/first-leg/types/firstLegOrder'
import { canClientEditFirstLegOrder, getFirstLegStatusLabel } from '@/modules/first-leg/utils/firstLegOrder' import { canClientEditFirstLegOrder, getFirstLegStatusLabel } from '@/modules/first-leg/utils/firstLegOrder'
import { loadAllWarehouseRecords } from '@/modules/first-leg/utils/warehousePagination' import { loadAllWarehouseRecords } from '@/modules/first-leg/utils/warehousePagination'
import { requireOrderProgress } from '@/modules/first-leg/utils/orderProgress'
export type ClientWarehouseType = 'DOMESTIC' | 'FBA' | 'OVERSEAS' export type ClientWarehouseType = 'DOMESTIC' | 'FBA' | 'OVERSEAS'
export type ClientWarehouse = PlatformReceivingWarehouse & { export type ClientWarehouse = PlatformReceivingWarehouse & {
@@ -507,8 +508,12 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', ()
} }
async function loadDetail(id: string) { async function loadDetail(id: string) {
delete orderProgress.value[id]
const detail = await getMerchantUnifiedOrder(id) const detail = await getMerchantUnifiedOrder(id)
return upsertOrder(mapUnifiedOrderDetail(detail, warehouses.value)) const progress = requireOrderProgress(id, detail.progress)
const mapped = mapUnifiedOrderDetail(detail, warehouses.value)
orderProgress.value[id] = progress
return upsertOrder(mapped)
} }
async function loadWarehouseDetail(id: string) { async function loadWarehouseDetail(id: string) {
@@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs'
import ts from 'typescript' import ts from 'typescript'
import * as vue from 'vue' import * as vue from 'vue'
import * as pinia from 'pinia' import * as pinia from 'pinia'
import { requireOrderProgress } from '../utils/orderProgress.ts'
const source = readFileSync(new URL('./firstLegOrders.ts', import.meta.url), 'utf8') const source = readFileSync(new URL('./firstLegOrders.ts', import.meta.url), 'utf8')
const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
@@ -27,11 +28,12 @@ function harness(t, read) {
getMerchantOrderWorkbench: query => { calls.push(structuredClone(query)); return read(query) }, getMerchantOrderWorkbench: query => { calls.push(structuredClone(query)); return read(query) },
getMerchantUnifiedOrder: async id => { getMerchantUnifiedOrder: async id => {
details.push(id) details.push(id)
return { summary: row(id, 'DRAFT'), order: { order: { id, status: 'DRAFT', version: 7 } }, timeline: [] } return { progress: { orderId: id, stage: 'none', partial: false }, summary: row(id, 'DRAFT'), order: { order: { id, status: 'DRAFT', version: 7 } }, timeline: [] }
}, },
}, },
'@/modules/first-leg/utils/firstLegOrder': { getFirstLegStatusLabel: value => value }, '@/modules/first-leg/utils/firstLegOrder': { getFirstLegStatusLabel: value => value },
'@/modules/first-leg/utils/warehousePagination': { loadAllWarehouseRecords: async type => { warehouseCalls.push(type); return [] } }, '@/modules/first-leg/utils/warehousePagination': { loadAllWarehouseRecords: async type => { warehouseCalls.push(type); return [] } },
'@/modules/first-leg/utils/orderProgress': { requireOrderProgress },
} }
const exports = {} const exports = {}
new Function('require', 'exports', code)(id => { assert.ok(id in deps, id); return deps[id] }, exports) new Function('require', 'exports', code)(id => { assert.ok(id in deps, id); return deps[id] }, exports)
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { orderBillingMetric } from './orderDetailSummary.ts'
test('detail uses the published billing quantity without substituting physical gross weight', () => {
const billing = { inbound: { summary: { actualGrossWeightKg: 23, chargeableWeightKg: null } },
billingBasis: { chargeUnit: 'KG', chargeableQuantity: 17.0006, chargeableWeightKg: null } }
assert.deepEqual(orderBillingMetric(billing, null), { label: '计费重量', quantity: 17.0006, unit: 'kg' })
assert.equal(orderBillingMetric(billing, null).quantity.toLocaleString('zh-CN'), '17.001')
})
test('volume charging retains its real unit and missing billing does not invent a quantity', () => {
assert.deepEqual(orderBillingMetric({ billingBasis: { chargeUnit: 'CBM', chargeableQuantity: 0.102 } }, 23),
{ label: '计费体积', quantity: 0.102, unit: 'm³' })
assert.equal(orderBillingMetric(null, null).quantity, null)
assert.equal(orderBillingMetric(null, 0).quantity, 0)
})
@@ -0,0 +1,14 @@
import type { MerchantInboundBilling } from '../types/inboundBilling'
/** Published billing quantity and physical gross weight have different meanings. */
export function orderBillingMetric(billing: MerchantInboundBilling | null, measuredChargeableWeight: number | null) {
if (billing) {
const basis = billing.billingBasis
return basis.chargeUnit === 'KG'
? { label: '计费重量', quantity: basis.chargeableQuantity, unit: 'kg' }
: basis.chargeUnit === 'CBM'
? { label: '计费体积', quantity: basis.chargeableQuantity, unit: 'm³' }
: { label: '计费量', quantity: null, unit: '' }
}
return { label: '计费重量', quantity: measuredChargeableWeight, unit: 'kg' }
}
@@ -13,3 +13,13 @@ test('prototype queues preserve order and distinguish partial progress from comp
assert.equal(exports.orderProgressLabel({ stage: 'none', partial: false }), '—') assert.equal(exports.orderProgressLabel({ stage: 'none', partial: false }), '—')
assert.equal(exports.orderProgressLabel(), '—') assert.equal(exports.orderProgressLabel(), '—')
}) })
test('detail progress must belong to the requested order and retain partial fulfillment', () => {
const progress = { orderId: '2110663764278312961', stage: 'scheduled', partial: true }
assert.equal(exports.requireOrderProgress(progress.orderId, progress), progress)
assert.equal(exports.orderProgressLabel(exports.requireOrderProgress(progress.orderId, progress)), '部分已排车')
assert.throws(() => exports.requireOrderProgress('other-order', progress), /订单履约进度不完整/)
assert.throws(() => exports.requireOrderProgress(progress.orderId, undefined), /订单履约进度不完整/)
assert.throws(() => exports.requireOrderProgress(progress.orderId, { ...progress, stage: 'UNKNOWN' }), /订单履约进度不完整/)
assert.throws(() => exports.requireOrderProgress(progress.orderId, { ...progress, partial: null }), /订单履约进度不完整/)
})
@@ -18,3 +18,12 @@ export function orderProgressLabel(progress?: MerchantOrderProgress) {
const label = ORDER_QUEUES.find(q => q.key === progress.stage)?.label ?? '—' const label = ORDER_QUEUES.find(q => q.key === progress.stage)?.label ?? '—'
return `${progress.partial ? '部分' : ''}${label}` return `${progress.partial ? '部分' : ''}${label}`
} }
export function requireOrderProgress(orderId: string, progress: MerchantOrderProgress) {
if (!progress || String(progress.orderId) !== orderId
|| !['none', 'loaded', 'declared', 'cleared', 'scheduled', 'delivering', 'delivered'].includes(progress.stage)
|| typeof progress.partial !== 'boolean') {
throw new Error('订单履约进度不完整,请重新加载')
}
return progress
}
@@ -223,7 +223,8 @@ test('shows one unified progress line and C02 business summaries without detail
assert.match(source, /progressSummaryItems/) assert.match(source, /progressSummaryItems/)
assert.match(source, /summary\.preloadStatus/) assert.match(source, /summary\.preloadStatus/)
assert.match(source, /summary\.expectedPickCount/) assert.match(source, /summary\.expectedPickCount/)
assert.match(source, /summary\.deliveryTrackingNo/) assert.match(source, /label: '后续履约', value: orderProgressLabel\(store\.orderProgress\[order\.id\]\)/)
assert.doesNotMatch(source, /待海外同步/)
assert.match(progressSource, /FIRST_LEG_PROGRESS_STAGES/) assert.match(progressSource, /FIRST_LEG_PROGRESS_STAGES/)
assert.match(progressSource, /订单主节点与关联柜次统一展示/) assert.match(progressSource, /订单主节点与关联柜次统一展示/)
assert.match(progressSource, /履约进度/) assert.match(progressSource, /履约进度/)
@@ -60,6 +60,7 @@ import {
validateFirstLegOrderDraft, validateFirstLegOrderDraft,
} from '@/modules/first-leg/utils/firstLegOrder' } from '@/modules/first-leg/utils/firstLegOrder'
import { ORDER_QUEUES, orderProgressLabel } from '../utils/orderProgress' import { ORDER_QUEUES, orderProgressLabel } from '../utils/orderProgress'
import { orderBillingMetric } from '../utils/orderDetailSummary'
import type { OrderQueue } from '../api/unifiedOrders' import type { OrderQueue } from '../api/unifiedOrders'
import { validateProductImageFile } from '@/modules/first-leg/utils/productImage' import { validateProductImageFile } from '@/modules/first-leg/utils/productImage'
import { createPriceEstimateBreakdown } from '@/modules/first-leg/utils/priceEstimate' import { createPriceEstimateBreakdown } from '@/modules/first-leg/utils/priceEstimate'
@@ -603,8 +604,10 @@ const pagedDetailCargoItems = computed(() => {
const progressSummaryItems = computed(() => { const progressSummaryItems = computed(() => {
const order = detailOrder.value const order = detailOrder.value
if (!order || order.status === 'CANCELLED') return [] if (!order || order.status === 'CANCELLED') return []
const inbound = inboundBilling.value?.inbound.summary const billing = inboundBillingOrderId.value === order.id ? inboundBilling.value : null
const inbound = billing?.inbound.summary
const summary = order.businessSummary const summary = order.businessSummary
const billingMetric = orderBillingMetric(billing, summary.chargeableWeightKg)
const formatMetric = (value: number | null | undefined, unit = '') => value == null ? '待更新' : `${value.toLocaleString()}${unit ? ` ${unit}` : ''}` const formatMetric = (value: number | null | undefined, unit = '') => value == null ? '待更新' : `${value.toLocaleString()}${unit ? ` ${unit}` : ''}`
const preloadStatus = ({ PENDING: '待确认', CONFIRMING: '确认中', CONFIRMED: '已确认' } as Record<string, string>)[summary.preloadStatus] ?? summary.preloadStatus const preloadStatus = ({ PENDING: '待确认', CONFIRMING: '确认中', CONFIRMED: '已确认' } as Record<string, string>)[summary.preloadStatus] ?? summary.preloadStatus
const transportReference = [summary.vesselName, summary.voyageNo].filter(Boolean).join(' · ') const transportReference = [summary.vesselName, summary.voyageNo].filter(Boolean).join(' · ')
@@ -615,12 +618,12 @@ const progressSummaryItems = computed(() => {
{ label: '实际箱数', value: formatMetric(summary.totalPackageCount ?? inbound?.totalPackageCount, '箱') }, { label: '实际箱数', value: formatMetric(summary.totalPackageCount ?? inbound?.totalPackageCount, '箱') },
{ label: '实测毛重', value: formatMetric(summary.actualGrossWeightKg ?? inbound?.actualGrossWeightKg, 'kg') }, { label: '实测毛重', value: formatMetric(summary.actualGrossWeightKg ?? inbound?.actualGrossWeightKg, 'kg') },
{ label: '实测体积', value: formatMetric(summary.actualVolumeCbm ?? inbound?.actualVolumeCbm, 'm³') }, { label: '实测体积', value: formatMetric(summary.actualVolumeCbm ?? inbound?.actualVolumeCbm, 'm³') },
{ label: '预装清单', value: preloadStatus ? `${preloadStatus}${summary.preloadItemCount == null ? '' : ` · ${summary.preloadItemCount} 项`}` : '待更新' }, { label: '预装确认', value: preloadStatus || '待更新' },
{ label: '拣货进度', value: pickingProgress }, { label: '拣货进度', value: pickingProgress },
{ label: '关联柜次', value: summary.cabinetNo || summary.containerNo || '—' }, { label: '关联柜次', value: summary.cabinetNo || summary.containerNo || '—' },
{ label: '船期摘要', value: transportReference || '待更新' }, { label: '船期摘要', value: transportReference || '待更新' },
{ label: '计费重量', value: formatMetric(summary.chargeableWeightKg, 'kg') }, { label: billingMetric.label, value: formatMetric(billingMetric.quantity, billingMetric.unit) },
{ label: '后续履约', value: summary.deliveryTrackingNo || (summary.deliveryCompletedAt ? `海外回传于 ${formatDateTime(summary.deliveryCompletedAt)}` : '待海外同步') }, { label: '后续履约', value: orderProgressLabel(store.orderProgress[order.id]) },
] ]
}) })
const cabinetProgressItems = computed(() => { const cabinetProgressItems = computed(() => {
@@ -1,7 +1,7 @@
import { apiRequest, apiBlobRequest } from '@/api/http' import { apiRequest, apiBlobRequest } from '@/api/http'
import { createIdempotencyKey } from '@/utils/idempotencyKey' import { createIdempotencyKey } from '@/utils/idempotencyKey'
export type RelabelDeliveryMethod = 'TRUCK' | 'EXPRESS' | 'TRUCK_PICKUP' | 'EXPRESS_PICKUP' export type RelabelDeliveryMethod = 'TRUCK' | 'EXPRESS' | 'AMAZON_PICKUP' | 'CUSTOMER_PICKUP'
export interface PageResult<T> { records: T[]; page: number; size: number; total: number; pages: number } export interface PageResult<T> { records: T[]; page: number; size: number; total: number; pages: number }
export interface LastlegOrderSummary { id: string; orderNo: string; inboundCode: string; orderType: string; packageCount: number; overseasWarehouseId?: string } export interface LastlegOrderSummary { id: string; orderNo: string; inboundCode: string; orderType: string; packageCount: number; overseasWarehouseId?: string }
@@ -23,10 +23,11 @@ export interface RelabelDetail {
remark?: string; items: RelabelItemDetail[] remark?: string; items: RelabelItemDetail[]
history: Array<{ fromStatus?: string; toStatus?: string; operatorId?: string; remark?: string; createdAt: string }> history: Array<{ fromStatus?: string; toStatus?: string; operatorId?: string; remark?: string; createdAt: string }>
tailOrders?: Array<{ id: string; orderNo: string; overseasWarehouseId: string; packageCount: number; status: string }> 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 } 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 } 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 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 DiscardBox { cartonId: string; cartonMark: string; palletNo?: string; skus: Array<{ skuCode: string; productName: string; availablePackages: number }> }
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 { export interface RelabelInventoryCandidate {
cartonSkuItems: Array<{ skuCode: string; quantity: number }>; cartonAvailablePackages: number | string cartonSkuItems: Array<{ skuCode: string; quantity: number }>; cartonAvailablePackages: number | string
skuConversions?: import('./returnInventory').ReturnSkuConversion[] skuConversions?: import('./returnInventory').ReturnSkuConversion[]
@@ -47,7 +48,7 @@ export interface TransferCreate {
deliveryAddress: string; packageCount: number; volumeCbm: number; deliveryMethod: string; estimatedArrivalAt: string; estimatedShipAt?: string deliveryAddress: string; packageCount: number; volumeCbm: number; deliveryMethod: string; estimatedArrivalAt: string; estimatedShipAt?: string
fbaShipmentNo?: string; trackingNo?: string; fbaBoxNos?: string; remark?: string; needRelabel: boolean; idempotencyKey: string fbaShipmentNo?: string; trackingNo?: string; fbaBoxNos?: string; remark?: string; needRelabel: boolean; idempotencyKey: string
labelDeadline?: string; pickupContactName?: string; pickupContactPhone?: string; relabelContactName?: string; relabelContactPhone?: string; relabelChargeAmount?: number; relabelDeliveryMethod?: RelabelDeliveryMethod labelDeadline?: string; pickupContactName?: string; pickupContactPhone?: string; relabelContactName?: string; relabelContactPhone?: string; relabelChargeAmount?: number; relabelDeliveryMethod?: RelabelDeliveryMethod
items: Array<{ skuCode?: string; productNameZh?: string; packageCount: number; shippingMark?: string }> items: Array<{ skuCode?: string; productNameZh?: string; quantity: number; packageCount: number; shippingMark?: string }>
relabelItems?: Array<{ operationType: 'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU'; customerCartonMarks: string[]; originalSku?: string; newSku?: string; quantity: number; fbaCartonMark?: string; destinationWarehouseCode?: string; remark?: string }> relabelItems?: Array<{ operationType: 'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU'; customerCartonMarks: string[]; originalSku?: string; newSku?: string; quantity: number; fbaCartonMark?: string; destinationWarehouseCode?: string; remark?: string }>
} }
export interface RelabelCreate { export interface RelabelCreate {
@@ -127,12 +128,15 @@ export function getDiscard(discardNo: string) {
export function listDiscardCandidates(inboundCode?: string) { export function listDiscardCandidates(inboundCode?: string) {
return apiRequest<DiscardCandidate[]>({ method: 'GET', url: '/merchant/v1/last-leg/cargo-discards/inventory-candidates', params: { inboundCode: inboundCode || undefined } }) return apiRequest<DiscardCandidate[]>({ method: 'GET', url: '/merchant/v1/last-leg/cargo-discards/inventory-candidates', params: { inboundCode: inboundCode || undefined } })
} }
export function createDiscard(payload: { items: Array<{ orderId?: string; returnCartonId?: string; skuCode: string; discardPackages: number }>; idempotencyKey: string; remark?: string }) { export function createDiscard(payload: { items: Array<{ orderId?: string; returnCartonId?: string; inventoryPositionId?: string; cartonIds?: string[]; skuCode: string; discardPackages: number }>; idempotencyKey: string; remark?: string }) {
return apiRequest<Record<string, unknown>>({ method: 'POST', url: '/merchant/v1/last-leg/cargo-discards', data: payload }) return apiRequest<Record<string, unknown>>({ method: 'POST', url: '/merchant/v1/last-leg/cargo-discards', data: payload })
} }
export function cancelDiscard(discardNo: string) { export function cancelDiscard(discardNo: string) {
return apiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/cancel` }) return apiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/cancel` })
} }
export function confirmDiscardCompletion(discardNo: string) {
return apiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/confirm-completion` })
}
export interface DiscardPhoto { fileId: string; palletNo: string; fileName: string; createdAt: string; submittedAt: string } export interface DiscardPhoto { fileId: string; palletNo: string; fileName: string; createdAt: string; submittedAt: string }
export function downloadDiscardPhoto(discardNo: string, fileId: string) { export function downloadDiscardPhoto(discardNo: string, fileId: string) {
@@ -0,0 +1,81 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import ts from 'typescript'
const source = readFileSync(new URL('./CompletionConfirmationDialog.vue', import.meta.url), 'utf8')
const script = source.match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
const js = ts.transpileModule(script, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText
.replace(/^import .*$/gm, '').replace(/^export \{\};?$/gm, '')
function harness(kind = 'disposal', overrides = {}) {
const props = { modelValue: true, kind, documentNo: 'CD/1' }
const calls = []; const events = []; const revoked = []
const api = {
getDiscard: async () => ({ summary: { status: 'PDA_COMPLETED' }, photos: [{ fileId: '101' }] }),
getRelabel: async () => ({ application: { status: 'CUSTOMER_CONFIRM_PENDING' }, completionEvidence: { photoFileIds: ['101'] } }),
downloadDiscardPhoto: async () => ({}), downloadRelabelCompletionPhoto: async () => ({}),
confirmDiscardCompletion: async no => calls.push(['discard', no]),
confirmRelabelCompletion: async no => calls.push(['relabel', no]), ...overrides,
}
const env = {
computed: fn => ({ get value() { return fn() } }), ref: value => ({ value }), watch: () => {}, onBeforeUnmount: () => {},
defineProps: () => props, defineEmits: () => (...args) => events.push(args),
useAuthStore: () => ({ hasPermission: () => true }), ElMessage: { success() {}, error() {} },
URL: { createObjectURL: () => 'blob:photo', revokeObjectURL: url => revoked.push(url) }, ...api,
}
const state = new Function(...Object.keys(env), js + '\nreturn { loadEvidence, loadPhoto, submit, clear, checked, canConfirm, urls, failed, error, busy };')(...Object.values(env))
return { ...state, props, calls, events, revoked }
}
for (const kind of ['disposal', 'labeling']) {
test(`${kind}: opening loads photos without confirming; only reviewed evidence can be submitted`, async () => {
const state = harness(kind)
await state.loadEvidence()
assert.deepEqual(state.calls, [])
await state.submit(); assert.deepEqual(state.calls, [])
state.checked.value = true
assert.equal(state.canConfirm.value, true)
await state.submit()
assert.deepEqual(state.calls, [[kind === 'disposal' ? 'discard' : 'relabel', 'CD/1']])
assert.deepEqual(state.events, [['confirmed', 'CD/1'], ['update:modelValue', false]])
state.clear(); assert.deepEqual(state.revoked, ['blob:photo'])
})
}
test('photo failure blocks confirmation and supports retry', async () => {
let reject = true
const state = harness('disposal', { downloadDiscardPhoto: async () => { if (reject) throw Error('offline'); return {} } })
await state.loadEvidence(); state.checked.value = true
assert.equal(state.failed.value['101'], true); assert.equal(state.canConfirm.value, false)
reject = false; await state.loadPhoto('101')
assert.equal(state.canConfirm.value, true)
})
test('closing while photos load prevents stale preview URLs', async () => {
let resolve
const state = harness('disposal', { downloadDiscardPhoto: () => new Promise(done => { resolve = done }) })
const pending = state.loadEvidence()
await Promise.resolve()
state.props.modelValue = false; state.clear(); resolve({}); await pending
assert.deepEqual(state.urls.value, {})
})
test('completed orders and missing evidence cannot be confirmed', async () => {
for (const detail of [{ summary: { status: 'COMPLETED' }, photos: [{ fileId: '101' }] }, { summary: { status: 'PDA_COMPLETED' }, photos: [] }]) {
const state = harness('disposal', { getDiscard: async () => detail })
await state.loadEvidence(); state.checked.value = true; await state.submit()
assert.equal(state.canConfirm.value, false); assert.deepEqual(state.calls, [])
}
})
test('failed submission preserves dialog and a pending submission is not sent twice', async () => {
let reject
const state = harness('disposal', { confirmDiscardCompletion: () => new Promise((_, fail) => { reject = fail }) })
await state.loadEvidence(); state.checked.value = true
const pending = state.submit()
assert.equal(state.busy.value, true); await state.submit()
reject(Error('offline')); await pending
assert.deepEqual(state.events, []); assert.equal(state.busy.value, false)
assert.equal(state.canConfirm.value, true)
})
@@ -0,0 +1,105 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/modules/auth/public'
import { confirmDiscardCompletion, confirmRelabelCompletion, downloadDiscardPhoto, downloadRelabelCompletionPhoto, getDiscard, getRelabel, type DiscardPhoto } from '../api/warehouseServices'
const props = defineProps<{ modelValue: boolean; kind: 'disposal' | 'labeling'; documentNo: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; confirmed: [documentNo: string] }>()
const auth = useAuthStore()
const title = computed(() => props.kind === 'disposal' ? '废弃确认' : '贴标完成确认')
const loading = ref(false)
const busy = ref(false)
const error = ref('')
const fileIds = ref<string[]>([])
const urls = ref<Record<string, string>>({})
const failed = ref<Record<string, boolean>>({})
const photoLoading = ref<Record<string, boolean>>({})
const checked = ref(false)
const eligible = ref(false)
let generation = 0
const previewUrls = computed(() => fileIds.value.map(id => urls.value[id]).filter(Boolean))
const canConfirm = computed(() => eligible.value && checked.value && !loading.value && !busy.value && !error.value
&& fileIds.value.length > 0 && fileIds.value.every(id => Boolean(urls.value[id]))
&& auth.hasPermission(`merchant:overseas-warehouse:${props.kind === 'disposal' ? 'discard' : 'relabel'}:confirm`))
function clear() {
generation++
Object.values(urls.value).forEach(URL.revokeObjectURL)
urls.value = {}; failed.value = {}; photoLoading.value = {}; fileIds.value = []; checked.value = false; eligible.value = false
}
async function loadPhoto(fileId: string) {
if (photoLoading.value[fileId]) return
const currentGeneration = generation; const no = props.documentNo; const kind = props.kind
photoLoading.value[fileId] = true; failed.value[fileId] = false
try {
const blob = kind === 'disposal' ? await downloadDiscardPhoto(no, fileId) : await downloadRelabelCompletionPhoto(no, fileId)
if (generation !== currentGeneration || !props.modelValue) return
if (urls.value[fileId]) URL.revokeObjectURL(urls.value[fileId])
urls.value[fileId] = URL.createObjectURL(blob)
} catch { if (generation === currentGeneration) failed.value[fileId] = true }
finally { if (generation === currentGeneration) photoLoading.value[fileId] = false }
}
async function loadEvidence() {
clear(); error.value = ''; loading.value = true
const currentGeneration = generation
try {
if (props.kind === 'disposal') {
const detail = await getDiscard(props.documentNo) as { summary?: { status?: string }; photos?: DiscardPhoto[] }
if (generation !== currentGeneration) return
eligible.value = detail.summary?.status === 'PDA_COMPLETED'
fileIds.value = (detail.photos || []).map(photo => String(photo.fileId))
} else {
const detail = await getRelabel(props.documentNo)
if (generation !== currentGeneration) return
eligible.value = detail.application.status === 'CUSTOMER_CONFIRM_PENDING' && !detail.application.customerConfirmedAt
fileIds.value = (detail.completionEvidence?.photoFileIds || []).map(String)
}
fileIds.value = [...new Set(fileIds.value)]
if (!eligible.value) error.value = '当前单据已确认或尚未完成 PDA 作业,请刷新列表核对。'
else if (!fileIds.value.length) error.value = '暂无 PDA 完工作业照片,暂不能确认。'
await Promise.all(fileIds.value.map(loadPhoto))
} catch (e) { if (generation === currentGeneration) error.value = e instanceof Error ? e.message : '作业照片加载失败' }
finally { if (generation === currentGeneration) loading.value = false }
}
async function submit() {
if (!canConfirm.value) return
busy.value = true
try {
if (props.kind === 'disposal') await confirmDiscardCompletion(props.documentNo)
else await confirmRelabelCompletion(props.documentNo)
ElMessage.success(props.kind === 'disposal' ? '废弃已确认,单据已完结' : '贴标已确认,订单进入后续出库流程')
emit('confirmed', props.documentNo)
emit('update:modelValue', false)
} catch (e) { ElMessage.error(e instanceof Error ? e.message : '确认失败,请重试') }
finally { busy.value = false }
}
watch(() => [props.modelValue, props.kind, props.documentNo], () => { if (props.modelValue) void loadEvidence(); else clear() }, { immediate: true })
onBeforeUnmount(clear)
</script>
<template>
<el-dialog :model-value="modelValue" :title="title" width="760px" :close-on-click-modal="false" :close-on-press-escape="!busy" :show-close="!busy" @update:model-value="!busy && emit('update:modelValue', $event)">
<p>单号:{{ documentNo }}</p>
<p>请核对以下 PDA 作业照片,确认作业结果无误后再提交。</p>
<p v-if="loading">正在读取作业照片…</p>
<el-alert v-if="error" :title="error" type="error" :closable="false" />
<el-button v-if="error" :disabled="loading || busy" @click="loadEvidence">重新读取</el-button>
<div class="completion-photo-grid">
<div v-for="(fileId, index) in fileIds" :key="fileId">
<el-image v-if="urls[fileId]" :src="urls[fileId]" :preview-src-list="previewUrls" :initial-index="previewUrls.indexOf(urls[fileId])" fit="contain" preview-teleported />
<span v-else-if="photoLoading[fileId]">照片 {{ index + 1 }} 加载中…</span>
<el-button v-else :disabled="busy" @click="loadPhoto(fileId)">{{ failed[fileId] ? '照片加载失败,点击重试' : '加载照片' }}</el-button>
</div>
</div>
<el-checkbox v-model="checked" :disabled="loading || busy || !!error || previewUrls.length !== fileIds.length">我已核对 PDA 作业照片,确认作业结果无误</el-checkbox>
<template #footer>
<el-button :disabled="busy" @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="busy" :disabled="!canConfirm" @click="submit">{{ kind === 'disposal' ? '确认废弃完结' : '确认贴标完成' }}</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.completion-photo-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 16px 0; }
.completion-photo-grid .el-image { width: 100%; height: 180px; }
</style>
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import test from 'node:test' import test from 'node:test'
import ts from 'typescript'
const routes = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8') const routes = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8')
const services = readFileSync(new URL('./views/WarehouseServiceView.vue', import.meta.url), 'utf8') const services = readFileSync(new URL('./views/WarehouseServiceView.vue', import.meta.url), 'utf8')
@@ -41,13 +42,31 @@ test('opens warehouse service and return receipt details as dedicated protected
}) })
test('returns to the labeling list after a labeling application is submitted', () => { test('returns to the labeling list after a labeling application is submitted', () => {
assert.match(services, /ElMessage\.success\(`\$\{config\.value\.title\}提交成功`\)[\s\S]*?router\.push\(\{ name: serviceListRouteName\(props\.service\) \}\)/) assert.match(services, /ElMessage\.success\(`\$\{config\.value\.title\}提交成功`\)[\s\S]*?await goToServiceList\(\)/)
// Return-carton sources use the unified relabel application and its file endpoint. // Return-carton sources use the unified relabel application and its file endpoint.
assert.match(services, /returnCartonId: item\.returnCartonId/) assert.match(services, /returnCartonId: item\.returnCartonId/)
assert.match(services, /uploadRelabelFile\(created\.application\.relabelNo,[\s\S]*?router\.push\(\{ name: serviceListRouteName\(props\.service\) \}\)/) assert.match(services, /uploadRelabelFile\(created\.application\.relabelNo,[\s\S]*?await goToServiceList\(\)/)
assert.match(services, /申请已创建,附件待补传/) assert.match(services, /申请已创建,附件待补传/)
}) })
test('service list return navigates standalone pages and refreshes embedded lists', async () => {
const source = services.slice(services.indexOf('function serviceListRouteName('), services.indexOf('async function openEdit('))
const javascript = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
for (const props of [
{ service: 'labeling', createPage: true, detailPage: false },
{ service: 'labeling', createPage: false, detailPage: true },
{ service: 'labeling', createPage: false, detailPage: false },
]) {
const calls = [], mode = { value: 'create' }
const goToServiceList = new Function('props', 'router', 'mode', 'loadPage', `${javascript}; return goToServiceList`)(
props, { push: async route => calls.push(route) }, mode, async () => calls.push('refresh'),
)
await goToServiceList()
assert.deepEqual(calls, props.createPage || props.detailPage ? [{ name: 'overseas-labeling' }] : ['refresh'])
if (!props.createPage && !props.detailPage) assert.equal(mode.value, 'list')
}
})
test('keeps overseas warehouse writes behind operation-specific button permissions', () => { test('keeps overseas warehouse writes behind operation-specific button permissions', () => {
for (const permission of [ for (const permission of [
'merchant:overseas-warehouse:relabel:create', 'merchant:overseas-warehouse:relabel:create',
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import test from 'node:test' import test from 'node:test'
import ts from 'typescript'
const view = readFileSync(new URL('./ReturnOrdersView.vue', import.meta.url), 'utf8') const view = readFileSync(new URL('./ReturnOrdersView.vue', import.meta.url), 'utf8')
const api = readFileSync(new URL('../api/returnOrders.ts', import.meta.url), 'utf8') const api = readFileSync(new URL('../api/returnOrders.ts', import.meta.url), 'utf8')
@@ -254,7 +255,25 @@ test('待审核退货可编辑,完成入库后从独立新增页发起贴标',
assert.match(view, /name: 'overseas-labeling-create'/) assert.match(view, /name: 'overseas-labeling-create'/)
assert.match(view, /query: \{ returnNo: order\.returnNo, warehouseId: String\(order\.overseasWarehouseId\)/) assert.match(view, /query: \{ returnNo: order\.returnNo, warehouseId: String\(order\.overseasWarehouseId\)/)
assert.match(view, /cartonMark \? \{ cartonMark \}/) assert.match(view, /cartonMark \? \{ cartonMark \}/)
assert.match(view, /openRelabel\(selected\.order, current\.carton\.cartonMark\)/) assert.match(view, /v-if="canCreateRelabel && canApplyRelabel\(selected\.order\)"[^>]*@click="openRelabel\(selected\.order\)">贴标申请/)
assert.match(view, /v-for="application in row\.relabelApplications \|\| \[\]"/) assert.match(view, /v-for="application in row\.relabelApplications \|\| \[\]"/)
assert.match(view, /relabelStatusLabel\(application\.status\)/) assert.match(view, /relabelStatusLabel\(application\.status\)/)
}) })
test('贴标入口保留完成入库门禁与退货来源,箱号只作为目标页库存筛选', () => {
const source = view.slice(view.indexOf('function canApplyRelabel('), view.indexOf('async function submit()'))
const javascript = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
const routes = []
const { canApplyRelabel, openRelabel } = new Function('router', `${javascript}; return { canApplyRelabel, openRelabel }`)({ push: route => routes.push(route) })
const order = { returnNo: 'RT-TEST', overseasWarehouseId: 'WH-TEST', status: 'COMPLETED', needRelabel: true }
assert.equal(canApplyRelabel(order), true)
assert.equal(canApplyRelabel({ ...order, status: 'PENDING_RECEIPT' }), false)
assert.equal(canApplyRelabel({ ...order, needRelabel: false }), false)
assert.equal(canApplyRelabel({ ...order, relabelNo: 'RL-EXISTS' }), false)
openRelabel(order)
openRelabel(order, 'RC-TEST')
assert.deepEqual(routes, [
{ name: 'overseas-labeling-create', query: { returnNo: 'RT-TEST', warehouseId: 'WH-TEST' } },
{ name: 'overseas-labeling-create', query: { returnNo: 'RT-TEST', warehouseId: 'WH-TEST', cartonMark: 'RC-TEST' } },
])
})
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { computed, onActivated, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { ArrowLeft, ChevronDown, Plus } from '@lucide/vue' import { ArrowLeft, ChevronDown, Plus } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
@@ -280,6 +280,11 @@ onMounted(() => {
else if (props.detailPage) void Promise.all([loadDetail(), loadOperationWarehouses()]) else if (props.detailPage) void Promise.all([loadDetail(), loadOperationWarehouses()])
else void Promise.all([load(), loadOperationWarehouses()]) else void Promise.all([load(), loadOperationWarehouses()])
}) })
let activatedOnce = false
onActivated(() => {
if (activatedOnce && !props.createPage && !props.detailPage) void load()
activatedOnce = true
})
watch(() => [query.page, query.size] as const, ([nextPage, nextSize], [previousPage, previousSize]) => { watch(() => [query.page, query.size] as const, ([nextPage, nextSize], [previousPage, previousSize]) => {
if (nextSize !== previousSize && nextPage !== 1) { query.page = 1; return } if (nextSize !== previousSize && nextPage !== 1) { query.page = 1; return }
if (!props.createPage && !props.detailPage) void load() if (!props.createPage && !props.detailPage) void load()
@@ -443,24 +448,6 @@ onBeforeUnmount(clearPhotoUrls)
<template #footer><el-button @click="showCartons = false">关闭</el-button></template> <template #footer><el-button @click="showCartons = false">关闭</el-button></template>
</el-dialog> </el-dialog>
</section> </section>
<section class="return-detail-section">
<h3>当前关联实物箱</h3>
<el-collapse v-if="selected.currentCartons?.length">
<el-collapse-item v-for="current in selected.currentCartons" :key="current.carton.cartonMark" :name="current.carton.cartonMark">
<template #title>{{ current.carton.cartonMark }} · 整箱 {{ current.carton.totalQuantity }} 件 · 本单 {{ current.orderQuantity }} 件 · {{ current.carton.sourceReturnNo?.includes(',') ? '跨来源单合箱' : '单一来源' }}</template>
<p>来源退货单:{{ current.carton.sourceReturnNo }} · 我方箱唛:{{ current.labelStatus === 'CONFIRMED' ? '已确认贴标' : '待贴标确认' }}</p>
<el-table :data="current.ownership" border>
<el-table-column prop="returnNo" label="来源退货单" min-width="200" />
<el-table-column prop="skuCode" label="SKU" min-width="180" />
<el-table-column prop="quantity" label="来源贡献数量" width="130" />
</el-table>
<p>换箱唛以当前实物箱为单位;跨来源单同 SKU 的部分贴标须先现场分拣确认。</p>
<el-button v-if="canCreateRelabel" :disabled="!current.canRelabel" @click="openRelabel(selected.order, current.carton.cartonMark)">选择此箱贴标</el-button>
<p v-if="!current.canRelabel">{{ current.blockReason }}</p>
</el-collapse-item>
</el-collapse>
<el-empty v-else description="暂无当前关联实物箱" :image-size="64" />
</section>
<section v-if="selected.photos?.length" class="return-detail-section"> <section v-if="selected.photos?.length" class="return-detail-section">
<h3>退货打托照片({{ selected.photos?.length || 0 }} 张)</h3> <h3>退货打托照片({{ selected.photos?.length || 0 }} 张)</h3>
<div v-if="selected.photos?.length" class="return-photo-grid"> <div v-if="selected.photos?.length" class="return-photo-grid">
@@ -1,19 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Download, Plus, Printer, Trash2, Upload, X } from '@lucide/vue' import { ArrowLeft, ChevronDown, Download, Plus, Printer, Trash2, Upload, X } from '@lucide/vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue' import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import type { TableAction } from '@/components/common/tableActions' import type { TableAction } from '@/components/common/tableActions'
import { useAuthStore } from '@/modules/auth/public' import { useAuthStore } from '@/modules/auth/public'
import CompletionConfirmationDialog from '../components/CompletionConfirmationDialog.vue'
import { returnRelabelCandidate } from '../utils/returnRelabelCandidates' import { returnRelabelCandidate } from '../utils/returnRelabelCandidates'
import { import {
createDiscard, createRelabel, createTransfer, getDiscard, getRelabel, getTransfer, resubmitRelabel, createDiscard, createRelabel, createTransfer, getDiscard, getRelabel, getTransfer, resubmitRelabel,
getTransferCartonLabel, submitTransfer, updateTransfer, getTransferCartonLabel, submitTransfer, updateTransfer,
confirmRelabelCompletion, confirmTransferMeasurement, downloadRelabelCompletionPhoto, downloadTransferMeasurementPhoto, downloadDiscardPhoto, type DiscardPhoto, uploadRelabelShippingLabel, confirmTransferMeasurement, downloadRelabelCompletionPhoto, downloadTransferMeasurementPhoto, downloadDiscardPhoto, type DiscardPhoto, uploadRelabelShippingLabel,
uploadRelabelFile, uploadRelabelFile,
listDiscards, listRelabelInventoryCandidates, listRelabelHistoricalOrders, listRelabels, listTransfers, cancelDiscard, listDiscards, listDiscardCandidates, listRelabelInventoryCandidates, listRelabelHistoricalOrders, listRelabels, listTransfers, cancelDiscard,
type RelabelInventoryCandidate, type RelabelInventoryCandidate,
type RelabelCreate, type RelabelCreate,
type RelabelDetail, type RelabelDetail,
@@ -33,11 +34,11 @@ type ServiceType = 'labeling' | 'transfer' | 'disposal' | 'relabel'
type PageMode = 'list' | 'create' | 'edit' | 'detail' type PageMode = 'list' | 'create' | 'edit' | 'detail'
type LabelingMode = 'carton' | 'sku' | 'carton_and_sku' type LabelingMode = 'carton' | 'sku' | 'carton_and_sku'
type RelabelTemplateType = 'carton' | 'sku' | 'mixed' type RelabelTemplateType = 'carton' | 'sku' | 'mixed'
interface CargoRow { id: number; included?: boolean; sku: string; name: string; quantity: number; availableQuantity?: number; sourceOrderId?: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON' | 'RETURN_PARCEL'; sourceInventoryBalanceId?: string; sourceInventoryPositionId?: string; sourceInboundOrderId?: string; returnCartonId?: string; sourceInboundOrderNo?: string; sourceInboundCode?: string; sourceWarehouseName?: string; palletNo?: string; warehouseLocationCode?: string; fnsku: string; shipmentNo: string; newSkuCode: string; shippingMark: string; customerCartonMarks: string; cartonMark: string; fbaCartonMark: string; destinationWarehouseId: string; remark: string } interface CargoRow { discardCartonIds?: string[]; id: number; included?: boolean; sku: string; name: string; quantity: number; expectedPackageCount?: number; availableQuantity?: number; sourceOrderId?: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON' | 'RETURN_PARCEL' | 'INVENTORY'; sourceInventoryBalanceId?: string; sourceInventoryPositionId?: string; sourceInboundOrderId?: string; returnCartonId?: string; sourceInboundOrderNo?: string; sourceInboundCode?: string; sourceWarehouseName?: string; palletNo?: string; warehouseLocationCode?: string; fnsku: string; shipmentNo: string; newSkuCode: string; shippingMark: string; customerCartonMarks: string; cartonMark: string; fbaCartonMark: string; destinationWarehouseId: string; remark: string }
interface SkuRelabelRow { id: number; sourceRowId: number | null; cartonMark: string; originalSku: string; newSku: string; quantity: number; fbaCartonMark: string; shipmentNo: string; destinationWarehouseId: string; remark: string } interface SkuRelabelRow { id: number; sourceRowId: number | null; cartonMark: string; originalSku: string; newSku: string; quantity: number; fbaCartonMark: string; shipmentNo: string; destinationWarehouseId: string; remark: string }
interface ImportedRelabelBinding extends SkuRelabelRow { templateType: RelabelTemplateType; selected: boolean; shipmentNo: string } interface ImportedRelabelBinding extends SkuRelabelRow { templateType: RelabelTemplateType; selected: boolean; shipmentNo: string }
interface ServiceConfig { title: string; createTitle: string; numberLabel: string; emptyText: string; columns: string[] } interface ServiceConfig { title: string; createTitle: string; numberLabel: string; emptyText: string; columns: string[] }
interface TransferCarton { cartonMark: string; customerCartonMark?: string; cartonIndex: number; totalCartons: number; status: string } interface TransferCarton { items?: Array<{ transferItemId: string | number; skuCode: string; quantity: number }>; cartonMark: string; customerCartonMark?: string; cartonIndex: number; totalCartons: number; status: string; transferItemId?: string | number; transferPalletId?: string | number; length?: number; width?: number; height?: number; weight?: number; quantity?: number; discrepancyReason?: string; countedAt?: string }
const props = withDefaults(defineProps<{ service: ServiceType; createPage?: boolean; detailPage?: boolean; detailNo?: string }>(), { createPage: false, detailPage: false, detailNo: '' }) const props = withDefaults(defineProps<{ service: ServiceType; createPage?: boolean; detailPage?: boolean; detailNo?: string }>(), { createPage: false, detailPage: false, detailNo: '' })
const auth = useAuthStore() const auth = useAuthStore()
@@ -53,10 +54,22 @@ const canCreate = computed(() => auth.hasPermission(({
const canManageRelabelFiles = computed(() => auth.hasPermission('merchant:overseas-warehouse:relabel:files')) const canManageRelabelFiles = computed(() => auth.hasPermission('merchant:overseas-warehouse:relabel:files'))
const canConfirmRelabel = computed(() => auth.hasPermission('merchant:overseas-warehouse:relabel:confirm')) const canConfirmRelabel = computed(() => auth.hasPermission('merchant:overseas-warehouse:relabel:confirm'))
const canConfirmTransfer = computed(() => auth.hasPermission('merchant:overseas-warehouse:transfer:confirm')) const canConfirmTransfer = computed(() => auth.hasPermission('merchant:overseas-warehouse:transfer:confirm'))
const canConfirmDiscard = computed(() => auth.hasPermission('merchant:overseas-warehouse:discard:confirm'))
const completionConfirmOpen = ref(false)
const completionConfirmNo = ref('')
const completionConfirmKind = ref<'disposal' | 'labeling'>('disposal')
function openCompletionConfirmation(no: string, kind: 'disposal' | 'labeling') {
if (!no) return
completionConfirmNo.value = no; completionConfirmKind.value = kind; completionConfirmOpen.value = true
}
async function refreshAfterCompletion(no: string) {
if (mode.value === 'detail') await openDetail({ '0': no })
else await load()
}
const canCancelDiscard = computed(() => auth.hasPermission('merchant:overseas-warehouse:discard:cancel')) const canCancelDiscard = computed(() => auth.hasPermission('merchant:overseas-warehouse:discard:cancel'))
const configs: Record<ServiceType, ServiceConfig> = { const configs: Record<ServiceType, ServiceConfig> = {
labeling: { title: '贴标申请', createTitle: '新增贴标申请', numberLabel: '贴标申请单号', emptyText: '暂无贴标申请', columns: ['操作', '贴标申请单号', '关联业务单号', '海外操作仓', 'SKU 数(种)', '总箱数/包裹', '换标类型', '状态', '审核时间', '贴标完成时间', '贴标出库时间', '创建时间'] }, labeling: { title: '贴标申请', createTitle: '新增贴标申请', numberLabel: '贴标申请单号', emptyText: '暂无贴标申请', columns: ['操作', '贴标申请单号', '关联业务单号', '海外操作仓', 'SKU 数(种)', '总箱数/包裹', '换标类型', '状态', '审核时间', '贴标完成时间', '贴标出库时间', '创建时间'] },
transfer: { title: '仓储转运单', createTitle: '新增仓储转运单', numberLabel: '转运单号', emptyText: '暂无仓储转运订单', columns: ['操作', '转运单号', '箱数', '海外仓', 'FBA 目的仓', '入仓方式', '是否换标', '换标单号', '换标状态', '状态', '审核时间', '测量时间', '出库时间', '创建时间'] }, transfer: { title: '仓储转运单', createTitle: '新增仓储转运单', numberLabel: '转运单号', emptyText: '暂无仓储转运订单', columns: ['操作', '转运单号', '预计箱数', '海外仓', 'FBA 目的仓', '入仓方式', '是否换标', '换标单号', '换标状态', '状态', '审核时间', '测量时间', '出库时间', '创建时间'] },
disposal: { title: '货物废弃', createTitle: '新增货物废弃申请', numberLabel: '废弃申请单号', emptyText: '暂无货物废弃申请', columns: ['操作', '废弃申请单号', '废弃出库单号', '进仓编号', '操作仓', 'SKU 汇总', '废弃数量', '预估费用', '状态', '废弃完成时间', '创建时间'] }, disposal: { title: '货物废弃', createTitle: '新增货物废弃申请', numberLabel: '废弃申请单号', emptyText: '暂无货物废弃申请', columns: ['操作', '废弃申请单号', '废弃出库单号', '进仓编号', '操作仓', 'SKU 汇总', '废弃数量', '预估费用', '状态', '废弃完成时间', '创建时间'] },
relabel: { title: '退货换标', createTitle: '新建退货换标单', numberLabel: '退货换标单号', emptyText: '暂无退货换标订单', columns: ['操作', '退货换标单号', '来源 FBA 仓', '目的 FBA 仓', 'SKU 汇总', '退件总数', '是否换标', '换标状态', '尾程状态', '审核时间', '测量时间', '出库时间', '创建时间'] }, relabel: { title: '退货换标', createTitle: '新建退货换标单', numberLabel: '退货换标单号', emptyText: '暂无退货换标订单', columns: ['操作', '退货换标单号', '来源 FBA 仓', '目的 FBA 仓', 'SKU 汇总', '退件总数', '是否换标', '换标状态', '尾程状态', '审核时间', '测量时间', '出库时间', '创建时间'] },
} }
@@ -81,7 +94,7 @@ const listQueryModel = computed<QueryModel>({
const statusOptions = computed(() => props.service === 'labeling' const statusOptions = computed(() => props.service === 'labeling'
? [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '换标完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }] ? [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '换标完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }]
: props.service === 'disposal' : props.service === 'disposal'
? [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '待 PDA 废弃', value: 'APPROVED' }, { label: '待仓库确认完成', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已拒绝', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] ? [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '待 PDA 废弃', value: 'APPROVED' }, { label: '待客户确认', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已拒绝', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }]
: props.service === 'transfer' : props.service === 'transfer'
? [{ label: '草稿', value: 'DRAFT' }, { label: '待审核', value: 'PENDING_REVIEW' }, ? [{ label: '草稿', value: 'DRAFT' }, { label: '待审核', value: 'PENDING_REVIEW' },
{ label: '待PDA收货', value: 'WAIT_PDA_RECEIVING' }, { label: '入库测量中', value: 'INBOUND_MEASURING' }, { label: '待PDA收货', value: 'WAIT_PDA_RECEIVING' }, { label: '入库测量中', value: 'INBOUND_MEASURING' },
@@ -130,11 +143,39 @@ interface ServiceDetail extends Record<string, unknown> {
relabelContactPhone?: string; relabelDeliveryMethod?: string; relabelNo?: string relabelContactPhone?: string; relabelDeliveryMethod?: string; relabelNo?: string
relabelStatus?: string; relabelFulfillmentStatus?: string; createdAt?: string relabelStatus?: string; relabelFulfillmentStatus?: string; createdAt?: string
inboundCode?: string; sourceOrders?: Array<{ lastlegOrderId?: string | number }> inboundCode?: string; sourceOrders?: Array<{ lastlegOrderId?: string | number }>
items?: Array<{ skuCode?: string; productNameZh?: string; packageCount?: number; shippingMark?: string }> items?: Array<{ id?: string | number; cartonMarks?: string[]; inboundCode?: string; productName?: string; discardPackages?: number; skuCode?: string; productNameZh?: string; quantity?: number | null; packageCount?: number; shippingMark?: string }>
relabelBindings?: Array<{ operationType?: string; customerCartonMark?: string; originalSku?: string; newSku?: string; quantity?: number; fbaCartonMark?: string; destinationWarehouseCode?: string; remark?: string }> relabelBindings?: Array<{ operationType?: string; customerCartonMark?: string; originalSku?: string; newSku?: string; quantity?: number; fbaCartonMark?: string; destinationWarehouseCode?: string; remark?: string }>
cartons?: TransferCarton[]; pallets?: TransferPallet[]; measurementPhotos?: TransferMeasurementPhoto[]; customerConfirmedAt?: string cartons?: TransferCarton[]; pallets?: TransferPallet[]; measurementPhotos?: TransferMeasurementPhoto[]; customerConfirmedAt?: string
} }
const selectedDetail = ref<ServiceDetail | null>(null) const selectedDetail = ref<ServiceDetail | null>(null)
const showTransferCartons = ref(false)
function transferCartonItem(carton: TransferCarton) {
return selectedDetail.value?.items?.find((item) => carton.transferItemId != null && String(item.id) === String(carton.transferItemId))
}
function transferCartonLines(carton: TransferCarton) {
if (carton.items?.length) return carton.items.map(line => ({ ...line, productNameZh: selectedDetail.value?.items?.find(item => String(item.id) === String(line.transferItemId))?.productNameZh || '—' }))
const item = transferCartonItem(carton)
return [{ transferItemId: carton.transferItemId, skuCode: item?.skuCode || '—', productNameZh: item?.productNameZh || '—', quantity: Number(carton.quantity || 0) }]
}
function palletCartons(pallet: TransferPallet) {
return (selectedDetail.value?.cartons || []).filter((carton) => carton.status === 'COUNTED' && pallet.id != null && carton.transferPalletId != null && String(carton.transferPalletId) === String(pallet.id))
}
function palletSkuRows(pallet: TransferPallet) {
const summary = new Map<string, number>()
for (const carton of palletCartons(pallet)) {
for (const line of transferCartonLines(carton)) summary.set(line.skuCode, (summary.get(line.skuCode) || 0) + Number(line.quantity))
}
return [...summary.entries()].map(([sku, quantity]) => ({ sku, quantity }))
}
function palletQuantity(pallet: TransferPallet) { return palletCartons(pallet).reduce((sum, carton) => sum + Number(carton.quantity || 0), 0) }
function countedTransferCartons() { return (selectedDetail.value?.cartons || []).filter((carton) => carton.status === 'COUNTED') }
function receivedItemCartons(itemId: string | number | undefined) {
return countedTransferCartons().filter(carton => itemId != null && transferCartonLines(carton).some(line => String(line.transferItemId) === String(itemId)))
}
function receivedItemQuantity(itemId: string | number | undefined) {
return receivedItemCartons(itemId).reduce((sum, carton) => sum + transferCartonLines(carton).filter(line => String(line.transferItemId) === String(itemId)).reduce((total, line) => total + Number(line.quantity), 0), 0)
}
const detailError = ref('') const detailError = ref('')
const selectedRelabelDetail = ref<RelabelDetail | null>(null) const selectedRelabelDetail = ref<RelabelDetail | null>(null)
const completionPhotoUrls = reactive<Record<string, string>>({}) const completionPhotoUrls = reactive<Record<string, string>>({})
@@ -201,7 +242,48 @@ const listColumns = computed<DataTableColumn[]>(() => config.value.columns.map((
: { key: String(index - 1), prop: String(index - 1), label, minWidth: label === '关联业务单号' ? 300 : index === 1 || label === '换标单号' ? 190 : 130, : { key: String(index - 1), prop: String(index - 1), label, minWidth: label === '关联业务单号' ? 300 : index === 1 || label === '换标单号' ? 190 : 130,
singleLine: label !== '关联业务单号', className: label === '关联业务单号' ? 'related-business-number-cell' : undefined, singleLine: label !== '关联业务单号', className: label === '关联业务单号' ? 'related-business-number-cell' : undefined,
slotName: props.service === 'labeling' && label === '换标类型' ? 'labelingType' : props.service === 'labeling' && label === '状态' ? 'labelingStatus' : props.service === 'transfer' && label === '换标状态' ? 'transferRelabelStatus' : props.service === 'transfer' && label === '状态' ? 'transferStatus' : undefined })) slotName: props.service === 'labeling' && label === '换标类型' ? 'labelingType' : props.service === 'labeling' && label === '状态' ? 'labelingStatus' : props.service === 'transfer' && label === '换标状态' ? 'transferRelabelStatus' : props.service === 'transfer' && label === '状态' ? 'transferStatus' : undefined }))
const discardSelectedBoxKeys = ref<string[]>([])
const discardBoxSearch = ref('')
const discardCandidates = ref<import('../api/warehouseServices').DiscardCandidate[]>([]) const discardCandidates = ref<import('../api/warehouseServices').DiscardCandidate[]>([])
const discardSelectedCandidate = computed(() => discardCandidates.value.find(item => item.orderId === selectedDiscardOrderId.value))
const discardSelectionSources = computed(() => {
const current = discardSelectedCandidate.value
if (!current) return []
if (current.sourceType !== 'RETURN_CARTON') return [current]
return discardCandidates.value.filter(item => item.sourceType === current.sourceType && item.inboundCode === current.inboundCode && item.overseasWarehouseId === current.overseasWarehouseId)
})
const discardSelectableBoxes = computed(() => discardSelectionSources.value.flatMap(source => (source.cartons || []).map(box => ({ ...box, key: `${source.sourceType}:${box.cartonId}`, source }))))
const discardVisibleBoxes = computed(() => discardSelectableBoxes.value.filter(box => !discardBoxSearch.value.trim() || box.cartonMark.includes(discardBoxSearch.value.trim()) || box.skus.some(sku => sku.skuCode.includes(discardBoxSearch.value.trim()))))
function toggleDiscardBox(key: string, selected: boolean) {
discardSelectedBoxKeys.value = selected ? [...new Set([...discardSelectedBoxKeys.value, key])] : discardSelectedBoxKeys.value.filter(value => value !== key)
syncDiscardSelection()
}
function selectAllDiscardBoxes(selected: boolean) {
discardSelectedBoxKeys.value = selected ? discardSelectableBoxes.value.map(box => box.key) : []
syncDiscardSelection()
}
function syncDiscardSelection() {
const selected = discardSelectableBoxes.value.filter(box => discardSelectedBoxKeys.value.includes(box.key))
const groups = new Map<string, CargoRow>()
for (const box of selected) {
const source = box.source
const skus = source.sourceType === 'RETURN_CARTON' ? source.skus : source.sourceType === 'INVENTORY' ? source.skus.slice(0, 1) : box.skus.slice(0, 1)
for (const sku of skus) {
const key = `${source.orderId}:${sku.skuCode}`
let row = groups.get(key)
if (!row) {
row = { ...createCargoRow(), sku: sku.skuCode, name: sku.productName, quantity: 0, availableQuantity: source.sourceType === 'RETURN_CARTON' ? sku.availablePackages : source.availablePackages,
sourceOrderId: source.orderId, sourceType: source.sourceType, sourceInventoryPositionId: source.inventoryPositionId, returnCartonId: source.returnCartonId,
palletNo: source.palletNo, discardCartonIds: source.sourceType === 'RETURN_CARTON' ? undefined : [] }
groups.set(key, row)
}
row.quantity += source.sourceType === 'RETURN_CARTON' ? sku.availablePackages : 1
row.discardCartonIds?.push(box.cartonId)
}
}
cargoRows.value = [...groups.values()]
}
const selectedDiscardOrderId = ref('') const selectedDiscardOrderId = ref('')
const discardCandidatesLoading = ref(false) const discardCandidatesLoading = ref(false)
const relabelCandidates = ref<RelabelInventoryCandidate[]>([]) const relabelCandidates = ref<RelabelInventoryCandidate[]>([])
@@ -340,6 +422,11 @@ onMounted(() => {
else void loadPage() else void loadPage()
void nextTick(refreshWarehouseMarkLabels) void nextTick(refreshWarehouseMarkLabels)
}) })
let activatedOnce = false
onActivated(() => {
if (activatedOnce && !props.createPage && !props.detailPage && mode.value === 'list') void loadPage()
activatedOnce = true
})
onBeforeUnmount(() => { clearCompletionPhotos(); clearMeasurementPhotos(); clearDiscardPhotos() }) onBeforeUnmount(() => { clearCompletionPhotos(); clearMeasurementPhotos(); clearDiscardPhotos() })
function resetFilters() { Object.assign(filters, { number: '', inboundNo: '', warehouse: '', destination: '', status: '', start: '', end: '', reviewStart: '', reviewEnd: '', completedStart: '', completedEnd: '', measuredStart: '', measuredEnd: '', outboundStart: '', outboundEnd: '' }) } function resetFilters() { Object.assign(filters, { number: '', inboundNo: '', warehouse: '', destination: '', status: '', start: '', end: '', reviewStart: '', reviewEnd: '', completedStart: '', completedEnd: '', measuredStart: '', measuredEnd: '', outboundStart: '', outboundEnd: '' }) }
function resetForm() { function resetForm() {
@@ -399,6 +486,14 @@ function returnToList() {
function serviceListRouteName(service: ServiceType) { function serviceListRouteName(service: ServiceType) {
return ({ labeling: 'overseas-labeling', transfer: 'overseas-storage-transfer', disposal: 'overseas-cargo-disposal', relabel: 'overseas-return-relabel' } as Record<ServiceType, string>)[service] return ({ labeling: 'overseas-labeling', transfer: 'overseas-storage-transfer', disposal: 'overseas-cargo-disposal', relabel: 'overseas-return-relabel' } as Record<ServiceType, string>)[service]
} }
async function goToServiceList() {
if (props.createPage || props.detailPage) {
await router.push({ name: serviceListRouteName(props.service) })
return
}
mode.value = 'list'
await loadPage()
}
async function openEdit(transferNo: string) { async function openEdit(transferNo: string) {
try { try {
@@ -422,7 +517,7 @@ async function openEdit(transferNo: string) {
transferDestinationType.value = detail.destinationType || (detail.fbaWarehouseCode ? 'FBA' : 'NONE') transferDestinationType.value = detail.destinationType || (detail.fbaWarehouseCode ? 'FBA' : 'NONE')
cargoRows.value = (detail.items || []).map((item) => ({ cargoRows.value = (detail.items || []).map((item) => ({
...createCargoRow(), sku: item.skuCode || '', name: item.productNameZh || '', ...createCargoRow(), sku: item.skuCode || '', name: item.productNameZh || '',
quantity: Number(item.packageCount || 0), shippingMark: item.shippingMark || '', quantity: Number(item.quantity || 0), expectedPackageCount: Number(item.packageCount || 0), shippingMark: item.shippingMark || '',
customerCartonMarks: item.shippingMark || '', customerCartonMarks: item.shippingMark || '',
})) }))
const bindings = detail.relabelBindings || [] const bindings = detail.relabelBindings || []
@@ -459,6 +554,8 @@ async function openEdit(transferNo: string) {
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '草稿加载失败') } } catch (error) { ElMessage.error(error instanceof Error ? error.message : '草稿加载失败') }
} }
async function selectDiscardWarehouse() { async function selectDiscardWarehouse() {
discardSelectedBoxKeys.value = []
discardBoxSearch.value = ''
selectedDiscardOrderId.value = '' selectedDiscardOrderId.value = ''
form.inboundNo = '' form.inboundNo = ''
discardCandidates.value = [] discardCandidates.value = []
@@ -467,60 +564,36 @@ async function selectDiscardWarehouse() {
if (!form.warehouse) return if (!form.warehouse) return
discardCandidatesLoading.value = true discardCandidatesLoading.value = true
try { try {
const inventory = await listRelabelInventoryCandidates(form.warehouse) const warehouse = form.warehouse
const orderByInbound = new Map<string, string>() const candidates = await listDiscardCandidates()
const inboundCodes = [...new Set(inventory.map((candidate) => String(candidate.inboundCode || '')).filter(Boolean))] if (form.warehouse !== warehouse) return
const historicalGroups = await Promise.all(inboundCodes.map(async (inboundCode) => ({ inboundCode, orders: await listRelabelHistoricalOrders(form.warehouse, inboundCode) }))) discardCandidates.value = candidates
for (const { inboundCode, orders } of historicalGroups) { .filter((candidate) => String(candidate.overseasWarehouseId) === warehouse)
for (const order of orders) { .filter((candidate) => candidate.sourceType === 'DROPSHIP' ? Boolean(candidate.orderId) : candidate.sourceType === 'RETURN_CARTON' ? Boolean(candidate.returnCartonId) : candidate.sourceType === 'INVENTORY' && Boolean(candidate.inventoryPositionId))
if (order.id) orderByInbound.set(inboundCode, String(order.id)) .map((candidate) => ({ ...candidate, orderId: candidate.sourceType === 'RETURN_CARTON' ? `return:${candidate.returnCartonId}` : candidate.sourceType === 'INVENTORY' ? `inventory:${candidate.inventoryPositionId}` : String(candidate.orderId) }))
}
}
const grouped = new Map<string, import('../api/warehouseServices').DiscardCandidate>()
for (const candidate of inventory) {
const returnCartonId = String(candidate.returnCartonId || '')
const sourceType = candidate.sourceType === 'RETURN_CARTON' ? 'RETURN_CARTON' : 'DROPSHIP'
const inboundCode = String(candidate.inboundCode || '')
const orderId = sourceType === 'RETURN_CARTON' ? `return:${returnCartonId}` : (orderByInbound.get(inboundCode) || '')
if (!orderId || (sourceType === 'RETURN_CARTON' && !returnCartonId)) continue
const current = grouped.get(orderId) || { orderId, inboundCode, inboundOrderNo: candidate.inboundOrderNo || '', overseasWarehouseId: String(candidate.overseasWarehouseId || form.warehouse), overseasWarehouseName: candidate.overseasWarehouseName || '', availablePackages: 0, skus: [] }
current.sourceType = sourceType
current.returnCartonId = returnCartonId || undefined
current.cartonMark = candidate.cartonMark || undefined
const packages = inventoryQuantity(candidate.availablePackages)
current.availablePackages += packages
const skuCode = String(candidate.skuCode || '').trim()
if (skuCode) {
const sku = current.skus.find((item) => item.skuCode === skuCode)
if (sku) sku.availablePackages += packages
else current.skus.push({ skuCode, productName: candidate.productNameZh || '', availablePackages: packages })
}
grouped.set(orderId, current)
}
discardCandidates.value = [...grouped.values()].filter((candidate) => String(candidate.overseasWarehouseId) === form.warehouse)
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '可废弃入库单加载失败') } } catch (error) { ElMessage.error(error instanceof Error ? error.message : '可废弃入库单加载失败') }
finally { discardCandidatesLoading.value = false } finally { discardCandidatesLoading.value = false }
} }
function createCargoRow(): CargoRow { return { id: ++cargoSequence, sku: '', name: '', quantity: 1, fnsku: '', shipmentNo: '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', cartonMark: '', fbaCartonMark: '', destinationWarehouseId: '', remark: '' } } function createCargoRow(): CargoRow { return { id: ++cargoSequence, sku: '', name: '', quantity: 1, expectedPackageCount: 1, fnsku: '', shipmentNo: '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', cartonMark: '', fbaCartonMark: '', destinationWarehouseId: '', remark: '' } }
function addCargoRow() { cargoRows.value.push(createCargoRow()) } function addCargoRow() { cargoRows.value.push(createCargoRow()) }
function escapePrintHtml(value: string) { function escapePrintHtml(value: string) {
return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;') return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;')
} }
function printTransferDraftCartonMarks() { function printTransferDraftCartonMarks() {
if (props.service !== 'transfer') return if (props.service !== 'transfer') return
const validRows = cargoRows.value.filter((row) => row.sku.trim() && Number(row.quantity) > 0) const validRows = cargoRows.value.filter((row) => row.sku.trim() && Number(row.expectedPackageCount) > 0)
if (!validRows.length || validRows.some((row) => !Number.isInteger(Number(row.quantity)))) { if (!validRows.length || validRows.some((row) => !Number.isInteger(Number(row.expectedPackageCount)))) {
ElMessage.warning('请先填写商品 SKU 和对应的整数箱数'); return ElMessage.warning('请先填写商品 SKU 和对应的整数箱数'); return
} }
const totalCartons = validRows.reduce((sum, row) => sum + Number(row.quantity), 0) const totalCartons = validRows.reduce((sum, row) => sum + Number(row.expectedPackageCount), 0)
if (totalCartons > 500) { ElMessage.warning('单次最多打印 500 张箱唛'); return } if (totalCartons > 500) { ElMessage.warning('单次最多打印 500 张箱唛'); return }
const batch = new Date().toISOString().replace(/\D/g, '').slice(2, 14) const batch = new Date().toISOString().replace(/\D/g, '').slice(2, 14)
const labels: Array<{ mark: string; sku: string; index: number }> = [] const labels: Array<{ mark: string; sku: string; index: number }> = []
let cartonIndex = 0 let cartonIndex = 0
for (const [rowIndex, row] of validRows.entries()) { for (const [rowIndex, row] of validRows.entries()) {
let marks = transferCustomerMarks(row) let marks = transferCustomerMarks(row)
if (marks.length !== Number(row.quantity)) { if (marks.length !== Number(row.expectedPackageCount)) {
marks = Array.from({ length: Number(row.quantity) }, (_, index) => `CT-${batch}-${String(rowIndex + 1).padStart(2, '0')}-${String(index + 1).padStart(3, '0')}`) marks = Array.from({ length: Number(row.expectedPackageCount) }, (_, index) => `CT-${batch}-${String(rowIndex + 1).padStart(2, '0')}-${String(index + 1).padStart(3, '0')}`)
row.customerCartonMarks = marks.join('\n') row.customerCartonMarks = marks.join('\n')
} }
for (const mark of marks) labels.push({ mark, sku: row.sku.trim(), index: ++cartonIndex }) for (const mark of marks) labels.push({ mark, sku: row.sku.trim(), index: ++cartonIndex })
@@ -677,7 +750,7 @@ function downloadTransferCartonMarkTemplate() {
ElMessage.warning('请先填写完整的 SKU 明细'); return ElMessage.warning('请先填写完整的 SKU 明细'); return
} }
const header = ['SKU', '客户箱唛号'] const header = ['SKU', '客户箱唛号']
const rows = cargoRows.value.flatMap((row) => Array.from({ length: row.quantity }, () => [row.sku, ''])) const rows = cargoRows.value.flatMap((row) => Array.from({ length: Number(row.expectedPackageCount || 0) }, () => [row.sku, '']))
const blob = new Blob([`\uFEFF${[header, ...rows].map((row) => row.map(csvCell).join(',')).join('\r\n')}\r\n`], { type: 'text/csv;charset=utf-8' }) const blob = new Blob([`\uFEFF${[header, ...rows].map((row) => row.map(csvCell).join(',')).join('\r\n')}\r\n`], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob); const anchor = document.createElement('a') const url = URL.createObjectURL(blob); const anchor = document.createElement('a')
anchor.href = url; anchor.download = '仓储转运-客户箱唛模板.csv'; anchor.click(); URL.revokeObjectURL(url) anchor.href = url; anchor.download = '仓储转运-客户箱唛模板.csv'; anchor.click(); URL.revokeObjectURL(url)
@@ -865,14 +938,17 @@ function importSelectedBindingsToDetails() {
ElMessage.success(`已将 ${selected.length} 条绑定关系导入本次换标明细`) ElMessage.success(`已将 ${selected.length} 条绑定关系导入本次换标明细`)
} }
function selectDiscardInbound(orderId: string) { function selectDiscardInbound(orderId: string) {
discardSelectedBoxKeys.value = []
discardBoxSearch.value = ''
const candidate = discardCandidates.value.find((item) => item.orderId === orderId) const candidate = discardCandidates.value.find((item) => item.orderId === orderId)
if (!candidate) { cargoRows.value = []; return } if (!candidate) { cargoRows.value = []; return }
form.inboundNo = candidate.inboundCode || candidate.inboundOrderNo form.inboundNo = candidate.inboundCode || candidate.inboundOrderNo
form.warehouse = candidate.overseasWarehouseId form.warehouse = candidate.overseasWarehouseId
if (candidate.cartonSelectionSupported) { selectAllDiscardBoxes(true); return }
cargoRows.value = candidate.skus.map((sku) => ({ cargoRows.value = candidate.skus.map((sku) => ({
id: ++cargoSequence, sku: sku.skuCode, name: sku.productName, quantity: 0, id: ++cargoSequence, sku: sku.skuCode, name: sku.productName, quantity: sku.availablePackages,
availableQuantity: sku.availablePackages, sourceOrderId: candidate.orderId, sourceType: candidate.sourceType, availableQuantity: sku.availablePackages, sourceOrderId: candidate.orderId, sourceType: candidate.sourceType,
returnCartonId: candidate.returnCartonId, cartonMark: candidate.cartonMark || '', returnCartonId: candidate.returnCartonId, sourceInventoryPositionId: candidate.inventoryPositionId, palletNo: candidate.palletNo, cartonMark: candidate.cartonMark || '',
fnsku: '', shipmentNo: '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', fbaCartonMark: '', destinationWarehouseId: '', remark: '', fnsku: '', shipmentNo: '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', fbaCartonMark: '', destinationWarehouseId: '', remark: '',
})) }))
} }
@@ -910,7 +986,7 @@ function statusLabel(value: string) {
PENDING_PROCESSING: '待处理', PROCESSING: '处理中', CUSTOMER_CONFIRM_PENDING: '待客户确认', PENDING_PROCESSING: '待处理', PROCESSING: '处理中', CUSTOMER_CONFIRM_PENDING: '待客户确认',
READY_FOR_OUTBOUND: '换标完成', COMPLETED: '已完成', CANCELLED: '已取消', READY_FOR_OUTBOUND: '换标完成', COMPLETED: '已完成', CANCELLED: '已取消',
DRAFT: '草稿', PENDING_REVIEW: '待审核', APPROVED: '待 PDA 废弃', PDA_COMPLETED: '待仓库确认完成', REJECTED: '已拒绝', PENDING_ASSIGNMENT: '待分配', DRAFT: '草稿', PENDING_REVIEW: '待审核', APPROVED: '待 PDA 废弃', PDA_COMPLETED: '待客户确认', REJECTED: '已拒绝', PENDING_ASSIGNMENT: '待分配',
WAIT_PDA_RECEIVING: '待PDA收货', WAIT_CUSTOMER_CONFIRMATION: '待客户确认实测', WAIT_FBA_DESTINATION: '待确认目的地', WAIT_RELABEL: '待换标', READY_FOR_DISPATCH: '待排车', WAIT_OUTBOUND: '待出库', WAIT_PDA_RECEIVING: '待PDA收货', WAIT_CUSTOMER_CONFIRMATION: '待客户确认实测', WAIT_FBA_DESTINATION: '待确认目的地', WAIT_RELABEL: '待换标', READY_FOR_DISPATCH: '待排车', WAIT_OUTBOUND: '待出库',
ALLOCATED: '已分配', OUTBOUNDED: '已出库', PARTIALLY_RECEIVED: '部分收货', RECEIVED: '已收货', INBOUND_COMPLETED: '入库完成', PENDING_DELIVERY: '待派送', DELIVERING: '派送中', DELIVERED: '派送完成', ALLOCATED: '已分配', OUTBOUNDED: '已出库', PARTIALLY_RECEIVED: '部分收货', RECEIVED: '已收货', INBOUND_COMPLETED: '入库完成', PENDING_DELIVERY: '待派送', DELIVERING: '派送中', DELIVERED: '派送完成',
@@ -981,7 +1057,7 @@ function transferRelabelStatusTagType(status: string): StatusTagType {
} }
function deliveryMethodLabel(value: string) { function deliveryMethodLabel(value: string) {
const labels: Record<string, string> = { const labels: Record<string, string> = {
TRUCK: '卡车派送', EXPRESS: '快递派送', TRUCK_PICKUP: '卡车自提', EXPRESS_PICKUP: '快递自提', TRUCK: '卡车派送', EXPRESS: '快递派送', AMAZON_PICKUP: '亚马逊自提', CUSTOMER_PICKUP: '客户自提',
WAREHOUSE_PICKUP: '仓库上门提货', CUSTOMER_DELIVERY: '客户送货到仓', WAREHOUSE_PICKUP: '仓库上门提货', CUSTOMER_DELIVERY: '客户送货到仓',
// Keep the legacy value readable for historical applications created before the four-way split. // Keep the legacy value readable for historical applications created before the four-way split.
PICKUP: '仓库自提', PICKUP: '仓库自提',
@@ -1009,7 +1085,7 @@ async function load() {
} else if (props.service === 'disposal') { } else if (props.service === 'disposal') {
const result = await listDiscards({ page: page.value, size: pageSize.value, discardNo: filters.number || undefined, inboundCode: filters.inboundNo || undefined, overseasWarehouseId: filters.warehouse || undefined, status: filters.status || undefined, createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true), completedFrom: dateTime(filters.completedStart), completedTo: dateTime(filters.completedEnd, true) }) const result = await listDiscards({ page: page.value, size: pageSize.value, discardNo: filters.number || undefined, inboundCode: filters.inboundNo || undefined, overseasWarehouseId: filters.warehouse || undefined, status: filters.status || undefined, createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true), completedFrom: dateTime(filters.completedStart), completedTo: dateTime(filters.completedEnd, true) })
total.value = Number.isFinite(Number(result.total)) ? Number(result.total) : result.records.length total.value = Number.isFinite(Number(result.total)) ? Number(result.total) : result.records.length
submittedRows.value = result.records.map((item) => row([item.discardNo, item.outboundNo || '—', item.inboundCodes, item.overseasWarehouseName || warehouseDisplay(item.overseasWarehouseId) || '—', '—', item.totalPackages, `${item.currency} ${item.totalFee}`, statusLabel(item.status), formatDateTime(item.completedAt), formatDateTime(item.createdAt)])) submittedRows.value = result.records.map((item) => ({ ...row([item.discardNo, item.outboundNo || '—', item.inboundCodes, item.overseasWarehouseName || warehouseDisplay(item.overseasWarehouseId) || '—', '—', item.totalPackages, `${item.currency} ${item.totalFee}`, statusLabel(item.status), formatDateTime(item.customerConfirmedAt), formatDateTime(item.createdAt)]), __status: item.status }))
} else { } else {
const result = await listTransfers({ page: page.value, size: pageSize.value, transferNo: filters.number || undefined, inboundCode: filters.inboundNo || undefined, operatingWarehouseId: filters.warehouse || undefined, fbaWarehouseCode: filters.destination || undefined, status: filters.status || undefined, ...(props.service === 'relabel' ? { needRelabel: true } : {}), createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true), reviewedFrom: dateTime(filters.reviewStart), reviewedTo: dateTime(filters.reviewEnd, true), measuredFrom: props.service === 'relabel' ? dateTime(filters.measuredStart) : dateTime(filters.completedStart), measuredTo: props.service === 'relabel' ? dateTime(filters.measuredEnd, true) : dateTime(filters.completedEnd, true), outboundFrom: dateTime(filters.outboundStart), outboundTo: dateTime(filters.outboundEnd, true) }) const result = await listTransfers({ page: page.value, size: pageSize.value, transferNo: filters.number || undefined, inboundCode: filters.inboundNo || undefined, operatingWarehouseId: filters.warehouse || undefined, fbaWarehouseCode: filters.destination || undefined, status: filters.status || undefined, ...(props.service === 'relabel' ? { needRelabel: true } : {}), createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true), reviewedFrom: dateTime(filters.reviewStart), reviewedTo: dateTime(filters.reviewEnd, true), measuredFrom: props.service === 'relabel' ? dateTime(filters.measuredStart) : dateTime(filters.completedStart), measuredTo: props.service === 'relabel' ? dateTime(filters.measuredEnd, true) : dateTime(filters.completedEnd, true), outboundFrom: dateTime(filters.outboundStart), outboundTo: dateTime(filters.outboundEnd, true) })
total.value = Number.isFinite(Number(result.total)) ? Number(result.total) : result.records.length total.value = Number.isFinite(Number(result.total)) ? Number(result.total) : result.records.length
@@ -1032,6 +1108,7 @@ function listRowActions(candidate: object): TableAction[] {
if (canCreate.value && props.service === 'transfer' && ['DRAFT', 'REJECTED'].includes(item.__status)) actions.push({ key: 'edit', label: '编辑', run: () => openEdit(item['0']) }) if (canCreate.value && props.service === 'transfer' && ['DRAFT', 'REJECTED'].includes(item.__status)) actions.push({ key: 'edit', label: '编辑', run: () => openEdit(item['0']) })
if (canConfirmTransfer.value && props.service === 'transfer' && item.__status === 'WAIT_CUSTOMER_CONFIRMATION') actions.push({ key: 'confirm-measurement', label: '确认测量', run: () => confirmTransferFromList(item['0']) }) if (canConfirmTransfer.value && props.service === 'transfer' && item.__status === 'WAIT_CUSTOMER_CONFIRMATION') actions.push({ key: 'confirm-measurement', label: '确认测量', run: () => confirmTransferFromList(item['0']) })
if (canConfirmRelabel.value && props.service === 'labeling' && item.__status === 'CUSTOMER_CONFIRM_PENDING' && !item.__confirmedAt) actions.push({ key: 'confirm-relabel', label: '确认换标', run: () => confirmRelabelFromList(item['0']) }) if (canConfirmRelabel.value && props.service === 'labeling' && item.__status === 'CUSTOMER_CONFIRM_PENDING' && !item.__confirmedAt) actions.push({ key: 'confirm-relabel', label: '确认换标', run: () => confirmRelabelFromList(item['0']) })
if (canConfirmDiscard.value && props.service === 'disposal' && item.__status === 'PDA_COMPLETED') actions.push({ key: 'confirm-discard', label: '废弃确认', run: () => openCompletionConfirmation(item['0'], 'disposal') })
return actions return actions
} }
watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => { watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
@@ -1047,7 +1124,7 @@ async function submit(action: 'draft' | 'submit' = 'submit') {
: (props.service === 'labeling' || props.service === 'relabel') && (relabelRows.length === 0 || (props.service === 'labeling' && labelingMode.value !== 'carton' : (props.service === 'labeling' || props.service === 'relabel') && (relabelRows.length === 0 || (props.service === 'labeling' && labelingMode.value !== 'carton'
? skuRelabelRows.value.some((item) => !item.cartonMark || !item.originalSku || !item.newSku || item.quantity < 1 ? skuRelabelRows.value.some((item) => !item.cartonMark || !item.originalSku || !item.newSku || item.quantity < 1
|| (labelingMode.value === 'carton_and_sku' && (!item.fbaCartonMark || !item.shipmentNo.trim()))) || (labelingMode.value === 'carton_and_sku' && (!item.fbaCartonMark || !item.shipmentNo.trim())))
: cargoRows.value.some((item) => (props.service !== 'labeling' && !item.sku) || !item.name : (props.service === 'labeling' ? cartonDetailRows.value : cargoRows.value).some((item) => (props.service !== 'labeling' && !item.sku) || !item.name
|| (props.service !== 'labeling' && item.quantity < 1)))) || (props.service !== 'labeling' && item.quantity < 1))))
if ((props.service === 'relabel' && !form.inboundNo) || !primaryWarehouse || invalidCargo) { if ((props.service === 'relabel' && !form.inboundNo) || !primaryWarehouse || invalidCargo) {
ElMessage.warning('请填写必填信息和完整的货物明细'); return ElMessage.warning('请填写必填信息和完整的货物明细'); return
@@ -1067,7 +1144,7 @@ async function submit(action: 'draft' | 'submit' = 'submit') {
if (props.service === 'labeling' && labelingMode.value !== 'sku' && !form.destination) { if (props.service === 'labeling' && labelingMode.value !== 'sku' && !form.destination) {
ElMessage.warning('请选择本张贴标申请统一使用的 FBA 目的仓'); return ElMessage.warning('请选择本张贴标申请统一使用的 FBA 目的仓'); return
} }
if (props.service === 'labeling' && labelingMode.value !== 'sku' && (form.deliveryMethod === 'EXPRESS' || form.deliveryMethod === 'EXPRESS_PICKUP') if (props.service === 'labeling' && labelingMode.value !== 'sku' && form.deliveryMethod === 'EXPRESS'
&& form.relabel && !shippingLabelFile.value) { && form.relabel && !shippingLabelFile.value) {
ElMessage.warning('选择打印面单时必须上传面单文件'); return ElMessage.warning('选择打印面单时必须上传面单文件'); return
} }
@@ -1083,10 +1160,14 @@ async function submit(action: 'draft' | 'submit' = 'submit') {
if (props.service === 'relabel' && (!form.destination || !form.address || !form.deliveryMethod || !form.estimatedArrivalAt || !form.fbaShipmentNo || form.volumeCbm <= 0)) { if (props.service === 'relabel' && (!form.destination || !form.address || !form.deliveryMethod || !form.estimatedArrivalAt || !form.fbaShipmentNo || form.volumeCbm <= 0)) {
ElMessage.warning('请填写完整的派送信息、体积、预计到达时间和 FBA 货件号'); return ElMessage.warning('请填写完整的派送信息、体积、预计到达时间和 FBA 货件号'); return
} }
if (props.service === 'transfer' && (!form.warehouse || !form.deliveryMethod || !form.address || !form.estimatedArrivalAt || form.volumeCbm < 0 || (form.deliveryMethod === 'WAREHOUSE_PICKUP' && (!form.pickupContact || !form.pickupPhone)))) { if (props.service === 'transfer' && (!form.warehouse || !form.deliveryMethod || !form.address || !form.estimatedArrivalAt || (form.deliveryMethod === 'WAREHOUSE_PICKUP' && (!form.pickupContact || !form.pickupPhone)))) {
const missing = [!form.warehouse && '海外仓', !form.deliveryMethod && '送货方式', !form.address && '地址', form.deliveryMethod === 'WAREHOUSE_PICKUP' && !form.pickupContact && '提货联系人', form.deliveryMethod === 'WAREHOUSE_PICKUP' && !form.pickupPhone && '联系人电话', !form.estimatedArrivalAt && '预计入仓时间', form.volumeCbm < 0 && '预估总体积'].filter(Boolean).join('、') const missing = [!form.warehouse && '海外仓', !form.deliveryMethod && '送货方式', !form.address && '地址', form.deliveryMethod === 'WAREHOUSE_PICKUP' && !form.pickupContact && '提货联系人', form.deliveryMethod === 'WAREHOUSE_PICKUP' && !form.pickupPhone && '联系人电话', !form.estimatedArrivalAt && '预计入仓时间'].filter(Boolean).join('、')
ElMessage.warning(`请填写:${missing || '必填信息'}`); return ElMessage.warning(`请填写:${missing || '必填信息'}`); return
} }
if (props.service === 'transfer' && (!cargoRows.value.length || cargoRows.value.some((row) =>
!Number.isSafeInteger(row.quantity) || row.quantity < 1 || !Number.isSafeInteger(row.expectedPackageCount) || Number(row.expectedPackageCount) < 1))) {
ElMessage.warning('请填写每个 SKU 的数量(件)和预计箱数,均须为正整数'); return
}
if (props.service === 'transfer' && transferDestinationType.value === 'FBA' && !form.destination) { if (props.service === 'transfer' && transferDestinationType.value === 'FBA' && !form.destination) {
ElMessage.warning('请选择 FBA 目的仓'); return ElMessage.warning('请选择 FBA 目的仓'); return
} }
@@ -1119,39 +1200,39 @@ async function submit(action: 'draft' | 'submit' = 'submit') {
const selectedOperationType = labelingMode.value === 'carton' ? 'CARTON_LABEL' : labelingMode.value === 'sku' ? 'SKU_LABEL' : 'CARTON_AND_SKU' const selectedOperationType = labelingMode.value === 'carton' ? 'CARTON_LABEL' : labelingMode.value === 'sku' ? 'SKU_LABEL' : 'CARTON_AND_SKU'
if (items.some(item => item.operationType !== selectedOperationType)) throw new Error('一张申请只能选择一种换标类型,请核对绑定明细') if (items.some(item => item.operationType !== selectedOperationType)) throw new Error('一张申请只能选择一种换标类型,请核对绑定明细')
const created = await createRelabel({ customerOrderNo: sourceInboundOrders || '贴标申请', operationWarehouseId: form.warehouse, labelDeadline: form.labelDeadline, contactName: form.contact, contactPhone: form.phone, remark: form.remark || undefined, idempotencyKey, items, const created = await createRelabel({ customerOrderNo: sourceInboundOrders || '贴标申请', operationWarehouseId: form.warehouse, labelDeadline: form.labelDeadline, contactName: form.contact, contactPhone: form.phone, remark: form.remark || undefined, idempotencyKey, items,
deliveryMethod: labelingMode.value === 'sku' ? undefined : form.deliveryMethod as 'TRUCK' | 'EXPRESS' | 'TRUCK_PICKUP' | 'EXPRESS_PICKUP', deliveryMethod: labelingMode.value === 'sku' ? undefined : form.deliveryMethod as 'TRUCK' | 'EXPRESS' | 'AMAZON_PICKUP' | 'CUSTOMER_PICKUP',
printShippingLabel: labelingMode.value !== 'sku' && (form.deliveryMethod === 'EXPRESS' || form.deliveryMethod === 'EXPRESS_PICKUP') && form.relabel }) printShippingLabel: labelingMode.value !== 'sku' && form.deliveryMethod === 'EXPRESS' && form.relabel })
try { try {
if (cartonTotalFile.value) await uploadRelabelFile(created.application.relabelNo, 'CARTON_TOTAL', cartonTotalFile.value) if (cartonTotalFile.value) await uploadRelabelFile(created.application.relabelNo, 'CARTON_TOTAL', cartonTotalFile.value)
if (skuTotalFile.value) await uploadRelabelFile(created.application.relabelNo, 'SKU_TOTAL', skuTotalFile.value) if (skuTotalFile.value) await uploadRelabelFile(created.application.relabelNo, 'SKU_TOTAL', skuTotalFile.value)
if (shippingLabelFile.value) await uploadRelabelShippingLabel(created.application.relabelNo, shippingLabelFile.value) if (shippingLabelFile.value) await uploadRelabelShippingLabel(created.application.relabelNo, shippingLabelFile.value)
} catch (error) { } catch (error) {
// Creation already committed: return to the list even when an attachment still needs retrying. // Creation already committed: return to the list even when an attachment still needs retrying.
if (props.createPage) await router.push({ name: serviceListRouteName(props.service) }) await goToServiceList()
else { mode.value = 'list'; await load(); await openDetail({ '0': created.application.relabelNo }) }
await ElMessageBox.alert(`申请 ${created.application.relabelNo} 已创建,部分附件上传失败:${error instanceof Error ? error.message : '上传失败'}。请在原单详情补传失败附件,不要重复下单。`, '申请已创建,附件待补传', { confirmButtonText: '知道了' }) await ElMessageBox.alert(`申请 ${created.application.relabelNo} 已创建,部分附件上传失败:${error instanceof Error ? error.message : '上传失败'}。请在原单详情补传失败附件,不要重复下单。`, '申请已创建,附件待补传', { confirmButtonText: '知道了' })
return return
} }
ElMessage.success(`${config.value.title}提交成功`) ElMessage.success(`${config.value.title}提交成功`)
await router.push({ name: serviceListRouteName(props.service) }) await goToServiceList()
return return
} else if (props.service === 'disposal') { } else if (props.service === 'disposal') {
const items = cargoRows.value.filter((item) => item.quantity > 0).map((item) => { const items = cargoRows.value.filter((item) => item.quantity > 0).map((item) => {
if (!item.sourceOrderId || item.sourceOrderId !== selectedDiscardOrderId.value || item.quantity > Number(item.availableQuantity || 0)) { if (!item.sourceOrderId || !discardSelectionSources.value.some(source => source.orderId === item.sourceOrderId) || item.quantity > Number(item.availableQuantity || 0)) {
throw new Error(`SKU ${item.sku} 的可废弃库存不足`) throw new Error(`SKU ${item.sku} 的可废弃库存不足`)
} }
return { orderId: item.sourceType === 'RETURN_CARTON' ? undefined : item.sourceOrderId, return { orderId: item.sourceType === 'DROPSHIP' ? item.sourceOrderId : undefined,
inventoryPositionId: item.sourceType === 'INVENTORY' ? item.sourceInventoryPositionId : undefined,
returnCartonId: item.sourceType === 'RETURN_CARTON' ? item.returnCartonId : undefined, returnCartonId: item.sourceType === 'RETURN_CARTON' ? item.returnCartonId : undefined,
skuCode: item.sku, discardPackages: item.quantity } skuCode: item.sku, discardPackages: item.quantity, cartonIds: item.discardCartonIds }
}) })
await createDiscard({ items, idempotencyKey, remark: [form.reason, form.remark].filter(Boolean).join(';') || undefined }) await createDiscard({ items, idempotencyKey, remark: [form.reason, form.remark].filter(Boolean).join(';') || undefined })
} else { } else {
const packageCount = cargoRows.value.reduce((sum, item) => sum + item.quantity, 0) const packageCount = cargoRows.value.reduce((sum, item) => sum + Number(item.expectedPackageCount || 0), 0)
const customerCartonMarks = (form.relabel ? transferRelabelRows.value : cargoRows.value).flatMap(transferCustomerMarks) const customerCartonMarks = (form.relabel ? transferRelabelRows.value : cargoRows.value).flatMap(transferCustomerMarks)
const transferRemark = [form.remark, form.relabel && form.requirement ? `换标要求:${form.requirement}` : ''].filter(Boolean).join(';') const transferRemark = [form.remark, form.relabel && form.requirement ? `换标要求:${form.requirement}` : ''].filter(Boolean).join(';')
const operationType = labelingMode.value === 'carton' ? 'CARTON_LABEL' : labelingMode.value === 'sku' ? 'SKU_LABEL' : 'CARTON_AND_SKU' const operationType = labelingMode.value === 'carton' ? 'CARTON_LABEL' : labelingMode.value === 'sku' ? 'SKU_LABEL' : 'CARTON_AND_SKU'
const selectedFba = fbaWarehouses.value.find((warehouse) => warehouse.warehouseCode === form.destination) const selectedFba = fbaWarehouses.value.find((warehouse) => warehouse.warehouseCode === form.destination)
const payload: TransferCreate = { operatingWarehouseId: primaryWarehouse, destinationType: transferDestinationType.value, fbaWarehouseCode: transferDestinationType.value === 'FBA' ? form.destination || undefined : undefined, destinationWarehouseId: transferDestinationType.value === 'FBA' ? String(selectedFba?.id || '') || undefined : undefined, destinationWarehouseCode: transferDestinationType.value === 'FBA' ? form.destination || undefined : undefined, destinationWarehouseName: transferDestinationType.value === 'FBA' ? selectedFba?.warehouseName : undefined, privateDestinationAddress: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationAddress : undefined, privateDestinationPostalCode: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationPostalCode : undefined, privateDestinationContactName: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationContact : undefined, privateDestinationContactPhone: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationPhone : undefined, deliveryAddress: form.address, packageCount, volumeCbm: form.volumeCbm, deliveryMethod: form.deliveryMethod, estimatedArrivalAt: form.estimatedArrivalAt, estimatedShipAt: form.estimatedShipAt || undefined, fbaShipmentNo: transferDestinationType.value === 'FBA' ? form.fbaShipmentNo || undefined : undefined, trackingNo: form.deliveryMethod === 'CUSTOMER_DELIVERY' ? form.trackingNo || undefined : undefined, fbaBoxNos: customerCartonMarks.length ? customerCartonMarks.join(',') : undefined, remark: transferRemark || undefined, needRelabel: props.service === 'relabel' || form.relabel, labelDeadline: form.relabel ? form.labelDeadline : undefined, pickupContactName: form.deliveryMethod === 'WAREHOUSE_PICKUP' ? form.pickupContact : undefined, pickupContactPhone: form.deliveryMethod === 'WAREHOUSE_PICKUP' ? form.pickupPhone : undefined, relabelContactName: form.relabel ? form.relabelContact : undefined, relabelContactPhone: form.relabel ? form.relabelPhone : undefined, relabelDeliveryMethod: form.relabel && labelingMode.value !== 'sku' ? form.relabelDeliveryMethod as 'TRUCK' | 'EXPRESS' | 'TRUCK_PICKUP' | 'EXPRESS_PICKUP' : undefined, idempotencyKey, items: cargoRows.value.map((item) => ({ skuCode: item.sku, productNameZh: item.name, packageCount: item.quantity, shippingMark: transferCustomerMarks(item).join(',') || undefined })), relabelItems: form.relabel ? transferRelabelRows.value.map((item) => ({ operationType, customerCartonMarks: transferCustomerMarks(item), originalSku: item.sku, newSku: item.newSkuCode || undefined, quantity: item.quantity, fbaCartonMark: item.fbaCartonMark || undefined, destinationWarehouseCode: transferRelabelDefaultDestination.value || undefined, remark: item.remark || undefined })) : undefined } const payload: TransferCreate = { operatingWarehouseId: primaryWarehouse, destinationType: transferDestinationType.value, fbaWarehouseCode: transferDestinationType.value === 'FBA' ? form.destination || undefined : undefined, destinationWarehouseId: transferDestinationType.value === 'FBA' ? String(selectedFba?.id || '') || undefined : undefined, destinationWarehouseCode: transferDestinationType.value === 'FBA' ? form.destination || undefined : undefined, destinationWarehouseName: transferDestinationType.value === 'FBA' ? selectedFba?.warehouseName : undefined, privateDestinationAddress: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationAddress : undefined, privateDestinationPostalCode: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationPostalCode : undefined, privateDestinationContactName: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationContact : undefined, privateDestinationContactPhone: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationPhone : undefined, deliveryAddress: form.address, packageCount, volumeCbm: 0, deliveryMethod: form.deliveryMethod, estimatedArrivalAt: form.estimatedArrivalAt, estimatedShipAt: form.estimatedShipAt || undefined, fbaShipmentNo: transferDestinationType.value === 'FBA' ? form.fbaShipmentNo || undefined : undefined, trackingNo: form.deliveryMethod === 'CUSTOMER_DELIVERY' ? form.trackingNo || undefined : undefined, fbaBoxNos: customerCartonMarks.length ? customerCartonMarks.join(',') : undefined, remark: transferRemark || undefined, needRelabel: props.service === 'relabel' || form.relabel, labelDeadline: form.relabel ? form.labelDeadline : undefined, pickupContactName: form.deliveryMethod === 'WAREHOUSE_PICKUP' ? form.pickupContact : undefined, pickupContactPhone: form.deliveryMethod === 'WAREHOUSE_PICKUP' ? form.pickupPhone : undefined, relabelContactName: form.relabel ? form.relabelContact : undefined, relabelContactPhone: form.relabel ? form.relabelPhone : undefined, relabelDeliveryMethod: form.relabel && labelingMode.value !== 'sku' ? form.relabelDeliveryMethod as 'TRUCK' | 'EXPRESS' | 'AMAZON_PICKUP' | 'CUSTOMER_PICKUP' : undefined, idempotencyKey, items: cargoRows.value.map((item) => ({ skuCode: item.sku, productNameZh: item.name, quantity: item.quantity, packageCount: Number(item.expectedPackageCount), shippingMark: transferCustomerMarks(item).join(',') || undefined })), relabelItems: form.relabel ? transferRelabelRows.value.map((item) => ({ operationType, customerCartonMarks: transferCustomerMarks(item), originalSku: item.sku, newSku: item.newSkuCode || undefined, quantity: item.quantity, fbaCartonMark: item.fbaCartonMark || undefined, destinationWarehouseCode: transferRelabelDefaultDestination.value || undefined, remark: item.remark || undefined })) : undefined }
if (props.service === 'transfer' && selectedTransferInventory.value) { if (props.service === 'transfer' && selectedTransferInventory.value) {
;(payload as TransferCreate).inboundCode = selectedTransferInventory.value ;(payload as TransferCreate).inboundCode = selectedTransferInventory.value
;(payload as TransferCreate).sourceLastlegOrderIds = selectedTransferSourceOrderIds.value ;(payload as TransferCreate).sourceLastlegOrderIds = selectedTransferSourceOrderIds.value
@@ -1168,7 +1249,7 @@ async function submit(action: 'draft' | 'submit' = 'submit') {
if (!relabelNo) { if (!relabelNo) {
ElMessage.success('转运单已提交审核;PDA 入库确认后将自动生成关联换标单') ElMessage.success('转运单已提交审核;PDA 入库确认后将自动生成关联换标单')
await ElMessageBox.alert(`转运单 ${transferNo} 已提交。关联换标单会在 PDA 完成入库、客户确认实测库存后自动生成;届时请进入关联换标单详情上传贴标总文件。`, '转运单已提交', { confirmButtonText: '知道了' }) await ElMessageBox.alert(`转运单 ${transferNo} 已提交。关联换标单会在 PDA 完成入库、客户确认实测库存后自动生成;届时请进入关联换标单详情上传贴标总文件。`, '转运单已提交', { confirmButtonText: '知道了' })
await router.push({ name: serviceListRouteName('transfer') }) await goToServiceList()
return return
} }
try { try {
@@ -1176,17 +1257,17 @@ async function submit(action: 'draft' | 'submit' = 'submit') {
if (skuTotalFile.value) await uploadRelabelFile(relabelNo, 'SKU_TOTAL', skuTotalFile.value) if (skuTotalFile.value) await uploadRelabelFile(relabelNo, 'SKU_TOTAL', skuTotalFile.value)
} catch (error) { } catch (error) {
await ElMessageBox.alert(`转运单 ${transferNo} 已提交,但换标总文件上传失败:${error instanceof Error ? error.message : '上传失败'}。请进入关联换标单详情补传,不要重复提交转运单。`, '转运单已提交,附件待补传', { confirmButtonText: '知道了' }) await ElMessageBox.alert(`转运单 ${transferNo} 已提交,但换标总文件上传失败:${error instanceof Error ? error.message : '上传失败'}。请进入关联换标单详情补传,不要重复提交转运单。`, '转运单已提交,附件待补传', { confirmButtonText: '知道了' })
await openDetail({ '0': transferNo }) await goToServiceList()
return return
} }
} }
ElMessage.success('转运单已提交审核') ElMessage.success('转运单已提交审核')
await router.push({ name: serviceListRouteName('transfer') }) await goToServiceList()
return return
} }
} }
ElMessage.success(props.service === 'transfer' ? '转运单草稿保存成功' : `${config.value.title}提交成功`) ElMessage.success(props.service === 'transfer' ? '转运单草稿保存成功' : `${config.value.title}提交成功`)
await router.push({ name: serviceListRouteName(props.service) }) await goToServiceList()
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '提交失败') } } catch (error) { ElMessage.error(error instanceof Error ? error.message : '提交失败') }
finally { saving.value = false } finally { saving.value = false }
} }
@@ -1200,6 +1281,7 @@ async function openDetail(value: Record<string, string>) {
try { try {
const detail = props.service === 'labeling' ? await getRelabel(value['0']) : props.service === 'disposal' ? await getDiscard(value['0']) : await getTransfer(value['0']) const detail = props.service === 'labeling' ? await getRelabel(value['0']) : props.service === 'disposal' ? await getDiscard(value['0']) : await getTransfer(value['0'])
const discardSummary = props.service === 'disposal' ? (detail as { summary?: ServiceDetail }).summary : undefined const discardSummary = props.service === 'disposal' ? (detail as { summary?: ServiceDetail }).summary : undefined
showTransferCartons.value = false
selectedDetail.value = { ...value, ...(detail as ServiceDetail), ...(discardSummary || {}) } selectedDetail.value = { ...value, ...(detail as ServiceDetail), ...(discardSummary || {}) }
selectedRelabelDetail.value = props.service === 'labeling' ? detail as RelabelDetail : null selectedRelabelDetail.value = props.service === 'labeling' ? detail as RelabelDetail : null
mode.value = 'detail' mode.value = 'detail'
@@ -1284,12 +1366,7 @@ async function loadMeasurementPhotos() {
await Promise.all((selectedDetail.value?.measurementPhotos || []).map((photo) => loadMeasurementPhoto(photo.fileId))) await Promise.all((selectedDetail.value?.measurementPhotos || []).map((photo) => loadMeasurementPhoto(photo.fileId)))
} }
async function confirmRelabelFromList(relabelNo: string) { async function confirmRelabelFromList(relabelNo: string) {
try { openCompletionConfirmation(relabelNo, 'labeling')
await ElMessageBox.confirm('确认仓库上传的换标结果无误并继续出库流程吗?', '换标完成确认', { confirmButtonText: '确认并继续', cancelButtonText: '取消' })
await confirmRelabelCompletion(relabelNo)
ElMessage.success('已确认换标完成,订单已进入后续出库流程')
await load()
} catch (error) { if (error !== 'cancel') ElMessage.error(error instanceof Error ? error.message : '确认失败') }
} }
async function cancelSelectedDiscard() { async function cancelSelectedDiscard() {
const no = String(selectedDetail.value?.discardNo || selectedDetail.value?.['0'] || '') const no = String(selectedDetail.value?.discardNo || selectedDetail.value?.['0'] || '')
@@ -1363,13 +1440,7 @@ async function selectTransferWarehouse() {
} }
async function confirmSelectedRelabel() { async function confirmSelectedRelabel() {
const relabelNo = selectedRelabelDetail.value?.application.relabelNo const relabelNo = selectedRelabelDetail.value?.application.relabelNo
if (!relabelNo) return if (relabelNo) openCompletionConfirmation(relabelNo, 'labeling')
try {
await ElMessageBox.confirm('确认仓库上传的换标结果无误并继续出库流程吗?', '换标完成确认', { confirmButtonText: '确认并继续', cancelButtonText: '取消' })
await confirmRelabelCompletion(relabelNo)
ElMessage.success('已确认换标完成,订单已进入后续出库流程')
await openDetail({ '0': relabelNo })
} catch (error) { if (error !== 'cancel') ElMessage.error(error instanceof Error ? error.message : '确认失败') }
} }
async function resubmitRejectedRelabel() { async function resubmitRejectedRelabel() {
const application = selectedRelabelDetail.value?.application const application = selectedRelabelDetail.value?.application
@@ -1385,7 +1456,7 @@ async function resubmitRejectedRelabel() {
idempotencyKey: createIdempotencyKey(), idempotencyKey: createIdempotencyKey(),
}) })
ElMessage.success('贴标单已重新提交审核') ElMessage.success('贴标单已重新提交审核')
await router.push({ name: serviceListRouteName('labeling') }) await goToServiceList()
} catch (error) { } catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '重新提交失败') if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '重新提交失败')
} }
@@ -1501,12 +1572,12 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
return return
} }
if (cartonDetailRows.value.some(item => item.cartonMark === source.cartonMark)) return if (cartonDetailRows.value.some(item => item.cartonMark === source.cartonMark)) return
cargoRows.value.push({ id: ++cargoSequence, included: true, sku: source.skuItems?.map(item => item.skuCode).join(',') || '', name: source.productNameZh || '', shipmentNo: '', cartonMark: source.cartonMark || '', fbaCartonMark: '', remark: '', sourceInventoryBalanceId: undefined, sourceInventoryPositionId: undefined, sourceInboundOrderId: undefined, returnCartonId: String(source.returnCartonId), quantity: 1, fnsku: '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', destinationWarehouseId: '' }) cargoRows.value.push({ id: ++cargoSequence, included: true, sku: source.skuItems?.map(item => item.skuCode).join(',') || '', name: source.productNameZh || '', shipmentNo: '', cartonMark: source.cartonMark || '', fbaCartonMark: '', remark: '', sourceInventoryBalanceId: undefined, sourceInventoryPositionId: undefined, sourceInboundOrderId: undefined, returnCartonId: String(source.returnCartonId), quantity: 1, expectedPackageCount: 1, fnsku: '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', destinationWarehouseId: '' })
} }
</script> </script>
<template> <template>
<section class="warehouse-service-page" :class="{ 'warehouse-service-page--detail': mode === 'detail', 'warehouse-service-page--return-relabel': isReturnRelabelSource }"> <section class="warehouse-service-page" :class="{ 'warehouse-service-page--detail': mode === 'detail', 'warehouse-service-page--transfer': props.service === 'transfer', 'warehouse-service-page--return-relabel': isReturnRelabelSource }">
<template v-if="mode === 'list'"> <template v-if="mode === 'list'">
<QueryToolbar v-model="listQueryModel" :class="{ 'labeling-query': props.service === 'labeling', 'transfer-query': props.service === 'transfer' || props.service === 'disposal', 'disposal-query': props.service === 'disposal' }" layout="grid" :ariaLabel="`${config.title}筛选`" :fields="listQueryFields" :search-on-reset="false" @search="query" @reset="resetListQuery" /> <QueryToolbar v-model="listQueryModel" :class="{ 'labeling-query': props.service === 'labeling', 'transfer-query': props.service === 'transfer' || props.service === 'disposal', 'disposal-query': props.service === 'disposal' }" layout="grid" :ariaLabel="`${config.title}筛选`" :fields="listQueryFields" :search-on-reset="false" @search="query" @reset="resetListQuery" />
<div class="warehouse-card warehouse-table-card"> <div class="warehouse-card warehouse-table-card">
@@ -1536,7 +1607,7 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
</div> </div>
<div v-show="props.service !== 'labeling' || !orderInfoCollapsed" class="warehouse-form-grid"> <div v-show="props.service !== 'labeling' || !orderInfoCollapsed" class="warehouse-form-grid">
<label v-if="props.service === 'disposal'" class="warehouse-disposal-field"><span><i>*</i>操作仓</span><el-select v-model="form.warehouse" filterable clearable :loading="fbaWarehousesLoading" :disabled="Boolean(operationWarehousesError)" placeholder="请选择海外仓" @change="selectDiscardWarehouse"><el-option v-for="warehouse in operationWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select><small v-if="operationWarehousesError" class="warehouse-hint">{{ operationWarehousesError }}</small><small v-else class="warehouse-hint">请先选择本次废弃货物所在的海外仓。</small></label> <label v-if="props.service === 'disposal'" class="warehouse-disposal-field"><span><i>*</i>操作仓</span><el-select v-model="form.warehouse" filterable clearable :loading="fbaWarehousesLoading" :disabled="Boolean(operationWarehousesError)" placeholder="请选择海外仓" @change="selectDiscardWarehouse"><el-option v-for="warehouse in operationWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select><small v-if="operationWarehousesError" class="warehouse-hint">{{ operationWarehousesError }}</small><small v-else class="warehouse-hint">请先选择本次废弃货物所在的海外仓。</small></label>
<label v-if="props.service === 'disposal'" class="warehouse-disposal-field"><span><i>*</i>选择入库单</span><el-select v-model="selectedDiscardOrderId" filterable clearable :loading="discardCandidatesLoading" :disabled="!form.warehouse" no-data-text="当前海外仓暂无可废弃库存" placeholder="请先选择操作仓,再选择有可用库存的入库单" @change="selectDiscardInbound"><el-option v-for="candidate in discardCandidates" :key="candidate.orderId" :label="candidate.sourceType === 'RETURN_CARTON' ? `退货|${candidate.inboundOrderNo || '—'}|${candidate.cartonMark || '—'}|可用 ${candidate.availablePackages}` : `${candidate.inboundOrderNo || candidate.inboundCode || '—'}|${candidate.inboundCode || '—'}|可用 ${candidate.availablePackages} 箱`" :value="candidate.orderId" /></el-select><small class="warehouse-hint">显示当前操作仓内有可用数量的普通入库库存和退货库存。</small></label> <label v-if="props.service === 'disposal'" class="warehouse-disposal-field"><span><i>*</i>选择入库单</span><el-select v-model="selectedDiscardOrderId" filterable clearable :loading="discardCandidatesLoading" :disabled="!form.warehouse" no-data-text="当前海外仓暂无可废弃库存" placeholder="请先选择操作仓,再选择有可用库存的入库单" @change="selectDiscardInbound"><el-option v-for="candidate in discardCandidates" :key="candidate.orderId" :label="candidate.sourceType === 'RETURN_CARTON' ? `退货|${candidate.inboundOrderNo || '—'}|${candidate.cartonMark || '—'}|可用 ${candidate.availablePackages}` : `${candidate.inboundOrderNo || candidate.inboundCode || '—'}|${candidate.inboundCode || '—'}|${candidate.palletNo || '散箱'}|可用 ${candidate.availablePackages} 箱`" :value="candidate.orderId" /></el-select><small class="warehouse-hint">显示当前操作仓内有可用库存的单据;带出箱唛后可勾选部分箱子,按整箱废弃。</small></label>
<label v-if="props.service === 'relabel'"><span><i>*</i>进仓编号</span><el-input v-model="form.inboundNo" placeholder="请输入已有入仓编号" /></label> <label v-if="props.service === 'relabel'"><span><i>*</i>进仓编号</span><el-input v-model="form.inboundNo" placeholder="请输入已有入仓编号" /></label>
<label v-if="props.service === 'relabel'"><span><i>*</i>来源 FBA 仓库</span><el-select v-model="form.sourceWarehouse" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" placeholder="请选择平台维护的 FBA 仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></label> <label v-if="props.service === 'relabel'"><span><i>*</i>来源 FBA 仓库</span><el-select v-model="form.sourceWarehouse" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" placeholder="请选择平台维护的 FBA 仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></label>
<label v-else-if="props.service === 'disposal'" class="warehouse-disposal-field"><span><i>*</i>废弃原因</span><el-select v-model="form.reason" placeholder="请选择"><el-option label="客户退货" value="客户退货" /><el-option label="亚马逊移除" value="亚马逊移除" /><el-option label="残损不可售" value="残损不可售" /></el-select></label> <label v-else-if="props.service === 'disposal'" class="warehouse-disposal-field"><span><i>*</i>废弃原因</span><el-select v-model="form.reason" placeholder="请选择"><el-option label="客户退货" value="客户退货" /><el-option label="亚马逊移除" value="亚马逊移除" /><el-option label="残损不可售" value="残损不可售" /></el-select></label>
@@ -1560,11 +1631,11 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
<label v-if="props.service === 'labeling'"><span><i>*</i>海外仓签收联系人<em v-if="form.warehouse && !form.contact" class="warehouse-unmaintained">未维护</em></span><el-input v-model="form.contact" placeholder="选择海外操作仓后自动带出" /></label> <label v-if="props.service === 'labeling'"><span><i>*</i>海外仓签收联系人<em v-if="form.warehouse && !form.contact" class="warehouse-unmaintained">未维护</em></span><el-input v-model="form.contact" placeholder="选择海外操作仓后自动带出" /></label>
<label v-if="props.service === 'labeling'"><span><i>*</i>海外仓联系电话<em v-if="form.warehouse && !form.phone" class="warehouse-unmaintained">未维护</em></span><el-input v-model="form.phone" placeholder="选择海外操作仓后自动带出" /></label> <label v-if="props.service === 'labeling'"><span><i>*</i>海外仓联系电话<em v-if="form.warehouse && !form.phone" class="warehouse-unmaintained">未维护</em></span><el-input v-model="form.phone" placeholder="选择海外操作仓后自动带出" /></label>
<label v-if="props.service === 'labeling'"><span><i>*</i>贴标截止时间</span><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择截止时间" /></label> <label v-if="props.service === 'labeling'"><span><i>*</i>贴标截止时间</span><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择截止时间" /></label>
<label v-if="props.service === 'labeling' && labelingMode !== 'sku'"><span><i>*</i>换标后派送方式</span><el-select v-model="form.deliveryMethod" placeholder="请选择派送方式"><el-option label="卡车派送" value="TRUCK" /><el-option label="快递派送" value="EXPRESS" /><el-option label="卡车自提" value="TRUCK_PICKUP" /><el-option label="快递自提" value="EXPRESS_PICKUP" /></el-select></label> <label v-if="props.service === 'labeling' && labelingMode !== 'sku'"><span><i>*</i>换标后派送方式</span><el-select v-model="form.deliveryMethod" placeholder="请选择派送方式"><el-option label="卡车派送" value="TRUCK" /><el-option label="快递派送" value="EXPRESS" /><el-option label="亚马逊自提" value="AMAZON_PICKUP" /><el-option label="客户自提" value="CUSTOMER_PICKUP" /></el-select></label>
<div v-if="props.service === 'labeling' && labelingMode !== 'sku' && (form.deliveryMethod === 'EXPRESS' || form.deliveryMethod === 'EXPRESS_PICKUP')" class="warehouse-checkbox-field"><span>是否打印面单</span><el-checkbox v-model="form.relabel">需要仓库打印面单</el-checkbox></div> <div v-if="props.service === 'labeling' && labelingMode !== 'sku' && form.deliveryMethod === 'EXPRESS'" class="warehouse-checkbox-field"><span>是否打印面单</span><el-checkbox v-model="form.relabel">需要仓库打印面单</el-checkbox></div>
<label v-if="canManageRelabelFiles && props.service === 'labeling' && labelingMode !== 'sku' && (form.deliveryMethod === 'EXPRESS' || form.deliveryMethod === 'EXPRESS_PICKUP') && form.relabel"><span><i>*</i>上传面单文件</span><input class="warehouse-native-file" type="file" accept="application/pdf,image/*,.pdf" @change="selectShippingLabel"></label> <label v-if="canManageRelabelFiles && props.service === 'labeling' && labelingMode !== 'sku' && form.deliveryMethod === 'EXPRESS' && form.relabel"><span><i>*</i>上传面单文件</span><input class="warehouse-native-file" type="file" accept="application/pdf,image/*,.pdf" @change="selectShippingLabel"></label>
<label v-if="props.service === 'relabel'" class="warehouse-span-2"><span><i>*</i>详细派送地址</span><el-input v-model="form.address" placeholder="请输入派送地址" /></label> <label v-if="props.service === 'relabel'" class="warehouse-span-2"><span><i>*</i>详细派送地址</span><el-input v-model="form.address" placeholder="请输入派送地址" /></label>
<label v-if="props.service === 'transfer' || props.service === 'relabel'"><span><i>*</i>{{ props.service === 'transfer' ? '预估体积(CBM)' : '预估总体积(CBM)' }}</span><el-input-number v-model="form.volumeCbm" :min="0" :precision="4" :step="0.0001" controls-position="right" placeholder="请输入预估总体积" /></label> <label v-if="props.service === 'relabel'"><span><i>*</i>预估总体积(CBM)</span><el-input-number v-model="form.volumeCbm" :min="0" :precision="4" :step="0.0001" controls-position="right" placeholder="请输入预估总体积" /></label>
<label v-if="props.service === 'transfer' || props.service === 'relabel'"><span><i>*</i>{{ props.service === 'transfer' ? '预计入仓时间' : '预计到达时间' }}</span><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :placeholder="props.service === 'transfer' ? '请选择预计入仓时间' : '请选择预计到达时间'" /></label> <label v-if="props.service === 'transfer' || props.service === 'relabel'"><span><i>*</i>{{ props.service === 'transfer' ? '预计入仓时间' : '预计到达时间' }}</span><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :placeholder="props.service === 'transfer' ? '请选择预计入仓时间' : '请选择预计到达时间'" /></label>
<label v-if="props.service === 'transfer'"><span>期望发货时间</span><el-date-picker v-model="form.estimatedShipAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="选填" /></label> <label v-if="props.service === 'transfer'"><span>期望发货时间</span><el-date-picker v-model="form.estimatedShipAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="选填" /></label>
<div v-if="props.service === 'transfer'" class="warehouse-choice-field warehouse-span-2"><span>目的去向</span><el-radio-group v-model="transferDestinationType" @change="selectTransferDestinationType"><el-radio-button value="NONE">暂未确定</el-radio-button><el-radio-button value="FBA">FBA 仓</el-radio-button><el-radio-button value="PRIVATE">私人仓</el-radio-button></el-radio-group></div> <div v-if="props.service === 'transfer'" class="warehouse-choice-field warehouse-span-2"><span>目的去向</span><el-radio-group v-model="transferDestinationType" @change="selectTransferDestinationType"><el-radio-button value="NONE">暂未确定</el-radio-button><el-radio-button value="FBA">FBA 仓</el-radio-button><el-radio-button value="PRIVATE">私人仓</el-radio-button></el-radio-group></div>
@@ -1639,13 +1710,22 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
<div v-else class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-sku-label-table"><thead><tr><th>仓库唛头 *</th><th>旧 SKU *</th><th>新 SKU *</th><th>换标数量(件)*</th><th v-if="labelingMode === 'carton_and_sku'">新 FBA 箱唛 *</th><th v-if="labelingMode === 'carton_and_sku'">FBA 货件编号 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in skuRelabelRows" :key="row.id"><td><el-input v-model="row.cartonMark" :readonly="isReturnRelabelSource" placeholder="请输入仓库唛头" @change="applyExistingCartonBinding(row)" /></td><td><el-input v-model="row.originalSku" :readonly="isReturnRelabelSource" placeholder="请输入旧 SKU" @change="applyExistingSkuBinding(row)" /></td><td><el-input v-model="row.newSku" placeholder="请输入新 SKU" /></td><td><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" placeholder="请输入件数" /></td><td v-if="labelingMode === 'carton_and_sku'"><el-input v-model="row.fbaCartonMark" placeholder="请输入新的 FBA 箱唛" /></td><td v-if="labelingMode === 'carton_and_sku'"><el-input v-model="row.shipmentNo" placeholder="请输入 FBA 货件编号" /></td><td><el-input v-model="row.remark" placeholder="请输入备注" /></td><td><button type="button" class="warehouse-delete" title="删除" @click="removeSkuRelabelRow(row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="skuRelabelRows.length === 0"><td :colspan="labelingMode === 'carton_and_sku' ? 8 : 6"><div class="warehouse-empty">暂无绑定明细,请点击“添加 SKU 明细”</div></td></tr></tbody></table></div> <div v-else class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-sku-label-table"><thead><tr><th>仓库唛头 *</th><th>旧 SKU *</th><th>新 SKU *</th><th>换标数量(件)*</th><th v-if="labelingMode === 'carton_and_sku'">新 FBA 箱唛 *</th><th v-if="labelingMode === 'carton_and_sku'">FBA 货件编号 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in skuRelabelRows" :key="row.id"><td><el-input v-model="row.cartonMark" :readonly="isReturnRelabelSource" placeholder="请输入仓库唛头" @change="applyExistingCartonBinding(row)" /></td><td><el-input v-model="row.originalSku" :readonly="isReturnRelabelSource" placeholder="请输入旧 SKU" @change="applyExistingSkuBinding(row)" /></td><td><el-input v-model="row.newSku" placeholder="请输入新 SKU" /></td><td><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" placeholder="请输入件数" /></td><td v-if="labelingMode === 'carton_and_sku'"><el-input v-model="row.fbaCartonMark" placeholder="请输入新的 FBA 箱唛" /></td><td v-if="labelingMode === 'carton_and_sku'"><el-input v-model="row.shipmentNo" placeholder="请输入 FBA 货件编号" /></td><td><el-input v-model="row.remark" placeholder="请输入备注" /></td><td><button type="button" class="warehouse-delete" title="删除" @click="removeSkuRelabelRow(row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="skuRelabelRows.length === 0"><td :colspan="labelingMode === 'carton_and_sku' ? 8 : 6"><div class="warehouse-empty">暂无绑定明细,请点击“添加 SKU 明细”</div></td></tr></tbody></table></div>
</template> </template>
<template v-else> <template v-else>
<div class="warehouse-section-heading" :class="{ 'warehouse-step-heading': props.service === 'transfer' }"><div><h3><span v-if="props.service === 'transfer'" class="warehouse-step-number">1</span>货物明细</h3></div><div v-if="props.service !== 'disposal'" class="warehouse-heading-actions"><el-button v-if="props.service === 'transfer'" size="small" :disabled="!cargoRows.some((row) => row.sku.trim() && row.quantity > 0)" @click="printTransferDraftCartonMarks"><Printer :size="15" />打印唛头</el-button><el-button type="primary" size="small" @click="addCargoRow"><Plus :size="15" />添加行</el-button><el-button size="small" @click="cargoRows = []">清空</el-button></div></div> <section v-if="props.service === 'disposal' && discardSelectedCandidate" class="warehouse-discard-boxes">
<div class="warehouse-table-scroll"><table class="warehouse-edit-table"><thead><tr><th>商品 SKU{{ props.service === 'transfer' ? '' : ' *' }}</th><th>中文品名{{ props.service === 'transfer' ? '' : ' *' }}</th><th v-if="props.service === 'disposal'">可用箱数</th><th v-if="props.service === 'relabel'">FNSKU / 箱唛</th><th>{{ props.service === 'disposal' ? '本次废弃箱数' : '预计箱数' }} *</th><th v-if="props.service !== 'disposal'">操作</th></tr></thead><tbody><tr v-for="row in cargoRows" :key="row.id"><td><el-input v-model="row.sku" :readonly="props.service === 'disposal'" :placeholder="props.service === 'transfer' ? '选填' : '请输入商品 SKU'" /></td><td><el-input v-model="row.name" :readonly="props.service === 'disposal'" :placeholder="props.service === 'transfer' ? '选填' : '请输入中文品名'" /></td><td v-if="props.service === 'disposal'"><strong>{{ row.availableQuantity }} 箱</strong></td><td v-if="props.service === 'relabel'"><el-input v-model="row.shippingMark" placeholder="请输入FNSKU或箱唛" /></td><td><el-input-number v-model="row.quantity" :min="props.service === 'disposal' ? 0 : 1" :max="row.availableQuantity" controls-position="right" placeholder="请输入箱数" /></td><td v-if="props.service !== 'disposal'"><button type="button" class="warehouse-delete" title="删除" @click="removeCargoRow(row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="cargoRows.length === 0"><td :colspan="props.service === 'disposal' ? 4 : props.service === 'relabel' ? 5 : 4"><div class="warehouse-empty">{{ props.service === 'disposal' ? '请先选择入库单,系统将自动带出 SKU 和可用箱数' : '暂无货物明细,请点击“添加行”' }}</div></td></tr></tbody></table></div> <div class="warehouse-section-heading"><div><h3>选择废弃箱子</h3><small class="warehouse-hint">选中箱子整体废弃,箱内所有 SKU 一起出库;未选箱子保留库存。</small></div><div v-if="discardSelectedCandidate.cartonSelectionSupported" class="warehouse-heading-actions"><el-button size="small" @click="selectAllDiscardBoxes(true)">全选</el-button><el-button size="small" @click="selectAllDiscardBoxes(false)">清空选择</el-button></div></div>
<template v-if="discardSelectedCandidate.cartonSelectionSupported">
<div class="warehouse-discard-box-toolbar"><el-input v-model="discardBoxSearch" clearable placeholder="搜索箱唛或 SKU" /><strong>已选 {{ discardSelectedBoxKeys.length }} / {{ discardSelectableBoxes.length }} 箱</strong></div>
<div class="warehouse-table-scroll"><table class="warehouse-edit-table"><thead><tr><th>选择</th><th>箱唛</th><th>所属托盘</th><th>箱内 SKU</th><th>中文品名</th></tr></thead><tbody><tr v-for="box in discardVisibleBoxes" :key="box.key"><td><el-checkbox :model-value="discardSelectedBoxKeys.includes(box.key)" :aria-label="`选择废弃箱子 ${box.cartonMark}`" @change="toggleDiscardBox(box.key, Boolean($event))" /></td><td><strong>{{ box.cartonMark }}</strong></td><td>{{ box.palletNo || '散箱' }}</td><td>{{ box.skus.map(sku => sku.skuCode).join('、') }}</td><td>{{ box.skus.map(sku => sku.productName || '—').join('、') }}</td></tr><tr v-if="!discardVisibleBoxes.length"><td colspan="5"><div class="warehouse-empty">暂无匹配箱唛</div></td></tr></tbody></table></div>
<small v-if="!discardSelectedBoxKeys.length" class="warehouse-hint">请至少选择一个箱子。</small>
</template>
<el-alert v-else type="info" :closable="false" :title="discardSelectedCandidate.cartonSelectionBlockReason || '当前库存缺少完整箱级记录,暂时只能选择全部库存废弃。'" />
</section>
<div v-if="props.service !== 'disposal' || !discardSelectedCandidate?.cartonSelectionSupported" class="warehouse-section-heading" :class="{ 'warehouse-step-heading': props.service === 'transfer' }"><div><h3><span v-if="props.service === 'transfer'" class="warehouse-step-number">1</span>货物明细</h3></div><div v-if="props.service !== 'disposal'" class="warehouse-heading-actions"><el-button v-if="props.service === 'transfer'" size="small" :disabled="!cargoRows.some((row) => row.sku.trim() && Number(row.expectedPackageCount) > 0)" @click="printTransferDraftCartonMarks"><Printer :size="15" />打印唛头</el-button><el-button type="primary" size="small" @click="addCargoRow"><Plus :size="15" />添加行</el-button><el-button size="small" @click="cargoRows = []">清空</el-button></div></div>
<div v-if="props.service !== 'disposal' || !discardSelectedCandidate?.cartonSelectionSupported" class="warehouse-table-scroll"><table class="warehouse-edit-table"><thead><tr><th>商品 SKU{{ props.service === 'transfer' ? '' : ' *' }}</th><th>中文品名{{ props.service === 'transfer' ? '' : ' *' }}</th><th v-if="props.service === 'disposal'">可用箱数</th><th v-if="props.service === 'relabel'">FNSKU / 箱唛</th><th v-if="props.service === 'transfer'">SKU 数量(件)*</th><th>{{ props.service === 'disposal' ? '本次废弃箱数' : '预计箱数' }} *</th><th v-if="props.service !== 'disposal'">操作</th></tr></thead><tbody><tr v-for="row in cargoRows" :key="row.id"><td><el-input v-model="row.sku" :readonly="props.service === 'disposal'" :placeholder="props.service === 'transfer' ? '选填' : '请输入商品 SKU'" /></td><td><el-input v-model="row.name" :readonly="props.service === 'disposal'" :placeholder="props.service === 'transfer' ? '选填' : '请输入中文品名'" /></td><td v-if="props.service === 'disposal'"><strong>{{ row.availableQuantity }} 箱</strong></td><td v-if="props.service === 'relabel'"><el-input v-model="row.shippingMark" placeholder="请输入FNSKU或箱唛" /></td><td v-if="props.service === 'transfer'"><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" placeholder="请输入件数" /></td><td v-if="props.service === 'transfer'"><el-input-number v-model="row.expectedPackageCount" :min="1" :precision="0" controls-position="right" placeholder="请输入预计箱数" /></td><td v-else><el-input-number v-model="row.quantity" :disabled="props.service === 'disposal'" :min="props.service === 'disposal' ? 0 : 1" :max="row.availableQuantity" controls-position="right" placeholder="请输入箱数" /></td><td v-if="props.service !== 'disposal'"><button type="button" class="warehouse-delete" title="删除" @click="removeCargoRow(row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="cargoRows.length === 0"><td :colspan="props.service === 'disposal' ? 4 : props.service === 'relabel' || props.service === 'transfer' ? 5 : 4"><div class="warehouse-empty">{{ props.service === 'disposal' ? '请先选择入库单,系统将自动带出 SKU 和可用箱数' : '暂无货物明细,请点击“添加行”' }}</div></td></tr></tbody></table></div>
</template> </template>
</div> </div>
<div v-if="props.service === 'transfer' && form.relabel" class="warehouse-card warehouse-form-section warehouse-transfer-relabel"> <div v-if="props.service === 'transfer' && form.relabel" class="warehouse-card warehouse-form-section warehouse-transfer-relabel">
<div class="warehouse-section-heading warehouse-labeling-heading warehouse-step-heading"><div><h3><span class="warehouse-step-number">2</span>换标申请</h3></div></div> <div class="warehouse-section-heading warehouse-labeling-heading warehouse-step-heading"><div><h3><span class="warehouse-step-number">2</span>换标申请</h3></div></div>
<div class="warehouse-form-grid warehouse-transfer-relabel-fields"><label><span><i>*</i>换标截止时间</span><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择截止时间" /></label><label><span><i>*</i>换标联系人</span><el-input v-model="form.relabelContact" placeholder="选择海外仓后自动带出,也可手动填写" maxlength="128" /></label><label><span><i>*</i>换标联系人电话</span><el-input v-model="form.relabelPhone" placeholder="选择海外仓后自动带出,也可手动填写" maxlength="64" /></label><label v-if="labelingMode !== 'sku'"><span><i>*</i>换标后派送方式</span><el-select v-model="form.relabelDeliveryMethod" placeholder="请选择派送方式"><el-option label="卡车派送" value="TRUCK" /><el-option label="快递派送" value="EXPRESS" /><el-option label="卡车自提" value="TRUCK_PICKUP" /><el-option label="快递自提" value="EXPRESS_PICKUP" /></el-select></label><label class="warehouse-span-2"><span><i>*</i>整票换标货物目的 FBA 仓</span><el-select v-model="transferRelabelDefaultDestination" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" filterable placeholder="请选择整票货物的目的仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select><small class="warehouse-hint">本票所有换标货物统一前往该目的仓,暂不支持一票多个目的仓。</small></label><label class="warehouse-span-2"><span>贴标要求</span><el-input v-model="form.requirement" type="textarea" :rows="2" maxlength="1000" show-word-limit placeholder="请输入贴标要求(选填)" /></label></div> <div class="warehouse-form-grid warehouse-transfer-relabel-fields"><label><span><i>*</i>换标截止时间</span><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择截止时间" /></label><label><span><i>*</i>换标联系人</span><el-input v-model="form.relabelContact" placeholder="选择海外仓后自动带出,也可手动填写" maxlength="128" /></label><label><span><i>*</i>换标联系人电话</span><el-input v-model="form.relabelPhone" placeholder="选择海外仓后自动带出,也可手动填写" maxlength="64" /></label><label v-if="labelingMode !== 'sku'"><span><i>*</i>换标后派送方式</span><el-select v-model="form.relabelDeliveryMethod" placeholder="请选择派送方式"><el-option label="卡车派送" value="TRUCK" /><el-option label="快递派送" value="EXPRESS" /><el-option label="亚马逊自提" value="AMAZON_PICKUP" /><el-option label="客户自提" value="CUSTOMER_PICKUP" /></el-select></label><label class="warehouse-span-2"><span><i>*</i>整票换标货物目的 FBA 仓</span><el-select v-model="transferRelabelDefaultDestination" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" filterable placeholder="请选择整票货物的目的仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select><small class="warehouse-hint">本票所有换标货物统一前往该目的仓,暂不支持一票多个目的仓。</small></label><label class="warehouse-span-2"><span>贴标要求</span><el-input v-model="form.requirement" type="textarea" :rows="2" maxlength="1000" show-word-limit placeholder="请输入贴标要求(选填)" /></label></div>
<div class="warehouse-labeling-mode"><span>换标类型</span><el-radio-group v-model="labelingMode" aria-label="换标类型"><el-radio-button value="carton">只换箱唛</el-radio-button><el-radio-button value="sku">只换 SKU 标签</el-radio-button><el-radio-button value="carton_and_sku">SKU 和箱唛都换</el-radio-button></el-radio-group></div> <div class="warehouse-labeling-mode"><span>换标类型</span><el-radio-group v-model="labelingMode" aria-label="换标类型"><el-radio-button value="carton">只换箱唛</el-radio-button><el-radio-button value="sku">只换 SKU 标签</el-radio-button><el-radio-button value="carton_and_sku">SKU 和箱唛都换</el-radio-button></el-radio-group></div>
<div class="warehouse-transfer-file-groups warehouse-file-groups--compact"> <div class="warehouse-transfer-file-groups warehouse-file-groups--compact">
<section v-if="canManageRelabelFiles" class="warehouse-file-group warehouse-transfer-total-files"> <section v-if="canManageRelabelFiles" class="warehouse-file-group warehouse-transfer-total-files">
@@ -1676,23 +1756,57 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
<header class="warehouse-detail-header"> <header class="warehouse-detail-header">
<button type="button" class="warehouse-detail-back" @click="returnToList"><ArrowLeft :size="16" />返回列表</button> <button type="button" class="warehouse-detail-back" @click="returnToList"><ArrowLeft :size="16" />返回列表</button>
<div class="warehouse-detail-title"><strong>{{ props.service === 'labeling' ? selectedRelabelDetail?.application.relabelNo : selectedDetail?.transferNo || selectedDetail?.['0'] || `${config.title}详情` }}</strong><span>{{ config.title }}详情</span></div> <div class="warehouse-detail-title"><strong>{{ props.service === 'labeling' ? selectedRelabelDetail?.application.relabelNo : selectedDetail?.transferNo || selectedDetail?.['0'] || `${config.title}详情` }}</strong><span>{{ config.title }}详情</span></div>
<div class="warehouse-detail-actions"><el-button v-if="canCreate && props.service === 'labeling' && selectedRelabelDetail?.application.status === 'REJECTED'" type="primary" @click="resubmitRejectedRelabel">修改后重新提交</el-button><el-button v-if="canConfirmRelabel && props.service === 'labeling' && selectedRelabelDetail?.application.status === 'CUSTOMER_CONFIRM_PENDING' && !selectedRelabelDetail?.application.customerConfirmedAt" type="primary" @click="confirmSelectedRelabel">确认换标完成</el-button><el-button v-if="canCancelDiscard && props.service === 'disposal' && selectedDetail?.status === 'PENDING_REVIEW'" type="danger" @click="cancelSelectedDiscard">取消申请</el-button><el-button v-if="canCreate && props.service === 'transfer' && ['DRAFT', 'REJECTED'].includes(selectedDetail?.status || '')" type="primary" @click="openEdit(String(selectedDetail?.transferNo || selectedDetail?.['0'] || ''))">编辑</el-button><el-button v-if="canConfirmTransfer && props.service === 'transfer' && selectedDetail?.status === 'WAIT_CUSTOMER_CONFIRMATION'" type="primary" @click="confirmTransferMeasurements">确认测量数据</el-button><el-tag :type="props.service === 'labeling' ? relabelStatusTagType(selectedRelabelDetail?.application.status || '') : undefined" effect="light">{{ statusLabel(props.service === 'labeling' ? selectedRelabelDetail?.application.status || '' : String(selectedDetail?.status || '')) }}</el-tag></div> <div class="warehouse-detail-actions"><el-button v-if="canCreate && props.service === 'labeling' && selectedRelabelDetail?.application.status === 'REJECTED'" type="primary" @click="resubmitRejectedRelabel">修改后重新提交</el-button><el-button v-if="canConfirmRelabel && props.service === 'labeling' && selectedRelabelDetail?.application.status === 'CUSTOMER_CONFIRM_PENDING' && !selectedRelabelDetail?.application.customerConfirmedAt" type="primary" @click="confirmSelectedRelabel">确认换标完成</el-button><el-button v-if="canConfirmDiscard && props.service === 'disposal' && selectedDetail?.status === 'PDA_COMPLETED'" type="primary" @click="openCompletionConfirmation(String(selectedDetail?.discardNo || ''), 'disposal')">废弃确认</el-button><el-button v-if="canCancelDiscard && props.service === 'disposal' && selectedDetail?.status === 'PENDING_REVIEW'" type="danger" @click="cancelSelectedDiscard">取消申请</el-button><el-button v-if="canCreate && props.service === 'transfer' && ['DRAFT', 'REJECTED'].includes(selectedDetail?.status || '')" type="primary" @click="openEdit(String(selectedDetail?.transferNo || selectedDetail?.['0'] || ''))">编辑</el-button><el-button v-if="canConfirmTransfer && props.service === 'transfer' && selectedDetail?.status === 'WAIT_CUSTOMER_CONFIRMATION'" type="primary" @click="confirmTransferMeasurements">确认测量数据</el-button><el-tag v-if="props.service !== 'transfer'" :type="props.service === 'labeling' ? relabelStatusTagType(selectedRelabelDetail?.application.status || '') : undefined" effect="light">{{ statusLabel(props.service === 'labeling' ? selectedRelabelDetail?.application.status || '' : String(selectedDetail?.status || '')) }}</el-tag></div>
</header> </header>
<div v-if="detailError" class="warehouse-detail-error" role="alert"><strong>{{ detailError }}</strong><el-button @click="loadDetailPage">重新加载</el-button></div> <div v-if="detailError" class="warehouse-detail-error" role="alert"><strong>{{ detailError }}</strong><el-button @click="loadDetailPage">重新加载</el-button></div>
<el-alert v-else-if="props.service === 'labeling' && selectedRelabelDetail?.application.status === 'REJECTED'" class="warehouse-rejected-alert" type="error" :closable="false" show-icon title="贴标单审核已驳回" :description="selectedRelabelDetail.application.reviewRemark || '平台未填写驳回备注'" /> <el-alert v-else-if="props.service === 'labeling' && selectedRelabelDetail?.application.status === 'REJECTED'" class="warehouse-rejected-alert" type="error" :closable="false" show-icon title="贴标单审核已驳回" :description="selectedRelabelDetail.application.reviewRemark || '平台未填写驳回备注'" />
<div v-else class="warehouse-card warehouse-form-section"><h3>订单概览</h3><div v-if="props.service === 'labeling'" class="warehouse-detail-grid"><div><span>操作仓</span><strong>{{ warehouseDisplay(selectedRelabelDetail?.application.overseasWarehouseId) }}</strong></div><div><span>客户参考号</span><strong>{{ selectedRelabelDetail?.application.customerOrderNo || '—' }}</strong></div><div><span>SKU 数(种)</span><strong>{{ selectedRelabelDetail ? relabelSkuCount(selectedRelabelDetail.items) : '—' }}</strong></div><div><span>总箱数</span><strong>{{ selectedRelabelDetail?.application.totalPackageCount ?? '—' }}</strong></div><div><span>贴标截止时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.labelDeadline) }}</strong></div><div><span>仓库联系人</span><strong>{{ selectedRelabelDetail?.application.contactName || '—' }}</strong></div><div><span>联系电话</span><strong>{{ selectedRelabelDetail?.application.contactPhone || '—' }}</strong></div><div><span>创建时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.createdAt) }}</strong></div></div><div v-else-if="props.service === 'transfer'" class="warehouse-detail-grid warehouse-transfer-overview-grid"><div><span>状态</span><el-tag :type="transferStatusTagType(selectedDetail?.status || '')" effect="light">{{ statusLabel(selectedDetail?.status || '') }}</el-tag></div><div><span>目的去向</span><strong>{{ destinationTypeLabel(selectedDetail?.destinationType, selectedDetail?.fbaWarehouseCode) }}</strong></div><div v-if="selectedDetail?.destinationType !== 'PRIVATE'"><span>FBA 目的仓</span><strong>{{ selectedDetail?.destinationWarehouseName || warehouseDisplay(selectedDetail?.destinationWarehouseCode || selectedDetail?.fbaWarehouseCode) }}</strong></div><div v-if="selectedDetail?.destinationType !== 'PRIVATE'"><span>FBA 货件号</span><strong>{{ selectedDetail?.fbaShipmentNo || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'" class="warehouse-overview-wide"><span>私人仓地址</span><strong>{{ selectedDetail?.privateDestinationAddress || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'"><span>邮编</span><strong>{{ selectedDetail?.privateDestinationPostalCode || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'"><span>私人仓联系人</span><strong>{{ selectedDetail?.privateDestinationContactName || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'"><span>联系电话</span><strong>{{ selectedDetail?.privateDestinationContactPhone || '—' }}</strong></div><div><span>海外仓</span><strong>{{ warehouseDisplay(selectedDetail?.operatingWarehouseId) }}</strong></div><div><span>箱数</span><strong>{{ selectedDetail?.packageCount ?? '—' }}</strong></div><div><span>入仓方式</span><strong>{{ deliveryMethodLabel(selectedDetail?.deliveryMethod || '') }}</strong></div><div><span>期望发货时间</span><strong>{{ formatDateTime(selectedDetail?.estimatedShipAt) }}</strong></div><div><span>实际发货时间</span><strong>{{ formatDateTime(selectedDetail?.actualShipAt) }}</strong></div><div><span>是否换标</span><strong>{{ selectedDetail?.needRelabel ? '是' : '否' }}</strong></div><div><span>换标状态</span><el-tag :type="transferRelabelStatusTagType(transferRelabelStatus(selectedDetail || {}).code)" effect="light">{{ transferRelabelStatus(selectedDetail || {}).label }}</el-tag></div><div><span>换标单号</span><strong>{{ selectedDetail?.relabelNo || '—' }}</strong></div><div><span>创建时间</span><strong>{{ formatDateTime(selectedDetail?.createdAt) }}</strong></div><div><span>转运单号</span><strong>{{ selectedDetail?.transferNo || '—' }}</strong></div></div><div v-else class="warehouse-detail-grid"><div v-for="(column, index) in config.columns.slice(1)" :key="column"><span>{{ column }}</span><strong>{{ selectedDetail?.[String(index)] || '—' }}</strong></div></div></div> <div v-else class="warehouse-card warehouse-form-section"><h3>订单概览</h3><div v-if="props.service === 'labeling'" class="warehouse-detail-grid"><div><span>操作仓</span><strong>{{ warehouseDisplay(selectedRelabelDetail?.application.overseasWarehouseId) }}</strong></div><div><span>客户参考号</span><strong>{{ selectedRelabelDetail?.application.customerOrderNo || '—' }}</strong></div><div><span>SKU 数(种)</span><strong>{{ selectedRelabelDetail ? relabelSkuCount(selectedRelabelDetail.items) : '—' }}</strong></div><div><span>总箱数</span><strong>{{ selectedRelabelDetail?.application.totalPackageCount ?? '—' }}</strong></div><div><span>贴标截止时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.labelDeadline) }}</strong></div><div><span>仓库联系人</span><strong>{{ selectedRelabelDetail?.application.contactName || '—' }}</strong></div><div><span>联系电话</span><strong>{{ selectedRelabelDetail?.application.contactPhone || '—' }}</strong></div><div><span>创建时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.createdAt) }}</strong></div></div><div v-else-if="props.service === 'transfer'" class="warehouse-detail-grid warehouse-transfer-overview-grid"><div><span>状态</span><el-tag :type="transferStatusTagType(selectedDetail?.status || '')" effect="light">{{ statusLabel(selectedDetail?.status || '') }}</el-tag></div><div><span>目的去向</span><strong>{{ destinationTypeLabel(selectedDetail?.destinationType, selectedDetail?.fbaWarehouseCode) }}</strong></div><div v-if="selectedDetail?.destinationType !== 'PRIVATE'"><span>FBA 目的仓</span><strong>{{ selectedDetail?.destinationWarehouseName || warehouseDisplay(selectedDetail?.destinationWarehouseCode || selectedDetail?.fbaWarehouseCode) }}</strong></div><div v-if="selectedDetail?.destinationType !== 'PRIVATE'"><span>FBA 货件号</span><strong>{{ selectedDetail?.fbaShipmentNo || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'" class="warehouse-overview-wide"><span>私人仓地址</span><strong>{{ selectedDetail?.privateDestinationAddress || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'"><span>邮编</span><strong>{{ selectedDetail?.privateDestinationPostalCode || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'"><span>私人仓联系人</span><strong>{{ selectedDetail?.privateDestinationContactName || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'"><span>联系电话</span><strong>{{ selectedDetail?.privateDestinationContactPhone || '—' }}</strong></div><div><span>海外仓</span><strong>{{ warehouseDisplay(selectedDetail?.operatingWarehouseId) }}</strong></div><div><span>预报 SKU 种数</span><strong>{{ new Set((selectedDetail?.items || []).map(item => item.skuCode).filter(Boolean)).size }}</strong></div><div><span>预计箱数</span><strong>{{ selectedDetail?.packageCount ?? '—' }}</strong></div><div><span>实际签收箱数</span><strong>{{ selectedDetail?.cartons?.filter((carton) => carton.status === 'COUNTED').length ?? '—' }}</strong></div><div><span>入仓方式</span><strong>{{ deliveryMethodLabel(selectedDetail?.deliveryMethod || '') }}</strong></div><div><span>期望发货时间</span><strong>{{ formatDateTime(selectedDetail?.estimatedShipAt) }}</strong></div><div><span>实际发货时间</span><strong>{{ formatDateTime(selectedDetail?.actualShipAt) }}</strong></div><div><span>是否换标</span><strong>{{ selectedDetail?.needRelabel ? '是' : '否' }}</strong></div><div><span>换标状态</span><el-tag :type="transferRelabelStatusTagType(transferRelabelStatus(selectedDetail || {}).code)" effect="light">{{ transferRelabelStatus(selectedDetail || {}).label }}</el-tag></div><div><span>换标单号</span><strong>{{ selectedDetail?.relabelNo || '—' }}</strong></div><div><span>创建时间</span><strong>{{ formatDateTime(selectedDetail?.createdAt) }}</strong></div></div><div v-else class="warehouse-detail-grid"><div v-for="(column, index) in config.columns.slice(1)" :key="column"><span>{{ column }}</span><strong>{{ selectedDetail?.[String(index)] || '—' }}</strong></div></div></div>
<div v-if="props.service === 'disposal'" class="warehouse-card warehouse-form-section"> <div v-if="props.service === 'disposal'" class="warehouse-card warehouse-form-section">
<h3>本次废弃箱子</h3><div class="warehouse-table-scroll"><table class="warehouse-edit-table"><thead><tr><th>入仓码</th><th>SKU</th><th>品名</th><th>申请数量</th><th>所选箱唛</th></tr></thead><tbody><tr v-for="(item, index) in selectedDetail?.items || []" :key="index"><td>{{ item.inboundCode || '—' }}</td><td>{{ item.skuCode || '—' }}</td><td>{{ item.productName || '—' }}</td><td>{{ item.discardPackages ?? '—' }}</td><td>{{ item.cartonMarks?.join('、') || '全部库存(历史记录无箱唛快照)' }}</td></tr></tbody></table></div>
<h3>废弃现场照片</h3><div v-if="selectedDetail?.photos?.length" class="warehouse-photo-grid"><div v-for="photo in selectedDetail.photos" :key="photo.fileId" class="warehouse-photo-item"><el-image v-if="discardPhotoUrls[photo.fileId]" :src="discardPhotoUrls[photo.fileId]" :preview-src-list="discardPhotoPreviewUrls" :initial-index="discardPhotoPreviewUrls.indexOf(discardPhotoUrls[photo.fileId])" fit="cover" preview-teleported /><div v-else-if="discardPhotoLoading[photo.fileId]" class="warehouse-photo-placeholder">照片加载中…</div><button v-else class="warehouse-photo-placeholder warehouse-photo-retry" type="button" @click="loadDiscardPhoto(photo.fileId)">{{ discardPhotoErrors[photo.fileId] ? '加载失败,点击重试' : '加载照片' }}</button><small>{{ formatDateTime(photo.submittedAt) }}</small></div></div><div v-else class="warehouse-empty">暂无已提交的废弃现场照片</div> <h3>废弃现场照片</h3><div v-if="selectedDetail?.photos?.length" class="warehouse-photo-grid"><div v-for="photo in selectedDetail.photos" :key="photo.fileId" class="warehouse-photo-item"><el-image v-if="discardPhotoUrls[photo.fileId]" :src="discardPhotoUrls[photo.fileId]" :preview-src-list="discardPhotoPreviewUrls" :initial-index="discardPhotoPreviewUrls.indexOf(discardPhotoUrls[photo.fileId])" fit="cover" preview-teleported /><div v-else-if="discardPhotoLoading[photo.fileId]" class="warehouse-photo-placeholder">照片加载中…</div><button v-else class="warehouse-photo-placeholder warehouse-photo-retry" type="button" @click="loadDiscardPhoto(photo.fileId)">{{ discardPhotoErrors[photo.fileId] ? '加载失败,点击重试' : '加载照片' }}</button><small>{{ formatDateTime(photo.submittedAt) }}</small></div></div><div v-else class="warehouse-empty">暂无已提交的废弃现场照片</div>
</div> </div>
<div v-if="props.service === 'transfer'" class="warehouse-card warehouse-form-section warehouse-transfer-measurement-card"> <div v-if="props.service === 'transfer'" class="warehouse-card warehouse-form-section warehouse-transfer-cargo-card">
<div class="warehouse-section-heading"><h3>PDA 打托实测数据</h3><span class="warehouse-hint">PDA 作业完成即显示入库完成,客户可确认仓库实测数据</span></div> <div class="warehouse-section-heading"><h3>预报与实际签收</h3></div>
<div v-if="selectedDetail?.pallets?.length" class="warehouse-table-scroll"><table><thead><tr><th>托盘号</th><th>库位</th><th>箱数</th><th>长 × 宽 × 高(cm)</th><th>毛重(kg)</th><th>体积(m³)</th><th>状态</th><th>完成时间</th></tr></thead><tbody><tr v-for="pallet in selectedDetail.pallets" :key="String(pallet.id || pallet.sequenceNo)"><td>{{ pallet.palletNo || `第${pallet.sequenceNo || '—'}托` }}</td><td>{{ pallet.warehouseLocationCode || '—' }}</td><td>{{ pallet.cartonCount ?? '—' }}</td><td>{{ [pallet.lengthCm, pallet.widthCm, pallet.heightCm].every((value) => value != null) ? `${pallet.lengthCm} × ${pallet.widthCm} × ${pallet.heightCm}` : '—' }}</td><td>{{ pallet.grossWeightKg ?? '—' }}</td><td>{{ pallet.volumeCbm ?? '—' }}</td><td>{{ statusLabel(pallet.status || '') }}</td><td>{{ formatDateTime(pallet.completedAt) }}</td></tr></tbody></table></div> <div class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-transfer-comparison-table"><colgroup><col style="width:500px" /><col style="width:140px" /><col style="width:130px" /><col style="width:100px" /><col style="width:130px" /><col style="width:130px" /></colgroup><thead><tr><th>商品 SKU</th><th>中文品名</th><th>预报数量(件)</th><th>预计箱数</th><th>实际签收件数</th><th>实际签收箱数</th></tr></thead><tbody><tr v-for="(item, index) in selectedDetail?.items || []" :key="String(item.id || index)"><td>{{ item.skuCode || '—' }}</td><td>{{ item.productNameZh || '—' }}</td><td>{{ item.quantity ?? '—' }}</td><td>{{ item.packageCount ?? '—' }}</td><td>{{ receivedItemQuantity(item.id) }}</td><td>{{ receivedItemCartons(item.id).length }}</td></tr><tr v-if="!selectedDetail?.items?.length"><td colspan="6"><div class="warehouse-empty">暂无货物明细</div></td></tr></tbody></table></div>
<details class="warehouse-transfer-pallet-group" open>
<summary class="warehouse-section-heading warehouse-transfer-pallet-heading warehouse-transfer-pallet-summary"><h3>PDA 打托实测数据</h3><span class="warehouse-transfer-pallet-count">{{ selectedDetail?.pallets?.length || 0 }} 托</span><span class="warehouse-transfer-pallet-toggle"><span class="when-collapsed">展开托盘</span><span class="when-expanded">收起托盘</span><ChevronDown :size="14" /></span></summary>
<div v-if="selectedDetail?.pallets?.length" class="warehouse-transfer-pallet-list">
<article v-for="pallet in selectedDetail.pallets" :key="String(pallet.id || pallet.sequenceNo)" class="warehouse-transfer-pallet-card">
<header class="warehouse-transfer-pallet-header">
<strong><span>托盘号</span> {{ pallet.palletNo || `第${pallet.sequenceNo || '—'}托` }}</strong>
<dl class="warehouse-transfer-pallet-metrics">
<div><dt>箱数</dt><dd>{{ palletCartons(pallet).length }} 箱</dd></div><div><dt>件数</dt><dd>{{ palletQuantity(pallet) }} 件</dd></div><div><dt>SKU 种数</dt><dd>{{ palletSkuRows(pallet).length }} 种</dd></div><div><dt>毛重(kg)</dt><dd>{{ pallet.grossWeightKg ?? '—' }}</dd></div><div><dt>体积(m³)</dt><dd>{{ pallet.volumeCbm ?? '—' }}</dd></div>
</dl>
</header>
<div class="warehouse-transfer-pallet-skus"><span>SKU 汇总</span><div><div v-for="item in palletSkuRows(pallet)" :key="item.sku" class="warehouse-transfer-pallet-sku"><strong>{{ item.sku }}</strong><span>{{ item.quantity }} 件</span></div><span v-if="!palletSkuRows(pallet).length">暂无已清点 SKU</span></div></div>
<div class="warehouse-transfer-pallet-meta"><span>库位:{{ pallet.warehouseLocationCode || '—' }}</span><span>完成时间:{{ formatDateTime(pallet.completedAt) }}</span></div>
</article>
</div>
<div v-else class="warehouse-empty">暂无 PDA 打托测量数据</div> <div v-else class="warehouse-empty">暂无 PDA 打托测量数据</div>
</details>
<div class="warehouse-evidence warehouse-measurement-evidence"><span>测量打托现场照片</span><div v-if="selectedDetail?.measurementPhotos?.length" class="warehouse-photo-grid warehouse-measurement-photo-grid"><div v-for="(photo, index) in selectedDetail.measurementPhotos" :key="photo.fileId" class="warehouse-photo-item warehouse-measurement-photo-item" :title="photo.fileName || `测量照片 ${index + 1}`"><el-image v-if="measurementPhotoUrls[photo.fileId]" :src="measurementPhotoUrls[photo.fileId]" :preview-src-list="measurementPhotoPreviewUrls" :initial-index="measurementPhotoPreviewUrls.indexOf(measurementPhotoUrls[photo.fileId])" :alt="photo.fileName || `测量照片 ${index + 1}`" fit="cover" preview-teleported /><div v-else-if="measurementPhotoLoading[photo.fileId]" class="warehouse-photo-placeholder">照片 {{ index + 1 }} 加载中…</div><button v-else class="warehouse-photo-placeholder warehouse-photo-retry" type="button" @click="loadMeasurementPhoto(photo.fileId)">{{ measurementPhotoErrors[photo.fileId] ? `照片 ${index + 1} 加载失败,点击重试` : `加载照片 ${index + 1}` }}</button></div></div><strong v-else>仓库尚未上传测量打托照片</strong></div> <div class="warehouse-evidence warehouse-measurement-evidence"><span>测量打托现场照片</span><div v-if="selectedDetail?.measurementPhotos?.length" class="warehouse-photo-grid warehouse-measurement-photo-grid"><div v-for="(photo, index) in selectedDetail.measurementPhotos" :key="photo.fileId" class="warehouse-photo-item warehouse-measurement-photo-item" :title="photo.fileName || `测量照片 ${index + 1}`"><el-image v-if="measurementPhotoUrls[photo.fileId]" :src="measurementPhotoUrls[photo.fileId]" :preview-src-list="measurementPhotoPreviewUrls" :initial-index="measurementPhotoPreviewUrls.indexOf(measurementPhotoUrls[photo.fileId])" :alt="photo.fileName || `测量照片 ${index + 1}`" fit="cover" preview-teleported /><div v-else-if="measurementPhotoLoading[photo.fileId]" class="warehouse-photo-placeholder">照片 {{ index + 1 }} 加载中…</div><button v-else class="warehouse-photo-placeholder warehouse-photo-retry" type="button" @click="loadMeasurementPhoto(photo.fileId)">{{ measurementPhotoErrors[photo.fileId] ? `照片 ${index + 1} 加载失败,点击重试` : `加载照片 ${index + 1}` }}</button></div></div><strong v-else>仓库尚未上传测量打托照片</strong></div>
</div> </div>
<div v-if="props.service === 'transfer'" class="warehouse-card warehouse-form-section warehouse-transfer-cargo-card"> <div v-if="props.service === 'transfer'" class="warehouse-card warehouse-form-section warehouse-transfer-received-card">
<div class="warehouse-section-heading"><h3>货物明细</h3><el-button v-if="selectedDetail?.status !== 'DRAFT'" native-type="button" type="primary" @click="printTransferCartons(String(selectedDetail?.transferNo || selectedDetail?.['0'] || ''))"><Printer :size="16" />打印箱唛</el-button></div> <div class="warehouse-section-heading warehouse-transfer-receipt-heading"><h3>签收箱号明细({{ countedTransferCartons().length }} 箱)</h3><div class="warehouse-transfer-receipt-actions"><el-button v-if="selectedDetail?.status !== 'DRAFT'" native-type="button" type="primary" size="small" @click="printTransferCartons(String(selectedDetail?.transferNo || selectedDetail?.['0'] || ''))"><Printer :size="14" />打印箱唛</el-button><el-button type="primary" size="small" :disabled="!countedTransferCartons().length" @click="showTransferCartons = true">查看箱号明细</el-button></div></div>
<div class="warehouse-table-scroll"><table class="warehouse-edit-table"><thead><tr><th>商品 SKU</th><th>中文品名</th><th>预计箱数</th><th>客户箱唛号</th></tr></thead><tbody><tr v-for="(item, index) in selectedDetail?.items || []" :key="`${item.skuCode}-${index}`"><td>{{ item.skuCode || '—' }}</td><td>{{ item.productNameZh || '—' }}</td><td>{{ item.packageCount ?? '—' }}</td><td>{{ item.shippingMark || '—' }}</td></tr><tr v-if="!selectedDetail?.items?.length"><td colspan="4"><div class="warehouse-empty">暂无货物明细</div></td></tr></tbody></table></div> <div v-if="!countedTransferCartons().length" class="warehouse-empty">暂无实际签收箱,等待 PDA 清点。</div>
<el-dialog v-model="showTransferCartons" title="签收箱号明细" width="1120px" align-center destroy-on-close append-to-body>
<div class="warehouse-cartons-dialog-body">
<el-collapse>
<el-collapse-item v-for="carton in countedTransferCartons()" :key="carton.cartonMark" :name="carton.cartonMark">
<template #title><strong>{{ carton.cartonMark }}</strong><span class="warehouse-carton-summary">已确认 · {{ transferCartonLines(carton).length }} 种 SKU · {{ carton.quantity ?? '—' }} 件</span></template>
<div class="warehouse-carton-measurements">所属托盘:{{ selectedDetail?.pallets?.find((pallet) => carton.transferPalletId != null && String(pallet.id) === String(carton.transferPalletId))?.palletNo || '散箱' }} · 清点时间:{{ formatDateTime(carton.countedAt) }}</div>
<div class="warehouse-carton-measurements">长 {{ carton.length ?? '—' }} cm · 宽 {{ carton.width ?? '—' }} cm · 高 {{ carton.height ?? '—' }} cm · 重量 {{ carton.weight ?? '—' }} kg · 体积 {{ [carton.length, carton.width, carton.height].every(value => value != null) ? (Number(carton.length) * Number(carton.width) * Number(carton.height) / 1000000).toFixed(6) : '—' }} m³</div>
<el-table :data="transferCartonLines(carton)" size="small" border>
<el-table-column prop="skuCode" label="SKU" min-width="220" />
<el-table-column prop="productNameZh" label="中文品名" min-width="180" />
<el-table-column prop="quantity" label="实际签收数量" width="130" />
</el-table>
<p v-if="carton.discrepancyReason" class="warehouse-carton-measurements">差异说明:{{ carton.discrepancyReason }}</p>
</el-collapse-item>
</el-collapse>
</div>
<template #footer><el-button @click="showTransferCartons = false">关闭</el-button></template>
</el-dialog>
</div> </div>
<div v-if="props.service === 'transfer' && selectedDetail?.needRelabel" class="warehouse-card warehouse-form-section"> <div v-if="props.service === 'transfer' && selectedDetail?.needRelabel" class="warehouse-card warehouse-form-section">
<h3>换标箱级去向</h3> <h3>换标箱级去向</h3>
@@ -1725,6 +1839,7 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
</div> </div>
</template> </template>
</section> </section>
<CompletionConfirmationDialog v-model="completionConfirmOpen" :kind="completionConfirmKind" :document-no="completionConfirmNo" @confirmed="refreshAfterCompletion" />
</template> </template>
<style scoped> <style scoped>
@@ -1868,7 +1983,7 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
.warehouse-native-file{min-height:32px;padding:5px 8px;border:1px solid #dcdfe6;border-radius:4px;background:#fff;color:#606266}.warehouse-fulfillment-grid{margin-bottom:18px}.warehouse-evidence{display:flex;align-items:flex-start;gap:16px;padding:14px;border:1px solid #e7ebf1;border-radius:4px;background:#f8fafc}.warehouse-evidence>span{padding-top:6px;color:#71809a;font-size:12px;white-space:nowrap}.warehouse-evidence>strong{padding-top:6px;color:#8a95a5;font-size:12px;font-weight:400}.warehouse-photo-grid{display:grid;grid-template-columns:repeat(4,140px);gap:12px}.warehouse-photo-item{width:140px;height:105px;overflow:hidden;border:1px solid #dce3ed;border-radius:5px;background:#fff}.warehouse-photo-item :deep(.el-image){display:block;width:100%;height:100%;cursor:zoom-in}.warehouse-photo-placeholder{display:flex;width:100%;height:100%;align-items:center;justify-content:center;padding:10px;border:0;background:#f4f7fb;color:#8792a2;font-size:12px;text-align:center}.warehouse-photo-retry{cursor:pointer}.warehouse-completion-remark{margin:10px 0 0;color:#596579;font-size:12px} .warehouse-native-file{min-height:32px;padding:5px 8px;border:1px solid #dcdfe6;border-radius:4px;background:#fff;color:#606266}.warehouse-fulfillment-grid{margin-bottom:18px}.warehouse-evidence{display:flex;align-items:flex-start;gap:16px;padding:14px;border:1px solid #e7ebf1;border-radius:4px;background:#f8fafc}.warehouse-evidence>span{padding-top:6px;color:#71809a;font-size:12px;white-space:nowrap}.warehouse-evidence>strong{padding-top:6px;color:#8a95a5;font-size:12px;font-weight:400}.warehouse-photo-grid{display:grid;grid-template-columns:repeat(4,140px);gap:12px}.warehouse-photo-item{width:140px;height:105px;overflow:hidden;border:1px solid #dce3ed;border-radius:5px;background:#fff}.warehouse-photo-item :deep(.el-image){display:block;width:100%;height:100%;cursor:zoom-in}.warehouse-photo-placeholder{display:flex;width:100%;height:100%;align-items:center;justify-content:center;padding:10px;border:0;background:#f4f7fb;color:#8792a2;font-size:12px;text-align:center}.warehouse-photo-retry{cursor:pointer}.warehouse-completion-remark{margin:10px 0 0;color:#596579;font-size:12px}
.warehouse-measurement-evidence{margin-top:16px} .warehouse-measurement-evidence{margin-top:16px}
.warehouse-measurement-photo-grid{flex:1;grid-template-columns:repeat(auto-fill,minmax(240px,1fr))} .warehouse-measurement-photo-grid{flex:1;grid-template-columns:repeat(auto-fill,minmax(240px,1fr))}
.warehouse-measurement-photo-item{width:100%;height:180px} .warehouse-measurement-photo-item{width:100%;height:120px}
.warehouse-template-actions{flex-wrap:wrap;justify-content:flex-end}.warehouse-import-preview{margin:0 0 16px;padding:14px;border:1px solid #cfe0f5;border-radius:5px;background:#f8fbff}.warehouse-import-preview .warehouse-section-heading{margin-bottom:10px}.warehouse-import-preview .warehouse-section-heading>div{display:flex;align-items:center;gap:10px}.warehouse-import-preview p{margin:0;color:#7d8797;font-size:12px}.warehouse-binding-preview-table{min-width:980px;background:#fff} .warehouse-template-actions{flex-wrap:wrap;justify-content:flex-end}.warehouse-import-preview{margin:0 0 16px;padding:14px;border:1px solid #cfe0f5;border-radius:5px;background:#f8fbff}.warehouse-import-preview .warehouse-section-heading{margin-bottom:10px}.warehouse-import-preview .warehouse-section-heading>div{display:flex;align-items:center;gap:10px}.warehouse-import-preview p{margin:0;color:#7d8797;font-size:12px}.warehouse-binding-preview-table{min-width:980px;background:#fff}
.warehouse-transfer-relabel .warehouse-labeling-heading>div:first-child{min-width:0}.warehouse-transfer-relabel .warehouse-labeling-heading h3{margin-bottom:6px}.warehouse-transfer-relabel .warehouse-labeling-heading p{margin:0 0 16px;color:#7d8797;font-size:12px}.warehouse-transfer-relabel-fields{margin-bottom:18px}.warehouse-transfer-file-groups{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(360px,.85fr);align-items:stretch;gap:14px;margin-bottom:14px}.warehouse-transfer-total-files,.warehouse-transfer-template-group{height:100%;margin:0}.warehouse-transfer-mark-table{min-width:1420px}.warehouse-transfer-mark-table td{vertical-align:top}.warehouse-transfer-mark-table :deep(.el-textarea){min-width:220px}.warehouse-transfer-mark-table :deep(.el-input-number){width:150px}.warehouse-count-error{color:#d64545} .warehouse-transfer-relabel .warehouse-labeling-heading>div:first-child{min-width:0}.warehouse-transfer-relabel .warehouse-labeling-heading h3{margin-bottom:6px}.warehouse-transfer-relabel .warehouse-labeling-heading p{margin:0 0 16px;color:#7d8797;font-size:12px}.warehouse-transfer-relabel-fields{margin-bottom:18px}.warehouse-transfer-file-groups{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(360px,.85fr);align-items:stretch;gap:14px;margin-bottom:14px}.warehouse-transfer-total-files,.warehouse-transfer-template-group{height:100%;margin:0}.warehouse-transfer-mark-table{min-width:1420px}.warehouse-transfer-mark-table td{vertical-align:top}.warehouse-transfer-mark-table :deep(.el-textarea){min-width:220px}.warehouse-transfer-mark-table :deep(.el-input-number){width:150px}.warehouse-count-error{color:#d64545}
.warehouse-transfer-form{display:grid;grid-template-columns:minmax(0,1fr);gap:12px}.warehouse-transfer-form--relabel{grid-template-columns:minmax(0,1fr) minmax(0,1.35fr);align-items:start}.warehouse-transfer-form--relabel .warehouse-transfer-main-card{grid-column:1;grid-row:1}.warehouse-transfer-form--relabel .warehouse-transfer-cargo-card{grid-column:1;grid-row:2}.warehouse-transfer-form--relabel .warehouse-transfer-relabel{grid-column:2;grid-row:1 / span 2;margin-bottom:0}.warehouse-transfer-form--relabel .warehouse-transfer-submit-bar{grid-column:1 / -1}.warehouse-transfer-form :deep(.el-input__wrapper),.warehouse-transfer-form :deep(.el-textarea__inner),.warehouse-transfer-form :deep(.el-select__wrapper),.warehouse-transfer-form :deep(.el-date-editor.el-input__wrapper){min-height:32px}.warehouse-transfer-form .warehouse-form-section{padding:16px}.warehouse-transfer-form .warehouse-form-section h3{margin-bottom:12px;padding-bottom:9px;font-size:14px}.warehouse-transfer-form .warehouse-form-grid{gap:12px 16px}.warehouse-transfer-form .warehouse-choice-field{min-height:34px;padding:8px 10px;grid-template-columns:118px minmax(0,1fr);gap:10px}.warehouse-transfer-form .warehouse-choice-field>span{font-size:12px}.warehouse-transfer-form .warehouse-receiving-card{gap:12px;padding:10px 12px}.warehouse-transfer-form .warehouse-section-heading{margin-bottom:8px}.warehouse-transfer-form .warehouse-edit-table{font-size:11px}.warehouse-transfer-form .warehouse-edit-table th{height:36px;padding:0 9px}.warehouse-transfer-form .warehouse-edit-table td{height:42px;padding:6px 7px}.warehouse-transfer-form .warehouse-transfer-mark-table :deep(.el-textarea){min-width:150px}.warehouse-transfer-form .warehouse-transfer-mark-table :deep(.el-input-number){width:112px}.warehouse-transfer-form .warehouse-hint{margin-top:5px;font-size:11px}.warehouse-transfer-form .warehouse-labeling-mode{gap:9px;margin-bottom:10px;padding:9px 10px}.warehouse-transfer-form .warehouse-labeling-mode>span{font-size:12px}.warehouse-transfer-form .warehouse-labeling-mode :deep(.el-radio-button__inner){padding:6px 9px}.warehouse-transfer-form .warehouse-template-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:5px;padding-bottom:8px}.warehouse-transfer-form .warehouse-template-actions :deep(.el-button){padding:6px 8px;font-size:11px}.warehouse-transfer-form .warehouse-submit-bar{padding:4px 0 18px}.warehouse-transfer-form .warehouse-submit-bar :deep(.el-button){min-height:32px;padding:7px 14px}.warehouse-transfer-form .warehouse-transfer-relabel{overflow:hidden}.warehouse-transfer-form .warehouse-transfer-relabel-fields{margin-bottom:10px}.warehouse-step-heading{align-items:flex-start}.warehouse-step-heading h3{display:flex;align-items:center;gap:9px}.warehouse-step-number{display:inline-grid;flex:0 0 auto;width:24px;height:24px;border-radius:50%;background:#3478c8;color:#fff;font-size:12px;place-items:center}.warehouse-heading-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px}.warehouse-transfer-template-group{margin:0 0 14px;padding:12px 14px;border:1px solid #dde5f0;border-radius:7px;background:#fbfcfe}.warehouse-binding-heading{margin-top:14px;padding:12px 14px;border:1px solid #dde5f0;border-radius:7px;background:#f8faff}.warehouse-binding-heading strong{display:flex;align-items:center;gap:8px;color:#34445c;font-size:13px} .warehouse-transfer-form{display:grid;grid-template-columns:minmax(0,1fr);gap:12px}.warehouse-transfer-form--relabel{grid-template-columns:minmax(0,1fr) minmax(0,1.35fr);align-items:start}.warehouse-transfer-form--relabel .warehouse-transfer-main-card{grid-column:1;grid-row:1}.warehouse-transfer-form--relabel .warehouse-transfer-cargo-card{grid-column:1;grid-row:2}.warehouse-transfer-form--relabel .warehouse-transfer-relabel{grid-column:2;grid-row:1 / span 2;margin-bottom:0}.warehouse-transfer-form--relabel .warehouse-transfer-submit-bar{grid-column:1 / -1}.warehouse-transfer-form :deep(.el-input__wrapper),.warehouse-transfer-form :deep(.el-textarea__inner),.warehouse-transfer-form :deep(.el-select__wrapper),.warehouse-transfer-form :deep(.el-date-editor.el-input__wrapper){min-height:32px}.warehouse-transfer-form .warehouse-form-section{padding:16px}.warehouse-transfer-form .warehouse-form-section h3{margin-bottom:12px;padding-bottom:9px;font-size:14px}.warehouse-transfer-form .warehouse-form-grid{gap:12px 16px}.warehouse-transfer-form .warehouse-choice-field{min-height:34px;padding:8px 10px;grid-template-columns:118px minmax(0,1fr);gap:10px}.warehouse-transfer-form .warehouse-choice-field>span{font-size:12px}.warehouse-transfer-form .warehouse-receiving-card{gap:12px;padding:10px 12px}.warehouse-transfer-form .warehouse-section-heading{margin-bottom:8px}.warehouse-transfer-form .warehouse-edit-table{font-size:11px}.warehouse-transfer-form .warehouse-edit-table th{height:36px;padding:0 9px}.warehouse-transfer-form .warehouse-edit-table td{height:42px;padding:6px 7px}.warehouse-transfer-form .warehouse-transfer-mark-table :deep(.el-textarea){min-width:150px}.warehouse-transfer-form .warehouse-transfer-mark-table :deep(.el-input-number){width:112px}.warehouse-transfer-form .warehouse-hint{margin-top:5px;font-size:11px}.warehouse-transfer-form .warehouse-labeling-mode{gap:9px;margin-bottom:10px;padding:9px 10px}.warehouse-transfer-form .warehouse-labeling-mode>span{font-size:12px}.warehouse-transfer-form .warehouse-labeling-mode :deep(.el-radio-button__inner){padding:6px 9px}.warehouse-transfer-form .warehouse-template-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:5px;padding-bottom:8px}.warehouse-transfer-form .warehouse-template-actions :deep(.el-button){padding:6px 8px;font-size:11px}.warehouse-transfer-form .warehouse-submit-bar{padding:4px 0 18px}.warehouse-transfer-form .warehouse-submit-bar :deep(.el-button){min-height:32px;padding:7px 14px}.warehouse-transfer-form .warehouse-transfer-relabel{overflow:hidden}.warehouse-transfer-form .warehouse-transfer-relabel-fields{margin-bottom:10px}.warehouse-step-heading{align-items:flex-start}.warehouse-step-heading h3{display:flex;align-items:center;gap:9px}.warehouse-step-number{display:inline-grid;flex:0 0 auto;width:24px;height:24px;border-radius:50%;background:#3478c8;color:#fff;font-size:12px;place-items:center}.warehouse-heading-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px}.warehouse-transfer-template-group{margin:0 0 14px;padding:12px 14px;border:1px solid #dde5f0;border-radius:7px;background:#fbfcfe}.warehouse-binding-heading{margin-top:14px;padding:12px 14px;border:1px solid #dde5f0;border-radius:7px;background:#f8faff}.warehouse-binding-heading strong{display:flex;align-items:center;gap:8px;color:#34445c;font-size:13px}
@@ -1881,6 +1996,7 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
.warehouse-service-page--detail .warehouse-transfer-overview-grid{grid-template-columns:repeat(4,minmax(180px,1fr));gap:6px 28px;padding:10px 16px 12px}.warehouse-service-page--detail .warehouse-transfer-overview-grid>div{min-height:26px;padding:2px 0;border:0}.warehouse-service-page--detail .warehouse-transfer-overview-grid span{flex:0 0 auto;min-width:76px}.warehouse-service-page--detail .warehouse-transfer-overview-grid :deep(.el-tag){height:24px} .warehouse-service-page--detail .warehouse-transfer-overview-grid{grid-template-columns:repeat(4,minmax(180px,1fr));gap:6px 28px;padding:10px 16px 12px}.warehouse-service-page--detail .warehouse-transfer-overview-grid>div{min-height:26px;padding:2px 0;border:0}.warehouse-service-page--detail .warehouse-transfer-overview-grid span{flex:0 0 auto;min-width:76px}.warehouse-service-page--detail .warehouse-transfer-overview-grid :deep(.el-tag){height:24px}
.warehouse-service-page--detail>.warehouse-detail-header+.warehouse-card .warehouse-detail-grid{grid-template-columns:repeat(4,minmax(180px,1fr));gap:6px 28px;padding:10px 16px 12px}.warehouse-service-page--detail>.warehouse-detail-header+.warehouse-card .warehouse-detail-grid>div{min-height:26px;padding:2px 0;border:0}.warehouse-service-page--detail>.warehouse-detail-header+.warehouse-card .warehouse-detail-grid span{flex:0 0 auto;min-width:76px} .warehouse-service-page--detail>.warehouse-detail-header+.warehouse-card .warehouse-detail-grid{grid-template-columns:repeat(4,minmax(180px,1fr));gap:6px 28px;padding:10px 16px 12px}.warehouse-service-page--detail>.warehouse-detail-header+.warehouse-card .warehouse-detail-grid>div{min-height:26px;padding:2px 0;border:0}.warehouse-service-page--detail>.warehouse-detail-header+.warehouse-card .warehouse-detail-grid span{flex:0 0 auto;min-width:76px}
.warehouse-service-page--detail .warehouse-transfer-overview-grid>.warehouse-overview-wide{grid-column:span 2} .warehouse-service-page--detail .warehouse-transfer-overview-grid>.warehouse-overview-wide{grid-column:span 2}
.warehouse-transfer-received-card{margin-top:14px}.warehouse-cartons-dialog-body{max-height:65vh;overflow:auto}.warehouse-carton-summary{margin-left:16px;color:var(--muted);font-size:12px}.warehouse-carton-measurements{margin:10px 0;color:var(--muted);font-size:13px;line-height:1.8}
@media(max-width:1100px){.warehouse-service-page--detail .warehouse-transfer-overview-grid{grid-template-columns:repeat(2,minmax(180px,1fr))}}@media(max-width:720px){.warehouse-service-page--detail .warehouse-transfer-overview-grid{grid-template-columns:1fr;gap:4px;padding:10px 14px}} @media(max-width:1100px){.warehouse-service-page--detail .warehouse-transfer-overview-grid{grid-template-columns:repeat(2,minmax(180px,1fr))}}@media(max-width:720px){.warehouse-service-page--detail .warehouse-transfer-overview-grid{grid-template-columns:1fr;gap:4px;padding:10px 14px}}
/* Keep the count and controls visible together on narrow list pages. */ /* Keep the count and controls visible together on narrow list pages. */
.warehouse-pagination { justify-content: flex-end; } .warehouse-pagination { justify-content: flex-end; }
@@ -1896,4 +2012,55 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
.warehouse-transfer-cargo-card .warehouse-section-heading, .warehouse-transfer-cargo-card .warehouse-table-scroll { max-width: none; } .warehouse-transfer-cargo-card .warehouse-section-heading, .warehouse-transfer-cargo-card .warehouse-table-scroll { max-width: none; }
.warehouse-transfer-form .warehouse-choice-field--relabel { grid-template-columns: 1fr; align-items: start; } .warehouse-transfer-form .warehouse-choice-field--relabel { grid-template-columns: 1fr; align-items: start; }
} }
</style>
<style scoped>
.warehouse-discard-boxes { margin-bottom: 24px; }
.warehouse-discard-box-toolbar { display: flex; align-items: center; gap: 20px; margin-bottom: 16px; }
.warehouse-discard-box-toolbar .el-input { max-width: 340px; }
.warehouse-service-page--detail .warehouse-table-scroll .warehouse-transfer-comparison-table{width:1130px;min-width:1130px;table-layout:fixed}
.warehouse-transfer-comparison-table td{white-space:normal;overflow-wrap:anywhere}
.warehouse-service-page--detail .warehouse-transfer-comparison-table th{height:36px;padding:0 10px}
.warehouse-service-page--detail .warehouse-transfer-comparison-table td{height:38px;padding:7px 10px}
.warehouse-transfer-comparison-table th:nth-child(n+3),.warehouse-transfer-comparison-table td:nth-child(n+3){text-align:center}
.warehouse-transfer-comparison-table td:first-child{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.warehouse-service-page--detail .warehouse-transfer-cargo-card .warehouse-transfer-pallet-heading{margin-top:12px}
.warehouse-transfer-pallet-list{display:grid;grid-template-columns:minmax(0,1fr);gap:12px;padding:0 14px;margin-bottom:12px;align-items:start}
.warehouse-transfer-pallet-card{padding:14px;border:1px solid var(--client-border);border-radius:10px;background:#fff;min-width:0}
.warehouse-transfer-pallet-header{display:flex;align-items:center;flex-wrap:wrap;gap:10px 20px}
.warehouse-transfer-pallet-header>strong{font-size:13px;color:var(--navy);overflow-wrap:anywhere;line-height:1.6}
.warehouse-transfer-pallet-header strong>span{display:inline-block;margin-right:8px;padding:2px 7px;border-radius:4px;background:#eef1ff;color:var(--primary);font-size:12px;font-weight:500}
.warehouse-transfer-pallet-metrics{display:flex;flex-wrap:wrap;gap:8px;margin:0}
.warehouse-transfer-pallet-metrics>div{display:flex;align-items:baseline;gap:8px;padding:6px 10px;background:#f5f7fc;border:1px solid #e9edf5;border-radius:6px}
.warehouse-transfer-pallet-metrics dt{font-size:12px;color:var(--muted)}
.warehouse-transfer-pallet-metrics dd{margin:0;font-size:13px;font-weight:600;color:var(--navy)}
.warehouse-transfer-pallet-summary{cursor:pointer;list-style:none;border-radius:4px}
.warehouse-transfer-pallet-summary::-webkit-details-marker{display:none}
.warehouse-transfer-pallet-summary:focus-visible{outline:2px solid var(--primary);outline-offset:4px}
.warehouse-transfer-pallet-toggle{display:flex;align-items:center;gap:8px;flex-shrink:0;margin-left:6px}
.warehouse-transfer-pallet-count{font-size:12px;color:var(--muted)}
.warehouse-service-page--detail.warehouse-service-page--transfer>.warehouse-detail-header{grid-template-columns:auto minmax(0,max-content) auto;justify-content:start;column-gap:14px}
.warehouse-service-page--detail .warehouse-transfer-pallet-heading h3{flex:0 0 auto}
.warehouse-service-page--detail .warehouse-transfer-pallet-heading .warehouse-transfer-pallet-count{flex:0 0 auto}
.warehouse-service-page--detail .warehouse-transfer-cargo-card .warehouse-transfer-pallet-heading{display:flex;align-items:center;justify-content:flex-start;gap:10px}
.warehouse-transfer-pallet-toggle{color:var(--primary);font-size:12px;gap:4px}
.warehouse-transfer-pallet-group .when-expanded{display:none}
.warehouse-transfer-pallet-group[open] .when-expanded{display:inline}
.warehouse-transfer-pallet-group[open] .when-collapsed{display:none}
.warehouse-transfer-pallet-group[open] .warehouse-transfer-pallet-toggle svg{transform:rotate(180deg)}
.warehouse-transfer-pallet-meta{display:flex;flex-wrap:wrap;gap:4px 20px;margin-top:8px;color:var(--muted);font-size:12px;line-height:1.7;overflow-wrap:anywhere}
.warehouse-transfer-pallet-skus{display:grid;grid-template-columns:64px minmax(0,1fr);align-items:start;gap:10px;margin-top:10px;padding-top:10px;border-top:1px solid var(--client-border)}
.warehouse-transfer-pallet-skus>span{font-size:12px;color:var(--muted);padding-top:5px}
.warehouse-transfer-pallet-skus>div{display:flex;flex-wrap:wrap;gap:6px;min-width:0}
.warehouse-transfer-pallet-sku{display:inline-flex;align-items:center;gap:8px;max-width:100%;box-sizing:border-box;padding:4px 8px;background:#f5f7ff;border:1px solid #e5e9f8;border-radius:5px;font-size:12px;line-height:1.6}
.warehouse-transfer-pallet-sku strong{font-weight:500;overflow-wrap:anywhere;min-width:0;color:var(--navy)}
.warehouse-transfer-pallet-sku>span{flex-shrink:0;padding:0 7px;border:1px solid #e0e6ff;border-radius:4px;background:#eef1ff;color:var(--primary);font-weight:600;white-space:nowrap}
.warehouse-service-page--detail .warehouse-transfer-received-card .warehouse-transfer-receipt-heading{justify-content:flex-start;flex-wrap:wrap;gap:10px 16px}
.warehouse-service-page--detail .warehouse-transfer-receipt-heading h3{flex:0 0 auto;margin:0;padding:0;border:0}
.warehouse-transfer-receipt-actions{display:flex;flex-wrap:wrap;gap:8px}.warehouse-transfer-receipt-actions :deep(.el-button+.el-button){margin-left:0}
.warehouse-service-page--detail>.warehouse-detail-header+.warehouse-card .warehouse-transfer-overview-grid{width:100%;max-width:1440px;box-sizing:border-box;grid-template-columns:repeat(4,minmax(0,1fr));column-gap:18px;row-gap:4px;padding:8px 14px 10px}
.warehouse-service-page--detail>.warehouse-detail-header+.warehouse-card .warehouse-transfer-overview-grid>div{min-height:24px;gap:8px;padding:1px 0}
.warehouse-service-page--detail>.warehouse-detail-header+.warehouse-card .warehouse-transfer-overview-grid>div>span{flex:0 0 84px;min-width:0}
</style> </style>
@@ -129,9 +129,9 @@ test('贴标货物明细通过单一换标类型选择三种模式', () => {
test('箱唛相关换标必须选择四选一派送方式', () => { test('箱唛相关换标必须选择四选一派送方式', () => {
assert.match(view, /label="卡车派送" value="TRUCK"/) assert.match(view, /label="卡车派送" value="TRUCK"/)
assert.match(view, /label="快递派送" value="EXPRESS"/) assert.match(view, /label="快递派送" value="EXPRESS"/)
assert.match(view, /label="卡车自提" value="TRUCK_PICKUP"/) assert.match(view, /label="亚马逊自提" value="AMAZON_PICKUP"/)
assert.match(view, /label="快递自提" value="EXPRESS_PICKUP"/) assert.match(view, /label="客户自提" value="CUSTOMER_PICKUP"/)
assert.match(api, /RelabelDeliveryMethod = 'TRUCK' \| 'EXPRESS' \| 'TRUCK_PICKUP' \| 'EXPRESS_PICKUP'/) assert.match(api, /RelabelDeliveryMethod = 'TRUCK' \| 'EXPRESS' \| 'AMAZON_PICKUP' \| 'CUSTOMER_PICKUP'/)
assert.match(view, /labelingMode\.value !== 'sku' && !form\.deliveryMethod/) assert.match(view, /labelingMode\.value !== 'sku' && !form\.deliveryMethod/)
}) })
@@ -5,6 +5,26 @@ import ts from 'typescript'
const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8') const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8')
const source = view.slice(view.indexOf('function returnSourceNos'), view.indexOf('const relabelInboundOptions')) const source = view.slice(view.indexOf('function returnSourceNos'), view.indexOf('const relabelInboundOptions'))
const compiled = ts.transpileModule(source + '\nexports.sourceNos = returnSourceNos; exports.progress = returnRelabelProgress', { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText const compiled = ts.transpileModule(source + '\nexports.sourceNos = returnSourceNos; exports.progress = returnRelabelProgress', { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText
test('carton validation ignores unselected stock after SKU relabel and consolidation', () => {
const start = view.indexOf('async function submit(')
const validation = view.slice(view.indexOf(' const invalidCargo =', start), view.indexOf(" if ((props.service === 'relabel'", start))
const compiledValidation = ts.transpileModule(validation, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
const validate = new Function('props', 'labelingMode', 'relabelRows', 'cartonDetailRows', 'cargoRows', 'skuRelabelRows', compiledValidation + '; return invalidCargo')
const stockRows = [
{ included: false, name: '', sku: 'OLD-SKU', quantity: 1 },
{ included: false, name: '', sku: 'OLD-SKU,NEW-SKU', quantity: 1 },
]
const selected = { included: true, name: 'Product', sku: 'OLD-SKU,NEW-SKU', quantity: 1 }
function invalid(details) {
return validate({ service: 'labeling' }, { value: 'carton' }, details,
{ value: details }, { value: [...stockRows, ...details] }, { value: [] })
}
assert.equal(invalid([selected]), false)
assert.equal(invalid([{ ...selected, name: '' }]), true)
assert.equal(invalid([]), true)
assert.equal(invalid([{ ...selected, sku: '' }]), false, 'SKU is optional for carton labels')
})
const exports = {} const exports = {}
new Function('exports', compiled)(exports) new Function('exports', compiled)(exports)
test('合箱来源按单号集合精确匹配并去重,不能用子串匹配', () => { test('合箱来源按单号集合精确匹配并去重,不能用子串匹配', () => {
@@ -7,6 +7,43 @@ const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url)
test('货物废弃先选择海外操作仓,再加载并选择该仓入库单', () => { test('货物废弃先选择海外操作仓,再加载并选择该仓入库单', () => {
assert.match(view, /props\.service === 'disposal'.*操作仓.*v-model="form\.warehouse".*请选择海外仓.*@change="selectDiscardWarehouse"/s) assert.match(view, /props\.service === 'disposal'.*操作仓.*v-model="form\.warehouse".*请选择海外仓.*@change="selectDiscardWarehouse"/s)
assert.match(view, /props\.service === 'disposal'.*选择入库单.*:disabled="!form\.warehouse".*请先选择操作仓/s) assert.match(view, /props\.service === 'disposal'.*选择入库单.*:disabled="!form\.warehouse".*请先选择操作仓/s)
assert.match(view, /async function selectDiscardWarehouse\(\).*selectedDiscardOrderId\.value = ''.*cargoRows\.value = \[\].*listRelabelInventoryCandidates\(form\.warehouse\).*grouped/s) assert.match(view, /async function selectDiscardWarehouse\(\).*selectedDiscardOrderId\.value = ''.*cargoRows\.value = \[\].*listDiscardCandidates\(\)/s)
assert.match(view, /\|\| !primaryWarehouse \|\| invalidCargo/) assert.match(view, /\|\| !primaryWarehouse \|\| invalidCargo/)
}) })
test('废弃来源包含通用仓位,选择后带出全部库存且不把选择键提交为订单ID', () => {
const body = view.match(/function selectDiscardInbound\(orderId: string\) \{([\s\S]*?)\n\}/)[1]
const select = new Function('discardSelectedBoxKeys', 'discardBoxSearch', 'selectAllDiscardBoxes', 'discardCandidates', 'cargoRows', 'form', 'cargoSequence', 'orderId', body)
const cargoRows = { value: [] }; const form = {}
select({ value: [] }, { value: '' }, () => {}, { value: [{ orderId: 'inventory:42', sourceType: 'INVENTORY', inventoryPositionId: '42', overseasWarehouseId: '99', inboundCode: 'WT-1', skus: [{ skuCode: 'A', productName: '商品', availablePackages: 100 }] }] }, cargoRows, form, 0, 'inventory:42')
assert.equal(cargoRows.value[0].quantity, 100)
assert.equal(cargoRows.value[0].sourceInventoryPositionId, '42')
assert.equal(form.inboundNo, 'WT-1')
assert.match(view, /orderId: item.sourceType === 'DROPSHIP' \? item.sourceOrderId : undefined/)
assert.match(view, /inventoryPositionId: item.sourceType === 'INVENTORY'/)
})
function selectionRows(boxes, selectedKeys) {
const body = view.match(/function syncDiscardSelection\(\) \{([\s\S]*?)\n\}/)[1].replace('new Map<string, CargoRow>()', 'new Map()')
const rows = { value: [] }
new Function('discardSelectableBoxes', 'discardSelectedBoxKeys', 'cargoRows', 'createCargoRow', body)({ value: boxes }, { value: selectedKeys }, rows, () => ({}))
return rows.value
}
test('部分废弃只提交勾选的箱子,数量由箱子汇总,清空后无明细', () => {
const source = { orderId: 'inventory:42', inventoryPositionId: '42', sourceType: 'INVENTORY', availablePackages: 3, skus: [{ skuCode: 'SKU', productName: '商品' }] }
const boxes = ['1', '2', '3'].map(id => ({ key: `INVENTORY:${id}`, cartonId: id, cartonMark: `BOX-${id}`, source, skus: source.skus }))
const rows = selectionRows(boxes, ['INVENTORY:2'])
assert.equal(rows.length, 1); assert.equal(rows[0].quantity, 1)
assert.deepEqual(rows[0].discardCartonIds, ['2']); assert.equal(rows[0].sourceInventoryPositionId, '42')
assert.equal(selectionRows(boxes, []).length, 0)
})
test('混合SKU箱按实物箱只计一次,退货箱仍整箱携带全部SKU', () => {
const skus = [{ skuCode: 'A', availablePackages: 2 }, { skuCode: 'B', availablePackages: 3 }]
const dropship = { orderId: '42', sourceType: 'DROPSHIP', availablePackages: 1, skus }
const rows = selectionRows([{ key: 'DROPSHIP:1', cartonId: '1', source: dropship, skus }], ['DROPSHIP:1'])
assert.equal(rows.length, 1); assert.equal(rows[0].quantity, 1); assert.deepEqual(rows[0].discardCartonIds, ['1'])
const returned = { orderId: 'return:2', returnCartonId: '2', sourceType: 'RETURN_CARTON', availablePackages: 5, skus }
const returnRows = selectionRows([{ key: 'RETURN_CARTON:2', cartonId: '2', source: returned, skus }], ['RETURN_CARTON:2'])
assert.deepEqual(returnRows.map(row => row.quantity), [2, 3]); assert.ok(returnRows.every(row => row.returnCartonId === '2' && row.discardCartonIds === undefined))
})
@@ -0,0 +1,21 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import ts from 'typescript'
const source = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8')
const functions = source.slice(source.indexOf('function transferCartonItem('), source.indexOf("const detailError = ref('')"))
const code = ts.transpileModule(functions, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
const load = selectedDetail => new Function('selectedDetail', `${code}; return { transferCartonLines, palletSkuRows, palletQuantity, receivedItemCartons, receivedItemQuantity };`)(selectedDetail)
test('mixed carton detail and pallet summaries preserve every SKU without duplicating boxes', () => {
const mixed = { status: 'COUNTED', transferItemId: '1', transferPalletId: '9', quantity: 10, items: [{ transferItemId: '1', skuCode: 'A', quantity: 3 }, { transferItemId: '2', skuCode: 'B', quantity: 7 }] }
const display = load({ value: { items: [{ id: '1', skuCode: 'A', productNameZh: '产品A' }, { id: '2', skuCode: 'B', productNameZh: '产品B' }], cartons: [mixed, { ...mixed, status: 'NOT_RECEIVED', quantity: 999 }] } })
assert.equal(display.transferCartonLines(mixed).length, 2)
assert.equal(display.transferCartonLines(mixed)[1].productNameZh, '产品B')
assert.deepEqual(display.palletSkuRows({ id: '9' }), [{ sku: 'A', quantity: 3 }, { sku: 'B', quantity: 7 }])
assert.equal(display.palletQuantity({ id: '9' }), 10)
assert.equal(display.receivedItemQuantity('1'), 3)
assert.equal(display.receivedItemQuantity('2'), 7)
assert.equal(display.receivedItemCartons('2').length, 1)
})
@@ -44,7 +44,8 @@ test('仓储转运支持无目的地、FBA 仓和私人仓并显式选择是否
test('换标详情待客户确认时提供确认入口并刷新详情', () => { test('换标详情待客户确认时提供确认入口并刷新详情', () => {
assert.match(view, /application\.status === 'CUSTOMER_CONFIRM_PENDING'[\s\S]*@click="confirmSelectedRelabel">确认换标完成/) assert.match(view, /application\.status === 'CUSTOMER_CONFIRM_PENDING'[\s\S]*@click="confirmSelectedRelabel">确认换标完成/)
assert.match(view, /async function confirmSelectedRelabel\(\)[\s\S]*await confirmRelabelCompletion\(relabelNo\)[\s\S]*await openDetail\(\{ '0': relabelNo \}\)/) assert.match(view, /async function confirmSelectedRelabel\(\)[\s\S]*openCompletionConfirmation\(relabelNo, 'labeling'\)/)
assert.match(view, /async function refreshAfterCompletion\(no: string\)[\s\S]*await openDetail\(\{ '0': no \}\)/)
}) })
test('仓储转运换标绑定明细自动同步只读的旧 SKU', () => { test('仓储转运换标绑定明细自动同步只读的旧 SKU', () => {
@@ -104,7 +105,7 @@ test('仓储转运不要求客户填写内部入仓号和我方箱唛,但换
test('仓储转运货物明细可按 SKU 和箱数打印客户预贴箱唛', () => { test('仓储转运货物明细可按 SKU 和箱数打印客户预贴箱唛', () => {
assert.match(view, /printTransferDraftCartonMarks/) assert.match(view, /printTransferDraftCartonMarks/)
assert.match(view, /请先填写商品 SKU 和对应的整数箱数/) assert.match(view, /请先填写商品 SKU 和对应的整数箱数/)
assert.match(view, /Array\.from\(\{ length: Number\(row\.quantity\) \}/) assert.match(view, /Array\.from\(\{ length: Number\(row\.expectedPackageCount\) \}/)
assert.match(view, /row\.customerCartonMarks = marks\.join\('\\n'\)/) assert.match(view, /row\.customerCartonMarks = marks\.join\('\\n'\)/)
assert.match(view, /仓储转运客户箱唛/) assert.match(view, /仓储转运客户箱唛/)
assert.match(view, /@click="printTransferDraftCartonMarks"/) assert.match(view, /@click="printTransferDraftCartonMarks"/)
@@ -210,7 +211,7 @@ test('仓储转运列表展示换标单号、换标状态并为两类状态着
test('仓储转运详情订单概览使用详情接口命名字段回填', () => { test('仓储转运详情订单概览使用详情接口命名字段回填', () => {
assert.match(view, /v-else-if="props\.service === 'transfer'" class="warehouse-detail-grid warehouse-transfer-overview-grid"/) assert.match(view, /v-else-if="props\.service === 'transfer'" class="warehouse-detail-grid warehouse-transfer-overview-grid"/)
assert.doesNotMatch(view, /入库箱数/) assert.doesNotMatch(view, /入库箱数/)
assert.match(view, /'转运单号', '箱数', '海外仓'/) assert.match(view, /'转运单号', '预计箱数', '海外仓'/)
for (const field of ['transferNo', 'packageCount', 'operatingWarehouseId', 'deliveryMethod', 'needRelabel', 'relabelNo', 'status', 'createdAt']) { for (const field of ['transferNo', 'packageCount', 'operatingWarehouseId', 'deliveryMethod', 'needRelabel', 'relabelNo', 'status', 'createdAt']) {
assert.match(view, new RegExp(`selectedDetail\\?\\.${field}`)) assert.match(view, new RegExp(`selectedDetail\\?\\.${field}`))
} }
@@ -258,7 +259,8 @@ test('贴标申请详情使用紧凑订单概览并中文展示换标状态', ()
test('仓储转运详情按接口具名字段展示订单概览', () => { test('仓储转运详情按接口具名字段展示订单概览', () => {
const overview = view.split('<h3>订单概览</h3>')[1]?.split('warehouse-transfer-cargo-card')[0] || '' const overview = view.split('<h3>订单概览</h3>')[1]?.split('warehouse-transfer-cargo-card')[0] || ''
assert.match(overview, /v-if="props\.service === 'transfer'"/) assert.match(overview, /v-if="props\.service === 'transfer'"/)
assert.match(overview, /selectedDetail\?\.transferNo/) assert.doesNotMatch(overview, /<span>转运单号<\/span>/)
assert.match(view.split('<header class="warehouse-detail-header">')[1]?.split('</header>')[0] || '', /selectedDetail\?\.transferNo/)
assert.match(overview, /selectedDetail\?\.packageCount \?\? '—'/) assert.match(overview, /selectedDetail\?\.packageCount \?\? '—'/)
assert.match(overview, /warehouseDisplay\(selectedDetail\?\.operatingWarehouseId\)/) assert.match(overview, /warehouseDisplay\(selectedDetail\?\.operatingWarehouseId\)/)
assert.match(overview, /selectedDetail\?\.destinationWarehouseName \|\| warehouseDisplay\(selectedDetail\?\.destinationWarehouseCode \|\| selectedDetail\?\.fbaWarehouseCode\)/) assert.match(overview, /selectedDetail\?\.destinationWarehouseName \|\| warehouseDisplay\(selectedDetail\?\.destinationWarehouseCode \|\| selectedDetail\?\.fbaWarehouseCode\)/)
@@ -272,26 +274,26 @@ test('PDA 打托完成后客户可从列表或详情确认并查看实测数据'
assert.match(view, /confirmTransferFromList\(item\['0'\]\)/) assert.match(view, /confirmTransferFromList\(item\['0'\]\)/)
assert.match(view, /selectedDetail\?\.status === 'WAIT_CUSTOMER_CONFIRMATION'/) assert.match(view, /selectedDetail\?\.status === 'WAIT_CUSTOMER_CONFIRMATION'/)
assert.match(view, /PDA 打托实测数据/) assert.match(view, /PDA 打托实测数据/)
for (const label of ['托盘号', '库位', '箱数', '长 × 宽 × 高(cm)', '毛重(kg)', '体积(m³)', '完成时间']) assert.equal(view.includes(label), true) for (const label of ['托盘号', '库位', '箱数', 'SKU 汇总', '毛重(kg)', '体积(m³)', '完成时间']) assert.equal(view.includes(label), true)
assert.match(view, /pallet\.warehouseLocationCode/) assert.match(view, /pallet\.warehouseLocationCode/)
assert.match(view, /pallet\.cartonCount/) assert.match(view, /palletCartons\(pallet\)\.length/)
assert.match(view, /pallet\.grossWeightKg/) assert.match(view, /pallet\.grossWeightKg/)
assert.match(view, /pallet\.volumeCbm/) assert.match(view, /pallet\.volumeCbm/)
assert.doesNotMatch(view, /pallet\.items/) assert.doesNotMatch(view, /pallet\.items/)
}) })
test('仓储转运详情按订单概览、PDA 实测数据、货物明细排序并放大测量照片', () => { test('仓储转运详情按订单概览、货物预报、PDA 实测数据排序并放大测量照片', () => {
const detailStart = view.indexOf('<header class="warehouse-detail-header">') const detailStart = view.indexOf('<header class="warehouse-detail-header">')
const detailSource = view.slice(detailStart) const detailSource = view.slice(detailStart)
const overviewIndex = detailSource.indexOf('<h3>订单概览</h3>') const overviewIndex = detailSource.indexOf('<h3>订单概览</h3>')
const measurementIndex = detailSource.indexOf('<h3>PDA 打托实测数据</h3>') const measurementIndex = detailSource.indexOf('<h3>PDA 打托实测数据</h3>')
const cargoIndex = detailSource.indexOf('<h3>货物明细</h3>') const cargoIndex = detailSource.indexOf('<h3>预报与实际签收</h3>')
assert.ok(overviewIndex >= 0 && overviewIndex < measurementIndex) assert.ok(overviewIndex >= 0 && overviewIndex < measurementIndex)
assert.ok(measurementIndex < cargoIndex) assert.ok(cargoIndex < measurementIndex)
assert.match(view, /class="warehouse-photo-grid warehouse-measurement-photo-grid"/) assert.match(view, /class="warehouse-photo-grid warehouse-measurement-photo-grid"/)
assert.match(view, /class="warehouse-photo-item warehouse-measurement-photo-item"/) assert.match(view, /class="warehouse-photo-item warehouse-measurement-photo-item"/)
assert.match(view, /warehouse-measurement-photo-grid\{flex:1;grid-template-columns:repeat\(auto-fill,minmax\(240px,1fr\)\)\}/) assert.match(view, /warehouse-measurement-photo-grid\{flex:1;grid-template-columns:repeat\(auto-fill,minmax\(240px,1fr\)\)\}/)
assert.match(view, /warehouse-measurement-photo-item\{width:100%;height:180px\}/) assert.match(view, /warehouse-measurement-photo-item\{width:100%;height:120px\}/)
}) })
test('仓储转运详情自动加载 PDA 测量照片并释放 Blob URL', () => { test('仓储转运详情自动加载 PDA 测量照片并释放 Blob URL', () => {
@@ -367,3 +369,45 @@ test('仓储转运基础货物不强制 SKU 和中文品名,只换箱唛不强
assert.match(api, /skuCode\?: string; productNameZh\?: string/) assert.match(api, /skuCode\?: string; productNameZh\?: string/)
assert.match(api, /originalSku\?: string/) assert.match(api, /originalSku\?: string/)
}) })
test('SKU 件数和预计箱数独立提交,签收展示实际清点箱数', () => {
assert.match(view, /quantity: item.quantity, packageCount: Number\(item.expectedPackageCount\)/)
assert.match(view, /sum \+ Number\(item.expectedPackageCount \|\| 0\)/)
assert.match(view, /v-model="row.expectedPackageCount"/)
assert.match(view, /Number.isSafeInteger\(row.quantity\)/)
assert.match(view, /Number.isSafeInteger\(row.expectedPackageCount\)/)
assert.match(view, /carton.status === 'COUNTED'/)
assert.match(view, /实际签收箱数/)
})
test('转运客户详情保留预报并显示实际签收箱内明细和托盘汇总', () => {
for (const label of ['预报数量(件)', '预报 SKU 种数', '签收箱号明细', '查看箱号明细', 'SKU 汇总', '毛重(kg)', '体积(m³)']) assert.ok(view.includes(label), label)
assert.match(view, /carton.status === 'COUNTED' && pallet.id != null/)
assert.match(view, /String\(carton.transferPalletId\) === String\(pallet.id\)/)
assert.match(view, /carton.transferItemId != null && String\(item.id\) === String\(carton.transferItemId\)/)
assert.match(view, /@click="showTransferCartons = true"/)
assert.match(view, /<el-dialog v-model="showTransferCartons"[\s\S]*?<el-collapse-item v-for="carton in countedTransferCartons\(\)"/)
assert.doesNotMatch(view, /warehouse-transfer-receipt-summary|expandedTransferCartons/)
const receiptSection = view.slice(view.indexOf('warehouse-transfer-received-card', view.indexOf('<header class="warehouse-detail-header">')), view.indexOf('v-if="props.service === \'transfer\' && selectedDetail?.needRelabel"'))
assert.doesNotMatch(receiptSection.split('<el-dialog')[0], /<table|<el-table|<el-collapse/)
assert.match(view, /暂无实际签收箱,等待 PDA 清点/)
assert.doesNotMatch(view, /<td>\{\{ \[pallet.lengthCm/)
})
test('预报实收同区对照、托盘卡片换行、签收按钮紧邻标题', () => {
const start = view.indexOf('class="warehouse-card warehouse-form-section warehouse-transfer-cargo-card"', view.indexOf('<header class="warehouse-detail-header">'))
const section = view.slice(start, view.indexOf('warehouse-transfer-received-card', start))
assert.ok(section.includes('预报与实际签收'))
assert.ok(section.includes('PDA 打托实测数据'))
assert.ok(section.includes('receivedItemQuantity(item.id)'))
assert.ok(section.includes('receivedItemCartons(item.id).length'))
assert.ok(section.includes('class="warehouse-transfer-pallet-card"'))
assert.ok(section.includes('class="warehouse-transfer-pallet-sku"'))
assert.doesNotMatch(section, /打印箱唛/)
const receipt = view.slice(view.indexOf('warehouse-transfer-received-card', start)).split('<el-dialog')[0]
assert.ok(receipt.includes('打印箱唛'))
assert.ok(receipt.includes('查看箱号明细'))
assert.match(view, /warehouse-transfer-receipt-heading h3\{flex:0 0 auto/)
})
@@ -75,6 +75,7 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [
{ code: 'merchant:overseas-warehouse:transfer:confirm', label: '确认转运实测', description: '确认仓库回传的箱数、尺寸、重量和打托数据' }, { code: 'merchant:overseas-warehouse:transfer:confirm', label: '确认转运实测', description: '确认仓库回传的箱数、尺寸、重量和打托数据' },
{ code: 'merchant:overseas-warehouse:discard:list', label: '查看货物废弃', description: '查看本企业货物废弃申请列表和详情' }, { code: 'merchant:overseas-warehouse:discard:list', label: '查看货物废弃', description: '查看本企业货物废弃申请列表和详情' },
{ code: 'merchant:overseas-warehouse:discard:create', label: '创建货物废弃', description: '使用本企业可用库存创建废弃申请' }, { code: 'merchant:overseas-warehouse:discard:create', label: '创建货物废弃', description: '使用本企业可用库存创建废弃申请' },
{ code: 'merchant:overseas-warehouse:discard:confirm', label: '确认废弃完成', description: '核对本企业废弃现场照片并确认完结' },
{ code: 'merchant:overseas-warehouse:discard:cancel', label: '取消货物废弃', description: '取消本企业尚未审核的废弃申请' }, { code: 'merchant:overseas-warehouse:discard:cancel', label: '取消货物废弃', description: '取消本企业尚未审核的废弃申请' },
{ code: 'merchant:overseas-warehouse:return-order:list', label: '查看退货预报', description: '查看本企业退货预报和仓库签收结果' }, { code: 'merchant:overseas-warehouse:return-order:list', label: '查看退货预报', description: '查看本企业退货预报和仓库签收结果' },
{ code: 'merchant:overseas-warehouse:return-order:create', label: '创建退货预报', description: '创建本企业退货 SKU 和数量预报' }, { code: 'merchant:overseas-warehouse:return-order:create', label: '创建退货预报', description: '创建本企业退货 SKU 和数量预报' },
@@ -157,6 +158,7 @@ export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly<Record<string, read
'merchant:overseas-warehouse:transfer:confirm': ['merchant:overseas-warehouse:transfer:confirm'], 'merchant:overseas-warehouse:transfer:confirm': ['merchant:overseas-warehouse:transfer:confirm'],
'merchant:overseas-warehouse:discard:list': ['merchant:overseas-warehouse:discard:list'], 'merchant:overseas-warehouse:discard:list': ['merchant:overseas-warehouse:discard:list'],
'merchant:overseas-warehouse:discard:create': ['merchant:overseas-warehouse:discard:create'], 'merchant:overseas-warehouse:discard:create': ['merchant:overseas-warehouse:discard:create'],
'merchant:overseas-warehouse:discard:confirm': ['merchant:overseas-warehouse:discard:confirm'],
'merchant:overseas-warehouse:discard:cancel': ['merchant:overseas-warehouse:discard:cancel'], 'merchant:overseas-warehouse:discard:cancel': ['merchant:overseas-warehouse:discard:cancel'],
'merchant:overseas-warehouse:return-order:list': ['merchant:overseas-warehouse:return-order:list'], 'merchant:overseas-warehouse:return-order:list': ['merchant:overseas-warehouse:return-order:list'],
'merchant:overseas-warehouse:return-order:create': ['merchant:overseas-warehouse:return-order:create'], 'merchant:overseas-warehouse:return-order:create': ['merchant:overseas-warehouse:return-order:create'],