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

This commit is contained in:
2026-10-16 16:28:49 +08:00
8 changed files with 326 additions and 105 deletions
@@ -45,7 +45,7 @@ export interface TransferCreate {
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
deliveryAddress?: string; packageCount: number; volumeCbm: number; deliveryMethod?: string; estimatedArrivalAt?: string; estimatedShipAt?: string
fbaShipmentNo?: string; trackingNo?: string; fbaBoxNos?: string; remark?: string; needRelabel: boolean; idempotencyKey: string
labelDeadline?: string; pickupContactName?: string; pickupContactPhone?: string; relabelContactName?: string; relabelContactPhone?: string; relabelChargeAmount?: number; relabelDeliveryMethod?: RelabelDeliveryMethod
items: Array<{ skuCode?: string; productNameZh?: string; quantity: number; packageCount: number; shippingMark?: string }>
@@ -76,6 +76,10 @@ export function updateTransfer(transferNo: string, payload: TransferCreate) {
export function submitTransfer(transferNo: string) {
return apiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/submit` })
}
/** 补充既有SKU的预报箱数,原箱数快照用于并发核对与安全重试。 */
export function increaseTransferForecastCartons(transferNo: string, payload: { expectedPackageCount: number; transferItemId: string; expectedItemPackageCount: number; packageCount: number }) {
return apiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/increase-forecast-cartons`, data: payload })
}
export function listRelabels(params: Record<string, unknown>) {
return apiRequest<PageResult<RelabelSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/relabel-applications', params })
}
@@ -142,3 +146,8 @@ export interface DiscardPhoto { fileId: string; palletNo: string; fileName: stri
export function downloadDiscardPhoto(discardNo: string, fileId: string) {
return apiBlobRequest({ method: 'GET', url: `/merchant/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/photos/files/${encodeURIComponent(fileId)}/download` })
}
export interface TransferSourceCarton { cartonMark: string; productNameZh?: string; quantity?: number; destinationWarehouseCode?: string; destinationWarehouseName?: string; items: Array<{skuCode?: string; quantity?: number}> }
export function getTransferSourceCartons(operatingWarehouseId: string, inboundCode: string) {
return apiRequest<TransferSourceCarton[]>({method:'GET',url:'/merchant/v1/last-leg/warehouse-transfers/inventory-source-cartons',params:{operatingWarehouseId,inboundCode}})
}
@@ -14,6 +14,9 @@ function harness(kind = 'disposal', overrides = {}) {
const api = {
getDiscard: async () => ({ summary: { status: 'PDA_COMPLETED' }, photos: [{ fileId: '101' }] }),
getRelabel: async () => ({ application: { status: 'CUSTOMER_CONFIRM_PENDING' }, completionEvidence: { photoFileIds: ['101'] } }),
getTransfer: async () => ({ status: 'WAIT_CUSTOMER_CONFIRMATION', measurementPhotos: [{ fileId: '101' }], cartons: [{ cartonMark: 'BOX-1', status: 'COUNTED', weight: 5 }], pallets: [{ palletNo: 'PLT-1', grossWeightKg: 5 }] }),
downloadTransferMeasurementPhoto: async () => ({}),
confirmTransferMeasurement: async no => calls.push(['transfer', no]),
downloadDiscardPhoto: async () => ({}), downloadRelabelCompletionPhoto: async () => ({}),
confirmDiscardCompletion: async no => calls.push(['discard', no]),
confirmRelabelCompletion: async no => calls.push(['relabel', no]), ...overrides,
@@ -24,11 +27,11 @@ function harness(kind = 'disposal', overrides = {}) {
useAuthStore: () => ({ hasPermission: () => true }), ElMessage: { success() {}, error() {} },
URL: { createObjectURL: () => 'blob:photo', revokeObjectURL: url => revoked.push(url) }, ...api,
}
const state = new Function(...Object.keys(env), js + '\nreturn { loadEvidence, loadPhoto, submit, clear, checked, canConfirm, urls, failed, error, busy };')(...Object.values(env))
const state = new Function(...Object.keys(env), js + '\nreturn { loadEvidence, loadPhoto, submit, clear, checked, canConfirm, urls, failed, error, busy, measurement };')(...Object.values(env))
return { ...state, props, calls, events, revoked }
}
for (const kind of ['disposal', 'labeling']) {
for (const kind of ['disposal', 'labeling', 'transfer']) {
test(`${kind}: opening loads photos without confirming; only reviewed evidence can be submitted`, async () => {
const state = harness(kind)
await state.loadEvidence()
@@ -37,12 +40,33 @@ for (const kind of ['disposal', 'labeling']) {
state.checked.value = true
assert.equal(state.canConfirm.value, true)
await state.submit()
assert.deepEqual(state.calls, [[kind === 'disposal' ? 'discard' : 'relabel', 'CD/1']])
assert.deepEqual(state.calls, [[kind === 'transfer' ? 'transfer' : kind === 'disposal' ? 'discard' : 'relabel', 'CD/1']])
assert.deepEqual(state.events, [['confirmed', 'CD/1'], ['update:modelValue', false]])
state.clear(); assert.deepEqual(state.revoked, ['blob:photo'])
})
}
test('transfer confirmation reads current measurements and blocks missing, failed or outdated evidence', async () => {
const state = harness('transfer', { downloadTransferMeasurementPhoto: async () => { throw Error('offline') } })
await state.loadEvidence()
assert.equal(state.measurement.value.pallets[0].grossWeightKg, 5)
assert.equal(state.measurement.value.cartons[0].cartonMark, 'BOX-1')
state.checked.value = true
await state.submit()
assert.equal(state.canConfirm.value, false)
assert.deepEqual(state.calls, [])
for (const detail of [
{ status: 'WAIT_CUSTOMER_CONFIRMATION', measurementPhotos: [] },
{ status: 'COMPLETED', measurementPhotos: [{ fileId: '101' }] },
{ status: 'WAIT_CUSTOMER_CONFIRMATION', customerConfirmedAt: '2026-10-16', measurementPhotos: [{ fileId: '101' }] },
]) {
const invalid = harness('transfer', { getTransfer: async () => detail })
await invalid.loadEvidence(); invalid.checked.value = true; await invalid.submit()
assert.equal(invalid.canConfirm.value, false)
assert.deepEqual(invalid.calls, [])
}
})
test('photo failure blocks confirmation and supports retry', async () => {
let reject = true
const state = harness('disposal', { downloadDiscardPhoto: async () => { if (reject) throw Error('offline'); return {} } })
@@ -2,12 +2,20 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/modules/auth/public'
import { confirmDiscardCompletion, confirmRelabelCompletion, downloadDiscardPhoto, downloadRelabelCompletionPhoto, getDiscard, getRelabel, type DiscardPhoto } from '../api/warehouseServices'
import { confirmDiscardCompletion, confirmRelabelCompletion, confirmTransferMeasurement, downloadDiscardPhoto, downloadRelabelCompletionPhoto, downloadTransferMeasurementPhoto, getDiscard, getRelabel, getTransfer, type DiscardPhoto } from '../api/warehouseServices'
const props = defineProps<{ modelValue: boolean; kind: 'disposal' | 'labeling'; documentNo: string }>()
const props = defineProps<{ modelValue: boolean; kind: 'disposal' | 'labeling' | 'transfer'; documentNo: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; confirmed: [documentNo: string] }>()
const auth = useAuthStore()
const title = computed(() => props.kind === 'disposal' ? '废弃确认' : '贴标完成确认')
const title = computed(() => props.kind === 'transfer' ? '确认测量数据' : props.kind === 'disposal' ? '废弃确认' : '贴标完成确认')
interface MeasurementEvidence {
status?: string
customerConfirmedAt?: string
cartons?: Array<{ cartonMark: string; status: string; length?: number; width?: number; height?: number; weight?: number }>
pallets?: Array<{ palletNo?: string; sequenceNo?: number; cartonCount?: number; lengthCm?: number; widthCm?: number; heightCm?: number; grossWeightKg?: number; volumeCbm?: number }>
measurementPhotos?: Array<{ fileId: string | number }>
}
const measurement = ref<MeasurementEvidence | null>(null)
const loading = ref(false)
const busy = ref(false)
const error = ref('')
@@ -21,18 +29,19 @@ let generation = 0
const previewUrls = computed(() => fileIds.value.map(id => urls.value[id]).filter(Boolean))
const canConfirm = computed(() => eligible.value && checked.value && !loading.value && !busy.value && !error.value
&& fileIds.value.length > 0 && fileIds.value.every(id => Boolean(urls.value[id]))
&& auth.hasPermission(`merchant:overseas-warehouse:${props.kind === 'disposal' ? 'discard' : 'relabel'}:confirm`))
&& auth.hasPermission(`merchant:overseas-warehouse:${props.kind === 'transfer' ? 'transfer' : props.kind === 'disposal' ? 'discard' : 'relabel'}:confirm`))
function clear() {
generation++
Object.values(urls.value).forEach(URL.revokeObjectURL)
urls.value = {}; failed.value = {}; photoLoading.value = {}; fileIds.value = []; checked.value = false; eligible.value = false
measurement.value = null
}
async function loadPhoto(fileId: string) {
if (photoLoading.value[fileId]) return
const currentGeneration = generation; const no = props.documentNo; const kind = props.kind
photoLoading.value[fileId] = true; failed.value[fileId] = false
try {
const blob = kind === 'disposal' ? await downloadDiscardPhoto(no, fileId) : await downloadRelabelCompletionPhoto(no, fileId)
const blob = kind === 'transfer' ? await downloadTransferMeasurementPhoto(no, fileId) : kind === 'disposal' ? await downloadDiscardPhoto(no, fileId) : await downloadRelabelCompletionPhoto(no, fileId)
if (generation !== currentGeneration || !props.modelValue) return
if (urls.value[fileId]) URL.revokeObjectURL(urls.value[fileId])
urls.value[fileId] = URL.createObjectURL(blob)
@@ -43,7 +52,13 @@ async function loadEvidence() {
clear(); error.value = ''; loading.value = true
const currentGeneration = generation
try {
if (props.kind === 'disposal') {
if (props.kind === 'transfer') {
const detail = await getTransfer(props.documentNo) as MeasurementEvidence
if (generation !== currentGeneration) return
measurement.value = detail
eligible.value = detail.status === 'WAIT_CUSTOMER_CONFIRMATION' && !detail.customerConfirmedAt
fileIds.value = (detail.measurementPhotos || []).map(photo => String(photo.fileId))
} else if (props.kind === 'disposal') {
const detail = await getDiscard(props.documentNo) as { summary?: { status?: string }; photos?: DiscardPhoto[] }
if (generation !== currentGeneration) return
eligible.value = detail.summary?.status === 'PDA_COMPLETED'
@@ -56,7 +71,7 @@ async function loadEvidence() {
}
fileIds.value = [...new Set(fileIds.value)]
if (!eligible.value) error.value = '当前单据已确认或尚未完成 PDA 作业,请刷新列表核对。'
else if (!fileIds.value.length) error.value = '暂无 PDA 完工作业照片,暂不能确认。'
else if (!fileIds.value.length) error.value = props.kind === 'transfer' ? '暂无 PDA 测量打托照片,暂不能确认。' : '暂无 PDA 完工作业照片,暂不能确认。'
await Promise.all(fileIds.value.map(loadPhoto))
} catch (e) { if (generation === currentGeneration) error.value = e instanceof Error ? e.message : '作业照片加载失败' }
finally { if (generation === currentGeneration) loading.value = false }
@@ -65,9 +80,10 @@ async function submit() {
if (!canConfirm.value) return
busy.value = true
try {
if (props.kind === 'disposal') await confirmDiscardCompletion(props.documentNo)
if (props.kind === 'transfer') await confirmTransferMeasurement(props.documentNo)
else if (props.kind === 'disposal') await confirmDiscardCompletion(props.documentNo)
else await confirmRelabelCompletion(props.documentNo)
ElMessage.success(props.kind === 'disposal' ? '废弃已确认,单据已完结' : '贴标已确认,订单进入后续出库流程')
ElMessage.success(props.kind === 'transfer' ? '仓库实测数据已确认' : props.kind === 'disposal' ? '废弃已确认,单据已完结' : '贴标已确认,订单进入后续出库流程')
emit('confirmed', props.documentNo)
emit('update:modelValue', false)
} catch (e) { ElMessage.error(e instanceof Error ? e.message : '确认失败,请重试') }
@@ -80,7 +96,22 @@ onBeforeUnmount(clear)
<template>
<el-dialog :model-value="modelValue" :title="title" width="760px" :close-on-click-modal="false" :close-on-press-escape="!busy" :show-close="!busy" @update:model-value="!busy && emit('update:modelValue', $event)">
<p>单号:{{ documentNo }}</p>
<p>请核对以下 PDA 作业照片,确认作业结果无误后再提交。</p>
<p>{{ kind === 'transfer' ? '请核对 PDA 回传的箱数、尺寸、重量、打托数据及现场照片,确认无误后再提交。' : '请核对以下 PDA 作业照片,确认作业结果无误后再提交。' }}</p>
<template v-if="kind === 'transfer' && measurement">
<p>实际签收箱数:{{ measurement.cartons?.filter(carton => carton.status === 'COUNTED').length ?? 0 }} 箱;托盘数:{{ measurement.pallets?.length ?? 0 }} 托</p>
<el-table :data="measurement.pallets || []" max-height="220" size="small" border empty-text="暂无 PDA 打托测量数据">
<el-table-column label="托盘号" min-width="180"><template #default="{ row }">{{ row.palletNo || `第${row.sequenceNo || '—'}托` }}</template></el-table-column>
<el-table-column prop="cartonCount" label="箱数" width="65" />
<el-table-column label="尺寸(cm)" min-width="130"><template #default="{ row }">{{ row.lengthCm ?? '—' }} × {{ row.widthCm ?? '—' }} × {{ row.heightCm ?? '—' }}</template></el-table-column>
<el-table-column prop="grossWeightKg" label="毛重(kg)" width="95" />
<el-table-column prop="volumeCbm" label="体积(m³)" width="95" />
</el-table>
<el-table :data="measurement.cartons?.filter(carton => carton.status === 'COUNTED') || []" max-height="220" size="small" border empty-text="暂无已签收箱子">
<el-table-column prop="cartonMark" label="箱唛" min-width="200" />
<el-table-column label="尺寸(cm)" min-width="180"><template #default="{ row }">{{ row.length ?? '—' }} × {{ row.width ?? '—' }} × {{ row.height ?? '—' }}</template></el-table-column>
<el-table-column prop="weight" label="重量(kg)" width="110" />
</el-table>
</template>
<p v-if="loading">正在读取作业照片…</p>
<el-alert v-if="error" :title="error" type="error" :closable="false" />
<el-button v-if="error" :disabled="loading || busy" @click="loadEvidence">重新读取</el-button>
@@ -91,10 +122,10 @@ onBeforeUnmount(clear)
<el-button v-else :disabled="busy" @click="loadPhoto(fileId)">{{ failed[fileId] ? '照片加载失败,点击重试' : '加载照片' }}</el-button>
</div>
</div>
<el-checkbox v-model="checked" :disabled="loading || busy || !!error || previewUrls.length !== fileIds.length">我已核对 PDA 作业照片,确认作业结果无误</el-checkbox>
<el-checkbox v-model="checked" :disabled="loading || busy || !!error || previewUrls.length !== fileIds.length">{{ kind === 'transfer' ? '我已核对 PDA 实测数据及现场照片,确认无误' : '我已核对 PDA 作业照片,确认作业结果无误' }}</el-checkbox>
<template #footer>
<el-button :disabled="busy" @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="busy" :disabled="!canConfirm" @click="submit">{{ kind === 'disposal' ? '确认废弃完结' : '确认贴标完成' }}</el-button>
<el-button type="primary" :loading="busy" :disabled="!canConfirm" @click="submit">{{ kind === 'transfer' ? '确认数据' : kind === 'disposal' ? '确认废弃完结' : '确认贴标完成' }}</el-button>
</template>
</el-dialog>
</template>
@@ -39,6 +39,8 @@ test('箱子在签收箱号明细展示一次,真实托盘和缺少箱号明
test('parcel waybills appear as tracking numbers while generated parcel codes remain physical codes', () => {
assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'sf561626' }), 'sf561626')
assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'PK0123456789ABCDEF' }), '')
assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'PK-FUW3Q9N0B8RL' }), '')
assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'PK-FUW3Q9N0B8RL', trackingNo: ' SF123 ' }), 'SF123')
assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'PK0123456789ABCDEF', trackingNo: ' SF123 ' }), 'SF123')
assert.equal(exports.receiptTrackingNo({ unitType: 'PALLET', unitCode: 'PLT123' }), '')
})
@@ -18,7 +18,7 @@ export function receiptTrackingNo(receipt: Pick<ReturnReceipt, 'trackingNo' | 'u
const trackingNo = typeof receipt.trackingNo === 'string' ? receipt.trackingNo.trim() : ''
if (trackingNo) return trackingNo
const code = typeof receipt.unitCode === 'string' ? receipt.unitCode.trim() : ''
return receipt.unitType === 'PARCEL' && code && !/^PK[0-9A-F]{16}$/.test(code) ? code : ''
return receipt.unitType === 'PARCEL' && code && !/^(?:PK[0-9A-F]{16}|PK-[0-9A-Z]+)$/.test(code) ? code : ''
}
export function formatReturnVolume(value?: number | null) {
@@ -11,8 +11,8 @@ import CompletionConfirmationDialog from '../components/CompletionConfirmationDi
import { returnRelabelCandidate } from '../utils/returnRelabelCandidates'
import {
createDiscard, createRelabel, createTransfer, getDiscard, getRelabel, getTransfer, resubmitRelabel,
getTransferCartonLabel, submitTransfer, updateTransfer,
confirmTransferMeasurement, downloadRelabelCompletionPhoto, downloadTransferMeasurementPhoto, downloadDiscardPhoto, type DiscardPhoto, uploadRelabelShippingLabel,
getTransferCartonLabel, submitTransfer, updateTransfer, increaseTransferForecastCartons, getTransferSourceCartons, type TransferSourceCarton,
downloadRelabelCompletionPhoto, downloadTransferMeasurementPhoto, downloadDiscardPhoto, type DiscardPhoto, uploadRelabelShippingLabel,
uploadRelabelFile,
listDiscards, listDiscardCandidates, listRelabelInventoryCandidates, listRelabelHistoricalOrders, listRelabels, listTransfers, cancelDiscard,
type RelabelInventoryCandidate,
@@ -58,8 +58,8 @@ const canConfirmTransfer = computed(() => auth.hasPermission('merchant:overseas-
const canConfirmDiscard = computed(() => auth.hasPermission('merchant:overseas-warehouse:discard:confirm'))
const completionConfirmOpen = ref(false)
const completionConfirmNo = ref('')
const completionConfirmKind = ref<'disposal' | 'labeling'>('disposal')
function openCompletionConfirmation(no: string, kind: 'disposal' | 'labeling') {
const completionConfirmKind = ref<'disposal' | 'labeling' | 'transfer'>('disposal')
function openCompletionConfirmation(no: string, kind: 'disposal' | 'labeling' | 'transfer') {
if (!no) return
completionConfirmNo.value = no; completionConfirmKind.value = kind; completionConfirmOpen.value = true
}
@@ -149,6 +149,34 @@ interface ServiceDetail extends Record<string, unknown> {
cartons?: TransferCarton[]; pallets?: TransferPallet[]; measurementPhotos?: TransferMeasurementPhoto[]; customerConfirmedAt?: string
}
const selectedDetail = ref<ServiceDetail | null>(null)
// 预报箱唛在提交后即可打印,与PDA实收清点明细分别展示。
const showForecastCartons = ref(false)
const increaseForecastOpen = ref(false)
const increasingForecast = ref(false)
const forecastSnapshot = ref<ServiceDetail | null>(null)
const increaseForecastForm = reactive({ itemId: '', additionalCount: 1 })
const canIncreaseForecast = computed(() => canCreate.value && selectedDetail.value?.transferNo === selectedDetail.value?.inboundCode && ['PENDING_REVIEW', 'WAIT_PDA_RECEIVING'].includes(selectedDetail.value?.status || ''))
/** 保存原预报快照,防止弹窗打开后其他设备的修改被覆盖。 */
function openIncreaseForecast() {
if (!canIncreaseForecast.value || !selectedDetail.value) return
forecastSnapshot.value = selectedDetail.value
increaseForecastForm.itemId = String(selectedDetail.value.items?.[0]?.id || '')
increaseForecastForm.additionalCount = 1; increaseForecastOpen.value = true
}
/** 只补箱唛,SKU预报件数不随包装箱数自动改变。 */
async function saveIncreaseForecast() {
if (increasingForecast.value || !forecastSnapshot.value?.transferNo) return
const snapshot = forecastSnapshot.value
const item = snapshot.items?.find(row => String(row.id) === increaseForecastForm.itemId)
if (!item || !Number.isSafeInteger(increaseForecastForm.additionalCount) || increaseForecastForm.additionalCount < 1) { ElMessage.warning('请选择SKU并填写新增箱数'); return }
increasingForecast.value = true
try {
const result = await increaseTransferForecastCartons(String(snapshot.transferNo), { expectedPackageCount: Number(snapshot.packageCount), transferItemId: String(item.id), expectedItemPackageCount: Number(item.packageCount), packageCount: Number(item.packageCount) + increaseForecastForm.additionalCount })
selectedDetail.value = result as ServiceDetail; increaseForecastOpen.value = false
ElMessage.success('预报箱数已增加,新增箱唛可打印;请通知PDA刷新后继续清点')
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '增加预报箱数失败') }
finally { increasingForecast.value = false }
}
const showTransferCartons = ref(false)
function transferCartonItem(carton: TransferCarton) {
return selectedDetail.value?.items?.find((item) => carton.transferItemId != null && String(item.id) === String(carton.transferItemId))
@@ -295,6 +323,40 @@ const availableSkusCollapsed = ref(false)
const relabelCandidatesLoading = ref(false)
const transferInventoryCandidates = ref<RelabelInventoryCandidate[]>([])
const selectedTransferInventory = ref('')
const inventoryTransfer = computed(() => props.service === 'transfer' && Boolean(selectedTransferInventory.value))
const transferSourceCartons = ref<TransferSourceCarton[]>([])
const transferSourceLoading = ref(false)
const transferSourceError = ref('')
let transferSourceRequest = 0
async function loadTransferSourceCartons() {
const request = ++transferSourceRequest
transferSourceCartons.value = []; transferSourceError.value = ''
if (!inventoryTransfer.value) return
transferSourceLoading.value = true
try {
// 退货候选本身就是逐箱库存,使用与下拉一致的实物箱数据,不能按普通入仓快照回查。
if (selectedTransferInventory.value.startsWith('RETURN_ORDER:')) {
const returnNo = selectedTransferInventory.value.slice('RETURN_ORDER:'.length)
transferSourceCartons.value = transferInventoryCandidates.value
.filter(item => (item.sourceType === 'RETURN_CARTON' || item.sourceType === 'RETURN_PARCEL')
&& String(item.inboundOrderNo || '').trim() === returnNo && Number(item.cartonAvailablePackages || 0) > 0)
.filter(item => Boolean(item.cartonMark))
.map(item => {
const contents = item.cartonSkuItems?.length ? item.cartonSkuItems : item.skuItems || []
return { cartonMark: item.cartonMark!, productNameZh: item.productNameZh,
quantity: contents.reduce((total, sku) => total + Number(sku.quantity || 0), 0),
destinationWarehouseCode: item.destinationFbaWarehouseCode,
destinationWarehouseName: item.destinationFbaWarehouseName,
items: contents.map(sku => ({ skuCode: sku.skuCode, quantity: Number(sku.quantity || 0) })) }
})
if (!transferSourceCartons.value.length) throw new Error('当前退货单没有可转运箱,请刷新库存后重试')
return
}
const cartons = await getTransferSourceCartons(form.warehouse, selectedTransferInventory.value)
if (request === transferSourceRequest) transferSourceCartons.value = cartons
} catch (error) { if (request === transferSourceRequest) transferSourceError.value = error instanceof Error ? error.message : '原单箱明细加载失败' }
finally { if (request === transferSourceRequest) transferSourceLoading.value = false }
}
// 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[]>([])
@@ -546,6 +608,8 @@ async function openEdit(transferNo: string) {
const sourceInboundCode = String(detail.inboundCode || '')
if (transferInventoryOptions.value.some((option) => option.value === sourceInboundCode)) {
selectedTransferInventory.value = sourceInboundCode
await loadTransferSourceCartons()
if (transferDestinationType.value === 'NONE') transferDestinationType.value = 'FBA'
form.inboundNo = sourceInboundCode
selectedTransferSourceOrderIds.value = (detail.sourceOrders || [])
.map((item) => String(item.lastlegOrderId || '')).filter(Boolean)
@@ -577,36 +641,6 @@ async function selectDiscardWarehouse() {
}
function createCargoRow(): CargoRow { return { id: ++cargoSequence, sku: '', name: '', quantity: 1, expectedPackageCount: 1, fnsku: '', shipmentNo: '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', cartonMark: '', fbaCartonMark: '', destinationWarehouseId: '', remark: '' } }
function addCargoRow() { cargoRows.value.push(createCargoRow()) }
function escapePrintHtml(value: string) {
return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;')
}
function printTransferDraftCartonMarks() {
if (props.service !== 'transfer') return
const validRows = cargoRows.value.filter((row) => row.sku.trim() && Number(row.expectedPackageCount) > 0)
if (!validRows.length || validRows.some((row) => !Number.isInteger(Number(row.expectedPackageCount)))) {
ElMessage.warning('请先填写商品 SKU 和对应的整数箱数'); return
}
const totalCartons = validRows.reduce((sum, row) => sum + Number(row.expectedPackageCount), 0)
if (totalCartons > 500) { ElMessage.warning('单次最多打印 500 张箱唛'); return }
const batch = new Date().toISOString().replace(/\D/g, '').slice(2, 14)
const labels: Array<{ mark: string; sku: string; index: number }> = []
let cartonIndex = 0
for (const [rowIndex, row] of validRows.entries()) {
let marks = transferCustomerMarks(row)
if (marks.length !== Number(row.expectedPackageCount)) {
marks = Array.from({ length: Number(row.expectedPackageCount) }, (_, index) => `CT-${batch}-${String(rowIndex + 1).padStart(2, '0')}-${String(index + 1).padStart(3, '0')}`)
row.customerCartonMarks = marks.join('\n')
}
for (const mark of marks) labels.push({ mark, sku: row.sku.trim(), index: ++cartonIndex })
}
const printWindow = window.open('', '_blank')
if (!printWindow) { ElMessage.warning('浏览器阻止了打印窗口,请允许弹窗后重试'); return }
printWindow.opener = null
const sections = labels.map((label) => `<section class="label"><div class="title">仓储转运客户箱唛</div><div class="mark">${escapePrintHtml(label.mark)}</div><div class="sku">SKU:${escapePrintHtml(label.sku)}</div><div class="meta">${label.index} / ${labels.length}</div></section>`).join('')
printWindow.document.open()
printWindow.document.write(`<!doctype html><html><head><meta charset="utf-8"><title>仓储转运客户箱唛</title><style>@page{size:100mm 100mm;margin:8mm}body{margin:0;font-family:Arial,'Microsoft YaHei',sans-serif;color:#111}.label{height:84mm;display:flex;flex-direction:column;align-items:center;justify-content:center;page-break-after:always;border:1px solid #222;box-sizing:border-box;padding:8mm}.title{font-size:18px;font-weight:700}.mark{margin:11mm 0 6mm;font-size:26px;font-weight:700;letter-spacing:1px;word-break:break-all;text-align:center}.sku{font-size:17px;font-weight:650}.meta{font-size:12px;margin-top:4mm}</style></head><body>${sections}</body></html>`)
printWindow.document.close(); printWindow.focus(); printWindow.print()
}
function addTransferRelabelRow() {
const source = cargoRows.value[Math.min(transferRelabelRows.value.length, Math.max(0, cargoRows.value.length - 1))]
transferRelabelRows.value.push({ ...createCargoRow(), sku: source?.sku || '' })
@@ -1059,7 +1093,7 @@ function transferRelabelStatusTagType(status: string): StatusTagType {
function deliveryMethodLabel(value: string) {
const labels: Record<string, string> = {
TRUCK: '卡车派送', EXPRESS: '快递派送', AMAZON_PICKUP: '亚马逊自提', CUSTOMER_PICKUP: '客户自提',
WAREHOUSE_PICKUP: '仓库上门提货', CUSTOMER_DELIVERY: '客户送货到仓',
INVENTORY_TRANSFER: '库存转运', WAREHOUSE_PICKUP: '仓库上门提货', CUSTOMER_DELIVERY: '客户送货到仓',
// Keep the legacy value readable for historical applications created before the four-way split.
PICKUP: '仓库自提',
}
@@ -1161,7 +1195,7 @@ async function submit(action: 'draft' | 'submit' = 'submit') {
if (props.service === 'relabel' && (!form.destination || !form.address || !form.deliveryMethod || !form.estimatedArrivalAt || !form.fbaShipmentNo || form.volumeCbm <= 0)) {
ElMessage.warning('请填写完整的派送信息、体积、预计到达时间和 FBA 货件号'); return
}
if (props.service === 'transfer' && (!form.warehouse || !form.deliveryMethod || !form.address || !form.estimatedArrivalAt || (form.deliveryMethod === 'WAREHOUSE_PICKUP' && (!form.pickupContact || !form.pickupPhone)))) {
if (props.service === 'transfer' && !inventoryTransfer.value && (!form.warehouse || !form.deliveryMethod || !form.address || !form.estimatedArrivalAt || (form.deliveryMethod === 'WAREHOUSE_PICKUP' && (!form.pickupContact || !form.pickupPhone)))) {
const missing = [!form.warehouse && '海外仓', !form.deliveryMethod && '送货方式', !form.address && '地址', form.deliveryMethod === 'WAREHOUSE_PICKUP' && !form.pickupContact && '提货联系人', form.deliveryMethod === 'WAREHOUSE_PICKUP' && !form.pickupPhone && '联系人电话', !form.estimatedArrivalAt && '预计入仓时间'].filter(Boolean).join('、')
ElMessage.warning(`请填写:${missing || '必填信息'}`); return
}
@@ -1169,6 +1203,8 @@ async function submit(action: 'draft' | 'submit' = 'submit') {
!Number.isSafeInteger(row.quantity) || row.quantity < 1 || !Number.isSafeInteger(row.expectedPackageCount) || Number(row.expectedPackageCount) < 1))) {
ElMessage.warning('请填写每个 SKU 的数量(件)和预计箱数,均须为正整数'); return
}
if (inventoryTransfer.value && (transferSourceLoading.value || transferSourceError.value)) { ElMessage.warning('请等待原单箱明细加载成功后再保存'); return }
if (inventoryTransfer.value && transferDestinationType.value === 'NONE') { ElMessage.warning('库存转运请选择 FBA 仓或私人仓'); return }
if (props.service === 'transfer' && transferDestinationType.value === 'FBA' && !form.destination) {
ElMessage.warning('请选择 FBA 目的仓'); return
}
@@ -1182,6 +1218,9 @@ async function submit(action: 'draft' | 'submit' = 'submit') {
if (action === 'submit' && !requiresBothTotalFiles.value && !cartonTotalFile.value && !skuTotalFile.value) { ElMessage.warning('请上传箱唛总文件或 SKU 总文件(二选一)'); return }
const relabelRows = transferRelabelRows.value
const allMarks = relabelRows.flatMap(transferCustomerMarks)
if (inventoryTransfer.value && allMarks.some(mark => !transferSourceCartons.value.some(box => box.cartonMark === mark))) {
ElMessage.error('当前箱子不存在或不属于当前订单'); return
}
if (!relabelRows.length || relabelRows.some((row) => transferCustomerMarks(row).length !== row.quantity)) { ElMessage.warning('每个换标明细的客户箱唛数量必须与换标数量一致'); return }
if (new Set(allMarks.map((mark) => mark.toUpperCase())).size !== allMarks.length) { ElMessage.warning('客户箱唛号不能重复'); return }
if (allMarks.join(',').length > 2000 || relabelRows.some((row) => transferCustomerMarks(row).join(',').length > 255)) { ElMessage.warning('客户箱唛内容过长,请减少单次提交箱数或缩短箱唛号'); return }
@@ -1233,7 +1272,7 @@ async function submit(action: 'draft' | 'submit' = 'submit') {
const transferRemark = [form.remark, form.relabel && form.requirement ? `换标要求:${form.requirement}` : ''].filter(Boolean).join(';')
const operationType = labelingMode.value === 'carton' ? 'CARTON_LABEL' : labelingMode.value === 'sku' ? 'SKU_LABEL' : 'CARTON_AND_SKU'
const selectedFba = fbaWarehouses.value.find((warehouse) => warehouse.warehouseCode === form.destination)
const payload: TransferCreate = { operatingWarehouseId: primaryWarehouse, destinationType: transferDestinationType.value, fbaWarehouseCode: transferDestinationType.value === 'FBA' ? form.destination || undefined : undefined, destinationWarehouseId: transferDestinationType.value === 'FBA' ? String(selectedFba?.id || '') || undefined : undefined, destinationWarehouseCode: transferDestinationType.value === 'FBA' ? form.destination || undefined : undefined, destinationWarehouseName: transferDestinationType.value === 'FBA' ? selectedFba?.warehouseName : undefined, privateDestinationAddress: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationAddress : undefined, privateDestinationPostalCode: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationPostalCode : undefined, privateDestinationContactName: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationContact : undefined, privateDestinationContactPhone: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationPhone : undefined, deliveryAddress: form.address, packageCount, volumeCbm: 0, deliveryMethod: form.deliveryMethod, estimatedArrivalAt: form.estimatedArrivalAt, estimatedShipAt: form.estimatedShipAt || undefined, fbaShipmentNo: transferDestinationType.value === 'FBA' ? form.fbaShipmentNo || undefined : undefined, trackingNo: form.deliveryMethod === 'CUSTOMER_DELIVERY' ? form.trackingNo || undefined : undefined, fbaBoxNos: customerCartonMarks.length ? customerCartonMarks.join(',') : undefined, remark: transferRemark || undefined, needRelabel: props.service === 'relabel' || form.relabel, labelDeadline: form.relabel ? form.labelDeadline : undefined, pickupContactName: form.deliveryMethod === 'WAREHOUSE_PICKUP' ? form.pickupContact : undefined, pickupContactPhone: form.deliveryMethod === 'WAREHOUSE_PICKUP' ? form.pickupPhone : undefined, relabelContactName: form.relabel ? form.relabelContact : undefined, relabelContactPhone: form.relabel ? form.relabelPhone : undefined, relabelDeliveryMethod: form.relabel && labelingMode.value !== 'sku' ? form.relabelDeliveryMethod as 'TRUCK' | 'EXPRESS' | 'AMAZON_PICKUP' | 'CUSTOMER_PICKUP' : undefined, idempotencyKey, items: cargoRows.value.map((item) => ({ skuCode: item.sku, productNameZh: item.name, quantity: item.quantity, packageCount: Number(item.expectedPackageCount), shippingMark: transferCustomerMarks(item).join(',') || undefined })), relabelItems: form.relabel ? transferRelabelRows.value.map((item) => ({ operationType, customerCartonMarks: transferCustomerMarks(item), originalSku: item.sku, newSku: item.newSkuCode || undefined, quantity: item.quantity, fbaCartonMark: item.fbaCartonMark || undefined, destinationWarehouseCode: transferRelabelDefaultDestination.value || undefined, remark: item.remark || undefined })) : undefined }
const payload: TransferCreate = { operatingWarehouseId: primaryWarehouse, destinationType: transferDestinationType.value, fbaWarehouseCode: transferDestinationType.value === 'FBA' ? form.destination || undefined : undefined, destinationWarehouseId: transferDestinationType.value === 'FBA' ? String(selectedFba?.id || '') || undefined : undefined, destinationWarehouseCode: transferDestinationType.value === 'FBA' ? form.destination || undefined : undefined, destinationWarehouseName: transferDestinationType.value === 'FBA' ? selectedFba?.warehouseName : undefined, privateDestinationAddress: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationAddress : undefined, privateDestinationPostalCode: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationPostalCode : undefined, privateDestinationContactName: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationContact : undefined, privateDestinationContactPhone: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationPhone : undefined, deliveryAddress: inventoryTransfer.value ? undefined : form.address, packageCount, volumeCbm: 0, deliveryMethod: inventoryTransfer.value ? undefined : form.deliveryMethod, estimatedArrivalAt: inventoryTransfer.value ? undefined : form.estimatedArrivalAt, estimatedShipAt: form.estimatedShipAt || undefined, fbaShipmentNo: transferDestinationType.value === 'FBA' ? form.fbaShipmentNo || undefined : undefined, trackingNo: !inventoryTransfer.value && form.deliveryMethod === 'CUSTOMER_DELIVERY' ? form.trackingNo || undefined : undefined, fbaBoxNos: customerCartonMarks.length ? customerCartonMarks.join(',') : undefined, remark: transferRemark || undefined, needRelabel: props.service === 'relabel' || form.relabel, labelDeadline: form.relabel ? form.labelDeadline : undefined, pickupContactName: !inventoryTransfer.value && form.deliveryMethod === 'WAREHOUSE_PICKUP' ? form.pickupContact : undefined, pickupContactPhone: !inventoryTransfer.value && form.deliveryMethod === 'WAREHOUSE_PICKUP' ? form.pickupPhone : undefined, relabelContactName: form.relabel ? form.relabelContact : undefined, relabelContactPhone: form.relabel ? form.relabelPhone : undefined, relabelDeliveryMethod: form.relabel && labelingMode.value !== 'sku' ? form.relabelDeliveryMethod as 'TRUCK' | 'EXPRESS' | 'AMAZON_PICKUP' | 'CUSTOMER_PICKUP' : undefined, idempotencyKey, items: cargoRows.value.map((item) => ({ skuCode: item.sku, productNameZh: item.name, quantity: item.quantity, packageCount: Number(item.expectedPackageCount), shippingMark: transferCustomerMarks(item).join(',') || undefined })), relabelItems: form.relabel ? transferRelabelRows.value.map((item) => ({ operationType, customerCartonMarks: transferCustomerMarks(item), originalSku: item.sku, newSku: item.newSkuCode || undefined, quantity: item.quantity, fbaCartonMark: item.fbaCartonMark || undefined, destinationWarehouseCode: transferRelabelDefaultDestination.value || undefined, remark: item.remark || undefined })) : undefined }
if (props.service === 'transfer' && selectedTransferInventory.value) {
;(payload as TransferCreate).inboundCode = selectedTransferInventory.value
;(payload as TransferCreate).sourceLastlegOrderIds = selectedTransferSourceOrderIds.value
@@ -1298,9 +1337,7 @@ async function loadDetailPage() {
async function confirmTransferMeasurements() {
const transferNo = String(selectedDetail.value?.transferNo || selectedDetail.value?.['0'] || '')
if (!transferNo) return
try { await ElMessageBox.confirm('请核对 PDA 回传的箱数、尺寸、重量和打托数据。', '确认测量数据', { confirmButtonText: '确认数据', cancelButtonText: '取消' }) } catch { return }
try { selectedDetail.value = await confirmTransferMeasurement(transferNo) as ServiceDetail; ElMessage.success('仓库实测数据已确认') }
catch (error) { ElMessage.error(error instanceof Error ? error.message : '确认失败') }
openCompletionConfirmation(transferNo, 'transfer')
}
async function printTransferCartons(transferNo: string) {
if (props.service !== 'transfer') return
@@ -1321,9 +1358,7 @@ function clearCompletionPhotos() {
for (const key of Object.keys(completionPhotoErrors)) delete completionPhotoErrors[key]
}
async function confirmTransferFromList(transferNo: string) {
try { await ElMessageBox.confirm('请核对 PDA 回传的箱数、尺寸、重量和打托数据。', '确认测量数据', { confirmButtonText: '确认数据', cancelButtonText: '取消' }) } catch { return }
try { await confirmTransferMeasurement(transferNo); ElMessage.success('仓库实测数据已确认'); await load() }
catch (error) { ElMessage.error(error instanceof Error ? error.message : '确认失败') }
openCompletionConfirmation(transferNo, 'transfer')
}
async function loadCompletionPhoto(fileId: string) {
const relabelNo = selectedRelabelDetail.value?.application.relabelNo
@@ -1414,7 +1449,11 @@ async function loadTransferInventoryCandidates(warehouseId: string) {
if (!warehouseId) return
relabelCandidatesLoading.value = true
try {
transferInventoryCandidates.value = await listRelabelInventoryCandidates(warehouseId)
// 共用接口包含退货历史及占用中的箱子,转运只展示当前可整箱使用的来源。
transferInventoryCandidates.value = (await listRelabelInventoryCandidates(warehouseId)).filter(item =>
item.sourceType === 'RETURN_CARTON' || item.sourceType === 'RETURN_PARCEL'
? Number(item.cartonAvailablePackages || 0) > 0
: Number(item.availablePackages || 0) > 0)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '当前海外仓库存来源加载失败')
} finally { relabelCandidatesLoading.value = false }
@@ -1465,39 +1504,57 @@ async function resubmitRejectedRelabel() {
const transferInventoryOptions = computed(() => {
const grouped = new Map<string, { value: string; label: string; packages: number }>()
for (const item of transferInventoryCandidates.value) {
const value = String(item.inboundCode || '')
// 退货库存没有普通入仓码,使用带前缀的退货来源编码,避免提交时被当作普通入仓单查询。
const returnNo = String(item.inboundOrderNo || '').trim()
const value = item.sourceType === 'RETURN_CARTON' || item.sourceType === 'RETURN_PARCEL'
? (returnNo ? `RETURN_ORDER:${returnNo}` : '')
: String(item.inboundCode || '')
if (!value) continue
const packages = inventoryQuantity(item.availablePackages)
const packages = item.sourceType === 'RETURN_CARTON' || item.sourceType === 'RETURN_PARCEL'
? inventoryQuantity(item.cartonAvailablePackages || 0)
: inventoryQuantity(item.availablePackages)
const current = grouped.get(value)
if (current) current.packages += packages
else grouped.set(value, { value, label: `${item.inboundOrderNo || value}|${value}`, packages })
else grouped.set(value, { value, label: value.startsWith('RETURN_ORDER:') ? `退货单|${returnNo}` : `${item.inboundOrderNo || value}|${value}`, packages })
}
return [...grouped.values()]
})
async function selectTransferInventory(value: string) {
selectedTransferInventory.value = value || ''
selectedTransferSourceOrderIds.value = []
if (!value) return
transferSourceCartons.value = []; transferSourceError.value = ''; ++transferSourceRequest
if (!value) { form.inboundNo = ''; cargoRows.value = [createCargoRow()]; transferSourceLoading.value = false; return }
if (transferDestinationType.value === 'NONE') transferDestinationType.value = 'FBA'
void loadTransferSourceCartons()
form.inboundNo = value
// The inventory-candidates response already contains the merchant-visible
// SKU/name/package data. Do not call the platform-only last-leg orders API
// here: merchants are not authorized for that endpoint (A0403), and the
// transfer service can derive source orders from inboundCode when IDs are
// omitted. Aggregate candidates for the selected inbound into cargo rows.
const candidates = transferInventoryCandidates.value.filter((item) => String(item.inboundCode || '') === value)
const isReturnSource = value.startsWith('RETURN_ORDER:')
const returnNo = isReturnSource ? value.slice('RETURN_ORDER:'.length) : ''
const candidates = transferInventoryCandidates.value.filter((item) => isReturnSource
? (item.sourceType === 'RETURN_CARTON' || item.sourceType === 'RETURN_PARCEL') && String(item.inboundOrderNo || '') === returnNo
: String(item.inboundCode || '') === value)
const grouped = new Map<string, CargoRow>()
for (const candidate of candidates) {
const sku = String(candidate.skuCode || '')
const current = grouped.get(sku)
const quantity = inventoryQuantity(candidate.availablePackages)
if (current) current.quantity += quantity
else grouped.set(sku, { ...createCargoRow(), sku, name: candidate.productNameZh || '', quantity, availableQuantity: quantity })
const candidateRows = isReturnSource
? (candidate.cartonSkuItems?.length ? candidate.cartonSkuItems : candidate.skuItems || []).map(item => ({ sku: item.skuCode, name: candidate.productNameZh || '', quantity: inventoryQuantity(item.quantity), packageCount: inventoryQuantity(candidate.cartonAvailablePackages || 0) }))
: [{ sku: String(candidate.skuCode || ''), name: candidate.productNameZh || '', quantity: inventoryQuantity(candidate.availablePackages), packageCount: inventoryQuantity(candidate.availablePackages) }]
for (const row of candidateRows) {
if (!row.sku) continue
const current = grouped.get(row.sku)
if (current) { current.quantity += row.quantity; current.expectedPackageCount = Number(current.expectedPackageCount || 0) + row.packageCount; current.availableQuantity = Number(current.availableQuantity || 0) + row.quantity }
else grouped.set(row.sku, { ...createCargoRow(), sku: row.sku, name: row.name, quantity: row.quantity, expectedPackageCount: row.packageCount, availableQuantity: row.quantity })
}
}
cargoRows.value = [...grouped.values()]
try {
if (isReturnSource) return
const historical = await listRelabelHistoricalOrders(form.warehouse, value)
const sourceIds = historical.map((item) => String(item.id || '')).filter(Boolean)
selectedTransferSourceOrderIds.value = sourceIds
if (selectedTransferInventory.value === value) selectedTransferSourceOrderIds.value = sourceIds
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '库存来源订单加载失败')
}
@@ -1613,14 +1670,14 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
<label v-if="props.service === 'relabel'"><span><i>*</i>来源 FBA 仓库</span><el-select v-model="form.sourceWarehouse" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" placeholder="请选择平台维护的 FBA 仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></label>
<label v-else-if="props.service === 'disposal'" class="warehouse-disposal-field"><span><i>*</i>废弃原因</span><el-select v-model="form.reason" placeholder="请选择"><el-option label="客户退货" value="客户退货" /><el-option label="亚马逊移除" value="亚马逊移除" /><el-option label="残损不可售" value="残损不可售" /></el-select></label>
<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 :filter-method="(value: string) => relabelInventorySearch = value" :loading="relabelCandidatesLoading" :disabled="!form.warehouse" no-data-text="暂无匹配的可选库存" placeholder="搜索单号或选择可用库存" @change="selectRelabelInventory"><el-option v-for="option in visibleRelabelInboundOptions.filter(item => !relabelInventorySearch.trim() || item.inboundOrderNo.toUpperCase().includes(relabelInventorySearch.trim().toUpperCase()) || item.inboundCode.toUpperCase().includes(relabelInventorySearch.trim().toUpperCase()))" :key="option.value" :label="relabelOptionLabel(option)" :value="option.value" :disabled="option.availablePackages <= 0" /></el-select><small class="warehouse-hint">在仓数量包含被占用货物;本次可选数量才可加入申请。合箱后的库存按当前包裹/箱展示,来源退货单保留。</small></label>
<label v-if="props.service === 'labeling'"><span><i>*</i>选择入仓单或退货单</span><el-select v-model="selectedRelabelInboundOrderId" filterable clearable :filter-method="(value: string) => relabelInventorySearch = value" :loading="relabelCandidatesLoading" :disabled="!form.warehouse" no-data-text="暂无匹配的可选库存" placeholder="搜索单号或选择可用库存" @change="selectRelabelInventory"><el-option v-for="option in visibleRelabelInboundOptions.filter(item => !relabelInventorySearch.trim() || item.inboundOrderNo.toUpperCase().includes(relabelInventorySearch.trim().toUpperCase()) || item.inboundCode.toUpperCase().includes(relabelInventorySearch.trim().toUpperCase()))" :key="option.value" :label="relabelOptionLabel(option)" :value="option.value" :disabled="option.availablePackages <= 0" /></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>
<label v-if="props.service === 'labeling' && labelingMode !== 'sku'"><span><i>*</i>FBA 目的仓</span><el-select v-model="form.destination" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" filterable placeholder="请选择本张申请统一的 FBA 目的仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></label>
<div v-if="props.service === 'transfer'" class="warehouse-choice-field warehouse-span-2"><span><i>*</i>入仓方式</span><el-radio-group v-model="form.deliveryMethod" @change="selectTransferInboundMethod"><el-radio-button value="WAREHOUSE_PICKUP">仓库上门提货</el-radio-button><el-radio-button value="CUSTOMER_DELIVERY">客户送货到仓</el-radio-button></el-radio-group></div>
<label v-if="props.service === 'transfer' && form.deliveryMethod === 'WAREHOUSE_PICKUP'" class="pickup-address-field"><span><i>*</i>提货地址</span><el-input v-model="form.address" placeholder="请输入仓库上门提货的详细地址" /></label>
<label v-if="props.service === 'transfer' && form.deliveryMethod === 'WAREHOUSE_PICKUP'"><span><i>*</i>提货联系人</span><el-input v-model="form.pickupContact" placeholder="请输入提货联系人" maxlength="128" /></label>
<label v-if="props.service === 'transfer' && form.deliveryMethod === 'WAREHOUSE_PICKUP'"><span><i>*</i>提货联系人电话</span><el-input v-model="form.pickupPhone" placeholder="请输入提货联系人电话" maxlength="64" /></label>
<div v-if="props.service === 'transfer' && form.deliveryMethod === 'CUSTOMER_DELIVERY'" v-loading="operationWarehouseDetailLoading" class="warehouse-receiving-card warehouse-span-2">
<div v-if="props.service === 'transfer' && !inventoryTransfer" class="warehouse-choice-field warehouse-span-2"><span><i>*</i>入仓方式</span><el-radio-group v-model="form.deliveryMethod" @change="selectTransferInboundMethod"><el-radio-button value="WAREHOUSE_PICKUP">仓库上门提货</el-radio-button><el-radio-button value="CUSTOMER_DELIVERY">客户送货到仓</el-radio-button></el-radio-group></div>
<label v-if="props.service === 'transfer' && !inventoryTransfer && form.deliveryMethod === 'WAREHOUSE_PICKUP'" class="pickup-address-field"><span><i>*</i>提货地址</span><el-input v-model="form.address" placeholder="请输入仓库上门提货的详细地址" /></label>
<label v-if="props.service === 'transfer' && !inventoryTransfer && form.deliveryMethod === 'WAREHOUSE_PICKUP'"><span><i>*</i>提货联系人</span><el-input v-model="form.pickupContact" placeholder="请输入提货联系人" maxlength="128" /></label>
<label v-if="props.service === 'transfer' && !inventoryTransfer && form.deliveryMethod === 'WAREHOUSE_PICKUP'"><span><i>*</i>提货联系人电话</span><el-input v-model="form.pickupPhone" placeholder="请输入提货联系人电话" maxlength="64" /></label>
<div v-if="props.service === 'transfer' && !inventoryTransfer && form.deliveryMethod === 'CUSTOMER_DELIVERY'" v-loading="operationWarehouseDetailLoading" class="warehouse-receiving-card warehouse-span-2">
<div><span>海外仓收货信息</span><strong>{{ operationWarehouseAddress || '该海外仓暂未维护收货地址' }}</strong></div>
<label class="warehouse-span-2"><span><i>*</i>送货地址</span><el-input v-model="form.address" placeholder="请输入实际送货地址" /></label>
<div><span>收货联系人</span><strong>{{ selectedOperationWarehouse?.warehouse.contactName || '未维护' }}</strong></div>
@@ -1637,9 +1694,9 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
<label v-if="canManageRelabelFiles && props.service === 'labeling' && labelingMode !== 'sku' && form.deliveryMethod === 'EXPRESS' && form.relabel"><span><i>*</i>上传面单文件</span><input class="warehouse-native-file" type="file" accept="application/pdf,image/*,.pdf" @change="selectShippingLabel"></label>
<label v-if="props.service === 'relabel'" class="warehouse-span-2"><span><i>*</i>详细派送地址</span><el-input v-model="form.address" placeholder="请输入派送地址" /></label>
<label v-if="props.service === 'relabel'"><span><i>*</i>预估总体积(CBM)</span><el-input-number v-model="form.volumeCbm" :min="0" :precision="4" :step="0.0001" controls-position="right" placeholder="请输入预估总体积" /></label>
<label v-if="props.service === 'transfer' || props.service === 'relabel'"><span><i>*</i>{{ props.service === 'transfer' ? '预计入仓时间' : '预计到达时间' }}</span><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :placeholder="props.service === 'transfer' ? '请选择预计入仓时间' : '请选择预计到达时间'" /></label>
<label v-if="(props.service === 'transfer' && !inventoryTransfer) || props.service === 'relabel'"><span><i>*</i>{{ props.service === 'transfer' ? '预计入仓时间' : '预计到达时间' }}</span><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :placeholder="props.service === 'transfer' ? '请选择预计入仓时间' : '请选择预计到达时间'" /></label>
<label v-if="props.service === 'transfer'"><span>期望发货时间</span><el-date-picker v-model="form.estimatedShipAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="选填" /></label>
<div v-if="props.service === 'transfer'" class="warehouse-choice-field warehouse-span-2"><span>目的去向</span><el-radio-group v-model="transferDestinationType" @change="selectTransferDestinationType"><el-radio-button value="NONE">暂未确定</el-radio-button><el-radio-button value="FBA">FBA 仓</el-radio-button><el-radio-button value="PRIVATE">私人仓</el-radio-button></el-radio-group></div>
<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 v-if="!inventoryTransfer" 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>
@@ -1720,13 +1777,22 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
</template>
<el-alert v-else type="info" :closable="false" :title="discardSelectedCandidate.cartonSelectionBlockReason || '当前库存缺少完整箱级记录,暂时只能选择全部库存废弃。'" />
</section>
<div v-if="props.service !== 'disposal' || !discardSelectedCandidate?.cartonSelectionSupported" class="warehouse-section-heading" :class="{ 'warehouse-step-heading': props.service === 'transfer' }"><div><h3><span v-if="props.service === 'transfer'" class="warehouse-step-number">1</span>货物明细</h3></div><div v-if="props.service !== 'disposal'" class="warehouse-heading-actions"><el-button v-if="props.service === 'transfer'" size="small" :disabled="!cargoRows.some((row) => row.sku.trim() && Number(row.expectedPackageCount) > 0)" @click="printTransferDraftCartonMarks"><Printer :size="15" />打印唛头</el-button><el-button type="primary" size="small" @click="addCargoRow"><Plus :size="15" />添加行</el-button><el-button size="small" @click="cargoRows = []">清空</el-button></div></div>
<div v-if="props.service !== 'disposal' || !discardSelectedCandidate?.cartonSelectionSupported" class="warehouse-table-scroll"><table class="warehouse-edit-table"><thead><tr><th>商品 SKU{{ props.service === 'transfer' ? '' : ' *' }}</th><th>中文品名{{ props.service === 'transfer' ? '' : ' *' }}</th><th v-if="props.service === 'disposal'">可用箱数</th><th v-if="props.service === 'relabel'">FNSKU / 箱唛</th><th v-if="props.service === 'transfer'">SKU 数量(件)*</th><th>{{ props.service === 'disposal' ? '本次废弃箱数' : '预计箱数' }} *</th><th v-if="props.service !== 'disposal'">操作</th></tr></thead><tbody><tr v-for="row in cargoRows" :key="row.id"><td><el-input v-model="row.sku" :readonly="props.service === 'disposal'" :placeholder="props.service === 'transfer' ? '选填' : '请输入商品 SKU'" /></td><td><el-input v-model="row.name" :readonly="props.service === 'disposal'" :placeholder="props.service === 'transfer' ? '选填' : '请输入中文品名'" /></td><td v-if="props.service === 'disposal'"><strong>{{ row.availableQuantity }} 箱</strong></td><td v-if="props.service === 'relabel'"><el-input v-model="row.shippingMark" placeholder="请输入FNSKU或箱唛" /></td><td v-if="props.service === 'transfer'"><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" placeholder="请输入件数" /></td><td v-if="props.service === 'transfer'"><el-input-number v-model="row.expectedPackageCount" :min="1" :precision="0" controls-position="right" placeholder="请输入预计箱数" /></td><td v-else><el-input-number v-model="row.quantity" :disabled="props.service === 'disposal'" :min="props.service === 'disposal' ? 0 : 1" :max="row.availableQuantity" controls-position="right" placeholder="请输入箱数" /></td><td v-if="props.service !== 'disposal'"><button type="button" class="warehouse-delete" title="删除" @click="removeCargoRow(row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="cargoRows.length === 0"><td :colspan="props.service === 'disposal' ? 4 : props.service === 'relabel' || props.service === 'transfer' ? 5 : 4"><div class="warehouse-empty">{{ props.service === 'disposal' ? '请先选择入库单,系统将自动带出 SKU 和可用箱数' : '暂无货物明细,请点击“添加行”' }}</div></td></tr></tbody></table></div>
<section v-if="inventoryTransfer" v-loading="transferSourceLoading">
<div class="warehouse-section-heading"><h3>原单箱明细 <small>共 {{ transferSourceCartons.length }} 箱 · 只读</small></h3></div>
<el-alert v-if="transferSourceError" :title="transferSourceError" type="error" :closable="false"><el-button link @click="loadTransferSourceCartons">重新加载</el-button></el-alert>
<div class="warehouse-table-scroll"><table class="warehouse-edit-table"><thead><tr><th>箱号</th><th>商品 SKU</th><th>中文品名</th><th>箱内数量(件)</th><th>原目的仓</th></tr></thead><tbody>
<tr v-for="box in transferSourceCartons" :key="box.cartonMark"><td>{{ box.cartonMark }}</td><td>{{ box.items.map(item => item.skuCode || '—').join(' / ') }}</td><td>{{ box.productNameZh || '—' }}</td><td>{{ box.quantity ?? '未记录' }}</td><td>{{ [box.destinationWarehouseCode, box.destinationWarehouseName].filter(Boolean).join(' · ') || '未指定' }}</td></tr>
<tr v-if="!transferSourceLoading && !transferSourceCartons.length"><td colspan="5"><div class="warehouse-empty">{{ transferSourceError ? '加载失败,请重试' : '原库存单据未记录逐箱明细,不虚构箱号;按原库存汇总转运' }}</div></td></tr>
</tbody></table></div>
<div v-if="!transferSourceLoading && !transferSourceError && !transferSourceCartons.length" class="warehouse-table-scroll"><table class="warehouse-edit-table"><thead><tr><th>商品 SKU</th><th>中文品名</th><th>库存箱数</th><th>原目的仓</th></tr></thead><tbody><tr v-for="row in cargoRows" :key="row.id"><td>{{ row.sku || '—' }}</td><td>{{ row.name || '—' }}</td><td>{{ row.expectedPackageCount }}</td><td>{{ transferInventoryCandidates.filter(item => item.inboundCode === selectedTransferInventory && item.skuCode === row.sku).map(item => item.destinationFbaWarehouseCode || item.destinationFbaWarehouseName).filter(Boolean).join(' / ') || '未指定' }}</td></tr></tbody></table></div>
</section>
<div v-if="!inventoryTransfer && (props.service !== 'disposal' || !discardSelectedCandidate?.cartonSelectionSupported)" class="warehouse-section-heading" :class="{ 'warehouse-step-heading': props.service === 'transfer' }"><div><h3><span v-if="props.service === 'transfer'" class="warehouse-step-number">1</span>货物明细</h3></div><div v-if="props.service !== 'disposal'" class="warehouse-heading-actions"><el-button type="primary" size="small" @click="addCargoRow"><Plus :size="15" />添加行</el-button><el-button size="small" @click="cargoRows = []">清空</el-button></div></div>
<div v-if="!inventoryTransfer && (props.service !== 'disposal' || !discardSelectedCandidate?.cartonSelectionSupported)" class="warehouse-table-scroll"><table class="warehouse-edit-table"><thead><tr><th>商品 SKU{{ props.service === 'transfer' ? '' : ' *' }}</th><th>中文品名{{ props.service === 'transfer' ? '' : ' *' }}</th><th v-if="props.service === 'disposal'">可用箱数</th><th v-if="props.service === 'relabel'">FNSKU / 箱唛</th><th v-if="props.service === 'transfer'">SKU 数量(件)*</th><th>{{ props.service === 'disposal' ? '本次废弃箱数' : '预计箱数' }} *</th><th v-if="props.service !== 'disposal'">操作</th></tr></thead><tbody><tr v-for="row in cargoRows" :key="row.id"><td><el-input v-model="row.sku" :readonly="props.service === 'disposal'" :placeholder="props.service === 'transfer' ? '选填' : '请输入商品 SKU'" /></td><td><el-input v-model="row.name" :readonly="props.service === 'disposal'" :placeholder="props.service === 'transfer' ? '选填' : '请输入中文品名'" /></td><td v-if="props.service === 'disposal'"><strong>{{ row.availableQuantity }} 箱</strong></td><td v-if="props.service === 'relabel'"><el-input v-model="row.shippingMark" placeholder="请输入FNSKU或箱唛" /></td><td v-if="props.service === 'transfer'"><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" placeholder="请输入件数" /></td><td v-if="props.service === 'transfer'"><el-input-number v-model="row.expectedPackageCount" :min="1" :precision="0" controls-position="right" placeholder="请输入预计箱数" /></td><td v-else><el-input-number v-model="row.quantity" :disabled="props.service === 'disposal'" :min="props.service === 'disposal' ? 0 : 1" :max="row.availableQuantity" controls-position="right" placeholder="请输入箱数" /></td><td v-if="props.service !== 'disposal'"><button type="button" class="warehouse-delete" title="删除" @click="removeCargoRow(row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="cargoRows.length === 0"><td :colspan="props.service === 'disposal' ? 4 : props.service === 'relabel' || props.service === 'transfer' ? 5 : 4"><div class="warehouse-empty">{{ props.service === 'disposal' ? '请先选择入库单,系统将自动带出 SKU 和可用箱数' : '暂无货物明细,请点击“添加行”' }}</div></td></tr></tbody></table></div>
</template>
</div>
<div v-if="props.service === 'transfer' && form.relabel" class="warehouse-card warehouse-form-section warehouse-transfer-relabel">
<div class="warehouse-section-heading warehouse-labeling-heading warehouse-step-heading"><div><h3><span class="warehouse-step-number">2</span>换标申请</h3></div></div>
<div class="warehouse-form-grid warehouse-transfer-relabel-fields"><label><span><i>*</i>换标截止时间</span><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择截止时间" /></label><label><span><i>*</i>换标联系人</span><el-input v-model="form.relabelContact" placeholder="选择海外仓后自动带出,也可手动填写" maxlength="128" /></label><label><span><i>*</i>换标联系人电话</span><el-input v-model="form.relabelPhone" placeholder="选择海外仓后自动带出,也可手动填写" maxlength="64" /></label><label v-if="labelingMode !== 'sku'"><span><i>*</i>换标后派送方式</span><el-select v-model="form.relabelDeliveryMethod" placeholder="请选择派送方式"><el-option label="卡车派送" value="TRUCK" /><el-option label="快递派送" value="EXPRESS" /><el-option label="亚马逊自提" value="AMAZON_PICKUP" /><el-option label="客户自提" value="CUSTOMER_PICKUP" /></el-select></label><label class="warehouse-span-2"><span><i>*</i>整票换标货物目的 FBA 仓</span><el-select v-model="transferRelabelDefaultDestination" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" filterable placeholder="请选择整票货物的目的仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select><small class="warehouse-hint">本票所有换标货物统一前往该目的仓,暂不支持一票多个目的仓。</small></label><label class="warehouse-span-2"><span>贴标要求</span><el-input v-model="form.requirement" type="textarea" :rows="2" maxlength="1000" show-word-limit placeholder="请输入贴标要求(选填)" /></label></div>
<div class="warehouse-form-grid warehouse-transfer-relabel-fields"><label><span><i>*</i>换标截止时间</span><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择截止时间" /></label><label><span><i>*</i>换标联系人</span><el-input v-model="form.relabelContact" placeholder="选择海外仓后自动带出,也可手动填写" maxlength="128" /></label><label><span><i>*</i>换标联系人电话</span><el-input v-model="form.relabelPhone" placeholder="选择海外仓后自动带出,也可手动填写" maxlength="64" /></label><label v-if="labelingMode !== 'sku'"><span><i>*</i>换标后派送方式</span><el-select v-model="form.relabelDeliveryMethod" placeholder="请选择派送方式"><el-option label="卡车派送" value="TRUCK" /><el-option label="快递派送" value="EXPRESS" /><el-option label="亚马逊自提" value="AMAZON_PICKUP" /><el-option label="客户自提" value="CUSTOMER_PICKUP" /></el-select></label><label class="warehouse-span-2"><span><i>*</i>整票换标货物目的 FBA 仓</span><el-select v-model="transferRelabelDefaultDestination" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" filterable placeholder="请选择整票货物的目的仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select><small class="warehouse-hint">本票所有换标货物统一前往该目的仓,暂不支持一票多个目的仓。</small></label><label><span>FBA 货件编号</span><el-input v-model="form.fbaShipmentNo" placeholder="请输入 FBA 货件编号(选填)" /></label><label class="warehouse-span-2"><span>贴标要求</span><el-input v-model="form.requirement" type="textarea" :rows="2" maxlength="1000" show-word-limit placeholder="请输入贴标要求(选填)" /></label></div>
<div class="warehouse-labeling-mode"><span>换标类型</span><el-radio-group v-model="labelingMode" aria-label="换标类型"><el-radio-button value="carton">只换箱唛</el-radio-button><el-radio-button value="sku">只换 SKU 标签</el-radio-button><el-radio-button value="carton_and_sku">SKU 和箱唛都换</el-radio-button></el-radio-group></div>
<div class="warehouse-transfer-file-groups warehouse-file-groups--compact">
<section v-if="canManageRelabelFiles" class="warehouse-file-group warehouse-transfer-total-files">
@@ -1743,11 +1809,11 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
<input ref="skuTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'sku')"><input ref="cartonTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'carton')"><input ref="mixedTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'mixed')">
</section>
</div>
<div v-if="importedRelabelBindings.length" class="warehouse-import-preview"><div class="warehouse-section-heading"><div><strong>待导入绑定关系</strong><p>可只勾选本次需要换标的部分,也可以全选后一次导入。</p></div><div><el-checkbox :model-value="importedRelabelBindings.every((item) => item.selected)" @change="selectAllImportedBindings(Boolean($event))">全选</el-checkbox><el-button type="primary" size="small" @click="importSelectedBindingsToDetails">导入到明细</el-button></div></div><div class="warehouse-table-scroll"><table class="warehouse-binding-preview-table"><thead><tr><th>选择</th><th>关系类型</th><th>客户箱唛号</th><th>旧 SKU</th><th>新 SKU</th><th>数量</th><th>新 FBA 箱唛</th></tr></thead><tbody><tr v-for="item in importedRelabelBindings" :key="item.id"><td><el-checkbox v-model="item.selected" /></td><td>{{ item.templateType === 'mixed' ? 'SKU 混箱换标' : item.templateType === 'carton' ? '箱号关联' : 'SKU 关联' }}</td><td>{{ item.cartonMark }}</td><td>{{ item.originalSku || '—' }}</td><td>{{ item.newSku || '—' }}</td><td>{{ item.quantity }}</td><td>{{ item.fbaCartonMark || '—' }}</td></tr></tbody></table></div></div>
<div v-if="importedRelabelBindings.length" class="warehouse-import-preview"><div class="warehouse-section-heading"><div><strong>待导入绑定关系</strong><p>可只勾选本次需要换标的部分,也可以全选后一次导入。</p></div><div><el-checkbox :model-value="importedRelabelBindings.every((item) => item.selected)" @change="selectAllImportedBindings(Boolean($event))">全选</el-checkbox><el-button type="primary" size="small" @click="importSelectedBindingsToDetails">导入到明细</el-button></div></div><div class="warehouse-table-scroll"><table class="warehouse-binding-preview-table"><thead><tr><th>选择</th><th>关系类型</th><th>仓库唛头</th><th>旧 SKU</th><th>新 SKU</th><th>数量</th><th>新 FBA 箱唛</th></tr></thead><tbody><tr v-for="item in importedRelabelBindings" :key="item.id"><td><el-checkbox v-model="item.selected" /></td><td>{{ item.templateType === 'mixed' ? 'SKU 混箱换标' : item.templateType === 'carton' ? '箱号关联' : 'SKU 关联' }}</td><td>{{ item.cartonMark }}</td><td>{{ item.originalSku || '—' }}</td><td>{{ item.newSku || '—' }}</td><td>{{ item.quantity }}</td><td>{{ item.fbaCartonMark || '—' }}</td></tr></tbody></table></div></div>
<div class="warehouse-section-heading warehouse-tab-actions warehouse-binding-heading"><div><strong><span class="warehouse-step-number">4</span>绑定明细</strong></div><div><el-button type="primary" size="small" @click="addTransferRelabelRow"><Plus :size="15" />添加 SKU 明细</el-button><el-button size="small" @click="clearTransferRelabelRows">清空</el-button></div></div>
<div v-if="labelingMode === 'carton'" class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-transfer-mark-table"><thead><tr><th>客户箱唛号 *</th><th>旧 SKU</th><th>新 FBA 箱唛 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in transferRelabelRows" :key="row.id"><td><el-input v-model="row.customerCartonMarks" type="textarea" :rows="2" placeholder="请输入客户箱唛号" /></td><td><el-input :model-value="row.sku" readonly /></td><td><el-input v-model="row.fbaCartonMark" placeholder="请输入新的 FBA 箱唛" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" placeholder="请输入备注" /></td><td><button type="button" class="warehouse-delete" title="删除该明细" @click="transferRelabelRows = transferRelabelRows.filter((item) => item.id !== row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="transferRelabelRows.length === 0"><td colspan="5"><div class="warehouse-empty">暂无绑定明细,请点击“添加换标明细”</div></td></tr></tbody></table></div>
<div v-else-if="labelingMode === 'sku'" class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-transfer-mark-table"><thead><tr><th>客户箱唛号 *</th><th>旧 SKU *</th><th>新 SKU *</th><th>换标数量(件)*</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in transferRelabelRows" :key="row.id"><td><el-input v-model="row.customerCartonMarks" type="textarea" :rows="2" placeholder="请输入客户箱唛号" /></td><td><el-input :model-value="row.sku" readonly /></td><td><el-input v-model="row.newSkuCode" placeholder="请输入新 SKU" /></td><td><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" placeholder="请输入箱数" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" placeholder="请输入备注" /></td><td><button type="button" class="warehouse-delete" title="删除该明细" @click="transferRelabelRows = transferRelabelRows.filter((item) => item.id !== row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="transferRelabelRows.length === 0"><td colspan="6"><div class="warehouse-empty">暂无绑定明细,请点击“添加 SKU 明细”</div></td></tr></tbody></table></div>
<div v-else class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-transfer-mark-table"><thead><tr><th>客户箱唛号 *</th><th>旧 SKU *</th><th>新 SKU *</th><th>换标数量(件)*</th><th>新 FBA 箱唛 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in transferRelabelRows" :key="row.id"><td><el-input v-model="row.customerCartonMarks" type="textarea" :rows="2" placeholder="请输入客户箱唛号" /></td><td><el-input :model-value="row.sku" readonly /></td><td><el-input v-model="row.newSkuCode" placeholder="请输入新 SKU" /></td><td><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" placeholder="请输入数量" /></td><td><el-input v-model="row.fbaCartonMark" placeholder="请输入新的 FBA 箱唛" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" placeholder="请输入备注" /></td><td><button type="button" class="warehouse-delete" title="删除该明细" @click="transferRelabelRows = transferRelabelRows.filter((item) => item.id !== row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="transferRelabelRows.length === 0"><td colspan="7"><div class="warehouse-empty">暂无绑定明细,请点击“添加 SKU 明细”</div></td></tr></tbody></table></div>
<div v-if="labelingMode === 'carton'" class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-transfer-mark-table"><thead><tr><th>仓库唛头 *</th><th>旧 SKU</th><th>新 FBA 箱唛 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in transferRelabelRows" :key="row.id"><td><el-input v-model="row.customerCartonMarks" placeholder="请输入仓库唛头" /></td><td><el-input :model-value="row.sku" readonly /></td><td><el-input v-model="row.fbaCartonMark" placeholder="请输入新的 FBA 箱唛" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" placeholder="请输入备注" /></td><td><button type="button" class="warehouse-delete" title="删除该明细" @click="transferRelabelRows = transferRelabelRows.filter((item) => item.id !== row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="transferRelabelRows.length === 0"><td colspan="5"><div class="warehouse-empty">暂无绑定明细,请点击“添加换标明细”</div></td></tr></tbody></table></div>
<div v-else-if="labelingMode === 'sku'" class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-transfer-mark-table"><thead><tr><th>仓库唛头 *</th><th>旧 SKU *</th><th>新 SKU *</th><th>换标数量(件)*</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in transferRelabelRows" :key="row.id"><td><el-input v-model="row.customerCartonMarks" placeholder="请输入仓库唛头" /></td><td><el-input :model-value="row.sku" readonly /></td><td><el-input v-model="row.newSkuCode" placeholder="请输入新 SKU" /></td><td><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" placeholder="请输入箱数" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" placeholder="请输入备注" /></td><td><button type="button" class="warehouse-delete" title="删除该明细" @click="transferRelabelRows = transferRelabelRows.filter((item) => item.id !== row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="transferRelabelRows.length === 0"><td colspan="6"><div class="warehouse-empty">暂无绑定明细,请点击“添加 SKU 明细”</div></td></tr></tbody></table></div>
<div v-else class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-transfer-mark-table"><thead><tr><th>仓库唛头 *</th><th>旧 SKU *</th><th>新 SKU *</th><th>换标数量(件)*</th><th>新 FBA 箱唛 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in transferRelabelRows" :key="row.id"><td><el-input v-model="row.customerCartonMarks" placeholder="请输入仓库唛头" /></td><td><el-input :model-value="row.sku" readonly /></td><td><el-input v-model="row.newSkuCode" placeholder="请输入新 SKU" /></td><td><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" placeholder="请输入数量" /></td><td><el-input v-model="row.fbaCartonMark" placeholder="请输入新的 FBA 箱唛" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" placeholder="请输入备注" /></td><td><button type="button" class="warehouse-delete" title="删除该明细" @click="transferRelabelRows = transferRelabelRows.filter((item) => item.id !== row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="transferRelabelRows.length === 0"><td colspan="7"><div class="warehouse-empty">暂无绑定明细,请点击“添加 SKU 明细”</div></td></tr></tbody></table></div>
</div>
<div class="warehouse-submit-bar warehouse-transfer-submit-bar"><el-button v-if="mode === 'create'" native-type="button" @click="resetForm">重置</el-button><el-button v-if="props.service === 'transfer'" native-type="button" :loading="saving" @click="submit('draft')">保存草稿</el-button><el-button v-if="props.service === 'transfer'" type="primary" native-type="submit" :loading="saving">提交订单</el-button><el-button v-if="props.service !== 'transfer'" type="primary" native-type="submit" :loading="saving">提交{{ config.title }}</el-button></div>
</form>
@@ -1766,6 +1832,14 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
<h3>本次废弃箱子</h3><div class="warehouse-table-scroll"><table class="warehouse-edit-table"><thead><tr><th>入仓码</th><th>SKU</th><th>品名</th><th>申请数量</th><th>所选箱唛</th></tr></thead><tbody><tr v-for="(item, index) in selectedDetail?.items || []" :key="index"><td>{{ item.inboundCode || '—' }}</td><td>{{ item.skuCode || '—' }}</td><td>{{ item.productName || '—' }}</td><td>{{ item.discardPackages ?? '—' }}</td><td>{{ item.cartonMarks?.join('、') || '全部库存(历史记录无箱唛快照)' }}</td></tr></tbody></table></div>
<h3>废弃现场照片</h3><div v-if="selectedDetail?.photos?.length" class="warehouse-photo-grid"><div v-for="photo in selectedDetail.photos" :key="photo.fileId" class="warehouse-photo-item"><el-image v-if="discardPhotoUrls[photo.fileId]" :src="discardPhotoUrls[photo.fileId]" :preview-src-list="discardPhotoPreviewUrls" :initial-index="discardPhotoPreviewUrls.indexOf(discardPhotoUrls[photo.fileId])" fit="cover" preview-teleported /><div v-else-if="discardPhotoLoading[photo.fileId]" class="warehouse-photo-placeholder">照片加载中…</div><button v-else class="warehouse-photo-placeholder warehouse-photo-retry" type="button" @click="loadDiscardPhoto(photo.fileId)">{{ discardPhotoErrors[photo.fileId] ? '加载失败,点击重试' : '加载照片' }}</button><small>{{ formatDateTime(photo.submittedAt) }}</small></div></div><div v-else class="warehouse-empty">暂无已提交的废弃现场照片</div>
</div>
<div v-if="props.service === 'transfer' && selectedDetail?.status !== 'DRAFT'" class="warehouse-card warehouse-form-section">
<div class="warehouse-section-heading warehouse-transfer-receipt-heading"><h3>预报箱唛({{ selectedDetail?.packageCount || 0 }} 箱)</h3><div class="warehouse-transfer-receipt-actions"><el-button v-if="canIncreaseForecast" type="primary" size="small" @click="openIncreaseForecast"><Plus :size="14" />增加预报箱数</el-button><el-button type="primary" size="small" @click="printTransferCartons(String(selectedDetail?.transferNo || ''))"><Printer :size="14" />打印预报箱唛</el-button><el-button size="small" @click="showForecastCartons = true">查看预报箱唛</el-button></div></div>
<el-dialog v-model="showForecastCartons" title="预报箱唛明细" width="900px" align-center destroy-on-close append-to-body><el-table :data="selectedDetail?.cartons || []" size="small" border><el-table-column prop="cartonMark" label="仓库唛头" min-width="300" /><el-table-column label="预报SKU" min-width="240"><template #default="{ row }">{{ selectedDetail?.items?.find(item => String(item.id) === String(row.transferItemId))?.skuCode || '—' }}</template></el-table-column><el-table-column label="清点状态" width="130"><template #default="{ row }">{{ row.status === 'COUNTED' ? '已清点' : row.status === 'NOT_RECEIVED' ? '未收到' : '待清点' }}</template></el-table-column></el-table><template #footer><el-button @click="showForecastCartons = false">关闭</el-button></template></el-dialog>
<el-dialog v-model="increaseForecastOpen" title="增加预报箱数" width="560px" align-center destroy-on-close append-to-body :close-on-click-modal="!increasingForecast" :show-close="!increasingForecast">
<el-form label-width="110px"><el-form-item label="预报SKU"><el-select v-model="increaseForecastForm.itemId" :disabled="increasingForecast" style="width:100%"><el-option v-for="item in forecastSnapshot?.items || []" :key="String(item.id)" :value="String(item.id)" :label="item.skuCode || item.productNameZh || '未命名SKU'" /></el-select></el-form-item><el-form-item label="新增箱数"><el-input-number v-model="increaseForecastForm.additionalCount" :disabled="increasingForecast" :min="1" :max="Math.max(1, 5000 - Number(forecastSnapshot?.packageCount || 0))" :precision="0" /></el-form-item></el-form><p class="warehouse-form-hint">保留原箱唛,仅补生成新增箱唛;SKU预报件数保持不变。</p>
<template #footer><el-button :disabled="increasingForecast" @click="increaseForecastOpen = false">取消</el-button><el-button type="primary" :loading="increasingForecast" @click="saveIncreaseForecast">确认增加</el-button></template>
</el-dialog>
</div>
<div v-if="props.service === 'transfer'" class="warehouse-card warehouse-form-section warehouse-transfer-cargo-card">
<div class="warehouse-section-heading"><h3>预报与实际签收</h3></div>
<div class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-transfer-comparison-table"><colgroup><col style="width:500px" /><col style="width:140px" /><col style="width:130px" /><col style="width:100px" /><col style="width:130px" /><col style="width:130px" /></colgroup><thead><tr><th>商品 SKU</th><th>中文品名</th><th>预报数量(件)</th><th>预计箱数</th><th>实际签收件数</th><th>实际签收箱数</th></tr></thead><tbody><tr v-for="(item, index) in selectedDetail?.items || []" :key="String(item.id || index)"><td>{{ item.skuCode || '—' }}</td><td>{{ item.productNameZh || '—' }}</td><td>{{ item.quantity ?? '—' }}</td><td>{{ item.packageCount ?? '—' }}</td><td>{{ receivedItemQuantity(item.id) }}</td><td>{{ receivedItemCartons(item.id).length }}</td></tr><tr v-if="!selectedDetail?.items?.length"><td colspan="6"><div class="warehouse-empty">暂无货物明细</div></td></tr></tbody></table></div>
@@ -1788,7 +1862,7 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
<div class="warehouse-evidence warehouse-measurement-evidence"><span>测量打托现场照片</span><div v-if="selectedDetail?.measurementPhotos?.length" class="warehouse-photo-grid warehouse-measurement-photo-grid"><div v-for="(photo, index) in selectedDetail.measurementPhotos" :key="photo.fileId" class="warehouse-photo-item warehouse-measurement-photo-item" :title="photo.fileName || `测量照片 ${index + 1}`"><el-image v-if="measurementPhotoUrls[photo.fileId]" :src="measurementPhotoUrls[photo.fileId]" :preview-src-list="measurementPhotoPreviewUrls" :initial-index="measurementPhotoPreviewUrls.indexOf(measurementPhotoUrls[photo.fileId])" :alt="photo.fileName || `测量照片 ${index + 1}`" fit="cover" preview-teleported /><div v-else-if="measurementPhotoLoading[photo.fileId]" class="warehouse-photo-placeholder">照片 {{ index + 1 }} 加载中…</div><button v-else class="warehouse-photo-placeholder warehouse-photo-retry" type="button" @click="loadMeasurementPhoto(photo.fileId)">{{ measurementPhotoErrors[photo.fileId] ? `照片 ${index + 1} 加载失败,点击重试` : `加载照片 ${index + 1}` }}</button></div></div><strong v-else>仓库尚未上传测量打托照片</strong></div>
</div>
<div v-if="props.service === 'transfer'" class="warehouse-card warehouse-form-section warehouse-transfer-received-card">
<div class="warehouse-section-heading warehouse-transfer-receipt-heading"><h3>签收箱号明细({{ countedTransferCartons().length }} 箱)</h3><div class="warehouse-transfer-receipt-actions"><el-button v-if="selectedDetail?.status !== 'DRAFT'" native-type="button" type="primary" size="small" @click="printTransferCartons(String(selectedDetail?.transferNo || selectedDetail?.['0'] || ''))"><Printer :size="14" />打印箱唛</el-button><el-button type="primary" size="small" :disabled="!countedTransferCartons().length" @click="showTransferCartons = true">查看箱号明细</el-button></div></div>
<div class="warehouse-section-heading warehouse-transfer-receipt-heading"><h3>签收箱号明细({{ countedTransferCartons().length }} 箱)</h3><div class="warehouse-transfer-receipt-actions"><el-button type="primary" size="small" :disabled="!countedTransferCartons().length" @click="showTransferCartons = true">查看箱号明细</el-button></div></div>
<div v-if="!countedTransferCartons().length" class="warehouse-empty">暂无实际签收箱,等待 PDA 清点。</div>
<el-dialog v-model="showTransferCartons" title="签收箱号明细" width="1120px" align-center destroy-on-close append-to-body>
<div class="warehouse-cartons-dialog-body">
@@ -1811,7 +1885,7 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
</div>
<div v-if="props.service === 'transfer' && selectedDetail?.needRelabel" class="warehouse-card warehouse-form-section">
<h3>换标箱级去向</h3>
<div class="warehouse-table-scroll"><table><thead><tr><th>换标类型</th><th>客户箱唛号</th><th>旧 SKU</th><th>新 SKU</th><th>新 FBA 箱唛</th><th>实际 FBA 目的仓</th><th>数量</th><th>备注</th></tr></thead><tbody><tr v-for="(binding, index) in selectedDetail?.relabelBindings || []" :key="`${binding.customerCartonMark}-${index}`"><td>{{ binding.operationType === 'CARTON_AND_SKU' ? 'SKU 和箱唛都换' : binding.operationType === 'SKU_LABEL' ? '只换 SKU' : '只换箱唛' }}</td><td>{{ binding.customerCartonMark || '—' }}</td><td>{{ binding.originalSku || '—' }}</td><td>{{ binding.newSku || '—' }}</td><td>{{ binding.fbaCartonMark || '—' }}</td><td>{{ binding.operationType === 'SKU_LABEL' ? '沿用原箱去向' : warehouseDisplay(binding.destinationWarehouseCode) }}</td><td>{{ binding.quantity ?? '—' }}</td><td>{{ binding.remark || '—' }}</td></tr><tr v-if="!selectedDetail?.relabelBindings?.length"><td colspan="8"><div class="warehouse-empty">暂无换标绑定明细</div></td></tr></tbody></table></div>
<div class="warehouse-table-scroll"><table><thead><tr><th>换标类型</th><th>仓库唛头</th><th>旧 SKU</th><th>新 SKU</th><th>新 FBA 箱唛</th><th>实际 FBA 目的仓</th><th>数量</th><th>备注</th></tr></thead><tbody><tr v-for="(binding, index) in selectedDetail?.relabelBindings || []" :key="`${binding.customerCartonMark}-${index}`"><td>{{ binding.operationType === 'CARTON_AND_SKU' ? 'SKU 和箱唛都换' : binding.operationType === 'SKU_LABEL' ? '只换 SKU' : '只换箱唛' }}</td><td>{{ binding.customerCartonMark || '—' }}</td><td>{{ binding.originalSku || '—' }}</td><td>{{ binding.newSku || '—' }}</td><td>{{ binding.fbaCartonMark || '—' }}</td><td>{{ binding.operationType === 'SKU_LABEL' ? '沿用原箱去向' : warehouseDisplay(binding.destinationWarehouseCode) }}</td><td>{{ binding.quantity ?? '—' }}</td><td>{{ binding.remark || '—' }}</td></tr><tr v-if="!selectedDetail?.relabelBindings?.length"><td colspan="8"><div class="warehouse-empty">暂无换标绑定明细</div></td></tr></tbody></table></div>
</div>
<div v-if="props.service === 'labeling' && canManageRelabelFiles" class="warehouse-card warehouse-form-section">
<div class="warehouse-section-heading"><h3>换标完成与出库</h3></div>
@@ -0,0 +1,86 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { transpileModule, ScriptTarget } from 'typescript'
const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8')
test('退货箱明细与来源下拉使用同一份逐箱库存,不请求普通入仓箱接口', async () => {
const body = view.slice(view.indexOf('async function loadTransferSourceCartons('), view.indexOf('// Snowflake IDs'))
const code = transpileModule(body, { compilerOptions: { target: ScriptTarget.ES2022 } }).outputText
const ctx = { transferSourceCartons: { value: [] }, transferSourceError: { value: '' },
transferSourceLoading: { value: false }, inventoryTransfer: { value: true },
selectedTransferInventory: { value: 'RETURN_ORDER:RET-1' }, form: { warehouse: 'WH' },
transferInventoryCandidates: { value: [
{ sourceType: 'RETURN_CARTON', inboundOrderNo: 'RET-1', cartonMark: 'BOX-1', cartonAvailablePackages: 1,
cartonSkuItems: [{ skuCode: 'A', quantity: 3 }, { skuCode: 'B', quantity: 2 }] },
{ sourceType: 'RETURN_PARCEL', inboundOrderNo: 'RET-1', cartonMark: 'BOX-2', cartonAvailablePackages: 1,
cartonSkuItems: [{ skuCode: 'A', quantity: 1 }] },
{ sourceType: 'RETURN_CARTON', inboundOrderNo: 'OTHER', cartonMark: 'FOREIGN', cartonAvailablePackages: 1 },
{ sourceType: 'RETURN_CARTON', inboundOrderNo: 'RET-1', cartonMark: 'OCCUPIED', cartonAvailablePackages: 0 },
] }, getTransferSourceCartons: () => { throw Error('不应调用普通来源箱查询') } }
const load = new Function(...Object.keys(ctx), 'let transferSourceRequest=0;' + code + ';return loadTransferSourceCartons')(...Object.values(ctx))
await load()
assert.deepEqual(ctx.transferSourceCartons.value.map(box => box.cartonMark), ['BOX-1', 'BOX-2'])
assert.equal(ctx.transferSourceCartons.value[0].quantity, 5)
assert.equal(ctx.transferSourceCartons.value[0].items.length, 2)
assert.equal(ctx.transferSourceError.value, '')
assert.equal(ctx.transferSourceLoading.value, false)
})
function setup() {
const body = view.slice(view.indexOf('async function selectTransferInventory('), view.indexOf('function selectTransferInboundMethod('))
const code = transpileModule(body, { compilerOptions: { target: ScriptTarget.ES2022 } }).outputText
const ctx = { selectedTransferInventory: {value:''}, selectedTransferSourceOrderIds:{value:[]},
transferSourceCartons:{value:[{cartonMark:'OLD'}]}, transferSourceError:{value:'OLD'}, transferSourceLoading:{value:true},
transferDestinationType:{value:'NONE'}, form:{warehouse:'WH',inboundNo:''},
transferInventoryCandidates:{value:[{inboundCode:'IN',skuCode:'SKU',productNameZh:'货品',availablePackages:2},
{inboundCode:'IN',skuCode:'SKU',availablePackages:3},{inboundCode:'OTHER',skuCode:'OTHER',availablePackages:99}]},
cargoRows:{value:[]}, inventoryQuantity:Number, createCargoRow:()=>({expectedPackageCount:1}),
loadTransferSourceCartons:async()=>{}, listRelabelHistoricalOrders:async()=>[{id:'1234567890123456789'}],
ElMessage:{error(message){throw Error(message)}} }
const select = new Function(...Object.keys(ctx), 'let transferSourceRequest=0;'+code+'; return selectTransferInventory')(...Object.values(ctx))
return {ctx,select}
}
test('inventory source populates full immutable cargo counts without mixing other inbound orders',async()=>{
const {ctx,select}=setup(); await select('IN')
assert.equal(ctx.transferDestinationType.value,'FBA')
assert.equal(ctx.cargoRows.value.length,1)
assert.equal(ctx.cargoRows.value[0].expectedPackageCount,5)
assert.equal(ctx.cargoRows.value[0].quantity,5)
assert.deepEqual(ctx.selectedTransferSourceOrderIds.value,['1234567890123456789'])
await select('')
assert.equal(ctx.form.inboundNo,'')
assert.deepEqual(ctx.selectedTransferSourceOrderIds.value,[])
assert.deepEqual(ctx.transferSourceCartons.value,[])
})
test('inventory view hides inbound fields and editing actions and shows original carton destination',()=>{
assert.match(view,/v-if="props.service === 'transfer' && !inventoryTransfer"[^>]*><span><i>\*<\/i>入仓方式/)
assert.match(view,/<el-radio-button v-if="!inventoryTransfer" value="NONE">/)
const panel=view.slice(view.indexOf('<section v-if="inventoryTransfer"'),view.indexOf('</section>',view.indexOf('<section v-if="inventoryTransfer"')))
assert.match(panel,/box.cartonMark/); assert.match(panel,/box.destinationWarehouseCode/)
assert.doesNotMatch(panel,/v-model|addCargoRow|removeCargoRow|清空/)
assert.match(view,/!inventoryTransfer\.value && \(!form.warehouse/)
assert.match(view,/estimatedArrivalAt: inventoryTransfer.value \? undefined : form.estimatedArrivalAt/)
})
test('仓储转运来源下拉包含退货单并使用独立来源编码', () => {
assert.match(view, /RETURN_ORDER:\$\{returnNo\}/)
assert.match(view, /退货单|\$\{returnNo\}/)
assert.match(view, /item\.sourceType === 'RETURN_CARTON' \|\| item\.sourceType === 'RETURN_PARCEL'/)
assert.match(view, /isReturnSource[\s\S]*item\.sourceType === 'RETURN_CARTON'/)
})
test('退货来源带出实物 SKU 数量并跳过普通历史订单查询', async () => {
const { ctx, select } = setup()
ctx.transferInventoryCandidates.value = [{ sourceType: 'RETURN_CARTON', inboundOrderNo: 'RET-1',
cartonAvailablePackages: 1, skuItems: [{ skuCode: 'SKU', quantity: 1 }],
cartonSkuItems: [{ skuCode: 'SKU', quantity: 6 }] },
{ sourceType: 'RETURN_CARTON', inboundOrderNo: 'OTHER', cartonAvailablePackages: 1,
cartonSkuItems: [{ skuCode: 'FOREIGN', quantity: 99 }] }]
await select('RETURN_ORDER:RET-1')
assert.equal(ctx.cargoRows.value.length, 1)
assert.equal(ctx.cargoRows.value[0].quantity, 6)
assert.equal(ctx.cargoRows.value[0].expectedPackageCount, 1)
assert.deepEqual(ctx.selectedTransferSourceOrderIds.value, [])
})
@@ -79,7 +79,7 @@ test('仓储转运不要求客户填写内部入仓号和我方箱唛,但换
assert.doesNotMatch(transferConfig, /进仓编号/)
assert.doesNotMatch(transferSubmit, /inboundCode|sourceLastlegOrderIds/)
assert.match(view, /货物明细/)
assert.match(view, /客户箱唛号 \*/)
assert.match(view, /仓库唛头 \*/)
assert.match(view, /downloadTransferCartonMarkTemplate/)
assert.match(view, /importTransferCartonMarks/)
assert.match(view, /<strong>SKU 关联<\/strong>/)
@@ -102,15 +102,10 @@ test('仓储转运不要求客户填写内部入仓号和我方箱唛,但换
assert.doesNotMatch(view, /<th v-if="props\.service === 'transfer'">FNSKU \/ 箱唛<\/th>/)
})
test('仓储转运货物明细可按 SKU 和箱数打印客户预贴箱唛', () => {
assert.match(view, /printTransferDraftCartonMarks/)
assert.match(view, /请先填写商品 SKU 和对应的整数箱数/)
assert.match(view, /Array\.from\(\{ length: Number\(row\.expectedPackageCount\) \}/)
assert.match(view, /row\.customerCartonMarks = marks\.join\('\\n'\)/)
assert.match(view, /仓储转运客户箱唛/)
assert.match(view, /@click="printTransferDraftCartonMarks"/)
assert.match(view, /打印唛头/)
assert.match(view, /单次最多打印 500 张箱唛/)
test('仓储转运提交后统一打印服务端预报箱唛', () => {
assert.doesNotMatch(view, /printTransferDraftCartonMarks|CT-\$\{batch\}/)
assert.match(view, /getTransferCartonLabel\(transferNo\)/)
assert.match(view, /打印预报箱唛/)
})
test('仓储转运换标资料按步骤分组展示', () => {
@@ -146,7 +141,7 @@ test('仓储转运换标总文件与明细模板在桌面端左右排列', () =>
})
test('上门提货不提交物流号,需要换标时随转运单登记申请', () => {
assert.match(view, /trackingNo:\s*form\.deliveryMethod === 'CUSTOMER_DELIVERY'\s*\?[^:]+:\s*undefined/)
assert.match(view, /trackingNo:\s*!inventoryTransfer\.value && form\.deliveryMethod === 'CUSTOMER_DELIVERY'\s*\?[^:]+:\s*undefined/)
assert.match(view, /relabelItems: form\.relabel/)
assert.match(view, /form\.deliveryMethod === 'CUSTOMER_DELIVERY'.*物流跟踪号/s)
assert.match(view, /提货联系人/)
@@ -157,8 +152,8 @@ test('上门提货不提交物流号,需要换标时随转运单登记申请',
assert.match(view, /form\.relabelContact/)
assert.match(view, /form\.relabelPhone/)
assert.match(view, /选择海外仓后自动带出,也可手动填写/)
assert.match(view, /pickupContactName: form\.deliveryMethod === 'WAREHOUSE_PICKUP' \? form\.pickupContact : undefined/)
assert.match(view, /pickupContactPhone: form\.deliveryMethod === 'WAREHOUSE_PICKUP' \? form\.pickupPhone : undefined/)
assert.match(view, /pickupContactName: !inventoryTransfer\.value && form\.deliveryMethod === 'WAREHOUSE_PICKUP' \? form\.pickupContact : undefined/)
assert.match(view, /pickupContactPhone: !inventoryTransfer\.value && form\.deliveryMethod === 'WAREHOUSE_PICKUP' \? form\.pickupPhone : undefined/)
assert.match(view, /relabelContactName: form\.relabel \? form\.relabelContact/)
assert.match(api, /pickupContactName\?: string; pickupContactPhone\?: string/)
})
@@ -330,7 +325,7 @@ test('仓储转运可在新建页直接提交,也可保存草稿后编辑提
assert.match(view, /await updateTransfer\(editingTransferNo\.value, payload\)/)
assert.match(view, /await submitTransfer\(transferNo\)/)
assert.match(view, /<form [^>]*@submit\.prevent="submit\('submit'\)"/)
assert.match(view, /selectedDetail\?\.status !== 'DRAFT'[^>]*[\s\S]*?printTransferCartons[^>]*[\s\S]*?打印箱唛/)
assert.match(view, /selectedDetail\?\.status !== 'DRAFT'[^>]*[\s\S]*?printTransferCartons[^>]*[\s\S]*?打印预报箱唛/)
assert.doesNotMatch(view, /row\.__status !== 'DRAFT'.*printTransferCartons\(row\['0'\]\)/s)
assert.doesNotMatch(view, /selectedDetail\?\.status === 'DRAFT'[^>]*[\s\S]*?>提交订单/)
assert.match(view, /native-type="button"[^>]*:loading="saving"[^>]*@click="submit\('draft'\)"/)
@@ -341,7 +336,7 @@ test('仓储转运可在新建页直接提交,也可保存草稿后编辑提
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, /transferInventoryCandidates\.value = \(await listRelabelInventoryCandidates\(warehouseId\)\)\.filter/)
assert.match(view, /transferInventoryOptions\.value\.some\(\(option\) => option\.value === sourceInboundCode\)/)
assert.match(view, /selectedTransferInventory\.value = sourceInboundCode/)
assert.match(view, /detail\.sourceOrders \|\| \[\]/)
@@ -407,7 +402,7 @@ test('预报实收同区对照、托盘卡片换行、签收按钮紧邻标题',
assert.ok(section.includes('class="warehouse-transfer-pallet-sku"'))
assert.doesNotMatch(section, /打印箱唛/)
const receipt = view.slice(view.indexOf('warehouse-transfer-received-card', start)).split('<el-dialog')[0]
assert.ok(receipt.includes('打印箱唛'))
assert.ok(view.includes('打印预报箱唛')); assert.ok(view.includes('增加预报箱数'))
assert.ok(receipt.includes('查看箱号明细'))
assert.match(view, /warehouse-transfer-receipt-heading h3\{flex:0 0 auto/)
})