This commit is contained in:
2026-09-17 13:36:20 +08:00
parent 9d6fde286b
commit 8024fd4717
6 changed files with 390 additions and 94 deletions
@@ -55,9 +55,10 @@ test('preserves overseas API diagnostic metadata without rendering it as a busin
test('renders inbound workflow statuses in Chinese and keeps desktop values on one line', () => {
for (const [status, label] of [
['PENDING_ASSIGNMENT', '待分配'], ['PDA_PENDING', '待收货'], ['ASSIGNED', '已分配'],
['PENDING_ASSIGNMENT', '待分配'], ['PDA_PENDING', '待入库'], ['ASSIGNED', '已分配'],
['SIGNED', '已签收'], ['PICKING', '拣货中'], ['PARTIAL_INBOUND', '部分入库'],
]) assert.match(view, new RegExp(`${status}: '${label}'`))
assert.match(view, /COMPLETED: '全部入库'/)
assert.match(view, /label="状态" width="120" show-overflow-tooltip/)
assert.match(view, /\.detail-grid div \{ min-width: 0;/)
assert.match(view, /\.detail-grid dd \{[^}]*overflow: hidden;[^}]*text-overflow: ellipsis;[^}]*white-space: nowrap;/)
@@ -12,11 +12,11 @@ const total = ref(0)
const inboundStatusOptions = [
{ label: '待分配', value: 'PENDING_ASSIGNMENT' },
{ label: '已分配', value: 'ASSIGNED' },
{ label: '待收货', value: 'PDA_PENDING' },
{ label: '待入库', value: 'PDA_PENDING' },
{ label: '已签收', value: 'SIGNED' },
{ label: '拣货中', value: 'PICKING' },
{ label: '部分入库', value: 'PARTIAL_INBOUND' },
{ label: '已完成', value: 'COMPLETED' },
{ label: '全部入库', value: 'COMPLETED' },
]
const query = reactive({
page: 1, size: 20, planNo: '', inboundCode: '', destinationWarehouseCode: '', status: '',
@@ -33,9 +33,9 @@ function destinationWarehouseLabel(...values: unknown[]) {
function dateTime(value: unknown) { return value ? new Date(String(value)).toLocaleString('zh-CN', { hour12: false }) : '—' }
function statusLabel(value: unknown) {
return ({
PENDING: '待处理', PROCESSING: '处理中', PENDING_ASSIGNMENT: '待分配', PDA_PENDING: '待收货',
PENDING: '待处理', PROCESSING: '处理中', PENDING_ASSIGNMENT: '待分配', PDA_PENDING: '待入库',
ASSIGNED: '已分配', SIGNED: '已签收', PICKING: '拣货中', PARTIAL_INBOUND: '部分入库',
COMPLETED: '已完成', CANCELLED: '已取消', UNAPPOINTED: '未预约', SUCCESS: '预约成功',
COMPLETED: '全部入库', CANCELLED: '已取消', UNAPPOINTED: '未预约', SUCCESS: '预约成功',
FAILED: '预约失败', SCHEDULED: '已排车', UNSCHEDULED: '未排车',
} as Record<string, string>)[String(value)] || text(value)
}
@@ -34,12 +34,14 @@ export interface RelabelInventoryCandidate {
}
export interface TransferCreate {
operatingWarehouseId: string; fbaWarehouseCode?: string
operatingWarehouseId: string; fbaWarehouseCode?: string; destinationType?: 'NONE' | 'FBA' | 'PRIVATE'
destinationWarehouseId?: string; destinationWarehouseCode?: string; destinationWarehouseName?: string
privateDestinationAddress?: string; privateDestinationPostalCode?: string; privateDestinationContactName?: string; privateDestinationContactPhone?: string
deliveryAddress: string; packageCount: number; volumeCbm: number; deliveryMethod: string; estimatedArrivalAt: string; estimatedShipAt?: string
fbaShipmentNo?: string; trackingNo?: string; fbaBoxNos?: string; remark?: string; needRelabel: boolean; idempotencyKey: string
labelDeadline?: string; relabelContactName?: string; relabelContactPhone?: string; relabelChargeAmount?: number; relabelDeliveryMethod?: RelabelDeliveryMethod
items: Array<{ skuCode: string; productNameZh: string; 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 }>
labelDeadline?: string; pickupContactName?: string; pickupContactPhone?: string; relabelContactName?: string; relabelContactPhone?: string; relabelChargeAmount?: number; relabelDeliveryMethod?: RelabelDeliveryMethod
items: Array<{ skuCode?: string; productNameZh?: string; 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 {
customerOrderNo: string; operationWarehouseId: string; labelDeadline: string; contactName: string; contactPhone: string; chargeAmount?: number
@@ -60,6 +62,12 @@ export function getTransfer(transferNo: string) {
export function createTransfer(payload: TransferCreate) {
return overseasApiRequest<Record<string, unknown>>({ method: 'POST', url: '/merchant/v1/last-leg/warehouse-transfers', data: payload })
}
export function updateTransfer(transferNo: string, payload: TransferCreate) {
return overseasApiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/save-draft`, data: payload })
}
export function submitTransfer(transferNo: string) {
return overseasApiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/submit` })
}
export function listRelabels(params: Record<string, unknown>) {
return overseasApiRequest<PageResult<RelabelSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/relabel-applications', params })
}
@@ -73,6 +81,9 @@ export interface TransferCartonLabel { transferNo: string; cartons: Array<{ cart
export function getTransferCartonLabel(transferNo: string) {
return overseasApiRequest<TransferCartonLabel>({ method: 'GET', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/carton-label` })
}
export function downloadTransferMeasurementPhoto(transferNo: string, fileId: string | number) {
return overseasBlobRequest({ method: 'GET', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/measurement-photos/files/${encodeURIComponent(fileId)}/download` })
}
export function listRelabelInventoryCandidates(operationWarehouseId: string, inboundCode?: string) {
return overseasApiRequest<RelabelInventoryCandidate[]>({
@@ -1,17 +1,18 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Download, Eye, Plus, Printer, Search, Trash2, Upload } from '@lucide/vue'
import { ArrowLeft, Download, Eye, Plus, Printer, Search, Trash2, Upload, X } from '@lucide/vue'
import SingleLineText from '@/components/common/SingleLineText.vue'
import {
createDiscard, createRelabel, createTransfer, getDiscard, getRelabel, getTransfer,
getTransferCartonLabel,
confirmRelabelCompletion, confirmTransferMeasurement, downloadRelabelCompletionPhoto, uploadRelabelShippingLabel,
getTransferCartonLabel, submitTransfer, updateTransfer,
confirmRelabelCompletion, confirmTransferMeasurement, downloadRelabelCompletionPhoto, downloadTransferMeasurementPhoto, uploadRelabelShippingLabel,
uploadRelabelFile,
listDiscardCandidates, listDiscards, listRelabelInventoryCandidates, listRelabels, listTransfers, cancelDiscard,
type RelabelInventoryCandidate,
type RelabelCreate,
type RelabelDetail,
type TransferCreate,
} from '../api/warehouseServices'
import {
getMerchantWarehouseDetail,
@@ -22,7 +23,7 @@ import {
import { loadAllWarehouseRecords } from '@/modules/first-leg/utils/warehousePagination'
type ServiceType = 'labeling' | 'transfer' | 'disposal' | 'relabel'
type PageMode = 'list' | 'create' | 'detail'
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; sourceInventoryBalanceId?: string; sourceInventoryPositionId?: string; sourceInboundOrderId?: 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 }
@@ -43,11 +44,19 @@ const statusOptions = computed(() => props.service === 'labeling'
? [{ label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '换标完成', value: 'COMPLETED' }]
: 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: '已通过', value: 'APPROVED' }, { label: '待分配', value: 'PENDING_ASSIGNMENT' }, { label: '已分配', value: 'ALLOCATED' }, { label: '已出库', value: 'OUTBOUNDED' }, { label: '部分收货', value: 'PARTIALLY_RECEIVED' }, { label: '已收货', value: 'RECEIVED' }, { label: '已拒绝', value: 'REJECTED' }])
: props.service === 'transfer'
? [{ label: '草稿', value: 'DRAFT' }, { label: '待审核', value: 'PENDING_REVIEW' },
{ label: '待PDA收货', value: 'WAIT_PDA_RECEIVING' }, { label: '入库测量中', value: 'INBOUND_MEASURING' },
{ label: '待客户确认实测', value: 'WAIT_CUSTOMER_CONFIRMATION' }, { label: '待换标', value: 'WAIT_RELABEL' },
{ label: '待确认目的地', value: 'WAIT_FBA_DESTINATION' }, { label: '待排车', value: 'READY_FOR_DISPATCH' },
{ label: '待出库', value: 'WAIT_OUTBOUND' }, { label: '已出库', value: 'OUTBOUNDED' },
{ label: '部分收货', value: 'PARTIALLY_RECEIVED' }, { label: '已收货', value: 'RECEIVED' },
{ label: '审核驳回', value: 'REJECTED' }]
: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已通过', value: 'APPROVED' }, { label: '待分配', value: 'PENDING_ASSIGNMENT' }, { label: '已分配', value: 'ALLOCATED' }, { label: '已出库', value: 'OUTBOUNDED' }, { label: '部分收货', value: 'PARTIALLY_RECEIVED' }, { label: '已收货', value: 'RECEIVED' }, { label: '已拒绝', value: 'REJECTED' }])
const mode = ref<PageMode>('list')
const filters = reactive({ number: '', inboundNo: '', warehouse: '', destination: '', status: '', start: '', end: '' })
const form = reactive({ customerNo: '', inboundNo: '', sourceWarehouse: '', warehouse: '', destination: '', address: '', contact: '', phone: '', deliveryMethod: '', relabelDeliveryMethod: '', trackingNo: '', reason: '', requirement: '', relabel: false, remark: '', labelDeadline: '', estimatedArrivalAt: '', estimatedShipAt: '', fbaShipmentNo: '', volumeCbm: 0.01 })
const transferHasFbaDestination = ref(false)
const form = reactive({ customerNo: '', inboundNo: '', sourceWarehouse: '', warehouse: '', destination: '', privateDestinationAddress: '', privateDestinationPostalCode: '', privateDestinationContact: '', privateDestinationPhone: '', address: '', contact: '', phone: '', pickupContact: '', pickupPhone: '', relabelContact: '', relabelPhone: '', deliveryMethod: '', relabelDeliveryMethod: '', trackingNo: '', reason: '', requirement: '', relabel: false, remark: '', labelDeadline: '', estimatedArrivalAt: '', estimatedShipAt: '', fbaShipmentNo: '', volumeCbm: 0.01 })
const transferDestinationType = ref<'NONE' | 'FBA' | 'PRIVATE'>('NONE')
const selectedOperationWarehouse = ref<MerchantWarehouseDetail | null>(null)
const operationWarehouseDetailLoading = ref(false)
const operationWarehouseAddress = computed(() => {
@@ -72,7 +81,22 @@ const submittedRows = ref<Record<string, string>[]>([])
const page = ref(1)
const pageSize = ref(15)
const total = ref(0)
interface ServiceDetail extends Record<string, unknown> { transferNo?: string; status?: string; estimatedShipAt?: string; actualShipAt?: string; cartons?: TransferCarton[] }
const editingTransferNo = ref('')
interface TransferPallet { id?: string | number; sequenceNo?: number; palletNo?: string; warehouseLocationCode?: string; cartonCount?: number; lengthCm?: number; widthCm?: number; heightCm?: number; grossWeightKg?: number; volumeCbm?: number; status?: string; completedAt?: string }
interface TransferMeasurementPhoto { fileId: string | number; fileName?: string; contentType?: string; sizeBytes?: number; uploadedAt?: string }
interface ServiceDetail extends Record<string, unknown> {
transferNo?: string; status?: string; operatingWarehouseId?: string; fbaWarehouseCode?: string
destinationType?: 'FBA' | 'PRIVATE'; destinationWarehouseCode?: string; destinationWarehouseName?: string
privateDestinationAddress?: string; privateDestinationPostalCode?: string; privateDestinationContactName?: string; privateDestinationContactPhone?: string
deliveryAddress?: string; pickupContactName?: string; pickupContactPhone?: string
packageCount?: number; volumeCbm?: number; deliveryMethod?: string; estimatedArrivalAt?: string
estimatedShipAt?: string; actualShipAt?: string; fbaShipmentNo?: string; trackingNo?: string
remark?: string; needRelabel?: boolean; labelDeadline?: string; relabelContactName?: string
relabelContactPhone?: string; relabelDeliveryMethod?: string
items?: Array<{ skuCode?: string; productNameZh?: string; 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 selectedRelabelDetail = ref<RelabelDetail | null>(null)
const completionPhotoUrls = reactive<Record<string, string>>({})
@@ -80,6 +104,11 @@ const completionPhotoLoading = reactive<Record<string, boolean>>({})
const completionPhotoErrors = reactive<Record<string, boolean>>({})
const completionPhotoPreviewUrls = computed(() => (selectedRelabelDetail.value?.completionEvidence?.photoFileIds || [])
.map((fileId) => completionPhotoUrls[fileId]).filter(Boolean))
const measurementPhotoUrls = reactive<Record<string, string>>({})
const measurementPhotoLoading = reactive<Record<string, boolean>>({})
const measurementPhotoErrors = reactive<Record<string, boolean>>({})
const measurementPhotoPreviewUrls = computed(() => (selectedDetail.value?.measurementPhotos || [])
.map((photo) => measurementPhotoUrls[photo.fileId]).filter(Boolean))
const fbaWarehouses = ref<MerchantWarehouse[]>([])
const operationWarehouses = ref<MerchantWarehouse[]>([])
const fbaWarehousesLoading = ref(false)
@@ -123,7 +152,7 @@ let cargoSequence = 0
let skuRelabelSequence = 0
watch(() => props.service, () => { mode.value = 'list'; page.value = 1; resetFilters(); resetForm(); void loadPage() })
watch(mode, (value) => { if (value !== 'detail') clearCompletionPhotos() })
watch(mode, (value) => { if (value !== 'detail') { clearCompletionPhotos(); clearMeasurementPhotos() } })
watch(() => form.relabel, (value) => {
if (props.service === 'transfer' && value) {
labelingMode.value = 'carton_and_sku'
@@ -146,11 +175,12 @@ async function loadPage() {
await load()
}
onMounted(() => { void loadPage(); void nextTick(refreshWarehouseMarkLabels) })
onBeforeUnmount(clearCompletionPhotos)
onBeforeUnmount(() => { clearCompletionPhotos(); clearMeasurementPhotos() })
function resetFilters() { Object.assign(filters, { number: '', inboundNo: '', warehouse: '', destination: '', status: '', start: '', end: '' }) }
function resetForm() {
Object.assign(form, { customerNo: '', inboundNo: '', sourceWarehouse: '', warehouse: '', destination: '', address: '', contact: '', phone: '', deliveryMethod: '', relabelDeliveryMethod: '', trackingNo: '', reason: '', requirement: '', relabel: false, remark: '', labelDeadline: '', estimatedArrivalAt: '', estimatedShipAt: '', fbaShipmentNo: '', volumeCbm: 0.01 })
Object.assign(form, { customerNo: '', inboundNo: '', sourceWarehouse: '', warehouse: '', destination: '', privateDestinationAddress: '', privateDestinationPostalCode: '', privateDestinationContact: '', privateDestinationPhone: '', address: '', contact: '', phone: '', pickupContact: '', pickupPhone: '', relabelContact: '', relabelPhone: '', deliveryMethod: '', relabelDeliveryMethod: '', trackingNo: '', reason: '', requirement: '', relabel: false, remark: '', labelDeadline: '', estimatedArrivalAt: '', estimatedShipAt: '', fbaShipmentNo: '', volumeCbm: 0.01 })
cargoRows.value = []
transferRelabelRows.value = []
labelingMode.value = 'carton'
skuRelabelRows.value = []
importedRelabelBindings.value = []
@@ -158,8 +188,9 @@ function resetForm() {
selectedDiscardOrderId.value = ''
relabelCandidates.value = []
selectedRelabelInboundOrderId.value = ''
transferHasFbaDestination.value = false
transferDestinationType.value = 'NONE'
selectedOperationWarehouse.value = null
editingTransferNo.value = ''
}
async function openCreate() {
resetForm(); mode.value = 'create'
@@ -167,11 +198,55 @@ async function openCreate() {
if (props.service === 'labeling') return
if (props.service !== 'disposal') { addCargoRow(); return }
}
async function openEdit(transferNo: string) {
try {
resetForm()
await loadWarehouses()
const detail = await getTransfer(transferNo) as ServiceDetail
if (detail.status !== 'DRAFT') throw new Error('只有草稿状态的转运单可以编辑')
editingTransferNo.value = transferNo
Object.assign(form, {
warehouse: detail.operatingWarehouseId || '', destination: detail.destinationWarehouseCode || detail.fbaWarehouseCode || '',
privateDestinationAddress: detail.privateDestinationAddress || '', privateDestinationPostalCode: detail.privateDestinationPostalCode || '',
privateDestinationContact: detail.privateDestinationContactName || '', privateDestinationPhone: detail.privateDestinationContactPhone || '',
address: detail.deliveryAddress || '', pickupContact: detail.pickupContactName || '',
pickupPhone: detail.pickupContactPhone || '', deliveryMethod: detail.deliveryMethod || '',
estimatedArrivalAt: detail.estimatedArrivalAt || '', estimatedShipAt: detail.estimatedShipAt || '',
fbaShipmentNo: detail.fbaShipmentNo || '', trackingNo: detail.trackingNo || '', remark: detail.remark || '',
volumeCbm: Number(detail.volumeCbm ?? 0), relabel: Boolean(detail.needRelabel),
labelDeadline: detail.labelDeadline || '', relabelContact: detail.relabelContactName || '',
relabelPhone: detail.relabelContactPhone || '', relabelDeliveryMethod: detail.relabelDeliveryMethod || '',
})
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 || '',
customerCartonMarks: item.shippingMark || '',
}))
const bindings = detail.relabelBindings || []
if (bindings.length) {
const operationType = bindings[0].operationType
labelingMode.value = operationType === 'SKU_LABEL' ? 'sku' : operationType === 'CARTON_LABEL' ? 'carton' : 'carton_and_sku'
transferRelabelRows.value = bindings.map((binding) => ({
...createCargoRow(), sku: binding.originalSku || '', newSkuCode: binding.newSku || '',
quantity: Number(binding.quantity || 1), customerCartonMarks: binding.customerCartonMark || '',
fbaCartonMark: binding.fbaCartonMark || '', destinationWarehouseId: binding.destinationWarehouseCode || '',
remark: binding.remark || '',
}))
}
if (form.warehouse) {
try { selectedOperationWarehouse.value = await getMerchantWarehouseDetail(form.warehouse) } catch { selectedOperationWarehouse.value = null }
}
mode.value = 'edit'
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '草稿加载失败') }
}
async function selectDiscardWarehouse() {
selectedDiscardOrderId.value = ''
form.inboundNo = ''
discardCandidates.value = []
cargoRows.value = []
transferRelabelRows.value = []
if (!form.warehouse) return
discardCandidatesLoading.value = true
try {
@@ -212,6 +287,13 @@ function applyExistingCartonBinding(row: SkuRelabelRow) {
function selectShippingLabel(event: Event) { shippingLabelFile.value = (event.target as HTMLInputElement).files?.[0] || null }
function selectCartonTotalFile(event: Event) { cartonTotalFile.value = (event.target as HTMLInputElement).files?.[0] || null }
function selectSkuTotalFile(event: Event) { skuTotalFile.value = (event.target as HTMLInputElement).files?.[0] || null }
function clearCreateTotalFile(scope: 'CARTON_TOTAL' | 'SKU_TOTAL') {
if (saving.value) return
const file = scope === 'CARTON_TOTAL' ? cartonTotalFile : skuTotalFile
const input = scope === 'CARTON_TOTAL' ? cartonTotalCreateInput : skuTotalCreateInput
file.value = null
if (input.value) input.value.value = ''
}
function chooseCreateTotalFile(scope: 'CARTON_TOTAL' | 'SKU_TOTAL') {
;(scope === 'CARTON_TOTAL' ? cartonTotalCreateInput : skuTotalCreateInput).value?.click()
}
@@ -477,14 +559,27 @@ function warehouseDisplay(value?: string) {
function statusLabel(value: string) {
if (props.service === 'labeling') {
if (value === 'PROCESSING') return '处理中'
return ['READY_FOR_OUTBOUND', 'COMPLETED'].includes(value) ? '换标完成' : '待处理'
return ['CUSTOMER_CONFIRM_PENDING', 'READY_FOR_OUTBOUND', 'COMPLETED'].includes(value) ? '换标完成' : '待处理'
}
if (props.service === 'transfer') {
const transferLabels: Record<string, string> = {
DRAFT: '草稿', PENDING_REVIEW: '待审核', APPROVED: '审核通过', REJECTED: '审核驳回',
WAIT_PDA_RECEIVING: '待PDA收货', INBOUND_MEASURING: '入库测量中',
WAIT_CUSTOMER_CONFIRMATION: '待客户确认实测', WAIT_RELABEL: '待换标',
WAIT_FBA_DESTINATION: '待确认目的地', READY_FOR_DISPATCH: '待排车',
WAIT_OUTBOUND: '待出库', PENDING_ASSIGNMENT: '待分配', ALLOCATED: '已分配库存',
OUTBOUNDED: '已出库', PARTIALLY_RECEIVED: '部分收货', RECEIVED: '已收货',
INBOUND_COMPLETED: '入库完成', COMPLETED: '已完成',
}
if (transferLabels[value]) return transferLabels[value]
}
const labels: Record<string, string> = {
PENDING_PROCESSING: '待处理', PROCESSING: '处理中', CUSTOMER_CONFIRM_PENDING: '待客户确认',
READY_FOR_OUTBOUND: '换标完成', COMPLETED: '已完成', CANCELLED: '已取消',
PENDING_REVIEW: '待审核', APPROVED: '待 PDA 废弃', PDA_COMPLETED: '待仓库确认完成', REJECTED: '已拒绝', PENDING_ASSIGNMENT: '待分配',
ALLOCATED: '已分配', OUTBOUNDED: '已出库', PARTIALLY_RECEIVED: '部分收货', RECEIVED: '已收货',
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: '派送完成',
}
return labels[value] || value || '—'
@@ -509,7 +604,7 @@ async function load() {
total.value = Number.isFinite(Number(result.total)) ? Number(result.total) : result.records.length
submittedRows.value = result.records.map((item) => {
const destinationWarehouses = warehouseDisplay(item.overseasWarehouseId)
return { ...row([item.relabelNo, item.customerOrderNo, destinationWarehouses, '—', item.itemCount, item.totalPackageCount, statusLabel(item.status), formatDateTime(item.createdAt)]), __status: item.status }
return { ...row([item.relabelNo, item.customerOrderNo, destinationWarehouses, '—', item.itemCount, item.totalPackageCount, statusLabel(item.status), formatDateTime(item.createdAt)]), __status: item.status, __confirmedAt: item.customerConfirmedAt || '' }
})
} 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) })
@@ -521,7 +616,7 @@ async function load() {
submittedRows.value = result.records.map((item) => {
const operationWarehouse = warehouseDisplay(item.operatingWarehouseId)
return props.service === 'transfer'
? row([item.transferNo, item.packageCount, operationWarehouse, item.fbaWarehouseCode, deliveryMethodLabel(item.deliveryMethod), item.needRelabel ? '是 · 待绑定换标单' : '否', statusLabel(item.status), formatDateTime(item.createdAt)])
? { ...row([item.transferNo, item.packageCount, operationWarehouse, item.fbaWarehouseCode, deliveryMethodLabel(item.deliveryMethod), item.needRelabel ? '是 · 待绑定换标单' : '否', statusLabel(item.status), formatDateTime(item.createdAt)]), __status: item.status }
: row([item.transferNo, '—', item.fbaWarehouseCode, '—', item.packageCount, '是', item.relabelNo ? '已生成' : '待生成', statusLabel(item.status), formatDateTime(item.createdAt)])
})
}
@@ -531,7 +626,7 @@ async function load() {
function query() { page.value = 1; void load() }
function changePage(value: number) { page.value = value; void load() }
function changePageSize(value: number) { pageSize.value = value; page.value = 1; void load() }
async function submit() {
async function submit(action: 'draft' | 'submit' = 'submit') {
const primaryWarehouse = props.service === 'relabel' ? form.sourceWarehouse : form.warehouse
const relabelRows = labelingMode.value === 'carton' ? cartonDetailRows.value : skuRelabelRows.value
const invalidCargo = props.service === 'disposal'
@@ -567,23 +662,29 @@ async function 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.estimatedShipAt || form.volumeCbm < 0 || (form.deliveryMethod === 'WAREHOUSE_PICKUP' && (!form.contact || !form.phone)))) {
const missing = [!form.warehouse && '海外仓', !form.deliveryMethod && '送货方式', !form.address && '地址', form.deliveryMethod === 'WAREHOUSE_PICKUP' && !form.contact && '提货联系人', form.deliveryMethod === 'WAREHOUSE_PICKUP' && !form.phone && '联系人电话', !form.estimatedArrivalAt && '预计入仓时间', !form.estimatedShipAt && '预计发货时间', form.volumeCbm < 0 && '预估总体积'].filter(Boolean).join('、')
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('、')
ElMessage.warning(`请填写:${missing || '必填信息'}`); return
}
if (props.service === 'transfer' && transferHasFbaDestination.value && (!form.destination || !form.fbaShipmentNo)) {
ElMessage.warning('已选择有 FBA 去向,请填写 FBA 目的仓和货件编号'); return
if (props.service === 'transfer' && transferDestinationType.value === 'FBA' && !form.destination) {
ElMessage.warning('请选择 FBA 目的仓'); return
}
if (props.service === 'transfer' && transferDestinationType.value === 'PRIVATE' && (!form.privateDestinationAddress || !form.privateDestinationPostalCode || !form.privateDestinationContact || !form.privateDestinationPhone)) {
ElMessage.warning('请完整填写私人仓地址、邮编、联系人和电话'); return
}
if (props.service === 'transfer' && form.relabel) {
if (!form.labelDeadline || !form.contact || !form.phone || (labelingMode.value !== 'sku' && !form.relabelDeliveryMethod)) { ElMessage.warning('请填写换标截止时间、联系人和换标后派送方式'); return }
if (!form.labelDeadline || !form.relabelContact || !form.relabelPhone || (labelingMode.value !== 'sku' && !form.relabelDeliveryMethod)) { ElMessage.warning('请填写换标截止时间、换标联系人和换标后派送方式'); return }
const relabelRows = transferRelabelRows.value
const allMarks = relabelRows.flatMap(transferCustomerMarks)
if (!relabelRows.length || relabelRows.some((row) => transferCustomerMarks(row).length !== row.quantity)) { ElMessage.warning('每个换标明细的客户箱唛数量必须与换标数量一致'); return }
if (new Set(allMarks.map((mark) => mark.toUpperCase())).size !== allMarks.length) { ElMessage.warning('客户箱唛号不能重复'); return }
if (allMarks.join(',').length > 2000 || relabelRows.some((row) => transferCustomerMarks(row).join(',').length > 255)) { ElMessage.warning('客户箱唛内容过长,请减少单次提交箱数或缩短箱唛号'); return }
if (relabelRows.some((row) => row.quantity < 1 || (labelingMode.value !== 'carton' && !row.newSkuCode.trim()) || (labelingMode.value !== 'sku' && (!row.fbaCartonMark.trim() || !row.destinationWarehouseId.trim())))) { ElMessage.warning('请按当前换标类型完整填写换标明细'); return }
if (labelingMode.value !== 'sku' && !form.destination) { ElMessage.warning('箱唛换标需要先在入库与转运信息中选择目的 FBA 仓'); return }
if (relabelRows.some((row) => row.quantity < 1 || (labelingMode.value !== 'carton' && (!row.sku.trim() || !row.newSkuCode.trim())) || (labelingMode.value !== 'sku' && !row.fbaCartonMark.trim()))) { ElMessage.warning('请按当前换标类型完整填写换标明细'); return }
}
if (props.service !== 'transfer' || action === 'submit') {
try { await ElMessageBox.confirm(`确认提交${config.value.title}吗?`, '提交确认', { confirmButtonText: '提交', cancelButtonText: '取消' }) } catch { return }
}
try { await ElMessageBox.confirm(`确认提交${config.value.title}吗?`, '提交确认', { confirmButtonText: '提交', cancelButtonText: '取消' }) } catch { return }
saving.value = true
try {
const idempotencyKey = crypto.randomUUID()
@@ -595,9 +696,18 @@ async function submit() {
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 })
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)
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: leave the creation form even if the detail reload fails.
mode.value = 'list'
await load()
await openDetail({ '0': created.application.relabelNo })
await ElMessageBox.alert(`申请 ${created.application.relabelNo} 已创建,部分附件上传失败:${error instanceof Error ? error.message : '上传失败'}。请在原单详情补传失败附件,不要重复下单。`, '申请已创建,附件待补传', { confirmButtonText: '知道了' })
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)) {
@@ -611,9 +721,21 @@ async function submit() {
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'
await createTransfer({ operatingWarehouseId: primaryWarehouse, fbaWarehouseCode: form.destination || undefined, deliveryAddress: form.address, packageCount, volumeCbm: form.volumeCbm, deliveryMethod: form.deliveryMethod, estimatedArrivalAt: form.estimatedArrivalAt, estimatedShipAt: form.estimatedShipAt || undefined, fbaShipmentNo: form.fbaShipmentNo || 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, relabelContactName: (form.relabel || form.deliveryMethod === 'WAREHOUSE_PICKUP') ? form.contact : undefined, relabelContactPhone: (form.relabel || form.deliveryMethod === 'WAREHOUSE_PICKUP') ? form.phone : 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: item.destinationWarehouseId || undefined, remark: item.remark || undefined })) : undefined })
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: labelingMode.value === 'sku' ? undefined : form.destination || undefined, remark: item.remark || undefined })) : undefined }
const created = editingTransferNo.value
? await updateTransfer(editingTransferNo.value, payload)
: await createTransfer(payload)
const transferNo = String(created.transferNo || '')
if (!transferNo) throw new Error('转运草稿已保存,但未返回转运单号')
if (action === 'submit') {
await submitTransfer(transferNo)
ElMessage.success('转运单已提交审核')
await openDetail({ '0': transferNo })
return
}
}
ElMessage.success(`${config.value.title}提交成功`); mode.value = 'list'; await load()
ElMessage.success(props.service === 'transfer' ? '转运单草稿保存成功' : `${config.value.title}提交成功`); mode.value = 'list'; await load()
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '提交失败') }
finally { saving.value = false }
}
@@ -625,26 +747,27 @@ async function openDetail(value: Record<string, string>) {
selectedRelabelDetail.value = props.service === 'labeling' ? detail as RelabelDetail : null
mode.value = 'detail'
if (props.service === 'labeling') void loadCompletionPhotos()
if (props.service === 'transfer') void loadMeasurementPhotos()
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '详情加载失败') }
}
async function confirmTransferMeasurements() {
const transferNo = String(selectedDetail.value?.transferNo || selectedDetail.value?.['0'] || '')
if (!transferNo) return
try { await ElMessageBox.confirm('确认 PDA 回传的箱数、尺寸、重量和打托数据吗?确认后将按实测数据入库。', '确认实测数据', { confirmButtonText: '确认入库', cancelButtonText: '取消' }) } catch { return }
try { selectedDetail.value = await confirmTransferMeasurement(transferNo) as ServiceDetail; ElMessage.success('实测数据已确认并入库') }
try { await ElMessageBox.confirm('请核对 PDA 回传的箱数、尺寸、重量和打托数据。', '确认测量数据', { confirmButtonText: '确认数据', cancelButtonText: '取消' }) } catch { return }
try { selectedDetail.value = await confirmTransferMeasurement(transferNo) as ServiceDetail; ElMessage.success('仓库实测数据已确认') }
catch (error) { ElMessage.error(error instanceof Error ? error.message : '确认失败') }
}
async function printTransferCartons() {
async function printTransferCartons(transferNo: string) {
if (props.service !== 'transfer') return
const printWindow = window.open('', '_blank')
if (!printWindow) { ElMessage.warning('浏览器阻止了打印窗口,请允许弹窗后重试'); return }
printWindow.opener = null
try {
const detail = selectedDetail.value
const transferNo = String(detail?.transferNo || detail?.['0'] || '')
if (!transferNo) { printWindow.close(); return }
const label = await getTransferCartonLabel(transferNo)
if (!label.cartons.length) { ElMessage.warning('暂无可打印的箱唛'); return }
const printWindow = window.open('', '_blank')
if (!printWindow) { ElMessage.warning('浏览器阻止了打印窗口,请允许弹窗后重试'); return }
printWindow.opener = null; printWindow.document.open(); printWindow.document.write(label.printableHtml); printWindow.document.close(); printWindow.focus(); printWindow.print()
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '箱唛加载失败') }
if (!label.cartons.length) { printWindow.close(); ElMessage.warning('暂无可打印的箱唛'); return }
printWindow.document.open(); printWindow.document.write(label.printableHtml); printWindow.document.close(); printWindow.focus(); printWindow.print()
} catch (error) { printWindow.close(); ElMessage.error(error instanceof Error ? error.message : '箱唛加载失败') }
}
function clearCompletionPhotos() {
for (const url of Object.values(completionPhotoUrls)) URL.revokeObjectURL(url)
@@ -652,6 +775,11 @@ function clearCompletionPhotos() {
for (const key of Object.keys(completionPhotoLoading)) delete completionPhotoLoading[key]
for (const key of Object.keys(completionPhotoErrors)) delete completionPhotoErrors[key]
}
async function confirmTransferFromList(transferNo: string) {
try { await ElMessageBox.confirm('请核对 PDA 回传的箱数、尺寸、重量和打托数据。', '确认测量数据', { confirmButtonText: '确认数据', cancelButtonText: '取消' }) } catch { return }
try { await confirmTransferMeasurement(transferNo); ElMessage.success('仓库实测数据已确认'); await load() }
catch (error) { ElMessage.error(error instanceof Error ? error.message : '确认失败') }
}
async function loadCompletionPhoto(fileId: string) {
const relabelNo = selectedRelabelDetail.value?.application.relabelNo
if (!relabelNo || completionPhotoLoading[fileId]) return
@@ -670,6 +798,29 @@ async function loadCompletionPhotos() {
clearCompletionPhotos()
await Promise.all((selectedRelabelDetail.value?.completionEvidence?.photoFileIds || []).map(loadCompletionPhoto))
}
function clearMeasurementPhotos() {
for (const url of Object.values(measurementPhotoUrls)) URL.revokeObjectURL(url)
for (const key of Object.keys(measurementPhotoUrls)) delete measurementPhotoUrls[key]
for (const key of Object.keys(measurementPhotoLoading)) delete measurementPhotoLoading[key]
for (const key of Object.keys(measurementPhotoErrors)) delete measurementPhotoErrors[key]
}
async function loadMeasurementPhoto(fileId: string | number) {
const transferNo = selectedDetail.value?.transferNo
if (!transferNo || measurementPhotoLoading[fileId]) return
measurementPhotoLoading[fileId] = true
measurementPhotoErrors[fileId] = false
try {
const blob = await downloadTransferMeasurementPhoto(transferNo, fileId)
if (mode.value !== 'detail' || props.service !== 'transfer' || selectedDetail.value?.transferNo !== transferNo) return
if (measurementPhotoUrls[fileId]) URL.revokeObjectURL(measurementPhotoUrls[fileId])
measurementPhotoUrls[fileId] = URL.createObjectURL(blob)
} catch { if (mode.value === 'detail' && props.service === 'transfer' && selectedDetail.value?.transferNo === transferNo) measurementPhotoErrors[fileId] = true }
finally { measurementPhotoLoading[fileId] = false }
}
async function loadMeasurementPhotos() {
clearMeasurementPhotos()
await Promise.all((selectedDetail.value?.measurementPhotos || []).map((photo) => loadMeasurementPhoto(photo.fileId)))
}
async function confirmRelabelFromList(relabelNo: string) {
try {
await ElMessageBox.confirm('确认仓库上传的换标结果无误并继续出库流程吗?', '换标完成确认', { confirmButtonText: '确认并继续', cancelButtonText: '取消' })
@@ -720,16 +871,18 @@ async function loadWarehouses() {
function warehouseLabel(value: MerchantWarehouse) { return `${value.warehouseName} · ${value.warehouseCode}` }
async function selectTransferWarehouse() {
selectedOperationWarehouse.value = null
form.contact = ''
form.phone = ''
form.pickupContact = ''
form.pickupPhone = ''
form.relabelContact = ''
form.relabelPhone = ''
if (form.deliveryMethod === 'CUSTOMER_DELIVERY') form.address = ''
if (!form.warehouse) return
operationWarehouseDetailLoading.value = true
try {
const detail = await getMerchantWarehouseDetail(form.warehouse)
selectedOperationWarehouse.value = detail
form.contact = detail.warehouse.contactName || ''
form.phone = detail.warehouse.contactPhone || ''
form.relabelContact = detail.warehouse.contactName || ''
form.relabelPhone = detail.warehouse.contactPhone || ''
if (form.deliveryMethod === 'CUSTOMER_DELIVERY') form.address = operationWarehouseAddress.value
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '中转仓联系信息加载失败')
@@ -738,10 +891,9 @@ async function selectTransferWarehouse() {
function selectTransferInboundMethod(value: string | number | boolean | undefined) {
form.address = value === 'CUSTOMER_DELIVERY' ? operationWarehouseAddress.value : ''
}
function selectTransferFbaAvailability(value: string | number | boolean | undefined) {
if (value === true) return
form.destination = ''
form.fbaShipmentNo = ''
function selectTransferDestinationType(value: string | number | boolean | undefined) {
if (value !== 'FBA') { form.destination = ''; form.fbaShipmentNo = '' }
if (value !== 'PRIVATE') { form.privateDestinationAddress = ''; form.privateDestinationPostalCode = ''; form.privateDestinationContact = ''; form.privateDestinationPhone = '' }
}
async function selectOperationWarehouse() {
cargoRows.value = []
@@ -800,7 +952,8 @@ function selectRelabelInventory(inboundOrderId: string) {
<div class="warehouse-filter-actions"><el-button type="primary" @click="query"><Search :size="16" />查询</el-button><el-button @click="resetFilters(); query()">重置</el-button></div>
</div>
<div v-loading="loading" class="warehouse-card warehouse-table-card">
<div class="warehouse-table-scroll"><table><thead><tr><th v-for="column in config.columns" :key="column">{{ column }}</th></tr></thead><tbody><tr v-for="(row, rowIndex) in submittedRows" :key="rowIndex"><td><div class="warehouse-list-actions"><button class="warehouse-link" type="button" @click="openDetail(row)"><Eye :size="15" />查看</button><el-button v-if="props.service === 'labeling' && row.__status === 'CUSTOMER_CONFIRM_PENDING'" size="small" type="primary" @click="confirmRelabelFromList(row['0'])">换标完成确认</el-button></div></td><td v-for="(_, index) in config.columns.slice(1)" :key="index"><SingleLineText :value="row[String(index)]" /></td></tr><tr v-if="submittedRows.length === 0"><td :colspan="config.columns.length"><div class="warehouse-empty">{{ config.emptyText }}</div></td></tr></tbody></table></div>
<div v-if="props.service === 'transfer' && submittedRows.some((item) => item.__status === 'WAIT_CUSTOMER_CONFIRMATION')" class="warehouse-hint">部分入库完成订单已有 PDA 实测数据,请在对应行确认测量数据。</div>
<div class="warehouse-table-scroll"><table><thead><tr><th v-for="column in config.columns" :key="column">{{ column }}</th></tr></thead><tbody><tr v-for="(row, rowIndex) in submittedRows" :key="rowIndex"><td><div class="warehouse-list-actions"><button class="warehouse-link" type="button" @click="openDetail(row)"><Eye :size="15" />查看</button><el-button v-if="props.service === 'transfer' && row.__status === 'DRAFT'" native-type="button" size="small" type="primary" @click="openEdit(row['0'])">编辑</el-button><el-button v-if="props.service === 'transfer' && row.__status === 'WAIT_CUSTOMER_CONFIRMATION'" size="small" type="primary" @click="confirmTransferFromList(row['0'])">确认测量数据</el-button><el-button v-if="props.service === 'labeling' && row.__status === 'CUSTOMER_CONFIRM_PENDING' && !row.__confirmedAt" size="small" type="primary" @click="confirmRelabelFromList(row['0'])">换标完成确认</el-button></div></td><td v-for="(_, index) in config.columns.slice(1)" :key="index"><SingleLineText :value="row[String(index)]" /></td></tr><tr v-if="submittedRows.length === 0"><td :colspan="config.columns.length"><div class="warehouse-empty">{{ config.emptyText }}</div></td></tr></tbody></table></div>
<div class="warehouse-pagination">
<span class="warehouse-pagination-total">共 {{ total }} 条</span>
<el-select v-model="pageSize" class="warehouse-page-size-select" aria-label="每页条数" @change="changePageSize"><el-option v-for="size in [15, 30, 50, 100]" :key="size" :label="`${size}条/页`" :value="size" /></el-select>
@@ -809,10 +962,10 @@ function selectRelabelInventory(inboundOrderId: string) {
</div>
</template>
<template v-else-if="mode === 'create'">
<div class="warehouse-breadcrumb"><button type="button" @click="mode = 'list'">{{ config.title }}</button><b>/</b><strong>新增</strong></div>
<div class="warehouse-page-heading"><h2>{{ config.createTitle }}</h2><el-button @click="mode = 'list'"><ArrowLeft :size="16" />返回列表</el-button></div>
<form :class="{ 'warehouse-transfer-form': props.service === 'transfer', 'warehouse-transfer-form--relabel': props.service === 'transfer' && form.relabel }" @submit.prevent="submit">
<template v-else-if="mode === 'create' || mode === 'edit'">
<div class="warehouse-breadcrumb"><button type="button" @click="mode = 'list'">{{ config.title }}</button><b>/</b><strong>{{ mode === 'edit' ? '编辑' : '新增' }}</strong></div>
<div class="warehouse-page-heading"><h2>{{ mode === 'edit' ? `编辑转运草稿 ${editingTransferNo}` : config.createTitle }}</h2><el-button @click="mode = 'list'"><ArrowLeft :size="16" />返回列表</el-button></div>
<form :class="{ 'warehouse-transfer-form': props.service === 'transfer', 'warehouse-transfer-form--relabel': props.service === 'transfer' && form.relabel }" @submit.prevent="submit('submit')">
<div class="warehouse-card warehouse-form-section warehouse-transfer-main-card"><h3>{{ props.service === 'transfer' ? '入库与转运信息' : props.service === 'relabel' ? '退货信息' : props.service === 'labeling' ? '订单信息' : '基础信息' }}</h3>
<div class="warehouse-form-grid">
<label v-if="props.service === 'disposal'" class="warehouse-span-2"><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>
@@ -824,13 +977,13 @@ function selectRelabelInventory(inboundOrderId: string) {
<label v-if="props.service === 'labeling'" class="warehouse-span-2"><span><i>*</i>选择有库存的历史入仓单</span><el-select v-model="selectedRelabelInboundOrderId" filterable clearable :loading="relabelCandidatesLoading" :disabled="!form.warehouse" no-data-text="当前中转仓暂无已入库可用库存" placeholder="请选择曾经下单且当前仍有库存的入仓单" @change="selectRelabelInventory"><el-option v-for="option in relabelInboundOptions" :key="option.value" :label="`${option.inboundOrderNo}|入仓码 ${option.inboundCode}|可用 ${option.availablePackages} 箱`" :value="option.value" /></el-select><small class="warehouse-hint">仅显示当前客户在所选中转仓已经完成入库且仍有可用库存的历史入仓单。</small></label>
<div v-if="props.service === 'transfer'" class="warehouse-choice-field warehouse-span-2"><span><i>*</i>货物如何送到海外仓</span><el-radio-group v-model="form.deliveryMethod" @change="selectTransferInboundMethod"><el-radio-button value="WAREHOUSE_PICKUP">仓库上门提货</el-radio-button><el-radio-button value="CUSTOMER_DELIVERY">客户送货到仓</el-radio-button></el-radio-group><small>仓库上门提货:填写货物当前地址;客户送货到仓:下方自动展示海外仓收货信息。</small></div>
<label v-if="props.service === 'transfer' && form.deliveryMethod === 'WAREHOUSE_PICKUP'" class="pickup-address-field"><span><i>*</i>提货地址</span><el-input v-model="form.address" placeholder="请输入仓库上门提货的详细地址" /></label>
<label v-if="props.service === 'transfer' && form.deliveryMethod === 'WAREHOUSE_PICKUP'"><span><i>*</i>提货联系人</span><el-input v-model="form.contact" placeholder="请输入提货联系人" maxlength="128" /></label>
<label v-if="props.service === 'transfer' && form.deliveryMethod === 'WAREHOUSE_PICKUP'"><span><i>*</i>联系人电话</span><el-input v-model="form.phone" placeholder="请输入联系人电话" maxlength="64" /></label>
<label v-if="props.service === 'transfer' && form.deliveryMethod === 'WAREHOUSE_PICKUP'"><span><i>*</i>提货联系人</span><el-input v-model="form.pickupContact" placeholder="请输入提货联系人" maxlength="128" /></label>
<label v-if="props.service === 'transfer' && form.deliveryMethod === 'WAREHOUSE_PICKUP'"><span><i>*</i>提货联系人电话</span><el-input v-model="form.pickupPhone" placeholder="请输入提货联系人电话" maxlength="64" /></label>
<div v-if="props.service === 'transfer' && form.deliveryMethod === 'CUSTOMER_DELIVERY'" v-loading="operationWarehouseDetailLoading" class="warehouse-receiving-card warehouse-span-2">
<div><span>海外仓收货信息</span><strong>{{ operationWarehouseAddress || '该海外仓暂未维护收货地址' }}</strong></div>
<label class="warehouse-span-2"><span><i>*</i>送货地址</span><el-input v-model="form.address" placeholder="请输入实际送货地址" /></label>
<div><span>收货联系人</span><strong>{{ form.contact || '未维护' }}</strong></div>
<div><span>联系电话</span><strong>{{ form.phone || '未维护' }}</strong></div>
<div><span>收货联系人</span><strong>{{ selectedOperationWarehouse?.warehouse.contactName || '未维护' }}</strong></div>
<div><span>联系电话</span><strong>{{ selectedOperationWarehouse?.warehouse.contactPhone || '未维护' }}</strong></div>
</div>
<label v-if="props.service === 'relabel'"><span><i>*</i>目的 FBA 仓</span><el-select v-model="form.destination" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" placeholder="请选择平台维护的 FBA 仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></label>
<label v-if="props.service === 'relabel'"><span><i>*</i>派送方式</span><el-select v-model="form.deliveryMethod" placeholder="请选择"><el-option label="卡车派送" value="卡车派送" /><el-option label="快递派送" value="快递派送" /></el-select></label>
@@ -844,10 +997,14 @@ function selectRelabelInventory(inboundOrderId: string) {
<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>预估总体积(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><i>*</i>预计发货时间</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><i>*</i>是否已有 FBA 去向</span><el-radio-group v-model="transferHasFbaDestination" @change="selectTransferFbaAvailability"><el-radio-button :value="false">暂未确定</el-radio-button><el-radio-button :value="true">已有 FBA 去向</el-radio-button></el-radio-group><small>暂未确定也可以提交,货物先正常入海外仓,后续再补充转运指令。</small></div>
<label v-if="props.service === 'transfer' && transferHasFbaDestination && !form.relabel"><span><i>*</i>目的 FBA 仓</span><el-select v-model="form.destination" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" filterable placeholder="请选择平台维护的 FBA 仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></label>
<label v-if="props.service === 'transfer' && transferHasFbaDestination && !form.relabel"><span><i>*</i>FBA 货件编号</span><el-input v-model="form.fbaShipmentNo" placeholder="请输入 FBA 货件编号" /></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="选填,可线下确认后补充" /><small class="warehouse-hint">暂未确定时可不填写,不影响货物先入海外仓。</small></label>
<div v-if="props.service === 'transfer'" class="warehouse-choice-field warehouse-span-2"><span><i>*</i>是否有目的去向</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><small>暂未确定也可以提交;完成入库后可在新建出库单中批量绑定目的地。</small></div>
<label v-if="props.service === 'transfer' && transferDestinationType === 'FBA'"><span><i>*</i>目的 FBA 仓</span><el-select v-model="form.destination" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" filterable placeholder="请选择平台维护的 FBA 仓"><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 v-if="props.service === 'transfer' && transferDestinationType === 'FBA'"><span>FBA 货件编号</span><el-input v-model="form.fbaShipmentNo" placeholder="选填,可后续补充" /></label>
<label v-if="props.service === 'transfer' && transferDestinationType === 'PRIVATE'" class="warehouse-span-2"><span><i>*</i>私人仓地址</span><el-input v-model="form.privateDestinationAddress" maxlength="1000" placeholder="请输入私人仓完整地址" /></label>
<label v-if="props.service === 'transfer' && transferDestinationType === 'PRIVATE'"><span><i>*</i>邮编</span><el-input v-model="form.privateDestinationPostalCode" maxlength="32" placeholder="请输入邮编" /></label>
<label v-if="props.service === 'transfer' && transferDestinationType === 'PRIVATE'"><span><i>*</i>私人仓联系人</span><el-input v-model="form.privateDestinationContact" maxlength="128" placeholder="请输入联系人" /></label>
<label v-if="props.service === 'transfer' && transferDestinationType === 'PRIVATE'"><span><i>*</i>联系电话</span><el-input v-model="form.privateDestinationPhone" maxlength="64" placeholder="请输入联系电话" /></label>
<label v-if="props.service === 'relabel'"><span><i>*</i>FBA 货件编号</span><el-input v-model="form.fbaShipmentNo" placeholder="请输入 FBA 货件编号" /></label>
<label v-if="props.service === 'labeling'"><span>贴标要求</span><el-input v-model="form.requirement" placeholder="请输入贴标要求" /></label>
<label v-else-if="props.service === 'relabel' || (props.service === 'transfer' && form.deliveryMethod === 'CUSTOMER_DELIVERY')"><span>物流跟踪号</span><el-input v-model="form.trackingNo" placeholder="请输入物流跟踪号(选填)" /></label>
@@ -875,7 +1032,10 @@ function selectRelabelInventory(inboundOrderId: string) {
<input ref="skuTotalCreateInput" class="warehouse-hidden-input" type="file" accept="application/pdf,.pdf" @change="selectSkuTotalFile">
</div>
</div>
<div class="warehouse-upload-summary"><span>箱唛总文件:{{ cartonTotalFile?.name || '未选择' }}</span><span>SKU 总文件:{{ skuTotalFile?.name || '未选择' }}</span></div>
<div class="warehouse-upload-summary">
<span>箱唛总文件:{{ cartonTotalFile?.name || '未选择' }}<button v-if="cartonTotalFile" type="button" class="warehouse-file-clear" title="清除箱唛总文件" aria-label="清除箱唛总文件" :disabled="saving" @click="clearCreateTotalFile('CARTON_TOTAL')"><X :size="14" /></button></span>
<span>SKU 总文件:{{ skuTotalFile?.name || '未选择' }}<button v-if="skuTotalFile" type="button" class="warehouse-file-clear" title="清除 SKU 总文件" aria-label="清除 SKU 总文件" :disabled="saving" @click="clearCreateTotalFile('SKU_TOTAL')"><X :size="14" /></button></span>
</div>
<div class="warehouse-labeling-mode">
<span>换标类型</span>
<el-radio-group v-model="labelingMode" aria-label="换标类型">
@@ -899,35 +1059,32 @@ function selectRelabelInventory(inboundOrderId: string) {
</template>
<template v-else>
<div class="warehouse-section-heading"><h3>货物明细</h3><div v-if="props.service !== 'disposal'"><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 *</th><th>中文品名 *</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="请输入商品 SKU" /></td><td><el-input v-model="row.name" :readonly="props.service === 'disposal'" placeholder="请输入中文品名" /></td><td v-if="props.service === 'disposal'"><strong>{{ row.availableQuantity }} 箱</strong></td><td v-if="props.service === 'relabel'"><el-input v-model="row.shippingMark" placeholder="请输入FNSKU或箱唛" /></td><td><el-input-number v-model="row.quantity" :min="props.service === 'disposal' ? 0 : 1" :max="row.availableQuantity" controls-position="right" placeholder="请输入箱数" /></td><td v-if="props.service !== 'disposal'"><button type="button" class="warehouse-delete" title="删除" @click="removeCargoRow(row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="cargoRows.length === 0"><td :colspan="props.service === 'disposal' ? 4 : props.service === 'relabel' ? 5 : 4"><div class="warehouse-empty">{{ props.service === 'disposal' ? '请先选择入库单,系统将自动带出 SKU 和可用箱数' : '暂无货物明细,请点击“添加行”' }}</div></td></tr></tbody></table></div>
<div class="warehouse-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>
</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"><div><h3>货物明细</h3><p>换标明细与贴标申请保持一致;客户只填写自己的箱唛,我方箱唛由系统生成。</p></div><div class="warehouse-template-actions"><el-button size="small" :disabled="!cargoRows.length" @click="downloadRelabelTemplate('sku')"><Download :size="15" />下载 SKU 关联模板</el-button><el-button size="small" :disabled="!cargoRows.length" @click="skuTemplateInput?.click()"><Upload :size="15" />上传 SKU 关联</el-button><el-button size="small" :disabled="!cargoRows.length" @click="downloadRelabelTemplate('carton')"><Download :size="15" />下载箱号关联模板</el-button><el-button size="small" :disabled="!cargoRows.length" @click="cartonTemplateInput?.click()"><Upload :size="15" />上传箱号关联</el-button><el-button size="small" :disabled="!cargoRows.length" @click="downloadRelabelTemplate('mixed')"><Download :size="15" />下载 SKU 混箱模板</el-button><el-button size="small" :disabled="!cargoRows.length" @click="mixedTemplateInput?.click()"><Upload :size="15" />上传 SKU 混箱</el-button><input ref="skuTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'sku')"><input ref="cartonTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'carton')"><input ref="mixedTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'mixed')"></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.contact" placeholder="请输入联系人" /></label><label><span><i>*</i>联系电话</span><el-input v-model="form.phone" placeholder="请输入联系电话" /></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><span>FBA 货件编号</span><el-input v-model="form.fbaShipmentNo" placeholder="选填,未填则使用转运单号" /></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="TRUCK_PICKUP" /><el-option label="快递自提" value="EXPRESS_PICKUP" /></el-select></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><p>{{ labelingModeHint }}</p></div>
<div v-if="importedRelabelBindings.length" class="warehouse-import-preview"><div class="warehouse-section-heading"><div><strong>待导入绑定关系</strong><p>可只勾选本次需要换标的部分,也可以全选后一次导入。</p></div><div><el-checkbox :model-value="importedRelabelBindings.every((item) => item.selected)" @change="selectAllImportedBindings(Boolean($event))">全选</el-checkbox><el-button type="primary" size="small" @click="importSelectedBindingsToDetails">导入到明细</el-button></div></div><div class="warehouse-table-scroll"><table class="warehouse-binding-preview-table"><thead><tr><th>选择</th><th>关系类型</th><th>仓库唛头</th><th>旧 SKU</th><th>新 SKU</th><th>数量</th><th>新 FBA 箱唛</th><th>FBA 目的仓</th></tr></thead><tbody><tr v-for="item in importedRelabelBindings" :key="item.id"><td><el-checkbox v-model="item.selected" /></td><td>{{ item.templateType === 'mixed' ? 'SKU 混箱换标' : item.templateType === 'carton' ? '箱号关联' : 'SKU 关联' }}</td><td>{{ item.cartonMark }}</td><td>{{ item.originalSku || '—' }}</td><td>{{ item.newSku || '—' }}</td><td>{{ item.quantity }}</td><td>{{ item.fbaCartonMark || '—' }}</td><td>{{ warehouseDisplay(item.destinationWarehouseId) }}</td></tr></tbody></table></div></div>
<div v-if="importedRelabelBindings.length" class="warehouse-import-preview"><div class="warehouse-section-heading"><div><strong>待导入绑定关系</strong><p>可只勾选本次需要换标的部分,也可以全选后一次导入。</p></div><div><el-checkbox :model-value="importedRelabelBindings.every((item) => item.selected)" @change="selectAllImportedBindings(Boolean($event))">全选</el-checkbox><el-button type="primary" size="small" @click="importSelectedBindingsToDetails">导入到明细</el-button></div></div><div class="warehouse-table-scroll"><table class="warehouse-binding-preview-table"><thead><tr><th>选择</th><th>关系类型</th><th>仓库唛头</th><th>旧 SKU</th><th>新 SKU</th><th>数量</th><th>新 FBA 箱唛</th></tr></thead><tbody><tr v-for="item in importedRelabelBindings" :key="item.id"><td><el-checkbox v-model="item.selected" /></td><td>{{ item.templateType === 'mixed' ? 'SKU 混箱换标' : item.templateType === 'carton' ? '箱号关联' : 'SKU 关联' }}</td><td>{{ item.cartonMark }}</td><td>{{ item.originalSku || '—' }}</td><td>{{ item.newSku || '—' }}</td><td>{{ item.quantity }}</td><td>{{ item.fbaCartonMark || '—' }}</td></tr></tbody></table></div></div>
<div class="warehouse-section-heading warehouse-tab-actions"><div><strong>绑定明细</strong><p>上传后在下方回显,也可以手动添加;客户箱唛作为仓库唛头来源,系统我方箱唛不需要手填。</p></div><div><el-button type="primary" size="small" @click="addTransferRelabelRow"><Plus :size="15" />添加 SKU 明细</el-button><el-button size="small" @click="clearTransferRelabelRows">清空</el-button></div></div>
<div v-if="labelingMode === 'carton'" class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-transfer-mark-table"><thead><tr><th>客户箱唛号 *</th><th>旧 SKU</th><th>新 FBA 箱唛 *</th><th>FBA 目的仓 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in transferRelabelRows" :key="row.id"><td><el-input v-model="row.customerCartonMarks" type="textarea" :rows="2" placeholder="请输入客户箱唛号" /></td><td><el-input :model-value="row.sku" readonly /></td><td><el-input v-model="row.fbaCartonMark" placeholder="请输入新的 FBA 箱唛" /></td><td><el-select v-model="row.destinationWarehouseId" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" filterable placeholder="请选择 FBA 目的仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></td><td><el-input v-model="row.remark" type="textarea" :rows="2" placeholder="请输入备注" /></td><td><button type="button" class="warehouse-delete" title="删除该明细" @click="transferRelabelRows = transferRelabelRows.filter((item) => item.id !== row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="transferRelabelRows.length === 0"><td colspan="6"><div class="warehouse-empty">暂无绑定明细,请点击“添加 SKU 明细”</div></td></tr></tbody></table></div>
<div v-if="labelingMode === 'carton'" class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-transfer-mark-table"><thead><tr><th>客户箱唛号 *</th><th>旧 SKU</th><th>新 FBA 箱唛 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in transferRelabelRows" :key="row.id"><td><el-input v-model="row.customerCartonMarks" type="textarea" :rows="2" placeholder="请输入客户箱唛号" /></td><td><el-input :model-value="row.sku" readonly /></td><td><el-input v-model="row.fbaCartonMark" placeholder="请输入新的 FBA 箱唛" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" placeholder="请输入备注" /></td><td><button type="button" class="warehouse-delete" title="删除该明细" @click="transferRelabelRows = transferRelabelRows.filter((item) => item.id !== row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="transferRelabelRows.length === 0"><td colspan="5"><div class="warehouse-empty">暂无绑定明细,请点击“添加 SKU 明细”</div></td></tr></tbody></table></div>
<div v-else-if="labelingMode === 'sku'" class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-transfer-mark-table"><thead><tr><th>客户箱唛号 *</th><th>旧 SKU *</th><th>新 SKU *</th><th>换标数量(件)*</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in transferRelabelRows" :key="row.id"><td><el-input v-model="row.customerCartonMarks" type="textarea" :rows="2" placeholder="请输入客户箱唛号" /></td><td><el-input :model-value="row.sku" readonly /></td><td><el-input v-model="row.newSkuCode" placeholder="请输入新 SKU" /></td><td><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" placeholder="请输入箱数" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" placeholder="请输入备注" /></td><td><button type="button" class="warehouse-delete" title="删除该明细" @click="transferRelabelRows = transferRelabelRows.filter((item) => item.id !== row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="transferRelabelRows.length === 0"><td colspan="6"><div class="warehouse-empty">暂无绑定明细,请点击“添加 SKU 明细”</div></td></tr></tbody></table></div>
<div v-else class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-transfer-mark-table"><thead><tr><th>客户箱唛号 *</th><th>旧 SKU *</th><th>新 SKU *</th><th>换标数量(件)*</th><th>新 FBA 箱唛 *</th><th>FBA 目的仓 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in transferRelabelRows" :key="row.id"><td><el-input v-model="row.customerCartonMarks" type="textarea" :rows="2" placeholder="请输入客户箱唛号" /></td><td><el-input :model-value="row.sku" readonly /></td><td><el-input v-model="row.newSkuCode" placeholder="请输入新 SKU" /></td><td><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" placeholder="请输入数量" /></td><td><el-input v-model="row.fbaCartonMark" placeholder="请输入新的 FBA 箱唛" /></td><td><el-select v-model="row.destinationWarehouseId" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" filterable placeholder="请选择 FBA 目的仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></td><td><el-input v-model="row.remark" type="textarea" :rows="2" placeholder="请输入备注" /></td><td><button type="button" class="warehouse-delete" title="删除该明细" @click="transferRelabelRows = transferRelabelRows.filter((item) => item.id !== row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="transferRelabelRows.length === 0"><td colspan="8"><div class="warehouse-empty">暂无绑定明细,请点击“添加 SKU 明细”</div></td></tr></tbody></table></div>
<div v-else class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-transfer-mark-table"><thead><tr><th>客户箱唛号 *</th><th>旧 SKU *</th><th>新 SKU *</th><th>换标数量(件)*</th><th>新 FBA 箱唛 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in transferRelabelRows" :key="row.id"><td><el-input v-model="row.customerCartonMarks" type="textarea" :rows="2" placeholder="请输入客户箱唛号" /></td><td><el-input :model-value="row.sku" readonly /></td><td><el-input v-model="row.newSkuCode" placeholder="请输入新 SKU" /></td><td><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" placeholder="请输入数量" /></td><td><el-input v-model="row.fbaCartonMark" placeholder="请输入新的 FBA 箱唛" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" placeholder="请输入备注" /></td><td><button type="button" class="warehouse-delete" title="删除该明细" @click="transferRelabelRows = transferRelabelRows.filter((item) => item.id !== row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="transferRelabelRows.length === 0"><td colspan="7"><div class="warehouse-empty">暂无绑定明细,请点击“添加 SKU 明细”</div></td></tr></tbody></table></div>
</div>
<div class="warehouse-submit-bar warehouse-transfer-submit-bar"><el-button @click="resetForm">重置</el-button><el-button type="primary" native-type="submit" :loading="saving">提交{{ config.title }}</el-button></div>
<div class="warehouse-submit-bar warehouse-transfer-submit-bar"><el-button v-if="mode === 'create'" native-type="button" @click="resetForm">重置</el-button><el-button v-if="props.service === 'transfer'" native-type="button" :loading="saving" @click="submit('draft')">保存草稿</el-button><el-button v-if="props.service === 'transfer'" type="primary" native-type="submit" :loading="saving">提交订单</el-button><el-button v-if="props.service !== 'transfer'" type="primary" native-type="submit" :loading="saving">提交{{ config.title }}</el-button></div>
</form>
</template>
<template v-else>
<div class="warehouse-breadcrumb"><button type="button" @click="mode = 'list'">{{ config.title }}</button><b>/</b><strong>订单详情</strong></div>
<div class="warehouse-page-heading"><h2>{{ config.title }}详情</h2><div><el-button v-if="props.service === 'disposal' && selectedDetail?.status === 'PENDING_REVIEW'" type="danger" @click="cancelSelectedDiscard">取消申请</el-button><el-button v-if="props.service === 'transfer' && selectedDetail?.status === 'WAIT_CUSTOMER_CONFIRMATION'" type="primary" @click="confirmTransferMeasurements">确认实测并入库</el-button><el-button @click="mode = 'list'"><ArrowLeft :size="16" />返回列表</el-button></div></div>
<div class="warehouse-page-heading"><h2>{{ config.title }}详情</h2><div><el-button v-if="props.service === 'disposal' && selectedDetail?.status === 'PENDING_REVIEW'" type="danger" @click="cancelSelectedDiscard">取消申请</el-button><el-button v-if="props.service === 'transfer' && selectedDetail?.status === 'DRAFT'" type="primary" @click="openEdit(String(selectedDetail?.transferNo || selectedDetail?.['0'] || ''))">编辑</el-button><el-button v-if="props.service === 'transfer' && selectedDetail?.status === 'WAIT_CUSTOMER_CONFIRMATION'" type="primary" @click="confirmTransferMeasurements">确认测量数据</el-button><el-button @click="mode = 'list'"><ArrowLeft :size="16" />返回列表</el-button></div></div>
<div class="warehouse-card warehouse-form-section"><h3>订单概览</h3><div 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 === 'transfer'" class="warehouse-card warehouse-form-section warehouse-transfer-times"><h3>发货时间</h3><div class="warehouse-detail-grid"><div><span>预计发货时间</span><strong>{{ formatDateTime(selectedDetail?.estimatedShipAt) }}</strong></div><div><span>实际发货时间</span><strong>{{ formatDateTime(selectedDetail?.actualShipAt) }}</strong></div></div><p>实际发货时间由仓库出库或 PDA 完成后回填,客户不能手动修改。</p></div>
<div v-if="props.service === 'transfer'" class="warehouse-card warehouse-form-section">
<div class="warehouse-section-heading"><h3>箱唛明细</h3><el-button type="primary" @click="printTransferCartons"><Printer :size="16" />打印箱唛</el-button></div>
<div class="warehouse-table-scroll"><table><thead><tr><th>系统箱唛</th><th>客户箱唛</th><th>箱序</th><th>状态</th></tr></thead><tbody>
<tr v-for="carton in (selectedDetail?.cartons || [])" :key="carton.cartonMark"><td><strong>{{ carton.cartonMark }}</strong></td><td>{{ carton.customerCartonMark || '—' }}</td><td>{{ carton.cartonIndex }} / {{ carton.totalCartons }}</td><td>{{ carton.status }}</td></tr>
<tr v-if="!(selectedDetail?.cartons || []).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-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>
<div v-if="props.service === 'transfer'" class="warehouse-card warehouse-form-section warehouse-transfer-times"><h3>发货时间</h3><div class="warehouse-detail-grid"><div><span>预计发货时间</span><strong>{{ formatDateTime(selectedDetail?.estimatedShipAt) }}</strong></div><div><span>实际发货时间</span><strong>{{ formatDateTime(selectedDetail?.actualShipAt) }}</strong></div></div><p>实际发货时间由仓库出库或 PDA 完成后回填,客户不能手动修改。</p></div>
<div v-if="props.service === 'labeling'" class="warehouse-card warehouse-form-section">
<div class="warehouse-section-heading"><h3>换标完成与出库</h3></div>
<div class="warehouse-detail-grid warehouse-fulfillment-grid">
@@ -946,6 +1103,12 @@ function selectRelabelInventory(inboundOrderId: string) {
<tr v-if="!selectedRelabelDetail?.items?.length"><td colspan="5"><div class="warehouse-empty">暂无贴标明细</div></td></tr>
</tbody></table></div>
</div>
<div v-if="props.service === 'transfer'" class="warehouse-card warehouse-form-section">
<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-else class="warehouse-empty">暂无 PDA 打托测量数据</div>
<div class="warehouse-evidence warehouse-measurement-evidence"><span>测量打托现场照片</span><div v-if="selectedDetail?.measurementPhotos?.length" class="warehouse-photo-grid"><div v-for="(photo, index) in selectedDetail.measurementPhotos" :key="photo.fileId" class="warehouse-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 === 'labeling'" class="warehouse-card warehouse-form-section">
<h3>换标任务明细</h3>
<div class="warehouse-table-scroll"><table class="warehouse-relabel-detail-table"><thead><tr><th>换标模式</th><th>我方箱唛</th><th>新 FBA 箱唛</th><th>FBA 目的仓</th><th>任务箱数</th><th>商品 SKU</th><th>SKU 换标明细</th><th>FBA 货件编号</th><th>贴标要求</th><th>备注</th></tr></thead><tbody>
@@ -963,10 +1126,15 @@ function selectRelabelInventory(inboundOrderId: string) {
.warehouse-choice-field{display:grid;grid-template-columns:150px auto 1fr;align-items:center;gap:14px;min-height:40px;padding:12px 14px;border:1px solid #e1e8f2;border-radius:5px;background:#f8faff}.warehouse-choice-field>span{color:#34445c;font-size:13px;font-weight:650}.warehouse-choice-field>span i{margin-right:3px;color:#e24b4b;font-style:normal}.warehouse-choice-field>small{color:#7a879a;font-size:12px}.warehouse-receiving-card{display:grid;grid-template-columns:2fr 1fr 1fr;gap:22px;padding:15px 16px;border:1px solid #cfe0f5;border-radius:5px;background:#f6faff}.warehouse-receiving-card div{display:flex;min-width:0;flex-direction:column;gap:6px}.warehouse-receiving-card span{color:#7c899c;font-size:12px}.warehouse-receiving-card strong{overflow-wrap:anywhere;color:#314561;font-size:13px;font-weight:600}
.warehouse-hidden-input{display:none}.warehouse-label-table{min-width:1120px}.warehouse-attachment-tip{margin:10px 0 12px;color:#7d8797;font-size:12px}.warehouse-mark-mapping{display:flex;flex-wrap:wrap;gap:8px;margin:4px 0 8px;padding:12px;border:1px solid #dce7f5;border-radius:5px;background:#f7faff}.warehouse-mark-mapping strong{width:100%;color:#34445c}.warehouse-mark-mapping span{padding:5px 9px;border-radius:3px;background:#eaf2fc;color:#3e6593;font-size:12px}
.warehouse-upload-summary{display:flex;flex-wrap:wrap;gap:8px 24px;margin:-4px 0 14px;padding:9px 12px;border:1px solid #dce7f5;border-radius:4px;background:#f8fbff;color:#596579;font-size:12px}
.warehouse-upload-summary>span{display:inline-flex;align-items:center;gap:6px;overflow-wrap:anywhere}
.warehouse-file-clear{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;width:24px;height:24px;border:0;border-radius:4px;background:transparent;color:var(--muted);cursor:pointer}
.warehouse-file-clear:hover:not(:disabled){color:#dc2626;background:#fee2e2}
.warehouse-file-clear:disabled{opacity:.5;cursor:not-allowed}
.warehouse-unmaintained{margin-left:8px;color:#e6a23c;font-size:12px;font-style:normal}
.warehouse-labeling-tabs{margin-top:-8px}.warehouse-labeling-heading{align-items:flex-start}.warehouse-labeling-heading h3{margin-bottom:16px}.warehouse-template-actions{display:flex;gap:8px;padding-bottom:12px}.warehouse-tab-actions{align-items:center;margin:2px 0 14px}.warehouse-tab-actions p{margin:0;color:#7d8797;font-size:12px}.warehouse-tab-actions>div{display:flex;gap:8px}.warehouse-sku-notice{margin:0 0 14px;padding:10px 12px;border:1px solid #f2d39b;border-radius:4px;background:#fff8e8;color:#8a6116;font-size:12px}.warehouse-sku-label-table{min-width:980px}.warehouse-sku-label-table :deep(.el-input-number){width:160px}:deep(.warehouse-labeling-tabs>.el-tabs__header){margin-bottom:18px}:deep(.warehouse-labeling-tabs .el-tabs__item){height:42px;padding:0 26px;font-weight:600}.warehouse-labeling-mode{display:flex;align-items:center;gap:14px;margin:0 0 16px;padding:14px 16px;border:1px solid #dce5f5;border-radius:5px;background:#f7f9ff}.warehouse-labeling-mode>span{color:#34445c;font-size:13px;font-weight:650;white-space:nowrap}.warehouse-labeling-mode p{margin:0;color:#71809a;font-size:12px}.warehouse-labeling-mode :deep(.el-radio-group){display:flex;flex-shrink:0}.warehouse-labeling-mode :deep(.el-radio-button__inner){padding:8px 14px}
.warehouse-relabel-detail-table{min-width:1420px}.warehouse-relabel-detail-table td strong{color:#253f67}
.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-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-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 auto 1fr;gap:10px}.warehouse-transfer-form .warehouse-choice-field>span{font-size:12px}.warehouse-transfer-form .warehouse-choice-field small{font-size:11px}.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 p{font-size:11px}.warehouse-transfer-form .warehouse-labeling-mode :deep(.el-radio-button__inner){padding:6px 9px}.warehouse-transfer-form .warehouse-template-actions{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}
@@ -6,6 +6,17 @@ const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url)
const api = readFileSync(new URL('../api/warehouseServices.ts', import.meta.url), 'utf8')
const warehouseApi = readFileSync(new URL('../../first-leg/api/firstLeg.ts', import.meta.url), 'utf8')
test('总文件可以独立清除并重选同名文件,提交时禁止清除', () => {
assert.match(view, /@click="clearCreateTotalFile\('CARTON_TOTAL'\)"/)
assert.match(view, /@click="clearCreateTotalFile\('SKU_TOTAL'\)"/)
const clear = view.slice(view.indexOf('function clearCreateTotalFile'), view.indexOf('function chooseCreateTotalFile'))
assert.match(clear, /if \(saving.value\) return/)
assert.match(clear, /file.value = null/)
assert.match(clear, /input.value.value = ''/)
assert.match(view, /aria-label="清除箱唛总文件" :disabled="saving"/)
assert.match(view, /aria-label="清除 SKU 总文件" :disabled="saving"/)
})
test('贴标申请从当前客户仍有库存的历史入仓单选择来源', () => {
assert.match(view, /海外操作仓(中转仓)/)
assert.match(view, /选择有库存的历史入仓单/)
@@ -163,3 +174,7 @@ test('客户详情进入页面后直接回显完工照片并支持大图预览',
assert.match(view, /:preview-src-list="completionPhotoPreviewUrls"/)
assert.doesNotMatch(view, />照片 \{\{ index \+ 1 \}\}<\/el-button>/)
})
test('进入复核后客户订单回显换标完成状态', () => {
assert.match(view, /\['CUSTOMER_CONFIRM_PENDING', 'READY_FOR_OUTBOUND', 'COMPLETED'\]\.includes\(value\) \? '换标完成'/)
})
@@ -13,22 +13,37 @@ test('仓储转运先选择入仓方式并按方式切换地址信息', () => {
assert.match(view, /getMerchantWarehouseDetail\(form\.warehouse\)/)
})
test('仓储转运允许暂未确定 FBA 去向并显式选择是否换标', () => {
assert.match(view, /是否已有 FBA 去向/)
assert.match(view, /:value="false">暂未确定/)
assert.match(view, /transferHasFbaDestination.*目的 FBA 仓/s)
test('仓储转运支持无目的地、FBA 仓和私人仓并显式选择是否换标', () => {
assert.match(view, /是否有目的去向/)
assert.match(view, /value="NONE">暂未确定/)
assert.match(view, /value="FBA">FBA 仓/)
assert.match(view, /value="PRIVATE">私人仓/)
assert.match(view, /transferDestinationType === 'PRIVATE'.*私人仓地址.*邮编.*私人仓联系人.*联系电话/s)
assert.match(view, /是否需要换标/)
assert.match(view, /:value="false">不需要换标/)
assert.match(view, /:value="true">需要换标/)
assert.match(api, /fbaWarehouseCode\?: string/)
assert.match(api, /privateDestinationAddress\?: string; privateDestinationPostalCode\?: string/)
assert.match(api, /fbaShipmentNo\?: string/)
assert.doesNotMatch(view, /换标费用(CNY)/)
assert.doesNotMatch(view, /relabelChargeAmount:/)
})
test('仓储转运目的仓使用主单字段且 FBA 货件号选填', () => {
assert.match(view, /props\.service === 'transfer' && transferDestinationType === 'FBA'[^\n]+目的 FBA 仓/)
assert.match(view, /整张转运单统一使用该目的仓,换标明细自动继承/)
assert.match(view, /FBA 货件编号<\/span><el-input v-model="form\.fbaShipmentNo" placeholder="选填,可后续补充"/)
assert.match(view, /transferDestinationType\.value === 'FBA' && !form\.destination/)
assert.match(view, /transferDestinationType\.value === 'PRIVATE'.*!form\.privateDestinationPostalCode/)
assert.match(view, /destinationWarehouseCode: labelingMode\.value === 'sku' \? undefined : form\.destination \|\| undefined/)
const transferRelabelSection = view.split('warehouse-transfer-relabel">')[1]?.split('warehouse-transfer-submit-bar')[0] || ''
assert.doesNotMatch(transferRelabelSection, /v-model="row\.destinationWarehouseId"/)
assert.doesNotMatch(transferRelabelSection, /<th>FBA 目的仓/)
})
test('仓储转运不要求客户填写内部入仓号和我方箱唛,但换标时填写客户箱唛', () => {
const transferConfig = view.match(/transfer:\s*\{[^\n]+/)?.[0] || ''
const transferSubmit = view.match(/await createTransfer\(\{[^\n]+/)?.[0] || ''
const transferSubmit = view.match(/const payload: TransferCreate = \{[^\n]+/)?.[0] || ''
assert.doesNotMatch(transferConfig, /进仓编号/)
assert.doesNotMatch(transferSubmit, /inboundCode|sourceLastlegOrderIds/)
assert.match(view, /货物明细/)
@@ -47,12 +62,13 @@ test('仓储转运不要求客户填写内部入仓号和我方箱唛,但换
assert.match(view, /v-else class="warehouse-table-scroll"/)
assert.match(view, /添加 SKU 明细/)
assert.match(view, /绑定明细/)
assert.match(view, /旧 SKU \*.*新 SKU \*.*换标数量(件)\*.*新 FBA 箱唛 \*.*FBA 目的仓 \*/)
assert.match(view, /旧 SKU \*.*新 SKU \*.*换标数量(件)\*.*新 FBA 箱唛 \*.*备注/)
assert.match(transferSubmit, /fbaBoxNos: customerCartonMarks/)
assert.match(transferSubmit, /shippingMark: transferCustomerMarks\(item\)/)
assert.match(transferSubmit, /relabelItems: form\.relabel/)
assert.match(view, /props\.service === 'disposal' \? '本次废弃箱数' : '预计箱数'/)
assert.doesNotMatch(view, /props\.service === 'transfer'.*FNSKU \/ 箱唛/)
assert.match(view, /<th v-if="props\.service === 'relabel'">FNSKU \/ 箱唛<\/th>/)
assert.doesNotMatch(view, /<th v-if="props\.service === 'transfer'">FNSKU \/ 箱唛<\/th>/)
})
test('上门提货不提交物流号,需要换标时随转运单登记申请', () => {
@@ -61,7 +77,16 @@ test('上门提货不提交物流号,需要换标时随转运单登记申请',
assert.match(view, /form\.deliveryMethod === 'CUSTOMER_DELIVERY'.*物流跟踪号/s)
assert.match(view, /提货联系人/)
assert.match(view, /联系人电话/)
assert.match(view, /提货联系人/)
assert.match(view, /换标联系人/)
assert.match(view, /form\.pickupContact/)
assert.match(view, /form\.pickupPhone/)
assert.match(view, /form\.relabelContact/)
assert.match(view, /form\.relabelPhone/)
assert.match(view, /选择海外仓后自动带出,也可手动填写/)
assert.match(view, /pickupContactName: form\.deliveryMethod === 'WAREHOUSE_PICKUP' \? form\.pickupContact : undefined/)
assert.match(view, /pickupContactPhone: form\.deliveryMethod === 'WAREHOUSE_PICKUP' \? form\.pickupPhone : undefined/)
assert.match(view, /relabelContactName: form\.relabel \? form\.relabelContact/)
assert.match(api, /pickupContactName\?: string; pickupContactPhone\?: string/)
})
test('仓储转运列表操作仓使用海外仓接口数据', () => {
@@ -72,10 +97,86 @@ test('仓储转运列表操作仓使用海外仓接口数据', () => {
assert.doesNotMatch(view, /美西洛杉矶仓/)
})
test('仓储转运填写预计发货时间并只读展示实际发货时间', () => {
test('仓储转运预计发货时间可选并只读展示实际发货时间', () => {
assert.match(view, /预计发货时间/)
assert.match(view, /v-model="form\.estimatedShipAt"/)
assert.match(view, /选填,可线下确认后补充/)
assert.doesNotMatch(view, /!form\.estimatedShipAt && '预计发货时间'/)
assert.match(view, /estimatedShipAt: form\.estimatedShipAt/)
assert.match(view, /实际发货时间由仓库出库或 PDA 完成后回填/)
assert.match(api, /estimatedShipAt\?: string/)
})
test('客户仓储转运展示每个实际流转状态的中文名称', () => {
assert.match(view, /label: '草稿', value: 'DRAFT'/)
assert.match(view, /label: '待审核', value: 'PENDING_REVIEW'/)
assert.match(view, /label: '入库测量中', value: 'INBOUND_MEASURING'/)
assert.match(view, /label: '待客户确认实测', value: 'WAIT_CUSTOMER_CONFIRMATION'/)
assert.match(view, /label: '待确认目的地', value: 'WAIT_FBA_DESTINATION'/)
assert.match(view, /label: '待排车', value: 'READY_FOR_DISPATCH'/)
assert.match(view, /WAIT_OUTBOUND: '待出库'/)
assert.match(view, /OUTBOUNDED: '已出库'/)
})
test('PDA 打托完成后客户可从列表或详情确认并查看实测数据', () => {
assert.match(view, /row\.__status === 'WAIT_CUSTOMER_CONFIRMATION'/)
assert.match(view, /confirmTransferFromList\(row\['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)
assert.match(view, /pallet\.warehouseLocationCode/)
assert.match(view, /pallet\.cartonCount/)
assert.match(view, /pallet\.grossWeightKg/)
assert.match(view, /pallet\.volumeCbm/)
assert.doesNotMatch(view, /pallet\.items/)
})
test('仓储转运详情自动加载 PDA 测量照片并释放 Blob URL', () => {
assert.match(api, /downloadTransferMeasurementPhoto/)
assert.match(api, /measurement-photos\/files\/\$\{encodeURIComponent\(fileId\)\}\/download/)
assert.match(view, /measurementPhotos\?: TransferMeasurementPhoto\[\]/)
assert.match(view, /fileId: string \| number/)
assert.match(view, /if \(props\.service === 'transfer'\) void loadMeasurementPhotos\(\)/)
assert.match(view, /测量打托现场照片/)
assert.match(view, /:preview-src-list="measurementPhotoPreviewUrls"/)
assert.match(view, /加载失败,点击重试/)
assert.match(view, /URL\.createObjectURL\(blob\)/)
assert.match(view, /URL\.revokeObjectURL\(url\)/)
assert.match(view, /onBeforeUnmount\(\(\) => \{ clearCompletionPhotos\(\); clearMeasurementPhotos\(\) \}\)/)
})
test('PDA 完成后主单显示入库完成并保留客户确认测量入口', () => {
assert.match(view, /row\.__status === 'WAIT_CUSTOMER_CONFIRMATION'.*确认测量数据/s)
assert.match(view, /selectedDetail\?\.status === 'WAIT_CUSTOMER_CONFIRMATION'.*确认测量数据/s)
assert.doesNotMatch(view, /确认实测并入库/)
})
test('仓储转运可在新建页直接提交,也可保存草稿后编辑提交', () => {
assert.match(api, /function submitTransfer\(transferNo: string\)/)
assert.match(api, /function updateTransfer\(transferNo: string, payload: TransferCreate\)/)
assert.match(api, /warehouse-transfers\/\$\{encodeURIComponent\(transferNo\)\}\/save-draft/)
assert.match(api, /warehouse-transfers\/\$\{encodeURIComponent\(transferNo\)\}\/submit/)
assert.match(view, /submit\('draft'\).*保存草稿/s)
assert.match(view, /async function openEdit\(transferNo: string\)/)
assert.match(view, /row\.__status === 'DRAFT'[^>]*[\s\S]*?@click="openEdit\(row\['0'\]\)"[^>]*>编辑/)
assert.match(view, /v-if="props\.service === 'transfer'"[^>]*type="primary"[^>]*native-type="submit"[^>]*>提交订单/)
assert.match(view, /await updateTransfer\(editingTransferNo\.value, payload\)/)
assert.match(view, /await submitTransfer\(transferNo\)/)
assert.match(view, /<form [^>]*@submit\.prevent="submit\('submit'\)"/)
assert.match(view, /selectedDetail\?\.status !== 'DRAFT'[^>]*[\s\S]*?printTransferCartons[^>]*[\s\S]*?打印箱唛/)
assert.doesNotMatch(view, /row\.__status !== 'DRAFT'.*printTransferCartons\(row\['0'\]\)/s)
assert.doesNotMatch(view, /selectedDetail\?\.status === 'DRAFT'[^>]*[\s\S]*?>提交订单/)
assert.match(view, /native-type="button"[^>]*:loading="saving"[^>]*@click="submit\('draft'\)"/)
assert.match(view, /DRAFT: '草稿'/)
assert.doesNotMatch(view, /<h3>箱唛明细<\/h3>/)
})
test('仓储转运基础货物不强制 SKU 和中文品名,只换箱唛不强制旧 SKU', () => {
assert.match(view, /商品 SKU\{\{ props\.service === 'transfer' \? '' : ' \*' \}\}/)
assert.match(view, /中文品名\{\{ props\.service === 'transfer' \? '' : ' \*' \}\}/)
assert.match(view, /props\.service === 'transfer' \? '选填' : '请输入商品 SKU'/)
assert.match(view, /v-if="labelingMode === 'carton'"[^\n]+<th>旧 SKU<\/th>/)
assert.match(view, /labelingMode\.value !== 'carton' && \(!row\.sku\.trim\(\) \|\| !row\.newSkuCode\.trim\(\)\)/)
assert.match(api, /skuCode\?: string; productNameZh\?: string/)
assert.match(api, /originalSku\?: string/)
})