From 0e7df9052a14242bd3e5cb50842795814cbae047 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 21 Aug 2026 16:04:16 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/nacos/cn/firstleg-service.yml | 8 + config/nacos/cn/integration-service.yml | 36 +++ config/nacos/local/firstleg-service.yml | 6 + config/nacos/local/integration-service.yml | 36 +++ deploy/docker-compose.yml | 36 +++ deploy/sql/头程数据库统一增量-MySQL8.sql | 90 +++++-- firstleg/AGENTS.md | 4 +- .../CarrierScheduleIntegrationClient.java | 4 + ...nternalCarrierScheduleQueryController.java | 48 ---- ...latformCarrierScheduleQueryController.java | 13 ++ .../domain/CarrierScheduleExceptionCode.java | 20 ++ .../domain/CarrierScheduleNode.java | 12 +- .../dto/CarrierScheduleQueryDtos.java | 39 +++- .../CarrierScheduleExceptionFactEntity.java | 31 +++ .../entity/CarrierScheduleNodeFactEntity.java | 1 + .../CarrierScheduleExceptionFactMapper.java | 23 ++ .../mapper/CarrierScheduleNodeFactMapper.java | 4 +- .../mapper/CarrierScheduleQueryMapper.java | 4 +- .../CarrierScheduleQueryRepository.java | 12 + .../CarrierScheduleAdapterRegistry.java | 52 +++-- .../CarrierScheduleQueryScheduler.java | 9 + .../service/CarrierScheduleQueryService.java | 137 ++++++++--- firstleg/src/main/resources/application.yml | 8 + .../phase3-carrier-schedule-query-schema.sql | 62 +++-- .../openapi/carrier-schedule-internal-v1.yaml | 23 -- .../openapi/carrier-schedule-query-v1.yaml | 140 ++++++++++- ...rrierScheduleQueryOpenApiContractTest.java | 13 +- ...erScheduleQueryControllerContractTest.java | 5 +- .../dto/CarrierScheduleQueryDtosTest.java | 14 ++ .../CarrierScheduleQuerySqlContractTest.java | 21 +- .../CarrierScheduleAdapterRegistryTest.java | 14 +- .../CarrierScheduleQuerySchedulerTest.java | 8 +- .../CarrierScheduleQueryServiceTest.java | 174 +++++++++++++- .../Phase3FinalUnifiedSqlContractTest.java | 2 +- integration-service/AGENTS.md | 2 +- .../config/IntegrationProperties.java | 133 ++++++++++- .../domain/CarrierScheduleExceptionCode.java | 15 ++ .../domain/CarrierScheduleStandardNode.java | 8 +- .../integration/dto/CarrierScheduleDtos.java | 6 + ...mpliantCarrierScheduleProviderAdapter.java | 7 +- ...ergreenCarrierScheduleProviderAdapter.java | 202 ++++++++++++++++ .../provider/EvergreenTrackingClient.java | 151 ++++++++++++ .../HedeCarrierScheduleProviderAdapter.java | 161 +++++++++++++ .../provider/HedeTrackingClient.java | 123 ++++++++++ .../MatsonCarrierScheduleProviderAdapter.java | 220 ++++++++++++++++++ .../provider/MatsonTrackingClient.java | 172 ++++++++++++++ .../SmlCarrierScheduleProviderAdapter.java | 154 ++++++++++++ .../provider/SmlTrackingClient.java | 165 +++++++++++++ .../ZimCarrierScheduleProviderAdapter.java | 199 ++++++++++++++++ .../provider/ZimTrackingClient.java | 207 ++++++++++++++++ .../CarrierScheduleProviderService.java | 12 +- .../src/main/resources/application.yml | 36 +++ .../openapi/carrier-schedule-provider-v1.yaml | 20 +- ...eenCarrierScheduleProviderAdapterTest.java | 74 ++++++ .../provider/EvergreenTrackingClientTest.java | 91 ++++++++ ...edeCarrierScheduleProviderAdapterTest.java | 95 ++++++++ .../provider/HedeTrackingClientTest.java | 59 +++++ ...sonCarrierScheduleProviderAdapterTest.java | 109 +++++++++ .../provider/MatsonTrackingClientTest.java | 73 ++++++ ...SmlCarrierScheduleProviderAdapterTest.java | 71 ++++++ .../provider/SmlTrackingClientTest.java | 147 ++++++++++++ ...ZimCarrierScheduleProviderAdapterTest.java | 106 +++++++++ .../provider/ZimTrackingClientTest.java | 152 ++++++++++++ .../CarrierScheduleProviderServiceTest.java | 20 +- .../ocr/WeightOcrBatchServiceTest.java | 2 +- .../phase3-carrier-schedule-queries.mjs | 54 ++--- .../phase4-carrier-nodes-contract.test.mjs | 47 ++-- .../phase4-carrier-nodes.mjs | 50 +++- 68 files changed, 3985 insertions(+), 267 deletions(-) delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/InternalCarrierScheduleQueryController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/domain/CarrierScheduleExceptionCode.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleExceptionFactEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleExceptionFactMapper.java delete mode 100644 firstleg/src/main/resources/openapi/carrier-schedule-internal-v1.yaml create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtosTest.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleExceptionCode.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapter.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/EvergreenTrackingClient.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/HedeCarrierScheduleProviderAdapter.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/HedeTrackingClient.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/MatsonCarrierScheduleProviderAdapter.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/MatsonTrackingClient.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/SmlCarrierScheduleProviderAdapter.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/SmlTrackingClient.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/ZimCarrierScheduleProviderAdapter.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/ZimTrackingClient.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapterTest.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/provider/EvergreenTrackingClientTest.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/provider/HedeCarrierScheduleProviderAdapterTest.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/provider/HedeTrackingClientTest.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/provider/MatsonCarrierScheduleProviderAdapterTest.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/provider/MatsonTrackingClientTest.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/provider/SmlCarrierScheduleProviderAdapterTest.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/provider/SmlTrackingClientTest.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/provider/ZimCarrierScheduleProviderAdapterTest.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/provider/ZimTrackingClientTest.java diff --git a/config/nacos/cn/firstleg-service.yml b/config/nacos/cn/firstleg-service.yml index c620909f7..b94804393 100644 --- a/config/nacos/cn/firstleg-service.yml +++ b/config/nacos/cn/firstleg-service.yml @@ -8,6 +8,14 @@ baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:} baiyue.clients.order.url: ${ORDER_SERVICE_URL:} baiyue.clients.file.url: ${FILE_SERVICE_URL:} baiyue: + carrier-schedule: + providers: + matson-automated: ${MATSON_TRACKING_ENABLED:false} + sml-automated: ${SML_TRACKING_ENABLED:false} + zim-official-api-enabled: ${ZIM_TRACKING_ENABLED:false} + cosco-official-api-enabled: ${COSCO_OPEN_API_ENABLED:false} + emc-automated: ${EMC_TRACKING_ENABLED:false} + hede-automated: ${HEDE_TRACKING_ENABLED:false} lastleg: event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL} outbox-relay: diff --git a/config/nacos/cn/integration-service.yml b/config/nacos/cn/integration-service.yml index 5c3f3ed6c..d4dd01f9c 100644 --- a/config/nacos/cn/integration-service.yml +++ b/config/nacos/cn/integration-service.yml @@ -21,3 +21,39 @@ baiyue: max-qps-retries: ${BAIDU_OCR_MAX_QPS_RETRIES:3} amazon-sp-api: enabled: ${AMAZON_SP_API_ENABLED:false} + carrier-schedule: + matson: + enabled: ${MATSON_TRACKING_ENABLED:false} + page-url: ${MATSON_TRACKING_PAGE_URL:https://www.matson.com/shipment-tracking.html} + query-url: ${MATSON_TRACKING_QUERY_URL:https://www.matson.com/wp-content/plugins/matson-plugin/Api_calls/tracking_booking_proxy.php} + connect-timeout: ${MATSON_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${MATSON_TRACKING_REQUEST_TIMEOUT:30s} + min-request-interval: ${MATSON_TRACKING_MIN_REQUEST_INTERVAL:5s} + hede: + enabled: ${HEDE_TRACKING_ENABLED:false} + query-url: ${HEDE_TRACKING_QUERY_URL:http://elines.hedehk.com/getVBilldynamic} + connect-timeout: ${HEDE_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${HEDE_TRACKING_REQUEST_TIMEOUT:30s} + min-request-interval: ${HEDE_TRACKING_MIN_REQUEST_INTERVAL:5s} + sml: + enabled: ${SML_TRACKING_ENABLED:false} + query-url: ${SML_TRACKING_QUERY_URL:https://esvc.smlines.com/smline/CUP_HOM_3301GS.do} + connect-timeout: ${SML_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${SML_TRACKING_REQUEST_TIMEOUT:30s} + min-request-interval: ${SML_TRACKING_MIN_REQUEST_INTERVAL:5s} + zim: + enabled: ${ZIM_TRACKING_ENABLED:false} + authorization-url: ${ZIM_AUTHORIZATION_URL:https://apigw.zim.com/authorize/v1} + tracing-base-url: ${ZIM_TRACING_BASE_URL:https://apigw.zim.com/tracing/v2} + client-id: ${ZIM_CLIENT_ID:} + client-secret: ${ZIM_CLIENT_SECRET:} + subscription-key: ${ZIM_TRACKING_SUBSCRIPTION_KEY:} + connect-timeout: ${ZIM_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${ZIM_TRACKING_REQUEST_TIMEOUT:30s} + max-concurrency: ${ZIM_TRACKING_MAX_CONCURRENCY:10} + evergreen: + enabled: ${EMC_TRACKING_ENABLED:false} + query-url: ${EMC_TRACKING_QUERY_URL:https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do} + connect-timeout: ${EMC_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${EMC_TRACKING_REQUEST_TIMEOUT:30s} + min-request-interval: ${EMC_TRACKING_MIN_REQUEST_INTERVAL:5s} diff --git a/config/nacos/local/firstleg-service.yml b/config/nacos/local/firstleg-service.yml index 1676cb3a7..c94b2aa41 100644 --- a/config/nacos/local/firstleg-service.yml +++ b/config/nacos/local/firstleg-service.yml @@ -6,6 +6,12 @@ spring.datasource: baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:} baiyue.clients.order.url: ${ORDER_SERVICE_URL:} baiyue.clients.file.url: ${FILE_SERVICE_URL:} +baiyue.carrier-schedule.providers.matson-automated: ${MATSON_TRACKING_ENABLED:false} +baiyue.carrier-schedule.providers.sml-automated: ${SML_TRACKING_ENABLED:false} +baiyue.carrier-schedule.providers.zim-official-api-enabled: ${ZIM_TRACKING_ENABLED:false} +baiyue.carrier-schedule.providers.cosco-official-api-enabled: ${COSCO_OPEN_API_ENABLED:false} +baiyue.carrier-schedule.providers.emc-automated: ${EMC_TRACKING_ENABLED:false} +baiyue.carrier-schedule.providers.hede-automated: ${HEDE_TRACKING_ENABLED:false} firstleg: inbound: allow-rejected-measurement: false diff --git a/config/nacos/local/integration-service.yml b/config/nacos/local/integration-service.yml index 9ed48b24c..b02d9d323 100644 --- a/config/nacos/local/integration-service.yml +++ b/config/nacos/local/integration-service.yml @@ -15,3 +15,39 @@ baiyue: max-batch-size: ${WEIGHT_OCR_MAX_BATCH_SIZE:50} amazon-sp-api: enabled: ${AMAZON_SP_API_ENABLED:false} + carrier-schedule: + matson: + enabled: ${MATSON_TRACKING_ENABLED:false} + page-url: ${MATSON_TRACKING_PAGE_URL:https://www.matson.com/shipment-tracking.html} + query-url: ${MATSON_TRACKING_QUERY_URL:https://www.matson.com/wp-content/plugins/matson-plugin/Api_calls/tracking_booking_proxy.php} + connect-timeout: ${MATSON_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${MATSON_TRACKING_REQUEST_TIMEOUT:30s} + min-request-interval: ${MATSON_TRACKING_MIN_REQUEST_INTERVAL:5s} + hede: + enabled: ${HEDE_TRACKING_ENABLED:false} + query-url: ${HEDE_TRACKING_QUERY_URL:http://elines.hedehk.com/getVBilldynamic} + connect-timeout: ${HEDE_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${HEDE_TRACKING_REQUEST_TIMEOUT:30s} + min-request-interval: ${HEDE_TRACKING_MIN_REQUEST_INTERVAL:5s} + sml: + enabled: ${SML_TRACKING_ENABLED:false} + query-url: ${SML_TRACKING_QUERY_URL:https://esvc.smlines.com/smline/CUP_HOM_3301GS.do} + connect-timeout: ${SML_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${SML_TRACKING_REQUEST_TIMEOUT:30s} + min-request-interval: ${SML_TRACKING_MIN_REQUEST_INTERVAL:5s} + zim: + enabled: ${ZIM_TRACKING_ENABLED:false} + authorization-url: ${ZIM_AUTHORIZATION_URL:https://apigw.zim.com/authorize/v1} + tracing-base-url: ${ZIM_TRACING_BASE_URL:https://apigw.zim.com/tracing/v2} + client-id: ${ZIM_CLIENT_ID:} + client-secret: ${ZIM_CLIENT_SECRET:} + subscription-key: ${ZIM_TRACKING_SUBSCRIPTION_KEY:} + connect-timeout: ${ZIM_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${ZIM_TRACKING_REQUEST_TIMEOUT:30s} + max-concurrency: ${ZIM_TRACKING_MAX_CONCURRENCY:10} + evergreen: + enabled: ${EMC_TRACKING_ENABLED:false} + query-url: ${EMC_TRACKING_QUERY_URL:https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do} + connect-timeout: ${EMC_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${EMC_TRACKING_REQUEST_TIMEOUT:30s} + min-request-interval: ${EMC_TRACKING_MIN_REQUEST_INTERVAL:5s} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 3d613af29..93d9a5eb0 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -93,6 +93,12 @@ services: FIRSTLEG_DB_USERNAME: ${FIRSTLEG_DB_USERNAME} FIRSTLEG_DB_PASSWORD: ${FIRSTLEG_DB_PASSWORD} LASTLEG_EVENT_BRIDGE_URL: ${LASTLEG_EVENT_BRIDGE_URL} + MATSON_TRACKING_ENABLED: ${MATSON_TRACKING_ENABLED:-false} + SML_TRACKING_ENABLED: ${SML_TRACKING_ENABLED:-false} + ZIM_TRACKING_ENABLED: ${ZIM_TRACKING_ENABLED:-false} + COSCO_OPEN_API_ENABLED: ${COSCO_OPEN_API_ENABLED:-false} + EMC_TRACKING_ENABLED: ${EMC_TRACKING_ENABLED:-false} + HEDE_TRACKING_ENABLED: ${HEDE_TRACKING_ENABLED:-false} finance: <<: *common @@ -187,6 +193,36 @@ services: AMAZON_SP_API_AWS_ACCESS_KEY: ${AMAZON_SP_API_AWS_ACCESS_KEY:-} AMAZON_SP_API_AWS_SECRET_KEY: ${AMAZON_SP_API_AWS_SECRET_KEY:-} AMAZON_SP_API_REGION: ${AMAZON_SP_API_REGION:-} + MATSON_TRACKING_ENABLED: ${MATSON_TRACKING_ENABLED:-false} + MATSON_TRACKING_PAGE_URL: ${MATSON_TRACKING_PAGE_URL:-https://www.matson.com/shipment-tracking.html} + MATSON_TRACKING_QUERY_URL: ${MATSON_TRACKING_QUERY_URL:-https://www.matson.com/wp-content/plugins/matson-plugin/Api_calls/tracking_booking_proxy.php} + MATSON_TRACKING_CONNECT_TIMEOUT: ${MATSON_TRACKING_CONNECT_TIMEOUT:-10s} + MATSON_TRACKING_REQUEST_TIMEOUT: ${MATSON_TRACKING_REQUEST_TIMEOUT:-30s} + MATSON_TRACKING_MIN_REQUEST_INTERVAL: ${MATSON_TRACKING_MIN_REQUEST_INTERVAL:-5s} + SML_TRACKING_ENABLED: ${SML_TRACKING_ENABLED:-false} + SML_TRACKING_QUERY_URL: ${SML_TRACKING_QUERY_URL:-https://esvc.smlines.com/smline/CUP_HOM_3301GS.do} + SML_TRACKING_CONNECT_TIMEOUT: ${SML_TRACKING_CONNECT_TIMEOUT:-10s} + SML_TRACKING_REQUEST_TIMEOUT: ${SML_TRACKING_REQUEST_TIMEOUT:-30s} + SML_TRACKING_MIN_REQUEST_INTERVAL: ${SML_TRACKING_MIN_REQUEST_INTERVAL:-5s} + ZIM_TRACKING_ENABLED: ${ZIM_TRACKING_ENABLED:-false} + ZIM_AUTHORIZATION_URL: ${ZIM_AUTHORIZATION_URL:-https://apigw.zim.com/authorize/v1} + ZIM_TRACING_BASE_URL: ${ZIM_TRACING_BASE_URL:-https://apigw.zim.com/tracing/v2} + ZIM_CLIENT_ID: ${ZIM_CLIENT_ID:-} + ZIM_CLIENT_SECRET: ${ZIM_CLIENT_SECRET:-} + ZIM_TRACKING_SUBSCRIPTION_KEY: ${ZIM_TRACKING_SUBSCRIPTION_KEY:-} + ZIM_TRACKING_CONNECT_TIMEOUT: ${ZIM_TRACKING_CONNECT_TIMEOUT:-10s} + ZIM_TRACKING_REQUEST_TIMEOUT: ${ZIM_TRACKING_REQUEST_TIMEOUT:-30s} + ZIM_TRACKING_MAX_CONCURRENCY: ${ZIM_TRACKING_MAX_CONCURRENCY:-10} + EMC_TRACKING_ENABLED: ${EMC_TRACKING_ENABLED:-false} + EMC_TRACKING_QUERY_URL: ${EMC_TRACKING_QUERY_URL:-https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do} + EMC_TRACKING_CONNECT_TIMEOUT: ${EMC_TRACKING_CONNECT_TIMEOUT:-10s} + EMC_TRACKING_REQUEST_TIMEOUT: ${EMC_TRACKING_REQUEST_TIMEOUT:-30s} + EMC_TRACKING_MIN_REQUEST_INTERVAL: ${EMC_TRACKING_MIN_REQUEST_INTERVAL:-5s} + HEDE_TRACKING_ENABLED: ${HEDE_TRACKING_ENABLED:-false} + HEDE_TRACKING_QUERY_URL: ${HEDE_TRACKING_QUERY_URL:-http://elines.hedehk.com/getVBilldynamic} + HEDE_TRACKING_CONNECT_TIMEOUT: ${HEDE_TRACKING_CONNECT_TIMEOUT:-10s} + HEDE_TRACKING_REQUEST_TIMEOUT: ${HEDE_TRACKING_REQUEST_TIMEOUT:-30s} + HEDE_TRACKING_MIN_REQUEST_INTERVAL: ${HEDE_TRACKING_MIN_REQUEST_INTERVAL:-5s} event-bridge-service: <<: *common diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 981c17abc..7d4038556 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -10244,11 +10244,11 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( tracking_type VARCHAR(32) NOT NULL COMMENT '官网跟踪类型:BOOKING_NO订舱号、BILL_OF_LADING提单号、CONTAINER_NO箱号', tracking_no VARCHAR(128) NOT NULL COMMENT '提交至船公司官网的跟踪号码', official_url VARCHAR(500) NOT NULL COMMENT '船公司官网查询入口地址快照', - access_mode VARCHAR(32) NOT NULL COMMENT '官网访问方式:PUBLIC_FORM公开表单、HCAPTCHA人机校验、DYNAMIC_IFRAME动态页面', - status VARCHAR(24) NOT NULL COMMENT '任务状态:READY、MANUAL_REQUIRED、ARRIVED、PICKUP_READY、STOPPED', + access_mode VARCHAR(32) NOT NULL COMMENT '船司访问方式:PUBLIC_FORM、PUBLIC_WEB_PROXY、PUBLIC_HTML、HCAPTCHA、OFFICIAL_API', + status VARCHAR(24) NOT NULL COMMENT '任务操作状态:READY、MANUAL_REQUIRED、STOPPED', trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL', - latest_standard_node VARCHAR(32) NULL COMMENT '最近标准船节点', - latest_node_observed_at DATETIME(3) NULL COMMENT '最近标准节点观测时间', + latest_standard_node VARCHAR(32) NULL COMMENT '最近实际标准船节点;预计事实不推进', + latest_node_observed_at DATETIME(3) NULL COMMENT '最近实际标准节点发生时间', stopped_at DATETIME(3) NULL COMMENT '任务停止时间', stop_reason VARCHAR(500) NULL COMMENT '任务停止原因', lease_owner VARCHAR(64) NULL COMMENT '当前调度租约持有者', @@ -10290,12 +10290,13 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( CONSTRAINT chk_carrier_schedule_query_tracking CHECK (tracking_type IN ('BOOKING_NO','BILL_OF_LADING','CONTAINER_NO')), CONSTRAINT chk_carrier_schedule_query_access CHECK - (access_mode IN ('PUBLIC_FORM','HCAPTCHA','DYNAMIC_IFRAME')), + (access_mode IN ('PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API')), CONSTRAINT chk_carrier_schedule_query_status CHECK - (status IN ('READY','MANUAL_REQUIRED','ARRIVED','PICKUP_READY','STOPPED')), + (status IN ('READY','MANUAL_REQUIRED','STOPPED')), CONSTRAINT chk_carrier_schedule_query_node CHECK (latest_standard_node IS NULL OR latest_standard_node IN - ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED','PICKUP_READY')), + ('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')), CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3) ) ENGINE=InnoDB COMMENT='六家船公司官网查询任务与三天窗口频控'; @@ -10306,7 +10307,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt ( carrier_code VARCHAR(16) NOT NULL COMMENT '本次尝试的船公司编码快照', access_mode VARCHAR(32) NOT NULL COMMENT '本次尝试的官网访问方式快照', official_url VARCHAR(500) NOT NULL COMMENT '本次尝试使用的官网地址快照', - attempt_status VARCHAR(24) NOT NULL COMMENT '尝试结果:MANUAL_REQUIRED需人工处理、SUCCESS成功、FAILED失败', + attempt_status VARCHAR(24) NOT NULL COMMENT '供应商调用结果', coverage_gap TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否存在自动化覆盖缺口:0否、1是', coverage_gap_code VARCHAR(64) NULL COMMENT '自动化覆盖缺口编码', manual_reason VARCHAR(1000) NULL COMMENT '转人工处理原因', @@ -10320,13 +10321,13 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt ( CONSTRAINT fk_carrier_schedule_attempt_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), CONSTRAINT chk_carrier_schedule_attempt_no CHECK (window_attempt_no BETWEEN 1 AND 3), CONSTRAINT chk_carrier_schedule_attempt_status CHECK - (attempt_status IN ('MANUAL_REQUIRED','SUCCESS','FAILED')) + (attempt_status IN ('SUCCESS','NO_CHANGE','RETRYABLE_FAILURE','PERMANENT_FAILURE','MANUAL_REQUIRED')) ) ENGINE=InnoDB COMMENT='船司官网查询尝试不可变审计'; CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history ( id BIGINT UNSIGNED NOT NULL COMMENT '船司查询任务历史ID', query_id BIGINT UNSIGNED NOT NULL COMMENT '所属船司官网查询任务ID', - action VARCHAR(24) NOT NULL COMMENT '历史动作:CREATE创建、ATTEMPT执行查询、MANUAL_RESULT录入人工结果', + action VARCHAR(24) NOT NULL COMMENT '查询任务业务历史动作', from_version INT UNSIGNED NULL COMMENT '变更前任务版本;创建时为空', to_version INT UNSIGNED NOT NULL COMMENT '变更后任务版本', reason VARCHAR(1000) NULL COMMENT '操作原因或说明', @@ -10339,7 +10340,8 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history ( KEY idx_carrier_schedule_history_query (query_id,created_at,id), CONSTRAINT fk_carrier_schedule_history_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), CONSTRAINT chk_carrier_schedule_history_action CHECK - (action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','NODE_OBSERVED','PROVIDER_QUERY','STOP')) + (action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','MANUAL_NODE','NODE_OBSERVED', + 'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP')) ) ENGINE=InnoDB COMMENT='船司查询任务不可变历史'; CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact ( @@ -10347,6 +10349,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact ( query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID', event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键', provider_code VARCHAR(32) NOT NULL COMMENT '轨迹供应商编码', + provider_event_id VARCHAR(128) NOT NULL COMMENT '供应商稳定事件ID', provider_node_code VARCHAR(64) NOT NULL COMMENT '供应商原始节点编码', standard_node VARCHAR(32) NOT NULL COMMENT '标准船节点', node_sequence INT UNSIGNED NOT NULL COMMENT '标准节点单调序号', @@ -10363,10 +10366,35 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact ( KEY idx_carrier_schedule_node_timeline (query_id,node_sequence,observed_at,id), CONSTRAINT fk_carrier_schedule_node_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), CONSTRAINT chk_carrier_schedule_node_standard CHECK - (standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED','PICKUP_READY')), - CONSTRAINT chk_carrier_schedule_node_sequence CHECK (node_sequence IN (10,20,30,40,50,60)) + (standard_node IN ('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')), + CONSTRAINT chk_carrier_schedule_node_sequence CHECK (node_sequence IN (10,20,30,40,50,60,70,80,90,100)) ) ENGINE=InnoDB COMMENT='供应商标准船节点不可变事实'; +CREATE TABLE IF NOT EXISTS fl_carrier_schedule_exception_fact ( + id BIGINT UNSIGNED NOT NULL COMMENT '船节点异常事实ID', + query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID', + event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键', + provider_code VARCHAR(32) NOT NULL COMMENT '供应商编码', + provider_event_id VARCHAR(128) NOT NULL COMMENT '供应商稳定事件ID', + provider_status VARCHAR(128) NOT NULL COMMENT '供应商原始异常状态', + exception_code VARCHAR(32) NOT NULL COMMENT '平台异常编码', + active TINYINT(1) NOT NULL COMMENT '1异常发生;0异常解除', + observed_at DATETIME(3) NOT NULL COMMENT '供应商观测时间', + location_code VARCHAR(32) NULL COMMENT '地点编码', + location_name VARCHAR(128) NULL COMMENT '地点名称', + source_reference VARCHAR(500) NULL COMMENT '可审计来源引用', + raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', + PRIMARY KEY (id), + UNIQUE KEY uk_carrier_schedule_exception_event (query_id,event_key), + KEY idx_carrier_schedule_exception_timeline (query_id,observed_at,id), + CONSTRAINT fk_carrier_schedule_exception_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), + CONSTRAINT chk_carrier_schedule_exception_code CHECK + (exception_code IN ('CUSTOMS_HOLD','TERMINAL_HOLD','DOCUMENT_HOLD','UNKNOWN_HOLD')), + CONSTRAINT chk_carrier_schedule_exception_active CHECK (active IN (0,1)) +) ENGINE=InnoDB COMMENT='船节点异常发生与解除不可变事实'; + CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation ( id BIGINT UNSIGNED NOT NULL COMMENT '船司查询写操作幂等记录ID', query_id BIGINT UNSIGNED NULL COMMENT '关联查询任务ID;创建任务落库前允许为空', @@ -10409,6 +10437,8 @@ DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_update; DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_delete; DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_update; DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_delete; +DROP TRIGGER IF EXISTS trg_carrier_schedule_exception_before_update; +DROP TRIGGER IF EXISTS trg_carrier_schedule_exception_before_delete; DELIMITER $$ CREATE TRIGGER trg_carrier_schedule_attempt_before_update BEFORE UPDATE ON fl_carrier_schedule_query_attempt FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询尝试不可修改'; END$$ @@ -10422,6 +10452,10 @@ CREATE TRIGGER trg_carrier_schedule_node_before_update BEFORE UPDATE ON fl_carri FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可修改'; END$$ CREATE TRIGGER trg_carrier_schedule_node_before_delete BEFORE DELETE ON fl_carrier_schedule_node_fact FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可删除'; END$$ +CREATE TRIGGER trg_carrier_schedule_exception_before_update BEFORE UPDATE ON fl_carrier_schedule_exception_fact +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船节点异常事实不可修改'; END$$ +CREATE TRIGGER trg_carrier_schedule_exception_before_delete BEFORE DELETE ON fl_carrier_schedule_exception_fact +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船节点异常事实不可删除'; END$$ DELIMITER ; USE baiyue_iam; @@ -10485,7 +10519,7 @@ BEGIN WHERE table_schema='baiyue_firstleg' AND table_name IN ('fl_carrier_schedule_query','fl_carrier_schedule_query_attempt', 'fl_carrier_schedule_query_history','fl_carrier_schedule_query_operation', - 'fl_carrier_schedule_node_fact')) <> 5 THEN + 'fl_carrier_schedule_node_fact','fl_carrier_schedule_exception_fact')) <> 6 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务表未完整创建'; END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission @@ -11899,6 +11933,7 @@ CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', '船司查询任务不可变历史'); CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operation', '船司查询写操作幂等与请求摘要'); CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', '供应商标准船节点不可变事实'); +CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', '船节点异常发生与解除不可变事实'); CALL queue_table_comment('baiyue_firstleg', 'fl_consumed_event', '头程消息消费幂等表:记录已处理事件并按事件ID去重,防止RocketMQ重复投递造成重复建单;不是消息发布表'); CALL queue_table_comment('baiyue_firstleg', 'fl_container_booking', '平台货柜预订资料;预装状态唯一来源为装柜计划'); CALL queue_table_comment('baiyue_firstleg', 'fl_container_document', '平台货柜清关、DO与PU文件关联'); @@ -12479,7 +12514,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_fba_rate', 'updated_at' CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_fba_rate', 'updated_by', '最后修改人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_fba_rate', 'version', '乐观锁版本号'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_fba_rate', 'weight_from_kg', '区域阶梯起始计费重量KG'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'access_mode', '官网访问方式:PUBLIC_FORM公开表单、HCAPTCHA人机校验、DYNAMIC_IFRAME动态页面'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'access_mode', '船司访问方式:PUBLIC_FORM、PUBLIC_WEB_PROXY、PUBLIC_HTML、HCAPTCHA、OFFICIAL_API'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'attempt_count', '当前查询窗口已执行次数;最多3次'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'carrier_code', '船公司编码:MATSON美森、SML森罗、ZIM以星、COSCO中远海运、EMC长荣、HEDE合德'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'container_booking_id', '指定查询的柜次ID;整票订舱查询时为空'); @@ -12503,10 +12538,10 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'query CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'remark', '平台内部备注'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'result_port_schedule_id', '最近有效结果对应的船期记录ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'source_updated_at', '船公司官网展示的结果更新时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'status', '任务状态:READY、MANUAL_REQUIRED、ARRIVED、PICKUP_READY、STOPPED'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'status', '任务操作状态:READY、MANUAL_REQUIRED、STOPPED'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'trigger_source', '任务触发来源:OUTBOUND_CONFIRM或MANUAL'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'latest_standard_node', '最近标准船节点'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'latest_node_observed_at', '最近标准节点观测时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'latest_standard_node', '最近实际标准船节点;预计事实不推进'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'latest_node_observed_at', '最近实际标准节点发生时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'stopped_at', '任务停止时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'stop_reason', '任务停止原因'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'lease_owner', '当前调度租约持有者'); @@ -12520,7 +12555,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'updat CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'version', '乐观锁版本号'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'window_started_at', '当前三天查询窗口起始时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'access_mode', '本次尝试的官网访问方式快照'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'attempt_status', '尝试结果:MANUAL_REQUIRED需人工处理、SUCCESS成功、FAILED失败'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'attempt_status', '供应商调用结果'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'carrier_code', '本次尝试的船公司编码快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'coverage_gap', '是否存在自动化覆盖缺口:0否、1是'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'coverage_gap_code', '自动化覆盖缺口编码'); @@ -12534,7 +12569,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt' CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'operator_name', '执行操作人姓名快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'query_id', '所属船司官网查询任务ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'window_attempt_no', '当前三天窗口内的尝试序号;取值1至3'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', 'action', '历史动作:CREATE创建、ATTEMPT执行查询、MANUAL_RESULT录入人工结果'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', 'action', '查询任务业务历史动作'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', 'after_snapshot', '变更后任务数据快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', 'before_snapshot', '变更前任务数据快照;创建时为空'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', 'created_at', '历史记录创建时间'); @@ -12559,6 +12594,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'i CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'query_id', '所属查询任务ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'event_key', '供应商编码与供应商事件ID组成的幂等键'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'provider_code', '轨迹供应商编码'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'provider_event_id', '供应商稳定事件ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'provider_node_code', '供应商原始节点编码'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'standard_node', '标准船节点'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'node_sequence', '标准节点单调序号'); @@ -12570,6 +12606,20 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'l CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'source_reference', '可审计来源引用'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'raw_payload_hash', '原始响应SHA-256;不保存敏感原文'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'created_at', '事实入库时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'id', '船节点异常事实ID'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'query_id', '所属查询任务ID'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'event_key', '供应商异常事件幂等键'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'provider_code', '供应商编码'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'provider_event_id', '供应商稳定事件ID'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'provider_status', '供应商原始异常状态'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'exception_code', '平台异常编码'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'active', '1异常发生;0异常解除'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'observed_at', '供应商观测时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'location_code', '地点编码'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'location_name', '地点名称'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'source_reference', '可审计来源引用'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'raw_payload_hash', '原始响应SHA-256'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'created_at', '事实入库时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_consumed_event', 'consumed_at', '首次成功消费时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_consumed_event', 'event_id', '已消费领域事件ID,与生产端Outbox事件ID一致'); CALL queue_column_comment('baiyue_firstleg', 'fl_consumed_event', 'event_type', '已消费事件类型'); diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 9a8cc9486..ea318b6a5 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -31,7 +31,7 @@ | 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`client/EventBridgeClient.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 三期海运订舱与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`portschedule/controller/PlatformPortScheduleController.java`;订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | -| 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/标准节点事实/历史 `CarrierScheduleQueryService.java`,内部停止入口 `controller/InternalCarrierScheduleQueryController.java`,平台只读入口 `PlatformCarrierScheduleQueryController.java`;外部船司/轨迹调用经 `integration-service/`;范围见 `../../docs/四期/README.md` | +| 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/四期/船节点标准化契约.md` | | Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | | 拖车与出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java` | | 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` | @@ -51,7 +51,7 @@ - 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 - 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 - 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 -- 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;标准节点固定为 `DEPARTED_WAREHOUSE → AT_ORIGIN_PORT → DEPARTED_ORIGIN_PORT → IN_TRANSIT → ARRIVED → PICKUP_READY`,保存来源、节点观测时间、最近成功结果、失败尝试及完整历史。现有 `CarrierScheduleQuery` 及“正常每 3 天一次、窗口最多 3 次、失败指数退避”约束属于四期;租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。 +- 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/四期/船节点标准化契约.md` 为准。现有 `CarrierScheduleQuery` 及“正常每 3 天一次、窗口最多 3 次、失败指数退避”约束属于四期;租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。 - 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜照片上传时间加两天计算,实际报空时间可为空。 - Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。 - `DIRECT_PAID` 仅适用于 `PAYABLE`,必须同时保存 `paidAt`、合法 `paymentMethod`,并至少提供 `paymentReferenceNo` 或一个当前客户范围内的 `FINANCE_VOUCHER`;`RECEIVABLE` 与 `ON_ACCOUNT` 不得残留付款字段。创建、调整和审核前均通过 `FeePaymentSnapshotValidator` 失败关闭复核。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/CarrierScheduleIntegrationClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/CarrierScheduleIntegrationClient.java index 89606b826..7e3d34a12 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/CarrierScheduleIntegrationClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/CarrierScheduleIntegrationClient.java @@ -20,9 +20,13 @@ public interface CarrierScheduleIntegrationClient { } record QueryRequest(String carrierCode, String trackingType, String trackingNo) { } record QueryResult(String providerCode, String outcome, List observations, + List exceptions, String errorCode, String errorMessage, LocalDateTime providerObservedAt) { } record Observation(String providerEventId, String providerNodeCode, String standardNode, LocalDateTime observedAt, LocalDateTime estimatedAt, LocalDateTime actualAt, String locationCode, String locationName, String sourceReference, String rawPayloadHash) { } + record ExceptionObservation(String providerEventId, String providerStatus, String exceptionCode, + boolean active, LocalDateTime observedAt, String locationCode, + String locationName, String sourceReference, String rawPayloadHash) { } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/InternalCarrierScheduleQueryController.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/InternalCarrierScheduleQueryController.java deleted file mode 100644 index 3187f3912..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/InternalCarrierScheduleQueryController.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.baiyue.firstleg.portschedule.controller; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail; -import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Positive; -import jakarta.validation.constraints.Size; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; - -@RestController -@RequestMapping("/internal/firstleg/v1/carrier-schedule-queries") -public class InternalCarrierScheduleQueryController { - private final CarrierScheduleQueryService service; - @Value("${baiyue.security.internal-service-token}") private String expectedToken; - - public InternalCarrierScheduleQueryController(CarrierScheduleQueryService service) { this.service = service; } - - @PostMapping("/{queryId}/stop") - public ApiResponse stop(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, - @PathVariable @Positive long queryId, - @Valid @RequestBody StopRequest request) { - authenticate(token); - return ApiResponse.success(service.stop(queryId, request.reason())); - } - - private void authenticate(String token) { - if (token == null || expectedToken == null || expectedToken.isBlank() - || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), - expectedToken.getBytes(StandardCharsets.UTF_8))) { - throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效"); - } - } - - public record StopRequest(@NotBlank @Size(max = 500) String reason) { } -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java index 90a5ce73a..4153fa1e2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java @@ -8,6 +8,7 @@ import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDesc import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.QueryView; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.History; @@ -93,4 +94,16 @@ public class PlatformCarrierScheduleQueryController { FirstlegScopeGuard.platform(user, service.customerId(queryId)); return ApiResponse.success(service.attachManualResult(queryId, user.userId(), user.username(), key, request)); } + + @PostMapping("/{queryId}/manual-nodes") + @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')") + @BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_NODE", resourceType = "CARRIER_SCHEDULE_QUERY", + resourceId = "#queryId", description = "人工补录船节点事实") + public ApiResponse manualNode(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long queryId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody ManualNodeRequest request) { + FirstlegScopeGuard.platform(user, service.customerId(queryId)); + return ApiResponse.success(service.addManualNode(queryId, user.userId(), user.username(), key, request)); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/domain/CarrierScheduleExceptionCode.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/domain/CarrierScheduleExceptionCode.java new file mode 100644 index 000000000..e24dfb348 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/domain/CarrierScheduleExceptionCode.java @@ -0,0 +1,20 @@ +package com.baiyue.firstleg.portschedule.domain; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; + +import java.util.Locale; + +public enum CarrierScheduleExceptionCode { + CUSTOMS_HOLD, + TERMINAL_HOLD, + DOCUMENT_HOLD, + UNKNOWN_HOLD; + + public static CarrierScheduleExceptionCode require(String value) { + try { return valueOf(value == null ? "" : value.trim().toUpperCase(Locale.ROOT)); } + catch (IllegalArgumentException exception) { + throw new BizException(ResultCode.BAD_REQUEST, "不支持的船节点异常:" + value); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/domain/CarrierScheduleNode.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/domain/CarrierScheduleNode.java index 724fc5524..5ace71e11 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/domain/CarrierScheduleNode.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/domain/CarrierScheduleNode.java @@ -9,10 +9,14 @@ import java.util.Locale; public enum CarrierScheduleNode { DEPARTED_WAREHOUSE(10), AT_ORIGIN_PORT(20), - DEPARTED_ORIGIN_PORT(30), - IN_TRANSIT(40), - ARRIVED(50), - PICKUP_READY(60); + 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); private final int sequence; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java index b0fe8b792..4c5a90237 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java @@ -16,9 +16,9 @@ public final class CarrierScheduleQueryDtos { public record Query(@Positive Long customerId, @Positive Long oceanBookingId, @Positive Long containerBookingId, @Size(max = 16) String carrierCode, @Size(max = 24) String status, Boolean expired, - @Size(max = 128) String keyword, @Min(1) long page, - @Min(1) @Max(200) long size) { - public Query { if (page == 0) page = 1; if (size == 0) size = 20; } + @Size(max = 128) String keyword, @Min(1) Long page, + @Min(1) @Max(200) Long size) { + public Query { if (page == null || page == 0) page = 1L; if (size == null || size == 0) size = 20L; } } public record CreateRequest(@NotNull @Min(0) @Max(0) Integer expectedVersion, @@ -34,6 +34,15 @@ public final class CarrierScheduleQueryDtos { @NotNull @Positive Long portScheduleId, @NotBlank @Size(max = 1000) String reason) {} + public record ManualNodeRequest(@NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 32) String standardNode, + @NotNull LocalDateTime observedAt, + LocalDateTime estimatedAt, + LocalDateTime actualAt, + @Size(max = 32) String locationCode, + @Size(max = 128) String locationName, + @NotBlank @Size(max = 1000) String reason) {} + public record ProviderObservationRequest(@NotBlank @Size(max = 32) String providerCode, @NotBlank @Size(max = 128) String providerEventId, @NotBlank @Size(max = 64) String providerNodeCode, @@ -46,6 +55,17 @@ public final class CarrierScheduleQueryDtos { @Size(max = 500) String sourceReference, @NotBlank @Size(min = 64, max = 64) String rawPayloadHash) {} + public record ProviderExceptionRequest(@NotBlank @Size(max = 32) String providerCode, + @NotBlank @Size(max = 128) String providerEventId, + @NotBlank @Size(max = 128) String providerStatus, + @NotBlank @Size(max = 32) String exceptionCode, + boolean active, + @NotNull LocalDateTime observedAt, + @Size(max = 32) String locationCode, + @Size(max = 128) String locationName, + @Size(max = 500) String sourceReference, + @NotBlank @Size(min = 64, max = 64) String rawPayloadHash) {} + public record CarrierDescriptor(String carrierCode, String carrierName, String officialUrl, String accessMode, boolean automated, boolean coverageGap, String coverageGapCode, String manualReason) {} @@ -53,21 +73,28 @@ public final class CarrierScheduleQueryDtos { public record Task(Long id, String queryNo, Long customerId, Long oceanBookingId, Long containerBookingId, String carrierCode, String trackingType, String trackingNo, String officialUrl, String accessMode, String status, String triggerSource, - String latestStandardNode, LocalDateTime latestNodeObservedAt, + String latestActualNode, LocalDateTime latestActualAt, LocalDateTime lastSuccessAt, LocalDateTime sourceUpdatedAt, LocalDateTime expiresAt, Long resultPortScheduleId, String manualReason, String lastErrorCode, String lastErrorMessage, LocalDateTime stoppedAt, String stopReason, Integer version) {} public record QueryView(Task query, boolean expired) {} - public record NodeFact(Long id, String providerCode, String providerNodeCode, String standardNode, + public record NodeFact(Long id, String providerCode, String providerEventId, + String providerNodeCode, String standardNode, int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt, LocalDateTime actualAt, String locationCode, String locationName, String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {} + public record ExceptionFact(Long id, String providerCode, String providerEventId, + String providerStatus, String exceptionCode, boolean active, + LocalDateTime observedAt, String locationCode, String locationName, + String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {} + public record History(Long id, String action, Integer fromVersion, Integer toVersion, String reason, Long operatorId, String operatorName, LocalDateTime createdAt) {} /** Frontend detail intentionally excludes scheduler leases and raw query attempts. */ - public record Detail(QueryView query, List nodes, List histories) {} + public record Detail(QueryView query, List nodes, List exceptions, + List histories) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleExceptionFactEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleExceptionFactEntity.java new file mode 100644 index 000000000..e7fa6c220 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleExceptionFactEntity.java @@ -0,0 +1,31 @@ +package com.baiyue.firstleg.portschedule.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("fl_carrier_schedule_exception_fact") +public class CarrierScheduleExceptionFactEntity { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private Long queryId; + private String eventKey; + private String providerCode; + private String providerEventId; + private String providerStatus; + private String exceptionCode; + private Boolean active; + private LocalDateTime observedAt; + private String locationCode; + private String locationName; + private String sourceReference; + private String rawPayloadHash; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleNodeFactEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleNodeFactEntity.java index 3015f55c3..8df45bac0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleNodeFactEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleNodeFactEntity.java @@ -18,6 +18,7 @@ public class CarrierScheduleNodeFactEntity { private Long queryId; private String eventKey; private String providerCode; + private String providerEventId; private String providerNodeCode; private String standardNode; private Integer nodeSequence; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleExceptionFactMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleExceptionFactMapper.java new file mode 100644 index 000000000..6032850bb --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleExceptionFactMapper.java @@ -0,0 +1,23 @@ +package com.baiyue.firstleg.portschedule.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleExceptionFactEntity; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface CarrierScheduleExceptionFactMapper extends BaseMapper { + @Insert(""" + INSERT INTO fl_carrier_schedule_exception_fact + (id,query_id,event_key,provider_code,provider_event_id,provider_status,exception_code,active, + observed_at,location_code,location_name,source_reference,raw_payload_hash,created_at) + VALUES + (#{value.id},#{value.queryId},#{value.eventKey},#{value.providerCode},#{value.providerEventId}, + #{value.providerStatus},#{value.exceptionCode},#{value.active},#{value.observedAt}, + #{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash}, + CURRENT_TIMESTAMP(3)) + ON DUPLICATE KEY UPDATE id=id + """) + int insertIfAbsent(@Param("value") CarrierScheduleExceptionFactEntity value); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleNodeFactMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleNodeFactMapper.java index a5aac0ec7..f20d62918 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleNodeFactMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleNodeFactMapper.java @@ -10,10 +10,10 @@ import org.apache.ibatis.annotations.Param; public interface CarrierScheduleNodeFactMapper extends BaseMapper { @Insert(""" INSERT INTO fl_carrier_schedule_node_fact - (id,query_id,event_key,provider_code,provider_node_code,standard_node,node_sequence,observed_at, + (id,query_id,event_key,provider_code,provider_event_id,provider_node_code,standard_node,node_sequence,observed_at, estimated_at,actual_at,location_code,location_name,source_reference,raw_payload_hash,created_at) VALUES - (#{value.id},#{value.queryId},#{value.eventKey},#{value.providerCode},#{value.providerNodeCode}, + (#{value.id},#{value.queryId},#{value.eventKey},#{value.providerCode},#{value.providerEventId},#{value.providerNodeCode}, #{value.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt}, #{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash}, CURRENT_TIMESTAMP(3)) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java index 69b0d47f2..1f3e38eca 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java @@ -48,7 +48,7 @@ public interface CarrierScheduleQueryMapper extends BaseMapper page(Query query) { return PageResult.from(queries.selectPage(new Page<>(query.page(), query.size()), query)); @@ -67,6 +70,15 @@ public class CarrierScheduleQueryRepository { .orderByAsc(CarrierScheduleNodeFactEntity::getId)); } public boolean insertIfAbsent(CarrierScheduleNodeFactEntity value) { return nodes.insertIgnore(value) == 1; } + public List exceptions(long id) { + return exceptions.selectList(new LambdaQueryWrapper() + .eq(CarrierScheduleExceptionFactEntity::getQueryId, id) + .orderByAsc(CarrierScheduleExceptionFactEntity::getObservedAt) + .orderByAsc(CarrierScheduleExceptionFactEntity::getId)); + } + public boolean insertIfAbsent(CarrierScheduleExceptionFactEntity value) { + return exceptions.insertIfAbsent(value) == 1; + } public boolean claimOperation(Long queryId, long operatorId, String operation, String key, String hash) { return operations.claim(IdWorker.getId(), queryId, operatorId, operation, key, hash) == 1; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistry.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistry.java index 82e780b8e..50a5919ec 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistry.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistry.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor; import org.springframework.stereotype.Component; +import org.springframework.beans.factory.annotation.Value; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; @@ -16,15 +17,27 @@ import java.util.Map; @Component public class CarrierScheduleAdapterRegistry { private static final Map DEFINITIONS = definitions(); + @Value("${baiyue.carrier-schedule.providers.matson-automated:false}") + private boolean matsonAutomated; + @Value("${baiyue.carrier-schedule.providers.hede-automated:false}") + private boolean hedeAutomated; + @Value("${baiyue.carrier-schedule.providers.sml-automated:false}") + private boolean smlAutomated; + @Value("${baiyue.carrier-schedule.providers.zim-official-api-enabled:false}") + private boolean zimOfficialApiEnabled; + @Value("${baiyue.carrier-schedule.providers.cosco-official-api-enabled:false}") + private boolean coscoOfficialApiEnabled; + @Value("${baiyue.carrier-schedule.providers.emc-automated:false}") + private boolean emcAutomated; public List descriptors() { - return DEFINITIONS.values().stream().map(Definition::descriptor).toList(); + return DEFINITIONS.values().stream().map(value -> value.descriptor(automated(value))).toList(); } public PreparedAttempt prepare(String carrierCode, String trackingType, String trackingNo) { Definition definition = required(carrierCode); return new PreparedAttempt(definition.code(), definition.accessMode(), - officialUrl(definition, trackingType, trackingNo), "MANUAL_REQUIRED", + officialUrl(definition, trackingType, trackingNo), automated(definition) ? "READY" : "MANUAL_REQUIRED", definition.coverageGapCode(), definition.manualReason()); } @@ -52,9 +65,6 @@ public class CarrierScheduleAdapterRegistry { private static String officialUrl(Definition definition, String trackingType, String trackingNo) { String encoded = URLEncoder.encode(trackingNo.trim(), StandardCharsets.UTF_8); - if ("ZIM".equals(definition.code())) { - return definition.officialUrl() + "?consnumber=" + encoded; - } if ("EMC".equals(definition.code())) { String type = "CONTAINER_NO".equalsIgnoreCase(trackingType) ? "cntr" : "bl"; return definition.officialUrl() + "?c=" + encoded + "&t=" + type + "&url=EVERGREEN"; @@ -65,26 +75,40 @@ public class CarrierScheduleAdapterRegistry { private static Map definitions() { Map values = new LinkedHashMap<>(); add(values, new Definition("MATSON", "美森", "https://www.matson.com/shipment-tracking.html", - "PUBLIC_FORM", "NO_DOCUMENTED_STRUCTURED_API", "官网仅公开交互表单,当前需人工查询并录入结果")); + "PUBLIC_WEB_PROXY", null, null)); add(values, new Definition("SML", "SML", "https://esvc.smlines.com/smline/CUP_HOM_3301.do?sessLocale=zh", - "PUBLIC_FORM", "NO_DOCUMENTED_STRUCTURED_API", "官网未提供稳定公开结构化接口,当前需人工查询并录入结果")); - add(values, new Definition("ZIM", "ZIM", "https://www.zimchina.com/tools/track-a-shipment", - "HCAPTCHA", "HCAPTCHA_REQUIRED", "官网启用hCaptcha,禁止绕过验证码,必须人工查询")); - add(values, new Definition("COSCO", "COSCO", "https://elines.coscoshipping.com/ebusiness/", - "DYNAMIC_IFRAME", "DYNAMIC_IFRAME_REQUIRED", "官网使用动态页面/iframe,未发现稳定公开API,必须人工查询")); + "PUBLIC_FORM", null, null)); + add(values, new Definition("ZIM", "ZIM", "https://api.zim.com/", + "OFFICIAL_API", "OFFICIAL_API_CREDENTIALS_REQUIRED", + "ZIM Cargo Tracking API 待配置订阅凭据及来源授权,启用前必须人工查询")); + add(values, new Definition("COSCO", "COSCO", "https://cop.lines.coscoshipping.com/copPortal/", + "OFFICIAL_API", "OFFICIAL_API_CREDENTIALS_REQUIRED", + "COSCO Shipping Open Platform Cargo Tracking API 待注册订阅及凭据,启用前必须人工查询")); add(values, new Definition("EMC", "EMC", "https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do", "PUBLIC_FORM", "NO_DOCUMENTED_STRUCTURED_API", "官网仅公开查询表单,当前需人工确认页面结果后录入")); add(values, new Definition("HEDE", "合德", "http://elines.hedehk.com/cargoDynamic", - "PUBLIC_FORM", "NO_DOCUMENTED_STRUCTURED_API", "官网未提供稳定公开结构化接口,当前需人工查询并录入结果")); + "PUBLIC_HTML", null, null)); return Collections.unmodifiableMap(values); } private static void add(Map values, Definition value) { values.put(value.code(), value); } + private boolean automated(Definition definition) { + return switch (definition.code()) { + case "MATSON" -> matsonAutomated; + case "SML" -> smlAutomated; + case "ZIM" -> zimOfficialApiEnabled; + case "COSCO" -> coscoOfficialApiEnabled; + case "EMC" -> emcAutomated; + case "HEDE" -> hedeAutomated; + default -> false; + }; + } + private record Definition(String code, String name, String officialUrl, String accessMode, String coverageGapCode, String manualReason) { - CarrierDescriptor descriptor() { - return new CarrierDescriptor(code, name, officialUrl, accessMode, false, true, + CarrierDescriptor descriptor(boolean automated) { + return new CarrierDescriptor(code, name, officialUrl, accessMode, automated, !automated, coverageGapCode, manualReason); } boolean matches(String value) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java index d1e28ef83..28c72c2a1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java @@ -3,6 +3,7 @@ package com.baiyue.firstleg.portschedule.service; import com.baiyue.firstleg.client.CarrierScheduleIntegrationClient; import com.baiyue.firstleg.client.CarrierScheduleIntegrationClient.QueryRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderExceptionRequest; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository; import org.springframework.beans.factory.annotation.Value; @@ -61,6 +62,14 @@ public class CarrierScheduleQueryScheduler { observation.actualAt(), observation.locationCode(), observation.locationName(), observation.sourceReference(), observation.rawPayloadHash())); } + List exceptions = result.exceptions() == null + ? List.of() : result.exceptions(); + for (var exception : exceptions) { + service.applyProviderException(candidate.getId(), new ProviderExceptionRequest( + result.providerCode(), exception.providerEventId(), exception.providerStatus(), + exception.exceptionCode(), exception.active(), exception.observedAt(), exception.locationCode(), + exception.locationName(), exception.sourceReference(), exception.rawPayloadHash())); + } service.completeScheduledAttempt(candidate.getId(), result.outcome(), result.errorCode(), result.errorMessage()); } catch (RuntimeException exception) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java index adaa9a117..69315989c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java @@ -9,17 +9,23 @@ import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDesc import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.QueryView; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.NodeFact; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.History; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ExceptionFact; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderExceptionRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Task; import com.baiyue.firstleg.portschedule.domain.CarrierScheduleNode; +import com.baiyue.firstleg.portschedule.domain.CarrierScheduleExceptionCode; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryOperationEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleExceptionFactEntity; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity; import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity; import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository; @@ -128,7 +134,6 @@ public class CarrierScheduleQueryService { @Transactional public Detail applyProviderObservation(long id, ProviderObservationRequest request) { CarrierScheduleQueryEntity value = requiredLocked(id); - if ("STOPPED".equals(value.getStatus())) return detail(value); CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode()); CarrierScheduleQueryEntity before = copy(value); if (!addNode(id, request.providerCode(), request.providerEventId(), request.providerNodeCode(), node, @@ -136,40 +141,83 @@ public class CarrierScheduleQueryService { request.locationName(), request.sourceReference(), request.rawPayloadHash())) { return detail(value); } - CarrierScheduleNode current = value.getLatestStandardNode() == null ? null - : CarrierScheduleNode.require(value.getLatestStandardNode()); - if (current == null || node.sequence() >= current.sequence()) { - value.setLatestStandardNode(node.name()); value.setLatestNodeObservedAt(request.observedAt()); - value.setLastSuccessAt(now()); value.setSourceUpdatedAt(request.observedAt()); - value.setLastAttemptStatus("SUCCESS"); value.setLastErrorCode(null); value.setLastErrorMessage(null); - value.setStatus(node == CarrierScheduleNode.ARRIVED ? "ARRIVED" - : node == CarrierScheduleNode.PICKUP_READY ? "PICKUP_READY" : "READY"); - value.setNextAllowedAt(now().plusDays(WINDOW_DAYS)); - if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent(); - } - CarrierScheduleQueryEntity after = required(id); + boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(), + 0L, "carrier-scheduler"); + CarrierScheduleQueryEntity after = advanced ? required(id) : value; addHistory(id, "NODE_OBSERVED", before.getVersion(), after.getVersion(), request.standardNode(), before, after, 0L, "carrier-scheduler"); return detail(after); } @Transactional - public Detail stop(long id, String reason) { + public Detail applyProviderException(long id, ProviderExceptionRequest request) { CarrierScheduleQueryEntity value = requiredLocked(id); - if ("STOPPED".equals(value.getStatus())) return detail(value); + CarrierScheduleExceptionCode code = CarrierScheduleExceptionCode.require(request.exceptionCode()); + if (!addException(id, request.providerCode(), request.providerEventId(), request.providerStatus(), code, + request.active(), request.observedAt(), request.locationCode(), request.locationName(), + request.sourceReference(), request.rawPayloadHash())) { + return detail(value); + } + addHistory(id, "EXCEPTION_OBSERVED", value.getVersion(), value.getVersion(), code.name(), value, value, + 0L, "carrier-scheduler"); + return detail(value); + } + + @Transactional + public Detail addManualNode(long id, long operatorId, String operatorName, String key, + ManualNodeRequest request) { + String operation = "MANUAL_NODE"; + Detail replay = claimOrReplay(id, operatorId, operation, key, hash(operation, id, request)); + if (replay != null) return replay; + if (request.estimatedAt() == null && request.actualAt() == null) { + throw badRequest("estimatedAt和actualAt至少提供一个"); + } + CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion()); + CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode()); CarrierScheduleQueryEntity before = copy(value); - value.setStatus("STOPPED"); value.setStoppedAt(now()); value.setStopReason(trim(reason)); - value.setLeaseOwner(null); value.setLeaseUntil(null); value.setNextAllowedAt(null); - if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent(); - CarrierScheduleQueryEntity after = required(id); - addHistory(id, "STOP", before.getVersion(), after.getVersion(), reason, before, after, 0L, - "carrier-scheduler"); - return detail(after); + String providerEventId = sha256("MANUAL|" + id + '|' + key); + if (!addNode(id, "MANUAL", providerEventId, node.name(), node, request.observedAt(), + request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(), + "MANUAL:" + operatorId, hash(operation, id, request))) { + return complete(id, operatorId, key, detail(value)); + } + boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(), + operatorId, operatorName); + CarrierScheduleQueryEntity after = advanced ? required(id) : value; + addHistory(id, operation, before.getVersion(), after.getVersion(), request.reason(), before, after, + operatorId, operatorName); + return complete(id, operatorId, key, detail(after)); + } + + private boolean advanceActualNode(CarrierScheduleQueryEntity value, CarrierScheduleNode node, + LocalDateTime actualAt, LocalDateTime observedAt, + long operatorId, String operatorName) { + if (actualAt == null || "STOPPED".equals(value.getStatus())) return false; + CarrierScheduleNode current = value.getLatestStandardNode() == null ? null + : CarrierScheduleNode.require(value.getLatestStandardNode()); + boolean advances = current == null || node.sequence() > current.sequence() + || node.sequence() == current.sequence() && (value.getLatestNodeObservedAt() == null + || actualAt.isAfter(value.getLatestNodeObservedAt())); + if (advances) { + value.setLatestStandardNode(node.name()); value.setLatestNodeObservedAt(actualAt); + value.setLastSuccessAt(now()); value.setSourceUpdatedAt(observedAt); + value.setLastAttemptStatus("SUCCESS"); value.setLastErrorCode(null); value.setLastErrorMessage(null); + if (node == CarrierScheduleNode.EMPTY_RETURNED) { + value.setStatus("STOPPED"); value.setStoppedAt(now()); value.setStopReason("已还柜"); + value.setLeaseOwner(null); value.setLeaseUntil(null); value.setNextAllowedAt(null); + } else { + value.setStatus("READY"); value.setNextAllowedAt(now().plusDays(WINDOW_DAYS)); + } + if (repository.updateState(value, value.getVersion(), operatorId, operatorName) != 1) throw concurrent(); + return true; + } + return false; } @Transactional public void completeScheduledAttempt(long id, String outcome, String errorCode, String errorMessage) { CarrierScheduleQueryEntity value = requiredLocked(id); + insertAttempt(value, outcome, errorCode, errorMessage); if ("STOPPED".equals(value.getStatus())) return; CarrierScheduleQueryEntity before = copy(value); LocalDateTime now = now(); @@ -177,6 +225,7 @@ public class CarrierScheduleQueryService { value.setLastAttemptStatus(outcome); value.setLastErrorCode(trim(errorCode)); value.setLastErrorMessage(trim(errorMessage)); if ("SUCCESS".equals(outcome) || "NO_CHANGE".equals(outcome)) { + value.setStatus("READY"); value.setConsecutiveFailures(0); value.setAttemptCount(0); value.setWindowStartedAt(now); value.setNextAllowedAt(now.plusDays(WINDOW_DAYS)); } else { @@ -189,9 +238,7 @@ public class CarrierScheduleQueryService { value.setWindowStartedAt(windowStart); value.setAttemptCount(Math.min(MAX_ATTEMPTS, attempts)); value.setConsecutiveFailures(failures); if ("MANUAL_REQUIRED".equals(outcome) || "PERMANENT_FAILURE".equals(outcome)) { - if (!"ARRIVED".equals(value.getStatus()) && !"PICKUP_READY".equals(value.getStatus())) { - value.setStatus("MANUAL_REQUIRED"); - } + value.setStatus("MANUAL_REQUIRED"); value.setNextAllowedAt(now.plusDays(WINDOW_DAYS)); } else { long minutes = Math.min(24L * 60L, 15L << Math.min(failures - 1, 6)); @@ -208,6 +255,20 @@ public class CarrierScheduleQueryService { "carrier-scheduler"); } + private void insertAttempt(CarrierScheduleQueryEntity value, String outcome, + String errorCode, String errorMessage) { + CarrierScheduleQueryAttemptEntity attempt = new CarrierScheduleQueryAttemptEntity(); + attempt.setId(IdWorker.getId()); attempt.setQueryId(value.getId()); + attempt.setWindowAttemptNo(Math.max(1, Math.min(MAX_ATTEMPTS, count(value.getAttemptCount()) + 1))); + attempt.setCarrierCode(value.getCarrierCode()); attempt.setAccessMode(value.getAccessMode()); + attempt.setOfficialUrl(value.getOfficialUrl()); attempt.setAttemptStatus(outcome); + boolean manual = "MANUAL_REQUIRED".equals(outcome); + attempt.setCoverageGap(manual); attempt.setCoverageGapCode(manual ? trim(errorCode) : null); + attempt.setManualReason(manual ? trim(errorMessage) : null); attempt.setErrorCode(trim(errorCode)); + attempt.setErrorMessage(trim(errorMessage)); attempt.setOperatorId(0L); + attempt.setOperatorName("carrier-scheduler"); repository.insert(attempt); + } + @Transactional public Detail create(long operatorId, String operatorName, String key, CreateRequest request) { String operation = "CREATE"; @@ -309,16 +370,24 @@ public class CarrierScheduleQueryService { private Detail detail(CarrierScheduleQueryEntity value) { return new Detail(view(value, now()), repository.nodes(value.getId()).stream().map(this::node).toList(), + repository.exceptions(value.getId()).stream().map(this::exception).toList(), repository.histories(value.getId()).stream().map(this::history).toList()); } private NodeFact node(CarrierScheduleNodeFactEntity value) { - return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderNodeCode(), + return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderEventId(), value.getProviderNodeCode(), value.getStandardNode(), value.getNodeSequence(), value.getObservedAt(), value.getEstimatedAt(), value.getActualAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(), value.getRawPayloadHash(), value.getCreatedAt()); } + private ExceptionFact exception(CarrierScheduleExceptionFactEntity value) { + return new ExceptionFact(value.getId(), value.getProviderCode(), value.getProviderEventId(), + value.getProviderStatus(), value.getExceptionCode(), Boolean.TRUE.equals(value.getActive()), + value.getObservedAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(), + value.getRawPayloadHash(), value.getCreatedAt()); + } + private History history(CarrierScheduleQueryHistoryEntity value) { return new History(value.getId(), value.getAction(), value.getFromVersion(), value.getToVersion(), value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt()); @@ -332,6 +401,7 @@ public class CarrierScheduleQueryService { fact.setId(IdWorker.getId()); fact.setQueryId(queryId); fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim()); fact.setProviderCode(providerCode.trim().toUpperCase(Locale.ROOT)); + fact.setProviderEventId(providerEventId.trim()); fact.setProviderNodeCode(providerNodeCode.trim()); fact.setStandardNode(node.name()); fact.setNodeSequence(node.sequence()); fact.setObservedAt(observedAt); fact.setEstimatedAt(estimatedAt); fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName)); @@ -339,6 +409,21 @@ public class CarrierScheduleQueryService { return repository.insertIfAbsent(fact); } + private boolean addException(long queryId, String providerCode, String providerEventId, + String providerStatus, CarrierScheduleExceptionCode code, boolean active, + LocalDateTime observedAt, String locationCode, String locationName, + String sourceReference, String rawPayloadHash) { + CarrierScheduleExceptionFactEntity fact = new CarrierScheduleExceptionFactEntity(); + fact.setId(IdWorker.getId()); fact.setQueryId(queryId); + fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim()); + fact.setProviderCode(providerCode.trim().toUpperCase(Locale.ROOT)); + fact.setProviderEventId(providerEventId.trim()); fact.setProviderStatus(providerStatus.trim()); + fact.setExceptionCode(code.name()); fact.setActive(active); fact.setObservedAt(observedAt); + fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName)); + fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash); + return repository.insertIfAbsent(fact); + } + private static QueryView view(CarrierScheduleQueryEntity value, LocalDateTime now) { boolean expired = value.getLastSuccessAt() == null || value.getExpiresAt() == null || !value.getExpiresAt().isAfter(now); diff --git a/firstleg/src/main/resources/application.yml b/firstleg/src/main/resources/application.yml index 81cab7574..e3aa7af0b 100644 --- a/firstleg/src/main/resources/application.yml +++ b/firstleg/src/main/resources/application.yml @@ -25,6 +25,14 @@ spring: max-file-size: 5MB max-request-size: 6MB baiyue: + carrier-schedule: + providers: + matson-automated: ${MATSON_TRACKING_ENABLED:false} + sml-automated: ${SML_TRACKING_ENABLED:false} + zim-official-api-enabled: ${ZIM_TRACKING_ENABLED:false} + cosco-official-api-enabled: ${COSCO_OPEN_API_ENABLED:false} + emc-automated: ${EMC_TRACKING_ENABLED:false} + hede-automated: ${HEDE_TRACKING_ENABLED:false} security: jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} diff --git a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql index 5b58c4d67..c9062effe 100644 --- a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql +++ b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql @@ -12,11 +12,11 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( tracking_type VARCHAR(32) NOT NULL COMMENT '官网跟踪类型:BOOKING_NO订舱号、BILL_OF_LADING提单号、CONTAINER_NO箱号', tracking_no VARCHAR(128) NOT NULL COMMENT '提交至船公司官网的跟踪号码', official_url VARCHAR(500) NOT NULL COMMENT '船公司官网查询入口地址快照', - access_mode VARCHAR(32) NOT NULL COMMENT '官网访问方式:PUBLIC_FORM公开表单、HCAPTCHA人机校验、DYNAMIC_IFRAME动态页面', - status VARCHAR(24) NOT NULL COMMENT '任务状态:READY、MANUAL_REQUIRED、ARRIVED、PICKUP_READY、STOPPED', + access_mode VARCHAR(32) NOT NULL COMMENT '船司访问方式:PUBLIC_FORM、PUBLIC_WEB_PROXY、PUBLIC_HTML、HCAPTCHA、OFFICIAL_API', + status VARCHAR(24) NOT NULL COMMENT '任务操作状态:READY、MANUAL_REQUIRED、STOPPED', trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL', - latest_standard_node VARCHAR(32) NULL COMMENT '最近标准船节点', - latest_node_observed_at DATETIME(3) NULL COMMENT '最近标准节点观测时间', + latest_standard_node VARCHAR(32) NULL COMMENT '最近实际标准船节点;预计事实不推进', + latest_node_observed_at DATETIME(3) NULL COMMENT '最近实际标准节点发生时间', stopped_at DATETIME(3) NULL COMMENT '任务停止时间', stop_reason VARCHAR(500) NULL COMMENT '任务停止原因', lease_owner VARCHAR(64) NULL COMMENT '当前调度租约持有者', @@ -58,12 +58,13 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( CONSTRAINT chk_carrier_schedule_query_tracking CHECK (tracking_type IN ('BOOKING_NO','BILL_OF_LADING','CONTAINER_NO')), CONSTRAINT chk_carrier_schedule_query_access CHECK - (access_mode IN ('PUBLIC_FORM','HCAPTCHA','DYNAMIC_IFRAME')), + (access_mode IN ('PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API')), CONSTRAINT chk_carrier_schedule_query_status CHECK - (status IN ('READY','MANUAL_REQUIRED','ARRIVED','PICKUP_READY','STOPPED')), + (status IN ('READY','MANUAL_REQUIRED','STOPPED')), CONSTRAINT chk_carrier_schedule_query_node CHECK (latest_standard_node IS NULL OR latest_standard_node IN - ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED','PICKUP_READY')), + ('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')), CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3) ) ENGINE=InnoDB COMMENT='六家船公司官网查询任务与三天窗口频控'; @@ -74,7 +75,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt ( carrier_code VARCHAR(16) NOT NULL COMMENT '本次尝试的船公司编码快照', access_mode VARCHAR(32) NOT NULL COMMENT '本次尝试的官网访问方式快照', official_url VARCHAR(500) NOT NULL COMMENT '本次尝试使用的官网地址快照', - attempt_status VARCHAR(24) NOT NULL COMMENT '尝试结果:MANUAL_REQUIRED需人工处理、SUCCESS成功、FAILED失败', + attempt_status VARCHAR(24) NOT NULL COMMENT '供应商调用结果', coverage_gap TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否存在自动化覆盖缺口:0否、1是', coverage_gap_code VARCHAR(64) NULL COMMENT '自动化覆盖缺口编码', manual_reason VARCHAR(1000) NULL COMMENT '转人工处理原因', @@ -88,13 +89,13 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt ( CONSTRAINT fk_carrier_schedule_attempt_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), CONSTRAINT chk_carrier_schedule_attempt_no CHECK (window_attempt_no BETWEEN 1 AND 3), CONSTRAINT chk_carrier_schedule_attempt_status CHECK - (attempt_status IN ('MANUAL_REQUIRED','SUCCESS','FAILED')) + (attempt_status IN ('SUCCESS','NO_CHANGE','RETRYABLE_FAILURE','PERMANENT_FAILURE','MANUAL_REQUIRED')) ) ENGINE=InnoDB COMMENT='船司官网查询尝试不可变审计'; CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history ( id BIGINT UNSIGNED NOT NULL COMMENT '船司查询任务历史ID', query_id BIGINT UNSIGNED NOT NULL COMMENT '所属船司官网查询任务ID', - action VARCHAR(24) NOT NULL COMMENT '历史动作:CREATE创建、ATTEMPT执行查询、MANUAL_RESULT录入人工结果', + action VARCHAR(24) NOT NULL COMMENT '查询任务业务历史动作', from_version INT UNSIGNED NULL COMMENT '变更前任务版本;创建时为空', to_version INT UNSIGNED NOT NULL COMMENT '变更后任务版本', reason VARCHAR(1000) NULL COMMENT '操作原因或说明', @@ -107,7 +108,8 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history ( KEY idx_carrier_schedule_history_query (query_id,created_at,id), CONSTRAINT fk_carrier_schedule_history_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), CONSTRAINT chk_carrier_schedule_history_action CHECK - (action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','NODE_OBSERVED','PROVIDER_QUERY','STOP')) + (action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','MANUAL_NODE','NODE_OBSERVED', + 'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP')) ) ENGINE=InnoDB COMMENT='船司查询任务不可变历史'; CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact ( @@ -115,6 +117,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact ( query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID', event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键', provider_code VARCHAR(32) NOT NULL COMMENT '轨迹供应商编码', + provider_event_id VARCHAR(128) NOT NULL COMMENT '供应商稳定事件ID', provider_node_code VARCHAR(64) NOT NULL COMMENT '供应商原始节点编码', standard_node VARCHAR(32) NOT NULL COMMENT '标准船节点', node_sequence INT UNSIGNED NOT NULL COMMENT '标准节点单调序号', @@ -131,10 +134,35 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact ( KEY idx_carrier_schedule_node_timeline (query_id,node_sequence,observed_at,id), CONSTRAINT fk_carrier_schedule_node_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), CONSTRAINT chk_carrier_schedule_node_standard CHECK - (standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED','PICKUP_READY')), - CONSTRAINT chk_carrier_schedule_node_sequence CHECK (node_sequence IN (10,20,30,40,50,60)) + (standard_node IN ('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')), + CONSTRAINT chk_carrier_schedule_node_sequence CHECK (node_sequence IN (10,20,30,40,50,60,70,80,90,100)) ) ENGINE=InnoDB COMMENT='供应商标准船节点不可变事实'; +CREATE TABLE IF NOT EXISTS fl_carrier_schedule_exception_fact ( + id BIGINT UNSIGNED NOT NULL COMMENT '船节点异常事实ID', + query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID', + event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键', + provider_code VARCHAR(32) NOT NULL COMMENT '供应商编码', + provider_event_id VARCHAR(128) NOT NULL COMMENT '供应商稳定事件ID', + provider_status VARCHAR(128) NOT NULL COMMENT '供应商原始异常状态', + exception_code VARCHAR(32) NOT NULL COMMENT '平台异常编码', + active TINYINT(1) NOT NULL COMMENT '1异常发生;0异常解除', + observed_at DATETIME(3) NOT NULL COMMENT '供应商观测时间', + location_code VARCHAR(32) NULL COMMENT '地点编码', + location_name VARCHAR(128) NULL COMMENT '地点名称', + source_reference VARCHAR(500) NULL COMMENT '可审计来源引用', + raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', + PRIMARY KEY (id), + UNIQUE KEY uk_carrier_schedule_exception_event (query_id,event_key), + KEY idx_carrier_schedule_exception_timeline (query_id,observed_at,id), + CONSTRAINT fk_carrier_schedule_exception_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), + CONSTRAINT chk_carrier_schedule_exception_code CHECK + (exception_code IN ('CUSTOMS_HOLD','TERMINAL_HOLD','DOCUMENT_HOLD','UNKNOWN_HOLD')), + CONSTRAINT chk_carrier_schedule_exception_active CHECK (active IN (0,1)) +) ENGINE=InnoDB COMMENT='船节点异常发生与解除不可变事实'; + CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation ( id BIGINT UNSIGNED NOT NULL COMMENT '船司查询写操作幂等记录ID', query_id BIGINT UNSIGNED NULL COMMENT '关联查询任务ID;创建任务落库前允许为空', @@ -177,6 +205,8 @@ DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_update; DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_delete; DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_update; DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_delete; +DROP TRIGGER IF EXISTS trg_carrier_schedule_exception_before_update; +DROP TRIGGER IF EXISTS trg_carrier_schedule_exception_before_delete; DELIMITER $$ CREATE TRIGGER trg_carrier_schedule_attempt_before_update BEFORE UPDATE ON fl_carrier_schedule_query_attempt FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询尝试不可修改'; END$$ @@ -190,6 +220,10 @@ CREATE TRIGGER trg_carrier_schedule_node_before_update BEFORE UPDATE ON fl_carri FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可修改'; END$$ CREATE TRIGGER trg_carrier_schedule_node_before_delete BEFORE DELETE ON fl_carrier_schedule_node_fact FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可删除'; END$$ +CREATE TRIGGER trg_carrier_schedule_exception_before_update BEFORE UPDATE ON fl_carrier_schedule_exception_fact +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船节点异常事实不可修改'; END$$ +CREATE TRIGGER trg_carrier_schedule_exception_before_delete BEFORE DELETE ON fl_carrier_schedule_exception_fact +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船节点异常事实不可删除'; END$$ DELIMITER ; USE baiyue_iam; @@ -253,7 +287,7 @@ BEGIN WHERE table_schema='baiyue_firstleg' AND table_name IN ('fl_carrier_schedule_query','fl_carrier_schedule_query_attempt', 'fl_carrier_schedule_query_history','fl_carrier_schedule_query_operation', - 'fl_carrier_schedule_node_fact')) <> 5 THEN + 'fl_carrier_schedule_node_fact','fl_carrier_schedule_exception_fact')) <> 6 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务表未完整创建'; END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission diff --git a/firstleg/src/main/resources/openapi/carrier-schedule-internal-v1.yaml b/firstleg/src/main/resources/openapi/carrier-schedule-internal-v1.yaml deleted file mode 100644 index 30ecf0cd5..000000000 --- a/firstleg/src/main/resources/openapi/carrier-schedule-internal-v1.yaml +++ /dev/null @@ -1,23 +0,0 @@ -openapi: 3.0.3 -info: - title: Carrier Schedule Internal Handoff API - version: 1.0.0 - description: Internal-only handoff contract; it is not a platform or merchant frontend API. -paths: - /internal/firstleg/v1/carrier-schedule-queries/{queryId}/stop: - post: - summary: Stop tracking after the overseas pickup handoff has completed - parameters: - - {name: queryId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} - - {name: X-Internal-Service-Token, in: header, required: true, schema: {type: string}} - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [reason] - properties: - reason: {type: string, minLength: 1, maxLength: 500} - responses: - '200': {description: Idempotent STOPPED task detail} diff --git a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml index 46a1b3371..1788868a2 100644 --- a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml +++ b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: Carrier Schedule Query API version: 1.0.0 - description: Phase-4 carrier tracking from domestic outbound confirmation through destination pickup readiness. + description: Phase-4 carrier tracking from domestic outbound confirmation through actual empty return. paths: /api/platform/v1/carrier-schedule-queries/carriers: get: @@ -43,18 +43,21 @@ paths: summary: Get task, standardized node facts and immutable business history x-permission: platform:firstleg:port-schedule-query:list responses: - '200': {description: Stable task detail} + '200': + description: Stable task detail + content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}} /api/platform/v1/carrier-schedule-queries/{queryId}/history: parameters: [{$ref: '#/components/parameters/QueryId'}] get: summary: Get immutable task history x-permission: platform:firstleg:port-schedule-query:list responses: - '200': {description: CREATE, ATTEMPT and MANUAL_RESULT history} + '200': {description: Immutable business history; scheduler attempts are not exposed} /api/platform/v1/carrier-schedule-queries/{queryId}/manual-results: parameters: [{$ref: '#/components/parameters/QueryId'}] post: summary: Link a validated manual port-schedule observation without pretending automated success + description: This legacy port-schedule association does not append a standard node and never stops tracking; phase-4 manual facts use /manual-nodes. x-permission: platform:firstleg:port-schedule-query:manual parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] requestBody: @@ -63,7 +66,24 @@ paths: application/json: schema: {$ref: '#/components/schemas/ManualResultRequest'} responses: - '200': {description: Successful manual fallback and next query time} + '200': + description: Successful manual fallback and next query time + content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}} + /api/platform/v1/carrier-schedule-queries/{queryId}/manual-nodes: + parameters: [{$ref: '#/components/parameters/QueryId'}] + post: + summary: Append one immutable manual estimated or actual standard-node fact + x-permission: platform:firstleg:port-schedule-query:manual + parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ManualNodeRequest'} + responses: + '200': + description: Idempotent fact append; only actualAt advances and actual EMPTY_RETURNED stops + content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}} components: parameters: QueryId: {name: queryId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} @@ -71,8 +91,11 @@ components: schemas: CarrierCode: {type: string, enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]} TrackingType: {type: string, enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]} - QueryStatus: {type: string, enum: [READY, MANUAL_REQUIRED, ARRIVED, PICKUP_READY, STOPPED]} - StandardNode: {type: string, enum: [DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED, PICKUP_READY]} + QueryStatus: {type: string, enum: [READY, MANUAL_REQUIRED, STOPPED]} + StandardNode: + type: string + enum: [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] + ExceptionCode: {type: string, enum: [CUSTOMS_HOLD, TERMINAL_HOLD, DOCUMENT_HOLD, UNKNOWN_HOLD]} CreateRequest: type: object required: [expectedVersion, customerId, oceanBookingId, carrierCode, trackingType, trackingNo] @@ -92,9 +115,112 @@ components: expectedVersion: {type: integer, minimum: 0} portScheduleId: {type: integer, format: int64, minimum: 1} reason: {type: string, minLength: 1, maxLength: 1000} + ManualNodeRequest: + type: object + required: [expectedVersion, standardNode, observedAt, reason] + description: estimatedAt and actualAt cannot both be null; estimatedAt never advances the latest actual node. + properties: + expectedVersion: {type: integer, minimum: 0} + standardNode: {$ref: '#/components/schemas/StandardNode'} + observedAt: {type: string, format: date-time} + estimatedAt: {type: string, format: date-time, nullable: true} + actualAt: {type: string, format: date-time, nullable: true} + locationCode: {type: string, maxLength: 32, nullable: true} + locationName: {type: string, maxLength: 128, nullable: true} + reason: {type: string, minLength: 1, maxLength: 1000} + Task: + type: object + required: [id, queryNo, customerId, oceanBookingId, carrierCode, trackingType, trackingNo, status, triggerSource, version] + properties: + id: {type: integer, format: int64} + queryNo: {type: string} + customerId: {type: integer, format: int64} + oceanBookingId: {type: integer, format: int64} + containerBookingId: {type: integer, format: int64, nullable: true} + carrierCode: {$ref: '#/components/schemas/CarrierCode'} + trackingType: {$ref: '#/components/schemas/TrackingType'} + trackingNo: {type: string} + officialUrl: {type: string, nullable: true} + accessMode: {type: string, nullable: true} + status: {$ref: '#/components/schemas/QueryStatus'} + triggerSource: {type: string} + latestActualNode: {allOf: [{$ref: '#/components/schemas/StandardNode'}], nullable: true} + latestActualAt: {type: string, format: date-time, nullable: true} + lastSuccessAt: {type: string, format: date-time, nullable: true} + sourceUpdatedAt: {type: string, format: date-time, nullable: true} + expiresAt: {type: string, format: date-time, nullable: true} + resultPortScheduleId: {type: integer, format: int64, nullable: true} + manualReason: {type: string, nullable: true} + lastErrorCode: {type: string, nullable: true} + lastErrorMessage: {type: string, nullable: true} + stoppedAt: {type: string, format: date-time, nullable: true} + stopReason: {type: string, nullable: true} + version: {type: integer, minimum: 0} QueryView: type: object required: [query, expired] properties: - query: {type: object, description: Includes triggerSource, latestStandardNode, latestNodeObservedAt, source freshness and stop fields; scheduler leases and raw attempts are never exposed} + query: {$ref: '#/components/schemas/Task'} expired: {type: boolean} + NodeFact: + type: object + required: [id, providerCode, providerEventId, providerNodeCode, standardNode, nodeSequence, observedAt, rawPayloadHash, createdAt] + properties: + id: {type: integer, format: int64} + providerCode: {type: string, maxLength: 32} + providerEventId: {type: string, maxLength: 128} + providerNodeCode: {type: string, maxLength: 64} + standardNode: {$ref: '#/components/schemas/StandardNode'} + nodeSequence: {type: integer, enum: [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]} + observedAt: {type: string, format: date-time} + estimatedAt: {type: string, format: date-time, nullable: true} + actualAt: {type: string, format: date-time, nullable: true} + locationCode: {type: string, maxLength: 32, nullable: true} + locationName: {type: string, maxLength: 128, nullable: true} + sourceReference: {type: string, maxLength: 500, nullable: true} + rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'} + createdAt: {type: string, format: date-time} + ExceptionFact: + type: object + required: [id, providerCode, providerEventId, providerStatus, exceptionCode, active, observedAt, rawPayloadHash, createdAt] + properties: + id: {type: integer, format: int64} + providerCode: {type: string, maxLength: 32} + providerEventId: {type: string, maxLength: 128} + providerStatus: {type: string, maxLength: 128} + exceptionCode: {$ref: '#/components/schemas/ExceptionCode'} + active: {type: boolean, description: true means hold occurred; false means released} + observedAt: {type: string, format: date-time} + locationCode: {type: string, maxLength: 32, nullable: true} + locationName: {type: string, maxLength: 128, nullable: true} + sourceReference: {type: string, maxLength: 500, nullable: true} + rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'} + createdAt: {type: string, format: date-time} + History: + type: object + required: [id, action, createdAt] + properties: + id: {type: integer, format: int64} + action: {type: string, enum: [CREATE, AUTO_CREATE, MANUAL_RESULT, MANUAL_NODE, NODE_OBSERVED, EXCEPTION_OBSERVED, PROVIDER_QUERY]} + fromVersion: {type: integer, nullable: true} + toVersion: {type: integer, nullable: true} + reason: {type: string, nullable: true} + operatorId: {type: integer, format: int64, nullable: true} + operatorName: {type: string, nullable: true} + createdAt: {type: string, format: date-time} + Detail: + type: object + required: [query, nodes, exceptions, histories] + description: Scheduler lease, attempt and internal execution fields are intentionally absent. + properties: + query: {$ref: '#/components/schemas/QueryView'} + nodes: {type: array, items: {$ref: '#/components/schemas/NodeFact'}} + exceptions: {type: array, items: {$ref: '#/components/schemas/ExceptionFact'}} + histories: {type: array, items: {$ref: '#/components/schemas/History'}} + DetailEnvelope: + type: object + required: [code, data] + properties: + code: {type: string, example: '200'} + message: {type: string, nullable: true} + data: {$ref: '#/components/schemas/Detail'} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java index d8546b86e..563955f74 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java @@ -15,10 +15,17 @@ class CarrierScheduleQueryOpenApiContractTest { assertThat(yaml) .contains("/api/platform/v1/carrier-schedule-queries:") .contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-results:") + .contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-nodes:") + .contains("x-permission: platform:firstleg:port-schedule-query:manual") .contains("enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]") - .contains("DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED, PICKUP_READY") - .contains("MANUAL_REQUIRED") - .contains("scheduler leases and raw attempts are never exposed") + .contains("LOADED_ON_VESSEL, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED_DESTINATION_PORT") + .contains("DISCHARGED_FROM_VESSEL, PICKUP_READY, PICKED_UP, EMPTY_RETURNED") + .contains("CUSTOMS_HOLD, TERMINAL_HOLD, DOCUMENT_HOLD, UNKNOWN_HOLD") + .contains("enum: [READY, MANUAL_REQUIRED, STOPPED]") + .contains("latestActualNode:") + .contains("latestActualAt:") + .contains("internal execution fields are intentionally absent") + .doesNotContain("enum: [READY, MANUAL_REQUIRED, ARRIVED") .doesNotContain("/{queryId}/attempts:") .doesNotContain("port-schedule-query:attempt"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java index 1fb6f87e7..b113eaaac 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java @@ -22,13 +22,14 @@ class PlatformCarrierScheduleQueryControllerContractTest { "detail", "platform:firstleg:port-schedule-query:list", "history", "platform:firstleg:port-schedule-query:list", "create", "platform:firstleg:port-schedule-query:create", - "manualResult", "platform:firstleg:port-schedule-query:manual"); + "manualResult", "platform:firstleg:port-schedule-query:manual", + "manualNode", "platform:firstleg:port-schedule-query:manual"); permissions.forEach((name, permission) -> { Method method = Arrays.stream(PlatformCarrierScheduleQueryController.class.getDeclaredMethods()) .filter(candidate -> candidate.getName().equals(name)).findFirst().orElseThrow(); assertThat(method.getAnnotation(PreAuthorize.class).value()) .isEqualTo("hasAuthority('" + permission + "')"); - if (name.equals("create") || name.equals("manualResult")) { + if (name.equals("create") || name.equals("manualResult") || name.equals("manualNode")) { assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull(); } }); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtosTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtosTest.java new file mode 100644 index 000000000..df038e684 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtosTest.java @@ -0,0 +1,14 @@ +package com.baiyue.firstleg.portschedule.dto; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class CarrierScheduleQueryDtosTest { + @Test + void omittedPageAndSizeBindToDocumentedDefaults() { + var query = new CarrierScheduleQueryDtos.Query(null, null, null, null, null, null, null, null, null); + assertThat(query.page()).isEqualTo(1L); + assertThat(query.size()).isEqualTo(20L); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java index ddaae29ea..ffb9c84fd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java @@ -18,15 +18,25 @@ class CarrierScheduleQuerySqlContractTest { .contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history") .contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation") .contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact") + .contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_exception_fact") .contains("attempt_count BETWEEN 0 AND 3") .contains("window_attempt_no BETWEEN 1 AND 3") .contains("UNIQUE KEY uk_carrier_schedule_operation (operator_id,idempotency_key)") .contains("船司查询尝试不可修改") .contains("船司查询历史不可删除") .contains("标准船节点事实不可修改") - .contains("ARRIVED','PICKUP_READY','STOPPED") + .contains("船节点异常事实不可修改") + .contains("status IN ('READY','MANUAL_REQUIRED','STOPPED')") + .contains("LOADED_ON_VESSEL") + .contains("ARRIVED_DESTINATION_PORT") + .contains("DISCHARGED_FROM_VESSEL") + .contains("PICKED_UP','EMPTY_RETURNED") + .contains("CUSTOMS_HOLD','TERMINAL_HOLD','DOCUMENT_HOLD','UNKNOWN_HOLD") + .contains("provider_event_id") + .contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'") .contains("lease_owner") - .contains("consecutive_failures"); + .contains("consecutive_failures") + .doesNotContain("status IN ('READY','MANUAL_REQUIRED','ARRIVED'"); } @Test @@ -36,7 +46,8 @@ class CarrierScheduleQuerySqlContractTest { .contains("allocation.container_booking_id=NEW.container_booking_id") .contains("id IN (5102184,5102186,5102188)") .contains("platform:firstleg:port-schedule-query:manual") - .contains("'platform:firstleg:port-schedule-query:attempt'") + .contains("permission.permission_code IN ('platform:firstleg:port-schedule-query:view'") + .doesNotContain("'platform:firstleg:port-schedule-query:attempt','执行船司查询'") .contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')") .contains("船司查询任务角色授权未完整创建"); } @@ -50,7 +61,9 @@ class CarrierScheduleQuerySqlContractTest { .contains("uk_carrier_schedule_query_scope") .contains("id IN (5102184,5102186,5102188)") .contains("assert_carrier_schedule_query_schema") - .contains("船司查询任务角色授权未完整创建"); + .contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'") + .contains("船司查询任务角色授权未完整创建") + .doesNotContain("'platform:firstleg:port-schedule-query:attempt','执行船司查询'"); } private static String moduleSql() throws Exception { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistryTest.java index b7fc97fda..04f896a2b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistryTest.java @@ -20,16 +20,18 @@ class CarrierScheduleAdapterRegistryTest { } @Test - void returnsStructuredManualRequirementsForCaptchaAndDynamicPage() { + void returnsStructuredManualRequirementsForCaptchaAndPendingOfficialApiCredentials() { var zim = registry.prepare("zim", "BILL_OF_LADING", "GAOU6898053"); var cosco = registry.prepare("COSCO", "CONTAINER_NO", "C123"); assertThat(zim.status()).isEqualTo("MANUAL_REQUIRED"); - assertThat(zim.accessMode()).isEqualTo("HCAPTCHA"); - assertThat(zim.coverageGapCode()).isEqualTo("HCAPTCHA_REQUIRED"); - assertThat(zim.officialUrl()).contains("consnumber=GAOU6898053"); - assertThat(cosco.accessMode()).isEqualTo("DYNAMIC_IFRAME"); - assertThat(cosco.coverageGapCode()).isEqualTo("DYNAMIC_IFRAME_REQUIRED"); + assertThat(zim.accessMode()).isEqualTo("OFFICIAL_API"); + assertThat(zim.coverageGapCode()).isEqualTo("OFFICIAL_API_CREDENTIALS_REQUIRED"); + assertThat(zim.officialUrl()).isEqualTo("https://api.zim.com/"); + assertThat(cosco.status()).isEqualTo("MANUAL_REQUIRED"); + assertThat(cosco.accessMode()).isEqualTo("OFFICIAL_API"); + assertThat(cosco.coverageGapCode()).isEqualTo("OFFICIAL_API_CREDENTIALS_REQUIRED"); + assertThat(cosco.officialUrl()).isEqualTo("https://cop.lines.coscoshipping.com/copPortal/"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java index c54b003d3..6b4c92c6e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java @@ -26,16 +26,20 @@ class CarrierScheduleQuerySchedulerTest { task.setCarrierCode("COSCO"); task.setTrackingType("CONTAINER_NO"); task.setTrackingNo("CSNU1234567"); LocalDateTime now = LocalDateTime.of(2026, 8, 20, 12, 0); when(repository.claimLease(anyLong(), anyString(), any(), any())).thenReturn(true); - var observation = new CarrierScheduleIntegrationClient.Observation("evt-1", "DISCHARGED", "ARRIVED", + var observation = new CarrierScheduleIntegrationClient.Observation("evt-1", "DISCHARGED", "DISCHARGED_FROM_VESSEL", now, null, now, "USLAX", "Los Angeles", "provider:evt-1", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + var exception = new CarrierScheduleIntegrationClient.ExceptionObservation("hold-1", "CUSTOMS HOLD", + "CUSTOMS_HOLD", true, now, "USLAX", "Los Angeles", "provider:hold-1", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); when(integration.query(any(), any())).thenReturn(new CarrierScheduleIntegrationClient.Envelope<>("200", null, - new CarrierScheduleIntegrationClient.QueryResult("TRACK_API", "SUCCESS", List.of(observation), + new CarrierScheduleIntegrationClient.QueryResult("TRACK_API", "SUCCESS", List.of(observation), List.of(exception), null, null, now))); scheduler.execute(task, now); verify(service).applyProviderObservation(anyLong(), any()); + verify(service).applyProviderException(anyLong(), any()); verify(service).completeScheduledAttempt(1L, "SUCCESS", null, null); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java index 2673fe3dc..9930d54c5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java @@ -5,8 +5,12 @@ import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEn import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderExceptionRequest; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleExceptionFactEntity; import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity; import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository; import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository; @@ -15,7 +19,9 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.ArgumentCaptor; import org.mockito.MockitoAnnotations; +import org.springframework.test.util.ReflectionTestUtils; import java.time.Clock; import java.time.Instant; @@ -28,6 +34,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -47,6 +54,7 @@ class CarrierScheduleQueryServiceTest { when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString())).thenReturn(1); when(repository.nodes(anyLong())).thenReturn(List.of()); + when(repository.exceptions(anyLong())).thenReturn(List.of()); when(repository.histories(anyLong())).thenReturn(List.of()); } @@ -86,7 +94,7 @@ class CarrierScheduleQueryServiceTest { } @Test - void providerObservationAdvancesToArrivedWithoutExposingAttempts() { + void providerActualObservationAdvancesBusinessNodeWithoutChangingTaskStatus() { CarrierScheduleQueryEntity task = task(0, 0); task.setLatestStandardNode("IN_TRANSIT"); when(repository.lock(1L)).thenReturn(task); @@ -96,14 +104,172 @@ class CarrierScheduleQueryServiceTest { when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); var result = service.applyProviderObservation(1L, new ProviderObservationRequest("TRACK_API", "evt-1", - "DISCHARGED", "ARRIVED", NOW, null, NOW, "USLAX", "Los Angeles", "provider:evt-1", + "DISCHARGED", "ARRIVED_DESTINATION_PORT", NOW, null, NOW, "USLAX", "Los Angeles", "provider:evt-1", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); - assertThat(result.query().query().status()).isEqualTo("ARRIVED"); - assertThat(result.query().query().latestStandardNode()).isEqualTo("ARRIVED"); + assertThat(result.query().query().status()).isEqualTo("READY"); + assertThat(result.query().query().latestActualNode()).isEqualTo("ARRIVED_DESTINATION_PORT"); assertThat(result.nodes()).isEmpty(); } + @Test + void estimatedObservationIsSavedWithoutAdvancingActualNode() { + CarrierScheduleQueryEntity task = task(0, 0); + task.setStatus("READY"); task.setLatestStandardNode("LOADED_ON_VESSEL"); + when(repository.lock(1L)).thenReturn(task); + when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class))) + .thenReturn(true); + + service.applyProviderObservation(1L, new ProviderObservationRequest("HEDE", "eta-1", "PRE_ARRIVAL", + "ARRIVED_DESTINATION_PORT", NOW, NOW.plusDays(10), null, null, null, "HEDE:X", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); + + assertThat(task.getLatestStandardNode()).isEqualTo("LOADED_ON_VESSEL"); + verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString()); + } + + @Test + void lateActualFactIsSavedButCannotRegressLatestNode() { + CarrierScheduleQueryEntity task = task(0, 5); + task.setStatus("READY"); task.setLatestStandardNode("PICKED_UP"); + when(repository.lock(1L)).thenReturn(task); + when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class))) + .thenReturn(true); + + service.applyProviderObservation(1L, new ProviderObservationRequest("HEDE", "late-1", "DISCHARGED", + "DISCHARGED_FROM_VESSEL", NOW, null, NOW.minusDays(2), null, null, "HEDE:X", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")); + + assertThat(task.getLatestStandardNode()).isEqualTo("PICKED_UP"); + verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString()); + } + + @Test + void onlyActualEmptyReturnedStopsTask() { + CarrierScheduleQueryEntity task = task(0, 2); + task.setStatus("READY"); task.setLatestStandardNode("PICKED_UP"); + when(repository.lock(1L)).thenReturn(task); when(repository.find(1L)).thenReturn(task); + when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class))) + .thenReturn(true); + when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); + + service.applyProviderObservation(1L, new ProviderObservationRequest("HEDE", "return-1", "EMPTY_RETURNED", + "EMPTY_RETURNED", NOW, null, NOW, null, null, "HEDE:X", + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc")); + + assertThat(task.getStatus()).isEqualTo("STOPPED"); + assertThat(task.getStopReason()).isEqualTo("已还柜"); + assertThat(task.getNextAllowedAt()).isNull(); + } + + @Test + void manualActualEmptyReturnedUsesExistingPermissionFlowAndStopsIdempotently() { + CarrierScheduleQueryEntity task = task(0, 3); + task.setStatus("READY"); task.setLatestStandardNode("PICKED_UP"); + when(repository.lock(1L)).thenReturn(task); when(repository.find(1L)).thenReturn(task); + when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class))) + .thenReturn(true); + when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); + + var result = service.addManualNode(1L, 7L, "admin", "manual-empty-1", + new ManualNodeRequest(3, "EMPTY_RETURNED", NOW, null, NOW, null, "Los Angeles", "人工确认还柜")); + + assertThat(result.query().query().status()).isEqualTo("STOPPED"); + assertThat(result.query().query().latestActualNode()).isEqualTo("EMPTY_RETURNED"); + verify(repository).completeOperation(eq(1L), eq(7L), eq("manual-empty-1"), anyString()); + } + + @Test + void holdIsPersistedAsSeparateImmutableExceptionFact() { + CarrierScheduleQueryEntity task = task(0, 1); + when(repository.lock(1L)).thenReturn(task); + when(repository.insertIfAbsent(any(CarrierScheduleExceptionFactEntity.class))).thenReturn(true); + + service.applyProviderException(1L, new ProviderExceptionRequest("MATSON_PUBLIC_TRACKING", "hold-1", + "CONTAINER ON HOLD - Customs Clearance", "CUSTOMS_HOLD", true, NOW, "USLAX", + "Long Beach", "MATSON:9974749", + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd")); + + ArgumentCaptor fact = + ArgumentCaptor.forClass(CarrierScheduleExceptionFactEntity.class); + verify(repository).insertIfAbsent(fact.capture()); + assertThat(fact.getValue().getExceptionCode()).isEqualTo("CUSTOMS_HOLD"); + assertThat(fact.getValue().getActive()).isTrue(); + assertThat(task.getLatestStandardNode()).isNull(); + } + + @Test + void everySchedulerCompletionWritesImmutableAttempt() { + CarrierScheduleQueryEntity task = task(0, 0); + task.setStatus("READY"); + when(repository.lock(1L)).thenReturn(task); when(repository.find(1L)).thenReturn(task); + when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); + + service.completeScheduledAttempt(1L, "NO_CHANGE", null, null); + + ArgumentCaptor attempt = + ArgumentCaptor.forClass(CarrierScheduleQueryAttemptEntity.class); + verify(repository).insert(attempt.capture()); + assertThat(attempt.getValue().getAttemptStatus()).isEqualTo("NO_CHANGE"); + assertThat(attempt.getValue().getOperatorName()).isEqualTo("carrier-scheduler"); + assertThat(task.getStatus()).isEqualTo("READY"); + } + + @Test + void matsonIsAdvertisedAsAutomatedOnlyWhenDeploymentEnablesIt() { + CarrierScheduleAdapterRegistry registry = new CarrierScheduleAdapterRegistry(); + assertThat(registry.descriptors()).filteredOn(value -> "MATSON".equals(value.carrierCode())) + .singleElement().satisfies(value -> { + assertThat(value.automated()).isFalse(); + assertThat(value.coverageGap()).isTrue(); + }); + + ReflectionTestUtils.setField(registry, "matsonAutomated", true); + + assertThat(registry.descriptors()).filteredOn(value -> "MATSON".equals(value.carrierCode())) + .singleElement().satisfies(value -> { + assertThat(value.automated()).isTrue(); + assertThat(value.coverageGap()).isFalse(); + assertThat(value.accessMode()).isEqualTo("PUBLIC_WEB_PROXY"); + }); + + ReflectionTestUtils.setField(registry, "hedeAutomated", true); + + assertThat(registry.descriptors()).filteredOn(value -> "HEDE".equals(value.carrierCode())) + .singleElement().satisfies(value -> { + assertThat(value.automated()).isTrue(); + assertThat(value.coverageGap()).isFalse(); + assertThat(value.accessMode()).isEqualTo("PUBLIC_HTML"); + }); + + ReflectionTestUtils.setField(registry, "smlAutomated", true); + + assertThat(registry.descriptors()).filteredOn(value -> "SML".equals(value.carrierCode())) + .singleElement().satisfies(value -> { + assertThat(value.automated()).isTrue(); + assertThat(value.coverageGap()).isFalse(); + assertThat(value.accessMode()).isEqualTo("PUBLIC_FORM"); + }); + + ReflectionTestUtils.setField(registry, "zimOfficialApiEnabled", true); + + assertThat(registry.descriptors()).filteredOn(value -> "ZIM".equals(value.carrierCode())) + .singleElement().satisfies(value -> { + assertThat(value.automated()).isTrue(); + assertThat(value.coverageGap()).isFalse(); + assertThat(value.accessMode()).isEqualTo("OFFICIAL_API"); + }); + + ReflectionTestUtils.setField(registry, "emcAutomated", true); + + assertThat(registry.descriptors()).filteredOn(value -> "EMC".equals(value.carrierCode())) + .singleElement().satisfies(value -> { + assertThat(value.automated()).isTrue(); + assertThat(value.coverageGap()).isFalse(); + assertThat(value.accessMode()).isEqualTo("PUBLIC_FORM"); + }); + } + @Test void manualResultMustBelongToSameCustomerBookingAndContainer() { CarrierScheduleQueryEntity task = task(1, 1); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index 5cd6b548c..5e0c86875 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -149,7 +149,7 @@ class Phase3FinalUnifiedSqlContractTest { .contains("存在缺失中文注释的业务表") .contains("存在缺失中文注释的业务字段") .doesNotContain("CREATE PROCEDURE upgrade_column_comment"); - assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(151); + assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(152); assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2459); assertThat(sql) .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'") diff --git a/integration-service/AGENTS.md b/integration-service/AGENTS.md index b049ac582..232328dc0 100644 --- a/integration-service/AGENTS.md +++ b/integration-service/AGENTS.md @@ -11,7 +11,7 @@ - 内部接口:`controller/IntegrationProviderController.java`、`WeightOcrController.java` - OCR 编排:`service/ocr/WeightOcrBatchService.java`、`ImageCropper.java`、`WeightCandidateSelector.java` - 供应商适配:`provider/IntegrationProviderAdapter.java`、`BaiduOcrAdapter.java`、`AmazonLogisticsAdapter.java` -- 四期船节点外部适配:内部契约 `controller/IntegrationProviderController.java` 的 `/internal/integrations/carrier-schedules/query`,统一 DTO `dto/CarrierScheduleDtos.java`,provider SPI `provider/CarrierScheduleProviderAdapter.java`,标准节点 `domain/CarrierScheduleStandardNode.java`;轮询任务、租约、订舱/柜次关联、最新节点和节点历史归 `firstleg/portschedule/`,范围见 `../../docs/四期/README.md` +- 四期船节点外部适配:内部契约 `controller/IntegrationProviderController.java` 的 `/internal/integrations/carrier-schedules/query`,统一十节点与四类异常 DTO `dto/CarrierScheduleDtos.java`,provider SPI `provider/CarrierScheduleProviderAdapter.java`,标准枚举 `domain/CarrierScheduleStandardNode.java`、`CarrierScheduleExceptionCode.java`;美森网页会话、动态 `ttr` 与公开查询代理适配见 `provider/MatsonTrackingClient.java`、`MatsonCarrierScheduleProviderAdapter.java`,由 `MATSON_TRACKING_ENABLED` 显式启用;SML 的 122→125→124→126 JSON 调用链与 A/E 时间解析见 `provider/SmlTrackingClient.java`、`SmlCarrierScheduleProviderAdapter.java`,由 `SML_TRACKING_ENABLED` 显式启用;ZIM 正式 Tracing API 的 OAuth2 token 缓存、401 单次刷新及柜号活动解析见 `provider/ZimTrackingClient.java`、`ZimCarrierScheduleProviderAdapter.java`,凭据只从外部 secret 注入并由 `ZIM_TRACKING_ENABLED` 显式启用;长荣柜号→关联提单两段 UTF-8 HTML 查询、表头定位与日粒度事实解析见 `provider/EvergreenTrackingClient.java`、`EvergreenCarrierScheduleProviderAdapter.java`,由 `EMC_TRACKING_ENABLED` 显式启用且不输出完整提单号;合德 UTF-8 HTML 主表解析见 `provider/HedeTrackingClient.java`、`HedeCarrierScheduleProviderAdapter.java`,由 `HEDE_TRACKING_ENABLED` 显式启用且当前只自动解析柜号;轮询任务、租约、订舱/柜次关联、最新实际节点、节点/异常事实和历史归 `firstleg/portschedule/`,标准见 `../../docs/四期/船节点标准化契约.md` - 安全:`security/InternalServiceTokenGuard.java`;配置 `application.yml`、`../config/nacos/{local,cn}/integration-service.yml` - 测试:`src/test/java/com/baiyue/integration/`;`mvn -pl integration-service -am test` diff --git a/integration-service/src/main/java/com/baiyue/integration/config/IntegrationProperties.java b/integration-service/src/main/java/com/baiyue/integration/config/IntegrationProperties.java index 7ad1b09aa..0a4f88a6c 100644 --- a/integration-service/src/main/java/com/baiyue/integration/config/IntegrationProperties.java +++ b/integration-service/src/main/java/com/baiyue/integration/config/IntegrationProperties.java @@ -8,7 +8,8 @@ import java.nio.file.Path; import java.time.Duration; @ConfigurationProperties(prefix = "baiyue.integration") -public record IntegrationProperties(BaiduOcr baiduOcr, AmazonSpApi amazonSpApi) { +public record IntegrationProperties(BaiduOcr baiduOcr, AmazonSpApi amazonSpApi, + CarrierSchedule carrierSchedule) { public record BaiduOcr(boolean enabled, String apiKey, String secretKey, @@ -84,6 +85,136 @@ public record IntegrationProperties(BaiduOcr baiduOcr, AmazonSpApi amazonSpApi) } } + public record CarrierSchedule(Matson matson, Hede hede, Sml sml, Zim zim, Evergreen evergreen) { + public record Matson(boolean enabled, + URI pageUrl, + URI queryUrl, + Duration connectTimeout, + Duration requestTimeout, + Duration minRequestInterval) { + public boolean configured() { + return enabled && pageUrl != null && queryUrl != null; + } + + public Duration effectiveConnectTimeout() { + return positive(connectTimeout) ? connectTimeout : Duration.ofSeconds(10); + } + + public Duration effectiveRequestTimeout() { + return positive(requestTimeout) ? requestTimeout : Duration.ofSeconds(30); + } + + public Duration effectiveMinRequestInterval() { + return minRequestInterval != null && !minRequestInterval.isNegative() + ? minRequestInterval : Duration.ofSeconds(5); + } + + private static boolean positive(Duration value) { + return value != null && !value.isNegative() && !value.isZero(); + } + } + + public record Hede(boolean enabled, + URI queryUrl, + Duration connectTimeout, + Duration requestTimeout, + Duration minRequestInterval) { + public boolean configured() { return enabled && queryUrl != null; } + + public Duration effectiveConnectTimeout() { + return positive(connectTimeout) ? connectTimeout : Duration.ofSeconds(10); + } + + public Duration effectiveRequestTimeout() { + return positive(requestTimeout) ? requestTimeout : Duration.ofSeconds(30); + } + + public Duration effectiveMinRequestInterval() { + return minRequestInterval != null && !minRequestInterval.isNegative() + ? minRequestInterval : Duration.ofSeconds(5); + } + + private static boolean positive(Duration value) { + return value != null && !value.isNegative() && !value.isZero(); + } + } + + public record Sml(boolean enabled, + URI queryUrl, + Duration connectTimeout, + Duration requestTimeout, + Duration minRequestInterval) { + public boolean configured() { return enabled && queryUrl != null; } + + public Duration effectiveConnectTimeout() { + return positive(connectTimeout) ? connectTimeout : Duration.ofSeconds(10); + } + + public Duration effectiveRequestTimeout() { + return positive(requestTimeout) ? requestTimeout : Duration.ofSeconds(30); + } + + public Duration effectiveMinRequestInterval() { + return minRequestInterval != null && !minRequestInterval.isNegative() + ? minRequestInterval : Duration.ofSeconds(5); + } + + private static boolean positive(Duration value) { + return value != null && !value.isNegative() && !value.isZero(); + } + } + + public record Zim(boolean enabled, + URI authorizationUrl, + URI tracingBaseUrl, + String clientId, + String clientSecret, + String subscriptionKey, + Duration connectTimeout, + Duration requestTimeout, + int maxConcurrency) { + public boolean configured() { + return enabled && authorizationUrl != null && tracingBaseUrl != null + && hasText(clientId) && hasText(clientSecret) && hasText(subscriptionKey); + } + + public Duration effectiveConnectTimeout() { + return positive(connectTimeout) ? connectTimeout : Duration.ofSeconds(10); + } + + public Duration effectiveRequestTimeout() { + return positive(requestTimeout) ? requestTimeout : Duration.ofSeconds(30); + } + + public int effectiveMaxConcurrency() { return Math.max(1, Math.min(10, maxConcurrency)); } + + private static boolean positive(Duration value) { + return value != null && !value.isNegative() && !value.isZero(); + } + } + + public record Evergreen(boolean enabled, + URI queryUrl, + Duration connectTimeout, + Duration requestTimeout, + Duration minRequestInterval) { + public boolean configured() { return enabled && queryUrl != null; } + public Duration effectiveConnectTimeout() { + return positive(connectTimeout) ? connectTimeout : Duration.ofSeconds(10); + } + public Duration effectiveRequestTimeout() { + return positive(requestTimeout) ? requestTimeout : Duration.ofSeconds(30); + } + public Duration effectiveMinRequestInterval() { + return minRequestInterval != null && !minRequestInterval.isNegative() + ? minRequestInterval : Duration.ofSeconds(5); + } + private static boolean positive(Duration value) { + return value != null && !value.isNegative() && !value.isZero(); + } + } + } + private static boolean hasText(String value) { return value != null && !value.isBlank(); } diff --git a/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleExceptionCode.java b/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleExceptionCode.java new file mode 100644 index 000000000..f8e956b08 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleExceptionCode.java @@ -0,0 +1,15 @@ +package com.baiyue.integration.domain; + +import java.util.Locale; + +public enum CarrierScheduleExceptionCode { + CUSTOMS_HOLD, + TERMINAL_HOLD, + DOCUMENT_HOLD, + UNKNOWN_HOLD; + + public static boolean supports(String value) { + try { valueOf(value == null ? "" : value.trim().toUpperCase(Locale.ROOT)); return true; } + catch (IllegalArgumentException exception) { return false; } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleStandardNode.java b/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleStandardNode.java index 430ec27fc..8edc347d9 100644 --- a/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleStandardNode.java +++ b/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleStandardNode.java @@ -5,10 +5,14 @@ import java.util.Locale; public enum CarrierScheduleStandardNode { DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, + LOADED_ON_VESSEL, DEPARTED_ORIGIN_PORT, IN_TRANSIT, - ARRIVED, - PICKUP_READY; + ARRIVED_DESTINATION_PORT, + DISCHARGED_FROM_VESSEL, + PICKUP_READY, + PICKED_UP, + EMPTY_RETURNED; public static boolean supports(String value) { try { valueOf(value == null ? "" : value.trim().toUpperCase(Locale.ROOT)); return true; } diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/CarrierScheduleDtos.java b/integration-service/src/main/java/com/baiyue/integration/dto/CarrierScheduleDtos.java index 4d94ac5ae..7692a0919 100644 --- a/integration-service/src/main/java/com/baiyue/integration/dto/CarrierScheduleDtos.java +++ b/integration-service/src/main/java/com/baiyue/integration/dto/CarrierScheduleDtos.java @@ -14,10 +14,16 @@ public final class CarrierScheduleDtos { @NotBlank @Size(max = 128) String trackingNo) { } public record QueryResult(String providerCode, String outcome, List observations, + List exceptions, String errorCode, String errorMessage, LocalDateTime providerObservedAt) { } public record Observation(String providerEventId, String providerNodeCode, String standardNode, LocalDateTime observedAt, LocalDateTime estimatedAt, LocalDateTime actualAt, String locationCode, String locationName, String sourceReference, String rawPayloadHash) { } + + public record ExceptionObservation(String providerEventId, String providerStatus, + String exceptionCode, boolean active, LocalDateTime observedAt, + String locationCode, String locationName, String sourceReference, + String rawPayloadHash) { } } diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/CompliantCarrierScheduleProviderAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/CompliantCarrierScheduleProviderAdapter.java index 9a4f219a2..d3f4db74a 100644 --- a/integration-service/src/main/java/com/baiyue/integration/provider/CompliantCarrierScheduleProviderAdapter.java +++ b/integration-service/src/main/java/com/baiyue/integration/provider/CompliantCarrierScheduleProviderAdapter.java @@ -4,6 +4,8 @@ import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; import com.baiyue.integration.dto.ProviderStatus; import org.springframework.stereotype.Component; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; import java.time.LocalDateTime; import java.util.List; @@ -15,6 +17,7 @@ import java.util.Set; * an explicit provider-unavailable outcome until a documented provider API adapter is configured. */ @Component +@Order(Ordered.LOWEST_PRECEDENCE) public class CompliantCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter { private static final Set CARRIERS = Set.of("MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE"); @@ -25,11 +28,11 @@ public class CompliantCarrierScheduleProviderAdapter implements CarrierScheduleP @Override public QueryResult query(QueryRequest request) { - return new QueryResult("COMPLIANT_MANUAL_FALLBACK", "MANUAL_REQUIRED", List.of(), + return new QueryResult("COMPLIANT_MANUAL_FALLBACK", "MANUAL_REQUIRED", List.of(), List.of(), "PROVIDER_API_NOT_CONFIGURED", "未配置可审计的结构化轨迹供应商接口,不执行网页抓取", LocalDateTime.now()); } @Override - public ProviderStatus status() { return ProviderStatus.of("CARRIER_SCHEDULE", true, false); } + public ProviderStatus status() { return ProviderStatus.of("CARRIER_SCHEDULE_FALLBACK", true, false); } } diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapter.java new file mode 100644 index 000000000..ebcca60ee --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapter.java @@ -0,0 +1,202 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.Observation; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; +import com.baiyue.integration.dto.ProviderStatus; +import com.baiyue.integration.provider.EvergreenTrackingClient.EvergreenTrackingException; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.util.HtmlUtils; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Component +@Order(8) +public class EvergreenCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter { + private static final String PROVIDER = "EVERGREEN_PUBLIC_HTML"; + private static final String LOADED = "LOADED (FCL) ON VESSEL"; + private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("MMM-dd-uuuu", Locale.ENGLISH); + private static final Pattern TABLE = Pattern.compile("(?is)]*>(.*?)"); + private static final Pattern ROW = Pattern.compile("(?is)]*>(.*?)"); + private static final Pattern CELL = Pattern.compile("(?is)]*>(.*?)"); + private static final Pattern COUNTRY_CODE = Pattern.compile("\\(([A-Z]{2})\\)\\s*$"); + private static final Set REQUIRED_HEADERS = Set.of("箱号", "柜型", "日期", "货柜动态", "地点", "船名航次", "METHOD", "VGM"); + private final EvergreenTrackingClient client; + + public EvergreenCarrierScheduleProviderAdapter(EvergreenTrackingClient client) { this.client = client; } + + @Override + public boolean supports(String carrierCode) { + return client.configured() && carrierCode != null && "EMC".equals(carrierCode.trim().toUpperCase(Locale.ROOT)); + } + + @Override + public QueryResult query(QueryRequest request) { + LocalDateTime queriedAt = LocalDateTime.now(ZoneOffset.UTC); + try { + EvergreenTrackingClient.TrackingResponse response = client.query(request); + List observations = observations(request.trackingNo(), response, queriedAt); + return new QueryResult(PROVIDER, observations.isEmpty() ? "NO_CHANGE" : "SUCCESS", observations, + List.of(), null, null, queriedAt); + } catch (EvergreenTrackingException exception) { + return new QueryResult(PROVIDER, exception.outcome(), List.of(), List.of(), exception.code(), + exception.getMessage(), queriedAt); + } catch (EvergreenParseException exception) { + return new QueryResult(PROVIDER, "MANUAL_REQUIRED", List.of(), List.of(), exception.code, + exception.getMessage(), queriedAt); + } + } + + List observations(String containerNo, EvergreenTrackingClient.TrackingResponse response, + LocalDateTime queriedAt) { + if (response == null) return List.of(); + String container = normalize(containerNo); + TrackingRow row = trackingRow(response.containerHtml(), container); + if (row == null) throw new EvergreenParseException("EMC_CONTAINER_ROW_NOT_FOUND", "长荣 HTML 未找到目标柜行"); + String rawHash = sha256(response.rawBody() == null + ? safe(response.containerHtml()) + safe(response.billHtml()) : response.rawBody()); + List facts = new ArrayList<>(); + if (LOADED.equals(normalizeText(row.status()))) { + LocalDate date = parseDay(row.date()); + if (date == null) throw new EvergreenParseException("EMC_DAY_PARSE_FAILED", "长荣货柜动态日期无法解析"); + add(facts, container, "LOADED_FCL_ON_VESSEL", "LOADED_ON_VESSEL", queriedAt, date, + false, row.location(), row.vesselVoyage(), response.billSuffix(), rawHash); + } + String estimatedRaw = estimatedLoadingDate(response.billHtml()); + LocalDate estimated = parseDay(estimatedRaw); + if (estimated != null) { + add(facts, container, "ESTIMATED_LOADING_DATE", "LOADED_ON_VESSEL", queriedAt, estimated, + true, row.location(), row.vesselVoyage(), response.billSuffix(), rawHash); + } + facts.sort(Comparator.comparing(value -> value.actualAt() == null ? value.estimatedAt() : value.actualAt())); + return List.copyOf(facts); + } + + static String standardNode(String status) { + return LOADED.equals(normalizeText(status)) ? "LOADED_ON_VESSEL" : null; + } + + private static void add(List facts, String container, String providerNode, String standardNode, + LocalDateTime queriedAt, LocalDate day, boolean estimated, String location, + String vesselVoyage, String billSuffix, String rawHash) { + LocalDateTime eventAt = day.atStartOfDay(); + String rawDate = day.format(DAY).toUpperCase(Locale.ENGLISH); + String reference = "EMC:CNTR:" + container + "|BL_SUFFIX:" + safe(billSuffix) + + "|DATE:" + rawDate + "|PRECISION:DAY|ZONE:Asia/Shanghai"; + String eventId = sha256(container + '|' + providerNode + '|' + rawDate + '|' + + normalizeText(location) + '|' + normalizeText(vesselVoyage)); + facts.add(new Observation(eventId, providerNode, standardNode, queriedAt, + estimated ? eventAt : null, estimated ? null : eventAt, countryCode(location), blank(location), + reference, rawHash)); + } + + static TrackingRow trackingRow(String html, String targetContainer) { + Matcher tables = TABLE.matcher(html == null ? "" : html); + while (tables.find()) { + List> rows = rows(tables.group(1)); + for (int headerIndex = 0; headerIndex < rows.size(); headerIndex++) { + Map header = header(rows.get(headerIndex)); + if (!header.keySet().containsAll(REQUIRED_HEADERS)) continue; + for (int index = headerIndex + 1; index < rows.size(); index++) { + List cells = rows.get(index); + String container = cell(cells, header.get("箱号")); + if (normalize(container).equals(targetContainer)) { + return new TrackingRow(container, cell(cells, header.get("日期")), + cell(cells, header.get("货柜动态")), cell(cells, header.get("地点")), + cell(cells, header.get("船名航次"))); + } + } + } + } + return null; + } + + static String estimatedLoadingDate(String html) { + for (List row : allRows(html)) { + for (int index = 0; index + 1 < row.size(); index++) { + String label = row.get(index).replaceAll("\\s+", ""); + if ("预计装船日".equals(label) || "預計裝船日".equals(label)) return row.get(index + 1).trim(); + } + } + return null; + } + + private static List> allRows(String html) { + List> values = new ArrayList<>(); Matcher tables = TABLE.matcher(html == null ? "" : html); + while (tables.find()) values.addAll(rows(tables.group(1))); return values; + } + private static List> rows(String table) { + List> values = new ArrayList<>(); Matcher rows = ROW.matcher(table); + while (rows.find()) { + List cells = new ArrayList<>(); Matcher cell = CELL.matcher(rows.group(1)); + while (cell.find()) cells.add(htmlText(cell.group(1))); if (!cells.isEmpty()) values.add(List.copyOf(cells)); + } + return values; + } + private static Map header(List cells) { + Map values = new HashMap<>(); + for (int index = 0; index < cells.size(); index++) values.put(normalizeHeader(cells.get(index)), index); + return values; + } + private static String normalizeHeader(String value) { + String text = value == null ? "" : value.replaceAll("\\s+", "").trim(); + return text.chars().allMatch(ch -> ch < 128) ? text.toUpperCase(Locale.ROOT) : text; + } + private static String cell(List cells, Integer index) { + return index == null || index < 0 || index >= cells.size() ? "" : cells.get(index).trim(); + } + private static String htmlText(String value) { + return HtmlUtils.htmlUnescape(value.replaceAll("(?is)<[^>]+>", " ")).replace('\u00a0', ' ') + .replaceAll("\\s+", " ").trim(); + } + private static LocalDate parseDay(String value) { + if (value == null || value.isBlank()) return null; + try { return LocalDate.parse(value.trim(), DAY); } + catch (DateTimeParseException exception) { return null; } + } + private static String countryCode(String location) { + Matcher matcher = COUNTRY_CODE.matcher(location == null ? "" : location.trim().toUpperCase(Locale.ROOT)); + return matcher.find() ? matcher.group(1) : null; + } + private static String normalizeText(String value) { + return value == null ? "" : HtmlUtils.htmlUnescape(value).trim().replaceAll("\\s+", " ").toUpperCase(Locale.ROOT); + } + private static String normalize(String value) { + return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT); + } + private static String blank(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static String safe(String value) { return value == null ? "" : value; } + private static String sha256(String value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); } + catch (Exception exception) { throw new IllegalStateException(exception); } + } + + record TrackingRow(String containerNo, String date, String status, String location, String vesselVoyage) { } + private static final class EvergreenParseException extends RuntimeException { + private final String code; + private EvergreenParseException(String code, String message) { super(message); this.code = code; } + } + + @Override + public ProviderStatus status() { + return ProviderStatus.of("CARRIER_SCHEDULE_EMC", client.configured(), client.configured()); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/EvergreenTrackingClient.java b/integration-service/src/main/java/com/baiyue/integration/provider/EvergreenTrackingClient.java new file mode 100644 index 000000000..30eed87f4 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/EvergreenTrackingClient.java @@ -0,0 +1,151 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; +import org.springframework.web.util.HtmlUtils; + +import java.io.IOException; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Component +public class EvergreenTrackingClient { + private static final Pattern INPUT = Pattern.compile("(?is)]*>"); + private static final Pattern ATTRIBUTE = Pattern.compile("(?i)\\b([a-z_:][-a-z0-9_:.]*)\\s*=\\s*([\"'])(.*?)\\2"); + private static final Pattern DISP_INFO = Pattern.compile("(?is)GetDispInfo[^0-9]{0,160}(\\d{12})"); + private static final Pattern BL_QUERY = Pattern.compile("(?i)(?:[?&]|&)(?:BL|bl_no)=(\\d{12})"); + private final Object requestLock = new Object(); + @Resource private IntegrationProperties properties; + private IntegrationProperties.CarrierSchedule.Evergreen config; + private HttpClient httpClient; + private long nextRequestAtMillis; + + @PostConstruct + void initialize() { + config = properties.carrierSchedule() == null ? null : properties.carrierSchedule().evergreen(); + httpClient = HttpClient.newBuilder() + .connectTimeout(config == null ? Duration.ofSeconds(10) : config.effectiveConnectTimeout()) + .followRedirects(HttpClient.Redirect.NORMAL).build(); + } + + public boolean configured() { return config != null && config.configured(); } + + public TrackingResponse query(QueryRequest request) { + if (!configured()) manual("EMC_NOT_CONFIGURED", "长荣公开货柜动态查询未启用"); + if (!"CONTAINER_NO".equalsIgnoreCase(request.trackingType())) { + manual("EMC_CONTAINER_REQUIRED", "长荣首批自动节点仅支持柜号查询"); + } + synchronized (requestLock) { + waitForSlot(); + try { + String container = normalize(request.trackingNo()); + String containerHtml = post("TYPE=CNTR&CNTR=" + encode(container), "CONTAINER"); + String bill = relatedBill(containerHtml); + if (bill == null) manual("EMC_RELATED_BL_MISSING", "长荣柜号结果缺少关联提单,需人工复核"); + String billHtml = post("TYPE=BL&BL=" + encode(bill), "BILL"); + return new TrackingResponse(containerHtml, billHtml, bill.substring(bill.length() - 4), + containerHtml + billHtml); + } finally { + nextRequestAtMillis = System.currentTimeMillis() + config.effectiveMinRequestInterval().toMillis(); + } + } + } + + private String post(String form, String stage) { + try { + HttpResponse response = httpClient.send(HttpRequest.newBuilder(config.queryUrl()) + .timeout(config.effectiveRequestTimeout()).header("Accept", "text/html") + .header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + .POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)).build(), + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() == 429 || response.statusCode() >= 500) { + throw retryable("EMC_" + stage + "_HTTP_" + response.statusCode(), "长荣公开查询暂时不可用", null); + } + if (response.statusCode() < 200 || response.statusCode() >= 300) { + manual("EMC_" + stage + "_HTTP_" + response.statusCode(), "长荣公开查询拒绝请求"); + } + if (!looksLikeHtml(response.body())) manual("EMC_" + stage + "_NON_HTML", "长荣返回非 HTML 内容"); + return response.body(); + } catch (EvergreenTrackingException exception) { + throw exception; + } catch (HttpTimeoutException exception) { + throw retryable("EMC_" + stage + "_TIMEOUT", "长荣公开查询超时", exception); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw retryable("EMC_" + stage + "_INTERRUPTED", "长荣公开查询被中断", exception); + } catch (IOException exception) { + throw retryable("EMC_" + stage + "_IO_FAILED", "长荣公开查询网络失败", exception); + } + } + + static String relatedBill(String html) { + Matcher inputs = INPUT.matcher(html == null ? "" : html); + while (inputs.find()) { + String name = attribute(inputs.group(), "name"); + String value = HtmlUtils.htmlUnescape(attribute(inputs.group(), "value")); + if ("bl_no".equalsIgnoreCase(name) && value.matches("\\d{12}")) return value; + } + String decoded = HtmlUtils.htmlUnescape(html == null ? "" : html); + Matcher action = DISP_INFO.matcher(decoded); + if (action.find()) return action.group(1); + Matcher query = BL_QUERY.matcher(decoded); + return query.find() ? query.group(1) : null; + } + + private static String attribute(String tag, String expected) { + Matcher attributes = ATTRIBUTE.matcher(tag); + while (attributes.find()) if (expected.equalsIgnoreCase(attributes.group(1))) return attributes.group(3).trim(); + return ""; + } + private static boolean looksLikeHtml(String value) { + String lower = value == null ? "" : value.toLowerCase(Locale.ROOT); + return lower.contains("]"; + } + } + public static final class EvergreenTrackingException extends RuntimeException { + private final String outcome; private final String code; + public EvergreenTrackingException(String outcome, String code, String message) { + super(message); this.outcome = outcome; this.code = code; + } + public EvergreenTrackingException(String outcome, String code, String message, Throwable cause) { + super(message, cause); this.outcome = outcome; this.code = code; + } + public String outcome() { return outcome; } + public String code() { return code; } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/HedeCarrierScheduleProviderAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/HedeCarrierScheduleProviderAdapter.java new file mode 100644 index 000000000..4206d504b --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/HedeCarrierScheduleProviderAdapter.java @@ -0,0 +1,161 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.Observation; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; +import com.baiyue.integration.dto.ProviderStatus; +import com.baiyue.integration.provider.HedeTrackingClient.HedeTrackingException; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Component +@Order(10) +public class HedeCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter { + private static final String PROVIDER = "HEDE_PUBLIC_HTML"; + private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + private static final Pattern ROW_PATTERN = Pattern.compile( + "]*class=[\\\"'][^\\\"']*read-tr[^\\\"']*[\\\"'][^>]*>([\\s\\S]*?)", + Pattern.CASE_INSENSITIVE); + private static final Pattern CELL_PATTERN = Pattern.compile("]*>([\\s\\S]*?)", + Pattern.CASE_INSENSITIVE); + private final HedeTrackingClient client; + + public HedeCarrierScheduleProviderAdapter(HedeTrackingClient client) { this.client = client; } + + @Override + public boolean supports(String carrierCode) { + return client.configured() && carrierCode != null && "HEDE".equals(carrierCode.trim().toUpperCase(Locale.ROOT)); + } + + @Override + public QueryResult query(QueryRequest request) { + LocalDateTime queriedAt = LocalDateTime.now(ZoneOffset.UTC); + if (!"CONTAINER_NO".equalsIgnoreCase(request.trackingType())) { + return new QueryResult(PROVIDER, "PERMANENT_FAILURE", List.of(), List.of(), "HEDE_CONTAINER_REQUIRED", + "合德自动节点当前仅支持按柜号精确解析,提单可能返回多个柜子", queriedAt); + } + try { + HedeTrackingClient.TrackingResponse response = client.query(request); + List observations = observations(request.trackingNo(), response.html(), queriedAt); + return new QueryResult(PROVIDER, observations.isEmpty() ? "NO_CHANGE" : "SUCCESS", observations, List.of(), + null, null, queriedAt); + } catch (HedeTrackingException exception) { + return new QueryResult(PROVIDER, exception.retryable() ? "RETRYABLE_FAILURE" : "PERMANENT_FAILURE", + List.of(), List.of(), exception.code(), exception.getMessage(), queriedAt); + } catch (Exception exception) { + return new QueryResult(PROVIDER, "PERMANENT_FAILURE", List.of(), List.of(), "HEDE_HTML_SCHEMA_CHANGED", + "合德查询 HTML 结构无法解析", queriedAt); + } + } + + List observations(String containerNo, String html) throws Exception { + return observations(containerNo, html, LocalDateTime.now(ZoneOffset.UTC)); + } + + private List observations(String containerNo, String html, LocalDateTime queriedAt) throws Exception { + String normalizedContainer = normalizeIdentifier(containerNo); + List matched = rows(html).stream() + .filter(value -> normalizeIdentifier(value.containerNo()).equals(normalizedContainer)).toList(); + if (matched.isEmpty()) return List.of(); + String rawHash = sha256(html); + List values = new ArrayList<>(); + for (HedeRow row : matched) { + String reference = "HEDE:" + row.containerNo() + ':' + row.vessel() + '/' + row.voyage(); + addEstimated(values, row, "ESTIMATED_DEPARTURE", "DEPARTED_ORIGIN_PORT", + row.estimatedDeparture(), queriedAt, reference, rawHash); + addEstimated(values, row, "ESTIMATED_ARRIVAL", "ARRIVED_DESTINATION_PORT", + row.estimatedArrival(), queriedAt, reference, rawHash); + addActual(values, row, "FULL_GATE_IN", "AT_ORIGIN_PORT", row.fullGateIn(), queriedAt, reference, rawHash); + addActual(values, row, "LOADED", "LOADED_ON_VESSEL", row.loadedAt(), queriedAt, reference, rawHash); + addActual(values, row, "DISCHARGED", "DISCHARGED_FROM_VESSEL", row.dischargedAt(), queriedAt, reference, rawHash); + addActual(values, row, "FULL_GATE_OUT", "PICKED_UP", row.fullGateOut(), queriedAt, reference, rawHash); + addActual(values, row, "EMPTY_RETURNED", "EMPTY_RETURNED", row.emptyReturnedAt(), queriedAt, reference, rawHash); + } + values.sort(Comparator.comparing(Observation::observedAt)); + return List.copyOf(values); + } + + private static void addActual(List values, HedeRow row, String providerNode, + String standardNode, String timestamp, LocalDateTime queriedAt, + String reference, String rawHash) { + LocalDateTime actualAt = parseDate(timestamp); + if (actualAt == null) return; + String eventIdentity = row.containerNo() + '|' + providerNode + '|' + timestamp; + values.add(new Observation(sha256(eventIdentity), providerNode, standardNode, queriedAt, + null, actualAt, null, null, reference, rawHash)); + } + + private static void addEstimated(List values, HedeRow row, String providerNode, + String standardNode, String timestamp, LocalDateTime queriedAt, + String reference, String rawHash) { + LocalDateTime estimatedAt = parseDate(timestamp); + if (estimatedAt == null) return; + String eventIdentity = row.containerNo() + '|' + providerNode + '|' + timestamp; + values.add(new Observation(sha256(eventIdentity), providerNode, standardNode, queriedAt, + estimatedAt, null, null, null, reference, rawHash)); + } + + static List rows(String html) { + List rows = new ArrayList<>(); + Matcher rowMatcher = ROW_PATTERN.matcher(html == null ? "" : html); + while (rowMatcher.find()) { + List cells = new ArrayList<>(); + Matcher cellMatcher = CELL_PATTERN.matcher(rowMatcher.group(1)); + while (cellMatcher.find()) cells.add(htmlText(cellMatcher.group(1))); + if (cells.size() >= 12) rows.add(row(cells)); + } + return List.copyOf(rows); + } + + private static String htmlText(String value) { + return value.replaceAll("(?is)<[^>]+>", " ") + .replace(" ", " ").replace("&", "&").replace("<", "<").replace(">", ">") + .replace(""", "\"").replace("'", "'").trim().replaceAll("\\s+", " "); + } + + private static HedeRow row(List cells) { + return new HedeRow(cells.get(0), cells.get(1), cells.get(2), cells.get(3), cells.get(4), cells.get(5), + cells.get(6), cells.get(7), cells.get(8), cells.get(9), cells.get(10), cells.get(11)); + } + + private static LocalDateTime parseDate(String value) { + if (value == null || value.isBlank()) return null; + try { return LocalDateTime.parse(value.trim(), DATE_TIME); } + catch (DateTimeParseException exception) { return null; } + } + + private static String normalizeIdentifier(String value) { + return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT); + } + + private static String sha256(String value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (Exception exception) { throw new IllegalStateException(exception); } + } + + @Override + public ProviderStatus status() { + return ProviderStatus.of("CARRIER_SCHEDULE_HEDE", client.configured(), client.configured()); + } + + record HedeRow(String billNo, String containerNo, String vessel, String voyage, + String estimatedDeparture, String estimatedArrival, String emptyGateOut, + String fullGateIn, String loadedAt, String dischargedAt, + String fullGateOut, String emptyReturnedAt) { } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/HedeTrackingClient.java b/integration-service/src/main/java/com/baiyue/integration/provider/HedeTrackingClient.java new file mode 100644 index 000000000..e223970f2 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/HedeTrackingClient.java @@ -0,0 +1,123 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.Locale; + +@Component +public class HedeTrackingClient { + private static final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + + @Resource private IntegrationProperties properties; + private IntegrationProperties.CarrierSchedule.Hede config; + private HttpClient httpClient; + private final Object requestLock = new Object(); + private long nextRequestAtMillis; + + @PostConstruct + void initialize() { + config = properties.carrierSchedule() == null ? null : properties.carrierSchedule().hede(); + httpClient = HttpClient.newBuilder() + .connectTimeout(config == null ? java.time.Duration.ofSeconds(10) : config.effectiveConnectTimeout()) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + } + + public boolean configured() { return config != null && config.configured(); } + + public TrackingResponse query(QueryRequest request) { + if (!configured()) { + throw new HedeTrackingException("HEDE_NOT_CONFIGURED", false, "合德网页查询能力未启用或地址未配置"); + } + synchronized (requestLock) { + waitForRequestSlot(); + try { + return execute(request); + } finally { + nextRequestAtMillis = System.currentTimeMillis() + + config.effectiveMinRequestInterval().toMillis(); + } + } + } + + private TrackingResponse execute(QueryRequest request) { + String trackingType = request.trackingType().trim().toUpperCase(Locale.ROOT); + String billNo = "BILL_OF_LADING".equals(trackingType) ? request.trackingNo().trim() : ""; + String containerNo = "CONTAINER_NO".equals(trackingType) ? request.trackingNo().trim() : ""; + if (billNo.isEmpty() && containerNo.isEmpty()) { + throw new HedeTrackingException("HEDE_TRACKING_TYPE_UNSUPPORTED", false, + "合德仅支持提单号或柜号查询"); + } + String form = "billno=" + encode(billNo) + "&cntr=" + encode(containerNo) + + "&selecttype=1&resultlist=1¶mtype=0"; + try { + HttpResponse response = httpClient.send(HttpRequest.newBuilder(config.queryUrl()) + .timeout(config.effectiveRequestTimeout()) + .header("User-Agent", USER_AGENT) + .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + .header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + .POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)).build(), + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + ensureHttp(response.statusCode()); + return new TrackingResponse(response.body()); + } catch (HedeTrackingException exception) { + throw exception; + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new HedeTrackingException("HEDE_INTERRUPTED", true, "合德查询被中断", exception); + } catch (IOException exception) { + throw new HedeTrackingException("HEDE_IO_FAILED", true, "合德查询网络失败", exception); + } + } + + private void waitForRequestSlot() { + long waitMillis = nextRequestAtMillis - System.currentTimeMillis(); + if (waitMillis <= 0) return; + try { + Thread.sleep(waitMillis); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new HedeTrackingException("HEDE_INTERRUPTED", true, "等待合德查询限流窗口时被中断", exception); + } + } + + private static void ensureHttp(int status) { + if (status >= 200 && status < 300) return; + if (status == 429) { + throw new HedeTrackingException("HEDE_RATE_LIMITED", true, "合德查询触发访问频率限制"); + } + throw new HedeTrackingException("HEDE_HTTP_" + status, status >= 500, + "合德查询返回异常 HTTP 状态:" + status); + } + + private static String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); } + + public record TrackingResponse(String html) { } + + public static final class HedeTrackingException extends RuntimeException { + private final String code; + private final boolean retryable; + + public HedeTrackingException(String code, boolean retryable, String message) { + super(message); this.code = code; this.retryable = retryable; + } + + public HedeTrackingException(String code, boolean retryable, String message, Throwable cause) { + super(message, cause); this.code = code; this.retryable = retryable; + } + + public String code() { return code; } + public boolean retryable() { return retryable; } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/MatsonCarrierScheduleProviderAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/MatsonCarrierScheduleProviderAdapter.java new file mode 100644 index 000000000..c78f41108 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/MatsonCarrierScheduleProviderAdapter.java @@ -0,0 +1,220 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.Observation; +import com.baiyue.integration.dto.CarrierScheduleDtos.ExceptionObservation; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; +import com.baiyue.integration.dto.ProviderStatus; +import com.baiyue.integration.provider.MatsonTrackingClient.MatsonTrackingException; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; + +@Component +@Order(0) +public class MatsonCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter { + private static final String PROVIDER = "MATSON_PUBLIC_TRACKING"; + private final MatsonTrackingClient client; + + public MatsonCarrierScheduleProviderAdapter(MatsonTrackingClient client) { + this.client = client; + } + + @Override + public boolean supports(String carrierCode) { + return client.configured() && carrierCode != null && "MATSON".equals(carrierCode.trim().toUpperCase(Locale.ROOT)); + } + + @Override + public QueryResult query(QueryRequest request) { + try { + MatsonTrackingClient.TrackingResponse response = client.query(request); + LocalDateTime queriedAt = LocalDateTime.now(ZoneOffset.UTC); + List observations = observations(request, response.payload(), response.rawBody(), queriedAt); + List exceptions = exceptions(request, response.payload(), response.rawBody(), queriedAt); + return new QueryResult(PROVIDER, observations.isEmpty() && exceptions.isEmpty() ? "NO_CHANGE" : "SUCCESS", + observations, exceptions, + null, null, queriedAt); + } catch (MatsonTrackingException exception) { + return new QueryResult(PROVIDER, exception.retryable() ? "RETRYABLE_FAILURE" : "PERMANENT_FAILURE", + List.of(), List.of(), exception.code(), exception.getMessage(), LocalDateTime.now(ZoneOffset.UTC)); + } + } + + List observations(QueryRequest request, JsonNode payload, String rawBody, + LocalDateTime queriedAt) { + if (payload == null || !payload.isObject()) return List.of(); + if ("CONTAINER_NO".equalsIgnoreCase(request.trackingType()) && !matchesContainer(request.trackingNo(), payload)) { + return List.of(); + } + String rawHash = sha256(rawBody == null ? payload.toString() : rawBody); + String sourceReference = "MATSON:" + text(payload, "bookingNumber", request.trackingNo()); + List values = new ArrayList<>(); + JsonNode events = payload.path("events"); + if (events.isArray()) for (JsonNode event : events) { + long epochMillis = event.path("date").asLong(0); + String rawStatus = text(event, "status", "").trim(); + String standardNode = standardNode(rawStatus); + if (epochMillis <= 0 || standardNode == null) continue; + LocalDateTime actualAt = LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZoneOffset.UTC); + String locationName = text(event, "location", "").trim(); + String locationCode = locationCode(payload, locationName, standardNode); + String eventIdentity = request.trackingNo().trim().toUpperCase(Locale.ROOT) + '|' + epochMillis + '|' + + normalizeStatus(rawStatus) + '|' + locationName.toUpperCase(Locale.ROOT); + values.add(new Observation(sha256(eventIdentity), truncate(normalizeStatus(rawStatus), 64), standardNode, + queriedAt, null, actualAt, blankToNull(locationCode), blankToNull(locationName), + sourceReference, rawHash)); + } + LocalDateTime eta = parseDate(payload.get("vesselETA")); + if (eta != null) { + String eventIdentity = request.trackingNo().trim().toUpperCase(Locale.ROOT) + "|VESSEL_ETA|" + eta; + values.add(new Observation(sha256(eventIdentity), "VESSEL_ETA", "ARRIVED_DESTINATION_PORT", + queriedAt, eta, null, blankToNull(text(payload, "destCode", "")), + blankToNull(text(payload, "destPort", "")), sourceReference, rawHash)); + } + values.sort(Comparator.comparing(Observation::observedAt)); + return List.copyOf(values); + } + + List exceptions(QueryRequest request, JsonNode payload, String rawBody, + LocalDateTime queriedAt) { + if (payload == null || !payload.isObject()) return List.of(); + if ("CONTAINER_NO".equalsIgnoreCase(request.trackingType()) && !matchesContainer(request.trackingNo(), payload)) { + return List.of(); + } + String rawHash = sha256(rawBody == null ? payload.toString() : rawBody); + String sourceReference = "MATSON:" + text(payload, "bookingNumber", request.trackingNo()); + List values = new ArrayList<>(); + JsonNode events = payload.path("events"); + if (!events.isArray()) return List.of(); + for (JsonNode event : events) { + String rawStatus = text(event, "status", "").trim(); + String status = normalizeStatus(rawStatus); + String exceptionCode = exceptionCode(status); + long epochMillis = event.path("date").asLong(0); + if (exceptionCode == null || epochMillis <= 0) continue; + String locationName = text(event, "location", "").trim(); + String identity = request.trackingNo().trim().toUpperCase(Locale.ROOT) + '|' + epochMillis + '|' + + status + '|' + locationName.toUpperCase(Locale.ROOT); + values.add(new ExceptionObservation(sha256(identity), truncate(rawStatus, 128), exceptionCode, true, + queriedAt, blankToNull(locationCode(payload, locationName, "PICKUP_READY")), + blankToNull(locationName), sourceReference, rawHash)); + } + values.sort(Comparator.comparing(ExceptionObservation::observedAt)); + return List.copyOf(values); + } + + static String standardNode(String status) { + String value = normalizeStatus(status); + return switch (value) { + case "INGATE" -> "AT_ORIGIN_PORT"; + case "LOAD TO VESSEL" -> "LOADED_ON_VESSEL"; + case "VESSEL DEPARTURE" -> "DEPARTED_ORIGIN_PORT"; + case "IN TRANSIT" -> "IN_TRANSIT"; + case "VESSEL ARRIVAL", "ARRIVED AT DESTINATION" -> "ARRIVED_DESTINATION_PORT"; + case "DISCHARGE FROM VESSEL" -> "DISCHARGED_FROM_VESSEL"; + case "AVAILABLE FOR PICKUP" -> "PICKUP_READY"; + case "OUTGATE" -> "PICKED_UP"; + case "EMPTY RETURN", "EMPTY RETURNED" -> "EMPTY_RETURNED"; + default -> null; + }; + } + + static String exceptionCode(String status) { + return "CONTAINER ON HOLD - CUSTOMS CLEARANCE".equals(normalizeStatus(status)) ? "CUSTOMS_HOLD" : null; + } + + private static boolean matchesContainer(String requested, JsonNode payload) { + String actual = text(payload, "containerNumber", "") + text(payload, "checkDigit", ""); + return normalizeIdentifier(requested).equals(normalizeIdentifier(actual)); + } + + private static String locationCode(JsonNode payload, String location, String standardNode) { + String normalized = location.toUpperCase(Locale.ROOT); + String originName = text(payload, "originPort", "").toUpperCase(Locale.ROOT); + String loadPort = text(payload, "loadPort", "").toUpperCase(Locale.ROOT); + String destinationName = text(payload, "destPort", "").toUpperCase(Locale.ROOT); + String dischargePort = text(payload, "dischargePort", "").toUpperCase(Locale.ROOT); + if (!normalized.isBlank() && (normalized.equals(originName) || normalized.equals(loadPort))) { + return text(payload, "originCode", ""); + } + if (!normalized.isBlank() && (normalized.equals(destinationName) || normalized.equals(dischargePort))) { + return text(payload, "destCode", ""); + } + if ("ARRIVED_DESTINATION_PORT".equals(standardNode) + || "DISCHARGED_FROM_VESSEL".equals(standardNode) || "PICKUP_READY".equals(standardNode) + || "PICKED_UP".equals(standardNode) || "EMPTY_RETURNED".equals(standardNode)) { + return text(payload, "destCode", ""); + } + return ""; + } + + private static String normalizeStatus(String value) { + return value == null ? "" : value.trim().replaceAll("\\s+", " ").toUpperCase(Locale.ROOT); + } + + private static String normalizeIdentifier(String value) { + return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT); + } + + private static String text(JsonNode node, String field, String fallback) { + JsonNode value = node.get(field); + return value == null || value.isNull() ? fallback : value.asText(fallback); + } + + private static LocalDateTime parseDate(JsonNode value) { + if (value == null || value.isNull()) return null; + if (value.isNumber()) { + long epoch = value.asLong(); + if (epoch <= 0) return null; + return LocalDateTime.ofInstant(Instant.ofEpochMilli(epoch < 10_000_000_000L ? epoch * 1000 : epoch), + ZoneOffset.UTC); + } + String text = value.asText("").trim(); + if (text.isEmpty()) return null; + try { return LocalDateTime.ofInstant(Instant.parse(text), ZoneOffset.UTC); } + catch (DateTimeParseException ignored) { } + for (DateTimeFormatter formatter : List.of(DateTimeFormatter.ISO_LOCAL_DATE_TIME, + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"), DateTimeFormatter.ofPattern("MM/dd/yyyy HH:mm"))) { + try { return LocalDateTime.parse(text, formatter); } + catch (DateTimeParseException ignored) { } + } + for (DateTimeFormatter formatter : List.of(DateTimeFormatter.ISO_LOCAL_DATE, + DateTimeFormatter.ofPattern("MM/dd/yy"), DateTimeFormatter.ofPattern("MM/dd/yyyy"))) { + try { return LocalDate.parse(text, formatter).atStartOfDay(); } + catch (DateTimeParseException ignored) { } + } + return null; + } + + private static String blankToNull(String value) { return value == null || value.isBlank() ? null : value; } + private static String truncate(String value, int max) { return value.length() <= max ? value : value.substring(0, max); } + + private static String sha256(String value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } + + @Override + public ProviderStatus status() { + return ProviderStatus.of("CARRIER_SCHEDULE_MATSON", client.configured(), client.configured()); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/MatsonTrackingClient.java b/integration-service/src/main/java/com/baiyue/integration/provider/MatsonTrackingClient.java new file mode 100644 index 000000000..470e86c68 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/MatsonTrackingClient.java @@ -0,0 +1,172 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.CookieManager; +import java.net.CookiePolicy; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Component +public class MatsonTrackingClient { + private static final Pattern TTR_PATTERN = Pattern.compile( + "id=[\\\"']ttr[\\\"'][^>]*value=[\\\"']([^\\\"']+)[\\\"']", Pattern.CASE_INSENSITIVE); + private static final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + + @Resource private IntegrationProperties properties; + @Resource private ObjectMapper objectMapper; + private IntegrationProperties.CarrierSchedule.Matson config; + private final Object requestLock = new Object(); + private long nextRequestAtMillis; + + @PostConstruct + void initialize() { + config = properties.carrierSchedule() == null ? null : properties.carrierSchedule().matson(); + } + + public boolean configured() { + return config != null && config.configured(); + } + + public TrackingResponse query(QueryRequest request) { + if (!configured()) { + throw new MatsonTrackingException("MATSON_NOT_CONFIGURED", false, + "美森公开查询能力未启用或地址未配置"); + } + synchronized (requestLock) { + waitForRequestSlot(); + try { + return execute(request); + } finally { + nextRequestAtMillis = System.currentTimeMillis() + + config.effectiveMinRequestInterval().toMillis(); + } + } + } + + private TrackingResponse execute(QueryRequest request) { + CookieManager cookies = new CookieManager(null, CookiePolicy.ACCEPT_ALL); + HttpClient client = HttpClient.newBuilder() + .cookieHandler(cookies) + .connectTimeout(config.effectiveConnectTimeout()) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + try { + HttpResponse page = client.send(HttpRequest.newBuilder(config.pageUrl()) + .timeout(config.effectiveRequestTimeout()) + .header("User-Agent", USER_AGENT) + .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + .header("Accept-Language", "en-US,en;q=0.9") + .GET().build(), + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + ensureHttp(page.statusCode(), "MATSON_PAGE"); + Matcher matcher = TTR_PATTERN.matcher(page.body()); + if (!matcher.find() || matcher.group(1).isBlank()) { + throw new MatsonTrackingException("MATSON_TTR_MISSING", true, + "美森查询页未返回动态校验参数"); + } + + String form = "container_trackingnumber=" + encode(request.trackingNo().trim()) + + "&trackingVal=" + trackingValue(request.trackingType()) + + "&ttr=" + encode(matcher.group(1)); + HttpResponse response = client.send(HttpRequest.newBuilder(config.queryUrl()) + .timeout(config.effectiveRequestTimeout()) + .header("User-Agent", USER_AGENT) + .header("Accept", "application/json, text/javascript, */*; q=0.01") + .header("Accept-Language", "en-US,en;q=0.9") + .header("Origin", origin(config.pageUrl())) + .header("Referer", config.pageUrl().toString()) + .header("X-Requested-With", "XMLHttpRequest") + .header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + .POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)).build(), + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + ensureHttp(response.statusCode(), "MATSON_QUERY"); + JsonNode payload = objectMapper.readTree(response.body()); + return new TrackingResponse(payload, response.body()); + } catch (MatsonTrackingException exception) { + throw exception; + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new MatsonTrackingException("MATSON_INTERRUPTED", true, "美森查询被中断", exception); + } catch (IOException exception) { + throw new MatsonTrackingException("MATSON_IO_FAILED", true, "美森查询网络或响应解析失败", exception); + } + } + + private void waitForRequestSlot() { + long waitMillis = nextRequestAtMillis - System.currentTimeMillis(); + if (waitMillis <= 0) return; + try { + Thread.sleep(waitMillis); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new MatsonTrackingException("MATSON_INTERRUPTED", true, "等待美森查询限流窗口时被中断", exception); + } + } + + private static void ensureHttp(int status, String stage) { + if (status >= 200 && status < 300) return; + if (status == 429) { + throw new MatsonTrackingException("MATSON_RATE_LIMITED", true, "美森查询触发访问频率限制"); + } + if (status == 403) { + throw new MatsonTrackingException("MATSON_SESSION_REJECTED", true, + "美森拒绝当前网页会话,请稍后重新获取动态参数"); + } + throw new MatsonTrackingException(stage + "_HTTP_" + status, status >= 500, + "美森查询返回异常 HTTP 状态:" + status); + } + + private static String trackingValue(String trackingType) { + return switch (trackingType.trim().toUpperCase(Locale.ROOT)) { + case "CONTAINER_NO" -> "tckconta"; + case "BOOKING_NO" -> "tckbok"; + case "BILL_OF_LADING" -> "tckbil"; + default -> throw new MatsonTrackingException("MATSON_TRACKING_TYPE_UNSUPPORTED", false, + "美森不支持该跟踪类型:" + trackingType); + }; + } + + private static String origin(URI uri) { + int port = uri.getPort(); + return uri.getScheme() + "://" + uri.getHost() + (port < 0 ? "" : ":" + port); + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + public record TrackingResponse(JsonNode payload, String rawBody) { } + + public static final class MatsonTrackingException extends RuntimeException { + private final String code; + private final boolean retryable; + + public MatsonTrackingException(String code, boolean retryable, String message) { + super(message); this.code = code; this.retryable = retryable; + } + + public MatsonTrackingException(String code, boolean retryable, String message, Throwable cause) { + super(message, cause); this.code = code; this.retryable = retryable; + } + + public String code() { return code; } + public boolean retryable() { return retryable; } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/SmlCarrierScheduleProviderAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/SmlCarrierScheduleProviderAdapter.java new file mode 100644 index 000000000..72af144ee --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/SmlCarrierScheduleProviderAdapter.java @@ -0,0 +1,154 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.Observation; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; +import com.baiyue.integration.dto.ProviderStatus; +import com.baiyue.integration.provider.SmlTrackingClient.SmlTrackingException; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@Component +@Order(5) +public class SmlCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter { + private static final String PROVIDER = "SML_PUBLIC_TRACKING"; + private static final Map NODE_MAP = Map.of( + "FOTMAD", "AT_ORIGIN_PORT", + "FLVMLO", "LOADED_ON_VESSEL", + "FLVMDO", "DEPARTED_ORIGIN_PORT", + "FUVMAD", "ARRIVED_DESTINATION_PORT", + "FUVMBD", "ARRIVED_DESTINATION_PORT", + "FUVMUD", "DISCHARGED_FROM_VESSEL", + "MITYAD", "EMPTY_RETURNED"); + private final SmlTrackingClient client; + + public SmlCarrierScheduleProviderAdapter(SmlTrackingClient client) { this.client = client; } + + @Override + public boolean supports(String carrierCode) { + return client.configured() && carrierCode != null && "SML".equals(carrierCode.trim().toUpperCase(Locale.ROOT)); + } + + @Override + public QueryResult query(QueryRequest request) { + LocalDateTime queriedAt = LocalDateTime.now(ZoneOffset.UTC); + try { + SmlTrackingClient.TrackingResponse response = client.query(request); + List observations = observations(request.trackingNo(), response, queriedAt); + return new QueryResult(PROVIDER, observations.isEmpty() ? "NO_CHANGE" : "SUCCESS", observations, + List.of(), null, null, queriedAt); + } catch (SmlTrackingException exception) { + return new QueryResult(PROVIDER, exception.retryable() ? "RETRYABLE_FAILURE" : "PERMANENT_FAILURE", + List.of(), List.of(), exception.code(), exception.getMessage(), queriedAt); + } + } + + List observations(String containerNo, SmlTrackingClient.TrackingResponse response) { + return observations(containerNo, response, LocalDateTime.now(ZoneOffset.UTC)); + } + + private List observations(String containerNo, SmlTrackingClient.TrackingResponse response, + LocalDateTime queriedAt) { + if (response == null || response.timeline() == null) return List.of(); + String rawHash = sha256(response.rawBody() == null ? response.timeline().toString() : response.rawBody()); + String reference = "SML:" + response.bookingNo() + ':' + response.copNo(); + Map facts = new LinkedHashMap<>(); + collect(response.timeline(), containerNo, reference, rawHash, queriedAt, facts); + return facts.values().stream().sorted(Comparator.comparing(Observation::observedAt)).toList(); + } + + private static void collect(JsonNode node, String containerNo, String reference, String rawHash, + LocalDateTime queriedAt, + Map facts) { + if (node == null) return; + if (node.isObject()) { + String providerNode = directText(node, "actStsMapgCd", "actCd", "actStsCd", "eventCode"); + String actType = directText(node, "actTpCd", "actualType"); + String standardNode = providerNode == null ? null : NODE_MAP.get(providerNode.toUpperCase(Locale.ROOT)); + LocalDateTime eventAt = parseDate(directText(node, "eventDt", "actDt", "skdDt", "eventDate", "date")); + if (standardNode != null && eventAt != null && ("A".equalsIgnoreCase(actType) || "E".equalsIgnoreCase(actType))) { + String locationCode = directText(node, "locCd", "locationCode", "portCd"); + String locationName = directText(node, "locNm", "locationName", "portNm"); + String identity = normalize(containerNo) + '|' + providerNode.toUpperCase(Locale.ROOT) + '|' + + actType.toUpperCase(Locale.ROOT) + '|' + eventAt + '|' + normalize(locationCode) + '|' + + normalize(locationName); + String eventId = sha256(identity); + boolean actual = "A".equalsIgnoreCase(actType); + facts.putIfAbsent(eventId, new Observation(eventId, providerNode.toUpperCase(Locale.ROOT), + standardNode, queriedAt, + actual ? null : eventAt, actual ? eventAt : null, blank(locationCode), blank(locationName), + reference, rawHash)); + } + Iterator children = node.elements(); + while (children.hasNext()) collect(children.next(), containerNo, reference, rawHash, queriedAt, facts); + } else if (node.isArray()) for (JsonNode child : node) { + collect(child, containerNo, reference, rawHash, queriedAt, facts); + } + } + + static String standardNode(String providerCode) { + return providerCode == null ? null : NODE_MAP.get(providerCode.trim().toUpperCase(Locale.ROOT)); + } + + private static String directText(JsonNode node, String... names) { + for (String name : names) { + Iterator> fields = node.fields(); + while (fields.hasNext()) { + var field = fields.next(); + if (field.getKey().equalsIgnoreCase(name) && field.getValue().isValueNode()) { + String value = field.getValue().asText("").trim(); + if (!value.isEmpty()) return value; + } + } + } + return null; + } + + private static LocalDateTime parseDate(String value) { + if (value == null || value.isBlank()) return null; + String text = value.trim(); + try { + long epoch = Long.parseLong(text); + return LocalDateTime.ofInstant(Instant.ofEpochMilli(epoch < 10_000_000_000L ? epoch * 1000 : epoch), + ZoneOffset.UTC); + } catch (NumberFormatException ignored) { } + try { return LocalDateTime.ofInstant(Instant.parse(text), ZoneOffset.UTC); } + catch (DateTimeParseException ignored) { } + for (DateTimeFormatter formatter : List.of(DateTimeFormatter.ISO_LOCAL_DATE_TIME, + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"), DateTimeFormatter.ofPattern("yyyyMMddHHmm"))) { + try { return LocalDateTime.parse(text, formatter); } + catch (DateTimeParseException ignored) { } + } + return null; + } + + private static String normalize(String value) { + return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT); + } + private static String blank(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static String sha256(String value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); } + catch (Exception exception) { throw new IllegalStateException(exception); } + } + + @Override + public ProviderStatus status() { return ProviderStatus.of("CARRIER_SCHEDULE_SML", client.configured(), client.configured()); } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/SmlTrackingClient.java b/integration-service/src/main/java/com/baiyue/integration/provider/SmlTrackingClient.java new file mode 100644 index 000000000..575b9a68c --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/SmlTrackingClient.java @@ -0,0 +1,165 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.URLEncoder; +import java.net.http.HttpTimeoutException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.Iterator; +import java.util.Locale; +import java.util.Map; + +@Component +public class SmlTrackingClient { + @Resource private IntegrationProperties properties; + @Resource private ObjectMapper objectMapper; + private IntegrationProperties.CarrierSchedule.Sml config; + private HttpClient httpClient; + private final Object requestLock = new Object(); + private long nextRequestAtMillis; + + @PostConstruct + void initialize() { + config = properties.carrierSchedule() == null ? null : properties.carrierSchedule().sml(); + httpClient = HttpClient.newBuilder() + .connectTimeout(config == null ? java.time.Duration.ofSeconds(10) : config.effectiveConnectTimeout()) + .followRedirects(HttpClient.Redirect.NORMAL).build(); + } + + public boolean configured() { return config != null && config.configured(); } + + public TrackingResponse query(QueryRequest request) { + if (!configured()) throw new SmlTrackingException("SML_NOT_CONFIGURED", false, "SML 查询能力未启用"); + if (!"CONTAINER_NO".equalsIgnoreCase(request.trackingType())) { + throw new SmlTrackingException("SML_CONTAINER_REQUIRED", false, "SML 自动节点只按柜号查询"); + } + synchronized (requestLock) { + waitForSlot(); + try { + String container = request.trackingNo().trim(); + JsonResponse lookup = call("f_cmd=122&cntr_no=" + encode(container) + "&cust_cd=", "LOOKUP"); + validateBusinessResult(lookup.payload()); + String bookingNo = findText(lookup.payload(), "bkgNo", "bkg_no"); + String copNo = findText(lookup.payload(), "copNo", "cop_no"); + if (bookingNo == null || copNo == null) { + throw new SmlTrackingException("SML_BUSINESS_KEYS_MISSING", false, + "SML 查询结果缺少 bkgNo 或 copNo"); + } + JsonResponse timeline = call("f_cmd=125&cntr_no=" + encode(container) + "&bkg_no=" + + encode(bookingNo) + "&cop_no=" + encode(copNo), "TIMELINE"); + validateBusinessResult(timeline.payload()); + JsonResponse schedule = call("f_cmd=124&bkg_no=" + encode(bookingNo), "SCHEDULE"); + JsonResponse clearance = call("f_cmd=126&cntr_no=" + encode(container) + "&bkg_no=" + + encode(bookingNo), "CLEARANCE"); + return new TrackingResponse(bookingNo, copNo, timeline.payload(), schedule.payload(), + clearance.payload(), lookup.raw() + timeline.raw() + schedule.raw() + clearance.raw()); + } finally { + nextRequestAtMillis = System.currentTimeMillis() + config.effectiveMinRequestInterval().toMillis(); + } + } + } + + private JsonResponse call(String form, String stage) { + try { + HttpResponse response = httpClient.send(HttpRequest.newBuilder(config.queryUrl()) + .timeout(config.effectiveRequestTimeout()) + .header("Accept", "application/json, text/javascript, */*; q=0.01") + .header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + .POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)).build(), + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() == 429) throw new SmlTrackingException("SML_RATE_LIMITED", true, "SML 访问限流"); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new SmlTrackingException("SML_" + stage + "_HTTP_" + response.statusCode(), + response.statusCode() >= 500, "SML 查询返回异常 HTTP 状态:" + response.statusCode()); + } + try { return new JsonResponse(objectMapper.readTree(response.body()), response.body()); } + catch (Exception exception) { + throw new SmlTrackingException("SML_" + stage + "_NON_JSON", true, + "SML 查询返回 HTML 或无效 JSON", exception); + } + } catch (SmlTrackingException exception) { + throw exception; + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new SmlTrackingException("SML_INTERRUPTED", true, "SML 查询被中断", exception); + } catch (HttpTimeoutException exception) { + throw new SmlTrackingException("SML_" + stage + "_TIMEOUT", true, "SML 查询超时", exception); + } catch (IOException exception) { + throw new SmlTrackingException("SML_IO_FAILED", true, "SML 查询网络失败", exception); + } + } + + private static void validateBusinessResult(JsonNode payload) { + if (payload == null || payload.isNull()) throw new SmlTrackingException("SML_EMPTY_RESULT", false, "SML 返回空结果"); + String result = findText(payload, "TRANS_RESULT_KEY", "transResultKey"); + if (result != null && !"S".equalsIgnoreCase(result)) { + throw new SmlTrackingException("SML_BUSINESS_FAILED", false, "SML 业务查询失败:" + result); + } + Long count = findLong(payload, "count", "totalCount"); + if (count != null && count == 0L) throw new SmlTrackingException("SML_NOT_FOUND", false, "SML 未找到业务对象"); + } + + static String findText(JsonNode node, String... names) { + if (node == null) return null; + if (node.isObject()) { + Iterator> fields = node.fields(); + while (fields.hasNext()) { + var field = fields.next(); + for (String name : names) if (field.getKey().equalsIgnoreCase(name) + && field.getValue().isValueNode() && !field.getValue().asText().isBlank()) { + return field.getValue().asText().trim(); + } + String nested = findText(field.getValue(), names); + if (nested != null) return nested; + } + } else if (node.isArray()) for (JsonNode child : node) { + String nested = findText(child, names); if (nested != null) return nested; + } + return null; + } + + private static Long findLong(JsonNode node, String... names) { + String value = findText(node, names); + if (value == null) return null; + try { return Long.parseLong(value); } + catch (NumberFormatException ignored) { return null; } + } + + private void waitForSlot() { + long wait = nextRequestAtMillis - System.currentTimeMillis(); + if (wait <= 0) return; + try { Thread.sleep(wait); } + catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new SmlTrackingException("SML_INTERRUPTED", true, "等待 SML 限流窗口时被中断", exception); + } + } + + private static String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); } + + private record JsonResponse(JsonNode payload, String raw) { } + public record TrackingResponse(String bookingNo, String copNo, JsonNode timeline, JsonNode schedule, + JsonNode clearance, String rawBody) { } + + public static final class SmlTrackingException extends RuntimeException { + private final String code; private final boolean retryable; + public SmlTrackingException(String code, boolean retryable, String message) { + super(message); this.code = code; this.retryable = retryable; + } + public SmlTrackingException(String code, boolean retryable, String message, Throwable cause) { + super(message, cause); this.code = code; this.retryable = retryable; + } + public String code() { return code; } + public boolean retryable() { return retryable; } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/ZimCarrierScheduleProviderAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/ZimCarrierScheduleProviderAdapter.java new file mode 100644 index 000000000..3d7e531ef --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/ZimCarrierScheduleProviderAdapter.java @@ -0,0 +1,199 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.Observation; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; +import com.baiyue.integration.dto.ProviderStatus; +import com.baiyue.integration.provider.ZimTrackingClient.ContainerEvent; +import com.baiyue.integration.provider.ZimTrackingClient.RouteDetail; +import com.baiyue.integration.provider.ZimTrackingClient.ZimTrackingException; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@Component +@Order(7) +public class ZimCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter { + private static final String PROVIDER = "ZIM_OFFICIAL_CARGO_TRACKING"; + private static final Map EVENT_MAP = Map.ofEntries( + Map.entry("EXPORT GATE-IN FROM CUSTOMER TO PORT OF LOADING", "AT_ORIGIN_PORT"), + Map.entry("EXPORT GATE-IN TO PORT OF LOADING", "AT_ORIGIN_PORT"), + Map.entry("CONTAINER WAS LOADED AT TRANSSHIPMENT PORT TO TRANSSHIPMENT PORT", "LOADED_ON_VESSEL"), + Map.entry("VESSEL DEPARTURE FROM FIRST TRANSSHIPMENT PORT TO NEXT TRANSSHIPMENT PORT", "IN_TRANSIT"), + Map.entry("CONTAINER LOADED AT PORT OF LOADING", "LOADED_ON_VESSEL"), + Map.entry("CONTAINER WAS LOADED AT PORT OF LOADING", "LOADED_ON_VESSEL"), + Map.entry("CONTAINER WAS LOADED AT PORT OF LOADING TO PORT OF DISCHARGE", "LOADED_ON_VESSEL"), + Map.entry("VESSEL DEPARTURE FROM PORT OF LOADING", "DEPARTED_ORIGIN_PORT"), + Map.entry("VESSEL DEPARTURE FROM PORT OF LOADING TO PORT OF DISCHARGE", "DEPARTED_ORIGIN_PORT")); + private final ZimTrackingClient client; + + public ZimCarrierScheduleProviderAdapter(ZimTrackingClient client) { this.client = client; } + + @Override + public boolean supports(String carrierCode) { + return client.configured() && carrierCode != null && "ZIM".equals(carrierCode.trim().toUpperCase(Locale.ROOT)); + } + + @Override + public QueryResult query(QueryRequest request) { + LocalDateTime queriedAt = LocalDateTime.now(ZoneOffset.UTC); + try { + ZimTrackingClient.TrackingResponse response = client.query(request); + List observations = observations(request.trackingNo(), response, queriedAt); + return new QueryResult(PROVIDER, observations.isEmpty() ? "NO_CHANGE" : "SUCCESS", observations, + List.of(), null, null, queriedAt); + } catch (ZimTrackingException exception) { + return new QueryResult(PROVIDER, exception.outcome(), List.of(), List.of(), exception.code(), + exception.getMessage(), queriedAt); + } + } + + List observations(String containerNo, ZimTrackingClient.TrackingResponse response, + LocalDateTime queriedAt) { + if (response == null || response.payload() == null || response.payload().bkBlDetails() == null) return List.of(); + String normalizedContainer = normalize(containerNo); + ZimTrackingClient.ContainerDetail unit = response.payload().bkBlDetails().safeContainers().stream() + .filter(value -> normalizedContainer.equals(normalize(value.containerNumber()))) + .findFirst().orElse(null); + if (unit == null) return List.of(); + String rawHash = sha256(response.rawBody() == null ? normalizedContainer : response.rawBody()); + String reference = sourceReference(response.payload(), normalizedContainer); + Map facts = new LinkedHashMap<>(); + for (ContainerEvent event : unit.safeEvents()) { + String eventName = normalizeEvent(event.eventName()); + String standardNode = EVENT_MAP.get(eventName); + LocalDateTime actualAt = parseDate(first(event.activityDateTz(), event.activityDate())); + if (standardNode == null || actualAt == null) continue; + add(facts, normalizedContainer, truncate(eventName, 64), standardNode, queriedAt, null, actualAt, + blank(event.portCode()), locationName(event.port(), event.country()), reference, rawHash, + event.vesselName(), event.voyage(), event.leg()); + } + List routes = response.payload().safeRoutes(); + RouteDetail firstSailing = routes.stream() + .filter(value -> parseDate(first(value.sailingDateTz(), value.sailingDateDT())) != null) + .findFirst().orElse(null); + if (firstSailing != null) { + addEstimated(facts, normalizedContainer, "ROUTE_POL_SAILING_PLAN", "DEPARTED_ORIGIN_PORT", + parseDate(first(firstSailing.sailingDateTz(), firstSailing.sailingDateDT())), queriedAt, + firstSailing, reference, rawHash); + } + RouteDetail lastArrival = null; + for (int index = routes.size() - 1; index >= 0; index--) { + RouteDetail candidate = routes.get(index); + if (parseDate(first(candidate.arrivalDateTz(), candidate.arrivalDateDT())) != null) { + lastArrival = candidate; break; + } + } + if (lastArrival != null) { + addEstimated(facts, normalizedContainer, "ROUTE_POD_ARRIVAL_PLAN", "ARRIVED_DESTINATION_PORT", + parseDate(first(lastArrival.arrivalDateTz(), lastArrival.arrivalDateDT())), queriedAt, + lastArrival, reference, rawHash); + } + if (response.payload().finalETA() != null) { + addEstimated(facts, normalizedContainer, "FINAL_ETA", "ARRIVED_DESTINATION_PORT", + parseDate(first(response.payload().finalETA().etaValue(), response.payload().finalETA().etaValueDate())), + queriedAt, null, reference, rawHash); + } + return facts.values().stream().sorted(Comparator.comparing(Observation::standardNode) + .thenComparing(value -> value.actualAt() == null ? value.estimatedAt() : value.actualAt())).toList(); + } + + static String standardNode(String eventName) { + return EVENT_MAP.get(normalizeEvent(eventName)); + } + + private static void addEstimated(Map facts, String container, String providerNode, + String standardNode, LocalDateTime estimatedAt, LocalDateTime queriedAt, + RouteDetail route, String reference, String rawHash) { + if (estimatedAt == null) return; + add(facts, container, providerNode, standardNode, queriedAt, estimatedAt, null, + route == null ? null : blank(route.portCode()), + route == null ? null : locationName(route.port(), route.country()), reference, rawHash, + route == null ? null : route.vesselName(), route == null ? null : route.voyage(), + route == null ? null : route.leg()); + } + + private static void add(Map facts, String container, String providerNode, + String standardNode, LocalDateTime queriedAt, LocalDateTime estimatedAt, + LocalDateTime actualAt, String locationCode, String locationName, + String reference, String rawHash, String vesselName, String voyage, String leg) { + LocalDateTime eventAt = actualAt == null ? estimatedAt : actualAt; + String eventId = sha256(container + '|' + providerNode + '|' + eventAt + '|' + + normalize(locationCode) + '|' + normalize(locationName) + '|' + clean(vesselName) + '|' + + clean(voyage) + '|' + clean(leg)); + facts.putIfAbsent(eventId, new Observation(eventId, providerNode, standardNode, queriedAt, + estimatedAt, actualAt, locationCode, locationName, reference, rawHash)); + } + + private static String first(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) return value.trim(); + } + return ""; + } + + private static String sourceReference(ZimTrackingClient.TracingPayload payload, String container) { + StringBuilder value = new StringBuilder("ZIM:CONTAINER:").append(container); + if (!clean(payload.bookingReference()).isEmpty()) value.append("|BOOKING:").append(clean(payload.bookingReference())); + if (!clean(payload.blReference()).isEmpty()) value.append("|BL:").append(clean(payload.blReference())); + return truncate(value.toString(), 500); + } + + private static String locationName(String port, String country) { + String name = clean(port); String nation = clean(country); + return blank(name + (name.isEmpty() || nation.isEmpty() ? "" : ", ") + nation); + } + + private static String normalizeEvent(String value) { + return value == null ? "" : value.trim().replaceAll("\\s+", " ").toUpperCase(Locale.ROOT); + } + + private static LocalDateTime parseDate(String value) { + if (value == null || value.isBlank()) return null; + String text = value.trim(); + try { return LocalDateTime.ofInstant(Instant.parse(text), ZoneOffset.UTC); } + catch (DateTimeParseException ignored) { } + try { return LocalDateTime.ofInstant(OffsetDateTime.parse(text).toInstant(), ZoneOffset.UTC); } + catch (DateTimeParseException ignored) { } + for (DateTimeFormatter formatter : List.of(DateTimeFormatter.ISO_LOCAL_DATE_TIME, + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))) { + try { return LocalDateTime.parse(text, formatter); } + catch (DateTimeParseException ignored) { } + } + try { return LocalDate.parse(text, DateTimeFormatter.ISO_LOCAL_DATE).atStartOfDay(); } + catch (DateTimeParseException ignored) { } + return null; + } + + private static String clean(String value) { return value == null ? "" : value.trim(); } + private static String blank(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static String truncate(String value, int max) { return value.length() <= max ? value : value.substring(0, max); } + private static String normalize(String value) { + return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT); + } + private static String sha256(String value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); } + catch (Exception exception) { throw new IllegalStateException(exception); } + } + + @Override + public ProviderStatus status() { + return ProviderStatus.of("CARRIER_SCHEDULE_ZIM", client.configured(), client.configured()); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/ZimTrackingClient.java b/integration-service/src/main/java/com/baiyue/integration/provider/ZimTrackingClient.java new file mode 100644 index 000000000..f56a6b0dd --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/ZimTrackingClient.java @@ -0,0 +1,207 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.Semaphore; + +@Component +public class ZimTrackingClient { + private static final Duration TOKEN_REFRESH_SKEW = Duration.ofMinutes(1); + @Resource private IntegrationProperties properties; + @Resource private ObjectMapper objectMapper; + private IntegrationProperties.CarrierSchedule.Zim config; + private HttpClient httpClient; + private Semaphore concurrency; + private final Object tokenLock = new Object(); + private volatile AccessToken cachedToken; + + @PostConstruct + void initialize() { + config = properties.carrierSchedule() == null ? null : properties.carrierSchedule().zim(); + httpClient = HttpClient.newBuilder() + .connectTimeout(config == null ? Duration.ofSeconds(10) : config.effectiveConnectTimeout()) + .followRedirects(HttpClient.Redirect.NORMAL).build(); + concurrency = new Semaphore(config == null ? 1 : config.effectiveMaxConcurrency()); + } + + public boolean configured() { return config != null && config.configured(); } + + public TrackingResponse query(QueryRequest request) { + if (!configured()) manual("ZIM_OFFICIAL_API_NOT_CONFIGURED", "ZIM Tracing API 未启用或缺少授权配置"); + if (!"CONTAINER_NO".equalsIgnoreCase(request.trackingType())) { + manual("ZIM_CONTAINER_REQUIRED", "ZIM 首批已验证契约仅支持柜号查询"); + } + boolean acquired = false; + try { + concurrency.acquire(); acquired = true; + return execute(normalize(request.trackingNo()), false); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw retryable("ZIM_INTERRUPTED", "ZIM Tracing API 查询被中断", exception); + } finally { + if (acquired) concurrency.release(); + } + } + + private TrackingResponse execute(String container, boolean refreshedAfterUnauthorized) { + String token = accessToken(false); + HttpResponse response = send(HttpRequest.newBuilder(tracingUri(container)) + .timeout(config.effectiveRequestTimeout()).header("Accept", "application/json") + .header("Ocp-Apim-Subscription-Key", config.subscriptionKey()) + .header("Authorization", "bearer " + token).GET().build(), "TRACING"); + if (response.statusCode() == 401 && !refreshedAfterUnauthorized) { + invalidateToken(token); return execute(container, true); + } + if (response.statusCode() == 401 || response.statusCode() == 403) { + manual("ZIM_HTTP_" + response.statusCode(), "ZIM Tracing API 授权或来源环境校验失败"); + } + if (response.statusCode() == 429 || response.statusCode() >= 500) { + throw retryable("ZIM_HTTP_" + response.statusCode(), "ZIM Tracing API 暂时不可用", null); + } + if (response.statusCode() < 200 || response.statusCode() >= 300) { + manual("ZIM_HTTP_" + response.statusCode(), "ZIM Tracing API 拒绝查询"); + } + TracingPayload payload = tracingPayload(response.body()); + validateBusinessResult(payload, container); + return new TrackingResponse(payload, response.body()); + } + + private String accessToken(boolean forceRefresh) { + AccessToken current = cachedToken; + if (!forceRefresh && current != null && current.usableAt(Instant.now())) return current.value(); + synchronized (tokenLock) { + current = cachedToken; + if (!forceRefresh && current != null && current.usableAt(Instant.now())) return current.value(); + String form = "grant_type=client_credentials&client_id=" + encode(config.clientId()) + + "&client_secret=" + encode(config.clientSecret()) + "&scope=tracing"; + HttpResponse response = send(HttpRequest.newBuilder(config.authorizationUrl()) + .timeout(config.effectiveRequestTimeout()).header("Accept", "application/json") + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)).build(), "AUTH"); + if (response.statusCode() == 429 || response.statusCode() >= 500) { + throw retryable("ZIM_AUTH_HTTP_" + response.statusCode(), "ZIM OAuth 服务暂时不可用", null); + } + if (response.statusCode() < 200 || response.statusCode() >= 300) { + manual("ZIM_AUTH_HTTP_" + response.statusCode(), "ZIM OAuth 客户端授权失败"); + } + JsonNode payload = json(response.body(), "ZIM_AUTH_NON_JSON"); + String value = payload.path("access_token").asText("").trim(); + if (value.isEmpty()) manual("ZIM_AUTH_TOKEN_MISSING", "ZIM OAuth 响应缺少 access_token"); + long expiresIn = Math.max(60L, payload.path("expires_in").asLong(3600L)); + cachedToken = new AccessToken(value, Instant.now().plusSeconds(expiresIn)); + return value; + } + } + + private HttpResponse send(HttpRequest request, String stage) { + try { return httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); } + catch (HttpTimeoutException exception) { + throw retryable("ZIM_" + stage + "_TIMEOUT", "ZIM 官方 API 请求超时", exception); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw retryable("ZIM_" + stage + "_INTERRUPTED", "ZIM 官方 API 请求被中断", exception); + } catch (IOException exception) { + throw retryable("ZIM_" + stage + "_IO_FAILED", "ZIM 官方 API 网络失败", exception); + } + } + + private JsonNode json(String body, String code) { + try { return objectMapper.readTree(body); } + catch (Exception exception) { throw retryable(code, "ZIM 官方 API 返回无效 JSON", exception); } + } + + private TracingPayload tracingPayload(String body) { + try { return objectMapper.readValue(body, TracingPayload.class); } + catch (Exception exception) { throw retryable("ZIM_TRACING_NON_JSON", "ZIM Tracing V2 返回无效 JSON", exception); } + } + + private void validateBusinessResult(TracingPayload payload, String requestedContainer) { + if (payload == null || payload.bkBlDetails() == null) manual("ZIM_EMPTY_RESULT", "ZIM 返回空业务对象"); + List containers = payload.bkBlDetails().safeContainers(); + boolean matched = containers.stream().anyMatch(value -> requestedContainer.equals(normalize(value.containerNumber()))); + if (containers.isEmpty() || !matched) manual("ZIM_NOT_FOUND", "ZIM 未返回匹配柜号的业务对象"); + } + + private URI tracingUri(String container) { + return URI.create(config.tracingBaseUrl().toString().replaceAll("/+$", "") + '/' + encode(container)); + } + private void invalidateToken(String usedToken) { + synchronized (tokenLock) { + if (cachedToken != null && cachedToken.value().equals(usedToken)) cachedToken = null; + } + } + + private static void manual(String code, String message) { + throw new ZimTrackingException("MANUAL_REQUIRED", code, message); + } + private static ZimTrackingException retryable(String code, String message, Throwable cause) { + return new ZimTrackingException("RETRYABLE_FAILURE", code, message, cause); + } + private static String normalize(String value) { + return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT); + } + private static String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); } + + private record AccessToken(String value, Instant expiresAt) { + boolean usableAt(Instant now) { return expiresAt.minus(TOKEN_REFRESH_SKEW).isAfter(now); } + } + public record TrackingResponse(TracingPayload payload, String rawBody) { } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record TracingPayload(FinalEta finalETA, List routeDetails, BkBlDetails bkBlDetails, + String bookingReference, String blReference) { + public List safeRoutes() { return routeDetails == null ? List.of() : List.copyOf(routeDetails); } + } + @JsonIgnoreProperties(ignoreUnknown = true) + public record FinalEta(String etaValue, String etaValueDate, String podInd) { } + @JsonIgnoreProperties(ignoreUnknown = true) + public record RouteDetail(String sailingDateTz, String sailingDateDT, String arrivalDateTz, + String arrivalDateDT, String portCode, String port, String country, + String vesselName, String voyage, String leg) { } + @JsonIgnoreProperties(ignoreUnknown = true) + public record BkBlDetails(List consContainerList) { + public List safeContainers() { + return consContainerList == null ? List.of() : List.copyOf(consContainerList); + } + } + @JsonIgnoreProperties(ignoreUnknown = true) + public record ContainerDetail(String containerNumber, List containerEventsList) { + public List safeEvents() { + return containerEventsList == null ? List.of() : List.copyOf(containerEventsList); + } + } + @JsonIgnoreProperties(ignoreUnknown = true) + public record ContainerEvent(String activityDateTz, String activityDate, String eventName, + String portCode, String port, String country, + String vesselName, String voyage, String leg) { } + public static final class ZimTrackingException extends RuntimeException { + private final String outcome; private final String code; + public ZimTrackingException(String outcome, String code, String message) { + super(message); this.outcome = outcome; this.code = code; + } + public ZimTrackingException(String outcome, String code, String message, Throwable cause) { + super(message, cause); this.outcome = outcome; this.code = code; + } + public String outcome() { return outcome; } + public String code() { return code; } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/service/CarrierScheduleProviderService.java b/integration-service/src/main/java/com/baiyue/integration/service/CarrierScheduleProviderService.java index f02110ab0..316bbf195 100644 --- a/integration-service/src/main/java/com/baiyue/integration/service/CarrierScheduleProviderService.java +++ b/integration-service/src/main/java/com/baiyue/integration/service/CarrierScheduleProviderService.java @@ -4,6 +4,7 @@ import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; import com.baiyue.integration.provider.CarrierScheduleProviderAdapter; import com.baiyue.integration.domain.CarrierScheduleStandardNode; +import com.baiyue.integration.domain.CarrierScheduleExceptionCode; import org.springframework.stereotype.Service; import java.util.List; @@ -24,7 +25,8 @@ public class CarrierScheduleProviderService { .orElseThrow(() -> new IllegalArgumentException( "没有支持该船司的轨迹供应商适配器:" + request.carrierCode())) .query(request); - if (result == null || result.observations() == null || !OUTCOMES.contains(result.outcome())) { + if (result == null || result.observations() == null || result.exceptions() == null + || !OUTCOMES.contains(result.outcome())) { throw new IllegalStateException("轨迹供应商返回空结果"); } if (result.observations().stream().anyMatch(value -> value == null @@ -34,6 +36,14 @@ public class CarrierScheduleProviderService { || !value.rawPayloadHash().matches("[0-9a-f]{64}"))) { throw new IllegalStateException("轨迹供应商返回了无效标准节点"); } + if (result.exceptions().stream().anyMatch(value -> value == null + || !CarrierScheduleExceptionCode.supports(value.exceptionCode()) + || value.providerEventId() == null || value.providerEventId().isBlank() + || value.providerStatus() == null || value.providerStatus().isBlank() + || value.observedAt() == null || value.rawPayloadHash() == null + || !value.rawPayloadHash().matches("[0-9a-f]{64}"))) { + throw new IllegalStateException("轨迹供应商返回了无效异常事实"); + } return result; } } diff --git a/integration-service/src/main/resources/application.yml b/integration-service/src/main/resources/application.yml index e5508b64f..cca1f3168 100644 --- a/integration-service/src/main/resources/application.yml +++ b/integration-service/src/main/resources/application.yml @@ -58,6 +58,42 @@ baiyue: aws-access-key: ${AMAZON_SP_API_AWS_ACCESS_KEY:} aws-secret-key: ${AMAZON_SP_API_AWS_SECRET_KEY:} region: ${AMAZON_SP_API_REGION:} + carrier-schedule: + matson: + enabled: ${MATSON_TRACKING_ENABLED:false} + page-url: ${MATSON_TRACKING_PAGE_URL:https://www.matson.com/shipment-tracking.html} + query-url: ${MATSON_TRACKING_QUERY_URL:https://www.matson.com/wp-content/plugins/matson-plugin/Api_calls/tracking_booking_proxy.php} + connect-timeout: ${MATSON_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${MATSON_TRACKING_REQUEST_TIMEOUT:30s} + min-request-interval: ${MATSON_TRACKING_MIN_REQUEST_INTERVAL:5s} + hede: + enabled: ${HEDE_TRACKING_ENABLED:false} + query-url: ${HEDE_TRACKING_QUERY_URL:http://elines.hedehk.com/getVBilldynamic} + connect-timeout: ${HEDE_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${HEDE_TRACKING_REQUEST_TIMEOUT:30s} + min-request-interval: ${HEDE_TRACKING_MIN_REQUEST_INTERVAL:5s} + sml: + enabled: ${SML_TRACKING_ENABLED:false} + query-url: ${SML_TRACKING_QUERY_URL:https://esvc.smlines.com/smline/CUP_HOM_3301GS.do} + connect-timeout: ${SML_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${SML_TRACKING_REQUEST_TIMEOUT:30s} + min-request-interval: ${SML_TRACKING_MIN_REQUEST_INTERVAL:5s} + zim: + enabled: ${ZIM_TRACKING_ENABLED:false} + authorization-url: ${ZIM_AUTHORIZATION_URL:https://apigw.zim.com/authorize/v1} + tracing-base-url: ${ZIM_TRACING_BASE_URL:https://apigw.zim.com/tracing/v2} + client-id: ${ZIM_CLIENT_ID:} + client-secret: ${ZIM_CLIENT_SECRET:} + subscription-key: ${ZIM_TRACKING_SUBSCRIPTION_KEY:} + connect-timeout: ${ZIM_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${ZIM_TRACKING_REQUEST_TIMEOUT:30s} + max-concurrency: ${ZIM_TRACKING_MAX_CONCURRENCY:10} + evergreen: + enabled: ${EMC_TRACKING_ENABLED:false} + query-url: ${EMC_TRACKING_QUERY_URL:https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do} + connect-timeout: ${EMC_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${EMC_TRACKING_REQUEST_TIMEOUT:30s} + min-request-interval: ${EMC_TRACKING_MIN_REQUEST_INTERVAL:5s} management: endpoints: diff --git a/integration-service/src/main/resources/openapi/carrier-schedule-provider-v1.yaml b/integration-service/src/main/resources/openapi/carrier-schedule-provider-v1.yaml index 54a27df9d..b376f8cea 100644 --- a/integration-service/src/main/resources/openapi/carrier-schedule-provider-v1.yaml +++ b/integration-service/src/main/resources/openapi/carrier-schedule-provider-v1.yaml @@ -30,10 +30,11 @@ components: trackingNo: {type: string, minLength: 1, maxLength: 128} StandardNode: type: string - enum: [DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED, PICKUP_READY] + enum: [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] + ExceptionCode: {type: string, enum: [CUSTOMS_HOLD, TERMINAL_HOLD, DOCUMENT_HOLD, UNKNOWN_HOLD]} QueryResult: type: object - required: [providerCode, outcome, observations] + required: [providerCode, outcome, observations, exceptions] properties: providerCode: {type: string} outcome: {type: string, enum: [SUCCESS, NO_CHANGE, RETRYABLE_FAILURE, PERMANENT_FAILURE, MANUAL_REQUIRED]} @@ -53,3 +54,18 @@ components: locationName: {type: string, nullable: true} sourceReference: {type: string, nullable: true} rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'} + exceptions: + type: array + items: + type: object + required: [providerEventId, providerStatus, exceptionCode, active, observedAt, rawPayloadHash] + properties: + providerEventId: {type: string, maxLength: 128} + providerStatus: {type: string, maxLength: 128} + exceptionCode: {$ref: '#/components/schemas/ExceptionCode'} + active: {type: boolean} + observedAt: {type: string, format: date-time} + locationCode: {type: string, nullable: true} + locationName: {type: string, nullable: true} + sourceReference: {type: string, nullable: true} + rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapterTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapterTest.java new file mode 100644 index 000000000..2a09fb252 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapterTest.java @@ -0,0 +1,74 @@ +package com.baiyue.integration.provider; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class EvergreenCarrierScheduleProviderAdapterTest { + private static final String CONTAINER_HTML = """ +
other
+ + + + + + + + +
Method地点箱号货柜动态日期柜型VGM船名航次
CYNINGBO, CHINA (CN) EGSU9319340 Loaded (FCL) on vesselAUG-07-202640HQEVER LOVELY 1232-067E
CYNINGBO, CHINA (CN)EITU1262610Loaded (FCL) on vesselAUG-06-202640HQEVER LOVELY 1232-067E
+ """; + private static final String BILL_HTML = """ +
预计装船日AUG-07-2026
+ """; + + @Test + void mapsTheExactContainerRowAndPreservesDayPrecisionWithoutUsingObservedAtAsActual() { + var response = new EvergreenTrackingClient.TrackingResponse(CONTAINER_HTML, BILL_HTML, "0000", + CONTAINER_HTML + BILL_HTML); + LocalDateTime queriedAt = LocalDateTime.of(2026, 8, 21, 15, 30); + + var values = new EvergreenCarrierScheduleProviderAdapter(mock(EvergreenTrackingClient.class)) + .observations("egsu9319340", response, queriedAt); + + assertThat(values).hasSize(2); + assertThat(values).extracting(value -> value.observedAt()).containsOnly(queriedAt); + assertThat(values).filteredOn(value -> value.actualAt() != null).singleElement().satisfies(value -> { + assertThat(value.standardNode()).isEqualTo("LOADED_ON_VESSEL"); + assertThat(value.actualAt()).isEqualTo(LocalDateTime.of(2026, 8, 7, 0, 0)); + assertThat(value.actualAt()).isNotEqualTo(queriedAt); + assertThat(value.locationCode()).isEqualTo("CN"); + assertThat(value.locationName()).isEqualTo("NINGBO, CHINA (CN)"); + assertThat(value.sourceReference()).contains("DATE:AUG-07-2026", "PRECISION:DAY", "ZONE:Asia/Shanghai") + .doesNotContain("000000000000"); + }); + assertThat(values).filteredOn(value -> value.estimatedAt() != null).singleElement() + .satisfies(value -> assertThat(value.estimatedAt()).isEqualTo(LocalDateTime.of(2026, 8, 7, 0, 0))); + } + + @Test + void secondConfirmedFixtureRowUsesItsOwnDateAndUnknownStatusesStayUnmapped() { + var response = new EvergreenTrackingClient.TrackingResponse(CONTAINER_HTML, "
", + "0000", CONTAINER_HTML); + var values = new EvergreenCarrierScheduleProviderAdapter(mock(EvergreenTrackingClient.class)) + .observations("EITU1262610", response, LocalDateTime.of(2026, 8, 21, 15, 30)); + + assertThat(values).singleElement().satisfies(value -> { + assertThat(value.actualAt()).isEqualTo(LocalDateTime.of(2026, 8, 6, 0, 0)); + assertThat(value.standardNode()).isEqualTo("LOADED_ON_VESSEL"); + }); + assertThat(EvergreenCarrierScheduleProviderAdapter.standardNode("Loaded (FCL) on vessel")) + .isEqualTo("LOADED_ON_VESSEL"); + assertThat(EvergreenCarrierScheduleProviderAdapter.standardNode("Discharged from vessel")).isNull(); + } + + @Test + void missingStableHeaderOrExactContainerRowIsNotParsed() { + assertThat(EvergreenCarrierScheduleProviderAdapter.trackingRow( + "
箱号日期
EGSU9319340AUG-07-2026
", + "EGSU9319340")).isNull(); + assertThat(EvergreenCarrierScheduleProviderAdapter.trackingRow(CONTAINER_HTML, "OTHER0000000")).isNull(); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/EvergreenTrackingClientTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/EvergreenTrackingClientTest.java new file mode 100644 index 000000000..d84b1d269 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/EvergreenTrackingClientTest.java @@ -0,0 +1,91 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class EvergreenTrackingClientTest { + private HttpServer server; + + @AfterEach + void stopServer() { if (server != null) server.stop(0); } + + @Test + void executesContainerThenRelatedBillPostsWithoutExposingTheFullBill() throws Exception { + List forms = new ArrayList<>(); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/track", exchange -> { + String form = body(exchange); forms.add(form); + String html = form.startsWith("TYPE=CNTR") + ? "
" + : "
预计装船日AUG-07-2026
"; + respond(exchange, 200, html); + }); + server.start(); + + var result = client().query(new QueryRequest("EMC", "CONTAINER_NO", "EGSU9319340")); + + assertThat(forms).containsExactly("TYPE=CNTR&CNTR=EGSU9319340", "TYPE=BL&BL=000000000000"); + assertThat(result.billSuffix()).isEqualTo("0000"); + assertThat(result.toString()).doesNotContain("000000000000"); + } + + @Test + void extractsTheRelatedBillFromHiddenInputOrDispInfoLink() { + assertThat(EvergreenTrackingClient.relatedBill( + "" )).isEqualTo("000000000000"); + assertThat(EvergreenTrackingClient.relatedBill( + "detail" )).isEqualTo("000000000000"); + } + + @Test + void nonHtmlAndMissingRelatedBillBecomeTraceableManualFailures() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/track", exchange -> respond(exchange, 200, "{\"unexpected\":true}")); + server.start(); + assertThatThrownBy(() -> client().query(new QueryRequest("EMC", "CONTAINER_NO", "EGSU9319340"))) + .isInstanceOfSatisfying(EvergreenTrackingClient.EvergreenTrackingException.class, + value -> assertThat(value.code()).isEqualTo("EMC_CONTAINER_NON_HTML")); + + server.stop(0); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/track", exchange -> respond(exchange, 200, "
")); + server.start(); + assertThatThrownBy(() -> client().query(new QueryRequest("EMC", "CONTAINER_NO", "EGSU9319340"))) + .isInstanceOfSatisfying(EvergreenTrackingClient.EvergreenTrackingException.class, + value -> assertThat(value.code()).isEqualTo("EMC_RELATED_BL_MISSING")); + } + + private EvergreenTrackingClient client() { + var evergreen = new IntegrationProperties.CarrierSchedule.Evergreen(true, + URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/track"), + Duration.ofSeconds(2), Duration.ofSeconds(2), Duration.ZERO); + EvergreenTrackingClient client = new EvergreenTrackingClient(); + ReflectionTestUtils.setField(client, "properties", new IntegrationProperties(null, null, + new IntegrationProperties.CarrierSchedule(null, null, null, null, evergreen))); + client.initialize(); return client; + } + private static String body(HttpExchange exchange) throws IOException { + return new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + } + private static void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "text/html; charset=utf-8"); + exchange.sendResponseHeaders(status, bytes.length); exchange.getResponseBody().write(bytes); exchange.close(); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/HedeCarrierScheduleProviderAdapterTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/HedeCarrierScheduleProviderAdapterTest.java new file mode 100644 index 000000000..f5cdfd456 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/HedeCarrierScheduleProviderAdapterTest.java @@ -0,0 +1,95 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.provider.HedeTrackingClient.TrackingResponse; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class HedeCarrierScheduleProviderAdapterTest { + @Test + void parsesConfirmedMainTableColumnsAndDoesNotTurnEtaIntoAnActualNode() throws Exception { + String html = row("XYLU8251457", "2026-08-20 00:00", "2026-09-09 00:00", + "", "", "", "", "", ""); + + var rows = HedeCarrierScheduleProviderAdapter.rows(html); + HedeTrackingClient client = mock(HedeTrackingClient.class); + HedeCarrierScheduleProviderAdapter adapter = new HedeCarrierScheduleProviderAdapter(client); + + assertThat(rows).singleElement().satisfies(value -> { + assertThat(value.containerNo()).isEqualTo("XYLU8251457"); + assertThat(value.vessel()).isEqualTo("POSEN"); + assertThat(value.voyage()).isEqualTo("2607W"); + assertThat(value.estimatedDeparture()).isEqualTo("2026-08-20 00:00"); + assertThat(value.estimatedArrival()).isEqualTo("2026-09-09 00:00"); + }); + assertThat(adapter.observations("XYLU8251457", html)).extracting(value -> value.standardNode()) + .containsExactlyInAnyOrder("DEPARTED_ORIGIN_PORT", "ARRIVED_DESTINATION_PORT"); + assertThat(adapter.observations("XYLU8251457", html)).allSatisfy(value -> { + assertThat(value.estimatedAt()).isNotNull(); + assertThat(value.actualAt()).isNull(); + }); + } + + @Test + void mapsOnlyActualHedeMilestonesForTheRequestedContainer() { + String html = row("FFAU4879673", "2026-08-05 04:24", "2026-08-22 00:00", + "2026-07-30 21:31", "2026-08-01 15:55", "2026-08-04 19:45", "", "", ""); + HedeTrackingClient client = mock(HedeTrackingClient.class); + when(client.configured()).thenReturn(true); + when(client.query(new QueryRequest("HEDE", "CONTAINER_NO", "FFAU4879673"))) + .thenReturn(new TrackingResponse(html)); + HedeCarrierScheduleProviderAdapter adapter = new HedeCarrierScheduleProviderAdapter(client); + + var result = adapter.query(new QueryRequest("HEDE", "CONTAINER_NO", "FFAU4879673")); + + assertThat(result.outcome()).isEqualTo("SUCCESS"); + assertThat(result.observations()).extracting(value -> value.standardNode()) + .containsExactlyInAnyOrder("DEPARTED_ORIGIN_PORT", "ARRIVED_DESTINATION_PORT", "AT_ORIGIN_PORT", + "LOADED_ON_VESSEL"); + assertThat(result.observations()).allSatisfy(value -> { + assertThat(value.providerEventId()).hasSize(64); + assertThat(value.rawPayloadHash()).hasSize(64); + assertThat(value.sourceReference()).contains("FFAU4879673").contains("POSEN/2607W"); + }); + assertThat(result.observations()).extracting(value -> value.observedAt()) + .containsOnly(result.observations().get(0).observedAt()); + } + + @Test + void actualEmptyReturnIsMappedPrecisely() throws Exception { + String html = row("FFAU4879673", "2026-08-05 04:24", "2026-08-22 00:00", + "2026-07-30 21:31", "2026-08-01 15:55", "2026-08-04 19:45", "", "", + "2026-08-28 08:00"); + HedeTrackingClient client = mock(HedeTrackingClient.class); + var result = new HedeCarrierScheduleProviderAdapter(client).observations("FFAU4879673", html); + assertThat(result).filteredOn(value -> "EMPTY_RETURNED".equals(value.standardNode())) + .singleElement().satisfies(value -> { + assertThat(value.actualAt()).isNotNull(); + assertThat(value.estimatedAt()).isNull(); + }); + } + + @Test + void refusesAmbiguousBillLevelAutomation() { + HedeTrackingClient client = mock(HedeTrackingClient.class); + HedeCarrierScheduleProviderAdapter adapter = new HedeCarrierScheduleProviderAdapter(client); + + var result = adapter.query(new QueryRequest("HEDE", "BILL_OF_LADING", "HDE123")); + + assertThat(result.outcome()).isEqualTo("PERMANENT_FAILURE"); + assertThat(result.errorCode()).isEqualTo("HEDE_CONTAINER_REQUIRED"); + } + + private static String row(String container, String etd, String eta, String emptyOut, String fullIn, + String loaded, String discharged, String fullOut, String emptyReturned) { + return "" + + td("***") + td(container) + td("POSEN") + td("2607W") + td(etd) + td(eta) + + td(emptyOut) + td(fullIn) + td(loaded) + td(discharged) + td(fullOut) + td(emptyReturned) + + td("查看") + "
"; + } + + private static String td(String value) { return "" + value + ""; } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/HedeTrackingClientTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/HedeTrackingClientTest.java new file mode 100644 index 000000000..b7b229a55 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/HedeTrackingClientTest.java @@ -0,0 +1,59 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +class HedeTrackingClientTest { + private HttpServer server; + + @AfterEach + void stopServer() { if (server != null) server.stop(0); } + + @Test + void postsTheConfirmedHedeFormAsUtf8() throws Exception { + AtomicReference postedBody = new AtomicReference<>(); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/getVBilldynamic", exchange -> { + postedBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + respond(exchange, 200, "" + + "
***XYLU8251457
"); + }); + server.start(); + + var hede = new IntegrationProperties.CarrierSchedule.Hede(true, + URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/getVBilldynamic"), + Duration.ofSeconds(2), Duration.ofSeconds(2), Duration.ZERO); + HedeTrackingClient client = new HedeTrackingClient(); + ReflectionTestUtils.setField(client, "properties", new IntegrationProperties(null, null, + new IntegrationProperties.CarrierSchedule(null, hede, null, null, null))); + client.initialize(); + + var response = client.query(new QueryRequest("HEDE", "CONTAINER_NO", "XYLU8251457")); + + assertThat(response.html()).contains("XYLU8251457"); + assertThat(postedBody.get()).isEqualTo( + "billno=&cntr=XYLU8251457&selecttype=1&resultlist=1¶mtype=0"); + } + + private static void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "text/html; charset=UTF-8"); + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/MatsonCarrierScheduleProviderAdapterTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/MatsonCarrierScheduleProviderAdapterTest.java new file mode 100644 index 000000000..c352c07d2 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/MatsonCarrierScheduleProviderAdapterTest.java @@ -0,0 +1,109 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.provider.MatsonTrackingClient.TrackingResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class MatsonCarrierScheduleProviderAdapterTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void realMatu4646247FixtureProducesTwoActualsAndEtaOnly() throws Exception { + String raw = """ + {"bookingNumber":"MATSON-REAL","containerNumber":"MATU464624","checkDigit":"7", + "originPort":"LONG BEACH","destPort":"HONOLULU","originCode":"LAX","destCode":"HIL", + "loadPort":"LONG BEACH","dischargePort":"HONOLULU","vesselETA":"08/25/26","events":[ + {"date":%d,"location":"LONG BEACH","status":"INGATE"}, + {"date":%d,"location":"LONG BEACH","status":"LOAD TO VESSEL"}]} + """.formatted(Instant.parse("2026-08-18T19:15:00Z").toEpochMilli(), + Instant.parse("2026-08-18T22:12:00Z").toEpochMilli()); + MatsonTrackingClient client = mock(MatsonTrackingClient.class); + when(client.configured()).thenReturn(true); + when(client.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU4646247"))) + .thenReturn(new TrackingResponse(objectMapper.readTree(raw), raw)); + + var result = new MatsonCarrierScheduleProviderAdapter(client) + .query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU4646247")); + + assertThat(result.observations()).extracting(value -> value.standardNode()) + .containsExactlyInAnyOrder("AT_ORIGIN_PORT", "LOADED_ON_VESSEL", "ARRIVED_DESTINATION_PORT"); + assertThat(result.observations()).filteredOn(value -> value.actualAt() != null).hasSize(2); + assertThat(result.observations()).filteredOn(value -> value.estimatedAt() != null).singleElement(); + assertThat(result.observations()).extracting(value -> value.observedAt()) + .containsOnly(result.observations().get(0).observedAt()); + assertThat(result.exceptions()).isEmpty(); + } + + @Test + void conservativelyMapsKnownMatsonEventsWithoutTreatingLoadAsDeparture() throws Exception { + String raw = """ + {"bookingNumber":"9974749","containerNumber":"MATU459895","checkDigit":"6", + "originPort":"NINGBO","destPort":"LONG BEACH","originCode":"NGB","destCode":"LAX", + "vesselETA":"08/24/26", + "loadPort":"NINGBO","dischargePort":"LONG BEACH","events":[ + {"date":1787160480000,"location":"","status":"CONTAINER ON HOLD - Customs Clearance"}, + {"date":1787033640000,"location":"NINGBO","status":"LOAD TO VESSEL"}, + {"date":1786940100000,"location":"NINGBO","status":"INGATE"}]} + """; + MatsonTrackingClient client = mock(MatsonTrackingClient.class); + when(client.configured()).thenReturn(true); + when(client.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU4598956"))) + .thenReturn(new TrackingResponse(objectMapper.readTree(raw), raw)); + MatsonCarrierScheduleProviderAdapter adapter = new MatsonCarrierScheduleProviderAdapter(client); + + var result = adapter.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU4598956")); + + assertThat(result.outcome()).isEqualTo("SUCCESS"); + assertThat(result.observations()).extracting(value -> value.standardNode()) + .containsExactlyInAnyOrder("AT_ORIGIN_PORT", "LOADED_ON_VESSEL", "ARRIVED_DESTINATION_PORT"); + assertThat(result.observations()).filteredOn(value -> "ARRIVED_DESTINATION_PORT".equals(value.standardNode())) + .singleElement().satisfies(value -> { + assertThat(value.estimatedAt()).isNotNull(); + assertThat(value.actualAt()).isNull(); + }); + assertThat(result.exceptions()).singleElement().satisfies(value -> { + assertThat(value.exceptionCode()).isEqualTo("CUSTOMS_HOLD"); + assertThat(value.active()).isTrue(); + assertThat(value.providerStatus()).isEqualTo("CONTAINER ON HOLD - Customs Clearance"); + }); + } + + @Test + void mapsDestinationDischargeAndAvailabilityWithoutUsingEtaAsActualArrival() { + assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("LOAD TO VESSEL")) + .isEqualTo("LOADED_ON_VESSEL"); + assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("DISCHARGE FROM VESSEL")) + .isEqualTo("DISCHARGED_FROM_VESSEL"); + assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("AVAILABLE FOR PICKUP")) + .isEqualTo("PICKUP_READY"); + assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("OUTGATE")).isEqualTo("PICKED_UP"); + assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("EMPTY RETURNED")) + .isEqualTo("EMPTY_RETURNED"); + assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("PICKUP APPOINTMENT")) + .isNull(); + assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("CONTAINER ON HOLD - Customs Clearance")) + .isNull(); + assertThat(MatsonCarrierScheduleProviderAdapter.exceptionCode( + "CONTAINER ON HOLD - Customs Clearance")).isEqualTo("CUSTOMS_HOLD"); + } + + @Test + void emptyBusinessObjectReturnsNoFacts() throws Exception { + String raw = "{}"; + MatsonTrackingClient client = mock(MatsonTrackingClient.class); + when(client.configured()).thenReturn(true); + when(client.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU5164086"))) + .thenReturn(new TrackingResponse(objectMapper.readTree(raw), raw)); + var result = new MatsonCarrierScheduleProviderAdapter(client) + .query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU5164086")); + assertThat(result.outcome()).isEqualTo("NO_CHANGE"); + assertThat(result.observations()).isEmpty(); + assertThat(result.exceptions()).isEmpty(); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/MatsonTrackingClientTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/MatsonTrackingClientTest.java new file mode 100644 index 000000000..e0bd01d1d --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/MatsonTrackingClientTest.java @@ -0,0 +1,73 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +class MatsonTrackingClientTest { + private HttpServer server; + + @AfterEach + void stopServer() { + if (server != null) server.stop(0); + } + + @Test + void obtainsDynamicTtrAndCookieBeforePostingAjaxForm() throws Exception { + AtomicReference postedBody = new AtomicReference<>(); + AtomicReference postedCookie = new AtomicReference<>(); + AtomicReference requestedWith = new AtomicReference<>(); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/page", exchange -> { + exchange.getResponseHeaders().add("Set-Cookie", "matson-session=abc123; Path=/"); + respond(exchange, 200, ""); + }); + server.createContext("/query", exchange -> { + postedBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + postedCookie.set(exchange.getRequestHeaders().getFirst("Cookie")); + requestedWith.set(exchange.getRequestHeaders().getFirst("X-Requested-With")); + respond(exchange, 200, "{\"containerNumber\":\"MATU459895\",\"checkDigit\":\"6\",\"events\":[]}"); + }); + server.start(); + + int port = server.getAddress().getPort(); + var matson = new IntegrationProperties.CarrierSchedule.Matson(true, + URI.create("http://127.0.0.1:" + port + "/page"), + URI.create("http://127.0.0.1:" + port + "/query"), + Duration.ofSeconds(2), Duration.ofSeconds(2), Duration.ZERO); + MatsonTrackingClient client = new MatsonTrackingClient(); + ReflectionTestUtils.setField(client, "properties", new IntegrationProperties(null, null, + new IntegrationProperties.CarrierSchedule(matson, null, null, null, null))); + ReflectionTestUtils.setField(client, "objectMapper", new ObjectMapper()); + client.initialize(); + + var result = client.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU4598956")); + + assertThat(result.payload().path("containerNumber").asText()).isEqualTo("MATU459895"); + assertThat(postedBody.get()).contains("container_trackingnumber=MATU4598956") + .contains("trackingVal=tckconta").contains("ttr=dynamic123"); + assertThat(postedCookie.get()).contains("matson-session=abc123"); + assertThat(requestedWith.get()).isEqualTo("XMLHttpRequest"); + } + + private static void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/SmlCarrierScheduleProviderAdapterTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/SmlCarrierScheduleProviderAdapterTest.java new file mode 100644 index 000000000..517556227 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/SmlCarrierScheduleProviderAdapterTest.java @@ -0,0 +1,71 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class SmlCarrierScheduleProviderAdapterTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void sevenSampleBoundaryUsesActTypeAndLeavesFitmdoUnmapped() throws Exception { + String timeline = """ + {"TRANS_RESULT_KEY":"S","count":9,"events":[ + {"actCd":"FOTMAD","actTpCd":"A","eventDt":"2026-08-01 15:55"}, + {"actCd":"FLVMLO","actTpCd":"A","eventDt":"2026-08-04 19:45"}, + {"actCd":"FLVMDO","actTpCd":"A","eventDt":"2026-08-07 17:00"}, + {"actCd":"FUVMAD","actTpCd":"E","eventDt":"2026-08-23 14:00"}, + {"actCd":"FUVMBD","actTpCd":"E","eventDt":"2026-08-23 16:00"}, + {"actCd":"FUVMUD","actTpCd":"E","eventDt":"2026-08-24 08:00"}, + {"actCd":"FITMDO","actTpCd":"E","eventDt":"2026-08-25 08:00"}, + {"actCd":"MITYAD","actTpCd":"E","eventDt":"2026-09-01 08:00"}, + {"actCd":"MOTYDO","actTpCd":"A","eventDt":"2026-07-28 08:00"}]} + """; + SmlTrackingClient client = mock(SmlTrackingClient.class); + when(client.configured()).thenReturn(true); + var response = new SmlTrackingClient.TrackingResponse("SMLBKG", "COP1", + objectMapper.readTree(timeline), objectMapper.createObjectNode(), objectMapper.createObjectNode(), timeline); + when(client.query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890"))).thenReturn(response); + + var result = new SmlCarrierScheduleProviderAdapter(client) + .query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890")); + + assertThat(result.observations()).hasSize(7); + assertThat(result.observations()).filteredOn(value -> value.actualAt() != null) + .extracting(value -> value.standardNode()) + .containsExactlyInAnyOrder("AT_ORIGIN_PORT", "LOADED_ON_VESSEL", "DEPARTED_ORIGIN_PORT"); + assertThat(result.observations()).filteredOn(value -> value.estimatedAt() != null).hasSize(4); + assertThat(result.observations()).extracting(value -> value.observedAt()) + .containsOnly(result.observations().get(0).observedAt()); + assertThat(result.observations()).extracting(value -> value.providerNodeCode()) + .doesNotContain("FITMDO", "MOTYDO"); + } + + @Test + void onlyActualMityadCanBecomeActualEmptyReturn() throws Exception { + String timeline = """ + {"events":[ + {"actCd":"MITYAD","actTpCd":"E","eventDt":"2026-09-01 08:00"}, + {"actCd":"MITYAD","actTpCd":"A","eventDt":"2026-09-02 09:00"}]} + """; + var response = new SmlTrackingClient.TrackingResponse("BKG", "COP", objectMapper.readTree(timeline), + objectMapper.createObjectNode(), objectMapper.createObjectNode(), timeline); + var values = new SmlCarrierScheduleProviderAdapter(mock(SmlTrackingClient.class)) + .observations("SMCU7019890", response); + assertThat(values).filteredOn(value -> value.actualAt() != null).singleElement() + .satisfies(value -> assertThat(value.standardNode()).isEqualTo("EMPTY_RETURNED")); + assertThat(values).filteredOn(value -> value.estimatedAt() != null).singleElement(); + } + + @Test + void ambiguousFitmdoAndCargoEmptyReleaseStayUnmapped() { + assertThat(SmlCarrierScheduleProviderAdapter.standardNode("FITMDO")).isNull(); + assertThat(SmlCarrierScheduleProviderAdapter.standardNode("MOTYDO")).isNull(); + assertThat(SmlCarrierScheduleProviderAdapter.standardNode("FUVMUD")) + .isEqualTo("DISCHARGED_FROM_VESSEL"); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/SmlTrackingClientTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/SmlTrackingClientTest.java new file mode 100644 index 000000000..6ed6966e2 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/SmlTrackingClientTest.java @@ -0,0 +1,147 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SmlTrackingClientTest { + private HttpServer server; + + @AfterEach + void stopServer() { if (server != null) server.stop(0); } + + @Test + void executesTheConfirmed122125124126Chain() throws Exception { + List forms = new ArrayList<>(); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/track", exchange -> { + String form = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + forms.add(form); + String body = form.contains("f_cmd=122") + ? "{\"TRANS_RESULT_KEY\":\"S\",\"count\":1,\"data\":[{\"bkgNo\":\"BKG1\",\"copNo\":\"COP1\"}]}" + : form.contains("f_cmd=125") + ? "{\"TRANS_RESULT_KEY\":\"S\",\"count\":1,\"events\":[{\"actCd\":\"FOTMAD\",\"actTpCd\":\"A\",\"eventDt\":\"2026-08-01 15:55\"}]}" + : "{\"TRANS_RESULT_KEY\":\"S\",\"count\":0}"; + respond(exchange, 200, body); + }); + server.start(); + + var result = new SmlCarrierScheduleProviderAdapter(client()) + .query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890")); + + assertThat(result.outcome()).isEqualTo("SUCCESS"); + assertThat(result.observations()).singleElement() + .satisfies(value -> assertThat(value.sourceReference()).isEqualTo("SML:BKG1:COP1")); + assertThat(forms).hasSize(4); + assertThat(forms.get(0)).contains("f_cmd=122").contains("cntr_no=SMCU7019890"); + assertThat(forms.get(1)).contains("f_cmd=125").contains("bkg_no=BKG1").contains("cop_no=COP1"); + assertThat(forms.get(2)).contains("f_cmd=124").contains("bkg_no=BKG1"); + assertThat(forms.get(3)).contains("f_cmd=126").contains("cntr_no=SMCU7019890"); + } + + @Test + void nonJsonAndMissingBusinessKeysFailClosed() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/track", exchange -> respond(exchange, 200, "schema changed")); + server.start(); + assertThatThrownBy(() -> client().query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890"))) + .isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class, + value -> assertThat(value.code()).contains("NON_JSON")); + + server.stop(0); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/track", exchange -> respond(exchange, 200, + "{\"TRANS_RESULT_KEY\":\"S\",\"count\":1}")); + server.start(); + assertThatThrownBy(() -> client().query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890"))) + .isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class, + value -> assertThat(value.code()).isEqualTo("SML_BUSINESS_KEYS_MISSING")); + } + + @Test + void providerFailureAndZeroCountAreTraceableNotFoundResults() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/track", exchange -> respond(exchange, 200, + "{\"TRANS_RESULT_KEY\":\"F\",\"count\":1}")); + server.start(); + assertThatThrownBy(() -> client().query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890"))) + .isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class, + value -> assertThat(value.code()).isEqualTo("SML_BUSINESS_FAILED")); + + server.stop(0); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/track", exchange -> respond(exchange, 200, + "{\"TRANS_RESULT_KEY\":\"S\",\"count\":0}")); + server.start(); + assertThatThrownBy(() -> client().query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890"))) + .isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class, + value -> assertThat(value.code()).isEqualTo("SML_NOT_FOUND")); + } + + @Test + void nonSuccessHttpAndTimeoutRemainTraceableRetryableFailures() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/track", exchange -> respond(exchange, 503, "{}")); + server.start(); + assertThatThrownBy(() -> client().query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890"))) + .isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class, value -> { + assertThat(value.code()).isEqualTo("SML_LOOKUP_HTTP_503"); + assertThat(value.retryable()).isTrue(); + }); + + server.stop(0); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/track", exchange -> { + try { Thread.sleep(200); } + catch (InterruptedException exception) { Thread.currentThread().interrupt(); } + respond(exchange, 200, "{}"); + }); + server.start(); + assertThatThrownBy(() -> client(Duration.ofMillis(50)) + .query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890"))) + .isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class, value -> { + assertThat(value.code()).isEqualTo("SML_LOOKUP_TIMEOUT"); + assertThat(value.retryable()).isTrue(); + }); + } + + private SmlTrackingClient client() { + return client(Duration.ofSeconds(2)); + } + + private SmlTrackingClient client(Duration requestTimeout) { + var sml = new IntegrationProperties.CarrierSchedule.Sml(true, + URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/track"), + Duration.ofSeconds(2), requestTimeout, Duration.ZERO); + SmlTrackingClient client = new SmlTrackingClient(); + ReflectionTestUtils.setField(client, "properties", new IntegrationProperties(null, null, + new IntegrationProperties.CarrierSchedule(null, null, sml, null, null))); + ReflectionTestUtils.setField(client, "objectMapper", new ObjectMapper()); + client.initialize(); + return client; + } + + private static void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json; charset=UTF-8"); + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/ZimCarrierScheduleProviderAdapterTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/ZimCarrierScheduleProviderAdapterTest.java new file mode 100644 index 000000000..9bb1064a6 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/ZimCarrierScheduleProviderAdapterTest.java @@ -0,0 +1,106 @@ +package com.baiyue.integration.provider; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class ZimCarrierScheduleProviderAdapterTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void v2FixtureSelectsOnlyTheExactContainerAndSeparatesActualFromPlans() throws Exception { + String fixture = """ + {"bookingReference":" BK-STRING-001 ","blReference":" BL-STRING-001 ", + "finalETA":{"etaValue":"2023-09-03T08:00:00","etaValueDate":"2023-09-03","podInd":"ETA"}, + "routeDetails":[ + {"sailingDateTz":"2023-08-10T12:00:00","arrivalDateTz":"2023-08-17T06:00:00","portCode":"CNNGB","port":"Ningbo","country":"CN","vesselName":"V1","voyage":"001E","leg":"1"}, + {"sailingDateDT":"2023-08-18T10:00:00","arrivalDateDT":"2023-09-02T04:00:00","portCode":"USNYC","port":"New York","country":"US","vesselName":"V2","voyage":"002E","leg":"2"}, + {"portCode":"USNYC","port":"New York","country":"US"}], + "bkBlDetails":{"consContainerList":[ + {"containerNumber":"SIBLING000001 ","containerEventsList":[ + {"activityDateTz":"2023-08-01T01:00:00","eventName":"Export Gate-In to Port of Loading","portCode":"CNNGB"}]}, + {"containerNumber":" zcsu7881970 ","containerEventsList":[ + {"activityDateTz":"2023-08-01T01:00:00","eventName":"Empty container dispatched from Port of Loading to Customer","portCode":"CNNGB"}, + {"activityDateTz":"2023-08-10T02:00:00","eventName":"Export gate-in from Customer to Port of Loading","portCode":"CNNGB","port":"Ningbo","country":"CN"}, + {"activityDateTz":"2023-08-18T11:00:00","eventName":"Vessel departure from first Transshipment Port to next Transshipment Port","portCode":"SGSIN","port":"Singapore","country":"SG","vesselName":"V2","voyage":"002E","leg":"2"}, + {"activityDateTz":"2023-08-19T12:00:00","eventName":"Container was loaded at Transshipment Port to Transshipment Port","portCode":"SGSIN","port":"Singapore","country":"SG","vesselName":"V2","voyage":"002E","leg":"2"}, + {"activityDate":"2023-08-20T12:00:00","eventName":"Container was loaded at Transshipment Port to Transshipment Port","portCode":"SGSIN","port":"Singapore","country":"SG","vesselName":"V2","voyage":"002E","leg":"2"}, + {"activityDateTz":"2023-08-21T12:00:00","eventName":"Unknown Future Event","portCode":"SGSIN"}]}]}} + """; + var payload = objectMapper.readValue(fixture, ZimTrackingClient.TracingPayload.class); + var response = new ZimTrackingClient.TrackingResponse(payload, fixture); + LocalDateTime queriedAt = LocalDateTime.of(2026, 8, 21, 12, 0); + + var values = new ZimCarrierScheduleProviderAdapter(mock(ZimTrackingClient.class)) + .observations("ZCSU7881970", response, queriedAt); + + assertThat(values).hasSize(7); + assertThat(values).extracting(value -> value.observedAt()).containsOnly(queriedAt); + assertThat(values).filteredOn(value -> value.actualAt() != null) + .extracting(value -> value.standardNode()) + .containsExactlyInAnyOrder("AT_ORIGIN_PORT", "IN_TRANSIT", "LOADED_ON_VESSEL", "LOADED_ON_VESSEL"); + assertThat(values).filteredOn(value -> value.estimatedAt() != null) + .extracting(value -> value.standardNode()) + .containsExactlyInAnyOrder("DEPARTED_ORIGIN_PORT", "ARRIVED_DESTINATION_PORT", + "ARRIVED_DESTINATION_PORT"); + assertThat(values).noneMatch(value -> LocalDateTime.of(2023, 8, 1, 1, 0).equals(value.actualAt())); + assertThat(values).extracting(value -> value.providerNodeCode()) + .doesNotContain("EMPTY CONTAINER DISPATCHED FROM PORT OF LOADING TO CUSTOMER", "UNKNOWN FUTURE EVENT"); + assertThat(values).allSatisfy(value -> assertThat(value.sourceReference()) + .isEqualTo("ZIM:CONTAINER:ZCSU7881970|BOOKING:BK-STRING-001|BL:BL-STRING-001")); + } + + @Test + void eventNameMappingIsAnExactExpandableWhitelist() { + assertThat(ZimCarrierScheduleProviderAdapter.standardNode( + "Export Gate-In to Port of Loading")).isEqualTo("AT_ORIGIN_PORT"); + assertThat(ZimCarrierScheduleProviderAdapter.standardNode( + "Container was loaded at Transshipment Port to Transshipment Port")).isEqualTo("LOADED_ON_VESSEL"); + assertThat(ZimCarrierScheduleProviderAdapter.standardNode( + "Vessel departure from first Transshipment Port to next Transshipment Port")).isEqualTo("IN_TRANSIT"); + assertThat(ZimCarrierScheduleProviderAdapter.standardNode( + "Container loaded at Port of Loading")).isEqualTo("LOADED_ON_VESSEL"); + assertThat(ZimCarrierScheduleProviderAdapter.standardNode( + "Vessel departure from Port of Loading")).isEqualTo("DEPARTED_ORIGIN_PORT"); + assertThat(ZimCarrierScheduleProviderAdapter.standardNode( + "Container was loaded at Port of Loading to Port of Discharge")).isEqualTo("LOADED_ON_VESSEL"); + assertThat(ZimCarrierScheduleProviderAdapter.standardNode( + "Vessel departure from Port of Loading to Port of Discharge")).isEqualTo("DEPARTED_ORIGIN_PORT"); + assertThat(ZimCarrierScheduleProviderAdapter.standardNode( + "Empty container dispatched from Port of Loading to Customer")).isNull(); + assertThat(ZimCarrierScheduleProviderAdapter.standardNode("Unknown Future Event")).isNull(); + } + + @Test + void liveBkBlOnlyFixtureToleratesMissingPlansAndDoesNotAdvanceCarrierRelease() throws Exception { + String fixture = """ + {"bkBlDetails":{"consContainerList":[ + {"containerNumber":" ZCSU7205614 ","containerEventsList":[ + {"activityDateTz":"2026-08-01T01:00:00Z","eventName":"Empty container dispatched from inland point to Customer","portCode":"CNNGB"}, + {"activityDateTz":"2026-08-03T02:00:00Z","eventName":"Export gate-in from Customer to Port of Loading","portCode":"CNNGB","port":"Ningbo","country":"CN"}, + {"activityDateTz":"2026-08-05T03:00:00Z","eventName":"Container was loaded at Port of Loading to Port of Discharge","portCode":"CNNGB","port":"Ningbo","country":"CN","vesselName":"LIVE VESSEL","voyage":"001E","leg":"1"}, + {"activityDateTz":"2026-08-06T04:00:00Z","eventName":"Vessel departure from Port of Loading to Port of Discharge","portCode":"CNNGB","port":"Ningbo","country":"CN","vesselName":"LIVE VESSEL","voyage":"001E","leg":"1"}, + {"activityDateTz":"2026-08-20T05:00:00Z","eventName":"Carrier Release","portCode":"USLAX","port":"Los Angeles","country":"US"}]}, + {"containerNumber":"ZCSU7960370","containerEventsList":[ + {"activityDateTz":"2026-08-07T00:00:00Z","eventName":"Export gate-in from Customer to Port of Loading"}]}]}} + """; + var payload = objectMapper.readValue(fixture, ZimTrackingClient.TracingPayload.class); + var response = new ZimTrackingClient.TrackingResponse(payload, fixture); + + var values = new ZimCarrierScheduleProviderAdapter(mock(ZimTrackingClient.class)) + .observations("zcsu7205614", response, LocalDateTime.of(2026, 8, 21, 12, 0)); + + assertThat(payload.routeDetails()).isNull(); + assertThat(payload.finalETA()).isNull(); + assertThat(values).hasSize(3).allSatisfy(value -> assertThat(value.actualAt()).isNotNull()); + assertThat(values).extracting(value -> value.standardNode()) + .containsExactlyInAnyOrder("AT_ORIGIN_PORT", "LOADED_ON_VESSEL", "DEPARTED_ORIGIN_PORT"); + assertThat(values).extracting(value -> value.standardNode()).doesNotContain("PICKUP_READY"); + assertThat(values).extracting(value -> value.providerNodeCode()) + .doesNotContain("CARRIER RELEASE", "EMPTY CONTAINER DISPATCHED FROM INLAND POINT TO CUSTOMER"); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/ZimTrackingClientTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/ZimTrackingClientTest.java new file mode 100644 index 000000000..01e0f9de7 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/ZimTrackingClientTest.java @@ -0,0 +1,152 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ZimTrackingClientTest { + private HttpServer server; + + @AfterEach + void stopServer() { if (server != null) server.stop(0); } + + @Test + void usesOAuthTokenCacheAndContainerOnlyTracingPath() throws Exception { + Credentials credentials = Credentials.random(); + String accessToken = UUID.randomUUID().toString(); + AtomicInteger authCalls = new AtomicInteger(); + AtomicReference authForm = new AtomicReference<>(); + AtomicReference tracingPath = new AtomicReference<>(); + server = server(); + server.createContext("/authorize/v1", exchange -> { + authCalls.incrementAndGet(); authForm.set(body(exchange)); + respond(exchange, 200, tokenBody(accessToken)); + }); + server.createContext("/tracing/v2", exchange -> { + tracingPath.set(exchange.getRequestURI().getPath()); + assertThat(exchange.getRequestHeaders().getFirst("Authorization")).isEqualTo("bearer " + accessToken); + assertThat(exchange.getRequestHeaders().getFirst("Ocp-Apim-Subscription-Key")) + .isEqualTo(credentials.subscriptionKey()); + respond(exchange, 200, successBody()); + }); + server.start(); + ZimTrackingClient client = client(credentials); + + client.query(query()); client.query(query()); + + assertThat(authCalls).hasValue(1); + assertThat(authForm.get()).contains("grant_type=client_credentials", "scope=tracing") + .contains("client_id=", "client_secret="); + assertThat(tracingPath.get()).isEqualTo("/tracing/v2/ZCSU7881970"); + } + + @Test + void unauthorizedRefreshesTokenAndRetriesTracingExactlyOnce() throws Exception { + Credentials credentials = Credentials.random(); + String firstToken = UUID.randomUUID().toString(); + String secondToken = UUID.randomUUID().toString(); + AtomicInteger authCalls = new AtomicInteger(); + AtomicInteger tracingCalls = new AtomicInteger(); + server = server(); + server.createContext("/authorize/v1", exchange -> { + int call = authCalls.incrementAndGet(); respond(exchange, 200, tokenBody(call == 1 ? firstToken : secondToken)); + }); + server.createContext("/tracing/v2", exchange -> { + int call = tracingCalls.incrementAndGet(); + respond(exchange, call == 1 ? 401 : 200, call == 1 ? "{}" : successBody()); + }); + server.start(); + + client(credentials).query(query()); + + assertThat(authCalls).hasValue(2); + assertThat(tracingCalls).hasValue(2); + } + + @Test + void forbiddenIsManualAuthorizationFailureAndDoesNotEchoSecrets() throws Exception { + Credentials credentials = Credentials.random(); + server = server(); + server.createContext("/authorize/v1", exchange -> respond(exchange, 200, tokenBody(UUID.randomUUID().toString()))); + server.createContext("/tracing/v2", exchange -> respond(exchange, 403, "forbidden")); + server.start(); + + assertThatThrownBy(() -> client(credentials).query(query())) + .isInstanceOfSatisfying(ZimTrackingClient.ZimTrackingException.class, value -> { + assertThat(value.outcome()).isEqualTo("MANUAL_REQUIRED"); + assertThat(value.code()).isEqualTo("ZIM_HTTP_403"); + assertThat(value.getMessage()).doesNotContain(credentials.clientSecret()) + .doesNotContain(credentials.subscriptionKey()).doesNotContain("ZIM_NOT_FOUND"); + }); + } + + @Test + void emptyOrMissingContainerListRequiresManualHandling() throws Exception { + Credentials credentials = Credentials.random(); + AtomicReference businessBody = new AtomicReference<>( + "{\"bkBlDetails\":{\"consContainerList\":[]}}"); + server = server(); + server.createContext("/authorize/v1", exchange -> respond(exchange, 200, tokenBody(UUID.randomUUID().toString()))); + server.createContext("/tracing/v2", exchange -> respond(exchange, 200, businessBody.get())); + server.start(); + ZimTrackingClient client = client(credentials); + assertThatThrownBy(() -> client.query(query())).isInstanceOfSatisfying( + ZimTrackingClient.ZimTrackingException.class, value -> assertThat(value.code()).isEqualTo("ZIM_NOT_FOUND")); + + businessBody.set("{}"); + assertThatThrownBy(() -> client.query(query())).isInstanceOfSatisfying( + ZimTrackingClient.ZimTrackingException.class, + value -> assertThat(value.code()).isEqualTo("ZIM_EMPTY_RESULT")); + } + + private HttpServer server() throws IOException { return HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); } + private QueryRequest query() { return new QueryRequest("ZIM", "CONTAINER_NO", "ZCSU7881970"); } + + private ZimTrackingClient client(Credentials credentials) { + String root = "http://127.0.0.1:" + server.getAddress().getPort(); + var zim = new IntegrationProperties.CarrierSchedule.Zim(true, URI.create(root + "/authorize/v1"), + URI.create(root + "/tracing/v2"), credentials.clientId(), credentials.clientSecret(), + credentials.subscriptionKey(), Duration.ofSeconds(2), Duration.ofSeconds(2), 10); + ZimTrackingClient client = new ZimTrackingClient(); + ReflectionTestUtils.setField(client, "properties", new IntegrationProperties(null, null, + new IntegrationProperties.CarrierSchedule(null, null, null, zim, null))); + ReflectionTestUtils.setField(client, "objectMapper", new ObjectMapper()); client.initialize(); return client; + } + + private static String body(HttpExchange exchange) throws IOException { + return new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + } + private static String tokenBody(String token) { + return "{\"access_token\":\"" + token + "\",\"expires_in\":3600}"; + } + private static String successBody() { + return "{\"bkBlDetails\":{\"consContainerList\":[{\"containerNumber\":\" ZCSU7881970 \"}]}}"; + } + private static void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json; charset=UTF-8"); + exchange.sendResponseHeaders(status, bytes.length); exchange.getResponseBody().write(bytes); exchange.close(); + } + private record Credentials(String clientId, String clientSecret, String subscriptionKey) { + static Credentials random() { + return new Credentials(UUID.randomUUID().toString(), UUID.randomUUID().toString(), UUID.randomUUID().toString()); + } + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/service/CarrierScheduleProviderServiceTest.java b/integration-service/src/test/java/com/baiyue/integration/service/CarrierScheduleProviderServiceTest.java index e5ec3bc04..e14c800f3 100644 --- a/integration-service/src/test/java/com/baiyue/integration/service/CarrierScheduleProviderServiceTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/service/CarrierScheduleProviderServiceTest.java @@ -3,6 +3,7 @@ package com.baiyue.integration.service; import com.baiyue.integration.dto.CarrierScheduleDtos.Observation; import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; +import com.baiyue.integration.dto.CarrierScheduleDtos.ExceptionObservation; import com.baiyue.integration.provider.CarrierScheduleProviderAdapter; import com.baiyue.integration.provider.CompliantCarrierScheduleProviderAdapter; import org.junit.jupiter.api.Test; @@ -21,6 +22,7 @@ class CarrierScheduleProviderServiceTest { assertThat(result.outcome()).isEqualTo("MANUAL_REQUIRED"); assertThat(result.errorCode()).isEqualTo("PROVIDER_API_NOT_CONFIGURED"); assertThat(result.observations()).isEmpty(); + assertThat(result.exceptions()).isEmpty(); } @Test @@ -30,7 +32,7 @@ class CarrierScheduleProviderServiceTest { @Override public QueryResult query(QueryRequest request) { return new QueryResult("TEST", "SUCCESS", List.of(new Observation("evt", "RAW", "UNKNOWN", LocalDateTime.now(), null, null, null, null, null, - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")), null, null, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")), List.of(), null, null, LocalDateTime.now()); } }; @@ -38,4 +40,20 @@ class CarrierScheduleProviderServiceTest { assertThatThrownBy(() -> service.query(new QueryRequest("COSCO", "CONTAINER_NO", "CSNU1234567"))) .hasMessageContaining("无效标准节点"); } + + @Test + void rejectsProviderHoldsOutsideTheFourFrozenCodes() { + CarrierScheduleProviderAdapter invalid = new CarrierScheduleProviderAdapter() { + @Override public boolean supports(String carrierCode) { return true; } + @Override public QueryResult query(QueryRequest request) { + return new QueryResult("TEST", "SUCCESS", List.of(), List.of(new ExceptionObservation( + "hold-1", "RAW HOLD", "OTHER_HOLD", true, LocalDateTime.now(), null, null, null, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")), null, null, + LocalDateTime.now()); + } + }; + var service = new CarrierScheduleProviderService(List.of(invalid)); + assertThatThrownBy(() -> service.query(new QueryRequest("COSCO", "CONTAINER_NO", "CSNU1234567"))) + .hasMessageContaining("无效异常事实"); + } } diff --git a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java index baa90465c..bade2d63b 100644 --- a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java @@ -80,7 +80,7 @@ class WeightOcrBatchServiceTest { URI.create("https://example.com/meter"), Duration.ofSeconds(1), Duration.ofSeconds(1), inputRoot, outputRoot, DataSize.ofMegabytes(3), 40_000_000L, 50, Duration.ZERO, 0); - return new IntegrationProperties(baidu, null); + return new IntegrationProperties(baidu, null, null); } @Configuration diff --git a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs index 3195018da..d040f1727 100644 --- a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs +++ b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs @@ -87,11 +87,11 @@ async function createManualSchedule(context, suffix, oceanBookingId, customerId) } export async function run(context) { - context.addCoverageGap(MODULE, OPTIONS.scenario, 'ZIM官网自动查询', - 'ZIM启用hCaptcha,本期禁止绕过;真实契约返回MANUAL_REQUIRED并提供官方入口和人工结果绑定', + context.addCoverageGap(MODULE, OPTIONS.scenario, 'ZIM官方API自动查询', + 'ZIM Cargo Tracking API 未配置订阅凭据或来源授权时返回MANUAL_REQUIRED;不记录或回显订阅键', 'external-third-party') - context.addCoverageGap(MODULE, OPTIONS.scenario, 'COSCO官网自动查询', - 'COSCO为动态页面/iframe且没有稳定公开机器接口;真实契约返回MANUAL_REQUIRED,不伪造自动成功', + context.addCoverageGap(MODULE, OPTIONS.scenario, 'COSCO官方API自动查询', + 'COSCO Shipping Open Platform Cargo Tracking API 待注册订阅和凭据;当前返回MANUAL_REQUIRED,不逆向网页WAF或伪造自动成功', 'external-third-party') const suffix = context.marker.replace(/\D/g, '').slice(-10) @@ -100,8 +100,10 @@ export async function run(context) { const byCode = new Map((Array.isArray(carriers) ? carriers : []).map((item) => [item.carrierCode, item])) context.assert(MODULE, '六家官方来源及合规模式完整', carriersResponse, ['MATSON', 'SML', 'ZIM', 'COSCO', 'EMC', 'HEDE'].every((code) => byCode.has(code)) - && byCode.get('ZIM')?.accessMode === 'HCAPTCHA' && byCode.get('ZIM')?.coverageGap === true - && byCode.get('COSCO')?.accessMode === 'DYNAMIC_IFRAME' && byCode.get('COSCO')?.coverageGap === true + && byCode.get('ZIM')?.accessMode === 'OFFICIAL_API' && byCode.get('ZIM')?.coverageGap === true + && byCode.get('ZIM')?.coverageGapCode === 'OFFICIAL_API_CREDENTIALS_REQUIRED' + && byCode.get('COSCO')?.accessMode === 'OFFICIAL_API' && byCode.get('COSCO')?.coverageGap === true + && byCode.get('COSCO')?.coverageGapCode === 'OFFICIAL_API_CREDENTIALS_REQUIRED' && [...byCode.values()].every((item) => item.officialUrl?.startsWith('http') && item.automated === false), `codes=${[...byCode.keys()].join(',') || 'missing'}`, OPTIONS) @@ -130,29 +132,7 @@ export async function run(context) { id(data(replay)?.query?.query?.id) === queryId, `first=${queryId}, replay=${id(data(replay)?.query?.query?.id) || 'missing'}`, OPTIONS) - let version = Number(created.query.query.version) - for (let attemptNo = 1; attemptNo <= 3; attemptNo += 1) { - const response = await request(context, 'POST', `${BASE}/${queryId}/attempts`, - { expectedVersion: version, reason: `第${attemptNo}次官网查询` }, - { 'Idempotency-Key': `${context.marker}-carrier-attempt-${attemptNo}`.slice(0, 128) }) - const detail = context.apiSuccess(MODULE, `第${attemptNo}次查询返回结构化人工兜底`, response, '', OPTIONS) - context.assert(MODULE, `第${attemptNo}次查询记录来源和审计状态`, response, - detail?.query?.query?.status === 'MANUAL_REQUIRED' - && Number(detail?.query?.query?.attemptCount) === attemptNo - && detail?.attempts?.length === attemptNo - && detail?.attempts?.at(-1)?.coverageGap === true - && detail?.attempts?.at(-1)?.coverageGapCode === 'HCAPTCHA_REQUIRED' - && Boolean(detail?.query?.query?.lastAttemptAt) && Boolean(detail?.query?.query?.manualReason), - `status=${detail?.query?.query?.status || 'missing'}, attempts=${detail?.query?.query?.attemptCount ?? 'missing'}`, OPTIONS) - version = Number(detail?.query?.query?.version) - } - - const fourth = await request(context, 'POST', `${BASE}/${queryId}/attempts`, - { expectedVersion: version, reason: '超过窗口上限' }, - { 'Idempotency-Key': `${context.marker}-carrier-attempt-4`.slice(0, 128) }) - step(context, '同一3天窗口第4次查询被服务端拒绝', fourth, - fourth.http === 200 && ['A0409', '409'].includes(codeOf(fourth)), - `HTTP=${fourth.http}, code=${codeOf(fourth)}`) + const version = Number(created.query.query.version) const unknownCarrier = await request(context, 'POST', BASE, { ...createBody, carrierCode: 'UNKNOWN', trackingNo: `${context.marker}-UNKNOWN` }, @@ -175,25 +155,17 @@ export async function run(context) { { 'Idempotency-Key': `${context.marker}-carrier-manual-result`.slice(0, 128) }) const completed = context.apiSuccess(MODULE, '绑定真实人工船期结果', manual, '', OPTIONS) context.assert(MODULE, '人工兜底保留官网尝试且形成有效更新时间和过期状态', manual, - completed?.query?.query?.status === 'SUCCESS' && id(completed?.query?.query?.resultPortScheduleId) === scheduleId + completed?.query?.query?.status === 'READY' && id(completed?.query?.query?.resultPortScheduleId) === scheduleId && Boolean(completed?.query?.query?.lastSuccessAt) && Boolean(completed?.query?.query?.sourceUpdatedAt) && Boolean(completed?.query?.nextAllowedAt) && completed?.query?.expired === false && completed?.histories?.some((item) => item.action === 'MANUAL_RESULT'), `status=${completed?.query?.query?.status || 'missing'}, result=${id(completed?.query?.query?.resultPortScheduleId) || 'missing'}`, OPTIONS) - const early = await request(context, 'POST', `${BASE}/${queryId}/attempts`, - { expectedVersion: Number(completed?.query?.query?.version), reason: '未到三天再次查询' }, - { 'Idempotency-Key': `${context.marker}-carrier-too-early`.slice(0, 128) }) - step(context, '成功后未到3天禁止再次查询', early, - early.http === 200 && ['A0409', '409'].includes(codeOf(early)), - `HTTP=${early.http}, code=${codeOf(early)}`) - const detailResponse = await request(context, 'GET', `${BASE}/${queryId}`) const detail = context.apiSuccess(MODULE, '回查船司查询任务详情与历史', detailResponse, '', OPTIONS) - context.assert(MODULE, '查询尝试和版本历史完整且不伪装自动成功', detailResponse, - detail?.attempts?.length === 3 && detail?.attempts?.every((item) => item.attemptStatus === 'MANUAL_REQUIRED') + context.assert(MODULE, '平台详情不暴露调度尝试且保留业务版本历史', detailResponse, + !Object.hasOwn(detail || {}, 'attempts') && detail?.histories?.some((item) => item.action === 'CREATE') - && detail?.histories?.filter((item) => item.action === 'ATTEMPT').length === 3 && detail?.histories?.some((item) => item.action === 'MANUAL_RESULT'), - `attempts=${detail?.attempts?.length ?? 'missing'}, histories=${detail?.histories?.length ?? 'missing'}`, OPTIONS) + `attemptsExposed=${Object.hasOwn(detail || {}, 'attempts')}, histories=${detail?.histories?.length ?? 'missing'}`, OPTIONS) } diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs index 729e39657..2cd67e92a 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs @@ -4,7 +4,7 @@ import path from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' -import { QUERY_STATUSES, STANDARD_NODES, run } from './phase4-carrier-nodes.mjs' +import { EXCEPTION_CODES, QUERY_STATUSES, STANDARD_NODES, run } from './phase4-carrier-nodes.mjs' const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'phase4-carrier-nodes.mjs'), 'utf8') @@ -14,9 +14,10 @@ function contextFor(routes) { const calls = [] return { calls, - request: async (method, requestPath) => { - calls.push({ method, path: requestPath }) - assert.equal(method, 'GET') + marker: 'T-PHASE4', token: 'token', + request: async (method, requestPath, body, _token, headers = {}) => { + calls.push(method === 'POST' ? { method, path: requestPath, body, headers } : { method, path: requestPath }) + assert.ok(['GET', 'POST'].includes(method)) assert.ok(Object.hasOwn(routes, requestPath), `unexpected path: ${requestPath}`) return response(routes[requestPath]) }, @@ -35,16 +36,22 @@ const task = { id: '9007199254740993', queryNo: 'CSQ9007199254740993', customerId: '10001', oceanBookingId: '20001', containerBookingId: '30001', carrierCode: 'COSCO', trackingType: 'CONTAINER_NO', trackingNo: 'CSNU1234567', status: 'STOPPED', - latestStandardNode: 'PICKUP_READY', latestNodeObservedAt: '2026-08-20T12:00:00', + latestActualNode: 'PICKED_UP', latestActualAt: '2026-08-20T12:00:00', version: 5, lastSuccessAt: '2026-08-20T12:00:00', sourceUpdatedAt: '2026-08-20T12:00:00', - expiresAt: null, stoppedAt: '2026-08-20T12:01:00', stopReason: '目的港可提柜后停止', + expiresAt: null, stoppedAt: null, stopReason: null, } const node = { - id: '40001', providerCode: 'COSCO', providerNodeCode: 'PICKUP', standardNode: 'PICKUP_READY', - nodeSequence: 60, observedAt: '2026-08-20T12:00:00', estimatedAt: null, + id: '40001', providerCode: 'COSCO', providerEventId: 'event-40001', providerNodeCode: 'OUTGATE', standardNode: 'PICKED_UP', + nodeSequence: 90, observedAt: '2026-08-20T12:00:00', estimatedAt: null, actualAt: '2026-08-20T11:55:00', locationCode: 'USLAX', locationName: 'Los Angeles', - sourceReference: null, createdAt: '2026-08-20T12:00:01', + sourceReference: null, rawPayloadHash: 'a'.repeat(64), createdAt: '2026-08-20T12:00:01', } +const manualNode = { ...node, id: '40002', providerCode: 'MANUAL', providerEventId: 'manual-40002', + providerNodeCode: 'PICKED_UP', estimatedAt: '2026-08-20T12:00:00', actualAt: null, rawPayloadHash: 'b'.repeat(64) } +const exception = { id: '45001', providerCode: 'MATSON_PUBLIC_TRACKING', providerEventId: 'hold-1', + providerStatus: 'CONTAINER ON HOLD - Customs Clearance', exceptionCode: 'CUSTOMS_HOLD', active: true, + observedAt: '2026-08-20T12:00:00', locationCode: 'USLAX', locationName: 'Long Beach', + sourceReference: 'MATSON:9974749', rawPayloadHash: 'c'.repeat(64), createdAt: '2026-08-20T12:00:01' } const history = { id: '50001', action: 'STOP', fromVersion: 4, toVersion: 5, reason: null, operatorId: null, operatorName: '系统', createdAt: '2026-08-20T12:01:00', @@ -54,10 +61,12 @@ const carriers = [{ accessMode: 'INTERNAL_PROVIDER', automated: true, coverageGap: false, }] -test('readonly module statically forbids writes and internal execution endpoints', () => { +test('module only permits the documented manual-node write and forbids internal execution endpoints', () => { assert.match(source, /context\.request\('GET'/) assert.doesNotMatch(source, /\bfetch\s*\(/) - for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) { + assert.match(source, /context\.request\('POST',[\s\S]*\/manual-nodes/) + assert.match(source, /'Idempotency-Key'/) + for (const method of ['PUT', 'PATCH', 'DELETE']) { assert.doesNotMatch(source, new RegExp(`context\\.request\\(['"]${method}['"]`)) } for (const endpoint of ['/scheduler', '/attempts', '/manual-results', '/internal', '/pda']) { @@ -65,12 +74,14 @@ test('readonly module statically forbids writes and internal execution endpoints } }) -test('canonical task and node sets are locked to the phase-4 read contract', () => { - assert.deepEqual(QUERY_STATUSES, ['READY', 'MANUAL_REQUIRED', 'ARRIVED', 'PICKUP_READY', 'STOPPED']) +test('canonical task node and exception sets are locked to the phase-4 contract', () => { + assert.deepEqual(QUERY_STATUSES, ['READY', 'MANUAL_REQUIRED', 'STOPPED']) assert.deepEqual(STANDARD_NODES, [ - 'DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'DEPARTED_ORIGIN_PORT', - 'IN_TRANSIT', 'ARRIVED', 'PICKUP_READY', + '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', ]) + assert.deepEqual(EXCEPTION_CODES, ['CUSTOMS_HOLD', 'TERMINAL_HOLD', 'DOCUMENT_HOLD', 'UNKNOWN_HOLD']) }) test('empty page validates read-only pagination without detail calls', async () => { @@ -90,10 +101,12 @@ test('existing task reads page, detail and history with nullable node times', as const context = contextFor({ [`${base}/carriers`]: carriers, [`${base}?page=1&size=20`]: { records: [{ query: task, expired: false }], page: 1, size: 20, total: 1, pages: 1 }, - [`${base}/${task.id}`]: { query: { query: task, expired: false }, nodes: [node], histories: [history] }, + [`${base}/${task.id}`]: { query: { query: task, expired: false }, nodes: [node], exceptions: [exception], histories: [history] }, + [`${base}/${task.id}/manual-nodes`]: { query: { query: task, expired: false }, nodes: [node, manualNode], exceptions: [exception], histories: [history] }, [`${base}/${task.id}/history`]: [history], }) await run(context) - assert.deepEqual(context.calls.map((item) => item.method), ['GET', 'GET', 'GET', 'GET']) + assert.deepEqual(context.calls.map((item) => item.method), ['GET', 'GET', 'GET', 'POST', 'GET']) + assert.equal(context.calls.find((item) => item.method === 'POST').headers['Idempotency-Key'], `T-PHASE4-carrier-manual-${task.id}`) assert.equal(context.calls.at(-1).path, `${base}/${task.id}/history`) }) diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs index bf02e09d2..14d16cc54 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs @@ -2,11 +2,13 @@ const MODULE = 'phase4-carrier-nodes' const OPTIONS = { scenario: 'phase4-carrier-nodes-lifecycle', deploymentBlocker: true } const BASE = '/api/platform/v1/carrier-schedule-queries' -export const QUERY_STATUSES = Object.freeze(['READY', 'MANUAL_REQUIRED', 'ARRIVED', 'PICKUP_READY', 'STOPPED']) +export const QUERY_STATUSES = Object.freeze(['READY', 'MANUAL_REQUIRED', 'STOPPED']) export const STANDARD_NODES = Object.freeze([ - 'DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'DEPARTED_ORIGIN_PORT', - 'IN_TRANSIT', 'ARRIVED', 'PICKUP_READY', + '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', ]) +export const EXCEPTION_CODES = Object.freeze(['CUSTOMS_HOLD', 'TERMINAL_HOLD', 'DOCUMENT_HOLD', 'UNKNOWN_HOLD']) function id(value) { return value == null ? '' : String(value) } function number(value) { return Number.isFinite(Number(value)) } @@ -18,21 +20,32 @@ function validTask(task) { return Boolean(id(task?.id) && task?.queryNo && id(task?.customerId) && id(task?.oceanBookingId)) && QUERY_STATUSES.includes(task?.status) && nullableId(task?.containerBookingId) - && nullableText(task?.latestStandardNode) - && (task?.latestStandardNode == null || STANDARD_NODES.includes(task.latestStandardNode)) - && ['latestNodeObservedAt', 'lastSuccessAt', 'sourceUpdatedAt', 'expiresAt', 'stoppedAt'] + && nullableText(task?.latestActualNode) + && (task?.latestActualNode == null || STANDARD_NODES.includes(task.latestActualNode)) + && number(task?.version) + && ['latestActualAt', 'lastSuccessAt', 'sourceUpdatedAt', 'expiresAt', 'stoppedAt'] .every((field) => nullableIso(task?.[field])) && nullableText(task?.stopReason) } function validNode(node) { - return Boolean(id(node?.id) && node?.providerCode && node?.providerNodeCode) + return Boolean(id(node?.id) && node?.providerCode && node?.providerEventId && node?.providerNodeCode) && STANDARD_NODES.includes(node?.standardNode) && number(node?.nodeSequence) && Number(node.nodeSequence) > 0 && nullableIso(node?.observedAt) && node?.observedAt != null && nullableIso(node?.estimatedAt) && nullableIso(node?.actualAt) && nullableText(node?.locationCode) && nullableText(node?.locationName) && nullableText(node?.sourceReference) && nullableIso(node?.createdAt) + && typeof node?.rawPayloadHash === 'string' && /^[0-9a-f]{64}$/.test(node.rawPayloadHash) +} + +function validException(value) { + return Boolean(id(value?.id) && value?.providerCode && value?.providerEventId && value?.providerStatus) + && EXCEPTION_CODES.includes(value?.exceptionCode) && typeof value?.active === 'boolean' + && nullableIso(value?.observedAt) && value?.observedAt != null + && nullableText(value?.locationCode) && nullableText(value?.locationName) + && nullableText(value?.sourceReference) && nullableIso(value?.createdAt) + && typeof value?.rawPayloadHash === 'string' && /^[0-9a-f]{64}$/.test(value.rawPayloadHash) } function validHistory(item) { @@ -71,12 +84,33 @@ export async function run(context) { const detailResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}`) const detail = context.apiSuccess(MODULE, '查询真实船节点任务详情', detailResponse, `queryId=${queryId}`, OPTIONS) const nodes = Array.isArray(detail?.nodes) ? detail.nodes : [] + const exceptions = Array.isArray(detail?.exceptions) ? detail.exceptions : [] const histories = Array.isArray(detail?.histories) ? detail.histories : [] context.assert(MODULE, '详情返回Task、标准节点与业务历史', detailResponse, validTask(detail?.query?.query) && typeof detail?.query?.expired === 'boolean' && Array.isArray(detail?.nodes) && nodes.every(validNode) + && Array.isArray(detail?.exceptions) && exceptions.every(validException) && Array.isArray(detail?.histories) && histories.every(validHistory), - `status=${detail?.query?.query?.status || 'missing'}, nodes=${nodes.length}, histories=${histories.length}`, OPTIONS) + `status=${detail?.query?.query?.status || 'missing'}, nodes=${nodes.length}, exceptions=${exceptions.length}, histories=${histories.length}`, OPTIONS) + + const task = detail.query.query + const observedAt = task.latestActualAt || '2026-08-21T00:00:00' + const manualResponse = await context.request('POST', `${BASE}/${encodeURIComponent(queryId)}/manual-nodes`, { + expectedVersion: task.version, + standardNode: task.latestActualNode || 'DEPARTED_WAREHOUSE', + observedAt, + estimatedAt: observedAt, + actualAt: null, + locationCode: null, + locationName: null, + reason: '四期船节点人工预计事实验收', + }, context.token, { 'Idempotency-Key': `${context.marker || 'phase4'}-carrier-manual-${queryId}`.slice(0, 128) }) + const manualDetail = context.apiSuccess(MODULE, '人工补录预计节点事实', manualResponse, `queryId=${queryId}`, OPTIONS) + context.assert(MODULE, '人工预计事实可见且不改变任务操作态', manualResponse, + QUERY_STATUSES.includes(manualDetail?.query?.query?.status) + && manualDetail?.nodes?.some((item) => item?.providerCode === 'MANUAL' + && item?.estimatedAt != null && item?.actualAt == null), + `status=${manualDetail?.query?.query?.status || 'missing'}, nodes=${manualDetail?.nodes?.length ?? 'missing'}`, OPTIONS) const historyResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}/history`) const history = context.apiSuccess(MODULE, '查询真实船节点任务历史', historyResponse, `queryId=${queryId}`, OPTIONS)