Files
client-web/src/modules/overseas-warehouse/views/WarehouseServiceView.vue
T
2026-09-21 19:36:37 +08:00

1218 lines
136 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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, X } from '@lucide/vue'
import SingleLineText from '@/components/common/SingleLineText.vue'
import { useAuthStore } from '@/modules/auth/public'
import {
createDiscard, createRelabel, createTransfer, getDiscard, getRelabel, getTransfer,
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,
getMerchantWarehouses,
type MerchantWarehouse,
type MerchantWarehouseDetail,
} from '@/modules/first-leg/api/firstLeg'
import { loadAllWarehouseRecords } from '@/modules/first-leg/utils/warehousePagination'
type ServiceType = 'labeling' | 'transfer' | 'disposal' | 'relabel'
type PageMode = 'list' | 'create' | 'edit' | 'detail'
type LabelingMode = 'carton' | 'sku' | 'carton_and_sku'
type RelabelTemplateType = 'carton' | 'sku' | 'mixed'
interface CargoRow { id: number; included?: boolean; sku: string; name: string; quantity: number; availableQuantity?: number; sourceOrderId?: string; sourceInventoryBalanceId?: string; sourceInventoryPositionId?: string; sourceInboundOrderId?: string; returnCartonId?: string; sourceInboundOrderNo?: string; sourceInboundCode?: string; sourceWarehouseName?: string; palletNo?: string; warehouseLocationCode?: string; fnsku: string; shipmentNo: string; newSkuCode: string; shippingMark: string; customerCartonMarks: string; cartonMark: string; fbaCartonMark: string; destinationWarehouseId: string; remark: string }
interface SkuRelabelRow { id: number; sourceRowId: number | null; cartonMark: string; originalSku: string; newSku: string; quantity: number; fbaCartonMark: string; destinationWarehouseId: string; remark: string }
interface ImportedRelabelBinding extends SkuRelabelRow { templateType: RelabelTemplateType; selected: boolean; shipmentNo: string }
interface ServiceConfig { title: string; createTitle: string; numberLabel: string; emptyText: string; columns: string[] }
interface TransferCarton { cartonMark: string; customerCartonMark?: string; cartonIndex: number; totalCartons: number; status: string }
const props = defineProps<{ service: ServiceType }>()
const auth = useAuthStore()
const canCreate = computed(() => auth.hasPermission(({
labeling: 'merchant:overseas-warehouse:relabel:create',
transfer: 'merchant:overseas-warehouse:transfer:create',
disposal: 'merchant:overseas-warehouse:discard:create',
relabel: 'merchant:overseas-warehouse:return-order:create',
} as Record<ServiceType, string>)[props.service]))
const canManageRelabelFiles = computed(() => auth.hasPermission('merchant:overseas-warehouse:relabel:files'))
const canConfirmRelabel = computed(() => auth.hasPermission('merchant:overseas-warehouse:relabel:confirm'))
const canConfirmTransfer = computed(() => auth.hasPermission('merchant:overseas-warehouse:transfer:confirm'))
const canCancelDiscard = computed(() => auth.hasPermission('merchant:overseas-warehouse:discard:cancel'))
const configs: Record<ServiceType, ServiceConfig> = {
labeling: { title: '贴标申请', createTitle: '新增贴标申请', numberLabel: '贴标申请单号', emptyText: '暂无贴标申请', columns: ['操作', '贴标申请单号', '客户单号', '海外操作仓', '贴标要求', 'SKU 数', '总箱数', '状态', '创建时间'] },
transfer: { title: '仓储转运单', createTitle: '新增仓储转运单', numberLabel: '转运单号', emptyText: '暂无仓储转运订单', columns: ['操作', '转运单号', '入库箱数', '海外仓', 'FBA 目的仓', '入仓方式', '是否换标', '状态', '创建时间'] },
disposal: { title: '货物废弃', createTitle: '新增货物废弃申请', numberLabel: '废弃申请单号', emptyText: '暂无货物废弃申请', columns: ['操作', '废弃申请单号', '进仓编号', '操作仓', 'SKU 汇总', '废弃数量', '预估费用', '状态', '创建时间'] },
relabel: { title: '退货换标', createTitle: '新建退货换标单', numberLabel: '退货换标单号', emptyText: '暂无退货换标订单', columns: ['操作', '退货换标单号', '来源 FBA 仓', '目的 FBA 仓', 'SKU 汇总', '退件总数', '是否换标', '换标状态', '尾程状态', '创建时间'] },
}
const config = computed(() => configs[props.service])
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' }]
: 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: '', 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(() => {
const address = selectedOperationWarehouse.value?.addresses[0]
if (!address) return ''
return [address.addressLine1, address.addressLine2, address.city, address.stateProvince, address.postalCode, address.countryCode]
.filter(Boolean).join(', ')
})
const cargoRows = ref<CargoRow[]>([])
const transferRelabelRows = ref<CargoRow[]>([])
const transferRelabelDefaultDestination = ref('')
const labelingMode = ref<LabelingMode>('carton')
const shippingLabelFile = ref<File | null>(null)
const skuRelabelRows = ref<SkuRelabelRow[]>([])
const importedRelabelBindings = ref<ImportedRelabelBinding[]>([])
const cartonDetailRows = computed(() => cargoRows.value.filter((row) => row.included))
const labelingModeHint = computed(() => labelingMode.value === 'carton'
? '只替换外箱箱唛,不修改箱内商品 SKU 标签;整张申请统一使用订单信息中的 FBA 目的仓。'
: labelingMode.value === 'sku'
? '只替换箱内商品 SKU 标签,保留原箱唛;同一箱唛可以添加多条 SKU 明细。'
: '同一箱同时替换商品 SKU 标签和外箱箱唛;整张申请统一使用订单信息中的 FBA 目的仓。')
const submittedRows = ref<Record<string, string>[]>([])
const page = ref(1)
const pageSize = ref(15)
const total = ref(0)
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
createdAt?: 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>>({})
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)
const fbaWarehousesError = ref('')
const operationWarehousesError = ref('')
const discardCandidates = ref<import('../api/warehouseServices').DiscardCandidate[]>([])
const selectedDiscardOrderId = ref('')
const discardCandidatesLoading = ref(false)
const relabelCandidates = ref<RelabelInventoryCandidate[]>([])
const selectedRelabelInboundOrderId = ref('')
const relabelCandidatesLoading = ref(false)
const relabelInboundOptions = computed(() => {
const grouped = new Map<string, { value: string; inboundOrderNo: string; inboundCode: string; availablePackages: number }>()
for (const candidate of relabelCandidates.value) {
const value = candidate.sourceType === 'RETURN_CARTON' ? `RETURN_CARTON:${candidate.returnCartonId}` : String(candidate.inboundOrderId)
const current = grouped.get(value)
if (current) current.availablePackages += candidate.availablePackages
else grouped.set(value, {
value,
inboundOrderNo: candidate.sourceType === 'RETURN_CARTON' ? `退货合箱 ${candidate.cartonMark || '—'}` : (candidate.inboundOrderNo || '—'),
inboundCode: candidate.sourceType === 'RETURN_CARTON' ? '仓库唛头库存' : (candidate.inboundCode || '—'),
availablePackages: candidate.availablePackages,
})
}
return [...grouped.values()]
})
const loading = ref(false)
const saving = ref(false)
const cartonTemplateInput = ref<HTMLInputElement | null>(null)
const skuTemplateInput = ref<HTMLInputElement | null>(null)
const mixedTemplateInput = ref<HTMLInputElement | null>(null)
const transferCartonMarkInput = ref<HTMLInputElement | null>(null)
const relabelFileInput = ref<HTMLInputElement | null>(null)
const relabelFileScope = ref<'CARTON_TOTAL' | 'SKU_TOTAL' | 'ITEM'>('CARTON_TOTAL')
const relabelFileItemId = ref<string>()
const cartonTotalFile = ref<File | null>(null)
const skuTotalFile = ref<File | null>(null)
const cartonTotalCreateInput = ref<HTMLInputElement | null>(null)
const skuTotalCreateInput = ref<HTMLInputElement | null>(null)
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(); clearMeasurementPhotos() } })
watch(() => form.relabel, (value) => {
if (props.service === 'transfer' && value) {
labelingMode.value = 'carton_and_sku'
if (!transferRelabelRows.value.length) addTransferRelabelRow()
}
})
watch(labelingMode, (value) => { if (value !== 'carton' && !skuRelabelRows.value.length) addSkuRelabelRow() })
watch(() => form.destination, (value) => {
if (props.service !== 'labeling') return
for (const row of cargoRows.value) row.destinationWarehouseId = value
for (const row of skuRelabelRows.value) row.destinationWarehouseId = value
for (const row of importedRelabelBindings.value) row.destinationWarehouseId = value
})
function refreshWarehouseMarkLabels() {
if (props.service !== 'labeling') return
const root = document.querySelector('.warehouse-service-page')
if (!root) return
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
const nodes: Text[] = []
let node: Node | null
while ((node = walker.nextNode())) nodes.push(node as Text)
for (const text of nodes) text.nodeValue = text.nodeValue?.replaceAll('旧箱唛', '仓库唛头').replaceAll('旧唛头', '仓库唛头') || text.nodeValue
}
async function loadPage() {
if (props.service === 'labeling' || props.service === 'relabel' || props.service === 'transfer' || props.service === 'disposal') await loadWarehouses()
await load()
}
onMounted(() => { void loadPage(); void nextTick(refreshWarehouseMarkLabels) })
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: '', 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 = []
transferRelabelDefaultDestination.value = ''
labelingMode.value = 'carton'
skuRelabelRows.value = []
importedRelabelBindings.value = []
discardCandidates.value = []
selectedDiscardOrderId.value = ''
relabelCandidates.value = []
selectedRelabelInboundOrderId.value = ''
transferDestinationType.value = 'NONE'
selectedOperationWarehouse.value = null
editingTransferNo.value = ''
}
async function openCreate() {
resetForm(); mode.value = 'create'
if (props.service === 'labeling' || props.service === 'relabel' || props.service === 'transfer') void loadWarehouses()
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 || '',
}))
const destinations = [...new Set(bindings.map((binding) => binding.destinationWarehouseCode).filter(Boolean))]
transferRelabelDefaultDestination.value = destinations.length === 1 ? destinations[0]! : ''
}
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 {
const candidates = await listDiscardCandidates()
discardCandidates.value = candidates.filter((candidate) => String(candidate.overseasWarehouseId) === form.warehouse)
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '可废弃入库单加载失败') }
finally { discardCandidatesLoading.value = false }
}
function createCargoRow(): CargoRow { return { id: ++cargoSequence, sku: '', name: '', quantity: 1, fnsku: '', shipmentNo: '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', cartonMark: '', fbaCartonMark: '', destinationWarehouseId: '', remark: '' } }
function addCargoRow() { cargoRows.value.push(createCargoRow()) }
function addTransferRelabelRow() { transferRelabelRows.value.push(createCargoRow()) }
function clearTransferRelabelRows() {
transferRelabelRows.value = []
}
function addSkuRelabelRow() {
const source = cargoRows.value[Math.min(skuRelabelRows.value.length, Math.max(0, cargoRows.value.length - 1))]
skuRelabelRows.value.push({ id: ++skuRelabelSequence, sourceRowId: source?.id || null,
cartonMark: '', originalSku: source?.sku || '', newSku: '', quantity: 1,
fbaCartonMark: '', destinationWarehouseId: form.destination, remark: '' })
}
function removeSkuRelabelRow(id: number) { skuRelabelRows.value = skuRelabelRows.value.filter((row) => row.id !== id) }
function applyExistingSkuBinding(row: SkuRelabelRow) {
if (!row.originalSku.trim() || row.newSku.trim()) return
const mapping = skuRelabelRows.value.find((item) => item.id !== row.id
&& item.originalSku.trim().toUpperCase() === row.originalSku.trim().toUpperCase() && item.newSku.trim())
if (mapping) row.newSku = mapping.newSku
}
function applyExistingCartonBinding(row: SkuRelabelRow) {
if (!row.cartonMark.trim()) return
const cartonMark = row.cartonMark.trim().toUpperCase()
const mapping = cargoRows.value.find((item) => item.cartonMark.trim().toUpperCase() === cartonMark && item.fbaCartonMark.trim())
|| skuRelabelRows.value.find((item) => item.id !== row.id
&& item.cartonMark.trim().toUpperCase() === cartonMark && item.fbaCartonMark.trim())
if (!mapping) return
if (!row.fbaCartonMark.trim()) row.fbaCartonMark = mapping.fbaCartonMark
if (!row.destinationWarehouseId) row.destinationWarehouseId = mapping.destinationWarehouseId
}
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()
}
function buildSkuRelabelItems(): RelabelCreate['items'] {
const groups = new Map<string, SkuRelabelRow[]>()
for (const row of skuRelabelRows.value) {
const cartonMark = row.cartonMark.trim().toUpperCase()
if (!cartonMark) throw new Error('请填写每个箱子的我方箱唛')
const rows = groups.get(cartonMark) || []
rows.push(row)
groups.set(cartonMark, rows)
}
const anchors = cargoRows.value.flatMap((item) => Array.from(
{ length: Number(item.availableQuantity || 0) }, () => item,
))
if (!anchors.length) throw new Error('请先选择有库存的历史入仓单')
if (groups.size > anchors.length) {
throw new Error('填写的箱唛数量超过当前入仓单可用库存箱数')
}
return [...groups.entries()].map(([_, rows], groupIndex) => {
const source = cargoRows.value.find((item) => item.id === rows[0].sourceRowId) || anchors[groupIndex]
if (!source?.returnCartonId && (!source?.sourceInventoryBalanceId || !source.sourceInventoryPositionId || !source.sourceInboundOrderId)) {
throw new Error('来源库存已变化,请重新选择有库存的历史入仓单')
}
const cartonMark = rows[0].cartonMark.trim()
const changeCarton = labelingMode.value === 'carton_and_sku'
const fbaCartonMark = rows[0].fbaCartonMark.trim()
if (changeCarton && (!fbaCartonMark || !form.destination
|| rows.some((row) => row.fbaCartonMark.trim() !== fbaCartonMark))) {
throw new Error('同一我方箱唛的组合换标必须使用相同的新 FBA 箱唛,并选择整票 FBA 目的仓')
}
return {
sourceInventoryBalanceId: source.sourceInventoryBalanceId,
sourceInventoryPositionId: source.sourceInventoryPositionId,
sourceInboundOrderId: source.sourceInboundOrderId,
returnCartonId: source.returnCartonId,
skuCode: source.sku || rows[0].originalSku,
productNameZh: source.name,
totalPackageCount: 1,
notifiedPackageCount: 1,
shipmentNo: source.shipmentNo || cartonMark,
trackingCode: form.trackingNo || undefined,
newSkuCode: rows[0].newSku,
labelRequirement: form.requirement || undefined,
cartonMark,
fbaCartonMark: changeCarton ? fbaCartonMark : undefined,
destinationFbaWarehouseCode: changeCarton ? form.destination : undefined,
operationType: changeCarton ? 'CARTON_AND_SKU' as const : 'SKU_LABEL' as const,
skuItems: rows.map((row) => ({ skuCode: row.originalSku.trim(), newSkuCode: row.newSku.trim(), quantity: row.quantity, remark: row.remark || undefined })),
remark: undefined,
}
})
}
function addRelabelCargoRow() {
if (!selectedRelabelInboundOrderId.value) { ElMessage.warning('请先选择有库存的历史入仓单'); return }
const source = cargoRows.value.find((row) => !row.included)
if (!source) { ElMessage.warning('所选入仓单没有更多可添加的库存箱'); return }
source.included = true
}
function removeCargoRow(id: number) {
const item = cargoRows.value.find((row) => row.id === id)
if (props.service === 'labeling' && item?.sourceInventoryBalanceId) {
Object.assign(item, { included: false, shipmentNo: '', cartonMark: '', fbaCartonMark: '', destinationWarehouseId: '', remark: '' })
return
}
cargoRows.value = cargoRows.value.filter((row) => row.id !== id)
}
function clearRelabelRows() {
for (const row of cargoRows.value) Object.assign(row, { included: false, shipmentNo: '', cartonMark: '', fbaCartonMark: '', destinationWarehouseId: '', remark: '' })
}
function csvCell(value: string | number | undefined) { return `"${String(value ?? '').replaceAll('"', '""')}"` }
function transferCustomerMarks(row: CargoRow) {
return row.customerCartonMarks.split(/[\r\n,,]+/).map((value) => value.trim()).filter(Boolean)
}
function downloadTransferCartonMarkTemplate() {
if (!cargoRows.value.length || cargoRows.value.some((row) => !row.sku.trim())) {
ElMessage.warning('请先填写完整的 SKU 明细'); return
}
const header = ['SKU', '客户箱唛号']
const rows = cargoRows.value.flatMap((row) => Array.from({ length: row.quantity }, () => [row.sku, '']))
const blob = new Blob([`\uFEFF${[header, ...rows].map((row) => row.map(csvCell).join(',')).join('\r\n')}\r\n`], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob); const anchor = document.createElement('a')
anchor.href = url; anchor.download = '仓储转运-客户箱唛模板.csv'; anchor.click(); URL.revokeObjectURL(url)
}
async function importTransferCartonMarks(event: Event) {
const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''
if (!file) return
const lines = (await file.text()).replace(/^\uFEFF/, '').split(/\r?\n/).filter((line) => line.trim())
if (lines.length < 2) { ElMessage.error('模板中没有可导入的客户箱唛'); return }
const headers = parseCsvLine(lines[0]).map((name) => name === '仓库唛头' || name === '旧唛头' ? '旧箱唛' : name)
if (!headers.includes('SKU') || !headers.includes('客户箱唛号')) { ElMessage.error('客户箱唛模板格式不正确,请重新下载模板'); return }
const imported = lines.slice(1).map(parseCsvLine).map((values) => Object.fromEntries(headers.map((key, index) => [key, values[index] || '']))).filter((item) => item.SKU || item['客户箱唛号'])
if (!imported.length || imported.some((item) => !item.SKU || !item['客户箱唛号'])) { ElMessage.error('SKU 和客户箱唛号必须填写完整'); return }
const marks = new Set<string>()
for (const item of imported) {
const row = cargoRows.value.find((candidate) => candidate.sku.trim().toUpperCase() === item.SKU.trim().toUpperCase())
if (!row) { ElMessage.error(`SKU ${item.SKU} 不属于当前转运单`); return }
const mark = item['客户箱唛号'].trim()
if (marks.has(mark.toUpperCase())) { ElMessage.error(`客户箱唛 ${mark} 不能重复`); return }
marks.add(mark.toUpperCase())
}
for (const row of cargoRows.value) row.customerCartonMarks = imported.filter((item) => item.SKU.trim().toUpperCase() === row.sku.trim().toUpperCase()).map((item) => item['客户箱唛号'].trim()).join('\n')
ElMessage.success(`已导入 ${imported.length} 个客户箱唛`)
}
function downloadRelabelTemplate(templateType: RelabelTemplateType) {
if (!cargoRows.value.length) { ElMessage.warning('请先选择有库存的历史入仓单'); return }
const cartonTemplate = templateType === 'carton'
const mixedTemplate = templateType === 'mixed'
const includeDestination = false
const cartonMarkHeader = props.service === 'transfer' ? '客户箱唛号' : '仓库唛头'
const header = cartonTemplate
? ['明细行标识', cartonMarkHeader, '新FBA箱唛', ...(includeDestination ? ['FBA目的仓'] : []), 'FBA货件编号', '备注']
: ['明细行标识', cartonMarkHeader, '旧SKU标签', '新SKU标签', '换标数量(件)',
...(mixedTemplate ? ['新FBA箱唛', ...(includeDestination ? ['FBA目的仓'] : []), 'FBA货件编号'] : []), '备注']
const rows = cargoRows.value.map((item) => cartonTemplate
? [item.id, item.cartonMark, item.fbaCartonMark, ...(includeDestination ? [item.destinationWarehouseId] : []), item.shipmentNo, item.remark]
: [item.id, item.cartonMark, item.sku, '', 1,
...(mixedTemplate ? [item.fbaCartonMark, ...(includeDestination ? [item.destinationWarehouseId] : []), item.shipmentNo] : []), item.remark])
const blob = new Blob([`\uFEFF${[header, ...rows].map((row) => row.map(csvCell).join(',')).join('\r\n')}\r\n`], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = cartonTemplate ? '贴标申请-箱号关联模板.csv'
: mixedTemplate ? '贴标申请-SKU混箱换标模板.csv' : '贴标申请-SKU关联模板.csv'
anchor.click(); URL.revokeObjectURL(url)
}
function parseCsvLine(line: string) {
const values: string[] = []; let value = ''; let quoted = false
for (let index = 0; index < line.length; index += 1) {
const char = line[index]
if (char === '"') { if (quoted && line[index + 1] === '"') { value += '"'; index += 1 } else quoted = !quoted }
else if (char === ',' && !quoted) { values.push(value.trim()); value = '' }
else value += char
}
values.push(value.trim()); return values
}
async function importRelabelTemplate(event: Event, templateType: RelabelTemplateType) {
const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''
if (!file) return
const lines = (await file.text()).replace(/^\uFEFF/, '').split(/\r?\n/).filter((line) => line.trim())
if (lines.length < 2) { ElMessage.error('模板中没有可导入的明细'); return }
// Accept both the current display name and legacy template header while keeping the internal key stable.
const headers = parseCsvLine(lines[0]).map((name) => name === '仓库唛头' || name === '客户箱唛号' || name === '旧唛头' ? '旧箱唛' : name)
if (templateType !== 'carton') {
const mixedTemplate = templateType === 'mixed'
const required = ['明细行标识', '旧箱唛', '旧SKU标签', '新SKU标签', '换标数量(件)',
...(mixedTemplate ? ['新FBA箱唛', 'FBA货件编号'] : [])]
if (required.some((name) => !headers.includes(name))) {
ElMessage.error('SKU 关联模板格式不正确,请重新下载 SKU 关联模板'); return
}
const imported = lines.slice(1).map(parseCsvLine)
.map((values) => Object.fromEntries(headers.map((key, index) => [key, values[index] || ''])))
.filter((item) => item['旧箱唛'] || item['新SKU标签'] || item['新FBA箱唛'])
if (!imported.length || imported.some((item) => required.some((key) => !item[key]))) {
ElMessage.error('仓库唛头、旧 SKU 标签、新 SKU 标签和换标数量必须填写完整'); return
}
const rows: ImportedRelabelBinding[] = []
for (const item of imported) {
const quantity = Number(item['换标数量(件)'])
if (!Number.isInteger(quantity) || quantity < 1) {
ElMessage.error(`仓库唛头 ${item['旧箱唛']} 的换标数量必须是大于 0 的整数`); return
}
const source = cargoRows.value.find((row) => String(row.id) === item['明细行标识'])
if (!source) { ElMessage.error(`明细行 ${item['明细行标识']} 不属于当前库存,请重新下载模板`); return }
rows.push({
id: ++skuRelabelSequence,
sourceRowId: source.id,
cartonMark: item['旧箱唛'].trim(),
originalSku: item['旧SKU标签'].trim(),
newSku: item['新SKU标签'].trim(),
quantity,
fbaCartonMark: mixedTemplate ? item['新FBA箱唛'].trim() : '',
destinationWarehouseId: form.destination,
remark: item['备注'] || '',
templateType,
selected: true,
shipmentNo: mixedTemplate ? item['FBA货件编号'].trim() : '',
})
}
importedRelabelBindings.value = rows
ElMessage.success(`已读取 ${rows.length} 条绑定关系,请选择后导入到明细`)
return
}
const required = ['明细行标识', '旧箱唛', '新FBA箱唛', 'FBA货件编号']
if (required.some((name) => !headers.includes(name))) { ElMessage.error('箱号关联模板格式不正确,请重新下载箱号关联模板'); return }
const imported = lines.slice(1).map(parseCsvLine)
.map((values) => Object.fromEntries(headers.map((key, index) => [key, values[index] || ''])))
.filter((item) => item['旧箱唛'] || item['新FBA箱唛'])
if (!imported.length || imported.some((item) => required.some((key) => !item[key]))) {
ElMessage.error('模板中的明细行、仓库唛头、新 FBA 箱唛和货件编号必须填写完整'); return
}
if (new Set(imported.map((item) => item['明细行标识'])).size !== imported.length) {
ElMessage.error('模板中的明细行标识不能重复'); return
}
const importedByRow = new Map(imported.map((item) => [item['明细行标识'], item]))
if (importedByRow.size !== imported.length || [...importedByRow.keys()].some((id) => !cargoRows.value.some((row) => String(row.id) === id))) {
ElMessage.error('模板存在重复或不属于当前库存的明细行,请重新下载模板'); return
}
const bindings: ImportedRelabelBinding[] = []
for (const [rowId, item] of importedByRow) {
const row = cargoRows.value.find((candidate) => String(candidate.id) === rowId)!
bindings.push({ id: ++skuRelabelSequence, sourceRowId: row.id, cartonMark: item['旧箱唛'],
originalSku: row.sku, newSku: '', quantity: 1, fbaCartonMark: item['新FBA箱唛'],
destinationWarehouseId: form.destination, remark: item['备注'] || '', templateType,
selected: true, shipmentNo: item['FBA货件编号'] })
}
importedRelabelBindings.value = bindings
ElMessage.success(`已读取 ${bindings.length} 条箱号关联,请选择后导入到明细`)
}
function selectAllImportedBindings(value: boolean) {
for (const item of importedRelabelBindings.value) item.selected = value
}
function importSelectedBindingsToDetails() {
const selected = importedRelabelBindings.value.filter((item) => item.selected)
if (!selected.length) { ElMessage.warning('请至少选择一条绑定关系'); return }
const templateType = selected[0].templateType
if (selected.some((item) => item.templateType !== templateType)) { ElMessage.warning('请重新上传同一种绑定模板'); return }
if (props.service === 'transfer') {
const importedRows = selected.map((binding) => ({
...createCargoRow(),
sku: binding.originalSku,
newSkuCode: binding.newSku,
quantity: binding.quantity,
customerCartonMarks: binding.cartonMark,
fbaCartonMark: binding.fbaCartonMark,
destinationWarehouseId: '',
remark: binding.remark,
}))
const importedMarks = new Set(importedRows.flatMap(transferCustomerMarks).map((mark) => mark.toUpperCase()))
transferRelabelRows.value = [
...transferRelabelRows.value.filter((row) => !transferCustomerMarks(row).some((mark) => importedMarks.has(mark.toUpperCase()))),
...importedRows,
]
labelingMode.value = templateType === 'carton' ? 'carton' : templateType === 'sku' ? 'sku' : 'carton_and_sku'
importedRelabelBindings.value = importedRelabelBindings.value.filter((item) => !item.selected)
ElMessage.success(`已导入 ${importedRows.length} 条仓储转运换标明细`)
return
}
if (templateType === 'carton') {
for (const binding of selected) {
const source = cargoRows.value.find((row) => row.id === binding.sourceRowId)
if (!source) continue
Object.assign(source, { included: true, quantity: 1, shipmentNo: binding.shipmentNo,
cartonMark: binding.cartonMark, fbaCartonMark: binding.fbaCartonMark,
destinationWarehouseId: form.destination, remark: binding.remark })
}
labelingMode.value = 'carton'
} else {
const importedRows = selected.map(({ templateType: _, selected: __, shipmentNo, ...item }) => item)
for (const item of importedRows) {
skuRelabelRows.value = skuRelabelRows.value.filter((row) => !(row.cartonMark.trim().toUpperCase() === item.cartonMark.trim().toUpperCase()
&& row.originalSku.trim().toUpperCase() === item.originalSku.trim().toUpperCase()))
item.destinationWarehouseId = form.destination
skuRelabelRows.value.push(item)
if (templateType === 'mixed') {
const source = cargoRows.value.find((row) => row.id === item.sourceRowId)
const shipmentNo = selected.find((binding) => binding.id === item.id)?.shipmentNo || ''
if (source) Object.assign(source, { included: true, cartonMark: item.cartonMark,
fbaCartonMark: item.fbaCartonMark, destinationWarehouseId: form.destination, shipmentNo })
}
}
labelingMode.value = templateType === 'mixed' ? 'carton_and_sku' : 'sku'
}
importedRelabelBindings.value = importedRelabelBindings.value.filter((item) => !item.selected)
ElMessage.success(`已将 ${selected.length} 条绑定关系导入本次换标明细`)
}
function selectDiscardInbound(orderId: string) {
const candidate = discardCandidates.value.find((item) => item.orderId === orderId)
if (!candidate) { cargoRows.value = []; return }
form.inboundNo = candidate.inboundCode
form.warehouse = candidate.overseasWarehouseId
cargoRows.value = candidate.skus.map((sku) => ({
id: ++cargoSequence, sku: sku.skuCode, name: sku.productName, quantity: 0,
availableQuantity: sku.availablePackages, sourceOrderId: candidate.orderId,
fnsku: '', shipmentNo: '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', cartonMark: '', fbaCartonMark: '', destinationWarehouseId: '', remark: '',
}))
}
function dateTime(value: string, end = false) { return value ? `${value}T${end ? '23:59:59' : '00:00:00'}` : undefined }
function row(values: Array<string | number | undefined | null>) { return Object.fromEntries(values.map((value, index) => [String(index), value == null || value === '' ? '—' : String(value)])) }
function warehouseDisplay(value?: string) {
if (!value) return '—'
return value.split(',').map((code) => {
const warehouse = [...operationWarehouses.value, ...fbaWarehouses.value].find((item) => String(item.id) === code || item.warehouseCode === code)
return warehouse ? warehouseLabel(warehouse) : code
}).join('、')
}
function statusLabel(value: string) {
if (props.service === 'labeling') {
if (value === 'PROCESSING') return '处理中'
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: '已取消',
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 || '—'
}
function deliveryMethodLabel(value: string) {
const labels: Record<string, string> = {
TRUCK: '卡车派送', EXPRESS: '快递派送', TRUCK_PICKUP: '卡车自提', EXPRESS_PICKUP: '快递自提',
WAREHOUSE_PICKUP: '仓库上门提货', CUSTOMER_DELIVERY: '客户送货到仓',
// Keep the legacy value readable for historical applications created before the four-way split.
PICKUP: '仓库自提',
}
return labels[value] || value || '—'
}
function formatDateTime(value?: string | null) {
return value ? value.replace('T', ' ').slice(0, 19) : '—'
}
async function load() {
loading.value = true
try {
if (props.service === 'labeling') {
const result = await listRelabels({ page: page.value, size: pageSize.value, relabelNo: filters.number || undefined, overseasWarehouseId: filters.warehouse || undefined, status: filters.status || undefined, createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true) })
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, __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) })
total.value = Number.isFinite(Number(result.total)) ? Number(result.total) : result.records.length
submittedRows.value = result.records.map((item) => row([item.discardNo, item.inboundCodes, item.overseasWarehouseName || '—', '—', item.totalPackages, `${item.currency} ${item.totalFee}`, statusLabel(item.status), formatDateTime(item.createdAt)]))
} else {
const result = await listTransfers({ page: page.value, size: pageSize.value, transferNo: filters.number || undefined, inboundCode: filters.inboundNo || undefined, operatingWarehouseId: filters.warehouse || undefined, fbaWarehouseCode: filters.destination || undefined, status: filters.status || undefined, ...(props.service === 'relabel' ? { needRelabel: true } : {}), createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true) })
total.value = Number.isFinite(Number(result.total)) ? Number(result.total) : result.records.length
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)]), __status: item.status }
: row([item.transferNo, '—', item.fbaWarehouseCode, '—', item.packageCount, '是', item.relabelNo ? '已生成' : '待生成', statusLabel(item.status), formatDateTime(item.createdAt)])
})
}
} catch (error) { submittedRows.value = []; total.value = 0; ElMessage.error(error instanceof Error ? error.message : '列表加载失败') }
finally { loading.value = false }
}
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(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'
? !selectedDiscardOrderId.value || cargoRows.value.every((item) => item.quantity < 1)
|| cargoRows.value.some((item) => item.quantity < 0 || item.quantity > Number(item.availableQuantity || 0))
: (props.service === 'labeling' || props.service === 'relabel') && (relabelRows.length === 0 || (props.service === 'labeling' && labelingMode.value !== 'carton'
? skuRelabelRows.value.some((item) => !item.cartonMark || !item.originalSku || !item.newSku || item.quantity < 1
|| (labelingMode.value === 'carton_and_sku' && !item.fbaCartonMark))
: cargoRows.value.some((item) => (props.service !== 'labeling' && !item.sku) || !item.name
|| (props.service !== 'labeling' && item.quantity < 1))))
if ((props.service === 'relabel' && !form.inboundNo) || !primaryWarehouse || invalidCargo) {
ElMessage.warning('请填写必填信息和完整的货物明细'); return
}
if (props.service === 'labeling' && (!form.labelDeadline || !form.contact || !form.phone)) {
ElMessage.warning('请填写贴标截止时间和海外仓联系人信息'); return
}
if (props.service === 'labeling' && labelingMode.value !== 'sku' && !form.deliveryMethod) {
ElMessage.warning('请选择换标完成后的派送方式'); return
}
if (props.service === 'labeling' && labelingMode.value !== 'sku' && !form.destination) {
ElMessage.warning('请选择本张贴标申请统一使用的 FBA 目的仓'); return
}
if (props.service === 'labeling' && labelingMode.value !== 'sku' && (form.deliveryMethod === 'EXPRESS' || form.deliveryMethod === 'EXPRESS_PICKUP')
&& form.relabel && !shippingLabelFile.value) {
ElMessage.warning('选择打印面单时必须上传面单文件'); return
}
if (props.service === 'labeling' && labelingMode.value === 'carton' && cartonDetailRows.value.some((item) => !item.shipmentNo || !item.cartonMark || !item.fbaCartonMark)) {
ElMessage.warning('请上传关联面单,确保每箱的我方箱唛和 FBA 箱唛完整'); return
}
if (props.service === 'labeling' && labelingMode.value === 'carton' && cartonDetailRows.value.some((item) => !item.sourceInventoryPositionId)) {
ElMessage.warning('请选择有库存的历史入仓单'); return
}
if (props.service === 'disposal' && !form.reason) {
ElMessage.warning('请选择废弃原因'); return
}
if (props.service === 'relabel' && (!form.destination || !form.address || !form.deliveryMethod || !form.estimatedArrivalAt || !form.fbaShipmentNo || form.volumeCbm <= 0)) {
ElMessage.warning('请填写完整的派送信息、体积、预计到达时间和 FBA 货件号'); return
}
if (props.service === 'transfer' && (!form.warehouse || !form.deliveryMethod || !form.address || !form.estimatedArrivalAt || form.volumeCbm < 0 || (form.deliveryMethod === 'WAREHOUSE_PICKUP' && (!form.pickupContact || !form.pickupPhone)))) {
const missing = [!form.warehouse && '海外仓', !form.deliveryMethod && '送货方式', !form.address && '地址', form.deliveryMethod === 'WAREHOUSE_PICKUP' && !form.pickupContact && '提货联系人', form.deliveryMethod === 'WAREHOUSE_PICKUP' && !form.pickupPhone && '联系人电话', !form.estimatedArrivalAt && '预计入仓时间', form.volumeCbm < 0 && '预估总体积'].filter(Boolean).join('、')
ElMessage.warning(`请填写:${missing || '必填信息'}`); 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.relabelContact || !form.relabelPhone || (labelingMode.value !== 'sku' && !form.relabelDeliveryMethod)) { ElMessage.warning('请填写换标截止时间、换标联系人和换标后派送方式'); return }
if (!transferRelabelDefaultDestination.value) { ElMessage.warning('请选择整票换标货物的 FBA 目的仓'); 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.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 }
}
saving.value = true
try {
const idempotencyKey = crypto.randomUUID()
if (props.service === 'labeling') {
const sourceInboundOrders = [...new Set(cargoRows.value.map((item) => item.sourceInboundOrderNo || item.sourceInboundCode).filter((value): value is string => Boolean(value)))].join('、').slice(0, 128)
const items = labelingMode.value === 'carton'
? cartonDetailRows.value.map((item) => ({ sourceInventoryBalanceId: item.sourceInventoryBalanceId, sourceInventoryPositionId: item.sourceInventoryPositionId, sourceInboundOrderId: item.sourceInboundOrderId, returnCartonId: item.returnCartonId, skuCode: item.sku, productNameZh: item.name, totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: item.shipmentNo, trackingCode: form.trackingNo || undefined, newSkuCode: undefined, labelRequirement: form.requirement || undefined, cartonMark: item.cartonMark, fbaCartonMark: item.fbaCartonMark, destinationFbaWarehouseCode: form.destination, operationType: 'CARTON_LABEL' as const, skuItems: [], remark: item.remark || undefined }))
: buildSkuRelabelItems()
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 })
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)) {
throw new Error(`SKU ${item.sku} 的可废弃库存不足`)
}
return { orderId: item.sourceOrderId, skuCode: item.sku, discardPackages: item.quantity }
})
await createDiscard({ items, idempotencyKey, remark: [form.reason, form.remark].filter(Boolean).join(';') || undefined })
} else {
const packageCount = cargoRows.value.reduce((sum, item) => sum + item.quantity, 0)
const customerCartonMarks = (form.relabel ? transferRelabelRows.value : cargoRows.value).flatMap(transferCustomerMarks)
const transferRemark = [form.remark, form.relabel && form.requirement ? `换标要求:${form.requirement}` : ''].filter(Boolean).join(';')
const operationType = labelingMode.value === 'carton' ? 'CARTON_LABEL' : labelingMode.value === 'sku' ? 'SKU_LABEL' : 'CARTON_AND_SKU'
const selectedFba = fbaWarehouses.value.find((warehouse) => warehouse.warehouseCode === form.destination)
const payload: TransferCreate = { operatingWarehouseId: primaryWarehouse, destinationType: transferDestinationType.value, fbaWarehouseCode: transferDestinationType.value === 'FBA' ? form.destination || undefined : undefined, destinationWarehouseId: transferDestinationType.value === 'FBA' ? String(selectedFba?.id || '') || undefined : undefined, destinationWarehouseCode: transferDestinationType.value === 'FBA' ? form.destination || undefined : undefined, destinationWarehouseName: transferDestinationType.value === 'FBA' ? selectedFba?.warehouseName : undefined, privateDestinationAddress: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationAddress : undefined, privateDestinationPostalCode: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationPostalCode : undefined, privateDestinationContactName: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationContact : undefined, privateDestinationContactPhone: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationPhone : undefined, deliveryAddress: form.address, packageCount, volumeCbm: form.volumeCbm, deliveryMethod: form.deliveryMethod, estimatedArrivalAt: form.estimatedArrivalAt, estimatedShipAt: form.estimatedShipAt || undefined, fbaShipmentNo: transferDestinationType.value === 'FBA' ? form.fbaShipmentNo || undefined : undefined, trackingNo: form.deliveryMethod === 'CUSTOMER_DELIVERY' ? form.trackingNo || undefined : undefined, fbaBoxNos: customerCartonMarks.length ? customerCartonMarks.join(',') : undefined, remark: transferRemark || undefined, needRelabel: props.service === 'relabel' || form.relabel, labelDeadline: form.relabel ? form.labelDeadline : undefined, pickupContactName: form.deliveryMethod === 'WAREHOUSE_PICKUP' ? form.pickupContact : undefined, pickupContactPhone: form.deliveryMethod === 'WAREHOUSE_PICKUP' ? form.pickupPhone : undefined, relabelContactName: form.relabel ? form.relabelContact : undefined, relabelContactPhone: form.relabel ? form.relabelPhone : undefined, relabelDeliveryMethod: form.relabel && labelingMode.value !== 'sku' ? form.relabelDeliveryMethod as 'TRUCK' | 'EXPRESS' | 'TRUCK_PICKUP' | 'EXPRESS_PICKUP' : undefined, idempotencyKey, items: cargoRows.value.map((item) => ({ skuCode: item.sku, productNameZh: item.name, packageCount: item.quantity, shippingMark: transferCustomerMarks(item).join(',') || undefined })), relabelItems: form.relabel ? transferRelabelRows.value.map((item) => ({ operationType, customerCartonMarks: transferCustomerMarks(item), originalSku: item.sku, newSku: item.newSkuCode || undefined, quantity: item.quantity, fbaCartonMark: item.fbaCartonMark || undefined, destinationWarehouseCode: transferRelabelDefaultDestination.value || undefined, remark: item.remark || undefined })) : undefined }
const 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(props.service === 'transfer' ? '转运单草稿保存成功' : `${config.value.title}提交成功`); mode.value = 'list'; await load()
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '提交失败') }
finally { saving.value = false }
}
async function openDetail(value: Record<string, string>) {
try {
const detail = props.service === 'labeling' ? await getRelabel(value['0']) : props.service === 'disposal' ? await getDiscard(value['0']) : await getTransfer(value['0'])
const discardSummary = props.service === 'disposal' ? (detail as { summary?: ServiceDetail }).summary : undefined
selectedDetail.value = { ...value, ...(detail as ServiceDetail), ...(discardSummary || {}) }
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('仓库实测数据已确认') }
catch (error) { ElMessage.error(error instanceof Error ? error.message : '确认失败') }
}
async function printTransferCartons(transferNo: string) {
if (props.service !== 'transfer') return
const printWindow = window.open('', '_blank')
if (!printWindow) { ElMessage.warning('浏览器阻止了打印窗口,请允许弹窗后重试'); return }
printWindow.opener = null
try {
if (!transferNo) { printWindow.close(); return }
const label = await getTransferCartonLabel(transferNo)
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)
for (const key of Object.keys(completionPhotoUrls)) delete completionPhotoUrls[key]
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
completionPhotoLoading[fileId] = true
completionPhotoErrors[fileId] = false
try {
const blob = await downloadRelabelCompletionPhoto(relabelNo, fileId)
if (selectedRelabelDetail.value?.application.relabelNo !== relabelNo) return
if (completionPhotoUrls[fileId]) URL.revokeObjectURL(completionPhotoUrls[fileId])
const url = URL.createObjectURL(blob)
completionPhotoUrls[fileId] = url
} catch { if (selectedRelabelDetail.value?.application.relabelNo === relabelNo) completionPhotoErrors[fileId] = true }
finally { completionPhotoLoading[fileId] = false }
}
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: '取消' })
await confirmRelabelCompletion(relabelNo)
ElMessage.success('已确认换标完成,订单已进入后续出库流程')
await load()
} catch (error) { if (error !== 'cancel') ElMessage.error(error instanceof Error ? error.message : '确认失败') }
}
async function cancelSelectedDiscard() {
const no = String(selectedDetail.value?.discardNo || selectedDetail.value?.['0'] || '')
if (!no) return
try { await ElMessageBox.confirm('确认取消该废弃申请吗?', '取消确认', { confirmButtonText: '取消申请', cancelButtonText: '返回' }); await cancelDiscard(no); ElMessage.success('废弃申请已取消'); mode.value = 'list'; await load() }
catch (error) { if (error !== 'cancel') ElMessage.error(error instanceof Error ? error.message : '取消失败') }
}
function chooseRelabelFile(scope: 'CARTON_TOTAL' | 'SKU_TOTAL' | 'ITEM', itemId?: string) {
relabelFileScope.value = scope; relabelFileItemId.value = itemId; relabelFileInput.value?.click()
}
async function uploadSelectedRelabelFile(event: Event) {
const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''
const relabelNo = selectedRelabelDetail.value?.application.relabelNo
if (!file || !relabelNo) return
try {
selectedRelabelDetail.value = await uploadRelabelFile(relabelNo, relabelFileScope.value, file, relabelFileItemId.value)
ElMessage.success('文件上传成功')
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '文件上传失败') }
}
async function loadWarehouses() {
fbaWarehousesLoading.value = true
fbaWarehousesError.value = ''
operationWarehousesError.value = ''
try {
const [fbaResult, operationResult] = await Promise.allSettled([
loadAllWarehouseRecords('FBA', getMerchantWarehouses),
loadAllWarehouseRecords('OVERSEAS', getMerchantWarehouses),
])
fbaWarehouses.value = fbaResult.status === 'fulfilled' ? fbaResult.value.filter((item) => item.status === 'ACTIVE' && item.warehouseType === 'FBA') : []
operationWarehouses.value = operationResult.status === 'fulfilled'
? operationResult.value.filter((item) => item.status === 'ACTIVE' && ['OVERSEAS', 'OVERSEAS_PARTNER'].includes(item.warehouseType))
: []
if (fbaResult.status === 'rejected') fbaWarehousesError.value = 'FBA 目的仓候选加载失败,请检查仓库主数据或权限'
if (operationResult.status === 'rejected') operationWarehousesError.value = '海外仓候选加载失败,请检查仓库主数据或数据库迁移'
} catch (error) {
fbaWarehouses.value = []
fbaWarehousesError.value = error instanceof Error ? error.message : 'FBA 目的仓加载失败'
ElMessage.error(fbaWarehousesError.value)
} finally { fbaWarehousesLoading.value = false }
}
function warehouseLabel(value: MerchantWarehouse) { return `${value.warehouseName} · ${value.warehouseCode}` }
async function selectTransferWarehouse() {
selectedOperationWarehouse.value = null
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.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 : '中转仓联系信息加载失败')
} finally { operationWarehouseDetailLoading.value = false }
}
function selectTransferInboundMethod(value: string | number | boolean | undefined) {
form.address = value === 'CUSTOMER_DELIVERY' ? operationWarehouseAddress.value : ''
}
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 = []
transferRelabelRows.value = []
skuRelabelRows.value = []
importedRelabelBindings.value = []
selectedRelabelInboundOrderId.value = ''
relabelCandidates.value = []
const warehouse = operationWarehouses.value.find((item) => String(item.id) === form.warehouse)
form.contact = warehouse?.contactName || ''
form.phone = warehouse?.contactPhone || ''
if (!form.warehouse) return
relabelCandidatesLoading.value = true
try { relabelCandidates.value = await listRelabelInventoryCandidates(form.warehouse) }
catch (error) { ElMessage.error(error instanceof Error ? error.message : '当前客户库存加载失败') }
finally { relabelCandidatesLoading.value = false }
}
function selectRelabelInventory(inboundOrderId: string) {
const rows: CargoRow[] = []
for (const candidate of relabelCandidates.value.filter((item) => (item.sourceType === 'RETURN_CARTON' ? `RETURN_CARTON:${item.returnCartonId}` : String(item.inboundOrderId)) === inboundOrderId)) {
if (candidate.sourceType === 'RETURN_CARTON') rows.push({
id: ++cargoSequence, sku: candidate.skuCode || '', name: candidate.productNameZh || '退货合箱库存',
included: true, quantity: 1, availableQuantity: 1, returnCartonId: String(candidate.returnCartonId),
sourceInboundOrderNo: candidate.inboundOrderNo, sourceWarehouseName: candidate.overseasWarehouseName,
fnsku: '', shipmentNo: candidate.inboundOrderNo || candidate.cartonMark || '', newSkuCode: '', shippingMark: '', customerCartonMarks: '',
cartonMark: candidate.cartonMark || '', fbaCartonMark: '', destinationWarehouseId: candidate.destinationFbaWarehouseCode || '', remark: '',
})
else for (const position of candidate.positions) rows.push(...Array.from({ length: Math.min(position.availablePackages, 500) }, () => ({
id: ++cargoSequence, sku: candidate.skuCode || '', name: candidate.productNameZh || '库存货物',
included: false, quantity: 1, availableQuantity: 1,
sourceInventoryBalanceId: String(candidate.inventoryBalanceId), sourceInventoryPositionId: String(position.positionId),
sourceInboundOrderId: String(candidate.inboundOrderId), sourceInboundOrderNo: candidate.inboundOrderNo,
sourceInboundCode: candidate.inboundCode, sourceWarehouseName: candidate.overseasWarehouseName,
palletNo: position.palletNo, warehouseLocationCode: position.warehouseLocationCode,
fnsku: '', shipmentNo: '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', cartonMark: '', fbaCartonMark: '',
destinationWarehouseId: candidate.destinationFbaWarehouseCode || '', remark: '',
})))
}
cargoRows.value = rows.slice(0, 500)
if (cargoRows.value.length) cargoRows.value[0].included = true
skuRelabelRows.value = []
importedRelabelBindings.value = []
shippingLabelFile.value = null
cartonTotalFile.value = null
skuTotalFile.value = null
if (labelingMode.value !== 'carton') addSkuRelabelRow()
}
</script>
<template>
<section class="warehouse-service-page">
<template v-if="mode === 'list'">
<div class="warehouse-breadcrumb"><span>首页</span><b>/</b><strong>{{ config.title }}</strong></div>
<div class="warehouse-page-heading"><h2>{{ config.title }}</h2><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />{{ props.service === 'relabel' ? '新建退货换标单' : props.service === 'transfer' ? '新增订单' : `新增${config.title}` }}</el-button></div>
<div class="warehouse-card warehouse-filter-card">
<div class="warehouse-filter-grid">
<label><span>{{ config.numberLabel }}</span><el-input v-model="filters.number" :placeholder="`请输入${config.numberLabel}`" clearable /></label>
<label v-if="props.service !== 'labeling' && props.service !== 'relabel'"><span>进仓编号</span><el-input v-model="filters.inboundNo" placeholder="请输入进仓编号" clearable /></label>
<label><span>{{ props.service === 'relabel' ? '来源 FBA 仓' : '操作仓' }}</span><el-select v-model="filters.warehouse" clearable :loading="fbaWarehousesLoading" placeholder="全部"><el-option label="全部" value="" /><el-option v-for="warehouse in (props.service === 'relabel' ? fbaWarehouses : operationWarehouses)" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select></label>
<label v-if="props.service === 'transfer' || props.service === 'relabel'"><span>目的 FBA 仓</span><el-input v-model="filters.destination" placeholder="请输入仓代码" clearable /></label>
<label><span>订单状态</span><el-select v-model="filters.status" placeholder="全部状态" clearable><el-option v-for="option in statusOptions" :key="option.value" :label="option.label" :value="option.value" /></el-select></label>
<label><span>创建时间</span><el-date-picker v-model="filters.start" type="date" placeholder="开始日期" value-format="YYYY-MM-DD" /></label>
<label><span>&nbsp;</span><el-date-picker v-model="filters.end" type="date" placeholder="结束日期" value-format="YYYY-MM-DD" /></label>
</div>
<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 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="canCreate && props.service === 'transfer' && row.__status === 'DRAFT'" native-type="button" size="small" type="primary" @click="openEdit(row['0'])">编辑</el-button><el-button v-if="canConfirmTransfer && props.service === 'transfer' && row.__status === 'WAIT_CUSTOMER_CONFIRMATION'" size="small" type="primary" @click="confirmTransferFromList(row['0'])">确认测量数据</el-button><el-button v-if="canConfirmRelabel && 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>
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" :hide-on-single-page="false" layout="prev, pager, next" @current-change="changePage" />
</div>
</div>
</template>
<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>
<label v-if="props.service === 'disposal'" class="warehouse-span-2"><span><i>*</i>选择入库单</span><el-select v-model="selectedDiscardOrderId" filterable clearable :loading="discardCandidatesLoading" :disabled="!form.warehouse" no-data-text="当前海外仓暂无可废弃库存" placeholder="请先选择操作仓,再选择有可用库存的入库单" @change="selectDiscardInbound"><el-option v-for="candidate in discardCandidates" :key="candidate.orderId" :label="`${candidate.inboundOrderNo || candidate.inboundCode || '—'}|${candidate.inboundCode || '—'}|可用 ${candidate.availablePackages} 箱`" :value="candidate.orderId" /></el-select><small class="warehouse-hint">仅显示当前操作仓下一件代发中仍有可用箱数的入库单。</small></label>
<label v-if="props.service === 'relabel'"><span><i>*</i>进仓编号</span><el-input v-model="form.inboundNo" placeholder="请输入已有入仓编号" /></label>
<label v-if="props.service === 'relabel'"><span><i>*</i>来源 FBA 仓库</span><el-select v-model="form.sourceWarehouse" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" placeholder="请选择平台维护的 FBA 仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></label>
<label v-else-if="props.service === 'disposal'"><span>进仓编号</span><el-input v-model="form.inboundNo" readonly /></label>
<label v-else><span><i>*</i>{{ props.service === 'labeling' ? '海外操作仓(中转仓)' : props.service === 'transfer' ? '入库海外仓' : '操作仓' }}</span><el-select v-model="form.warehouse" :loading="fbaWarehousesLoading || operationWarehouseDetailLoading" :disabled="Boolean(operationWarehousesError)" clearable :placeholder="props.service === 'transfer' ? '请选择海外仓' : '请选择国外中转仓'" @change="props.service === 'labeling' ? selectOperationWarehouse() : props.service === 'transfer' && selectTransferWarehouse()"><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-if="props.service === 'labeling'" class="warehouse-hint">选择后仅查询当前客户在该中转仓的可用库存。</small><small v-else-if="props.service === 'transfer'" class="warehouse-hint">本批货物将先进入该海外仓完成签收和入库。</small></label>
<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>
<label v-if="props.service === 'labeling' && labelingMode !== 'sku'"><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>
<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.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>{{ 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>
<label v-if="props.service === 'relabel' || props.service === 'disposal'"><span><i>*</i>{{ props.service === 'relabel' ? '退货原因' : '废弃原因' }}</span><el-select v-model="form.reason" placeholder="请选择"><el-option label="客户退货" value="客户退货" /><el-option label="亚马逊移除" value="亚马逊移除" /><el-option label="残损不可售" value="残损不可售" /></el-select></label>
<label v-if="props.service === 'labeling'"><span><i>*</i>海外仓签收联系人<em v-if="form.warehouse && !form.contact" class="warehouse-unmaintained">未维护</em></span><el-input v-model="form.contact" placeholder="选择海外操作仓后自动带出" /></label>
<label v-if="props.service === 'labeling'"><span><i>*</i>海外仓联系电话<em v-if="form.warehouse && !form.phone" class="warehouse-unmaintained">未维护</em></span><el-input v-model="form.phone" placeholder="选择海外操作仓后自动带出" /></label>
<label v-if="props.service === 'labeling'"><span><i>*</i>贴标截止时间</span><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择截止时间" /></label>
<label v-if="props.service === 'labeling' && labelingMode !== 'sku'"><span><i>*</i>换标后派送方式</span><el-select v-model="form.deliveryMethod" placeholder="请选择派送方式"><el-option label="卡车派送" value="TRUCK" /><el-option label="快递派送" value="EXPRESS" /><el-option label="卡车自提" value="TRUCK_PICKUP" /><el-option label="快递自提" value="EXPRESS_PICKUP" /></el-select><small class="warehouse-hint">卡车派送进入预排车;其余方式确认换标完成后进入换标出库。</small></label>
<div v-if="props.service === 'labeling' && labelingMode !== 'sku' && (form.deliveryMethod === 'EXPRESS' || form.deliveryMethod === 'EXPRESS_PICKUP')" class="warehouse-checkbox-field"><span>是否打印面单</span><el-checkbox v-model="form.relabel">需要仓库打印面单</el-checkbox></div>
<label v-if="canManageRelabelFiles && props.service === 'labeling' && labelingMode !== 'sku' && (form.deliveryMethod === 'EXPRESS' || form.deliveryMethod === 'EXPRESS_PICKUP') && form.relabel"><span><i>*</i>上传面单文件</span><input class="warehouse-native-file" type="file" accept="application/pdf,image/*,.pdf" @change="selectShippingLabel"><small class="warehouse-hint">支持 PDF 或图片,最大 20 MB。</small></label>
<label v-if="props.service === 'relabel'" class="warehouse-span-2"><span><i>*</i>详细派送地址</span><el-input v-model="form.address" placeholder="请输入派送地址" /></label>
<label v-if="props.service === 'transfer' || props.service === 'relabel'"><span><i>*</i>预估总体积(CBM)</span><el-input-number v-model="form.volumeCbm" :min="0" :precision="4" :step="0.0001" controls-position="right" placeholder="请输入预估总体积" /></label>
<label v-if="props.service === 'transfer' || props.service === 'relabel'"><span><i>*</i>{{ props.service === 'transfer' ? '预计入仓时间' : '预计到达时间' }}</span><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :placeholder="props.service === 'transfer' ? '请选择预计入仓时间' : '请选择预计到达时间'" /></label>
<label v-if="props.service === 'transfer'"><span>预计发货时间</span><el-date-picker v-model="form.estimatedShipAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="选填,可线下确认后补充" /><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>
<label class="warehouse-span-2"><span>订单备注</span><el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入备注信息" /></label>
<div v-if="props.service === 'transfer'" class="warehouse-choice-field warehouse-span-2"><span><i>*</i>是否需要换标</span><el-radio-group v-model="form.relabel"><el-radio-button :value="false">不需要换标</el-radio-button><el-radio-button :value="true">需要换标</el-radio-button></el-radio-group><small>{{ form.relabel ? '本次提交将同时登记换标申请,入库完成后自动进入换标作业。' : '入库完成且已有目的仓后,可直接进入排车流程。' }}</small></div>
</div>
</div>
<div class="warehouse-card warehouse-form-section warehouse-transfer-cargo-card">
<template v-if="props.service === 'labeling'">
<div class="warehouse-section-heading warehouse-labeling-heading">
<h3>货物明细</h3>
<div class="warehouse-template-actions">
<el-button v-if="canManageRelabelFiles" size="small" @click="chooseCreateTotalFile('CARTON_TOTAL')"><Upload :size="15" />上传箱唛总文件</el-button>
<el-button v-if="canManageRelabelFiles" size="small" @click="chooseCreateTotalFile('SKU_TOTAL')"><Upload :size="15" />上传 SKU 总文件</el-button>
<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')">
<input ref="cartonTotalCreateInput" class="warehouse-hidden-input" type="file" accept="application/pdf,.pdf" @change="selectCartonTotalFile">
<input ref="skuTotalCreateInput" class="warehouse-hidden-input" type="file" accept="application/pdf,.pdf" @change="selectSkuTotalFile">
</div>
</div>
<div v-if="canManageRelabelFiles" 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="换标类型">
<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></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>上传后在下方回显,也可以手动添加;SKU 与箱号关联通过仓库唛头合并。系统只提供大概库存位置,不代表箱唛已绑定具体托盘。</p></div>
<div v-if="labelingMode === 'carton'"><el-button type="primary" size="small" @click="addRelabelCargoRow"><Plus :size="15" />手动添加行</el-button><el-button size="small" @click="clearRelabelRows">清空</el-button></div>
<div v-else><el-button type="primary" size="small" @click="addSkuRelabelRow"><Plus :size="15" />添加 SKU 明细</el-button><el-button size="small" @click="skuRelabelRows = []">清空</el-button></div>
</div>
<div v-if="labelingMode === 'carton'" class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-label-table"><thead><tr><th>商品 SKU</th><th>中文品名 *</th><th>FBA 货件编号 *</th><th>箱数</th><th>仓库唛头 *</th><th>新 FBA 箱唛 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in cartonDetailRows" :key="row.id"><td><el-input v-model="row.sku" placeholder="选填" /></td><td><el-input v-model="row.name" placeholder="请输入中文品名" /></td><td><el-input v-model="row.shipmentNo" placeholder="请输入FBA货件编号" /></td><td><strong>1 箱</strong></td><td><el-input v-model="row.cartonMark" placeholder="请输入仓库唛头" /></td><td><el-input v-model="row.fbaCartonMark" placeholder="请输入新的 FBA 箱唛" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" maxlength="1000" show-word-limit placeholder="请输入明细备注" /></td><td><button type="button" class="warehouse-delete" title="移出本次明细" @click="removeCargoRow(row.id)"><Trash2 :size="16" />移出</button></td></tr><tr v-if="cartonDetailRows.length === 0"><td colspan="8"><div class="warehouse-empty">暂无本次换标明细,请上传关系后导入或手动添加</div></td></tr></tbody></table></div>
<div v-else class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-sku-label-table"><thead><tr><th>仓库唛头 *</th><th>旧 SKU *</th><th>新 SKU *</th><th>换标数量(件)*</th><th v-if="labelingMode === 'carton_and_sku'">新 FBA 箱唛 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in skuRelabelRows" :key="row.id"><td><el-input v-model="row.cartonMark" placeholder="请输入仓库唛头" @change="applyExistingCartonBinding(row)" /></td><td><el-input v-model="row.originalSku" placeholder="请输入旧 SKU" @change="applyExistingSkuBinding(row)" /></td><td><el-input v-model="row.newSku" placeholder="请输入新 SKU" /></td><td><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" placeholder="请输入件数" /></td><td v-if="labelingMode === 'carton_and_sku'"><el-input v-model="row.fbaCartonMark" placeholder="请输入新的 FBA 箱唛" /></td><td><el-input v-model="row.remark" placeholder="请输入备注" /></td><td><button type="button" class="warehouse-delete" title="删除" @click="removeSkuRelabelRow(row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="skuRelabelRows.length === 0"><td :colspan="labelingMode === 'carton_and_sku' ? 7 : 6"><div class="warehouse-empty">暂无绑定明细,请点击“添加 SKU 明细”</div></td></tr></tbody></table></div>
</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{{ 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.relabelContact" placeholder="选择海外仓后自动带出,也可手动填写" maxlength="128" /></label><label><span><i>*</i>换标联系人电话</span><el-input v-model="form.relabelPhone" placeholder="选择海外仓后自动带出,也可手动填写" maxlength="64" /></label><label v-if="labelingMode !== 'sku'"><span><i>*</i>换标后派送方式</span><el-select v-model="form.relabelDeliveryMethod" placeholder="请选择派送方式"><el-option label="卡车派送" value="TRUCK" /><el-option label="快递派送" value="EXPRESS" /><el-option label="卡车自提" value="TRUCK_PICKUP" /><el-option label="快递自提" value="EXPRESS_PICKUP" /></el-select></label><label class="warehouse-span-2"><span><i>*</i>整票换标货物目的 FBA 仓</span><el-select v-model="transferRelabelDefaultDestination" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" filterable placeholder="请选择整票货物的目的仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select><small class="warehouse-hint">本票所有换标货物统一前往该目的仓,暂不支持一票多个目的仓。</small></label><label class="warehouse-span-2"><span>贴标要求</span><el-input v-model="form.requirement" type="textarea" :rows="2" maxlength="1000" show-word-limit placeholder="请输入贴标要求(选填)" /></label></div>
<div class="warehouse-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></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>备注</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">暂无绑定明细,请点击“添加换标明细”</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>备注</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 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="canCancelDiscard && props.service === 'disposal' && selectedDetail?.status === 'PENDING_REVIEW'" type="danger" @click="cancelSelectedDiscard">取消申请</el-button><el-button v-if="canCreate && props.service === 'transfer' && selectedDetail?.status === 'DRAFT'" type="primary" @click="openEdit(String(selectedDetail?.transferNo || selectedDetail?.['0'] || ''))">编辑</el-button><el-button v-if="canConfirmTransfer && props.service === 'transfer' && selectedDetail?.status === 'WAIT_CUSTOMER_CONFIRMATION'" type="primary" @click="confirmTransferMeasurements">确认测量数据</el-button><el-button @click="mode = 'list'"><ArrowLeft :size="16" />返回列表</el-button></div></div>
<div class="warehouse-card warehouse-form-section"><h3>订单概览</h3><div v-if="props.service === 'transfer'" class="warehouse-detail-grid"><div><span>转运单号</span><strong>{{ selectedDetail?.transferNo || '—' }}</strong></div><div><span>入库箱数</span><strong>{{ selectedDetail?.packageCount ?? '—' }}</strong></div><div><span>海外仓</span><strong>{{ warehouseDisplay(selectedDetail?.operatingWarehouseId) }}</strong></div><div><span>FBA 目的仓</span><strong>{{ selectedDetail?.destinationWarehouseName || warehouseDisplay(selectedDetail?.destinationWarehouseCode || selectedDetail?.fbaWarehouseCode) }}</strong></div><div><span>入仓方式</span><strong>{{ deliveryMethodLabel(selectedDetail?.deliveryMethod || '') }}</strong></div><div><span>是否换标</span><strong>{{ selectedDetail?.needRelabel ? '是' : '否' }}</strong></div><div><span>状态</span><strong>{{ statusLabel(selectedDetail?.status || '') }}</strong></div><div><span>创建时间</span><strong>{{ formatDateTime(selectedDetail?.createdAt) }}</strong></div></div><div v-else class="warehouse-detail-grid"><div v-for="(column, index) in config.columns.slice(1)" :key="column"><span>{{ column }}</span><strong>{{ selectedDetail?.[String(index)] || '—' }}</strong></div></div></div>
<div v-if="props.service === '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' && selectedDetail?.needRelabel" class="warehouse-card warehouse-form-section">
<h3>换标箱级去向</h3>
<div class="warehouse-table-scroll"><table><thead><tr><th>换标类型</th><th>客户箱唛号</th><th>旧 SKU</th><th>新 SKU</th><th>新 FBA 箱唛</th><th>实际 FBA 目的仓</th><th>数量</th><th>备注</th></tr></thead><tbody><tr v-for="(binding, index) in selectedDetail?.relabelBindings || []" :key="`${binding.customerCartonMark}-${index}`"><td>{{ binding.operationType === 'CARTON_AND_SKU' ? 'SKU 和箱唛都换' : binding.operationType === 'SKU_LABEL' ? '只换 SKU' : '只换箱唛' }}</td><td>{{ binding.customerCartonMark || '—' }}</td><td>{{ binding.originalSku || '—' }}</td><td>{{ binding.newSku || '—' }}</td><td>{{ binding.fbaCartonMark || '—' }}</td><td>{{ binding.operationType === 'SKU_LABEL' ? '沿用原箱去向' : warehouseDisplay(binding.destinationWarehouseCode) }}</td><td>{{ binding.quantity ?? '—' }}</td><td>{{ binding.remark || '—' }}</td></tr><tr v-if="!selectedDetail?.relabelBindings?.length"><td colspan="8"><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' && canManageRelabelFiles" class="warehouse-card warehouse-form-section">
<div class="warehouse-section-heading"><h3>换标完成与出库</h3></div>
<div class="warehouse-detail-grid warehouse-fulfillment-grid">
<div><span>派送方式</span><strong>{{ deliveryMethodLabel(selectedRelabelDetail?.application.deliveryMethod || '') || '无需出库' }}</strong></div>
<div><span>履约状态</span><strong>{{ selectedRelabelDetail?.application.fulfillmentStatus || '—' }}</strong></div>
<div><span>客户确认时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.customerConfirmedAt) }}</strong></div>
</div>
<div class="warehouse-evidence"><span>仓库完工照片</span><div v-if="selectedRelabelDetail?.completionEvidence?.photoFileIds?.length" class="warehouse-photo-grid"><div v-for="(fileId, index) in selectedRelabelDetail.completionEvidence.photoFileIds" :key="fileId" class="warehouse-photo-item"><el-image v-if="completionPhotoUrls[fileId]" :src="completionPhotoUrls[fileId]" :preview-src-list="completionPhotoPreviewUrls" :initial-index="completionPhotoPreviewUrls.indexOf(completionPhotoUrls[fileId])" fit="cover" preview-teleported /><div v-else-if="completionPhotoLoading[fileId]" class="warehouse-photo-placeholder">照片 {{ index + 1 }} 加载中…</div><button v-else class="warehouse-photo-placeholder warehouse-photo-retry" type="button" @click="loadCompletionPhoto(fileId)">{{ completionPhotoErrors[fileId] ? `照片 ${index + 1} 加载失败,点击重试` : `加载照片 ${index + 1}` }}</button></div></div><strong v-else>仓库尚未上传完工照片</strong></div>
<p v-if="selectedRelabelDetail?.completionEvidence?.completionRemark" class="warehouse-completion-remark">完工备注:{{ selectedRelabelDetail.completionEvidence.completionRemark }}</p>
</div>
<div v-if="props.service === 'labeling'" class="warehouse-card warehouse-form-section">
<div class="warehouse-section-heading"><h3>贴标明细资料</h3><input ref="relabelFileInput" class="warehouse-hidden-input" type="file" accept="application/pdf,.pdf" @change="uploadSelectedRelabelFile"></div>
<div class="warehouse-hint">需要补充或替换资料时,可在对应明细行上传 SKU 或箱唛文件。</div>
<div class="warehouse-table-scroll"><table class="warehouse-relabel-detail-table"><thead><tr><th>换标模式</th><th>仓库唛头</th><th>SKU</th><th>明细文件</th><th>操作</th></tr></thead><tbody>
<tr v-for="item in selectedRelabelDetail?.items || []" :key="item.id"><td>{{ item.operationType === 'CARTON_AND_SKU' ? '箱唛 + SKU 换标' : item.operationType === 'SKU_LABEL' ? 'SKU 换标' : '箱唛换标' }}</td><td>{{ item.cartonMark || '—' }}</td><td>{{ item.skuCode || '—' }}</td><td>{{ item.relabelFileName || '未上传' }}</td><td><el-button size="small" @click="chooseRelabelFile('ITEM', String(item.id))"><Upload :size="14" />上传明细文件</el-button></td></tr>
<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>
<tr v-for="item in selectedRelabelDetail?.items || []" :key="item.id"><td>{{ item.operationType === 'CARTON_AND_SKU' ? '箱唛 + SKU 换标' : item.operationType === 'SKU_LABEL' ? 'SKU 换标' : '箱唛换标' }}</td><td><strong>{{ item.cartonMark || '—' }}</strong></td><td><strong>{{ item.fbaCartonMark || '—' }}</strong></td><td>{{ warehouseDisplay(item.destinationFbaWarehouseCode) }}</td><td>{{ item.totalPackageCount }} 箱</td><td>{{ item.skuCode || '—' }}</td><td><template v-if="item.skuItems?.length"><div v-for="skuItem in item.skuItems" :key="`${skuItem.skuCode}-${skuItem.newSkuCode}-${skuItem.quantity}`">{{ skuItem.skuCode }} → {{ skuItem.newSkuCode }} × {{ skuItem.quantity }} 件</div></template><span v-else>{{ item.newSkuCode || '—' }}</span></td><td>{{ item.shipmentNo || '—' }}</td><td>{{ item.labelRequirement || '—' }}</td><td>{{ item.remark || '—' }}</td></tr>
<tr v-if="!selectedRelabelDetail?.items?.length"><td colspan="10"><div class="warehouse-empty">暂无换标任务明细</div></td></tr>
</tbody></table></div>
</div>
</template>
</section>
</template>
<style scoped>
.warehouse-service-page{color:#25324a}.warehouse-breadcrumb{display:flex;align-items:center;gap:9px;margin:0 0 14px;color:#9aa3b2;font-size:12px}.warehouse-breadcrumb b{font-weight:400}.warehouse-breadcrumb strong{color:#606b7c}.warehouse-breadcrumb button{padding:0;border:0;background:transparent;color:#4376b8;cursor:pointer}.warehouse-page-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.warehouse-page-heading h2{margin:0;color:#17233a;font-size:22px;letter-spacing:0}.warehouse-card{margin-bottom:12px;border:1px solid #e7ebf1;border-radius:4px;background:#fff;box-shadow:0 1px 3px rgba(25,42,70,.04)}.warehouse-filter-card{padding:20px 22px 16px}.warehouse-filter-grid{display:grid;grid-template-columns:repeat(4,minmax(180px,1fr));gap:16px 20px}.warehouse-filter-grid label,.warehouse-form-grid label{display:flex;min-width:0;flex-direction:column;gap:7px}.warehouse-filter-grid label>span,.warehouse-form-grid label>span{color:#596579;font-size:12px}.warehouse-filter-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:18px}.warehouse-table-card{overflow:hidden}.warehouse-table-scroll{overflow-x:auto}table{width:100%;min-width:940px;border-collapse:collapse;font-size:12px}th{height:44px;padding:0 14px;border-bottom:1px solid #e8edf3;background:#f7f9fc;color:#4d5a6e;font-weight:650;text-align:left;white-space:nowrap}td{height:48px;padding:8px 14px;border-bottom:1px solid #eef1f5;color:#526176;white-space:nowrap}.warehouse-empty{display:grid;height:150px;color:#a0a9b7;place-items:center}.warehouse-link,.warehouse-delete{display:inline-flex;align-items:center;gap:5px;padding:4px;border:0;background:transparent;color:#2176d2;cursor:pointer}.warehouse-delete{color:#d64545}.warehouse-pagination{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:14px 18px;color:#778398;font-size:12px}.warehouse-pagination span{margin-right:8px}.warehouse-page-number{width:28px;height:28px;border:1px solid #2176d2;border-radius:3px;background:#2176d2;color:#fff}.warehouse-form-section{padding:22px}.warehouse-form-section h3{margin:0 0 18px;padding-bottom:12px;border-bottom:1px solid #edf0f4;color:#26364f;font-size:15px;letter-spacing:0}.warehouse-form-grid{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr));gap:18px 28px}.warehouse-form-grid i{margin-right:3px;color:#e24b4b;font-style:normal}.warehouse-span-2{grid-column:1/-1}.warehouse-section-heading{display:flex;align-items:center;justify-content:space-between}.warehouse-section-heading h3{flex:1}.warehouse-edit-table td{padding:9px 10px}.warehouse-edit-table :deep(.el-input-number){width:130px}.warehouse-hint{margin:12px 0 0;color:#8792a2;font-size:12px}.warehouse-submit-bar{display:flex;justify-content:center;gap:10px;padding:12px 0 30px}.warehouse-detail-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:24px}.warehouse-detail-grid div{display:flex;flex-direction:column;gap:7px}.warehouse-detail-grid span{color:#8a95a5;font-size:12px}.warehouse-detail-grid strong{color:#334157;font-size:13px}:deep(.el-button){border-radius:4px}:deep(.el-button span){display:inline-flex;align-items:center;gap:6px}:deep(.el-select),:deep(.el-date-editor.el-input){width:100%}@media(max-width:1100px){.warehouse-filter-grid{grid-template-columns:repeat(2,1fr)}.warehouse-detail-grid{grid-template-columns:repeat(2,1fr)}}
.warehouse-checkbox-field{display:flex;min-height:32px;align-items:center;justify-content:flex-start;gap:16px}.warehouse-checkbox-field>span{color:#596579;font-size:12px}.warehouse-checkbox-field :deep(.el-checkbox){margin-right:0}
.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}
@media(max-width:1250px){.warehouse-transfer-form--relabel{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.warehouse-transfer-form .warehouse-template-actions{justify-content:flex-start}.warehouse-transfer-form .warehouse-labeling-heading{align-items:flex-start;flex-direction:column}}
.warehouse-transfer-form .pickup-address-field{grid-column:span 1}
/* Keep the count and controls visible together on narrow list pages. */
.warehouse-pagination { justify-content: flex-end; }
.warehouse-pagination-total { color: #526176; font-size: 14px; }
.warehouse-pagination :deep(.el-pagination) { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; margin: 0; }
.warehouse-page-size-select { width: 112px; margin-left: 8px; }
.warehouse-page-size-select :deep(.el-input__inner), .warehouse-page-size-select :deep(.el-select__selected-item) { color: #526176 !important; opacity: 1 !important; -webkit-text-fill-color: #526176; }
.warehouse-list-actions { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
@media (max-width: 720px) {
.warehouse-pagination :deep(.el-pagination) { width: 100%; justify-content: flex-end; }
}
</style>