2 Commits
Author SHA1 Message Date
liuyihe f11d85e280 Merge branch 'dev' of http://8.163.30.128:10082/html/client-web into dev
# Conflicts:
#	src/modules/overseas-warehouse/api/warehouseServices.ts
#	src/modules/overseas-warehouse/views/WarehouseServiceView.vue
2026-09-06 15:59:14 +08:00
liuyihe 6c4af419cf 优化 2026-09-06 15:53:56 +08:00
7 changed files with 131 additions and 32 deletions
+1
View File
@@ -126,6 +126,7 @@ export interface FirstLegOrderSaveRequest {
inboundCode?: string
customerName: string
fulfillmentType?: 'STANDARD' | 'ONE_CLICK_DROPSHIP'
lxCode?: string
transportMode: ApiTransportMode
tradeTerm?: string
destinationCountryCode: string
@@ -73,6 +73,7 @@ function savePayload(draft: FirstLegOrderDraft, warehouse: ClientWarehouse, cust
inboundCode: draft.inboundCode?.trim() || undefined,
customerName: customerName.trim(),
fulfillmentType: draft.fulfillmentType ?? 'STANDARD',
lxCode: draft.fulfillmentType === 'ONE_CLICK_DROPSHIP' ? draft.lxCode?.trim() || undefined : undefined,
transportMode: apiTransport(draft),
tradeTerm: draft.tradeTerm || undefined,
destinationCountryCode: draft.destinationCountryCode.trim().toUpperCase(),
@@ -100,6 +100,7 @@ export interface FirstLegOrder {
transportMode: FirstLegTransportMode | null
businessType: FirstLegBusinessType
fulfillmentType?: 'STANDARD' | 'ONE_CLICK_DROPSHIP'
lxCode?: string
origin: string
destination: string
receivingWarehouseId: string
@@ -159,6 +160,7 @@ export type FirstLegOrderDraft = Omit<Pick<
| 'transportMode'
| 'businessType'
| 'fulfillmentType'
| 'lxCode'
| 'origin'
| 'destination'
| 'receivingWarehouseId'
@@ -339,6 +339,7 @@ function emptyDraft(): FirstLegOrderDraft {
transportMode: 'FCL',
businessType: 'EXPORT',
fulfillmentType: 'STANDARD',
lxCode: '',
origin: '',
destination: '',
receivingWarehouseId: '',
@@ -753,6 +754,8 @@ function draftFromOrder(order: FirstLegOrder): FirstLegOrderDraft {
referenceNo: order.referenceNo,
transportMode: order.transportMode ?? 'FCL',
businessType: order.businessType,
fulfillmentType: order.fulfillmentType ?? 'STANDARD',
lxCode: order.lxCode ?? '',
origin: order.origin,
destination: order.destination,
receivingWarehouseId: order.receivingWarehouseId,
@@ -1895,6 +1898,7 @@ async function searchOrders(model: QueryModel) {
<div class="order-section-heading"><div><h3>客户下单</h3><p>先选择货物如何送到国内收货仓,并填写本次运输安排。</p></div></div>
<div class="prototype-form-flow shipping-form-flow">
<el-form-item label="履约类型" prop="fulfillmentType" required class="prototype-form-row prototype-form-row--tabs"><el-radio-group v-model="form.fulfillmentType" class="segmented-options"><el-radio-button value="STANDARD">普通头程</el-radio-button><el-radio-button value="ONE_CLICK_DROPSHIP">一件代发</el-radio-button></el-radio-group><small v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" class="field-guidance">测量完成后不办理国内库存入库,将直接推送 PDA 合托。</small></el-form-item>
<el-form-item v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" label="LX 代码" prop="lxCode" class="prototype-form-row prototype-form-row--wide"><el-input v-model="form.lxCode" maxlength="128" placeholder="请输入领星入库单 LX 代码" /><small class="field-guidance">用于绑定领星入库单,提交后可在尾程订单、入库单和出库单中查询。</small></el-form-item>
<el-form-item label="取货方式" prop="pickupMethod" required class="prototype-form-row prototype-form-row--tabs"><el-radio-group v-model="form.pickupMethod" class="segmented-options" @change="changePickupMethod"><el-radio-button value="SELF_EXPRESS">快递自送</el-radio-button><el-radio-button value="PICKUP">我司提货</el-radio-button><el-radio-button value="SELF_TRUCK">卡车自送</el-radio-button></el-radio-group></el-form-item>
<template v-if="form.pickupMethod === 'PICKUP' && form.shipperPartySnapshot">
<el-form-item label="企业提货地址簿" prop="senderAddressId" required :rules="requiredSelectRules('请选择企业提货地址')" class="prototype-form-row prototype-form-row--wide address-book-row">
@@ -14,9 +14,10 @@ export interface TransferCreate {
items: Array<{ skuCode: string; productNameZh: string; packageCount: number; shippingMark?: string }>
}
export interface RelabelCreate {
customerOrderNo: string; overseasWarehouseId: string; labelDeadline: string; contactName: string; contactPhone: string
chargeAmount: number; remark?: string; idempotencyKey: string
items: Array<{ skuCode: string; productNameZh: string; totalPackageCount: number; notifiedPackageCount: number; shipmentNo: string; trackingCode?: string; newSkuCode: string; labelRequirement?: string }>
customerOrderNo: string; overseasWarehouseId?: string; labelDeadline: string; contactName: string; contactPhone: string; chargeAmount: number
remark?: string; idempotencyKey: string
items: Array<{ skuCode?: string; productNameZh: string; totalPackageCount: number; notifiedPackageCount: number; shipmentNo: string; trackingCode?: string; newSkuCode: string; labelRequirement?: string
cartonMark: string; fbaCartonMark: string; destinationWarehouseId?: string; remark?: string }>
}
export function listLastlegOrders(inboundCode: string) {
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Eye, Plus, Search, Trash2 } from '@lucide/vue'
import { ArrowLeft, Download, Eye, Plus, Search, Trash2, Upload } from '@lucide/vue'
import {
createDiscard, createRelabel, createTransfer, getDiscard, getRelabel, getTransfer,
listDiscardCandidates, listDiscards, listLastlegOrders, listRelabels, listTransfers,
@@ -9,15 +9,15 @@ import {
type ServiceType = 'labeling' | 'transfer' | 'disposal' | 'relabel'
type PageMode = 'list' | 'create' | 'detail'
interface CargoRow { id: number; sku: string; name: string; quantity: number; availableQuantity?: number; sourceOrderId?: string; fnsku: string; shipmentNo: string; newSkuCode: string; shippingMark: string }
interface CargoRow { id: number; sku: string; name: string; quantity: number; availableQuantity?: number; sourceOrderId?: string; fnsku: string; shipmentNo: string; newSkuCode: string; shippingMark: string; cartonMark: string; fbaCartonMark: string; destinationWarehouseId: string; remark: string }
interface ServiceConfig { title: string; createTitle: string; numberLabel: string; emptyText: string; columns: string[] }
const props = defineProps<{ service: ServiceType }>()
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 汇总', '退件总数', '是否换标', '换标状态', '尾程状态', '创建时间'] },
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'
@@ -36,6 +36,7 @@ const selectedDiscardOrderId = ref('')
const discardCandidatesLoading = ref(false)
const loading = ref(false)
const saving = ref(false)
const templateInput = ref<HTMLInputElement | null>(null)
let cargoSequence = 0
watch(() => props.service, () => { mode.value = 'list'; resetFilters(); resetForm(); void load() })
@@ -55,8 +56,47 @@ async function openCreate() {
catch (error) { ElMessage.error(error instanceof Error ? error.message : '可废弃入库单加载失败') }
finally { discardCandidatesLoading.value = false }
}
function addCargoRow() { cargoRows.value.push({ id: ++cargoSequence, sku: '', name: '', quantity: 1, fnsku: '', shipmentNo: '', newSkuCode: '', shippingMark: '' }) }
function addCargoRow() { cargoRows.value.push({ id: ++cargoSequence, sku: '', name: '', quantity: 1, fnsku: '', shipmentNo: '', newSkuCode: '', shippingMark: '', cartonMark: '', fbaCartonMark: '', destinationWarehouseId: '', remark: '' }) }
function removeCargoRow(id: number) { cargoRows.value = cargoRows.value.filter((row) => row.id !== id) }
function csvCell(value: string) { return `"${value.replaceAll('"', '""')}"` }
function downloadRelabelTemplate() {
const header = ['商品SKU(选填)', '中文品名', 'FBA货件编号', '换标新SKU', '我方箱唛', 'FBA箱唛', '目的仓', '备注']
const example = ['', '示例商品', 'FBA-SHIPMENT-001', 'LX1000-NEW', 'LX1000箱唛', 'FBA-LX1000箱唛', 'ONT8', '']
const blob = new Blob([`\uFEFF${header.map(csvCell).join(',')}\r\n${example.map(csvCell).join(',')}\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)
}
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) {
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]); const required = ['中文品名', 'FBA货件编号', '换标新SKU', '我方箱唛', 'FBA箱唛']
const skuHeader = headers.includes('商品SKU(选填)') ? '商品SKU(选填)' : '商品SKU'
if (!headers.includes(skuHeader)) { ElMessage.error('模板格式不正确,请重新下载模板填写'); return }
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) => required.some((key) => item[key]))
if (!imported.length || imported.some((item) => required.some((key) => !item[key]))) { ElMessage.error('模板中的商品、货件、换标 SKU 和箱唛信息必须填写完整'); return }
if (new Set(imported.map((item) => item['我方箱唛'])).size !== imported.length) { ElMessage.error('模板中的我方箱唛不能重复'); return }
cargoRows.value = imported.map((item) => ({
id: ++cargoSequence, sku: item[skuHeader], name: item['中文品名'], quantity: 1, fnsku: '',
shipmentNo: item['FBA货件编号'], newSkuCode: item['换标新SKU'], shippingMark: '',
cartonMark: item['我方箱唛'], fbaCartonMark: item['FBA箱唛'],
destinationWarehouseId: item['目的仓'] || '', remark: item['备注'] || '',
}))
ElMessage.success(`已导入 ${cargoRows.value.length} 箱货物明细`)
}
function selectDiscardInbound(orderId: string) {
const candidate = discardCandidates.value.find((item) => item.orderId === orderId)
if (!candidate) { cargoRows.value = []; return }
@@ -65,28 +105,33 @@ function selectDiscardInbound(orderId: string) {
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: '',
fnsku: '', shipmentNo: '', newSkuCode: '', shippingMark: '', 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 statusLabel(value: string) { return statusOptions.value.find((option) => option.value === value)?.label || value || '—' }
function deliveryMethodLabel(value: string) { return ({ TRUCK: '卡车派送', TRUCK_DELIVERY: '卡车派送', EXPRESS: '快递派送', EXPRESS_DELIVERY: '快递派送' } as Record<string, string>)[value] || value || '—' }
function formatDateTime(value: string) { return value ? value.replace('T', ' ').replace(/Z$/, '').slice(0, 19) : '—' }
function warehouseDisplay(value?: string) {
if (!value) return '—'
const labels: Record<string, string> = { ONT8: '美西海外仓-ONT8', ABE8: '美东海外仓-ABE8', MDW2: '美中海外仓-MDW2' }
return value.split(',').map((warehouse) => labels[warehouse] || warehouse).join('、')
}
async function load() {
loading.value = true
try {
if (props.service === 'labeling') {
const result = await listRelabels({ page: 1, size: 20, relabelNo: filters.number || undefined, overseasWarehouseId: filters.warehouse || undefined, status: filters.status || undefined, createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true) })
submittedRows.value = result.records.map((item) => row([item.relabelNo, item.customerOrderNo, '—', '—', item.itemCount, item.totalPackageCount, statusLabel(item.status), formatDateTime(item.createdAt)]))
submittedRows.value = result.records.map((item) => {
const destinationWarehouses = warehouseDisplay(item.overseasWarehouseId)
return row([item.relabelNo, item.customerOrderNo, destinationWarehouses, '—', item.itemCount, item.totalPackageCount, item.status, item.createdAt])
})
} else if (props.service === 'disposal') {
const result = await listDiscards({ page: 1, size: 20, 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) })
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)]))
submittedRows.value = result.records.map((item) => row([item.discardNo, item.inboundCodes, item.overseasWarehouseName || '—', '—', item.totalPackages, `${item.currency} ${item.totalFee}`, item.status, item.createdAt]))
} else {
const result = await listTransfers({ page: 1, size: 20, transferNo: filters.number || undefined, inboundCode: filters.inboundNo || undefined, operatingWarehouseId: filters.warehouse || undefined, fbaWarehouseCode: filters.destination || undefined, status: filters.status || undefined, needRelabel: props.service === 'relabel', createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true) })
submittedRows.value = result.records.map((item) => props.service === 'transfer'
? row([item.transferNo, item.inboundCode, item.packageCount, '—', item.fbaWarehouseCode, deliveryMethodLabel(item.deliveryMethod), item.needRelabel ? '是' : '否', statusLabel(item.status), formatDateTime(item.createdAt)])
: row([item.transferNo, '—', item.fbaWarehouseCode, '—', item.packageCount, '是', item.relabelNo ? '已生成' : '待生成', statusLabel(item.status), formatDateTime(item.createdAt)]))
? row([item.transferNo, item.inboundCode, item.packageCount, '—', item.fbaWarehouseCode, item.deliveryMethod, item.needRelabel ? '是' : '否', item.status, item.createdAt])
: row([item.transferNo, '—', item.fbaWarehouseCode, '—', item.packageCount, '是', item.relabelNo ? '已生成' : '待生成', item.status, item.createdAt]))
}
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '列表加载失败') }
finally { loading.value = false }
@@ -97,12 +142,18 @@ async function submit() {
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))
: cargoRows.value.length === 0 || cargoRows.value.some((item) => !item.sku || !item.name || item.quantity < 1)
if ((!form.customerNo && !form.inboundNo) || !primaryWarehouse || invalidCargo) {
: cargoRows.value.length === 0 || cargoRows.value.some((item) => (props.service !== 'labeling' && !item.sku) || !item.name || item.quantity < 1)
if ((!form.customerNo && !form.inboundNo) || (props.service !== 'labeling' && !primaryWarehouse) || invalidCargo) {
ElMessage.warning('请填写必填信息和完整的货物明细'); return
}
if (props.service === 'labeling' && (!form.labelDeadline || !form.contact || !form.phone || form.chargeAmount <= 0 || cargoRows.value.some((item) => !item.shipmentNo || !item.newSkuCode))) {
ElMessage.warning('请填写贴标截止时间、联系人、换标费用以及货物的货件编号和新 SKU'); return
ElMessage.warning('请填写贴标截止时间、联系人、贴标费用以及货物的货件编号和新 SKU'); return
}
if (props.service === 'labeling' && cargoRows.value.some((item) => !item.cartonMark || !item.fbaCartonMark)) {
ElMessage.warning('请上传关联面单,确保每箱的我方箱唛和 FBA 箱唛完整'); return
}
if (props.service === 'labeling' && cargoRows.value.some((item) => !item.destinationWarehouseId && !form.warehouse)) {
ElMessage.warning('请填写统一目的海外仓,或为每箱货物填写目的仓'); return
}
if (props.service === 'disposal' && !form.reason) {
ElMessage.warning('请选择废弃原因'); return
@@ -115,7 +166,7 @@ async function submit() {
try {
const idempotencyKey = crypto.randomUUID()
if (props.service === 'labeling') {
await createRelabel({ customerOrderNo: form.customerNo, overseasWarehouseId: form.warehouse, labelDeadline: form.labelDeadline, contactName: form.contact, contactPhone: form.phone, chargeAmount: form.chargeAmount, remark: form.remark || undefined, idempotencyKey, items: cargoRows.value.map((item) => ({ skuCode: item.sku, productNameZh: item.name, totalPackageCount: item.quantity, notifiedPackageCount: item.quantity, shipmentNo: item.shipmentNo, trackingCode: form.trackingNo || undefined, newSkuCode: item.newSkuCode, labelRequirement: form.requirement || undefined })) })
await createRelabel({ customerOrderNo: form.customerNo, overseasWarehouseId: form.warehouse || undefined, labelDeadline: form.labelDeadline, contactName: form.contact, contactPhone: form.phone, chargeAmount: form.chargeAmount, remark: form.remark || undefined, idempotencyKey, items: cargoRows.value.map((item) => ({ skuCode: item.sku, productNameZh: item.name, totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: item.shipmentNo, trackingCode: form.trackingNo || undefined, newSkuCode: item.newSkuCode, labelRequirement: form.requirement || undefined, cartonMark: item.cartonMark, fbaCartonMark: item.fbaCartonMark, destinationWarehouseId: item.destinationWarehouseId || undefined, remark: item.remark || undefined })) })
} 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)) {
@@ -160,7 +211,7 @@ async function openDetail(value: Record<string, string>) {
<div class="warehouse-filter-actions"><el-button type="primary" @click="query"><Search :size="16" />查询</el-button><el-button @click="resetFilters(); query()">重置</el-button></div>
</div>
<div v-loading="loading" class="warehouse-card warehouse-table-card">
<div class="warehouse-table-scroll"><table><thead><tr><th v-for="column in config.columns" :key="column">{{ column }}</th></tr></thead><tbody><tr v-for="(row, rowIndex) in submittedRows" :key="rowIndex"><td><button class="warehouse-link" type="button" @click="openDetail(row)"><Eye :size="15" />查看</button></td><td v-for="(_, index) in config.columns.slice(1)" :key="index">{{ 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-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 v-for="(_, index) in config.columns.slice(0, -1)" :key="index">{{ row[String(index)] }}</td><td><button class="warehouse-link" type="button" @click="openDetail(row)"><Eye :size="15" />查看</button></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>共 <strong>{{ submittedRows.length }}</strong> 条</span><el-button size="small" disabled>上一页</el-button><button type="button" class="warehouse-page-number">1</button><el-button size="small" disabled>下一页</el-button></div>
</div>
</template>
@@ -176,7 +227,7 @@ async function openDetail(value: Record<string, string>) {
<label v-if="props.service === 'transfer' || 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" placeholder="请选择"><el-option label="ONT8" value="ONT8" /><el-option label="ABE8" value="ABE8" /><el-option label="CLT2" value="CLT2" /></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' ? '目的海外仓' : '操作仓' }}</span><el-select v-model="form.warehouse" placeholder="请选择"><el-option label="美西海外仓-ONT8" value="ONT8" /><el-option label="美东海外仓-ABE8" value="ABE8" /><el-option label="美中海外仓-MDW2" value="MDW2" /></el-select></label>
<label v-else><span><i v-if="props.service !== 'labeling'">*</i>{{ props.service === 'labeling' ? '统一目的海外仓' : '操作仓' }}</span><el-select v-model="form.warehouse" clearable placeholder="留空则按货物明细目的仓"><el-option label="美西海外仓-ONT8" value="ONT8" /><el-option label="美东海外仓-ABE8" value="ABE8" /><el-option label="美中海外仓-MDW2" value="MDW2" /></el-select><small v-if="props.service === 'labeling'" class="warehouse-hint">填写后,未单独指定目的仓的货物将使用此仓库。</small></label>
<label v-if="props.service === 'disposal'"><span>操作仓</span><el-input :model-value="discardCandidates.find((item) => item.orderId === selectedDiscardOrderId)?.overseasWarehouseName || form.warehouse" readonly /></label>
<label v-if="props.service === 'relabel' || props.service === 'transfer'"><span><i>*</i>目的 FBA 仓</span><el-input v-model="form.destination" placeholder="请输入仓代码" /></label>
<label v-if="props.service === 'transfer' || 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>
@@ -184,28 +235,29 @@ async function openDetail(value: Record<string, string>) {
<label v-if="props.service === 'labeling'"><span><i>*</i>联系人</span><el-input v-model="form.contact" placeholder="请输入联系人" /></label>
<label v-if="props.service === 'labeling'"><span><i>*</i>联系电话</span><el-input v-model="form.phone" placeholder="请输入联系电话" /></label>
<label v-if="props.service === 'labeling'"><span><i>*</i>贴标截止时间</span><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择截止时间" /></label>
<label v-if="props.service === 'labeling'"><span><i>*</i>换标费用(CNY)</span><el-input-number v-model="form.chargeAmount" :min="0.01" :precision="2" controls-position="right" placeholder="请输入换标费用" /></label>
<label v-if="props.service === 'labeling'"><span><i>*</i>贴标费用(CNY)</span><el-input-number v-model="form.chargeAmount" :min="0" :precision="2" controls-position="right" /></label>
<label v-if="props.service === 'transfer' || 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.0001" :precision="4" controls-position="right" 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.0001" :precision="4" controls-position="right" /></label>
<label v-if="props.service === 'transfer' || props.service === 'relabel'"><span><i>*</i>预计到达时间</span><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到达时间" /></label>
<label v-if="props.service === 'transfer' || 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 !== 'disposal'"><span>物流跟踪号</span><el-input v-model="form.trackingNo" placeholder="请输入物流跟踪号(选填)" /></label>
<label v-if="props.service === 'labeling'"><span>贴标要求</span><el-input v-model="form.requirement" /></label>
<label v-else-if="props.service !== 'disposal'"><span>物流跟踪号</span><el-input v-model="form.trackingNo" /></label>
<label class="warehouse-span-2"><span>订单备注</span><el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入备注信息" /></label>
</div>
</div>
<div class="warehouse-card warehouse-form-section"><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 v-if="props.service !== 'disposal'">操作</th><th>商品 SKU *</th><th>中文品名 *</th><th v-if="props.service === 'disposal'">可用箱数</th><th v-else>{{ props.service === 'labeling' ? '货件编号 *' : 'FNSKU / 箱唛' }}</th><th v-if="props.service === 'labeling'">换标新 SKU *</th><th>{{ props.service === 'disposal' ? '本次废弃箱数' : props.service === 'relabel' ? '退件数量' : '箱数' }} *</th></tr></thead><tbody><tr v-for="row in cargoRows" :key="row.id"><td v-if="props.service !== 'disposal'"><button type="button" class="warehouse-delete" title="删除" @click="removeCargoRow(row.id)"><Trash2 :size="16" /></button></td><td><el-input v-model="row.sku" :readonly="props.service === 'disposal'" placeholder="请输入商品 SKU" /></td><td><el-input v-model="row.name" :readonly="props.service === 'disposal'" placeholder="请输入中文品名" /></td><td v-if="props.service === 'disposal'"><strong>{{ row.availableQuantity }} 箱</strong></td><td v-else><el-input v-if="props.service === 'labeling'" v-model="row.shipmentNo" placeholder="请输入货件编号" /><el-input v-else v-model="row.shippingMark" placeholder="请输入 FNSKU 或箱唛" /></td><td v-if="props.service === 'labeling'"><el-input v-model="row.newSkuCode" placeholder="请输入换标后的 SKU" /></td><td><el-input-number v-model="row.quantity" :min="props.service === 'disposal' ? 0 : 1" :max="props.service === 'disposal' ? row.availableQuantity : undefined" controls-position="right" placeholder="请输入箱数" /></td></tr><tr v-if="cargoRows.length === 0"><td :colspan="props.service === 'labeling' ? 6 : props.service === 'disposal' ? 4 : 5"><div class="warehouse-empty">{{ props.service === 'disposal' ? '请先选择入库单,系统将自动带出 SKU 和可用箱数' : '暂无货物明细,请点击“添加行”' }}</div></td></tr></tbody></table></div>
<div class="warehouse-card warehouse-form-section"><div class="warehouse-section-heading"><h3>货物明细</h3><div v-if="props.service !== 'disposal'"><template v-if="props.service === 'labeling'"><el-button size="small" @click="downloadRelabelTemplate"><Download :size="15" />下载关联模板</el-button><el-button size="small" @click="templateInput?.click()"><Upload :size="15" />上传关联面单</el-button><input ref="templateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate"></template><el-button type="primary" size="small" @click="addCargoRow"><Plus :size="15" />添加行</el-button><el-button size="small" @click="cargoRows = []">清空</el-button></div></div>
<p v-if="props.service === 'labeling'" class="warehouse-attachment-tip">模板每行代表一箱;上传后直接回显到下方明细。明细目的仓优先,留空时使用顶部统一目的海外仓。</p>
<div class="warehouse-table-scroll"><table class="warehouse-edit-table" :class="{ 'warehouse-label-table': props.service === 'labeling' }"><thead><tr><th>商品 SKU<span v-if="props.service !== 'labeling'"> *</span></th><th>中文品名 *</th><th v-if="props.service === 'disposal'">可用箱数</th><th v-else>{{ props.service === 'labeling' ? 'FBA 货件编号 *' : 'FNSKU / 箱唛' }}</th><th v-if="props.service === 'labeling'">换标新 SKU *</th><th v-if="props.service !== 'labeling'">{{ props.service === 'disposal' ? '本次废弃箱数' : props.service === 'relabel' ? '退件数量' : '箱数' }} *</th><template v-if="props.service === 'labeling'"><th>我方箱唛</th><th>FBA 箱唛</th><th>目的仓</th><th>备注</th></template><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="选填" /></td><td><el-input v-model="row.name" :readonly="props.service === 'disposal'" /></td><td v-if="props.service === 'disposal'"><strong>{{ row.availableQuantity }} 箱</strong></td><td v-else><el-input v-if="props.service === 'labeling'" v-model="row.shipmentNo" /><el-input v-else v-model="row.shippingMark" /></td><td v-if="props.service === 'labeling'"><el-input v-model="row.newSkuCode" /></td><td v-if="props.service !== 'labeling'"><el-input-number v-model="row.quantity" :min="props.service === 'disposal' ? 0 : 1" :max="props.service === 'disposal' ? row.availableQuantity : undefined" controls-position="right" /></td><template v-if="props.service === 'labeling'"><td><el-input v-model="row.cartonMark" placeholder="请输入我方箱唛" /></td><td><el-input v-model="row.fbaCartonMark" placeholder="请输入 FBA 箱唛" /></td><td><el-select v-model="row.destinationWarehouseId" clearable placeholder="使用顶部统一目的仓"><el-option label="美西海外仓-ONT8" value="ONT8" /><el-option label="美东海外仓-ABE8" value="ABE8" /><el-option label="美中海外仓-MDW2" value="MDW2" /></el-select></td><td><el-input v-model="row.remark" type="textarea" :rows="2" maxlength="1000" show-word-limit placeholder="请输入明细备注" /></td></template><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 === 'labeling' ? 8 : 5"><div class="warehouse-empty">{{ props.service === 'disposal' ? '请先选择入库单,系统将自动带出 SKU 和可用箱数' : '暂无货物明细,请点击“添加行”' }}</div></td></tr></tbody></table></div>
</div>
<div v-if="props.service === 'transfer' || props.service === 'relabel'" class="warehouse-card warehouse-form-section"><h3>换标设置</h3><el-checkbox v-model="form.relabel">是否换标</el-checkbox><p v-if="form.relabel" class="warehouse-hint">请确认 SKU 与 FNSKU 信息准确,提交后海外仓将按明细执行换标。</p></div>
<div class="warehouse-submit-bar"><el-button @click="resetForm">重置</el-button><el-button 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><el-button @click="mode = 'list'"><ArrowLeft :size="16" />返回列表</el-button></div><div class="warehouse-card warehouse-form-section"><h3>订单概览</h3><div class="warehouse-detail-grid"><div v-for="(column, index) in config.columns.slice(1)" :key="column"><span>{{ column }}</span><strong>{{ selectedDetail?.[String(index)] || '—' }}</strong></div></div></div></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><el-button @click="mode = 'list'"><ArrowLeft :size="16" />返回列表</el-button></div><div class="warehouse-card warehouse-form-section"><h3>订单概览</h3><div class="warehouse-detail-grid"><div v-for="(column, index) in config.columns.slice(0, -1)" :key="column"><span>{{ column }}</span><strong>{{ selectedDetail?.[String(index)] || '—' }}</strong></div></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-hidden-input{display:none}.warehouse-label-table{min-width:1320px}.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}
</style>
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8')
const api = readFileSync(new URL('../api/warehouseServices.ts', import.meta.url), 'utf8')
test('上传关联面单后直接生成箱维度货物明细', () => {
assert.match(view, /下载关联模板/)
assert.match(view, /上传关联面单/)
assert.match(view, /cargoRows\.value = imported\.map/)
assert.match(view, /totalPackageCount: 1, notifiedPackageCount: 1/)
assert.doesNotMatch(view, /applyCartonMarkBinding/)
assert.doesNotMatch(view, /上传SKU 标签/)
})
test('目的仓支持顶部兜底并随每箱明细提交', () => {
assert.match(api, /destinationWarehouseId\?: string/)
assert.match(view, /!item\.destinationWarehouseId && !form\.warehouse/)
assert.match(view, /destinationWarehouseId: item\.destinationWarehouseId \|\| undefined/)
assert.match(view, /cartonMark: item\.cartonMark/)
assert.match(view, /fbaCartonMark: item\.fbaCartonMark/)
})
test('贴标明细保留箱唛绑定,不再单独填箱数', () => {
assert.match(view, /<th>我方箱唛<\/th><th>FBA 箱唛/)
assert.match(view, /v-model="row\.cartonMark"/)
assert.match(view, /v-if="props\.service !== 'labeling'">\{\{ props\.service === 'disposal'/)
})
test('商品 SKU 在贴标模板和明细中为选填', () => {
assert.match(view, /const header = \['商品SKU(选填)'/)
assert.match(view, /const example = \['', '示例商品'/)
assert.match(view, /headers\.includes\('商品SKU(选填)'\)/)
assert.match(view, /const required = \['中文品名', 'FBA货件编号'/)
assert.match(view, /商品 SKU<span v-if="props\.service !== 'labeling'"> \*<\/span>/)
assert.match(api, /skuCode\?: string; productNameZh/)
})