fix: align audit and dispatch workflows and release failed requests

This commit is contained in:
2026-10-22 23:10:09 +08:00
parent 9cdcb9c077
commit 4a3456756f
124 changed files with 1296 additions and 2120 deletions
+3 -3
View File
@@ -15,7 +15,7 @@
- 车队报价`pages/FleetQuotesView.vue`、`api/fleetQuotes.ts`沿`fleet-quotes-v1.yaml`区分采购/销售;原型通用菜单,编辑780px、CSV导入1040px,未知价格与零价分开。出车记录`pages/TripOperationsView.vue`、`api/tripOperations.ts`沿`fleet-operations-v1.yaml`按实际DEPART查询,列表/图表同筛选,缺少成本或财务事实不推为零;分别使用fleet:quote:list与fleet:operations:list,写入/导出独立权限。
- 车次“直接成本”页签:`components/TripCostPanel.vue`、`api/tripCosts.ts`,选择页签才读取一次分页及整趟汇总;登记/更正分别使用trip:cost-create/cost-edit,历史沿trip:list。编辑先取最新历史快照版本;不修改内容不写入,未知新增结果保留原键及内容重试。按原币种展示,不将未登记或未分摊当零,不推定财务付款;费用表操作列在左,分摊表只读。后端合同为`fleet-trip-costs-v1.yaml`。
- 车次“直接成本”页签:`components/TripCostPanel.vue`、`api/tripCosts.ts`,选择页签才读取一次分页及整趟汇总;登记/更正分别使用trip:cost-create/cost-edit,历史沿trip:list。编辑先取最新历史快照版本;不修改内容不写入,请求失败即释放提交态并保留表单,下一次点击按当前输入发起新请求,不保存失败请求用于重放。按原币种展示,不将未登记或未分摊当零,不推定财务付款;费用表操作列在左,分摊表只读。后端合同为`fleet-trip-costs-v1.yaml`。
- 车辆概览中的派送与更换记录由`components/VehicleFulfillmentTable.vue`、`api/vehicleFulfillment.ts`读取,替代原提示文字。派送要求vehicle:list与delivery:list,更换要求vehicle:list与trip:list;无对应权限不请求接口。两个可见分区各一次分页请求,不逐行加载;换车/换页忽略旧响应,错误保留重试及诊断。更换显示原/新车辆与司机、历史车次及排车业务号;现有快照没有操作人姓名时显示“—”,不用内部操作者ID回退。
@@ -24,7 +24,7 @@
- 共享车次接口在 `api/trips.ts`,类型在 `tripTypes.ts`,后端合同为 `fleet-trips-v1.yaml`;覆盖计划/分配、客户排车关联、实际装载、货物结案和分别结束车辆/司机任务。接口保留稳定幂等键、版本与货物hash,长ID按字符串透传;数量和容量展示服务端结果,不在浏览器推算整车可发或可结案。新页面承载按 `PROTOTYPE_MATRIX.md` 确定,不因API存在推导页面能力。
- 当前日常作业统一从排车列表进入,侧栏`fleet-trips`为hidden;“出车记录”保留实际发车后的运营查询。原`pages/TripsView.vue`、`pages/TripDetailView.vue`及附加能力代码保留,不能作为新排车的前置步骤。旧详情仍按页签按需读取,资源候选聚焦或搜索时查询,不逐行读取。新整车审核、PDA真实拣货、双端装车确认及实际发车规则以`../../../../../docs/海外仓业务/排车列表统一作业改造契约.md`为准;详情只有已确认四页签,不把旧八项附加功能自动迁入或以派送完成自动释放资源。
- 车辆实际托位/载重kg/容积m³分别由服务端返回,三项同填正数或全空待确认;不按车型名称推算。占用期间主档关键变更由服务端拒绝,下载中心 XLSX 导出使用车牌业务标识并包含容量字段。
- 车辆运维在 `VehicleDetailView.vue` 的页签内,表格与编辑框为 `components/VehicleCareTable.vue`、`VehicleCareEditor.vue`;`api/vehicleCare.ts` 对接 `fleet-vehicle-records-v1.yaml`。保养/加油680px,维修/故障/救援/异常620px居中弹框,行操作按 `PROTOTYPE_MATRIX.md` 的逐类型入口执行。进入页签才分页查询,保养加油图片点击才鉴权读取并在新标签页预览;卸载释放Blob。车辆list包含记录只读,record-create/edit/upload独立;未知保存结果保留原请求键,照片重试不重复建记录。处理状态与付款状态独立,费用未填和零值区分;无财务关联时只显示服务端UNLINKED,不自动修改车辆公里码、状态或财务。
- 车辆运维在 `VehicleDetailView.vue` 的页签内,表格与编辑框为 `components/VehicleCareTable.vue`、`VehicleCareEditor.vue`;`api/vehicleCare.ts` 对接 `fleet-vehicle-records-v1.yaml`。保养/加油680px,维修/故障/救援/异常620px居中弹框,行操作按 `PROTOTYPE_MATRIX.md` 的逐类型入口执行。进入页签才分页查询,保养加油图片点击才鉴权读取并在新标签页预览;卸载释放Blob。车辆list包含记录只读,record-create/edit/upload独立;保存失败即恢复编辑、关闭及重新提交,不保存失败请求;已确认创建成功的记录和附件继续使用返回的业务身份。处理状态与付款状态独立,费用未填和零值区分;无财务关联时只显示服务端UNLINKED,不自动修改车辆公里码、状态或财务。
- 维修/故障/救援的“更新处理”已获用户确认,复用原620px弹框及record-edit权限;更正说明选填,空值由页面填标准动作说明,不移除服务端不可变历史。
- 司机列表 `pages/DriversView.vue`、详情 `pages/DriverDetailView.vue`,编辑和证照预览在 `components/DriverEditorDialog.vue`、`DriverLicensePreview.vue`。详情独立路由;编辑居中1080px三列表单;预览居中620px。
- 列表与统计使用同一次服务端响应;统计范围与列表筛选一致,30天提醒按证照时区计算。页面不能用当前分页行数替代总统计,也不能在浏览器重算派车资格。
@@ -37,7 +37,7 @@
- 车次详情“计划容量”页签按需读写`/capacity-plan`,复用`TripLoadingPanel`的计划模式,预计重量/体积与实际装载分别保存;只在该页签展示逐段计划余位。`planValid=false`时不展示旧余位,货物变化后重新核对;保存计划只更新车次版本,不把理货审核变成通过或写入实载。
- `TripBookingPanel.vue`在车次详情“外接预订”页签按需读取列表;1080px居中弹框登记/编辑或只读详情,客户聚焦搜索才读取`/fleet/trips/customer-options`。询价只要求客户、既有卸货站和箱数,载荷/价格/联系信息可后补;确认承运由服务端校验余位及完整报价。create/edit/confirm/cancel分别使用独立`trip:booking-*`权限,读操作沿trip:list。未知创建及确认结果保留原请求重试,成功用命令结果更新列表和车次版本;计划容量表展示availableSegments(已扣除外接占位),不使用旧自有货余位覆盖。
- `TripBookingPanel.vue`在车次详情“外接预订”页签按需读取列表;1080px居中弹框登记/编辑或只读详情,客户聚焦搜索才读取`/fleet/trips/customer-options`。询价只要求客户、既有卸货站和箱数,载荷/价格/联系信息可后补;确认承运由服务端校验余位及完整报价。create/edit/confirm/cancel分别使用独立`trip:booking-*`权限,读操作沿trip:list。创建及确认失败即释放提交态,下一次点击使用当前输入及新请求键,成功用命令结果更新列表和车次版本;计划容量表展示availableSegments(已扣除外接占位),不使用旧自有货余位覆盖。
- 外接履约弹框`TripBookingFulfillmentDialog.vue`为1080px居中:登记来货、按服务器实托/receiptHash核对实收与约定价格、输入客户排车单号关联;来货详情只读。只有显式打开来货/实收弹框才查询来源,不逐行请求。关联后刷新外接列表及车次详情,更新全部排车成员和理货审核状态;预订取消不删除来货或库存。
@@ -57,7 +57,7 @@ export async function createExchangeRate(payload: { month: string; usdToCnyAvera
export async function updateExchangeRate(month: string, payload: { recordVersion: number; usdToCnyAverage: number; remark: string | null }) { return normalizeExchangeRate(unwrapApiResponse(await http.put<ApiEnvelope<ExchangeRateWire>, ApiEnvelope<ExchangeRateWire>>(`${base}/exchange-rates/${encodeURIComponent(month)}`, payload))) }
export async function changeExchangeRateStatus(month: string, status: MasterDataStatus, recordVersion: number, remark: string | null) { return normalizeExchangeRate(unwrapApiResponse(await http.patch<ApiEnvelope<ExchangeRateWire>, ApiEnvelope<ExchangeRateWire>>(`${base}/exchange-rates/${encodeURIComponent(month)}/status`, { status, recordVersion, remark }))) }
export interface VehicleQuery { page: number; size: number; keyword?: string; ownership?: VehicleOwnership; region?: VehicleRegion; status?: VehicleStatus }
export interface VehicleQuery { page: number; size: number; keyword?: string; ownership?: VehicleOwnership; region?: VehicleRegion; status?: VehicleStatus; dispatchNo?: string }
type VehicleEditableFields = Omit<VehicleDetail, 'id' | 'recordVersion' | 'updatedBy' | 'updatedAt' | 'status'>
export type VehicleCreate = VehicleEditableFields & Pick<VehicleDetail, 'status'>
export type VehicleUpdate = VehicleEditableFields & Pick<VehicleDetail, 'recordVersion'>
@@ -64,7 +64,7 @@ export async function listVehicleCareFinance(vehicleId: string, recordNos: strin
export async function submitVehicleCareFinance(vehicleId: string, recordNo: string, request: VehicleCareFinanceSubmit): Promise<VehicleCareFinanceSubmitted> {
type Wire = Omit<VehicleCareFinanceSubmitted, 'sourceVersion' | 'submittedRecordVersion'> & { sourceVersion: Numeric; submittedRecordVersion: Numeric }
const response = unwrapApiResponse(await http.post<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(`${path(vehicleId, recordNo)}/finance`, request))
if (response.recordNo !== recordNo || response.status !== 'PENDING_FINANCE') throw new Error('财务提交响应不匹配,请使用原请求重试')
if (response.recordNo !== recordNo || response.status !== 'PENDING_FINANCE') throw new Error('财务提交响应不匹配,请刷新当前记录')
return { ...response, sourceVersion: financeVersion(response.sourceVersion), submittedRecordVersion: financeVersion(response.submittedRecordVersion) }
}
export async function listVehicleCareSuppliers(keyword = ''): Promise<Array<{ id: string; name: string }>> {
@@ -93,7 +93,7 @@ it('locks the destination in readonly follow-up even without offer detail, while
expect(saveCapacityInquiry).not.toHaveBeenCalled()
})
it('matches an existing looseboard order without inventing space, arrival or agreed price and retries the same source', async () => {
it('matches an existing looseboard order without inventing space, arrival or agreed price and submits a fresh command after failure', 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'))
@@ -107,6 +107,6 @@ it('matches an existing looseboard order without inventing space, arrival or agr
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(saveCapacityInquiry).toHaveBeenNthCalledWith(2, offer.offerNo, null, { ...body, idempotencyKey: expect.any(String) }); expect(vi.mocked(saveCapacityInquiry).mock.calls[1]![2].idempotencyKey).not.toBe(body.idempotencyKey)
expect(listOfferCustomers).not.toHaveBeenCalled()
})
@@ -54,7 +54,7 @@ const lock = computed(() => !writable.value || busy.value || !!pending.value)
const cargoLabel = computed(() => inquiryCargoLabel(props.inquiry?.status))
const selectedCapacity = computed(() => props.offer?.capacityKnown ? props.offer.destinations.find(stop => stop.destinationKey === form.cargo.destinationKey)?.remaining : null)
const quoteReady = computed(() => !!form.customerId && form.cargo.destinationKey.startsWith('FBA:') && ['CNY', 'USD'].includes(form.cargo.currency || ''))
const guard = useFleetDraftGuard('capacity-inquiry-editor', open, form, busy, computed(() => !!pending.value))
const guard = useFleetDraftGuard('capacity-inquiry-editor', open, form, busy)
let generation = 0, customerSequence = 0, quoteSequence = 0
let timer: ReturnType<typeof setTimeout> | undefined
function resetQuotes() { quoteSequence++; quoteRows.value = []; quoteSearched.value = false; quoteError.value = null; quoteLoading.value = false; quotePage.value = 1; quotePages.value = 0 }
@@ -108,12 +108,12 @@ async function save() {
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 } }
finally { if (current === generation) busy.value = false }
finally { pending.value = null; if (current === generation) busy.value = false }
}
onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clearTimeout(timer) })
</script>
<template>
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog admin-workbench is-dialog" destroy-on-close :title="readonly ? '客户货物跟进' : inquiry ? '补充客户货物资料' : '登记客户货物'" width="1080px" :show-confirm="writable" :confirm-text="pending ? '重试原请求' : confirmMode ? '确认承运并保位' : '保存登记'" :confirm-loading="busy" @confirm="save">
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog admin-workbench is-dialog" destroy-on-close :title="readonly ? '客户货物跟进' : inquiry ? '补充客户货物资料' : '登记客户货物'" width="1080px" :show-confirm="writable" :confirm-text="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="sourceDemand">散板原单:{{ sourceDemand.orderNo }} · {{ sourceDemand.origin }} → {{ sourceDemand.destination }}。请核对卸货站、标准托位和预计到仓时间。</p>
<p v-if="confirmMode">核对本票货量、价格与交货资料后确认承运。成功将保留货位并关联本土订单,不自动安排仓库收货。</p>
@@ -123,7 +123,7 @@ onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clear
<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>
@@ -49,15 +49,15 @@ it('does not publish if capacity validation is incomplete', async () => {
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(offers.saveCapacityOffer).not.toHaveBeenCalled(); expect(offers.commandCapacityOffer).not.toHaveBeenCalled()
})
it('retries only the uncertain publish command with the original key', async () => {
it('releases a failed publish and submits current settings with a new key', async () => {
vi.mocked(offers.commandCapacityOffer).mockRejectedValueOnce(new Error('network'))
const wrapper = mount(); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
const first = vi.mocked(offers.commandCapacityOffer).mock.calls[0]!
expect(wrapper.findComponent({ name: 'AppDialog' }).props('confirmText')).toBe('重试原请求')
expect(wrapper.findComponent({ name: 'AppDialog' }).props('confirmText')).toBe('确认发布')
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(offers.commandCapacityOffer).toHaveBeenNthCalledWith(2, ...first)
expect(state.plan).toHaveBeenCalledOnce(); expect(offers.saveCapacityOffer).toHaveBeenCalledOnce()
expect(offers.commandCapacityOffer).toHaveBeenNthCalledWith(2, first[0], first[1], { ...first[2], idempotencyKey: expect.any(String) }); expect(vi.mocked(offers.commandCapacityOffer).mock.calls[1]![2].idempotencyKey).not.toBe(first[2].idempotencyKey)
expect(state.plan).toHaveBeenCalledTimes(2); expect(offers.saveCapacityOffer).toHaveBeenCalledTimes(2)
})
it('submits measurements from the embedded panel before saving and publishing', async () => {
@@ -30,7 +30,7 @@ const form = reactive({ cutoffAt: '', contactName: '', contactPhone: '', remark:
const pending = ref<{ action: 'save'; offerNo: string | null; body: OfferWrite } | { action: 'publish' | 'pause' | 'resume' | 'close'; offerNo: string; body: OfferCommand } | null>(null)
let generation = 0
const writable = computed(() => access.can('platform:fleet:capacity-offer:manage'))
const guard = useFleetDraftGuard('capacity-offer-settings', open, reactive({ form, planState }), busy, computed(() => !!pending.value))
const guard = useFleetDraftGuard('capacity-offer-settings', open, reactive({ form, planState }), busy)
async function load() {
if (!writable.value || !open.value || pending.value) return
const current = ++generation; loading.value = true; error.value = null; loaded.value = false
@@ -69,7 +69,7 @@ async function execute() {
else ElMessage.success('发布设置已更新')
}
catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '发布操作未完成'); if (cause instanceof ApiResponseError) pending.value = null } }
finally { if (current === generation) busy.value = false }
finally { pending.value = null; if (current === generation) busy.value = false }
}
async function matchExistingStops() {
if (busy.value || !canPlan.value || pending.value) return
@@ -86,7 +86,7 @@ async function matchExistingStops() {
currentTrip.value = result.trip; emit('tripUpdated', result.trip); emit('saved', result.trip.recordVersion)
if (offer.value) offer.value = { ...offer.value, tripRecordVersion: result.trip.recordVersion }
} catch (cause) { error.value = getApiErrorDetails(cause, '卸货站匹配未完成') }
finally { busy.value = false }
finally { pending.value = null; busy.value = false }
}
function planChanged(value: string) {
planState.value = value
@@ -100,7 +100,7 @@ async function save() {
if (new Date(form.cutoffAt).getTime() <= Date.now() || new Date(form.cutoffAt).getTime() > new Date(currentTrip.value.plannedDepartureAt).getTime()) { ElMessage.warning('交货截止须晚于现在且不晚于计划发车'); return }
busy.value = true
let plan
try { plan = await planner.value?.save() } finally { busy.value = false }
try { plan = await planner.value?.save() } finally { pending.value = null; busy.value = false }
if (!plan) return
currentTrip.value = { ...currentTrip.value, recordVersion: plan.recordVersion }
emit('saved', plan.recordVersion)
@@ -118,9 +118,9 @@ async function command(action: 'publish' | 'pause' | 'resume' | 'close') {
}
onBeforeUnmount(() => { generation++ })
</script>
<template><AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog admin-workbench is-dialog" title="发布拼车余位" width="1080px" :show-confirm="writable && (!offer || offer.actions.canEdit || !!pending)" :confirm-text="pending ? '重试原请求' : '确认发布'" :confirm-loading="busy" :confirm-disabled="loading || !loaded" @confirm="save">
<template><AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog admin-workbench is-dialog" title="发布拼车余位" width="1080px" :show-confirm="writable && (!offer || offer.actions.canEdit || !!pending)" :confirm-text="'确认发布'" :confirm-loading="busy" :confirm-disabled="loading || !loaded" @confirm="save">
<LoadErrorState v-if="error" title="发布操作未完成" :message="error.message" @retry="pending ? execute() : load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<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>
<p>{{ trip.tripNo }} · {{ offer ? `${offer.offerNo} · ${offerStatusLabels[offer.status]}` : '尚未建立发布' }}</p><p v-if="offer?.blockReason && offer.status !== 'DRAFT'">{{ offer.blockReason }}</p>
<template v-if="loaded && open && (!offer || offer.actions.canEdit)">
<h3>本车货物与余位<InlineHelp title="容量发布说明"><p>卸货站已按货物匹配。核对下方数据,确认发布时一并保存容量;多个目的地的先后顺序请在车次计划中确认。</p></InlineHelp></h3>
@@ -33,12 +33,12 @@ it('HTTP 200/A0409 unlocks the form so corrected content receives a new key', as
const corrected = vi.mocked(api.createFleetQuote).mock.calls[1]!
expect(corrected[0]).not.toBe(original[0]); expect(corrected[1].originCity).toBe('QA-Another-Origin'); expect(corrected[1].fullDeliveryPrice).toBe(0)
})
it('unknown creation retains its exact key after viewing another quote', async () => {
it('unknown creation unlocks the current form and sends edited values with a fresh key', async () => {
const wrapper = await mount(); vi.mocked(api.createFleetQuote).mockRejectedValueOnce(new Error('Network unavailable'))
await clickSave(wrapper); const original = vi.mocked(api.createFleetQuote).mock.calls[0]!
const other: api.FleetQuote = { quoteNo: 'FQ-OTHER', details: { ...original[1], originCity: 'OTHER' }, recordVersion: '2', partyName: '测试客户', status: 'ACTIVE', updatedAt: '' }
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ quote: other, readonly: true, modelValue: true }); await flushPromises()
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ quote: null, readonly: false, modelValue: true }); await flushPromises()
expect(wrapper.text()).toContain('按原请求重试'); vi.mocked(api.createFleetQuote).mockResolvedValue({ ...other, quoteNo: 'FQ-NEW', details: original[1] })
await clickSave(wrapper); expect(vi.mocked(api.createFleetQuote).mock.calls[1]).toEqual(original)
expect(wrapper.findComponent({ name: 'ElForm' }).props('disabled')).toBe(false)
wrapper.findAllComponents({ name: 'ElInput' }).find(input => input.attributes('placeholder') === '请输入发货城市')!.vm.$emit('update:modelValue', 'CHANGED')
await clickSave(wrapper)
const next = vi.mocked(api.createFleetQuote).mock.calls[1]!
expect(next[0]).not.toBe(original[0]); expect(next[1].originCity).toBe('CHANGED')
})
@@ -6,15 +6,13 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import FleetQuotePartySelect from './FleetQuotePartySelect.vue'
import { createFleetQuote, updateFleetQuote, isFleetCommandRejected, type FleetQuote, type FleetQuoteDraft, type FleetQuoteKind } from '../api/fleetQuotes'
import { createFleetQuote, updateFleetQuote, type FleetQuote, type FleetQuoteDraft, type FleetQuoteKind } from '../api/fleetQuotes'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ quote: FleetQuote | null; readonly?: boolean; kind: FleetQuoteKind }>()
const emit = defineEmits<{ saved: [] }>()
type QuoteForm = Omit<FleetQuoteDraft, 'distanceMiles'> & { distanceMiles: number | null }
const empty = (): QuoteForm => ({ kind: props.kind, partyId: '', originCity: '', destinationCode: '', distanceMiles: null, currency: 'USD', selfPickupPrice: null, fullDeliveryPrice: null, looseDeliveryPrice: null, validFrom: new Date().toISOString().slice(0, 10), validTo: '', remark: null })
const form = reactive<QuoteForm>(empty()), saving = ref(false), error = ref<ApiErrorDetails | null>(null)
let pending: { idempotencyKey: string; details: FleetQuoteDraft } | null = null
const uncertain = ref(false)
const title = computed(() => {
const action = props.readonly ? '报价详情' : props.quote ? '编辑报价' : form.kind === 'PURCHASE' ? '新增车队采购报价' : '新增外接销售报价'
return props.quote?.quoteNo?.trim() ? `${action} · ${props.quote.quoteNo.trim()}` : action
@@ -30,8 +28,7 @@ const facts = computed(() => [
])
watch([open, () => props.quote?.quoteNo], () => {
if (!open.value) return
uncertain.value = !props.quote && pending !== null
Object.assign(form, props.quote?.details || pending?.details || empty()); error.value = null
Object.assign(form, props.quote?.details || empty()); error.value = null
})
async function save() {
if (props.readonly || saving.value) return
@@ -43,25 +40,24 @@ async function save() {
if (form.selfPickupPrice == null && form.fullDeliveryPrice == null && form.looseDeliveryPrice == null) throw new Error('至少填写一种价格,零表示明确零价')
const details: FleetQuoteDraft = { ...form, distanceMiles: form.distanceMiles }
if (editing) await updateFleetQuote(editing, details)
else { pending ??= { idempotencyKey: crypto.randomUUID(), details }; await createFleetQuote(pending.idempotencyKey, pending.details) }
if (!editing) pending = null
uncertain.value = false; open.value = false; emit('saved')
} catch (e) { error.value = getApiErrorDetails(e, '报价保存失败'); if (!editing && isFleetCommandRejected(e)) pending = null; uncertain.value = !editing && pending !== null }
else await createFleetQuote(crypto.randomUUID(), details)
open.value = false; emit('saved')
} catch (e) { error.value = getApiErrorDetails(e, '报价保存失败') }
finally { saving.value = false }
}
</script>
<template><AppDialog v-model="open" :title="title" width="780px" class="admin-density-dialog admin-workbench is-dialog" fullscreenable :confirm-loading="saving" :show-confirm="false" :close-on-click-modal="false">
<el-alert v-if="error" :title="error.message" type="error" :closable="false"><ApiErrorMeta :details="error" /></el-alert>
<p v-if="uncertain" role="status" class="density-dialog-note">保存结果尚未确认,重试将发送原请求和原内容。</p>
<section v-if="readonly" class="density-dialog-section" data-tone="source" aria-label="车队报价资料">
<h3 class="density-dialog-section__title">报价资料</h3>
<dl class="density-fields fleet-quote-facts"><div v-for="(fact,index) in facts" :key="index"><dt>{{fact[0]}}</dt><dd>{{fact[1]}}</dd></div></dl>
</section>
<el-form v-else label-position="left" label-width="130px" class="density-form density-dialog-sections" :disabled="saving || uncertain">
<el-form v-else label-position="left" label-width="130px" class="density-form density-dialog-sections" :disabled="saving">
<section class="density-dialog-section" data-tone="work" aria-label="报价路线与归属">
<h3 class="density-dialog-section__title">基础信息</h3>
<table class="density-table density-table--editable fleet-quote-form"><tbody>
<tr><td><el-form-item label="用途"><span>{{form.kind === 'PURCHASE' ? '车队采购报价' : '外接销售报价'}}</span></el-form-item></td><td><el-form-item :label="form.kind === 'PURCHASE' ? '海外车队供应商' : '委托客户'" required><FleetQuotePartySelect v-model="form.partyId" :kind="form.kind" :party-name="quote?.partyName" :disabled="saving || uncertain" /></el-form-item></td></tr>
<tr><td><el-form-item label="用途"><span>{{form.kind === 'PURCHASE' ? '车队采购报价' : '外接销售报价'}}</span></el-form-item></td><td><el-form-item :label="form.kind === 'PURCHASE' ? '海外车队供应商' : '委托客户'" required><FleetQuotePartySelect v-model="form.partyId" :kind="form.kind" :party-name="quote?.partyName" :disabled="saving" /></el-form-item></td></tr>
<tr><td><el-form-item label="发货城市" required><el-input v-model="form.originCity" maxlength="128" placeholder="请输入发货城市" /></el-form-item></td><td><el-form-item label="目的仓码" required><el-input v-model="form.destinationCode" maxlength="64" placeholder="请输入目的仓码,如 ONT8" /></el-form-item></td></tr>
<tr><td><el-form-item label="预估距离(mile)" required><el-input-number :model-value="form.distanceMiles" @update:model-value="form.distanceMiles = $event ?? null" :min="0" :precision="1" placeholder="请输入预估英里数" /></el-form-item></td><td><el-form-item label="币种"><el-select v-model="form.currency"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item></td></tr>
</tbody></table>
@@ -82,7 +78,7 @@ async function save() {
</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>
<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">保存报价</el-button></div></template>
</AppDialog></template>
<style scoped>
.fleet-quote-form { table-layout: fixed; }
@@ -30,16 +30,16 @@ it('definite import rejection unlocks file selection and corrected rows use a ne
const second = vi.mocked(api.importFleetQuotes).mock.calls[1]!
expect(second[0]).not.toBe(first[0]); expect(second[1][0]?.originCity).toBe('QA-CORRECTED')
})
it('unknown result preserves original purpose, key and rows across closing and category changes', async () => {
it('failed import allows a different category and corrected file on the next click', async () => {
const wrapper = await mount(); await pick(wrapper)
vi.mocked(api.importFleetQuotes).mockRejectedValueOnce(new Error('network')); await save(wrapper)
const call = vi.mocked(api.importFleetQuotes).mock.calls[0]!
const first = [call[0], call[1].map(row => ({ ...row }))]
const firstKey = call[0]
await wrapper.setProps({ modelValue: false, kind: 'PURCHASE' }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect(wrapper.findComponent({ name: 'FleetQuotePartySelect' }).props('kind')).toBe('SALE')
expect(wrapper.find('input[type="file"]').attributes('disabled')).toBeDefined()
expect(wrapper.findComponent({ name: 'FleetQuotePartySelect' }).props('kind')).toBe('PURCHASE')
expect(wrapper.find('input[type="file"]').attributes('disabled')).toBeUndefined(); wrapper.findComponent({ name: 'FleetQuotePartySelect' }).vm.$emit('update:modelValue', '101'); await pick(wrapper, 'QA-NEW')
vi.mocked(api.importFleetQuotes).mockResolvedValue({ importedRows: 1 }); await save(wrapper)
expect(vi.mocked(api.importFleetQuotes).mock.calls[1]).toEqual(first)
expect(vi.mocked(api.importFleetQuotes).mock.calls[1]![0]).not.toBe(firstKey); expect(vi.mocked(api.importFleetQuotes).mock.calls[1]![1][0]).toMatchObject({ kind: 'PURCHASE', originCity: 'QA-NEW' })
})
it('selecting an oversized replacement file cannot accidentally submit the previous valid file', async () => {
const wrapper = await mount(); await pick(wrapper); await pick(wrapper, 'TOO-LARGE', 500001)
@@ -23,13 +23,13 @@ async function pick(event: Event) {
catch (e) { if (current === fileSequence) error.value = getApiErrorDetails(e, 'CSV读取失败,请重新选择文件') }
}
function template() { saveFleetCsv(new Blob(['\uFEFF' + fleetQuoteCsvHeader.join(',') + '\r\n'], { type: 'text/csv;charset=utf-8' }), '车队报价模板.csv') }
async function save() { if (saving.value || (!pending.value && (!rows.value.length || !partyId.value))) return; fileSequence++; saving.value = true; error.value = null; try { pending.value ??= { key: crypto.randomUUID(), rows: rows.value.map(r => ({ ...r })) }; await importFleetQuotes(pending.value.key, pending.value.rows); pending.value = null; rows.value = []; source.value = ''; open.value = false; emit('saved') } catch (e) { error.value = getApiErrorDetails(e, '整批导入失败,请核对报价库与目录后重试'); if (isFleetCommandRejected(e)) pending.value = null } finally { saving.value = false } }
async function save() { if (saving.value || (!pending.value && (!rows.value.length || !partyId.value))) return; fileSequence++; saving.value = true; error.value = null; try { pending.value ??= { key: crypto.randomUUID(), rows: rows.value.map(r => ({ ...r })) }; await importFleetQuotes(pending.value.key, pending.value.rows); pending.value = null; rows.value = []; source.value = ''; open.value = false; emit('saved') } catch (e) { error.value = getApiErrorDetails(e, '整批导入失败,请核对报价库与目录后重试'); if (isFleetCommandRejected(e)) pending.value = null } finally { pending.value = null; saving.value = false } }
onBeforeUnmount(() => { fileSequence++ })
</script>
<template><AppDialog v-model="open" title="批量导入车队报价" width="1040px" class="admin-density-dialog admin-workbench is-dialog" fullscreenable :confirm-loading="saving" :show-confirm="false" :close-on-click-modal="false">
<p class="density-dialog-note">本批{{ activeKind === 'PURCHASE' ? '采购' : '销售' }}报价使用同一往来单位;每批最多200条,服务端整批校验,任一失败全部不写入。</p>
<el-alert v-if="error" type="error" :title="error.message" :closable="false"><ApiErrorMeta :details="error" /></el-alert>
<p v-if="pending" role="status" class="density-dialog-note">已保留原批次和原内容。结果未确认时请按原请求重试,避免重复导入。</p>
<div class="density-dialog-sections">
<section class="density-dialog-section" data-tone="work" aria-label="导入报价文件与归属">
<h3 class="density-dialog-section__title">导入资料</h3>
@@ -48,7 +48,7 @@ onBeforeUnmount(() => { fileSequence++ })
<div class="fleet-import-scroll"><table class="density-table fleet-import-table"><thead><tr><th scope="col">发货城市</th><th scope="col">目的仓码</th><th scope="col">距离(mile)</th><th scope="col">币种</th><th scope="col">整车自提</th><th scope="col">整车派送</th><th scope="col">散板派送</th><th scope="col">有效期</th></tr></thead><tbody><tr v-for="(row, index) in rows.slice(0,10)" :key="index"><td>{{ row.originCity }}</td><td>{{ row.destinationCode }}</td><td>{{ row.distanceMiles }}</td><td>{{ row.currency }}</td><td>{{ row.selfPickupPrice ?? '—' }}</td><td>{{ row.fullDeliveryPrice ?? '—' }}</td><td>{{ row.looseDeliveryPrice ?? '—' }}</td><td>{{ row.validFrom }} — {{ row.validTo }}</td></tr><tr v-if="!rows.length"><td colspan="8">请选择往来单位并上传有效CSV文件</td></tr></tbody></table></div>
</section>
</div>
<template #footer="{close}"><div class="density-actions density-actions--footer"><el-button :disabled="saving" @click="close">关闭</el-button><el-button type="primary" :disabled="!rows.length || !partyId" :loading="saving" @click="save">{{ pending ? '按原请求重试' : '确认导入' }}</el-button></div></template>
<template #footer="{close}"><div class="density-actions density-actions--footer"><el-button :disabled="saving" @click="close">关闭</el-button><el-button type="primary" :disabled="!rows.length || !partyId" :loading="saving" @click="save">{{ '确认导入' }}</el-button></div></template>
</AppDialog></template>
<style scoped>
.fleet-import-scroll { overflow-x: auto; }
@@ -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 }
}
@@ -7,7 +7,6 @@ const props = defineProps<{
dispatchNo?: string
busy?: boolean
saveDisabled?: boolean
pending?: boolean
beforeClose?: (done: () => void) => void | Promise<void>
}>()
const open = defineModel<boolean>({ default: false })
@@ -15,12 +14,12 @@ const emit = defineEmits<{ save: [] }>()
function close(done: () => void) {
if (props.busy) return
if (props.beforeClose) return props.beforeClose(done)
if (!props.pending) done()
done()
}
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" title="登记运营信息" width="1080px" :confirm-loading="busy" :confirm-disabled="saveDisabled" :confirm-text="pending ? '核对并重试原请求' : '保存登记'" :before-close="close" :close-on-press-escape="!busy" @confirm="emit('save')">
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" title="登记运营信息" width="1080px" :confirm-loading="busy" :confirm-disabled="saveDisabled" confirm-text="保存登记" :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 density-dialog-section" data-tone="work"><legend>整车承运金额<InlineHelp title="承运金额口径">整车只登记一次;单价采用系统返回的承运金额与实际物理托数计算结果。</InlineHelp></legend><slot name="amount" /></fieldset>
@@ -36,7 +36,7 @@ describe('outsourced operations registration', () => {
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 () => {
it('clears money as null and unlocks the form 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()
@@ -46,8 +46,8 @@ describe('outsourced operations registration', () => {
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()
expect(calls.command).not.toHaveBeenCalled()
expect(calls.save.mock.calls[1]![1]).toEqual({...original,idempotencyKey:expect.any(String)});expect(calls.save.mock.calls[1]![1].idempotencyKey).not.toBe(original.idempotencyKey); 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()
@@ -9,33 +9,28 @@ 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'
import { saveOutsourcedBusinessOperations } 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 auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
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) })
const active = computed(() => open.value)
const registration = useUnsavedWorkRegistration({ key: `outsourced-business-operations:${getCurrentInstance()?.uid}`, label: computed(() => '外配任务运营登记'), isOpen: active, model: reactive({ form }), 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) }
const writable = computed(() => access.can(permission.outsourcedBusinessEdit) && props.detail?.row.actions.edit.allowed === true)
const locked = computed(() => busy.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, '运营资料恢复失败') }
actor = auth.user?.id || ''; number = props.detail.row.dispatchNo; error.value = null
const operations = props.detail.row.operations
Object.assign(form, { currency: operations.money?.currency || '', amount: operations.money?.amount ?? '', 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(); registration.captureBaseline()
}, { immediate: true })
function payload(): OutsourcedBusinessSave {
const amount = outsourcedBusinessDecimal(form.amount, 2, 18)
@@ -54,32 +49,25 @@ async function save() {
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 : '运营登记结果尚未确认') }
if (JSON.stringify(form) === baseline) { open.value = false; return }
const request = payload()
const result = await saveOutsourcedBusinessOperations(number, request); ensureScope()
registration.captureBaseline(); emit('saved', result); open.value = false; ElMessage.success('运营信息已保存')
} catch (cause) { error.value = getApiErrorDetails(cause, '运营登记失败,请重新操作') }
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 admin-workbench is-dialog', confirmButtonText: pending.value ? '保留请求并关闭' : '放弃修改', cancelButtonText: '继续编辑' }) } catch { return }
if (JSON.stringify(form) !== baseline) {
try { await ElMessageBox.confirm('放弃尚未保存的运营信息?', '关闭运营登记', { customClass: 'admin-density-confirm admin-workbench is-dialog', confirmButtonText: '放弃修改', 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>
<OutsourcedBusinessOperationsDialog v-model="open" :dispatch-no="detail?.row.dispatchNo" :busy="busy" :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="!writable">{{ detail?.row.actions.edit.blockReason || '没有运营登记权限' }}</p></template>
<template #amount><el-form label-position="top" class="density-form 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>
@@ -25,23 +25,23 @@ beforeEach(()=>{
})
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()=>{
it('unlocks after binding failure and accepts a fresh photo submission',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()
expect(wrapper.find('input[type="file"]').attributes('disabled')).toBeUndefined();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')
await choose(wrapper)
expect(calls.upload).toHaveBeenCalledTimes(2);expect(calls.bind).toHaveBeenCalledTimes(2)
expect(calls.bind.mock.calls[1]![2].idempotencyKey).not.toBe(original.idempotencyKey);expect(original.fileId).toBe(photo.fileId);expect(original.recordVersion).toBe('1')
expect(wrapper.emitted('saved')).toHaveLength(1);wrapper.unmount()
})
it('does not offer a new upload after unknown upload or fabricate a saved photo',async()=>{
it('allows another upload after an unknown failure without fabricating 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()
await choose(wrapper)
expect(calls.upload).toHaveBeenCalledTimes(2);expect(calls.bind).not.toHaveBeenCalled();expect(wrapper.emitted('saved')).toBeUndefined()
expect(wrapper.find('input[type="file"]').attributes('disabled')).toBeUndefined();expect(sessionStorage.length).toBe(0);wrapper.unmount()
})
it('keeps saved metadata readable without upload permission and fetches image bytes only on explicit preview',async()=>{
calls.canUpload=false
@@ -1,46 +1,32 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import { computed, getCurrentInstance, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage } 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 { 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 { outsourcedBusinessPhotoRoles, outsourcedBusinessVersion, type OutsourcedBusinessDetail, type OutsourcedBusinessPhotoSlot } from '../outsourcedBusinessTypes'
import { getOutsourcedBusiness, getOutsourcedBusinessPhotoContent, uploadOutsourcedBusinessPhoto, bindOutsourcedBusinessPhoto } 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 auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
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) })
const uploadable = computed(() => access.can(permission.outsourcedBusinessUpload) && props.detail?.row.actions.upload.allowed === true)
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
generation++; release(); reading.value = null; error.value = null
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() })
@@ -52,71 +38,32 @@ async function preview(slot: OutsourcedBusinessPhotoSlot) {
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
async function upload(slot: OutsourcedBusinessPhotoSlot, event: Event) {
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
if (!file || busy.value || !uploadable.value) return
if (!['image/jpeg','image/png'].includes(file.type) || !file.size) { error.value = { message: '请选择有效的JPG或PNG图片' }; 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 : '照片结果尚未确认') }
const photo = await uploadOutsourcedBusinessPhoto(number, slot, file, crypto.randomUUID()); ensureScope()
const latest = await getOutsourcedBusiness(number); ensureScope()
if (!latest.row.actions.upload.allowed) throw new Error(latest.row.actions.upload.blockReason || '当前不允许上传照片')
const result = await bindOutsourcedBusinessPhoto(number, slot, { recordVersion: outsourcedBusinessVersion(latest.row.recordVersion), idempotencyKey: crypto.randomUUID(), fileId: photo.fileId }); ensureScope()
emit('saved', result); ElMessage.success('照片已保存')
} catch (cause) { error.value = getApiErrorDetails(cause, '图片上传或保存失败,请重新选择图片') }
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 admin-workbench is-dialog', confirmButtonText: '保留记录并关闭', cancelButtonText: '继续核对' }) } catch { return }
}
done()
}
function beforeClose(done: () => void) { if (!busy.value) 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 #state><LoadErrorState v-if="error" title="照片作业未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><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>
<label v-if="uploadable" class="outsourced-business-upload">{{ savedPhoto(role.slot) ? '替换图片' : '上传图片' }}<input type="file" accept="image/jpeg,image/png" :disabled="busy" @change="upload(role.slot, $event)" /></label>
</template>
</OutsourcedBusinessPhotosDialog>
</template>
@@ -1,72 +0,0 @@
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()
})
})
@@ -1,89 +0,0 @@
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 }
}
@@ -7,7 +7,6 @@ const props = defineProps<{
dispatchNo?: string
busy?: boolean
saveDisabled?: boolean
pending?: boolean
beforeClose?: (done: () => void) => void | Promise<void>
}>()
const open = defineModel<boolean>({ default: false })
@@ -15,12 +14,12 @@ const emit = defineEmits<{ save: [] }>()
function close(done: () => void) {
if (props.busy) return
if (props.beforeClose) return props.beforeClose(done)
if (!props.pending) done()
done()
}
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" title="登记运营信息" width="1080px" :confirm-loading="busy" :confirm-disabled="saveDisabled" :confirm-text="pending ? '核对并重试原请求' : '保存登记'" :before-close="close" :close-on-press-escape="!busy" @confirm="emit('save')">
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" title="登记运营信息" width="1080px" :confirm-loading="busy" :confirm-disabled="saveDisabled" confirm-text="保存登记" :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 density-dialog-section" data-tone="work"><legend>整车金额<InlineHelp title="金额口径">整车只登记一次;单价采用系统返回的总金额与实际物理托数计算结果。</InlineHelp></legend><slot name="amount" /></fieldset>
@@ -9,33 +9,28 @@ 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'
import { saveOwnedBusinessOperations } 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 auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
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) })
const active = computed(() => open.value)
const registration = useUnsavedWorkRegistration({ key: `owned-business-operations:${getCurrentInstance()?.uid}`, label: computed(() => '自有业务运营登记'), isOpen: active, model: reactive({ form }), 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) }
const writable = computed(() => access.can(permission.ownedBusinessEdit) && props.detail?.row.actions.edit.allowed === true)
const locked = computed(() => busy.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, '运营资料恢复失败') }
actor = auth.user?.id || ''; number = props.detail.row.dispatchNo; error.value = null
const operations = props.detail.row.operations
Object.assign(form, { currency: operations.money?.currency || '', amount: operations.money?.amount ?? '', actualDistanceMiles: operations.actualDistanceMiles ?? '', fuelConsumptionLitresPer100Km: operations.fuelConsumptionLitresPer100Km ?? '', 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(); registration.captureBaseline()
}, { immediate: true })
function payload(): OwnedBusinessSave {
const amount = ownedBusinessDecimal(form.amount, 2, 18)
@@ -54,32 +49,25 @@ async function save() {
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 : '运营登记结果尚未确认') }
if (JSON.stringify(form) === baseline) { open.value = false; return }
const request = payload()
const result = await saveOwnedBusinessOperations(number, request); ensureScope()
registration.captureBaseline(); emit('saved', result); open.value = false; ElMessage.success('运营信息已保存')
} catch (cause) { error.value = getApiErrorDetails(cause, '运营登记失败,请重新操作') }
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 admin-workbench is-dialog', confirmButtonText: pending.value ? '保留请求并关闭' : '放弃修改', cancelButtonText: '继续编辑' }) } catch { return }
if (JSON.stringify(form) !== baseline) {
try { await ElMessageBox.confirm('放弃尚未保存的运营信息?', '关闭运营登记', { customClass: 'admin-density-confirm admin-workbench is-dialog', confirmButtonText: '放弃修改', 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>
<OwnedBusinessOperationsDialog v-model="open" :dispatch-no="detail?.row.dispatchNo" :busy="busy" :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="!writable">{{ detail?.row.actions.edit.blockReason || '没有运营登记权限' }}</p></template>
<template #amount><el-form label-position="top" class="density-form 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="density-form 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>
@@ -24,23 +24,23 @@ beforeEach(()=>{
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()=>{
it('unlocks after binding failure and accepts a fresh photo submission',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()
expect(wrapper.find('input[type="file"]').attributes('disabled')).toBeUndefined();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')
await choose(wrapper)
expect(calls.upload).toHaveBeenCalledTimes(2);expect(calls.bind).toHaveBeenCalledTimes(2)
expect(calls.bind.mock.calls[1]![2].idempotencyKey).not.toBe(original.idempotencyKey);expect(original.fileId).toBe(photo.fileId);expect(original.recordVersion).toBe('1')
expect(wrapper.emitted('saved')).toHaveLength(1);wrapper.unmount()
})
it('does not offer a new upload after unknown upload or fabricate a saved photo',async()=>{
it('allows another upload after an unknown failure without fabricating 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()
await choose(wrapper)
expect(calls.upload).toHaveBeenCalledTimes(2);expect(calls.bind).not.toHaveBeenCalled();expect(wrapper.emitted('saved')).toBeUndefined()
expect(wrapper.find('input[type="file"]').attributes('disabled')).toBeUndefined();expect(sessionStorage.length).toBe(0);wrapper.unmount()
})
it('keeps saved metadata readable without upload permission and fetches image bytes only on explicit preview',async()=>{
calls.canUpload=false
@@ -1,46 +1,32 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import { computed, getCurrentInstance, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage } 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 { 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 { ownedBusinessPhotoRoles, ownedBusinessVersion, type OwnedBusinessDetail, type OwnedBusinessPhotoSlot } from '../ownedBusinessTypes'
import { getOwnedBusiness, getOwnedBusinessPhotoContent, uploadOwnedBusinessPhoto, bindOwnedBusinessPhoto } 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 auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
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) })
const uploadable = computed(() => access.can(permission.ownedBusinessUpload) && props.detail?.row.actions.upload.allowed === true)
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
generation++; release(); reading.value = null; error.value = null
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() })
@@ -52,71 +38,32 @@ async function preview(slot: OwnedBusinessPhotoSlot) {
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
async function upload(slot: OwnedBusinessPhotoSlot, event: Event) {
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
if (!file || busy.value || !uploadable.value) return
if (!['image/jpeg','image/png'].includes(file.type) || !file.size) { error.value = { message: '请选择有效的JPG或PNG图片' }; 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 : '照片结果尚未确认') }
const photo = await uploadOwnedBusinessPhoto(number, slot, file, crypto.randomUUID()); ensureScope()
const latest = await getOwnedBusiness(number); ensureScope()
if (!latest.row.actions.upload.allowed) throw new Error(latest.row.actions.upload.blockReason || '当前不允许上传照片')
const result = await bindOwnedBusinessPhoto(number, slot, { recordVersion: ownedBusinessVersion(latest.row.recordVersion), idempotencyKey: crypto.randomUUID(), fileId: photo.fileId }); ensureScope()
emit('saved', result); ElMessage.success('照片已保存')
} catch (cause) { error.value = getApiErrorDetails(cause, '图片上传或保存失败,请重新选择图片') }
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 admin-workbench is-dialog', confirmButtonText: '保留记录并关闭', cancelButtonText: '继续核对' }) } catch { return }
}
done()
}
function beforeClose(done: () => void) { if (!busy.value) 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 #state><LoadErrorState v-if="error" title="照片作业未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><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>
<label v-if="uploadable" class="owned-business-upload">{{ savedPhoto(role.slot) ? '替换图片' : '上传图片' }}<input type="file" accept="image/jpeg,image/png" :disabled="busy" @change="upload(role.slot, $event)" /></label>
</template>
</OwnedBusinessPhotosDialog>
</template>
@@ -30,7 +30,7 @@ it('uses server total and rejects combining different destination warehouses', a
expect(wrapper.findComponent({ name: 'AppDialog' }).props('modelValue')).toBe(false)
expect(createPickupTrip).not.toHaveBeenCalled()
})
it('keeps exact source identities and the same create request after an unknown network result', async () => {
it('keeps source identities and submits a new request after network failure', async () => {
const wrapper = mount(); await flushPromises()
wrapper.findComponent({ name: 'DataTable' }).vm.$emit('selection-change', rows); await flushPromises(); await wrapper.find('button').trigger('click')
const dates = wrapper.findAllComponents({ name: 'ElDatePicker' })
@@ -40,7 +40,7 @@ it('keeps exact source identities and the same create request after an unknown n
const request = vi.mocked(createPickupTrip).mock.calls[0]![0]
expect(request.sources).toEqual(rows.map(({ customerId, transferNo }) => ({ customerId, transferNo })))
dialog.vm.$emit('confirm'); await flushPromises()
expect(vi.mocked(createPickupTrip).mock.calls[1]![0]).toBe(request)
expect(vi.mocked(createPickupTrip).mock.calls[1]![0]).toEqual({ ...request, idempotencyKey: expect.any(String) }); expect(vi.mocked(createPickupTrip).mock.calls[1]![0].idempotencyKey).not.toBe(request.idempotencyKey)
expect(state.push).toHaveBeenCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-TEST' } })
})
it('does not show scheduling to read-only users', async () => {
@@ -40,7 +40,6 @@ function arrange() {
if (new Set(selected.value.map(row => row.warehouseId)).size !== 1) { ElMessage.warning('同车取货必须送往同一海外仓'); return }
snapshot.value = [...selected.value]; pending.value = null; departure.value = ''; end.value = ''; remark.value = ''; error.value = ''; open.value = true
}
/** 网络结果未知时沿原键原内容重试,业务错误后允许修正输入。 */
async function save() {
if (busy.value) return
if (!pending.value && (!departure.value || !end.value)) { ElMessage.warning('请填写取货发车时间和预计任务结束时间'); return }
@@ -48,10 +47,9 @@ async function save() {
busy.value = true
try { const value = await createPickupTrip(pending.value); open.value = false; pending.value = null; await router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: value.trip.tripNo } }) }
catch (cause) { error.value = getApiErrorDetails(cause, '安排取货未完成').message; if (cause instanceof ApiResponseError) pending.value = null }
finally { busy.value = false }
finally { pending.value = null; busy.value = false }
}
/** 未知提交结果必须保留原请求,避免关闭后重复建车次。 */
function close(done: () => void) { if (busy.value || pending.value) { ElMessage.warning('请先重试原安排请求,确认结果后再关闭'); return }; done() }
function close(done: () => void) { if (busy.value || pending.value) { ElMessage.warning('操作正在提交,请稍候'); return }; done() }
onBeforeRouteLeave(() => { if (busy.value || pending.value) { ElMessage.warning('请先确认本次安排结果'); return false }; return true })
watch(page, load); onMounted(load); onBeforeUnmount(() => { sequence++ })
</script>
@@ -62,7 +60,7 @@ watch(page, load); onMounted(load); onBeforeUnmount(() => { sequence++ })
<template #notices><LoadErrorState v-if="error && !open" title="待取货需求加载失败" :message="error" @retry="load" /></template>
<template #warehouse="{ row }">{{ row.warehouseName || '海外仓' }} · {{ row.warehouseCode || row.warehouseId }}</template>
</DataTable></div>
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :before-close="close" title="安排仓储转运取货" width="780px" :confirm-text="pending ? '重试原安排请求' : '创建取货车次'" :confirm-loading="busy" @confirm="save">
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :before-close="close" title="安排仓储转运取货" width="780px" :confirm-text="'创建取货车次'" :confirm-loading="busy" @confirm="save">
<p>已选 {{ snapshot.length }} 单:{{ snapshot.map(row => row.transferNo).join('、') }}</p>
<el-form label-position="top" class="pickup-plan-form" :disabled="busy || !!pending">
<el-form-item label="计划取货发车时间" required><el-date-picker v-model="departure" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
@@ -46,15 +46,15 @@ it('does not accept a receipt while the warehouse is still waiting for actual go
const wrapper = mount('receipt'); await flushPromises(); wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.acceptTripIncoming).not.toHaveBeenCalled(); expect(wrapper.findComponent({ name: 'AppDialog' }).props('confirmDisabled')).toBe(true)
})
it('retries the same incoming registration after an uncertain result without creating a new command', async () => {
it('submits a fresh incoming registration after failure', async () => {
vi.mocked(api.startTripIncoming).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ tripRecordVersion: 5, booking, replayed: true })
const wrapper = mount('incoming'); await flushPromises(); wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
const first = vi.mocked(api.startTripIncoming).mock.calls[0]![2]
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.startTripIncoming).toHaveBeenNthCalledWith(2, 'TR-1', 'FB-1', first); expect(api.getTripIncoming).not.toHaveBeenCalled()
expect(api.startTripIncoming).toHaveBeenNthCalledWith(2, 'TR-1', 'FB-1', { ...first, idempotencyKey: expect.any(String) }); expect(vi.mocked(api.startTripIncoming).mock.calls[1]![2].idempotencyKey).not.toBe(first.idempotencyKey); expect(api.getTripIncoming).not.toHaveBeenCalled()
})
it('keeps an uncertain receipt command retryable after closing and reopening without reloading or changing its hash', async () => {
it('reloads current receipt on reopening and submits a fresh command', async () => {
vi.mocked(api.acceptTripIncoming).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: true })
const wrapper=mount('receipt'); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
@@ -62,8 +62,8 @@ it('keeps an uncertain receipt command retryable after closing and reopening wit
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect(wrapper.findComponent({ name: 'AppDialog' }).props('confirmDisabled')).toBe(false)
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.acceptTripIncoming).toHaveBeenNthCalledWith(2, 'TR-1', 'FB-1', original)
expect(api.getTripIncoming).toHaveBeenCalledOnce()
expect(api.acceptTripIncoming).toHaveBeenNthCalledWith(2, 'TR-1', 'FB-1', { ...original, idempotencyKey: expect.any(String) }); expect(vi.mocked(api.acceptTripIncoming).mock.calls[1]![2].idempotencyKey).not.toBe(original.idempotencyKey)
expect(api.getTripIncoming).toHaveBeenCalledTimes(2)
})
it.each([
@@ -136,7 +136,7 @@ it('requires explicit acceptance of a changed received snapshot and submits the
expect(api.acceptTripIncoming).toHaveBeenCalledExactlyOnceWith('TR-1', 'FB-1', { tripRecordVersion: 11, recordVersion: 8, idempotencyKey: expect.any(String), receiptHash: 'merged-hash', unitPrice: 50, pallets: [{ warehousePalletId: '903', palletSpaces: 1 }] })
})
it('uses a new key after a completed confirmation while preserving the original key for unknown retries', async () => {
it('uses a new key after a completed confirmation and after failed requests', async () => {
vi.mocked(api.acceptTripIncoming).mockResolvedValue({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: false })
const wrapper = mount('receipt'); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
@@ -174,7 +174,7 @@ it.each(['bound', 'departed', 'permission'] as const)('rejects a receipt write i
expect(wrapper.findComponent({ name: 'AppDialog' }).props('confirmDisabled')).toBe(true)
})
it('requires new measurements for changed pallet composition and keeps them in the exact unknown-result retry', async () => {
it('requires measurements for changed pallet composition and submits them with a new key after failure', async () => {
const merged = { ...preview, incoming: { ...preview.incoming!, status: 'READY_FOR_DISPATCH', receiptHash: 'merged-null-measurement', pallets: [{ warehousePalletId: '903', palletNo: 'MERGED-1', packages: 22, weightKg: null, volumeCbm: null }] } }
vi.mocked(api.getTripIncoming).mockResolvedValue(merged)
vi.mocked(api.acceptTripIncoming).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: true })
@@ -190,7 +190,7 @@ it('requires new measurements for changed pallet composition and keeps them in t
const original = vi.mocked(api.acceptTripIncoming).mock.calls[0]![2]
expect(original).toMatchObject({ receiptHash: 'merged-null-measurement', pallets: [{ warehousePalletId: '903', palletSpaces: 1, weightKg: 123.456, volumeCbm: 1.234 }] })
confirm(); await flushPromises()
expect(api.acceptTripIncoming).toHaveBeenNthCalledWith(2, 'TR-1', 'FB-1', original)
expect(api.acceptTripIncoming).toHaveBeenNthCalledWith(2, 'TR-1', 'FB-1', { ...original, idempotencyKey: expect.any(String) }); expect(vi.mocked(api.acceptTripIncoming).mock.calls[1]![2].idempotencyKey).not.toBe(original.idempotencyKey)
})
it('does not offer measurement overrides for unchanged source pallets', async () => {
@@ -23,7 +23,7 @@ const spaces = reactive<Record<string, number>>({}), unitPrice = ref<number | nu
const measurements = reactive({ weightKg: {} as Record<string, number | undefined>, volumeCbm: {} as Record<string, number | undefined> })
const form = reactive({ estimatedArrivalAt: '', deliveryInstructions: '', privateAddress: '', postalCode: '', recipientName: '', recipientPhone: '' })
const pending = ref<{ target: string; body: TripIncomingStart | TripIncomingAccept | TripBookingBind } | null>(null)
const guard = useFleetDraftGuard('booking-fulfillment', open, reactive({ form, spaces, measurements, unitPrice, dispatchNo }), busy, computed(() => !!pending.value))
const guard = useFleetDraftGuard('booking-fulfillment', open, reactive({ form, spaces, measurements, unitPrice, dispatchNo }), busy)
defineExpose({ hasPending: computed(() => !!pending.value) })
let generation = 0
const target = computed(() => `${props.trip.tripNo}:${props.booking?.bookingNo}:${props.mode}`)
@@ -113,7 +113,7 @@ async function save() {
busy.value = true
try { await ElMessageBox.confirm(`当前实托、货量、托位或约定价格存在变化,确认按当前完整实收和 ${amount.value?.toFixed(2)} ${booking.cargo.currency || ''} 核实?容量仍由服务端复核,超容量不会保存。`, '确认实收差异', { customClass: 'admin-density-confirm admin-workbench is-dialog', confirmButtonText: '确认当前实收与运费', cancelButtonText: '返回核对', type: 'warning' }) }
catch { return }
finally { busy.value = false }
finally { pending.value = null; busy.value = false }
if (current !== generation || !receiptAllowed.value) return
}
pending.value = { target: target.value, body: { ...versions, receiptHash: preview.value.incoming.receiptHash, unitPrice: unitPrice.value,
@@ -128,15 +128,15 @@ async function save() {
: await acceptTripIncoming(props.trip.tripNo, booking.bookingNo, body as TripIncomingAccept)
if (current === generation) { pending.value = null; guard.capture(); emit('saved', value); open.value = false; ElMessage.success(value.booking.status === 'BOUND' ? '已排入本车,尚未装车或发车' : value.booking.status === 'RECEIVED' ? '实收货量及约定运费已核实' : value.booking.incomingNo ? '已安排来货,等待仓库实际收货' : '履约命令已完成,请查看最新进度') }
} catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '外接履约操作未完成'); if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) pending.value = null } }
finally { busy.value = false }
finally { pending.value = null; busy.value = false }
}
onBeforeUnmount(() => { generation++ })
</script>
<template>
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog admin-workbench is-dialog" :title="titles[mode]" width="1080px" :show-confirm="mode !== 'view'" :confirm-text="pending ? '重试原请求' : mode === 'receipt' ? '确认实收与运费' : mode === 'bind' ? '确认排入本车' : '生成来货单'" :confirm-disabled="loading || (mode === 'receipt' && (!receiptAllowed || (!pending && !ready)))" :confirm-loading="busy" @confirm="save">
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog admin-workbench is-dialog" :title="titles[mode]" width="1080px" :show-confirm="mode !== 'view'" :confirm-text="mode === 'receipt' ? '确认实收与运费' : mode === 'bind' ? '确认排入本车' : '生成来货单'" :confirm-disabled="loading || (mode === 'receipt' && (!receiptAllowed || (!pending && !ready)))" :confirm-loading="busy" @confirm="save">
<LoadErrorState v-if="error" title="操作未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-if="booking" v-loading="loading">
<p>{{ booking.bookingNo }} · {{ booking.customerName }} · {{ booking.destinationLabel }}</p><p v-if="pending" role="status">本票上次提交结果尚未确定,保留原内容与请求键重试;不会改为处理另一票货物。</p>
<p>{{ booking.bookingNo }} · {{ booking.customerName }} · {{ booking.destinationLabel }}</p>
<el-form v-if="mode === 'incoming'" label-position="top" class="density-form incoming-form" :disabled="busy || !!pending">
<el-form-item label="预计到仓时间"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="交货说明"><el-input v-model="form.deliveryInstructions" placeholder="选填,默认客户自送至本起运仓" /></el-form-item>
@@ -63,7 +63,7 @@ it('reopens saved negotiated price in a fresh numeric control while retaining it
expect(wrapper.text()).toContain('来源单价:16.00 USD')
expect(wrapper.text()).toContain('约定运费:18.00 USD')
})
it('keeps inquiry estimates and contacts optional and retries the same uncertain create command', async () => {
it('keeps inquiry estimates and contacts optional and submits a fresh create command after failure', async () => {
vi.mocked(api.createTripBooking).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ tripRecordVersion: 5, booking: row, replayed: true })
const wrapper = mount(); await flushPromises(); await button(wrapper, '登记外接询价').trigger('click'); await flushPromises()
const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0]!.vm.$emit('update:modelValue', '9'); selects[1]!.vm.$emit('update:modelValue', 'FBA:ONT8'); await flushPromises()
@@ -71,7 +71,7 @@ it('keeps inquiry estimates and contacts optional and retries the same uncertain
const first = vi.mocked(api.createTripBooking).mock.calls[0]![1]
expect(first.cargo).toMatchObject({ palletSpaces: null, weightKg: null, volumeCbm: null, unitPrice: null, contactPhone: null })
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.createTripBooking).toHaveBeenNthCalledWith(2, 'TR-1', first)
expect(api.createTripBooking).toHaveBeenNthCalledWith(2, 'TR-1', { ...first, idempotencyKey: expect.any(String) }); expect(vi.mocked(api.createTripBooking).mock.calls[1]![1].idempotencyKey).not.toBe(first.idempotencyKey)
})
it('does not expose booking writes to a read-only operator', async () => {
access.writable = false; const wrapper = mount(); await flushPromises()
@@ -123,7 +123,7 @@ async function save() {
}
if (current === generation) { accept(value); pendingCreate.value = null; open.value = false; ElMessage.success('外接询价已保存') }
} catch (cause) { if (current === generation) failure(cause, '外接登记未完成') }
finally { busy.value = false }
finally { pendingCreate.value = null; pendingCommand.value = null; busy.value = false }
}
async function command(row: TripBooking, action: 'confirm' | 'cancel') {
if (busy.value || row.offerNo || version.value == null) return
@@ -140,7 +140,7 @@ async function command(row: TripBooking, action: 'confirm' | 'cancel') {
const value = await (action === 'confirm' ? confirmTripBooking : cancelTripBooking)(props.trip.tripNo, row.bookingNo, pendingCommand.value.body)
if (current === generation) { accept(value); pendingCommand.value = null; ElMessage.success('外接预订状态已更新') }
} catch (cause) { if (current === generation) failure(cause, '外接预订操作未完成') }
finally { busy.value = false }
finally { pendingCreate.value = null; pendingCommand.value = null; busy.value = false }
}
watch(() => [props.trip.tripNo, props.trip.recordVersion], () => { if (version.value !== props.trip.recordVersion) void load() }, { immediate: true })
watch(open, value => { if (!value) resetCandidates() })
@@ -152,14 +152,14 @@ onBeforeUnmount(() => { generation++; searchSequence++; quoteSequence++; clearTi
<LoadErrorState v-if="error" title="外接操作未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div class="workbench-table"><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><InlineHelp title="外接预订说明"><p>询价不占位,确认承运后预留容量。实收时核对运费,转派送后按确认报价登记应收,付款状态以财务为准。</p></InlineHelp></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()">{{ '登记外接询价' }}</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>
<template #documents="{ row }"><div>{{ row.incomingNo || '尚未登记来货' }}</div><div>{{ row.dispatchNo || '尚未关联排车' }}</div></template>
<template #amount="{ row }">{{ row.agreedAmount == null ? '待报价' : `${row.agreedAmount.toFixed(2)} ${row.cargo.currency || ''}` }}</template>
</DataTable></div>
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" destroy-on-close :title="readOnly ? '外接预订详情' : editing ? '编辑外接询价' : '登记外接询价'" :show-confirm="!readOnly" width="1080px" :confirm-text="pendingCreate ? '重试原登记' : '保存询价'" :confirm-loading="busy" @confirm="save">
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" destroy-on-close :title="readOnly ? '外接预订详情' : editing ? '编辑外接询价' : '登记外接询价'" :show-confirm="!readOnly" width="1080px" :confirm-text="'保存询价'" :confirm-loading="busy" @confirm="save">
<p v-if="editing?.offerNo">池来源意向 {{ editing.offerNo }},请从拼车需求池管理询价与承运。</p><el-form label-position="top" class="density-form booking-form" :disabled="readOnly || busy || !!pendingCreate">
<el-form-item label="委托客户" required><el-select v-model="customerId" filterable remote :remote-method="searchCustomers" :loading="customersLoading" :disabled="!!editing" placeholder="按客户名称、编号搜索" @focus="searchCustomers()"><el-option v-if="editing" :value="editing.customerId" :label="editing.customerName" /><el-option v-for="customer in customers" :key="customer.id" :value="customer.id" :label="`${customer.code} · ${customer.name}`" /></el-select></el-form-item>
<el-form-item label="本车卸货站" required><el-select v-model="form.destinationKey" placeholder="选择顺路卸货站"><el-option v-for="stop in trip.stops.slice(1).filter(row => row.destinationKey)" :key="stop.destinationKey!" :value="stop.destinationKey!" :label="stop.name" /></el-select></el-form-item>
@@ -46,14 +46,14 @@ it('ignores late responses after switching trips and resets later pagination onc
expect(api.listTripCosts).toHaveBeenNthCalledWith(4, 'TR-THREE', 1, 10)
expect(w.findComponent({ name: 'DataTable' }).props('data')[0].costNo).toBe('TC-THREE')
})
it('retains the exact key and contents when a creation response is uncertain', async () => {
it('releases a failed cost create and submits current contents with a new key', async () => {
vi.mocked(api.createTripCost).mockRejectedValueOnce(new Error('unknown outcome')).mockResolvedValueOnce(cost)
const w = mount(); await flushPromises(); await button(w, '登记成本').trigger('click'); await flushPromises()
w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 12.5); await button(w, '保存').trigger('click'); await flushPromises()
const first = vi.mocked(api.createTripCost).mock.calls[0]!
expect(w.text()).toContain('正在核对上次登记结果')
expect(w.text()).not.toContain('正在核对上次登记结果')
await button(w, '保存').trigger('click'); await flushPromises()
expect(vi.mocked(api.createTripCost).mock.calls[1]).toEqual(first)
expect(vi.mocked(api.createTripCost).mock.calls[1]).toEqual([first[0], expect.any(String), first[2]]); expect(vi.mocked(api.createTripCost).mock.calls[1]![1]).not.toBe(first[1])
})
it('keeps the successful save authoritative when pagination changes while it is pending', async () => {
let finish!: (value: api.TripCost) => void
@@ -119,7 +119,7 @@ async function save() {
if (scope !== props.tripNo || request !== scopeGeneration) return
if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) pendingCreate.value = null
saveError.value = getApiErrorDetails(cause, '成本保存失败')
} finally { if (scope === props.tripNo && request === scopeGeneration) saving.value = false }
} finally { pendingCreate.value = null; if (scope === props.tripNo && request === scopeGeneration) saving.value = false }
}
function showHistory(row: TripCost) { historyNo.value = row.costNo; historyPage.value = 1; historyOpen.value = true }
async function loadHistory() {
@@ -166,7 +166,7 @@ onBeforeUnmount(() => { scopeGeneration++; generation++; historyGeneration++ })
</template>
<AppDialog v-model="editorOpen" :title="editing ? '更正车次直接成本' : '登记车次直接成本'" width="680px" class="admin-density-dialog admin-workbench is-dialog" confirm-text="保存成本" :confirm-loading="saving" :confirm-disabled="!writable" @confirm="save">
<LoadErrorState v-if="saveError" title="成本保存未完成" :message="saveError.message" @retry="save"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
<p v-if="pendingCreate" class="cost-note">正在核对上次登记结果,重试会使用同一请求,不重复记账。</p>
<el-form label-position="top" :disabled="saving || !writable || !!pendingCreate" class="cost-form">
<el-form-item label="费用类别" required><el-select v-model="form.category" placeholder="请选择费用类别"><el-option v-for="(label, key) in categories" :key="key" :value="key" :label="label" /></el-select></el-form-item>
<el-form-item label="币种" required><el-select v-model="form.currency" placeholder="请选择原始币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item>
@@ -51,16 +51,16 @@ it('invalidates preview when replacement or trip version changes', async () => {
await w.setProps({ detail: { ...detail, trip: { ...detail.trip, recordVersion: 8 } } }); await flushPromises()
expect(w.text()).not.toContain('确认实际交接')
})
it('retries exactly the original request after an unknown confirmation response', async () => {
it('releases failed confirmation and submits a fresh request', async () => {
vi.mocked(api.confirmTripHandover).mockRejectedValueOnce(new Error('network unknown')).mockResolvedValueOnce(record)
const w = mount(); await flushPromises()
w.findAllComponents({ name: 'ElInput' })[0]!.vm.$emit('update:modelValue', '实际交接点')
await button(w, '核对交接货物').trigger('click'); await flushPromises()
await button(w, '确认实际交接').trigger('click'); await flushPromises()
const first = vi.mocked(api.confirmTripHandover).mock.calls[0]
expect(w.text()).toContain('上次交接结果尚待确认')
await button(w, '重试原交接请求').trigger('click'); await flushPromises()
expect(vi.mocked(api.confirmTripHandover).mock.calls[1]).toEqual(first)
expect(w.text()).not.toContain('上次交接结果尚待确认')
await button(w, '确认实际交接').trigger('click'); await flushPromises()
expect(vi.mocked(api.confirmTripHandover).mock.calls[1]).toEqual([first![0], { ...first![1], idempotencyKey: expect.any(String) }]); expect(vi.mocked(api.confirmTripHandover).mock.calls[1]![1].idempotencyKey).not.toBe(first![1].idempotencyKey)
expect(w.emitted('saved')).toHaveLength(1)
})
it('ignores old trip response after switching scope', async () => {
@@ -70,7 +70,7 @@ it('ignores old trip response after switching scope', async () => {
complete({ records: [{ ...record, handoverNo: 'OLD-RESULT' }], page: 1, size: 10, total: 1, pages: 1 }); await flushPromises()
expect(w.findComponent({ name: 'DataTable' }).props('data')[0].handoverNo).toBe('HO-ONE')
})
it('retains the original old-driver release request when its response is unknown', async () => {
it('releases failed old-driver command and submits a fresh request', async () => {
vi.mocked(api.releaseHandoverResource).mockRejectedValueOnce(new Error('unknown response')).mockResolvedValueOnce({ ...record, recordVersion: 1, driverRelease: { actualEndAt: date, location: '结束地点', vehicleStatus: null } })
const w = mount(); await w.setProps({ detail: { ...detail, trip: { ...detail.trip, vehicleId: '12', driverId: '22', recordVersion: 8 } } }); await flushPromises()
await button(w, '结束原司机任务').trigger('click'); await flushPromises()
@@ -78,11 +78,11 @@ it('retains the original old-driver release request when its response is unknown
await button(w, '确认原资源任务结束').trigger('click'); await flushPromises()
const first = vi.mocked(api.releaseHandoverResource).mock.calls[0]
expect(first?.[2].resourceKind).toBe('DRIVER'); expect(first?.[2].vehicleStatus).toBeUndefined()
expect(w.text()).toContain('上次任务结束结果尚待确认')
await button(w, '重试原任务结束请求').trigger('click'); await flushPromises()
expect(vi.mocked(api.releaseHandoverResource).mock.calls[1]).toEqual(first)
expect(w.text()).not.toContain('上次任务结束结果尚待确认')
await button(w, '确认原资源任务结束').trigger('click'); await flushPromises()
expect(vi.mocked(api.releaseHandoverResource).mock.calls[1]).toEqual([first![0], first![1], { ...first![2], idempotencyKey: expect.any(String) }]); expect(vi.mocked(api.releaseHandoverResource).mock.calls[1]![2].idempotencyKey).not.toBe(first![2].idempotencyKey)
})
it('reloads the handover version after a definite release conflict', async () => {
it('preserves inputs and releases the form without automatic reload after a conflict', async () => {
vi.mocked(api.releaseHandoverResource).mockRejectedValueOnce(new ApiResponseError('交接记录已变化', 'A0409')).mockResolvedValueOnce({ ...record, recordVersion: 2 })
const w = mount(); await w.setProps({ detail: { ...detail, trip: { ...detail.trip, vehicleId: '12', driverId: '22', recordVersion: 8 } } }); await flushPromises()
await button(w, '结束原司机任务').trigger('click'); await flushPromises()
@@ -90,6 +90,6 @@ it('reloads the handover version after a definite release conflict', async () =>
vi.mocked(api.listTripHandovers).mockResolvedValue({ records: [{ ...record, recordVersion: 1 }], page: 1, size: 10, total: 1, pages: 1 })
await button(w, '确认原资源任务结束').trigger('click'); await flushPromises()
await button(w, '确认原资源任务结束').trigger('click'); await flushPromises()
expect(vi.mocked(api.releaseHandoverResource).mock.calls[1]?.[2].recordVersion).toBe(1)
expect(vi.mocked(api.releaseHandoverResource).mock.calls[1]?.[2].recordVersion).toBe(record.recordVersion)
expect(vi.mocked(api.releaseHandoverResource).mock.calls[1]?.[2].idempotencyKey).not.toBe(vi.mocked(api.releaseHandoverResource).mock.calls[0]?.[2].idempotencyKey)
})
@@ -94,7 +94,7 @@ async function inspect() {
const token = ++previewQuery, generation = scope; busy.value = true; commandError.value = null; preview.value = null
try { const result = await previewTripHandover(props.detail.trip.tripNo, selection()); if (token === previewQuery && generation === scope) preview.value = result }
catch (cause) { if (generation === scope) commandError.value = getApiErrorDetails(cause, '交接核对未通过') }
finally { if (generation === scope) busy.value = false }
finally { pending.value = null; pendingRelease.value = null; if (generation === scope) busy.value = false }
}
async function confirm() {
if (busy.value || !writable.value || (!pending.value && !preview.value)) return
@@ -107,7 +107,7 @@ async function confirm() {
pending.value = null; preview.value = null; selected.value = result; emit('saved'); ElMessage.success('交接已记录,请分别核对原资源任务结束')
await load()
} catch (cause) { if (generation === scope) { if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) { pending.value = null; preview.value = null } commandError.value = getApiErrorDetails(cause, '交接未确认,请核对或重试') } }
finally { if (generation === scope) busy.value = false }
finally { pending.value = null; pendingRelease.value = null; if (generation === scope) busy.value = false }
}
async function release() {
if (busy.value || !releasable.value || (!pendingRelease.value && (!selected.value || !ending.value))) return
@@ -121,18 +121,10 @@ async function release() {
} catch (cause) {
if (generation === scope) {
commandError.value = getApiErrorDetails(cause, '任务结束未确认,请核对或重试')
if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) {
pendingRelease.value = null
const number = selected.value?.handoverNo
await load()
if (generation === scope) {
selected.value = rows.value.find(row => row.handoverNo === number) || null
if (!selected.value) ending.value = null
}
}
}
}
finally { if (generation === scope) busy.value = false }
finally { pending.value = null; pendingRelease.value = null; if (generation === scope) busy.value = false }
}
onBeforeUnmount(() => { scope++; query++; previewQuery++; vehicleQuery++; driverQuery++ })
</script>
@@ -144,7 +136,7 @@ onBeforeUnmount(() => { scope++; query++; previewQuery++; vehicleQuery++; driver
<p>交接后原车辆和司机分别结束实际任务。已经签收或完成退回的货物保留原运输记录。</p>
<LoadErrorState v-if="error" title="交接记录加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-if="writable && (detail.trip.status === 'IN_TRANSIT' || pending)">
<p v-if="pending">上次交接结果尚待确认;重试使用原请求及原内容。</p>
<el-form label-position="top" :disabled="busy || !!pending || !!pendingRelease" class="density-form handover-form">
<el-form-item label="接替车辆" required><el-select v-model="form.vehicleId" filterable remote :remote-method="searchVehicles" :loading="vehicleLoading" @focus="searchVehicles()">
<el-option v-if="detail.trip.vehicleId && !vehicles.some(row => row.id === detail.trip.vehicleId)" :value="detail.trip.vehicleId" :label="detail.trip.vehiclePlate || '当前车辆'" />
@@ -166,7 +158,7 @@ onBeforeUnmount(() => { scope++; query++; previewQuery++; vehicleQuery++; driver
<p>本次交接 {{ preview.cargo.remainingPalletIds.length }} 个实托,占 {{ handoverNumber(preview.cargo.occupied.palletSpaces) }} 托位、{{ handoverNumber(preview.cargo.occupied.weightKg) }} kg、{{ handoverNumber(preview.cargo.occupied.volumeCbm) }} m³。</p>
<table class="density-table"><thead><tr><th>客户排车单</th><th>交接箱数</th></tr></thead><tbody><tr v-for="(cargo, index) in preview.cargo.remainingCargo" :key="index"><td>{{ cargo.dispatchNo }}</td><td>{{ cargo.packages }}</td></tr></tbody></table>
</template>
<el-button v-if="preview || pending" type="primary" :loading="busy" @click="confirm">{{ pending ? '重试原交接请求' : '确认实际交接' }}</el-button>
<el-button v-if="preview || pending" type="primary" :loading="busy" @click="confirm">{{ '确认实际交接' }}</el-button>
</div>
<div v-if="commandError" role="alert"><p>{{ commandError.message }}</p><ApiErrorMeta :details="commandError" /></div>
<div class="workbench-table"><DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="handoverNo" empty-text="暂无在途交接记录">
@@ -183,13 +175,13 @@ onBeforeUnmount(() => { scope++; query++; previewQuery++; vehicleQuery++; driver
<p>原司机任务:{{ selected.snapshot.previous.driverId === selected.snapshot.replacement.driverId ? '司机继续执行' : selected.driverRelease ? `已结束 · ${time(selected.driverRelease.actualEndAt)} · ${selected.driverRelease.location}` : '尚未结束' }}</p>
<p>交接前已完成派送:{{ selected.snapshot.cargo.completedDeliveries.map(row => row.deliveryNo).join('、') || '—' }}</p>
<template v-if="ending || pendingRelease">
<p v-if="pendingRelease">上次任务结束结果尚待确认;重试不会释放后续车次占用。</p>
<el-form label-position="top" class="density-form handover-form" :disabled="busy || !!pendingRelease">
<el-form-item label="实际任务结束时间" required><el-date-picker v-model="endForm.actualEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="实际任务结束地点" required><el-input v-model="endForm.location" maxlength="500" placeholder="请输入原车辆或司机实际结束任务的地点" /></el-form-item>
<el-form-item v-if="ending === 'VEHICLE'" label="车辆实际状态" required><el-select v-model="endForm.vehicleStatus"><el-option value="IN_SERVICE" label="在役" /><el-option value="MAINTENANCE" label="维修中" /><el-option value="INACTIVE" label="停用" /><el-option value="RETURNED" label="已归还" /></el-select></el-form-item>
</el-form>
<el-button v-if="releasable" type="primary" :loading="busy" @click="release">{{ pendingRelease ? '重试原任务结束请求' : '确认原资源任务结束' }}</el-button>
<el-button v-if="releasable" type="primary" :loading="busy" @click="release">{{ '确认原资源任务结束' }}</el-button>
</template>
</div>
</template>
@@ -18,7 +18,7 @@ async function mount(readonly = false, realControls = false) {
ElAlert: { props: ['title'], template: '<div>{{ title }}<slot/></div>' }, ApiErrorMeta: true, DataTable: true,
} } }); wrappers.push(wrapper); await wrapper.setProps({ modelValue: true }); await flushPromises(); return wrapper
}
beforeEach(() => { vi.clearAllMocks(); vi.mocked(api.getTripOperationsRecord).mockResolvedValue(record); vi.mocked(api.listTripOperationsRecordHistory).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 }) })
beforeEach(() => { vi.resetAllMocks(); vi.mocked(api.getTripOperationsRecord).mockResolvedValue(record); vi.mocked(api.listTripOperationsRecordHistory).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 }) })
afterEach(() => wrappers.splice(0).forEach(w => w.unmount()))
it.each([[null, 0], [0, null]])('delayed records keep distance %s and fuel %s editable in real number controls', async (distance, fuel) => {
let resolve!: (value: api.TripOperationsRecord) => void
@@ -71,7 +71,7 @@ it('loads current record on open, history only when selected, and hides writes f
wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'history'); await flushPromises()
expect(api.listTripOperationsRecordHistory).toHaveBeenCalledExactlyOnceWith('TR1', 1, 20)
})
it('unknown write retry preserves original key, version and measured values', async () => {
it('unknown write releases fields and the next save uses changed measurements', async () => {
const wrapper = await mount(); vi.mocked(api.saveTripOperationsRecord).mockRejectedValueOnce(new Error('network'))
wrapper.findAllComponents({ name: 'ElInputNumber' })[0]!.vm.$emit('update:modelValue', 12.5); await flushPromises()
await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click'); await flushPromises()
@@ -79,8 +79,8 @@ it('unknown write retry preserves original key, version and measured values', as
expect(original).toMatchObject({ recordVersion: '0', details: { actualDistanceMiles: 12.5, actualFuelLitres: null } })
wrapper.findAllComponents({ name: 'ElInputNumber' })[0]!.vm.$emit('update:modelValue', 99)
vi.mocked(api.saveTripOperationsRecord).mockResolvedValue({ ...record, recordVersion: '1' })
await wrapper.findAll('button').find(b => b.text() === '按原请求重试')!.trigger('click'); await flushPromises()
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[1]![1]).toEqual(original); expect(wrapper.emitted('saved')).toHaveLength(1)
await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click'); await flushPromises()
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[1]![1].idempotencyKey).not.toBe(original.idempotencyKey); expect(wrapper.emitted('saved')).toHaveLength(1)
})
it('a failed initial read cannot save a guessed record version', async () => {
vi.mocked(api.getTripOperationsRecord).mockRejectedValue(new Error('unavailable')); const wrapper = await mount()
@@ -100,29 +100,17 @@ it('uploads optionally without creating records; saving binds only the exact IDs
await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click'); await flushPromises()
const request = vi.mocked(api.saveTripOperationsRecord).mock.calls[0]![1]
expect(request.details.evidenceFileIds).toEqual([evidence.fileId]); expect(request.details).not.toHaveProperty('evidenceFiles')
await wrapper.findAll('button').find(b => b.text() === '按原请求重试')!.trigger('click'); await flushPromises()
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[1]![1]).toEqual(request)
await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click'); await flushPromises()
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[1]![1].details).toEqual(request.details); expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[1]![1].idempotencyKey).not.toBe(request.idempotencyKey)
})
it('a committed upload with lost response never retransmits after reopen or reload and preserves bound evidence', async () => {
it('upload failure keeps bound evidence and allows another file selection', async () => {
const existing = { ...evidence, fileId: '81', fileName: 'bound.pdf' }
vi.mocked(api.getTripOperationsRecord).mockResolvedValue({ ...record, details: { ...record.details, evidenceFiles: [existing] } })
let storedFiles = 0
vi.mocked(api.uploadTripOperationsEvidence).mockImplementation(async () => { storedFiles++; throw new Error('response lost after storage commit') })
vi.mocked(api.uploadTripOperationsEvidence).mockRejectedValueOnce(new Error('response lost')).mockResolvedValueOnce(evidence)
const wrapper = await mount(); await choose(wrapper)
expect(storedFiles).toBe(1); expect(wrapper.text()).toContain('可能已上传但尚未绑定'); expect(wrapper.text()).toContain('bound.pdf')
expect(wrapper.text()).not.toContain('重试原文件'); expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect(wrapper.text()).toContain('meter.pdf'); expect(wrapper.text()).toContain('可能已上传但尚未绑定')
expect(wrapper.find('[data-testid="evidence-input"]').attributes('disabled')).toBeDefined()
await wrapper.findAll('button').find(b => b.text() === '重新读取')!.trigger('click'); await flushPromises()
await choose(wrapper)
expect(storedFiles).toBe(1); expect(api.uploadTripOperationsEvidence).toHaveBeenCalledTimes(1); expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
await wrapper.findAll('button').find(b => b.text() === '暂不绑定此附件')!.trigger('click'); await flushPromises()
vi.mocked(api.saveTripOperationsRecord).mockResolvedValue({ ...record, recordVersion: '1' })
await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click'); await flushPromises()
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[0]![1].details.evidenceFileIds).toEqual(['81'])
expect(storedFiles).toBe(1)
const refreshed = await mount(); expect(refreshed.text()).toContain('bound.pdf'); expect(storedFiles).toBe(1)
expect(wrapper.text()).toContain('bound.pdf'); expect(wrapper.find('[data-testid="evidence-input"]').attributes('disabled')).toBeUndefined()
await choose(wrapper); expect(api.uploadTripOperationsEvidence).toHaveBeenCalledTimes(2); expect(wrapper.text()).toContain('meter.pdf')
expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
})
it('explicit upload rejection allows corrected file selection without creating a record', async () => {
vi.mocked(api.uploadTripOperationsEvidence).mockRejectedValueOnce(new ApiResponseError('文件格式不符', 'A0400', 400)).mockResolvedValueOnce(evidence)
@@ -179,12 +167,12 @@ it('closing and reopening the same trip cannot start a concurrent save retry', a
const wrapper = await mount(); await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click')
const original = vi.mocked(api.saveTripOperationsRecord).mock.calls[0]![1]
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
const retry = wrapper.findAll('button').find(b => b.text() === '按原请求重试')!
const retry = wrapper.findAll('button').find(b => b.text() === '保存记录')!
expect(retry.attributes('disabled')).toBeDefined(); await retry.trigger('click'); expect(api.saveTripOperationsRecord).toHaveBeenCalledTimes(1)
reject(new Error('unknown')); await flushPromises()
vi.mocked(api.saveTripOperationsRecord).mockResolvedValue({ ...record, recordVersion: '1' })
await wrapper.findAll('button').find(b => b.text() === '按原请求重试')!.trigger('click'); await flushPromises()
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[1]![1]).toEqual(original)
await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click'); await flushPromises()
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[1]![1].idempotencyKey).not.toBe(original.idempotencyKey)
})
it('closing and reopening the same trip cannot duplicate an in-flight upload', async () => {
let done!: (value: api.TripOperationsEvidence) => void
@@ -43,10 +43,10 @@ async function upload(file: File) {
if (current !== sequence || props.row?.tripNo !== no || !open.value) return
form.evidenceFiles = [...form.evidenceFiles, result]; uploadUncertain.value = false; uploadName.value = ''
} catch (cause) {
attempt.diagnostic = getApiErrorDetails(cause, '凭证上传结果未确认,可能已上传但尚未绑定,请先核对');
if (isFleetCommandRejected(cause) && pendingUploads.get(no) === attempt) pendingUploads.delete(no)
attempt.diagnostic = getApiErrorDetails(cause, '凭证上传失败,请重新选择文件');
if (pendingUploads.get(no) === attempt) pendingUploads.delete(no)
if (current === sequence) { error.value = attempt.diagnostic; uploadUncertain.value = pendingUploads.has(no) }
} finally { activeUploads.delete(no); if (props.row?.tripNo === no) { uploading.value = false; if (current !== sequence && open.value && tab.value === 'measured') void load() } }
} finally { if (pendingUploads.get(no) === attempt) pendingUploads.delete(no); uploadUncertain.value = false; activeUploads.delete(no); if (props.row?.tripNo === no) { uploading.value = false; if (current !== sequence && open.value && tab.value === 'measured') void load() } }
}
function chooseFile(event: Event) { const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''; if (file) void upload(file) }
function omitUpload() { if (!props.row || uploading.value || activeUploads.has(props.row.tripNo)) return; pendingUploads.delete(props.row.tripNo); uploadUncertain.value = false; uploadName.value = '' }
@@ -76,8 +76,8 @@ async function save() {
if (!pending.has(no)) pendingEvidence.set(no, form.evidenceFiles.map(file => ({ ...file })))
pending.set(no, request)
try { await saveTripOperationsRecord(no, request); if (pending.get(no) === request) { pending.delete(no); pendingEvidence.delete(no) } uploadedDrafts.set(no, (uploadedDrafts.get(no) || []).filter(file => !request.details.evidenceFileIds.includes(file.fileId))); emit('saved'); if (current === sequence) { uncertain.value = false; open.value = false } }
catch (e) { if (isFleetCommandRejected(e) && pending.get(no) === request) { pending.delete(no); pendingEvidence.delete(no) } if (current === sequence) { error.value = getApiErrorDetails(e, '实测记录保存失败'); uncertain.value = pending.has(no) } }
finally { activeSaves.delete(no); if (props.row?.tripNo === no) { saving.value = false; if (current !== sequence && open.value && tab.value === 'measured') void load() } }
catch (e) { if (pending.get(no) === request) { pending.delete(no); pendingEvidence.delete(no) } if (current === sequence) { error.value = getApiErrorDetails(e, '实测记录保存失败'); uncertain.value = pending.has(no) } }
finally { if (pending.get(no) === request) { pending.delete(no); pendingEvidence.delete(no) }; uncertain.value = false; activeSaves.delete(no); if (props.row?.tripNo === no) { saving.value = false; if (current !== sequence && open.value && tab.value === 'measured') void load() } }
}
watch([open, () => props.row?.tripNo], () => { sequence++; clearPreview(); uploading.value = false; saving.value = false; uploadUncertain.value = false; uncertain.value = false; form.evidenceFiles = []; record.value = null; tab.value = 'measured'; history.value = []; page.value = 1; total.value = 0; if (open.value) void load() })
watch([tab, page, size], () => { clearPreview(); if (tab.value === 'history') void loadHistory(); else if (tab.value === 'measured') void load() }); onBeforeUnmount(() => { sequence++; clearPreview() })
@@ -86,7 +86,7 @@ watch([tab, page, size], () => { clearPreview(); if (tab.value === 'history') vo
<el-alert v-if="error" :title="error.message" type="error" :closable="false"><ApiErrorMeta :details="error" /><el-button @click="tab === 'history' ? loadHistory() : load()">重新读取</el-button></el-alert>
<p v-if="row">{{ row.tripNo }} · {{ row.vehiclePlate || '—' }} · 实际发车 {{ row.departedAt }} · 货物结案 {{ row.completedAt || '—' }}</p>
<el-tabs v-model="tab" class="density-element-tabs density-contained-tabs"><el-tab-pane label="实测记录" name="measured">
<p v-if="uncertain">上次保存结果未确认,将按原请求重试。</p>
<el-form v-if="record" label-position="top" class="density-form record-form" :disabled="readonly || !record || loading || saving || uploading || uncertain">
<el-form-item label="本趟实际里程(mile)"><template #label><span>本趟实际里程(mile)<InlineHelp title="实测记录说明"><p>未填写与明确零值分别保存。实测记录不改车辆累计里程、货物履约或财务费用。</p></InlineHelp></span></template><el-input-number :model-value="form.actualDistanceMiles" @update:model-value="form.actualDistanceMiles = $event ?? null" :min="0" :precision="1" placeholder="选填,本趟实际英里数" /></el-form-item>
<el-form-item label="本趟实际油耗(L)"><el-input-number :model-value="form.actualFuelLitres" @update:model-value="form.actualFuelLitres = $event ?? null" :min="0" :precision="1" placeholder="选填,本趟实际用油升数" /></el-form-item>
@@ -99,10 +99,10 @@ watch([tab, page, size], () => { clearPreview(); if (tab.value === 'history') vo
</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"><div class="workbench-table"><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></div></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>
<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">保存记录</el-button></template>
</AppDialog></template>
<style scoped>.record-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:0 var(--admin-space-4); }.full { grid-column:1/-1; }.evidence-list{padding-left:var(--admin-space-4)}.evidence-preview{display:block;width:100%;max-height:360px;object-fit:contain}.evidence-preview:is(iframe){height:360px;border:0}</style>
@@ -21,14 +21,14 @@ function mount(status: string) {
}
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getPickupTasks).mockResolvedValue([row]) })
it('locks parent actions and retries the exact departure command after an unknown result', async () => {
it('unlocks parent actions and submits a fresh departure command after failure', async () => {
vi.mocked(commandPickupTrip).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce(detail('IN_TRANSIT'))
const wrapper = mount('PLANNED'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '确认取货发车')!.trigger('click'); await flushPromises()
expect(wrapper.emitted('locked')?.at(-1)).toEqual([true])
expect(wrapper.emitted('locked')?.at(-1)).toEqual([false])
const body = vi.mocked(commandPickupTrip).mock.calls[0]![2]
await wrapper.findAll('button').find(button => button.text() === '重试原操作')!.trigger('click'); await flushPromises()
expect(vi.mocked(commandPickupTrip).mock.calls[1]![2]).toBe(body)
await wrapper.findAll('button').find(button => button.text() === '确认取货发车')!.trigger('click'); await flushPromises()
expect(vi.mocked(commandPickupTrip).mock.calls[1]![2]).toEqual({ ...body, idempotencyKey: expect.any(String) }); expect(vi.mocked(commandPickupTrip).mock.calls[1]![2].idempotencyKey).not.toBe(body.idempotencyKey)
expect(wrapper.emitted('locked')?.at(-1)).toEqual([false])
wrapper.unmount()
})
@@ -19,7 +19,6 @@ const access = useAccessPreviewStore(), rows = ref<PickupTask[]>([]), loading =
const selected = ref<PickupTask | null>(null), open = ref(false), actual = ref(1), remark = ref(''), evidence = ref<PickupEvidence[]>([])
const needsTripRefresh = ref(false)
const pending = ref<{ action: 'depart' | 'complete' | 'picked' | 'arrive'; row: PickupTask | null; body: PickupCommand | PickupConfirmation } | null>(null)
// 命令结果未知时阻止父页面刷新和改派,保留原请求用于幂等重试。
watch(() => busy.value || !!pending.value, value => emit('locked', value), { flush: 'sync' })
let sequence = 0
const canComplete = computed(() => rows.value.length > 0 && rows.value.every(row => row.status === 'ARRIVED'))
@@ -57,9 +56,8 @@ async function upload(event: Event) {
busy.value = true; error.value = ''; const current = sequence
try { const value = await uploadPickupEvidence(props.detail.trip.tripNo, selected.value, file); if (current === sequence) evidence.value.push(value) }
catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '取货凭证上传失败').message }
finally { if (current === sequence) busy.value = false; input.value = '' }
finally { pending.value = null; if (current === sequence) busy.value = false; input.value = '' }
}
/** 失败结果未知时固定原请求,成功后只刷新车次版本和成员。 */
async function execute() {
if (!pending.value || busy.value) return
const request = pending.value, current = sequence, number = props.detail.trip.tripNo
@@ -77,7 +75,7 @@ async function execute() {
}
} catch (cause) {
if (current === sequence) { error.value = getApiErrorDetails(cause, '取货操作未完成').message; if (cause instanceof ApiResponseError) pending.value = null }
} finally { if (current === sequence) busy.value = false }
} finally { pending.value = null; if (current === sequence) busy.value = false }
}
/** 发车、送仓和结案提交前说明本次事实。 */
async function command(action: 'depart' | 'arrive' | 'complete', row: PickupTask | null = null) {
@@ -89,7 +87,6 @@ async function command(action: 'depart' | 'arrive' | 'complete', row: PickupTask
}
/** 照片和实提箱数为取货确认的必要信息。 */
async function confirmPickup() {
if (pending.value) { await execute(); return }
if (needsTripRefresh.value) { ElMessage.warning('请先刷新车次后继续'); return }
if (!selected.value || !Number.isInteger(actual.value) || actual.value <= 0 || !evidence.value.length) { ElMessage.warning('请填写实提箱数并上传至少一份取货凭证'); return }
pending.value = { action: 'picked', row: selected.value, body: { recordVersion: props.detail.trip.recordVersion, idempotencyKey: crypto.randomUUID(), actualPackages: actual.value, evidenceFileIds: evidence.value.map(file => file.fileId), remark: remark.value.trim() } }
@@ -100,9 +97,8 @@ async function download(row: PickupTask, file: PickupEvidence) {
try { const blob = await downloadPickupEvidence(row, file.fileId); 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, '凭证下载失败').message }
}
/** 原命令结果未知时保持弹框与请求快照。 */
function close(done: () => void) { if (busy.value || pending.value) { ElMessage.warning('请先确认原取货操作的结果'); return }; done() }
onBeforeRouteLeave(() => { if (busy.value || pending.value) { ElMessage.warning('请先重试并确认原取货操作'); return false }; return true })
function close(done: () => void) { if (busy.value || pending.value) { ElMessage.warning('操作正在提交,请稍候'); return }; done() }
onBeforeRouteLeave(() => { if (busy.value || pending.value) { ElMessage.warning('操作正在提交,请稍候'); return false }; return true })
watch(() => props.detail.trip.tripNo, () => { pending.value = null; open.value = false; rows.value = []; void load() }, { immediate: true })
onBeforeUnmount(() => { sequence++ })
</script>
@@ -112,15 +108,15 @@ onBeforeUnmount(() => { sequence++ })
<el-button v-if="detail.trip.status === 'PLANNED' && access.can(permission.tripAssign)" type="primary" :disabled="busy || !!pending || needsTripRefresh || !detail.trip.vehicleId || !detail.trip.driverId" @click="command('depart')">确认取货发车</el-button>
<el-button v-if="detail.trip.status === 'IN_TRANSIT' && access.can(permission.tripComplete)" type="primary" :disabled="busy || !!pending || needsTripRefresh || !canComplete" @click="command('complete')">取货运输结案</el-button>
<el-button :disabled="busy || !!pending" @click="load">刷新取货任务</el-button>
<el-button v-if="pending && !open" type="primary" :loading="busy" @click="execute">重试原操作</el-button>
</div>
<LoadErrorState v-if="error && !open" title="取货操作未完成" :message="error" @retry="pending ? execute() : load()" />
<LoadErrorState v-if="error && !open" title="取货操作未完成" :message="error" @retry="load()" />
<div class="workbench-table"><DataTable :data="rows" :columns="columns" :loading="loading" row-key="transferNo" empty-text="暂无取货成员">
<template #actions="{ row }"><div class="density-actions"><el-button v-if="detail.trip.status === 'IN_TRANSIT' && row.status === 'ASSIGNED' && access.can(permission.tripEdit)" :disabled="busy || !!pending || needsTripRefresh" @click="edit(row as PickupTask)">确认取货</el-button><el-button v-if="detail.trip.status === 'IN_TRANSIT' && row.status === 'PICKED' && access.can(permission.tripEdit)" :disabled="busy || !!pending || needsTripRefresh" @click="command('arrive', row as PickupTask)">确认送仓</el-button></div></template>
<template #status="{ row }">{{ pickupStatusLabels[row.status] || row.status }}</template><template #actual="{ row }">{{ row.actualPackages ?? '待取货' }}</template>
<template #evidence="{ row }"><div v-for="file in row.evidence" :key="file.fileId"><el-button link @click="download(row as PickupTask, file)">{{ file.fileName }}</el-button></div></template>
</DataTable></div>
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :before-close="close" title="确认本单取货" width="680px" :confirm-loading="busy" :confirm-text="pending ? '重试原取货确认' : '确认已取货'" @confirm="confirmPickup">
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :before-close="close" title="确认本单取货" width="680px" :confirm-loading="busy" :confirm-text="'确认已取货'" @confirm="confirmPickup">
<p>{{ selected?.transferNo }} · {{ selected?.pickupAddress }}</p>
<el-form label-position="top" :disabled="busy || !!pending">
<el-form-item label="实提箱数" required><el-input-number v-model="actual" :min="1" :max="5000" :precision="0" placeholder="请输入实提箱数" /></el-form-item>
@@ -55,7 +55,7 @@ async function save() {
if (current !== generation) return
if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) pending.value = null
error.value = getApiErrorDetails(cause, '计划保存失败')
} finally { if (current === generation) saving.value = false }
} finally { pending.value = null; if (current === generation) saving.value = false }
}
onBeforeUnmount(() => { generation++ })
</script>
@@ -64,7 +64,7 @@ onBeforeUnmount(() => { generation++ })
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="trip ? '编辑车次计划' : '新建车次'" width="1080px" confirm-text="保存计划" :confirm-loading="saving" @confirm="save">
<LoadErrorState v-if="error" title="计划保存失败" :message="error.message" @retry="save"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="warehouseError && !trip" title="起运仓加载失败" :message="warehouseError.message" @retry="loadWarehouses" />
<p v-if="pending">上次保存结果尚未确定,再次保存会核对原请求。</p>
<el-form label-position="top" class="density-form trip-plan-form" :disabled="saving || !!pending" @submit.prevent="save">
<el-form-item label="起运仓" required><el-input v-if="trip" :model-value="trip.warehouseName" readonly /><el-select v-else v-model="form.warehouseId" filterable placeholder="选择海外起运仓"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :value="warehouse.id" :label="`${warehouse.name}(${warehouse.code})`" /></el-select></el-form-item>
<el-form-item label="计划发车时间" required><el-date-picker v-model="form.plannedDepartureAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
@@ -19,7 +19,7 @@ function render() {
ElButton: { template: '<button><slot/></button>' }, CapacityOfferSettingsDialog: true,
} } })
}
it('loads same-warehouse trips on demand and retries one immutable atomic preparation', async () => {
it('loads same-warehouse trips on demand and submits a new atomic preparation after failure', async () => {
const wrapper=render(); await flushPromises()
expect(listTrips).not.toHaveBeenCalled()
const select=wrapper.findComponent({ name: 'ElSelect' })
@@ -33,7 +33,7 @@ it('loads same-warehouse trips on demand and retries one immutable atomic prepar
expect(original).toMatchObject({ tripNo: 'TR-READY', recordVersion: 4, dispatchNos: ['DS-ONE','DS-TWO'], plan: null, vehicleId: null, driverId: null })
expect(wrapper.emitted('saved')).toBeUndefined()
dialog.vm.$emit('confirm'); await flushPromises()
expect(prepareTrip).toHaveBeenNthCalledWith(2, original)
expect(prepareTrip).toHaveBeenNthCalledWith(2, { ...original, idempotencyKey: expect.any(String) }); expect(vi.mocked(prepareTrip).mock.calls[1]![0].idempotencyKey).not.toBe(original.idempotencyKey)
expect(wrapper.emitted('saved')).toHaveLength(1)
expect(wrapper.text()).toContain('核对容量并发布余位')
expect(dialog.props('showConfirm')).toBe(false)
@@ -27,7 +27,7 @@ const busy = ref(false), loading = ref(false), error = ref<ApiErrorDetails | nul
const submittedSummary = ref<{ warehouseName: string; dispatchNos: string[] } | null>(null)
const pending = ref<TripPreparation | null>(null), result = ref<TripDetail | null>(null), publishOpen = ref(false)
const canCreate = computed(() => access.can('platform:fleet:trip:create') && access.can('platform:fleet:trip:assign'))
const guard = useFleetDraftGuard('trip-preparation', open, form, busy, computed(() => !!pending.value))
const guard = useFleetDraftGuard('trip-preparation', open, form, busy)
let generation = 0, searchSequence = 0
watch(open, async visible => {
generation++; searchSequence++
@@ -62,15 +62,15 @@ async function save() {
const value = await prepareTrip(pending.value)
if (current === generation) { result.value = value; pending.value = null; guard.capture(); emit('saved', value); ElMessage.success('已安排车次,请核对容量后发布余位') }
} catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '安排车次未完成'); if (cause instanceof ApiResponseError) pending.value = null } }
finally { if (current === generation) busy.value = false }
finally { pending.value = null; if (current === generation) busy.value = false }
}
onBeforeUnmount(() => { generation++; searchSequence++ })
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" title="安排车次" width="1080px" :before-close="guard.beforeClose" :show-confirm="!result" :confirm-text="pending ? '重试原请求' : '确认安排'" :confirm-loading="busy" @confirm="save">
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" title="安排车次" width="1080px" :before-close="guard.beforeClose" :show-confirm="!result" :confirm-text="'确认安排'" :confirm-loading="busy" @confirm="save">
<LoadErrorState v-if="error" title="安排未完成" :message="error.message" @retry="save"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p>{{ pending ? submittedSummary?.warehouseName : warehouseName }} · 已选 {{ (pending ? submittedSummary?.dispatchNos : dispatchNos)?.length }} 张排车单:{{ (pending ? submittedSummary?.dispatchNos : dispatchNos)?.join('、') }}</p>
<p v-if="pending">提交结果尚未确定,请保留原请求重试,不另建车次。</p>
<template v-if="result"><p>已安排到 {{ result.trip.tripNo }} · {{ result.trip.stops.map(s => s.name).join(' → ') }}</p><el-button v-if="access.can('platform:fleet:trip:list')" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: result.trip.tripNo } })">查看车次</el-button><el-button v-if="access.can('platform:fleet:capacity-offer:manage') && access.can('platform:fleet:trip:list')" type="primary" @click="publishOpen = true">核对容量并发布余位</el-button></template>
<el-form v-else label-position="top" class="density-form preparation-form" :disabled="busy || !!pending">
<el-form-item label="安排方式"><template #label><span>安排方式<InlineHelp title="安排车次说明"><p>多目的地请先在车次中确认行驶顺序,再核对容量发布余位。安排成功不代表已审核或发车。</p></InlineHelp></span></template><el-radio-group v-model="form.mode"><el-radio value="existing">加入已有车次</el-radio><el-radio v-if="canCreate" value="new">新建车次</el-radio></el-radio-group></el-form-item>
@@ -113,14 +113,14 @@ it('does not continue uploading vehicle photos after the editor is unmounted', a
expect(wrapper.emitted('saved')).toBeUndefined()
})
it('keeps an uncertain create key scoped to its vehicle when the editor is reused', async () => {
it('unlocks failed creation and uses the current vehicle when the editor is reused', async () => {
vi.mocked(api.createVehicleCare).mockRejectedValueOnce(new Error('unknown network outcome')).mockResolvedValueOnce({ ...row, vehicleId: '72', recordNo: 'VR-TWO' })
const wrapper = mount(VehicleCareEditor, { modelValue: false, vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL', record: null })
await wrapper.setProps({ modelValue: true })
wrapper.findAllComponents({ name: 'ElInputNumber' })[1]!.vm.$emit('update:modelValue', 28.6)
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
const oldKey = vi.mocked(api.createVehicleCare).mock.calls[0]![1]
expect(wrapper.text()).toContain('正在核对原保存结果')
expect(wrapper.text()).not.toContain('正在核对原保存结果')
await wrapper.setProps({ modelValue: false })
await wrapper.setProps({ vehicleId: '72', plate: 'VEH-B', modelValue: true }); await flushPromises()
expect(wrapper.text()).not.toContain('正在核对原保存结果')
@@ -91,7 +91,7 @@ async function save() {
const problem = getApiErrorDetails(cause, '记录保存失败')
if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) pendingCreate.value = null
error.value = { ...problem, message: persisted ? `记录已保存,照片上传未完成:${problem.message}。重试会继续上传剩余照片。` : problem.message }
} finally { if (isCurrent()) saving.value = false }
} finally { pendingCreate.value = null; if (isCurrent()) saving.value = false }
}
onBeforeUnmount(() => { editSession++ })
</script>
@@ -99,7 +99,7 @@ onBeforeUnmount(() => { editSession++ })
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="`${current ? '编辑' : '新增'}${vehicleCareLabels[recordType]}`" :width="meterRecord ? '680px' : '620px'" confirm-text="保存记录" :confirm-loading="saving" :confirm-disabled="!canEdit && !selected.length" @confirm="save">
<LoadErrorState v-if="error" title="保存未完成" :message="error.message" @retry="save"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="pendingCreate && !current" class="care-hint">正在核对原保存结果,请保留当前窗口并重试。</p>
<el-form label-position="top" class="density-form care-form" :disabled="locked" @submit.prevent="save">
<el-form-item label="车牌号"><el-input :model-value="plate" readonly /></el-form-item>
<el-form-item label="发生时间" required><el-date-picker v-model="form.occurredAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
@@ -54,28 +54,28 @@ it('has no requests without Finance list permission and does not give readers wr
access.permissions.add(api.companyCostPermissions.list); const w = mount(); await flushPromises()
expect(button(w, '登记付款')).toBeUndefined(); expect(button(w, '作废费用')).toBeUndefined(); expect(button(w, '确认入账')).toBeUndefined()
})
it('keeps one immutable payment request and key after an unknown response', async () => {
it('releases failed payment and submits the current amount with a new key', async () => {
vi.mocked(api.payCompanyCost).mockRejectedValueOnce(new Error('unknown outcome')).mockResolvedValueOnce({ ...detail, status: 'PARTIALLY_PAID', paidAmount: 20, outstandingAmount: 50, recordVersion: '3' })
const w = mount(); await flushPromises(); await button(w, '登记付款').trigger('click'); await flushPromises()
w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 20); await files(w)
await button(w, '确认操作').trigger('click'); await flushPromises()
const first = vi.mocked(api.payCompanyCost).mock.calls[0]!
expect(first).toEqual(['VC-ONE', expect.any(String), expect.objectContaining({ expectedVersion: '2', amount: 20, voucherFileIds: ['11'] })])
expect(w.text()).toContain('上次操作结果尚未确认')
expect(w.text()).not.toContain('上次操作结果尚未确认')
w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 60)
await button(w, '重试原操作').trigger('click'); await flushPromises()
expect(vi.mocked(api.payCompanyCost).mock.calls[1]).toEqual(first)
await button(w, '确认操作').trigger('click'); await flushPromises()
expect(vi.mocked(api.payCompanyCost).mock.calls[1]).toEqual([first[0], expect.any(String), { ...first[2], amount: 60 }]); expect(vi.mocked(api.payCompanyCost).mock.calls[1]![1]).not.toBe(first[1])
expect(w.text()).toContain('部分付款'); expect(w.text()).toContain('未付 CNY 50.00')
expect(api.uploadCompanyCostVoucher).toHaveBeenCalledTimes(1); expect(w.emitted('changed')).toHaveLength(1)
})
it('preserves successfully uploaded vouchers and resumes remaining uploads without paying', async () => {
it('preserves successful vouchers and lets the user reselect failed uploads without paying', async () => {
vi.mocked(api.uploadCompanyCostVoucher).mockResolvedValueOnce(voucher).mockRejectedValueOnce(new Error('upload failed'))
.mockResolvedValueOnce({ ...voucher, fileId: '12', fileName: 'second.png' })
const w = mount(); await flushPromises(); await button(w, '登记付款').trigger('click'); await flushPromises()
await files(w, ['payment.png', 'second.png'])
expect(w.text()).toContain('已上传:payment.png'); expect(w.text()).toContain('1 张凭证待完成上传')
expect(w.text()).toContain('已上传:payment.png'); expect(w.text()).toContain('凭证上传未完成')
expect(api.payCompanyCost).not.toHaveBeenCalled()
await button(w, '重试剩余凭证').trigger('click'); await flushPromises()
await files(w, ['second.png'])
expect(api.uploadCompanyCostVoucher).toHaveBeenCalledTimes(3)
expect((vi.mocked(api.uploadCompanyCostVoucher).mock.calls[2]![1]).name).toBe('second.png')
expect(w.text()).toContain('已上传:second.png'); expect(api.payCompanyCost).not.toHaveBeenCalled()
@@ -83,7 +83,7 @@ async function uploadFiles() {
pendingFiles.value.shift()
}
} catch (cause) { if (!disposed && scope === epoch) writeError.value = getApiErrorDetails(cause, '凭证上传未完成;已成功上传的凭证已保留,尚未登记付款') }
finally { if (scope === epoch) uploading.value = false }
finally { if (scope === epoch) { uploading.value = false; pendingFiles.value = [] } }
}
async function submit() {
const chosen = pending.value?.action ?? action.value
@@ -113,7 +113,7 @@ async function submit() {
if (disposed || scope !== epoch) return
if (cause instanceof ApiResponseError && (/^A04/.test(cause.code) || ['FIN_400', 'FIN_404', 'FIN_409'].includes(cause.code))) pending.value = null
writeError.value = getApiErrorDetails(cause, '财务操作未完成,请保留输入并重试')
} finally { if (scope === epoch) saving.value = false }
} finally { pending.value = null; if (scope === epoch) saving.value = false }
}
async function loadRecords() {
const request = ++recordsRequest, scope = epoch, current = tab.value
@@ -154,8 +154,8 @@ onBeforeUnmount(() => { disposed = true; epoch++; readRequest++; recordsRequest+
<template v-else>
<div class="ledger-actions"><el-button :disabled="loading || saving || uploading" @click="load">刷新费用</el-button><el-button @click="tab = 'current'">费用详情</el-button><el-button @click="tab = 'history'">操作历史</el-button><el-button @click="tab = 'payments'">付款流水</el-button></div>
<LoadErrorState v-if="error" title="公司费用读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="writeError" title="财务操作未完成" :message="writeError.message" @retry="pendingFiles.length ? uploadFiles() : submit()"><template #details><ApiErrorMeta :details="writeError" /></template></LoadErrorState>
<p v-if="pending">上次操作结果尚未确认。重试使用原版本、原内容和原请求键。</p>
<LoadErrorState v-if="writeError" title="财务操作未完成" :message="writeError.message" :show-retry="false"><template #details><ApiErrorMeta :details="writeError" /></template></LoadErrorState>
<template v-if="tab === 'current' && cost">
<p>{{ cost.costNo }} · {{ statusLabels[cost.status] }} · {{ cost.supplierName }}</p>
<p>{{ cost.source.feeName }} · {{ cost.source.businessDate }} · 原币 {{ cost.source.originalCurrency }} {{ cost.source.originalAmount.toFixed(2) }}</p>
@@ -172,8 +172,7 @@ onBeforeUnmount(() => { disposed = true; epoch++; readRequest++; recordsRequest+
<input v-if="access.can(permissions.upload)" type="file" accept="image/png,image/jpeg" multiple :disabled="busy" aria-label="上传公司付款凭证" @change="selectFiles" />
<p v-else>登记新付款需公司付款凭证上传权限。</p>
<p v-for="file in vouchers" :key="file.fileId">已上传:{{ file.fileName }}</p>
<p v-if="pendingFiles.length">{{ pendingFiles.length }} 张凭证待完成上传。</p>
<el-button v-if="pendingFiles.length" :disabled="busy" @click="uploadFiles">重试剩余凭证</el-button>
</el-form-item>
<el-form-item label="付款说明(选填)" class="ledger-wide"><el-input v-model="form.remark" type="textarea" maxlength="1000" placeholder="补充本次付款用途或说明" /></el-form-item>
</template>
@@ -181,7 +180,7 @@ onBeforeUnmount(() => { disposed = true; epoch++; readRequest++; recordsRequest+
</el-form>
<template v-if="action">
<p>{{ action === 'pay' ? '确认实际付款及凭证后登记。此操作不会发起银行转账。' : action === 'void' ? '确认作废这笔未付款费用;历史记录保留,重新登记须重新入账。' : '确认将这笔公司费用入账为应付;不会自动付款。' }}</p>
<el-button :loading="saving" :disabled="uploading || !access.can(permissions[action])" :type="action === 'void' ? 'danger' : 'primary'" @click="submit">{{ pending ? '重试原操作' : '确认操作' }}</el-button>
<el-button :loading="saving" :disabled="uploading || !access.can(permissions[action])" :type="action === 'void' ? 'danger' : 'primary'" @click="submit">{{ '确认操作' }}</el-button>
<el-button :disabled="busy" @click="action = null">取消操作</el-button>
</template>
</template>
@@ -139,7 +139,7 @@ async function submitFinance() {
if (disposed || scope !== operationEpoch) return
if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) pendingFinance.value = null
financeSaveError.value = getApiErrorDetails(cause, '财务提交未完成')
} finally { if (scope === operationEpoch) financeSaving.value = false }
} finally { pendingFinance.value = null; if (scope === operationEpoch) financeSaving.value = false }
}
async function edit(row: VehicleCareRecord | null) {
if (openingRecord.value) return
@@ -203,7 +203,7 @@ onBeforeUnmount(() => { request++; operationEpoch++; supplierRequest++; disposed
<p>已付 CNY {{ financeStatus.finance.paidAmount.toFixed(2) }} · 未付 {{ financeStatus.finance.outstandingAmount == null ? '未形成应付' : `CNY ${financeStatus.finance.outstandingAmount.toFixed(2)}` }}</p>
</template>
<LoadErrorState v-if="financeSaveError" title="财务提交未完成" :message="financeSaveError.message" @retry="submitFinance"><template #details><ApiErrorMeta :details="financeSaveError" /></template></LoadErrorState>
<p v-if="pendingFinance">提交结果待核对;重试使用原请求,不重复登记。</p>
<el-form v-if="canSubmitFinance || pendingFinance" label-position="top" :disabled="financeSaving || !!pendingFinance">
<el-form-item label="供应商" required><el-select v-model="supplierId" filterable remote :remote-method="searchSuppliers" :loading="suppliersLoading" placeholder="搜索启用的供应商" @focus="searchSuppliers()"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></el-form-item>
<el-form-item label="费用业务日期" required><template #label><span>费用业务日期<InlineHelp title="公司成本说明"><p>按公司成本登记,不生成客户应收。财务接收、入账和付款分别确认。</p></InlineHelp></span></template><el-date-picker v-model="businessDate" type="date" value-format="YYYY-MM-DD" placeholder="用于确定原币折算月份" /></el-form-item>
@@ -112,7 +112,7 @@ it('keeps read-only trip detail free of write actions and reads history only whe
expect(api.listTripHistory).toHaveBeenCalledExactlyOnceWith('TR-TEST', 1, 20)
})
it('keeps the exact create command for an uncertain retry and accepts optional notes', async () => {
it('releases a failed create and sends a fresh command with optional notes', async () => {
vi.mocked(api.createTrip).mockRejectedValueOnce(new Error('连接中断')).mockResolvedValueOnce(structuredClone(detail))
const wrapper = mount(TripPlanDialog, { modelValue: false, trip: null })
await wrapper.setProps({ modelValue: true }); await flushPromises()
@@ -121,7 +121,7 @@ it('keeps the exact create command for an uncertain retry and accepts optional n
const first = vi.mocked(api.createTrip).mock.calls[0]![0]
expect(first.idempotencyKey).toBeTruthy(); expect(first.remark).toBeNull()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.createTrip).toHaveBeenNthCalledWith(2, first)
expect(api.createTrip).toHaveBeenNthCalledWith(2, { ...first, idempotencyKey: expect.any(String) }); expect(vi.mocked(api.createTrip).mock.calls[1]![0].idempotencyKey).not.toBe(first.idempotencyKey)
expect(wrapper.emitted('saved')?.[0]?.[0]).toEqual(detail)
})
@@ -5,19 +5,19 @@ import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRe
import { isWorkspaceRoute, workspaceRouteId } from '@/core/workspace-tabs/routeWorkspace'
/** Veto native closing and supplement cached route switches; retain the shared registry for other exits. */
export function useFleetDraftGuard(key: string, open: Ref<boolean>, model: object, busy: Ref<boolean>, uncertain: Ref<boolean>) {
export function useFleetDraftGuard(key: string, open: Ref<boolean>, model: object, busy: Ref<boolean>) {
const baseline = ref(''), checking = ref(false)
const snapshot = computed(() => JSON.stringify(model))
const registration = useUnsavedWorkRegistration({ key: `${key}:${getCurrentInstance()?.uid}`, label: computed(() => '拼车表单'), isOpen: computed(() => open.value || uncertain.value), model: reactive({ form: model, uncertain }), requiredPermission: computed(() => null) })
function capture() { baseline.value = snapshot.value; if (!uncertain.value) registration.captureBaseline() }
const registration = useUnsavedWorkRegistration({ key: `${key}:${getCurrentInstance()?.uid}`, label: computed(() => '拼车表单'), isOpen: computed(() => open.value), model: reactive({ form: model }), requiredPermission: computed(() => null) })
function capture() { baseline.value = snapshot.value; registration.captureBaseline() }
async function mayClose(leaving = false) {
if (busy.value || checking.value) return false
if (snapshot.value !== baseline.value || uncertain.value) {
if (snapshot.value !== baseline.value) {
checking.value = true
try { await ElMessageBox.confirm(
uncertain.value ? `上次提交结果尚未确定,原请求将保留。${leaving ? '离开' : '关闭'}后可重新打开并核对或重试,是否继续?` : leaving ? '表单有未保存内容,离开后将关闭表单并放弃本次修改。是否离开?' : '表单有未保存内容,确认放弃本次修改?',
leaving ? '表单有未保存内容,离开后将关闭表单并放弃本次修改。是否离开?' : '表单有未保存内容,确认放弃本次修改?',
leaving ? '离开页面' : '关闭表单',
{ customClass: 'admin-density-confirm', confirmButtonText: leaving ? uncertain.value ? '保留请求并离开' : '放弃并离开' : '确认关闭', cancelButtonText: '继续填写' },
{ customClass: 'admin-density-confirm', confirmButtonText: leaving ? '放弃并离开' : '确认关闭', cancelButtonText: '继续填写' },
) }
catch { return false }
finally { checking.value = false }
@@ -28,7 +28,7 @@ export function useFleetDraftGuard(key: string, open: Ref<boolean>, model: objec
if (getCurrentInstance()?.proxy?.$route) {
onBeforeRouteLeave(async (to, from) => {
// The global guard protects destructive navigation; it intentionally skips cached workspace switches.
if (!isWorkspaceRoute(to) || workspaceRouteId(to) === workspaceRouteId(from) || !open.value && !uncertain.value) return true
if (!isWorkspaceRoute(to) || workspaceRouteId(to) === workspaceRouteId(from) || !open.value) return true
if (!await mayClose(true)) return false
open.value = false
return true