优化
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', '已消费事件类型');
|
||||
|
||||
+2
-2
@@ -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` 失败关闭复核。
|
||||
|
||||
+4
@@ -20,9 +20,13 @@ public interface CarrierScheduleIntegrationClient {
|
||||
}
|
||||
record QueryRequest(String carrierCode, String trackingType, String trackingNo) { }
|
||||
record QueryResult(String providerCode, String outcome, List<Observation> observations,
|
||||
List<ExceptionObservation> 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) { }
|
||||
}
|
||||
|
||||
-48
@@ -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<Detail> 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) { }
|
||||
}
|
||||
+13
@@ -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<Detail> 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));
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-4
@@ -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;
|
||||
|
||||
|
||||
+33
-6
@@ -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<NodeFact> nodes, List<History> histories) {}
|
||||
public record Detail(QueryView query, List<NodeFact> nodes, List<ExceptionFact> exceptions,
|
||||
List<History> histories) {}
|
||||
}
|
||||
|
||||
+31
@@ -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;
|
||||
}
|
||||
+1
@@ -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;
|
||||
|
||||
+23
@@ -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<CarrierScheduleExceptionFactEntity> {
|
||||
@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);
|
||||
}
|
||||
+2
-2
@@ -10,10 +10,10 @@ import org.apache.ibatis.annotations.Param;
|
||||
public interface CarrierScheduleNodeFactMapper extends BaseMapper<CarrierScheduleNodeFactEntity> {
|
||||
@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))
|
||||
|
||||
+2
-2
@@ -48,7 +48,7 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
|
||||
|
||||
@Select("""
|
||||
SELECT * FROM fl_carrier_schedule_query
|
||||
WHERE status IN ('READY','MANUAL_REQUIRED','ARRIVED','PICKUP_READY')
|
||||
WHERE status IN ('READY','MANUAL_REQUIRED')
|
||||
AND next_allowed_at IS NOT NULL AND next_allowed_at<=#{now}
|
||||
AND (lease_until IS NULL OR lease_until<#{now})
|
||||
ORDER BY next_allowed_at,id LIMIT #{limit}
|
||||
@@ -58,7 +58,7 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
|
||||
@Update("""
|
||||
UPDATE fl_carrier_schedule_query SET lease_owner=#{owner},lease_until=#{leaseUntil},
|
||||
updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE id=#{id} AND status IN ('READY','MANUAL_REQUIRED','ARRIVED','PICKUP_READY')
|
||||
WHERE id=#{id} AND status IN ('READY','MANUAL_REQUIRED')
|
||||
AND next_allowed_at IS NOT NULL AND next_allowed_at<=#{now}
|
||||
AND (lease_until IS NULL OR lease_until<#{now})
|
||||
""")
|
||||
|
||||
+12
@@ -10,11 +10,13 @@ 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.mapper.CarrierScheduleQueryAttemptMapper;
|
||||
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryHistoryMapper;
|
||||
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryMapper;
|
||||
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryOperationMapper;
|
||||
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleNodeFactMapper;
|
||||
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleExceptionFactMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@@ -28,6 +30,7 @@ public class CarrierScheduleQueryRepository {
|
||||
@Resource private CarrierScheduleQueryHistoryMapper histories;
|
||||
@Resource private CarrierScheduleQueryOperationMapper operations;
|
||||
@Resource private CarrierScheduleNodeFactMapper nodes;
|
||||
@Resource private CarrierScheduleExceptionFactMapper exceptions;
|
||||
|
||||
public PageResult<CarrierScheduleQueryEntity> 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<CarrierScheduleExceptionFactEntity> exceptions(long id) {
|
||||
return exceptions.selectList(new LambdaQueryWrapper<CarrierScheduleExceptionFactEntity>()
|
||||
.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;
|
||||
}
|
||||
|
||||
+38
-14
@@ -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<String, Definition> 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<CarrierDescriptor> 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<String, Definition> definitions() {
|
||||
Map<String, Definition> 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<String, Definition> 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) {
|
||||
|
||||
+9
@@ -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<CarrierScheduleIntegrationClient.ExceptionObservation> 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) {
|
||||
|
||||
+111
-26
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -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'}
|
||||
|
||||
+10
-3
@@ -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");
|
||||
}
|
||||
|
||||
+3
-2
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
+14
@@ -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);
|
||||
}
|
||||
}
|
||||
+17
-4
@@ -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 {
|
||||
|
||||
+8
-6
@@ -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
|
||||
|
||||
+6
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+170
-4
@@ -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<CarrierScheduleExceptionFactEntity> 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<CarrierScheduleQueryAttemptEntity> 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);
|
||||
|
||||
+1
-1
@@ -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'")
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
+132
-1
@@ -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();
|
||||
}
|
||||
|
||||
+15
@@ -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; }
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -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; }
|
||||
|
||||
@@ -14,10 +14,16 @@ public final class CarrierScheduleDtos {
|
||||
@NotBlank @Size(max = 128) String trackingNo) { }
|
||||
|
||||
public record QueryResult(String providerCode, String outcome, List<Observation> observations,
|
||||
List<ExceptionObservation> 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) { }
|
||||
}
|
||||
|
||||
+5
-2
@@ -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<String> 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); }
|
||||
}
|
||||
|
||||
+202
@@ -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)<table\\b[^>]*>(.*?)</table>");
|
||||
private static final Pattern ROW = Pattern.compile("(?is)<tr\\b[^>]*>(.*?)</tr>");
|
||||
private static final Pattern CELL = Pattern.compile("(?is)<t[hd]\\b[^>]*>(.*?)</t[hd]>");
|
||||
private static final Pattern COUNTRY_CODE = Pattern.compile("\\(([A-Z]{2})\\)\\s*$");
|
||||
private static final Set<String> 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<Observation> 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<Observation> 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<Observation> 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<Observation> 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<List<String>> rows = rows(tables.group(1));
|
||||
for (int headerIndex = 0; headerIndex < rows.size(); headerIndex++) {
|
||||
Map<String, Integer> header = header(rows.get(headerIndex));
|
||||
if (!header.keySet().containsAll(REQUIRED_HEADERS)) continue;
|
||||
for (int index = headerIndex + 1; index < rows.size(); index++) {
|
||||
List<String> 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<String> 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<List<String>> allRows(String html) {
|
||||
List<List<String>> values = new ArrayList<>(); Matcher tables = TABLE.matcher(html == null ? "" : html);
|
||||
while (tables.find()) values.addAll(rows(tables.group(1))); return values;
|
||||
}
|
||||
private static List<List<String>> rows(String table) {
|
||||
List<List<String>> values = new ArrayList<>(); Matcher rows = ROW.matcher(table);
|
||||
while (rows.find()) {
|
||||
List<String> 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<String, Integer> header(List<String> cells) {
|
||||
Map<String, Integer> 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<String> 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());
|
||||
}
|
||||
}
|
||||
+151
@@ -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)<input\\b[^>]*>");
|
||||
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<String> 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("<html") || lower.contains("<table") || lower.contains("<input");
|
||||
}
|
||||
private void waitForSlot() {
|
||||
long wait = nextRequestAtMillis - System.currentTimeMillis();
|
||||
if (wait <= 0) return;
|
||||
try { Thread.sleep(wait); }
|
||||
catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw retryable("EMC_INTERRUPTED", "等待长荣查询限流窗口时被中断", exception);
|
||||
}
|
||||
}
|
||||
private static void manual(String code, String message) {
|
||||
throw new EvergreenTrackingException("MANUAL_REQUIRED", code, message);
|
||||
}
|
||||
private static EvergreenTrackingException retryable(String code, String message, Throwable cause) {
|
||||
return new EvergreenTrackingException("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); }
|
||||
|
||||
public record TrackingResponse(String containerHtml, String billHtml, String billSuffix, String rawBody) {
|
||||
@Override public String toString() {
|
||||
return "TrackingResponse[billSuffix=" + billSuffix + ",html=<redacted>]";
|
||||
}
|
||||
}
|
||||
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; }
|
||||
}
|
||||
}
|
||||
+161
@@ -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(
|
||||
"<tr[^>]*class=[\\\"'][^\\\"']*read-tr[^\\\"']*[\\\"'][^>]*>([\\s\\S]*?)</tr>",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern CELL_PATTERN = Pattern.compile("<td[^>]*>([\\s\\S]*?)</td>",
|
||||
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<Observation> 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<Observation> observations(String containerNo, String html) throws Exception {
|
||||
return observations(containerNo, html, LocalDateTime.now(ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
private List<Observation> observations(String containerNo, String html, LocalDateTime queriedAt) throws Exception {
|
||||
String normalizedContainer = normalizeIdentifier(containerNo);
|
||||
List<HedeRow> matched = rows(html).stream()
|
||||
.filter(value -> normalizeIdentifier(value.containerNo()).equals(normalizedContainer)).toList();
|
||||
if (matched.isEmpty()) return List.of();
|
||||
String rawHash = sha256(html);
|
||||
List<Observation> 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<Observation> 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<Observation> 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<HedeRow> rows(String html) {
|
||||
List<HedeRow> rows = new ArrayList<>();
|
||||
Matcher rowMatcher = ROW_PATTERN.matcher(html == null ? "" : html);
|
||||
while (rowMatcher.find()) {
|
||||
List<String> 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<String> 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) { }
|
||||
}
|
||||
+123
@@ -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<String> 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; }
|
||||
}
|
||||
}
|
||||
+220
@@ -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<Observation> observations = observations(request, response.payload(), response.rawBody(), queriedAt);
|
||||
List<ExceptionObservation> 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<Observation> 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<Observation> 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<ExceptionObservation> 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<ExceptionObservation> 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());
|
||||
}
|
||||
}
|
||||
+172
@@ -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<String> 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<String> 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; }
|
||||
}
|
||||
}
|
||||
+154
@@ -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<String, String> 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<Observation> 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<Observation> observations(String containerNo, SmlTrackingClient.TrackingResponse response) {
|
||||
return observations(containerNo, response, LocalDateTime.now(ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
private List<Observation> 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<String, Observation> 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<String, Observation> 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<JsonNode> 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<Map.Entry<String, JsonNode>> 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()); }
|
||||
}
|
||||
+165
@@ -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<String> 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<Map.Entry<String, JsonNode>> 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; }
|
||||
}
|
||||
}
|
||||
+199
@@ -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<String, String> 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<Observation> 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<Observation> 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<String, Observation> 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<RouteDetail> 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<String, Observation> 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<String, Observation> 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());
|
||||
}
|
||||
}
|
||||
+207
@@ -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<String> 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<String> 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<String> 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<ContainerDetail> 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<RouteDetail> routeDetails, BkBlDetails bkBlDetails,
|
||||
String bookingReference, String blReference) {
|
||||
public List<RouteDetail> 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<ContainerDetail> consContainerList) {
|
||||
public List<ContainerDetail> safeContainers() {
|
||||
return consContainerList == null ? List.of() : List.copyOf(consContainerList);
|
||||
}
|
||||
}
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record ContainerDetail(String containerNumber, List<ContainerEvent> containerEventsList) {
|
||||
public List<ContainerEvent> 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; }
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}$'}
|
||||
|
||||
+74
@@ -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 = """
|
||||
<html><nav>irrelevant navigation</nav><table><tr><td>other</td></tr></table>
|
||||
<table class="tracking"><tr>
|
||||
<th>Method</th><th>地点</th><th>箱号</th><th>货柜动态</th><th>日期</th><th>柜型</th><th>VGM</th><th>船名航次</th>
|
||||
</tr><tr>
|
||||
<td>CY</td><td>NINGBO, CHINA (CN)</td><td> EGSU9319340 </td><td>Loaded (FCL) on vessel</td>
|
||||
<td>AUG-07-2026</td><td>40HQ</td><td></td><td>EVER LOVELY 1232-067E</td>
|
||||
</tr><tr>
|
||||
<td>CY</td><td>NINGBO, CHINA (CN)</td><td>EITU1262610</td><td>Loaded (FCL) on vessel</td>
|
||||
<td>AUG-06-2026</td><td>40HQ</td><td></td><td>EVER LOVELY 1232-067E</td>
|
||||
</tr></table></html>
|
||||
""";
|
||||
private static final String BILL_HTML = """
|
||||
<html><table><tr><td>预计装船日</td><td>AUG-07-2026</td></tr></table></html>
|
||||
""";
|
||||
|
||||
@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, "<html><table></table></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(
|
||||
"<table><tr><th>箱号</th><th>日期</th></tr><tr><td>EGSU9319340</td><td>AUG-07-2026</td></tr></table>",
|
||||
"EGSU9319340")).isNull();
|
||||
assertThat(EvergreenCarrierScheduleProviderAdapter.trackingRow(CONTAINER_HTML, "OTHER0000000")).isNull();
|
||||
}
|
||||
}
|
||||
+91
@@ -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<String> 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")
|
||||
? "<html><input value='000000000000' name='bl_no'><table></table></html>"
|
||||
: "<html><table><tr><td>预计装船日</td><td>AUG-07-2026</td></tr></table></html>";
|
||||
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(
|
||||
"<input VALUE=\"000000000000\" NAME=\"bl_no\">" )).isEqualTo("000000000000");
|
||||
assertThat(EvergreenTrackingClient.relatedBill(
|
||||
"<a href=\"javascript:GetDispInfo('000000000000')\">detail</a>" )).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, "<html><table></table></html>"));
|
||||
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();
|
||||
}
|
||||
}
|
||||
+95
@@ -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 "<table><tbody><tr class=\"read-tr\">"
|
||||
+ td("***") + td(container) + td("POSEN") + td("2607W") + td(etd) + td(eta)
|
||||
+ td(emptyOut) + td(fullIn) + td(loaded) + td(discharged) + td(fullOut) + td(emptyReturned)
|
||||
+ td("查看") + "</tr></tbody></table>";
|
||||
}
|
||||
|
||||
private static String td(String value) { return "<td>" + value + "</td>"; }
|
||||
}
|
||||
+59
@@ -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<String> 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, "<html><table><tr class=\"read-tr\"><td>***</td>"
|
||||
+ "<td>XYLU8251457</td></tr></table></html>");
|
||||
});
|
||||
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();
|
||||
}
|
||||
}
|
||||
+109
@@ -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();
|
||||
}
|
||||
}
|
||||
+73
@@ -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<String> postedBody = new AtomicReference<>();
|
||||
AtomicReference<String> postedCookie = new AtomicReference<>();
|
||||
AtomicReference<String> 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, "<input type=\"hidden\" id=\"ttr\" name=\"ttr\" value=\"dynamic123\" />");
|
||||
});
|
||||
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();
|
||||
}
|
||||
}
|
||||
+71
@@ -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");
|
||||
}
|
||||
}
|
||||
+147
@@ -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<String> 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, "<html>schema changed</html>"));
|
||||
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();
|
||||
}
|
||||
}
|
||||
+106
@@ -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");
|
||||
}
|
||||
}
|
||||
+152
@@ -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<String> authForm = new AtomicReference<>();
|
||||
AtomicReference<String> 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, "<html>forbidden</html>"));
|
||||
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<String> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -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("无效异常事实");
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user