diff --git a/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts b/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts
index f0efd047..e1ff7844 100644
--- a/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts
+++ b/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts
@@ -273,7 +273,7 @@ describe('InboundMeasurementPanel', () => {
expect(text).toContain('客户申报')
expect(text).toContain('平台实测')
expect(text).toContain('差异 +1.5 kg')
- expect(text).toContain('差异 -0.2 m³')
+ expect(text).toContain('差异 -0.200 m³')
expect(text).toContain('平台实测箱规')
expect(text).not.toContain('租户')
expect(text).not.toContain('数据版本')
@@ -297,7 +297,7 @@ describe('InboundMeasurementPanel', () => {
const wrapper = await mountPanel()
const rows = wrapper.findAll('.measurement-comparison tbody tr')
expect(rows.map(row => row.findAll('td').map(cell => cell.text()))).toEqual([
- ['10', '10', '一致'], ['118.5', '120', '差异 +1.5 kg'], ['0.8', '0.6', '差异 -0.2 m³'],
+ ['10', '10', '一致'], ['118.5', '120', '差异 +1.5 kg'], ['0.800', '0.600', '差异 -0.200 m³'],
])
expect(wrapper.find('.measurement-summary-section .measurement-declaration').exists()).toBe(false)
expect(wrapper.find('.measurement-declaration').element.parentElement).toBe(wrapper.find('.measurement-summary-section').element.parentElement)
@@ -319,8 +319,8 @@ describe('InboundMeasurementPanel', () => {
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ measurementId: null, items: [], weightResults: [] }))
const wrapper = await mountPanel()
expect(wrapper.find('.measurement-summary-header h4').text()).toBe('申报与实测对比')
- expect(wrapper.find('.measurement-declaration-header').text()).toContain('DOC-001')
- expect(wrapper.find('.measurement-section__heading .measurement-goods-name input').exists()).toBe(true)
+ expect(wrapper.find('.measurement-declaration-header').text()).not.toContain('DOC-001')
+ expect(wrapper.find('.measurement-section__heading .measurement-goods-name').exists()).toBe(false)
expect(wrapper.find('.measurement-section__heading .measurement-section-actions').text()).toContain('手动录入箱规')
expect(wrapper.findAll('.el-alert').some(alert => alert.text().includes('请先完成箱规测量'))).toBe(false)
expect(wrapper.find('.measurement-panel__footer').text()).toContain('请先完成箱规测量')
@@ -334,11 +334,11 @@ describe('InboundMeasurementPanel', () => {
expect(comparison.text()).toContain('118.5')
expect(comparison.text()).toContain('0.8')
expect(comparison.findAll('tbody tr')[0].text()).toContain('10')
- expect(declaration.text()).toContain('原单据号码')
- expect(declaration.text()).toContain('DOC-001')
- expect(declaration.findAll('.measurement-readonly-field')).toHaveLength(1)
+ expect(declaration.text()).not.toContain('原单据号码')
+ expect(declaration.text()).not.toContain('DOC-001')
+ expect(declaration.findAll('.measurement-readonly-field')).toHaveLength(0)
expect(declaration.findAll('input')).toHaveLength(0)
- expect(wrapper.find('.measurement-items-section .measurement-goods-name input').exists()).toBe(true)
+ expect(wrapper.find('.measurement-items-section .measurement-goods-name').exists()).toBe(false)
expect(declaration.find('[aria-label="客户申报箱规明细"]').text()).toContain('蓝牙音箱')
expect(declaration.find('[aria-label="客户申报箱规明细"]').text()).toContain('50 × 40 × 30')
})
@@ -356,13 +356,14 @@ describe('InboundMeasurementPanel', () => {
expect(declaration.find('input').exists()).toBe(false)
expect(declaration.text()).toContain('蓝牙音箱')
- expect(declaration.findAll('.measurement-readonly-field')).toHaveLength(1)
- expect(wrapper.find('.measurement-items-section .measurement-goods-name').text()).toContain('蓝牙音箱')
+ expect(declaration.findAll('.measurement-readonly-field')).toHaveLength(0)
+ expect(wrapper.find('.measurement-items-section .measurement-goods-name').exists()).toBe(false)
})
- it('saves the corrected actual goods name without overriding declaration facts', async () => {
+ it('preserves the hidden actual goods name without overriding declaration facts', async () => {
const wrapper = await mountPanel()
- await wrapper.find('.measurement-goods-name input').setValue('修正后的蓝牙音箱')
+ expect(wrapper.text()).not.toContain('实际品名')
+ await wrapper.find('input[aria-label="仓库修改外箱长"]').setValue('55')
const save = wrapper.findAll('button').find((button) => button.text().includes('保存测量数据'))
await save?.trigger('click')
await flushPromises()
@@ -370,7 +371,7 @@ describe('InboundMeasurementPanel', () => {
expect(saveInboundMeasurementDraft).toHaveBeenCalledWith('10001', '7001', expect.objectContaining({
measurementId: '9001',
version: 2,
- actualGoodsName: '修正后的蓝牙音箱',
+ actualGoodsName: '蓝牙音箱',
items: expect.any(Array),
}))
const payload = vi.mocked(saveInboundMeasurementDraft).mock.calls[0]?.[2]
@@ -573,7 +574,7 @@ describe('InboundMeasurementPanel', () => {
const wrapper = await mountPanel()
expect(wrapper.find('.measurement-declaration input').exists()).toBe(false)
expect(wrapper.find('.measurement-items-section .measurement-expected-pallets input').exists()).toBe(true)
- expect(wrapper.find('.measurement-items-section .measurement-goods-name input').exists()).toBe(true)
+ expect(wrapper.find('.measurement-items-section .measurement-goods-name').exists()).toBe(false)
})
it('requires at least one confirmed weight image before submitting for review', async () => {
diff --git a/src/modules/inbounds/measurement/InboundMeasurementPanel.vue b/src/modules/inbounds/measurement/InboundMeasurementPanel.vue
index 964758cd..9938094b 100644
--- a/src/modules/inbounds/measurement/InboundMeasurementPanel.vue
+++ b/src/modules/inbounds/measurement/InboundMeasurementPanel.vue
@@ -123,7 +123,7 @@ const confirmedPackageCount = computed(() => form.items.reduce((sum, item) => su
const comparisonMetrics = computed(() => [
{ label: '箱数', declared: detail.value?.originalPackageCount, actual: form.items.length ? confirmedPackageCount.value : null, digits: 0, unit: '' },
{ label: '毛重(kg)', declared: detail.value?.originalGrossWeightKg, actual: detail.value?.actualGrossWeightKg, digits: 3, unit: ' kg' },
- { label: '总体积(m³)', declared: detail.value?.originalVolumeCbm, actual: detail.value?.actualVolumeCbm, digits: 4, unit: ' m³' },
+ { label: '总体积(m³)', declared: detail.value?.originalVolumeCbm, actual: detail.value?.actualVolumeCbm, digits: 3, unit: ' m³' },
])
const expectedPackageCount = computed(() => detail.value?.originalPackageCount ?? null)
@@ -556,9 +556,9 @@ async function submitForReview() {
}
}
-function formatNumber(value: number | null | undefined, maximumFractionDigits = 3) {
+function formatNumber(value: number | null | undefined, maximumFractionDigits = 3, minimumFractionDigits = 0) {
if (value == null || !Number.isFinite(value)) return '—'
- return new Intl.NumberFormat('zh-CN', { maximumFractionDigits }).format(value)
+ return new Intl.NumberFormat('zh-CN', { maximumFractionDigits, minimumFractionDigits }).format(value)
}
function deviationRatio(actual: number | null | undefined, declared: number | null | undefined) {
@@ -575,7 +575,7 @@ function formatDifference(actual: number | null | undefined, declared: number |
const difference = actual - declared
if (difference === 0) return null
const sign = difference > 0 ? '+' : ''
- return `差异 ${sign}${formatNumber(difference, maximumFractionDigits)}${unit}`
+ return `差异 ${sign}${formatNumber(difference, maximumFractionDigits, unit === ' m³' ? 3 : 0)}${unit}`
}
function releaseImagePreview() {
@@ -727,8 +727,8 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
| {{ metric.label }} |
- {{ formatNumber(metric.declared, metric.digits) }} |
- {{ metric.actual == null ? '未测量' : formatNumber(metric.actual, metric.digits) }} |
+ {{ formatNumber(metric.declared, metric.digits, metric.unit === ' m³' ? 3 : 0) }} |
+ {{ metric.actual == null ? '未测量' : formatNumber(metric.actual, metric.digits, metric.unit === ' m³' ? 3 : 0) }} |
{{ metric.actual == null ? '待测量' : metric.declared == null ? '—' : formatDifference(metric.actual, metric.declared, metric.digits, metric.unit) || '一致' }}
|
@@ -745,7 +745,6 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
平台实测箱规
-
-
-
实际品名{{ detail.actualGoodsName || '—' }}
+
@@ -799,7 +796,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
测量时间{{ row.measuredAt ? row.measuredAt.replace('T', ' ') : '—' }}
计算方式{{ calculationModeLabel(row.coefficientMode) }}
计算后外箱{{ formatNumber(row.calculatedOuterLengthCm) }} × {{ formatNumber(row.calculatedOuterWidthCm) }} × {{ formatNumber(row.calculatedOuterHeightCm) }} cm
-
单箱体积{{ formatNumber(row.serverValues.unitVolumeCbm, 4) }} m³
+
单箱体积{{ formatNumber(row.serverValues.unitVolumeCbm, 3, 3) }} m³
箱规周长{{ formatNumber(row.serverValues.girthCm) }} cm
总材积重{{ formatNumber(row.serverValues.volumeWeightKg) }} kg
计算规则版本{{ row.calculationRuleVersion || '—' }}
@@ -847,7 +844,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
- {{ formatNumber(row.serverValues.totalVolumeCbm, 4) }}
+ {{ formatNumber(row.serverValues.totalVolumeCbm, 3, 3) }}
diff --git a/src/modules/overseas-master-data/api/capacityOffers.ts b/src/modules/overseas-master-data/api/capacityOffers.ts
index 35e4d83f..670f0269 100644
--- a/src/modules/overseas-master-data/api/capacityOffers.ts
+++ b/src/modules/overseas-master-data/api/capacityOffers.ts
@@ -37,3 +37,7 @@ export async function commandInquiryFulfillment(bookingNo: string, action: 'inco
}
export async function saveAndConfirmInquiry(bookingNo: string, body: InquiryWrite) { return inquiryResult(await post(`${inquiryPath(bookingNo)}/save-and-confirm`, body)) }
+
+export async function listLooseboardDemands(query: { page: number; size: number; keyword?: string }) {
+ return page(await get>(`${base}/pending-looseboard-demands`, query), row => ({ ...row, version: number(row.version), estimatedPalletCount: nullable(row.estimatedPalletCount), packageCount: nullable(row.packageCount), totalWeightKg: nullable(row.totalWeightKg), totalVolumeCbm: nullable(row.totalVolumeCbm) }))
+}
diff --git a/src/modules/overseas-master-data/capacityOfferTypes.ts b/src/modules/overseas-master-data/capacityOfferTypes.ts
index ec2b7d0f..3d1d3970 100644
--- a/src/modules/overseas-master-data/capacityOfferTypes.ts
+++ b/src/modules/overseas-master-data/capacityOfferTypes.ts
@@ -31,6 +31,7 @@ export interface InquiryQuery { page?: number; size?: number; keyword?: string;
export interface OfferWrite { tripNo: string; tripRecordVersion: number; offerRecordVersion: number | null; cutoffAt: string; contactName: string | null; contactPhone: string | null; remark: string | null; idempotencyKey: string }
export interface OfferCommand { tripRecordVersion: number; offerRecordVersion: number; idempotencyKey: string; reason?: string }
export interface InquiryWrite {
+ sourceOrderNo?: string; sourceOrderVersion?: number
tripRecordVersion: number; offerRecordVersion: number; recordVersion: number | null; customerId: string
businessType: CapacityInquiry['businessType']; deliveryMode: CapacityInquiry['deliveryMode']; estimatedArrivalAt: string | null
expectedPalletCount: number | null; cargo: TripBookingCargo; quoteRef: FleetQuoteReference | null; idempotencyKey: string
@@ -47,3 +48,9 @@ export const loadText = (value: CapacityLoad | null) => value ? `${value.palletS
export const appointmentCoverageLabels: Record = { SOURCE_NOT_READY: '待确认承运后筹划', SOURCE_NOT_FOUND: '来源待核对', SOURCE_NOT_MATERIALIZED: '待形成真实货件资料', DESTINATION_MISMATCH: '目的地变化待核对', REVIEW_REQUIRED: '货量待复核', UNASSIGNED: '尚未关联收货预约', PARTIAL: '部分货物未覆盖', PENDING: '等待收货方确认', CONFIRMED: '预约记录已确认' }
export const appointmentMissingLabels: Record = { CARRIER_CONFIRMATION: '承运确认', REAL_CARGO_MATERIALS: '真实货件资料', DESTINATION: '目的地核对', CARGO_QUANTITY_REVIEW: '货量复核', RECEIVING_APPOINTMENT: '收货预约', UNCOVERED_CARGO: '未覆盖货物的预约', RECEIVING_CONFIRMATION: '收货方确认', APPOINTMENT_TIMEZONE_UNVERIFIED: '预约时间所属地区尚未核实', SOURCE_CHANGED_REVIEW: '货源变化后重新核对预约' }
+
+export interface LooseboardDemand {
+ orderNo: string; customerId: string; version: number; origin: string; destination: string; productName: string | null
+ estimatedPalletCount: number | null; packageCount: number | null; totalWeightKg: number | null; totalVolumeCbm: number | null
+ currency: string; originContactName: string | null; originContactPhone: string | null; remark: string | null
+}
diff --git a/src/modules/overseas-master-data/components/CapacityInquiryDialog.test.ts b/src/modules/overseas-master-data/components/CapacityInquiryDialog.test.ts
index 51b2cf4c..31e6ebcb 100644
--- a/src/modules/overseas-master-data/components/CapacityInquiryDialog.test.ts
+++ b/src/modules/overseas-master-data/components/CapacityInquiryDialog.test.ts
@@ -92,3 +92,21 @@ it('locks the destination in readonly follow-up even without offer detail, while
expect(destination().get('input[role="combobox"]').attributes('disabled')).toBeDefined()
expect(saveCapacityInquiry).not.toHaveBeenCalled()
})
+
+it('matches an existing looseboard order without inventing space, arrival or agreed price and retries the same source', async () => {
+ permissions.values = new Set(['platform:fleet:capacity-offer:manage'])
+ const source = { orderNo: 'LB-ORIGINAL', customerId: '19', version: 4, origin: 'LA', destination: 'ONT8', productName: '家居用品', estimatedPalletCount: 3, packageCount: 12, totalWeightKg: 100, totalVolumeCbm: 2.5, currency: 'USD', originContactName: 'Alice', originContactPhone: '555', remark: '原单备注' }
+ vi.mocked(saveCapacityInquiry).mockRejectedValue(new Error('network'))
+ const wrapper = mount(CapacityInquiryDialog, { attachTo: document.body, props: { modelValue: true, offer, inquiry: null, readonly: false, sourceDemand: source }, global: { plugins: [createPinia(), ElementPlus] } }); wrappers.push(wrapper)
+ await flushPromises()
+ expect(formItem(wrapper, '委托客户').findComponent({ name: 'ElSelect' }).props('disabled')).toBe(true)
+ expect(formItem(wrapper, '本车卸货站').findComponent({ name: 'ElSelect' }).props('modelValue')).toBe('')
+ expect(formItem(wrapper, '本车卸货站').findComponent({ name: 'ElSelect' }).props('disabled')).toBe(false)
+ await select(wrapper, '本车卸货站', 'FBA:ONT8')
+ const dialog = wrapper.findComponent({ name: 'AppDialog' }); dialog.vm.$emit('confirm'); await flushPromises()
+ const body = vi.mocked(saveCapacityInquiry).mock.calls[0]![2]
+ expect(body).toMatchObject({ sourceOrderNo: 'LB-ORIGINAL', sourceOrderVersion: 4, customerId: '19', businessType: 'LOCAL_LOOSEBOARD', estimatedArrivalAt: null, expectedPalletCount: 3, cargo: { packageCount: 12, palletSpaces: null, unitPrice: null, weightKg: 100, volumeCbm: 2.5 } })
+ dialog.vm.$emit('confirm'); await flushPromises()
+ expect(saveCapacityInquiry).toHaveBeenNthCalledWith(2, offer.offerNo, null, body)
+ expect(listOfferCustomers).not.toHaveBeenCalled()
+})
diff --git a/src/modules/overseas-master-data/components/CapacityInquiryDialog.vue b/src/modules/overseas-master-data/components/CapacityInquiryDialog.vue
index 5e7cf371..459b8825 100644
--- a/src/modules/overseas-master-data/components/CapacityInquiryDialog.vue
+++ b/src/modules/overseas-master-data/components/CapacityInquiryDialog.vue
@@ -10,12 +10,12 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listOfferCustomers, listOfferQuotes, saveCapacityInquiry, saveAndConfirmInquiry } from '../api/capacityOffers'
import { fleetQuoteReference, type FleetQuoteReference, type FleetQuoteSourceSnapshot } from '../api/fleetQuoteCandidates'
import { offerTime, appointmentCoverageLabels, appointmentMissingLabels, inquiryCargoLabel } from '../capacityOfferTypes'
-import type { CapacityOffer, CapacityInquiry, InquiryResult, InquiryWrite, OfferCustomerOption } from '../capacityOfferTypes'
+import type { CapacityOffer, CapacityInquiry, InquiryResult, InquiryWrite, OfferCustomerOption, LooseboardDemand } from '../capacityOfferTypes'
import type { TripBookingCargo } from '../bookingTypes'
import { useFleetDraftGuard } from '../useFleetDraftGuard'
import { inquiryOrderTarget } from '../inquiryNavigation'
const open = defineModel({ default: false })
-const props = defineProps<{ offer: CapacityOffer | null; inquiry: CapacityInquiry | null; readonly: boolean; confirmMode?: boolean }>()
+const props = defineProps<{ offer: CapacityOffer | null; inquiry: CapacityInquiry | null; readonly: boolean; confirmMode?: boolean; sourceDemand?: LooseboardDemand | null }>()
const emit = defineEmits<{ saved: [value: InquiryResult]; refresh: []; action: [key: string, row: CapacityInquiry] }>()
const access = useAccessPreviewStore(), busy = ref(false), error = ref(null)
const followupActions = computed(() => {
@@ -67,13 +67,19 @@ watch(() => [open.value, target.value], async () => {
else {
const row = props.inquiry
Object.assign(form, { customerId: row?.customerId || '', businessType: row?.businessType || 'LOCAL_LOOSEBOARD', deliveryMode: row?.deliveryMode || 'SELF_DELIVERY', estimatedArrivalAt: row?.estimatedArrivalAt || null, expectedPalletCount: row?.expectedPalletCount ?? null, cargo: row ? { ...row.cargo } : blankCargo() })
- if (!row && props.offer?.destinations.length === 1) form.cargo.destinationKey = props.offer.destinations[0]!.destinationKey
+ if (!row && props.sourceDemand) {
+ const source = props.sourceDemand
+ Object.assign(form, { customerId: source.customerId, businessType: 'LOCAL_LOOSEBOARD', expectedPalletCount: source.estimatedPalletCount })
+ Object.assign(form.cargo, { cargoName: source.productName, packageCount: source.packageCount, weightKg: source.totalWeightKg, volumeCbm: source.totalVolumeCbm, currency: source.currency, contactName: source.originContactName, contactPhone: source.originContactPhone, remark: source.remark })
+ customers.value = [{ id: source.customerId, code: source.customerId, name: `原单客户 ${source.customerId}` }]
+ }
+ if (!row && !props.sourceDemand && props.offer?.destinations.length === 1) form.cargo.destinationKey = props.offer.destinations[0]!.destinationKey
quoteSource.value = row?.quoteSource || null; form.quoteRef = row?.quoteSource ? fleetQuoteReference(row.quoteSource) : null
}
await nextTick(); guard.capture()
}, { immediate: true })
function searchCustomers(keyword = '') {
- if (lock.value || props.inquiry) return
+ if (lock.value || props.inquiry || props.sourceDemand) return
clearTimeout(timer); const current = ++customerSequence
timer = setTimeout(async () => {
customerLoading.value = true; customerError.value = null
@@ -97,7 +103,7 @@ async function save() {
if (form.quoteRef && !canQuote.value) { ElMessage.warning('修改带报价来源的意向需要报价读取权限'); return }
const current = generation; busy.value = true; error.value = null
try {
- pending.value ||= { target: target.value, confirm: !!props.confirmMode, body: { tripRecordVersion: props.offer.tripRecordVersion, offerRecordVersion: props.offer.recordVersion, recordVersion: props.inquiry?.recordVersion ?? null, customerId: form.customerId, businessType: form.businessType, deliveryMode: form.deliveryMode, estimatedArrivalAt: form.estimatedArrivalAt || null, expectedPalletCount: form.expectedPalletCount ?? null, cargo: { ...form.cargo, packageCount: form.cargo.packageCount ?? null, palletSpaces: form.cargo.palletSpaces ?? null, weightKg: form.cargo.weightKg ?? null, volumeCbm: form.cargo.volumeCbm ?? null, unitPrice: form.cargo.unitPrice ?? null }, quoteRef: form.quoteRef ? { ...form.quoteRef } : null, idempotencyKey: crypto.randomUUID() } }
+ pending.value ||= { target: target.value, confirm: !!props.confirmMode, body: { ...(props.sourceDemand && !props.inquiry ? { sourceOrderNo: props.sourceDemand.orderNo, sourceOrderVersion: props.sourceDemand.version } : {}), tripRecordVersion: props.offer.tripRecordVersion, offerRecordVersion: props.offer.recordVersion, recordVersion: props.inquiry?.recordVersion ?? null, customerId: form.customerId, businessType: form.businessType, deliveryMode: form.deliveryMode, estimatedArrivalAt: form.estimatedArrivalAt || null, expectedPalletCount: form.expectedPalletCount ?? null, cargo: { ...form.cargo, packageCount: form.cargo.packageCount ?? null, palletSpaces: form.cargo.palletSpaces ?? null, weightKg: form.cargo.weightKg ?? null, volumeCbm: form.cargo.volumeCbm ?? null, unitPrice: form.cargo.unitPrice ?? null }, quoteRef: form.quoteRef ? { ...form.quoteRef } : null, idempotencyKey: crypto.randomUUID() } }
const result = pending.value.confirm && props.inquiry ? await saveAndConfirmInquiry(props.inquiry.bookingNo, pending.value.body) : await saveCapacityInquiry(props.offer.offerNo, props.inquiry?.bookingNo || null, pending.value.body)
if (current === generation) { pending.value = null; guard.capture(); emit('saved', result); open.value = false; ElMessage.success(result.inquiry.status === 'CONFIRMED' ? '已保留货位并关联本土订单,尚未安排来货' : '登记已保存,调度可见,尚未保留货位') }
} catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '意向保存未完成'); if (cause instanceof ApiResponseError) pending.value = null } }
@@ -108,7 +114,8 @@ onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clear
- 核对本票货量、价格与交货资料后确认承运。成功将保留货位并创建关联本土订单,不自动安排仓库收货。
+ 散板原单:{{ sourceDemand.orderNo }} · {{ sourceDemand.origin }} → {{ sourceDemand.destination }}。请核对卸货站、标准托位和预计到仓时间。
+ 核对本票货量、价格与交货资料后确认承运。成功将保留货位并关联本土订单,不自动安排仓库收货。
本目的站可接余位
{{ selectedCapacity.palletSpaces }} 托位 · {{ selectedCapacity.weightKg }} kg · {{ selectedCapacity.volumeCbm }} m³
@@ -129,8 +136,8 @@ onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clear
- 委托客户先选客户即可登记;多站车次需选择卸货站。保存后调度可见,尚不保位。
暂无可选客户
-
+ 委托客户先选客户即可登记;多站车次需选择卸货站。保存后调度可见,尚不保位。
暂无可选客户
+
{{ cargoLabel === '已核实' ? '已核实货量与预计实托数' : readonly ? `${cargoLabel}货量` : '已知货量(可先填写,未知可留空)' }}
确认承运前待补资料{{ inquiry?.progress.missingFields.length ? `(还需:${inquiry.progress.missingFields.join('、')})` : '(货名、价格、到仓及联系信息)' }}
-
+
diff --git a/src/modules/overseas-master-data/index.ts b/src/modules/overseas-master-data/index.ts
index 30f63890..bc159f04 100644
--- a/src/modules/overseas-master-data/index.ts
+++ b/src/modules/overseas-master-data/index.ts
@@ -14,3 +14,7 @@ export type { TripPreviousDispatch } from './tripTypes'
export { listVehicles } from './api/overseasMasterData'
export { listDrivers } from './api/drivers'
export type { DriverDetail } from './driverTypes'
+export { normalizeTripDetail } from './api/trips'
+export type { TripDetail, TripAssignment, TripResourceRelease } from './tripTypes'
+export type { PickupCandidate, PickupTask, PickupEvidence, PickupPlan, PickupCommand, PickupConfirmation } from './api/pickups'
+export { pickupStatusLabels, downloadPickupEvidence } from './api/pickups'
diff --git a/src/modules/overseas-master-data/pages/CapacityOffersBehavior.test.ts b/src/modules/overseas-master-data/pages/CapacityOffersBehavior.test.ts
index f8d575e1..c9a4a81b 100644
--- a/src/modules/overseas-master-data/pages/CapacityOffersBehavior.test.ts
+++ b/src/modules/overseas-master-data/pages/CapacityOffersBehavior.test.ts
@@ -15,7 +15,7 @@ const state = vi.hoisted(() => ({ manage: false, write: true, quote: false, link
vi.mock('vue-router', () => ({ useRoute: () => state.route, useRouter: () => ({ push: state.push }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (p: string) => state.links.has(p) || (p === 'platform:fleet:capacity-offer:manage' ? state.manage : p === 'platform:fleet:quote:list' ? state.quote : p === 'platform:fleet:capacity-offer:list' || p.includes(':inquiry-') && state.write) }) }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined) } }))
-vi.mock('../api/capacityOffers', () => ({ listCapacityOffers: vi.fn(), listCapacityInquiries: vi.fn(), getCapacityOffer: vi.fn(), getCapacityInquiry: vi.fn(), saveCapacityInquiry: vi.fn(), saveAndConfirmInquiry: vi.fn(), commandCapacityInquiry: vi.fn(), listOfferCustomers: vi.fn(), listOfferQuotes: vi.fn(), saveCapacityOffer: vi.fn(), commandCapacityOffer: vi.fn() }))
+vi.mock('../api/capacityOffers', () => ({ listLooseboardDemands: vi.fn(), listCapacityOffers: vi.fn(), listCapacityInquiries: vi.fn(), getCapacityOffer: vi.fn(), getCapacityInquiry: vi.fn(), saveCapacityInquiry: vi.fn(), saveAndConfirmInquiry: vi.fn(), commandCapacityInquiry: vi.fn(), listOfferCustomers: vi.fn(), listOfferQuotes: vi.fn(), saveCapacityOffer: vi.fn(), commandCapacityOffer: vi.fn() }))
export const offer: CapacityOffer = { offerNo: 'CO-1', tripNo: 'TR-1', status: 'OPEN', recordVersion: 2, tripRecordVersion: 5, warehouseId: '92', warehouseName: '测试起运仓', plannedDepartureAt: '2026-10-10T12:00:00Z', cutoffAt: '2026-10-10T10:00:00Z', contactName: '调度', contactPhone: '测试联系信息', remark: null, capacityKnown: false, canCreateInquiry: true, canConfirm: false, blockReason: '待调度核对', destinations: [{ destinationKey: 'FBA:ONT8', name: 'ONT8', deliveryIndex: 1, remaining: null }], segments: [], actions: { canEdit: true, canPublish: false, canPause: true, canResume: false, canClose: true } }
export const inquiry: CapacityInquiry = { bookingNo: 'FB-1', offerNo: 'CO-1', tripNo: 'TR-1', status: 'DRAFT', recordVersion: 1, offerRecordVersion: 2, tripRecordVersion: 5, salesOwnerUserId: '17', customerId: '19', customerCode: 'C-19', customerName: '测试客户', businessType: 'US_TRUCK', deliveryMode: 'SELF_DELIVERY', estimatedArrivalAt: null, expectedPalletCount: null, cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null }, destinationLabel: 'ONT8', agreedAmount: null, quoteSource: null, businessOrderNo: null, incomingNo: null, dispatchNo: null, updatedAt: '2026-10-07T00:00:00Z', actions: { canEdit: true, canConfirm: false, canCancel: true, blockReason: null }, appointmentCoverage: null, progress: { stage: 'NEEDS_DETAILS', label: '待补资料', reservation: '尚未保位', owner: '销售', primaryAction: 'EDIT', message: '确认前还需补充资料', missingFields: ['预计重量'] } }
const pageOf =
(records: T[], total = records.length) => ({ records, total, page: 1, size: 20, pages: Math.ceil(total / 20) })
@@ -369,3 +369,18 @@ it('fetches an off-page returned inquiry once per refresh and retries without fa
expect(api.listCapacityInquiries).not.toHaveBeenCalled()
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
})
+
+it('shows customer looseboard demand in the same pool and passes the original to the selected trip inquiry', async () => {
+ const source = { orderNo: 'LB-SOURCE', customerId: '19', version: 2, origin: 'LA', destination: 'ONT8', productName: '货物', estimatedPalletCount: 3, packageCount: 12, totalWeightKg: 100, totalVolumeCbm: 2, currency: 'USD', originContactName: 'Alice', originContactPhone: '555', remark: null }
+ vi.mocked(api.listLooseboardDemands).mockResolvedValue(pageOf([source]))
+ const { wrapper } = mount(CapacityOffersView); await flushPromises()
+ await button(wrapper, '待匹配散板').trigger('click'); await flushPromises()
+ expect(api.listLooseboardDemands).toHaveBeenCalledWith({ page: 1, size: 20, keyword: undefined })
+ expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toEqual([source])
+ expect(api.getCapacityOffer).not.toHaveBeenCalled()
+ // 用页面实际暴露的匹配动作选择来源,再沿已有登记入口选车。
+ const vm = wrapper.vm as unknown as { matchDemand: (row: typeof source) => void; create: (row: CapacityOffer) => Promise }
+ vm.matchDemand(source); await flushPromises(); await vm.create(offer); await flushPromises()
+ expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('sourceDemand')).toEqual(source)
+ expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('offer').offerNo).toBe(offer.offerNo)
+})
diff --git a/src/modules/overseas-master-data/pages/CapacityOffersView.vue b/src/modules/overseas-master-data/pages/CapacityOffersView.vue
index 28847777..eed035ab 100644
--- a/src/modules/overseas-master-data/pages/CapacityOffersView.vue
+++ b/src/modules/overseas-master-data/pages/CapacityOffersView.vue
@@ -13,18 +13,21 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import CapacityInquiryTable from '../components/CapacityInquiryTable.vue'
import CapacityInquiryFulfillment from '../components/CapacityInquiryFulfillment.vue'
import CapacityInquiryDialog from '../components/CapacityInquiryDialog.vue'
-import { listCapacityOffers, listCapacityInquiries, getCapacityOffer, getCapacityInquiry } from '../api/capacityOffers'
-import { offerStatusLabels, inquiryStatusLabels, offerTime, loadText, type CapacityOffer, type CapacityInquiry, type InquiryResult, type OfferQuery, type InquiryQuery } from '../capacityOfferTypes'
+import { listLooseboardDemands, listCapacityOffers, listCapacityInquiries, getCapacityOffer, getCapacityInquiry } from '../api/capacityOffers'
+import { offerStatusLabels, inquiryStatusLabels, offerTime, loadText, type LooseboardDemand, type CapacityOffer, type CapacityInquiry, type InquiryResult, type OfferQuery, type InquiryQuery } from '../capacityOfferTypes'
import { inquirySourceQuery, inquiryOrderTarget } from '../inquiryNavigation'
import { useCapacityInquiryCommands } from '../useCapacityInquiryCommands'
const router = useRouter(), access = useAccessPreviewStore()
const manage = computed(() => access.can('platform:fleet:capacity-offer:manage'))
-const tab = ref<'offers' | 'inquiries'>(manage.value ? 'inquiries' : 'offers')
+const tab = ref<'offers' | 'inquiries' | 'demands'>(manage.value ? 'inquiries' : 'offers')
const filters = ref(manage.value ? { scope: 'TODO' } : {}), applied = ref({ ...filters.value })
const page = ref(1), size = ref(20), total = ref(0), loading = ref(false), error = ref(null)
const offers = ref([]), inquiries = ref([])
const editorOpen = ref(false), editing = ref(null), selectedOffer = ref(null), readonly = ref(true)
const confirmMode = ref(false)
+const demands = ref([]), selectedDemand = ref(null)
+const demandColumns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 130, fixed: 'left', slotName: 'actions' }, { key: 'orderNo', label: '散板原单', minWidth: 220 }, { key: 'customerId', label: '客户编号', minWidth: 140 }, { key: 'origin', label: '起运地', minWidth: 170 }, { key: 'destination', label: '目的地', minWidth: 170 }, { key: 'productName', label: '货物', minWidth: 160 }, { key: 'estimatedPalletCount', label: '预计实托', width: 110 }, { key: 'packageCount', label: '箱数', width: 100 }]
+function matchDemand(row: LooseboardDemand) { selectedDemand.value = row; switchTab('offers') }
const fulfillmentOpen = ref(false), fulfillmentRow = ref(null), fulfillmentMode = ref<'incoming' | 'receipt' | 'bind' | 'view'>('view')
async function fulfillmentSaved() {
const row = fulfillmentRow.value
@@ -54,7 +57,7 @@ const fields = computed(() => tab.value === 'offers' ? [
{ key: 'departureFrom', label: '发车起日', type: 'date', placeholder: '发车起日', width: 170 }, { key: 'departureTo', label: '发车止日', type: 'date', placeholder: '发车止日', width: 170 },
{ key: 'canConfirm', label: '接单资格', type: 'select', placeholder: '全部接单资格', width: 150, options: [{ value: 'true', label: '可确认承运' }, { value: 'false', label: '暂不可确认' }] },
...(manage.value ? [{ key: 'scope', label: '发布范围', type: 'select', placeholder: '可登记意向', width: 180, options: [{ value: 'AVAILABLE', label: '可登记意向' }, { value: 'ALL', label: '全部发布(含历史)' }] } as QueryField, { key: 'status', label: '发布状态', type: 'select', placeholder: '全部发布状态', width: 140, options: Object.entries(offerStatusLabels).map(([value, label]) => ({ value, label })) } as QueryField] : []),
-] : [
+] : tab.value === 'demands' ? [{ key: 'keyword', label: '需求关键词', type: 'input', placeholder: '原单号 / 起运地 / 目的地', width: 280 }] : [
{ key: 'keyword', label: '意向关键词', type: 'input', placeholder: '预订号 / 客户 / 车次', width: 260 },
{ key: 'status', label: '意向状态', type: 'select', placeholder: '全部状态', width: 150, options: Object.entries(inquiryStatusLabels).map(([value, label]) => ({ value, label })) },
...(manage.value ? [{ key: 'scope', label: '意向范围', type: 'select', width: 150, placeholder: '我的登记', options: [{ value: 'MINE', label: '我的登记' }, { value: 'ALL', label: '全部登记' }, { value: 'TODO', label: '待我处理' }] } as QueryField] : []),
@@ -73,12 +76,14 @@ async function create(row: CapacityOffer) {
try { const value = await getCapacityOffer(row.offerNo); if (current !== editorSequence) return; selectedOffer.value = value.offer; confirmMode.value = false; editing.value = null; readonly.value = false; editorOpen.value = true }
catch (cause) { if (current === editorSequence) error.value = getApiErrorDetails(cause, '登记信息读取失败') }
}
-function switchTab(value: 'offers' | 'inquiries') { if (tab.value === value) return; filters.value = {}; applied.value = {}; page.value = 1; total.value = 0; tab.value = value }
+function switchTab(value: 'offers' | 'inquiries' | 'demands') { if (tab.value === value) return; filters.value = {}; applied.value = {}; page.value = 1; total.value = 0; tab.value = value }
async function load() {
const current = ++sequence; loading.value = true; error.value = null
try {
const q = applied.value
- if (tab.value === 'offers') {
+ if (tab.value === 'demands') {
+ const value = await listLooseboardDemands({ page: page.value, size: size.value, keyword: String(q.keyword || '') || undefined }); if (current === sequence) { demands.value = value.records; total.value = value.total }
+ } else if (tab.value === 'offers') {
const query: OfferQuery = { scope: manage.value && q.scope === 'ALL' ? 'ALL' : 'AVAILABLE', page: page.value, size: size.value, origin: String(q.origin || '') || undefined, destination: String(q.destination || '') || undefined, departureFrom: String(q.departureFrom || '') || undefined, departureTo: String(q.departureTo || '') || undefined, status: manage.value ? String(q.status || '') || undefined : undefined, canConfirm: q.canConfirm === 'true' ? true : q.canConfirm === 'false' ? false : undefined }
const value = await listCapacityOffers(query); if (current === sequence) { offers.value = value.records; total.value = value.total }
} else {
@@ -90,6 +95,7 @@ async function load() {
}
function search() { applied.value = { ...filters.value }; if (page.value !== 1) page.value = 1; else void load() }
function accept(value: InquiryResult) {
+ if (selectedDemand.value && value.inquiry.businessOrderNo === selectedDemand.value.orderNo) { selectedDemand.value = null; switchTab('inquiries') }
const index = inquiries.value.findIndex(row => row.bookingNo === value.inquiry.bookingNo)
if (index >= 0) inquiries.value[index] = value.inquiry
for (const row of inquiries.value) if (row.offerNo === value.offer.offerNo) { row.offerRecordVersion = value.offer.recordVersion; row.tripRecordVersion = value.offer.tripRecordVersion }
@@ -121,11 +127,18 @@ async function recheckEditor() {
watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { followupSequence++; sequence++; editorSequence++ })
+
+ 正在匹配 {{ selectedDemand.orderNo }}:{{ selectedDemand.origin }} → {{ selectedDemand.destination }}。请选择合适车次,保存登记后尚不保位。取消选择
+
+ 待匹配散板
+
+
+ 匹配车次
+
散板需求
待我处理选择车次发布余位
@@ -138,16 +151,15 @@ watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { followu
{{ offerStatusLabels[(row as CapacityOffer).status] }} · {{ row.canConfirm ? '可登记,确认后保位' : row.canCreateInquiry ? '可先登记,待调度核对,尚不保位' : '暂不可登记' }}{{ row.blockReason }}
{{ row.contactName || '—' }}
{{ row.contactPhone || '—' }}
-
+
{{ applied.scope === 'TODO' ? '待我处理' : applied.scope === 'ALL' ? '全部登记' : '我的登记' }}
重试原操作
-
+
diff --git a/src/modules/overseas-master-data/tripTypes.ts b/src/modules/overseas-master-data/tripTypes.ts
index b34ed9d6..49310bbc 100644
--- a/src/modules/overseas-master-data/tripTypes.ts
+++ b/src/modules/overseas-master-data/tripTypes.ts
@@ -8,7 +8,7 @@ export interface TripStop { name: string; address?: string | null; destinationKe
export type ReleasedVehicleStatus = 'IN_SERVICE' | 'MAINTENANCE' | 'INACTIVE' | 'RETURNED'
export interface TripReleaseInfo { actualEndAt: string; endLocation: string; vehicleStatus: ReleasedVehicleStatus | null }
export interface FleetTrip {
- tripPurpose?: 'DELIVERY' | 'PICKUP'
+ tripPurpose?: 'DELIVERY' | 'PICKUP' | 'LOCAL_TRUCK'
tripNo: string
status: TripStatus
warehouseId: string
diff --git a/src/modules/overseas-warehouse/api/transportTasks.ts b/src/modules/overseas-warehouse/api/transportTasks.ts
new file mode 100644
index 00000000..950bdb1f
--- /dev/null
+++ b/src/modules/overseas-warehouse/api/transportTasks.ts
@@ -0,0 +1,26 @@
+import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
+import { normalizeTripDetail, type TripDetail, type PickupTask, type PickupCandidate, type PickupEvidence, type PickupPlan, type TripAssignment, type TripResourceRelease } from '@/modules/overseas-master-data'
+
+export type TransportType = 'PICKUP' | 'LOCAL_TRUCK'
+export const transportLabels = { DELIVERY: '仓库派送', PICKUP: '到仓取货', LOCAL_TRUCK: '本土卡车' }
+export interface TransportDetail { dispatchNo: string; taskType: TransportType; execution: TripDetail; customerId: string | null; orderNo: string | null; pickups: PickupTask[]; completionEvidence: PickupEvidence[] }
+export interface TruckCandidate { customerId: string; orderNo: string; origin: string; originAddress: string; destination: string; destinationAddress: string; palletCount: number; packageCount: number }
+export interface TruckPlan { customerId: string; orderNo: string; warehouseId: string; plannedDepartureAt: string; expectedDriverEndAt: string; idempotencyKey: string }
+type WireDetail = Omit & { execution: Parameters[0] }
+const base = '/platform/v1/last-leg/dispatches/transport-tasks'
+const path = (number: string) => `${base}/${encodeURIComponent(number)}`
+async function get(url: string, params?: object) { return unwrapApiResponse(await http.get, ApiEnvelope>(url, { params })) }
+async function post(url: string, body: object) { return unwrapApiResponse(await http.post, ApiEnvelope>(url, body)) }
+const normalize = (value: WireDetail): TransportDetail => ({ ...value, execution: normalizeTripDetail(value.execution), pickups: value.pickups.map(row => ({ ...row, packageCount: Number(row.packageCount), actualPackages: row.actualPackages == null ? null : Number(row.actualPackages) })) })
+export const getTransport = (number: string) => get(path(number)).then(normalize)
+export const pickupCandidates = () => get<{ records: PickupCandidate[]; total: number | string }>(`${base}/pickup-candidates`, { page: 1, size: 100 })
+export const truckCandidates = () => get(`${base}/truck-candidates`)
+export const createTransportPickup = (body: PickupPlan) => post(`${base}/pickup`, body).then(normalize)
+export const createTransportTruck = (body: TruckPlan) => post(`${base}/truck`, body).then(normalize)
+export const assignTransport = (number: string, body: TripAssignment) => post(`${path(number)}/assign`, body).then(normalize)
+export const transportOccupancy = (number: string, kind: 'VEHICLE' | 'DRIVER', resourceIds: string[]) => resourceIds.length ? post>(`${path(number)}/resource-occupancy`, { kind, resourceIds }) : Promise.resolve([])
+export const cancelTransport = (number: string, body: object) => post(`${path(number)}/cancel`, body)
+export const releaseTransport = (number: string, body: TripResourceRelease) => post(`${path(number)}/release`, body)
+export const commandTransport = (number: string, suffix: string, body: object) => post(`${path(number)}/${suffix}`, body)
+export async function uploadTransport(number: string, suffix: string, file: File) { const body = new FormData(); body.append('file', file); return post(`${path(number)}/${suffix}/files`, body) }
+export const downloadTransportEvidence = (number: string, fileId: string, row?: PickupTask): Promise => http.get(`${path(number)}/${row ? `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}` : 'truck'}/files/${encodeURIComponent(fileId)}`, { responseType: 'blob' })
diff --git a/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue b/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue
new file mode 100644
index 00000000..abd8952a
--- /dev/null
+++ b/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue
@@ -0,0 +1,126 @@
+
+
+
+
+ 按原请求重试
+
+ {{ transportLabels[detail.taskType] }} · {{ { PLANNED: '待发车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' }[trip.status] }}
+ 本土卡车原单:{{ detail.orderNo }} · 客户:{{ detail.customerId }}
+ 调度仓库:{{ trip.warehouseName }} · 车辆:{{ trip.vehiclePlate || '待分配' }} · 司机:{{ trip.driverName || '待分配' }} {{ trip.driverPhone }}
+ {{ index + 1 }}. {{ stop.name }} {{ stop.address }}
+
+
+ 加载车辆司机候选
+
+
+ 保存车辆司机
+
+
+ 确认发车
+ 取消排车
+ 确认运输完成
+ 结束车辆任务结束司机任务
+ 刷新运输进度
+
+ 送仓后由仓库独立清点收货,取货数量保留为运输事实。
+ {{ pickupStatusLabels[row.status] }}{{ file.fileName }}确认取货确认送仓
+
+ 已确认的运输完成凭证
{{ file.fileName }}
+
+ {{ selected?.transferNo }}
{{ file.fileName }}
确认实提
+ 确认任务结束并释放占用
+
+
diff --git a/src/modules/overseas-warehouse/components/TransportTaskCreateDialog.vue b/src/modules/overseas-warehouse/components/TransportTaskCreateDialog.vue
new file mode 100644
index 00000000..e1c019ee
--- /dev/null
+++ b/src/modules/overseas-warehouse/components/TransportTaskCreateDialog.vue
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+ 审核通过的仓储转运上门取货单自动进入候选;同车货物必须送往同一个海外仓。
+
+
+
+
+
+
+
+
+
+ 取消{{ pending ? '按原请求重试' : '生成排车单' }}
+
+
diff --git a/src/modules/overseas-warehouse/dispatchContracts.ts b/src/modules/overseas-warehouse/dispatchContracts.ts
index a3c27d53..1a077caf 100644
--- a/src/modules/overseas-warehouse/dispatchContracts.ts
+++ b/src/modules/overseas-warehouse/dispatchContracts.ts
@@ -24,13 +24,13 @@ export interface DispatchBatchAllocation { planPalletId: string; batchKey: strin
export interface DispatchQuery { page: number; size: number; dispatchNo?: string; vehiclePlate?: string; warehouseId?: string; customerId?: string; fbaAppointmentNo?: string; status?: DispatchStatus | 'ALL'; stage?: DispatchStage; beforeLoadingSection?: BeforeLoadingSection; reviewStatus?: ReviewStatus }
export interface ReviewCounts { notSubmitted: number; pending: number; approved: number; rejected: number }
export interface DispatchStatistics { matchedTotal: number; activeTotal: number; cancelledTotal: number; stageCounts: Record; beforeLoadingReviewCounts: { vehicleInfoTotal: number; dispatchReview: ReviewCounts; loadingReview: ReviewCounts } }
-export interface DispatchListRow { dispatchNo: string; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; resources: Resources; warehouseId: string; warehouseCode: string; warehouseName: string; plannedDepartureAt: string | null; departedAt: string | null; counts: Counts; pickingReconciled: boolean; arranged: boolean; actions: DispatchActions }
+export interface DispatchListRow { dispatchNo: string; taskType?: 'DELIVERY' | 'PICKUP' | 'LOCAL_TRUCK'; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; resources: Resources; warehouseId: string; warehouseCode: string; warehouseName: string; plannedDepartureAt: string | null; departedAt: string | null; counts: Counts; pickingReconciled: boolean; arranged: boolean; actions: DispatchActions }
export interface DispatchPage { records: DispatchListRow[]; total: number; page: number; size: number; pages: number; statistics: DispatchStatistics; snapshotAt: string }
export interface OutboundReference { customerId: string; customerName: string; outboundNo: string; status: string; packageCount: number; palletCount: number }
export interface DeliveryReference { groupKey: string; customerId: string; customerName: string; outboundNo: string; deliveryNo: string; destination: Destination; status: string; packageCount: number; palletCount: number }
export interface FbaAppointment { batchKey: string; batchNo: string | null; appointmentNo: string | null; destinationKey: string; destinationWarehouseCode: string | null; status: string; confirmedAt: string | null; blockReason: string | null }
export interface OrderDetail { customerId: string; customerName: string; orderNo: string; inboundCode: string | null; grossWeightKg: number | null; palletCount: number | null; destinationFbaWarehouse: string | null; volumeCbm: number | null; containerNo: string | null; sourceKey: string; sourceBusinessType: string | null; sourceBusinessNo: string | null }
-export interface DispatchDetail { dispatchNo: string; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; counts: Counts; pickingReconciled: boolean; actions: DispatchActions; vehicleInfo: Resources; dispatchInfo: { route: string | null; plannedDepartureAt: string | null; departedAt: string | null; estimatedArrivalAt: string | null; fbaAppointments: FbaAppointment[]; appointmentBatches: DispatchBatchReference[] }; documents: { dispatchNo: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[]; createdAt: string; updatedAt: string; remark: string | null }; orderDetails: OrderDetail[] }
+export interface DispatchDetail { dispatchNo: string; taskType?: 'DELIVERY' | 'PICKUP' | 'LOCAL_TRUCK'; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; counts: Counts; pickingReconciled: boolean; actions: DispatchActions; vehicleInfo: Resources; dispatchInfo: { route: string | null; plannedDepartureAt: string | null; departedAt: string | null; estimatedArrivalAt: string | null; fbaAppointments: FbaAppointment[]; appointmentBatches: DispatchBatchReference[] }; documents: { dispatchNo: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[]; createdAt: string; updatedAt: string; remark: string | null }; orderDetails: OrderDetail[] }
export interface CargoSelection { sourceType: 'INBOUND_PLAN' | 'RETURN_RECEIPT' | 'RELABEL'; planPalletId: string | null; returnReceiptId: string | null; relabelNo: string | null; warehousePalletId: string | null; sourceVersion: string }
export interface CreateDispatch { idempotencyKey: string; warehouseId: string; vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null; cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[] }
export interface VersionCommand { recordVersion: Version; idempotencyKey: string }
diff --git a/src/modules/overseas-warehouse/pages/DispatchDetailView.test.ts b/src/modules/overseas-warehouse/pages/DispatchDetailView.test.ts
index 09f3121e..82ebc326 100644
--- a/src/modules/overseas-warehouse/pages/DispatchDetailView.test.ts
+++ b/src/modules/overseas-warehouse/pages/DispatchDetailView.test.ts
@@ -16,9 +16,16 @@ vi.mock('@/modules/overseas-master-data', async () => ({
vehicleStatusLabels: (await vi.importActual('../../overseas-master-data/presentation')).vehicleStatusLabels,
inquiryReturnTarget: (value: Record) => value.fleetOfferNo ? { name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: value.fleetOfferNo }, query: { inquiry: value.fleetBookingNo } } : null,
}))
-const options = { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub, DispatchWorkflowDialog: true } } }
+const options = { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub, DispatchWorkflowDialog: true, TransportExecutionPanel: true } } }
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getDispatch).mockResolvedValue(detail()); can.mockReturnValue(false); push.mockReset(); Object.keys(query).forEach(key => delete query[key]) })
describe('whole-dispatch four-tab detail', () => {
+ it.each(['PICKUP', 'LOCAL_TRUCK'] as const)('hosts %s execution in the existing dispatch page', async taskType => {
+ vi.mocked(getDispatch).mockResolvedValueOnce({ ...detail(), taskType })
+ const wrapper = mount(DispatchDetailView, options); await flushPromises()
+ expect(wrapper.get('transport-execution-panel-stub').attributes('dispatchno')).toBe('PC/1')
+ expect(wrapper.findAll('[role="tab"]')).toHaveLength(0)
+ expect(wrapper.find('[aria-label="排车操作"]').exists()).toBe(false); wrapper.unmount()
+ })
it('returns to the original appointment date and view from the dispatch detail', async () => {
query.appointmentReturn = '/appointment-calendar?date=2026-10-21&mode=week'
const wrapper = mount(DispatchDetailView, options); await flushPromises()
diff --git a/src/modules/overseas-warehouse/pages/DispatchDetailView.vue b/src/modules/overseas-warehouse/pages/DispatchDetailView.vue
index 52ee2267..c5ebda22 100644
--- a/src/modules/overseas-warehouse/pages/DispatchDetailView.vue
+++ b/src/modules/overseas-warehouse/pages/DispatchDetailView.vue
@@ -13,6 +13,7 @@ import { getDispatch } from '../api/dispatches'
import type { DispatchAction, DispatchDetail } from '../dispatchContracts'
import { stageLabel } from '../dispatchContracts'
import { canDispatchAction, dispatchActionLabels, displayDispatchValue as text } from '../dispatchPresentation'
+import TransportExecutionPanel from '../components/TransportExecutionPanel.vue'
import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
import DispatchEvidenceFiles from '../components/DispatchEvidenceFiles.vue'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
@@ -81,8 +82,9 @@ onBeforeUnmount(() => { generation++ })
-
{{ dispatchActionLabels[value] }}调整卸货顺序
-
+
+ {{ dispatchActionLabels[value] }}调整卸货顺序
+
🚚车辆信息- {{ field[0] }}
- {{ field[1] }}
📋排车信息- {{ field[0] }}
- {{ field[1] }}
📝单据信息
diff --git a/src/modules/overseas-warehouse/pages/DispatchesView.test.ts b/src/modules/overseas-warehouse/pages/DispatchesView.test.ts
index 9e2fa442..f96a31c8 100644
--- a/src/modules/overseas-warehouse/pages/DispatchesView.test.ts
+++ b/src/modules/overseas-warehouse/pages/DispatchesView.test.ts
@@ -18,7 +18,7 @@ vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) })
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null }))
vi.mock('@/modules/overseas-master-data', () => ({ TransportRouteDialog: { template: '' }, routeLocation: (label: string) => ({ label }) }))
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), getDispatch: vi.fn(), getDispatchBolPdf: vi.fn() }))
-const options = { global: { plugins: [createPinia(), ElementPlus], stubs: { SmartDispatchDialog: true, DispatchWorkflowDialog: true } } }
+const options = { global: { plugins: [createPinia(), ElementPlus], stubs: { SmartDispatchDialog: true, DispatchWorkflowDialog: true, TransportTaskCreateDialog: true } } }
beforeEach(() => { vi.clearAllMocks(); identity.id = crypto.randomUUID(); sessionStorage.clear(); can.mockReturnValue(true); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); localStorage.clear(); vi.mocked(listDispatches).mockResolvedValue(page()); vi.mocked(getDispatch).mockResolvedValue(detail()) })
it('offers recovery of the original pending command independently of the filtered list rows', async () => {
const pending = useDispatchCommandRecovery(() => identity.id); pending.begin('PC-OTHER', 'DEPART', { recordVersion: 0, idempotencyKey: 'original-key' })
@@ -66,6 +66,15 @@ it('navigates by the business number without arranging a separate trip', async (
const wrapper = mount(DispatchesView, options); await flushPromises(); wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.vm.$emit('action', 'detail'); await flushPromises()
expect(push).toHaveBeenCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'PC/1' } }); expect(wrapper.text()).not.toContain('安排车次'); wrapper.unmount()
})
+it.each(['PICKUP', 'LOCAL_TRUCK'] as const)('opens %s through the existing dispatch detail and omits warehouse commands', async taskType => {
+ vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ taskType })]))
+ const wrapper = mount(DispatchesView, options); await flushPromises()
+ const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
+ expect(row.props('actions')).toEqual([expect.objectContaining({ key: 'detail', label: '运输操作' })])
+ row.vm.$emit('action', 'detail'); await flushPromises()
+ expect(push).toHaveBeenCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'PC/1' } })
+ expect(wrapper.text()).toContain('安排到仓取货'); expect(wrapper.text()).toContain('安排本土卡车'); wrapper.unmount()
+})
it('reads one BOL on demand and preserves popup failure handling', async () => {
vi.stubGlobal('open', vi.fn().mockReturnValue(null)); const wrapper = mount(DispatchesView, options); await flushPromises(); wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.vm.$emit('action', 'bol'); await flushPromises(); expect(getDispatchBolPdf).not.toHaveBeenCalled(); wrapper.unmount()
})
diff --git a/src/modules/overseas-warehouse/pages/DispatchesView.vue b/src/modules/overseas-warehouse/pages/DispatchesView.vue
index c32a5637..1ed4f084 100644
--- a/src/modules/overseas-warehouse/pages/DispatchesView.vue
+++ b/src/modules/overseas-warehouse/pages/DispatchesView.vue
@@ -20,6 +20,8 @@ import { getDispatch, getDispatchBolPdf, listDispatches } from '../api/dispatche
import { dispatchStages, reviewLabels, stageLabel, type BeforeLoadingSection, type DispatchAction, type DispatchListRow, type DispatchStage, type DispatchStatistics, type DispatchStatus, type ReviewStatus } from '../dispatchContracts'
import { canDispatchAction, dispatchActionLabels, dispatchActionPermissions } from '../dispatchPresentation'
import { formatOverseasDateTime as time } from '../lastMilePresentation'
+import TransportTaskCreateDialog from '../components/TransportTaskCreateDialog.vue'
+import { transportLabels, type TransportType } from '../api/transportTasks'
import SmartDispatchDialog from '../components/SmartDispatchDialog.vue'
import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
@@ -29,6 +31,8 @@ const auth = useAuthStore(), recovery = useDispatchCommandRecovery(() => auth.us
const rows = ref([]), statistics = ref(null)
const loading = ref(false), loadError = ref(null), mutationError = ref(null)
const rejectionError = ref(null)
+const transportOpen = ref(false), transportType = ref('PICKUP')
+function createTransport(type: TransportType) { transportType.value = type; transportOpen.value = true }
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
@@ -47,6 +51,7 @@ const fields = computed(() => [
])
const columns = computed(() => [
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' },
+ { key: 'taskType', label: '排车类型', width: 115, slotName: 'taskType' },
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 190 },
{ key: 'vehicle', label: '车牌号', minWidth: 130, slotName: 'vehicle' }, { key: 'driver', label: '司机/联系电话', minWidth: 170, slotName: 'driver' },
{ key: 'warehouseName', prop: 'warehouseName', label: '起运仓', minWidth: 160 },
@@ -74,6 +79,7 @@ function switchSection(value: BeforeLoadingSection) { section.value = value; mod
function openDetail(row: DispatchListRow) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }) }
const rowActionOrder: DispatchAction[] = ['EDIT', 'ADD_CARGO', 'REMOVE_CARGO', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'ARRANGE_DELIVERY', 'DEPART', 'CANCEL']
function rowActions(row: DispatchListRow): RowActionItem[] {
+ if (row.taskType && row.taskType !== 'DELIVERY') return [{ key: 'detail', label: '运输操作', priority: 500 }]
return [{ key: 'detail', label: '详情', priority: 500 }, ...rowActionOrder.filter(value => canDispatchAction(row.actions, value, access.can)).map((value, index) => ({ key: value, label: dispatchActionLabels[value], priority: 400 - index * 10, tone: value === 'CANCEL' ? 'danger' as const : undefined })), ...(canDispatchAction(row.actions, 'EDIT', access.can) ? [{ key: 'route-order', label: '调整卸货顺序', priority: 190 }] : []), { key: 'bol', label: '下载BOL', priority: 100, loading: bolLoadingNo.value === row.dispatchNo }]
}
function handleAction(value: string, row: DispatchListRow) {
@@ -117,15 +123,17 @@ onBeforeUnmount(() => { sequence++ })
{{ recovery.pending.value.dispatchNo || '新排车单' }} 的{{ dispatchActionLabels[recovery.pending.value.action] }}{{ recovery.pending.value.result ? '操作已完成,页面资料待刷新。' : '结果尚待核对。' }}{{ recovery.pending.value.result ? '刷新页面资料' : '核对原请求' }}
返回预约工作排车列表
- 新建排车
+ 安排到仓取货安排本土卡车新建排车
操作资格未返回,请刷新核对
+ {{ transportLabels[(row.taskType || 'DELIVERY') as keyof typeof transportLabels] }}
{{ row.resources.vehicle?.plate || '—' }}{{ row.resources.driver?.name || '—' }}
{{ row.resources.driver?.phone || '—' }}
{{ reviewLabels[review.status as ReviewStatus] }}
{{ review.reason }}
{{ review.invalidatedReason }}
{{ time(review.reviewedAt) }}
{{ row.counts.palletCount }}托/{{ row.counts.packageCount }}箱/已拣{{ row.counts.pickedPackageCount }}箱{{ time(row.plannedDepartureAt) }}{{ time(row.departedAt) }}{{ row.status === 'CANCELLED' ? '已取消' : stageLabel(row.stage) }}查看路线
+
diff --git a/src/modules/unified-orders/api/unifiedOrders.ts b/src/modules/unified-orders/api/unifiedOrders.ts
index 9fbb1d30..63eb2061 100644
--- a/src/modules/unified-orders/api/unifiedOrders.ts
+++ b/src/modules/unified-orders/api/unifiedOrders.ts
@@ -30,7 +30,7 @@ export const unifiedOrderStatusOptions: Array<{ value: UnifiedOrderMainStatus; l
{ value: 'WAIT_DISPATCH', label: '待排车' },
{ value: 'OUTBOUND_DOCUMENT', label: '出库单' },
{ value: 'DELIVERING', label: '派送中' },
- { value: 'SIGNED', label: '签收完成' },
+ { value: 'SIGNED', label: '派送完成' },
]
export interface UnifiedOrderMeasurementSummary {
@@ -694,7 +694,7 @@ export async function listPlatformUnifiedOrderFeeDetails(accountId: string, inbo
}
export function unifiedOrderStatusLabel(status: UnifiedOrderMainStatus) {
- if (status === 'DELIVERED') return '签收完成'
+ if (status === 'DELIVERED') return '派送完成'
if (status === 'COMPLETED') return '已完成'
return unifiedOrderStatusOptions.find((item) => item.value === status)?.label || '未知状态'
}
diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.fulfillment.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.fulfillment.test.ts
index 9c8afb11..47bf8ac8 100644
--- a/src/modules/unified-orders/pages/UnifiedOrdersView.fulfillment.test.ts
+++ b/src/modules/unified-orders/pages/UnifiedOrdersView.fulfillment.test.ts
@@ -106,6 +106,56 @@ describe('UnifiedOrdersView downstream fulfillment detail', () => {
})
})
+ it('keeps unchanged actual and outer volumes on the same server snapshot with three decimal places', async () => {
+ const wrapper = mountDetail()
+ await flushPromises()
+ const vm = wrapper.vm as unknown as {
+ measurementDetail: Record
+ measurementSectionModes: { outer: string; actual: string }
+ outerMeasurementRows: Record[]
+ actualMeasurementRows: Record[]
+ measurementTotals(rows: Record[]): { volume: number; weightVolume: number }
+ measurementValue(row: Record, key: string): string
+ formatMeasurementVolume(value: number | null): string
+ }
+ const items = [
+ { lengthCm: 60, widthCm: 50, heightCm: 50, packageCount: 10, unitVolumeCbm: 0.15, totalVolumeCbm: 1.5 },
+ { lengthCm: 61, widthCm: 51, heightCm: 51, packageCount: 9, unitVolumeCbm: 0.159, totalVolumeCbm: 1.431 },
+ { lengthCm: 50, widthCm: 40, heightCm: 40, packageCount: 1, unitVolumeCbm: 0.08, totalVolumeCbm: 0.08 },
+ ].map((row, index) => ({
+ ...row, itemNo: index + 1, coefficientMode: 'ACTUAL',
+ calculatedOuterLengthCm: row.lengthCm, calculatedOuterWidthCm: row.widthCm, calculatedOuterHeightCm: row.heightCm,
+ }))
+ vm.measurementDetail = { items }
+ vm.measurementSectionModes.outer = 'ACTUAL'
+ vm.measurementSectionModes.actual = 'ACTUAL'
+ await flushPromises()
+
+ for (const rows of [vm.outerMeasurementRows, vm.actualMeasurementRows]) {
+ expect(rows.map(row => vm.measurementValue(row, 'totalVolumeCbm'))).toEqual(['1.500', '1.431', '0.080'])
+ expect(rows.map(row => vm.measurementValue(row, 'unitVolumeCbm'))).toEqual(['0.150', '0.159', '0.080'])
+ expect(vm.formatMeasurementVolume(vm.measurementTotals(rows).volume)).toBe('3.011')
+ expect(vm.formatMeasurementVolume(vm.measurementTotals(rows).weightVolume)).toBe('3.011')
+ }
+ expect(vm.formatMeasurementVolume(3)).toBe('3.000')
+ expect(vm.formatMeasurementVolume(0)).toBe('0.000')
+ expect(vm.formatMeasurementVolume(null)).toBe('—')
+
+ // 仓库修正的外箱仍与实测尺寸分开,不能把外箱计费快照当成原始实测体积。
+ vm.measurementDetail = { items: [{ ...items[1], outerLengthCm: 62,
+ calculatedOuterLengthCm: 62, unitVolumeCbm: 0.1613, totalVolumeCbm: 1.4517,
+ calculationRuleVersion: 'WAREHOUSE_OUTER_OVERRIDE_V1' }] }
+ await flushPromises()
+ expect(vm.measurementValue(vm.outerMeasurementRows[0]!, 'totalVolumeCbm')).toBe('1.452')
+ expect(vm.measurementValue(vm.actualMeasurementRows[0]!, 'totalVolumeCbm')).toBe('1.428')
+
+ vm.measurementDetail = { items: [{ ...items[1], coefficientMode: 'ADD', calculatedOuterLengthCm: 62,
+ calculatedOuterWidthCm: 52, calculatedOuterHeightCm: 52, unitVolumeCbm: 0.168, totalVolumeCbm: 1.512 }] }
+ await flushPromises()
+ expect(vm.measurementValue(vm.actualMeasurementRows[0]!, 'totalVolumeCbm')).toBe('1.428')
+ wrapper.unmount()
+ })
+
it('downloads one preview for cargo rows sharing the same product image', async () => {
vi.mocked(getPlatformUnifiedOrder).mockResolvedValue({
...detail,
diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.requests.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.requests.test.ts
index 10ddae91..05cb9119 100644
--- a/src/modules/unified-orders/pages/UnifiedOrdersView.requests.test.ts
+++ b/src/modules/unified-orders/pages/UnifiedOrdersView.requests.test.ts
@@ -45,7 +45,7 @@ vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({
{ value: 'LOADING', label: '装柜中' },
{ value: 'LOADED', label: '装柜完成' },
{ value: 'DELIVERING', label: '派送中' },
- { value: 'SIGNED', label: '签收完成' },
+ { value: 'SIGNED', label: '派送完成' },
],
}))
@@ -121,7 +121,7 @@ describe('UnifiedOrdersView list request coordination', () => {
expect(tabs[1]!.text()).toBe('待审核(12)')
expect(tabs[2]!.text()).toBe('待测量数据(0)')
expect(tabs[3]!.text()).toBe('测量数据审核')
- expect(tabs.at(-1)!.text()).toBe('签收完成(3)')
+ expect(tabs.at(-1)!.text()).toBe('派送完成(3)')
expect(tabs.map((tab) => tab.text())).not.toContain('派送完成')
expect(tabs.map((tab) => tab.text())).not.toContain('已完成')
await tabs[1]!.trigger('click')
diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts
index 6d92eca6..ed18d97b 100644
--- a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts
+++ b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts
@@ -62,7 +62,7 @@ describe('UnifiedOrdersView contract', () => {
it('exposes the product main statuses including dropship palletizing', () => {
expect(unifiedOrderStatusOptions.map((item) => item.label)).toEqual([
- '待审核', '待测量数据', '待客户确认', '已确认数据', '待上传资料', '一件代发打托', '待装柜', '装柜中', '装柜完成', '出口报关', '已开船/待清关', '待拆柜', '待排车', '出库单', '派送中', '签收完成',
+ '待审核', '待测量数据', '待客户确认', '已确认数据', '待上传资料', '一件代发打托', '待装柜', '装柜中', '装柜完成', '出口报关', '已开船/待清关', '待拆柜', '待排车', '出库单', '派送中', '派送完成',
])
for (const legacyLabel of ['差异处理中', '进入分柜']) {
expect(source).not.toContain(legacyLabel)
diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.vue b/src/modules/unified-orders/pages/UnifiedOrdersView.vue
index 7113b185..dda6da5d 100644
--- a/src/modules/unified-orders/pages/UnifiedOrdersView.vue
+++ b/src/modules/unified-orders/pages/UnifiedOrdersView.vue
@@ -500,10 +500,18 @@ function calculatedMeasurementRow(
const strategyDimensions = matchedDimensions.map((value) => roundedUp(value * specificationCoefficient, 3))
const [lengthCm, widthCm, heightCm] = strategyDimensions
const packageCount = Number(source.packageCount)
- const unitVolumeCbm = mode === 'ADD' && rule && !hasOuterOverride
+ const savedDimensions = [source.calculatedOuterLengthCm, source.calculatedOuterWidthCm, source.calculatedOuterHeightCm]
+ // 不调整且尺寸与落库快照一致时沿用服务端体积,避免历史三位精度与预览四位取整产生差额。
+ const useSavedVolume = !customerSource && mode === 'ACTUAL'
+ && measurementCalculationModeForRow(source) === 'ACTUAL'
+ && savedDimensions.every((value, index) => value != null
+ && Math.abs(Number(value) - strategyDimensions[index]!) < 0.000001)
+ && Number(source.unitVolumeCbm) > 0 && Number(source.totalVolumeCbm) > 0
+ const unitVolumeCbm = useSavedVolume ? Number(source.unitVolumeCbm) : mode === 'ADD' && rule && !hasOuterOverride
? Math.round((lengthCm * widthCm * heightCm / 1_000_000) * 1000) / 1000
: roundedUp(lengthCm * widthCm * heightCm / 1_000_000, 4)
- const totalVolumeCbm = Number.isFinite(packageCount) ? roundedUp(unitVolumeCbm * packageCount, 4) : null
+ const totalVolumeCbm = useSavedVolume ? Number(source.totalVolumeCbm)
+ : Number.isFinite(packageCount) ? roundedUp(unitVolumeCbm * packageCount, 4) : null
const fallbackMeasuredUnitWeight = !customerSource && Number.isFinite(Number(measurementDetail.value?.actualGrossWeightKg))
&& Number(measurementDetail.value?.actualPackageCount) > 0
? Number(measurementDetail.value?.actualGrossWeightKg) / Number(measurementDetail.value?.actualPackageCount)
@@ -517,7 +525,8 @@ function calculatedMeasurementRow(
...source,
lengthCm, widthCm, heightCm, unitWeightKg, unitVolumeCbm, totalWeightKg, totalVolumeCbm,
girthCm: roundedUp(lengthCm + 2 * (widthCm + heightCm), 3),
- volumeWeightKg: totalVolumeCbm == null ? null : roundedUp(totalVolumeCbm * 167, 3),
+ volumeWeightKg: useSavedVolume && Number(source.volumeWeightKg) > 0 ? Number(source.volumeWeightKg)
+ : totalVolumeCbm == null ? null : roundedUp(totalVolumeCbm * 167, 3),
remark: customerSource ? source.cargoRemark : source.remark,
}
}
@@ -866,6 +875,11 @@ function formatNumber(value: number | null | undefined, digits = 2) {
return value === null || value === undefined ? '—' : value.toLocaleString('zh-CN', { maximumFractionDigits: digits })
}
+function formatMeasurementVolume(value: number | null | undefined) {
+ return value == null || !Number.isFinite(value) ? '—'
+ : value.toLocaleString('zh-CN', { minimumFractionDigits: 3, maximumFractionDigits: 3 })
+}
+
function partyValue(parties: Array>, type: 'SENDER' | 'RECEIVER', key: string) {
const party = parties.find((item) => String(item.partyType || '').toUpperCase() === type)
const value = party?.[key]
@@ -1020,11 +1034,12 @@ function measurementValue(row: Record, key: string, customerSou
if (customerSource && key === 'unitVolumeCbm') {
const total = Number(row.volumeCbm)
const count = Number(row.packageCount)
- return Number.isFinite(total) && Number.isFinite(count) && count > 0 ? formatNumber(total / count, 6) : '—'
+ return Number.isFinite(total) && Number.isFinite(count) && count > 0 ? formatMeasurementVolume(total / count) : '—'
}
if (customerSource && key === 'remark') return String(row.cargoRemark || '—')
const value = row[key]
if (value === null || value === undefined || value === '') return '—'
+ if (key === 'unitVolumeCbm' || key === 'totalVolumeCbm') return formatMeasurementVolume(Number(value))
return typeof value === 'number' ? formatNumber(value, 6) : String(value)
}
@@ -2660,17 +2675,17 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
-
+
{{ measurementValue(row as Record, column.key) }}
-
合计箱数:{{ measurementTotals(customerMeasurementRows).count ?? '—' }}总毛重:{{ formatNumber(measurementTotals(customerMeasurementRows).gross) }}总体积:{{ formatNumber(measurementTotals(customerMeasurementRows).volume, 6) }}总材积重:{{ formatNumber(measurementTotals(customerMeasurementRows).volumetric) }}重量体积:{{ formatNumber(measurementTotals(customerMeasurementRows).weightVolume, 6) }}
+
合计箱数:{{ measurementTotals(customerMeasurementRows).count ?? '—' }}总毛重:{{ formatNumber(measurementTotals(customerMeasurementRows).gross) }}总体积:{{ formatMeasurementVolume(measurementTotals(customerMeasurementRows).volume) }}总材积重:{{ formatNumber(measurementTotals(customerMeasurementRows).volumetric) }}重量体积:{{ formatMeasurementVolume(measurementTotals(customerMeasurementRows).weightVolume) }}
-
+
{{ measurementValue(row as Record, column.key) }}
-
合计箱数:{{ measurementTotals(outerMeasurementRows).count ?? '—' }}总毛重:{{ formatNumber(measurementTotals(outerMeasurementRows).gross) }}总体积:{{ formatNumber(measurementTotals(outerMeasurementRows).volume, 6) }}总材积重:{{ formatNumber(measurementTotals(outerMeasurementRows).volumetric) }}重量体积:{{ formatNumber(measurementTotals(outerMeasurementRows).weightVolume, 6) }}
+
合计箱数:{{ measurementTotals(outerMeasurementRows).count ?? '—' }}总毛重:{{ formatNumber(measurementTotals(outerMeasurementRows).gross) }}总体积:{{ formatMeasurementVolume(measurementTotals(outerMeasurementRows).volume) }}总材积重:{{ formatNumber(measurementTotals(outerMeasurementRows).volumetric) }}重量体积:{{ formatMeasurementVolume(measurementTotals(outerMeasurementRows).weightVolume) }}
@@ -2683,7 +2698,7 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
{{ measurementStrategyLockedHint }}
-
+
@@ -2721,7 +2736,7 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
-
合计箱数:{{ measurementTotals(actualMeasurementRows).count ?? '—' }}总毛重:{{ formatNumber(measurementTotals(actualMeasurementRows).gross) }}总体积:{{ formatNumber(measurementTotals(actualMeasurementRows).volume, 6) }}总材积重:{{ formatNumber(measurementTotals(actualMeasurementRows).volumetric) }}重量体积:{{ formatNumber(measurementTotals(actualMeasurementRows).weightVolume, 6) }}
+
合计箱数:{{ measurementTotals(actualMeasurementRows).count ?? '—' }}总毛重:{{ formatNumber(measurementTotals(actualMeasurementRows).gross) }}总体积:{{ formatMeasurementVolume(measurementTotals(actualMeasurementRows).volume) }}总材积重:{{ formatNumber(measurementTotals(actualMeasurementRows).volumetric) }}重量体积:{{ formatMeasurementVolume(measurementTotals(actualMeasurementRows).weightVolume) }}