This commit is contained in:
wujinliang
2026-09-18 11:41:27 +08:00
parent f2d5b2d8e4
commit 451663b6ff
24 changed files with 156 additions and 30 deletions
@@ -37,7 +37,7 @@ describe('InboundTaskList measurement scope', () => {
expect(source).toContain(':tenant-id="selectedInbound.tenantId"') expect(source).toContain(':tenant-id="selectedInbound.tenantId"')
expect(source).toContain(':customer-id="selectedInbound.tenantId"') expect(source).toContain(':customer-id="selectedInbound.tenantId"')
expect(source).toContain(':customer-name="selectedInbound.customerName || selectedInbound.companyName"') expect(source).toContain(':customer-name="customerDisplayName(selectedInbound)"')
expect(source).toContain(':channel-name="selectedInbound.channelName"') expect(source).toContain(':channel-name="selectedInbound.channelName"')
expect(source).not.toContain('selectedInbound.customerId || selectedInbound.tenantId') expect(source).not.toContain('selectedInbound.customerId || selectedInbound.tenantId')
}) })
@@ -65,6 +65,9 @@ const actionPermission = computed(() => isReview.value
const taskLabel = computed(() => isReview.value ? '审核' : '测量') const taskLabel = computed(() => isReview.value ? '审核' : '测量')
const emptyText = computed(() => isReview.value ? '暂无待审核的测量数据' : '暂无当前可测量的订单') const emptyText = computed(() => isReview.value ? '暂无待审核的测量数据' : '暂无当前可测量的订单')
const preferenceKey = computed(() => isReview.value ? 'inbound-measurement-reviews' : 'inbound-measurements') const preferenceKey = computed(() => isReview.value ? 'inbound-measurement-reviews' : 'inbound-measurements')
const customerNameById = computed(() => new Map(customers.value.map((customer) => [
String(customer.id), customer.legalName?.trim() || customer.tenantName,
])))
const queryFields = computed<QueryField[]>(() => [ const queryFields = computed<QueryField[]>(() => [
{ {
@@ -111,6 +114,11 @@ function text(value: unknown) {
return value === null || value === undefined || value === '' ? '—' : String(value) return value === null || value === undefined || value === '' ? '—' : String(value)
} }
function customerDisplayName(inbound: InboundOrderSummary) {
const snapshot = inbound.customerName?.trim() || inbound.companyName?.trim()
return snapshot || customerNameById.value.get(String(inbound.customerId || inbound.tenantId)) || ''
}
function formatNumber(value: number | null | undefined) { function formatNumber(value: number | null | undefined) {
return value === null || value === undefined ? '—' : value.toLocaleString('zh-CN', { maximumFractionDigits: 3 }) return value === null || value === undefined ? '—' : value.toLocaleString('zh-CN', { maximumFractionDigits: 3 })
} }
@@ -440,6 +448,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
v-if="!isReview && selectedInbound" v-if="!isReview && selectedInbound"
v-model="measurementDialogOpen" v-model="measurementDialogOpen"
:customer-id="selectedInbound.tenantId" :customer-id="selectedInbound.tenantId"
:customer-name="customerDisplayName(selectedInbound)"
:inbound-id="selectedInbound.id" :inbound-id="selectedInbound.id"
:inbound-code="selectedInbound.inboundCode" :inbound-code="selectedInbound.inboundCode"
@updated="handleMeasurementUpdated" @updated="handleMeasurementUpdated"
@@ -40,6 +40,7 @@ describe('InboundWorkflowWorkspace', () => {
expect(source).toContain('@click="reviewDialogOpen = true"') expect(source).toContain('@click="reviewDialogOpen = true"')
expect(source).toContain(':key="`review-dialog-${revision}`"') expect(source).toContain(':key="`review-dialog-${revision}`"')
expect(source).toContain(':customer-name="detail?.summary.customerName || detail?.summary.companyName"') expect(source).toContain(':customer-name="detail?.summary.customerName || detail?.summary.companyName"')
expect(source).toContain(':customer-name="detail?.summary.customerName || detail?.summary.companyName"')
expect(source).toContain(':channel-name="detail?.summary.channelName"') expect(source).toContain(':channel-name="detail?.summary.channelName"')
expect(source).toContain('@changed="handleReviewChanged"') expect(source).toContain('@changed="handleReviewChanged"')
expect(source).not.toContain('<template #footer>') expect(source).not.toContain('<template #footer>')
@@ -197,6 +197,7 @@ onBeforeUnmount(() => {
<InboundMeasurementDialog <InboundMeasurementDialog
v-model="measurementDialogOpen" v-model="measurementDialogOpen"
:customer-id="customerId" :customer-id="customerId"
:customer-name="detail?.summary.customerName || detail?.summary.companyName"
:inbound-id="inboundId" :inbound-id="inboundId"
:inbound-code="title" :inbound-code="title"
@updated="handleDomainChanged" @updated="handleDomainChanged"
@@ -20,6 +20,8 @@ describe('InboundMeasurementDialog', () => {
expect(densitySource).toContain('padding: var(--admin-space-4, 10px) 48px var(--admin-space-4, 10px) var(--admin-space-6, 16px)') expect(densitySource).toContain('padding: var(--admin-space-4, 10px) 48px var(--admin-space-4, 10px) var(--admin-space-6, 16px)')
expect(source).toContain('<WeightOcrPanel') expect(source).toContain('<WeightOcrPanel')
expect(source).toContain('<InboundMeasurementPanel') expect(source).toContain('<InboundMeasurementPanel')
expect(source).toContain('customerName?: string | null')
expect(source).toContain(':customer-name="customerName"')
expect(source).toContain('class="measurement-dialog-weight"') expect(source).toContain('class="measurement-dialog-weight"')
expect(source).not.toContain('class="surface-panel measurement-dialog-weight"') expect(source).not.toContain('class="surface-panel measurement-dialog-weight"')
expect(source).toContain('border-bottom: 1px solid var(--cc-border)') expect(source).toContain('border-bottom: 1px solid var(--cc-border)')
@@ -10,6 +10,7 @@ import WeightOcrPanel from '../weight/components/WeightOcrPanel.vue'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
customerId: string customerId: string
customerName?: string | null
inboundId: string inboundId: string
inboundCode?: string | null inboundCode?: string | null
openWarehouseSuggestion?: boolean openWarehouseSuggestion?: boolean
@@ -92,6 +93,7 @@ function handleSubmitted() {
<InboundMeasurementPanel <InboundMeasurementPanel
ref="measurementPanelRef" ref="measurementPanelRef"
:customer-id="customerId" :customer-id="customerId"
:customer-name="customerName"
:inbound-id="inboundId" :inbound-id="inboundId"
:open-warehouse-suggestion="openWarehouseSuggestion" :open-warehouse-suggestion="openWarehouseSuggestion"
@updated="handleUpdated" @updated="handleUpdated"
@@ -52,6 +52,7 @@ function measurement(overrides: Partial<InboundMeasurementDetail> = {}): Inbound
channelName: '美西海运', channelName: '美西海运',
processStatus: 'MEASURING', processStatus: 'MEASURING',
measureReviewStatus: null, measureReviewStatus: null,
customerFeeConfirmed: false,
recommendedPalletType: 'STANDARD', recommendedPalletType: 'STANDARD',
reviewReason: null, reviewReason: null,
reviewerId: null, reviewerId: null,
@@ -135,7 +136,7 @@ async function mountPanel(permissions = [
'platform:firstleg:inbound:measure:edit', 'platform:firstleg:inbound:measure:edit',
'platform:firstleg:inbound:measure:manual-entry', 'platform:firstleg:inbound:measure:manual-entry',
'platform:firstleg:inbound:measure:submit', 'platform:firstleg:inbound:measure:submit',
]) { ], props: Partial<{ customerId: string; inboundId: string; customerName: string }> = {}) {
const pinia = createPinia() const pinia = createPinia()
setActivePinia(pinia) setActivePinia(pinia)
const auth = useAuthStore() const auth = useAuthStore()
@@ -143,7 +144,7 @@ async function mountPanel(permissions = [
auth.permissions = permissions auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin() useAccessPreviewStore().initializeAfterLogin()
const wrapper = mount(InboundMeasurementPanel, { const wrapper = mount(InboundMeasurementPanel, {
props: { customerId: '10001', inboundId: '7001' }, props: { customerId: '10001', inboundId: '7001', ...props },
attachTo: document.body, attachTo: document.body,
global: { plugins: [pinia, ElementPlus] }, global: { plugins: [pinia, ElementPlus] },
}) })
@@ -195,6 +196,13 @@ describe('InboundMeasurementPanel', () => {
expect(wrapper.find('button[aria-label="预览测量图片"]').exists()).toBe(true) expect(wrapper.find('button[aria-label="预览测量图片"]').exists()).toBe(true)
}) })
it('uses the list customer name when a legacy measurement detail has no customer snapshot', async () => {
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ companyName: '' }))
const wrapper = await mountPanel(undefined, { customerName: '柏悦物流' })
expect(wrapper.find('.measurement-summary-context').text()).toContain('柏悦物流')
})
it('warns when declared dimensions or gross weight differ from measurements by at least ten percent', async () => { it('warns when declared dimensions or gross weight differ from measurements by at least ten percent', async () => {
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({
actualGrossWeightKg: 132, actualGrossWeightKg: 132,
@@ -499,7 +507,7 @@ describe('InboundMeasurementPanel', () => {
await flushPromises() await flushPromises()
expect(ElMessageBox.confirm).toHaveBeenCalledWith( expect(ElMessageBox.confirm).toHaveBeenCalledWith(
expect.stringContaining('准备托盘与库位占用'), expect.stringContaining('提交成功后本批次不能继续编辑'),
'提交测量审核', '提交测量审核',
expect.objectContaining({ confirmButtonText: '确认提交' }), expect.objectContaining({ confirmButtonText: '确认提交' }),
) )
@@ -21,6 +21,7 @@ const props = defineProps<{
customerId?: string customerId?: string
tenantId?: string tenantId?: string
inboundId: string inboundId: string
customerName?: string | null
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -110,6 +111,7 @@ const canEdit = computed(() => access.can('platform:firstleg:inbound:measure:edi
const canManualEntry = computed(() => access.can('platform:firstleg:inbound:measure:manual-entry')) const canManualEntry = computed(() => access.can('platform:firstleg:inbound:measure:manual-entry'))
const canSubmit = computed(() => access.can('platform:firstleg:inbound:measure:submit')) const canSubmit = computed(() => access.can('platform:firstleg:inbound:measure:submit'))
const customerId = computed(() => props.customerId || props.tenantId || '') const customerId = computed(() => props.customerId || props.tenantId || '')
const displayCustomerName = computed(() => detail.value?.companyName?.trim() || props.customerName?.trim() || '客户未命名')
const hasContext = computed(() => Boolean(customerId.value && props.inboundId)) const hasContext = computed(() => Boolean(customerId.value && props.inboundId))
const countEditableProcess = computed(() => ['PREPARING', 'WAIT_MEASURE', 'MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || '')) const countEditableProcess = computed(() => ['PREPARING', 'WAIT_MEASURE', 'MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
const submitEditableProcess = computed(() => ['PREPARING', 'WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || '')) const submitEditableProcess = computed(() => ['PREPARING', 'WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
@@ -698,7 +700,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
<section class="measurement-section measurement-summary-section"> <section class="measurement-section measurement-summary-section">
<header class="measurement-summary-header"> <header class="measurement-summary-header">
<div class="measurement-summary-context"> <div class="measurement-summary-context">
<p><span>客户</span>{{ detail.companyName || '客户未命名' }}</p> <p><span>客户</span>{{ displayCustomerName }}</p>
<p><span>渠道</span>{{ detail.channelName || detail.channelCode || '渠道未填写' }}</p> <p><span>渠道</span>{{ detail.channelName || detail.channelCode || '渠道未填写' }}</p>
</div> </div>
<div class="measurement-summary-status"> <div class="measurement-summary-status">
@@ -107,6 +107,7 @@ export interface InboundMeasurementDetail {
expectedPalletCount?: number | null expectedPalletCount?: number | null
processStatus: InboundMeasurementProcessStatus processStatus: InboundMeasurementProcessStatus
measureReviewStatus: string | null measureReviewStatus: string | null
customerFeeConfirmed: boolean
recommendedPalletType: string | null recommendedPalletType: string | null
reviewReason: string | null reviewReason: string | null
reviewerId: MeasurementIdentifier | null reviewerId: MeasurementIdentifier | null
@@ -21,10 +21,12 @@ export interface DispatchPalletCandidateDto {
planNo?: string | null planNo?: string | null
orderType?: string | null orderType?: string | null
volumeWeightKg?: number | null volumeWeightKg?: number | null
grossWeightKg?: number | null
containerNo?: string | null containerNo?: string | null
appointmentAt?: string | null appointmentAt?: string | null
expectedLoadingAt?: string | null expectedLoadingAt?: string | null
overseasWarehouseId?: string | null overseasWarehouseId?: string | null
overseasWarehouseCode?: string | null
overseasWarehouseName?: string | null overseasWarehouseName?: string | null
orderNo?: string | null orderNo?: string | null
orderRefs?: string | null orderRefs?: string | null
@@ -26,6 +26,7 @@ export interface InboundOrderSummaryDto {
inboundCode?: string | null inboundCode?: string | null
lxCode?: string | null lxCode?: string | null
orderType?: string | null orderType?: string | null
overseasWarehouseId?: string | null
overseasWarehouseCode?: string | null overseasWarehouseCode?: string | null
overseasWarehouseName?: string | null overseasWarehouseName?: string | null
warehouseId?: string | null warehouseId?: string | null
@@ -1,12 +1,28 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp' import { overseasHttp } from '@/shared/api/overseasHttp'
import { getInventorySummary, listInventoryBalances, listInventoryLedger, listInventoryPositions } from './inventory' import { downloadInventoryBalances, downloadInventoryLedger, getInventorySummary, listInventoryBalances, listInventoryLedger, listInventoryPositions } from './inventory'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn() } })) vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn() } }))
describe('overseas inventory API', () => { describe('overseas inventory API', () => {
beforeEach(() => { vi.clearAllMocks() }) beforeEach(() => { vi.clearAllMocks() })
it('returns the binary response directly for inventory exports', async () => {
const balances = new Blob(['customerId\\r\\n10001\\r\\n'], { type: 'text/csv' })
const ledger = new Blob(['occurredAt\\r\\n'], { type: 'text/csv' })
vi.mocked(overseasHttp.get).mockResolvedValueOnce(balances).mockResolvedValueOnce(ledger)
await expect(downloadInventoryBalances({ skuCode: 'SKU-1' })).resolves.toBe(balances)
await expect(downloadInventoryLedger({ inboundCode: 'IN-1' })).resolves.toBe(ledger)
expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/inventory/balances/export', {
params: { skuCode: 'SKU-1' }, responseType: 'blob',
})
expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/inventory/ledger/export', {
params: { inboundCode: 'IN-1' }, responseType: 'blob',
})
})
it('keeps skuCode and nullable location filters on real balance queries', async () => { it('keeps skuCode and nullable location filters on real balance queries', async () => {
vi.mocked(overseasHttp.get).mockResolvedValueOnce({ code: '200', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } }).mockResolvedValueOnce({ code: '200', data: { totalPackages: 0 } }) vi.mocked(overseasHttp.get).mockResolvedValueOnce({ code: '200', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } }).mockResolvedValueOnce({ code: '200', data: { totalPackages: 0 } })
const query = { page: 1, size: 20, skuCode: 'SKU-1', warehouseLocationCode: 'A-01' } const query = { page: 1, size: 20, skuCode: 'SKU-1', warehouseLocationCode: 'A-01' }
@@ -118,8 +118,7 @@ export async function listInventoryBalances(query: InventoryBalanceQuery) {
} }
export async function downloadInventoryBalances(query: Partial<InventoryBalanceQuery>) { export async function downloadInventoryBalances(query: Partial<InventoryBalanceQuery>) {
const response = await overseasHttp.get<Blob>('/platform/v1/last-leg/inventory/balances/export', { params: query, responseType: 'blob' }) return overseasHttp.get<Blob, Blob>('/platform/v1/last-leg/inventory/balances/export', { params: query, responseType: 'blob' })
return response.data
} }
export async function getInventorySummary(query: InventoryBalanceQuery) { export async function getInventorySummary(query: InventoryBalanceQuery) {
@@ -168,6 +167,5 @@ export async function listInventoryLedger(query: InventoryLedgerQuery) {
} }
export async function downloadInventoryLedger(query: Partial<InventoryLedgerQuery>) { export async function downloadInventoryLedger(query: Partial<InventoryLedgerQuery>) {
const response = await overseasHttp.get<Blob>('/platform/v1/last-leg/inventory/ledger/export', { params: query, responseType: 'blob' }) return overseasHttp.get<Blob, Blob>('/platform/v1/last-leg/inventory/ledger/export', { params: query, responseType: 'blob' })
return response.data
} }
@@ -14,6 +14,7 @@ import { formatOverseasDateTime } from '../lastMilePresentation'
import type { InboundOrderListItem } from '../types' import type { InboundOrderListItem } from '../types'
import { dispatchVehicleSpecs } from '../dispatchVehicleSpecs' import { dispatchVehicleSpecs } from '../dispatchVehicleSpecs'
import OrderReferenceText from './OrderReferenceText.vue' import OrderReferenceText from './OrderReferenceText.vue'
import { matchesSmartDispatchWarehouseTab, type SmartDispatchWarehouseTab } from '../smartDispatchWarehouseTab'
interface LoadingContainer { interface LoadingContainer {
id: string id: string
@@ -50,7 +51,7 @@ const query = reactive({
sourceStage: 'RECEIVED' as '' | 'PLAN' | 'RECEIVED' | 'RELABEL', sourceStage: 'RECEIVED' as '' | 'PLAN' | 'RECEIVED' | 'RELABEL',
fbaWarehouseCode: '', fbaWarehouseCode: '',
}) })
const warehouseTab = ref<'ALL' | 'LAX' | 'NYC'>('ALL') const warehouseTab = ref<SmartDispatchWarehouseTab>('ALL')
const orderTypeOptions = [ const orderTypeOptions = [
{ value: 'FIRST_LEG', label: '头程订单' }, { value: 'FIRST_LEG', label: '头程订单' },
@@ -65,9 +66,7 @@ const visibleCandidates = computed(() =>
candidates.value.filter((item) => { candidates.value.filter((item) => {
const typeMatches = !query.orderType || String(item.orderType || '') === query.orderType const typeMatches = !query.orderType || String(item.orderType || '') === query.orderType
if (!typeMatches) return false if (!typeMatches) return false
if (warehouseTab.value === 'ALL') return true return matchesSmartDispatchWarehouseTab(item, warehouseTab.value)
const warehouse = `${item.overseasWarehouseId || ''} ${item.overseasWarehouseName || ''}`.toLowerCase()
return warehouseTab.value === 'LAX' ? /lax|洛杉矶|los angeles/.test(warehouse) : /nyc|jfk|纽约|new york/.test(warehouse)
}), }),
) )
const orderGroups = computed(() => { const orderGroups = computed(() => {
@@ -88,7 +87,14 @@ const assigned = computed(() => assignedIds.value.map((id) => palletSnapshot(id)
const assignedPackages = computed(() => assigned.value.reduce((sum, item) => sum + Number(item.packageCount || 0), 0)) const assignedPackages = computed(() => assigned.value.reduce((sum, item) => sum + Number(item.packageCount || 0), 0))
const assignedVolume = computed(() => assigned.value.reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0)) const assignedVolume = computed(() => assigned.value.reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
const selectedVehicleSpec = computed(() => dispatchVehicleSpecs.find((item) => item.value === selectedVehicle.value)) const selectedVehicleSpec = computed(() => dispatchVehicleSpecs.find((item) => item.value === selectedVehicle.value))
const weightLb = (item?: DispatchPalletCandidateDto) => Number(item?.volumeWeightKg || 0) * 2.2046226218 function weightKg(item?: DispatchPalletCandidateDto) {
const grossWeightKg = Number(item?.grossWeightKg)
if (Number.isFinite(grossWeightKg) && grossWeightKg > 0) return grossWeightKg
const volumeWeightKg = Number(item?.volumeWeightKg)
return Number.isFinite(volumeWeightKg) && volumeWeightKg > 0 ? volumeWeightKg : 0
}
const weightLb = (item?: DispatchPalletCandidateDto) => weightKg(item) * 2.2046226218
const orderWeightLb = (pallets: DispatchPalletCandidateDto[]) => pallets.reduce((sum, item) => sum + weightLb(item), 0)
const containerWeightLb = (container: LoadingContainer) => container.palletIds.reduce((sum, id) => sum + weightLb(palletSnapshot(id)), 0) const containerWeightLb = (container: LoadingContainer) => container.palletIds.reduce((sum, id) => sum + weightLb(palletSnapshot(id)), 0)
function palletId(item: DispatchPalletCandidateDto) { function palletId(item: DispatchPalletCandidateDto) {
@@ -390,7 +396,7 @@ watch([warehouseTab, () => query.orderType], () => {
:key="tab.value" :key="tab.value"
type="button" type="button"
:class="['warehouse-tab', { active: warehouseTab === tab.value }]" :class="['warehouse-tab', { active: warehouseTab === tab.value }]"
@click="warehouseTab = tab.value as 'ALL' | 'LAX' | 'NYC'" @click="warehouseTab = tab.value as SmartDispatchWarehouseTab"
> >
{{ tab.label }} {{ tab.label }}
</button> </button>
@@ -428,7 +434,7 @@ watch([warehouseTab, () => query.orderType], () => {
><template #default="{ row }">{{ orderType(row.first.orderType) }}</template></el-table-column ><template #default="{ row }">{{ orderType(row.first.orderType) }}</template></el-table-column
> >
<el-table-column label="重量(lb)" width="100" align="right" <el-table-column label="重量(lb)" width="100" align="right"
><template #default="{ row }">{{ number(Number(row.first.volumeWeightKg || 0) * 2.2046226218, 0) }}LB</template></el-table-column ><template #default="{ row }">{{ number(orderWeightLb(row.pallets), 0) }}LB</template></el-table-column
> >
<el-table-column label="托盘数量" width="90" align="right" <el-table-column label="托盘数量" width="90" align="right"
><template #default="{ row }">{{ row.pallets.length }}</template></el-table-column ><template #default="{ row }">{{ row.pallets.length }}</template></el-table-column
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { formatOverseasDateTime, inventoryBusinessTypeLabel, lastMileOrderReference, lastMileStatusLabel, packageDifferenceText, relabelBusinessStatusLabel, toLastMileOrderListItem } from './lastMilePresentation' import { formatOverseasDateTime, inboundPlanStatusLabel, inventoryBusinessTypeLabel, lastMileOrderReference, lastMileStatusLabel, packageDifferenceText, relabelBusinessStatusLabel, toLastMileOrderListItem } from './lastMilePresentation'
describe('last-mile order presentation adapter', () => { describe('last-mile order presentation adapter', () => {
it('formats overseas timestamps with zero-padded seconds', () => { it('formats overseas timestamps with zero-padded seconds', () => {
@@ -109,6 +109,18 @@ describe('last-mile order presentation adapter', () => {
expect(lastMileStatusLabel(status)).toBe(expected) expect(lastMileStatusLabel(status)).toBe(expected)
}) })
it.each([
['PENDING_ASSIGNMENT', '待入库'],
['ASSIGNED', '待入库'],
['PDA_PENDING', '待入库'],
['SIGNED', '已签收'],
['PICKING', '拣货中'],
['PARTIAL_INBOUND', '部分入库'],
['COMPLETED', '全部入库'],
])('maps inbound plan status %s to %s', (status, expected) => {
expect(inboundPlanStatusLabel(status)).toBe(expected)
})
it.each([ it.each([
['RELABEL_APPLICATION', '换标下单'], ['RELABEL_APPLICATION', '换标下单'],
['WAREHOUSE_TRANSFER', '仓储转运'], ['WAREHOUSE_TRANSFER', '仓储转运'],
@@ -38,6 +38,16 @@ const statusLabels: Record<string, string> = {
CANCELLED: '已取消', CANCELLED: '已取消',
} }
const inboundPlanStatusLabels: Record<string, string> = {
PENDING_ASSIGNMENT: '待入库',
ASSIGNED: '待入库',
PDA_PENDING: '待入库',
SIGNED: '已签收',
PICKING: '拣货中',
PARTIAL_INBOUND: '部分入库',
COMPLETED: '全部入库',
}
const inventoryBusinessTypeLabels: Record<string, string> = { const inventoryBusinessTypeLabels: Record<string, string> = {
IN_TRANSIT: '在途入账', IN_TRANSIT: '在途入账',
STOCK_IN: '入库', STOCK_IN: '入库',
@@ -74,6 +84,11 @@ export function lastMileStatusLabel(value: unknown) {
return statusLabels[code] || text(code) return statusLabels[code] || text(code)
} }
export function inboundPlanStatusLabel(value: unknown) {
const code = String(value ?? '').trim()
return inboundPlanStatusLabels[code] || text(code)
}
export function packageDifferenceText(value: number | '—' | null | undefined) { export function packageDifferenceText(value: number | '—' | null | undefined) {
if (value == null || value === '—' || !Number.isFinite(value)) return '—' if (value == null || value === '—' || !Number.isFinite(value)) return '—'
return value > 0 ? `+${value}` : String(value) return value > 0 ? `+${value}` : String(value)
@@ -8,7 +8,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import LoadErrorState from '@/shared/components/LoadErrorState.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getInboundPlan, type InboundPlanDetailDto } from '../api/inboundPlans' import { getInboundPlan, type InboundPlanDetailDto } from '../api/inboundPlans'
import OverseasDetailShell from '../components/OverseasDetailShell.vue' import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import { formatOverseasDateTime, lastMileOrderTypeLabel } from '../lastMilePresentation' import { formatOverseasDateTime, inboundPlanStatusLabel, lastMileOrderTypeLabel } from '../lastMilePresentation'
const route = useRoute(); const router = useRouter() const route = useRoute(); const router = useRouter()
const planNo = computed(() => String(route.params.orderId || '')) const planNo = computed(() => String(route.params.orderId || ''))
@@ -21,7 +21,7 @@ function receivingWarehouse(code: unknown, name: unknown) {
const normalizedName = String(name ?? '').trim() const normalizedName = String(name ?? '').trim()
return normalizedCode && normalizedName ? `${normalizedCode} · ${normalizedName}` : '—' return normalizedCode && normalizedName ? `${normalizedCode} · ${normalizedName}` : '—'
} }
function status(value: unknown) { const labels: Record<string, string> = { PENDING_ASSIGNMENT: '待入库', PDA_PENDING: '待入库', ASSIGNED: '待入库', PICKING: '拣货中', PARTIAL_INBOUND: '部分入库', COMPLETED: '全部入库' }; return labels[String(value || '')] || text(value) } function status(value: unknown) { return inboundPlanStatusLabel(value) }
const firstLine = computed(() => detail.value?.lines[0]) const firstLine = computed(() => detail.value?.lines[0])
const fieldValues = computed<Record<string, string | number>>(() => ({ const fieldValues = computed<Record<string, string | number>>(() => ({
入库单号: text(detail.value?.planNo), 入仓码: text(firstLine.value?.inboundCode), 类型: lastMileOrderTypeLabel(detail.value?.orderType), 货主: text(detail.value?.customerName), 关联订单: text(detail.value?.sourceOrderNo), 入库单号: text(detail.value?.planNo), 入仓码: text(firstLine.value?.inboundCode), 类型: lastMileOrderTypeLabel(detail.value?.orderType), 货主: text(detail.value?.customerName), 关联订单: text(detail.value?.sourceOrderNo),
@@ -18,7 +18,7 @@ import SmartDispatchDialog from '../components/SmartDispatchDialog.vue'
import { createFbaAppointment, getFbaAppointment, listInboundOrders, updateFbaAppointment, updateInboundPlan, type InboundOrderSummaryDto } from '../api/inboundOrders' import { createFbaAppointment, getFbaAppointment, listInboundOrders, updateFbaAppointment, updateInboundPlan, type InboundOrderSummaryDto } from '../api/inboundOrders'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets' import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { overseasWarehousePermissions as permission } from '../permissions' import { overseasWarehousePermissions as permission } from '../permissions'
import { formatOverseasDateTime, lastMileOrderTypeLabel } from '../lastMilePresentation' import { formatOverseasDateTime, inboundPlanStatusLabel, lastMileOrderTypeLabel } from '../lastMilePresentation'
import type { InboundOrderListItem } from '../types' import type { InboundOrderListItem } from '../types'
const access = useAccessPreviewStore() const access = useAccessPreviewStore()
@@ -37,7 +37,7 @@ const fields = computed<QueryField[]>(() => [
{ key: 'orderNo', type: 'input', label: '入库单号', width: 165, placeholder: '入库单号' }, { key: 'orderNo', type: 'input', label: '入库单号', width: 165, placeholder: '入库单号' },
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 145, placeholder: '入仓码' }, { key: 'inboundCode', type: 'input', label: '入仓码', width: 145, placeholder: '入仓码' },
{ key: 'lxCode', type: 'input', label: 'LX代码', width: 160, placeholder: '领星入库单代码' }, { key: 'lxCode', type: 'input', label: 'LX代码', width: 160, placeholder: '领星入库单代码' },
{ key: 'status', type: 'select', label: '状态', width: 130, placeholder: '全部状态', options: [{ label: '待入库', value: 'PENDING_ASSIGNMENT' }, { label: '拣货中', value: 'PICKING' }, { label: '全部入库', value: 'COMPLETED' }, { label: '部分入库', value: 'PARTIAL_INBOUND' }] }, { key: 'status', type: 'select', label: '状态', width: 130, placeholder: '全部状态', options: [{ label: '待入库', value: 'PENDING_ASSIGNMENT' }, { label: '已签收', value: 'SIGNED' }, { label: '拣货中', value: 'PICKING' }, { label: '全部入库', value: 'COMPLETED' }, { label: '部分入库', value: 'PARTIAL_INBOUND' }] },
{ key: 'warehouse', type: 'input', label: '海外仓', width: 155, placeholder: '全部海外仓' }, { key: 'warehouse', type: 'input', label: '海外仓', width: 155, placeholder: '全部海外仓' },
{ key: 'fbaWarehouse', type: 'input', label: 'FBA仓库', width: 145, placeholder: '全部FBA仓库' }, { key: 'fbaWarehouse', type: 'input', label: 'FBA仓库', width: 145, placeholder: '全部FBA仓库' },
{ key: 'appointmentStatus', type: 'select', label: '预约状态', width: 145, placeholder: '全部预约状态', options: [{ label: '未预约', value: 'UNAPPOINTED' }, { label: '预约中', value: 'PENDING' }, { label: '预约成功', value: 'SUCCESS' }, { label: '预约失败', value: 'FAILED' }, { label: '已取消', value: 'CANCELLED' }] }, { key: 'appointmentStatus', type: 'select', label: '预约状态', width: 145, placeholder: '全部预约状态', options: [{ label: '未预约', value: 'UNAPPOINTED' }, { label: '预约中', value: 'PENDING' }, { label: '预约成功', value: 'SUCCESS' }, { label: '预约失败', value: 'FAILED' }, { label: '已取消', value: 'CANCELLED' }] },
@@ -95,7 +95,6 @@ function receivingWarehouse(code: unknown, name: unknown) {
} }
function number(value: unknown) { const normalized = Number(value); return Number.isFinite(normalized) ? normalized : 0 } function number(value: unknown) { const normalized = Number(value); return Number.isFinite(normalized) ? normalized : 0 }
function typeLabel(value: unknown) { return lastMileOrderTypeLabel(value) } function typeLabel(value: unknown) { return lastMileOrderTypeLabel(value) }
function planStatusLabel(value: unknown) { return ({ PENDING_ASSIGNMENT: '待入库', ASSIGNED: '待入库', PDA_PENDING: '待入库', PICKING: '拣货中', COMPLETED: '全部入库', PARTIAL_INBOUND: '部分入库' } as Record<string, string>)[String(value || '')] || text(value) }
function appointmentStatusLabel(value: unknown) { return !value ? '未预约' : ({ PENDING: '预约中', SUCCESS: '预约成功', FAILED: '预约失败', CANCELLED: '已取消' } as Record<string, string>)[String(value)] || text(value) } function appointmentStatusLabel(value: unknown) { return !value ? '未预约' : ({ PENDING: '预约中', SUCCESS: '预约成功', FAILED: '预约失败', CANCELLED: '已取消' } as Record<string, string>)[String(value)] || text(value) }
function toListItem(dto: InboundOrderSummaryDto): InboundOrderListItem { function toListItem(dto: InboundOrderSummaryDto): InboundOrderListItem {
@@ -105,7 +104,7 @@ function toListItem(dto: InboundOrderSummaryDto): InboundOrderListItem {
orderNo: text(dto.planNo), inboundCode: text(dto.inboundCode), lxCode: text(dto.lxCode), type: typeLabel(dto.orderType), warehouse: receivingWarehouse(dto.overseasWarehouseCode, dto.overseasWarehouseName), orderNo: text(dto.planNo), inboundCode: text(dto.inboundCode), lxCode: text(dto.lxCode), type: typeLabel(dto.orderType), warehouse: receivingWarehouse(dto.overseasWarehouseCode, dto.overseasWarehouseName),
fbaWarehouse: text(dto.fbaWarehouseCode), owner: text(dto.customerName), cartons: number(dto.packageCount), pickedCartons: number(dto.pickedPackageCount), volume: number(dto.volumeCbm), pallets: number(dto.palletCount), fbaWarehouse: text(dto.fbaWarehouseCode), owner: text(dto.customerName), cartons: number(dto.packageCount), pickedCartons: number(dto.pickedPackageCount), volume: number(dto.volumeCbm), pallets: number(dto.palletCount),
assigned: dto.assigned ? '已分配' : '未分配', mergeAllowed: dto.mergeAllowed ? '可合托' : '不合托', urgent: dto.urgent ? '加急' : '普通', assigned: dto.assigned ? '已分配' : '未分配', mergeAllowed: dto.mergeAllowed ? '可合托' : '不合托', urgent: dto.urgent ? '加急' : '普通',
status: planStatusLabel(dto.status), appointmentNo: text(dto.appointmentNo), appointmentStatus: appointmentStatusLabel(dto.appointmentStatus), status: inboundPlanStatusLabel(dto.status), appointmentNo: text(dto.appointmentNo), appointmentStatus: appointmentStatusLabel(dto.appointmentStatus),
appointmentAt: formatOverseasDateTime(dto.appointmentAt), expectedLoadingAt: formatOverseasDateTime(dto.expectedLoadingAt), dispatchStatus: dto.dispatchStatus === 'SCHEDULED' ? '已排车' : '未排车', appointmentAt: formatOverseasDateTime(dto.appointmentAt), expectedLoadingAt: formatOverseasDateTime(dto.expectedLoadingAt), dispatchStatus: dto.dispatchStatus === 'SCHEDULED' ? '已排车' : '未排车',
createdAt: formatOverseasDateTime(dto.createdAt), version: number(dto.version), createdAt: formatOverseasDateTime(dto.createdAt), version: number(dto.version),
} }
@@ -246,6 +246,13 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(availableFields.find((field) => field.key === 'lxCode')).toMatchObject({ expect(availableFields.find((field) => field.key === 'lxCode')).toMatchObject({
label: 'LX代码', placeholder: '请输入LX代码', label: 'LX代码', placeholder: '请输入LX代码',
}) })
expect(availableFields.filter((field) => ['orderType', 'status', 'abnormal', 'detained'].includes(field.key)))
.toEqual(expect.arrayContaining([
expect.objectContaining({ key: 'orderType', placeholder: '请选择订单类型' }),
expect.objectContaining({ key: 'status', placeholder: '请选择当前状态' }),
expect.objectContaining({ key: 'abnormal', placeholder: '请选择是否异常' }),
expect.objectContaining({ key: 'detained', placeholder: '请选择是否暂扣' }),
]))
expect(availableFields.map((field) => field.key)).not.toEqual(expect.arrayContaining(['planNo', 'inboundOrderNo'])) expect(availableFields.map((field) => field.key)).not.toEqual(expect.arrayContaining(['planNo', 'inboundOrderNo']))
await wrapper.findAll('.el-radio-button').find((item) => item.text() === '待分配')!.trigger('click') await wrapper.findAll('.el-radio-button').find((item) => item.text() === '待分配')!.trigger('click')
@@ -122,7 +122,7 @@ const allFields: QueryField[] = [
{ key: 'relabelNo', type: 'input', label: '换标单号', width: 190, placeholder: '请输入换标单号' }, { key: 'relabelNo', type: 'input', label: '换标单号', width: 190, placeholder: '请输入换标单号' },
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 180, placeholder: '请输入入仓码' }, { key: 'inboundCode', type: 'input', label: '入仓码', width: 180, placeholder: '请输入入仓码' },
{ key: 'lxCode', type: 'input', label: 'LX代码', width: 180, placeholder: '请输入LX代码' }, { key: 'lxCode', type: 'input', label: 'LX代码', width: 180, placeholder: '请输入LX代码' },
{ key: 'orderType', type: 'select', label: '订单类型', width: 170, placeholder: '全部类型', options: [ { key: 'orderType', type: 'select', label: '订单类型', width: 170, placeholder: '请选择订单类型', options: [
{ label: '头程订单', value: 'FIRST_LEG' }, { label: '换标下单', value: 'RELABEL_APPLICATION' }, { label: '头程订单', value: 'FIRST_LEG' }, { label: '换标下单', value: 'RELABEL_APPLICATION' },
{ label: '仓储转运', value: 'WAREHOUSE_TRANSFER' }, { label: '本土散板', value: 'LOCAL_LOOSEBOARD' }, { label: '仓储转运', value: 'WAREHOUSE_TRANSFER' }, { label: '本土散板', value: 'LOCAL_LOOSEBOARD' },
{ label: '退货换标', value: 'RETURN_RELABEL' }, { label: '一件代发', value: 'ONE_CLICK_DROPSHIP' }, { label: '退货换标', value: 'RETURN_RELABEL' }, { label: '一件代发', value: 'ONE_CLICK_DROPSHIP' },
@@ -132,7 +132,7 @@ const allFields: QueryField[] = [
{ key: 'sourceOrderNo', type: 'input', label: '来源订单号', width: 190, placeholder: '请输入来源订单号' }, { key: 'sourceOrderNo', type: 'input', label: '来源订单号', width: 190, placeholder: '请输入来源订单号' },
{ key: 'containerNo', type: 'input', label: '集装箱号', width: 180, placeholder: '请输入集装箱号' }, { key: 'containerNo', type: 'input', label: '集装箱号', width: 180, placeholder: '请输入集装箱号' },
{ key: 'transferWarehouseCode', type: 'input', label: '转仓代码', width: 170, placeholder: '请输入转仓代码' }, { key: 'transferWarehouseCode', type: 'input', label: '转仓代码', width: 170, placeholder: '请输入转仓代码' },
{ key: 'status', type: 'select', label: '当前状态', width: 160, placeholder: '全部状态', options: [ { key: 'status', type: 'select', label: '当前状态', width: 160, placeholder: '请选择当前状态', options: [
{ label: '待分配', value: 'PENDING_ASSIGNMENT' }, { label: '待分配', value: 'PENDING_ASSIGNMENT' },
{ label: '待换标', value: 'WAIT_RELABEL' }, { label: '待换标', value: 'WAIT_RELABEL' },
{ label: '待排车', value: 'READY_FOR_DISPATCH' }, { label: '待排车', value: 'READY_FOR_DISPATCH' },
@@ -143,8 +143,8 @@ const allFields: QueryField[] = [
{ label: '派送中', value: 'DELIVERING' }, { label: '派送中', value: 'DELIVERING' },
{ label: '派送完成', value: 'DELIVERED' }, { label: '派送完成', value: 'DELIVERED' },
] }, ] },
{ key: 'abnormal', type: 'select', label: '是否异常', width: 140, placeholder: '全部', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] }, { key: 'abnormal', type: 'select', label: '是否异常', width: 140, placeholder: '请选择是否异常', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
{ key: 'detained', type: 'select', label: '是否暂扣', width: 140, placeholder: '全部', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] }, { key: 'detained', type: 'select', label: '是否暂扣', width: 140, placeholder: '请选择是否暂扣', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
{ key: 'createdAt', type: 'daterange', label: '创建时间', width: 280 }, { key: 'createdAt', type: 'daterange', label: '创建时间', width: 280 },
] ]
const fields = computed<QueryField[]>(() => statusTab.value === '柜子' const fields = computed<QueryField[]>(() => statusTab.value === '柜子'
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { matchesSmartDispatchWarehouseTab } from './smartDispatchWarehouseTab'
describe('smart dispatch warehouse tabs', () => {
it('uses the overseas warehouse code when the warehouse name is unavailable', () => {
const losAngeles = { overseasWarehouseCode: 'US-LAX-01', overseasWarehouseName: null }
const newYork = { overseasWarehouseCode: 'US-NY-01', overseasWarehouseName: null }
expect(matchesSmartDispatchWarehouseTab(losAngeles, 'LAX')).toBe(true)
expect(matchesSmartDispatchWarehouseTab(losAngeles, 'NYC')).toBe(false)
expect(matchesSmartDispatchWarehouseTab(newYork, 'NYC')).toBe(true)
expect(matchesSmartDispatchWarehouseTab(newYork, 'LAX')).toBe(false)
})
it('keeps the all tab unfiltered and supports warehouse names as a fallback', () => {
const warehouse = { overseasWarehouseCode: null, overseasWarehouseName: '洛杉矶接柜仓' }
expect(matchesSmartDispatchWarehouseTab(warehouse, 'ALL')).toBe(true)
expect(matchesSmartDispatchWarehouseTab(warehouse, 'LAX')).toBe(true)
})
})
@@ -0,0 +1,16 @@
import type { DispatchPalletCandidateDto } from './api/dispatches'
export type SmartDispatchWarehouseTab = 'ALL' | 'LAX' | 'NYC'
export function matchesSmartDispatchWarehouseTab(
candidate: Pick<DispatchPalletCandidateDto, 'overseasWarehouseCode' | 'overseasWarehouseName'>,
tab: SmartDispatchWarehouseTab,
) {
if (tab === 'ALL') return true
const warehouse = [candidate.overseasWarehouseCode, candidate.overseasWarehouseName]
.map((value) => String(value || '').trim().toLowerCase())
.filter(Boolean)
.join(' ')
if (tab === 'LAX') return /lax|洛杉矶|los angeles/.test(warehouse)
return /nyc|jfk|(?:^|[^a-z])ny(?:[^a-z]|$)|纽约|new[ -]?york/.test(warehouse)
}
@@ -28,6 +28,11 @@ describe('UnifiedOrdersView contract', () => {
expect(sendFeeSource).toContain("ElMessage.error(getApiErrorDetails(error, '确认费用发送失败').message)") expect(sendFeeSource).toContain("ElMessage.error(getApiErrorDetails(error, '确认费用发送失败').message)")
}) })
it('locks measurement changes as soon as the customer confirms the fee', () => {
expect(source).toContain('&& !measurementDetail.value.customerFeeConfirmed')
expect(source).toContain('费用已由客户确认,测量数据已锁定')
})
it('prevents duplicate confirm-and-send chains and reuses one attempt idempotency key set', () => { it('prevents duplicate confirm-and-send chains and reuses one attempt idempotency key set', () => {
const sendFeeSource = source.slice(source.indexOf('async function executeConfirmAndSendFee()'), source.indexOf('function handleOrderSelectionChange')) const sendFeeSource = source.slice(source.indexOf('async function executeConfirmAndSendFee()'), source.indexOf('function handleOrderSelectionChange'))
@@ -440,7 +445,7 @@ describe('UnifiedOrdersView contract', () => {
expect(source).not.toContain('@visible-change="handleOuterModeDropdownVisibleChange($event)"') expect(source).not.toContain('@visible-change="handleOuterModeDropdownVisibleChange($event)"')
expect(source.match(/:disabled="Boolean\(measurementStrategySavingId\) \|\| !measurementStrategyEditable"/g)).toHaveLength(1) expect(source.match(/:disabled="Boolean\(measurementStrategySavingId\) \|\| !measurementStrategyEditable"/g)).toHaveLength(1)
expect(source).toContain('const measurementStrategyEditable = computed') expect(source).toContain('const measurementStrategyEditable = computed')
expect(source).toContain('客户已确认且该票已装柜完成后,箱规计算规则将冻结') expect(source).toContain('费用已由客户确认,测量数据已锁定')
expect(source).toContain('if (!measurementStrategyEditable.value)') expect(source).toContain('if (!measurementStrategyEditable.value)')
expect(source).toContain('const previousMode = measurementSectionModes.outer') expect(source).toContain('const previousMode = measurementSectionModes.outer')
expect(source).toContain('if (!saved) measurementSectionModes.outer = previousMode') expect(source).toContain('if (!saved) measurementSectionModes.outer = previousMode')
@@ -231,11 +231,13 @@ const measurementStrategyEditable = computed(() => (
can('platform:firstleg:inbound:measure:edit') can('platform:firstleg:inbound:measure:edit')
&& Boolean(measurementDetail.value?.measurementId) && Boolean(measurementDetail.value?.measurementId)
&& measurementDetail.value?.version != null && measurementDetail.value?.version != null
&& !measurementDetail.value.customerFeeConfirmed
)) ))
const measurementStrategyLockedHint = computed(() => { const measurementStrategyLockedHint = computed(() => {
if (!can('platform:firstleg:inbound:measure:edit')) return '当前账号缺少测量编辑权限' if (!can('platform:firstleg:inbound:measure:edit')) return '当前账号缺少测量编辑权限'
if (!measurementDetail.value?.measurementId) return '当前没有可修改的测量批次' if (!measurementDetail.value?.measurementId) return '当前没有可修改的测量批次'
return '客户已确认且该票已装柜完成后,箱规计算规则将冻结' if (measurementDetail.value.customerFeeConfirmed) return '费用已由客户确认,测量数据已锁定'
return '当前测量数据不可修改'
}) })
const cargoImagePreviewUrls = new Set<string>() const cargoImagePreviewUrls = new Set<string>()
const measurementDialogOpen = ref(false) const measurementDialogOpen = ref(false)