fix(admin): allow manual container nodes without tracking
This commit is contained in:
@@ -6,9 +6,10 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { appendCarrierScheduleManualNode, ensureCarrierScheduleQueryForContainer, getCarrierScheduleQuery, listCarrierScheduleQueries } from '../api/platformCarrierScheduleQueries'
|
||||
import { getCarrierScheduleQuery, listCarrierScheduleQueries } from '../api/platformCarrierScheduleQueries'
|
||||
import { appendContainerShippingNode, getContainerShippingNodes } from '../api/platformContainerShippingNodes'
|
||||
import { getPortSchedule } from '../api/platformPortSchedules'
|
||||
import type { CarrierScheduleExceptionFact, CarrierScheduleManualNodeRequest, CarrierScheduleNodeFact, CarrierScheduleQueryDetail, CarrierScheduleQueryView, CarrierScheduleStandardNode, PortScheduleView } from '../types'
|
||||
import type { CarrierScheduleExceptionFact, CarrierScheduleNodeFact, CarrierScheduleQueryDetail, CarrierScheduleQueryView, CarrierScheduleStandardNode, ContainerManualNodeRequest, ContainerShippingNodeDetail, PortScheduleView } from '../types'
|
||||
|
||||
type WarehouseTimelineEntry = {
|
||||
id?: string | null; eventId?: string | null; idempotencyKey?: string | null
|
||||
@@ -26,13 +27,12 @@ const queryDetailError = ref<ApiErrorDetails | null>(null)
|
||||
const scheduleError = ref<ApiErrorDetails | null>(null)
|
||||
const selected = ref<CarrierScheduleQueryView | null>(null)
|
||||
const detail = ref<CarrierScheduleQueryDetail | null>(null)
|
||||
const containerNodes = ref<ContainerShippingNodeDetail | null>(null)
|
||||
const latestSchedule = ref<PortScheduleView | null>(null)
|
||||
const manualDialogOpen = ref(false)
|
||||
const manualIdempotencyKey = ref('')
|
||||
const manualObservedAt = ref('')
|
||||
const manualSaving = ref(false)
|
||||
const manualPreparing = ref(false)
|
||||
const manualEntryError = ref<ApiErrorDetails | null>(null)
|
||||
const manualError = ref<ApiErrorDetails | null>(null)
|
||||
const manualValidationError = ref('')
|
||||
const manualForm = reactive({ standardNode: '' as CarrierScheduleStandardNode | '', estimatedAt: '', actualAt: '', locationCode: '', locationName: '', reason: '' })
|
||||
@@ -50,7 +50,7 @@ const statusType = computed<'success' | 'warning' | 'danger' | 'info'>(() => {
|
||||
if (queryView.value?.expired || query.value?.status === 'MANUAL_REQUIRED') return 'danger'
|
||||
return 'warning'
|
||||
})
|
||||
const nodes = computed(() => detail.value?.nodes || [])
|
||||
const nodes = computed(() => containerNodes.value?.nodes?.length ? containerNodes.value.nodes : detail.value?.nodes || [])
|
||||
const exceptions = computed(() => [...(detail.value?.exceptions || [])].reverse())
|
||||
const latestActualNodeFact = computed(() => [...nodes.value].reverse().find((item) => item.standardNode === query.value?.latestActualNode && item.actualAt === query.value?.latestActualAt)
|
||||
|| [...nodes.value].reverse().find((item) => item.standardNode === query.value?.latestActualNode && item.actualAt)
|
||||
@@ -60,8 +60,6 @@ const sourceReference = computed(() => latestActualNodeFact.value?.sourceReferen
|
||||
const latestObservation = computed(() => latestActualNodeFact.value?.observedAt || null)
|
||||
const histories = computed(() => [...(detail.value?.histories || [])].reverse())
|
||||
const standardNodes: CarrierScheduleStandardNode[] = ['DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'LOADED_ON_VESSEL', 'DEPARTED_ORIGIN_PORT', 'IN_TRANSIT', 'ARRIVED_DESTINATION_PORT', 'DISCHARGED_FROM_VESSEL', 'PICKUP_READY', 'PICKED_UP', 'EMPTY_RETURNED']
|
||||
const standardNodeSequence = new Map(standardNodes.map((node, index) => [node, (index + 1) * 10]))
|
||||
const latestActualNodeSequence = computed(() => query.value?.latestActualNode ? standardNodeSequence.get(query.value.latestActualNode) || 0 : 0)
|
||||
|
||||
function compareActualFacts(left: CarrierScheduleNodeFact, right: CarrierScheduleNodeFact) {
|
||||
for (const field of ['actualAt', 'observedAt', 'createdAt'] as const) {
|
||||
@@ -83,7 +81,6 @@ const supersededActualNodes = computed(() => nodes.value
|
||||
.filter((item) => item.actualAt && latestActualFactsByNode.value.get(item.standardNode)?.id !== item.id)
|
||||
.sort((left, right) => compareActualFacts(right, left)))
|
||||
const primaryNodeFacts = computed(() => nodes.value.filter((item) => !item.actualAt || latestActualFactsByNode.value.get(item.standardNode)?.id === item.id))
|
||||
function isManualActualNodeAllowed(node: CarrierScheduleStandardNode) { return (standardNodeSequence.get(node) || 0) > latestActualNodeSequence.value }
|
||||
|
||||
function formatTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||||
function trackingTypeText(value: string) { return ({ BOOKING_NO: '订舱号', BILL_OF_LADING: '提单号', CONTAINER_NO: '集装箱号' } as Record<string, string>)[value] || value }
|
||||
@@ -207,47 +204,33 @@ function localDateTimeNow() {
|
||||
const now = new Date()
|
||||
return new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 19)
|
||||
}
|
||||
async function openManualDialog() {
|
||||
if (manualPreparing.value) return
|
||||
manualEntryError.value = null
|
||||
if (!query.value) {
|
||||
if (!props.containerBookingId) { manualEntryError.value = getApiErrorDetails(new Error('当前页面缺少货柜信息,无法建立船节点任务')); return }
|
||||
manualPreparing.value = true
|
||||
try {
|
||||
const ensured = await ensureCarrierScheduleQueryForContainer(props.containerBookingId, createIdempotencyKey())
|
||||
detail.value = ensured
|
||||
selected.value = ensured.query
|
||||
queryDetailError.value = null
|
||||
} catch (cause) {
|
||||
manualEntryError.value = getApiErrorDetails(cause, '船节点任务建立失败')
|
||||
return
|
||||
} finally { manualPreparing.value = false }
|
||||
}
|
||||
function openManualDialog() {
|
||||
if (!props.containerBookingId) return
|
||||
resetManualForm(); manualIdempotencyKey.value = createIdempotencyKey(); manualObservedAt.value = localDateTimeNow(); manualDialogOpen.value = true
|
||||
}
|
||||
async function submitManualNode() {
|
||||
manualValidationError.value = ''
|
||||
if (!query.value || !manualForm.standardNode) { manualValidationError.value = '请选择标准节点'; return }
|
||||
if (!props.containerBookingId || !manualForm.standardNode) { manualValidationError.value = '请选择标准节点'; return }
|
||||
if (!manualForm.estimatedAt && !manualForm.actualAt) { manualValidationError.value = '预计时间和实际时间至少填写一项'; return }
|
||||
if (manualForm.actualAt && !isManualActualNodeAllowed(manualForm.standardNode)) { manualValidationError.value = '实际补录只能选择严格晚于当前实际进度的节点'; return }
|
||||
if (!manualForm.reason.trim()) { manualValidationError.value = '请输入补录原因'; return }
|
||||
const base = { expectedVersion: query.value.version, standardNode: manualForm.standardNode, observedAt: manualObservedAt.value, locationCode: manualForm.locationCode.trim() || null, locationName: manualForm.locationName.trim() || null, reason: manualForm.reason.trim() }
|
||||
const request: CarrierScheduleManualNodeRequest = manualForm.actualAt
|
||||
const base = { standardNode: manualForm.standardNode, observedAt: manualObservedAt.value, locationCode: manualForm.locationCode.trim() || null, locationName: manualForm.locationName.trim() || null, reason: manualForm.reason.trim() }
|
||||
const request: ContainerManualNodeRequest = manualForm.actualAt
|
||||
? { ...base, actualAt: manualForm.actualAt, estimatedAt: manualForm.estimatedAt || null }
|
||||
: { ...base, estimatedAt: manualForm.estimatedAt, actualAt: null }
|
||||
manualSaving.value = true; manualError.value = null
|
||||
try {
|
||||
const result = await appendCarrierScheduleManualNode(query.value.id, manualIdempotencyKey.value, request)
|
||||
detail.value = result; selected.value = result.query; queryDetailError.value = null; manualDialogOpen.value = false; manualIdempotencyKey.value = ''; manualObservedAt.value = ''; resetManualForm()
|
||||
containerNodes.value = await appendContainerShippingNode(props.containerBookingId, manualIdempotencyKey.value, request)
|
||||
manualDialogOpen.value = false; manualIdempotencyKey.value = ''; manualObservedAt.value = ''; resetManualForm()
|
||||
} catch (cause) { manualError.value = getApiErrorDetails(cause, '人工节点补录失败') }
|
||||
finally { manualSaving.value = false }
|
||||
}
|
||||
|
||||
async function load() {
|
||||
selected.value = null; detail.value = null; latestSchedule.value = null; error.value = null; queryDetailError.value = null; scheduleError.value = null
|
||||
selected.value = null; detail.value = null; containerNodes.value = null; latestSchedule.value = null; error.value = null; queryDetailError.value = null; scheduleError.value = null
|
||||
if (!props.oceanBookingId && !props.containerBookingId) return
|
||||
loading.value = true
|
||||
try {
|
||||
if (props.containerBookingId) containerNodes.value = await getContainerShippingNodes(props.containerBookingId)
|
||||
const page = await listCarrierScheduleQueries({ page: 1, size: 200, oceanBookingId: props.oceanBookingId || undefined, containerBookingId: props.containerBookingId || undefined })
|
||||
const matching = props.portScheduleId ? page.records.filter((item) => item.query.resultPortScheduleId === String(props.portScheduleId)) : page.records
|
||||
selected.value = matching[0] || page.records[0] || null
|
||||
@@ -266,26 +249,22 @@ async function load() {
|
||||
|
||||
watch(() => [props.oceanBookingId, props.containerBookingId, props.portScheduleId], () => void load(), { immediate: true })
|
||||
watch(manualDialogOpen, (open) => { if (!open && !manualSaving.value) { manualIdempotencyKey.value = ''; manualObservedAt.value = '' } })
|
||||
watch(() => manualForm.actualAt, (actualAt) => {
|
||||
if (actualAt && manualForm.standardNode && !isManualActualNodeAllowed(manualForm.standardNode)) manualForm.standardNode = ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="carrier-query" aria-label="货柜履约轨迹">
|
||||
<header><div><h2>货柜履约轨迹</h2><p>统一展示仓内操作、确认离仓与船运节点;预计节点不会推进实际进度。</p></div><div class="header-actions"><span class="history-count">仓内 {{ filteredWarehouseTimeline.length }} 条 · 共 {{ fulfillmentEvents.length }} 条</span><el-button v-if="(query || props.containerBookingId) && access.can('platform:firstleg:port-schedule-query:manual')" size="small" type="primary" plain :loading="manualPreparing" @click="openManualDialog"><Plus v-if="!manualPreparing" :size="15" />人工补录节点</el-button><el-tag v-if="query" :type="statusType" effect="light">{{ statusText }}</el-tag></div></header>
|
||||
<header><div><h2>货柜履约轨迹</h2><p>统一展示仓内操作、确认离仓与船运节点;预计节点不会推进实际进度。</p></div><div class="header-actions"><span class="history-count">仓内 {{ filteredWarehouseTimeline.length }} 条 · 共 {{ fulfillmentEvents.length }} 条</span><el-button v-if="props.containerBookingId && access.can('platform:firstleg:port-schedule-query:manual')" size="small" type="primary" plain @click="openManualDialog"><Plus :size="15" />人工补录节点</el-button><el-tag v-if="query" :type="statusType" effect="light">{{ statusText }}</el-tag></div></header>
|
||||
<LoadErrorState v-if="error" compact title="船节点查询状态加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-else-if="loading" class="loading-state"><el-skeleton :rows="1" animated /></div>
|
||||
<template v-else>
|
||||
<el-alert v-if="query?.lastErrorMessage || query?.status === 'MANUAL_REQUIRED'" type="error" :closable="false" show-icon :title="providerErrorText(query?.lastErrorMessage || query?.manualReason)" />
|
||||
<el-alert v-if="manualEntryError" type="error" :closable="false" show-icon title="无法进入人工补录"><template #default><ApiErrorMeta :details="manualEntryError" /></template></el-alert>
|
||||
<el-alert v-if="query?.status === 'STOPPED'" type="info" :closable="false" show-icon :title="query.stopReason || '船节点查询已停止。'" />
|
||||
<el-alert v-if="queryDetailError" type="warning" :closable="false" show-icon title="标准节点与变更历史暂不可用"><template #default><ApiErrorMeta :details="queryDetailError" /></template></el-alert>
|
||||
<el-alert v-if="scheduleError" type="warning" :closable="false" show-icon title="关联船期结果暂不可用"><template #default><ApiErrorMeta :details="scheduleError" /></template></el-alert>
|
||||
<div v-if="latestSchedule" class="latest-result"><strong>{{ latestSchedule.schedule.vesselName }} / {{ latestSchedule.schedule.voyageNo }}</strong><span>{{ latestSchedule.schedule.departurePortName || latestSchedule.schedule.departurePortCode }} → {{ latestSchedule.schedule.destinationPortName || latestSchedule.schedule.destinationPortCode }}</span><span>预计开船 {{ formatTime(latestSchedule.schedule.etdAt) }} · 预计到港 {{ formatTime(latestSchedule.schedule.etaAt) }}</span></div>
|
||||
<ol v-if="fulfillmentEvents.length" class="fulfillment-timeline"><li v-for="item in fulfillmentEvents" :key="item.key"><time>{{ formatTime(item.time) }}</time><el-tag size="small" :type="eventTagType(item.type)">{{ item.type }}</el-tag><strong>{{ item.title }}</strong><span>{{ item.summary }}</span><small>{{ item.source }}</small></li></ol>
|
||||
<p v-else class="empty-line">暂无货柜履约记录</p>
|
||||
<p v-if="!query" class="tracking-prompt">确认离仓后自动开始船节点跟踪</p>
|
||||
<p v-if="!query" class="tracking-prompt">当前未开启跟踪任务;可独立使用人工补录修改货柜当前节点。</p>
|
||||
<details v-if="supersededActualNodes.length" class="technical-details duplicate-node-history"><summary>历史重复实际节点({{ supersededActualNodes.length }})</summary><ol class="technical-history"><li v-for="item in supersededActualNodes" :key="item.id"><span>{{ nodeTimeText(item) }}</span><strong>{{ nodeText(item.standardNode) }}</strong><small>{{ nodeLocation(item) }} · {{ item.providerCode }}<template v-if="item.sourceReference"> · {{ item.sourceReference }}</template></small></li></ol></details>
|
||||
<details v-if="query" class="technical-details"><summary>查询任务摘要、异常与变更历史</summary><dl class="query-facts"><div><dt>查询任务</dt><dd>{{ query.queryNo || query.id }}</dd></div><div><dt>跟踪标识</dt><dd>{{ trackingTypeText(query.trackingType) }} · {{ query.trackingNo || '—' }}</dd></div><div><dt>任务状态</dt><dd>{{ statusText }}</dd></div><div><dt>建立来源</dt><dd>{{ triggerSourceText(query.triggerSource) }}</dd></div><div><dt>最新实际节点</dt><dd>{{ nodeText(query.latestActualNode) }}</dd></div><div><dt>实际发生时间</dt><dd>{{ formatTime(query.latestActualAt) }}</dd></div><div><dt>最新来源</dt><dd>{{ latestSource }}</dd></div><div><dt>来源参考</dt><dd class="break-value">{{ sourceReference }}</dd></div><div><dt>观测时间</dt><dd>{{ formatTime(latestObservation) }}</dd></div><div><dt>最近成功</dt><dd>{{ formatTime(query.lastSuccessAt) }}</dd></div><div><dt>结果有效性</dt><dd>{{ queryView?.expired ? '结果已过期' : '结果有效' }}</dd></div><div v-if="query.lastErrorCode"><dt>最近错误码</dt><dd>{{ query.lastErrorCode }}</dd></div></dl><h3>任务变更历史</h3><p v-if="!histories.length" class="empty-line">暂无变更历史</p><ol v-else class="technical-history"><li v-for="item in histories" :key="item.id"><span>{{ formatTime(item.createdAt) }}</span><strong>{{ historyText(item.action) }}</strong><small>{{ item.operatorName || '系统' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></details>
|
||||
</template>
|
||||
@@ -294,7 +273,7 @@ watch(() => manualForm.actualAt, (actualAt) => {
|
||||
<el-alert v-if="manualValidationError" type="warning" :closable="false" show-icon :title="manualValidationError" />
|
||||
<el-alert type="info" :closable="false" title="观测时间按提交时刻记录;预计事实不推进实际进度,既有事实不可编辑或删除。" />
|
||||
<el-form label-position="top" class="manual-form">
|
||||
<el-form-item label="标准节点" required><el-select v-model="manualForm.standardNode" placeholder="请选择标准节点" style="width:100%"><el-option v-for="node in standardNodes" :key="node" :label="nodeText(node)" :value="node" :disabled="Boolean(manualForm.actualAt) && !isManualActualNodeAllowed(node)" /></el-select></el-form-item>
|
||||
<el-form-item label="标准节点" required><el-select v-model="manualForm.standardNode" placeholder="请选择标准节点" style="width:100%"><el-option v-for="node in standardNodes" :key="node" :label="nodeText(node)" :value="node" /></el-select></el-form-item>
|
||||
<div class="manual-form-grid"><el-form-item label="预计时间"><el-date-picker v-model="manualForm.estimatedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="可选" style="width:100%" /></el-form-item><el-form-item label="实际时间"><el-date-picker v-model="manualForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="可选,填写后推进实际节点" style="width:100%" /></el-form-item></div>
|
||||
<div class="manual-form-grid"><el-form-item label="地点编码"><el-input v-model="manualForm.locationCode" maxlength="32" placeholder="可选,如 USLAX" /></el-form-item><el-form-item label="地点名称"><el-input v-model="manualForm.locationName" maxlength="128" placeholder="可选" /></el-form-item></div>
|
||||
<el-form-item label="补录原因" required><el-input v-model="manualForm.reason" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入事实来源与补录原因" /></el-form-item>
|
||||
|
||||
Reference in New Issue
Block a user