94 lines
4.5 KiB
Python
94 lines
4.5 KiB
Python
"""Customer tracking derived from explicit order-linked facts, without inference."""
|
||
|
||
import json
|
||
from datetime import datetime, timezone
|
||
|
||
|
||
# Maritime codes follow the firstleg standard; warehouse facts remain distinct.
|
||
NODE_LABELS = {
|
||
'DOMESTIC_RECEIVED': (0, '已入国内仓', '预计入国内仓'),
|
||
'LOADING_COMPLETED': (5, '已完成装柜', '预计完成装柜'),
|
||
'DEPARTED_WAREHOUSE': (10, '已离开国内仓库', '预计离开国内仓库'),
|
||
'AT_ORIGIN_PORT': (20, '重箱已进入起运港或码头', '预计进入起运港或码头'),
|
||
'LOADED_ON_VESSEL': (30, '已装船', '预计装船'),
|
||
'DEPARTED_ORIGIN_PORT': (40, '船舶已离开起运港', '预计离开起运港'),
|
||
'IN_TRANSIT': (50, '海运途中', '预计进入海运阶段'),
|
||
'ARRIVED_DESTINATION_PORT': (60, '船舶已到目的港', '预计到达目的港'),
|
||
'DISCHARGED_FROM_VESSEL': (70, '已卸船、未清关', '预计卸船'),
|
||
'PICKUP_READY': (80, '已清关、可提柜', '预计可提柜'),
|
||
'PICKED_UP': (90, '已提柜', '预计提柜'),
|
||
'EMPTY_RETURNED': (100, '已还柜', '预计还柜'),
|
||
}
|
||
|
||
|
||
def parse_time(value):
|
||
if not isinstance(value, str) or not value.strip():
|
||
return None
|
||
try:
|
||
result = datetime.fromisoformat(value.strip().replace('Z', '+00:00'))
|
||
if result.tzinfo:
|
||
result = result.astimezone(timezone.utc).replace(tzinfo=None)
|
||
return result
|
||
except (ValueError, OverflowError):
|
||
return None
|
||
|
||
|
||
def build_tracking(raw):
|
||
"""Latest observation corrects a milestone; forecasts never advance progress."""
|
||
try:
|
||
facts = json.loads(raw) if isinstance(raw, str) else raw
|
||
except (ValueError, TypeError):
|
||
return None
|
||
if not isinstance(facts, list):
|
||
return None
|
||
actual, estimated = {}, {}
|
||
for index, fact in enumerate(facts):
|
||
if not isinstance(fact, dict) or not isinstance(fact.get('code'), str) or fact['code'] not in NODE_LABELS:
|
||
continue
|
||
shipment = str(fact.get('shipment_key') or '')
|
||
key = (shipment, fact['code'])
|
||
observation = parse_time(fact.get('observed_at')) or parse_time(fact.get('created_at')) or datetime.min
|
||
for field, target in (('actual_at', actual), ('estimated_at', estimated)):
|
||
when = parse_time(fact.get(field))
|
||
if when is None:
|
||
continue
|
||
candidate = (observation, index, when, fact)
|
||
if key not in target or candidate[:2] > target[key][:2]:
|
||
target[key] = candidate
|
||
if not actual and not estimated:
|
||
return None
|
||
|
||
shipments = sorted({key[0] for key in actual.keys() | estimated.keys()} - {''})
|
||
multiple = len(shipments) > 1
|
||
progress = {}
|
||
for (shipment, code), (_, _, when, fact) in actual.items():
|
||
sequence = NODE_LABELS[code][0]
|
||
if shipment not in progress or sequence > progress[shipment][0]:
|
||
progress[shipment] = (sequence, code, fact)
|
||
|
||
def label_shipment(shipment, fact):
|
||
if not multiple or not shipment:
|
||
return ''
|
||
return '货柜 ' + str(fact.get('shipment_no') or (shipments.index(shipment) + 1))
|
||
|
||
def node(shipment, code, value, forecast=False):
|
||
_, _, when, fact = value
|
||
place = str(fact.get('place') or '')
|
||
scope = label_shipment(shipment, fact)
|
||
return {'time': str(fact['estimated_at' if forecast else 'actual_at']),
|
||
'text': NODE_LABELS[code][2 if forecast else 1],
|
||
'place': ' · '.join(part for part in (scope, place) if part)}
|
||
|
||
nodes = [node(shipment, code, value) for (shipment, code), value in
|
||
sorted(actual.items(), key=lambda item: (item[1][2], NODE_LABELS[item[0][1]][0]), reverse=True)]
|
||
forecasts = [node(shipment, code, value, True) for (shipment, code), value in
|
||
sorted(estimated.items(), key=lambda item: item[1][2])
|
||
if (shipment, code) not in actual and NODE_LABELS[code][0] > progress.get(shipment, (-1,))[0]]
|
||
# Multiple containers keep independent progress rather than claiming the whole order advanced.
|
||
current = [(shipment, value) for shipment, value in sorted(progress.items()) if shipment or not shipments]
|
||
if not current and '' in progress:
|
||
current = [('', progress[''])]
|
||
statuses = [':'.join(part for part in (label_shipment(shipment, value[2]), NODE_LABELS[value[1]][1]) if part)
|
||
for shipment, value in current]
|
||
return {'nodes': nodes[:50], 'estimated_nodes': forecasts[:20], 'status': ';'.join(statuses)}
|