优化
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user