Files
admin-web/src/modules/appointment-calendar/TripAppointmentPanel.vue
T
2026-10-20 23:34:11 +08:00

105 lines
11 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/adminWorkbench.css'
import { computed, onMounted, onBeforeUnmount, provide, ref, watch } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { type DispatchBatchReference, type FbaAppointmentDto } from '@/modules/overseas-warehouse'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getAppointmentBatch, type FbaAppointmentBatchContext } from './batchApi'
import { getReceivingContext, type ReceivingContext } from './receivingApi'
import WorkReceivingPanel from './WorkReceivingPanel.vue'
import AppointmentBatchForm from './AppointmentBatchForm.vue'
import WorkAppointmentForm from './WorkAppointmentForm.vue'
import WorkBatchContinuation from './WorkBatchContinuation.vue'
import type { TripPreviousDispatch } from '@/modules/overseas-master-data'
import type { LeaveGuard } from './useWorkDraft'
const props = defineProps<{ dispatches: Array<{ dispatchNo: string; customerId: string; packageCount: number; appointmentBatches: DispatchBatchReference[] }>; previousDispatches: TripPreviousDispatch[]; historyUnavailableReason: string | null; canWrite: boolean }>()
const emit = defineEmits<{ updated: []; work: [key: string]; external: [path: string] }>()
type Card = { key: string; batchKey: string; batchNo: string | null; customerId: string; current: boolean; dispatchNos: string[]; historyNos: string[]; sources: DispatchBatchReference['sources']; batch: FbaAppointmentBatchContext | null; receiving: ReceivingContext | null; error: ApiErrorDetails | null }
const access = useAccessPreviewStore(), cards = ref<Card[]>([]), loading = ref(false)
const readable = computed(() => ['platform:appointment:list', 'platform:overseas-warehouse:dispatch:list'].every(permission => access.can(permission)))
const selected = ref<{ batchKey: string; customerId: string } | null>(null), mode = ref<'receiving' | 'batch' | 'appointment' | 'continue'>('receiving'), appointmentNo = ref('')
const guards = new Set<LeaveGuard>()
provide('appointment-work-leave-guard', (guard: LeaveGuard) => { guards.add(guard); return () => guards.delete(guard) })
async function canLeave() { for (const guard of guards) if (!await guard()) return false; return true }
defineExpose({ canLeave })
onBeforeRouteLeave(canLeave)
let generation = 0
async function load() {
if (!readable.value) return
const current = ++generation; loading.value = true
const batches = new Map<string, Promise<FbaAppointmentBatchContext>>(), receiving = new Map<string, Promise<ReceivingContext>>()
const references = new Map<string, Card>()
const add = (binding: { dispatchNo: string; customerId: string; appointmentBatches: DispatchBatchReference[] }, historical: boolean) => {
for (const reference of binding.appointmentBatches) {
const key = JSON.stringify([reference.batchKey, reference.customerId])
let card = references.get(key)
if (!card) { card = { key, batchKey: reference.batchKey, batchNo: reference.batchNo, customerId: reference.customerId, current: false, dispatchNos: [], historyNos: [], sources: [], batch: null, receiving: null, error: null }; references.set(key, card) }
if (reference.customerId !== binding.customerId || !reference.batchKey || !reference.sources.length) card.error = { message: '排车引用的客户或来源范围不完整,请核对原排车单。' }
card.current ||= !historical
const numbers = historical ? card.historyNos : card.dispatchNos
if (!numbers.includes(binding.dispatchNo)) numbers.push(binding.dispatchNo)
for (const source of reference.sources) if (!card.sources.some(existing => existing.sourceKey === source.sourceKey && existing.destinationKey === source.destinationKey)) card.sources.push(source)
}
}
props.dispatches.forEach(binding => add(binding, false))
props.previousDispatches.forEach(binding => add(binding, true))
const loaded = await Promise.all([...references.values()].map(async card => {
if (card.error) return card
try {
if (!batches.has(card.batchKey)) batches.set(card.batchKey, getAppointmentBatch(card.batchKey))
if (!receiving.has(card.batchKey)) receiving.set(card.batchKey, getReceivingContext(card.batchKey))
const [batch, scope] = await Promise.all([batches.get(card.batchKey)!, receiving.get(card.batchKey)!])
if (batch.appointment.batchKey !== card.batchKey || scope.batch.batchKey !== card.batchKey) throw new Error('批次范围不匹配')
card.batch = batch; card.receiving = scope
} catch (cause) { card.error = getApiErrorDetails(cause, '本客户预约与去向读取失败') }
return card
}))
if (current === generation) { cards.value = loaded; loading.value = false }
}
function customerName(card: Card) { return card.receiving?.agreements.flatMap(row => row.members).find(member => member.customerId === card.customerId)?.customerName || '—' }
function destinations(card: Card) {
if (!card.batch) return []
const original = new Set(card.batch.originalSourceRefs.filter(item => item.customerId === card.customerId && card.sources.some(source => source.sourceKey === item.sourceKey && source.destinationKey === item.destinationKey)).map(item => JSON.stringify([item.sourceKey, item.destinationKey])))
return [...new Map(card.batch.currentBatches.map(batch => [batch.batchKey, batch])).values()].map(batch => ({ batch, items: batch.items.filter(item => item.customerId === card.customerId && original.has(JSON.stringify([item.sourceKey, item.destinationKey])) && (item.packageCount == null || item.packageCount > 0)) })).filter(row => row.items.length)
}
async function open(batchKey: string, customerId: string, next: 'receiving' | 'batch' | 'continue') { if (await canLeave()) { selected.value = { batchKey, customerId }; mode.value = next } }
async function openAppointment(number: string) { if (await canLeave()) { appointmentNo.value = number; mode.value = 'appointment' } }
async function close(done: () => void) { if (await canLeave()) { done(); selected.value = null } }
function saved(appointment: FbaAppointmentDto) { if (appointment.batchKey && selected.value) { selected.value = { ...selected.value, batchKey: appointment.batchKey }; mode.value = 'continue' }; updated() }
function updated() { emit('updated'); void load() }
watch(() => JSON.stringify([props.dispatches, props.previousDispatches]), () => { void load() })
onMounted(load)
onBeforeUnmount(() => { ++generation })
</script>
<template>
<section aria-label="本车客户预约与后送去向">
<p v-if="!readable">需要预约及排车列表权限才能读取客户预约范围。</p>
<p v-if="loading">正在核对本车客户的预约与当前去向…</p>
<el-button :loading="loading" @click="load">刷新客户预约与去向</el-button>
<p v-if="historyUnavailableReason" role="alert">历史预约引用读取不完整:{{ historyUnavailableReason }}。当前车次仍可查看,请重试读取后再核对历史去向。</p><p v-for="binding in dispatches.filter(row => !row.appointmentBatches.length)" :key="binding.dispatchNo" role="status">{{ binding.dispatchNo }}:本排车单没有预约批次引用,请从原排车单核对业务来源。</p><article v-for="card in cards" :key="card.key" class="trip-customer-card">
<h4>{{ customerName(card) }} · {{ card.batchNo || '批次业务号未提供' }}</h4><p v-if="card.current">当前关联排车单:{{ card.dispatchNos.join('、') }}</p><p v-if="card.historyNos.length">历史关联排车单:{{ card.historyNos.join('、') }} · 仅供追溯,不代表当前装载或可排数量。</p>
<LoadErrorState v-if="card.error" title="本客户范围不可用" :message="card.error.message" @retry="load" />
<template v-if="card.batch && card.receiving">
<p v-for="pending in card.receiving.pendingChanges.filter(row => row.intent.customerId === card.customerId)" :key="pending.changeKey" role="status">待仓库回复:{{ pending.intent.reason }}。原安排保留,请继续处理。</p>
<el-button v-if="card.current" :disabled="loading || !canWrite" @click="open(card.batch.appointment.batchKey!, card.customerId, 'receiving')">调整本客户先送 / 后送</el-button>
<el-button v-if="card.current" :disabled="loading" @click="open(card.batch.appointment.batchKey!, card.customerId, 'batch')">查看批次与完整去向</el-button>
<div v-for="destination in destinations(card)" :key="destination.batch.batchKey"><b>{{ destination.batch.appointmentNo }}</b><p>{{ destination.batch.plannedDate || '日期未定' }}</p><p v-for="item in destination.items" :key="item.id">{{ item.inboundCode || '—' }} · {{ item.packageCount == null ? '箱数待核实' : `${item.packageCount} 箱` }}</p><el-button :disabled="loading" @click="open(destination.batch.batchKey!, card.customerId, 'continue')">继续本批当前工作</el-button></div>
<p v-if="!destinations(card).length">原客户来源范围当前不可匹配,请核对完整批次去向;此处不代表已完成或没有后送需求。</p>
</template>
</article>
<p v-if="readable && !loading && !cards.length">当前没有可读取的预约批次引用。</p>
<AppDialog :model-value="!!selected" title="客户预约与后送安排" width="min(1120px, calc(100vw - 48px))" class="admin-density-dialog admin-density admin-workbench is-dialog" fullscreenable append-to-body :show-footer="false" :before-close="close" :close-on-click-modal="true" :close-on-press-escape="true" destroy-on-close>
<template v-if="selected">
<WorkReceivingPanel v-if="mode === 'receiving'" :key="`${selected.batchKey}:${selected.customerId}`" :batch-key="selected.batchKey" :initial-customer-id="selected.customerId" :can-write="canWrite" @updated="updated" @appointment="openAppointment" @batch="key => open(key, selected!.customerId, 'batch')" @external="path => emit('external', path)" />
<AppointmentBatchForm v-else-if="mode === 'batch'" :key="selected.batchKey" :batch-key="selected.batchKey" @updated="updated" @appointment="openAppointment" />
<WorkAppointmentForm v-else-if="mode === 'appointment'" :key="appointmentNo" :appointment-no="appointmentNo" :cancel="false" :can-write="canWrite" @saved="saved" @batch="key => open(key, selected!.customerId, 'batch')" @receiving="key => open(key, selected!.customerId, 'receiving')" />
<WorkBatchContinuation v-else :key="`${selected.batchKey}:${selected.customerId}`" :batch-key="selected.batchKey" :customer-id="selected.customerId" @open="key => emit('work', key)" />
</template>
</AppDialog>
</section>
</template>
<style scoped>.trip-customer-card { padding-block: var(--admin-space-4); border-bottom: 1px solid var(--workbench-border); }</style>