feat(firstleg): close phase3 outbound customs and schedule gates

This commit is contained in:
2026-08-08 22:36:41 +08:00
parent 789d97675b
commit 66d1aa0d3d
81 changed files with 5196 additions and 36 deletions
@@ -6458,6 +6458,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_evidence (
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_loading_evidence_type (tenant_id, inbound_id, evidence_type),
UNIQUE KEY uk_loading_evidence_seal (tenant_id, seal_no),
KEY idx_loading_evidence_time (tenant_id, inbound_id, updated_at),
CONSTRAINT fk_loading_evidence_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='订单装柜四步照片和铅封证据';
@@ -7937,6 +7938,522 @@ CALL assert_phase3_service_center_schema();
DROP PROCEDURE IF EXISTS assert_phase3_service_center_schema;
-- Phase 3 domestic outbound aggregate and dispatch gate.
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_outbound_dispatch (
id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单引用,不复制订单主数据',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单主对象',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '柜次引用',
status VARCHAR(32) NOT NULL DEFAULT 'READY_FOR_DISPATCH',
version INT UNSIGNED NOT NULL DEFAULT 0,
confirmed_by BIGINT UNSIGNED NULL,
confirmed_by_name VARCHAR(64) NULL,
confirmed_at DATETIME(3) NULL,
reversed_by BIGINT UNSIGNED NULL,
reversed_by_name VARCHAR(64) NULL,
reversed_at DATETIME(3) NULL,
remark VARCHAR(500) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_outbound_dispatch_owner (tenant_id,inbound_id,container_booking_id),
KEY idx_outbound_dispatch_order (tenant_id,order_id,updated_at),
KEY idx_outbound_dispatch_container (container_booking_id,status,updated_at),
CONSTRAINT fk_outbound_dispatch_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order(id),
CONSTRAINT fk_outbound_dispatch_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id),
CONSTRAINT chk_outbound_dispatch_status CHECK (status IN ('READY_FOR_DISPATCH','WAITING_FOR_LOADING'))
) ENGINE=InnoDB COMMENT='三期国内出仓派车状态,不重复创建出仓单';
CREATE TABLE IF NOT EXISTS fl_outbound_dispatch_history (
id BIGINT UNSIGNED NOT NULL,
dispatch_id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL,
container_booking_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(32) NOT NULL,
from_status VARCHAR(32) NULL,
to_status VARCHAR(32) NOT NULL,
remark VARCHAR(500) NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operator_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_outbound_dispatch_history (tenant_id,inbound_id,container_booking_id,created_at,id),
CONSTRAINT fk_outbound_dispatch_history FOREIGN KEY (dispatch_id) REFERENCES fl_outbound_dispatch(id)
) ENGINE=InnoDB COMMENT='国内出仓追加式审计历史';
CREATE TABLE IF NOT EXISTS fl_outbound_dispatch_operation (
id BIGINT UNSIGNED NOT NULL,
dispatch_id BIGINT UNSIGNED NULL COMMENT '首次声明幂等键时允许为空',
operator_id BIGINT UNSIGNED NOT NULL,
operation VARCHAR(32) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_outbound_dispatch_operation (operator_id,operation,idempotency_key),
KEY idx_outbound_dispatch_operation_resource (dispatch_id,created_at),
CONSTRAINT fk_outbound_dispatch_operation FOREIGN KEY (dispatch_id) REFERENCES fl_outbound_dispatch(id),
CONSTRAINT chk_outbound_dispatch_operation_status CHECK (status IN ('PROCESSING','SUCCESS'))
) ENGINE=InnoDB COMMENT='国内出仓写操作幂等声明';
USE baiyue_iam;
DROP PROCEDURE IF EXISTS assert_outbound_dispatch_permission_ids_available;
DELIMITER $$
CREATE PROCEDURE assert_outbound_dispatch_permission_ids_available()
BEGIN
IF EXISTS (
SELECT 1 FROM iam_permission
WHERE (id BETWEEN 5102160 AND 5102164
AND permission_code <> CASE id
WHEN 5102160 THEN 'platform:firstleg:outbound-dispatch:list'
WHEN 5102161 THEN 'platform:firstleg:outbound-dispatch:view'
WHEN 5102162 THEN 'platform:firstleg:outbound-dispatch:check'
WHEN 5102163 THEN 'platform:firstleg:outbound-dispatch:confirm'
WHEN 5102164 THEN 'platform:firstleg:outbound-dispatch:reverse'
END)
OR (permission_code LIKE 'platform:firstleg:outbound-dispatch:%'
AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102160 AND 5102164))
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '三期出仓权限迁移失败:5102160-5102164 ID 或权限码被其他语义占用';
END IF;
END$$
DELIMITER ;
CALL assert_outbound_dispatch_permission_ids_available();
DROP PROCEDURE IF EXISTS assert_outbound_dispatch_permission_ids_available;
INSERT INTO iam_permission
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,
route_path,http_method,sort_no,status,built_in,deleted)
VALUES
(5102160,0,'PLATFORM','MENU',5102000,'platform:firstleg:outbound-dispatch:list','国内出仓','/first-leg/outbound-dispatches','GET',95,'ACTIVE',1,0),
(5102161,0,'PLATFORM','BUTTON',5102160,'platform:firstleg:outbound-dispatch:view','查看出仓聚合',NULL,'GET',10,'ACTIVE',1,0),
(5102162,0,'PLATFORM','BUTTON',5102160,'platform:firstleg:outbound-dispatch:check','检查出仓门禁',NULL,'GET',20,'ACTIVE',1,0),
(5102163,0,'PLATFORM','BUTTON',5102160,'platform:firstleg:outbound-dispatch:confirm','确认国内出仓',NULL,'POST',30,'ACTIVE',1,0),
(5102164,0,'PLATFORM','BUTTON',5102160,'platform:firstleg:outbound-dispatch:reverse','撤销国内出仓',NULL,'POST',40,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type),
permission_code=VALUES(permission_code),permission_name=VALUES(permission_name),parent_id=VALUES(parent_id),
route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),status='ACTIVE',built_in=1,deleted=0;
INSERT IGNORE INTO iam_role_permission(role_id,permission_id,tenant_id)
SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission
ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.permission_code LIKE 'platform:firstleg:outbound-dispatch:%'
WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR');
USE baiyue_firstleg;
-- Phase 3 independent port and sailing schedule observations.
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_port_schedule (
id BIGINT UNSIGNED NOT NULL,
schedule_no VARCHAR(40) NOT NULL,
customer_id BIGINT UNSIGNED NOT NULL COMMENT '当前产品客户ID',
ocean_booking_id BIGINT UNSIGNED NOT NULL,
container_booking_id BIGINT UNSIGNED NULL,
source_type VARCHAR(24) NOT NULL COMMENT 'MANUAL/CARRIER/TERMINAL/API',
source_name VARCHAR(128) NOT NULL,
source_ref VARCHAR(255) NOT NULL,
vessel_name VARCHAR(128) NOT NULL,
voyage_no VARCHAR(64) NOT NULL,
departure_port_code VARCHAR(32) NOT NULL,
departure_port_name VARCHAR(128) NULL,
destination_port_code VARCHAR(32) NOT NULL,
destination_port_name VARCHAR(128) NULL,
etd_at DATETIME(3) NULL,
eta_at DATETIME(3) NULL,
booking_cutoff_at DATETIME(3) NULL COMMENT '截单时间',
customs_cutoff_at DATETIME(3) NULL COMMENT '截关时间',
port_cutoff_at DATETIME(3) NULL COMMENT '截港时间',
observed_at DATETIME(3) NOT NULL COMMENT '来源数据观测时间',
expires_at DATETIME(3) NOT NULL COMMENT '超过该时间即由API计算为过期',
remark VARCHAR(1000) NULL,
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT UNSIGNED NOT NULL,
updated_by_name VARCHAR(64) NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
version INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_port_schedule_no (schedule_no),
UNIQUE KEY uk_port_schedule_source (source_type,source_name,source_ref),
KEY idx_port_schedule_customer (customer_id,observed_at,id),
KEY idx_port_schedule_booking (ocean_booking_id,observed_at,id),
KEY idx_port_schedule_container (container_booking_id,observed_at,id),
KEY idx_port_schedule_expiry (expires_at,id),
CONSTRAINT fk_port_schedule_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
CONSTRAINT fk_port_schedule_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id),
CONSTRAINT chk_port_schedule_customer CHECK (customer_id > 0),
CONSTRAINT chk_port_schedule_source_type CHECK (source_type IN ('MANUAL','CARRIER','TERMINAL','API')),
CONSTRAINT chk_port_schedule_eta CHECK (etd_at IS NULL OR eta_at IS NULL OR eta_at >= etd_at),
CONSTRAINT chk_port_schedule_expiry CHECK (expires_at > observed_at),
CONSTRAINT chk_port_schedule_booking_cutoff CHECK
(booking_cutoff_at IS NULL OR etd_at IS NULL OR booking_cutoff_at <= etd_at),
CONSTRAINT chk_port_schedule_customs_cutoff CHECK
(customs_cutoff_at IS NULL OR etd_at IS NULL OR customs_cutoff_at <= etd_at),
CONSTRAINT chk_port_schedule_port_cutoff CHECK
(port_cutoff_at IS NULL OR etd_at IS NULL OR port_cutoff_at <= etd_at)
) ENGINE=InnoDB COMMENT='独立港务船期来源聚合,不替代订舱快照';
CREATE TABLE IF NOT EXISTS fl_port_schedule_history (
id BIGINT UNSIGNED NOT NULL,
port_schedule_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(24) NOT NULL,
from_version INT UNSIGNED NULL,
to_version INT UNSIGNED NOT NULL,
reason VARCHAR(1000) NULL,
before_snapshot JSON NULL,
after_snapshot JSON NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operator_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_port_schedule_history (port_schedule_id,created_at,id),
CONSTRAINT fk_port_schedule_history FOREIGN KEY (port_schedule_id) REFERENCES fl_port_schedule(id),
CONSTRAINT chk_port_schedule_history_action CHECK (action IN ('CREATE','UPDATE'))
) ENGINE=InnoDB COMMENT='港务船期不可变变更历史';
CREATE TABLE IF NOT EXISTS fl_port_schedule_operation (
id BIGINT UNSIGNED NOT NULL,
port_schedule_id BIGINT UNSIGNED NULL COMMENT '创建请求完成前允许为空',
operator_id BIGINT UNSIGNED NOT NULL,
operation VARCHAR(24) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING',
result_json JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_port_schedule_operation (operator_id,idempotency_key),
KEY idx_port_schedule_operation_resource (port_schedule_id,created_at),
CONSTRAINT fk_port_schedule_operation FOREIGN KEY (port_schedule_id) REFERENCES fl_port_schedule(id),
CONSTRAINT chk_port_schedule_operation_status CHECK (status IN ('PROCESSING','SUCCESS'))
) ENGINE=InnoDB COMMENT='港务船期写操作幂等与请求哈希';
DROP TRIGGER IF EXISTS trg_port_schedule_before_insert;
DROP TRIGGER IF EXISTS trg_port_schedule_before_update;
DELIMITER $$
CREATE TRIGGER trg_port_schedule_before_insert BEFORE INSERT ON fl_port_schedule FOR EACH ROW
BEGIN
IF (SELECT customer_id FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id) <> NEW.customer_id THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船期客户与订舱客户不一致';
END IF;
IF NEW.container_booking_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM fl_ocean_booking_container_allocation
WHERE ocean_booking_id=NEW.ocean_booking_id AND container_booking_id=NEW.container_booking_id
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船期柜次未分配至订舱';
END IF;
END$$
CREATE TRIGGER trg_port_schedule_before_update BEFORE UPDATE ON fl_port_schedule FOR EACH ROW
BEGIN
IF NEW.customer_id <> OLD.customer_id OR NEW.ocean_booking_id <> OLD.ocean_booking_id
OR NOT (NEW.container_booking_id <=> OLD.container_booking_id) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船期客户、订舱和柜次归属不可变更';
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_port_schedule_history_before_update;
DROP TRIGGER IF EXISTS trg_port_schedule_history_before_delete;
DELIMITER $$
CREATE TRIGGER trg_port_schedule_history_before_update BEFORE UPDATE ON fl_port_schedule_history FOR EACH ROW
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='港务船期历史不可修改'; END$$
CREATE TRIGGER trg_port_schedule_history_before_delete BEFORE DELETE ON fl_port_schedule_history FOR EACH ROW
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='港务船期历史不可删除'; END$$
DELIMITER ;
USE baiyue_iam;
DROP PROCEDURE IF EXISTS assert_port_schedule_permission_ids_available;
DELIMITER $$
CREATE PROCEDURE assert_port_schedule_permission_ids_available()
BEGIN
IF EXISTS (
SELECT 1 FROM iam_permission
WHERE (id BETWEEN 5102180 AND 5102183
AND permission_code <> CASE id
WHEN 5102180 THEN 'platform:firstleg:port-schedule:list'
WHEN 5102181 THEN 'platform:firstleg:port-schedule:view'
WHEN 5102182 THEN 'platform:firstleg:port-schedule:create'
WHEN 5102183 THEN 'platform:firstleg:port-schedule:edit'
END)
OR (permission_code LIKE 'platform:firstleg:port-schedule:%'
AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102180 AND 5102183))
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '三期港务船期权限迁移失败:5102180-5102183 ID 或权限码被其他语义占用';
END IF;
END$$
DELIMITER ;
CALL assert_port_schedule_permission_ids_available();
DROP PROCEDURE IF EXISTS assert_port_schedule_permission_ids_available;
INSERT INTO iam_permission
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,
route_path,http_method,sort_no,status,built_in,deleted)
VALUES
(5102180,0,'PLATFORM','MENU',5102000,'platform:firstleg:port-schedule:list','港务船期','/first-leg/port-schedules','GET',120,'ACTIVE',1,0),
(5102181,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule:view','查看港务船期',NULL,'GET',10,'ACTIVE',1,0),
(5102182,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule:create','新增港务船期',NULL,'POST',20,'ACTIVE',1,0),
(5102183,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule:edit','更新港务船期',NULL,'PUT',30,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type),
permission_code=VALUES(permission_code),permission_name=VALUES(permission_name),parent_id=VALUES(parent_id),
route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),
status='ACTIVE',built_in=1,deleted=0;
INSERT IGNORE INTO iam_role_permission(role_id,permission_id,tenant_id)
SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission
ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.permission_code LIKE 'platform:firstleg:port-schedule:%'
WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR');
USE baiyue_firstleg;
-- Phase 3 customs document, supplementation, broker submission and receipt lifecycle.
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_customs_checklist_version (
id BIGINT UNSIGNED NOT NULL, checklist_code VARCHAR(40) NOT NULL, version_no VARCHAR(32) NOT NULL,
country_code VARCHAR(32) NOT NULL, channel_code VARCHAR(40) NOT NULL, cargo_type VARCHAR(40) NOT NULL,
items_json JSON NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY (id),
UNIQUE KEY uk_customs_checklist_version (checklist_code,version_no),
KEY idx_customs_checklist_match (country_code,channel_code,cargo_type,status),
CONSTRAINT chk_customs_checklist_status CHECK (status IN ('ACTIVE','RETIRED'))
) ENGINE=InnoDB COMMENT='三期报关资料清单不可变版本';
CREATE TABLE IF NOT EXISTS fl_customs_case (
id BIGINT UNSIGNED NOT NULL, customs_no VARCHAR(40) NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL, inbound_id BIGINT UNSIGNED NOT NULL,
container_booking_id BIGINT UNSIGNED NOT NULL DEFAULT 0, checklist_version_id BIGINT UNSIGNED NOT NULL,
checklist_code VARCHAR(40) NOT NULL, checklist_version_no VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'DOCUMENTS_PENDING', remark VARCHAR(500) NULL,
created_by BIGINT UNSIGNED NOT NULL, created_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_by BIGINT UNSIGNED NOT NULL,
updated_by_name VARCHAR(64) NULL, updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
version INT UNSIGNED NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_customs_no (customs_no),
UNIQUE KEY uk_customs_case_scope (tenant_id,order_id,inbound_id,container_booking_id),
KEY idx_customs_case_status (status,updated_at), KEY idx_customs_case_container (container_booking_id),
CONSTRAINT fk_customs_case_checklist FOREIGN KEY (checklist_version_id) REFERENCES fl_customs_checklist_version(id),
CONSTRAINT chk_customs_case_status CHECK (status IN ('DOCUMENTS_PENDING','REVIEWING','SUPPLEMENT_REQUIRED','READY_TO_SUBMIT','RECEIPT_PENDING','RECEIPT_AVAILABLE','CLEARANCE_COMPLETED','CANCELLED'))
) ENGINE=InnoDB COMMENT='三期订单报关协同聚合';
CREATE TABLE IF NOT EXISTS fl_customs_document (
id BIGINT UNSIGNED NOT NULL, customs_case_id BIGINT UNSIGNED NOT NULL, checklist_item_code VARCHAR(40) NOT NULL,
file_id BIGINT UNSIGNED NOT NULL, document_version INT UNSIGNED NOT NULL, replaces_document_id BIGINT UNSIGNED NULL,
file_name VARCHAR(255) NOT NULL, file_hash CHAR(64) NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'UPLOADED',
visibility VARCHAR(16) NOT NULL DEFAULT 'CUSTOMER', uploaded_by BIGINT UNSIGNED NOT NULL,
uploaded_by_type VARCHAR(16) NOT NULL, uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
reviewed_by BIGINT UNSIGNED NULL, reviewed_by_name VARCHAR(64) NULL, reviewed_at DATETIME(3) NULL,
review_reason VARCHAR(500) NULL, PRIMARY KEY (id),
UNIQUE KEY uk_customs_document_version (customs_case_id,checklist_item_code,document_version),
UNIQUE KEY uk_customs_document_file (customs_case_id,file_id), KEY idx_customs_document_case (customs_case_id,status),
CONSTRAINT fk_customs_document_case FOREIGN KEY (customs_case_id) REFERENCES fl_customs_case(id),
CONSTRAINT chk_customs_document_status CHECK (status IN ('UPLOADED','APPROVED','RETURNED','SUPERSEDED','FROZEN')),
CONSTRAINT chk_customs_document_actor CHECK (uploaded_by_type IN ('PLATFORM','MERCHANT')),
CONSTRAINT chk_customs_document_visibility CHECK (visibility IN ('PLATFORM','CUSTOMER'))
) ENGINE=InnoDB COMMENT='报关客户文件版本引用';
CREATE TABLE IF NOT EXISTS fl_customs_supplement_request (
id BIGINT UNSIGNED NOT NULL, customs_case_id BIGINT UNSIGNED NOT NULL, source_document_id BIGINT UNSIGNED NOT NULL,
resolved_document_id BIGINT UNSIGNED NULL, checklist_item_code VARCHAR(40) NOT NULL, reason VARCHAR(500) NOT NULL,
due_at DATETIME(3) NULL, status VARCHAR(16) NOT NULL DEFAULT 'OPEN', requested_by BIGINT UNSIGNED NOT NULL,
requested_by_name VARCHAR(64) NULL, requested_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
resolved_by BIGINT UNSIGNED NULL, resolved_at DATETIME(3) NULL, PRIMARY KEY (id),
KEY idx_customs_supplement_case (customs_case_id,status,due_at),
CONSTRAINT fk_customs_supplement_case FOREIGN KEY (customs_case_id) REFERENCES fl_customs_case(id),
CONSTRAINT chk_customs_supplement_status CHECK (status IN ('OPEN','RESOLVED','CANCELLED'))
) ENGINE=InnoDB COMMENT='报关补件请求';
CREATE TABLE IF NOT EXISTS fl_customs_submission_batch (
id BIGINT UNSIGNED NOT NULL, customs_case_id BIGINT UNSIGNED NOT NULL, batch_no VARCHAR(40) NOT NULL,
batch_version INT UNSIGNED NOT NULL, customs_agent_name VARCHAR(128) NOT NULL, external_reference VARCHAR(128) NULL,
file_snapshot_json JSON NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'SUBMITTED', note VARCHAR(500) NULL,
submitted_by BIGINT UNSIGNED NOT NULL, submitted_by_name VARCHAR(64) NULL,
submitted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY (id),
UNIQUE KEY uk_customs_batch_no (batch_no), UNIQUE KEY uk_customs_batch_version (customs_case_id,batch_version),
CONSTRAINT fk_customs_batch_case FOREIGN KEY (customs_case_id) REFERENCES fl_customs_case(id),
CONSTRAINT chk_customs_batch_status CHECK (status IN ('SUBMITTED','ACKNOWLEDGED','REJECTED','COMPLETED'))
) ENGINE=InnoDB COMMENT='报关代理提交批次及冻结文件版本';
CREATE TABLE IF NOT EXISTS fl_customs_receipt (
id BIGINT UNSIGNED NOT NULL, customs_case_id BIGINT UNSIGNED NOT NULL, submission_batch_id BIGINT UNSIGNED NOT NULL,
receipt_type VARCHAR(16) NOT NULL, file_id BIGINT UNSIGNED NOT NULL, receipt_version INT UNSIGNED NOT NULL,
replaces_receipt_id BIGINT UNSIGNED NULL, file_name VARCHAR(255) NOT NULL, file_hash CHAR(64) NOT NULL,
visibility VARCHAR(16) NOT NULL DEFAULT 'PLATFORM', note VARCHAR(500) NULL, uploaded_by BIGINT UNSIGNED NOT NULL,
uploaded_by_name VARCHAR(64) NULL, uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY (id),
UNIQUE KEY uk_customs_receipt_version (submission_batch_id,receipt_type,receipt_version),
UNIQUE KEY uk_customs_receipt_file (submission_batch_id,file_id),
KEY idx_customs_receipt_case (customs_case_id,visibility,uploaded_at),
CONSTRAINT fk_customs_receipt_case FOREIGN KEY (customs_case_id) REFERENCES fl_customs_case(id),
CONSTRAINT fk_customs_receipt_batch FOREIGN KEY (submission_batch_id) REFERENCES fl_customs_submission_batch(id),
CONSTRAINT chk_customs_receipt_type CHECK (receipt_type IN ('RECEIVED','DECLARED','REJECTED','INSPECTION','RELEASED','OTHER')),
CONSTRAINT chk_customs_receipt_visibility CHECK (visibility IN ('PLATFORM','CUSTOMER'))
) ENGINE=InnoDB COMMENT='报关代理回执版本与客户可见范围';
CREATE TABLE IF NOT EXISTS fl_customs_history (
id BIGINT UNSIGNED NOT NULL, customs_case_id BIGINT UNSIGNED NOT NULL, action VARCHAR(40) NOT NULL,
from_status VARCHAR(32) NULL, to_status VARCHAR(32) NULL, resource_type VARCHAR(32) NOT NULL,
resource_id BIGINT UNSIGNED NOT NULL, note VARCHAR(500) NULL, operator_id BIGINT UNSIGNED NOT NULL,
operator_name VARCHAR(64) NULL, operator_type VARCHAR(16) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY (id),
KEY idx_customs_history_case (customs_case_id,created_at,id),
CONSTRAINT fk_customs_history_case FOREIGN KEY (customs_case_id) REFERENCES fl_customs_case(id),
CONSTRAINT chk_customs_history_actor CHECK (operator_type IN ('PLATFORM','MERCHANT'))
) ENGINE=InnoDB COMMENT='报关协同稳定历史';
CREATE TABLE IF NOT EXISTS fl_customs_operation (
id BIGINT UNSIGNED NOT NULL, operator_id BIGINT UNSIGNED NOT NULL, action VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL, request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING', result_type VARCHAR(24) NULL, result_id BIGINT UNSIGNED NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), completed_at DATETIME(3) NULL, PRIMARY KEY (id),
UNIQUE KEY uk_customs_operation (operator_id,action,idempotency_key),
CONSTRAINT chk_customs_operation_status CHECK (status IN ('PROCESSING','SUCCESS'))
) ENGINE=InnoDB COMMENT='报关协同命令幂等记录';
USE baiyue_iam;
DROP PROCEDURE IF EXISTS assert_phase3_customs_permission_ids;
DELIMITER $$
CREATE PROCEDURE assert_phase3_customs_permission_ids()
BEGIN
IF EXISTS (
SELECT 1 FROM iam_permission
WHERE (id BETWEEN 5102170 AND 5102175
AND permission_code <> CASE id
WHEN 5102170 THEN 'platform:firstleg:customs:list'
WHEN 5102171 THEN 'platform:firstleg:customs:view'
WHEN 5102172 THEN 'platform:firstleg:customs:manage'
WHEN 5102173 THEN 'platform:firstleg:customs:review'
WHEN 5102174 THEN 'platform:firstleg:customs:submit'
WHEN 5102175 THEN 'platform:firstleg:customs:receipt'
END)
OR (permission_code LIKE 'platform:firstleg:customs:%'
AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102170 AND 5102175))
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '三期报关权限迁移失败:5102170-5102175 ID 或权限码被其他语义占用';
END IF;
END$$
DELIMITER ;
CALL assert_phase3_customs_permission_ids();
DROP PROCEDURE IF EXISTS assert_phase3_customs_permission_ids;
INSERT INTO iam_permission
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,
route_path,http_method,sort_no,status,built_in,deleted)
VALUES
(5102170,0,'PLATFORM','MENU',5102000,'platform:firstleg:customs:list','报关协同','/first-leg/customs','GET',100,'ACTIVE',1,0),
(5102171,0,'PLATFORM','BUTTON',5102170,'platform:firstleg:customs:view','查看报关协同',NULL,'GET',10,'ACTIVE',1,0),
(5102172,0,'PLATFORM','BUTTON',5102170,'platform:firstleg:customs:manage','维护报关资料',NULL,'POST',20,'ACTIVE',1,0),
(5102173,0,'PLATFORM','BUTTON',5102170,'platform:firstleg:customs:review','审核退回报关资料',NULL,'POST',30,'ACTIVE',1,0),
(5102174,0,'PLATFORM','BUTTON',5102170,'platform:firstleg:customs:submit','提交报关代理',NULL,'POST',40,'ACTIVE',1,0),
(5102175,0,'PLATFORM','BUTTON',5102170,'platform:firstleg:customs:receipt','维护报关回执',NULL,'POST',50,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type),
permission_code=VALUES(permission_code),permission_name=VALUES(permission_name),parent_id=VALUES(parent_id),
route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),status='ACTIVE',built_in=1,deleted=0;
INSERT IGNORE INTO iam_role_permission(role_id,permission_id,tenant_id)
SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission
ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.permission_code LIKE 'platform:firstleg:customs:%'
WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR');
USE baiyue_firstleg;
-- Phase 3 loading evidence hardening for existing databases.
USE baiyue_firstleg;
UPDATE fl_loading_evidence SET seal_no=NULL WHERE evidence_type<>'SEAL' AND seal_no IS NOT NULL;
UPDATE fl_loading_evidence duplicate_evidence
JOIN (
SELECT tenant_id,seal_no,MIN(id) keep_id
FROM fl_loading_evidence
WHERE evidence_type='SEAL' AND seal_no IS NOT NULL AND seal_no<>''
GROUP BY tenant_id,seal_no HAVING COUNT(*)>1
) duplicates ON duplicates.tenant_id=duplicate_evidence.tenant_id
AND duplicates.seal_no=duplicate_evidence.seal_no AND duplicate_evidence.id<>duplicates.keep_id
SET duplicate_evidence.seal_no=NULL;
SET @ddl = IF(
EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='fl_loading_evidence')
AND NOT EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='fl_loading_evidence' AND index_name='uk_loading_evidence_seal'),
'ALTER TABLE fl_loading_evidence ADD UNIQUE KEY uk_loading_evidence_seal (tenant_id,seal_no)',
'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
DROP PROCEDURE IF EXISTS assert_phase3_final_schema;
DELIMITER $$
CREATE PROCEDURE assert_phase3_final_schema()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema='baiyue_firstleg' AND table_name IN (
'fl_outbound_dispatch','fl_outbound_dispatch_history','fl_outbound_dispatch_operation',
'fl_customs_checklist_version','fl_customs_case','fl_customs_document','fl_customs_supplement_request',
'fl_customs_submission_batch','fl_customs_receipt','fl_customs_history','fl_customs_operation',
'fl_port_schedule','fl_port_schedule_history','fl_port_schedule_operation')) <> 14 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期出仓、报关或船期表未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_evidence'
AND index_name='uk_loading_evidence_seal' AND non_unique=0
AND ((seq_in_index=1 AND column_name='tenant_id') OR (seq_in_index=2 AND column_name='seal_no'))) <> 2
OR (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema='baiyue_firstleg' AND table_name='fl_outbound_dispatch'
AND index_name='uk_outbound_dispatch_owner' AND non_unique=0) <> 3
OR (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema='baiyue_firstleg' AND table_name='fl_customs_case'
AND index_name='uk_customs_case_scope' AND non_unique=0) <> 4
OR (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema='baiyue_firstleg' AND table_name='fl_port_schedule'
AND index_name='uk_port_schedule_source' AND non_unique=0) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期关键唯一索引未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.referential_constraints
WHERE constraint_schema='baiyue_firstleg' AND constraint_name IN (
'fk_outbound_dispatch_inbound','fk_outbound_dispatch_container','fk_customs_case_checklist',
'fk_customs_document_case','fk_customs_supplement_case','fk_customs_batch_case',
'fk_customs_receipt_case','fk_customs_receipt_batch','fk_customs_history_case',
'fk_port_schedule_booking','fk_port_schedule_container','fk_port_schedule_history',
'fk_port_schedule_operation')) <> 13 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期关键外键未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.triggers
WHERE trigger_schema='baiyue_firstleg' AND trigger_name IN (
'trg_port_schedule_before_insert','trg_port_schedule_before_update',
'trg_port_schedule_history_before_update','trg_port_schedule_history_before_delete')) <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:港务船期归属或历史保护触发器未完整创建';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM'
AND id BETWEEN 5102160 AND 5102164 AND permission_code LIKE 'platform:firstleg:outbound-dispatch:%') <> 5
OR (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM'
AND id BETWEEN 5102170 AND 5102175 AND permission_code LIKE 'platform:firstleg:customs:%') <> 6
OR (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM'
AND id BETWEEN 5102180 AND 5102183 AND permission_code LIKE 'platform:firstleg:port-schedule:%') <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期出仓、报关或船期权限未完整写入';
END IF;
IF (SELECT COUNT(DISTINCT CONCAT(r.role_code,'|',p.permission_code))
FROM baiyue_iam.iam_role_permission rp
JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id
JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id
WHERE r.tenant_id=0 AND r.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
AND (p.permission_code LIKE 'platform:firstleg:outbound-dispatch:%'
OR p.permission_code LIKE 'platform:firstleg:customs:%'
OR p.permission_code LIKE 'platform:firstleg:port-schedule:%')) <> 30 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期出仓、报关或船期角色授权未完整写入';
END IF;
END$$
DELIMITER ;
CALL assert_phase3_final_schema();
DROP PROCEDURE IF EXISTS assert_phase3_final_schema;
-- WAIT_SPLIT 是二期当前有效终点;统一迁移不得改写主表或历史状态。
SELECT 'PASS' AS migration_status,
'统一增量全部执行完成;箱号映射、单箱测量、三期订舱、拖车预约、出仓、费用、公共资源及可靠审计结构校验通过' AS message;
@@ -1,3 +1,3 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.api.*;import com.baiyue.common.core.exception.BizException;import com.baiyue.file.service.InternalBusinessFileService;import jakarta.annotation.Resource;import jakarta.validation.constraints.*;import org.springframework.beans.factory.annotation.Value;import org.springframework.http.MediaType;import org.springframework.web.bind.annotation.*;import org.springframework.web.multipart.MultipartFile;
@RestController @RequestMapping("/internal/file/v1/business-files") public class InternalBusinessFileController{@Resource InternalBusinessFileService service;@Value("${baiyue.security.internal-service-token}") String expected;private void check(String token){if(token==null||!java.security.MessageDigest.isEqual(token.getBytes(java.nio.charset.StandardCharsets.UTF_8),expected.getBytes(java.nio.charset.StandardCharsets.UTF_8)))throw new BizException(ResultCode.FORBIDDEN,"内部服务令牌无效");}@PostMapping(value="/{tenantId}/{businessType}/{businessId}",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResponse<InternalBusinessFileService.Metadata> upload(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@RequestHeader("X-Operator-Id") long operatorId,@PathVariable @Positive long tenantId,@PathVariable @NotBlank String businessType,@PathVariable @NotBlank String businessId,@RequestPart("file") MultipartFile file){check(token);return ApiResponse.success(service.upload(tenantId,businessType,businessId,operatorId,file));}@GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}") public ApiResponse<InternalBusinessFileService.Metadata> metadata(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@PathVariable @Positive long tenantId,@PathVariable @NotBlank String businessType,@PathVariable @NotBlank String businessId,@PathVariable @Positive long fileId){check(token);return ApiResponse.success(service.metadata(tenantId,businessType,businessId,fileId));}}
@RestController @RequestMapping("/internal/file/v1/business-files") public class InternalBusinessFileController{@Resource InternalBusinessFileService service;@Value("${baiyue.security.internal-service-token}") String expected;private void check(String token){if(token==null||!java.security.MessageDigest.isEqual(token.getBytes(java.nio.charset.StandardCharsets.UTF_8),expected.getBytes(java.nio.charset.StandardCharsets.UTF_8)))throw new BizException(ResultCode.FORBIDDEN,"内部服务令牌无效");}@PostMapping(value="/{tenantId}/{businessType}/{businessId}",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResponse<InternalBusinessFileService.Metadata> upload(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@RequestHeader("X-Operator-Id") long operatorId,@PathVariable @Positive long tenantId,@PathVariable @NotBlank String businessType,@PathVariable @NotBlank String businessId,@RequestPart("file") MultipartFile file){check(token);return ApiResponse.success(service.upload(tenantId,businessType,businessId,operatorId,file));}@GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}") public ApiResponse<InternalBusinessFileService.Metadata> metadata(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@PathVariable @Positive long tenantId,@PathVariable @NotBlank String businessType,@PathVariable @NotBlank String businessId,@PathVariable @Positive long fileId){check(token);return ApiResponse.success(service.metadata(tenantId,businessType,businessId,fileId));}@GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}/content") public org.springframework.http.ResponseEntity<byte[]> download(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@PathVariable @Positive long tenantId,@PathVariable @NotBlank String businessType,@PathVariable @NotBlank String businessId,@PathVariable @Positive long fileId){check(token);var value=service.download(tenantId,businessType,businessId,fileId);String name=value.fileName().replace("\r","").replace("\n","").replace("\"","");return org.springframework.http.ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore()).contentType(MediaType.parseMediaType(value.contentType())).header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=\""+name+"\"").contentLength(value.content().length).body(value.content());}}
@@ -55,6 +55,16 @@ public class BusinessFileStorage {
}
}
byte[] read(String objectKey) {
Path target = root.resolve(objectKey).normalize();
if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界");
try {
return Files.readAllBytes(target);
} catch (IOException exception) {
throw new BizException(ResultCode.NOT_FOUND, "附件内容不存在");
}
}
private void verifyContent(MultipartFile file, String type) {
try (InputStream input = file.getInputStream()) {
byte[] prefix = input.readNBytes(4096);
@@ -12,9 +12,14 @@ import java.time.LocalDateTime;
@Service
public class InternalBusinessFileService {
private static final String TRUCK_ATTACHMENT = "TRUCK_APPOINTMENT_ATTACHMENT";
private static final java.util.Set<String> BUSINESS_TYPES = java.util.Set.of(
"TRUCK_APPOINTMENT_ATTACHMENT", "CUSTOMS_DOCUMENT", "CUSTOMS_RECEIPT");
@Resource FileObjectMapper files; @Resource BusinessFileStorage storage;
public Metadata upload(long tenantId,String businessType,String businessId,long operatorId,MultipartFile file){if(tenantId<=0||operatorId<=0)throw new BizException(ResultCode.BAD_REQUEST,"归属和操作人必须有效");if(!TRUCK_ATTACHMENT.equals(businessType))throw new BizException(ResultCode.BAD_REQUEST,"不支持的附件业务类型");long appointmentId;try{appointmentId=Long.parseLong(businessId);}catch(NumberFormatException ex){throw new BizException(ResultCode.BAD_REQUEST,"预约ID格式无效");}if(appointmentId<=0)throw new BizException(ResultCode.BAD_REQUEST,"预约ID必须有效");var stored=storage.storeLoading(tenantId,appointmentId,file);FileObjectEntity value=new FileObjectEntity();value.setTenantId(tenantId);value.setBusinessType(TRUCK_ATTACHMENT);value.setBusinessId(businessId);value.setOriginalName(stored.originalName());value.setObjectKey(stored.objectKey());value.setStorageProvider("LOCAL");value.setBucketName("business-files");value.setContentType(stored.contentType());value.setSizeBytes(stored.size());value.setSha256(stored.sha256());value.setStatus("ACTIVE");value.setCreatedBy(operatorId);value.setUpdatedBy(operatorId);value.setCreatedAt(LocalDateTime.now());value.setUpdatedAt(value.getCreatedAt());value.setDeleted(false);value.setVersion(0);if(files.insert(value)!=1)throw new BizException(ResultCode.INTERNAL_ERROR,"附件元数据保存失败");return new Metadata(value.getId(),tenantId,TRUCK_ATTACHMENT,businessId,value.getOriginalName(),value.getSha256());}
public Metadata metadata(long tenantId,String businessType,String businessId,long fileId){FileObjectEntity value=files.selectOne(new LambdaQueryWrapper<FileObjectEntity>().eq(FileObjectEntity::getTenantId,tenantId).eq(FileObjectEntity::getBusinessType,businessType).eq(FileObjectEntity::getBusinessId,businessId).eq(FileObjectEntity::getId,fileId).eq(FileObjectEntity::getStatus,"ACTIVE").eq(FileObjectEntity::getDeleted,false));if(value==null)throw new BizException(ResultCode.NOT_FOUND,"业务附件不存在或归属不匹配");return new Metadata(value.getId(),value.getTenantId(),value.getBusinessType(),value.getBusinessId(),value.getOriginalName(),value.getSha256());}
public record Metadata(Long fileId,Long tenantId,String businessType,String businessId,String fileName,String sha256){}
public Metadata upload(long tenantId,String businessType,String businessId,long operatorId,MultipartFile file){if(tenantId<=0||operatorId<=0)throw new BizException(ResultCode.BAD_REQUEST,"归属和操作人必须有效");if(!BUSINESS_TYPES.contains(businessType))throw new BizException(ResultCode.BAD_REQUEST,"不支持的附件业务类型");long parsedBusinessId;try{parsedBusinessId=Long.parseLong(businessId);}catch(NumberFormatException ex){throw new BizException(ResultCode.BAD_REQUEST,"业务ID格式无效");}if(parsedBusinessId<=0)throw new BizException(ResultCode.BAD_REQUEST,"业务ID必须有效");var stored=storage.storeLoading(tenantId,parsedBusinessId,file);FileObjectEntity value=new FileObjectEntity();value.setTenantId(tenantId);value.setBusinessType(businessType);value.setBusinessId(businessId);value.setOriginalName(stored.originalName());value.setObjectKey(stored.objectKey());value.setStorageProvider("LOCAL");value.setBucketName("business-files");value.setContentType(stored.contentType());value.setSizeBytes(stored.size());value.setSha256(stored.sha256());value.setStatus("ACTIVE");value.setCreatedBy(operatorId);value.setUpdatedBy(operatorId);value.setCreatedAt(LocalDateTime.now());value.setUpdatedAt(value.getCreatedAt());value.setDeleted(false);value.setVersion(0);if(files.insert(value)!=1)throw new BizException(ResultCode.INTERNAL_ERROR,"附件元数据保存失败");return metadata(value);}
public Metadata metadata(long tenantId,String businessType,String businessId,long fileId){return metadata(required(tenantId,businessType,businessId,fileId));}
public Download download(long tenantId,String businessType,String businessId,long fileId){FileObjectEntity value=required(tenantId,businessType,businessId,fileId);return new Download(value.getOriginalName(),value.getContentType(),storage.read(value.getObjectKey()));}
private FileObjectEntity required(long tenantId,String businessType,String businessId,long fileId){FileObjectEntity value=files.selectOne(new LambdaQueryWrapper<FileObjectEntity>().eq(FileObjectEntity::getTenantId,tenantId).eq(FileObjectEntity::getBusinessType,businessType).eq(FileObjectEntity::getBusinessId,businessId).eq(FileObjectEntity::getId,fileId).eq(FileObjectEntity::getStatus,"ACTIVE").eq(FileObjectEntity::getDeleted,false));if(value==null)throw new BizException(ResultCode.NOT_FOUND,"业务附件不存在或归属不匹配");return value;}
private static Metadata metadata(FileObjectEntity value){return new Metadata(value.getId(),value.getTenantId(),value.getBusinessType(),value.getBusinessId(),value.getOriginalName(),value.getSha256(),value.getContentType(),value.getSizeBytes());}
public record Metadata(Long fileId,Long tenantId,String businessType,String businessId,String fileName,String sha256,String contentType,Long sizeBytes){}
public record Download(String fileName,String contentType,byte[] content){}
}
@@ -0,0 +1,58 @@
package com.baiyue.file.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.*;
import org.springframework.mock.web.MockMultipartFile;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class InternalBusinessFileServiceTest {
@Mock FileObjectMapper files;
@Mock BusinessFileStorage storage;
@InjectMocks InternalBusinessFileService service;
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this); }
@Test void customsDocumentUploadPersistsExactBusinessOwnership() {
MockMultipartFile file = new MockMultipartFile("file", "invoice.txt", "text/plain", "invoice".getBytes());
when(storage.storeLoading(10001L, 41L, file)).thenReturn(new BusinessFileStorage.Stored(
"business-files/10001/41/key.txt", "text/plain", 7, "a".repeat(64), "invoice.txt"));
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
((FileObjectEntity) invocation.getArgument(0)).setId(71L); return 1;
});
var result = service.upload(10001L, "CUSTOMS_DOCUMENT", "41", 7L, file);
assertThat(result.fileId()).isEqualTo(71L);
assertThat(result.businessType()).isEqualTo("CUSTOMS_DOCUMENT");
assertThat(result.businessId()).isEqualTo("41");
assertThat(result.contentType()).isEqualTo("text/plain");
}
@Test void unsupportedBusinessTypeFailsClosed() {
assertThatThrownBy(() -> service.upload(10001L, "ARBITRARY", "41", 7L,
new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes())))
.isInstanceOf(BizException.class).hasMessageContaining("不支持");
verifyNoInteractions(files, storage);
}
@Test void downloadUsesValidatedMetadataObjectKey() {
FileObjectEntity value = new FileObjectEntity(); value.setId(71L); value.setTenantId(10001L);
value.setBusinessType("CUSTOMS_RECEIPT"); value.setBusinessId("61"); value.setOriginalName("release.txt");
value.setObjectKey("business-files/10001/61/release.txt"); value.setContentType("text/plain");
when(files.selectOne(any())).thenReturn(value);
when(storage.read(value.getObjectKey())).thenReturn("released".getBytes());
var result = service.download(10001L, "CUSTOMS_RECEIPT", "61", 71L);
assertThat(result.fileName()).isEqualTo("release.txt");
assertThat(result.content()).isEqualTo("released".getBytes());
verify(storage).read(value.getObjectKey());
}
}
@@ -1,3 +1,3 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;import org.springframework.cloud.openfeign.FeignClient;import org.springframework.web.bind.annotation.*;import org.springframework.web.multipart.MultipartFile;
@FeignClient(name="file-service",contextId="truckAppointmentFileClient",url="${baiyue.clients.file.url:}",path="/internal/file/v1/business-files") public interface TruckAppointmentFileClient{@PostMapping(value="/{tenantId}/{businessType}/{businessId}",consumes="multipart/form-data") ApiResponse<Metadata> upload(@RequestHeader("X-Internal-Service-Token") String token,@RequestHeader("X-Operator-Id") long operatorId,@PathVariable long tenantId,@PathVariable String businessType,@PathVariable String businessId,@RequestPart("file") MultipartFile file);@GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}") ApiResponse<Metadata> metadata(@RequestHeader("X-Internal-Service-Token") String token,@PathVariable long tenantId,@PathVariable String businessType,@PathVariable String businessId,@PathVariable long fileId);record Metadata(Long fileId,Long tenantId,String businessType,String businessId,String fileName,String sha256){} }
@FeignClient(name="file-service",contextId="truckAppointmentFileClient",url="${baiyue.clients.file.url:}",path="/internal/file/v1/business-files") public interface TruckAppointmentFileClient{@PostMapping(value="/{tenantId}/{businessType}/{businessId}",consumes="multipart/form-data") ApiResponse<Metadata> upload(@RequestHeader("X-Internal-Service-Token") String token,@RequestHeader("X-Operator-Id") long operatorId,@PathVariable long tenantId,@PathVariable String businessType,@PathVariable String businessId,@RequestPart("file") MultipartFile file);@GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}") ApiResponse<Metadata> metadata(@RequestHeader("X-Internal-Service-Token") String token,@PathVariable long tenantId,@PathVariable String businessType,@PathVariable String businessId,@PathVariable long fileId);record Metadata(Long fileId,Long tenantId,String businessType,String businessId,String fileName,String sha256,String contentType,Long sizeBytes){} }
@@ -0,0 +1,36 @@
package com.baiyue.firstleg.customs.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@FeignClient(name = "file-service", contextId = "customsFileClient",
url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/business-files")
public interface CustomsFileClient {
@PostMapping(value = "/{tenantId}/{businessType}/{businessId}", consumes = "multipart/form-data")
ApiResponse<Metadata> upload(@RequestHeader("X-Internal-Service-Token") String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long tenantId,
@PathVariable String businessType,
@PathVariable String businessId,
@RequestPart("file") MultipartFile file);
@GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}")
ApiResponse<Metadata> metadata(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId,
@PathVariable String businessType,
@PathVariable String businessId,
@PathVariable long fileId);
@GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}/content")
ResponseEntity<byte[]> download(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId,
@PathVariable String businessType,
@PathVariable String businessId,
@PathVariable long fileId);
record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
String fileName, String sha256, String contentType, Long sizeBytes) {}
}
@@ -0,0 +1,88 @@
package com.baiyue.firstleg.customs.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.customs.dto.CustomsDtos.*;
import com.baiyue.firstleg.customs.entity.CustomsDocumentEntity;
import com.baiyue.firstleg.customs.service.CustomsService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@Tag(name = "客户端报关资料与回执")
@Validated
@RestController
@RequestMapping("/api/merchant/v1/customs")
public class MerchantCustomsController {
@Resource private CustomsService service;
@GetMapping("/orders/{orderId}")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ApiResponse<CaseDetail> byOrder(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long orderId) {
return ApiResponse.success(service.merchantOrder(FirstlegScopeGuard.merchant(user), orderId));
}
@GetMapping("/cases/{caseId}")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ApiResponse<CaseDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long caseId) {
return ApiResponse.success(service.merchantDetail(FirstlegScopeGuard.merchant(user), caseId));
}
@PostMapping(value = "/cases/{caseId}/documents/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('merchant:order:edit')")
public ApiResponse<CustomsDocumentEntity> upload(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long caseId,
@RequestHeader("Idempotency-Key") String key,
@RequestParam @NotBlank String itemCode,
@RequestParam @Min(0) int expectedVersion,
@RequestPart("file") MultipartFile file) {
long tenantId = FirstlegScopeGuard.merchant(user);
return ApiResponse.success(service.uploadDocument(tenantId, caseId, "MERCHANT", user.userId(), user.username(), key,
new DocumentUploadData(itemCode, expectedVersion), file));
}
@PostMapping("/cases/{caseId}/documents")
@PreAuthorize("hasAuthority('merchant:order:edit')")
public ApiResponse<CustomsDocumentEntity> reference(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long caseId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody DocumentReferenceRequest request) {
long tenantId = FirstlegScopeGuard.merchant(user);
return ApiResponse.success(service.referenceDocument(tenantId, caseId, "MERCHANT", user.userId(), user.username(), key, request));
}
@GetMapping("/cases/{caseId}/documents/{documentId}/download")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ResponseEntity<byte[]> downloadDocument(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long caseId,
@PathVariable @Positive long documentId) {
return response(service.downloadDocument(FirstlegScopeGuard.merchant(user), caseId, documentId, true));
}
@GetMapping("/cases/{caseId}/receipts/{receiptId}/download")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ResponseEntity<byte[]> downloadReceipt(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long caseId,
@PathVariable @Positive long receiptId) {
return response(service.downloadReceipt(FirstlegScopeGuard.merchant(user), caseId, receiptId, true));
}
private static ResponseEntity<byte[]> response(Download value) {
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(value.contentType()))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + safeName(value.fileName()) + "\"")
.contentLength(value.content().length).body(value.content());
}
private static String safeName(String value) { return value.replace("\r", "").replace("\n", "").replace("\"", ""); }
}
@@ -0,0 +1,161 @@
package com.baiyue.firstleg.customs.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.*;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.customs.dto.CustomsDtos.*;
import com.baiyue.firstleg.customs.entity.*;
import com.baiyue.firstleg.customs.service.CustomsService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@Tag(name = "三期报关协同")
@Validated
@RestController
@RequestMapping("/api/platform/v1/customs")
public class PlatformCustomsController {
@Resource private CustomsService service;
@GetMapping("/cases")
@PreAuthorize("hasAuthority('platform:firstleg:customs:list')")
public ApiResponse<PageResult<CustomsCaseEntity>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid Query query) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.page(query));
}
@GetMapping("/cases/{caseId}")
@PreAuthorize("hasAuthority('platform:firstleg:customs:view')")
public ApiResponse<CaseDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long caseId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.platformDetail(caseId));
}
@PostMapping("/checklist-versions")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
@BusinessAudit(event = "CUSTOMS_CHECKLIST_CREATE", resourceType = "CUSTOMS_CHECKLIST", captureBefore = false)
public ApiResponse<ChecklistView> createChecklist(@AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody CreateChecklistRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.createChecklist(user.userId(), user.username(), key, request));
}
@PostMapping("/cases")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
@BusinessAudit(event = "CUSTOMS_CASE_CREATE", resourceType = "CUSTOMS_CASE", captureBefore = false)
public ApiResponse<CaseDetail> createCase(@AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody CreateCaseRequest request) {
FirstlegScopeGuard.platform(user, request.customerId());
return ApiResponse.success(service.createCase(user.userId(), user.username(), key, request));
}
@PostMapping(value = "/cases/{caseId}/documents/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
public ApiResponse<CustomsDocumentEntity> uploadDocument(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long caseId,
@RequestHeader("Idempotency-Key") String key,
@RequestParam @NotBlank String itemCode,
@RequestParam @Min(0) int expectedVersion,
@RequestPart("file") MultipartFile file) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.uploadDocument(0, caseId, "PLATFORM", user.userId(), user.username(), key,
new DocumentUploadData(itemCode, expectedVersion), file));
}
@PostMapping("/cases/{caseId}/documents")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
public ApiResponse<CustomsDocumentEntity> referenceDocument(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long caseId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody DocumentReferenceRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.referenceDocument(0, caseId, "PLATFORM", user.userId(), user.username(), key, request));
}
@PostMapping("/cases/{caseId}/documents/{documentId}/review")
@PreAuthorize("hasAuthority('platform:firstleg:customs:review')")
@BusinessAudit(event = "CUSTOMS_DOCUMENT_REVIEW", resourceType = "CUSTOMS_DOCUMENT")
public ApiResponse<CaseDetail> review(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long caseId,
@PathVariable @Positive long documentId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ReviewRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.review(caseId, documentId, user.userId(), user.username(), key, request));
}
@PostMapping("/cases/{caseId}/submission-batches")
@PreAuthorize("hasAuthority('platform:firstleg:customs:submit')")
@BusinessAudit(event = "CUSTOMS_AGENT_SUBMIT", resourceType = "CUSTOMS_SUBMISSION_BATCH")
public ApiResponse<CustomsSubmissionBatchEntity> submit(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long caseId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody SubmitBatchRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.submitBatch(caseId, user.userId(), user.username(), key, request));
}
@PostMapping(value = "/submission-batches/{batchId}/receipts/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:firstleg:customs:receipt')")
public ApiResponse<CustomsReceiptEntity> uploadReceipt(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long batchId,
@RequestHeader("Idempotency-Key") String key,
@RequestParam @Pattern(regexp = "RECEIVED|DECLARED|REJECTED|INSPECTION|RELEASED|OTHER") String receiptType,
@RequestParam @Pattern(regexp = "PLATFORM|CUSTOMER") String visibility,
@RequestParam(required = false) @Size(max = 500) String note,
@RequestParam @Min(0) int expectedVersion,
@RequestPart("file") MultipartFile file) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.uploadReceipt(batchId, user.userId(), user.username(), key,
new ReceiptUploadData(receiptType, visibility, note, expectedVersion), file));
}
@PostMapping("/submission-batches/{batchId}/receipts")
@PreAuthorize("hasAuthority('platform:firstleg:customs:receipt')")
public ApiResponse<CustomsReceiptEntity> referenceReceipt(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long batchId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ReceiptReferenceRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.referenceReceipt(batchId, user.userId(), user.username(), key, request));
}
@GetMapping("/cases/{caseId}/documents/{documentId}/download")
@PreAuthorize("hasAuthority('platform:firstleg:customs:view')")
public ResponseEntity<byte[]> downloadDocument(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long caseId,
@PathVariable @Positive long documentId) {
FirstlegScopeGuard.platform(user);
return response(service.downloadDocument(0, caseId, documentId, false));
}
@GetMapping("/cases/{caseId}/receipts/{receiptId}/download")
@PreAuthorize("hasAuthority('platform:firstleg:customs:view')")
public ResponseEntity<byte[]> downloadReceipt(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long caseId,
@PathVariable @Positive long receiptId) {
FirstlegScopeGuard.platform(user);
return response(service.downloadReceipt(0, caseId, receiptId, false));
}
private static ResponseEntity<byte[]> response(Download value) {
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(value.contentType()))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + safeName(value.fileName()) + "\"")
.contentLength(value.content().length).body(value.content());
}
private static String safeName(String value) { return value.replace("\r", "").replace("\n", "").replace("\"", ""); }
}
@@ -0,0 +1,79 @@
package com.baiyue.firstleg.customs.dto;
import com.baiyue.firstleg.customs.entity.*;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.time.LocalDateTime;
import java.util.List;
public final class CustomsDtos {
private CustomsDtos() {}
public record ChecklistItem(@NotBlank @Size(max = 40) String code,
@NotBlank @Size(max = 100) String name,
boolean required) {}
public record CreateChecklistRequest(@NotBlank @Size(max = 40) String checklistCode,
@NotBlank @Size(max = 32) String versionNo,
@NotBlank @Size(max = 32) String countryCode,
@NotBlank @Size(max = 40) String channelCode,
@NotBlank @Size(max = 40) String cargoType,
@NotEmpty List<@Valid ChecklistItem> items) {}
public record ChecklistView(CustomsChecklistVersionEntity version, List<ChecklistItem> items) {}
public record CreateCaseRequest(@Positive long customerId,
@Positive long orderId,
@Positive long inboundId,
@Positive Long containerBookingId,
@Positive long checklistVersionId,
@Size(max = 500) String remark,
@Min(0) int expectedVersion) {}
public record Query(Long customerId, Long orderId, Long inboundId, Long containerBookingId, String status,
@Min(1) int page, @Min(1) @Max(200) int size) {
public Query {
if (page == 0) page = 1;
if (size == 0) size = 20;
}
}
public record DocumentReferenceRequest(@NotBlank @Size(max = 40) String itemCode,
@Positive long fileId,
@Min(0) int expectedVersion) {}
public record DocumentUploadData(@NotBlank @Size(max = 40) String itemCode,
@Min(0) int expectedVersion) {}
public record ReviewRequest(@NotBlank @Pattern(regexp = "APPROVE|RETURN") String decision,
@Size(max = 500) String reason,
LocalDateTime supplementDueAt,
@Min(0) int expectedVersion) {}
public record SubmitBatchRequest(@NotBlank @Size(max = 128) String customsAgentName,
@Size(max = 128) String externalReference,
@Size(max = 500) String note,
@Min(0) int expectedVersion) {}
public record ReceiptReferenceRequest(@NotBlank @Pattern(regexp = "RECEIVED|DECLARED|REJECTED|INSPECTION|RELEASED|OTHER") String receiptType,
@Positive long fileId,
@NotBlank @Pattern(regexp = "PLATFORM|CUSTOMER") String visibility,
@Size(max = 500) String note,
@Min(0) int expectedVersion) {}
public record ReceiptUploadData(@NotBlank @Pattern(regexp = "RECEIVED|DECLARED|REJECTED|INSPECTION|RELEASED|OTHER") String receiptType,
@NotBlank @Pattern(regexp = "PLATFORM|CUSTOMER") String visibility,
@Size(max = 500) String note,
@Min(0) int expectedVersion) {}
public record CaseDetail(CustomsCaseEntity customsCase,
ChecklistView checklist,
List<CustomsDocumentEntity> documents,
List<CustomsSupplementRequestEntity> supplements,
List<CustomsSubmissionBatchEntity> submissionBatches,
List<CustomsReceiptEntity> receipts,
List<CustomsHistoryEntity> history) {}
public record Download(String fileName, String contentType, byte[] content) {}
}
@@ -0,0 +1,28 @@
package com.baiyue.firstleg.customs.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_customs_case")
public class CustomsCaseEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String customsNo;
private Long tenantId;
private Long orderId;
private Long inboundId;
private Long containerBookingId;
private Long checklistVersionId;
private String checklistCode;
private String checklistVersionNo;
private String status;
private String remark;
private Long createdBy;
private String createdByName;
private LocalDateTime createdAt;
private Long updatedBy;
private String updatedByName;
private LocalDateTime updatedAt;
@Version private Integer version;
}
@@ -0,0 +1,21 @@
package com.baiyue.firstleg.customs.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_customs_checklist_version")
public class CustomsChecklistVersionEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String checklistCode;
private String versionNo;
private String countryCode;
private String channelCode;
private String cargoType;
private String itemsJson;
private String status;
private Long createdBy;
private String createdByName;
private LocalDateTime createdAt;
}
@@ -0,0 +1,27 @@
package com.baiyue.firstleg.customs.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_customs_document")
public class CustomsDocumentEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long customsCaseId;
private String checklistItemCode;
private Long fileId;
private Integer documentVersion;
private Long replacesDocumentId;
private String fileName;
private String fileHash;
private String status;
private String visibility;
private Long uploadedBy;
private String uploadedByType;
private LocalDateTime uploadedAt;
private Long reviewedBy;
private String reviewedByName;
private LocalDateTime reviewedAt;
private String reviewReason;
}
@@ -0,0 +1,22 @@
package com.baiyue.firstleg.customs.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_customs_history")
public class CustomsHistoryEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long customsCaseId;
private String action;
private String fromStatus;
private String toStatus;
private String resourceType;
private Long resourceId;
private String note;
private Long operatorId;
private String operatorName;
private String operatorType;
private LocalDateTime createdAt;
}
@@ -0,0 +1,20 @@
package com.baiyue.firstleg.customs.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_customs_operation")
public class CustomsOperationEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long operatorId;
private String action;
private String idempotencyKey;
private String requestHash;
private String status;
private String resultType;
private Long resultId;
private LocalDateTime createdAt;
private LocalDateTime completedAt;
}
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.customs.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_customs_receipt")
public class CustomsReceiptEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long customsCaseId;
private Long submissionBatchId;
private String receiptType;
private Long fileId;
private Integer receiptVersion;
private Long replacesReceiptId;
private String fileName;
private String fileHash;
private String visibility;
private String note;
private Long uploadedBy;
private String uploadedByName;
private LocalDateTime uploadedAt;
}
@@ -0,0 +1,22 @@
package com.baiyue.firstleg.customs.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_customs_submission_batch")
public class CustomsSubmissionBatchEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long customsCaseId;
private String batchNo;
private Integer batchVersion;
private String customsAgentName;
private String externalReference;
private String fileSnapshotJson;
private String status;
private String note;
private Long submittedBy;
private String submittedByName;
private LocalDateTime submittedAt;
}
@@ -0,0 +1,23 @@
package com.baiyue.firstleg.customs.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_customs_supplement_request")
public class CustomsSupplementRequestEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long customsCaseId;
private Long sourceDocumentId;
private Long resolvedDocumentId;
private String checklistItemCode;
private String reason;
private LocalDateTime dueAt;
private String status;
private Long requestedBy;
private String requestedByName;
private LocalDateTime requestedAt;
private Long resolvedBy;
private LocalDateTime resolvedAt;
}
@@ -0,0 +1,5 @@
package com.baiyue.firstleg.customs.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.customs.entity.CustomsCaseEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface CustomsCaseMapper extends BaseMapper<CustomsCaseEntity> {}
@@ -0,0 +1,5 @@
package com.baiyue.firstleg.customs.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.customs.entity.CustomsChecklistVersionEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface CustomsChecklistVersionMapper extends BaseMapper<CustomsChecklistVersionEntity> {}
@@ -0,0 +1,5 @@
package com.baiyue.firstleg.customs.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.customs.entity.CustomsDocumentEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface CustomsDocumentMapper extends BaseMapper<CustomsDocumentEntity> {}
@@ -0,0 +1,5 @@
package com.baiyue.firstleg.customs.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.customs.entity.CustomsHistoryEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface CustomsHistoryMapper extends BaseMapper<CustomsHistoryEntity> {}
@@ -0,0 +1,5 @@
package com.baiyue.firstleg.customs.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.customs.entity.CustomsOperationEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface CustomsOperationMapper extends BaseMapper<CustomsOperationEntity> {}
@@ -0,0 +1,5 @@
package com.baiyue.firstleg.customs.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.customs.entity.CustomsReceiptEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface CustomsReceiptMapper extends BaseMapper<CustomsReceiptEntity> {}
@@ -0,0 +1,5 @@
package com.baiyue.firstleg.customs.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.customs.entity.CustomsSubmissionBatchEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface CustomsSubmissionBatchMapper extends BaseMapper<CustomsSubmissionBatchEntity> {}
@@ -0,0 +1,5 @@
package com.baiyue.firstleg.customs.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.customs.entity.CustomsSupplementRequestEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface CustomsSupplementRequestMapper extends BaseMapper<CustomsSupplementRequestEntity> {}
@@ -0,0 +1,188 @@
package com.baiyue.firstleg.customs.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.customs.dto.CustomsDtos.Query;
import com.baiyue.firstleg.customs.entity.*;
import com.baiyue.firstleg.customs.mapper.*;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import com.baiyue.firstleg.mapper.LoadingPlanItemMapper;
import com.baiyue.firstleg.mapper.LoadingPlanMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public class CustomsRepository {
@Resource private CustomsChecklistVersionMapper checklists;
@Resource private CustomsCaseMapper cases;
@Resource private CustomsDocumentMapper documents;
@Resource private CustomsSupplementRequestMapper supplements;
@Resource private CustomsSubmissionBatchMapper batches;
@Resource private CustomsReceiptMapper receipts;
@Resource private CustomsHistoryMapper histories;
@Resource private CustomsOperationMapper operations;
@Resource private InboundOrderMapper inboundOrders;
@Resource private LoadingPlanMapper loadingPlans;
@Resource private LoadingPlanItemMapper loadingPlanItems;
public void insert(CustomsChecklistVersionEntity value) { checklists.insert(value); }
public CustomsChecklistVersionEntity checklist(long id) { return checklists.selectById(id); }
public CustomsChecklistVersionEntity checklist(String code, String version) {
return checklists.selectOne(new LambdaQueryWrapper<CustomsChecklistVersionEntity>()
.eq(CustomsChecklistVersionEntity::getChecklistCode, code)
.eq(CustomsChecklistVersionEntity::getVersionNo, version));
}
public void insert(CustomsCaseEntity value) { cases.insert(value); }
public CustomsCaseEntity customsCase(long id) { return cases.selectById(id); }
public CustomsCaseEntity customsCase(long tenantId, long id) {
return cases.selectOne(new LambdaQueryWrapper<CustomsCaseEntity>()
.eq(CustomsCaseEntity::getId, id).eq(CustomsCaseEntity::getTenantId, tenantId));
}
public CustomsCaseEntity caseByOrder(long tenantId, long orderId) {
return cases.selectOne(new LambdaQueryWrapper<CustomsCaseEntity>()
.eq(CustomsCaseEntity::getTenantId, tenantId).eq(CustomsCaseEntity::getOrderId, orderId)
.orderByDesc(CustomsCaseEntity::getUpdatedAt).last("LIMIT 1"));
}
public PageResult<CustomsCaseEntity> page(Query query) {
var wrapper = new LambdaQueryWrapper<CustomsCaseEntity>()
.eq(query.customerId() != null, CustomsCaseEntity::getTenantId, query.customerId())
.eq(query.orderId() != null, CustomsCaseEntity::getOrderId, query.orderId())
.eq(query.inboundId() != null, CustomsCaseEntity::getInboundId, query.inboundId())
.eq(query.containerBookingId() != null, CustomsCaseEntity::getContainerBookingId, query.containerBookingId())
.eq(query.status() != null && !query.status().isBlank(), CustomsCaseEntity::getStatus, query.status())
.orderByDesc(CustomsCaseEntity::getUpdatedAt).orderByDesc(CustomsCaseEntity::getId);
return PageResult.from(cases.selectPage(new Page<>(query.page(), query.size()), wrapper));
}
public int transition(long id, int expectedVersion, String status, long operatorId, String operatorName) {
return cases.update(null, new LambdaUpdateWrapper<CustomsCaseEntity>()
.eq(CustomsCaseEntity::getId, id).eq(CustomsCaseEntity::getVersion, expectedVersion)
.set(CustomsCaseEntity::getStatus, status).set(CustomsCaseEntity::getUpdatedBy, operatorId)
.set(CustomsCaseEntity::getUpdatedByName, operatorName).set(CustomsCaseEntity::getUpdatedAt, LocalDateTime.now())
.set(CustomsCaseEntity::getVersion, expectedVersion + 1));
}
public boolean inboundBelongsTo(long tenantId, long orderId, long inboundId) {
return inboundOrders.selectCount(new LambdaQueryWrapper<com.baiyue.firstleg.entity.InboundOrderEntity>()
.eq(com.baiyue.firstleg.entity.InboundOrderEntity::getTenantId, tenantId)
.eq(com.baiyue.firstleg.entity.InboundOrderEntity::getOrderId, orderId)
.eq(com.baiyue.firstleg.entity.InboundOrderEntity::getId, inboundId)) > 0;
}
public CustomsCaseEntity gateCase(long tenantId, long orderId, long inboundId, long containerBookingId) {
return cases.selectOne(new LambdaQueryWrapper<CustomsCaseEntity>()
.eq(CustomsCaseEntity::getTenantId, tenantId).eq(CustomsCaseEntity::getOrderId, orderId)
.eq(CustomsCaseEntity::getInboundId, inboundId)
.eq(CustomsCaseEntity::getContainerBookingId, containerBookingId)
.orderByDesc(CustomsCaseEntity::getUpdatedAt).last("LIMIT 1"));
}
public boolean containerCarriesOrder(long containerId, long tenantId, long orderId) {
LoadingPlanEntity plan = loadingPlans.selectOne(new LambdaQueryWrapper<LoadingPlanEntity>()
.eq(LoadingPlanEntity::getContainerBookingId, containerId));
return plan != null && loadingPlanItems.selectCount(new LambdaQueryWrapper<com.baiyue.firstleg.entity.LoadingPlanItemEntity>()
.eq(com.baiyue.firstleg.entity.LoadingPlanItemEntity::getLoadingPlanId, plan.getId())
.eq(com.baiyue.firstleg.entity.LoadingPlanItemEntity::getAccountId, tenantId)
.eq(com.baiyue.firstleg.entity.LoadingPlanItemEntity::getOrderId, orderId)
.eq(com.baiyue.firstleg.entity.LoadingPlanItemEntity::getDeleted, false)) > 0;
}
public void insert(CustomsDocumentEntity value) { documents.insert(value); }
public CustomsDocumentEntity document(long id) { return documents.selectById(id); }
public List<CustomsDocumentEntity> documents(long caseId) {
return documents.selectList(new LambdaQueryWrapper<CustomsDocumentEntity>()
.eq(CustomsDocumentEntity::getCustomsCaseId, caseId)
.orderByAsc(CustomsDocumentEntity::getChecklistItemCode)
.orderByAsc(CustomsDocumentEntity::getDocumentVersion));
}
public CustomsDocumentEntity latestDocument(long caseId, String itemCode) {
return documents.selectOne(new LambdaQueryWrapper<CustomsDocumentEntity>()
.eq(CustomsDocumentEntity::getCustomsCaseId, caseId)
.eq(CustomsDocumentEntity::getChecklistItemCode, itemCode)
.orderByDesc(CustomsDocumentEntity::getDocumentVersion).last("LIMIT 1"));
}
public int markDocument(long id, String status, long reviewerId, String reviewerName, String reason) {
return documents.update(null, new LambdaUpdateWrapper<CustomsDocumentEntity>()
.eq(CustomsDocumentEntity::getId, id)
.set(CustomsDocumentEntity::getStatus, status).set(CustomsDocumentEntity::getReviewedBy, reviewerId)
.set(CustomsDocumentEntity::getReviewedByName, reviewerName)
.set(CustomsDocumentEntity::getReviewedAt, LocalDateTime.now())
.set(CustomsDocumentEntity::getReviewReason, reason));
}
public int supersede(long id) {
return documents.update(null, new LambdaUpdateWrapper<CustomsDocumentEntity>()
.eq(CustomsDocumentEntity::getId, id).in(CustomsDocumentEntity::getStatus, "UPLOADED", "RETURNED")
.set(CustomsDocumentEntity::getStatus, "SUPERSEDED"));
}
public int freeze(long id) {
return documents.update(null, new LambdaUpdateWrapper<CustomsDocumentEntity>()
.eq(CustomsDocumentEntity::getId, id).eq(CustomsDocumentEntity::getStatus, "APPROVED")
.set(CustomsDocumentEntity::getStatus, "FROZEN"));
}
public void insert(CustomsSupplementRequestEntity value) { supplements.insert(value); }
public List<CustomsSupplementRequestEntity> supplements(long caseId) {
return supplements.selectList(new LambdaQueryWrapper<CustomsSupplementRequestEntity>()
.eq(CustomsSupplementRequestEntity::getCustomsCaseId, caseId)
.orderByAsc(CustomsSupplementRequestEntity::getRequestedAt));
}
public void resolveSupplements(long caseId, String itemCode, long documentId, long operatorId) {
supplements.update(null, new LambdaUpdateWrapper<CustomsSupplementRequestEntity>()
.eq(CustomsSupplementRequestEntity::getCustomsCaseId, caseId)
.eq(CustomsSupplementRequestEntity::getChecklistItemCode, itemCode)
.eq(CustomsSupplementRequestEntity::getStatus, "OPEN")
.set(CustomsSupplementRequestEntity::getStatus, "RESOLVED")
.set(CustomsSupplementRequestEntity::getResolvedDocumentId, documentId)
.set(CustomsSupplementRequestEntity::getResolvedBy, operatorId)
.set(CustomsSupplementRequestEntity::getResolvedAt, LocalDateTime.now()));
}
public void insert(CustomsSubmissionBatchEntity value) { batches.insert(value); }
public CustomsSubmissionBatchEntity batch(long id) { return batches.selectById(id); }
public List<CustomsSubmissionBatchEntity> batches(long caseId) {
return batches.selectList(new LambdaQueryWrapper<CustomsSubmissionBatchEntity>()
.eq(CustomsSubmissionBatchEntity::getCustomsCaseId, caseId)
.orderByAsc(CustomsSubmissionBatchEntity::getBatchVersion));
}
public void insert(CustomsReceiptEntity value) { receipts.insert(value); }
public CustomsReceiptEntity receipt(long id) { return receipts.selectById(id); }
public CustomsReceiptEntity latestReceipt(long batchId, String type) {
return receipts.selectOne(new LambdaQueryWrapper<CustomsReceiptEntity>()
.eq(CustomsReceiptEntity::getSubmissionBatchId, batchId)
.eq(CustomsReceiptEntity::getReceiptType, type)
.orderByDesc(CustomsReceiptEntity::getReceiptVersion).last("LIMIT 1"));
}
public List<CustomsReceiptEntity> receipts(long caseId, boolean customerOnly) {
return receipts.selectList(new LambdaQueryWrapper<CustomsReceiptEntity>()
.eq(CustomsReceiptEntity::getCustomsCaseId, caseId)
.eq(customerOnly, CustomsReceiptEntity::getVisibility, "CUSTOMER")
.orderByAsc(CustomsReceiptEntity::getUploadedAt));
}
public void insert(CustomsHistoryEntity value) { histories.insert(value); }
public List<CustomsHistoryEntity> history(long caseId) {
return histories.selectList(new LambdaQueryWrapper<CustomsHistoryEntity>()
.eq(CustomsHistoryEntity::getCustomsCaseId, caseId).orderByAsc(CustomsHistoryEntity::getCreatedAt));
}
public void insert(CustomsOperationEntity value) { operations.insert(value); }
public CustomsOperationEntity operation(long operatorId, String action, String key) {
return operations.selectOne(new LambdaQueryWrapper<CustomsOperationEntity>()
.eq(CustomsOperationEntity::getOperatorId, operatorId)
.eq(CustomsOperationEntity::getAction, action)
.eq(CustomsOperationEntity::getIdempotencyKey, key));
}
public int completeOperation(long id, String resultType, long resultId) {
return operations.update(null, new LambdaUpdateWrapper<CustomsOperationEntity>()
.eq(CustomsOperationEntity::getId, id).eq(CustomsOperationEntity::getStatus, "PROCESSING")
.set(CustomsOperationEntity::getStatus, "SUCCESS")
.set(CustomsOperationEntity::getResultType, resultType)
.set(CustomsOperationEntity::getResultId, resultId)
.set(CustomsOperationEntity::getCompletedAt, LocalDateTime.now()));
}
}
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.customs.service;
import com.baiyue.firstleg.customs.entity.CustomsCaseEntity;
import com.baiyue.firstleg.customs.repository.CustomsRepository;
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.GateItem;
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
@Component
public class CustomsDispatchGateContributor implements OutboundDispatchGateContributor {
@Resource private CustomsRepository repository;
@Override public String code() { return "CUSTOMS_CLEARANCE"; }
@Override
public GateItem assess(Context context) {
if (context.tenantId() <= 0 || context.customerId() <= 0 || context.tenantId() != context.customerId()) {
return gate(false, "INVALID_CUSTOMER_SCOPE");
}
CustomsCaseEntity value = repository.gateCase(context.tenantId(), context.orderId(), context.inboundId(),
context.containerBookingId());
if (value == null) return gate(false, "MISSING");
return gate("CLEARANCE_COMPLETED".equals(value.getStatus()), value.getStatus());
}
private static GateItem gate(boolean passed, String actual) {
return new GateItem("CUSTOMS_CLEARANCE", "报关放行", passed, actual, "CLEARANCE_COMPLETED");
}
}
@@ -0,0 +1,477 @@
package com.baiyue.firstleg.customs.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.customs.client.CustomsFileClient;
import com.baiyue.firstleg.customs.dto.CustomsDtos.*;
import com.baiyue.firstleg.customs.entity.*;
import com.baiyue.firstleg.customs.repository.CustomsRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class CustomsService {
private static final String DOCUMENT_TYPE = "CUSTOMS_DOCUMENT";
private static final String RECEIPT_TYPE = "CUSTOMS_RECEIPT";
private static final Set<String> EDITABLE = Set.of("DOCUMENTS_PENDING", "REVIEWING", "SUPPLEMENT_REQUIRED", "READY_TO_SUBMIT");
@Resource private CustomsRepository repository;
@Resource private CustomsFileClient files;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
public PageResult<CustomsCaseEntity> page(Query query) { return repository.page(query); }
public CaseDetail platformDetail(long caseId) { return detail(required(caseId), false); }
public CaseDetail merchantDetail(long tenantId, long caseId) { return detail(required(tenantId, caseId), true); }
public CaseDetail merchantOrder(long tenantId, long orderId) {
CustomsCaseEntity value = repository.caseByOrder(tenantId, orderId);
if (value == null) throw notFound("订单报关协同单不存在");
return detail(value, true);
}
@Transactional
public ChecklistView createChecklist(long operatorId, String operatorName, String key, CreateChecklistRequest request) {
validateChecklist(request.items());
List<ChecklistItem> normalizedItems = request.items().stream()
.map(item -> new ChecklistItem(normalize(item.code()), item.name().trim(), item.required())).toList();
String action = "CREATE_CHECKLIST";
CustomsOperationEntity operation = begin(operatorId, action, key, hash(action, request));
if (completed(operation)) return checklist(requiredChecklist(operation.getResultId()));
if (repository.checklist(normalize(request.checklistCode()), normalize(request.versionNo())) != null) {
throw conflict("资料清单版本已存在");
}
CustomsChecklistVersionEntity value = new CustomsChecklistVersionEntity();
value.setId(IdWorker.getId()); value.setChecklistCode(normalize(request.checklistCode()));
value.setVersionNo(normalize(request.versionNo())); value.setCountryCode(normalize(request.countryCode()));
value.setChannelCode(normalize(request.channelCode())); value.setCargoType(normalize(request.cargoType()));
value.setItemsJson(json(normalizedItems)); value.setStatus("ACTIVE"); value.setCreatedBy(operatorId);
value.setCreatedByName(operatorName); value.setCreatedAt(LocalDateTime.now());
repository.insert(value); complete(operation, "CHECKLIST", value.getId());
return checklist(value);
}
@Transactional
public CaseDetail createCase(long operatorId, String operatorName, String key, CreateCaseRequest request) {
if (request.expectedVersion() != 0) throw bad("新增 expectedVersion 必须为0");
CustomsChecklistVersionEntity checklist = requiredChecklist(request.checklistVersionId());
if (!"ACTIVE".equals(checklist.getStatus())) throw conflict("资料清单版本不可用");
if (!repository.inboundBelongsTo(request.customerId(), request.orderId(), request.inboundId())) {
throw bad("入库单、订单与目标客户归属不一致");
}
long containerId = request.containerBookingId() == null ? 0 : request.containerBookingId();
if (containerId > 0 && !repository.containerCarriesOrder(containerId, request.customerId(), request.orderId())) {
throw bad("柜次未承载该客户订单");
}
String action = "CREATE_CASE";
CustomsOperationEntity operation = begin(operatorId, action, key, hash(action, request));
if (completed(operation)) return platformDetail(operation.getResultId());
CustomsCaseEntity value = new CustomsCaseEntity();
value.setId(IdWorker.getId()); value.setCustomsNo("CD" + value.getId()); value.setTenantId(request.customerId());
value.setOrderId(request.orderId()); value.setInboundId(request.inboundId()); value.setContainerBookingId(containerId);
value.setChecklistVersionId(checklist.getId()); value.setChecklistCode(checklist.getChecklistCode());
value.setChecklistVersionNo(checklist.getVersionNo()); value.setStatus("DOCUMENTS_PENDING");
value.setRemark(trim(request.remark())); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setCreatedAt(LocalDateTime.now()); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName);
value.setUpdatedAt(value.getCreatedAt()); value.setVersion(0);
try { repository.insert(value); } catch (DuplicateKeyException ex) { throw conflict("订单和柜次已有报关协同单"); }
history(value, "CREATE", null, value.getStatus(), "CASE", value.getId(), value.getRemark(), operatorId, operatorName, "PLATFORM");
complete(operation, "CASE", value.getId());
return platformDetail(value.getId());
}
@Transactional
public CustomsDocumentEntity uploadDocument(long tenantId, long caseId, String actorType, long operatorId,
String operatorName, String key, DocumentUploadData data,
MultipartFile file) {
CustomsCaseEntity customsCase = scopedCase(tenantId, caseId, actorType);
String action = actorType + "_UPLOAD_DOCUMENT";
String fileHash = multipartHash(file);
CustomsOperationEntity operation = begin(operatorId, action, key,
hash(action, caseId, data, fileHash, file == null ? null : file.getOriginalFilename()));
if (completed(operation)) return requiredDocument(operation.getResultId(), customsCase.getId());
validateDocumentCommand(customsCase, data.itemCode(), data.expectedVersion());
CustomsFileClient.Metadata metadata = upload(customsCase, DOCUMENT_TYPE, Long.toString(caseId), operatorId, file);
CustomsDocumentEntity value = addDocument(customsCase, data.itemCode(), metadata, actorType, operatorId, operatorName);
complete(operation, "DOCUMENT", value.getId());
return value;
}
@Transactional
public CustomsDocumentEntity referenceDocument(long tenantId, long caseId, String actorType, long operatorId,
String operatorName, String key, DocumentReferenceRequest request) {
CustomsCaseEntity customsCase = scopedCase(tenantId, caseId, actorType);
String action = actorType + "_REFERENCE_DOCUMENT";
CustomsOperationEntity operation = begin(operatorId, action, key, hash(action, caseId, request));
if (completed(operation)) return requiredDocument(operation.getResultId(), customsCase.getId());
validateDocumentCommand(customsCase, request.itemCode(), request.expectedVersion());
CustomsFileClient.Metadata metadata = metadata(customsCase, DOCUMENT_TYPE, Long.toString(caseId), request.fileId());
CustomsDocumentEntity value = addDocument(customsCase, request.itemCode(), metadata, actorType, operatorId, operatorName);
complete(operation, "DOCUMENT", value.getId());
return value;
}
@Transactional
public CaseDetail review(long caseId, long documentId, long operatorId, String operatorName,
String key, ReviewRequest request) {
CustomsCaseEntity customsCase = required(caseId);
String action = "REVIEW_DOCUMENT_" + request.decision();
CustomsOperationEntity operation = begin(operatorId, action, key, hash(action, caseId, documentId, request));
if (completed(operation)) return platformDetail(caseId);
requiredVersion(customsCase, request.expectedVersion());
if (!EDITABLE.contains(customsCase.getStatus())) throw conflict("当前报关状态不可审核资料");
CustomsDocumentEntity document = requiredDocument(documentId, caseId);
CustomsDocumentEntity latest = repository.latestDocument(caseId, document.getChecklistItemCode());
if (!Objects.equals(document.getId(), latest.getId()) || !"UPLOADED".equals(document.getStatus())) {
throw conflict("只能审核当前待审文件版本");
}
if ("RETURN".equals(request.decision()) && !StringUtils.hasText(request.reason())) throw bad("退回原因不能为空");
repository.markDocument(documentId, "APPROVE".equals(request.decision()) ? "APPROVED" : "RETURNED",
operatorId, operatorName, trim(request.reason()));
if ("RETURN".equals(request.decision())) {
CustomsSupplementRequestEntity supplement = new CustomsSupplementRequestEntity();
supplement.setId(IdWorker.getId()); supplement.setCustomsCaseId(caseId); supplement.setSourceDocumentId(documentId);
supplement.setChecklistItemCode(document.getChecklistItemCode()); supplement.setReason(request.reason().trim());
supplement.setDueAt(request.supplementDueAt()); supplement.setStatus("OPEN"); supplement.setRequestedBy(operatorId);
supplement.setRequestedByName(operatorName); supplement.setRequestedAt(LocalDateTime.now()); repository.insert(supplement);
}
String next = calculateStatus(customsCase);
transition(customsCase, next, operatorId, operatorName);
history(customsCase, action, customsCase.getStatus(), next, "DOCUMENT", documentId, request.reason(),
operatorId, operatorName, "PLATFORM");
complete(operation, "CASE", caseId);
return platformDetail(caseId);
}
@Transactional
public CustomsSubmissionBatchEntity submitBatch(long caseId, long operatorId, String operatorName,
String key, SubmitBatchRequest request) {
CustomsCaseEntity customsCase = required(caseId);
String action = "SUBMIT_AGENT_BATCH";
CustomsOperationEntity operation = begin(operatorId, action, key, hash(action, caseId, request));
if (completed(operation)) return requiredBatch(operation.getResultId(), caseId);
requiredVersion(customsCase, request.expectedVersion());
if (!"READY_TO_SUBMIT".equals(customsCase.getStatus())) throw conflict("报关资料未全部审核通过");
List<CustomsDocumentEntity> current = currentDocuments(customsCase);
for (CustomsDocumentEntity document : current) {
if (!"APPROVED".equals(document.getStatus()) || repository.freeze(document.getId()) != 1) {
throw conflict("文件版本已变化,请刷新后重试");
}
}
CustomsSubmissionBatchEntity batch = new CustomsSubmissionBatchEntity();
batch.setId(IdWorker.getId()); batch.setCustomsCaseId(caseId); batch.setBatchVersion(repository.batches(caseId).size() + 1);
batch.setBatchNo("CB" + batch.getId()); batch.setCustomsAgentName(request.customsAgentName().trim());
batch.setExternalReference(trim(request.externalReference())); batch.setFileSnapshotJson(json(current.stream()
.map(v -> Map.of("documentId", v.getId(), "fileId", v.getFileId(), "itemCode", v.getChecklistItemCode(),
"documentVersion", v.getDocumentVersion(), "fileHash", v.getFileHash())).toList()));
batch.setStatus("SUBMITTED"); batch.setNote(trim(request.note())); batch.setSubmittedBy(operatorId);
batch.setSubmittedByName(operatorName); batch.setSubmittedAt(LocalDateTime.now()); repository.insert(batch);
transition(customsCase, "RECEIPT_PENDING", operatorId, operatorName);
history(customsCase, action, customsCase.getStatus(), "RECEIPT_PENDING", "SUBMISSION_BATCH", batch.getId(),
request.note(), operatorId, operatorName, "PLATFORM");
complete(operation, "BATCH", batch.getId());
return batch;
}
@Transactional
public CustomsReceiptEntity uploadReceipt(long batchId, long operatorId, String operatorName, String key,
ReceiptUploadData data, MultipartFile file) {
CustomsSubmissionBatchEntity batch = requiredBatch(batchId, null);
CustomsCaseEntity customsCase = required(batch.getCustomsCaseId());
String action = "UPLOAD_RECEIPT_" + data.receiptType();
String fileHash = multipartHash(file);
CustomsOperationEntity operation = begin(operatorId, action, key,
hash(action, batchId, data, fileHash, file == null ? null : file.getOriginalFilename()));
if (completed(operation)) return requiredReceipt(operation.getResultId(), customsCase.getId(), false);
requiredVersion(customsCase, data.expectedVersion());
validateReceiptStatus(customsCase);
CustomsFileClient.Metadata metadata = upload(customsCase, RECEIPT_TYPE, Long.toString(batchId), operatorId, file);
CustomsReceiptEntity value = addReceipt(customsCase, batch, data.receiptType(), data.visibility(), data.note(),
metadata, operatorId, operatorName);
complete(operation, "RECEIPT", value.getId());
return value;
}
@Transactional
public CustomsReceiptEntity referenceReceipt(long batchId, long operatorId, String operatorName, String key,
ReceiptReferenceRequest request) {
CustomsSubmissionBatchEntity batch = requiredBatch(batchId, null);
CustomsCaseEntity customsCase = required(batch.getCustomsCaseId());
String action = "REFERENCE_RECEIPT_" + request.receiptType();
CustomsOperationEntity operation = begin(operatorId, action, key, hash(action, batchId, request));
if (completed(operation)) return requiredReceipt(operation.getResultId(), customsCase.getId(), false);
requiredVersion(customsCase, request.expectedVersion());
validateReceiptStatus(customsCase);
CustomsFileClient.Metadata metadata = metadata(customsCase, RECEIPT_TYPE, Long.toString(batchId), request.fileId());
CustomsReceiptEntity value = addReceipt(customsCase, batch, request.receiptType(), request.visibility(), request.note(),
metadata, operatorId, operatorName);
complete(operation, "RECEIPT", value.getId());
return value;
}
public Download downloadDocument(long tenantId, long caseId, long documentId, boolean merchant) {
CustomsCaseEntity customsCase = merchant ? required(tenantId, caseId) : required(caseId);
CustomsDocumentEntity document = requiredDocument(documentId, caseId);
return download(customsCase, DOCUMENT_TYPE, Long.toString(caseId), document.getFileId(), document.getFileName());
}
public Download downloadReceipt(long tenantId, long caseId, long receiptId, boolean merchant) {
CustomsCaseEntity customsCase = merchant ? required(tenantId, caseId) : required(caseId);
CustomsReceiptEntity receipt = requiredReceipt(receiptId, caseId, merchant);
CustomsSubmissionBatchEntity batch = requiredBatch(receipt.getSubmissionBatchId(), caseId);
return download(customsCase, RECEIPT_TYPE, Long.toString(batch.getId()), receipt.getFileId(), receipt.getFileName());
}
private CustomsDocumentEntity addDocument(CustomsCaseEntity customsCase, String rawItemCode,
CustomsFileClient.Metadata metadata, String actorType,
long operatorId, String operatorName) {
String itemCode = normalize(rawItemCode);
CustomsDocumentEntity previous = repository.latestDocument(customsCase.getId(), itemCode);
if (previous != null && Set.of("UPLOADED", "RETURNED").contains(previous.getStatus())
&& repository.supersede(previous.getId()) != 1) throw conflict("文件版本已变化,请刷新后重试");
CustomsDocumentEntity value = new CustomsDocumentEntity();
value.setId(IdWorker.getId()); value.setCustomsCaseId(customsCase.getId()); value.setChecklistItemCode(itemCode);
value.setFileId(metadata.fileId()); value.setDocumentVersion(previous == null ? 1 : previous.getDocumentVersion() + 1);
value.setReplacesDocumentId(previous == null ? null : previous.getId()); value.setFileName(metadata.fileName());
value.setFileHash(metadata.sha256()); value.setStatus("UPLOADED"); value.setVisibility("CUSTOMER");
value.setUploadedBy(operatorId); value.setUploadedByType(actorType); value.setUploadedAt(LocalDateTime.now());
try { repository.insert(value); } catch (DuplicateKeyException ex) { throw conflict("文件版本已存在"); }
repository.resolveSupplements(customsCase.getId(), itemCode, value.getId(), operatorId);
String next = hasEveryRequiredDocument(customsCase) ? "REVIEWING" : "DOCUMENTS_PENDING";
transition(customsCase, next, operatorId, operatorName);
history(customsCase, previous == null ? "UPLOAD_DOCUMENT" : "REPLACE_DOCUMENT", customsCase.getStatus(), next,
"DOCUMENT", value.getId(), itemCode, operatorId, operatorName, actorType);
return value;
}
private CustomsReceiptEntity addReceipt(CustomsCaseEntity customsCase, CustomsSubmissionBatchEntity batch,
String receiptType, String visibility, String note,
CustomsFileClient.Metadata metadata, long operatorId, String operatorName) {
CustomsReceiptEntity previous = repository.latestReceipt(batch.getId(), receiptType);
CustomsReceiptEntity value = new CustomsReceiptEntity(); value.setId(IdWorker.getId());
value.setCustomsCaseId(customsCase.getId()); value.setSubmissionBatchId(batch.getId());
value.setReceiptType(receiptType); value.setFileId(metadata.fileId());
value.setReceiptVersion(previous == null ? 1 : previous.getReceiptVersion() + 1);
value.setReplacesReceiptId(previous == null ? null : previous.getId()); value.setFileName(metadata.fileName());
value.setFileHash(metadata.sha256()); value.setVisibility(visibility); value.setNote(trim(note));
value.setUploadedBy(operatorId); value.setUploadedByName(operatorName); value.setUploadedAt(LocalDateTime.now());
repository.insert(value);
String next = "REJECTED".equals(receiptType) ? "SUPPLEMENT_REQUIRED"
: "RELEASED".equals(receiptType) ? "CLEARANCE_COMPLETED" : "RECEIPT_AVAILABLE";
transition(customsCase, next, operatorId, operatorName);
history(customsCase, "UPLOAD_RECEIPT", customsCase.getStatus(), next, "RECEIPT", value.getId(), note,
operatorId, operatorName, "PLATFORM");
return value;
}
private List<CustomsDocumentEntity> currentDocuments(CustomsCaseEntity customsCase) {
Map<String, CustomsDocumentEntity> current = repository.documents(customsCase.getId()).stream()
.collect(Collectors.toMap(CustomsDocumentEntity::getChecklistItemCode, Function.identity(),
(left, right) -> left.getDocumentVersion() > right.getDocumentVersion() ? left : right));
return checklistItems(customsCase).stream().filter(ChecklistItem::required).map(v -> normalize(v.code()))
.map(current::get).filter(Objects::nonNull).toList();
}
private String calculateStatus(CustomsCaseEntity customsCase) {
List<CustomsSupplementRequestEntity> supplements = repository.supplements(customsCase.getId());
if (supplements.stream().anyMatch(v -> "OPEN".equals(v.getStatus()))) return "SUPPLEMENT_REQUIRED";
Map<String, CustomsDocumentEntity> current = repository.documents(customsCase.getId()).stream()
.collect(Collectors.toMap(CustomsDocumentEntity::getChecklistItemCode, Function.identity(),
(left, right) -> left.getDocumentVersion() > right.getDocumentVersion() ? left : right));
List<String> required = checklistItems(customsCase).stream().filter(ChecklistItem::required)
.map(v -> normalize(v.code())).toList();
if (required.stream().allMatch(code -> current.containsKey(code) && "APPROVED".equals(current.get(code).getStatus()))) {
return "READY_TO_SUBMIT";
}
return required.stream().allMatch(current::containsKey) ? "REVIEWING" : "DOCUMENTS_PENDING";
}
private boolean hasEveryRequiredDocument(CustomsCaseEntity customsCase) {
Set<String> codes = repository.documents(customsCase.getId()).stream()
.filter(v -> !"SUPERSEDED".equals(v.getStatus()))
.map(CustomsDocumentEntity::getChecklistItemCode).collect(Collectors.toSet());
return checklistItems(customsCase).stream().filter(ChecklistItem::required)
.allMatch(v -> codes.contains(normalize(v.code())));
}
private void validateDocumentCommand(CustomsCaseEntity customsCase, String itemCode, int expectedVersion) {
requiredVersion(customsCase, expectedVersion);
if (!EDITABLE.contains(customsCase.getStatus())) throw conflict("当前报关状态不可上传或替换资料");
String normalized = normalize(itemCode);
if (checklistItems(customsCase).stream().noneMatch(v -> normalize(v.code()).equals(normalized))) {
throw bad("文件类型不在本单资料清单中");
}
}
private void validateReceiptStatus(CustomsCaseEntity customsCase) {
if (!Set.of("RECEIPT_PENDING", "RECEIPT_AVAILABLE", "SUPPLEMENT_REQUIRED").contains(customsCase.getStatus())) {
throw conflict("当前报关状态不可上传回执");
}
}
private CustomsCaseEntity scopedCase(long tenantId, long caseId, String actorType) {
return "MERCHANT".equals(actorType) ? required(tenantId, caseId) : required(caseId);
}
private CustomsFileClient.Metadata upload(CustomsCaseEntity customsCase, String type, String businessId,
long operatorId, MultipartFile file) {
try {
var response = files.upload(internalToken, operatorId, customsCase.getTenantId(), type, businessId, file);
if (response == null || !response.successful() || response.data() == null) throw bad("附件上传失败");
return response.data();
} catch (FeignException ex) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); }
}
private CustomsFileClient.Metadata metadata(CustomsCaseEntity customsCase, String type, String businessId, long fileId) {
try {
var response = files.metadata(internalToken, customsCase.getTenantId(), type, businessId, fileId);
if (response == null || !response.successful() || response.data() == null) throw bad("附件不存在或归属不匹配");
return response.data();
} catch (FeignException ex) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); }
}
private Download download(CustomsCaseEntity customsCase, String type, String businessId, long fileId, String fileName) {
try {
var response = files.download(internalToken, customsCase.getTenantId(), type, businessId, fileId);
if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
throw notFound("附件内容不存在");
}
String contentType = response.getHeaders().getContentType() == null
? "application/octet-stream" : response.getHeaders().getContentType().toString();
if (contentType.toLowerCase(Locale.ROOT).contains("application/json")) throw notFound("附件内容不存在");
return new Download(fileName, contentType, response.getBody());
} catch (FeignException ex) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); }
}
private CustomsOperationEntity begin(long operatorId, String action, String key, String requestHash) {
if (!StringUtils.hasText(key) || key.length() > 128) throw bad("Idempotency-Key不能为空且不能超过128个字符");
CustomsOperationEntity value = new CustomsOperationEntity(); value.setId(IdWorker.getId());
value.setOperatorId(operatorId); value.setAction(action); value.setIdempotencyKey(key);
value.setRequestHash(requestHash); value.setStatus("PROCESSING"); value.setCreatedAt(LocalDateTime.now());
try { repository.insert(value); return value; }
catch (DuplicateKeyException ex) {
CustomsOperationEntity existing = repository.operation(operatorId, action, key);
if (existing == null || !Objects.equals(requestHash, existing.getRequestHash())) throw conflict("幂等键已用于不同请求");
if (!"SUCCESS".equals(existing.getStatus()) || existing.getResultId() == null) throw conflict("相同请求正在处理中,请稍后重试");
return existing;
}
}
private void complete(CustomsOperationEntity operation, String resultType, long resultId) {
if (repository.completeOperation(operation.getId(), resultType, resultId) != 1) throw conflict("幂等请求完成状态冲突");
}
private static boolean completed(CustomsOperationEntity operation) { return "SUCCESS".equals(operation.getStatus()); }
private void transition(CustomsCaseEntity value, String next, long operatorId, String operatorName) {
if (repository.transition(value.getId(), value.getVersion(), next, operatorId, operatorName) != 1) throw concurrent();
}
private void history(CustomsCaseEntity customsCase, String action, String from, String to,
String resourceType, long resourceId, String note, long operatorId,
String operatorName, String operatorType) {
CustomsHistoryEntity value = new CustomsHistoryEntity(); value.setId(IdWorker.getId());
value.setCustomsCaseId(customsCase.getId()); value.setAction(action); value.setFromStatus(from);
value.setToStatus(to); value.setResourceType(resourceType); value.setResourceId(resourceId);
value.setNote(trim(note)); value.setOperatorId(operatorId); value.setOperatorName(operatorName);
value.setOperatorType(operatorType); value.setCreatedAt(LocalDateTime.now()); repository.insert(value);
}
private CaseDetail detail(CustomsCaseEntity value, boolean customerOnly) {
return new CaseDetail(value, checklist(requiredChecklist(value.getChecklistVersionId())),
repository.documents(value.getId()), repository.supplements(value.getId()), repository.batches(value.getId()),
repository.receipts(value.getId(), customerOnly), repository.history(value.getId()));
}
private ChecklistView checklist(CustomsChecklistVersionEntity value) {
return new ChecklistView(value, parseItems(value.getItemsJson()));
}
private List<ChecklistItem> checklistItems(CustomsCaseEntity value) {
return parseItems(requiredChecklist(value.getChecklistVersionId()).getItemsJson());
}
private List<ChecklistItem> parseItems(String json) {
try { return objectMapper.readValue(json, new TypeReference<>() {}); }
catch (JsonProcessingException ex) { throw new IllegalStateException("无法读取报关清单版本", ex); }
}
private CustomsCaseEntity required(long id) {
CustomsCaseEntity value = repository.customsCase(id); if (value == null) throw notFound("报关协同单不存在"); return value;
}
private CustomsCaseEntity required(long tenantId, long id) {
CustomsCaseEntity value = repository.customsCase(tenantId, id); if (value == null) throw notFound("报关协同单不存在"); return value;
}
private CustomsCaseEntity requiredVersion(CustomsCaseEntity value, int expectedVersion) {
if (!Integer.valueOf(expectedVersion).equals(value.getVersion())) throw concurrent(); return value;
}
private CustomsChecklistVersionEntity requiredChecklist(long id) {
CustomsChecklistVersionEntity value = repository.checklist(id); if (value == null) throw notFound("资料清单版本不存在"); return value;
}
private CustomsDocumentEntity requiredDocument(long id, long caseId) {
CustomsDocumentEntity value = repository.document(id);
if (value == null || !Objects.equals(value.getCustomsCaseId(), caseId)) throw notFound("报关文件版本不存在");
return value;
}
private CustomsSubmissionBatchEntity requiredBatch(long id, Long caseId) {
CustomsSubmissionBatchEntity value = repository.batch(id);
if (value == null || caseId != null && !Objects.equals(value.getCustomsCaseId(), caseId)) throw notFound("代理提交批次不存在");
return value;
}
private CustomsReceiptEntity requiredReceipt(long id, long caseId, boolean customerOnly) {
CustomsReceiptEntity value = repository.receipt(id);
if (value == null || !Objects.equals(value.getCustomsCaseId(), caseId)
|| customerOnly && !"CUSTOMER".equals(value.getVisibility())) throw notFound("报关回执不存在");
return value;
}
private static void validateChecklist(List<ChecklistItem> items) {
Set<String> codes = new HashSet<>();
for (ChecklistItem item : items) if (!codes.add(normalize(item.code()))) throw bad("资料清单项目编码不能重复");
if (items.stream().noneMatch(ChecklistItem::required)) throw bad("资料清单至少包含一个必填项目");
}
private String hash(Object... values) {
try { return sha256(objectMapper.writeValueAsBytes(values)); }
catch (JsonProcessingException ex) { throw new IllegalStateException("无法计算报关请求摘要", ex); }
}
private static String multipartHash(MultipartFile file) {
if (file == null || file.isEmpty()) throw bad("附件不能为空");
try (InputStream input = file.getInputStream()) {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] buffer = new byte[8192];
for (int count; (count = input.read(buffer)) >= 0;) digest.update(buffer, 0, count);
return HexFormat.of().formatHex(digest.digest());
}
catch (Exception ex) { throw bad("附件内容读取失败"); }
}
private static String sha256(byte[] bytes) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); }
catch (Exception ex) { throw new IllegalStateException(ex); }
}
private String json(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException ex) { throw new IllegalStateException("无法保存报关版本快照", ex); }
}
private static String normalize(String value) { return value.trim().toUpperCase(Locale.ROOT); }
private static String trim(String value) { if (value == null) return null; String result = value.trim(); return result.isEmpty() ? null : result; }
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
private static BizException concurrent() { return conflict("数据已被其他人修改,请刷新后重试"); }
private static BizException notFound(String message) { return new BizException(ResultCode.NOT_FOUND, message); }
}
@@ -12,7 +12,7 @@ public final class LoadingRequests {
public record UpsertEvidenceRequest(
@NotNull @PositiveOrZero Integer expectedVersion,
@NotBlank @Size(max = 32) String evidenceType,
@Positive Long fileId,
@NotNull @Positive Long fileId,
@Size(max = 255) String fileName,
@Size(max = 64) String sealNo,
@Size(max = 1000) String remark) {}
@@ -47,6 +47,13 @@ public class LoadingRepository {
.orderByAsc(LoadingEvidenceEntity::getEvidenceType));
}
public LoadingEvidenceEntity findEvidenceBySealNo(long tenantId, String sealNo) {
return evidences.selectOne(new LambdaQueryWrapper<LoadingEvidenceEntity>()
.eq(LoadingEvidenceEntity::getTenantId, tenantId)
.eq(LoadingEvidenceEntity::getEvidenceType, "SEAL")
.eq(LoadingEvidenceEntity::getSealNo, sealNo));
}
public void createEvidence(LoadingEvidenceEntity entity) { evidences.insert(entity); }
public int updateEvidence(long tenantId, long inboundId, String type, Long fileId, String fileName,
@@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -28,8 +29,11 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/** 装柜证据、装柜完成和派送完成;不检查证据完整度,不阻断业务流转。 */
/** 装柜证据、拣货复核、装柜完成和派送完成。 */
@Service
public class LoadingDeliveryService {
private static final String UPSERT_EVIDENCE = "UPSERT_LOADING_EVIDENCE";
@@ -52,21 +56,37 @@ public class LoadingDeliveryService {
if (!LoadingEvidenceType.VALUES.contains(request.evidenceType())) {
throw new BizException(ResultCode.BAD_REQUEST, "不支持的装柜证据类型");
}
var file = request.fileId() == null ? null : validateFile(tenantId, inboundId, request.fileId(), request.evidenceType());
String fileName = file == null ? request.fileName() : file.fileName();
LoadingEvidenceEntity existing = repository.findEvidence(tenantId, inboundId, request.evidenceType());
if (existing == null) {
if (request.expectedVersion() != 0) throw concurrent();
LoadingEvidenceEntity entity = new LoadingEvidenceEntity();
entity.setTenantId(tenantId); entity.setInboundId(inboundId);
entity.setEvidenceType(request.evidenceType()); entity.setFileId(request.fileId());
entity.setFileName(fileName); entity.setSealNo(request.sealNo()); entity.setRemark(request.remark());
entity.setVersion(0); entity.setCreatedBy(operatorId); entity.setCreatedByName(operatorName);
entity.setUpdatedBy(operatorId); entity.setUpdatedByName(operatorName);
repository.createEvidence(entity);
} else if (repository.updateEvidence(tenantId, inboundId, request.evidenceType(), request.fileId(), fileName,
request.sealNo(), request.remark(), operatorId, operatorName, request.expectedVersion()) != 1) {
throw concurrent();
if (request.fileId() == null) throw new BizException(ResultCode.BAD_REQUEST, "装柜证据必须先上传真实文件");
if (LoadingEvidenceType.SEAL.equals(request.evidenceType()) && !StringUtils.hasText(request.sealNo())) {
throw new BizException(ResultCode.BAD_REQUEST, "铅封证据必须填写锁码");
}
if (LoadingEvidenceType.SEAL.equals(request.evidenceType())) {
LoadingEvidenceEntity duplicate = repository.findEvidenceBySealNo(tenantId, request.sealNo().trim());
if (duplicate != null && !Long.valueOf(inboundId).equals(duplicate.getInboundId())) {
throw new BizException(ResultCode.CONFLICT, "锁码已用于其他装柜任务");
}
}
var file = validateFile(tenantId, inboundId, request.fileId(), request.evidenceType());
String fileName = file.fileName();
String sealNo = LoadingEvidenceType.SEAL.equals(request.evidenceType()) ? request.sealNo().trim() : null;
try {
LoadingEvidenceEntity existing = repository.findEvidence(tenantId, inboundId, request.evidenceType());
if (existing == null) {
if (request.expectedVersion() != 0) throw concurrent();
LoadingEvidenceEntity entity = new LoadingEvidenceEntity();
entity.setTenantId(tenantId); entity.setInboundId(inboundId);
entity.setEvidenceType(request.evidenceType()); entity.setFileId(request.fileId());
entity.setFileName(fileName); entity.setSealNo(sealNo); entity.setRemark(request.remark());
entity.setVersion(0); entity.setCreatedBy(operatorId); entity.setCreatedByName(operatorName);
entity.setUpdatedBy(operatorId); entity.setUpdatedByName(operatorName);
repository.createEvidence(entity);
} else if (repository.updateEvidence(tenantId, inboundId, request.evidenceType(), request.fileId(), fileName,
sealNo, request.remark(), operatorId, operatorName, request.expectedVersion()) != 1) {
throw concurrent();
}
} catch (DuplicateKeyException ex) {
throw new BizException(ResultCode.CONFLICT, LoadingEvidenceType.SEAL.equals(request.evidenceType())
? "锁码已用于其他装柜任务" : "数据已被其他人修改,请刷新后重试");
}
LoadingActionResponse result = new LoadingActionResponse(requireInbound(tenantId, inboundId).processStatus(), evidenceResponse(tenantId, inboundId));
return complete(tenantId, inboundId, operatorId, UPSERT_EVIDENCE, key, result);
@@ -84,6 +104,8 @@ public class LoadingDeliveryService {
new LoadingActionResponse(inbound.processStatus(), evidenceResponse(tenantId, inboundId)));
}
if (!"LOADING".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜中");
requireCompletedPicking(tenantId, inboundId);
requireCompletionEvidence(tenantId, inboundId);
if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent();
addHistory(tenantId, inboundId, "LOADING", "LOADED", COMPLETE_LOADING, request.remark(), operatorId, operatorName);
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
@@ -120,6 +142,11 @@ public class LoadingDeliveryService {
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "START_PICKING", key, hash);
if (replay != null) return pickResponse(tenantId, inboundId);
requireLoadingStage(tenantId, inboundId);
if (request.expectedCount() == null || request.expectedCount() <= 0
|| request.pickedCount() == null || request.pickedCount() < 0
|| request.pickedCount() > request.expectedCount()) {
throw new BizException(ResultCode.BAD_REQUEST, "拣货计划数量必须大于0且已拣数量不能超过计划数量");
}
InboundLoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
if (task == null) {
if (request.expectedVersion() != 0) throw concurrent();
@@ -128,6 +155,8 @@ public class LoadingDeliveryService {
task.setPickedCount(request.pickedCount());
task.setRemark(request.remark()); task.setUpdatedBy(operatorId); task.setUpdatedByName(operatorName);
repository.createPickTask(task);
} else if (PickingStatus.COMPLETED.equals(task.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "拣货任务已完成,不允许重新开始");
} else if (repository.changePickStatus(tenantId, inboundId, task.getStatus(), PickingStatus.IN_PROGRESS,
operatorId, operatorName, request.remark(), request.expectedCount(), request.pickedCount(), request.expectedVersion()) != 1) throw concurrent();
PickingResponse result = pickResponse(tenantId, inboundId);
@@ -143,14 +172,18 @@ public class LoadingDeliveryService {
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "COMPLETE_PICKING", key, hash);
if (replay != null) return pickResponse(tenantId, inboundId);
requireLoadingStage(tenantId, inboundId);
if (request.expectedCount() == null || request.expectedCount() <= 0
|| request.pickedCount() == null || !request.expectedCount().equals(request.pickedCount())) {
throw new BizException(ResultCode.CONFLICT, "拣货复核数量必须与计划数量一致");
}
InboundLoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
if (task == null) {
task = new InboundLoadingPickTaskEntity(); task.setTenantId(tenantId); task.setInboundId(inboundId);
task.setStatus(PickingStatus.COMPLETED); task.setVersion(0); task.setExpectedCount(request.expectedCount());
task.setPickedCount(request.pickedCount());
task.setRemark(request.remark()); task.setUpdatedBy(operatorId); task.setUpdatedByName(operatorName);
repository.createPickTask(task);
} else if (repository.changePickStatus(tenantId, inboundId,
if (task == null || !PickingStatus.IN_PROGRESS.equals(task.getStatus())) {
throw new BizException(ResultCode.CONFLICT, task == null ? "拣货任务尚未开始" : "拣货任务不在作业中");
}
if (!request.expectedCount().equals(task.getExpectedCount())) {
throw new BizException(ResultCode.CONFLICT, "拣货复核数量与任务计划数量不一致");
}
if (repository.changePickStatus(tenantId, inboundId,
task.getStatus(), PickingStatus.COMPLETED, operatorId, operatorName, request.remark(), request.expectedCount(), request.pickedCount(), request.expectedVersion()) != 1) throw concurrent();
PickingResponse result = pickResponse(tenantId, inboundId);
complete(tenantId, inboundId, operatorId, "COMPLETE_PICKING", key,
@@ -170,6 +203,30 @@ public class LoadingDeliveryService {
task.getExpectedCount(), task.getPickedCount(), task.getRemark());
}
private void requireCompletedPicking(long tenantId, long inboundId) {
InboundLoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
if (task == null || !PickingStatus.COMPLETED.equals(task.getStatus())
|| task.getExpectedCount() == null || task.getExpectedCount() <= 0
|| !task.getExpectedCount().equals(task.getPickedCount())) {
throw new BizException(ResultCode.CONFLICT, "PDA拣货复核尚未完成");
}
}
private void requireCompletionEvidence(long tenantId, long inboundId) {
Map<String, LoadingEvidenceEntity> values = repository.listEvidence(tenantId, inboundId).stream()
.collect(Collectors.toMap(LoadingEvidenceEntity::getEvidenceType, Function.identity(), (left, right) -> right));
boolean filesReady = LoadingEvidenceType.VALUES.stream()
.allMatch(type -> values.get(type) != null && values.get(type).getFileId() != null);
LoadingEvidenceEntity seal = values.get(LoadingEvidenceType.SEAL);
if (!filesReady || seal == null || !StringUtils.hasText(seal.getSealNo())) {
throw new BizException(ResultCode.CONFLICT, "空仓、半仓、满仓、锁仓、带锁码照及锁码必须齐全");
}
LoadingEvidenceType.VALUES.forEach(type -> {
LoadingEvidenceEntity evidence = values.get(type);
validateFile(tenantId, inboundId, evidence.getFileId(), type);
});
}
private LoadingEvidenceFileClient.FileMetadata validateFile(long tenantId, long inboundId, long fileId, String evidenceType) {
try {
var response = files.validate(internalToken, tenantId, inboundId, fileId);
@@ -0,0 +1,60 @@
package com.baiyue.firstleg.outbound.dispatch.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.*;
import com.baiyue.firstleg.outbound.dispatch.service.OutboundDispatchService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "三期国内出仓聚合")
@Validated @RestController
@RequestMapping("/api/platform/v1/first-leg/outbound-dispatches")
public class PlatformOutboundDispatchController {
private final OutboundDispatchService service;
public PlatformOutboundDispatchController(OutboundDispatchService service) { this.service = service; }
@GetMapping
@PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:list')")
public ApiResponse<List<Aggregate>> list(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.list(query));
}
@GetMapping("/detail")
@PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:view')")
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(query));
}
@GetMapping("/check-gate")
@PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:check')")
public ApiResponse<GateResult> checkGate(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.checkGate(query));
}
@PostMapping("/confirm-dispatch")
@PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:confirm')")
@BusinessAudit(event="OUTBOUND_CONFIRM_DISPATCH",resourceType="INBOUND",resourceId="#request.inboundId",description="确认国内出仓")
public ApiResponse<Detail> confirm(@AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ActionRequest request) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.confirm(user.userId(), user.username(), key, request));
}
@PostMapping("/reverse-dispatch")
@PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:reverse')")
@BusinessAudit(event="OUTBOUND_REVERSE_DISPATCH",resourceType="INBOUND",resourceId="#request.inboundId",description="撤销国内出仓")
public ApiResponse<Detail> reverse(@AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ActionRequest request) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.reverse(user.userId(), user.username(), key, request));
}
}
@@ -0,0 +1,6 @@
package com.baiyue.firstleg.outbound.dispatch.domain;
public enum OutboundDispatchStatus {
READY_FOR_DISPATCH,
WAITING_FOR_LOADING
}
@@ -0,0 +1,32 @@
package com.baiyue.firstleg.outbound.dispatch.dto;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
public final class OutboundDispatchDtos {
private OutboundDispatchDtos() {}
public record Query(@Positive Long orderId, @Positive Long inboundId, @Positive Long containerBookingId) {}
public record ActionRequest(@Positive Long inboundId, @Positive Long containerBookingId,
@PositiveOrZero int expectedVersion, @Size(max = 500) String remark) {}
public record GateItem(String code, String name, boolean passed, String actual, String requirement) {}
public record GateResult(boolean passed, List<GateItem> items, LocalDateTime checkedAt) {}
public record Aggregate(Long orderId, String orderNo, Long inboundId, String inboundCode, Long tenantId,
Long customerId, String customerName, String inboundStatus,
Long containerBookingId, String containerNo, Long loadingPlanId, String loadingPlanStatus,
Long pickTaskId, String pickTaskStatus, Long oceanBookingId, String oceanBookingStatus,
Long truckAppointmentId, String truckAppointmentStatus,
String dispatchStatus, Integer version, GateResult gate) {}
public record TimelineItem(String module, String action, String fromStatus, String toStatus,
Long operatorId, String operatorName, String remark, LocalDateTime occurredAt) {}
public record Detail(Aggregate aggregate, List<TimelineItem> timeline) {}
}
@@ -0,0 +1,29 @@
package com.baiyue.firstleg.outbound.dispatch.entity;
import com.baomidou.mybatisplus.annotation.IdType;
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_outbound_dispatch")
public class OutboundDispatchEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long tenantId;
private Long orderId;
private Long inboundId;
private Long containerBookingId;
private String status;
private Integer version;
private Long confirmedBy;
private String confirmedByName;
private LocalDateTime confirmedAt;
private Long reversedBy;
private String reversedByName;
private LocalDateTime reversedAt;
private String remark;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,25 @@
package com.baiyue.firstleg.outbound.dispatch.entity;
import com.baomidou.mybatisplus.annotation.IdType;
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_outbound_dispatch_history")
public class OutboundDispatchHistoryEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long dispatchId;
private Long tenantId;
private Long inboundId;
private Long containerBookingId;
private String action;
private String fromStatus;
private String toStatus;
private String remark;
private Long operatorId;
private String operatorName;
private LocalDateTime createdAt;
}
@@ -0,0 +1,22 @@
package com.baiyue.firstleg.outbound.dispatch.entity;
import com.baomidou.mybatisplus.annotation.IdType;
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_outbound_dispatch_operation")
public class OutboundDispatchOperationEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long dispatchId;
private Long operatorId;
private String operation;
private String idempotencyKey;
private String requestHash;
private String status;
private LocalDateTime createdAt;
private LocalDateTime completedAt;
}
@@ -0,0 +1,12 @@
package com.baiyue.firstleg.outbound.dispatch.gate;
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.GateItem;
/** External Phase 3 modules contribute a fail-closed gate result without coupling their entities here. */
public interface OutboundDispatchGateContributor {
String code();
GateItem assess(Context context);
record Context(long tenantId, long customerId, long orderId, long inboundId,
long containerBookingId, long loadingPlanId, Long oceanBookingId) {}
}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.outbound.dispatch.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchHistoryEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface OutboundDispatchHistoryMapper extends BaseMapper<OutboundDispatchHistoryEntity> {}
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.outbound.dispatch.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface OutboundDispatchMapper extends BaseMapper<OutboundDispatchEntity> {
@Select("SELECT * FROM fl_outbound_dispatch WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} "
+ "AND container_booking_id=#{containerBookingId}")
OutboundDispatchEntity find(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("containerBookingId") long containerBookingId);
@Update("UPDATE fl_outbound_dispatch SET status='WAITING_FOR_LOADING',confirmed_by=#{operatorId},"
+ "confirmed_by_name=#{operatorName},confirmed_at=CURRENT_TIMESTAMP(3),reversed_by=NULL,reversed_by_name=NULL,"
+ "reversed_at=NULL,remark=#{remark},version=version+1,updated_at=CURRENT_TIMESTAMP(3) "
+ "WHERE id=#{id} AND status='READY_FOR_DISPATCH' AND version=#{version}")
int confirm(@Param("id") long id, @Param("version") int version, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName, @Param("remark") String remark);
@Update("UPDATE fl_outbound_dispatch SET status='READY_FOR_DISPATCH',reversed_by=#{operatorId},"
+ "reversed_by_name=#{operatorName},reversed_at=CURRENT_TIMESTAMP(3),remark=#{remark},"
+ "version=version+1,updated_at=CURRENT_TIMESTAMP(3) "
+ "WHERE id=#{id} AND status='WAITING_FOR_LOADING' AND version=#{version}")
int reverse(@Param("id") long id, @Param("version") int version, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName, @Param("remark") String remark);
}
@@ -0,0 +1,28 @@
package com.baiyue.firstleg.outbound.dispatch.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchOperationEntity;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface OutboundDispatchOperationMapper extends BaseMapper<OutboundDispatchOperationEntity> {
@Insert("INSERT IGNORE INTO fl_outbound_dispatch_operation(id,operator_id,operation,idempotency_key,request_hash,status) "
+ "VALUES(#{id},#{operatorId},#{operation},#{key},#{hash},'PROCESSING')")
int claim(@Param("id") long id, @Param("operatorId") long operatorId, @Param("operation") String operation,
@Param("key") String key, @Param("hash") String hash);
@Select("SELECT * FROM fl_outbound_dispatch_operation WHERE operator_id=#{operatorId} "
+ "AND operation=#{operation} AND idempotency_key=#{key}")
OutboundDispatchOperationEntity find(@Param("operatorId") long operatorId,
@Param("operation") String operation, @Param("key") String key);
@Update("UPDATE fl_outbound_dispatch_operation SET dispatch_id=#{dispatchId},status='SUCCESS',"
+ "completed_at=CURRENT_TIMESTAMP(3) WHERE operator_id=#{operatorId} AND operation=#{operation} "
+ "AND idempotency_key=#{key} AND status='PROCESSING'")
int complete(@Param("dispatchId") long dispatchId, @Param("operatorId") long operatorId,
@Param("operation") String operation, @Param("key") String key);
}
@@ -0,0 +1,119 @@
package com.baiyue.firstleg.outbound.dispatch.mapper;
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.TimelineItem;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface OutboundDispatchReadMapper {
@Select("""
<script>
SELECT DISTINCT inbound.order_id orderId,inbound.order_no orderNo,inbound.id inboundId,
inbound.inbound_code inboundCode,inbound.tenant_id tenantId,inbound.customer_id customerId,
inbound.customer_name customerName,inbound.process_status inboundStatus,
booking.id containerBookingId,booking.container_no containerNo,
plan.id loadingPlanId,plan.plan_status loadingPlanStatus
FROM fl_loading_plan_item item
JOIN fl_loading_plan plan ON plan.id=item.loading_plan_id
JOIN fl_container_booking booking ON booking.id=plan.container_booking_id
JOIN fl_inbound_order inbound ON inbound.id=item.inbound_id AND inbound.tenant_id=item.account_id AND inbound.deleted=0
WHERE item.deleted=0
<if test="orderId != null">AND inbound.order_id=#{orderId}</if>
<if test="inboundId != null">AND inbound.id=#{inboundId}</if>
<if test="containerBookingId != null">AND booking.id=#{containerBookingId}</if>
ORDER BY inbound.id,booking.id
</script>
""")
List<Binding> bindings(@Param("orderId") Long orderId, @Param("inboundId") Long inboundId,
@Param("containerBookingId") Long containerBookingId);
@Select("SELECT id,task_status status FROM fl_loading_pick_task WHERE loading_plan_id=#{planId} "
+ "ORDER BY created_at DESC,id DESC LIMIT 1")
IdStatus pick(@Param("planId") long planId);
@Select("""
SELECT booking.id,booking.status
FROM fl_ocean_booking_container_allocation allocation
JOIN fl_ocean_booking booking ON booking.id=allocation.ocean_booking_id
WHERE allocation.container_booking_id=#{containerBookingId} AND booking.customer_id=#{customerId}
ORDER BY booking.updated_at DESC,booking.id DESC LIMIT 1
""")
IdStatus ocean(@Param("containerBookingId") long containerBookingId, @Param("customerId") long customerId);
@Select("""
SELECT id,status FROM fl_truck_appointment
WHERE container_booking_id=#{containerBookingId} AND loading_plan_id=#{loadingPlanId}
AND customer_id=#{customerId}
ORDER BY updated_at DESC,id DESC LIMIT 1
""")
IdStatus truck(@Param("containerBookingId") long containerBookingId, @Param("loadingPlanId") long loadingPlanId,
@Param("customerId") long customerId);
@Select("""
SELECT COUNT(DISTINCT CASE WHEN file_id IS NOT NULL AND evidence_type IN
('PHOTO_STEP_1','PHOTO_STEP_2','PHOTO_STEP_3','PHOTO_STEP_4','SEAL') THEN evidence_type END) evidenceCount,
MAX(CASE WHEN evidence_type='SEAL' AND file_id IS NOT NULL AND seal_no IS NOT NULL
AND seal_no&lt;&gt;'' THEN 1 ELSE 0 END) validSeal
FROM fl_loading_evidence WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId}
""")
Evidence evidence(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
@Update("UPDATE fl_inbound_order SET process_status='WAITING_FOR_LOADING',updated_by=#{operatorId},"
+ "version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=#{tenantId} AND id=#{inboundId} "
+ "AND deleted=0 AND process_status='LOADED'")
int confirmInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId);
@Update("UPDATE fl_inbound_order SET process_status='LOADED',updated_by=#{operatorId},"
+ "version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=#{tenantId} AND id=#{inboundId} "
+ "AND deleted=0 AND process_status='WAITING_FOR_LOADING'")
int reverseInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId);
@Select("""
SELECT module,action,fromStatus,toStatus,operatorId,operatorName,remark,occurredAt FROM (
SELECT 'INBOUND' module,h.action action,h.from_status fromStatus,h.to_status toStatus,
h.operator_id operatorId,h.operator_name operatorName,h.reason remark,h.occurred_at occurredAt
FROM fl_inbound_status_history h WHERE h.tenant_id=#{tenantId} AND h.inbound_id=#{inboundId}
UNION ALL
SELECT 'LOADING_PLAN',h.action_type,h.from_status,h.to_status,h.operator_id,h.operator_name,
h.operation_reason,h.created_at FROM fl_loading_plan_history h WHERE h.loading_plan_id=#{loadingPlanId}
UNION ALL
SELECT 'PDA_PICK',h.action_type,NULL,NULL,h.operator_id,h.operator_name,h.operation_reason,h.created_at
FROM fl_loading_pick_history h JOIN fl_loading_pick_task task ON task.id=h.pick_task_id
WHERE task.loading_plan_id=#{loadingPlanId}
UNION ALL
SELECT 'LOADING_EVIDENCE','UPSERT_EVIDENCE',NULL,NULL,
COALESCE(e.updated_by,e.created_by),COALESCE(e.updated_by_name,e.created_by_name),
CONCAT(e.evidence_type,CASE WHEN e.seal_no IS NULL OR e.seal_no='' THEN '' ELSE CONCAT(':',e.seal_no) END),
COALESCE(e.updated_at,e.created_at)
FROM fl_loading_evidence e WHERE e.tenant_id=#{tenantId} AND e.inbound_id=#{inboundId}
UNION ALL
SELECT 'OCEAN_BOOKING',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.reason,h.created_at
FROM fl_ocean_booking_status_history h WHERE h.ocean_booking_id=#{oceanBookingId}
UNION ALL
SELECT 'TRUCK_APPOINTMENT',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.reason,h.created_at
FROM fl_truck_appointment_history h WHERE h.appointment_id=#{truckAppointmentId}
UNION ALL
SELECT 'OUTBOUND_DISPATCH',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.remark,h.created_at
FROM fl_outbound_dispatch_history h WHERE h.tenant_id=#{tenantId} AND h.inbound_id=#{inboundId}
AND h.container_booking_id=#{containerBookingId}
) timeline ORDER BY occurredAt,module
""")
List<TimelineItem> timeline(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("containerBookingId") long containerBookingId,
@Param("loadingPlanId") long loadingPlanId,
@Param("oceanBookingId") Long oceanBookingId,
@Param("truckAppointmentId") Long truckAppointmentId);
record Binding(Long orderId, String orderNo, Long inboundId, String inboundCode, Long tenantId,
Long customerId, String customerName, String inboundStatus, Long containerBookingId,
String containerNo, Long loadingPlanId, String loadingPlanStatus) {}
record IdStatus(Long id, String status) {}
record Evidence(Integer evidenceCount, Integer validSeal) {}
}
@@ -0,0 +1,68 @@
package com.baiyue.firstleg.outbound.dispatch.repository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.Query;
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.TimelineItem;
import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchEntity;
import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchHistoryEntity;
import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchOperationEntity;
import com.baiyue.firstleg.outbound.dispatch.mapper.OutboundDispatchHistoryMapper;
import com.baiyue.firstleg.outbound.dispatch.mapper.OutboundDispatchMapper;
import com.baiyue.firstleg.outbound.dispatch.mapper.OutboundDispatchOperationMapper;
import com.baiyue.firstleg.outbound.dispatch.mapper.OutboundDispatchReadMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class OutboundDispatchRepository {
@Resource private OutboundDispatchMapper dispatches;
@Resource private OutboundDispatchHistoryMapper histories;
@Resource private OutboundDispatchOperationMapper operations;
@Resource private OutboundDispatchReadMapper reads;
public List<OutboundDispatchReadMapper.Binding> bindings(Query query) {
return reads.bindings(query.orderId(), query.inboundId(), query.containerBookingId());
}
public OutboundDispatchReadMapper.IdStatus pick(long planId) { return reads.pick(planId); }
public OutboundDispatchReadMapper.IdStatus ocean(long containerId, long customerId) {
return reads.ocean(containerId, customerId);
}
public OutboundDispatchReadMapper.IdStatus truck(long containerId, long planId, long customerId) {
return reads.truck(containerId, planId, customerId);
}
public OutboundDispatchReadMapper.Evidence evidence(long tenantId, long inboundId) {
return reads.evidence(tenantId, inboundId);
}
public OutboundDispatchEntity dispatch(long tenantId, long inboundId, long containerId) {
return dispatches.find(tenantId, inboundId, containerId);
}
public void insert(OutboundDispatchEntity value) { dispatches.insert(value); }
public int confirm(long id, int version, long operatorId, String operatorName, String remark) {
return dispatches.confirm(id, version, operatorId, operatorName, remark);
}
public int reverse(long id, int version, long operatorId, String operatorName, String remark) {
return dispatches.reverse(id, version, operatorId, operatorName, remark);
}
public int confirmInbound(long tenantId, long inboundId, long operatorId) {
return reads.confirmInbound(tenantId, inboundId, operatorId);
}
public int reverseInbound(long tenantId, long inboundId, long operatorId) {
return reads.reverseInbound(tenantId, inboundId, operatorId);
}
public void history(OutboundDispatchHistoryEntity value) { histories.insert(value); }
public boolean claim(long operatorId, String operation, String key, String hash) {
return operations.claim(IdWorker.getId(), operatorId, operation, key, hash) == 1;
}
public OutboundDispatchOperationEntity operation(long operatorId, String operation, String key) {
return operations.find(operatorId, operation, key);
}
public int completeOperation(long dispatchId, long operatorId, String operation, String key) {
return operations.complete(dispatchId, operatorId, operation, key);
}
public List<TimelineItem> timeline(OutboundDispatchReadMapper.Binding binding, Long oceanId, Long truckId) {
return reads.timeline(binding.tenantId(), binding.inboundId(), binding.containerBookingId(),
binding.loadingPlanId(), oceanId, truckId);
}
}
@@ -0,0 +1,228 @@
package com.baiyue.firstleg.outbound.dispatch.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.outbound.dispatch.domain.OutboundDispatchStatus;
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.*;
import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchEntity;
import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchHistoryEntity;
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor;
import com.baiyue.firstleg.outbound.dispatch.mapper.OutboundDispatchReadMapper;
import com.baiyue.firstleg.outbound.dispatch.repository.OutboundDispatchRepository;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.*;
@Service
public class OutboundDispatchService {
private static final Set<String> REQUIRED_CONTRIBUTORS = Set.of("CUSTOMS_CLEARANCE", "SAILING_SCHEDULE");
private static final Set<String> TRUCK_READY = Set.of("DEPARTED", "COMPLETED");
private final OutboundDispatchRepository repository;
private final Map<String, OutboundDispatchGateContributor> contributors;
public OutboundDispatchService(OutboundDispatchRepository repository,
List<OutboundDispatchGateContributor> contributors) {
this.repository = repository;
Map<String, OutboundDispatchGateContributor> indexed = new HashMap<>();
for (OutboundDispatchGateContributor contributor : contributors) {
if (indexed.put(contributor.code(), contributor) != null) {
throw new IllegalStateException("重复出仓门禁贡献器: " + contributor.code());
}
}
this.contributors = Map.copyOf(indexed);
}
public List<Aggregate> list(Query query) {
requireSelector(query);
return repository.bindings(query).stream().map(this::aggregate).toList();
}
public Detail detail(Query query) {
OutboundDispatchReadMapper.Binding binding = single(query);
Aggregate aggregate = aggregate(binding);
return new Detail(aggregate, repository.timeline(binding, aggregate.oceanBookingId(), aggregate.truckAppointmentId()));
}
public GateResult checkGate(Query query) { return aggregate(single(query)).gate(); }
@Transactional
public Detail confirm(long operatorId, String operatorName, String key, ActionRequest request) {
validateKey(key);
Query query = new Query(null, request.inboundId(), request.containerBookingId());
OutboundDispatchReadMapper.Binding binding = single(query);
String hash = hash("CONFIRM", request);
Detail replay = replay(operatorId, "CONFIRM_DISPATCH", key, hash, query);
if (replay != null) return replay;
Aggregate before = aggregate(binding);
if (!before.gate().passed()) throw conflict("国内出仓门禁未通过");
OutboundDispatchEntity state = state(binding);
if (!Objects.equals(state.getVersion(), request.expectedVersion())) throw concurrent();
if (OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(state.getStatus())) {
throw conflict("当前出仓单已确认派车");
}
if (repository.confirm(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1
|| repository.confirmInbound(binding.tenantId(), binding.inboundId(), operatorId) != 1) {
throw concurrent();
}
history(state, "CONFIRM_DISPATCH", OutboundDispatchStatus.READY_FOR_DISPATCH.name(),
OutboundDispatchStatus.WAITING_FOR_LOADING.name(), request.remark(), operatorId, operatorName);
if (repository.completeOperation(state.getId(), operatorId, "CONFIRM_DISPATCH", key) != 1) throw concurrent();
return detail(query);
}
@Transactional
public Detail reverse(long operatorId, String operatorName, String key, ActionRequest request) {
validateKey(key);
Query query = new Query(null, request.inboundId(), request.containerBookingId());
OutboundDispatchReadMapper.Binding binding = single(query);
String hash = hash("REVERSE", request);
Detail replay = replay(operatorId, "REVERSE_DISPATCH", key, hash, query);
if (replay != null) return replay;
OutboundDispatchEntity state = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId());
if (state == null || !OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(state.getStatus())) {
throw conflict("当前出仓单不在等待装船状态");
}
if (!Objects.equals(state.getVersion(), request.expectedVersion())) throw concurrent();
if (repository.reverse(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1
|| repository.reverseInbound(binding.tenantId(), binding.inboundId(), operatorId) != 1) {
throw concurrent();
}
history(state, "REVERSE_DISPATCH", OutboundDispatchStatus.WAITING_FOR_LOADING.name(),
OutboundDispatchStatus.READY_FOR_DISPATCH.name(), request.remark(), operatorId, operatorName);
if (repository.completeOperation(state.getId(), operatorId, "REVERSE_DISPATCH", key) != 1) throw concurrent();
return detail(query);
}
private Aggregate aggregate(OutboundDispatchReadMapper.Binding binding) {
var pick = repository.pick(binding.loadingPlanId());
var ocean = repository.ocean(binding.containerBookingId(), binding.customerId());
var truck = repository.truck(binding.containerBookingId(), binding.loadingPlanId(), binding.customerId());
var evidence = repository.evidence(binding.tenantId(), binding.inboundId());
var dispatch = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId());
GateResult gate = gate(binding, pick, ocean, truck, evidence);
return new Aggregate(binding.orderId(), binding.orderNo(), binding.inboundId(), binding.inboundCode(),
binding.tenantId(), binding.customerId(), binding.customerName(), binding.inboundStatus(),
binding.containerBookingId(), binding.containerNo(), binding.loadingPlanId(), binding.loadingPlanStatus(),
id(pick), status(pick), id(ocean), status(ocean), id(truck), status(truck),
dispatch == null ? OutboundDispatchStatus.READY_FOR_DISPATCH.name() : dispatch.getStatus(),
dispatch == null ? 0 : dispatch.getVersion(), gate);
}
private GateResult gate(OutboundDispatchReadMapper.Binding binding, OutboundDispatchReadMapper.IdStatus pick,
OutboundDispatchReadMapper.IdStatus ocean, OutboundDispatchReadMapper.IdStatus truck,
OutboundDispatchReadMapper.Evidence evidence) {
List<GateItem> items = new ArrayList<>();
items.add(item("INBOUND_LOADED", "入库单装柜完成", "LOADED".equals(binding.inboundStatus()),
binding.inboundStatus(), "LOADED"));
items.add(item("LOADING_PLAN_COMPLETED", "柜次装柜计划完成", "COMPLETED".equals(binding.loadingPlanStatus()),
binding.loadingPlanStatus(), "COMPLETED"));
items.add(item("PDA_PICK_COMPLETED", "PDA柜次拣货完成", pick != null && "COMPLETED".equals(pick.status()),
status(pick), "COMPLETED"));
items.add(item("OCEAN_BOOKING_RELEASED", "订舱已放舱且分配当前柜次",
ocean != null && "RELEASED".equals(ocean.status()), status(ocean), "RELEASED"));
items.add(item("TRUCK_DEPARTED", "关联拖车已离仓", truck != null && TRUCK_READY.contains(truck.status()),
status(truck), "DEPARTED或COMPLETED"));
boolean evidenceReady = evidence != null && evidence.evidenceCount() != null
&& evidence.evidenceCount() >= 5 && Objects.equals(evidence.validSeal(), 1);
items.add(item("LOADING_EVIDENCE_COMPLETE", "四步照片与铅封完整", evidenceReady,
evidence == null ? null : evidence.evidenceCount() + "个证据/有效铅封=" + evidence.validSeal(),
"PHOTO_STEP_1..4和含铅封号的SEAL"));
var context = new OutboundDispatchGateContributor.Context(binding.tenantId(), binding.customerId(),
binding.orderId(), binding.inboundId(), binding.containerBookingId(), binding.loadingPlanId(), id(ocean));
for (String code : REQUIRED_CONTRIBUTORS.stream().sorted().toList()) {
OutboundDispatchGateContributor contributor = contributors.get(code);
if (contributor == null) {
items.add(item(code, contributorName(code), false, "MODULE_UNAVAILABLE", "模块返回可放行状态"));
continue;
}
try {
GateItem result = contributor.assess(context);
items.add(result == null ? item(code, contributorName(code), false, "NO_RESULT", "模块返回可放行状态") : result);
} catch (RuntimeException ex) {
items.add(item(code, contributorName(code), false, "ASSESSMENT_ERROR", "模块返回可放行状态"));
}
}
return new GateResult(items.stream().allMatch(GateItem::passed), List.copyOf(items), LocalDateTime.now());
}
private Detail replay(long operatorId, String operation, String key, String hash, Query query) {
if (repository.claim(operatorId, operation, key, hash)) return null;
var existing = repository.operation(operatorId, operation, key);
if (existing == null || !Objects.equals(existing.getRequestHash(), hash)) {
throw conflict("Idempotency-Key已用于不同请求");
}
if (!"SUCCESS".equals(existing.getStatus())) throw conflict("相同请求正在处理中");
return detail(query);
}
private OutboundDispatchEntity state(OutboundDispatchReadMapper.Binding binding) {
OutboundDispatchEntity state = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId());
if (state != null) return state;
state = new OutboundDispatchEntity();
state.setId(IdWorker.getId()); state.setTenantId(binding.tenantId()); state.setOrderId(binding.orderId());
state.setInboundId(binding.inboundId()); state.setContainerBookingId(binding.containerBookingId());
state.setStatus(OutboundDispatchStatus.READY_FOR_DISPATCH.name()); state.setVersion(0);
try { repository.insert(state); }
catch (DuplicateKeyException ex) {
state = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId());
if (state == null) throw ex;
}
return state;
}
private void history(OutboundDispatchEntity state, String action, String from, String to, String remark,
long operatorId, String operatorName) {
OutboundDispatchHistoryEntity history = new OutboundDispatchHistoryEntity();
history.setId(IdWorker.getId()); history.setDispatchId(state.getId()); history.setTenantId(state.getTenantId());
history.setInboundId(state.getInboundId()); history.setContainerBookingId(state.getContainerBookingId());
history.setAction(action); history.setFromStatus(from); history.setToStatus(to); history.setRemark(remark);
history.setOperatorId(operatorId); history.setOperatorName(operatorName); repository.history(history);
}
private OutboundDispatchReadMapper.Binding single(Query query) {
requireSelector(query);
List<OutboundDispatchReadMapper.Binding> bindings = repository.bindings(query);
if (bindings.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "未找到匹配的入库单与柜次关联");
if (bindings.size() != 1) throw new BizException(ResultCode.BAD_REQUEST, "查询条件匹配多个出仓聚合,请同时指定inboundId和containerBookingId");
return bindings.get(0);
}
private static void requireSelector(Query query) {
if (query == null || query.orderId() == null && query.inboundId() == null && query.containerBookingId() == null) {
throw new BizException(ResultCode.BAD_REQUEST, "orderId、inboundId、containerBookingId至少提供一个");
}
}
private static void validateKey(String key) {
if (key == null || key.isBlank() || key.length() > 128) {
throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key不能为空且长度不能超过128");
}
}
private static String hash(String action, ActionRequest request) {
String source = action + "|" + request.inboundId() + "|" + request.containerBookingId() + "|"
+ request.expectedVersion() + "|" + Objects.toString(request.remark(), "");
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(source.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest);
} catch (Exception ex) { throw new IllegalStateException(ex); }
}
private static Long id(OutboundDispatchReadMapper.IdStatus value) { return value == null ? null : value.id(); }
private static String status(OutboundDispatchReadMapper.IdStatus value) { return value == null ? null : value.status(); }
private static GateItem item(String code, String name, boolean pass, String actual, String requirement) {
return new GateItem(code, name, pass, actual, requirement);
}
private static String contributorName(String code) {
return "CUSTOMS_CLEARANCE".equals(code) ? "报关协同完成" : "船期有效且未过期";
}
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
private static BizException concurrent() { return conflict("数据已被其他人修改,请刷新后重试"); }
}
@@ -0,0 +1,87 @@
package com.baiyue.firstleg.portschedule.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.CreateRequest;
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.Detail;
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.Query;
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.ScheduleView;
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.UpdateRequest;
import com.baiyue.firstleg.portschedule.entity.PortScheduleHistoryEntity;
import com.baiyue.firstleg.portschedule.service.PortScheduleService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
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.util.List;
@Validated
@RestController
@Tag(name = "港务船期")
@RequestMapping("/api/platform/v1/port-schedules")
public class PlatformPortScheduleController {
@Resource private PortScheduleService service;
@GetMapping
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule:list')")
public ApiResponse<PageResult<ScheduleView>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid Query query) {
FirstlegScopeGuard.platform(user);
if (query.customerId() != null) FirstlegScopeGuard.platform(user, query.customerId());
return ApiResponse.success(service.page(query));
}
@GetMapping("/{portScheduleId}")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule:view')")
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long portScheduleId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.detail(portScheduleId));
}
@GetMapping("/{portScheduleId}/history")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule:view')")
public ApiResponse<List<PortScheduleHistoryEntity>> history(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long portScheduleId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.history(portScheduleId));
}
@PostMapping
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule:create')")
@BusinessAudit(event = "PORT_SCHEDULE_CREATE", resourceType = "PORT_SCHEDULE",
description = "新增港务船期", captureBefore = false)
public ApiResponse<Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody CreateRequest request) {
FirstlegScopeGuard.platform(user, request.data().customerId());
return ApiResponse.success(service.create(user.userId(), user.username(), key, request));
}
@PutMapping("/{portScheduleId}")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule:edit')")
@BusinessAudit(event = "PORT_SCHEDULE_UPDATE", resourceType = "PORT_SCHEDULE",
resourceId = "#portScheduleId", description = "更新港务船期")
public ApiResponse<Detail> update(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long portScheduleId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody UpdateRequest request) {
FirstlegScopeGuard.platform(user, request.data().customerId());
return ApiResponse.success(service.update(portScheduleId, user.userId(), user.username(), key, request));
}
}
@@ -0,0 +1,58 @@
package com.baiyue.firstleg.portschedule.dto;
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
import com.baiyue.firstleg.portschedule.entity.PortScheduleHistoryEntity;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
public final class PortScheduleDtos {
private PortScheduleDtos() {}
public record Query(@Positive Long customerId, @Positive Long oceanBookingId,
@Positive Long containerBookingId, 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; }
}
public record ScheduleData(@NotNull @Positive Long customerId,
@NotNull @Positive Long oceanBookingId,
@Positive Long containerBookingId,
@NotBlank @Size(max = 24) String sourceType,
@NotBlank @Size(max = 128) String sourceName,
@NotBlank @Size(max = 255) String sourceRef,
@NotBlank @Size(max = 128) String vesselName,
@NotBlank @Size(max = 64) String voyageNo,
@NotBlank @Size(max = 32) String departurePortCode,
@Size(max = 128) String departurePortName,
@NotBlank @Size(max = 32) String destinationPortCode,
@Size(max = 128) String destinationPortName,
LocalDateTime etdAt,
LocalDateTime etaAt,
LocalDateTime bookingCutoffAt,
LocalDateTime customsCutoffAt,
LocalDateTime portCutoffAt,
@NotNull LocalDateTime observedAt,
@NotNull LocalDateTime expiresAt,
@Size(max = 1000) String remark) {}
public record CreateRequest(@NotNull @Min(0) Integer expectedVersion,
@Valid @NotNull ScheduleData data,
@Size(max = 1000) String reason) {}
public record UpdateRequest(@NotNull @Min(0) Integer expectedVersion,
@Valid @NotNull ScheduleData data,
@Size(max = 1000) String reason) {}
public record ScheduleView(PortScheduleEntity schedule, boolean expired) {}
public record Detail(ScheduleView portSchedule, List<PortScheduleHistoryEntity> histories) {}
}
@@ -0,0 +1,47 @@
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 com.baomidou.mybatisplus.annotation.Version;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_port_schedule")
public class PortScheduleEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String scheduleNo;
private Long customerId;
private Long oceanBookingId;
private Long containerBookingId;
private String sourceType;
private String sourceName;
private String sourceRef;
private String vesselName;
private String voyageNo;
private String departurePortCode;
private String departurePortName;
private String destinationPortCode;
private String destinationPortName;
private LocalDateTime etdAt;
private LocalDateTime etaAt;
private LocalDateTime bookingCutoffAt;
private LocalDateTime customsCutoffAt;
private LocalDateTime portCutoffAt;
private LocalDateTime observedAt;
private LocalDateTime expiresAt;
private String remark;
private Long createdBy;
private String createdByName;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
private Long updatedBy;
private String updatedByName;
@TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updatedAt;
@Version @TableField(fill = FieldFill.INSERT) private Integer version;
}
@@ -0,0 +1,28 @@
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_port_schedule_history")
public class PortScheduleHistoryEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long portScheduleId;
private String action;
private Integer fromVersion;
private Integer toVersion;
private String reason;
private String beforeSnapshot;
private String afterSnapshot;
private Long operatorId;
private String operatorName;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
}
@@ -0,0 +1,27 @@
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_port_schedule_operation")
public class PortScheduleOperationEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long portScheduleId;
private Long operatorId;
private String operation;
private String idempotencyKey;
private String requestHash;
private String status;
private String resultJson;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
private LocalDateTime completedAt;
}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.portschedule.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.portschedule.entity.PortScheduleHistoryEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PortScheduleHistoryMapper extends BaseMapper<PortScheduleHistoryEntity> {}
@@ -0,0 +1,61 @@
package com.baiyue.firstleg.portschedule.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.Query;
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface PortScheduleMapper extends BaseMapper<PortScheduleEntity> {
@Select("""
<script>
SELECT * FROM fl_port_schedule
WHERE (#{query.customerId} IS NULL OR customer_id=#{query.customerId})
AND (#{query.oceanBookingId} IS NULL OR ocean_booking_id=#{query.oceanBookingId})
AND (#{query.containerBookingId} IS NULL OR container_booking_id=#{query.containerBookingId})
AND (#{query.expired} IS NULL
OR (#{query.expired}=TRUE AND expires_at&lt;=CURRENT_TIMESTAMP(3))
OR (#{query.expired}=FALSE AND expires_at&gt;CURRENT_TIMESTAMP(3)))
AND (#{query.keyword} IS NULL OR schedule_no LIKE CONCAT('%',#{query.keyword},'%')
OR source_name LIKE CONCAT('%',#{query.keyword},'%')
OR source_ref LIKE CONCAT('%',#{query.keyword},'%')
OR vessel_name LIKE CONCAT('%',#{query.keyword},'%')
OR voyage_no LIKE CONCAT('%',#{query.keyword},'%')
OR departure_port_code LIKE CONCAT('%',#{query.keyword},'%')
OR destination_port_code LIKE CONCAT('%',#{query.keyword},'%'))
ORDER BY observed_at DESC,id DESC
</script>
""")
Page<PortScheduleEntity> selectPage(Page<PortScheduleEntity> page, @Param("query") Query query);
@Select("""
SELECT * FROM fl_port_schedule
WHERE customer_id=#{customerId} AND ocean_booking_id=#{oceanBookingId}
AND container_booking_id=#{containerBookingId}
ORDER BY observed_at DESC,updated_at DESC,id DESC LIMIT 1
""")
PortScheduleEntity latestForDispatch(@Param("customerId") long customerId,
@Param("oceanBookingId") long oceanBookingId,
@Param("containerBookingId") long containerBookingId);
@Update("""
UPDATE fl_port_schedule SET source_type=#{value.sourceType},source_name=#{value.sourceName},
source_ref=#{value.sourceRef},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},
departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},
destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},
etd_at=#{value.etdAt},eta_at=#{value.etaAt},booking_cutoff_at=#{value.bookingCutoffAt},
customs_cutoff_at=#{value.customsCutoffAt},port_cutoff_at=#{value.portCutoffAt},
observed_at=#{value.observedAt},expires_at=#{value.expiresAt},remark=#{value.remark},
updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{value.id} AND customer_id=#{value.customerId} AND ocean_booking_id=#{value.oceanBookingId}
AND (container_booking_id&lt;=&gt;#{value.containerBookingId}) AND version=#{expectedVersion}
""")
int updateSchedule(@Param("value") PortScheduleEntity value,
@Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
}
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.portschedule.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.portschedule.entity.PortScheduleOperationEntity;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface PortScheduleOperationMapper extends BaseMapper<PortScheduleOperationEntity> {
@Insert("INSERT IGNORE INTO fl_port_schedule_operation "
+ "(id,port_schedule_id,operator_id,operation,idempotency_key,request_hash,status,created_at) "
+ "VALUES(#{id},#{portScheduleId},#{operatorId},#{operation},#{key},#{hash},'PROCESSING',CURRENT_TIMESTAMP(3))")
int claim(@Param("id") long id, @Param("portScheduleId") Long portScheduleId,
@Param("operatorId") long operatorId, @Param("operation") String operation,
@Param("key") String key, @Param("hash") String hash);
@Update("UPDATE fl_port_schedule_operation SET port_schedule_id=#{portScheduleId},status='SUCCESS',"
+ "result_json=#{resultJson},completed_at=CURRENT_TIMESTAMP(3) WHERE operator_id=#{operatorId} "
+ "AND idempotency_key=#{key} AND status='PROCESSING'")
int complete(@Param("portScheduleId") long portScheduleId, @Param("operatorId") long operatorId,
@Param("key") String key, @Param("resultJson") String resultJson);
}
@@ -0,0 +1,69 @@
package com.baiyue.firstleg.portschedule.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingContainerAllocationMapper;
import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingMapper;
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.Query;
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
import com.baiyue.firstleg.portschedule.entity.PortScheduleHistoryEntity;
import com.baiyue.firstleg.portschedule.entity.PortScheduleOperationEntity;
import com.baiyue.firstleg.portschedule.mapper.PortScheduleHistoryMapper;
import com.baiyue.firstleg.portschedule.mapper.PortScheduleMapper;
import com.baiyue.firstleg.portschedule.mapper.PortScheduleOperationMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class PortScheduleRepository {
@Resource private PortScheduleMapper schedules;
@Resource private PortScheduleHistoryMapper histories;
@Resource private PortScheduleOperationMapper operations;
@Resource private OceanBookingMapper oceanBookings;
@Resource private OceanBookingContainerAllocationMapper allocations;
@Resource private ContainerBookingMapper containerBookings;
public PageResult<PortScheduleEntity> page(Query query) {
return PageResult.from(schedules.selectPage(new Page<>(query.page(), query.size()), query));
}
public PortScheduleEntity find(long id) { return schedules.selectById(id); }
public PortScheduleEntity latestForDispatch(long customerId, long oceanBookingId, long containerBookingId) {
return schedules.latestForDispatch(customerId, oceanBookingId, containerBookingId);
}
public void insert(PortScheduleEntity value) { schedules.insert(value); }
public int update(PortScheduleEntity value, int expectedVersion, long operatorId, String operatorName) {
return schedules.updateSchedule(value, expectedVersion, operatorId, operatorName);
}
public List<PortScheduleHistoryEntity> histories(long id) {
return histories.selectList(new LambdaQueryWrapper<PortScheduleHistoryEntity>()
.eq(PortScheduleHistoryEntity::getPortScheduleId, id)
.orderByAsc(PortScheduleHistoryEntity::getCreatedAt).orderByAsc(PortScheduleHistoryEntity::getId));
}
public void insert(PortScheduleHistoryEntity value) { histories.insert(value); }
public OceanBookingEntity oceanBooking(long id) { return oceanBookings.selectById(id); }
public ContainerBookingEntity containerBooking(long id) { return containerBookings.selectById(id); }
public OceanBookingContainerAllocationEntity allocation(long oceanBookingId, long containerBookingId) {
return allocations.selectOne(new LambdaQueryWrapper<OceanBookingContainerAllocationEntity>()
.eq(OceanBookingContainerAllocationEntity::getOceanBookingId, oceanBookingId)
.eq(OceanBookingContainerAllocationEntity::getContainerBookingId, containerBookingId));
}
public boolean claimOperation(Long id, long operatorId, String operation, String key, String hash) {
return operations.claim(IdWorker.getId(), id, operatorId, operation, key, hash) == 1;
}
public PortScheduleOperationEntity operation(long operatorId, String key) {
return operations.selectOne(new LambdaQueryWrapper<PortScheduleOperationEntity>()
.eq(PortScheduleOperationEntity::getOperatorId, operatorId)
.eq(PortScheduleOperationEntity::getIdempotencyKey, key));
}
public int completeOperation(long id, long operatorId, String key, String resultJson) {
return operations.complete(id, operatorId, key, resultJson);
}
}
@@ -0,0 +1,59 @@
package com.baiyue.firstleg.portschedule.service;
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.GateItem;
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor;
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
@Component
public class PortScheduleDispatchGateContributor implements OutboundDispatchGateContributor {
private static final String CODE = "SAILING_SCHEDULE";
private static final String REQUIREMENT = "最新独立船期来源有效,且未超过expiresAt、截港时间和ETD";
@Resource private PortScheduleRepository repository;
@Override public String code() { return CODE; }
@Override
public GateItem assess(Context context) {
if (context.tenantId() <= 0 || context.customerId() <= 0 || context.tenantId() != context.customerId()) {
return gate(false, "INVALID_CUSTOMER_SCOPE");
}
if (context.oceanBookingId() == null || context.oceanBookingId() <= 0) {
return gate(false, "MISSING_OCEAN_BOOKING");
}
if (context.containerBookingId() <= 0) return gate(false, "MISSING_CONTAINER_BOOKING");
PortScheduleEntity schedule = repository.latestForDispatch(context.tenantId(), context.oceanBookingId(),
context.containerBookingId());
if (schedule == null) return gate(false, "MISSING");
if (!java.util.Objects.equals(schedule.getCustomerId(), context.tenantId())
|| !java.util.Objects.equals(schedule.getOceanBookingId(), context.oceanBookingId())
|| !java.util.Objects.equals(schedule.getContainerBookingId(), context.containerBookingId())) {
return gate(false, "ASSOCIATION_MISMATCH");
}
return assess(schedule, LocalDateTime.now());
}
private static GateItem assess(PortScheduleEntity schedule, LocalDateTime now) {
if (!StringUtils.hasText(schedule.getSourceType()) || !StringUtils.hasText(schedule.getSourceName())
|| !StringUtils.hasText(schedule.getSourceRef()) || schedule.getObservedAt() == null
|| schedule.getExpiresAt() == null || schedule.getPortCutoffAt() == null || schedule.getEtdAt() == null) {
return gate(false, "MISSING_REQUIRED_SCHEDULE_DATA");
}
if (schedule.getObservedAt().isAfter(now)) return gate(false, "OBSERVATION_IN_FUTURE");
if (!schedule.getExpiresAt().isAfter(now)) return gate(false, "EXPIRED");
if (!schedule.getPortCutoffAt().isAfter(now)) return gate(false, "PORT_CUTOFF_PASSED");
if (!schedule.getEtdAt().isAfter(now)) return gate(false, "ETD_PASSED");
return gate(true, "READY:scheduleId=" + schedule.getId() + ",expiresAt=" + schedule.getExpiresAt()
+ ",portCutoffAt=" + schedule.getPortCutoffAt() + ",etdAt=" + schedule.getEtdAt());
}
private static GateItem gate(boolean passed, String actual) {
return new GateItem(CODE, "有效港务船期", passed, actual, REQUIREMENT);
}
}
@@ -0,0 +1,223 @@
package com.baiyue.firstleg.portschedule.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.CreateRequest;
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.Detail;
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.Query;
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.ScheduleData;
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.ScheduleView;
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.UpdateRequest;
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
import com.baiyue.firstleg.portschedule.entity.PortScheduleHistoryEntity;
import com.baiyue.firstleg.portschedule.entity.PortScheduleOperationEntity;
import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.Objects;
import java.util.Set;
@Service
public class PortScheduleService {
private static final Set<String> SOURCE_TYPES = Set.of("MANUAL", "CARRIER", "TERMINAL", "API");
@Resource private PortScheduleRepository repository;
@Resource private ObjectMapper objectMapper;
public PageResult<ScheduleView> page(Query query) {
PageResult<PortScheduleEntity> source = repository.page(query);
LocalDateTime now = LocalDateTime.now();
return new PageResult<>(source.records().stream().map(value -> view(value, now)).toList(),
source.page(), source.size(), source.total(), source.pages());
}
public Detail detail(long id) {
return detail(required(id));
}
public java.util.List<PortScheduleHistoryEntity> history(long id) {
required(id);
return repository.histories(id);
}
@Transactional
public Detail create(long operatorId, String operatorName, String key, CreateRequest request) {
String operation = "CREATE";
Detail replay = claimOrReplay(null, operatorId, operation, key, hash(operation, null, request));
if (replay != null) return replay;
if (request.expectedVersion() != 0) throw badRequest("新建船期expectedVersion必须为0");
validate(request.data());
validateLinks(request.data());
PortScheduleEntity value = new PortScheduleEntity();
value.setId(IdWorker.getId());
value.setScheduleNo("PS" + value.getId());
copy(value, request.data());
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName);
value.setVersion(0);
try { repository.insert(value); }
catch (DuplicateKeyException ex) { throw conflict("来源引用已存在,请更新已有船期"); }
addHistory(value.getId(), operation, null, 0, request.reason(), null, value, operatorId, operatorName);
return complete(value.getId(), operatorId, key, detail(value));
}
@Transactional
public Detail update(long id, long operatorId, String operatorName, String key, UpdateRequest request) {
String operation = "UPDATE";
Detail replay = claimOrReplay(id, operatorId, operation, key, hash(operation, id, request));
if (replay != null) return replay;
PortScheduleEntity before = requiredVersion(id, request.expectedVersion());
validate(request.data());
validateLinks(request.data());
if (!Objects.equals(before.getCustomerId(), request.data().customerId())
|| !Objects.equals(before.getOceanBookingId(), request.data().oceanBookingId())
|| !Objects.equals(before.getContainerBookingId(), request.data().containerBookingId())) {
throw conflict("船期的客户、订舱和柜次归属不可变更");
}
PortScheduleEntity value = new PortScheduleEntity();
value.setId(id);
copy(value, request.data());
try {
if (repository.update(value, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent();
} catch (DuplicateKeyException ex) { throw conflict("来源引用已存在,请更新已有船期"); }
PortScheduleEntity after = required(id);
addHistory(id, operation, before.getVersion(), after.getVersion(), request.reason(), before, after,
operatorId, operatorName);
return complete(id, operatorId, key, detail(after));
}
private void validateLinks(ScheduleData data) {
OceanBookingEntity booking = repository.oceanBooking(data.oceanBookingId());
if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "关联订舱不存在");
if (!Objects.equals(booking.getCustomerId(), data.customerId())) throw badRequest("customerId与订舱客户不一致");
if (data.containerBookingId() != null) {
if (repository.containerBooking(data.containerBookingId()) == null) {
throw new BizException(ResultCode.NOT_FOUND, "关联柜次不存在");
}
if (repository.allocation(data.oceanBookingId(), data.containerBookingId()) == null) {
throw badRequest("containerBookingId未分配至该订舱");
}
}
}
private static void validate(ScheduleData data) {
String sourceType = data.sourceType().trim().toUpperCase();
if (!SOURCE_TYPES.contains(sourceType)) throw badRequest("sourceType仅支持MANUAL、CARRIER、TERMINAL或API");
if (data.etdAt() != null && data.etaAt() != null && data.etaAt().isBefore(data.etdAt())) {
throw badRequest("ETA不能早于ETD");
}
if (!data.expiresAt().isAfter(data.observedAt())) throw badRequest("expiresAt必须晚于observedAt");
validateCutoff("截单时间", data.bookingCutoffAt(), data.etdAt());
validateCutoff("截关时间", data.customsCutoffAt(), data.etdAt());
validateCutoff("截港时间", data.portCutoffAt(), data.etdAt());
}
private static void validateCutoff(String name, LocalDateTime cutoff, LocalDateTime etd) {
if (cutoff != null && etd != null && cutoff.isAfter(etd)) throw badRequest(name + "不能晚于ETD");
}
private Detail claimOrReplay(Long id, long operatorId, String operation, String key, String requestHash) {
validateKey(key);
if (repository.claimOperation(id, operatorId, operation, key, requestHash)) return null;
PortScheduleOperationEntity existing = repository.operation(operatorId, key);
if (existing == null || !requestHash.equals(existing.getRequestHash())) throw conflict("幂等键已用于其他请求");
if (!"SUCCESS".equals(existing.getStatus()) || !StringUtils.hasText(existing.getResultJson())) {
throw conflict("相同请求正在处理中,请稍后重试");
}
try { return objectMapper.readValue(existing.getResultJson(), Detail.class); }
catch (JsonProcessingException ex) { throw new IllegalStateException("无法读取船期幂等结果", ex); }
}
private Detail complete(long id, long operatorId, String key, Detail result) {
try {
if (repository.completeOperation(id, operatorId, key, objectMapper.writeValueAsString(result)) != 1) {
throw conflict("船期幂等请求完成状态冲突");
}
return result;
} catch (JsonProcessingException ex) { throw new IllegalStateException("无法保存船期幂等结果", ex); }
}
private void addHistory(long id, String action, Integer fromVersion, Integer toVersion, String reason,
Object before, Object after, long operatorId, String operatorName) {
PortScheduleHistoryEntity history = new PortScheduleHistoryEntity();
history.setId(IdWorker.getId()); history.setPortScheduleId(id); history.setAction(action);
history.setFromVersion(fromVersion); history.setToVersion(toVersion); history.setReason(trim(reason));
history.setBeforeSnapshot(json(before)); history.setAfterSnapshot(json(after));
history.setOperatorId(operatorId); history.setOperatorName(operatorName);
repository.insert(history);
}
private Detail detail(PortScheduleEntity value) {
return new Detail(view(value, LocalDateTime.now()), repository.histories(value.getId()));
}
private static ScheduleView view(PortScheduleEntity value, LocalDateTime now) {
return new ScheduleView(value, !value.getExpiresAt().isAfter(now));
}
private PortScheduleEntity required(long id) {
PortScheduleEntity value = repository.find(id);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "港务船期不存在");
return value;
}
private PortScheduleEntity requiredVersion(long id, int version) {
PortScheduleEntity value = required(id);
if (!Integer.valueOf(version).equals(value.getVersion())) throw concurrent();
return value;
}
private static void copy(PortScheduleEntity value, ScheduleData data) {
value.setCustomerId(data.customerId()); value.setOceanBookingId(data.oceanBookingId());
value.setContainerBookingId(data.containerBookingId()); value.setSourceType(data.sourceType().trim().toUpperCase());
value.setSourceName(data.sourceName().trim()); value.setSourceRef(data.sourceRef().trim());
value.setVesselName(data.vesselName().trim()); value.setVoyageNo(data.voyageNo().trim());
value.setDeparturePortCode(data.departurePortCode().trim().toUpperCase());
value.setDeparturePortName(trim(data.departurePortName()));
value.setDestinationPortCode(data.destinationPortCode().trim().toUpperCase());
value.setDestinationPortName(trim(data.destinationPortName()));
value.setEtdAt(data.etdAt()); value.setEtaAt(data.etaAt());
value.setBookingCutoffAt(data.bookingCutoffAt()); value.setCustomsCutoffAt(data.customsCutoffAt());
value.setPortCutoffAt(data.portCutoffAt()); value.setObservedAt(data.observedAt());
value.setExpiresAt(data.expiresAt()); value.setRemark(trim(data.remark()));
}
private String hash(String operation, Long id, Object request) {
try {
byte[] bytes = (operation + '|' + id + '|' + objectMapper.writeValueAsString(request))
.getBytes(StandardCharsets.UTF_8);
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
} catch (Exception ex) { throw new IllegalStateException("无法计算船期请求摘要", ex); }
}
private String json(Object value) {
if (value == null) return null;
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException ex) { throw new IllegalStateException("无法保存船期历史快照", ex); }
}
private static void validateKey(String key) {
if (!StringUtils.hasText(key) || key.length() > 128) throw badRequest("Idempotency-Key不能为空且不能超过128个字符");
}
private static String trim(String value) {
if (value == null) return null;
String result = value.trim();
return result.isEmpty() ? null : result;
}
private static BizException badRequest(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
private static BizException concurrent() { return conflict("数据已被其他人修改,请刷新后重试"); }
}
@@ -0,0 +1,225 @@
SET NAMES utf8mb4;
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_customs_checklist_version (
id BIGINT UNSIGNED NOT NULL,
checklist_code VARCHAR(40) NOT NULL,
version_no VARCHAR(32) NOT NULL,
country_code VARCHAR(32) NOT NULL,
channel_code VARCHAR(40) NOT NULL,
cargo_type VARCHAR(40) NOT NULL,
items_json JSON NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_customs_checklist_version (checklist_code, version_no),
KEY idx_customs_checklist_match (country_code, channel_code, cargo_type, status),
CONSTRAINT chk_customs_checklist_status CHECK (status IN ('ACTIVE','RETIRED'))
) ENGINE=InnoDB COMMENT='三期报关资料清单不可变版本';
CREATE TABLE IF NOT EXISTS fl_customs_case (
id BIGINT UNSIGNED NOT NULL,
customs_no VARCHAR(40) NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL,
container_booking_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
checklist_version_id BIGINT UNSIGNED NOT NULL,
checklist_code VARCHAR(40) NOT NULL,
checklist_version_no VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'DOCUMENTS_PENDING',
remark VARCHAR(500) NULL,
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT UNSIGNED NOT NULL,
updated_by_name VARCHAR(64) NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
version INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_customs_no (customs_no),
UNIQUE KEY uk_customs_case_scope (tenant_id, order_id, inbound_id, container_booking_id),
KEY idx_customs_case_status (status, updated_at),
KEY idx_customs_case_container (container_booking_id),
CONSTRAINT fk_customs_case_checklist FOREIGN KEY (checklist_version_id) REFERENCES fl_customs_checklist_version(id),
CONSTRAINT chk_customs_case_status CHECK (status IN ('DOCUMENTS_PENDING','REVIEWING','SUPPLEMENT_REQUIRED','READY_TO_SUBMIT','RECEIPT_PENDING','RECEIPT_AVAILABLE','CLEARANCE_COMPLETED','CANCELLED'))
) ENGINE=InnoDB COMMENT='三期订单报关协同聚合';
CREATE TABLE IF NOT EXISTS fl_customs_document (
id BIGINT UNSIGNED NOT NULL,
customs_case_id BIGINT UNSIGNED NOT NULL,
checklist_item_code VARCHAR(40) NOT NULL,
file_id BIGINT UNSIGNED NOT NULL,
document_version INT UNSIGNED NOT NULL,
replaces_document_id BIGINT UNSIGNED NULL,
file_name VARCHAR(255) NOT NULL,
file_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'UPLOADED',
visibility VARCHAR(16) NOT NULL DEFAULT 'CUSTOMER',
uploaded_by BIGINT UNSIGNED NOT NULL,
uploaded_by_type VARCHAR(16) NOT NULL,
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
reviewed_by BIGINT UNSIGNED NULL,
reviewed_by_name VARCHAR(64) NULL,
reviewed_at DATETIME(3) NULL,
review_reason VARCHAR(500) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_customs_document_version (customs_case_id, checklist_item_code, document_version),
UNIQUE KEY uk_customs_document_file (customs_case_id, file_id),
KEY idx_customs_document_case (customs_case_id, status),
CONSTRAINT fk_customs_document_case FOREIGN KEY (customs_case_id) REFERENCES fl_customs_case(id),
CONSTRAINT chk_customs_document_status CHECK (status IN ('UPLOADED','APPROVED','RETURNED','SUPERSEDED','FROZEN')),
CONSTRAINT chk_customs_document_actor CHECK (uploaded_by_type IN ('PLATFORM','MERCHANT')),
CONSTRAINT chk_customs_document_visibility CHECK (visibility IN ('PLATFORM','CUSTOMER'))
) ENGINE=InnoDB COMMENT='报关客户文件版本引用';
CREATE TABLE IF NOT EXISTS fl_customs_supplement_request (
id BIGINT UNSIGNED NOT NULL,
customs_case_id BIGINT UNSIGNED NOT NULL,
source_document_id BIGINT UNSIGNED NOT NULL,
resolved_document_id BIGINT UNSIGNED NULL,
checklist_item_code VARCHAR(40) NOT NULL,
reason VARCHAR(500) NOT NULL,
due_at DATETIME(3) NULL,
status VARCHAR(16) NOT NULL DEFAULT 'OPEN',
requested_by BIGINT UNSIGNED NOT NULL,
requested_by_name VARCHAR(64) NULL,
requested_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
resolved_by BIGINT UNSIGNED NULL,
resolved_at DATETIME(3) NULL,
PRIMARY KEY (id),
KEY idx_customs_supplement_case (customs_case_id, status, due_at),
CONSTRAINT fk_customs_supplement_case FOREIGN KEY (customs_case_id) REFERENCES fl_customs_case(id),
CONSTRAINT chk_customs_supplement_status CHECK (status IN ('OPEN','RESOLVED','CANCELLED'))
) ENGINE=InnoDB COMMENT='报关补件请求';
CREATE TABLE IF NOT EXISTS fl_customs_submission_batch (
id BIGINT UNSIGNED NOT NULL,
customs_case_id BIGINT UNSIGNED NOT NULL,
batch_no VARCHAR(40) NOT NULL,
batch_version INT UNSIGNED NOT NULL,
customs_agent_name VARCHAR(128) NOT NULL,
external_reference VARCHAR(128) NULL,
file_snapshot_json JSON NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'SUBMITTED',
note VARCHAR(500) NULL,
submitted_by BIGINT UNSIGNED NOT NULL,
submitted_by_name VARCHAR(64) NULL,
submitted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_customs_batch_no (batch_no),
UNIQUE KEY uk_customs_batch_version (customs_case_id, batch_version),
CONSTRAINT fk_customs_batch_case FOREIGN KEY (customs_case_id) REFERENCES fl_customs_case(id),
CONSTRAINT chk_customs_batch_status CHECK (status IN ('SUBMITTED','ACKNOWLEDGED','REJECTED','COMPLETED'))
) ENGINE=InnoDB COMMENT='报关代理提交批次及冻结文件版本';
CREATE TABLE IF NOT EXISTS fl_customs_receipt (
id BIGINT UNSIGNED NOT NULL,
customs_case_id BIGINT UNSIGNED NOT NULL,
submission_batch_id BIGINT UNSIGNED NOT NULL,
receipt_type VARCHAR(16) NOT NULL,
file_id BIGINT UNSIGNED NOT NULL,
receipt_version INT UNSIGNED NOT NULL,
replaces_receipt_id BIGINT UNSIGNED NULL,
file_name VARCHAR(255) NOT NULL,
file_hash CHAR(64) NOT NULL,
visibility VARCHAR(16) NOT NULL DEFAULT 'PLATFORM',
note VARCHAR(500) NULL,
uploaded_by BIGINT UNSIGNED NOT NULL,
uploaded_by_name VARCHAR(64) NULL,
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_customs_receipt_version (submission_batch_id, receipt_type, receipt_version),
UNIQUE KEY uk_customs_receipt_file (submission_batch_id, file_id),
KEY idx_customs_receipt_case (customs_case_id, visibility, uploaded_at),
CONSTRAINT fk_customs_receipt_case FOREIGN KEY (customs_case_id) REFERENCES fl_customs_case(id),
CONSTRAINT fk_customs_receipt_batch FOREIGN KEY (submission_batch_id) REFERENCES fl_customs_submission_batch(id),
CONSTRAINT chk_customs_receipt_type CHECK (receipt_type IN ('RECEIVED','DECLARED','REJECTED','INSPECTION','RELEASED','OTHER')),
CONSTRAINT chk_customs_receipt_visibility CHECK (visibility IN ('PLATFORM','CUSTOMER'))
) ENGINE=InnoDB COMMENT='报关代理回执版本与客户可见范围';
CREATE TABLE IF NOT EXISTS fl_customs_history (
id BIGINT UNSIGNED NOT NULL,
customs_case_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(40) NOT NULL,
from_status VARCHAR(32) NULL,
to_status VARCHAR(32) NULL,
resource_type VARCHAR(32) NOT NULL,
resource_id BIGINT UNSIGNED NOT NULL,
note VARCHAR(500) NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operator_name VARCHAR(64) NULL,
operator_type VARCHAR(16) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_customs_history_case (customs_case_id, created_at, id),
CONSTRAINT fk_customs_history_case FOREIGN KEY (customs_case_id) REFERENCES fl_customs_case(id),
CONSTRAINT chk_customs_history_actor CHECK (operator_type IN ('PLATFORM','MERCHANT'))
) ENGINE=InnoDB COMMENT='报关协同稳定历史';
CREATE TABLE IF NOT EXISTS fl_customs_operation (
id BIGINT UNSIGNED NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING',
result_type VARCHAR(24) NULL,
result_id BIGINT UNSIGNED NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_customs_operation (operator_id, action, idempotency_key),
CONSTRAINT chk_customs_operation_status CHECK (status IN ('PROCESSING','SUCCESS'))
) ENGINE=InnoDB COMMENT='报关协同命令幂等记录';
USE baiyue_iam;
DROP PROCEDURE IF EXISTS assert_phase3_customs_permission_ids;
DELIMITER $$
CREATE PROCEDURE assert_phase3_customs_permission_ids()
BEGIN
IF EXISTS (
SELECT 1 FROM iam_permission
WHERE (id BETWEEN 5102170 AND 5102175
AND permission_code <> CASE id
WHEN 5102170 THEN 'platform:firstleg:customs:list'
WHEN 5102171 THEN 'platform:firstleg:customs:view'
WHEN 5102172 THEN 'platform:firstleg:customs:manage'
WHEN 5102173 THEN 'platform:firstleg:customs:review'
WHEN 5102174 THEN 'platform:firstleg:customs:submit'
WHEN 5102175 THEN 'platform:firstleg:customs:receipt'
END)
OR (permission_code LIKE 'platform:firstleg:customs:%'
AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102170 AND 5102175))
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '三期报关权限迁移失败:5102170-5102175 ID 或权限码被其他语义占用';
END IF;
END$$
DELIMITER ;
CALL assert_phase3_customs_permission_ids();
DROP PROCEDURE IF EXISTS assert_phase3_customs_permission_ids;
INSERT INTO iam_permission
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,
route_path,http_method,sort_no,status,built_in,deleted)
VALUES
(5102170,0,'PLATFORM','MENU',5102000,'platform:firstleg:customs:list','报关协同','/first-leg/customs','GET',100,'ACTIVE',1,0),
(5102171,0,'PLATFORM','BUTTON',5102170,'platform:firstleg:customs:view','查看报关协同',NULL,'GET',10,'ACTIVE',1,0),
(5102172,0,'PLATFORM','BUTTON',5102170,'platform:firstleg:customs:manage','维护报关资料',NULL,'POST',20,'ACTIVE',1,0),
(5102173,0,'PLATFORM','BUTTON',5102170,'platform:firstleg:customs:review','审核退回报关资料',NULL,'POST',30,'ACTIVE',1,0),
(5102174,0,'PLATFORM','BUTTON',5102170,'platform:firstleg:customs:submit','提交报关代理',NULL,'POST',40,'ACTIVE',1,0),
(5102175,0,'PLATFORM','BUTTON',5102170,'platform:firstleg:customs:receipt','维护报关回执',NULL,'POST',50,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type),
permission_code=VALUES(permission_code),permission_name=VALUES(permission_name),parent_id=VALUES(parent_id),
route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),status='ACTIVE',built_in=1,deleted=0;
INSERT IGNORE INTO iam_role_permission(role_id,permission_id,tenant_id)
SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission
ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.permission_code LIKE 'platform:firstleg:customs:%'
WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR');
USE baiyue_firstleg;
@@ -1,13 +1,13 @@
SET NAMES utf8mb4;
USE baiyue_firstleg;
/* 装柜证据和派送完成;不设置证据完整度门禁,允许后续覆盖编辑。 */
/* 装柜证据、PDA 拣货复核和派送完成。业务服务强制五类真实文件证据齐全后才能完成装柜。 */
CREATE TABLE IF NOT EXISTS fl_loading_evidence (
id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL,
evidence_type VARCHAR(32) NOT NULL COMMENT 'PHOTO_STEP_1..4/SEAL',
file_id BIGINT UNSIGNED NULL COMMENT 'File 服务逻辑文件ID',
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File 服务逻辑文件ID',
file_name VARCHAR(255) NULL,
seal_no VARCHAR(64) NULL,
remark VARCHAR(1000) NULL,
@@ -20,6 +20,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_evidence (
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_loading_evidence_type (tenant_id, inbound_id, evidence_type),
UNIQUE KEY uk_loading_evidence_seal (tenant_id, seal_no),
KEY idx_loading_evidence_time (tenant_id, inbound_id, updated_at),
CONSTRAINT fk_loading_evidence_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='订单装柜四步照片和铅封证据';
@@ -0,0 +1,110 @@
-- Phase 3 domestic outbound aggregate and dispatch gate. MySQL 8, idempotent.
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_outbound_dispatch (
id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单引用,不复制订单主数据',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单主对象',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '柜次引用',
status VARCHAR(32) NOT NULL DEFAULT 'READY_FOR_DISPATCH',
version INT UNSIGNED NOT NULL DEFAULT 0,
confirmed_by BIGINT UNSIGNED NULL,
confirmed_by_name VARCHAR(64) NULL,
confirmed_at DATETIME(3) NULL,
reversed_by BIGINT UNSIGNED NULL,
reversed_by_name VARCHAR(64) NULL,
reversed_at DATETIME(3) NULL,
remark VARCHAR(500) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_outbound_dispatch_owner (tenant_id,inbound_id,container_booking_id),
KEY idx_outbound_dispatch_order (tenant_id,order_id,updated_at),
KEY idx_outbound_dispatch_container (container_booking_id,status,updated_at),
CONSTRAINT fk_outbound_dispatch_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order(id),
CONSTRAINT fk_outbound_dispatch_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id),
CONSTRAINT chk_outbound_dispatch_status CHECK (status IN ('READY_FOR_DISPATCH','WAITING_FOR_LOADING'))
) ENGINE=InnoDB COMMENT='三期国内出仓派车状态,不重复创建出仓单';
CREATE TABLE IF NOT EXISTS fl_outbound_dispatch_history (
id BIGINT UNSIGNED NOT NULL,
dispatch_id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL,
container_booking_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(32) NOT NULL,
from_status VARCHAR(32) NULL,
to_status VARCHAR(32) NOT NULL,
remark VARCHAR(500) NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operator_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_outbound_dispatch_history (tenant_id,inbound_id,container_booking_id,created_at,id),
CONSTRAINT fk_outbound_dispatch_history FOREIGN KEY (dispatch_id) REFERENCES fl_outbound_dispatch(id)
) ENGINE=InnoDB COMMENT='国内出仓追加式审计历史';
CREATE TABLE IF NOT EXISTS fl_outbound_dispatch_operation (
id BIGINT UNSIGNED NOT NULL,
dispatch_id BIGINT UNSIGNED NULL COMMENT '首次声明幂等键时允许为空',
operator_id BIGINT UNSIGNED NOT NULL,
operation VARCHAR(32) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_outbound_dispatch_operation (operator_id,operation,idempotency_key),
KEY idx_outbound_dispatch_operation_resource (dispatch_id,created_at),
CONSTRAINT fk_outbound_dispatch_operation FOREIGN KEY (dispatch_id) REFERENCES fl_outbound_dispatch(id),
CONSTRAINT chk_outbound_dispatch_operation_status CHECK (status IN ('PROCESSING','SUCCESS'))
) ENGINE=InnoDB COMMENT='国内出仓写操作幂等声明';
USE baiyue_iam;
DROP PROCEDURE IF EXISTS assert_outbound_dispatch_permission_ids_available;
DELIMITER $$
CREATE PROCEDURE assert_outbound_dispatch_permission_ids_available()
BEGIN
IF EXISTS (
SELECT 1 FROM iam_permission
WHERE (id BETWEEN 5102160 AND 5102164
AND permission_code <> CASE id
WHEN 5102160 THEN 'platform:firstleg:outbound-dispatch:list'
WHEN 5102161 THEN 'platform:firstleg:outbound-dispatch:view'
WHEN 5102162 THEN 'platform:firstleg:outbound-dispatch:check'
WHEN 5102163 THEN 'platform:firstleg:outbound-dispatch:confirm'
WHEN 5102164 THEN 'platform:firstleg:outbound-dispatch:reverse'
END)
OR (permission_code LIKE 'platform:firstleg:outbound-dispatch:%'
AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102160 AND 5102164))
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '三期出仓权限迁移失败:5102160-5102164 ID 或权限码被其他语义占用';
END IF;
END$$
DELIMITER ;
CALL assert_outbound_dispatch_permission_ids_available();
DROP PROCEDURE IF EXISTS assert_outbound_dispatch_permission_ids_available;
INSERT INTO iam_permission
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,
route_path,http_method,sort_no,status,built_in,deleted)
VALUES
(5102160,0,'PLATFORM','MENU',5102000,'platform:firstleg:outbound-dispatch:list','国内出仓','/first-leg/outbound-dispatches','GET',95,'ACTIVE',1,0),
(5102161,0,'PLATFORM','BUTTON',5102160,'platform:firstleg:outbound-dispatch:view','查看出仓聚合',NULL,'GET',10,'ACTIVE',1,0),
(5102162,0,'PLATFORM','BUTTON',5102160,'platform:firstleg:outbound-dispatch:check','检查出仓门禁',NULL,'GET',20,'ACTIVE',1,0),
(5102163,0,'PLATFORM','BUTTON',5102160,'platform:firstleg:outbound-dispatch:confirm','确认国内出仓',NULL,'POST',30,'ACTIVE',1,0),
(5102164,0,'PLATFORM','BUTTON',5102160,'platform:firstleg:outbound-dispatch:reverse','撤销国内出仓',NULL,'POST',40,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type),
permission_code=VALUES(permission_code),permission_name=VALUES(permission_name),parent_id=VALUES(parent_id),
route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),status='ACTIVE',built_in=1,deleted=0;
INSERT IGNORE INTO iam_role_permission(role_id,permission_id,tenant_id)
SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission
ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.permission_code LIKE 'platform:firstleg:outbound-dispatch:%'
WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR');
USE baiyue_firstleg;
@@ -0,0 +1,168 @@
SET NAMES utf8mb4;
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_port_schedule (
id BIGINT UNSIGNED NOT NULL,
schedule_no VARCHAR(40) NOT NULL,
customer_id BIGINT UNSIGNED NOT NULL COMMENT '当前产品客户ID',
ocean_booking_id BIGINT UNSIGNED NOT NULL,
container_booking_id BIGINT UNSIGNED NULL,
source_type VARCHAR(24) NOT NULL COMMENT 'MANUAL/CARRIER/TERMINAL/API',
source_name VARCHAR(128) NOT NULL,
source_ref VARCHAR(255) NOT NULL,
vessel_name VARCHAR(128) NOT NULL,
voyage_no VARCHAR(64) NOT NULL,
departure_port_code VARCHAR(32) NOT NULL,
departure_port_name VARCHAR(128) NULL,
destination_port_code VARCHAR(32) NOT NULL,
destination_port_name VARCHAR(128) NULL,
etd_at DATETIME(3) NULL,
eta_at DATETIME(3) NULL,
booking_cutoff_at DATETIME(3) NULL COMMENT '截单时间',
customs_cutoff_at DATETIME(3) NULL COMMENT '截关时间',
port_cutoff_at DATETIME(3) NULL COMMENT '截港时间',
observed_at DATETIME(3) NOT NULL COMMENT '来源数据观测时间',
expires_at DATETIME(3) NOT NULL COMMENT '超过该时间即由API计算为过期',
remark VARCHAR(1000) NULL,
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT UNSIGNED NOT NULL,
updated_by_name VARCHAR(64) NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
version INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_port_schedule_no (schedule_no),
UNIQUE KEY uk_port_schedule_source (source_type,source_name,source_ref),
KEY idx_port_schedule_customer (customer_id,observed_at,id),
KEY idx_port_schedule_booking (ocean_booking_id,observed_at,id),
KEY idx_port_schedule_container (container_booking_id,observed_at,id),
KEY idx_port_schedule_expiry (expires_at,id),
CONSTRAINT fk_port_schedule_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
CONSTRAINT fk_port_schedule_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id),
CONSTRAINT chk_port_schedule_customer CHECK (customer_id > 0),
CONSTRAINT chk_port_schedule_source_type CHECK (source_type IN ('MANUAL','CARRIER','TERMINAL','API')),
CONSTRAINT chk_port_schedule_eta CHECK (etd_at IS NULL OR eta_at IS NULL OR eta_at >= etd_at),
CONSTRAINT chk_port_schedule_expiry CHECK (expires_at > observed_at),
CONSTRAINT chk_port_schedule_booking_cutoff CHECK
(booking_cutoff_at IS NULL OR etd_at IS NULL OR booking_cutoff_at <= etd_at),
CONSTRAINT chk_port_schedule_customs_cutoff CHECK
(customs_cutoff_at IS NULL OR etd_at IS NULL OR customs_cutoff_at <= etd_at),
CONSTRAINT chk_port_schedule_port_cutoff CHECK
(port_cutoff_at IS NULL OR etd_at IS NULL OR port_cutoff_at <= etd_at)
) ENGINE=InnoDB COMMENT='独立港务船期来源聚合,不替代订舱快照';
CREATE TABLE IF NOT EXISTS fl_port_schedule_history (
id BIGINT UNSIGNED NOT NULL,
port_schedule_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(24) NOT NULL,
from_version INT UNSIGNED NULL,
to_version INT UNSIGNED NOT NULL,
reason VARCHAR(1000) NULL,
before_snapshot JSON NULL,
after_snapshot JSON NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operator_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_port_schedule_history (port_schedule_id,created_at,id),
CONSTRAINT fk_port_schedule_history FOREIGN KEY (port_schedule_id) REFERENCES fl_port_schedule(id),
CONSTRAINT chk_port_schedule_history_action CHECK (action IN ('CREATE','UPDATE'))
) ENGINE=InnoDB COMMENT='港务船期不可变变更历史';
CREATE TABLE IF NOT EXISTS fl_port_schedule_operation (
id BIGINT UNSIGNED NOT NULL,
port_schedule_id BIGINT UNSIGNED NULL COMMENT '创建请求完成前允许为空',
operator_id BIGINT UNSIGNED NOT NULL,
operation VARCHAR(24) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING',
result_json JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_port_schedule_operation (operator_id,idempotency_key),
KEY idx_port_schedule_operation_resource (port_schedule_id,created_at),
CONSTRAINT fk_port_schedule_operation FOREIGN KEY (port_schedule_id) REFERENCES fl_port_schedule(id),
CONSTRAINT chk_port_schedule_operation_status CHECK (status IN ('PROCESSING','SUCCESS'))
) ENGINE=InnoDB COMMENT='港务船期写操作幂等与请求哈希';
DROP TRIGGER IF EXISTS trg_port_schedule_before_insert;
DROP TRIGGER IF EXISTS trg_port_schedule_before_update;
DELIMITER $$
CREATE TRIGGER trg_port_schedule_before_insert BEFORE INSERT ON fl_port_schedule FOR EACH ROW
BEGIN
IF (SELECT customer_id FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id) <> NEW.customer_id THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船期客户与订舱客户不一致';
END IF;
IF NEW.container_booking_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM fl_ocean_booking_container_allocation
WHERE ocean_booking_id=NEW.ocean_booking_id AND container_booking_id=NEW.container_booking_id
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船期柜次未分配至订舱';
END IF;
END$$
CREATE TRIGGER trg_port_schedule_before_update BEFORE UPDATE ON fl_port_schedule FOR EACH ROW
BEGIN
IF NEW.customer_id <> OLD.customer_id OR NEW.ocean_booking_id <> OLD.ocean_booking_id
OR NOT (NEW.container_booking_id <=> OLD.container_booking_id) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船期客户、订舱和柜次归属不可变更';
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_port_schedule_history_before_update;
DROP TRIGGER IF EXISTS trg_port_schedule_history_before_delete;
DELIMITER $$
CREATE TRIGGER trg_port_schedule_history_before_update BEFORE UPDATE ON fl_port_schedule_history FOR EACH ROW
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='港务船期历史不可修改'; END$$
CREATE TRIGGER trg_port_schedule_history_before_delete BEFORE DELETE ON fl_port_schedule_history FOR EACH ROW
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='港务船期历史不可删除'; END$$
DELIMITER ;
USE baiyue_iam;
DROP PROCEDURE IF EXISTS assert_port_schedule_permission_ids_available;
DELIMITER $$
CREATE PROCEDURE assert_port_schedule_permission_ids_available()
BEGIN
IF EXISTS (
SELECT 1 FROM iam_permission
WHERE (id BETWEEN 5102180 AND 5102183
AND permission_code <> CASE id
WHEN 5102180 THEN 'platform:firstleg:port-schedule:list'
WHEN 5102181 THEN 'platform:firstleg:port-schedule:view'
WHEN 5102182 THEN 'platform:firstleg:port-schedule:create'
WHEN 5102183 THEN 'platform:firstleg:port-schedule:edit'
END)
OR (permission_code LIKE 'platform:firstleg:port-schedule:%'
AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102180 AND 5102183))
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '三期港务船期权限迁移失败:5102180-5102183 ID 或权限码被其他语义占用';
END IF;
END$$
DELIMITER ;
CALL assert_port_schedule_permission_ids_available();
DROP PROCEDURE IF EXISTS assert_port_schedule_permission_ids_available;
INSERT INTO iam_permission
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,
route_path,http_method,sort_no,status,built_in,deleted)
VALUES
(5102180,0,'PLATFORM','MENU',5102000,'platform:firstleg:port-schedule:list','港务船期','/first-leg/port-schedules','GET',120,'ACTIVE',1,0),
(5102181,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule:view','查看港务船期',NULL,'GET',10,'ACTIVE',1,0),
(5102182,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule:create','新增港务船期',NULL,'POST',20,'ACTIVE',1,0),
(5102183,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule:edit','更新港务船期',NULL,'PUT',30,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type),
permission_code=VALUES(permission_code),permission_name=VALUES(permission_name),parent_id=VALUES(parent_id),
route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),
status='ACTIVE',built_in=1,deleted=0;
INSERT IGNORE INTO iam_role_permission(role_id,permission_id,tenant_id)
SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission
ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.permission_code LIKE 'platform:firstleg:port-schedule:%'
WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR');
USE baiyue_firstleg;
@@ -0,0 +1,93 @@
openapi: 3.0.3
info:
title: Phase 3 Customs Collaboration API
version: 1.0.0
description: Immutable checklist and file versions, supplement requests, frozen agent batches, receipts and history.
paths:
/api/platform/v1/customs/checklist-versions:
post:
summary: Create an immutable customs document checklist version
parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
responses: {'200': {description: Checklist version}}
/api/platform/v1/customs/cases:
get:
summary: Page customs cases
responses: {'200': {description: Page result}}
post:
summary: Create a customer/order/inbound/container scoped customs case
parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
responses: {'200': {description: Customs case detail}}
/api/platform/v1/customs/cases/{caseId}:
parameters: [{$ref: '#/components/parameters/CaseId'}]
get:
summary: Query complete customs detail and stable history
responses: {'200': {description: Detail}}
/api/platform/v1/customs/cases/{caseId}/documents:
parameters: [{$ref: '#/components/parameters/CaseId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
post:
summary: Reference a file validated against customer, CUSTOMS_DOCUMENT and case ID
responses: {'200': {description: New document version}}
/api/platform/v1/customs/cases/{caseId}/documents/upload:
parameters: [{$ref: '#/components/parameters/CaseId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
post:
summary: Upload a document and create a new immutable version
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [itemCode, expectedVersion, file]
properties:
itemCode: {type: string}
expectedVersion: {type: integer, minimum: 0}
file: {type: string, format: binary}
responses: {'200': {description: New document version}}
/api/platform/v1/customs/cases/{caseId}/documents/{documentId}/review:
parameters:
- {$ref: '#/components/parameters/CaseId'}
- {name: documentId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {$ref: '#/components/parameters/IdempotencyKey'}
post:
summary: Approve a current file version or return it with a supplement task
responses: {'200': {description: Updated detail}}
/api/platform/v1/customs/cases/{caseId}/submission-batches:
parameters: [{$ref: '#/components/parameters/CaseId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
post:
summary: Submit to customs agent and freeze approved file versions in a batch snapshot
responses: {'200': {description: Submission batch}}
/api/platform/v1/customs/submission-batches/{batchId}/receipts:
parameters:
- {name: batchId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {$ref: '#/components/parameters/IdempotencyKey'}
post:
summary: Reference a customer-owned receipt file with explicit visibility
responses: {'200': {description: Receipt version}}
/api/platform/v1/customs/submission-batches/{batchId}/receipts/upload:
post:
summary: Upload a receipt version; RELEASED closes the customs dispatch gate
responses: {'200': {description: Receipt version}}
/api/merchant/v1/customs/orders/{orderId}:
get:
summary: Query the authenticated customer's customs case by order
responses: {'200': {description: Customer-filtered detail}}
/api/merchant/v1/customs/cases/{caseId}/documents/upload:
post:
summary: Upload or replace a checklist document within authenticated customer scope
responses: {'200': {description: New document version}}
/api/merchant/v1/customs/cases/{caseId}/receipts/{receiptId}/download:
get:
summary: Download only CUSTOMER-visible receipts from the authenticated customer's case
responses: {'200': {description: Authorized file stream}}
components:
parameters:
IdempotencyKey:
name: Idempotency-Key
in: header
required: true
schema: {type: string, minLength: 1, maxLength: 128}
CaseId:
name: caseId
in: path
required: true
schema: {type: string, pattern: '^[1-9][0-9]*$'}
@@ -0,0 +1,86 @@
openapi: 3.0.3
info:
title: Phase 3 Loading Evidence and Picking API
version: 1.0.0
paths:
/api/platform/v1/inbounds/{inboundId}/loading/evidence:
get:
summary: List loading evidence bound to one customer inbound
parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}]
responses:
'200': {description: Business envelope containing the stable evidence array}
post:
summary: Bind one real File service object to a loading evidence slot
parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody:
required: true
content:
application/json: {schema: {$ref: '#/components/schemas/UpsertEvidenceRequest'}}
responses:
'200': {description: Business envelope; ownership, type and version failures use business error codes}
/api/platform/v1/inbounds/{inboundId}/loading/picking/start:
post:
summary: Start the inbound-level PDA picking review task
parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody:
required: true
content:
application/json: {schema: {$ref: '#/components/schemas/PickingRequest'}}
responses:
'200': {description: Business envelope containing IN_PROGRESS task}
/api/platform/v1/inbounds/{inboundId}/loading/picking/complete:
post:
summary: Complete a started PDA picking task when planned and picked counts match
parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody:
required: true
content:
application/json: {schema: {$ref: '#/components/schemas/PickingRequest'}}
responses:
'200': {description: Business envelope containing COMPLETED task}
/api/platform/v1/inbounds/{inboundId}/loading/picking:
get:
summary: Read the inbound-level PDA picking task
parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}]
responses:
'200': {description: Business envelope containing task or null}
/api/platform/v1/inbounds/{inboundId}/loading/complete:
post:
summary: Complete loading only after picking and PHOTO_STEP_1..4 plus SEAL evidence are complete
parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody:
required: true
content:
application/json: {schema: {$ref: '#/components/schemas/CompleteLoadingRequest'}}
responses:
'200': {description: Business envelope containing LOADED status}
components:
parameters:
InboundId: {name: inboundId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
TenantId: {name: tenantId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
schemas:
UpsertEvidenceRequest:
type: object
required: [expectedVersion, evidenceType, fileId]
properties:
expectedVersion: {type: integer, minimum: 0}
evidenceType: {type: string, enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL]}
fileId: {type: integer, format: int64, minimum: 1}
fileName: {type: string, maxLength: 255, deprecated: true, description: Ignored; server uses File metadata}
sealNo: {type: string, maxLength: 64, description: Required when evidenceType is SEAL}
remark: {type: string, maxLength: 1000}
PickingRequest:
type: object
required: [expectedVersion, expectedCount, pickedCount]
properties:
expectedVersion: {type: integer, minimum: 0}
expectedCount: {type: integer, minimum: 1}
pickedCount: {type: integer, minimum: 0}
remark: {type: string, maxLength: 1000}
CompleteLoadingRequest:
type: object
required: [expectedVersion]
properties:
expectedVersion: {type: integer, minimum: 0}
remark: {type: string, maxLength: 1000}
@@ -0,0 +1,69 @@
openapi: 3.0.3
info:
title: Phase 3 Domestic Outbound Dispatch API
version: 1.0.0
paths:
/api/platform/v1/first-leg/outbound-dispatches:
get:
summary: Query outbound aggregates by order, inbound or container
parameters:
- {$ref: '#/components/parameters/OrderId'}
- {$ref: '#/components/parameters/InboundId'}
- {$ref: '#/components/parameters/ContainerBookingId'}
responses:
'200': {description: Business envelope containing matching aggregates}
/api/platform/v1/first-leg/outbound-dispatches/detail:
get:
summary: Query one aggregate and its unified timeline
parameters:
- {$ref: '#/components/parameters/OrderId'}
- {$ref: '#/components/parameters/InboundId'}
- {$ref: '#/components/parameters/ContainerBookingId'}
responses:
'200': {description: Business envelope containing aggregate detail}
/api/platform/v1/first-leg/outbound-dispatches/check-gate:
get:
summary: Evaluate the fail-closed domestic outbound gate
parameters:
- {$ref: '#/components/parameters/OrderId'}
- {$ref: '#/components/parameters/InboundId'}
- {$ref: '#/components/parameters/ContainerBookingId'}
responses:
'200': {description: Business envelope containing every gate item}
/api/platform/v1/first-leg/outbound-dispatches/confirm-dispatch:
post:
summary: Confirm domestic dispatch and enter WAITING_FOR_LOADING
parameters:
- {$ref: '#/components/parameters/IdempotencyKey'}
requestBody:
required: true
content:
application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}
responses:
'200': {description: Business envelope; gate or version failures use business error code}
/api/platform/v1/first-leg/outbound-dispatches/reverse-dispatch:
post:
summary: Reverse WAITING_FOR_LOADING to LOADED
parameters:
- {$ref: '#/components/parameters/IdempotencyKey'}
requestBody:
required: true
content:
application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}
responses:
'200': {description: Business envelope containing reverted aggregate}
components:
parameters:
OrderId: {name: orderId, in: query, schema: {type: integer, format: int64, minimum: 1}}
InboundId: {name: inboundId, in: query, schema: {type: integer, format: int64, minimum: 1}}
ContainerBookingId: {name: containerBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}}
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
schemas:
ActionRequest:
type: object
required: [inboundId, containerBookingId, expectedVersion]
properties:
inboundId: {type: integer, format: int64, minimum: 1}
containerBookingId: {type: integer, format: int64, minimum: 1}
expectedVersion: {type: integer, minimum: 0}
remark: {type: string, maxLength: 500}
@@ -0,0 +1,114 @@
openapi: 3.0.3
info:
title: Platform Port Schedule API
version: 1.0.0
description: Independent port and vessel schedule observations linked to ocean bookings and allocated containers.
paths:
/api/platform/v1/port-schedules:
get:
summary: Query schedules by customer, ocean booking, allocated container or expiry
parameters:
- {name: customerId, in: query, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: oceanBookingId, in: query, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: containerBookingId, in: query, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: expired, in: query, schema: {type: boolean}}
- {name: keyword, in: query, schema: {type: string, maxLength: 128}}
- {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}}
- {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}
responses:
'200':
description: HTTP 200 business envelope containing a page of schedule views
post:
summary: Create a source observation linked to a real ocean booking
parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/CreateRequest'}
responses:
'200': {description: Created detail in the standard business envelope}
/api/platform/v1/port-schedules/{portScheduleId}:
parameters: [{$ref: '#/components/parameters/PortScheduleId'}]
get:
summary: Get schedule and immutable history
responses:
'200': {description: Schedule detail with computed expired flag}
put:
summary: Update source observation with optimistic locking
parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/UpdateRequest'}
responses:
'200': {description: Updated detail or A0409 conflict in the business envelope}
/api/platform/v1/port-schedules/{portScheduleId}/history:
parameters: [{$ref: '#/components/parameters/PortScheduleId'}]
get:
summary: Query immutable source observation history
responses:
'200': {description: Ordered CREATE and UPDATE history records}
components:
parameters:
PortScheduleId:
name: portScheduleId
in: path
required: true
schema: {type: string, pattern: '^[1-9][0-9]*$'}
IdempotencyKey:
name: Idempotency-Key
in: header
required: true
schema: {type: string, minLength: 1, maxLength: 128}
schemas:
ScheduleData:
type: object
required:
- customerId
- oceanBookingId
- sourceType
- sourceName
- sourceRef
- vesselName
- voyageNo
- departurePortCode
- destinationPortCode
- observedAt
- expiresAt
properties:
customerId: {type: string, pattern: '^[1-9][0-9]*$'}
oceanBookingId: {type: string, pattern: '^[1-9][0-9]*$'}
containerBookingId: {type: string, nullable: true, pattern: '^[1-9][0-9]*$'}
sourceType: {type: string, enum: [MANUAL, CARRIER, TERMINAL, API]}
sourceName: {type: string, maxLength: 128}
sourceRef: {type: string, maxLength: 255}
vesselName: {type: string, maxLength: 128}
voyageNo: {type: string, maxLength: 64}
departurePortCode: {type: string, maxLength: 32}
departurePortName: {type: string, nullable: true, maxLength: 128}
destinationPortCode: {type: string, maxLength: 32}
destinationPortName: {type: string, nullable: true, maxLength: 128}
etdAt: {type: string, format: date-time, nullable: true}
etaAt: {type: string, format: date-time, nullable: true}
bookingCutoffAt: {type: string, format: date-time, nullable: true, description: Booking cutoff}
customsCutoffAt: {type: string, format: date-time, nullable: true, description: Customs cutoff}
portCutoffAt: {type: string, format: date-time, nullable: true, description: Port gate cutoff}
observedAt: {type: string, format: date-time}
expiresAt: {type: string, format: date-time}
remark: {type: string, nullable: true, maxLength: 1000}
CreateRequest:
type: object
required: [expectedVersion, data]
properties:
expectedVersion: {type: integer, minimum: 0, maximum: 0}
data: {$ref: '#/components/schemas/ScheduleData'}
reason: {type: string, nullable: true, maxLength: 1000}
UpdateRequest:
type: object
required: [expectedVersion, data]
properties:
expectedVersion: {type: integer, minimum: 0}
data: {$ref: '#/components/schemas/ScheduleData'}
reason: {type: string, nullable: true, maxLength: 1000}
@@ -0,0 +1,22 @@
package com.baiyue.firstleg.customs.repository;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class CustomsSqlContractTest {
@Test
void moduleSqlContainsVersioningIsolationPermissionsAndFrozenBatchContracts() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/phase3-customs-schema.sql"));
assertThat(sql).contains("fl_customs_checklist_version", "items_json JSON NOT NULL")
.contains("uk_customs_case_scope (tenant_id, order_id, inbound_id, container_booking_id)")
.contains("uk_customs_document_version", "file_snapshot_json JSON NOT NULL")
.contains("uk_customs_receipt_version", "visibility VARCHAR(16)")
.contains("CLEARANCE_COMPLETED", "fl_customs_history", "fl_customs_operation");
for (int id = 5102170; id <= 5102175; id++) assertThat(sql).contains(Integer.toString(id));
assertThat(sql).contains("PLATFORM_ADMIN", "ORDER_OPERATOR", "platform:firstleg:customs:receipt");
}
}
@@ -0,0 +1,34 @@
package com.baiyue.firstleg.customs.service;
import com.baiyue.firstleg.customs.entity.CustomsCaseEntity;
import com.baiyue.firstleg.customs.repository.CustomsRepository;
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor.Context;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
class CustomsDispatchGateContributorTest {
@Mock CustomsRepository repository;
@InjectMocks CustomsDispatchGateContributor contributor;
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this); }
@Test void failsClosedWhenCaseMissingOrScopeMismatches() {
assertThat(contributor.assess(context()).passed()).isFalse();
assertThat(contributor.assess(new Context(10001L, 10002L, 21L, 31L, 81L, 91L, null)).actual())
.isEqualTo("INVALID_CUSTOMER_SCOPE");
}
@Test void passesOnlyReleasedTerminalState() {
CustomsCaseEntity value = new CustomsCaseEntity(); value.setStatus("RECEIPT_AVAILABLE");
when(repository.gateCase(10001L, 21L, 31L, 81L)).thenReturn(value);
assertThat(contributor.assess(context()).passed()).isFalse();
value.setStatus("CLEARANCE_COMPLETED");
assertThat(contributor.assess(context()).passed()).isTrue();
}
private static Context context() { return new Context(10001L, 10001L, 21L, 31L, 81L, 91L, null); }
}
@@ -0,0 +1,162 @@
package com.baiyue.firstleg.customs.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.customs.client.CustomsFileClient;
import com.baiyue.firstleg.customs.dto.CustomsDtos.*;
import com.baiyue.firstleg.customs.entity.*;
import com.baiyue.firstleg.customs.repository.CustomsRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.*;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class CustomsServiceTest {
@Mock CustomsRepository repository;
@Mock CustomsFileClient files;
@InjectMocks CustomsService service;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
ReflectionTestUtils.setField(service, "internalToken", "token");
when(repository.completeOperation(anyLong(), anyString(), anyLong())).thenReturn(1);
}
@Test
void createCaseRejectsMismatchedInboundScope() {
CustomsChecklistVersionEntity checklist = checklist();
when(repository.checklist(11L)).thenReturn(checklist);
when(repository.inboundBelongsTo(10001L, 21L, 31L)).thenReturn(false);
assertThatThrownBy(() -> service.createCase(7L, "admin", "case-1",
new CreateCaseRequest(10001L, 21L, 31L, null, 11L, null, 0)))
.isInstanceOf(BizException.class).hasMessageContaining("归属不一致");
verify(repository, never()).insert(any(CustomsCaseEntity.class));
}
@Test
void returnedDocumentCreatesSupplementAndMovesCaseToSupplementRequired() {
CustomsCaseEntity customsCase = customsCase("REVIEWING", 2);
CustomsDocumentEntity document = document("UPLOADED");
when(repository.customsCase(41L)).thenReturn(customsCase);
when(repository.document(51L)).thenReturn(document);
when(repository.latestDocument(41L, "INVOICE")).thenReturn(document);
when(repository.supplements(41L)).thenReturn(List.of(openSupplement()));
when(repository.transition(41L, 2, "SUPPLEMENT_REQUIRED", 7L, "admin")).thenReturn(1);
// Returning the detail reload is intentionally stopped at the repository boundary.
when(repository.checklist(11L)).thenReturn(checklist());
when(repository.documents(41L)).thenReturn(List.of(document));
when(repository.batches(41L)).thenReturn(List.of());
when(repository.receipts(41L, false)).thenReturn(List.of());
when(repository.history(41L)).thenReturn(List.of());
service.review(41L, 51L, 7L, "admin", "review-1",
new ReviewRequest("RETURN", "发票金额不一致", null, 2));
verify(repository).insert(any(CustomsSupplementRequestEntity.class));
verify(repository).markDocument(51L, "RETURNED", 7L, "admin", "发票金额不一致");
verify(repository).transition(41L, 2, "SUPPLEMENT_REQUIRED", 7L, "admin");
}
@Test
void submitBatchFreezesApprovedVersionSnapshot() {
CustomsCaseEntity customsCase = customsCase("READY_TO_SUBMIT", 4);
CustomsDocumentEntity document = document("APPROVED");
when(repository.customsCase(41L)).thenReturn(customsCase);
when(repository.checklist(11L)).thenReturn(checklist());
when(repository.documents(41L)).thenReturn(List.of(document));
when(repository.batches(41L)).thenReturn(List.of());
when(repository.freeze(51L)).thenReturn(1);
when(repository.transition(41L, 4, "RECEIPT_PENDING", 7L, "admin")).thenReturn(1);
CustomsSubmissionBatchEntity result = service.submitBatch(41L, 7L, "admin", "batch-1",
new SubmitBatchRequest("线下报关代理", "EXT-1", "提交", 4));
assertThat(result.getFileSnapshotJson()).contains("documentVersion").contains("INVOICE");
verify(repository).freeze(51L);
verify(repository).insert(any(CustomsSubmissionBatchEntity.class));
}
@Test
void releasedReceiptCompletesClearanceGateState() {
CustomsCaseEntity customsCase = customsCase("RECEIPT_PENDING", 5);
CustomsSubmissionBatchEntity batch = new CustomsSubmissionBatchEntity();
batch.setId(61L); batch.setCustomsCaseId(41L);
when(repository.batch(61L)).thenReturn(batch);
when(repository.customsCase(41L)).thenReturn(customsCase);
when(repository.latestReceipt(61L, "RELEASED")).thenReturn(null);
when(repository.transition(41L, 5, "CLEARANCE_COMPLETED", 7L, "admin")).thenReturn(1);
var metadata = new CustomsFileClient.Metadata(71L, 10001L, "CUSTOMS_RECEIPT", "61",
"release.pdf", "a".repeat(64), "application/pdf", 8L);
when(files.upload(eq("token"), eq(7L), eq(10001L), eq("CUSTOMS_RECEIPT"), eq("61"), any()))
.thenReturn(ApiResponse.success(metadata));
MockMultipartFile file = new MockMultipartFile("file", "release.pdf", "application/pdf", "%PDF-1.4".getBytes());
CustomsReceiptEntity result = service.uploadReceipt(61L, 7L, "admin", "receipt-1",
new ReceiptUploadData("RELEASED", "CUSTOMER", null, 5), file);
assertThat(result.getReceiptType()).isEqualTo("RELEASED");
verify(repository).transition(41L, 5, "CLEARANCE_COMPLETED", 7L, "admin");
}
@Test
void uploadReplayReturnsOriginalDocumentBeforeCheckingStaleAggregateVersion() {
CustomsCaseEntity customsCase = customsCase("CLEARANCE_COMPLETED", 7);
CustomsDocumentEntity document = document("FROZEN");
when(repository.customsCase(10001L, 41L)).thenReturn(customsCase);
when(repository.document(51L)).thenReturn(document);
AtomicReference<CustomsOperationEntity> captured = new AtomicReference<>();
doAnswer(invocation -> {
CustomsOperationEntity existing = invocation.getArgument(0);
existing.setStatus("SUCCESS"); existing.setResultType("DOCUMENT"); existing.setResultId(51L);
captured.set(existing);
throw new DuplicateKeyException("duplicate replay");
}).when(repository).insert(any(CustomsOperationEntity.class));
when(repository.operation(7L, "MERCHANT_UPLOAD_DOCUMENT", "upload-replay")).thenAnswer(invocation -> captured.get());
MockMultipartFile file = new MockMultipartFile("file", "invoice.txt", "text/plain", "invoice".getBytes());
CustomsDocumentEntity result = service.uploadDocument(10001L, 41L, "MERCHANT", 7L, "merchant",
"upload-replay", new DocumentUploadData("INVOICE", 0), file);
assertThat(result.getId()).isEqualTo(51L);
verifyNoInteractions(files);
verify(repository, never()).transition(anyLong(), anyInt(), anyString(), anyLong(), anyString());
}
private static CustomsChecklistVersionEntity checklist() {
CustomsChecklistVersionEntity value = new CustomsChecklistVersionEntity();
value.setId(11L); value.setChecklistCode("US_FBA"); value.setVersionNo("V1"); value.setStatus("ACTIVE");
value.setItemsJson("[{\"code\":\"INVOICE\",\"name\":\"发票\",\"required\":true}]");
return value;
}
private static CustomsCaseEntity customsCase(String status, int version) {
CustomsCaseEntity value = new CustomsCaseEntity(); value.setId(41L); value.setTenantId(10001L);
value.setOrderId(21L); value.setInboundId(31L); value.setContainerBookingId(81L);
value.setChecklistVersionId(11L); value.setStatus(status); value.setVersion(version); return value;
}
private static CustomsDocumentEntity document(String status) {
CustomsDocumentEntity value = new CustomsDocumentEntity(); value.setId(51L); value.setCustomsCaseId(41L);
value.setChecklistItemCode("INVOICE"); value.setFileId(71L); value.setDocumentVersion(1);
value.setFileHash("a".repeat(64)); value.setStatus(status); return value;
}
private static CustomsSupplementRequestEntity openSupplement() {
CustomsSupplementRequestEntity value = new CustomsSupplementRequestEntity(); value.setStatus("OPEN"); return value;
}
}
@@ -0,0 +1,26 @@
package com.baiyue.firstleg.loading.controller;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class LoadingDeliveryOpenApiContractTest {
@Test
void publishesRealFileFiveEvidenceAndPickingGate() throws Exception {
String yaml = new ClassPathResource("openapi/phase3-loading-delivery-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(yaml)
.contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence:")
.contains("/api/platform/v1/inbounds/{inboundId}/loading/picking/start:")
.contains("/api/platform/v1/inbounds/{inboundId}/loading/picking/complete:")
.contains("/api/platform/v1/inbounds/{inboundId}/loading/complete:")
.contains("required: [expectedVersion, evidenceType, fileId]")
.contains("enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL]")
.contains("Required when evidenceType is SEAL")
.contains("Complete a started PDA picking task");
}
}
@@ -0,0 +1,22 @@
package com.baiyue.firstleg.loading.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class LoadingEvidenceSqlContractTest {
@Test
void newSchemaRequiresRealFilesAndUniqueSealWithinCustomer() throws Exception {
String sql = new ClassPathResource("db/phase3-loading-delivery-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.contains("file_id BIGINT UNSIGNED NOT NULL")
.contains("UNIQUE KEY uk_loading_evidence_type (tenant_id, inbound_id, evidence_type)")
.contains("UNIQUE KEY uk_loading_evidence_seal (tenant_id, seal_no)")
.contains("fl_inbound_loading_pick_task");
}
}
@@ -1,11 +1,14 @@
package com.baiyue.firstleg.loading.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.client.LoadingEvidenceFileClient;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.PickingRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.UpsertEvidenceRequest;
import com.baiyue.firstleg.loading.dto.LoadingResponses.LoadingActionResponse;
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
import com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.Snapshot;
import com.baiyue.firstleg.loading.repository.LoadingRepository;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
@@ -20,6 +23,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@@ -41,26 +45,85 @@ class LoadingDeliveryServiceTest {
}
@Test
void completesLoadingWithoutEvidenceGate() {
void completesLoadingOnlyAfterPickingAndFiveEvidenceFiles() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
InboundLoadingPickTaskEntity pick = new InboundLoadingPickTaskEntity();
pick.setStatus("COMPLETED"); pick.setExpectedCount(5); pick.setPickedCount(5);
when(repository.findPickTask(10, 20)).thenReturn(pick);
when(repository.listEvidence(10, 20)).thenReturn(List.of(
evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null),
evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null),
evidence("SEAL", 5L, "LOCK-001")));
when(files.validate(eq("token"), eq(10L), eq(20L), anyLong())).thenAnswer(invocation -> {
long fileId = invocation.getArgument(3);
String type = fileId == 5L ? "SEAL" : "PHOTO_STEP_" + fileId;
return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata(
fileId, type.toLowerCase() + ".png", "LOADING_" + type, "image/png"));
});
when(repository.completeLoading(10, 20, 7, 3)).thenReturn(1);
when(repository.listEvidence(10, 20)).thenReturn(List.of());
LoadingActionResponse result = service.completeLoading(10, 20, 7, "admin", "k1", new CompleteLoadingRequest(3, null));
assertThat(result.mainStatus()).isEqualTo("LOADED");
verify(histories).insert(any(InboundStatusHistoryEntity.class));
}
@Test
void rejectsCompletionWhenEvidenceIsIncomplete() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
InboundLoadingPickTaskEntity pick = new InboundLoadingPickTaskEntity();
pick.setStatus("COMPLETED"); pick.setExpectedCount(5); pick.setPickedCount(5);
when(repository.findPickTask(10, 20)).thenReturn(pick);
when(repository.listEvidence(10, 20)).thenReturn(List.of(evidence("SEAL", 5L, "LOCK-001")));
assertThatThrownBy(() -> service.completeLoading(10, 20, 7, "admin", "k-missing",
new CompleteLoadingRequest(3, null))).hasMessageContaining("必须齐全");
verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt());
}
@Test
void evidenceCanBeEditedAndDoesNotChangeMainStatus() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
when(repository.findEvidence(10, 20, "PHOTO_STEP_1")).thenReturn(null);
when(repository.listEvidence(10, 20)).thenReturn(List.of());
when(files.validate("token", 10, 20, 1)).thenReturn(ApiResponse.success(
new LoadingEvidenceFileClient.FileMetadata(1L, "photo.jpg", "LOADING_PHOTO_STEP_1", "image/jpeg")));
LoadingActionResponse result = service.upsertEvidence(10, 20, 7, "admin", "k2",
new UpsertEvidenceRequest(0, "PHOTO_STEP_1", null, "photo.jpg", null, "updated"));
new UpsertEvidenceRequest(0, "PHOTO_STEP_1", 1L, "ignored.jpg", null, "updated"));
assertThat(result.mainStatus()).isEqualTo("LOADING");
verify(repository).createEvidence(any(LoadingEvidenceEntity.class));
verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt());
}
@Test
void rejectsDuplicateSealUsedByAnotherInbound() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
LoadingEvidenceEntity duplicate = evidence("SEAL", 9L, "LOCK-DUP");
duplicate.setInboundId(21L);
when(repository.findEvidenceBySealNo(10, "LOCK-DUP")).thenReturn(duplicate);
assertThatThrownBy(() -> service.upsertEvidence(10, 20, 7, "admin", "k-duplicate-seal",
new UpsertEvidenceRequest(0, "SEAL", 5L, null, "LOCK-DUP", null)))
.hasMessageContaining("锁码已用于其他装柜任务");
verify(repository, never()).createEvidence(any());
}
@Test
void rejectsCompletingPickingBeforeTaskStarts() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
when(repository.findPickTask(10, 20)).thenReturn(null);
assertThatThrownBy(() -> service.completePicking(10, 20, 7, "admin", "k-picking",
new PickingRequest(0, 1, 1, null))).hasMessageContaining("尚未开始");
verify(repository, never()).createPickTask(any());
}
private static LoadingEvidenceEntity evidence(String type, Long fileId, String sealNo) {
LoadingEvidenceEntity value = new LoadingEvidenceEntity();
value.setEvidenceType(type); value.setFileId(fileId); value.setSealNo(sealNo);
return value;
}
}
@@ -0,0 +1,135 @@
package com.baiyue.firstleg.outbound.dispatch;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.*;
import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchEntity;
import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchOperationEntity;
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor;
import com.baiyue.firstleg.outbound.dispatch.mapper.OutboundDispatchReadMapper;
import com.baiyue.firstleg.outbound.dispatch.repository.OutboundDispatchRepository;
import com.baiyue.firstleg.outbound.dispatch.service.OutboundDispatchService;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class OutboundDispatchServiceTest {
private final OutboundDispatchRepository repository = mock(OutboundDispatchRepository.class);
@Test
void gateFailsClosedWhenCustomsAndScheduleModulesAreUnavailable() {
OutboundDispatchService service = new OutboundDispatchService(repository, List.of());
stubReadyChain();
GateResult result = service.checkGate(new Query(null, 11L, 22L));
assertThat(result.passed()).isFalse();
assertThat(result.items()).filteredOn(item -> !item.passed())
.extracting(GateItem::code).containsExactlyInAnyOrder("CUSTOMS_CLEARANCE", "SAILING_SCHEDULE");
}
@Test
void confirmAtomicallyAdvancesDispatchAndInboundAfterEveryGatePasses() {
OutboundDispatchService service = new OutboundDispatchService(repository,
List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")));
stubReadyChain();
OutboundDispatchEntity state = new OutboundDispatchEntity();
state.setId(33L); state.setTenantId(7L); state.setOrderId(10L); state.setInboundId(11L);
state.setContainerBookingId(22L); state.setStatus("READY_FOR_DISPATCH"); state.setVersion(0);
when(repository.dispatch(7L, 11L, 22L)).thenReturn(state);
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-1"), anyString())).thenReturn(true);
when(repository.confirm(33L, 0, 9L, "admin", "ok")).thenReturn(1);
when(repository.confirmInbound(7L, 11L, 9L)).thenReturn(1);
when(repository.completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-1")).thenReturn(1);
when(repository.timeline(any(), any(), any())).thenReturn(List.of());
service.confirm(9L, "admin", "confirm-1", new ActionRequest(11L, 22L, 0, "ok"));
verify(repository).confirm(33L, 0, 9L, "admin", "ok");
verify(repository).confirmInbound(7L, 11L, 9L);
verify(repository).history(argThat(history -> "CONFIRM_DISPATCH".equals(history.getAction())
&& "WAITING_FOR_LOADING".equals(history.getToStatus())));
verify(repository).completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-1");
}
@Test
void confirmNeverWritesStateWhenAnyRealGateFails() {
OutboundDispatchService service = new OutboundDispatchService(repository,
List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")));
stubReadyChain();
when(repository.pick(44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(55L, "LOADING"));
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-2"), anyString())).thenReturn(true);
assertThatThrownBy(() -> service.confirm(9L, "admin", "confirm-2",
new ActionRequest(11L, 22L, 0, null))).isInstanceOf(BizException.class).hasMessageContaining("门禁");
verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any());
verify(repository, never()).confirmInbound(anyLong(), anyLong(), anyLong());
}
@Test
void selectorsThatMatchMultipleContainerBindingsRequireDisambiguation() {
OutboundDispatchService service = new OutboundDispatchService(repository, List.of());
when(repository.bindings(any())).thenReturn(List.of(binding(), binding()));
assertThatThrownBy(() -> service.detail(new Query(10L, null, null)))
.isInstanceOf(BizException.class).hasMessageContaining("多个出仓聚合");
}
@Test
void staleDispatchVersionIsRejectedWithoutPartialInboundAdvance() {
OutboundDispatchService service = new OutboundDispatchService(repository,
List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")));
stubReadyChain();
OutboundDispatchEntity state = new OutboundDispatchEntity();
state.setId(33L); state.setTenantId(7L); state.setInboundId(11L); state.setContainerBookingId(22L);
state.setStatus("READY_FOR_DISPATCH"); state.setVersion(2);
when(repository.dispatch(7L, 11L, 22L)).thenReturn(state);
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("stale"), anyString())).thenReturn(true);
assertThatThrownBy(() -> service.confirm(9L, "admin", "stale",
new ActionRequest(11L, 22L, 1, null))).isInstanceOf(BizException.class).hasMessageContaining("刷新");
verify(repository, never()).confirmInbound(anyLong(), anyLong(), anyLong());
}
@Test
void reusedIdempotencyKeyWithDifferentHashIsRejected() {
OutboundDispatchService service = new OutboundDispatchService(repository,
List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")));
stubReadyChain();
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("reused"), anyString())).thenReturn(false);
OutboundDispatchOperationEntity existing = new OutboundDispatchOperationEntity();
existing.setRequestHash("different"); existing.setStatus("SUCCESS");
when(repository.operation(9L, "CONFIRM_DISPATCH", "reused")).thenReturn(existing);
assertThatThrownBy(() -> service.confirm(9L, "admin", "reused",
new ActionRequest(11L, 22L, 0, null))).isInstanceOf(BizException.class)
.hasMessageContaining("用于不同请求");
verify(repository, never()).confirmInbound(anyLong(), anyLong(), anyLong());
}
private void stubReadyChain() {
when(repository.bindings(any())).thenReturn(List.of(binding()));
when(repository.pick(44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(55L, "COMPLETED"));
when(repository.ocean(22L, 8L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(66L, "RELEASED"));
when(repository.truck(22L, 44L, 8L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "DEPARTED"));
when(repository.evidence(7L, 11L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1));
}
private static OutboundDispatchReadMapper.Binding binding() {
return new OutboundDispatchReadMapper.Binding(10L, "O-10", 11L, "I-11", 7L, 8L, "客户A",
"LOADED", 22L, "C-22", 44L, "COMPLETED");
}
private static OutboundDispatchGateContributor passing(String code) {
return new OutboundDispatchGateContributor() {
@Override public String code() { return code; }
@Override public GateItem assess(Context context) {
return new GateItem(code, code, true, "READY", "READY");
}
};
}
}
@@ -0,0 +1,57 @@
package com.baiyue.firstleg.outbound.dispatch;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class OutboundDispatchSqlContractTest {
@Test
void schemaReusesInboundAndContainerRootsAndProvidesConcurrencyHistoryAndIdempotency() throws Exception {
String sql = migration();
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fl_outbound_dispatch")
.contains("UNIQUE KEY uk_outbound_dispatch_owner (tenant_id,inbound_id,container_booking_id)")
.contains("FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order(id)")
.contains("FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)")
.contains("status IN ('READY_FOR_DISPATCH','WAITING_FOR_LOADING')")
.contains("version INT UNSIGNED NOT NULL DEFAULT 0")
.contains("CREATE TABLE IF NOT EXISTS fl_outbound_dispatch_history")
.contains("CREATE TABLE IF NOT EXISTS fl_outbound_dispatch_operation")
.contains("UNIQUE KEY uk_outbound_dispatch_operation (operator_id,operation,idempotency_key)")
.doesNotContain("CREATE TABLE IF NOT EXISTS fl_outbound_order");
}
@Test
void permissionsUseReservedRangeAndGrantBothPlatformRoles() throws Exception {
assertThat(migration())
.contains("5102160,0,'PLATFORM','MENU'")
.contains("5102164,0,'PLATFORM','BUTTON'")
.contains("platform:firstleg:outbound-dispatch:list")
.contains("platform:firstleg:outbound-dispatch:view")
.contains("platform:firstleg:outbound-dispatch:check")
.contains("platform:firstleg:outbound-dispatch:confirm")
.contains("platform:firstleg:outbound-dispatch:reverse")
.contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')");
}
@Test
void openApiPublishesSelectorsGateAndBothVersionedActions() throws Exception {
String openApi = resource("openapi/phase3-outbound-dispatch-v1.yaml");
assertThat(openApi)
.contains("outbound-dispatches:")
.contains("outbound-dispatches/detail:")
.contains("outbound-dispatches/check-gate:")
.contains("outbound-dispatches/confirm-dispatch:")
.contains("outbound-dispatches/reverse-dispatch:")
.contains("expectedVersion")
.contains("Idempotency-Key");
}
private static String migration() throws Exception { return resource("db/phase3-outbound-dispatch-schema.sql"); }
private static String resource(String path) throws Exception {
return new String(new ClassPathResource(path).getInputStream().readAllBytes(), StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,35 @@
package com.baiyue.firstleg.portschedule.controller;
import com.baiyue.common.audit.BusinessAudit;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class PlatformPortScheduleControllerContractTest {
@Test
void exposesDedicatedPlatformResourceAndPermissions() {
assertThat(PlatformPortScheduleController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/api/platform/v1/port-schedules");
Map<String, String> permissions = Map.of(
"page", "platform:firstleg:port-schedule:list",
"detail", "platform:firstleg:port-schedule:view",
"history", "platform:firstleg:port-schedule:view",
"create", "platform:firstleg:port-schedule:create",
"update", "platform:firstleg:port-schedule:edit");
permissions.forEach((name, permission) -> {
Method method = Arrays.stream(PlatformPortScheduleController.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("update")) {
assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull();
}
});
}
}
@@ -0,0 +1,31 @@
package com.baiyue.firstleg.portschedule.controller;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class PortScheduleOpenApiContractTest {
@Test
void publishesQueryWriteHistoryAndFreshnessContract() throws Exception {
String yaml = new ClassPathResource("openapi/port-schedules-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(yaml)
.contains("/api/platform/v1/port-schedules:")
.contains("/api/platform/v1/port-schedules/{portScheduleId}:")
.contains("/api/platform/v1/port-schedules/{portScheduleId}/history:")
.contains("name: customerId")
.contains("name: oceanBookingId")
.contains("name: containerBookingId")
.contains("name: expired")
.contains("name: Idempotency-Key")
.contains("sourceType:")
.contains("observedAt:")
.contains("expiresAt:")
.contains("bookingCutoffAt:")
.contains("customsCutoffAt:")
.contains("portCutoffAt:");
}
}
@@ -0,0 +1,50 @@
package com.baiyue.firstleg.portschedule.repository;
import com.baiyue.firstleg.portschedule.mapper.PortScheduleMapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PortScheduleMapperContractTest {
@Test
void pageSupportsAllStableFiltersAndDatabaseTimeExpiry() throws Exception {
String sql = PortScheduleMapper.class
.getMethod("selectPage", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class,
com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.Query.class)
.getAnnotation(Select.class).value()[0];
assertThat(sql)
.contains("customer_id=#{query.customerId}")
.contains("ocean_booking_id=#{query.oceanBookingId}")
.contains("container_booking_id=#{query.containerBookingId}")
.contains("expires_at&lt;=CURRENT_TIMESTAMP(3)")
.contains("expires_at&gt;CURRENT_TIMESTAMP(3)");
}
@Test
void updateUsesVersionAndImmutableAssociationPredicate() throws Exception {
String sql = PortScheduleMapper.class
.getMethod("updateSchedule", com.baiyue.firstleg.portschedule.entity.PortScheduleEntity.class,
int.class, long.class, String.class)
.getAnnotation(Update.class).value()[0];
assertThat(sql)
.contains("version=version+1")
.contains("customer_id=#{value.customerId}")
.contains("ocean_booking_id=#{value.oceanBookingId}")
.contains("container_booking_id&lt;=&gt;#{value.containerBookingId}")
.contains("version=#{expectedVersion}");
}
@Test
void dispatchGateReadsLatestExactCustomerBookingContainerSchedule() throws Exception {
String sql = PortScheduleMapper.class
.getMethod("latestForDispatch", long.class, long.class, long.class)
.getAnnotation(Select.class).value()[0];
assertThat(sql)
.contains("customer_id=#{customerId}")
.contains("ocean_booking_id=#{oceanBookingId}")
.contains("container_booking_id=#{containerBookingId}")
.contains("ORDER BY observed_at DESC,updated_at DESC,id DESC LIMIT 1");
}
}
@@ -0,0 +1,52 @@
package com.baiyue.firstleg.portschedule.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class PortScheduleSqlContractTest {
@Test
void definesIndependentScheduleSourceHistoryAndIdempotency() throws Exception {
assertThat(sql())
.contains("CREATE TABLE IF NOT EXISTS fl_port_schedule (")
.contains("CREATE TABLE IF NOT EXISTS fl_port_schedule_history")
.contains("CREATE TABLE IF NOT EXISTS fl_port_schedule_operation")
.contains("source_type VARCHAR(24) NOT NULL")
.contains("observed_at DATETIME(3) NOT NULL")
.contains("expires_at DATETIME(3) NOT NULL")
.contains("UNIQUE KEY uk_port_schedule_operation (operator_id,idempotency_key)")
.contains("港务船期历史不可修改");
}
@Test
void enforcesBookingCustomerContainerAndTemporalBoundaries() throws Exception {
assertThat(sql())
.contains("FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id)")
.contains("FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)")
.contains("SELECT customer_id FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id")
.contains("ocean_booking_id=NEW.ocean_booking_id AND container_booking_id=NEW.container_booking_id")
.contains("expires_at > observed_at")
.contains("booking_cutoff_at <= etd_at")
.contains("customs_cutoff_at <= etd_at")
.contains("port_cutoff_at <= etd_at");
}
@Test
void reservesDedicatedPlatformPermissionsForBothOperationalRoles() throws Exception {
assertThat(sql())
.contains("5102180 AND 5102183")
.contains("platform:firstleg:port-schedule:list")
.contains("platform:firstleg:port-schedule:view")
.contains("platform:firstleg:port-schedule:create")
.contains("platform:firstleg:port-schedule:edit")
.contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')");
}
private static String sql() throws Exception {
return new ClassPathResource("db/phase3-port-schedule-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,106 @@
package com.baiyue.firstleg.portschedule.service;
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor.Context;
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class PortScheduleDispatchGateContributorTest {
@Mock private PortScheduleRepository repository;
@InjectMocks private PortScheduleDispatchGateContributor contributor;
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this); }
@Test
void usesFixedCodeAndFailsClosedBeforeQueryForInvalidContext() {
assertThat(contributor.code()).isEqualTo("SAILING_SCHEDULE");
assertThat(contributor.assess(new Context(10001L, 10002L, 1L, 2L, 3L, 4L, 5L)).actual())
.isEqualTo("INVALID_CUSTOMER_SCOPE");
assertThat(contributor.assess(new Context(10001L, 10001L, 1L, 2L, 3L, 4L, null)).actual())
.isEqualTo("MISSING_OCEAN_BOOKING");
assertThat(contributor.assess(new Context(10001L, 10001L, 1L, 2L, 0L, 4L, 5L)).actual())
.isEqualTo("MISSING_CONTAINER_BOOKING");
verify(repository, never()).latestForDispatch(10001L, 5L, 3L);
}
@Test
void queriesExactCustomerOceanAndContainerAndFailsWhenMissing() {
Context context = context();
var result = contributor.assess(context);
assertThat(result.passed()).isFalse();
assertThat(result.actual()).isEqualTo("MISSING");
assertThat(result.requirement()).contains("expiresAt", "截港时间", "ETD");
verify(repository).latestForDispatch(10001L, 5L, 3L);
}
@Test
void rejectsMismatchedOrIncompleteLatestSchedule() {
PortScheduleEntity mismatched = ready();
mismatched.setContainerBookingId(99L);
when(repository.latestForDispatch(10001L, 5L, 3L)).thenReturn(mismatched);
assertThat(contributor.assess(context()).actual()).isEqualTo("ASSOCIATION_MISMATCH");
PortScheduleEntity incomplete = ready();
incomplete.setPortCutoffAt(null);
when(repository.latestForDispatch(10001L, 5L, 3L)).thenReturn(incomplete);
assertThat(contributor.assess(context()).actual()).isEqualTo("MISSING_REQUIRED_SCHEDULE_DATA");
}
@Test
void rejectsExpiredFutureObservationAndPassedOperationalTimes() {
PortScheduleEntity schedule = ready();
when(repository.latestForDispatch(10001L, 5L, 3L)).thenReturn(schedule);
schedule.setObservedAt(LocalDateTime.now().plusMinutes(5));
assertThat(contributor.assess(context()).actual()).isEqualTo("OBSERVATION_IN_FUTURE");
schedule.setObservedAt(LocalDateTime.now().minusMinutes(5));
schedule.setExpiresAt(LocalDateTime.now().minusSeconds(1));
assertThat(contributor.assess(context()).actual()).isEqualTo("EXPIRED");
schedule.setExpiresAt(LocalDateTime.now().plusHours(1));
schedule.setPortCutoffAt(LocalDateTime.now().minusSeconds(1));
assertThat(contributor.assess(context()).actual()).isEqualTo("PORT_CUTOFF_PASSED");
schedule.setPortCutoffAt(LocalDateTime.now().plusHours(1));
schedule.setEtdAt(LocalDateTime.now().minusSeconds(1));
assertThat(contributor.assess(context()).actual()).isEqualTo("ETD_PASSED");
}
@Test
void passesOnlyCurrentLatestScheduleWithFutureCutoffAndEtd() {
PortScheduleEntity schedule = ready();
when(repository.latestForDispatch(10001L, 5L, 3L)).thenReturn(schedule);
var result = contributor.assess(context());
assertThat(result.passed()).isTrue();
assertThat(result.code()).isEqualTo("SAILING_SCHEDULE");
assertThat(result.actual()).contains("READY:scheduleId=8", "expiresAt=", "portCutoffAt=", "etdAt=");
}
private static Context context() {
return new Context(10001L, 10001L, 1L, 2L, 3L, 4L, 5L);
}
private static PortScheduleEntity ready() {
PortScheduleEntity value = new PortScheduleEntity();
value.setId(8L); value.setCustomerId(10001L); value.setOceanBookingId(5L); value.setContainerBookingId(3L);
value.setSourceType("CARRIER"); value.setSourceName("Carrier"); value.setSourceRef("REF-8");
value.setObservedAt(LocalDateTime.now().minusMinutes(5));
value.setExpiresAt(LocalDateTime.now().plusHours(1));
value.setPortCutoffAt(LocalDateTime.now().plusHours(2));
value.setEtdAt(LocalDateTime.now().plusHours(3));
return value;
}
}
@@ -0,0 +1,143 @@
package com.baiyue.firstleg.portschedule.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.CreateRequest;
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.Detail;
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.ScheduleData;
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.UpdateRequest;
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
import com.baiyue.firstleg.portschedule.entity.PortScheduleHistoryEntity;
import com.baiyue.firstleg.portschedule.entity.PortScheduleOperationEntity;
import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
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;
class PortScheduleServiceTest {
@Mock private PortScheduleRepository repository;
@InjectMocks private PortScheduleService service;
@BeforeEach
void setUp() throws Exception {
MockitoAnnotations.openMocks(this);
var field = PortScheduleService.class.getDeclaredField("objectMapper");
field.setAccessible(true);
field.set(service, new ObjectMapper().findAndRegisterModules());
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString())).thenReturn(1);
when(repository.histories(anyLong())).thenReturn(List.of());
}
@Test
void createsIndependentObservationWithValidatedBookingAndAllocatedContainer() {
when(repository.oceanBooking(10L)).thenReturn(booking(10001L));
when(repository.containerBooking(20L)).thenReturn(new ContainerBookingEntity());
when(repository.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
Detail result = service.create(7L, "admin", "port-create",
new CreateRequest(0, data(20L, future(1), future(2)), "首录"));
assertThat(result.portSchedule().schedule().getOceanBookingId()).isEqualTo(10L);
assertThat(result.portSchedule().schedule().getContainerBookingId()).isEqualTo(20L);
assertThat(result.portSchedule().expired()).isFalse();
ArgumentCaptor<PortScheduleHistoryEntity> history = ArgumentCaptor.forClass(PortScheduleHistoryEntity.class);
verify(repository).insert(history.capture());
assertThat(history.getValue().getAction()).isEqualTo("CREATE");
assertThat(history.getValue().getFromVersion()).isNull();
assertThat(history.getValue().getToVersion()).isZero();
}
@Test
void rejectsContainerThatWasNotAllocatedToBooking() {
when(repository.oceanBooking(10L)).thenReturn(booking(10001L));
when(repository.containerBooking(20L)).thenReturn(new ContainerBookingEntity());
assertThatThrownBy(() -> service.create(7L, "admin", "port-unallocated",
new CreateRequest(0, data(20L, future(1), future(2)), null)))
.isInstanceOf(BizException.class).hasMessageContaining("未分配至该订舱");
verify(repository, never()).insert(any(PortScheduleEntity.class));
}
@Test
void rejectsStaleUpdateWithoutMutation() {
when(repository.find(1L)).thenReturn(schedule(3));
assertThatThrownBy(() -> service.update(1L, 7L, "admin", "port-stale",
new UpdateRequest(2, data(null, future(1), future(2)), "旧版本")))
.isInstanceOf(BizException.class).hasMessageContaining("刷新后重试");
verify(repository, never()).update(any(), any(Integer.class), anyLong(), anyString());
}
@Test
void replaysCompletedCreateBeforeLinkValidation() throws Exception {
PortScheduleEntity value = schedule(0);
Detail expected = new Detail(new com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.ScheduleView(value, false), List.of());
CreateRequest request = new CreateRequest(0, data(null, future(1), future(2)), "首录");
var hash = PortScheduleService.class.getDeclaredMethod("hash", String.class, Long.class, Object.class);
hash.setAccessible(true);
PortScheduleOperationEntity operation = new PortScheduleOperationEntity();
operation.setRequestHash((String) hash.invoke(service, "CREATE", null, request));
operation.setStatus("SUCCESS");
operation.setResultJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(expected));
when(repository.claimOperation(eq(null), eq(7L), eq("CREATE"), eq("port-replay"), anyString())).thenReturn(false);
when(repository.operation(7L, "port-replay")).thenReturn(operation);
Detail replay = service.create(7L, "admin", "port-replay", request);
assertThat(replay.portSchedule().schedule().getId()).isEqualTo(1L);
verify(repository, never()).oceanBooking(anyLong());
}
@Test
void validatesObservationExpiryAndNullableOperationalTimes() {
assertThatThrownBy(() -> service.create(7L, "admin", "port-expiry",
new CreateRequest(0, data(null, future(2), future(1)), null)))
.isInstanceOf(BizException.class).hasMessageContaining("expiresAt");
}
private static OceanBookingEntity booking(long customerId) {
OceanBookingEntity value = new OceanBookingEntity();
value.setId(10L); value.setCustomerId(customerId);
return value;
}
private static PortScheduleEntity schedule(int version) {
PortScheduleEntity value = new PortScheduleEntity();
value.setId(1L); value.setScheduleNo("PS1"); value.setCustomerId(10001L);
value.setOceanBookingId(10L); value.setSourceType("CARRIER"); value.setSourceName("Carrier");
value.setSourceRef("REF-1"); value.setVesselName("VESSEL"); value.setVoyageNo("V1");
value.setDeparturePortCode("CNSZX"); value.setDestinationPortCode("USLAX");
value.setObservedAt(future(1)); value.setExpiresAt(future(2)); value.setVersion(version);
return value;
}
private static ScheduleData data(Long containerId, LocalDateTime observedAt, LocalDateTime expiresAt) {
return new ScheduleData(10001L, 10L, containerId, "CARRIER", "Carrier", "REF-1",
"VESSEL", "V1", "CNSZX", "深圳", "USLAX", "洛杉矶",
future(5), future(20), future(2), future(3), future(4), observedAt, expiresAt, "验收");
}
private static LocalDateTime future(long days) { return LocalDateTime.now().plusDays(days); }
}
@@ -0,0 +1,46 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class Phase3FinalUnifiedSqlContractTest {
private static final Path UNIFIED = Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
@Test
void unifiedEntryOwnsPhase3OutboundCustomsPortAndEvidenceHardening() throws Exception {
String sql = Files.readString(UNIFIED, StandardCharsets.UTF_8);
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fl_outbound_dispatch")
.contains("CREATE TABLE IF NOT EXISTS fl_customs_case")
.contains("CREATE TABLE IF NOT EXISTS fl_customs_document")
.contains("CREATE TABLE IF NOT EXISTS fl_customs_submission_batch")
.contains("CREATE TABLE IF NOT EXISTS fl_customs_receipt")
.contains("CREATE TABLE IF NOT EXISTS fl_port_schedule")
.contains("CREATE TABLE IF NOT EXISTS fl_port_schedule_history")
.contains("uk_loading_evidence_seal (tenant_id,seal_no)")
.contains("CALL assert_phase3_final_schema()")
.contains("trg_port_schedule_history_before_update")
.contains("trg_port_schedule_history_before_delete");
}
@Test
void permissionRangesDoNotOverlapAndBothPlatformRolesAreGranted() throws Exception {
String sql = Files.readString(UNIFIED, StandardCharsets.UTF_8);
for (int id = 5102160; id <= 5102164; id++) assertThat(sql).contains(Integer.toString(id));
for (int id = 5102170; id <= 5102175; id++) assertThat(sql).contains(Integer.toString(id));
for (int id = 5102180; id <= 5102183; id++) assertThat(sql).contains(Integer.toString(id));
assertThat(sql)
.contains("platform:firstleg:outbound-dispatch:%")
.contains("platform:firstleg:customs:%")
.contains("platform:firstleg:port-schedule:%")
.contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')")
.contains("角色授权未完整写入");
}
}
+1 -1
View File
@@ -77,7 +77,7 @@ spring:
- id: firstleg-service
uri: lb://firstleg-service
predicates:
- Path=/api/merchant/v1/first-leg/**,/api/platform/v1/first-leg/**,/api/platform/v1/containers/**,/api/platform/v1/ocean-bookings/**,/api/platform/v1/truck-appointments/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/v1/fee-items/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/**
- Path=/api/merchant/v1/first-leg/**,/api/merchant/v1/customs/**,/api/platform/v1/first-leg/**,/api/platform/v1/customs/**,/api/platform/v1/port-schedules/**,/api/platform/v1/containers/**,/api/platform/v1/ocean-bookings/**,/api/platform/v1/truck-appointments/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/v1/fee-items/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/**
# Swagger 文档聚合路由。
- id: auth-openapi
uri: lb://auth-service
@@ -25,4 +25,16 @@ class FirstlegContainerRouteContractTest {
.contains("/api/platform/v1/ocean-bookings/**")
.contains("/api/platform/v1/truck-appointments/**");
}
@Test
void routesPhase3CustomsPortSchedulesAndOutboundDispatch() throws Exception {
String yaml = new ClassPathResource("application.yml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(yaml)
.contains("/api/platform/v1/customs/**")
.contains("/api/merchant/v1/customs/**")
.contains("/api/platform/v1/port-schedules/**")
.contains("/api/platform/v1/first-leg/**");
}
}