This commit is contained in:
2026-10-20 15:48:27 +08:00
parent 489b1bcd16
commit db32f31cc2
67 changed files with 1262 additions and 71 deletions
@@ -113,12 +113,12 @@ onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clear
<strong>本目的站可接余位</strong>
<p v-if="selectedCapacity">{{ selectedCapacity.palletSpaces }} 托位 · {{ selectedCapacity.weightKg }} kg · {{ selectedCapacity.volumeCbm }} m³</p>
<p v-else>待调度核对,当前没有可靠的可接余位。</p>
</section>
<p v-if="pending" role="status">上次提交结果尚未确定,请保留原内容重试或核对。</p>
<p v-if="offer">{{ offer.offerNo }} · {{ offer.warehouseName }} · {{ offer.blockReason || '询价不占位,调度确认承运后占位。' }}</p>
<section v-if="inquiry" aria-label="本票处理进度"><strong>{{ inquiry.customerName }} · {{ inquiry.destinationLabel }}</strong><p>车次:{{ inquiry.tripNo }} · 登记:{{ inquiry.bookingNo }}</p><strong>{{ inquiry.progress.label }} · {{ inquiry.progress.reservation }}</strong><p>下一步负责人:{{ inquiry.progress.owner }} · {{ inquiry.progress.message }}</p></section>
<p v-if="!inquiry">业务类型:{{ form.businessType === 'US_TRUCK' ? '本土卡车' : '本土散板' }},可在“确认承运前待补资料”中调整。</p>
<p v-if="offer">计划发车:{{ offerTime(offer.plannedDepartureAt) }} · 最晚送仓:{{ offerTime(offer.cutoffAt) }}</p>
<p v-if="inquiry?.actions.blockReason">{{ inquiry.actions.blockReason }}</p>
@@ -122,7 +122,7 @@ onBeforeUnmount(() => { generation++ })
<p>{{ trip.tripNo }} · {{ offer ? `${offer.offerNo} · ${offerStatusLabels[offer.status]}` : '尚未建立发布' }}</p><p v-if="offer?.blockReason && offer.status !== 'DRAFT'">{{ offer.blockReason }}</p><p v-if="pending">上次结果尚未确定,原请求已保留,请重试核对。</p>
<template v-if="loaded && open && (!offer || offer.actions.canEdit)">
<h3>本车货物与余位<InlineHelp title="容量发布说明"><p>卸货站已按货物匹配。核对下方数据,确认发布时一并保存容量;多个目的地的先后顺序请在车次计划中确认。</p></InlineHelp></h3>
<el-button v-if="canPlan && planner?.destinationIssue" :disabled="busy || !!pending" @click="matchExistingStops">按货物补齐卸货站</el-button>
<TripLoadingPanel ref="planner" :trip="currentTrip" :editable="canPlan && !busy && !pending" planning embedded @changed="planChanged" />
</template>
@@ -80,7 +80,7 @@ async function save() {
</tbody></table>
</section>
</el-form>
<template #footer="{close}"><div class="density-actions density-actions--footer"><el-button :disabled="saving" @click="close">关闭</el-button><el-button v-if="!readonly" type="primary" :loading="saving" @click="save">{{ uncertain ? '按原请求重试' : '保存报价' }}</el-button></div></template>
</AppDialog></template>
<style scoped>
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import * as api from '../api/ownedBusiness'
import { ownedBusinessFixture, ownedBusinessCommandFixture } from './OwnedBusinessFixtures'
import { acceptOwnedBusinessCommand, newOwnedBusinessPending, persistOwnedBusinessPending, restoreOwnedBusinessPending, recoverOwnedBusinessCommand, ownedBusinessPendingKey } from './OwnedBusinessCommandRecovery'
import type { OwnedBusinessPhoto, OwnedBusinessSave } from '../ownedBusinessTypes'
beforeEach(() => sessionStorage.clear())
const no = 'DS-OWN-1'
function operation() : OwnedBusinessSave { return { recordVersion: '0', idempotencyKey: 'original-key', operations: { ...ownedBusinessFixture().row.operations, money: { currency: 'USD', amount: '9999999999999999.99' } }, reason: null } }
function client() {
const current = ownedBusinessFixture(); current.row.recordVersion = '1'
const photo: OwnedBusinessPhoto = { slot:'FUEL_START',category:'FUEL',fileId:'9007199254740993',fileName:'fuel.png',contentType:'image/png',sizeBytes:'5',uploadedAt:'2026-10-20T10:00:00Z',savedAt:null }
return { ...api, getOwnedBusiness: vi.fn(async () => current), getOwnedBusinessCommand: vi.fn(async () => ownedBusinessCommandFixture()), saveOwnedBusinessOperations: vi.fn(async () => current), bindOwnedBusinessPhoto: vi.fn(async () => current), uploadOwnedBusinessPhoto: vi.fn(async () => photo) }
}
describe('owned business exact command recovery', () => {
it('persists the complete original payload, actor and zero auxiliary version', () => {
const pending = newOwnedBusinessPending('actor', no, 'SAVE_OPERATIONS', operation())
persistOwnedBusinessPending('actor', no, 'operations', pending)
expect(restoreOwnedBusinessPending('actor', no, 'operations')).toEqual(pending)
expect(restoreOwnedBusinessPending('another', no, 'operations')).toBeNull()
const raw = JSON.parse(sessionStorage.getItem(ownedBusinessPendingKey('actor',no,'operations'))!)
raw.actor = 'another'; sessionStorage.setItem(ownedBusinessPendingKey('actor',no,'operations'),JSON.stringify(raw))
expect(() => restoreOwnedBusinessPending('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 = newOwnedBusinessPending('actor', no, 'SAVE_OPERATIONS', operation()); pending.attempted = true
const service = client(), persist = vi.fn()
service.getOwnedBusinessCommand.mockRejectedValue(new ApiResponseError('not found','A0404',404))
await recoverOwnedBusinessCommand(pending,persist,() => {},service)
expect(service.getOwnedBusinessCommand).toHaveBeenCalledWith(no,'original-key')
expect(service.saveOwnedBusinessOperations).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 = newOwnedBusinessPending('actor', no, 'SAVE_OPERATIONS', operation()), service = client()
service.getOwnedBusiness.mockRejectedValueOnce(new Error('read offline'))
await expect(recoverOwnedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('read offline')
expect(pending.confirmed?.recordVersion).toBe('1')
await recoverOwnedBusinessCommand(pending,() => {},() => {},service)
expect(service.saveOwnedBusinessOperations).toHaveBeenCalledTimes(1); expect(service.getOwnedBusinessCommand).not.toHaveBeenCalled()
})
it('never retransmits a file for an unknown upload, including command not-found', async () => {
const pending = newOwnedBusinessPending('actor',no,'UPLOAD_PHOTO',null,'FUEL_START'); pending.attempted = true
const service = client(); service.getOwnedBusinessCommand.mockRejectedValue(new ApiResponseError('not found','A0404',404))
await expect(recoverOwnedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('禁止重复上传')
expect(service.uploadOwnedBusinessPhoto).not.toHaveBeenCalled(); expect(service.bindOwnedBusinessPhoto).not.toHaveBeenCalled(); expect(pending.key).toBeTruthy()
})
it('keeps the prepared file and exact binding key through unknown binding and later replacement', async () => {
const photo: OwnedBusinessPhoto = { slot:'FUEL_START',category:'FUEL',fileId:'9007199254740993',fileName:'fuel.png',contentType:'image/png',sizeBytes:'5',uploadedAt:'2026-10-20T10:00:00Z',savedAt:'2026-10-20T10:01:00Z' }
const pending = newOwnedBusinessPending('actor',no,'BIND_PHOTO',{recordVersion:'0',idempotencyKey:'bind-key',fileId:photo.fileId},photo.slot)
pending.attempted = true
const service = client(); const bound = ownedBusinessFixture(); bound.row.recordVersion='1'; bound.photos=[photo]
const original = ownedBusinessCommandFixture({action:'BIND_PHOTO',idempotencyKey:'bind-key',result:bound})
service.getOwnedBusinessCommand.mockResolvedValue(original)
const current = ownedBusinessFixture();current.row.recordVersion='2';current.photos=[{...photo,fileId:'9007199254740994'}]
service.getOwnedBusiness.mockResolvedValue(current)
const result = await recoverOwnedBusinessCommand(pending,() => {},() => {},service)
expect(result.detail?.photos[0]?.fileId).toBe('9007199254740994')
expect(pending.body).toEqual({recordVersion:'0',idempotencyKey:'bind-key',fileId:photo.fileId})
expect(service.uploadOwnedBusinessPhoto).not.toHaveBeenCalled();expect(service.bindOwnedBusinessPhoto).not.toHaveBeenCalled()
})
it('refuses wrong command identities, stale reads, definite rejection retries and scope changes', async () => {
const pending = newOwnedBusinessPending('actor',no,'SAVE_OPERATIONS',operation()), service=client()
expect(() => acceptOwnedBusinessCommand(pending,ownedBusinessCommandFixture({dispatchNo:'another'}))).toThrow('不一致')
pending.command=ownedBusinessCommandFixture({recordVersion:'9007199254740993'})
await expect(recoverOwnedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('早于')
pending.rejected=true;await expect(recoverOwnedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('明确拒绝')
pending.rejected=false;await expect(recoverOwnedBusinessCommand(pending,() => {},() => {throw new Error('scope changed')},service)).rejects.toThrow('scope changed')
expect(service.saveOwnedBusinessOperations).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,89 @@
import { ApiResponseError } from '@/shared/api/http'
import * as api from '../api/ownedBusiness'
import { ownedBusinessPhotoRoles, ownedBusinessVersion, type OwnedBusinessAction, type OwnedBusinessBind, type OwnedBusinessCommand, type OwnedBusinessDetail, type OwnedBusinessPhoto, type OwnedBusinessPhotoSlot, type OwnedBusinessSave } from '../ownedBusinessTypes'
export interface OwnedBusinessPending {
actor: string; dispatchNo: string; action: OwnedBusinessAction; key: string; slot?: OwnedBusinessPhotoSlot
body: OwnedBusinessSave | OwnedBusinessBind | null; attempted: boolean; command: OwnedBusinessCommand | null; confirmed: { recordVersion: string | null; result: OwnedBusinessDetail | OwnedBusinessPhoto } | null; rejected: boolean
}
export const ownedBusinessPendingKey = (actor: string, no: string, area: string) => `owned-business:${encodeURIComponent(actor)}:${encodeURIComponent(no)}:${area}`
export function restoreOwnedBusinessPending(actor: string, no: string, area: string): OwnedBusinessPending | null {
const raw = sessionStorage.getItem(ownedBusinessPendingKey(actor, no, area))
if (!raw) return null
const pending = JSON.parse(raw) as OwnedBusinessPending
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' && !ownedBusinessPhotoRoles.some(role => role.slot === pending.slot) || pending.action !== 'UPLOAD_PHOTO' && (!pending.body || pending.body.idempotencyKey !== pending.key)) throw new Error('原运营请求内容不完整,请保留现场核对')
return pending
}
export function persistOwnedBusinessPending(actor: string, no: string, area: string, pending: OwnedBusinessPending | null) {
if (!actor || !no || pending && (pending.actor !== actor || pending.dispatchNo !== no)) throw new Error('运营请求身份已变化')
const key = ownedBusinessPendingKey(actor, no, area)
if (pending) sessionStorage.setItem(key, JSON.stringify(pending)); else sessionStorage.removeItem(key)
}
export function newOwnedBusinessPending(actor: string, no: string, action: OwnedBusinessAction, body: OwnedBusinessPending['body'], slot?: OwnedBusinessPhotoSlot): OwnedBusinessPending {
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 acceptOwnedBusinessResult(pending: OwnedBusinessPending, result: OwnedBusinessDetail | OwnedBusinessPhoto) {
if (pending.action === 'UPLOAD_PHOTO') {
if (!('fileId' in result) || result.slot !== pending.slot || 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: ownedBusinessVersion(result.row.recordVersion), result }
}
}
export function acceptOwnedBusinessCommand(pending: OwnedBusinessPending, command: OwnedBusinessCommand) {
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('原运营命令的成功结果不完整,请继续核对')
acceptOwnedBusinessResult(pending, command.result)
if (pending.action !== 'UPLOAD_PHOTO' && (command.recordVersion == null || command.recordVersion !== pending.confirmed!.recordVersion)) throw new Error('原运营命令版本与结果不一致')
}
export async function recoverOwnedBusinessCommand(pending: OwnedBusinessPending, persist: () => void, ensureScope: () => void, client = api): Promise<{ detail?: OwnedBusinessDetail; photo?: OwnedBusinessPhoto }> {
ensureScope()
if (pending.rejected) throw new Error('原请求已明确拒绝,请先按明确拒绝结果重新核对')
if (pending.command?.state === 'SUCCEEDED') acceptOwnedBusinessCommand(pending, pending.command)
const previous = pending.attempted
if (!pending.confirmed) {
let original: OwnedBusinessCommand | null = null
if (previous) {
try { original = await client.getOwnedBusinessCommand(pending.dispatchNo, pending.key) }
catch (cause) { if (!(cause instanceof ApiResponseError) || !['404','A0404','HTTP_404'].includes(cause.code)) throw cause }
ensureScope()
}
if (original) { try { acceptOwnedBusinessCommand(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.saveOwnedBusinessOperations(pending.dispatchNo, pending.body as OwnedBusinessSave)
: await client.bindOwnedBusinessPhoto(pending.dispatchNo, pending.slot!, pending.body as OwnedBusinessBind)
ensureScope(); acceptOwnedBusinessResult(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 OwnedBusinessPhoto | null
if (!photo || photo.slot !== pending.slot || 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.getOwnedBusiness(pending.dispatchNo); ensureScope()
if (current.row.dispatchNo !== pending.dispatchNo || confirmed.recordVersion == null || BigInt(ownedBusinessVersion(current.row.recordVersion)) < BigInt(ownedBusinessVersion(confirmed.recordVersion))) throw new Error('当前运营版本早于原操作结果,请继续核对')
if (pending.action === 'BIND_PHOTO') {
const matches = (photo: OwnedBusinessPhoto) => photo.slot === pending.slot && photo.fileId === (pending.body as OwnedBusinessBind).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 }
}
@@ -0,0 +1,36 @@
<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="owned-business-detail-heading">
<div><strong>自有业务</strong><span class="owned-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="任务与车辆信息"><slot v-if="activeTab === 'task'" name="task" /></el-tab-pane>
<el-tab-pane name="route" label="路线货物与凭证"><slot v-if="activeTab === 'route'" name="route" /></el-tab-pane>
<el-tab-pane name="history" label="操作记录"><slot v-if="activeTab === 'history'" name="history" /></el-tab-pane>
</el-tabs>
</div>
</DetailPageLayout>
</AdminPage>
</template>
<style scoped>
.owned-business-detail-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-4); }
.owned-business-detail-heading > div { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: var(--admin-space-4); }
.owned-business-reference { overflow-wrap: anywhere; }
</style>
@@ -0,0 +1,20 @@
import type { OwnedBusinessCommand, OwnedBusinessDetail } from '../ownedBusinessTypes'
export function ownedBusinessFixture(): OwnedBusinessDetail {
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-OWN-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: 'OWN-1', ownership: 'OWNED' }, 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, actualDistanceMiles: null, fuelConsumptionLitresPer100Km: null, arrivals: [{ destinationKey: 'destination-1', actualArrivedAt: null }] }, unitPrice: null, recordVersion: '0', dispatchRecordVersion: '8',
photoSummary: { loadingCount: 0, fuelCount: 0 }, actions: { edit: { allowed: true, blockReason: null }, upload: { allowed: true, blockReason: null } }, sourceIssues: [],
},
dispatch: { dispatchNo: 'DS-OWN-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-OWN-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 ownedBusinessCommandFixture(overrides: Partial<OwnedBusinessCommand> = {}): OwnedBusinessCommand {
const detail = ownedBusinessFixture(); detail.row.recordVersion = overrides.recordVersion ?? '1'
return { dispatchNo: 'DS-OWN-1', action: 'SAVE_OPERATIONS', idempotencyKey: 'original-key', state: 'SUCCEEDED', recordVersion: '1', replayed: false, result: detail, errorCode: null, retryAllowed: false, ...overrides }
}
@@ -0,0 +1,48 @@
import { defineComponent } from 'vue'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import OwnedBusinessDetailLayout from './OwnedBusinessDetailLayout.vue'
import OwnedBusinessPhotosDialog from './OwnedBusinessPhotosDialog.vue'
import OwnedBusinessOperationsDialog from './OwnedBusinessOperationsDialog.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('OwnedBusiness presentation boundaries', () => {
it('mounts only the selected one of the three independent detail regions', async () => {
const wrapper = mount(OwnedBusinessDetailLayout, { global, props: { dispatchNo: 'DS-OWN-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 seven real prototype photo roles without demo images or uploaded claims', () => {
const wrapper = mount(OwnedBusinessPhotosDialog, { 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(OwnedBusinessPhotosDialog, { 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(OwnedBusinessOperationsDialog, { 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()
})
})
@@ -0,0 +1,16 @@
<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>
@@ -0,0 +1,35 @@
<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="owned-business-operation-reference">排车单号 {{ dispatchNo || '—' }}</p>
<slot name="state" />
<fieldset class="owned-business-operation-section"><legend>整车金额</legend><slot name="amount" /><p>整车只登记一次;单价采用系统返回的总金额与实际物理托数计算结果。</p></fieldset>
<fieldset class="owned-business-operation-section"><legend>逐目的地实际到达时间</legend><slot name="arrivals" /></fieldset>
<fieldset class="owned-business-operation-section"><legend>实际里程与油耗</legend><slot name="measurements" /></fieldset>
<p>登记运营信息不推进排车或派送状态;照片在独立上传照片窗口中处理。</p>
</AppDialog>
</template>
<style scoped>
.owned-business-operation-reference { overflow-wrap: anywhere; }
.owned-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); }
.owned-business-operation-section legend { padding: 0 var(--admin-space-2); font-weight: 600; }
</style>
@@ -0,0 +1,92 @@
<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 OwnedBusinessOperationsDialog from './OwnedBusinessOperationsDialog.vue'
import { newOwnedBusinessPending, persistOwnedBusinessPending, restoreOwnedBusinessPending, recoverOwnedBusinessCommand, type OwnedBusinessPending } from './OwnedBusinessCommandRecovery'
import { ownedBusinessDecimal, ownedBusinessVersion, type OwnedBusinessDetail, type OwnedBusinessMoney, type OwnedBusinessSave } from '../ownedBusinessTypes'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { getOwnedBusiness } from '../api/ownedBusiness'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ detail: OwnedBusinessDetail | null }>()
const emit = defineEmits<{ saved: [value: OwnedBusinessDetail]; busy: [value: boolean] }>()
const auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), pending = ref<OwnedBusinessPending | null>(null), error = ref<ApiErrorDetails | null>(null), corrupted = ref(false)
const form = reactive({ currency: '', amount: '', actualDistanceMiles: '', fuelConsumptionLitresPer100Km: '', reason: '', arrivals: {} as Record<string,string> })
const active = computed(() => open.value || Boolean(pending.value))
const registration = useUnsavedWorkRegistration({ key: `owned-business-operations:${getCurrentInstance()?.uid}`, label: computed(() => '自有业务运营登记'), isOpen: active, model: reactive({ form, pending }), requiredPermission: computed(() => permission.ownedBusinessEdit) })
let actor = '', number = '', baseline = ''
const writable = computed(() => !corrupted.value && access.can(permission.ownedBusinessEdit) && props.detail?.row.actions.edit.allowed === true)
const locked = computed(() => busy.value || Boolean(pending.value))
function persist() { persistOwnedBusinessPending(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 = restoreOwnedBusinessPending(actor, number, 'operations')
const operations = pending.value?.action === 'SAVE_OPERATIONS' ? (pending.value.body as OwnedBusinessSave).operations : props.detail.row.operations
Object.assign(form, { currency: operations.money?.currency || '', amount: operations.money?.amount ?? '', actualDistanceMiles: operations.actualDistanceMiles ?? '', fuelConsumptionLitresPer100Km: operations.fuelConsumptionLitresPer100Km ?? '', reason: pending.value ? (pending.value.body as OwnedBusinessSave).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(): OwnedBusinessSave {
const amount = ownedBusinessDecimal(form.amount, 2, 18)
let money: OwnedBusinessMoney | null = null
if (amount != null) { if (!['CNY','USD'].includes(form.currency)) throw new Error('请选择金额币种'); money = { currency: form.currency as OwnedBusinessMoney['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: ownedBusinessVersion(props.detail!.row.recordVersion), idempotencyKey: crypto.randomUUID(), operations: { money, actualDistanceMiles: ownedBusinessDecimal(form.actualDistanceMiles, 3, 12), fuelConsumptionLitresPer100Km: ownedBusinessDecimal(form.fuelConsumptionLitresPer100Km, 3, 12), 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 = newOwnedBusinessPending(actor, number, 'SAVE_OPERATIONS', payload()); persist() }
const result = await recoverOwnedBusinessCommand(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 getOwnedBusiness(number); ensureScope(); pending.value = null; persist(); emit('saved', current); error.value = null }
catch (cause) { error.value = getApiErrorDetails(cause, '当前运营版本核对失败') }
finally { busy.value = false }
}
</script>
<template>
<OwnedBusinessOperationsDialog 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="owned-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="owned-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>
<template #measurements><el-form label-position="top" class="owned-business-form"><el-form-item label="实际里程(mile)"><el-input v-model="form.actualDistanceMiles" :disabled="locked" placeholder="未登记" /></el-form-item><el-form-item label="油耗(L/100km)"><el-input v-model="form.fuelConsumptionLitresPer100Km" :disabled="locked" placeholder="手填耗油率,未登记留空" /></el-form-item></el-form></template>
</OwnedBusinessOperationsDialog>
</template>
<style scoped>
.owned-business-form { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 0 var(--admin-space-4); }
.owned-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); }
.owned-business-arrival small { display: block; }
</style>
@@ -0,0 +1,56 @@
<script setup lang="ts">
import AppDialog from '@/shared/components/AppDialog.vue'
const props = defineProps<{
dispatchNo?: string
busy?: boolean
beforeClose?: (done: () => void) => void | Promise<void>
}>()
const open = defineModel<boolean>({ default: false })
const activeTab = defineModel<'loading' | 'fuel'>('activeTab', { default: 'loading' })
// These are prototype presentation slots; the public file categories are not yet frozen.
const loadingPhotos = [
{ slot: 'loading-scene', label: '装车现场' },
{ slot: 'loading-list', label: '装车清单' },
{ slot: 'pre-departure-check', label: '出车前检查' },
{ slot: 'driver-license', label: '驾驶证附件' },
{ slot: 'delivery-receipt', label: '签收凭证' },
]
const fuelPhotos = [{ slot: 'fuel-start', label: '起始油耗表盘' }, { slot: 'fuel-end', label: '结束油耗表盘' }]
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="owned-business-photo-reference">排车单号 {{ dispatchNo || '—' }}</p>
<slot name="state" />
<p>每个附件方框单独处理,支持 JPG、PNG;上传照片不改变装车状态。</p>
<el-tabs v-model="activeTab" class="density-element-tabs density-contained-tabs">
<el-tab-pane name="loading" label="装车完成">
<div class="owned-business-photo-grid">
<section v-for="photo in loadingPhotos" :key="photo.slot" class="owned-business-photo-slot" :aria-label="photo.label">
<h3>{{ photo.label }}</h3><slot :name="photo.slot"><p>暂无照片</p></slot>
</section>
</div>
</el-tab-pane>
<el-tab-pane name="fuel" label="油耗图片">
<div class="owned-business-photo-grid">
<section v-for="photo in fuelPhotos" :key="photo.slot" class="owned-business-photo-slot" :aria-label="photo.label">
<h3>{{ photo.label }}</h3><slot :name="photo.slot"><p>暂无照片</p></slot>
</section>
</div>
</el-tab-pane>
</el-tabs>
</AppDialog>
</template>
<style scoped>
.owned-business-photo-reference { overflow-wrap: anywhere; }
.owned-business-photo-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-4); }
.owned-business-photo-slot { min-width: 0; padding: var(--admin-space-4); border: 1px solid var(--admin-table-border); }
.owned-business-photo-slot h3 { margin: 0 0 var(--admin-space-3); font-size: inherit; }
</style>
@@ -0,0 +1,52 @@
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 OwnedBusinessPhotosEditor from './OwnedBusinessPhotosEditor.vue'
import { ownedBusinessFixture } from './OwnedBusinessFixtures'
import type { OwnedBusinessPhoto } from '../ownedBusinessTypes'
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/ownedBusiness',()=>({uploadOwnedBusinessPhoto:calls.upload,bindOwnedBusinessPhoto:calls.bind,getOwnedBusinessCommand:calls.command,getOwnedBusiness:calls.detail,getOwnedBusinessPhotoContent: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:{OwnedBusinessPhotosDialog:Frame,ElButton:Button,LoadErrorState:{props:['message'],template:'<div>{{message}}<slot name="details" /></div>'},ApiErrorMeta:true}}
const photo:OwnedBusinessPhoto={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=ownedBusinessFixture();current.row.recordVersion='1';current.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}]
calls.bind.mockResolvedValue(current);calls.detail.mockResolvedValue(current)
})
describe('owned 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(OwnedBusinessPhotosEditor,{global,props:{modelValue:true,detail:ownedBusinessFixture()}});await choose(wrapper)
expect(calls.upload).toHaveBeenCalledTimes(1);expect(calls.bind).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('尚未确认绑定保存');expect(wrapper.emitted('saved')).toBeUndefined()
const original=structuredClone(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(OwnedBusinessPhotosEditor,{global,props:{modelValue:true,detail:ownedBusinessFixture()}});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=ownedBusinessFixture();detail.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}]
const wrapper=mount(OwnedBusinessPhotosEditor,{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()
})
})
@@ -0,0 +1,127 @@
<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 OwnedBusinessPhotosDialog from './OwnedBusinessPhotosDialog.vue'
import { ownedBusinessPhotoRoles, ownedBusinessVersion, type OwnedBusinessDetail, type OwnedBusinessPhoto, type OwnedBusinessPhotoSlot } from '../ownedBusinessTypes'
import { getOwnedBusiness, getOwnedBusinessPhotoContent, uploadOwnedBusinessPhoto } from '../api/ownedBusiness'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { acceptOwnedBusinessResult, newOwnedBusinessPending, ownedBusinessPendingKey, persistOwnedBusinessPending, restoreOwnedBusinessPending, recoverOwnedBusinessCommand, type OwnedBusinessPending } from './OwnedBusinessCommandRecovery'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ detail: OwnedBusinessDetail | null }>()
const emit = defineEmits<{ saved: [value: OwnedBusinessDetail]; busy: [value: boolean] }>()
const auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null), pending = ref<OwnedBusinessPending | null>(null), prepared = ref<OwnedBusinessPhoto | null>(null), corrupted = ref(false)
const urls = ref<Partial<Record<OwnedBusinessPhotoSlot,string>>>({}), reading = ref<OwnedBusinessPhotoSlot | null>(null)
const uploadable = computed(() => !corrupted.value && access.can(permission.ownedBusinessUpload) && props.detail?.row.actions.upload.allowed === true)
useUnsavedWorkRegistration({ key: `owned-business-photos:${getCurrentInstance()?.uid}`, label: computed(() => '自有业务照片作业'), isOpen: computed(() => open.value || Boolean(pending.value) || Boolean(prepared.value)), model: reactive({ pending, prepared }), requiredPermission: computed(() => permission.ownedBusinessUpload) })
let actor = '', number = '', generation = 0
function savedPhoto(slot: OwnedBusinessPhotoSlot) { 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() { persistOwnedBusinessPending(actor, number, 'photos', pending.value) }
function persistPrepared() {
const key = ownedBusinessPendingKey(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 = restoreOwnedBusinessPending(actor, number, 'photos')
const raw = sessionStorage.getItem(ownedBusinessPendingKey(actor, number, 'prepared-photo'))
prepared.value = raw ? JSON.parse(raw) as OwnedBusinessPhoto : null
if (prepared.value && (!prepared.value.fileId || !ownedBusinessPhotoRoles.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: OwnedBusinessPhotoSlot) {
const photo = savedPhoto(slot), current = generation
if (!photo || reading.value) return
reading.value = slot; error.value = null
try { const blob = await getOwnedBusinessPhotoContent(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: ownedBusinessVersion(props.detail.row.recordVersion), idempotencyKey: crypto.randomUUID(), fileId: prepared.value!.fileId }
pending.value = newOwnedBusinessPending(actor, number, 'BIND_PHOTO', body, prepared.value!.slot); persist()
}
const result = await recoverOwnedBusinessCommand(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 recoverOwnedBusinessCommand(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: OwnedBusinessPhotoSlot, 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 = newOwnedBusinessPending(actor, number, 'UPLOAD_PHOTO', null, slot); pending.value.attempted = true; persist()
const uploaded = await uploadOwnedBusinessPhoto(number, slot, file, pending.value.key)
ensureScope(); acceptOwnedBusinessResult(pending.value, uploaded); persist()
const result = await recoverOwnedBusinessCommand(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 getOwnedBusiness(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>
<OwnedBusinessPhotosDialog 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 ownedBusinessPhotoRoles" :key="role.slot" #[role.viewSlot]>
<img v-if="urls[role.slot]" :src="urls[role.slot]" :alt="role.label" class="owned-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="owned-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>
</OwnedBusinessPhotosDialog>
</template>
<style scoped>
.owned-business-photo { display: block; width: 100%; max-height: 160px; object-fit: contain; }
.owned-business-upload { display: block; margin-top: var(--admin-space-3); }
.owned-business-upload input { display: block; width: 100%; margin-top: var(--admin-space-2); }
</style>
@@ -25,7 +25,7 @@ describe('transport route address resolution', () => {
expect(wrapper.text()).toContain('目的地地址:' + addresses.ABE3)
expect(resolveRouteLocation).toHaveBeenCalledTimes(2)
const iframe = wrapper.get('iframe').element
await wrapper.get('button').trigger('click')
await wrapper.findAll('button').find(button => button.text() === '重新加载地图')!.trigger('click')
expect(wrapper.get('iframe').element).not.toBe(iframe)
expect(resolveRouteLocation).toHaveBeenCalledTimes(2)
await wrapper.setProps({ modelValue: false })
@@ -56,7 +56,7 @@ const tooLong = computed(() => mapUrl.value.length > 2048 || externalUrl.value.l
<el-select v-if="props.destinations.length > 1" v-model="destinationIndex" aria-label="选择目的仓">
<el-option v-for="(item, index) in props.destinations" :key="index" :label="item.label" :value="index" />
</el-select>
<p v-if="loading" role="status">正在查询仓库详细地址…</p>
<LoadErrorState v-else-if="error" title="无法加载路线" :message="error.message" @retry="retry"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-else-if="tooLong" role="status">地点信息过长,请核对起点和目的地地址后重试。</p>
@@ -65,7 +65,7 @@ const tooLong = computed(() => mapUrl.value.length > 2048 || externalUrl.value.l
referrerpolicy="no-referrer" />
</template>
<div v-if="ready && !tooLong" class="route-help">
<el-button @click="mapRevision++">重新加载地图</el-button>
<el-link :href="externalUrl" target="_blank" rel="noopener noreferrer" type="primary">在新窗口打开地图</el-link>
</div>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
@@ -147,10 +148,10 @@ onBeforeUnmount(() => { generation++; searchSequence++; quoteSequence++; clearTi
</script>
<template>
<div>
<p>询价不占用车辆空间;确认承运后预留容量。实收时核对运费,转派送时按确认报价登记应收,付款状态以财务为准。</p>
<LoadErrorState v-if="error" title="外接操作未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<DataTable :data="records" :columns="columns" :loading="loading" row-key="bookingNo" empty-text="暂无外接预订">
<template #toolbar-actions><el-button :disabled="busy" @click="load">刷新预订</el-button><el-button v-if="planned && access.can(permission.tripBookingCreate)" type="primary" :disabled="busy" @click="edit()">{{ pendingCreate ? '继续原登记' : '登记外接询价' }}</el-button></template>
<template #toolbar-actions><el-button :disabled="busy" @click="load">刷新预订</el-button><el-button v-if="planned && access.can(permission.tripBookingCreate)" type="primary" :disabled="busy" @click="edit()">{{ pendingCreate ? '继续原登记' : '登记外接询价' }}</el-button><InlineHelp title="外接预订说明"><p>询价不占位,确认承运后预留容量。实收时核对运费,转派送后按确认报价登记应收,付款状态以财务为准。</p></InlineHelp></template>
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" :disabled="busy" @click="edit(row as TripBooking, true)">详情</el-button><el-button v-if="!row.offerNo && planned && row.status === 'DRAFT' && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy || !!pendingCreate" @click="edit(row as TripBooking)">编辑</el-button><el-button v-if="!row.offerNo && planned && row.status === 'DRAFT' && access.can(permission.tripBookingConfirm)" class="density-action" :disabled="busy" @click="command(row as TripBooking, 'confirm')">确认承运</el-button><el-button v-if="!row.offerNo && planned && ['DRAFT','CONFIRMED','RECEIVED'].includes(row.status) && access.can(permission.tripBookingCancel)" class="density-action" data-tone="danger" :disabled="busy" @click="command(row as TripBooking, 'cancel')">取消预订</el-button><el-button v-if="row.incomingNo" class="density-action" @click="fulfill(row as TripBooking, 'view')">来货详情</el-button><el-button v-if="planned && row.status === 'CONFIRMED' && !row.incomingNo && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'incoming')">登记来货</el-button><el-button v-if="canConfirmReceipt(row as TripBooking)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'receipt')">核对实收</el-button><el-button v-if="planned && row.status === 'RECEIVED' && access.can(permission.tripBookingEdit) && access.can(permission.tripEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'bind')">关联排车</el-button></div></template>
<template #status="{ row }">{{ bookingStatusLabels[row.status as keyof typeof bookingStatusLabels] }}</template>
<template #cargo="{ row }">{{ row.receipt ? '实收' : '预计' }} · {{ row.cargo.packageCount ?? '—' }} 箱 · {{ row.expectedPalletCount ?? '—' }} 实托 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</template>
@@ -177,9 +178,9 @@ onBeforeUnmount(() => { generation++; searchSequence++; quoteSequence++; clearTi
<p v-if="quoteSource">来源:{{ quoteSource.quoteNo }} · 版本 {{ quoteSource.recordVersion }} · {{ quoteSource.originCity }} → {{ quoteSource.destinationCode }} · 适用日期 {{ quoteSource.effectiveOn }}<br>来源单价:{{ quoteSource.quotedUnitPrice.toFixed(2) }} {{ quoteSource.currency }} / {{ quoteSource.priceBasis === 'PALLET' ? '托位' : '单' }} · 有效期 {{ quoteSource.validFrom }} 至 {{ quoteSource.validTo }}</p>
<p v-else>{{ quoteRef ? `已选报价 ${quoteRef.quoteNo} · 版本 ${quoteRef.recordVersion}` : '手工询价,尚未选择报价库来源。' }}</p>
<template v-if="!readOnly && canReadQuotes">
<el-button :disabled="quoteLocked || !canQueryQuotes || quoteLoading" @click="queryQuotes()">查询报价库</el-button>
<el-button :disabled="quoteLocked || !canQueryQuotes || quoteLoading" @click="queryQuotes()">查询报价库</el-button><InlineHelp title="报价来源说明"><p>来源报价与实际约定价格分别保存;确认承运时重新核对报价版本与适用日期。可选择手工询价。</p></InlineHelp>
<el-button v-if="quoteRef" :disabled="quoteLocked" @click="clearQuote">改为手工询价</el-button>
<p v-if="!canQueryQuotes">选择客户、FBA卸货站及CNY或USD后可查询;其他场景可继续填写手工价格。</p>
<p v-if="!canQueryQuotes">查询报价需客户、FBA卸货站和 CNY/USD。</p>
<el-select v-if="candidates.length" data-testid="booking-quote-candidate" :model-value="quoteRef ? sourceKey(quoteRef) : ''" :disabled="quoteLocked || quoteLoading" placeholder="选择报价后带入单价,可另行议价" @update:model-value="selectQuote">
<el-option v-for="item in candidates" :key="sourceKey(item)" :value="sourceKey(item)" :label="`${item.quoteNo} · ${item.quotedUnitPrice.toFixed(2)} ${item.currency} · ${item.validFrom} 至 ${item.validTo}`" />
</el-select>
@@ -187,7 +188,7 @@ onBeforeUnmount(() => { generation++; searchSequence++; quoteSequence++; clearTi
<div v-if="quotePages > 1"><el-button :disabled="quoteLoading || quotePage <= 1" @click="queryQuotes(quotePage - 1)">上一页</el-button> {{ quotePage }} / {{ quotePages }} <el-button :disabled="quoteLoading || quotePage >= quotePages" @click="queryQuotes(quotePage + 1)">下一页</el-button></div>
<p v-if="quoteError" role="alert">{{ quoteError.message }} <ApiErrorMeta :details="quoteError" /></p>
</template>
<p v-if="quoteRef">来源报价与实际约定价格分别保存;确认承运时会重新核对报价版本与适用日期。</p>
</section>
<p>约定运费:{{ quotedAmount == null ? '待报价' : `${quotedAmount.toFixed(2)} ${form.currency || ''}` }}。保存询价不会占位,确认承运时核对本车剩余容量。</p>
</AppDialog>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
@@ -139,7 +140,7 @@ onBeforeUnmount(() => { scopeGeneration++; generation++; historyGeneration++ })
<section aria-label="车次直接成本">
<p v-if="!allowed">暂无车次成本查看权限。</p>
<template v-else>
<p class="cost-note">按实际费用登记,金额按币种分别汇总。运营分摊用于单趟分析,不代表已付款,也不生成客户账单。</p>
<LoadErrorState v-if="error" title="车次成本加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-if="summary">
<p v-if="!summary.totals.length" class="cost-note">尚未登记直接成本。</p>
@@ -154,7 +155,7 @@ onBeforeUnmount(() => { scopeGeneration++; generation++; historyGeneration++ })
</div>
</template>
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="costNo" empty-text="暂无直接成本记录">
<template #toolbar-actions><el-button :disabled="loading || saving" @click="load">刷新</el-button><el-button v-if="access.can(permission.tripCostCreate)" type="primary" :disabled="opening || saving" @click="beginEdit(null)">登记成本</el-button></template>
<template #toolbar-actions><el-button :disabled="loading || saving" @click="load">刷新</el-button><el-button v-if="access.can(permission.tripCostCreate)" type="primary" :disabled="opening || saving" @click="beginEdit(null)">登记成本</el-button><InlineHelp title="直接成本说明"><p>按实际费用登记,各币种分别汇总。运营分摊用于单趟分析,不代表已付款,也不生成客户账单。</p></InlineHelp></template>
<template #actions="{ row }"><RowActions :actions="actions()" @action="$event === 'edit' ? beginEdit(row as TripCost) : showHistory(row as TripCost)" /></template>
<template #category="{ row }">{{ categories[row.details.category as CostCategory] }}</template>
<template #amount="{ row }">{{ row.details.currency }} {{ row.details.amount.toFixed(2) }}</template>
@@ -93,13 +93,13 @@ watch([tab, page, size], () => { clearPreview(); if (tab.value === 'history') vo
<el-form-item v-if="!readonly" label="更正说明(选填)" class="full"><el-input v-model="reason" maxlength="1000" placeholder="选填,留空使用标准更正说明" /></el-form-item>
<el-form-item label="实测凭证(选填)" class="full"><template #label><span>实测凭证(选填)<InlineHelp title="实测凭证说明"><p>PDF、JPG、PNG,单个不超过10MiB,最多20个;不上传也可保存。</p></InlineHelp></span></template>
<input v-if="!readonly" data-testid="evidence-input" type="file" accept="application/pdf,image/jpeg,image/png" aria-label="选择实测凭证" :disabled="!record || loading || saving || uploading || uncertain || uploadUncertain || form.evidenceFiles.length >= 20" @change="chooseFile" />
</el-form-item>
</el-form>
<ul v-if="form.evidenceFiles.length" class="evidence-list"><li v-for="file in form.evidenceFiles" :key="file.fileId"><el-button :disabled="previewLoading || !savedFileIds.has(file.fileId)" @click="preview(file)">{{ file.fileName }}</el-button><span v-if="!savedFileIds.has(file.fileId)">(保存后可预览)</span><el-button v-if="!readonly" :disabled="saving || uploading || uncertain" @click="removeFile(file.fileId)">移除引用</el-button></li></ul>
<p v-if="uploading">正在上传 {{ uploadName }}…</p>
<p v-if="uploadUncertain">{{ uploadName }} 的上传结果未确认,可能已上传但尚未绑定。请先核对,不要重复上传。<el-button :disabled="uploading" @click="omitUpload">暂不绑定此附件</el-button></p>
</el-tab-pane><el-tab-pane label="更正历史" name="history"><DataTable v-model:page="page" v-model:page-size="size" :data="history" :columns="columns" :total="total" :loading="loading" row-key="recordVersion" empty-text="暂无实测更正历史"><template #distance="{ row: item }">{{ item.details.actualDistanceMiles ?? '—' }}</template><template #fuel="{ row: item }">{{ item.details.actualFuelLitres ?? '—' }}</template><template #remark="{ row: item }">{{ item.details.remark || '—' }}</template><template #evidence="{ row: item }"><el-button v-for="file in item.details.evidenceFiles" :key="file.fileId" :disabled="previewLoading" @click="preview(file)">{{ file.fileName }}</el-button><span v-if="!item.details.evidenceFiles.length">—</span></template></DataTable></el-tab-pane></el-tabs>
<section v-if="previewUrl && previewFile" aria-label="实测凭证预览"><el-button @click="clearPreview">关闭预览</el-button><iframe v-if="previewFile.contentType === 'application/pdf'" :src="previewUrl" :title="previewFile.fileName" class="evidence-preview" /><img v-else :src="previewUrl" :alt="previewFile.fileName" class="evidence-preview" /></section>
<template #footer><el-button :disabled="saving" @click="open = false">关闭</el-button><el-button v-if="!readonly && tab === 'measured'" type="primary" :loading="saving" :disabled="!record || loading || uploading || uploadUncertain" @click="save">{{ uncertain ? '按原请求重试' : '保存记录' }}</el-button></template>
@@ -121,7 +121,7 @@ onBeforeUnmount(() => { editSession++ })
<el-form-item v-if="costRecord" label="费用(申报金额)"><template #label><span>费用(申报金额)<InlineHelp title="申报费用说明"><p>此处保存申报费用;保存后可从记录行“财务”核对并提交公司成本。处理完成不会标记为已入账或已付款。</p></InlineHelp></span></template><el-input-number v-model="form.incident.reportedCost" :min="0" :precision="2" placeholder="未确定时留空" /></el-form-item>
<el-form-item v-if="costRecord" label="币种" :required="form.incident.reportedCost != null"><el-select v-model="form.incident.currency" filterable allow-create default-first-option clearable placeholder="请选择或输入三位币种"><el-option v-for="currency in ['USD','CNY','CAD','EUR','GBP','MXN']" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item>
<el-form-item label="处理状态"><el-select v-model="form.incident.processingStatus"><el-option v-for="(label, key) in processingLabels" :key="key" :label="label" :value="key" /></el-select></el-form-item>
</template>
<el-form-item v-if="meterRecord" label="核验状态"><el-select v-model="form.verificationStatus"><el-option label="待核验" value="PENDING" /><el-option label="已核验" value="VERIFIED" /></el-select></el-form-item>
<el-form-item label="备注" class="full-width"><el-input v-model="form.remark" type="textarea" :rows="2" maxlength="1000" placeholder="补充本次记录的其他情况(选填)" /></el-form-item>
@@ -194,7 +194,7 @@ onBeforeUnmount(() => { request++; operationEpoch++; supplierRequest++; disposed
<VehicleCareEditor v-model="editorOpen" :vehicle-id="vehicleId" :plate="plate" :record-type="recordType" :record="editing" @saved="saved" />
<AppDialog v-model="financeOpen" title="公司车辆成本" width="620px" class="admin-density-dialog" confirm-text="提交财务"
:show-confirm="canSubmitFinance || !!pendingFinance" :confirm-loading="financeSaving" :confirm-disabled="ledgerBusy || !access.can(vehicleCareFinanceSubmitPermission)" @confirm="submitFinance">
<p v-if="financeTarget">{{ financeTarget.recordNo }} · {{ financeTarget.vehiclePlate }} · 申报费用 {{ financeTarget.details.incident?.currency || '—' }} {{ financeTarget.details.incident?.reportedCost?.toFixed(2) ?? '—' }}</p>
<p v-if="financeStatus">{{ vehicleCareFinanceLabels[financeStatus.status] }}</p>
<template v-if="financeStatus?.finance">