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