This commit is contained in:
2026-10-15 15:45:22 +08:00
parent 63e169b051
commit 5b320a3293
7 changed files with 430 additions and 63 deletions
@@ -23,9 +23,9 @@ export interface RelabelDetail {
remark?: string; items: RelabelItemDetail[]
history: Array<{ fromStatus?: string; toStatus?: string; operatorId?: string; remark?: string; createdAt: string }>
tailOrders?: Array<{ id: string; orderNo: string; overseasWarehouseId: string; packageCount: number; status: string }>
completionEvidence?: { taskId: string; photoFileIds: string[]; completionRemark?: string; reviewerName?: string; reviewRemark?: string; completedAt?: string }
completionEvidence?: { taskId: string; photoFileIds: string[]; completionRemark?: string; reviewerName?: string; reviewRemark?: string; completedAt?: string; customerConfirmedAt?: string }
}
export interface DiscardSummary { id: string; discardNo: string; outboundNo?: string; inboundCodes: string; overseasWarehouseId: string; overseasWarehouseName: string; totalPackages: number; totalFee: number; currency: string; status: string; createdAt: string; completedAt?: string }
export interface DiscardSummary { id: string; discardNo: string; outboundNo?: string; inboundCodes: string; overseasWarehouseId: string; overseasWarehouseName: string; totalPackages: number; totalFee: number; currency: string; status: string; createdAt: string; completedAt?: string; customerConfirmedAt?: string }
export interface DiscardBox { cartonId: string; cartonMark: string; palletNo?: string; skus: Array<{ skuCode: string; productName: string; availablePackages: number }> }
export interface DiscardCandidate { cartons?: DiscardBox[]; cartonSelectionSupported?: boolean; cartonSelectionBlockReason?: string; orderId: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON' | 'INVENTORY'; inventoryPositionId?: string; palletNo?: string; returnCartonId?: string; cartonMark?: string; inboundCode: string; inboundOrderNo: string; overseasWarehouseId: string; overseasWarehouseName: string; availablePackages: number; skus: Array<{ skuCode: string; productName: string; availablePackages: number }> }
export interface RelabelInventoryCandidate {
@@ -48,7 +48,7 @@ export interface TransferCreate {
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; packageCount: number; shippingMark?: string }>
items: Array<{ skuCode?: string; productNameZh?: string; quantity: number; packageCount: number; shippingMark?: string }>
relabelItems?: Array<{ operationType: 'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU'; customerCartonMarks: string[]; originalSku?: string; newSku?: string; quantity: number; fbaCartonMark?: string; destinationWarehouseCode?: string; remark?: string }>
}
export interface RelabelCreate {
@@ -134,6 +134,9 @@ export function createDiscard(payload: { items: Array<{ orderId?: string; return
export function cancelDiscard(discardNo: string) {
return apiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/cancel` })
}
export function confirmDiscardCompletion(discardNo: string) {
return apiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/confirm-completion` })
}
export interface DiscardPhoto { fileId: string; palletNo: string; fileName: string; createdAt: string; submittedAt: string }
export function downloadDiscardPhoto(discardNo: string, fileId: string) {
@@ -0,0 +1,81 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import ts from 'typescript'
const source = readFileSync(new URL('./CompletionConfirmationDialog.vue', import.meta.url), 'utf8')
const script = source.match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
const js = ts.transpileModule(script, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText
.replace(/^import .*$/gm, '').replace(/^export \{\};?$/gm, '')
function harness(kind = 'disposal', overrides = {}) {
const props = { modelValue: true, kind, documentNo: 'CD/1' }
const calls = []; const events = []; const revoked = []
const api = {
getDiscard: async () => ({ summary: { status: 'PDA_COMPLETED' }, photos: [{ fileId: '101' }] }),
getRelabel: async () => ({ application: { status: 'CUSTOMER_CONFIRM_PENDING' }, completionEvidence: { photoFileIds: ['101'] } }),
downloadDiscardPhoto: async () => ({}), downloadRelabelCompletionPhoto: async () => ({}),
confirmDiscardCompletion: async no => calls.push(['discard', no]),
confirmRelabelCompletion: async no => calls.push(['relabel', no]), ...overrides,
}
const env = {
computed: fn => ({ get value() { return fn() } }), ref: value => ({ value }), watch: () => {}, onBeforeUnmount: () => {},
defineProps: () => props, defineEmits: () => (...args) => events.push(args),
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))
return { ...state, props, calls, events, revoked }
}
for (const kind of ['disposal', 'labeling']) {
test(`${kind}: opening loads photos without confirming; only reviewed evidence can be submitted`, async () => {
const state = harness(kind)
await state.loadEvidence()
assert.deepEqual(state.calls, [])
await state.submit(); assert.deepEqual(state.calls, [])
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.events, [['confirmed', 'CD/1'], ['update:modelValue', false]])
state.clear(); assert.deepEqual(state.revoked, ['blob:photo'])
})
}
test('photo failure blocks confirmation and supports retry', async () => {
let reject = true
const state = harness('disposal', { downloadDiscardPhoto: async () => { if (reject) throw Error('offline'); return {} } })
await state.loadEvidence(); state.checked.value = true
assert.equal(state.failed.value['101'], true); assert.equal(state.canConfirm.value, false)
reject = false; await state.loadPhoto('101')
assert.equal(state.canConfirm.value, true)
})
test('closing while photos load prevents stale preview URLs', async () => {
let resolve
const state = harness('disposal', { downloadDiscardPhoto: () => new Promise(done => { resolve = done }) })
const pending = state.loadEvidence()
await Promise.resolve()
state.props.modelValue = false; state.clear(); resolve({}); await pending
assert.deepEqual(state.urls.value, {})
})
test('completed orders and missing evidence cannot be confirmed', async () => {
for (const detail of [{ summary: { status: 'COMPLETED' }, photos: [{ fileId: '101' }] }, { summary: { status: 'PDA_COMPLETED' }, photos: [] }]) {
const state = harness('disposal', { getDiscard: async () => detail })
await state.loadEvidence(); state.checked.value = true; await state.submit()
assert.equal(state.canConfirm.value, false); assert.deepEqual(state.calls, [])
}
})
test('failed submission preserves dialog and a pending submission is not sent twice', async () => {
let reject
const state = harness('disposal', { confirmDiscardCompletion: () => new Promise((_, fail) => { reject = fail }) })
await state.loadEvidence(); state.checked.value = true
const pending = state.submit()
assert.equal(state.busy.value, true); await state.submit()
reject(Error('offline')); await pending
assert.deepEqual(state.events, []); assert.equal(state.busy.value, false)
assert.equal(state.canConfirm.value, true)
})
@@ -0,0 +1,105 @@
<script setup lang="ts">
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'
const props = defineProps<{ modelValue: boolean; kind: 'disposal' | 'labeling'; documentNo: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; confirmed: [documentNo: string] }>()
const auth = useAuthStore()
const title = computed(() => props.kind === 'disposal' ? '废弃确认' : '贴标完成确认')
const loading = ref(false)
const busy = ref(false)
const error = ref('')
const fileIds = ref<string[]>([])
const urls = ref<Record<string, string>>({})
const failed = ref<Record<string, boolean>>({})
const photoLoading = ref<Record<string, boolean>>({})
const checked = ref(false)
const eligible = ref(false)
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`))
function clear() {
generation++
Object.values(urls.value).forEach(URL.revokeObjectURL)
urls.value = {}; failed.value = {}; photoLoading.value = {}; fileIds.value = []; checked.value = false; eligible.value = false
}
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)
if (generation !== currentGeneration || !props.modelValue) return
if (urls.value[fileId]) URL.revokeObjectURL(urls.value[fileId])
urls.value[fileId] = URL.createObjectURL(blob)
} catch { if (generation === currentGeneration) failed.value[fileId] = true }
finally { if (generation === currentGeneration) photoLoading.value[fileId] = false }
}
async function loadEvidence() {
clear(); error.value = ''; loading.value = true
const currentGeneration = generation
try {
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'
fileIds.value = (detail.photos || []).map(photo => String(photo.fileId))
} else {
const detail = await getRelabel(props.documentNo)
if (generation !== currentGeneration) return
eligible.value = detail.application.status === 'CUSTOMER_CONFIRM_PENDING' && !detail.application.customerConfirmedAt
fileIds.value = (detail.completionEvidence?.photoFileIds || []).map(String)
}
fileIds.value = [...new Set(fileIds.value)]
if (!eligible.value) error.value = '当前单据已确认或尚未完成 PDA 作业,请刷新列表核对。'
else if (!fileIds.value.length) error.value = '暂无 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 }
}
async function submit() {
if (!canConfirm.value) return
busy.value = true
try {
if (props.kind === 'disposal') await confirmDiscardCompletion(props.documentNo)
else await confirmRelabelCompletion(props.documentNo)
ElMessage.success(props.kind === 'disposal' ? '废弃已确认,单据已完结' : '贴标已确认,订单进入后续出库流程')
emit('confirmed', props.documentNo)
emit('update:modelValue', false)
} catch (e) { ElMessage.error(e instanceof Error ? e.message : '确认失败,请重试') }
finally { busy.value = false }
}
watch(() => [props.modelValue, props.kind, props.documentNo], () => { if (props.modelValue) void loadEvidence(); else clear() }, { immediate: true })
onBeforeUnmount(clear)
</script>
<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 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>
<div class="completion-photo-grid">
<div v-for="(fileId, index) in fileIds" :key="fileId">
<el-image v-if="urls[fileId]" :src="urls[fileId]" :preview-src-list="previewUrls" :initial-index="previewUrls.indexOf(urls[fileId])" fit="contain" preview-teleported />
<span v-else-if="photoLoading[fileId]">照片 {{ index + 1 }} 加载中…</span>
<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>
<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>
</template>
</el-dialog>
</template>
<style scoped>
.completion-photo-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 16px 0; }
.completion-photo-grid .el-image { width: 100%; height: 180px; }
</style>
@@ -2,16 +2,17 @@
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Download, Plus, Printer, Trash2, Upload, X } from '@lucide/vue'
import { ArrowLeft, ChevronDown, Download, Plus, Printer, Trash2, Upload, X } from '@lucide/vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import type { TableAction } from '@/components/common/tableActions'
import { useAuthStore } from '@/modules/auth/public'
import CompletionConfirmationDialog from '../components/CompletionConfirmationDialog.vue'
import { returnRelabelCandidate } from '../utils/returnRelabelCandidates'
import {
createDiscard, createRelabel, createTransfer, getDiscard, getRelabel, getTransfer, resubmitRelabel,
getTransferCartonLabel, submitTransfer, updateTransfer,
confirmRelabelCompletion, confirmTransferMeasurement, downloadRelabelCompletionPhoto, downloadTransferMeasurementPhoto, downloadDiscardPhoto, type DiscardPhoto, uploadRelabelShippingLabel,
confirmTransferMeasurement, downloadRelabelCompletionPhoto, downloadTransferMeasurementPhoto, downloadDiscardPhoto, type DiscardPhoto, uploadRelabelShippingLabel,
uploadRelabelFile,
listDiscards, listDiscardCandidates, listRelabelInventoryCandidates, listRelabelHistoricalOrders, listRelabels, listTransfers, cancelDiscard,
type RelabelInventoryCandidate,
@@ -33,11 +34,11 @@ type ServiceType = 'labeling' | 'transfer' | 'disposal' | 'relabel'
type PageMode = 'list' | 'create' | 'edit' | 'detail'
type LabelingMode = 'carton' | 'sku' | 'carton_and_sku'
type RelabelTemplateType = 'carton' | 'sku' | 'mixed'
interface CargoRow { discardCartonIds?: string[]; id: number; included?: boolean; sku: string; name: string; quantity: number; availableQuantity?: number; sourceOrderId?: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON' | 'RETURN_PARCEL' | 'INVENTORY'; sourceInventoryBalanceId?: string; sourceInventoryPositionId?: string; sourceInboundOrderId?: string; returnCartonId?: string; sourceInboundOrderNo?: string; sourceInboundCode?: string; sourceWarehouseName?: string; palletNo?: string; warehouseLocationCode?: string; fnsku: string; shipmentNo: string; newSkuCode: string; shippingMark: string; customerCartonMarks: string; cartonMark: string; fbaCartonMark: string; destinationWarehouseId: string; remark: string }
interface CargoRow { discardCartonIds?: string[]; id: number; included?: boolean; sku: string; name: string; quantity: number; expectedPackageCount?: number; availableQuantity?: number; sourceOrderId?: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON' | 'RETURN_PARCEL' | 'INVENTORY'; sourceInventoryBalanceId?: string; sourceInventoryPositionId?: string; sourceInboundOrderId?: string; returnCartonId?: string; sourceInboundOrderNo?: string; sourceInboundCode?: string; sourceWarehouseName?: string; palletNo?: string; warehouseLocationCode?: string; fnsku: string; shipmentNo: string; newSkuCode: string; shippingMark: string; customerCartonMarks: string; cartonMark: string; fbaCartonMark: string; destinationWarehouseId: string; remark: string }
interface SkuRelabelRow { id: number; sourceRowId: number | null; cartonMark: string; originalSku: string; newSku: string; quantity: number; fbaCartonMark: string; shipmentNo: string; destinationWarehouseId: string; remark: string }
interface ImportedRelabelBinding extends SkuRelabelRow { templateType: RelabelTemplateType; selected: boolean; shipmentNo: string }
interface ServiceConfig { title: string; createTitle: string; numberLabel: string; emptyText: string; columns: string[] }
interface TransferCarton { cartonMark: string; customerCartonMark?: string; cartonIndex: number; totalCartons: number; status: string }
interface TransferCarton { cartonMark: string; customerCartonMark?: string; cartonIndex: number; totalCartons: number; status: string; transferItemId?: string | number; transferPalletId?: string | number; length?: number; width?: number; height?: number; weight?: number; quantity?: number; discrepancyReason?: string; countedAt?: string }
const props = withDefaults(defineProps<{ service: ServiceType; createPage?: boolean; detailPage?: boolean; detailNo?: string }>(), { createPage: false, detailPage: false, detailNo: '' })
const auth = useAuthStore()
@@ -53,10 +54,22 @@ const canCreate = computed(() => auth.hasPermission(({
const canManageRelabelFiles = computed(() => auth.hasPermission('merchant:overseas-warehouse:relabel:files'))
const canConfirmRelabel = computed(() => auth.hasPermission('merchant:overseas-warehouse:relabel:confirm'))
const canConfirmTransfer = computed(() => auth.hasPermission('merchant:overseas-warehouse:transfer:confirm'))
const 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') {
if (!no) return
completionConfirmNo.value = no; completionConfirmKind.value = kind; completionConfirmOpen.value = true
}
async function refreshAfterCompletion(no: string) {
if (mode.value === 'detail') await openDetail({ '0': no })
else await load()
}
const canCancelDiscard = computed(() => auth.hasPermission('merchant:overseas-warehouse:discard:cancel'))
const configs: Record<ServiceType, ServiceConfig> = {
labeling: { title: '贴标申请', createTitle: '新增贴标申请', numberLabel: '贴标申请单号', emptyText: '暂无贴标申请', columns: ['操作', '贴标申请单号', '关联业务单号', '海外操作仓', 'SKU 数(种)', '总箱数/包裹', '换标类型', '状态', '审核时间', '贴标完成时间', '贴标出库时间', '创建时间'] },
transfer: { title: '仓储转运单', createTitle: '新增仓储转运单', numberLabel: '转运单号', emptyText: '暂无仓储转运订单', columns: ['操作', '转运单号', '箱数', '海外仓', 'FBA 目的仓', '入仓方式', '是否换标', '换标单号', '换标状态', '状态', '审核时间', '测量时间', '出库时间', '创建时间'] },
transfer: { title: '仓储转运单', createTitle: '新增仓储转运单', numberLabel: '转运单号', emptyText: '暂无仓储转运订单', columns: ['操作', '转运单号', '预计箱数', '海外仓', 'FBA 目的仓', '入仓方式', '是否换标', '换标单号', '换标状态', '状态', '审核时间', '测量时间', '出库时间', '创建时间'] },
disposal: { title: '货物废弃', createTitle: '新增货物废弃申请', numberLabel: '废弃申请单号', emptyText: '暂无货物废弃申请', columns: ['操作', '废弃申请单号', '废弃出库单号', '进仓编号', '操作仓', 'SKU 汇总', '废弃数量', '预估费用', '状态', '废弃完成时间', '创建时间'] },
relabel: { title: '退货换标', createTitle: '新建退货换标单', numberLabel: '退货换标单号', emptyText: '暂无退货换标订单', columns: ['操作', '退货换标单号', '来源 FBA 仓', '目的 FBA 仓', 'SKU 汇总', '退件总数', '是否换标', '换标状态', '尾程状态', '审核时间', '测量时间', '出库时间', '创建时间'] },
}
@@ -81,7 +94,7 @@ const listQueryModel = computed<QueryModel>({
const statusOptions = computed(() => props.service === 'labeling'
? [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '换标完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }]
: props.service === 'disposal'
? [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '待 PDA 废弃', value: 'APPROVED' }, { label: '待仓库确认完成', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已拒绝', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }]
? [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '待 PDA 废弃', value: 'APPROVED' }, { label: '待客户确认', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已拒绝', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }]
: props.service === 'transfer'
? [{ label: '草稿', value: 'DRAFT' }, { label: '待审核', value: 'PENDING_REVIEW' },
{ label: '待PDA收货', value: 'WAIT_PDA_RECEIVING' }, { label: '入库测量中', value: 'INBOUND_MEASURING' },
@@ -130,11 +143,35 @@ interface ServiceDetail extends Record<string, unknown> {
relabelContactPhone?: string; relabelDeliveryMethod?: string; relabelNo?: string
relabelStatus?: string; relabelFulfillmentStatus?: string; createdAt?: string
inboundCode?: string; sourceOrders?: Array<{ lastlegOrderId?: string | number }>
items?: Array<{ cartonMarks?: string[]; inboundCode?: string; productName?: string; discardPackages?: number; skuCode?: string; productNameZh?: string; packageCount?: number; shippingMark?: string }>
items?: Array<{ id?: string | number; cartonMarks?: string[]; inboundCode?: string; productName?: string; discardPackages?: number; skuCode?: string; productNameZh?: string; quantity?: number | null; packageCount?: number; shippingMark?: string }>
relabelBindings?: Array<{ operationType?: string; customerCartonMark?: string; originalSku?: string; newSku?: string; quantity?: number; fbaCartonMark?: string; destinationWarehouseCode?: string; remark?: string }>
cartons?: TransferCarton[]; pallets?: TransferPallet[]; measurementPhotos?: TransferMeasurementPhoto[]; customerConfirmedAt?: string
}
const selectedDetail = ref<ServiceDetail | null>(null)
const showTransferCartons = ref(false)
function transferCartonItem(carton: TransferCarton) {
return selectedDetail.value?.items?.find((item) => carton.transferItemId != null && String(item.id) === String(carton.transferItemId))
}
function palletCartons(pallet: TransferPallet) {
return (selectedDetail.value?.cartons || []).filter((carton) => carton.status === 'COUNTED' && pallet.id != null && carton.transferPalletId != null && String(carton.transferPalletId) === String(pallet.id))
}
function palletSkuRows(pallet: TransferPallet) {
const summary = new Map<string, number>()
for (const carton of palletCartons(pallet)) {
const item = transferCartonItem(carton); const sku = item?.skuCode || '未标记 SKU'
summary.set(sku, (summary.get(sku) || 0) + Number(carton.quantity || 0))
}
return [...summary.entries()].map(([sku, quantity]) => ({ sku, quantity }))
}
function palletQuantity(pallet: TransferPallet) { return palletCartons(pallet).reduce((sum, carton) => sum + Number(carton.quantity || 0), 0) }
function countedTransferCartons() { return (selectedDetail.value?.cartons || []).filter((carton) => carton.status === 'COUNTED') }
function receivedItemCartons(itemId: string | number | undefined) {
return countedTransferCartons().filter(carton => itemId != null && carton.transferItemId != null && String(carton.transferItemId) === String(itemId))
}
function receivedItemQuantity(itemId: string | number | undefined) {
return receivedItemCartons(itemId).reduce((sum, carton) => sum + Number(carton.quantity || 0), 0)
}
const detailError = ref('')
const selectedRelabelDetail = ref<RelabelDetail | null>(null)
const completionPhotoUrls = reactive<Record<string, string>>({})
@@ -463,7 +500,7 @@ async function openEdit(transferNo: string) {
transferDestinationType.value = detail.destinationType || (detail.fbaWarehouseCode ? 'FBA' : 'NONE')
cargoRows.value = (detail.items || []).map((item) => ({
...createCargoRow(), sku: item.skuCode || '', name: item.productNameZh || '',
quantity: Number(item.packageCount || 0), shippingMark: item.shippingMark || '',
quantity: Number(item.quantity || 0), expectedPackageCount: Number(item.packageCount || 0), shippingMark: item.shippingMark || '',
customerCartonMarks: item.shippingMark || '',
}))
const bindings = detail.relabelBindings || []
@@ -520,26 +557,26 @@ async function selectDiscardWarehouse() {
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '可废弃入库单加载失败') }
finally { discardCandidatesLoading.value = false }
}
function createCargoRow(): CargoRow { return { id: ++cargoSequence, sku: '', name: '', quantity: 1, fnsku: '', shipmentNo: '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', cartonMark: '', fbaCartonMark: '', destinationWarehouseId: '', remark: '' } }
function 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.quantity) > 0)
if (!validRows.length || validRows.some((row) => !Number.isInteger(Number(row.quantity)))) {
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.quantity), 0)
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.quantity)) {
marks = Array.from({ length: Number(row.quantity) }, (_, index) => `CT-${batch}-${String(rowIndex + 1).padStart(2, '0')}-${String(index + 1).padStart(3, '0')}`)
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 })
@@ -696,7 +733,7 @@ function downloadTransferCartonMarkTemplate() {
ElMessage.warning('请先填写完整的 SKU 明细'); return
}
const header = ['SKU', '客户箱唛号']
const rows = cargoRows.value.flatMap((row) => Array.from({ length: row.quantity }, () => [row.sku, '']))
const rows = cargoRows.value.flatMap((row) => Array.from({ length: Number(row.expectedPackageCount || 0) }, () => [row.sku, '']))
const blob = new Blob([`\uFEFF${[header, ...rows].map((row) => row.map(csvCell).join(',')).join('\r\n')}\r\n`], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob); const anchor = document.createElement('a')
anchor.href = url; anchor.download = '仓储转运-客户箱唛模板.csv'; anchor.click(); URL.revokeObjectURL(url)
@@ -932,7 +969,7 @@ function statusLabel(value: string) {
PENDING_PROCESSING: '待处理', PROCESSING: '处理中', CUSTOMER_CONFIRM_PENDING: '待客户确认',
READY_FOR_OUTBOUND: '换标完成', COMPLETED: '已完成', CANCELLED: '已取消',
DRAFT: '草稿', PENDING_REVIEW: '待审核', APPROVED: '待 PDA 废弃', PDA_COMPLETED: '待仓库确认完成', REJECTED: '已拒绝', PENDING_ASSIGNMENT: '待分配',
DRAFT: '草稿', PENDING_REVIEW: '待审核', APPROVED: '待 PDA 废弃', PDA_COMPLETED: '待客户确认', REJECTED: '已拒绝', PENDING_ASSIGNMENT: '待分配',
WAIT_PDA_RECEIVING: '待PDA收货', WAIT_CUSTOMER_CONFIRMATION: '待客户确认实测', WAIT_FBA_DESTINATION: '待确认目的地', WAIT_RELABEL: '待换标', READY_FOR_DISPATCH: '待排车', WAIT_OUTBOUND: '待出库',
ALLOCATED: '已分配', OUTBOUNDED: '已出库', PARTIALLY_RECEIVED: '部分收货', RECEIVED: '已收货', INBOUND_COMPLETED: '入库完成', PENDING_DELIVERY: '待派送', DELIVERING: '派送中', DELIVERED: '派送完成',
@@ -1031,7 +1068,7 @@ async function load() {
} else if (props.service === 'disposal') {
const result = await listDiscards({ page: page.value, size: pageSize.value, discardNo: filters.number || undefined, inboundCode: filters.inboundNo || undefined, overseasWarehouseId: filters.warehouse || undefined, status: filters.status || undefined, createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true), completedFrom: dateTime(filters.completedStart), completedTo: dateTime(filters.completedEnd, true) })
total.value = Number.isFinite(Number(result.total)) ? Number(result.total) : result.records.length
submittedRows.value = result.records.map((item) => row([item.discardNo, item.outboundNo || '—', item.inboundCodes, item.overseasWarehouseName || warehouseDisplay(item.overseasWarehouseId) || '—', '—', item.totalPackages, `${item.currency} ${item.totalFee}`, statusLabel(item.status), formatDateTime(item.completedAt), formatDateTime(item.createdAt)]))
submittedRows.value = result.records.map((item) => ({ ...row([item.discardNo, item.outboundNo || '—', item.inboundCodes, item.overseasWarehouseName || warehouseDisplay(item.overseasWarehouseId) || '—', '—', item.totalPackages, `${item.currency} ${item.totalFee}`, statusLabel(item.status), formatDateTime(item.customerConfirmedAt), formatDateTime(item.createdAt)]), __status: item.status }))
} else {
const result = await listTransfers({ page: page.value, size: pageSize.value, transferNo: filters.number || undefined, inboundCode: filters.inboundNo || undefined, operatingWarehouseId: filters.warehouse || undefined, fbaWarehouseCode: filters.destination || undefined, status: filters.status || undefined, ...(props.service === 'relabel' ? { needRelabel: true } : {}), createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true), reviewedFrom: dateTime(filters.reviewStart), reviewedTo: dateTime(filters.reviewEnd, true), measuredFrom: props.service === 'relabel' ? dateTime(filters.measuredStart) : dateTime(filters.completedStart), measuredTo: props.service === 'relabel' ? dateTime(filters.measuredEnd, true) : dateTime(filters.completedEnd, true), outboundFrom: dateTime(filters.outboundStart), outboundTo: dateTime(filters.outboundEnd, true) })
total.value = Number.isFinite(Number(result.total)) ? Number(result.total) : result.records.length
@@ -1054,6 +1091,7 @@ function listRowActions(candidate: object): TableAction[] {
if (canCreate.value && props.service === 'transfer' && ['DRAFT', 'REJECTED'].includes(item.__status)) actions.push({ key: 'edit', label: '编辑', run: () => openEdit(item['0']) })
if (canConfirmTransfer.value && props.service === 'transfer' && item.__status === 'WAIT_CUSTOMER_CONFIRMATION') actions.push({ key: 'confirm-measurement', label: '确认测量', run: () => confirmTransferFromList(item['0']) })
if (canConfirmRelabel.value && props.service === 'labeling' && item.__status === 'CUSTOMER_CONFIRM_PENDING' && !item.__confirmedAt) actions.push({ key: 'confirm-relabel', label: '确认换标', run: () => confirmRelabelFromList(item['0']) })
if (canConfirmDiscard.value && props.service === 'disposal' && item.__status === 'PDA_COMPLETED') actions.push({ key: 'confirm-discard', label: '废弃确认', run: () => openCompletionConfirmation(item['0'], 'disposal') })
return actions
}
watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
@@ -1069,7 +1107,7 @@ async function submit(action: 'draft' | 'submit' = 'submit') {
: (props.service === 'labeling' || props.service === 'relabel') && (relabelRows.length === 0 || (props.service === 'labeling' && labelingMode.value !== 'carton'
? skuRelabelRows.value.some((item) => !item.cartonMark || !item.originalSku || !item.newSku || item.quantity < 1
|| (labelingMode.value === 'carton_and_sku' && (!item.fbaCartonMark || !item.shipmentNo.trim())))
: cargoRows.value.some((item) => (props.service !== 'labeling' && !item.sku) || !item.name
: (props.service === 'labeling' ? cartonDetailRows.value : cargoRows.value).some((item) => (props.service !== 'labeling' && !item.sku) || !item.name
|| (props.service !== 'labeling' && item.quantity < 1))))
if ((props.service === 'relabel' && !form.inboundNo) || !primaryWarehouse || invalidCargo) {
ElMessage.warning('请填写必填信息和完整的货物明细'); return
@@ -1105,10 +1143,14 @@ 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.volumeCbm < 0 || (form.deliveryMethod === 'WAREHOUSE_PICKUP' && (!form.pickupContact || !form.pickupPhone)))) {
const missing = [!form.warehouse && '海外仓', !form.deliveryMethod && '送货方式', !form.address && '地址', form.deliveryMethod === 'WAREHOUSE_PICKUP' && !form.pickupContact && '提货联系人', form.deliveryMethod === 'WAREHOUSE_PICKUP' && !form.pickupPhone && '联系人电话', !form.estimatedArrivalAt && '预计入仓时间', form.volumeCbm < 0 && '预估总体积'].filter(Boolean).join('、')
if (props.service === 'transfer' && (!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
}
if (props.service === 'transfer' && (!cargoRows.value.length || cargoRows.value.some((row) =>
!Number.isSafeInteger(row.quantity) || row.quantity < 1 || !Number.isSafeInteger(row.expectedPackageCount) || Number(row.expectedPackageCount) < 1))) {
ElMessage.warning('请填写每个 SKU 的数量(件)和预计箱数,均须为正整数'); return
}
if (props.service === 'transfer' && transferDestinationType.value === 'FBA' && !form.destination) {
ElMessage.warning('请选择 FBA 目的仓'); return
}
@@ -1169,12 +1211,12 @@ async function submit(action: 'draft' | 'submit' = 'submit') {
})
await createDiscard({ items, idempotencyKey, remark: [form.reason, form.remark].filter(Boolean).join(';') || undefined })
} else {
const packageCount = cargoRows.value.reduce((sum, item) => sum + item.quantity, 0)
const packageCount = cargoRows.value.reduce((sum, item) => sum + Number(item.expectedPackageCount || 0), 0)
const customerCartonMarks = (form.relabel ? transferRelabelRows.value : cargoRows.value).flatMap(transferCustomerMarks)
const transferRemark = [form.remark, form.relabel && form.requirement ? `换标要求:${form.requirement}` : ''].filter(Boolean).join(';')
const operationType = labelingMode.value === 'carton' ? 'CARTON_LABEL' : labelingMode.value === 'sku' ? 'SKU_LABEL' : 'CARTON_AND_SKU'
const selectedFba = fbaWarehouses.value.find((warehouse) => warehouse.warehouseCode === form.destination)
const payload: TransferCreate = { operatingWarehouseId: primaryWarehouse, destinationType: transferDestinationType.value, fbaWarehouseCode: transferDestinationType.value === 'FBA' ? form.destination || undefined : undefined, destinationWarehouseId: transferDestinationType.value === 'FBA' ? String(selectedFba?.id || '') || undefined : undefined, destinationWarehouseCode: transferDestinationType.value === 'FBA' ? form.destination || undefined : undefined, destinationWarehouseName: transferDestinationType.value === 'FBA' ? selectedFba?.warehouseName : undefined, privateDestinationAddress: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationAddress : undefined, privateDestinationPostalCode: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationPostalCode : undefined, privateDestinationContactName: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationContact : undefined, privateDestinationContactPhone: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationPhone : undefined, deliveryAddress: form.address, packageCount, volumeCbm: form.volumeCbm, deliveryMethod: form.deliveryMethod, estimatedArrivalAt: form.estimatedArrivalAt, estimatedShipAt: form.estimatedShipAt || undefined, fbaShipmentNo: transferDestinationType.value === 'FBA' ? form.fbaShipmentNo || undefined : undefined, trackingNo: form.deliveryMethod === 'CUSTOMER_DELIVERY' ? form.trackingNo || undefined : undefined, fbaBoxNos: customerCartonMarks.length ? customerCartonMarks.join(',') : undefined, remark: transferRemark || undefined, needRelabel: props.service === 'relabel' || form.relabel, labelDeadline: form.relabel ? form.labelDeadline : undefined, pickupContactName: form.deliveryMethod === 'WAREHOUSE_PICKUP' ? form.pickupContact : undefined, pickupContactPhone: form.deliveryMethod === 'WAREHOUSE_PICKUP' ? form.pickupPhone : undefined, relabelContactName: form.relabel ? form.relabelContact : undefined, relabelContactPhone: form.relabel ? form.relabelPhone : undefined, relabelDeliveryMethod: form.relabel && labelingMode.value !== 'sku' ? form.relabelDeliveryMethod as 'TRUCK' | 'EXPRESS' | 'AMAZON_PICKUP' | 'CUSTOMER_PICKUP' : undefined, idempotencyKey, items: cargoRows.value.map((item) => ({ skuCode: item.sku, productNameZh: item.name, packageCount: item.quantity, shippingMark: transferCustomerMarks(item).join(',') || undefined })), relabelItems: form.relabel ? transferRelabelRows.value.map((item) => ({ operationType, customerCartonMarks: transferCustomerMarks(item), originalSku: item.sku, newSku: item.newSkuCode || undefined, quantity: item.quantity, fbaCartonMark: item.fbaCartonMark || undefined, destinationWarehouseCode: transferRelabelDefaultDestination.value || undefined, remark: item.remark || undefined })) : undefined }
const 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 }
if (props.service === 'transfer' && selectedTransferInventory.value) {
;(payload as TransferCreate).inboundCode = selectedTransferInventory.value
;(payload as TransferCreate).sourceLastlegOrderIds = selectedTransferSourceOrderIds.value
@@ -1223,6 +1265,7 @@ async function openDetail(value: Record<string, string>) {
try {
const detail = props.service === 'labeling' ? await getRelabel(value['0']) : props.service === 'disposal' ? await getDiscard(value['0']) : await getTransfer(value['0'])
const discardSummary = props.service === 'disposal' ? (detail as { summary?: ServiceDetail }).summary : undefined
showTransferCartons.value = false
selectedDetail.value = { ...value, ...(detail as ServiceDetail), ...(discardSummary || {}) }
selectedRelabelDetail.value = props.service === 'labeling' ? detail as RelabelDetail : null
mode.value = 'detail'
@@ -1307,12 +1350,7 @@ async function loadMeasurementPhotos() {
await Promise.all((selectedDetail.value?.measurementPhotos || []).map((photo) => loadMeasurementPhoto(photo.fileId)))
}
async function confirmRelabelFromList(relabelNo: string) {
try {
await ElMessageBox.confirm('确认仓库上传的换标结果无误并继续出库流程吗?', '换标完成确认', { confirmButtonText: '确认并继续', cancelButtonText: '取消' })
await confirmRelabelCompletion(relabelNo)
ElMessage.success('已确认换标完成,订单已进入后续出库流程')
await load()
} catch (error) { if (error !== 'cancel') ElMessage.error(error instanceof Error ? error.message : '确认失败') }
openCompletionConfirmation(relabelNo, 'labeling')
}
async function cancelSelectedDiscard() {
const no = String(selectedDetail.value?.discardNo || selectedDetail.value?.['0'] || '')
@@ -1386,13 +1424,7 @@ async function selectTransferWarehouse() {
}
async function confirmSelectedRelabel() {
const relabelNo = selectedRelabelDetail.value?.application.relabelNo
if (!relabelNo) return
try {
await ElMessageBox.confirm('确认仓库上传的换标结果无误并继续出库流程吗?', '换标完成确认', { confirmButtonText: '确认并继续', cancelButtonText: '取消' })
await confirmRelabelCompletion(relabelNo)
ElMessage.success('已确认换标完成,订单已进入后续出库流程')
await openDetail({ '0': relabelNo })
} catch (error) { if (error !== 'cancel') ElMessage.error(error instanceof Error ? error.message : '确认失败') }
if (relabelNo) openCompletionConfirmation(relabelNo, 'labeling')
}
async function resubmitRejectedRelabel() {
const application = selectedRelabelDetail.value?.application
@@ -1524,12 +1556,12 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
return
}
if (cartonDetailRows.value.some(item => item.cartonMark === source.cartonMark)) return
cargoRows.value.push({ id: ++cargoSequence, included: true, sku: source.skuItems?.map(item => item.skuCode).join(',') || '', name: source.productNameZh || '', shipmentNo: '', cartonMark: source.cartonMark || '', fbaCartonMark: '', remark: '', sourceInventoryBalanceId: undefined, sourceInventoryPositionId: undefined, sourceInboundOrderId: undefined, returnCartonId: String(source.returnCartonId), quantity: 1, fnsku: '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', destinationWarehouseId: '' })
cargoRows.value.push({ id: ++cargoSequence, included: true, sku: source.skuItems?.map(item => item.skuCode).join(',') || '', name: source.productNameZh || '', shipmentNo: '', cartonMark: source.cartonMark || '', fbaCartonMark: '', remark: '', sourceInventoryBalanceId: undefined, sourceInventoryPositionId: undefined, sourceInboundOrderId: undefined, returnCartonId: String(source.returnCartonId), quantity: 1, expectedPackageCount: 1, fnsku: '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', destinationWarehouseId: '' })
}
</script>
<template>
<section class="warehouse-service-page" :class="{ 'warehouse-service-page--detail': mode === 'detail', 'warehouse-service-page--return-relabel': isReturnRelabelSource }">
<section class="warehouse-service-page" :class="{ 'warehouse-service-page--detail': mode === 'detail', 'warehouse-service-page--transfer': props.service === 'transfer', 'warehouse-service-page--return-relabel': isReturnRelabelSource }">
<template v-if="mode === 'list'">
<QueryToolbar v-model="listQueryModel" :class="{ 'labeling-query': props.service === 'labeling', 'transfer-query': props.service === 'transfer' || props.service === 'disposal', 'disposal-query': props.service === 'disposal' }" layout="grid" :ariaLabel="`${config.title}筛选`" :fields="listQueryFields" :search-on-reset="false" @search="query" @reset="resetListQuery" />
<div class="warehouse-card warehouse-table-card">
@@ -1587,7 +1619,7 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
<div v-if="props.service === 'labeling' && labelingMode !== 'sku' && form.deliveryMethod === 'EXPRESS'" class="warehouse-checkbox-field"><span>是否打印面单</span><el-checkbox v-model="form.relabel">需要仓库打印面单</el-checkbox></div>
<label v-if="canManageRelabelFiles && props.service === 'labeling' && labelingMode !== 'sku' && form.deliveryMethod === 'EXPRESS' && form.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 === 'transfer' || props.service === 'relabel'"><span><i>*</i>{{ props.service === 'transfer' ? '预估体积(CBM)' : '预估总体积(CBM)' }}</span><el-input-number v-model="form.volumeCbm" :min="0" :precision="4" :step="0.0001" controls-position="right" placeholder="请输入预估总体积" /></label>
<label v-if="props.service === 'relabel'"><span><i>*</i>预估总体积(CBM)</span><el-input-number v-model="form.volumeCbm" :min="0" :precision="4" :step="0.0001" controls-position="right" placeholder="请输入预估总体积" /></label>
<label v-if="props.service === 'transfer' || props.service === 'relabel'"><span><i>*</i>{{ props.service === 'transfer' ? '预计入仓时间' : '预计到达时间' }}</span><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :placeholder="props.service === 'transfer' ? '请选择预计入仓时间' : '请选择预计到达时间'" /></label>
<label v-if="props.service === 'transfer'"><span>期望发货时间</span><el-date-picker v-model="form.estimatedShipAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="选填" /></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>
@@ -1671,8 +1703,8 @@ 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() && row.quantity > 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>{{ props.service === 'disposal' ? '本次废弃箱数' : '预计箱数' }} *</th><th v-if="props.service !== 'disposal'">操作</th></tr></thead><tbody><tr v-for="row in cargoRows" :key="row.id"><td><el-input v-model="row.sku" :readonly="props.service === 'disposal'" :placeholder="props.service === 'transfer' ? '选填' : '请输入商品 SKU'" /></td><td><el-input v-model="row.name" :readonly="props.service === 'disposal'" :placeholder="props.service === 'transfer' ? '选填' : '请输入中文品名'" /></td><td v-if="props.service === 'disposal'"><strong>{{ row.availableQuantity }} 箱</strong></td><td v-if="props.service === 'relabel'"><el-input v-model="row.shippingMark" placeholder="请输入FNSKU或箱唛" /></td><td><el-input-number v-model="row.quantity" :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' ? 5 : 4"><div class="warehouse-empty">{{ props.service === 'disposal' ? '请先选择入库单,系统将自动带出 SKU 和可用箱数' : '暂无货物明细,请点击“添加行”' }}</div></td></tr></tbody></table></div>
<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>
</template>
</div>
<div v-if="props.service === 'transfer' && form.relabel" class="warehouse-card warehouse-form-section warehouse-transfer-relabel">
@@ -1708,24 +1740,57 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
<header class="warehouse-detail-header">
<button type="button" class="warehouse-detail-back" @click="returnToList"><ArrowLeft :size="16" />返回列表</button>
<div class="warehouse-detail-title"><strong>{{ props.service === 'labeling' ? selectedRelabelDetail?.application.relabelNo : selectedDetail?.transferNo || selectedDetail?.['0'] || `${config.title}详情` }}</strong><span>{{ config.title }}详情</span></div>
<div class="warehouse-detail-actions"><el-button v-if="canCreate && props.service === 'labeling' && selectedRelabelDetail?.application.status === 'REJECTED'" type="primary" @click="resubmitRejectedRelabel">修改后重新提交</el-button><el-button v-if="canConfirmRelabel && props.service === 'labeling' && selectedRelabelDetail?.application.status === 'CUSTOMER_CONFIRM_PENDING' && !selectedRelabelDetail?.application.customerConfirmedAt" type="primary" @click="confirmSelectedRelabel">确认换标完成</el-button><el-button v-if="canCancelDiscard && props.service === 'disposal' && selectedDetail?.status === 'PENDING_REVIEW'" type="danger" @click="cancelSelectedDiscard">取消申请</el-button><el-button v-if="canCreate && props.service === 'transfer' && ['DRAFT', 'REJECTED'].includes(selectedDetail?.status || '')" type="primary" @click="openEdit(String(selectedDetail?.transferNo || selectedDetail?.['0'] || ''))">编辑</el-button><el-button v-if="canConfirmTransfer && props.service === 'transfer' && selectedDetail?.status === 'WAIT_CUSTOMER_CONFIRMATION'" type="primary" @click="confirmTransferMeasurements">确认测量数据</el-button><el-tag :type="props.service === 'labeling' ? relabelStatusTagType(selectedRelabelDetail?.application.status || '') : undefined" effect="light">{{ statusLabel(props.service === 'labeling' ? selectedRelabelDetail?.application.status || '' : String(selectedDetail?.status || '')) }}</el-tag></div>
<div class="warehouse-detail-actions"><el-button v-if="canCreate && props.service === 'labeling' && selectedRelabelDetail?.application.status === 'REJECTED'" type="primary" @click="resubmitRejectedRelabel">修改后重新提交</el-button><el-button v-if="canConfirmRelabel && props.service === 'labeling' && selectedRelabelDetail?.application.status === 'CUSTOMER_CONFIRM_PENDING' && !selectedRelabelDetail?.application.customerConfirmedAt" type="primary" @click="confirmSelectedRelabel">确认换标完成</el-button><el-button v-if="canConfirmDiscard && props.service === 'disposal' && selectedDetail?.status === 'PDA_COMPLETED'" type="primary" @click="openCompletionConfirmation(String(selectedDetail?.discardNo || ''), 'disposal')">废弃确认</el-button><el-button v-if="canCancelDiscard && props.service === 'disposal' && selectedDetail?.status === 'PENDING_REVIEW'" type="danger" @click="cancelSelectedDiscard">取消申请</el-button><el-button v-if="canCreate && props.service === 'transfer' && ['DRAFT', 'REJECTED'].includes(selectedDetail?.status || '')" type="primary" @click="openEdit(String(selectedDetail?.transferNo || selectedDetail?.['0'] || ''))">编辑</el-button><el-button v-if="canConfirmTransfer && props.service === 'transfer' && selectedDetail?.status === 'WAIT_CUSTOMER_CONFIRMATION'" type="primary" @click="confirmTransferMeasurements">确认测量数据</el-button><el-tag v-if="props.service !== 'transfer'" :type="props.service === 'labeling' ? relabelStatusTagType(selectedRelabelDetail?.application.status || '') : undefined" effect="light">{{ statusLabel(props.service === 'labeling' ? selectedRelabelDetail?.application.status || '' : String(selectedDetail?.status || '')) }}</el-tag></div>
</header>
<div v-if="detailError" class="warehouse-detail-error" role="alert"><strong>{{ detailError }}</strong><el-button @click="loadDetailPage">重新加载</el-button></div>
<el-alert v-else-if="props.service === 'labeling' && selectedRelabelDetail?.application.status === 'REJECTED'" class="warehouse-rejected-alert" type="error" :closable="false" show-icon title="贴标单审核已驳回" :description="selectedRelabelDetail.application.reviewRemark || '平台未填写驳回备注'" />
<div v-else class="warehouse-card warehouse-form-section"><h3>订单概览</h3><div v-if="props.service === 'labeling'" class="warehouse-detail-grid"><div><span>操作仓</span><strong>{{ warehouseDisplay(selectedRelabelDetail?.application.overseasWarehouseId) }}</strong></div><div><span>客户参考号</span><strong>{{ selectedRelabelDetail?.application.customerOrderNo || '—' }}</strong></div><div><span>SKU 数(种)</span><strong>{{ selectedRelabelDetail ? relabelSkuCount(selectedRelabelDetail.items) : '—' }}</strong></div><div><span>总箱数</span><strong>{{ selectedRelabelDetail?.application.totalPackageCount ?? '—' }}</strong></div><div><span>贴标截止时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.labelDeadline) }}</strong></div><div><span>仓库联系人</span><strong>{{ selectedRelabelDetail?.application.contactName || '—' }}</strong></div><div><span>联系电话</span><strong>{{ selectedRelabelDetail?.application.contactPhone || '—' }}</strong></div><div><span>创建时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.createdAt) }}</strong></div></div><div v-else-if="props.service === 'transfer'" class="warehouse-detail-grid warehouse-transfer-overview-grid"><div><span>状态</span><el-tag :type="transferStatusTagType(selectedDetail?.status || '')" effect="light">{{ statusLabel(selectedDetail?.status || '') }}</el-tag></div><div><span>目的去向</span><strong>{{ destinationTypeLabel(selectedDetail?.destinationType, selectedDetail?.fbaWarehouseCode) }}</strong></div><div v-if="selectedDetail?.destinationType !== 'PRIVATE'"><span>FBA 目的仓</span><strong>{{ selectedDetail?.destinationWarehouseName || warehouseDisplay(selectedDetail?.destinationWarehouseCode || selectedDetail?.fbaWarehouseCode) }}</strong></div><div v-if="selectedDetail?.destinationType !== 'PRIVATE'"><span>FBA 货件号</span><strong>{{ selectedDetail?.fbaShipmentNo || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'" class="warehouse-overview-wide"><span>私人仓地址</span><strong>{{ selectedDetail?.privateDestinationAddress || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'"><span>邮编</span><strong>{{ selectedDetail?.privateDestinationPostalCode || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'"><span>私人仓联系人</span><strong>{{ selectedDetail?.privateDestinationContactName || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'"><span>联系电话</span><strong>{{ selectedDetail?.privateDestinationContactPhone || '—' }}</strong></div><div><span>海外仓</span><strong>{{ warehouseDisplay(selectedDetail?.operatingWarehouseId) }}</strong></div><div><span>箱数</span><strong>{{ selectedDetail?.packageCount ?? '—' }}</strong></div><div><span>入仓方式</span><strong>{{ deliveryMethodLabel(selectedDetail?.deliveryMethod || '') }}</strong></div><div><span>期望发货时间</span><strong>{{ formatDateTime(selectedDetail?.estimatedShipAt) }}</strong></div><div><span>实际发货时间</span><strong>{{ formatDateTime(selectedDetail?.actualShipAt) }}</strong></div><div><span>是否换标</span><strong>{{ selectedDetail?.needRelabel ? '是' : '否' }}</strong></div><div><span>换标状态</span><el-tag :type="transferRelabelStatusTagType(transferRelabelStatus(selectedDetail || {}).code)" effect="light">{{ transferRelabelStatus(selectedDetail || {}).label }}</el-tag></div><div><span>换标单号</span><strong>{{ selectedDetail?.relabelNo || '—' }}</strong></div><div><span>创建时间</span><strong>{{ formatDateTime(selectedDetail?.createdAt) }}</strong></div><div><span>转运单号</span><strong>{{ selectedDetail?.transferNo || '—' }}</strong></div></div><div v-else class="warehouse-detail-grid"><div v-for="(column, index) in config.columns.slice(1)" :key="column"><span>{{ column }}</span><strong>{{ selectedDetail?.[String(index)] || '—' }}</strong></div></div></div>
<div v-else class="warehouse-card warehouse-form-section"><h3>订单概览</h3><div v-if="props.service === 'labeling'" class="warehouse-detail-grid"><div><span>操作仓</span><strong>{{ warehouseDisplay(selectedRelabelDetail?.application.overseasWarehouseId) }}</strong></div><div><span>客户参考号</span><strong>{{ selectedRelabelDetail?.application.customerOrderNo || '—' }}</strong></div><div><span>SKU 数(种)</span><strong>{{ selectedRelabelDetail ? relabelSkuCount(selectedRelabelDetail.items) : '—' }}</strong></div><div><span>总箱数</span><strong>{{ selectedRelabelDetail?.application.totalPackageCount ?? '—' }}</strong></div><div><span>贴标截止时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.labelDeadline) }}</strong></div><div><span>仓库联系人</span><strong>{{ selectedRelabelDetail?.application.contactName || '—' }}</strong></div><div><span>联系电话</span><strong>{{ selectedRelabelDetail?.application.contactPhone || '—' }}</strong></div><div><span>创建时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.createdAt) }}</strong></div></div><div v-else-if="props.service === 'transfer'" class="warehouse-detail-grid warehouse-transfer-overview-grid"><div><span>状态</span><el-tag :type="transferStatusTagType(selectedDetail?.status || '')" effect="light">{{ statusLabel(selectedDetail?.status || '') }}</el-tag></div><div><span>目的去向</span><strong>{{ destinationTypeLabel(selectedDetail?.destinationType, selectedDetail?.fbaWarehouseCode) }}</strong></div><div v-if="selectedDetail?.destinationType !== 'PRIVATE'"><span>FBA 目的仓</span><strong>{{ selectedDetail?.destinationWarehouseName || warehouseDisplay(selectedDetail?.destinationWarehouseCode || selectedDetail?.fbaWarehouseCode) }}</strong></div><div v-if="selectedDetail?.destinationType !== 'PRIVATE'"><span>FBA 货件号</span><strong>{{ selectedDetail?.fbaShipmentNo || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'" class="warehouse-overview-wide"><span>私人仓地址</span><strong>{{ selectedDetail?.privateDestinationAddress || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'"><span>邮编</span><strong>{{ selectedDetail?.privateDestinationPostalCode || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'"><span>私人仓联系人</span><strong>{{ selectedDetail?.privateDestinationContactName || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'"><span>联系电话</span><strong>{{ selectedDetail?.privateDestinationContactPhone || '—' }}</strong></div><div><span>海外仓</span><strong>{{ warehouseDisplay(selectedDetail?.operatingWarehouseId) }}</strong></div><div><span>预报 SKU 种数</span><strong>{{ new Set((selectedDetail?.items || []).map(item => item.skuCode).filter(Boolean)).size }}</strong></div><div><span>预计箱数</span><strong>{{ selectedDetail?.packageCount ?? '—' }}</strong></div><div><span>实际签收箱数</span><strong>{{ selectedDetail?.cartons?.filter((carton) => carton.status === 'COUNTED').length ?? '—' }}</strong></div><div><span>入仓方式</span><strong>{{ deliveryMethodLabel(selectedDetail?.deliveryMethod || '') }}</strong></div><div><span>期望发货时间</span><strong>{{ formatDateTime(selectedDetail?.estimatedShipAt) }}</strong></div><div><span>实际发货时间</span><strong>{{ formatDateTime(selectedDetail?.actualShipAt) }}</strong></div><div><span>是否换标</span><strong>{{ selectedDetail?.needRelabel ? '是' : '否' }}</strong></div><div><span>换标状态</span><el-tag :type="transferRelabelStatusTagType(transferRelabelStatus(selectedDetail || {}).code)" effect="light">{{ transferRelabelStatus(selectedDetail || {}).label }}</el-tag></div><div><span>换标单号</span><strong>{{ selectedDetail?.relabelNo || '—' }}</strong></div><div><span>创建时间</span><strong>{{ formatDateTime(selectedDetail?.createdAt) }}</strong></div></div><div v-else class="warehouse-detail-grid"><div v-for="(column, index) in config.columns.slice(1)" :key="column"><span>{{ column }}</span><strong>{{ selectedDetail?.[String(index)] || '—' }}</strong></div></div></div>
<div v-if="props.service === 'disposal'" class="warehouse-card warehouse-form-section">
<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'" class="warehouse-card warehouse-form-section warehouse-transfer-measurement-card">
<div class="warehouse-section-heading"><h3>PDA 打托实测数据</h3><span class="warehouse-hint">PDA 作业完成即显示入库完成,客户可确认仓库实测数据</span></div>
<div v-if="selectedDetail?.pallets?.length" class="warehouse-table-scroll"><table><thead><tr><th>托盘号</th><th>库位</th><th>箱数</th><th>长 × 宽 × 高(cm)</th><th>毛重(kg)</th><th>体积(m³)</th><th>状态</th><th>完成时间</th></tr></thead><tbody><tr v-for="pallet in selectedDetail.pallets" :key="String(pallet.id || pallet.sequenceNo)"><td>{{ pallet.palletNo || `第${pallet.sequenceNo || '—'}托` }}</td><td>{{ pallet.warehouseLocationCode || '—' }}</td><td>{{ pallet.cartonCount ?? '—' }}</td><td>{{ [pallet.lengthCm, pallet.widthCm, pallet.heightCm].every((value) => value != null) ? `${pallet.lengthCm} × ${pallet.widthCm} × ${pallet.heightCm}` : '—' }}</td><td>{{ pallet.grossWeightKg ?? '—' }}</td><td>{{ pallet.volumeCbm ?? '—' }}</td><td>{{ statusLabel(pallet.status || '') }}</td><td>{{ formatDateTime(pallet.completedAt) }}</td></tr></tbody></table></div>
<div v-else class="warehouse-empty">暂无 PDA 打托测量数据</div>
<div 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>
<details class="warehouse-transfer-pallet-group" open>
<summary class="warehouse-section-heading warehouse-transfer-pallet-heading warehouse-transfer-pallet-summary"><h3>PDA 打托实测数据</h3><span class="warehouse-transfer-pallet-count">{{ selectedDetail?.pallets?.length || 0 }} 托</span><span class="warehouse-transfer-pallet-toggle"><span class="when-collapsed">展开托盘</span><span class="when-expanded">收起托盘</span><ChevronDown :size="14" /></span></summary>
<div v-if="selectedDetail?.pallets?.length" class="warehouse-transfer-pallet-list">
<article v-for="pallet in selectedDetail.pallets" :key="String(pallet.id || pallet.sequenceNo)" class="warehouse-transfer-pallet-card">
<header class="warehouse-transfer-pallet-header">
<strong><span>托盘号</span> {{ pallet.palletNo || `第${pallet.sequenceNo || '—'}托` }}</strong>
<dl class="warehouse-transfer-pallet-metrics">
<div><dt>箱数</dt><dd>{{ palletCartons(pallet).length }} 箱</dd></div><div><dt>件数</dt><dd>{{ palletQuantity(pallet) }} 件</dd></div><div><dt>SKU 种数</dt><dd>{{ palletSkuRows(pallet).length }} 种</dd></div><div><dt>毛重(kg)</dt><dd>{{ pallet.grossWeightKg ?? '—' }}</dd></div><div><dt>体积(m³)</dt><dd>{{ pallet.volumeCbm ?? '—' }}</dd></div>
</dl>
</header>
<div class="warehouse-transfer-pallet-skus"><span>SKU 汇总</span><div><div v-for="item in palletSkuRows(pallet)" :key="item.sku" class="warehouse-transfer-pallet-sku"><strong>{{ item.sku }}</strong><span>{{ item.quantity }} 件</span></div><span v-if="!palletSkuRows(pallet).length">暂无已清点 SKU</span></div></div>
<div class="warehouse-transfer-pallet-meta"><span>库位:{{ pallet.warehouseLocationCode || '—' }}</span><span>完成时间:{{ formatDateTime(pallet.completedAt) }}</span></div>
</article>
</div>
<div v-else class="warehouse-empty">暂无 PDA 打托测量数据</div>
</details>
<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-cargo-card">
<div class="warehouse-section-heading"><h3>货物明细</h3><el-button v-if="selectedDetail?.status !== 'DRAFT'" native-type="button" type="primary" @click="printTransferCartons(String(selectedDetail?.transferNo || selectedDetail?.['0'] || ''))"><Printer :size="16" />打印箱唛</el-button></div>
<div class="warehouse-table-scroll"><table class="warehouse-edit-table"><thead><tr><th>商品 SKU</th><th>中文品名</th><th>预计箱数</th><th>客户箱唛号</th></tr></thead><tbody><tr v-for="(item, index) in selectedDetail?.items || []" :key="`${item.skuCode}-${index}`"><td>{{ item.skuCode || '—' }}</td><td>{{ item.productNameZh || '—' }}</td><td>{{ item.packageCount ?? '—' }}</td><td>{{ item.shippingMark || '—' }}</td></tr><tr v-if="!selectedDetail?.items?.length"><td colspan="4"><div class="warehouse-empty">暂无货物明细</div></td></tr></tbody></table></div>
<div 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 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">
<el-collapse>
<el-collapse-item v-for="carton in countedTransferCartons()" :key="carton.cartonMark" :name="carton.cartonMark">
<template #title><strong>{{ carton.cartonMark }}</strong><span class="warehouse-carton-summary">已确认 · {{ transferCartonItem(carton)?.skuCode ? 1 : 0 }} 种 SKU · {{ carton.quantity ?? '—' }} 件</span></template>
<div class="warehouse-carton-measurements">所属托盘:{{ selectedDetail?.pallets?.find((pallet) => carton.transferPalletId != null && String(pallet.id) === String(carton.transferPalletId))?.palletNo || '散箱' }} · 清点时间:{{ formatDateTime(carton.countedAt) }}</div>
<div class="warehouse-carton-measurements">长 {{ carton.length ?? '—' }} cm · 宽 {{ carton.width ?? '—' }} cm · 高 {{ carton.height ?? '—' }} cm · 重量 {{ carton.weight ?? '—' }} kg · 体积 {{ [carton.length, carton.width, carton.height].every(value => value != null) ? (Number(carton.length) * Number(carton.width) * Number(carton.height) / 1000000).toFixed(6) : '—' }} m³</div>
<el-table :data="[{ skuCode: transferCartonItem(carton)?.skuCode || '—', productNameZh: transferCartonItem(carton)?.productNameZh || '—', quantity: carton.quantity ?? '—' }]" size="small" border>
<el-table-column prop="skuCode" label="SKU" min-width="220" />
<el-table-column prop="productNameZh" label="中文品名" min-width="180" />
<el-table-column prop="quantity" label="实际签收数量" width="130" />
</el-table>
<p v-if="carton.discrepancyReason" class="warehouse-carton-measurements">差异说明:{{ carton.discrepancyReason }}</p>
</el-collapse-item>
</el-collapse>
</div>
<template #footer><el-button @click="showTransferCartons = false">关闭</el-button></template>
</el-dialog>
</div>
<div v-if="props.service === 'transfer' && selectedDetail?.needRelabel" class="warehouse-card warehouse-form-section">
<h3>换标箱级去向</h3>
@@ -1758,6 +1823,7 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
</div>
</template>
</section>
<CompletionConfirmationDialog v-model="completionConfirmOpen" :kind="completionConfirmKind" :document-no="completionConfirmNo" @confirmed="refreshAfterCompletion" />
</template>
<style scoped>
@@ -1901,7 +1967,7 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
.warehouse-native-file{min-height:32px;padding:5px 8px;border:1px solid #dcdfe6;border-radius:4px;background:#fff;color:#606266}.warehouse-fulfillment-grid{margin-bottom:18px}.warehouse-evidence{display:flex;align-items:flex-start;gap:16px;padding:14px;border:1px solid #e7ebf1;border-radius:4px;background:#f8fafc}.warehouse-evidence>span{padding-top:6px;color:#71809a;font-size:12px;white-space:nowrap}.warehouse-evidence>strong{padding-top:6px;color:#8a95a5;font-size:12px;font-weight:400}.warehouse-photo-grid{display:grid;grid-template-columns:repeat(4,140px);gap:12px}.warehouse-photo-item{width:140px;height:105px;overflow:hidden;border:1px solid #dce3ed;border-radius:5px;background:#fff}.warehouse-photo-item :deep(.el-image){display:block;width:100%;height:100%;cursor:zoom-in}.warehouse-photo-placeholder{display:flex;width:100%;height:100%;align-items:center;justify-content:center;padding:10px;border:0;background:#f4f7fb;color:#8792a2;font-size:12px;text-align:center}.warehouse-photo-retry{cursor:pointer}.warehouse-completion-remark{margin:10px 0 0;color:#596579;font-size:12px}
.warehouse-measurement-evidence{margin-top:16px}
.warehouse-measurement-photo-grid{flex:1;grid-template-columns:repeat(auto-fill,minmax(240px,1fr))}
.warehouse-measurement-photo-item{width:100%;height:180px}
.warehouse-measurement-photo-item{width:100%;height:120px}
.warehouse-template-actions{flex-wrap:wrap;justify-content:flex-end}.warehouse-import-preview{margin:0 0 16px;padding:14px;border:1px solid #cfe0f5;border-radius:5px;background:#f8fbff}.warehouse-import-preview .warehouse-section-heading{margin-bottom:10px}.warehouse-import-preview .warehouse-section-heading>div{display:flex;align-items:center;gap:10px}.warehouse-import-preview p{margin:0;color:#7d8797;font-size:12px}.warehouse-binding-preview-table{min-width:980px;background:#fff}
.warehouse-transfer-relabel .warehouse-labeling-heading>div:first-child{min-width:0}.warehouse-transfer-relabel .warehouse-labeling-heading h3{margin-bottom:6px}.warehouse-transfer-relabel .warehouse-labeling-heading p{margin:0 0 16px;color:#7d8797;font-size:12px}.warehouse-transfer-relabel-fields{margin-bottom:18px}.warehouse-transfer-file-groups{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(360px,.85fr);align-items:stretch;gap:14px;margin-bottom:14px}.warehouse-transfer-total-files,.warehouse-transfer-template-group{height:100%;margin:0}.warehouse-transfer-mark-table{min-width:1420px}.warehouse-transfer-mark-table td{vertical-align:top}.warehouse-transfer-mark-table :deep(.el-textarea){min-width:220px}.warehouse-transfer-mark-table :deep(.el-input-number){width:150px}.warehouse-count-error{color:#d64545}
.warehouse-transfer-form{display:grid;grid-template-columns:minmax(0,1fr);gap:12px}.warehouse-transfer-form--relabel{grid-template-columns:minmax(0,1fr) minmax(0,1.35fr);align-items:start}.warehouse-transfer-form--relabel .warehouse-transfer-main-card{grid-column:1;grid-row:1}.warehouse-transfer-form--relabel .warehouse-transfer-cargo-card{grid-column:1;grid-row:2}.warehouse-transfer-form--relabel .warehouse-transfer-relabel{grid-column:2;grid-row:1 / span 2;margin-bottom:0}.warehouse-transfer-form--relabel .warehouse-transfer-submit-bar{grid-column:1 / -1}.warehouse-transfer-form :deep(.el-input__wrapper),.warehouse-transfer-form :deep(.el-textarea__inner),.warehouse-transfer-form :deep(.el-select__wrapper),.warehouse-transfer-form :deep(.el-date-editor.el-input__wrapper){min-height:32px}.warehouse-transfer-form .warehouse-form-section{padding:16px}.warehouse-transfer-form .warehouse-form-section h3{margin-bottom:12px;padding-bottom:9px;font-size:14px}.warehouse-transfer-form .warehouse-form-grid{gap:12px 16px}.warehouse-transfer-form .warehouse-choice-field{min-height:34px;padding:8px 10px;grid-template-columns:118px minmax(0,1fr);gap:10px}.warehouse-transfer-form .warehouse-choice-field>span{font-size:12px}.warehouse-transfer-form .warehouse-receiving-card{gap:12px;padding:10px 12px}.warehouse-transfer-form .warehouse-section-heading{margin-bottom:8px}.warehouse-transfer-form .warehouse-edit-table{font-size:11px}.warehouse-transfer-form .warehouse-edit-table th{height:36px;padding:0 9px}.warehouse-transfer-form .warehouse-edit-table td{height:42px;padding:6px 7px}.warehouse-transfer-form .warehouse-transfer-mark-table :deep(.el-textarea){min-width:150px}.warehouse-transfer-form .warehouse-transfer-mark-table :deep(.el-input-number){width:112px}.warehouse-transfer-form .warehouse-hint{margin-top:5px;font-size:11px}.warehouse-transfer-form .warehouse-labeling-mode{gap:9px;margin-bottom:10px;padding:9px 10px}.warehouse-transfer-form .warehouse-labeling-mode>span{font-size:12px}.warehouse-transfer-form .warehouse-labeling-mode :deep(.el-radio-button__inner){padding:6px 9px}.warehouse-transfer-form .warehouse-template-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:5px;padding-bottom:8px}.warehouse-transfer-form .warehouse-template-actions :deep(.el-button){padding:6px 8px;font-size:11px}.warehouse-transfer-form .warehouse-submit-bar{padding:4px 0 18px}.warehouse-transfer-form .warehouse-submit-bar :deep(.el-button){min-height:32px;padding:7px 14px}.warehouse-transfer-form .warehouse-transfer-relabel{overflow:hidden}.warehouse-transfer-form .warehouse-transfer-relabel-fields{margin-bottom:10px}.warehouse-step-heading{align-items:flex-start}.warehouse-step-heading h3{display:flex;align-items:center;gap:9px}.warehouse-step-number{display:inline-grid;flex:0 0 auto;width:24px;height:24px;border-radius:50%;background:#3478c8;color:#fff;font-size:12px;place-items:center}.warehouse-heading-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px}.warehouse-transfer-template-group{margin:0 0 14px;padding:12px 14px;border:1px solid #dde5f0;border-radius:7px;background:#fbfcfe}.warehouse-binding-heading{margin-top:14px;padding:12px 14px;border:1px solid #dde5f0;border-radius:7px;background:#f8faff}.warehouse-binding-heading strong{display:flex;align-items:center;gap:8px;color:#34445c;font-size:13px}
@@ -1914,6 +1980,7 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
.warehouse-service-page--detail .warehouse-transfer-overview-grid{grid-template-columns:repeat(4,minmax(180px,1fr));gap:6px 28px;padding:10px 16px 12px}.warehouse-service-page--detail .warehouse-transfer-overview-grid>div{min-height:26px;padding:2px 0;border:0}.warehouse-service-page--detail .warehouse-transfer-overview-grid span{flex:0 0 auto;min-width:76px}.warehouse-service-page--detail .warehouse-transfer-overview-grid :deep(.el-tag){height:24px}
.warehouse-service-page--detail>.warehouse-detail-header+.warehouse-card .warehouse-detail-grid{grid-template-columns:repeat(4,minmax(180px,1fr));gap:6px 28px;padding:10px 16px 12px}.warehouse-service-page--detail>.warehouse-detail-header+.warehouse-card .warehouse-detail-grid>div{min-height:26px;padding:2px 0;border:0}.warehouse-service-page--detail>.warehouse-detail-header+.warehouse-card .warehouse-detail-grid span{flex:0 0 auto;min-width:76px}
.warehouse-service-page--detail .warehouse-transfer-overview-grid>.warehouse-overview-wide{grid-column:span 2}
.warehouse-transfer-received-card{margin-top:14px}.warehouse-cartons-dialog-body{max-height:65vh;overflow:auto}.warehouse-carton-summary{margin-left:16px;color:var(--muted);font-size:12px}.warehouse-carton-measurements{margin:10px 0;color:var(--muted);font-size:13px;line-height:1.8}
@media(max-width:1100px){.warehouse-service-page--detail .warehouse-transfer-overview-grid{grid-template-columns:repeat(2,minmax(180px,1fr))}}@media(max-width:720px){.warehouse-service-page--detail .warehouse-transfer-overview-grid{grid-template-columns:1fr;gap:4px;padding:10px 14px}}
/* Keep the count and controls visible together on narrow list pages. */
.warehouse-pagination { justify-content: flex-end; }
@@ -1929,10 +1996,55 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
.warehouse-transfer-cargo-card .warehouse-section-heading, .warehouse-transfer-cargo-card .warehouse-table-scroll { max-width: none; }
.warehouse-transfer-form .warehouse-choice-field--relabel { grid-template-columns: 1fr; align-items: start; }
}
</style>
<style scoped>
.warehouse-discard-boxes { margin-bottom: 24px; }
.warehouse-discard-box-toolbar { display: flex; align-items: center; gap: 20px; margin-bottom: 16px; }
.warehouse-discard-box-toolbar .el-input { max-width: 340px; }
.warehouse-service-page--detail .warehouse-table-scroll .warehouse-transfer-comparison-table{width:1130px;min-width:1130px;table-layout:fixed}
.warehouse-transfer-comparison-table td{white-space:normal;overflow-wrap:anywhere}
.warehouse-service-page--detail .warehouse-transfer-comparison-table th{height:36px;padding:0 10px}
.warehouse-service-page--detail .warehouse-transfer-comparison-table td{height:38px;padding:7px 10px}
.warehouse-transfer-comparison-table th:nth-child(n+3),.warehouse-transfer-comparison-table td:nth-child(n+3){text-align:center}
.warehouse-transfer-comparison-table td:first-child{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.warehouse-service-page--detail .warehouse-transfer-cargo-card .warehouse-transfer-pallet-heading{margin-top:12px}
.warehouse-transfer-pallet-list{display:grid;grid-template-columns:minmax(0,1fr);gap:12px;padding:0 14px;margin-bottom:12px;align-items:start}
.warehouse-transfer-pallet-card{padding:14px;border:1px solid var(--client-border);border-radius:10px;background:#fff;min-width:0}
.warehouse-transfer-pallet-header{display:flex;align-items:center;flex-wrap:wrap;gap:10px 20px}
.warehouse-transfer-pallet-header>strong{font-size:13px;color:var(--navy);overflow-wrap:anywhere;line-height:1.6}
.warehouse-transfer-pallet-header strong>span{display:inline-block;margin-right:8px;padding:2px 7px;border-radius:4px;background:#eef1ff;color:var(--primary);font-size:12px;font-weight:500}
.warehouse-transfer-pallet-metrics{display:flex;flex-wrap:wrap;gap:8px;margin:0}
.warehouse-transfer-pallet-metrics>div{display:flex;align-items:baseline;gap:8px;padding:6px 10px;background:#f5f7fc;border:1px solid #e9edf5;border-radius:6px}
.warehouse-transfer-pallet-metrics dt{font-size:12px;color:var(--muted)}
.warehouse-transfer-pallet-metrics dd{margin:0;font-size:13px;font-weight:600;color:var(--navy)}
.warehouse-transfer-pallet-summary{cursor:pointer;list-style:none;border-radius:4px}
.warehouse-transfer-pallet-summary::-webkit-details-marker{display:none}
.warehouse-transfer-pallet-summary:focus-visible{outline:2px solid var(--primary);outline-offset:4px}
.warehouse-transfer-pallet-toggle{display:flex;align-items:center;gap:8px;flex-shrink:0;margin-left:6px}
.warehouse-transfer-pallet-count{font-size:12px;color:var(--muted)}
.warehouse-service-page--detail.warehouse-service-page--transfer>.warehouse-detail-header{grid-template-columns:auto minmax(0,max-content) auto;justify-content:start;column-gap:14px}
.warehouse-service-page--detail .warehouse-transfer-pallet-heading h3{flex:0 0 auto}
.warehouse-service-page--detail .warehouse-transfer-pallet-heading .warehouse-transfer-pallet-count{flex:0 0 auto}
.warehouse-service-page--detail .warehouse-transfer-cargo-card .warehouse-transfer-pallet-heading{display:flex;align-items:center;justify-content:flex-start;gap:10px}
.warehouse-transfer-pallet-toggle{color:var(--primary);font-size:12px;gap:4px}
.warehouse-transfer-pallet-group .when-expanded{display:none}
.warehouse-transfer-pallet-group[open] .when-expanded{display:inline}
.warehouse-transfer-pallet-group[open] .when-collapsed{display:none}
.warehouse-transfer-pallet-group[open] .warehouse-transfer-pallet-toggle svg{transform:rotate(180deg)}
.warehouse-transfer-pallet-meta{display:flex;flex-wrap:wrap;gap:4px 20px;margin-top:8px;color:var(--muted);font-size:12px;line-height:1.7;overflow-wrap:anywhere}
.warehouse-transfer-pallet-skus{display:grid;grid-template-columns:64px minmax(0,1fr);align-items:start;gap:10px;margin-top:10px;padding-top:10px;border-top:1px solid var(--client-border)}
.warehouse-transfer-pallet-skus>span{font-size:12px;color:var(--muted);padding-top:5px}
.warehouse-transfer-pallet-skus>div{display:flex;flex-wrap:wrap;gap:6px;min-width:0}
.warehouse-transfer-pallet-sku{display:inline-flex;align-items:center;gap:8px;max-width:100%;box-sizing:border-box;padding:4px 8px;background:#f5f7ff;border:1px solid #e5e9f8;border-radius:5px;font-size:12px;line-height:1.6}
.warehouse-transfer-pallet-sku strong{font-weight:500;overflow-wrap:anywhere;min-width:0;color:var(--navy)}
.warehouse-transfer-pallet-sku>span{flex-shrink:0;padding:0 7px;border:1px solid #e0e6ff;border-radius:4px;background:#eef1ff;color:var(--primary);font-weight:600;white-space:nowrap}
.warehouse-service-page--detail .warehouse-transfer-received-card .warehouse-transfer-receipt-heading{justify-content:flex-start;flex-wrap:wrap;gap:10px 16px}
.warehouse-service-page--detail .warehouse-transfer-receipt-heading h3{flex:0 0 auto;margin:0;padding:0;border:0}
.warehouse-transfer-receipt-actions{display:flex;flex-wrap:wrap;gap:8px}.warehouse-transfer-receipt-actions :deep(.el-button+.el-button){margin-left:0}
.warehouse-service-page--detail>.warehouse-detail-header+.warehouse-card .warehouse-transfer-overview-grid{width:100%;max-width:1440px;box-sizing:border-box;grid-template-columns:repeat(4,minmax(0,1fr));column-gap:18px;row-gap:4px;padding:8px 14px 10px}
.warehouse-service-page--detail>.warehouse-detail-header+.warehouse-card .warehouse-transfer-overview-grid>div{min-height:24px;gap:8px;padding:1px 0}
.warehouse-service-page--detail>.warehouse-detail-header+.warehouse-card .warehouse-transfer-overview-grid>div>span{flex:0 0 84px;min-width:0}
</style>
@@ -5,6 +5,26 @@ import ts from 'typescript'
const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8')
const source = view.slice(view.indexOf('function returnSourceNos'), view.indexOf('const relabelInboundOptions'))
const compiled = ts.transpileModule(source + '\nexports.sourceNos = returnSourceNos; exports.progress = returnRelabelProgress', { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText
test('carton validation ignores unselected stock after SKU relabel and consolidation', () => {
const start = view.indexOf('async function submit(')
const validation = view.slice(view.indexOf(' const invalidCargo =', start), view.indexOf(" if ((props.service === 'relabel'", start))
const compiledValidation = ts.transpileModule(validation, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
const validate = new Function('props', 'labelingMode', 'relabelRows', 'cartonDetailRows', 'cargoRows', 'skuRelabelRows', compiledValidation + '; return invalidCargo')
const stockRows = [
{ included: false, name: '', sku: 'OLD-SKU', quantity: 1 },
{ included: false, name: '', sku: 'OLD-SKU,NEW-SKU', quantity: 1 },
]
const selected = { included: true, name: 'Product', sku: 'OLD-SKU,NEW-SKU', quantity: 1 }
function invalid(details) {
return validate({ service: 'labeling' }, { value: 'carton' }, details,
{ value: details }, { value: [...stockRows, ...details] }, { value: [] })
}
assert.equal(invalid([selected]), false)
assert.equal(invalid([{ ...selected, name: '' }]), true)
assert.equal(invalid([]), true)
assert.equal(invalid([{ ...selected, sku: '' }]), false, 'SKU is optional for carton labels')
})
const exports = {}
new Function('exports', compiled)(exports)
test('合箱来源按单号集合精确匹配并去重,不能用子串匹配', () => {
@@ -44,7 +44,8 @@ test('仓储转运支持无目的地、FBA 仓和私人仓并显式选择是否
test('换标详情待客户确认时提供确认入口并刷新详情', () => {
assert.match(view, /application\.status === 'CUSTOMER_CONFIRM_PENDING'[\s\S]*@click="confirmSelectedRelabel">确认换标完成/)
assert.match(view, /async function confirmSelectedRelabel\(\)[\s\S]*await confirmRelabelCompletion\(relabelNo\)[\s\S]*await openDetail\(\{ '0': relabelNo \}\)/)
assert.match(view, /async function confirmSelectedRelabel\(\)[\s\S]*openCompletionConfirmation\(relabelNo, 'labeling'\)/)
assert.match(view, /async function refreshAfterCompletion\(no: string\)[\s\S]*await openDetail\(\{ '0': no \}\)/)
})
test('仓储转运换标绑定明细自动同步只读的旧 SKU', () => {
@@ -104,7 +105,7 @@ test('仓储转运不要求客户填写内部入仓号和我方箱唛,但换
test('仓储转运货物明细可按 SKU 和箱数打印客户预贴箱唛', () => {
assert.match(view, /printTransferDraftCartonMarks/)
assert.match(view, /请先填写商品 SKU 和对应的整数箱数/)
assert.match(view, /Array\.from\(\{ length: Number\(row\.quantity\) \}/)
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"/)
@@ -210,7 +211,7 @@ test('仓储转运列表展示换标单号、换标状态并为两类状态着
test('仓储转运详情订单概览使用详情接口命名字段回填', () => {
assert.match(view, /v-else-if="props\.service === 'transfer'" class="warehouse-detail-grid warehouse-transfer-overview-grid"/)
assert.doesNotMatch(view, /入库箱数/)
assert.match(view, /'转运单号', '箱数', '海外仓'/)
assert.match(view, /'转运单号', '预计箱数', '海外仓'/)
for (const field of ['transferNo', 'packageCount', 'operatingWarehouseId', 'deliveryMethod', 'needRelabel', 'relabelNo', 'status', 'createdAt']) {
assert.match(view, new RegExp(`selectedDetail\\?\\.${field}`))
}
@@ -258,7 +259,8 @@ test('贴标申请详情使用紧凑订单概览并中文展示换标状态', ()
test('仓储转运详情按接口具名字段展示订单概览', () => {
const overview = view.split('<h3>订单概览</h3>')[1]?.split('warehouse-transfer-cargo-card')[0] || ''
assert.match(overview, /v-if="props\.service === 'transfer'"/)
assert.match(overview, /selectedDetail\?\.transferNo/)
assert.doesNotMatch(overview, /<span>转运单号<\/span>/)
assert.match(view.split('<header class="warehouse-detail-header">')[1]?.split('</header>')[0] || '', /selectedDetail\?\.transferNo/)
assert.match(overview, /selectedDetail\?\.packageCount \?\? '—'/)
assert.match(overview, /warehouseDisplay\(selectedDetail\?\.operatingWarehouseId\)/)
assert.match(overview, /selectedDetail\?\.destinationWarehouseName \|\| warehouseDisplay\(selectedDetail\?\.destinationWarehouseCode \|\| selectedDetail\?\.fbaWarehouseCode\)/)
@@ -272,26 +274,26 @@ test('PDA 打托完成后客户可从列表或详情确认并查看实测数据'
assert.match(view, /confirmTransferFromList\(item\['0'\]\)/)
assert.match(view, /selectedDetail\?\.status === 'WAIT_CUSTOMER_CONFIRMATION'/)
assert.match(view, /PDA 打托实测数据/)
for (const label of ['托盘号', '库位', '箱数', '长 × 宽 × 高(cm)', '毛重(kg)', '体积(m³)', '完成时间']) assert.equal(view.includes(label), true)
for (const label of ['托盘号', '库位', '箱数', 'SKU 汇总', '毛重(kg)', '体积(m³)', '完成时间']) assert.equal(view.includes(label), true)
assert.match(view, /pallet\.warehouseLocationCode/)
assert.match(view, /pallet\.cartonCount/)
assert.match(view, /palletCartons\(pallet\)\.length/)
assert.match(view, /pallet\.grossWeightKg/)
assert.match(view, /pallet\.volumeCbm/)
assert.doesNotMatch(view, /pallet\.items/)
})
test('仓储转运详情按订单概览、PDA 实测数据、货物明细排序并放大测量照片', () => {
test('仓储转运详情按订单概览、货物预报、PDA 实测数据排序并放大测量照片', () => {
const detailStart = view.indexOf('<header class="warehouse-detail-header">')
const detailSource = view.slice(detailStart)
const overviewIndex = detailSource.indexOf('<h3>订单概览</h3>')
const measurementIndex = detailSource.indexOf('<h3>PDA 打托实测数据</h3>')
const cargoIndex = detailSource.indexOf('<h3>货物明细</h3>')
const cargoIndex = detailSource.indexOf('<h3>预报与实际签收</h3>')
assert.ok(overviewIndex >= 0 && overviewIndex < measurementIndex)
assert.ok(measurementIndex < cargoIndex)
assert.ok(cargoIndex < measurementIndex)
assert.match(view, /class="warehouse-photo-grid warehouse-measurement-photo-grid"/)
assert.match(view, /class="warehouse-photo-item warehouse-measurement-photo-item"/)
assert.match(view, /warehouse-measurement-photo-grid\{flex:1;grid-template-columns:repeat\(auto-fill,minmax\(240px,1fr\)\)\}/)
assert.match(view, /warehouse-measurement-photo-item\{width:100%;height:180px\}/)
assert.match(view, /warehouse-measurement-photo-item\{width:100%;height:120px\}/)
})
test('仓储转运详情自动加载 PDA 测量照片并释放 Blob URL', () => {
@@ -367,3 +369,45 @@ test('仓储转运基础货物不强制 SKU 和中文品名,只换箱唛不强
assert.match(api, /skuCode\?: string; productNameZh\?: string/)
assert.match(api, /originalSku\?: string/)
})
test('SKU 件数和预计箱数独立提交,签收展示实际清点箱数', () => {
assert.match(view, /quantity: item.quantity, packageCount: Number\(item.expectedPackageCount\)/)
assert.match(view, /sum \+ Number\(item.expectedPackageCount \|\| 0\)/)
assert.match(view, /v-model="row.expectedPackageCount"/)
assert.match(view, /Number.isSafeInteger\(row.quantity\)/)
assert.match(view, /Number.isSafeInteger\(row.expectedPackageCount\)/)
assert.match(view, /carton.status === 'COUNTED'/)
assert.match(view, /实际签收箱数/)
})
test('转运客户详情保留预报并显示实际签收箱内明细和托盘汇总', () => {
for (const label of ['预报数量(件)', '预报 SKU 种数', '签收箱号明细', '查看箱号明细', 'SKU 汇总', '毛重(kg)', '体积(m³)']) assert.ok(view.includes(label), label)
assert.match(view, /carton.status === 'COUNTED' && pallet.id != null/)
assert.match(view, /String\(carton.transferPalletId\) === String\(pallet.id\)/)
assert.match(view, /carton.transferItemId != null && String\(item.id\) === String\(carton.transferItemId\)/)
assert.match(view, /@click="showTransferCartons = true"/)
assert.match(view, /<el-dialog v-model="showTransferCartons"[\s\S]*?<el-collapse-item v-for="carton in countedTransferCartons\(\)"/)
assert.doesNotMatch(view, /warehouse-transfer-receipt-summary|expandedTransferCartons/)
const receiptSection = view.slice(view.indexOf('warehouse-transfer-received-card', view.indexOf('<header class="warehouse-detail-header">')), view.indexOf('v-if="props.service === \'transfer\' && selectedDetail?.needRelabel"'))
assert.doesNotMatch(receiptSection.split('<el-dialog')[0], /<table|<el-table|<el-collapse/)
assert.match(view, /暂无实际签收箱,等待 PDA 清点/)
assert.doesNotMatch(view, /<td>\{\{ \[pallet.lengthCm/)
})
test('预报实收同区对照、托盘卡片换行、签收按钮紧邻标题', () => {
const start = view.indexOf('class="warehouse-card warehouse-form-section warehouse-transfer-cargo-card"', view.indexOf('<header class="warehouse-detail-header">'))
const section = view.slice(start, view.indexOf('warehouse-transfer-received-card', start))
assert.ok(section.includes('预报与实际签收'))
assert.ok(section.includes('PDA 打托实测数据'))
assert.ok(section.includes('receivedItemQuantity(item.id)'))
assert.ok(section.includes('receivedItemCartons(item.id).length'))
assert.ok(section.includes('class="warehouse-transfer-pallet-card"'))
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(receipt.includes('查看箱号明细'))
assert.match(view, /warehouse-transfer-receipt-heading h3\{flex:0 0 auto/)
})
@@ -75,6 +75,7 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [
{ code: 'merchant:overseas-warehouse:transfer:confirm', label: '确认转运实测', description: '确认仓库回传的箱数、尺寸、重量和打托数据' },
{ code: 'merchant:overseas-warehouse:discard:list', label: '查看货物废弃', description: '查看本企业货物废弃申请列表和详情' },
{ code: 'merchant:overseas-warehouse:discard:create', label: '创建货物废弃', description: '使用本企业可用库存创建废弃申请' },
{ code: 'merchant:overseas-warehouse:discard:confirm', label: '确认废弃完成', description: '核对本企业废弃现场照片并确认完结' },
{ code: 'merchant:overseas-warehouse:discard:cancel', label: '取消货物废弃', description: '取消本企业尚未审核的废弃申请' },
{ code: 'merchant:overseas-warehouse:return-order:list', label: '查看退货预报', description: '查看本企业退货预报和仓库签收结果' },
{ code: 'merchant:overseas-warehouse:return-order:create', label: '创建退货预报', description: '创建本企业退货 SKU 和数量预报' },
@@ -157,6 +158,7 @@ export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly<Record<string, read
'merchant:overseas-warehouse:transfer:confirm': ['merchant:overseas-warehouse:transfer:confirm'],
'merchant:overseas-warehouse:discard:list': ['merchant:overseas-warehouse:discard:list'],
'merchant:overseas-warehouse:discard:create': ['merchant:overseas-warehouse:discard:create'],
'merchant:overseas-warehouse:discard:confirm': ['merchant:overseas-warehouse:discard:confirm'],
'merchant:overseas-warehouse:discard:cancel': ['merchant:overseas-warehouse:discard:cancel'],
'merchant:overseas-warehouse:return-order:list': ['merchant:overseas-warehouse:return-order:list'],
'merchant:overseas-warehouse:return-order:create': ['merchant:overseas-warehouse:return-order:create'],