"""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)}