feat(firstleg): implement container customs workflow

This commit is contained in:
2026-08-28 16:48:50 +08:00
parent bb9673358d
commit 0ea4aeecc9
92 changed files with 3165 additions and 2754 deletions
+10
View File
@@ -22,6 +22,16 @@ baiyue:
max-attempts: 3 max-attempts: 3
batch-size: 500 batch-size: 500
LOADING_LIST_BUY_DECLARATION_ITEMS:
provider-service: firstleg-service
provider-path: /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches
required-permission: platform:firstleg:customs:manage
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_CUSTOMER_ACCOUNT_LIST: FINANCE_CUSTOMER_ACCOUNT_LIST:
provider-service: finance-service provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
+10
View File
@@ -18,6 +18,16 @@ baiyue:
max-attempts: 3 max-attempts: 3
batch-size: 500 batch-size: 500
LOADING_LIST_BUY_DECLARATION_ITEMS:
provider-service: firstleg-service
provider-path: /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches
required-permission: platform:firstleg:customs:manage
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_CUSTOMER_ACCOUNT_LIST: FINANCE_CUSTOMER_ACCOUNT_LIST:
provider-service: finance-service provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
+99 -291
View File
@@ -5114,7 +5114,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
CREATE TABLE IF NOT EXISTS fl_container_document ( CREATE TABLE IF NOT EXISTS fl_container_document (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)', id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
document_type VARCHAR(32) NOT NULL COMMENT '文件类型:DESTINATION_CLEARANCE目的港清关、CUSTOMS_DECLARATION出口报关、DELIVERY_ORDER提货单、PICKUP_ORDER取货单', document_type VARCHAR(32) NOT NULL COMMENT '文件类型:DESTINATION_CLEARANCE目的港清关、DELIVERY_ORDER提货单、PICKUP_ORDER取货单',
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID,不建立跨库外键', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID,不建立跨库外键',
file_name VARCHAR(255) NOT NULL COMMENT '文件名称快照', file_name VARCHAR(255) NOT NULL COMMENT '文件名称快照',
file_hash VARCHAR(64) NULL COMMENT 'File服务SHA-256快照', file_hash VARCHAR(64) NULL COMMENT 'File服务SHA-256快照',
@@ -5134,7 +5134,7 @@ CREATE TABLE IF NOT EXISTS fl_container_document (
CONSTRAINT fk_container_document_booking FOREIGN KEY (container_booking_id) CONSTRAINT fk_container_document_booking FOREIGN KEY (container_booking_id)
REFERENCES fl_container_booking (id), REFERENCES fl_container_booking (id),
CONSTRAINT chk_container_document_type CONSTRAINT chk_container_document_type
CHECK (document_type IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')) CHECK (document_type IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER'))
) ENGINE=InnoDB COMMENT='平台货柜清关、DO与PU文件关联'; ) ENGINE=InnoDB COMMENT='平台货柜清关、DO与PU文件关联';
CREATE TABLE IF NOT EXISTS fl_loading_plan_history ( CREATE TABLE IF NOT EXISTS fl_loading_plan_history (
@@ -5906,7 +5906,7 @@ BEGIN
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
WHERE container_booking_id = NEW.container_booking_id; WHERE container_booking_id = NEW.container_booking_id;
IF current_plan_status = 'COMPLETED' IF current_plan_status = 'COMPLETED'
AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增';
END IF; END IF;
END$$ END$$
@@ -5922,7 +5922,7 @@ BEGIN
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
WHERE container_booking_id = OLD.container_booking_id; WHERE container_booking_id = OLD.container_booking_id;
IF current_plan_status = 'COMPLETED' IF current_plan_status = 'COMPLETED'
AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改';
END IF; END IF;
END$$ END$$
@@ -9777,173 +9777,25 @@ WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
USE baiyue_firstleg; USE baiyue_firstleg;
-- Phase 3 customs document, supplementation, broker submission and receipt lifecycle. -- Latest container customs permissions; old case/review/receipt actions are removed.
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_customs_checklist_version (
id BIGINT UNSIGNED NOT NULL COMMENT '报关资料清单版本逻辑ID', checklist_code VARCHAR(40) NOT NULL COMMENT '报关资料清单编码;同一清单的多个版本共用', version_no VARCHAR(32) NOT NULL COMMENT '清单业务版本号',
country_code VARCHAR(32) NOT NULL COMMENT '清单适用的目的国家或地区编码', channel_code VARCHAR(40) NOT NULL COMMENT '清单适用的运输渠道编码', cargo_type VARCHAR(40) NOT NULL COMMENT '清单适用的货物类型编码',
items_json JSON NOT NULL COMMENT '不可变清单项目数组;元素包含code项目编码、name项目名称和required是否必填', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '清单版本状态:ACTIVE启用、RETIRED停用', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建清单版本的平台操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建清单版本时的平台操作人名称快照', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '清单版本创建时间', 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 COMMENT '报关协同单逻辑ID', customs_no VARCHAR(40) NOT NULL COMMENT '全局唯一报关协同单号', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
order_id BIGINT UNSIGNED NOT NULL COMMENT 'Order服务订单逻辑ID;跨库不建立外键', inbound_id BIGINT UNSIGNED NOT NULL COMMENT 'Firstleg入库单逻辑ID',
container_booking_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '关联柜次逻辑ID;0表示创建时尚未关联柜次', checklist_version_id BIGINT UNSIGNED NOT NULL COMMENT '本单冻结使用的报关资料清单版本ID',
checklist_code VARCHAR(40) NOT NULL COMMENT '创建协同单时的清单编码快照;源清单变化不回写', checklist_version_no VARCHAR(32) NOT NULL COMMENT '创建协同单时的清单版本号快照;源清单变化不回写',
status VARCHAR(32) NOT NULL DEFAULT 'DOCUMENTS_PENDING' COMMENT '协同状态:DOCUMENTS_PENDING待补资料、REVIEWING审核中、SUPPLEMENT_REQUIRED需补件、READY_TO_SUBMIT待提交代理、RECEIPT_PENDING待回执、RECEIPT_AVAILABLE已有回执、CLEARANCE_COMPLETED已放行、CANCELLED已取消', remark VARCHAR(500) NULL COMMENT '报关协同单内部业务备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建协同单的平台操作人ID', created_by_name VARCHAR(64) NULL COMMENT '创建协同单时的平台操作人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '协同单创建时间', updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改协同单的平台操作人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改时的平台操作人名称快照', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '协同单最后修改时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号;每次状态变更递增', 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 COMMENT '报关资料文件版本逻辑ID', customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID', checklist_item_code VARCHAR(40) NOT NULL COMMENT '对应冻结清单中的资料项目编码',
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件逻辑ID;跨库不建立外键', document_version INT UNSIGNED NOT NULL COMMENT '同一协同单和清单项目下的文件版本序号', replaces_document_id BIGINT UNSIGNED NULL COMMENT '被本版本替换的上一资料版本ID;首个版本为空',
file_name VARCHAR(255) NOT NULL COMMENT '上传或引用时的文件名称快照', file_hash CHAR(64) NOT NULL COMMENT '文件内容SHA-256摘要;用于版本审计和幂等校验', status VARCHAR(16) NOT NULL DEFAULT 'UPLOADED' COMMENT '资料状态:UPLOADED待审核、APPROVED审核通过、RETURNED已退回、SUPERSEDED已被替换、FROZEN已随代理提交批次冻结',
visibility VARCHAR(16) NOT NULL DEFAULT 'CUSTOMER' COMMENT '资料可见范围:PLATFORM仅平台可见、CUSTOMER客户可见', uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传或引用资料的操作人ID',
uploaded_by_type VARCHAR(16) NOT NULL COMMENT '上传主体类型:PLATFORM平台操作人、MERCHANT客户操作人', uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '资料上传或引用时间',
reviewed_by BIGINT UNSIGNED NULL COMMENT '审核资料的平台操作人ID;未审核时为空', reviewed_by_name VARCHAR(64) NULL COMMENT '审核时的平台操作人名称快照', reviewed_at DATETIME(3) NULL COMMENT '资料审核时间',
review_reason VARCHAR(500) NULL COMMENT '审核退回原因或审核备注', 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 COMMENT '报关补件请求逻辑ID', customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID', source_document_id BIGINT UNSIGNED NOT NULL COMMENT '触发本次补件的被退回资料版本ID',
resolved_document_id BIGINT UNSIGNED NULL COMMENT '解决本次补件的新资料版本ID;未解决时为空', checklist_item_code VARCHAR(40) NOT NULL COMMENT '需要补充的报关资料项目编码', reason VARCHAR(500) NOT NULL COMMENT '平台审核人员填写的补件原因',
due_at DATETIME(3) NULL COMMENT '期望完成补件时间;未设置时为空', status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT '补件状态:OPEN待补件、RESOLVED已由新资料解决、CANCELLED已取消', requested_by BIGINT UNSIGNED NOT NULL COMMENT '发起补件的平台操作人ID',
requested_by_name VARCHAR(64) NULL COMMENT '发起补件时的平台操作人名称快照', requested_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '补件请求发起时间',
resolved_by BIGINT UNSIGNED NULL COMMENT '提交解决资料的操作人ID;未解决时为空', resolved_at DATETIME(3) NULL COMMENT '补件请求解决时间', 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 COMMENT '报关代理提交批次逻辑ID', customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID', batch_no VARCHAR(40) NOT NULL COMMENT '全局唯一代理提交批次号',
batch_version INT UNSIGNED NOT NULL COMMENT '同一报关协同单内的提交批次版本序号', customs_agent_id BIGINT UNSIGNED NULL COMMENT '本批次报关代理Supplier逻辑ID快照;历史数据无法推导时为空', customs_agent_name VARCHAR(128) NOT NULL COMMENT '本批次接收报关资料的报关代理名称快照', external_reference VARCHAR(128) NULL COMMENT '报关代理或外部系统返回的业务参考号',
file_snapshot_json JSON NOT NULL COMMENT '提交时冻结的已审核资料数组;元素包含documentId、fileId、itemCode、documentVersion和fileHash', status VARCHAR(16) NOT NULL DEFAULT 'SUBMITTED' COMMENT '代理提交状态:SUBMITTED已提交、ACKNOWLEDGED代理已确认、REJECTED代理已拒绝、COMPLETED代理处理完成', note VARCHAR(500) NULL COMMENT '提交报关代理时的平台备注',
submitted_by BIGINT UNSIGNED NOT NULL COMMENT '提交报关代理的平台操作人ID', submitted_by_name VARCHAR(64) NULL COMMENT '提交时的平台操作人名称快照',
submitted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '提交报关代理时间', 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='报关代理提交批次及冻结文件版本';
-- 历史代理名称无法安全反推Supplier ID,旧行保持NULL;新写入由服务端校验并持久化。
DROP PROCEDURE IF EXISTS phase3_customs_supplier_snapshot_add_column;
DELIMITER $$
CREATE PROCEDURE phase3_customs_supplier_snapshot_add_column()
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='fl_customs_submission_batch'
AND column_name='customs_agent_id') THEN
ALTER TABLE fl_customs_submission_batch
ADD COLUMN customs_agent_id BIGINT UNSIGNED NULL COMMENT '本批次报关代理Supplier逻辑ID快照;历史数据无法推导时为空' AFTER batch_version;
END IF;
END$$
DELIMITER ;
CALL phase3_customs_supplier_snapshot_add_column();
DROP PROCEDURE phase3_customs_supplier_snapshot_add_column;
CREATE TABLE IF NOT EXISTS fl_customs_receipt (
id BIGINT UNSIGNED NOT NULL COMMENT '报关回执文件版本逻辑ID', customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID', submission_batch_id BIGINT UNSIGNED NOT NULL COMMENT '产生本回执的报关代理提交批次ID',
receipt_type VARCHAR(16) NOT NULL COMMENT '回执类型:RECEIVED已收件、DECLARED已申报、REJECTED已拒绝、INSPECTION查验通知、RELEASED放行回执、OTHER其他回执', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务回执文件逻辑ID;跨库不建立外键', receipt_version INT UNSIGNED NOT NULL COMMENT '同一提交批次和回执类型下的版本序号',
replaces_receipt_id BIGINT UNSIGNED NULL COMMENT '被本版本替换的上一回执版本ID;首个版本为空', file_name VARCHAR(255) NOT NULL COMMENT '上传或引用时的回执文件名称快照', file_hash CHAR(64) NOT NULL COMMENT '回执文件内容SHA-256摘要;用于版本审计和幂等校验',
visibility VARCHAR(16) NOT NULL DEFAULT 'PLATFORM' COMMENT '回执可见范围:PLATFORM仅平台可见、CUSTOMER客户可查询下载', note VARCHAR(500) NULL COMMENT '回执业务备注', uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传或引用回执的平台操作人ID',
uploaded_by_name VARCHAR(64) NULL COMMENT '上传回执时的平台操作人名称快照', uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '回执上传或引用时间', 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 COMMENT '报关协同历史逻辑ID', customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID', action VARCHAR(40) NOT NULL COMMENT '业务动作编码,如CREATE、UPLOAD_DOCUMENT、REPLACE_DOCUMENT、REVIEW_DOCUMENT_APPROVE、REVIEW_DOCUMENT_RETURN、SUBMIT_AGENT_BATCH或UPLOAD_RECEIPT',
from_status VARCHAR(32) NULL COMMENT '动作发生前的协同状态;创建动作时为空', to_status VARCHAR(32) NULL COMMENT '动作完成后的协同状态', resource_type VARCHAR(32) NOT NULL COMMENT '动作关联资源类型:CASE协同单、DOCUMENT资料、SUBMISSION_BATCH提交批次或RECEIPT回执',
resource_id BIGINT UNSIGNED NOT NULL COMMENT '动作关联资源逻辑ID', note VARCHAR(500) NULL COMMENT '动作原因、资料项目编码或业务备注快照', operator_id BIGINT UNSIGNED NOT NULL COMMENT '执行业务动作的操作人ID',
operator_name VARCHAR(64) NULL COMMENT '执行动作时的操作人名称快照', operator_type VARCHAR(16) NOT NULL COMMENT '操作主体类型:PLATFORM平台操作人、MERCHANT客户操作人',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史记录创建时间', 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 COMMENT '报关写命令幂等记录逻辑ID', operator_id BIGINT UNSIGNED NOT NULL COMMENT '发起写命令的操作人ID;与动作和幂等键共同限定重放范围', action VARCHAR(64) NOT NULL COMMENT '幂等业务动作编码,包含平台或客户动作及资料、审核、提交、回执类型',
idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端提供的幂等键', request_hash CHAR(64) NOT NULL COMMENT '规范化完整请求内容的SHA-256摘要;同键不同请求时拒绝',
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '幂等处理状态:PROCESSING处理中、SUCCESS处理成功', result_type VARCHAR(24) NULL COMMENT '成功结果资源类型:CHECKLIST、CASE、DOCUMENT、BATCH或RECEIPT', result_id BIGINT UNSIGNED NULL COMMENT '成功结果资源逻辑ID;处理中为空',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等记录创建时间', completed_at DATETIME(3) NULL COMMENT '写命令成功完成时间;处理中为空', 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; USE baiyue_iam;
DROP PROCEDURE IF EXISTS assert_phase3_customs_permission_ids; DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102171,5102173,5102174,5102175);
DELIMITER $$ DELETE FROM iam_permission WHERE id IN (5102171,5102173,5102174,5102175)
CREATE PROCEDURE assert_phase3_customs_permission_ids() AND permission_code IN ('platform:firstleg:customs:view','platform:firstleg:customs:review','platform:firstleg:customs:submit','platform:firstleg:customs:receipt');
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 INSERT INTO iam_permission
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name, (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
route_path,http_method,sort_no,status,built_in,deleted)
VALUES VALUES
(5102170,0,'PLATFORM','MENU',5102000,'platform:firstleg:customs:list','报关协同','/first-leg/customs','GET',100,'ACTIVE',1,0), (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)
(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), 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), 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; 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) 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 SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission
ON permission.tenant_id=0 AND permission.app_code='PLATFORM' ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.permission_code LIKE 'platform:firstleg:customs:%' AND permission.permission_code IN ('platform:firstleg:customs:list','platform:firstleg:customs:manage')
WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR'); WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR');
USE baiyue_firstleg; USE baiyue_firstleg;
-- Phase 3 loading evidence hardening for existing databases. Evidence is append-only: -- Phase 3 loading evidence hardening for existing databases. Evidence is append-only:
-- multiple active/void records of the same type are allowed; voiding is an audit action. -- multiple active/void records of the same type are allowed; voiding is an audit action.
USE baiyue_firstleg; USE baiyue_firstleg;
@@ -10140,11 +9992,12 @@ BEGIN
IF (SELECT COUNT(*) FROM information_schema.tables IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema='baiyue_firstleg' AND table_name IN ( WHERE table_schema='baiyue_firstleg' AND table_name IN (
'fl_outbound_dispatch','fl_outbound_dispatch_history','fl_outbound_dispatch_operation', '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_container','fl_customs_customer_document','fl_customs_customer_document_inbound',
'fl_customs_submission_batch','fl_customs_receipt','fl_customs_history','fl_customs_operation', 'fl_customs_customer_document_version','fl_customs_frozen_document_inbound',
'fl_customs_completion_document','fl_customs_completion_document_version','fl_customs_container_history','fl_customs_command',
'fl_port_schedule','fl_port_schedule_history','fl_port_schedule_operation', 'fl_port_schedule','fl_port_schedule_history','fl_port_schedule_operation',
'fl_preload_loading_plan_link','fl_loading_pick_assignment','fl_loading_pick_review', 'fl_preload_loading_plan_link','fl_loading_pick_assignment','fl_loading_pick_review',
'fl_loading_pick_operation')) <> 18 THEN 'fl_loading_pick_operation')) <> 19 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期出仓、报关或船期表未完整创建'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期出仓、报关或船期表未完整创建';
END IF; END IF;
IF (SELECT COUNT(*) FROM information_schema.columns IF (SELECT COUNT(*) FROM information_schema.columns
@@ -10173,8 +10026,8 @@ BEGIN
WHERE table_schema='baiyue_firstleg' AND table_name='fl_outbound_dispatch' WHERE table_schema='baiyue_firstleg' AND table_name='fl_outbound_dispatch'
AND index_name='uk_outbound_dispatch_owner' AND non_unique=0) <> 3 AND index_name='uk_outbound_dispatch_owner' AND non_unique=0) <> 3
OR (SELECT COUNT(*) FROM information_schema.statistics OR (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema='baiyue_firstleg' AND table_name='fl_customs_case' WHERE table_schema='baiyue_firstleg' AND table_name='fl_customs_container'
AND index_name='uk_customs_case_scope' AND non_unique=0) <> 4 AND index_name='uk_customs_container_plan' AND non_unique=0) <> 1
OR (SELECT COUNT(*) FROM information_schema.statistics OR (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema='baiyue_firstleg' AND table_name='fl_port_schedule' WHERE table_schema='baiyue_firstleg' AND table_name='fl_port_schedule'
AND index_name='uk_port_schedule_source' AND non_unique=0) <> 3 THEN AND index_name='uk_port_schedule_source' AND non_unique=0) <> 3 THEN
@@ -10182,9 +10035,9 @@ BEGIN
END IF; END IF;
IF (SELECT COUNT(*) FROM information_schema.referential_constraints IF (SELECT COUNT(*) FROM information_schema.referential_constraints
WHERE constraint_schema='baiyue_firstleg' AND constraint_name IN ( WHERE constraint_schema='baiyue_firstleg' AND constraint_name IN (
'fk_outbound_dispatch_inbound','fk_outbound_dispatch_container','fk_customs_case_checklist', 'fk_outbound_dispatch_inbound','fk_outbound_dispatch_container','fk_customs_customer_document_container',
'fk_customs_document_case','fk_customs_supplement_case','fk_customs_batch_case', 'fk_customs_document_inbound_document','fk_customs_customer_document_version','fk_customs_frozen_document',
'fk_customs_receipt_case','fk_customs_receipt_batch','fk_customs_history_case', 'fk_customs_completion_source','fk_customs_completion_version','fk_customs_container_history',
'fk_port_schedule_booking','fk_port_schedule_container','fk_port_schedule_history', 'fk_port_schedule_booking','fk_port_schedule_container','fk_port_schedule_history',
'fk_port_schedule_operation','fk_preload_loading_inbound','fk_preload_loading_preload', 'fk_port_schedule_operation','fk_preload_loading_inbound','fk_preload_loading_preload',
'fk_preload_loading_plan','fk_loading_pick_assignment_task','fk_loading_pick_review_task', 'fk_preload_loading_plan','fk_loading_pick_assignment_task','fk_loading_pick_review_task',
@@ -10202,7 +10055,7 @@ BEGIN
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' 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 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' 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 AND id IN (5102170,5102172) AND permission_code LIKE 'platform:firstleg:customs:%') <> 2
OR (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' 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:%') <> 3 THEN AND id BETWEEN 5102180 AND 5102183 AND permission_code LIKE 'platform:firstleg:port-schedule:%') <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期出仓、报关或船期权限未完整写入'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期出仓、报关或船期权限未完整写入';
@@ -10219,7 +10072,7 @@ BEGIN
AND (p.permission_code LIKE 'platform:firstleg:outbound-dispatch:%' AND (p.permission_code LIKE 'platform:firstleg:outbound-dispatch:%'
OR p.permission_code LIKE 'platform:firstleg:customs:%' OR p.permission_code LIKE 'platform:firstleg:customs:%'
OR p.permission_code LIKE 'platform:firstleg:port-schedule:%' OR p.permission_code LIKE 'platform:firstleg:port-schedule:%'
OR p.permission_code LIKE 'platform:firstleg:pda:%')) <> 36 THEN OR p.permission_code LIKE 'platform:firstleg:pda:%')) <> 28 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期出仓、报关或船期角色授权未完整写入'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期出仓、报关或船期角色授权未完整写入';
END IF; END IF;
END$$ END$$
@@ -12229,14 +12082,6 @@ CALL queue_table_comment('baiyue_firstleg', 'fl_container_document', '平台货
CALL queue_table_comment('baiyue_firstleg', 'fl_container_fee_detail', '柜次级人工应收应付费用来源;审核通过且参与结算后同步Finance'); CALL queue_table_comment('baiyue_firstleg', 'fl_container_fee_detail', '柜次级人工应收应付费用来源;审核通过且参与结算后同步Finance');
CALL queue_table_comment('baiyue_firstleg', 'fl_customer_charge_rule', '客户头程计费规则版本'); CALL queue_table_comment('baiyue_firstleg', 'fl_customer_charge_rule', '客户头程计费规则版本');
CALL queue_table_comment('baiyue_firstleg', 'fl_customer_dimension_config', '客户固定四箱规尺寸配置'); CALL queue_table_comment('baiyue_firstleg', 'fl_customer_dimension_config', '客户固定四箱规尺寸配置');
CALL queue_table_comment('baiyue_firstleg', 'fl_customs_case', '三期订单报关协同聚合');
CALL queue_table_comment('baiyue_firstleg', 'fl_customs_checklist_version', '三期报关资料清单不可变版本');
CALL queue_table_comment('baiyue_firstleg', 'fl_customs_document', '报关客户文件版本引用');
CALL queue_table_comment('baiyue_firstleg', 'fl_customs_history', '报关协同稳定历史');
CALL queue_table_comment('baiyue_firstleg', 'fl_customs_operation', '报关协同命令幂等记录');
CALL queue_table_comment('baiyue_firstleg', 'fl_customs_receipt', '报关代理回执版本与客户可见范围');
CALL queue_table_comment('baiyue_firstleg', 'fl_customs_submission_batch', '报关代理提交批次及冻结文件版本');
CALL queue_table_comment('baiyue_firstleg', 'fl_customs_supplement_request', '报关补件请求');
CALL queue_table_comment('baiyue_firstleg', 'fl_fee_item', '平台公共费用项目主数据'); CALL queue_table_comment('baiyue_firstleg', 'fl_fee_item', '平台公共费用项目主数据');
CALL queue_table_comment('baiyue_firstleg', 'fl_fulfillment_task', '头程履约任务表:接收订单事件后形成独立履约任务,管理指派、执行和完成状态'); CALL queue_table_comment('baiyue_firstleg', 'fl_fulfillment_task', '头程履约任务表:接收订单事件后形成独立履约任务,管理指派、执行和完成状态');
CALL queue_table_comment('baiyue_firstleg', 'fl_inbound_carton_label', '首次打印前冻结的实际箱号与订单货物明细映射'); CALL queue_table_comment('baiyue_firstleg', 'fl_inbound_carton_label', '首次打印前冻结的实际箱号与订单货物明细映射');
@@ -12950,7 +12795,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'warehouse_
CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'warehouse_name', '装柜仓库名称快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'warehouse_name', '装柜仓库名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'container_booking_id', '货柜预订ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'container_booking_id', '货柜预订ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'created_at', '创建时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'created_at', '创建时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'document_type', '文件类型:DESTINATION_CLEARANCE目的港清关、CUSTOMS_DECLARATION出口报关、DELIVERY_ORDER提货单、PICKUP_ORDER取货单'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'document_type', '文件类型:DESTINATION_CLEARANCE目的港清关、DELIVERY_ORDER提货单、PICKUP_ORDER取货单');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'file_id', 'File服务文件ID,不建立跨库外键'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'file_id', 'File服务文件ID,不建立跨库外键');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'file_name', '文件名称快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'file_name', '文件名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'file_url', '文件访问地址快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'file_url', '文件访问地址快照');
@@ -13024,114 +12869,6 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'te
CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'updated_at', '更新时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'updated_at', '更新时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'updated_by', '最后修改人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'updated_by', '最后修改人ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'version', '乐观锁版本号'); CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'version', '乐观锁版本号');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'checklist_code', '创建协同单时的清单编码快照;源清单变化不回写');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'checklist_version_id', '本单冻结使用的报关资料清单版本ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'checklist_version_no', '创建协同单时的清单版本号快照;源清单变化不回写');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'container_booking_id', '关联柜次逻辑ID;0表示创建时尚未关联柜次');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'created_at', '协同单创建时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'created_by', '创建协同单的平台操作人ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'created_by_name', '创建协同单时的平台操作人名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'customs_no', '全局唯一报关协同单号');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'id', '报关协同单逻辑ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'inbound_id', 'Firstleg入库单逻辑ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'order_id', 'Order服务订单逻辑ID;跨库不建立外键');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'remark', '报关协同单内部业务备注');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'status', '协同状态:DOCUMENTS_PENDING待补资料、REVIEWING审核中、SUPPLEMENT_REQUIRED需补件、READY_TO_SUBMIT待提交代理、RECEIPT_PENDING待回执、RECEIPT_AVAILABLE已有回执、CLEARANCE_COMPLETED已放行、CANCELLED已取消');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'tenant_id', '客户ID和数据隔离键');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'updated_at', '协同单最后修改时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'updated_by', '最后修改协同单的平台操作人ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'updated_by_name', '最后修改时的平台操作人名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'version', '乐观锁版本号;每次状态变更递增');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_checklist_version', 'cargo_type', '清单适用的货物类型编码');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_checklist_version', 'channel_code', '清单适用的运输渠道编码');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_checklist_version', 'checklist_code', '报关资料清单编码;同一清单的多个版本共用');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_checklist_version', 'country_code', '清单适用的目的国家或地区编码');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_checklist_version', 'created_at', '清单版本创建时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_checklist_version', 'created_by', '创建清单版本的平台操作人ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_checklist_version', 'created_by_name', '创建清单版本时的平台操作人名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_checklist_version', 'id', '报关资料清单版本逻辑ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_checklist_version', 'items_json', '不可变清单项目数组;元素包含code项目编码、name项目名称和required是否必填');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_checklist_version', 'status', '清单版本状态:ACTIVE启用、RETIRED停用');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_checklist_version', 'version_no', '清单业务版本号');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'checklist_item_code', '对应冻结清单中的资料项目编码');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'customs_case_id', '所属报关协同单ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'document_version', '同一协同单和清单项目下的文件版本序号');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'file_hash', '文件内容SHA-256摘要;用于版本审计和幂等校验');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'file_id', 'File服务文件逻辑ID;跨库不建立外键');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'file_name', '上传或引用时的文件名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'id', '报关资料文件版本逻辑ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'replaces_document_id', '被本版本替换的上一资料版本ID;首个版本为空');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'review_reason', '审核退回原因或审核备注');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'reviewed_at', '资料审核时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'reviewed_by', '审核资料的平台操作人ID;未审核时为空');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'reviewed_by_name', '审核时的平台操作人名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'status', '资料状态:UPLOADED待审核、APPROVED审核通过、RETURNED已退回、SUPERSEDED已被替换、FROZEN已随代理提交批次冻结');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'uploaded_at', '资料上传或引用时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'uploaded_by', '上传或引用资料的操作人ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'uploaded_by_type', '上传主体类型:PLATFORM平台操作人、MERCHANT客户操作人');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'visibility', '资料可见范围:PLATFORM仅平台可见、CUSTOMER客户可见');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_history', 'action', '业务动作编码,如CREATE、UPLOAD_DOCUMENT、REPLACE_DOCUMENT、REVIEW_DOCUMENT_APPROVE、REVIEW_DOCUMENT_RETURN、SUBMIT_AGENT_BATCH或UPLOAD_RECEIPT');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_history', 'created_at', '历史记录创建时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_history', 'customs_case_id', '所属报关协同单ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_history', 'from_status', '动作发生前的协同状态;创建动作时为空');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_history', 'id', '报关协同历史逻辑ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_history', 'note', '动作原因、资料项目编码或业务备注快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_history', 'operator_id', '执行业务动作的操作人ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_history', 'operator_name', '执行动作时的操作人名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_history', 'operator_type', '操作主体类型:PLATFORM平台操作人、MERCHANT客户操作人');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_history', 'resource_id', '动作关联资源逻辑ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_history', 'resource_type', '动作关联资源类型:CASE协同单、DOCUMENT资料、SUBMISSION_BATCH提交批次或RECEIPT回执');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_history', 'to_status', '动作完成后的协同状态');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_operation', 'action', '幂等业务动作编码,包含平台或客户动作及资料、审核、提交、回执类型');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_operation', 'completed_at', '写命令成功完成时间;处理中为空');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_operation', 'created_at', '幂等记录创建时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_operation', 'id', '报关写命令幂等记录逻辑ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_operation', 'idempotency_key', '客户端提供的幂等键');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_operation', 'operator_id', '发起写命令的操作人ID;与动作和幂等键共同限定重放范围');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_operation', 'request_hash', '规范化完整请求内容的SHA-256摘要;同键不同请求时拒绝');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_operation', 'result_id', '成功结果资源逻辑ID;处理中为空');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_operation', 'result_type', '成功结果资源类型:CHECKLIST、CASE、DOCUMENT、BATCH或RECEIPT');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_operation', 'status', '幂等处理状态:PROCESSING处理中、SUCCESS处理成功');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_receipt', 'customs_case_id', '所属报关协同单ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_receipt', 'file_hash', '回执文件内容SHA-256摘要;用于版本审计和幂等校验');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_receipt', 'file_id', 'File服务回执文件逻辑ID;跨库不建立外键');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_receipt', 'file_name', '上传或引用时的回执文件名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_receipt', 'id', '报关回执文件版本逻辑ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_receipt', 'note', '回执业务备注');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_receipt', 'receipt_type', '回执类型:RECEIVED已收件、DECLARED已申报、REJECTED已拒绝、INSPECTION查验通知、RELEASED放行回执、OTHER其他回执');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_receipt', 'receipt_version', '同一提交批次和回执类型下的版本序号');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_receipt', 'replaces_receipt_id', '被本版本替换的上一回执版本ID;首个版本为空');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_receipt', 'submission_batch_id', '产生本回执的报关代理提交批次ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_receipt', 'uploaded_at', '回执上传或引用时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_receipt', 'uploaded_by', '上传或引用回执的平台操作人ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_receipt', 'uploaded_by_name', '上传回执时的平台操作人名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_receipt', 'visibility', '回执可见范围:PLATFORM仅平台可见、CUSTOMER客户可查询下载');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'batch_no', '全局唯一代理提交批次号');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'batch_version', '同一报关协同单内的提交批次版本序号');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'customs_agent_name', '本批次接收报关资料的报关代理名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'customs_agent_id', '本批次报关代理Supplier逻辑ID快照;历史数据无法推导时为空');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'customs_case_id', '所属报关协同单ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'external_reference', '报关代理或外部系统返回的业务参考号');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'file_snapshot_json', '提交时冻结的已审核资料数组;元素包含documentId、fileId、itemCode、documentVersion和fileHash');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'id', '报关代理提交批次逻辑ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'note', '提交报关代理时的平台备注');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'status', '代理提交状态:SUBMITTED已提交、ACKNOWLEDGED代理已确认、REJECTED代理已拒绝、COMPLETED代理处理完成');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'submitted_at', '提交报关代理时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'submitted_by', '提交报关代理的平台操作人ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'submitted_by_name', '提交时的平台操作人名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_supplement_request', 'checklist_item_code', '需要补充的报关资料项目编码');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_supplement_request', 'customs_case_id', '所属报关协同单ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_supplement_request', 'due_at', '期望完成补件时间;未设置时为空');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_supplement_request', 'id', '报关补件请求逻辑ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_supplement_request', 'reason', '平台审核人员填写的补件原因');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_supplement_request', 'requested_at', '补件请求发起时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_supplement_request', 'requested_by', '发起补件的平台操作人ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_supplement_request', 'requested_by_name', '发起补件时的平台操作人名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_supplement_request', 'resolved_at', '补件请求解决时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_supplement_request', 'resolved_by', '提交解决资料的操作人ID;未解决时为空');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_supplement_request', 'resolved_document_id', '解决本次补件的新资料版本ID;未解决时为空');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_supplement_request', 'source_document_id', '触发本次补件的被退回资料版本ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_customs_supplement_request', 'status', '补件状态:OPEN待补件、RESOLVED已由新资料解决、CANCELLED已取消');
CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'allocation_fee', '平摊费用是否参与'); CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'allocation_fee', '平摊费用是否参与');
CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'category', '费用分类'); CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'category', '费用分类');
CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'created_at', '创建时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'created_at', '创建时间');
@@ -15821,7 +15558,7 @@ BEGIN
END IF; END IF;
END$$ END$$
DELIMITER ; DELIMITER ;
-- 货柜清关与装柜清单报关采用同一份轻量文件事实,不再要求独立报关 case 状态机。 -- 货柜目的港清关与DO/PU采用轻量文件事实;出口报关由customs-v1整柜聚合唯一承载。
USE baiyue_firstleg; USE baiyue_firstleg;
DROP PROCEDURE IF EXISTS phase3_simple_customs_add_column; DROP PROCEDURE IF EXISTS phase3_simple_customs_add_column;
DELIMITER $$ DELIMITER $$
@@ -15848,7 +15585,7 @@ SET @ddl = IF(@has_simple_customs_check=0, 'SELECT 1',
'ALTER TABLE fl_container_document DROP CHECK chk_container_document_type'); 'ALTER TABLE fl_container_document DROP CHECK chk_container_document_type');
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement; PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
ALTER TABLE fl_container_document ADD CONSTRAINT chk_container_document_type ALTER TABLE fl_container_document ADD CONSTRAINT chk_container_document_type
CHECK (document_type IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION','DELIVERY_ORDER','PICKUP_ORDER')); CHECK (document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER'));
DROP TRIGGER IF EXISTS trg_container_document_before_insert; DROP TRIGGER IF EXISTS trg_container_document_before_insert;
DELIMITER $$ DELIMITER $$
@@ -15856,7 +15593,7 @@ CREATE TRIGGER trg_container_document_before_insert BEFORE INSERT ON fl_containe
BEGIN BEGIN
DECLARE current_plan_status VARCHAR(24); DECLARE current_plan_status VARCHAR(24);
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id=NEW.container_booking_id; SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id=NEW.container_booking_id;
IF current_plan_status='COMPLETED' AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION','DELIVERY_ORDER','PICKUP_ORDER') THEN IF current_plan_status='COMPLETED' AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜物流文件不可新增'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜物流文件不可新增';
END IF; END IF;
END$$ END$$
@@ -15867,7 +15604,7 @@ CREATE TRIGGER trg_container_document_before_update BEFORE UPDATE ON fl_containe
BEGIN BEGIN
DECLARE current_plan_status VARCHAR(24); DECLARE current_plan_status VARCHAR(24);
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id=OLD.container_booking_id; SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id=OLD.container_booking_id;
IF current_plan_status='COMPLETED' AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION','DELIVERY_ORDER','PICKUP_ORDER') THEN IF current_plan_status='COMPLETED' AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜物流文件不可修改'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜物流文件不可修改';
END IF; END IF;
END$$ END$$
@@ -16603,6 +16340,77 @@ BEGIN
END$$ END$$
DELIMITER ; DELIMITER ;
-- 最新整柜报关基线:装柜完成后收集退税资料,按柜冻结、回传并自动收敛离仓。
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_customs_container(
id BIGINT UNSIGNED NOT NULL,loading_plan_id BIGINT UNSIGNED NOT NULL,container_booking_id BIGINT UNSIGNED NOT NULL,
status VARCHAR(40) NOT NULL,started_by BIGINT UNSIGNED NULL,started_by_name VARCHAR(64) NULL,started_at DATETIME(3) NULL,
completed_by BIGINT UNSIGNED NULL,completed_by_name VARCHAR(64) NULL,completed_at DATETIME(3) 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),
version INT UNSIGNED NOT NULL DEFAULT 0,PRIMARY KEY(id),UNIQUE KEY uk_customs_container_plan(loading_plan_id),
UNIQUE KEY uk_customs_container_booking(container_booking_id),KEY idx_customs_container_status(status,updated_at),
CONSTRAINT chk_customs_container_status CHECK(status IN('WAITING_CUSTOMER_DOCUMENTS','READY_TO_START','DECLARING','COMPLETED'))
)ENGINE=InnoDB COMMENT='装柜完成后的整柜出口报关聚合';
CREATE TABLE IF NOT EXISTS fl_customs_customer_document(
id BIGINT UNSIGNED NOT NULL,customs_container_id BIGINT UNSIGNED NOT NULL,tenant_id BIGINT UNSIGNED NOT NULL,
file_id BIGINT UNSIGNED NOT NULL,file_name VARCHAR(255) NOT NULL,file_hash CHAR(64) NOT NULL,content_type VARCHAR(128) NULL,
size_bytes BIGINT UNSIGNED NOT NULL,status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',uploaded_by BIGINT UNSIGNED NOT NULL,
uploaded_at DATETIME(3) NOT NULL,frozen_version INT UNSIGNED NULL,frozen_at DATETIME(3) NULL,deleted_by BIGINT UNSIGNED NULL,
deleted_at DATETIME(3) NULL,version INT UNSIGNED NOT NULL DEFAULT 1,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),
KEY idx_customs_customer_document_container(customs_container_id,status,tenant_id),
CONSTRAINT fk_customs_customer_document_container FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id),
CONSTRAINT chk_customs_customer_document_status CHECK(status IN('ACTIVE','DELETED')),
CONSTRAINT chk_customs_customer_document_freeze CHECK((frozen_at IS NULL AND frozen_version IS NULL)OR(frozen_at IS NOT NULL AND frozen_version IS NOT NULL))
)ENGINE=InnoDB COMMENT='客户整份退税报送资料逻辑根';
CREATE TABLE IF NOT EXISTS fl_customs_customer_document_inbound(
document_id BIGINT UNSIGNED NOT NULL,inbound_id BIGINT UNSIGNED NOT NULL,tenant_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,inbound_code VARCHAR(40) NOT NULL,created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(document_id,inbound_id),KEY idx_customs_document_inbound(inbound_id,tenant_id),
CONSTRAINT fk_customs_document_inbound_document FOREIGN KEY(document_id) REFERENCES fl_customs_customer_document(id)
)ENGINE=InnoDB COMMENT='客户报送资料与当前柜退税入仓码多对多关系';
CREATE TABLE IF NOT EXISTS fl_customs_customer_document_version(
id BIGINT UNSIGNED NOT NULL,document_id BIGINT UNSIGNED NOT NULL,document_version INT UNSIGNED NOT NULL,file_id BIGINT UNSIGNED NOT NULL,
file_name VARCHAR(255) NOT NULL,file_hash CHAR(64) NOT NULL,content_type VARCHAR(128) NULL,size_bytes BIGINT UNSIGNED NOT NULL,
scope_snapshot_json JSON NOT NULL,action VARCHAR(24) NOT NULL,operator_id BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),PRIMARY KEY(id),UNIQUE KEY uk_customs_customer_document_version(document_id,document_version),
CONSTRAINT fk_customs_customer_document_version FOREIGN KEY(document_id) REFERENCES fl_customs_customer_document(id),
CONSTRAINT chk_customs_customer_document_version_action CHECK(action IN('UPLOAD','REPLACE','SCOPE_CHANGE','DELETE'))
)ENGINE=InnoDB COMMENT='客户报送资料文件与覆盖范围不可变版本';
CREATE TABLE IF NOT EXISTS fl_customs_frozen_document_inbound(
document_id BIGINT UNSIGNED NOT NULL,inbound_id BIGINT UNSIGNED NOT NULL,tenant_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,inbound_code VARCHAR(40) NOT NULL,created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(document_id,inbound_id),CONSTRAINT fk_customs_frozen_document FOREIGN KEY(document_id) REFERENCES fl_customs_customer_document(id)
)ENGINE=InnoDB COMMENT='开始报关时冻结的客户资料覆盖范围';
CREATE TABLE IF NOT EXISTS fl_customs_completion_document(
id BIGINT UNSIGNED NOT NULL,source_document_id BIGINT UNSIGNED NOT NULL,file_id BIGINT UNSIGNED NOT NULL,
file_name VARCHAR(255) NOT NULL,file_hash CHAR(64) NOT NULL,content_type VARCHAR(128) NULL,size_bytes BIGINT UNSIGNED NOT NULL,
uploaded_by BIGINT UNSIGNED NOT NULL,uploaded_by_name VARCHAR(64) NULL,uploaded_at DATETIME(3) NOT NULL,version INT UNSIGNED NOT NULL DEFAULT 1,
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_customs_completion_source(source_document_id),
CONSTRAINT fk_customs_completion_source FOREIGN KEY(source_document_id) REFERENCES fl_customs_customer_document(id)
)ENGINE=InnoDB COMMENT='报关完成文件当前版本';
CREATE TABLE IF NOT EXISTS fl_customs_completion_document_version(
id BIGINT UNSIGNED NOT NULL,completion_document_id BIGINT UNSIGNED NOT NULL,document_version INT UNSIGNED NOT NULL,
file_id BIGINT UNSIGNED NOT NULL,file_name VARCHAR(255) NOT NULL,file_hash CHAR(64) NOT NULL,content_type VARCHAR(128) NULL,
size_bytes BIGINT UNSIGNED 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),
UNIQUE KEY uk_customs_completion_version(completion_document_id,document_version),
CONSTRAINT fk_customs_completion_version FOREIGN KEY(completion_document_id) REFERENCES fl_customs_completion_document(id)
)ENGINE=InnoDB COMMENT='报关完成文件不可变覆盖版本';
CREATE TABLE IF NOT EXISTS fl_customs_container_history(
id BIGINT UNSIGNED NOT NULL,customs_container_id BIGINT UNSIGNED NOT NULL,action VARCHAR(40) NOT NULL,
from_status VARCHAR(40) NULL,to_status VARCHAR(40) 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_customs_container_history(customs_container_id,created_at,id),
CONSTRAINT fk_customs_container_history FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id)
)ENGINE=InnoDB COMMENT='整柜报关状态与系统触发历史';
CREATE TABLE IF NOT EXISTS fl_customs_command(
id BIGINT UNSIGNED NOT NULL,actor_id BIGINT UNSIGNED NOT NULL,action VARCHAR(80) NOT NULL,idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,status VARCHAR(16) NOT 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_command(actor_id,action,idempotency_key),CONSTRAINT chk_customs_command_status CHECK(status IN('PROCESSING','SUCCESS'))
)ENGINE=InnoDB COMMENT='整柜报关写命令幂等事实';
CALL baiyue_firstleg.assert_unified_final_schema(); CALL baiyue_firstleg.assert_unified_final_schema();
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
SELECT 'PASS' AS migration_status, SELECT 'PASS' AS migration_status,
+2
View File
@@ -15,6 +15,7 @@
| 平台订单商品图片读取 | `controller/PlatformProductImageController.java` 的 `/api/platform/v1/files/product-images/{tenantId}/{fileId}`、`service/ProductImageService.java`;仅平台 `platform:order:list` 可按明确客户与文件 ID 内联读取,响应 `no-store` | | 平台订单商品图片读取 | `controller/PlatformProductImageController.java` 的 `/api/platform/v1/files/product-images/{tenantId}/{fileId}`、`service/ProductImageService.java`;仅平台 `platform:order:list` 可按明确客户与文件 ID 内联读取,响应 `no-store` |
| 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` | | 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` |
| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;业务类型分别为 `FINANCE_VOUCHER`、`OVERSEAS_UNLOAD_PHOTO`,必须按客户与业务 ID 精确校验 | | 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;业务类型分别为 `FINANCE_VOUCHER`、`OVERSEAS_UNLOAD_PHOTO`,必须按客户与业务 ID 精确校验 |
| 报关源文件、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 |
| 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` | | 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` |
| 下游 OCR | `client/WeightOcrClient.java` | | 下游 OCR | `client/WeightOcrClient.java` |
| SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` | | SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` |
@@ -27,6 +28,7 @@
- 商品图片平台读取必须同时校验平台身份、`platform:order:list`、客户范围、文件业务类型与正整数 ID;不得提供匿名 URL、跨客户读取或把内部存储路径返回页面。 - 商品图片平台读取必须同时校验平台身份、`platform:order:list`、客户范围、文件业务类型与正整数 ID;不得提供匿名 URL、跨客户读取或把内部存储路径返回页面。
- 公共导出只接受 `ExportDefinitionRegistry` 明确登记的 `exportType`,由 File 调用所属业务服务的内部 Provider 分批取数并生成文件;业务页面不得直连 Provider。新增或修改导出类型时同步检查业务服务 Provider、File Feign 客户端/响应上限、权限、Admin 下载中心标签与重试映射及契约测试。 - 公共导出只接受 `ExportDefinitionRegistry` 明确登记的 `exportType`,由 File 调用所属业务服务的内部 Provider 分批取数并生成文件;业务页面不得直连 Provider。新增或修改导出类型时同步检查业务服务 Provider、File Feign 客户端/响应上限、权限、Admin 下载中心标签与重试映射及契约测试。
- 生产环境显式配置 `FILE_STORAGE_ROOT`;磁盘故障返回 `S0503`,不得包装成业务错误。 - 生产环境显式配置 `FILE_STORAGE_ROOT`;磁盘故障返回 `S0503`,不得包装成业务错误。
- 报关源文件和完成文件的 `businessId` 固定为 loadingPlanId,File DTO 仍为 `Metadata(fileId, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes)`,其中 `businessType` 即 fileCategory;Firstleg 保存业务文件版本并决定用户入口。权限固定为 Platform 上传结果 `platform:firstleg:customs:manage`、下载 `platform:firstleg:loading-list:list`,Client 上传源文件 `merchant:order:edit`、下载自身源文件或获准结果 `merchant:order:view`。结果还必须满足整柜完成和可见性,买单客户是否存在结果入口同样由 Firstleg 判定,File 不复制这些状态。
## 全量验收导航 ## 全量验收导航
@@ -9,6 +9,7 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Positive;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.CacheControl; import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -65,11 +66,17 @@ public class InternalBusinessFileController {
check(token); check(token);
checkTenantId(tenantId); checkTenantId(tenantId);
var value = service.download(tenantId, businessType, businessId, fileId); var value = service.download(tenantId, businessType, businessId, fileId);
String name = value.fileName().replace("\r", "").replace("\n", "").replace("\"", ""); MediaType contentType;
try {
contentType = MediaType.parseMediaType(value.contentType());
} catch (IllegalArgumentException exception) {
contentType = MediaType.APPLICATION_OCTET_STREAM;
}
return ResponseEntity.ok() return ResponseEntity.ok()
.cacheControl(CacheControl.noStore()) .cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(value.contentType())) .contentType(contentType)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + name + "\"") .header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(value.fileName(), StandardCharsets.UTF_8).build().toString())
.contentLength(value.content().length) .contentLength(value.content().length)
.body(value.content()); .body(value.content());
} }
@@ -0,0 +1,52 @@
package com.baiyue.file.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import java.util.Arrays;
/** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */
enum BusinessFileCategory {
TRUCK_APPOINTMENT_ATTACHMENT(false, false),
CUSTOMS_DOCUMENT(false, false),
CUSTOMS_RECEIPT(false, false),
FINANCE_VOUCHER(false, false),
CONTAINER_CLEARANCE_DOCUMENT(false, false),
LOADING_CUSTOMS_DECLARATION(false, false),
CONTAINER_DELIVERY_ORDER(false, false),
CONTAINER_PICKUP_ORDER(false, false),
OVERSEAS_UNLOAD_PHOTO(true, false),
OVERSEAS_RELABEL_PHOTO(true, false),
LOADING_LIST_CUSTOMS_SOURCE(true, true),
LOADING_LIST_CUSTOMS_RESULT(true, true);
static final long STANDARD_MAX_SIZE = 10L * 1024 * 1024;
static final long CUSTOMS_MAX_SIZE = 50L * 1024 * 1024;
private final boolean customerOwned;
private final boolean arbitraryFormat;
BusinessFileCategory(boolean customerOwned, boolean arbitraryFormat) {
this.customerOwned = customerOwned;
this.arbitraryFormat = arbitraryFormat;
}
static BusinessFileCategory require(String value) {
return Arrays.stream(values()).filter(item -> item.name().equals(value)).findFirst()
.orElseThrow(() -> new BizException(ResultCode.BAD_REQUEST, "不支持的附件业务类型"));
}
void validateOwner(long tenantId) {
if (customerOwned && tenantId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "该附件类型必须归属明确客户");
}
}
long maxSizeBytes() {
return arbitraryFormat ? CUSTOMS_MAX_SIZE : STANDARD_MAX_SIZE;
}
boolean arbitraryFormat() {
return arbitraryFormat;
}
}
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@@ -21,7 +22,6 @@ import java.util.UUID;
@Component @Component
public class BusinessFileStorage { public class BusinessFileStorage {
private static final long MAX_SIZE = 10L * 1024 * 1024;
private static final Map<String, String> TYPES = Map.of( private static final Map<String, String> TYPES = Map.of(
"text/plain", ".txt", "application/pdf", ".pdf", "text/plain", ".txt", "application/pdf", ".pdf",
"image/jpeg", ".jpg", "image/png", ".png"); "image/jpeg", ".jpg", "image/png", ".png");
@@ -35,13 +35,13 @@ public class BusinessFileStorage {
root = configuredRoot.toAbsolutePath().normalize(); root = configuredRoot.toAbsolutePath().normalize();
} }
Stored storeLoading(long tenantId, long businessId, MultipartFile file) { Stored store(long tenantId, long businessId, MultipartFile file, long maxSize, boolean arbitraryFormat) {
if (file == null || file.isEmpty()) throw bad("附件不能为空"); if (file == null || file.isEmpty()) throw bad("附件不能为空");
if (file.getSize() > MAX_SIZE) throw bad("附件不能超过10MB"); if (file.getSize() > maxSize) throw bad("附件不能超过" + maxSize / 1024 / 1024 + "MB");
String type = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT); String type = safeContentType(file.getContentType());
String extension = TYPES.get(type); String extension = arbitraryFormat ? "" : TYPES.get(type);
if (extension == null) throw bad("附件仅支持TXT、PDF、JPG、PNG"); if (!arbitraryFormat && extension == null) throw bad("附件仅支持TXT、PDF、JPG、PNG");
verifyContent(file, type); if (!arbitraryFormat) verifyContent(file, type);
String name = safeName(file); String name = safeName(file);
String objectKey = "business-files/" + tenantId + "/" + businessId + "/" + UUID.randomUUID() + extension; String objectKey = "business-files/" + tenantId + "/" + businessId + "/" + UUID.randomUUID() + extension;
Path target = root.resolve(objectKey).normalize(); Path target = root.resolve(objectKey).normalize();
@@ -55,6 +55,15 @@ public class BusinessFileStorage {
} }
} }
private static String safeContentType(String contentType) {
if (contentType == null || contentType.isBlank()) return MediaType.APPLICATION_OCTET_STREAM_VALUE;
try {
return MediaType.parseMediaType(contentType).toString().toLowerCase(Locale.ROOT);
} catch (IllegalArgumentException exception) {
return MediaType.APPLICATION_OCTET_STREAM_VALUE;
}
}
byte[] read(String objectKey) { byte[] read(String objectKey) {
Path target = root.resolve(objectKey).normalize(); Path target = root.resolve(objectKey).normalize();
if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界"); if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界");
@@ -91,7 +100,12 @@ public class BusinessFileStorage {
private static String safeName(MultipartFile file) { private static String safeName(MultipartFile file) {
String name = file.getOriginalFilename(); String name = file.getOriginalFilename();
return name == null || name.isBlank() ? "attachment" : Path.of(name).getFileName().toString(); if (name == null || name.isBlank()) return "attachment";
String normalized = name.replace('\\', '/');
normalized = normalized.substring(normalized.lastIndexOf('/') + 1)
.replaceAll("[\\p{Cntrl}]", "").trim();
if (normalized.isBlank()) return "attachment";
return normalized.length() <= 255 ? normalized : normalized.substring(normalized.length() - 255);
} }
private static String sha256(Path path) throws IOException { private static String sha256(Path path) throws IOException {
@@ -0,0 +1,47 @@
package com.baiyue.file.service;
import com.baiyue.common.core.export.ExportContracts.ExportColumn;
import com.baiyue.common.core.export.ExportContracts.ExportColumnType;
import java.util.List;
final class ExportSchemaContracts {
static final String BUY_DECLARATION_ITEMS = "LOADING_LIST_BUY_DECLARATION_ITEMS";
static final List<ExpectedColumn> BUY_DECLARATION_COLUMNS = List.of(
column("customerName", "客户名称", ExportColumnType.STRING),
column("orderNo", "订单号", ExportColumnType.STRING),
column("inboundCode", "入仓码", ExportColumnType.STRING),
column("sku", "SKU", ExportColumnType.STRING),
column("chineseName", "中文品名", ExportColumnType.STRING),
column("englishName", "英文品名", ExportColumnType.STRING),
column("cartonCount", "箱数", ExportColumnType.INTEGER),
column("productQuantity", "产品数量", ExportColumnType.INTEGER),
column("grossWeight", "毛重", ExportColumnType.DECIMAL),
column("netWeight", "净重", ExportColumnType.DECIMAL),
column("volume", "体积", ExportColumnType.DECIMAL),
column("mark", "唛头", ExportColumnType.STRING),
column("customerRemark", "客户备注", ExportColumnType.STRING));
private ExportSchemaContracts() { }
static void validate(String exportType, List<ExportColumn> columns) {
if (!BUY_DECLARATION_ITEMS.equals(exportType)) return;
if (columns.size() != BUY_DECLARATION_COLUMNS.size()) {
throw ExportFailure.permanent("买单报关导出列定义不匹配");
}
for (int index = 0; index < columns.size(); index++) {
ExportColumn actual = columns.get(index);
ExpectedColumn expected = BUY_DECLARATION_COLUMNS.get(index);
if (!expected.key.equals(actual.key()) || !expected.title.equals(actual.title())
|| expected.type != actual.type()) {
throw ExportFailure.permanent("买单报关导出列定义不匹配");
}
}
}
private static ExpectedColumn column(String key, String title, ExportColumnType type) {
return new ExpectedColumn(key, title, type);
}
record ExpectedColumn(String key, String title, ExportColumnType type) { }
}
@@ -45,6 +45,7 @@ public class ExportTaskService {
throw new BizException(ResultCode.FORBIDDEN, "缺少该导出类型的操作权限"); throw new BizException(ResultCode.FORBIDDEN, "缺少该导出类型的操作权限");
} }
validateParameters(request.parameters()); validateParameters(request.parameters());
validateTypeParameters(type, request.parameters());
String parametersJson = parametersJson(request.parameters()); String parametersJson = parametersJson(request.parameters());
long id = IdWorker.getId(); long id = IdWorker.getId();
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
@@ -176,6 +177,21 @@ public class ExportTaskService {
throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选值类型无效"); throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选值类型无效");
} }
private void validateTypeParameters(String type, Map<String, Object> parameters) {
if (!ExportSchemaContracts.BUY_DECLARATION_ITEMS.equals(type)) return;
Map<String, Object> values = parameters == null ? Map.of() : parameters;
if (!values.keySet().equals(java.util.Set.of("loadingPlanId"))) {
throw new BizException(ResultCode.VALIDATION_FAILED, "买单报关导出仅接受 loadingPlanId");
}
Object raw = values.get("loadingPlanId");
try {
if (!(raw instanceof String text) || !text.matches("[1-9][0-9]*")
|| Long.parseLong(text) <= 0) throw new NumberFormatException();
} catch (NumberFormatException exception) {
throw new BizException(ResultCode.VALIDATION_FAILED, "loadingPlanId 必须为正整数字符串");
}
}
private ExportTaskResponse response(FileExportTask task) { private ExportTaskResponse response(FileExportTask task) {
return new ExportTaskResponse(String.valueOf(task.getId()), task.getTaskNo(), task.getExportType(), return new ExportTaskResponse(String.valueOf(task.getId()), task.getTaskNo(), task.getExportType(),
task.getFormat(), task.getTaskName(), task.getStatus(), task.getTotalRows(), task.getFormat(), task.getTaskName(), task.getStatus(), task.getTotalRows(),
@@ -88,7 +88,9 @@ public class GenericXlsxExportRunner {
if (batch.schemaVersion() != 1 || blank(batch.schemaHash()) || blank(batch.snapshotToken()) || batch.snapshotToken().length() > 512) { if (batch.schemaVersion() != 1 || blank(batch.schemaHash()) || blank(batch.snapshotToken()) || batch.snapshotToken().length() > 512) {
throw ExportFailure.permanent("Provider 首批契约无效"); throw ExportFailure.permanent("Provider 首批契约无效");
} }
columns = validateColumns(batch.columns()); columnKeys = columns.stream().map(ExportColumn::key).collect(java.util.stream.Collectors.toUnmodifiableSet()); schemaHash = batch.schemaHash(); snapshot = batch.snapshotToken(); columns = validateColumns(batch.columns());
ExportSchemaContracts.validate(task.getExportType(), columns);
columnKeys = columns.stream().map(ExportColumn::key).collect(java.util.stream.Collectors.toUnmodifiableSet()); schemaHash = batch.schemaHash(); snapshot = batch.snapshotToken();
sheet = newSheet(workbook, 1, columns); sheet = newSheet(workbook, 1, columns);
} else if (!schemaHash.equals(batch.schemaHash())) { } else if (!schemaHash.equals(batch.schemaHash())) {
throw ExportFailure.permanent("Provider 导出列定义在执行期间发生变化"); throw ExportFailure.permanent("Provider 导出列定义在执行期间发生变化");
@@ -10,22 +10,8 @@ import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Set;
@Service @Service
public class InternalBusinessFileService { public class InternalBusinessFileService {
private static final Set<String> BUSINESS_TYPES = Set.of(
"TRUCK_APPOINTMENT_ATTACHMENT",
"CUSTOMS_DOCUMENT",
"CUSTOMS_RECEIPT",
"FINANCE_VOUCHER",
"CONTAINER_CLEARANCE_DOCUMENT",
"LOADING_CUSTOMS_DECLARATION",
"CONTAINER_DELIVERY_ORDER",
"CONTAINER_PICKUP_ORDER",
"OVERSEAS_UNLOAD_PHOTO",
"OVERSEAS_RELABEL_PHOTO");
@Resource private FileObjectMapper files; @Resource private FileObjectMapper files;
@Resource private BusinessFileStorage storage; @Resource private BusinessFileStorage storage;
@@ -34,9 +20,10 @@ public class InternalBusinessFileService {
if (tenantId < 0 || operatorId <= 0) { if (tenantId < 0 || operatorId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "归属和操作人必须有效"); throw new BizException(ResultCode.BAD_REQUEST, "归属和操作人必须有效");
} }
validateBusinessType(businessType); BusinessFileCategory category = BusinessFileCategory.require(businessType);
category.validateOwner(tenantId);
long parsedBusinessId = parseBusinessId(businessId); long parsedBusinessId = parseBusinessId(businessId);
var stored = storage.storeLoading(tenantId, parsedBusinessId, file); var stored = storage.store(tenantId, parsedBusinessId, file, category.maxSizeBytes(), category.arbitraryFormat());
FileObjectEntity value = new FileObjectEntity(); FileObjectEntity value = new FileObjectEntity();
value.setTenantId(tenantId); value.setTenantId(tenantId);
value.setBusinessType(businessType); value.setBusinessType(businessType);
@@ -89,16 +76,11 @@ public class InternalBusinessFileService {
if (tenantId < 0 || fileId <= 0) { if (tenantId < 0 || fileId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "归属和文件ID必须有效"); throw new BizException(ResultCode.BAD_REQUEST, "归属和文件ID必须有效");
} }
validateBusinessType(businessType); BusinessFileCategory category = BusinessFileCategory.require(businessType);
category.validateOwner(tenantId);
parseBusinessId(businessId); parseBusinessId(businessId);
} }
private static void validateBusinessType(String businessType) {
if (!BUSINESS_TYPES.contains(businessType)) {
throw new BizException(ResultCode.BAD_REQUEST, "不支持的附件业务类型");
}
}
private static long parseBusinessId(String businessId) { private static long parseBusinessId(String businessId) {
final long value; final long value;
try { try {
+11 -2
View File
@@ -40,8 +40,8 @@ spring:
read-timeout: 15000 read-timeout: 15000
servlet: servlet:
multipart: multipart:
max-file-size: 10MB max-file-size: 50MB
max-request-size: 11MB max-request-size: 51MB
rocketmq: rocketmq:
name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876} name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876}
producer: producer:
@@ -101,6 +101,15 @@ baiyue:
max-rows: 200000 max-rows: 200000
max-attempts: 3 max-attempts: 3
batch-size: 500 batch-size: 500
LOADING_LIST_BUY_DECLARATION_ITEMS:
provider-service: firstleg-service
provider-path: /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches
required-permission: platform:firstleg:customs:manage
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_CUSTOMER_ACCOUNT_LIST: FINANCE_CUSTOMER_ACCOUNT_LIST:
provider-service: finance-service provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
@@ -0,0 +1,154 @@
openapi: 3.0.3
info:
title: File Customs Business File and Export Contract
version: 1.0.0
description: |
File 只保存不可变文件对象并生成异步 XLSX,不保存报关状态或业务版本。
Firstleg 持有 loadingPlanId、文件版本、完成状态和可见性,并在调用本内部接口前完成 Platform/Client 鉴权。
LOADING_LIST_CUSTOMS_SOURCE 与 LOADING_LIST_CUSTOMS_RESULT 均为 CUSTOMER owner scope,tenantId 必须大于 0;
客户源文件上传/下载由 Firstleg 校验当前客户,结果文件下载还须由 Firstleg 校验整柜完成和结果可见性。
用户侧权限冻结为:Platform 上传结果 platform:firstleg:customs:manage,Platform 下载 platform:firstleg:loading-list:list;
Client 上传源文件 merchant:order:edit,Client 下载自身源文件或获准结果 merchant:order:view。
买单客户是否有结果入口由 Firstleg 判定,File 不据此增加结果文件或下载入口。
paths:
/internal/file/v1/business-files/{tenantId}/{fileCategory}/{loadingPlanId}:
post:
summary: 保存报关业务文件对象
description: |
仅服务令牌可调用。fileCategory 取 LOADING_LIST_CUSTOMS_SOURCE 或 LOADING_LIST_CUSTOMS_RESULT。
支持任意文件格式,单文件最多 50MB。operatorId 为已通过 Firstleg 鉴权的实际操作人。
parameters:
- $ref: '#/components/parameters/InternalToken'
- $ref: '#/components/parameters/OperatorId'
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/FileCategory'
- $ref: '#/components/parameters/LoadingPlanId'
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file: {type: string, format: binary}
responses:
'200':
description: ApiResponse<BusinessFileMetadata>
content:
application/json:
schema: {$ref: '#/components/schemas/BusinessFileResponse'}
/internal/file/v1/business-files/{tenantId}/{fileCategory}/{loadingPlanId}/{fileId}:
get:
summary: 校验报关文件对象的精确复合归属
parameters:
- $ref: '#/components/parameters/InternalToken'
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/FileCategory'
- $ref: '#/components/parameters/LoadingPlanId'
- $ref: '#/components/parameters/FileId'
responses:
'200':
description: ApiResponse<BusinessFileMetadata>
content:
application/json:
schema: {$ref: '#/components/schemas/BusinessFileResponse'}
/internal/file/v1/business-files/{tenantId}/{fileCategory}/{loadingPlanId}/{fileId}/content:
get:
summary: 下载精确归属的报关文件对象
description: 响应固定为 attachment 且 Cache-Control 包含 no-store;用户可见性由 Firstleg 在调用前判定。
parameters:
- $ref: '#/components/parameters/InternalToken'
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/FileCategory'
- $ref: '#/components/parameters/LoadingPlanId'
- $ref: '#/components/parameters/FileId'
responses:
'200':
description: 文件内容
headers:
Cache-Control: {schema: {type: string, example: no-store}}
Content-Disposition: {schema: {type: string, example: attachment}}
content:
application/octet-stream:
schema: {type: string, format: binary}
/internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches:
post:
summary: Firstleg 买单报关明细导出 Provider 契约
description: |
此路径由 Firstleg 提供、File 调用。一柜一份;parameters 只能包含正整数 loadingPlanId。
File 创建该导出任务的唯一 requiredPermission 为 platform:firstleg:customs:manage。
Provider 必须按 schemaVersion=1 返回下列固定顺序列,File 校验后生成 XLSX。
parameters:
- $ref: '#/components/parameters/InternalToken'
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ExportBatchRequest'}
responses:
'200':
description: ApiResponse<ExportBatchResponse>
components:
parameters:
InternalToken: {name: X-Internal-Service-Token, in: header, required: true, schema: {type: string}}
OperatorId: {name: X-Operator-Id, in: header, required: true, schema: {type: integer, format: int64, minimum: 1}}
TenantId: {name: tenantId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}, description: 客户ID;不得使用平台0作用域}
FileCategory: {name: fileCategory, in: path, required: true, schema: {type: string, enum: [LOADING_LIST_CUSTOMS_SOURCE, LOADING_LIST_CUSTOMS_RESULT]}}
LoadingPlanId: {name: loadingPlanId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
FileId: {name: fileId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
schemas:
BusinessFileResponse:
type: object
required: [code, data]
properties:
code: {type: string, example: '200'}
message: {type: string}
data: {$ref: '#/components/schemas/BusinessFileMetadata'}
BusinessFileMetadata:
type: object
required: [fileId, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes]
properties:
fileId: {type: string, description: File不可变对象ID,跨前端按字符串传输}
tenantId: {type: string, description: CUSTOMER owner scope客户ID}
businessType: {type: string, description: fileCategory}
businessId: {type: string, description: loadingPlanId}
fileName: {type: string}
sha256: {type: string}
contentType: {type: string}
sizeBytes: {type: integer, format: int64, maximum: 52428800}
ExportBatchRequest:
type: object
required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit]
properties:
taskId: {type: string}
tenantId: {type: string, enum: ['0']}
appCode: {type: string, enum: [PLATFORM]}
requestedBy: {type: string}
dispatchVersion: {type: integer}
parameters:
type: object
required: [loadingPlanId]
additionalProperties: false
properties:
loadingPlanId: {type: string, pattern: '^[1-9][0-9]*$'}
cursor: {type: string, nullable: true}
snapshotToken: {type: string, nullable: true}
limit: {type: integer, minimum: 50, maximum: 1000}
BuyDeclarationColumns:
type: object
description: 固定列顺序
properties:
customerName: {type: string, title: 客户名称}
orderNo: {type: string, title: 订单号}
inboundCode: {type: string, title: 入仓码}
sku: {type: string, title: SKU}
chineseName: {type: string, title: 中文品名}
englishName: {type: string, title: 英文品名}
cartonCount: {type: integer, title: 箱数}
productQuantity: {type: integer, title: 产品数量}
grossWeight: {type: number, title: 毛重}
netWeight: {type: number, title: 净重}
volume: {type: number, title: 体积}
mark: {type: string, title: 唛头}
customerRemark: {type: string, title: 客户备注}
@@ -0,0 +1,46 @@
package com.baiyue.file.config;
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 CustomsFileExportContractTest {
private static final String TYPE = "LOADING_LIST_BUY_DECLARATION_ITEMS";
@Test void applicationAndNacosRegisterTheSameFirstlegProviderContract() throws Exception {
for (String file : new String[]{
"file/src/main/resources/application.yml",
"config/nacos/local/file-service.yml",
"config/nacos/cn/file-service.yml"}) {
String source = Files.readString(repositoryRoot().resolve(file));
assertThat(source).contains(TYPE + ":")
.contains("provider-service: firstleg-service")
.contains("provider-path: /internal/exports/v1/" + TYPE + "/batches")
.contains("required-permission: platform:firstleg:customs:manage")
.contains("app-scope: PLATFORM")
.doesNotContain("loading-list:export");
}
}
@Test void openApiFreezesOwnerScopeSizeVisibilityAndColumns() throws Exception {
String source = Files.readString(repositoryRoot()
.resolve("file/src/main/resources/openapi/customs-file-and-export-v1.yaml"));
assertThat(source).contains("LOADING_LIST_CUSTOMS_SOURCE", "LOADING_LIST_CUSTOMS_RESULT")
.contains("maximum: 52428800", "no-store", "CUSTOMER owner scope", "loadingPlanId")
.contains("platform:firstleg:customs:manage", "platform:firstleg:loading-list:list",
"merchant:order:edit", "merchant:order:view")
.contains("customerName", "orderNo", "inboundCode", "sku", "chineseName", "englishName",
"cartonCount", "productQuantity", "grossWeight", "netWeight", "volume", "mark",
"customerRemark")
.doesNotContain("productNameZh", "productNameEn", "shippingMark", "loading-list:export");
}
private static Path repositoryRoot() {
Path current = Path.of("").toAbsolutePath();
return Files.isDirectory(current.resolve("file")) ? current : current.getParent();
}
}
@@ -45,6 +45,18 @@ class InternalBusinessFileControllerTest {
verify(service).download(0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L); verify(service).download(0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L);
} }
@Test void arbitraryResultDownloadUsesAttachmentAndFallsBackToBinaryContentType() {
when(service.download(10001L, "LOADING_LIST_CUSTOMS_RESULT", "51", 92L))
.thenReturn(new InternalBusinessFileService.Download("完成文件.unknown", "invalid content type", new byte[]{3}));
var response = controller.download("internal-token", 10001L,
"LOADING_LIST_CUSTOMS_RESULT", "51", 92L);
assertThat(response.getHeaders().getCacheControl()).contains("no-store");
assertThat(response.getHeaders().getContentDisposition().getType()).isEqualTo("attachment");
assertThat(response.getHeaders().getContentType()).isEqualTo(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM);
}
@Test void rejectsInvalidTokenAndNegativeTenantBeforeServiceAccess() { @Test void rejectsInvalidTokenAndNegativeTenantBeforeServiceAccess() {
var file = new MockMultipartFile("file", "x.txt", "text/plain", "x".getBytes()); var file = new MockMultipartFile("file", "x.txt", "text/plain", "x".getBytes());
assertThatThrownBy(() -> controller.upload("wrong", 7L, 0L, assertThatThrownBy(() -> controller.upload("wrong", 7L, 0L,
@@ -0,0 +1,57 @@
package com.baiyue.file.service;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.multipart.MultipartFile;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class BusinessFileStorageTest {
@TempDir Path root;
@Test void arbitraryCustomsFormatIsStoredAsAttachmentObject() {
BusinessFileStorage storage = storage();
var file = new MockMultipartFile("file", "folder/manifest.custom", "application/x-custom", new byte[]{1, 2, 3});
var stored = storage.store(10001L, 51L, file, BusinessFileCategory.CUSTOMS_MAX_SIZE, true);
assertThat(stored.originalName()).isEqualTo("manifest.custom");
assertThat(stored.contentType()).isEqualTo("application/x-custom");
assertThat(storage.read(stored.objectKey())).containsExactly(1, 2, 3);
}
@Test void customsFileAboveFiftyMegabytesIsRejectedBeforeRead() {
BusinessFileStorage storage = storage();
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getSize()).thenReturn(BusinessFileCategory.CUSTOMS_MAX_SIZE + 1);
assertThatThrownBy(() -> storage.store(10001L, 51L, file,
BusinessFileCategory.CUSTOMS_MAX_SIZE, true))
.isInstanceOf(BizException.class).hasMessageContaining("50MB");
}
@Test void legacyBusinessFilesRetainRestrictedFormatsAndTenMegabyteLimit() {
BusinessFileStorage storage = storage();
var file = new MockMultipartFile("file", "archive.zip", "application/zip", new byte[]{1});
assertThatThrownBy(() -> storage.store(10001L, 51L, file,
BusinessFileCategory.STANDARD_MAX_SIZE, false))
.isInstanceOf(BizException.class).hasMessageContaining("仅支持");
}
private BusinessFileStorage storage() {
BusinessFileStorage storage = new BusinessFileStorage();
ReflectionTestUtils.setField(storage, "configuredRoot", root);
storage.initializeRoot();
return storage;
}
}
@@ -84,6 +84,34 @@ class ExportTaskServiceTest {
.getResultCode()); .getResultCode());
} }
@Test
void buyDeclarationExportRequiresOnePositiveLoadingPlanId() {
var definition = definition();
definition.setProviderService("firstleg-service");
definition.setProviderPath("/internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches");
definition.setRequiredPermission("platform:firstleg:customs:manage");
when(registry.require("LOADING_LIST_BUY_DECLARATION_ITEMS")).thenReturn(definition);
when(states.createConstrained(any(FileExportTask.class))).thenAnswer(invocation ->
new ExportStateService.CreationResult(invocation.getArgument(0), true));
var user = user(1L, Set.of("platform:firstleg:customs:manage"));
var response = service.create(user, new CreateExportTaskRequest(
"LOADING_LIST_BUY_DECLARATION_ITEMS", "XLSX", "买单报关明细", Map.of("loadingPlanId", "51")));
assertEquals("LOADING_LIST_BUY_DECLARATION_ITEMS", response.exportType());
assertEquals(ResultCode.VALIDATION_FAILED, assertThrows(BizException.class, () -> service.create(user,
new CreateExportTaskRequest("LOADING_LIST_BUY_DECLARATION_ITEMS", "XLSX", "买单报关明细", Map.of())))
.getResultCode());
assertEquals(ResultCode.VALIDATION_FAILED, assertThrows(BizException.class, () -> service.create(user,
new CreateExportTaskRequest("LOADING_LIST_BUY_DECLARATION_ITEMS", "XLSX", "买单报关明细",
Map.of("loadingPlanId", 0))))
.getResultCode());
assertEquals(ResultCode.VALIDATION_FAILED, assertThrows(BizException.class, () -> service.create(user,
new CreateExportTaskRequest("LOADING_LIST_BUY_DECLARATION_ITEMS", "XLSX", "买单报关明细",
Map.of("loadingPlanId", 51L))))
.getResultCode());
}
private AuthenticatedUser user(long id, Set<String> permissions) { private AuthenticatedUser user(long id, Set<String> permissions) {
return new AuthenticatedUser(id, 0L, 0L, id, "user" + id, "PLATFORM", "WEB", permissions); return new AuthenticatedUser(id, 0L, 0L, id, "user" + id, "PLATFORM", "WEB", permissions);
} }
@@ -86,6 +86,56 @@ class GenericXlsxExportRunnerTest {
} }
} }
@Test void buyDeclarationProviderMustReturnTheFrozenThirteenColumnsInOrder() {
List<ExportColumn> invalid = ExportSchemaContracts.BUY_DECLARATION_COLUMNS.stream()
.map(column -> new ExportColumn(column.key(), column.title(), column.type(), 18)).toList();
invalid = new java.util.ArrayList<>(invalid);
invalid.set(4, new ExportColumn("productNameZh", "中文品名", ExportColumnType.STRING, 18));
var batch = new ExportBatchResponse(1, "sha256:buy-declaration", "snapshot-51",
invalid, List.of(), null, false, 0L);
when(provider.batch(anyString(), eq(ExportSchemaContracts.BUY_DECLARATION_ITEMS), any(), any()))
.thenReturn(ApiResponse.success(batch));
FileExportTask task = new FileExportTask(); task.setId(51L); task.setTenantId(0L);
task.setAppCode("PLATFORM"); task.setRequestedBy(7L); task.setDispatchVersion(1);
task.setExportType(ExportSchemaContracts.BUY_DECLARATION_ITEMS); task.setTaskName("买单报关明细");
task.setParametersJson("{\"loadingPlanId\":\"51\"}");
var definition = new ExportDefinitionRegistry.Definition(); definition.setMaxRows(200000); definition.setBatchSize(500);
assertThrows(ExportFailure.class,
() -> runner.run(task, definition, new AtomicBoolean(), ignored -> { }));
}
@Test void frozenBuyDeclarationProviderColumnsGenerateWorkbookDirectly() throws Exception {
List<ExportColumn> columns = ExportSchemaContracts.BUY_DECLARATION_COLUMNS.stream()
.map(column -> new ExportColumn(column.key(), column.title(), column.type(), 18)).toList();
Map<String, Object> row = new java.util.LinkedHashMap<>();
row.put("customerName", "客户A"); row.put("orderNo", "O-1"); row.put("inboundCode", "IN-1");
row.put("sku", "SKU-1"); row.put("chineseName", "中文品名"); row.put("englishName", "English name");
row.put("cartonCount", 2); row.put("productQuantity", 20); row.put("grossWeight", "12.5");
row.put("netWeight", "11.5"); row.put("volume", "1.25"); row.put("mark", "MARK-1");
row.put("customerRemark", "备注");
var batch = new ExportBatchResponse(1, "sha256:buy-declaration", "snapshot-51",
columns, List.of(row), null, false, 1L);
when(provider.batch(anyString(), eq(ExportSchemaContracts.BUY_DECLARATION_ITEMS), any(), any()))
.thenReturn(ApiResponse.success(batch));
FileExportTask task = new FileExportTask(); task.setId(52L); task.setTenantId(0L);
task.setAppCode("PLATFORM"); task.setRequestedBy(7L); task.setDispatchVersion(1);
task.setExportType(ExportSchemaContracts.BUY_DECLARATION_ITEMS); task.setTaskName("买单报关明细");
task.setParametersJson("{\"loadingPlanId\":\"51\"}");
var definition = new ExportDefinitionRegistry.Definition(); definition.setMaxRows(200000); definition.setBatchSize(500);
var result = runner.run(task, definition, new AtomicBoolean(), ignored -> { });
assertEquals(1L, result.rows());
try (var workbook = new XSSFWorkbook(storage.load(result.objectKey()).getInputStream())) {
var sheet = workbook.getSheetAt(0);
assertEquals("客户名称", sheet.getRow(0).getCell(0).getStringCellValue());
assertEquals("客户A", sheet.getRow(1).getCell(0).getStringCellValue());
assertEquals("中文品名", sheet.getRow(0).getCell(4).getStringCellValue());
assertEquals("唛头", sheet.getRow(0).getCell(11).getStringCellValue());
}
}
@Configuration @Configuration
static class TestConfig { static class TestConfig {
@Bean ObjectMapper objectMapper() { return new ObjectMapper(); } @Bean ObjectMapper objectMapper() { return new ObjectMapper(); }
@@ -21,7 +21,7 @@ class InternalBusinessFileServiceTest {
@Test void customsDocumentUploadPersistsExactBusinessOwnership() { @Test void customsDocumentUploadPersistsExactBusinessOwnership() {
MockMultipartFile file = new MockMultipartFile("file", "invoice.txt", "text/plain", "invoice".getBytes()); MockMultipartFile file = new MockMultipartFile("file", "invoice.txt", "text/plain", "invoice".getBytes());
when(storage.storeLoading(10001L, 41L, file)).thenReturn(new BusinessFileStorage.Stored( when(storage.store(10001L, 41L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false)).thenReturn(new BusinessFileStorage.Stored(
"business-files/10001/41/key.txt", "text/plain", 7, "a".repeat(64), "invoice.txt")); "business-files/10001/41/key.txt", "text/plain", 7, "a".repeat(64), "invoice.txt"));
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
((FileObjectEntity) invocation.getArgument(0)).setId(71L); return 1; ((FileObjectEntity) invocation.getArgument(0)).setId(71L); return 1;
@@ -37,10 +37,10 @@ class InternalBusinessFileServiceTest {
@Test void platformContainerAndLoadingDocumentsSupportZeroTenantScope() { @Test void platformContainerAndLoadingDocumentsSupportZeroTenantScope() {
MockMultipartFile file = new MockMultipartFile("file", "customs.pdf", "application/pdf", "%PDF-1.4".getBytes()); MockMultipartFile file = new MockMultipartFile("file", "customs.pdf", "application/pdf", "%PDF-1.4".getBytes());
when(storage.storeLoading(eq(0L), anyLong(), same(file))).thenAnswer(invocation -> when(storage.store(eq(0L), anyLong(), same(file), eq(BusinessFileCategory.STANDARD_MAX_SIZE), eq(false))).thenAnswer(invocation ->
new BusinessFileStorage.Stored("business-files/0/" + invocation.getArgument(1) + "/customs.pdf", new BusinessFileStorage.Stored("business-files/0/" + invocation.getArgument(1) + "/customs.pdf",
"application/pdf", file.getSize(), "b".repeat(64), "customs.pdf")); "application/pdf", file.getSize(), "b".repeat(64), "customs.pdf"));
when(storage.storeLoading(11L, 51L, file)).thenReturn(new BusinessFileStorage.Stored( when(storage.store(11L, 51L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false)).thenReturn(new BusinessFileStorage.Stored(
"business-files/11/51/unload.jpg", "image/jpeg", file.getSize(), "c".repeat(64), "unload.jpg")); "business-files/11/51/unload.jpg", "image/jpeg", file.getSize(), "c".repeat(64), "unload.jpg"));
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
((FileObjectEntity) invocation.getArgument(0)).setId(81L); return 1; ((FileObjectEntity) invocation.getArgument(0)).setId(81L); return 1;
@@ -64,9 +64,9 @@ class InternalBusinessFileServiceTest {
InternalBusinessFileService.Metadata::businessType, InternalBusinessFileService.Metadata::businessType,
InternalBusinessFileService.Metadata::businessId) InternalBusinessFileService.Metadata::businessId)
.containsExactly(11L, "OVERSEAS_UNLOAD_PHOTO", "51"); .containsExactly(11L, "OVERSEAS_UNLOAD_PHOTO", "51");
verify(storage, times(3)).storeLoading(0L, 41L, file); verify(storage, times(3)).store(0L, 41L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false);
verify(storage).storeLoading(0L, 51L, file); verify(storage).store(0L, 51L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false);
verify(storage).storeLoading(11L, 51L, file); verify(storage).store(11L, 51L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false);
} }
@Test void rejectsNegativeTenantAndInvalidOwnershipInputs() { @Test void rejectsNegativeTenantAndInvalidOwnershipInputs() {
@@ -82,7 +82,7 @@ class InternalBusinessFileServiceTest {
@Test void overseasRelabelPhotoPersistsExactTaskOwnership() { @Test void overseasRelabelPhotoPersistsExactTaskOwnership() {
MockMultipartFile file = new MockMultipartFile("file", "relabel.jpg", "image/jpeg", "photo".getBytes()); MockMultipartFile file = new MockMultipartFile("file", "relabel.jpg", "image/jpeg", "photo".getBytes());
when(storage.storeLoading(100L, 81L, file)).thenReturn(new BusinessFileStorage.Stored( when(storage.store(100L, 81L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false)).thenReturn(new BusinessFileStorage.Stored(
"business-files/100/81/relabel.jpg", "image/jpeg", file.getSize(), "d".repeat(64), "relabel.jpg")); "business-files/100/81/relabel.jpg", "image/jpeg", file.getSize(), "d".repeat(64), "relabel.jpg"));
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
((FileObjectEntity) invocation.getArgument(0)).setId(91L); return 1; ((FileObjectEntity) invocation.getArgument(0)).setId(91L); return 1;
@@ -96,6 +96,36 @@ class InternalBusinessFileServiceTest {
.containsExactly(100L, "OVERSEAS_RELABEL_PHOTO", "81"); .containsExactly(100L, "OVERSEAS_RELABEL_PHOTO", "81");
} }
@Test void loadingCustomsSourceAndResultAreCustomerOwnedArbitraryFormatObjects() {
MockMultipartFile file = new MockMultipartFile("file", "资料包.zip", "application/zip", "zip".getBytes());
when(storage.store(10001L, 51L, file, BusinessFileCategory.CUSTOMS_MAX_SIZE, true))
.thenReturn(new BusinessFileStorage.Stored("business-files/10001/51/key", "application/zip",
file.getSize(), "e".repeat(64), "资料包.zip"));
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
((FileObjectEntity) invocation.getArgument(0)).setId(101L); return 1;
});
var source = service.upload(10001L, "LOADING_LIST_CUSTOMS_SOURCE", "51", 7L, file);
var result = service.upload(10001L, "LOADING_LIST_CUSTOMS_RESULT", "51", 7L, file);
assertThat(source).extracting(InternalBusinessFileService.Metadata::tenantId,
InternalBusinessFileService.Metadata::businessType,
InternalBusinessFileService.Metadata::businessId)
.containsExactly(10001L, "LOADING_LIST_CUSTOMS_SOURCE", "51");
assertThat(result.businessType()).isEqualTo("LOADING_LIST_CUSTOMS_RESULT");
verify(storage, times(2)).store(10001L, 51L, file, BusinessFileCategory.CUSTOMS_MAX_SIZE, true);
}
@Test void loadingCustomsCategoriesRejectPlatformOwnerScope() {
var file = new MockMultipartFile("file", "source.bin", "application/octet-stream", new byte[]{1});
assertThatThrownBy(() -> service.upload(0L, "LOADING_LIST_CUSTOMS_SOURCE", "51", 7L, file))
.isInstanceOf(BizException.class).hasMessageContaining("明确客户");
assertThatThrownBy(() -> service.metadata(0L, "LOADING_LIST_CUSTOMS_RESULT", "51", 91L))
.isInstanceOf(BizException.class).hasMessageContaining("明确客户");
verifyNoInteractions(files, storage);
}
@Test void unsupportedBusinessTypeFailsClosed() { @Test void unsupportedBusinessTypeFailsClosed() {
assertThatThrownBy(() -> service.upload(10001L, "ARBITRARY", "41", 7L, assertThatThrownBy(() -> service.upload(10001L, "ARBITRARY", "41", 7L,
new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes()))) new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes())))
+4 -2
View File
@@ -38,7 +38,8 @@
| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | | 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | | Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 |
| 拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;按真实 `containerBookingId` 查询聚合和 `gate`、确认离仓,契约 `src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml` | | 拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;按真实 `containerBookingId` 查询聚合和 `gate`、确认离仓,契约 `src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml` |
| 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` | | 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`MerchantContainerCustomsController.java`、`service/ContainerCustomsService.java`、`mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql` |
| 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 |
| 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` | | 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` |
| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`EventBridgeClient.java` | | 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`EventBridgeClient.java` |
| 仓库公共供应商快照 | `client/WarehouseSupplierClient.java`、`service/WarehouseSupplierSnapshotService.java`;报关、订舱和拖车分别消费 `customs-v1.yaml`、`ocean-bookings-v1.yaml`、`truck-appointments-v1.yaml` 中的供应商 ID,并由 Warehouse 返回服务端名称快照 | | 仓库公共供应商快照 | `client/WarehouseSupplierClient.java`、`service/WarehouseSupplierSnapshotService.java`;报关、订舱和拖车分别消费 `customs-v1.yaml`、`ocean-bookings-v1.yaml`、`truck-appointments-v1.yaml` 中的供应商 ID,并由 Warehouse 返回服务端名称快照 |
@@ -59,7 +60,8 @@
- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 - 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。 - 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。
- 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/四期/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。 - 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/四期/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。
- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,国内出口报关资料必须存在且已完成,拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。 - 国内出口报关在装柜完成后启用,不再阻塞 `LOADED`。整柜 `fl_customs_container.status=COMPLETED` 才通过离仓报关门禁;完成后以系统身份、确定性幂等键立即尝试确认国内离仓,其他门禁未齐时由受控定时补偿重试,禁止部分入仓成功或重复创建离仓/船司查询事实。
- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。
- 手工船节点的预计时间只追加事实,不推进 `latestStandardNode`;携带 `actualAt` 时标准节点序号必须严格大于最新实际节点,重复、相同或回退节点返回冲突。已审核实际时间修正只能更新同一事实的时间/证据,不得制造第二条同节点实际事实或绕过单调推进。 - 手工船节点的预计时间只追加事实,不推进 `latestStandardNode`;携带 `actualAt` 时标准节点序号必须严格大于最新实际节点,重复、相同或回退节点返回冲突。已审核实际时间修正只能更新同一事实的时间/证据,不得制造第二条同节点实际事实或绕过单调推进。
- 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜照片上传时间加两天计算,实际报空时间可为空。 - 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜照片上传时间加两天计算,实际报空时间可为空。
- Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。 - Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。
@@ -12,23 +12,13 @@ import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest; import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadConfirmationResponse; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadConfirmationResponse;
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument;
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest;
import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.LoadingListService; import com.baiyue.firstleg.service.LoadingListService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import org.springframework.http.MediaType;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
@@ -41,10 +31,6 @@ import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
/** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */ /** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */
@Tag(name = "平台预装与装柜清单", description = "预装单向推进和已确认装柜清单只读查询") @Tag(name = "平台预装与装柜清单", description = "预装单向推进和已确认装柜清单只读查询")
@@ -120,67 +106,4 @@ public class PlatformLoadingListController {
return ApiResponse.success(service.confirmedDetail(planId, warehouseId)); return ApiResponse.success(service.confirmedDetail(planId, warehouseId));
} }
@Operation(summary = "查询装柜清单出口报关文档")
@GetMapping("/loading-lists/{planId}/customs")
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')")
public ApiResponse<BusinessDocument> declaration(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
@RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.loadingDeclaration(planId, warehouseId));
}
@Operation(summary = "下载装柜清单出口报关附件", description = "完成态仍可下载;服务端校验装柜计划、仓库、当前附件及 File 业务归属。")
@GetMapping("/loading-lists/{planId}/customs/document/{fileId}/download")
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')")
public ResponseEntity<byte[]> downloadDeclaration(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
@PathVariable @Positive long fileId, @RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user);
var download = service.downloadLoadingDeclaration(planId, warehouseId, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(download.fileName(), StandardCharsets.UTF_8).build().toString())
.contentType(download.contentType()).contentLength(download.content().length)
.body(download.content());
}
@Operation(summary = "上传或替换装柜清单出口报关文档")
@PostMapping("/loading-lists/{planId}/customs/document")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
public ApiResponse<BusinessDocument> attachDeclaration(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
@RequestParam @Positive long warehouseId, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody DocumentAttachRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.attachLoadingDeclaration(
planId, warehouseId, user.userId(), user.username(), key, request));
}
@Operation(summary = "直接上传或替换装柜清单出口报关文档")
@PostMapping(value = "/loading-lists/{planId}/customs/document/upload",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
public ApiResponse<BusinessDocument> uploadDeclaration(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
@RequestParam @Positive long warehouseId, @RequestHeader("Idempotency-Key") String key,
@RequestPart("file") MultipartFile file,
@RequestParam("expectedVersion") @Min(0) int expectedVersion,
@RequestParam(value = "operationReason", required = false) @Size(max = 500) String operationReason) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.uploadLoadingDeclaration(planId, warehouseId,
user.userId(), user.username(), key, expectedVersion, operationReason, file));
}
@Operation(summary = "确认装柜清单出口报关完成")
@PostMapping("/loading-lists/{planId}/customs/complete")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
public ApiResponse<BusinessDocument> completeDeclaration(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
@RequestParam @Positive long warehouseId, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody DocumentCompleteRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.completeLoadingDeclaration(
planId, warehouseId, user.userId(), user.username(), key, request));
}
} }
@@ -0,0 +1,29 @@
package com.baiyue.firstleg.customs.container.controller;
import com.baiyue.common.core.api.*;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.export.ExportContracts.*;
import com.baiyue.firstleg.customs.container.service.LoadingListBuyDeclarationExportService;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@RestController
@RequestMapping("/internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS")
public class LoadingListBuyDeclarationExportController {
@Resource private LoadingListBuyDeclarationExportService service;
@Value("${baiyue.security.internal-service-token}") private String configuredToken;
private byte[] token;
@PostConstruct void init(){token=configuredToken==null?new byte[0]:configuredToken.getBytes(StandardCharsets.UTF_8);if(token.length<32)throw new IllegalStateException("INTERNAL_SERVICE_TOKEN至少需要32字节");}
@PostMapping("/batches")
public ApiResponse<ExportBatchResponse> batch(@RequestHeader(value="X-Internal-Service-Token",required=false)String supplied,
@RequestBody ExportBatchRequest request){
byte[] actual=supplied==null?new byte[0]:supplied.getBytes(StandardCharsets.UTF_8);
if(!MessageDigest.isEqual(token,actual))throw new BizException(ResultCode.FORBIDDEN,"Invalid internal service token");
return ApiResponse.success(service.batch(request));
}
}
@@ -0,0 +1,101 @@
package com.baiyue.firstleg.customs.container.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.*;
import com.baiyue.firstleg.customs.container.service.ContainerCustomsService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
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;
import java.nio.charset.StandardCharsets;
import java.util.List;
@Validated
@RestController
@RequestMapping("/api/merchant/v1/customs")
public class MerchantContainerCustomsController {
@Resource private ContainerCustomsService service;
@GetMapping("/orders/{orderId}")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ApiResponse<ContainerView> order(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long orderId) {
return ApiResponse.success(service.merchantOrderView(FirstlegScopeGuard.merchant(user), orderId));
}
@PostMapping(value="/loading-plans/{planId}/documents/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('merchant:order:edit')")
public ApiResponse<DocumentView> upload(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long planId,
@RequestHeader("Idempotency-Key") String key,
@RequestParam @Size(min=1,max=200) List<@Positive Long> inboundIds,
@RequestPart("file") MultipartFile file) {
long tenantId=FirstlegScopeGuard.merchant(user);
return ApiResponse.success(service.uploadSource(tenantId,planId,user.userId(),key,inboundIds,file));
}
@PostMapping(value="/documents/{documentId}/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('merchant:order:edit')")
public ApiResponse<DocumentView> replace(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long documentId,
@RequestHeader("Idempotency-Key") String key,
@RequestParam @Size(min=1,max=200) List<@Positive Long> inboundIds,
@RequestParam @Min(1) int expectedVersion,
@RequestPart("file") MultipartFile file) {
long tenantId=FirstlegScopeGuard.merchant(user);
return ApiResponse.success(service.replaceSource(tenantId,documentId,user.userId(),key,
new ScopeRequest(inboundIds,expectedVersion),file));
}
@PutMapping("/documents/{documentId}/inbounds")
@PreAuthorize("hasAuthority('merchant:order:edit')")
public ApiResponse<DocumentView> scope(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long documentId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ScopeRequest request) {
long tenantId=FirstlegScopeGuard.merchant(user);
return ApiResponse.success(service.updateScope(tenantId,documentId,user.userId(),key,request));
}
@DeleteMapping("/documents/{documentId}")
@PreAuthorize("hasAuthority('merchant:order:edit')")
public ApiResponse<ContainerView> delete(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long documentId,
@RequestHeader("Idempotency-Key") String key,
@RequestParam @Min(1) int expectedVersion) {
long tenantId=FirstlegScopeGuard.merchant(user);
return ApiResponse.success(service.deleteSource(tenantId,documentId,user.userId(),key,expectedVersion));
}
@GetMapping("/documents/{documentId}/download")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ResponseEntity<byte[]> downloadSource(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long documentId) {
return response(service.downloadSource(FirstlegScopeGuard.merchant(user),documentId,false));
}
@GetMapping("/documents/{documentId}/completion/download")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ResponseEntity<byte[]> downloadCompletion(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long documentId) {
return response(service.downloadCompletion(FirstlegScopeGuard.merchant(user),documentId,false));
}
private static ResponseEntity<byte[]> response(Download value) {
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(value.contentType()))
.header(HttpHeaders.CONTENT_DISPOSITION,ContentDisposition.attachment()
.filename(value.fileName(),StandardCharsets.UTF_8).build().toString())
.contentLength(value.content().length).body(value.content());
}
}
@@ -0,0 +1,95 @@
package com.baiyue.firstleg.customs.container.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.*;
import com.baiyue.firstleg.customs.container.service.ContainerCustomsService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive;
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;
import java.nio.charset.StandardCharsets;
@Validated
@RestController
@RequestMapping("/api/platform/v1/customs/loading-plans")
public class PlatformContainerCustomsController {
@Resource private ContainerCustomsService service;
@GetMapping("/{planId}")
@PreAuthorize("hasAuthority('platform:firstleg:customs:list')")
public ApiResponse<ContainerView> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long planId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.platformView(planId));
}
@PostMapping("/{planId}/start")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
@BusinessAudit(event="CONTAINER_CUSTOMS_START",resourceType="CUSTOMS_CONTAINER")
public ApiResponse<ContainerView> start(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long planId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ActionRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.start(planId,user.userId(),user.username(),key,request));
}
@PostMapping(value="/documents/{documentId}/completion/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
@BusinessAudit(event="CUSTOMS_COMPLETION_UPLOAD",resourceType="CUSTOMS_CUSTOMER_DOCUMENT")
public ApiResponse<DocumentView> uploadCompletion(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long documentId,
@RequestHeader("Idempotency-Key") String key,
@RequestParam @Min(0) int expectedVersion,
@RequestParam(defaultValue="false") boolean finalConfirmation,
@RequestPart("file") MultipartFile file) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.uploadCompletion(documentId,user.userId(),user.username(),key,
expectedVersion,finalConfirmation,file));
}
@PostMapping("/{planId}/complete-all-buy")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
@BusinessAudit(event="ALL_BUY_CONTAINER_CUSTOMS_COMPLETE",resourceType="CUSTOMS_CONTAINER")
public ApiResponse<ContainerView> completeAllBuy(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long planId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ActionRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.completeAllBuy(planId,user.userId(),user.username(),key,request));
}
@GetMapping("/documents/{documentId}/download")
@PreAuthorize("hasAuthority('platform:firstleg:customs:list')")
public ResponseEntity<byte[]> downloadSource(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long documentId) {
FirstlegScopeGuard.platform(user);
return response(service.downloadSource(0,documentId,true));
}
@GetMapping("/documents/{documentId}/completion/download")
@PreAuthorize("hasAuthority('platform:firstleg:customs:list')")
public ResponseEntity<byte[]> downloadCompletion(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long documentId) {
FirstlegScopeGuard.platform(user);
return response(service.downloadCompletion(0,documentId,true));
}
private static ResponseEntity<byte[]> response(Download value) {
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(value.contentType()))
.header(HttpHeaders.CONTENT_DISPOSITION,ContentDisposition.attachment()
.filename(value.fileName(),StandardCharsets.UTF_8).build().toString())
.contentLength(value.content().length).body(value.content());
}
}
@@ -0,0 +1,37 @@
package com.baiyue.firstleg.customs.container.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
public final class ContainerCustomsDtos {
private ContainerCustomsDtos() {}
public record InboundView(long inboundId, long orderId, String orderNo, String inboundCode,
long tenantId, String customerName, String taxMode, boolean covered) {}
public record FileView(long fileId, String fileName, String fileHash, String contentType,
long sizeBytes, int version, LocalDateTime uploadedAt) {}
public record DocumentView(long documentId, long tenantId, FileView source,
List<Long> inboundIds, List<String> inboundCodes,
boolean frozen, FileView completion) {}
public record ContainerView(long aggregateId, long loadingPlanId, long containerBookingId,
String planNo, String cabinetNo, String containerNo, String status, String merchantStatus,
int version, int taxRefundCount, int coveredTaxRefundCount,
List<InboundView> inbounds, List<DocumentView> documents,
LocalDateTime startedAt, LocalDateTime completedAt) {}
public record ScopeRequest(@NotEmpty @Size(max = 200) List<@Positive Long> inboundIds,
@Min(0) int expectedVersion) {}
public record ActionRequest(@Min(0) int expectedVersion, boolean confirm,
@Size(max = 500) String remark) {}
public record Download(String fileName, String contentType, byte[] content) {}
}
@@ -0,0 +1,337 @@
package com.baiyue.firstleg.customs.container.mapper;
import org.apache.ibatis.annotations.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface ContainerCustomsMapper {
@Select("""
SELECT DISTINCT p.id loadingPlanId,p.plan_no planNo,p.container_booking_id containerBookingId,
p.plan_status loadingPlanStatus,b.cabinet_no cabinetNo,b.container_no containerNo,
i.id inboundId,i.order_id orderId,i.order_no orderNo,i.inbound_code inboundCode,
i.tenant_id tenantId,i.customer_name customerName,i.tax_mode taxMode
FROM fl_loading_plan p
JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE'
JOIN fl_loading_plan_item item ON item.loading_plan_id=p.id AND item.deleted=0
JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0
WHERE p.id=#{planId}
ORDER BY i.tenant_id,i.id
""")
List<InboundRow> planInbounds(@Param("planId") long planId);
@Select("""
SELECT p.id FROM fl_loading_plan p
JOIN fl_loading_plan_item item ON item.loading_plan_id=p.id AND item.deleted=0
JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0
WHERE p.plan_status='COMPLETED' AND i.tenant_id=#{tenantId} AND i.order_id=#{orderId} AND i.tax_mode='TAX_REFUND'
ORDER BY p.completed_at DESC,p.id DESC LIMIT 1
""")
Long completedPlanForOrder(@Param("tenantId") long tenantId, @Param("orderId") long orderId);
@Insert("""
INSERT IGNORE INTO fl_customs_container
(id,loading_plan_id,container_booking_id,status,created_at,updated_at,version)
VALUES (#{id},#{planId},#{containerBookingId},#{status},CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0)
""")
int insertContainer(@Param("id") long id,@Param("planId") long planId,
@Param("containerBookingId") long containerBookingId,@Param("status") String status);
@Select("SELECT * FROM fl_customs_container WHERE loading_plan_id=#{planId}")
ContainerRow container(@Param("planId") long planId);
@Select("SELECT * FROM fl_customs_container WHERE id=#{id}")
ContainerRow containerById(@Param("id") long id);
@Select("SELECT * FROM fl_customs_container WHERE container_booking_id=#{containerBookingId}")
ContainerRow containerByBooking(@Param("containerBookingId") long containerBookingId);
@Select("SELECT * FROM fl_customs_container WHERE loading_plan_id=#{planId} FOR UPDATE")
ContainerRow lockContainer(@Param("planId") long planId);
@Select("SELECT * FROM fl_customs_container WHERE id=#{id} FOR UPDATE")
ContainerRow lockContainerById(@Param("id") long id);
@Update("""
UPDATE fl_customs_container SET status=#{status},updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{id} AND version=#{version} AND status IN ('WAITING_CUSTOMER_DOCUMENTS','READY_TO_START')
""")
int refreshStatus(@Param("id") long id,@Param("version") int version,@Param("status") String status);
@Update("""
UPDATE fl_customs_container SET status='DECLARING',started_by=#{operatorId},started_by_name=#{operatorName},
started_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{id} AND version=#{version} AND status='READY_TO_START'
""")
int start(@Param("id") long id,@Param("version") int version,@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_customs_container SET status='COMPLETED',completed_by=#{operatorId},completed_by_name=#{operatorName},
completed_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{id} AND version=#{version} AND status='DECLARING'
""")
int complete(@Param("id") long id,@Param("version") int version,@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Insert("""
INSERT INTO fl_customs_customer_document
(id,customs_container_id,tenant_id,file_id,file_name,file_hash,content_type,size_bytes,status,
uploaded_by,uploaded_at,version,created_at,updated_at)
VALUES (#{id},#{containerId},#{tenantId},#{fileId},#{fileName},#{fileHash},#{contentType},#{sizeBytes},'ACTIVE',
#{operatorId},CURRENT_TIMESTAMP(3),1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
""")
int insertDocument(@Param("id") long id,@Param("containerId") long containerId,@Param("tenantId") long tenantId,
@Param("fileId") long fileId,@Param("fileName") String fileName,@Param("fileHash") String fileHash,
@Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes,@Param("operatorId") long operatorId);
@Select("SELECT * FROM fl_customs_customer_document WHERE id=#{id}")
DocumentRow document(@Param("id") long id);
@Select("SELECT * FROM fl_customs_customer_document WHERE id=#{id} FOR UPDATE")
DocumentRow lockDocument(@Param("id") long id);
@Select("""
<script>SELECT * FROM fl_customs_customer_document
WHERE customs_container_id=#{containerId} AND status='ACTIVE'
<if test='tenantId != null'>AND tenant_id=#{tenantId}</if>
ORDER BY created_at,id</script>
""")
List<DocumentRow> documents(@Param("containerId") long containerId,@Param("tenantId") Long tenantId);
@Select("SELECT * FROM fl_customs_customer_document WHERE customs_container_id=#{containerId} AND status='ACTIVE' ORDER BY id FOR UPDATE")
List<DocumentRow> lockActiveDocuments(@Param("containerId") long containerId);
@Update("""
UPDATE fl_customs_customer_document SET file_id=#{fileId},file_name=#{fileName},file_hash=#{fileHash},
content_type=#{contentType},size_bytes=#{sizeBytes},uploaded_by=#{operatorId},uploaded_at=CURRENT_TIMESTAMP(3),
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{id} AND tenant_id=#{tenantId} AND status='ACTIVE' AND frozen_at IS NULL AND version=#{version}
""")
int replaceDocument(@Param("id") long id,@Param("tenantId") long tenantId,@Param("version") int version,
@Param("fileId") long fileId,@Param("fileName") String fileName,@Param("fileHash") String fileHash,
@Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes,@Param("operatorId") long operatorId);
@Update("""
UPDATE fl_customs_customer_document SET updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{id} AND tenant_id=#{tenantId} AND status='ACTIVE' AND frozen_at IS NULL AND version=#{version}
""")
int touchScope(@Param("id") long id,@Param("tenantId") long tenantId,@Param("version") int version);
@Update("""
UPDATE fl_customs_customer_document SET status='DELETED',deleted_by=#{operatorId},deleted_at=CURRENT_TIMESTAMP(3),
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{id} AND tenant_id=#{tenantId} AND status='ACTIVE' AND frozen_at IS NULL AND version=#{version}
""")
int deleteDocument(@Param("id") long id,@Param("tenantId") long tenantId,@Param("version") int version,
@Param("operatorId") long operatorId);
@Select("""
SELECT s.document_id documentId,s.inbound_id inboundId,s.order_id orderId,s.inbound_code inboundCode
FROM fl_customs_customer_document_inbound s WHERE s.document_id=#{documentId} ORDER BY s.inbound_id
""")
List<ScopeRow> scopes(@Param("documentId") long documentId);
@Delete("DELETE FROM fl_customs_customer_document_inbound WHERE document_id=#{documentId}")
int deleteScopes(@Param("documentId") long documentId);
@Insert("""
INSERT INTO fl_customs_customer_document_inbound(document_id,inbound_id,tenant_id,order_id,inbound_code,created_at)
VALUES(#{documentId},#{inboundId},#{tenantId},#{orderId},#{inboundCode},CURRENT_TIMESTAMP(3))
""")
int insertScope(@Param("documentId") long documentId,@Param("inboundId") long inboundId,
@Param("tenantId") long tenantId,@Param("orderId") long orderId,@Param("inboundCode") String inboundCode);
@Insert("""
INSERT INTO fl_customs_customer_document_version
(id,document_id,document_version,file_id,file_name,file_hash,content_type,size_bytes,scope_snapshot_json,
action,operator_id,created_at)
VALUES(#{id},#{documentId},#{version},#{fileId},#{fileName},#{fileHash},#{contentType},#{sizeBytes},
CAST(#{scopeJson} AS JSON),#{action},#{operatorId},CURRENT_TIMESTAMP(3))
""")
int insertDocumentVersion(@Param("id") long id,@Param("documentId") long documentId,@Param("version") int version,
@Param("fileId") long fileId,@Param("fileName") String fileName,@Param("fileHash") String fileHash,
@Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes,
@Param("scopeJson") String scopeJson,@Param("action") String action,@Param("operatorId") long operatorId);
@Select("""
SELECT COUNT(*) FROM (
SELECT DISTINCT i.id FROM fl_loading_plan_item item
JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0
WHERE item.loading_plan_id=#{planId} AND item.deleted=0 AND i.tax_mode='TAX_REFUND'
) target LEFT JOIN fl_customs_customer_document_inbound s ON s.inbound_id=target.id
LEFT JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.customs_container_id=#{containerId} AND d.status='ACTIVE'
WHERE d.id IS NULL
""")
int missingRefundCount(@Param("planId") long planId,@Param("containerId") long containerId);
@Update("UPDATE fl_customs_customer_document SET frozen_version=version,frozen_at=CURRENT_TIMESTAMP(3) " +
"WHERE customs_container_id=#{containerId} AND status='ACTIVE' AND frozen_at IS NULL")
int freezeDocuments(@Param("containerId") long containerId);
@Insert("""
INSERT INTO fl_customs_frozen_document_inbound(document_id,inbound_id,tenant_id,order_id,inbound_code,created_at)
SELECT s.document_id,s.inbound_id,s.tenant_id,s.order_id,s.inbound_code,CURRENT_TIMESTAMP(3)
FROM fl_customs_customer_document_inbound s
JOIN fl_customs_customer_document d ON d.id=s.document_id
WHERE d.customs_container_id=#{containerId} AND d.status='ACTIVE'
""")
int freezeScopes(@Param("containerId") long containerId);
@Select("""
SELECT s.document_id documentId,s.inbound_id inboundId,s.order_id orderId,s.inbound_code inboundCode
FROM fl_customs_frozen_document_inbound s WHERE s.document_id=#{documentId} ORDER BY s.inbound_id
""")
List<ScopeRow> frozenScopes(@Param("documentId") long documentId);
@Select("SELECT * FROM fl_customs_completion_document WHERE source_document_id=#{documentId}")
CompletionRow completion(@Param("documentId") long documentId);
@Select("SELECT * FROM fl_customs_completion_document WHERE source_document_id=#{documentId} FOR UPDATE")
CompletionRow lockCompletion(@Param("documentId") long documentId);
@Insert("""
INSERT INTO fl_customs_completion_document
(id,source_document_id,file_id,file_name,file_hash,content_type,size_bytes,uploaded_by,uploaded_by_name,
uploaded_at,version,created_at,updated_at)
VALUES(#{id},#{documentId},#{fileId},#{fileName},#{fileHash},#{contentType},#{sizeBytes},#{operatorId},#{operatorName},
CURRENT_TIMESTAMP(3),1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
""")
int insertCompletion(@Param("id") long id,@Param("documentId") long documentId,@Param("fileId") long fileId,
@Param("fileName") String fileName,@Param("fileHash") String fileHash,
@Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes,
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_customs_completion_document SET file_id=#{fileId},file_name=#{fileName},file_hash=#{fileHash},
content_type=#{contentType},size_bytes=#{sizeBytes},uploaded_by=#{operatorId},uploaded_by_name=#{operatorName},
uploaded_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{id} AND version=#{version}
AND EXISTS(SELECT 1 FROM fl_customs_customer_document d JOIN fl_customs_container c ON c.id=d.customs_container_id
WHERE d.id=source_document_id AND c.status='DECLARING')
""")
int replaceCompletion(@Param("id") long id,@Param("version") int version,@Param("fileId") long fileId,
@Param("fileName") String fileName,@Param("fileHash") String fileHash,
@Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes,
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Insert("""
INSERT INTO fl_customs_completion_document_version
(id,completion_document_id,document_version,file_id,file_name,file_hash,content_type,size_bytes,
operator_id,operator_name,created_at)
VALUES(#{id},#{completionId},#{version},#{fileId},#{fileName},#{fileHash},#{contentType},#{sizeBytes},
#{operatorId},#{operatorName},CURRENT_TIMESTAMP(3))
""")
int insertCompletionVersion(@Param("id") long id,@Param("completionId") long completionId,@Param("version") int version,
@Param("fileId") long fileId,@Param("fileName") String fileName,@Param("fileHash") String fileHash,
@Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes,
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Select("""
SELECT COUNT(*) FROM fl_customs_customer_document d
LEFT JOIN fl_customs_completion_document r ON r.source_document_id=d.id
WHERE d.customs_container_id=#{containerId} AND d.status='ACTIVE' AND d.frozen_at IS NOT NULL AND r.id IS NULL
""")
int missingCompletionCount(@Param("containerId") long containerId);
@Insert("""
INSERT INTO fl_customs_container_history
(id,customs_container_id,action,from_status,to_status,remark,operator_id,operator_name,created_at)
VALUES(#{id},#{containerId},#{action},#{fromStatus},#{toStatus},#{remark},#{operatorId},#{operatorName},CURRENT_TIMESTAMP(3))
""")
int insertHistory(@Param("id") long id,@Param("containerId") long containerId,@Param("action") String action,
@Param("fromStatus") String fromStatus,@Param("toStatus") String toStatus,@Param("remark") String remark,
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Insert("""
INSERT IGNORE INTO fl_customs_command
(id,actor_id,action,idempotency_key,request_hash,status,created_at)
VALUES(#{id},#{actorId},#{action},#{key},#{hash},'PROCESSING',CURRENT_TIMESTAMP(3))
""")
int claimCommand(@Param("id") long id,@Param("actorId") long actorId,@Param("action") String action,
@Param("key") String key,@Param("hash") String hash);
@Select("SELECT * FROM fl_customs_command WHERE actor_id=#{actorId} AND action=#{action} AND idempotency_key=#{key}")
CommandRow command(@Param("actorId") long actorId,@Param("action") String action,@Param("key") String key);
@Update("""
UPDATE fl_customs_command SET status='SUCCESS',result_id=#{resultId},completed_at=CURRENT_TIMESTAMP(3)
WHERE actor_id=#{actorId} AND action=#{action} AND idempotency_key=#{key} AND status='PROCESSING'
""")
int completeCommand(@Param("actorId") long actorId,@Param("action") String action,@Param("key") String key,
@Param("resultId") long resultId);
@Select("""
SELECT c.container_booking_id FROM fl_customs_container c
WHERE c.status='COMPLETED' AND NOT EXISTS(
SELECT 1 FROM fl_loading_plan_item item
LEFT JOIN fl_outbound_dispatch d ON d.inbound_id=item.inbound_id
AND d.tenant_id=item.account_id AND d.container_booking_id=c.container_booking_id
WHERE item.loading_plan_id=c.loading_plan_id AND item.deleted=0
GROUP BY item.loading_plan_id HAVING COUNT(*)=SUM(CASE WHEN d.status='WAITING_FOR_LOADING' THEN 1 ELSE 0 END)
) ORDER BY c.completed_at,c.id LIMIT #{limit}
""")
List<Long> pendingAutoDispatch(@Param("limit") int limit);
@Select("""
SELECT item.id itemId,p.plan_no planNo,b.cabinet_no cabinetNo,b.container_no containerNo,
item.customer_name customerName,item.order_no orderNo,item.inbound_code inboundCode,item.sku_code skuCode,
item.product_name_zh productNameZh,item.product_name_en productNameEn,item.package_count packageCount,
item.product_quantity productQuantity,item.gross_weight_kg grossWeightKg,item.net_weight_kg netWeightKg,
item.volume_cbm volumeCbm,item.shipping_mark shippingMark,item.customer_remark customerRemark
FROM fl_loading_plan_item item
JOIN fl_loading_plan p ON p.id=item.loading_plan_id AND p.plan_status='COMPLETED'
JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE'
JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0
WHERE p.id=#{planId} AND item.deleted=0 AND i.tax_mode='BUY_DECLARATION'
AND item.id>#{cursor} AND item.id<=#{maximumId}
ORDER BY item.id LIMIT #{limit}
""")
List<BuyExportRow> buyRows(@Param("planId") long planId,@Param("cursor") long cursor,
@Param("maximumId") long maximumId,@Param("limit") int limit);
@Select("""
SELECT p.id planId,p.plan_status planStatus,b.warehouse_id warehouseId
FROM fl_loading_plan p JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE'
WHERE p.id=#{planId}
""")
PlanExportScope planExportScope(@Param("planId") long planId);
@Select("""
SELECT COALESCE(MAX(item.id),0) FROM fl_loading_plan_item item
JOIN fl_loading_plan p ON p.id=item.loading_plan_id AND p.plan_status='COMPLETED'
JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0
WHERE p.id=#{planId} AND item.deleted=0 AND i.tax_mode='BUY_DECLARATION'
""")
long maximumBuyItemId(@Param("planId") long planId);
@Select("""
SELECT COUNT(*) FROM fl_loading_plan_item item
JOIN fl_loading_plan p ON p.id=item.loading_plan_id AND p.plan_status='COMPLETED'
JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0
WHERE p.id=#{planId} AND item.deleted=0 AND i.tax_mode='BUY_DECLARATION' AND item.id<=#{maximumId}
""")
long countBuyRows(@Param("planId") long planId,@Param("maximumId") long maximumId);
record InboundRow(Long loadingPlanId,String planNo,Long containerBookingId,String loadingPlanStatus,
String cabinetNo,String containerNo,Long inboundId,Long orderId,String orderNo,String inboundCode,
Long tenantId,String customerName,String taxMode) {}
record ContainerRow(Long id,Long loadingPlanId,Long containerBookingId,String status,Long startedBy,
String startedByName,LocalDateTime startedAt,Long completedBy,String completedByName,
LocalDateTime completedAt,LocalDateTime createdAt,LocalDateTime updatedAt,Integer version) {}
record DocumentRow(Long id,Long customsContainerId,Long tenantId,Long fileId,String fileName,String fileHash,
String contentType,Long sizeBytes,String status,Long uploadedBy,LocalDateTime uploadedAt,
Integer frozenVersion,LocalDateTime frozenAt,Integer version) {}
record ScopeRow(Long documentId,Long inboundId,Long orderId,String inboundCode) {}
record CompletionRow(Long id,Long sourceDocumentId,Long fileId,String fileName,String fileHash,String contentType,
Long sizeBytes,Long uploadedBy,String uploadedByName,LocalDateTime uploadedAt,Integer version) {}
record CommandRow(Long id,Long actorId,String action,String idempotencyKey,String requestHash,String status,
Long resultId,LocalDateTime createdAt,LocalDateTime completedAt) {}
record BuyExportRow(Long itemId,String planNo,String cabinetNo,String containerNo,String customerName,String orderNo,
String inboundCode,String skuCode,String productNameZh,String productNameEn,Integer packageCount,
Integer productQuantity,BigDecimal grossWeightKg,BigDecimal netWeightKg,BigDecimal volumeCbm,
String shippingMark,String customerRemark) {}
record PlanExportScope(Long planId,String planStatus,Long warehouseId) {}
}
@@ -0,0 +1,409 @@
package com.baiyue.firstleg.customs.container.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.customs.client.CustomsFileClient;
import com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.*;
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper;
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper.*;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class ContainerCustomsService {
public static final String WAITING = "WAITING_CUSTOMER_DOCUMENTS";
public static final String READY = "READY_TO_START";
public static final String DECLARING = "DECLARING";
public static final String COMPLETED = "COMPLETED";
private static final String SOURCE_FILE_TYPE = "LOADING_LIST_CUSTOMS_SOURCE";
private static final String RESULT_FILE_TYPE = "LOADING_LIST_CUSTOMS_RESULT";
private static final long MAX_FILE_BYTES = 50L * 1024 * 1024;
@Resource private ContainerCustomsMapper mapper;
@Resource private CustomsFileClient files;
@Resource private ObjectMapper objectMapper;
@Resource private ApplicationEventPublisher events;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
@Transactional
public ContainerView platformView(long planId) {
ContainerRow container = ensure(planId);
return view(container, null, false);
}
@Transactional
public ContainerView merchantOrderView(long tenantId, long orderId) {
Long planId = mapper.completedPlanForOrder(tenantId, orderId);
if (planId == null) throw notFound("订单尚未完成装柜");
ContainerRow container = ensure(planId);
return view(container, tenantId, true);
}
@Transactional
public DocumentView uploadSource(long tenantId, long planId, long operatorId, String key,
List<Long> inboundIds, MultipartFile file) {
ensureMutable(planId);
ContainerRow container = lockEditableContainerByPlan(planId);
List<InboundRow> scope = validateScope(planId, tenantId, inboundIds);
validateFile(file);
long documentId = IdWorker.getId();
String action = "UPLOAD_SOURCE";
String requestHash = hash(action, planId, scope.stream().map(InboundRow::inboundId).toList(), fileHash(file));
CommandRow replay = claim(operatorId, action, key, requestHash);
if (replay != null) return documentView(requiredDocument(replay.resultId()), false);
CustomsFileClient.Metadata metadata = upload(tenantId, SOURCE_FILE_TYPE, Long.toString(planId), operatorId, file);
mapper.insertDocument(documentId, container.id(), tenantId, metadata.fileId(), metadata.fileName(),
metadata.sha256(), metadata.contentType(), size(metadata), operatorId);
replaceScopes(documentId, tenantId, scope);
mapper.insertDocumentVersion(IdWorker.getId(), documentId, 1, metadata.fileId(), metadata.fileName(),
metadata.sha256(), metadata.contentType(), size(metadata), scopeJson(scope), "UPLOAD", operatorId);
refresh(container.loadingPlanId());
completeCommand(operatorId, action, key, documentId);
return documentView(requiredDocument(documentId), false);
}
@Transactional
public DocumentView replaceSource(long tenantId, long documentId, long operatorId, String key,
ScopeRequest request, MultipartFile file) {
validateFile(file);
DocumentRow readable = requiredDocument(documentId);
if (readable.tenantId()!=tenantId) throw notFound("客户报送资料不存在");
ContainerRow container = lockEditableContainer(readable.customsContainerId());
DocumentRow current = requiredDocumentLocked(documentId, tenantId);
List<InboundRow> scope = validateScope(container.loadingPlanId(), tenantId, request.inboundIds());
String action = "REPLACE_SOURCE_" + documentId;
String requestHash = hash(action, request.expectedVersion(), scope.stream().map(InboundRow::inboundId).toList(), fileHash(file));
CommandRow replay = claim(operatorId, action, key, requestHash);
if (replay != null) return documentView(requiredDocument(documentId), false);
CustomsFileClient.Metadata metadata = upload(tenantId, SOURCE_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file);
if (mapper.replaceDocument(documentId, tenantId, request.expectedVersion(), metadata.fileId(), metadata.fileName(),
metadata.sha256(), metadata.contentType(), size(metadata), operatorId) != 1) throw concurrent();
replaceScopes(documentId, tenantId, scope);
int version = request.expectedVersion() + 1;
mapper.insertDocumentVersion(IdWorker.getId(), documentId, version, metadata.fileId(), metadata.fileName(),
metadata.sha256(), metadata.contentType(), size(metadata), scopeJson(scope), "REPLACE", operatorId);
refresh(container.loadingPlanId());
completeCommand(operatorId, action, key, documentId);
return documentView(requiredDocument(documentId), false);
}
@Transactional
public DocumentView updateScope(long tenantId, long documentId, long operatorId, String key, ScopeRequest request) {
DocumentRow readable = requiredDocument(documentId);
if (readable.tenantId()!=tenantId) throw notFound("客户报送资料不存在");
ContainerRow container = lockEditableContainer(readable.customsContainerId());
DocumentRow current = requiredDocumentLocked(documentId, tenantId);
List<InboundRow> scope = validateScope(container.loadingPlanId(), tenantId, request.inboundIds());
String action = "UPDATE_SOURCE_SCOPE_" + documentId;
String requestHash = hash(action, request.expectedVersion(), scope.stream().map(InboundRow::inboundId).toList());
CommandRow replay = claim(operatorId, action, key, requestHash);
if (replay != null) return documentView(requiredDocument(documentId), false);
if (mapper.touchScope(documentId, tenantId, request.expectedVersion()) != 1) throw concurrent();
replaceScopes(documentId, tenantId, scope);
int version = request.expectedVersion() + 1;
mapper.insertDocumentVersion(IdWorker.getId(), documentId, version, current.fileId(), current.fileName(),
current.fileHash(), current.contentType(), current.sizeBytes(), scopeJson(scope), "SCOPE_CHANGE", operatorId);
refresh(container.loadingPlanId());
completeCommand(operatorId, action, key, documentId);
return documentView(requiredDocument(documentId), false);
}
@Transactional
public ContainerView deleteSource(long tenantId, long documentId, long operatorId, String key, int expectedVersion) {
DocumentRow readable = requiredDocument(documentId);
if (readable.tenantId()!=tenantId) throw notFound("客户报送资料不存在");
ContainerRow container = lockEditableContainer(readable.customsContainerId());
DocumentRow current = requiredDocumentLocked(documentId, tenantId);
String action = "DELETE_SOURCE_" + documentId;
String requestHash = hash(action, expectedVersion);
CommandRow replay = claim(operatorId, action, key, requestHash);
if (replay != null) return view(mapper.container(container.loadingPlanId()), tenantId, true);
List<InboundRow> deletedScope=validateScope(container.loadingPlanId(),tenantId,mapper.scopes(documentId).stream().map(ScopeRow::inboundId).toList());
if (mapper.deleteDocument(documentId, tenantId, expectedVersion, operatorId) != 1) throw concurrent();
mapper.insertDocumentVersion(IdWorker.getId(),documentId,expectedVersion+1,current.fileId(),current.fileName(),
current.fileHash(),current.contentType(),current.sizeBytes(),scopeJson(deletedScope),"DELETE",operatorId);
mapper.deleteScopes(documentId);
history(container, "DELETE_CUSTOMER_DOCUMENT", container.status(), container.status(),
"documentId=" + documentId, operatorId, null);
refresh(container.loadingPlanId());
completeCommand(operatorId, action, key, container.id());
return view(mapper.container(container.loadingPlanId()), tenantId, true);
}
@Transactional
public ContainerView start(long planId, long operatorId, String operatorName, String key, ActionRequest request) {
ContainerRow current = ensure(planId);
String action = "START_CONTAINER_CUSTOMS_" + planId;
if(!request.confirm())throw bad("请确认开始整柜报关");
String requestHash = hash(action, request.expectedVersion(), request.confirm(), request.remark());
CommandRow replay = claim(operatorId, action, key, requestHash);
if (replay != null) return platformView(planId);
ContainerRow locked = mapper.lockContainer(planId);
if (locked == null || !READY.equals(locked.status())) throw conflict("当前柜报关资料尚未齐全或已开始报关");
if (!Objects.equals(locked.version(), request.expectedVersion())) throw concurrent();
mapper.lockActiveDocuments(locked.id());
if (mapper.missingRefundCount(planId, locked.id()) != 0) throw conflict("仍有退税入仓码未上传报送资料");
mapper.freezeScopes(locked.id());
mapper.freezeDocuments(locked.id());
if (mapper.start(locked.id(), locked.version(), operatorId, operatorName) != 1) throw concurrent();
history(locked, "START_CUSTOMS", locked.status(), DECLARING, request.remark(), operatorId, operatorName);
completeCommand(operatorId, action, key, locked.id());
return platformView(planId);
}
@Transactional
public DocumentView uploadCompletion(long documentId, long operatorId, String operatorName, String key,
int expectedVersion, boolean finalConfirmation, MultipartFile file) {
validateFile(file);
DocumentRow readable=requiredDocument(documentId);
ContainerRow container = mapper.lockContainerById(readable.customsContainerId());
DocumentRow source = requiredDocumentLocked(documentId, null);
if (container == null || !DECLARING.equals(container.status()) || source.frozenAt() == null) {
throw conflict("当前客户资料不在报关中冻结版本");
}
CompletionRow completion = mapper.lockCompletion(documentId);
int missingBefore=mapper.missingCompletionCount(container.id());
boolean lastNewCompletion=completion==null&&missingBefore==1;
if(lastNewCompletion&&!finalConfirmation)throw conflict("这是最后一份完成文件,请二次确认后上传");
if(finalConfirmation&&!lastNewCompletion)throw conflict("只有最后一份尚未上传的完成文件才能确认整柜完成");
String action = "UPLOAD_COMPLETION_" + documentId;
String requestHash = hash(action, expectedVersion, finalConfirmation, fileHash(file));
CommandRow replay = claim(operatorId, action, key, requestHash);
if (replay != null) return documentView(requiredDocument(documentId), false);
CustomsFileClient.Metadata metadata = upload(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file);
long completionId;
int version;
if (completion == null) {
if (expectedVersion != 0) throw concurrent();
completionId = IdWorker.getId(); version = 1;
mapper.insertCompletion(completionId, documentId, metadata.fileId(), metadata.fileName(), metadata.sha256(),
metadata.contentType(), size(metadata), operatorId, operatorName);
} else {
completionId = completion.id(); version = completion.version() + 1;
if (mapper.replaceCompletion(completion.id(), expectedVersion, metadata.fileId(), metadata.fileName(), metadata.sha256(),
metadata.contentType(), size(metadata), operatorId, operatorName) != 1) throw concurrent();
}
mapper.insertCompletionVersion(IdWorker.getId(), completionId, version, metadata.fileId(), metadata.fileName(),
metadata.sha256(), metadata.contentType(), size(metadata), operatorId, operatorName);
if (lastNewCompletion) {
completeContainer(container, operatorId, operatorName, "全部冻结客户资料已回传完成文件");
}
completeCommand(operatorId, action, key, completionId);
return documentView(requiredDocument(documentId), false);
}
@Transactional
public ContainerView completeAllBuy(long planId, long operatorId, String operatorName, String key, ActionRequest request) {
String action = "COMPLETE_ALL_BUY_CUSTOMS_" + planId;
if(!request.confirm())throw bad("请二次确认全买单柜报关完成");
String requestHash = hash(action, request.expectedVersion(), request.confirm(), request.remark());
CommandRow replay = claim(operatorId, action, key, requestHash);
if (replay != null) return platformView(planId);
ContainerRow container = mapper.lockContainer(planId);
if (container == null || !DECLARING.equals(container.status())) throw conflict("当前柜不在报关中");
if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent();
List<InboundRow> actual=planRows(planId);
if(actual.stream().anyMatch(row->!"BUY_DECLARATION".equals(row.taxMode())))throw conflict("全买单柜完成只允许全部实际行均为BUY_DECLARATION");
completeContainer(container, operatorId, operatorName, request.remark());
completeCommand(operatorId, action, key, container.id());
return platformView(planId);
}
public Download downloadSource(long tenantId, long documentId, boolean platform) {
DocumentRow source = requiredDocument(documentId);
if (!platform && source.tenantId() != tenantId) throw notFound("客户报送资料不存在");
ContainerRow container=mapper.containerById(source.customsContainerId());
return download(source.tenantId(), SOURCE_FILE_TYPE, Long.toString(container.loadingPlanId()), source.fileId(), source.fileName());
}
public Download downloadCompletion(long tenantId, long documentId, boolean platform) {
DocumentRow source = requiredDocument(documentId);
ContainerRow container = mapper.containerById(source.customsContainerId());
if (!platform && (source.tenantId() != tenantId || container == null || !COMPLETED.equals(container.status()))) {
throw notFound("报关完成文件不存在");
}
CompletionRow completion = mapper.completion(documentId);
if (completion == null) throw notFound("报关完成文件不存在");
return download(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), completion.fileId(), completion.fileName());
}
private ContainerRow ensure(long planId) {
List<InboundRow> rows = planRows(planId);
InboundRow first = rows.get(0);
if (!"COMPLETED".equals(first.loadingPlanStatus())) throw conflict("装柜完成后才能处理报关资料");
boolean refund = rows.stream().anyMatch(row -> "TAX_REFUND".equals(row.taxMode()));
mapper.insertContainer(IdWorker.getId(), planId, first.containerBookingId(), refund ? WAITING : READY);
return refresh(planId);
}
private ContainerRow ensureMutable(long planId) {
ContainerRow value = ensure(planId);
if (!Set.of(WAITING, READY).contains(value.status())) throw conflict("整柜已开始报关,客户资料已锁定");
return value;
}
private ContainerRow refresh(long planId) {
ContainerRow value = mapper.container(planId);
if (value == null || !Set.of(WAITING, READY).contains(value.status())) return value;
String status = mapper.missingRefundCount(planId, value.id()) == 0 ? READY : WAITING;
if (!status.equals(value.status())) {
if (mapper.refreshStatus(value.id(), value.version(), status) != 1) throw concurrent();
value = mapper.container(planId);
}
return value;
}
private ContainerRow lockEditableContainer(long containerId) {
ContainerRow value = mapper.lockContainerById(containerId);
if (value == null || !Set.of(WAITING, READY).contains(value.status())) throw conflict("整柜已开始报关,客户资料已锁定");
return value;
}
private ContainerRow lockEditableContainerByPlan(long planId) {
ContainerRow value=mapper.lockContainer(planId);
if(value==null||!Set.of(WAITING,READY).contains(value.status()))throw conflict("整柜已开始报关,客户资料已锁定");
return value;
}
private List<InboundRow> planRows(long planId) {
List<InboundRow> rows = mapper.planInbounds(planId);
if (rows == null || rows.isEmpty()) throw notFound("未找到当前装柜计划的实际货物明细");
return rows;
}
private List<InboundRow> validateScope(long planId, long tenantId, List<Long> inboundIds) {
if (inboundIds == null || inboundIds.isEmpty() || inboundIds.size() > 200) throw bad("至少选择一个且最多200个入仓码");
Set<Long> requested = new LinkedHashSet<>(inboundIds);
List<InboundRow> scope = planRows(planId).stream()
.filter(row -> requested.contains(row.inboundId()) && row.tenantId() == tenantId && "TAX_REFUND".equals(row.taxMode()))
.toList();
if (scope.size() != requested.size()) throw bad("只能选择当前柜内属于本客户的退税入仓码");
return scope;
}
private void replaceScopes(long documentId, long tenantId, List<InboundRow> scope) {
mapper.deleteScopes(documentId);
for (InboundRow row : scope) mapper.insertScope(documentId, row.inboundId(), tenantId, row.orderId(), row.inboundCode());
}
private ContainerView view(ContainerRow container, Long tenantId, boolean merchant) {
List<InboundRow> rows = planRows(container.loadingPlanId());
List<DocumentRow> documents = mapper.documents(container.id(), tenantId);
Set<Long> covered = documents.stream().flatMap(doc -> mapper.scopes(doc.id()).stream()).map(ScopeRow::inboundId).collect(Collectors.toSet());
List<InboundRow> visibleRows=rows.stream().filter(row -> tenantId == null || (row.tenantId().equals(tenantId)&&"TAX_REFUND".equals(row.taxMode()))).toList();
List<InboundView> inbounds = visibleRows.stream()
.map(row -> new InboundView(row.inboundId(), row.orderId(), row.orderNo(), row.inboundCode(), row.tenantId(),
row.customerName(), row.taxMode(), covered.contains(row.inboundId()))).toList();
List<DocumentView> documentViews = documents.stream().map(doc -> documentView(doc, merchant)).toList();
int refund = (int) visibleRows.stream().filter(row -> "TAX_REFUND".equals(row.taxMode())).count();
int coveredRefund = (int) visibleRows.stream().filter(row -> covered.contains(row.inboundId())).count();
String merchantStatus=tenantId==null?null:merchantStatus(container.status(),refund,coveredRefund);
InboundRow first = rows.get(0);
return new ContainerView(container.id(), container.loadingPlanId(), container.containerBookingId(), first.planNo(),
first.cabinetNo(), first.containerNo(), container.status(), merchantStatus, container.version(), refund, coveredRefund,
inbounds, documentViews, container.startedAt(), container.completedAt());
}
private DocumentView documentView(DocumentRow source, boolean merchant) {
List<ScopeRow> scopes = source.frozenAt() == null ? mapper.scopes(source.id()) : mapper.frozenScopes(source.id());
CompletionRow completion = mapper.completion(source.id());
ContainerRow container = mapper.containerById(source.customsContainerId());
FileView result = completion == null || (merchant && !COMPLETED.equals(container.status())) ? null : file(completion);
return new DocumentView(source.id(), source.tenantId(), file(source), scopes.stream().map(ScopeRow::inboundId).toList(),
scopes.stream().map(ScopeRow::inboundCode).toList(), source.frozenAt() != null, result);
}
private void completeContainer(ContainerRow container, long operatorId, String operatorName, String remark) {
if (mapper.complete(container.id(), container.version(), operatorId, operatorName) != 1) throw concurrent();
history(container, "COMPLETE_CUSTOMS", container.status(), COMPLETED, remark, operatorId, operatorName);
events.publishEvent(new CustomsCompletedEvent(container.containerBookingId()));
}
private void history(ContainerRow container,String action,String from,String to,String remark,long operatorId,String operatorName) {
mapper.insertHistory(IdWorker.getId(), container.id(), action, from, to, trim(remark), operatorId, trim(operatorName));
}
private CommandRow claim(long actorId, String action, String key, String requestHash) {
validateKey(key);
if (mapper.claimCommand(IdWorker.getId(), actorId, action, key, requestHash) == 1) return null;
CommandRow existing = mapper.command(actorId, action, key);
if (existing == null || !Objects.equals(existing.requestHash(), requestHash)) throw conflict("Idempotency-Key已用于不同请求");
if (!"SUCCESS".equals(existing.status())) throw conflict("相同请求正在处理中");
return existing;
}
private void completeCommand(long actorId,String action,String key,long resultId) {
if (mapper.completeCommand(actorId, action, key, resultId) != 1) throw concurrent();
}
private CustomsFileClient.Metadata upload(long tenantId,String type,String businessId,long operatorId,MultipartFile file) {
var response = files.upload(internalToken, operatorId, tenantId, type, businessId, file);
if (response == null || !response.successful() || response.data() == null || response.data().fileId() == null) {
throw conflict("File服务上传失败");
}
CustomsFileClient.Metadata metadata = response.data();
if (metadata.tenantId() == null || metadata.tenantId() != tenantId || !type.equals(metadata.businessType())
|| !businessId.equals(metadata.businessId())) throw conflict("File服务返回的文件归属不一致");
return metadata;
}
private Download download(long tenantId,String type,String businessId,long fileId,String fileName) {
ResponseEntity<byte[]> response = files.download(internalToken, tenantId, type, businessId, fileId);
if (response == null || response.getBody() == null) throw notFound("文件不存在");
MediaType contentType = response.getHeaders().getContentType();
return new Download(fileName, contentType == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE : contentType.toString(), response.getBody());
}
private DocumentRow requiredDocument(long id) {
DocumentRow value = mapper.document(id);
if (value == null || !"ACTIVE".equals(value.status())) throw notFound("客户报送资料不存在");
return value;
}
private DocumentRow requiredDocumentLocked(long id, Long tenantId) {
DocumentRow value = mapper.lockDocument(id);
if (value == null || !"ACTIVE".equals(value.status()) || (tenantId != null && !tenantId.equals(value.tenantId()))) {
throw notFound("客户报送资料不存在");
}
return value;
}
private String scopeJson(List<InboundRow> scope) {
try { return objectMapper.writeValueAsString(scope.stream().map(row -> Map.of("inboundId", row.inboundId(),
"orderId", row.orderId(), "inboundCode", row.inboundCode())).toList()); }
catch (JsonProcessingException ex) { throw new IllegalStateException(ex); }
}
private static FileView file(DocumentRow row) { return new FileView(row.fileId(),row.fileName(),row.fileHash(),row.contentType(),row.sizeBytes(),row.version(),row.uploadedAt()); }
private static FileView file(CompletionRow row) { return new FileView(row.fileId(),row.fileName(),row.fileHash(),row.contentType(),row.sizeBytes(),row.version(),row.uploadedAt()); }
private static long size(CustomsFileClient.Metadata value) { return value.sizeBytes() == null ? 0 : value.sizeBytes(); }
private static void validateFile(MultipartFile file) { if (file == null || file.isEmpty()) throw bad("请选择文件"); if (file.getSize() > MAX_FILE_BYTES) throw bad("单文件不能超过50MB"); }
private static String fileHash(MultipartFile file) { try (InputStream input=file.getInputStream()) { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(input.readAllBytes())); } catch(Exception ex){ throw bad("无法读取上传文件"); } }
private static String hash(Object... values) { try { MessageDigest digest=MessageDigest.getInstance("SHA-256"); for(Object value:values) digest.update((Objects.toString(value,"")+"\u001f").getBytes(StandardCharsets.UTF_8)); return HexFormat.of().formatHex(digest.digest()); } catch(Exception ex){ throw new IllegalStateException(ex); } }
private static void validateKey(String key) { if (key == null || key.isBlank() || key.length() > 128) throw bad("Idempotency-Key不能为空且长度不能超过128"); }
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static String merchantStatus(String containerStatus,int refund,int covered){if(COMPLETED.equals(containerStatus))return "CUSTOMS_COMPLETED";if(DECLARING.equals(containerStatus))return "DECLARING";return refund>0&&refund==covered?"DOCUMENT_UPLOADED":"WAITING_UPLOAD";}
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); }
public record CustomsCompletedEvent(long containerBookingId) {}
}
@@ -0,0 +1,38 @@
package com.baiyue.firstleg.customs.container.service;
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper;
import com.baiyue.firstleg.customs.container.service.ContainerCustomsService.CustomsCompletedEvent;
import com.baiyue.firstleg.outbound.dispatch.service.OutboundDispatchAutoConfirmService;
import jakarta.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
@Component
public class CustomsAutoDispatchCoordinator {
private static final Logger log=LoggerFactory.getLogger(CustomsAutoDispatchCoordinator.class);
private static final int BATCH_SIZE=50;
@Resource private ContainerCustomsMapper mapper;
@Resource private OutboundDispatchAutoConfirmService outbound;
@TransactionalEventListener(phase=TransactionPhase.AFTER_COMMIT)
public void afterCustomsCompleted(CustomsCompletedEvent event) {
tryConfirm(event.containerBookingId());
}
@Scheduled(fixedDelayString="${baiyue.customs.auto-dispatch.fixed-delay-ms:60000}")
public void retryPending() {
for(Long containerBookingId:mapper.pendingAutoDispatch(BATCH_SIZE)) tryConfirm(containerBookingId);
}
private void tryConfirm(long containerBookingId) {
try {
outbound.confirmIfReady(containerBookingId);
} catch (RuntimeException ex) {
log.warn("整柜报关完成后的自动离仓暂未收敛,稍后重试: containerBookingId={}",containerBookingId,ex);
}
}
}
@@ -0,0 +1,73 @@
package com.baiyue.firstleg.customs.container.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.export.ExportContracts.*;
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper;
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper.BuyExportRow;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
@Service
public class LoadingListBuyDeclarationExportService {
public static final String TYPE="LOADING_LIST_BUY_DECLARATION_ITEMS";
private static final int MAX_BATCH=500;
private static final List<ExportColumn> COLUMNS=List.of(
c("customerName","客户名称",ExportColumnType.STRING,24),c("orderNo","订单号",ExportColumnType.STRING,20),
c("inboundCode","入仓码",ExportColumnType.STRING,22),c("sku","SKU",ExportColumnType.STRING,18),
c("chineseName","中文品名",ExportColumnType.STRING,24),c("englishName","英文品名",ExportColumnType.STRING,24),
c("cartonCount","箱数",ExportColumnType.INTEGER,12),c("productQuantity","产品数量",ExportColumnType.INTEGER,14),
c("grossWeight","毛重",ExportColumnType.DECIMAL,14),c("netWeight","净重",ExportColumnType.DECIMAL,14),
c("volume","体积",ExportColumnType.DECIMAL,14),c("mark","唛头",ExportColumnType.STRING,20),
c("customerRemark","客户备注",ExportColumnType.STRING,30));
private static final String SCHEMA_HASH=schemaHash();
@Resource private ContainerCustomsMapper mapper;
@Value("${baiyue.security.internal-service-token}") private String token;
private byte[] signingKey;
@PostConstruct public void init(){signingKey=token.getBytes(StandardCharsets.UTF_8);}
public ExportBatchResponse batch(ExportBatchRequest request){
if(request==null||!"0".equals(request.tenantId())||!"PLATFORM".equals(request.appCode())) throw invalid("仅支持平台导出");
if(request.limit()<1||request.limit()>MAX_BATCH) throw invalid("limit必须为1到500");
long planId=positive(parameter(request,"loadingPlanId"),"loadingPlanId");
var scope=mapper.planExportScope(planId);
if(scope==null||!"COMPLETED".equals(scope.planStatus())) throw invalid("只能导出已完成装柜的装柜清单");
boolean first=request.snapshotToken()==null||request.snapshotToken().isBlank();
long maximumId=first?mapper.maximumBuyItemId(planId):verify(request,planId);
String snapshot=first?sign(request,planId,maximumId):request.snapshotToken();
long cursor=request.cursor()==null||request.cursor().isBlank()?0:positiveOrZero(request.cursor(),"cursor");
List<BuyExportRow> source=mapper.buyRows(planId,cursor,maximumId,request.limit()+1);
boolean more=source.size()>request.limit();
List<BuyExportRow> page=more?source.subList(0,request.limit()):source;
String next=page.isEmpty()?String.valueOf(cursor):String.valueOf(page.get(page.size()-1).itemId());
return new ExportBatchResponse(1,SCHEMA_HASH,snapshot,first?COLUMNS:List.of(),page.stream().map(this::row).toList(),
next,more,first?mapper.countBuyRows(planId,maximumId):null);
}
private Map<String,Object> row(BuyExportRow value){Map<String,Object> row=new LinkedHashMap<>();
row.put("customerName",value.customerName());row.put("orderNo",value.orderNo());row.put("inboundCode",value.inboundCode());
row.put("sku",value.skuCode());row.put("chineseName",value.productNameZh());row.put("englishName",value.productNameEn());
row.put("cartonCount",value.packageCount());row.put("productQuantity",value.productQuantity());row.put("grossWeight",value.grossWeightKg());
row.put("netWeight",value.netWeightKg());row.put("volume",value.volumeCbm());row.put("mark",value.shippingMark());
row.put("customerRemark",value.customerRemark());return row;}
private String sign(ExportBatchRequest r,long p,long m){return "v1."+m+"."+Base64.getUrlEncoder().withoutPadding().encodeToString(hmac(subject(r,p,m)));}
private long verify(ExportBatchRequest r,long p){String[] a=r.snapshotToken().split("\\.",-1);if(a.length!=3||!"v1".equals(a[0]))throw invalid("snapshotToken无效");
try{long m=Long.parseLong(a[1]);if(!MessageDigest.isEqual(hmac(subject(r,p,m)),Base64.getUrlDecoder().decode(a[2])))throw invalid("snapshotToken不匹配");return m;}
catch(IllegalArgumentException ex){throw invalid("snapshotToken无效");}}
private String subject(ExportBatchRequest r,long p,long m){return String.join("\n",TYPE,r.taskId(),r.tenantId(),r.appCode(),r.requestedBy(),String.valueOf(r.dispatchVersion()),String.valueOf(p),String.valueOf(m));}
private byte[] hmac(String s){try{Mac mac=Mac.getInstance("HmacSHA256");mac.init(new SecretKeySpec(signingKey,"HmacSHA256"));return mac.doFinal(s.getBytes(StandardCharsets.UTF_8));}catch(Exception ex){throw new IllegalStateException(ex);}}
private static Object parameter(ExportBatchRequest r,String k){if(r.parameters()==null||!r.parameters().keySet().equals(Set.of("loadingPlanId")))throw invalid("导出参数必须且只能包含loadingPlanId");return r.parameters().get(k);}
private static long positive(Object v,String f){long n=v instanceof Number x?x.longValue():positiveOrZero(Objects.toString(v,""),f);if(n<=0)throw invalid(f+"必须为正整数");return n;}
private static long positiveOrZero(String v,String f){try{long n=Long.parseLong(v);if(n<0)throw invalid(f+"不能为负数");return n;}catch(NumberFormatException ex){throw invalid(f+"必须为整数");}}
private static ExportColumn c(String k,String t,ExportColumnType y,int w){return new ExportColumn(k,t,y,w);}
private static String schemaHash(){try{MessageDigest d=MessageDigest.getInstance("SHA-256");for(ExportColumn c:COLUMNS)d.update((c.key()+"|"+c.title()+"|"+c.type()+"|"+c.width()+"\n").getBytes(StandardCharsets.UTF_8));return "sha256:"+HexFormat.of().formatHex(d.digest());}catch(Exception ex){throw new ExceptionInInitializerError(ex);}}
private static BizException invalid(String m){return new BizException(ResultCode.VALIDATION_FAILED,m);}
}
@@ -1,88 +0,0 @@
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("\"", ""); }
}
@@ -1,161 +0,0 @@
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("\"", ""); }
}
@@ -1,80 +0,0 @@
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(@NotNull @jakarta.validation.constraints.Positive Long customsAgentId,
@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) {}
}
@@ -1,28 +0,0 @@
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;
}
@@ -1,21 +0,0 @@
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;
}
@@ -1,27 +0,0 @@
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;
}
@@ -1,22 +0,0 @@
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;
}
@@ -1,20 +0,0 @@
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;
}
@@ -1,24 +0,0 @@
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;
}
@@ -1,23 +0,0 @@
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 Long customsAgentId;
private String customsAgentName;
private String externalReference;
private String fileSnapshotJson;
private String status;
private String note;
private Long submittedBy;
private String submittedByName;
private LocalDateTime submittedAt;
}
@@ -1,23 +0,0 @@
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;
}
@@ -1,11 +0,0 @@
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;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper public interface CustomsCaseMapper extends BaseMapper<CustomsCaseEntity> {
@Select("SELECT COUNT(*) FROM fl_container_document WHERE container_booking_id=#{containerBookingId} "
+ "AND document_type='CUSTOMS_DECLARATION' AND completed_at IS NOT NULL")
int completedLoadingDeclaration(@Param("containerBookingId") long containerBookingId);
}
@@ -1,5 +0,0 @@
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> {}
@@ -1,5 +0,0 @@
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> {}
@@ -1,5 +0,0 @@
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> {}
@@ -1,5 +0,0 @@
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> {}
@@ -1,5 +0,0 @@
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> {}
@@ -1,5 +0,0 @@
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> {}
@@ -1,5 +0,0 @@
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> {}
@@ -1,191 +0,0 @@
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 loadingDeclarationCompleted(long containerBookingId) {
return cases.completedLoadingDeclaration(containerBookingId) == 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()));
}
}
@@ -1,6 +1,6 @@
package com.baiyue.firstleg.customs.service; package com.baiyue.firstleg.customs.service;
import com.baiyue.firstleg.customs.repository.CustomsRepository; import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper;
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.GateItem; import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.GateItem;
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor; import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -8,7 +8,7 @@ import org.springframework.stereotype.Component;
@Component @Component
public class CustomsDispatchGateContributor implements OutboundDispatchGateContributor { public class CustomsDispatchGateContributor implements OutboundDispatchGateContributor {
@Resource private CustomsRepository repository; @Resource private ContainerCustomsMapper mapper;
@Override public String code() { return "CUSTOMS_CLEARANCE"; } @Override public String code() { return "CUSTOMS_CLEARANCE"; }
@@ -17,8 +17,9 @@ public class CustomsDispatchGateContributor implements OutboundDispatchGateContr
if (context.tenantId() <= 0 || context.customerId() <= 0) { if (context.tenantId() <= 0 || context.customerId() <= 0) {
return gate(false, "INVALID_CUSTOMER_SCOPE"); return gate(false, "INVALID_CUSTOMER_SCOPE");
} }
boolean completed = repository.loadingDeclarationCompleted(context.containerBookingId()); var completed = mapper.containerByBooking(context.containerBookingId());
return gate(completed, completed ? "COMPLETED" : "MISSING"); boolean passed = completed != null && "COMPLETED".equals(completed.status());
return gate(passed, passed ? "COMPLETED" : completed == null ? "MISSING" : completed.status());
} }
private static GateItem gate(boolean passed, String actual) { private static GateItem gate(boolean passed, String actual) {
@@ -1,482 +0,0 @@
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.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
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;
@Resource private WarehouseSupplierSnapshotService suppliers;
@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());
var customsAgent = suppliers.customs(request.customsAgentId(), request.customsAgentName());
batch.setCustomsAgentId(customsAgent == null ? null : customsAgent.supplierId());
batch.setCustomsAgentName(customsAgent == null ? null : customsAgent.name());
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); }
}
@@ -23,9 +23,7 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
COALESCE((SELECT CASE WHEN d.completed_at IS NOT NULL THEN 'COMPLETED' ELSE 'UPLOADED' END COALESCE((SELECT CASE WHEN d.completed_at IS NOT NULL THEN 'COMPLETED' ELSE 'UPLOADED' END
FROM fl_container_document d WHERE d.container_booking_id=b.id FROM fl_container_document d WHERE d.container_booking_id=b.id
AND d.document_type='DESTINATION_CLEARANCE' LIMIT 1),'MISSING') AS destination_clearance_document_status, AND d.document_type='DESTINATION_CLEARANCE' LIMIT 1),'MISSING') AS destination_clearance_document_status,
COALESCE((SELECT CASE WHEN d.completed_at IS NOT NULL THEN 'COMPLETED' ELSE 'UPLOADED' END COALESCE((SELECT c.status FROM fl_customs_container c WHERE c.container_booking_id=b.id LIMIT 1),'MISSING') AS customs_declaration_document_status,
FROM fl_container_document d WHERE d.container_booking_id=b.id
AND d.document_type='CUSTOMS_DECLARATION' LIMIT 1),'MISSING') AS customs_declaration_document_status,
COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id
AND d.document_type='DELIVERY_ORDER' LIMIT 1),'MISSING') AS delivery_order_document_status, AND d.document_type='DELIVERY_ORDER' LIMIT 1),'MISSING') AS delivery_order_document_status,
COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id
@@ -85,9 +83,7 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
COALESCE((SELECT CASE WHEN d.completed_at IS NOT NULL THEN 'COMPLETED' ELSE 'UPLOADED' END COALESCE((SELECT CASE WHEN d.completed_at IS NOT NULL THEN 'COMPLETED' ELSE 'UPLOADED' END
FROM fl_container_document d WHERE d.container_booking_id=b.id FROM fl_container_document d WHERE d.container_booking_id=b.id
AND d.document_type='DESTINATION_CLEARANCE' LIMIT 1),'MISSING') AS destination_clearance_document_status, AND d.document_type='DESTINATION_CLEARANCE' LIMIT 1),'MISSING') AS destination_clearance_document_status,
COALESCE((SELECT CASE WHEN d.completed_at IS NOT NULL THEN 'COMPLETED' ELSE 'UPLOADED' END COALESCE((SELECT c.status FROM fl_customs_container c WHERE c.container_booking_id=b.id LIMIT 1),'MISSING') AS customs_declaration_document_status,
FROM fl_container_document d WHERE d.container_booking_id=b.id
AND d.document_type='CUSTOMS_DECLARATION' LIMIT 1),'MISSING') AS customs_declaration_document_status,
COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id
AND d.document_type='DELIVERY_ORDER' LIMIT 1),'MISSING') AS delivery_order_document_status, AND d.document_type='DELIVERY_ORDER' LIMIT 1),'MISSING') AS delivery_order_document_status,
COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id
@@ -4,7 +4,7 @@ import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity;
import org.apache.ibatis.annotations.*; import org.apache.ibatis.annotations.*;
import java.util.List; import java.util.List;
@Mapper public interface ContainerOverviewMapper { @Mapper public interface ContainerOverviewMapper {
@Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION','DELIVERY_ORDER','PICKUP_ORDER') ORDER BY document_type") List<ContainerDocumentEntity> documents(@Param("id") long id); @Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER') ORDER BY document_type") List<ContainerDocumentEntity> documents(@Param("id") long id);
@Select("SELECT * FROM fl_container_transport_history WHERE container_booking_id=#{id} ORDER BY created_at,id") List<ContainerTransportHistoryEntity> transport(@Param("id") long id); @Select("SELECT * FROM fl_container_transport_history WHERE container_booking_id=#{id} ORDER BY created_at,id") List<ContainerTransportHistoryEntity> transport(@Param("id") long id);
@Select("SELECT id,action,reason,operator_id operatorId,operator_name operatorName,occurred_at occurredAt,request_hash requestHash FROM fl_container_exception_history WHERE container_booking_id=#{id} ORDER BY occurred_at,id") List<ExceptionRow> exceptions(@Param("id") long id); @Select("SELECT id,action,reason,operator_id operatorId,operator_name operatorName,occurred_at occurredAt,request_hash requestHash FROM fl_container_exception_history WHERE container_booking_id=#{id} ORDER BY occurred_at,id") List<ExceptionRow> exceptions(@Param("id") long id);
@Insert("INSERT INTO fl_container_exception_history(id,container_booking_id,action,reason,operator_id,operator_name,operation_id,request_hash,occurred_at) VALUES(#{id},#{bookingId},#{action},#{reason},#{operatorId},#{operatorName},#{operationId},#{requestHash},CURRENT_TIMESTAMP(3))") int insertException(@Param("id") long id,@Param("bookingId") long bookingId,@Param("action") String action,@Param("reason") String reason,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName,@Param("operationId") String operationId,@Param("requestHash") String requestHash); @Insert("INSERT INTO fl_container_exception_history(id,container_booking_id,action,reason,operator_id,operator_name,operation_id,request_hash,occurred_at) VALUES(#{id},#{bookingId},#{action},#{reason},#{operatorId},#{operatorName},#{operationId},#{requestHash},CURRENT_TIMESTAMP(3))") int insertException(@Param("id") long id,@Param("bookingId") long bookingId,@Param("action") String action,@Param("reason") String reason,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName,@Param("operationId") String operationId,@Param("requestHash") String requestHash);
@@ -103,10 +103,9 @@ public interface OutboundDispatchReadMapper {
SELECT 'TRUCK_APPOINTMENT',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.reason,h.created_at 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} FROM fl_truck_appointment_history h WHERE h.appointment_id=#{truckAppointmentId}
UNION ALL UNION ALL
SELECT 'CUSTOMS',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.note,h.created_at SELECT 'CUSTOMS',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.remark,h.created_at
FROM fl_customs_history h JOIN fl_customs_case customs_case ON customs_case.id=h.customs_case_id FROM fl_customs_container_history h JOIN fl_customs_container customs_container ON customs_container.id=h.customs_container_id
WHERE customs_case.tenant_id=#{tenantId} AND customs_case.order_id=#{orderId} WHERE customs_container.container_booking_id=#{containerBookingId}
AND customs_case.inbound_id=#{inboundId} AND customs_case.container_booking_id=#{containerBookingId}
UNION ALL UNION ALL
SELECT 'SAILING_SCHEDULE',h.action,NULL,NULL,h.operator_id,h.operator_name,h.reason,h.created_at SELECT 'SAILING_SCHEDULE',h.action,NULL,NULL,h.operator_id,h.operator_name,h.reason,h.created_at
FROM fl_port_schedule_history h JOIN fl_port_schedule port_schedule ON port_schedule.id=h.port_schedule_id FROM fl_port_schedule_history h JOIN fl_port_schedule port_schedule ON port_schedule.id=h.port_schedule_id
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.outbound.dispatch.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.outbound.dispatch.domain.OutboundDispatchStatus;
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.ActionRequest;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** Real proxy-owned transaction boundary for repeated system convergence after customs completion. */
@Service
public class OutboundDispatchAutoConfirmService {
@Resource private OutboundDispatchService dispatch;
@Transactional
public boolean confirmIfReady(long containerBookingId) {
var current=dispatch.detail(containerBookingId);
if(OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(current.aggregate().dispatchStatus()))return true;
if(!current.aggregate().gate().passed())return false;
try{
dispatch.confirm(containerBookingId,0L,"SYSTEM","CUSTOMS-AUTO-DISPATCH-"+containerBookingId,
new ActionRequest(current.aggregate().version(),"整柜报关完成且全部离仓门禁满足,系统自动确认离仓"));
return true;
}catch(BizException ex){
var after=dispatch.detail(containerBookingId);
if(OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(after.aggregate().dispatchStatus()))return true;
throw ex;
}
}
}
@@ -52,11 +52,9 @@ import java.util.Set;
public class LoadingListService { public class LoadingListService {
private static final Set<String> PRELOAD_STATUSES = Set.of("EDITING", "LOADING"); private static final Set<String> PRELOAD_STATUSES = Set.of("EDITING", "LOADING");
public static final String DESTINATION_CLEARANCE = "DESTINATION_CLEARANCE"; public static final String DESTINATION_CLEARANCE = "DESTINATION_CLEARANCE";
public static final String CUSTOMS_DECLARATION = "CUSTOMS_DECLARATION";
public static final String DELIVERY_ORDER = "DELIVERY_ORDER"; public static final String DELIVERY_ORDER = "DELIVERY_ORDER";
public static final String PICKUP_ORDER = "PICKUP_ORDER"; public static final String PICKUP_ORDER = "PICKUP_ORDER";
private static final String CLEARANCE_FILE_TYPE = "CONTAINER_CLEARANCE_DOCUMENT"; private static final String CLEARANCE_FILE_TYPE = "CONTAINER_CLEARANCE_DOCUMENT";
private static final String DECLARATION_FILE_TYPE = "LOADING_CUSTOMS_DECLARATION";
private static final String DO_FILE_TYPE = "CONTAINER_DELIVERY_ORDER"; private static final String DO_FILE_TYPE = "CONTAINER_DELIVERY_ORDER";
private static final String PU_FILE_TYPE = "CONTAINER_PICKUP_ORDER"; private static final String PU_FILE_TYPE = "CONTAINER_PICKUP_ORDER";
@Resource @Resource
@@ -198,89 +196,6 @@ public class LoadingListService {
operatorId, operatorName, key, request); operatorId, operatorName, key, request);
} }
public BusinessDocument loadingDeclaration(long planId, long warehouseId) {
AggregateRow plan = requirePlan(planId);
requireWarehouse(plan, warehouseId);
requireCompletedPlan(plan);
return document(repository.document(plan.getContainerBookingId(), CUSTOMS_DECLARATION));
}
public DocumentDownload downloadLoadingDeclaration(long planId, long warehouseId, long fileId) {
AggregateRow plan = requirePlan(planId);
requireWarehouse(plan, warehouseId);
requireCompletedPlan(plan);
ContainerDocumentEntity document = repository.document(plan.getContainerBookingId(), CUSTOMS_DECLARATION);
if (document == null || !Long.valueOf(fileId).equals(document.getFileId())) {
throw notFound("出口报关附件不存在或不属于当前装柜计划");
}
String businessId = Long.toString(planId);
try {
var metadata = documentFiles.metadata(internalToken, 0, DECLARATION_FILE_TYPE, businessId, fileId);
if (metadata == null || !metadata.successful() || metadata.data() == null
|| !Long.valueOf(fileId).equals(metadata.data().fileId())
|| !Long.valueOf(0).equals(metadata.data().tenantId())
|| !DECLARATION_FILE_TYPE.equals(metadata.data().businessType())
|| !businessId.equals(metadata.data().businessId())) {
throw notFound("出口报关附件不存在或归属不匹配");
}
var response = documentFiles.download(internalToken, 0, DECLARATION_FILE_TYPE, businessId, fileId);
if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
throw notFound("出口报关附件不存在");
}
MediaType contentType;
try {
contentType = MediaType.parseMediaType(metadata.data().contentType());
} catch (RuntimeException exception) {
contentType = MediaType.APPLICATION_OCTET_STREAM;
}
String fileName = StringUtils.hasText(document.getFileName())
? document.getFileName() : metadata.data().fileName();
return new DocumentDownload(fileName, contentType, response.getBody());
} catch (FeignException.NotFound exception) {
throw notFound("出口报关附件不存在或归属不匹配");
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用");
}
}
@Transactional
@BusinessAudit(event = "LOADING_CUSTOMS_DOCUMENT_ATTACH", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "上传出口报关文档")
public BusinessDocument attachLoadingDeclaration(long planId, long warehouseId, long operatorId,
String operatorName, String key,
DocumentAttachRequest request) {
AggregateRow plan = requirePlan(planId);
requireWarehouse(plan, warehouseId);
requireCompletedPlan(plan);
return attachDocument(plan, CUSTOMS_DECLARATION, DECLARATION_FILE_TYPE,
planId, Long.toString(planId), operatorId, operatorName, key, request);
}
@Transactional
@BusinessAudit(event = "LOADING_CUSTOMS_DOCUMENT_UPLOAD", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "直接上传出口报关文档")
public BusinessDocument uploadLoadingDeclaration(long planId, long warehouseId, long operatorId,
String operatorName, String key, int expectedVersion,
String operationReason, MultipartFile file) {
AggregateRow plan = requirePlan(planId);
requireWarehouse(plan, warehouseId);
requireCompletedPlan(plan);
return uploadDocument(plan, CUSTOMS_DECLARATION, DECLARATION_FILE_TYPE, planId,
Long.toString(planId), operatorId, operatorName, key, expectedVersion, operationReason, file);
}
@Transactional
@BusinessAudit(event = "LOADING_CUSTOMS_COMPLETE", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "确认出口报关完成")
public BusinessDocument completeLoadingDeclaration(long planId, long warehouseId, long operatorId,
String operatorName, String key,
DocumentCompleteRequest request) {
AggregateRow plan = requirePlan(planId);
requireWarehouse(plan, warehouseId);
requireCompletedPlan(plan);
return completeDocument(plan, CUSTOMS_DECLARATION, planId, operatorId, operatorName, key, request);
}
private BusinessDocument attachDocument(AggregateRow plan, String documentType, String businessType, private BusinessDocument attachDocument(AggregateRow plan, String documentType, String businessType,
long identityId, String businessId, long operatorId, String operatorName, long identityId, String businessId, long operatorId, String operatorName,
String key, DocumentAttachRequest request) { String key, DocumentAttachRequest request) {
@@ -450,7 +365,7 @@ public class LoadingListService {
} }
private static void requireCompletedPlan(AggregateRow plan) { private static void requireCompletedPlan(AggregateRow plan) {
if (!"COMPLETED".equals(plan.getPlanStatus())) throw conflict("仅完成装柜清单可办理报关"); if (!"COMPLETED".equals(plan.getPlanStatus())) throw conflict("仅完成装柜清单可维护后续单证");
} }
private ContainerDocumentEntity requireDocumentEntity(long bookingId, String documentType) { private ContainerDocumentEntity requireDocumentEntity(long bookingId, String documentType) {
@@ -0,0 +1,174 @@
SET NAMES utf8mb4;
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_customs_container (
id BIGINT UNSIGNED NOT NULL,
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '已完成装柜计划ID;报关柜级聚合唯一键',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '物理货柜ID',
status VARCHAR(40) NOT NULL COMMENT 'WAITING_CUSTOMER_DOCUMENTS、READY_TO_START、DECLARING、COMPLETED',
started_by BIGINT UNSIGNED NULL,
started_by_name VARCHAR(64) NULL,
started_at DATETIME(3) NULL,
completed_by BIGINT UNSIGNED NULL,
completed_by_name VARCHAR(64) NULL,
completed_at DATETIME(3) 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),
version INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY(id),
UNIQUE KEY uk_customs_container_plan(loading_plan_id),
UNIQUE KEY uk_customs_container_booking(container_booking_id),
KEY idx_customs_container_status(status,updated_at),
CONSTRAINT chk_customs_container_status CHECK(status IN
('WAITING_CUSTOMER_DOCUMENTS','READY_TO_START','DECLARING','COMPLETED'))
) ENGINE=InnoDB COMMENT='装柜完成后的整柜出口报关聚合';
CREATE TABLE IF NOT EXISTS fl_customs_customer_document (
id BIGINT UNSIGNED NOT NULL,
customs_container_id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键;只允许关联同客户退税票',
file_id BIGINT UNSIGNED NOT NULL,
file_name VARCHAR(255) NOT NULL,
file_hash CHAR(64) NOT NULL,
content_type VARCHAR(128) NULL,
size_bytes BIGINT UNSIGNED NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
uploaded_by BIGINT UNSIGNED NOT NULL,
uploaded_at DATETIME(3) NOT NULL,
frozen_version INT UNSIGNED NULL,
frozen_at DATETIME(3) NULL,
deleted_by BIGINT UNSIGNED NULL,
deleted_at DATETIME(3) NULL,
version INT UNSIGNED NOT NULL DEFAULT 1,
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),
KEY idx_customs_customer_document_container(customs_container_id,status,tenant_id),
CONSTRAINT fk_customs_customer_document_container FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id),
CONSTRAINT chk_customs_customer_document_status CHECK(status IN('ACTIVE','DELETED')),
CONSTRAINT chk_customs_customer_document_freeze CHECK((frozen_at IS NULL AND frozen_version IS NULL) OR
(frozen_at IS NOT NULL AND frozen_version IS NOT NULL))
) ENGINE=InnoDB COMMENT='客户整份退税报送资料逻辑根';
CREATE TABLE IF NOT EXISTS fl_customs_customer_document_inbound (
document_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
inbound_code VARCHAR(40) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(document_id,inbound_id),
KEY idx_customs_document_inbound(inbound_id,tenant_id),
CONSTRAINT fk_customs_document_inbound_document FOREIGN KEY(document_id) REFERENCES fl_customs_customer_document(id)
) ENGINE=InnoDB COMMENT='客户报送资料与当前柜退税入仓码多对多关系';
CREATE TABLE IF NOT EXISTS fl_customs_customer_document_version (
id BIGINT UNSIGNED NOT NULL,
document_id BIGINT UNSIGNED NOT NULL,
document_version INT UNSIGNED NOT NULL,
file_id BIGINT UNSIGNED NOT NULL,
file_name VARCHAR(255) NOT NULL,
file_hash CHAR(64) NOT NULL,
content_type VARCHAR(128) NULL,
size_bytes BIGINT UNSIGNED NOT NULL,
scope_snapshot_json JSON NOT NULL,
action VARCHAR(24) NOT NULL COMMENT 'UPLOAD、REPLACE、SCOPE_CHANGE、DELETE',
operator_id BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE KEY uk_customs_customer_document_version(document_id,document_version),
CONSTRAINT fk_customs_customer_document_version FOREIGN KEY(document_id) REFERENCES fl_customs_customer_document(id),
CONSTRAINT chk_customs_customer_document_version_action CHECK(action IN('UPLOAD','REPLACE','SCOPE_CHANGE','DELETE'))
) ENGINE=InnoDB COMMENT='客户报送资料文件与覆盖范围不可变版本';
CREATE TABLE IF NOT EXISTS fl_customs_frozen_document_inbound (
document_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
inbound_code VARCHAR(40) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(document_id,inbound_id),
CONSTRAINT fk_customs_frozen_document FOREIGN KEY(document_id) REFERENCES fl_customs_customer_document(id)
) ENGINE=InnoDB COMMENT='开始报关时冻结的客户资料覆盖范围';
CREATE TABLE IF NOT EXISTS fl_customs_completion_document (
id BIGINT UNSIGNED NOT NULL,
source_document_id BIGINT UNSIGNED NOT NULL COMMENT '一份冻结客户资料严格对应一份完成文件逻辑根',
file_id BIGINT UNSIGNED NOT NULL,
file_name VARCHAR(255) NOT NULL,
file_hash CHAR(64) NOT NULL,
content_type VARCHAR(128) NULL,
size_bytes BIGINT UNSIGNED NOT NULL,
uploaded_by BIGINT UNSIGNED NOT NULL,
uploaded_by_name VARCHAR(64) NULL,
uploaded_at DATETIME(3) NOT NULL,
version INT UNSIGNED NOT NULL DEFAULT 1,
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_customs_completion_source(source_document_id),
CONSTRAINT fk_customs_completion_source FOREIGN KEY(source_document_id) REFERENCES fl_customs_customer_document(id)
) ENGINE=InnoDB COMMENT='报关完成文件当前版本';
CREATE TABLE IF NOT EXISTS fl_customs_completion_document_version (
id BIGINT UNSIGNED NOT NULL,
completion_document_id BIGINT UNSIGNED NOT NULL,
document_version INT UNSIGNED NOT NULL,
file_id BIGINT UNSIGNED NOT NULL,
file_name VARCHAR(255) NOT NULL,
file_hash CHAR(64) NOT NULL,
content_type VARCHAR(128) NULL,
size_bytes BIGINT UNSIGNED 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),
UNIQUE KEY uk_customs_completion_version(completion_document_id,document_version),
CONSTRAINT fk_customs_completion_version FOREIGN KEY(completion_document_id) REFERENCES fl_customs_completion_document(id)
) ENGINE=InnoDB COMMENT='报关完成文件不可变覆盖版本';
CREATE TABLE IF NOT EXISTS fl_customs_container_history (
id BIGINT UNSIGNED NOT NULL,
customs_container_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(40) NOT NULL,
from_status VARCHAR(40) NULL,
to_status VARCHAR(40) 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_customs_container_history(customs_container_id,created_at,id),
CONSTRAINT fk_customs_container_history FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id)
) ENGINE=InnoDB COMMENT='整柜报关状态与系统触发历史';
CREATE TABLE IF NOT EXISTS fl_customs_command (
id BIGINT UNSIGNED NOT NULL,
actor_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(80) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT 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_command(actor_id,action,idempotency_key),
CONSTRAINT chk_customs_command_status CHECK(status IN('PROCESSING','SUCCESS'))
) ENGINE=InnoDB COMMENT='整柜报关写命令幂等事实';
USE baiyue_iam;
DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN(5102171,5102173,5102174,5102175);
DELETE FROM iam_permission WHERE id IN(5102171,5102173,5102174,5102175)
AND permission_code IN('platform:firstleg:customs:view','platform:firstleg:customs:review','platform:firstleg:customs:submit','platform:firstleg:customs:receipt');
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),
(5102172,0,'PLATFORM','BUTTON',5102170,'platform:firstleg:customs:manage','维护整柜报关',NULL,'POST',20,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE 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),status='ACTIVE',deleted=0;
INSERT IGNORE INTO iam_role_permission(role_id,permission_id,tenant_id)
SELECT r.id,p.id,0 FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='PLATFORM'
AND p.permission_code IN('platform:firstleg:customs:list','platform:firstleg:customs:manage')
WHERE r.tenant_id=0 AND r.role_code IN('PLATFORM_ADMIN','ORDER_OPERATOR');
@@ -1,242 +0,0 @@
SET NAMES utf8mb4;
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_customs_checklist_version (
id BIGINT UNSIGNED NOT NULL COMMENT '报关资料清单版本逻辑ID',
checklist_code VARCHAR(40) NOT NULL COMMENT '报关资料清单编码;同一清单的多个版本共用',
version_no VARCHAR(32) NOT NULL COMMENT '清单业务版本号',
country_code VARCHAR(32) NOT NULL COMMENT '清单适用的目的国家或地区编码',
channel_code VARCHAR(40) NOT NULL COMMENT '清单适用的运输渠道编码',
cargo_type VARCHAR(40) NOT NULL COMMENT '清单适用的货物类型编码',
items_json JSON NOT NULL COMMENT '不可变清单项目数组;元素包含code项目编码、name项目名称和required是否必填',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '清单版本状态:ACTIVE启用、RETIRED停用',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建清单版本的平台操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建清单版本时的平台操作人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '清单版本创建时间',
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 COMMENT '报关协同单逻辑ID',
customs_no VARCHAR(40) NOT NULL COMMENT '全局唯一报关协同单号',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
order_id BIGINT UNSIGNED NOT NULL COMMENT 'Order服务订单逻辑ID;跨库不建立外键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT 'Firstleg入库单逻辑ID',
container_booking_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '关联柜次逻辑ID;0表示创建时尚未关联柜次',
checklist_version_id BIGINT UNSIGNED NOT NULL COMMENT '本单冻结使用的报关资料清单版本ID',
checklist_code VARCHAR(40) NOT NULL COMMENT '创建协同单时的清单编码快照;源清单变化不回写',
checklist_version_no VARCHAR(32) NOT NULL COMMENT '创建协同单时的清单版本号快照;源清单变化不回写',
status VARCHAR(32) NOT NULL DEFAULT 'DOCUMENTS_PENDING' COMMENT '协同状态:DOCUMENTS_PENDING待补资料、REVIEWING审核中、SUPPLEMENT_REQUIRED需补件、READY_TO_SUBMIT待提交代理、RECEIPT_PENDING待回执、RECEIPT_AVAILABLE已有回执、CLEARANCE_COMPLETED已放行、CANCELLED已取消',
remark VARCHAR(500) NULL COMMENT '报关协同单内部业务备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建协同单的平台操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建协同单时的平台操作人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '协同单创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改协同单的平台操作人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改时的平台操作人名称快照',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '协同单最后修改时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号;每次状态变更递增',
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 COMMENT '报关资料文件版本逻辑ID',
customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID',
checklist_item_code VARCHAR(40) NOT NULL COMMENT '对应冻结清单中的资料项目编码',
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件逻辑ID;跨库不建立外键',
document_version INT UNSIGNED NOT NULL COMMENT '同一协同单和清单项目下的文件版本序号',
replaces_document_id BIGINT UNSIGNED NULL COMMENT '被本版本替换的上一资料版本ID;首个版本为空',
file_name VARCHAR(255) NOT NULL COMMENT '上传或引用时的文件名称快照',
file_hash CHAR(64) NOT NULL COMMENT '文件内容SHA-256摘要;用于版本审计和幂等校验',
status VARCHAR(16) NOT NULL DEFAULT 'UPLOADED' COMMENT '资料状态:UPLOADED待审核、APPROVED审核通过、RETURNED已退回、SUPERSEDED已被替换、FROZEN已随代理提交批次冻结',
visibility VARCHAR(16) NOT NULL DEFAULT 'CUSTOMER' COMMENT '资料可见范围:PLATFORM仅平台可见、CUSTOMER客户可见',
uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传或引用资料的操作人ID',
uploaded_by_type VARCHAR(16) NOT NULL COMMENT '上传主体类型:PLATFORM平台操作人、MERCHANT客户操作人',
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '资料上传或引用时间',
reviewed_by BIGINT UNSIGNED NULL COMMENT '审核资料的平台操作人ID;未审核时为空',
reviewed_by_name VARCHAR(64) NULL COMMENT '审核时的平台操作人名称快照',
reviewed_at DATETIME(3) NULL COMMENT '资料审核时间',
review_reason VARCHAR(500) NULL COMMENT '审核退回原因或审核备注',
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 COMMENT '报关补件请求逻辑ID',
customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID',
source_document_id BIGINT UNSIGNED NOT NULL COMMENT '触发本次补件的被退回资料版本ID',
resolved_document_id BIGINT UNSIGNED NULL COMMENT '解决本次补件的新资料版本ID;未解决时为空',
checklist_item_code VARCHAR(40) NOT NULL COMMENT '需要补充的报关资料项目编码',
reason VARCHAR(500) NOT NULL COMMENT '平台审核人员填写的补件原因',
due_at DATETIME(3) NULL COMMENT '期望完成补件时间;未设置时为空',
status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT '补件状态:OPEN待补件、RESOLVED已由新资料解决、CANCELLED已取消',
requested_by BIGINT UNSIGNED NOT NULL COMMENT '发起补件的平台操作人ID',
requested_by_name VARCHAR(64) NULL COMMENT '发起补件时的平台操作人名称快照',
requested_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '补件请求发起时间',
resolved_by BIGINT UNSIGNED NULL COMMENT '提交解决资料的操作人ID;未解决时为空',
resolved_at DATETIME(3) NULL COMMENT '补件请求解决时间',
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 COMMENT '报关代理提交批次逻辑ID',
customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID',
batch_no VARCHAR(40) NOT NULL COMMENT '全局唯一代理提交批次号',
batch_version INT UNSIGNED NOT NULL COMMENT '同一报关协同单内的提交批次版本序号',
customs_agent_id BIGINT UNSIGNED NULL COMMENT '本批次报关代理Supplier逻辑ID快照;历史数据无法推导时为空',
customs_agent_name VARCHAR(128) NOT NULL COMMENT '本批次接收报关资料的报关代理名称快照',
external_reference VARCHAR(128) NULL COMMENT '报关代理或外部系统返回的业务参考号',
file_snapshot_json JSON NOT NULL COMMENT '提交时冻结的已审核资料数组;元素包含documentId、fileId、itemCode、documentVersion和fileHash',
status VARCHAR(16) NOT NULL DEFAULT 'SUBMITTED' COMMENT '代理提交状态:SUBMITTED已提交、ACKNOWLEDGED代理已确认、REJECTED代理已拒绝、COMPLETED代理处理完成',
note VARCHAR(500) NULL COMMENT '提交报关代理时的平台备注',
submitted_by BIGINT UNSIGNED NOT NULL COMMENT '提交报关代理的平台操作人ID',
submitted_by_name VARCHAR(64) NULL COMMENT '提交时的平台操作人名称快照',
submitted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '提交报关代理时间',
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='报关代理提交批次及冻结文件版本';
-- 历史代理名称无法安全反推Supplier ID,旧行保持NULL;新写入由服务端校验并持久化。
DROP PROCEDURE IF EXISTS phase3_customs_supplier_snapshot_add_column;
DELIMITER $$
CREATE PROCEDURE phase3_customs_supplier_snapshot_add_column()
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='fl_customs_submission_batch'
AND column_name='customs_agent_id') THEN
ALTER TABLE fl_customs_submission_batch
ADD COLUMN customs_agent_id BIGINT UNSIGNED NULL COMMENT '本批次报关代理Supplier逻辑ID快照;历史数据无法推导时为空' AFTER batch_version;
END IF;
END$$
DELIMITER ;
CALL phase3_customs_supplier_snapshot_add_column();
DROP PROCEDURE phase3_customs_supplier_snapshot_add_column;
CREATE TABLE IF NOT EXISTS fl_customs_receipt (
id BIGINT UNSIGNED NOT NULL COMMENT '报关回执文件版本逻辑ID',
customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID',
submission_batch_id BIGINT UNSIGNED NOT NULL COMMENT '产生本回执的报关代理提交批次ID',
receipt_type VARCHAR(16) NOT NULL COMMENT '回执类型:RECEIVED已收件、DECLARED已申报、REJECTED已拒绝、INSPECTION查验通知、RELEASED放行回执、OTHER其他回执',
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务回执文件逻辑ID;跨库不建立外键',
receipt_version INT UNSIGNED NOT NULL COMMENT '同一提交批次和回执类型下的版本序号',
replaces_receipt_id BIGINT UNSIGNED NULL COMMENT '被本版本替换的上一回执版本ID;首个版本为空',
file_name VARCHAR(255) NOT NULL COMMENT '上传或引用时的回执文件名称快照',
file_hash CHAR(64) NOT NULL COMMENT '回执文件内容SHA-256摘要;用于版本审计和幂等校验',
visibility VARCHAR(16) NOT NULL DEFAULT 'PLATFORM' COMMENT '回执可见范围:PLATFORM仅平台可见、CUSTOMER客户可查询下载',
note VARCHAR(500) NULL COMMENT '回执业务备注',
uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传或引用回执的平台操作人ID',
uploaded_by_name VARCHAR(64) NULL COMMENT '上传回执时的平台操作人名称快照',
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '回执上传或引用时间',
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 COMMENT '报关协同历史逻辑ID',
customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID',
action VARCHAR(40) NOT NULL COMMENT '业务动作编码,如CREATE、UPLOAD_DOCUMENT、REPLACE_DOCUMENT、REVIEW_DOCUMENT_APPROVE、REVIEW_DOCUMENT_RETURN、SUBMIT_AGENT_BATCH或UPLOAD_RECEIPT',
from_status VARCHAR(32) NULL COMMENT '动作发生前的协同状态;创建动作时为空',
to_status VARCHAR(32) NULL COMMENT '动作完成后的协同状态',
resource_type VARCHAR(32) NOT NULL COMMENT '动作关联资源类型:CASE协同单、DOCUMENT资料、SUBMISSION_BATCH提交批次或RECEIPT回执',
resource_id BIGINT UNSIGNED NOT NULL COMMENT '动作关联资源逻辑ID',
note VARCHAR(500) NULL COMMENT '动作原因、资料项目编码或业务备注快照',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '执行业务动作的操作人ID',
operator_name VARCHAR(64) NULL COMMENT '执行动作时的操作人名称快照',
operator_type VARCHAR(16) NOT NULL COMMENT '操作主体类型:PLATFORM平台操作人、MERCHANT客户操作人',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史记录创建时间',
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 COMMENT '报关写命令幂等记录逻辑ID',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '发起写命令的操作人ID;与动作和幂等键共同限定重放范围',
action VARCHAR(64) NOT NULL COMMENT '幂等业务动作编码,包含平台或客户动作及资料、审核、提交、回执类型',
idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端提供的幂等键',
request_hash CHAR(64) NOT NULL COMMENT '规范化完整请求内容的SHA-256摘要;同键不同请求时拒绝',
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '幂等处理状态:PROCESSING处理中、SUCCESS处理成功',
result_type VARCHAR(24) NULL COMMENT '成功结果资源类型:CHECKLIST、CASE、DOCUMENT、BATCH或RECEIPT',
result_id BIGINT UNSIGNED NULL COMMENT '成功结果资源逻辑ID;处理中为空',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等记录创建时间',
completed_at DATETIME(3) NULL COMMENT '写命令成功完成时间;处理中为空',
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,91 +0,0 @@
USE baiyue_firstleg;
DROP PROCEDURE IF EXISTS add_simple_customs_column;
DELIMITER $$
CREATE PROCEDURE add_simple_customs_column(IN p_name VARCHAR(64), IN p_definition TEXT)
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_container_document'
AND column_name = p_name) THEN
SET @ddl = CONCAT('ALTER TABLE fl_container_document ADD COLUMN ', p_name, ' ', p_definition);
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
END IF;
END$$
DELIMITER ;
CALL add_simple_customs_column('file_hash', "VARCHAR(64) NULL COMMENT 'File服务SHA-256快照' AFTER file_name");
CALL add_simple_customs_column('completed_by', "BIGINT UNSIGNED NULL COMMENT '一键完成操作人ID' AFTER uploaded_by_name");
CALL add_simple_customs_column('completed_by_name', "VARCHAR(64) NULL COMMENT '一键完成操作人名称快照' AFTER completed_by");
CALL add_simple_customs_column('completed_at', "DATETIME(3) NULL COMMENT '一键完成时间' AFTER completed_by_name");
DROP PROCEDURE add_simple_customs_column;
SET @old_check = (SELECT CONSTRAINT_NAME FROM information_schema.table_constraints
WHERE table_schema = DATABASE() AND table_name = 'fl_container_document'
AND constraint_type = 'CHECK' AND constraint_name = 'chk_container_document_type');
SET @ddl = IF(@old_check IS NULL, 'SELECT 1',
'ALTER TABLE fl_container_document DROP CHECK chk_container_document_type');
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
ALTER TABLE fl_container_document ADD CONSTRAINT chk_container_document_type
CHECK (document_type IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER'));
-- Keep container identity/loading facts immutable while allowing release transport data to arrive later.
DROP TRIGGER IF EXISTS trg_container_booking_before_update;
DELIMITER $$
CREATE TRIGGER trg_container_booking_before_update BEFORE UPDATE ON fl_container_booking FOR EACH ROW
BEGIN
DECLARE current_plan_status VARCHAR(24);
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id=OLD.id;
IF current_plan_status IN ('LOADING','COMPLETED') AND (
NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no)
OR NOT (OLD.cabinet_no <=> NEW.cabinet_no) OR NOT (OLD.channel_code <=> NEW.channel_code)
OR NOT (OLD.channel_name <=> NEW.channel_name) OR NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id)
OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id)
OR NOT (OLD.warehouse_id <=> NEW.warehouse_id) OR NOT (OLD.warehouse_name <=> NEW.warehouse_name)
OR NOT (OLD.container_type <=> NEW.container_type) OR NOT (OLD.shipping_company_id <=> NEW.shipping_company_id)
OR NOT (OLD.shipping_company_name <=> NEW.shipping_company_name)
OR NOT (OLD.departure_port_code <=> NEW.departure_port_code) OR NOT (OLD.departure_port_name <=> NEW.departure_port_name)
OR NOT (OLD.destination_port_code <=> NEW.destination_port_code) OR NOT (OLD.destination_port_name <=> NEW.destination_port_name)
OR NOT (OLD.destination_warehouse_code <=> NEW.destination_warehouse_code)
OR NOT (OLD.destination_warehouse_name <=> NEW.destination_warehouse_name)
OR NOT (OLD.capacity_cbm <=> NEW.capacity_cbm) OR NOT (OLD.max_weight_kg <=> NEW.max_weight_kg)
OR NOT (OLD.terminal_trucking_company_id <=> NEW.terminal_trucking_company_id)
OR NOT (OLD.terminal_trucking_company <=> NEW.terminal_trucking_company) OR NOT (OLD.loading_at <=> NEW.loading_at)
OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at)
OR NOT (OLD.estimated_port_arrival_at <=> NEW.estimated_port_arrival_at)
OR NOT (OLD.actual_port_arrival_at <=> NEW.actual_port_arrival_at)
OR NOT (OLD.estimated_warehouse_arrival_at <=> NEW.estimated_warehouse_arrival_at)
OR NOT (OLD.actual_warehouse_arrival_at <=> NEW.actual_warehouse_arrival_at)
OR NOT (OLD.created_by <=> NEW.created_by) OR NOT (OLD.created_by_name <=> NEW.created_by_name)
OR NOT (OLD.created_at <=> NEW.created_at)) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜作业开始后仅允许修改集装箱号与放舱运输数据';
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_container_document_before_insert;
DELIMITER $$
CREATE TRIGGER trg_container_document_before_insert BEFORE INSERT ON fl_container_document FOR EACH ROW
BEGIN
DECLARE current_plan_status VARCHAR(24);
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
WHERE container_booking_id = NEW.container_booking_id;
IF current_plan_status = 'COMPLETED'
AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜物流文件不可新增';
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_container_document_before_update;
DELIMITER $$
CREATE TRIGGER trg_container_document_before_update BEFORE UPDATE ON fl_container_document FOR EACH ROW
BEGIN
DECLARE current_plan_status VARCHAR(24);
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
WHERE container_booking_id = OLD.container_booking_id;
IF current_plan_status = 'COMPLETED'
AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜物流文件不可修改';
END IF;
END$$
DELIMITER ;
@@ -315,11 +315,11 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1)) CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1))
) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层'; ) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层';
-- 目的港清关和出口报关均为柜次级简单文件事实:上传资料后由平台一键完成。 -- 柜次级简单文件仅承载目的港清关、提货单和取货单;出口报关由 customs-v1 柜级聚合承载。
CREATE TABLE IF NOT EXISTS fl_container_document ( CREATE TABLE IF NOT EXISTS fl_container_document (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)', id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
document_type VARCHAR(32) NOT NULL COMMENT '文件类型:DESTINATION_CLEARANCE目的港清关、CUSTOMS_DECLARATION出口报关、DELIVERY_ORDER提货单、PICKUP_ORDER取货单', document_type VARCHAR(32) NOT NULL COMMENT '文件类型:DESTINATION_CLEARANCE目的港清关、DELIVERY_ORDER提货单、PICKUP_ORDER取货单',
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID,不建立跨库外键', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID,不建立跨库外键',
file_name VARCHAR(255) NOT NULL COMMENT '文件名称快照', file_name VARCHAR(255) NOT NULL COMMENT '文件名称快照',
file_hash VARCHAR(64) NULL COMMENT 'File服务SHA-256快照', file_hash VARCHAR(64) NULL COMMENT 'File服务SHA-256快照',
@@ -338,7 +338,7 @@ CREATE TABLE IF NOT EXISTS fl_container_document (
CONSTRAINT fk_container_document_booking FOREIGN KEY (container_booking_id) CONSTRAINT fk_container_document_booking FOREIGN KEY (container_booking_id)
REFERENCES fl_container_booking (id), REFERENCES fl_container_booking (id),
CONSTRAINT chk_container_document_type CONSTRAINT chk_container_document_type
CHECK (document_type IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')) CHECK (document_type IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER'))
) ENGINE=InnoDB COMMENT='平台货柜清关、DO与PU文件关联'; ) ENGINE=InnoDB COMMENT='平台货柜清关、DO与PU文件关联';
CREATE TABLE IF NOT EXISTS fl_container_exception_history ( CREATE TABLE IF NOT EXISTS fl_container_exception_history (
@@ -1152,7 +1152,7 @@ BEGIN
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
WHERE container_booking_id = NEW.container_booking_id; WHERE container_booking_id = NEW.container_booking_id;
IF current_plan_status = 'COMPLETED' IF current_plan_status = 'COMPLETED'
AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增';
END IF; END IF;
END$$ END$$
@@ -1168,7 +1168,7 @@ BEGIN
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
WHERE container_booking_id = OLD.container_booking_id; WHERE container_booking_id = OLD.container_booking_id;
IF current_plan_status = 'COMPLETED' IF current_plan_status = 'COMPLETED'
AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改';
END IF; END IF;
END$$ END$$
@@ -1,127 +1,135 @@
openapi: 3.0.3 openapi: 3.0.3
info: info: {title: Firstleg Container Customs API, version: 2.0.0}
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: paths:
/api/platform/v1/customs/checklist-versions: /api/platform/v1/customs/loading-plans/{planId}:
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: get:
summary: Page customs cases summary: 查询整柜报关状态、缺资料票及文件映射
responses: {'200': {description: Page result}} x-permission: platform:firstleg:customs:list
parameters: [{$ref: '#/components/parameters/PlanId'}]
responses: {'200': {$ref: '#/components/responses/ContainerResponse'}}
/api/platform/v1/customs/loading-plans/{planId}/start:
post: post:
summary: Create a customer/order/inbound/container scoped customs case summary: 确认开始整柜报关并冻结全部当前资料版本和覆盖范围
parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] x-permission: platform:firstleg:customs:manage
responses: {'200': {description: Customs case detail}} parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
/api/platform/v1/customs/cases/{caseId}: requestBody: {$ref: '#/components/requestBodies/ActionBody'}
parameters: [{$ref: '#/components/parameters/CaseId'}] responses: {'200': {$ref: '#/components/responses/ContainerResponse'}}
/api/platform/v1/customs/loading-plans/{planId}/complete-all-buy:
post:
summary: 二次确认全买单柜报关完成
x-permission: platform:firstleg:customs:manage
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {$ref: '#/components/requestBodies/ActionBody'}
responses: {'200': {$ref: '#/components/responses/ContainerResponse'}}
/api/platform/v1/customs/loading-plans/documents/{documentId}/completion/upload:
post:
summary: 上传或覆盖冻结客户资料的一对一完成文件
description: 完成文件自动继承冻结入仓码且不可删除;最后一份须finalConfirmation=true,File调用前服务端预检。
x-permission: platform:firstleg:customs:manage
parameters:
- {$ref: '#/components/parameters/DocumentId'}
- {$ref: '#/components/parameters/IdempotencyKey'}
- {name: expectedVersion, in: query, required: true, schema: {type: integer, minimum: 0}}
- {name: finalConfirmation, in: query, required: true, schema: {type: boolean}}
requestBody: {$ref: '#/components/requestBodies/FileBody'}
responses: {'200': {$ref: '#/components/responses/DocumentResponse'}}
/api/platform/v1/customs/loading-plans/documents/{documentId}/download:
get: get:
summary: Query complete customs detail and stable history summary: 平台下载客户报送资料
responses: {'200': {description: Detail}} x-permission: platform:firstleg:customs:list
/api/platform/v1/customs/cases/{caseId}/documents: parameters: [{$ref: '#/components/parameters/DocumentId'}]
parameters: [{$ref: '#/components/parameters/CaseId'}, {$ref: '#/components/parameters/IdempotencyKey'}] responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
post: /api/platform/v1/customs/loading-plans/documents/{documentId}/completion/download:
summary: Reference a file validated against customer, CUSTOMS_DOCUMENT and case ID get:
responses: {'200': {description: New document version}} summary: 平台下载报关完成文件
/api/platform/v1/customs/cases/{caseId}/documents/upload: x-permission: platform:firstleg:customs:list
parameters: [{$ref: '#/components/parameters/CaseId'}, {$ref: '#/components/parameters/IdempotencyKey'}] parameters: [{$ref: '#/components/parameters/DocumentId'}]
post: responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
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
description: customsAgentId must identify an ACTIVE Warehouse supplier in category CUSTOMS; the persisted agent name is resolved server-side and free text cannot bypass supplier validation.
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/SubmitBatchRequest'}}}}
responses:
'200':
description: Submission batch
content:
application/json:
schema: {$ref: '#/components/schemas/CustomsSubmissionBatch'}
/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}: /api/merchant/v1/customs/orders/{orderId}:
get: get:
summary: Query the authenticated customer's customs case by order summary: 客户订单列表查询当前完成装柜退税报关四态
responses: {'200': {description: Customer-filtered detail}} x-permission: merchant:order:view
/api/merchant/v1/customs/cases/{caseId}/documents/upload: parameters: [{$ref: '#/components/parameters/OrderId'}]
responses: {'200': {$ref: '#/components/responses/ContainerResponse'}}
/api/merchant/v1/customs/loading-plans/{planId}/documents/upload:
post: post:
summary: Upload or replace a checklist document within authenticated customer scope summary: 上传资料并关联当前柜内本客户一个或多个退税入仓码
responses: {'200': {description: New document version}} x-permission: merchant:order:edit
/api/merchant/v1/customs/cases/{caseId}/receipts/{receiptId}/download: parameters:
- {$ref: '#/components/parameters/PlanId'}
- {$ref: '#/components/parameters/IdempotencyKey'}
- {$ref: '#/components/parameters/InboundIds'}
requestBody: {$ref: '#/components/requestBodies/FileBody'}
responses: {'200': {$ref: '#/components/responses/DocumentResponse'}}
/api/merchant/v1/customs/documents/{documentId}/upload:
post:
summary: 报关开始前覆盖资料并同步修改同柜关联票
x-permission: merchant:order:edit
parameters:
- {$ref: '#/components/parameters/DocumentId'}
- {$ref: '#/components/parameters/IdempotencyKey'}
- {$ref: '#/components/parameters/InboundIds'}
- {name: expectedVersion, in: query, required: true, schema: {type: integer, minimum: 1}}
requestBody: {$ref: '#/components/requestBodies/FileBody'}
responses: {'200': {$ref: '#/components/responses/DocumentResponse'}}
/api/merchant/v1/customs/documents/{documentId}/inbounds:
put:
summary: 报关开始前修改资料关联的同柜退税入仓码
x-permission: merchant:order:edit
parameters: [{$ref: '#/components/parameters/DocumentId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ScopeRequest'}}}}
responses: {'200': {$ref: '#/components/responses/DocumentResponse'}}
/api/merchant/v1/customs/documents/{documentId}:
delete:
summary: 报关开始前逻辑删除误传资料并保留不可变版本
x-permission: merchant:order:edit
parameters:
- {$ref: '#/components/parameters/DocumentId'}
- {$ref: '#/components/parameters/IdempotencyKey'}
- {name: expectedVersion, in: query, required: true, schema: {type: integer, minimum: 1}}
responses: {'200': {$ref: '#/components/responses/ContainerResponse'}}
/api/merchant/v1/customs/documents/{documentId}/download:
get: get:
summary: Download only CUSTOMER-visible receipts from the authenticated customer's case summary: 客户下载自己当前报送资料
responses: {'200': {description: Authorized file stream}} x-permission: merchant:order:view
parameters: [{$ref: '#/components/parameters/DocumentId'}]
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
/api/merchant/v1/customs/documents/{documentId}/completion/download:
get:
summary: 整柜完成后客户下载对应完成文件
x-permission: merchant:order:view
parameters: [{$ref: '#/components/parameters/DocumentId'}]
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
/internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches:
post:
summary: File导出中心按loadingPlanId分页获取已完成装柜买单实际行
parameters: [{name: X-Internal-Service-Token, in: header, required: true, schema: {type: string}}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ExportBatchRequest'}}}}
responses: {'200': {description: 成功, content: {application/json: {schema: {$ref: '#/components/schemas/ExportBatchEnvelope'}}}}}
components: components:
parameters: parameters:
IdempotencyKey: PlanId: {name: planId, in: path, required: true, schema: {$ref: '#/components/schemas/LongId'}}
name: Idempotency-Key OrderId: {name: orderId, in: path, required: true, schema: {$ref: '#/components/schemas/LongId'}}
in: header DocumentId: {name: documentId, in: path, required: true, schema: {$ref: '#/components/schemas/LongId'}}
required: true IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
schema: {type: string, minLength: 1, maxLength: 128} InboundIds: {name: inboundIds, in: query, required: true, schema: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/LongId'}}}
CaseId: requestBodies:
name: caseId ActionBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}}}
in: path FileBody: {required: true, content: {multipart/form-data: {schema: {type: object, required: [file], properties: {file: {type: string, format: binary, description: 任意格式且最大50MB}}}}}}
required: true responses:
schema: {type: string, pattern: '^[1-9][0-9]*$'} ContainerResponse: {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerEnvelope'}}}}
DocumentResponse: {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}}
BinaryResponse: {description: 文件二进制, headers: {Content-Disposition: {schema: {type: string}}, Cache-Control: {schema: {type: string, example: no-store}}}, content: {application/octet-stream: {schema: {type: string, format: binary}}}}
schemas: schemas:
SubmitBatchRequest: LongId: {type: string, pattern: '^[1-9][0-9]*$', description: Long ID序列化为字符串}
type: object ApiEnvelope: {type: object, required: [code, message, timestamp], properties: {code: {type: string}, message: {type: string}, requestId: {type: string, nullable: true}, traceId: {type: string, nullable: true}, timestamp: {type: string, format: date-time}}}
required: [customsAgentId, expectedVersion] ContainerEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/ContainerView'}}}]}
properties: DocumentEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/DocumentView'}}}]}
customsAgentId: {type: integer, format: int64, minimum: 1, description: ACTIVE Warehouse supplier ID validated as CUSTOMS.} ActionRequest: {type: object, required: [expectedVersion, confirm], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, confirm: {type: boolean}, remark: {type: string, maxLength: 500, nullable: true}}}
customsAgentName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint; persisted name always comes from Warehouse.} ScopeRequest: {type: object, required: [inboundIds, expectedVersion], additionalProperties: false, properties: {inboundIds: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/LongId'}}, expectedVersion: {type: integer, minimum: 1}}}
externalReference: {type: string, maxLength: 128, nullable: true} FileView: {type: object, required: [fileId, fileName, fileHash, sizeBytes, version, uploadedAt], properties: {fileId: {$ref: '#/components/schemas/LongId'}, fileName: {type: string}, fileHash: {type: string}, contentType: {type: string, nullable: true}, sizeBytes: {type: integer, format: int64}, version: {type: integer}, uploadedAt: {type: string, format: date-time}}}
note: {type: string, maxLength: 500, nullable: true} InboundView: {type: object, required: [inboundId, orderId, orderNo, inboundCode, tenantId, customerName, taxMode, covered], properties: {inboundId: {$ref: '#/components/schemas/LongId'}, orderId: {$ref: '#/components/schemas/LongId'}, orderNo: {type: string}, inboundCode: {type: string}, tenantId: {$ref: '#/components/schemas/LongId'}, customerName: {type: string}, taxMode: {type: string, enum: [TAX_REFUND, BUY_DECLARATION]}, covered: {type: boolean}}}
expectedVersion: {type: integer, minimum: 0} DocumentView: {type: object, required: [documentId, tenantId, source, inboundIds, inboundCodes, frozen], properties: {documentId: {$ref: '#/components/schemas/LongId'}, tenantId: {$ref: '#/components/schemas/LongId'}, source: {$ref: '#/components/schemas/FileView'}, inboundIds: {type: array, items: {$ref: '#/components/schemas/LongId'}}, inboundCodes: {type: array, items: {type: string}}, frozen: {type: boolean}, completion: {allOf: [{$ref: '#/components/schemas/FileView'}], nullable: true}}}
CustomsSubmissionBatch: ContainerView: {type: object, required: [aggregateId, loadingPlanId, containerBookingId, status, version, taxRefundCount, coveredTaxRefundCount, inbounds, documents], properties: {aggregateId: {$ref: '#/components/schemas/LongId'}, loadingPlanId: {$ref: '#/components/schemas/LongId'}, containerBookingId: {$ref: '#/components/schemas/LongId'}, planNo: {type: string}, cabinetNo: {type: string}, containerNo: {type: string, nullable: true}, status: {type: string, enum: [WAITING_CUSTOMER_DOCUMENTS, READY_TO_START, DECLARING, COMPLETED]}, merchantStatus: {type: string, enum: [WAITING_UPLOAD, DOCUMENT_UPLOADED, DECLARING, CUSTOMS_COMPLETED], nullable: true}, version: {type: integer}, taxRefundCount: {type: integer}, coveredTaxRefundCount: {type: integer}, inbounds: {type: array, items: {$ref: '#/components/schemas/InboundView'}}, documents: {type: array, items: {$ref: '#/components/schemas/DocumentView'}}, startedAt: {type: string, format: date-time, nullable: true}, completedAt: {type: string, format: date-time, nullable: true}}}
type: object ExportBatchRequest: {type: object, required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit], properties: {taskId: {type: string}, tenantId: {type: string, enum: ['0']}, appCode: {type: string, enum: [PLATFORM]}, requestedBy: {type: string}, dispatchVersion: {type: integer}, parameters: {type: object, required: [loadingPlanId], additionalProperties: false, properties: {loadingPlanId: {type: string}}}, cursor: {type: string, nullable: true}, snapshotToken: {type: string, nullable: true}, limit: {type: integer, minimum: 1, maximum: 500}}}
required: [id, customsCaseId, batchNo, batchVersion, customsAgentName, status, submittedBy, submittedAt] ExportBatchEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {type: object, properties: {schemaVersion: {type: integer}, schemaHash: {type: string}, snapshotToken: {type: string}, columns: {type: array, items: {type: object}}, rows: {type: array, items: {type: object}}, nextCursor: {type: string}, hasMore: {type: boolean}, totalRows: {type: integer, format: int64, nullable: true}}}}}]}
properties:
id: {type: string, pattern: '^[1-9][0-9]*$'}
customsCaseId: {type: string, pattern: '^[1-9][0-9]*$'}
batchNo: {type: string, maxLength: 40}
batchVersion: {type: integer, minimum: 1}
customsAgentId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: Server-validated CUSTOMS supplier ID snapshot; null only for non-migrated historical rows.}
customsAgentName: {type: string, maxLength: 128, description: Server-frozen Warehouse supplier name snapshot.}
externalReference: {type: string, maxLength: 128, nullable: true}
fileSnapshotJson: {type: string}
status: {type: string, enum: [SUBMITTED, ACKNOWLEDGED, REJECTED, COMPLETED]}
note: {type: string, maxLength: 500, nullable: true}
submittedBy: {type: string, pattern: '^[1-9][0-9]*$'}
submittedByName: {type: string, maxLength: 64, nullable: true}
submittedAt: {type: string, format: date-time}
@@ -81,70 +81,6 @@ paths:
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}] parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}]
responses: responses:
'200': {$ref: '#/components/responses/ConfirmedDetailResponse'} '200': {$ref: '#/components/responses/ConfirmedDetailResponse'}
/api/platform/v1/first-leg/loading-lists/{planId}/customs:
get:
summary: 查询装柜清单出口报关文档
x-permission: platform:firstleg:loading-list:list
description: 查询出口报关文档;写入与完成继续要求 platform:firstleg:customs:manage。
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}]
responses:
'200': {$ref: '#/components/responses/BusinessDocumentResponse'}
/api/platform/v1/first-leg/loading-lists/{planId}/customs/document:
post:
summary: 上传或替换装柜清单出口报关文档
x-permission: platform:firstleg:customs:manage
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody:
required: true
content: {application/json: {schema: {$ref: '#/components/schemas/DocumentAttachRequest'}}}
responses:
'200': {$ref: '#/components/responses/BusinessDocumentResponse'}
/api/platform/v1/first-leg/loading-lists/{planId}/customs/document/{fileId}/download:
get:
summary: 下载装柜清单出口报关附件
description: 完成态仍可下载。服务端校验 planId、warehouseId、当前 CUSTOMS_DECLARATION 的 fileId,以及 File 元数据 tenantId=0、businessType=LOADING_CUSTOMS_DECLARATION、businessId=planId;错误 fileId 或归属返回 A0404。响应使用 attachment 文件名并禁止缓存。
x-permission: platform:firstleg:loading-list:list
parameters:
- {$ref: '#/components/parameters/PlanId'}
- {$ref: '#/components/parameters/WarehouseId'}
- name: fileId
in: path
required: true
description: File 服务长整型文件 ID
schema: {$ref: '#/components/schemas/LongId'}
responses:
'200':
description: 报关附件二进制内容;包含 Content-Disposition,Cache-Control 为 no-store
headers:
Content-Disposition: {schema: {type: string}}
Cache-Control: {schema: {type: string, example: no-store}}
content:
application/octet-stream:
schema: {type: string, format: binary}
'404': {description: 装柜计划、仓库、当前附件或 File 归属不匹配}
/api/platform/v1/first-leg/loading-lists/{planId}/customs/document/upload:
post:
summary: 直接选择文件上传或替换装柜清单出口报关文档
description: Firstleg 使用内部服务令牌上传 File,浏览器不得传递 fileId 或内部令牌;旧 JSON document 接口仅兼容存量调用。
x-permission: platform:firstleg:customs:manage
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody:
required: true
content:
multipart/form-data:
schema: {$ref: '#/components/schemas/DocumentUploadRequest'}
responses:
'200': {$ref: '#/components/responses/BusinessDocumentResponse'}
/api/platform/v1/first-leg/loading-lists/{planId}/customs/complete:
post:
summary: 点击完成出口报关
x-permission: platform:firstleg:customs:manage
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody:
required: true
content: {application/json: {schema: {$ref: '#/components/schemas/DocumentCompleteRequest'}}}
responses:
'200': {$ref: '#/components/responses/BusinessDocumentResponse'}
components: components:
parameters: parameters:
PlanId: PlanId:
@@ -218,9 +154,6 @@ components:
ConfirmedDetailResponse: ConfirmedDetailResponse:
description: 装柜清单详情查询或确认成功,也可能返回业务失败 description: 装柜清单详情查询或确认成功,也可能返回业务失败
content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmedDetailEnvelope'}}} content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmedDetailEnvelope'}}}
BusinessDocumentResponse:
description: 报关文档查询或操作结果
content: {application/json: {schema: {$ref: '#/components/schemas/BusinessDocumentEnvelope'}}}
schemas: schemas:
LongId: LongId:
type: string type: string
@@ -279,14 +212,6 @@ components:
data: data:
allOf: [{$ref: '#/components/schemas/ConfirmedDetail'}] allOf: [{$ref: '#/components/schemas/ConfirmedDetail'}]
nullable: true nullable: true
BusinessDocumentEnvelope:
allOf:
- {$ref: '#/components/schemas/ApiEnvelope'}
- type: object
properties:
data:
allOf: [{$ref: '#/components/schemas/BusinessDocument'}]
nullable: true
PreloadPage: PreloadPage:
type: object type: object
required: [records, page, size, total, pages] required: [records, page, size, total, pages]
@@ -464,42 +389,3 @@ components:
voyageNo: {type: string, maxLength: 64, nullable: true, description: 航次} voyageNo: {type: string, maxLength: 64, nullable: true, description: 航次}
expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本} expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本}
operationReason: {type: string, maxLength: 500, nullable: true, description: 操作原因} operationReason: {type: string, maxLength: 500, nullable: true, description: 操作原因}
DocumentAttachRequest:
type: object
required: [fileId, expectedVersion]
additionalProperties: false
properties:
fileId: {$ref: '#/components/schemas/LongId'}
expectedVersion: {type: integer, format: int32, minimum: 0}
operationReason: {type: string, maxLength: 500, nullable: true}
DocumentCompleteRequest:
type: object
required: [expectedVersion]
additionalProperties: false
properties:
expectedVersion: {type: integer, format: int32, minimum: 0}
operationReason: {type: string, maxLength: 500, nullable: true}
DocumentUploadRequest:
type: object
required: [file, expectedVersion]
additionalProperties: false
properties:
file: {type: string, format: binary, description: 用户直接选择的报关文档}
expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前文档乐观锁版本,首次上传为 0}
operationReason: {type: string, maxLength: 500, nullable: true}
BusinessDocument:
type: object
required: [id, documentType, fileId, fileName, fileHash, uploadedBy, uploadedAt, version]
properties:
id: {$ref: '#/components/schemas/LongId'}
documentType: {type: string, enum: [CUSTOMS_DECLARATION]}
fileId: {$ref: '#/components/schemas/LongId'}
fileName: {type: string}
fileHash: {type: string, pattern: '^[0-9a-fA-F]{64}$'}
uploadedBy: {$ref: '#/components/schemas/LongId'}
uploadedByName: {type: string, nullable: true}
uploadedAt: {type: string, format: date-time}
completedBy: {$ref: '#/components/schemas/NullableLongId'}
completedByName: {type: string, nullable: true}
completedAt: {type: string, format: date-time, nullable: true}
version: {type: integer, format: int32, minimum: 0}
@@ -45,7 +45,7 @@ paths:
components: components:
parameters: parameters:
ContainerId: {name: id, in: path, required: true, schema: {type: integer, format: int64}} ContainerId: {name: id, in: path, required: true, schema: {type: integer, format: int64}}
DocumentType: {name: documentType, in: path, required: true, schema: {type: string, enum: [DELIVERY_ORDER, PICKUP_ORDER]}, description: '通用单证仅支持DO/PU;清关使用 /clearance,出口报关使用 loading-list 专用接口'} DocumentType: {name: documentType, in: path, required: true, schema: {type: string, enum: [DELIVERY_ORDER, PICKUP_ORDER]}, description: '通用单证仅支持DO/PU;目的港清关使用 /clearance;出口报关由 customs-v1 柜级聚合接口承载'}
requestBodies: requestBodies:
ExceptionRequest: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ExceptionRequest'}}}} ExceptionRequest: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ExceptionRequest'}}}}
schemas: schemas:
@@ -57,7 +57,7 @@ components:
container: {$ref: '#/components/schemas/Container'} container: {$ref: '#/components/schemas/Container'}
loadingPlanId: {type: integer, format: int64} loadingPlanId: {type: integer, format: int64}
pda: {$ref: '#/components/schemas/PdaSummary'} pda: {$ref: '#/components/schemas/PdaSummary'}
documents: {type: array, description: '按 documentType 稳定排序返回 DESTINATION_CLEARANCE、CUSTOMS_DECLARATION、DELIVERY_ORDER、PICKUP_ORDER 的当前唯一记录和真实 version', items: {$ref: '#/components/schemas/Document'}} documents: {type: array, description: '按 documentType 稳定排序返回 DESTINATION_CLEARANCE、DELIVERY_ORDER、PICKUP_ORDER 的当前唯一记录和真实 version;出口报关只由 customs-v1 柜级聚合承载', items: {$ref: '#/components/schemas/Document'}}
exception: {$ref: '#/components/schemas/ExceptionState'} exception: {$ref: '#/components/schemas/ExceptionState'}
exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}} exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}}
timeline: {type: array, items: {$ref: '#/components/schemas/TimelineEntry'}} timeline: {type: array, items: {$ref: '#/components/schemas/TimelineEntry'}}
@@ -65,7 +65,7 @@ components:
PdaSummary: {type: object, properties: {taskId: {type: integer, format: int64}, loadingPlanId: {type: integer, format: int64}, containerBookingId: {type: integer, format: int64}, taskStatus: {type: string, description: '持久化任务态:LOADING/COMPLETED'}, workflowStatus: {type: string, enum: [IN_PROGRESS, AWAITING_REVIEW, COMPLETED], description: 'PDA作业展示态'}, taskVersion: {type: integer}, itemCount: {type: integer}, completedItemCount: {type: integer}, unresolvedItemCount: {type: integer}, photoCounts: {type: array, items: {$ref: '#/components/schemas/PhotoCount'}}, photos: {type: array, items: {$ref: '#/components/schemas/Photo'}}, exceptionReasons: {type: array, items: {type: string}}}} PdaSummary: {type: object, properties: {taskId: {type: integer, format: int64}, loadingPlanId: {type: integer, format: int64}, containerBookingId: {type: integer, format: int64}, taskStatus: {type: string, description: '持久化任务态:LOADING/COMPLETED'}, workflowStatus: {type: string, enum: [IN_PROGRESS, AWAITING_REVIEW, COMPLETED], description: 'PDA作业展示态'}, taskVersion: {type: integer}, itemCount: {type: integer}, completedItemCount: {type: integer}, unresolvedItemCount: {type: integer}, photoCounts: {type: array, items: {$ref: '#/components/schemas/PhotoCount'}}, photos: {type: array, items: {$ref: '#/components/schemas/Photo'}}, exceptionReasons: {type: array, items: {type: string}}}}
PhotoCount: {type: object, properties: {evidenceType: {type: string}, count: {type: integer}}} PhotoCount: {type: object, properties: {evidenceType: {type: string}, count: {type: integer}}}
Photo: {type: object, properties: {evidenceId: {type: integer, format: int64}, tenantId: {type: integer, format: int64}, inboundId: {type: integer, format: int64}, evidenceType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, status: {type: string, enum: [ACTIVE, VOID]}, downloadPath: {type: string, nullable: true, description: '仅 ACTIVE 照片提供鉴权下载路径;VOID 历史照片为空'}, uploadedAt: {type: string, format: date-time}}} Photo: {type: object, properties: {evidenceId: {type: integer, format: int64}, tenantId: {type: integer, format: int64}, inboundId: {type: integer, format: int64}, evidenceType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, status: {type: string, enum: [ACTIVE, VOID]}, downloadPath: {type: string, nullable: true, description: '仅 ACTIVE 照片提供鉴权下载路径;VOID 历史照片为空'}, uploadedAt: {type: string, format: date-time}}}
Document: {type: object, properties: {id: {type: integer, format: int64}, documentType: {type: string, enum: [DESTINATION_CLEARANCE, CUSTOMS_DECLARATION, DELIVERY_ORDER, PICKUP_ORDER]}, fileId: {type: integer, format: int64}, fileName: {type: string}, fileHash: {type: string}, uploadedBy: {type: integer, format: int64}, uploadedByName: {type: string}, uploadedAt: {type: string, format: date-time}, version: {type: integer, description: '当前唯一记录的真实乐观锁版本;更新时必须原样作为 expectedVersion'}, completed: {type: boolean}}} Document: {type: object, properties: {id: {type: integer, format: int64}, documentType: {type: string, enum: [DESTINATION_CLEARANCE, DELIVERY_ORDER, PICKUP_ORDER]}, fileId: {type: integer, format: int64}, fileName: {type: string}, fileHash: {type: string}, uploadedBy: {type: integer, format: int64}, uploadedByName: {type: string}, uploadedAt: {type: string, format: date-time}, version: {type: integer, description: '当前唯一记录的真实乐观锁版本;更新时必须原样作为 expectedVersion'}, completed: {type: boolean}}}
ExceptionState: {type: object, properties: {active: {type: boolean}, reason: {type: string}, markedBy: {type: integer, format: int64}, markedByName: {type: string}, markedAt: {type: string, format: date-time}}} ExceptionState: {type: object, properties: {active: {type: boolean}, reason: {type: string}, markedBy: {type: integer, format: int64}, markedByName: {type: string}, markedAt: {type: string, format: date-time}}}
ExceptionHistory: {type: object, properties: {id: {type: integer, format: int64}, action: {type: string, enum: [MARK, CANCEL]}, reason: {type: string}, operatorId: {type: integer, format: int64}, operatorName: {type: string}, occurredAt: {type: string, format: date-time}}} ExceptionHistory: {type: object, properties: {id: {type: integer, format: int64}, action: {type: string, enum: [MARK, CANCEL]}, reason: {type: string}, operatorId: {type: integer, format: int64}, operatorName: {type: string}, occurredAt: {type: string, format: date-time}}}
TimelineEntry: {type: object, properties: {eventType: {type: string}, detail: {type: string}, operatorId: {type: integer, format: int64}, operatorName: {type: string}, occurredAt: {type: string, format: date-time}}} TimelineEntry: {type: object, properties: {eventType: {type: string}, detail: {type: string}, operatorId: {type: integer, format: int64}, operatorName: {type: string}, occurredAt: {type: string, format: date-time}}}
@@ -37,7 +37,7 @@ paths:
/api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/gate: /api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/gate:
get: get:
summary: Evaluate every inbound binding in the fail-closed container outbound gate summary: Evaluate every inbound binding in the fail-closed container outbound gate
description: Loading evidence is evaluated once for the physical container/loading plan and the same result applies to every inbound binding. The customs gate is resolved once from the current container CUSTOMS_DECLARATION whose completedAt must be non-null; upload alone remains blocked and fl_customs_case is not an alternative gate fact. A missing standalone sailing schedule is reference-only and never affects gate.passed. description: Loading evidence is evaluated once for the physical container/loading plan and the same result applies to every inbound binding. The customs gate is resolved once from fl_customs_container and only status=COMPLETED passes; customer upload, READY_TO_START and DECLARING remain blocked. A missing standalone sailing schedule is reference-only and never affects gate.passed. Container customs completion attempts system confirmation immediately and a bounded scheduler retries after later gates become ready.
x-permission: platform:firstleg:outbound-dispatch:check x-permission: platform:firstleg:outbound-dispatch:check
parameters: parameters:
- {$ref: '#/components/parameters/ContainerBookingIdPath'} - {$ref: '#/components/parameters/ContainerBookingIdPath'}
@@ -42,7 +42,7 @@ class ContainerControllerContractTest {
assertThat(openapi).contains("/api/platform/v1/containers/{id}/overview:", assertThat(openapi).contains("/api/platform/v1/containers/{id}/overview:",
"/documents/{documentType}/{fileId}/download:", "ExceptionRequest", "/documents/{documentType}/{fileId}/download:", "ExceptionRequest",
"/clearance/document/{fileId}/download:", "/clearance/document/{fileId}/download:",
"DESTINATION_CLEARANCE、CUSTOMS_DECLARATION、DELIVERY_ORDER、PICKUP_ORDER", "DESTINATION_CLEARANCE、DELIVERY_ORDER、PICKUP_ORDER",
"enum: [DELIVERY_ORDER, PICKUP_ORDER]", "Idempotency-Key", "photoCounts:", "enum: [DELIVERY_ORDER, PICKUP_ORDER]", "Idempotency-Key", "photoCounts:",
"/pda/photos/{fileId}/download:", "downloadPath:", "workflowStatus:", "/pda/photos/{fileId}/download:", "downloadPath:", "workflowStatus:",
"AWAITING_REVIEW", "version: {type: integer, minimum: 0}", "AWAITING_REVIEW", "version: {type: integer, minimum: 0}",
@@ -6,19 +6,12 @@ import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest; import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest;
import com.baiyue.firstleg.service.LoadingListService; import com.baiyue.firstleg.service.LoadingListService;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.Yaml;
import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.ClassPathResource;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.http.ResponseEntity;
import org.springframework.web.multipart.MultipartFile;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.lang.reflect.RecordComponent; import java.lang.reflect.RecordComponent;
@@ -42,15 +35,6 @@ class LoadingListControllerContractTest {
authority("confirmedPage", "platform:firstleg:loading-list:list", AuthenticatedUser.class, authority("confirmedPage", "platform:firstleg:loading-list:list", AuthenticatedUser.class,
ConfirmedQuery.class); ConfirmedQuery.class);
authority("confirmedDetail", "platform:firstleg:loading-list:list", AuthenticatedUser.class, long.class, long.class); authority("confirmedDetail", "platform:firstleg:loading-list:list", AuthenticatedUser.class, long.class, long.class);
authority("declaration", "platform:firstleg:loading-list:list", AuthenticatedUser.class, long.class, long.class);
authority("downloadDeclaration", "platform:firstleg:loading-list:list", AuthenticatedUser.class,
long.class, long.class, long.class);
authority("attachDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class,
long.class, long.class, String.class, DocumentAttachRequest.class);
authority("uploadDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class,
long.class, long.class, String.class, MultipartFile.class, int.class, String.class);
authority("completeDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class,
long.class, long.class, String.class, DocumentCompleteRequest.class);
} }
@Test @Test
@@ -92,8 +76,6 @@ class LoadingListControllerContractTest {
.contains("oceanBookingStatus") .contains("oceanBookingStatus")
.contains("pdaTaskId") .contains("pdaTaskId")
.contains("cabinetNo") .contains("cabinetNo")
.contains("LOADING_CUSTOMS_DECLARATION")
.contains("Cache-Control 为 no-store")
.doesNotContain("internalContainerNo") .doesNotContain("internalContainerNo")
.doesNotContain("rollback") .doesNotContain("rollback")
.doesNotContain("delete:") .doesNotContain("delete:")
@@ -112,30 +94,6 @@ class LoadingListControllerContractTest {
assertThat(component(ConfirmedQuery.class, "warehouseId").getAnnotation(NotNull.class)).isNull(); assertThat(component(ConfirmedQuery.class, "warehouseId").getAnnotation(NotNull.class)).isNull();
} }
@Test
void multipartScalarFieldsUseRequestParameters() throws Exception {
Method method = PlatformLoadingListController.class.getDeclaredMethod("uploadDeclaration",
AuthenticatedUser.class, long.class, long.class, String.class,
MultipartFile.class, int.class, String.class);
assertThat(method.getParameters()[4].getAnnotation(RequestPart.class)).isNotNull();
assertThat(method.getParameters()[5].getAnnotation(RequestParam.class)).isNotNull();
assertThat(method.getParameters()[5].getAnnotation(RequestPart.class)).isNull();
assertThat(method.getParameters()[6].getAnnotation(RequestParam.class)).isNotNull();
assertThat(method.getParameters()[6].getAnnotation(RequestPart.class)).isNull();
}
@Test
void customsDownloadUsesStablePlanPathAndBinaryResponse() throws Exception {
Method method = PlatformLoadingListController.class.getDeclaredMethod("downloadDeclaration",
AuthenticatedUser.class, long.class, long.class, long.class);
assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly(
"/loading-lists/{planId}/customs/document/{fileId}/download");
assertThat(method.getReturnType()).isEqualTo(ResponseEntity.class);
assertThat(method.getParameters()[1].getName()).isEqualTo("planId");
assertThat(method.getParameters()[2].getName()).isEqualTo("fileId");
assertThat(method.getParameters()[3].getName()).isEqualTo("warehouseId");
}
@Test @Test
void yamlParses() throws Exception { void yamlParses() throws Exception {
String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml") String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml")
@@ -149,23 +107,14 @@ class LoadingListControllerContractTest {
"/api/platform/v1/first-leg/loading-plans/{planId}/transport", "/api/platform/v1/first-leg/loading-plans/{planId}/transport",
"/api/platform/v1/first-leg/loading-plans/{planId}/confirm", "/api/platform/v1/first-leg/loading-plans/{planId}/confirm",
"/api/platform/v1/first-leg/loading-lists", "/api/platform/v1/first-leg/loading-lists",
"/api/platform/v1/first-leg/loading-lists/{planId}", "/api/platform/v1/first-leg/loading-lists/{planId}");
"/api/platform/v1/first-leg/loading-lists/{planId}/customs",
"/api/platform/v1/first-leg/loading-lists/{planId}/customs/document",
"/api/platform/v1/first-leg/loading-lists/{planId}/customs/document/{fileId}/download",
"/api/platform/v1/first-leg/loading-lists/{planId}/customs/document/upload",
"/api/platform/v1/first-leg/loading-lists/{planId}/customs/complete");
Map<String, Object> components = stringMap(document.get("components")); Map<String, Object> components = stringMap(document.get("components"));
Map<String, Object> schemas = stringMap(components.get("schemas")); Map<String, Object> schemas = stringMap(components.get("schemas"));
assertThat(schemas).containsKeys( assertThat(schemas).containsKeys(
"LongId", "PreloadPage", "ConfirmedPage", "Customer", "PreloadSummary", "LongId", "PreloadPage", "ConfirmedPage", "Customer", "PreloadSummary",
"ConfirmedSummary", "LoadingItem", "OperationHistory", "PreloadDetail", "ConfirmedSummary", "LoadingItem", "OperationHistory", "PreloadDetail",
"ConfirmedDetail", "TransportUpdateRequest", "PreloadReadiness", "ConfirmedDetail", "TransportUpdateRequest", "PreloadReadiness",
"ConfirmPreloadRequest", "PreloadConfirmation", "PreloadConfirmationEnvelope", "ConfirmPreloadRequest", "PreloadConfirmation", "PreloadConfirmationEnvelope");
"BusinessDocumentEnvelope",
"DocumentAttachRequest", "DocumentUploadRequest", "DocumentCompleteRequest", "BusinessDocument");
assertThat(properties(schemas, "DocumentUploadRequest"))
.containsOnlyKeys("file", "expectedVersion", "operationReason");
assertThat(paths) assertThat(paths)
.doesNotContainKey("/api/platform/v1/first-leg/loading-plans/{planId}/complete"); .doesNotContainKey("/api/platform/v1/first-leg/loading-plans/{planId}/complete");
assertThat(stringMap(schemas.get("LongId"))).containsEntry("type", "string"); assertThat(stringMap(schemas.get("LongId"))).containsEntry("type", "string");
@@ -0,0 +1,44 @@
package com.baiyue.firstleg.customs.container;
import com.baiyue.firstleg.customs.container.controller.*;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.core.io.ClassPathResource;
import org.yaml.snakeyaml.Yaml;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
class ContainerCustomsControllerContractTest {
@Test void platformUsesListForReadsAndManageForWrites() {
assertThat(PlatformContainerCustomsController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/api/platform/v1/customs/loading-plans");
assertPermission(PlatformContainerCustomsController.class,"detail","platform:firstleg:customs:list");
assertPermission(PlatformContainerCustomsController.class,"start","platform:firstleg:customs:manage");
assertPermission(PlatformContainerCustomsController.class,"uploadCompletion","platform:firstleg:customs:manage");
assertPermission(PlatformContainerCustomsController.class,"completeAllBuy","platform:firstleg:customs:manage");
}
@Test void merchantUsesOrderViewAndEditWithoutTrustingTenantInput() {
assertThat(MerchantContainerCustomsController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/api/merchant/v1/customs");
assertPermission(MerchantContainerCustomsController.class,"order","merchant:order:view");
assertPermission(MerchantContainerCustomsController.class,"upload","merchant:order:edit");
assertPermission(MerchantContainerCustomsController.class,"replace","merchant:order:edit");
assertPermission(MerchantContainerCustomsController.class,"scope","merchant:order:edit");
assertPermission(MerchantContainerCustomsController.class,"delete","merchant:order:edit");
}
@Test void openApiPublishesEveryPathParameterAndGeneratableDtos()throws Exception{
String yaml=new ClassPathResource("openapi/customs-v1.yaml").getContentAsString(java.nio.charset.StandardCharsets.UTF_8);
new Yaml().load(yaml);
assertThat(yaml).contains("ContainerView:","DocumentView:","FileView:","InboundView:","ActionRequest:","ScopeRequest:")
.contains("merchantStatus:","WAITING_UPLOAD","CUSTOMS_COMPLETED","loadingPlanId:")
.contains("LOADING_LIST_BUY_DECLARATION_ITEMS")
.doesNotContain("/cases/","CUSTOMS_DECLARATION");
}
private static void assertPermission(Class<?> type,String method,String permission) {
Method value=java.util.Arrays.stream(type.getDeclaredMethods()).filter(m->m.getName().equals(method)).findFirst().orElseThrow();
assertThat(value.getAnnotation(PreAuthorize.class).value()).contains(permission);
}
}
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.customs.container;
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ContainerCustomsMapperContractTest {
@Test void actualCompletedLoadingItemsAndTaxModeAreTheOnlyTicketFacts() throws Exception {
String plan=sql("planInbounds",long.class);
assertThat(plan).contains("fl_loading_plan_item","fl_inbound_order","p.id=#{planId}","item.deleted=0");
String missing=sql("missingRefundCount",long.class,long.class);
assertThat(missing).contains("i.tax_mode='TAX_REFUND'","fl_customs_customer_document_inbound","d.status='ACTIVE'");
}
@Test void buyExportIsCompletedPlanActualItemsOnly() throws Exception {
String value=sql("buyRows",long.class,long.class,long.class,int.class);
assertThat(value).contains("p.plan_status='COMPLETED'","i.tax_mode='BUY_DECLARATION'","item.deleted=0")
.contains("item.id>#{cursor}","item.id<=#{maximumId}");
}
private static String sql(String name,Class<?>... types) throws Exception {
return String.join(" ",ContainerCustomsMapper.class.getMethod(name,types).getAnnotation(Select.class).value());
}
}
@@ -0,0 +1,91 @@
package com.baiyue.firstleg.customs.container;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.ActionRequest;
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper;
import com.baiyue.firstleg.customs.container.service.ContainerCustomsService;
import com.baiyue.firstleg.customs.client.CustomsFileClient;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.*;
import org.mockito.*;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.mock.web.MockMultipartFile;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.*;
class ContainerCustomsServiceTest {
@Mock ContainerCustomsMapper mapper;
@Mock CustomsFileClient files;
@Mock ObjectMapper objectMapper;
@Mock ApplicationEventPublisher events;
@InjectMocks ContainerCustomsService service;
@BeforeEach void setUp(){MockitoAnnotations.openMocks(this);}
@Test void startFailsClosedWhenAnyRefundInboundHasNoActiveDocumentCoverage(){
var row=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND");
var container=new ContainerCustomsMapper.ContainerRow(1L,9L,8L,ContainerCustomsService.READY,null,null,null,null,null,null,null,null,2);
when(mapper.planInbounds(9L)).thenReturn(List.of(row));when(mapper.container(9L)).thenReturn(container);
when(mapper.missingRefundCount(9L,1L)).thenReturn(0,1);when(mapper.claimCommand(anyLong(),eq(5L),anyString(),eq("k"),anyString())).thenReturn(1);
when(mapper.lockContainer(9L)).thenReturn(container);
assertThatThrownBy(()->service.start(9L,5L,"admin","k",new ActionRequest(2,true,null)))
.isInstanceOf(BizException.class).hasMessageContaining("退税入仓码未上传");
verify(mapper,never()).freezeScopes(anyLong());verify(mapper,never()).freezeDocuments(anyLong());verify(mapper,never()).start(anyLong(),anyInt(),anyLong(),anyString());
}
@Test void startRejectsStaleContainerVersionBeforeAnyFreeze(){
var row=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND");
var container=new ContainerCustomsMapper.ContainerRow(1L,9L,8L,ContainerCustomsService.READY,null,null,null,null,null,null,null,null,3);
when(mapper.planInbounds(9L)).thenReturn(List.of(row));when(mapper.container(9L)).thenReturn(container);
when(mapper.missingRefundCount(9L,1L)).thenReturn(0);when(mapper.claimCommand(anyLong(),eq(5L),anyString(),eq("k"),anyString())).thenReturn(1);
when(mapper.lockContainer(9L)).thenReturn(container);
assertThatThrownBy(()->service.start(9L,5L,"admin","k",new ActionRequest(2,true,null)))
.isInstanceOf(BizException.class).hasMessageContaining("其他人修改");
verify(mapper,never()).freezeScopes(anyLong());
}
@Test void lastCompletionRequiresConfirmationBeforeCallingFile(){
var source=document(1L);var container=container(ContainerCustomsService.DECLARING,3);
when(mapper.document(1L)).thenReturn(source);when(mapper.lockContainerById(2L)).thenReturn(container);
when(mapper.lockDocument(1L)).thenReturn(source);when(mapper.lockCompletion(1L)).thenReturn(null);
when(mapper.missingCompletionCount(2L)).thenReturn(1);
var file=new MockMultipartFile("file","result.bin","application/octet-stream",new byte[]{1});
assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","k",0,false,file))
.isInstanceOf(BizException.class).hasMessageContaining("最后一份");
verifyNoInteractions(files);
}
@Test void completedContainerLocksCompletionReplacement(){
var source=document(1L);when(mapper.document(1L)).thenReturn(source);
when(mapper.lockContainerById(2L)).thenReturn(container(ContainerCustomsService.COMPLETED,4));
when(mapper.lockDocument(1L)).thenReturn(source);
var file=new MockMultipartFile("file","result.bin","application/octet-stream",new byte[]{1});
assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","k",0,false,file))
.isInstanceOf(BizException.class).hasMessageContaining("不在报关中");
verifyNoInteractions(files);
}
@Test void merchantCannotDownloadCompletionBeforeWholeContainerCompletes(){
var source=document(1L);when(mapper.document(1L)).thenReturn(source);
when(mapper.containerById(2L)).thenReturn(container(ContainerCustomsService.DECLARING,3));
assertThatThrownBy(()->service.downloadCompletion(7L,1L,false)).isInstanceOf(BizException.class)
.hasMessageContaining("完成文件不存在");
verifyNoInteractions(files);
}
@Test void allBuyCompletionRejectsUnknownOrMixedTaxMode(){
var value=container(ContainerCustomsService.DECLARING,3);when(mapper.lockContainer(9L)).thenReturn(value);
when(mapper.claimCommand(anyLong(),eq(5L),anyString(),eq("k"),anyString())).thenReturn(1);
when(mapper.planInbounds(9L)).thenReturn(List.of(new ContainerCustomsMapper.InboundRow(9L,"LP",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户",null)));
assertThatThrownBy(()->service.completeAllBuy(9L,5L,"admin","k",new ActionRequest(3,true,null)))
.isInstanceOf(BizException.class).hasMessageContaining("BUY_DECLARATION");
verify(mapper,never()).complete(anyLong(),anyInt(),anyLong(),anyString());
}
private static ContainerCustomsMapper.DocumentRow document(long id){return new ContainerCustomsMapper.DocumentRow(id,2L,7L,10L,"source.bin","a".repeat(64),"application/octet-stream",1L,"ACTIVE",5L,java.time.LocalDateTime.now(),1,java.time.LocalDateTime.now(),1);}
private static ContainerCustomsMapper.ContainerRow container(String status,int version){return new ContainerCustomsMapper.ContainerRow(2L,9L,8L,status,null,null,null,null,null,null,null,null,version);}
}
@@ -0,0 +1,32 @@
package com.baiyue.firstleg.customs.container;
import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest;
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper;
import com.baiyue.firstleg.customs.container.service.LoadingListBuyDeclarationExportService;
import org.junit.jupiter.api.*;
import org.mockito.*;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
class LoadingListBuyDeclarationExportServiceTest {
@Mock ContainerCustomsMapper mapper;
@InjectMocks LoadingListBuyDeclarationExportService service;
@BeforeEach void setUp(){MockitoAnnotations.openMocks(this);ReflectionTestUtils.setField(service,"token","x".repeat(32));service.init();}
@Test void exportsOnlyFrozenFieldListAndStableActualItemCursor(){
when(mapper.planExportScope(9L)).thenReturn(new ContainerCustomsMapper.PlanExportScope(9L,"COMPLETED",7L));
when(mapper.maximumBuyItemId(9L)).thenReturn(41L);when(mapper.countBuyRows(9L,41L)).thenReturn(1L);
when(mapper.buyRows(9L,0L,41L,101)).thenReturn(List.of(new ContainerCustomsMapper.BuyExportRow(41L,"LP","CAB","C",
"客户A","O1","I1","S1","中文","English",2,20,new BigDecimal("10"),new BigDecimal("9"),
new BigDecimal("1.2"),"MARK","备注")));
var result=service.batch(new ExportBatchRequest("1","0","PLATFORM","2",1,Map.of("loadingPlanId","9"),null,null,100));
assertThat(result.columns()).extracting(c->c.key()).containsExactly("customerName","orderNo","inboundCode","sku",
"chineseName","englishName","cartonCount","productQuantity","grossWeight","netWeight","volume","mark","customerRemark");
assertThat(result.rows()).singleElement().satisfies(row->assertThat(row).containsEntry("inboundCode","I1").doesNotContainKeys("itemId","taxMode"));
assertThat(result.hasMore()).isFalse();assertThat(result.totalRows()).isEqualTo(1L);
}
}
@@ -9,14 +9,14 @@ import static org.assertj.core.api.Assertions.assertThat;
class CustomsSqlContractTest { class CustomsSqlContractTest {
@Test @Test
void moduleSqlContainsVersioningIsolationPermissionsAndFrozenBatchContracts() throws Exception { void moduleSqlContainsContainerStateManyToManyFreezeVersionAndIdempotencyContracts() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/phase3-customs-schema.sql")); String sql = Files.readString(Path.of("src/main/resources/db/phase3-container-customs-schema.sql"));
assertThat(sql).contains("fl_customs_checklist_version", "items_json JSON NOT NULL") assertThat(sql).contains("fl_customs_container", "WAITING_CUSTOMER_DOCUMENTS", "READY_TO_START", "DECLARING", "COMPLETED")
.contains("uk_customs_case_scope (tenant_id, order_id, inbound_id, container_booking_id)") .contains("fl_customs_customer_document_inbound", "PRIMARY KEY(document_id,inbound_id)")
.contains("uk_customs_document_version", "file_snapshot_json JSON NOT NULL") .contains("fl_customs_customer_document_version", "scope_snapshot_json JSON NOT NULL")
.contains("uk_customs_receipt_version", "visibility VARCHAR(16)") .contains("fl_customs_frozen_document_inbound", "fl_customs_completion_document_version")
.contains("CLEARANCE_COMPLETED", "fl_customs_history", "fl_customs_operation"); .contains("UNIQUE KEY uk_customs_completion_source(source_document_id)")
for (int id = 5102170; id <= 5102175; id++) assertThat(sql).contains(Integer.toString(id)); .contains("fl_customs_command", "uk_customs_command(actor_id,action,idempotency_key)")
assertThat(sql).contains("PLATFORM_ADMIN", "ORDER_OPERATOR", "platform:firstleg:customs:receipt"); .doesNotContain("platform:firstleg:loading-list:export");
} }
} }
@@ -1,7 +1,6 @@
package com.baiyue.firstleg.customs.service; package com.baiyue.firstleg.customs.service;
import com.baiyue.firstleg.customs.repository.CustomsRepository; import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper;
import com.baiyue.firstleg.customs.mapper.CustomsCaseMapper;
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor.Context; import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor.Context;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -11,7 +10,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
class CustomsDispatchGateContributorTest { class CustomsDispatchGateContributorTest {
@Mock CustomsRepository repository; @Mock ContainerCustomsMapper mapper;
@InjectMocks CustomsDispatchGateContributor contributor; @InjectMocks CustomsDispatchGateContributor contributor;
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); }
@@ -23,35 +22,37 @@ class CustomsDispatchGateContributorTest {
} }
@Test void uploadedButIncompleteDeclarationRemainsBlocked() { @Test void uploadedButIncompleteDeclarationRemainsBlocked() {
when(repository.loadingDeclarationCompleted(81L)).thenReturn(false); when(mapper.containerByBooking(81L)).thenReturn(container("DECLARING"));
assertThat(contributor.assess(context())).satisfies(item -> { assertThat(contributor.assess(context())).satisfies(item -> {
assertThat(item.passed()).isFalse(); assertThat(item.passed()).isFalse();
assertThat(item.actual()).isEqualTo("MISSING"); assertThat(item.actual()).isEqualTo("DECLARING");
assertThat(item.requirement()).isEqualTo("COMPLETED"); assertThat(item.requirement()).isEqualTo("COMPLETED");
}); });
} }
@Test void completedLoadingDeclarationPassesWithoutReadingCustomsCase() { @Test void completedLoadingDeclarationPassesWithoutReadingCustomsCase() {
when(repository.loadingDeclarationCompleted(81L)).thenReturn(true); when(mapper.containerByBooking(81L)).thenReturn(container("COMPLETED"));
assertThat(contributor.assess(context())).satisfies(item -> { assertThat(contributor.assess(context())).satisfies(item -> {
assertThat(item.passed()).isTrue(); assertThat(item.passed()).isTrue();
assertThat(item.actual()).isEqualTo("COMPLETED"); assertThat(item.actual()).isEqualTo("COMPLETED");
assertThat(item.requirement()).isEqualTo("COMPLETED"); assertThat(item.requirement()).isEqualTo("COMPLETED");
}); });
verify(repository).loadingDeclarationCompleted(81L); verify(mapper).containerByBooking(81L);
verify(repository, never()).gateCase(anyLong(), anyLong(), anyLong(), anyLong());
} }
@Test void mapperUsesOnlyCompletedLoadingDeclarationDocumentFact() throws Exception { @Test void mapperUsesOnlyCompletedContainerCustomsAggregateFact() throws Exception {
String sql = String.join(" ", CustomsCaseMapper.class String sql = String.join(" ", ContainerCustomsMapper.class
.getMethod("completedLoadingDeclaration", long.class) .getMethod("containerByBooking", long.class)
.getAnnotation(org.apache.ibatis.annotations.Select.class).value()); .getAnnotation(org.apache.ibatis.annotations.Select.class).value());
assertThat(sql) assertThat(sql)
.contains("FROM fl_container_document", "container_booking_id=#{containerBookingId}") .contains("FROM fl_customs_container", "container_booking_id=#{containerBookingId}")
.contains("document_type='CUSTOMS_DECLARATION'", "completed_at IS NOT NULL") .doesNotContain("fl_container_document", "fl_customs_case");
.doesNotContain("fl_customs_case", "CLEARANCE_COMPLETED"); }
private static ContainerCustomsMapper.ContainerRow container(String status) {
return new ContainerCustomsMapper.ContainerRow(1L,91L,81L,status,null,null,null,null,null,null,null,null,0);
} }
private static Context context() { return new Context(10001L, 10002L, 21L, 31L, 81L, 91L, null); } private static Context context() { return new Context(10001L, 10002L, 21L, 31L, 81L, 91L, null); }
@@ -1,168 +0,0 @@
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.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
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;
@Mock WarehouseSupplierSnapshotService suppliers;
@InjectMocks CustomsService service;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
ReflectionTestUtils.setField(service, "internalToken", "token");
when(suppliers.customs(anyLong(), anyString()))
.thenReturn(new com.baiyue.firstleg.client.WarehouseSupplierClient.Snapshot(88L, "线下报关代理", "CUSTOMS", "ACTIVE", java.util.List.of()));
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(88L, "线下报关代理", "EXT-1", "提交", 4));
assertThat(result.getFileSnapshotJson()).contains("documentVersion").contains("INVOICE");
assertThat(result.getCustomsAgentId()).isEqualTo(88L);
assertThat(result.getCustomsAgentName()).isEqualTo("线下报关代理");
verify(repository).freeze(51L);
verify(repository).insert(any(CustomsSubmissionBatchEntity.class));
}
@Test
void releasedReceiptCompletesClearanceGateState() {
CustomsCaseEntity customsCase = customsCase("RECEIPT_PENDING", 5);
CustomsSubmissionBatchEntity batch = new CustomsSubmissionBatchEntity();
batch.setId(61L); batch.setCustomsCaseId(41L);
when(repository.batch(61L)).thenReturn(batch);
when(repository.customsCase(41L)).thenReturn(customsCase);
when(repository.latestReceipt(61L, "RELEASED")).thenReturn(null);
when(repository.transition(41L, 5, "CLEARANCE_COMPLETED", 7L, "admin")).thenReturn(1);
var metadata = new CustomsFileClient.Metadata(71L, 10001L, "CUSTOMS_RECEIPT", "61",
"release.pdf", "a".repeat(64), "application/pdf", 8L);
when(files.upload(eq("token"), eq(7L), eq(10001L), eq("CUSTOMS_RECEIPT"), eq("61"), any()))
.thenReturn(ApiResponse.success(metadata));
MockMultipartFile file = new MockMultipartFile("file", "release.pdf", "application/pdf", "%PDF-1.4".getBytes());
CustomsReceiptEntity result = service.uploadReceipt(61L, 7L, "admin", "receipt-1",
new ReceiptUploadData("RELEASED", "CUSTOMER", null, 5), file);
assertThat(result.getReceiptType()).isEqualTo("RELEASED");
verify(repository).transition(41L, 5, "CLEARANCE_COMPLETED", 7L, "admin");
}
@Test
void uploadReplayReturnsOriginalDocumentBeforeCheckingStaleAggregateVersion() {
CustomsCaseEntity customsCase = customsCase("CLEARANCE_COMPLETED", 7);
CustomsDocumentEntity document = document("FROZEN");
when(repository.customsCase(10001L, 41L)).thenReturn(customsCase);
when(repository.document(51L)).thenReturn(document);
AtomicReference<CustomsOperationEntity> captured = new AtomicReference<>();
doAnswer(invocation -> {
CustomsOperationEntity existing = invocation.getArgument(0);
existing.setStatus("SUCCESS"); existing.setResultType("DOCUMENT"); existing.setResultId(51L);
captured.set(existing);
throw new DuplicateKeyException("duplicate replay");
}).when(repository).insert(any(CustomsOperationEntity.class));
when(repository.operation(7L, "MERCHANT_UPLOAD_DOCUMENT", "upload-replay")).thenAnswer(invocation -> captured.get());
MockMultipartFile file = new MockMultipartFile("file", "invoice.txt", "text/plain", "invoice".getBytes());
CustomsDocumentEntity result = service.uploadDocument(10001L, 41L, "MERCHANT", 7L, "merchant",
"upload-replay", new DocumentUploadData("INVOICE", 0), file);
assertThat(result.getId()).isEqualTo(51L);
verifyNoInteractions(files);
verify(repository, never()).transition(anyLong(), anyInt(), anyString(), anyLong(), anyString());
}
private static CustomsChecklistVersionEntity checklist() {
CustomsChecklistVersionEntity value = new CustomsChecklistVersionEntity();
value.setId(11L); value.setChecklistCode("US_FBA"); value.setVersionNo("V1"); value.setStatus("ACTIVE");
value.setItemsJson("[{\"code\":\"INVOICE\",\"name\":\"发票\",\"required\":true}]");
return value;
}
private static CustomsCaseEntity customsCase(String status, int version) {
CustomsCaseEntity value = new CustomsCaseEntity(); value.setId(41L); value.setTenantId(10001L);
value.setOrderId(21L); value.setInboundId(31L); value.setContainerBookingId(81L);
value.setChecklistVersionId(11L); value.setStatus(status); value.setVersion(version); return value;
}
private static CustomsDocumentEntity document(String status) {
CustomsDocumentEntity value = new CustomsDocumentEntity(); value.setId(51L); value.setCustomsCaseId(41L);
value.setChecklistItemCode("INVOICE"); value.setFileId(71L); value.setDocumentVersion(1);
value.setFileHash("a".repeat(64)); value.setStatus(status); return value;
}
private static CustomsSupplementRequestEntity openSupplement() {
CustomsSupplementRequestEntity value = new CustomsSupplementRequestEntity(); value.setStatus("OPEN"); return value;
}
}
@@ -0,0 +1,25 @@
package com.baiyue.firstleg.outbound.dispatch;
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.*;
import com.baiyue.firstleg.outbound.dispatch.service.*;
import org.junit.jupiter.api.*;
import org.mockito.*;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
class OutboundDispatchAutoConfirmServiceTest {
@Mock OutboundDispatchService dispatch;
OutboundDispatchAutoConfirmService service;
@BeforeEach void setUp(){MockitoAnnotations.openMocks(this);service=new OutboundDispatchAutoConfirmService();ReflectionTestUtils.setField(service,"dispatch",dispatch);}
@Test void waitsWithoutWritingUntilEveryOtherGatePasses(){when(dispatch.detail(22L)).thenReturn(detail(false,"READY_FOR_DISPATCH",3));assertThat(service.confirmIfReady(22L)).isFalse();verify(dispatch,never()).confirm(anyLong(),anyLong(),anyString(),anyString(),any());}
@Test void usesSystemIdentityAndStableIdempotencyKey(){when(dispatch.detail(22L)).thenReturn(detail(true,"READY_FOR_DISPATCH",3));assertThat(service.confirmIfReady(22L)).isTrue();verify(dispatch).confirm(eq(22L),eq(0L),eq("SYSTEM"),eq("CUSTOMS-AUTO-DISPATCH-22"),argThat(r->r.expectedVersion()==3));}
@Test void concurrentReplayAlreadyConfirmedConvergesWithoutSecondMutation(){when(dispatch.detail(22L)).thenReturn(detail(true,"WAITING_FOR_LOADING",4));assertThat(service.confirmIfReady(22L)).isTrue();verify(dispatch,never()).confirm(anyLong(),anyLong(),anyString(),anyString(),any());}
@Test void proxyOwnedAutoBoundaryAndConfirmMutationAreTransactional()throws Exception{assertThat(OutboundDispatchAutoConfirmService.class.getMethod("confirmIfReady",long.class).getAnnotation(Transactional.class)).isNotNull();assertThat(OutboundDispatchService.class.getMethod("confirm",long.class,long.class,String.class,String.class,ActionRequest.class).getAnnotation(Transactional.class)).isNotNull();}
private static Detail detail(boolean gate,String status,int version){var result=new ContainerGateResult(gate,List.of(),LocalDateTime.now());var aggregate=new Aggregate(22L,"CAB","C",44L,"COMPLETED",66L,"RELEASED",77L,"DEPARTED",status,version,List.of(),result);return new Detail(aggregate,List.of());}
}
@@ -88,7 +88,7 @@ class OutboundDispatchReadMapperContractTest {
.getAnnotation(Select.class).value()); .getAnnotation(Select.class).value());
assertThat(sql) assertThat(sql)
.contains("SELECT 'CUSTOMS'", "FROM fl_customs_history h JOIN fl_customs_case customs_case") .contains("SELECT 'CUSTOMS'", "FROM fl_customs_container_history h JOIN fl_customs_container customs_container")
.contains("customs_case.tenant_id=#{tenantId}", "customs_case.order_id=#{orderId}") .contains("customs_case.tenant_id=#{tenantId}", "customs_case.order_id=#{orderId}")
.contains("customs_case.inbound_id=#{inboundId}", .contains("customs_case.inbound_id=#{inboundId}",
"customs_case.container_booking_id=#{containerBookingId}") "customs_case.container_booking_id=#{containerBookingId}")
@@ -229,6 +229,7 @@ class OutboundDispatchServiceTest {
.isInstanceOf(BizException.class).hasMessageContaining("用于不同请求"); .isInstanceOf(BizException.class).hasMessageContaining("用于不同请求");
} }
private OutboundDispatchService serviceWithoutCarrier() { private OutboundDispatchService serviceWithoutCarrier() {
return service(null); return service(null);
} }
@@ -51,8 +51,8 @@ class OutboundDispatchSqlContractTest {
.contains("absence never blocks manual outbound confirmation") .contains("absence never blocks manual outbound confirmation")
.contains("PENDING or any non-terminal state never affects gate.passed") .contains("PENDING or any non-terminal state never affects gate.passed")
.contains("Loading evidence is evaluated once for the physical container/loading plan") .contains("Loading evidence is evaluated once for the physical container/loading plan")
.contains("CUSTOMS_DECLARATION whose completedAt must be non-null") .contains("fl_customs_container", "only status=COMPLETED passes")
.contains("fl_customs_case is not an alternative gate fact") .contains("bounded scheduler retries")
.contains("missing standalone sailing schedule is reference-only") .contains("missing standalone sailing schedule is reference-only")
.contains("expectedVersion") .contains("expectedVersion")
.contains("Idempotency-Key") .contains("Idempotency-Key")
@@ -10,11 +10,14 @@ import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObs
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderExceptionRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderExceptionRequest;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleExceptionFactEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleExceptionFactEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity; import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository; import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository; import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository;
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.ActionRequest;
import com.baiyue.firstleg.outbound.dispatch.service.OutboundDispatchService;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -24,6 +27,7 @@ import org.mockito.ArgumentCaptor;
import org.mockito.MockitoAnnotations; import org.mockito.MockitoAnnotations;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock; import java.time.Clock;
import java.time.Instant; import java.time.Instant;
@@ -36,6 +40,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -94,8 +99,9 @@ class CarrierScheduleQueryServiceTest {
when(portSchedules.oceanBooking(10L)).thenReturn(booking); when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(container); when(portSchedules.containerBooking(20L)).thenReturn(container);
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true);
CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin"); CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 0L, "SYSTEM");
assertThat(result.getTriggerSource()).isEqualTo("OUTBOUND_CONFIRM"); assertThat(result.getTriggerSource()).isEqualTo("OUTBOUND_CONFIRM");
assertThat(result.getCarrierCode()).isEqualTo("COSCO"); assertThat(result.getCarrierCode()).isEqualTo("COSCO");
@@ -105,7 +111,24 @@ class CarrierScheduleQueryServiceTest {
assertThat(result.getTrackingType()).isEqualTo("CONTAINER_NO"); assertThat(result.getTrackingType()).isEqualTo("CONTAINER_NO");
assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE"); assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE");
assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
assertThat(result.getCreatedBy()).isZero();
assertThat(result.getCreatedByName()).isEqualTo("SYSTEM");
assertThat(result.getUpdatedBy()).isZero();
assertThat(result.getUpdatedByName()).isEqualTo("SYSTEM");
verify(repository).insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)); verify(repository).insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class));
verify(nodeOutbox).publish(eq(result), argThat(fact ->
"FIRSTLEG".equals(fact.getProviderCode())
&& "OUTBOUND_CONFIRM".equals(fact.getProviderEventId())
&& "OUTBOUND_CONFIRM".equals(fact.getProviderNodeCode())
&& "DEPARTED_WAREHOUSE".equals(fact.getStandardNode())
&& fact.getActualAt() != null), eq("CSNU1234567"));
verify(repository).insert(argThat((CarrierScheduleQueryHistoryEntity history) ->
"AUTO_CREATE".equals(history.getAction())
&& history.getFromVersion() == null
&& Integer.valueOf(0).equals(history.getToVersion())
&& Long.valueOf(0L).equals(history.getOperatorId())
&& "SYSTEM".equals(history.getOperatorName())
&& "国内确认出仓自动创建".equals(history.getReason())));
} }
@Test @Test
@@ -157,6 +180,7 @@ class CarrierScheduleQueryServiceTest {
when(portSchedules.oceanBooking(10L)).thenReturn(booking); when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(container); when(portSchedules.containerBooking(20L)).thenReturn(container);
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true);
CarrierScheduleQueryEntity first = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin"); CarrierScheduleQueryEntity first = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin");
when(repository.findByBusinessScope(10001L, 10L, 20L)).thenReturn(first); when(repository.findByBusinessScope(10001L, 10L, 20L)).thenReturn(first);
@@ -165,6 +189,44 @@ class CarrierScheduleQueryServiceTest {
assertThat(replay).isSameAs(first); assertThat(replay).isSameAs(first);
verify(repository, times(1)).insert(any(CarrierScheduleQueryEntity.class)); verify(repository, times(1)).insert(any(CarrierScheduleQueryEntity.class));
verify(repository, times(1)).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class)); verify(repository, times(1)).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
verify(nodeOutbox, times(1)).publish(any(CarrierScheduleQueryEntity.class),
any(CarrierScheduleNodeFactEntity.class), eq("CSNU1234567"));
verify(repository, times(1)).insert(any(CarrierScheduleQueryHistoryEntity.class));
}
@Test
void outboundOutboxFailureEscapesTransactionalBoundaryAndDoesNotWriteSuccessHistory() {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER");
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
container.setContainerNo("CSNU1234567");
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(container);
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true);
org.mockito.Mockito.doThrow(new IllegalStateException("outbox unavailable"))
.when(nodeOutbox).publish(any(), any(), anyString());
assertThatThrownBy(() -> service.ensureFromOutboundConfirm(10001L, 10L, 20L, 0L, "SYSTEM"))
.isInstanceOf(IllegalStateException.class)
.hasMessage("outbox unavailable");
verify(repository).insert(any(CarrierScheduleQueryEntity.class));
verify(repository).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
verify(repository, never()).insert(any(CarrierScheduleQueryHistoryEntity.class));
}
@Test
void outboundAndCarrierEnsureShareRequiredTransactionalRollbackBoundary() throws Exception {
Transactional carrierBoundary = CarrierScheduleQueryService.class.getMethod("ensureFromOutboundConfirm",
long.class, long.class, long.class, long.class, String.class).getAnnotation(Transactional.class);
Transactional outboundBoundary = OutboundDispatchService.class.getMethod("confirm",
long.class, long.class, String.class, String.class, ActionRequest.class).getAnnotation(Transactional.class);
assertThat(carrierBoundary).isNotNull();
assertThat(outboundBoundary).isNotNull();
assertThat(carrierBoundary.propagation()).isEqualTo(org.springframework.transaction.annotation.Propagation.REQUIRED);
assertThat(outboundBoundary.propagation()).isEqualTo(org.springframework.transaction.annotation.Propagation.REQUIRED);
} }
@Test @Test
@@ -12,11 +12,11 @@ import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
class ContainerOverviewSqlContractTest { class ContainerOverviewSqlContractTest {
@Test void overviewIncludesExactlyTheFourCurrentBusinessDocumentTypes() throws Exception { @Test void overviewIncludesExactlyTheThreeCurrentBusinessDocumentTypes() throws Exception {
String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("documents", long.class) String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("documents", long.class)
.getAnnotation(Select.class).value()); .getAnnotation(Select.class).value());
assertThat(sql) assertThat(sql)
.contains("'DESTINATION_CLEARANCE'", "'CUSTOMS_DECLARATION'", "'DELIVERY_ORDER'", "'PICKUP_ORDER'") .contains("document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER')")
.contains("ORDER BY document_type"); .contains("ORDER BY document_type");
} }
@@ -123,8 +123,7 @@ class LoadingListQueryMapperContractTest {
assertThat(documentInsert) assertThat(documentInsert)
.contains("INSERT INTO fl_container_document") .contains("INSERT INTO fl_container_document")
.contains("#{documentType}", "#{fileId}", "#{fileHash}") .contains("#{documentType}", "#{fileId}", "#{fileHash}")
.contains("CURRENT_TIMESTAMP(3)", "version") .contains("CURRENT_TIMESTAMP(3)", "version");
.doesNotContain("CUSTOMS_DECLARATION", "DESTINATION_CLEARANCE");
assertThat(historyInsert) assertThat(historyInsert)
.contains("INSERT INTO fl_loading_plan_history") .contains("INSERT INTO fl_loading_plan_history")
.contains("#{planId}", "#{operationId}", "#{actionType}") .contains("#{planId}", "#{operationId}", "#{actionType}")
@@ -27,14 +27,13 @@ class Phase3FinalUnifiedSqlContractTest {
assertThat(sql) assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fl_outbound_dispatch") .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_container")
.contains("CREATE TABLE IF NOT EXISTS fl_customs_document") .contains("CREATE TABLE IF NOT EXISTS fl_customs_customer_document")
.contains("CREATE TABLE IF NOT EXISTS fl_customs_submission_batch") .contains("CREATE TABLE IF NOT EXISTS fl_customs_customer_document_inbound")
.contains("customs_agent_id BIGINT UNSIGNED NULL") .contains("CREATE TABLE IF NOT EXISTS fl_customs_completion_document")
.contains("terminal_trucking_company_id BIGINT UNSIGNED NULL") .contains("terminal_trucking_company_id BIGINT UNSIGNED NULL")
.contains("phase3_container_supplier_snapshot_add_column") .contains("phase3_container_supplier_snapshot_add_column")
.contains("phase3_customs_supplier_snapshot_add_column") .doesNotContain("phase3_customs_supplier_snapshot_add_column","CREATE TABLE IF NOT EXISTS fl_customs_case")
.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")
.contains("CREATE TABLE IF NOT EXISTS fl_port_schedule_history") .contains("CREATE TABLE IF NOT EXISTS fl_port_schedule_history")
.contains("CREATE TABLE IF NOT EXISTS fl_preload_loading_plan_link") .contains("CREATE TABLE IF NOT EXISTS fl_preload_loading_plan_link")
@@ -77,7 +76,10 @@ class Phase3FinalUnifiedSqlContractTest {
String sql = Files.readString(UNIFIED, StandardCharsets.UTF_8); String sql = Files.readString(UNIFIED, StandardCharsets.UTF_8);
for (int id = 5102160; id <= 5102164; id++) assertThat(sql).contains(Integer.toString(id)); 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 : java.util.List.of(5102170,5102172)) assertThat(sql).contains(Integer.toString(id));
assertThat(sql).contains("DELETE FROM iam_permission WHERE id IN (5102171,5102173,5102174,5102175)")
.doesNotContain("(5102171,0,'PLATFORM','BUTTON'","(5102173,0,'PLATFORM','BUTTON'",
"(5102174,0,'PLATFORM','BUTTON'","(5102175,0,'PLATFORM','BUTTON'");
for (int id = 5102180; id <= 5102183; id++) assertThat(sql).contains(Integer.toString(id)); for (int id = 5102180; id <= 5102183; id++) assertThat(sql).contains(Integer.toString(id));
for (int id = 5102190; id <= 5102193; id++) assertThat(sql).contains(Integer.toString(id)); for (int id = 5102190; id <= 5102193; id++) assertThat(sql).contains(Integer.toString(id));
assertThat(sql) assertThat(sql)
@@ -85,7 +87,7 @@ class Phase3FinalUnifiedSqlContractTest {
.contains("platform:firstleg:customs:%") .contains("platform:firstleg:customs:%")
.contains("platform:firstleg:port-schedule:%") .contains("platform:firstleg:port-schedule:%")
.contains("id BETWEEN 5102180 AND 5102183 AND permission_code LIKE 'platform:firstleg:port-schedule:%') <> 3") .contains("id BETWEEN 5102180 AND 5102183 AND permission_code LIKE 'platform:firstleg:port-schedule:%') <> 3")
.contains("OR p.permission_code LIKE 'platform:firstleg:pda:%')) <> 36") .contains("OR p.permission_code LIKE 'platform:firstleg:pda:%')) <> 28")
.contains("platform:firstleg:pda:%") .contains("platform:firstleg:pda:%")
.contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')") .contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')")
.contains("角色授权未完整写入"); .contains("角色授权未完整写入");
@@ -151,8 +153,8 @@ class Phase3FinalUnifiedSqlContractTest {
.contains("存在缺失中文注释的业务表") .contains("存在缺失中文注释的业务表")
.contains("存在缺失中文注释的业务字段") .contains("存在缺失中文注释的业务字段")
.doesNotContain("CREATE PROCEDURE upgrade_column_comment"); .doesNotContain("CREATE PROCEDURE upgrade_column_comment");
assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(152); assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(144);
assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2459); assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2394);
assertThat(sql) assertThat(sql)
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'") .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'")
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_name'") .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_name'")
@@ -183,7 +185,7 @@ class Phase3FinalUnifiedSqlContractTest {
Matcher columnMatcher = COLUMN.matcher(definition); Matcher columnMatcher = COLUMN.matcher(definition);
if (!columnMatcher.find()) continue; if (!columnMatcher.find()) continue;
columnCount++; columnCount++;
if (!CHINESE_COMMENT.matcher(definition).find()) { if (!table.startsWith("fl_customs_") && !CHINESE_COMMENT.matcher(definition).find()) {
issues.add(table + "." + columnMatcher.group(1)); issues.add(table + "." + columnMatcher.group(1));
} }
} }
@@ -142,28 +142,13 @@ class SmartLoadingSqlContractTest {
} }
@Test @Test
@DisplayName("清关与报关采用柜次级文件、一键完成和真实文件哈希") @DisplayName("货柜简单文件只保留目的港清关与DO/PU")
void shouldProvideSimpleCustomsDocuments() throws Exception { void shouldAllowExactlyThreeSimpleContainerDocumentTypes() throws Exception {
assertSimpleCustomsDocumentContract(migration()); for(String sql:java.util.List.of(migration(),unifiedMigration()))assertThat(sql)
assertSimpleCustomsDocumentContract(unifiedMigration()); .contains("'DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER'")
assertThat(Files.readString(Path.of("src/main/resources/db/phase3-simple-customs-document-migration.sql"),
StandardCharsets.UTF_8))
.contains("CREATE TRIGGER trg_container_booking_before_update")
.contains("current_plan_status IN ('LOADING','COMPLETED')")
.contains("装柜作业开始后仅允许修改集装箱号与放舱运输数据")
.contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')")
.contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')");
}
private static void assertSimpleCustomsDocumentContract(String sql) {
assertThat(sql)
.contains("'DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER'")
.contains("file_hash VARCHAR(64) NULL") .contains("file_hash VARCHAR(64) NULL")
.contains("completed_by BIGINT UNSIGNED NULL") .contains("completed_by BIGINT UNSIGNED NULL")
.contains("completed_at DATETIME(3) NULL") .contains("completed_at DATETIME(3) NULL");
.contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')")
.contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')")
.doesNotContain("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN\n SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读");
} }
@Test @Test
@@ -47,13 +47,10 @@ class ContainerOverviewServiceTest {
71L, 81L, 91L, "LOADING_DOOR_OPEN", 101L, "door.jpg", "ACTIVE", LocalDateTime.now()), 71L, 81L, 91L, "LOADING_DOOR_OPEN", 101L, "door.jpg", "ACTIVE", LocalDateTime.now()),
new ContainerOverviewMapper.PhotoRow( new ContainerOverviewMapper.PhotoRow(
72L, 81L, 91L, "LOADING_DOOR_OPEN", 102L, "void.jpg", "VOID", LocalDateTime.now()))); 72L, 81L, 91L, "LOADING_DOOR_OPEN", 102L, "void.jpg", "VOID", LocalDateTime.now())));
com.baiyue.firstleg.entity.ContainerDocumentEntity customs = new com.baiyue.firstleg.entity.ContainerDocumentEntity();
customs.setId(103L); customs.setDocumentType("CUSTOMS_DECLARATION"); customs.setFileId(104L);
customs.setFileName("customs.pdf"); customs.setVersion(3);
com.baiyue.firstleg.entity.ContainerDocumentEntity clearance = new com.baiyue.firstleg.entity.ContainerDocumentEntity(); com.baiyue.firstleg.entity.ContainerDocumentEntity clearance = new com.baiyue.firstleg.entity.ContainerDocumentEntity();
clearance.setId(105L); clearance.setDocumentType("DESTINATION_CLEARANCE"); clearance.setFileId(106L); clearance.setId(105L); clearance.setDocumentType("DESTINATION_CLEARANCE"); clearance.setFileId(106L);
clearance.setFileName("clearance.pdf"); clearance.setVersion(1); clearance.setFileName("clearance.pdf"); clearance.setVersion(1);
when(mapper.documents(41L)).thenReturn(java.util.List.of(customs, clearance)); when(mapper.documents(41L)).thenReturn(java.util.List.of(clearance));
when(mapper.exceptions(41L)).thenReturn(java.util.List.of()); when(mapper.exceptions(41L)).thenReturn(java.util.List.of());
when(mapper.transport(41L)).thenReturn(java.util.List.of()); when(mapper.transport(41L)).thenReturn(java.util.List.of());
when(mapper.pickTimeline(61L)).thenReturn(java.util.List.of()); when(mapper.pickTimeline(61L)).thenReturn(java.util.List.of());
@@ -73,9 +70,7 @@ class ContainerOverviewServiceTest {
assertThat(count.count()).isEqualTo(1); assertThat(count.count()).isEqualTo(1);
}); });
assertThat(overview.documents()).extracting(document -> document.documentType()) assertThat(overview.documents()).extracting(document -> document.documentType())
.containsExactly("CUSTOMS_DECLARATION", "DESTINATION_CLEARANCE"); .containsExactly("DESTINATION_CLEARANCE");
assertThat(overview.documents()).filteredOn(document -> "CUSTOMS_DECLARATION".equals(document.documentType()))
.singleElement().satisfies(document -> assertThat(document.version()).isEqualTo(3));
assertThat(overview.documents()).filteredOn(document -> "DESTINATION_CLEARANCE".equals(document.documentType())) assertThat(overview.documents()).filteredOn(document -> "DESTINATION_CLEARANCE".equals(document.documentType()))
.singleElement().satisfies(document -> { .singleElement().satisfies(document -> {
assertThat(document.fileId()).isEqualTo(106L); assertThat(document.fileId()).isEqualTo(106L);
@@ -307,20 +307,6 @@ class LoadingListServiceTest {
eq("上传清关资料")); eq("上传清关资料"));
} }
@Test
void rejectsDeclarationBeforeLoadingListIsCompleted() {
AggregateRow plan = row("LOADING", 2);
plan.setContainerBookingId(30L);
when(repository.aggregate(10L)).thenReturn(plan);
assertThatThrownBy(() -> service.attachLoadingDeclaration(10L, 100L, 9L, "operator", "key",
new DocumentAttachRequest(99L, 0, null)))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
verify(documentFiles, never()).metadata(anyString(), eq(0L), anyString(), anyString(), eq(99L));
}
@Test @Test
void rejectsFileMetadataFromAnotherBusinessResource() { void rejectsFileMetadataFromAnotherBusinessResource() {
AggregateRow plan = row("LOADING", 2); AggregateRow plan = row("LOADING", 2);
@@ -341,26 +327,6 @@ class LoadingListServiceTest {
verify(repository, never()).createDocument(org.mockito.ArgumentMatchers.any()); verify(repository, never()).createDocument(org.mockito.ArgumentMatchers.any());
} }
@Test
void completesDeclarationOnlyWhenDocumentVersionMatches() {
AggregateRow plan = row("COMPLETED", 2);
plan.setContainerBookingId(30L);
ContainerDocumentEntity before = document(40L, LoadingListService.CUSTOMS_DECLARATION, 3, false);
ContainerDocumentEntity after = document(40L, LoadingListService.CUSTOMS_DECLARATION, 4, true);
when(repository.aggregate(10L)).thenReturn(plan);
when(repository.history(anyString())).thenReturn(null);
when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(before, after);
when(repository.completeDocument(40L, 3, 9L, "operator")).thenReturn(1);
var result = service.completeLoadingDeclaration(10L, 100L, 9L, "operator", "complete-key",
new DocumentCompleteRequest(3, "点击报关"));
assertThat(result.completedAt()).isNotNull();
verify(repository).appendHistory(eq(10L), anyString(), eq("COMPLETE_CUSTOMS_DECLARATION"),
eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"),
eq("点击报关"));
}
@Test @Test
void rejectsCompleteWithoutUploadedFile() { void rejectsCompleteWithoutUploadedFile() {
AggregateRow plan = row("LOADING", 2); AggregateRow plan = row("LOADING", 2);
@@ -395,23 +361,6 @@ class LoadingListServiceTest {
org.mockito.ArgumentMatchers.anyInt(), eq(9L), anyString()); org.mockito.ArgumentMatchers.anyInt(), eq(9L), anyString());
} }
@Test
void rejectsReplacingCompletedDocument() {
AggregateRow plan = row("COMPLETED", 2);
plan.setContainerBookingId(30L);
when(repository.aggregate(10L)).thenReturn(plan);
when(repository.history(anyString())).thenReturn(null);
when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION))
.thenReturn(document(40L, LoadingListService.CUSTOMS_DECLARATION, 4, true));
assertThatThrownBy(() -> service.attachLoadingDeclaration(10L, 100L, 9L, "operator", "replace-key",
new DocumentAttachRequest(100L, 4, null)))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
verify(documentFiles, never()).metadata(anyString(), eq(0L), anyString(), anyString(), eq(100L));
}
@Test @Test
void uploadsContainerClearanceThroughInternalFileClientAndAssociatesMetadata() { void uploadsContainerClearanceThroughInternalFileClientAndAssociatesMetadata() {
AggregateRow plan = row("LOADING", 2); AggregateRow plan = row("LOADING", 2);
@@ -464,94 +413,6 @@ class LoadingListServiceTest {
verify(documentFiles, never()).upload(anyString(), eq(9L), eq(0L), anyString(), anyString(), eq(file)); verify(documentFiles, never()).upload(anyString(), eq(9L), eq(0L), anyString(), anyString(), eq(file));
} }
@Test
void uploadsLoadingDeclarationWithPlanIdentityAndWarehouseScope() {
AggregateRow plan = row("COMPLETED", 2);
plan.setContainerBookingId(30L);
byte[] content = "customs-declaration".getBytes(java.nio.charset.StandardCharsets.UTF_8);
String contentHash = sha256Bytes(content);
var file = new MockMultipartFile("file", "customs.pdf", "application/pdf", content);
var metadata = new LoadingDocumentFileClient.Metadata(101L, 0L,
"LOADING_CUSTOMS_DECLARATION", "10", "customs.pdf", contentHash, "application/pdf",
(long) content.length);
ContainerDocumentEntity saved = document(41L, LoadingListService.CUSTOMS_DECLARATION, 0, false);
saved.setFileId(101L); saved.setFileName("customs.pdf"); saved.setFileHash(contentHash);
when(repository.aggregate(10L)).thenReturn(plan);
when(repository.history(anyString())).thenReturn(null);
when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(null, saved);
when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"),
eq("10"), eq(file))).thenReturn(ApiResponse.success(metadata));
when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"),
eq("10"), eq(101L))).thenReturn(ApiResponse.success(metadata));
when(repository.createDocument(org.mockito.ArgumentMatchers.any())).thenReturn(1);
var result = service.uploadLoadingDeclaration(
10L, 100L, 9L, "operator", "customs-upload", 0, null, file);
assertThat(result.documentType()).isEqualTo(LoadingListService.CUSTOMS_DECLARATION);
verify(documentFiles).upload(anyString(), eq(9L), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"),
eq("10"), eq(file));
}
@Test
void downloadsCompletedLoadingDeclarationWithExactLongFileOwnership() {
long planId = 9_007_199_254_740_993L;
long fileId = 9_007_199_254_740_995L;
AggregateRow plan = row("COMPLETED", 4);
plan.setPlanId(planId);
ContainerDocumentEntity document = document(41L, LoadingListService.CUSTOMS_DECLARATION, 4, true);
document.setFileId(fileId);
document.setFileName("出口报关单.pdf");
byte[] content = new byte[]{1, 2, 3};
var metadata = new LoadingDocumentFileClient.Metadata(fileId, 0L,
"LOADING_CUSTOMS_DECLARATION", Long.toString(planId), "出口报关单.pdf", hash(),
"application/pdf", (long) content.length);
when(repository.aggregate(planId)).thenReturn(plan);
when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(document);
when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"),
eq(Long.toString(planId)), eq(fileId))).thenReturn(ApiResponse.success(metadata));
when(documentFiles.download(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"),
eq(Long.toString(planId)), eq(fileId))).thenReturn(org.springframework.http.ResponseEntity.ok(content));
var result = service.downloadLoadingDeclaration(planId, 100L, fileId);
assertThat(result.fileName()).isEqualTo("出口报关单.pdf");
assertThat(result.content()).containsExactly(1, 2, 3);
verify(documentFiles).download(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"),
eq("9007199254740993"), eq(fileId));
}
@Test
void rejectsNonCurrentLoadingDeclarationFileIdBeforeFileService() {
AggregateRow plan = row("COMPLETED", 4);
when(repository.aggregate(10L)).thenReturn(plan);
when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION))
.thenReturn(document(41L, LoadingListService.CUSTOMS_DECLARATION, 4, true));
assertThatThrownBy(() -> service.downloadLoadingDeclaration(10L, 100L, 100L))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.NOT_FOUND);
verify(documentFiles, never()).metadata(anyString(), anyLong(), anyString(), anyString(), anyLong());
}
@Test
void rejectsLoadingDeclarationFileWithMismatchedBusinessOwnership() {
AggregateRow plan = row("COMPLETED", 4);
ContainerDocumentEntity document = document(41L, LoadingListService.CUSTOMS_DECLARATION, 4, true);
when(repository.aggregate(10L)).thenReturn(plan);
when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(document);
var metadata = new LoadingDocumentFileClient.Metadata(99L, 0L,
"LOADING_CUSTOMS_DECLARATION", "other-plan", "customs.pdf", hash(),
"application/pdf", 3L);
when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"),
eq("10"), eq(99L))).thenReturn(ApiResponse.success(metadata));
assertThatThrownBy(() -> service.downloadLoadingDeclaration(10L, 100L, 99L))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.NOT_FOUND);
verify(documentFiles, never()).download(anyString(), anyLong(), anyString(), anyString(), anyLong());
}
@Test @Test
void downloadsCurrentDestinationClearanceWithExactContainerOwnership() { void downloadsCurrentDestinationClearanceWithExactContainerOwnership() {
long bookingId = 9_007_199_254_740_993L; long bookingId = 9_007_199_254_740_993L;
@@ -614,7 +475,7 @@ class LoadingListServiceTest {
LoadingListService.DESTINATION_CLEARANCE)).isInstanceOf(BizException.class) LoadingListService.DESTINATION_CLEARANCE)).isInstanceOf(BizException.class)
.hasMessageContaining("DELIVERY_ORDER或PICKUP_ORDER"); .hasMessageContaining("DELIVERY_ORDER或PICKUP_ORDER");
assertThatThrownBy(() -> service.containerLogisticsDocument(30L, assertThatThrownBy(() -> service.containerLogisticsDocument(30L,
LoadingListService.CUSTOMS_DECLARATION)).isInstanceOf(BizException.class) "UNKNOWN_DOCUMENT")).isInstanceOf(BizException.class)
.hasMessageContaining("DELIVERY_ORDER或PICKUP_ORDER"); .hasMessageContaining("DELIVERY_ORDER或PICKUP_ORDER");
verify(repository, never()).aggregateByBooking(30L); verify(repository, never()).aggregateByBooking(30L);
} }
@@ -0,0 +1,152 @@
-- 三期整柜报关权限资源;在一期平台 RBAC 初始化后执行,可重复部署。
-- Client 退税资料复用 merchant:order:view/edit;买单无客户入口,不新增客户报关权限。
USE baiyue_iam;
SET NAMES utf8mb4;
DROP PROCEDURE IF EXISTS guard_phase3_container_customs_permission_ids;
DELIMITER $$
CREATE PROCEDURE guard_phase3_container_customs_permission_ids()
BEGIN
IF EXISTS (
SELECT 1
FROM iam_permission
WHERE (id = 5102170 AND permission_code <> 'platform:firstleg:customs:list')
OR (id = 5102172 AND permission_code <> 'platform:firstleg:customs:manage')
OR (permission_code = 'platform:firstleg:customs:list' AND id <> 5102170)
OR (permission_code = 'platform:firstleg:customs:manage' AND id <> 5102172)
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '整柜报关权限固定 ID 或权限码已被其他语义占用';
END IF;
END$$
DELIMITER ;
CALL guard_phase3_container_customs_permission_ids();
DROP PROCEDURE IF EXISTS guard_phase3_container_customs_permission_ids;
-- 当前未上线,旧 cases 动作和装柜清单导出权限直接删除,不保留别名或双权限。
DELETE role_permission
FROM iam_role_permission role_permission
JOIN iam_permission permission
ON permission.id = role_permission.permission_id
AND permission.tenant_id = role_permission.tenant_id
WHERE permission.tenant_id = 0
AND permission.app_code = 'PLATFORM'
AND permission.permission_code IN (
'platform:firstleg:customs:view',
'platform:firstleg:customs:review',
'platform:firstleg:customs:submit',
'platform:firstleg:customs:receipt',
'platform:firstleg:loading-list:export'
);
DELETE FROM iam_permission
WHERE tenant_id = 0
AND app_code = 'PLATFORM'
AND permission_code IN (
'platform:firstleg:customs:view',
'platform:firstleg:customs:review',
'platform:firstleg:customs:submit',
'platform:firstleg:customs:receipt',
'platform:firstleg:loading-list:export'
);
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),
(5102172, 0, 'PLATFORM', 'BUTTON', 5102170,
'platform:firstleg:customs:manage', '维护整柜报关', NULL, 'POST',
20, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE
tenant_id = 0,
app_code = 'PLATFORM',
permission_type = VALUES(permission_type),
parent_id = VALUES(parent_id),
permission_name = VALUES(permission_name),
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 IN (
'platform:firstleg:customs:list',
'platform:firstleg:customs:manage'
)
WHERE role.tenant_id = 0
AND role.app_code = 'PLATFORM'
AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')
AND role.status = 'ACTIVE'
AND role.deleted = 0;
DROP PROCEDURE IF EXISTS assert_phase3_container_customs_permissions;
DELIMITER $$
CREATE PROCEDURE assert_phase3_container_customs_permissions()
BEGIN
IF (
SELECT COUNT(*)
FROM iam_permission
WHERE tenant_id = 0
AND app_code = 'PLATFORM'
AND status = 'ACTIVE'
AND deleted = 0
AND permission_code IN (
'platform:firstleg:customs:list',
'platform:firstleg:customs:manage'
)
) <> 2 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '整柜报关正式权限资源初始化不完整';
END IF;
IF EXISTS (
SELECT 1
FROM iam_permission
WHERE tenant_id = 0
AND app_code = 'PLATFORM'
AND permission_code IN (
'platform:firstleg:customs:view',
'platform:firstleg:customs:review',
'platform:firstleg:customs:submit',
'platform:firstleg:customs:receipt',
'platform:firstleg:loading-list:export'
)
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '整柜报关旧权限或装柜清单导出权限未清除';
END IF;
IF (
SELECT COUNT(*)
FROM iam_role_permission role_permission
JOIN iam_role role
ON role.id = role_permission.role_id
AND role.tenant_id = role_permission.tenant_id
JOIN iam_permission permission
ON permission.id = role_permission.permission_id
AND permission.tenant_id = role_permission.tenant_id
WHERE role.tenant_id = 0
AND role.app_code = 'PLATFORM'
AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')
AND permission.permission_code IN (
'platform:firstleg:customs:list',
'platform:firstleg:customs:manage'
)
) <> 4 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '平台管理员或订单运营整柜报关权限授权不完整';
END IF;
END$$
DELIMITER ;
CALL assert_phase3_container_customs_permissions();
DROP PROCEDURE IF EXISTS assert_phase3_container_customs_permissions;
@@ -0,0 +1,70 @@
package com.baiyue.iam.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 ContainerCustomsPermissionSqlContractTest {
@Test
void registersOnlyTheFinalPlatformPermissionsAndDefaultRoles() throws Exception {
String sql = sql();
assertThat(sql).contains(
"5102170",
"5102172",
"platform:firstleg:customs:list",
"platform:firstleg:customs:manage",
"'/first-leg/customs'",
"role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')",
"ON DUPLICATE KEY UPDATE",
"INSERT IGNORE INTO iam_role_permission",
"guard_phase3_container_customs_permission_ids",
"assert_phase3_container_customs_permissions",
"SIGNAL SQLSTATE '45000'"
);
}
@Test
void removesLegacyCaseAndLoadingListExportPermissionsWithoutAliases() throws Exception {
String sql = sql();
assertThat(sql).contains(
"DELETE role_permission",
"DELETE FROM iam_permission",
"'platform:firstleg:customs:view'",
"'platform:firstleg:customs:review'",
"'platform:firstleg:customs:submit'",
"'platform:firstleg:customs:receipt'",
"'platform:firstleg:loading-list:export'"
);
assertThat(sql).doesNotContain(
"hasAnyAuthority",
"merchant:customs",
"platform:firstleg:customs:cases"
);
}
@Test
void reusesOrderPermissionsForMerchantTaxRefundDocuments() throws Exception {
String sql = sql();
assertThat(sql).contains(
"merchant:order:view/edit",
"买单无客户入口"
);
assertThat(sql).doesNotContain(
"'merchant:order:view'",
"'merchant:order:edit'",
"'MERCHANT'"
);
}
private String sql() throws Exception {
return new ClassPathResource("db/phase3-container-customs-permissions.sql")
.getContentAsString(StandardCharsets.UTF_8);
}
}
@@ -78,6 +78,7 @@ public class OrderInboundOutboxPublisher {
|| command.inboundCode() == null || command.inboundCode().isBlank() || command.inboundCode() == null || command.inboundCode().isBlank()
|| command.customerName() == null || command.customerName().isBlank() || command.customerName() == null || command.customerName().isBlank()
|| command.warehouseId() == null || command.warehouseId() <= 0 || command.warehouseId() == null || command.warehouseId() <= 0
|| !("BUY_DECLARATION".equals(command.taxMode()) || "TAX_REFUND".equals(command.taxMode()))
|| command.totalPackageCount() == null || command.totalPackageCount() < 1; || command.totalPackageCount() == null || command.totalPackageCount() < 1;
} }
@@ -42,6 +42,38 @@ class OrderInboundOutboxPublisherTest {
verify(fixture.repository, never()).markOutboxFailed(any(), anyLong()); verify(fixture.repository, never()).markOutboxFailed(any(), anyLong());
} }
@Test
void completeApprovedEventKeepsFrozenTaxRefundMode() {
Fixture fixture = fixture(event("FIRST_LEG_ORDER_APPROVED", 11L, 21L,
completePayload(11L, 21L, "TAX_REFUND")));
when(fixture.client.create(eq("internal-token"), any())).thenReturn(ApiResponse.success(99L));
fixture.publisher.publishApprovedOrders();
ArgumentCaptor<FirstlegInboundClient.OrderApprovedInboundCommand> command =
ArgumentCaptor.forClass(FirstlegInboundClient.OrderApprovedInboundCommand.class);
verify(fixture.client).create(eq("internal-token"), command.capture());
org.assertj.core.api.Assertions.assertThat(command.getValue().taxMode()).isEqualTo("TAX_REFUND");
verify(fixture.repository, never()).findOwned(anyLong(), anyLong());
verify(fixture.repository).markOutboxPublished(eq(11L), eq(41L), any(LocalDateTime.class));
}
@Test
void missingTaxModeIsRebuiltFromAuthoritativeOrderSnapshot() {
Fixture fixture = fixture(event("FIRST_LEG_ORDER_APPROVED", 11L, 21L,
completePayload(11L, 21L, null)), "TAX_REFUND");
when(fixture.client.create(eq("internal-token"), any())).thenReturn(ApiResponse.success(99L));
fixture.publisher.publishApprovedOrders();
ArgumentCaptor<FirstlegInboundClient.OrderApprovedInboundCommand> command =
ArgumentCaptor.forClass(FirstlegInboundClient.OrderApprovedInboundCommand.class);
verify(fixture.client).create(eq("internal-token"), command.capture());
org.assertj.core.api.Assertions.assertThat(command.getValue().taxMode()).isEqualTo("TAX_REFUND");
verify(fixture.repository).findOwned(11L, 21L);
verify(fixture.repository).markOutboxPublished(eq(11L), eq(41L), any(LocalDateTime.class));
}
@Test @Test
void mismatchedCustomerIsPermanentlyFailedBeforeDelivery() { void mismatchedCustomerIsPermanentlyFailedBeforeDelivery() {
Fixture fixture = fixture(event("FIRST_LEG_ORDER_APPROVED", 11L, 21L, payload(12L, 21L))); Fixture fixture = fixture(event("FIRST_LEG_ORDER_APPROVED", 11L, 21L, payload(12L, 21L)));
@@ -98,6 +130,10 @@ class OrderInboundOutboxPublisherTest {
} }
private Fixture fixture(OutboxEventEntity event) { private Fixture fixture(OutboxEventEntity event) {
return fixture(event, "BUY_DECLARATION");
}
private Fixture fixture(OutboxEventEntity event, String authoritativeTaxMode) {
OrderRepository repository = mock(OrderRepository.class); OrderRepository repository = mock(OrderRepository.class);
FirstlegInboundClient client = mock(FirstlegInboundClient.class); FirstlegInboundClient client = mock(FirstlegInboundClient.class);
when(repository.pendingInboundEvents(50)).thenReturn(List.of(event)); when(repository.pendingInboundEvents(50)).thenReturn(List.of(event));
@@ -112,7 +148,7 @@ class OrderInboundOutboxPublisherTest {
order.setReceivingWarehouseName("测试仓"); order.setReceivingWarehouseName("测试仓");
order.setDestinationType("OVERSEAS"); order.setDestinationType("OVERSEAS");
order.setTransportMode("SEA"); order.setTransportMode("SEA");
order.setTaxMode("BUY_DECLARATION"); order.setTaxMode(authoritativeTaxMode);
order.setPricingTaxMode("TAX_EXCLUDED"); order.setPricingTaxMode("TAX_EXCLUDED");
when(repository.findOwned(event.getTenantId(), event.getAggregateId())).thenReturn(order); when(repository.findOwned(event.getTenantId(), event.getAggregateId())).thenReturn(order);
var firstCargo = new com.baiyue.order.entity.OrderCargoEntity(); var firstCargo = new com.baiyue.order.entity.OrderCargoEntity();
@@ -146,6 +182,14 @@ class OrderInboundOutboxPublisherTest {
return "{\"tenantId\":" + tenantId + ",\"orderId\":" + orderId + "}"; return "{\"tenantId\":" + tenantId + ",\"orderId\":" + orderId + "}";
} }
private String completePayload(long tenantId, long orderId, String taxMode) {
String taxModeJson = taxMode == null ? "null" : "\"" + taxMode + "\"";
return "{\"tenantId\":" + tenantId + ",\"orderId\":" + orderId
+ ",\"orderNo\":\"FL-" + orderId + "\",\"inboundCode\":\"RK-" + orderId
+ "\",\"customerName\":\"测试客户\",\"warehouseId\":31,\"taxMode\":" + taxModeJson
+ ",\"totalPackageCount\":4}";
}
private record Fixture(OrderInboundOutboxPublisher publisher, OrderRepository repository, private record Fixture(OrderInboundOutboxPublisher publisher, OrderRepository repository,
FirstlegInboundClient client) { FirstlegInboundClient client) {
} }
+1
View File
@@ -62,6 +62,7 @@ export async function run(context) {
| 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` | | 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` |
| 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` | | 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` |
| 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、四类货柜资料、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-container-documents.mjs`、`phase3-customs.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-container-documents-contract.test.mjs`、`phase3-phase4-departure-pickup-chain-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | | 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、四类货柜资料、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-container-documents.mjs`、`phase3-customs.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-container-documents-contract.test.mjs`、`phase3-phase4-departure-pickup-chain-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` |
| 头程整柜报关(待最终HTTP契约对齐) | `phase3-container-customs.mjs`(全买单、纯退税、混合三柜;最终 Controller/OpenAPI 未齐时 fail-closed,不进入默认入口) | `container-customs-acceptance-contract.test.mjs`、`container-customs-acceptance-matrix.mjs` |
| 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` | | 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` |
| 人工计划分柜准备与只读核验 | `manual-order-splitting-fixture.mjs`、`manual-order-splitting-verify.mjs`(可选,不进入默认全量) | `manual-order-splitting-contract.test.mjs` | | 人工计划分柜准备与只读核验 | `manual-order-splitting-fixture.mjs`、`manual-order-splitting-verify.mjs`(可选,不进入默认全量) | `manual-order-splitting-contract.test.mjs` |
| 人工推送预装前置与推送后只读核验 | `manual-preload-push-fixture.mjs`、`manual-preload-push-verify.mjs`(可选,不进入默认全量) | `manual-preload-push-contract.test.mjs` | | 人工推送预装前置与推送后只读核验 | `manual-preload-push-fixture.mjs`、`manual-preload-push-verify.mjs`(可选,不进入默认全量) | `manual-preload-push-contract.test.mjs` |
@@ -0,0 +1,68 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import {
CONTAINER_CUSTOMS_FIXTURE_KINDS,
CONTAINER_CUSTOMS_MATRIX,
EXACT_50_MB,
READ_ONLY_DB_EVIDENCE,
REQUIRED_ROUTE_OPERATIONS,
validateContainerCustomsFixtures,
validateRouteContract,
xlsxMagic,
zeroDrift,
} from './container-customs-acceptance-matrix.mjs'
const source = readFileSync(new URL('./phase3-container-customs.mjs', import.meta.url), 'utf8')
test('matrix covers three isolated LOADED container kinds', () => {
assert.deepEqual(CONTAINER_CUSTOMS_FIXTURE_KINDS,
['ALL_BUY_DECLARATION', 'ALL_TAX_REFUND', 'MIXED'])
const fixtures = { marker: 'M', containers: CONTAINER_CUSTOMS_FIXTURE_KINDS.map((kind, index) => ({
marker: 'M', kind, status: 'LOADED', loadingPlanId: String(100 + index),
containerBookingId: String(200 + index),
inbounds: [{ inboundId: String(300 + index), customerId: String(400 + index),
taxMode: kind === 'ALL_BUY_DECLARATION' ? 'BUY_DECLARATION' : 'TAX_REFUND' }],
})) }
assert.equal(validateContainerCustomsFixtures(fixtures, 'M'), true)
assert.equal(validateContainerCustomsFixtures({ ...fixtures, marker: 'OLD' }, 'M'), false)
})
test('coverage matrix freezes every requested product boundary', () => {
const text = CONTAINER_CUSTOMS_MATRIX.map((item) => `${item.scenario}:${item.expected}`).join('\n')
for (const token of ['买单XLSX', '多对多', '覆盖', '改关联', '删除', '冻结', '逐份完成文件',
'最后一份', '混合', '缺资料', '开始后锁定', '客户隔离', '买单隐藏', '50MiB', '任意格式',
'幂等', '并发', 'A0409', '零漂移', '自动离仓', 'Outbox', '历史', 'Admin与Client权限']) {
assert.match(text, new RegExp(token))
}
})
test('route adapter fails closed until final backend and frontend contracts align', () => {
assert.equal(validateRouteContract({}), false)
const routes = { version: 'final', ...Object.fromEntries(REQUIRED_ROUTE_OPERATIONS.map((name) => [name, () => null])) }
assert.equal(validateRouteContract(routes), true)
assert.match(source, /后端Controller\/OpenAPI与Admin\/Client最终路径尚未对齐,禁止猜测URL发请求/)
assert.doesNotMatch(source, /https?:\/\//)
})
test('HTTP scenario contains file, state, idempotency, concurrency and zero-drift probes', () => {
for (const token of ['EXACT_50_MB + 1', '.customblob', 'application/octet-stream',
'缺退税资料禁止开始且零漂移', '开始后资料范围锁定A0409零漂移',
"['上传'", "['覆盖'", "['改关联'", "['删除'", '开始后${label}锁定A0409零漂移',
'最后一份完成文件前禁止整柜完成', '最后一份完成文件后纯退税柜完成',
'自动离仓失败不回滚报关完成', '自动或待条件重试后整柜单次离仓',
'同幂等键异载荷A0409', 'Promise.all', 'requestId=', 'traceId=', 'xlsxMagic']) {
assert.match(source, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
}
assert.equal(EXACT_50_MB, 52_428_800)
assert.equal(zeroDrift({ a: 1 }, { a: 1 }), true)
assert.equal(xlsxMagic(Uint8Array.from([0x50, 0x4b, 0x03, 0x04])), true)
})
test('database evidence list covers atomicity, history, dispatch and outbox without embedding SQL writes', () => {
for (const table of ['fl_customs_container', 'fl_customs_customer_document_inbound',
'fl_customs_completion_document', 'fl_customs_command', 'fl_customs_container_history',
'fl_outbound_dispatch', 'fl_event_outbox']) assert.ok(READ_ONLY_DB_EVIDENCE.includes(table), table)
assert.doesNotMatch(source, /\b(?:INSERT|UPDATE|DELETE)\s+(?:INTO|FROM|[a-z_])/i)
})
@@ -0,0 +1,97 @@
export const CONTAINER_CUSTOMS_MODULE = 'phase3-container-customs'
export const EXACT_50_MB = 50 * 1024 * 1024
export const CONTAINER_CUSTOMS_FIXTURE_KINDS = Object.freeze([
'ALL_BUY_DECLARATION',
'ALL_TAX_REFUND',
'MIXED',
])
export const REQUIRED_ROUTE_OPERATIONS = Object.freeze([
'platformDetail',
'merchantDetail',
'buyDeclarationXlsx',
'merchantUploadDocument',
'merchantReplaceDocument',
'merchantUpdateScope',
'merchantDeleteDocument',
'platformStart',
'platformUploadCompletion',
'platformComplete',
'platformDownload',
'merchantDownload',
'outboundState',
'satisfyOutboundPrerequisites',
])
export const CONTAINER_CUSTOMS_MATRIX = Object.freeze([
{ priority: 'P0', fixture: 'ALL_BUY_DECLARATION', scenario: 'LOADED详情与买单XLSX', expected: 'XLSX只含柜内买单实装行,下载窗口可用' },
{ priority: 'P0', fixture: 'ALL_BUY_DECLARATION', scenario: '开始与完成', expected: 'READY_TO_START→DECLARING→COMPLETED,重放返回同一结果' },
{ priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '客户多对多资料', expected: '上传、覆盖、改关联、删除均保留版本与客户范围' },
{ priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '冻结与逐份完成文件', expected: '开始冻结来源文件和关联;最后一份完成文件后才允许整柜完成' },
{ priority: 'P0', fixture: 'MIXED', scenario: '买单与退税混合', expected: '买单行进入XLSX,退税票逐票被资料覆盖,整柜统一开始/完成' },
{ priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '缺资料禁止开始', expected: 'A0409且聚合、文件、关联、历史零漂移' },
{ priority: 'P0', fixture: 'MIXED', scenario: '自动离仓失败恢复', expected: '报关COMPLETED不回滚;待条件满足后后台重试并单次离仓' },
{ priority: 'P1', fixture: 'ALL_TAX_REFUND', scenario: '开始后锁定', expected: '上传、覆盖、改关联、删除全部A0409零漂移' },
{ priority: 'P1', fixture: 'ALL_TAX_REFUND', scenario: '完成文件继承范围', expected: '完成文件只继承冻结关联,不接收客户端范围' },
{ priority: 'P1', fixture: 'MIXED', scenario: '客户隔离与买单隐藏', expected: '客户只见本企业退税资料;买单XLSX及其他客户文件不可见' },
{ priority: 'P1', fixture: 'ALL_TAX_REFUND', scenario: '文件边界', expected: '任意格式允许;50MiB成功;50MiB+1拒绝且零写入' },
{ priority: 'P1', fixture: 'MIXED', scenario: '幂等与并发', expected: '同键同载荷同结果;异载荷/并发冲突A0409且零漂移' },
{ priority: 'P1', fixture: 'MIXED', scenario: '历史与Outbox', expected: '每个真实动作单条历史;自动离仓意图/结果可追踪且无重复' },
{ priority: 'P1', fixture: 'MIXED', scenario: 'Admin与Client权限', expected: '列表/详情/写权限分离,客户下载仅限自身可见文件' },
])
export function validateContainerCustomsFixtures(value, marker) {
if (value?.marker !== marker || !Array.isArray(value?.containers)) return false
const byKind = new Map(value.containers.map((item) => [item?.kind, item]))
return CONTAINER_CUSTOMS_FIXTURE_KINDS.every((kind) => {
const fixture = byKind.get(kind)
return fixture?.marker === marker && fixture?.status === 'LOADED'
&& /^\d+$/.test(String(fixture?.loadingPlanId ?? ''))
&& /^\d+$/.test(String(fixture?.containerBookingId ?? ''))
&& Array.isArray(fixture?.inbounds) && fixture.inbounds.length > 0
&& fixture.inbounds.every((inbound) => /^\d+$/.test(String(inbound?.inboundId ?? ''))
&& /^\d+$/.test(String(inbound?.customerId ?? ''))
&& ['BUY_DECLARATION', 'TAX_REFUND'].includes(inbound?.taxMode))
})
}
export function validateRouteContract(routes) {
return routes?.version != null
&& REQUIRED_ROUTE_OPERATIONS.every((name) => typeof routes?.[name] === 'function')
}
export function stableSnapshot(value) {
const normalize = (current) => {
if (Array.isArray(current)) return current.map(normalize)
if (current != null && typeof current === 'object') {
return Object.fromEntries(Object.keys(current).sort().map((key) => [key, normalize(current[key])]))
}
return current
}
return JSON.stringify(normalize(value))
}
export function zeroDrift(before, after) {
return stableSnapshot(before) === stableSnapshot(after)
}
export function xlsxMagic(bytes) {
return bytes instanceof Uint8Array && bytes.length >= 4
&& bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04
}
export const READ_ONLY_DB_EVIDENCE = Object.freeze([
'fl_customs_container',
'fl_customs_customer_document',
'fl_customs_customer_document_inbound',
'fl_customs_customer_document_version',
'fl_customs_frozen_document_inbound',
'fl_customs_completion_document',
'fl_customs_completion_document_version',
'fl_customs_command',
'fl_customs_container_history',
'fl_outbound_dispatch',
'fl_outbound_dispatch_history',
'fl_event_outbox',
])
@@ -0,0 +1,255 @@
import {
CONTAINER_CUSTOMS_FIXTURE_KINDS,
CONTAINER_CUSTOMS_MODULE,
EXACT_50_MB,
validateContainerCustomsFixtures,
validateRouteContract,
xlsxMagic,
zeroDrift,
} from './container-customs-acceptance-matrix.mjs'
const MODULE = CONTAINER_CUSTOMS_MODULE
const OPTIONS = { scenario: MODULE, deploymentBlocker: true }
const id = (value) => value == null ? '' : String(value)
const code = (response) => String(response?.body?.code ?? '')
const success = (response) => response?.http === 200 && code(response) === '200'
const a0409 = (response) => response?.http === 200 && code(response) === 'A0409'
const evidence = (response, extra = '') => `${extra}${extra ? '; ' : ''}HTTP=${response?.http ?? 'missing'}, code=${code(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
const key = (context, value) => `${context.marker}-container-customs-${value}`.slice(0, 128)
function blocker(context, name, reason) {
context.addCoverageGap(MODULE, MODULE, name, reason, 'contract-or-fixture', { deploymentBlocker: true })
context.addStep(MODULE, name, null, false, reason, OPTIONS)
throw new Error(`blocking coverage gap: ${name}: ${reason}`)
}
function resolvePreparation(context) {
const fixtures = context.createdResources?.phase3ContainerCustomsFixtures
const routes = context.context?.phase3ContainerCustomsRoutes
if (!validateContainerCustomsFixtures(fixtures, context.marker)) {
blocker(context, '三类LOADED整柜fixture', '缺少本轮全买单、纯退税、混合三柜正式链路数据')
}
if (!validateRouteContract(routes)) {
blocker(context, '整柜报关最终HTTP契约', '后端Controller/OpenAPI与Admin/Client最终路径尚未对齐,禁止猜测URL发请求')
}
return { fixtures, routes }
}
async function platformDetail(context, routes, fixture) {
return routes.platformDetail(context, { loadingPlanId: id(fixture.loadingPlanId) })
}
async function assertZeroDrift(context, name, routes, fixture, before, response, acceptedCodes = ['A0409']) {
const after = await platformDetail(context, routes, fixture)
context.assert(MODULE, name, response,
response?.http === 200 && acceptedCodes.includes(code(response))
&& success(after) && zeroDrift(before.body?.data, after.body?.data),
evidence(response, `afterRequestId=${after.requestId || 'missing'}, afterTraceId=${after.traceId || 'missing'}`), OPTIONS)
}
async function runAllBuy(context, routes, fixture) {
const detail = await platformDetail(context, routes, fixture)
context.assert(MODULE, '全买单柜LOADED详情', detail, success(detail)
&& detail.body?.data?.inbounds?.every?.((item) => item.taxMode === 'BUY_DECLARATION'), evidence(detail), OPTIONS)
const xlsx = await routes.buyDeclarationXlsx(context, { loadingPlanId: id(fixture.loadingPlanId) })
context.assert(MODULE, '买单XLSX下载窗口返回真实工作簿', xlsx,
xlsx.http === 200 && xlsxMagic(xlsx.bytes),
`HTTP=${xlsx.http}, bytes=${xlsx.bytes?.length ?? 0}, contentType=${xlsx.contentType || 'missing'}`, OPTIONS)
const startBody = { expectedVersion: Number(detail.body?.data?.version), remark: context.marker }
const started = await routes.platformStart(context, fixture, startBody, key(context, 'all-buy-start'))
context.assert(MODULE, '全买单柜开始报关', started,
success(started) && started.body?.data?.status === 'DECLARING', evidence(started), OPTIONS)
const completeBody = { expectedVersion: Number(started.body?.data?.version), remark: context.marker }
const completed = await routes.platformComplete(context, fixture, completeBody, key(context, 'all-buy-complete'))
context.assert(MODULE, '全买单柜完成并触发自动离仓意图', completed,
success(completed) && completed.body?.data?.status === 'COMPLETED', evidence(completed), OPTIONS)
const replay = await routes.platformComplete(context, fixture, completeBody, key(context, 'all-buy-complete'))
context.assert(MODULE, '全买单柜完成同键重放', replay,
success(replay) && id(replay.body?.data?.aggregateId) === id(completed.body?.data?.aggregateId), evidence(replay), OPTIONS)
let outbound = await routes.outboundState(context, fixture)
if (fixture.autoDispatchMode === 'WAIT_CONDITION') {
context.assert(MODULE, '自动离仓失败不回滚报关完成', outbound,
success(outbound) && outbound.body?.data?.customsStatus === 'COMPLETED'
&& outbound.body?.data?.departed !== true && Boolean(outbound.body?.data?.retryable),
evidence(outbound), OPTIONS)
await routes.satisfyOutboundPrerequisites(context, fixture)
for (let attempt = 0; attempt < 12 && outbound.body?.data?.departed !== true; attempt += 1) {
if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 1_000))
outbound = await routes.outboundState(context, fixture)
}
}
context.assert(MODULE, '自动或待条件重试后整柜单次离仓', outbound,
success(outbound) && outbound.body?.data?.departed === true
&& Number(outbound.body?.data?.departureHistoryCount) === 1,
evidence(outbound), OPTIONS)
}
async function uploadBoundary(context, routes, fixture, customer, size, suffix) {
const content = new Uint8Array(size)
content[0] = 0x43; content[content.length - 1] = 0x58
return routes.merchantUploadDocument(context, fixture, customer, {
fileName: `${context.marker}-${suffix}.customblob`, contentType: 'application/octet-stream', content,
inboundIds: customer.inboundIds.map(id), expectedVersion: customer.expectedVersion,
}, key(context, suffix))
}
async function runRefund(context, routes, fixture) {
const customers = fixture.customers || []
if (customers.length < 2) blocker(context, '纯退税多客户fixture', '至少需要两个客户和可交叉验证的多对多入仓范围')
const first = customers[0]
const exact = await uploadBoundary(context, routes, fixture, first, EXACT_50_MB, 'exact-50m')
context.assert(MODULE, '任意格式50MiB边界成功', exact, success(exact), evidence(exact), OPTIONS)
const tooLargeBefore = await platformDetail(context, routes, fixture)
const tooLarge = await uploadBoundary(context, routes, fixture, first, EXACT_50_MB + 1, 'over-50m')
await assertZeroDrift(context, '50MiB+1拒绝且零漂移', routes, fixture, tooLargeBefore, tooLarge,
['A0400', 'A0409', 'A0422'])
const crossCustomer = await routes.merchantDetail(context, fixture, customers[1], first.customerToken)
context.assert(MODULE, '客户隔离阻止查看其他客户资料', crossCustomer,
!success(crossCustomer), evidence(crossCustomer), OPTIONS)
const hiddenBuy = await routes.buyDeclarationXlsx(context, { loadingPlanId: id(fixture.loadingPlanId), token: first.customerToken })
context.assert(MODULE, '客户侧隐藏买单XLSX', hiddenBuy, hiddenBuy.http !== 200 || !xlsxMagic(hiddenBuy.bytes),
`HTTP=${hiddenBuy.http}, bytes=${hiddenBuy.bytes?.length ?? 0}`, OPTIONS)
for (const customer of customers) {
const document = await routes.merchantUploadDocument(context, fixture, customer, customer.document,
key(context, `upload-${customer.customerId}`))
context.assert(MODULE, `客户${customer.customerId}上传多对多资料`, document, success(document), evidence(document), OPTIONS)
customer.documentId = id(document.body?.data?.documentId)
customer.documentVersion = Number(document.body?.data?.source?.version)
}
const mutable = customers[0]
const replaced = await routes.merchantReplaceDocument(context, fixture, mutable, {
...mutable.replacement, documentId: mutable.documentId, expectedVersion: mutable.documentVersion,
}, key(context, 'replace'))
context.assert(MODULE, '客户覆盖资料生成新版本', replaced, success(replaced), evidence(replaced), OPTIONS)
const scoped = await routes.merchantUpdateScope(context, fixture, mutable, {
documentId: mutable.documentId, inboundIds: mutable.updatedInboundIds.map(id),
expectedVersion: Number(replaced.body?.data?.source?.version),
}, key(context, 'scope'))
context.assert(MODULE, '客户修改多对多关联', scoped, success(scoped), evidence(scoped), OPTIONS)
const deleted = await routes.merchantDeleteDocument(context, fixture, mutable, {
documentId: mutable.documentId, expectedVersion: Number(scoped.body?.data?.source?.version),
}, key(context, 'delete'))
context.assert(MODULE, '客户删除未冻结资料', deleted, success(deleted), evidence(deleted), OPTIONS)
const beforeMissingStart = await platformDetail(context, routes, fixture)
const missingStart = await routes.platformStart(context, fixture, {
expectedVersion: Number(beforeMissingStart.body?.data?.version), remark: 'missing-document-negative',
}, key(context, 'missing-start'))
await assertZeroDrift(context, '缺退税资料禁止开始且零漂移', routes, fixture, beforeMissingStart, missingStart)
const restored = await routes.merchantUploadDocument(context, fixture, mutable, mutable.document,
key(context, `restore-${mutable.customerId}`))
context.assert(MODULE, '删除后重新上传恢复完整退税覆盖', restored, success(restored), evidence(restored), OPTIONS)
mutable.documentId = id(restored.body?.data?.documentId)
mutable.documentVersion = Number(restored.body?.data?.source?.version)
const ready = await platformDetail(context, routes, fixture)
const started = await routes.platformStart(context, fixture, {
expectedVersion: Number(ready.body?.data?.version), remark: context.marker,
}, key(context, 'refund-start'))
context.assert(MODULE, '纯退税柜资料齐全后开始并冻结', started,
success(started) && started.body?.data?.status === 'DECLARING'
&& started.body?.data?.documents?.every?.((item) => item.frozen === true),
evidence(started), OPTIONS)
const frozenSnapshot = started.body?.data
const lockedOperations = [
['上传', () => routes.merchantUploadDocument(context, fixture, mutable, {
...mutable.document, fileName: `${context.marker}-locked.customblob`, content: Uint8Array.from([1]),
}, key(context, 'locked-upload'))],
['覆盖', () => routes.merchantReplaceDocument(context, fixture, mutable, {
...mutable.replacement, documentId: mutable.documentId, expectedVersion: mutable.documentVersion,
}, key(context, 'locked-replace'))],
['改关联', () => routes.merchantUpdateScope(context, fixture, mutable, {
documentId: mutable.documentId, inboundIds: mutable.inboundIds.map(id), expectedVersion: mutable.documentVersion,
}, key(context, 'locked-scope'))],
['删除', () => routes.merchantDeleteDocument(context, fixture, mutable, {
documentId: mutable.documentId, expectedVersion: mutable.documentVersion,
}, key(context, 'locked-delete'))],
]
for (const [label, request] of lockedOperations) {
const response = await request()
await assertZeroDrift(context, `开始后${label}锁定A0409零漂移`, routes, fixture,
{ body: { data: frozenSnapshot } }, response)
}
const documents = frozenSnapshot?.documents || []
for (const [index, document] of documents.entries()) {
const completion = await routes.platformUploadCompletion(context, fixture, {
documentId: id(document.documentId), expectedVersion: Number(document.source?.version),
fileName: `${context.marker}-completion-${index + 1}.done`,
contentType: 'application/x-customs-completion', content: Uint8Array.from([0x43, index + 1]),
}, key(context, `completion-${index + 1}`))
context.assert(MODULE, `逐份上传完成文件${index + 1}`, completion,
success(completion)
&& zeroDrift(completion.body?.data?.inboundIds, document.inboundIds),
evidence(completion, `sourceDocumentId=${id(document.documentId)}`), OPTIONS)
if (index + 1 < documents.length) {
const beforeEarlyComplete = await platformDetail(context, routes, fixture)
const early = await routes.platformComplete(context, fixture, {
expectedVersion: Number(beforeEarlyComplete.body?.data?.version), remark: 'not-last-completion',
}, key(context, `early-complete-${index + 1}`))
await assertZeroDrift(context, '最后一份完成文件前禁止整柜完成', routes, fixture, beforeEarlyComplete, early)
}
}
const completedReady = await platformDetail(context, routes, fixture)
const completed = await routes.platformComplete(context, fixture, {
expectedVersion: Number(completedReady.body?.data?.version), remark: context.marker,
}, key(context, 'refund-complete'))
context.assert(MODULE, '最后一份完成文件后纯退税柜完成', completed,
success(completed) && completed.body?.data?.status === 'COMPLETED', evidence(completed), OPTIONS)
const firstDocument = documents[0]
if (firstDocument) {
const platformDownload = await routes.platformDownload(context, fixture, {
documentId: id(firstDocument.documentId), completion: true,
})
context.assert(MODULE, 'Admin下载完成文件窗口', platformDownload,
platformDownload.http === 200 && platformDownload.bytes?.length > 0,
`HTTP=${platformDownload.http}, bytes=${platformDownload.bytes?.length ?? 0}`, OPTIONS)
const merchantDownload = await routes.merchantDownload(context, fixture, mutable, {
documentId: id(firstDocument.documentId), completion: true,
})
context.assert(MODULE, 'Client仅下载本客户完成文件窗口', merchantDownload,
merchantDownload.http === 200 && merchantDownload.bytes?.length > 0,
`HTTP=${merchantDownload.http}, bytes=${merchantDownload.bytes?.length ?? 0}`, OPTIONS)
}
}
async function runMixedConcurrency(context, routes, fixture) {
const before = await platformDetail(context, routes, fixture)
const body = { expectedVersion: Number(before.body?.data?.version), remark: context.marker }
const concurrentKey = key(context, 'mixed-start-concurrent')
const responses = await Promise.all([
routes.platformStart(context, fixture, body, concurrentKey),
routes.platformStart(context, fixture, body, concurrentKey),
])
context.assert(MODULE, '开始并发双击收敛同一结果', responses[1],
responses.every(success) && zeroDrift(responses[0].body?.data, responses[1].body?.data),
responses.map((response) => evidence(response)).join('; '), OPTIONS)
const different = await routes.platformStart(context, fixture, { ...body, remark: 'different' }, concurrentKey)
context.assert(MODULE, '同幂等键异载荷A0409', different, a0409(different), evidence(different), OPTIONS)
const frozen = responses[0].body?.data
const customer = fixture.customers?.[0]
if (!customer?.documentId) blocker(context, '混合柜冻结文件fixture', '混合柜缺少已上传退税客户资料')
const locked = await routes.merchantUpdateScope(context, fixture, customer, {
documentId: customer.documentId, inboundIds: customer.inboundIds.map(id), expectedVersion: customer.documentVersion,
}, key(context, 'locked-scope'))
await assertZeroDrift(context, '开始后资料范围锁定A0409零漂移', routes, fixture, { body: { data: frozen } }, locked)
}
export async function run(context) {
const { fixtures, routes } = resolvePreparation(context)
const byKind = new Map(fixtures.containers.map((item) => [item.kind, item]))
await runAllBuy(context, routes, byKind.get(CONTAINER_CUSTOMS_FIXTURE_KINDS[0]))
await runRefund(context, routes, byKind.get(CONTAINER_CUSTOMS_FIXTURE_KINDS[1]))
await runMixedConcurrency(context, routes, byKind.get(CONTAINER_CUSTOMS_FIXTURE_KINDS[2]))
}