fix: require appointment cancellation reason and expose history

This commit is contained in:
2026-10-11 13:27:39 +08:00
parent d77abbef7c
commit 240aa5d6d0
8 changed files with 272 additions and 26 deletions
@@ -4,7 +4,7 @@ import { computed, provide, onBeforeUnmount, onMounted, reactive, ref, watch } f
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
import { resolvePlanningSource, listUnscheduledAppointments, type ResolvedPlanningSource, type PlanningSource } from './planningApi'
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
@@ -21,12 +21,18 @@ import {
import { listPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import AppointmentCalendarPanel, { type CalendarEvent } from './AppointmentCalendarPanel.vue'
import AppointmentInboundCodes from './AppointmentInboundCodes.vue'
import AppointmentCancellationDialog from './AppointmentCancellationDialog.vue'
import AppointmentCancellationHistory from './AppointmentCancellationHistory.vue'
import AppointmentCancellationRecords from './AppointmentCancellationRecords.vue'
import AppointmentCargoPanel from './AppointmentCargoPanel.vue'
import AppointmentContainerPanel from './AppointmentContainerPanel.vue'
import { calendarRange, dateKey } from './calendar'
import { deleteAppointment, listArrivalCalendar, listPickupCalendar, type ArrivalCalendarEntry, type PickupCalendarEntry } from './api'
import { listArrivalCalendar, listPickupCalendar, type ArrivalCalendarEntry, type PickupCalendarEntry } from './api'
import { listTripCalendar, projectTripCalendar, tripIssues, type TripCalendarEntry } from './tripCalendar'
const cancellationTarget = ref<FbaAppointmentDto | null>(null)
const cancellationsOpen = ref(false), cancellationsRevision = ref(0), cancellationHistoryOpen = ref(false), forceReadOnly = ref(false)
const appointmentReadOnly = computed(() => forceReadOnly.value || editing.value?.appointmentStatus === 'CANCELLED' || !access.can(permission.inboundOrderAppointment))
const access = useAccessPreviewStore()
const route = useRoute()
const router = useRouter()
@@ -221,6 +227,7 @@ onBeforeRouteLeave(canLeave)
onBeforeRouteUpdate(canLeave)
function openCreate(date: string) {
if (!access.can(permission.inboundOrderAppointment) || !canLeave()) return
forceReadOnly.value = false; cancellationHistoryOpen.value = false
++candidateRequest
++detailRequest
detailLoading.value = false
@@ -241,13 +248,13 @@ function openCreate(date: string) {
void searchWarehouses('')
}
async function openEdit(event: Pick<CalendarEvent, 'key'>) {
async function openEdit(event: Pick<CalendarEvent, 'key'>, readOnly = false) {
if (!canLeave()) return
forceReadOnly.value = readOnly; cancellationHistoryOpen.value = false
const request = ++detailRequest
dialogError.value = null
editing.value = [...appointments.value, ...unscheduled.value].find(item => item.appointmentNo === event.key) || null
pendingSave.value = null
if (!editing.value) return
dialogOpen.value = true
detailLoading.value = true
detailReady.value = false
@@ -312,7 +319,7 @@ async function loadCandidates() {
}
async function save() {
if (!access.can(permission.inboundOrderAppointment) || saving.value || detailLoading.value || !detailReady.value) return
if (appointmentReadOnly.value || saving.value || detailLoading.value || !detailReady.value) return
const validation = appointmentFormError(form)
if (!pendingSave.value && validation) { ElMessage.warning(validation); return }
if (!editing.value) {
@@ -343,23 +350,16 @@ function selectCandidates(rows: object[]) {
selected.value = rows as InboundOrderSummaryDto[]
}
async function remove(event: CalendarEvent) {
const appointment = appointments.value.find(item => item.appointmentNo === event.key)
if (!appointment || loading.value) return
try {
await ElMessageBox.confirm(`确认取消预约 ${appointment.appointmentNo}?将释放全部 ${appointment.selectedCount} 票货物的占用,历史仍保留。`, '取消预约',
{ type: 'warning', confirmButtonText: '确认取消预约', cancelButtonText: '返回', customClass: 'admin-density-confirm' })
} catch { return }
loading.value = true
try {
await deleteAppointment(appointment.appointmentNo, appointment.version)
ElMessage.success('预约已取消,历史仍保留')
await load()
} catch (error) {
errorTitle.value = '预约取消失败'
loadError.value = getApiErrorDetails(error, '预约取消失败')
} finally { loading.value = false }
function remove(event: CalendarEvent) {
if (!access.can('platform:appointment:delete') || loading.value || !canLeave()) return
cancellationTarget.value = appointments.value.find(item => item.appointmentNo === event.key) || null
}
async function cancelled() {
cancellationTarget.value = null; ++cancellationsRevision.value
ElMessage.success('预约已取消,可在取消记录中查看原因与原记录')
await Promise.all([load(),loadUnscheduled()])
}
watch(dialogOpen, open => {
if (!open) { ++candidateRequest; ++detailRequest; ++warehouseRequest; candidateLoading.value = false; warehouseLoading.value = false; detailLoading.value = false; detailReady.value = false }
@@ -412,6 +412,7 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ
<LoadErrorState v-for="error in sourceErrors" :key="error.title" :title="error.title" :message="error.details.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error.details" /></template></LoadErrorState>
<LoadErrorState v-if="unscheduledError" title="未定时安排加载失败" :message="unscheduledError.message" @retry="loadUnscheduled" />
<details v-if="unscheduledTotal" class="appointment-extra"><summary>未定时安排({{ unscheduledTotal }})</summary><DataTable v-model:page="unscheduledPage" v-model:page-size="unscheduledSize" :data="unscheduled" :columns="unscheduledColumns" :total="unscheduledTotal" row-key="appointmentNo" @update:page="loadUnscheduled" @update:page-size="unscheduledPage = 1; loadUnscheduled()"><template #actions="{ row }"><el-button @click="openEdit({key: row.appointmentNo})">查看 / 继续</el-button></template></DataTable></details>
<details class="appointment-extra" @toggle="cancellationsOpen = ($event.target as HTMLDetailsElement).open"><summary>取消记录</summary><AppointmentCancellationRecords v-if="cancellationsOpen" :key="cancellationsRevision" @view="openEdit({key: $event}, true)" /></details>
<AppointmentCalendarPanel :notices="[pickupNotice, showTripLayer ? tripNotice : ''].filter(Boolean)" :events="events" :loading="loading" :incomplete="sourceErrors.length > 0 || !!pickupNotice || (showTripLayer && !!tripNotice)" :can-create="access.can(permission.inboundOrderAppointment)" @layers="showTripLayer = $event.trips" @range="load" @create="openCreate" @edit="openEdit" @delete="remove" @open="openSource">
<template #notice-actions><el-button v-if="showTripLayer && unresolvedTrips.length" link type="primary" @click="unresolvedDialogOpen = true">查看待核实车次({{ unresolvedTrips.length }})</el-button></template>
</AppointmentCalendarPanel>
@@ -423,7 +424,8 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ
<template #issues="{ row }">{{ tripIssues(row as TripCalendarEntry) }}</template>
</DataTable>
</AppDialog>
<AppDialog :model-value="dialogOpen" @update:model-value="closeAppointment" :before-close="beforeCloseAppointment" :show-confirm="access.can(permission.inboundOrderAppointment)" class="admin-density-dialog" :title="editing ? (access.can(permission.inboundOrderAppointment) ? '编辑预约' : '查看预约') : '新增预约'" width="min(1080px, calc(100vw - 48px))" :confirm-text="pendingSave ? '重试本次保存' : '保存'" :confirm-loading="saving" :confirm-disabled="detailLoading || !detailReady || (!editing && (!warehouse || !selected.length || candidateLoading || !!candidateError))" @confirm="save">
<AppointmentCancellationDialog v-if="cancellationTarget" :appointment-no="cancellationTarget.appointmentNo" :version="cancellationTarget.version" :selected-count="cancellationTarget.selectedCount" @close="cancellationTarget = null" @cancelled="cancelled" />
<AppDialog :model-value="dialogOpen" @update:model-value="closeAppointment" :before-close="beforeCloseAppointment" :show-confirm="!appointmentReadOnly" class="admin-density-dialog" :title="appointmentReadOnly ? '查看预约' : editing ? '编辑预约' : '新增预约'" width="min(1080px, calc(100vw - 48px))" :confirm-text="pendingSave ? '重试本次保存' : '保存'" :confirm-loading="saving" :confirm-disabled="detailLoading || !detailReady || (!editing && (!warehouse || !selected.length || candidateLoading || !!candidateError))" @confirm="save">
<LoadErrorState v-if="dialogError" title="预约操作失败" :message="dialogError.message" :show-retry="false"><template #details><ApiErrorMeta :details="dialogError" /></template></LoadErrorState>
<div v-loading="detailLoading">
<el-form label-position="top" class="calendar-form density-form">
@@ -436,7 +438,7 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ
</el-form>
<LoadErrorState v-if="!editing && warehouseError" title="仓库加载失败" :message="warehouseError.message" @retry="searchWarehouses(warehouseKeyword)" />
<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="!access.can(permission.inboundOrderAppointment) || saving || !!pendingSave" />
<FbaAppointmentFields :model="form" :appointment-no="editing?.appointmentNo" :timezone="editing?.timezone || warehouseOptions.find(item => item.warehouseCode === warehouse)?.timezone" :disabled="appointmentReadOnly || saving || !!pendingSave" />
<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()">
<template #customer="{ row }">{{ row.customerName?.trim() || '客户名称缺失' }}</template>
@@ -448,6 +450,7 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ
<div v-else class="appointment-cargo">
<span>{{ editing.selectedCount }} 票货物</span>
<AppointmentInboundCodes :key="editing.appointmentNo" :codes="editing.items.map(item => item.inboundCode)" />
<details :key="`cancellations-${editing.appointmentNo}`" @toggle="cancellationHistoryOpen = ($event.target as HTMLDetailsElement).open"><summary>取消历史</summary><AppointmentCancellationHistory v-if="cancellationHistoryOpen" :key="editing.appointmentNo" :appointment-no="editing.appointmentNo" /></details>
</div>
</div>
</AppDialog>