Files
admin-web/src/modules/appointment-calendar/AppointmentCalendarView.vue
T

369 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { resolvePlanningSource, listUnscheduledAppointments, type ResolvedPlanningSource } from './planningApi'
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import {
createFbaAppointment, getFbaAppointment, listFbaAppointmentCalendar, listInboundOrders, updateFbaAppointment,
overseasWarehousePermissions as permission,
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'
import AppointmentInboundCodes from './AppointmentInboundCodes.vue'
import AppointmentCargoPanel from './AppointmentCargoPanel.vue'
import AppointmentContainerPanel from './AppointmentContainerPanel.vue'
import { calendarRange, dateKey } from './calendar'
import { deleteAppointment, listArrivalCalendar, type ArrivalCalendarEntry } from './api'
const access = useAccessPreviewStore()
const route = useRoute()
const linkedSource = ref<ResolvedPlanningSource | null>(null)
const linkError = ref<ApiErrorDetails | null>(null)
const linkLoading = ref(false)
let linkRequest = 0
const workView = ref<'calendar' | 'cargo' | 'containers'>('calendar')
const appointments = ref<FbaAppointmentDto[]>([])
const arrivals = ref<ArrivalCalendarEntry[]>([])
const range = ref(calendarRange(dateKey(new Date()), 'month'))
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const sourceErrors = ref<Array<{ title: string; details: ApiErrorDetails }>>([])
const errorTitle = ref('预约日历加载失败')
const dialogError = ref<ApiErrorDetails | null>(null)
const dialogOpen = ref(false)
const saving = ref(false)
const detailLoading = ref(false)
const detailReady = ref(false)
const editing = ref<FbaAppointmentDto | null>(null)
const warehouse = ref('')
const warehouseOptions = ref<PlatformWarehouseSummaryResource[]>([])
const warehouseLoading = ref(false)
const warehouseError = ref<ApiErrorDetails | null>(null)
const warehouseKeyword = ref('')
const warehouseTotal = ref(0)
let warehouseRequest = 0
const candidates = ref<InboundOrderSummaryDto[]>([])
const selected = ref<InboundOrderSummaryDto[]>([])
const candidateLoading = ref(false)
const candidateError = ref<ApiErrorDetails | null>(null)
const candidatePage = ref(1)
const candidateSize = ref(10)
const candidateTotal = ref(0)
const searchedWarehouse = ref('')
const selectionEpoch = ref(0)
let calendarController: AbortController | undefined
let calendarRequest = 0
let candidateRequest = 0
let detailRequest = 0
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 },
{ prop: 'customerName', label: '客户', slotName: 'customer', minWidth: 150 },
{ prop: 'fbaWarehouseCode', label: '目的仓', width: 110 },
{ prop: 'overseasWarehouseName', label: '海外仓', minWidth: 150 },
{ prop: 'packageCount', label: '箱数', width: 80 },
{ prop: 'palletCount', label: '托数', width: 80 },
{ prop: 'appointmentNo', label: '已有预约号', minWidth: 160 },
]
const events = computed<CalendarEvent[]>(() => [...arrivals.value.map(arrival => ({
key: `arrival-${arrival.containerBookingId}`, type: 'arrival' as const,
date: arrival.arrivalAt.slice(0, 10), time: arrival.arrivalAt.slice(11, 16),
title: `${arrival.voyageNo || arrival.bookingNo} ${arrival.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港'}`,
fields: [
{ label: '船名 / 航次', value: [arrival.vesselName, arrival.voyageNo].filter(Boolean).join(' / ') },
{ label: '目的港', value: arrival.destinationPortName },
{ label: '已装货柜号', value: arrival.containerNo },
{ label: '柜次号', value: arrival.bookingNo },
{ label: '货物去向', value: arrival.destinationWarehouseCode },
],
})), ...appointments.value.map(appointment => ({
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.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'),
}))])
async function load(nextRange = range.value) {
range.value = nextRange
const request = ++calendarRequest
calendarController?.abort()
calendarController = new AbortController()
loading.value = true
loadError.value = null
sourceErrors.value = []
errorTitle.value = '预约日历加载失败'
appointments.value = []
arrivals.value = []
try {
const [result, arrivalResult] = await Promise.allSettled([
listFbaAppointmentCalendar(nextRange, calendarController.signal),
listArrivalCalendar(nextRange, calendarController.signal),
])
if (request !== calendarRequest) return
if (result.status === 'fulfilled') appointments.value = result.value
else sourceErrors.value.push({ title: '送仓预约加载失败', details: getApiErrorDetails(result.reason, '送仓预约加载失败') })
if (arrivalResult.status === 'fulfilled') arrivals.value = arrivalResult.value
else sourceErrors.value.push({ title: '船舶到港加载失败', details: getApiErrorDetails(arrivalResult.reason, '船舶到港加载失败') })
} catch (error) {
if (request === calendarRequest) loadError.value = getApiErrorDetails(error, '预约日历加载失败')
} finally { if (request === calendarRequest) loading.value = false }
}
function openCreate(date: string) {
++candidateRequest
++detailRequest
detailLoading.value = false
candidateLoading.value = false
candidatePage.value = 1
editing.value = null
warehouse.value = ''
searchedWarehouse.value = ''
candidates.value = []
selected.value = []
selectionEpoch.value++
candidateTotal.value = 0
candidateError.value = null
dialogError.value = null
Object.assign(form, createAppointmentForm(), { plannedDate: date }); pendingSave.value = null
dialogOpen.value = true
detailReady.value = true
void searchWarehouses('')
}
async function openEdit(event: Pick<CalendarEvent, 'key'>) {
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
try {
const detail = await getFbaAppointment(event.key)
if (request !== detailRequest || !dialogOpen.value) return
editing.value = detail
detailReady.value = true
warehouse.value = detail.fbaWarehouseCode
Object.assign(form, createAppointmentForm(detail))
} catch (error) {
if (request === detailRequest) dialogError.value = getApiErrorDetails(error, '预约详情加载失败')
} finally { if (request === detailRequest) detailLoading.value = false }
}
async function searchWarehouses(keyword: string) {
warehouseKeyword.value = keyword
const request = ++warehouseRequest
warehouseLoading.value = true
warehouseError.value = null
warehouseOptions.value = []
warehouseTotal.value = 0
try {
const result = await listPlatformWarehouses({ page: 1, size: 50, keyword: keyword.trim() || undefined, warehouseType: 'FBA', status: 'ACTIVE' })
if (request !== warehouseRequest || !dialogOpen.value) return
warehouseOptions.value = result.records.filter(item => item.warehouseType === 'FBA' && item.status === 'ACTIVE')
warehouseTotal.value = result.total
} catch (error) {
if (request === warehouseRequest) warehouseError.value = getApiErrorDetails(error, '仓库搜索失败,请重试')
} finally { if (request === warehouseRequest) warehouseLoading.value = false }
}
function changeWarehouse(value: string) {
++candidateRequest
candidateLoading.value = false
candidates.value = []
selected.value = []
candidateTotal.value = 0
candidateError.value = null
candidatePage.value = 1
selectionEpoch.value++
searchedWarehouse.value = value
if (value) void loadCandidates()
}
async function loadCandidates() {
if (!searchedWarehouse.value) return
candidateLoading.value = true
candidateError.value = null
const request = ++candidateRequest
try {
const result = await listInboundOrders({ page: candidatePage.value, size: candidateSize.value, fbaWarehouseCode: searchedWarehouse.value })
if (request !== candidateRequest || !dialogOpen.value) return
candidates.value = result.records
candidateTotal.value = result.total
} catch (error) {
if (request === candidateRequest) {
candidates.value = []
candidateError.value = getApiErrorDetails(error, '可预约货物加载失败')
}
} finally { if (request === candidateRequest) candidateLoading.value = false }
}
async function save() {
if (saving.value || detailLoading.value || !detailReady.value) 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
}
}
saving.value = true
dialogError.value = null
try {
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 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 }
}
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 }
}
watch(dialogOpen, open => {
if (!open) { ++candidateRequest; ++detailRequest; ++warehouseRequest; candidateLoading.value = false; warehouseLoading.value = false; detailLoading.value = false; detailReady.value = false }
})
async function resolveLink() {
const request = ++linkRequest
linkedSource.value = null; linkError.value = null; linkLoading.value = false
const { sourceKey, customerId, destinationKey } = route.query
if (!sourceKey && !customerId && !destinationKey) return
if (typeof sourceKey !== 'string' || typeof customerId !== 'string' || typeof destinationKey !== 'string') {
linkError.value = { message: '货物链接不完整,请返回原登记重新打开' }; return
}
if (!access.can(permission.lastMileOrderList)) { linkError.value = { message: '当前账号没有货源读取权限,请联系预约负责人处理' }; return }
linkLoading.value = true
try {
const result = await resolvePlanningSource({ sourceKey, customerId, destinationKey })
if (request === linkRequest) linkedSource.value = result
} catch (cause) { if (request === linkRequest) linkError.value = getApiErrorDetails(cause, '预约货源核对失败') }
finally { if (request === linkRequest) linkLoading.value = false }
}
watch(() => [route.query.sourceKey, route.query.customerId, route.query.destinationKey], resolveLink, { immediate: true })
onMounted(() => { void load(); void loadUnscheduled() })
onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequest; ++detailRequest; ++warehouseRequest; ++linkRequest; calendarController?.abort() })
</script>
<template>
<AdminPage class="admin-density">
<p v-if="linkLoading">正在核对预约货源…</p>
<LoadErrorState v-if="linkError" title="预约货源核对失败" :message="linkError.message" :loading="linkLoading" @retry="resolveLink" />
<AppointmentFollowupDialog v-if="linkedSource" :source="linkedSource.source" :coverage="linkedSource.coverage" :can-write="access.can('platform:appointment:followup')" @close="linkedSource = null" />
<div class="density-status-tabs" role="group" aria-label="预约工作视图">
<button type="button" :class="{ 'is-active': workView === 'calendar' }" :aria-pressed="workView === 'calendar'" @click="workView = 'calendar'">预约日历</button>
<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'" @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="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">
<el-form-item label="预约仓库" required>
<el-input v-if="editing" :model-value="warehouse" readonly />
<el-select v-else v-model="warehouse" aria-label="预约仓库" filterable remote clearable :remote-method="searchWarehouses" :loading="warehouseLoading" placeholder="搜索仓库编码或名称" @change="changeWarehouse">
<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>
<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="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>
<template #notices><LoadErrorState v-if="candidateError" title="货物加载失败" :message="candidateError.message" @retry="loadCandidates()" /></template>
<template #pagination-left>已选 {{ selected.length }} 票,可跨客户选择同一目的仓货物</template>
</DataTable>
</template>
<p v-else-if="!editing" class="appointment-hint">先选择预约仓库,即可勾选同仓货物,支持多个客户一起预约。</p>
<div v-else class="appointment-cargo">
<span>{{ editing.selectedCount }} 票货物</span>
<AppointmentInboundCodes :key="editing.appointmentNo" :codes="editing.items.map(item => item.inboundCode)" />
</div>
</div>
</AppDialog>
</AdminPage>
</template>
<style scoped>
.calendar-form { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-6); }
.calendar-form :deep(.el-select), .calendar-form :deep(.el-date-editor) { width: 100%; }
.appointment-cargo { padding: 12px 0; line-height: 1.7; overflow-wrap: anywhere; font-size: 13px; }
.appointment-extra { margin-bottom: var(--admin-space-5); }
.appointment-extra summary { cursor: pointer; margin-bottom: var(--admin-space-3); font-size: var(--admin-font-body); }
.appointment-hint { color: var(--el-text-color-secondary); font-size: var(--admin-font-body); margin: var(--admin-space-5) 0; }
</style>