feat(appointments): simplify planning and preserve source warehouse intent

This commit is contained in:
2026-10-10 10:44:07 +08:00
parent fb062b5ec4
commit 901431f17a
26 changed files with 423 additions and 108 deletions
@@ -2,7 +2,7 @@
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { resolvePlanningSource, type ResolvedPlanningSource } from './planningApi'
import { resolvePlanningSource, listUnscheduledAppointments, type ResolvedPlanningSource } from './planningApi'
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -15,7 +15,8 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import {
createFbaAppointment, getFbaAppointment, listFbaAppointmentCalendar, listInboundOrders, updateFbaAppointment,
overseasWarehousePermissions as permission,
type FbaAppointmentDto, type BatchFbaAppointmentPayload, type InboundOrderSummaryDto,
FbaAppointmentFields, createAppointmentForm, appointmentPayload, appointmentFormError,
type FbaAppointmentDto, type FbaAppointmentDetailsPayload, type InboundOrderSummaryDto,
} from '@/modules/overseas-warehouse'
import { listPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import AppointmentCalendarPanel, { type CalendarEvent } from './AppointmentCalendarPanel.vue'
@@ -65,8 +66,18 @@ let calendarController: AbortController | undefined
let calendarRequest = 0
let candidateRequest = 0
let detailRequest = 0
const form = reactive({ appointmentEndAt: '', appointmentNo: '', appointmentAt: '', expectedLoadingAt: '', appointmentStatus: 'PENDING' as BatchFbaAppointmentPayload['appointmentStatus'], appointmentRemark: '' })
const statusLabels: Record<string, string> = { PENDING: '预约中', SUCCESS: '预约成功', FAILED: '预约失败', CANCELLED: '已取消' }
const form = reactive(createAppointmentForm())
const pendingSave = ref<{ appointmentNo: string | null; version: number; originWarehouseId: string; fbaWarehouseCode: string; lineIds: string[]; details: FbaAppointmentDetailsPayload; idempotencyKey: string } | null>(null)
const unscheduled = ref<FbaAppointmentDto[]>([]), unscheduledTotal = ref(0), unscheduledPage = ref(1), unscheduledSize = ref(10)
const unscheduledError = ref<ApiErrorDetails | null>(null)
const unscheduledColumns: DataTableColumn[] = [{key:'actions',label:'操作',slotName:'actions',width:100,fixed:'left'},{prop:'appointmentNo',label:'系统预约单号',minWidth:200},{prop:'fbaWarehouseCode',label:'目的仓',width:100},{prop:'selectedCount',label:'票数',width:80}]
let unscheduledRequest = 0
async function loadUnscheduled() {
const current = ++unscheduledRequest; unscheduledError.value = null
try { const result = await listUnscheduledAppointments(unscheduledPage.value, unscheduledSize.value); if(current === unscheduledRequest) {unscheduled.value = result.records; unscheduledTotal.value = result.total} }
catch(cause) {if(current === unscheduledRequest) unscheduledError.value = getApiErrorDetails(cause, '未定时安排加载失败')}
}
const statusLabels: Record<string, string> = { PENDING: '安排待确认', SUCCESS: '对方已确认', FAILED: '未约成', CANCELLED: '已取消' }
const columns: DataTableColumn[] = [
{ key: 'selection', type: 'selection', reserveSelection: true, width: 46, selectable: (row: InboundOrderSummaryDto) => row.fbaWarehouseCode === searchedWarehouse.value && (!row.appointmentStatus || ['FAILED', 'CANCELLED'].includes(row.appointmentStatus)) },
{ prop: 'inboundCode', label: '入仓码', minWidth: 150 },
@@ -89,15 +100,17 @@ const events = computed<CalendarEvent[]>(() => [...arrivals.value.map(arrival =>
{ label: '货物去向', value: arrival.destinationWarehouseCode },
],
})), ...appointments.value.map(appointment => ({
key: appointment.appointmentNo, type: 'delivery' as const, date: appointment.appointmentAt.slice(0, 10),
time: appointment.appointmentAt.slice(11, 16) + (appointment.appointmentEndAt ? `–${appointment.appointmentEndAt.slice(11, 16)}` : ''), title: `${appointment.fbaWarehouseCode} 预约送仓`,
key: appointment.appointmentNo, type: 'delivery' as const, date: (appointment.appointmentAt || appointment.plannedAt || appointment.plannedDate || '').slice(0, 10),
time: (appointment.appointmentAt || appointment.plannedAt || '').slice(11, 16) + (appointment.appointmentEndAt ? `–${appointment.appointmentEndAt.slice(11, 16)}` : ''), title: `${appointment.fbaWarehouseCode} ${appointment.appointmentStatus === 'SUCCESS' ? '已确认送仓' : '计划送仓'}`,
fields: [
{ label: '预约仓库', value: appointment.fbaWarehouseCode },
{ label: '预约编号', value: appointment.appointmentNo },
{ label: '系统单号', value: appointment.appointmentNo },
{ label: '外部约号', value: appointment.confirmationNo },
{ label: '地点时区', value: appointment.timezone || '待核实' },
{ label: '预约状态', value: statusLabels[appointment.appointmentStatus] || '—' },
{ label: '货物信息', value: `${appointment.selectedCount} 票 / ${new Set(appointment.items.map(item => item.customerId)).size} 个客户` },
{ label: '预约备注', value: appointment.appointmentRemark },
],
].filter(field => field.value != null && field.value !== ''),
inboundCodes: appointment.items.map(item => item.inboundCode),
canEdit: access.can(permission.inboundOrderAppointment),
canDelete: access.can('platform:appointment:delete'),
@@ -144,16 +157,17 @@ function openCreate(date: string) {
candidateTotal.value = 0
candidateError.value = null
dialogError.value = null
Object.assign(form, { appointmentEndAt: '', appointmentNo: '', appointmentAt: `${date}T09:00:00`, expectedLoadingAt: '', appointmentStatus: 'PENDING', appointmentRemark: '' })
Object.assign(form, createAppointmentForm(), { plannedDate: date }); pendingSave.value = null
dialogOpen.value = true
detailReady.value = true
void searchWarehouses('')
}
async function openEdit(event: CalendarEvent) {
async function openEdit(event: Pick<CalendarEvent, 'key'>) {
const request = ++detailRequest
dialogError.value = null
editing.value = appointments.value.find(item => item.appointmentNo === event.key) || 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
@@ -164,9 +178,7 @@ async function openEdit(event: CalendarEvent) {
editing.value = detail
detailReady.value = true
warehouse.value = detail.fbaWarehouseCode
Object.assign(form, { appointmentEndAt: detail.appointmentEndAt || '', appointmentNo: detail.appointmentNo, appointmentAt: detail.appointmentAt,
expectedLoadingAt: detail.expectedLoadingAt, appointmentStatus: detail.appointmentStatus,
appointmentRemark: detail.appointmentRemark || '' })
Object.assign(form, createAppointmentForm(detail))
} catch (error) {
if (request === detailRequest) dialogError.value = getApiErrorDetails(error, '预约详情加载失败')
} finally { if (request === detailRequest) detailLoading.value = false }
@@ -222,13 +234,13 @@ async function loadCandidates() {
async function save() {
if (saving.value || detailLoading.value || !detailReady.value) return
if (!form.appointmentNo.trim() || !form.appointmentAt || !form.expectedLoadingAt) {
ElMessage.warning('请填写预约号、预约送仓时间和预计装车时间'); return
}
const validation = appointmentFormError(form)
if (!pendingSave.value && validation) { ElMessage.warning(validation); return }
if (!editing.value) {
if (!warehouse.value || warehouse.value !== searchedWarehouse.value || candidateLoading.value || candidateError.value) { ElMessage.warning('请先选择仓库并等待货物加载完成'); return }
if (selected.value.some(item => item.fbaWarehouseCode !== warehouse.value)) { ElMessage.warning('请重新选择当前仓库的货物'); return }
if (!selected.value.length) { ElMessage.warning('请选择需要预约的货物'); return }
if (selected.value.some(item => !item.overseasWarehouseId) || new Set(selected.value.map(item => item.overseasWarehouseId)).size !== 1) { ElMessage.warning('请选择同一接仓点的货物'); return }
if (new Set(selected.value.map(item => item.fbaWarehouseCode)).size !== 1) {
ElMessage.warning('同一预约的货物必须送往同一目的仓'); return
}
@@ -236,14 +248,15 @@ async function save() {
saving.value = true
dialogError.value = null
try {
const common = { appointmentEndAt: form.appointmentEndAt || null, appointmentAt: form.appointmentAt, expectedLoadingAt: form.expectedLoadingAt,
appointmentStatus: form.appointmentStatus, appointmentRemark: form.appointmentRemark.trim() || undefined }
if (editing.value) await updateFbaAppointment(editing.value.appointmentNo, { ...common, version: editing.value.version })
else await createFbaAppointment({ ...common, appointmentNo: form.appointmentNo.trim(), lineIds: selected.value.map(item => item.lineId) })
pendingSave.value ||= {appointmentNo: editing.value?.appointmentNo || null, version: editing.value?.version || 0, originWarehouseId: String(selected.value[0]?.overseasWarehouseId || ''), fbaWarehouseCode: searchedWarehouse.value, lineIds: selected.value.map(item => item.lineId), details: appointmentPayload(form), idempotencyKey: crypto.randomUUID()}
const payload = pendingSave.value
if (payload.appointmentNo) await updateFbaAppointment(payload.appointmentNo, { details: payload.details, version: payload.version, idempotencyKey: payload.idempotencyKey })
else await createFbaAppointment({ originWarehouseId: payload.originWarehouseId, fbaWarehouseCode: payload.fbaWarehouseCode, details: payload.details, idempotencyKey: payload.idempotencyKey, lineIds: payload.lineIds })
pendingSave.value = null
dialogOpen.value = false
ElMessage.success('预约已保存')
await load()
} catch (error) { dialogError.value = getApiErrorDetails(error, '预约保存失败,请核对后重试') }
await Promise.all([load(), loadUnscheduled()])
} catch (error) { dialogError.value = getApiErrorDetails(error, '预约保存失败,请核对后重试'); if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(dialogError.value.code || '')) pendingSave.value = null }
finally { saving.value = false }
}
@@ -255,17 +268,17 @@ 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' })
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('预约已删除')
ElMessage.success('预约已取消,历史仍保留')
await load()
} catch (error) {
errorTitle.value = '预约删除失败'
loadError.value = getApiErrorDetails(error, '预约删除失败')
errorTitle.value = '预约取消失败'
loadError.value = getApiErrorDetails(error, '预约取消失败')
} finally { loading.value = false }
}
@@ -291,8 +304,8 @@ async function resolveLink() {
}
watch(() => [route.query.sourceKey, route.query.customerId, route.query.destinationKey], resolveLink, { immediate: true })
onMounted(() => load())
onBeforeUnmount(() => { ++calendarRequest; ++candidateRequest; ++detailRequest; ++warehouseRequest; ++linkRequest; calendarController?.abort() })
onMounted(() => { void load(); void loadUnscheduled() })
onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequest; ++detailRequest; ++warehouseRequest; ++linkRequest; calendarController?.abort() })
</script>
<template>
@@ -305,14 +318,16 @@ onBeforeUnmount(() => { ++calendarRequest; ++candidateRequest; ++detailRequest;
<button v-if="access.can(permission.lastMileOrderList)" type="button" :class="{ 'is-active': workView === 'containers' }" :aria-pressed="workView === 'containers'" @click="workView = 'containers'">整柜提柜与接仓</button>
<button v-if="access.can(permission.lastMileOrderList)" type="button" :class="{ 'is-active': workView === 'cargo' }" :aria-pressed="workView === 'cargo'" @click="workView = 'cargo'">已开船 FBA 货物</button>
</div>
<AppointmentCargoPanel v-if="workView === 'cargo'" />
<AppointmentCargoPanel v-if="workView === 'cargo'" @created="load(); loadUnscheduled()" />
<AppointmentContainerPanel v-if="workView === 'containers'" />
<div v-show="workView === 'calendar'">
<LoadErrorState v-if="loadError" :title="errorTitle" :message="loadError.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<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>
<AppointmentCalendarPanel :events="events" :loading="loading" :incomplete="sourceErrors.length > 0" :can-create="access.can(permission.inboundOrderAppointment)" @range="load" @create="openCreate" @edit="openEdit" @delete="remove" />
</div>
<AppDialog v-model="dialogOpen" class="admin-density-dialog" :title="editing ? '编辑预约' : '新增预约'" width="min(1080px, calc(100vw - 48px))" confirm-text="保存" :confirm-loading="saving" :confirm-disabled="detailLoading || !detailReady || (!editing && (!warehouse || !selected.length || candidateLoading || !!candidateError))" @confirm="save">
<AppDialog v-model="dialogOpen" class="admin-density-dialog" :title="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">
@@ -322,21 +337,10 @@ onBeforeUnmount(() => { ++calendarRequest; ++candidateRequest; ++detailRequest;
<el-option v-for="item in warehouseOptions" :key="String(item.id)" :value="item.warehouseCode" :label="item.warehouseName === item.warehouseCode ? item.warehouseCode : `${item.warehouseCode} · ${item.warehouseName}`" />
</el-select>
</el-form-item>
<el-form-item label="预约送仓时间" required><el-date-picker v-model="form.appointmentAt" type="datetime" format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<el-form-item label="预约编号" required><el-input v-model="form.appointmentNo" :readonly="!!editing" maxlength="64" placeholder="填写仓库提供的预约编号" /></el-form-item>
<el-form-item label="预计装车时间" required><el-date-picker v-model="form.expectedLoadingAt" type="datetime" format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<el-form-item v-if="editing" label="预约状态"><el-select v-model="form.appointmentStatus"><el-option v-for="(label, value) in statusLabels" :key="value" :value="value" :label="label" /></el-select></el-form-item>
</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>
<details :key="editing?.appointmentNo || 'create'" class="appointment-extra">
<summary>补充信息(结束时间、备注{{ editing ? '' : '、状态' }})</summary>
<el-form label-position="top" class="calendar-form density-form">
<el-form-item label="预约结束时间"><el-date-picker v-model="form.appointmentEndAt" type="datetime" format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<el-form-item v-if="!editing" label="预约状态"><el-select v-model="form.appointmentStatus"><el-option v-for="(label, value) in statusLabels" :key="value" :value="value" :label="label" /></el-select></el-form-item>
<el-form-item label="备注"><el-input v-model="form.appointmentRemark" maxlength="1000" placeholder="有特殊要求时填写" /></el-form-item>
</el-form>
</details>
<FbaAppointmentFields :model="form" :appointment-no="editing?.appointmentNo" :timezone="editing?.timezone || warehouseOptions.find(item => item.warehouseCode === warehouse)?.timezone" :disabled="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>