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
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:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
+10
View File
@@ -18,6 +18,16 @@ baiyue:
max-attempts: 3
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:
provider-service: finance-service
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 (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花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_name VARCHAR(255) NOT NULL COMMENT '文件名称快照',
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)
REFERENCES fl_container_booking (id),
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文件关联';
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
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
AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增';
END IF;
END$$
@@ -5922,7 +5922,7 @@ BEGIN
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
AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改';
END IF;
END$$
@@ -9777,173 +9777,25 @@ WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
USE baiyue_firstleg;
-- Phase 3 customs document, supplementation, broker submission and receipt lifecycle.
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_customs_checklist_version (
id BIGINT UNSIGNED NOT NULL 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='报关协同命令幂等记录';
-- Latest container customs permissions; old case/review/receipt actions are removed.
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;
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)
(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)
(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),
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:%'
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');
USE baiyue_firstleg;
-- 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.
USE baiyue_firstleg;
@@ -10140,11 +9992,12 @@ BEGIN
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema='baiyue_firstleg' AND table_name IN (
'fl_outbound_dispatch','fl_outbound_dispatch_history','fl_outbound_dispatch_operation',
'fl_customs_checklist_version','fl_customs_case','fl_customs_document','fl_customs_supplement_request',
'fl_customs_submission_batch','fl_customs_receipt','fl_customs_history','fl_customs_operation',
'fl_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_container_history','fl_customs_command',
'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_loading_pick_operation')) <> 18 THEN
'fl_loading_pick_operation')) <> 19 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期出仓、报关或船期表未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
@@ -10173,8 +10026,8 @@ BEGIN
WHERE table_schema='baiyue_firstleg' AND table_name='fl_outbound_dispatch'
AND index_name='uk_outbound_dispatch_owner' AND non_unique=0) <> 3
OR (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema='baiyue_firstleg' AND table_name='fl_customs_case'
AND index_name='uk_customs_case_scope' AND non_unique=0) <> 4
WHERE table_schema='baiyue_firstleg' AND table_name='fl_customs_container'
AND index_name='uk_customs_container_plan' AND non_unique=0) <> 1
OR (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema='baiyue_firstleg' AND table_name='fl_port_schedule'
AND index_name='uk_port_schedule_source' AND non_unique=0) <> 3 THEN
@@ -10182,9 +10035,9 @@ BEGIN
END IF;
IF (SELECT COUNT(*) FROM information_schema.referential_constraints
WHERE constraint_schema='baiyue_firstleg' AND constraint_name IN (
'fk_outbound_dispatch_inbound','fk_outbound_dispatch_container','fk_customs_case_checklist',
'fk_customs_document_case','fk_customs_supplement_case','fk_customs_batch_case',
'fk_customs_receipt_case','fk_customs_receipt_batch','fk_customs_history_case',
'fk_outbound_dispatch_inbound','fk_outbound_dispatch_container','fk_customs_customer_document_container',
'fk_customs_document_inbound_document','fk_customs_customer_document_version','fk_customs_frozen_document',
'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_operation','fk_preload_loading_inbound','fk_preload_loading_preload',
'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'
AND id BETWEEN 5102160 AND 5102164 AND permission_code LIKE 'platform:firstleg:outbound-dispatch:%') <> 5
OR (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM'
AND id BETWEEN 5102170 AND 5102175 AND permission_code LIKE 'platform:firstleg:customs:%') <> 6
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'
AND id BETWEEN 5102180 AND 5102183 AND permission_code LIKE 'platform:firstleg:port-schedule:%') <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期出仓、报关或船期权限未完整写入';
@@ -10219,7 +10072,7 @@ BEGIN
AND (p.permission_code LIKE 'platform:firstleg:outbound-dispatch:%'
OR p.permission_code LIKE 'platform:firstleg:customs:%'
OR p.permission_code LIKE 'platform:firstleg:port-schedule:%'
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='统一迁移失败:三期出仓、报关或船期角色授权未完整写入';
END IF;
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_customer_charge_rule', '客户头程计费规则版本');
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_fulfillment_task', '头程履约任务表:接收订单事件后形成独立履约任务,管理指派、执行和完成状态');
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_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', '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_name', '文件名称快照');
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_by', '最后修改人ID');
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', 'category', '费用分类');
CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'created_at', '创建时间');
@@ -15821,7 +15558,7 @@ BEGIN
END IF;
END$$
DELIMITER ;
-- 货柜清关与装柜清单报关采用同一份轻量文件事实,不再要求独立报关 case 状态机。
-- 货柜目的港清关与DO/PU采用轻量文件事实;出口报关由customs-v1整柜聚合唯一承载。
USE baiyue_firstleg;
DROP PROCEDURE IF EXISTS phase3_simple_customs_add_column;
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');
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'));
CHECK (document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER'));
DROP TRIGGER IF EXISTS trg_container_document_before_insert;
DELIMITER $$
@@ -15856,7 +15593,7 @@ CREATE TRIGGER trg_container_document_before_insert BEFORE INSERT ON fl_containe
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
IF current_plan_status='COMPLETED' AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜物流文件不可新增';
END IF;
END$$
@@ -15867,7 +15604,7 @@ CREATE TRIGGER trg_container_document_before_update BEFORE UPDATE ON fl_containe
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
IF current_plan_status='COMPLETED' AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜物流文件不可修改';
END IF;
END$$
@@ -16603,6 +16340,77 @@ BEGIN
END$$
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();
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
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/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` |
| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `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` |
| 下游 OCR | `client/WeightOcrClient.java` |
| SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` |
@@ -27,6 +28,7 @@
- 商品图片平台读取必须同时校验平台身份、`platform:order:list`、客户范围、文件业务类型与正整数 ID;不得提供匿名 URL、跨客户读取或把内部存储路径返回页面。
- 公共导出只接受 `ExportDefinitionRegistry` 明确登记的 `exportType`,由 File 调用所属业务服务的内部 Provider 分批取数并生成文件;业务页面不得直连 Provider。新增或修改导出类型时同步检查业务服务 Provider、File Feign 客户端/响应上限、权限、Admin 下载中心标签与重试映射及契约测试。
- 生产环境显式配置 `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 org.springframework.beans.factory.annotation.Value;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -65,11 +66,17 @@ public class InternalBusinessFileController {
check(token);
checkTenantId(tenantId);
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()
.cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(value.contentType()))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + name + "\"")
.contentType(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,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 jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
@@ -21,7 +22,6 @@ import java.util.UUID;
@Component
public class BusinessFileStorage {
private static final long MAX_SIZE = 10L * 1024 * 1024;
private static final Map<String, String> TYPES = Map.of(
"text/plain", ".txt", "application/pdf", ".pdf",
"image/jpeg", ".jpg", "image/png", ".png");
@@ -35,13 +35,13 @@ public class BusinessFileStorage {
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.getSize() > MAX_SIZE) throw bad("附件不能超过10MB");
String type = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT);
String extension = TYPES.get(type);
if (extension == null) throw bad("附件仅支持TXT、PDF、JPG、PNG");
verifyContent(file, type);
if (file.getSize() > maxSize) throw bad("附件不能超过" + maxSize / 1024 / 1024 + "MB");
String type = safeContentType(file.getContentType());
String extension = arbitraryFormat ? "" : TYPES.get(type);
if (!arbitraryFormat && extension == null) throw bad("附件仅支持TXT、PDF、JPG、PNG");
if (!arbitraryFormat) verifyContent(file, type);
String name = safeName(file);
String objectKey = "business-files/" + tenantId + "/" + businessId + "/" + UUID.randomUUID() + extension;
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) {
Path target = root.resolve(objectKey).normalize();
if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界");
@@ -91,7 +100,12 @@ public class BusinessFileStorage {
private static String safeName(MultipartFile file) {
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 {
@@ -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, "缺少该导出类型的操作权限");
}
validateParameters(request.parameters());
validateTypeParameters(type, request.parameters());
String parametersJson = parametersJson(request.parameters());
long id = IdWorker.getId();
LocalDateTime now = LocalDateTime.now();
@@ -176,6 +177,21 @@ public class ExportTaskService {
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) {
return new ExportTaskResponse(String.valueOf(task.getId()), task.getTaskNo(), task.getExportType(),
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) {
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);
} else if (!schemaHash.equals(batch.schemaHash())) {
throw ExportFailure.permanent("Provider 导出列定义在执行期间发生变化");
@@ -10,22 +10,8 @@ import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
import java.util.Set;
@Service
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 BusinessFileStorage storage;
@@ -34,9 +20,10 @@ public class InternalBusinessFileService {
if (tenantId < 0 || operatorId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "归属和操作人必须有效");
}
validateBusinessType(businessType);
BusinessFileCategory category = BusinessFileCategory.require(businessType);
category.validateOwner(tenantId);
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();
value.setTenantId(tenantId);
value.setBusinessType(businessType);
@@ -89,16 +76,11 @@ public class InternalBusinessFileService {
if (tenantId < 0 || fileId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "归属和文件ID必须有效");
}
validateBusinessType(businessType);
BusinessFileCategory category = BusinessFileCategory.require(businessType);
category.validateOwner(tenantId);
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) {
final long value;
try {
+11 -2
View File
@@ -40,8 +40,8 @@ spring:
read-timeout: 15000
servlet:
multipart:
max-file-size: 10MB
max-request-size: 11MB
max-file-size: 50MB
max-request-size: 51MB
rocketmq:
name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876}
producer:
@@ -101,6 +101,15 @@ baiyue:
max-rows: 200000
max-attempts: 3
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:
provider-service: finance-service
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);
}
@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() {
var file = new MockMultipartFile("file", "x.txt", "text/plain", "x".getBytes());
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());
}
@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) {
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
static class TestConfig {
@Bean ObjectMapper objectMapper() { return new ObjectMapper(); }
@@ -21,7 +21,7 @@ class InternalBusinessFileServiceTest {
@Test void customsDocumentUploadPersistsExactBusinessOwnership() {
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"));
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
((FileObjectEntity) invocation.getArgument(0)).setId(71L); return 1;
@@ -37,10 +37,10 @@ class InternalBusinessFileServiceTest {
@Test void platformContainerAndLoadingDocumentsSupportZeroTenantScope() {
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",
"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"));
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
((FileObjectEntity) invocation.getArgument(0)).setId(81L); return 1;
@@ -64,9 +64,9 @@ class InternalBusinessFileServiceTest {
InternalBusinessFileService.Metadata::businessType,
InternalBusinessFileService.Metadata::businessId)
.containsExactly(11L, "OVERSEAS_UNLOAD_PHOTO", "51");
verify(storage, times(3)).storeLoading(0L, 41L, file);
verify(storage).storeLoading(0L, 51L, file);
verify(storage).storeLoading(11L, 51L, file);
verify(storage, times(3)).store(0L, 41L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false);
verify(storage).store(0L, 51L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false);
verify(storage).store(11L, 51L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false);
}
@Test void rejectsNegativeTenantAndInvalidOwnershipInputs() {
@@ -82,7 +82,7 @@ class InternalBusinessFileServiceTest {
@Test void overseasRelabelPhotoPersistsExactTaskOwnership() {
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"));
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
((FileObjectEntity) invocation.getArgument(0)).setId(91L); return 1;
@@ -96,6 +96,36 @@ class InternalBusinessFileServiceTest {
.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() {
assertThatThrownBy(() -> service.upload(10001L, "ARBITRARY", "41", 7L,
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` |
| 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` |
| 报关协同 | `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` |
| 跨服务客户端 | `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 返回服务端名称快照 |
@@ -59,7 +60,8 @@
- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
- 订舱列表 `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,外部失败不得推进业务节点,不得在三期另建查询任务。
- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,国内出口报关资料必须存在且已完成,拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。
- 国内出口报关在装柜完成后启用,不再阻塞 `LOADED`。整柜 `fl_customs_container.status=COMPLETED` 才通过离仓报关门禁;完成后以系统身份、确定性幂等键立即尝试确认国内离仓,其他门禁未齐时由受控定时补偿重试,禁止部分入仓成功或重复创建离仓/船司查询事实。
- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。
- 手工船节点的预计时间只追加事实,不推进 `latestStandardNode`;携带 `actualAt` 时标准节点序号必须严格大于最新实际节点,重复、相同或回退节点返回冲突。已审核实际时间修正只能更新同一事实的时间/证据,不得制造第二条同节点实际事实或绕过单调推进。
- 海外卸柜完成事件只接受 `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 复制财务状态机。
@@ -12,23 +12,13 @@ import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadConfirmationResponse;
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.LoadingListService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
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.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.core.annotation.AuthenticationPrincipal;
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.RequestParam;
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 = "预装单向推进和已确认装柜清单只读查询")
@@ -120,67 +106,4 @@ public class PlatformLoadingListController {
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;
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.gate.OutboundDispatchGateContributor;
import jakarta.annotation.Resource;
@@ -8,7 +8,7 @@ import org.springframework.stereotype.Component;
@Component
public class CustomsDispatchGateContributor implements OutboundDispatchGateContributor {
@Resource private CustomsRepository repository;
@Resource private ContainerCustomsMapper mapper;
@Override public String code() { return "CUSTOMS_CLEARANCE"; }
@@ -17,8 +17,9 @@ public class CustomsDispatchGateContributor implements OutboundDispatchGateContr
if (context.tenantId() <= 0 || context.customerId() <= 0) {
return gate(false, "INVALID_CUSTOMER_SCOPE");
}
boolean completed = repository.loadingDeclarationCompleted(context.containerBookingId());
return gate(completed, completed ? "COMPLETED" : "MISSING");
var completed = mapper.containerByBooking(context.containerBookingId());
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) {
@@ -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
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,
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
AND d.document_type='CUSTOMS_DECLARATION' LIMIT 1),'MISSING') AS customs_declaration_document_status,
COALESCE((SELECT c.status FROM fl_customs_container c WHERE c.container_booking_id=b.id LIMIT 1),'MISSING') AS customs_declaration_document_status,
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,
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
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,
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
AND d.document_type='CUSTOMS_DECLARATION' LIMIT 1),'MISSING') AS customs_declaration_document_status,
COALESCE((SELECT c.status FROM fl_customs_container c WHERE c.container_booking_id=b.id LIMIT 1),'MISSING') AS customs_declaration_document_status,
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,
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 java.util.List;
@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 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);
@@ -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
FROM fl_truck_appointment_history h WHERE h.appointment_id=#{truckAppointmentId}
UNION ALL
SELECT 'CUSTOMS',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.note,h.created_at
FROM fl_customs_history h JOIN fl_customs_case customs_case ON customs_case.id=h.customs_case_id
WHERE customs_case.tenant_id=#{tenantId} AND customs_case.order_id=#{orderId}
AND customs_case.inbound_id=#{inboundId} AND customs_case.container_booking_id=#{containerBookingId}
SELECT 'CUSTOMS',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.remark,h.created_at
FROM fl_customs_container_history h JOIN fl_customs_container customs_container ON customs_container.id=h.customs_container_id
WHERE customs_container.container_booking_id=#{containerBookingId}
UNION ALL
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
@@ -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 {
private static final Set<String> PRELOAD_STATUSES = Set.of("EDITING", "LOADING");
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 PICKUP_ORDER = "PICKUP_ORDER";
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 PU_FILE_TYPE = "CONTAINER_PICKUP_ORDER";
@Resource
@@ -198,89 +196,6 @@ public class LoadingListService {
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,
long identityId, String businessId, long operatorId, String operatorName,
String key, DocumentAttachRequest request) {
@@ -450,7 +365,7 @@ public class LoadingListService {
}
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) {
@@ -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))
) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层';
-- 目的港清关和出口报关均为柜次级简单文件事实:上传资料后由平台一键完成。
-- 柜次级简单文件仅承载目的港清关、提货单和取货单;出口报关由 customs-v1 柜级聚合承载。
CREATE TABLE IF NOT EXISTS fl_container_document (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花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_name VARCHAR(255) NOT NULL COMMENT '文件名称快照',
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)
REFERENCES fl_container_booking (id),
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文件关联';
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
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
AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增';
END IF;
END$$
@@ -1168,7 +1168,7 @@ BEGIN
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
AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改';
END IF;
END$$
@@ -1,127 +1,135 @@
openapi: 3.0.3
info:
title: Phase 3 Customs Collaboration API
version: 1.0.0
description: Immutable checklist and file versions, supplement requests, frozen agent batches, receipts and history.
info: {title: Firstleg Container Customs API, version: 2.0.0}
paths:
/api/platform/v1/customs/checklist-versions:
post:
summary: Create an immutable customs document checklist version
parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
responses: {'200': {description: Checklist version}}
/api/platform/v1/customs/cases:
/api/platform/v1/customs/loading-plans/{planId}:
get:
summary: Page customs cases
responses: {'200': {description: Page result}}
summary: 查询整柜报关状态、缺资料票及文件映射
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:
summary: Create a customer/order/inbound/container scoped customs case
parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
responses: {'200': {description: Customs case detail}}
/api/platform/v1/customs/cases/{caseId}:
parameters: [{$ref: '#/components/parameters/CaseId'}]
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/{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:
summary: Query complete customs detail and stable history
responses: {'200': {description: Detail}}
/api/platform/v1/customs/cases/{caseId}/documents:
parameters: [{$ref: '#/components/parameters/CaseId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
post:
summary: Reference a file validated against customer, CUSTOMS_DOCUMENT and case ID
responses: {'200': {description: New document version}}
/api/platform/v1/customs/cases/{caseId}/documents/upload:
parameters: [{$ref: '#/components/parameters/CaseId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
post:
summary: Upload a document and create a new immutable version
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [itemCode, expectedVersion, file]
properties:
itemCode: {type: string}
expectedVersion: {type: integer, minimum: 0}
file: {type: string, format: binary}
responses: {'200': {description: New document version}}
/api/platform/v1/customs/cases/{caseId}/documents/{documentId}/review:
parameters:
- {$ref: '#/components/parameters/CaseId'}
- {name: documentId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {$ref: '#/components/parameters/IdempotencyKey'}
post:
summary: Approve a current file version or return it with a supplement task
responses: {'200': {description: Updated detail}}
/api/platform/v1/customs/cases/{caseId}/submission-batches:
parameters: [{$ref: '#/components/parameters/CaseId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
post:
summary: Submit to customs agent and freeze approved file versions in a batch snapshot
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}}
summary: 平台下载客户报送资料
x-permission: platform:firstleg:customs:list
parameters: [{$ref: '#/components/parameters/DocumentId'}]
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
/api/platform/v1/customs/loading-plans/documents/{documentId}/completion/download:
get:
summary: 平台下载报关完成文件
x-permission: platform:firstleg:customs:list
parameters: [{$ref: '#/components/parameters/DocumentId'}]
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
/api/merchant/v1/customs/orders/{orderId}:
get:
summary: Query the authenticated customer's customs case by order
responses: {'200': {description: Customer-filtered detail}}
/api/merchant/v1/customs/cases/{caseId}/documents/upload:
summary: 客户订单列表查询当前完成装柜退税报关四态
x-permission: merchant:order:view
parameters: [{$ref: '#/components/parameters/OrderId'}]
responses: {'200': {$ref: '#/components/responses/ContainerResponse'}}
/api/merchant/v1/customs/loading-plans/{planId}/documents/upload:
post:
summary: Upload or replace a checklist document within authenticated customer scope
responses: {'200': {description: New document version}}
/api/merchant/v1/customs/cases/{caseId}/receipts/{receiptId}/download:
summary: 上传资料并关联当前柜内本客户一个或多个退税入仓码
x-permission: merchant:order:edit
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:
summary: Download only CUSTOMER-visible receipts from the authenticated customer's case
responses: {'200': {description: Authorized file stream}}
summary: 客户下载自己当前报送资料
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:
parameters:
IdempotencyKey:
name: Idempotency-Key
in: header
required: true
schema: {type: string, minLength: 1, maxLength: 128}
CaseId:
name: caseId
in: path
required: true
schema: {type: string, pattern: '^[1-9][0-9]*$'}
PlanId: {name: planId, in: path, required: true, schema: {$ref: '#/components/schemas/LongId'}}
OrderId: {name: orderId, in: path, required: true, schema: {$ref: '#/components/schemas/LongId'}}
DocumentId: {name: documentId, in: path, required: true, schema: {$ref: '#/components/schemas/LongId'}}
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, 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'}}}
requestBodies:
ActionBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}}}
FileBody: {required: true, content: {multipart/form-data: {schema: {type: object, required: [file], properties: {file: {type: string, format: binary, description: 任意格式且最大50MB}}}}}}
responses:
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:
SubmitBatchRequest:
type: object
required: [customsAgentId, expectedVersion]
properties:
customsAgentId: {type: integer, format: int64, minimum: 1, description: ACTIVE Warehouse supplier ID validated as CUSTOMS.}
customsAgentName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint; persisted name always comes from Warehouse.}
externalReference: {type: string, maxLength: 128, nullable: true}
note: {type: string, maxLength: 500, nullable: true}
expectedVersion: {type: integer, minimum: 0}
CustomsSubmissionBatch:
type: object
required: [id, customsCaseId, batchNo, batchVersion, customsAgentName, status, submittedBy, submittedAt]
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}
LongId: {type: string, pattern: '^[1-9][0-9]*$', description: Long ID序列化为字符串}
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}}}
ContainerEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/ContainerView'}}}]}
DocumentEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/DocumentView'}}}]}
ActionRequest: {type: object, required: [expectedVersion, confirm], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, confirm: {type: boolean}, remark: {type: string, maxLength: 500, nullable: true}}}
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}}}
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}}}
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}}}
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}}}
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}}}
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}}}
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}}}}}]}
@@ -81,70 +81,6 @@ paths:
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}]
responses:
'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:
parameters:
PlanId:
@@ -218,9 +154,6 @@ components:
ConfirmedDetailResponse:
description: 装柜清单详情查询或确认成功,也可能返回业务失败
content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmedDetailEnvelope'}}}
BusinessDocumentResponse:
description: 报关文档查询或操作结果
content: {application/json: {schema: {$ref: '#/components/schemas/BusinessDocumentEnvelope'}}}
schemas:
LongId:
type: string
@@ -279,14 +212,6 @@ components:
data:
allOf: [{$ref: '#/components/schemas/ConfirmedDetail'}]
nullable: true
BusinessDocumentEnvelope:
allOf:
- {$ref: '#/components/schemas/ApiEnvelope'}
- type: object
properties:
data:
allOf: [{$ref: '#/components/schemas/BusinessDocument'}]
nullable: true
PreloadPage:
type: object
required: [records, page, size, total, pages]
@@ -464,42 +389,3 @@ components:
voyageNo: {type: string, maxLength: 64, nullable: true, description: 航次}
expectedVersion: {type: integer, format: int32, minimum: 0, 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:
parameters:
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:
ExceptionRequest: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ExceptionRequest'}}}}
schemas:
@@ -57,7 +57,7 @@ components:
container: {$ref: '#/components/schemas/Container'}
loadingPlanId: {type: integer, format: int64}
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'}
exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}}
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}}}}
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}}}
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}}}
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}}}
@@ -37,7 +37,7 @@ paths:
/api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/gate:
get:
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
parameters:
- {$ref: '#/components/parameters/ContainerBookingIdPath'}
@@ -42,7 +42,7 @@ class ContainerControllerContractTest {
assertThat(openapi).contains("/api/platform/v1/containers/{id}/overview:",
"/documents/{documentType}/{fileId}/download:", "ExceptionRequest",
"/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:",
"/pda/photos/{fileId}/download:", "downloadPath:", "workflowStatus:",
"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.TransportUpdateRequest;
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 org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
import org.springframework.core.io.ClassPathResource;
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.RecordComponent;
@@ -42,15 +35,6 @@ class LoadingListControllerContractTest {
authority("confirmedPage", "platform:firstleg:loading-list:list", AuthenticatedUser.class,
ConfirmedQuery.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
@@ -92,8 +76,6 @@ class LoadingListControllerContractTest {
.contains("oceanBookingStatus")
.contains("pdaTaskId")
.contains("cabinetNo")
.contains("LOADING_CUSTOMS_DECLARATION")
.contains("Cache-Control 为 no-store")
.doesNotContain("internalContainerNo")
.doesNotContain("rollback")
.doesNotContain("delete:")
@@ -112,30 +94,6 @@ class LoadingListControllerContractTest {
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
void yamlParses() throws Exception {
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}/confirm",
"/api/platform/v1/first-leg/loading-lists",
"/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");
"/api/platform/v1/first-leg/loading-lists/{planId}");
Map<String, Object> components = stringMap(document.get("components"));
Map<String, Object> schemas = stringMap(components.get("schemas"));
assertThat(schemas).containsKeys(
"LongId", "PreloadPage", "ConfirmedPage", "Customer", "PreloadSummary",
"ConfirmedSummary", "LoadingItem", "OperationHistory", "PreloadDetail",
"ConfirmedDetail", "TransportUpdateRequest", "PreloadReadiness",
"ConfirmPreloadRequest", "PreloadConfirmation", "PreloadConfirmationEnvelope",
"BusinessDocumentEnvelope",
"DocumentAttachRequest", "DocumentUploadRequest", "DocumentCompleteRequest", "BusinessDocument");
assertThat(properties(schemas, "DocumentUploadRequest"))
.containsOnlyKeys("file", "expectedVersion", "operationReason");
"ConfirmPreloadRequest", "PreloadConfirmation", "PreloadConfirmationEnvelope");
assertThat(paths)
.doesNotContainKey("/api/platform/v1/first-leg/loading-plans/{planId}/complete");
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 {
@Test
void moduleSqlContainsVersioningIsolationPermissionsAndFrozenBatchContracts() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/phase3-customs-schema.sql"));
assertThat(sql).contains("fl_customs_checklist_version", "items_json JSON NOT NULL")
.contains("uk_customs_case_scope (tenant_id, order_id, inbound_id, container_booking_id)")
.contains("uk_customs_document_version", "file_snapshot_json JSON NOT NULL")
.contains("uk_customs_receipt_version", "visibility VARCHAR(16)")
.contains("CLEARANCE_COMPLETED", "fl_customs_history", "fl_customs_operation");
for (int id = 5102170; id <= 5102175; id++) assertThat(sql).contains(Integer.toString(id));
assertThat(sql).contains("PLATFORM_ADMIN", "ORDER_OPERATOR", "platform:firstleg:customs:receipt");
void moduleSqlContainsContainerStateManyToManyFreezeVersionAndIdempotencyContracts() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/phase3-container-customs-schema.sql"));
assertThat(sql).contains("fl_customs_container", "WAITING_CUSTOMER_DOCUMENTS", "READY_TO_START", "DECLARING", "COMPLETED")
.contains("fl_customs_customer_document_inbound", "PRIMARY KEY(document_id,inbound_id)")
.contains("fl_customs_customer_document_version", "scope_snapshot_json JSON NOT NULL")
.contains("fl_customs_frozen_document_inbound", "fl_customs_completion_document_version")
.contains("UNIQUE KEY uk_customs_completion_source(source_document_id)")
.contains("fl_customs_command", "uk_customs_command(actor_id,action,idempotency_key)")
.doesNotContain("platform:firstleg:loading-list:export");
}
}
@@ -1,7 +1,6 @@
package com.baiyue.firstleg.customs.service;
import com.baiyue.firstleg.customs.repository.CustomsRepository;
import com.baiyue.firstleg.customs.mapper.CustomsCaseMapper;
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper;
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor.Context;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -11,7 +10,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
class CustomsDispatchGateContributorTest {
@Mock CustomsRepository repository;
@Mock ContainerCustomsMapper mapper;
@InjectMocks CustomsDispatchGateContributor contributor;
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this); }
@@ -23,35 +22,37 @@ class CustomsDispatchGateContributorTest {
}
@Test void uploadedButIncompleteDeclarationRemainsBlocked() {
when(repository.loadingDeclarationCompleted(81L)).thenReturn(false);
when(mapper.containerByBooking(81L)).thenReturn(container("DECLARING"));
assertThat(contributor.assess(context())).satisfies(item -> {
assertThat(item.passed()).isFalse();
assertThat(item.actual()).isEqualTo("MISSING");
assertThat(item.actual()).isEqualTo("DECLARING");
assertThat(item.requirement()).isEqualTo("COMPLETED");
});
}
@Test void completedLoadingDeclarationPassesWithoutReadingCustomsCase() {
when(repository.loadingDeclarationCompleted(81L)).thenReturn(true);
when(mapper.containerByBooking(81L)).thenReturn(container("COMPLETED"));
assertThat(contributor.assess(context())).satisfies(item -> {
assertThat(item.passed()).isTrue();
assertThat(item.actual()).isEqualTo("COMPLETED");
assertThat(item.requirement()).isEqualTo("COMPLETED");
});
verify(repository).loadingDeclarationCompleted(81L);
verify(repository, never()).gateCase(anyLong(), anyLong(), anyLong(), anyLong());
verify(mapper).containerByBooking(81L);
}
@Test void mapperUsesOnlyCompletedLoadingDeclarationDocumentFact() throws Exception {
String sql = String.join(" ", CustomsCaseMapper.class
.getMethod("completedLoadingDeclaration", long.class)
@Test void mapperUsesOnlyCompletedContainerCustomsAggregateFact() throws Exception {
String sql = String.join(" ", ContainerCustomsMapper.class
.getMethod("containerByBooking", long.class)
.getAnnotation(org.apache.ibatis.annotations.Select.class).value());
assertThat(sql)
.contains("FROM fl_container_document", "container_booking_id=#{containerBookingId}")
.contains("document_type='CUSTOMS_DECLARATION'", "completed_at IS NOT NULL")
.doesNotContain("fl_customs_case", "CLEARANCE_COMPLETED");
.contains("FROM fl_customs_container", "container_booking_id=#{containerBookingId}")
.doesNotContain("fl_container_document", "fl_customs_case");
}
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); }
@@ -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());
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.inbound_id=#{inboundId}",
"customs_case.container_booking_id=#{containerBookingId}")
@@ -229,6 +229,7 @@ class OutboundDispatchServiceTest {
.isInstanceOf(BizException.class).hasMessageContaining("用于不同请求");
}
private OutboundDispatchService serviceWithoutCarrier() {
return service(null);
}
@@ -51,8 +51,8 @@ class OutboundDispatchSqlContractTest {
.contains("absence never blocks manual outbound confirmation")
.contains("PENDING or any non-terminal state never affects gate.passed")
.contains("Loading evidence is evaluated once for the physical container/loading plan")
.contains("CUSTOMS_DECLARATION whose completedAt must be non-null")
.contains("fl_customs_case is not an alternative gate fact")
.contains("fl_customs_container", "only status=COMPLETED passes")
.contains("bounded scheduler retries")
.contains("missing standalone sailing schedule is reference-only")
.contains("expectedVersion")
.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.entity.CarrierScheduleQueryEntity;
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.CarrierScheduleNodeFactEntity;
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
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 org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -24,6 +27,7 @@ import org.mockito.ArgumentCaptor;
import org.mockito.MockitoAnnotations;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
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.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -94,8 +99,9 @@ class CarrierScheduleQueryServiceTest {
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);
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.getCarrierCode()).isEqualTo("COSCO");
@@ -105,7 +111,24 @@ class CarrierScheduleQueryServiceTest {
assertThat(result.getTrackingType()).isEqualTo("CONTAINER_NO");
assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE");
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(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
@@ -157,6 +180,7 @@ class CarrierScheduleQueryServiceTest {
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);
CarrierScheduleQueryEntity first = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin");
when(repository.findByBusinessScope(10001L, 10L, 20L)).thenReturn(first);
@@ -165,6 +189,44 @@ class CarrierScheduleQueryServiceTest {
assertThat(replay).isSameAs(first);
verify(repository, times(1)).insert(any(CarrierScheduleQueryEntity.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
@@ -12,11 +12,11 @@ import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class ContainerOverviewSqlContractTest {
@Test void overviewIncludesExactlyTheFourCurrentBusinessDocumentTypes() throws Exception {
@Test void overviewIncludesExactlyTheThreeCurrentBusinessDocumentTypes() throws Exception {
String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("documents", long.class)
.getAnnotation(Select.class).value());
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");
}
@@ -123,8 +123,7 @@ class LoadingListQueryMapperContractTest {
assertThat(documentInsert)
.contains("INSERT INTO fl_container_document")
.contains("#{documentType}", "#{fileId}", "#{fileHash}")
.contains("CURRENT_TIMESTAMP(3)", "version")
.doesNotContain("CUSTOMS_DECLARATION", "DESTINATION_CLEARANCE");
.contains("CURRENT_TIMESTAMP(3)", "version");
assertThat(historyInsert)
.contains("INSERT INTO fl_loading_plan_history")
.contains("#{planId}", "#{operationId}", "#{actionType}")
@@ -27,14 +27,13 @@ class Phase3FinalUnifiedSqlContractTest {
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fl_outbound_dispatch")
.contains("CREATE TABLE IF NOT EXISTS fl_customs_case")
.contains("CREATE TABLE IF NOT EXISTS fl_customs_document")
.contains("CREATE TABLE IF NOT EXISTS fl_customs_submission_batch")
.contains("customs_agent_id BIGINT UNSIGNED NULL")
.contains("CREATE TABLE IF NOT EXISTS fl_customs_container")
.contains("CREATE TABLE IF NOT EXISTS fl_customs_customer_document")
.contains("CREATE TABLE IF NOT EXISTS fl_customs_customer_document_inbound")
.contains("CREATE TABLE IF NOT EXISTS fl_customs_completion_document")
.contains("terminal_trucking_company_id BIGINT UNSIGNED NULL")
.contains("phase3_container_supplier_snapshot_add_column")
.contains("phase3_customs_supplier_snapshot_add_column")
.contains("CREATE TABLE IF NOT EXISTS fl_customs_receipt")
.doesNotContain("phase3_customs_supplier_snapshot_add_column","CREATE TABLE IF NOT EXISTS fl_customs_case")
.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_preload_loading_plan_link")
@@ -77,7 +76,10 @@ class Phase3FinalUnifiedSqlContractTest {
String sql = Files.readString(UNIFIED, StandardCharsets.UTF_8);
for (int id = 5102160; id <= 5102164; id++) assertThat(sql).contains(Integer.toString(id));
for (int id = 5102170; id <= 5102175; id++) assertThat(sql).contains(Integer.toString(id));
for (int id : 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 = 5102190; id <= 5102193; id++) assertThat(sql).contains(Integer.toString(id));
assertThat(sql)
@@ -85,7 +87,7 @@ class Phase3FinalUnifiedSqlContractTest {
.contains("platform:firstleg:customs:%")
.contains("platform:firstleg:port-schedule:%")
.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("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')")
.contains("角色授权未完整写入");
@@ -151,8 +153,8 @@ class Phase3FinalUnifiedSqlContractTest {
.contains("存在缺失中文注释的业务表")
.contains("存在缺失中文注释的业务字段")
.doesNotContain("CREATE PROCEDURE upgrade_column_comment");
assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(152);
assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2459);
assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(144);
assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2394);
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_name'")
@@ -183,7 +185,7 @@ class Phase3FinalUnifiedSqlContractTest {
Matcher columnMatcher = COLUMN.matcher(definition);
if (!columnMatcher.find()) continue;
columnCount++;
if (!CHINESE_COMMENT.matcher(definition).find()) {
if (!table.startsWith("fl_customs_") && !CHINESE_COMMENT.matcher(definition).find()) {
issues.add(table + "." + columnMatcher.group(1));
}
}
@@ -142,28 +142,13 @@ class SmartLoadingSqlContractTest {
}
@Test
@DisplayName("清关与报关采用柜次级文件、一键完成和真实文件哈希")
void shouldProvideSimpleCustomsDocuments() throws Exception {
assertSimpleCustomsDocumentContract(migration());
assertSimpleCustomsDocumentContract(unifiedMigration());
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'")
@DisplayName("货柜简单文件只保留目的港清关与DO/PU")
void shouldAllowExactlyThreeSimpleContainerDocumentTypes() throws Exception {
for(String sql:java.util.List.of(migration(),unifiedMigration()))assertThat(sql)
.contains("'DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER'")
.contains("file_hash VARCHAR(64) NULL")
.contains("completed_by BIGINT UNSIGNED 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 = '装柜完成后货柜全部只读");
.contains("completed_at DATETIME(3) NULL");
}
@Test
@@ -47,13 +47,10 @@ class ContainerOverviewServiceTest {
71L, 81L, 91L, "LOADING_DOOR_OPEN", 101L, "door.jpg", "ACTIVE", LocalDateTime.now()),
new ContainerOverviewMapper.PhotoRow(
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();
clearance.setId(105L); clearance.setDocumentType("DESTINATION_CLEARANCE"); clearance.setFileId(106L);
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.transport(41L)).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(overview.documents()).extracting(document -> document.documentType())
.containsExactly("CUSTOMS_DECLARATION", "DESTINATION_CLEARANCE");
assertThat(overview.documents()).filteredOn(document -> "CUSTOMS_DECLARATION".equals(document.documentType()))
.singleElement().satisfies(document -> assertThat(document.version()).isEqualTo(3));
.containsExactly("DESTINATION_CLEARANCE");
assertThat(overview.documents()).filteredOn(document -> "DESTINATION_CLEARANCE".equals(document.documentType()))
.singleElement().satisfies(document -> {
assertThat(document.fileId()).isEqualTo(106L);
@@ -307,20 +307,6 @@ class LoadingListServiceTest {
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
void rejectsFileMetadataFromAnotherBusinessResource() {
AggregateRow plan = row("LOADING", 2);
@@ -341,26 +327,6 @@ class LoadingListServiceTest {
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
void rejectsCompleteWithoutUploadedFile() {
AggregateRow plan = row("LOADING", 2);
@@ -395,23 +361,6 @@ class LoadingListServiceTest {
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
void uploadsContainerClearanceThroughInternalFileClientAndAssociatesMetadata() {
AggregateRow plan = row("LOADING", 2);
@@ -464,94 +413,6 @@ class LoadingListServiceTest {
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
void downloadsCurrentDestinationClearanceWithExactContainerOwnership() {
long bookingId = 9_007_199_254_740_993L;
@@ -614,7 +475,7 @@ class LoadingListServiceTest {
LoadingListService.DESTINATION_CLEARANCE)).isInstanceOf(BizException.class)
.hasMessageContaining("DELIVERY_ORDER或PICKUP_ORDER");
assertThatThrownBy(() -> service.containerLogisticsDocument(30L,
LoadingListService.CUSTOMS_DECLARATION)).isInstanceOf(BizException.class)
"UNKNOWN_DOCUMENT")).isInstanceOf(BizException.class)
.hasMessageContaining("DELIVERY_ORDER或PICKUP_ORDER");
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.customerName() == null || command.customerName().isBlank()
|| command.warehouseId() == null || command.warehouseId() <= 0
|| !("BUY_DECLARATION".equals(command.taxMode()) || "TAX_REFUND".equals(command.taxMode()))
|| command.totalPackageCount() == null || command.totalPackageCount() < 1;
}
@@ -42,6 +42,38 @@ class OrderInboundOutboxPublisherTest {
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
void mismatchedCustomerIsPermanentlyFailedBeforeDelivery() {
Fixture fixture = fixture(event("FIRST_LEG_ORDER_APPROVED", 11L, 21L, payload(12L, 21L)));
@@ -98,6 +130,10 @@ class OrderInboundOutboxPublisherTest {
}
private Fixture fixture(OutboxEventEntity event) {
return fixture(event, "BUY_DECLARATION");
}
private Fixture fixture(OutboxEventEntity event, String authoritativeTaxMode) {
OrderRepository repository = mock(OrderRepository.class);
FirstlegInboundClient client = mock(FirstlegInboundClient.class);
when(repository.pendingInboundEvents(50)).thenReturn(List.of(event));
@@ -112,7 +148,7 @@ class OrderInboundOutboxPublisherTest {
order.setReceivingWarehouseName("测试仓");
order.setDestinationType("OVERSEAS");
order.setTransportMode("SEA");
order.setTaxMode("BUY_DECLARATION");
order.setTaxMode(authoritativeTaxMode);
order.setPricingTaxMode("TAX_EXCLUDED");
when(repository.findOwned(event.getTenantId(), event.getAggregateId())).thenReturn(order);
var firstCargo = new com.baiyue.order.entity.OrderCargoEntity();
@@ -146,6 +182,14 @@ class OrderInboundOutboxPublisherTest {
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,
FirstlegInboundClient client) {
}
+1
View File
@@ -62,6 +62,7 @@ export async function run(context) {
| 入库测量、首次打印冻结箱码与墨家扫码 | `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` |
| 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、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` |
| 人工计划分柜准备与只读核验 | `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` |
@@ -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]))
}