@@ -52,6 +52,30 @@ 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 ) {
const compared = String ( left [ field ] || '' ) . localeCompare ( String ( right [ field ] || '' ) )
if ( compared ) return compared
}
return left . id . localeCompare ( right . id )
}
const latestActualFactsByNode = computed ( ( ) => {
const result = new Map < CarrierScheduleStandardNode , CarrierScheduleNodeFact > ( )
for ( const item of nodes . value ) {
if ( ! item . actualAt ) continue
const current = result . get ( item . standardNode )
if ( ! current || compareActualFacts ( item , current ) > 0 ) result . set ( item . standardNode , item )
}
return result
} )
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 }
@@ -75,7 +99,7 @@ const fulfillmentEvents = computed<FulfillmentEvent[]>(() => {
values . push ( { key : ` warehouse- ${ item . eventType } - ${ item . occurredAt } - ${ index } ` , time : item . occurredAt , type : departure ? '确认离仓' : '仓内操作' , title : warehouseEventText ( item . eventType ) , summary : warehouseEventSummary ( item ) , source : item . operatorName || '系统' , actual : true } )
}
const nodeKeys = new Set < string > ( )
for ( const item of nodes . value ) {
for ( const item of primaryNodeFacts . value ) {
const actual = Boolean ( item . actualAt )
const time = item . actualAt || item . estimatedAt || item . observedAt
const key = JSON . stringify ( [ item . standardNode , item . actualAt || null , item . estimatedAt || null , item . locationCode || null , item . locationName || null , item . providerCode , item . sourceReference || null ] )
@@ -111,6 +135,7 @@ async function submitManualNode() {
manualValidationError . value = ''
if ( ! query . value || ! 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
@@ -147,6 +172,9 @@ 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 >
@@ -163,14 +191,15 @@ watch(manualDialogOpen, (open) => { if (!open && !manualSaving.value) { manualId
< 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 >
< details v-else 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 histori es" :key="item.id"><span>{{ formatTime(item.createdAt ) }}</span><strong>{{ history Text(item.action ) }}</strong><small>{{ item.operatorName || '系统' }}<template v-if="item.reason "> · {{ item.reason }} < / template > < / small > < / li > < / ol > < / details >
< details v-if = "supersededActualNodes.length" class="technical-details duplicate-node-history"><summary>历史重复实际节点({{ supersededActualNodes.length }}) </summary><ol class="technical-history"><li v-for="item in supersededActualNod es" :key="item.id"><span>{{ nodeTimeText(item ) }}</span><strong>{{ node Text(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 >
< AppDialog v-model = "manualDialogOpen" title="人工补录船节点" width="min(620px, calc(100vw - 28px))" confirm-text="确认补录" :confirm-loading="manualSaving" @confirm="submitManualNode" >
< el -alert v-if = "manualError" type="error" :closable="false" show-icon title="补录未成功,已保留当前填写内容"><template #default><ApiErrorMeta :details="manualError" / > < / template > < / el-alert >
< 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" / > < / 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" :disabled="Boolean(manualForm.actualAt) && !isManualActualNodeAllowed(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 >