This commit is contained in:
2026-10-20 14:00:49 +08:00
parent 3879a47673
commit 5679b156ca
28 changed files with 803 additions and 515 deletions
@@ -10,11 +10,11 @@ import { overseasWarehousePermissions as permission } from '../permissions'
import type { CargoDiscardDetail, CargoDiscardSummary } from '../api/cargoDiscards'
import CargoDiscardsView from './CargoDiscardsView.vue'
const api = vi.hoisted(() => ({ customers: vi.fn(), list: vi.fn(), detail: vi.fn(), review: vi.fn(), photo: vi.fn() }))
const api = vi.hoisted(() => ({ customers: vi.fn(), list: vi.fn(), detail: vi.fn(), review: vi.fn(), photo: vi.fn(), statistics: vi.fn() }))
const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: api.customers }))
vi.mock('../api/cargoDiscards', () => ({ listCargoDiscards: api.list, getCargoDiscard: api.detail, reviewCargoDiscard: api.review, downloadCargoDiscardPhoto: api.photo }))
vi.mock('../api/cargoDiscards', () => ({ listCargoDiscards: api.list, getCargoDiscard: api.detail, reviewCargoDiscard: api.review, downloadCargoDiscardPhoto: api.photo, getCargoDiscardStatistics: api.statistics }))
enableAutoUnmount(afterEach)
afterEach(() => { ElMessageBox.close(); vi.unstubAllGlobals() })
@@ -25,6 +25,7 @@ beforeEach(() => {
vi.resetAllMocks(); can.mockReturnValue(true)
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
api.customers.mockResolvedValue({ records: [{ id: '9007199254740994', tenantName: '客户一' }] })
api.statistics.mockResolvedValue({ orderCount: 1, skuCount: 1, requestedPackages: 2, actualPackages: null, requestedPieces: null, actualPieces: null, palletCount: null })
api.list.mockResolvedValue({ records: [summary()], total: 1 })
api.detail.mockImplementation(async () => detail()); api.review.mockResolvedValue(detail())
api.photo.mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
@@ -8,7 +8,10 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { getApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { downloadCargoDiscardPhoto, listCargoDiscards, getCargoDiscard, reviewCargoDiscard, getCargoDiscardStatistics, type CargoDiscardStatistics, type CargoDiscardDetail, type CargoDiscardSummary } from '../api/cargoDiscards'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { overseasWarehousePermissions as permission } from '../permissions'
@@ -18,38 +21,94 @@ const statistics = ref<CargoDiscardStatistics | null>(null)
/** 数量事实缺失时明确提示,不用零代替未记录。 */
function quantity(value: number | null | undefined, unit: string) { return value == null ? '未记录 / 不适用' : `${value} ${unit}` }
const fields: QueryField[] = [{ key: 'customerId', type: 'select', label: '客户', placeholder: '请选择客户', options: [] }, { key: 'discardNo', type: 'input', label: '废弃单号', placeholder: '废弃单号', width: 220 }, { key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 180 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已审核', value: 'APPROVED' }, { label: '待客户确认', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] }]
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'discardNo', label: '废弃单号', minWidth: 180 }, { key: 'inboundCodes', label: '入仓码', minWidth: 180 }, { key: 'overseasWarehouseName', label: '仓库', minWidth: 140 }, { key: 'totalPackages', label: '申请箱数', width: 90 }, { key: 'totalFee', label: '废弃费用', width: 120, formatter: (row: CargoDiscardSummary) => `${row.currency || 'USD'} ${row.totalFee ?? 0}` }, { key: 'createdBy', label: '申请人', width: 110 }, { key: 'createdAt', label: '创建时间', minWidth: 170 }, { key: 'status', label: '状态', width: 150, slotName: 'status' }]
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'discardNo', label: '废弃单号', minWidth: 180 }, { key: 'inboundCodes', label: '入仓码', minWidth: 180 }, { key: 'overseasWarehouseName', label: '仓库', minWidth: 140 }, { key: 'requestedPackages', label: '申请箱数', width: 90 }, { key: 'totalFee', label: '废弃费用', width: 120, formatter: (row: CargoDiscardSummary) => `${row.currency || 'USD'} ${row.totalFee ?? 0}` }, { key: 'createdBy', label: '申请人', width: 110 }, { key: 'createdAt', label: '创建时间', minWidth: 170 }, { key: 'status', label: '状态', width: 150, slotName: 'status' }]
const statusLabels: Record<string, string> = { PENDING_REVIEW: '待审核', APPROVED: '已审核,待 PDA', PDA_COMPLETED: 'PDA 已完成,待客户确认', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
const photoUrls = reactive<Record<string, string>>({}); const photoLoading = reactive<Record<string, boolean>>({}); const photoErrors = reactive<Record<string, boolean>>({})
const customerError = ref<ApiErrorDetails | null>(null); const listError = ref<ApiErrorDetails | null>(null); const statisticsError = ref<ApiErrorDetails | null>(null)
const detailError = ref<ApiErrorDetails | null>(null); const reviewError = ref<ApiErrorDetails | null>(null)
const detailLoading = ref(false); const reviewing = ref(''); const detailTarget = ref<CargoDiscardSummary | null>(null)
const photoUrls = reactive<Record<string, string>>({}); const photoLoading = reactive<Record<string, boolean>>({}); const photoErrors = reactive<Record<string, ApiErrorDetails | null>>({})
const photoPreviewUrls = computed(() => (detail.value?.photos || []).map(photo => photoUrls[photo.fileId]).filter(Boolean))
let photoGeneration = 0
function clearPhotos() { photoGeneration++; Object.values(photoUrls).forEach(URL.revokeObjectURL); for (const map of [photoUrls, photoLoading, photoErrors]) Object.keys(map).forEach(key => delete map[key]) }
let photoGeneration = 0, detailGeneration = 0, loadGeneration = 0, disposed = false
function clearPhotos() { photoGeneration++; Object.values(photoUrls).forEach(url => URL.revokeObjectURL(url)); for (const map of [photoUrls, photoLoading, photoErrors]) Object.keys(map).forEach(key => delete map[key]) }
async function loadPhoto(fileId: string) {
const current = detail.value; const generation = photoGeneration
if (!current || photoLoading[fileId]) return
photoLoading[fileId] = true; photoErrors[fileId] = false
try { const blob = await downloadCargoDiscardPhoto(current.summary.discardNo, fileId, String(current.summary.customerId)); if (photoGeneration !== generation || detail.value !== current || !detailOpen.value) return; if (photoUrls[fileId]) URL.revokeObjectURL(photoUrls[fileId]); photoUrls[fileId] = URL.createObjectURL(blob) }
catch { if (generation === photoGeneration) photoErrors[fileId] = true }
if (!current.summary.customerId) { photoErrors[fileId] = { message: '照片缺少客户范围' }; return }
photoLoading[fileId] = true; photoErrors[fileId] = null
try { const blob = await downloadCargoDiscardPhoto(current.summary.discardNo, fileId, current.summary.customerId); if (disposed || photoGeneration !== generation || detail.value !== current || !detailOpen.value) return; if (photoUrls[fileId]) URL.revokeObjectURL(photoUrls[fileId]); photoUrls[fileId] = URL.createObjectURL(blob) }
catch (error) { if (!disposed && generation === photoGeneration) photoErrors[fileId] = getApiErrorDetails(error, '废弃照片加载失败') }
finally { if (generation === photoGeneration) photoLoading[fileId] = false }
}
watch(detailOpen, value => { if (!value) clearPhotos() })
onBeforeUnmount(clearPhotos)
watch(detailOpen, value => { if (!value) { detailGeneration++; clearPhotos() } })
onBeforeUnmount(() => { disposed = true; loadGeneration++; detailGeneration++; clearPhotos() })
function text(key: string) { return String(filters.value[key] || '').trim() || undefined }
/** 同一筛选读取分页与全量统计,失败时清除旧数据,避免陈旧合计。 */
let loadGeneration = 0
async function load(model: QueryModel = filters.value) { const generation = ++loadGeneration; Object.assign(filters.value, model); loading.value = true; try { const query = { page: page.value, size: pageSize.value, customerId: text('customerId'), discardNo: text('discardNo'), inboundCode: text('inboundCode'), status: text('status') }; const [result, summary] = await Promise.all([listCargoDiscards(query), getCargoDiscardStatistics(query)]); if (generation !== loadGeneration) return; rows.value = result.records; total.value = result.total; statistics.value = summary } catch (e) { if (generation !== loadGeneration) return; rows.value = []; total.value = 0; statistics.value = null; ElMessage.error(getApiErrorDetails(e, '废弃单列表加载失败').message) } finally { if (generation === loadGeneration) loading.value = false } }
async function openDetail(row: CargoDiscardSummary) { try { detail.value = await getCargoDiscard(row.discardNo, row.customerId || undefined); detailOpen.value = true; clearPhotos(); void Promise.all(detail.value.photos.map(photo => loadPhoto(photo.fileId))) } catch (e) { ElMessage.error(getApiErrorDetails(e, '废弃单详情加载失败').message) } }
async function audit(row: CargoDiscardSummary) { try { await ElMessageBox.confirm('审核通过后将推送 PDA 执行废弃,确认继续?', '审核确认', { confirmButtonText: '通过并推送 PDA', cancelButtonText: '取消' }); await reviewCargoDiscard(row.discardNo, 'APPROVE', undefined, row.customerId || undefined); ElMessage.success('已审核通过并推送 PDA'); await load() } catch (e) { if (e !== 'cancel') ElMessage.error(getApiErrorDetails(e, '审核失败').message) } }
onMounted(async () => { try { const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }); customers.value = result.records; const field = fields[0]; if (field.type === 'select') field.options = [{ label: '全部客户', value: '' }, ...customers.value.map(item => ({ label: item.tenantName, value: String(item.id) }))]; await load() } catch (e) { ElMessage.error(getApiErrorDetails(e, '客户列表加载失败').message) } })
/** 分页与全筛选统计独立保留诊断,过期响应不得覆盖新的查询。 */
async function load(model: QueryModel = filters.value) {
const generation = ++loadGeneration; Object.assign(filters.value, model); loading.value = true; listError.value = null; statisticsError.value = null; statistics.value = null
const query = { page: page.value, size: pageSize.value, customerId: text('customerId'), discardNo: text('discardNo'), inboundCode: text('inboundCode'), status: text('status') }
const [list, stat] = await Promise.allSettled([listCargoDiscards(query), getCargoDiscardStatistics(query)])
if (disposed || generation !== loadGeneration) return
if (list.status === 'fulfilled') { rows.value = list.value.records; total.value = list.value.total }
else { rows.value = []; total.value = 0; listError.value = getApiErrorDetails(list.reason, '废弃单列表加载失败') }
if (stat.status === 'fulfilled') statistics.value = stat.value
else statisticsError.value = getApiErrorDetails(stat.reason, '废弃统计加载失败')
loading.value = false
}
async function loadCustomers() {
customerError.value = null
try { const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }); if (disposed) return; customers.value = result.records; const field = fields[0]; if (field.type === 'select') field.options = [{ label: '全部客户', value: '' }, ...customers.value.map(item => ({ label: item.tenantName, value: String(item.id) }))] }
catch (error) { if (!disposed) customerError.value = getApiErrorDetails(error, '客户列表加载失败') }
}
async function readDetail() {
const target = detailTarget.value; if (!target) return
const generation = ++detailGeneration; detailLoading.value = true; detailError.value = null
try { const result = await getCargoDiscard(target.discardNo, target.customerId || undefined); if (disposed || generation !== detailGeneration || !detailOpen.value) return; detail.value = result; void Promise.all(result.photos.map(photo => loadPhoto(photo.fileId))) }
catch (error) { if (!disposed && generation === detailGeneration) detailError.value = getApiErrorDetails(error, '废弃单详情加载失败') }
finally { if (generation === detailGeneration) detailLoading.value = false }
}
async function openDetail(row: CargoDiscardSummary) { if (reviewing.value) return; clearPhotos(); detail.value = null; detailTarget.value = row; detailOpen.value = true; await readDetail() }
async function audit(row: CargoDiscardSummary) {
if (reviewing.value || row.status !== 'PENDING_REVIEW' || !access.can(permission.discardApprove)) return
reviewing.value = row.discardNo; reviewError.value = null
try { await ElMessageBox.confirm('确认审核废弃单 ' + row.discardNo + ' 并推送 PDA 执行废弃?', '审核确认', { confirmButtonText: '通过并推送 PDA', cancelButtonText: '取消', customClass: 'admin-density-confirm' }); if (!access.can(permission.discardApprove)) return; await reviewCargoDiscard(row.discardNo, 'APPROVE', undefined, row.customerId || undefined); ElMessage.success('已审核通过并推送 PDA'); await load() }
catch (error) { if (error !== 'cancel' && error !== 'close') reviewError.value = getApiErrorDetails(error, '审核失败') }
finally { reviewing.value = '' }
}
onMounted(() => { void loadCustomers(); void load() })
</script>
<template>
<AdminPage class="admin-density"><ListPageLayout aria-label="废弃单列表"><section><DataTable appearance="integrated" :stripe="false" :data="rows" :columns="columns" :loading="loading" :total="total" v-model:page="page" v-model:page-size="pageSize" preference-key="overseas-cargo-discards" row-key="id" empty-text="暂无废弃单" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-start><h1>废弃单</h1></template><template #filters><QueryToolbar ariaLabel="废弃单筛选" v-model="filters" :fields="fields" :loading="loading" preference-key="overseas-cargo-discards-query" @search="page = 1; load($event)" @reset="page = 1; load($event)" /></template><template #actions="{ row }"><el-button size="small" @click="openDetail(row as CargoDiscardSummary)">详情</el-button><el-button v-if="access.can(permission.discardApprove) && (row as CargoDiscardSummary).status === 'PENDING_REVIEW'" size="small" type="primary" @click="audit(row as CargoDiscardSummary)">审核并推送 PDA</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabels[String((row as CargoDiscardSummary).status)] || String((row as CargoDiscardSummary).status)" /></template><template #pagination-left><span v-if="statistics">全筛选:{{ statistics.orderCount }} 票 · {{ statistics.skuCount }} 种 SKU · 申请 {{ statistics.requestedPackages }} 箱 · 已确认实际 {{ quantity(statistics.actualPackages, '箱') }} · 商品 {{ quantity(statistics.requestedPieces, '个') }} · 托盘 {{ quantity(statistics.palletCount, '托盘') }}</span><span v-else>统计未加载</span></template></DataTable></section></ListPageLayout></AdminPage>
<AppDialog v-model="detailOpen" title="废弃单详情" :show-confirm="false" cancel-text="关闭" width="min(920px, calc(100vw - 28px))"><template v-if="detail"><el-descriptions :column="3" border><el-descriptions-item label="废弃单号">{{ detail.summary.discardNo }}</el-descriptions-item><el-descriptions-item label="出库单号">{{ detail.summary.outboundNo || '—' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabels[String(detail.summary.status)] }}</el-descriptions-item><el-descriptions-item label="仓库">{{ detail.summary.overseasWarehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="入仓码" :span="2">{{ detail.summary.inboundCodes || '—' }}</el-descriptions-item><el-descriptions-item label="备注">{{ detail.remark || '—' }}</el-descriptions-item></el-descriptions><h3>废弃明细</h3><el-table :data="detail.items" border><el-table-column prop="inboundCode" label="入仓码"/><el-table-column prop="skuCode" label="SKU"/><el-table-column prop="productName" label="品名"/><el-table-column label="申请数量" width="120"><template #default="{ row }">{{ row.discardPackages }} {{ row.quantityUnit || '单位未记录' }}</template></el-table-column><el-table-column label="实际废弃" width="150"><template #default="{ row }">{{ quantity(row.actualDiscardPackages, row.quantityUnit || '单位未记录') }}</template></el-table-column><el-table-column label="所选箱唛" min-width="180"><template #default="{ row }">{{ row.cartonMarks?.join('、') || '全部库存(历史记录无箱唛快照)' }}</template></el-table-column></el-table><h3>废弃现场照片</h3><div v-if="detail.photos.length" class="discard-photo-grid"><div v-for="photo in detail.photos" :key="photo.fileId" class="discard-photo-item"><el-image v-if="photoUrls[photo.fileId]" :src="photoUrls[photo.fileId]" :preview-src-list="photoPreviewUrls" :initial-index="photoPreviewUrls.indexOf(photoUrls[photo.fileId])" fit="cover" preview-teleported /><span v-else-if="photoLoading[photo.fileId]">照片加载中…</span><el-button v-else @click="loadPhoto(photo.fileId)">{{ photoErrors[photo.fileId] ? '加载失败,点击重试' : '加载照片' }}</el-button><small>托盘:{{ photo.palletNo }} · {{ photo.submittedAt }}</small></div></div><p v-else>暂无已提交的废弃现场照片</p><h3>状态流转</h3><el-timeline><el-timeline-item v-for="(item, index) in detail.histories" :key="index" :timestamp="item.createdAt || ''">{{ statusLabels[item.toStatus || ''] || item.toStatus }} {{ item.remark ? `— ${item.remark}` : '' }}</el-timeline-item></el-timeline></template></AppDialog>
<AdminPage class="admin-density"><ListPageLayout aria-label="废弃单列表"><section><LoadErrorState v-if="customerError" title="客户列表加载失败" :message="customerError.message" @retry="loadCustomers"><template #details><ApiErrorMeta :details="customerError" /></template></LoadErrorState><LoadErrorState v-if="listError" title="废弃单列表加载失败" :message="listError.message" @retry="load()"><template #details><ApiErrorMeta :details="listError" /></template></LoadErrorState><LoadErrorState v-if="statisticsError" title="废弃统计加载失败" :message="statisticsError.message" @retry="load()"><template #details><ApiErrorMeta :details="statisticsError" /></template></LoadErrorState><LoadErrorState v-if="reviewError" title="审核失败" :message="reviewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="reviewError" /></template></LoadErrorState><DataTable appearance="integrated" :stripe="false" :data="rows" :columns="columns" :loading="loading" :total="total" v-model:page="page" v-model:page-size="pageSize" preference-key="overseas-cargo-discards" row-key="id" empty-text="暂无废弃单" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-start><h1>废弃单</h1></template><template #filters><QueryToolbar ariaLabel="废弃单筛选" v-model="filters" :fields="fields" :loading="loading" preference-key="overseas-cargo-discards-query" @search="page = 1; load($event)" @reset="page = 1; load($event)" /></template><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="!!reviewing" @click="openDetail(row as CargoDiscardSummary)">详情</el-button><el-button class="density-action" v-if="access.can(permission.discardApprove) && (row as CargoDiscardSummary).status === 'PENDING_REVIEW'" size="small" type="primary" :disabled="!!reviewing" @click="audit(row as CargoDiscardSummary)">审核并推送 PDA</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabels[String((row as CargoDiscardSummary).status)] || (row as CargoDiscardSummary).status || '未记录'" /></template><template #pagination-left><span v-if="statistics">全筛选:{{ statistics.orderCount }} 票 · {{ statistics.skuCount }} 种 SKU · 申请 {{ statistics.requestedPackages }} 箱 · 已确认实际 {{ quantity(statistics.actualPackages, '箱') }} · 商品 {{ quantity(statistics.requestedPieces, '个') }} · 托盘 {{ quantity(statistics.palletCount, '托盘') }}</span><span v-else>统计未加载</span></template></DataTable></section></ListPageLayout></AdminPage>
<AppDialog v-model="detailOpen" class="admin-density-dialog" :title="'废弃单详情 · ' + (detailTarget?.discardNo || '')" :confirm-loading="detailLoading" :show-confirm="false" cancel-text="关闭" width="min(920px, calc(100vw - 28px))" fullscreenable>
<p v-if="detailLoading" role="status">正在读取废弃单…</p>
<LoadErrorState v-else-if="detailError" title="废弃单详情加载失败" :message="detailError.message" @retry="readDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<template v-else-if="detail">
<table class="density-table" aria-label="废弃单基础资料"><tbody>
<tr data-tone="summary"><th>废弃单号</th><td>{{ detail.summary.discardNo }}</td><th>出库单号</th><td>{{ detail.summary.outboundNo || '—' }}</td></tr>
<tr data-tone="source"><th>仓库</th><td>{{ detail.summary.overseasWarehouseName || '—' }}</td><th>入仓码</th><td>{{ detail.summary.inboundCodes || '—' }}</td></tr>
<tr data-tone="work"><th>状态</th><td>{{ statusLabels[detail.summary.status || ''] || detail.summary.status || '未记录' }}</td><th>备注</th><td>{{ detail.remark || '—' }}</td></tr>
</tbody></table>
<h3>废弃明细</h3><el-table :data="detail.items" border>
<el-table-column label="入仓码"><template #default="{ row }"><LineClampText :content="row.inboundCode || ''" /></template></el-table-column>
<el-table-column label="SKU"><template #default="{ row }"><LineClampText :content="row.skuCode || ''" /></template></el-table-column>
<el-table-column label="品名"><template #default="{ row }"><LineClampText :content="row.productName || ''" /></template></el-table-column>
<el-table-column label="申请数量" width="120"><template #default="{ row }">{{ quantity(row.discardPackages, row.quantityUnit || '单位未记录') }}</template></el-table-column>
<el-table-column label="实际废弃" width="150"><template #default="{ row }">{{ quantity(row.actualDiscardPackages, row.quantityUnit || '单位未记录') }}</template></el-table-column>
<el-table-column label="所选箱唛" min-width="180"><template #default="{ row }"><LineClampText :content="row.cartonMarks?.join('、') || ''" /></template></el-table-column>
</el-table>
<h3>废弃现场照片</h3><div v-if="detail.photos.length" class="discard-photo-grid"><div v-for="photo in detail.photos" :key="photo.fileId" class="discard-photo-item">
<el-image v-if="photoUrls[photo.fileId]" :src="photoUrls[photo.fileId]" :preview-src-list="photoPreviewUrls" :initial-index="photoPreviewUrls.indexOf(photoUrls[photo.fileId])" fit="cover" preview-teleported />
<span v-else-if="photoLoading[photo.fileId]">照片加载中…</span>
<LoadErrorState v-else-if="photoErrors[photo.fileId]" title="废弃照片加载失败" :message="photoErrors[photo.fileId]?.message" @retry="loadPhoto(photo.fileId)"><template #details><ApiErrorMeta :details="photoErrors[photo.fileId]!" /></template></LoadErrorState>
<el-button v-else @click="loadPhoto(photo.fileId)">加载照片</el-button><small>托盘:{{ photo.palletNo }} · {{ photo.submittedAt }}</small>
</div></div><p v-else>暂无已提交的废弃现场照片</p>
<h3>状态流转</h3><table v-if="detail.histories.length" class="density-table" aria-label="废弃状态流转"><tbody><tr v-for="(item, index) in detail.histories" :key="index"><td>{{ item.createdAt || '—' }}</td><td>{{ statusLabels[item.toStatus || ''] || item.toStatus || '未记录' }}</td><td>{{ item.remark || '—' }}</td></tr></tbody></table><p v-else>暂无状态流转记录</p>
</template>
</AppDialog>
</template>
<style scoped>
.discard-photo-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.discard-photo-item{display:flex;flex-direction:column;gap:6px}.discard-photo-item .el-image{width:100%;height:150px;border-radius:6px}.discard-photo-item small{color:var(--cc-text-secondary)}
.discard-photo-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.discard-photo-item{display:flex;flex-direction:column;gap:6px}.discard-photo-item .el-image{width:100%;height: 150px}.discard-photo-item small{color:var(--cc-text-secondary)}
</style>
@@ -2,7 +2,7 @@ import ElementPlus from 'element-plus'
import { createPinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getInventorySummary, listInventoryBalances, listInventoryLedger, listInventoryPositions } from '../api/inventory'
import { getInventoryPieceProjection, getInventorySummary, listInventoryBalances, listInventoryLedger, listInventoryPositions } from '../api/inventory'
import InventoryDetailView from './InventoryDetailView.vue'
import InventoryView from './InventoryView.vue'
@@ -13,6 +13,7 @@ vi.mock('../api/pallets', () => ({ listReceivingOverseasWarehouses: vi.fn() }))
vi.mock('../api/inventory', () => ({
listInventoryBalances: vi.fn(), getInventorySummary: vi.fn(),
listInventoryPositions: vi.fn(), listInventoryLedger: vi.fn(),
getInventoryPieceProjection: vi.fn(),
}))
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
@@ -45,6 +46,7 @@ describe('inventory customer-name snapshots', () => {
vi.mocked(listInventoryBalances).mockReset().mockResolvedValue({ records: [balance], page: 1, size: 10, total: 1, pages: 1 })
vi.mocked(getInventorySummary).mockReset().mockResolvedValue({ inboundOrderCount: 1, palletCount: 1, totalPackages: 10, availablePackages: 10, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, inTransitPackages: 0, receivingPackages: 0, procurementPackages: 0 })
vi.mocked(listInventoryPositions).mockReset().mockResolvedValue([])
vi.mocked(getInventoryPieceProjection).mockReset().mockResolvedValue({ availablePieceCount: null, knownAvailablePieceCount: 0, unknownCartonCount: 10, availableCartonCount: 10, availablePalletCount: 1, groups: [] })
vi.mocked(listInventoryLedger).mockReset().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
})
@@ -72,6 +74,24 @@ describe('inventory customer-name snapshots', () => {
expect(wrapper.text()).not.toContain(balance.id)
})
it('keeps the formal customer and carton list when piece projection fails', async () => {
vi.mocked(getInventoryPieceProjection).mockRejectedValueOnce(new Error('projection unavailable'))
const wrapper = mount(InventoryView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('华远贸易')
expect(wrapper.text()).toContain('可用件数及箱内SKU加载失败')
expect(wrapper.text()).not.toContain('库存数据加载失败')
})
it('renders every mixed-carton SKU and real piece count alongside carton totals', async () => {
vi.mocked(getInventoryPieceProjection).mockResolvedValueOnce({ availablePieceCount: '12', knownAvailablePieceCount: '12', unknownCartonCount: '0', availableCartonCount: '2', availablePalletCount: '1', groups: [{ customerId, warehouseId: '200', destinationCode: null, anchorSkuCode: 'SKU-1', availablePieceCount: '12', knownAvailablePieceCount: '12', unknownCartonCount: '0', availableCartonCount: '2', availablePalletCount: '1', skuContents: [{ skuCode: 'SKU-1', quantity: '5' }, { skuCode: 'SKU-SECOND', quantity: '7' }], cartons: [] }] })
const wrapper = mount(InventoryView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('华远贸易')
expect(wrapper.text()).toContain('SKU-1 × 5 件;SKU-SECOND × 7 件')
expect(wrapper.text()).toContain('可用 12 件 / 2 箱 / 1 托')
})
it('combines the available references and then shows an explicit empty business reference without exposing the inventory id', async () => {
vi.mocked(listInventoryBalances).mockResolvedValueOnce({ records: [{ ...balance, inboundOrderNo: '', skuCode: '' }], page: 1, size: 10, total: 1, pages: 1 })
routing.route.params = { inventoryId: balance.id }
@@ -12,7 +12,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getInventorySummary, listInventoryBalances, type InventoryDashboardDto } from '../api/inventory'
import { getInventorySummary, getInventoryPieceProjection, listInventoryBalances, type InventoryDashboardDto, type InventoryPieceProjection, type InventoryPieceGroup } from '../api/inventory'
import { ElMessage } from 'element-plus'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { toInventoryListItem } from '../inventoryPresentation'
@@ -25,6 +25,9 @@ const access = useAccessPreviewStore()
const warehouses = ref<ReceivingOverseasWarehouseDto[]>([])
const rows = ref<InventoryListItem[]>([])
const summary = ref<InventoryDashboardDto | null>(null)
const pieces = ref<InventoryPieceProjection | null>(null)
const rowPieces = ref<Record<string, InventoryPieceGroup | undefined>>({})
const pieceError = ref('')
const loading = ref(false)
const contextLoading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
@@ -50,7 +53,8 @@ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
{ key: 'owner', prop: 'owner', label: '客户', minWidth: 160 },
{ key: 'warehouse', prop: 'warehouse', label: '海外仓', minWidth: 170 },
{ key: 'sku', prop: 'sku', label: 'SKU编码', minWidth: 150 },
{ key: 'sku', prop: 'sku', label: '箱内SKU / 可用件数', minWidth: 220, slotName: 'skuContents', singleLine: false, className: 'inventory-sku-contents' },
{ key: 'availablePieces', label: '可用件数', minWidth: 130, slotName: 'availablePieces' },
{ key: 'productName', prop: 'productName', label: '中文品名', minWidth: 180 },
{ key: 'location', prop: 'location', label: '库位', minWidth: 140, defaultVisible: false },
{ key: 'onHand', prop: 'onHand', label: '在库总量', width: 105, align: 'right' },
@@ -75,9 +79,18 @@ function query() {
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
loading.value = true; loadError.value = null
try { const [pageResult, summaryResult] = await Promise.all([listInventoryBalances(query()), getInventorySummary(query())]); rows.value = pageResult.records.map(toInventoryListItem); total.value = pageResult.total; summary.value = summaryResult }
catch (error) { rows.value = []; total.value = 0; summary.value = null; loadError.value = getApiErrorDetails(error, '库存数据加载失败,请重试') }
loading.value = true; loadError.value = null; pieces.value = null; rowPieces.value = {}; pieceError.value = ''
try {
const currentQuery = query()
const [pageResult, summaryResult] = await Promise.all([listInventoryBalances(currentQuery), getInventorySummary(currentQuery)])
rows.value = pageResult.records.map(toInventoryListItem); total.value = pageResult.total; summary.value = summaryResult
try {
const pieceResult = await getInventoryPieceProjection(currentQuery)
rowPieces.value = Object.fromEntries(pageResult.records.map(dto => [String(dto.id), pieceResult.groups.find(group => String(group.customerId) === String(dto.customerId) && String(group.warehouseId) === String(dto.overseasWarehouseId) && (group.destinationCode ?? '') === (dto.destinationWarehouseCode ?? '') && (group.anchorSkuCode ?? '') === (dto.skuCode ?? ''))]))
pieces.value = pieceResult
} catch { pieceError.value = '可用件数及箱内SKU加载失败,请重新查询;箱数列表仍可查看。' }
}
catch (error) { rows.value = []; total.value = 0; summary.value = null; pieces.value = null; rowPieces.value = {}; loadError.value = getApiErrorDetails(error, '库存数据加载失败,请重试') }
finally { loading.value = false }
}
@@ -95,17 +108,20 @@ onMounted(() => { void loadWarehouses(); void load() })
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inventory" row-key="id" empty-text="暂无库存记录" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>库存管理</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading || contextLoading" ariaLabel="库存筛选" preference-key="overseas-inventory-query" @search="search" @reset="page = 1; rows = []; total = 0; summary = null" /></template>
<template #notices><template v-if="loadError"><LoadErrorState title="库存数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
<template #before-results><section class="inventory-metrics" aria-label="库存汇总"><div v-for="metric in metrics" :key="metric[0]" class="inventory-metric" :class="`is-${metric[2]}`"><span>{{ metric[0] }}</span><strong>{{ metric[1] }}</strong></div></section></template>
<template #notices><template v-if="loadError"><LoadErrorState title="库存数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template><p v-if="pieceError" role="alert">{{ pieceError }}</p></template>
<template #before-results><section class="inventory-metrics" aria-label="库存汇总"><div v-for="metric in metrics" :key="metric[0]" class="inventory-metric" :class="`is-${metric[2]}`"><span>{{ metric[0] }}(箱)</span><strong>{{ metric[1] }}</strong></div></section><p v-if="pieces" role="status" aria-label="可用库存件箱托汇总">全部筛选结果 · 可用 {{ pieces.availablePieceCount ?? `未知(已知 ${pieces.knownAvailablePieceCount} 件,${pieces.unknownCartonCount} 箱件数未记录)` }}{{ pieces.availablePieceCount == null ? '' : ' 件' }} / {{ pieces.availableCartonCount }} 箱 / {{ pieces.availablePalletCount }} 托</p></template>
<template #toolbar-actions><el-button v-if="access.can(overseasWarehousePermissions.inventoryExport) && access.can('platform:download:list')" plain :loading="exportLoading" @click="exportBalances"><Download :size="16" />导出库存</el-button><el-button @click="router.push({ name: 'overseas-inventory-transactions' })"><BookOpen :size="16" />进销存记录</el-button></template>
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button></div></template>
<template #skuContents="{ row }"><span v-if="rowPieces[row.id]?.skuContents.length">{{ rowPieces[row.id]!.skuContents.map(sku => `${sku.skuCode} × ${sku.quantity} 件`).join(';') }}<template v-if="Number(rowPieces[row.id]!.unknownCartonCount) > 0">;另有 {{ rowPieces[row.id]!.unknownCartonCount }} 箱内容未知</template></span><span v-else>记账SKU:{{ row.sku }};箱内件数未记录</span></template>
<template #availablePieces="{ row }">{{ rowPieces[row.id]?.availablePieceCount ?? (row.available === 0 ? 0 : '未记录') }}</template>
<template #pagination-left>共 {{ total }} 条库存记录</template>
</DataTable></section>
</ListPageLayout></AdminPage>
</template>
<style scoped>
.admin-density :deep(.inventory-sku-contents .cell) { white-space: normal; overflow-wrap: anywhere; }
.inventory-metrics { display: grid; grid-template-columns: repeat(6, minmax(130px, 1fr)); overflow: hidden; border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: #fff; }
.inventory-metric:last-child { border-right: 0; }
.inventory-metric span { color: #64748b; font-size: var(--admin-font-caption); }
@@ -119,6 +119,8 @@ const orderFields = computed<DetailField[]>(() => {
{ label: '备注(客户)', wide: true, value: text(order?.customerRemark) },
]
if (order?.orderType === 'WAREHOUSE_TRANSFER') fields.splice(6, 0,
{ label: '客户预报件数', value: order.warehouseTransferQuantity?.declaredPieceCount ?? '未记录' },
{ label: '历史实收件数', value: order.warehouseTransferQuantity?.receivedPieceCount ?? '未记录' },
{ label: '客户预报箱数', value: order.packageCount == null ? '—' : order.packageCount },
{ label: '实收箱数', value: order.receivedPackageCount == null ? '—' : order.receivedPackageCount },
{ label: '箱数差异', value: packageDifferenceText(order.packageDifference) },
@@ -19,6 +19,7 @@ import {
getContainerPlanProgress,
listContainerPlanProgress,
listLastMileOrders,
getReturnOrderStatistics,
reviewCargoDiscard,
transferLastMileOrders,
updateLastMileOrderPalletMerge,
@@ -35,6 +36,7 @@ const routerPush = vi.hoisted(() => vi.fn())
vi.mock('vue-router', () => ({ useRouter: () => ({ push: routerPush }) }))
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
vi.mock('../api/lastMileOrders', () => ({
getReturnOrderStatistics: vi.fn(),
listLastMileOrders: vi.fn(), listContainerPlanProgress: vi.fn(), getContainerPlanProgress: vi.fn(),
changeContainerReceivingWarehouse: vi.fn(),
createManualInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), getContainerInboundPlanPreview: vi.fn(), transferLastMileOrders: vi.fn(),
@@ -52,6 +54,10 @@ function memoryStorage(): Storage {
}
const bookingId = '2092873463568597001'
const emptyReturnStatistics = { totals: { orderCount: 0, skuTypes: 0, expectedQuantity: 0, receivedQuantity: 0, remainingQuantity: 0, receivedBoxCount: 0, receivedParcelCount: 0, receivedPalletCount: 0 }, pageDimensions: [] }
beforeEach(() => {
vi.mocked(getReturnOrderStatistics).mockReset().mockResolvedValue(emptyReturnStatistics)
})
const customerId = '2092618717861048321'
const summary = {
sourceContainerBookingId: bookingId, containerNo: 'CONT-15', totalOrderCount: 15,
@@ -188,6 +194,26 @@ async function chooseContainer(wrapper: Awaited<ReturnType<typeof mountView>>) {
}
describe('LastMileOrdersView pending order and container conversion', () => {
it('keeps server-wide return totals across pages and clears them on a statistics failure', async () => {
const totals = { orderCount: 3, skuTypes: 3, expectedQuantity: 19, receivedQuantity: 14, remainingQuantity: 5, receivedBoxCount: 3, receivedParcelCount: 2, receivedPalletCount: 1 }
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [], page: 1, size: 10, total: 30, pages: 3 })
vi.mocked(getReturnOrderStatistics).mockResolvedValue({ totals, pageDimensions: [] })
const wrapper = await mountView()
const summary = () => wrapper.find('[aria-label="退货全筛选汇总"]')
expect(summary().text()).toContain('预报 19 件')
expect(summary().text()).toContain('3 种 SKU')
const table = wrapper.findComponent(DataTable)
table.vm.$emit('update:page', 2)
await flushPromises()
expect(getReturnOrderStatistics).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 }))
expect(summary().text()).toContain('实收 14 件')
vi.mocked(getReturnOrderStatistics).mockRejectedValueOnce(new Error('统计暂不可用'))
table.vm.$emit('update:page', 3)
await flushPromises()
expect(summary().exists()).toBe(false)
expect(wrapper.text()).toContain('尾程订单加载失败')
expect(table.props('data')).toEqual([])
})
it('shows commercial source alongside fulfillment type and sends source filters to the server', async () => {
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: '1', customerId: 'A', orderNo: 'WT-1', orderType: 'WAREHOUSE_TRANSFER', businessSource: { businessType: 'LOCAL_LOOSEBOARD', label: '本土散板 · 拼车需求池', businessOrderNo: 'LB-1', externalBookingNo: 'FB-1' } }], page: 1, size: 10, total: 1, pages: 1 })
const wrapper = await mountView()
@@ -14,11 +14,13 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import TransferQuantitySummary from '../components/TransferQuantitySummary.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, reviewDirectRelabelOrder, reviewReturnOrder, transferLastMileOrders, updateLastMileOrderPalletMerge, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode, type LastMileOrderQuery } from '../api/lastMileOrders'
import { businessOrderLink } from '../businessOrderLink'
import { getReturnOrderStatistics, type ReturnOrderStatistics } from '../api/lastMileOrders'
import OrderReferenceText from '../components/OrderReferenceText.vue'
import { reviewCargoDiscard } from '../api/lastMileOrders'
import WarehouseTransferReviewDialog from '../components/WarehouseTransferReviewDialog.vue'
@@ -51,6 +53,11 @@ const transferReviewOpen = ref(false)
const transferReviewTarget = ref<LastMileOrderListItem | null>(null)
const selectedRows = ref<LastMileOrderListItem[]>([])
const selectedReturnRows = computed(() => selectedRows.value.filter((row) => row.orderTypeCode === 'RETURN_RELABEL' && ['INBOUND_COMPLETED', 'COMPLETED', 'RECEIVED', 'IN_WAREHOUSE', '已完成入库'].includes(String(row.currentStatusCode || row.status || ''))))
const returnStatistics = ref<ReturnOrderStatistics>()
let orderLoadRequest = 0
function returnDimension(row: { id?: unknown; orderTypeCode?: unknown }) {
return row.orderTypeCode === 'RETURN_RELABEL' ? returnStatistics.value?.pageDimensions.find(item => String(item.lastlegOrderId) === String(row.id)) : undefined
}
function openReturnPackingPlan() {
const selected = selectedReturnRows.value
if (!selected.length) { ElMessage.warning('请勾选已入库的退货订单'); return }
@@ -290,11 +297,17 @@ const columns: DataTableColumn[] = [
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 150 },
{ key: 'abnormal', prop: 'abnormal', label: '是否异常', width: 100 },
{ key: 'held', prop: 'held', label: '是否暂扣', width: 100 },
{ key: 'pallets', prop: 'pallets', label: '托数', width: 80, align: 'right' },
{ key: 'cartons', prop: 'cartons', label: '箱数', width: 80, align: 'right' },
{ key: 'pallets', prop: 'pallets', label: '托数(退货实收)', width: 130, align: 'right', slotName: 'receiptPallets' },
{ key: 'cartons', prop: 'cartons', label: '箱数(退货实收)', width: 130, align: 'right', slotName: 'receiptBoxes' },
{ key: 'returnParcels', label: '退货实收包裹', width: 120, align: 'right', slotName: 'returnParcels' },
{ key: 'returnExpected', label: '退货预报(件)', width: 120, align: 'right', slotName: 'returnExpected' },
{ key: 'returnReceived', label: '退货实收(件)', width: 120, align: 'right', slotName: 'returnReceived' },
{ key: 'returnRemaining', label: '退货待收(件)', width: 120, align: 'right', slotName: 'returnRemaining' },
{ key: 'cartonDifference', prop: 'cartonDifference', label: '差异(箱)', width: 100, align: 'right', slotName: 'cartonDifference' },
{ key: 'volume', prop: 'volume', label: '体积(m³)', width: 100, align: 'right' },
{ key: 'quantity', prop: 'quantity', label: '数量', width: 80, align: 'right' },
{ key: 'quantity', prop: 'quantity', label: '数量(非退货)', width: 110, align: 'right', slotName: 'nonReturnQuantity' },
{ key: 'declaredPieces', prop: 'declaredPieces', label: '转运预报件数', width: 120, align: 'right' },
{ key: 'receivedPieces', prop: 'receivedPieces', label: '转运实收件数', width: 120, align: 'right' },
{ key: 'volumeWeight', prop: 'volumeWeight', label: '体积重量', width: 100, align: 'right' },
{ key: 'portEta', prop: 'portEta', label: '预计到港时间', minWidth: 150 },
{ key: 'portAta', prop: 'portAta', label: '实际到港时间', minWidth: 150 },
@@ -391,6 +404,8 @@ function orderQuery(): LastMileOrderQuery {
}
}
async function load(model: QueryModel = filters.value) {
const request = ++orderLoadRequest
returnStatistics.value = undefined
Object.assign(filters.value, model)
loading.value = true
loadError.value = null
@@ -416,17 +431,21 @@ async function load(model: QueryModel = filters.value) {
return
}
try {
const result = await listLastMileOrders(orderQuery())
const query = orderQuery()
const [result, summary] = await Promise.all([listLastMileOrders(query), getReturnOrderStatistics(query)])
if (request !== orderLoadRequest) return
rows.value = result.records.map(toLastMileOrderListItem)
total.value = result.total
returnStatistics.value = summary
} catch (error) {
if (request !== orderLoadRequest) return
rows.value = []
total.value = 0
loadError.value = error instanceof Error && error.message === '当前环境未签发海外访问令牌'
? { message: error.message }
: getApiErrorDetails(error, '尾程订单加载失败,请重试')
} finally {
loading.value = false
if (request === orderLoadRequest) loading.value = false
}
}
@@ -1090,6 +1109,10 @@ onMounted(() => { void load() })
</el-button>
</template>
</QueryToolbar>
<TransferQuantitySummary v-if="!loading && statusTab !== '柜子'" :query="orderQuery()" />
<div v-if="returnStatistics && !loading" class="return-filter-summary" role="status" aria-label="退货全筛选汇总">
当前筛选中的退货:{{ returnStatistics.totals.orderCount }} 票 · {{ returnStatistics.totals.skuTypes }} 种 SKU · 预报 {{ returnStatistics.totals.expectedQuantity }} 件 · 实收 {{ returnStatistics.totals.receivedQuantity }} 件 · 待收 {{ returnStatistics.totals.remainingQuantity }} 件 · 签收 {{ returnStatistics.totals.receivedBoxCount }} 箱 / {{ returnStatistics.totals.receivedParcelCount }} 包裹 / {{ returnStatistics.totals.receivedPalletCount }} 托 · 预报载体数未提供
</div>
</section>
</template>
<template #notices><template v-if="loadError">
@@ -1142,7 +1165,14 @@ onMounted(() => { void load() })
<template #actions="{ row }"><RowActions text-actions :actions="lastMileRowActions(row as LastMileOrderListItem)" :more-label="`更多尾程订单操作 · ${(row as LastMileOrderListItem).orderNo || '当前记录'}`" @action="handleLastMileRowAction($event, row as LastMileOrderListItem)" /></template>
<template #businessOrderNo="{ row }"><el-button v-if="businessOrderLink(row.businessType, row.businessOrderNo, access.can)" link type="primary" @click="router.push(businessOrderLink(row.businessType, row.businessOrderNo, access.can)!)">{{ row.businessOrderNo }}</el-button><span v-else>{{ row.businessOrderNo }}</span></template>
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template>
<template #cartonDifference="{ row }"><span :class="{ 'package-difference--nonzero': typeof row.cartonDifference === 'number' && row.cartonDifference !== 0 }">{{ packageDifferenceText(row.cartonDifference) }}</span></template>
<template #cartonDifference="{ row }"><span :class="{ 'package-difference--nonzero': row.orderTypeCode !== 'RETURN_RELABEL' && typeof row.cartonDifference === 'number' && row.cartonDifference !== 0 }">{{ row.orderTypeCode === 'RETURN_RELABEL' ? '—' : packageDifferenceText(row.cartonDifference) }}</span></template>
<template #receiptPallets="{ row }">{{ row.orderTypeCode === 'RETURN_RELABEL' ? returnDimension(row)?.receivedPalletCount ?? '—' : row.pallets }}</template>
<template #receiptBoxes="{ row }">{{ row.orderTypeCode === 'RETURN_RELABEL' ? returnDimension(row)?.receivedBoxCount ?? '—' : row.cartons }}</template>
<template #returnParcels="{ row }">{{ returnDimension(row)?.receivedParcelCount ?? '—' }}</template>
<template #returnExpected="{ row }">{{ returnDimension(row)?.expectedQuantity ?? '—' }}</template>
<template #returnReceived="{ row }">{{ returnDimension(row)?.receivedQuantity ?? '—' }}</template>
<template #returnRemaining="{ row }">{{ returnDimension(row)?.remainingQuantity ?? '—' }}</template>
<template #nonReturnQuantity="{ row }">{{ row.orderTypeCode === 'RETURN_RELABEL' ? '—' : row.quantity }}</template>
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable>
</ListPageLayout></AdminPage>
@@ -1281,6 +1311,8 @@ onMounted(() => { void load() })
</template>
<style scoped>
.return-filter-summary { display: flex; flex-wrap: wrap; gap: var(--admin-space-3); padding: var(--admin-space-3) var(--admin-space-5); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); color: var(--cc-text); background: var(--cc-surface); font-size: var(--admin-font-body); }
.return-filter-summary > span { color: var(--cc-muted); }
.last-mile-dialog-facts, .last-mile-command-table, .manual-nodes-editor { table-layout: fixed; }
.last-mile-dialog-facts th { width: var(--admin-label-width); }
.last-mile-command-table td:not([colspan]) { width: 50%; }
@@ -1,25 +1,28 @@
import ElementPlus, { ElMessageBox } from 'element-plus'
import { DOMWrapper, enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import RelabelInventoryItemsEditor from '../components/RelabelInventoryItemsEditor.vue'
import RelabelOrdersView from './RelabelOrdersView.vue'
import RelabelOrderDetailView from './RelabelOrderDetailView.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { ApiResponseError } from '@/shared/api/http'
import type { RelabelDetailDto } from '../api/relabelOrders'
import { getRelabelQuantities, listRelabelInventoryCandidates, type RelabelDetailDto } from '../api/relabelOrders'
const api = vi.hoisted(() => ({ list: vi.fn(), detail: vi.fn(), create: vi.fn(), status: vi.fn(), customers: vi.fn(), warehouses: vi.fn(), pdf: vi.fn(), upload: vi.fn(), documents: vi.fn(), complete: vi.fn(), task: vi.fn(), rework: vi.fn() }))
const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'RL-1' } }), useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: api.customers }))
vi.mock('../api/relabelOrders', () => ({ listRelabelOrders: api.list, getRelabelOrder: api.detail, createRelabelOrder: api.create, changeRelabelOrderStatus: api.status, listRelabelWarehouseOptions: api.warehouses, downloadRelabelPdf: api.pdf, uploadRelabelPdf: api.upload, downloadRelabelDocument: api.documents, confirmRelabelCompletion: api.complete, getRelabelReviewTask: api.task, returnRelabelForRework: api.rework, downloadRelabelCompletionPhoto: vi.fn() }))
vi.mock('../api/relabelOrders', () => ({ getRelabelQuantities: vi.fn().mockResolvedValue({orderCount:1,skuCount:2,knownLabelPieceCount:15,unknownPieceOrders:0,knownCartonCount:1,unknownCartonOrders:0,orders:{'RL-1':{cartonCount:1,labelPieceCount:15,skuCount:2}}}), listRelabelInventoryCandidates: vi.fn().mockResolvedValue([]), listRelabelOrders: api.list, getRelabelOrder: api.detail, createRelabelOrder: api.create, changeRelabelOrderStatus: api.status, listRelabelWarehouseOptions: api.warehouses, downloadRelabelPdf: api.pdf, uploadRelabelPdf: api.upload, downloadRelabelDocument: api.documents, confirmRelabelCompletion: api.complete, getRelabelReviewTask: api.task, returnRelabelForRework: api.rework, downloadRelabelCompletionPhoto: vi.fn() }))
enableAutoUnmount(afterEach)
afterEach(() => { ElMessageBox.close(); vi.unstubAllGlobals() })
function detail(): RelabelDetailDto { return { application: { id: '1', relabelNo: 'RL-1', customerOrderNo: 'CO-1', status: 'PENDING_REVIEW', chargeAmount: 20, totalPackageCount: 1, totalFileId: '9007199254740993', totalFileName: '总换标.pdf' }, items: [{ id: '9007199254740994', skuCode: 'SKU-A', productNameZh: '商品甲', shipmentNo: 'SHIP-1', labelRequirement: '换SKU标', relabelFileId: '9007199254740995', relabelFileName: '明细换标.pdf', destinationFbaWarehouseCode: null, destinationWarehouseId: '9007199254740999', skuItems: [{ skuCode: 'SKU-A', newSkuCode: 'NEW-A', quantity: 10 }, { skuCode: 'SKU-B', newSkuCode: 'NEW-B', quantity: 5 }] }], history: [] } }
beforeEach(() => {
vi.resetAllMocks(); can.mockReturnValue(true)
vi.mocked(listRelabelInventoryCandidates).mockResolvedValue([])
vi.mocked(getRelabelQuantities).mockResolvedValue({orderCount:1,skuCount:2,knownLabelPieceCount:15,unknownPieceOrders:0,knownCartonCount:1,unknownCartonOrders:0,orders:{'RL-1':{cartonCount:1,labelPieceCount:15,skuCount:2,duplicateCarton:false,quantityUnit:'CARTON',sourcePalletCount:null,targetPalletCount:null}}})
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
api.list.mockResolvedValue({ records: [{ id: '1', relabelNo: 'RL-1', status: 'PENDING_REVIEW' }], total: 1 })
api.detail.mockImplementation(async () => detail()); api.create.mockImplementation(async () => detail()); api.status.mockResolvedValue(null)
@@ -32,9 +35,13 @@ beforeEach(() => {
function field(wrapper: Pick<VueWrapper, 'findAllComponents'>, label: string) { return wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
async function openForm(action = '新增换标单') { const wrapper = mount(RelabelOrdersView, { global: { plugins: [ElementPlus] } }); await flushPromises(); await wrapper.findAll('button').find(button => button.text() === action)!.trigger('click'); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
async function fill(dialog: VueWrapper) {
for (const [label, value] of [['关联订单', 'CO-1'], ['商品SKU', 'SKU-1'], ['中文品名', '商品一'], ['联系人', '联系人'], ['联系电话', '测试电话'], ['货件号', 'SHIP-1'], ['新SKU', 'NEW-1']]) await field(dialog, label!).get('input').setValue(value!)
for (const [label, value] of [['客户', 'C1'], ['公共海外收货仓', 'W1'], ['贴标要求', '换SKU标']]) field(dialog, label!).getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', value)
field(dialog, '箱数').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2)
for (const [label, value] of [['关联订单', 'CO-1'], ['联系人', '联系人'], ['联系电话', '测试电话'], ['货件号', 'SHIP-1']]) await field(dialog, label!).get('input').setValue(value!)
for (const [label, value] of [['客户', 'C1'], ['公共海外收货仓', 'W1']]) field(dialog, label!).getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', value)
field(dialog, '换标类型').getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'SKU_LABEL')
await flushPromises()
field(dialog, '贴标要求').get('input').setValue('换SKU标')
await flushPromises()
dialog.findComponent(RelabelInventoryItemsEditor).vm.$emit('update:items', [{ sourceInventoryBalanceId:'B1',sourceInventoryPositionId:'P1',sourceInboundOrderId:'I1',cartonMark:'BOX1',operationType:'SKU_LABEL',skuCode:'SKU-1',productNameZh:'商品一',totalPackageCount:1,notifiedPackageCount:1,shipmentNo:'SHIP-1',newSkuCode:'NEW-1',labelRequirement:'换SKU标',skuItems:[{skuCode:'SKU-1',newSkuCode:'NEW-1',quantity:8}]}])
field(dialog, '换标费用(CNY)').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 20)
field(dialog, '截止时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-01T10:00:00'); await flushPromises()
}
@@ -54,17 +61,17 @@ describe('native relabel operation dialogs', () => {
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect((field(dialog, '关联订单').get('input').element as HTMLInputElement).value).toBe('CO-1')
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.create).toHaveBeenCalledExactlyOnceWith('C1', { customerOrderNo: 'CO-1', operationWarehouseId: 'W1', labelDeadline: '2026-11-01T10:00:00', contactName: '联系人', contactPhone: '测试电话', chargeAmount: 20, remark: '本次备注', idempotencyKey: expect.any(String), items: [{ skuCode: 'SKU-1', productNameZh: '商品一', totalPackageCount: 2, notifiedPackageCount: 2, shipmentNo: 'SHIP-1', newSkuCode: 'NEW-1', labelRequirement: '换SKU标' }] })
expect(api.create).toHaveBeenCalledExactlyOnceWith('C1', expect.objectContaining({ customerOrderNo: 'CO-1', operationWarehouseId: 'W1', labelDeadline: '2026-11-01T10:00:00', contactName: '联系人', contactPhone: '测试电话', chargeAmount: 20, remark: '本次备注', idempotencyKey: expect.any(String), deliveryMethod:undefined, items:[expect.objectContaining({sourceInventoryPositionId:'P1',cartonMark:'BOX1',operationType:'SKU_LABEL',totalPackageCount:1,notifiedPackageCount:1,skuItems:[{skuCode:'SKU-1',newSkuCode:'NEW-1',quantity:8}]})] }))
})
it('prevents duplicate creation and retains inputs and the original creation key after failure', async () => {
let reject!: (cause: Error) => void
api.create.mockReturnValueOnce(new Promise((_done, fail) => { reject = fail }))
const { dialog } = await openForm(); await fill(dialog)
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.create).toHaveBeenCalledOnce(); expect(field(dialog, '商品SKU').get('input').attributes('disabled')).toBeDefined()
expect(api.create).toHaveBeenCalledOnce(); expect(field(dialog, '关联订单').get('input').attributes('disabled')).toBeDefined()
const key = api.create.mock.calls[0]![1].idempotencyKey
reject(new ApiResponseError('创建未确认', 'A0503', 503, null, 'relabel-create-request')); await flushPromises()
expect(dialog.text()).toContain('relabel-create-request'); expect((field(dialog, '商品SKU').get('input').element as HTMLInputElement).value).toBe('SKU-1')
expect(dialog.text()).toContain('relabel-create-request'); expect((field(dialog, '关联订单').get('input').element as HTMLInputElement).value).toBe('CO-1')
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.create.mock.calls[1]![1].idempotencyKey).toBe(key)
})
it('shows every original SKU as plain facts and requires a rejection reason', async () => {
@@ -109,12 +116,13 @@ describe('native relabel operation dialogs', () => {
let resolve!: (value: null) => void
api.complete.mockReturnValueOnce(new Promise(done => { resolve = done }))
const { wrapper } = await openDetail()
expect(api.task).toHaveBeenCalledExactlyOnceWith('RL-1')
await wrapper.findAll('button').find(button => button.text().includes('确认换标完成'))!.trigger('click'); await flushPromises()
const element = Array.from(document.querySelectorAll<HTMLElement>('.el-message-box')).at(-1)!
await new DOMWrapper(element).get('.el-message-box__btns .el-button--primary').trigger('click'); await flushPromises()
const rework = wrapper.findAll('button').find(button => button.text().includes('退回重新贴标'))!
expect(rework.attributes('disabled')).toBeDefined(); await rework.trigger('click'); await flushPromises()
expect(api.complete).toHaveBeenCalledOnce(); expect(api.task).not.toHaveBeenCalled()
expect(api.complete).toHaveBeenCalledOnce(); expect(api.task).toHaveBeenCalledExactlyOnceWith('RL-1'); expect(api.rework).not.toHaveBeenCalled()
resolve(null); await flushPromises()
})
it('keeps readonly file previews available while hiding upload and internal warehouse identifiers', async () => {
@@ -24,7 +24,6 @@ const orderId = computed(() => String(route.params.orderId || ''))
const detail = ref<RelabelDetailDto | null>(null)
const quantity = ref<RelabelQuantity | null>(null)
const currentPallets = ref<string[]>([])
const targetPallets = ref<string[]>([])
const sourceMissing = ref(false)
const quantityMessage = ref('')
const loading = ref(false)
@@ -116,8 +115,8 @@ const fieldValues = computed<Record<string, string | number>>(() => {
贴标件数: quantity.value?.labelPieceCount ?? '未提供/不适用',
SKU种数: quantity.value?.skuCount ?? '未提供',
当前关联托盘: currentPallets.value.length ? `${currentPallets.value.length} 托:${currentPallets.value.join('、')}${sourceMissing.value ? '(部分来源未提供)' : ''}` : '未提供',
本轮目标托盘: targetPallets.value.length ? `${targetPallets.value.length} 托:${targetPallets.value.join('、')}` : '未提供/不适用',
原托盘快照: '未提供(当前关联托盘不代表换标前快照)',
本轮目标托盘: quantity.value?.targetPalletCount == null ? '未提供/不适用' : `${quantity.value.targetPalletCount} 托(本轮各任务最新绑定)`,
原托盘快照: quantity.value?.sourcePalletCount == null ? '未提供(缺少可信快照)' : `${quantity.value.sourcePalletCount} 托(首次可信来源)`,
预算总价: '—',
贴标要求: items.map((item) => text(item.labelRequirement)).join(';') || '—',
上传文件: items.map((item) => text(item.relabelFileName)).filter((item) => item !== '—').join('、') || '—',
@@ -131,7 +130,7 @@ async function load() {
loading.value = true; loadError.value = null
try {
detail.value = await getRelabelOrder(orderId.value)
quantity.value = null; quantityMessage.value = ''; currentPallets.value = []; targetPallets.value = []; sourceMissing.value = false
quantity.value = null; quantityMessage.value = ''; currentPallets.value = []; sourceMissing.value = false
try {
const result = await getRelabelQuantities({ page: 1, size: 1, relabelNo: orderId.value })
quantity.value = result.orders[orderId.value] || null
@@ -139,12 +138,6 @@ async function load() {
const task = await getRelabelReviewTask(orderId.value)
currentPallets.value = [...new Set((task.items || []).map(item => item.sourcePalletCode).filter((value): value is string => Boolean(value)))]
sourceMissing.value = (task.items || []).some(item => !item.sourcePalletCode)
const history = task.history || []
const lastRework = history.map(item => item.action).lastIndexOf('REWORK_ARCHIVE')
const targets = history.slice(lastRework + 1).filter(item => item.action === 'PALLET_BIND').flatMap(item => {
try { const parsed: { targetPallets?: string[] } = JSON.parse(item.detail || '{}'); return parsed.targetPallets || [] } catch { return [] }
})
targetPallets.value = [...new Set(targets)]
} catch { quantityMessage.value = '部分数量或托盘资料加载失败,未将缺失值显示为0。' }
}
catch (error) { loadError.value = getApiErrorDetails(error, '换标单详情加载失败,请重试') }
@@ -11,6 +11,7 @@ import {
getRelabelQuantities,
listRelabelWarehouseOptions,
} from '../api/relabelOrders'
import RelabelInventoryItemsEditor from '../components/RelabelInventoryItemsEditor.vue'
import RelabelOrdersView from './RelabelOrdersView.vue'
const routerPush = vi.hoisted(() => vi.fn())
@@ -24,6 +25,7 @@ vi.mock('../api/relabelOrders', () => ({
listRelabelOrders: vi.fn(),
getRelabelQuantities: vi.fn(),
listRelabelWarehouseOptions: vi.fn(),
listRelabelInventoryCandidates: vi.fn().mockResolvedValue([]),
}))
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
@@ -126,18 +128,18 @@ describe('RelabelOrdersView', () => {
const warehouse = select(wrapper, '请选择公共海外收货仓')
warehouse.vm.$emit('update:modelValue', 'WH-1')
formItem(wrapper, '关联订单').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', 'CO-1')
formItem(wrapper, '商品SKU').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', 'SKU-1')
formItem(wrapper, '中文品名').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', '商品一')
formItem(wrapper, '箱数').findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2)
formItem(wrapper, '换标费用(CNY)').findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 20)
formItem(wrapper, '截止时间').findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-08-30T10:00:00')
formItem(wrapper, '联系人').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', '张三')
formItem(wrapper, '联系电话').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', '13800000000')
formItem(wrapper, '贴标要求').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '换SKU标')
formItem(wrapper, '贴标要求').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', '换SKU标')
formItem(wrapper, '货件号').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', 'SHIP-1')
formItem(wrapper, '新SKU').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', 'SKU-2')
await flushPromises()
formItem(wrapper, '换标类型').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'SKU_LABEL')
await flushPromises()
wrapper.findComponent(RelabelInventoryItemsEditor).vm.$emit('update:items', [{ sourceInventoryBalanceId: '9007199254740999', sourceInventoryPositionId: '9007199254740998', sourceInboundOrderId: '9007199254740997', skuCode: 'SKU-1', productNameZh: '商品一', cartonMark: 'BOX-1', operationType: 'SKU_LABEL', totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: 'SHIP-1', newSkuCode: 'SKU-2', labelRequirement: '换SKU标', skuItems: [{ skuCode: 'SKU-1', newSkuCode: 'SKU-2', quantity: 8 }] }])
await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(candidate => candidate.props('title') === '新增换标单')!
dialog.vm.$emit('confirm')
await flushPromises()
@@ -152,7 +154,7 @@ describe('RelabelOrdersView', () => {
expect(retryKey).toBe(firstKey)
expect(createRelabelOrder).toHaveBeenLastCalledWith('10001', expect.objectContaining({
customerOrderNo: 'CO-1', operationWarehouseId: 'WH-1', chargeAmount: 20, contactName: '张三', contactPhone: '13800000000', labelDeadline: '2026-08-30T10:00:00', remark: undefined, idempotencyKey: firstKey,
items: [expect.objectContaining({ skuCode: 'SKU-1', newSkuCode: 'SKU-2', labelRequirement: '换SKU标' })],
deliveryMethod: undefined, items: [expect.objectContaining({ sourceInventoryPositionId: '9007199254740998', cartonMark: 'BOX-1', totalPackageCount: 1, notifiedPackageCount: 1, skuItems: [{ skuCode: 'SKU-1', newSkuCode: 'SKU-2', quantity: 8 }] })],
}))
})
@@ -18,6 +18,8 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import { changeRelabelOrderStatus, createRelabelOrder, getRelabelOrder, listRelabelOrders, listRelabelWarehouseOptions, getRelabelQuantities, type RelabelQuantityTotals, type RelabelWarehouseOptionDto } from '../api/relabelOrders'
import { overseasWarehousePermissions as permission } from '../permissions'
import { formatOverseasDateTime, relabelBusinessStatusLabel } from '../lastMilePresentation'
import RelabelInventoryItemsEditor from '../components/RelabelInventoryItemsEditor.vue'
import type { CreateRelabelItemPayload } from '../api/relabelOrders'
import type { RelabelOrderListItem } from '../types'
const access = useAccessPreviewStore()
@@ -37,7 +39,7 @@ const total = ref(0)
const quantities = ref<RelabelQuantityTotals | null>(null)
const quantityError = ref('')
function dimension(value: number | string | null | undefined) { return value == null ? '未提供' : String(value) }
function knownDimension(value: number | string, unknown: number | string, unit: string) { return Number(value) === 0 && Number(unknown) > 0 ? `${unit === '箱' ? '箱数' : '贴标件数'}未提供(${unknown}票)` : `已知 ${value} ${unit}${Number(unknown) > 0 ? `(${unknown}票未提供)` : ''}` }
function knownDimension(value: number | string, unknown: number | string, unit: string) { return Number(value) === 0 && Number(unknown) > 0 ? `${unit === '箱' ? '箱数' : unit === '托' ? '托盘数' : '贴标件数'}未提供(${unknown}票)` : `已知 ${value} ${unit}${Number(unknown) > 0 ? `(${unknown}票未提供)` : ''}` }
const filters = ref<QueryModel>({ relabelNo: '', sku: '', status: '' })
const fields = computed<QueryField[]>(() => [
{ key: 'relabelNo', type: 'input', label: '换标单号', placeholder: '请输入换标单号', width: 220 },
@@ -68,6 +70,10 @@ const detailLoading = ref(false)
const editSourceStatus = ref('')
const auditLabelPieces = ref('未提供')
const form = reactive({ orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined as number | undefined, budgetTotal: undefined as number | undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined as number | undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
const createItems = ref<CreateRelabelItemPayload[]>([])
const createOperation = ref<CreateRelabelItemPayload['operationType']>('CARTON_LABEL')
const createDestination = ref('')
const createDelivery = ref<'TRUCK' | 'EXPRESS' | 'PICKUP' | 'AMAZON_PICKUP' | 'CUSTOMER_PICKUP'>('PICKUP')
const createIdempotencyKey = ref('')
const dialogError = ref<ApiErrorDetails | null>(null), customersError = ref<ApiErrorDetails | null>(null), warehousesError = ref<ApiErrorDetails | null>(null)
const formLocked = computed(() => submitting.value || detailLoading.value || dialogMode.value === 'create' && (customersLoading.value || warehousesLoading.value) || !access.can(dialogMode.value === 'create' ? permission.relabelOrderCreate : permission.relabelOrderStatus))
@@ -99,6 +105,7 @@ async function openForm(mode: 'create' | 'edit', source?: object) {
Object.assign(form, { orderNo: String(row.orderNo || ''), relatedOrderNo: String(row.relatedOrderNo || ''), sku: String(row.sku || ''), productName: String(row.productName || ''), cartons: optionalNumber(row.cartons), budgetTotal: optionalNumber(row.budgetTotal), status: '', labelRequirement: String(row.labelRequirement || ''), relabelCost: optionalNumber(row.relabelCost), remark: String(row.remark || '') })
}
if (mode === 'create') {
createItems.value = []; createOperation.value = 'CARTON_LABEL'; createDestination.value = ''; createDelivery.value = 'PICKUP'
createIdempotencyKey.value = globalThis.crypto?.randomUUID?.() || `admin-relabel-${Date.now()}`
warehouses.value = []
void loadCustomers()
@@ -167,12 +174,16 @@ async function submitForm() {
try {
if (dialogMode.value === 'create') {
if (!form.customerId || !form.warehouseId) { ElMessage.warning('请选择客户和公共海外收货仓'); return }
if (!form.relatedOrderNo.trim() || !form.sku.trim() || !form.productName.trim() || !form.cartons || !form.relabelCost || form.relabelCost <= 0 || !form.labelDeadline || !form.contactName.trim() || !form.contactPhone.trim() || !form.shipmentNo.trim() || !form.newSkuCode.trim() || !form.labelRequirement) {
if (!form.relatedOrderNo.trim() || !createItems.value.length || !form.relabelCost || form.relabelCost <= 0 || !form.labelDeadline || !form.contactName.trim() || !form.contactPhone.trim() || !form.shipmentNo.trim()) {
ElMessage.warning('请完整填写换标单必填信息')
return
}
const marks = createItems.value.map(item => item.cartonMark.trim())
if (marks.some(mark => !mark) || new Set(marks).size !== marks.length) { ElMessage.warning('请填写不重复的我方箱唛'); return }
if (createOperation.value !== 'SKU_LABEL' && (!createDestination.value.trim() || createItems.value.some(item => !item.fbaCartonMark?.trim()))) { ElMessage.warning('请填写 FBA 目的仓和每箱的新箱唛'); return }
if (createOperation.value !== 'CARTON_LABEL' && createItems.value.some(item => !item.skuItems.length || item.skuItems.some(sku => !sku.skuCode.trim() || !sku.newSkuCode.trim() || !Number.isInteger(sku.quantity) || sku.quantity <= 0))) { ElMessage.warning('请完整填写每箱 SKU 换标及件数'); return }
submitting.value = true
await createRelabelOrder(form.customerId, { customerOrderNo: form.relatedOrderNo.trim(), operationWarehouseId: form.warehouseId, labelDeadline: form.labelDeadline, contactName: form.contactName.trim(), contactPhone: form.contactPhone.trim(), chargeAmount: Number(form.relabelCost), remark: form.remark.trim() || undefined, idempotencyKey: createIdempotencyKey.value, items: [{ skuCode: form.sku.trim(), productNameZh: form.productName.trim(), totalPackageCount: Number(form.cartons), notifiedPackageCount: Number(form.cartons), shipmentNo: form.shipmentNo.trim(), newSkuCode: form.newSkuCode.trim(), labelRequirement: form.labelRequirement }] })
await createRelabelOrder(form.customerId, { customerOrderNo: form.relatedOrderNo.trim(), operationWarehouseId: form.warehouseId, labelDeadline: form.labelDeadline, contactName: form.contactName.trim(), contactPhone: form.contactPhone.trim(), chargeAmount: Number(form.relabelCost), remark: form.remark.trim() || undefined, idempotencyKey: createIdempotencyKey.value, deliveryMethod: createOperation.value === 'SKU_LABEL' ? undefined : createDelivery.value, printShippingLabel: false, items: createItems.value })
ElMessage.success('换标单已创建')
} else {
if (!allowedStatusOptions.value.includes(form.status)) { ElMessage.warning('当前状态没有可提交的审核流转'); return }
@@ -269,7 +280,7 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
<AdminPage class="admin-density"><ListPageLayout aria-label="换标单列表">
<section class="quantity-summary" aria-label="全部筛选结果数量汇总"><template v-if="quantities"><strong>全部筛选结果</strong><span>{{ quantities.orderCount }} 票</span><span>{{ quantities.skuCount }} 种 SKU</span><span>{{ knownDimension(quantities.knownLabelPieceCount, quantities.unknownPieceOrders, '件') }}</span><span>{{ knownDimension(quantities.knownCartonCount, quantities.unknownCartonOrders, '箱') }}</span><span>原托盘/目标托盘数:未提供</span></template><span v-else-if="quantityError" role="alert">{{ quantityError }}</span></section>
<section class="quantity-summary" aria-label="全部筛选结果数量汇总"><template v-if="quantities"><strong>全部筛选结果</strong><span>{{ quantities.orderCount }} 票</span><span>{{ quantities.skuCount }} 种 SKU</span><span>{{ knownDimension(quantities.knownLabelPieceCount, quantities.unknownPieceOrders, '件') }}</span><span>{{ knownDimension(quantities.knownCartonCount, quantities.unknownCartonOrders, '箱') }}</span><span>原托盘:{{ knownDimension(quantities.knownSourcePalletCount ?? 0, quantities.unknownSourcePalletOrders ?? quantities.orderCount, '托') }}</span><span>本轮目标托盘:{{ knownDimension(quantities.knownTargetPalletCount ?? 0, quantities.unknownTargetPalletOrders ?? quantities.orderCount, '托') }}</span></template><span v-else-if="quantityError" role="alert">{{ quantityError }}</span></section>
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-relabel-orders" row-key="orderNo" empty-text="暂无换标单" @selection-change="selectedRows = $event" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>换标单</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="换标单筛选" preference-key="overseas-relabel-orders-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
@@ -290,8 +301,13 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
<el-form class="density-form density-dialog-sections" label-position="left" label-width="120px" :disabled="formLocked">
<template v-if="dialogMode === 'create'">
<section class="density-dialog-section" data-tone="summary" aria-label="换标单生成方式"><table class="density-table"><tbody><tr><th scope="row">换标单号</th><td>系统自动生成</td></tr></tbody></table></section>
<section class="density-dialog-section" data-tone="work" aria-label="换标基础资料"><h3 class="density-dialog-section__title">订单信息</h3><table class="density-table density-table--editable relabel-editor-table"><tbody><tr><td><el-form-item label="关联订单" required><el-input v-model="form.relatedOrderNo" placeholder="请输入关联订单号" /></el-form-item></td><td><el-form-item label="客户" required><el-select v-model="form.customerId" filterable :loading="customersLoading" placeholder="请选择客户"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item></td></tr><tr><td><el-form-item label="公共海外收货仓" required><el-select v-model="form.warehouseId" filterable :loading="warehousesLoading" placeholder="请选择公共海外收货仓"><el-option v-for="item in warehouses" :key="item.id" :label="`${item.warehouseName} · ${item.warehouseCode}`" :value="String(item.id)" /></el-select></el-form-item></td><td><el-form-item label="截止时间" required><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择截止时间" /></el-form-item></td></tr><tr><td><el-form-item label="商品SKU" required><el-input v-model="form.sku" :autosize="{ minRows: 1, maxRows: 4 }" placeholder="请输入商品SKU" /></el-form-item></td><td><el-form-item label="中文品名" required><el-input v-model="form.productName" placeholder="请输入中文品名" /></el-form-item></td></tr><tr><td><el-form-item label="箱数" required><el-input-number v-model="form.cartons" :min="1" :precision="0" controls-position="right" placeholder="请输入箱数" /></el-form-item></td><td><el-form-item label="换标费用(CNY)" required><el-input-number v-model="form.relabelCost" :min="0.01" :precision="2" controls-position="right" placeholder="请输入换标费用" /></el-form-item></td></tr><tr><td><el-form-item label="联系人" required><el-input v-model="form.contactName" placeholder="请输入联系人" /></el-form-item></td><td><el-form-item label="联系电话" required><el-input v-model="form.contactPhone" placeholder="请输入联系电话" /></el-form-item></td></tr></tbody></table></section>
<section class="density-dialog-section" data-tone="work" aria-label="换标作业要求"><h3 class="density-dialog-section__title">作业要求</h3><table class="density-table density-table--editable relabel-editor-table"><tbody><tr><td><el-form-item label="贴标要求" required><el-select v-model="form.labelRequirement" placeholder="请选择贴标要求"><el-option v-for="value in ['换箱唛', '换SKU标', '换箱唛和SKU标']" :key="value" :label="value" :value="value" /></el-select></el-form-item></td><td><el-form-item label="货件号" required><el-input v-model="form.shipmentNo" placeholder="请输入货件号" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="新SKU" required><el-input v-model="form.newSkuCode" :autosize="{ minRows: 1, maxRows: 4 }" placeholder="请输入新SKU" /></el-form-item></td></tr></tbody></table></section>
<section class="density-dialog-section" data-tone="work" aria-label="换标基础资料"><h3 class="density-dialog-section__title">订单信息</h3><table class="density-table density-table--editable relabel-editor-table"><tbody><tr><td><el-form-item label="关联订单" required><el-input v-model="form.relatedOrderNo" placeholder="请输入关联订单号" /></el-form-item></td><td><el-form-item label="客户" required><el-select v-model="form.customerId" filterable :loading="customersLoading" placeholder="请选择客户"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item></td></tr><tr><td><el-form-item label="公共海外收货仓" required><el-select v-model="form.warehouseId" filterable :loading="warehousesLoading" placeholder="请选择公共海外收货仓"><el-option v-for="item in warehouses" :key="item.id" :label="`${item.warehouseName} · ${item.warehouseCode}`" :value="String(item.id)" /></el-select></el-form-item></td><td><el-form-item label="截止时间" required><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择截止时间" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="换标费用(CNY)" required><el-input-number v-model="form.relabelCost" :min="0.01" :precision="2" controls-position="right" placeholder="请输入换标费用" /></el-form-item></td></tr><tr><td><el-form-item label="联系人" required><el-input v-model="form.contactName" placeholder="请输入联系人" /></el-form-item></td><td><el-form-item label="联系电话" required><el-input v-model="form.contactPhone" placeholder="请输入联系电话" /></el-form-item></td></tr></tbody></table></section>
<section class="density-dialog-section" data-tone="work" aria-label="换标作业要求"><h3 class="density-dialog-section__title">作业要求</h3><table class="density-table density-table--editable relabel-editor-table"><tbody><tr><td><el-form-item label="贴标要求" required><el-input v-model="form.labelRequirement" placeholder="请输入贴标要求" /></el-form-item></td><td><el-form-item label="货件号" required><el-input v-model="form.shipmentNo" placeholder="请输入货件号" /></el-form-item></td></tr></tbody></table></section>
<section class="density-dialog-section" data-tone="work" aria-label="换标类型与去向">
<el-form-item label="换标类型" required><el-select v-model="createOperation" placeholder="请选择换标类型"><el-option label="只换箱唛" value="CARTON_LABEL" /><el-option label="只换 SKU" value="SKU_LABEL" /><el-option label="箱唛和 SKU 都换" value="CARTON_AND_SKU" /></el-select></el-form-item>
<template v-if="createOperation !== 'SKU_LABEL'"><el-form-item label="FBA 目的仓" required><el-input v-model="createDestination" placeholder="填写正式 FBA 仓码" /></el-form-item><el-form-item label="配送方式" required><el-select v-model="createDelivery" placeholder="请选择配送方式"><el-option label="普通自提" value="PICKUP" /><el-option label="平台卡车" value="TRUCK" /><el-option label="快递" value="EXPRESS" /><el-option label="亚马逊自提" value="AMAZON_PICKUP" /><el-option label="客户自提" value="CUSTOMER_PICKUP" /></el-select></el-form-item></template>
</section>
<RelabelInventoryItemsEditor :customer-id="form.customerId" :warehouse-id="form.warehouseId" :operation-type="createOperation" :shipment-no="form.shipmentNo" :destination="createDestination" :requirement="form.labelRequirement" @update:items="createItems = $event" />
</template>
<template v-else>
<section class="density-dialog-section" data-tone="source" aria-label="换标审核原始资料"><h3 class="density-dialog-section__title">原始资料</h3><table class="density-table"><tbody>
@@ -310,20 +326,9 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
</template>
<style scoped>
.quantity-summary { display: flex; flex-wrap: wrap; gap: 8px 18px; padding: 12px 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--cc-bg-card, white); margin-bottom: 12px; font-size: 13px; }
h3 { margin: 0 0 var(--admin-space-5); color: var(--cc-text); font-size: var(--admin-font-section); }
h3:not(:first-child) { margin-top: var(--admin-space-1); padding-top: var(--admin-space-5); border-top: 1px solid var(--cc-border); }
.quantity-summary { display: flex; flex-wrap: wrap; gap: 8px 18px; padding: 12px 16px; border: 1px solid var(--cc-border); border-radius: 8px; margin-bottom: 12px; }
.el-form :deep(.el-row) { margin-left: 0 !important; margin-right: 0 !important; }
.el-form :deep(.el-input), .el-form :deep(.el-select), .el-form :deep(.el-input-number), .el-form :deep(.el-date-editor) { width: 100%; }
.packing-plan-dialog { display: grid; gap: 14px; }
.packing-plan-summary { display: flex; gap: 24px; color: var(--cc-text-secondary); font-size: var(--admin-font-body); }
.packing-mode { margin-bottom: 2px; }
.packing-box { border: 1px solid var(--cc-border); border-radius: 6px; padding: 14px; display: grid; gap: 10px; }
.packing-box-head, .packing-item, .packing-dimensions { display: flex; align-items: center; gap: 10px; }
.packing-box-head { justify-content: space-between; }
.packing-item .el-input { flex: 1; }
.packing-item .el-input-number { width: 160px; }
.packing-dimensions .el-input-number { width: 150px; }
.relabel-editor-table { table-layout: fixed; }
.relabel-editor-table td:not([colspan]) { width: 50%; }
@media (max-width: 720px) { .relabel-editor-table, .relabel-editor-table tbody { display: block; }.relabel-editor-table tr { display: grid; grid-template-columns: 1fr; }.relabel-editor-table td:not([colspan]) { width: auto; } }
@@ -68,8 +68,10 @@ describe('ReturnInventoryManagementView shared list', () => {
expect(inventory.text()).toContain('-5')
vi.mocked(getReturnCarton).mockResolvedValue({ ...carton, totalQuantity: 5, items: [{ skuCode: 'SKU-TEST', quantity: 5 }], discardHistory: [{ discardNo: 'CD-TEST-1', skuCode: 'SKU-TEST', beforeQuantity: 10, quantity: 5, afterQuantity: 5, occurredAt: '2026-10-19T17:00:00' }] })
const cartons = await mountView('cartons')
await cartons.findAll('button').find(button => button.text() === '详情')!.trigger('click')
const cartonRow = cartons.findAll('.el-table__body tr').filter(row => row.text().includes('RETURN-BOX-1')).at(-1)!
await cartonRow.findAll('button').find(button => button.text() === '详情')!.trigger('click')
await flushPromises()
expect(getReturnCarton).toHaveBeenCalledWith('RETURN-BOX-1')
expect(document.body.textContent).toContain('废弃扣减明细')
expect(document.body.textContent).toContain('扣减前(件)')
expect(document.body.textContent).toContain('扣减后(件)')
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { returnRelabelStatusLabel } from "../utils/returnRelabelProgress";
import "@/styles/adminDensity.css";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import dayjs from "dayjs";
import { useRoute } from "vue-router";
import { Search, Plus } from "@lucide/vue";
@@ -16,6 +16,12 @@ import DataTable, {
} from "@/shared/components/DataTable.vue";
import AdminPage from "@/shared/components/AdminPage.vue";
import AppDialog from "@/shared/components/AppDialog.vue";
import ApiErrorMeta from "@/shared/components/ApiErrorMeta.vue";
import LoadErrorState from "@/shared/components/LoadErrorState.vue";
import LineClampText from "@/shared/components/LineClampText.vue";
import { getApiErrorDetails, type ApiErrorDetails } from "@/shared/api/http";
import { useAccessPreviewStore } from "@/core/access/accessPreview";
import { overseasWarehousePermissions as permission } from "../permissions";
import {
getReturnCarton,
getReturnCartonLabel,
@@ -38,6 +44,25 @@ import {
} from "../api/relabelOrders";
const props = defineProps<{ mode: "inventory" | "cartons" }>();
const route = useRoute();
const access = useAccessPreviewStore();
const canManagePacking = computed(() => access.can(permission.returnInventoryList) && access.can("lastleg:inventory:manage"));
const canCreatePacking = computed(() => canManagePacking.value && access.can(permission.returnCartonsList));
const listError = ref<ApiErrorDetails | null>(null);
const customerError = ref<ApiErrorDetails | null>(null);
const warehouseError = ref<ApiErrorDetails | null>(null);
const actionError = ref<ApiErrorDetails | null>(null);
const packingSourceError = ref<ApiErrorDetails | null>(null);
const packingSubmitError = ref<ApiErrorDetails | null>(null);
const cartonError = ref<ApiErrorDetails | null>(null);
const cartonLoading = ref(false);
const selectedCartonMark = ref("");
const printingMark = ref("");
const planActionNo = ref("");
const packingRequestKey = ref("");
let listLoadSequence = 0;
let cartonLoadSequence = 0;
function newPackingRequestKey() { return globalThis.crypto?.randomUUID?.() || `return-packing-${Date.now()}`; }
const loading = ref(false),
total = ref(0),
detailOpen = ref(false);
@@ -115,15 +140,19 @@ function params() {
};
}
async function load() {
const sequence = ++listLoadSequence;
listError.value = null;
loading.value = true;
try {
if (props.mode === "inventory") {
if (inventoryTab.value === "stock") {
const page = await listReturnStock(params());
if (sequence !== listLoadSequence) return;
stockRows.value = page.records;
total.value = page.total;
} else if (inventoryTab.value === "movements") {
const page = await listReturnInventoryMovements(params());
if (sequence !== listLoadSequence) return;
movementRows.value = page.records;
total.value = page.total;
} else {
@@ -135,17 +164,24 @@ async function load() {
...(query.overseasWarehouseId ? { overseasWarehouseId: query.overseasWarehouseId } : {}),
...(query.skuCode.trim() ? { skuCode: query.skuCode.trim() } : {}),
});
if (sequence !== listLoadSequence) return;
planRows.value = page.records;
planTotal.value = page.total;
total.value = page.total;
}
} else {
const page = await listReturnCartons(params());
cartonRows.value = page.records;
if (sequence !== listLoadSequence) return;
cartonRows.value = page.records;
total.value = page.total;
}
} catch (error) {
if (sequence !== listLoadSequence) return;
stockRows.value = []; movementRows.value = []; planRows.value = []; cartonRows.value = [];
total.value = 0; planTotal.value = 0;
listError.value = getApiErrorDetails(error, "退货数据加载失败,请重试");
} finally {
loading.value = false;
if (sequence === listLoadSequence) loading.value = false;
}
}
function search() {
@@ -157,21 +193,25 @@ function changeInventoryTab() {
void load();
}
async function reviewPlan(plan: Record<string, unknown>) {
const no = String(plan.planNo || "");
if (!no || plan.status !== "PENDING_REVIEW" || !canManagePacking.value || planActionNo.value || packingSaving.value) return;
planActionNo.value = no; actionError.value = null;
try {
await reviewReturnPackingPlan(String(plan.planNo));
ElMessage.success("合箱计划审核通过,已推送 PDA");
await load();
} catch (error) {
ElMessage.error(
error instanceof Error ? error.message : "合箱计划审核失败",
);
}
await reviewReturnPackingPlan(no);
ElMessage.success("合箱计划审核通过,已推送 PDA"); await load();
} catch (error) { actionError.value = getApiErrorDetails(error, "合箱计划审核失败"); }
finally { planActionNo.value = ""; }
}
async function cancelPlan(plan: Record<string, unknown>) {
const no = String(plan.planNo || "");
if (!no || !["PENDING_REVIEW", "READY"].includes(String(plan.status)) || !canManagePacking.value || planActionNo.value || packingSaving.value) return;
planActionNo.value = no; actionError.value = null;
try {
await ElMessageBox.confirm('取消后释放本计划锁定的来源库存,是否继续?', '取消合箱计划', { type: 'warning', customClass: 'admin-density-confirm' });
await cancelReturnPackingPlan(String(plan.planNo)); ElMessage.success('计划已取消,库存已释放'); await load();
} catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '取消失败'); }
await ElMessageBox.confirm(`确认取消合箱计划“${no}”?取消后释放本计划锁定的来源库存,是否继续?`, "取消合箱计划", { type: "warning", customClass: "admin-density-confirm" });
if (!canManagePacking.value) return;
await cancelReturnPackingPlan(no); ElMessage.success("计划已取消,库存已释放"); await load();
} catch (error) { if (error !== "cancel" && error !== "close") actionError.value = getApiErrorDetails(error, "取消合箱计划失败"); }
finally { planActionNo.value = ""; }
}
function openPlanDetail(row: Record<string, unknown>) {
planDetail.value = planRows.value.find(plan => plan.planNo === row.planNo);
@@ -231,44 +271,35 @@ async function loadCustomerOptions() {
}
}
async function loadOptions() {
optionsLoading.value = true;
if (optionsLoading.value) return;
optionsLoading.value = true; customerError.value = null; warehouseError.value = null;
try {
const [customerResult, warehouseResult] = await Promise.allSettled([
loadCustomerOptions(),
listSharedReceivingOverseasWarehouseOptions(),
]);
if (customerResult.status === "fulfilled")
customers.value = customerResult.value;
else customers.value = [];
if (warehouseResult.status === "fulfilled")
warehouses.value = warehouseResult.value;
else warehouses.value = [];
if (
customerResult.status === "rejected" ||
warehouseResult.status === "rejected"
) {
const failed = customerResult.status === "rejected" ? "客户" : "海外仓";
ElMessage.warning(`${failed}筛选数据加载失败,请检查服务或数据库迁移`);
}
} catch (error) {
ElMessage.warning(
error instanceof Error
? `筛选名称加载失败:${error.message}`
: "筛选名称加载失败",
);
} finally {
optionsLoading.value = false;
}
const [customerResult, warehouseResult] = await Promise.allSettled([loadCustomerOptions(), listSharedReceivingOverseasWarehouseOptions()]);
if (customerResult.status === "fulfilled") customers.value = customerResult.value;
else { customers.value = []; customerError.value = getApiErrorDetails(customerResult.reason, "客户候选加载失败"); }
if (warehouseResult.status === "fulfilled") warehouses.value = warehouseResult.value;
else { warehouses.value = []; warehouseError.value = getApiErrorDetails(warehouseResult.reason, "海外仓候选加载失败"); }
} finally { optionsLoading.value = false; }
}
async function openCarton(mark: string) {
if (!mark || !access.can(permission.returnCartonsList) || detailOpen.value || cartonLoading.value || packingSaving.value || planActionNo.value) return;
selectedCartonMark.value = mark; detail.value = undefined; cartonError.value = null; detailOpen.value = true;
await loadCarton();
}
async function loadCarton() {
const mark = selectedCartonMark.value;
if (!mark || !detailOpen.value || cartonLoading.value || !access.can(permission.returnCartonsList)) return;
const sequence = ++cartonLoadSequence;
cartonLoading.value = true; cartonError.value = null; detail.value = undefined;
try {
detail.value = await getReturnCarton(mark);
detailOpen.value = true;
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : "箱号详情加载失败");
}
const result = await getReturnCarton(mark);
if (sequence === cartonLoadSequence && detailOpen.value) detail.value = result;
} catch (error) { if (sequence === cartonLoadSequence && detailOpen.value) cartonError.value = getApiErrorDetails(error, "箱号详情加载失败"); }
finally { if (sequence === cartonLoadSequence) cartonLoading.value = false; }
}
async function printLabel(mark: string) {
if (!access.can(permission.returnCartonsList) || printingMark.value) return;
printingMark.value = mark; actionError.value = null;
try {
const label = await getReturnCartonLabel(mark);
const popup = window.open("", "_blank");
@@ -280,10 +311,12 @@ async function printLabel(mark: string) {
popup.focus();
popup.print();
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : "箱唛加载失败");
}
actionError.value = getApiErrorDetails(e, "箱唛加载失败");
} finally { printingMark.value = ""; }
}
function openPackingPlan() {
if (!canCreatePacking.value || packingSaving.value || packingDialogOpen.value || planActionNo.value) return;
packingRequestKey.value = newPackingRequestKey(); packingSubmitError.value = null;
packingForm.customerId = query.customerId;
packingForm.overseasWarehouseId = query.overseasWarehouseId || warehouses.value[0]?.id || "";
packingForm.returnNos = [];
@@ -299,6 +332,8 @@ const packingSources = ref<ReturnPackingSource[]>([]);
const packingSourceLoading = ref(false);
let packingLoadSequence = 0;
async function loadPackingSources() {
if (!canCreatePacking.value || packingSaving.value) return;
packingSourceError.value = null;
const sequence = ++packingLoadSequence;
packingSkuRows.value = []; packingBoxRows.value = []; packingSources.value = [];
packingReturnOptions.value = []; packingForm.returnNos = []; packingForm.returnNosInput = ""; packingForm.skuSearchInput = ""; packingForm.skuQuantities = {}; packingForm.sourceCartonMarks = [];
@@ -318,7 +353,7 @@ async function loadPackingSources() {
packingSources.value = sources;
updatePackingScope();
} catch (error) {
if (sequence === packingLoadSequence) ElMessage.error(error instanceof Error ? error.message : '客户退货库存加载失败,请重试');
if (sequence === packingLoadSequence) packingSourceError.value = getApiErrorDetails(error, "客户退货库存加载失败,请重试");
} finally { if (sequence === packingLoadSequence) packingSourceLoading.value = false; }
}
function sourceReturnNos(source: ReturnPackingSource) { return (source.sourceReturnNo || '').split(',').filter(Boolean); }
@@ -351,6 +386,9 @@ function changePackingMode() {
updatePackingScope();
}
async function submitPackingPlan() {
if (!packingDialogOpen.value || packingSaving.value || optionsLoading.value || packingSourceLoading.value || customerError.value || warehouseError.value || packingSourceError.value || !canCreatePacking.value) return;
const invalidQuantity = packingSkuRows.value.some(row => packingForm.skuQuantities[row.key] !== undefined && (!Number.isInteger(packingForm.skuQuantities[row.key]) || packingForm.skuQuantities[row.key]! <= 0 || packingForm.skuQuantities[row.key]! > row.availableQuantity));
if (packingForm.mode === "SKU" && invalidQuantity) { ElMessage.warning("合箱数量必须为不超过可用量的正整数"); return; }
const sourceDeductions = packingSkuRows.value.filter(row => Number(packingForm.skuQuantities[row.key]) > 0)
.map(row => ({ cartonMark: row.cartonMark, skuCode: row.skuCode, quantity: Number(packingForm.skuQuantities[row.key]) }));
const selectedMarks = packingForm.mode === 'BOX' ? packingForm.sourceCartonMarks : [...new Set(sourceDeductions.map(row => row.cartonMark))];
@@ -374,7 +412,7 @@ async function submitPackingPlan() {
);
return;
}
packingSaving.value = true;
packingSaving.value = true; packingSubmitError.value = null;
try {
await createReturnPackingPlan({
returnNos,
@@ -383,15 +421,12 @@ async function submitPackingPlan() {
sourceCartonMarks: selectedMarks,
sourceDeductions: packingForm.mode === 'SKU' ? sourceDeductions : undefined,
remark: packingForm.remark.trim() || undefined,
idempotencyKey:
globalThis.crypto?.randomUUID?.() || `return-packing-${Date.now()}`,
idempotencyKey: packingRequestKey.value,
});
ElMessage.success("合箱计划已创建,待审核后由 PDA 填写箱规并作业");
packingDialogOpen.value = false; inventoryTab.value = "plans"; query.page = 1; await load();
} catch (error) {
ElMessage.error(
error instanceof Error ? error.message : "合箱计划创建失败",
);
packingSubmitError.value = getApiErrorDetails(error, "合箱计划创建失败");
} finally {
packingSaving.value = false;
}
@@ -406,7 +441,8 @@ watch(() => [route.name, route.query.packing, route.query.customerId, route.quer
query.customerId = value('customerId');
query.overseasWarehouseId = value('overseasWarehouseId');
void load();
if (value('packing') === '1') {
if (value('packing') === '1' && canCreatePacking.value) {
packingRequestKey.value = newPackingRequestKey(); packingSubmitError.value = null;
packingForm.customerId = query.customerId;
packingForm.overseasWarehouseId = query.overseasWarehouseId;
packingForm.mode = 'BOX';
@@ -415,6 +451,15 @@ watch(() => [route.name, route.query.packing, route.query.customerId, route.quer
packingForm.returnNosInput = value('returnNos');
}
}, { immediate: true });
watch(packingDialogOpen, open => {
if (open) return;
packingLoadSequence++; packingSourceLoading.value = false; packingRequestKey.value = "";
});
watch(detailOpen, open => {
if (open) return;
cartonLoadSequence++; cartonLoading.value = false; selectedCartonMark.value = ""; detail.value = undefined; cartonError.value = null;
});
onBeforeUnmount(() => { listLoadSequence++; cartonLoadSequence++; packingLoadSequence++; });
onMounted(() => { void loadOptions(); });
const stockColumns: DataTableColumn[] = [
{ key: "stock-0", label: "客户", minWidth: 180, slotName: "stock-0" },
@@ -557,7 +602,6 @@ const tableColumns = computed(() =>
: planColumns,
);
</script>
<template>
<AdminPage as="div" class="business-page return-inventory-page admin-density">
<DataTable
@@ -569,6 +613,7 @@ const tableColumns = computed(() =>
:data="tableRows"
:columns="tableColumns"
:loading="loading"
:results-visible="!listError"
:total="total"
:page-sizes="[20, 50, 100]"
:preference-key="`overseas-return-${mode === 'inventory' ? inventoryTab : 'cartons'}`"
@@ -585,12 +630,19 @@ const tableColumns = computed(() =>
>
<template #toolbar-actions
><el-button
v-if="mode === 'inventory'"
v-if="mode === 'inventory' && canCreatePacking"
:disabled="packingSaving || Boolean(planActionNo)"
type="warning"
@click="openPackingPlan"
><Plus :size="15" />创建合箱计划</el-button
></template
>
<template #notices>
<LoadErrorState v-if="listError" title="退货数据加载失败" :message="listError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="listError" /></template></LoadErrorState>
<LoadErrorState v-if="customerError" title="客户候选加载失败" :message="customerError.message" :loading="optionsLoading" @retry="loadOptions"><template #details><ApiErrorMeta :details="customerError" /></template></LoadErrorState>
<LoadErrorState v-if="warehouseError" title="海外仓候选加载失败" :message="warehouseError.message" :loading="optionsLoading" @retry="loadOptions"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<LoadErrorState v-if="actionError" title="退货操作失败" :message="actionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState>
</template>
<template #filters
><el-tabs
class="density-element-tabs density-status-tabs"
@@ -713,22 +765,25 @@ const tableColumns = computed(() =>
@click="openPlanDetail(row)"
>详情</el-button
><el-button
v-if="row.status === 'PENDING_REVIEW'"
v-if="canManagePacking && row.status === 'PENDING_REVIEW'"
class="density-action"
data-tone="warning"
:disabled="Boolean(planActionNo) || packingSaving"
@click="reviewPlan(row)"
>审核</el-button><el-button v-if="['PENDING_REVIEW','READY'].includes(row.status)" class="density-action" data-tone="danger" @click="cancelPlan(row)">取消</el-button>
>审核</el-button><el-button v-if="canManagePacking && ['PENDING_REVIEW','READY'].includes(row.status)" :disabled="Boolean(planActionNo) || packingSaving" class="density-action" data-tone="danger" @click="cancelPlan(row)">取消</el-button>
</div></template
><template #carton-0="{ row }"
><div class="row-actions density-actions admin-text-actions">
<el-button
class="density-action"
data-tone="neutral"
v-if="access.can(permission.returnCartonsList)" :disabled="cartonLoading"
@click="openCarton(row.cartonMark)"
>详情</el-button
><el-button
class="density-action"
data-tone="neutral"
v-if="access.can(permission.returnCartonsList)" :loading="printingMark === row.cartonMark" :disabled="Boolean(printingMark)"
@click="printLabel(row.cartonMark)"
>箱唛</el-button
>
@@ -748,240 +803,155 @@ const tableColumns = computed(() =>
row.length ? `${row.length}×${row.width}×${row.height} cm` : "—"
}}</template>
</DataTable>
<AppDialog
class="packing-dialog admin-density-dialog"
v-model="packingDialogOpen"
title="创建退货合箱计划"
width="min(1380px, calc(100vw - 32px))"
confirm-text="提交审核"
:confirm-loading="packingSaving"
@confirm="submitPackingPlan"
><el-form v-loading="packingSourceLoading" label-position="top" class="packing-form"
><el-form-item label="客户"
><el-select
v-model="packingForm.customerId"
filterable
placeholder="请选择客户"
style="width: 100%"
@change="loadPackingSources"
><el-option
v-for="item in customerOptions"
:key="item.value"
:label="item.label"
:value="item.value" /></el-select></el-form-item
><el-form-item label="海外仓"><el-select v-model="packingForm.overseasWarehouseId" style="width:100%" @change="loadPackingSources"><el-option v-for="warehouse in warehouseOptions" :key="warehouse.value" :label="warehouse.label" :value="warehouse.value" /></el-select></el-form-item><el-form-item label="合箱方式"
><el-radio-group v-model="packingForm.mode" class="packing-mode" @change="changePackingMode"
><el-radio-button label="BOX">整包裹/箱合箱</el-radio-button
><el-radio-button label="SKU"
>部分包裹/箱合箱</el-radio-button
></el-radio-group
></el-form-item
><el-form-item label="来源退货单筛选(可选,后台自动追溯)"
><el-input
v-model="packingForm.returnNosInput"
clearable
placeholder="默认加载该客户全部可用库存;多个退货单号请用英文逗号分隔"
style="width: 100%"
><template #suffix><span v-if="packingForm.returnNos.length" class="return-nos-count">已识别 {{ packingForm.returnNos.length }} 个</span></template></el-input></el-form-item
><el-form-item v-if="packingForm.mode === 'SKU'" label="来源包裹/箱和合箱数量"
><div class="packing-sku-list-wrap">
<el-input
v-model="packingForm.skuSearchInput"
clearable
placeholder="请输入 SKU,多个 SKU 请用英文逗号分隔"
class="packing-sku-search"
><template #suffix><span v-if="packingForm.skuSearchInput.trim()" class="return-nos-count">按 SKU 筛选</span></template></el-input>
<div class="packing-sku-list">
<div class="packing-sku-list-head"><span>选择 / SKU</span><span>可用数量</span><span>合箱数量</span></div>
<div
v-for="item in visiblePackingSkuRows"
:key="item.key"
class="packing-sku-row"
>
<el-checkbox
:model-value="
packingForm.skuQuantities[item.key] !== undefined
"
@change="togglePackingSku(item.key, Boolean($event))"
>{{ item.cartonMark }} · {{ item.skuCode }}</el-checkbox
><span>可用 {{ item.availableQuantity }}</span
><el-input-number
v-if="packingForm.skuQuantities[item.key] !== undefined"
v-model="packingForm.skuQuantities[item.key]"
:min="1"
:max="item.availableQuantity"
:controls="false"
placeholder="数量"
/>
</div>
<el-empty
v-if="!visiblePackingSkuRows.length"
:description="packingForm.skuSearchInput.trim() || packingForm.returnNos.length ? '没有符合查询条件的可用 SKU' : '该客户暂无可用包裹/箱库存'"
/></div></div></el-form-item
><el-form-item v-if="packingForm.mode === 'BOX'" label="箱维度明细"
><div class="packing-sku-list-wrap">
<el-input v-model="packingForm.skuSearchInput" clearable placeholder="按箱内 SKU 查找,多个 SKU 请用英文逗号分隔" class="packing-sku-search" />
<div class="packing-box-list">
<div class="packing-box-list-head"><span>选择</span><span>箱唛</span><span>来源退货单</span><span>类型</span><span>SKU 明细</span></div>
<div v-for="box in visiblePackingBoxRows" :key="box.cartonMark" class="packing-box-list-row">
<el-checkbox-group v-model="packingForm.sourceCartonMarks"><el-checkbox :value="box.cartonMark" :aria-label="`选择箱唛 ${box.cartonMark}`"><span /></el-checkbox></el-checkbox-group>
<strong>{{ box.cartonMark }}</strong><span>{{ box.returnNo || '—' }}</span><span>{{ box.unitType === 'PALLET' ? '托盘载体' : box.unitType === 'PARCEL' ? '包裹' : '箱子' }}</span><span class="packing-box-items"><span v-for="item in box.items" :key="item.skuCode">{{ item.skuCode }} × {{ item.quantity }}</span></span>
</div>
<el-empty v-if="!visiblePackingBoxRows.length" :description="packingForm.skuSearchInput.trim() ? '没有包含该 SKU 的可用箱子' : '所选客户暂无箱维度库存'" />
</div></div></el-form-item
><el-alert
type="info"
:closable="false"
title="部分包裹/箱必须生成独立新箱并贴标签;整包裹/箱合并可使用已有目标箱。箱规由 PDA 填写。提交即锁定来源库存,取消计划可释放。" /><el-form-item
label="合箱备注(可选)"
><el-input
v-model="packingForm.remark"
type="textarea"
:rows="4"
maxlength="1000"
show-word-limit
resize="vertical"
placeholder="请输入合箱说明,最多 1000 字" /></el-form-item></el-form
></AppDialog>
<AppDialog
class="admin-density-dialog"
v-model="planDetailOpen"
title="合箱计划详情"
width="min(1400px, calc(100vw - 56px))"
:show-footer="false"
><template v-if="planDetail"
><el-descriptions :column="2" border
><el-descriptions-item label="计划号">{{
planDetail.planNo
}}</el-descriptions-item
><el-descriptions-item label="客户">{{
customerName(planDetail.customerId)
}}</el-descriptions-item
><el-descriptions-item label="合箱方式">{{
planDetail.mode === "SKU" ? "部分包裹/箱合箱" : "整包裹/箱合箱"
}}</el-descriptions-item
><el-descriptions-item label="状态">{{
planDetail.status === 'COMPLETED' ? '已完成' : planDetail.status === 'READY' ? '待 PDA 作业' : planDetail.status === 'CANCELLED' ? '已取消' : '待审核'
}}</el-descriptions-item
><el-descriptions-item label="来源退货单" :span="2">{{
planDetail.returnNos?.join("、") || "—"
}}</el-descriptions-item
><el-descriptions-item label="备注" :span="2">{{
planDetail.remark || "—"
}}</el-descriptions-item></el-descriptions
>
<h3>计划来源箱明细</h3>
<el-table :data="planSourceRows" class="detail-items" empty-text="暂无来源箱明细">
<el-table-column prop="sourceReturnNo" label="来源退货单" min-width="210" />
<el-table-column prop="cartonMark" label="来源箱唛" min-width="230" />
<el-table-column prop="role" label="实际角色" min-width="140" />
<el-table-column label="参与方式" width="90"><template #default="{ row }">{{ row.participation === 'FULL' ? '整箱' : row.participation === 'PARTIAL' ? '部分' : '未留存' }}</template></el-table-column>
<el-table-column prop="skuCode" label="SKU" min-width="190" />
<el-table-column label="合箱前数量" width="110"><template #default="{ row }">{{ row.originalQuantity ?? '—' }}</template></el-table-column>
<el-table-column prop="take" label="计划取出" width="100" />
<el-table-column label="预计剩余" width="100"><template #default="{ row }">{{ row.remaining ?? '—' }}</template></el-table-column>
</el-table>
<template v-if="planDetail.status === 'COMPLETED'">
<h3>实际合箱流向</h3>
<el-table :data="planDetail.transfers || []" class="detail-items" empty-text="暂无合箱流向">
<el-table-column prop="returnNo" label="来源退货单" min-width="210" />
<el-table-column prop="sourceCartonMark" label="来源箱唛" min-width="230" />
<el-table-column prop="targetCartonMark" label="实际目标箱唛" min-width="230" />
<el-table-column prop="skuCode" label="SKU" min-width="180" />
<AppDialog class="admin-density-dialog" v-model="packingDialogOpen" title="创建退货合箱计划" width="min(1380px, calc(100vw - 32px))" fullscreenable confirm-text="提交审核" :confirm-loading="packingSaving" :confirm-disabled="optionsLoading || packingSourceLoading || Boolean(customerError || warehouseError || packingSourceError) || !canCreatePacking" @confirm="submitPackingPlan">
<LoadErrorState v-if="customerError" title="客户候选加载失败" :message="customerError.message" :loading="optionsLoading" @retry="loadOptions"><template #details><ApiErrorMeta :details="customerError" /></template></LoadErrorState>
<LoadErrorState v-if="warehouseError" title="海外仓候选加载失败" :message="warehouseError.message" :loading="optionsLoading" @retry="loadOptions"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<LoadErrorState v-if="packingSubmitError" title="合箱计划创建失败" :message="packingSubmitError.message" :show-retry="false"><template #details><ApiErrorMeta :details="packingSubmitError" /></template></LoadErrorState>
<el-form class="packing-form density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="packingSaving || !canCreatePacking">
<section class="density-dialog-section" data-tone="work" aria-label="合箱基础资料">
<table class="density-table density-table--editable packing-basic-table"><tbody>
<tr><td><el-form-item label="客户"><el-select v-model="packingForm.customerId" filterable :loading="optionsLoading" :disabled="optionsLoading || Boolean(customerError)" placeholder="请选择客户" @change="loadPackingSources"><el-option v-for="item in customerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item></td>
<td><el-form-item label="海外仓"><el-select v-model="packingForm.overseasWarehouseId" :loading="optionsLoading" :disabled="optionsLoading || Boolean(warehouseError)" placeholder="请选择海外仓" @change="loadPackingSources"><el-option v-for="warehouse in warehouseOptions" :key="warehouse.value" :label="warehouse.label" :value="warehouse.value" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="合箱方式"><el-radio-group v-model="packingForm.mode" class="packing-mode" @change="changePackingMode"><el-radio-button label="BOX">整包裹/箱合箱</el-radio-button><el-radio-button label="SKU">部分包裹/箱合箱</el-radio-button></el-radio-group></el-form-item></td>
<td><el-form-item label="退货单筛选"><el-input v-model="packingForm.returnNosInput" clearable placeholder="默认加载该客户全部可用库存;多个退货单号请用英文逗号分隔"><template #suffix><span v-if="packingForm.returnNos.length" class="return-nos-count density-dialog-note">已识别 {{ packingForm.returnNos.length }} 个</span></template></el-input></el-form-item></td></tr>
</tbody></table>
</section>
<section class="density-dialog-section" data-tone="source" aria-label="合箱来源选择">
<h3 class="density-dialog-section__title">{{ packingForm.mode === 'SKU' ? '来源包裹/箱和合箱数量' : '箱维度明细' }}</h3>
<p v-if="packingSourceLoading" class="density-dialog-note" role="status">正在读取可用来源库存…</p>
<LoadErrorState v-if="packingSourceError" title="合箱来源加载失败" :message="packingSourceError.message" :loading="packingSourceLoading" @retry="loadPackingSources"><template #details><ApiErrorMeta :details="packingSourceError" /></template></LoadErrorState>
<el-input v-model="packingForm.skuSearchInput" clearable :placeholder="packingForm.mode === 'SKU' ? '请输入 SKU,多个 SKU 请用英文逗号分隔' : '按箱内 SKU 查找,多个 SKU 请用英文逗号分隔'" class="packing-sku-search"><template #suffix><span v-if="packingForm.skuSearchInput.trim()" class="return-nos-count density-dialog-note">按 SKU 筛选</span></template></el-input>
<table v-if="packingForm.mode === 'SKU'" class="density-table density-table--editable packing-source-table">
<colgroup><col /><col class="packing-available" /><col class="packing-quantity" /></colgroup>
<thead><tr><th>选择 / 箱唛 · SKU</th><th>可用数量</th><th>合箱数量</th></tr></thead><tbody>
<tr v-for="item in visiblePackingSkuRows" :key="item.key" class="packing-sku-row">
<td><el-checkbox :model-value="packingForm.skuQuantities[item.key] !== undefined" @change="togglePackingSku(item.key, Boolean($event))"><LineClampText :content="item.cartonMark + ' · ' + item.skuCode" /></el-checkbox></td>
<td>可用 {{ item.availableQuantity }}</td>
<td><el-input-number v-if="packingForm.skuQuantities[item.key] !== undefined" v-model="packingForm.skuQuantities[item.key]" :min="1" :max="item.availableQuantity" :precision="0" :controls="false" placeholder="数量" /></td>
</tr>
<tr v-if="!visiblePackingSkuRows.length && !packingSourceLoading && !packingSourceError"><td colspan="3"><el-empty :description="!packingForm.customerId || !packingForm.overseasWarehouseId ? '请选择客户和海外仓后读取可用来源' : packingForm.skuSearchInput.trim() || packingForm.returnNos.length ? '没有符合查询条件的可用 SKU' : '该客户暂无可用包裹/箱库存'" /></td></tr>
</tbody>
</table>
<table v-else class="density-table packing-source-table">
<colgroup><col class="packing-selection" /><col class="packing-mark" /><col class="packing-return" /><col class="packing-unit" /><col /></colgroup>
<thead><tr><th>选择</th><th>箱唛</th><th>来源退货单</th><th>类型</th><th>SKU 明细</th></tr></thead><tbody>
<tr v-for="box in visiblePackingBoxRows" :key="box.cartonMark" class="packing-box-list-row">
<td><el-checkbox-group v-model="packingForm.sourceCartonMarks"><el-checkbox :value="box.cartonMark" :aria-label="'选择箱唛 ' + box.cartonMark"><span /></el-checkbox></el-checkbox-group></td>
<td><LineClampText :content="box.cartonMark" /></td><td><LineClampText :content="box.returnNo" /></td>
<td>{{ box.unitType === 'PALLET' ? '托盘载体' : box.unitType === 'PARCEL' ? '包裹' : '箱子' }}</td>
<td><span class="packing-box-items"><span v-for="item in box.items" :key="item.skuCode">{{ item.skuCode }} × {{ item.quantity }}</span></span></td>
</tr>
<tr v-if="!visiblePackingBoxRows.length && !packingSourceLoading && !packingSourceError"><td colspan="5"><el-empty :description="!packingForm.customerId || !packingForm.overseasWarehouseId ? '请选择客户和海外仓后读取可用来源' : packingForm.skuSearchInput.trim() ? '没有包含该 SKU 的可用箱子' : '所选客户暂无箱维度库存'" /></td></tr>
</tbody>
</table>
</section>
<section class="density-dialog-section" data-tone="work" aria-label="合箱备注">
<el-alert type="info" :closable="false" title="部分包裹/箱必须生成独立新箱并贴标签;整包裹/箱合并可使用已有目标箱。箱规由 PDA 填写。提交即锁定来源库存,取消计划可释放。" />
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="合箱备注(可选)"><el-input v-model="packingForm.remark" type="textarea" :rows="2" maxlength="1000" show-word-limit resize="vertical" placeholder="请输入合箱说明,最多 1000 字" /></el-form-item></td></tr></tbody></table>
</section>
</el-form>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="planDetailOpen" :title="'合箱计划详情 · ' + (planDetail?.planNo || '—')" width="min(1400px, calc(100vw - 56px))" fullscreenable :show-footer="false">
<div v-if="planDetail" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="summary" aria-label="合箱计划资料">
<table class="density-table return-detail-facts"><tbody>
<tr><th scope="row">计划号</th><td><LineClampText :content="planDetail.planNo" /></td><th scope="row">客户</th><td><LineClampText :content="customerName(planDetail.customerId)" /></td></tr>
<tr><th scope="row">合箱方式</th><td>{{ planDetail.mode === 'SKU' ? '部分包裹/箱合箱' : '整包裹/箱合箱' }}</td><th scope="row">状态</th><td>{{ planDetail.status === 'COMPLETED' ? '已完成' : planDetail.status === 'READY' ? '待 PDA 作业' : planDetail.status === 'CANCELLED' ? '已取消' : '待审核' }}</td></tr>
<tr><th scope="row">来源退货单</th><td colspan="3"><LineClampText :content="planDetail.returnNos?.join('、')" :lines="2" /></td></tr>
<tr><th scope="row">备注</th><td colspan="3"><LineClampText :content="planDetail.remark" :lines="2" /></td></tr>
</tbody></table>
</section>
<section class="density-dialog-section" data-tone="source" aria-label="计划来源箱明细">
<h3 class="density-dialog-section__title">计划来源箱明细</h3>
<el-table :data="planSourceRows" empty-text="暂无来源箱明细">
<el-table-column prop="sourceReturnNo" label="来源退货单" min-width="210"><template #default="{ row }"><LineClampText :content="row.sourceReturnNo" /></template></el-table-column>
<el-table-column prop="cartonMark" label="来源箱唛" min-width="230"><template #default="{ row }"><LineClampText :content="row.cartonMark" /></template></el-table-column>
<el-table-column prop="role" label="实际角色" min-width="140" />
<el-table-column label="参与方式" width="90"><template #default="{ row }">{{ row.participation === 'FULL' ? '整箱' : row.participation === 'PARTIAL' ? '部分' : '未留存' }}</template></el-table-column>
<el-table-column prop="skuCode" label="SKU" min-width="190"><template #default="{ row }"><LineClampText :content="row.skuCode" /></template></el-table-column>
<el-table-column label="合箱前数量" width="110"><template #default="{ row }">{{ row.originalQuantity ?? '—' }}</template></el-table-column>
<el-table-column prop="take" label="计划取出" width="100" />
<el-table-column label="预计剩余" width="100"><template #default="{ row }">{{ row.remaining ?? '—' }}</template></el-table-column>
</el-table>
</section>
<section v-if="planDetail.status === 'COMPLETED'" class="density-dialog-section" data-tone="source" aria-label="实际合箱流向">
<h3 class="density-dialog-section__title">实际合箱流向</h3>
<el-table :data="planDetail.transfers || []" empty-text="暂无合箱流向">
<el-table-column prop="returnNo" label="来源退货单" min-width="210"><template #default="{ row }"><LineClampText :content="row.returnNo" /></template></el-table-column>
<el-table-column prop="sourceCartonMark" label="来源箱唛" min-width="230"><template #default="{ row }"><LineClampText :content="row.sourceCartonMark" /></template></el-table-column>
<el-table-column prop="targetCartonMark" label="实际目标箱唛" min-width="230"><template #default="{ row }"><LineClampText :content="row.targetCartonMark" /></template></el-table-column>
<el-table-column prop="skuCode" label="SKU" min-width="180"><template #default="{ row }"><LineClampText :content="row.skuCode" /></template></el-table-column>
<el-table-column prop="quantity" label="本次合入" width="100" />
</el-table>
<h3>合箱完成时结果({{ planDetail.completedBy || '操作员未留存' }})</h3>
<p>结果数量为完成时快照;目标原箱内容包含在整箱数量中,本次合入数量见上表。</p>
</template>
<el-table v-if="planDetail.completionCartons?.length" :data="planCartonItemRows" class="detail-items">
<el-table-column prop="cartonMark" label="实际箱唛" min-width="250" />
<el-table-column prop="skuCode" label="SKU" min-width="300" />
<el-table-column prop="quantity" label="数量" width="90" align="right" />
<el-table-column label="箱规 / 重量" min-width="190"><template #default="{ row }">{{ row.length ?? '—' }} × {{ row.width ?? '—' }} × {{ row.height ?? '—' }} cm / {{ row.weight ?? '—' }} kg</template></el-table-column>
<el-table-column label="合箱完成时间" min-width="170"><template #default>{{ planDetail.completedAt ? dayjs(planDetail.completedAt).format('YYYY-MM-DD HH:mm:ss') : '—' }}</template></el-table-column>
<el-table-column label="操作" width="130"><template #default="{ row }"><el-button class="density-action" data-tone="neutral" :disabled="!planDetail.resultCartons?.some(carton => carton.cartonMark === row.cartonMark && carton.status !== 'MERGED' && carton.totalQuantity > 0)" @click="printLabel(row.cartonMark)">打印当前箱唛</el-button></template></el-table-column>
</el-table>
<el-table v-else :data="plannedItemRows" class="detail-items" empty-text="待 PDA 填写箱子明细">
<el-table-column prop="boxNo" label="箱序号" width="90" />
<el-table-column prop="skuCode" label="SKU" min-width="300" />
<el-table-column prop="quantity" label="数量" width="90" align="right" />
</el-table></template
></AppDialog
><AppDialog
class="admin-density-dialog"
v-model="detailOpen"
title="退货箱号详情"
width="min(1080px, calc(100vw - 56px))"
:show-footer="false"
><template v-if="detail"
><el-descriptions class="density-descriptions" :column="2" border
><el-descriptions-item label="箱号">{{
detail.cartonMark
}}</el-descriptions-item
><el-descriptions-item label="客户">{{
customerName(detail.customerId)
}}</el-descriptions-item
><el-descriptions-item label="海外仓">{{
warehouseName(detail.overseasWarehouseId)
}}</el-descriptions-item
><el-descriptions-item label="来源退货单">{{
detail.sourceReturnNo || "—"
}}</el-descriptions-item
><el-descriptions-item label="FBA箱唛">{{
detail.fbaCartonMark || "—"
}}</el-descriptions-item
><el-descriptions-item label="目的仓">{{
detail.destinationWarehouseCode ||
detail.destinationWarehouseName ||
"—"
}}</el-descriptions-item
><el-descriptions-item label="换标单号">{{
detail.relabelNo || "—"
}}</el-descriptions-item
><el-descriptions-item label="换标状态">{{
returnRelabelStatusLabel(detail.relabelStatus)
}}|实际完成 {{ detail.relabelledQuantity ?? "—" }} 件|处理中占用 {{ detail.relabelReservedQuantity ?? "—" }} 件</el-descriptions-item
><el-descriptions-item label="状态">{{
statusLabel(detail.status)
}}</el-descriptions-item
><el-descriptions-item label="箱规">{{
detail.length
? `${detail.length}×${detail.width}×${detail.height} cm`
: "—"
}}</el-descriptions-item
><el-descriptions-item label="重量">{{
detail.weight ? `${detail.weight} kg` : "—"
}}</el-descriptions-item></el-descriptions
><el-table :data="detail.items" class="detail-items"
><el-table-column prop="skuCode" label="SKU" /><el-table-column
prop="productNameZh"
label="中文品名" /><el-table-column
prop="quantity"
label="数量"
align="right" /></el-table>
<h3>废弃扣减明细</h3>
<el-table :data="detail.discardHistory || []" class="detail-items" empty-text="暂无废弃记录">
<el-table-column prop="occurredAt" label="操作时间" min-width="170" />
<el-table-column prop="discardNo" label="废弃单号" min-width="210" />
<el-table-column prop="skuCode" label="SKU" min-width="170" />
<el-table-column prop="productNameZh" label="中文品名" min-width="140" />
<el-table-column prop="beforeQuantity" label="扣减前(件)" width="110" align="right" />
<el-table-column prop="quantity" label="废弃数量(件)" width="120" align="right" />
<el-table-column prop="afterQuantity" label="扣减后(件)" width="110" align="right" />
</el-table>
<h3>合箱明细</h3>
<el-table :data="detail.mergeHistory || []" class="detail-items" empty-text="暂无合箱记录">
<el-table-column prop="createdAt" label="合箱时间" min-width="170" />
<el-table-column label="合箱计划" min-width="210"><template #default="{ row }">{{ row.planNo && row.planNo !== 'null' ? row.planNo : '现场合箱 / 历史未关联' }}</template></el-table-column>
<el-table-column label="流向" width="80"><template #default="{ row }">{{ row.targetCartonMark === detail.cartonMark ? '合入' : '合出' }}</template></el-table-column>
<el-table-column prop="sourceCartonMark" label="来源箱唛" min-width="220" />
<el-table-column prop="targetCartonMark" label="目标箱唛" min-width="220" />
<el-table-column prop="returnNo" label="来源退货单" min-width="240" />
<el-table-column prop="skuCode" label="SKU" min-width="170" />
<el-table-column prop="quantity" label="本次合箱数量" width="110" align="right" />
</el-table>
</template
></AppDialog>
</section>
<section class="density-dialog-section" data-tone="summary" aria-label="合箱结果">
<h3 class="density-dialog-section__title">{{ planDetail.status === 'COMPLETED' ? '合箱完成时结果(' + (planDetail.completedBy || '操作员未留存') + ')' : '计划箱子明细' }}</h3>
<p v-if="planDetail.status === 'COMPLETED'" class="density-dialog-note">结果数量为完成时快照;目标原箱内容包含在整箱数量中,本次合入数量见上表。</p>
<el-table v-if="planDetail.completionCartons?.length" :data="planCartonItemRows">
<el-table-column v-if="access.can(permission.returnCartonsList)" label="操作" width="130"><template #default="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" :loading="printingMark === row.cartonMark" :disabled="Boolean(printingMark) || !planDetail.resultCartons?.some(carton => carton.cartonMark === row.cartonMark && carton.status !== 'MERGED' && carton.totalQuantity > 0)" @click="printLabel(row.cartonMark)">打印当前箱唛</el-button></div></template></el-table-column>
<el-table-column prop="cartonMark" label="实际箱唛" min-width="250"><template #default="{ row }"><LineClampText :content="row.cartonMark" /></template></el-table-column>
<el-table-column prop="skuCode" label="SKU" min-width="300"><template #default="{ row }"><LineClampText :content="row.skuCode" /></template></el-table-column>
<el-table-column prop="quantity" label="数量" width="90" align="right" />
<el-table-column label="箱规 / 重量" min-width="190"><template #default="{ row }">{{ row.length ?? '—' }} × {{ row.width ?? '—' }} × {{ row.height ?? '—' }} cm / {{ row.weight ?? '—' }} kg</template></el-table-column>
<el-table-column label="合箱完成时间" min-width="170"><template #default>{{ planDetail.completedAt ? dayjs(planDetail.completedAt).format('YYYY-MM-DD HH:mm:ss') : '—' }}</template></el-table-column>
</el-table>
<el-table v-else :data="plannedItemRows" empty-text="待 PDA 填写箱子明细">
<el-table-column prop="boxNo" label="箱序号" width="90" />
<el-table-column prop="skuCode" label="SKU" min-width="300"><template #default="{ row }"><LineClampText :content="row.skuCode" /></template></el-table-column>
<el-table-column prop="quantity" label="数量" width="90" align="right" />
</el-table>
</section>
</div>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="detailOpen" :title="'退货箱号详情 · ' + (selectedCartonMark || '—')" width="min(1080px, calc(100vw - 56px))" fullscreenable :show-footer="false" :loading="cartonLoading" :confirm-loading="cartonLoading">
<p v-if="cartonLoading" class="density-dialog-note" role="status">正在读取退货箱号详情…</p>
<LoadErrorState v-if="cartonError" title="箱号详情加载失败" :message="cartonError.message" :loading="cartonLoading" @retry="loadCarton"><template #details><ApiErrorMeta :details="cartonError" /></template></LoadErrorState>
<div v-if="detail" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="summary" aria-label="退货箱号资料">
<table class="density-table return-detail-facts"><tbody>
<tr><th scope="row">箱号</th><td><LineClampText :content="detail.cartonMark" /></td><th scope="row">状态</th><td>{{ statusLabel(detail.status) }}</td></tr>
<tr><th scope="row">客户</th><td><LineClampText :content="customerName(detail.customerId)" /></td><th scope="row">海外仓</th><td><LineClampText :content="warehouseName(detail.overseasWarehouseId)" /></td></tr>
<tr><th scope="row">来源退货单</th><td><LineClampText :content="detail.sourceReturnNo" /></td><th scope="row">FBA箱唛</th><td><LineClampText :content="detail.fbaCartonMark" /></td></tr>
<tr><th scope="row">目的仓</th><td><LineClampText :content="detail.destinationWarehouseCode || detail.destinationWarehouseName" /></td><th scope="row">换标单号</th><td><LineClampText :content="detail.relabelNo" /></td></tr>
<tr><th scope="row">换标状态</th><td colspan="3">{{ returnRelabelStatusLabel(detail.relabelStatus) }}|实际完成 {{ detail.relabelledQuantity ?? '—' }} 件|处理中占用 {{ detail.relabelReservedQuantity ?? '—' }} 件</td></tr>
<tr><th scope="row">箱规</th><td>{{ detail.length ? detail.length + '×' + detail.width + '×' + detail.height + ' cm' : '—' }}</td><th scope="row">重量</th><td>{{ detail.weight ? detail.weight + ' kg' : '—' }}</td></tr>
</tbody></table>
</section>
<section class="density-dialog-section" data-tone="source" aria-label="退货箱内SKU">
<h3 class="density-dialog-section__title">箱内SKU明细</h3>
<el-table :data="detail.items">
<el-table-column prop="skuCode" label="SKU"><template #default="{ row }"><LineClampText :content="row.skuCode" /></template></el-table-column>
<el-table-column prop="productNameZh" label="中文品名"><template #default="{ row }"><LineClampText :content="row.productNameZh" /></template></el-table-column>
<el-table-column prop="quantity" label="数量" align="right" />
</el-table>
</section>
<section class="density-dialog-section" data-tone="source" aria-label="退货合箱明细">
<h3 class="density-dialog-section__title">废弃扣减明细</h3>
<el-table :data="detail.discardHistory || []" empty-text="暂无废弃记录">
<el-table-column prop="occurredAt" label="操作时间" min-width="170" />
<el-table-column prop="discardNo" label="废弃单号" min-width="210"><template #default="{ row }"><LineClampText :content="row.discardNo" /></template></el-table-column>
<el-table-column prop="skuCode" label="SKU" min-width="170"><template #default="{ row }"><LineClampText :content="row.skuCode" /></template></el-table-column>
<el-table-column prop="productNameZh" label="中文品名" min-width="140" />
<el-table-column prop="beforeQuantity" label="扣减前(件)" width="110" align="right" />
<el-table-column prop="quantity" label="废弃数量(件)" width="120" align="right" />
<el-table-column prop="afterQuantity" label="扣减后(件)" width="110" align="right" />
</el-table>
<h3 class="density-dialog-section__title">合箱明细</h3>
<el-table :data="detail.mergeHistory || []" empty-text="暂无合箱记录">
<el-table-column prop="createdAt" label="合箱时间" min-width="170" />
<el-table-column label="合箱计划" min-width="210"><template #default="{ row }"><LineClampText :content="row.planNo && row.planNo !== 'null' ? row.planNo : '现场合箱 / 历史未关联'" /></template></el-table-column>
<el-table-column label="流向" width="80"><template #default="{ row }">{{ row.targetCartonMark === detail.cartonMark ? '合入' : '合出' }}</template></el-table-column>
<el-table-column prop="sourceCartonMark" label="来源箱唛" min-width="220"><template #default="{ row }"><LineClampText :content="row.sourceCartonMark" /></template></el-table-column>
<el-table-column prop="targetCartonMark" label="目标箱唛" min-width="220"><template #default="{ row }"><LineClampText :content="row.targetCartonMark" /></template></el-table-column>
<el-table-column prop="returnNo" label="来源退货单" min-width="240"><template #default="{ row }"><LineClampText :content="row.returnNo" /></template></el-table-column>
<el-table-column prop="skuCode" label="SKU" min-width="170"><template #default="{ row }"><LineClampText :content="row.skuCode" /></template></el-table-column>
<el-table-column prop="quantity" label="本次合箱数量" width="110" align="right" />
</el-table>
</section>
</div>
</AppDialog>
</AdminPage>
</template>
<style scoped>
@@ -1006,135 +976,25 @@ const tableColumns = computed(() =>
justify-content: flex-end;
margin-top: var(--admin-space-5);
}
.detail-items {
margin-top: var(--admin-space-5);
}
.packing-form {
padding: 2px 4px;
}
.packing-form :deep(.el-form-item) {
margin-bottom: 14px;
}
.packing-mode {
display: flex;
width: 100%;
}
.packing-mode :deep(.el-radio-button) {
flex: 1;
}
.packing-mode :deep(.el-radio-button__inner) {
width: 100%;
padding: 9px 14px;
}
.packing-sku-list-wrap { width: 100%; }
.packing-sku-search { margin-bottom: 8px; }
.packing-sku-list {
display: grid;
gap: 0;
width: 100%;
max-height: 260px;
overflow: auto;
border: 1px solid var(--cc-border);
border-radius: 6px;
}
.packing-sku-list-head,
.packing-sku-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 90px 92px;
align-items: center;
gap: 8px;
min-height: 42px;
padding: 7px 10px;
border-bottom: 1px solid var(--cc-border);
}
.packing-sku-list-head { position: sticky; top: 0; z-index: 1; background: var(--cc-surface-subtle, #f8fafc); color: var(--cc-muted); font-size: var(--admin-font-caption); }
.packing-sku-row:last-of-type { border-bottom: 0; }
.packing-sku-row > span {
color: var(--cc-muted);
font-size: var(--admin-font-caption);
white-space: nowrap;
}
.packing-sku-row :deep(.el-input-number) {
width: 68px;
}
.packing-sku-row :deep(.el-input-number .el-input__wrapper) {
padding: 0 7px;
}
.packing-sku-row :deep(.el-input__inner) {
font-size: var(--admin-font-caption);
text-align: center;
}
.packing-box-list {
display: grid;
gap: 0;
width: 100%;
max-height: 260px;
overflow: auto;
border: 1px solid var(--cc-border);
border-radius: 6px;
}
.packing-box-list-head,
.packing-box-list-row {
display: grid;
grid-template-columns: 72px minmax(180px, 1.15fr) minmax(150px, 1fr) 90px minmax(220px, 2fr);
align-items: center;
gap: 8px;
padding: 8px 10px;
}
.packing-box-list-row > strong,
.packing-box-list-row > span { overflow-wrap: anywhere; }
.packing-box-list-head {
position: sticky;
top: 0;
z-index: 1;
color: var(--cc-muted);
font-size: var(--admin-font-caption);
background: var(--cc-surface-subtle, #f8fafc);
border-bottom: 1px solid var(--cc-border);
}
.packing-box-list-row {
min-height: 42px;
border-bottom: 1px solid var(--cc-border);
}
.packing-box-list-row:last-of-type {
border-bottom: 0;
}
.packing-box-list-row > span,
.packing-box-items {
color: var(--cc-muted);
font-size: var(--admin-font-caption);
}
.packing-box-items {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.return-nos-count { color: var(--cc-muted); font-size: var(--admin-font-caption); white-space: nowrap; }
.packing-form :deep(.el-alert) {
margin-top: 2px;
padding: 8px 12px;
}
.packing-form :deep(.el-alert__title) {
font-size: var(--admin-font-caption);
}
@media (max-width: 700px) {
.packing-box-list { overflow-x: auto; }
.packing-box-list-head,
.packing-box-list-row { min-width: 760px; }
}
:deep(.packing-dialog.el-dialog) {
resize: both;
overflow: auto;
min-width: 720px;
min-height: 520px;
max-width: calc(100vw - 24px);
max-height: calc(100vh - 24px);
}
:deep(.packing-dialog .el-dialog__body) {
min-width: 0;
}
.packing-form :deep(.el-textarea__inner) {
width: 100%;
line-height: 1.6;
}
.packing-basic-table { table-layout: fixed; }
.packing-basic-table td { width: 50%; }
.packing-mode { display: flex; width: 100%; }
.packing-mode :deep(.el-radio-button) { flex: 1; }
.packing-mode :deep(.el-radio-button__inner) { width: 100%; }
.packing-source-table { table-layout: fixed; }
.packing-source-table th { position: sticky; top: 0; z-index: 1; }
.packing-available { width: 100px; }
.packing-quantity { width: 130px; }
.packing-selection { width: 56px; }
.packing-mark { width: 220px; }
.packing-return { width: 200px; }
.packing-unit { width: 96px; }
.packing-sku-search { margin-bottom: var(--admin-space-3); }
.packing-sku-row .el-checkbox { max-width: 100%; }
.packing-sku-row :deep(.el-checkbox__label) { min-width: 0; max-width: 100%; }
.packing-box-items { display: flex; flex-wrap: wrap; gap: 6px; }
.return-nos-count { white-space: nowrap; }
.return-detail-facts { table-layout: fixed; }
.return-detail-facts th { width: 110px; }
</style>
@@ -367,7 +367,8 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(columnKeys('LastMileOrdersView.vue')).toEqual([
'actions', 'selection', 'orderNo', 'businessSource', 'businessOrderNo', 'externalBookingNo', 'consolidated', 'palletMergeRelation', 'warning', 'orderType', 'rejectedAt', 'inboundCode', 'lxCode', 'owner', 'warehouse',
'transferWarehouse', 'fbaWarehouse', 'containerNo', 'abnormal', 'held', 'pallets',
'cartons', 'cartonDifference', 'volume', 'quantity', 'volumeWeight', 'portEta', 'portAta', 'warehouseEta', 'inboundOrderNo',
// 本轮明确拆分退货件数/载体与转运预报/实收,保留其他原型列的相对顺序。
'cartons', 'returnParcels', 'returnExpected', 'returnReceived', 'returnRemaining', 'cartonDifference', 'volume', 'quantity', 'declaredPieces', 'receivedPieces', 'volumeWeight', 'portEta', 'portAta', 'warehouseEta', 'inboundOrderNo',
'dispatchStatus', 'outboundOrderNo', 'dispatchOrderNo', 'relabelOrderNo', 'relabelStatus', 'status',
'createdAt', 'internalRemark', 'customerRemark', 'warehouseRemark',
])
@@ -376,11 +377,11 @@ describe('overseas warehouse first-batch prototype contract', () => {
'pallets', 'assigned', 'mergeAllowed', 'urgent', 'status', 'appointmentStatus', 'dispatchStatus', 'createdAt',
])
expect(columnKeys('InventoryView.vue')).toEqual([
'actions', 'owner', 'warehouse', 'sku', 'productName', 'location', 'onHand', 'available', 'held', 'looseboard',
'actions', 'owner', 'warehouse', 'sku', 'availablePieces', 'productName', 'location', 'onHand', 'available', 'held', 'looseboard',
'inTransit', 'updatedAt',
])
expect(columnKeys('RelabelOrdersView.vue')).toEqual([
'actions', 'selection', 'orderNo', 'sku', 'productName', 'cartons', 'budgetTotal', 'relabelCost', 'walletStatus', 'labelRequirement', 'fileName', 'status', 'createdAt',
'actions', 'selection', 'orderNo', 'sku', 'productName', 'cartons', 'labelPieces', 'skuCount', 'budgetTotal', 'relabelCost', 'walletStatus', 'labelRequirement', 'fileName', 'status', 'createdAt',
])
expect(columnKeys('PalletsView.vue')).toEqual([
'actions', 'code', 'type', 'warehouse', 'volume', 'purchasePrice', 'fbaWarehouse', 'status', 'remark',