Add approved outsourced dispatch operations preserving TEST Admin #662
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
|
||||
defineProps<{ dispatchNo?: string; statusText?: string; loading?: boolean }>()
|
||||
const activeTab = defineModel<'task' | 'route' | 'history'>('activeTab', { default: 'task' })
|
||||
const emit = defineEmits<{ back: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density" spacing="compact">
|
||||
<DetailPageLayout aria-label="外配业务详情">
|
||||
<template #header>
|
||||
<div class="outsourced-business-detail-heading">
|
||||
<div><strong>外配业务</strong><span class="outsourced-business-reference">{{ dispatchNo || '—' }}</span><span v-if="statusText">{{ statusText }}</span></div>
|
||||
<div class="density-actions"><slot name="actions" /><el-button class="density-action" @click="emit('back')">返回外配业务</el-button></div>
|
||||
</div>
|
||||
</template>
|
||||
<slot name="state" />
|
||||
<div v-loading="loading" :aria-busy="loading">
|
||||
<el-tabs v-model="activeTab" class="density-element-tabs density-contained-tabs">
|
||||
<el-tab-pane name="task" label="任务与车辆信息"><template #label><span class="density-tab-icon" aria-hidden="true">🚚</span>任务与车辆信息</template><slot v-if="activeTab === 'task'" name="task" /></el-tab-pane>
|
||||
<el-tab-pane name="route" label="路线货物与凭证"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span>路线货物与凭证</template><slot v-if="activeTab === 'route'" name="route" /></el-tab-pane>
|
||||
<el-tab-pane name="history" label="操作记录"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span>操作记录</template><slot v-if="activeTab === 'history'" name="history" /></el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</DetailPageLayout>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.outsourced-business-detail-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-4); }
|
||||
.outsourced-business-detail-heading > div { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: var(--admin-space-4); }
|
||||
.outsourced-business-reference { overflow-wrap: anywhere; }
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { OutsourcedBusinessCommand, OutsourcedBusinessDetail } from '../outsourcedBusinessTypes'
|
||||
|
||||
export function outsourcedBusinessFixture(): OutsourcedBusinessDetail {
|
||||
const review = { status: 'APPROVED' as const, reason: null, reviewerId: null, reviewedAt: '2026-10-20T10:00:00Z', reviewRound: '1', basisHash: null, invalidatedReason: null }
|
||||
return {
|
||||
row: {
|
||||
dispatchNo: 'DS-OUT-1', stage: 'AFTER_LOADING', plannedDepartureAt: '2026-10-20T10:00:00Z', loadingCompletedAt: '2026-10-20T09:00:00Z', departedAt: null, deliveryCompletedAt: null,
|
||||
origin: { warehouseId: '1', warehouseCode: 'WH-1', warehouseName: '起运仓' }, vehicle: { vehicleId: '2', plate: 'OUT-1', ownership: 'DISPATCHED', carrierName: '测试外配承运商' }, driver: { name: null, phone: null },
|
||||
destinations: [{ destinationKey: 'destination-1', warehouseCode: 'FBA-1', warehouseName: '目的仓', address: null, appointments: [{ appointmentNo: 'AP-1', confirmationNo: null, scheduledAt: null, allocatedPackages: '2' }], actualArrivedAt: null }],
|
||||
actualPalletCount: 1, operations: { money: null, arrivals: [{ destinationKey: 'destination-1', actualArrivedAt: null }] }, unitPrice: null, recordVersion: '0', dispatchRecordVersion: '8',
|
||||
photoSummary: { loadingCount: 0 }, actions: { edit: { allowed: true, blockReason: null }, upload: { allowed: true, blockReason: null } }, sourceIssues: [],
|
||||
},
|
||||
dispatch: { dispatchNo: 'DS-OUT-1', stage: 'AFTER_LOADING', status: 'ACTIVE', recordVersion: '8', reviewState: 'APPROVED', reviews: { dispatchReview: { ...review }, loadingReview: { ...review } }, counts: { itemCount: 2, palletCount: 1, packageCount: 2, pickedPackageCount: 2, shippedPackageCount: 0 }, pickingReconciled: true, actions: {}, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: '2026-10-20T10:00:00Z', departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [] }, documents: { dispatchNo: 'DS-OUT-1', outbounds: [], deliveries: [], createdAt: '2026-10-20T08:00:00Z', updatedAt: '2026-10-20T09:00:00Z', remark: null }, orderDetails: [] },
|
||||
cargo: ['A','B'].map(customer => ({ sourceKey: `source-${customer}`, customerId: customer, customerName: `客户${customer}`, orderNo: `ORDER-${customer}`, inboundCode: `IN-${customer}`, palletNo: 'ONE-PHYSICAL-PALLET', destinationKey: 'destination-1', packages: '1' })), photos: [],
|
||||
}
|
||||
}
|
||||
export function outsourcedBusinessCommandFixture(overrides: Partial<OutsourcedBusinessCommand> = {}): OutsourcedBusinessCommand {
|
||||
const detail = outsourcedBusinessFixture(); detail.row.recordVersion = overrides.recordVersion ?? '1'
|
||||
return { dispatchNo: 'DS-OUT-1', action: 'SAVE_OPERATIONS', idempotencyKey: 'original-key', state: 'SUCCEEDED', recordVersion: '1', replayed: false, result: detail, errorCode: null, retryAllowed: false, ...overrides }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { defineComponent } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import OutsourcedBusinessDetailLayout from './OutsourcedBusinessDetailLayout.vue'
|
||||
import OutsourcedBusinessPhotosDialog from './OutsourcedBusinessPhotosDialog.vue'
|
||||
import OutsourcedBusinessOperationsDialog from './OutsourcedBusinessOperationsDialog.vue'
|
||||
|
||||
const Surface = defineComponent({ template: '<section><slot name="header" /><slot /></section>' })
|
||||
const Tabs = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], template: '<div><slot /></div>' })
|
||||
const Pane = defineComponent({ props: ['name', 'label'], template: '<article><slot /></article>' })
|
||||
const Dialog = defineComponent({ props: ['width', 'showConfirm', 'beforeClose'], template: '<div><slot /></div>' })
|
||||
const global = { stubs: { AdminPage: Surface, DetailPageLayout: Surface, AppDialog: Dialog, ElTabs: Tabs, ElTabPane: Pane, ElButton: Surface }, directives: { loading: () => {} } }
|
||||
|
||||
describe('OutsourcedBusiness presentation boundaries', () => {
|
||||
it('mounts only the selected one of the three independent detail regions', async () => {
|
||||
const wrapper = mount(OutsourcedBusinessDetailLayout, { global, props: { dispatchNo: 'DS-OUT-1' }, slots: { task: '<p>task-facts</p>', route: '<p>route-facts</p>', history: '<p>history-facts</p>' } })
|
||||
expect(wrapper.findAllComponents(Pane).map(pane => pane.props('label'))).toEqual(['任务与车辆信息', '路线货物与凭证', '操作记录'])
|
||||
expect(wrapper.text()).toContain('task-facts'); expect(wrapper.text()).not.toContain('history-facts')
|
||||
wrapper.findComponent(Tabs).vm.$emit('update:modelValue', 'history'); await wrapper.vm.$nextTick()
|
||||
expect(wrapper.text()).toContain('history-facts'); expect(wrapper.text()).not.toContain('task-facts')
|
||||
})
|
||||
|
||||
it('preserves only five approved photo roles without demo images or uploaded claims', () => {
|
||||
const wrapper = mount(OutsourcedBusinessPhotosDialog, { global, props: { modelValue: true } })
|
||||
expect(wrapper.findComponent(Dialog).props('width')).toBe('760px')
|
||||
expect(wrapper.findComponent(Dialog).props('showConfirm')).toBe(false)
|
||||
expect(wrapper.findAll('section[aria-label]').map(section => section.attributes('aria-label'))).toEqual(['装车现场', '装车清单', '出车前检查', '驾驶证附件', '签收凭证'])
|
||||
expect(wrapper.findAll('img')).toHaveLength(0); expect(wrapper.text()).not.toContain('已上传')
|
||||
})
|
||||
|
||||
it('blocks a close while busy and delegates unsaved-result decisions to the owner', async () => {
|
||||
const guard = vi.fn(), done = vi.fn()
|
||||
const wrapper = mount(OutsourcedBusinessPhotosDialog, { global, props: { modelValue: true, busy: true, beforeClose: guard } })
|
||||
const close = wrapper.findComponent(Dialog).props('beforeClose') as (done: () => void) => void
|
||||
close(done); expect(guard).not.toHaveBeenCalled(); expect(done).not.toHaveBeenCalled()
|
||||
await wrapper.setProps({ busy: false }); close(done)
|
||||
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an unknown operations request until its owner explicitly handles closing', async () => {
|
||||
const done = vi.fn(), guard = vi.fn()
|
||||
const wrapper = mount(OutsourcedBusinessOperationsDialog, { global, props: { modelValue: true, pending: true } })
|
||||
const close = wrapper.findComponent(Dialog).props('beforeClose') as (done: () => void) => void
|
||||
close(done); expect(done).not.toHaveBeenCalled()
|
||||
await wrapper.setProps({ beforeClose: guard }); close(done)
|
||||
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density" spacing="compact">
|
||||
<slot name="statistics" />
|
||||
<ListPageLayout aria-label="外配业务任务列表">
|
||||
<template v-if="$slots.filters" #toolbar><slot name="filters" /></template>
|
||||
<template v-if="$slots.state" #state><slot name="state" /></template>
|
||||
<slot />
|
||||
</ListPageLayout>
|
||||
</AdminPage>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
dispatchNo?: string
|
||||
busy?: boolean
|
||||
saveDisabled?: boolean
|
||||
pending?: boolean
|
||||
beforeClose?: (done: () => void) => void | Promise<void>
|
||||
}>()
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const emit = defineEmits<{ save: [] }>()
|
||||
function close(done: () => void) {
|
||||
if (props.busy) return
|
||||
if (props.beforeClose) return props.beforeClose(done)
|
||||
if (!props.pending) done()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density" title="登记运营信息" width="1080px" :confirm-loading="busy" :confirm-disabled="saveDisabled" :confirm-text="pending ? '核对并重试原请求' : '保存登记'" :before-close="close" :close-on-press-escape="!busy" @confirm="emit('save')">
|
||||
<p class="outsourced-business-operation-reference">排车单号 {{ dispatchNo || '—' }}</p>
|
||||
<slot name="state" />
|
||||
<fieldset class="outsourced-business-operation-section"><legend>整车承运金额</legend><slot name="amount" /><p>整车只登记一次;单价采用系统返回的承运金额与实际物理托数计算结果。</p></fieldset>
|
||||
<fieldset class="outsourced-business-operation-section"><legend>逐目的地实际到达时间</legend><slot name="arrivals" /></fieldset>
|
||||
<p>登记运营信息不推进排车或派送状态;照片在独立上传照片窗口中处理。</p>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.outsourced-business-operation-reference { overflow-wrap: anywhere; }
|
||||
.outsourced-business-operation-section { min-width: 0; margin: 0 0 var(--admin-space-4); padding: var(--admin-space-4); border: 1px solid var(--admin-table-border); }
|
||||
.outsourced-business-operation-section legend { padding: 0 var(--admin-space-2); font-weight: 600; }
|
||||
</style>
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import OutsourcedBusinessOperationsEditor from './OutsourcedBusinessOperationsEditor.vue'
|
||||
import { outsourcedBusinessFixture } from './OutsourcedBusinessFixtures'
|
||||
|
||||
const calls = vi.hoisted(() => ({ save: vi.fn(), detail: vi.fn(), command: vi.fn(), canEdit: true }))
|
||||
vi.mock('../api/outsourcedBusiness', () => ({ saveOutsourcedBusinessOperations: calls.save, getOutsourcedBusiness: calls.detail, getOutsourcedBusinessCommand: calls.command }))
|
||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: { id: 'actor' } }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => calls.canEdit }) }))
|
||||
vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
const Frame = defineComponent({ props: ['modelValue','busy','saveDisabled','pending'], emits: ['save','update:modelValue'], setup(props,{ slots,emit }) { return () => h('div', [...Object.values(slots).map(slot => slot?.()), h('button',{ 'data-save':'',disabled: props.busy || props.saveDisabled,onClick: () => emit('save') },'save')]) } })
|
||||
const Control = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' })
|
||||
const Field = defineComponent({ props: ['label'], template: '<label>{{label}}<slot /></label>' })
|
||||
const global = { stubs: { OutsourcedBusinessOperationsDialog: Frame, ElForm: { template:'<div><slot /></div>' }, ElFormItem: Field, ElInput: Control, ElSelect: Control, ElOption: true, ElDatePicker: Control, LoadErrorState: { props:['message'],template:'<div>{{message}}</div>' }, ApiErrorMeta: true } }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); sessionStorage.clear(); calls.canEdit = true
|
||||
const current = outsourcedBusinessFixture(); current.row.recordVersion = '1'
|
||||
calls.save.mockResolvedValue(current); calls.detail.mockResolvedValue(current)
|
||||
calls.command.mockRejectedValue(new ApiResponseError('not found','A0404',404))
|
||||
})
|
||||
|
||||
describe('outsourced operations registration', () => {
|
||||
it('sends explicit zero and destination arrival with auxiliary zero, without fuel or mileage fields', async () => {
|
||||
const wrapper = mount(OutsourcedBusinessOperationsEditor,{ global,props:{modelValue:true,detail:outsourcedBusinessFixture()} }); await flushPromises()
|
||||
const currency = wrapper.findAll('label').find(field => field.text() === '币种')!.find('input')
|
||||
const amount = wrapper.findAll('label').find(field => field.text() === '整车承运金额')!.find('input')
|
||||
await currency.setValue('USD'); await amount.setValue('0.00')
|
||||
await wrapper.findAll('input').at(-1)!.setValue('2026-10-20T12:00:00+08:00')
|
||||
await wrapper.find('[data-save]').trigger('click'); await flushPromises()
|
||||
const body = calls.save.mock.calls[0]![1]
|
||||
expect(body).toEqual({ recordVersion:'0',idempotencyKey:expect.any(String),operations:{money:{currency:'USD',amount:'0.00'},arrivals:[{destinationKey:'destination-1',actualArrivedAt:'2026-10-20T12:00:00+08:00'}]},reason:null })
|
||||
expect(calls.save).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
expect(wrapper.text()).not.toContain('油耗'); expect(wrapper.text()).not.toContain('里程'); wrapper.unmount()
|
||||
})
|
||||
it('clears money as null and retains exactly the original request after an unknown save', async () => {
|
||||
const detail = outsourcedBusinessFixture(); detail.row.operations.money = {currency:'CNY',amount:'123.45'}
|
||||
calls.save.mockRejectedValueOnce(new TypeError('network interrupted'))
|
||||
const wrapper = mount(OutsourcedBusinessOperationsEditor,{global,props:{modelValue:true,detail}}); await flushPromises()
|
||||
await wrapper.findAll('label').find(field => field.text() === '币种')!.find('input').setValue('')
|
||||
await wrapper.findAll('label').find(field => field.text() === '整车承运金额')!.find('input').setValue('')
|
||||
await wrapper.find('[data-save]').trigger('click'); await flushPromises()
|
||||
const original = JSON.parse(JSON.stringify(calls.save.mock.calls[0]![1]))
|
||||
expect(original.operations.money).toBeNull(); expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
await wrapper.find('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(calls.command).toHaveBeenCalledExactlyOnceWith('DS-OUT-1',original.idempotencyKey)
|
||||
expect(calls.save).toHaveBeenNthCalledWith(2,'DS-OUT-1',original); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('does not write for an unchanged form or a user without edit permission', async () => {
|
||||
const first = mount(OutsourcedBusinessOperationsEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}}); await flushPromises()
|
||||
await first.find('[data-save]').trigger('click'); await flushPromises(); expect(calls.save).not.toHaveBeenCalled(); first.unmount()
|
||||
calls.canEdit = false
|
||||
const readonly = mount(OutsourcedBusinessOperationsEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}}); await flushPromises()
|
||||
expect(readonly.find('[data-save]').attributes('disabled')).toBeDefined(); expect(calls.save).not.toHaveBeenCalled(); readonly.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, getCurrentInstance, nextTick, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OutsourcedBusinessOperationsDialog from './OutsourcedBusinessOperationsDialog.vue'
|
||||
import { newOutsourcedBusinessPending, persistOutsourcedBusinessPending, restoreOutsourcedBusinessPending, recoverOutsourcedBusinessCommand, type OutsourcedBusinessPending } from './OutsourcedBusinessCommandRecovery'
|
||||
import { outsourcedBusinessDecimal, outsourcedBusinessVersion, type OutsourcedBusinessDetail, type OutsourcedBusinessMoney, type OutsourcedBusinessSave } from '../outsourcedBusinessTypes'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import { getOutsourcedBusiness } from '../api/outsourcedBusiness'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ detail: OutsourcedBusinessDetail | null }>()
|
||||
const emit = defineEmits<{ saved: [value: OutsourcedBusinessDetail]; busy: [value: boolean] }>()
|
||||
const auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), pending = ref<OutsourcedBusinessPending | null>(null), error = ref<ApiErrorDetails | null>(null), corrupted = ref(false)
|
||||
const form = reactive({ currency: '', amount: '', reason: '', arrivals: {} as Record<string,string> })
|
||||
const active = computed(() => open.value || Boolean(pending.value))
|
||||
const registration = useUnsavedWorkRegistration({ key: `outsourced-business-operations:${getCurrentInstance()?.uid}`, label: computed(() => '外配业务运营登记'), isOpen: active, model: reactive({ form, pending }), requiredPermission: computed(() => permission.outsourcedBusinessEdit) })
|
||||
let actor = '', number = '', baseline = ''
|
||||
const writable = computed(() => !corrupted.value && access.can(permission.outsourcedBusinessEdit) && props.detail?.row.actions.edit.allowed === true)
|
||||
const locked = computed(() => busy.value || Boolean(pending.value))
|
||||
function persist() { persistOutsourcedBusinessPending(actor, number, 'operations', pending.value) }
|
||||
function ensureScope() { if (auth.user?.id !== actor || props.detail?.row.dispatchNo !== number) throw new Error('操作身份或排车单已变化,请重新打开') }
|
||||
watch(busy, value => emit('busy', value))
|
||||
watch(open, async value => {
|
||||
if (!value || !props.detail) return
|
||||
actor = auth.user?.id || ''; number = props.detail.row.dispatchNo; error.value = null; corrupted.value = false
|
||||
try {
|
||||
pending.value = restoreOutsourcedBusinessPending(actor, number, 'operations')
|
||||
const operations = pending.value?.action === 'SAVE_OPERATIONS' ? (pending.value.body as OutsourcedBusinessSave).operations : props.detail.row.operations
|
||||
Object.assign(form, { currency: operations.money?.currency || '', amount: operations.money?.amount ?? '', reason: pending.value ? (pending.value.body as OutsourcedBusinessSave).reason || '' : '', arrivals: Object.fromEntries(props.detail.row.destinations.map(destination => [destination.destinationKey, operations.arrivals.find(arrival => arrival.destinationKey === destination.destinationKey)?.actualArrivedAt || ''])) })
|
||||
baseline = JSON.stringify(form); await nextTick(); if (!pending.value) registration.captureBaseline()
|
||||
} catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '运营资料恢复失败') }
|
||||
}, { immediate: true })
|
||||
function payload(): OutsourcedBusinessSave {
|
||||
const amount = outsourcedBusinessDecimal(form.amount, 2, 18)
|
||||
let money: OutsourcedBusinessMoney | null = null
|
||||
if (amount != null) { if (!['CNY','USD'].includes(form.currency)) throw new Error('请选择金额币种'); money = { currency: form.currency as OutsourcedBusinessMoney['currency'], amount } }
|
||||
else if (form.currency) throw new Error('金额和币种请成对填写,清空时同时清空两项')
|
||||
const arrivals = props.detail!.row.destinations.map(destination => {
|
||||
const value = form.arrivals[destination.destinationKey] || null
|
||||
if (value && (!/(?:Z|[+-]\d{2}:\d{2})$/.test(value) || !Number.isFinite(Date.parse(value)))) throw new Error('实际到达时间必须包含有效时区')
|
||||
return { destinationKey: destination.destinationKey, actualArrivedAt: value }
|
||||
})
|
||||
return { recordVersion: outsourcedBusinessVersion(props.detail!.row.recordVersion), idempotencyKey: crypto.randomUUID(), operations: { money, arrivals }, reason: form.reason.trim() || null }
|
||||
}
|
||||
async function save() {
|
||||
if (busy.value || !writable.value || !props.detail) return
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
ensureScope()
|
||||
if (!pending.value) { if (JSON.stringify(form) === baseline) { open.value = false; return }; pending.value = newOutsourcedBusinessPending(actor, number, 'SAVE_OPERATIONS', payload()); persist() }
|
||||
const result = await recoverOutsourcedBusinessCommand(pending.value, persist, ensureScope)
|
||||
if (!result.detail) throw new Error('运营登记结果未确认')
|
||||
pending.value = null; persist(); registration.captureBaseline(); emit('saved', result.detail); open.value = false; ElMessage.success('运营信息已保存')
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '运营登记结果尚未确认') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
async function beforeClose(done: () => void) {
|
||||
if (busy.value) return
|
||||
if (pending.value || JSON.stringify(form) !== baseline) {
|
||||
try { await ElMessageBox.confirm(pending.value ? '原请求将保留,重新打开后可核对原结果。是否关闭?' : '放弃尚未保存的运营信息?', '关闭运营登记', { customClass: 'admin-density-confirm', confirmButtonText: pending.value ? '保留请求并关闭' : '放弃修改', cancelButtonText: '继续编辑' }) } catch { return }
|
||||
}
|
||||
done()
|
||||
}
|
||||
async function discardRejected() {
|
||||
if (!pending.value?.rejected || busy.value) return
|
||||
busy.value = true
|
||||
try { const current = await getOutsourcedBusiness(number); ensureScope(); pending.value = null; persist(); emit('saved', current); error.value = null }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '当前运营版本核对失败') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OutsourcedBusinessOperationsDialog v-model="open" :dispatch-no="detail?.row.dispatchNo" :busy="busy" :pending="Boolean(pending)" :save-disabled="!writable" :before-close="beforeClose" @save="save">
|
||||
<template #state><LoadErrorState v-if="error" title="运营登记未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="pending">原请求已保留,字段锁定;核对结果前不会建立新请求。</p><el-button v-if="pending?.rejected" :disabled="busy" @click="discardRejected">按明确拒绝结果重新编辑</el-button><p v-if="!writable">{{ detail?.row.actions.edit.blockReason || '没有运营登记权限' }}</p></template>
|
||||
<template #amount><el-form label-position="top" class="outsourced-business-form"><el-form-item label="币种"><el-select v-model="form.currency" clearable :disabled="locked"><el-option label="CNY" value="CNY" /><el-option label="USD" value="USD" /></el-select></el-form-item><el-form-item label="整车承运金额"><el-input v-model="form.amount" :disabled="locked" placeholder="未登记;允许明确填写0" /></el-form-item><el-form-item label="更正说明"><el-input v-model="form.reason" :disabled="locked" maxlength="1000" /></el-form-item></el-form></template>
|
||||
<template #arrivals><div v-for="destination in detail?.row.destinations || []" :key="destination.destinationKey" class="outsourced-business-arrival"><span>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}<small v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ appointment.appointmentNo }}</small></span><el-date-picker v-model="form.arrivals[destination.destinationKey]" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" :disabled="locked" placeholder="实际到达时间,未登记留空" /></div><p>时间按浏览器本地时区输入,提交时保留时区。</p></template>
|
||||
</OutsourcedBusinessOperationsDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.outsourced-business-form { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 0 var(--admin-space-4); }
|
||||
.outsourced-business-arrival { display: grid; grid-template-columns: minmax(0,1fr) 280px; align-items: center; gap: var(--admin-space-4); margin-bottom: var(--admin-space-3); }
|
||||
.outsourced-business-arrival small { display: block; }
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { outsourcedBusinessPhotoRoles } from '../outsourcedBusinessTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
dispatchNo?: string
|
||||
busy?: boolean
|
||||
beforeClose?: (done: () => void) => void | Promise<void>
|
||||
}>()
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
function close(done: () => void) {
|
||||
if (props.busy) return
|
||||
if (props.beforeClose) return props.beforeClose(done)
|
||||
done()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density" title="上传照片" width="760px" :show-confirm="false" cancel-text="关闭" :confirm-loading="busy" :close-on-press-escape="!busy" :before-close="close">
|
||||
<p class="outsourced-business-photo-reference">排车单号 {{ dispatchNo || '—' }}</p>
|
||||
<slot name="state" />
|
||||
<p>每个附件方框单独处理,支持 JPG、PNG;上传照片不改变装车状态。</p>
|
||||
<div class="outsourced-business-photo-grid">
|
||||
<section v-for="photo in outsourcedBusinessPhotoRoles" :key="photo.slot" class="outsourced-business-photo-slot" :aria-label="photo.label">
|
||||
<h3>{{ photo.label }}</h3><slot :name="photo.viewSlot"><p>暂无照片</p></slot>
|
||||
</section>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.outsourced-business-photo-reference { overflow-wrap: anywhere; }
|
||||
.outsourced-business-photo-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-4); }
|
||||
.outsourced-business-photo-slot { min-width: 0; padding: var(--admin-space-4); border: 1px solid var(--admin-table-border); }
|
||||
.outsourced-business-photo-slot h3 { margin: 0 0 var(--admin-space-3); font-size: inherit; }
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import OutsourcedBusinessPhotosEditor from './OutsourcedBusinessPhotosEditor.vue'
|
||||
import { outsourcedBusinessFixture } from './OutsourcedBusinessFixtures'
|
||||
import type { OutsourcedBusinessPhoto } from '../outsourcedBusinessTypes'
|
||||
|
||||
const calls=vi.hoisted(()=>({upload:vi.fn(),bind:vi.fn(),command:vi.fn(),detail:vi.fn(),content:vi.fn(),actor:{user:{id:'actor'}},canUpload:true}))
|
||||
vi.mock('../api/outsourcedBusiness',()=>({uploadOutsourcedBusinessPhoto:calls.upload,bindOutsourcedBusinessPhoto:calls.bind,getOutsourcedBusinessCommand:calls.command,getOutsourcedBusiness:calls.detail,getOutsourcedBusinessPhotoContent:calls.content}))
|
||||
vi.mock('@/core/auth/auth',()=>({useAuthStore:()=>calls.actor}))
|
||||
vi.mock('@/core/access/accessPreview',()=>({useAccessPreviewStore:()=>({can:()=>calls.canUpload})}))
|
||||
vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration',()=>({useUnsavedWorkRegistration:()=>({captureBaseline:vi.fn()})}))
|
||||
vi.mock('element-plus',()=>({ElMessage:{success:vi.fn()},ElMessageBox:{confirm:vi.fn()}}))
|
||||
const Frame=defineComponent({props:['modelValue','busy','beforeClose'],setup(_props,{slots}){return()=>h('div',Object.values(slots).map(slot=>slot?.()))}})
|
||||
const Button=defineComponent({props:['disabled'],template:'<button :disabled="disabled"><slot /></button>'})
|
||||
const global={stubs:{OutsourcedBusinessPhotosDialog:Frame,ElButton:Button,LoadErrorState:{props:['message'],template:'<div>{{message}}<slot name="details" /></div>'},ApiErrorMeta:true}}
|
||||
const photo:OutsourcedBusinessPhoto={slot:'LOADING_SCENE',category:'LOADING',fileId:'9007199254740993',fileName:'loading.png',contentType:'image/png',sizeBytes:'5',uploadedAt:'2026-10-20T10:00:00Z',savedAt:null}
|
||||
async function choose(wrapper:ReturnType<typeof mount>){const input=wrapper.find('input[type="file"]');Object.defineProperty(input.element,'files',{configurable:true,value:[new File(['image'],'loading.png',{type:'image/png'})]});await input.trigger('change');await flushPromises()}
|
||||
beforeEach(()=>{
|
||||
vi.clearAllMocks();sessionStorage.clear();calls.actor.user.id='actor';calls.canUpload=true
|
||||
calls.upload.mockResolvedValue(photo);calls.command.mockRejectedValue(new ApiResponseError('not found','A0404',404))
|
||||
const current=outsourcedBusinessFixture();current.row.recordVersion='1';current.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}]
|
||||
calls.bind.mockResolvedValue(current);calls.detail.mockResolvedValue(current)
|
||||
})
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
describe('outsourced business per-slot photo save',()=>{
|
||||
it('retains prepared file and original binding request through a failed bind without retransmitting the image',async()=>{
|
||||
calls.bind.mockRejectedValueOnce(new TypeError('network interrupted'))
|
||||
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}});await choose(wrapper)
|
||||
expect(calls.upload).toHaveBeenCalledTimes(1);expect(calls.bind).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).toContain('尚未确认绑定保存');expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
const original={...calls.bind.mock.calls[0]![2]}
|
||||
await wrapper.findAll('button').find(button=>button.text()==='核对原结果并继续绑定')!.trigger('click');await flushPromises()
|
||||
expect(calls.upload).toHaveBeenCalledTimes(1);expect(calls.bind).toHaveBeenCalledTimes(2)
|
||||
expect(calls.bind.mock.calls[1]![2]).toEqual(original);expect(original.fileId).toBe(photo.fileId);expect(original.recordVersion).toBe('0')
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1);wrapper.unmount()
|
||||
})
|
||||
it('does not offer a new upload after unknown upload or fabricate a saved photo',async()=>{
|
||||
calls.upload.mockRejectedValue(new TypeError('network interrupted'))
|
||||
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}});await choose(wrapper)
|
||||
await wrapper.findAll('button').find(button=>button.text()==='核对原结果并继续绑定')!.trigger('click');await flushPromises()
|
||||
expect(calls.upload).toHaveBeenCalledTimes(1);expect(calls.bind).not.toHaveBeenCalled();expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
expect(wrapper.find('input[type="file"]').attributes('disabled')).toBeDefined();expect(wrapper.text()).toContain('禁止重复上传');wrapper.unmount()
|
||||
})
|
||||
it('keeps saved metadata readable without upload permission and fetches image bytes only on explicit preview',async()=>{
|
||||
calls.canUpload=false
|
||||
const detail=outsourcedBusinessFixture();detail.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}]
|
||||
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail}});await flushPromises()
|
||||
expect(wrapper.find('input[type="file"]').exists()).toBe(false);expect(wrapper.text()).toContain('已保存');expect(calls.content).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('reads a saved loading photo only on click and releases its URL on closing',async()=>{
|
||||
const create=vi.fn(()=> 'blob:outsourced-loading'),revoke=vi.fn()
|
||||
vi.stubGlobal('URL',class extends URL { static createObjectURL=create; static revokeObjectURL=revoke })
|
||||
calls.canUpload=false;calls.content.mockResolvedValue(new Blob(['image'],{type:'image/png'}))
|
||||
const detail=outsourcedBusinessFixture();detail.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}]
|
||||
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail}});await flushPromises()
|
||||
expect(calls.content).not.toHaveBeenCalled()
|
||||
await wrapper.findAll('button').find(button=>button.text()==='查看图片')!.trigger('click');await flushPromises()
|
||||
expect(calls.content).toHaveBeenCalledExactlyOnceWith('DS-OUT-1',detail.photos[0]);expect(wrapper.find('img').attributes('src')).toBe('blob:outsourced-loading')
|
||||
await wrapper.setProps({modelValue:false});expect(revoke).toHaveBeenCalledWith('blob:outsourced-loading');wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, getCurrentInstance, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OutsourcedBusinessPhotosDialog from './OutsourcedBusinessPhotosDialog.vue'
|
||||
import { outsourcedBusinessPhotoRoles, outsourcedBusinessVersion, type OutsourcedBusinessDetail, type OutsourcedBusinessPhoto, type OutsourcedBusinessPhotoSlot } from '../outsourcedBusinessTypes'
|
||||
import { getOutsourcedBusiness, getOutsourcedBusinessPhotoContent, uploadOutsourcedBusinessPhoto } from '../api/outsourcedBusiness'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import { acceptOutsourcedBusinessResult, newOutsourcedBusinessPending, outsourcedBusinessPendingKey, persistOutsourcedBusinessPending, restoreOutsourcedBusinessPending, recoverOutsourcedBusinessCommand, type OutsourcedBusinessPending } from './OutsourcedBusinessCommandRecovery'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ detail: OutsourcedBusinessDetail | null }>()
|
||||
const emit = defineEmits<{ saved: [value: OutsourcedBusinessDetail]; busy: [value: boolean] }>()
|
||||
const auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null), pending = ref<OutsourcedBusinessPending | null>(null), prepared = ref<OutsourcedBusinessPhoto | null>(null), corrupted = ref(false)
|
||||
const urls = ref<Partial<Record<OutsourcedBusinessPhotoSlot,string>>>({}), reading = ref<OutsourcedBusinessPhotoSlot | null>(null)
|
||||
const uploadable = computed(() => !corrupted.value && access.can(permission.outsourcedBusinessUpload) && props.detail?.row.actions.upload.allowed === true)
|
||||
useUnsavedWorkRegistration({ key: `outsourced-business-photos:${getCurrentInstance()?.uid}`, label: computed(() => '外配业务照片作业'), isOpen: computed(() => open.value || Boolean(pending.value) || Boolean(prepared.value)), model: reactive({ pending, prepared }), requiredPermission: computed(() => permission.outsourcedBusinessUpload) })
|
||||
let actor = '', number = '', generation = 0
|
||||
function savedPhoto(slot: OutsourcedBusinessPhotoSlot) { return props.detail?.photos.find(photo => photo.slot === slot && photo.savedAt) }
|
||||
function release() { Object.values(urls.value).forEach(url => { if (url) URL.revokeObjectURL(url) }); urls.value = {} }
|
||||
function ensureScope() { if (auth.user?.id !== actor || props.detail?.row.dispatchNo !== number) throw new Error('图片作业身份或排车单已变化,请重新打开') }
|
||||
function persist() { persistOutsourcedBusinessPending(actor, number, 'photos', pending.value) }
|
||||
function persistPrepared() {
|
||||
const key = outsourcedBusinessPendingKey(actor, number, 'prepared-photo')
|
||||
if (prepared.value) sessionStorage.setItem(key, JSON.stringify(prepared.value)); else sessionStorage.removeItem(key)
|
||||
}
|
||||
watch(busy, value => emit('busy', value))
|
||||
watch(open, value => {
|
||||
generation++; release(); reading.value = null; error.value = null; corrupted.value = false
|
||||
if (!value || !props.detail) return
|
||||
actor = auth.user?.id || ''; number = props.detail.row.dispatchNo
|
||||
try {
|
||||
pending.value = restoreOutsourcedBusinessPending(actor, number, 'photos')
|
||||
const raw = sessionStorage.getItem(outsourcedBusinessPendingKey(actor, number, 'prepared-photo'))
|
||||
prepared.value = raw ? JSON.parse(raw) as OutsourcedBusinessPhoto : null
|
||||
if (prepared.value && (!prepared.value.fileId || !outsourcedBusinessPhotoRoles.some(role => role.slot === prepared.value!.slot))) throw new Error('原上传文件资料损坏,请保留现场核对')
|
||||
} catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '照片作业恢复失败') }
|
||||
}, { immediate: true })
|
||||
watch(() => props.detail?.photos, () => { generation++; release(); reading.value = null })
|
||||
onBeforeUnmount(() => { generation++; release() })
|
||||
async function preview(slot: OutsourcedBusinessPhotoSlot) {
|
||||
const photo = savedPhoto(slot), current = generation
|
||||
if (!photo || reading.value) return
|
||||
reading.value = slot; error.value = null
|
||||
try { const blob = await getOutsourcedBusinessPhotoContent(number, photo); if (current !== generation || !open.value) return; urls.value[slot] = URL.createObjectURL(blob) }
|
||||
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '图片读取失败') }
|
||||
finally { if (current === generation) reading.value = null }
|
||||
}
|
||||
async function bindPrepared() {
|
||||
if ((!prepared.value && !pending.value) || !props.detail || !uploadable.value) return
|
||||
if (!pending.value) {
|
||||
const body = { recordVersion: outsourcedBusinessVersion(props.detail.row.recordVersion), idempotencyKey: crypto.randomUUID(), fileId: prepared.value!.fileId }
|
||||
pending.value = newOutsourcedBusinessPending(actor, number, 'BIND_PHOTO', body, prepared.value!.slot); persist()
|
||||
}
|
||||
const result = await recoverOutsourcedBusinessCommand(pending.value, persist, ensureScope)
|
||||
if (!result.detail) throw new Error('照片绑定尚未确认')
|
||||
pending.value = null; persist(); prepared.value = null; persistPrepared(); emit('saved', result.detail); ElMessage.success('照片已绑定保存')
|
||||
}
|
||||
async function recover() {
|
||||
if (busy.value || !uploadable.value) return
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
ensureScope()
|
||||
if (pending.value?.action === 'UPLOAD_PHOTO') {
|
||||
const result = await recoverOutsourcedBusinessCommand(pending.value, persist, ensureScope)
|
||||
if (!result.photo) throw new Error('原上传文件未确认')
|
||||
prepared.value = result.photo; persistPrepared(); pending.value = null; persist()
|
||||
}
|
||||
await bindPrepared()
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '照片结果尚未确认') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
async function upload(slot: OutsourcedBusinessPhotoSlot, event: Event) {
|
||||
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
|
||||
if (!file || busy.value || pending.value || prepared.value || !uploadable.value) return
|
||||
if (!['image/jpeg','image/png'].includes(file.type) || file.size <= 0) { error.value = { message: '请选择有效的JPG或PNG图片' }; return }
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
ensureScope(); pending.value = newOutsourcedBusinessPending(actor, number, 'UPLOAD_PHOTO', null, slot); pending.value.attempted = true; persist()
|
||||
const uploaded = await uploadOutsourcedBusinessPhoto(number, slot, file, pending.value.key)
|
||||
ensureScope(); acceptOutsourcedBusinessResult(pending.value, uploaded); persist()
|
||||
const result = await recoverOutsourcedBusinessCommand(pending.value, persist, ensureScope)
|
||||
if (!result.photo) throw new Error('真实上传文件未确认')
|
||||
prepared.value = result.photo; persistPrepared(); pending.value = null; persist(); await bindPrepared()
|
||||
} catch (cause) {
|
||||
if (pending.value?.action === 'UPLOAD_PHOTO' && cause instanceof ApiResponseError && ['A0400','A0403','A0404','400','403','404','422'].includes(cause.code)) { pending.value.rejected = true; persist() }
|
||||
error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '图片上传或绑定结果尚未确认')
|
||||
} finally { busy.value = false }
|
||||
}
|
||||
async function discardRejected() {
|
||||
if (busy.value || !pending.value?.rejected) return
|
||||
busy.value = true
|
||||
try { const latest = await getOutsourcedBusiness(number); ensureScope(); pending.value = null; persist(); emit('saved', latest); error.value = null }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '当前照片版本读取失败') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
async function beforeClose(done: () => void) {
|
||||
if (busy.value) return
|
||||
if (pending.value || prepared.value) {
|
||||
try { await ElMessageBox.confirm('未完成的原上传或绑定记录将保留,重新打开后继续核对;关闭不会保存尚未绑定的照片。是否关闭?', '关闭照片窗口', { customClass: 'admin-density-confirm', confirmButtonText: '保留记录并关闭', cancelButtonText: '继续核对' }) } catch { return }
|
||||
}
|
||||
done()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OutsourcedBusinessPhotosDialog v-model="open" :dispatch-no="detail?.row.dispatchNo" :busy="busy" :before-close="beforeClose">
|
||||
<template #state><LoadErrorState v-if="error" title="照片作业未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="pending">原{{ pending.action === 'UPLOAD_PHOTO' ? '上传' : '绑定' }}请求已保留,不会重新上传文件。</p><p v-if="prepared">{{ prepared.fileName }} 已上传,尚未确认绑定保存。</p><el-button v-if="pending || prepared" :loading="busy" :disabled="!uploadable" @click="recover">核对原结果并继续绑定</el-button><el-button v-if="pending?.rejected" :disabled="busy" @click="discardRejected">按明确拒绝重新核对,保留已上传文件</el-button><p v-if="!uploadable">{{ detail?.row.actions.upload.blockReason || '当前仅可查看照片' }}</p></template>
|
||||
<template v-for="role in outsourcedBusinessPhotoRoles" :key="role.slot" #[role.viewSlot]>
|
||||
<img v-if="urls[role.slot]" :src="urls[role.slot]" :alt="role.label" class="outsourced-business-photo" />
|
||||
<template v-if="savedPhoto(role.slot)"><p>{{ savedPhoto(role.slot)?.fileName }} · 已保存</p><el-button :loading="reading === role.slot" :disabled="Boolean(reading)" @click="preview(role.slot)">查看图片</el-button></template>
|
||||
<p v-else>暂无已保存照片</p>
|
||||
<label v-if="uploadable" class="outsourced-business-upload">{{ savedPhoto(role.slot) ? '替换图片' : '上传图片' }}<input type="file" accept="image/jpeg,image/png" :disabled="busy || Boolean(pending) || Boolean(prepared)" @change="upload(role.slot, $event)" /></label>
|
||||
</template>
|
||||
</OutsourcedBusinessPhotosDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.outsourced-business-photo { display: block; width: 100%; max-height: 160px; object-fit: contain; }
|
||||
.outsourced-business-upload { display: block; margin-top: var(--admin-space-3); }
|
||||
.outsourced-business-upload input { display: block; width: 100%; margin-top: var(--admin-space-2); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user