Files

94 lines
4.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)}