feat(appointment): complete customer receiving and trip continuation workflows
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { appointmentReturnTarget } from '@/modules/appointment-calendar'
|
||||
import { appointmentReturnTarget, TripAppointmentPanel } from '@/modules/appointment-calendar'
|
||||
import { inquiryReturnTarget } from '../inquiryNavigation'
|
||||
import TripDispatchSelector from '../components/TripDispatchSelector.vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
@@ -32,6 +32,8 @@ const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const detail = ref<TripDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), operationError = ref<ApiErrorDetails | null>(null)
|
||||
const activeTab = ref('overview'), editorOpen = ref(false), busy = ref(''), dispatchNo = ref('')
|
||||
const offerSettingsOpen = ref(false)
|
||||
const appointmentsOpen = ref(false)
|
||||
const appointmentsPanel = ref<InstanceType<typeof TripAppointmentPanel> | null>(null)
|
||||
const inspection = ref<TripCompletionInspection | null>(null), inspectionLoading = ref(false)
|
||||
const history = ref<TripHistory[]>([]), historyPage = ref(1), historyTotal = ref(0), historyLoading = ref(false)
|
||||
const resourceKind = ref<'VEHICLE' | 'DRIVER'>('VEHICLE'), actualEndAt = ref(new Date().toISOString()), endLocation = ref('')
|
||||
@@ -65,9 +67,11 @@ function accept(value: TripDetail) {
|
||||
if (value.trip.vehicleRelease && resourceKind.value === 'VEHICLE') resourceKind.value = 'DRIVER'
|
||||
}
|
||||
async function load() {
|
||||
if (appointmentsPanel.value && !await appointmentsPanel.value.canLeave()) return
|
||||
const current = ++generation; inspectionSequence++; historySequence++
|
||||
loading.value = true; error.value = null; detail.value = null; operationError.value = null; activeTab.value = route.query.tab === 'loading' ? 'loading' : 'overview'
|
||||
editorOpen.value = false; busy.value = ''; dispatchNo.value = ''; inspection.value = null; history.value = []; historyPage.value = 1
|
||||
appointmentsOpen.value = false
|
||||
completionRequest.value = null; releaseRequest.value = null; endLocation.value = ''
|
||||
try { const result = await getTrip(String(route.params.tripNo)); if (current === generation) accept(result) }
|
||||
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '车次详情加载失败') }
|
||||
@@ -202,6 +206,10 @@ onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
|
||||
<DataTable :data="detail.dispatches" :columns="bindingsColumns" row-key="dispatchNo" empty-text="尚未关联排车单"><template #actions="{ row }"><div class="density-actions"><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list')" class="density-action" @click="router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } })">详情</el-button><el-button v-if="editable" class="density-action" data-tone="danger" :disabled="!!busy" @click="unbind(row.dispatchNo)">解除关联</el-button><el-button v-if="planned && access.can(permission.tripDispatchReview)" class="density-action" :disabled="!!busy || row.review.status === 'APPROVED'" @click="review('APPROVED', row.dispatchNo)">审核通过</el-button><el-button v-if="planned && access.can(permission.tripDispatchReview)" class="density-action" data-tone="danger" :disabled="!!busy" @click="review('REJECTED', row.dispatchNo)">打回</el-button></div></template><template #review="{ row }"><span>{{ tripReviewLabels[row.review.status as keyof typeof tripReviewLabels] }}</span><p v-if="row.review.reason">{{ row.review.reason }}</p><span v-if="row.review.reviewedAt">{{ time(row.review.reviewedAt) }}</span></template><template #boundAt="{ row }">{{ time(row.boundAt) }}</template></DataTable>
|
||||
</FormSection>
|
||||
</template>
|
||||
<FormSection v-show="activeTab === 'overview'" v-if="access.can('platform:appointment:list')" class="density-form-section" title="客户预约与后送去向">
|
||||
<el-button v-if="!appointmentsOpen" @click="appointmentsOpen = true">核对客户预约与后送去向</el-button>
|
||||
<TripAppointmentPanel v-if="appointmentsOpen" ref="appointmentsPanel" :key="detail.trip.tripNo" :dispatches="detail.dispatches" :previous-dispatches="detail.previousDispatches" :history-unavailable-reason="detail.appointmentHistoryUnavailableReason" :can-write="planned && !busy" @updated="refreshLinked" @work="key => router.push({ path: '/appointment-calendar', query: { workItem: key } })" @external="path => router.push(path)" />
|
||||
</FormSection>
|
||||
<FormSection v-if="activeTab === 'bookings'" class="density-form-section" title="本车外接预订"><TripBookingPanel :key="detail.trip.tripNo" :trip="detail.trip" @changed="detail.trip.recordVersion = $event" @linked="refreshLinked" /></FormSection>
|
||||
<FormSection v-if="activeTab === 'handovers'" class="density-form-section" title="在途车辆与司机交接"><TripHandoverPanel :key="detail.trip.tripNo" :detail="detail" @saved="refreshLinked" /></FormSection>
|
||||
<FormSection v-if="activeTab === 'costs'" class="density-form-section" title="本趟直接成本"><TripCostPanel :key="detail.trip.tripNo" :trip-no="detail.trip.tripNo" /></FormSection>
|
||||
|
||||
@@ -22,7 +22,7 @@ const trip: FleetTrip = { tripNo: 'TR-TEST', status: 'PLANNED', warehouseId: '92
|
||||
plannedDepartureAt: '2026-10-03T00:00:00Z', expectedDriverEndAt: '2026-10-04T00:00:00Z', departedAt: null, completedAt: null,
|
||||
stops: [{ name: '起点' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }], remark: null, recordVersion: 3, updatedAt: '2026-10-02T00:00:00Z', vehicleRelease: null, driverRelease: null }
|
||||
const reviewState = { status: 'PENDING' as const, reason: null, reviewerId: null, reviewedAt: null, recordVersion: null }
|
||||
const detail: TripDetail = { trip, driverQualification: null, dispatches: [], loadingReview: reviewState }
|
||||
const detail: TripDetail = { trip, driverQualification: null, previousDispatches: [], appointmentHistoryUnavailableReason: null, dispatches: [], loadingReview: reviewState }
|
||||
const layout = { template: '<div><slot name="header"/><slot/></div>' }
|
||||
const input = { name: 'ElInput', props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\',$event.target.value)" />' }
|
||||
const dialog = { name: 'AppDialog', props: ['modelValue'], emits: ['confirm'], template: '<div v-if="modelValue"><slot/><button @click="$emit(\'confirm\')">保存计划</button></div>' }
|
||||
@@ -53,6 +53,18 @@ it('returns from a trip to the exact appointment work context and preserves it w
|
||||
const page = mount(TripDetailView); await flushPromises(); await button(page, '返回预约工作').trigger('click')
|
||||
expect(state.push).toHaveBeenLastCalledWith(tripQuery.appointmentReturn)
|
||||
})
|
||||
it('loads customer appointment cards only on request and navigates using a resolved work key', async () => {
|
||||
const previousDispatches = [{ dispatchNo: 'DS-REMOVED', customerId: 'A', recordVersion: 2, recordedAt: trip.updatedAt, appointmentBatches: [] }]
|
||||
vi.mocked(api.getTrip).mockResolvedValue({ ...structuredClone(detail), previousDispatches, appointmentHistoryUnavailableReason: '历史证据不完整' })
|
||||
const page = mount(TripDetailView); await flushPromises()
|
||||
expect(page.findComponent({ name: 'TripAppointmentPanel' }).exists()).toBe(false)
|
||||
await button(page, '核对客户预约与后送去向').trigger('click'); await flushPromises()
|
||||
const panel = page.getComponent({ name: 'TripAppointmentPanel' })
|
||||
expect(panel.props()).toMatchObject({ previousDispatches, historyUnavailableReason: '历史证据不完整' })
|
||||
expect(panel.props('dispatches')).toEqual([])
|
||||
panel.vm.$emit('work', 'real-child-work'); await flushPromises()
|
||||
expect(state.push).toHaveBeenLastCalledWith({ path: '/appointment-calendar', query: { workItem: 'real-child-work' } })
|
||||
})
|
||||
|
||||
it('shows mixed signature and return quantities from one lazy completion read', async () => {
|
||||
vi.mocked(api.getTrip).mockResolvedValue({ ...structuredClone(detail), trip: { ...trip, status: 'IN_TRANSIT' } })
|
||||
@@ -129,7 +141,7 @@ it('counts a shared physical pallet once and submits only operator-entered measu
|
||||
})
|
||||
|
||||
it('submits a specific dispatch review with optional approval notes and a reason when rejecting', async () => {
|
||||
const pending: TripDetail = { ...structuredClone(detail), dispatches: [{ dispatchNo: 'DP-A', customerId: 'A', packageCount: 10, boundBy: '1', boundAt: trip.updatedAt, review: reviewState }] }
|
||||
const pending: TripDetail = { ...structuredClone(detail), dispatches: [{ appointmentBatches: [], dispatchNo: 'DP-A', customerId: 'A', packageCount: 10, boundBy: '1', boundAt: trip.updatedAt, review: reviewState }] }
|
||||
vi.mocked(api.getTrip).mockResolvedValue(pending)
|
||||
vi.mocked(api.reviewTripDispatch).mockResolvedValue({ ...pending, trip: { ...trip, recordVersion: 4 } })
|
||||
const wrapper = mount(TripDetailView); await flushPromises()
|
||||
|
||||
Reference in New Issue
Block a user