This commit is contained in:
2026-10-14 11:43:38 +08:00
parent d28408f678
commit d67da9af82
7 changed files with 99 additions and 11 deletions
@@ -18,9 +18,10 @@ export interface ReturnReceipt {
}
export interface ReturnPallet { palletCode: string; unitType: 'PALLET' | 'BOX'; unitCode: string; skuTypes: number; totalQuantity: number; totalBoxCount: number; length?: number; width?: number; height?: number; weight?: number; volumeCbm?: number; items: Array<{ skuCode: string; productNameZh?: string; quantity: number; boxCount: number }> }
export interface ReturnPhoto { fileId: number; fileName: string; contentType: string; sizeBytes: number }
export interface ReturnPackingDetail { sourceCartonMark: string; targetCartonMark: string; returnNo: string; skuCode: string; quantity: number; createdAt: string }
export interface ReturnPackingDetail { planNo?: string; sourceCartonMark: string; targetCartonMark: string; returnNo: string; skuCode: string; quantity: number; createdAt: string }
export interface CurrentReturnCarton { carton: import('./returnInventory').ReturnInventoryCartonDetail; ownership: Array<{ returnNo: string; skuCode: string; quantity: number }>; orderQuantity: number; labelStatus: string; canRelabel: boolean; blockReason?: string }
export interface ReturnReceiptCarton { receiptNo: string; palletCode?: string; cartonMark: string; countStatus: 'PENDING' | 'CONFIRMED' | 'UNRECORDED'; countedAt?: string; items: ReturnReceipt['items']; length?: number | null; width?: number | null; height?: number | null; weight?: number | null }
export interface ReturnOrderDetail { order: ReturnOrderSummary; remark?: string; items: ReturnOrderItem[]; receipts: ReturnReceipt[]; pallets: ReturnPallet[]; photos: ReturnPhoto[]; packingDetails?: ReturnPackingDetail[]; cartons?: ReturnReceiptCarton[] }
export interface ReturnOrderDetail { order: ReturnOrderSummary; remark?: string; items: ReturnOrderItem[]; receipts: ReturnReceipt[]; pallets: ReturnPallet[]; photos: ReturnPhoto[]; packingDetails?: ReturnPackingDetail[]; cartons?: ReturnReceiptCarton[]; currentCartons?: CurrentReturnCarton[] }
export interface ReturnOrderCreate {
overseasWarehouseId: string; warehouseContactName: string; warehouseContactPhone: string; customerReferenceNo?: string; remark?: string; needRelabel: boolean; idempotencyKey: string
items: Array<{ skuCode: string; productNameZh?: string; expectedQuantity: number }>
@@ -133,3 +133,8 @@ 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 interface DiscardPhoto { fileId: string; palletNo: string; fileName: string; createdAt: string; submittedAt: string }
export function downloadDiscardPhoto(discardNo: string, fileId: string) {
return apiBlobRequest({ method: 'GET', url: `/merchant/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/photos/files/${encodeURIComponent(fileId)}/download` })
}
@@ -252,7 +252,9 @@ test('待审核退货可编辑,完成入库后从独立新增页发起贴标',
assert.match(view, /else await createReturnOrder\(payload\)/)
assert.match(view, /order\.status === 'COMPLETED' && order\.needRelabel === true/)
assert.match(view, /name: 'overseas-labeling-create'/)
assert.match(view, /query: \{ returnNo: order\.returnNo, warehouseId: String\(order\.overseasWarehouseId\) \}/)
assert.match(view, /query: \{ returnNo: order\.returnNo, warehouseId: String\(order\.overseasWarehouseId\)/)
assert.match(view, /cartonMark \? \{ cartonMark \}/)
assert.match(view, /openRelabel\(selected\.order, current\.carton\.cartonMark\)/)
assert.match(view, /v-for="application in row\.relabelApplications \|\| \[\]"/)
assert.match(view, /relabelStatusLabel\(application\.status\)/)
})
@@ -226,8 +226,8 @@ function openDetail(returnNo: string) { void router.push({ name: 'overseas-retur
function returnToList() { void router.push({ name: 'overseas-return-relabel' }) }
function cancelCreate() { resetForm(); void router.push({ name: 'overseas-return-relabel' }) }
function canApplyRelabel(order: { status?: unknown; needRelabel?: unknown; relabelNo?: unknown }) { return order.status === 'COMPLETED' && order.needRelabel === true && !order.relabelNo }
function openRelabel(order: Pick<ReturnOrderSummary, 'returnNo' | 'overseasWarehouseId'>) {
void router.push({ name: 'overseas-labeling-create', query: { returnNo: order.returnNo, warehouseId: String(order.overseasWarehouseId) } })
function openRelabel(order: Pick<ReturnOrderSummary, 'returnNo' | 'overseasWarehouseId'>, cartonMark?: string) {
void router.push({ name: 'overseas-labeling-create', query: { returnNo: order.returnNo, warehouseId: String(order.overseasWarehouseId), ...(cartonMark ? { cartonMark } : {}) } })
}
async function submit() {
const items = form.items.map(({ skuCode, productNameZh, expectedQuantity }) => ({ skuCode: skuCode.trim(), productNameZh: productNameZh.trim(), expectedQuantity }))
@@ -442,6 +442,32 @@ onBeforeUnmount(clearPhotoUrls)
<template #footer><el-button @click="showCartons = false">关闭</el-button></template>
</el-dialog>
</section>
<section class="return-detail-section">
<h3>合箱记录</h3>
<el-table :data="selected.packingDetails || []" border empty-text="暂无合箱记录">
<el-table-column prop="planNo" label="合箱计划" min-width="200" />
<el-table-column prop="sourceCartonMark" label="来源箱唛" min-width="210" />
<el-table-column prop="targetCartonMark" label="目标箱唛" min-width="210" />
<el-table-column prop="skuCode" label="SKU" min-width="180" />
<el-table-column prop="quantity" label="本单合入数量" width="130" />
</el-table>
<h3>当前关联实物箱</h3>
<el-collapse v-if="selected.currentCartons?.length">
<el-collapse-item v-for="current in selected.currentCartons" :key="current.carton.cartonMark" :name="current.carton.cartonMark">
<template #title>{{ current.carton.cartonMark }} · 整箱 {{ current.carton.totalQuantity }} 件 · 本单 {{ current.orderQuantity }} 件 · {{ current.carton.sourceReturnNo?.includes(',') ? '跨来源单合箱' : '单一来源' }}</template>
<p>来源退货单:{{ current.carton.sourceReturnNo }} · 我方箱唛:{{ current.labelStatus === 'CONFIRMED' ? '已确认贴标' : '待贴标确认' }}</p>
<el-table :data="current.ownership" border>
<el-table-column prop="returnNo" label="来源退货单" min-width="200" />
<el-table-column prop="skuCode" label="SKU" min-width="180" />
<el-table-column prop="quantity" label="来源贡献数量" width="130" />
</el-table>
<p>换箱唛以当前实物箱为单位;跨来源单同 SKU 的部分贴标须先现场分拣确认。</p>
<el-button v-if="canCreateRelabel" :disabled="!current.canRelabel" @click="openRelabel(selected.order, current.carton.cartonMark)">选择此箱贴标</el-button>
<p v-if="!current.canRelabel">{{ current.blockReason }}</p>
</el-collapse-item>
</el-collapse>
<el-empty v-else description="暂无当前关联实物箱" :image-size="64" />
</section>
<section v-if="selected.photos?.length" class="return-detail-section">
<h3>退货打托照片({{ selected.photos?.length || 0 }} 张)</h3>
<div v-if="selected.photos?.length" class="return-photo-grid">
@@ -11,7 +11,7 @@ import { returnRelabelCandidate } from '../utils/returnRelabelCandidates'
import {
createDiscard, createRelabel, createTransfer, getDiscard, getRelabel, getTransfer, resubmitRelabel,
getTransferCartonLabel, submitTransfer, updateTransfer,
confirmRelabelCompletion, confirmTransferMeasurement, downloadRelabelCompletionPhoto, downloadTransferMeasurementPhoto, uploadRelabelShippingLabel,
confirmRelabelCompletion, confirmTransferMeasurement, downloadRelabelCompletionPhoto, downloadTransferMeasurementPhoto, downloadDiscardPhoto, type DiscardPhoto, uploadRelabelShippingLabel,
uploadRelabelFile,
listDiscards, listRelabelInventoryCandidates, listRelabelHistoricalOrders, listRelabels, listTransfers, cancelDiscard,
type RelabelInventoryCandidate,
@@ -119,6 +119,7 @@ const editingTransferNo = ref('')
interface TransferPallet { id?: string | number; sequenceNo?: number; palletNo?: string; warehouseLocationCode?: string; cartonCount?: number; lengthCm?: number; widthCm?: number; heightCm?: number; grossWeightKg?: number; volumeCbm?: number; status?: string; completedAt?: string }
interface TransferMeasurementPhoto { fileId: string | number; fileName?: string; contentType?: string; sizeBytes?: number; uploadedAt?: string }
interface ServiceDetail extends Record<string, unknown> {
discardNo?: string; photos?: DiscardPhoto[]
transferNo?: string; status?: string; operatingWarehouseId?: string; fbaWarehouseCode?: string
destinationType?: 'NONE' | 'FBA' | 'PRIVATE'; destinationWarehouseCode?: string; destinationWarehouseName?: string
privateDestinationAddress?: string; privateDestinationPostalCode?: string; privateDestinationContactName?: string; privateDestinationContactPhone?: string
@@ -146,6 +147,21 @@ const measurementPhotoLoading = reactive<Record<string, boolean>>({})
const measurementPhotoErrors = reactive<Record<string, boolean>>({})
const measurementPhotoPreviewUrls = computed(() => (selectedDetail.value?.measurementPhotos || [])
.map((photo) => measurementPhotoUrls[photo.fileId]).filter(Boolean))
const discardPhotoUrls = reactive<Record<string, string>>({})
const discardPhotoLoading = reactive<Record<string, boolean>>({})
const discardPhotoErrors = reactive<Record<string, boolean>>({})
const discardPhotoPreviewUrls = computed(() => (selectedDetail.value?.photos || []).map(photo => discardPhotoUrls[photo.fileId]).filter(Boolean))
let discardPhotoGeneration = 0
function clearDiscardPhotos() { discardPhotoGeneration++; Object.values(discardPhotoUrls).forEach(URL.revokeObjectURL); for (const map of [discardPhotoUrls, discardPhotoLoading, discardPhotoErrors]) Object.keys(map).forEach(key => delete map[key]) }
async function loadDiscardPhoto(fileId: string) {
const current = selectedDetail.value; const generation = discardPhotoGeneration
if (!current?.discardNo || discardPhotoLoading[fileId]) return
discardPhotoLoading[fileId] = true; discardPhotoErrors[fileId] = false
try { const blob = await downloadDiscardPhoto(current.discardNo, fileId); if (generation !== discardPhotoGeneration || selectedDetail.value !== current || mode.value !== 'detail') return; if (discardPhotoUrls[fileId]) URL.revokeObjectURL(discardPhotoUrls[fileId]); discardPhotoUrls[fileId] = URL.createObjectURL(blob) }
catch { if (generation === discardPhotoGeneration) discardPhotoErrors[fileId] = true }
finally { if (generation === discardPhotoGeneration) discardPhotoLoading[fileId] = false }
}
async function loadDiscardPhotos() { clearDiscardPhotos(); await Promise.all((selectedDetail.value?.photos || []).map(photo => loadDiscardPhoto(photo.fileId))) }
const fbaWarehouses = ref<MerchantWarehouse[]>([])
const operationWarehouses = ref<MerchantWarehouse[]>([])
const fbaWarehousesLoading = ref(false)
@@ -202,7 +218,10 @@ const selectedTransferSourceOrderIds = ref<string[]>([])
const isReturnRelabelSource = computed(() => props.service === 'labeling' && selectedRelabelInboundOrderId.value.startsWith('RETURN_ORDER:'))
const selectedReturnOrderNo = computed(() => isReturnRelabelSource.value ? selectedRelabelInboundOrderId.value.slice('RETURN_ORDER:'.length) : '')
const currentRelabelCandidates = computed(() => relabelCandidates.value.map(item => returnRelabelCandidate(item, labelingMode.value)))
const selectedReturnCandidates = computed(() => currentRelabelCandidates.value.filter(item => Boolean(item.returnCartonId) && returnSourceNos(item).includes(selectedReturnOrderNo.value)))
const returnCartonQuery = ref(String(route.query.cartonMark || ''))
const selectedReturnCandidates = computed(() => currentRelabelCandidates.value.filter(item => Boolean(item.returnCartonId)
&& returnSourceNos(item).includes(selectedReturnOrderNo.value)
&& (!returnCartonQuery.value.trim() || (item.cartonMark || '').toUpperCase().includes(returnCartonQuery.value.trim().toUpperCase()))))
const availableReturnCandidates = computed(() => selectedReturnCandidates.value.filter(item => inventoryQuantity(item.availablePackages) > 0).map(item => ({ ...item, skuItems: (item.skuItems || []).filter(sku => inventoryQuantity(sku.quantity) > 0) })))
const returnBoxCandidates = computed(() => availableReturnCandidates.value.filter(item => item.sourceType === 'RETURN_CARTON'))
const returnLooseCandidates = computed(() => availableReturnCandidates.value.filter(item => item.sourceType === 'RETURN_PARCEL'))
@@ -221,7 +240,7 @@ function returnRelabelProgress(candidate: RelabelInventoryCandidate) {
}
function returnInventoryState(candidate: RelabelInventoryCandidate) {
const labels: Record<string, string> = { AVAILABLE: '可选库存', RELABEL_PENDING: '换标占用', PACKING_RESERVED: '合箱计划预留', OUTBOUND_PENDING: '出库占用', DISCARD_PENDING: '废弃申请占用', OUTBOUNDED: '已出库', MERGED: '已合箱' }
return [...(candidate.mergedIntoCartonMarks?.length ? ['历史部分转入 ' + candidate.mergedIntoCartonMarks.join('、') + '(不代表当前库存位置)'] : []), labels[candidate.inventoryStatus || ''] || (inventoryQuantity(candidate.availablePackages) > 0 ? '可选库存' : '当前不可选'), candidate.occupiedRelabelNo].filter(Boolean).join(' · ')
return [...(returnSourceNos(candidate).length > 1 ? ['跨来源单合箱,数量为整箱内容'] : []), ...(candidate.mergedIntoCartonMarks?.length ? ['历史部分转入 ' + candidate.mergedIntoCartonMarks.join('、') + '(不代表当前库存位置)'] : []), labels[candidate.inventoryStatus || ''] || (inventoryQuantity(candidate.availablePackages) > 0 ? '可选库存' : '当前不可选'), candidate.occupiedRelabelNo].filter(Boolean).join(' · ')
}
const relabelInboundOptions = computed(() => {
const grouped = new Map<string, { value: string; inboundOrderNo: string; inboundCode: string; availablePackages: number; quantityUnit: '箱' | '件'; stockQuantity: number | null; units: number; hasOutbound: boolean; reasons: string[] }>()
@@ -278,7 +297,7 @@ watch(() => [props.service, props.createPage, props.detailPage, detailNumber.val
else if (props.detailPage && detailNumber.value) void loadDetailPage()
else void loadPage()
})
watch(mode, (value) => { if (value !== 'detail') { clearCompletionPhotos(); clearMeasurementPhotos() } })
watch(mode, (value) => { if (value !== 'detail') { clearCompletionPhotos(); clearMeasurementPhotos(); clearDiscardPhotos() } })
watch(() => form.relabel, (value) => {
if (props.service === 'transfer' && value) {
labelingMode.value = 'carton_and_sku'
@@ -321,7 +340,7 @@ onMounted(() => {
else void loadPage()
void nextTick(refreshWarehouseMarkLabels)
})
onBeforeUnmount(() => { clearCompletionPhotos(); clearMeasurementPhotos() })
onBeforeUnmount(() => { clearCompletionPhotos(); clearMeasurementPhotos(); clearDiscardPhotos() })
function resetFilters() { Object.assign(filters, { number: '', inboundNo: '', warehouse: '', destination: '', status: '', start: '', end: '', reviewStart: '', reviewEnd: '', completedStart: '', completedEnd: '', measuredStart: '', measuredEnd: '', outboundStart: '', outboundEnd: '' }) }
function resetForm() {
orderInfoCollapsed.value = false
@@ -1186,6 +1205,7 @@ async function openDetail(value: Record<string, string>) {
mode.value = 'detail'
if (props.service === 'labeling') void loadCompletionPhotos()
if (props.service === 'transfer') void loadMeasurementPhotos()
if (props.service === 'disposal') void loadDiscardPhotos()
} catch (error) { selectedDetail.value = null; selectedRelabelDetail.value = null; detailError.value = error instanceof Error ? error.message : '详情加载失败'; mode.value = 'detail'; ElMessage.error(detailError.value) }
}
async function loadDetailPage() {
@@ -1587,6 +1607,8 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
<input ref="skuTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'sku')"><input ref="cartonTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'carton')"><input ref="mixedTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'mixed')"><input ref="cartonTotalCreateInput" class="warehouse-hidden-input" type="file" accept="application/pdf,.pdf" @change="selectCartonTotalFile"><input ref="skuTotalCreateInput" class="warehouse-hidden-input" type="file" accept="application/pdf,.pdf" @change="selectSkuTotalFile">
<p v-if="isReturnRelabelSource" class="warehouse-list-notice">仓库识别码用于定位实物,不代表商品已换标。SKU 换标完成后扣减旧 SKU、增加新 SKU,包裹/箱总件数不变;已换标件数不可重复申请,剩余未换标件数可继续选择。客户确认前新 SKU 保持占用。</p>
<div v-if="isReturnRelabelSource" class="warehouse-table-scroll">
<el-input v-model="returnCartonQuery" clearable placeholder="按当前箱唛筛选贴标箱子" />
<p class="warehouse-hint">来源退货单用于查找;贴标选择当前实物箱。跨来源单箱内数量为整箱内容,同 SKU 部分贴标请先现场分拣确认。</p>
<template v-if="returnLooseCandidates.length">
<div class="warehouse-section-heading"><h3>可用包裹</h3><el-button text type="primary" native-type="button" :aria-expanded="!availableSkusCollapsed" aria-controls="return-available-skus" @click="availableSkusCollapsed = !availableSkusCollapsed">{{ availableSkusCollapsed ? '展开明细' : '收起明细' }}</el-button></div>
<table v-show="!availableSkusCollapsed" id="return-available-skus" class="warehouse-edit-table"><thead><tr><th>包裹识别码</th><th>来源退货单</th><th>SKU</th><th>SKU 换标结果</th><th>剩余可换(件)</th><th>操作</th></tr></thead><tbody><template v-for="source in returnLooseCandidates" :key="source.returnCartonId"><tr v-for="sku in source.skuItems" :key="sku.skuCode"><td>{{ source.cartonMark }}</td><td>{{ source.inboundOrderNo }}</td><td>{{ sku.skuCode }}</td><td><div v-for="conversion in (source.skuConversions || []).filter(item => item.status !== 'REVERSED')" :key="`${conversion.relabelNo}-${conversion.oldSkuCode}-${conversion.newSkuCode}`">{{ conversion.oldSkuCode }} → {{ conversion.newSkuCode }}:已换 {{ conversion.quantity }} 件{{ conversion.status === 'APPLIED' ? '(待确认)' : '' }}</div><span v-if="!source.skuConversions?.some(item => item.status !== 'REVERSED')">尚未换标</span></td><td>{{ sku.quantity }}</td><td><el-button size="small" :disabled="inventoryQuantity(source.availablePackages) <= 0" @click="labelingMode === 'sku' ? addReturnSkuBinding(source, sku.skuCode) : addReturnBoxBinding(source)">添加贴标</el-button></td></tr></template><tr v-if="!returnLooseCandidates.length"><td colspan="6">暂无可用包裹库存</td></tr></tbody></table>
@@ -1659,6 +1681,9 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
<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-if="props.service === 'disposal'" class="warehouse-card warehouse-form-section">
<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>
@@ -0,0 +1,29 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
const source = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8')
function loader(downloadDiscardPhoto) {
const section = source.slice(source.indexOf('let discardPhotoGeneration = 0'), source.indexOf('const fbaWarehouses = ref'))
.replace('loadDiscardPhoto(fileId: string)', 'loadDiscardPhoto(fileId)')
const maps = { discardPhotoUrls: {}, discardPhotoLoading: {}, discardPhotoErrors: {} }
const selectedDetail = { value: { discardNo: 'CD/1', photos: [{ fileId: '101' }] } }
const mode = { value: 'detail' }; const revoked = []
const URL = { createObjectURL: () => 'blob:photo', revokeObjectURL: url => revoked.push(url) }
const functions = new Function('selectedDetail', 'mode', 'downloadDiscardPhoto', 'URL', ...Object.keys(maps), section + ';return { loadDiscardPhotos, loadDiscardPhoto, clearDiscardPhotos }')(selectedDetail, mode, downloadDiscardPhoto, URL, ...Object.values(maps))
return { ...functions, ...maps, selectedDetail, mode, revoked }
}
test('client detail loads submitted discard photos and frees preview URLs on close', async () => {
const calls = []; const state = loader(async (...args) => { calls.push(args); return {} })
await state.loadDiscardPhotos()
assert.deepEqual(calls, [['CD/1', '101']]); assert.equal(state.discardPhotoUrls['101'], 'blob:photo')
state.clearDiscardPhotos(); assert.deepEqual(state.revoked, ['blob:photo']); assert.deepEqual(state.discardPhotoUrls, {})
})
test('photo failures offer retry and delayed responses cannot populate a closed detail', async () => {
let reject = true
const state = loader(async () => { if (reject) throw new Error('offline'); return {} })
await state.loadDiscardPhotos(); assert.equal(state.discardPhotoErrors['101'], true)
reject = false; await state.loadDiscardPhoto('101'); assert.equal(state.discardPhotoErrors['101'], false)
let resolve; const slow = loader(() => new Promise(done => { resolve = done }))
const pending = slow.loadDiscardPhotos(); slow.mode.value = 'list'; slow.clearDiscardPhotos(); resolve({}); await pending
assert.deepEqual(slow.discardPhotoUrls, {}); assert.deepEqual(slow.discardPhotoLoading, {})
})
@@ -305,7 +305,7 @@ test('仓储转运详情自动加载 PDA 测量照片并释放 Blob URL', () =>
assert.match(view, /加载失败,点击重试/)
assert.match(view, /URL\.createObjectURL\(blob\)/)
assert.match(view, /URL\.revokeObjectURL\(url\)/)
assert.match(view, /onBeforeUnmount\(\(\) => \{ clearCompletionPhotos\(\); clearMeasurementPhotos\(\) \}\)/)
assert.match(view, /onBeforeUnmount\(\(\) => \{ clearCompletionPhotos\(\); clearMeasurementPhotos\(\); clearDiscardPhotos\(\) \}\)/)
})
test('PDA 完成后主单显示入库完成并保留客户确认测量入口', () => {