Compare commits

..
Author SHA1 Message Date
liuyihe 8afd04aa55 优化 2026-10-20 21:33:46 +08:00
47 changed files with 450 additions and 1331 deletions
+1 -8
View File
@@ -8,13 +8,6 @@ import {
} from './adminNavigation'
describe('adminNavigation', () => {
it('keeps outsourced read access separate from owned access and write-only permissions', () => {
const read = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:outsourced-business:list']))
const vehicle = read.find(node => node.key === 'vehicle-management')
expect(vehicle?.children?.map(node => node.path)).toEqual(['/overseas-master-data/outsourced-business'])
const write = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:outsourced-business:edit', 'platform:fleet:outsourced-business:upload']))
expect(getNavigationLeaves(write).some(node => node.path === '/overseas-master-data/outsourced-business')).toBe(false)
})
it('places owned-business under vehicle management and keeps edit/upload from granting page access', () => {
const read = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:owned-business:list']))
const vehicle = read.find(node => node.key === 'vehicle-management')
@@ -47,7 +40,7 @@ describe('adminNavigation', () => {
{ title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] },
{ title: '海外尾程', children: ['尾程订单', '入库单', '废弃单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理', '退货库存管理', '退货箱号管理'] },
{ title: '预约管理', children: ['预约列表'] },
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '自有业务', '外配业务', '出车记录'] },
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '自有业务', '出车记录'] },
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板', '散板需求'] },
{ title: '客户服务', children: ['异常订单'] },
{ title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
-1
View File
@@ -135,7 +135,6 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' },
{ key: 'driver-management', title: '司机管理', icon: ContactRound, path: '/overseas-master-data/drivers', permission: 'platform:overseas-master-data:driver:list' },
{ key: 'fleet-owned-business', title: '自有业务', icon: Truck, path: '/overseas-master-data/owned-business', permission: 'platform:fleet:owned-business:list' },
{ key: 'fleet-outsourced-business', title: '外配业务', icon: Truck, path: '/overseas-master-data/outsourced-business', permission: 'platform:fleet:outsourced-business:list' },
{ key: 'fleet-trips', title: '车次管理', icon: Truck, path: '/overseas-master-data/trips', permission: 'platform:fleet:trip:list', hidden: true },
{ key: 'fleet-operations', title: '出车记录', icon: Truck, path: '/overseas-master-data/trip-operations', permission: 'platform:fleet:operations:list' },
],
@@ -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) }))
}
@@ -1,87 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { getOutsourcedBusiness, listOutsourcedBusiness, listOutsourcedBusinessHistory, saveOutsourcedBusinessOperations, uploadOutsourcedBusinessPhoto, bindOutsourcedBusinessPhoto, getOutsourcedBusinessPhotoContent } from './outsourcedBusiness'
import { outsourcedBusinessFixture } from '../components/OutsourcedBusinessFixtures'
import type { OutsourcedBusinessPhoto, OutsourcedBusinessPhotoSlot, OutsourcedBusinessSave } from '../outsourcedBusinessTypes'
vi.mock('@/shared/api/http', async importOriginal => {
const actual = await importOriginal<typeof import('@/shared/api/http')>()
return { ...actual, http: { get: vi.fn(), put: vi.fn(), post: vi.fn() }, unwrapApiResponse: (value: { data: unknown }) => value.data }
})
beforeEach(() => vi.clearAllMocks())
const base = '/platform/v1/last-leg/fleet/outsourced-business'
const photo: OutsourcedBusinessPhoto = { slot: 'LOADING_SCENE', category: 'LOADING', fileId: '9007199254740993', fileName: 'loading.png', contentType: 'image/png', sizeBytes: '10', uploadedAt: '2026-10-20T10:00:00Z', savedAt: null }
describe('outsourced business HTTP contract', () => {
it('uses one list response for matched statistics and keeps physical count independent of cargo rows', async () => {
const detail = outsourcedBusinessFixture()
vi.mocked(http.get).mockResolvedValue({ data: { records: [detail.row], page: '1', size: '20', total: '41', summary: { dispatchCount: '41', afterLoadingCount: '40', deliveringCount: '1', deliveredCount: '0' } } })
const query = { page: 1, size: 20, plannedDepartureFrom: '2026-10-20T00:00:00Z', plannedDepartureTo: '2026-10-21T00:00:00Z', keyword: 'FBA-1' }
const value = await listOutsourcedBusiness(query)
expect(http.get).toHaveBeenCalledExactlyOnceWith(base, { params: query })
expect(value.summary.dispatchCount).toBe(41); expect(value.records).toHaveLength(1); expect(value.records[0]!.actualPalletCount).toBe(1)
})
it('encodes the dispatch business number and preserves auxiliary/main versions independently', async () => {
const detail = outsourcedBusinessFixture(); detail.row.recordVersion = '0'; detail.row.dispatchRecordVersion = '9007199254740993'
detail.row.dispatchNo = 'DS/1'; detail.dispatch.dispatchNo = 'DS/1'
vi.mocked(http.get).mockResolvedValue({ data: detail })
expect((await getOutsourcedBusiness('DS/1')).row.dispatchRecordVersion).toBe('9007199254740993')
expect(http.get).toHaveBeenCalledWith(`${base}/DS%2F1`, { params: undefined })
detail.dispatch.dispatchNo = 'another'; await expect(getOutsourcedBusiness('DS/1')).rejects.toThrow('不一致')
})
it('does not convert money text, calculate a client unit price or submit a dispatch version', async () => {
const body: OutsourcedBusinessSave = { recordVersion: '0', idempotencyKey: 'original-key', operations: { ...outsourcedBusinessFixture().row.operations, money: { currency: 'USD', amount: '9999999999999999.99' } }, reason: null }
const detail = outsourcedBusinessFixture(); detail.row.operations.money = body.operations.money
vi.mocked(http.put).mockResolvedValue({ data: detail })
const saved = await saveOutsourcedBusinessOperations('DS-OUT-1', body)
expect(http.put).toHaveBeenCalledExactlyOnceWith(`${base}/DS-OUT-1/operations`, body)
expect(body.operations.money!.amount).toBe('9999999999999999.99'); expect('dispatchRecordVersion' in body).toBe(false)
expect(saved.row.operations.money!.amount).toBe('9999999999999999.99')
})
it('keeps upload and versioned file binding separate with the same prepared file identity', async () => {
vi.mocked(http.post).mockResolvedValue({ data: photo })
const file = new File(['image'], 'loading.png', { type: 'image/png' })
await uploadOutsourcedBusinessPhoto('DS/1', 'LOADING_SCENE', file, 'upload-key')
const [url, form] = vi.mocked(http.post).mock.calls[0]!
expect(url).toBe(`${base}/DS%2F1/photos/LOADING_SCENE/uploads`); expect((form as FormData).get('file')).toBe(file); expect((form as FormData).get('idempotencyKey')).toBe('upload-key')
const detail = outsourcedBusinessFixture(); detail.row.dispatchNo = 'DS/1'; detail.dispatch.dispatchNo = 'DS/1'
vi.mocked(http.put).mockResolvedValue({ data: detail })
await bindOutsourcedBusinessPhoto('DS/1', 'LOADING_SCENE', { recordVersion: '0', idempotencyKey: 'bind-key', fileId: photo.fileId })
expect(http.put).toHaveBeenCalledExactlyOnceWith(`${base}/DS%2F1/photos/LOADING_SCENE`, { recordVersion: '0', idempotencyKey: 'bind-key', fileId: photo.fileId })
})
it('retains the two history streams and reads historical files only at their explicit revision', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { records: [{ eventSource: 'DISPATCH', recordVersion: null, dispatchRecordVersion: '9', operations: null, photos: [] }, { eventSource: 'OPERATIONS', recordVersion: '1', dispatchRecordVersion: null, operations: outsourcedBusinessFixture().row.operations, photos: [] }], page: '1', size: '20', total: '2' } })
const value = await listOutsourcedBusinessHistory('DS/1', 1, 20)
expect(value.records.map(record => [record.eventSource, record.recordVersion, record.dispatchRecordVersion])).toEqual([['DISPATCH',null,'9'],['OPERATIONS','1',null]])
vi.mocked(http.get).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
await getOutsourcedBusinessPhotoContent('DS/1', photo, '1')
expect(http.get).toHaveBeenLastCalledWith(`${base}/DS%2F1/photos/LOADING_SCENE/files/${photo.fileId}/content`, { responseType: 'blob', params: { revisionVersion: '1' } })
})
it('never renders JSON errors or empty bytes as a photo', async () => {
vi.mocked(http.get).mockResolvedValue(new Blob([JSON.stringify({ code: 'A0403', message: '拒绝读取', requestId: 'request', traceId: 'trace' })], { type: 'application/json' }))
await expect(getOutsourcedBusinessPhotoContent('DS-OUT-1', photo)).rejects.toMatchObject({ code: 'A0403', requestId: 'request', traceId: 'trace' })
vi.mocked(http.get).mockResolvedValue(new Blob([], { type: 'image/png' })); await expect(getOutsourcedBusinessPhotoContent('DS-OUT-1', photo)).rejects.toThrow('为空')
await expect(uploadOutsourcedBusinessPhoto('DS-OUT-1','LOADING_SCENE',new File(['text'],'bad.txt',{type:'text/plain'}),'key')).rejects.toThrow('JPG')
})
it.each(['2', null])('accepts authoritative external vehicle identity %s without looking up the current master', async vehicleId => {
const detail = outsourcedBusinessFixture(); detail.row.vehicle.vehicleId = vehicleId; detail.row.vehicle.carrierName = null
vi.mocked(http.get).mockResolvedValue({ data: detail })
const value = await getOutsourcedBusiness('DS-OUT-1')
expect(value.row.vehicle).toMatchObject({ vehicleId, ownership: 'DISPATCHED', carrierName: null })
expect(http.get).toHaveBeenCalledTimes(1)
})
it('rejects owned vehicle facts and unsupported fuel or mileage writes before any request', async () => {
const detail = outsourcedBusinessFixture()
vi.mocked(http.get).mockResolvedValue({ data: { ...detail, row: { ...detail.row, vehicle: { ...detail.row.vehicle, ownership: 'OWNED' } } } })
await expect(getOutsourcedBusiness('DS-OUT-1')).rejects.toThrow('来源或阶段')
for (const field of ['actualDistanceMiles','fuelConsumptionLitresPer100Km','actualFuelLitres']) {
const body = { recordVersion:'0', idempotencyKey:'original-key', operations:{ ...detail.row.operations, [field]:'1' }, reason:null }
await expect(saveOutsourcedBusinessOperations('DS-OUT-1', body)).rejects.toThrow('不支持里程或油耗')
}
expect(http.put).not.toHaveBeenCalled()
for (const slot of ['FUEL_START','FUEL_END']) {
await expect(uploadOutsourcedBusinessPhoto('DS-OUT-1',slot as OutsourcedBusinessPhotoSlot,new File(['image'],'loading.png',{type:'image/png'}),'upload-key')).rejects.toThrow('不支持该照片分类')
await expect(bindOutsourcedBusinessPhoto('DS-OUT-1',slot as OutsourcedBusinessPhotoSlot,{recordVersion:'0',idempotencyKey:'bind-key',fileId:photo.fileId})).rejects.toThrow('不支持该照片分类')
}
expect(http.post).not.toHaveBeenCalled(); expect(http.put).not.toHaveBeenCalled()
})
})
@@ -1,74 +0,0 @@
import axios from 'axios'
import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http'
import { outsourcedBusinessDecimal, outsourcedBusinessVersion, outsourcedBusinessPhotoRoles, type OutsourcedBusinessOperations, type OutsourcedBusinessRow, type OutsourcedBusinessDetail, type OutsourcedBusinessList, type OutsourcedBusinessQuery, type OutsourcedBusinessSave, type OutsourcedBusinessBind, type OutsourcedBusinessPhoto, type OutsourcedBusinessPhotoSlot, type OutsourcedBusinessCommand, type OutsourcedBusinessHistory, type OutsourcedBusinessPage } from '../outsourcedBusinessTypes'
const base = '/platform/v1/last-leg/fleet/outsourced-business'
const path = (no: string) => `${base}/${encodeURIComponent(no)}`
async function get<T>(url: string, params?: object): Promise<T> { return unwrapApiResponse(await http.get<ApiEnvelope<T>, ApiEnvelope<T>>(url, { params })) }
function count(value: unknown): number { const text = outsourcedBusinessVersion(value); const result = Number(text); if (!Number.isSafeInteger(result)) throw new Error('运营记录数量超出精确范围'); return result }
function decimal(value: string | null, scale: number, precision: number): string | null {
if (value == null) return null
if (typeof value !== 'string' || outsourcedBusinessDecimal(value, scale, precision) !== value) throw new Error('运营金额或测量必须使用精确十进制字符串')
return value
}
function operations(value: OutsourcedBusinessOperations): OutsourcedBusinessOperations {
if ('actualDistanceMiles' in value || 'fuelConsumptionLitresPer100Km' in value || 'actualFuelLitres' in value) throw new Error('外配运营信息不支持里程或油耗')
if (value.money && !['CNY','USD'].includes(value.money.currency)) throw new Error('运营金额币种无效')
const amount = value.money ? decimal(value.money.amount, 2, 18) : null
if (value.money && amount == null) throw new Error('承运金额与币种必须同时登记')
return { money: value.money ? { currency: value.money.currency, amount: amount! } : null, arrivals: value.arrivals }
}
function requireSlot(slot: OutsourcedBusinessPhotoSlot) { if (!outsourcedBusinessPhotoRoles.some(role => role.slot === slot)) throw new Error('外配业务不支持该照片分类') }
function row(value: OutsourcedBusinessRow): OutsourcedBusinessRow {
if (!value.dispatchNo || !['AFTER_LOADING','DELIVERING','DELIVERED'].includes(value.stage) || value.vehicle?.ownership !== 'DISPATCHED') throw new Error('外配排车来源或阶段不完整,请重新读取')
return { ...value, operations: operations(value.operations), unitPrice: decimal(value.unitPrice,2,18), recordVersion: outsourcedBusinessVersion(value.recordVersion), dispatchRecordVersion: outsourcedBusinessVersion(value.dispatchRecordVersion), actualPalletCount: value.actualPalletCount == null ? null : count(value.actualPalletCount), photoSummary: { loadingCount: count(value.photoSummary.loadingCount) } }
}
function detail(value: OutsourcedBusinessDetail): OutsourcedBusinessDetail { if (value.row.dispatchNo !== value.dispatch.dispatchNo) throw new Error('运营资料与原排车单不一致'); return { ...value, row: row(value.row) } }
function command(value: OutsourcedBusinessCommand): OutsourcedBusinessCommand { return { ...value, recordVersion: value.recordVersion == null ? null : outsourcedBusinessVersion(value.recordVersion) } }
export async function listOutsourcedBusiness(query: OutsourcedBusinessQuery): Promise<OutsourcedBusinessList> {
const value = await get<OutsourcedBusinessList>(base, query)
return { ...value, records: value.records.map(row), page: count(value.page), size: count(value.size), total: count(value.total), summary: { dispatchCount: count(value.summary.dispatchCount), afterLoadingCount: count(value.summary.afterLoadingCount), deliveringCount: count(value.summary.deliveringCount), deliveredCount: count(value.summary.deliveredCount) } }
}
export async function getOutsourcedBusiness(no: string) { const value = detail(await get<OutsourcedBusinessDetail>(path(no))); if (value.row.dispatchNo !== no) throw new Error('运营资料与当前排车业务号不一致'); return value }
export async function listOutsourcedBusinessHistory(no: string, page: number, size: number): Promise<OutsourcedBusinessPage<OutsourcedBusinessHistory>> {
const value = await get<OutsourcedBusinessPage<OutsourcedBusinessHistory>>(`${path(no)}/history`, { page, size })
return { ...value, records: value.records.map(record => ({ ...record, operations: record.operations ? operations(record.operations) : null, recordVersion: record.recordVersion == null ? null : outsourcedBusinessVersion(record.recordVersion), dispatchRecordVersion: record.dispatchRecordVersion == null ? null : outsourcedBusinessVersion(record.dispatchRecordVersion) })), page: count(value.page), size: count(value.size), total: count(value.total) }
}
export const getOutsourcedBusinessCommand = (no: string, key: string) => get<OutsourcedBusinessCommand>(`${path(no)}/commands/${encodeURIComponent(key)}`).then(command)
export async function saveOutsourcedBusinessOperations(no: string, body: OutsourcedBusinessSave) {
outsourcedBusinessVersion(body.recordVersion)
operations(body.operations)
const value = detail(unwrapApiResponse(await http.put<ApiEnvelope<OutsourcedBusinessDetail>, ApiEnvelope<OutsourcedBusinessDetail>>(`${path(no)}/operations`, body)))
if (value.row.dispatchNo !== no) throw new Error('运营保存结果与原排车单不一致')
return value
}
export async function uploadOutsourcedBusinessPhoto(no: string, slot: OutsourcedBusinessPhotoSlot, file: File, key: string) {
requireSlot(slot)
if (!['image/jpeg','image/png'].includes(file.type) || file.size <= 0) throw new Error('请选择有效的JPG或PNG图片')
const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key)
const value = unwrapApiResponse(await http.post<ApiEnvelope<OutsourcedBusinessPhoto>, ApiEnvelope<OutsourcedBusinessPhoto>>(`${path(no)}/photos/${slot}/uploads`, form))
if (value.slot !== slot || !value.fileId) throw new Error('上传文件与原照片分类不一致')
return value
}
export async function bindOutsourcedBusinessPhoto(no: string, slot: OutsourcedBusinessPhotoSlot, body: OutsourcedBusinessBind) {
requireSlot(slot)
outsourcedBusinessVersion(body.recordVersion)
const value = detail(unwrapApiResponse(await http.put<ApiEnvelope<OutsourcedBusinessDetail>, ApiEnvelope<OutsourcedBusinessDetail>>(`${path(no)}/photos/${slot}`, body)))
if (value.row.dispatchNo !== no) throw new Error('照片绑定结果与原排车单不一致')
return value
}
async function downloadError(payload: unknown, status: number) {
let value = payload
if (payload instanceof Blob && payload.type.includes('json')) { try { value = JSON.parse(await payload.text()) as unknown } catch { throw new Error('图片接口返回了无法识别的错误') } }
if (value && typeof value === 'object' && 'code' in value) { const error = value as ApiEnvelope<unknown>; throw new ApiResponseError(error.message || '图片读取失败', error.code, status, error.data, error.requestId, error.traceId) }
}
export async function getOutsourcedBusinessPhotoContent(no: string, photo: OutsourcedBusinessPhoto, revisionVersion?: string): Promise<Blob> {
requireSlot(photo.slot)
if (revisionVersion != null && BigInt(outsourcedBusinessVersion(revisionVersion)) < 1n) throw new Error('历史图片版本必须大于零')
try {
const blob = await http.get<Blob, Blob>(`${path(no)}/photos/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob', params: revisionVersion == null ? undefined : { revisionVersion: outsourcedBusinessVersion(revisionVersion) } })
await downloadError(blob, 200)
if (!(blob instanceof Blob) || !['image/jpeg','image/png'].includes(blob.type) || !blob.size) throw new Error('图片内容为空或格式无效')
return blob
} catch (cause) { if (axios.isAxiosError(cause)) await downloadError(cause.response?.data, cause.response?.status || 0); throw cause }
}
@@ -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>
@@ -1,72 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import * as api from '../api/outsourcedBusiness'
import { outsourcedBusinessFixture, outsourcedBusinessCommandFixture } from './OutsourcedBusinessFixtures'
import { acceptOutsourcedBusinessCommand, newOutsourcedBusinessPending, persistOutsourcedBusinessPending, restoreOutsourcedBusinessPending, recoverOutsourcedBusinessCommand, outsourcedBusinessPendingKey } from './OutsourcedBusinessCommandRecovery'
import type { OutsourcedBusinessPhoto, OutsourcedBusinessSave } from '../outsourcedBusinessTypes'
beforeEach(() => sessionStorage.clear())
const no = 'DS-OUT-1'
function operation() : OutsourcedBusinessSave { return { recordVersion: '0', idempotencyKey: 'original-key', operations: { ...outsourcedBusinessFixture().row.operations, money: { currency: 'USD', amount: '9999999999999999.99' } }, reason: null } }
function client() {
const current = outsourcedBusinessFixture(); current.row.recordVersion = '1'
const photo: OutsourcedBusinessPhoto = { slot:'LOADING_SCENE',category:'LOADING',fileId:'9007199254740993',fileName:'loading.png',contentType:'image/png',sizeBytes:'5',uploadedAt:'2026-10-20T10:00:00Z',savedAt:null }
return { ...api, getOutsourcedBusiness: vi.fn(async () => current), getOutsourcedBusinessCommand: vi.fn(async () => outsourcedBusinessCommandFixture()), saveOutsourcedBusinessOperations: vi.fn(async () => current), bindOutsourcedBusinessPhoto: vi.fn(async () => current), uploadOutsourcedBusinessPhoto: vi.fn(async () => photo) }
}
describe('outsourced business exact command recovery', () => {
it('persists the complete original payload, actor and zero auxiliary version', () => {
const pending = newOutsourcedBusinessPending('actor', no, 'SAVE_OPERATIONS', operation())
persistOutsourcedBusinessPending('actor', no, 'operations', pending)
expect(restoreOutsourcedBusinessPending('actor', no, 'operations')).toEqual(pending)
expect(restoreOutsourcedBusinessPending('another', no, 'operations')).toBeNull()
const raw = JSON.parse(sessionStorage.getItem(outsourcedBusinessPendingKey('actor',no,'operations'))!)
raw.actor = 'another'; sessionStorage.setItem(outsourcedBusinessPendingKey('actor',no,'operations'),JSON.stringify(raw))
expect(() => restoreOutsourcedBusinessPending('actor',no,'operations')).toThrow('损坏')
})
it('looks up an unknown write first and retries only the unchanged original body on explicit not-found', async () => {
const pending = newOutsourcedBusinessPending('actor', no, 'SAVE_OPERATIONS', operation()); pending.attempted = true
const service = client(), persist = vi.fn()
service.getOutsourcedBusinessCommand.mockRejectedValue(new ApiResponseError('not found','A0404',404))
await recoverOutsourcedBusinessCommand(pending,persist,() => {},service)
expect(service.getOutsourcedBusinessCommand).toHaveBeenCalledWith(no,'original-key')
expect(service.saveOutsourcedBusinessOperations).toHaveBeenCalledExactlyOnceWith(no,operation())
expect(pending.body).toEqual(operation()); expect(pending.body?.recordVersion).toBe('0')
})
it('retains a proven success through failed fresh reads without writing it a second time', async () => {
const pending = newOutsourcedBusinessPending('actor', no, 'SAVE_OPERATIONS', operation()), service = client()
service.getOutsourcedBusiness.mockRejectedValueOnce(new Error('read offline'))
await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('read offline')
expect(pending.confirmed?.recordVersion).toBe('1')
await recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)
expect(service.saveOutsourcedBusinessOperations).toHaveBeenCalledTimes(1); expect(service.getOutsourcedBusinessCommand).not.toHaveBeenCalled()
})
it('never retransmits a file for an unknown upload, including command not-found', async () => {
const pending = newOutsourcedBusinessPending('actor',no,'UPLOAD_PHOTO',null,'LOADING_SCENE'); pending.attempted = true
const service = client(); service.getOutsourcedBusinessCommand.mockRejectedValue(new ApiResponseError('not found','A0404',404))
await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('禁止重复上传')
expect(service.uploadOutsourcedBusinessPhoto).not.toHaveBeenCalled(); expect(service.bindOutsourcedBusinessPhoto).not.toHaveBeenCalled(); expect(pending.key).toBeTruthy()
})
it('keeps the prepared file and exact binding key through unknown binding and later replacement', async () => {
const photo: OutsourcedBusinessPhoto = { slot:'LOADING_SCENE',category:'LOADING',fileId:'9007199254740993',fileName:'loading.png',contentType:'image/png',sizeBytes:'5',uploadedAt:'2026-10-20T10:00:00Z',savedAt:'2026-10-20T10:01:00Z' }
const pending = newOutsourcedBusinessPending('actor',no,'BIND_PHOTO',{recordVersion:'0',idempotencyKey:'bind-key',fileId:photo.fileId},photo.slot)
pending.attempted = true
const service = client(); const bound = outsourcedBusinessFixture(); bound.row.recordVersion='1'; bound.photos=[photo]
const original = outsourcedBusinessCommandFixture({action:'BIND_PHOTO',idempotencyKey:'bind-key',result:bound})
service.getOutsourcedBusinessCommand.mockResolvedValue(original)
const current = outsourcedBusinessFixture();current.row.recordVersion='2';current.photos=[{...photo,fileId:'9007199254740994'}]
service.getOutsourcedBusiness.mockResolvedValue(current)
const result = await recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)
expect(result.detail?.photos[0]?.fileId).toBe('9007199254740994')
expect(pending.body).toEqual({recordVersion:'0',idempotencyKey:'bind-key',fileId:photo.fileId})
expect(service.uploadOutsourcedBusinessPhoto).not.toHaveBeenCalled();expect(service.bindOutsourcedBusinessPhoto).not.toHaveBeenCalled()
})
it('refuses wrong command identities, stale reads, definite rejection retries and scope changes', async () => {
const pending = newOutsourcedBusinessPending('actor',no,'SAVE_OPERATIONS',operation()), service=client()
expect(() => acceptOutsourcedBusinessCommand(pending,outsourcedBusinessCommandFixture({dispatchNo:'another'}))).toThrow('不一致')
pending.command=outsourcedBusinessCommandFixture({recordVersion:'9007199254740993'})
await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('早于')
pending.rejected=true;await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('明确拒绝')
pending.rejected=false;await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {throw new Error('scope changed')},service)).rejects.toThrow('scope changed')
expect(service.saveOutsourcedBusinessOperations).not.toHaveBeenCalled()
})
})
@@ -1,89 +0,0 @@
import { ApiResponseError } from '@/shared/api/http'
import * as api from '../api/outsourcedBusiness'
import { outsourcedBusinessPhotoRoles, outsourcedBusinessVersion, type OutsourcedBusinessAction, type OutsourcedBusinessBind, type OutsourcedBusinessCommand, type OutsourcedBusinessDetail, type OutsourcedBusinessPhoto, type OutsourcedBusinessPhotoSlot, type OutsourcedBusinessSave } from '../outsourcedBusinessTypes'
export interface OutsourcedBusinessPending {
actor: string; dispatchNo: string; action: OutsourcedBusinessAction; key: string; slot?: OutsourcedBusinessPhotoSlot
body: OutsourcedBusinessSave | OutsourcedBusinessBind | null; attempted: boolean; command: OutsourcedBusinessCommand | null; confirmed: { recordVersion: string | null; result: OutsourcedBusinessDetail | OutsourcedBusinessPhoto } | null; rejected: boolean
}
export const outsourcedBusinessPendingKey = (actor: string, no: string, area: string) => `outsourced-business:${encodeURIComponent(actor)}:${encodeURIComponent(no)}:${area}`
export function restoreOutsourcedBusinessPending(actor: string, no: string, area: string): OutsourcedBusinessPending | null {
const raw = sessionStorage.getItem(outsourcedBusinessPendingKey(actor, no, area))
if (!raw) return null
const pending = JSON.parse(raw) as OutsourcedBusinessPending
if (pending.actor !== actor || pending.dispatchNo !== no || !pending.key || !['SAVE_OPERATIONS','UPLOAD_PHOTO','BIND_PHOTO'].includes(pending.action)) throw new Error('原运营请求记录损坏,请保留现场核对')
if (pending.action !== 'SAVE_OPERATIONS' && !outsourcedBusinessPhotoRoles.some(role => role.slot === pending.slot) || pending.action !== 'UPLOAD_PHOTO' && (!pending.body || pending.body.idempotencyKey !== pending.key)) throw new Error('原运营请求内容不完整,请保留现场核对')
return pending
}
export function persistOutsourcedBusinessPending(actor: string, no: string, area: string, pending: OutsourcedBusinessPending | null) {
if (!actor || !no || pending && (pending.actor !== actor || pending.dispatchNo !== no)) throw new Error('运营请求身份已变化')
const key = outsourcedBusinessPendingKey(actor, no, area)
if (pending) sessionStorage.setItem(key, JSON.stringify(pending)); else sessionStorage.removeItem(key)
}
export function newOutsourcedBusinessPending(actor: string, no: string, action: OutsourcedBusinessAction, body: OutsourcedBusinessPending['body'], slot?: OutsourcedBusinessPhotoSlot): OutsourcedBusinessPending {
if (!actor || !no) throw new Error('当前运营作业身份缺失')
const key = body?.idempotencyKey || crypto.randomUUID()
return { actor, dispatchNo: no, action, key, slot, body: body ? structuredClone(body) : null, attempted: false, command: null, confirmed: null, rejected: false }
}
export function acceptOutsourcedBusinessResult(pending: OutsourcedBusinessPending, result: OutsourcedBusinessDetail | OutsourcedBusinessPhoto) {
if (pending.action === 'UPLOAD_PHOTO') {
if (!('fileId' in result) || result.slot !== pending.slot || result.category !== 'LOADING' || typeof result.fileId !== 'string' || !/^[1-9]\d*$/.test(result.fileId) || !['image/jpeg','image/png'].includes(result.contentType)) throw new Error('原上传尚未返回本分类的真实文件')
pending.confirmed = { recordVersion: null, result }
} else {
if (!('row' in result) || result.row.dispatchNo !== pending.dispatchNo) throw new Error('运营保存结果与原排车单不一致')
pending.confirmed = { recordVersion: outsourcedBusinessVersion(result.row.recordVersion), result }
}
}
export function acceptOutsourcedBusinessCommand(pending: OutsourcedBusinessPending, command: OutsourcedBusinessCommand) {
if (command.dispatchNo !== pending.dispatchNo || command.action !== pending.action || command.idempotencyKey !== pending.key) throw new Error('返回结果与原运营请求不一致,请保留原请求核对')
pending.command = command
if (command.state === 'REJECTED') pending.rejected = true
if (command.state !== 'SUCCEEDED') throw new Error(command.state === 'REJECTED' ? '原请求已明确拒绝,请核对原因后重新登记' : '原请求结果尚未确定,请保留原请求继续核对')
if (!command.result) throw new Error('原运营命令的成功结果不完整,请继续核对')
acceptOutsourcedBusinessResult(pending, command.result)
if (pending.action !== 'UPLOAD_PHOTO' && (command.recordVersion == null || command.recordVersion !== pending.confirmed!.recordVersion)) throw new Error('原运营命令版本与结果不一致')
}
export async function recoverOutsourcedBusinessCommand(pending: OutsourcedBusinessPending, persist: () => void, ensureScope: () => void, client = api): Promise<{ detail?: OutsourcedBusinessDetail; photo?: OutsourcedBusinessPhoto }> {
ensureScope()
if (pending.rejected) throw new Error('原请求已明确拒绝,请先按明确拒绝结果重新核对')
if (pending.command?.state === 'SUCCEEDED') acceptOutsourcedBusinessCommand(pending, pending.command)
const previous = pending.attempted
if (!pending.confirmed) {
let original: OutsourcedBusinessCommand | null = null
if (previous) {
try { original = await client.getOutsourcedBusinessCommand(pending.dispatchNo, pending.key) }
catch (cause) { if (!(cause instanceof ApiResponseError) || !['404','A0404','HTTP_404'].includes(cause.code)) throw cause }
ensureScope()
}
if (original) { try { acceptOutsourcedBusinessCommand(pending, original) } finally { persist() } }
else {
if (pending.action === 'UPLOAD_PHOTO') throw new Error('图片上传结果未知,禁止重复上传;请继续核对原上传请求')
pending.attempted = true; persist()
try {
const result = pending.action === 'SAVE_OPERATIONS'
? await client.saveOutsourcedBusinessOperations(pending.dispatchNo, pending.body as OutsourcedBusinessSave)
: await client.bindOutsourcedBusinessPhoto(pending.dispatchNo, pending.slot!, pending.body as OutsourcedBusinessBind)
ensureScope(); acceptOutsourcedBusinessResult(pending, result); persist()
} catch (cause) {
ensureScope()
if (!previous && cause instanceof ApiResponseError && ['A0400','A0403','A0404','A0409','400','403','404','409','422'].includes(cause.code)) { pending.rejected = true; persist() }
throw cause
}
}
}
const confirmed = pending.confirmed!
if (pending.action === 'UPLOAD_PHOTO') {
const photo = confirmed.result as OutsourcedBusinessPhoto | null
if (!photo || photo.slot !== pending.slot || photo.category !== 'LOADING' || typeof photo.fileId !== 'string' || !/^[1-9]\d*$/.test(photo.fileId) || !['image/jpeg','image/png'].includes(photo.contentType)) throw new Error('原上传尚未返回本分类的真实文件')
return { photo }
}
const current = await client.getOutsourcedBusiness(pending.dispatchNo); ensureScope()
if (current.row.dispatchNo !== pending.dispatchNo || confirmed.recordVersion == null || BigInt(outsourcedBusinessVersion(current.row.recordVersion)) < BigInt(outsourcedBusinessVersion(confirmed.recordVersion))) throw new Error('当前运营版本早于原操作结果,请继续核对')
if (pending.action === 'BIND_PHOTO') {
const matches = (photo: OutsourcedBusinessPhoto) => photo.slot === pending.slot && photo.fileId === (pending.body as OutsourcedBusinessBind).fileId && Boolean(photo.savedAt)
const original = confirmed.result
const originalPhotos = original && 'row' in original ? original.photos : original && 'fileId' in original ? [original] : []
if (!current.photos.some(matches) && !originalPhotos.some(matches)) throw new Error('图片绑定结果尚未确认,请保留原文件和请求')
}
return { detail: current }
}
@@ -1,36 +0,0 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import AdminPage from '@/shared/components/AdminPage.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
defineProps<{ dispatchNo?: string; statusText?: string; loading?: boolean }>()
const activeTab = defineModel<'task' | 'route' | 'history'>('activeTab', { default: 'task' })
const emit = defineEmits<{ back: [] }>()
</script>
<template>
<AdminPage class="admin-density" spacing="compact">
<DetailPageLayout aria-label="外配业务详情">
<template #header>
<div class="outsourced-business-detail-heading">
<div><strong>外配业务</strong><span class="outsourced-business-reference">{{ dispatchNo || '—' }}</span><span v-if="statusText">{{ statusText }}</span></div>
<div class="density-actions"><slot name="actions" /><el-button class="density-action" @click="emit('back')">返回外配业务</el-button></div>
</div>
</template>
<slot name="state" />
<div v-loading="loading" :aria-busy="loading">
<el-tabs v-model="activeTab" class="density-element-tabs density-contained-tabs">
<el-tab-pane name="task" label="任务与车辆信息"><template #label><span class="density-tab-icon" aria-hidden="true">🚚</span>任务与车辆信息</template><slot v-if="activeTab === 'task'" name="task" /></el-tab-pane>
<el-tab-pane name="route" label="路线货物与凭证"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span>路线货物与凭证</template><slot v-if="activeTab === 'route'" name="route" /></el-tab-pane>
<el-tab-pane name="history" label="操作记录"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span>操作记录</template><slot v-if="activeTab === 'history'" name="history" /></el-tab-pane>
</el-tabs>
</div>
</DetailPageLayout>
</AdminPage>
</template>
<style scoped>
.outsourced-business-detail-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-4); }
.outsourced-business-detail-heading > div { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: var(--admin-space-4); }
.outsourced-business-reference { overflow-wrap: anywhere; }
</style>
@@ -1,20 +0,0 @@
import type { OutsourcedBusinessCommand, OutsourcedBusinessDetail } from '../outsourcedBusinessTypes'
export function outsourcedBusinessFixture(): OutsourcedBusinessDetail {
const review = { status: 'APPROVED' as const, reason: null, reviewerId: null, reviewedAt: '2026-10-20T10:00:00Z', reviewRound: '1', basisHash: null, invalidatedReason: null }
return {
row: {
dispatchNo: 'DS-OUT-1', stage: 'AFTER_LOADING', plannedDepartureAt: '2026-10-20T10:00:00Z', loadingCompletedAt: '2026-10-20T09:00:00Z', departedAt: null, deliveryCompletedAt: null,
origin: { warehouseId: '1', warehouseCode: 'WH-1', warehouseName: '起运仓' }, vehicle: { vehicleId: '2', plate: 'OUT-1', ownership: 'DISPATCHED', carrierName: '测试外配承运商' }, driver: { name: null, phone: null },
destinations: [{ destinationKey: 'destination-1', warehouseCode: 'FBA-1', warehouseName: '目的仓', address: null, appointments: [{ appointmentNo: 'AP-1', confirmationNo: null, scheduledAt: null, allocatedPackages: '2' }], actualArrivedAt: null }],
actualPalletCount: 1, operations: { money: null, arrivals: [{ destinationKey: 'destination-1', actualArrivedAt: null }] }, unitPrice: null, recordVersion: '0', dispatchRecordVersion: '8',
photoSummary: { loadingCount: 0 }, actions: { edit: { allowed: true, blockReason: null }, upload: { allowed: true, blockReason: null } }, sourceIssues: [],
},
dispatch: { dispatchNo: 'DS-OUT-1', stage: 'AFTER_LOADING', status: 'ACTIVE', recordVersion: '8', reviewState: 'APPROVED', reviews: { dispatchReview: { ...review }, loadingReview: { ...review } }, counts: { itemCount: 2, palletCount: 1, packageCount: 2, pickedPackageCount: 2, shippedPackageCount: 0 }, pickingReconciled: true, actions: {}, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: '2026-10-20T10:00:00Z', departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [] }, documents: { dispatchNo: 'DS-OUT-1', outbounds: [], deliveries: [], createdAt: '2026-10-20T08:00:00Z', updatedAt: '2026-10-20T09:00:00Z', remark: null }, orderDetails: [] },
cargo: ['A','B'].map(customer => ({ sourceKey: `source-${customer}`, customerId: customer, customerName: `客户${customer}`, orderNo: `ORDER-${customer}`, inboundCode: `IN-${customer}`, palletNo: 'ONE-PHYSICAL-PALLET', destinationKey: 'destination-1', packages: '1' })), photos: [],
}
}
export function outsourcedBusinessCommandFixture(overrides: Partial<OutsourcedBusinessCommand> = {}): OutsourcedBusinessCommand {
const detail = outsourcedBusinessFixture(); detail.row.recordVersion = overrides.recordVersion ?? '1'
return { dispatchNo: 'DS-OUT-1', action: 'SAVE_OPERATIONS', idempotencyKey: 'original-key', state: 'SUCCEEDED', recordVersion: '1', replayed: false, result: detail, errorCode: null, retryAllowed: false, ...overrides }
}
@@ -1,48 +0,0 @@
import { defineComponent } from 'vue'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import OutsourcedBusinessDetailLayout from './OutsourcedBusinessDetailLayout.vue'
import OutsourcedBusinessPhotosDialog from './OutsourcedBusinessPhotosDialog.vue'
import OutsourcedBusinessOperationsDialog from './OutsourcedBusinessOperationsDialog.vue'
const Surface = defineComponent({ template: '<section><slot name="header" /><slot /></section>' })
const Tabs = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], template: '<div><slot /></div>' })
const Pane = defineComponent({ props: ['name', 'label'], template: '<article><slot /></article>' })
const Dialog = defineComponent({ props: ['width', 'showConfirm', 'beforeClose'], template: '<div><slot /></div>' })
const global = { stubs: { AdminPage: Surface, DetailPageLayout: Surface, AppDialog: Dialog, ElTabs: Tabs, ElTabPane: Pane, ElButton: Surface }, directives: { loading: () => {} } }
describe('OutsourcedBusiness presentation boundaries', () => {
it('mounts only the selected one of the three independent detail regions', async () => {
const wrapper = mount(OutsourcedBusinessDetailLayout, { global, props: { dispatchNo: 'DS-OUT-1' }, slots: { task: '<p>task-facts</p>', route: '<p>route-facts</p>', history: '<p>history-facts</p>' } })
expect(wrapper.findAllComponents(Pane).map(pane => pane.props('label'))).toEqual(['任务与车辆信息', '路线货物与凭证', '操作记录'])
expect(wrapper.text()).toContain('task-facts'); expect(wrapper.text()).not.toContain('history-facts')
wrapper.findComponent(Tabs).vm.$emit('update:modelValue', 'history'); await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('history-facts'); expect(wrapper.text()).not.toContain('task-facts')
})
it('preserves only five approved photo roles without demo images or uploaded claims', () => {
const wrapper = mount(OutsourcedBusinessPhotosDialog, { global, props: { modelValue: true } })
expect(wrapper.findComponent(Dialog).props('width')).toBe('760px')
expect(wrapper.findComponent(Dialog).props('showConfirm')).toBe(false)
expect(wrapper.findAll('section[aria-label]').map(section => section.attributes('aria-label'))).toEqual(['装车现场', '装车清单', '出车前检查', '驾驶证附件', '签收凭证'])
expect(wrapper.findAll('img')).toHaveLength(0); expect(wrapper.text()).not.toContain('已上传')
})
it('blocks a close while busy and delegates unsaved-result decisions to the owner', async () => {
const guard = vi.fn(), done = vi.fn()
const wrapper = mount(OutsourcedBusinessPhotosDialog, { global, props: { modelValue: true, busy: true, beforeClose: guard } })
const close = wrapper.findComponent(Dialog).props('beforeClose') as (done: () => void) => void
close(done); expect(guard).not.toHaveBeenCalled(); expect(done).not.toHaveBeenCalled()
await wrapper.setProps({ busy: false }); close(done)
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
})
it('keeps an unknown operations request until its owner explicitly handles closing', async () => {
const done = vi.fn(), guard = vi.fn()
const wrapper = mount(OutsourcedBusinessOperationsDialog, { global, props: { modelValue: true, pending: true } })
const close = wrapper.findComponent(Dialog).props('beforeClose') as (done: () => void) => void
close(done); expect(done).not.toHaveBeenCalled()
await wrapper.setProps({ beforeClose: guard }); close(done)
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
})
})
@@ -1,16 +0,0 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import AdminPage from '@/shared/components/AdminPage.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
</script>
<template>
<AdminPage class="admin-density" spacing="compact">
<slot name="statistics" />
<ListPageLayout aria-label="外配业务任务列表">
<template v-if="$slots.filters" #toolbar><slot name="filters" /></template>
<template v-if="$slots.state" #state><slot name="state" /></template>
<slot />
</ListPageLayout>
</AdminPage>
</template>
@@ -1,34 +0,0 @@
<script setup lang="ts">
import AppDialog from '@/shared/components/AppDialog.vue'
const props = defineProps<{
dispatchNo?: string
busy?: boolean
saveDisabled?: boolean
pending?: boolean
beforeClose?: (done: () => void) => void | Promise<void>
}>()
const open = defineModel<boolean>({ default: false })
const emit = defineEmits<{ save: [] }>()
function close(done: () => void) {
if (props.busy) return
if (props.beforeClose) return props.beforeClose(done)
if (!props.pending) done()
}
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-density" title="登记运营信息" width="1080px" :confirm-loading="busy" :confirm-disabled="saveDisabled" :confirm-text="pending ? '核对并重试原请求' : '保存登记'" :before-close="close" :close-on-press-escape="!busy" @confirm="emit('save')">
<p class="outsourced-business-operation-reference">排车单号 {{ dispatchNo || '—' }}</p>
<slot name="state" />
<fieldset class="outsourced-business-operation-section"><legend>整车承运金额</legend><slot name="amount" /><p>整车只登记一次;单价采用系统返回的承运金额与实际物理托数计算结果。</p></fieldset>
<fieldset class="outsourced-business-operation-section"><legend>逐目的地实际到达时间</legend><slot name="arrivals" /></fieldset>
<p>登记运营信息不推进排车或派送状态;照片在独立上传照片窗口中处理。</p>
</AppDialog>
</template>
<style scoped>
.outsourced-business-operation-reference { overflow-wrap: anywhere; }
.outsourced-business-operation-section { min-width: 0; margin: 0 0 var(--admin-space-4); padding: var(--admin-space-4); border: 1px solid var(--admin-table-border); }
.outsourced-business-operation-section legend { padding: 0 var(--admin-space-2); font-weight: 600; }
</style>
@@ -1,59 +0,0 @@
import { defineComponent, h } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import OutsourcedBusinessOperationsEditor from './OutsourcedBusinessOperationsEditor.vue'
import { outsourcedBusinessFixture } from './OutsourcedBusinessFixtures'
const calls = vi.hoisted(() => ({ save: vi.fn(), detail: vi.fn(), command: vi.fn(), canEdit: true }))
vi.mock('../api/outsourcedBusiness', () => ({ saveOutsourcedBusinessOperations: calls.save, getOutsourcedBusiness: calls.detail, getOutsourcedBusinessCommand: calls.command }))
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: { id: 'actor' } }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => calls.canEdit }) }))
vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
const Frame = defineComponent({ props: ['modelValue','busy','saveDisabled','pending'], emits: ['save','update:modelValue'], setup(props,{ slots,emit }) { return () => h('div', [...Object.values(slots).map(slot => slot?.()), h('button',{ 'data-save':'',disabled: props.busy || props.saveDisabled,onClick: () => emit('save') },'save')]) } })
const Control = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' })
const Field = defineComponent({ props: ['label'], template: '<label>{{label}}<slot /></label>' })
const global = { stubs: { OutsourcedBusinessOperationsDialog: Frame, ElForm: { template:'<div><slot /></div>' }, ElFormItem: Field, ElInput: Control, ElSelect: Control, ElOption: true, ElDatePicker: Control, LoadErrorState: { props:['message'],template:'<div>{{message}}</div>' }, ApiErrorMeta: true } }
beforeEach(() => {
vi.clearAllMocks(); sessionStorage.clear(); calls.canEdit = true
const current = outsourcedBusinessFixture(); current.row.recordVersion = '1'
calls.save.mockResolvedValue(current); calls.detail.mockResolvedValue(current)
calls.command.mockRejectedValue(new ApiResponseError('not found','A0404',404))
})
describe('outsourced operations registration', () => {
it('sends explicit zero and destination arrival with auxiliary zero, without fuel or mileage fields', async () => {
const wrapper = mount(OutsourcedBusinessOperationsEditor,{ global,props:{modelValue:true,detail:outsourcedBusinessFixture()} }); await flushPromises()
const currency = wrapper.findAll('label').find(field => field.text() === '币种')!.find('input')
const amount = wrapper.findAll('label').find(field => field.text() === '整车承运金额')!.find('input')
await currency.setValue('USD'); await amount.setValue('0.00')
await wrapper.findAll('input').at(-1)!.setValue('2026-10-20T12:00:00+08:00')
await wrapper.find('[data-save]').trigger('click'); await flushPromises()
const body = calls.save.mock.calls[0]![1]
expect(body).toEqual({ recordVersion:'0',idempotencyKey:expect.any(String),operations:{money:{currency:'USD',amount:'0.00'},arrivals:[{destinationKey:'destination-1',actualArrivedAt:'2026-10-20T12:00:00+08:00'}]},reason:null })
expect(calls.save).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toHaveLength(1)
expect(wrapper.text()).not.toContain('油耗'); expect(wrapper.text()).not.toContain('里程'); wrapper.unmount()
})
it('clears money as null and retains exactly the original request after an unknown save', async () => {
const detail = outsourcedBusinessFixture(); detail.row.operations.money = {currency:'CNY',amount:'123.45'}
calls.save.mockRejectedValueOnce(new TypeError('network interrupted'))
const wrapper = mount(OutsourcedBusinessOperationsEditor,{global,props:{modelValue:true,detail}}); await flushPromises()
await wrapper.findAll('label').find(field => field.text() === '币种')!.find('input').setValue('')
await wrapper.findAll('label').find(field => field.text() === '整车承运金额')!.find('input').setValue('')
await wrapper.find('[data-save]').trigger('click'); await flushPromises()
const original = JSON.parse(JSON.stringify(calls.save.mock.calls[0]![1]))
expect(original.operations.money).toBeNull(); expect(wrapper.emitted('saved')).toBeUndefined()
await wrapper.find('[data-save]').trigger('click'); await flushPromises()
expect(calls.command).toHaveBeenCalledExactlyOnceWith('DS-OUT-1',original.idempotencyKey)
expect(calls.save).toHaveBeenNthCalledWith(2,'DS-OUT-1',original); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('does not write for an unchanged form or a user without edit permission', async () => {
const first = mount(OutsourcedBusinessOperationsEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}}); await flushPromises()
await first.find('[data-save]').trigger('click'); await flushPromises(); expect(calls.save).not.toHaveBeenCalled(); first.unmount()
calls.canEdit = false
const readonly = mount(OutsourcedBusinessOperationsEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}}); await flushPromises()
expect(readonly.find('[data-save]').attributes('disabled')).toBeDefined(); expect(calls.save).not.toHaveBeenCalled(); readonly.unmount()
})
})
@@ -1,91 +0,0 @@
<script setup lang="ts">
import { computed, getCurrentInstance, nextTick, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OutsourcedBusinessOperationsDialog from './OutsourcedBusinessOperationsDialog.vue'
import { newOutsourcedBusinessPending, persistOutsourcedBusinessPending, restoreOutsourcedBusinessPending, recoverOutsourcedBusinessCommand, type OutsourcedBusinessPending } from './OutsourcedBusinessCommandRecovery'
import { outsourcedBusinessDecimal, outsourcedBusinessVersion, type OutsourcedBusinessDetail, type OutsourcedBusinessMoney, type OutsourcedBusinessSave } from '../outsourcedBusinessTypes'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { getOutsourcedBusiness } from '../api/outsourcedBusiness'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ detail: OutsourcedBusinessDetail | null }>()
const emit = defineEmits<{ saved: [value: OutsourcedBusinessDetail]; busy: [value: boolean] }>()
const auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), pending = ref<OutsourcedBusinessPending | null>(null), error = ref<ApiErrorDetails | null>(null), corrupted = ref(false)
const form = reactive({ currency: '', amount: '', reason: '', arrivals: {} as Record<string,string> })
const active = computed(() => open.value || Boolean(pending.value))
const registration = useUnsavedWorkRegistration({ key: `outsourced-business-operations:${getCurrentInstance()?.uid}`, label: computed(() => '外配业务运营登记'), isOpen: active, model: reactive({ form, pending }), requiredPermission: computed(() => permission.outsourcedBusinessEdit) })
let actor = '', number = '', baseline = ''
const writable = computed(() => !corrupted.value && access.can(permission.outsourcedBusinessEdit) && props.detail?.row.actions.edit.allowed === true)
const locked = computed(() => busy.value || Boolean(pending.value))
function persist() { persistOutsourcedBusinessPending(actor, number, 'operations', pending.value) }
function ensureScope() { if (auth.user?.id !== actor || props.detail?.row.dispatchNo !== number) throw new Error('操作身份或排车单已变化,请重新打开') }
watch(busy, value => emit('busy', value))
watch(open, async value => {
if (!value || !props.detail) return
actor = auth.user?.id || ''; number = props.detail.row.dispatchNo; error.value = null; corrupted.value = false
try {
pending.value = restoreOutsourcedBusinessPending(actor, number, 'operations')
const operations = pending.value?.action === 'SAVE_OPERATIONS' ? (pending.value.body as OutsourcedBusinessSave).operations : props.detail.row.operations
Object.assign(form, { currency: operations.money?.currency || '', amount: operations.money?.amount ?? '', reason: pending.value ? (pending.value.body as OutsourcedBusinessSave).reason || '' : '', arrivals: Object.fromEntries(props.detail.row.destinations.map(destination => [destination.destinationKey, operations.arrivals.find(arrival => arrival.destinationKey === destination.destinationKey)?.actualArrivedAt || ''])) })
baseline = JSON.stringify(form); await nextTick(); if (!pending.value) registration.captureBaseline()
} catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '运营资料恢复失败') }
}, { immediate: true })
function payload(): OutsourcedBusinessSave {
const amount = outsourcedBusinessDecimal(form.amount, 2, 18)
let money: OutsourcedBusinessMoney | null = null
if (amount != null) { if (!['CNY','USD'].includes(form.currency)) throw new Error('请选择金额币种'); money = { currency: form.currency as OutsourcedBusinessMoney['currency'], amount } }
else if (form.currency) throw new Error('金额和币种请成对填写,清空时同时清空两项')
const arrivals = props.detail!.row.destinations.map(destination => {
const value = form.arrivals[destination.destinationKey] || null
if (value && (!/(?:Z|[+-]\d{2}:\d{2})$/.test(value) || !Number.isFinite(Date.parse(value)))) throw new Error('实际到达时间必须包含有效时区')
return { destinationKey: destination.destinationKey, actualArrivedAt: value }
})
return { recordVersion: outsourcedBusinessVersion(props.detail!.row.recordVersion), idempotencyKey: crypto.randomUUID(), operations: { money, arrivals }, reason: form.reason.trim() || null }
}
async function save() {
if (busy.value || !writable.value || !props.detail) return
busy.value = true; error.value = null
try {
ensureScope()
if (!pending.value) { if (JSON.stringify(form) === baseline) { open.value = false; return }; pending.value = newOutsourcedBusinessPending(actor, number, 'SAVE_OPERATIONS', payload()); persist() }
const result = await recoverOutsourcedBusinessCommand(pending.value, persist, ensureScope)
if (!result.detail) throw new Error('运营登记结果未确认')
pending.value = null; persist(); registration.captureBaseline(); emit('saved', result.detail); open.value = false; ElMessage.success('运营信息已保存')
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '运营登记结果尚未确认') }
finally { busy.value = false }
}
async function beforeClose(done: () => void) {
if (busy.value) return
if (pending.value || JSON.stringify(form) !== baseline) {
try { await ElMessageBox.confirm(pending.value ? '原请求将保留,重新打开后可核对原结果。是否关闭?' : '放弃尚未保存的运营信息?', '关闭运营登记', { customClass: 'admin-density-confirm', confirmButtonText: pending.value ? '保留请求并关闭' : '放弃修改', cancelButtonText: '继续编辑' }) } catch { return }
}
done()
}
async function discardRejected() {
if (!pending.value?.rejected || busy.value) return
busy.value = true
try { const current = await getOutsourcedBusiness(number); ensureScope(); pending.value = null; persist(); emit('saved', current); error.value = null }
catch (cause) { error.value = getApiErrorDetails(cause, '当前运营版本核对失败') }
finally { busy.value = false }
}
</script>
<template>
<OutsourcedBusinessOperationsDialog v-model="open" :dispatch-no="detail?.row.dispatchNo" :busy="busy" :pending="Boolean(pending)" :save-disabled="!writable" :before-close="beforeClose" @save="save">
<template #state><LoadErrorState v-if="error" title="运营登记未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="pending">原请求已保留,字段锁定;核对结果前不会建立新请求。</p><el-button v-if="pending?.rejected" :disabled="busy" @click="discardRejected">按明确拒绝结果重新编辑</el-button><p v-if="!writable">{{ detail?.row.actions.edit.blockReason || '没有运营登记权限' }}</p></template>
<template #amount><el-form label-position="top" class="outsourced-business-form"><el-form-item label="币种"><el-select v-model="form.currency" clearable :disabled="locked"><el-option label="CNY" value="CNY" /><el-option label="USD" value="USD" /></el-select></el-form-item><el-form-item label="整车承运金额"><el-input v-model="form.amount" :disabled="locked" placeholder="未登记;允许明确填写0" /></el-form-item><el-form-item label="更正说明"><el-input v-model="form.reason" :disabled="locked" maxlength="1000" /></el-form-item></el-form></template>
<template #arrivals><div v-for="destination in detail?.row.destinations || []" :key="destination.destinationKey" class="outsourced-business-arrival"><span>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}<small v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ appointment.appointmentNo }}</small></span><el-date-picker v-model="form.arrivals[destination.destinationKey]" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" :disabled="locked" placeholder="实际到达时间,未登记留空" /></div><p>时间按浏览器本地时区输入,提交时保留时区。</p></template>
</OutsourcedBusinessOperationsDialog>
</template>
<style scoped>
.outsourced-business-form { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 0 var(--admin-space-4); }
.outsourced-business-arrival { display: grid; grid-template-columns: minmax(0,1fr) 280px; align-items: center; gap: var(--admin-space-4); margin-bottom: var(--admin-space-3); }
.outsourced-business-arrival small { display: block; }
</style>
@@ -1,36 +0,0 @@
<script setup lang="ts">
import AppDialog from '@/shared/components/AppDialog.vue'
import { outsourcedBusinessPhotoRoles } from '../outsourcedBusinessTypes'
const props = defineProps<{
dispatchNo?: string
busy?: boolean
beforeClose?: (done: () => void) => void | Promise<void>
}>()
const open = defineModel<boolean>({ default: false })
function close(done: () => void) {
if (props.busy) return
if (props.beforeClose) return props.beforeClose(done)
done()
}
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-density" title="上传照片" width="760px" :show-confirm="false" cancel-text="关闭" :confirm-loading="busy" :close-on-press-escape="!busy" :before-close="close">
<p class="outsourced-business-photo-reference">排车单号 {{ dispatchNo || '—' }}</p>
<slot name="state" />
<p>每个附件方框单独处理,支持 JPG、PNG;上传照片不改变装车状态。</p>
<div class="outsourced-business-photo-grid">
<section v-for="photo in outsourcedBusinessPhotoRoles" :key="photo.slot" class="outsourced-business-photo-slot" :aria-label="photo.label">
<h3>{{ photo.label }}</h3><slot :name="photo.viewSlot"><p>暂无照片</p></slot>
</section>
</div>
</AppDialog>
</template>
<style scoped>
.outsourced-business-photo-reference { overflow-wrap: anywhere; }
.outsourced-business-photo-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-4); }
.outsourced-business-photo-slot { min-width: 0; padding: var(--admin-space-4); border: 1px solid var(--admin-table-border); }
.outsourced-business-photo-slot h3 { margin: 0 0 var(--admin-space-3); font-size: inherit; }
</style>
@@ -1,64 +0,0 @@
import { defineComponent, h } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import OutsourcedBusinessPhotosEditor from './OutsourcedBusinessPhotosEditor.vue'
import { outsourcedBusinessFixture } from './OutsourcedBusinessFixtures'
import type { OutsourcedBusinessPhoto } from '../outsourcedBusinessTypes'
const calls=vi.hoisted(()=>({upload:vi.fn(),bind:vi.fn(),command:vi.fn(),detail:vi.fn(),content:vi.fn(),actor:{user:{id:'actor'}},canUpload:true}))
vi.mock('../api/outsourcedBusiness',()=>({uploadOutsourcedBusinessPhoto:calls.upload,bindOutsourcedBusinessPhoto:calls.bind,getOutsourcedBusinessCommand:calls.command,getOutsourcedBusiness:calls.detail,getOutsourcedBusinessPhotoContent:calls.content}))
vi.mock('@/core/auth/auth',()=>({useAuthStore:()=>calls.actor}))
vi.mock('@/core/access/accessPreview',()=>({useAccessPreviewStore:()=>({can:()=>calls.canUpload})}))
vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration',()=>({useUnsavedWorkRegistration:()=>({captureBaseline:vi.fn()})}))
vi.mock('element-plus',()=>({ElMessage:{success:vi.fn()},ElMessageBox:{confirm:vi.fn()}}))
const Frame=defineComponent({props:['modelValue','busy','beforeClose'],setup(_props,{slots}){return()=>h('div',Object.values(slots).map(slot=>slot?.()))}})
const Button=defineComponent({props:['disabled'],template:'<button :disabled="disabled"><slot /></button>'})
const global={stubs:{OutsourcedBusinessPhotosDialog:Frame,ElButton:Button,LoadErrorState:{props:['message'],template:'<div>{{message}}<slot name="details" /></div>'},ApiErrorMeta:true}}
const photo:OutsourcedBusinessPhoto={slot:'LOADING_SCENE',category:'LOADING',fileId:'9007199254740993',fileName:'loading.png',contentType:'image/png',sizeBytes:'5',uploadedAt:'2026-10-20T10:00:00Z',savedAt:null}
async function choose(wrapper:ReturnType<typeof mount>){const input=wrapper.find('input[type="file"]');Object.defineProperty(input.element,'files',{configurable:true,value:[new File(['image'],'loading.png',{type:'image/png'})]});await input.trigger('change');await flushPromises()}
beforeEach(()=>{
vi.clearAllMocks();sessionStorage.clear();calls.actor.user.id='actor';calls.canUpload=true
calls.upload.mockResolvedValue(photo);calls.command.mockRejectedValue(new ApiResponseError('not found','A0404',404))
const current=outsourcedBusinessFixture();current.row.recordVersion='1';current.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}]
calls.bind.mockResolvedValue(current);calls.detail.mockResolvedValue(current)
})
afterEach(() => vi.unstubAllGlobals())
describe('outsourced business per-slot photo save',()=>{
it('retains prepared file and original binding request through a failed bind without retransmitting the image',async()=>{
calls.bind.mockRejectedValueOnce(new TypeError('network interrupted'))
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}});await choose(wrapper)
expect(calls.upload).toHaveBeenCalledTimes(1);expect(calls.bind).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('尚未确认绑定保存');expect(wrapper.emitted('saved')).toBeUndefined()
const original={...calls.bind.mock.calls[0]![2]}
await wrapper.findAll('button').find(button=>button.text()==='核对原结果并继续绑定')!.trigger('click');await flushPromises()
expect(calls.upload).toHaveBeenCalledTimes(1);expect(calls.bind).toHaveBeenCalledTimes(2)
expect(calls.bind.mock.calls[1]![2]).toEqual(original);expect(original.fileId).toBe(photo.fileId);expect(original.recordVersion).toBe('0')
expect(wrapper.emitted('saved')).toHaveLength(1);wrapper.unmount()
})
it('does not offer a new upload after unknown upload or fabricate a saved photo',async()=>{
calls.upload.mockRejectedValue(new TypeError('network interrupted'))
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}});await choose(wrapper)
await wrapper.findAll('button').find(button=>button.text()==='核对原结果并继续绑定')!.trigger('click');await flushPromises()
expect(calls.upload).toHaveBeenCalledTimes(1);expect(calls.bind).not.toHaveBeenCalled();expect(wrapper.emitted('saved')).toBeUndefined()
expect(wrapper.find('input[type="file"]').attributes('disabled')).toBeDefined();expect(wrapper.text()).toContain('禁止重复上传');wrapper.unmount()
})
it('keeps saved metadata readable without upload permission and fetches image bytes only on explicit preview',async()=>{
calls.canUpload=false
const detail=outsourcedBusinessFixture();detail.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}]
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail}});await flushPromises()
expect(wrapper.find('input[type="file"]').exists()).toBe(false);expect(wrapper.text()).toContain('已保存');expect(calls.content).not.toHaveBeenCalled()
wrapper.unmount()
})
it('reads a saved loading photo only on click and releases its URL on closing',async()=>{
const create=vi.fn(()=> 'blob:outsourced-loading'),revoke=vi.fn()
vi.stubGlobal('URL',class extends URL { static createObjectURL=create; static revokeObjectURL=revoke })
calls.canUpload=false;calls.content.mockResolvedValue(new Blob(['image'],{type:'image/png'}))
const detail=outsourcedBusinessFixture();detail.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}]
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail}});await flushPromises()
expect(calls.content).not.toHaveBeenCalled()
await wrapper.findAll('button').find(button=>button.text()==='查看图片')!.trigger('click');await flushPromises()
expect(calls.content).toHaveBeenCalledExactlyOnceWith('DS-OUT-1',detail.photos[0]);expect(wrapper.find('img').attributes('src')).toBe('blob:outsourced-loading')
await wrapper.setProps({modelValue:false});expect(revoke).toHaveBeenCalledWith('blob:outsourced-loading');wrapper.unmount()
})
})
@@ -1,127 +0,0 @@
<script setup lang="ts">
import { computed, getCurrentInstance, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OutsourcedBusinessPhotosDialog from './OutsourcedBusinessPhotosDialog.vue'
import { outsourcedBusinessPhotoRoles, outsourcedBusinessVersion, type OutsourcedBusinessDetail, type OutsourcedBusinessPhoto, type OutsourcedBusinessPhotoSlot } from '../outsourcedBusinessTypes'
import { getOutsourcedBusiness, getOutsourcedBusinessPhotoContent, uploadOutsourcedBusinessPhoto } from '../api/outsourcedBusiness'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { acceptOutsourcedBusinessResult, newOutsourcedBusinessPending, outsourcedBusinessPendingKey, persistOutsourcedBusinessPending, restoreOutsourcedBusinessPending, recoverOutsourcedBusinessCommand, type OutsourcedBusinessPending } from './OutsourcedBusinessCommandRecovery'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ detail: OutsourcedBusinessDetail | null }>()
const emit = defineEmits<{ saved: [value: OutsourcedBusinessDetail]; busy: [value: boolean] }>()
const auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null), pending = ref<OutsourcedBusinessPending | null>(null), prepared = ref<OutsourcedBusinessPhoto | null>(null), corrupted = ref(false)
const urls = ref<Partial<Record<OutsourcedBusinessPhotoSlot,string>>>({}), reading = ref<OutsourcedBusinessPhotoSlot | null>(null)
const uploadable = computed(() => !corrupted.value && access.can(permission.outsourcedBusinessUpload) && props.detail?.row.actions.upload.allowed === true)
useUnsavedWorkRegistration({ key: `outsourced-business-photos:${getCurrentInstance()?.uid}`, label: computed(() => '外配业务照片作业'), isOpen: computed(() => open.value || Boolean(pending.value) || Boolean(prepared.value)), model: reactive({ pending, prepared }), requiredPermission: computed(() => permission.outsourcedBusinessUpload) })
let actor = '', number = '', generation = 0
function savedPhoto(slot: OutsourcedBusinessPhotoSlot) { return props.detail?.photos.find(photo => photo.slot === slot && photo.savedAt) }
function release() { Object.values(urls.value).forEach(url => { if (url) URL.revokeObjectURL(url) }); urls.value = {} }
function ensureScope() { if (auth.user?.id !== actor || props.detail?.row.dispatchNo !== number) throw new Error('图片作业身份或排车单已变化,请重新打开') }
function persist() { persistOutsourcedBusinessPending(actor, number, 'photos', pending.value) }
function persistPrepared() {
const key = outsourcedBusinessPendingKey(actor, number, 'prepared-photo')
if (prepared.value) sessionStorage.setItem(key, JSON.stringify(prepared.value)); else sessionStorage.removeItem(key)
}
watch(busy, value => emit('busy', value))
watch(open, value => {
generation++; release(); reading.value = null; error.value = null; corrupted.value = false
if (!value || !props.detail) return
actor = auth.user?.id || ''; number = props.detail.row.dispatchNo
try {
pending.value = restoreOutsourcedBusinessPending(actor, number, 'photos')
const raw = sessionStorage.getItem(outsourcedBusinessPendingKey(actor, number, 'prepared-photo'))
prepared.value = raw ? JSON.parse(raw) as OutsourcedBusinessPhoto : null
if (prepared.value && (!prepared.value.fileId || !outsourcedBusinessPhotoRoles.some(role => role.slot === prepared.value!.slot))) throw new Error('原上传文件资料损坏,请保留现场核对')
} catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '照片作业恢复失败') }
}, { immediate: true })
watch(() => props.detail?.photos, () => { generation++; release(); reading.value = null })
onBeforeUnmount(() => { generation++; release() })
async function preview(slot: OutsourcedBusinessPhotoSlot) {
const photo = savedPhoto(slot), current = generation
if (!photo || reading.value) return
reading.value = slot; error.value = null
try { const blob = await getOutsourcedBusinessPhotoContent(number, photo); if (current !== generation || !open.value) return; urls.value[slot] = URL.createObjectURL(blob) }
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '图片读取失败') }
finally { if (current === generation) reading.value = null }
}
async function bindPrepared() {
if ((!prepared.value && !pending.value) || !props.detail || !uploadable.value) return
if (!pending.value) {
const body = { recordVersion: outsourcedBusinessVersion(props.detail.row.recordVersion), idempotencyKey: crypto.randomUUID(), fileId: prepared.value!.fileId }
pending.value = newOutsourcedBusinessPending(actor, number, 'BIND_PHOTO', body, prepared.value!.slot); persist()
}
const result = await recoverOutsourcedBusinessCommand(pending.value, persist, ensureScope)
if (!result.detail) throw new Error('照片绑定尚未确认')
pending.value = null; persist(); prepared.value = null; persistPrepared(); emit('saved', result.detail); ElMessage.success('照片已绑定保存')
}
async function recover() {
if (busy.value || !uploadable.value) return
busy.value = true; error.value = null
try {
ensureScope()
if (pending.value?.action === 'UPLOAD_PHOTO') {
const result = await recoverOutsourcedBusinessCommand(pending.value, persist, ensureScope)
if (!result.photo) throw new Error('原上传文件未确认')
prepared.value = result.photo; persistPrepared(); pending.value = null; persist()
}
await bindPrepared()
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '照片结果尚未确认') }
finally { busy.value = false }
}
async function upload(slot: OutsourcedBusinessPhotoSlot, event: Event) {
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
if (!file || busy.value || pending.value || prepared.value || !uploadable.value) return
if (!['image/jpeg','image/png'].includes(file.type) || file.size <= 0) { error.value = { message: '请选择有效的JPG或PNG图片' }; return }
busy.value = true; error.value = null
try {
ensureScope(); pending.value = newOutsourcedBusinessPending(actor, number, 'UPLOAD_PHOTO', null, slot); pending.value.attempted = true; persist()
const uploaded = await uploadOutsourcedBusinessPhoto(number, slot, file, pending.value.key)
ensureScope(); acceptOutsourcedBusinessResult(pending.value, uploaded); persist()
const result = await recoverOutsourcedBusinessCommand(pending.value, persist, ensureScope)
if (!result.photo) throw new Error('真实上传文件未确认')
prepared.value = result.photo; persistPrepared(); pending.value = null; persist(); await bindPrepared()
} catch (cause) {
if (pending.value?.action === 'UPLOAD_PHOTO' && cause instanceof ApiResponseError && ['A0400','A0403','A0404','400','403','404','422'].includes(cause.code)) { pending.value.rejected = true; persist() }
error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '图片上传或绑定结果尚未确认')
} finally { busy.value = false }
}
async function discardRejected() {
if (busy.value || !pending.value?.rejected) return
busy.value = true
try { const latest = await getOutsourcedBusiness(number); ensureScope(); pending.value = null; persist(); emit('saved', latest); error.value = null }
catch (cause) { error.value = getApiErrorDetails(cause, '当前照片版本读取失败') }
finally { busy.value = false }
}
async function beforeClose(done: () => void) {
if (busy.value) return
if (pending.value || prepared.value) {
try { await ElMessageBox.confirm('未完成的原上传或绑定记录将保留,重新打开后继续核对;关闭不会保存尚未绑定的照片。是否关闭?', '关闭照片窗口', { customClass: 'admin-density-confirm', confirmButtonText: '保留记录并关闭', cancelButtonText: '继续核对' }) } catch { return }
}
done()
}
</script>
<template>
<OutsourcedBusinessPhotosDialog v-model="open" :dispatch-no="detail?.row.dispatchNo" :busy="busy" :before-close="beforeClose">
<template #state><LoadErrorState v-if="error" title="照片作业未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="pending">原{{ pending.action === 'UPLOAD_PHOTO' ? '上传' : '绑定' }}请求已保留,不会重新上传文件。</p><p v-if="prepared">{{ prepared.fileName }} 已上传,尚未确认绑定保存。</p><el-button v-if="pending || prepared" :loading="busy" :disabled="!uploadable" @click="recover">核对原结果并继续绑定</el-button><el-button v-if="pending?.rejected" :disabled="busy" @click="discardRejected">按明确拒绝重新核对,保留已上传文件</el-button><p v-if="!uploadable">{{ detail?.row.actions.upload.blockReason || '当前仅可查看照片' }}</p></template>
<template v-for="role in outsourcedBusinessPhotoRoles" :key="role.slot" #[role.viewSlot]>
<img v-if="urls[role.slot]" :src="urls[role.slot]" :alt="role.label" class="outsourced-business-photo" />
<template v-if="savedPhoto(role.slot)"><p>{{ savedPhoto(role.slot)?.fileName }} · 已保存</p><el-button :loading="reading === role.slot" :disabled="Boolean(reading)" @click="preview(role.slot)">查看图片</el-button></template>
<p v-else>暂无已保存照片</p>
<label v-if="uploadable" class="outsourced-business-upload">{{ savedPhoto(role.slot) ? '替换图片' : '上传图片' }}<input type="file" accept="image/jpeg,image/png" :disabled="busy || Boolean(pending) || Boolean(prepared)" @change="upload(role.slot, $event)" /></label>
</template>
</OutsourcedBusinessPhotosDialog>
</template>
<style scoped>
.outsourced-business-photo { display: block; width: 100%; max-height: 160px; object-fit: contain; }
.outsourced-business-upload { display: block; margin-top: var(--admin-space-3); }
.outsourced-business-upload input { display: block; width: 100%; margin-top: var(--admin-space-2); }
</style>
@@ -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'
@@ -1,21 +0,0 @@
import { describe, expect, it } from 'vitest'
import { outsourcedBusinessDecimal, outsourcedBusinessVersion, outsourcedBusinessText, outsourcedBusinessPhotoRoles } from './outsourcedBusinessTypes'
describe('outsourced business decimal and version boundary', () => {
it('supports exactly the five approved loading photo slots', () => {
expect(outsourcedBusinessPhotoRoles.map(role => role.slot)).toEqual(['LOADING_SCENE','LOADING_LIST','PRE_DEPARTURE_CHECK','DRIVER_LICENSE','RECEIPT'])
expect(outsourcedBusinessPhotoRoles.every(role => role.category === 'LOADING')).toBe(true)
})
it('preserves the maximum decimal money text and distinguishes empty from explicit zero', () => {
expect(outsourcedBusinessDecimal('9999999999999999.99', 2, 18)).toBe('9999999999999999.99')
expect(outsourcedBusinessDecimal('0.00', 2, 18)).toBe('0.00'); expect(outsourcedBusinessDecimal(' ', 2, 18)).toBeNull()
expect(() => outsourcedBusinessDecimal('10000000000000000.00', 2, 18)).toThrow()
for (const value of ['-1','1e3','1.001','01','NaN','Infinity']) expect(() => outsourcedBusinessDecimal(value, 2, 18)).toThrow()
expect(outsourcedBusinessDecimal('123.456', 3, 12)).toBe('123.456')
})
it('keeps version zero and large string identities without accepting unsafe numeric versions', () => {
expect(outsourcedBusinessVersion(0)).toBe('0'); expect(outsourcedBusinessVersion('9007199254740993')).toBe('9007199254740993')
for (const value of [undefined,null,-1,0.5,Number.MAX_SAFE_INTEGER + 1,'','1e3']) expect(() => outsourcedBusinessVersion(value)).toThrow()
expect(outsourcedBusinessText(null)).toBe('—'); expect(outsourcedBusinessText(0)).toBe('0')
})
})
@@ -1,45 +0,0 @@
import type { DispatchDetail } from '@/modules/overseas-warehouse'
export type OutsourcedBusinessStage = 'AFTER_LOADING' | 'DELIVERING' | 'DELIVERED'
export type OutsourcedBusinessPhotoSlot = 'LOADING_SCENE' | 'LOADING_LIST' | 'PRE_DEPARTURE_CHECK' | 'DRIVER_LICENSE' | 'RECEIPT'
export interface OutsourcedBusinessMoney { currency: 'CNY' | 'USD'; amount: string }
export interface OutsourcedBusinessArrival { destinationKey: string; actualArrivedAt: string | null }
export interface OutsourcedBusinessOperations { money: OutsourcedBusinessMoney | null; arrivals: OutsourcedBusinessArrival[] }
export interface OutsourcedBusinessPhoto { slot: OutsourcedBusinessPhotoSlot; category: 'LOADING'; fileId: string; fileName: string; contentType: 'image/jpeg' | 'image/png'; sizeBytes: string; uploadedAt: string; savedAt: string | null }
export interface OutsourcedBusinessDestination { destinationKey: string; warehouseCode: string | null; warehouseName: string | null; address: string | null; appointments: { appointmentNo: string; confirmationNo: string | null; scheduledAt: string | null; allocatedPackages: string }[]; actualArrivedAt: string | null }
export interface OutsourcedBusinessRow {
dispatchNo: string; stage: OutsourcedBusinessStage; plannedDepartureAt: string | null; loadingCompletedAt: string; departedAt: string | null; deliveryCompletedAt: string | null
origin: { warehouseId: string; warehouseCode: string; warehouseName: string }
vehicle: { vehicleId: string | null; plate: string | null; ownership: 'DISPATCHED'; carrierName: string | null }; driver: { name: string | null; phone: string | null }
destinations: OutsourcedBusinessDestination[]; actualPalletCount: number | null; operations: OutsourcedBusinessOperations; unitPrice: string | null; recordVersion: string; dispatchRecordVersion: string
photoSummary: { loadingCount: number }; actions: { edit: OutsourcedBusinessGate; upload: OutsourcedBusinessGate }; sourceIssues: string[]
}
export interface OutsourcedBusinessGate { allowed: boolean; blockReason: string | null }
export interface OutsourcedBusinessQuery { page?: number; size?: number; plannedDepartureFrom?: string; plannedDepartureTo?: string; keyword?: string; stage?: OutsourcedBusinessStage; warehouseId?: string }
export interface OutsourcedBusinessSummary { dispatchCount: number; afterLoadingCount: number; deliveringCount: number; deliveredCount: number }
export interface OutsourcedBusinessPage<T> { records: T[]; page: number; size: number; total: number }
export interface OutsourcedBusinessList extends OutsourcedBusinessPage<OutsourcedBusinessRow> { summary: OutsourcedBusinessSummary }
export interface OutsourcedBusinessDetail { row: OutsourcedBusinessRow; dispatch: DispatchDetail; cargo: { sourceKey: string; customerId: string; customerName: string | null; orderNo: string | null; inboundCode: string | null; palletNo: string; destinationKey: string; packages: string }[]; photos: OutsourcedBusinessPhoto[] }
export interface OutsourcedBusinessSave { recordVersion: string; idempotencyKey: string; operations: OutsourcedBusinessOperations; reason: string | null }
export interface OutsourcedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string }
export type OutsourcedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIND_PHOTO'
export interface OutsourcedBusinessCommand { dispatchNo: string; action: OutsourcedBusinessAction; idempotencyKey: string; state: 'SUCCEEDED' | 'PENDING' | 'UNKNOWN_RESULT' | 'REJECTED'; recordVersion: string | null; replayed: boolean; result: OutsourcedBusinessDetail | OutsourcedBusinessPhoto | null; errorCode: string | null; retryAllowed: boolean }
export interface OutsourcedBusinessHistory { eventSource: 'DISPATCH' | 'OPERATIONS'; recordVersion: string | null; dispatchRecordVersion: string | null; action: string; operatorName: string | null; occurredAt: string; reason: string | null; operations: OutsourcedBusinessOperations | null; photos: OutsourcedBusinessPhoto[] }
export const outsourcedBusinessStages: Record<OutsourcedBusinessStage, string> = { AFTER_LOADING: '装车完成', DELIVERING: '派送中', DELIVERED: '派送完成' }
export const outsourcedBusinessActionLabels: Record<string,string> = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片' }
export const outsourcedBusinessPhotoRoles: { slot: OutsourcedBusinessPhotoSlot; viewSlot: string; label: string; category: 'LOADING' }[] = [
{ slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '装车现场', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '装车清单', category: 'LOADING' },
{ slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '出车前检查', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '驾驶证附件', category: 'LOADING' },
{ slot: 'RECEIPT', viewSlot: 'delivery-receipt', label: '签收凭证', category: 'LOADING' },
]
export function outsourcedBusinessVersion(value: unknown): string {
if ((typeof value !== 'string' && typeof value !== 'number') || !/^(0|[1-9]\d*)$/.test(String(value)) || typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('运营记录版本无效,请重新读取')
return String(value)
}
export function outsourcedBusinessDecimal(value: string, scale: number, precision: number): string | null {
const text = value.trim()
if (!text) return null
if (!new RegExp(`^(?:0|[1-9]\\d*)(?:\\.\\d{1,${scale}})?$`).test(text) || text.split('.')[0]!.length > precision - scale) throw new Error(`请输入不超过${scale}位小数的有效非负数`)
return text
}
export function outsourcedBusinessText(value: unknown): string { return value == null || value === '' ? '—' : String(value) }
@@ -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>
@@ -1,82 +0,0 @@
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import dayjs from 'dayjs'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import OutsourcedBusinessDetailLayout from '../components/OutsourcedBusinessDetailLayout.vue'
import OutsourcedBusinessOperationsEditor from '../components/OutsourcedBusinessOperationsEditor.vue'
import OutsourcedBusinessPhotosEditor from '../components/OutsourcedBusinessPhotosEditor.vue'
import { getOutsourcedBusiness, getOutsourcedBusinessPhotoContent, listOutsourcedBusinessHistory } from '../api/outsourcedBusiness'
import { outsourcedBusinessActionLabels, outsourcedBusinessStages, outsourcedBusinessText as text, outsourcedBusinessPhotoRoles, type OutsourcedBusinessDetail, type OutsourcedBusinessHistory, type OutsourcedBusinessPhoto } from '../outsourcedBusinessTypes'
import { overseasMasterDataPermissions as permission } from '../permissions'
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const dispatchNo = computed(() => typeof route.params.dispatchNo === 'string' ? route.params.dispatchNo : '')
const detail = ref<OutsourcedBusinessDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), activeTab = ref<'task'|'route'|'history'>('task')
const editorOpen = ref(false), photosOpen = ref(false), busy = ref(false)
const history = ref<OutsourcedBusinessHistory[]>([]), historyPage = ref(1), historySize = ref(20), historyTotal = ref(0), historyLoading = ref(false), historyError = ref<ApiErrorDetails | null>(null), previewError = ref<ApiErrorDetails | null>(null)
let generation = 0, historyGeneration = 0, active = true, initialActivation = true
const previewUrls = new Set<string>()
const historyRows = computed(() => history.value.map((event, index) => ({ ...event, eventKey: `${event.eventSource}:${event.recordVersion ?? event.dispatchRecordVersion ?? event.occurredAt}:${event.action}:${index}` })))
const cargoColumns: DataTableColumn[] = [{ prop: 'customerName', label: '客户', minWidth: 150 }, { prop: 'orderNo', label: '订单号', minWidth: 220 }, { prop: 'inboundCode', label: '入仓码', minWidth: 180 }, { prop: 'palletNo', label: '实物托盘码', minWidth: 180 }, { key: 'destination', label: '目的地', minWidth: 180, slotName: 'destination' }, { prop: 'packages', label: '箱数', minWidth: 90 }]
const historyColumns: DataTableColumn[] = [{ key: 'source', label: '事件来源', minWidth: 110, slotName: 'source' }, { key: 'revision', label: '运营版本', minWidth: 90, prop: 'recordVersion' }, { prop: 'dispatchRecordVersion', label: '排车版本', minWidth: 90 }, { key: 'action', label: '操作', minWidth: 150, slotName: 'action' }, { prop: 'operatorName', label: '操作人', minWidth: 140 }, { key: 'occurredAt', label: '操作时间', minWidth: 180, slotName: 'occurredAt' }, { prop: 'reason', label: '说明', minWidth: 160 }, { key: 'snapshot', label: '当时运营信息', minWidth: 250, slotName: 'snapshot' }, { key: 'photos', label: '当时照片', minWidth: 190, slotName: 'photos' }]
const taskFields = computed(() => {
const row = detail.value?.row
return [['计划发车', time(row?.plannedDepartureAt)], ['装车完成', time(row?.loadingCompletedAt)], ['实际发车', time(row?.departedAt)], ['派送完成', time(row?.deliveryCompletedAt)], ['车牌', text(row?.vehicle.plate)], ['司机', text(row?.driver.name)], ['司机电话', text(row?.driver.phone)], ['提货仓库', text(row?.origin.warehouseName)], ['实际物理托数', text(row?.actualPalletCount)], ['整车承运金额', row?.operations.money ? `${row.operations.money.currency} ${row.operations.money.amount}` : '未登记'], ['单价', row?.unitPrice == null ? '—' : `${row.operations.money?.currency || ''} ${row.unitPrice}`], ['承运商', text(row?.vehicle.carrierName)]]
})
function time(value: string | null | undefined) { return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—' }
function destinationLabel(key: string) { const value = detail.value?.row.destinations.find(destination => destination.destinationKey === key); return value?.warehouseCode || value?.warehouseName || value?.address || '—' }
function photoLabel(photo: OutsourcedBusinessPhoto) { return outsourcedBusinessPhotoRoles.find(role => role.slot === photo.slot)?.label || '照片' }
async function load() {
if (!active) return
const request = ++generation, no = dispatchNo.value; loading.value = true; error.value = null
try { if (!no || !access.can(permission.outsourcedBusinessList)) throw new Error('缺少排车业务号或查看权限'); const value = await getOutsourcedBusiness(no); if (active && request === generation) { if (value.row.dispatchNo !== no) throw new Error('当前运营资料与排车业务号不一致'); detail.value = value; if (activeTab.value === 'history') void loadHistory() } }
catch (cause) { if (active && request === generation) { detail.value = null; error.value = getApiErrorDetails(cause, '外配业务详情读取失败') } }
finally { if (request === generation) loading.value = false }
}
async function loadHistory() {
if (!active || activeTab.value !== 'history' || !detail.value) return
const request = ++historyGeneration, no = dispatchNo.value; historyLoading.value = true; historyError.value = null
try { const value = await listOutsourcedBusinessHistory(no, historyPage.value, historySize.value); if (active && request === historyGeneration && no === dispatchNo.value) { history.value = value.records; historyTotal.value = value.total } }
catch (cause) { if (request === historyGeneration) historyError.value = getApiErrorDetails(cause, '操作记录读取失败') }
finally { if (request === historyGeneration) historyLoading.value = false }
}
async function preview(photo: OutsourcedBusinessPhoto, revision: string | null) {
const no = dispatchNo.value, request = generation; previewError.value = null
if (revision == null) { previewError.value = { message: '历史照片缺少运营资料版本,请重新读取' }; return }
const windowRef = window.open('', '_blank')
if (!windowRef) { previewError.value = { message: '请允许本站打开图片预览窗口' }; return }
try { const blob = await getOutsourcedBusinessPhotoContent(no, photo, revision); if (!active || request !== generation || no !== dispatchNo.value) { windowRef.close(); return }; const url = URL.createObjectURL(blob); previewUrls.add(url); windowRef.location.href = url }
catch (cause) { windowRef.close(); previewError.value = getApiErrorDetails(cause, '历史照片读取失败') }
}
function saved(value: OutsourcedBusinessDetail) { if (value.row.dispatchNo !== dispatchNo.value) return; detail.value = value; if (activeTab.value === 'history') void loadHistory() }
watch([activeTab,historyPage,historySize], () => { if (active) void loadHistory() })
watch(dispatchNo, () => { generation++; historyGeneration++; detail.value = null; history.value = []; historyTotal.value = 0; activeTab.value = 'task'; editorOpen.value = false; photosOpen.value = false; if (active) void load() })
onMounted(load)
onActivated(() => { active = true; if (initialActivation) initialActivation = false; else void load() })
onDeactivated(() => { active = false; generation++; historyGeneration++; loading.value = false; historyLoading.value = false; editorOpen.value = false; photosOpen.value = false })
onBeforeUnmount(() => { active = false; generation++; historyGeneration++; previewUrls.forEach(url => URL.revokeObjectURL(url)) })
</script>
<template>
<OutsourcedBusinessDetailLayout v-model:active-tab="activeTab" :dispatch-no="detail?.row.dispatchNo || dispatchNo" :status-text="detail ? outsourcedBusinessStages[detail.row.stage] : ''" :loading="loading" @back="router.push('/overseas-master-data/outsourced-business')">
<template #actions><el-button v-if="detail && access.can(permission.outsourcedBusinessEdit)" class="density-action" :disabled="busy || !detail.row.actions.edit.allowed" @click="editorOpen = true">登记运营信息</el-button><el-button v-if="detail" class="density-action" :disabled="busy" @click="photosOpen = true">{{ access.can(permission.outsourcedBusinessUpload) && detail.row.actions.upload.allowed ? '上传照片' : '查看照片' }}</el-button></template>
<template #state><LoadErrorState v-if="error" title="外配业务详情读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-for="issue in detail?.row.sourceIssues || []" :key="issue">{{ issue }}</p></template>
<template #task><dl v-if="detail" class="density-fields outsourced-business-fields"><div v-for="field in taskFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl></template>
<template #route><template v-if="detail"><section v-for="destination in detail.row.destinations" :key="destination.destinationKey" class="outsourced-business-route"><h3>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}</h3><p v-if="destination.address">{{ destination.address }}</p><p v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约号 {{ appointment.appointmentNo }} · 确认号 {{ text(appointment.confirmationNo) }} · 预约到达 {{ time(appointment.scheduledAt) }}</p><p>实际到达 {{ time(destination.actualArrivedAt) }}</p></section><DataTable :data="detail.cargo" :columns="cargoColumns" row-key="sourceKey" column-settings-mode="hide" empty-text="暂无真实货物明细"><template #destination="{ row }">{{ destinationLabel(row.destinationKey) }}</template></DataTable><section class="outsourced-business-route"><h3>原排车关联单据</h3><p>出库单:{{ detail.dispatch.documents.outbounds.map(item => item.outboundNo).join('、') || '—' }}</p><p>派送单:{{ detail.dispatch.documents.deliveries.map(item => item.deliveryNo).join('、') || '—' }}</p><el-button @click="photosOpen = true">查看分类照片</el-button></section></template></template>
<template #history><LoadErrorState v-if="historyError" title="操作记录读取失败" :message="historyError.message" @retry="loadHistory"><template #details><ApiErrorMeta :details="historyError" /></template></LoadErrorState><LoadErrorState v-if="previewError" title="历史照片读取失败" :message="previewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="previewError" /></template></LoadErrorState><DataTable v-model:page="historyPage" v-model:page-size="historySize" :data="historyRows" :columns="historyColumns" :total="historyTotal" :loading="historyLoading" row-key="eventKey" empty-text="暂无操作记录"><template #source="{ row }">{{ row.eventSource === 'DISPATCH' ? '原排车' : '运营登记' }}</template><template #action="{ row }">{{ outsourcedBusinessActionLabels[row.action] || '其他操作' }}</template><template #occurredAt="{ row }">{{ time(row.occurredAt) }}</template><template #snapshot="{ row }"><template v-if="row.operations"><p>整车承运金额 {{ row.operations.money ? row.operations.money.currency + ' ' + row.operations.money.amount : '未登记' }}</p><div v-for="arrival in row.operations.arrivals" :key="arrival.destinationKey">{{ destinationLabel(arrival.destinationKey) }} · {{ time(arrival.actualArrivedAt) }}</div></template><span v-else>—</span></template><template #photos="{ row }"><el-button v-for="photo in row.photos" :key="photo.slot" link @click="preview(photo, row.recordVersion)">{{ photoLabel(photo) }}</el-button></template></DataTable></template>
</OutsourcedBusinessDetailLayout>
<OutsourcedBusinessOperationsEditor v-model="editorOpen" :detail="detail" @busy="busy = $event" @saved="saved" />
<OutsourcedBusinessPhotosEditor v-model="photosOpen" :detail="detail" @busy="busy = $event" @saved="saved" />
</template>
<style scoped>
.outsourced-business-fields { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); padding: var(--admin-space-4); }
.outsourced-business-fields > div { min-width: 0; overflow-wrap: anywhere; }
.outsourced-business-route { padding: var(--admin-space-4); border-bottom: 1px solid var(--admin-table-border); }
.outsourced-business-route h3 { font-size: inherit; margin: 0 0 var(--admin-space-3); }
</style>
@@ -1,128 +0,0 @@
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import dayjs from 'dayjs'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listAllPlatformWarehouses } from '@/modules/warehouses'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import QueryToolbar, { type QueryField, type QueryModel, type QueryOption } from '@/shared/components/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import OutsourcedBusinessListLayout from '../components/OutsourcedBusinessListLayout.vue'
import OutsourcedBusinessOperationsEditor from '../components/OutsourcedBusinessOperationsEditor.vue'
import OutsourcedBusinessPhotosEditor from '../components/OutsourcedBusinessPhotosEditor.vue'
import { getOutsourcedBusiness, listOutsourcedBusiness } from '../api/outsourcedBusiness'
import { outsourcedBusinessStages, outsourcedBusinessText as text, type OutsourcedBusinessDetail, type OutsourcedBusinessList, type OutsourcedBusinessQuery, type OutsourcedBusinessRow, type OutsourcedBusinessStage } from '../outsourcedBusinessTypes'
import { overseasMasterDataPermissions as permission } from '../permissions'
const router = useRouter(), access = useAccessPreviewStore()
const result = ref<OutsourcedBusinessList | null>(null), page = ref(1), size = ref(20), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const filters = ref<QueryModel>({ plannedRange: [], keyword: '', stage: '', warehouseId: '' })
const warehouseOptions = ref<QueryOption[]>([]), warehouseError = ref<ApiErrorDetails | null>(null)
const detail = ref<OutsourcedBusinessDetail | null>(null), editorOpen = ref(false), photosOpen = ref(false), opening = ref(false), mutating = ref(false)
let applied: Omit<OutsourcedBusinessQuery,'page'|'size'> = {}, generation = 0, openGeneration = 0, active = true, initialActivation = true
const fields = computed<QueryField[]>(() => [
{ key: 'plannedRange', type: 'daterange', label: '计划发车日期', width: 270, props: { valueFormat: 'YYYY-MM-DD', 'aria-label': '计划发车日期范围' } },
{ key: 'keyword', type: 'input', label: '关键词', width: 230, placeholder: '排车单号 / 车牌 / 目的仓', props: { maxlength: 128 } },
{ key: 'stage', type: 'select', label: '阶段', width: 140, options: Object.entries(outsourcedBusinessStages).map(([value,label]) => ({ value,label })) },
{ key: 'warehouseId', type: 'select', label: '提货仓库', width: 180, options: warehouseOptions.value, props: { disabled: !access.can('platform:warehouse:list') } },
])
const summary = computed(() => [
['排车数', result.value?.summary.dispatchCount], ['装车完成', result.value?.summary.afterLoadingCount], ['派送中', result.value?.summary.deliveringCount], ['派送完成', result.value?.summary.deliveredCount],
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 245, fixed: 'left', slotName: 'actions' },
{ key: 'appointments', label: '预约号', minWidth: 195, slotName: 'appointments' }, { prop: 'dispatchNo', label: '排车单号', minWidth: 300 },
{ key: 'carrier', label: '承运商', minWidth: 150, slotName: 'carrier' },
{ prop: 'vehicle.plate', label: '车牌', minWidth: 130 }, { key: 'driver', label: '司机', minWidth: 150, slotName: 'driver' }, { prop: 'origin.warehouseName', label: '提货仓库', minWidth: 150 },
{ key: 'destinations', label: '目的仓 / 预约到达 / 实际到达', minWidth: 285, slotName: 'destinations' },
{ prop: 'actualPalletCount', label: '实际托数', minWidth: 105 },
{ key: 'money', label: '整车承运金额 / 单价', minWidth: 170, slotName: 'money' },
{ key: 'plannedDepartureAt', label: '计划发车时间', minWidth: 180, slotName: 'plannedDeparture' }, { key: 'loadingCompletedAt', label: '实际装车完成时间', minWidth: 180, slotName: 'loaded' },
{ key: 'stage', label: '阶段', minWidth: 110, slotName: 'stage' },
]
function time(value: string | null | undefined) { return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—' }
function rowActions(row: OutsourcedBusinessRow): RowActionItem[] {
return [
{ key: 'detail', label: '查看明细' },
{ key: 'operations', label: '登记运营信息', visible: access.can(permission.outsourcedBusinessEdit), disabled: opening.value || mutating.value || !row.actions.edit.allowed },
{ key: 'photos', label: access.can(permission.outsourcedBusinessUpload) && row.actions.upload.allowed ? '上传照片' : '查看照片', disabled: opening.value || mutating.value },
]
}
function act(row: OutsourcedBusinessRow, key: string) {
if (key === 'detail') void router.push(`/overseas-master-data/outsourced-business/${encodeURIComponent(row.dispatchNo)}`)
else if (key === 'operations' || key === 'photos') void openAction(row, key)
}
async function load() {
if (!active) return
const request = ++generation
loading.value = true; error.value = null
try {
if (!access.can(permission.outsourcedBusinessList)) throw new Error('没有外配业务查看权限')
const value = await listOutsourcedBusiness({ ...applied, page: page.value, size: size.value })
if (active && request === generation) result.value = value
} catch (cause) { if (active && request === generation) { result.value = null; error.value = getApiErrorDetails(cause, '外配业务读取失败') } }
finally { if (request === generation) loading.value = false }
}
function search() {
try {
const range = filters.value.plannedRange
const dates = Array.isArray(range) ? range : []
if (dates.length && (dates.length !== 2 || !dayjs(String(dates[0])).isValid() || !dayjs(String(dates[1])).isValid() || dayjs(String(dates[1])).isBefore(dayjs(String(dates[0]))))) throw new Error('请选择有效的计划发车日期范围')
applied = { keyword: String(filters.value.keyword || '').trim() || undefined, stage: (filters.value.stage || undefined) as OutsourcedBusinessStage | undefined, warehouseId: String(filters.value.warehouseId || '') || undefined,
plannedDepartureFrom: dates.length ? dayjs(String(dates[0])).startOf('day').toISOString() : undefined, plannedDepartureTo: dates.length ? dayjs(String(dates[1])).add(1,'day').startOf('day').toISOString() : undefined }
if (page.value !== 1) page.value = 1; else void load()
} catch (cause) { error.value = getApiErrorDetails(cause, '筛选条件无效') }
}
async function loadWarehouses() {
if (!access.can('platform:warehouse:list')) return
try { const values = await listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' }); warehouseOptions.value = values.map(value => ({ value: String(value.id), label: value.warehouseName })) }
catch (cause) { warehouseError.value = getApiErrorDetails(cause, '仓库目录读取失败,可继续使用其他筛选') }
}
async function openAction(row: OutsourcedBusinessRow, kind: 'operations' | 'photos') {
if (opening.value || mutating.value) return
const code = kind === 'operations' ? permission.outsourcedBusinessEdit : permission.outsourcedBusinessList
if (!access.can(code) || kind === 'operations' && !row.actions.edit.allowed) return
const request = ++openGeneration; opening.value = true; error.value = null
try { const value = await getOutsourcedBusiness(row.dispatchNo); if (!active || request !== openGeneration) return; detail.value = value; if (kind === 'operations') editorOpen.value = true; else photosOpen.value = true }
catch (cause) { if (request === openGeneration) error.value = getApiErrorDetails(cause, '当前运营资料读取失败') }
finally { if (request === openGeneration) opening.value = false }
}
function saved(value: OutsourcedBusinessDetail) { detail.value = value; void load() }
watch([page,size], () => { if (active) void load() })
onMounted(() => { void load(); void loadWarehouses() })
onActivated(() => { active = true; if (initialActivation) initialActivation = false; else void load() })
onDeactivated(() => { active = false; generation++; openGeneration++; loading.value = false; opening.value = false; editorOpen.value = false; photosOpen.value = false })
onBeforeUnmount(() => { active = false; generation++; openGeneration++ })
</script>
<template>
<OutsourcedBusinessListLayout>
<template #statistics><section class="outsourced-business-statistics" aria-label="完整筛选范围外配业务统计" :aria-busy="loading"><div v-for="item in summary" :key="String(item[0])"><span>{{ item[0] }}</span><strong>{{ item[1] ?? '—' }}</strong></div></section></template>
<DataTable appearance="integrated" :stripe="false" v-model:page="page" v-model:page-size="size" :data="result?.records || []" :columns="columns" :total="result?.total || 0" :loading="loading" row-key="dispatchNo" preference-key="outsourced-business-table" empty-text="当前筛选范围暂无已装车的外配车辆排车单">
<template #toolbar-start><h1>外配业务</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="外配业务筛选" preference-key="outsourced-business-query" :loading="loading" @search="search" /><small>日期和时间按浏览器本地时区显示;统计覆盖同一完整筛选范围。</small></template>
<template #notices><LoadErrorState v-if="error" title="外配业务读取未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="warehouseError">{{ warehouseError.message }} <el-button link @click="loadWarehouses">重试目录</el-button></p><p v-if="!access.can('platform:warehouse:list')">没有公共仓库目录读取权限,仓库筛选暂不可选。</p></template>
<template #actions="{ row }"><RowActions :actions="rowActions(row as OutsourcedBusinessRow)" text-actions @action="act(row as OutsourcedBusinessRow, $event)" /></template>
<template #carrier="{ row }">{{ text(row.vehicle.carrierName) }}</template>
<template #driver="{ row }">{{ text(row.driver.name) }}<br>{{ text(row.driver.phone) }}</template>
<template #appointments="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="outsourced-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ appointment.appointmentNo }}<small v-if="appointment.confirmationNo">确认号 {{ appointment.confirmationNo }}</small></div><span v-if="!destination.appointments.length">—</span></div></template>
<template #destinations="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="outsourced-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || destination.address || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约 {{ time(appointment.scheduledAt) }}</div><div>实到 {{ time(destination.actualArrivedAt) }}</div></div></template>
<template #money="{ row }"><template v-if="row.operations.money">{{ row.operations.money.currency }} {{ row.operations.money.amount }}<br>单价 {{ row.unitPrice ?? '—' }}</template><span v-else>未登记</span></template>
<template #plannedDeparture="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #loaded="{ row }">{{ time(row.loadingCompletedAt) }}</template><template #stage="{ row }">{{ outsourcedBusinessStages[row.stage as OutsourcedBusinessStage] }}</template>
</DataTable>
<OutsourcedBusinessOperationsEditor v-model="editorOpen" :detail="detail" @busy="mutating = $event" @saved="saved" />
<OutsourcedBusinessPhotosEditor v-model="photosOpen" :detail="detail" @busy="mutating = $event" @saved="saved" />
</OutsourcedBusinessListLayout>
</template>
<style scoped>
.outsourced-business-statistics { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); background: white; border: 1px solid var(--admin-table-border); }
.outsourced-business-statistics > div { padding: var(--admin-space-4) var(--admin-space-5); border-right: 1px solid var(--admin-table-border); }
.outsourced-business-statistics > div:last-child { border-right: 0; }
.outsourced-business-statistics strong { display: block; margin-top: var(--admin-space-2); font-size: var(--admin-font-title); }
.outsourced-business-destination + .outsourced-business-destination { margin-top: var(--admin-space-3); border-top: 1px solid var(--admin-table-border); padding-top: var(--admin-space-3); }
.outsourced-business-destination small { display: block; }
</style>
@@ -1,96 +0,0 @@
import { defineComponent, h } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import dayjs from 'dayjs'
import OutsourcedBusinessView from './OutsourcedBusinessView.vue'
import OutsourcedBusinessDetailView from './OutsourcedBusinessDetailView.vue'
import { outsourcedBusinessFixture } from '../components/OutsourcedBusinessFixtures'
import { overseasMasterDataPermissions as permission } from '../permissions'
const calls = vi.hoisted(() => ({ list: vi.fn(), detail: vi.fn(), history: vi.fn(), content: vi.fn(), warehouses: vi.fn(), push: vi.fn(), allowed: new Set<string>() }))
vi.mock('../api/outsourcedBusiness', () => ({ listOutsourcedBusiness: calls.list, getOutsourcedBusiness: calls.detail, listOutsourcedBusinessHistory: calls.history, getOutsourcedBusinessPhotoContent: calls.content }))
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: calls.warehouses }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (code: string) => calls.allowed.has(code) }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: calls.push }), useRoute: () => ({ params: { dispatchNo: 'DS-OUT-1' } }) }))
const Surface = defineComponent({ template: '<section><slot name="header" /><slot /></section>' })
const Button = defineComponent({ props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' })
const Tabs = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], template: '<div><slot /></div>' })
const Pane = defineComponent({ props: ['name','label'], template: '<article><slot /></article>' })
const Query = defineComponent({ props: ['modelValue','fields'], emits: ['update:modelValue','search'], template: '<div />' })
const Table = defineComponent({ props: ['data','columns','total','page','pageSize'], emits: ['update:page','update:pageSize'], setup(props,{slots}) { return () => h('div', [slots['toolbar-start']?.(), slots.filters?.(), slots.notices?.(), ...(props.data || []).map((row: object) => h('div', Object.entries(slots).filter(([name]) => !['toolbar-start','filters','notices'].includes(name)).map(([,slot]) => slot?.({row}))))]) } })
const OperationsEditor = defineComponent({ name: 'OutsourcedBusinessOperationsEditorStub', props: ['modelValue','detail'], emits: ['update:modelValue','saved','busy'], template: '<div />' })
const PhotosEditor = defineComponent({ name: 'OutsourcedBusinessPhotosEditorStub', props: ['modelValue','detail'], emits: ['update:modelValue','saved','busy'], template: '<div />' })
const global = { stubs: { AdminPage: Surface, DetailPageLayout: Surface, DataTable: Table, QueryToolbar: Query, ElTabs: Tabs, ElTabPane: Pane, ElButton: Button, OutsourcedBusinessOperationsEditor: OperationsEditor, OutsourcedBusinessPhotosEditor: PhotosEditor, LoadErrorState: { props:['message'], template:'<div>{{message}}<slot name="details" /></div>' }, ApiErrorMeta: true }, directives: { loading: () => {} } }
beforeEach(() => {
vi.clearAllMocks(); calls.allowed = new Set(Object.values(permission)); calls.allowed.add('platform:warehouse:list')
const detail = outsourcedBusinessFixture()
calls.list.mockResolvedValue({ records:[detail.row],page:1,size:20,total:41,summary:{dispatchCount:41,afterLoadingCount:30,deliveringCount:10,deliveredCount:1} })
calls.detail.mockResolvedValue(detail); calls.history.mockResolvedValue({records:[],page:1,size:20,total:0}); calls.warehouses.mockResolvedValue([])
})
describe('outsourced business views', () => {
it('uses one list request for all statistics and never hydrates each row', async () => {
const wrapper = mount(OutsourcedBusinessView,{global}); await flushPromises()
expect(calls.list).toHaveBeenCalledTimes(1); expect(calls.detail).not.toHaveBeenCalled(); expect(calls.content).not.toHaveBeenCalled()
expect(wrapper.find('[aria-label="完整筛选范围外配业务统计"]').text()).toContain('排车数41')
expect(wrapper.text()).not.toContain('今日排车')
expect(wrapper.findComponent(Table).props('columns')[0].key).toBe('actions')
expect(wrapper.findComponent(Table).props('total')).toBe(41)
expect(wrapper.text()).toContain('测试外配承运商'); expect(wrapper.text()).not.toContain('油耗'); expect(wrapper.text()).not.toContain('自有成本')
wrapper.unmount()
})
it('applies planned date half-open boundaries only on query and keeps all stage counts from the response', async () => {
const wrapper=mount(OutsourcedBusinessView,{global});await flushPromises();const query=wrapper.findComponent(Query)
query.vm.$emit('update:modelValue',{plannedRange:['2026-10-20','2026-10-21'],keyword:' FBA-1 ',stage:'AFTER_LOADING',warehouseId:'1'});await wrapper.vm.$nextTick()
expect(calls.list).toHaveBeenCalledTimes(1)
query.vm.$emit('search');await flushPromises()
expect(calls.list).toHaveBeenLastCalledWith({page:1,size:20,keyword:'FBA-1',stage:'AFTER_LOADING',warehouseId:'1',plannedDepartureFrom:dayjs('2026-10-20').startOf('day').toISOString(),plannedDepartureTo:dayjs('2026-10-21').add(1,'day').startOf('day').toISOString()})
expect(wrapper.find('[aria-label="完整筛选范围外配业务统计"]').text()).toContain('装车完成30')
wrapper.unmount()
})
it('opens the operations modal on demand with auxiliary zero version instead of the dispatch version', async () => {
const wrapper=mount(OutsourcedBusinessView,{global});await flushPromises()
expect(calls.detail).not.toHaveBeenCalled();expect(wrapper.findComponent(OperationsEditor).props('modelValue')).toBe(false)
await wrapper.findAll('button').find(button=>button.text()==='登记运营信息')!.trigger('click');await flushPromises()
expect(calls.detail).toHaveBeenCalledExactlyOnceWith('DS-OUT-1')
const editor=wrapper.findComponent(OperationsEditor)
expect(editor.props('modelValue')).toBe(true);expect(wrapper.findComponent(PhotosEditor).props('modelValue')).toBe(false)
expect(editor.props('detail').row.recordVersion).toBe('0');expect(editor.props('detail').dispatch.recordVersion).toBe('8')
wrapper.unmount()
})
it('keeps a list-only user read-only while retaining detail and authorized photo reads', async () => {
calls.allowed=new Set([permission.outsourcedBusinessList])
const wrapper=mount(OutsourcedBusinessView,{global});await flushPromises()
expect(wrapper.text()).not.toContain('登记运营信息');expect(wrapper.text()).toContain('查看照片');expect(calls.warehouses).not.toHaveBeenCalled()
wrapper.unmount()
})
it('shows a temporary external vehicle and a missing carrier without requiring a master ID', async () => {
const detail = outsourcedBusinessFixture(); detail.row.vehicle = { vehicleId:null,plate:'TEMP-1',ownership:'DISPATCHED',carrierName:null }
calls.detail.mockResolvedValue(detail)
const wrapper = mount(OutsourcedBusinessDetailView,{global}); await flushPromises()
expect(wrapper.text()).toContain('TEMP-1'); expect(wrapper.text()).toContain('承运商'); expect(wrapper.text()).toContain('—')
expect(calls.detail).toHaveBeenCalledExactlyOnceWith('DS-OUT-1'); expect(calls.content).not.toHaveBeenCalled(); wrapper.unmount()
})
it('loads aggregated history only on its tab, preserving null operations on dispatch events', async () => {
calls.history.mockResolvedValue({records:[{eventSource:'DISPATCH',recordVersion:null,dispatchRecordVersion:'8',action:'LOADING_COMPLETE',operatorName:null,occurredAt:'2026-10-20T09:00:00Z',reason:null,operations:null,photos:[]},{eventSource:'OPERATIONS',recordVersion:'1',dispatchRecordVersion:null,action:'SAVE_OPERATIONS',operatorName:'登记员',occurredAt:'2026-10-20T10:00:00Z',reason:null,operations:outsourcedBusinessFixture().row.operations,photos:[]}],page:1,size:20,total:2})
const wrapper=mount(OutsourcedBusinessDetailView,{global});await flushPromises()
expect(calls.detail).toHaveBeenCalledTimes(1);expect(calls.history).not.toHaveBeenCalled()
wrapper.findComponent(Tabs).vm.$emit('update:modelValue','history');await flushPromises()
expect(calls.history).toHaveBeenCalledExactlyOnceWith('DS-OUT-1',1,20)
expect(wrapper.text()).toContain('原排车');expect(wrapper.text()).toContain('运营登记')
const table=wrapper.findComponent(Table), records=table.props('data')
expect(records[0].eventKey).not.toBe(records[1].eventKey);expect(records[0].operations).toBeNull()
table.vm.$emit('update:page',2);await flushPromises();expect(calls.history).toHaveBeenLastCalledWith('DS-OUT-1',2,20)
expect(calls.detail).toHaveBeenCalledTimes(1);expect(calls.content).not.toHaveBeenCalled()
wrapper.unmount()
})
it('shows authoritative large money text and unit price without rounding or recomputing from two cargo rows', async () => {
const detail=outsourcedBusinessFixture();detail.row.operations.money={currency:'USD',amount:'9999999999999999.99'};detail.row.unitPrice='9999999999999999.99'
calls.detail.mockResolvedValue(detail)
const wrapper=mount(OutsourcedBusinessDetailView,{global});await flushPromises()
expect(wrapper.text()).toContain('USD 9999999999999999.99');expect(wrapper.text()).not.toContain('10000000000000000')
expect(wrapper.text()).not.toContain('4999999999999999')
wrapper.unmount()
})
})
@@ -17,9 +17,6 @@ export const overseasMasterDataPermissions = {
ownedBusinessList: 'platform:fleet:owned-business:list',
ownedBusinessEdit: 'platform:fleet:owned-business:edit',
ownedBusinessUpload: 'platform:fleet:owned-business:upload',
outsourcedBusinessList: 'platform:fleet:outsourced-business:list',
outsourcedBusinessEdit: 'platform:fleet:outsourced-business:edit',
outsourcedBusinessUpload: 'platform:fleet:outsourced-business:upload',
tripBookingCreate: 'platform:fleet:trip:booking-create',
tripBookingEdit: 'platform:fleet:trip:booking-edit',
tripBookingConfirm: 'platform:fleet:trip:booking-confirm',
@@ -13,8 +13,6 @@ describe('overseas master data routes', () => {
['overseas-master-data/trip-operations', overseasMasterDataPermissions.operationsList],
['overseas-master-data/owned-business', overseasMasterDataPermissions.ownedBusinessList],
['overseas-master-data/owned-business/:dispatchNo', overseasMasterDataPermissions.ownedBusinessList],
['overseas-master-data/outsourced-business', overseasMasterDataPermissions.outsourcedBusinessList],
['overseas-master-data/outsourced-business/:dispatchNo', overseasMasterDataPermissions.outsourcedBusinessList],
['overseas-master-data/trips', overseasMasterDataPermissions.tripList],
['overseas-master-data/trips/:tripNo', overseasMasterDataPermissions.tripList],
['overseas-master-data/drivers', overseasMasterDataPermissions.driverList],
@@ -47,17 +45,6 @@ describe('overseas master data routes', () => {
}
})
it('uses external list permission for both routes independently of owned and write permissions', () => {
const external = overseasMasterDataRoutes.filter(route => route.path.includes('/outsourced-business'))
expect(external).toHaveLength(2)
for (const route of external) {
expect(route.meta?.permission).toBe(overseasMasterDataPermissions.outsourcedBusinessList)
expect(route.meta?.permissionAnyOf).toBeUndefined()
expect(route.meta?.permission).not.toBe(overseasMasterDataPermissions.ownedBusinessList)
expect(route.meta?.permission).not.toBe(overseasMasterDataPermissions.outsourcedBusinessEdit)
}
})
it('keeps vehicle detail as a route page and does not add a duplicate partner warehouse route', () => {
expect(overseasMasterDataRoutes.at(-1)?.meta?.showPageHeading).toBe(false)
expect(overseasMasterDataRoutes.some((route) => route.path.includes('partner-warehouse'))).toBe(false)
@@ -38,18 +38,6 @@ export const overseasMasterDataRoutes = [
component: () => import('./pages/OwnedBusinessDetailView.vue'),
meta: { title: '自有业务详情', description: '查看原排车事实与附加运营信息', permission: permission.ownedBusinessList, showPageHeading: false },
},
{
path: 'overseas-master-data/outsourced-business',
name: 'overseas-master-data-outsourced-business',
component: () => import('./pages/OutsourcedBusinessView.vue'),
meta: { title: '外配业务', description: '按原排车查询外配车辆装车后的履约与承运信息', permission: permission.outsourcedBusinessList, showPageHeading: false },
},
{
path: 'overseas-master-data/outsourced-business/:dispatchNo',
name: 'overseas-master-data-outsourced-business-detail',
component: () => import('./pages/OutsourcedBusinessDetailView.vue'),
meta: { title: '外配业务详情', description: '查看原排车及外配承运登记', permission: permission.outsourcedBusinessList, showPageHeading: false },
},
{
path: 'overseas-master-data/trips',
name: 'overseas-master-data-trips',
@@ -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>