fix: align audit and dispatch workflows and release failed requests
This commit is contained in:
@@ -14,7 +14,7 @@ vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({
|
||||
vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() }))
|
||||
vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('./tripCalendar')>(), listTripCalendar: vi.fn() }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true }
|
||||
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, plannedDate: '2026-10-21', isDefault: true, actualAt: null, version: 0, planCount: 0, canEdit: true }
|
||||
const row: ScheduleEvent = { key: 'warehouse-1', type: 'warehouse', date: '2026-10-21', time: '', title: 'TEST-CONT 计划到仓', fields: [], warehouse, canEdit: true }
|
||||
function dispatchEntry(overrides: Partial<TripCalendarEntry> = {}): TripCalendarEntry {
|
||||
return { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], cabinetNumbers: [], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [], ...overrides }
|
||||
@@ -34,7 +34,7 @@ export function renderSchedule() {
|
||||
} } })
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); routing.query = { date: '2026-10-19' }; routing.denied.clear()
|
||||
vi.resetAllMocks(); routing.query = { date: '2026-10-19' }; routing.denied.clear()
|
||||
vi.mocked(getCalendarSources).mockResolvedValue({ containers: [{ containerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', vesselName: null, voyageNo: null, destinationPortName: null, destinationWarehouseCode: null, arrivalAt: '2026-10-19T10:00:00', arrivalType: 'ACTUAL' }], emptyReturns: [], emptyReturnsReadable: true })
|
||||
vi.mocked(getWarehouseArrivals).mockResolvedValue([warehouse])
|
||||
vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] })
|
||||
@@ -110,29 +110,24 @@ describe('simplified appointment entry', () => {
|
||||
expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events')).toHaveLength(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('retries an unknown save with the same payload and key, then only reloads reads', async () => {
|
||||
it('releases an unknown save and sends the edited date with a fresh key', async () => {
|
||||
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('network interrupted')).mockResolvedValueOnce({})
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
|
||||
await wrapper.get('input[data-temporal-picker]').setValue('2026-10-22T09:30')
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('提交结果尚未确认')
|
||||
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
|
||||
const guard = vi.mocked(onBeforeRouteLeave).mock.calls.at(-1)![0] as () => Promise<boolean>
|
||||
expect(await guard()).toBe(false)
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(saveWarehouseArrival).mock.calls[1]).toEqual(vi.mocked(saveWarehouseArrival).mock.calls[0])
|
||||
expect(saveWarehouseArrival).toHaveBeenCalledWith('1', { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 0 }, expect.any(String))
|
||||
expect(wrapper.find('[data-dialog]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
await wrapper.get('input[data-temporal-picker]').setValue('2026-10-22T09:30'); await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.get('input').attributes('disabled')).toBeUndefined()
|
||||
await wrapper.get('input[data-temporal-picker]').setValue('2026-10-23T09:30'); await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
const calls = vi.mocked(saveWarehouseArrival).mock.calls
|
||||
expect(calls[1]![2]).not.toBe(calls[0]![2]); expect(calls[1]![1].estimatedWarehouseArrivalAt).toBe('2026-10-23T09:30:00')
|
||||
expect(wrapper.find('[data-dialog]').exists()).toBe(false); wrapper.unmount()
|
||||
})
|
||||
it('requires an explicit reread after a conflict and retains the entered date', async () => {
|
||||
it('allows editing after a conflict and preserves input across an optional manual refresh', async () => {
|
||||
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new ApiResponseError('版本已变化', 'A0409', 409))
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
|
||||
await wrapper.get('input').setValue('2026-10-22T09:30')
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.get('[data-save]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('[data-save]').attributes('disabled')).toBeUndefined()
|
||||
vi.mocked(getWarehouseArrivals).mockResolvedValueOnce([{ ...warehouse, version: 2 }])
|
||||
await wrapper.get('[data-retry]').trigger('click'); await flushPromises()
|
||||
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30')
|
||||
|
||||
@@ -87,30 +87,27 @@ async function openSource(event: ScheduleEvent) {
|
||||
await router.push(appointmentExternalTarget(event.sourcePath, context))
|
||||
}
|
||||
const editor = ref<ScheduleEvent | null>(null), plannedAt = ref(''), originalAt = ref(''), saving = ref(false)
|
||||
const saveError = ref<ApiErrorDetails | null>(null), unknownResult = ref(false), conflict = ref(false)
|
||||
let pending: { id: string; body: WarehouseArrivalChange; key: string } | null = null
|
||||
const saveError = ref<ApiErrorDetails | null>(null), conflict = ref(false)
|
||||
function edit(event: ScheduleEvent) {
|
||||
if (!event.canEdit || !event.warehouse) return
|
||||
editor.value = event; plannedAt.value = event.warehouse.plannedAt?.slice(0, 16) || ''; originalAt.value = plannedAt.value
|
||||
pending = null; saveError.value = null; unknownResult.value = false; conflict.value = false
|
||||
saveError.value = null; conflict.value = false
|
||||
}
|
||||
async function save() {
|
||||
const row = editor.value?.warehouse
|
||||
if (!row || saving.value || conflict.value || !plannedAt.value) return
|
||||
pending ||= { id: row.sourceContainerBookingId, body: { estimatedWarehouseArrivalAt: `${plannedAt.value}:00`, expectedVersion: row.version },
|
||||
if (!row || saving.value || !plannedAt.value) return
|
||||
const request = { id: row.sourceContainerBookingId, body: { estimatedWarehouseArrivalAt: `${plannedAt.value}:00`, expectedVersion: row.version },
|
||||
key: `calendar-arrival-${crypto.randomUUID()}` }
|
||||
saving.value = true; saveError.value = null
|
||||
try {
|
||||
await saveWarehouseArrival(pending.id, pending.body, pending.key)
|
||||
pending = null; unknownResult.value = false; editor.value = null
|
||||
await saveWarehouseArrival(request.id, request.body, request.key)
|
||||
editor.value = null
|
||||
ElMessage.success('计划到仓已保存')
|
||||
await load()
|
||||
} catch (cause) {
|
||||
saveError.value = getApiErrorDetails(cause, '计划到仓保存失败')
|
||||
unknownResult.value = !rejectedCommand(cause)
|
||||
const status = cause instanceof ApiResponseError ? cause.status : isAxiosError(cause) ? cause.response?.status : undefined
|
||||
conflict.value = status === 409 || /409$/.test(saveError.value.code || '')
|
||||
if (!unknownResult.value) pending = null
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
async function rereadEditor() {
|
||||
@@ -128,7 +125,7 @@ async function rereadEditor() {
|
||||
const guards = new Set<LeaveGuard>()
|
||||
provide('appointment-work-leave-guard', (guard: LeaveGuard) => { guards.add(guard); return () => guards.delete(guard) })
|
||||
async function canLeave() {
|
||||
if (saving.value || unknownResult.value) return false
|
||||
if (saving.value) return false
|
||||
for (const guard of guards) if (!await guard()) return false
|
||||
if (editor.value && plannedAt.value !== originalAt.value) {
|
||||
try { await ElMessageBox.confirm('计划到仓尚未保存,确定放弃本次修改?', '未保存修改', { type: 'warning', customClass: 'admin-density-confirm admin-workbench is-dialog' }) }
|
||||
@@ -179,18 +176,17 @@ onBeforeUnmount(() => { ++generation; controller?.abort() })
|
||||
<AppDialog v-if="active && creating" :model-value="true" title="新增FBA送仓预约" class="admin-density-dialog admin-density business-dialog admin-workbench is-dialog" width="1080px" :show-footer="false" :before-close="closeCreate" :close-on-click-modal="true" append-to-body fullscreenable>
|
||||
<AppointmentCreatePanel :can-write="canCreate" :can-leave="canLeave" @created="load()" />
|
||||
</AppDialog>
|
||||
<AppDialog v-if="active" :model-value="!!editor" title="调整计划到仓" class="admin-density-dialog admin-workbench is-dialog" width="520px" fullscreenable :confirm-loading="saving" :confirm-disabled="!plannedAt || conflict" :before-close="closeEditor" :confirm-text="unknownResult ? '重试确认保存结果' : '保存'" @confirm="save">
|
||||
<AppDialog v-if="active" :model-value="!!editor" title="调整计划到仓" class="admin-density-dialog admin-workbench is-dialog" width="520px" fullscreenable :confirm-loading="saving" :confirm-disabled="!plannedAt" :before-close="closeEditor" confirm-text="保存" @confirm="save">
|
||||
<div v-if="editor" class="density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="到仓业务信息">
|
||||
<table class="density-table"><tbody><tr><th scope="row">业务</th><td>{{ editor.title }}</td></tr></tbody></table>
|
||||
</section>
|
||||
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="调整到仓时间">
|
||||
<el-form class="density-form" label-position="left" label-width="150px" :disabled="saving || unknownResult">
|
||||
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="计划到仓时间" required><template #label>计划到仓时间<InlineHelp title="到仓时间说明"><p>自动计划为到港日期加2个自然日;人工保存后优先使用人工时间。填写接仓地点当地时间。</p></InlineHelp></template><el-date-picker v-model="plannedAt" type="datetime" :disabled="saving || unknownResult" required aria-label="计划到仓时间" placeholder="接仓地点当地时间" format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DDTHH:mm" clearable /></el-form-item></td></tr></tbody></table>
|
||||
<el-form class="density-form" label-position="left" label-width="150px" :disabled="saving">
|
||||
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="计划到仓时间" required><template #label>计划到仓时间<InlineHelp title="到仓时间说明"><p>自动计划为到港日期加2个自然日;人工保存后优先使用人工时间。填写接仓地点当地时间。</p></InlineHelp></template><el-date-picker v-model="plannedAt" type="datetime" :disabled="saving" required aria-label="计划到仓时间" placeholder="接仓地点当地时间" format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DDTHH:mm" clearable /></el-form-item></td></tr></tbody></table>
|
||||
</el-form>
|
||||
</section>
|
||||
<p v-if="unknownResult" role="status">上次提交结果尚未确认,请使用原请求重试,避免重复修改。</p>
|
||||
<LoadErrorState v-if="saveError" title="计划到仓未保存" :message="saveError.message" :loading="saving" @retry="conflict ? rereadEditor() : save()"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
<p v-if="conflict" role="status">重试将重新读取当前资格和版本并保留输入,核对后再次点击保存。</p>
|
||||
</div>
|
||||
|
||||
@@ -82,9 +82,9 @@ async function saveArrangement() {
|
||||
pendingCreate.value = null; batchPreview.value = null; selected.value = {}
|
||||
ElMessage.success(`预约安排 ${result.appointmentNo} 已保存`); ++externalRevision.value; emit('created'); await load()
|
||||
} catch (cause) {
|
||||
createError.value = getApiErrorDetails(cause, '保存结果待核实,请重试原请求')
|
||||
createError.value = getApiErrorDetails(cause, '保存失败,请重新操作')
|
||||
if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(createError.value.code || '')) pendingCreate.value = null
|
||||
} finally { creating.value = false }
|
||||
} finally { pendingCreate.value = null; creating.value = false }
|
||||
}
|
||||
let followupRequest = 0
|
||||
const query = ref<QueryModel>({ keyword: '' })
|
||||
@@ -202,7 +202,7 @@ onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller
|
||||
</template>
|
||||
</DataTable>
|
||||
<AppointmentFollowupDialog v-if="following" :source="following.source" :coverage="following.coverage" :can-write="access.can('platform:appointment:followup')" :can-arrange="access.can(overseasWarehousePermissions.inboundOrderAppointment)" @arrange="arrangeFollowing" @close="following = null" />
|
||||
<AppDialog v-if="batchPreview" :model-value="true" class="admin-density-dialog" title="合批预约安排" width="min(1080px, calc(100vw - 48px))" :show-confirm="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :confirm-text="pendingCreate ? '重试本次保存' : '保存预约安排'" :confirm-loading="creating" cancel-text="关闭" @confirm="saveArrangement" @update:model-value="closeBatch" :before-close="beforeClose">
|
||||
<AppDialog v-if="batchPreview" :model-value="true" class="admin-density-dialog" title="合批预约安排" width="min(1080px, calc(100vw - 48px))" :show-confirm="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :confirm-text="'保存预约安排'" :confirm-loading="creating" cancel-text="关闭" @confirm="saveArrangement" @update:model-value="closeBatch" :before-close="beforeClose">
|
||||
<p>{{ batchPreview.originWarehouseName }} → {{ batchPreview.destinationWarehouseCode }}</p>
|
||||
<p>{{ batchPreview.group.sourceCount }} 票 / {{ batchPreview.group.customerCount }} 个客户 / 预报 {{ batchPreview.group.packages ?? '待核实' }} 箱</p>
|
||||
<p v-if="batchPreview.group.readinessIncomplete">货物备妥窗口待核实;预计到港或预计到仓不能替代备妥时间。</p>
|
||||
|
||||
@@ -78,24 +78,17 @@ describe('appointment impact handling', () => {
|
||||
expect(wrapper.text()).toContain('认领跟进')
|
||||
expect(handleImpact).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('freezes an uncertain command and retries exactly once with original version and key', async () => {
|
||||
it('unlocks close and navigation after uncertainty and permits a fresh click', async () => {
|
||||
vi.mocked(handleImpact).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({ ...detail.impact, version: 2 })
|
||||
vi.mocked(getImpact).mockResolvedValueOnce({...detail,transport:{trips:[{tripNo:'TRIP-1',status:'PLANNED',plannedDepartureAt:null}],sameTripBookings:[]}})
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button').find(b => b.text() === '认领跟进')!.trigger('click'); await flushPromises()
|
||||
const original = vi.mocked(handleImpact).mock.calls[0]![1]
|
||||
expect(original).toMatchObject({ action: 'CLAIM', expectedVersion: 1, forecastToken: 'current' })
|
||||
await wrapper.get('[data-close]').trigger('click'); expect(wrapper.emitted('close')).toBeUndefined()
|
||||
const done=vi.fn(); wrapper.findComponent({name:'AppDialog'}).props('beforeClose')(done); expect(done).not.toHaveBeenCalled()
|
||||
expect(guards.register.mock.calls[0]![0]()).toBe(false)
|
||||
await wrapper.findAll('button').find(b=>b.text()==='TRIP-1')!.trigger('click');expect(guards.push).not.toHaveBeenCalled()
|
||||
for (const hook of [onBeforeRouteLeave,onBeforeRouteUpdate]) expect((vi.mocked(hook).mock.calls[0]![0] as () => boolean)()).toBe(false)
|
||||
await wrapper.findAll('button').find(b => b.text() === '重试本次处理')!.trigger('click'); await flushPromises()
|
||||
expect(handleImpact).toHaveBeenNthCalledWith(2, '1', original)
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
expect(getImpact).toHaveBeenCalledTimes(2)
|
||||
expect(guards.register.mock.calls[0]![0]()).toBe(true)
|
||||
wrapper.unmount(); expect(guards.unregister).toHaveBeenCalledOnce()
|
||||
await wrapper.get('[data-close]').trigger('click'); expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
const done = vi.fn(); wrapper.findComponent({name:'AppDialog'}).props('beforeClose')(done); expect(done).toHaveBeenCalledOnce()
|
||||
await wrapper.findAll('button').find(b => b.text() === '认领跟进')!.trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(handleImpact).mock.calls[1]![1].idempotencyKey).not.toBe(original.idempotencyKey)
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('does not resolve without an actual handling result', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
|
||||
@@ -26,8 +26,8 @@ async function save(action: ImpactCommand['action']) {
|
||||
pending.value ||= { expectedVersion: detail.value.impact.version, forecastToken: detail.value.impact.forecastToken, idempotencyKey: crypto.randomUUID(), action, result: form.result.trim() || null, nextAction: form.nextAction.trim() || null, followupDate: form.followupDate || null }
|
||||
saving.value = true; error.value = null
|
||||
try { await handleImpact(props.containerId, pending.value); pending.value = null; emit('saved'); historyPage.value = 1; await load() }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '处理结果待核实,请重试原请求'); if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(error.value.code || '')) pending.value = null }
|
||||
finally { saving.value = false }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '操作失败,请重新操作'); if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(error.value.code || '')) pending.value = null }
|
||||
finally { pending.value = null; saving.value = false }
|
||||
}
|
||||
function canLeave() { if (saving.value || pending.value) { ElMessage.warning('请先核实本次处理结果,再关闭或切换'); return false } return true }
|
||||
function close() { if (canLeave()) emit('close') }
|
||||
@@ -65,7 +65,7 @@ onMounted(load)
|
||||
<el-button v-if="!pending" :disabled="saving || loading" @click="save('CLAIM')">认领跟进</el-button>
|
||||
<el-button v-if="!pending" :disabled="saving || loading" @click="save('RECORD')">记录处理</el-button>
|
||||
<el-button v-if="!pending" type="primary" :disabled="saving || loading" @click="save('RESOLVE')">完成本次核对</el-button>
|
||||
<el-button v-else type="primary" :loading="saving" @click="save(pending.action)">重试本次处理</el-button>
|
||||
|
||||
</div>
|
||||
<details><summary>观察及处理历史({{ detail.history.total }} 条)</summary><div class="impact-list"><p v-for="row in detail.history.records" :key="row.version">{{ actions[row.action] || '核对记录' }} · {{ row.operatorName || '—' }} · {{ row.createdAt }} UTC<br>{{ row.result || '记录来源观察' }} · {{ row.nextAction || '—' }} · {{ row.followupDate || '—' }}</p></div><el-pagination v-model:current-page="historyPage" :page-size="20" :total="detail.history.total" layout="prev, pager, next" :disabled="loading || saving || !!pending" @current-change="load" /></details>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import WorkDispatchForm from './WorkDispatchForm.vue'
|
||||
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, type BatchDispatchCandidate, type Candidate, type CommandResult, type DispatchDetail, type DispatchDraft } from '@/modules/overseas-warehouse'
|
||||
import { createDispatch, getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, type BatchDispatchCandidate, type Candidate, type CommandResult, type DispatchDetail, type DispatchDraft } from '@/modules/overseas-warehouse'
|
||||
import { ApiResponseError, http } from '@/shared/api/http'
|
||||
import { parseApiJsonResponse } from '@chongchong/shared'
|
||||
import { getReceivingContext, type ReceivingContext } from './receivingApi'
|
||||
@@ -12,7 +12,7 @@ const identity = vi.hoisted(() => ({ id: '' }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => access.allowed && !access.denied.includes(permission) }) }))
|
||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn(), getBatchDispatchCandidates: vi.fn(), listDispatchCandidates: vi.fn(), createDispatchDraft: vi.fn(), createDispatchDraftContainer: vi.fn(), listDispatchDrafts: vi.fn(), lockDispatchDraftPallet: vi.fn(), convertDispatchDraft: vi.fn(), releaseDispatchDraft: vi.fn() }))
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), createDispatch: vi.fn(), getDispatch: vi.fn(), getBatchDispatchCandidates: vi.fn(), listDispatchCandidates: vi.fn(), createDispatchDraft: vi.fn(), createDispatchDraftContainer: vi.fn(), listDispatchDrafts: vi.fn(), lockDispatchDraftPallet: vi.fn(), convertDispatchDraft: vi.fn(), releaseDispatchDraft: vi.fn() }))
|
||||
vi.mock('./receivingApi', async original => ({ ...await original<typeof import('./receivingApi')>(), getReceivingContext: vi.fn() }))
|
||||
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
||||
const batch = 'FBA_APPOINTMENT:1'
|
||||
@@ -33,7 +33,7 @@ it.each([0, '0', '9007199254740993'])('preserves authoritative allocation versio
|
||||
const wrapper = render()
|
||||
try {
|
||||
await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations[0]!.recordVersion).toBe(recordVersion)
|
||||
expect(vi.mocked(createDispatch).mock.calls[0]![0].batchAllocations[0]!.recordVersion).toBe(recordVersion)
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
it.each([undefined, null, 'bad', 9007199254740992])('blocks an existing-batch candidate lacking a valid authoritative version: %s', async recordVersion => {
|
||||
@@ -49,6 +49,7 @@ beforeEach(() => {
|
||||
vi.mocked(getReceivingContext).mockImplementation(async key => receiving(key))
|
||||
vi.mocked(getBatchDispatchCandidates).mockImplementation(async key => ({ batchKey: key, version: 4, candidates: [row('9007199254740993', 'customer-a', key)], blockReasons: [] }))
|
||||
vi.mocked(listDispatchCandidates).mockImplementation(async () => ({ total: 1, page: 1, size: 20, pages: 1, candidates: clone(natives), complete: true, snapshotAt: '2026-10-19T00:00:00Z' }))
|
||||
vi.mocked(createDispatch).mockImplementation(async body => proof(body.idempotencyKey))
|
||||
vi.mocked(createDispatchDraft).mockImplementation(async () => clone(draft))
|
||||
vi.mocked(createDispatchDraftContainer).mockImplementation(async body => { draft.recordVersion = 1; draft.containers = [{ draftContainerId: 'container-1', sequenceNo: 1, capacity: body.capacity, sourceKeys: [] }]; return clone(draft) })
|
||||
vi.mocked(lockDispatchDraftPallet).mockImplementation(async body => { const source = natives.find(value => value.selection.planPalletId === body.source.planPalletId)!; draft.recordVersion = Number(draft.recordVersion) + 1; draft.sources.push(clone(source)); draft.containers[0]!.sourceKeys.push(source.sourceKey); return clone(draft) })
|
||||
@@ -64,8 +65,8 @@ it('creates the whole dispatch when native Long candidate quantities arrive as s
|
||||
try {
|
||||
await flushPromises(); expect(wrapper.get('input').attributes('disabled')).toBeUndefined()
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
|
||||
expect(convertDispatchDraft).toHaveBeenCalledOnce(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual(row().allocations)
|
||||
expect(vi.mocked(lockDispatchDraftPallet).mock.calls[0]![0].source.planPalletId).toBe('9007199254740993'); expect(wrapper.emitted('updated')).toHaveLength(1)
|
||||
expect(createDispatch).toHaveBeenCalledOnce(); expect(vi.mocked(createDispatch).mock.calls[0]![0].batchAllocations).toEqual(row().allocations)
|
||||
expect(vi.mocked(createDispatch).mock.calls[0]![0].cargo[0]!.planPalletId).toBe('9007199254740993'); expect(wrapper.emitted('updated')).toHaveLength(1)
|
||||
} finally { wrapper.unmount(); get.mockRestore() }
|
||||
})
|
||||
it('restores existing batch dispatches after remount and navigates to the same whole dispatch without any creation', async () => {
|
||||
@@ -74,7 +75,7 @@ it('restores existing batch dispatches after remount and navigates to the same w
|
||||
const wrapper = render(); await flushPromises(); expect(wrapper.text()).toContain('DS/EXISTING')
|
||||
await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2FEXISTING']]); expect(wrapper.text()).not.toContain('创建整车排车单'); wrapper.unmount()
|
||||
}
|
||||
expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); expect(createDispatchDraft).not.toHaveBeenCalled(); expect(convertDispatchDraft).not.toHaveBeenCalled()
|
||||
expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); expect(createDispatchDraft).not.toHaveBeenCalled(); expect(createDispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
it('does not fall back to new creation when transport relations fail or their nested batch ownership differs', async () => {
|
||||
vi.mocked(getReceivingContext).mockRejectedValueOnce(new Error('unavailable')); const failed = render(); await flushPromises(); expect(failed.text()).toContain('批次可排实物读取失败'); expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); failed.unmount()
|
||||
@@ -84,71 +85,52 @@ it('does not fall back to new creation when transport relations fail or their ne
|
||||
it('reads remaining physical cargo only after an explicit choice while retaining the original dispatch', async () => {
|
||||
vi.mocked(getReceivingContext).mockImplementation(async key => receiving(key, [{ dispatchNo: 'DS-OLD', batchKey: key, customerId: 'customer-a', tripNo: null, version: null, status: 'ACTIVE', packageCount: 12 }]))
|
||||
const wrapper = render(); await flushPromises(); expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
|
||||
await button(wrapper, '核对本批剩余可排实物').trigger('click'); await flushPromises(); expect(getBatchDispatchCandidates).toHaveBeenCalledExactlyOnceWith(batch); expect(wrapper.text()).toContain('DS-OLD'); expect(convertDispatchDraft).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
await button(wrapper, '核对本批剩余可排实物').trigger('click'); await flushPromises(); expect(getBatchDispatchCandidates).toHaveBeenCalledExactlyOnceWith(batch); expect(wrapper.text()).toContain('DS-OLD'); expect(createDispatch).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('uses a server-owned draft, explicit zero versions, native source hashes and exact allocations for one whole dispatch', async () => {
|
||||
it('creates the whole dispatch with native source versions and exact batch allocations', async () => {
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
|
||||
expect(createDispatchDraft).toHaveBeenCalledWith({ warehouseId: 'origin-1', idempotencyKey: expect.any(String) })
|
||||
expect(createDispatchDraftContainer).toHaveBeenCalledWith({ draftToken: 'server-owned-token', recordVersion: 0, idempotencyKey: expect.any(String), capacity: 30 })
|
||||
expect(lockDispatchDraftPallet).toHaveBeenCalledWith({ draftToken: 'server-owned-token', draftContainerId: 'container-1', recordVersion: 1, idempotencyKey: expect.any(String), source: natives[0]!.selection })
|
||||
const request = vi.mocked(convertDispatchDraft).mock.calls[0]![0]
|
||||
expect(request).toMatchObject({ draftToken: 'server-owned-token', recordVersion: 2, groups: [{ draftContainerId: 'container-1', dispatch: { warehouseId: 'origin-1', cargo: [natives[0]!.selection], batchAllocations: row().allocations, vehicle: null, driver: null } }] })
|
||||
expect(createDispatch).toHaveBeenCalledWith(expect.objectContaining({ warehouseId: 'origin-1', cargo: [natives[0]!.selection], batchAllocations: row().allocations, vehicle: null, driver: null }))
|
||||
expect(wrapper.text()).toContain('DS/001'); expect(wrapper.emitted('updated')).toHaveLength(1); expect(await guard()).toBe(true)
|
||||
await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2F001']]); wrapper.unmount()
|
||||
})
|
||||
it('combines same-warehouse real customers in one container and keeps each batch source owner', async () => {
|
||||
const second = row('9007199254740994', 'customer-b'); natives.push(candidate(second)); vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [row(), second], blockReasons: [] })
|
||||
const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input[type=checkbox]').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, true])
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const groups = vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const groups = [{ dispatch: vi.mocked(createDispatch).mock.calls[0]![0] }]
|
||||
expect(groups).toHaveLength(1); expect(groups[0]!.dispatch.cargo).toEqual(natives.map(value => value.selection)); expect(groups[0]!.dispatch.batchAllocations.map(value => value.customerId)).toEqual(['customer-a', 'customer-b']); expect(wrapper.text()).not.toContain('共车'); wrapper.unmount()
|
||||
})
|
||||
it('preserves exact distinct batch allocations while combining selected same-warehouse batches', async () => {
|
||||
const otherKey = 'FBA_APPOINTMENT:2', second = row('9007199254740994', 'customer-b', otherKey); natives.push(candidate(second))
|
||||
vi.mocked(getBatchDispatchCandidates).mockImplementation(async key => ({ batchKey: key, version: 4, candidates: [key === batch ? row() : second], blockReasons: [] }))
|
||||
const wrapper = render({ batchKeys: [batch, otherKey] }); await flushPromises(); for (const input of wrapper.findAll('input[type="checkbox"]')) await input.setValue(true)
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual([...row().allocations, ...second.allocations]); wrapper.unmount()
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(createDispatch).mock.calls[0]![0].batchAllocations).toEqual([...row().allocations, ...second.allocations]); wrapper.unmount()
|
||||
})
|
||||
it('keeps blocked, mismatched-customer, wrong-source and unknown-quantity cargo out of the payload', async () => {
|
||||
const blocked = { ...row('2'), blockReasons: ['本托已被其他排车占用'] }, wrong = row('3'), unknown = { ...row('4'), packageCount: null }, wrongSource = row('5')
|
||||
wrong.allocations[0]!.customerId = 'customer-b'; wrongSource.allocations[0]!.sourceKey = 'ORDER:unrelated'; natives.push(...[blocked, wrong, unknown, wrongSource].map(value => candidate(value)))
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [row(), blocked, wrong, unknown, wrongSource], blockReasons: [] })
|
||||
const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input[type=checkbox]').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, false, false, false, false])
|
||||
expect(wrapper.text()).toContain('本托已被其他排车占用'); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual(row().allocations); wrapper.unmount()
|
||||
expect(wrapper.text()).toContain('本托已被其他排车占用'); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(createDispatch).mock.calls[0]![0].batchAllocations).toEqual(row().allocations); wrapper.unmount()
|
||||
})
|
||||
it('accepts partial pages but fails closed on missing versions and cross-warehouse batches', async () => {
|
||||
vi.mocked(listDispatchCandidates).mockResolvedValueOnce({ total: 1, page: 1, size: 20, pages: 1, candidates: natives, complete: false, snapshotAt: '' }); const incomplete = render(); await flushPromises(); expect(incomplete.get('input[type=checkbox]').attributes('disabled')).toBeUndefined(); expect(createDispatchDraft).not.toHaveBeenCalled(); incomplete.unmount()
|
||||
natives[0]!.selection.sourceVersion = ''; const missing = render(); await flushPromises(); expect(missing.text()).toContain('来源版本不完整'); missing.unmount()
|
||||
vi.mocked(getReceivingContext).mockImplementation(async key => { const value = receiving(key); if (key !== batch) value.batch.originWarehouseId = 'another-warehouse'; return value })
|
||||
const cross = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(cross.text()).toContain('相同的起运仓'); expect(convertDispatchDraft).not.toHaveBeenCalled(); cross.unmount()
|
||||
const cross = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(cross.text()).toContain('相同的起运仓'); expect(createDispatch).not.toHaveBeenCalled(); cross.unmount()
|
||||
})
|
||||
it('retains the entire original conversion and skips all prior locks after an unknown response, even after remount with the work closed', async () => {
|
||||
vi.mocked(convertDispatchDraft).mockRejectedValueOnce(new Error('lost response'))
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const original = clone(vi.mocked(convertDispatchDraft).mock.calls[0]![0])
|
||||
expect(await guard()).toBe(false); wrapper.unmount()
|
||||
const restored = render({ canWrite: false }); await flushPromises(); expect(restored.text()).toContain('原请求已保留'); await button(restored, '核对并重试原请求').trigger('click'); await flushPromises()
|
||||
expect(convertDispatchDraft).toHaveBeenNthCalledWith(2, original); expect(lockDispatchDraftPallet).toHaveBeenCalledTimes(1); expect(createDispatchDraftContainer).toHaveBeenCalledTimes(1); expect(restored.emitted('updated')).toHaveLength(1); restored.unmount()
|
||||
})
|
||||
it('retries the exact container command after a lost response instead of generating another container key', async () => {
|
||||
vi.mocked(createDispatchDraftContainer).mockRejectedValueOnce(new Error('unknown container'))
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const original = clone(vi.mocked(createDispatchDraftContainer).mock.calls[0]![0])
|
||||
await button(wrapper, '核对并重试原请求').trigger('click'); await flushPromises(); expect(createDispatchDraftContainer).toHaveBeenNthCalledWith(2, original); expect(createDispatchDraft).toHaveBeenCalledTimes(1); expect(convertDispatchDraft).toHaveBeenCalledTimes(1); wrapper.unmount()
|
||||
})
|
||||
it('keeps a proven CREATE across failed current-detail GET and only repeats the read', async () => {
|
||||
|
||||
|
||||
it('shows the confirmed dispatch link even when its detail read fails', async () => {
|
||||
vi.mocked(getDispatch).mockRejectedValueOnce(new Error('GET unavailable'))
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(convertDispatchDraft).toHaveBeenCalledTimes(1); expect(wrapper.emitted('updated')).toBeUndefined()
|
||||
await button(wrapper, '核对并重试原请求').trigger('click'); await flushPromises(); expect(convertDispatchDraft).toHaveBeenCalledTimes(1); expect(getDispatch).toHaveBeenCalledTimes(2); expect(wrapper.emitted('updated')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('never releases an unknown result for a later rejection, and releases only an explicitly rejected owned draft at its read-back version', async () => {
|
||||
vi.mocked(lockDispatchDraftPallet).mockRejectedValueOnce(new Error('unknown')).mockRejectedValueOnce(new ApiResponseError('conflict', 'A0409', 409))
|
||||
const unknown = render(); await flushPromises(); await button(unknown, '创建整车排车单').trigger('click'); await flushPromises(); await button(unknown, '核对并重试原请求').trigger('click'); await flushPromises(); expect(unknown.text()).not.toContain('按明确拒绝结果释放'); expect(await guard()).toBe(false); unknown.unmount()
|
||||
sessionStorage.clear(); vi.mocked(lockDispatchDraftPallet).mockRejectedValueOnce(new ApiResponseError('conflict', 'A0409', 409)); draft = { ...draft, draftToken: 'another-owned-token', containers: [], recordVersion: 0 }
|
||||
const rejected = render(); await flushPromises(); await button(rejected, '创建整车排车单').trigger('click'); await flushPromises(); draft.recordVersion = 9
|
||||
await button(rejected, '按明确拒绝结果释放本草稿并重新核对').trigger('click'); await flushPromises(); expect(releaseDispatchDraft).toHaveBeenCalledWith({ draftToken: 'another-owned-token', recordVersion: 9, idempotencyKey: expect.any(String) }); rejected.unmount()
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
|
||||
expect(createDispatch).toHaveBeenCalledTimes(1); expect(wrapper.emitted('updated')).toHaveLength(1)
|
||||
await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2F001']]); expect(await guard()).toBe(true); wrapper.unmount()
|
||||
})
|
||||
|
||||
it('preserves quantity-split navigation and uses create permission rather than the removed smart-dispatch permission', async () => {
|
||||
access.denied = ['platform:overseas-warehouse:inbound-order:smart-dispatch']; const wrapper = render(); await flushPromises()
|
||||
await button(wrapper, '按当前批次箱数分批').trigger('click'); expect(wrapper.emitted('batch')).toEqual([[batch]]); expect(lockDispatchDraftPallet).not.toHaveBeenCalled()
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(convertDispatchDraft).toHaveBeenCalledTimes(1)
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(createDispatch).toHaveBeenCalledTimes(1)
|
||||
await button(wrapper, '为本车添加同仓货物').trigger('click'); expect(wrapper.getComponent({ name: 'WorkSharedTripForm' }).props('dispatchNo')).toBe('DS/001'); wrapper.unmount()
|
||||
})
|
||||
it('does not read scoped cargo without original permissions or create while only read access remains', async () => {
|
||||
@@ -158,7 +140,7 @@ it('does not read scoped cargo without original permissions or create while only
|
||||
it('shows batch-level blockers even without rows and refuses duplicate physical candidates across batches', async () => {
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValueOnce({ batchKey: batch, version: 4, candidates: [], blockReasons: ['原批次已替代,请进入当前批次'] })
|
||||
const blocked = render(); await flushPromises(); expect(blocked.get('[role="alert"]').text()).toContain('原批次已替代'); expect(createDispatchDraft).not.toHaveBeenCalled(); blocked.unmount()
|
||||
const duplicate = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(duplicate.findAll('input').every(input => input.attributes('disabled') !== undefined)).toBe(true); expect(duplicate.text()).toContain('同一实物'); expect(convertDispatchDraft).not.toHaveBeenCalled(); duplicate.unmount()
|
||||
const duplicate = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(duplicate.findAll('input').every(input => input.attributes('disabled') !== undefined)).toBe(true); expect(duplicate.text()).toContain('同一实物'); expect(createDispatch).not.toHaveBeenCalled(); duplicate.unmount()
|
||||
})
|
||||
|
||||
it('keeps original batch allocations and selected cargo while paging through associated plan candidates', async () => {
|
||||
@@ -176,8 +158,8 @@ it('keeps original batch allocations and selected cargo while paging through ass
|
||||
expect(getBatchDispatchCandidates).toHaveBeenCalledTimes(1)
|
||||
expect(listDispatchCandidates).toHaveBeenCalledTimes(2)
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
|
||||
expect(convertDispatchDraft).toHaveBeenCalledTimes(1)
|
||||
expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual([...first.allocations, ...second.allocations])
|
||||
expect(createDispatch).toHaveBeenCalledTimes(1)
|
||||
expect(vi.mocked(createDispatch).mock.calls[0]![0].batchAllocations).toEqual([...first.allocations, ...second.allocations])
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
it('rejects a selected batch source whose group version changed on another candidate page', async () => {
|
||||
@@ -195,3 +177,13 @@ it('rejects a selected batch source whose group version changed on another candi
|
||||
expect(createDispatchDraft).not.toHaveBeenCalled()
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
|
||||
it('failure preserves selection, releases navigation and creates a fresh request on the next click', async () => {
|
||||
vi.mocked(createDispatch).mockRejectedValueOnce(new Error('timeout'))
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
|
||||
const original = vi.mocked(createDispatch).mock.calls[0]![0]
|
||||
expect(wrapper.get('input[type=checkbox]').attributes('disabled')).toBeUndefined(); expect((wrapper.get('input[type=checkbox]').element as HTMLInputElement).checked).toBe(true)
|
||||
expect(await guard()).toBe(true); expect(sessionStorage.length).toBe(0)
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(createDispatch).mock.calls[1]![0].idempotencyKey).not.toBe(original.idempotencyKey); expect(getReceivingContext).toHaveBeenCalledTimes(1); wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, useDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, dispatchVehicleSpecs, DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection, type BatchDispatchCandidate, type BatchDispatchCandidates, type Candidate, type DispatchDetail, type DispatchDraft, type ConvertDraft, type ConvertedDraft, type DraftSelection, type VersionCommand } from '@/modules/overseas-warehouse'
|
||||
import { getDispatch, createDispatch, getBatchDispatchCandidates, listDispatchCandidates, useDispatchCandidates, dispatchVehicleSpecs, DispatchInputError, requireBatchVersions, expandSelectionGroups, selectionGroupBlockReason, validateCommandResult, validateCurrentDispatch, type BatchDispatchCandidate, type BatchDispatchCandidates, type DispatchDetail, type Candidate } from '@/modules/overseas-warehouse'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -17,11 +17,8 @@ import { useWorkDraft } from './useWorkDraft'
|
||||
const props = defineProps<{ batchKeys: string[]; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ updated: []; external: [path: string]; batch: [key: string] }>()
|
||||
type BatchRow = BatchDispatchCandidate & { key: string; batchKey: string; native: Candidate | null; blockReasons: string[] }
|
||||
type ReadyRow = BatchRow & { native: Candidate }
|
||||
type Step = { action: 'CREATE' | 'CONTAINER' | 'LOCK' | 'CONVERT' | 'RELEASE'; body: Record<string, unknown>; attempted: boolean; rejected: boolean }
|
||||
type Preparation = { warehouseId: string; capacity: number; rows: ReadyRow[]; dispatchKey: string; draft: DispatchDraft | null; containerId: string | null; lockedKeys: string[]; step: Step | null; converted: ConvertedDraft | null; releasing: boolean }
|
||||
const access = useAccessPreviewStore(), auth = useAuthStore(), owner = auth.user?.id || ''
|
||||
const scope = JSON.stringify([...new Set(props.batchKeys)].sort()), storageKey = `appointment-dispatch:${owner}:${scope}`
|
||||
const scope = JSON.stringify([...new Set(props.batchKeys)].sort())
|
||||
const contexts = ref<BatchDispatchCandidates[]>([]), rows = ref<BatchRow[]>([]), selected = ref<string[]>([]), warehouseId = ref('')
|
||||
const working = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
||||
const candidatePage = useDispatchCandidates(error, listDispatchCandidates)
|
||||
@@ -30,26 +27,24 @@ const loading = computed(() => working.value || candidatePage.loading.value)
|
||||
const selectedRows = ref<Record<string, BatchRow>>({})
|
||||
let receivingContexts: Awaited<ReturnType<typeof getReceivingContext>>[] = []
|
||||
let nativeGeneration = 0
|
||||
const capacity = ref(30), result = ref<DispatchDetail | null>(null), pending = ref<Preparation | null>(null), corrupted = ref(false)
|
||||
const createdNo = ref('')
|
||||
const capacity = ref(30), result = ref<DispatchDetail | null>(null)
|
||||
const adding = ref(false), addingBusy = ref(false), arrangingRemaining = ref(false), existingReady = ref(false)
|
||||
const existing = ref<Array<{ batchKey: string; dispatchNo: string; customerId: string }>>([]), baseline = ref('[]')
|
||||
const readable = computed(() => ['platform:appointment:list', 'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-warehouse:inbound-order:list', 'platform:overseas-warehouse:dispatch:list'].every(permission => access.can(permission)))
|
||||
const canCreate = computed(() => readable.value && access.can('platform:overseas-warehouse:dispatch:create'))
|
||||
const writable = computed(() => props.canWrite && canCreate.value && !corrupted.value && (!existing.value.length || existingReady.value))
|
||||
const writable = computed(() => props.canWrite && canCreate.value && (!existing.value.length || existingReady.value))
|
||||
const chosen = computed(() => selected.value.map(key => selectedRows.value[key]).filter((row): row is BatchRow => Boolean(row)))
|
||||
const unresolved = computed(() => saving.value || Boolean(pending.value) || corrupted.value)
|
||||
useWorkDraft(computed(() => JSON.stringify(selected.value) !== baseline.value && !result.value), unresolved)
|
||||
const unresolved = computed(() => saving.value)
|
||||
useWorkDraft(computed(() => JSON.stringify(selected.value) !== baseline.value && !createdNo.value), unresolved)
|
||||
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
||||
let generation = 0
|
||||
function ensureOwner() { if (!owner || auth.user?.id !== owner || JSON.stringify([...new Set(props.batchKeys)].sort()) !== scope) throw new DispatchInputError('排车登录身份或批次范围已变化,请重新进入原事项') }
|
||||
function persist() { ensureOwner(); if (pending.value) sessionStorage.setItem(storageKey, JSON.stringify(pending.value)); else sessionStorage.removeItem(storageKey) }
|
||||
try { const saved = owner && sessionStorage.getItem(storageKey); if (saved) pending.value = JSON.parse(saved) as Preparation }
|
||||
catch { corrupted.value = true; saveError.value = { message: '原排车请求记录损坏,请保留现场核对,不能另建排车单' } }
|
||||
function reasons(row: BatchRow) { return row.blockReasons }
|
||||
function physicalCount(values: BatchRow[]) { return new Set(values.flatMap(row => row.native?.selectionGroup.pallets.map(pallet => pallet.warehousePalletId || pallet.palletNo || row.key) || [])).size }
|
||||
function detailPath(no: string) { return `/overseas-warehouse/dispatches/${encodeURIComponent(no)}` }
|
||||
async function load() {
|
||||
if (!readable.value || pending.value || corrupted.value) return
|
||||
if (!readable.value) return
|
||||
const current = ++generation, batches = [...new Set(props.batchKeys)]
|
||||
working.value = true; error.value = null; existingReady.value = false; rows.value = []; selected.value = []; baseline.value = '[]'; contexts.value = []; selectedRows.value = {}; candidatePage.reset(); nativeGeneration++
|
||||
try {
|
||||
@@ -122,105 +117,24 @@ function select(row: BatchRow, event: Event) {
|
||||
}
|
||||
input.checked = selected.value.includes(row.key)
|
||||
}
|
||||
function step(action: Step['action'], body: object) { pending.value!.step = { action, body: clone(body) as Record<string, unknown>, attempted: false, rejected: false }; persist() }
|
||||
function common(draft: DispatchDraft) { return { recordVersion: requireVersion(draft.recordVersion), idempotencyKey: crypto.randomUUID(), draftToken: draft.draftToken } }
|
||||
async function executeStep() {
|
||||
const request = pending.value!, command = request.step!, previouslyAttempted = command.attempted
|
||||
command.attempted = true; persist()
|
||||
try {
|
||||
let value: DispatchDraft | ConvertedDraft
|
||||
if (command.action === 'CREATE') value = await createDispatchDraft(command.body as unknown as { idempotencyKey: string; warehouseId: string })
|
||||
else if (command.action === 'CONTAINER') value = await createDispatchDraftContainer(command.body as unknown as VersionCommand & { draftToken: string; capacity: number })
|
||||
else if (command.action === 'LOCK') value = await lockDispatchDraftPallet(command.body as unknown as DraftSelection)
|
||||
else if (command.action === 'RELEASE') value = await releaseDispatchDraft(command.body as unknown as VersionCommand & { draftToken: string })
|
||||
else value = await convertDispatchDraft(command.body as unknown as ConvertDraft)
|
||||
ensureOwner()
|
||||
if (!value.draftToken || command.body.draftToken && value.draftToken !== command.body.draftToken) throw new DispatchInputError('草稿结果与原请求不一致,请保留原请求')
|
||||
if (command.action === 'CONVERT') {
|
||||
const converted = value as ConvertedDraft
|
||||
if (converted.vehicles.length !== 1 || converted.vehicles[0]?.draftContainerId !== request.containerId) throw new DispatchInputError('整车结果与原装车容器不一致')
|
||||
validateCommandResult(converted.vehicles[0]!.dispatch, { dispatchNo: '', action: 'CREATE', body: { idempotencyKey: request.dispatchKey } })
|
||||
request.converted = converted
|
||||
} else {
|
||||
const draft = value as DispatchDraft; requireVersion(draft.recordVersion)
|
||||
if (draft.warehouseId !== request.warehouseId) throw new DispatchInputError('草稿起运仓与原批次不一致')
|
||||
if (command.action === 'CONTAINER') {
|
||||
if (draft.containers.length !== 1) throw new DispatchInputError('本次草稿装车容器不唯一,请保留原请求核对')
|
||||
request.containerId = draft.containers[0]!.draftContainerId
|
||||
}
|
||||
if (command.action === 'LOCK') {
|
||||
const selectedSource = command.body.source as DraftSelection['source']
|
||||
const expected = request.rows.find(row => row.native.selection.planPalletId === selectedSource.planPalletId && row.native.selection.sourceType === selectedSource.sourceType && row.native.sourceVersion === selectedSource.sourceVersion)
|
||||
const source = expected && draft.sources.find(row => row.sourceKey === expected.native.sourceKey && row.customerId === expected.customerId)
|
||||
if (!source || !draft.containers.find(row => row.draftContainerId === request.containerId)?.sourceKeys.includes(source.sourceKey)) throw new DispatchInputError('原来源未确认进入本装车组,请核对原请求')
|
||||
const group = requireSelectionGroup(source)
|
||||
assertSelectionGroupVersions([expected!.native], [source])
|
||||
const boundKeys = draft.containers.find(row => row.draftContainerId === request.containerId)!.sourceKeys
|
||||
if (group.sources.some(required => !boundKeys.includes(required.sourceKey))) throw new DispatchInputError('完整选货组尚未原子进入本装车组,请核对原请求')
|
||||
request.lockedKeys.push(...group.sources.map(required => required.sourceKey))
|
||||
}
|
||||
request.draft = draft
|
||||
}
|
||||
request.step = null; persist()
|
||||
} catch (cause) {
|
||||
ensureOwner(); if (!previouslyAttempted && isDefiniteDispatchRejection(cause)) command.rejected = true
|
||||
persist(); throw cause
|
||||
}
|
||||
}
|
||||
async function save() {
|
||||
if (saving.value || loading.value || corrupted.value || !canCreate.value) return
|
||||
saveError.value = null
|
||||
if (saving.value || loading.value || !canCreate.value) return
|
||||
saveError.value = null; saving.value = true
|
||||
try {
|
||||
ensureOwner()
|
||||
if (!pending.value) {
|
||||
if (!writable.value || error.value || result.value || !chosen.value.length || chosen.value.some(row => reasons(row).length || !row.native) || physicalCount(chosen.value) > capacity.value) throw new DispatchInputError('请核对本仓实物资格、来源批次与计划托位')
|
||||
candidatePage.assertCurrent(chosen.value.map(row => row.native!))
|
||||
pending.value = { warehouseId: warehouseId.value, capacity: capacity.value, rows: clone(chosen.value) as ReadyRow[], dispatchKey: crypto.randomUUID(), draft: null, containerId: null, lockedKeys: [], step: null, converted: null, releasing: false }; persist()
|
||||
}
|
||||
saving.value = true
|
||||
const request = pending.value
|
||||
while (!request.converted) {
|
||||
if (!request.step) {
|
||||
if (request.releasing) { step('RELEASE', common(request.draft!)) }
|
||||
else if (!request.draft) step('CREATE', { idempotencyKey: crypto.randomUUID(), warehouseId: request.warehouseId })
|
||||
else if (!request.containerId) step('CONTAINER', { ...common(request.draft), capacity: request.capacity })
|
||||
else {
|
||||
const next = request.rows.find(row => !request.lockedKeys.includes(row.native.sourceKey))
|
||||
if (next) step('LOCK', { ...common(request.draft), draftContainerId: request.containerId, source: next.native.selection })
|
||||
else {
|
||||
const cargo = [...new Set(request.rows.map(row => row.native.sourceKey))].map(key => request.draft!.sources.find(source => source.sourceKey === key))
|
||||
if (cargo.some(source => !source || source.warehouseId !== request.warehouseId || !source.sourceVersion || source.selection.sourceVersion !== source.sourceVersion || request.rows.some(row => row.native.sourceKey === source.sourceKey && row.customerId !== source.customerId))) throw new DispatchInputError('草稿真实来源尚未完整恢复,请保留原请求核对')
|
||||
step('CONVERT', { ...common(request.draft), groups: [{ draftContainerId: request.containerId, dispatch: { publishLooseboardDemand: true, idempotencyKey: request.dispatchKey, warehouseId: request.warehouseId, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: expandSelectionGroups(cargo as Candidate[]), batchAllocations: request.rows.flatMap(row => row.allocations) } }] } satisfies ConvertDraft)
|
||||
}
|
||||
}
|
||||
}
|
||||
await executeStep()
|
||||
if (request.releasing) { pending.value = null; persist(); selected.value = []; await load(); return }
|
||||
}
|
||||
// Immutable CREATE proof is persisted before reading current facts; GET failure never reconverts.
|
||||
const proof = request.converted!.vehicles[0]!.dispatch, latest = await getDispatch(proof.dispatchNo)
|
||||
ensureOwner(); validateCurrentDispatch(latest, proof)
|
||||
result.value = latest; pending.value = null; persist(); selected.value = []; baseline.value = '[]'
|
||||
ElMessage.success(`整车排车单 ${latest.dispatchNo} 已创建`); emit('updated')
|
||||
} catch (cause) { saveError.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '本次整车结果待确认,请核对并重试原请求') }
|
||||
if (!writable.value || error.value || createdNo.value || !chosen.value.length || chosen.value.some(row => reasons(row).length || !row.native) || physicalCount(chosen.value) > capacity.value) throw new DispatchInputError('请核对本仓实物资格、来源批次与计划托位')
|
||||
candidatePage.assertCurrent(chosen.value.map(row => row.native!))
|
||||
const body = { publishLooseboardDemand: true, idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: expandSelectionGroups(chosen.value.map(row => row.native!)), batchAllocations: chosen.value.flatMap(row => row.allocations) }
|
||||
const proof = await createDispatch(body); ensureOwner()
|
||||
validateCommandResult(proof, { dispatchNo: '', action: 'CREATE', body: { idempotencyKey: body.idempotencyKey } })
|
||||
createdNo.value = proof.dispatchNo; selected.value = []; baseline.value = '[]'; emit('updated')
|
||||
const latest = await getDispatch(proof.dispatchNo); ensureOwner(); validateCurrentDispatch(latest, proof)
|
||||
result.value = latest; selected.value = []; baseline.value = '[]'
|
||||
ElMessage.success(`整车排车单 ${latest.dispatchNo} 已创建`)
|
||||
} catch (cause) { saveError.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车创建失败,请刷新核对当前货物后重新操作') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
async function releaseRejected() {
|
||||
if (saving.value || !pending.value?.step?.rejected || pending.value.converted) return
|
||||
saving.value = true; saveError.value = null
|
||||
try {
|
||||
ensureOwner(); const request = pending.value
|
||||
if (!request.draft) { pending.value = null; persist(); return }
|
||||
const owned = (await listDispatchDrafts()).find(row => row.draftToken === request.draft!.draftToken)
|
||||
ensureOwner()
|
||||
if (!owned || owned.warehouseId !== request.warehouseId) throw new DispatchInputError('原拥有者草稿尚未读回,请保留现场核对')
|
||||
request.draft = owned; request.releasing = true; request.step = null; persist()
|
||||
} catch (cause) { saveError.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '原草稿版本读取失败') }
|
||||
finally { saving.value = false }
|
||||
if (pending.value?.releasing) await save()
|
||||
else if (!pending.value) await load()
|
||||
}
|
||||
async function remaining() { if (!unresolved.value && !addingBusy.value) { result.value = null; adding.value = false; arrangingRemaining.value = true; await load() } }
|
||||
async function remaining() { if (!unresolved.value && !addingBusy.value) { createdNo.value = ''; result.value = null; adding.value = false; arrangingRemaining.value = true; await load() } }
|
||||
onMounted(load)
|
||||
onBeforeUnmount(() => { generation++; nativeGeneration++; candidatePage.reset() })
|
||||
</script>
|
||||
@@ -230,12 +144,12 @@ onBeforeUnmount(() => { generation++; nativeGeneration++; candidatePage.reset()
|
||||
<p v-if="!readable">读取批次实物需要预约、尾程订单、入仓单和排车列表权限。</p>
|
||||
<LoadErrorState v-if="error" title="批次可排实物读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="loading">正在核对批次、客户、起运仓与实物来源…</p>
|
||||
<template v-if="existing.length && !loading && !error && !result && !pending">
|
||||
<template v-if="existing.length && !loading && !error && !createdNo">
|
||||
<WorkExistingDispatchForm :key="JSON.stringify(existing)" :dispatches="existing" :can-write="canWrite" @ready="existingReady = $event" @updated="emit('updated')" @external="path => emit('external', path)" />
|
||||
<el-button v-if="!arrangingRemaining && writable" @click="remaining">核对本批剩余可排实物</el-button>
|
||||
</template>
|
||||
<template v-if="result"><p>已创建整车排车单:{{ result.dispatchNo }}</p><el-button @click="emit('external', detailPath(result.dispatchNo))">继续维护本车</el-button><el-button v-if="canWrite && access.can('platform:overseas-warehouse:dispatch:edit') && result.actions.ADD_CARGO?.allowed === true" @click="adding = true">为本车添加同仓货物</el-button><WorkSharedTripForm v-if="adding" :dispatch-no="result.dispatchNo" :can-write="canWrite" @busy="addingBusy = $event" @updated="emit('updated')" @external="path => emit('external', path)" /><el-button :disabled="addingBusy" @click="remaining">继续核对剩余实物</el-button></template>
|
||||
<template v-else-if="!pending && !corrupted && readable && !loading && !error && (!existing.length || arrangingRemaining && existingReady)">
|
||||
<template v-if="createdNo"><p>已创建整车排车单:{{ createdNo }}</p><el-button @click="emit('external', detailPath(createdNo))">继续维护本车</el-button><el-button v-if="canWrite && access.can('platform:overseas-warehouse:dispatch:edit') && result?.actions.ADD_CARGO?.allowed === true" @click="adding = true">为本车添加同仓货物</el-button><WorkSharedTripForm v-if="adding" :dispatch-no="createdNo" :can-write="canWrite" @busy="addingBusy = $event" @updated="emit('updated')" @external="path => emit('external', path)" /><el-button :disabled="addingBusy" @click="remaining">继续核对剩余实物</el-button></template>
|
||||
<template v-else-if="readable && !loading && !error && (!existing.length || arrangingRemaining && existingReady)">
|
||||
<p v-for="reason in [...new Set(contexts.flatMap(context => context.blockReasons))]" :key="reason" role="alert">{{ reason }}</p>
|
||||
<el-button v-if="batchKeys.length === 1" :disabled="!writable || unresolved" @click="emit('batch', batchKeys[0]!)">按当前批次箱数分批</el-button><p>部分箱数先按本批逐来源数量分批;不对应完整实物时,先在原库存作业拆托。</p>
|
||||
<label>本组计划托位<select v-model.number="capacity" :disabled="!writable || unresolved" class="workbench-input"><option v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.capacity">{{ spec.label }}</option></select></label>
|
||||
@@ -244,7 +158,6 @@ onBeforeUnmount(() => { generation++; nativeGeneration++; candidatePage.reset()
|
||||
<p>上方总数为关联计划的候选数;当前页展示其中属于本批次的实物。已选 {{ chosen.length }} 条(含其他页)。</p>
|
||||
<el-button type="primary" :disabled="!writable || !chosen.length" :loading="saving" @click="save">创建整车排车单</el-button>
|
||||
</template>
|
||||
<p v-if="pending" role="status">本次草稿与原请求已保留,请勿改变来源、版本或另建排车单。<el-button :disabled="!canCreate || saving" :loading="saving" @click="save">核对并重试原请求</el-button></p>
|
||||
<LoadErrorState v-if="saveError" title="排车尚未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState><el-button v-if="pending?.step?.rejected && !pending.converted" :loading="saving" @click="releaseRejected">按明确拒绝结果释放本草稿并重新核对</el-button>
|
||||
<LoadErrorState v-if="saveError" title="排车尚未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -24,16 +24,16 @@ it('opens a real plan in the exact customer scope while leaving missing plans un
|
||||
it('does not read the container source without its original permission', async () => {
|
||||
state.canRead = false; const wrapper = render(); await flushPromises(); expect(getContainerPlanProgress).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('submits the actual preview snapshot and replays the same request after an unknown result', async () => {
|
||||
it('submits the actual preview snapshot and permits a fresh click after an unknown result', async () => {
|
||||
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue(preview())
|
||||
vi.mocked(convertLastMileContainerToInboundPlans).mockRejectedValueOnce(new Error('lost response')).mockResolvedValueOnce([])
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '核对本柜入库计划预览').trigger('click'); await flushPromises()
|
||||
await button(wrapper, '确认生成本柜计划').trigger('click'); await flushPromises()
|
||||
const original = vi.mocked(convertLastMileContainerToInboundPlans).mock.calls[0]!
|
||||
expect(original).toEqual([{ sourceContainerBookingId: '9007199254740993', snapshotVersion: 4, snapshotHash: 'snapshot-4' }, expect.any(String)])
|
||||
expect(await guard()).toBe(false)
|
||||
await button(wrapper, '重试本次计划生成').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(convertLastMileContainerToInboundPlans).mock.calls[1]).toEqual(original)
|
||||
expect(await guard()).toBe(true)
|
||||
await button(wrapper, '确认生成本柜计划').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(convertLastMileContainerToInboundPlans).mock.calls[1]![0]).toEqual(original[0]); expect(vi.mocked(convertLastMileContainerToInboundPlans).mock.calls[1]![1]).not.toBe(original[1])
|
||||
expect(wrapper.emitted('updated')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('uses the original resource recovery snapshot and key instead of creating new plans', async () => {
|
||||
|
||||
@@ -35,9 +35,9 @@ async function createPlans() {
|
||||
await convertLastMileContainerToInboundPlans(pending.value.body, pending.value.key)
|
||||
pending.value = null; preview.value = null; ElMessage.success('本柜入库计划已确认,请查看原接仓记录'); emit('updated'); await load()
|
||||
} catch (cause) {
|
||||
saveError.value = getApiErrorDetails(cause, '入库计划生成结果待确认,请重试原请求')
|
||||
saveError.value = getApiErrorDetails(cause, '入库计划生成失败,请重新操作')
|
||||
if (rejectedCommand(cause)) { pending.value = null; preview.value = null }
|
||||
} finally { saving.value = false }
|
||||
} finally { pending.value = null; saving.value = false }
|
||||
}
|
||||
async function load() { if (!readable.value) return; loading.value = true; error.value = null; try { detail.value = await getContainerPlanProgress(props.containerBookingId) } catch (cause) { error.value = getApiErrorDetails(cause, '本柜入仓计划读取失败') } finally { loading.value = false } }
|
||||
onMounted(load)
|
||||
@@ -48,7 +48,7 @@ onMounted(load)
|
||||
<template v-if="detail && !error"><p>{{ detail.summary.containerNo || '柜号待核实' }} · {{ detail.summary.overseasWarehouseName || '接仓仓库待核实' }}</p><p v-if="detail.summary.blockingReason">{{ detail.summary.blockingReason }}</p><table class="density-table"><thead><tr><th>操作</th><th>入库单号</th><th>入仓码 / 订单号</th></tr></thead><tbody><tr v-for="row in detail.orders" :key="`${row.customerId}:${row.orderId}`"><td><el-button :disabled="saving || !!pending || !row.planNo || !row.customerId || !row.planValid || !access.can('platform:overseas-warehouse:inbound-order:list')" @click="emit('external', `/overseas-warehouse/inbound-orders/${encodeURIComponent(row.planNo!)}?customerId=${encodeURIComponent(row.customerId)}`)">查看原接仓记录</el-button></td><td>{{ row.planNo || '尚未生成有效计划' }}</td><td>{{ row.inboundCode || row.orderNo || '—' }}</td></tr></tbody></table><p v-if="!detail.orders.length">本柜尚无可读取的入仓来源。</p></template>
|
||||
<el-button v-if="writable && !preview && !pending" :loading="previewLoading" @click="loadPreview">核对本柜入库计划预览</el-button>
|
||||
<LoadErrorState v-if="previewError" title="入库计划预览失败" :message="previewError.message" :loading="previewLoading" @retry="loadPreview"><template #details><ApiErrorMeta :details="previewError" /></template></LoadErrorState>
|
||||
<div v-if="preview && !previewError"><h4>核对本柜计划与托盘</h4><p>{{ preview.containerNo || '柜号待核实' }} · {{ preview.overseasWarehouseName || '接仓仓库待核实' }} · {{ preview.pendingOrderCount }} 票待生成 · {{ preview.blockedOrderCount }} 票待处理</p><p>头程已有 {{ preview.sourcePalletCount }} 托;海外补充 {{ preview.supplementPalletCount }} 托;已保存计划 {{ preview.totalPlannedPalletCount }} 托。</p><table class="density-table"><thead><tr><th>客户</th><th>原票</th><th>目的地</th><th>箱数</th><th>目标托数</th><th>计划状态</th></tr></thead><tbody><tr v-for="row in preview.groups.flatMap(group => group.orders)" :key="`${row.customerId}:${row.orderId}`"><td>{{ row.customerName || '客户名称待核实' }}</td><td>{{ row.inboundCode || row.businessOrderNo || '—' }}</td><td>{{ row.destinationWarehouseName || row.fbaWarehouseCode || (row.destinationType === 'PRIVATE_ADDRESS' ? '客户地址' : '目的地待核实') }}</td><td>{{ row.packageCount }}</td><td>{{ row.targetPalletCount }}</td><td>{{ row.existingPlanNo || ({ READY: '可生成', RESOURCE_PENDING: '资源待确认', PLANNED: '已有计划', BLOCKED: '来源条件未就绪' }[row.readiness]) }}</td></tr></tbody></table><p v-if="!preview.ready">本柜尚不具备生成条件,请在原尾程订单核对阻塞来源。</p><el-button type="primary" :loading="saving" :disabled="!writable || (!pending && (!preview.ready || (!preview.resourceRetry && preview.pendingOrderCount <= 0)))" @click="createPlans">{{ pending ? '重试本次计划生成' : preview.resourceRetry ? '重试原资源确认' : '确认生成本柜计划' }}</el-button></div>
|
||||
<div v-if="preview && !previewError"><h4>核对本柜计划与托盘</h4><p>{{ preview.containerNo || '柜号待核实' }} · {{ preview.overseasWarehouseName || '接仓仓库待核实' }} · {{ preview.pendingOrderCount }} 票待生成 · {{ preview.blockedOrderCount }} 票待处理</p><p>头程已有 {{ preview.sourcePalletCount }} 托;海外补充 {{ preview.supplementPalletCount }} 托;已保存计划 {{ preview.totalPlannedPalletCount }} 托。</p><table class="density-table"><thead><tr><th>客户</th><th>原票</th><th>目的地</th><th>箱数</th><th>目标托数</th><th>计划状态</th></tr></thead><tbody><tr v-for="row in preview.groups.flatMap(group => group.orders)" :key="`${row.customerId}:${row.orderId}`"><td>{{ row.customerName || '客户名称待核实' }}</td><td>{{ row.inboundCode || row.businessOrderNo || '—' }}</td><td>{{ row.destinationWarehouseName || row.fbaWarehouseCode || (row.destinationType === 'PRIVATE_ADDRESS' ? '客户地址' : '目的地待核实') }}</td><td>{{ row.packageCount }}</td><td>{{ row.targetPalletCount }}</td><td>{{ row.existingPlanNo || ({ READY: '可生成', RESOURCE_PENDING: '资源待确认', PLANNED: '已有计划', BLOCKED: '来源条件未就绪' }[row.readiness]) }}</td></tr></tbody></table><p v-if="!preview.ready">本柜尚不具备生成条件,请在原尾程订单核对阻塞来源。</p><el-button type="primary" :loading="saving" :disabled="!writable || (!pending && (!preview.ready || (!preview.resourceRetry && preview.pendingOrderCount <= 0)))" @click="createPlans">{{ preview.resourceRetry ? '重试原资源确认' : '确认生成本柜计划' }}</el-button></div>
|
||||
<LoadErrorState v-if="saveError" title="入库计划尚未确认" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { ApiResponseError, http } from '@/shared/api/http'
|
||||
import { createDeclarationBatch, submitDeclarationBatch, recordClearanceResult, type DeclarationBatch } from './platformContainerCustoms'
|
||||
import { completeAllBuyContainerCustoms, downloadContainerCustomsReceipt, downloadContainerCustomsDocument, getContainerCustoms, sendContainerCustomsToBroker, startContainerCustoms, uploadContainerCustomsReceipt } from './platformContainerCustoms'
|
||||
import { completeAllBuyContainerCustoms, downloadContainerCustomsReceipt, downloadContainerCustomsDocument, downloadContainerCustomsFinal, getContainerCustoms, sendContainerCustomsToBroker, startContainerCustoms, uploadContainerCustomsReceipt } from './platformContainerCustoms'
|
||||
|
||||
const customs = {
|
||||
aggregateId: 2092438843630391401n, loadingPlanId: 2092438843630391402n, containerBookingId: 2092438843630391403n,
|
||||
@@ -99,7 +99,42 @@ describe('platform container customs API', () => {
|
||||
|
||||
expect(get.mock.calls).toEqual([
|
||||
[`/platform/v1/customs/loading-plans/documents/${documentId}/download`, { responseType: 'blob' }],
|
||||
[`/platform/v1/customs/loading-plans/documents/${documentId}/receipt/download`, { responseType: 'blob' }],
|
||||
[`/platform/v1/customs/loading-plans/documents/${documentId}/completion/download`, { responseType: 'blob' }],
|
||||
])
|
||||
})
|
||||
|
||||
it.each([downloadContainerCustomsDocument, downloadContainerCustomsReceipt, downloadContainerCustomsFinal])('拒绝 HTTP 200 中的真实 JSON Blob 业务错误 %#', async (download) => {
|
||||
const body = new Blob([JSON.stringify({ code: 'A0404', message: '预录单不存在', data: null, requestId: 'request-customs-blob', traceId: 'trace-customs-blob' })], { type: 'application/json;charset=UTF-8' })
|
||||
vi.spyOn(http, 'get').mockResolvedValue(body)
|
||||
|
||||
const error: unknown = await download('2092438843630391407').catch((reason: unknown) => reason)
|
||||
expect(error).toBeInstanceOf(ApiResponseError)
|
||||
expect(error).toMatchObject({ code: 'A0404', message: '预录单不存在', status: 200, requestId: 'request-customs-blob', traceId: 'trace-customs-blob' })
|
||||
})
|
||||
|
||||
it('非 2xx 的 JSON Blob 也保留业务错误和诊断信息', async () => {
|
||||
const body = new Blob([JSON.stringify({ code: 'A0403', message: '无权下载预录单', data: null, requestId: 'request-forbidden', traceId: 'trace-forbidden' })], { type: 'application/json' })
|
||||
vi.spyOn(http, 'get').mockRejectedValue({ isAxiosError: true, response: { status: 403, data: body } })
|
||||
|
||||
await expect(downloadContainerCustomsReceipt('7')).rejects.toMatchObject({ code: 'A0403', message: '无权下载预录单', status: 403, requestId: 'request-forbidden', traceId: 'trace-forbidden' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['application/pdf', '%PDF-1.7 原文件'],
|
||||
['application/octet-stream', '原始二进制'],
|
||||
['application/json', '{"product":"报关资料","code":"ITEM-1"}'],
|
||||
['application/json', '{invalid-json'],
|
||||
])('保留原文件内容与对象:%s', async (type, contents) => {
|
||||
const body = new Blob([contents], { type })
|
||||
vi.spyOn(http, 'get').mockResolvedValue(body)
|
||||
|
||||
await expect(downloadContainerCustomsFinal('7')).resolves.toBe(body)
|
||||
})
|
||||
|
||||
it('非 JSON 的传输错误沿用原始错误', async () => {
|
||||
const error = { isAxiosError: true, response: { status: 502, data: new Blob(['bad gateway'], { type: 'text/html' }) } }
|
||||
vi.spyOn(http, 'get').mockRejectedValue(error)
|
||||
|
||||
await expect(downloadContainerCustomsReceipt('7')).rejects.toBe(error)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import axios from 'axios'
|
||||
import { ApiResponseError, http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export type ContainerCustomsStatus = 'WAITING_CUSTOMER_DOCUMENTS' | 'READY_TO_START' | 'DECLARING' | 'COMPLETED'
|
||||
export type ContainerCustomsTaxMode = 'TAX_REFUND' | 'BUY_DECLARATION'
|
||||
@@ -210,16 +211,45 @@ export async function getContainerCustoms(loadingPlanId: string) {
|
||||
return value ? normalize(value) : null
|
||||
}
|
||||
|
||||
// 报关资料允许下载原始 JSON 文件,仅把统一业务错误信封识别为失败。
|
||||
async function customsDownloadError(blob: Blob, status: number): Promise<ApiResponseError | null> {
|
||||
if (!blob.type.toLowerCase().includes('json')) return null
|
||||
let payload: unknown
|
||||
try { payload = JSON.parse(await blob.text()) } catch { return null }
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null
|
||||
const envelope = payload as Record<string, unknown>
|
||||
if (typeof envelope.code !== 'string' || envelope.code === '200'
|
||||
|| typeof envelope.message !== 'string' || !('data' in envelope)) return null
|
||||
return new ApiResponseError(envelope.message, envelope.code, status, envelope.data,
|
||||
typeof envelope.requestId === 'string' ? envelope.requestId : undefined,
|
||||
typeof envelope.traceId === 'string' ? envelope.traceId : undefined)
|
||||
}
|
||||
|
||||
async function downloadCustomsFile(path: string) {
|
||||
try {
|
||||
const blob = await http.get<Blob, Blob>(path, { responseType: 'blob' })
|
||||
const error = await customsDownloadError(blob, 200)
|
||||
if (error) throw error
|
||||
return blob
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.data instanceof Blob) {
|
||||
const downloadError = await customsDownloadError(error.response.data, error.response.status)
|
||||
if (downloadError) throw downloadError
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function downloadContainerCustomsDocument(documentId: string) {
|
||||
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/download`, { responseType: 'blob' })
|
||||
return downloadCustomsFile(`${basePath}/documents/${encodeURIComponent(documentId)}/download`)
|
||||
}
|
||||
|
||||
export function downloadContainerCustomsReceipt(documentId: string) {
|
||||
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/receipt/download`, { responseType: 'blob' })
|
||||
return downloadCustomsFile(`${basePath}/documents/${encodeURIComponent(documentId)}/completion/download`)
|
||||
}
|
||||
|
||||
export function downloadContainerCustomsFinal(documentId: string) {
|
||||
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/final/download`, { responseType: 'blob' })
|
||||
return downloadCustomsFile(`${basePath}/documents/${encodeURIComponent(documentId)}/final/download`)
|
||||
}
|
||||
|
||||
async function action(loadingPlanId: string, name: 'start' | 'send-to-broker' | 'submit-to-broker' | 'complete-all-buy', expectedVersion: number, remark?: string) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { ApiResponseError, http } from '@/shared/api/http'
|
||||
import type { ContainerCustomsStatus, ContainerCustomsView, DeclarationBatch, ClearanceResultType } from '../api/platformContainerCustoms'
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
@@ -94,6 +94,8 @@ describe('ContainerCustomsPanel', () => {
|
||||
global: { plugins: [ElementPlus] },
|
||||
})
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('ENTRY-001')
|
||||
expect(wrapper.text()).not.toContain('版本')
|
||||
await wrapper.findAll('button').find(button => button.text() === '标记海关异常/结果')!.trigger('click')
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: {
|
||||
clearanceBatch: DeclarationBatch; clearanceResultType: ClearanceResultType; clearanceRemark: string; clearanceDialogVisible: boolean
|
||||
@@ -167,11 +169,11 @@ describe('ContainerCustomsPanel', () => {
|
||||
expect(wrapper.find('[aria-label="报关概况"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('客户退税资料.pdf')
|
||||
expect(wrapper.text()).toContain('客户退税资料')
|
||||
expect(wrapper.text()).toContain('报关行回执')
|
||||
expect(wrapper.text()).toContain('预录单')
|
||||
expect(wrapper.text()).not.toContain('客户报关资料')
|
||||
expect(wrapper.find('[aria-label="下载客户退税资料 客户退税资料.pdf"]').exists()).toBe(true)
|
||||
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
|
||||
expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执')
|
||||
expect(buttonTexts(wrapper)).not.toContain('上传预录单')
|
||||
expect(buttonTexts(wrapper)).not.toContain('覆盖预录单')
|
||||
expect(buttonTexts(wrapper)).not.toContain('提交')
|
||||
})
|
||||
|
||||
@@ -204,13 +206,13 @@ describe('ContainerCustomsPanel', () => {
|
||||
it('allows completion upload and overwrite only while DECLARING with manage permission', async () => {
|
||||
let wrapper = await render('DECLARING', true, false)
|
||||
expect(wrapper.text()).toContain('首次发送报关行')
|
||||
expect(wrapper.text()).toContain('首次发送后上传回执')
|
||||
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
|
||||
expect(wrapper.text()).toContain('首次发送后上传预录单')
|
||||
expect(buttonTexts(wrapper)).not.toContain('上传预录单')
|
||||
wrapper.unmount()
|
||||
|
||||
wrapper = await render('DECLARING', true, true)
|
||||
expect(wrapper.text()).toContain('发送报关行执行')
|
||||
expect(wrapper.text()).toContain('覆盖报关行回执')
|
||||
expect(wrapper.text()).toContain('覆盖预录单')
|
||||
expect(wrapper.text()).toContain('报关行回执.pdf')
|
||||
expect(wrapper.text()).toContain('真实报关完税文件.pdf')
|
||||
expect(wrapper.text()).toContain('确认 · 2026/8/29 11:15:00 · 客户一')
|
||||
@@ -218,8 +220,8 @@ describe('ContainerCustomsPanel', () => {
|
||||
|
||||
wrapper = await render('DECLARING', false, true)
|
||||
expect(wrapper.text()).toContain('只读')
|
||||
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
|
||||
expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执')
|
||||
expect(buttonTexts(wrapper)).not.toContain('上传预录单')
|
||||
expect(buttonTexts(wrapper)).not.toContain('覆盖预录单')
|
||||
expect(buttonTexts(wrapper)).not.toContain('提交')
|
||||
})
|
||||
|
||||
@@ -235,14 +237,40 @@ describe('ContainerCustomsPanel', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('隐藏资料版本但保留预录单历史、原文件名和上传并发版本', async () => {
|
||||
const value = customs('DECLARING', true)
|
||||
const document = value.documents[0]!
|
||||
document.receipt!.version = 7
|
||||
document.receiptHistory = [{ version: 6, file: { ...document.receipt!, version: 6 }, operatorName: '报关员', createdAt: '2026-08-29T10:50:00' }]
|
||||
api.get.mockResolvedValue(value)
|
||||
const wrapper = mount(ContainerCustomsPanel, {
|
||||
props: { loadingPlanId: value.loadingPlanId, canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' },
|
||||
global: { plugins: [ElementPlus] },
|
||||
})
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('th').map(header => header.text())).toEqual(['操作', '客户退税资料', '预录单', '真实报关完税文件', '关联入仓码', '报关行状态', '客户', '预录单情况'])
|
||||
expect(wrapper.text()).not.toMatch(/版本|退税资料 v|回执 v|完税 v/)
|
||||
expect(wrapper.text()).toContain('预录单历史:2026/8/29 10:50:00 · 报关员')
|
||||
expect(wrapper.text()).toContain('确认 · 2026/8/29 11:15:00 · 客户一')
|
||||
expect(wrapper.find('[aria-label="下载预录单 报关行回执.pdf"]').exists()).toBe(true)
|
||||
const selected = new File(['receipt'], '新预录单.pdf', { type: 'application/pdf' })
|
||||
const onChange = wrapper.findAllComponents({ name: 'ElUpload' })[0]!.props('onChange') as (upload: { raw: File }) => void
|
||||
onChange({ raw: selected })
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '提交')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.upload).toHaveBeenCalledWith(document.documentId, selected, 7)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps COMPLETED strictly read-only with both files visible and downloadable', async () => {
|
||||
const wrapper = await render('COMPLETED', true, true)
|
||||
expect(wrapper.text()).toContain('客户退税资料.pdf')
|
||||
expect(wrapper.text()).toContain('报关行回执.pdf')
|
||||
expect(wrapper.text()).toContain('真实报关完税文件.pdf')
|
||||
expect(wrapper.text()).toContain('报关已完成,待其他离仓条件')
|
||||
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
|
||||
expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执')
|
||||
expect(buttonTexts(wrapper)).not.toContain('上传预录单')
|
||||
expect(buttonTexts(wrapper)).not.toContain('覆盖预录单')
|
||||
expect(buttonTexts(wrapper)).not.toContain('提交')
|
||||
})
|
||||
|
||||
@@ -253,7 +281,7 @@ describe('ContainerCustomsPanel', () => {
|
||||
expect(api.downloadSource).toHaveBeenCalledWith('2092438843630391407')
|
||||
expect(api.downloadReceipt).not.toHaveBeenCalled()
|
||||
|
||||
await wrapper.get('[aria-label="下载报关行回执 报关行回执.pdf"]').trigger('click')
|
||||
await wrapper.get('[aria-label="下载预录单 报关行回执.pdf"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.downloadReceipt).toHaveBeenCalledWith('2092438843630391407')
|
||||
await wrapper.get('[aria-label="下载报关完税文件 真实报关完税文件.pdf"]').trigger('click')
|
||||
@@ -277,4 +305,46 @@ describe('ContainerCustomsPanel', () => {
|
||||
expect(wrapper.text()).toContain('trace-customs-download')
|
||||
expect(api.downloadReceipt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('下载预录单失败时透传正式名称与诊断信息,保留原文件名', async () => {
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValueOnce(new Blob([JSON.stringify({ code: 'A0404', message: '预录单不存在', data: null, requestId: 'request-pre-entry', traceId: 'trace-pre-entry' })], { type: 'application/json' }))
|
||||
api.downloadReceipt.mockImplementationOnce(async (documentId: string) => {
|
||||
const actual = await vi.importActual<typeof import('../api/platformContainerCustoms')>('../api/platformContainerCustoms')
|
||||
return actual.downloadContainerCustomsReceipt(documentId)
|
||||
})
|
||||
const wrapper = await render('COMPLETED', false, true)
|
||||
|
||||
await wrapper.get('[aria-label="下载预录单 报关行回执.pdf"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
await vi.waitFor(() => expect(wrapper.text()).toContain('预录单不存在'))
|
||||
expect(wrapper.text()).toContain('request-pre-entry')
|
||||
expect(wrapper.text()).toContain('trace-pre-entry')
|
||||
expect(wrapper.text()).toContain('报关行回执.pdf')
|
||||
expect(wrapper.text()).not.toContain('报关行回执不存在')
|
||||
expect(HTMLAnchorElement.prototype.click).not.toHaveBeenCalled()
|
||||
expect(get).toHaveBeenCalledWith('/platform/v1/customs/loading-plans/documents/2092438843630391407/completion/download', { responseType: 'blob' })
|
||||
get.mockRestore()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('上传冲突时展示服务端预录单提示并保留待上传文件', async () => {
|
||||
const message = '客户确认的预录单已发送给报关行,不可再修改预录单'
|
||||
api.upload.mockRejectedValueOnce(new ApiResponseError(message, 'A0409', 200, undefined, 'request-pre-entry-upload', 'trace-pre-entry-upload'))
|
||||
const wrapper = await render('DECLARING', true, true)
|
||||
const selected = new File(['receipt'], '预录单.pdf', { type: 'application/pdf' })
|
||||
const onChange = wrapper.findAllComponents({ name: 'ElUpload' })[0]!.props('onChange') as (upload: { raw: File }) => void
|
||||
onChange({ raw: selected })
|
||||
await flushPromises()
|
||||
const submit = wrapper.findAll('button').find(button => button.text() === '提交')!
|
||||
await submit.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain(message)
|
||||
expect(wrapper.text()).toContain('A0409')
|
||||
expect(wrapper.text()).toContain('request-pre-entry-upload')
|
||||
expect(wrapper.text()).toContain('trace-pre-entry-upload')
|
||||
expect(submit.attributes('disabled')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -79,9 +79,6 @@ const statusText: Record<ContainerCustomsStatus, string> = {
|
||||
function documentCustomers(document: ContainerCustomsDocument) {
|
||||
return [...new Set((customs.value?.inbounds || []).filter((item) => document.inboundIds.includes(item.inboundId)).map((item) => item.customerName))].join('、') || '—'
|
||||
}
|
||||
function receiptHistoryLabel(document: ContainerCustomsDocument) {
|
||||
return (document.receiptHistory || []).map((item) => `v${item.version}`).join('、') || '—'
|
||||
}
|
||||
function confirmationActionLabel(action: ContainerCustomsDocument['confirmationHistory'][number]['action']) {
|
||||
return action === 'CONFIRM' ? '确认' : '打回'
|
||||
}
|
||||
@@ -126,7 +123,7 @@ function declarationStatusLabel(value: DeclarationBatch['status']) {
|
||||
|
||||
async function createFormalDeclaration() {
|
||||
if (!customs.value || !['DECLARING', 'COMPLETED'].includes(customs.value.status) || !brokerSubmitted.value || openDeclarationBatch.value) return
|
||||
try { await ElMessageBox.confirm('当前柜的资料、回执已确认并已发送报关行执行,确认创建正式申报批次?完税文件仍按报关行完成后回传。', customsDialogTitle('创建正式申报批次'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认创建' }) } catch { return }
|
||||
try { await ElMessageBox.confirm('当前柜的资料、预录单已确认并已发送报关行执行,确认创建正式申报批次?完税文件仍按报关行完成后回传。', customsDialogTitle('创建正式申报批次'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认创建' }) } catch { return }
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try { await createDeclarationBatch(props.loadingPlanId, customs.value.version); await load(); ElMessage.success('正式申报批次已创建') }
|
||||
catch (error) { actionError.value = getApiErrorDetails(error, '正式申报批次创建失败') }
|
||||
@@ -194,15 +191,15 @@ async function start() {
|
||||
async function uploadReceipt(document: ContainerCustomsDocument) {
|
||||
if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value) return
|
||||
const selected = receiptFiles[document.documentId]
|
||||
if (!selected) { ElMessage.warning('请选择对应的报关行回执'); return }
|
||||
if (!selected) { ElMessage.warning('请选择对应的预录单'); return }
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try {
|
||||
await uploadContainerCustomsReceipt(document.documentId, selected, document.receipt?.version || 0)
|
||||
receiptFiles[document.documentId] = null
|
||||
await load()
|
||||
emit('refreshed')
|
||||
ElMessage.success('报关行回执已上传,等待客户确认')
|
||||
} catch (error) { actionError.value = getApiErrorDetails(error, '报关行回执上传失败') }
|
||||
ElMessage.success('预录单已上传,等待客户确认')
|
||||
} catch (error) { actionError.value = getApiErrorDetails(error, '预录单上传失败') }
|
||||
finally { actionLoading.value = false }
|
||||
}
|
||||
|
||||
@@ -239,9 +236,9 @@ async function downloadFile(document: ContainerCustomsDocument, kind: 'source' |
|
||||
? await downloadContainerCustomsDocument(document.documentId)
|
||||
: kind === 'receipt' ? await downloadContainerCustomsReceipt(document.documentId) : await downloadContainerCustomsFinal(document.documentId)
|
||||
saveBlob(blob, target.fileName)
|
||||
ElMessage.success(`${kind === 'source' ? '客户退税资料' : kind === 'receipt' ? '报关行回执' : '报关完税文件'}下载已开始`)
|
||||
ElMessage.success(`${kind === 'source' ? '客户退税资料' : kind === 'receipt' ? '预录单' : '报关完税文件'}下载已开始`)
|
||||
} catch (error) {
|
||||
actionError.value = getApiErrorDetails(error, `${kind === 'source' ? '客户退税资料' : '报关完成文件'}下载失败`)
|
||||
actionError.value = getApiErrorDetails(error, `${kind === 'source' ? '客户退税资料' : kind === 'receipt' ? '预录单' : '报关完税文件'}下载失败`)
|
||||
} finally {
|
||||
downloadingKey.value = ''
|
||||
}
|
||||
@@ -249,7 +246,7 @@ async function downloadFile(document: ContainerCustomsDocument, kind: 'source' |
|
||||
|
||||
async function submitToBroker() {
|
||||
if (!customs.value || customs.value.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value || !allReceiptsConfirmed.value) return
|
||||
try { await ElMessageBox.confirm('确认已核对全部回执并把确认结果发送给报关行执行?', customsDialogTitle('发送报关行执行'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
|
||||
try { await ElMessageBox.confirm('确认已核对全部预录单并把确认结果发送给报关行执行?', customsDialogTitle('发送报关行执行'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try { customs.value = await submitContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已发送给报关行执行') }
|
||||
catch (error) { actionError.value = getApiErrorDetails(error, '发送报关行执行失败') }
|
||||
@@ -258,9 +255,9 @@ async function submitToBroker() {
|
||||
|
||||
async function sendToBroker() {
|
||||
if (!customs.value || customs.value.status !== 'DECLARING' || brokerSubmission.value || brokerSubmitted.value) return
|
||||
try { await ElMessageBox.confirm('确认将当前冻结的退税报关资料首次发送给报关行?发送后由报关行返回回执,资料版本和关联范围按当前快照处理。', customsDialogTitle('首次发送报关行'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
|
||||
try { await ElMessageBox.confirm('确认将当前冻结的退税报关资料首次发送给报关行?发送后由报关行返回预录单,关联范围按当前快照处理。', customsDialogTitle('首次发送报关行'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try { customs.value = await sendContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已首次发送给报关行,等待回执') }
|
||||
try { customs.value = await sendContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已首次发送给报关行,等待预录单') }
|
||||
catch (error) { actionError.value = getApiErrorDetails(error, '首次发送报关行失败') }
|
||||
finally { actionLoading.value = false }
|
||||
}
|
||||
@@ -315,7 +312,7 @@ onMounted(load)
|
||||
<el-empty v-if="!customs.hasActualCargo" description="当前装柜计划没有实际装柜货物,无需报关" :image-size="56" />
|
||||
<template v-else>
|
||||
<dl v-if="brokerSubmission" class="density-fields customs-summary" aria-label="报关概况">
|
||||
<div class="submission-batch"><dt>首次发送批次</dt><dd>{{ brokerSubmission.batchNo }}</dd></div><div><dt>发送状态</dt><dd>{{ brokerSubmission.status === 'SENT' ? '已发送' : brokerSubmission.status }}</dd></div><div><dt>资料版本</dt><dd>v{{ brokerSubmission.sourceVersion }}</dd></div>
|
||||
<div class="submission-batch"><dt>首次发送批次</dt><dd>{{ brokerSubmission.batchNo }}</dd></div><div><dt>发送状态</dt><dd>{{ brokerSubmission.status === 'SENT' ? '已发送' : brokerSubmission.status }}</dd></div>
|
||||
<div class="submission-batch"><dt>发送人</dt><dd>{{ brokerSubmission.sentByName || '—' }}</dd></div><div class="submission-batch"><dt>发送时间</dt><dd>{{ formatDateTime(brokerSubmission.sentAt) }}</dd></div>
|
||||
</dl>
|
||||
<div v-if="missingInbounds.length" class="compact-table-wrap">
|
||||
@@ -323,14 +320,14 @@ onMounted(load)
|
||||
<div class="workbench-evidence"><table class="compact-table density-table"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th></tr></thead><tbody><tr v-for="item in missingInbounds" :key="item.inboundId"><td>{{ item.customerName }}</td><td>{{ item.orderNo }}</td><td>{{ item.inboundCode }}</td></tr></tbody></table></div>
|
||||
</div>
|
||||
<div v-if="customs.documents.length" class="compact-table-wrap">
|
||||
<h3>已冻结退税资料、报关行回执与完税文件</h3>
|
||||
<div class="workbench-evidence"><table class="compact-table document-table density-table"><thead><tr><th>操作</th><th>客户退税资料</th><th>报关行回执</th><th>真实报关完税文件</th><th>关联入仓码</th><th>报关行状态</th><th>版本</th><th>客户</th><th>回执情况</th></tr></thead><tbody>
|
||||
<h3>已冻结退税资料、预录单与完税文件</h3>
|
||||
<div class="workbench-evidence"><table class="compact-table document-table density-table"><thead><tr><th>操作</th><th>客户退税资料</th><th>预录单</th><th>真实报关完税文件</th><th>关联入仓码</th><th>报关行状态</th><th>客户</th><th>预录单情况</th></tr></thead><tbody>
|
||||
<tr v-for="document in customs.documents" :key="document.documentId">
|
||||
<td><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions density-actions"><el-upload :disabled="!brokerSubmission || brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectReceipt(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmission || brokerSubmitted">{{ !brokerSubmission ? '首次发送后上传回执' : document.receipt ? '覆盖报关行回执' : '上传报关行回执' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmission || brokerSubmitted || !receiptFiles[document.documentId]" :loading="actionLoading" @click="uploadReceipt(document)">提交</el-button><el-upload :disabled="!brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectFinal(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmitted">{{ document.finalDocument ? '覆盖完税文件' : '上传完税文件' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmitted || !finalFiles[document.documentId]" :loading="actionLoading" @click="uploadFinal(document)">提交</el-button></div><span v-else class="readonly-label">只读</span></td>
|
||||
<td><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions density-actions"><el-upload :disabled="!brokerSubmission || brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectReceipt(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmission || brokerSubmitted">{{ !brokerSubmission ? '首次发送后上传预录单' : document.receipt ? '覆盖预录单' : '上传预录单' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmission || brokerSubmitted || !receiptFiles[document.documentId]" :loading="actionLoading" @click="uploadReceipt(document)">提交</el-button><el-upload :disabled="!brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectFinal(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmitted">{{ document.finalDocument ? '覆盖完税文件' : '上传完税文件' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmitted || !finalFiles[document.documentId]" :loading="actionLoading" @click="uploadFinal(document)">提交</el-button></div><span v-else class="readonly-label">只读</span></td>
|
||||
<td><div class="file-cell"><span :title="document.source.fileName">{{ document.source.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `source:${document.documentId}`" :aria-label="`下载客户退税资料 ${document.source.fileName}`" @click="downloadFile(document, 'source')">下载</el-button></div></td>
|
||||
<td><div v-if="document.receipt" class="file-cell"><span :title="document.receipt.fileName">{{ document.receipt.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `receipt:${document.documentId}`" :aria-label="`下载报关行回执 ${document.receipt.fileName}`" @click="downloadFile(document, 'receipt')">下载</el-button></div><span v-else>未上传</span></td>
|
||||
<td><div v-if="document.receipt" class="file-cell"><span :title="document.receipt.fileName">{{ document.receipt.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `receipt:${document.documentId}`" :aria-label="`下载预录单 ${document.receipt.fileName}`" @click="downloadFile(document, 'receipt')">下载</el-button></div><span v-else>未上传</span></td>
|
||||
<td><div v-if="document.finalDocument" class="file-cell"><span :title="document.finalDocument.fileName">{{ document.finalDocument.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `final:${document.documentId}`" :aria-label="`下载报关完税文件 ${document.finalDocument.fileName}`" @click="downloadFile(document, 'final')">下载</el-button></div><span v-else>未上传</span></td>
|
||||
<td>{{ document.inboundCodes.join('、') || '—' }}</td><td>{{ brokerSubmitted ? '已发送报关行执行' : brokerSubmission ? '已首次发送,等待回执确认' : '待首次发送报关行' }}</td><td>退税资料 v{{ document.source.version }}<span v-if="document.receipt"> / 回执 v{{ document.receipt.version }}</span><span v-if="document.finalDocument"> / 完税 v{{ document.finalDocument.version }}</span><small v-if="document.receiptHistory?.length" class="receipt-history-note">回执历史:{{ receiptHistoryLabel(document) }}</small><small v-for="history in document.confirmationHistory" :key="history.id" class="confirmation-history-note">{{ confirmationActionLabel(history.action) }} · {{ formatDateTime(history.createdAt) }} · {{ history.operatorName || '—' }}<span v-if="history.reason"> · {{ history.reason }}</span></small></td><td>{{ documentCustomers(document) }}</td><td>{{ document.receipt ? (document.receiptStatus === 'CONFIRMED' ? '客户已确认回执' : document.receiptStatus === 'REJECTED' ? '客户已打回回执' : '待客户确认回执') : '待上传报关行回执' }}</td>
|
||||
<td>{{ document.inboundCodes.join('、') || '—' }}</td><td>{{ brokerSubmitted ? '已发送报关行执行' : brokerSubmission ? '已首次发送,等待预录单确认' : '待首次发送报关行' }}</td><td>{{ documentCustomers(document) }}</td><td><small v-for="history in document.confirmationHistory" :key="history.id" class="confirmation-history-note">{{ confirmationActionLabel(history.action) }} · {{ formatDateTime(history.createdAt) }} · {{ history.operatorName || '—' }}<span v-if="history.reason"> · {{ history.reason }}</span></small><small v-for="history in document.receiptHistory" :key="history.version" class="receipt-history-note">预录单历史:{{ formatDateTime(history.createdAt) }} · {{ history.operatorName || '—' }}</small>{{ document.receipt ? (document.receiptStatus === 'CONFIRMED' ? '客户已确认预录单' : document.receiptStatus === 'REJECTED' ? '客户已打回预录单' : '待客户确认预录单') : '待上传预录单' }}</td>
|
||||
</tr>
|
||||
</tbody></table></div>
|
||||
</div>
|
||||
@@ -339,7 +336,7 @@ onMounted(load)
|
||||
<el-empty v-if="!declarationBatches.length" description="尚未创建正式申报批次" :image-size="48" />
|
||||
<div v-else class="declaration-batch-list">
|
||||
<article v-for="batch in declarationBatches" :key="batch.id" class="declaration-batch-card">
|
||||
<div class="declaration-batch-main"><strong>{{ batch.batchNo }}</strong><span>报关单号:{{ batch.customsEntryNo || '—' }} · 版本 {{ batch.version }}</span></div>
|
||||
<div class="declaration-batch-main"><strong>{{ batch.batchNo }}</strong><span>报关单号:{{ batch.customsEntryNo || '—' }}</span></div>
|
||||
<el-tag size="small" :type="batch.status === 'CLOSED' ? 'success' : batch.status === 'CANCELLED' ? 'info' : 'warning'">{{ declarationStatusLabel(batch.status) }}</el-tag>
|
||||
<div class="declaration-batch-actions" v-if="canManage"><el-button v-if="batch.status === 'READY'" size="small" type="primary" :loading="actionLoading" @click="submitFormalDeclaration(batch)">提交正式申报</el-button><el-button v-if="['SUBMITTED','ACKNOWLEDGED','RESULT_PENDING'].includes(batch.status)" size="small" plain :loading="actionLoading" @click="openFormalResultDialog(batch)">标记海关异常/结果</el-button></div>
|
||||
<div v-if="batch.results.length" class="declaration-result-list"><div v-for="result in batch.results" :key="result.id" class="declaration-result-row"><span>{{ clearanceResultLabel(result.resultType) }}</span><el-tag size="small" :type="result.resultStatus === 'RESOLVED' ? 'success' : 'warning'">{{ result.resultStatus === 'RESOLVED' ? '已处理' : result.resultStatus === 'OPEN' ? '待处理' : '已取消' }}</el-tag><small>{{ result.resolution || '—' }}</small><el-button v-if="canManage && result.resultStatus === 'OPEN'" link type="primary" size="small" :loading="actionLoading" @click="resolveFormalResult(batch, result)">处理</el-button></div></div>
|
||||
|
||||
@@ -47,6 +47,9 @@ describe('OceanBookingLinkDialog', () => {
|
||||
|
||||
it.each([
|
||||
[' mscu1234567 ', 'MSCU1234567'],
|
||||
['local-1', 'LOCAL-1'],
|
||||
['MSCU6639871', 'MSCU6639871'],
|
||||
['ABCU123456789012', 'ABCU123456789012'],
|
||||
['', null],
|
||||
])('normalizes and submits the optional real container number %#', async (input, expected) => {
|
||||
const wrapper = mount(OceanBookingLinkDialog, { props: { modelValue: true, container }, global: { plugins: [ElementPlus] } })
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('MeasurementReviewPanel', () => {
|
||||
expect(wrapper.text()).not.toContain('通过并进入')
|
||||
await wrapper.findAll('button').find((button) => button.text() === '通过')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(confirmation).toHaveBeenCalledWith(expect.stringContaining('进入待一件代发组托'), '确认测量审核通过', expect.objectContaining({ confirmButtonText: '通过' }))
|
||||
expect(confirmation).toHaveBeenCalledWith('审核通过后,入库单将进入待分柜', '确认测量', expect.objectContaining({ confirmButtonText: '通过' }))
|
||||
expect(initialPricingSucceeded ? success : warning).toHaveBeenCalledWith(expect.stringContaining('入库单已进入待一件代发组托'))
|
||||
expect(approveMeasurement).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.emitted('changed')).toHaveLength(1)
|
||||
|
||||
@@ -140,8 +140,8 @@ async function confirmApprove() {
|
||||
if (!detail.value || !isPending.value || !canReview.value || actionLoading.value || props.postApproveLoading || props.approvalCompleted) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`审核通过后,入库单将进入${approvalTargetLabel.value}。系统会尝试生成初算费用,但初算失败不影响审核通过,后续可由平台人员补充调整。是否继续?`,
|
||||
'确认测量审核通过',
|
||||
'审核通过后,入库单将进入待分柜',
|
||||
'确认测量',
|
||||
{ customClass: 'admin-density-confirm', confirmButtonText: approvalButtonLabel.value, cancelButtonText: '返回', type: 'warning' },
|
||||
)
|
||||
} catch {
|
||||
@@ -285,6 +285,8 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
|
||||
</section>
|
||||
|
||||
<WeightOcrPanel
|
||||
class="density-dialog-section"
|
||||
data-tone="source"
|
||||
review-mode
|
||||
table-mode
|
||||
:correction-mode="correctionMode"
|
||||
@@ -310,7 +312,7 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.review-panel { display: grid; gap: var(--admin-space-5, 12px); color: var(--workbench-text); }
|
||||
.review-panel { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--admin-space-5, 12px); color: var(--workbench-text); }
|
||||
.review-toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5, 12px); position: sticky; top: 0; z-index: 3; flex-wrap: wrap; padding: var(--admin-space-3, 8px) 0; border-bottom: 1px solid var(--workbench-border); background: var(--admin-surface, #fff); }
|
||||
.review-toolbar__status, .review-toolbar__actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3, 8px); }
|
||||
.review-toolbar__status > span { color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
|
||||
|
||||
@@ -100,6 +100,17 @@ const workspace: WeightWorkspaceResource = {
|
||||
},
|
||||
}
|
||||
|
||||
const confirmedReviewWorkspace: WeightWorkspaceResource = {
|
||||
...workspace,
|
||||
processStatus: 'MEASURE_PENDING',
|
||||
actualGrossWeightKg: 120,
|
||||
actualNetWeightKg: 108,
|
||||
weightResults: [{
|
||||
...pendingWeight, recognitionStatus: 'CONFIRMED', adjustedWeightValue: 120,
|
||||
adjustedWeightUnit: 'KG', grossWeightKg: 120, netWeightKg: 108, version: 1,
|
||||
}],
|
||||
}
|
||||
|
||||
function palletPage(records = [{
|
||||
palletId: '50001', palletCode: 'TP-001', palletType: '木托盘', capacityCbm: 1.2, tareWeightKg: 12,
|
||||
}]) {
|
||||
@@ -496,7 +507,7 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
expect(wrapper.text()).toContain('1747.500 KG')
|
||||
})
|
||||
|
||||
it('opens a table image and atomically corrects and confirms an approved weight during measurement review', async () => {
|
||||
it('opens the explicit table correction action and atomically confirms an approved weight during measurement review', async () => {
|
||||
const confirmed = {
|
||||
...pendingWeight,
|
||||
recognitionStatus: 'CONFIRMED',
|
||||
@@ -521,7 +532,10 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
expect(wrapper.text()).not.toContain('删除图片')
|
||||
expect(wrapper.find('.weight-data-table').exists()).toBe(true)
|
||||
expect(wrapper.find('.weight-grid').exists()).toBe(false)
|
||||
await openWeightDialog(wrapper)
|
||||
expect(wrapper.get('.weight-data-table thead th').text()).toBe('操作')
|
||||
expect(wrapper.find('.pallet-picker').exists()).toBe(false)
|
||||
await wrapper.get('button[aria-label="修改重量 1"]').trigger('click')
|
||||
await flushPromises()
|
||||
await dialogWeightInput().setValue('125')
|
||||
const reason = document.body.querySelector<HTMLTextAreaElement>('textarea[placeholder="请填写本次毛重修正原因"]')
|
||||
expect(reason).not.toBeNull()
|
||||
@@ -538,6 +552,57 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
expect(correctWeightResult).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ permissions: allWeightPermissions.filter(permission => !permission.endsWith('weight:edit')), status: 'MEASURE_PENDING', correctionMode: true },
|
||||
{ permissions: allWeightPermissions.filter(permission => !permission.endsWith('weight:confirm')), status: 'MEASURE_PENDING', correctionMode: true },
|
||||
{ permissions: allWeightPermissions, status: 'WAIT_SPLIT', correctionMode: true },
|
||||
{ permissions: allWeightPermissions, status: 'MEASURE_PENDING', correctionMode: false },
|
||||
])('keeps table correction unavailable for missing permissions or inactive review: %j', async ({ permissions, status, correctionMode }) => {
|
||||
vi.mocked(getWeightWorkspace).mockResolvedValue({ ...confirmedReviewWorkspace, processStatus: status })
|
||||
const wrapper = mountPanel(permissions, { reviewMode: true, correctionMode, tableMode: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('button[aria-label="修改重量 1"]').exists()).toBe(false)
|
||||
await openWeightDialog(wrapper)
|
||||
expect(dialogWeightInput().attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.getComponent(AppDialog).props('showConfirm')).toBe(false)
|
||||
expect(confirmWeightResult).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows cancelling and reopening correction, and preserves the draft for retry after a save fails', async () => {
|
||||
vi.mocked(getWeightWorkspace).mockResolvedValue(confirmedReviewWorkspace)
|
||||
vi.mocked(confirmWeightResult)
|
||||
.mockRejectedValueOnce(new Error('重量写入失败'))
|
||||
.mockResolvedValueOnce({ ...confirmedReviewWorkspace.weightResults[0], grossWeightKg: 125, netWeightKg: 113, version: 2 })
|
||||
const wrapper = mountPanel(allWeightPermissions, { reviewMode: true, correctionMode: true, tableMode: true })
|
||||
await flushPromises()
|
||||
const correctionButton = wrapper.get('button[aria-label="修改重量 1"]')
|
||||
await correctionButton.trigger('click')
|
||||
await flushPromises()
|
||||
await dialogWeightInput().setValue('125')
|
||||
await new DOMWrapper(document.body.querySelector<HTMLTextAreaElement>('#weight-correction-reason')!).setValue('复核磅秤读数')
|
||||
const cancelButton = [...document.body.querySelectorAll<HTMLButtonElement>('.el-dialog__footer button')].find(button => button.textContent?.trim() === '取消')!
|
||||
await new DOMWrapper(cancelButton).trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.getComponent(AppDialog).props('modelValue')).toBe(false)
|
||||
expect(confirmWeightResult).not.toHaveBeenCalled()
|
||||
|
||||
await correctionButton.trigger('click')
|
||||
await flushPromises()
|
||||
expect(Number(dialogWeightInput().element.value)).toBe(125)
|
||||
await dialogConfirmButton('保存重量修正').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.getComponent(AppDialog).props('modelValue')).toBe(true)
|
||||
expect(Number(dialogWeightInput().element.value)).toBe(125)
|
||||
expect(dialogConfirmButton('保存重量修正').attributes('disabled')).toBeUndefined()
|
||||
expect(wrapper.emitted('changed')).toBeUndefined()
|
||||
await dialogConfirmButton('保存重量修正').trigger('click')
|
||||
await flushPromises()
|
||||
expect(confirmWeightResult).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.getComponent(AppDialog).props('modelValue')).toBe(false)
|
||||
expect(wrapper.emitted('changed')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('closes the confirmation dialog immediately after a successful confirmation', async () => {
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
|
||||
@@ -504,9 +504,8 @@ defineExpose({ reload: loadWorkspace })
|
||||
<div class="weight-summary"><span>实测毛重</span><strong>{{ formatWeight(workspace.actualGrossWeightKg) }}</strong></div>
|
||||
<div class="weight-summary"><span>货物重量</span><strong>{{ formatWeight(workspace.actualNetWeightKg) }}</strong></div>
|
||||
</div>
|
||||
<div class="pallet-picker">
|
||||
<div v-if="canEdit && !reviewMode" class="pallet-picker">
|
||||
<el-select-v2
|
||||
v-if="canEdit && !reviewMode"
|
||||
v-model="selectedPalletId"
|
||||
:options="virtualPalletOptions"
|
||||
aria-label="选择称重托盘"
|
||||
@@ -582,9 +581,14 @@ defineExpose({ reload: loadWorkspace })
|
||||
|
||||
<div v-else-if="tableMode" class="weight-table-wrap">
|
||||
<div class="workbench-evidence"><table class="weight-data-table">
|
||||
<thead><tr><th>#</th><th>称重图片</th><th>托盘编码</th><th>托盘类型</th><th>库位</th><th>实测毛重(KG)</th><th>服务端确认毛重(KG)</th><th>托盘皮重(KG)</th><th>货物重量(KG)</th><th>操作</th></tr></thead>
|
||||
<thead><tr><th>操作</th><th>#</th><th>称重图片</th><th>托盘编码</th><th>托盘类型</th><th>库位</th><th>实测毛重(KG)</th><th>服务端确认毛重(KG)</th><th>托盘皮重(KG)</th><th>货物重量(KG)</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in rows" :key="row.id">
|
||||
<td>
|
||||
<el-button v-if="reviewCorrectionEnabled && row.recognitionStatus === 'CONFIRMED' && canEdit && canConfirm" link type="primary" :aria-label="`修改重量 ${index + 1}`" :disabled="Boolean(activeAction)" @click="openPreview(row)">修改重量</el-button>
|
||||
<el-tooltip v-else-if="canEdit && editable && !reviewMode" content="删除图片" placement="top"><el-button text type="danger" :aria-label="`删除称重图片 ${index + 1}`" :loading="isActionLoading('delete', row.id)" :disabled="Boolean(activeAction)" @click="removeResult(row)"><Trash2 :size="16" /></el-button></el-tooltip>
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
<td>{{ index + 1 }}</td>
|
||||
<td><WeightImageThumbnail :customer-id="customerId" :inbound-id="inboundId" :file-id="row.fileId" :alt="`称重图片 ${index + 1}`" @open="openPreview(row)" /></td>
|
||||
<td>{{ row.palletCode || '—' }}</td>
|
||||
@@ -594,7 +598,6 @@ defineExpose({ reload: loadWorkspace })
|
||||
<td>{{ formatWeight(row.grossWeightKg) }}</td>
|
||||
<td>{{ formatWeight(row.palletTareWeightKg) }}</td>
|
||||
<td class="weight-net-value">{{ formatWeight(row.netWeightKg) }}</td>
|
||||
<td><el-tooltip v-if="canEdit && editable && !reviewMode" content="删除图片" placement="top"><el-button text type="danger" :aria-label="`删除称重图片 ${index + 1}`" :loading="isActionLoading('delete', row.id)" :disabled="Boolean(activeAction)" @click="removeResult(row)"><Trash2 :size="16" /></el-button></el-tooltip><span v-else>—</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 订舱草稿按当前版本维护资料;放舱后运输、关系方、柜型需求、预报货量和附件只读;仅实货量可由订舱编辑权限通过 `/actual-cargo` 按版本、幂等键和原因补录并审计,禁止绕过运输冻结边界。
|
||||
- 订舱草稿按当前版本维护资料;放舱后除预计到港外的运输、关系方、柜型需求、预报货量和附件只读;预计到港经 `/estimated-arrival` 按编辑权限、版本和幂等键单独维护,不得清空或早于预计开船;实货量可由订舱编辑权限通过 `/actual-cargo` 按版本、幂等键和原因补录并审计,禁止绕过运输冻结边界。
|
||||
- 打印对草稿与已放舱订舱均开放,按当前**已保存**资料生成,未填写字段显示“—”,不用未保存的表单值。托运单取委值,提单确认件取实值;实值由客服手工录入,不回退为委值,不生成正式提单。
|
||||
- 关联候选只消费服务端 `/link-candidates` 分页结果,不在浏览器重排或本地拼接候选。
|
||||
- 订舱是拖车预约的唯一归属:拖车由 `../trucking/` 创建,本模块不直接建拖车单。
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
# 订舱页面受影响操作核对
|
||||
|
||||
业务依据为用户提供的《订舱信息页面说明.docx》及页面截图;本轮确认:放舱后仅补实货量、客服手工录入、保留现有提单确认件,正式提单从外部附件接收。样式使用 `docs/Admin高密度界面规范.md` 的详情与基本信息、蓝白主题及既有公共密度。用户后续提供现页与产品稿对比,确认现页缺少层次、重点且间距过大;订舱页以该产品稿为局部视觉依据:紧凑页头、蓝色圆点连线流程、编号区块、细线小标题、浅底三方卡片及右下主打印按钮。可编辑输入、下拉和文本域使用白底、清晰边框与文字,悬停/聚焦使用蓝色反馈;灰底和禁止光标仅用于真实禁用状态,校验错误仍保留组件错误色。
|
||||
业务依据为用户提供的《订舱信息页面说明.docx》及页面截图;本轮确认:放舱后允许单独维护预计到港及补实货量、客服手工录入、保留现有提单确认件,正式提单从外部附件接收。样式使用 `docs/Admin高密度界面规范.md` 的详情与基本信息、蓝白主题及既有公共密度。用户后续提供现页与产品稿对比,确认现页缺少层次、重点且间距过大;订舱页以该产品稿为局部视觉依据:紧凑页头、蓝色圆点连线流程、编号区块、细线小标题、浅底三方卡片及右下主打印按钮。可编辑输入、下拉和文本域使用白底、清晰边框与文字,悬停/聚焦使用蓝色反馈;灰底和禁止光标仅用于真实禁用状态,校验错误仍保留组件错误色。
|
||||
|
||||
流程条根据已保存订舱、当前未保存修改、放舱状态及实货量推进:当前步骤蓝底,已通过阶段勾选,待处理灰色。第一步的“已保存”不代替放舱完整性校验;打印节点仅在本次页面成功生成对应数据的 PDF 预览后显示“已预览”,失败或只点击按钮不产生预览事实,也不声称实际打印完成。预览标记随本次页面保留;刷新后按服务端保存/放舱/实值恢复进度,已放舱时托运单阶段保持通过,提示“可重新预览 / 打印”,避免前面灰色、后面完成的断开。放舱前托运单数据改变须重新预览;提单确认件始终要求已保存实值和对应 PDF 成功预览后才完成。
|
||||
|
||||
| 页面/路由 | 操作 | 参考承载与布局 | 当前实现 | 页面核对 |
|
||||
|---|---|---|---|---|
|
||||
| `/ocean-bookings/new`、`/ocean-bookings/:bookingId` | 草稿新增、维护 | 独立页面;左主资料、右三方并排,下方放舱资料 | 移除客户选择、港名、箱种、容量、限重、备注及包装单位选择;预计到港恢复在预计开船旁,并在放舱确认摘要展示;仓库和渠道保留在默认收起的“作业配置”,放舱缺配置时自动展开;委实成对输入,新增保存留在详情 | TEST #702 已核对预计到港恢复、录入保存和刷新回显;完整长字段展示待人工确认 |
|
||||
| `/ocean-bookings/:bookingId` | 放舱后维护预计到港 | 本轮用户确认仅开放预计到港;沿用现有单字段AppDialog | 页头独立入口,560px紧凑时间表单,仅提交预计到港和版本,不触及其他冻结资料 | 待1920×1080、100%人工截图确认 |
|
||||
| `/ocean-bookings/:bookingId` | 已放舱补实 | 客服补录实际货量 | 运输及预报只读,实值可按编辑权限补录,原因必填并版本审计 | 已核对放舱、原因校验、补实保存和历史;最终 TEST #698 回验实值保存后立即回显,无需刷新 |
|
||||
| `/ocean-bookings/:bookingId` | 托运单、提单确认件 | 两个作业入口,A4单据预览 | 两张业务卡片底部右侧的蓝色主按钮,分别直达现有PDF预览;读已保存委/实数据,缺失显示“—” | 弹框开关/重开、PDF 生成与数据映射已核对;内嵌 PDF 空白,显示未通过 |
|
||||
|
||||
人工按 1920×1080、100% 缩放核对三方长名称/地址、两类货量、保存失败与版本冲突、冻结/无编辑权限,以及两类 PDF 预览的打开、关闭和重开。自动检查结果在交付中单独报告,不能代替人工截图。
|
||||
|
||||
新增订舱默认运费条款 `FREIGHT PREPAID`、运输条款 `CY-CY`、委值 `500 / 15000 / 68`,实值留空;包装按 `CARTONS` 保存,新柜需求为整箱 FCL,容量和限重沿平台柜型规格计算。默认值只用于新增,不覆盖已保存资料。预计到港草稿可留空,放舱前必填且不得早于预计开船;放舱后仍遵守运输资料冻结规则。
|
||||
新增订舱默认运费条款 `FREIGHT PREPAID`、运输条款 `CY-CY`、委值 `500 / 15000 / 68`,实值留空;包装按 `CARTONS` 保存,新柜需求为整箱 FCL,容量和限重沿平台柜型规格计算。默认值只用于新增,不覆盖已保存资料。预计到港草稿可留空,放舱前必填且不得早于预计开船;放舱后预计到港允许独立窄命令维护,其余运输资料仍冻结。
|
||||
|
||||
用户后续确认预计到港供日历和入库操作使用,恢复该字段及前后端放舱门禁。代码核对:关联柜次时 `ContainerRepository.linkReleasedBooking` 传入订舱预计到港;日历在无实际到港时读柜级预计到港;海外 `ContainerWarehouseArrivalService` 在无人工或计划冻结日期时按到港当地日期加 2 天解析默认到仓,入库计划使用该解析结果,不能把订舱预计到港直接当成计划到仓日期。
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createOceanBooking, deleteOceanBookingDraft, downloadOceanBookingDocument, downloadOceanBookingPrint, invalidateOceanBookingDocument, listOceanBookingAgents, listOceanBookingDocuments, listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, referenceOceanBookingDocument, releaseOceanBooking, updateOceanBookingActualCargo, updateOceanBookingDraft, uploadOceanBookingDocument } from './platformOceanBookings'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { updateOceanBookingEstimatedArrival } from './platformOceanBookings'
|
||||
|
||||
const detailResponse = (version = 0, status = 'DRAFT') => ({
|
||||
oceanBooking: {
|
||||
@@ -14,6 +15,16 @@ const detailResponse = (version = 0, status = 'DRAFT') => ({
|
||||
})
|
||||
|
||||
describe('ocean bookings api', () => {
|
||||
it('预计到港使用窄命令并规范化详情回显与列表到港字段', async () => {
|
||||
const payload = { expectedVersion: 3, estimatedArrivalAt: '2026-10-25T10:00:00' }
|
||||
const raw = detailResponse(4, 'RELEASED')
|
||||
const put = vi.spyOn(http, 'put').mockResolvedValue({ code: '200', data: { ...raw, oceanBooking: { ...raw.oceanBooking, estimatedArrivalAt: payload.estimatedArrivalAt } } })
|
||||
const saved = await updateOceanBookingEstimatedArrival('123', payload)
|
||||
expect(put).toHaveBeenCalledWith('/platform/v1/ocean-bookings/123/estimated-arrival', payload, expect.objectContaining({ headers: { 'Idempotency-Key': expect.any(String) } }))
|
||||
expect(saved.estimatedArrivalAt).toBe(payload.estimatedArrivalAt)
|
||||
expect(saved.eta).toBe(payload.estimatedArrivalAt)
|
||||
expect(saved.version).toBe(4)
|
||||
})
|
||||
it('sends actual cargo only to the versioned supplement endpoint and normalizes decimal responses', async () => {
|
||||
const payload = { expectedVersion: 3, actualPackageCount: 480, actualGrossWeightKg: 14000.125, actualVolumeCbm: 65.321, reason: '客服补录' }
|
||||
const response = detailResponse(4, 'RELEASED')
|
||||
|
||||
@@ -98,6 +98,10 @@ export async function updateOceanBookingActualCargo(id: string, payload: OceanBo
|
||||
const response = await http.put<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}/actual-cargo`, payload, { headers: { 'Idempotency-Key': idempotencyKey() } })
|
||||
return detail(unwrapApiResponse(response))
|
||||
}
|
||||
export async function updateOceanBookingEstimatedArrival(id: string, payload: { expectedVersion: number; estimatedArrivalAt: string }) {
|
||||
const response = await http.put<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}/estimated-arrival`, payload, { headers: { 'Idempotency-Key': idempotencyKey() } })
|
||||
return detail(unwrapApiResponse(response))
|
||||
}
|
||||
export async function deleteOceanBookingDraft(id: string, expectedVersion: number) { await http.delete(`${basePath}/${encodeURIComponent(id)}`, { data: { expectedVersion }, headers: { 'Idempotency-Key': idempotencyKey() } }) }
|
||||
export async function releaseOceanBooking(id: string, request: OceanBookingReleaseRequest) { const response = await http.post<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}/release`, request, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
|
||||
const printPaths: Record<OceanBookingPrintType, string> = {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
releaseOceanBooking,
|
||||
updateOceanBookingDraft,
|
||||
updateOceanBookingActualCargo,
|
||||
updateOceanBookingEstimatedArrival,
|
||||
} from '../api/platformOceanBookings'
|
||||
import type { OceanBookingDetail, OceanBookingStatus } from '../types'
|
||||
import OceanBookingDetailView from './OceanBookingDetailView.vue'
|
||||
@@ -32,7 +33,7 @@ vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('../api/platformOceanBookings', () => ({
|
||||
createOceanBooking: vi.fn(), deleteOceanBookingDraft: vi.fn(), getOceanBooking: vi.fn(),
|
||||
listOceanBookingAgents: vi.fn(), listWarehouseLogisticsChannels: vi.fn(),
|
||||
releaseOceanBooking: vi.fn(), updateOceanBookingDraft: vi.fn(), updateOceanBookingActualCargo: vi.fn(),
|
||||
releaseOceanBooking: vi.fn(), updateOceanBookingDraft: vi.fn(), updateOceanBookingActualCargo: vi.fn(), updateOceanBookingEstimatedArrival: vi.fn(),
|
||||
}))
|
||||
|
||||
function booking(status: OceanBookingStatus = 'DRAFT', version = 3, billOfLadingNo = 'BL-001'): OceanBookingDetail {
|
||||
@@ -65,6 +66,12 @@ function booking(status: OceanBookingStatus = 'DRAFT', version = 3, billOfLading
|
||||
}
|
||||
|
||||
type ViewModel = {
|
||||
arrivalValue: string | null
|
||||
arrivalDialogOpen: boolean
|
||||
arrivalSaving: boolean
|
||||
arrivalError: { message: string; code?: string } | null
|
||||
openArrivalDialog: () => void
|
||||
saveEstimatedArrival: () => Promise<void>
|
||||
form: { data: import('../types').OceanBookingDraftData }
|
||||
saveActualCargo: () => Promise<void>
|
||||
actualReason: string
|
||||
@@ -96,6 +103,63 @@ async function mountView() {
|
||||
}
|
||||
|
||||
describe('OceanBookingDetailView one-click release', () => {
|
||||
it('放舱后仅单独保存预计到港并立即回显,保留尚未提交的实货量', async () => {
|
||||
vi.mocked(getOceanBooking).mockResolvedValue(booking('RELEASED'))
|
||||
const saved = { ...booking('RELEASED', 4), estimatedArrivalAt: '2026-09-25T08:00:00' }
|
||||
vi.mocked(updateOceanBookingEstimatedArrival).mockResolvedValue(saved)
|
||||
const { wrapper, vm } = await mountView()
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '维护预计到港')).toBe(true)
|
||||
vm.form.data.actualPackageCount = 499
|
||||
vm.actualReason = '尚未提交'
|
||||
vm.openArrivalDialog()
|
||||
vm.arrivalValue = saved.estimatedArrivalAt
|
||||
await vm.saveEstimatedArrival()
|
||||
expect(updateOceanBookingEstimatedArrival).toHaveBeenCalledExactlyOnceWith('123', { expectedVersion: 3, estimatedArrivalAt: saved.estimatedArrivalAt })
|
||||
expect(vm.detail?.estimatedArrivalAt).toBe(saved.estimatedArrivalAt)
|
||||
expect(vm.detail?.version).toBe(4)
|
||||
expect(vm.form.data.actualPackageCount).toBe(499)
|
||||
expect(vm.actualReason).toBe('尚未提交')
|
||||
expect(updateOceanBookingDraft).not.toHaveBeenCalled()
|
||||
const coreForm = wrapper.findAllComponents({ name: 'ElForm' }).find(form => form.classes().includes('booking-groups'))!
|
||||
expect(coreForm.props('disabled')).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('无编辑权限不开放维护入口;版本冲突保留输入并恢复操作', async () => {
|
||||
vi.mocked(getOceanBooking).mockResolvedValue(booking('RELEASED'))
|
||||
deniedPermissions.add('platform:firstleg:ocean-booking:edit')
|
||||
let rendered = await mountView()
|
||||
expect(rendered.wrapper.findAll('button').some(button => button.text() === '维护预计到港')).toBe(false)
|
||||
rendered.vm.openArrivalDialog()
|
||||
await rendered.vm.saveEstimatedArrival()
|
||||
expect(updateOceanBookingEstimatedArrival).not.toHaveBeenCalled()
|
||||
rendered.wrapper.unmount()
|
||||
deniedPermissions.clear()
|
||||
rendered = await mountView()
|
||||
rendered.vm.openArrivalDialog()
|
||||
rendered.vm.arrivalValue = '2026-09-25T08:00:00'
|
||||
vi.mocked(updateOceanBookingEstimatedArrival).mockRejectedValueOnce(new ApiResponseError('版本已变化,请刷新后重试', 'A0409'))
|
||||
await rendered.vm.saveEstimatedArrival()
|
||||
expect(rendered.vm.arrivalDialogOpen).toBe(true)
|
||||
expect(rendered.vm.arrivalValue).toBe('2026-09-25T08:00:00')
|
||||
expect(rendered.vm.arrivalSaving).toBe(false)
|
||||
expect(rendered.vm.arrivalError?.code).toBe('A0409')
|
||||
rendered.wrapper.unmount()
|
||||
})
|
||||
|
||||
it('放舱后预计到港不得清空或早于预计开船', async () => {
|
||||
vi.mocked(getOceanBooking).mockResolvedValue(booking('RELEASED'))
|
||||
const { wrapper, vm } = await mountView()
|
||||
vm.openArrivalDialog()
|
||||
for (const value of [null, '2026-08-01T08:00:00']) {
|
||||
vm.arrivalValue = value
|
||||
await vm.saveEstimatedArrival()
|
||||
expect(vm.arrivalError?.message).toBeTruthy()
|
||||
}
|
||||
expect(updateOceanBookingEstimatedArrival).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
routeState.name = 'ocean-booking-detail'; routeState.params.bookingId = '123'
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('OceanBookingDetailView draft and release contract', () => {
|
||||
expect(source).toContain('if (!saved) return')
|
||||
expect(source).toContain("{{ form.data.billOfLadingNo || '—' }}")
|
||||
expect(source).not.toContain("{{ detail?.billOfLadingNo || '—' }}")
|
||||
expect(source).toContain('const workflowBusy = computed(() => saving.value || actionSaving.value || actualSaving.value)')
|
||||
expect(source).toContain('const workflowBusy = computed(() => saving.value || actionSaving.value || actualSaving.value || arrivalSaving.value)')
|
||||
expect(source).toContain(':disabled="workflowBusy" @click="openReleaseDialog"')
|
||||
expect(source).not.toContain('releaseReason')
|
||||
expect(source).toContain('放舱前请上传有效的订舱确认文件或提单文件')
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
listWarehouseLogisticsChannels,
|
||||
updateOceanBookingDraft,
|
||||
updateOceanBookingActualCargo,
|
||||
updateOceanBookingEstimatedArrival,
|
||||
type OceanLogisticsChannel,
|
||||
} from '../api/platformOceanBookings'
|
||||
import type {
|
||||
@@ -115,9 +116,14 @@ const canPrint = computed(() => !!detail.value && access.can('platform:firstleg:
|
||||
const canManageDocuments = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:edit'))
|
||||
const documentManageReason = computed(() => !access.can('platform:firstleg:ocean-booking:edit') ? '暂无订舱资料维护权限' : '')
|
||||
const actualSaving = ref(false)
|
||||
const arrivalDialogOpen = ref(false)
|
||||
const arrivalSaving = ref(false)
|
||||
const arrivalValue = ref<string | null>(null)
|
||||
const arrivalError = ref<ApiErrorDetails | null>(null)
|
||||
const canMaintainArrival = computed(() => detail.value?.status === 'RELEASED' && access.can('platform:firstleg:ocean-booking:edit'))
|
||||
const actualReason = ref('')
|
||||
const cargoWritable = computed(() => formWritable.value || (detail.value?.status === 'RELEASED' && access.can('platform:firstleg:ocean-booking:edit')))
|
||||
const workflowBusy = computed(() => saving.value || actionSaving.value || actualSaving.value)
|
||||
const workflowBusy = computed(() => saving.value || actionSaving.value || actualSaving.value || arrivalSaving.value)
|
||||
const releaseDialogOpen = ref(false)
|
||||
const operationSections = ref<string[]>([])
|
||||
const printOpen = ref(false)
|
||||
@@ -128,7 +134,7 @@ function statusLabel(status: OceanBookingStatus | string | null | undefined) {
|
||||
return oceanBookingStatusLabel(status)
|
||||
}
|
||||
function historyActionLabel(action: string) {
|
||||
const labels: Record<string, string> = { CREATE: '创建草稿', UPDATE_DRAFT: '编辑草稿', RELEASE: '确认放舱', UPDATE_ACTUAL_CARGO: '实货量补录/修正', UPLOAD_DOCUMENT: '上传文件', ATTACH_DOCUMENT: '关联文件', INVALIDATE_DOCUMENT: '作废文件' }
|
||||
const labels: Record<string, string> = { CREATE: '创建草稿', UPDATE_DRAFT: '编辑草稿', RELEASE: '确认放舱', UPDATE_ESTIMATED_ARRIVAL: '维护预计到港', UPDATE_ACTUAL_CARGO: '实货量补录/修正', UPLOAD_DOCUMENT: '上传文件', ATTACH_DOCUMENT: '关联文件', INVALIDATE_DOCUMENT: '作废文件' }
|
||||
return labels[action] || '资料维护'
|
||||
}
|
||||
async function loadOptions() {
|
||||
@@ -185,6 +191,35 @@ function openPrint(type: OceanBookingPrintType) {
|
||||
printType.value = type
|
||||
printOpen.value = true
|
||||
}
|
||||
function openArrivalDialog() {
|
||||
if (!canMaintainArrival.value || workflowBusy.value) return
|
||||
arrivalValue.value = detail.value?.estimatedArrivalAt || null
|
||||
arrivalError.value = null
|
||||
arrivalDialogOpen.value = true
|
||||
}
|
||||
async function saveEstimatedArrival() {
|
||||
const current = detail.value
|
||||
if (!current || !canMaintainArrival.value || workflowBusy.value) return
|
||||
if (!arrivalValue.value || (current.estimatedSailingAt && arrivalValue.value < current.estimatedSailingAt)) {
|
||||
arrivalError.value = { message: !arrivalValue.value ? '请选择预计到港时间' : '预计到港时间不能早于预计开船时间' }
|
||||
return
|
||||
}
|
||||
arrivalSaving.value = true
|
||||
arrivalError.value = null
|
||||
try {
|
||||
const saved = await updateOceanBookingEstimatedArrival(current.id, { expectedVersion: current.version, estimatedArrivalAt: arrivalValue.value })
|
||||
// 单独保存预计到港不丢弃客服尚未提交的实际货量。
|
||||
const pendingCargo = { actualPackageCount: form.data.actualPackageCount, actualGrossWeightKg: form.data.actualGrossWeightKg, actualVolumeCbm: form.data.actualVolumeCbm }
|
||||
const pendingReason = actualReason.value
|
||||
applyDetail(saved)
|
||||
Object.assign(form.data, pendingCargo)
|
||||
actualReason.value = pendingReason
|
||||
workspace.bumpCacheEpoch('ocean-bookings')
|
||||
arrivalDialogOpen.value = false
|
||||
ElMessage.success('预计到港已保存')
|
||||
} catch (cause) { arrivalError.value = getApiErrorDetails(cause, '预计到港保存失败') }
|
||||
finally { arrivalSaving.value = false }
|
||||
}
|
||||
async function saveActualCargo() {
|
||||
if (detail.value?.status !== 'RELEASED' || !cargoWritable.value || workflowBusy.value) return
|
||||
const validationError = cargoValidationError()
|
||||
@@ -451,6 +486,7 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
<StatusBadge v-if="detail" :status="statusLabel(detail.status)" />
|
||||
<el-button v-if="canDelete" type="danger" plain :loading="actionSaving" :disabled="workflowBusy" @click="removeDraft"><Trash2 :size="16" />删除草稿</el-button>
|
||||
<el-button v-if="canSave" plain :loading="saving" :disabled="workflowBusy" @click="save"><Save :size="16" />保存草稿</el-button>
|
||||
<el-button v-if="canMaintainArrival" plain :disabled="workflowBusy" @click="openArrivalDialog">维护预计到港</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</PageToolbar>
|
||||
@@ -614,6 +650,10 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
</section>
|
||||
</template>
|
||||
</DetailPageLayout>
|
||||
<AppDialog v-if="detail?.status === 'RELEASED'" v-model="arrivalDialogOpen" class="admin-density-dialog admin-workbench is-dialog" :title="bookingDialogTitle('维护预计到港')" width="560px" :confirm-loading="arrivalSaving" :confirm-disabled="!canMaintainArrival" confirm-text="保存" @confirm="saveEstimatedArrival">
|
||||
<el-form class="density-form" label-position="top"><el-form-item label="预计到港" required><el-date-picker v-model="arrivalValue" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item></el-form>
|
||||
<LoadErrorState v-if="arrivalError" compact title="预计到港保存失败" :message="arrivalError.message" :show-retry="false"><template #details><ApiErrorMeta :details="arrivalError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
<OceanBookingPrintDialog v-if="detail" v-model="printOpen" :booking-id="detail.id" :booking-no="detail.bookingNo" :allowed="canPrint" :initial-type="printType" @previewed="onPrintPreviewed" />
|
||||
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="releaseDialogOpen" :title="bookingDialogTitle('确认放舱')" width="min(560px, 94vw)" :confirm-loading="actionSaving" :confirm-disabled="workflowBusy" confirm-text="确认放舱" cancel-text="取消" @confirm="submitRelease">
|
||||
|
||||
@@ -9,7 +9,6 @@ import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ProcurementNodeActions from './ProcurementNodeActions.vue'
|
||||
import { listOverseasPurchaseWarehouseOptions } from '../api/platformOverseasBusiness'
|
||||
import { submitProcurementCommand, uploadProcurementFile } from '../api/procurementCommands'
|
||||
import { readPendingProcurement } from '../procurementPending'
|
||||
import type { PlatformProcurement } from '../procurementTypes'
|
||||
vi.mock('../api/platformOverseasBusiness', () => ({ listOverseasPurchaseWarehouseOptions: vi.fn() }))
|
||||
vi.mock('../api/procurementCommands', () => ({ submitProcurementCommand: vi.fn(), uploadProcurementFile: vi.fn() }))
|
||||
@@ -74,7 +73,7 @@ describe('procurement purchase node operations', () => {
|
||||
expect(dialog.props('confirmDisabled')).toBe(false)
|
||||
expect(submitProcurementCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
it('saves and restores a separate correction while the immutable original request is pending, scoped to operator/customer/order', async () => {
|
||||
it('keeps ordinary correction drafts scoped to operator/customer/order while ignoring old requests', async () => {
|
||||
const value = fixture('FINANCE_ADJUST', false)
|
||||
value.fees = [{ id: '11', feeNo: 'PF-11', purpose: 'SAMPLE_PAYMENT', confirmationOrigin: 'BUSINESS', feeItemCode: 'SAMPLE_PAYMENT', feeItemName: '样品货款', feeVersion: 1, active: true, determination: 'POSITIVE', amount: '70.00', currency: 'CNY', originalAmount: '10.00', originalCurrency: 'USD', exchangeRate: '7', exchangeRateMonth: '2020-01', businessDate: '2020-01-01', parentFeeId: null, sourceFeeId: null, voucherFileId: '501', voucherFileName: 'invoice.pdf', walletStatus: 'PENDING', walletLedgerId: null, walletErrorCode: null, walletErrorMessage: null, remark: null }]
|
||||
const original = JSON.stringify({ command: 'fees/correct-target', payload: { version: 3, idempotencyKey: 'original-request', feeId: '11', targetOriginalAmount: '11.00', reason: '原请求', voucherFileId: '501' } })
|
||||
@@ -90,12 +89,11 @@ describe('procurement purchase node operations', () => {
|
||||
expect(submitProcurementCommand).not.toHaveBeenCalled()
|
||||
expect(sessionStorage.getItem(storageKey)).toBe(original)
|
||||
expect(JSON.parse(sessionStorage.getItem(`${storageKey}:correction-draft`)!)).toMatchObject({ targetOriginalAmount: '12.00', feeId: '11', reason: '准备下一版本' })
|
||||
expect(wrapper.findAll('button').find(button => button.text() === '核对并继续确认')!.attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.findAll('button').find(button => button.text() === '核对并继续确认')!.attributes('disabled')).toBeUndefined()
|
||||
wrapper.unmount(); wrapper = render(value)
|
||||
await click('恢复费用更正草稿')
|
||||
expect((wrapper.findComponent(AppDialog).find('input[placeholder="填写原费用链正确总额,最多两位小数"]').element as HTMLInputElement).value).toBe('12.00')
|
||||
await click('确认上次提交结果')
|
||||
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'fees/correct-target', JSON.parse(original).payload)
|
||||
expect(submitProcurementCommand).not.toHaveBeenCalled()
|
||||
expect(sessionStorage.getItem(`${storageKey}:correction-draft`)).not.toBeNull()
|
||||
const latest = structuredClone(value)
|
||||
latest.actions[0].allowed = true; latest.customerView.order.version = 9
|
||||
@@ -104,7 +102,7 @@ describe('procurement purchase node operations', () => {
|
||||
await click('恢复费用更正草稿'); await click('核对并继续确认')
|
||||
wrapper.findComponent(AppDialog).findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '本单凭证')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '501')
|
||||
await confirm(wrapper)
|
||||
expect(submitProcurementCommand).toHaveBeenCalledTimes(1)
|
||||
expect(submitProcurementCommand).not.toHaveBeenCalled()
|
||||
expect(wrapper.findComponent(AppDialog).text()).toContain('原实际费用及来源关联尚未完成')
|
||||
latest.fees[0].walletStatus = 'SETTLED'; latest.fees[0].walletLedgerId = 'LEDGER-1'
|
||||
latest.customerView.fees = [{ feeNo: 'PF-11', purpose: 'SAMPLE_PAYMENT', name: '样品货款', version: 1, active: true, determination: 'POSITIVE', amount: '70.00', currency: 'CNY', originalAmount: '10.00', originalCurrency: 'USD', exchangeRate: '7', exchangeRateMonth: '2020-01', businessDate: '2020-01-01', parentFeeNo: null, walletStatus: 'SETTLED', sourceLinked: true }]
|
||||
@@ -251,22 +249,20 @@ describe('procurement purchase node operations', () => {
|
||||
await confirm(wrapper)
|
||||
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'purchase', expect.objectContaining({ productName: '样品商品', specification: '黑色', quantity: 2, amount: '0.00', currency: 'USD', voucherFileId: '101', purchasedAt: '2026-10-08T10:00:00' }))
|
||||
})
|
||||
it('recovers an uncertain request after reload without changing its body or key', async () => {
|
||||
it('unknown request leaves inputs editable and a later click submits current input', async () => {
|
||||
vi.mocked(submitProcurementCommand).mockRejectedValueOnce(new Error('timeout'))
|
||||
let wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper, '客户取消'); await confirm(wrapper)
|
||||
const original = vi.mocked(submitProcurementCommand).mock.calls[0][3]; expect(sessionStorage.getItem(storageKey)).not.toBeNull(); wrapper.unmount()
|
||||
wrapper = render(fixture('CANCEL')); await flushPromises()
|
||||
const retry = wrapper.findAll('button').find(button => button.text() === '确认上次提交结果')!; await retry.trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(submitProcurementCommand).mock.calls[1][3]).toEqual(original); expect(sessionStorage.getItem(storageKey)).toBeNull()
|
||||
const wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper, '客户取消'); await confirm(wrapper)
|
||||
const original = vi.mocked(submitProcurementCommand).mock.calls[0]![3]
|
||||
expect(sessionStorage.getItem(storageKey)).toBeNull(); await explain(wrapper, '修改后的原因'); await confirm(wrapper)
|
||||
const next = vi.mocked(submitProcurementCommand).mock.calls[1]![3]
|
||||
expect(next.idempotencyKey).not.toBe(original.idempotencyKey); expect(next.reason).toBe('修改后的原因')
|
||||
})
|
||||
it('preserves an earlier uncertain financial or physical command when replay is rejected', async () => {
|
||||
it('consecutive failures never retain a request or block another click', async () => {
|
||||
vi.mocked(submitProcurementCommand).mockRejectedValueOnce(new Error('timeout')).mockRejectedValueOnce(new ApiResponseError('资源正在处理中', 'A0409'))
|
||||
const wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper); await confirm(wrapper)
|
||||
const saved = sessionStorage.getItem(storageKey); expect(saved).not.toBeNull()
|
||||
await confirm(wrapper)
|
||||
expect(sessionStorage.getItem(storageKey)).toBe(saved)
|
||||
expect(vi.mocked(submitProcurementCommand).mock.calls[1][3]).toEqual(vi.mocked(submitProcurementCommand).mock.calls[0][3])
|
||||
expect(wrapper.emitted('changed')).toBeUndefined()
|
||||
const wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper); await confirm(wrapper); await confirm(wrapper)
|
||||
const calls = vi.mocked(submitProcurementCommand).mock.calls
|
||||
expect(calls).toHaveLength(2); expect(calls[1]![3].idempotencyKey).not.toBe(calls[0]![3].idempotencyKey)
|
||||
expect(sessionStorage.getItem(storageKey)).toBeNull(); expect(wrapper.emitted('changed')).toBeUndefined()
|
||||
})
|
||||
it('keeps a version conflict visible, releases the failed command and does not report success', async () => {
|
||||
vi.mocked(submitProcurementCommand).mockRejectedValue(new ApiResponseError('订单版本冲突,请刷新', 'A0409'))
|
||||
@@ -300,9 +296,9 @@ describe('procurement purchase node operations', () => {
|
||||
wrapper.findComponent(AppDialog).vm.$emit('confirm'); wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
|
||||
expect(submitProcurementCommand).toHaveBeenCalledTimes(1); complete(); await flushPromises()
|
||||
})
|
||||
it('rejects malformed recovery records instead of allowing a fresh competing operation', async () => {
|
||||
it('old malformed request storage does not disable current actions', async () => {
|
||||
sessionStorage.setItem(storageKey, '{bad')
|
||||
const wrapper = render(); expect(wrapper.text()).toContain('上次操作记录无法读取'); expect(wrapper.find('.purchase-actions button').attributes('disabled')).toBeDefined()
|
||||
expect(() => readPendingProcurement(JSON.stringify({ command: 'purchase', payload: { version: 3, idempotencyKey: 'a', amount: '-2' } }))).toThrow()
|
||||
const wrapper = render(); expect(wrapper.text()).not.toContain('上次操作记录无法读取')
|
||||
expect(wrapper.find('.purchase-actions button').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { listOverseasPurchaseWarehouseOptions } from '../api/platformOverseasBusiness'
|
||||
import { submitProcurementCommand, uploadProcurementFile, type ProcurementCommand, type ProcurementCommandBody } from '../api/procurementCommands'
|
||||
import { commandKey, definitelyRejected, readPendingProcurement, type PendingProcurement } from '../procurementPending'
|
||||
import { commandKey, type PendingProcurement } from '../procurementPending'
|
||||
import { readCorrectionDraft, type ProcurementCorrectionDraft } from '../procurementCorrectionDraft'
|
||||
import type { PlatformProcurement, ProcurementAction, ProcurementFile } from '../procurementTypes'
|
||||
import type { OverseasPurchaseWarehouseOption } from '../types'
|
||||
@@ -75,8 +75,6 @@ const { captureBaseline } = useUnsavedWorkRegistration({ key: 'procurement-purch
|
||||
watch(key, () => {
|
||||
opened.value = false; pending.value = null; storageError.value = ''; extraFiles.value = []; active.value = null; editingDraft.value = false; correctionDraft.value = null; draftError.value = ''
|
||||
if (!key.value) return
|
||||
try { pending.value = readPendingProcurement(sessionStorage.getItem(key.value)) }
|
||||
catch { storageError.value = '上次操作记录无法读取,请先联系管理员核对该订单的处理结果。' }
|
||||
try { correctionDraft.value = readCorrectionDraft(sessionStorage.getItem(draftKey.value)) }
|
||||
catch { draftError.value = '本浏览器的费用更正草稿无法读取,请核对后重新填写。' }
|
||||
}, { immediate: true })
|
||||
@@ -123,11 +121,6 @@ function useCorrectionDraft() {
|
||||
const fee = props.detail.fees.find(row => row.id === finance.value.feeId)
|
||||
active.value = action; financialCommand.value = fee && !fee.active && ['TERMINATED', 'RELEASED'].includes(fee.walletStatus) ? 'fees/replace-failed' : 'fees/correct-target'; editingDraft.value = false
|
||||
}
|
||||
async function retryPending() {
|
||||
if (editingDraft.value) { saveCorrectionDraft(); if (draftError.value) return }
|
||||
editingDraft.value = false; opened.value = false
|
||||
await submit()
|
||||
}
|
||||
watch(financialCommand, () => { if (editingDraft.value) return; finance.value.feeId = ''; finance.value.targetOriginalAmount = ''; finance.value.refundAmount = ''; form.voucherFileId = ''; error.value = null }, { flush: 'sync' })
|
||||
watch(needsWarehouse, value => { if (value && opened.value) void loadWarehouses() })
|
||||
function build(): ProcurementCommandBody {
|
||||
@@ -157,33 +150,29 @@ function build(): ProcurementCommandBody {
|
||||
async function submit() {
|
||||
if (editingDraft.value) { saveCorrectionDraft(); return }
|
||||
if (saving.value || uploading.value || storageError.value || !key.value) return
|
||||
const resuming = !!pending.value
|
||||
const scope = key.value; const orderNo = props.detail.customerView.order.orderNo; const customer = props.customerId
|
||||
if (!pending.value) {
|
||||
if (!active.value?.allowed || !access.can(active.value.permission) || !command.value) return
|
||||
try {
|
||||
if (managementBlock.value) throw new Error(managementBlock.value)
|
||||
const request = { command: command.value, payload: build() }
|
||||
const serialized = JSON.stringify(request); readPendingProcurement(serialized)
|
||||
sessionStorage.setItem(scope, serialized); pending.value = request
|
||||
pending.value = request
|
||||
} catch (issue) { error.value = { message: issue instanceof Error ? issue.message : '提交信息保存失败,请重试' }; return }
|
||||
}
|
||||
const retryFee = pending.value.command === 'fees/retry' ? props.detail.fees.find(fee => fee.id === pending.value?.payload.feeId) : null
|
||||
const requiredPermission = pending.value.command === 'fees/retry' ? (retryFee ? feeRetryPermission(retryFee) : '') : `platform:overseas-business:purchase:${commandPermissions[pending.value.command]}`
|
||||
if (!requiredPermission || !access.can(requiredPermission)) { error.value = { message: '当前账号无此节点操作权限' }; return }
|
||||
if (!requiredPermission || !access.can(requiredPermission)) { pending.value = null; error.value = { message: '当前账号无此节点操作权限' }; return }
|
||||
saving.value = true; error.value = null
|
||||
const confirmsCorrection = !resuming && ['fees/correct-target', 'fees/replace-failed'].includes(pending.value.command)
|
||||
const confirmsCorrection = ['fees/correct-target', 'fees/replace-failed'].includes(pending.value.command)
|
||||
try {
|
||||
await submitProcurementCommand(orderNo, customer, pending.value.command, pending.value.payload)
|
||||
sessionStorage.removeItem(scope)
|
||||
if (key.value !== scope) return
|
||||
if (confirmsCorrection) { sessionStorage.removeItem(draftKey.value); correctionDraft.value = null }
|
||||
pending.value = null; opened.value = false; captureBaseline(); ElMessage.success('操作已保存,请核对最新业务及资金结果'); emit('changed')
|
||||
} catch (issue) {
|
||||
if (key.value !== scope) return
|
||||
if (!resuming && definitelyRejected(issue)) { sessionStorage.removeItem(scope); pending.value = null }
|
||||
error.value = getApiErrorDetails(issue, '结果尚未确认,请使用原请求重试')
|
||||
} finally { saving.value = false }
|
||||
error.value = getApiErrorDetails(issue, '操作失败,请重新提交')
|
||||
} finally { pending.value = null; saving.value = false }
|
||||
}
|
||||
async function upload(event: Event) {
|
||||
const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''
|
||||
@@ -212,10 +201,9 @@ async function upload(event: Event) {
|
||||
<dl><template v-for="action in unavailableActions" :key="action.key"><dt>{{ action.label }}</dt><dd>{{ action.reason || '当前不可操作' }}</dd></template></dl>
|
||||
</details>
|
||||
<el-alert v-if="draftError" :title="draftError" type="error" :closable="false" /><el-alert v-if="storageError" :title="storageError" type="error" :closable="false" />
|
||||
<el-alert v-if="pending" title="上次提交结果尚未确认,重试将使用原始内容与原请求编号。" type="warning" :closable="false"><p>原操作:{{ pending.command === 'review' ? (pending.payload.decision === 'APPROVE' ? '审核通过' : '审核拒绝') : actions.find(action => routes[action.key] === pending?.command)?.label || '采购节点操作' }} · {{ pending.payload.remark || pending.payload.reason }}</p><p v-if="pending.payload.amount !== undefined">原提交金额:{{ pending.payload.amount }} {{ pending.payload.currency || (['international-estimate', 'fees/refund'].includes(pending.command) ? 'CNY' : '') }}</p><el-button :loading="saving" @click="retryPending">确认上次提交结果</el-button></el-alert>
|
||||
<ApiErrorMeta v-if="error && !opened" :details="error" />
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="opened" :title="active?.label || '采购节点操作'" :width="['packing', 'labels', 'charges/confirm', 'exception-close'].includes(command || '') ? 'min(1120px, calc(100vw - 28px))' : 'min(760px, calc(100vw - 28px))'" :confirm-loading="saving || uploading" :confirm-disabled="!!managementBlock || uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" :confirm-text="editingDraft ? '保存本地草稿' : pending ? '重试原请求' : '确认提交'" @confirm="submit">
|
||||
<template #default><el-alert v-if="editingDraft" title="草稿仅保存在当前浏览器标签页,关闭标签页后可能丢失;不代表已确认费用。原请求未决时只能保存,结果明确后请刷新订单再核对确认。" type="info" :closable="false" /><el-alert v-if="command === 'purchase'" title="记录已实际购买的商品和供应商实付总额。资金未完成时,真实购买事实仍保留。" type="info" :closable="false" />
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="opened" :title="active?.label || '采购节点操作'" :width="['packing', 'labels', 'charges/confirm', 'exception-close'].includes(command || '') ? 'min(1120px, calc(100vw - 28px))' : 'min(760px, calc(100vw - 28px))'" :confirm-loading="saving || uploading" :confirm-disabled="!!managementBlock || uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" :confirm-text="editingDraft ? '保存本地草稿' : '确认提交'" @confirm="submit">
|
||||
<template #default><el-alert v-if="editingDraft" title="草稿仅保存在当前浏览器标签页,关闭标签页后可能丢失;不代表已确认费用。" type="info" :closable="false" /><el-alert v-if="command === 'purchase'" title="记录已实际购买的商品和供应商实付总额。资金未完成时,真实购买事实仍保留。" type="info" :closable="false" />
|
||||
<el-alert v-if="managementBlock" :title="managementBlock" type="warning" :closable="false" />
|
||||
<el-form class="density-form" label-position="top" :disabled="saving || uploading || (!!pending && !editingDraft)">
|
||||
<el-form-item v-if="active?.key === 'MANAGE'" label="实物更正操作"><el-select v-model="manageCommand" placeholder="选择处置或更正操作"><el-option v-for="option in manageOptions" :key="option.value" :value="option.value" :disabled="!!option.reason" :label="option.reason ? `${option.label}(${option.reason})` : option.label" /></el-select></el-form-item>
|
||||
@@ -235,7 +223,7 @@ async function upload(event: Event) {
|
||||
<el-form-item v-if="needsAmount || needsEvidence" label="本单凭证" required><el-select v-model="form.voucherFileId" placeholder="选择已上传凭证"><el-option v-for="file in files" :key="file.fileId" :value="file.fileId" :label="file.fileName" /></el-select><input aria-label="上传本单凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
|
||||
<el-form-item label="操作说明" required><el-input v-model="form.reason" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
|
||||
</el-form><el-alert v-if="error" :title="error.message" type="error" :closable="false" /><ApiErrorMeta v-if="error" :details="error" /></template>
|
||||
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="saving || uploading" @click="opened = false">关闭</el-button><el-button v-if="editingDraft" :disabled="!!pending || !!storageError || saving || uploading" @click="useCorrectionDraft">核对并继续确认</el-button><el-button type="primary" :loading="saving" :disabled="uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" @click="submit">{{ editingDraft ? '保存本地草稿' : pending ? '重试原请求' : '确认提交' }}</el-button></div></template>
|
||||
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="saving || uploading" @click="opened = false">关闭</el-button><el-button v-if="editingDraft" :disabled="!!pending || !!storageError || saving || uploading" @click="useCorrectionDraft">核对并继续确认</el-button><el-button type="primary" :loading="saving" :disabled="uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" @click="submit">{{ editingDraft ? '保存本地草稿' : '确认提交' }}</el-button></div></template>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -19,7 +19,6 @@ const commands = computed(() => access.can(overseasBusinessPermissions.truckStat
|
||||
const open = ref(false), saving = ref(false)
|
||||
const active = ref<TruckCommand | null>(null)
|
||||
const error = ref<ApiErrorDetails | null>(null), validationError = ref('')
|
||||
// 响应丢失时保留原命令、版本和幂等键,重试不得变更请求内容。
|
||||
const pending = ref<TruckOperation | null>(null)
|
||||
const form = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', actualPalletCount: 1, packageCount: undefined as number | undefined, palletIds: [] as string[], remark: '' })
|
||||
const selectablePallets = computed(() => props.detail.pallets.filter(pallet => pallet.status === 'RECEIVED'))
|
||||
@@ -71,7 +70,7 @@ function buildOperation(): TruckOperation | null {
|
||||
async function submit() {
|
||||
if (saving.value || !active.value || !commands.value.includes(active.value)) return
|
||||
validationError.value = ''; error.value = null
|
||||
const operation = pending.value ?? buildOperation()
|
||||
const operation = buildOperation()
|
||||
if (!operation) return
|
||||
pending.value = operation; saving.value = true
|
||||
const customerId = props.detail.customerId, orderNo = props.detail.orderNo
|
||||
@@ -83,20 +82,20 @@ async function submit() {
|
||||
emit('updated', updated)
|
||||
} catch (cause) {
|
||||
if (props.detail.customerId === customerId && props.detail.orderNo === orderNo) error.value = getApiErrorDetails(cause, '操作未确认成功,请重试或刷新订单核对最新记录')
|
||||
} finally { saving.value = false }
|
||||
} finally { pending.value = null; saving.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="commands.length" class="truck-order-actions">
|
||||
<el-button v-for="command in commands" :key="command" :type="command === 'cancel' ? 'danger' : 'primary'" :plain="command === 'cancel'" :disabled="saving" @click="start(command)">{{ truckCommandLabels[command] }}</el-button>
|
||||
<p v-if="pending && !open" role="status">上次操作尚未确认结果,请重新打开重试,或刷新订单核对最新记录。</p>
|
||||
|
||||
</div>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="active ? truckCommandLabels[active] : '单据操作'" width="680px" :confirm-text="pending ? '重试原操作' : '确认提交'" :confirm-type="active === 'cancel' || (review && form.decision === 'REJECT') ? 'danger' : 'primary'" :confirm-loading="saving" :confirm-disabled="!active || !commands.includes(active)" @confirm="submit">
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="active ? truckCommandLabels[active] : '单据操作'" width="680px" :confirm-text="'确认提交'" :confirm-type="active === 'cancel' || (review && form.decision === 'REJECT') ? 'danger' : 'primary'" :confirm-loading="saving" :confirm-disabled="!active || !commands.includes(active)" @confirm="submit">
|
||||
<template v-if="active">
|
||||
<p>{{ detail.orderNo }} · {{ truckCommandHints[active] }}</p>
|
||||
<el-alert v-if="validationError" :title="validationError" type="warning" :closable="false" role="alert" />
|
||||
<div v-if="error" role="alert"><el-alert :title="error.message" type="error" :closable="false" /><ApiErrorMeta :details="error" /><p>可重试原操作;如提示版本冲突,请关闭弹框并刷新订单,核对最新状态及操作记录。</p></div>
|
||||
<div v-if="error" role="alert"><el-alert :title="error.message" type="error" :closable="false" /><ApiErrorMeta :details="error" /><p>可修改后重新提交;如提示版本冲突,请关闭弹框并刷新订单,核对最新状态及操作记录。</p></div>
|
||||
<el-form :model="form" label-width="130px" :disabled="saving || !!pending" @submit.prevent="submit">
|
||||
<el-form-item v-if="review" label="审核结果"><el-radio-group v-model="form.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">拒绝</el-radio></el-radio-group></el-form-item>
|
||||
<template v-if="active === 'receive-pallets'">
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { isAxiosError } from 'axios'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import type { ProcurementCommand, ProcurementCommandBody } from './api/procurementCommands'
|
||||
export interface PendingProcurement { command: ProcurementCommand; payload: ProcurementCommandBody }
|
||||
const commands = new Set(['review', 'purchase', 'purchase/confirm', 'budget-authorizations', 'planned-warehouse', 'cancel', 'overseas-receipts', 'domestic-receipts', 'overseas-receipts/complete', 'domestic-receipts/complete', 'packing', 'packing/confirm', 'packing/withdraw', 'dispositions', 'receipts/correct', 'dispositions/revoke', 'labels', 'international-estimate', 'labels/apply', 'domestic-repack', 'overseas-shipment', 'customer-shipment', 'charges/confirm', 'fees/retry', 'fees/terminate', 'fees/release-estimate', 'fees/correct-target', 'fees/refund', 'fees/replace-failed', 'fees/additional', 'control-notes', 'control-notes/resolve', 'actual-warehouse/correct', 'exception-close'])
|
||||
@@ -7,126 +5,3 @@ export function commandKey() {
|
||||
const bytes = new Uint8Array(16); globalThis.crypto.getRandomValues(bytes)
|
||||
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
export function readPendingProcurement(raw: string | null): PendingProcurement | null {
|
||||
if (raw === null) return null
|
||||
const value: unknown = JSON.parse(raw)
|
||||
if (!value || typeof value !== 'object' || !('command' in value) || !('payload' in value) || !commands.has(String(value.command))) throw new Error('invalid pending command')
|
||||
const payload = value.payload
|
||||
if (!payload || typeof payload !== 'object' || !('version' in payload) || !Number.isSafeInteger(payload.version) || Number(payload.version) < 0 || !('idempotencyKey' in payload) || typeof payload.idempotencyKey !== 'string' || !payload.idempotencyKey) throw new Error('invalid pending payload')
|
||||
const fields = new Set(['version', 'idempotencyKey', 'reason', 'decision', 'remark', 'purchaseWarehouseId', 'warehouseId', 'productName', 'specification', 'quantity', 'amount', 'currency', 'purchasedAt', 'estimatedArrivalAt', 'voucherFileId', 'leg', 'sourceCartonId', 'receiptId', 'dispositionId', 'goodQuantity', 'damagedQuantity', 'extraQuantity', 'receivedAt', 'kind', 'packingVersion', 'cartons', 'labelVersion', 'receiver', 'addressVoucherFileId', 'labels', 'cartonIds', 'businessDate', 'appliedAt', 'shippedAt', 'cartonId', 'lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'feeId', 'targetOriginalAmount', 'internationalFreight', 'duty', 'domesticFreight', 'otherDetermination', 'otherFees', 'targetType', 'targetId', 'blocking', 'clientSummary', 'noteId', 'resolutions', 'customerVoucherFileId', 'financialConclusion', 'name'])
|
||||
const body = payload as Record<string, unknown>
|
||||
if (Object.keys(body).some(field => !fields.has(field))) throw new Error('unexpected pending field')
|
||||
const nonempty = (field: string) => typeof body[field] === 'string' && !!body[field].trim()
|
||||
const positiveId = (field: string) => nonempty(field) && /^[1-9]\d*$/.test(String(body[field]))
|
||||
if (value.command === 'review') {
|
||||
if (!['APPROVE', 'REJECT'].includes(String(body.decision)) || (body.decision === 'APPROVE' && !positiveId('purchaseWarehouseId'))) throw new Error('invalid pending review')
|
||||
} else if (!nonempty('reason')) throw new Error('missing pending reason')
|
||||
if (value.command === 'planned-warehouse' && !positiveId('warehouseId')) throw new Error('invalid pending warehouse')
|
||||
if (['purchase', 'budget-authorizations'].includes(String(value.command))) {
|
||||
if (!/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || !['CNY', 'USD'].includes(String(body.currency)) || !positiveId('voucherFileId')) throw new Error('invalid pending amount')
|
||||
}
|
||||
if (value.command === 'purchase' && (!nonempty('productName') || !Number.isSafeInteger(body.quantity) || Number(body.quantity) < 1 || !nonempty('purchasedAt') || !nonempty('estimatedArrivalAt'))) throw new Error('invalid pending purchase')
|
||||
const count = (field: string, minimum: number) => Number.isSafeInteger(body[field]) && Number(body[field]) >= minimum
|
||||
if (['overseas-receipts', 'domestic-receipts', 'overseas-receipts/complete', 'domestic-receipts/complete', 'receipts/correct', 'dispositions'].includes(String(value.command))) {
|
||||
if (!['OVERSEAS', 'DOMESTIC'].includes(String(body.leg))) throw new Error('invalid receipt leg')
|
||||
if ((String(value.command).startsWith('overseas') && body.leg !== 'OVERSEAS') || (String(value.command).startsWith('domestic') && body.leg !== 'DOMESTIC')) throw new Error('wrong receipt endpoint')
|
||||
}
|
||||
if (['overseas-receipts', 'domestic-receipts', 'receipts/correct'].includes(String(value.command))) {
|
||||
if (!['goodQuantity', 'damagedQuantity', 'extraQuantity'].every(field => count(field, 0)) || Number(body.goodQuantity) + Number(body.damagedQuantity) + Number(body.extraQuantity) <= 0 || !nonempty('receivedAt')) throw new Error('invalid pending receipt')
|
||||
if (body.leg === 'OVERSEAS' ? !positiveId('warehouseId') || body.sourceCartonId != null : !positiveId('sourceCartonId') || body.warehouseId != null) throw new Error('invalid receipt source')
|
||||
}
|
||||
if (['receipts/correct', 'dispositions', 'dispositions/revoke'].includes(String(value.command)) && !positiveId('voucherFileId')) throw new Error('missing correction evidence')
|
||||
if (value.command === 'receipts/correct' && !positiveId('receiptId')) throw new Error('missing original receipt')
|
||||
if (value.command === 'dispositions/revoke' && !positiveId('dispositionId')) throw new Error('missing original disposition')
|
||||
if (value.command === 'dispositions') {
|
||||
const decisions: Record<string, string> = { SHORTAGE: 'ACCEPT_SHORTAGE', DAMAGED: 'DISPOSE_DAMAGED', EXTRA: 'RETURN_EXTRA' }
|
||||
if (!count('quantity', 1) || !decisions[String(body.kind)] || decisions[String(body.kind)] !== body.decision || (body.kind !== 'SHORTAGE' && !positiveId('receiptId')) || (body.kind === 'SHORTAGE' && body.receiptId != null) || (body.leg === 'DOMESTIC' && !positiveId('sourceCartonId'))) throw new Error('invalid pending disposition')
|
||||
}
|
||||
if (value.command === 'packing/confirm' && !count('packingVersion', 1)) throw new Error('invalid packing version')
|
||||
if (value.command === 'packing') {
|
||||
if (!Array.isArray(body.cartons) || !body.cartons.length) throw new Error('missing packing list')
|
||||
const names = new Set<string>()
|
||||
for (const item of body.cartons) {
|
||||
if (!item || typeof item !== 'object' || typeof item.cartonNo !== 'string' || !item.cartonNo.trim() || names.has(item.cartonNo) || Object.keys(item).some(key => !['cartonNo', 'lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'sources'].includes(key))) throw new Error('invalid packing carton')
|
||||
names.add(item.cartonNo)
|
||||
for (const field of ['lengthCm', 'widthCm', 'heightCm', 'grossWeightKg']) {
|
||||
const pattern = field === 'grossWeightKg' ? /^\d{1,9}(?:\.\d{1,3})?$/ : /^\d{1,10}(?:\.\d{1,2})?$/
|
||||
if (typeof item[field] !== 'string' || !pattern.test(item[field]) || Number(item[field]) <= 0) throw new Error('invalid packing measurement')
|
||||
}
|
||||
if (!Array.isArray(item.sources) || !item.sources.length || item.sources.some((source: unknown) => {
|
||||
if (!source || typeof source !== 'object') return true
|
||||
const row = source as Record<string, unknown>
|
||||
return Object.keys(row).some(key => !['receiptId', 'quantity'].includes(key)) || typeof row.receiptId !== 'string' || !/^[1-9]\d*$/.test(row.receiptId) || !Number.isSafeInteger(row.quantity) || Number(row.quantity) < 1
|
||||
})) throw new Error('invalid packing source')
|
||||
}
|
||||
}
|
||||
if (value.command === 'labels') {
|
||||
const receiver = body.receiver
|
||||
if (!count('packingVersion', 1) || !['OVERSEAS', 'DOMESTIC'].includes(String(body.leg)) || !receiver || typeof receiver !== 'object' || !['name', 'phone', 'address'].every(key => typeof (receiver as Record<string, unknown>)[key] === 'string' && String((receiver as Record<string, unknown>)[key]).trim())) throw new Error('invalid label address')
|
||||
if (!Array.isArray(body.labels) || !body.labels.length) throw new Error('missing labels')
|
||||
const cartonIds = new Set<string>()
|
||||
for (const row of body.labels) {
|
||||
if (!row || typeof row !== 'object' || typeof row.cartonId !== 'string' || !/^[1-9]\d*$/.test(row.cartonId) || typeof row.fileId !== 'string' || !/^[1-9]\d*$/.test(row.fileId) || !Number.isSafeInteger(row.page) || row.page < 1 || !row.carrier?.trim() || !row.trackingNo?.trim() || cartonIds.has(row.cartonId)) throw new Error('invalid label association')
|
||||
cartonIds.add(row.cartonId)
|
||||
}
|
||||
}
|
||||
if (value.command === 'international-estimate' && (!count('labelVersion', 1) || !/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || !nonempty('businessDate'))) throw new Error('invalid pending estimate')
|
||||
if (['labels/apply', 'overseas-shipment', 'customer-shipment'].includes(String(value.command))) {
|
||||
if (!count('labelVersion', 1) || !Array.isArray(body.cartonIds) || !body.cartonIds.length || body.cartonIds.some(id => typeof id !== 'string' || !/^[1-9]\d*$/.test(id)) || new Set(body.cartonIds).size !== body.cartonIds.length) throw new Error('invalid transport cartons')
|
||||
if (!nonempty(value.command === 'labels/apply' ? 'appliedAt' : 'shippedAt')) throw new Error('missing actual transport time')
|
||||
if ((value.command === 'overseas-shipment' && body.leg !== 'OVERSEAS') || (value.command === 'customer-shipment' && body.leg !== 'DOMESTIC')) throw new Error('wrong shipment leg')
|
||||
}
|
||||
if (value.command === 'domestic-repack') {
|
||||
if (!positiveId('cartonId') || !positiveId('voucherFileId')) throw new Error('invalid repack reference')
|
||||
for (const field of ['lengthCm', 'widthCm', 'heightCm', 'grossWeightKg']) {
|
||||
const pattern = field === 'grossWeightKg' ? /^\d{1,9}(?:\.\d{1,3})?$/ : /^\d{1,10}(?:\.\d{1,2})?$/
|
||||
if (typeof body[field] !== 'string' || !pattern.test(String(body[field])) || Number(body[field]) <= 0) throw new Error('invalid repack measurement')
|
||||
}
|
||||
}
|
||||
if (String(value.command).startsWith('fees/') && value.command !== 'fees/additional') {
|
||||
if (!positiveId('feeId')) throw new Error('invalid original fee')
|
||||
if (['fees/correct-target', 'fees/refund', 'fees/replace-failed'].includes(String(value.command)) && !positiveId('voucherFileId')) throw new Error('missing finance evidence')
|
||||
if (['fees/correct-target', 'fees/replace-failed'].includes(String(value.command)) && !/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.targetOriginalAmount))) throw new Error('invalid correction target')
|
||||
if (value.command === 'fees/refund' && (!/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || Number(body.amount) <= 0)) throw new Error('invalid refund amount')
|
||||
}
|
||||
if (value.command === 'charges/confirm') {
|
||||
const selection = (input: unknown) => {
|
||||
if (!input || typeof input !== 'object') throw new Error('missing fee determination')
|
||||
const item = input as Record<string, unknown>
|
||||
if ((item.feeId == null) === (item.value == null) || Object.keys(item).some(key => !['feeId', 'value'].includes(key))) throw new Error('ambiguous fee determination')
|
||||
if (item.feeId != null) { if (typeof item.feeId !== 'string' || !/^[1-9]\d*$/.test(item.feeId)) throw new Error('invalid fee reference'); return }
|
||||
if (!item.value || typeof item.value !== 'object') throw new Error('invalid fee amount')
|
||||
const value = item.value as Record<string, unknown>
|
||||
if (Object.keys(value).some(key => !['determination', 'amount', 'currency', 'businessDate', 'voucherFileId', 'reason'].includes(key))) throw new Error('unexpected fee amount field')
|
||||
if (!['POSITIVE', 'ZERO', 'NOT_APPLICABLE'].includes(String(value.determination)) || typeof value.amount !== 'string' || !/^\d{1,16}(?:\.\d{1,2})?$/.test(value.amount) || (value.determination === 'POSITIVE' ? Number(value.amount) <= 0 : Number(value.amount) !== 0) || !['CNY', 'USD'].includes(String(value.currency)) || typeof value.businessDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value.businessDate) || typeof value.reason !== 'string' || !value.reason.trim()) throw new Error('invalid fee determination value')
|
||||
if (value.determination === 'POSITIVE' && (typeof value.voucherFileId !== 'string' || !/^[1-9]\d*$/.test(value.voucherFileId))) throw new Error('missing fee voucher')
|
||||
}
|
||||
for (const key of ['internationalFreight', 'duty', 'domesticFreight']) selection(body[key])
|
||||
if (!['NOT_APPLICABLE', 'ITEMIZED'].includes(String(body.otherDetermination)) || !Array.isArray(body.otherFees) || (body.otherDetermination === 'NOT_APPLICABLE') !== (body.otherFees.length === 0)) throw new Error('invalid other fee determination')
|
||||
const codes = new Set<string>()
|
||||
for (const row of body.otherFees) {
|
||||
if (!row || typeof row !== 'object' || typeof row.code !== 'string' || !/^[A-Z][A-Z0-9_]{0,31}$/.test(row.code) || codes.has(row.code) || typeof row.name !== 'string' || !row.name.trim()) throw new Error('invalid other fee')
|
||||
codes.add(row.code); selection(row.selection)
|
||||
}
|
||||
}
|
||||
if (['control-notes', 'control-notes/resolve', 'actual-warehouse/correct'].includes(String(value.command)) && !positiveId('voucherFileId')) throw new Error('missing control evidence')
|
||||
if (value.command === 'control-notes' && (!['PURCHASE', 'WAREHOUSE', 'RECEIPT', 'SHIPMENT'].includes(String(body.targetType)) || typeof body.blocking !== 'boolean' || (body.targetType === 'PURCHASE' ? body.targetId != null : !positiveId('targetId')))) throw new Error('invalid control target')
|
||||
if (value.command === 'control-notes/resolve' && !positiveId('noteId')) throw new Error('missing original blocker')
|
||||
if (value.command === 'actual-warehouse/correct' && !positiveId('warehouseId')) throw new Error('invalid actual warehouse')
|
||||
if (value.command === 'exception-close') {
|
||||
if (!positiveId('customerVoucherFileId') || !nonempty('financialConclusion') || !nonempty('clientSummary') || !Array.isArray(body.resolutions)) throw new Error('incomplete closure facts')
|
||||
const decisions: Record<string, string[]> = { PURCHASE_REMAINDER: ['SUPPLIER_RETAINED', 'RETURNED_TO_SUPPLIER', 'LOSS_CONFIRMED'], TRANSIT_REMAINDER: ['LOSS_CONFIRMED', 'RETURNED_TO_SENDER'], GOOD: ['RETURNED_TO_SUPPLIER', 'CUSTOMER_AUTHORIZED_RETENTION', 'DISPOSED'], DAMAGED: ['DISPOSED', 'RETURNED_TO_SUPPLIER'], EXTRA: ['RETURNED_TO_SUPPLIER'] }
|
||||
const sources = new Set<string>()
|
||||
for (const row of body.resolutions) {
|
||||
if (!row || typeof row !== 'object' || !decisions[row.kind]?.includes(row.decision) || !Number.isSafeInteger(row.quantity) || row.quantity < 1 || typeof row.destination !== 'string' || !row.destination.trim() || typeof row.voucherFileId !== 'string' || !/^[1-9]\d*$/.test(row.voucherFileId) || (row.kind === 'PURCHASE_REMAINDER' ? row.sourceId != null : typeof row.sourceId !== 'string' || !/^[1-9]\d*$/.test(row.sourceId))) throw new Error('invalid closure resolution')
|
||||
const key = `${row.kind}:${row.sourceId ?? ''}`; if (sources.has(key)) throw new Error('duplicate closure source'); sources.add(key)
|
||||
}
|
||||
}
|
||||
if (value.command === 'fees/additional' && (!nonempty('name') || !nonempty('businessDate') || !['CNY', 'USD'].includes(String(body.currency)) || !/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || Number(body.amount) <= 0 || !positiveId('voucherFileId'))) throw new Error('invalid independent financial fee')
|
||||
return value as PendingProcurement
|
||||
}
|
||||
export function definitelyRejected(error: unknown) {
|
||||
const status = error instanceof ApiResponseError ? error.status : isAxiosError(error) ? error.response?.status : undefined
|
||||
const code = error instanceof ApiResponseError ? error.code : ''
|
||||
return [400, 401, 403, 404, 409, 422].includes(status ?? 0) || ['A0400', 'A0401', 'A0403', 'A0404', 'A0409', 'A0422'].includes(code)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+3
-3
@@ -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()
|
||||
|
||||
+19
-31
@@ -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
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
| 转运驳回、REJECT 接口能力 | `api/warehouseTransfers.ts` 的 `reviewWarehouseTransfer` | API 支持 `APPROVE/REJECT` 与 `remark`;不据此推定当前弹框提供驳回操作,界面能力沿上一行组件核对 |
|
||||
| 尾程订单控制与柜级入仓计划转换 | `pages/LastMileOrdersView.vue`、`pages/LastMileOrderDetailView.vue` | `api/lastMileOrders.ts`;[入仓计划页面约束](../../../../../docs/业务实现边界.md#admin-入仓计划页面约束),仅计划/状态/请求行为变化时查 |
|
||||
| 入仓单、入仓计划、FBA 预约 | `pages/InboundOrdersView.vue`、`pages/InboundOrderDetailView.vue` | 列表/预约 `api/inboundOrders.ts`,计划列表/详情 `api/inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
|
||||
| 排车查询、整车草稿、审核与装车资料 | `pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue`、`components/SmartDispatchExistingCargo.vue`、`components/DispatchWorkflowDialog.vue` | `api/dispatches.ts`、`dispatchContracts.ts`、`dispatchCommandRecovery.ts`;Backend `lastleg-service/dispatch/`、`delivery/`。一单一趟、同仓多客户多目的地,空单或先选货均可;候选使用来源版本,草稿走服务器token及公共版本,全部容器原子转换。当前规则见`../../../../../docs/海外仓业务/排车列表统一作业改造契约.md`,不恢复旧单客户预排协议 |
|
||||
| 排车查询、整车草稿、审核与装车资料 | `pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue`、`components/DispatchWorkflowDialog.vue` | `api/dispatches.ts`、`dispatchContracts.ts`、`dispatchCommandRecovery.ts`;Backend `lastleg-service/dispatch/`、`delivery/`。一单一趟、同仓多客户多目的地,空单或先选货均可;候选使用来源版本,草稿走服务器token及公共版本,全部容器原子转换。当前规则见`../../../../../docs/海外仓业务/排车列表统一作业改造契约.md`,不恢复旧单客户预排协议 |
|
||||
| 海外出库与派送 | `pages/OutboundsView.vue`、`pages/OutboundDetailView.vue`、`pages/DeliveryOrdersView.vue`、`pages/DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`api/deliveryOrders.ts`;[出库与派送页面约束](../../../../../docs/业务实现边界.md#admin-出库与派送页面约束),候选选择另查下表 |
|
||||
| 异常退回与客户回访 | `pages/ExceptionOrdersView.vue`、`pages/CustomerVisitsView.vue` | `api/exceptionOrders.ts`、`api/customerVisits.ts`;异常关系解析、异常建档、派送退回、完成/状态和回访附件对应 Backend `lastleg-service/exceptionorder/`、`customervisit/` |
|
||||
| 库存余额、库位与流水 | `pages/InventoryView.vue`、`pages/InventoryDetailView.vue`、`pages/InventoryTransactionsView.vue` | `api/inventory.ts`;业务库存归 `lastleg-service/inventory/`,实物资源归 `warehouse-overseas-service/` |
|
||||
|
||||
@@ -27,7 +27,7 @@ it('reads and writes original dispatch appointment allocations with lossless ver
|
||||
|
||||
describe('native whole-vehicle dispatch API', () => {
|
||||
it('round-trips this trip maintenance data and its vehicle photo references without altering actual departure', async () => {
|
||||
const maintenance = { ...blankVehicleMaintenance(), plannedDeliveryDate: '2026-11-02', deliveryMethod: '外配', fleetEmail: 'fleet@example.test', appointmentNo: 'APPT-1' }
|
||||
const maintenance = { ...blankVehicleMaintenance(), plannedDeliveryDate: '2026-11-02', deliveryMethod: '外配', fleetEmail: 'fleet@example.test', destinationAppointments: [{ destinationKey: 'FBA:ONT8', appointmentNo: 'APPT-1' }] }
|
||||
const value = detail()
|
||||
value.vehicleInfo.maintenance = maintenance
|
||||
value.vehicleInfo.vehicle!.evidenceFileIds = ['9007199254740993']
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { expect, it } from 'vitest'
|
||||
import DispatchAppointments from './DispatchAppointments.vue'
|
||||
import type { FbaAppointment } from '../dispatchContracts'
|
||||
|
||||
it('shows each destination once and keeps all shipment numbers from its grouped projection', () => {
|
||||
const first: FbaAppointment = { batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'A', appointmentNo: 'CONF-1', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'SUCCESS', confirmedAt: '2026-10-25T17:00:00Z', blockReason: null, shipmentNumbers: ['SHIP-1', 'SHIP-2'] }
|
||||
const wrapper = mount(DispatchAppointments, { props: { appointments: [first, { ...first, destinationKey: 'FBA:LGB8', destinationWarehouseCode: 'LGB8', batchKey: 'FBA_APPOINTMENT:2', shipmentNumbers: ['SHIP-3'] }] } })
|
||||
expect(wrapper.findAll('tbody tr')).toHaveLength(2)
|
||||
expect(wrapper.text()).toContain('SHIP-1、SHIP-2'); expect(wrapper.text()).toContain('SHIP-3')
|
||||
expect(wrapper.text()).not.toContain('FBA_APPOINTMENT:')
|
||||
})
|
||||
it('shows missing confirmation as a dash and never substitutes a batch number or planned time', () => {
|
||||
const wrapper = mount(DispatchAppointments, { props: { appointments: [{ batchKey: 'FBA_APPOINTMENT:1', customerId: 'A', shipmentNumbers: [], batchNo: 'AP-PLAN', appointmentNo: null, destinationKey: 'FBA:ONT8', destinationWarehouseCode: null, status: 'PENDING', confirmedAt: null, blockReason: null }] } })
|
||||
expect(wrapper.findAll('tbody td').map(cell => cell.text())).toEqual(['—', '—', '—', '—'])
|
||||
expect(wrapper.text()).not.toContain('AP-PLAN')
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import type { FbaAppointment } from '../dispatchContracts'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
defineProps<{ appointments: FbaAppointment[] }>()
|
||||
</script>
|
||||
<template>
|
||||
<table class="density-table" aria-label="目的仓预约"><thead><tr><th>目的仓</th><th>预约号</th><th>确认送达时间</th><th>货件号</th></tr></thead><tbody>
|
||||
<tr v-for="appointment in appointments" :key="appointment.destinationKey"><td>{{ appointment.destinationWarehouseCode || '—' }}</td><td>{{ appointment.appointmentNo || '—' }}</td><td>{{ formatOverseasDateTime(appointment.confirmedAt) }}</td><td>{{ appointment.shipmentNumbers?.join('、') || '—' }}</td></tr>
|
||||
<tr v-if="!appointments.length"><td colspan="4">—</td></tr>
|
||||
</tbody></table>
|
||||
</template>
|
||||
@@ -66,16 +66,13 @@ it('switches customer scope exactly and keeps unsaved delivery edits when switch
|
||||
expect(wrapper.find('[data-category="BOL"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-category="LOADING_PHOTO"]').exists()).toBe(false)
|
||||
expect(wrapper.getComponent(DeliveryDocumentsDialog).find('[aria-label="派送资料摘要"]').exists()).toBe(false)
|
||||
await wrapper.findAll('[role="tab"]').find(tab => tab.text() === '油耗图片')!.trigger('click')
|
||||
expect(wrapper.find('dispatch-loading-documents-stub').exists()).toBe(false)
|
||||
expect(wrapper.find('dispatch-vehicle-documents-stub').isVisible()).toBe(true)
|
||||
expect(receipt.isVisible()).toBe(false)
|
||||
expect(wrapper.find('.save').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('油耗图片')
|
||||
expect(wrapper.find('dispatch-vehicle-documents-stub').exists()).toBe(false)
|
||||
expect(getDeliveryDocuments).toHaveBeenCalledTimes(1)
|
||||
await wrapper.findAll('[role="tab"]').find(tab => tab.text() === '签收文件')!.trigger('click')
|
||||
expect(receipt.isVisible()).toBe(true)
|
||||
expect(receipt.text()).not.toContain('existing-pod.pdf')
|
||||
expect(wrapper.find('dispatch-vehicle-documents-stub').isVisible()).toBe(false)
|
||||
expect(wrapper.find('dispatch-vehicle-documents-stub').exists()).toBe(false)
|
||||
expect(wrapper.find('.save').exists()).toBe(true)
|
||||
const confirmation = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValue('cancel')
|
||||
wrapper.getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'DL-2'); await flushPromises()
|
||||
|
||||
@@ -12,7 +12,6 @@ import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { getDispatch } from '../api/dispatches'
|
||||
import type { DispatchDetail } from '../dispatchContracts'
|
||||
import DeliveryDocumentsDialog from './DeliveryDocumentsDialog.vue'
|
||||
import DispatchVehicleDocuments from './DispatchVehicleDocuments.vue'
|
||||
import DispatchLoadingDocuments from './DispatchLoadingDocuments.vue'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
@@ -20,13 +19,13 @@ const props = defineProps<{ dispatchNo: string }>()
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const detail = ref<DispatchDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const tab = ref('RECEIPT'), selected = ref(''), fuelVisited = ref(false)
|
||||
const editor = ref<InstanceType<typeof DeliveryDocumentsDialog>>(), vehicleEditor = ref<InstanceType<typeof DispatchVehicleDocuments>>()
|
||||
const tab = ref('RECEIPT'), selected = ref('')
|
||||
const editor = ref<InstanceType<typeof DeliveryDocumentsDialog>>()
|
||||
const loadingEditor = ref<InstanceType<typeof DispatchLoadingDocuments>>()
|
||||
const deliveries = computed(() => detail.value?.documents.deliveries || [])
|
||||
const beforeDeparture = computed(() => ['LOADING', 'AFTER_LOADING'].includes(detail.value?.stage || ''))
|
||||
const delivery = computed(() => deliveries.value.find(item => item.deliveryNo === selected.value))
|
||||
const busy = computed(() => Boolean(editor.value?.busy || vehicleEditor.value?.busy || loadingEditor.value?.busy))
|
||||
const busy = computed(() => Boolean(editor.value?.busy || loadingEditor.value?.busy))
|
||||
let sequence = 0
|
||||
onBeforeUnmount(() => { sequence++ })
|
||||
async function load() {
|
||||
@@ -49,11 +48,11 @@ async function canLeave() {
|
||||
}
|
||||
async function selectDelivery(value: string) { if (value !== selected.value && await canLeave()) selected.value = value }
|
||||
async function beforeClose(done: () => void) { if (await canLeave()) done() }
|
||||
function selectTab(value: string) { if (!busy.value) { tab.value = value; if (value === 'FUEL') fuelVisited.value = true } }
|
||||
function selectTab(value: string) { if (!busy.value) { tab.value = value } }
|
||||
function saved() { emit('saved') }
|
||||
async function loadingSaved() { emit('saved'); await load() }
|
||||
watch(() => [open.value, props.dispatchNo] as const, ([visible]) => {
|
||||
sequence++; detail.value = null; selected.value = ''; tab.value = 'RECEIPT'; fuelVisited.value = false; error.value = null
|
||||
sequence++; detail.value = null; selected.value = ''; tab.value = 'RECEIPT'; error.value = null
|
||||
if (visible && props.dispatchNo) void load()
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
@@ -61,15 +60,14 @@ watch(() => [open.value, props.dispatchNo] as const, ([visible]) => {
|
||||
<AppDialog v-model="open" class="admin-density admin-density-dialog admin-workbench is-dialog" :title="'管理资料 · ' + (dispatchNo || '—')" width="min(1100px, calc(100vw - 40px))" fullscreenable :loading="loading" :confirm-loading="busy" :before-close="beforeClose" :show-confirm="tab === 'RECEIPT' && Boolean(editor?.canEdit)" :confirm-disabled="loading || editor?.loading || editor?.conflict" confirm-text="保存资料" cancel-text="关闭" @confirm="editor?.save()">
|
||||
<LoadErrorState v-if="error" compact title="排车资料读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<template v-if="detail">
|
||||
<div class="density-status-tabs" role="tablist" aria-label="排车资料类别"><button v-for="item in [{ key: 'RECEIPT', label: '签收文件' }, { key: 'FUEL', label: '油耗图片' }, { key: 'LOADING', label: '装车资料' }]" :key="item.key" role="tab" :aria-selected="tab === item.key" :disabled="busy" :class="{ 'is-active': tab === item.key }" @click="selectTab(item.key)">{{ item.label }}</button></div>
|
||||
<div class="density-status-tabs" role="tablist" aria-label="排车资料类别"><button v-for="item in [{ key: 'RECEIPT', label: '签收文件' }, { key: 'LOADING', label: '装车资料' }]" :key="item.key" role="tab" :aria-selected="tab === item.key" :disabled="busy" :class="{ 'is-active': tab === item.key }" @click="selectTab(item.key)">{{ item.label }}</button></div>
|
||||
<DispatchLoadingDocuments v-if="tab === 'LOADING'" ref="loadingEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @saved="loadingSaved" />
|
||||
<div v-show="tab === 'RECEIPT'" v-if="!beforeDeparture">
|
||||
<el-form class="density-form" label-position="left" label-width="80px"><el-form-item label="派送单"><el-select :model-value="selected" :disabled="busy || !access.can(permission.deliveryList)" aria-label="选择派送单" @update:model-value="selectDelivery"><el-option v-for="item in deliveries" :key="item.deliveryNo" :value="item.deliveryNo" :label="item.deliveryNo" /></el-select></el-form-item></el-form>
|
||||
<DeliveryDocumentsDialog v-if="delivery && access.can(permission.deliveryList)" ref="editor" :key="delivery.customerId + ':' + delivery.deliveryNo" :model-value="open" embedded :delivery-no="delivery.deliveryNo" :customer-id="delivery.customerId" :categories="['RECEIPT']" @saved="saved" />
|
||||
<p v-else>{{ access.can(permission.deliveryList) ? '暂无关联派送单' : '当前账号无派送资料查看权限' }}</p>
|
||||
</div>
|
||||
<p v-if="beforeDeparture && tab !== 'LOADING'">完成装车并补齐装车资料后,进入派送中管理{{ tab === 'FUEL' ? '油耗图片' : '签收文件' }}。</p>
|
||||
<DispatchVehicleDocuments v-if="fuelVisited && !beforeDeparture" v-show="tab === 'FUEL'" ref="vehicleEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @saved="saved" />
|
||||
<p v-if="beforeDeparture && tab !== 'LOADING'">完成装车并补齐装车资料后,进入派送中管理签收文件。</p>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
@@ -12,6 +12,17 @@ vi.mock('../api/dispatches', () => ({ getDispatchEvidence: vi.fn(), getDispatchE
|
||||
const metadata = (fileId = '301', contentType = 'image/png'): DispatchEvidenceFile => ({ fileId, fileName: contentType === 'application/pdf' ? '本车证照.pdf' : '本车证据.png', contentType, sizeBytes: 3 })
|
||||
const options = { props: { modelValue: ['301'], dispatchNo: 'PC/1', kind: 'VEHICLE' as const }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, AppDialog: { name: 'AppDialog', props: ['modelValue', 'title'], template: '<section v-if="modelValue" role="dialog"><slot /><button @click="$emit(\'update:modelValue\', false)">关闭预览</button></section>' } } } }
|
||||
const button = (wrapper: ReturnType<typeof mount>, label: string) => wrapper.findAll('button').find(value => value.text() === label)!
|
||||
it.each(['VEHICLE', 'DRIVER_PHOTO', 'DRIVER'] as const)('does not count a PDF or failed upload as a %s photo', async kind => {
|
||||
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, kind, photoCard: true, modelValue: [] } }); await flushPromises()
|
||||
await selectFile(wrapper, new File(['pdf'], 'document.pdf', { type: 'application/pdf' }))
|
||||
expect(api.uploadDispatchEvidence).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
vi.mocked(api.uploadDispatchEvidence).mockRejectedValueOnce(new Error('上传失败'))
|
||||
await selectFile(wrapper, new File(['png'], 'photo.png', { type: 'image/png' }))
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(wrapper.emitted('busy')?.at(-1)).toEqual([false])
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('previews an unsaved master license through its driver and keeps uploaded license files on the dispatch route', async () => {
|
||||
vi.mocked(downloadDriverLicense).mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
|
||||
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, kind: 'DRIVER', photoCard: true, modelValue: ['301'], referenceIds: ['33'], masterLicense: { driverId: '2', fileId: '33' } } }); await flushPromises()
|
||||
|
||||
@@ -23,7 +23,7 @@ const previewId = ref(''), imageDecoding = ref(false), imageFailure = ref('')
|
||||
const previewBusy = computed(() => previewLoading.value || imageDecoding.value)
|
||||
const label = computed(() => props.label || (props.kind === 'VEHICLE' ? '本趟车辆证据' : props.kind === 'DRIVER_PHOTO' ? '司机照片' : '本趟司机证照'))
|
||||
const types = ['application/pdf', 'image/jpeg', 'image/png'], maximum = 10 * 1024 * 1024, urls = new Set<string>()
|
||||
const uploadTypes = computed(() => ['DRIVER_PHOTO', 'EXCEPTION'].includes(props.kind) ? types.slice(1) : types)
|
||||
const uploadTypes = computed(() => (props.photoCard || ['DRIVER_PHOTO', 'EXCEPTION'].includes(props.kind)) ? types.slice(1) : types)
|
||||
const uploadFormats = computed(() => ['DRIVER_PHOTO', 'EXCEPTION'].includes(props.kind) ? 'JPG、PNG' : 'PDF、JPG、PNG')
|
||||
let generation = 0, previewGeneration = 0, replacing: string | null = null, pendingPopup: Window | null = null
|
||||
function valid(value: DispatchEvidenceFile, expected?: string) {
|
||||
|
||||
@@ -38,26 +38,26 @@ it('offers five whole-dispatch slots and only reports saved after binding succee
|
||||
expect(table.get('tbody tr').text()).toContain('loading.png')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps the original binding and file after an unknown result and never uploads again', async () => {
|
||||
it('unlocks after binding failure and accepts a new upload with a fresh key', async () => {
|
||||
vi.mocked(api.bindLoadingPhoto).mockRejectedValueOnce(new Error('network unknown'))
|
||||
const wrapper = setup(); await flushPromises(); await upload(wrapper)
|
||||
expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
const original = vi.mocked(api.bindLoadingPhoto).mock.calls[0]![2]
|
||||
await wrapper.findAll('button').find(item => item.text() === '核对原请求并继续保存')!.trigger('click'); await flushPromises()
|
||||
expect(api.bindLoadingPhoto).toHaveBeenLastCalledWith('PC/1', 'LOADING_SCENE', original)
|
||||
expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(1)
|
||||
await upload(wrapper)
|
||||
expect(api.bindLoadingPhoto).toHaveBeenLastCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ fileId: original.fileId })); expect(vi.mocked(api.bindLoadingPhoto).mock.calls[1]![2].idempotencyKey).not.toBe(original.idempotencyKey)
|
||||
expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('refreshes a definitive version conflict while retaining the uploaded file', async () => {
|
||||
it('uses the current version when the operator chooses a photo after a conflict', async () => {
|
||||
vi.mocked(api.bindLoadingPhoto).mockRejectedValueOnce(new ApiResponseError('version conflict', 'A0409'))
|
||||
const wrapper = setup(); await flushPromises(); await upload(wrapper)
|
||||
const original = vi.mocked(api.bindLoadingPhoto).mock.calls[0]![2]
|
||||
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ recordVersion: '4' }))
|
||||
vi.mocked(api.bindLoadingPhoto).mockResolvedValue(data({ stage: 'DELIVERING', complete: true, recordVersion: '5' }))
|
||||
await wrapper.findAll('button').find(item => item.text() === '刷新并重试保存')!.trigger('click'); await flushPromises()
|
||||
await upload(wrapper)
|
||||
expect(api.bindLoadingPhoto).toHaveBeenLastCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ recordVersion: '4', fileId: '101' }))
|
||||
expect(vi.mocked(api.bindLoadingPhoto).mock.calls[1]![2].idempotencyKey).not.toBe(original.idempotencyKey)
|
||||
expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(2); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('shows existing photos without exposing upload for a read-only account', async () => {
|
||||
can.mockReturnValue(false)
|
||||
|
||||
@@ -7,19 +7,17 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { bindLoadingPhoto, getLoadingDocuments, loadingPhotoSlots, readLoadingPhoto, recoverLoadingPhoto, uploadLoadingPhoto, type BindLoadingPhoto, type LoadingDocuments, type LoadingPhoto, type LoadingPhotoSlot } from '../api/dispatchLoadingDocuments'
|
||||
import { bindLoadingPhoto, getLoadingDocuments, loadingPhotoSlots, readLoadingPhoto, uploadLoadingPhoto, type LoadingDocuments, type LoadingPhotoSlot } from '../api/dispatchLoadingDocuments'
|
||||
|
||||
const props = defineProps<{ dispatchNo: string }>()
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
const auth = useAuthStore(), access = useAccessPreviewStore()
|
||||
const snapshot = ref<LoadingDocuments | null>(null), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
interface Pending { slot: LoadingPhotoSlot; key: string; photo?: LoadingPhoto; binding?: BindLoadingPhoto; conflict?: boolean }
|
||||
const pending = ref<Pending | null>(null), corrupted = ref(false), images = ref<Record<string, string>>({})
|
||||
const actor = auth.user?.id || '', number = props.dispatchNo, storageKey = `dispatch-loading-documents:${actor}:${number}`
|
||||
const editable = computed(() => !corrupted.value && access.can(permission.dispatchEdit) && snapshot.value?.editable === true)
|
||||
const images = ref<Record<string, string>>({})
|
||||
const actor = auth.user?.id || '', number = props.dispatchNo
|
||||
const editable = computed(() => access.can(permission.dispatchEdit) && snapshot.value?.editable === true)
|
||||
let active = true
|
||||
function ensureScope() { if (!active || auth.user?.id !== actor || props.dispatchNo !== number) throw new Error('资料操作身份已变化,请重新打开') }
|
||||
function persist() { if (pending.value) sessionStorage.setItem(storageKey, JSON.stringify(pending.value)); else sessionStorage.removeItem(storageKey) }
|
||||
function accept(value: LoadingDocuments) { ensureScope(); if (value.dispatchNo !== number) throw new Error('装车资料与排车单不一致'); snapshot.value = value }
|
||||
async function previews() {
|
||||
const photos = snapshot.value?.photos || []
|
||||
@@ -29,62 +27,30 @@ async function previews() {
|
||||
}))
|
||||
}
|
||||
async function load() {
|
||||
if (busy.value) return
|
||||
busy.value = true; error.value = null
|
||||
try { accept(await getLoadingDocuments(number)); await previews() }
|
||||
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '装车资料读取失败') }
|
||||
finally { if (active) busy.value = false }
|
||||
}
|
||||
async function bind() {
|
||||
const item = pending.value
|
||||
if (!item?.photo || !snapshot.value) return
|
||||
if (item.photo.slot !== item.slot) throw new Error('上传结果与装车照片框不一致')
|
||||
if (!item.binding) { item.binding = { recordVersion: snapshot.value.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: item.photo.fileId }; persist() }
|
||||
try {
|
||||
const result = await bindLoadingPhoto(number, item.slot, item.binding)
|
||||
accept(result); pending.value = null; persist(); emit('saved')
|
||||
ElMessage.success(result.stage === 'DELIVERING' && result.complete ? '装车资料已齐全,本车已进入派送中' : '装车照片已保存')
|
||||
await previews()
|
||||
} catch (cause) {
|
||||
const failure = getApiErrorDetails(cause, '装车照片保存失败')
|
||||
if (pending.value && /^(?:A0|HTTP_)?409$/.test(String(failure.code))) { pending.value.conflict = true; persist() }
|
||||
throw cause
|
||||
}
|
||||
}
|
||||
async function recover() {
|
||||
if (busy.value || !editable.value || !pending.value) return
|
||||
async function upload(slot: LoadingPhotoSlot, event: Event) {
|
||||
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
|
||||
if (!file || busy.value || !editable.value) return
|
||||
if (!['image/jpeg','image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) { ElMessage.warning('请选择不超过10MiB的JPG或PNG图片'); return }
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
ensureScope()
|
||||
if (pending.value.conflict) { accept(await getLoadingDocuments(number)); pending.value.binding = undefined; pending.value.conflict = false; persist() }
|
||||
if (!pending.value.photo) { pending.value.photo = await recoverLoadingPhoto(number, pending.value.key); ensureScope(); persist() }
|
||||
await bind()
|
||||
} catch (cause) { if (active) error.value = getApiErrorDetails(cause, '原上传或保存结果尚未确认') }
|
||||
finally { if (active) busy.value = false }
|
||||
const photo = await uploadLoadingPhoto(number, slot, file, crypto.randomUUID()); ensureScope()
|
||||
const latest = await getLoadingDocuments(number); ensureScope(); snapshot.value = latest
|
||||
if (!latest.editable) throw new Error('当前状态不允许修改照片')
|
||||
if (photo.slot !== slot || !photo.fileId) throw new Error('上传结果与照片类别不一致')
|
||||
const result = await bindLoadingPhoto(number, slot, { recordVersion: latest.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: photo.fileId }); ensureScope()
|
||||
snapshot.value = result; emit('saved'); ElMessage.success('照片已保存')
|
||||
await previews()
|
||||
} catch (cause) { if (active) error.value = getApiErrorDetails(cause, '图片上传或保存失败,请重新选择图片') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
async function upload(slot: LoadingPhotoSlot, event: Event) {
|
||||
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
|
||||
if (!file || busy.value || pending.value || !editable.value) return
|
||||
if (!['image/jpeg', 'image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) { ElMessage.warning('请选择不超过10MiB的JPG或PNG图片'); return }
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
ensureScope(); pending.value = { slot, key: crypto.randomUUID() }; persist()
|
||||
pending.value.photo = await uploadLoadingPhoto(number, slot, file, pending.value.key); ensureScope(); persist(); await bind()
|
||||
} catch (cause) {
|
||||
if (active) {
|
||||
error.value = getApiErrorDetails(cause, '装车照片上传或保存失败,请核对原请求')
|
||||
// 文件校验明确拒绝时尚未创建上传意图,允许重新选择;未知结果仍保留原请求。
|
||||
if (pending.value && !pending.value.photo && /^(?:A0|HTTP_)?400$/.test(String(error.value.code))) { pending.value = null; persist() }
|
||||
}
|
||||
}
|
||||
finally { if (active) busy.value = false }
|
||||
}
|
||||
onMounted(() => {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(storageKey)
|
||||
if (raw) { const value = JSON.parse(raw) as Pending; if (!loadingPhotoSlots.includes(value.slot) || typeof value.key !== 'string' || !value.key) throw new Error('原装车照片请求记录无法读取'); pending.value = value }
|
||||
} catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '原装车照片请求记录无法读取') }
|
||||
if (!corrupted.value) void load()
|
||||
})
|
||||
onMounted(() => { void load() })
|
||||
onBeforeUnmount(() => { active = false; Object.values(images.value).forEach(url => URL.revokeObjectURL(url)) })
|
||||
defineExpose({ busy })
|
||||
</script>
|
||||
@@ -92,11 +58,10 @@ defineExpose({ busy })
|
||||
<div v-loading="busy" class="density-dialog-sections">
|
||||
<p v-if="snapshot?.stage === 'LOADING'">可边装车边上传 5 张装车照片;装车确认完成且照片齐全后自动进入派送中。</p>
|
||||
<p v-if="snapshot?.stage === 'AFTER_LOADING'">请上传 5 张装车照片,全部保存成功后自动进入派送中。</p>
|
||||
<LoadErrorState v-if="error" compact title="装车资料操作失败" :message="error.message" :show-retry="!pending && !corrupted" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-if="pending" role="status">有装车照片尚未确认保存。<el-button :disabled="busy || !editable" @click="recover">{{ pending.conflict ? '刷新并重试保存' : '核对原请求并继续保存' }}</el-button></div>
|
||||
<LoadErrorState v-if="error" compact title="装车资料操作失败" :message="error.message" :show-retry="true" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<table class="density-table loading-documents-table" aria-label="装车照片"><thead><tr><th class="photo-actions">操作</th><th class="photo-category">照片类别</th><th>文件名称</th><th class="photo-preview">图片预览</th><th class="photo-time">保存时间</th></tr></thead><tbody>
|
||||
<tr v-for="(slot, index) in loadingPhotoSlots" :key="slot">
|
||||
<td><label v-if="editable" class="photo-upload" :class="{ 'is-disabled': busy || Boolean(pending) }">上传/替换<input type="file" accept="image/jpeg,image/png" :aria-label="`装车照片 ${index + 1}`" :disabled="busy || Boolean(pending)" @change="upload(slot, $event)" /></label><span v-else>—</span></td>
|
||||
<td><label v-if="editable" class="photo-upload" :class="{ 'is-disabled': busy }">上传/替换<input type="file" accept="image/jpeg,image/png" :aria-label="`装车照片 ${index + 1}`" :disabled="busy" @change="upload(slot, $event)" /></label><span v-else>—</span></td>
|
||||
<td>装车照片 {{ index + 1 }}</td>
|
||||
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">{{ photo.fileName }}</div><span v-if="!snapshot?.photos.some(item => item.slot === slot)">未上传</span></td>
|
||||
<td><template v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">
|
||||
|
||||
@@ -7,14 +7,14 @@ import { listDrivers, listVehicles, type VehicleDetail } from '@/modules/oversea
|
||||
const can = vi.hoisted(() => vi.fn(() => true))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('@/modules/overseas-master-data', () => ({ listDrivers: vi.fn(), listVehicles: vi.fn(), overseasMasterDataPermissions: { vehicleList: 'vehicle:list', driverList: 'driver:list' } }))
|
||||
beforeEach(() => { vi.clearAllMocks(); can.mockReturnValue(true) })
|
||||
beforeEach(() => { vi.clearAllMocks(); can.mockReturnValue(true); vi.mocked(listVehicles).mockResolvedValue({ records: [], page: 1, size: 100, total: 0, pages: 0 }) })
|
||||
it.each([['本趟承运公司', '555-0123'], [null, null]])('shows continuous master snapshot fields read-only and uses a dash for missing facts (%s)', async (carrierName, phone) => {
|
||||
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = 'vehicle-1'; value.vehicle.carrierName = carrierName; value.driver.mode = 'MASTER'; value.driver.driverId = 'driver-1'; value.driver.phone = phone
|
||||
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous: true }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
const field = (label: string) => wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)!.getComponent({ name: 'ElInput' })
|
||||
expect(field('拖车公司名称').props('modelValue')).toBe(carrierName || '—'); expect(field('拖车公司名称').props('readonly')).toBe(true)
|
||||
expect(field('联系电话').props('modelValue')).toBe(phone || '—'); expect(field('联系电话').props('readonly')).toBe(true)
|
||||
expect(listVehicles).not.toHaveBeenCalled(); expect(listDrivers).not.toHaveBeenCalled()
|
||||
expect(listVehicles).toHaveBeenCalled(); expect(listDrivers).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('update:vehicle')).toBeUndefined(); expect(wrapper.emitted('update:driver')).toBeUndefined(); wrapper.unmount()
|
||||
})
|
||||
it('keeps master company and phone fields out of the existing non-continuous creation form', async () => {
|
||||
@@ -25,7 +25,7 @@ it('keeps master company and phone fields out of the existing non-continuous cre
|
||||
})
|
||||
it('shows the newly selected authorized vehicle company without copying it into the master command', async () => {
|
||||
const candidate: VehicleDetail = { id: 'vehicle-2', plateNumber: 'CA-200', model: '厢式货车', driverOrDispatchCompany: '新承运公司', phone: null, maxPalletSpaces: 20, maxLoadKg: 18000, maxVolumeCbm: 60, fuelConsumptionPer100Km: 0, mileageKm: 0, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: 1, updatedBy: 'operator', updatedAt: '2026-10-21T10:00:00Z' }
|
||||
vi.mocked(listVehicles).mockResolvedValueOnce({ records: [candidate], page: 1, size: 100, total: 1, pages: 1 })
|
||||
vi.mocked(listVehicles).mockResolvedValue({ records: [candidate], page: 1, size: 100, total: 1, pages: 1 })
|
||||
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = 'vehicle-1'
|
||||
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous: true }, global: { plugins: [ElementPlus] } })
|
||||
const state = wrapper.vm as unknown as { searchVehicles(): Promise<void>; selectVehicle(id: string): void }
|
||||
@@ -54,3 +54,20 @@ it.each([false, true])('requires each directory permission before reading candid
|
||||
const state = wrapper.vm as unknown as { searchVehicles(): Promise<void>; searchDrivers(): Promise<void> }; await state.searchVehicles(); await state.searchDrivers()
|
||||
expect(listVehicles).not.toHaveBeenCalled(); expect(listDrivers).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('缺少司机档案读取权限'); wrapper.unmount()
|
||||
})
|
||||
|
||||
it('filters real unavailable statuses, sends dispatch context, and lets an invalid selection be cleared', async () => {
|
||||
const base: VehicleDetail = { id: 'good', plateNumber: 'GOOD', model: '厢式货车', driverOrDispatchCompany: null, phone: null, maxPalletSpaces: 20, maxLoadKg: 18000, maxVolumeCbm: 60, fuelConsumptionPer100Km: 0, mileageKm: 0, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: 1, updatedBy: 'operator', updatedAt: '2026-10-21T10:00:00Z' }
|
||||
vi.mocked(listVehicles).mockResolvedValue({ records: [base, { ...base, id: 'bad', plateNumber: 'BAD', status: 'MAINTENANCE' }], page: 1, size: 100, total: 2, pages: 1 })
|
||||
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = 'bad'
|
||||
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous: true, dispatchNo: 'PC-REAL' }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('当前车辆已不可用')
|
||||
const state = wrapper.vm as unknown as { searchVehicles(): Promise<void>; selectVehicle(id: string | null): void }
|
||||
await state.searchVehicles(); await flushPromises()
|
||||
expect(listVehicles).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'IN_SERVICE', region: 'OVERSEAS', dispatchNo: 'PC-REAL' }))
|
||||
const options = wrapper.findAllComponents({ name: 'ElOption' })
|
||||
expect(options.find(item => item.props('value') === 'good')?.props('disabled')).toBe(false)
|
||||
expect(options.find(item => item.props('value') === 'bad')?.props('disabled')).toBe(true)
|
||||
state.selectVehicle('bad'); expect(wrapper.emitted('update:vehicle')).toBeUndefined()
|
||||
state.selectVehicle(null); expect(wrapper.emitted('update:vehicle')!.at(-1)![0]).toMatchObject({ vehicleId: null })
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { listDrivers, listVehicles, overseasMasterDataPermissions as resourcePermission, type DriverDetail, type VehicleDetail } from '@/modules/overseas-master-data'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import type { Driver, Vehicle } from '../dispatchContracts'
|
||||
const vehicle = defineModel<Vehicle>('vehicle', { required: true })
|
||||
const driver = defineModel<Driver>('driver', { required: true })
|
||||
defineProps<{ disabled: boolean; continuous?: boolean }>()
|
||||
const props = defineProps<{ disabled: boolean; continuous?: boolean; dispatchNo?: string }>()
|
||||
const access = useAccessPreviewStore(), vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([]), error = ref('')
|
||||
/** 维护弹窗只读展示本趟快照或已读取的当前主档资料,不回写主档字段。 */
|
||||
const masterCarrierName = computed(() => vehicle.value.carrierName?.trim() || vehicles.value.find(row => row.id === vehicle.value.vehicleId)?.driverOrDispatchCompany?.trim() || '—')
|
||||
const masterDriverPhone = computed(() => driver.value.phone?.trim() || drivers.value.find(row => row.id === driver.value.driverId)?.details.phone?.trim() || '—')
|
||||
const currentVehicle = ref<VehicleDetail | null>(null), vehicleUnavailable = ref(false)
|
||||
const vehicleOptions = computed(() => [...vehicles.value, ...(currentVehicle.value && !vehicles.value.some(row => row.id === currentVehicle.value?.id) ? [currentVehicle.value] : [])])
|
||||
const availableVehicle = (row: VehicleDetail) => row.region === 'OVERSEAS' && row.status === 'IN_SERVICE'
|
||||
let eligibilitySequence = 0
|
||||
watch(() => [vehicle.value.mode, vehicle.value.vehicleId, props.dispatchNo] as const, async ([mode, id]) => {
|
||||
const sequence = ++eligibilitySequence
|
||||
currentVehicle.value = null; vehicleUnavailable.value = false
|
||||
if (mode !== 'MASTER' || !id || !vehicle.value.plate || !access.can(resourcePermission.vehicleList)) return
|
||||
try {
|
||||
const result = await listVehicles({ page: 1, size: 100, keyword: vehicle.value.plate, region: 'OVERSEAS', status: 'IN_SERVICE', dispatchNo: props.dispatchNo })
|
||||
if (sequence !== eligibilitySequence) return
|
||||
currentVehicle.value = result.records.find(row => row.id === id && availableVehicle(row)) || null
|
||||
vehicleUnavailable.value = !currentVehicle.value
|
||||
} catch (cause) { if (sequence === eligibilitySequence) error.value = cause instanceof Error ? cause.message : '当前车辆资格读取失败' }
|
||||
}, { immediate: true })
|
||||
let vehicleSequence = 0, driverSequence = 0
|
||||
async function searchVehicles(keyword = '') {
|
||||
if (!access.can(resourcePermission.vehicleList)) { error.value = '缺少车辆档案读取权限'; return }
|
||||
const sequence = ++vehicleSequence
|
||||
try { const result = await listVehicles({ page: 1, size: 100, keyword, region: 'OVERSEAS', status: 'IN_SERVICE' }); if (sequence === vehicleSequence) vehicles.value = result.records }
|
||||
try { const result = await listVehicles({ page: 1, size: 100, keyword, region: 'OVERSEAS', status: 'IN_SERVICE', dispatchNo: props.dispatchNo }); if (sequence === vehicleSequence) vehicles.value = result.records.filter(availableVehicle) }
|
||||
catch (cause) { if (sequence === vehicleSequence) error.value = cause instanceof Error ? cause.message : '车辆候选读取失败' }
|
||||
}
|
||||
async function searchDrivers(keyword = '') {
|
||||
@@ -26,8 +41,10 @@ async function searchDrivers(keyword = '') {
|
||||
}
|
||||
function changeVehicle() { vehicle.value = { mode: vehicle.value.mode, vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] } }
|
||||
function changeDriver() { driver.value = { mode: driver.value.mode, driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } } }
|
||||
function selectVehicle(id: string) {
|
||||
const selected = vehicles.value.find(row => row.id === id)
|
||||
function selectVehicle(id: string | null) {
|
||||
if (!id) { changeVehicle(); return }
|
||||
const selected = vehicleOptions.value.find(row => row.id === id)
|
||||
if (!selected || !availableVehicle(selected)) { error.value = '车辆当前不可用,请重新选择'; return }
|
||||
vehicle.value = { mode: 'MASTER', vehicleId: id, plate: selected?.plateNumber || null, carrierName: null, capacity: null, evidenceFileIds: [] }
|
||||
}
|
||||
function selectDriver(id: string) {
|
||||
@@ -36,10 +53,11 @@ function selectDriver(id: string) {
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<el-alert v-if="vehicleUnavailable" title="当前车辆已不可用,请清除或重新选择车辆;草稿可分步保存。" type="warning" :closable="false" show-icon />
|
||||
<template v-if="continuous">
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
|
||||
<table class="density-table density-table--editable dispatch-resource-table"><tbody>
|
||||
<tr><td><el-form-item label="车辆来源"><template #label><span>车辆来源<InlineHelp title="临时资料说明"><p>临时车辆和司机仅用于本趟,不创建档案。保存排车单后,在本车维护中上传证据,资料齐备后由调度审核。</p></InlineHelp></span></template><el-select v-model="vehicle.mode" :disabled="disabled" @change="changeVehicle"><el-option label="车辆档案" value="MASTER" /><el-option label="临时外配车辆" value="EXTERNAL" /></el-select></el-form-item></td><td><el-form-item v-if="vehicle.mode === 'MASTER'" label="车辆"><el-select v-model="vehicle.vehicleId" filterable remote :remote-method="searchVehicles" :disabled="disabled" placeholder="按车牌搜索" @focus="searchVehicles()" @change="selectVehicle"><el-option v-if="vehicle.vehicleId && !vehicles.some(row => row.id === vehicle.vehicleId)" :value="vehicle.vehicleId" :label="vehicle.plate || '已关联车辆(待核实)'" /><el-option v-for="item in vehicles" :key="item.id" :value="item.id" :label="item.plateNumber" /></el-select></el-form-item><el-form-item v-else label="车牌号"><el-input v-model="vehicle.plate" :disabled="disabled" placeholder="请输入本趟车牌号" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="车辆来源"><template #label><span>车辆来源<InlineHelp title="临时资料说明"><p>临时车辆和司机仅用于本趟,不创建档案。保存排车单后,在本车维护中上传证据,资料齐备后由调度审核。</p></InlineHelp></span></template><el-select v-model="vehicle.mode" :disabled="disabled" @change="changeVehicle"><el-option label="车辆档案" value="MASTER" /><el-option label="临时外配车辆" value="EXTERNAL" /></el-select></el-form-item></td><td><el-form-item v-if="vehicle.mode === 'MASTER'" label="车辆"><el-select v-model="vehicle.vehicleId" clearable filterable remote no-data-text="暂无可用车辆" :remote-method="searchVehicles" :disabled="disabled" placeholder="按车牌搜索" @focus="searchVehicles()" @change="selectVehicle"><el-option v-if="vehicle.vehicleId && !vehicleOptions.some(row => row.id === vehicle.vehicleId)" :value="vehicle.vehicleId" :label="vehicle.plate || '已关联车辆(待核实)'" disabled /><el-option v-for="item in vehicleOptions" :key="item.id" :value="item.id" :label="item.plateNumber" /></el-select></el-form-item><el-form-item v-else label="车牌号"><el-input v-model="vehicle.plate" :disabled="disabled" placeholder="请输入本趟车牌号" /></el-form-item></td></tr>
|
||||
<template v-if="vehicle.mode === 'EXTERNAL'">
|
||||
<tr><td><el-form-item label="拖车公司名称"><el-input v-model="vehicle.carrierName" :disabled="disabled" placeholder="请输入承运公司" /></el-form-item></td><td><el-form-item v-if="vehicle.capacity" label="实际托位容量"><el-input-number v-model="vehicle.capacity.palletSpaces" :disabled="disabled" :min="1" placeholder="审核前核实" controls-position="right" /></el-form-item></td></tr>
|
||||
<tr v-if="vehicle.capacity"><td><el-form-item label="最大载重(kg)"><el-input-number v-model="vehicle.capacity.maxLoadKg" :disabled="disabled" :min="0.001" placeholder="审核前核实" controls-position="right" /></el-form-item></td><td><el-form-item label="最大容积(m³)"><el-input-number v-model="vehicle.capacity.maxVolumeCbm" :disabled="disabled" :min="0.001" placeholder="审核前核实" controls-position="right" /></el-form-item></td></tr>
|
||||
@@ -56,7 +74,7 @@ function selectDriver(id: string) {
|
||||
<p v-if="error" role="alert">{{ error }}</p>
|
||||
<div class="resource-grid">
|
||||
<el-form-item label="车辆来源"><template #label><span>车辆来源<InlineHelp title="临时资料说明"><p>临时车辆和司机仅用于本趟,不创建档案。保存排车单后,在本车维护中上传证据,资料齐备后由调度审核。</p></InlineHelp></span></template><el-select v-model="vehicle.mode" :disabled="disabled" @change="changeVehicle"><el-option label="车辆档案" value="MASTER" /><el-option label="临时外配车辆" value="EXTERNAL" /></el-select></el-form-item>
|
||||
<el-form-item v-if="vehicle.mode === 'MASTER'" label="车辆"><el-select v-model="vehicle.vehicleId" filterable remote :remote-method="searchVehicles" :disabled="disabled" placeholder="按车牌搜索" @focus="searchVehicles()" @change="selectVehicle"><el-option v-if="vehicle.vehicleId && !vehicles.some(row => row.id === vehicle.vehicleId)" :value="vehicle.vehicleId" :label="vehicle.plate || '已关联车辆(待核实)'" /><el-option v-for="item in vehicles" :key="item.id" :value="item.id" :label="item.plateNumber" /></el-select></el-form-item>
|
||||
<el-form-item v-if="vehicle.mode === 'MASTER'" label="车辆"><el-select v-model="vehicle.vehicleId" clearable filterable remote no-data-text="暂无可用车辆" :remote-method="searchVehicles" :disabled="disabled" placeholder="按车牌搜索" @focus="searchVehicles()" @change="selectVehicle"><el-option v-if="vehicle.vehicleId && !vehicleOptions.some(row => row.id === vehicle.vehicleId)" :value="vehicle.vehicleId" :label="vehicle.plate || '已关联车辆(待核实)'" disabled /><el-option v-for="item in vehicleOptions" :key="item.id" :value="item.id" :label="item.plateNumber" /></el-select></el-form-item>
|
||||
<template v-else>
|
||||
<el-form-item label="车牌号"><el-input v-model="vehicle.plate" :disabled="disabled" placeholder="请输入本趟车牌号" /></el-form-item><el-form-item label="拖车公司名称"><el-input v-model="vehicle.carrierName" :disabled="disabled" placeholder="请输入承运公司" /></el-form-item>
|
||||
<template v-if="vehicle.capacity"><el-form-item label="实际托位容量"><el-input-number v-model="vehicle.capacity.palletSpaces" :disabled="disabled" :min="1" placeholder="审核前核实" /></el-form-item><el-form-item label="最大载重(kg)"><el-input-number v-model="vehicle.capacity.maxLoadKg" :disabled="disabled" :min="0.001" placeholder="审核前核实" /></el-form-item><el-form-item label="最大容积(m³)"><el-input-number v-model="vehicle.capacity.maxVolumeCbm" :disabled="disabled" :min="0.001" placeholder="审核前核实" /></el-form-item></template>
|
||||
|
||||
@@ -19,7 +19,7 @@ beforeEach(() => {
|
||||
vi.mocked(recoverVehiclePhoto).mockResolvedValue(photo)
|
||||
vi.mocked(bindVehiclePhoto).mockResolvedValue({ ...response('4'), photos: [{ ...photo, savedAt: '2026-10-21T00:00:00Z' }] })
|
||||
})
|
||||
it('binds the uploaded file to the car and restores the original binding after reopening without uploading twice', async () => {
|
||||
it('does not restore failed bindings after reopening and allows a new upload', async () => {
|
||||
vi.mocked(bindVehiclePhoto).mockRejectedValueOnce(new Error('network timeout'))
|
||||
const first = setup(); await flushPromises(); await upload(first)
|
||||
const table = first.get('table[aria-label="油耗图片"]')
|
||||
@@ -29,33 +29,33 @@ it('binds the uploaded file to the car and restores the original binding after r
|
||||
expect(original).toMatchObject({ recordVersion: '3', fileId: photo.fileId })
|
||||
first.unmount()
|
||||
const reopened = setup(); await flushPromises()
|
||||
await reopened.findAll('button').find(button => button.text() === '核对原请求并继续保存')!.trigger('click'); await flushPromises()
|
||||
expect(bindVehiclePhoto).toHaveBeenLastCalledWith('PC/1', 'FUEL_START', original)
|
||||
expect(uploadVehiclePhoto).toHaveBeenCalledTimes(1)
|
||||
await upload(reopened)
|
||||
expect(vi.mocked(bindVehiclePhoto).mock.calls[1]![2].idempotencyKey).not.toBe(original.idempotencyKey)
|
||||
expect(uploadVehiclePhoto).toHaveBeenCalledTimes(2)
|
||||
expect(reopened.text()).toContain('fuel.png')
|
||||
expect(reopened.get('table tbody tr').text()).toContain('2026-10-21 00:00:00Z')
|
||||
expect(sessionStorage.length).toBe(0)
|
||||
reopened.unmount()
|
||||
})
|
||||
it('keeps uncertain uploads for original-key recovery and never resubmits the file', async () => {
|
||||
it('unlocks uncertain uploads for a new file selection', async () => {
|
||||
vi.mocked(uploadVehiclePhoto).mockRejectedValueOnce(new Error('connection lost'))
|
||||
const wrapper = setup(); await flushPromises(); await upload(wrapper)
|
||||
const originalKey = vi.mocked(uploadVehiclePhoto).mock.calls[0]![3]
|
||||
await wrapper.findAll('button').find(button => button.text() === '核对原请求并继续保存')!.trigger('click'); await flushPromises()
|
||||
expect(recoverVehiclePhoto).toHaveBeenCalledWith('PC/1', originalKey)
|
||||
expect(uploadVehiclePhoto).toHaveBeenCalledTimes(1)
|
||||
await upload(wrapper)
|
||||
expect(recoverVehiclePhoto).not.toHaveBeenCalled(); expect(vi.mocked(uploadVehiclePhoto).mock.calls[1]![3]).not.toBe(originalKey)
|
||||
expect(uploadVehiclePhoto).toHaveBeenCalledTimes(2)
|
||||
expect(bindVehiclePhoto).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('refreshes only a known version conflict and reuses the uploaded file', async () => {
|
||||
it('allows a new photo and current version after a binding conflict', async () => {
|
||||
vi.mocked(bindVehiclePhoto).mockRejectedValueOnce(new ApiResponseError('运营记录版本已变化,请刷新后重试', 'A0409', 409))
|
||||
const wrapper = setup(); await flushPromises(); await upload(wrapper)
|
||||
const originalKey = vi.mocked(bindVehiclePhoto).mock.calls[0]![2].idempotencyKey
|
||||
vi.mocked(getVehicleDocuments).mockResolvedValue(response('8'))
|
||||
await wrapper.findAll('button').find(button => button.text() === '刷新版本并保存已上传图片')!.trigger('click'); await flushPromises()
|
||||
await upload(wrapper)
|
||||
expect(bindVehiclePhoto).toHaveBeenLastCalledWith('PC/1', 'FUEL_START', { recordVersion: '8', fileId: photo.fileId, idempotencyKey: expect.any(String) })
|
||||
expect(vi.mocked(bindVehiclePhoto).mock.calls[1]![2].idempotencyKey).not.toBe(originalKey)
|
||||
expect(uploadVehiclePhoto).toHaveBeenCalledTimes(1)
|
||||
expect(uploadVehiclePhoto).toHaveBeenCalledTimes(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('does not offer upload with list permission alone', async () => {
|
||||
|
||||
@@ -7,70 +7,41 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { bindVehiclePhoto, getVehicleDocuments, readVehiclePhoto, recoverVehiclePhoto, uploadVehiclePhoto, type BindVehiclePhoto, type FuelSlot, type VehicleDocuments, type VehiclePhoto } from '../api/dispatchVehicleDocuments'
|
||||
import { bindVehiclePhoto, getVehicleDocuments, readVehiclePhoto, uploadVehiclePhoto, type FuelSlot, type VehicleDocuments, type VehiclePhoto } from '../api/dispatchVehicleDocuments'
|
||||
|
||||
const props = defineProps<{ dispatchNo: string }>()
|
||||
const auth = useAuthStore(), access = useAccessPreviewStore()
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
const snapshot = ref<VehicleDocuments | null>(null), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
interface Pending { slot: FuelSlot; key: string; photo?: VehiclePhoto; binding?: BindVehiclePhoto; versionConflict?: boolean }
|
||||
const pending = ref<Pending | null>(null), preview = ref(''), corrupted = ref(false)
|
||||
const preview = ref('')
|
||||
const actor = auth.user?.id || '', number = props.dispatchNo
|
||||
const storageKey = `dispatch-vehicle-documents:${actor}:${number}`
|
||||
const editable = computed(() => !corrupted.value && access.can(permission.dispatchEdit) && snapshot.value?.editable === true)
|
||||
const editable = computed(() => access.can(permission.dispatchEdit) && snapshot.value?.editable === true)
|
||||
const roles: Array<{ slot: FuelSlot; label: string }> = [{ slot: 'FUEL_START', label: '起始油耗表盘' }, { slot: 'FUEL_END', label: '结束油耗表盘' }]
|
||||
let active = true
|
||||
function ensureScope() { if (!active || auth.user?.id !== actor || props.dispatchNo !== number) throw new Error('资料操作身份已变化,请重新打开') }
|
||||
function persist() { if (pending.value) sessionStorage.setItem(storageKey, JSON.stringify(pending.value)); else sessionStorage.removeItem(storageKey) }
|
||||
async function load() {
|
||||
if (busy.value) return
|
||||
busy.value = true; error.value = null
|
||||
try { const result = await getVehicleDocuments(number); ensureScope(); if (result.dispatchNo !== number) throw new Error('车辆资料与排车单不一致'); snapshot.value = result }
|
||||
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '车辆资料读取失败') }
|
||||
finally { if (active) busy.value = false }
|
||||
}
|
||||
async function bind() {
|
||||
const item = pending.value
|
||||
if (!item?.photo || !snapshot.value) return
|
||||
if (item.photo.slot !== item.slot) throw new Error('上传结果与图片分类不一致')
|
||||
if (!item.binding) { item.binding = { recordVersion: snapshot.value.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: item.photo.fileId }; persist() }
|
||||
let result: VehicleDocuments
|
||||
try { result = await bindVehiclePhoto(number, item.slot, item.binding) }
|
||||
catch (cause) {
|
||||
const details = getApiErrorDetails(cause, '图片保存失败')
|
||||
if (details.code === 'A0409' && /记录版本已变化|记录版本竞争失败/.test(details.message)) { item.versionConflict = true; persist() }
|
||||
throw cause
|
||||
}
|
||||
ensureScope(); snapshot.value = result; pending.value = null; persist(); emit('saved'); ElMessage.success('油耗图片已保存')
|
||||
}
|
||||
async function recover() {
|
||||
if (busy.value || !editable.value || !pending.value) return
|
||||
busy.value = true; error.value = null
|
||||
try { ensureScope(); if (!pending.value.photo) { pending.value.photo = await recoverVehiclePhoto(number, pending.value.key); ensureScope(); persist() }; await bind() }
|
||||
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '原上传或保存结果尚未确认') }
|
||||
finally { if (active) busy.value = false }
|
||||
}
|
||||
async function refreshBinding() {
|
||||
if (busy.value || !editable.value || !pending.value?.versionConflict) return
|
||||
await load()
|
||||
if (error.value || !pending.value) return
|
||||
pending.value.binding = undefined; pending.value.versionConflict = false; persist()
|
||||
await recover()
|
||||
}
|
||||
async function upload(slot: FuelSlot, event: Event) {
|
||||
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
|
||||
if (!file || busy.value || pending.value || !editable.value) return
|
||||
if (!['image/jpeg', 'image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) { ElMessage.warning('请选择不超过10MiB的JPG或PNG图片'); return }
|
||||
if (!file || busy.value || !editable.value) return
|
||||
if (!['image/jpeg','image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) { ElMessage.warning('请选择不超过10MiB的JPG或PNG图片'); return }
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
ensureScope(); pending.value = { slot, key: crypto.randomUUID() }; persist()
|
||||
pending.value.photo = await uploadVehiclePhoto(number, slot, file, pending.value.key); ensureScope(); persist(); await bind()
|
||||
} catch (cause) {
|
||||
if (active) {
|
||||
error.value = getApiErrorDetails(cause, '图片上传或保存失败,请核对原请求')
|
||||
if (!pending.value?.photo && ['A0400', 'A0403'].includes(error.value.code || '')) { pending.value = null; persist() }
|
||||
}
|
||||
}
|
||||
finally { if (active) busy.value = false }
|
||||
ensureScope()
|
||||
const photo = await uploadVehiclePhoto(number, slot, file, crypto.randomUUID()); ensureScope()
|
||||
const latest = await getVehicleDocuments(number); ensureScope(); snapshot.value = latest
|
||||
if (!latest.editable) throw new Error('当前状态不允许修改照片')
|
||||
if (photo.slot !== slot || !photo.fileId) throw new Error('上传结果与照片类别不一致')
|
||||
const result = await bindVehiclePhoto(number, slot, { recordVersion: latest.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: photo.fileId }); ensureScope()
|
||||
snapshot.value = result; emit('saved'); ElMessage.success('照片已保存')
|
||||
|
||||
} catch (cause) { if (active) error.value = getApiErrorDetails(cause, '图片上传或保存失败,请重新选择图片') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
async function view(photo: VehiclePhoto) {
|
||||
if (busy.value) return
|
||||
@@ -79,23 +50,16 @@ async function view(photo: VehiclePhoto) {
|
||||
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '图片读取失败') }
|
||||
finally { if (active) busy.value = false }
|
||||
}
|
||||
onMounted(() => {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(storageKey)
|
||||
if (raw) { const value = JSON.parse(raw) as Pending; if (!roles.some(role => role.slot === value.slot) || typeof value.key !== 'string' || !value.key) throw new Error('原图片请求记录无法读取'); pending.value = value }
|
||||
} catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '原图片请求记录无法读取') }
|
||||
if (!corrupted.value) void load()
|
||||
})
|
||||
onMounted(() => { void load() })
|
||||
onBeforeUnmount(() => { active = false; if (preview.value) URL.revokeObjectURL(preview.value) })
|
||||
defineExpose({ busy })
|
||||
</script>
|
||||
<template>
|
||||
<div v-loading="busy" class="density-dialog-sections">
|
||||
<LoadErrorState v-if="error" compact title="车辆资料操作失败" :message="error.message" :show-retry="!pending && !corrupted" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-if="pending" role="status">有图片尚未确认保存。<el-button v-if="pending.versionConflict" :disabled="busy || !editable" @click="refreshBinding">刷新版本并保存已上传图片</el-button><el-button v-else :disabled="busy || !editable" @click="recover">核对原请求并继续保存</el-button></div>
|
||||
<LoadErrorState v-if="error" compact title="车辆资料操作失败" :message="error.message" :show-retry="true" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<table class="density-table fuel-documents-table" aria-label="油耗图片"><thead><tr><th class="photo-actions">操作</th><th class="photo-category">图片类别</th><th>文件名称</th><th class="photo-time">保存时间</th></tr></thead><tbody>
|
||||
<tr v-for="role in roles" :key="role.slot">
|
||||
<td><label v-if="editable" class="photo-upload" :class="{ 'is-disabled': busy || Boolean(pending) }">上传/替换<input type="file" accept="image/jpeg,image/png" :aria-label="role.label" :disabled="busy || Boolean(pending)" @change="upload(role.slot, $event)" /></label><span v-else>—</span></td>
|
||||
<td><label v-if="editable" class="photo-upload" :class="{ 'is-disabled': busy }">上传/替换<input type="file" accept="image/jpeg,image/png" :aria-label="role.label" :disabled="busy" @change="upload(role.slot, $event)" /></label><span v-else>—</span></td>
|
||||
<td>{{ role.label }}</td>
|
||||
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === role.slot) || []" :key="photo.fileId"><el-button link :disabled="busy" @click="view(photo)">{{ photo.fileName }}</el-button></div><span v-if="!snapshot?.photos.some(item => item.slot === role.slot)">未上传</span></td>
|
||||
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === role.slot) || []" :key="photo.fileId">{{ photo.savedAt?.replace('T', ' ') || '—' }}</div><span v-if="!snapshot?.photos.some(item => item.slot === role.slot)">—</span></td>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { DispatchDetail, Driver, Vehicle } from '../dispatchContracts'
|
||||
import type { DispatchVehicleMaintenance } from '../dispatchVehicleMaintenance'
|
||||
import type { DispatchDestinationAppointment, DispatchVehicleMaintenance } from '../dispatchVehicleMaintenance'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import DispatchResourceFields from './DispatchResourceFields.vue'
|
||||
|
||||
defineProps<{ detail: DispatchDetail; disabled: boolean }>()
|
||||
const props = defineProps<{ detail: DispatchDetail; disabled: boolean }>()
|
||||
const vehicle = defineModel<Vehicle>('vehicle', { required: true })
|
||||
const driver = defineModel<Driver>('driver', { required: true })
|
||||
const maintenance = defineModel<DispatchVehicleMaintenance>('maintenance', { required: true })
|
||||
@@ -17,6 +17,12 @@ const deliveryMethods = ['FBA自提', '客户自提', '卡派打托', '卡派地
|
||||
const pickupMethods = ['一提一卸', '一提多卸']
|
||||
const loadTypes = ['托盘', '地板', '混装']
|
||||
const vehicleTypes = ['厢式货车', '平板车', '集装箱卡车']
|
||||
const appointmentFacts = computed(() => new Map(props.detail.dispatchInfo.fbaAppointments.map(row => [row.destinationKey, row])))
|
||||
/** 修改编号后不继续显示原编号的确认时间,保存后由服务端重新匹配正式确认。 */
|
||||
function confirmedTime(appointment: DispatchDestinationAppointment) {
|
||||
const fact = appointmentFacts.value.get(appointment.destinationKey)
|
||||
return formatOverseasDateTime(fact?.appointmentNo === appointment.appointmentNo?.trim() ? fact?.confirmedAt : null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -26,7 +32,7 @@ const vehicleTypes = ['厢式货车', '平板车', '集装箱卡车']
|
||||
<section class="density-dialog-section" aria-label="本车资源登记">
|
||||
<h3 class="density-dialog-section__title">车辆与司机</h3>
|
||||
<table class="density-table density-table--editable maintenance-table"><tbody><tr><td><el-form-item label="排车单号"><el-input :model-value="detail.dispatchNo" readonly /></el-form-item></td><td><el-form-item label="发车时间"><el-input :model-value="formatOverseasDateTime(detail.dispatchInfo.departedAt)" readonly /></el-form-item></td></tr></tbody></table>
|
||||
<DispatchResourceFields continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="disabled" />
|
||||
<DispatchResourceFields :dispatch-no="detail.dispatchNo" continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="disabled" />
|
||||
</section>
|
||||
<section class="density-dialog-section" aria-label="本车时间登记">
|
||||
<h3 class="density-dialog-section__title">排车时间</h3>
|
||||
@@ -42,17 +48,17 @@ const vehicleTypes = ['厢式货车', '平板车', '集装箱卡车']
|
||||
<el-form-item label="车队邮箱"><el-input v-model="maintenance.fleetEmail" :disabled="disabled" maxlength="254" placeholder="请输入车队邮箱" /></el-form-item>
|
||||
<el-form-item label="车辆类型"><el-select v-model="maintenance.vehicleType" :disabled="disabled" clearable placeholder="请选择车辆类型"><el-option v-for="value in vehicleTypes" :key="value" :label="value" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="预约账号"><el-input v-model="maintenance.appointmentAccount" :disabled="disabled" maxlength="128" placeholder="请输入预约账号" /></el-form-item>
|
||||
<el-form-item label="预约号"><el-input v-model="maintenance.appointmentNo" :disabled="disabled" maxlength="128" placeholder="请输入预约号" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item v-if="detail.taskType === 'DELIVERY'" label="散板需求"><el-checkbox v-model="publishLooseboardDemand" :disabled="disabled">发布公共散板需求</el-checkbox></el-form-item>
|
||||
<table v-if="detail.dispatchInfo.fbaAppointments.length" class="density-table"><thead><tr><th>目的仓</th><th>已确认预约号</th><th>确认送达时间</th></tr></thead><tbody><tr v-for="(appointment, index) in detail.dispatchInfo.fbaAppointments" :key="appointment.batchKey + '-' + index"><td>{{ appointment.destinationWarehouseCode || '—' }}</td><td>{{ appointment.appointmentNo || appointment.batchNo || '—' }}</td><td>{{ formatOverseasDateTime(appointment.confirmedAt) }}</td></tr></tbody></table>
|
||||
<table v-if="maintenance.destinationAppointments.length" class="density-table density-table--editable" aria-label="目的仓预约"><thead><tr><th>目的仓</th><th>预约号</th><th>确认送达时间</th><th>货件号</th></tr></thead><tbody>
|
||||
<tr v-for="appointment in maintenance.destinationAppointments" :key="appointment.destinationKey"><td>{{ appointmentFacts.get(appointment.destinationKey)?.destinationWarehouseCode || '—' }}</td><td><el-form-item :label="`${appointmentFacts.get(appointment.destinationKey)?.destinationWarehouseCode || '目的仓'} 预约号`"><el-input v-model="appointment.appointmentNo" :disabled="disabled" maxlength="128" placeholder="填写已约好的编号,提交审核前必填" /></el-form-item></td><td>{{ confirmedTime(appointment) }}</td><td>{{ appointmentFacts.get(appointment.destinationKey)?.shipmentNumbers?.join('、') || '—' }}</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" aria-label="本车费用信息"><h3 class="density-dialog-section__title">费用信息</h3><slot name="fees"><p class="density-dialog-note">{{ detail.documents.deliveries.length ? '费用以关联派送单的正式费用记录为准。' : '尚未生成派送单费用。' }}</p></slot></section>
|
||||
<section class="density-dialog-section" aria-label="车辆维护附件"><h3 class="density-dialog-section__title">附件</h3><slot name="attachments" /></section>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="`关联订单 (${detail.orderDetails.length})`" name="orders">
|
||||
<slot name="appointments" />
|
||||
<div class="maintenance-orders"><table class="density-table"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th><th>重量(kg)</th><th>托数</th><th>目的FBA仓</th><th>体积(m³)</th><th>集装箱号</th></tr></thead><tbody><tr v-for="(order, index) in detail.orderDetails" :key="`${order.sourceKey}-${order.orderNo}-${index}`"><td>{{ order.customerName || '—' }}</td><td>{{ order.orderNo || '—' }}</td><td>{{ order.inboundCode || '—' }}</td><td>{{ order.grossWeightKg ?? '—' }}</td><td>{{ order.palletCount ?? '—' }}</td><td>{{ order.destinationFbaWarehouse || '—' }}</td><td>{{ order.volumeCbm ?? '—' }}</td><td>{{ order.containerNo || '—' }}</td></tr><tr v-if="!detail.orderDetails.length"><td colspan="8">暂无关联订单</td></tr></tbody></table></div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
@@ -53,11 +53,28 @@ async function render(action: DispatchAction, operation?: 'ROUTE', files = false
|
||||
function field(wrapper: Pick<VueWrapper, 'findAllComponents'>, label: string) { return wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
|
||||
|
||||
describe('dispatch workflow native layout', () => {
|
||||
it('brings ten orders at one destination into one appointment input with confirmed time and all shipments', async () => {
|
||||
const initial = detail(), shipmentNumbers = Array.from({ length: 10 }, (_, index) => `SHIP-${index}`)
|
||||
const appointment = { batchKey: null, batchNo: null, customerId: null, appointmentNo: 'ISA-ONE', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'SUCCESS', confirmedAt: '2026-10-25T17:00:00Z', blockReason: null, shipmentNumbers }
|
||||
const { dialog } = await render('EDIT', undefined, false, { orderDetails: shipmentNumbers.map((_, index) => ({ ...initial.orderDetails[0]!, orderNo: `LM-${index}`, customerId: `C-${index}` })), dispatchInfo: { ...initial.dispatchInfo, fbaAppointments: [appointment] } })
|
||||
const table = dialog.get('[aria-label="目的仓预约"]')
|
||||
expect(table.findAll('tbody tr')).toHaveLength(1)
|
||||
expect(table.findAll('input')).toHaveLength(1)
|
||||
expect((table.get('input').element as HTMLInputElement).value).toBe('ISA-ONE')
|
||||
expect(table.findAll('tbody td')[2]!.text()).not.toBe('—')
|
||||
for (const shipment of shipmentNumbers) expect(table.text()).toContain(shipment)
|
||||
await table.get('input').setValue('ISA-CHANGED')
|
||||
expect(table.findAll('tbody td')[2]!.text()).toBe('—')
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ maintenance: expect.objectContaining({ destinationAppointments: [{ destinationKey: 'FBA:ONT8', appointmentNo: 'ISA-CHANGED' }] }) }))
|
||||
})
|
||||
it('restores saved maintenance facts and shows associated orders without editable departure facts', async () => {
|
||||
const saved = { ...blankVehicleMaintenance(), plannedDeliveryDate: '2026-11-02', fleetEmail: 'fleet@example.test', appointmentNo: 'APPT-SAVED' }
|
||||
const { dialog } = await render('EDIT', undefined, false, { vehicleInfo: { ...resources(), maintenance: saved } })
|
||||
const saved = { ...blankVehicleMaintenance(), plannedDeliveryDate: '2026-11-02', fleetEmail: 'fleet@example.test', destinationAppointments: [{ destinationKey: 'FBA:ONT8', appointmentNo: 'CONF-REAL' }] }
|
||||
const { dialog } = await render('EDIT', undefined, false, { vehicleInfo: { ...resources(), maintenance: saved }, dispatchInfo: { ...detail().dispatchInfo, fbaAppointments: [{ batchKey: 'FBA_APPOINTMENT:1', customerId: 'A', shipmentNumbers: ['SHIP-REAL'], batchNo: 'AP-1', appointmentNo: 'CONF-REAL', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'SUCCESS', confirmedAt: '2026-10-25T17:00:00Z', blockReason: null }] } })
|
||||
expect((field(dialog, '车队邮箱').get('input').element as HTMLInputElement).value).toBe('fleet@example.test')
|
||||
expect((field(dialog, '预约号').get('input').element as HTMLInputElement).value).toBe('APPT-SAVED')
|
||||
expect((field(dialog, 'ONT8 预约号').get('input').element as HTMLInputElement).value).toBe('CONF-REAL')
|
||||
expect(dialog.findAll('[aria-label="目的仓预约"] tbody tr')).toHaveLength(1)
|
||||
expect(dialog.text()).not.toContain('关联正式预约')
|
||||
expect(dialog.text()).toContain('关联订单 (1)')
|
||||
const orders = dialog.findAll('[role="tab"]').find(tab => tab.text().includes('关联订单'))!
|
||||
await orders.trigger('click')
|
||||
@@ -67,6 +84,29 @@ describe('dispatch workflow native layout', () => {
|
||||
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ maintenance: saved }))
|
||||
expect(vi.mocked(api.updateDispatch).mock.calls[0]![1]).not.toHaveProperty('departedAt')
|
||||
})
|
||||
it('edits one number per destination and can save an incomplete second destination', async () => {
|
||||
const destinations = ['ONT8', 'LGB8'].map(code => ({ batchKey: null, batchNo: null, customerId: null, appointmentNo: null, destinationKey: `FBA:${code}`, destinationWarehouseCode: code, status: 'PENDING', confirmedAt: null, blockReason: null, shipmentNumbers: [] }))
|
||||
const initial = detail()
|
||||
const { dialog } = await render('EDIT', undefined, false, { orderDetails: [...initial.orderDetails, { ...initial.orderDetails[0]!, customerId: 'OTHER', orderNo: 'LM-OTHER' }], dispatchInfo: { ...initial.dispatchInfo, fbaAppointments: destinations } })
|
||||
expect(dialog.findAll('[aria-label="目的仓预约"] tbody tr')).toHaveLength(2)
|
||||
await field(dialog, 'ONT8 预约号').get('input').setValue(' ISA-ONT8 ')
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ maintenance: expect.objectContaining({ destinationAppointments: [{ destinationKey: 'FBA:ONT8', appointmentNo: 'ISA-ONT8' }, { destinationKey: 'FBA:LGB8', appointmentNo: null }] }) }))
|
||||
})
|
||||
it('keeps input editable after rejection and adds concurrent destinations when current facts are refreshed', async () => {
|
||||
const appointment = { batchKey: null, batchNo: null, customerId: null, appointmentNo: null, destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'PENDING', confirmedAt: null, blockReason: null, shipmentNumbers: [] }
|
||||
const initial = detail()
|
||||
const { dialog } = await render('EDIT', undefined, false, { dispatchInfo: { ...initial.dispatchInfo, fbaAppointments: [appointment] } })
|
||||
await field(dialog, 'ONT8 预约号').get('input').setValue('ISA-KEEP')
|
||||
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new ApiResponseError('排车版本已变化', 'A0409', 200))
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 2, actions, dispatchInfo: { ...initial.dispatchInfo, fbaAppointments: [appointment, { ...appointment, destinationKey: 'FBA:LGB8', destinationWarehouseCode: 'LGB8' }] } }))
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(field(dialog, 'ONT8 预约号').get('input').attributes('disabled')).toBeUndefined()
|
||||
dialog.getComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
|
||||
expect((field(dialog, 'ONT8 预约号').get('input').element as HTMLInputElement).value).toBe('ISA-KEEP')
|
||||
expect(field(dialog, 'LGB8 预约号')).toBeDefined()
|
||||
expect(dialog.findAll('[aria-label="目的仓预约"] tbody tr')).toHaveLength(2)
|
||||
})
|
||||
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('blocks %s approval for missing appointment but keeps rejection available', async action => {
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(detail({ actions: { ...actions, [action]: { allowed: true, blockCode: 'APPROVAL_BLOCKED', blockReason: '预约收货时间尚未确认' } } }))
|
||||
const wrapper = mount(DispatchWorkflowDialog, { attachTo: document.body, props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
@@ -90,13 +130,12 @@ describe('dispatch workflow native layout', () => {
|
||||
for (const [label, value] of [['派送方式', '外配'], ['提货方式', '一提多卸'], ['装车类型', '混装'], ['车辆类型', '厢式货车']]) field(dialog, label!).getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', value)
|
||||
await field(dialog, '车队邮箱').get('input').setValue(' fleet@example.test ')
|
||||
await field(dialog, '预约账号').get('input').setValue(' fleet-account ')
|
||||
await field(dialog, '预约号').get('input').setValue(' APPT-NEW ')
|
||||
expect(field(dialog, '发车时间').get('input').attributes('readonly')).toBeDefined()
|
||||
expect(dialog.find('textarea[aria-label="本次操作备注"]').exists()).toBe(false)
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
const { vehicleStatus: _status, ...vehicle } = resources().vehicle
|
||||
expect(api.updateDispatch).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, publishLooseboardDemand: true, idempotencyKey: expect.any(String), vehicle: { ...vehicle, plate: 'CA-NEW', capacity: { ...vehicle.capacity, palletSpaces: 22 } }, driver: { ...resources().driver, name: '新司机' }, maintenance: { plannedDeliveryDate: '2026-11-02', deliveryMethod: '外配', pickupMethod: '一提多卸', loadType: '混装', fleetEmail: 'fleet@example.test', vehicleType: '厢式货车', appointmentAccount: 'fleet-account', appointmentNo: 'APPT-NEW' }, plannedDepartureAt: '2026-11-01T10:00:00Z', estimatedArrivalAt: null, remark: '真实备注' })
|
||||
expect(api.updateDispatch).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, publishLooseboardDemand: true, idempotencyKey: expect.any(String), vehicle: { ...vehicle, plate: 'CA-NEW', capacity: { ...vehicle.capacity, palletSpaces: 22 } }, driver: { ...resources().driver, name: '新司机' }, maintenance: { plannedDeliveryDate: '2026-11-02', deliveryMethod: '外配', pickupMethod: '一提多卸', loadType: '混装', fleetEmail: 'fleet@example.test', vehicleType: '厢式货车', appointmentAccount: 'fleet-account', destinationAppointments: [] }, plannedDepartureAt: '2026-11-01T10:00:00Z', estimatedArrivalAt: null, remark: '真实备注' })
|
||||
})
|
||||
it('saves all three resource photos before submitting review with the refreshed version', async () => {
|
||||
const { dialog } = await render('EDIT')
|
||||
@@ -128,8 +167,15 @@ describe('dispatch workflow native layout', () => {
|
||||
})
|
||||
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('keeps %s facts read-only and requires a rejection reason', async action => {
|
||||
const { dialog } = await render(action)
|
||||
expect(dialog.props('width')).toBe('1100px')
|
||||
expect(dialog.get('[aria-label="本车审核依据"]').findAll('input')).toHaveLength(0)
|
||||
expect(dialog.get('[aria-label="本车审核依据"]').find('h3').exists()).toBe(false)
|
||||
expect(dialog.get('[aria-label="本车审核依据"] h3').text()).toBe('车辆与货物')
|
||||
expect(dialog.get('[aria-label="本车审核依据"]').text()).toContain('联系电话')
|
||||
expect(dialog.get('[aria-label="排车与预约"]').findAll('input')).toHaveLength(0)
|
||||
const photos = dialog.get('[aria-label="审核照片"]').findAllComponents(DispatchEvidenceFiles)
|
||||
expect(photos.map(photo => photo.props('label'))).toEqual(['车辆图片', '司机照片', '驾驶证照片'])
|
||||
expect(photos.every(photo => photo.props('readonly'))).toBe(true)
|
||||
expect(dialog.get('[aria-label="操作原因与意见"] h3').text()).toBe('审核意见')
|
||||
expect(dialog.text()).not.toContain('请核对本车资源与本趟证据')
|
||||
expect(dialog.findAll('button').some(button => button.text() === '取消')).toBe(false)
|
||||
expect(dialog.findComponent({ name: 'ElRadioGroup' }).exists()).toBe(false)
|
||||
@@ -191,6 +237,8 @@ describe('dispatch workflow native layout', () => {
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
const original = structuredClone(vi.mocked(api.updateDispatch).mock.calls[0]![1])
|
||||
expect(original).toMatchObject({ vehicle: { evidenceFileIds: ['301', '401'] }, driver: { evidence: { photoFileIds: ['402'], fileIds: expect.arrayContaining(['403']) } } })
|
||||
expect(dialog.props('confirmLoading')).toBe(false)
|
||||
dialog.getComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
const next=vi.mocked(api.updateDispatch).mock.calls[1]![1]
|
||||
expect(next).toMatchObject({...original,recordVersion:1,idempotencyKey:expect.any(String)})
|
||||
@@ -232,9 +280,9 @@ describe('dispatch workflow native layout', () => {
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.reorderDispatchRoute).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), destinationKeys: ['B', 'A'], reason: '顺序调整' })
|
||||
})
|
||||
it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('replaces the old %s editor with the smart dispatch entry notice', async action => {
|
||||
it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('keeps the retired %s editor unavailable', async action => {
|
||||
const { dialog } = await render(action)
|
||||
expect(dialog.text()).toContain('货物安排统一在智能排车中修改')
|
||||
expect(dialog.text()).toContain('当前入口不支持调整已建排车货物')
|
||||
expect(dialog.props('confirmDisabled')).toBe(true)
|
||||
expect(dialog.findComponent({ name: 'ElForm' }).exists()).toBe(false)
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
|
||||
@@ -11,7 +11,6 @@ const identity = vi.hoisted(() => ({ id: '' }))
|
||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
|
||||
vi.mock('./DispatchAppointmentAllocationsDialog.vue', () => ({ default: { name: 'DispatchAppointmentAllocationsDialog', props: ['modelValue', 'dispatchNo'], emits: ['saved', 'update:modelValue'], template: '<div />' } }))
|
||||
vi.mock('../api/dispatches', () => ({ markDispatchException: vi.fn(), getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn() }))
|
||||
interface State { exception: { exceptionType: string; returnDirection: string; description: string; imageFileIds: string[] }; submit(submitReviewAfterSave?: boolean): Promise<void>; retryError(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string; publishLooseboardDemand: boolean }; detail: DispatchDetail }
|
||||
async function open(action: DispatchAction, allowed = true) {
|
||||
@@ -19,19 +18,7 @@ async function open(action: DispatchAction, allowed = true) {
|
||||
const wrapper = mount(DispatchWorkflowDialog, { props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
return { wrapper, state: wrapper.vm as unknown as State }
|
||||
}
|
||||
beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(api.getDispatchLoading).mockResolvedValue(loadingPreview()) })
|
||||
it('opens appointment linking from vehicle maintenance and refreshes the original version without losing unsaved inputs', async () => {
|
||||
const { wrapper, state } = await open('EDIT')
|
||||
state.form.remark = '尚未保存的车辆备注'
|
||||
const button = wrapper.findAll('button').find(button => button.text() === '关联正式预约')
|
||||
expect(button).toBeDefined(); await button!.trigger('click'); await flushPromises()
|
||||
const child = wrapper.findComponent({ name: 'DispatchAppointmentAllocationsDialog' })
|
||||
expect(child.props('dispatchNo')).toBe('PC/1')
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 2 }))
|
||||
child.vm.$emit('saved'); await flushPromises()
|
||||
expect(state.detail.recordVersion).toBe(2); expect(state.form.remark).toBe('尚未保存的车辆备注')
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
beforeEach(() => { vi.resetAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(api.getDispatchLoading).mockResolvedValue(loadingPreview()) })
|
||||
it('confirms scanned cargo without requesting measurements and preserves native version zero', async () => {
|
||||
const value = loadingPreview(); value.pallets[0]!.weightKg = 100; value.pallets[0]!.volumeCbm = 2
|
||||
vi.mocked(api.getDispatchLoading).mockResolvedValue(value)
|
||||
@@ -103,17 +90,17 @@ it('refuses a route that drops an authoritative destination', async () => {
|
||||
vi.mocked(api.getDispatchRoute).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, edit: { allowed: true, blockCode: null, blockReason: null }, stops: [{ index: 0, name: '起运仓', address: null, destinationKey: null }, { index: 1, name: 'ONT8', address: null, destinationKey: 'CODE:ONT8' }, { index: 2, name: '外接点', address: null, destinationKey: 'EXTERNAL:destination' }] })
|
||||
const route = await open('EDIT'); await route.wrapper.setProps({ operation: 'ROUTE' }); await flushPromises(); (route.wrapper.vm as unknown as { stops: unknown[] }).stops.pop(); route.state.form.reason = '调整'; await route.state.submit(); expect(api.reorderDispatchRoute).not.toHaveBeenCalled(); expect(route.wrapper.text()).toContain('完整保留'); route.wrapper.unmount()
|
||||
})
|
||||
it('refreshes current actions and notifies its parent after a fresh A0409, without presenting an unknown result or replay request', async () => {
|
||||
it('reports A0409 and immediately releases the form without a forced readback', async () => {
|
||||
const { wrapper, state } = await open('SUBMIT_REVIEW')
|
||||
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new ApiResponseError('本轮调度审核已提交,请勿重复提交', 'A0409', 409, undefined, 'review-request', 'review-trace'))
|
||||
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { SUBMIT_REVIEW: { allowed: false, blockCode: 'ALREADY_SUBMITTED', blockReason: '审核已提交' } } }))
|
||||
await state.submit(); await flushPromises()
|
||||
expect(api.submitDispatchReview).toHaveBeenCalledTimes(1)
|
||||
expect(api.getDispatch).toHaveBeenCalledTimes(2)
|
||||
expect(api.getDispatch).toHaveBeenCalledTimes(1)
|
||||
expect(api.getDispatchCommand).not.toHaveBeenCalled()
|
||||
expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull()
|
||||
expect(wrapper.emitted('rejected')).toEqual([[expect.objectContaining({ code: 'A0409', requestId: 'review-request', traceId: 'review-trace' })]])
|
||||
expect(wrapper.emitted('update:modelValue')).toContainEqual([false])
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
|
||||
wrapper.unmount()
|
||||
@@ -124,7 +111,7 @@ it('retains edited values and the original validation response when the action i
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 7, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null } } }))
|
||||
await state.submit(); await flushPromises()
|
||||
expect(state.form.remark).toBe('用户未保存的本轮备注')
|
||||
expect(state.detail.recordVersion).toBe(7)
|
||||
expect(state.detail.recordVersion).toBe(0)
|
||||
expect(wrapper.text()).toContain('预计到达时间须在发车之后')
|
||||
expect(wrapper.text()).toContain('A0400')
|
||||
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
|
||||
@@ -135,7 +122,7 @@ it('retains edited values and the original validation response when the action i
|
||||
expect(wrapper.text()).toContain('预计到达时间须在发车之后')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('reports a definite rejection to its parent even if the follow-up GET fails and blocks a new write until refreshed', async () => {
|
||||
it('allows another click after rejection even when a later read would fail', async () => {
|
||||
const { wrapper, state } = await open('SUBMIT_REVIEW'); state.form.remark = '保留本次备注'
|
||||
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new ApiResponseError('审核已提交', 'A0409', 409, undefined, 'reject-request'))
|
||||
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('GET network failure'))
|
||||
@@ -146,7 +133,7 @@ it('reports a definite rejection to its parent even if the follow-up GET fails a
|
||||
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
|
||||
expect(state.form.remark).toBe('保留本次备注')
|
||||
await state.submit()
|
||||
expect(api.submitDispatchReview).toHaveBeenCalledTimes(1)
|
||||
expect(api.submitDispatchReview).toHaveBeenCalledTimes(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('ends every failed request and displays a later business rejection without replaying',async()=>{
|
||||
@@ -174,10 +161,10 @@ it('unlocks when a successful write is followed by a failed read',async()=>{
|
||||
expect(api.getDispatchCommand).not.toHaveBeenCalled();wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('blocks a fresh %s command and directs cargo changes to smart dispatch', async action => {
|
||||
it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('blocks a fresh %s command after the existing cargo editor is retired', async action => {
|
||||
const { wrapper, state } = await open(action)
|
||||
try {
|
||||
expect(wrapper.text()).toContain('货物安排统一在智能排车中修改')
|
||||
expect(wrapper.text()).toContain('当前入口不支持调整已建排车货物')
|
||||
await state.submit()
|
||||
expect(api.addDispatchCargo).not.toHaveBeenCalled()
|
||||
expect(api.removeDispatchItem).not.toHaveBeenCalled()
|
||||
@@ -191,7 +178,7 @@ it.each(['ADD_CARGO','REMOVE_CARGO'] as const)('ignores old %s records when open
|
||||
sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'PC/ORIGINAL',action,attempted:true,body:{idempotencyKey:'old'}}))
|
||||
const {wrapper,state}=await open('EDIT')
|
||||
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
|
||||
expect(sessionStorage.getItem('dispatch-command:'+identity.id)).toBeNull()
|
||||
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
|
||||
expect(wrapper.text()).not.toContain('请从智能排车恢复原请求')
|
||||
expect(api.addDispatchCargo).not.toHaveBeenCalled();expect(api.removeDispatchItem).not.toHaveBeenCalled();wrapper.unmount()
|
||||
})
|
||||
@@ -252,7 +239,7 @@ it('allows a fresh save and review after a failed save without persisting review
|
||||
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new Error('save unknown'))
|
||||
await state.submit(true)
|
||||
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
|
||||
expect(sessionStorage.getItem('dispatch-command:'+identity.id)).toBeNull()
|
||||
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
|
||||
vi.mocked(api.updateDispatch).mockImplementationOnce(async(_,body)=>command('EDIT',body.idempotencyKey))
|
||||
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({recordVersion:1,actions:{SUBMIT_REVIEW:{allowed:true,blockCode:null,blockReason:null}}})).mockResolvedValueOnce(detail({recordVersion:2}))
|
||||
vi.mocked(api.submitDispatchReview).mockImplementationOnce(async(_,body)=>({...command('SUBMIT_REVIEW',body.idempotencyKey),recordVersion:2}))
|
||||
@@ -283,3 +270,14 @@ it('persists the explicit publication choice with the existing vehicle save', as
|
||||
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, publishLooseboardDemand: false }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('saves a cleared unavailable master vehicle as an empty draft resource', async () => {
|
||||
const { wrapper, state } = await open('EDIT')
|
||||
const editor = wrapper.vm as unknown as { vehicle: { mode: 'MASTER'; vehicleId: string | null } }
|
||||
editor.vehicle.mode = 'MASTER'; editor.vehicle.vehicleId = null
|
||||
vi.mocked(api.updateDispatch).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
|
||||
await state.submit()
|
||||
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ vehicle: null }))
|
||||
expect(api.submitDispatchReview).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -16,8 +16,8 @@ import { DispatchInputError, requireVersion } from '../dispatchContracts'
|
||||
import { dispatchExceptionLabels, dispatchReturnDirectionLabels, canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence } from '../dispatchPresentation'
|
||||
import { isDefiniteDispatchRejection, useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
|
||||
import DispatchVehicleMaintenanceForm from './DispatchVehicleMaintenanceForm.vue'
|
||||
import DispatchAppointments from './DispatchAppointments.vue'
|
||||
import DispatchVehicleMaintenanceFees from './DispatchVehicleMaintenanceFees.vue'
|
||||
import DispatchAppointmentAllocationsDialog from './DispatchAppointmentAllocationsDialog.vue'
|
||||
import { blankVehicleMaintenance, vehicleMaintenancePayload } from '../dispatchVehicleMaintenance'
|
||||
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||
|
||||
@@ -33,9 +33,6 @@ const currentOperation = computed(() => pending.value ? pending.value.operation
|
||||
const route = ref<DispatchRoute | null>(null), stops = ref<DispatchRouteStop[]>([])
|
||||
const detail = ref<DispatchDetail | null>(null), working = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const factsStale = ref(false)
|
||||
const appointmentsOpen = ref(false)
|
||||
const canLinkAppointments = computed(() => vehicleEditor.value && detail.value?.orderDetails.some(row => Boolean(row.destinationFbaWarehouse)) && canDispatchAction(detail.value?.actions, 'EDIT', access.can))
|
||||
async function appointmentsSaved() { error.value = null; await refreshFacts(); emit('saved') }
|
||||
const preview = ref<LoadingPreview | null>(null)
|
||||
const loading = computed(() => working.value)
|
||||
const measurements = ref<Array<{ warehousePalletId: string; palletNo: string; palletSpaces: number | null; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null }>>([])
|
||||
@@ -49,14 +46,19 @@ const approvalBlock = computed(() => isReview.value && detail.value?.actions[cur
|
||||
const reviewTime = (value: string | null | undefined) => value ? new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', dateStyle: 'short', timeStyle: 'short', hour12: false }).format(new Date(value)) : '未填写'
|
||||
const exception = reactive({ exceptionType: '' as DispatchExceptionType | '', returnDirection: '' as DispatchReturnDirection | '', description: '', imageFileIds: [] as string[] })
|
||||
const form = reactive({ publishLooseboardDemand: true, plannedDepartureAt: '', estimatedArrivalAt: '', maintenance: blankVehicleMaintenance(), remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
|
||||
/** 并发加货后补入新目的仓,保留客服已填写的同仓编号。 */
|
||||
function fillDestinationAppointments(value: DispatchDetail, keepInput = false) {
|
||||
const entered = new Map(keepInput ? form.maintenance.destinationAppointments.map(row => [row.destinationKey, row.appointmentNo]) : [])
|
||||
form.maintenance.destinationAppointments = value.dispatchInfo.fbaAppointments.map(row => ({ destinationKey: row.destinationKey, appointmentNo: entered.has(row.destinationKey) ? entered.get(row.destinationKey)! : row.appointmentNo }))
|
||||
}
|
||||
let generation = 0
|
||||
let editorRequestKey = ''
|
||||
const cargoAction = computed(() => ['ADD_CARGO', 'REMOVE_CARGO'].includes(currentAction.value))
|
||||
const cargoEntryMessage = '货物安排统一在智能排车中修改;如有未确认操作,请从智能排车恢复原请求。'
|
||||
const cargoEntryMessage = '当前入口不支持调整已建排车货物。'
|
||||
const allowed = computed(() => !cargoAction.value && !factsStale.value && !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true))
|
||||
const vehicleEditor = computed(() => currentOperation.value !== 'ROUTE' && (currentAction.value === 'EDIT' || props.action === 'EDIT' && currentAction.value === 'SUBMIT_REVIEW'))
|
||||
const submitting = ref(false)
|
||||
const width = computed(() => vehicleEditor.value ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW', 'MARK_EXCEPTION'].includes(currentAction.value) ? '760px' : '560px')
|
||||
const width = computed(() => vehicleEditor.value ? '1120px' : isReview.value ? '1100px' : currentAction.value === 'MARK_EXCEPTION' ? '760px' : '560px')
|
||||
const frozen = computed(() => submitting.value || busy.value || Boolean(pending.value))
|
||||
const evidenceBusy = reactive({ EXCEPTION: false, VEHICLE: false, DRIVER_PHOTO: false, DRIVER: false })
|
||||
const uploadingEvidence = computed(() => Object.values(evidenceBusy).some(Boolean))
|
||||
@@ -68,12 +70,13 @@ async function load() {
|
||||
route.value = null; stops.value = []
|
||||
try {
|
||||
if (cargoAction.value) return
|
||||
if (action === 'CREATE') throw new DispatchInputError('请从排车列表的原请求入口恢复新建排车')
|
||||
if (action === 'CREATE') throw new DispatchInputError('请从排车列表进入新建排车')
|
||||
const value = await api.getDispatch(no)
|
||||
if (sequence !== generation) return
|
||||
if (value.dispatchNo !== no) throw new DispatchInputError('排车业务号不一致,请重新打开')
|
||||
detail.value = value
|
||||
form.maintenance = value.vehicleInfo.maintenance ? { ...value.vehicleInfo.maintenance } : blankVehicleMaintenance()
|
||||
fillDestinationAppointments(value)
|
||||
Object.assign(exception, { exceptionType: '', returnDirection: '', description: '', imageFileIds: [] })
|
||||
const oldVehicle = value.vehicleInfo.vehicle
|
||||
vehicle.value = oldVehicle ? { mode: oldVehicle.mode, vehicleId: oldVehicle.vehicleId, plate: oldVehicle.plate, carrierName: oldVehicle.carrierName, capacity: oldVehicle.capacity ? { ...oldVehicle.capacity } : null, evidenceFileIds: [...oldVehicle.evidenceFileIds] } : blankVehicle()
|
||||
@@ -114,7 +117,7 @@ function payload(): object {
|
||||
case 'MARK_EXCEPTION':
|
||||
if (!exception.exceptionType) throw new DispatchInputError('请选择异常类型')
|
||||
return { ...common, exceptionType: exception.exceptionType, returnDirection: exception.returnDirection || null, description: exception.description.trim() || null, imageFileIds: [...exception.imageFileIds] } satisfies MarkDispatchException
|
||||
case 'EDIT': return { ...common, publishLooseboardDemand: form.publishLooseboardDemand, vehicle: vehicle.value, driver: driver.value, maintenance: vehicleMaintenancePayload(form.maintenance), plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
|
||||
case 'EDIT': return { ...common, publishLooseboardDemand: form.publishLooseboardDemand, vehicle: vehicle.value.mode === 'MASTER' && !vehicle.value.vehicleId ? null : vehicle.value, driver: driver.value.mode === 'MASTER' && !driver.value.driverId ? null : driver.value, maintenance: vehicleMaintenancePayload(form.maintenance), plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
|
||||
case 'DISPATCH_REVIEW': case 'LOADING_REVIEW':
|
||||
if (form.decision === 'APPROVED' && approvalBlock.value) throw new DispatchInputError(approvalBlock.value)
|
||||
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
|
||||
@@ -152,13 +155,12 @@ async function submitReview(decision: 'APPROVED' | 'REJECTED') {
|
||||
await submit()
|
||||
}
|
||||
async function submit(submitReviewAfterSave = false) {
|
||||
if (appointmentsOpen.value) return
|
||||
if (submitting.value || busy.value || loading.value || uploadingEvidence.value) return
|
||||
submitting.value = true
|
||||
error.value = null
|
||||
try {
|
||||
if (cargoAction.value) throw new DispatchInputError(cargoEntryMessage)
|
||||
if (pending.value?.action === 'CREATE') throw new DispatchInputError('请从排车列表的原请求入口恢复新建排车')
|
||||
if (pending.value?.action === 'CREATE') throw new DispatchInputError('请从排车列表进入新建排车')
|
||||
if (!pending.value) {
|
||||
recovery.begin(props.dispatchNo, props.action, payload(), { operation: currentOperation.value, ...(submitReviewAfterSave && vehicleEditor.value ? { submitReviewAfterSave: true } : {}) })
|
||||
editorRequestKey = String(recovery.pending.value?.body.idempotencyKey || '')
|
||||
@@ -168,7 +170,7 @@ async function submit(submitReviewAfterSave = false) {
|
||||
const continueReview = pending.value?.action === 'EDIT' && pending.value.submitReviewAfterSave === true
|
||||
const readCurrent = async (no: string) => {
|
||||
const value = await api.getDispatch(no)
|
||||
if (value.dispatchNo === currentNo.value) detail.value = value
|
||||
if (value.dispatchNo === currentNo.value) { detail.value = value; fillDestinationAppointments(value, true) }
|
||||
return value
|
||||
}
|
||||
let result = await recovery.execute(send, readCurrent)
|
||||
@@ -187,7 +189,7 @@ async function submit(submitReviewAfterSave = false) {
|
||||
error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '本次操作失败,请重试')
|
||||
editorRequestKey = ''
|
||||
if (isDefiniteDispatchRejection(cause)) emit('rejected', error.value)
|
||||
if (!(cause instanceof DispatchInputError)) await refreshFacts()
|
||||
|
||||
} finally { submitting.value = false }
|
||||
}
|
||||
async function refreshFacts() {
|
||||
@@ -198,6 +200,7 @@ async function refreshFacts() {
|
||||
if (sequence !== generation) return
|
||||
if (value.dispatchNo !== no) throw new DispatchInputError('排车业务号不一致,请重新打开')
|
||||
detail.value = value
|
||||
fillDestinationAppointments(value, true)
|
||||
if (currentOperation.value === 'ROUTE') {
|
||||
const data = await api.getDispatchRoute(no)
|
||||
if (sequence !== generation) return
|
||||
@@ -216,13 +219,12 @@ function moveStop(index: number, offset: number) { const target = index + offset
|
||||
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else { generation++ } }, { immediate: true })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :show-cancel="!isReview" :loading="loading" :confirm-loading="submitting || busy || uploadingEvidence" :confirm-disabled="cargoAction || loading || uploadingEvidence || !pending && !allowed" :confirm-text="cargoAction ? '请从智能排车修改' : currentAction === 'MARK_EXCEPTION' ? '保存' : '确认提交'" @confirm="submit()">
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :show-cancel="!isReview" :loading="loading" :confirm-loading="submitting || busy || uploadingEvidence" :confirm-disabled="cargoAction || loading || uploadingEvidence || !pending && !allowed" :confirm-text="cargoAction ? '当前不可操作' : currentAction === 'MARK_EXCEPTION' ? '保存' : '确认提交'" @confirm="submit()">
|
||||
<div class="density-dialog-sections">
|
||||
<el-alert v-if="cargoAction" :title="cargoEntryMessage" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理,请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
|
||||
<LoadErrorState v-if="error && !cargoAction" title="排车操作未完成" :message="error.message" :loading="loading || busy || uploadingEvidence" retry-text="刷新当前资料" @retry="retryError"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-alert v-if="detail && !allowed && !pending" :title="detail?.actions[currentAction]?.blockReason || '当前操作未获得允许,请核对权限与作业状态'" type="warning" :closable="false" show-icon />
|
||||
<div v-if="canLinkAppointments" class="density-actions"><el-button :disabled="loading || busy || submitting" @click="appointmentsOpen = true">关联正式预约</el-button></div>
|
||||
<el-form v-if="detail && !cargoAction" class="density-form density-dialog-sections" label-position="left" label-width="130px" :disabled="frozen || loading || uploadingEvidence || !allowed">
|
||||
<section v-if="currentOperation === 'ROUTE' && route" class="density-dialog-section" data-tone="source" aria-label="本车卸货顺序"><h3 class="density-dialog-section__title">卸货顺序</h3>
|
||||
<table class="density-table"><tbody><tr><th scope="row">起运仓</th><td>{{ route.stops[0]?.name || '—' }}</td><th scope="row">地址</th><td>{{ route.stops[0]?.address || '—' }}</td></tr></tbody></table>
|
||||
@@ -237,7 +239,6 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
</template>
|
||||
<template v-else-if="vehicleEditor">
|
||||
<DispatchVehicleMaintenanceForm :key="currentNo" :detail="detail" v-model:vehicle="vehicle" v-model:driver="driver" v-model:maintenance="form.maintenance" v-model:planned-departure-at="form.plannedDepartureAt" v-model:estimated-arrival-at="form.estimatedArrivalAt" v-model:publish-looseboard-demand="form.publishLooseboardDemand" :disabled="frozen || loading || uploadingEvidence || !allowed">
|
||||
<template #appointments><div v-if="canLinkAppointments" class="density-actions"><el-button :disabled="loading || busy || submitting" @click="appointmentsOpen = true">关联正式预约</el-button></div></template>
|
||||
<template #fees><DispatchVehicleMaintenanceFees :dispatch-no="currentNo" :deliveries="detail.documents.deliveries" /></template>
|
||||
<template #attachments>
|
||||
<div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||||
@@ -249,9 +250,17 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
</DispatchVehicleMaintenanceForm>
|
||||
</template>
|
||||
<template v-else-if="currentAction === 'DISPATCH_REVIEW' || currentAction === 'LOADING_REVIEW'">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="本车审核依据"><table class="density-table"><tbody><tr><th scope="row">车牌</th><td>{{ detail.vehicleInfo.vehicle?.plate || '—' }}</td><th scope="row">司机</th><td>{{ detail.vehicleInfo.driver?.name || '—' }}</td></tr><tr><th scope="row">托盘数</th><td>{{ detail.counts.palletCount }} 托</td><th scope="row">箱数</th><td>{{ detail.counts.packageCount }} 箱</td></tr></tbody></table>
|
||||
<table class="density-table"><tbody><tr><th>计划来车/发车(北京时间)</th><td>{{ reviewTime(detail.dispatchInfo.plannedDepartureAt) }}</td></tr><tr><th>预计送达(北京时间)</th><td>{{ reviewTime(detail.dispatchInfo.estimatedArrivalAt) }}</td></tr></tbody></table>
|
||||
<table v-if="detail.dispatchInfo.fbaAppointments.length" class="density-table"><thead><tr><th>目的仓</th><th>预约单号</th><th>预约状态</th><th>确认送达(北京时间)</th></tr></thead><tbody><tr v-for="(appointment, index) in detail.dispatchInfo.fbaAppointments" :key="appointment.batchKey + '-' + index"><td>{{ appointment.destinationWarehouseCode }}</td><td>{{ appointment.appointmentNo || appointment.batchNo || '—' }}</td><td>{{ appointment.status === 'SUCCESS' ? '已确认' : '未确认' }}</td><td>{{ reviewTime(appointment.confirmedAt) }}</td></tr></tbody></table>
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="本车审核依据">
|
||||
<h3 class="density-dialog-section__title">车辆与货物</h3>
|
||||
<table class="density-table"><tbody>
|
||||
<tr><th scope="row">车牌</th><td>{{ detail.vehicleInfo.vehicle?.plate || '—' }}</td><th scope="row">司机</th><td>{{ detail.vehicleInfo.driver?.name || '—' }}</td><th scope="row">联系电话</th><td>{{ detail.vehicleInfo.driver?.phone || '—' }}</td></tr>
|
||||
<tr><th scope="row">托盘数</th><td>{{ detail.counts.palletCount }} 托</td><th scope="row">箱数</th><td>{{ detail.counts.packageCount }} 箱</td><th scope="row">路线</th><td>{{ detail.dispatchInfo.route || '—' }}</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="排车与预约">
|
||||
<h3 class="density-dialog-section__title">排车与预约</h3>
|
||||
<table class="density-table"><tbody><tr><th scope="row">计划来车/发车(北京时间)</th><td>{{ reviewTime(detail.dispatchInfo.plannedDepartureAt) }}</td><th scope="row">预计送达(北京时间)</th><td>{{ reviewTime(detail.dispatchInfo.estimatedArrivalAt) }}</td></tr></tbody></table>
|
||||
<DispatchAppointments :appointments="detail.dispatchInfo.fbaAppointments" />
|
||||
<el-alert v-if="approvalBlock" :title="approvalBlock" type="warning" :closable="false" show-icon />
|
||||
</section>
|
||||
</template>
|
||||
@@ -261,10 +270,13 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
</template>
|
||||
<el-alert v-else-if="currentAction === 'RETURN_TO_REVIEW'" title="仅零真实扫码允许退回装车前,保留原排车号,修改后两级审核重新提交。" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-else-if="currentAction === 'CANCEL'" title="确认取消本排车单;服务端将核对真实作业和来源占用,不自动回库。" type="warning" :closable="false" show-icon />
|
||||
<div v-if="isReview" class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||||
<DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" photo-card readonly :model-value="reviewEvidence[field.key]" :dispatch-no="currentNo" :kind="field.kind" :label="field.label" />
|
||||
</div>
|
||||
<section v-if="currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">操作说明</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="isReview && form.decision === 'REJECTED' ? '驳回原因(必填)' : currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'" :error="isReview && form.decision === 'REJECTED' && !form.reason.trim() ? '请填写驳回原因' : ''"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" :placeholder="isReview ? '驳回时必须填写原因,审核通过时选填' : ''" /></el-form-item></td></tr></tbody></table></section>
|
||||
<section v-if="isReview" class="density-dialog-section" data-tone="source" aria-label="审核照片">
|
||||
<h3 class="density-dialog-section__title">车辆司机照片</h3>
|
||||
<div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||||
<DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" photo-card readonly :model-value="reviewEvidence[field.key]" :dispatch-no="currentNo" :kind="field.kind" :label="field.label" />
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">{{ isReview ? '审核意见' : '操作说明' }}</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="isReview && form.decision === 'REJECTED' ? '驳回原因(必填)' : currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'" :error="isReview && form.decision === 'REJECTED' && !form.reason.trim() ? '请填写驳回原因' : ''"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" :placeholder="isReview ? '驳回时必须填写原因,审核通过时选填' : ''" /></el-form-item></td></tr></tbody></table></section>
|
||||
<section v-else-if="!['MARK_EXCEPTION', 'EDIT', 'SUBMIT_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="本次操作备注"><h3 class="density-dialog-section__title">本次备注</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="备注"><el-input v-model="form.remark" aria-label="本次操作备注" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
|
||||
</el-form>
|
||||
</div>
|
||||
@@ -280,7 +292,6 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
<DispatchAppointmentAllocationsDialog v-if="appointmentsOpen" v-model="appointmentsOpen" :dispatch-no="currentNo" @saved="appointmentsSaved" />
|
||||
</template>
|
||||
<style scoped>
|
||||
.workflow-order-actions { width: 160px; white-space: nowrap; }
|
||||
|
||||
@@ -141,23 +141,24 @@ it('receives the native original carton whole into a verified target without inv
|
||||
expect(getOverseasLocation).toHaveBeenCalledWith('92', '40')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('retains the exact command after unknown failure even if the visible form is changed', async () => {
|
||||
it('allows editing after unknown failure and submits a new command', async () => {
|
||||
vi.mocked(commandException).mockRejectedValueOnce(new Error('network lost')).mockResolvedValueOnce({ exceptionNo: 'EX-1', version: 2, status: 'PROCESSING', restoredPackages: 2, replayed: true })
|
||||
const wrapper = open(); await flushPromises(); await fill(wrapper)
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
const payload = JSON.parse(JSON.stringify(vi.mocked(commandException).mock.calls[0]![3]))
|
||||
expect(wrapper.text()).toContain('已保留原请求')
|
||||
expect(wrapper.get('[data-test="notes"]').attributes('disabled')).toBeUndefined()
|
||||
await wrapper.get('[data-test="notes"]').setValue('changed')
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(commandException).mock.calls[1]![3]).toEqual(payload)
|
||||
expect(vi.mocked(commandException).mock.calls[1]![3].idempotencyKey).not.toBe(payload.idempotencyKey); expect(vi.mocked(commandException).mock.calls[1]![3]).toEqual(expect.objectContaining({ handlingResult: 'changed' }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('refreshes a definite stale version and requires a reviewed new submission', async () => {
|
||||
it('keeps a conflict visible and lets the operator refresh voluntarily', async () => {
|
||||
vi.mocked(getExceptionDetail).mockResolvedValueOnce(snapshot()).mockResolvedValueOnce(snapshot('EX-1', 7, 2))
|
||||
vi.mocked(commandException).mockRejectedValueOnce(new ApiResponseError('版本变化', 'A0409', 409))
|
||||
const wrapper = open(); await flushPromises(); await fill(wrapper)
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('已重新读取当前版本'); expect(commandException).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).toContain('版本变化'); expect(commandException).toHaveBeenCalledTimes(1); expect(getExceptionDetail).toHaveBeenCalledTimes(1)
|
||||
await wrapper.findAll('button').find(button => button.text() === '重新读取详情')!.trigger('click'); await flushPromises()
|
||||
await fill(wrapper); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(commandException).mock.calls[1]![3].expectedVersion).toBe(7)
|
||||
expect(vi.mocked(commandException).mock.calls[1]![3].idempotencyKey).not.toBe(vi.mocked(commandException).mock.calls[0]![3].idempotencyKey)
|
||||
@@ -246,7 +247,7 @@ it('allows cancellation and ordinary completion with standard audit text and sti
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('closing and reopening cannot replay an in-flight command or lose a later unknown batch', async () => {
|
||||
it('closing and reopening blocks only the active request and failures release each subsequent command', async () => {
|
||||
let rejectFirst!: (reason: Error) => void
|
||||
vi.mocked(commandException).mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectFirst = reject }))
|
||||
const wrapper = open(); await flushPromises(); await fill(wrapper)
|
||||
@@ -258,15 +259,15 @@ it('closing and reopening cannot replay an in-flight command or lose a later unk
|
||||
rejectFirst(new Error('R1 outcome unknown')); await flushPromises()
|
||||
vi.mocked(getExceptionDetail).mockResolvedValue(snapshot('EX-1', 2, 2))
|
||||
vi.mocked(commandException).mockResolvedValueOnce({ exceptionNo: 'EX-1', version: 2, status: 'PROCESSING', restoredPackages: 2, replayed: true })
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(commandException).mock.calls[1]![3].idempotencyKey).toBe(originalKey)
|
||||
await fill(wrapper); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(commandException).mock.calls[1]![3].idempotencyKey).not.toBe(originalKey)
|
||||
await fill(wrapper)
|
||||
vi.mocked(commandException).mockRejectedValueOnce(new Error('next batch unknown')).mockRejectedValueOnce(new Error('still unknown'))
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
const newKey = vi.mocked(commandException).mock.calls[2]![3].idempotencyKey
|
||||
expect(newKey).not.toBe(originalKey)
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(commandException).mock.calls[3]![3].idempotencyKey).toBe(newKey)
|
||||
expect(vi.mocked(commandException).mock.calls[3]![3].idempotencyKey).not.toBe(newKey)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
|
||||
@@ -22,13 +22,12 @@ type ReceiptInput = ExceptionReturnSource & { sourcePalletNo: string; quantity:
|
||||
const receipts = ref<ReceiptInput[]>([]), targets = ref<PalletDto[]>([]), notes = ref(''), evidence = ref<{ fileId: string; fileName: string }[]>([])
|
||||
type Attempt = { action: ExceptionAction; payload: ExceptionCommand }
|
||||
const pendingByScope = reactive(new Map<string, Attempt>())
|
||||
const unknownUploads = reactive(new Set<string>())
|
||||
const savedEvidence = new Map<string, { fileId: string; fileName: string }[]>()
|
||||
const selectedHistory = ref<string[]>([])
|
||||
const inFlight = reactive(new Map<string, string>())
|
||||
const uploadingScopes = reactive(new Set<string>())
|
||||
const pending = computed(() => pendingByScope.get(identity.value))
|
||||
const locked = computed(() => busy.value || inFlight.has(identity.value) || uploadingScopes.has(identity.value) || !!pending.value || unknownUploads.has(identity.value))
|
||||
const locked = computed(() => busy.value || inFlight.has(identity.value) || uploadingScopes.has(identity.value) || !!pending.value)
|
||||
const warehouseReturn = computed(() => detail.value?.order.exceptionType === 'RETURN' && detail.value.order.returnDirection === 'OVERSEAS_WAREHOUSE')
|
||||
const allowed = (action: string) => canProcess.value && detail.value?.order.allowedActions?.includes(action)
|
||||
let sequence = 0
|
||||
@@ -91,13 +90,12 @@ async function upload(event: Event) {
|
||||
savedEvidence.set(scope, [...(savedEvidence.get(scope) || []), fileResult])
|
||||
if (current(ticket)) evidence.value.push(fileResult)
|
||||
} catch (cause) {
|
||||
if (!definite(cause)) unknownUploads.add(scope)
|
||||
if (current(ticket)) error.value = definite(cause) ? getApiErrorDetails(cause, '证据上传失败').message : '证据上传结果未知,请先人工核对;不会自动重复上传'
|
||||
if (current(ticket)) error.value = getApiErrorDetails(cause, '证据上传失败,请重新选择文件').message
|
||||
} finally { uploadingScopes.delete(scope); if (current(ticket)) busy.value = false; input.value = '' }
|
||||
}
|
||||
async function execute(action: ExceptionAction) {
|
||||
const actionName = { 'start-processing': 'START_PROCESSING', complete: 'COMPLETE_EXCEPTION', cancel: 'CANCEL_EXCEPTION' }[action]
|
||||
if (busy.value || inFlight.has(identity.value) || uploadingScopes.has(identity.value) || loading.value || !canProcess.value || (!pending.value && !allowed(actionName)) || unknownUploads.has(identity.value)) return
|
||||
if (busy.value || inFlight.has(identity.value) || uploadingScopes.has(identity.value) || loading.value || !canProcess.value || (!pending.value && !allowed(actionName))) return
|
||||
if (pending.value && pending.value.action !== action) return
|
||||
if (!pending.value) {
|
||||
const selectedFiles = [...new Set([...evidence.value.map(row => row.fileId), ...selectedHistory.value])]
|
||||
@@ -128,7 +126,7 @@ async function execute(action: ExceptionAction) {
|
||||
const result = await commandException(customer, number, attempt.action, attempt.payload)
|
||||
const statuses = attempt.action === 'complete' ? ['PROCESSING', 'COMPLETED'] : [attempt.action === 'cancel' ? 'CANCELLED' : 'PROCESSING']
|
||||
if (!result || result.exceptionNo !== number || Number(result.version) !== attempt.payload.expectedVersion + 1 || !statuses.includes(result.status))
|
||||
throw new Error('处理结果无法核对,请保留原请求重试')
|
||||
throw new Error('处理结果无法核对,请刷新当前记录')
|
||||
if (sameAttempt()) { pendingByScope.delete(scope); savedEvidence.delete(scope) }
|
||||
if (!current(ticket)) return
|
||||
message.value = action === 'complete' && result.status === 'PROCESSING' ? `本批已实收 ${result.restoredPackages} 箱,仍有待收货物` : '操作已保存'
|
||||
@@ -137,9 +135,9 @@ async function execute(action: ExceptionAction) {
|
||||
if (definite(cause) && sameAttempt()) pendingByScope.delete(scope)
|
||||
if (!current(ticket)) return
|
||||
const text = getApiErrorDetails(cause, '处理请求失败').message
|
||||
if (definite(cause)) { responseSequence = sequence + 1; await load(); if (current(responseSequence)) error.value = `${text};已重新读取当前版本,请核对后再操作` }
|
||||
else error.value = `${text};结果未确认,已保留原请求,请仅重试原操作`
|
||||
error.value = text
|
||||
} finally {
|
||||
if (sameAttempt()) pendingByScope.delete(scope)
|
||||
if (inFlight.get(scope) === requestKey) inFlight.delete(scope)
|
||||
if (current(responseSequence)) busy.value = false
|
||||
}
|
||||
@@ -221,12 +219,9 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
|
||||
<p v-else-if="detail.order.exceptionType === 'RETURN'">关闭此异常只记录处理结果,不代表货物交接完成,也不会释放车辆或司机。</p>
|
||||
</template>
|
||||
</el-form>
|
||||
<p v-if="pending">存在结果未确认的操作,表单已锁定;切换后仍保留该单原请求。</p>
|
||||
<p v-if="unknownUploads.has(identity)">证据上传结果未知,需人工核对后继续。</p>
|
||||
<el-button v-if="pending && !detail.order.allowedActions?.includes({ 'start-processing': 'START_PROCESSING', complete: 'COMPLETE_EXCEPTION', cancel: 'CANCEL_EXCEPTION' }[pending.action])" data-test="retry" :disabled="busy" @click="execute(pending.action)">重试原操作</el-button>
|
||||
<el-button v-if="allowed('START_PROCESSING')" data-test="start" :disabled="busy || (!!pending && pending.action !== 'start-processing')" @click="execute('start-processing')">{{ pending ? '重试原开始请求' : '开始处理' }}</el-button>
|
||||
<el-button v-if="allowed('CANCEL_EXCEPTION')" data-test="cancel" :disabled="busy || (!!pending && pending.action !== 'cancel')" @click="execute('cancel')">{{ pending ? '重试原取消请求' : '取消异常' }}</el-button>
|
||||
<el-button v-if="allowed('COMPLETE_EXCEPTION')" data-test="complete" :disabled="busy || (!!pending && pending.action !== 'complete')" @click="execute('complete')">{{ pending ? '重试原处理请求' : warehouseReturn ? '提交本批实收' : '完成处理' }}</el-button>
|
||||
<el-button v-if="allowed('START_PROCESSING')" data-test="start" :disabled="busy || (!!pending && pending.action !== 'start-processing')" @click="execute('start-processing')">{{ '开始处理' }}</el-button>
|
||||
<el-button v-if="allowed('CANCEL_EXCEPTION')" data-test="cancel" :disabled="busy || (!!pending && pending.action !== 'cancel')" @click="execute('cancel')">{{ '取消异常' }}</el-button>
|
||||
<el-button v-if="allowed('COMPLETE_EXCEPTION')" data-test="complete" :disabled="busy || (!!pending && pending.action !== 'complete')" @click="execute('complete')">{{ warehouseReturn ? '提交本批实收' : '完成处理' }}</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
@@ -14,30 +14,29 @@ vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push }) }))
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
|
||||
vi.mock('./SmartDispatchExistingCargo.vue', () => ({ default: { name: 'SmartDispatchExistingCargo', emits: ['saved', 'busy'], template: '<div data-existing-cargo />' } }))
|
||||
vi.mock('../api/dispatches', () => ({ listDispatchDrafts: vi.fn(), listDispatchCandidates: vi.fn(), createDispatchDraft: vi.fn(), createDispatchDraftContainer: vi.fn(), lockDispatchDraftPallet: vi.fn(), unlockDispatchDraftPallet: vi.fn(), deleteDispatchDraftContainer: vi.fn(), releaseDispatchDraft: vi.fn(), convertDispatchDraft: vi.fn(), createDispatch: vi.fn(), getDispatch: vi.fn(), getDispatchCommand: vi.fn() }))
|
||||
const source = () => candidate(), draft = (): DispatchDraft => ({ draftToken: 'server-token', recordVersion: 0, status: 'ACTIVE', warehouseId: '321', containers: [{ draftContainerId: '100000000000000001', sequenceNo: 1, capacity: 30, sourceKeys: ['source-1'] }], sources: [source()], expiresAt: '2026-12-01T00:00:00Z' })
|
||||
it('switches to existing cargo inside the same dialog without creating a draft or submitting a new dispatch', async () => {
|
||||
it('opens only new dispatch planning and keeps draft cargo selection available', async () => {
|
||||
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
|
||||
const dialog = wrapper.getComponent(AppDialog)
|
||||
await wrapper.getComponent({ name: 'ElDialogContent' }).findAll('button').find(button => button.text() === '调整已有排车货物')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'SmartDispatchExistingCargo' }).exists()).toBe(true)
|
||||
expect(dialog.props('showConfirm')).toBe(false)
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(dialog.find('[aria-label="智能排车工作方式"]').exists()).toBe(false)
|
||||
expect(dialog.text()).not.toContain('调整已有排车货物')
|
||||
expect(dialog.findComponent({ name: 'SmartDispatchCargoBoard' }).exists()).toBe(true)
|
||||
expect(dialog.props('showConfirm')).toBe(true)
|
||||
expect(api.listDispatchCandidates).toHaveBeenCalledOnce()
|
||||
expect(api.createDispatch).not.toHaveBeenCalled()
|
||||
expect(api.createDispatchDraft).not.toHaveBeenCalled()
|
||||
expect(api.convertDispatchDraft).not.toHaveBeenCalled()
|
||||
wrapper.getComponent({ name: 'SmartDispatchExistingCargo' }).vm.$emit('saved'); await flushPromises()
|
||||
expect(wrapper.emitted('created')).toHaveLength(1)
|
||||
expect(wrapper.emitted('created')).toBeUndefined()
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each(['ADD_CARGO','REMOVE_CARGO'] as const)('ignores old %s browser requests and opens a fresh workspace',async action=>{
|
||||
sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'PC/1',action,attempted:true,body:{idempotencyKey:'old'}}))
|
||||
const wrapper=mount(SmartDispatchDialog,options);await flushPromises()
|
||||
expect(wrapper.findComponent({name:'SmartDispatchExistingCargo'}).exists()).toBe(false)
|
||||
expect(wrapper.find('[aria-label="智能排车工作方式"]').exists()).toBe(false)
|
||||
expect(api.listDispatchDrafts).toHaveBeenCalledOnce()
|
||||
expect(sessionStorage.getItem('dispatch-command:'+identity.id)).toBeNull()
|
||||
expect(wrapper.text()).not.toContain('原请求已保留')
|
||||
expect(wrapper.text()).not.toContain('原请求结果尚待核对');wrapper.unmount()
|
||||
})
|
||||
it('converts a filtered relabel group with both authoritative sources and locks it through one HTTP command', async () => {
|
||||
@@ -148,7 +147,7 @@ it('clears an old departure request without blocking new empty creation',async()
|
||||
const state=wrapper.vm as unknown as {mode:string;submit():Promise<void>}
|
||||
state.mode='empty';await flushPromises();await state.submit()
|
||||
expect(api.createDispatch).toHaveBeenCalledOnce()
|
||||
expect(sessionStorage.getItem('dispatch-command:'+identity.id)).toBeNull();wrapper.unmount()
|
||||
expect(wrapper.text()).not.toContain('原请求已保留');wrapper.unmount()
|
||||
})
|
||||
it('rejects a restored source whose batch customer differs from its real cargo owner', async () => {
|
||||
const original = draft(); original.sources[0]!.appointmentBatches = [{ batchKey: 'batch-1', batchNo: 'BATCH-1', recordVersion: 0, customerId: 'other-customer', sources: [{ sourceKey: 'source-1', destinationKey: 'CODE:ONT8', packageCount: 2 }] }]
|
||||
|
||||
@@ -20,7 +20,6 @@ import { dispatchActionPermissions } from '../dispatchPresentation'
|
||||
import { useDispatchCommandRecovery, validateCurrentDispatch } from '../dispatchCommandRecovery'
|
||||
import type { InboundOrderListItem } from '../types'
|
||||
import SmartDispatchCargoBoard from './SmartDispatchCargoBoard.vue'
|
||||
import SmartDispatchExistingCargo from './SmartDispatchExistingCargo.vue'
|
||||
import { matchesSmartDispatchWarehouseTab, type SmartDispatchWarehouseTab } from '../smartDispatchWarehouseTab'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; orders: InboundOrderListItem[] }>()
|
||||
@@ -30,8 +29,6 @@ const recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
|
||||
const owner = auth.user?.id || '', storageKey = `dispatch-draft-command:${owner}`
|
||||
type DraftCommand = { action: 'CREATE' | 'CONTAINER' | 'LOCK' | 'UNLOCK' | 'DELETE' | 'RELEASE' | 'CONVERT'; body: Record<string, unknown>; containerId?: string; priorContainerIds?: string[] }
|
||||
const pending = ref<DraftCommand | null>(null)
|
||||
if (owner) sessionStorage.removeItem(storageKey)
|
||||
const workspace = ref<'create' | 'existing'>('create'), existingBusy = ref(false)
|
||||
const settingsExpanded = ref(false)
|
||||
const mode = ref<'cargo' | 'empty'>('cargo'), warehouseId = ref(''), warehouses = ref<PlatformWarehouseResource[]>([])
|
||||
const draft = ref<DispatchDraft | null>(null), drafts = ref<DispatchDraft[]>([]), activeContainerId = ref(''), capacity = ref(30)
|
||||
@@ -44,8 +41,6 @@ const warehouseTab = ref<SmartDispatchWarehouseTab>('ALL')
|
||||
const publishLooseboardDemand = ref(false)
|
||||
const frozen = computed(() => !canCreate.value || preparing.value || saving.value || recovery.busy.value)
|
||||
const canCreate = computed(() => access.can(dispatchActionPermissions.CREATE))
|
||||
const canEditCargo = computed(() => access.can(dispatchActionPermissions.ADD_CARGO))
|
||||
const workspaceFrozen = computed(() => loading.value || preparing.value || saving.value || existingBusy.value || recovery.busy.value)
|
||||
const planIds = computed(() => [...new Set(props.orders.map(row => row.planId).filter(Boolean))])
|
||||
const currentContainer = computed(() => draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value))
|
||||
const populatedGroupCount = computed(() => draft.value?.containers.filter(row => row.sourceKeys.length).length || 0)
|
||||
@@ -107,8 +102,7 @@ async function init() {
|
||||
const sequence = ++generation
|
||||
working.value = true; error.value = null
|
||||
try {
|
||||
if (!canCreate.value && canEditCargo.value) workspace.value = 'existing'
|
||||
if (workspace.value === 'existing' || !canCreate.value) return
|
||||
if (!canCreate.value) return
|
||||
const [directory, saved] = await Promise.all([listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' }), api.listDispatchDrafts()])
|
||||
if (sequence !== generation) return
|
||||
warehouses.value = directory; drafts.value = saved
|
||||
@@ -124,13 +118,6 @@ async function init() {
|
||||
} catch (cause) { if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车候选或草稿恢复失败,请保留原选择重试') }
|
||||
finally { if (sequence === generation) working.value = false }
|
||||
}
|
||||
function switchWorkspace(value: 'create' | 'existing') {
|
||||
if (workspaceFrozen.value || value === workspace.value || (value === 'create' ? !canCreate.value : !canEditCargo.value)) return
|
||||
workspace.value = value
|
||||
existingBusy.value = false
|
||||
candidatePage.reset()
|
||||
void init()
|
||||
}
|
||||
async function loadCandidates(sequence = generation) {
|
||||
if (sequence !== generation) return
|
||||
const candidateWarehouse = draft.value?.warehouseId
|
||||
@@ -219,20 +206,9 @@ async function retryDraft() {
|
||||
if (draft.value) warehouseId.value = draft.value.warehouseId
|
||||
activeContainerId.value = addedContainerId || draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value)?.draftContainerId || draft.value?.containers[0]?.draftContainerId || ''
|
||||
if (command.action !== 'CREATE' && command.action !== 'CONTAINER') await loadCandidates()
|
||||
} catch (cause) {
|
||||
// 请求失败后读取服务器最新草稿,保留本地时间和备注,后续点击按当前版本重新提交。
|
||||
if (command.body.draftToken) {
|
||||
try {
|
||||
const saved = await api.listDispatchDrafts()
|
||||
drafts.value = saved; draft.value = saved.find(row => row.draftToken === command.body.draftToken) || null
|
||||
activeContainerId.value = draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value)?.draftContainerId || draft.value?.containers[0]?.draftContainerId || ''
|
||||
} catch { /* 刷新失败只保留原错误,不锁住其他操作。 */ }
|
||||
}
|
||||
throw cause
|
||||
} finally { pending.value = null; saving.value = false }
|
||||
}
|
||||
async function submit() {
|
||||
if (workspace.value === 'existing') return
|
||||
if (!canCreate.value || preparing.value || saving.value || loading.value) return
|
||||
error.value = null
|
||||
try {
|
||||
@@ -246,13 +222,7 @@ async function submit() {
|
||||
watch(() => props.modelValue, value => { if (value) { publishLooseboardDemand.value = false; void init() } else { generation++; candidatePage.reset() } }, { immediate: true })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog class="admin-density-dialog admin-density admin-workbench is-dialog smart-dispatch-dialog" :class="{ 'is-cargo-workspace': workspace === 'create' && mode === 'cargo' }" append-to-body :lock-scroll="true" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" fullscreenable default-fullscreen :loading="working" :show-confirm="workspace === 'create'" :confirm-loading="saving || recovery.busy.value || existingBusy" :confirm-disabled="confirmDisabled" :confirm-text="mode === 'empty' ? '保存空排车单' : '生成排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
|
||||
<div class="density-status-tabs" role="tablist" aria-label="智能排车工作方式">
|
||||
<button v-if="canCreate" type="button" role="tab" :aria-selected="workspace === 'create'" :class="{ 'is-active': workspace === 'create' }" :disabled="workspaceFrozen" @click="switchWorkspace('create')">新建排车</button>
|
||||
<button v-if="canEditCargo" type="button" role="tab" :aria-selected="workspace === 'existing'" :class="{ 'is-active': workspace === 'existing' }" :disabled="workspaceFrozen" @click="switchWorkspace('existing')">调整已有排车货物</button>
|
||||
</div>
|
||||
<SmartDispatchExistingCargo v-if="workspace === 'existing' && modelValue" @saved="emit('created')" @busy="existingBusy = $event" />
|
||||
<template v-else-if="workspace === 'create'">
|
||||
<AppDialog class="admin-density-dialog admin-density admin-workbench is-dialog smart-dispatch-dialog" :class="{ 'is-cargo-workspace': mode === 'cargo' }" append-to-body :lock-scroll="true" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" fullscreenable default-fullscreen :loading="working" :confirm-loading="saving || recovery.busy.value" :confirm-disabled="confirmDisabled" :confirm-text="mode === 'empty' ? '保存空排车单' : '生成排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
|
||||
<div class="density-dialog-sections dispatch-workspace">
|
||||
<LoadErrorState v-if="error" title="排车未完成" :message="error.message" :loading="loading || saving || recovery.busy.value" @retry="init()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<section class="density-dialog-section dispatch-search" aria-label="候选货物查询">
|
||||
@@ -283,7 +253,6 @@ watch(() => props.modelValue, value => { if (value) { publishLooseboardDemand.va
|
||||
</SmartDispatchCargoBoard>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
<style scoped>
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import SmartDispatchExistingCargo from './SmartDispatchExistingCargo.vue'
|
||||
import * as api from '../api/dispatches'
|
||||
import { candidate, command, detail, listRow, page, sharedPalletCandidates } from '../dispatchTestFixtures'
|
||||
import type { DispatchCargoItem, DispatchDetail, DispatchItems } from '../dispatchContracts'
|
||||
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
|
||||
|
||||
const identity = vi.hoisted(() => ({ id: '', allowed: true }))
|
||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => identity.allowed }) }))
|
||||
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), getDispatch: vi.fn(), getDispatchItems: vi.fn(), listDispatchCandidates: vi.fn(), addDispatchCargo: vi.fn(), removeDispatchItem: vi.fn(), getDispatchCommand: vi.fn() }))
|
||||
enableAutoUnmount(afterEach)
|
||||
const allowed = { allowed: true, blockCode: null, blockReason: null }
|
||||
const current = (overrides: Partial<DispatchDetail> = {}) => detail({ taskType: 'DELIVERY', actions: { ADD_CARGO: allowed, REMOVE_CARGO: allowed }, ...overrides })
|
||||
function cargo(overrides: Partial<DispatchCargoItem> = {}): DispatchCargoItem {
|
||||
const source = candidate()
|
||||
return { ...source.selection, dispatchItemId: '9007199254740993', sourceKey: source.sourceKey, sourceVersion: source.sourceVersion, customerId: 'C1', customerName: '客户甲', palletNo: 'PALLET-1', orderNo: 'LM-1', inboundCode: 'IN-1', destination: source.destination, scheduledPackages: 2, pickedPackages: 0, remove: allowed, ...overrides }
|
||||
}
|
||||
const manifest = (overrides: Partial<DispatchItems> = {}): DispatchItems => ({ dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [cargo()], ...overrides })
|
||||
function button(wrapper: VueWrapper, text: string) { const found = wrapper.findAll('button').find(row => row.text() === text); if (!found) throw new Error(`Missing button ${text}`); return found }
|
||||
async function open(dispatchNo: string | null = 'PC/1') {
|
||||
const wrapper = mount(SmartDispatchExistingCargo, { props: { dispatchNo: dispatchNo ?? undefined }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
async function add(wrapper: VueWrapper) { await button(wrapper, '查看托盘').trigger('click'); await wrapper.get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises() }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); identity.allowed = true
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
vi.mocked(api.listDispatches).mockResolvedValue(page([listRow({ taskType: 'DELIVERY' })]))
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(current())
|
||||
vi.mocked(api.getDispatchItems).mockResolvedValue(manifest())
|
||||
vi.mocked(api.listDispatchCandidates).mockImplementation(async query => ({ candidates: [candidate()], page: query.page || 1, size: query.size || 20, total: 1, pages: 1, complete: true, snapshotAt: '' }))
|
||||
})
|
||||
|
||||
it('keeps both searches in one panel and the manifest inside the shared board column', async () => {
|
||||
const wrapper = await open()
|
||||
const search = wrapper.get('[aria-label="已有排车查询"]')
|
||||
expect(search.find('[aria-label="选择已有排车"]').exists()).toBe(true)
|
||||
expect(search.text()).not.toContain('查询排车')
|
||||
expect(search.find('input[aria-label="查找可排货物"]').exists()).toBe(true)
|
||||
expect(search.findAll('.existing-cargo-query > label')).toHaveLength(0)
|
||||
const column = wrapper.get('[aria-label="车辆装载"]')
|
||||
expect(column.get(':scope > .cargo-board-heading').text()).toContain('本车货物 · PC/1')
|
||||
expect(column.find(':scope > .cargo-board-vehicle-content [aria-label="当前排车货物"]').exists()).toBe(true)
|
||||
expect(wrapper.get('.dispatch-cargo-board').findAll(':scope > section > .cargo-board-heading')).toHaveLength(3)
|
||||
expect(column.text()).not.toContain('添加车辆')
|
||||
})
|
||||
|
||||
it('adds the complete hidden shared-pallet group through real board buttons, then reloads current versions', async () => {
|
||||
const [first, second] = sharedPalletCandidates()
|
||||
vi.mocked(api.listDispatchCandidates).mockImplementation(async query => ({ candidates: query.size === 100 ? [first!, second!] : [first!], page: query.page!, size: query.size!, total: 2, pages: 1, complete: true, snapshotAt: '' }))
|
||||
const wrapper = await open()
|
||||
vi.mocked(api.addDispatchCargo).mockImplementation(async (_, body) => {
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(current({ recordVersion: 1 }))
|
||||
vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ recordVersion: 1 }))
|
||||
return command('ADD_CARGO', body.idempotencyKey)
|
||||
})
|
||||
await add(wrapper)
|
||||
expect(api.addDispatchCargo).toHaveBeenCalledExactlyOnceWith('PC/1', expect.objectContaining({ recordVersion: 0, cargo: [first!.selection, second!.selection] }))
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
expect(api.getDispatchItems).toHaveBeenCalledTimes(3)
|
||||
expect(api.listDispatchCandidates).toHaveBeenCalledTimes(3)
|
||||
expect(wrapper.find('[aria-label="当前排车货物"]').text()).toContain('真实已拣 0 箱')
|
||||
expect(wrapper.text()).not.toContain('添加车辆')
|
||||
})
|
||||
|
||||
it.each(['complete', 'missing', 'changed'] as const)('reads appointments of a hidden second group source: %s', async scenario => {
|
||||
const first = candidate()
|
||||
const second = candidate({ sourceKey: 'source-2', customerId: 'C2', selection: { ...first.selection, planPalletId: '12' }, appointmentBatches: [{ batchKey: 'B2', batchNo: 'AP2', customerId: 'C2', recordVersion: '9007199254740993', sources: [{ sourceKey: 'S2', destinationKey: 'D2', packageCount: 2 }] }] })
|
||||
const group = { ...first.selectionGroup, sources: [...first.selectionGroup.sources, ...second.selectionGroup.sources], pallets: [{ warehousePalletId: '21', palletNo: 'PALLET-1', sourceKeys: ['source-1', 'source-2'], packageCount: 4 }] }
|
||||
first.selectionGroup = group; second.selectionGroup = group
|
||||
vi.mocked(api.listDispatchCandidates).mockImplementation(async query => ({ candidates: query.size !== 100 ? [first] : query.page === 1 ? [first] : scenario === 'missing' ? [] : [{ ...second, selectionGroup: scenario === 'changed' ? { ...group, groupVersion: 'new-composition' } : group }], page: query.page!, size: query.size!, total: 101, pages: 2, complete: true, snapshotAt: '' }))
|
||||
vi.mocked(api.addDispatchCargo).mockRejectedValue(new Error('unknown'))
|
||||
const wrapper = await open(); await add(wrapper)
|
||||
if (scenario === 'complete') {
|
||||
expect(api.listDispatchCandidates).toHaveBeenCalledWith({ warehouseId: '321', warehouseTab: 'ALL', page: 2, size: 100 })
|
||||
expect(api.addDispatchCargo).toHaveBeenCalledWith('PC/1', expect.objectContaining({ cargo: [first.selection, second.selection], batchAllocations: [{ planPalletId: '12', batchKey: 'B2', customerId: 'C2', recordVersion: '9007199254740993', sourceKey: 'S2', destinationKey: 'D2', packageCount: 2 }] }))
|
||||
expect(wrapper.emitted('busy')?.at(-1)).toEqual([false])
|
||||
} else {
|
||||
expect(api.addDispatchCargo).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain(scenario === 'missing' ? '同托货物及预约资料未完整返回' : '选货组组成已变化')
|
||||
}
|
||||
})
|
||||
|
||||
it.each([0, '0', '9007199254740993'])('preserves precise appointment allocation version %s in additions', async recordVersion => {
|
||||
const source = candidate({ appointmentBatches: [{ batchKey: 'B1', batchNo: 'AP1', customerId: 'C1', recordVersion, sources: [{ sourceKey: 'S1', destinationKey: 'D1', packageCount: 2 }] }] })
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [source], page: 1, size: 20, total: 1, pages: 1, complete: true, snapshotAt: '' })
|
||||
vi.mocked(api.addDispatchCargo).mockRejectedValue(new Error('unknown'))
|
||||
const wrapper = await open(); await add(wrapper)
|
||||
expect(api.addDispatchCargo).toHaveBeenCalledWith('PC/1', expect.objectContaining({ batchAllocations: [{ planPalletId: '11', batchKey: 'B1', customerId: 'C1', recordVersion, sourceKey: 'S1', destinationKey: 'D1', packageCount: 2 }] }))
|
||||
expect(wrapper.get('button[aria-label^="将托盘 "]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('requires a removal reason and submits the authoritative item ID and zero version', async () => {
|
||||
const wrapper = await open()
|
||||
await button(wrapper, '移除货物').trigger('click')
|
||||
expect(button(wrapper, '确认移除').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('textarea[aria-label="移除原因"]').setValue('本车计划调整')
|
||||
vi.mocked(api.removeDispatchItem).mockImplementation(async (_, __, body) => {
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(current({ recordVersion: 1 }))
|
||||
vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ recordVersion: 1, items: [] }))
|
||||
return command('REMOVE_CARGO', body.idempotencyKey)
|
||||
})
|
||||
await button(wrapper, '确认移除').trigger('click'); await flushPromises()
|
||||
expect(api.removeDispatchItem).toHaveBeenCalledExactlyOnceWith('PC/1', '9007199254740993', expect.objectContaining({ recordVersion: 0, reason: '本车计划调整' }))
|
||||
expect(wrapper.text()).toContain('本车暂无货物')
|
||||
})
|
||||
|
||||
it('rejects an addition whose authoritative appointment version is missing', async () => {
|
||||
const source = candidate({ appointmentBatches: [{ batchKey: 'B1', batchNo: 'AP1', customerId: 'C1', recordVersion: undefined as unknown as number, sources: [] }] })
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [source], page: 1, size: 20, total: 1, pages: 1, complete: true, snapshotAt: '' })
|
||||
const wrapper = await open(); await add(wrapper)
|
||||
expect(api.addDispatchCargo).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('预约批次版本缺失或无效')
|
||||
expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a candidate page from another warehouse before exposing selectable cargo', async () => {
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [candidate({ warehouseId: 'OTHER' })], page: 1, size: 20, total: 1, pages: 1, complete: true, snapshotAt: '' })
|
||||
const wrapper = await open()
|
||||
expect(wrapper.text()).toContain('候选起运仓或来源版本不完整')
|
||||
expect(wrapper.findAll('button').some(row => row.text() === '加入这辆车')).toBe(false)
|
||||
expect(api.addDispatchCargo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['picked', 'item-gate', 'shared-pallet'] as const)('locks removal for %s instead of changing real picking facts', async scenario => {
|
||||
vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ items: scenario === 'shared-pallet' ? [cargo(), cargo({ dispatchItemId: 'other', sourceKey: 'other' })] : [cargo(scenario === 'picked' ? { pickedPackages: 1 } : { remove: { allowed: false, blockCode: 'LOCK', blockReason: '正在作业' } })] }))
|
||||
const wrapper = await open()
|
||||
for (const row of wrapper.findAll('button').filter(row => row.text() === '移除货物')) expect(row.attributes('disabled')).toBeDefined()
|
||||
expect(api.removeDispatchItem).not.toHaveBeenCalled()
|
||||
if (scenario === 'shared-pallet') expect(wrapper.text()).toContain('同一实物托盘包含多个来源')
|
||||
})
|
||||
|
||||
it.each(['loading', 'permission', 'task-type', 'version', 'read-failure'] as const)('fails closed when %s blocks editing', async scenario => {
|
||||
if (scenario === 'loading') { vi.mocked(api.getDispatch).mockResolvedValue(current({ stage: 'LOADING' })); vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ stage: 'LOADING' })) }
|
||||
if (scenario === 'permission') identity.allowed = false
|
||||
if (scenario === 'task-type') vi.mocked(api.getDispatch).mockResolvedValue(current({ taskType: 'PICKUP' }))
|
||||
if (scenario === 'version') vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ recordVersion: 2 }))
|
||||
if (scenario === 'read-failure') vi.mocked(api.getDispatchItems).mockRejectedValue(new Error('read unavailable'))
|
||||
const wrapper = await open()
|
||||
expect(api.listDispatchCandidates).not.toHaveBeenCalled()
|
||||
expect(wrapper.findAll('button').filter(row => row.text() === '移除货物').every(row => row.attributes('disabled') !== undefined)).toBe(true)
|
||||
expect(api.addDispatchCargo).not.toHaveBeenCalled()
|
||||
if (scenario === 'version') expect(wrapper.text()).toContain('一致版本')
|
||||
})
|
||||
|
||||
it('unlocks a failed removal and accepts an updated reason on a fresh request',async()=>{
|
||||
const wrapper=await open()
|
||||
await button(wrapper,'移除货物').trigger('click');await wrapper.get('textarea[aria-label="移除原因"]').setValue('原移除原因')
|
||||
vi.mocked(api.removeDispatchItem).mockRejectedValueOnce(new Error('unknown result'))
|
||||
await button(wrapper,'确认移除').trigger('click');await flushPromises()
|
||||
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
|
||||
expect((wrapper.get('textarea[aria-label="移除原因"]').element as HTMLTextAreaElement).value).toBe('原移除原因')
|
||||
await wrapper.get('textarea[aria-label="移除原因"]').setValue('重新安排')
|
||||
vi.mocked(api.removeDispatchItem).mockImplementationOnce(async(_,__,body)=>command('REMOVE_CARGO',body.idempotencyKey))
|
||||
await button(wrapper,'确认移除').trigger('click');await flushPromises()
|
||||
const calls=vi.mocked(api.removeDispatchItem).mock.calls
|
||||
expect(calls).toHaveLength(2);expect(calls[1]![2].reason).toBe('重新安排')
|
||||
expect(calls[1]![2].idempotencyKey).not.toBe(calls[0]![2].idempotencyKey)
|
||||
expect(api.getDispatchCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('unlocks a completed request when its follow-up manifest read fails', async () => {
|
||||
const wrapper = await open()
|
||||
vi.mocked(api.addDispatchCargo).mockImplementation(async (_, body) => {
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(current({ recordVersion: 1 }))
|
||||
vi.mocked(api.getDispatchItems).mockRejectedValue(new Error('read unavailable'))
|
||||
return command('ADD_CARGO', body.idempotencyKey)
|
||||
})
|
||||
await add(wrapper)
|
||||
expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull()
|
||||
vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ recordVersion: 1 }))
|
||||
await button(wrapper, '刷新本车').trigger('click'); await flushPromises()
|
||||
expect(api.addDispatchCargo).toHaveBeenCalledTimes(1)
|
||||
expect(api.getDispatchCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not let a late previous dispatch read overwrite the selected dispatch', async () => {
|
||||
let resolve!: (value: DispatchDetail) => void
|
||||
vi.mocked(api.listDispatches).mockResolvedValue(page([listRow({ taskType: 'DELIVERY' }), listRow({ dispatchNo: 'PC/2', taskType: 'DELIVERY' })]))
|
||||
vi.mocked(api.getDispatch).mockImplementation(no => no === 'PC/1' ? new Promise(done => { resolve = done }) : Promise.resolve(current({ dispatchNo: 'PC/2' })))
|
||||
vi.mocked(api.getDispatchItems).mockImplementation(async no => manifest({ dispatchNo: no }))
|
||||
const wrapper = await open()
|
||||
await wrapper.setProps({ dispatchNo: 'PC/2' }); await flushPromises()
|
||||
resolve(current()); await flushPromises()
|
||||
expect(wrapper.get('[aria-label="车辆装载"] > .cargo-board-heading').text()).toContain('PC/2')
|
||||
expect(wrapper.get('[aria-label="车辆装载"] > .cargo-board-heading').text()).not.toContain('PC/1')
|
||||
})
|
||||
|
||||
it('queries and paginates active before-loading dispatches using the entered business number', async () => {
|
||||
const wrapper = await open(null)
|
||||
vi.mocked(api.listDispatches).mockImplementation(async query => ({ ...page([listRow({ taskType: 'DELIVERY' })]), page: query.page, size: query.size, total: 22, pages: 3 }))
|
||||
const picker = wrapper.getComponent({ name: 'ElSelect' })
|
||||
expect(picker.props('remote')).toBe(true)
|
||||
await picker.get('.el-select__wrapper').trigger('click'); await flushPromises()
|
||||
await picker.props('remoteMethod')('PC2026'); await flushPromises()
|
||||
await wrapper.getComponent({ name: 'ElPagination' }).get('button.btn-next').trigger('click'); await flushPromises()
|
||||
expect(api.listDispatches).toHaveBeenLastCalledWith({ page: 2, size: 10, status: 'ACTIVE', stage: 'BEFORE_LOADING', dispatchNo: 'PC2026' })
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user