优化
This commit is contained in:
@@ -3,7 +3,12 @@ import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrder
|
||||
|
||||
export interface CargoDiscardQuery { page: number; size: number; customerId?: string; discardNo?: string; inboundCode?: string; overseasWarehouseId?: string; status?: string }
|
||||
export interface CargoDiscardSummary { id: string; customerId?: string | null; discardNo: string; outboundNo?: string | null; inboundCodes?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; totalPackages?: number | null; totalFee?: number | string | null; currency?: string | null; status?: string | null; createdBy?: string | null; createdAt?: string | null; reviewedAt?: string | null }
|
||||
export interface CargoDiscardItem { cartonMarks?: string[]; id: string; orderId?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackagesSnapshot?: number | null; discardPackages?: number | null }
|
||||
export interface CargoDiscardItem { quantityUnit?: string | null; actualDiscardPackages?: number | null; pdaStatus?: string | null; cartonMarks?: string[]; id: string; orderId?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackagesSnapshot?: number | null; discardPackages?: number | null }
|
||||
export interface CargoDiscardStatistics { orderCount: number; skuCount: number; requestedPackages: number; actualPackages: number | null; requestedPieces: number | null; actualPieces: number | null; palletCount: number | null }
|
||||
export async function getCargoDiscardStatistics(query: CargoDiscardQuery) {
|
||||
const response = await http.get<ApiEnvelope<CargoDiscardStatistics>, ApiEnvelope<CargoDiscardStatistics>>('/platform/v1/last-leg/cargo-discards/statistics', { params: query })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
export interface CargoDiscardPhoto { fileId: string; palletNo: string; fileName: string; createdAt: string; submittedAt: string }
|
||||
export interface CargoDiscardDetail { photos: CargoDiscardPhoto[]; summary: CargoDiscardSummary; feePerPackage?: number | string | null; remark?: string | null; reviewedBy?: string | null; reviewRemark?: string | null; items: CargoDiscardItem[]; histories: { fromStatus?: string | null; toStatus?: string | null; operatorId?: string | null; remark?: string | null; createdAt?: string | null }[] }
|
||||
export interface CargoDiscardCandidate { orderId?: string | null; sourceType: string; returnCartonId?: string | null; cartonMark?: string | null; inboundCode?: string | null; inboundOrderNo?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; availablePackages?: number; skus: { skuCode: string; productName?: string | null; availablePackages?: number }[] }
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface RelabelWarehouseOptionDto {
|
||||
}
|
||||
|
||||
export interface RelabelSummaryDto {
|
||||
operationTypes?: Array<'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU' | 'RETURN_BULK_SKU_LABEL'>
|
||||
id: string
|
||||
relabelNo: string
|
||||
lastlegOrderId?: string | null
|
||||
@@ -93,7 +94,7 @@ export interface RelabelSummaryDto {
|
||||
|
||||
export interface RelabelItemDto {
|
||||
id: string
|
||||
operationType?: 'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU'
|
||||
operationType?: 'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU' | 'RETURN_BULK_SKU_LABEL'
|
||||
skuItems?: Array<{ skuCode: string; newSkuCode: string; quantity: number; remark?: string }>
|
||||
skuCode?: string | null
|
||||
productNameZh?: string | null
|
||||
@@ -129,6 +130,30 @@ export interface RelabelDetailDto {
|
||||
completionEvidence?: { taskId: string; photoFileIds: string[]; completionRemark?: string | null; reviewerName?: string | null; reviewRemark?: string | null; completedAt?: string | null } | null
|
||||
}
|
||||
|
||||
export interface RelabelQuantity {
|
||||
labelPieceCount: number | null
|
||||
cartonCount: number | null
|
||||
skuCount: number
|
||||
duplicateCarton: boolean
|
||||
quantityUnit: string
|
||||
sourcePalletCount: number | null
|
||||
targetPalletCount: number | null
|
||||
}
|
||||
export interface RelabelQuantityTotals {
|
||||
orderCount: number
|
||||
skuCount: number
|
||||
knownLabelPieceCount: number
|
||||
unknownPieceOrders: number
|
||||
knownCartonCount: number
|
||||
unknownCartonOrders: number
|
||||
orders: Record<string, RelabelQuantity>
|
||||
}
|
||||
/** 汇总同一筛选下全部单据,不以当前页合计代替。 */
|
||||
export async function getRelabelQuantities(query: RelabelOrderQuery) {
|
||||
const response = await http.get<ApiEnvelope<RelabelQuantityTotals>, ApiEnvelope<RelabelQuantityTotals>>('/platform/v1/last-leg/relabel-applications/quantity-summary', { params: query })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export interface ReturnPackingPlanBoxItem {
|
||||
skuCode: string
|
||||
quantity: number
|
||||
@@ -233,8 +258,14 @@ export async function changeRelabelOrderStatus(relabelNo: string, status: string
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export interface RelabelReviewTask {
|
||||
version: number
|
||||
taskStatus: string
|
||||
items?: Array<{ sourcePalletCode?: string | null; sourcePalletBasis?: string }>
|
||||
history?: Array<{ action: string; detail?: string }>
|
||||
}
|
||||
export async function getRelabelReviewTask(relabelNo: string) {
|
||||
const response = await http.get<ApiEnvelope<{ version: number; taskStatus: string }>, ApiEnvelope<{ version: number; taskStatus: string }>>(`/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/review-task`)
|
||||
const response = await http.get<ApiEnvelope<RelabelReviewTask>, ApiEnvelope<RelabelReviewTask>>(`/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/review-task`)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,11 @@ import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrder
|
||||
export interface ReturnStockRow { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; totalQuantity: number; availableQuantity: number; relabelLockedQuantity: number; looseAvailableQuantity?: number; cartonQuantity: number; updatedAt: string }
|
||||
export interface ReturnCartonRow { customerId: string; overseasWarehouseId: string; cartonMark: string; sourceReturnNo?: string; sourceUnitType?: 'PALLET' | 'BOX' | 'PARCEL' | string; sourceBatchNo?: string; status: string; fbaCartonMark?: string; destinationWarehouseCode?: string; destinationWarehouseName?: string; relabelNo?: string; relabelStatus?: string; relabelledQuantity?: number; relabelReservedQuantity?: number; skuTypes: number; totalQuantity: number; length?: number; width?: number; height?: number; weight?: number; createdAt: string }
|
||||
export interface ReturnCartonMergeHistory { planNo?: string; sourceCartonMark: string; targetCartonMark: string; returnNo: string; skuCode: string; quantity: number; createdAt: string }
|
||||
export interface ReturnCartonDetail extends ReturnCartonRow { customerId: string; items: Array<{ skuCode: string; productNameZh?: string; quantity: number; availableQuantity?: number }>; mergeHistory?: ReturnCartonMergeHistory[] }
|
||||
export interface ReturnCartonDiscardHistory { discardNo: string; skuCode: string; productNameZh?: string; beforeQuantity: number; quantity: number; afterQuantity: number; operatorId?: string; occurredAt: string }
|
||||
export interface ReturnCartonDetail extends ReturnCartonRow { customerId: string; items: Array<{ skuCode: string; productNameZh?: string; quantity: number; availableQuantity?: number }>; mergeHistory?: ReturnCartonMergeHistory[]; discardHistory?: ReturnCartonDiscardHistory[] }
|
||||
export interface ReturnCartonLabel extends ReturnCartonDetail { printableHtml: string }
|
||||
export interface ReturnPackingSource { cartonMark: string; sourceReturnNo?: string; sourceUnitType?: string; items: ReturnCartonDetail['items'] }
|
||||
export interface ReturnInventoryMovement { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; movementType: 'RECEIPT' | 'MERGE' | 'OUTBOUND'; referenceNo: string; totalDelta: number; availableDelta: number; occurredAt: string }
|
||||
export interface ReturnInventoryMovement { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; movementType: 'RECEIPT' | 'MERGE' | 'OUTBOUND' | 'DISCARD'; referenceNo: string; totalDelta: number; availableDelta: number; occurredAt: string }
|
||||
export interface ReturnInventoryQuery { page: number; size: number; customerId?: string; overseasWarehouseId?: string; skuCode?: string; cartonMark?: string; returnNo?: string; status?: string }
|
||||
|
||||
export async function listReturnStock(params: ReturnInventoryQuery) { const response = await http.get<ApiEnvelope<OverseasPagePayload<ReturnStockRow>>, ApiEnvelope<OverseasPagePayload<ReturnStockRow>>>('/platform/v1/last-leg/return-inventory', { params }); return normalizeOverseasPage(unwrapApiResponse(response)) }
|
||||
|
||||
@@ -30,6 +30,19 @@ function fixture(): WarehouseTransferDetailDto {
|
||||
beforeEach(() => { vi.mocked(getWarehouseTransfer).mockReset(); vi.mocked(getWarehouseTransferCartonLabel).mockReset() })
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
describe('转运预报与实际签收明细', () => {
|
||||
it('keeps historical receipt totals after discard or shipment and leaves unknown declaration differences blank', async () => {
|
||||
const value = fixture()
|
||||
value.items[0]!.quantity = null
|
||||
value.cartons[0]!.status = 'DISCARD_PENDING'
|
||||
value.cartons[1]!.status = 'SHIPPED'
|
||||
value.cartons[2]!.status = 'DISCARDED'
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValue(value)
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.emitted('summary')!.at(-1)![0]).toMatchObject({ boxes: 3, quantity: 27, skus: 2, forecastQuantity: null })
|
||||
const rows = wrapper.findAllComponents(DataTableStub)[0]!.props('data')
|
||||
expect(rows[0]).toMatchObject({ receivedQuantity: 20, receivedBoxes: 2, quantityDifference: null })
|
||||
expect(wrapper.findAll('.test-table')[0]!.text()).toContain('未记录')
|
||||
})
|
||||
it('shows every SKU in a mixed box and counts its physical measurements once', async () => {
|
||||
const value = fixture()
|
||||
value.cartons = [{ ...value.cartons[0]!, quantity: 10, items: [{ transferItemId: '10', skuCode: 'SKU-A', quantity: 3 }, { transferItemId: '20', skuCode: 'SKU-B', quantity: 7 }] }]
|
||||
|
||||
@@ -46,7 +46,8 @@ const cartonsOpen = ref(false)
|
||||
let generation = 0
|
||||
// 同一完成原因保存于箱记录,去重后在尾程详情直接展示。
|
||||
const discrepancyReasons = computed(() => [...new Set((detail.value?.cartons || []).map(box => box.discrepancyReason?.trim()).filter((reason): reason is string => Boolean(reason)))])
|
||||
const cartons = computed(() => (detail.value?.cartons || []).filter(box => box.status === 'COUNTED'))
|
||||
// 已签收是历史事实,后续废弃占用、废弃或出库不能从实收汇总中移除。
|
||||
const cartons = computed(() => (detail.value?.cartons || []).filter(box => ['COUNTED', 'DISCARD_PENDING', 'DISCARDED', 'SHIPPED'].includes(box.status)))
|
||||
function itemFor(box: WarehouseTransferCartonDto) { return detail.value?.items.find(item => box.transferItemId != null && String(item.id) === String(box.transferItemId)) }
|
||||
function boxLines(box: WarehouseTransferCartonDto) {
|
||||
if (box.items?.length) return box.items
|
||||
@@ -64,9 +65,10 @@ watch([detail, totals], () => emit('summary', detail.value ? { ...totals.value,
|
||||
const comparisonRows = computed(() => (detail.value?.items || []).map(item => {
|
||||
const boxes = cartons.value.filter(box => boxLines(box).some(line => String(line.transferItemId) === String(item.id)))
|
||||
const receivedQuantity = boxes.reduce((sum, box) => sum + boxLines(box).filter(line => String(line.transferItemId) === String(item.id)).reduce((total, line) => total + Number(line.quantity), 0), 0)
|
||||
return { ...item, receivedQuantity, receivedBoxes: boxes.length, quantityDifference: receivedQuantity - Number(item.quantity || 0), boxDifference: boxes.length - Number(item.packageCount || 0) }
|
||||
return { ...item, receivedQuantity, receivedBoxes: boxes.length, quantityDifference: item.quantity == null ? null : receivedQuantity - Number(item.quantity), boxDifference: boxes.length - Number(item.packageCount || 0) }
|
||||
}))
|
||||
function differenceText(value: unknown) {
|
||||
if (value == null) return '未记录'
|
||||
if (!cartons.value.length) return '待签收'
|
||||
const difference = Number(value)
|
||||
return difference > 0 ? `+${difference}` : String(difference)
|
||||
|
||||
@@ -9,13 +9,16 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { getApiErrorDetails } from '@/shared/api/http'
|
||||
import { downloadCargoDiscardPhoto, listCargoDiscards, getCargoDiscard, reviewCargoDiscard, type CargoDiscardDetail, type CargoDiscardSummary } from '../api/cargoDiscards'
|
||||
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'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
const access = useAccessPreviewStore(); const rows = ref<CargoDiscardSummary[]>([]); const customers = ref<PlatformTenantResource[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const detailOpen = ref(false); const detail = ref<CargoDiscardDetail | null>(null); const filters = ref<QueryModel>({ customerId: '', discardNo: '', inboundCode: '', status: '' })
|
||||
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: '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 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 photoPreviewUrls = computed(() => (detail.value?.photos || []).map(photo => photoUrls[photo.fileId]).filter(Boolean))
|
||||
@@ -32,14 +35,16 @@ async function loadPhoto(fileId: string) {
|
||||
watch(detailOpen, value => { if (!value) clearPhotos() })
|
||||
onBeforeUnmount(clearPhotos)
|
||||
function text(key: string) { return String(filters.value[key] || '').trim() || undefined }
|
||||
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); loading.value = true; try { const result = await listCargoDiscards({ page: page.value, size: pageSize.value, customerId: text('customerId'), discardNo: text('discardNo'), inboundCode: text('inboundCode'), status: text('status') }); rows.value = result.records; total.value = result.total } catch (e) { ElMessage.error(getApiErrorDetails(e, '废弃单列表加载失败').message) } finally { loading.value = false } }
|
||||
/** 同一筛选读取分页与全量统计,失败时清除旧数据,避免陈旧合计。 */
|
||||
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) } })
|
||||
</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>共 {{ total }} 条废弃单</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 prop="discardPackages" label="申请数量" width="100"/><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><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>
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,16 @@ vi.mock('../api/warehouseTransfers', () => ({ getWarehouseTransfer: vi.fn() }))
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('LastMileOrderDetailView domestic detail alignment', () => {
|
||||
it('counts mixed parcel and box receipts by their physical unit instead of calling both parcels', async () => {
|
||||
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'return-tail', customerId: 'C1', orderType: 'RETURN_RELABEL', orderNo: 'RT-1' }, cargo: [], fees: [], statusHistory: [], operationHistory: [],
|
||||
returnReceipt: { order: { returnNo: 'RT-1', expectedQuantity: 8, receivedQuantity: 8 }, receipts: [{ receiptNo: 'P1', unitType: 'PARCEL' }, { receiptNo: 'B1', unitType: 'BOX', length: 30, width: 20, height: 10, weight: 2 }],
|
||||
cartons: [{ receiptNo: 'P1', cartonMark: 'PK1', items: [] }, { receiptNo: 'B1', cartonMark: 'RC1', items: [] }] } })
|
||||
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
await wrapper.get('#tab-return-receipt').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('签收实物明细(1 包裹 · 1 箱)')
|
||||
expect(wrapper.text()).not.toContain('2 包裹')
|
||||
expect(wrapper.text()).toContain('长 30 cm · 宽 20 cm · 高 10 cm · 体积 0.006 m³ · 重量 2 kg')
|
||||
})
|
||||
it.each(['PENDING_REVIEW', 'PARTIALLY_RECEIVED', 'COMPLETED'])('shows only customer SKU forecast regardless of receipt status (%s)', async status => {
|
||||
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'return-tail', customerId: 'C1', orderType: 'RETURN_RELABEL', orderNo: 'RT-1', currentStatus: status, packageCount: 30 },
|
||||
cargo: [], fees: [], statusHistory: [], operationHistory: [],
|
||||
|
||||
@@ -75,10 +75,25 @@ function isSingleLineBusinessField(field: DetailField) { return field.label ===
|
||||
|
||||
const referenceValue = computed(() => lastMileOrderReference(detail.value?.order.orderNo, '订单编码待补充'))
|
||||
const isReturnOrder = computed(() => detail.value?.order.orderType === 'RETURN_RELABEL')
|
||||
const returnReceiptUnitLabel = computed(() => detail.value?.returnReceipt?.receipts.some(receipt => receipt.unitType === 'PARCEL') ? '包裹' : '箱')
|
||||
const returnReceiptUnits = computed(() => {
|
||||
const receiptTypes = new Map((detail.value?.returnReceipt?.receipts || []).map(receipt => [receipt.receiptNo, receipt.unitType]))
|
||||
const counts = { parcels: 0, boxes: 0, unknown: 0 }
|
||||
for (const carton of detail.value?.returnReceipt?.cartons || []) {
|
||||
const type = receiptTypes.get(carton.receiptNo)
|
||||
if (type === 'PARCEL') counts.parcels++
|
||||
else if (type === 'BOX' || type === 'PALLET') counts.boxes++
|
||||
else counts.unknown++
|
||||
}
|
||||
return counts
|
||||
})
|
||||
const returnReceiptUnitLabel = computed(() => returnReceiptUnits.value.parcels && !returnReceiptUnits.value.boxes && !returnReceiptUnits.value.unknown ? '包裹' : returnReceiptUnits.value.parcels || returnReceiptUnits.value.unknown ? '实物' : '箱')
|
||||
const returnReceiptUnitSummary = computed(() => {
|
||||
const { parcels, boxes, unknown } = returnReceiptUnits.value
|
||||
return [parcels ? `${parcels} 包裹` : '', boxes ? `${boxes} 箱` : '', unknown ? `${unknown} 个未识别载体` : ''].filter(Boolean).join(' · ') || '0 箱'
|
||||
})
|
||||
const signedReturnCartons = computed(() => (detail.value?.returnReceipt?.cartons || []).map(box => {
|
||||
const parcel = detail.value?.returnReceipt?.receipts.find(receipt => receipt.receiptNo === box.receiptNo && receipt.unitType === 'PARCEL')
|
||||
return { ...box, length: box.length ?? parcel?.length, width: box.width ?? parcel?.width, height: box.height ?? parcel?.height, weight: box.weight ?? parcel?.weight }
|
||||
const measuredUnit = detail.value?.returnReceipt?.receipts.find(receipt => receipt.receiptNo === box.receiptNo && (receipt.unitType === 'PARCEL' || receipt.unitType === 'BOX'))
|
||||
return { ...box, length: box.length ?? measuredUnit?.length, width: box.width ?? measuredUnit?.width, height: box.height ?? measuredUnit?.height, weight: box.weight ?? measuredUnit?.weight }
|
||||
}))
|
||||
function returnCartonVolume(box: { length?: number | null; width?: number | null; height?: number | null }) {
|
||||
if (box.length == null || box.width == null || box.height == null) return '—'
|
||||
@@ -302,7 +317,7 @@ onMounted(load)
|
||||
<el-table-column prop="boxCount" label="实收箱数" width="100" />
|
||||
<el-table-column prop="receivedQuantity" label="实收数量" width="100" />
|
||||
</el-table>
|
||||
<h3>签收{{ returnReceiptUnitLabel }}明细({{ detail.returnReceipt.cartons?.length || 0 }} {{ returnReceiptUnitLabel }})</h3>
|
||||
<h3>签收{{ returnReceiptUnitLabel }}明细({{ returnReceiptUnitSummary }})</h3>
|
||||
<el-collapse v-if="detail.returnReceipt.cartons?.length">
|
||||
<el-collapse-item v-for="box in signedReturnCartons" :key="box.cartonMark" :name="box.cartonMark">
|
||||
<template #title><span class="return-carton-title"><span>{{ box.cartonMark }} · {{ box.countStatus === 'CONFIRMED' ? `已确认 · ${box.items.length} 种 SKU · ${box.items.reduce((sum, item) => sum + Number(item.quantity), 0)} 件` : box.countStatus === 'PENDING' ? '待清点' : '未留存箱内签收明细' }}</span><span class="return-carton-measurements">长 {{ box.length ?? '—' }} cm · 宽 {{ box.width ?? '—' }} cm · 高 {{ box.height ?? '—' }} cm · 体积 {{ returnCartonVolume(box) }} m³ · 重量 {{ box.weight ?? '—' }} kg</span></span></template>
|
||||
@@ -319,7 +334,7 @@ onMounted(load)
|
||||
<el-empty v-else description="暂无签收箱号明细" />
|
||||
<h3>合箱记录</h3>
|
||||
<el-table :data="detail.returnReceipt.packingDetails || []" size="small" border empty-text="暂无合箱记录">
|
||||
<el-table-column prop="planNo" label="合箱计划" min-width="210" />
|
||||
<el-table-column label="合箱计划" min-width="210"><template #default="{ row }">{{ row.planNo && row.planNo !== 'null' ? row.planNo : '—' }}</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="skuCode" label="SKU" min-width="180" />
|
||||
|
||||
@@ -5,7 +5,7 @@ import { CheckCircle2, Eye, FileUp, Undo2 } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { changeRelabelOrderStatus, confirmRelabelCompletion, getRelabelReviewTask, returnRelabelForRework } from '../api/relabelOrders'
|
||||
import { changeRelabelOrderStatus, confirmRelabelCompletion, getRelabelReviewTask, returnRelabelForRework, getRelabelQuantities, type RelabelQuantity } from '../api/relabelOrders'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
@@ -21,6 +21,11 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
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)
|
||||
const access = useAccessPreviewStore()
|
||||
const returning = ref(false)
|
||||
@@ -65,7 +70,7 @@ const previewTitle = ref('')
|
||||
const previewOpen = ref(false)
|
||||
const groups = [
|
||||
{ title: '单据信息', fields: ['换标单号', '关联订单', '状态', '资金状态', '创建时间', '更新时间'] },
|
||||
{ title: '商品信息', fields: ['商品SKU', '中文品名', '箱数', '预算总价'] },
|
||||
{ title: '商品信息', fields: ['商品SKU', '中文品名', '箱数', '贴标件数', 'SKU种数', '当前关联托盘', '本轮目标托盘', '原托盘快照', '预算总价'] },
|
||||
{ title: '作业要求', fields: ['贴标要求', '上传文件', '备注'] },
|
||||
{ title: '费用明细', fields: ['换标费总计', '资金异常说明'] },
|
||||
]
|
||||
@@ -96,7 +101,12 @@ const fieldValues = computed<Record<string, string | number>>(() => {
|
||||
更新时间: value?.history.length ? formatOverseasDateTime(value.history[value.history.length - 1]?.createdAt) : '—',
|
||||
商品SKU: [...new Set(items.flatMap(relabelSkuCodes))].join('、') || '—',
|
||||
中文品名: items.map((item) => text(item.productNameZh)).join('、') || '—',
|
||||
箱数: Number(value?.application.totalPackageCount || 0),
|
||||
箱数: quantity.value?.cartonCount ?? '未提供',
|
||||
贴标件数: 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('、')}` : '未提供/不适用',
|
||||
原托盘快照: '未提供(当前关联托盘不代表换标前快照)',
|
||||
预算总价: '—',
|
||||
贴标要求: items.map((item) => text(item.labelRequirement)).join(';') || '—',
|
||||
上传文件: items.map((item) => text(item.relabelFileName)).filter((item) => item !== '—').join('、') || '—',
|
||||
@@ -108,7 +118,24 @@ const fieldValues = computed<Record<string, string | number>>(() => {
|
||||
async function load() {
|
||||
if (!orderId.value) { loadError.value = { message: '详情缺少换标单号,请从换标单列表重新进入' }; return }
|
||||
loading.value = true; loadError.value = null
|
||||
try { detail.value = await getRelabelOrder(orderId.value) }
|
||||
try {
|
||||
detail.value = await getRelabelOrder(orderId.value)
|
||||
quantity.value = null; quantityMessage.value = ''; currentPallets.value = []; targetPallets.value = []; sourceMissing.value = false
|
||||
try {
|
||||
const result = await getRelabelQuantities({ page: 1, size: 1, relabelNo: orderId.value })
|
||||
quantity.value = result.orders[orderId.value] || null
|
||||
if (quantity.value?.duplicateCarton) quantityMessage.value = '该单存在重复箱唛关联,箱数已按箱唛去重,贴标件数待核查。'
|
||||
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, '换标单详情加载失败,请重试') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
@@ -145,6 +172,7 @@ async function previewCompletionPhoto(fileId: string) {
|
||||
<section v-if="access.can(permission.relabelOrderStatus) && ['CUSTOMER_CONFIRM_PENDING', 'PROCESSING'].includes(detail?.application.status || '') && detail?.completionEvidence?.photoFileIds?.length" class="relabel-document-panel relabel-review-actions"><el-button v-if="detail?.application.status === 'CUSTOMER_CONFIRM_PENDING'" type="primary" :loading="confirming" @click="confirmCompletion"><CheckCircle2 :size="15" />确认换标完成</el-button><el-button type="warning" :loading="returning" @click="returnForRework"><Undo2 :size="15" />退回重新贴标</el-button></section>
|
||||
<RelabelDocumentsPanel v-if="detail" :relabel-no="detail.application.relabelNo" :detail="detail" /><section class="relabel-document-panel" aria-label="PDA换标完工证据"><div class="relabel-mark-list__head"><h2>PDA 换标完工证据</h2><span>{{ detail?.completionEvidence?.reviewerName ? `复核人:${detail.completionEvidence.reviewerName}` : '等待仓库作业' }}</span></div><div v-if="detail?.completionEvidence?.photoFileIds?.length" class="document-row"><strong>共 {{ detail.completionEvidence.photoFileIds.length }} 张完工照片</strong><span class="document-actions density-actions"><el-button class="density-action" data-tone="neutral" v-for="(fileId, index) in detail.completionEvidence.photoFileIds" :key="fileId" size="small" @click="previewCompletionPhoto(fileId)"><Eye :size="14" />照片 {{ index + 1 }}</el-button></span></div><div v-else class="attachment-empty">暂未上传完工照片</div></section>
|
||||
<section class="relabel-document-panel" aria-label="总换标文件"><div class="relabel-mark-list__head"><h2>总换标 PDF</h2><label class="file-picker"><FileUp :size="15" />{{ uploading ? '上传中' : (detail?.application.totalFileName ? '更换PDF' : '上传PDF') }}<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'TOTAL')" /></label></div><div v-if="detail?.application.totalFileId" class="document-row"><strong>{{ detail.application.totalFileName }}</strong><el-button class="density-action" data-tone="neutral" size="small" @click="previewPdf(detail.application.totalFileId, detail.application.totalFileName || '总换标PDF', 'TOTAL')"><Eye :size="14" />直接预览</el-button></div><div v-else class="attachment-empty">暂未上传总换标PDF</div></section>
|
||||
<p v-if="quantityMessage" role="status">{{ quantityMessage }}</p>
|
||||
<section class="relabel-mark-list" aria-label="箱唛换标信息">
|
||||
<div class="relabel-mark-list__head"><h2>箱唛换标信息</h2><span>共 {{ detail?.items.length || 0 }} 条</span></div>
|
||||
<div class="relabel-mark-list__scroll">
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
createRelabelOrder,
|
||||
getRelabelOrder,
|
||||
listRelabelOrders,
|
||||
getRelabelQuantities,
|
||||
listRelabelWarehouseOptions,
|
||||
} from '../api/relabelOrders'
|
||||
import RelabelOrdersView from './RelabelOrdersView.vue'
|
||||
@@ -21,6 +22,7 @@ vi.mock('../api/relabelOrders', () => ({
|
||||
createRelabelOrder: vi.fn(),
|
||||
getRelabelOrder: vi.fn(),
|
||||
listRelabelOrders: vi.fn(),
|
||||
getRelabelQuantities: vi.fn(),
|
||||
listRelabelWarehouseOptions: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -62,6 +64,7 @@ describe('RelabelOrdersView', () => {
|
||||
expect(wrapper.text()).toContain('贴标要求111')
|
||||
})
|
||||
beforeEach(() => {
|
||||
vi.mocked(getRelabelQuantities).mockReset().mockResolvedValue({ orderCount: 0, skuCount: 0, knownLabelPieceCount: 0, unknownPieceOrders: 0, knownCartonCount: 0, unknownCartonOrders: 0, orders: {} })
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('localStorage', memoryStorage())
|
||||
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
|
||||
|
||||
@@ -15,14 +15,15 @@ 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 AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { changeRelabelOrderStatus, createRelabelOrder, getRelabelOrder, listRelabelOrders, listRelabelWarehouseOptions, type RelabelWarehouseOptionDto } from '../api/relabelOrders'
|
||||
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 type { RelabelOrderListItem } from '../types'
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const router = useRouter()
|
||||
const rows = ref<RelabelOrderListItem[]>([])
|
||||
type RelabelRow = Omit<RelabelOrderListItem, 'cartons'> & { cartons: string; labelPieces: string; skuCount: string }
|
||||
const rows = ref<RelabelRow[]>([])
|
||||
const loading = ref(false)
|
||||
const voiding = ref('')
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
@@ -33,11 +34,15 @@ const warehousesLoading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const quantities = ref<RelabelQuantityTotals | null>(null)
|
||||
const quantityError = ref('')
|
||||
function dimension(value: number | null | undefined) { return value == null ? '未提供' : String(value) }
|
||||
function knownDimension(value: number, unknown: number, unit: string) { return Number(value) === 0 && Number(unknown) > 0 ? `${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 },
|
||||
{ key: 'sku', type: 'input', label: '商品SKU', placeholder: '请输入商品SKU', width: 180 },
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '换标完成', value: 'COMPLETED' }, { label: '已作废', value: 'CANCELLED' }] },
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '待客户确认', value: 'CUSTOMER_CONFIRM_PENDING' }, { label: '已驳回', value: 'REJECTED' }, { label: '换标完成', value: 'COMPLETED' }, { label: '已作废', value: 'CANCELLED' }] },
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
|
||||
@@ -46,6 +51,8 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'sku', label: '商品SKU', minWidth: 150 },
|
||||
{ key: 'productName', label: '中文品名', minWidth: 180 },
|
||||
{ key: 'cartons', label: '箱数', width: 90, align: 'right' },
|
||||
{ key: 'labelPieces', label: '贴标件数', width: 110, align: 'right' },
|
||||
{ key: 'skuCount', label: 'SKU种数', width: 100, align: 'right' },
|
||||
{ key: 'budgetTotal', label: '预算总价', width: 120, align: 'right' },
|
||||
{ key: 'relabelCost', label: '换标费用', width: 120, align: 'right' },
|
||||
{ key: 'walletStatus', label: '资金状态', width: 120 },
|
||||
@@ -61,7 +68,7 @@ const detailLoading = ref(false)
|
||||
const editSourceStatus = 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 createIdempotencyKey = ref('')
|
||||
const selectedRows = ref<RelabelOrderListItem[]>([])
|
||||
const selectedRows = ref<RelabelRow[]>([])
|
||||
const allowedStatusOptions = computed(() => {
|
||||
if (editSourceStatus.value === 'PENDING_REVIEW') return ['PENDING_PROCESSING', 'REJECTED']
|
||||
if (editSourceStatus.value === 'PENDING_PROCESSING') return ['PROCESSING', 'REJECTED', 'CANCELLED']
|
||||
@@ -77,7 +84,7 @@ function openPackingPlan() {
|
||||
void router.push({ name: 'overseas-return-inventory', query: { packing: '1', ...(customerIds[0] ? { customerId: customerIds[0] } : {}) } })
|
||||
}
|
||||
async function openForm(mode: 'create' | 'edit', source?: object) {
|
||||
const row = source as RelabelOrderListItem | undefined
|
||||
const row = source as RelabelRow | undefined
|
||||
dialogMode.value = mode
|
||||
Object.assign(form, { orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined, budgetTotal: undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
|
||||
editSourceStatus.value = ''
|
||||
@@ -173,21 +180,23 @@ async function submitForm() {
|
||||
}
|
||||
|
||||
function openDetail(source: object) {
|
||||
const row = source as RelabelOrderListItem
|
||||
const row = source as RelabelRow
|
||||
void router.push({ name: 'overseas-relabel-order-detail', params: { orderId: String(row.orderNo) } })
|
||||
}
|
||||
|
||||
function relabelStatus(value: unknown) {
|
||||
if (value === 'REJECTED') return '已驳回'
|
||||
if (value === 'PENDING_REVIEW') return '待审核'
|
||||
if (value === 'CANCELLED') return '已作废'
|
||||
if (value === 'CUSTOMER_CONFIRM_PENDING') return '待客户确认'
|
||||
return relabelBusinessStatusLabel(value)
|
||||
}
|
||||
function canVoid(source: object) {
|
||||
const row = source as RelabelOrderListItem
|
||||
const row = source as RelabelRow
|
||||
return !['READY_FOR_OUTBOUND', 'COMPLETED', 'CANCELLED'].includes(row.rawStatus || relabelStatusCode(row.status))
|
||||
}
|
||||
async function voidOrder(source: object) {
|
||||
const row = source as RelabelOrderListItem
|
||||
const row = source as RelabelRow
|
||||
if (voiding.value || !canVoid(row) || !access.can(permission.relabelOrderStatus)) return
|
||||
voiding.value = String(row.orderNo)
|
||||
try {
|
||||
@@ -213,7 +222,7 @@ function relabelStatusCode(value: unknown) {
|
||||
}
|
||||
function walletStatus(value: unknown) { return ({ FREEZE_PENDING: '冻结中', FROZEN: '已冻结', SETTLE_PENDING: '结算中', SETTLED: '已结算', RELEASE_PENDING: '释放中', RELEASED: '已释放', FREEZE_FAILED: '冻结失败', SETTLE_FAILED: '结算失败', RELEASE_FAILED: '释放失败' } as Record<string,string>)[String(value || '')] || String(value || '—') }
|
||||
function canAudit(source: object) {
|
||||
const row = source as RelabelOrderListItem
|
||||
const row = source as RelabelRow
|
||||
return row.reviewChannel !== 'TAIL_ORDER'
|
||||
&& (row.rawStatus || relabelStatusCode(row.status)) === 'PENDING_REVIEW'
|
||||
}
|
||||
@@ -221,10 +230,14 @@ async function load(model: QueryModel = filters.value) {
|
||||
Object.assign(filters.value, model)
|
||||
loading.value = true; loadError.value = null
|
||||
try {
|
||||
const result = await listRelabelOrders({ page: page.value, size: pageSize.value, relabelNo: String(filters.value.relabelNo || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined })
|
||||
const query = { page: page.value, size: pageSize.value, relabelNo: String(filters.value.relabelNo || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined }
|
||||
quantities.value = null; quantityError.value = ''
|
||||
const result = await listRelabelOrders(query)
|
||||
try { quantities.value = await getRelabelQuantities(query) }
|
||||
catch { quantityError.value = '数量汇总加载失败,请重试;未将当前页作为全部合计' }
|
||||
rows.value = result.records.map((item) => ({
|
||||
customerId: '', orderNo: String(item.relabelNo), relatedOrderNo: String(item.lastlegOrderNo || item.customerOrderNo || '—'), sku: String(item.skuCodes || '—'),
|
||||
productName: item.productNameZh?.trim() || '—', cartons: Number(item.totalPackageCount || 0), budgetTotal: null, relabelCost: Number(item.chargeAmount || 0), walletStatus: walletStatus(item.walletStatus), walletErrorMessage: item.walletErrorMessage || '', labelRequirement: item.labelRequirement?.trim() || '—', fileName: '—', status: relabelStatus(item.status), rawStatus: String(item.status || ''), remark: '',
|
||||
productName: item.productNameZh?.trim() || '—', cartons: dimension(quantities.value?.orders[item.relabelNo]?.cartonCount), labelPieces: dimension(quantities.value?.orders[item.relabelNo]?.labelPieceCount), skuCount: dimension(quantities.value?.orders[item.relabelNo]?.skuCount), budgetTotal: null, relabelCost: Number(item.chargeAmount || 0), walletStatus: walletStatus(item.walletStatus), walletErrorMessage: item.walletErrorMessage || '', labelRequirement: item.labelRequirement?.trim() || '—', fileName: '—', status: relabelStatus(item.status), rawStatus: String(item.status || ''), remark: '',
|
||||
reviewChannel: item.reviewChannel || undefined,
|
||||
createdAt: formatOverseasDateTime(item.createdAt),
|
||||
}))
|
||||
@@ -243,6 +256,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><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>
|
||||
@@ -280,6 +294,7 @@ 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); }
|
||||
.el-form :deep(.el-row) { margin-left: 0 !important; margin-right: 0 !important; }
|
||||
|
||||
@@ -56,6 +56,24 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('ReturnInventoryManagementView shared list', () => {
|
||||
it('shows discard deductions in stock movements and the physical carton history', async () => {
|
||||
vi.mocked(listReturnInventoryMovements).mockResolvedValue({ records: [{ ...stock, movementType: 'DISCARD', referenceNo: 'CD-TEST-1', totalDelta: -5, availableDelta: -5, occurredAt: '2026-10-19T17:00:00' }], page: 1, size: 20, total: 1, pages: 1 })
|
||||
const inventory = await mountView('inventory')
|
||||
const tabs = inventory.getComponent({ name: 'ElTabs' })
|
||||
tabs.vm.$emit('update:modelValue', 'movements'); tabs.vm.$emit('tab-change', 'movements')
|
||||
await flushPromises()
|
||||
expect(inventory.text()).toContain('货物废弃')
|
||||
expect(inventory.text()).toContain('CD-TEST-1')
|
||||
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')
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('废弃扣减明细')
|
||||
expect(document.body.textContent).toContain('扣减前(件)')
|
||||
expect(document.body.textContent).toContain('扣减后(件)')
|
||||
expect(document.body.textContent).toContain('CD-TEST-1')
|
||||
})
|
||||
it('退货库存展示服务端换标占用列', async () => {
|
||||
const wrapper = await mountView('inventory')
|
||||
const columns = wrapper.findComponent(DataTable).props('columns')
|
||||
|
||||
@@ -679,7 +679,7 @@ const tableColumns = computed(() =>
|
||||
warehouseName(row.overseasWarehouseId)
|
||||
}}</template>
|
||||
<template #movement-4="{ row }">{{
|
||||
({ RECEIPT: "退货签收", MERGE: "退货合箱", OUTBOUND: "退货出库",
|
||||
({ RECEIPT: "退货签收", MERGE: "退货合箱", OUTBOUND: "退货出库", DISCARD: "货物废弃",
|
||||
SKU_CONVERSION_OUT: "SKU换标", SKU_CONVERSION_IN: "SKU换标",
|
||||
SKU_CONVERSION_REVERSE_IN: "SKU换标撤销", SKU_CONVERSION_REVERSE_OUT: "SKU换标撤销"
|
||||
} as Record<string, string>)[row.movementType] || row.movementType || "—"
|
||||
@@ -959,6 +959,16 @@ const tableColumns = computed(() =>
|
||||
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" />
|
||||
|
||||
Reference in New Issue
Block a user