Files
admin-web/src/modules/port-schedules/components/ContainerPortSchedulePanel.vue
T

138 lines
12 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AppDialog from '@/shared/components/AppDialog.vue'
import { ApiResponseError, getApiErrorDetails } from '@/shared/api/http'
import { createPortSchedule, getPortSchedule, updatePortSchedule } from '../api/platformPortSchedules'
import type { PortScheduleData, PortScheduleDetail, PortScheduleSaveRequest } from '../types'
const props = defineProps<{ container: {
id: string; currentPortScheduleId?: string | null; customerId?: string | null; customerName?: string | null
oceanBookingId?: string | null; bookingNo: string; cabinetNo: string; containerNo: string | null
vesselName: string | null; voyageNo: string | null; departurePortCode: string | null; departurePortName?: string | null
destinationPortCode: string | null; destinationPortName?: string | null
estimatedSailingAt: string | null; estimatedPortArrivalAt: string | null
} }>()
const emit = defineEmits<{ saved: [] }>()
const access = useAccessPreviewStore()
const canRead = computed(() => access.can('platform:firstleg:port-schedule:list'))
const canWrite = computed(() => access.can(`platform:firstleg:port-schedule:${props.container.currentPortScheduleId || schedule.value ? 'edit' : 'create'}`))
const open = ref(false), busy = ref(false), loading = ref(false), error = ref('')
const schedule = ref<PortScheduleDetail | null>(null)
const pending = ref<{ id: string | null; key: string; request: PortScheduleSaveRequest } | null>(null)
const form = reactive({ vesselName: '', voyageNo: '', etdAt: '', etaAt: '', reason: '', sourceName: '', sourceRef: '', observedAt: '', expiresAt: '', portCutoffAt: '', bookingCutoffAt: '', customsCutoffAt: '' })
let generation = 0
const scope = computed(() => JSON.stringify([props.container.id, props.container.customerId, props.container.oceanBookingId, props.container.currentPortScheduleId]))
const prerequisite = computed(() => {
const c = props.container
if (!c.oceanBookingId || c.customerId == null || c.customerId === '') return '尚未取得原订舱及客户归属,不能维护船期。'
if (!c.currentPortScheduleId && (!c.departurePortCode || !c.destinationPortCode)) return '原订舱起运港或目的港缺失,不能登记船期。'
return ''
})
const dates = ['etdAt', 'etaAt'] as const
const extraDates = ['observedAt', 'expiresAt', 'portCutoffAt', 'bookingCutoffAt', 'customsCutoffAt'] as const
const dateLabels = { etdAt: '预计开船 ETD', etaAt: '预计到港 ETA', observedAt: '信息确认时间', expiresAt: '信息有效至', portCutoffAt: '截港时间', bookingCutoffAt: '截单时间(选填)', customsCutoffAt: '截关时间(选填)' }
function time(value?: string | null) { return value ? value.replace('T', ' ') : '—' }
function snapshot(raw?: string | null) {
if (!raw) return '—'
try { const value = JSON.parse(raw) as PortScheduleData; return `${value.vesselName || '—'} / ${value.voyageNo || '—'};ETD ${time(value.etdAt)};ETA ${time(value.etaAt)}` }
catch { return '历史快照无法读取' }
}
async function begin() {
if (!canRead.value || prerequisite.value) return
open.value = true; loading.value = true; error.value = ''; schedule.value = null; pending.value = null
const current = ++generation, c = props.container
Object.assign(form, { vesselName: c.vesselName || '', voyageNo: c.voyageNo || '', etdAt: c.estimatedSailingAt || '', etaAt: c.estimatedPortArrivalAt || '', reason: '', sourceName: '', sourceRef: '', observedAt: '', expiresAt: '', portCutoffAt: '', bookingCutoffAt: '', customsCutoffAt: '' })
try {
if (c.currentPortScheduleId) {
const result = await getPortSchedule(c.currentPortScheduleId)
if (current !== generation || !open.value) return
const value = result.portSchedule.schedule
if (value.id !== c.currentPortScheduleId || value.containerBookingId !== c.id || value.oceanBookingId !== c.oceanBookingId || value.customerId !== c.customerId) throw new Error('当前船期不属于该柜的原客户和订舱,不能修改共享或其他柜船期。')
schedule.value = result
Object.assign(form, { vesselName: value.vesselName, voyageNo: value.voyageNo, etdAt: value.etdAt || '', etaAt: value.etaAt || '' })
}
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '船期读取失败').message }
finally { if (current === generation) loading.value = false }
}
async function save() {
if (!open.value || loading.value || busy.value || !canRead.value || !canWrite.value || prerequisite.value) return
if (props.container.currentPortScheduleId && !schedule.value) return
if (!pending.value) {
const original = schedule.value?.portSchedule.schedule
if (original && form.vesselName.trim() === original.vesselName && form.voyageNo.trim() === original.voyageNo
&& form.etdAt === (original.etdAt || '') && form.etaAt === (original.etaAt || '')) {
error.value = '船期信息未变更,无需重复保存。'; return
}
if (!form.vesselName.trim() || !form.voyageNo.trim() || !form.etdAt || !form.etaAt || !form.reason.trim()) { error.value = '请填写船名、航次、预计开船/到港时间和修改原因。'; return }
if (form.etaAt < form.etdAt) { error.value = '预计到港时间不能早于预计开船时间。'; return }
let data: PortScheduleData
if (schedule.value) {
const { id: _id, scheduleNo: _no, version: _version, updatedAt: _updatedAt, ...original } = schedule.value.portSchedule.schedule
data = { ...original, vesselName: form.vesselName.trim(), voyageNo: form.voyageNo.trim(), etdAt: form.etdAt, etaAt: form.etaAt }
} else {
if (!form.sourceName.trim() || !form.sourceRef.trim() || !form.observedAt || !form.expiresAt || !form.portCutoffAt) { error.value = '首次登记须填写来源名、来源参考、信息确认时间、信息有效至和截港时间。'; return }
if (form.expiresAt <= form.observedAt || form.portCutoffAt > form.etdAt || (form.bookingCutoffAt && form.bookingCutoffAt > form.etdAt) || (form.customsCutoffAt && form.customsCutoffAt > form.etdAt)) { error.value = '有效至须晚于确认时间,各截点不得晚于预计开船。'; return }
const c = props.container
data = { customerId: c.customerId!, oceanBookingId: c.oceanBookingId!, containerBookingId: c.id, sourceType: 'MANUAL', sourceName: form.sourceName.trim(), sourceRef: form.sourceRef.trim(), vesselName: form.vesselName.trim(), voyageNo: form.voyageNo.trim(), departurePortCode: c.departurePortCode!, departurePortName: c.departurePortName, destinationPortCode: c.destinationPortCode!, destinationPortName: c.destinationPortName, etdAt: form.etdAt, etaAt: form.etaAt, observedAt: form.observedAt, expiresAt: form.expiresAt, portCutoffAt: form.portCutoffAt, bookingCutoffAt: form.bookingCutoffAt || null, customsCutoffAt: form.customsCutoffAt || null }
}
pending.value = { id: schedule.value?.portSchedule.schedule.id || null, key: crypto.randomUUID(), request: { expectedVersion: schedule.value?.portSchedule.schedule.version ?? 0, data, reason: form.reason.trim() } }
}
const current = generation, command = pending.value
busy.value = true; error.value = ''
try {
const result = command.id ? await updatePortSchedule(command.id, command.request, command.key) : await createPortSchedule(command.request.data, command.key, command.request.reason || '')
if (current !== generation || !open.value) return
schedule.value = result; pending.value = null; emit('saved')
} catch (cause) {
if (current === generation) {
error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '船期保存失败').message
if (cause instanceof ApiResponseError && cause.code === 'A0409') error.value += ';输入已保留,请关闭后重新打开核对最新版本。'
}
} finally { if (current === generation) busy.value = false }
}
watch(open, value => { if (!value) { generation++; loading.value = false; busy.value = false } })
watch(scope, () => { open.value = false; generation++; schedule.value = null; pending.value = null })
onBeforeUnmount(() => { generation++ })
</script>
<template>
<div class="schedule-actions">
<el-button v-if="canRead" :disabled="!!prerequisite" @click="begin">船期维护与历史</el-button>
<span v-if="prerequisite">{{ prerequisite }}</span>
<span v-else-if="!canRead">当前账号无船期读取权限。</span>
</div>
<AppDialog v-model="open" class="admin-density-dialog" :title="container.currentPortScheduleId ? '原柜船期与修改历史' : '登记原柜船期'" :width="container.currentPortScheduleId ? '1000px' : '1120px'" :show-confirm="canWrite" :confirm-disabled="loading || !!prerequisite || (!!container.currentPortScheduleId && !schedule)" :confirm-loading="busy" :confirm-text="pending ? '重试原请求' : '保存船期'" @confirm="save">
<div v-loading="loading" class="schedule-body">
<p>{{ container.cabinetNo || '—' }} · {{ container.containerNo || '—' }} · 订舱 {{ container.bookingNo || '—' }} · {{ container.customerName || '—' }}</p>
<el-alert v-if="error" :title="error" type="error" :closable="false" />
<p v-if="!container.currentPortScheduleId">请登记本柜的船名、航次和预计时间,并按实际信息填写来源及有效期。</p>
<el-form label-position="top" class="density-form" :disabled="!canWrite || busy || !!pending || loading">
<el-row :gutter="16">
<el-col :span="container.currentPortScheduleId ? 12 : 8"><el-form-item label="船名" required><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item></el-col>
<el-col :span="container.currentPortScheduleId ? 12 : 8"><el-form-item label="航次" required><el-input v-model="form.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item></el-col>
<el-col v-for="key in dates" :key="key" :span="container.currentPortScheduleId ? 12 : 8"><el-form-item :label="dateLabels[key]" required><el-date-picker v-model="form[key]" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :placeholder="`请选择${dateLabels[key]}`" /></el-form-item></el-col>
<template v-if="!container.currentPortScheduleId">
<el-col :span="8"><el-form-item label="信息来源(人工登记)" required><el-input v-model="form.sourceName" maxlength="128" placeholder="请输入信息来源" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="来源参考号或链接" required><el-input v-model="form.sourceRef" maxlength="255" placeholder="请输入来源参考号或链接" /></el-form-item></el-col>
<el-col v-for="key in extraDates" :key="key" :span="8"><el-form-item :label="dateLabels[key]" :required="!['bookingCutoffAt', 'customsCutoffAt'].includes(key)"><el-date-picker v-model="form[key]" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :placeholder="`请选择${dateLabels[key]}`" /></el-form-item></el-col>
</template>
<el-col :span="24"><el-form-item label="修改原因" required><el-input v-model="form.reason" type="textarea" maxlength="1000" placeholder="请说明本次修改原因" /></el-form-item></el-col>
</el-row>
</el-form>
<h3>修改历史</h3>
<el-table :data="schedule?.histories || []" empty-text="暂无修改历史">
<el-table-column label="修改时间" min-width="170"><template #default="{ row }">{{ time(row.createdAt) }}</template></el-table-column>
<el-table-column prop="operatorName" label="修改人" width="125" />
<el-table-column prop="reason" label="原因" min-width="150" />
<el-table-column label="修改前" min-width="240"><template #default="{ row }">{{ snapshot(row.beforeSnapshot) }}</template></el-table-column>
<el-table-column label="修改后" min-width="240"><template #default="{ row }">{{ snapshot(row.afterSnapshot) }}</template></el-table-column>
</el-table>
</div>
</AppDialog>
</template>
<style scoped>
.schedule-actions { display:flex; align-items:center; gap:12px; margin:8px 0; }
.schedule-body { max-height:65vh; overflow:auto; padding-inline:8px; }
.schedule-body :deep(.el-date-editor) { width:100%; }
</style>