Merge remote-tracking branch 'origin/release/test' into hotfix

This commit is contained in:
2026-09-28 16:36:02 +01:00
5 changed files with 141 additions and 58 deletions
@@ -6,8 +6,8 @@ export type RelabelDeliveryMethod = 'TRUCK' | 'EXPRESS' | 'TRUCK_PICKUP' | 'EXPR
export interface PageResult<T> { records: T[]; page: number; size: number; total: number; pages: number }
export interface LastlegOrderSummary { id: string; orderNo: string; inboundCode: string; orderType: string; packageCount: number; overseasWarehouseId?: string }
export interface HistoricalOrderSummary { id: string | null; orderNo: string; sourceOrderNo?: string; outboundOrderNo?: string; relabelOrderNo?: string; orderType?: string; status?: string; packageCount?: number }
export interface TransferSummary { id: string; transferNo: string; inboundCode: string; lastlegOrderId: string; lastlegOrderNo: string; operatingWarehouseId: string; fbaWarehouseCode?: string; packageCount: number; deliveryMethod: string; needRelabel: boolean; relabelNo?: string; relabelStatus?: string; relabelFulfillmentStatus?: string; status: string; estimatedArrivalAt: string; estimatedShipAt?: string; actualShipAt?: string; createdAt: string }
export interface RelabelSummary { id: string; relabelNo: string; lastlegOrderId: string; lastlegOrderNo: string; customerOrderNo: string; overseasWarehouseId: string; status: string; itemCount: number; totalPackageCount: number; notifiedPackageCount: number; skuCodes: string; createdAt: string; totalFileId?: string; totalFileName?: string; totalFileUrl?: string; cartonTotalFileId?: string; cartonTotalFileName?: string; cartonTotalFileUrl?: string; skuTotalFileId?: string; skuTotalFileName?: string; skuTotalFileUrl?: string; deliveryMethod?: RelabelDeliveryMethod; printShippingLabel?: boolean; shippingLabelFileId?: string; shippingLabelFileName?: string; shippingLabelFileUrl?: string; fulfillmentStatus?: string; outboundOrderNo?: string; customerConfirmedAt?: string; reviewChannel?: 'TAIL_ORDER' | 'RELABEL_ORDER'; reviewRemark?: string }
export interface TransferSummary { id: string; transferNo: string; inboundCode: string; lastlegOrderId: string; lastlegOrderNo: string; operatingWarehouseId: string; fbaWarehouseCode?: string; packageCount: number; deliveryMethod: string; needRelabel: boolean; relabelNo?: string; relabelStatus?: string; relabelFulfillmentStatus?: string; status: string; estimatedArrivalAt: string; estimatedShipAt?: string; actualShipAt?: string; reviewedAt?: string; measuredAt?: string; outboundAt?: string; relabelCompletedAt?: string; createdAt: string }
export interface RelabelSummary { id: string; relabelNo: string; lastlegOrderId: string; lastlegOrderNo: string; customerOrderNo: string; overseasWarehouseId: string; status: string; itemCount: number; totalPackageCount: number; notifiedPackageCount: number; skuCodes: string; createdAt: string; reviewedAt?: string; relabelCompletedAt?: string; outboundAt?: string; totalFileId?: string; totalFileName?: string; totalFileUrl?: string; cartonTotalFileId?: string; cartonTotalFileName?: string; cartonTotalFileUrl?: string; skuTotalFileId?: string; skuTotalFileName?: string; skuTotalFileUrl?: string; deliveryMethod?: RelabelDeliveryMethod; printShippingLabel?: boolean; shippingLabelFileId?: string; shippingLabelFileName?: string; shippingLabelFileUrl?: string; fulfillmentStatus?: string; outboundOrderNo?: string; customerConfirmedAt?: string; reviewChannel?: 'TAIL_ORDER' | 'RELABEL_ORDER'; reviewRemark?: string }
export interface RelabelItemDetail {
id: string; skuCode?: string; productNameZh: string; totalPackageCount: number; notifiedPackageCount: number
shipmentNo: string; trackingCode?: string; newSkuCode?: string; labelRequirement?: string; remark?: string
@@ -25,8 +25,8 @@ export interface RelabelDetail {
tailOrders?: Array<{ id: string; orderNo: string; overseasWarehouseId: string; packageCount: number; status: string }>
completionEvidence?: { taskId: string; photoFileIds: string[]; completionRemark?: string; reviewerName?: string; reviewRemark?: string; completedAt?: string }
}
export interface DiscardSummary { id: string; discardNo: string; inboundCodes: string; overseasWarehouseId: string; overseasWarehouseName: string; totalPackages: number; totalFee: number; currency: string; status: string; createdAt: string }
export interface DiscardCandidate { orderId: string; inboundCode: string; inboundOrderNo: string; overseasWarehouseId: string; overseasWarehouseName: string; availablePackages: number; skus: Array<{ skuCode: string; productName: string; availablePackages: number }> }
export interface DiscardSummary { id: string; discardNo: string; outboundNo?: string; inboundCodes: string; overseasWarehouseId: string; overseasWarehouseName: string; totalPackages: number; totalFee: number; currency: string; status: string; createdAt: string; completedAt?: string }
export interface DiscardCandidate { orderId: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON'; returnCartonId?: string; cartonMark?: string; inboundCode: string; inboundOrderNo: string; overseasWarehouseId: string; overseasWarehouseName: string; availablePackages: number; skus: Array<{ skuCode: string; productName: string; availablePackages: number }> }
export interface RelabelInventoryCandidate {
inventoryBalanceId: string; inboundOrderId: string; inboundOrderNo?: string; inboundCode?: string
overseasWarehouseId: string; overseasWarehouseCode?: string; overseasWarehouseName?: string
@@ -38,7 +38,7 @@ export interface RelabelInventoryCandidate {
}
export interface TransferCreate {
inboundCode?: string; sourceLastlegOrderIds?: number[]; operatingWarehouseId: string; fbaWarehouseCode?: string; destinationType?: 'NONE' | 'FBA' | 'PRIVATE'
inboundCode?: string; sourceLastlegOrderIds?: string[]; operatingWarehouseId: string; fbaWarehouseCode?: string; destinationType?: 'NONE' | 'FBA' | 'PRIVATE'
destinationWarehouseId?: string; destinationWarehouseCode?: string; destinationWarehouseName?: string
privateDestinationAddress?: string; privateDestinationPostalCode?: string; privateDestinationContactName?: string; privateDestinationContactPhone?: string
deliveryAddress: string; packageCount: number; volumeCbm: number; deliveryMethod: string; estimatedArrivalAt: string; estimatedShipAt?: string
@@ -124,7 +124,7 @@ export function getDiscard(discardNo: string) {
export function listDiscardCandidates(inboundCode?: string) {
return overseasApiRequest<DiscardCandidate[]>({ method: 'GET', url: '/merchant/v1/last-leg/cargo-discards/inventory-candidates', params: { inboundCode: inboundCode || undefined } })
}
export function createDiscard(payload: { items: Array<{ orderId: string; skuCode: string; discardPackages: number }>; idempotencyKey: string; remark?: string }) {
export function createDiscard(payload: { items: Array<{ orderId?: string; returnCartonId?: string; skuCode: string; discardPackages: number }>; idempotencyKey: string; remark?: string }) {
return overseasApiRequest<Record<string, unknown>>({ method: 'POST', url: '/merchant/v1/last-leg/cargo-discards', data: payload })
}
export function cancelDiscard(discardNo: string) {
@@ -41,8 +41,8 @@ test('opens warehouse service and return receipt details as dedicated protected
})
test('returns to the labeling list after a labeling application is submitted', () => {
assert.match(services, /ElMessage\.success\(`\$\{config\.value\.title\}提交成功`\)[\s\S]*?router\.push\(\{ name: 'overseas-labeling' \}\)/)
assert.match(services, /uploadReturnRelabelFile\([\s\S]*?router\.push\(\{ name: 'overseas-labeling' \}\)/)
assert.match(services, /ElMessage\.success\(`\$\{config\.value\.title\}提交成功`\)[\s\S]*?router\.push\(\{ name: serviceListRouteName\(props\.service\) \}\)/)
assert.match(services, /uploadReturnRelabelFile\([\s\S]*?router\.push\(\{ name: serviceListRouteName\(props\.service\) \}\)/)
})
test('keeps overseas warehouse writes behind operation-specific button permissions', () => {
@@ -187,8 +187,8 @@ async function submit() {
creating.value = true
try {
const payload = { overseasWarehouseId: form.overseasWarehouseId, warehouseContactName: form.warehouseContactName.trim(), warehouseContactPhone: form.warehouseContactPhone.trim(), customerReferenceNo: form.customerReferenceNo || undefined, remark: form.remark || undefined, needRelabel: form.needRelabel, idempotencyKey: createIdempotencyKey(), items: items.map(item => ({ ...item, productNameZh: item.productNameZh || undefined })) }
const detail = editingReturnNo.value ? await updateReturnOrder(editingReturnNo.value, payload) : await createReturnOrder(payload)
ElMessage.success(editingReturnNo.value ? '退货预报已更新' : '退货预报已创建'); showCreate.value = false; editingReturnNo.value = ''; resetForm(); await router.push({ name: 'overseas-return-relabel-detail', params: { returnNo: detail.order.returnNo } })
await (editingReturnNo.value ? updateReturnOrder(editingReturnNo.value, payload) : createReturnOrder(payload))
ElMessage.success(editingReturnNo.value ? '退货预报已更新' : '退货预报已创建'); showCreate.value = false; editingReturnNo.value = ''; resetForm(); await router.push({ name: 'overseas-return-relabel' })
} finally { creating.value = false }
}
function statusType(status: string, needRelabel = false) {
@@ -32,7 +32,7 @@ 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 CargoRow { id: number; included?: boolean; sku: string; name: string; quantity: number; availableQuantity?: number; sourceOrderId?: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON'; 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 ReturnRelabelSkuRow { skuCode: string; productNameZh: string; availableQuantity: number; selected: boolean; quantity: number }
@@ -55,19 +55,27 @@ const canConfirmRelabel = computed(() => auth.hasPermission('merchant:overseas-w
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 汇总', '退件总数', '是否换标', '换标状态', '尾程状态', '创建时间'] },
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 listQueryModel = computed<QueryModel>({
get: () => ({ ...filters, createdDates: [filters.start, filters.end].filter(Boolean) }),
get: () => ({ ...filters, createdDates: [filters.start, filters.end].filter(Boolean), reviewDates: [filters.reviewStart, filters.reviewEnd].filter(Boolean), completedDates: [filters.completedStart, filters.completedEnd].filter(Boolean), measuredDates: [filters.measuredStart, filters.measuredEnd].filter(Boolean), outboundDates: [filters.outboundStart, filters.outboundEnd].filter(Boolean) }),
set: (value) => Object.assign(filters, {
number: String(value.number || ''), inboundNo: String(value.inboundNo || ''), warehouse: String(value.warehouse || ''),
destination: String(value.destination || ''), status: String(value.status || ''),
start: Array.isArray(value.createdDates) ? String(value.createdDates[0] || '') : '',
end: Array.isArray(value.createdDates) ? String(value.createdDates[1] || '') : '',
reviewStart: Array.isArray(value.reviewDates) ? String(value.reviewDates[0] || '') : '',
reviewEnd: Array.isArray(value.reviewDates) ? String(value.reviewDates[1] || '') : '',
completedStart: Array.isArray(value.completedDates) ? String(value.completedDates[0] || '') : '',
completedEnd: Array.isArray(value.completedDates) ? String(value.completedDates[1] || '') : '',
measuredStart: Array.isArray(value.measuredDates) ? String(value.measuredDates[0] || '') : '',
measuredEnd: Array.isArray(value.measuredDates) ? String(value.measuredDates[1] || '') : '',
outboundStart: Array.isArray(value.outboundDates) ? String(value.outboundDates[0] || '') : '',
outboundEnd: Array.isArray(value.outboundDates) ? String(value.outboundDates[1] || '') : '',
}),
})
const statusOptions = computed(() => props.service === 'labeling'
@@ -84,7 +92,7 @@ const statusOptions = computed(() => props.service === 'labeling'
{ 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>(props.detailPage ? 'detail' : props.createPage ? 'create' : 'list')
const filters = reactive({ number: '', inboundNo: '', warehouse: '', destination: '', status: '', start: '', end: '' })
const filters = reactive({ number: '', inboundNo: '', warehouse: '', destination: '', status: '', start: '', end: '', reviewStart: '', reviewEnd: '', completedStart: '', completedEnd: '', measuredStart: '', measuredEnd: '', outboundStart: '', outboundEnd: '' })
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)
@@ -120,6 +128,7 @@ interface ServiceDetail extends Record<string, unknown> {
remark?: string; needRelabel?: boolean; labelDeadline?: string; relabelContactName?: string
relabelContactPhone?: string; relabelDeliveryMethod?: string; relabelNo?: string
relabelStatus?: string; relabelFulfillmentStatus?: string; createdAt?: string
inboundCode?: string; sourceOrders?: Array<{ lastlegOrderId?: string | number }>
items?: Array<{ skuCode?: string; productNameZh?: string; packageCount?: number; shippingMark?: string }>
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
@@ -152,9 +161,22 @@ const listQueryFields = computed<QueryField[]>(() => {
options: (props.service === 'relabel' ? fbaWarehouses.value : operationWarehouses.value).map((warehouse) => ({ label: warehouseLabel(warehouse), value: String(warehouse.id) })),
})
if (props.service === 'transfer' || props.service === 'relabel') fields.push({ key: 'destination', label: '目的 FBA 仓', type: 'input', placeholder: '请输入仓代码' })
fields.push(
{ key: 'status', label: '订单状态', type: 'select', options: statusOptions.value },
{ key: 'createdDates', label: '创建时间', type: 'daterange' },
fields.push({ key: 'status', label: '订单状态', type: 'select', options: statusOptions.value })
fields.push({ key: 'createdDates', label: '创建时间', type: 'daterange' })
if (props.service === 'labeling') fields.push(
{ key: 'reviewDates', label: '审核时间', type: 'daterange' },
{ key: 'completedDates', label: '贴标完成时间', type: 'daterange' },
{ key: 'outboundDates', label: '贴标出库时间', type: 'daterange' },
)
if (props.service === 'relabel') fields.push(
{ key: 'reviewDates', label: '审核时间', type: 'daterange' },
{ key: 'measuredDates', label: '测量时间', type: 'daterange' },
{ key: 'outboundDates', label: '出库时间', type: 'daterange' },
)
if (props.service === 'transfer') fields.push(
{ key: 'reviewDates', label: '审核时间', type: 'daterange' },
{ key: 'completedDates', label: '测量时间', type: 'daterange' },
{ key: 'outboundDates', label: '出库时间', type: 'daterange' },
)
return fields
})
@@ -170,7 +192,9 @@ const selectedRelabelInboundOrderId = ref('')
const relabelCandidatesLoading = ref(false)
const transferInventoryCandidates = ref<RelabelInventoryCandidate[]>([])
const selectedTransferInventory = ref('')
const selectedTransferSourceOrderIds = ref<number[]>([])
// Snowflake IDs exceed JavaScript's safe integer range. Keep them as strings
// all the way to JSON so the backend receives the exact source order ID.
const selectedTransferSourceOrderIds = ref<string[]>([])
const returnRelabelSkuRows = ref<ReturnRelabelSkuRow[]>([])
const isReturnRelabelSource = computed(() => props.service === 'labeling' && selectedRelabelInboundOrderId.value.startsWith('RETURN_ORDER:'))
const selectedReturnOrderNo = computed(() => isReturnRelabelSource.value ? selectedRelabelInboundOrderId.value.slice('RETURN_ORDER:'.length) : '')
@@ -267,7 +291,7 @@ onMounted(() => {
void nextTick(refreshWarehouseMarkLabels)
})
onBeforeUnmount(() => { clearCompletionPhotos(); clearMeasurementPhotos() })
function resetFilters() { Object.assign(filters, { number: '', inboundNo: '', warehouse: '', destination: '', status: '', start: '', end: '' }) }
function resetFilters() { Object.assign(filters, { number: '', inboundNo: '', warehouse: '', destination: '', status: '', start: '', end: '', reviewStart: '', reviewEnd: '', completedStart: '', completedEnd: '', measuredStart: '', measuredEnd: '', outboundStart: '', outboundEnd: '' }) }
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 = []
@@ -314,19 +338,21 @@ async function openCreate() {
function returnToList() {
if (props.createPage || props.detailPage) {
const routeName = ({ labeling: 'overseas-labeling', transfer: 'overseas-storage-transfer', disposal: 'overseas-cargo-disposal', relabel: 'overseas-return-relabel' } as Record<ServiceType, string>)[props.service]
void router.push({ name: routeName })
void router.push({ name: serviceListRouteName(props.service) })
return
}
mode.value = 'list'
}
function serviceListRouteName(service: ServiceType) {
return ({ labeling: 'overseas-labeling', transfer: 'overseas-storage-transfer', disposal: 'overseas-cargo-disposal', relabel: 'overseas-return-relabel' } as Record<ServiceType, string>)[service]
}
async function openEdit(transferNo: string) {
try {
resetForm()
await loadWarehouses()
const detail = await getTransfer(transferNo) as ServiceDetail
if (detail.status !== 'DRAFT') throw new Error('只有草稿状态的转运单可以编辑')
if (!['DRAFT', 'REJECTED'].includes(detail.status || '')) throw new Error('只有草稿或审核驳回状态的转运单可以编辑')
editingTransferNo.value = transferNo
Object.assign(form, {
warehouse: detail.operatingWarehouseId || '', destination: detail.destinationWarehouseCode || detail.fbaWarehouseCode || '',
@@ -362,6 +388,20 @@ async function openEdit(transferNo: string) {
if (form.warehouse) {
try { selectedOperationWarehouse.value = await getMerchantWarehouseDetail(form.warehouse) } catch { selectedOperationWarehouse.value = null }
}
// The warehouse selector loads inventory candidates for a new transfer, but
// edit mode starts with an already populated warehouse and never emits the
// selector change event. Load the same candidates explicitly so the source
// inventory dropdown is usable when editing a draft.
if (props.service === 'transfer' && form.warehouse) {
await loadTransferInventoryCandidates(form.warehouse)
const sourceInboundCode = String(detail.inboundCode || '')
if (transferInventoryOptions.value.some((option) => option.value === sourceInboundCode)) {
selectedTransferInventory.value = sourceInboundCode
form.inboundNo = sourceInboundCode
selectedTransferSourceOrderIds.value = (detail.sourceOrders || [])
.map((item) => String(item.lastlegOrderId || '')).filter(Boolean)
}
}
mode.value = 'edit'
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '草稿加载失败') }
}
@@ -375,11 +415,25 @@ async function selectDiscardWarehouse() {
discardCandidatesLoading.value = true
try {
const inventory = await listRelabelInventoryCandidates(form.warehouse)
const orderByInbound = new Map<string, string>()
const inboundCodes = [...new Set(inventory.map((candidate) => String(candidate.inboundCode || '')).filter(Boolean))]
const historicalGroups = await Promise.all(inboundCodes.map(async (inboundCode) => ({ inboundCode, orders: await listRelabelHistoricalOrders(form.warehouse, inboundCode) })))
for (const { inboundCode, orders } of historicalGroups) {
for (const order of orders) {
if (order.id) orderByInbound.set(inboundCode, String(order.id))
}
}
const grouped = new Map<string, import('../api/warehouseServices').DiscardCandidate>()
for (const candidate of inventory) {
const orderId = String(candidate.inboundOrderId || '')
if (!orderId) continue
const current = grouped.get(orderId) || { orderId, inboundCode: candidate.inboundCode || '', inboundOrderNo: candidate.inboundOrderNo || '', overseasWarehouseId: String(candidate.overseasWarehouseId || form.warehouse), overseasWarehouseName: candidate.overseasWarehouseName || '', availablePackages: 0, skus: [] }
const returnCartonId = String(candidate.returnCartonId || '')
const sourceType = candidate.sourceType === 'RETURN_CARTON' ? 'RETURN_CARTON' : 'DROPSHIP'
const inboundCode = String(candidate.inboundCode || '')
const orderId = sourceType === 'RETURN_CARTON' ? `return:${returnCartonId}` : (orderByInbound.get(inboundCode) || '')
if (!orderId || (sourceType === 'RETURN_CARTON' && !returnCartonId)) continue
const current = grouped.get(orderId) || { orderId, inboundCode, inboundOrderNo: candidate.inboundOrderNo || '', overseasWarehouseId: String(candidate.overseasWarehouseId || form.warehouse), overseasWarehouseName: candidate.overseasWarehouseName || '', availablePackages: 0, skus: [] }
current.sourceType = sourceType
current.returnCartonId = returnCartonId || undefined
current.cartonMark = candidate.cartonMark || undefined
const packages = inventoryQuantity(candidate.availablePackages)
current.availablePackages += packages
const skuCode = String(candidate.skuCode || '').trim()
@@ -741,12 +795,13 @@ function importSelectedBindingsToDetails() {
function selectDiscardInbound(orderId: string) {
const candidate = discardCandidates.value.find((item) => item.orderId === orderId)
if (!candidate) { cargoRows.value = []; return }
form.inboundNo = candidate.inboundCode
form.inboundNo = candidate.inboundCode || candidate.inboundOrderNo
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: '',
availableQuantity: sku.availablePackages, sourceOrderId: candidate.orderId, sourceType: candidate.sourceType,
returnCartonId: candidate.returnCartonId, cartonMark: candidate.cartonMark || '',
fnsku: '', shipmentNo: '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', fbaCartonMark: '', destinationWarehouseId: '', remark: '',
}))
}
function dateTime(value: string, end = false) { return value ? `${value}T${end ? '23:59:59' : '00:00:00'}` : undefined }
@@ -860,25 +915,25 @@ 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) })
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), reviewedFrom: dateTime(filters.reviewStart), reviewedTo: dateTime(filters.reviewEnd, true), completedFrom: dateTime(filters.completedStart), completedTo: dateTime(filters.completedEnd, true), outboundFrom: dateTime(filters.outboundStart), outboundTo: dateTime(filters.outboundEnd, true) })
total.value = Number.isFinite(Number(result.total)) ? Number(result.total) : result.records.length
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 || '' }
return { ...row([item.relabelNo, item.customerOrderNo, destinationWarehouses, '—', item.itemCount, item.totalPackageCount, statusLabel(item.status), formatDateTime(item.reviewedAt), formatDateTime(item.relabelCompletedAt), formatDateTime(item.outboundAt), 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) })
const result = await listDiscards({ page: page.value, size: pageSize.value, discardNo: filters.number || undefined, inboundCode: filters.inboundNo || undefined, overseasWarehouseId: filters.warehouse || undefined, status: filters.status || undefined, createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true), completedFrom: dateTime(filters.completedStart), completedTo: dateTime(filters.completedEnd, true) })
total.value = Number.isFinite(Number(result.total)) ? Number(result.total) : result.records.length
submittedRows.value = result.records.map((item) => row([item.discardNo, item.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.outboundNo || '—', item.inboundCodes, item.overseasWarehouseName || warehouseDisplay(item.overseasWarehouseId) || '—', '—', item.totalPackages, `${item.currency} ${item.totalFee}`, statusLabel(item.status), formatDateTime(item.completedAt), 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) })
const result = await listTransfers({ page: page.value, size: pageSize.value, transferNo: filters.number || undefined, inboundCode: filters.inboundNo || undefined, operatingWarehouseId: filters.warehouse || undefined, fbaWarehouseCode: filters.destination || undefined, status: filters.status || undefined, ...(props.service === 'relabel' ? { needRelabel: true } : {}), createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true), reviewedFrom: dateTime(filters.reviewStart), reviewedTo: dateTime(filters.reviewEnd, true), measuredFrom: props.service === 'relabel' ? dateTime(filters.measuredStart) : dateTime(filters.completedStart), measuredTo: props.service === 'relabel' ? dateTime(filters.measuredEnd, true) : dateTime(filters.completedEnd, true), outboundFrom: dateTime(filters.outboundStart), outboundTo: dateTime(filters.outboundEnd, true) })
total.value = Number.isFinite(Number(result.total)) ? Number(result.total) : result.records.length
submittedRows.value = result.records.map((item) => {
const operationWarehouse = warehouseDisplay(item.operatingWarehouseId)
const relabelStatus = transferRelabelStatus(item)
return props.service === 'transfer'
? { ...row([item.transferNo, item.packageCount, operationWarehouse, item.fbaWarehouseCode, deliveryMethodLabel(item.deliveryMethod), item.needRelabel ? '是' : '否', item.relabelNo, relabelStatus.label, statusLabel(item.status), formatDateTime(item.createdAt)]), __status: item.status, __statusLabel: statusLabel(item.status), __relabelStatus: relabelStatus.code, __relabelStatusLabel: relabelStatus.label }
: row([item.transferNo, '—', item.fbaWarehouseCode, '—', item.packageCount, '是', item.relabelNo ? '已生成' : '待生成', statusLabel(item.status), formatDateTime(item.createdAt)])
? { ...row([item.transferNo, item.packageCount, operationWarehouse, item.fbaWarehouseCode, deliveryMethodLabel(item.deliveryMethod), item.needRelabel ? '是' : '否', item.relabelNo, relabelStatus.label, statusLabel(item.status), formatDateTime(item.reviewedAt), formatDateTime(item.measuredAt), formatDateTime(item.outboundAt), formatDateTime(item.createdAt)]), __status: item.status, __statusLabel: statusLabel(item.status), __relabelStatus: relabelStatus.code, __relabelStatusLabel: relabelStatus.label }
: row([item.transferNo, '—', item.fbaWarehouseCode, '—', item.packageCount, '是', item.relabelNo ? '已生成' : '待生成', statusLabel(item.status), formatDateTime(item.reviewedAt), formatDateTime(item.measuredAt), formatDateTime(item.outboundAt), formatDateTime(item.createdAt)])
})
}
} catch (error) { submittedRows.value = []; total.value = 0; ElMessage.error(error instanceof Error ? error.message : '列表加载失败') }
@@ -889,7 +944,7 @@ function resetListQuery() { resetFilters(); query() }
function listRowActions(candidate: object): TableAction[] {
const item = candidate as Record<string, string>
const actions: TableAction[] = [{ key: 'detail', label: '详情', run: () => openDetail(item) }]
if (canCreate.value && props.service === 'transfer' && item.__status === 'DRAFT') actions.push({ key: 'edit', label: '编辑', run: () => openEdit(item['0']) })
if (canCreate.value && props.service === 'transfer' && ['DRAFT', 'REJECTED'].includes(item.__status)) actions.push({ key: 'edit', label: '编辑', run: () => openEdit(item['0']) })
if (canConfirmTransfer.value && props.service === 'transfer' && item.__status === 'WAIT_CUSTOMER_CONFIRMATION') actions.push({ key: 'confirm-measurement', label: '确认测量', run: () => confirmTransferFromList(item['0']) })
if (canConfirmRelabel.value && props.service === 'labeling' && item.__status === 'CUSTOMER_CONFIRM_PENDING' && !item.__confirmedAt) actions.push({ key: 'confirm-relabel', label: '确认换标', run: () => confirmRelabelFromList(item['0']) })
return actions
@@ -980,9 +1035,6 @@ async function submit(action: 'draft' | 'submit' = 'submit') {
if (props.service === 'transfer' && transferDestinationType.value === 'FBA' && !form.destination) {
ElMessage.warning('请选择 FBA 目的仓'); return
}
if (props.service === 'transfer' && transferDestinationType.value === 'NONE') {
ElMessage.warning('请选择 FBA 仓或私人仓'); return
}
if (props.service === 'transfer' && transferDestinationType.value === 'PRIVATE' && (!form.privateDestinationAddress || !form.privateDestinationPostalCode || !form.privateDestinationContact || !form.privateDestinationPhone)) {
ElMessage.warning('请完整填写私人仓地址、邮编、联系人和电话'); return
}
@@ -1018,20 +1070,22 @@ async function submit(action: 'draft' | 'submit' = 'submit') {
if (shippingLabelFile.value) await uploadRelabelShippingLabel(created.application.relabelNo, shippingLabelFile.value)
} catch (error) {
// Creation already committed: return to the list even when an attachment still needs retrying.
if (props.createPage) await router.push({ name: 'overseas-labeling' })
if (props.createPage) await router.push({ name: serviceListRouteName(props.service) })
else { mode.value = 'list'; await load(); await openDetail({ '0': created.application.relabelNo }) }
await ElMessageBox.alert(`申请 ${created.application.relabelNo} 已创建,部分附件上传失败:${error instanceof Error ? error.message : '上传失败'}。请在原单详情补传失败附件,不要重复下单。`, '申请已创建,附件待补传', { confirmButtonText: '知道了' })
return
}
ElMessage.success(`${config.value.title}提交成功`)
await router.push({ name: 'overseas-labeling' })
await router.push({ name: serviceListRouteName(props.service) })
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 }
return { orderId: item.sourceType === 'RETURN_CARTON' ? undefined : item.sourceOrderId,
returnCartonId: item.sourceType === 'RETURN_CARTON' ? item.returnCartonId : undefined,
skuCode: item.sku, discardPackages: item.quantity }
})
await createDiscard({ items, idempotencyKey, remark: [form.reason, form.remark].filter(Boolean).join(';') || undefined })
} else {
@@ -1057,7 +1111,7 @@ async function submit(action: 'draft' | 'submit' = 'submit') {
if (!relabelNo) {
ElMessage.success('转运单已提交审核;PDA 入库确认后将自动生成关联换标单')
await ElMessageBox.alert(`转运单 ${transferNo} 已提交。关联换标单会在 PDA 完成入库、客户确认实测库存后自动生成;届时请进入关联换标单详情上传贴标总文件。`, '转运单已提交', { confirmButtonText: '知道了' })
await openDetail({ '0': transferNo })
await router.push({ name: serviceListRouteName('transfer') })
return
}
try {
@@ -1070,13 +1124,12 @@ async function submit(action: 'draft' | 'submit' = 'submit') {
}
}
ElMessage.success('转运单已提交审核')
await openDetail({ '0': transferNo })
await router.push({ name: serviceListRouteName('transfer') })
return
}
}
ElMessage.success(props.service === 'transfer' ? '转运单草稿保存成功' : `${config.value.title}提交成功`)
if (props.createPage) await router.push({ name: props.service === 'relabel' ? 'overseas-return-relabel' : 'overseas-labeling' })
else { mode.value = 'list'; await load() }
await router.push({ name: serviceListRouteName(props.service) })
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '提交失败') }
finally { saving.value = false }
}
@@ -1220,6 +1273,16 @@ async function loadWarehouses() {
} finally { fbaWarehousesLoading.value = false }
}
function warehouseLabel(value: MerchantWarehouse) { return `${value.warehouseName} · ${value.warehouseCode}` }
async function loadTransferInventoryCandidates(warehouseId: string) {
transferInventoryCandidates.value = []
if (!warehouseId) return
relabelCandidatesLoading.value = true
try {
transferInventoryCandidates.value = await listRelabelInventoryCandidates(warehouseId)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '当前海外仓库存来源加载失败')
} finally { relabelCandidatesLoading.value = false }
}
async function selectTransferWarehouse() {
selectedOperationWarehouse.value = null
form.pickupContact = ''
@@ -1235,7 +1298,7 @@ async function selectTransferWarehouse() {
form.relabelContact = detail.warehouse.contactName || ''
form.relabelPhone = detail.warehouse.contactPhone || ''
if (form.deliveryMethod === 'CUSTOMER_DELIVERY') form.address = operationWarehouseAddress.value
transferInventoryCandidates.value = await listRelabelInventoryCandidates(form.warehouse)
await loadTransferInventoryCandidates(form.warehouse)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '中转仓联系信息加载失败')
} finally { operationWarehouseDetailLoading.value = false }
@@ -1264,6 +1327,7 @@ async function resubmitRejectedRelabel() {
idempotencyKey: createIdempotencyKey(),
})
ElMessage.success('贴标单已重新提交审核')
await router.push({ name: serviceListRouteName('labeling') })
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '重新提交失败')
}
@@ -1302,7 +1366,7 @@ async function selectTransferInventory(value: string) {
cargoRows.value = [...grouped.values()]
try {
const historical = await listRelabelHistoricalOrders(form.warehouse, value)
const sourceIds = historical.map((item) => Number(item.id)).filter((id) => Number.isInteger(id) && id > 0)
const sourceIds = historical.map((item) => String(item.id || '')).filter(Boolean)
selectedTransferSourceOrderIds.value = sourceIds
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '库存来源订单加载失败')
@@ -1405,11 +1469,11 @@ function selectRelabelInventory(inboundOrderId: string) {
<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 === 'disposal'" class="warehouse-disposal-field"><span><i>*</i>操作仓</span><el-select v-model="form.warehouse" filterable clearable :loading="fbaWarehousesLoading" :disabled="Boolean(operationWarehousesError)" placeholder="请选择海外仓" @change="selectDiscardWarehouse"><el-option v-for="warehouse in operationWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select><small v-if="operationWarehousesError" class="warehouse-hint">{{ operationWarehousesError }}</small><small v-else class="warehouse-hint">请先选择本次废弃货物所在的海外仓。</small></label>
<label v-if="props.service === 'disposal'" class="warehouse-disposal-field"><span><i>*</i>选择入库单</span><el-select v-model="selectedDiscardOrderId" filterable clearable :loading="discardCandidatesLoading" :disabled="!form.warehouse" no-data-text="当前海外仓暂无可废弃库存" placeholder="请先选择操作仓,再选择有可用库存的入库单" @change="selectDiscardInbound"><el-option v-for="candidate in discardCandidates" :key="candidate.orderId" :label="candidate.sourceType === 'RETURN_CARTON' ? `退货|${candidate.inboundOrderNo || '—'}|${candidate.cartonMark || '—'}|可用 ${candidate.availablePackages}` : `${candidate.inboundOrderNo || candidate.inboundCode || '—'}|${candidate.inboundCode || '—'}|可用 ${candidate.availablePackages} 箱`" :value="candidate.orderId" /></el-select><small class="warehouse-hint">显示当前操作仓内有可用数量的普通入库库存和退货库存。</small></label>
<label v-if="props.service === '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-if="props.service === 'disposal'" class="warehouse-disposal-field"><span><i>*</i>废弃原因</span><el-select v-model="form.reason" placeholder="请选择"><el-option label="客户退货" value="客户退货" /><el-option label="亚马逊移除" value="亚马逊移除" /><el-option label="残损不可售" value="残损不可售" /></el-select></label>
<label v-else><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></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} ${option.quantityUnit}${option.relabelledPackages > 0 ? `|已换标 ${option.relabelledPackages} ${option.quantityUnit}` : ''}`" :value="option.value" /></el-select></label>
<label v-if="props.service === 'transfer'"><span>库存转运来源(可选)</span><el-select v-model="selectedTransferInventory" filterable clearable :loading="relabelCandidatesLoading" :disabled="!form.warehouse" no-data-text="当前海外仓暂无可用库存" placeholder="不选则按新货转运;选择后免 PDA 测量" @change="selectTransferInventory"><el-option v-for="option in transferInventoryOptions" :key="option.value" :label="`${option.label}|可用 ${option.packages} 箱`" :value="option.value" /></el-select><small class="warehouse-hint">库存转运可直接进入目的仓、换标和排车流程,PDA 可拆托、重打或同目的仓合托。</small></label>
@@ -1426,7 +1490,7 @@ function selectRelabelInventory(inboundOrderId: string) {
</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 === 'relabel'"><span><i>*</i>退货原因</span><el-select v-model="form.reason" placeholder="请选择"><el-option label="客户退货" value="客户退货" /><el-option label="亚马逊移除" value="亚马逊移除" /><el-option label="残损不可售" value="残损不可售" /></el-select></label>
<label v-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>
@@ -1437,7 +1501,7 @@ function selectRelabelInventory(inboundOrderId: string) {
<label v-if="props.service === 'transfer' || props.service === 'relabel'"><span><i>*</i>{{ props.service === 'transfer' ? '预估体积(CBM)' : '预估总体积(CBM)' }}</span><el-input-number v-model="form.volumeCbm" :min="0" :precision="4" :step="0.0001" controls-position="right" placeholder="请输入预估总体积" /></label>
<label v-if="props.service === 'transfer' || props.service === 'relabel'"><span><i>*</i>{{ props.service === 'transfer' ? '预计入仓时间' : '预计到达时间' }}</span><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :placeholder="props.service === 'transfer' ? '请选择预计入仓时间' : '请选择预计到达时间'" /></label>
<label v-if="props.service === 'transfer'"><span>期望发货时间</span><el-date-picker v-model="form.estimatedShipAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="选填" /></label>
<div v-if="props.service === 'transfer'" class="warehouse-choice-field warehouse-span-2"><span><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></div>
<div v-if="props.service === 'transfer'" class="warehouse-choice-field warehouse-span-2"><span>目的去向</span><el-radio-group v-model="transferDestinationType" @change="selectTransferDestinationType"><el-radio-button value="NONE">暂未确定</el-radio-button><el-radio-button value="FBA">FBA 仓</el-radio-button><el-radio-button value="PRIVATE">私人仓</el-radio-button></el-radio-group></div>
<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></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'"><span><i>*</i>私人仓地址</span><el-input v-model="form.privateDestinationAddress" maxlength="1000" placeholder="请输入私人仓完整地址" /></label>
@@ -1539,7 +1603,7 @@ function selectRelabelInventory(inboundOrderId: string) {
<header class="warehouse-detail-header">
<button type="button" class="warehouse-detail-back" @click="returnToList"><ArrowLeft :size="16" />返回列表</button>
<div class="warehouse-detail-title"><strong>{{ props.service === 'labeling' ? selectedRelabelDetail?.application.relabelNo : selectedDetail?.transferNo || selectedDetail?.['0'] || `${config.title}详情` }}</strong><span>{{ config.title }}详情</span></div>
<div class="warehouse-detail-actions"><el-button v-if="canCreate && props.service === 'labeling' && selectedRelabelDetail?.application.status === 'REJECTED'" type="primary" @click="resubmitRejectedRelabel">修改后重新提交</el-button><el-button v-if="canConfirmRelabel && props.service === 'labeling' && selectedRelabelDetail?.application.status === 'CUSTOMER_CONFIRM_PENDING' && !selectedRelabelDetail?.application.customerConfirmedAt" type="primary" @click="confirmSelectedRelabel">确认换标完成</el-button><el-button v-if="canCancelDiscard && props.service === 'disposal' && selectedDetail?.status === 'PENDING_REVIEW'" type="danger" @click="cancelSelectedDiscard">取消申请</el-button><el-button v-if="canCreate && props.service === 'transfer' && 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-tag :type="props.service === 'labeling' ? relabelStatusTagType(selectedRelabelDetail?.application.status || '') : undefined" effect="light">{{ statusLabel(props.service === 'labeling' ? selectedRelabelDetail?.application.status || '' : String(selectedDetail?.status || '')) }}</el-tag></div>
<div class="warehouse-detail-actions"><el-button v-if="canCreate && props.service === 'labeling' && selectedRelabelDetail?.application.status === 'REJECTED'" type="primary" @click="resubmitRejectedRelabel">修改后重新提交</el-button><el-button v-if="canConfirmRelabel && props.service === 'labeling' && selectedRelabelDetail?.application.status === 'CUSTOMER_CONFIRM_PENDING' && !selectedRelabelDetail?.application.customerConfirmedAt" type="primary" @click="confirmSelectedRelabel">确认换标完成</el-button><el-button v-if="canCancelDiscard && props.service === 'disposal' && selectedDetail?.status === 'PENDING_REVIEW'" type="danger" @click="cancelSelectedDiscard">取消申请</el-button><el-button v-if="canCreate && props.service === 'transfer' && ['DRAFT', 'REJECTED'].includes(selectedDetail?.status || '')" type="primary" @click="openEdit(String(selectedDetail?.transferNo || selectedDetail?.['0'] || ''))">编辑</el-button><el-button v-if="canConfirmTransfer && props.service === 'transfer' && selectedDetail?.status === 'WAIT_CUSTOMER_CONFIRMATION'" type="primary" @click="confirmTransferMeasurements">确认测量数据</el-button><el-tag :type="props.service === 'labeling' ? relabelStatusTagType(selectedRelabelDetail?.application.status || '') : undefined" effect="light">{{ statusLabel(props.service === 'labeling' ? selectedRelabelDetail?.application.status || '' : String(selectedDetail?.status || '')) }}</el-tag></div>
</header>
<div v-if="detailError" class="warehouse-detail-error" role="alert"><strong>{{ detailError }}</strong><el-button @click="loadDetailPage">重新加载</el-button></div>
<el-alert v-else-if="props.service === 'labeling' && selectedRelabelDetail?.application.status === 'REJECTED'" class="warehouse-rejected-alert" type="error" :closable="false" show-icon title="贴标单审核已驳回" :description="selectedRelabelDetail.application.reviewRemark || '平台未填写驳回备注'" />
@@ -1628,6 +1692,7 @@ function selectRelabelInventory(inboundOrderId: string) {
.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; }
.warehouse-disposal-field { max-width: 520px; }
@media (max-width: 720px) {
.warehouse-pagination :deep(.el-pagination) { width: 100%; justify-content: flex-end; }
.warehouse-transfer-main-card .warehouse-form-grid { grid-template-columns: minmax(0, 1fr); }
@@ -319,7 +319,9 @@ test('仓储转运可在新建页直接提交,也可保存草稿后编辑提
assert.match(api, /warehouse-transfers\/\$\{encodeURIComponent\(transferNo\)\}\/submit/)
assert.match(view, /submit\('draft'\).*保存草稿/s)
assert.match(view, /async function openEdit\(transferNo: string\)/)
assert.match(view, /item\.__status === 'DRAFT'.*openEdit\(item\['0'\]\)/s)
assert.match(view, /\['DRAFT', 'REJECTED'\]\.includes\(item\.__status\).*openEdit\(item\['0'\]\)/s)
assert.match(view, /\['DRAFT', 'REJECTED'\]\.includes\(detail\.status \|\| ''\)/)
assert.match(view, /\['DRAFT', 'REJECTED'\]\.includes\(selectedDetail\?\.status \|\| ''\)/)
assert.match(view, /v-if="props\.service === 'transfer'"[^>]*type="primary"[^>]*native-type="submit"[^>]*>提交订单/)
assert.match(view, /await updateTransfer\(editingTransferNo\.value, payload\)/)
assert.match(view, /await submitTransfer\(transferNo\)/)
@@ -332,6 +334,22 @@ test('仓储转运可在新建页直接提交,也可保存草稿后编辑提
assert.doesNotMatch(view, /<h3>箱唛明细<\/h3>/)
})
test('编辑仓储转运时加载库存转运来源候选', () => {
assert.match(view, /async function loadTransferInventoryCandidates\(warehouseId: string\)/)
assert.match(view, /if \(props\.service === 'transfer' && form\.warehouse\) \{[\s\S]*await loadTransferInventoryCandidates\(form\.warehouse\)/)
assert.match(view, /transferInventoryCandidates\.value = await listRelabelInventoryCandidates\(warehouseId\)/)
assert.match(view, /transferInventoryOptions\.value\.some\(\(option\) => option\.value === sourceInboundCode\)/)
assert.match(view, /selectedTransferInventory\.value = sourceInboundCode/)
assert.match(view, /detail\.sourceOrders \|\| \[\]/)
})
test('仓储转运来源订单雪花 ID 按字符串传递,避免 Number 精度丢失', () => {
assert.match(view, /const selectedTransferSourceOrderIds = ref<string\[\]>\(\[\]\)/)
assert.match(view, /historical\.map\(\(item\) => String\(item\.id \|\| ''\)\)/)
assert.match(api, /sourceLastlegOrderIds\?: string\[\]/)
assert.doesNotMatch(view, /sourceLastlegOrderIds[\s\S]{0,160}Number\(/)
})
test('转运贴标单在PDA确认库存前不误报附件上传失败', () => {
assert.match(view, /if \(!relabelNo\) \{[\s\S]*PDA 入库确认后将自动生成关联换标单/)
assert.match(view, /届时请进入关联换标单详情上传贴标总文件/)