fix: close appointment cancellation bypass and clarify verified cargo
This commit is contained in:
@@ -70,6 +70,18 @@ describe('appointment calendar data integration', () => {
|
|||||||
vi.mocked(createFbaAppointment).mockResolvedValue({ version: 0, id: '1', appointmentNo: 'APT-AB', plannedDate: '2026-10-09', appointmentAt: null, originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] })
|
vi.mocked(createFbaAppointment).mockResolvedValue({ version: 0, id: '1', appointmentNo: 'APT-AB', plannedDate: '2026-10-09', appointmentAt: null, originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses the dedicated cancellation dialog for an undated appointment instead of editing its status', async () => {
|
||||||
|
const record = { version: 3, id: '1', appointmentNo: 'AP-UNDATED', originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 1, fbaWarehouseCode: 'ABE3', items: [] }
|
||||||
|
vi.mocked(listUnscheduledAppointments).mockResolvedValue({records: [record], total: 1})
|
||||||
|
vi.mocked(getFbaAppointment).mockResolvedValue(record)
|
||||||
|
const wrapper=render(); await flushPromises()
|
||||||
|
await wrapper.findAll('button').find(button=>button.text()==='查看 / 继续')!.trigger('click'); await flushPromises()
|
||||||
|
expect(wrapper.find('option[value="CANCELLED"]').exists()).toBe(false)
|
||||||
|
await wrapper.get('[data-cancel-editing]').trigger('click'); await flushPromises()
|
||||||
|
expect(wrapper.findComponent({name:'AppointmentCancellationDialog'}).attributes('appointment-no')).toBe('AP-UNDATED')
|
||||||
|
expect(wrapper.find('[data-save]').exists()).toBe(false)
|
||||||
|
expect(updateFbaAppointment).not.toHaveBeenCalled(); wrapper.unmount()
|
||||||
|
})
|
||||||
it('loads cancellation records only after expansion and opens cancelled appointments read-only', async () => {
|
it('loads cancellation records only after expansion and opens cancelled appointments read-only', async () => {
|
||||||
const record = { version: 1, id: '1', appointmentNo: 'AP-CANCEL', originWarehouseId: '100', appointmentStatus: 'CANCELLED', selectedCount: 1, fbaWarehouseCode: 'ABE3', items: [] }
|
const record = { version: 1, id: '1', appointmentNo: 'AP-CANCEL', originWarehouseId: '100', appointmentStatus: 'CANCELLED', selectedCount: 1, fbaWarehouseCode: 'ABE3', items: [] }
|
||||||
vi.mocked(getFbaAppointment).mockResolvedValue(record)
|
vi.mocked(getFbaAppointment).mockResolvedValue(record)
|
||||||
|
|||||||
@@ -354,6 +354,11 @@ function remove(event: CalendarEvent) {
|
|||||||
if (!access.can('platform:appointment:delete') || loading.value || !canLeave()) return
|
if (!access.can('platform:appointment:delete') || loading.value || !canLeave()) return
|
||||||
cancellationTarget.value = appointments.value.find(item => item.appointmentNo === event.key) || null
|
cancellationTarget.value = appointments.value.find(item => item.appointmentNo === event.key) || null
|
||||||
}
|
}
|
||||||
|
function cancelEditing() {
|
||||||
|
if (!editing.value || editing.value.appointmentStatus === 'CANCELLED' || !access.can('platform:appointment:delete') || !canLeave()) return
|
||||||
|
cancellationTarget.value = editing.value
|
||||||
|
dialogOpen.value = false
|
||||||
|
}
|
||||||
async function cancelled() {
|
async function cancelled() {
|
||||||
cancellationTarget.value = null; ++cancellationsRevision.value
|
cancellationTarget.value = null; ++cancellationsRevision.value
|
||||||
ElMessage.success('预约已取消,可在取消记录中查看原因与原记录')
|
ElMessage.success('预约已取消,可在取消记录中查看原因与原记录')
|
||||||
@@ -439,6 +444,7 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ
|
|||||||
<LoadErrorState v-if="!editing && warehouseError" title="仓库加载失败" :message="warehouseError.message" @retry="searchWarehouses(warehouseKeyword)" />
|
<LoadErrorState v-if="!editing && warehouseError" title="仓库加载失败" :message="warehouseError.message" @retry="searchWarehouses(warehouseKeyword)" />
|
||||||
<p v-if="!editing && warehouseTotal > warehouseOptions.length && !warehouseError" class="appointment-hint">输入更完整的仓库编码或名称,缩小搜索范围。</p>
|
<p v-if="!editing && warehouseTotal > warehouseOptions.length && !warehouseError" class="appointment-hint">输入更完整的仓库编码或名称,缩小搜索范围。</p>
|
||||||
<FbaAppointmentFields :model="form" :appointment-no="editing?.appointmentNo" :timezone="editing?.timezone || warehouseOptions.find(item => item.warehouseCode === warehouse)?.timezone" :disabled="appointmentReadOnly || saving || !!pendingSave" />
|
<FbaAppointmentFields :model="form" :appointment-no="editing?.appointmentNo" :timezone="editing?.timezone || warehouseOptions.find(item => item.warehouseCode === warehouse)?.timezone" :disabled="appointmentReadOnly || saving || !!pendingSave" />
|
||||||
|
<el-button v-if="editing && editing.appointmentStatus !== 'CANCELLED' && access.can('platform:appointment:delete')" data-cancel-editing type="danger" plain :disabled="saving || !!pendingSave || !detailReady" @click="cancelEditing">取消预约</el-button>
|
||||||
<template v-if="!editing && warehouse">
|
<template v-if="!editing && warehouse">
|
||||||
<DataTable :key="selectionEpoch" v-model:page="candidatePage" v-model:page-size="candidateSize" :data="candidates" :columns="columns" :total="candidateTotal" :loading="candidateLoading" row-key="lineId" :results-visible="!candidateError" @selection-change="selectCandidates" @update:page="loadCandidates()" @update:page-size="candidatePage = 1; loadCandidates()">
|
<DataTable :key="selectionEpoch" v-model:page="candidatePage" v-model:page-size="candidateSize" :data="candidates" :columns="columns" :total="candidateTotal" :loading="candidateLoading" row-key="lineId" :results-visible="!candidateError" @selection-change="selectCandidates" @update:page="loadCandidates()" @update:page-size="candidatePage = 1; loadCandidates()">
|
||||||
<template #customer="{ row }">{{ row.customerName?.trim() || '客户名称缺失' }}</template>
|
<template #customer="{ row }">{{ row.customerName?.trim() || '客户名称缺失' }}</template>
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ import { flushPromises, mount } from '@vue/test-utils'
|
|||||||
import ElementPlus from 'element-plus'
|
import ElementPlus from 'element-plus'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import AppointmentImpactDialog from './AppointmentImpactDialog.vue'
|
import AppointmentImpactDialog from './AppointmentImpactDialog.vue'
|
||||||
|
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
|
||||||
import { getImpact, handleImpact, type ImpactDetail } from './impactApi'
|
import { getImpact, handleImpact, type ImpactDetail } from './impactApi'
|
||||||
vi.mock('./impactApi', async importOriginal => ({ ...await importOriginal<typeof import('./impactApi')>(), getImpact: vi.fn(), handleImpact: vi.fn() }))
|
vi.mock('./impactApi', async importOriginal => ({ ...await importOriginal<typeof import('./impactApi')>(), getImpact: vi.fn(), handleImpact: vi.fn() }))
|
||||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
const guards = vi.hoisted(() => ({ register: vi.fn(), unregister: vi.fn(), push: vi.fn() }))
|
||||||
|
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRouter: () => ({ push: guards.push }) }))
|
||||||
const detail: ImpactDetail = { impact: { containerBookingId: '1', impactNo: 'AI-TEST', version: 1, status: 'OPEN', reason: 'FORECAST_CHANGED', ownerId: null, ownerName: null, baseline: { estimatedAt: '2026-10-15T09:00:00', actualAt: null, observedAt: null, providerCode: 'carrier', sourceReference: 'ref' }, latest: { estimatedAt: '2026-10-17T09:00:00', actualAt: null, observedAt: null, providerCode: 'carrier', sourceReference: 'ref' }, forecastToken: 'current', updatedAt: '2026-10-10T12:00:00' }, cargo: [{ inboundCode: 'IN-A', customerName: '客户甲', packages: 10, destination: 'ONT8' }], appointments: [{ appointmentNo: 'AP-1', status: 'SUCCESS', destination: 'ONT8', confirmedAt: '2026-10-16T09:00:00', timezone: 'America/Los_Angeles' }], transport: null, history: { records: [], total: 0 } }
|
const detail: ImpactDetail = { impact: { containerBookingId: '1', impactNo: 'AI-TEST', version: 1, status: 'OPEN', reason: 'FORECAST_CHANGED', ownerId: null, ownerName: null, baseline: { estimatedAt: '2026-10-15T09:00:00', actualAt: null, observedAt: null, providerCode: 'carrier', sourceReference: 'ref' }, latest: { estimatedAt: '2026-10-17T09:00:00', actualAt: null, observedAt: null, providerCode: 'carrier', sourceReference: 'ref' }, forecastToken: 'current', updatedAt: '2026-10-10T12:00:00' }, cargo: [{ inboundCode: 'IN-A', customerName: '客户甲', packages: 10, destination: 'ONT8' }], appointments: [{ appointmentNo: 'AP-1', status: 'SUCCESS', destination: 'ONT8', confirmedAt: '2026-10-16T09:00:00', timezone: 'America/Los_Angeles' }], transport: null, history: { records: [], total: 0 } }
|
||||||
const render = (canWrite = true) => mount(AppointmentImpactDialog, { props: { containerId: '1', canWrite }, global: { plugins: [ElementPlus], stubs: { AppDialog: { template: '<section><slot /><button data-close @click="$emit(\'update:model-value\',false)">关闭</button></section>' } } } })
|
const render = (canWrite = true) => mount(AppointmentImpactDialog, { props: { containerId: '1', canWrite }, global: { provide: { 'appointment-register-leave-guard': guards.register }, plugins: [ElementPlus], stubs: { AppDialog: { name: 'AppDialog', props: ['beforeClose'], template: '<section><slot /><button data-close @click="$emit(\'update:model-value\',false)">关闭</button></section>' } } } })
|
||||||
describe('appointment impact handling', () => {
|
describe('appointment impact handling', () => {
|
||||||
beforeEach(() => { vi.resetAllMocks(); vi.mocked(getImpact).mockResolvedValue(detail) })
|
beforeEach(() => { vi.resetAllMocks(); guards.register.mockReturnValue(guards.unregister); vi.mocked(getImpact).mockResolvedValue(detail) })
|
||||||
it('shows separate forecasts, unchanged confirmed appointment and omitted transport permission', async () => {
|
it('shows separate forecasts, unchanged confirmed appointment and omitted transport permission', async () => {
|
||||||
const wrapper = render(false); await flushPromises()
|
const wrapper = render(false); await flushPromises()
|
||||||
expect(wrapper.text()).toContain('2026-10-15T09:00:00')
|
expect(wrapper.text()).toContain('2026-10-15T09:00:00')
|
||||||
@@ -33,15 +35,22 @@ describe('appointment impact handling', () => {
|
|||||||
})
|
})
|
||||||
it('freezes an uncertain command and retries exactly once with original version and key', async () => {
|
it('freezes an uncertain command and retries exactly once with original version and key', async () => {
|
||||||
vi.mocked(handleImpact).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({ ...detail.impact, version: 2 })
|
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()
|
const wrapper = render(); await flushPromises()
|
||||||
await wrapper.findAll('button').find(b => b.text() === '认领跟进')!.trigger('click'); await flushPromises()
|
await wrapper.findAll('button').find(b => b.text() === '认领跟进')!.trigger('click'); await flushPromises()
|
||||||
const original = vi.mocked(handleImpact).mock.calls[0]![1]
|
const original = vi.mocked(handleImpact).mock.calls[0]![1]
|
||||||
expect(original).toMatchObject({ action: 'CLAIM', expectedVersion: 1, forecastToken: 'current' })
|
expect(original).toMatchObject({ action: 'CLAIM', expectedVersion: 1, forecastToken: 'current' })
|
||||||
await wrapper.get('[data-close]').trigger('click'); expect(wrapper.emitted('close')).toBeUndefined()
|
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()
|
await wrapper.findAll('button').find(b => b.text() === '重试本次处理')!.trigger('click'); await flushPromises()
|
||||||
expect(handleImpact).toHaveBeenNthCalledWith(2, '1', original)
|
expect(handleImpact).toHaveBeenNthCalledWith(2, '1', original)
|
||||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||||
expect(getImpact).toHaveBeenCalledTimes(2); wrapper.unmount()
|
expect(getImpact).toHaveBeenCalledTimes(2)
|
||||||
|
expect(guards.register.mock.calls[0]![0]()).toBe(true)
|
||||||
|
wrapper.unmount(); expect(guards.unregister).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
it('does not resolve without an actual handling result', async () => {
|
it('does not resolve without an actual handling result', async () => {
|
||||||
const wrapper = render(); await flushPromises()
|
const wrapper = render(); await flushPromises()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { inject, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
@@ -27,11 +27,19 @@ async function save(action: ImpactCommand['action']) {
|
|||||||
catch (cause) { error.value = getApiErrorDetails(cause, '处理结果待核实,请重试原请求'); if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(error.value.code || '')) pending.value = null }
|
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 }
|
finally { saving.value = false }
|
||||||
}
|
}
|
||||||
function close() { if (saving.value || pending.value) { ElMessage.warning('请先核实本次处理结果,再关闭'); return } emit('close') }
|
function canLeave() { if (saving.value || pending.value) { ElMessage.warning('请先核实本次处理结果,再关闭或切换'); return false } return true }
|
||||||
|
function close() { if (canLeave()) emit('close') }
|
||||||
|
function beforeClose(done: () => void) { if (canLeave()) done() }
|
||||||
|
function openTrip(tripNo: string) { if (canLeave()) void router.push(`/overseas-master-data/trips/${encodeURIComponent(tripNo)}`) }
|
||||||
|
const register = inject<((guard: () => boolean) => () => void) | undefined>('appointment-register-leave-guard', undefined)
|
||||||
|
const unregister = register?.(canLeave)
|
||||||
|
onBeforeUnmount(() => unregister?.())
|
||||||
|
onBeforeRouteLeave(canLeave)
|
||||||
|
onBeforeRouteUpdate(canLeave)
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<AppDialog :model-value="true" title="船期变化影响核对" width="min(1200px, calc(100vw - 48px))" :show-confirm="false" class="admin-density-dialog" @update:model-value="close">
|
<AppDialog :model-value="true" title="船期变化影响核对" width="min(1200px, calc(100vw - 48px))" :show-confirm="false" class="admin-density-dialog" @update:model-value="close" :before-close="beforeClose">
|
||||||
<LoadErrorState v-if="error" title="核对未完成" :message="error.message" :show-retry="!pending" @retry="load" />
|
<LoadErrorState v-if="error" title="核对未完成" :message="error.message" :show-retry="!pending" @retry="load" />
|
||||||
<template v-if="detail">
|
<template v-if="detail">
|
||||||
<p>{{ detail.impact.impactNo }} · {{ impactStatus[detail.impact.status] }} · 负责人:{{ detail.impact.ownerName || '未认领' }}</p>
|
<p>{{ detail.impact.impactNo }} · {{ impactStatus[detail.impact.status] }} · 负责人:{{ detail.impact.ownerName || '未认领' }}</p>
|
||||||
@@ -43,7 +51,7 @@ onMounted(load)
|
|||||||
<details open><summary>需复核的收货预约 {{ detail.appointments.length }} 张(确认时间不自动修改)</summary><div class="impact-list"><p v-for="row in detail.appointments" :key="row.appointmentNo">{{ row.appointmentNo }} · {{ row.destination }} · {{ row.confirmedAt || '尚未确认时段' }} {{ row.timezone || '时区待核实' }}</p></div></details>
|
<details open><summary>需复核的收货预约 {{ detail.appointments.length }} 张(确认时间不自动修改)</summary><div class="impact-list"><p v-for="row in detail.appointments" :key="row.appointmentNo">{{ row.appointmentNo }} · {{ row.destination }} · {{ row.confirmedAt || '尚未确认时段' }} {{ row.timezone || '时区待核实' }}</p></div></details>
|
||||||
<p v-if="!detail.transport">无车次查看权限,运输影响未加载。</p>
|
<p v-if="!detail.transport">无车次查看权限,运输影响未加载。</p>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<details open><summary>关联车次 {{ detail.transport.trips.length }} 辆</summary><div class="impact-list"><p v-for="row in detail.transport.trips" :key="row.tripNo"><el-button link type="primary" @click="router.push(`/overseas-master-data/trips/${encodeURIComponent(row.tripNo)}`)">{{ row.tripNo }}</el-button> · 计划出发(UTC):{{ row.plannedDepartureAt || '待定' }}</p></div></details>
|
<details open><summary>关联车次 {{ detail.transport.trips.length }} 辆</summary><div class="impact-list"><p v-for="row in detail.transport.trips" :key="row.tripNo"><el-button link type="primary" @click="openTrip(row.tripNo)">{{ row.tripNo }}</el-button> · 计划出发(UTC):{{ row.plannedDepartureAt || '待定' }}</p></div></details>
|
||||||
<details><summary>同车外接承诺 {{ detail.transport.sameTripBookings.length }} 票,仅提示运输安排复核</summary><div class="impact-list"><p v-for="row in detail.transport.sameTripBookings" :key="row.bookingNo">{{ row.bookingNo }} · {{ row.customerName || '—' }} · {{ row.tripNo }} · {{ row.destinationLabel || '—' }}</p></div></details>
|
<details><summary>同车外接承诺 {{ detail.transport.sameTripBookings.length }} 票,仅提示运输安排复核</summary><div class="impact-list"><p v-for="row in detail.transport.sameTripBookings" :key="row.bookingNo">{{ row.bookingNo }} · {{ row.customerName || '—' }} · {{ row.tripNo }} · {{ row.destinationLabel || '—' }}</p></div></details>
|
||||||
</template>
|
</template>
|
||||||
<el-form v-if="canWrite && detail.impact.status === 'OPEN'" label-position="top" :disabled="saving || !!pending">
|
<el-form v-if="canWrite && detail.impact.status === 'OPEN'" label-position="top" :disabled="saving || !!pending">
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export interface InquiryResult extends OfferResult { inquiry: CapacityInquiry }
|
|||||||
export interface OfferCustomerOption { id: string; code: string; name: string }
|
export interface OfferCustomerOption { id: string; code: string; name: string }
|
||||||
export const offerStatusLabels = { DRAFT: '发布草稿', OPEN: '接单中', PAUSED: '已暂停', CLOSED: '已关闭' }
|
export const offerStatusLabels = { DRAFT: '发布草稿', OPEN: '接单中', PAUSED: '已暂停', CLOSED: '已关闭' }
|
||||||
export const inquiryStatusLabels = { DRAFT: '已登记', CONFIRMED: '已确认承运', RECEIVED: '已确认实收', BOUND: '已排入本车', CANCELLED: '已取消' }
|
export const inquiryStatusLabels = { DRAFT: '已登记', CONFIRMED: '已确认承运', RECEIVED: '已确认实收', BOUND: '已排入本车', CANCELLED: '已取消' }
|
||||||
|
export const inquiryCargoLabel = (status?: CapacityInquiry['status']) => status === 'RECEIVED' || status === 'BOUND' ? '已核实' : status === 'CANCELLED' ? '记录' : '预报'
|
||||||
export const offerTime = (value: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
|
export const offerTime = (value: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
|
||||||
export const loadText = (value: CapacityLoad | null) => value ? `${value.palletSpaces} 托位 / ${value.weightKg} kg / ${value.volumeCbm} m³` : '待调度核对'
|
export const loadText = (value: CapacityLoad | null) => value ? `${value.palletSpaces} 托位 / ${value.weightKg} kg / ${value.volumeCbm} m³` : '待调度核对'
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/sh
|
|||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { listOfferCustomers, listOfferQuotes, saveCapacityInquiry, saveAndConfirmInquiry } from '../api/capacityOffers'
|
import { listOfferCustomers, listOfferQuotes, saveCapacityInquiry, saveAndConfirmInquiry } from '../api/capacityOffers'
|
||||||
import { fleetQuoteReference, type FleetQuoteReference, type FleetQuoteSourceSnapshot } from '../api/fleetQuoteCandidates'
|
import { fleetQuoteReference, type FleetQuoteReference, type FleetQuoteSourceSnapshot } from '../api/fleetQuoteCandidates'
|
||||||
import { offerTime, appointmentCoverageLabels, appointmentMissingLabels } from '../capacityOfferTypes'
|
import { offerTime, appointmentCoverageLabels, appointmentMissingLabels, inquiryCargoLabel } from '../capacityOfferTypes'
|
||||||
import type { CapacityOffer, CapacityInquiry, InquiryResult, InquiryWrite, OfferCustomerOption } from '../capacityOfferTypes'
|
import type { CapacityOffer, CapacityInquiry, InquiryResult, InquiryWrite, OfferCustomerOption } from '../capacityOfferTypes'
|
||||||
import type { TripBookingCargo } from '../bookingTypes'
|
import type { TripBookingCargo } from '../bookingTypes'
|
||||||
import { useFleetDraftGuard } from '../useFleetDraftGuard'
|
import { useFleetDraftGuard } from '../useFleetDraftGuard'
|
||||||
@@ -47,6 +47,7 @@ const target = computed(() => props.inquiry?.bookingNo || props.offer?.offerNo |
|
|||||||
const writable = computed(() => !props.readonly && (access.can('platform:fleet:capacity-offer:manage') || access.can('platform:fleet:capacity-offer:list') && access.can(`platform:fleet:capacity-offer:inquiry-${props.inquiry ? 'edit' : 'create'}`)) && (props.inquiry ? props.inquiry.actions.canEdit : props.offer?.canCreateInquiry))
|
const writable = computed(() => !props.readonly && (access.can('platform:fleet:capacity-offer:manage') || access.can('platform:fleet:capacity-offer:list') && access.can(`platform:fleet:capacity-offer:inquiry-${props.inquiry ? 'edit' : 'create'}`)) && (props.inquiry ? props.inquiry.actions.canEdit : props.offer?.canCreateInquiry))
|
||||||
const canQuote = computed(() => access.can('platform:fleet:quote:list'))
|
const canQuote = computed(() => access.can('platform:fleet:quote:list'))
|
||||||
const lock = computed(() => !writable.value || busy.value || !!pending.value)
|
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 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 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, computed(() => !!pending.value))
|
||||||
@@ -127,12 +128,12 @@ onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clear
|
|||||||
<el-form label-position="top" class="density-form capacity-inquiry-form" :disabled="lock">
|
<el-form label-position="top" class="density-form capacity-inquiry-form" :disabled="lock">
|
||||||
<el-form-item label="委托客户" required><el-select v-model="form.customerId" filterable remote :remote-method="searchCustomers" :loading="customerLoading" :disabled="!!inquiry" placeholder="按客户编号或名称搜索" @focus="searchCustomers()"><el-option v-if="inquiry" :value="inquiry.customerId" :label="`${inquiry.customerCode} · ${inquiry.customerName}`" /><el-option v-for="customer in customers" :key="customer.id" :value="customer.id" :label="`${customer.code} · ${customer.name}`" /></el-select><span v-if="customerSearched && !customers.length">暂无可选客户</span></el-form-item>
|
<el-form-item label="委托客户" required><el-select v-model="form.customerId" filterable remote :remote-method="searchCustomers" :loading="customerLoading" :disabled="!!inquiry" placeholder="按客户编号或名称搜索" @focus="searchCustomers()"><el-option v-if="inquiry" :value="inquiry.customerId" :label="`${inquiry.customerCode} · ${inquiry.customerName}`" /><el-option v-for="customer in customers" :key="customer.id" :value="customer.id" :label="`${customer.code} · ${customer.name}`" /></el-select><span v-if="customerSearched && !customers.length">暂无可选客户</span></el-form-item>
|
||||||
<el-form-item label="本车卸货站" required><el-select v-model="form.cargo.destinationKey" :disabled="lock || offer?.destinations.length === 1" placeholder="选择本车既有卸货站"><el-option v-for="stop in offer?.destinations || []" :key="stop.destinationKey" :value="stop.destinationKey" :label="stop.name" /><el-option v-if="!offer && inquiry" :value="inquiry.cargo.destinationKey" :label="inquiry.destinationLabel" /></el-select></el-form-item>
|
<el-form-item label="本车卸货站" required><el-select v-model="form.cargo.destinationKey" :disabled="lock || offer?.destinations.length === 1" placeholder="选择本车既有卸货站"><el-option v-for="stop in offer?.destinations || []" :key="stop.destinationKey" :value="stop.destinationKey" :label="stop.name" /><el-option v-if="!offer && inquiry" :value="inquiry.cargo.destinationKey" :label="inquiry.destinationLabel" /></el-select></el-form-item>
|
||||||
<details class="capacity-inquiry-wide" :open="readonly || confirmMode"><summary>已知货量(可先填写,未知可留空)</summary><div class="capacity-inquiry-form">
|
<details class="capacity-inquiry-wide" :open="readonly || confirmMode"><summary>{{ cargoLabel === '已核实' ? '已核实货量与预计实托数' : readonly ? `${cargoLabel}货量` : '已知货量(可先填写,未知可留空)' }}</summary><div class="capacity-inquiry-form">
|
||||||
<el-form-item label="预计箱数"><el-input-number v-model="form.cargo.packageCount" :min="1" :precision="0" placeholder="未知可留空" /></el-form-item>
|
<el-form-item :label="`${cargoLabel}箱数`"><el-input-number v-model="form.cargo.packageCount" :min="1" :precision="0" placeholder="未知可留空" /></el-form-item>
|
||||||
<el-form-item label="预计实托数"><el-input-number v-model="form.expectedPalletCount" :min="1" :precision="0" placeholder="卡车确认承运前必填" /></el-form-item>
|
<el-form-item label="预计实托数"><el-input-number v-model="form.expectedPalletCount" :min="1" :precision="0" placeholder="卡车确认承运前必填" /></el-form-item>
|
||||||
<el-form-item label="预计标准托位"><el-input-number v-model="form.cargo.palletSpaces" :min="1" :precision="0" placeholder="确认承运前填写" /></el-form-item>
|
<el-form-item :label="`${cargoLabel}标准托位`"><el-input-number v-model="form.cargo.palletSpaces" :min="1" :precision="0" placeholder="确认承运前填写" /></el-form-item>
|
||||||
<el-form-item label="预计重量(kg)"><el-input-number v-model="form.cargo.weightKg" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
|
<el-form-item :label="`${cargoLabel}重量(kg)`"><el-input-number v-model="form.cargo.weightKg" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
|
||||||
<el-form-item label="预计体积(m³)"><el-input-number v-model="form.cargo.volumeCbm" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
|
<el-form-item :label="`${cargoLabel}体积(m³)`"><el-input-number v-model="form.cargo.volumeCbm" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
|
||||||
</div></details>
|
</div></details>
|
||||||
<details class="capacity-inquiry-wide" :open="readonly || confirmMode"><summary>确认承运前待补资料{{ inquiry?.progress.missingFields.length ? `(还需:${inquiry.progress.missingFields.join('、')})` : '(货名、价格、到仓及联系信息)' }}</summary><div class="capacity-inquiry-form">
|
<details class="capacity-inquiry-wide" :open="readonly || confirmMode"><summary>确认承运前待补资料{{ inquiry?.progress.missingFields.length ? `(还需:${inquiry.progress.missingFields.join('、')})` : '(货名、价格、到仓及联系信息)' }}</summary><div class="capacity-inquiry-form">
|
||||||
<el-form-item label="本土业务类型"><el-select v-model="form.businessType"><el-option value="LOCAL_LOOSEBOARD" label="本土散板" /><el-option value="US_TRUCK" label="本土卡车" /></el-select></el-form-item>
|
<el-form-item label="本土业务类型"><el-select v-model="form.businessType"><el-option value="LOCAL_LOOSEBOARD" label="本土散板" /><el-option value="US_TRUCK" label="本土卡车" /></el-select></el-form-item>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { inquiryStatusLabels, offerTime, type CapacityInquiry } from '../capacityOfferTypes'
|
import { inquiryCargoLabel, offerTime, type CapacityInquiry } from '../capacityOfferTypes'
|
||||||
withDefaults(defineProps<{ rows: CapacityInquiry[]; total: number; loading: boolean; busy?: boolean; integrated?: boolean; failed?: boolean }>(), { busy: false, integrated: false, failed: false })
|
withDefaults(defineProps<{ rows: CapacityInquiry[]; total: number; loading: boolean; busy?: boolean; integrated?: boolean; failed?: boolean }>(), { busy: false, integrated: false, failed: false })
|
||||||
const page = defineModel<number>('page', { default: 1 }), size = defineModel<number>('size', { default: 20 })
|
const page = defineModel<number>('page', { default: 1 }), size = defineModel<number>('size', { default: 20 })
|
||||||
const emit = defineEmits<{ action: [key: string, row: CapacityInquiry] }>()
|
const emit = defineEmits<{ action: [key: string, row: CapacityInquiry] }>()
|
||||||
@@ -26,7 +26,7 @@ const columns: DataTableColumn[] = [
|
|||||||
{ key: 'actions', label: '操作', width: 320, fixed: 'left', slotName: 'actions' },
|
{ key: 'actions', label: '操作', width: 320, fixed: 'left', slotName: 'actions' },
|
||||||
{ key: 'bookingNo', label: '意向 / 预订号', minWidth: 210, singleLine: false }, { key: 'customerName', label: '客户', minWidth: 210, singleLine: false },
|
{ key: 'bookingNo', label: '意向 / 预订号', minWidth: 210, singleLine: false }, { key: 'customerName', label: '客户', minWidth: 210, singleLine: false },
|
||||||
{ key: 'tripNo', label: '车次号', minWidth: 210 }, { key: 'destinationLabel', label: '卸货站', minWidth: 130 },
|
{ key: 'tripNo', label: '车次号', minWidth: 210 }, { key: 'destinationLabel', label: '卸货站', minWidth: 130 },
|
||||||
{ key: 'status', label: '当前进度 / 下一步', minWidth: 270, slotName: 'status', singleLine: false }, { key: 'cargo', label: '预计货量', minWidth: 270, slotName: 'cargo' },
|
{ key: 'status', label: '当前进度 / 下一步', minWidth: 270, slotName: 'status', singleLine: false }, { key: 'cargo', label: '货量', minWidth: 270, slotName: 'cargo', singleLine: false },
|
||||||
{ key: 'agreedAmount', label: '约定运费(非实收)', minWidth: 165, slotName: 'amount' }, { key: 'businessOrderNo', label: '本土订单号', minWidth: 210 },
|
{ key: 'agreedAmount', label: '约定运费(非实收)', minWidth: 165, slotName: 'amount' }, { key: 'businessOrderNo', label: '本土订单号', minWidth: 210 },
|
||||||
{ key: 'updatedAt', label: '更新时间', minWidth: 180, slotName: 'time' },
|
{ key: 'updatedAt', label: '更新时间', minWidth: 180, slotName: 'time' },
|
||||||
]
|
]
|
||||||
@@ -37,7 +37,7 @@ const columns: DataTableColumn[] = [
|
|||||||
<template v-if="$slots.notices" #notices><slot name="notices" /></template>
|
<template v-if="$slots.notices" #notices><slot name="notices" /></template>
|
||||||
<template #actions="{ row }"><RowActions :max-visible="1" :actions="actions(row as CapacityInquiry, busy)" text-actions @action="emit('action', $event, row as CapacityInquiry)" /></template>
|
<template #actions="{ row }"><RowActions :max-visible="1" :actions="actions(row as CapacityInquiry, busy)" text-actions @action="emit('action', $event, row as CapacityInquiry)" /></template>
|
||||||
<template #status="{ row }"><strong>{{ row.progress.label }} · {{ row.progress.reservation }}</strong><div>下一步:{{ row.progress.owner }}</div><small class="inquiry-reason">{{ row.progress.message }}</small></template>
|
<template #status="{ row }"><strong>{{ row.progress.label }} · {{ row.progress.reservation }}</strong><div>下一步:{{ row.progress.owner }}</div><small class="inquiry-reason">{{ row.progress.message }}</small></template>
|
||||||
<template #cargo="{ row }">{{ row.cargo.packageCount ?? '—' }} 箱 · {{ row.expectedPalletCount ?? '—' }} 实托 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</template>
|
<template #cargo="{ row }"><div>{{ inquiryCargoLabel(row.status) }}:{{ row.cargo.packageCount ?? '—' }} 箱 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</div><div>预计实托数:{{ row.expectedPalletCount ?? '—' }}</div></template>
|
||||||
<template #amount="{ row }">{{ row.agreedAmount == null ? '待报价' : `${Number(row.agreedAmount).toFixed(2)} ${row.cargo.currency || ''}` }}</template>
|
<template #amount="{ row }">{{ row.agreedAmount == null ? '待报价' : `${Number(row.agreedAmount).toFixed(2)} ${row.cargo.currency || ''}` }}</template>
|
||||||
<template #time="{ row }">{{ offerTime(row.updatedAt) }}</template>
|
<template #time="{ row }">{{ offerTime(row.updatedAt) }}</template>
|
||||||
</DataTable></template>
|
</DataTable></template>
|
||||||
|
|||||||
@@ -38,6 +38,27 @@ function mount(component: object, props: Record<string, unknown> = {}): { wrappe
|
|||||||
const button = (w: VueWrapper, label: string) => w.findAll('button').find(b => b.text() === label)!
|
const button = (w: VueWrapper, label: string) => w.findAll('button').find(b => b.text() === label)!
|
||||||
beforeEach(() => { vi.clearAllMocks(); state.manage = false; state.write = true; state.quote = false; state.links.clear(); vi.mocked(ElMessageBox.confirm).mockImplementation(vi.fn().mockResolvedValue(undefined)); vi.mocked(api.listCapacityOffers).mockResolvedValue(pageOf([structuredClone(offer)], 41)); vi.mocked(api.listCapacityInquiries).mockResolvedValue(pageOf([structuredClone(inquiry)])); vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer: structuredClone(offer), inquiries: pageOf([structuredClone(inquiry)]) }) })
|
beforeEach(() => { vi.clearAllMocks(); state.manage = false; state.write = true; state.quote = false; state.links.clear(); vi.mocked(ElMessageBox.confirm).mockImplementation(vi.fn().mockResolvedValue(undefined)); vi.mocked(api.listCapacityOffers).mockResolvedValue(pageOf([structuredClone(offer)], 41)); vi.mocked(api.listCapacityInquiries).mockResolvedValue(pageOf([structuredClone(inquiry)])); vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer: structuredClone(offer), inquiries: pageOf([structuredClone(inquiry)]) }) })
|
||||||
afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.useRealTimers() })
|
afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.useRealTimers() })
|
||||||
|
it.each(['DRAFT', 'CONFIRMED', 'RECEIVED', 'BOUND', 'CANCELLED'] as const)('labels %s cargo consistently in the list and detail without treating estimated pallets as actual', async status => {
|
||||||
|
const row: CapacityInquiry = { ...inquiry, status, expectedPalletCount: 7, cargo: { ...inquiry.cargo, packageCount: 12, palletSpaces: 3, weightKg: 80, volumeCbm: 2 } }
|
||||||
|
const label = status === 'RECEIVED' || status === 'BOUND' ? '已核实' : status === 'CANCELLED' ? '记录' : '预报'
|
||||||
|
const list = shallowMount(CapacityInquiryTable, { props: { rows: [row], total: 1, loading: false }, global: { stubs: {
|
||||||
|
DataTable: { props: ['data', 'columns'], template: '<div><slot name="cargo" :row="data[0]"/></div>' },
|
||||||
|
} } })
|
||||||
|
wrappers.push(list)
|
||||||
|
expect(list.text()).toContain(`${label}:12 箱 · 3 托位 · 80 kg · 2 m³`)
|
||||||
|
expect(list.text()).toContain('预计实托数:7')
|
||||||
|
expect(list.text()).not.toContain('7 实托')
|
||||||
|
await list.setProps({ rows: [{ ...row, expectedPalletCount: null, cargo: { ...row.cargo, packageCount: null } }] })
|
||||||
|
expect(list.text()).toContain(`${label}:— 箱`)
|
||||||
|
expect(list.text()).toContain('预计实托数:—')
|
||||||
|
const { wrapper: detail } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry: row, readonly: true })
|
||||||
|
await flushPromises()
|
||||||
|
for (const suffix of ['箱数', '标准托位', '重量(kg)', '体积(m³)']) expect(detail.find(`[label="${label}${suffix}"]`).exists()).toBe(true)
|
||||||
|
expect(detail.findAll('summary').map(item => item.text())).toContain(label === '已核实' ? '已核实货量与预计实托数' : `${label}货量`)
|
||||||
|
expect(detail.find('[label="预计实托数"]').exists()).toBe(true)
|
||||||
|
expect(detail.find('[label="已核实实托数"]').exists()).toBe(false)
|
||||||
|
expect(api.saveCapacityInquiry).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
it('loads only the active tab and sends exactly one query after resetting from a later page', async () => {
|
it('loads only the active tab and sends exactly one query after resetting from a later page', async () => {
|
||||||
const { wrapper } = mount(CapacityOffersView); await flushPromises()
|
const { wrapper } = mount(CapacityOffersView); await flushPromises()
|
||||||
expect(api.listCapacityOffers).toHaveBeenCalledOnce(); expect(api.listCapacityInquiries).not.toHaveBeenCalled()
|
expect(api.listCapacityOffers).toHaveBeenCalledOnce(); expect(api.listCapacityInquiries).not.toHaveBeenCalled()
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ export function appointmentPayload(form: AppointmentForm): FbaAppointmentDetails
|
|||||||
startOffset: form.startOffset.trim() || null, endOffset: form.endOffset.trim() || null }
|
startOffset: form.startOffset.trim() || null, endOffset: form.endOffset.trim() || null }
|
||||||
}
|
}
|
||||||
export function appointmentFormError(form: AppointmentForm): string | null {
|
export function appointmentFormError(form: AppointmentForm): string | null {
|
||||||
|
if (form.appointmentStatus === 'CANCELLED') return '请在预约列表使用取消预约操作,并填写实际取消原因'
|
||||||
if (form.appointmentStatus === 'SUCCESS' && (!form.confirmationNo.trim() || !form.appointmentAt)) return '登记对方确认时,请填写外部约号与确认时间'
|
if (form.appointmentStatus === 'SUCCESS' && (!form.confirmationNo.trim() || !form.appointmentAt)) return '登记对方确认时,请填写外部约号与确认时间'
|
||||||
if (['FAILED', 'CANCELLED'].includes(form.appointmentStatus) && !form.appointmentRemark.trim()) return '请填写取消或未约成的原因'
|
if (form.appointmentStatus === 'FAILED' && !form.appointmentRemark.trim()) return '请填写未约成的原因'
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ defineProps<{ model: AppointmentForm; appointmentNo?: string; timezone?: string
|
|||||||
<p>{{ appointmentNo ? `系统单号:${appointmentNo}` : '系统单号将在保存后生成' }} · {{ timezone || '目的仓时区待核实' }}</p>
|
<p>{{ appointmentNo ? `系统单号:${appointmentNo}` : '系统单号将在保存后生成' }} · {{ timezone || '目的仓时区待核实' }}</p>
|
||||||
<el-form label-position="top" class="appointment-fields density-form" :disabled="disabled">
|
<el-form label-position="top" class="appointment-fields density-form" :disabled="disabled">
|
||||||
<el-form-item label="计划送仓日期"><el-date-picker v-model="model.plannedDate" type="date" value-format="YYYY-MM-DD" placeholder="日期未定可留空" /></el-form-item>
|
<el-form-item label="计划送仓日期"><el-date-picker v-model="model.plannedDate" type="date" value-format="YYYY-MM-DD" placeholder="日期未定可留空" /></el-form-item>
|
||||||
<el-form-item label="本次登记"><el-select v-model="model.appointmentStatus"><el-option label="先保存安排,等待对方确认" value="PENDING" /><el-option label="已取得对方正式确认" value="SUCCESS" /><el-option v-if="appointmentNo" label="未约成" value="FAILED" /><el-option v-if="appointmentNo" label="取消安排" value="CANCELLED" /></el-select></el-form-item>
|
<el-form-item label="本次登记"><el-select v-model="model.appointmentStatus"><el-option label="先保存安排,等待对方确认" value="PENDING" /><el-option label="已取得对方正式确认" value="SUCCESS" /><el-option v-if="appointmentNo" label="未约成" value="FAILED" /><el-option v-if="disabled && model.appointmentStatus === 'CANCELLED'" label="已取消" value="CANCELLED" disabled /></el-select></el-form-item>
|
||||||
<template v-if="model.appointmentStatus === 'SUCCESS'">
|
<template v-if="model.appointmentStatus === 'SUCCESS'">
|
||||||
<el-form-item label="外部约号" required><el-input v-model="model.confirmationNo" maxlength="128" placeholder="填写收货方提供的确认号" /></el-form-item>
|
<el-form-item label="外部约号" required><el-input v-model="model.confirmationNo" maxlength="128" placeholder="填写收货方提供的确认号" /></el-form-item>
|
||||||
<el-form-item label="对方确认开始时间" required><el-date-picker v-model="model.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="目的仓当地时间" @change="model.startOffset = ''" /></el-form-item>
|
<el-form-item label="对方确认开始时间" required><el-date-picker v-model="model.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="目的仓当地时间" @change="model.startOffset = ''" /></el-form-item>
|
||||||
<el-form-item label="对方确认结束时间"><el-date-picker v-model="model.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="未明确可留空" @change="model.endOffset = ''" /></el-form-item>
|
<el-form-item label="对方确认结束时间"><el-date-picker v-model="model.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="未明确可留空" @change="model.endOffset = ''" /></el-form-item>
|
||||||
</template>
|
</template>
|
||||||
<el-form-item :label="['FAILED','CANCELLED'].includes(model.appointmentStatus) ? '原因' : '备注'" :required="['FAILED','CANCELLED'].includes(model.appointmentStatus)"><el-input v-model="model.appointmentRemark" maxlength="1000" placeholder="有需交接的事项时填写" /></el-form-item>
|
<el-form-item :label="model.appointmentStatus === 'FAILED' ? '原因' : '备注'" :required="model.appointmentStatus === 'FAILED'"><el-input v-model="model.appointmentRemark" maxlength="1000" placeholder="有需交接的事项时填写" /></el-form-item>
|
||||||
<details class="appointment-fields__extra"><summary>补充计划时段与夏令时信息</summary>
|
<details class="appointment-fields__extra"><summary>补充计划时段与夏令时信息</summary>
|
||||||
<el-form-item label="计划开始时段"><el-date-picker v-model="model.plannedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="只知道日期时不必填写" /></el-form-item>
|
<el-form-item label="计划开始时段"><el-date-picker v-model="model.plannedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="只知道日期时不必填写" /></el-form-item>
|
||||||
<el-form-item label="计划结束时段"><el-date-picker v-model="model.plannedEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
|
<el-form-item label="计划结束时段"><el-date-picker v-model="model.plannedEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { expect, it, vi } from 'vitest'
|
||||||
|
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
|
||||||
|
import InboundOrdersView from './InboundOrdersView.vue'
|
||||||
|
import { getFbaAppointment, updateFbaAppointment } from '../api/inboundOrders'
|
||||||
|
vi.mock('vue-router', () => ({ useRouter: () => ({push: vi.fn()}), onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||||
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({can: () => true}) }))
|
||||||
|
vi.mock('../api/inboundOrders', () => ({
|
||||||
|
listInboundOrders: vi.fn().mockResolvedValue({records:[{lineId:'1',planNo:'IP-1',customerId:'A',appointmentNo:'AP-1',fbaWarehouseCode:'ONT8'}],total:1}),
|
||||||
|
getFbaAppointment: vi.fn(), updateFbaAppointment: vi.fn(), createFbaAppointment: vi.fn(), updateInboundPlan: vi.fn(),
|
||||||
|
}))
|
||||||
|
vi.mock('element-plus', () => ({ ElMessage: {warning:vi.fn(),success:vi.fn()} }))
|
||||||
|
it('keeps the inbound appointment retry across close, reopen and route attempts', async () => {
|
||||||
|
vi.mocked(getFbaAppointment).mockResolvedValue({id:'1',appointmentNo:'AP-1',version:2,originWarehouseId:'100',fbaWarehouseCode:'ONT8',appointmentStatus:'PENDING',selectedCount:1,items:[]})
|
||||||
|
vi.mocked(updateFbaAppointment).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({} as never)
|
||||||
|
const wrapper=mount(InboundOrdersView,{global:{directives:{loading:{}},stubs:{
|
||||||
|
AdminPage:{template:'<main><slot/></main>'},ListPageLayout:{template:'<div><slot/></div>'},
|
||||||
|
DataTable:{props:['data'],template:'<div><slot v-for="row in data" name="actions" :row="row"/></div>'},
|
||||||
|
AppDialog:{name:'AppDialog',props:['modelValue','beforeClose'],template:'<section v-if="modelValue"><slot/><button data-close @click="$emit(\'update:model-value\',false)">关闭</button><button data-save @click="$emit(\'confirm\')">保存</button></section>'},
|
||||||
|
ElButton:{template:'<button><slot/></button>'}, FbaAppointmentFields:true,SmartDispatchDialog:true,QueryToolbar:true,OperationErrorToast:true,
|
||||||
|
}}})
|
||||||
|
await flushPromises()
|
||||||
|
const open=()=>wrapper.findAll('button').find(b=>b.text()==='预约')!.trigger('click')
|
||||||
|
await open();await flushPromises();await wrapper.get('[data-save]').trigger('click');await flushPromises()
|
||||||
|
const original=vi.mocked(updateFbaAppointment).mock.calls[0]![1]
|
||||||
|
await wrapper.get('[data-close]').trigger('click')
|
||||||
|
const dialog=wrapper.findAllComponents({name:'AppDialog'})[0]!
|
||||||
|
const done=vi.fn();dialog.props('beforeClose')(done);expect(done).not.toHaveBeenCalled()
|
||||||
|
expect(wrapper.find('[data-save]').exists()).toBe(true)
|
||||||
|
await open();await flushPromises();expect(getFbaAppointment).toHaveBeenCalledTimes(1)
|
||||||
|
for(const hook of [onBeforeRouteLeave,onBeforeRouteUpdate]) expect((vi.mocked(hook).mock.calls[0]![0] as () => boolean)()).toBe(false)
|
||||||
|
await wrapper.get('[data-save]').trigger('click');await flushPromises()
|
||||||
|
expect(updateFbaAppointment).toHaveBeenNthCalledWith(2,'AP-1',original)
|
||||||
|
expect(wrapper.find('[data-save]').exists()).toBe(false)
|
||||||
|
expect((vi.mocked(onBeforeRouteLeave).mock.calls[0]![0] as () => boolean)()).toBe(true)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
@@ -3,7 +3,7 @@ import '@/styles/adminDensity.css'
|
|||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { CalendarCheck2, Truck } from '@lucide/vue'
|
import { CalendarCheck2, Truck } from '@lucide/vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { useRouter } from 'vue-router'
|
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRouter } from 'vue-router'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
@@ -127,6 +127,7 @@ function selectionError() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openAppointment() {
|
function openAppointment() {
|
||||||
|
if (!canLeaveAppointment()) return
|
||||||
const error = selectionError()
|
const error = selectionError()
|
||||||
if (error) { ElMessage.warning(error); return }
|
if (error) { ElMessage.warning(error); return }
|
||||||
appointmentMode.value = 'create'; editingAppointmentNo.value = ''
|
appointmentMode.value = 'create'; editingAppointmentNo.value = ''
|
||||||
@@ -142,6 +143,7 @@ function openSmartDispatch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openAppointmentEdit(source: object) {
|
async function openAppointmentEdit(source: object) {
|
||||||
|
if (!canLeaveAppointment()) return
|
||||||
const row = source as InboundOrderListItem
|
const row = source as InboundOrderListItem
|
||||||
if (!row.appointmentNo || row.appointmentNo === '—') return
|
if (!row.appointmentNo || row.appointmentNo === '—') return
|
||||||
appointmentMode.value = 'edit'; editingAppointmentNo.value = row.appointmentNo
|
appointmentMode.value = 'edit'; editingAppointmentNo.value = row.appointmentNo
|
||||||
@@ -158,7 +160,16 @@ async function openAppointmentEdit(source: object) {
|
|||||||
} finally { appointmentLoading.value = false }
|
} finally { appointmentLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canLeaveAppointment() {
|
||||||
|
if (saving.value || pendingAppointment.value) { ElMessage.warning('请先核实本次预约保存结果,再关闭或切换'); return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
function closeAppointment() { if (canLeaveAppointment()) appointmentOpen.value = false }
|
||||||
|
function beforeCloseAppointment(done: () => void) { if (canLeaveAppointment()) done() }
|
||||||
|
onBeforeRouteLeave(canLeaveAppointment)
|
||||||
|
onBeforeRouteUpdate(canLeaveAppointment)
|
||||||
async function submitAppointment() {
|
async function submitAppointment() {
|
||||||
|
if (saving.value || appointmentLoading.value) return
|
||||||
if (appointmentMode.value === 'create') { const error = selectionError(); if (error) { ElMessage.warning(error); return } }
|
if (appointmentMode.value === 'create') { const error = selectionError(); if (error) { ElMessage.warning(error); return } }
|
||||||
const validation = appointmentFormError(appointmentForm)
|
const validation = appointmentFormError(appointmentForm)
|
||||||
if (!pendingAppointment.value && validation) { ElMessage.warning(validation); return }
|
if (!pendingAppointment.value && validation) { ElMessage.warning(validation); return }
|
||||||
@@ -213,7 +224,7 @@ onMounted(() => { void load() })
|
|||||||
<template #pagination-left>共 {{ total }} 条入库单 · 已选 {{ selectedRows.length }} 条</template>
|
<template #pagination-left>共 {{ total }} 条入库单 · 已选 {{ selectedRows.length }} 条</template>
|
||||||
</DataTable></section>
|
</DataTable></section>
|
||||||
</ListPageLayout>
|
</ListPageLayout>
|
||||||
<AppDialog class="admin-density-dialog" v-model="appointmentOpen" :title="appointmentTitle" width="min(1080px, calc(100vw - 48px))" :confirm-text="pendingAppointment ? '重试本次保存' : '保存预约安排'" :confirm-loading="saving" @confirm="submitAppointment">
|
<AppDialog class="admin-density-dialog" :model-value="appointmentOpen" @update:model-value="closeAppointment" :before-close="beforeCloseAppointment" :title="appointmentTitle" width="min(1080px, calc(100vw - 48px))" :confirm-text="pendingAppointment ? '重试本次保存' : '保存预约安排'" :confirm-loading="saving" @confirm="submitAppointment">
|
||||||
<div v-loading="appointmentLoading">
|
<div v-loading="appointmentLoading">
|
||||||
<div class="appointment-summary">已选择 {{ appointmentSelectedCount }} 条,FBA仓库:{{ appointmentFbaWarehouse }}</div>
|
<div class="appointment-summary">已选择 {{ appointmentSelectedCount }} 条,FBA仓库:{{ appointmentFbaWarehouse }}</div>
|
||||||
<FbaAppointmentFields :model="appointmentForm" :appointment-no="editingAppointmentNo || undefined" :timezone="appointmentTimezone" :disabled="saving || !!pendingAppointment" />
|
<FbaAppointmentFields :model="appointmentForm" :appointment-no="editingAppointmentNo || undefined" :timezone="appointmentTimezone" :disabled="saving || !!pendingAppointment" />
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import InventoryTransactionsView from './InventoryTransactionsView.vue'
|
|||||||
import LastMileOrderDetailView from './LastMileOrderDetailView.vue'
|
import LastMileOrderDetailView from './LastMileOrderDetailView.vue'
|
||||||
|
|
||||||
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: {} as Record<string, string>, query: {} as Record<string, string> } }))
|
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: {} as Record<string, string>, query: {} as Record<string, string> } }))
|
||||||
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
|
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
|
||||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||||
vi.mock('../api/inboundOrders', () => ({
|
vi.mock('../api/inboundOrders', () => ({
|
||||||
listInboundOrders: vi.fn(), createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(),
|
listInboundOrders: vi.fn(), createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(),
|
||||||
|
|||||||
Reference in New Issue
Block a user