156 lines
14 KiB
Vue
156 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import '@/styles/adminDensity.css'
|
||
import '@/styles/adminWorkbench.css'
|
||
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; billOfLadingNo?: string | null; 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 dialogTitle = computed(() => {
|
||
const action = props.container.currentPortScheduleId ? '原柜船期与修改历史' : '登记原柜船期'
|
||
const code = props.container.cabinetNo?.trim() || props.container.containerNo?.trim()
|
||
return code ? `${action} · ${code}` : action
|
||
})
|
||
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 { pending.value = null; 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 admin-workbench">
|
||
<el-button v-if="canRead" :disabled="!!prerequisite" @click="begin">船期维护与历史</el-button>
|
||
<span v-if="prerequisite && container.oceanBookingId && container.customerId">{{ prerequisite }}</span>
|
||
<span v-if="!prerequisite && !canRead">当前账号无船期读取权限。</span>
|
||
</div>
|
||
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="dialogTitle" fullscreenable :width="container.currentPortScheduleId ? '1000px' : '1120px'" :show-confirm="canWrite" :confirm-disabled="loading || !!prerequisite || (!!container.currentPortScheduleId && !schedule)" :confirm-loading="busy" confirm-text="保存船期" @confirm="save">
|
||
<div v-loading="loading" class="density-dialog-sections">
|
||
<el-alert v-if="error" :title="error" type="error" :closable="false" />
|
||
<section class="density-dialog-section" data-tone="source">
|
||
<div class="workbench-evidence"><table class="density-table schedule-context-table" aria-label="原柜船期归属"><tbody>
|
||
<tr><th>柜次号</th><td>{{ container.cabinetNo || '—' }}</td><th>集装箱号</th><td>{{ container.containerNo || '—' }}</td></tr>
|
||
<tr><th>提单号</th><td>{{ container.billOfLadingNo || '—' }}</td><th>客户</th><td>{{ container.customerName || '—' }}</td></tr>
|
||
</tbody></table></div>
|
||
</section>
|
||
<section class="density-dialog-section">
|
||
|
||
<el-form v-if="canWrite" class="density-form" :disabled="busy || !!pending || loading">
|
||
<div class="workbench-evidence"><table class="density-table schedule-editor-table" aria-label="原柜船期填写"><tbody>
|
||
<tr><td><el-form-item label="船名" label-position="left" label-width="120px" required><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item></td><td><el-form-item label="航次" label-position="left" label-width="120px" required><el-input v-model="form.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item></td></tr>
|
||
<tr><td v-for="key in dates" :key="key"><el-form-item :label="dateLabels[key]" label-position="left" label-width="120px" required><el-date-picker v-model="form[key]" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :placeholder="`请选择${dateLabels[key]}`" /></el-form-item></td></tr>
|
||
<template v-if="!container.currentPortScheduleId">
|
||
<tr><td><el-form-item label="信息来源" label-position="left" label-width="120px" required><el-input v-model="form.sourceName" maxlength="128" placeholder="请输入信息来源" /></el-form-item></td><td><el-form-item label="来源参考号或链接" label-position="left" label-width="120px" required><el-input v-model="form.sourceRef" maxlength="255" placeholder="请输入来源参考号或链接" /></el-form-item></td></tr>
|
||
<tr v-for="(keys, index) in [extraDates.slice(0, 2), extraDates.slice(2, 4), extraDates.slice(4)]" :key="index"><td v-for="key in keys" :key="key" :colspan="keys.length === 1 ? 2 : 1"><el-form-item :label="dateLabels[key]" label-position="left" label-width="120px" :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></td></tr>
|
||
</template>
|
||
<tr><td colspan="2"><el-form-item label="修改原因" label-position="left" label-width="120px" required><el-input v-model="form.reason" type="textarea" maxlength="1000" placeholder="请说明本次修改原因" /></el-form-item></td></tr>
|
||
</tbody></table></div>
|
||
</el-form>
|
||
<div class="workbench-evidence" v-else><table class="density-table schedule-context-table" aria-label="当前船期只读资料"><tbody>
|
||
<tr><th>船名</th><td>{{ form.vesselName || '—' }}</td><th>航次</th><td>{{ form.voyageNo || '—' }}</td></tr>
|
||
<tr><template v-for="key in dates" :key="key"><th>{{ dateLabels[key] }}</th><td>{{ time(form[key]) }}</td></template></tr>
|
||
</tbody></table></div>
|
||
</section>
|
||
<section class="density-dialog-section" data-tone="source">
|
||
<h3 class="density-dialog-section__title">修改历史</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" show-overflow-tooltip />
|
||
<el-table-column prop="reason" label="原因" min-width="150" show-overflow-tooltip />
|
||
<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>
|
||
</section>
|
||
</div>
|
||
</AppDialog>
|
||
</template>
|
||
<style scoped>
|
||
.schedule-actions { display:flex; align-items:center; gap:var(--admin-space-5); margin:var(--admin-space-3) 0; }
|
||
.schedule-editor-table, .schedule-context-table { table-layout:fixed; }
|
||
.schedule-context-table th { width:120px; }
|
||
</style>
|