diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index ed3c3a695..6796dde57 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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; diff --git a/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java b/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java index f06d7d9cd..99b4c1f9f 100644 --- a/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java +++ b/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java @@ -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 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 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 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 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 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());}} diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java index 6048a07e0..5087c9ae9 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java @@ -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); diff --git a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java index 9aa1c2665..e929b9858 100644 --- a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java +++ b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java @@ -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 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().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().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){} } diff --git a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java new file mode 100644 index 000000000..cae7cc142 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java @@ -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()); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/TruckAppointmentFileClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/TruckAppointmentFileClient.java index 636fa0c09..c71ab5024 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/TruckAppointmentFileClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/TruckAppointmentFileClient.java @@ -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 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(@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 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(@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){} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/client/CustomsFileClient.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/client/CustomsFileClient.java new file mode 100644 index 000000000..4560f17dd --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/client/CustomsFileClient.java @@ -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 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(@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 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) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/controller/MerchantCustomsController.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/controller/MerchantCustomsController.java new file mode 100644 index 000000000..07f912514 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/controller/MerchantCustomsController.java @@ -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 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 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 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 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 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 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 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("\"", ""); } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/controller/PlatformCustomsController.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/controller/PlatformCustomsController.java new file mode 100644 index 000000000..82a9046e2 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/controller/PlatformCustomsController.java @@ -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> 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 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 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 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 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 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 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 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 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 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 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 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 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("\"", ""); } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/dto/CustomsDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/dto/CustomsDtos.java new file mode 100644 index 000000000..494a9c8b7 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/dto/CustomsDtos.java @@ -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 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 documents, + List supplements, + List submissionBatches, + List receipts, + List history) {} + + public record Download(String fileName, String contentType, byte[] content) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsCaseEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsCaseEntity.java new file mode 100644 index 000000000..06b73b886 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsCaseEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsChecklistVersionEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsChecklistVersionEntity.java new file mode 100644 index 000000000..5ba3a881f --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsChecklistVersionEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsDocumentEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsDocumentEntity.java new file mode 100644 index 000000000..2a835bfb4 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsDocumentEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsHistoryEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsHistoryEntity.java new file mode 100644 index 000000000..10f75b5a5 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsHistoryEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsOperationEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsOperationEntity.java new file mode 100644 index 000000000..7b2af0c6b --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsOperationEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsReceiptEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsReceiptEntity.java new file mode 100644 index 000000000..410f6df34 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsReceiptEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsSubmissionBatchEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsSubmissionBatchEntity.java new file mode 100644 index 000000000..537f12c9b --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsSubmissionBatchEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsSupplementRequestEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsSupplementRequestEntity.java new file mode 100644 index 000000000..bdcd0028d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsSupplementRequestEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsCaseMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsCaseMapper.java new file mode 100644 index 000000000..8f12886f8 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsCaseMapper.java @@ -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 {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsChecklistVersionMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsChecklistVersionMapper.java new file mode 100644 index 000000000..a4a749625 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsChecklistVersionMapper.java @@ -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 {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsDocumentMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsDocumentMapper.java new file mode 100644 index 000000000..e3e5deb2d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsDocumentMapper.java @@ -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 {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsHistoryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsHistoryMapper.java new file mode 100644 index 000000000..ce7ebd68e --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsHistoryMapper.java @@ -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 {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsOperationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsOperationMapper.java new file mode 100644 index 000000000..eccf3fc48 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsOperationMapper.java @@ -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 {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsReceiptMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsReceiptMapper.java new file mode 100644 index 000000000..4edf7bf3b --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsReceiptMapper.java @@ -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 {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsSubmissionBatchMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsSubmissionBatchMapper.java new file mode 100644 index 000000000..92dd546ee --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsSubmissionBatchMapper.java @@ -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 {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsSupplementRequestMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsSupplementRequestMapper.java new file mode 100644 index 000000000..ce49b66ca --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsSupplementRequestMapper.java @@ -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 {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java new file mode 100644 index 000000000..f33063e95 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java @@ -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() + .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() + .eq(CustomsCaseEntity::getId, id).eq(CustomsCaseEntity::getTenantId, tenantId)); + } + public CustomsCaseEntity caseByOrder(long tenantId, long orderId) { + return cases.selectOne(new LambdaQueryWrapper() + .eq(CustomsCaseEntity::getTenantId, tenantId).eq(CustomsCaseEntity::getOrderId, orderId) + .orderByDesc(CustomsCaseEntity::getUpdatedAt).last("LIMIT 1")); + } + public PageResult page(Query query) { + var wrapper = new LambdaQueryWrapper() + .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() + .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() + .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() + .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() + .eq(LoadingPlanEntity::getContainerBookingId, containerId)); + return plan != null && loadingPlanItems.selectCount(new LambdaQueryWrapper() + .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 documents(long caseId) { + return documents.selectList(new LambdaQueryWrapper() + .eq(CustomsDocumentEntity::getCustomsCaseId, caseId) + .orderByAsc(CustomsDocumentEntity::getChecklistItemCode) + .orderByAsc(CustomsDocumentEntity::getDocumentVersion)); + } + public CustomsDocumentEntity latestDocument(long caseId, String itemCode) { + return documents.selectOne(new LambdaQueryWrapper() + .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() + .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() + .eq(CustomsDocumentEntity::getId, id).in(CustomsDocumentEntity::getStatus, "UPLOADED", "RETURNED") + .set(CustomsDocumentEntity::getStatus, "SUPERSEDED")); + } + public int freeze(long id) { + return documents.update(null, new LambdaUpdateWrapper() + .eq(CustomsDocumentEntity::getId, id).eq(CustomsDocumentEntity::getStatus, "APPROVED") + .set(CustomsDocumentEntity::getStatus, "FROZEN")); + } + + public void insert(CustomsSupplementRequestEntity value) { supplements.insert(value); } + public List supplements(long caseId) { + return supplements.selectList(new LambdaQueryWrapper() + .eq(CustomsSupplementRequestEntity::getCustomsCaseId, caseId) + .orderByAsc(CustomsSupplementRequestEntity::getRequestedAt)); + } + public void resolveSupplements(long caseId, String itemCode, long documentId, long operatorId) { + supplements.update(null, new LambdaUpdateWrapper() + .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 batches(long caseId) { + return batches.selectList(new LambdaQueryWrapper() + .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() + .eq(CustomsReceiptEntity::getSubmissionBatchId, batchId) + .eq(CustomsReceiptEntity::getReceiptType, type) + .orderByDesc(CustomsReceiptEntity::getReceiptVersion).last("LIMIT 1")); + } + public List receipts(long caseId, boolean customerOnly) { + return receipts.selectList(new LambdaQueryWrapper() + .eq(CustomsReceiptEntity::getCustomsCaseId, caseId) + .eq(customerOnly, CustomsReceiptEntity::getVisibility, "CUSTOMER") + .orderByAsc(CustomsReceiptEntity::getUploadedAt)); + } + + public void insert(CustomsHistoryEntity value) { histories.insert(value); } + public List history(long caseId) { + return histories.selectList(new LambdaQueryWrapper() + .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() + .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() + .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())); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java new file mode 100644 index 000000000..7da941d3a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java @@ -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"); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsService.java new file mode 100644 index 000000000..bbdcfa067 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsService.java @@ -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 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 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 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 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 currentDocuments(CustomsCaseEntity customsCase) { + Map 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 supplements = repository.supplements(customsCase.getId()); + if (supplements.stream().anyMatch(v -> "OPEN".equals(v.getStatus()))) return "SUPPLEMENT_REQUIRED"; + Map current = repository.documents(customsCase.getId()).stream() + .collect(Collectors.toMap(CustomsDocumentEntity::getChecklistItemCode, Function.identity(), + (left, right) -> left.getDocumentVersion() > right.getDocumentVersion() ? left : right)); + List 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 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 checklistItems(CustomsCaseEntity value) { + return parseItems(requiredChecklist(value.getChecklistVersionId()).getItemsJson()); + } + private List 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 items) { + Set 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); } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingRequests.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingRequests.java index 8920e531a..446915dd7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingRequests.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingRequests.java @@ -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) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java index 029b0e777..786f65ffe 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java @@ -47,6 +47,13 @@ public class LoadingRepository { .orderByAsc(LoadingEvidenceEntity::getEvidenceType)); } + public LoadingEvidenceEntity findEvidenceBySealNo(long tenantId, String sealNo) { + return evidences.selectOne(new LambdaQueryWrapper() + .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, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java index e04fc364f..255e660a6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java @@ -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 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); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/controller/PlatformOutboundDispatchController.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/controller/PlatformOutboundDispatchController.java new file mode 100644 index 000000000..36f8e7bd7 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/controller/PlatformOutboundDispatchController.java @@ -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(@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(@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 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 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 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)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/domain/OutboundDispatchStatus.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/domain/OutboundDispatchStatus.java new file mode 100644 index 000000000..bac447851 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/domain/OutboundDispatchStatus.java @@ -0,0 +1,6 @@ +package com.baiyue.firstleg.outbound.dispatch.domain; + +public enum OutboundDispatchStatus { + READY_FOR_DISPATCH, + WAITING_FOR_LOADING +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java new file mode 100644 index 000000000..c9b54e718 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java @@ -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 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 timeline) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/entity/OutboundDispatchEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/entity/OutboundDispatchEntity.java new file mode 100644 index 000000000..c20f35c25 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/entity/OutboundDispatchEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/entity/OutboundDispatchHistoryEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/entity/OutboundDispatchHistoryEntity.java new file mode 100644 index 000000000..1f28ce79c --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/entity/OutboundDispatchHistoryEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/entity/OutboundDispatchOperationEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/entity/OutboundDispatchOperationEntity.java new file mode 100644 index 000000000..d41879ffa --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/entity/OutboundDispatchOperationEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/gate/OutboundDispatchGateContributor.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/gate/OutboundDispatchGateContributor.java new file mode 100644 index 000000000..89af7696f --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/gate/OutboundDispatchGateContributor.java @@ -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) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchHistoryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchHistoryMapper.java new file mode 100644 index 000000000..5f665b2ad --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchHistoryMapper.java @@ -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 {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchMapper.java new file mode 100644 index 000000000..09fb7a8a7 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchMapper.java @@ -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 { + @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); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchOperationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchOperationMapper.java new file mode 100644 index 000000000..456a08bf1 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchOperationMapper.java @@ -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 { + @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); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java new file mode 100644 index 000000000..a6fed5237 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java @@ -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(""" + + """) + List 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<>'' 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 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) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java new file mode 100644 index 000000000..fc516a47a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java @@ -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 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 timeline(OutboundDispatchReadMapper.Binding binding, Long oceanId, Long truckId) { + return reads.timeline(binding.tenantId(), binding.inboundId(), binding.containerBookingId(), + binding.loadingPlanId(), oceanId, truckId); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java new file mode 100644 index 000000000..9bca0286c --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java @@ -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 REQUIRED_CONTRIBUTORS = Set.of("CUSTOMS_CLEARANCE", "SAILING_SCHEDULE"); + private static final Set TRUCK_READY = Set.of("DEPARTED", "COMPLETED"); + + private final OutboundDispatchRepository repository; + private final Map contributors; + + public OutboundDispatchService(OutboundDispatchRepository repository, + List contributors) { + this.repository = repository; + Map 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 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 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 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("数据已被其他人修改,请刷新后重试"); } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleController.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleController.java new file mode 100644 index 000000000..2b586c181 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleController.java @@ -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> 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(@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> 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 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 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)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/PortScheduleDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/PortScheduleDtos.java new file mode 100644 index 000000000..3d496b8fd --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/PortScheduleDtos.java @@ -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 histories) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/PortScheduleEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/PortScheduleEntity.java new file mode 100644 index 000000000..1c9c989ac --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/PortScheduleEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/PortScheduleHistoryEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/PortScheduleHistoryEntity.java new file mode 100644 index 000000000..e1a62c0ee --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/PortScheduleHistoryEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/PortScheduleOperationEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/PortScheduleOperationEntity.java new file mode 100644 index 000000000..5d1b513e9 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/PortScheduleOperationEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/PortScheduleHistoryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/PortScheduleHistoryMapper.java new file mode 100644 index 000000000..a090f1e93 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/PortScheduleHistoryMapper.java @@ -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 {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/PortScheduleMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/PortScheduleMapper.java new file mode 100644 index 000000000..5514b50cf --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/PortScheduleMapper.java @@ -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 { + @Select(""" + + """) + Page selectPage(Page 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<=>#{value.containerBookingId}) AND version=#{expectedVersion} + """) + int updateSchedule(@Param("value") PortScheduleEntity value, + @Param("expectedVersion") int expectedVersion, + @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/PortScheduleOperationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/PortScheduleOperationMapper.java new file mode 100644 index 000000000..2e6187237 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/PortScheduleOperationMapper.java @@ -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 { + @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); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/PortScheduleRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/PortScheduleRepository.java new file mode 100644 index 000000000..6c72db910 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/PortScheduleRepository.java @@ -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 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 histories(long id) { + return histories.selectList(new LambdaQueryWrapper() + .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() + .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() + .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); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/PortScheduleDispatchGateContributor.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/PortScheduleDispatchGateContributor.java new file mode 100644 index 000000000..e9de77cb4 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/PortScheduleDispatchGateContributor.java @@ -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); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/PortScheduleService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/PortScheduleService.java new file mode 100644 index 000000000..e6d9d38a0 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/PortScheduleService.java @@ -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 SOURCE_TYPES = Set.of("MANUAL", "CARRIER", "TERMINAL", "API"); + + @Resource private PortScheduleRepository repository; + @Resource private ObjectMapper objectMapper; + + public PageResult page(Query query) { + PageResult 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 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("数据已被其他人修改,请刷新后重试"); } +} diff --git a/firstleg/src/main/resources/db/phase3-customs-schema.sql b/firstleg/src/main/resources/db/phase3-customs-schema.sql new file mode 100644 index 000000000..f6c8925e8 --- /dev/null +++ b/firstleg/src/main/resources/db/phase3-customs-schema.sql @@ -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; diff --git a/firstleg/src/main/resources/db/phase3-loading-delivery-schema.sql b/firstleg/src/main/resources/db/phase3-loading-delivery-schema.sql index b14c75629..9e57eabd0 100644 --- a/firstleg/src/main/resources/db/phase3-loading-delivery-schema.sql +++ b/firstleg/src/main/resources/db/phase3-loading-delivery-schema.sql @@ -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='订单装柜四步照片和铅封证据'; diff --git a/firstleg/src/main/resources/db/phase3-outbound-dispatch-schema.sql b/firstleg/src/main/resources/db/phase3-outbound-dispatch-schema.sql new file mode 100644 index 000000000..a3a46c3f8 --- /dev/null +++ b/firstleg/src/main/resources/db/phase3-outbound-dispatch-schema.sql @@ -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; diff --git a/firstleg/src/main/resources/db/phase3-port-schedule-schema.sql b/firstleg/src/main/resources/db/phase3-port-schedule-schema.sql new file mode 100644 index 000000000..8be920775 --- /dev/null +++ b/firstleg/src/main/resources/db/phase3-port-schedule-schema.sql @@ -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; diff --git a/firstleg/src/main/resources/openapi/customs-v1.yaml b/firstleg/src/main/resources/openapi/customs-v1.yaml new file mode 100644 index 000000000..0c2bcffaf --- /dev/null +++ b/firstleg/src/main/resources/openapi/customs-v1.yaml @@ -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]*$'} diff --git a/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml b/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml new file mode 100644 index 000000000..2d9953559 --- /dev/null +++ b/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml @@ -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} diff --git a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml new file mode 100644 index 000000000..31a3042c9 --- /dev/null +++ b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml @@ -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} diff --git a/firstleg/src/main/resources/openapi/port-schedules-v1.yaml b/firstleg/src/main/resources/openapi/port-schedules-v1.yaml new file mode 100644 index 000000000..4016226a5 --- /dev/null +++ b/firstleg/src/main/resources/openapi/port-schedules-v1.yaml @@ -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} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/repository/CustomsSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/repository/CustomsSqlContractTest.java new file mode 100644 index 000000000..8c03e055a --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/repository/CustomsSqlContractTest.java @@ -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"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java new file mode 100644 index 000000000..2036e89bf --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java @@ -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); } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsServiceTest.java new file mode 100644 index 000000000..5e090bdca --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsServiceTest.java @@ -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 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; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java new file mode 100644 index 000000000..8f36491ef --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java @@ -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"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/repository/LoadingEvidenceSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/repository/LoadingEvidenceSqlContractTest.java new file mode 100644 index 000000000..70255178e --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/repository/LoadingEvidenceSqlContractTest.java @@ -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"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java index c723060fb..4f00e1bc6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java @@ -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; + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java new file mode 100644 index 000000000..cb1fee84e --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java @@ -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"); + } + }; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java new file mode 100644 index 000000000..03a8422c9 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java @@ -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); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleControllerContractTest.java new file mode 100644 index 000000000..42959cc52 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleControllerContractTest.java @@ -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 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(); + } + }); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PortScheduleOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PortScheduleOpenApiContractTest.java new file mode 100644 index 000000000..9373f48e8 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PortScheduleOpenApiContractTest.java @@ -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:"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/PortScheduleMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/PortScheduleMapperContractTest.java new file mode 100644 index 000000000..ef4cb4025 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/PortScheduleMapperContractTest.java @@ -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<=CURRENT_TIMESTAMP(3)") + .contains("expires_at>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<=>#{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"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/PortScheduleSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/PortScheduleSqlContractTest.java new file mode 100644 index 000000000..e1049bca6 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/PortScheduleSqlContractTest.java @@ -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); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/PortScheduleDispatchGateContributorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/PortScheduleDispatchGateContributorTest.java new file mode 100644 index 000000000..1143a9533 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/PortScheduleDispatchGateContributorTest.java @@ -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; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/PortScheduleServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/PortScheduleServiceTest.java new file mode 100644 index 000000000..7bfaae9aa --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/PortScheduleServiceTest.java @@ -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 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); } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java new file mode 100644 index 000000000..32b885683 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -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("角色授权未完整写入"); + } +} diff --git a/gateway/src/main/resources/application.yml b/gateway/src/main/resources/application.yml index 41463d204..d099e2614 100644 --- a/gateway/src/main/resources/application.yml +++ b/gateway/src/main/resources/application.yml @@ -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 diff --git a/gateway/src/test/java/com/baiyue/gateway/config/FirstlegContainerRouteContractTest.java b/gateway/src/test/java/com/baiyue/gateway/config/FirstlegContainerRouteContractTest.java index 9945111e5..923751a55 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/FirstlegContainerRouteContractTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/FirstlegContainerRouteContractTest.java @@ -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/**"); + } }