zhongjr il y a 1 semaine
Parent
commit
daa67d4ac7

+ 1 - 1
README.md

@@ -31,7 +31,7 @@ Gateway 保持“薄网关”边界:
 |---|---|---|---|
 | `query_order` | 普通订单列表查询 | `/mcp/tools/queryOrder` | 旧协议兼容 |
 | `query_track` | 按订单号/排舱单号或跟踪号/柜号/快递单号查询轨迹 | `/mcp/tools/queryTrack` | 安全表格 |
-| `query_freightower_track` | 按本公司排舱单的提单号或柜号查询飞驼现场船期和已发生节点 | `/mcp/tools/queryFreightowerTrack` | 船期、港口、轨迹 |
+| `query_freightower_track` | 按本公司排舱单的提单号或柜号查询飞驼现场船期、港口、箱子、轨迹和港区计划 | `/mcp/tools/queryFreightowerTrack` | 船期、港口、箱子、轨迹、港区计划 |
 | `query_order_exact` | 按明确号码类型精准查询订单 | `/mcp/tools/queryOrderExact` | 安全表格 |
 | `query_order_detail` | 按明确订单号查询订单详情,支持“全部”聚合 | `/mcp/tools/queryOrderDetail` | 安全中文详情 |
 | `query_customer_list` | 查询当前员工有权查看的启用主客户 | `/mcp/tools/queryCustomerList` | 固定 17 列安全表格 |

+ 116 - 82
services/output_presenter.py

@@ -2446,98 +2446,132 @@ class OutputPresenter:
             )
         return '\n'.join(lines)
 
-        return self._success_result(content, '\n'.join(lines), meta)
-
     def _present_freightower_track(self, data, meta):
-        if set(data) != {'schedule', 'ports', 'tracks'}:
+        expected = {'schedule', 'ports', 'containers', 'tracks', 'terminal_plan'}
+        if set(data) != expected:
             return self._format_error(meta)
-        schedule = data.get('schedule')
-        ports = data.get('ports')
-        tracks = data.get('tracks')
-        schedule_keys = (
-            'bl_number', 'container_code', 'vessel', 'voyage', 'etd', 'eta',
-        )
-        if (
-            not isinstance(schedule, dict)
-            or set(schedule) != set(schedule_keys)
-            or not isinstance(ports, list)
-            or not isinstance(tracks, list)
-        ):
+        schedule = self._freightower_object(data.get('schedule'), (
+            ('bl_number', '提单号'),
+            ('container_code', '柜号'),
+            ('carrier_name', '船公司'),
+            ('vessel', '船名'),
+            ('voyage', '航次'),
+            ('route_code', '航线'),
+            ('current_status', '当前状态'),
+            ('current_status_time', '当前状态时间'),
+            ('current_status_place', '当前状态地点'),
+            ('update_time', '数据更新时间'),
+            ('etd', '预计离港'),
+            ('atd', '实际离港'),
+            ('eta', '预计到达'),
+        ))
+        if schedule is None:
+            return self._format_error(meta)
+        ports = self._freightower_rows(data.get('ports'), (
+            ('place_type', '地点类型'),
+            ('port_name', '港口'),
+            ('terminal_name', '码头'),
+            ('etd', '预计离港'),
+            ('atd', '实际离港'),
+            ('eta', '预计到达'),
+            ('ata', '实际到达'),
+        ), {'place_type': ('起运港', '中转港', '目的港', '交货地')})
+        containers = self._freightower_rows(data.get('containers'), (
+            ('container_no', '箱号'),
+            ('container_type', '箱型'),
+            ('seal_no', '铅封号'),
+            ('current_status', '当前状态'),
+            ('off_load', '是否甩柜'),
+        ), {'off_load': ('', '是', '否')})
+        tracks = self._freightower_rows(data.get('tracks'), (
+            ('time', '时间'),
+            ('location', '地点'),
+            ('content', '内容'),
+            ('event_code', '状态代码'),
+            ('estimate_flag', '实际/预计'),
+            ('vessel_voyage', '船名航次'),
+            ('terminal_name', '码头'),
+        ), {'estimate_flag': ('实际', '预计')})
+        terminal_plan = self._freightower_object(data.get('terminal_plan'), (
+            ('port_name', '港口'),
+            ('terminal_name', '码头'),
+            ('open_time', '开港'),
+            ('close_time', '截港'),
+            ('etd', '预计离港'),
+            ('atd', '实际离港'),
+            ('shipping_agent', '船代'),
+        ))
+        if None in (ports, containers, tracks, terminal_plan):
             return self._format_error(meta)
-        labels = {
-            'bl_number': '提单号',
-            'container_code': '柜号',
-            'vessel': '船名',
-            'voyage': '航次',
-            'etd': '预计离港',
-            'eta': '预计到达',
-        }
-        safe_schedule = {}
-        for key in schedule_keys:
-            value = schedule.get(key)
-            if not isinstance(value, str):
-                return self._format_error(meta)
-            safe_schedule[labels[key]] = value
-        safe_ports = []
-        for port in ports:
-            if not isinstance(port, dict) or set(port) != {
-                'place_type', 'port_name', 'eta', 'ata',
-            }:
-                return self._format_error(meta)
-            if port['place_type'] not in ('中转港', '目的港', '交货地'):
-                return self._format_error(meta)
-            for key in ('port_name', 'eta', 'ata'):
-                if not isinstance(port[key], str):
-                    return self._format_error(meta)
-            safe_ports.append({
-                '地点类型': port['place_type'],
-                '港口': port['port_name'],
-                '预计到达': port['eta'],
-                '实际到达': port['ata'],
-            })
-        safe_tracks = []
-        for track in tracks:
-            if not isinstance(track, dict) or set(track) != {
-                'time', 'location', 'content', 'event_code',
-            }:
-                return self._format_error(meta)
-            for key in ('time', 'location', 'content', 'event_code'):
-                if not isinstance(track[key], str):
-                    return self._format_error(meta)
-            safe_tracks.append({
-                '时间': track['time'],
-                '地点': track['location'],
-                '内容': track['content'],
-                '状态代码': track['event_code'],
-            })
         content = {
-            '船期': safe_schedule,
-            '港口': safe_ports,
-            '轨迹': safe_tracks,
+            '船期': schedule,
+            '港口': ports,
+            '箱子': containers,
+            '轨迹': tracks,
+            '港区计划': terminal_plan,
         }
         lines = ['飞驼船期']
-        for label, value in safe_schedule.items():
+        for label, value in schedule.items():
             lines.append('- {0}: {1}'.format(label, value))
-        lines.append('港口')
-        if not safe_ports:
-            lines.append('- 无')
-        for port in safe_ports:
-            lines.append(
-                '- {0} {1} 预计到达 {2} 实际到达 {3}'.format(
-                    port['地点类型'], port['港口'], port['预计到达'], port['实际到达'],
-                )
-            )
-        lines.append('轨迹')
-        if not safe_tracks:
+        lines.extend(self._freightower_section('港口', ports, (
+            '地点类型', '港口', '码头', '预计离港', '实际离港', '预计到达', '实际到达',
+        )))
+        lines.extend(self._freightower_section('箱子', containers, (
+            '箱号', '箱型', '铅封号', '当前状态', '是否甩柜',
+        )))
+        lines.extend(self._freightower_section('轨迹', tracks, (
+            '时间', '地点', '内容', '状态代码', '实际/预计', '船名航次', '码头',
+        )))
+        if any(terminal_plan.values()):
+            lines.append('港区计划')
+            for label, value in terminal_plan.items():
+                lines.append('- {0}: {1}'.format(label, value))
+        else:
+            lines.append('港区计划')
             lines.append('- 无')
-        for track in safe_tracks:
-            lines.append(
-                '- {0} {1} {2} {3}'.format(
-                    track['时间'], track['地点'], track['内容'], track['状态代码'],
-                )
-            )
         return self._success_result(content, '\n'.join(lines), meta)
 
+    def _freightower_object(self, value, fields):
+        if not isinstance(value, dict) or set(value) != {key for key, _label in fields}:
+            return None
+        result = {}
+        for key, label in fields:
+            item = value.get(key)
+            if not isinstance(item, str):
+                return None
+            result[label] = item
+        return result
+
+    def _freightower_rows(self, rows, fields, choices):
+        if not isinstance(rows, list):
+            return None
+        keys = {key for key, _label in fields}
+        result = []
+        for row in rows:
+            if not isinstance(row, dict) or set(row) != keys:
+                return None
+            item = {}
+            for key, label in fields:
+                cell = row.get(key)
+                if not isinstance(cell, str):
+                    return None
+                allowed = choices.get(key)
+                if allowed is not None and cell not in allowed:
+                    return None
+                item[label] = cell
+            result.append(item)
+        return result
+
+    @staticmethod
+    def _freightower_section(title, rows, labels):
+        lines = [title]
+        if not rows:
+            lines.append('- 无')
+            return lines
+        for row in rows:
+            lines.append('- ' + ' '.join(row[label] for label in labels))
+        return lines
+
     @staticmethod
     def _success_result(content, text, meta):
         return {

+ 36 - 1
tests/test_query_freightower_track_tool.py

@@ -22,23 +22,52 @@ def sample_data():
         'schedule': {
             'bl_number': 'BL1',
             'container_code': 'BOX1',
+            'carrier_name': '马士基',
             'vessel': 'VESSEL',
             'voyage': '001E',
+            'route_code': 'CVT',
+            'current_status': '抵港',
+            'current_status_time': '2026-09-20 01:00:00',
+            'current_status_place': '洛杉矶',
+            'update_time': '2026-09-20 02:00:00',
             'etd': '2026-09-01 00:00:00',
+            'atd': '2026-09-02 00:00:00',
             'eta': '2026-09-20 00:00:00',
         },
         'ports': [{
             'place_type': '目的港',
             'port_name': '洛杉矶',
+            'terminal_name': 'TCCL',
+            'etd': '',
+            'atd': '',
             'eta': '2026-09-20',
             'ata': '',
         }],
+        'containers': [{
+            'container_no': 'BOX1',
+            'container_type': '40HC',
+            'seal_no': 'SEAL1',
+            'current_status': '抵港',
+            'off_load': '否',
+        }],
         'tracks': [{
             'time': '2026-09-20 01:00:00',
             'location': 'LOS ANGELES',
             'content': '抵港',
             'event_code': 'BDAR',
+            'estimate_flag': '实际',
+            'vessel_voyage': 'VESSEL 001E',
+            'terminal_name': 'TCCL',
         }],
+        'terminal_plan': {
+            'port_name': '上海',
+            'terminal_name': '外高桥',
+            'open_time': '2026-08-28 00:00:00',
+            'close_time': '2026-09-01 03:00:00',
+            'etd': '2026-09-01 00:00:00',
+            'atd': '',
+            'shipping_agent': '中外运',
+        },
     }
 
 
@@ -133,6 +162,8 @@ class QueryFreightowerTrackToolTest(unittest.TestCase):
         self.assertFalse(result['is_error'])
         self.assertEqual('洛杉矶', result['structured_content']['港口'][0]['港口'])
         self.assertEqual('BDAR', result['structured_content']['轨迹'][0]['状态代码'])
+        self.assertEqual('马士基', result['structured_content']['船期']['船公司'])
+        self.assertEqual('中外运', result['structured_content']['港区计划']['船代'])
         self.assertIn('抵港', result['text'])
         self.assertNotIn('bl_number', result['text'])
         self.assertEqual({'request_id': 'rq_ft'}, result['meta'])
@@ -140,14 +171,18 @@ class QueryFreightowerTrackToolTest(unittest.TestCase):
     def test_presenter_accepts_empty_ports_and_tracks(self):
         data = sample_data()
         data['ports'] = []
+        data['containers'] = []
         data['tracks'] = []
+        data['terminal_plan'] = {key: '' for key in data['terminal_plan']}
         result = OutputPresenter().present('query_freightower_track', {
             'code': 'MCP_0000',
             'data': data,
         })
         self.assertFalse(result['is_error'])
         self.assertIn('港口\n- 无', result['text'])
+        self.assertIn('箱子\n- 无', result['text'])
         self.assertIn('轨迹\n- 无', result['text'])
+        self.assertIn('港区计划\n- 无', result['text'])
 
     def test_presenter_rejects_unknown_shape(self):
         cases = []
@@ -155,7 +190,7 @@ class QueryFreightowerTrackToolTest(unittest.TestCase):
         extra['schedule']['secret'] = 'x'
         cases.append(extra)
         bad_type = sample_data()
-        bad_type['ports'][0]['place_type'] = '起运港'
+        bad_type['ports'][0]['place_type'] = '接货地'
         cases.append(bad_type)
         bad_value = sample_data()
         bad_value['tracks'][0]['time'] = 1

+ 3 - 2
tools/query_freightower_track.py

@@ -15,8 +15,9 @@ class QueryFreightowerTrackTool:
             'name': self.name,
             'description': (
                 '使用场景:用户明确要查看飞驼现场船期和集装箱节点时使用,'
-                '返回船名、航次、预计离港、预计到达、中转港/目的港/交货地,'
-                '以及已经发生的轨迹节点。提单号、柜号至少提供一个;'
+                '返回船公司、船名航次、航线、当前状态、预计和实际离到港、'
+                '起运港到交货地、箱型铅封、实际和预计轨迹,以及港区开截港。'
+                '提单号、柜号至少提供一个;'
                 '号码类型不明确时必须先询问用户是提单号还是柜号,确认前不得调用。'
                 '不得根据号码格式猜测,不得跨字段或跨工具试查。'
                 '禁止使用:系统里已经保存的轨迹用 query_track;'