This commit is contained in:
2026-10-20 14:00:49 +08:00
parent 3879a47673
commit 5679b156ca
28 changed files with 803 additions and 515 deletions
+3 -2
View File
@@ -5,8 +5,9 @@
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5173",
"build": "vue-tsc --noEmit && vite build",
"typecheck": "vue-tsc --noEmit",
"build": "vue-tsc --noEmit -p tsconfig.app.json && vite build",
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json",
"typecheck:all": "vue-tsc --noEmit -p tsconfig.json",
"test": "vitest run",
"test:phase4:test": "node scripts/phase4-test-frontend-acceptance.mjs",
"preview": "vite preview --port 4173"
@@ -2,7 +2,7 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
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 CargoDiscardSummary { skuCodes?: string[]; skuCount?: number; requestedPackages?: number; actualPackages?: number | null; 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 { 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) {
@@ -42,6 +42,36 @@ export interface InventoryDashboardDto {
procurementPackages: number
}
export interface InventoryPieceGroup {
customerId: string
warehouseId: string
destinationCode: string | null
anchorSkuCode: string | null
availablePieceCount: number | string | null
knownAvailablePieceCount: number | string
unknownCartonCount: number | string
availableCartonCount: number | string
availablePalletCount: number | string
skuContents: { skuCode: string; quantity: number | string }[]
cartons: { cartonMark: string; inventoryPositionId: string; palletId: string | null; quantity: number | string | null; items: { skuCode: string; quantity: number | string }[] }[]
}
export interface InventoryPieceProjection {
availablePieceCount: number | string | null
knownAvailablePieceCount: number | string
unknownCartonCount: number | string
availableCartonCount: number | string
availablePalletCount: number | string
groups: InventoryPieceGroup[]
}
/** 完整筛选的真实可用件数及混箱全部SKU,不按记账SKU换算。 */
export async function getInventoryPieceProjection(query: InventoryBalanceQuery) {
const response = await http.get<ApiEnvelope<InventoryPieceProjection>, ApiEnvelope<InventoryPieceProjection>>(
'/platform/v1/last-leg/inventory/piece-projection', { params: params(query) },
)
return unwrapApiResponse(response)
}
export interface InventoryPositionDto {
id: string
balanceId: string
@@ -51,8 +51,17 @@ export interface LastMileBusinessSource {
businessOrderNo: string | null
externalBookingNo: string | null
}
export interface ReturnOrderStatistics {
totals: { orderCount: number; skuTypes: number; expectedQuantity: number; receivedQuantity: number; remainingQuantity: number; receivedBoxCount: number; receivedParcelCount: number; receivedPalletCount: number }
pageDimensions: Array<{ returnNo: string; lastlegOrderId: string; expectedQuantity: number; receivedQuantity: number; remainingQuantity: number; receivedBoxCount: number; receivedParcelCount: number; receivedPalletCount: number }>
}
export async function getReturnOrderStatistics(params: LastMileOrderQuery) {
const response = await http.get<ApiEnvelope<ReturnOrderStatistics>, ApiEnvelope<ReturnOrderStatistics>>('/platform/v1/last-leg/return-orders/statistics', { params })
return unwrapApiResponse(response)
}
export interface LastMileOrderSummaryDto {
warehouseTransferQuantity?: import('./transferQuantityFacts').TransferQuantityFacts | null
businessSource?: LastMileBusinessSource | null
id: string
customerId: string
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { listSharedReceivingOverseasWarehouseOptions } from '@/modules/iam'
import { http } from '@/shared/api/http'
import { changeRelabelOrderStatus, createRelabelOrder, getRelabelOrder, listRelabelOrders, listRelabelWarehouseOptions } from './relabelOrders'
import { changeRelabelOrderStatus, createRelabelOrder, getRelabelOrder, listRelabelOrders, listRelabelWarehouseOptions, type CreateRelabelPayload } from './relabelOrders'
vi.mock('@/modules/iam', () => ({ listSharedReceivingOverseasWarehouseOptions: vi.fn() }))
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
@@ -29,7 +29,7 @@ describe('relabel overseas API', () => {
it('uses the real create and status endpoints', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: {} })
vi.mocked(http.put).mockResolvedValue({ code: '200', data: {} })
const payload = { customerOrderNo: 'CO-1', overseasWarehouseId: '9', labelDeadline: '2026-08-20T10:00:00', contactName: 'A', contactPhone: '1', chargeAmount: 20, idempotencyKey: 'k', items: [{ skuCode: 'SKU', productNameZh: 'P', totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: 'S', newSkuCode: 'SKU-2' }] }
const payload: CreateRelabelPayload = { customerOrderNo: 'CO-1', operationWarehouseId: '9', labelDeadline: '2026-08-20T10:00:00', contactName: 'A', contactPhone: '1', chargeAmount: 20, idempotencyKey: 'k', items: [{ sourceInventoryBalanceId: '11', sourceInventoryPositionId: '12', sourceInboundOrderId: '13', cartonMark: 'BOX', operationType: 'SKU_LABEL', skuItems: [{ skuCode: 'SKU', newSkuCode: 'SKU-2', quantity: 8 }], skuCode: 'SKU', productNameZh: 'P', totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: 'S', newSkuCode: 'SKU-2' }] }
await createRelabelOrder('10001', payload)
await changeRelabelOrderStatus('RL/1', 'PROCESSING', 'ok')
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications', payload, { params: { customerId: '10001' } })
@@ -12,6 +12,15 @@ export interface RelabelOrderQuery {
}
export interface CreateRelabelItemPayload {
sourceInventoryBalanceId?: string
sourceInventoryPositionId?: string
sourceInboundOrderId?: string
returnCartonId?: string
cartonMark: string
fbaCartonMark?: string
destinationFbaWarehouseCode?: string
operationType: 'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU'
skuItems: Array<{ skuCode: string; newSkuCode: string; quantity: number; remark?: string }>
skuCode: string
productNameZh: string
totalPackageCount: number
@@ -30,9 +39,9 @@ export interface CreateRelabelItemPayload {
export interface CreateRelabelPayload {
customerOrderNo: string
operationWarehouseId?: string
/** @deprecated compatibility alias for older platform fixtures */
overseasWarehouseId?: string
operationWarehouseId: string
deliveryMethod?: 'TRUCK' | 'EXPRESS' | 'PICKUP' | 'AMAZON_PICKUP' | 'CUSTOMER_PICKUP'
printShippingLabel?: boolean
labelDeadline: string
contactName: string
contactPhone: string
@@ -131,6 +140,7 @@ export interface RelabelDetailDto {
}
export interface RelabelQuantity {
sourceUnitCounts?: Record<string, number | string> | null
labelPieceCount: number | string | null
cartonCount: number | string | null
skuCount: number | string
@@ -140,6 +150,10 @@ export interface RelabelQuantity {
targetPalletCount: number | string | null
}
export interface RelabelQuantityTotals {
knownSourcePalletCount?: number | string
unknownSourcePalletOrders?: number | string
knownTargetPalletCount?: number | string
unknownTargetPalletOrders?: number | string
orderCount: number | string
skuCount: number | string
knownLabelPieceCount: number | string
@@ -148,6 +162,26 @@ export interface RelabelQuantityTotals {
unknownCartonOrders: number | string
orders: Record<string, RelabelQuantity>
}
export interface RelabelInventoryCandidate {
inventoryBalanceId?: string | null
inboundOrderId?: string | null
inboundOrderNo: string
inboundCode?: string
skuCode: string
productNameZh: string
availablePackages: number | string
sourceType: string
returnCartonId?: string | null
cartonMark?: string | null
cartonAvailablePackages?: number | string
cartonSkuItems?: Array<{ skuCode: string; quantity: number }>
positions: Array<{ positionId: string; palletNo: string; availablePackages: number | string }>
}
/** 候选归属和可用量由服务端决定,长整型来源ID按字符串原样提交。 */
export async function listRelabelInventoryCandidates(customerId: string, operationWarehouseId: string) {
const response = await http.get<ApiEnvelope<RelabelInventoryCandidate[]>, ApiEnvelope<RelabelInventoryCandidate[]>>('/platform/v1/last-leg/relabel-applications/inventory-candidates', { params: { customerId, operationWarehouseId } })
return unwrapApiResponse(response)
}
/** 汇总同一筛选下全部单据,不以当前页合计代替。 */
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 })
@@ -0,0 +1,26 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { LastMileOrderQuery } from './lastMileOrders'
export interface TransferQuantityFacts {
declaredPieceCount: number | string | null
receivedPieceCount: number | string | null
declaredCartonCount: number | string
receivedCartonCount: number | string
receivedPalletCount: number | string
availableCartonCount: number | string
shippedCartonCount: number | string
remainingPalletCount: number | string
shippedPalletCount: number | string
}
export interface TransferQuantityTotals extends TransferQuantityFacts {
orderCount: number | string
knownDeclaredPieceCount: number | string
unknownDeclaredOrderCount: number | string
knownReceivedPieceCount: number | string
unknownReceivedOrderCount: number | string
}
export async function getTransferQuantityTotals(query: LastMileOrderQuery) {
const response = await http.get<ApiEnvelope<TransferQuantityTotals>, ApiEnvelope<TransferQuantityTotals>>(
'/platform/v1/last-leg/orders/warehouse-transfer-quantities', { params: query },
)
return unwrapApiResponse(response)
}
@@ -0,0 +1,27 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { listRelabelInventoryCandidates } from '../api/relabelOrders'
import RelabelInventoryItemsEditor from './RelabelInventoryItemsEditor.vue'
vi.mock('../api/relabelOrders', () => ({ listRelabelInventoryCandidates: vi.fn() }))
describe('formal relabel source tasks', () => {
it('submits one carton with long source IDs and independently editable SKU pieces; warehouse change clears it', async () => {
vi.mocked(listRelabelInventoryCandidates).mockResolvedValue([{ inventoryBalanceId: '9007199254740993', inboundOrderId: '9007199254740994', inboundOrderNo: 'IN-1', skuCode: 'A', productNameZh: '商品', availablePackages: 1, sourceType: 'INBOUND', positions: [{ positionId: '9007199254740995', palletNo: 'P1', availablePackages: 1 }] }])
const wrapper = mount(RelabelInventoryItemsEditor, { props: { customerId:'C', warehouseId:'W',operationType:'CARTON_AND_SKU',shipmentNo:'S',destination:'SCK8',requirement:'要求' }, global:{plugins:[ElementPlus]} })
await flushPromises()
wrapper.findComponent({name:'ElSelect'}).vm.$emit('update:modelValue','inventory:9007199254740995')
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '添加箱任务')!.trigger('click'); await flushPromises()
await wrapper.findAll('input').find(input => input.attributes('maxlength') === '255')!.setValue('BOX-1')
await wrapper.get('input[aria-label="新 SKU"]').setValue('B')
wrapper.findComponent({name:'ElInputNumber'}).vm.$emit('update:modelValue',8)
await flushPromises()
const items = wrapper.emitted('update:items')!.at(-1)![0] as Array<Record<string, unknown>>
expect(items).toMatchObject([{sourceInventoryBalanceId:'9007199254740993',sourceInventoryPositionId:'9007199254740995',sourceInboundOrderId:'9007199254740994',cartonMark:'BOX-1',totalPackageCount:1,notifiedPackageCount:1,destinationFbaWarehouseCode:'SCK8',skuItems:[{skuCode:'A',newSkuCode:'B',quantity:8}]}])
expect(items[0]).not.toHaveProperty('sourceLabel')
await wrapper.setProps({warehouseId:'OTHER'}); await flushPromises()
expect(wrapper.emitted('update:items')!.at(-1)![0]).toEqual([])
expect(listRelabelInventoryCandidates).toHaveBeenLastCalledWith('C','OTHER')
wrapper.unmount()
})
})
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { listRelabelInventoryCandidates, type CreateRelabelItemPayload, type RelabelInventoryCandidate } from '../api/relabelOrders'
const props = defineProps<{ customerId: string; warehouseId: string; operationType: CreateRelabelItemPayload['operationType']; shipmentNo: string; destination: string; requirement: string }>()
const emit = defineEmits<{ 'update:items': [CreateRelabelItemPayload[]] }>()
const candidates = ref<RelabelInventoryCandidate[]>([]), selected = ref(''), loading = ref(false), error = ref('')
const tasks = ref<Array<CreateRelabelItemPayload & { sourceLabel: string; sourceKey: string; available: number }>>([])
let generation = 0
type SourceOption = { key: string; label: string; candidate: RelabelInventoryCandidate; position: RelabelInventoryCandidate['positions'][number] | undefined; available: number }
const options = computed(() => candidates.value.flatMap<SourceOption>(candidate => candidate.returnCartonId
? Number(candidate.cartonAvailablePackages) > 0 ? [{ key: `return:${candidate.returnCartonId}`, label: `${candidate.inboundOrderNo} · ${candidate.cartonMark}`, candidate, position: undefined, available: 1 }] : []
: candidate.positions.map(position => ({ key: `inventory:${position.positionId}`, label: `${candidate.inboundOrderNo} · ${candidate.skuCode} · 记账托盘 ${position.palletNo || '未提供'} · 可用 ${position.availablePackages} 箱`, candidate, position, available: Number(position.availablePackages) }))))
/** 客户或仓库切换立即清空旧来源,异步返回只接收最后一次请求。 */
async function load() {
const current = ++generation
candidates.value = []; selected.value = ''; tasks.value = []; error.value = ''
if (!props.customerId || !props.warehouseId) { loading.value = false; return }
loading.value = true
try { const result = await listRelabelInventoryCandidates(props.customerId, props.warehouseId); if (current === generation) candidates.value = result }
catch { if (current === generation) error.value = '库存来源加载失败,请重试' }
finally { if (current === generation) loading.value = false }
}
watch(() => [props.customerId, props.warehouseId], load, { immediate: true })
watch(() => props.operationType, () => { tasks.value = [] })
function add() {
const option = options.value.find(item => item.key === selected.value)
if (!option) return
if (tasks.value.filter(item => item.sourceKey === option.key).length >= option.available) { error.value = '已达到该来源可用箱数'; return }
const source = option.candidate
tasks.value.push({ sourceLabel: option.label, sourceKey: option.key, available: option.available,
sourceInventoryBalanceId: source.inventoryBalanceId || undefined, sourceInventoryPositionId: option.position?.positionId,
sourceInboundOrderId: source.inboundOrderId || undefined, returnCartonId: source.returnCartonId || undefined,
skuCode: source.skuCode, productNameZh: source.productNameZh || source.skuCode,
totalPackageCount: 1, notifiedPackageCount: 1, cartonMark: source.cartonMark || '', fbaCartonMark: '',
operationType: props.operationType, shipmentNo: props.shipmentNo, newSkuCode: '',
skuItems: props.operationType === 'CARTON_LABEL' ? [] : (source.cartonSkuItems?.length ? source.cartonSkuItems : [{ skuCode: source.skuCode, quantity: 1 }]).map(item => ({ skuCode: item.skuCode, newSkuCode: '', quantity: item.quantity })) })
error.value = ''
}
/** 每箱任务数量固定为一,SKU子项单独按件提交,额外展示字段不进入正式请求。 */
watch(() => [tasks.value, props.shipmentNo, props.destination, props.requirement], () => {
emit('update:items', tasks.value.map(({ sourceLabel: _label, sourceKey: _key, available: _available, ...item }) => ({ ...item,
shipmentNo: props.shipmentNo.trim(), labelRequirement: props.requirement.trim() || undefined,
destinationFbaWarehouseCode: props.operationType === 'SKU_LABEL' ? undefined : props.destination.trim(),
newSkuCode: item.skuItems.map(sku => sku.newSkuCode).join(','),
skuItems: props.operationType === 'CARTON_LABEL' ? [] : item.skuItems })))
}, { deep: true, immediate: true })
</script>
<template>
<section class="density-dialog-section" data-tone="source" aria-label="库存与箱任务">
<h3 class="density-dialog-section__title">库存与箱任务({{ tasks.length }} 箱)</h3>
<p class="density-dialog-note">普通入库托盘仅为冻结记账位置;每箱填写一个我方箱唛,SKU 数量按件填写,实际来源托盘由 PDA 现场确认。</p>
<el-select v-model="selected" filterable :loading="loading" :disabled="!customerId || !warehouseId" placeholder="请选择正式库存来源">
<el-option v-for="option in options" :key="option.key" :label="option.label" :value="option.key" />
</el-select>
<el-button :disabled="!selected || loading" @click="add">添加箱任务</el-button>
<p v-if="error" role="alert">{{ error }} <el-button link @click="load">重试加载</el-button></p>
<section v-for="(item, index) in tasks" :key="index" class="density-dialog-section">
<p>{{ index + 1 }}. {{ item.sourceLabel }} <el-button link type="danger" @click="tasks.splice(index, 1)">移除</el-button></p>
<el-form-item label="我方箱唛" required><el-input v-model="item.cartonMark" :readonly="Boolean(item.returnCartonId)" maxlength="255" placeholder="请输入来源箱的我方箱唛" /></el-form-item>
<el-form-item label="中文品名" required><el-input v-model="item.productNameZh" maxlength="255" placeholder="请输入中文品名" /></el-form-item>
<el-form-item v-if="operationType !== 'SKU_LABEL'" label="新 FBA 箱唛" required><el-input v-model="item.fbaCartonMark" maxlength="255" placeholder="请输入新 FBA 箱唛" /></el-form-item>
<table v-if="operationType !== 'CARTON_LABEL'" class="density-table density-table--editable"><thead><tr><th>旧 SKU</th><th>新 SKU</th><th>件数</th><th>操作</th></tr></thead><tbody>
<tr v-for="(sku, skuIndex) in item.skuItems" :key="skuIndex"><td><el-input v-model="sku.skuCode" aria-label="旧 SKU" placeholder="请输入旧 SKU" /></td><td><el-input v-model="sku.newSkuCode" aria-label="新 SKU" placeholder="请输入新 SKU" /></td><td><el-input-number v-model="sku.quantity" :min="1" :precision="0" aria-label="SKU 件数" placeholder="请输入件数" /></td><td><el-button link @click="item.skuItems.splice(skuIndex, 1)">移除 SKU</el-button></td></tr>
</tbody></table>
<el-button v-if="operationType !== 'CARTON_LABEL'" @click="item.skuItems.push({ skuCode: '', newSkuCode: '', quantity: 1 })">添加 SKU</el-button>
</section>
<p v-if="!loading && !error && customerId && warehouseId && !options.length" role="status">当前客户及仓库没有可用库存来源。</p>
</section>
</template>
@@ -0,0 +1,28 @@
import { mount, flushPromises } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import TransferQuantitySummary from './TransferQuantitySummary.vue'
import { getTransferQuantityTotals } from '../api/transferQuantityFacts'
vi.mock('../api/transferQuantityFacts', () => ({ getTransferQuantityTotals: vi.fn() }))
describe('转运完整筛选数量', () => {
it('keeps unknown pieces explicit and renders overlapping pallet facts after paging', async () => {
vi.mocked(getTransferQuantityTotals).mockResolvedValue({ orderCount: '2', declaredPieceCount: null, knownDeclaredPieceCount: '16', unknownDeclaredOrderCount: '1', receivedPieceCount: '20', knownReceivedPieceCount: '20', unknownReceivedOrderCount: '0', declaredCartonCount: '4', receivedCartonCount: '4', receivedPalletCount: '3', availableCartonCount: '2', shippedCartonCount: '2', remainingPalletCount: '2', shippedPalletCount: '2' })
const wrapper = mount(TransferQuantitySummary, { props: { query: { page: 1, size: 1, orderType: 'WAREHOUSE_TRANSFER' } } })
await flushPromises()
expect(wrapper.text()).toContain('未记录(已知 16 件,1 票未知)')
expect(wrapper.text()).toContain('20 件 / 4 箱 / 3 托')
expect(wrapper.text()).toContain('剩余 2 托')
expect(wrapper.text()).toContain('2 箱 / 2 托(托盘可重叠)')
await wrapper.setProps({ query: { page: 2, size: 1, orderType: 'WAREHOUSE_TRANSFER' } })
await flushPromises()
expect(wrapper.text()).toContain('全筛选 2 票')
expect(getTransferQuantityTotals).toHaveBeenLastCalledWith({ page: 2, size: 1, orderType: 'WAREHOUSE_TRANSFER' })
})
it('shows a recoverable error instead of zero totals', async () => {
vi.mocked(getTransferQuantityTotals).mockRejectedValue(new Error('offline'))
const wrapper = mount(TransferQuantitySummary, { props: { query: { page: 1, size: 1 } } })
await flushPromises()
expect(wrapper.get('[role="alert"]').text()).toContain('加载失败')
expect(wrapper.text()).not.toContain('全筛选 0')
})
})
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { getTransferQuantityTotals, type TransferQuantityTotals } from '../api/transferQuantityFacts'
import type { LastMileOrderQuery } from '../api/lastMileOrders'
const props = defineProps<{ query: LastMileOrderQuery }>()
const summary = ref<TransferQuantityTotals>()
const error = ref('')
let sequence = 0
watch(() => JSON.stringify(props.query), async () => {
const current = ++sequence
summary.value = undefined; error.value = ''
try { const value = await getTransferQuantityTotals(props.query); if (current === sequence) summary.value = value }
catch { if (current === sequence) error.value = '转运全筛选数量加载失败,请重新查询' }
}, { immediate: true })
</script>
<template>
<div v-if="summary" role="status" aria-label="仓储转运全筛选汇总">
<strong>仓储转运 · 全筛选 {{ summary.orderCount }} 票</strong> ·
预报 {{ summary.declaredPieceCount ?? `未记录(已知 ${summary.knownDeclaredPieceCount} 件,${summary.unknownDeclaredOrderCount} 票未知)` }}{{ summary.declaredPieceCount == null ? '' : ' 件' }} / {{ summary.declaredCartonCount }} 箱 ·
历史实收 {{ summary.receivedPieceCount ?? `未记录(已知 ${summary.knownReceivedPieceCount} 件,${summary.unknownReceivedOrderCount} 票未知)` }}{{ summary.receivedPieceCount == null ? '' : ' 件' }} / {{ summary.receivedCartonCount }} 箱 / {{ summary.receivedPalletCount }} 托 ·
当前已清点 {{ summary.availableCartonCount }} 箱 · 剩余 {{ summary.remainingPalletCount }} 托 · 已出库 {{ summary.shippedCartonCount }} 箱 / {{ summary.shippedPalletCount }} 托(托盘可重叠)
</div>
<p v-else-if="error" role="alert">{{ error }}</p>
</template>
@@ -183,7 +183,9 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
cartons: metric(dto.packageCount),
cartonDifference: warehouseTransfer ? metric(dto.packageDifference) : '—',
volume: metric(dto.volumeCbm),
quantity: metric(dto.productQuantity),
quantity: warehouseTransfer ? (dto.warehouseTransferQuantity?.receivedPieceCount ?? '未记录') : metric(dto.productQuantity),
declaredPieces: warehouseTransfer ? (dto.warehouseTransferQuantity?.declaredPieceCount ?? '未记录') : '—',
receivedPieces: warehouseTransfer ? (dto.warehouseTransferQuantity?.receivedPieceCount ?? '未记录') : '—',
volumeWeight: metric(dto.volumeWeightKg),
portEta: formatOverseasDateTime(dto.estimatedArrivalAt),
portAta: formatOverseasDateTime(dto.actualArrivalAt),
@@ -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());
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) {
try {
detail.value = await getReturnCarton(mark);
detailOpen.value = true;
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : "箱号详情加载失败");
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 {
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" />
<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" />
<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>
<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" />
</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" />
</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-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 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" />
<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></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>
</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" />
<el-table-column prop="skuCode" label="SKU" 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>合箱明细</h3>
<el-table :data="detail.mergeHistory || []" class="detail-items" empty-text="暂无合箱记录">
<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 }">{{ row.planNo && row.planNo !== 'null' ? row.planNo : '现场合箱 / 历史未关联' }}</template></el-table-column>
<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" />
<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="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>
</template
></AppDialog>
</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',
+3 -1
View File
@@ -39,7 +39,9 @@ export interface LastMileOrderListItem {
cartons: number | '—'
cartonDifference: number | '—'
volume: number | '—'
quantity: number | '—'
quantity: number | string
declaredPieces?: number | string
receivedPieces?: number | string
volumeWeight: number | '—'
portEta: string
portAta: string
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"exclude": [
"src/**/*.test.ts",
"src/**/*.test.tsx",
"src/**/*.spec.ts",
"src/**/*.spec.tsx"
]
}