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 {
progress: MerchantOrderProgress
transportFragments: MerchantTransportFragment[]
summary: MerchantUnifiedOrderRow
order: MerchantFirstLegOrderDetail
@@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs'
import ts from 'typescript'
import * as vue from 'vue'
import * as pinia from 'pinia'
import { requireOrderProgress } from '../utils/orderProgress.ts'
function compile(path, dependencies) {
const source = readFileSync(new URL(path, import.meta.url), 'utf8')
@@ -31,10 +32,11 @@ function harness(t) {
vue, pinia,
'@/modules/first-leg/api/firstLeg': api,
'@/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/warehousePagination': {},
'@/modules/first-leg/utils/orderProgress': { requireOrderProgress },
})
const store = module.useFirstLegOrdersStore()
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 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', () => {
const mapper = storeSource.slice(storeSource.indexOf('function businessSummary('), storeSource.indexOf('\nconst unifiedActionLabels'))
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 { canClientEditFirstLegOrder, getFirstLegStatusLabel } from '@/modules/first-leg/utils/firstLegOrder'
import { loadAllWarehouseRecords } from '@/modules/first-leg/utils/warehousePagination'
import { requireOrderProgress } from '@/modules/first-leg/utils/orderProgress'
export type ClientWarehouseType = 'DOMESTIC' | 'FBA' | 'OVERSEAS'
export type ClientWarehouse = PlatformReceivingWarehouse & {
@@ -507,8 +508,12 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', ()
}
async function loadDetail(id: string) {
delete orderProgress.value[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) {
@@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs'
import ts from 'typescript'
import * as vue from 'vue'
import * as pinia from 'pinia'
import { requireOrderProgress } from '../utils/orderProgress.ts'
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
@@ -27,11 +28,12 @@ function harness(t, read) {
getMerchantOrderWorkbench: query => { calls.push(structuredClone(query)); return read(query) },
getMerchantUnifiedOrder: async 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/warehousePagination': { loadAllWarehouseRecords: async type => { warehouseCalls.push(type); return [] } },
'@/modules/first-leg/utils/orderProgress': { requireOrderProgress },
}
const 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(), '—')
})
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 ?? '—'
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, /summary\.preloadStatus/)
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, /订单主节点与关联柜次统一展示/)
assert.match(progressSource, /履约进度/)
@@ -60,6 +60,7 @@ import {
validateFirstLegOrderDraft,
} from '@/modules/first-leg/utils/firstLegOrder'
import { ORDER_QUEUES, orderProgressLabel } from '../utils/orderProgress'
import { orderBillingMetric } from '../utils/orderDetailSummary'
import type { OrderQueue } from '../api/unifiedOrders'
import { validateProductImageFile } from '@/modules/first-leg/utils/productImage'
import { createPriceEstimateBreakdown } from '@/modules/first-leg/utils/priceEstimate'
@@ -603,8 +604,10 @@ const pagedDetailCargoItems = computed(() => {
const progressSummaryItems = computed(() => {
const order = detailOrder.value
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 billingMetric = orderBillingMetric(billing, summary.chargeableWeightKg)
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 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.actualGrossWeightKg ?? inbound?.actualGrossWeightKg, 'kg') },
{ 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: summary.cabinetNo || summary.containerNo || '—' },
{ label: '船期摘要', value: transportReference || '待更新' },
{ label: '计费重量', value: formatMetric(summary.chargeableWeightKg, 'kg') },
{ label: '后续履约', value: summary.deliveryTrackingNo || (summary.deliveryCompletedAt ? `海外回传于 ${formatDateTime(summary.deliveryCompletedAt)}` : '待海外同步') },
{ label: billingMetric.label, value: formatMetric(billingMetric.quantity, billingMetric.unit) },
{ label: '后续履约', value: orderProgressLabel(store.orderProgress[order.id]) },
]
})
const cabinetProgressItems = computed(() => {
@@ -1,7 +1,7 @@
import { apiRequest, apiBlobRequest } from '@/api/http'
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 LastlegOrderSummary { id: string; orderNo: string; inboundCode: string; orderType: string; packageCount: number; overseasWarehouseId?: string }
@@ -23,10 +23,11 @@ export interface RelabelDetail {
remark?: string; items: RelabelItemDetail[]
history: Array<{ fromStatus?: string; toStatus?: string; operatorId?: string; remark?: string; createdAt: 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 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 DiscardSummary { id: string; discardNo: string; outboundNo?: string; inboundCodes: string; overseasWarehouseId: string; overseasWarehouseName: string; totalPackages: number; totalFee: number; currency: string; status: string; createdAt: string; completedAt?: string; customerConfirmedAt?: string }
export interface DiscardBox { cartonId: string; cartonMark: string; palletNo?: string; skus: Array<{ skuCode: string; productName: string; availablePackages: number }> }
export interface DiscardCandidate { cartons?: DiscardBox[]; cartonSelectionSupported?: boolean; cartonSelectionBlockReason?: string; orderId: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON' | 'INVENTORY'; inventoryPositionId?: string; palletNo?: string; returnCartonId?: string; cartonMark?: string; inboundCode: string; inboundOrderNo: string; overseasWarehouseId: string; overseasWarehouseName: string; availablePackages: number; skus: Array<{ skuCode: string; productName: string; availablePackages: number }> }
export interface RelabelInventoryCandidate {
cartonSkuItems: Array<{ skuCode: string; quantity: number }>; cartonAvailablePackages: number | string
skuConversions?: import('./returnInventory').ReturnSkuConversion[]
@@ -47,7 +48,7 @@ export interface TransferCreate {
deliveryAddress: string; packageCount: number; volumeCbm: number; deliveryMethod: string; estimatedArrivalAt: string; estimatedShipAt?: 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
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 }>
}
export interface RelabelCreate {
@@ -127,12 +128,15 @@ export function getDiscard(discardNo: string) {
export function listDiscardCandidates(inboundCode?: string) {
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 })
}
export function cancelDiscard(discardNo: string) {
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 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 { readFileSync } from 'node:fs'
import test from 'node:test'
import ts from 'typescript'
const routes = readFileSync(new URL('./routes.ts', 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', () => {
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.
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, /申请已创建,附件待补传/)
})
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', () => {
for (const permission of [
'merchant:overseas-warehouse:relabel:create',
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import ts from 'typescript'
const view = readFileSync(new URL('./ReturnOrdersView.vue', import.meta.url), 'utf8')
const api = readFileSync(new URL('../api/returnOrders.ts', import.meta.url), 'utf8')
@@ -254,7 +255,25 @@ test('待审核退货可编辑,完成入库后从独立新增页发起贴标',
assert.match(view, /name: 'overseas-labeling-create'/)
assert.match(view, /query: \{ returnNo: order\.returnNo, warehouseId: String\(order\.overseasWarehouseId\)/)
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, /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">
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 { ArrowLeft, ChevronDown, Plus } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
@@ -280,6 +280,11 @@ onMounted(() => {
else if (props.detailPage) void Promise.all([loadDetail(), 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]) => {
if (nextSize !== previousSize && nextPage !== 1) { query.page = 1; return }
if (!props.createPage && !props.detailPage) void load()
@@ -443,24 +448,6 @@ onBeforeUnmount(clearPhotoUrls)
<template #footer><el-button @click="showCartons = false">关闭</el-button></template>
</el-dialog>
</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">
<h3>退货打托照片({{ selected.photos?.length || 0 }} 张)</h3>
<div v-if="selected.photos?.length" class="return-photo-grid">
@@ -1,19 +1,20 @@
<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 { 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 DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import type { TableAction } from '@/components/common/tableActions'
import { useAuthStore } from '@/modules/auth/public'
import CompletionConfirmationDialog from '../components/CompletionConfirmationDialog.vue'
import { returnRelabelCandidate } from '../utils/returnRelabelCandidates'
import {
createDiscard, createRelabel, createTransfer, getDiscard, getRelabel, getTransfer, resubmitRelabel,
getTransferCartonLabel, submitTransfer, updateTransfer,
confirmRelabelCompletion, confirmTransferMeasurement, downloadRelabelCompletionPhoto, downloadTransferMeasurementPhoto, downloadDiscardPhoto, type DiscardPhoto, uploadRelabelShippingLabel,
confirmTransferMeasurement, downloadRelabelCompletionPhoto, downloadTransferMeasurementPhoto, downloadDiscardPhoto, type DiscardPhoto, uploadRelabelShippingLabel,
uploadRelabelFile,
listDiscards, listRelabelInventoryCandidates, listRelabelHistoricalOrders, listRelabels, listTransfers, cancelDiscard,
listDiscards, listDiscardCandidates, listRelabelInventoryCandidates, listRelabelHistoricalOrders, listRelabels, listTransfers, cancelDiscard,
type RelabelInventoryCandidate,
type RelabelCreate,
type RelabelDetail,
@@ -33,11 +34,11 @@ type ServiceType = 'labeling' | 'transfer' | 'disposal' | 'relabel'
type PageMode = 'list' | 'create' | 'edit' | 'detail'
type LabelingMode = 'carton' | 'sku' | 'carton_and_sku'
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 ImportedRelabelBinding extends SkuRelabelRow { templateType: RelabelTemplateType; selected: boolean; shipmentNo: 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 auth = useAuthStore()
@@ -53,10 +54,22 @@ const canCreate = computed(() => auth.hasPermission(({
const canManageRelabelFiles = computed(() => auth.hasPermission('merchant:overseas-warehouse:relabel:files'))
const canConfirmRelabel = computed(() => auth.hasPermission('merchant:overseas-warehouse:relabel: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 configs: Record<ServiceType, ServiceConfig> = {
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 汇总', '废弃数量', '预估费用', '状态', '废弃完成时间', '创建时间'] },
relabel: { title: '退货换标', createTitle: '新建退货换标单', numberLabel: '退货换标单号', emptyText: '暂无退货换标订单', columns: ['操作', '退货换标单号', '来源 FBA 仓', '目的 FBA 仓', 'SKU 汇总', '退件总数', '是否换标', '换标状态', '尾程状态', '审核时间', '测量时间', '出库时间', '创建时间'] },
}
@@ -81,7 +94,7 @@ const listQueryModel = computed<QueryModel>({
const statusOptions = computed(() => props.service === 'labeling'
? [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '换标完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }]
: 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'
? [{ label: '草稿', value: 'DRAFT' }, { label: '待审核', value: 'PENDING_REVIEW' },
{ 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
relabelStatus?: string; relabelFulfillmentStatus?: string; createdAt?: string
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 }>
cartons?: TransferCarton[]; pallets?: TransferPallet[]; measurementPhotos?: TransferMeasurementPhoto[]; customerConfirmedAt?: string
}
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 selectedRelabelDetail = ref<RelabelDetail | null>(null)
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,
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 }))
const discardSelectedBoxKeys = ref<string[]>([])
const discardBoxSearch = ref('')
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 discardCandidatesLoading = ref(false)
const relabelCandidates = ref<RelabelInventoryCandidate[]>([])
@@ -340,6 +422,11 @@ onMounted(() => {
else void loadPage()
void nextTick(refreshWarehouseMarkLabels)
})
let activatedOnce = false
onActivated(() => {
if (activatedOnce && !props.createPage && !props.detailPage && mode.value === 'list') void loadPage()
activatedOnce = true
})
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 resetForm() {
@@ -399,6 +486,14 @@ function returnToList() {
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]
}
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) {
try {
@@ -422,7 +517,7 @@ async function openEdit(transferNo: string) {
transferDestinationType.value = detail.destinationType || (detail.fbaWarehouseCode ? 'FBA' : 'NONE')
cargoRows.value = (detail.items || []).map((item) => ({
...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 || '',
}))
const bindings = detail.relabelBindings || []
@@ -459,6 +554,8 @@ async function openEdit(transferNo: string) {
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '草稿加载失败') }
}
async function selectDiscardWarehouse() {
discardSelectedBoxKeys.value = []
discardBoxSearch.value = ''
selectedDiscardOrderId.value = ''
form.inboundNo = ''
discardCandidates.value = []
@@ -467,60 +564,36 @@ async function selectDiscardWarehouse() {
if (!form.warehouse) return
discardCandidatesLoading.value = true
try {
const inventory = await listRelabelInventoryCandidates(form.warehouse)
const orderByInbound = new Map<string, string>()
const inboundCodes = [...new Set(inventory.map((candidate) => String(candidate.inboundCode || '')).filter(Boolean))]
const historicalGroups = await Promise.all(inboundCodes.map(async (inboundCode) => ({ inboundCode, orders: await listRelabelHistoricalOrders(form.warehouse, inboundCode) })))
for (const { inboundCode, orders } of historicalGroups) {
for (const order of orders) {
if (order.id) orderByInbound.set(inboundCode, String(order.id))
}
}
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)
const warehouse = form.warehouse
const candidates = await listDiscardCandidates()
if (form.warehouse !== warehouse) return
discardCandidates.value = candidates
.filter((candidate) => String(candidate.overseasWarehouseId) === warehouse)
.filter((candidate) => candidate.sourceType === 'DROPSHIP' ? Boolean(candidate.orderId) : candidate.sourceType === 'RETURN_CARTON' ? Boolean(candidate.returnCartonId) : candidate.sourceType === 'INVENTORY' && Boolean(candidate.inventoryPositionId))
.map((candidate) => ({ ...candidate, orderId: candidate.sourceType === 'RETURN_CARTON' ? `return:${candidate.returnCartonId}` : candidate.sourceType === 'INVENTORY' ? `inventory:${candidate.inventoryPositionId}` : String(candidate.orderId) }))
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '可废弃入库单加载失败') }
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 escapePrintHtml(value: string) {
return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;')
}
function printTransferDraftCartonMarks() {
if (props.service !== 'transfer') return
const validRows = cargoRows.value.filter((row) => row.sku.trim() && Number(row.quantity) > 0)
if (!validRows.length || validRows.some((row) => !Number.isInteger(Number(row.quantity)))) {
const validRows = cargoRows.value.filter((row) => row.sku.trim() && Number(row.expectedPackageCount) > 0)
if (!validRows.length || validRows.some((row) => !Number.isInteger(Number(row.expectedPackageCount)))) {
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 }
const batch = new Date().toISOString().replace(/\D/g, '').slice(2, 14)
const labels: Array<{ mark: string; sku: string; index: number }> = []
let cartonIndex = 0
for (const [rowIndex, row] of validRows.entries()) {
let marks = transferCustomerMarks(row)
if (marks.length !== Number(row.quantity)) {
marks = Array.from({ length: Number(row.quantity) }, (_, index) => `CT-${batch}-${String(rowIndex + 1).padStart(2, '0')}-${String(index + 1).padStart(3, '0')}`)
if (marks.length !== Number(row.expectedPackageCount)) {
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')
}
for (const mark of marks) labels.push({ mark, sku: row.sku.trim(), index: ++cartonIndex })
@@ -677,7 +750,7 @@ function downloadTransferCartonMarkTemplate() {
ElMessage.warning('请先填写完整的 SKU 明细'); return
}
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 url = URL.createObjectURL(blob); const anchor = document.createElement('a')
anchor.href = url; anchor.download = '仓储转运-客户箱唛模板.csv'; anchor.click(); URL.revokeObjectURL(url)
@@ -865,14 +938,17 @@ function importSelectedBindingsToDetails() {
ElMessage.success(`已将 ${selected.length} 条绑定关系导入本次换标明细`)
}
function selectDiscardInbound(orderId: string) {
discardSelectedBoxKeys.value = []
discardBoxSearch.value = ''
const candidate = discardCandidates.value.find((item) => item.orderId === orderId)
if (!candidate) { cargoRows.value = []; return }
form.inboundNo = candidate.inboundCode || candidate.inboundOrderNo
form.warehouse = candidate.overseasWarehouseId
if (candidate.cartonSelectionSupported) { selectAllDiscardBoxes(true); return }
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,
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: '',
}))
}
@@ -910,7 +986,7 @@ function statusLabel(value: string) {
PENDING_PROCESSING: '待处理', PROCESSING: '处理中', CUSTOMER_CONFIRM_PENDING: '待客户确认',
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: '待出库',
ALLOCATED: '已分配', OUTBOUNDED: '已出库', PARTIALLY_RECEIVED: '部分收货', RECEIVED: '已收货', INBOUND_COMPLETED: '入库完成', PENDING_DELIVERY: '待派送', DELIVERING: '派送中', DELIVERED: '派送完成',
@@ -981,7 +1057,7 @@ function transferRelabelStatusTagType(status: string): StatusTagType {
}
function deliveryMethodLabel(value: string) {
const labels: Record<string, string> = {
TRUCK: '卡车派送', EXPRESS: '快递派送', TRUCK_PICKUP: '卡车自提', EXPRESS_PICKUP: '快递自提',
TRUCK: '卡车派送', EXPRESS: '快递派送', AMAZON_PICKUP: '亚马逊自提', CUSTOMER_PICKUP: '客户自提',
WAREHOUSE_PICKUP: '仓库上门提货', CUSTOMER_DELIVERY: '客户送货到仓',
// Keep the legacy value readable for historical applications created before the four-way split.
PICKUP: '仓库自提',
@@ -1009,7 +1085,7 @@ async function load() {
} 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) })
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 {
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
@@ -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 (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 (canConfirmDiscard.value && props.service === 'disposal' && item.__status === 'PDA_COMPLETED') actions.push({ key: 'confirm-discard', label: '废弃确认', run: () => openCompletionConfirmation(item['0'], 'disposal') })
return actions
}
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'
? skuRelabelRows.value.some((item) => !item.cartonMark || !item.originalSku || !item.newSku || item.quantity < 1
|| (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))))
if ((props.service === 'relabel' && !form.inboundNo) || !primaryWarehouse || invalidCargo) {
ElMessage.warning('请填写必填信息和完整的货物明细'); return
@@ -1067,7 +1144,7 @@ async function submit(action: 'draft' | 'submit' = 'submit') {
if (props.service === 'labeling' && labelingMode.value !== 'sku' && !form.destination) {
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) {
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)) {
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)))) {
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('、')
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 && '预计入仓时间'].filter(Boolean).join('、')
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) {
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'
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,
deliveryMethod: labelingMode.value === 'sku' ? undefined : form.deliveryMethod as 'TRUCK' | 'EXPRESS' | 'TRUCK_PICKUP' | 'EXPRESS_PICKUP',
printShippingLabel: labelingMode.value !== 'sku' && (form.deliveryMethod === 'EXPRESS' || form.deliveryMethod === 'EXPRESS_PICKUP') && form.relabel })
deliveryMethod: labelingMode.value === 'sku' ? undefined : form.deliveryMethod as 'TRUCK' | 'EXPRESS' | 'AMAZON_PICKUP' | 'CUSTOMER_PICKUP',
printShippingLabel: labelingMode.value !== 'sku' && form.deliveryMethod === 'EXPRESS' && form.relabel })
try {
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 (shippingLabelFile.value) await uploadRelabelShippingLabel(created.application.relabelNo, shippingLabelFile.value)
} catch (error) {
// Creation already committed: return to the list even when an attachment still needs retrying.
if (props.createPage) await router.push({ name: serviceListRouteName(props.service) })
else { mode.value = 'list'; await load(); await openDetail({ '0': created.application.relabelNo }) }
await goToServiceList()
await ElMessageBox.alert(`申请 ${created.application.relabelNo} 已创建,部分附件上传失败:${error instanceof Error ? error.message : '上传失败'}。请在原单详情补传失败附件,不要重复下单。`, '申请已创建,附件待补传', { confirmButtonText: '知道了' })
return
}
ElMessage.success(`${config.value.title}提交成功`)
await router.push({ name: serviceListRouteName(props.service) })
await goToServiceList()
return
} else if (props.service === 'disposal') {
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} 的可废弃库存不足`)
}
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,
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 })
} 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 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 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) {
;(payload as TransferCreate).inboundCode = selectedTransferInventory.value
;(payload as TransferCreate).sourceLastlegOrderIds = selectedTransferSourceOrderIds.value
@@ -1168,7 +1249,7 @@ async function submit(action: 'draft' | 'submit' = 'submit') {
if (!relabelNo) {
ElMessage.success('转运单已提交审核;PDA 入库确认后将自动生成关联换标单')
await ElMessageBox.alert(`转运单 ${transferNo} 已提交。关联换标单会在 PDA 完成入库、客户确认实测库存后自动生成;届时请进入关联换标单详情上传贴标总文件。`, '转运单已提交', { confirmButtonText: '知道了' })
await router.push({ name: serviceListRouteName('transfer') })
await goToServiceList()
return
}
try {
@@ -1176,17 +1257,17 @@ async function submit(action: 'draft' | 'submit' = 'submit') {
if (skuTotalFile.value) await uploadRelabelFile(relabelNo, 'SKU_TOTAL', skuTotalFile.value)
} catch (error) {
await ElMessageBox.alert(`转运单 ${transferNo} 已提交,但换标总文件上传失败:${error instanceof Error ? error.message : '上传失败'}。请进入关联换标单详情补传,不要重复提交转运单。`, '转运单已提交,附件待补传', { confirmButtonText: '知道了' })
await openDetail({ '0': transferNo })
await goToServiceList()
return
}
}
ElMessage.success('转运单已提交审核')
await router.push({ name: serviceListRouteName('transfer') })
await goToServiceList()
return
}
}
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 : '提交失败') }
finally { saving.value = false }
}
@@ -1200,6 +1281,7 @@ async function openDetail(value: Record<string, string>) {
try {
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
showTransferCartons.value = false
selectedDetail.value = { ...value, ...(detail as ServiceDetail), ...(discardSummary || {}) }
selectedRelabelDetail.value = props.service === 'labeling' ? detail as RelabelDetail : null
mode.value = 'detail'
@@ -1284,12 +1366,7 @@ async function loadMeasurementPhotos() {
await Promise.all((selectedDetail.value?.measurementPhotos || []).map((photo) => loadMeasurementPhoto(photo.fileId)))
}
async function confirmRelabelFromList(relabelNo: string) {
try {
await ElMessageBox.confirm('确认仓库上传的换标结果无误并继续出库流程吗?', '换标完成确认', { confirmButtonText: '确认并继续', cancelButtonText: '取消' })
await confirmRelabelCompletion(relabelNo)
ElMessage.success('已确认换标完成,订单已进入后续出库流程')
await load()
} catch (error) { if (error !== 'cancel') ElMessage.error(error instanceof Error ? error.message : '确认失败') }
openCompletionConfirmation(relabelNo, 'labeling')
}
async function cancelSelectedDiscard() {
const no = String(selectedDetail.value?.discardNo || selectedDetail.value?.['0'] || '')
@@ -1363,13 +1440,7 @@ async function selectTransferWarehouse() {
}
async function confirmSelectedRelabel() {
const relabelNo = selectedRelabelDetail.value?.application.relabelNo
if (!relabelNo) return
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 : '确认失败') }
if (relabelNo) openCompletionConfirmation(relabelNo, 'labeling')
}
async function resubmitRejectedRelabel() {
const application = selectedRelabelDetail.value?.application
@@ -1385,7 +1456,7 @@ async function resubmitRejectedRelabel() {
idempotencyKey: createIdempotencyKey(),
})
ElMessage.success('贴标单已重新提交审核')
await router.push({ name: serviceListRouteName('labeling') })
await goToServiceList()
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '重新提交失败')
}
@@ -1501,12 +1572,12 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
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>
<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'">
<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">
@@ -1536,7 +1607,7 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
</div>
<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="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>来源 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>
@@ -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.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' && 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>
<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>
<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="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'" 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.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 === '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'"><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>
@@ -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>
</template>
<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>
<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>
<section v-if="props.service === 'disposal' && discardSelectedCandidate" class="warehouse-discard-boxes">
<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>
</div>
<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-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-transfer-file-groups warehouse-file-groups--compact">
<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">
<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-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>
<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 || '平台未填写驳回备注'" />
<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">
<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>
</div>
<div v-if="props.service === 'transfer'" class="warehouse-card warehouse-form-section warehouse-transfer-measurement-card">
<div class="warehouse-section-heading"><h3>PDA 打托实测数据</h3><span class="warehouse-hint">PDA 作业完成即显示入库完成,客户可确认仓库实测数据</span></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 v-if="props.service === 'transfer'" class="warehouse-card warehouse-form-section warehouse-transfer-cargo-card">
<div class="warehouse-section-heading"><h3>预报与实际签收</h3></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>
</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>
<div v-if="props.service === 'transfer'" class="warehouse-card warehouse-form-section warehouse-transfer-cargo-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-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="props.service === 'transfer'" class="warehouse-card warehouse-form-section warehouse-transfer-received-card">
<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 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 v-if="props.service === 'transfer' && selectedDetail?.needRelabel" class="warehouse-card warehouse-form-section">
<h3>换标箱级去向</h3>
@@ -1725,6 +1839,7 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
</div>
</template>
</section>
<CompletionConfirmationDialog v-model="completionConfirmOpen" :kind="completionConfirmKind" :document-no="completionConfirmNo" @confirmed="refreshAfterCompletion" />
</template>
<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-measurement-evidence{margin-top:16px}
.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-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}
@@ -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-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-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}}
/* Keep the count and controls visible together on narrow list pages. */
.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-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>
@@ -129,9 +129,9 @@ test('贴标货物明细通过单一换标类型选择三种模式', () => {
test('箱唛相关换标必须选择四选一派送方式', () => {
assert.match(view, /label="卡车派送" value="TRUCK"/)
assert.match(view, /label="快递派送" value="EXPRESS"/)
assert.match(view, /label="卡车自提" value="TRUCK_PICKUP"/)
assert.match(view, /label="快递自提" value="EXPRESS_PICKUP"/)
assert.match(api, /RelabelDeliveryMethod = 'TRUCK' \| 'EXPRESS' \| 'TRUCK_PICKUP' \| 'EXPRESS_PICKUP'/)
assert.match(view, /label="亚马逊自提" value="AMAZON_PICKUP"/)
assert.match(view, /label="客户自提" value="CUSTOMER_PICKUP"/)
assert.match(api, /RelabelDeliveryMethod = 'TRUCK' \| 'EXPRESS' \| 'AMAZON_PICKUP' \| 'CUSTOMER_PICKUP'/)
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 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
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 = {}
new Function('exports', compiled)(exports)
test('合箱来源按单号集合精确匹配并去重,不能用子串匹配', () => {
@@ -7,6 +7,43 @@ const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url)
test('货物废弃先选择海外操作仓,再加载并选择该仓入库单', () => {
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, /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/)
})
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('换标详情待客户确认时提供确认入口并刷新详情', () => {
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', () => {
@@ -104,7 +105,7 @@ test('仓储转运不要求客户填写内部入仓号和我方箱唛,但换
test('仓储转运货物明细可按 SKU 和箱数打印客户预贴箱唛', () => {
assert.match(view, /printTransferDraftCartonMarks/)
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, /仓储转运客户箱唛/)
assert.match(view, /@click="printTransferDraftCartonMarks"/)
@@ -210,7 +211,7 @@ test('仓储转运列表展示换标单号、换标状态并为两类状态着
test('仓储转运详情订单概览使用详情接口命名字段回填', () => {
assert.match(view, /v-else-if="props\.service === 'transfer'" class="warehouse-detail-grid warehouse-transfer-overview-grid"/)
assert.doesNotMatch(view, /入库箱数/)
assert.match(view, /'转运单号', '箱数', '海外仓'/)
assert.match(view, /'转运单号', '预计箱数', '海外仓'/)
for (const field of ['transferNo', 'packageCount', 'operatingWarehouseId', 'deliveryMethod', 'needRelabel', 'relabelNo', 'status', 'createdAt']) {
assert.match(view, new RegExp(`selectedDetail\\?\\.${field}`))
}
@@ -258,7 +259,8 @@ test('贴标申请详情使用紧凑订单概览并中文展示换标状态', ()
test('仓储转运详情按接口具名字段展示订单概览', () => {
const overview = view.split('<h3>订单概览</h3>')[1]?.split('warehouse-transfer-cargo-card')[0] || ''
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, /warehouseDisplay\(selectedDetail\?\.operatingWarehouseId\)/)
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, /selectedDetail\?\.status === 'WAIT_CUSTOMER_CONFIRMATION'/)
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\.cartonCount/)
assert.match(view, /palletCartons\(pallet\)\.length/)
assert.match(view, /pallet\.grossWeightKg/)
assert.match(view, /pallet\.volumeCbm/)
assert.doesNotMatch(view, /pallet\.items/)
})
test('仓储转运详情按订单概览、PDA 实测数据、货物明细排序并放大测量照片', () => {
test('仓储转运详情按订单概览、货物预报、PDA 实测数据排序并放大测量照片', () => {
const detailStart = view.indexOf('<header class="warehouse-detail-header">')
const detailSource = view.slice(detailStart)
const overviewIndex = detailSource.indexOf('<h3>订单概览</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(measurementIndex < cargoIndex)
assert.ok(cargoIndex < measurementIndex)
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, /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', () => {
@@ -367,3 +369,45 @@ test('仓储转运基础货物不强制 SKU 和中文品名,只换箱唛不强
assert.match(api, /skuCode\?: string; productNameZh\?: 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:discard:list', 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:return-order:list', label: '查看退货预报', description: '查看本企业退货预报和仓库签收结果' },
{ 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:discard:list': ['merchant:overseas-warehouse:discard:list'],
'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:return-order:list': ['merchant:overseas-warehouse:return-order:list'],
'merchant:overseas-warehouse:return-order:create': ['merchant:overseas-warehouse:return-order:create'],