优化
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
<tbody>
|
||||
<tr v-for="metric in comparisonMetrics" :key="metric.label">
|
||||
<th scope="row">{{ metric.label }}</th>
|
||||
<td>{{ formatNumber(metric.declared, metric.digits) }}</td>
|
||||
<td>{{ metric.actual == null ? '未测量' : formatNumber(metric.actual, metric.digits) }}</td>
|
||||
<td>{{ formatNumber(metric.declared, metric.digits, metric.unit === ' m³' ? 3 : 0) }}</td>
|
||||
<td>{{ metric.actual == null ? '未测量' : formatNumber(metric.actual, metric.digits, metric.unit === ' m³' ? 3 : 0) }}</td>
|
||||
<td :class="{ 'measurement-comparison__difference': metric.actual != null && metric.declared != null && metric.actual !== metric.declared }">
|
||||
{{ metric.actual == null ? '待测量' : metric.declared == null ? '—' : formatDifference(metric.actual, metric.declared, metric.digits, metric.unit) || '一致' }}
|
||||
</td>
|
||||
@@ -745,7 +745,6 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
||||
<section class="measurement-declaration" aria-labelledby="measurement-declaration-title">
|
||||
<header class="measurement-declaration-header">
|
||||
<h4 id="measurement-declaration-title">客户申报明细</h4>
|
||||
<div class="measurement-readonly-field measurement-document-number"><span>原单据号码</span><strong>{{ detail.originalDocumentNo || '—' }}</strong></div>
|
||||
</header>
|
||||
<el-table
|
||||
v-if="detail.latestDeclaration?.items.length"
|
||||
@@ -769,9 +768,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
||||
<div class="measurement-section__heading">
|
||||
<div><h3>平台实测箱规</h3></div>
|
||||
|
||||
<div class="measurement-data-grid measurement-platform-fields">
|
||||
<label v-if="mayMaintain" class="measurement-goods-name"><span>实际品名</span><el-input v-model="form.actualGoodsName" placeholder="请输入实际品名" maxlength="255" /></label>
|
||||
<div v-else class="measurement-readonly-field measurement-goods-name"><span>实际品名</span><strong>{{ detail.actualGoodsName || '—' }}</strong></div>
|
||||
<div v-if="isDropship" class="measurement-data-grid measurement-platform-fields">
|
||||
<label v-if="isDropship && mayMaintain" class="measurement-expected-pallets"><span>预计托数</span><el-input-number v-model="form.expectedPalletCount" placeholder="请输入预计托数" :min="1" :max="1000" :step="1" step-strictly controls-position="right" /></label>
|
||||
<div v-else-if="isDropship" class="measurement-readonly-field measurement-expected-pallets"><span>预计托数</span><strong>{{ detail.expectedPalletCount ?? '—' }}</strong></div>
|
||||
</div>
|
||||
@@ -799,7 +796,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
||||
<div><dt>测量时间</dt><dd>{{ row.measuredAt ? row.measuredAt.replace('T', ' ') : '—' }}</dd></div>
|
||||
<div><dt>计算方式</dt><dd>{{ calculationModeLabel(row.coefficientMode) }}</dd></div>
|
||||
<div><dt>计算后外箱</dt><dd>{{ formatNumber(row.calculatedOuterLengthCm) }} × {{ formatNumber(row.calculatedOuterWidthCm) }} × {{ formatNumber(row.calculatedOuterHeightCm) }} cm</dd></div>
|
||||
<div><dt>单箱体积</dt><dd>{{ formatNumber(row.serverValues.unitVolumeCbm, 4) }} m³</dd></div>
|
||||
<div><dt>单箱体积</dt><dd>{{ formatNumber(row.serverValues.unitVolumeCbm, 3, 3) }} m³</dd></div>
|
||||
<div><dt>箱规周长</dt><dd>{{ formatNumber(row.serverValues.girthCm) }} cm</dd></div>
|
||||
<div><dt>总材积重</dt><dd>{{ formatNumber(row.serverValues.volumeWeightKg) }} kg</dd></div>
|
||||
<div><dt>计算规则版本</dt><dd>{{ row.calculationRuleVersion || '—' }}</dd></div>
|
||||
@@ -847,7 +844,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="总体积(m³)" width="108" align="right">
|
||||
<template #default="{ row }">{{ formatNumber(row.serverValues.totalVolumeCbm, 4) }}</template>
|
||||
<template #default="{ row }">{{ formatNumber(row.serverValues.totalVolumeCbm, 3, 3) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="图片" width="82" align="center">
|
||||
<template #default="{ row }">
|
||||
|
||||
@@ -37,3 +37,7 @@ export async function commandInquiryFulfillment(bookingNo: string, action: 'inco
|
||||
}
|
||||
|
||||
export async function saveAndConfirmInquiry(bookingNo: string, body: InquiryWrite) { return inquiryResult(await post<InquiryResult>(`${inquiryPath(bookingNo)}/save-and-confirm`, body)) }
|
||||
|
||||
export async function listLooseboardDemands(query: { page: number; size: number; keyword?: string }) {
|
||||
return page(await get<OfferPage<import('../capacityOfferTypes').LooseboardDemand>>(`${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) }))
|
||||
}
|
||||
|
||||
@@ -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<string, string> = { SOURCE_NOT_READY: '待确认承运后筹划', SOURCE_NOT_FOUND: '来源待核对', SOURCE_NOT_MATERIALIZED: '待形成真实货件资料', DESTINATION_MISMATCH: '目的地变化待核对', REVIEW_REQUIRED: '货量待复核', UNASSIGNED: '尚未关联收货预约', PARTIAL: '部分货物未覆盖', PENDING: '等待收货方确认', CONFIRMED: '预约记录已确认' }
|
||||
export const appointmentMissingLabels: Record<string, string> = { 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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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<boolean>({ 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<ApiErrorDetails | null>(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
|
||||
<template>
|
||||
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" destroy-on-close :title="readonly ? '客户货物跟进' : inquiry ? '补充客户货物资料' : '登记客户货物'" width="1080px" :show-confirm="writable" :confirm-text="pending ? '重试原请求' : confirmMode ? '确认承运并保位' : '保存登记'" :confirm-loading="busy" @confirm="save">
|
||||
<LoadErrorState v-if="error" title="意向未能保存" :message="error.message" @retry="pending ? save() : emit('refresh')"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="confirmMode">核对本票货量、价格与交货资料后确认承运。成功将保留货位并创建关联本土订单,不自动安排仓库收货。</p>
|
||||
<p v-if="sourceDemand">散板原单:{{ sourceDemand.orderNo }} · {{ sourceDemand.origin }} → {{ sourceDemand.destination }}。请核对卸货站、标准托位和预计到仓时间。</p>
|
||||
<p v-if="confirmMode">核对本票货量、价格与交货资料后确认承运。成功将保留货位并关联本土订单,不自动安排仓库收货。</p>
|
||||
<section v-if="confirmMode" aria-label="本目的站可接余位">
|
||||
<strong>本目的站可接余位</strong>
|
||||
<p v-if="selectedCapacity">{{ selectedCapacity.palletSpaces }} 托位 · {{ selectedCapacity.weightKg }} kg · {{ selectedCapacity.volumeCbm }} m³</p>
|
||||
@@ -129,8 +136,8 @@ onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clear
|
||||
</div></details>
|
||||
</section>
|
||||
<el-form label-position="top" class="density-form capacity-inquiry-form" :disabled="lock">
|
||||
<el-form-item label="委托客户" required><template #label><span>委托客户<InlineHelp title="登记货物说明"><p>先选客户即可登记;多站车次需选择卸货站。保存后调度可见,尚不保位。</p></InlineHelp></span></template><el-select v-model="form.customerId" filterable remote :remote-method="searchCustomers" :loading="customerLoading" :disabled="!!inquiry" placeholder="按客户编号或名称搜索" @focus="searchCustomers()"><el-option v-if="inquiry" :value="inquiry.customerId" :label="`${inquiry.customerCode} · ${inquiry.customerName}`" /><el-option v-for="customer in customers" :key="customer.id" :value="customer.id" :label="`${customer.code} · ${customer.name}`" /></el-select><span v-if="customerSearched && !customers.length">暂无可选客户</span></el-form-item>
|
||||
<el-form-item label="本车卸货站" required><el-select v-model="form.cargo.destinationKey" :disabled="lock || offer?.destinations.length === 1" placeholder="选择本车既有卸货站"><el-option v-for="stop in offer?.destinations || []" :key="stop.destinationKey" :value="stop.destinationKey" :label="stop.name" /><el-option v-if="!offer && inquiry" :value="inquiry.cargo.destinationKey" :label="inquiry.destinationLabel" /></el-select></el-form-item>
|
||||
<el-form-item label="委托客户" required><template #label><span>委托客户<InlineHelp title="登记货物说明"><p>先选客户即可登记;多站车次需选择卸货站。保存后调度可见,尚不保位。</p></InlineHelp></span></template><el-select v-model="form.customerId" filterable remote :remote-method="searchCustomers" :loading="customerLoading" :disabled="!!inquiry || !!sourceDemand" placeholder="按客户编号或名称搜索" @focus="searchCustomers()"><el-option v-if="inquiry" :value="inquiry.customerId" :label="`${inquiry.customerCode} · ${inquiry.customerName}`" /><el-option v-for="customer in customers" :key="customer.id" :value="customer.id" :label="`${customer.code} · ${customer.name}`" /></el-select><span v-if="customerSearched && !customers.length">暂无可选客户</span></el-form-item>
|
||||
<el-form-item label="本车卸货站" required><el-select v-model="form.cargo.destinationKey" :disabled="lock || !sourceDemand && offer?.destinations.length === 1" placeholder="选择本车既有卸货站"><el-option v-for="stop in offer?.destinations || []" :key="stop.destinationKey" :value="stop.destinationKey" :label="stop.name" /><el-option v-if="!offer && inquiry" :value="inquiry.cargo.destinationKey" :label="inquiry.destinationLabel" /></el-select></el-form-item>
|
||||
<details class="capacity-inquiry-wide" :open="readonly || confirmMode"><summary>{{ cargoLabel === '已核实' ? '已核实货量与预计实托数' : readonly ? `${cargoLabel}货量` : '已知货量(可先填写,未知可留空)' }}</summary><div class="capacity-inquiry-form">
|
||||
<el-form-item :label="`${cargoLabel}箱数`"><el-input-number v-model="form.cargo.packageCount" :min="1" :precision="0" placeholder="未知可留空" /></el-form-item>
|
||||
<el-form-item label="预计实托数"><template #label><span>预计实托数<InlineHelp title="容量核验说明"><p>托位、重量、体积须同时满足;确认时由系统重新核验,不代表已经保位。</p></InlineHelp></span></template><el-input-number v-model="form.expectedPalletCount" :min="1" :precision="0" placeholder="卡车确认承运前必填" /></el-form-item>
|
||||
@@ -139,7 +146,7 @@ onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clear
|
||||
<el-form-item :label="`${cargoLabel}体积(m³)`"><el-input-number v-model="form.cargo.volumeCbm" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
|
||||
</div></details>
|
||||
<details class="capacity-inquiry-wide" :open="readonly || confirmMode"><summary>确认承运前待补资料{{ inquiry?.progress.missingFields.length ? `(还需:${inquiry.progress.missingFields.join('、')})` : '(货名、价格、到仓及联系信息)' }}</summary><div class="capacity-inquiry-form">
|
||||
<el-form-item label="本土业务类型"><el-select v-model="form.businessType"><el-option value="LOCAL_LOOSEBOARD" label="本土散板" /><el-option value="US_TRUCK" label="本土卡车" /></el-select></el-form-item>
|
||||
<el-form-item label="本土业务类型"><el-select v-model="form.businessType" :disabled="!!sourceDemand || !!inquiry?.businessOrderNo"><el-option value="LOCAL_LOOSEBOARD" label="本土散板" /><el-option value="US_TRUCK" label="本土卡车" /></el-select></el-form-item>
|
||||
|
||||
<el-form-item label="货物名称"><el-input v-model="form.cargo.cargoName" placeholder="填写货物名称(确认承运前必填)" /></el-form-item>
|
||||
<el-form-item label="交货方式"><el-select v-model="form.deliveryMode"><el-option value="SELF_DELIVERY" label="客户自行送至我仓" /><el-option value="MANUAL_PICKUP_TO_WAREHOUSE" label="线下安排提货回我仓" /></el-select></el-form-item>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 = <T,>(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<void> }
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -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<QueryModel>(manage.value ? { scope: 'TODO' } : {}), applied = ref<QueryModel>({ ...filters.value })
|
||||
const page = ref(1), size = ref(20), total = ref(0), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const offers = ref<CapacityOffer[]>([]), inquiries = ref<CapacityInquiry[]>([])
|
||||
const editorOpen = ref(false), editing = ref<CapacityInquiry | null>(null), selectedOffer = ref<CapacityOffer | null>(null), readonly = ref(true)
|
||||
const confirmMode = ref(false)
|
||||
const demands = ref<LooseboardDemand[]>([]), selectedDemand = ref<LooseboardDemand | null>(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<CapacityInquiry | null>(null), fulfillmentMode = ref<'incoming' | 'receipt' | 'bind' | 'view'>('view')
|
||||
async function fulfillmentSaved() {
|
||||
const row = fulfillmentRow.value
|
||||
@@ -54,7 +57,7 @@ const fields = computed<QueryField[]>(() => 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++ })
|
||||
</script>
|
||||
<template><AdminPage class="admin-density"><ListPageLayout aria-label="散板需求">
|
||||
<div class="density-status-tabs" role="tablist" aria-label="需求来源"><button v-if="canCreate" role="tab" :aria-selected="tab === 'demands'" :class="{ 'is-active': tab === 'demands' }" @click="switchTab('demands')">待匹配散板</button><button role="tab" :aria-selected="tab === 'offers'" :class="{ 'is-active': tab === 'offers' }" @click="switchTab('offers')">可拼车次</button><button role="tab" :aria-selected="tab === 'inquiries'" :class="{ 'is-active': tab === 'inquiries' }" @click="switchTab('inquiries')">我的登记</button></div>
|
||||
<p v-if="selectedDemand && tab === 'offers'">正在匹配 {{ selectedDemand.orderNo }}:{{ selectedDemand.origin }} → {{ selectedDemand.destination }}。请选择合适车次,保存登记后尚不保位。<el-button link @click="selectedDemand = null">取消选择</el-button></p>
|
||||
<DataTable v-if="tab === 'demands'" appearance="integrated" :stripe="false" preference-key="fleet-looseboard-demands" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="demands" :columns="demandColumns" :loading="loading" :total="total" row-key="orderNo" empty-text="暂无待匹配散板需求">
|
||||
<template #toolbar-start><h1>待匹配散板</h1></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="待匹配散板查询" :loading="loading" @search="search" /></template>
|
||||
<template #notices><LoadErrorState v-if="error" title="需求读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" v-if="canCreate" @click="matchDemand(row as LooseboardDemand)">匹配车次</el-button></div></template>
|
||||
</DataTable>
|
||||
<DataTable v-if="tab === 'offers'" appearance="integrated" :stripe="false" preference-key="fleet-capacity-offers" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="offers" :columns="columns" :loading="loading" :total="total" row-key="offerNo" :empty-text="Object.values(applied).some(Boolean) ? '当前筛选条件下暂无运力发布' : '暂无获准查看的运力发布'">
|
||||
<template #toolbar-start><h1>散板需求</h1></template><template #toolbar-end><el-button v-if="manage" @click="switchTab('inquiries'); filters = { scope: 'TODO' }; applied = { scope: 'TODO' }">待我处理</el-button><el-button v-if="manage && access.can('platform:fleet:trip:list')" @click="router.push({ name: 'overseas-master-data-trips' })">选择车次发布余位</el-button></template>
|
||||
<template #filters>
|
||||
<div class="integrated-query-group">
|
||||
<div class="density-status-tabs" role="tablist" aria-label="散板需求页签"><button role="tab" aria-selected="true" class="is-active" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="false" @click="switchTab('inquiries')">我的登记</button></div>
|
||||
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="可拼车次查询" :loading="loading" @search="search" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -138,16 +151,15 @@ watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { followu
|
||||
<template #status="{ row }">{{ offerStatusLabels[(row as CapacityOffer).status] }} · {{ row.canConfirm ? '可登记,确认后保位' : row.canCreateInquiry ? '可先登记,待调度核对,尚不保位' : '暂不可登记' }}<div v-if="row.blockReason">{{ row.blockReason }}</div></template>
|
||||
<template #contact="{ row }"><div>{{ row.contactName || '—' }}</div><div>{{ row.contactPhone || '—' }}</div></template>
|
||||
</DataTable>
|
||||
<CapacityInquiryTable v-else integrated v-model:page="page" v-model:size="size" :rows="inquiries" :total="total" :loading="loading" :failed="!!error" :busy="commands.busy.value" @action="inquiryAction">
|
||||
<CapacityInquiryTable v-else-if="tab === 'inquiries'" integrated v-model:page="page" v-model:size="size" :rows="inquiries" :total="total" :loading="loading" :failed="!!error" :busy="commands.busy.value" @action="inquiryAction">
|
||||
<template #toolbar-start><h1>{{ applied.scope === 'TODO' ? '待我处理' : applied.scope === 'ALL' ? '全部登记' : '我的登记' }}</h1></template>
|
||||
<template #filters>
|
||||
<div class="integrated-query-group">
|
||||
<div class="density-status-tabs" role="tablist" aria-label="散板需求页签"><button role="tab" aria-selected="false" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="true" class="is-active" @click="switchTab('inquiries')">我的登记</button></div>
|
||||
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="拼车意向查询" :loading="loading" @search="search" />
|
||||
</div>
|
||||
</template>
|
||||
<template #notices><LoadErrorState v-if="error || commands.error.value" title="意向操作未完成" :message="(error || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : load()"><template #details><ApiErrorMeta :details="(error || commands.error.value)!" /></template></LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry">重试原操作</el-button></template>
|
||||
</CapacityInquiryTable>
|
||||
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="fulfillmentSaved" />
|
||||
<CapacityInquiryDialog v-model="editorOpen" :offer="selectedOffer" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="inquiryAction" @saved="accept" @refresh="recheckEditor" />
|
||||
<CapacityInquiryDialog v-model="editorOpen" :offer="selectedOffer" :source-demand="editing ? null : selectedDemand" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="inquiryAction" @saved="accept" @refresh="recheckEditor" />
|
||||
</ListPageLayout></AdminPage></template>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<TransportDetail, 'execution'> & { execution: Parameters<typeof normalizeTripDetail>[0] }
|
||||
const base = '/platform/v1/last-leg/dispatches/transport-tasks'
|
||||
const path = (number: string) => `${base}/${encodeURIComponent(number)}`
|
||||
async function get<T>(url: string, params?: object) { return unwrapApiResponse(await http.get<ApiEnvelope<T>, ApiEnvelope<T>>(url, { params })) }
|
||||
async function post<T>(url: string, body: object) { return unwrapApiResponse(await http.post<ApiEnvelope<T>, ApiEnvelope<T>>(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<WireDetail>(path(number)).then(normalize)
|
||||
export const pickupCandidates = () => get<{ records: PickupCandidate[]; total: number | string }>(`${base}/pickup-candidates`, { page: 1, size: 100 })
|
||||
export const truckCandidates = () => get<TruckCandidate[]>(`${base}/truck-candidates`)
|
||||
export const createTransportPickup = (body: PickupPlan) => post<WireDetail>(`${base}/pickup`, body).then(normalize)
|
||||
export const createTransportTruck = (body: TruckPlan) => post<WireDetail>(`${base}/truck`, body).then(normalize)
|
||||
export const assignTransport = (number: string, body: TripAssignment) => post<WireDetail>(`${path(number)}/assign`, body).then(normalize)
|
||||
export const transportOccupancy = (number: string, kind: 'VEHICLE' | 'DRIVER', resourceIds: string[]) => resourceIds.length ? post<Array<{ resourceId: string; occupiedTripNo: string | null; available: boolean }>>(`${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<PickupEvidence>(`${path(number)}/${suffix}/files`, body) }
|
||||
export const downloadTransportEvidence = (number: string, fileId: string, row?: PickupTask): Promise<Blob> => http.get<Blob, Blob>(`${path(number)}/${row ? `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}` : 'truck'}/files/${encodeURIComponent(fileId)}`, { responseType: 'blob' })
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { listVehicles, listDrivers, pickupStatusLabels, type VehicleDetail, type DriverDetail, type PickupTask, type PickupEvidence } from '@/modules/overseas-master-data'
|
||||
import { getTransport, assignTransport, transportOccupancy, cancelTransport, releaseTransport, commandTransport, uploadTransport, downloadTransportEvidence, transportLabels, type TransportDetail } from '../api/transportTasks'
|
||||
import { dispatchActionPermissions as permission } from '../dispatchPresentation'
|
||||
|
||||
const props = defineProps<{ dispatchNo: string }>(), emit = defineEmits<{ saved: [] }>()
|
||||
const access = useAccessPreviewStore(), detail = ref<TransportDetail | null>(null), error = ref<ApiErrorDetails | null>(null), busy = ref(false)
|
||||
const vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([]), vehicleId = ref(''), driverId = ref(''), reason = ref('')
|
||||
const occupancy = ref<Record<string, boolean>>({}), candidateBusy = ref(false)
|
||||
const selected = ref<PickupTask | null>(null), pickupOpen = ref(false), actual = ref(1), evidence = ref<PickupEvidence[]>([]), truckEvidence = ref<PickupEvidence[]>([])
|
||||
const releaseOpen = ref(false), releaseKind = ref<'VEHICLE' | 'DRIVER'>('VEHICLE'), endLocation = ref(''), actualEnd = ref('')
|
||||
const pending = ref<{ suffix: string; body: object; kind?: 'cancel' | 'release' } | null>(null)
|
||||
const trip = computed(() => detail.value?.execution.trip)
|
||||
const editable = computed(() => access.can(permission.EDIT))
|
||||
const locked = computed(() => busy.value || !!pending.value)
|
||||
async function load() {
|
||||
error.value = null
|
||||
try { detail.value = await getTransport(props.dispatchNo); vehicleId.value = trip.value?.vehicleId || ''; driverId.value = trip.value?.driverId || '' }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '运输任务加载失败') }
|
||||
}
|
||||
watch(() => props.dispatchNo, () => { detail.value = null; pending.value = null; void load() }, { immediate: true })
|
||||
/** 资源候选与占用分开校验;保存由后端锁定复验。 */
|
||||
async function candidates() {
|
||||
if (!trip.value || candidateBusy.value) return
|
||||
candidateBusy.value = true; error.value = null
|
||||
try {
|
||||
const [v, d] = await Promise.all([listVehicles({ page: 1, size: 100, region: 'OVERSEAS', status: 'IN_SERVICE' }), listDrivers({ page: 1, size: 100, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: trip.value.expectedDriverEndAt })])
|
||||
const [vo, dr] = await Promise.all([transportOccupancy(props.dispatchNo, 'VEHICLE', v.records.map(row => row.id)), transportOccupancy(props.dispatchNo, 'DRIVER', d.records.map(row => row.id))])
|
||||
occupancy.value = Object.fromEntries([...vo.map(row => [`V:${row.resourceId}`, row.available]), ...dr.map(row => [`D:${row.resourceId}`, row.available])]); vehicles.value = v.records; drivers.value = d.records
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '资源候选加载失败') }
|
||||
finally { candidateBusy.value = false }
|
||||
}
|
||||
async function assign() {
|
||||
if (!trip.value || locked.value || !vehicleId.value || !driverId.value) return
|
||||
busy.value = true; error.value = null
|
||||
try { detail.value = await assignTransport(props.dispatchNo, { recordVersion: trip.value.recordVersion, vehicleId: vehicleId.value, driverId: driverId.value, reason: reason.value.trim() || '分配运输车辆及司机' }); emit('saved'); ElMessage.success('车辆司机已分配') }
|
||||
catch (cause) { await load(); error.value = getApiErrorDetails(cause, '分配未完成') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
/** 每次业务命令保留原版本与幂等键,结果未知只重试原请求。 */
|
||||
async function execute() {
|
||||
if (!pending.value || busy.value) return
|
||||
const request = pending.value; busy.value = true; error.value = null
|
||||
try {
|
||||
if (request.kind === 'cancel') await cancelTransport(props.dispatchNo, request.body)
|
||||
else if (request.kind === 'release') await releaseTransport(props.dispatchNo, request.body as Parameters<typeof releaseTransport>[1])
|
||||
else await commandTransport(props.dispatchNo, request.suffix, request.body)
|
||||
pending.value = null; pickupOpen.value = false; releaseOpen.value = false; await load(); emit('saved'); ElMessage.success('运输操作已保存')
|
||||
} catch (cause) { if (cause instanceof ApiResponseError) { pending.value = null; await load() }; error.value = getApiErrorDetails(cause, '运输操作未完成') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
async function command(action: 'depart' | 'complete' | 'arrive' | 'cancel', row?: PickupTask) {
|
||||
if (!trip.value || locked.value) return
|
||||
const message = { depart: '确认车辆司机已出发执行运输任务?', complete: '确认运输已实际完成?', arrive: '确认货物已实际送达仓库?确认后仓库可开始收货。', cancel: '确认取消未发车排车并释放车辆、司机和原单安排?' }[action]
|
||||
try { await ElMessageBox.confirm(message, '确认运输操作', { customClass: 'admin-density-confirm' }) } catch { return }
|
||||
const prefix = detail.value?.taskType === 'PICKUP' ? 'pickup' : 'truck'
|
||||
const suffix = row ? `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}/${action}` : `${prefix}/${action}`
|
||||
pending.value = { suffix, kind: action === 'cancel' ? 'cancel' : undefined, body: { recordVersion: trip.value.recordVersion, idempotencyKey: crypto.randomUUID(), reason: reason.value.trim() || message, ...(action === 'complete' && prefix === 'truck' ? { evidenceFileIds: truckEvidence.value.map(file => file.fileId) } : {}) } }
|
||||
await execute()
|
||||
}
|
||||
function editPickup(row: PickupTask) { selected.value = row; actual.value = Number(row.packageCount); evidence.value = []; pickupOpen.value = true }
|
||||
async function download(file: PickupEvidence, row?: PickupTask) {
|
||||
try { const blob = await downloadTransportEvidence(props.dispatchNo, file.fileId, row); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = file.fileName; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '凭证下载失败') }
|
||||
}
|
||||
async function upload(event: Event, pickup: boolean) {
|
||||
const input = event.target as HTMLInputElement, file = input.files?.[0]; if (!file || locked.value) return
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
const row = selected.value, suffix = pickup && row ? `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}` : 'truck'
|
||||
const result = await uploadTransport(props.dispatchNo, suffix, file); (pickup ? evidence.value : truckEvidence.value).push(result)
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '凭证上传失败') }
|
||||
finally { busy.value = false; input.value = '' }
|
||||
}
|
||||
async function picked() {
|
||||
if (!selected.value || !trip.value || !Number.isInteger(actual.value) || actual.value <= 0 || !evidence.value.length || locked.value) return
|
||||
const row = selected.value
|
||||
pending.value = { suffix: `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}/picked`, body: { recordVersion: trip.value.recordVersion, idempotencyKey: crypto.randomUUID(), actualPackages: actual.value, evidenceFileIds: evidence.value.map(file => file.fileId), remark: reason.value.trim() } }; await execute()
|
||||
}
|
||||
function openRelease(kind: 'VEHICLE' | 'DRIVER') { releaseKind.value = kind; endLocation.value = ''; actualEnd.value = ''; releaseOpen.value = true }
|
||||
async function release() {
|
||||
if (!trip.value || !actualEnd.value || !endLocation.value.trim() || locked.value) return
|
||||
pending.value = { suffix: 'release', kind: 'release', body: { recordVersion: trip.value.recordVersion, resourceKind: releaseKind.value, actualEndAt: actualEnd.value, endLocation: endLocation.value.trim(), vehicleStatus: releaseKind.value === 'VEHICLE' ? 'IN_SERVICE' : null, idempotencyKey: crypto.randomUUID(), reason: reason.value.trim() || '确认资源实际任务结束' } }; await execute()
|
||||
}
|
||||
onBeforeRouteLeave(() => !locked.value || window.confirm('运输请求尚待核对,离开会丢失原请求。确认离开?'))
|
||||
</script>
|
||||
<template>
|
||||
<section class="detail-section" aria-label="运输任务执行">
|
||||
<LoadErrorState v-if="error" title="运输操作未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-button v-if="pending" :loading="busy" @click="execute">按原请求重试</el-button>
|
||||
<template v-if="detail && trip">
|
||||
<h2>{{ transportLabels[detail.taskType] }} · {{ { PLANNED: '待发车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' }[trip.status] }}</h2>
|
||||
<p v-if="detail.orderNo">本土卡车原单:{{ detail.orderNo }} · 客户:{{ detail.customerId }}</p>
|
||||
<p>调度仓库:{{ trip.warehouseName }} · 车辆:{{ trip.vehiclePlate || '待分配' }} · 司机:{{ trip.driverName || '待分配' }} {{ trip.driverPhone }}</p>
|
||||
<p v-for="(stop, index) in trip.stops" :key="index">{{ index + 1 }}. {{ stop.name }} {{ stop.address }}</p>
|
||||
<el-input v-if="editable" v-model="reason" :disabled="locked" placeholder="本次操作说明" />
|
||||
<el-form v-if="trip.status === 'PLANNED' && editable" label-position="top" :disabled="locked" class="density-form">
|
||||
<el-button :loading="candidateBusy" @click="candidates">加载车辆司机候选</el-button>
|
||||
<el-form-item label="车辆"><el-select v-model="vehicleId" filterable><el-option v-for="row in vehicles" :key="row.id" :value="row.id" :disabled="occupancy[`V:${row.id}`] !== true" :label="`${row.plateNumber} · ${row.maxPalletSpaces}托位${occupancy[`V:${row.id}`] ? '' : ' · 已占用'}`" /></el-select></el-form-item>
|
||||
<el-form-item label="司机"><el-select v-model="driverId" filterable><el-option v-for="row in drivers" :key="row.id" :value="row.id" :disabled="occupancy[`D:${row.id}`] !== true || !row.qualification.canDispatch" :label="`${row.details.name} · ${row.details.phone}${occupancy[`D:${row.id}`] ? '' : ' · 已占用'}`" /></el-select></el-form-item>
|
||||
<el-button :loading="busy" :disabled="candidateBusy || !vehicleId || !driverId" @click="assign">保存车辆司机</el-button>
|
||||
</el-form>
|
||||
<div class="density-actions">
|
||||
<el-button v-if="trip.status === 'PLANNED' && access.can(permission.DEPART)" :disabled="locked || !trip.vehicleId || !trip.driverId" @click="command('depart')">确认发车</el-button>
|
||||
<el-button v-if="trip.status === 'PLANNED' && access.can(permission.CANCEL)" :disabled="locked" @click="command('cancel')">取消排车</el-button>
|
||||
<el-button v-if="trip.status === 'IN_TRANSIT' && editable" :disabled="locked || (detail.taskType === 'PICKUP' ? !detail.pickups.length || detail.pickups.some(row => row.status !== 'ARRIVED') : !truckEvidence.length)" @click="command('complete')">确认运输完成</el-button>
|
||||
<template v-if="trip.status === 'COMPLETED' && editable"><el-button v-if="!trip.vehicleRelease" :disabled="locked" @click="openRelease('VEHICLE')">结束车辆任务</el-button><el-button v-if="!trip.driverRelease" :disabled="locked" @click="openRelease('DRIVER')">结束司机任务</el-button></template>
|
||||
<el-button :disabled="locked" @click="load">刷新运输进度</el-button>
|
||||
</div>
|
||||
<p v-if="detail.taskType === 'PICKUP'">送仓后由仓库独立清点收货,取货数量保留为运输事实。</p>
|
||||
<el-table v-if="detail.taskType === 'PICKUP'" :data="detail.pickups"><el-table-column prop="transferNo" label="转运单号" min-width="190" /><el-table-column label="进度"><template #default="{ row }">{{ pickupStatusLabels[row.status] }}</template></el-table-column><el-table-column prop="packageCount" label="预报箱数" /><el-table-column prop="actualPackages" label="实提箱数" /><el-table-column label="凭证"><template #default="{ row }"><el-button v-for="file in row.evidence" :key="file.fileId" link @click="download(file, row as PickupTask)">{{ file.fileName }}</el-button></template></el-table-column><el-table-column label="操作" width="180"><template #default="{ row }"><template v-if="trip.status === 'IN_TRANSIT' && editable"><el-button v-if="row.status === 'ASSIGNED'" link :disabled="locked" @click="editPickup(row as PickupTask)">确认取货</el-button><el-button v-if="row.status === 'PICKED'" link :disabled="locked" @click="command('arrive', row as PickupTask)">确认送仓</el-button></template></template></el-table-column></el-table>
|
||||
<div v-if="detail.taskType === 'LOCAL_TRUCK' && trip.status === 'IN_TRANSIT' && editable"><label>运输完成凭证 <input type="file" :disabled="locked" @change="upload($event, false)" /></label><p v-for="file in truckEvidence" :key="file.fileId">{{ file.fileName }}</p></div>
|
||||
<div v-if="detail.completionEvidence?.length"><p>已确认的运输完成凭证</p><el-button v-for="file in detail.completionEvidence" :key="file.fileId" link @click="download(file)">{{ file.fileName }}</el-button></div>
|
||||
</template>
|
||||
<AppDialog class="admin-density-dialog" v-model="pickupOpen" title="确认原单取货" width="650px" :show-close="!locked" :close-on-click-modal="!locked" :close-on-press-escape="!locked"><p>{{ selected?.transferNo }}</p><el-form label-position="top" :disabled="locked"><el-form-item label="实提箱数" required><el-input-number v-model="actual" :min="1" :precision="0" /></el-form-item><el-form-item label="取货凭证" required><input type="file" :disabled="locked" @change="upload($event, true)" /></el-form-item><p v-for="file in evidence" :key="file.fileId">{{ file.fileName }}</p></el-form><template #footer><el-button type="primary" :disabled="locked || !evidence.length" @click="picked">确认实提</el-button></template></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="releaseOpen" :title="releaseKind === 'VEHICLE' ? '结束车辆任务' : '结束司机任务'" width="650px" :show-close="!locked" :close-on-click-modal="!locked"><el-form label-position="top" :disabled="locked"><el-form-item label="实际任务结束时间" required><el-date-picker v-model="actualEnd" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item><el-form-item label="实际结束地点" required><el-input v-model="endLocation" /></el-form-item></el-form><template #footer><el-button type="primary" :disabled="locked || !actualEnd || !endLocation.trim()" @click="release">确认任务结束并释放占用</el-button></template></AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
||||
import type { PickupCandidate, PickupPlan } from '@/modules/overseas-master-data'
|
||||
import { pickupCandidates, truckCandidates, createTransportPickup, createTransportTruck, type TruckCandidate, type TruckPlan, type TransportType } from '../api/transportTasks'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; type: TransportType }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; created: [] }>()
|
||||
const router = useRouter(), pickups = ref<PickupCandidate[]>([]), trucks = ref<TruckCandidate[]>([]), warehouses = ref<PlatformWarehouseResource[]>([])
|
||||
const selected = ref<PickupCandidate[]>([]), truck = ref(''), warehouse = ref(''), departure = ref(''), end = ref('')
|
||||
const loading = ref(false), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const pending = ref<{ type: TransportType; body: PickupPlan | TruckPlan } | null>(null)
|
||||
let sequence = 0
|
||||
async function load() {
|
||||
const current = ++sequence; loading.value = true; error.value = null
|
||||
try {
|
||||
if (props.type === 'PICKUP') { const value = await pickupCandidates(); if (current === sequence) pickups.value = value.records }
|
||||
else { const [orders, directory] = await Promise.all([truckCandidates(), listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'OVERSEAS', status: 'ACTIVE' })]); if (current === sequence) { trucks.value = orders; warehouses.value = directory.records } }
|
||||
} catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '运输需求加载失败') }
|
||||
finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
watch(() => props.modelValue, value => { if (value) { selected.value = []; truck.value = ''; warehouse.value = ''; pending.value = null; void load() } else sequence++ })
|
||||
/** 结果未知时保留相同原请求,禁止重复选择来源另建单。 */
|
||||
async function save() {
|
||||
if (busy.value) return
|
||||
if (!pending.value) {
|
||||
if (!departure.value || !end.value || Date.parse(end.value) <= Date.parse(departure.value)) { ElMessage.warning('请填写有效的计划发车和预计任务结束时间'); return }
|
||||
const common = { plannedDepartureAt: departure.value, expectedDriverEndAt: end.value, idempotencyKey: crypto.randomUUID() }
|
||||
if (props.type === 'PICKUP') {
|
||||
if (!selected.value.length || new Set(selected.value.map(row => row.warehouseId)).size !== 1) { ElMessage.warning('请选择送往同一个仓库的取货单'); return }
|
||||
pending.value = { type: 'PICKUP', body: { ...common, sources: selected.value.map(({ customerId, transferNo }) => ({ customerId, transferNo })) } }
|
||||
} else {
|
||||
const order = trucks.value.find(row => `${row.customerId}:${row.orderNo}` === truck.value)
|
||||
if (!order || !warehouse.value) { ElMessage.warning('请选择本土卡车原单和调度仓库'); return }
|
||||
pending.value = { type: 'LOCAL_TRUCK', body: { ...common, customerId: order.customerId, orderNo: order.orderNo, warehouseId: warehouse.value } }
|
||||
}
|
||||
}
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
const request = pending.value
|
||||
const detail = request.type === 'PICKUP' ? await createTransportPickup(request.body as PickupPlan) : await createTransportTruck(request.body as TruckPlan)
|
||||
pending.value = null; emit('created'); emit('update:modelValue', false); ElMessage.success(`已生成排车单 ${detail.dispatchNo}`)
|
||||
await router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: detail.dispatchNo } })
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '安排排车未完成'); if (cause instanceof ApiResponseError) pending.value = null }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog class="admin-density-dialog" :model-value="modelValue" :title="type === 'PICKUP' ? '安排到仓取货' : '安排本土卡车'" width="1100px" :close-on-click-modal="!busy && !pending" :close-on-press-escape="!busy && !pending" :show-close="!busy && !pending" @update:model-value="emit('update:modelValue', $event)">
|
||||
<LoadErrorState v-if="error" title="排车安排未完成" :message="error.message" @retry="pending ? save() : load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-form label-position="top" class="density-form" :disabled="busy || !!pending" v-loading="loading">
|
||||
<template v-if="type === 'PICKUP'">
|
||||
<p>审核通过的仓储转运上门取货单自动进入候选;同车货物必须送往同一个海外仓。</p>
|
||||
<DataTable :data="pickups" row-key="transferNo" :columns="[{ key: 'selection', type: 'selection', width: 45 }, { key: 'transferNo', prop: 'transferNo', label: '转运单号', minWidth: 190 }, { key: 'warehouseName', prop: 'warehouseName', label: '送达仓库', minWidth: 140 }, { key: 'pickupAddress', prop: 'pickupAddress', label: '取货地址', minWidth: 240 }, { key: 'packageCount', prop: 'packageCount', label: '预报箱数', width: 100 }]" :pagination="false" @selection-change="selected = $event as PickupCandidate[]" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-form-item label="本土卡车原单" required><el-select v-model="truck" filterable><el-option v-for="row in trucks" :key="`${row.customerId}:${row.orderNo}`" :value="`${row.customerId}:${row.orderNo}`" :label="`${row.orderNo} · ${row.origin} → ${row.destination} · ${row.palletCount}托`" /></el-select></el-form-item>
|
||||
<el-form-item label="调度仓库" required><el-select v-model="warehouse" filterable><el-option v-for="row in warehouses" :key="String(row.id)" :value="String(row.id)" :label="row.warehouseName" /></el-select></el-form-item>
|
||||
</template>
|
||||
<el-form-item label="计划发车时间" required><el-date-picker v-model="departure" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
<el-form-item label="预计司机任务结束时间" required><el-date-picker v-model="end" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button v-if="!pending" :disabled="busy" @click="emit('update:modelValue', false)">取消</el-button><el-button type="primary" :loading="busy" @click="save">{{ pending ? '按原请求重试' : '生成排车单' }}</el-button></template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
@@ -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<DispatchStage, number>; 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 }
|
||||
|
||||
@@ -16,9 +16,16 @@ vi.mock('@/modules/overseas-master-data', async () => ({
|
||||
vehicleStatusLabels: (await vi.importActual<typeof import('../../overseas-master-data/presentation')>('../../overseas-master-data/presentation')).vehicleStatusLabels,
|
||||
inquiryReturnTarget: (value: Record<string, string>) => 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()
|
||||
|
||||
@@ -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++ })
|
||||
<LoadErrorState v-if="rejectionError" title="排车操作未完成" :message="rejectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="rejectionError" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="loadError" title="排车详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<div v-else v-loading="loading" class="dispatch-detail" :aria-busy="loading">
|
||||
<section v-if="detail" class="detail-section" aria-label="排车操作"><div class="density-actions"><el-button v-for="value in visibleActions" :key="value" class="density-action" @click="openAction(value)">{{ dispatchActionLabels[value] }}</el-button><el-button v-if="canDispatchAction(detail?.actions, 'EDIT', access.can)" @click="openAction('EDIT', 'ROUTE')">调整卸货顺序</el-button></div></section>
|
||||
<el-tabs v-model="activeTab" class="density-element-tabs density-contained-tabs">
|
||||
<TransportExecutionPanel v-if="detail?.taskType && detail.taskType !== 'DELIVERY'" :dispatch-no="dispatchNo" @saved="load" />
|
||||
<section v-else-if="detail" class="detail-section" aria-label="排车操作"><div class="density-actions"><el-button v-for="value in visibleActions" :key="value" class="density-action" @click="openAction(value)">{{ dispatchActionLabels[value] }}</el-button><el-button v-if="canDispatchAction(detail?.actions, 'EDIT', access.can)" @click="openAction('EDIT', 'ROUTE')">调整卸货顺序</el-button></div></section>
|
||||
<el-tabs v-if="!detail?.taskType || detail.taskType === 'DELIVERY'" v-model="activeTab" class="density-element-tabs density-contained-tabs">
|
||||
<el-tab-pane name="vehicle"><template #label><span class="density-tab-icon" aria-hidden="true">🚚</span><span>车辆信息</span></template><dl class="detail-fields density-fields"><div v-for="field in vehicleFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><DispatchEvidenceFiles :model-value="detail?.vehicleInfo.vehicle?.evidenceFileIds || []" :dispatch-no="dispatchNo" kind="VEHICLE" readonly /><DispatchEvidenceFiles :model-value="detail?.vehicleInfo.driver?.evidence?.fileIds || []" :dispatch-no="dispatchNo" kind="DRIVER" readonly /></el-tab-pane>
|
||||
<el-tab-pane name="dispatch"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>排车信息</span></template><dl class="detail-fields density-fields"><div v-for="field in dispatchFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl></el-tab-pane>
|
||||
<el-tab-pane name="documents"><template #label><span class="density-tab-icon" aria-hidden="true">📝</span><span>单据信息</span></template>
|
||||
|
||||
@@ -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: '<div />' }, 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()
|
||||
})
|
||||
|
||||
@@ -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<DispatchListRow[]>([]), statistics = ref<DispatchStatistics | null>(null)
|
||||
const loading = ref(false), loadError = ref<ApiErrorDetails | null>(null), mutationError = ref<ApiErrorDetails | null>(null)
|
||||
const rejectionError = ref<ApiErrorDetails | null>(null)
|
||||
const transportOpen = ref(false), transportType = ref<TransportType>('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<QueryField[]>(() => [
|
||||
])
|
||||
const columns = computed<DataTableColumn[]>(() => [
|
||||
{ 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++ })
|
||||
<p v-if="recovery.pending.value" role="status">{{ recovery.pending.value.dispatchNo || '新排车单' }} 的{{ dispatchActionLabels[recovery.pending.value.action] }}{{ recovery.pending.value.result ? '操作已完成,页面资料待刷新。' : '结果尚待核对。' }}<el-button link :disabled="recovery.busy.value" @click="recoverPending">{{ recovery.pending.value.result ? '刷新页面资料' : '核对原请求' }}</el-button></p>
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" appearance="integrated" :stripe="false" :data="rows" :columns="columns" :loading="loading" :total="total" :preference-key="preferenceKey" row-key="dispatchNo" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-start><el-button v-if="appointmentReturnTarget(route.query)" link @click="router.push(appointmentReturnTarget(route.query)!)">返回预约工作</el-button><h1>排车列表</h1></template>
|
||||
<template #toolbar-end><el-button v-if="access.can(dispatchActionPermissions.CREATE)" type="primary" @click="createOpen = true">新建排车</el-button></template>
|
||||
<template #toolbar-end><el-button v-if="access.can(dispatchActionPermissions.CREATE)" @click="createTransport('PICKUP')">安排到仓取货</el-button><el-button v-if="access.can(dispatchActionPermissions.CREATE)" @click="createTransport('LOCAL_TRUCK')">安排本土卡车</el-button><el-button v-if="access.can(dispatchActionPermissions.CREATE)" type="primary" @click="createOpen = true">新建排车</el-button></template>
|
||||
<template #filters><div class="integrated-query-group"><div class="density-status-tabs" role="tablist" aria-label="排车执行阶段"><button v-for="item in dispatchStages" :key="item.value" role="tab" :aria-selected="stage === item.value" :class="{ 'is-active': stage === item.value }" @click="switchStage(item.value)">{{ item.label }} <span>{{ statistics?.stageCounts[item.value] ?? '—' }}</span></button></div><div v-if="showSections" class="density-status-tabs" role="tablist" aria-label="装车前审核分区"><button v-for="item in sections" :key="item.value" role="tab" :aria-selected="section === item.value" :class="{ 'is-active': section === item.value }" @click="switchSection(item.value)">{{ item.label }}</button></div><QueryToolbar v-model="model" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="search" /></div></template>
|
||||
<template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListRow)" :more-label="`更多操作 · ${row.dispatchNo}`" @action="handleAction($event, row as DispatchListRow)" /><p v-if="!row.actions">操作资格未返回,请刷新核对</p></template>
|
||||
<template #taskType="{ row }">{{ transportLabels[(row.taskType || 'DELIVERY') as keyof typeof transportLabels] }}</template>
|
||||
<template #vehicle="{ row }">{{ row.resources.vehicle?.plate || '—' }}</template><template #driver="{ row }">{{ row.resources.driver?.name || '—' }}<br>{{ row.resources.driver?.phone || '—' }}</template>
|
||||
<template #review="{ row }"><template v-for="review in [section === 'DISPATCH_REVIEW' ? row.reviews.dispatchReview : row.reviews.loadingReview]" :key="review.reviewRound"><div>{{ reviewLabels[review.status as ReviewStatus] }}</div><div v-if="review.reason">{{ review.reason }}</div><div v-if="review.invalidatedReason">{{ review.invalidatedReason }}</div><div>{{ time(review.reviewedAt) }}</div></template></template>
|
||||
<template #counts="{ row }">{{ row.counts.palletCount }}托/{{ row.counts.packageCount }}箱/已拣{{ row.counts.pickedPackageCount }}箱</template><template #planned="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #departure="{ row }">{{ time(row.departedAt) }}</template><template #stage="{ row }">{{ row.status === 'CANCELLED' ? '已取消' : stageLabel(row.stage) }}</template><template #routeMap="{ row }"><el-button link @click="openRoute(row as DispatchListRow)">查看路线</el-button></template>
|
||||
</DataTable>
|
||||
</ListPageLayout></AdminPage>
|
||||
<TransportTaskCreateDialog v-model="transportOpen" :type="transportType" @created="load" />
|
||||
<SmartDispatchDialog v-model="createOpen" :orders="[]" @created="load" />
|
||||
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="actionNo" :action="action" :operation="operation" @saved="handleSaved" @rejected="handleRejection" />
|
||||
<TransportRouteDialog v-model="routeOpen" :origin="routeOrigin" :destinations="routeDestinations" />
|
||||
|
||||
@@ -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 || '未知状态'
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>
|
||||
measurementSectionModes: { outer: string; actual: string }
|
||||
outerMeasurementRows: Record<string, unknown>[]
|
||||
actualMeasurementRows: Record<string, unknown>[]
|
||||
measurementTotals(rows: Record<string, unknown>[]): { volume: number; weightVolume: number }
|
||||
measurementValue(row: Record<string, unknown>, 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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Record<string, unknown>>, 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<string, unknown>, 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) {
|
||||
</LoadErrorState>
|
||||
<template v-else>
|
||||
<section class="measurement-section">
|
||||
<div class="measurement-section-title"><button type="button" class="measurement-section-toggle" :aria-expanded="!measurementCollapsed.customer" @click="toggleMeasurementSection('customer')"><h3>一、客户下单数据</h3><span v-if="measurementCollapsed.customer" class="measurement-section-summary"><span>总件数:{{ measurementTotals(customerMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(customerMeasurementRows).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(customerMeasurementRows).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals(customerMeasurementRows).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals(customerMeasurementRows).weightVolume, 6) }}</span></span><ChevronUp v-if="!measurementCollapsed.customer" :size="18" /><ChevronDown v-else :size="18" /></button><el-select class="measurement-section-mode" :model-value="measurementSectionModes.customer" aria-label="客户下单数据计算方式" @change="updateMeasurementSectionMode('customer', $event)"><el-option label="实测原尺寸(不调整)" value="ACTUAL" /><el-option label="箱规尺寸" value="ADD" /><el-option label="箱规系数" value="MULTIPLY" :disabled="multiplyModeDisabled" /></el-select></div>
|
||||
<div class="measurement-section-title"><button type="button" class="measurement-section-toggle" :aria-expanded="!measurementCollapsed.customer" @click="toggleMeasurementSection('customer')"><h3>一、客户下单数据</h3><span v-if="measurementCollapsed.customer" class="measurement-section-summary"><span>总件数:{{ measurementTotals(customerMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(customerMeasurementRows).gross) }}</span><span>总体积:{{ formatMeasurementVolume(measurementTotals(customerMeasurementRows).volume) }}</span><span>总材积重:{{ formatNumber(measurementTotals(customerMeasurementRows).volumetric) }}</span><span>重量体积:{{ formatMeasurementVolume(measurementTotals(customerMeasurementRows).weightVolume) }}</span></span><ChevronUp v-if="!measurementCollapsed.customer" :size="18" /><ChevronDown v-else :size="18" /></button><el-select class="measurement-section-mode" :model-value="measurementSectionModes.customer" aria-label="客户下单数据计算方式" @change="updateMeasurementSectionMode('customer', $event)"><el-option label="实测原尺寸(不调整)" value="ACTUAL" /><el-option label="箱规尺寸" value="ADD" /><el-option label="箱规系数" value="MULTIPLY" :disabled="multiplyModeDisabled" /></el-select></div>
|
||||
<div v-if="!measurementCollapsed.customer">
|
||||
<div class="detail-table-wrap"><el-table :data="customerMeasurementRows" border stripe empty-text="暂无客户下单数据"><el-table-column v-for="column in customerMeasurementColumns" :key="column.key" :label="column.label" :prop="column.key" min-width="120"><template #default="{ row }"><LineClampText v-if="column.key === 'remark'" :content="measurementValue(row as Record<string, unknown>, column.key)" /><template v-else>{{ measurementValue(row as Record<string, unknown>, column.key) }}</template></template></el-table-column></el-table></div>
|
||||
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementTotals(customerMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(customerMeasurementRows).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(customerMeasurementRows).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals(customerMeasurementRows).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals(customerMeasurementRows).weightVolume, 6) }}</span></div>
|
||||
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementTotals(customerMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(customerMeasurementRows).gross) }}</span><span>总体积:{{ formatMeasurementVolume(measurementTotals(customerMeasurementRows).volume) }}</span><span>总材积重:{{ formatNumber(measurementTotals(customerMeasurementRows).volumetric) }}</span><span>重量体积:{{ formatMeasurementVolume(measurementTotals(customerMeasurementRows).weightVolume) }}</span></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="measurement-section">
|
||||
<div class="measurement-section-title"><button type="button" class="measurement-section-toggle" :aria-expanded="!measurementCollapsed.outer" @click="toggleMeasurementSection('outer')"><h3>二、外箱规数据</h3><span v-if="measurementCollapsed.outer" class="measurement-section-summary"><span>总件数:{{ measurementTotals(outerMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(outerMeasurementRows).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(outerMeasurementRows).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals(outerMeasurementRows).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals(outerMeasurementRows).weightVolume, 6) }}</span></span><ChevronUp v-if="!measurementCollapsed.outer" :size="18" /><ChevronDown v-else :size="18" /></button></div>
|
||||
<div class="measurement-section-title"><button type="button" class="measurement-section-toggle" :aria-expanded="!measurementCollapsed.outer" @click="toggleMeasurementSection('outer')"><h3>二、外箱规数据</h3><span v-if="measurementCollapsed.outer" class="measurement-section-summary"><span>总件数:{{ measurementTotals(outerMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(outerMeasurementRows).gross) }}</span><span>总体积:{{ formatMeasurementVolume(measurementTotals(outerMeasurementRows).volume) }}</span><span>总材积重:{{ formatNumber(measurementTotals(outerMeasurementRows).volumetric) }}</span><span>重量体积:{{ formatMeasurementVolume(measurementTotals(outerMeasurementRows).weightVolume) }}</span></span><ChevronUp v-if="!measurementCollapsed.outer" :size="18" /><ChevronDown v-else :size="18" /></button></div>
|
||||
<div v-if="!measurementCollapsed.outer">
|
||||
<div v-loading="measurementLoading" class="detail-table-wrap"><el-table :data="outerMeasurementRows" border stripe empty-text="暂无外箱规数据"><el-table-column label="#" prop="itemNo" min-width="70" /><el-table-column v-for="column in outerColumns.filter((item) => item.key !== 'itemNo')" :key="column.key" :label="column.label" :prop="column.key" min-width="150"><template #default="{ row }"><LineClampText v-if="column.key === 'remark'" :content="measurementValue(row as Record<string, unknown>, column.key)" /><template v-else>{{ measurementValue(row as Record<string, unknown>, column.key) }}</template></template></el-table-column></el-table></div>
|
||||
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementTotals(outerMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(outerMeasurementRows).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(outerMeasurementRows).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals(outerMeasurementRows).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals(outerMeasurementRows).weightVolume, 6) }}</span></div>
|
||||
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementTotals(outerMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(outerMeasurementRows).gross) }}</span><span>总体积:{{ formatMeasurementVolume(measurementTotals(outerMeasurementRows).volume) }}</span><span>总材积重:{{ formatNumber(measurementTotals(outerMeasurementRows).volumetric) }}</span><span>重量体积:{{ formatMeasurementVolume(measurementTotals(outerMeasurementRows).weightVolume) }}</span></div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="measurement-rule-selector">
|
||||
@@ -2683,7 +2698,7 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
|
||||
<span v-if="!measurementStrategyEditable" class="measurement-rule-selector__hint">{{ measurementStrategyLockedHint }}</span>
|
||||
</div>
|
||||
<section class="measurement-section">
|
||||
<div class="measurement-section-title"><button type="button" class="measurement-section-toggle" :aria-expanded="!measurementCollapsed.actual" @click="toggleMeasurementSection('actual')"><h3>三、实际测量数据</h3><span v-if="measurementCollapsed.actual" class="measurement-section-summary"><span>总件数:{{ measurementTotals(actualMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(actualMeasurementRows).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(actualMeasurementRows).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals(actualMeasurementRows).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals(actualMeasurementRows).weightVolume, 6) }}</span></span><ChevronUp v-if="!measurementCollapsed.actual" :size="18" /><ChevronDown v-else :size="18" /></button><el-select class="measurement-section-mode" :model-value="measurementSectionModes.actual" aria-label="实际测量数据计算方式" @change="updateMeasurementSectionMode('actual', $event)"><el-option label="实测原尺寸(不调整)" value="ACTUAL" /><el-option label="箱规尺寸" value="ADD" /><el-option label="箱规系数" value="MULTIPLY" :disabled="multiplyModeDisabled" /></el-select></div>
|
||||
<div class="measurement-section-title"><button type="button" class="measurement-section-toggle" :aria-expanded="!measurementCollapsed.actual" @click="toggleMeasurementSection('actual')"><h3>三、实际测量数据</h3><span v-if="measurementCollapsed.actual" class="measurement-section-summary"><span>总件数:{{ measurementTotals(actualMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(actualMeasurementRows).gross) }}</span><span>总体积:{{ formatMeasurementVolume(measurementTotals(actualMeasurementRows).volume) }}</span><span>总材积重:{{ formatNumber(measurementTotals(actualMeasurementRows).volumetric) }}</span><span>重量体积:{{ formatMeasurementVolume(measurementTotals(actualMeasurementRows).weightVolume) }}</span></span><ChevronUp v-if="!measurementCollapsed.actual" :size="18" /><ChevronDown v-else :size="18" /></button><el-select class="measurement-section-mode" :model-value="measurementSectionModes.actual" aria-label="实际测量数据计算方式" @change="updateMeasurementSectionMode('actual', $event)"><el-option label="实测原尺寸(不调整)" value="ACTUAL" /><el-option label="箱规尺寸" value="ADD" /><el-option label="箱规系数" value="MULTIPLY" :disabled="multiplyModeDisabled" /></el-select></div>
|
||||
<div v-if="!measurementCollapsed.actual">
|
||||
<div v-loading="measurementLoading" class="detail-table-wrap">
|
||||
<el-table :data="actualMeasurementRows" row-key="id" border stripe empty-text="暂无实际测量数据">
|
||||
@@ -2721,7 +2736,7 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementTotals(actualMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(actualMeasurementRows).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(actualMeasurementRows).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals(actualMeasurementRows).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals(actualMeasurementRows).weightVolume, 6) }}</span></div>
|
||||
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementTotals(actualMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(actualMeasurementRows).gross) }}</span><span>总体积:{{ formatMeasurementVolume(measurementTotals(actualMeasurementRows).volume) }}</span><span>总材积重:{{ formatNumber(measurementTotals(actualMeasurementRows).volumetric) }}</span><span>重量体积:{{ formatMeasurementVolume(measurementTotals(actualMeasurementRows).weightVolume) }}</span></div>
|
||||
<section class="weight-evidence" aria-label="称重图片与服务端确认重量">
|
||||
<div class="weight-evidence__heading">
|
||||
<div><h4>称重图片与确认重量</h4></div>
|
||||
|
||||
Reference in New Issue
Block a user