This commit is contained in:
2026-08-13 11:57:33 +08:00
parent 5a48992d78
commit 16dc03a2e2
102 changed files with 3656 additions and 120 deletions
+3
View File
@@ -39,6 +39,9 @@
## 契约、数据库与验证入口
- 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。
- 平台后台存在正式列表页时,`*:list` 同时授权列表、详情及只读辅助接口;`create`、`edit`、`delete`、`status`、`approve`、`review`、`export`、`upload`、`complete` 等操作保持独立。当前未上线,不兼容旧独立 `*:view`,Controller 不得使用 `hasAnyAuthority(list, view)`,OpenAPI 和角色初始化只保留 `*:list`。
- 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。
- 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。
- 数据库只访问所属服务库。模块迁移位于 `<module>/src/main/resources/db/`;测试/存量环境唯一部署入口是 `deploy/sql/头程数据库统一增量-MySQL8.sql`,说明见 `deploy/sql/README.md`,历史日期脚本仅追溯。
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
- 全量验证:`mvn -DskipTests validate`、`mvn test`;单服务优先 `mvn -pl <module> -am test`。不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
@@ -4344,9 +4344,9 @@ BEGIN
WHERE tenant_id = 0 AND status = 'ACTIVE' AND deleted = 0
AND permission_code IN (
'platform:overseas-storage-fee:list', 'platform:overseas-storage-fee:create',
'platform:overseas-storage-fee:edit', 'platform:overseas-storage-fee:delete')) <> 4 THEN
'platform:overseas-storage-fee:edit', 'platform:overseas-storage-fee:delete')) <> 2 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:海外仓阶梯费用权限未完整创建';
SET MESSAGE_TEXT = '统一迁移失败:海外仓阶梯费用列表/编辑权限未完整创建';
END IF;
END$$
DELIMITER ;
@@ -13930,6 +13930,7 @@ BEGIN
OR (id = 5105033 AND permission_code <> 'platform:finance:settlement-ar:view')
OR (id = 5105034 AND permission_code <> 'platform:finance:settlement-ap:view')
OR (id = 5105035 AND permission_code <> 'platform:finance:payment:create')
OR (id = 5105036 AND permission_code <> 'platform:finance:settlement:void')
OR (permission_code = 'platform:finance:account:view' AND id <> 5105022)
OR (permission_code = 'platform:finance:fee-source:view' AND id <> 5105023)
OR (permission_code = 'platform:finance:fee-source:review' AND id <> 5105024)
@@ -13944,7 +13945,8 @@ BEGIN
OR (permission_code = 'platform:finance:settlement-ar:view' AND id <> 5105033)
OR (permission_code = 'platform:finance:settlement-ap:view' AND id <> 5105034)
OR (permission_code = 'platform:finance:payment:create' AND id <> 5105035)
OR (id BETWEEN 5105022 AND 5105035
OR (permission_code = 'platform:finance:settlement:void' AND id <> 5105036)
OR (id BETWEEN 5105022 AND 5105036
AND (tenant_id <> 0 OR app_code <> 'PLATFORM'))
) THEN
SIGNAL SQLSTATE '45000'
@@ -13972,7 +13974,8 @@ BEGIN
(5105032, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement-ap:create', '生成应付结算单', NULL, 'POST', 230, 'ACTIVE', 1, 0),
(5105033, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement-ar:view', '查看应收结算单', NULL, 'GET', 240, 'ACTIVE', 1, 0),
(5105034, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement-ap:view', '查看应付结算单', NULL, 'GET', 250, 'ACTIVE', 1, 0),
(5105035, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:payment:create', '登记应付付款', NULL, 'POST', 260, 'ACTIVE', 1, 0)
(5105035, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:payment:create', '登记应付付款', NULL, 'POST', 260, 'ACTIVE', 1, 0),
(5105036, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement:void', '作废或重开结算单', NULL, 'POST', 270, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
permission_name = VALUES(permission_name), route_path = VALUES(route_path),
@@ -14002,7 +14005,8 @@ BEGIN
'platform:finance:settlement-ap:create',
'platform:finance:settlement-ar:view',
'platform:finance:settlement-ap:view',
'platform:finance:payment:create')) <> 14 THEN
'platform:finance:payment:create',
'platform:finance:settlement:void')) <> 15 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'finance G1-G4 permission catalog is incomplete';
END IF;
@@ -14031,7 +14035,8 @@ WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN (
'platform:finance:reconciliation:issue', 'platform:finance:reconciliation:adjust',
'platform:finance:reconciliation:void', 'platform:finance:settlement-ar:create',
'platform:finance:settlement-ap:create', 'platform:finance:settlement-ar:view',
'platform:finance:settlement-ap:view', 'platform:finance:payment:create')
'platform:finance:settlement-ap:view', 'platform:finance:payment:create',
'platform:finance:settlement:void')
ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id);
INSERT INTO baiyue_iam.iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000101, id, 10001 FROM baiyue_iam.iam_permission
@@ -14042,7 +14047,7 @@ ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id);
DELIMITER $$
CREATE PROCEDURE assert_finance_permissions()
BEGIN
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 26 THEN
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 27 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台 Finance 权限不完整';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=10001 AND app_code='MERCHANT' AND permission_code IN ('merchant:finance:statement:list','merchant:finance:statement:view') AND status='ACTIVE' AND deleted=0) <> 2 THEN
@@ -14429,6 +14434,67 @@ END$$
DELIMITER ;
-- 二、三期对外统一使用 INBOUNDED;历史 WAIT_SPLIT 仅由查询适配为 INBOUNDED,统一迁移不改写主表或历史状态。
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_container_exception_history (
id BIGINT UNSIGNED NOT NULL COMMENT '异常历史ID',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '稳定货柜预订ID',
action VARCHAR(16) NOT NULL COMMENT 'MARK标记异常/CANCEL取消异常',
reason VARCHAR(500) NOT NULL COMMENT '标记或取消原因',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
operation_id VARCHAR(128) NOT NULL COMMENT '同一操作人幂等键',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求摘要',
occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '发生时间',
PRIMARY KEY (id), UNIQUE KEY uk_container_exception_operation (container_booking_id, operator_id, operation_id),
KEY idx_container_exception_history (container_booking_id, occurred_at, id),
CONSTRAINT fk_container_exception_booking FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id),
CONSTRAINT chk_container_exception_action CHECK (action IN ('MARK','CANCEL'))
) ENGINE=InnoDB COMMENT='货柜异常标记及取消原因历史';
DROP PROCEDURE IF EXISTS phase3_container_exception_add_column;
DELIMITER $$
CREATE PROCEDURE phase3_container_exception_add_column(IN p_name VARCHAR(64),IN p_definition TEXT)
BEGIN
IF NOT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_exception_history' AND column_name=p_name) THEN
SET @ddl=CONCAT('ALTER TABLE fl_container_exception_history ADD COLUMN ',p_name,' ',p_definition);
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
END IF;
END$$
DELIMITER ;
CALL phase3_container_exception_add_column('operation_id',"VARCHAR(128) NULL COMMENT '同一操作人幂等键' AFTER operator_name");
CALL phase3_container_exception_add_column('request_hash',"CHAR(64) NULL COMMENT '规范化请求摘要' AFTER operation_id");
DROP PROCEDURE phase3_container_exception_add_column;
UPDATE fl_container_exception_history SET operation_id=CONCAT('LEGACY-',id) WHERE operation_id IS NULL OR operation_id='';
UPDATE fl_container_exception_history SET request_hash=SHA2(CONCAT(action,'|',container_booking_id,'|',id,'|',reason),256) WHERE request_hash IS NULL OR request_hash='';
ALTER TABLE fl_container_exception_history MODIFY operation_id VARCHAR(128) NOT NULL, MODIFY request_hash CHAR(64) NOT NULL;
SET @has_exception_operation_index=(SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_exception_history' AND index_name='uk_container_exception_operation');
SET @ddl=IF(@has_exception_operation_index=0,'ALTER TABLE fl_container_exception_history ADD UNIQUE KEY uk_container_exception_operation(container_booking_id,operator_id,operation_id)','SELECT 1');
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
DROP TRIGGER IF EXISTS trg_container_exception_history_before_update;
DROP TRIGGER IF EXISTS trg_container_exception_history_before_delete;
DELIMITER $$
CREATE TRIGGER trg_container_exception_history_before_update BEFORE UPDATE ON fl_container_exception_history FOR EACH ROW
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜异常历史不可修改'; END$$
CREATE TRIGGER trg_container_exception_history_before_delete BEFORE DELETE ON fl_container_exception_history FOR EACH ROW
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜异常历史不可删除'; END$$
DELIMITER ;
-- Admin 原型菜单与财务五页权限补充(来源:iam/src/main/resources/db/admin-prototype-menu-permissions.sql)。
USE baiyue_iam;
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
(5105040,0,'PLATFORM','MENU',5105000,'platform:finance:customer-account:list','客户账户','/finance/customer-accounts','GET',10,'ACTIVE',1,0),
(5105041,0,'PLATFORM','MENU',5105000,'platform:finance:customer-arap:list','客户应收应付','/finance/customer-arap','GET',20,'ACTIVE',1,0),
(5105042,0,'PLATFORM','MENU',5105000,'platform:finance:customer-statement:list','客户对账单','/finance/customer-statements','GET',30,'ACTIVE',1,0),
(5105043,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ar:list','结算单应收','/finance/settlement-receivables','GET',40,'ACTIVE',1,0),
(5105044,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ap:list','结算单应付','/finance/settlement-payables','GET',50,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE 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;
UPDATE iam_permission SET status='INACTIVE', deleted=1 WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN ('platform:overseas-storage-fee:create','platform:overseas-storage-fee:delete');
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='PLATFORM'
WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND p.permission_code IN ('platform:finance:customer-account:list','platform:finance:customer-arap:list','platform:finance:customer-statement:list','platform:finance:settlement-ar:list','platform:finance:settlement-ap:list');
UPDATE iam_permission p JOIN (SELECT 'platform:finance:account:view' code,5105040 parent_id UNION ALL SELECT 'platform:finance:fee-source:view',5105041 UNION ALL SELECT 'platform:finance:fee-source:review',5105041 UNION ALL SELECT 'platform:finance:fee-source:settlement',5105041 UNION ALL SELECT 'platform:finance:reconciliation:create',5105042 UNION ALL SELECT 'platform:finance:reconciliation:review',5105042 UNION ALL SELECT 'platform:finance:reconciliation:issue',5105042 UNION ALL SELECT 'platform:finance:reconciliation:adjust',5105042 UNION ALL SELECT 'platform:finance:reconciliation:void',5105042 UNION ALL SELECT 'platform:finance:settlement-ar:create',5105043 UNION ALL SELECT 'platform:finance:settlement-ar:view',5105043 UNION ALL SELECT 'platform:finance:settlement-ap:create',5105044 UNION ALL SELECT 'platform:finance:settlement-ap:view',5105044 UNION ALL SELECT 'platform:finance:payment:create',5105044) mapping ON mapping.code=p.permission_code SET p.parent_id=mapping.parent_id WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_type='BUTTON';
INSERT INTO baiyue_iam.iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted)
VALUES
@@ -14451,6 +14517,186 @@ ALTER TABLE baiyue_order.ord_order_cargo
MODIFY COLUMN gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克);草稿可空,提交前必须大于0',
MODIFY COLUMN volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米);草稿可空,提交前必须大于0';
-- 平台公共供应商业务档案及权限(来源:warehouse-supplier.sql / platform-supplier-permissions.sql)。
USE baiyue_warehouse;
CREATE TABLE IF NOT EXISTS wh_supplier (
id BIGINT UNSIGNED NOT NULL COMMENT '供应商ID', tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共作用域,固定为0',
name VARCHAR(160) NOT NULL COMMENT '供应商名称', category VARCHAR(20) NOT NULL COMMENT 'BOOKING/CUSTOMS/TRUCKING/CERTIFICATE',
scope VARCHAR(20) NULL COMMENT '订舱分类范围:DOMESTIC/INTERNATIONAL', region VARCHAR(80) NOT NULL COMMENT '地区',
legal_representative VARCHAR(80) NOT NULL COMMENT '法人代表', contact_name VARCHAR(80) NOT NULL COMMENT '联系人',
phone VARCHAR(40) NOT NULL COMMENT '联系电话', email VARCHAR(160) NULL COMMENT '电子邮箱', address VARCHAR(255) NULL COMMENT '公司地址',
business_license_no VARCHAR(64) NULL COMMENT '营业执照号', license_file_id VARCHAR(128) NULL COMMENT 'File服务营业执照文件ID',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT 'ACTIVE/INACTIVE', settlement_period VARCHAR(32) NOT NULL COMMENT '结算周期', remark VARCHAR(500) NULL COMMENT '备注',
created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后修改时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id),
UNIQUE KEY uk_wh_supplier_name(tenant_id,name), KEY idx_wh_supplier_filter(tenant_id,category,status,region),
CONSTRAINT chk_wh_supplier_category CHECK(category IN ('BOOKING','CUSTOMS','TRUCKING','CERTIFICATE')),
CONSTRAINT chk_wh_supplier_scope CHECK((category='BOOKING' AND scope IN ('DOMESTIC','INTERNATIONAL')) OR (category<>'BOOKING' AND scope IS NULL)),
CONSTRAINT chk_wh_supplier_status CHECK(status IN ('ACTIVE','INACTIVE')),
CONSTRAINT chk_wh_supplier_settlement CHECK(settlement_period IN ('MONTHLY_30','MONTHLY_45','MONTHLY_60','MONTHLY_90','PER_TRANSACTION','IMMEDIATE'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台公共供应商业务档案';
DROP PROCEDURE IF EXISTS supplier_add_column_if_missing;
DELIMITER $$
CREATE PROCEDURE supplier_add_column_if_missing(IN p_name VARCHAR(64),IN p_definition TEXT)
BEGIN
IF NOT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND column_name=p_name) THEN
SET @supplier_ddl=CONCAT('ALTER TABLE wh_supplier ADD COLUMN ',p_name,' ',p_definition); PREPARE supplier_statement FROM @supplier_ddl; EXECUTE supplier_statement; DEALLOCATE PREPARE supplier_statement;
END IF;
END$$
DELIMITER ;
CALL supplier_add_column_if_missing('scope',"VARCHAR(20) NULL COMMENT '订舱分类范围:DOMESTIC/INTERNATIONAL' AFTER category");
CALL supplier_add_column_if_missing('license_file_id',"VARCHAR(128) NULL COMMENT 'File服务营业执照文件ID' AFTER business_license_no");
CALL supplier_add_column_if_missing('created_by',"BIGINT UNSIGNED NULL COMMENT '创建人ID'");
CALL supplier_add_column_if_missing('created_at',"DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间'");
CALL supplier_add_column_if_missing('updated_by',"BIGINT UNSIGNED NULL COMMENT '最后修改人ID'");
CALL supplier_add_column_if_missing('updated_at',"DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后修改时间'");
CALL supplier_add_column_if_missing('deleted',"TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记'");
CALL supplier_add_column_if_missing('version',"INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本'");
DROP PROCEDURE supplier_add_column_if_missing;
SET @supplier_ddl=IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND index_name='uk_wh_supplier_name'),'SELECT 1','ALTER TABLE wh_supplier ADD UNIQUE KEY uk_wh_supplier_name(tenant_id,name)'); PREPARE supplier_statement FROM @supplier_ddl; EXECUTE supplier_statement; DEALLOCATE PREPARE supplier_statement;
SET @supplier_ddl=IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND index_name='idx_wh_supplier_filter'),'SELECT 1','ALTER TABLE wh_supplier ADD KEY idx_wh_supplier_filter(tenant_id,category,status,region)'); PREPARE supplier_statement FROM @supplier_ddl; EXECUTE supplier_statement; DEALLOCATE PREPARE supplier_statement;
DROP PROCEDURE IF EXISTS supplier_add_check_if_missing;
DELIMITER $$
CREATE PROCEDURE supplier_add_check_if_missing(IN p_name VARCHAR(64),IN p_expression TEXT)
BEGIN
IF NOT EXISTS(SELECT 1 FROM information_schema.table_constraints WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND constraint_name=p_name AND constraint_type='CHECK') THEN
SET @supplier_ddl=CONCAT('ALTER TABLE wh_supplier ADD CONSTRAINT ',p_name,' CHECK(',p_expression,')'); PREPARE supplier_statement FROM @supplier_ddl; EXECUTE supplier_statement; DEALLOCATE PREPARE supplier_statement;
END IF;
END$$
DELIMITER ;
CALL supplier_add_check_if_missing('chk_wh_supplier_category',"category IN ('BOOKING','CUSTOMS','TRUCKING','CERTIFICATE')");
CALL supplier_add_check_if_missing('chk_wh_supplier_scope',"(category='BOOKING' AND scope IN ('DOMESTIC','INTERNATIONAL')) OR (category<>'BOOKING' AND scope IS NULL)");
CALL supplier_add_check_if_missing('chk_wh_supplier_status',"status IN ('ACTIVE','INACTIVE')");
CALL supplier_add_check_if_missing('chk_wh_supplier_settlement',"settlement_period IN ('MONTHLY_30','MONTHLY_45','MONTHLY_60','MONTHLY_90','PER_TRANSACTION','IMMEDIATE')");
DROP PROCEDURE supplier_add_check_if_missing;
USE baiyue_iam;
DROP PROCEDURE IF EXISTS guard_platform_supplier_permission_ids;
DELIMITER $$
CREATE PROCEDURE guard_platform_supplier_permission_ids()
BEGIN
IF EXISTS(SELECT 1 FROM iam_permission WHERE id BETWEEN 5103080 AND 5103083 AND permission_code<>CASE id WHEN 5103080 THEN 'platform:supplier:list' WHEN 5103081 THEN 'platform:supplier:create' WHEN 5103082 THEN 'platform:supplier:edit' WHEN 5103083 THEN 'platform:supplier:status' END)
OR EXISTS(SELECT 1 FROM iam_permission WHERE permission_code IN('platform:supplier:list','platform:supplier:create','platform:supplier:edit','platform:supplier:status') AND id NOT BETWEEN 5103080 AND 5103083) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商权限固定ID或权限码已被其他语义占用';
END IF;
END$$
DELIMITER ;
CALL guard_platform_supplier_permission_ids(); DROP PROCEDURE guard_platform_supplier_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
(5103080,0,'PLATFORM','MENU',5103000,'platform:supplier:list','供应商列表','/suppliers','GET',80,'ACTIVE',1,0),
(5103081,0,'PLATFORM','BUTTON',5103080,'platform:supplier:create','新增供应商',NULL,'POST',10,'ACTIVE',1,0),
(5103082,0,'PLATFORM','BUTTON',5103080,'platform:supplier:edit','编辑供应商',NULL,'PUT',20,'ACTIVE',1,0),
(5103083,0,'PLATFORM','BUTTON',5103080,'platform:supplier:status','启停供应商',NULL,'PATCH',30,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE 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(tenant_id,role_id,permission_id,created_at)
SELECT 0,r.id,p.id,CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='PLATFORM'
WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND r.status='ACTIVE' AND r.deleted=0
AND p.permission_code IN('platform:supplier:list','platform:supplier:create','platform:supplier:edit','platform:supplier:status');
DROP PROCEDURE IF EXISTS assert_platform_supplier_permissions;
DELIMITER $$
CREATE PROCEDURE assert_platform_supplier_permissions()
BEGIN
IF (SELECT COUNT(*) FROM baiyue_warehouse.wh_supplier WHERE tenant_id<>0)<>0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商存在非平台作用域数据'; END IF;
IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND index_name IN('PRIMARY','uk_wh_supplier_name','idx_wh_supplier_filter'))<>3 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商表索引不完整'; END IF;
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:supplier:list','platform:supplier:create','platform:supplier:edit','platform:supplier:status'))<>4 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商权限资源初始化不完整'; END IF;
IF (SELECT COUNT(*) FROM iam_role_permission rp JOIN iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id JOIN iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE r.role_code='PLATFORM_ADMIN' AND p.permission_code IN('platform:supplier:list','platform:supplier:create','platform:supplier:edit','platform:supplier:status'))<>4 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PLATFORM_ADMIN供应商权限授权不完整'; END IF;
END$$
DELIMITER ;
CALL assert_platform_supplier_permissions(); DROP PROCEDURE assert_platform_supplier_permissions;
-- 国内仓整柜入库、出库、库存和不可变交易流水。
USE baiyue_warehouse;
CREATE TABLE IF NOT EXISTS wh_domestic_container_order (
id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0, business_type VARCHAR(16) NOT NULL,
order_no VARCHAR(40) NOT NULL, warehouse_id BIGINT UNSIGNED NOT NULL, warehouse_name VARCHAR(120) NOT NULL,
container_no VARCHAR(32) NOT NULL, cargo_name VARCHAR(128) NOT NULL, box_count INT UNSIGNED NOT NULL,
volume_cbm DECIMAL(16,4) NOT NULL DEFAULT 0, operation_time DATETIME(3) NOT NULL, status VARCHAR(16) NOT NULL,
remark VARCHAR(500) NULL, created_by BIGINT UNSIGNED NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT UNSIGNED NULL, updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT(1) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0,
PRIMARY KEY(id), UNIQUE KEY uk_dc_order_no(tenant_id,order_no,deleted),
KEY idx_dc_order_list(tenant_id,business_type,warehouse_id,status,operation_time),
KEY idx_dc_order_container(tenant_id,warehouse_id,container_no,business_type),
CONSTRAINT chk_dc_order_scope CHECK(tenant_id=0), CONSTRAINT chk_dc_order_type CHECK(business_type IN('INBOUND','OUTBOUND')),
CONSTRAINT chk_dc_order_status CHECK(status IN('PENDING','COMPLETED')), CONSTRAINT chk_dc_order_quantity CHECK(box_count>0 AND volume_cbm>=0)
) ENGINE=InnoDB COMMENT='国内仓独立整柜入出库单';
CREATE TABLE IF NOT EXISTS wh_domestic_container_inventory (
id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0, warehouse_id BIGINT UNSIGNED NOT NULL,
warehouse_name VARCHAR(120) NOT NULL, container_no VARCHAR(32) NOT NULL, cargo_name VARCHAR(128) NOT NULL,
inbound_boxes INT UNSIGNED NOT NULL, outbound_boxes INT UNSIGNED NOT NULL DEFAULT 0, available_boxes INT UNSIGNED NOT NULL,
inbound_volume_cbm DECIMAL(16,4) NOT NULL, inventory_status VARCHAR(24) NOT NULL,
created_by BIGINT UNSIGNED NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT UNSIGNED NULL, updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT(1) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0,
PRIMARY KEY(id), UNIQUE KEY uk_dc_inventory_container(tenant_id,warehouse_id,container_no,deleted),
KEY idx_dc_inventory_list(tenant_id,warehouse_id,inventory_status,updated_at),
CONSTRAINT chk_dc_inventory_scope CHECK(tenant_id=0),
CONSTRAINT chk_dc_inventory_balance CHECK(inbound_boxes=outbound_boxes+available_boxes),
CONSTRAINT chk_dc_inventory_status CHECK(inventory_status IN('IN_STOCK','PARTIALLY_OUTBOUND','OUTBOUND'))
) ENGINE=InnoDB COMMENT='平台国内仓整柜库存台账';
CREATE TABLE IF NOT EXISTS wh_domestic_container_transaction (
id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0, inventory_id BIGINT UNSIGNED NOT NULL,
warehouse_id BIGINT UNSIGNED NOT NULL, container_no VARCHAR(32) NOT NULL, business_type VARCHAR(16) NOT NULL,
order_id BIGINT UNSIGNED NOT NULL, order_no VARCHAR(40) NOT NULL, quantity INT UNSIGNED NOT NULL,
operation_time DATETIME(3) NOT NULL, remark VARCHAR(500) NULL, created_by BIGINT UNSIGNED NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_by BIGINT UNSIGNED NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT(1) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0,
PRIMARY KEY(id), UNIQUE KEY uk_dc_transaction_order(tenant_id,business_type,order_id),
KEY idx_dc_transaction_detail(tenant_id,inventory_id,operation_time),
CONSTRAINT chk_dc_transaction_scope CHECK(tenant_id=0), CONSTRAINT chk_dc_transaction_type CHECK(business_type IN('INBOUND','OUTBOUND')),
CONSTRAINT chk_dc_transaction_quantity CHECK(quantity>0)
) ENGINE=InnoDB COMMENT='国内仓整柜不可变进销存交易流水';
CREATE TABLE IF NOT EXISTS wh_domestic_container_idempotency (
id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0, operation_type VARCHAR(16) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL, request_hash CHAR(64) NOT NULL, resource_id BIGINT UNSIGNED NULL,
resource_no VARCHAR(40) NULL, operation_status VARCHAR(16) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), completed_at DATETIME(3) NULL,
PRIMARY KEY(id), UNIQUE KEY uk_dc_idempotency(tenant_id,operation_type,idempotency_key),
CONSTRAINT chk_dc_idempotency_scope CHECK(tenant_id=0), CONSTRAINT chk_dc_idempotency_type CHECK(operation_type IN('INBOUND','OUTBOUND')),
CONSTRAINT chk_dc_idempotency_status CHECK(operation_status IN('PROCESSING','COMPLETED'))
) ENGINE=InnoDB COMMENT='国内仓整柜写操作幂等记录';
USE baiyue_iam;
DROP PROCEDURE IF EXISTS migrate_domestic_container_permissions;
DELIMITER $$
CREATE PROCEDURE migrate_domestic_container_permissions()
BEGIN
IF EXISTS(SELECT 1 FROM iam_permission WHERE (id=5103070 AND permission_code<>'platform:domestic-container:list') OR (id=5103071 AND permission_code<>'platform:domestic-container:inbound:create') OR (id=5103072 AND permission_code<>'platform:domestic-container:outbound:create') OR (permission_code='platform:domestic-container:list' AND id<>5103070) OR (permission_code='platform:domestic-container:inbound:create' AND id<>5103071) OR (permission_code='platform:domestic-container:outbound:create' AND id<>5103072)) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='domestic container permission id/code conflict';
END IF;
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
(5103070,0,'PLATFORM','MENU',5103000,'platform:domestic-container:list','整柜业务','/domestic-containers','GET',70,'ACTIVE',1,0),
(5103071,0,'PLATFORM','BUTTON',5103070,'platform:domestic-container:inbound:create','新建整柜入库单','/api/platform/v1/domestic-containers/inbounds','POST',10,'ACTIVE',1,0),
(5103072,0,'PLATFORM','BUTTON',5103070,'platform:domestic-container:outbound:create','新建整柜出库单','/api/platform/v1/domestic-containers/outbounds','POST',20,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE 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(tenant_id,role_id,permission_id,created_at)
SELECT 0,r.id,p.id,CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='PLATFORM'
WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND r.status='ACTIVE' AND r.deleted=0
AND p.permission_code IN('platform:domestic-container:list','platform:domestic-container:inbound:create','platform:domestic-container:outbound:create');
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:domestic-container:list','platform:domestic-container:inbound:create','platform:domestic-container:outbound:create'))<>3 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='domestic container permission catalog is incomplete'; END IF;
IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_warehouse' AND table_name IN('wh_domestic_container_order','wh_domestic_container_inventory','wh_domestic_container_transaction','wh_domestic_container_idempotency'))<>4 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='domestic container tables are incomplete'; END IF;
END$$
DELIMITER ;
CALL migrate_domestic_container_permissions(); DROP PROCEDURE migrate_domestic_container_permissions;
-- 消息页面菜单与已读操作权限分离。
USE baiyue_iam;
UPDATE iam_permission SET permission_type='MENU',parent_id=5106000,permission_name='模板管理',route_path='/notifications/templates',http_method='GET',status='ACTIVE',deleted=0 WHERE id=5106003 AND permission_code='platform:notification:template:list';
UPDATE iam_permission SET permission_type='MENU',parent_id=5106000,permission_name='消息管理',route_path='/notifications/messages',http_method='GET',status='ACTIVE',deleted=0 WHERE id=5106007 AND permission_code='platform:notification:task:list';
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
(5106011,0,'PLATFORM','BUTTON',5106001,'platform:notification:read','标记消息已读','/api/platform/v1/notifications/{id}/read','PUT',610,'ACTIVE',1,0),
(5106012,0,'PLATFORM','BUTTON',5106001,'platform:notification:read-all','全部标记已读','/api/platform/v1/notifications/read-all','PUT',611,'ACTIVE',1,0),
(5106013,0,'MERCHANT','BUTTON',5106010,'merchant:notification:read','标记消息已读','/api/merchant/v1/notifications/{id}/read','PUT',901,'ACTIVE',1,0),
(5106014,0,'MERCHANT','BUTTON',5106010,'merchant:notification:read-all','全部标记已读','/api/merchant/v1/notifications/read-all','PUT',902,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE 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(tenant_id,role_id,permission_id,created_at)
SELECT 0,r.id,p.id,CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=0
WHERE r.tenant_id=0 AND r.status='ACTIVE' AND r.deleted=0
AND ((r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND p.permission_code IN('platform:notification:read','platform:notification:read-all'))
OR (r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND p.permission_code IN('merchant:notification:read','merchant:notification:read-all')));
-- 新完成入库记录使用 WAIT_SPLIT;存量 INBOUNDED 继续作为可分柜兼容状态,统一迁移不改写主表或历史状态。
SELECT 'PASS' AS migration_status,
'统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源及可靠审计结构校验通过' AS message;
@@ -102,7 +102,7 @@ public class PlatformQualificationFileController {
}
@GetMapping("/{tenantId}/{fileName}")
@PreAuthorize("hasAuthority('platform:customer:view')")
@PreAuthorize("hasAnyAuthority('platform:customer:list','platform:customer:view')")
public ResponseEntity<org.springframework.core.io.Resource> download(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long tenantId, @PathVariable String fileName) {
@@ -20,7 +20,9 @@ public class InternalBusinessFileService {
"CUSTOMS_RECEIPT",
"FINANCE_VOUCHER",
"CONTAINER_CLEARANCE_DOCUMENT",
"LOADING_CUSTOMS_DOCUMENT");
"LOADING_CUSTOMS_DOCUMENT",
"CONTAINER_DELIVERY_ORDER",
"CONTAINER_PICKUP_ORDER");
@Resource private FileObjectMapper files;
@Resource private BusinessFileStorage storage;
@@ -46,6 +46,8 @@ class InternalBusinessFileServiceTest {
var clearance = service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file);
var customs = service.upload(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 7L, file);
var deliveryOrder = service.upload(0L, "CONTAINER_DELIVERY_ORDER", "41", 7L, file);
var pickupOrder = service.upload(0L, "CONTAINER_PICKUP_ORDER", "41", 7L, file);
assertThat(clearance).extracting(InternalBusinessFileService.Metadata::tenantId,
InternalBusinessFileService.Metadata::businessType,
@@ -53,7 +55,9 @@ class InternalBusinessFileServiceTest {
assertThat(customs).extracting(InternalBusinessFileService.Metadata::tenantId,
InternalBusinessFileService.Metadata::businessType,
InternalBusinessFileService.Metadata::businessId).containsExactly(0L, "LOADING_CUSTOMS_DOCUMENT", "51");
verify(storage).storeLoading(0L, 41L, file);
assertThat(deliveryOrder.businessType()).isEqualTo("CONTAINER_DELIVERY_ORDER");
assertThat(pickupOrder.businessType()).isEqualTo("CONTAINER_PICKUP_ORDER");
verify(storage, times(3)).storeLoading(0L, 41L, file);
verify(storage).storeLoading(0L, 51L, file);
}
@@ -27,7 +27,7 @@ public class FinancePhase1OperationController {
public FinancePhase1OperationController(FinancePhase1OperationService service) { this.service = service; }
@GetMapping("/accounts")
@PreAuthorize("hasAuthority('platform:finance:account:view')")
@PreAuthorize("hasAnyAuthority('platform:finance:customer-account:list','platform:finance:account:view')")
public Object listDerivedCustomerAccounts(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,
@RequestParam(required = false) String keyword,
@@ -36,7 +36,7 @@ public class FinancePhase1OperationController {
}
@GetMapping("/accounts/{customerId}/ledger")
@PreAuthorize("hasAuthority('platform:finance:account:view')")
@PreAuthorize("hasAnyAuthority('platform:finance:customer-account:list','platform:finance:account:view')")
public Object listDerivedCustomerAccountLedger(@PathVariable long customerId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,
@@ -47,7 +47,7 @@ public class FinancePhase1OperationController {
}
@GetMapping("/fee-sources/{feeSourceId}")
@PreAuthorize("hasAuthority('platform:finance:fee-source:view')")
@PreAuthorize("hasAnyAuthority('platform:finance:customer-arap:list','platform:finance:fee-source:view')")
public ApiResponse<?> getFinanceFeeSource(@PathVariable long feeSourceId) {
return FinanceResponses.success(service.getFinanceFeeSource(feeSourceId));
}
@@ -8,6 +8,7 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetailEnvelope;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementListEnvelope;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
import com.baiyue.finance.service.FinanceSettlementService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -40,7 +41,7 @@ public class FinanceSettlementController {
}
@GetMapping("/receivable-settlements")
@PreAuthorize("hasAuthority('platform:finance:settlement-ar:view')")
@PreAuthorize("hasAnyAuthority('platform:finance:settlement-ar:list','platform:finance:settlement-ar:view')")
@Operation(summary = "查询正式应收结算单")
public SettlementListEnvelope receivableSettlements(
@Parameter(description = "可选客户筛选;省略时查询全部客户")
@@ -49,7 +50,7 @@ public class FinanceSettlementController {
}
@GetMapping("/payable-settlements")
@PreAuthorize("hasAuthority('platform:finance:settlement-ap:view')")
@PreAuthorize("hasAnyAuthority('platform:finance:settlement-ap:list','platform:finance:settlement-ap:view')")
@Operation(summary = "查询正式应付结算单")
public SettlementListEnvelope payableSettlements(
@Parameter(description = "可选客户筛选;省略时查询全部客户")
@@ -76,13 +77,17 @@ public class FinanceSettlementController {
}
@GetMapping("/settlements/{settlementId}")
@PreAuthorize("hasAnyAuthority('platform:finance:settlement-ar:view','platform:finance:settlement-ap:view')")
@PreAuthorize("hasAnyAuthority('platform:finance:settlement-ar:list','platform:finance:settlement-ar:view','platform:finance:settlement-ap:list','platform:finance:settlement-ap:view')")
public SettlementDetailEnvelope getSettlement(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long settlementId) {
SettlementDetail detail = service.getSettlement(settlementId);
String permission = "RECEIVABLE".equals(detail.summary().direction())
String direction = detail.summary().direction();
String listPermission = "RECEIVABLE".equals(direction)
? "platform:finance:settlement-ar:list" : "platform:finance:settlement-ap:list";
String legacyViewPermission = "RECEIVABLE".equals(direction)
? "platform:finance:settlement-ar:view" : "platform:finance:settlement-ap:view";
if (user == null || !user.permissions().contains(permission)) {
if (user == null || (!user.permissions().contains(listPermission)
&& !user.permissions().contains(legacyViewPermission))) {
throw new AccessDeniedException("无权查看该方向结算单");
}
return success(detail);
@@ -98,6 +103,16 @@ public class FinanceSettlementController {
return success(service.createSettlementPayment(settlementId, user.userId(), idempotencyKey, request));
}
@PostMapping("/settlements/{settlementId}/actions")
@PreAuthorize("hasAuthority('platform:finance:settlement:void')")
public SettlementDetailEnvelope actionSettlement(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long settlementId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody SettlementActionRequest request) {
return success(service.actionSettlement(settlementId, user.userId(), idempotencyKey, request));
}
private static SettlementDetailEnvelope success(SettlementDetail detail) {
String requestId = first(MDC.get("requestId"), MDC.get("request_id"));
if (!StringUtils.hasText(requestId)) requestId = UUID.randomUUID().toString();
@@ -45,14 +45,14 @@ public class PlatformFinanceController {
}
@GetMapping("/customer-accounts/{customerId}")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@PreAuthorize("hasAnyAuthority('platform:finance:customer-account:list','platform:finance:account:view','platform:finance:bill:view')")
@Operation(summary = "客户人工应收账户及流水")
public ApiResponse<?> customerAccount(@PathVariable long customerId) {
return FinanceResponses.success(queryService.customerAccount(customerId));
}
@GetMapping("/fee-pool")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@PreAuthorize("hasAnyAuthority('platform:finance:customer-arap:list','platform:finance:bill:view')")
@Operation(summary = "客户应收应付费用池")
public ApiResponse<?> feePool(
@Parameter(description = "可选客户筛选;省略时查询全部客户")
@@ -62,7 +62,7 @@ public class PlatformFinanceController {
}
@GetMapping("/statements")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@PreAuthorize("hasAnyAuthority('platform:finance:customer-statement:list','platform:finance:bill:view')")
@Operation(summary = "客户对账单")
public ApiResponse<?> statements(
@Parameter(description = "可选客户筛选;省略时查询全部客户")
@@ -87,7 +87,7 @@ public class PlatformFinanceController {
public ApiResponse<?> terms(@PathVariable long tenantId) { return FinanceResponses.success(termsView(tenantId, service.customerTerms(tenantId))); }
@GetMapping("/bills")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@PreAuthorize("hasAnyAuthority('platform:finance:customer-statement:list','platform:finance:bill:view')")
@Operation(summary = "查询平台账单")
public ApiResponse<?> bills(@RequestParam long customerId) { return FinanceResponses.success(queryService.statements(customerId, false)); }
@@ -115,11 +115,11 @@ public class PlatformFinanceController {
}
@GetMapping("/bills/{billId}")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@PreAuthorize("hasAnyAuthority('platform:finance:customer-statement:list','platform:finance:bill:view')")
public ApiResponse<?> bill(@PathVariable long billId) { return FinanceResponses.success(queryService.platformStatement(billId)); }
@GetMapping("/bills/{billId}/revisions")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@PreAuthorize("hasAnyAuthority('platform:finance:customer-statement:list','platform:finance:bill:view')")
public ApiResponse<?> revisions(@PathVariable long billId, @RequestParam long customerId) {
return FinanceResponses.success(queryService.billRevisions(customerId, billId));
}
@@ -36,7 +36,7 @@ public class ReconciliationStatementController {
}
@GetMapping("/reconciliation-statements/{statementId}")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@PreAuthorize("hasAnyAuthority('platform:finance:customer-statement:list','platform:finance:bill:view')")
public ApiResponse<ReconciliationDetail> getReconciliationStatement(@PathVariable long statementId) {
return FinanceResponses.success(service.detailForPlatform(statementId));
}
@@ -56,7 +56,11 @@ public final class FinanceReadModels {
BigDecimal outstandingAmount,
String currency,
String status,
int version) {}
int version,
long createdBy,
LocalDateTime createdAt,
long updatedBy,
LocalDateTime updatedAt) {}
public record StatementDetail(Statement statement, List<StatementLine> lines) {}
@@ -18,6 +18,7 @@ public final class FinanceSettlementDtos {
public record CreateSettlementPaymentRequest(LocalDate transactionDate, BigDecimal amount, String currency,
String method, String referenceNo, List<String> voucherFileIds,
Integer expectedVersion, String remark) {}
public record SettlementActionRequest(String action, String reason, Integer expectedVersion) {}
public record SettlementSummary(long settlementId, String settlementNo, String direction, long customerId,
String customerName, Long supplierId, String supplierName,
@@ -23,7 +23,8 @@ public final class ReconciliationStatementDtos {
int creditDays, String currency, BigDecimal totalReceivable,
BigDecimal receivedAmount, BigDecimal outstandingAmount,
boolean settled, String status, int version,
LocalDateTime createdAt, LocalDateTime updatedAt, String remark,
long createdBy, LocalDateTime createdAt,
long updatedBy, LocalDateTime updatedAt, String remark,
List<ReconciliationLine> lines, List<PaymentHistory> paymentHistory,
List<ReconciliationRevision> revisions) {}
@@ -93,22 +93,24 @@ public class FinanceQueryService {
public List<Statement> statements(Long customerId, boolean clientVisibleOnly) {
validateOptionalCustomer(customerId);
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
String sql = "SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version FROM fin_bill WHERE 1=1";
String sql = "SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,created_by,created_at,updated_by,updated_at FROM fin_bill WHERE 1=1";
Object[] args = new Object[0];
if (customerId != null) { sql += " AND tenant_id=?"; args = new Object[]{customerId}; }
return jdbc.query(sql + visible + " ORDER BY bill_date DESC,id DESC",
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"),
rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"),
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version")), args);
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version"),
rs.getLong("created_by"), time(rs, "created_at"), rs.getLong("updated_by"), time(rs, "updated_at")), args);
}
public StatementDetail statement(long customerId, long billId, boolean clientVisibleOnly) {
requireCustomer(customerId);
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
List<Statement> rows = jdbc.query("SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version FROM fin_bill WHERE tenant_id=? AND id=?" + visible,
List<Statement> rows = jdbc.query("SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,created_by,created_at,updated_by,updated_at FROM fin_bill WHERE tenant_id=? AND id=?" + visible,
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"),
rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"),
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version")), customerId, billId);
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version"),
rs.getLong("created_by"), time(rs, "created_at"), rs.getLong("updated_by"), time(rs, "updated_at")), customerId, billId);
if (rows.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "账单不存在或不属于客户");
List<StatementLine> lines = jdbc.query("SELECT id,source_type,source_id,source_version,order_id,inbound_id,fee_item_code,fee_item_name,amount,currency_code FROM fin_bill_line WHERE tenant_id=? AND bill_id=? ORDER BY id",
(rs, rowNum) -> new StatementLine(rs.getLong("id"), rs.getString("source_type"), rs.getLong("source_id"), rs.getInt("source_version"),
@@ -9,6 +9,7 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementRevision;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementTransaction;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -250,6 +251,43 @@ public class FinanceSettlementService {
return getSettlement(settlementId);
}
@Transactional
public SettlementDetail actionSettlement(long settlementId, long operatorId, String key,
SettlementActionRequest request) {
validateSettlementAction(request);
Map<String, Object> settlement = one(
"SELECT id,tenant_id,total_amount,paid_amount,outstanding_amount,payment_status,status,version " +
"FROM fin_settlement WHERE id=? FOR UPDATE", "结算单不存在", settlementId);
long customerId = number(settlement, "tenant_id").longValue();
Long replay = reserve(customerId, operatorId, "SETTLEMENT_STATE_ACTION", key, "POST",
"/api/platform/finance/v1/settlements/" + settlementId + "/actions", request);
if (replay != null) return getSettlement(replay);
if (number(settlement, "version").intValue() != request.expectedVersion()) {
throw error("FIN_409", "结算单版本冲突");
}
String currentStatus = text(settlement, "status");
String targetStatus;
if ("VOID".equals(request.action())) {
if (!"ISSUED".equals(currentStatus) || decimal(settlement, "paid_amount").signum() != 0) {
throw error("FIN_409", "只有未发生收付款的生效结算单可以作废");
}
targetStatus = "VOIDED";
} else {
if (!"VOIDED".equals(currentStatus) || decimal(settlement, "paid_amount").signum() != 0) {
throw error("FIN_409", "只有未发生收付款的已作废结算单可以重开");
}
targetStatus = "ISSUED";
}
int changed = jdbc.update("UPDATE fin_settlement SET status=?,updated_by=?,version=version+1 " +
"WHERE id=? AND tenant_id=? AND version=?", targetStatus, operatorId, settlementId, customerId,
request.expectedVersion());
if (changed != 1) throw error("FIN_409", "结算单版本冲突");
audit(customerId, settlementId, request.action(), request.reason(), settlementStateSnapshot(settlement),
settlementStateSnapshot(settlement, targetStatus), operatorId);
complete(customerId, operatorId, "SETTLEMENT_STATE_ACTION", key, settlementId);
return getSettlement(settlementId);
}
private long insertSettlement(long customerId, String no, String direction, String customerName,
Long supplierId, String supplierName, Long reconciliationId,
String businessPeriod, String currency, BigDecimal total,
@@ -308,6 +346,14 @@ public class FinanceSettlementService {
if (request.remark() != null && request.remark().length() > 500) throw error("FIN_400", "付款备注不能超过500字");
}
private void validateSettlementAction(SettlementActionRequest request) {
if (request == null || !Set.of("VOID", "REOPEN").contains(request.action())
|| !StringUtils.hasText(request.reason()) || request.reason().length() > 500
|| request.expectedVersion() == null || request.expectedVersion() < 0) {
throw error("FIN_400", "结算单操作、原因或版本无效");
}
}
private void validateVoucherFiles(long customerId, List<String> fileIds) {
if (fileClient == null || !StringUtils.hasText(fileToken)) throw error("FIN_503", "File服务校验能力不可用");
for (String raw : fileIds) {
@@ -435,6 +481,16 @@ public class FinanceSettlementService {
"outstandingAmount", totalAmount.subtract(paidAmount));
}
private static Map<String, Object> settlementStateSnapshot(Map<String, Object> settlement) {
return settlementStateSnapshot(settlement, text(settlement, "status"));
}
private static Map<String, Object> settlementStateSnapshot(Map<String, Object> settlement, String status) {
return Map.of("totalAmount", decimal(settlement, "total_amount"),
"paidAmount", decimal(settlement, "paid_amount"),
"outstandingAmount", decimal(settlement, "outstanding_amount"), "status", status);
}
private String documentNo(String prefix) {
return prefix + "-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" +
UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase(Locale.ROOT);
@@ -169,8 +169,8 @@ public class ReconciliationStatementService {
(rs, row) -> revision(rs, row + 1), customerId, statementId);
return new ReconciliationDetail(bill.id(), bill.number(), bill.customerId(), bill.customerName(),
metadata.periodStart(), metadata.periodEnd(), bill.creditDays(), "CNY", bill.total(), bill.received(),
bill.outstanding(), bill.outstanding().signum() == 0, reconciliationStatus(bill.status()), bill.version(), bill.createdAt(),
bill.updatedAt(), bill.remark(), lines, payments, revisions);
bill.outstanding(), bill.outstanding().signum() == 0, reconciliationStatus(bill.status()), bill.version(),
bill.createdBy(), bill.createdAt(), bill.updatedBy(), bill.updatedAt(), bill.remark(), lines, payments, revisions);
}
public ReconciliationDetail detailForPlatform(long statementId) {
@@ -420,7 +420,8 @@ public class ReconciliationStatementService {
return new Bill(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"),
rs.getString("customer_name_snapshot"), rs.getString("status"), rs.getBigDecimal("total_amount"),
rs.getBigDecimal("received_amount"), rs.getBigDecimal("outstanding_amount"), rs.getInt("version"),
rs.getInt("credit_days"), rs.getString("remark"), rs.getTimestamp("created_at").toLocalDateTime(),
rs.getInt("credit_days"), rs.getString("remark"), rs.getLong("created_by"),
rs.getTimestamp("created_at").toLocalDateTime(), rs.getLong("updated_by"),
rs.getTimestamp("updated_at").toLocalDateTime());
}
@@ -461,5 +462,6 @@ public class ReconciliationStatementService {
String feeItemName, BigDecimal amount) {}
private record Bill(long id, long customerId, String number, String customerName, String status,
BigDecimal total, BigDecimal received, BigDecimal outstanding, int version,
int creditDays, String remark, LocalDateTime createdAt, LocalDateTime updatedAt) {}
int creditDays, String remark, long createdBy, LocalDateTime createdAt,
long updatedBy, LocalDateTime updatedAt) {}
}
@@ -15,18 +15,20 @@ class FinancePhase1PermissionContractTest {
@Test
void freezesG1AndG2ControllerPermissionsAndHttpActions() throws Exception {
assertEndpoint("listDerivedCustomerAccounts", GetMapping.class,
"hasAuthority('platform:finance:account:view')", int.class, int.class, String.class, String.class);
"platform:finance:customer-account:list", "platform:finance:account:view",
int.class, int.class, String.class, String.class);
assertEndpoint("listDerivedCustomerAccountLedger", GetMapping.class,
"hasAuthority('platform:finance:account:view')", long.class, int.class, int.class,
"platform:finance:customer-account:list", "platform:finance:account:view",
long.class, int.class, int.class,
String.class, java.time.LocalDate.class, java.time.LocalDate.class);
assertEndpoint("getFinanceFeeSource", GetMapping.class,
"hasAuthority('platform:finance:fee-source:view')", long.class);
"platform:finance:customer-arap:list", "platform:finance:fee-source:view", long.class);
assertEndpoint("reviewFinanceFeeSource", PostMapping.class,
"hasAuthority('platform:finance:fee-source:review')",
"platform:finance:fee-source:review", null,
com.baiyue.common.security.AuthenticatedUser.class, long.class, String.class,
com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest.class);
assertEndpoint("updateFeeSourceSettlementInclusion", PutMapping.class,
"hasAuthority('platform:finance:fee-source:settlement')",
"platform:finance:fee-source:settlement", null,
com.baiyue.common.security.AuthenticatedUser.class, long.class, String.class,
com.baiyue.finance.dto.FinancePhase1Dtos.SettlementInclusionRequest.class);
}
@@ -34,9 +36,13 @@ class FinancePhase1PermissionContractTest {
private static void assertEndpoint(String methodName,
Class<? extends java.lang.annotation.Annotation> mapping,
String authority,
String compatibleAuthority,
Class<?>... parameterTypes) throws Exception {
Method method = FinancePhase1OperationController.class.getMethod(methodName, parameterTypes);
assertThat(method.isAnnotationPresent(mapping)).isTrue();
assertThat(method.getAnnotation(PreAuthorize.class).value()).isEqualTo(authority);
assertThat(method.getAnnotation(PreAuthorize.class).value()).contains(authority);
if (compatibleAuthority != null) {
assertThat(method.getAnnotation(PreAuthorize.class).value()).contains(compatibleAuthority);
}
}
}
@@ -28,12 +28,12 @@ class FinancePlatformListContractTest {
@Test
void platformListPermissionsRemainSeparatedByReceivableAndPayableData() throws Exception {
Map<String, String> expected = Map.of(
"feePool", "hasAuthority('platform:finance:bill:view')",
"statements", "hasAuthority('platform:finance:bill:view')",
"receivableSettlements", "hasAuthority('platform:finance:settlement-ar:view')",
"payableSettlements", "hasAuthority('platform:finance:settlement-ap:view')");
"feePool", "platform:finance:customer-arap:list",
"statements", "platform:finance:customer-statement:list",
"receivableSettlements", "platform:finance:settlement-ar:view",
"payableSettlements", "platform:finance:settlement-ap:view");
for (var entry : expected.entrySet()) {
assertThat(find(entry.getKey()).getAnnotation(PreAuthorize.class).value()).isEqualTo(entry.getValue());
assertThat(find(entry.getKey()).getAnnotation(PreAuthorize.class).value()).contains(entry.getValue());
}
}
@@ -9,6 +9,7 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.CreateReceivableSettlementRe
import com.baiyue.finance.dto.FinanceSettlementDtos.CreateSettlementPaymentRequest;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinanceSettlementService;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -161,6 +162,55 @@ class FinanceSettlementBehaviorTest {
verify(jdbc, never()).update(startsWith("INSERT INTO fin_settlement_payment"), (Object[]) any());
}
@Test
void unpaidSettlementCanBeVoidedAndReopenedWithAuditAndVersionCheck() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation ->
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation));
Map<String, Object> issued = settlementRow("PAYABLE", "ISSUED", BigDecimal.ZERO,
new BigDecimal("100.00"), 2);
Map<String, Object> voided = settlementRow("PAYABLE", "VOIDED", BigDecimal.ZERO,
new BigDecimal("100.00"), 3);
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,total_amount"), eq(88L)))
.thenReturn(List.of(issued));
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L),
eq("SETTLEMENT_STATE_ACTION"), eq("void-key"))).thenReturn(List.of());
stubSettlementDetail(jdbc, voided);
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
SettlementDetail result = service.actionSettlement(88L, 7L, "void-key",
new SettlementActionRequest("VOID", "业务取消", 2));
assertThat(result.summary().status()).isEqualTo("VOIDED");
verify(jdbc).update(startsWith("UPDATE fin_settlement SET status="), eq("VOIDED"), eq(7L),
eq(88L), eq(10L), eq(2));
verify(jdbc).update(startsWith("INSERT INTO fin_audit_log"), eq(10L), eq("FIN_SETTLEMENT"),
eq(88L), eq("VOID"), eq("业务取消"), any(), any(), eq(7L), any(), any(), any());
}
@Test
void settlementActionRejectsPaidInvalidStateAndStaleVersion() {
JdbcTemplate paidJdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(paidJdbc.queryForList(startsWith("SELECT id,tenant_id,total_amount"), eq(88L)))
.thenReturn(List.of(settlementRow("PAYABLE", "PARTIALLY_PAID", new BigDecimal("40.00"),
new BigDecimal("60.00"), 2)));
when(paidJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L),
eq("SETTLEMENT_STATE_ACTION"), anyString())).thenReturn(List.of());
FinanceSettlementService paidService = service(paidJdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertCode(() -> paidService.actionSettlement(88L, 7L, "paid-key",
new SettlementActionRequest("VOID", "不能作废", 2)), "FIN_409");
JdbcTemplate staleJdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(staleJdbc.queryForList(startsWith("SELECT id,tenant_id,total_amount"), eq(88L)))
.thenReturn(List.of(settlementRow("PAYABLE", "VOIDED", BigDecimal.ZERO,
new BigDecimal("100.00"), 3)));
when(staleJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L),
eq("SETTLEMENT_STATE_ACTION"), eq("stale-action"))).thenReturn(List.of());
FinanceSettlementService staleService = service(staleJdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertCode(() -> staleService.actionSettlement(88L, 7L, "stale-action",
new SettlementActionRequest("REOPEN", "恢复结算", 2)), "FIN_409");
verify(staleJdbc, never()).update(startsWith("UPDATE fin_settlement SET status="), (Object[]) any());
}
@Test
void partialPaymentUpdatesOutstandingAndReturnsTypedDetail() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation ->
@@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.http.ResponseEntity;
/** Validates that a referenced File object belongs to the exact loading business resource. */
@FeignClient(name = "file-service", contextId = "firstlegLoadingDocumentFileClient",
@@ -24,6 +25,10 @@ public interface LoadingDocumentFileClient {
@PathVariable long tenantId, @PathVariable String businessType,
@PathVariable String businessId, @PathVariable long fileId);
@GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}/content")
ResponseEntity<byte[]> download(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId,@PathVariable String businessType,@PathVariable String businessId,@PathVariable long fileId);
record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
String fileName, String sha256, String contentType, Long sizeBytes) {}
}
@@ -15,6 +15,8 @@ import com.baiyue.firstleg.service.LoadingListService;
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.dto.ContainerOverviewDtos.*;
import com.baiyue.firstleg.service.ContainerOverviewService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
@@ -23,6 +25,10 @@ import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
import java.nio.charset.StandardCharsets;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
@@ -34,16 +40,39 @@ import org.springframework.web.multipart.MultipartFile;
public class PlatformContainerController {
@Resource private ContainerService service;
@Resource private LoadingListService loadingLists;
@Resource private ContainerOverviewService overview;
@GetMapping @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ApiResponse<PageResult<ContainerBookingEntity>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid Query query) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.page(query));
}
@GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:view')")
@GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ApiResponse<ContainerBookingEntity> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(id));
}
@GetMapping("/{id}/overview") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ApiResponse<Overview> overview(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(overview.overview(id));
}
@PostMapping("/{id}/exception") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
public ApiResponse<Void> markException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ExceptionRequest request) {
FirstlegScopeGuard.platform(user); overview.mark(id,user.userId(),user.username(),key,request); return ApiResponse.success(null);
}
@DeleteMapping("/{id}/exception") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
public ApiResponse<Void> cancelException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ExceptionRequest request) {
FirstlegScopeGuard.platform(user); overview.cancel(id,user.userId(),user.username(),key,request); return ApiResponse.success(null);
}
@GetMapping("/{id}/documents/{documentType}") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ApiResponse<BusinessDocument> logisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType){FirstlegScopeGuard.platform(user);return ApiResponse.success(loadingLists.containerLogisticsDocument(id,documentType));}
@PostMapping(value="/{id}/documents/{documentType}/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
public ApiResponse<BusinessDocument> uploadLogisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@RequestHeader("Idempotency-Key") String key,@RequestPart("file") MultipartFile file,@RequestPart("expectedVersion") @Min(0) int version,@RequestPart(value="operationReason",required=false) String reason){FirstlegScopeGuard.platform(user);return ApiResponse.success(loadingLists.uploadContainerLogisticsDocument(id,documentType,user.userId(),user.username(),key,version,reason,file));}
@GetMapping("/{id}/documents/{documentType}/{fileId}/download") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ResponseEntity<byte[]> downloadDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@PathVariable @Positive long fileId){FirstlegScopeGuard.platform(user);var d=loadingLists.downloadContainerDocument(id,documentType,fileId);return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header(HttpHeaders.CONTENT_DISPOSITION,org.springframework.http.ContentDisposition.attachment().filename(d.fileName(),StandardCharsets.UTF_8).build().toString()).contentType(d.contentType()).contentLength(d.content().length).body(d.content());}
@PostMapping @PreAuthorize("hasAuthority('platform:firstleg:container:create')")
@BusinessAudit(event="CONTAINER_CREATE", resourceType="CONTAINER", description="新增货柜基础资料", captureBefore=false)
public ApiResponse<ContainerBookingEntity> create(@AuthenticationPrincipal AuthenticatedUser user,
@@ -58,7 +58,7 @@ public class PlatformLoadingListController {
@Operation(summary = "查询装柜详情", description = "装柜中可维护未拣货明细;完成装柜后永久只读。")
@GetMapping("/loading-plans/{planId}")
@PreAuthorize("hasAuthority('platform:firstleg:preload:view')")
@PreAuthorize("hasAuthority('platform:firstleg:preload:list')")
public ApiResponse<PreloadDetail> preloadDetail(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
@RequestParam @Positive long warehouseId) {
@@ -91,7 +91,7 @@ public class PlatformLoadingListController {
@Operation(summary = "查询完成装柜清单明细", description = "已完成主单和明细全部只读。")
@GetMapping("/loading-lists/{planId}")
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:view')")
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')")
public ApiResponse<ConfirmedDetail> confirmedDetail(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
@RequestParam @Positive long warehouseId) {
@@ -101,7 +101,7 @@ public class PlatformLoadingListController {
@Operation(summary = "查询装柜清单出口报关文档")
@GetMapping("/loading-lists/{planId}/customs")
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:view')")
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')")
public ApiResponse<BusinessDocument> declaration(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
@RequestParam @Positive long warehouseId) {
@@ -2,9 +2,9 @@ package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.*; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.TruckAppointmentDtos.*; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import com.baiyue.firstleg.service.*; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; import jakarta.validation.constraints.Positive; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime;
@Tag(name="拖车预约") @Validated @RestController @RequestMapping("/api/platform/v1/truck-appointments") public class PlatformTruckAppointmentController { @Resource TruckAppointmentService service;
@GetMapping @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse<PageResult<TruckAppointmentEntity>> page(@AuthenticationPrincipal AuthenticatedUser u,@Valid Query q){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.page(q));}
@GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:view')") public ApiResponse<TruckAppointmentEntity> detail(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.detail(id));}
@GetMapping("/{id}/history") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:view')") public ApiResponse<java.util.List<com.baiyue.firstleg.entity.TruckAppointmentHistoryEntity>> history(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.history(id));}
@GetMapping("/{id}/attachments") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:view')") public ApiResponse<java.util.List<com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity>> attachments(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.attachments(id));}
@GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse<TruckAppointmentEntity> detail(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.detail(id));}
@GetMapping("/{id}/history") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse<java.util.List<com.baiyue.firstleg.entity.TruckAppointmentHistoryEntity>> history(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.history(id));}
@GetMapping("/{id}/attachments") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse<java.util.List<com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity>> attachments(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.attachments(id));}
@PostMapping("/{id}/attachments") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:edit')") public ApiResponse<com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity> attach(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id,@Valid @RequestBody AttachmentRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.attach(id,r,u.userId()));}
@PostMapping(value="/{id}/attachments/upload",consumes="multipart/form-data") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:edit')") public ApiResponse<com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity> upload(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id,@RequestParam(required=false) String attachmentType,@RequestPart("file") org.springframework.web.multipart.MultipartFile file){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.upload(id,attachmentType,file,u.userId()));}
@DeleteMapping("/{id}/attachments/{attachmentId}") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:edit')") public ApiResponse<Void> deleteAttachment(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id,@PathVariable @Positive long attachmentId){FirstlegScopeGuard.platform(u);service.deleteAttachment(id,attachmentId);return ApiResponse.success(null);}
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
public final class ContainerOverviewDtos {
private ContainerOverviewDtos() {}
public record Overview(ContainerBookingEntityView container, Long loadingPlanId, PdaSummary pda,
List<Document> documents, ExceptionState exception, List<ExceptionHistory> exceptionHistory,
List<TimelineEntry> timeline) {}
public record ContainerBookingEntityView(Long id, String bookingNo, String cabinetNo, String containerNo,
String status, Long warehouseId, String warehouseName, String voyageNo, String vesselName,
Integer version) {}
public record PdaSummary(Long taskId, Long loadingPlanId, Long containerBookingId, String taskStatus,
Integer taskVersion, Integer itemCount, Integer completedItemCount, Integer unresolvedItemCount,
List<PhotoCount> photoCounts, List<Photo> photos, List<String> exceptionReasons) {}
public record PhotoCount(String evidenceType, Integer count) {}
public record Photo(Long evidenceId, Long tenantId, Long inboundId, String evidenceType,
Long fileId, String fileName, String status, String downloadPath, LocalDateTime uploadedAt) {}
public record Document(Long id, String documentType, Long fileId, String fileName, String fileHash,
Long uploadedBy, String uploadedByName, LocalDateTime uploadedAt, Integer version, boolean completed) {}
public record ExceptionState(boolean active, String reason, Long markedBy, String markedByName, LocalDateTime markedAt) {}
public record ExceptionHistory(Long id, String action, String reason, Long operatorId, String operatorName, LocalDateTime occurredAt) {}
public record TimelineEntry(String eventType, String detail, Long operatorId, String operatorName, LocalDateTime occurredAt) {}
public record ExceptionRequest(@NotNull @Min(0) Integer expectedVersion, @NotBlank @Size(max=500) String reason) {}
}
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter @Setter
@TableName("fl_container_transport_history")
@@ -18,4 +19,5 @@ public class ContainerTransportHistoryEntity {
private String reason;
private Long operatorId;
private String operatorName;
private LocalDateTime createdAt;
}
@@ -61,6 +61,9 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
""")
ContainerBookingEntity selectBooking(@Param("id") long id);
@Select("SELECT * FROM fl_container_booking WHERE id=#{id} FOR UPDATE")
ContainerBookingEntity selectBookingForUpdate(@Param("id") long id);
@Update("""
UPDATE fl_container_booking SET
container_no=#{value.containerNo}, cabinet_no=#{value.cabinetNo},
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.mapper;
import com.baiyue.firstleg.entity.ContainerDocumentEntity;
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 ('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,operator_name,occurred_at 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);
@Select("SELECT action,reason,operator_id operatorId,operator_name operatorName,occurred_at occurredAt FROM fl_container_exception_history WHERE container_booking_id=#{id} ORDER BY id DESC LIMIT 1") ExceptionRow latestException(@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=#{bookingId} AND operator_id=#{operatorId} AND operation_id=#{operationId} LIMIT 1") ExceptionRow exceptionOperation(@Param("bookingId") long bookingId,@Param("operatorId") long operatorId,@Param("operationId") String operationId);
@Select("SELECT * FROM fl_loading_pick_task WHERE container_booking_id=#{id} ORDER BY id DESC LIMIT 1") PickTaskRow pickTask(@Param("id") long id);
@Select("SELECT COUNT(*) FROM fl_loading_pick_item WHERE pick_task_id=#{id}") int itemCount(@Param("id") long id);
@Select("SELECT COUNT(*) FROM fl_loading_pick_item WHERE pick_task_id=#{id} AND item_status IN ('PICKED','PARTIAL','SKIPPED')") int completedItemCount(@Param("id") long id);
@Select("SELECT COUNT(*) FROM fl_loading_pick_item WHERE pick_task_id=#{id} AND item_status NOT IN ('PICKED','PARTIAL','SKIPPED')") int unresolvedItemCount(@Param("id") long id);
@Select("SELECT DISTINCT h.operation_reason FROM fl_loading_pick_history h WHERE h.pick_task_id=#{id} AND h.operation_reason IS NOT NULL AND h.operation_reason<>'' ORDER BY h.id") List<String> pickExceptionReasons(@Param("id") long id);
@Select("SELECT DISTINCT e.id evidenceId,e.tenant_id tenantId,e.inbound_id inboundId,e.evidence_type evidenceType,e.file_id fileId,e.file_name fileName,e.status,e.created_at uploadedAt FROM fl_loading_evidence e JOIN fl_loading_plan_item i ON i.inbound_id=e.inbound_id AND i.account_id=e.tenant_id WHERE i.loading_plan_id=#{id} AND i.deleted=0 ORDER BY e.created_at,e.id") List<PhotoRow> photos(@Param("id") long id);
@Select("SELECT action_type eventType,operation_id detail,operator_id operatorId,operator_name operatorName,created_at occurredAt FROM fl_loading_pick_operation WHERE pick_task_id=#{id} ORDER BY created_at,id") List<TimelineRow> pickTimeline(@Param("id") long id);
record ExceptionRow(Long id,String action,String reason,Long operatorId,String operatorName,java.time.LocalDateTime occurredAt,String requestHash) {}
record PickTaskRow(Long id,Long loadingPlanId,Long containerBookingId,String taskStatus,Integer version) {}
record TimelineRow(String eventType,String detail,Long operatorId,String operatorName,java.time.LocalDateTime occurredAt) {}
record PhotoRow(Long evidenceId,Long tenantId,Long inboundId,String evidenceType,Long fileId,String fileName,String status,java.time.LocalDateTime uploadedAt) {}
}
@@ -26,7 +26,7 @@ public class PlatformOceanBookingController {
@GetMapping @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')")
public ApiResponse<PageResult<OceanBookingEntity>> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.page(query)); }
@GetMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:view')")
@GetMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')")
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(oceanBookingId)); }
@PostMapping @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:create')")
@@ -16,6 +16,7 @@ public class ContainerRepository {
return PageResult.from(mapper.selectBookingPage(new Page<>(query.page(), query.size()), query));
}
public ContainerBookingEntity find(long id) { return mapper.selectBooking(id); }
public ContainerBookingEntity findForUpdate(long id) { return mapper.selectBookingForUpdate(id); }
public boolean containerNoExists(String no, Long excludedId) {
if (no == null) return false;
return mapper.selectCount(new LambdaQueryWrapper<ContainerBookingEntity>()
@@ -0,0 +1,152 @@
package com.baiyue.firstleg.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.dto.ContainerOverviewDtos.*;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.ContainerDocumentEntity;
import com.baiyue.firstleg.mapper.ContainerOverviewMapper;
import com.baiyue.firstleg.repository.ContainerRepository;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
@Service
public class ContainerOverviewService {
@Resource private ContainerRepository containers;
@Resource private ContainerOverviewMapper mapper;
public Overview overview(long id) {
ContainerBookingEntity booking = required(id);
var task = mapper.pickTask(id);
var documents = mapper.documents(id).stream().map(this::document).toList();
var latestException = mapper.latestException(id);
var exceptionHistory = mapper.exceptions(id).stream().map(value -> new ExceptionHistory(
value.id(), value.action(), value.reason(), value.operatorId(), value.operatorName(), value.occurredAt()))
.toList();
PdaSummary pda = task == null ? null : pda(task);
List<TimelineEntry> timeline = timeline(id, task == null ? null : task.id(), exceptionHistory);
return new Overview(new ContainerBookingEntityView(booking.getId(), booking.getBookingNo(),
booking.getCabinetNo(), booking.getContainerNo(), booking.getPlanStatus(), booking.getWarehouseId(),
booking.getWarehouseName(), booking.getVoyageNo(), booking.getVesselName(), booking.getVersion()),
task == null ? booking.getPlanId() : task.loadingPlanId(), pda, documents,
new ExceptionState(latestException != null && "MARK".equals(latestException.action()),
latestException == null ? null : latestException.reason(),
latestException == null ? null : latestException.operatorId(),
latestException == null ? null : latestException.operatorName(),
latestException == null ? null : latestException.occurredAt()), exceptionHistory, timeline);
}
@Transactional
public void mark(long id, long operatorId, String operatorName, String idempotencyKey,
ExceptionRequest request) {
changeException(id, operatorId, operatorName, idempotencyKey, request, "MARK");
}
@Transactional
public void cancel(long id, long operatorId, String operatorName, String idempotencyKey,
ExceptionRequest request) {
changeException(id, operatorId, operatorName, idempotencyKey, request, "CANCEL");
}
private void changeException(long id, long operatorId, String operatorName, String rawKey,
ExceptionRequest request, String action) {
String operationId = operationId(rawKey);
String requestHash = sha256(action + '|' + id + '|' + request.expectedVersion() + '|'
+ request.reason().trim());
var replay = mapper.exceptionOperation(id, operatorId, operationId);
if (replay != null) {
if (!requestHash.equals(replay.requestHash())) throw conflict("Idempotency-Key已用于不同异常操作");
return;
}
ContainerBookingEntity booking = containers.findForUpdate(id);
if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
if (!request.expectedVersion().equals(booking.getVersion())) throw conflict("货柜版本已变化,请刷新后重试");
replay = mapper.exceptionOperation(id, operatorId, operationId);
if (replay != null) {
if (!requestHash.equals(replay.requestHash())) throw conflict("Idempotency-Key已用于不同异常操作");
return;
}
var latest = mapper.latestException(id);
if ("MARK".equals(action) && latest != null && "MARK".equals(latest.action())) throw conflict("货柜已标记异常");
if ("CANCEL".equals(action) && (latest == null || !"MARK".equals(latest.action()))) throw conflict("货柜当前未标记异常");
try {
mapper.insertException(IdWorker.getId(), id, action, request.reason().trim(), operatorId,
operatorName, operationId, requestHash);
} catch (DuplicateKeyException exception) {
var concurrent = mapper.exceptionOperation(id, operatorId, operationId);
if (concurrent == null || !requestHash.equals(concurrent.requestHash())) {
throw conflict("Idempotency-Key已被并发用于不同异常操作");
}
}
}
private PdaSummary pda(ContainerOverviewMapper.PickTaskRow task) {
List<Photo> photos = mapper.photos(task.loadingPlanId()).stream().map(value -> new Photo(value.evidenceId(),
value.tenantId(), value.inboundId(), value.evidenceType(), value.fileId(), value.fileName(),
value.status(), "/api/platform/v1/files/loading-evidence/" + value.tenantId() + '/'
+ value.inboundId() + '/' + value.fileId(), value.uploadedAt())).toList();
var counts = new LinkedHashMap<String, Integer>();
photos.stream().filter(photo -> "ACTIVE".equals(photo.status()))
.forEach(photo -> counts.merge(photo.evidenceType(), 1, Integer::sum));
List<PhotoCount> photoCounts = counts.entrySet().stream()
.map(entry -> new PhotoCount(entry.getKey(), entry.getValue())).toList();
return new PdaSummary(task.id(), task.loadingPlanId(), task.containerBookingId(), task.taskStatus(),
task.version(), mapper.itemCount(task.id()), mapper.completedItemCount(task.id()),
mapper.unresolvedItemCount(task.id()), photoCounts, photos, mapper.pickExceptionReasons(task.id()));
}
private List<TimelineEntry> timeline(long id, Long taskId, List<ExceptionHistory> exceptionHistory) {
var result = new ArrayList<TimelineEntry>();
mapper.transport(id).forEach(value -> result.add(new TimelineEntry("TRANSPORT_UPDATE", value.getReason(),
value.getOperatorId(), value.getOperatorName(), value.getCreatedAt())));
exceptionHistory.forEach(value -> result.add(new TimelineEntry(value.action(), value.reason(),
value.operatorId(), value.operatorName(), value.occurredAt())));
if (taskId != null) mapper.pickTimeline(taskId).forEach(value -> result.add(new TimelineEntry(
"PDA_" + value.eventType(), value.detail(), value.operatorId(), value.operatorName(), value.occurredAt())));
result.sort(Comparator.comparing(TimelineEntry::occurredAt));
return result;
}
private ContainerBookingEntity required(long id) {
ContainerBookingEntity value = containers.find(id);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
return value;
}
private Document document(ContainerDocumentEntity value) {
return new Document(value.getId(), value.getDocumentType(), value.getFileId(), value.getFileName(),
value.getFileHash(), value.getUploadedBy(), value.getUploadedByName(), value.getCreatedAt(),
value.getVersion(), value.getCompletedAt() != null);
}
private static String operationId(String value) {
if (value == null || value.isBlank()) throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key不能为空");
String result = value.trim();
if (result.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key长度不能超过128");
return result;
}
private static String sha256(String value) {
try {
return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException(exception);
}
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
}
@@ -31,6 +31,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.http.MediaType;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@@ -45,8 +46,12 @@ 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_DOCUMENT";
private static final String DO_FILE_TYPE = "CONTAINER_DELIVERY_ORDER";
private static final String PU_FILE_TYPE = "CONTAINER_PICKUP_ORDER";
@Resource
private LoadingListRepository repository;
@Resource
@@ -96,6 +101,25 @@ public class LoadingListService {
requireBookingPlan(bookingId);
return document(repository.document(bookingId, DESTINATION_CLEARANCE));
}
public BusinessDocument containerLogisticsDocument(long bookingId,String type){ requireLogisticsType(type); requireBookingPlan(bookingId); return document(repository.document(bookingId,type)); }
public BusinessDocument uploadContainerLogisticsDocument(long bookingId,String type,long operatorId,String operatorName,String key,int expectedVersion,String reason,MultipartFile file){
requireLogisticsType(type); String businessType=fileType(type); return uploadDocument(requireBookingPlan(bookingId),type,businessType,bookingId,Long.toString(bookingId),operatorId,operatorName,key,expectedVersion,reason,file);
}
public DocumentDownload downloadContainerDocument(long bookingId,String type,long fileId){
requireLogisticsType(type); String businessType=fileType(type); requireBookingPlan(bookingId); ContainerDocumentEntity d=requireDocumentEntity(bookingId,type); if(!Long.valueOf(fileId).equals(d.getFileId())) throw notFound("货柜资料不存在");
String businessId=Long.toString(bookingId);
try {
var metadata=documentFiles.metadata(internalToken,0,businessType,businessId,fileId);
if(metadata==null||!metadata.successful()||metadata.data()==null||!Long.valueOf(fileId).equals(metadata.data().fileId())||!Long.valueOf(0).equals(metadata.data().tenantId())||!businessType.equals(metadata.data().businessType())||!businessId.equals(metadata.data().businessId())) throw notFound("货柜资料不存在或归属不匹配");
var response=documentFiles.download(internalToken,0,businessType,businessId,fileId); if(response==null||response.getBody()==null) throw notFound("货柜资料不存在");
MediaType contentType; try{contentType=MediaType.parseMediaType(metadata.data().contentType());}catch(RuntimeException exception){contentType=MediaType.APPLICATION_OCTET_STREAM;}
return new DocumentDownload(d.getFileName(),contentType,response.getBody());
}
catch(FeignException e){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}
}
public record DocumentDownload(String fileName,MediaType contentType,byte[] content) {}
private static void requireLogisticsType(String type){ if(!DELIVERY_ORDER.equals(type)&&!PICKUP_ORDER.equals(type)) throw badRequest("documentType只能是DELIVERY_ORDER或PICKUP_ORDER"); }
private static String fileType(String type){ return switch(type){case DELIVERY_ORDER->DO_FILE_TYPE; case PICKUP_ORDER->PU_FILE_TYPE; default->throw badRequest("documentType只能是DELIVERY_ORDER或PICKUP_ORDER");}; }
@Transactional
@BusinessAudit(event = "CONTAINER_CLEARANCE_DOCUMENT_ATTACH", resourceType = "CONTAINER",
@@ -0,0 +1,41 @@
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_container_exception_history (
id BIGINT UNSIGNED NOT NULL, container_booking_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(16) NOT NULL COMMENT 'MARK/CANCEL', reason VARCHAR(500) NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL, operator_name VARCHAR(64),
operation_id VARCHAR(128) NOT NULL COMMENT '同一操作人幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求摘要',
occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE KEY uk_container_exception_operation(container_booking_id,operator_id,operation_id),
KEY idx_container_exception_history(container_booking_id,occurred_at,id),
CONSTRAINT fk_container_exception_booking FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id),
CONSTRAINT chk_container_exception_action CHECK(action IN ('MARK','CANCEL'))
) ENGINE=InnoDB COMMENT='货柜异常标记及取消历史';
DROP PROCEDURE IF EXISTS phase3_container_exception_add_column;
DELIMITER $$
CREATE PROCEDURE phase3_container_exception_add_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_exception_history' AND column_name=p_name) THEN
SET @ddl=CONCAT('ALTER TABLE fl_container_exception_history ADD COLUMN ',p_name,' ',p_definition);
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
END IF;
END$$
DELIMITER ;
CALL phase3_container_exception_add_column('operation_id',"VARCHAR(128) NULL COMMENT '同一操作人幂等键' AFTER operator_name");
CALL phase3_container_exception_add_column('request_hash',"CHAR(64) NULL COMMENT '请求摘要' AFTER operation_id");
DROP PROCEDURE phase3_container_exception_add_column;
UPDATE fl_container_exception_history SET operation_id=CONCAT('LEGACY-',id) WHERE operation_id IS NULL OR operation_id='';
UPDATE fl_container_exception_history SET request_hash=SHA2(CONCAT(action,'|',container_booking_id,'|',id,'|',reason),256) WHERE request_hash IS NULL OR request_hash='';
ALTER TABLE fl_container_exception_history MODIFY operation_id VARCHAR(128) NOT NULL, MODIFY request_hash CHAR(64) NOT NULL;
SET @has_exception_operation_index=(SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='fl_container_exception_history' AND index_name='uk_container_exception_operation');
SET @ddl=IF(@has_exception_operation_index=0,'ALTER TABLE fl_container_exception_history ADD UNIQUE KEY uk_container_exception_operation(container_booking_id,operator_id,operation_id)','SELECT 1');
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
DROP TRIGGER IF EXISTS trg_container_exception_history_before_update;
DROP TRIGGER IF EXISTS trg_container_exception_history_before_delete;
DELIMITER $$
CREATE TRIGGER trg_container_exception_history_before_update BEFORE UPDATE ON fl_container_exception_history FOR EACH ROW
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜异常历史不可修改'; END$$
CREATE TRIGGER trg_container_exception_history_before_delete BEFORE DELETE ON fl_container_exception_history FOR EACH ROW
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜异常历史不可删除'; END$$
DELIMITER ;
@@ -206,6 +206,27 @@ CREATE TABLE IF NOT EXISTS fl_container_document (
CHECK (document_type IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER'))
) ENGINE=InnoDB COMMENT='平台货柜清关、DO与PU文件关联';
CREATE TABLE IF NOT EXISTS fl_container_exception_history (
id BIGINT UNSIGNED NOT NULL, container_booking_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(16) NOT NULL, reason VARCHAR(500) NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL, operator_name VARCHAR(64) NULL,
operation_id VARCHAR(128) NOT NULL, request_hash CHAR(64) NOT NULL,
occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id),
UNIQUE KEY uk_container_exception_operation(container_booking_id,operator_id,operation_id),
KEY idx_container_exception_history(container_booking_id,occurred_at,id),
CONSTRAINT fk_container_exception_booking FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id),
CONSTRAINT chk_container_exception_action CHECK(action IN ('MARK','CANCEL'))
) ENGINE=InnoDB COMMENT='货柜异常标记及取消原因历史';
DROP TRIGGER IF EXISTS trg_container_exception_history_before_update;
DROP TRIGGER IF EXISTS trg_container_exception_history_before_delete;
DELIMITER $$
CREATE TRIGGER trg_container_exception_history_before_update BEFORE UPDATE ON fl_container_exception_history FOR EACH ROW
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜异常历史不可修改'; END$$
CREATE TRIGGER trg_container_exception_history_before_delete BEFORE DELETE ON fl_container_exception_history FOR EACH ROW
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜异常历史不可删除'; END$$
DELIMITER ;
CREATE TABLE IF NOT EXISTS fl_loading_plan_history (
id BIGINT UNSIGNED NOT NULL COMMENT '装柜操作历史ID(雪花ID)',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID',
@@ -210,7 +210,7 @@ paths:
/api/platform/v1/containers/{id}:
get:
summary: 查询平台货柜详情
description: 需要 platform:firstleg:container:view 权限
description: 需要 platform:firstleg:container:list 权限
parameters:
- {$ref: '#/components/parameters/ContainerBookingId'}
responses:
@@ -25,8 +25,8 @@ paths:
/api/platform/v1/first-leg/loading-plans/{planId}:
get:
summary: 查询预装清单详情
x-permission: platform:firstleg:preload:list
description: 返回同一装柜计划的汇总、冻结明细和完整操作历史。
x-permission: platform:firstleg:preload:view
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}]
responses:
'200': {$ref: '#/components/responses/PreloadDetailResponse'}
@@ -63,15 +63,16 @@ paths:
/api/platform/v1/first-leg/loading-lists/{planId}:
get:
summary: 查询装柜清单详情
description: 返回已确认计划的汇总、冻结明细和操作历史,全部只读。
x-permission: platform:firstleg:loading-list:view
description: 返回已确认计划的汇总、冻结明细和操作历史,全部只读;查看权限与分页列表权限独立。
x-permission: platform:firstleg:loading-list:list
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:view
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'}
@@ -0,0 +1,60 @@
openapi: 3.0.3
info: {title: Firstleg Container Overview API, version: 1.0.0}
paths:
/api/platform/v1/containers/{id}/overview:
get:
operationId: getContainerOverview
description: '权限:platform:firstleg:container:list'
parameters: [{$ref: '#/components/parameters/ContainerId'}]
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/OverviewEnvelope'}}}}}
/api/platform/v1/containers/{id}/exception:
parameters: [{$ref: '#/components/parameters/ContainerId'}]
post: {operationId: markContainerException, description: '权限:platform:firstleg:container:edit', parameters: [{name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}], requestBody: {$ref: '#/components/requestBodies/ExceptionRequest'}, responses: {'200': {description: OK}}}
delete: {operationId: cancelContainerException, description: '权限:platform:firstleg:container:edit', parameters: [{name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}], requestBody: {$ref: '#/components/requestBodies/ExceptionRequest'}, responses: {'200': {description: OK}}}
/api/platform/v1/containers/{id}/documents/{documentType}:
get:
operationId: getContainerDocument
description: '权限:platform:firstleg:container:list;仅DO/PU,completed恒为false'
parameters: [{$ref: '#/components/parameters/ContainerId'}, {$ref: '#/components/parameters/DocumentType'}]
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}}}
/api/platform/v1/containers/{id}/documents/{documentType}/upload:
post:
operationId: uploadContainerDocument
description: '权限:platform:firstleg:container:edit;仅DO/PU,无完成动作'
parameters: [{$ref: '#/components/parameters/ContainerId'}, {$ref: '#/components/parameters/DocumentType'}, {name: Idempotency-Key, in: header, required: true, schema: {type: string}}]
requestBody: {required: true, content: {multipart/form-data: {schema: {type: object, required: [file, expectedVersion], properties: {file: {type: string, format: binary}, expectedVersion: {type: integer, minimum: 0}, operationReason: {type: string, maxLength: 500}}}}}}
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}}}
/api/platform/v1/containers/{id}/documents/{documentType}/{fileId}/download:
get:
operationId: downloadContainerDocument
description: '权限:platform:firstleg:container:list;Firstleg代理File内部下载,不向调用方暴露内部令牌'
parameters: [{$ref: '#/components/parameters/ContainerId'}, {$ref: '#/components/parameters/DocumentType'}, {name: fileId, in: path, required: true, schema: {type: integer, format: int64}}]
responses: {'200': {description: file bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}}
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 专用接口'}
requestBodies:
ExceptionRequest: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ExceptionRequest'}}}}
schemas:
OverviewEnvelope: {type: object, properties: {code: {type: string}, message: {type: string}, data: {$ref: '#/components/schemas/Overview'}}}
DocumentEnvelope: {type: object, properties: {code: {type: string}, message: {type: string}, data: {$ref: '#/components/schemas/Document'}}}
Overview:
type: object
properties:
container: {$ref: '#/components/schemas/Container'}
loadingPlanId: {type: integer, format: int64}
pda: {$ref: '#/components/schemas/PdaSummary'}
documents: {type: array, 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'}}
Container: {type: object, properties: {id: {type: integer, format: int64}, bookingNo: {type: string}, cabinetNo: {type: string}, containerNo: {type: string}, status: {type: string}, warehouseId: {type: integer, format: int64}, warehouseName: {type: string}, voyageNo: {type: string}, vesselName: {type: string}, version: {type: integer, minimum: 0}}}
PdaSummary: {type: object, properties: {taskId: {type: integer, format: int64}, loadingPlanId: {type: integer, format: int64}, containerBookingId: {type: integer, format: int64}, taskStatus: {type: string}, 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}, downloadPath: {type: string}, uploadedAt: {type: string, format: date-time}}}
Document: {type: object, properties: {id: {type: integer, format: int64}, documentType: {type: string}, 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}, 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}}}
ExceptionRequest: {type: object, required: [expectedVersion, reason], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, maxLength: 500}}}
@@ -17,11 +17,27 @@ class ContainerControllerContractTest {
@DisplayName("查询、新增和编辑按钮权限彼此独立")
void separatesButtonPermissions() {
assertPermission("page", "platform:firstleg:container:list");
assertPermission("detail", "platform:firstleg:container:view");
assertPermission("detail", "platform:firstleg:container:list");
assertPermission("create", "platform:firstleg:container:create");
assertPermission("updateDraft", "platform:firstleg:container:edit");
assertPermission("updateTransport", "platform:firstleg:container:edit");
assertPermission("uploadClearance", "platform:firstleg:customs:manage");
assertPermission("overview", "platform:firstleg:container:list");
assertPermission("markException", "platform:firstleg:container:edit");
assertPermission("cancelException", "platform:firstleg:container:edit");
assertPermission("logisticsDocument", "platform:firstleg:container:list");
assertPermission("uploadLogisticsDocument", "platform:firstleg:container:edit");
assertPermission("downloadDocument", "platform:firstleg:container:list");
}
@Test void overviewOpenApiFreezesContainerIdBasedPdaContract() throws Exception {
String openapi = new ClassPathResource("openapi/phase3-container-overview-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(openapi).contains("/api/platform/v1/containers/{id}/overview:",
"/documents/{documentType}/{fileId}/download:", "ExceptionRequest",
"enum: [DELIVERY_ORDER, PICKUP_ORDER]", "Idempotency-Key", "photoCounts:",
"downloadPath:", "version: {type: integer, minimum: 0}")
.doesNotContain("?containerNo", "containerNo in: query");
}
@Test
@@ -53,4 +69,5 @@ class ContainerControllerContractTest {
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + authority + "')");
}
}
@@ -29,13 +29,13 @@ class LoadingListControllerContractTest {
@Test
void permissionsMatch() throws Exception {
authority("preloadPage", "platform:firstleg:preload:list", AuthenticatedUser.class, PreloadQuery.class);
authority("preloadDetail", "platform:firstleg:preload:view", AuthenticatedUser.class, long.class, long.class);
authority("preloadDetail", "platform:firstleg:preload:list", AuthenticatedUser.class, long.class, long.class);
authority("updateTransport", "platform:firstleg:preload:edit", AuthenticatedUser.class,
long.class, long.class, String.class, TransportUpdateRequest.class);
authority("confirmedPage", "platform:firstleg:loading-list:list", AuthenticatedUser.class,
ConfirmedQuery.class);
authority("confirmedDetail", "platform:firstleg:loading-list:view", AuthenticatedUser.class, long.class, long.class);
authority("declaration", "platform:firstleg:loading-list:view", AuthenticatedUser.class, long.class, long.class);
authority("confirmedDetail", "platform:firstleg:loading-list:list", AuthenticatedUser.class, long.class, long.class);
authority("declaration", "platform:firstleg:loading-list:list", AuthenticatedUser.class, long.class, long.class);
authority("attachDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class,
long.class, long.class, String.class, DocumentAttachRequest.class);
authority("uploadDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class,
@@ -60,7 +60,7 @@ class LoadingListControllerContractTest {
.contains("expectedVersion")
.contains("createdFrom")
.contains("createdTo")
.contains("platform:firstleg:loading-list:view")
.contains("platform:firstleg:loading-list:list")
.contains("PreloadPageEnvelope")
.contains("ConfirmedPageEnvelope")
.contains("PreloadSummary")
@@ -21,8 +21,8 @@ class TruckAppointmentControllerContractTest {
@Test void openApiDocumentsLifecycleIdempotencyAndActualTimes() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/{id}/{action}:","/api/platform/v1/truck-appointments/{id}/reschedule:","name: Idempotency-Key","required: true","actual event timestamps");}
@Test void methodsHavePlatformPermissionsAndAuthenticatedScope() {
Map<String,String> expected=Map.ofEntries(
Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:view"),
Map.entry("history","platform:firstleg:truck-appointment:view"), Map.entry("attachments","platform:firstleg:truck-appointment:view"),
Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"),
Map.entry("history","platform:firstleg:truck-appointment:list"), Map.entry("attachments","platform:firstleg:truck-appointment:list"),
Map.entry("attach","platform:firstleg:truck-appointment:edit"), Map.entry("upload","platform:firstleg:truck-appointment:edit"),
Map.entry("deleteAttachment","platform:firstleg:truck-appointment:edit"), Map.entry("create","platform:firstleg:truck-appointment:create"),
Map.entry("edit","platform:firstleg:truck-appointment:edit"), Map.entry("action","platform:firstleg:truck-appointment:operate"),
@@ -18,7 +18,7 @@ class PlatformOceanBookingControllerContractTest {
.containsExactly("/api/platform/v1/ocean-bookings");
Map<String, String> permissions = Map.ofEntries(
Map.entry("page", "platform:firstleg:ocean-booking:list"),
Map.entry("detail", "platform:firstleg:ocean-booking:view"),
Map.entry("detail", "platform:firstleg:ocean-booking:list"),
Map.entry("create", "platform:firstleg:ocean-booking:create"),
Map.entry("updateDraft", "platform:firstleg:ocean-booking:edit"),
Map.entry("submit", "platform:firstleg:ocean-booking:submit"),
@@ -0,0 +1,25 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class ContainerOverviewSqlContractTest {
@Test void developmentAndUnifiedSchemasContainImmutableIdempotentExceptionHistory() throws Exception {
String development = new ClassPathResource("db/phase3-smart-loading-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
String migration = new ClassPathResource("db/phase3-container-overview.sql")
.getContentAsString(StandardCharsets.UTF_8);
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
for (String sql : java.util.List.of(development, migration, unified)) {
assertThat(sql).contains("fl_container_exception_history", "operation_id", "request_hash",
"uk_container_exception_operation", "trg_container_exception_history_before_update",
"trg_container_exception_history_before_delete");
}
}
}
@@ -0,0 +1,68 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.ContainerOverviewDtos.ExceptionRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.mapper.ContainerOverviewMapper;
import com.baiyue.firstleg.repository.ContainerRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class ContainerOverviewServiceTest {
@Mock ContainerRepository containers;
@Mock ContainerOverviewMapper mapper;
@InjectMocks ContainerOverviewService service;
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this); }
@Test void markLocksContainerAndPersistsIdempotentImmutableHistory() {
ContainerBookingEntity booking = new ContainerBookingEntity(); booking.setId(41L); booking.setVersion(3);
when(containers.findForUpdate(41L)).thenReturn(booking);
service.mark(41L, 7L, "operator", "mark-1", new ExceptionRequest(3, "broken seal"));
verify(containers).findForUpdate(41L);
verify(mapper).insertException(anyLong(), eq(41L), eq("MARK"), eq("broken seal"), eq(7L),
eq("operator"), eq("mark-1"), matches("[0-9a-f]{64}"));
}
@Test void exactReplayReturnsWithoutLockingOrWriting() {
String hash = hash("MARK|41|3|broken seal");
when(mapper.exceptionOperation(41L, 7L, "mark-1")).thenReturn(new ContainerOverviewMapper.ExceptionRow(
1L, "MARK", "broken seal", 7L, "operator", LocalDateTime.now(), hash));
service.mark(41L, 7L, "operator", "mark-1", new ExceptionRequest(3, "broken seal"));
verifyNoInteractions(containers);
verify(mapper, never()).insertException(anyLong(), anyLong(), anyString(), anyString(), anyLong(),
anyString(), anyString(), anyString());
}
@Test void reusedIdempotencyKeyWithDifferentRequestFailsClosed() {
when(mapper.exceptionOperation(41L, 7L, "mark-1")).thenReturn(new ContainerOverviewMapper.ExceptionRow(
1L, "MARK", "other", 7L, "operator", LocalDateTime.now(), "a".repeat(64)));
assertThatThrownBy(() -> service.mark(41L, 7L, "operator", "mark-1",
new ExceptionRequest(3, "broken seal"))).isInstanceOf(BizException.class)
.hasMessageContaining("不同异常操作");
verifyNoInteractions(containers);
}
private static String hash(String value) {
try {
return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
} catch (java.security.NoSuchAlgorithmException exception) {
throw new IllegalStateException(exception);
}
}
}
@@ -441,6 +441,17 @@ class LoadingListServiceTest {
verify(repository, never()).createDocument(org.mockito.ArgumentMatchers.any());
}
@Test
void genericContainerDocumentsOnlyAcceptDeliveryAndPickupOrders() {
assertThatThrownBy(() -> service.containerLogisticsDocument(30L,
LoadingListService.DESTINATION_CLEARANCE)).isInstanceOf(BizException.class)
.hasMessageContaining("DELIVERY_ORDER或PICKUP_ORDER");
assertThatThrownBy(() -> service.containerLogisticsDocument(30L,
LoadingListService.CUSTOMS_DECLARATION)).isInstanceOf(BizException.class)
.hasMessageContaining("DELIVERY_ORDER或PICKUP_ORDER");
verify(repository, never()).aggregateByBooking(30L);
}
private static AggregateRow row(String status, int version) {
AggregateRow row = new AggregateRow();
row.setPlanId(10L);
@@ -69,7 +69,7 @@ public class PlatformTenantController {
}
@GetMapping("/tenants/{tenantId:\\d+}")
@PreAuthorize("hasAuthority('platform:customer:view')")
@PreAuthorize("hasAnyAuthority('platform:customer:list','platform:customer:view')")
@Operation(summary = "查询企业客户档案", description = "返回企业基础资料、销售负责人、账期、注册地址以及入驻和审批时间。")
public ApiResponse<TenantResponse> tenant(@PathVariable long tenantId,
@AuthenticationPrincipal AuthenticatedUser user) {
@@ -89,7 +89,7 @@ public class PlatformTenantController {
}
@GetMapping("/tenants/{tenantId:\\d+}/members")
@PreAuthorize("hasAuthority('platform:customer:view')")
@PreAuthorize("hasAnyAuthority('platform:customer:list','platform:customer:view')")
@Operation(summary = "查询企业客户成员及账号", description = "平台只读查询指定企业的全部成员,包含 OWNER 企业管理员,并返回登录账号。")
public ApiResponse<List<PlatformCustomerMemberDtos.MemberResponse>> members(
@PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) {
@@ -115,7 +115,7 @@ public class ProfileResourceController {
}
@GetMapping("/api/platform/v1/iam/tenants/{tenantId}/qualifications")
@PreAuthorize("hasAuthority('platform:customer:view')")
@PreAuthorize("hasAnyAuthority('platform:customer:list','platform:customer:view')")
@Operation(summary = "查询企业客户资质", description = "只读查询企业客户资质元数据,不授予审核能力。")
public ApiResponse<List<IamTenantQualification>> platformQualifications(
@PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) {
@@ -124,7 +124,7 @@ public class ProfileResourceController {
}
@GetMapping("/api/platform/v1/iam/tenants/{tenantId}/addresses")
@PreAuthorize("hasAuthority('platform:customer:view')")
@PreAuthorize("hasAnyAuthority('platform:customer:list','platform:customer:view')")
@Operation(summary = "查询企业客户地址", description = "只读查询企业客户地址,不开放平台端写入。")
public ApiResponse<List<IamTenantAddress>> platformAddresses(
@PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) {
@@ -31,9 +31,9 @@ public class NotificationInboxController {
public ApiResponse<UnreadCountResponse> unread(@PathVariable String app,@AuthenticationPrincipal AuthenticatedUser user){String code=guard(app,user);return ApiResponse.success(service.unread(user.tenantId(),user.membershipId(),code));}
@GetMapping("/{id:\\d+}") @PreAuthorize("hasAuthority(#app + ':notification:view')")
public ApiResponse<NotificationDetail> detail(@PathVariable String app,@PathVariable Long id,@AuthenticationPrincipal AuthenticatedUser user){String code=guard(app,user);return ApiResponse.success(service.detail(user.tenantId(),user.membershipId(),code,id));}
@PutMapping("/{id:\\d+}/read") @PreAuthorize("hasAuthority(#app + ':notification:view')")
@PutMapping("/{id:\\d+}/read") @PreAuthorize("hasAuthority(#app + ':notification:read')")
public ApiResponse<NotificationDetail> read(@PathVariable String app,@PathVariable Long id,@AuthenticationPrincipal AuthenticatedUser user){String code=guard(app,user);return ApiResponse.success(service.read(user.tenantId(),user.membershipId(),code,id));}
@PutMapping("/read-all") @PreAuthorize("hasAuthority(#app + ':notification:view')")
@PutMapping("/read-all") @PreAuthorize("hasAuthority(#app + ':notification:read-all')")
public ApiResponse<ReadAllResponse> readAll(@PathVariable String app,@AuthenticationPrincipal AuthenticatedUser user){String code=guard(app,user);return ApiResponse.success(service.readAll(user.tenantId(),user.membershipId(),code));}
private String guard(String app,AuthenticatedUser user){String code=switch(app){case"platform"->"PLATFORM";case"merchant"->"MERCHANT";default->throw new BizException(ResultCode.BAD_REQUEST,"不支持的应用端");};TenantGuard.tenant(user,code);return code;}
}
@@ -0,0 +1,79 @@
-- Admin prototype-aligned page menus and page permissions.
-- Permission codes remain stable; this migration only adds page-level MENU resources,
-- reparents existing finance action resources, and retires unsupported fee create/delete actions.
USE baiyue_iam;
SET NAMES utf8mb4;
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
(5102041,0,'PLATFORM','MENU',5102000,'platform:firstleg:inbound:measure:view','订单测量','/inbounds/measurements','GET',30,'ACTIVE',1,0),
(5102051,0,'PLATFORM','MENU',5102000,'firstleg:inbound:measurement-review','测量数据审核','/inbounds/measurement-reviews','GET',40,'ACTIVE',1,0),
(5103070,0,'PLATFORM','API',5103010,'platform:domestic-container:list','整柜业务','/api/platform/v1/domestic-containers','GET',70,'ACTIVE',1,0),
(5103071,0,'PLATFORM','BUTTON',5103070,'platform:domestic-container:inbound:create','新建整柜入库单',NULL,'POST',10,'ACTIVE',1,0),
(5103072,0,'PLATFORM','BUTTON',5103070,'platform:domestic-container:outbound:create','新建整柜出库单',NULL,'POST',20,'ACTIVE',1,0),
(5103080,0,'PLATFORM','MENU',5103000,'platform:supplier:list','供应商列表','/suppliers','GET',80,'ACTIVE',1,0),
(5103081,0,'PLATFORM','BUTTON',5103080,'platform:supplier:create','新增供应商',NULL,'POST',10,'ACTIVE',1,0),
(5103082,0,'PLATFORM','BUTTON',5103080,'platform:supplier:edit','编辑供应商',NULL,'PUT',20,'ACTIVE',1,0),
(5103083,0,'PLATFORM','BUTTON',5103080,'platform:supplier:status','启停供应商',NULL,'PATCH',30,'ACTIVE',1,0),
(5105040,0,'PLATFORM','MENU',5105000,'platform:finance:customer-account:list','客户账户','/finance/customer-accounts','GET',10,'ACTIVE',1,0),
(5105041,0,'PLATFORM','MENU',5105000,'platform:finance:customer-arap:list','客户应收应付','/finance/customer-arap','GET',20,'ACTIVE',1,0),
(5105042,0,'PLATFORM','MENU',5105000,'platform:finance:customer-statement:list','客户对账单','/finance/customer-statements','GET',30,'ACTIVE',1,0),
(5105043,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ar:list','结算单应收','/finance/settlement-receivables','GET',40,'ACTIVE',1,0),
(5105044,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ap:list','结算单应付','/finance/settlement-payables','GET',50,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE
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;
UPDATE iam_permission
SET status='INACTIVE', deleted=1
WHERE tenant_id=0 AND app_code='PLATFORM'
AND permission_code IN ('platform:overseas-storage-fee:create','platform:overseas-storage-fee:delete');
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3)
FROM iam_role r
JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='PLATFORM'
WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN'
AND p.permission_code IN (
'platform:finance:customer-account:list','platform:finance:customer-arap:list',
'platform:finance:customer-statement:list','platform:finance:settlement-ar:list',
'platform:finance:settlement-ap:list','platform:firstleg:inbound:measure:view',
'firstleg:inbound:measurement-review','platform:supplier:list',
'platform:supplier:create','platform:supplier:edit','platform:supplier:status',
'platform:domestic-container:list','platform:domestic-container:inbound:create',
'platform:domestic-container:outbound:create');
UPDATE iam_permission p
JOIN (SELECT 'platform:firstleg:inbound:measure:edit' code, 5102041 parent_id UNION ALL
SELECT 'platform:firstleg:inbound:measure:submit', 5102041 UNION ALL
SELECT 'platform:firstleg:inbound:measure:manual-entry', 5102041 UNION ALL
SELECT 'platform:firstleg:inbound:weight:edit', 5102041 UNION ALL
SELECT 'platform:firstleg:inbound:weight:confirm', 5102041 UNION ALL
SELECT 'firstleg:inbound:measurement-review-history', 5102051 UNION ALL
SELECT 'firstleg:inbound:exception-release', 5102051) mapping
ON mapping.code=p.permission_code
SET p.parent_id=mapping.parent_id
WHERE p.tenant_id=0 AND p.app_code='PLATFORM';
UPDATE iam_permission p
JOIN (SELECT 'platform:finance:account:view' code, 5105040 parent_id UNION ALL
SELECT 'platform:finance:fee-source:view', 5105041 UNION ALL
SELECT 'platform:finance:fee-source:review', 5105041 UNION ALL
SELECT 'platform:finance:fee-source:settlement', 5105041 UNION ALL
SELECT 'platform:finance:reconciliation:create', 5105042 UNION ALL
SELECT 'platform:finance:reconciliation:review', 5105042 UNION ALL
SELECT 'platform:finance:reconciliation:issue', 5105042 UNION ALL
SELECT 'platform:finance:reconciliation:adjust', 5105042 UNION ALL
SELECT 'platform:finance:reconciliation:void', 5105042 UNION ALL
SELECT 'platform:finance:settlement-ar:create', 5105043 UNION ALL
SELECT 'platform:finance:settlement-ar:view', 5105043 UNION ALL
SELECT 'platform:finance:settlement-ap:create', 5105044 UNION ALL
SELECT 'platform:finance:settlement-ap:view', 5105044 UNION ALL
SELECT 'platform:finance:payment:create', 5105044) mapping
ON mapping.code=p.permission_code
SET p.parent_id=mapping.parent_id
WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_type='BUTTON';
@@ -0,0 +1,61 @@
USE baiyue_iam;
SET NAMES utf8mb4;
DROP PROCEDURE IF EXISTS migrate_domestic_container_permissions;
DELIMITER $$
CREATE PROCEDURE migrate_domestic_container_permissions()
BEGIN
IF EXISTS (
SELECT 1 FROM iam_permission
WHERE (id = 5103070 AND permission_code <> 'platform:domestic-container:list')
OR (id = 5103071 AND permission_code <> 'platform:domestic-container:inbound:create')
OR (id = 5103072 AND permission_code <> 'platform:domestic-container:outbound:create')
OR (permission_code = 'platform:domestic-container:list' AND id <> 5103070)
OR (permission_code = 'platform:domestic-container:inbound:create' AND id <> 5103071)
OR (permission_code = 'platform:domestic-container:outbound:create' AND id <> 5103072)
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'domestic container permission id/code conflict';
END IF;
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
(5103070,0,'PLATFORM','API',5103010,'platform:domestic-container:list',
'查看国内整柜业务','/api/platform/v1/domestic-containers','GET',40,'ACTIVE',1,0),
(5103071,0,'PLATFORM','BUTTON',5103070,'platform:domestic-container:inbound:create',
'新建整柜入库单','/api/platform/v1/domestic-containers/inbounds','POST',10,'ACTIVE',1,0),
(5103072,0,'PLATFORM','BUTTON',5103070,'platform:domestic-container:outbound:create',
'新建整柜出库单','/api/platform/v1/domestic-containers/outbounds','POST',20,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE
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 (tenant_id, role_id, permission_id, created_at)
SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3)
FROM iam_role r
JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='PLATFORM'
WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN'
AND r.status='ACTIVE' AND r.deleted=0
AND p.permission_code IN ('platform:domestic-container:list',
'platform:domestic-container:inbound:create','platform:domestic-container:outbound:create');
IF (SELECT COUNT(*) FROM iam_permission WHERE tenant_id=0 AND app_code='PLATFORM'
AND status='ACTIVE' AND built_in=1 AND deleted=0
AND permission_code IN ('platform:domestic-container:list',
'platform:domestic-container:inbound:create','platform:domestic-container:outbound:create')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'domestic container permission catalog is incomplete';
END IF;
IF (SELECT COUNT(*) FROM iam_role_permission rp
JOIN iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id
JOIN iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id
WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN'
AND p.permission_code IN ('platform:domestic-container:list',
'platform:domestic-container:inbound:create','platform:domestic-container:outbound:create')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PLATFORM_ADMIN domestic container grant is incomplete';
END IF;
END$$
DELIMITER ;
CALL migrate_domestic_container_permissions();
DROP PROCEDURE IF EXISTS migrate_domestic_container_permissions;
@@ -58,24 +58,28 @@ VALUES
(5106000,0,'PLATFORM','MENU',0,'platform:notification','消息中心','/notifications',NULL,600,'ACTIVE',0),
(5106001,0,'PLATFORM','API',5106000,'platform:notification:view','查看消息','/api/platform/v1/notifications','GET',601,'ACTIVE',0),
(5106002,0,'PLATFORM','BUTTON',5106000,'platform:notification:create','发送消息','/api/platform/v1/notifications','POST',602,'ACTIVE',0),
(5106003,0,'PLATFORM','API',5106000,'platform:notification:template:list','查看消息模板','/api/platform/v1/notification-templates','GET',603,'ACTIVE',0),
(5106003,0,'PLATFORM','MENU',5106000,'platform:notification:template:list','模板管理','/notifications/templates','GET',603,'ACTIVE',0),
(5106004,0,'PLATFORM','BUTTON',5106000,'platform:notification:template:create','新增消息模板','/api/platform/v1/notification-templates','POST',604,'ACTIVE',0),
(5106005,0,'PLATFORM','BUTTON',5106000,'platform:notification:template:edit','编辑消息模板','/api/platform/v1/notification-templates/{id}','PUT',605,'ACTIVE',0),
(5106006,0,'PLATFORM','BUTTON',5106000,'platform:notification:template:status','变更模板状态','/api/platform/v1/notification-templates/{id}/status','PUT',606,'ACTIVE',0),
(5106007,0,'PLATFORM','API',5106000,'platform:notification:task:list','查看消息任务','/api/platform/v1/notification-tasks','GET',607,'ACTIVE',0),
(5106007,0,'PLATFORM','MENU',5106000,'platform:notification:task:list','消息管理','/notifications/messages','GET',607,'ACTIVE',0),
(5106008,0,'PLATFORM','BUTTON',5106000,'platform:notification:task:cancel','取消消息任务','/api/platform/v1/notification-tasks/{id}/cancel','PUT',608,'ACTIVE',0),
(5106009,0,'PLATFORM','BUTTON',5106000,'platform:notification:task:retry','重试消息任务','/api/platform/v1/notification-tasks/{id}/retry','PUT',609,'ACTIVE',0)
(5106009,0,'PLATFORM','BUTTON',5106000,'platform:notification:task:retry','重试消息任务','/api/platform/v1/notification-tasks/{id}/retry','PUT',609,'ACTIVE',0),
(5106011,0,'PLATFORM','BUTTON',5106001,'platform:notification:read','标记消息已读','/api/platform/v1/notifications/{id}/read','PUT',610,'ACTIVE',0),
(5106012,0,'PLATFORM','BUTTON',5106001,'platform:notification:read-all','全部标记已读','/api/platform/v1/notifications/read-all','PUT',611,'ACTIVE',0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), http_method=VALUES(http_method), status='ACTIVE', deleted=0;
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted)
VALUES (5106010, 0, 'MERCHANT', 'API', 0, 'merchant:notification:view', '查看我的消息',
'/api/merchant/v1/notifications', 'GET', 900, 'ACTIVE', 0)
VALUES
(5106010,0,'MERCHANT','API',0,'merchant:notification:view','查看我的消息','/api/merchant/v1/notifications','GET',900,'ACTIVE',0),
(5106013,0,'MERCHANT','BUTTON',5106010,'merchant:notification:read','标记消息已读','/api/merchant/v1/notifications/{id}/read','PUT',901,'ACTIVE',0),
(5106014,0,'MERCHANT','BUTTON',5106010,'merchant:notification:read-all','全部标记已读','/api/merchant/v1/notifications/read-all','PUT',902,'ACTIVE',0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), status='ACTIVE', deleted=0;
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3)
FROM iam_permission p JOIN iam_role r ON r.tenant_id=0 AND r.app_code='MERCHANT' AND r.status='ACTIVE'
WHERE p.tenant_id=0 AND p.permission_code='merchant:notification:view' AND r.role_code='MERCHANT_OWNER';
WHERE p.tenant_id=0 AND p.permission_code IN ('merchant:notification:view','merchant:notification:read','merchant:notification:read-all') AND r.role_code='MERCHANT_OWNER';
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3)
@@ -0,0 +1,46 @@
-- 平台供应商权限资源及 PLATFORM_ADMIN 默认授权;可重复执行。
USE baiyue_iam;
SET NAMES utf8mb4;
DROP PROCEDURE IF EXISTS guard_platform_supplier_permission_ids;
DELIMITER $$
CREATE PROCEDURE guard_platform_supplier_permission_ids()
BEGIN
IF EXISTS (SELECT 1 FROM iam_permission WHERE id BETWEEN 5103080 AND 5103083 AND permission_code <> CASE id WHEN 5103080 THEN 'platform:supplier:list' WHEN 5103081 THEN 'platform:supplier:create' WHEN 5103082 THEN 'platform:supplier:edit' WHEN 5103083 THEN 'platform:supplier:status' END)
OR EXISTS (SELECT 1 FROM iam_permission WHERE permission_code IN ('platform:supplier:list','platform:supplier:create','platform:supplier:edit','platform:supplier:status') AND id NOT BETWEEN 5103080 AND 5103083) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商权限固定ID或权限码已被其他语义占用';
END IF;
END$$
DELIMITER ;
CALL guard_platform_supplier_permission_ids();
DROP PROCEDURE guard_platform_supplier_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
(5103080,0,'PLATFORM','MENU',5103000,'platform:supplier:list','供应商列表','/suppliers','GET',80,'ACTIVE',1,0),
(5103081,0,'PLATFORM','BUTTON',5103080,'platform:supplier:create','新增供应商',NULL,'POST',10,'ACTIVE',1,0),
(5103082,0,'PLATFORM','BUTTON',5103080,'platform:supplier:edit','编辑供应商',NULL,'PUT',20,'ACTIVE',1,0),
(5103083,0,'PLATFORM','BUTTON',5103080,'platform:supplier:status','启停供应商',NULL,'PATCH',30,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE 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 (tenant_id,role_id,permission_id,created_at)
SELECT 0,r.id,p.id,CURRENT_TIMESTAMP(3)
FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='PLATFORM'
WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND r.status='ACTIVE' AND r.deleted=0
AND p.permission_code IN ('platform:supplier:list','platform:supplier:create','platform:supplier:edit','platform:supplier:status');
DROP PROCEDURE IF EXISTS assert_platform_supplier_permissions;
DELIMITER $$
CREATE PROCEDURE assert_platform_supplier_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:supplier:list','platform:supplier:create','platform:supplier:edit','platform:supplier:status')) <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商权限资源初始化不完整';
END IF;
IF (SELECT COUNT(*) FROM iam_role_permission rp JOIN iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id JOIN iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE r.role_code='PLATFORM_ADMIN' AND p.permission_code IN ('platform:supplier:list','platform:supplier:create','platform:supplier:edit','platform:supplier:status')) <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PLATFORM_ADMIN供应商权限授权不完整';
END IF;
END$$
DELIMITER ;
CALL assert_platform_supplier_permissions();
DROP PROCEDURE assert_platform_supplier_permissions;
@@ -35,12 +35,14 @@ paths:
put:
tags: [Notification inbox]
summary: Mark one message as read (idempotent)
description: Requires {app}:notification:read.
parameters: [{$ref: '#/components/parameters/AppPath'}, {$ref: '#/components/parameters/IdPath'}]
responses: {'200': {$ref: '#/components/responses/InboxDetail'}}
/{app}/v1/notifications/read-all:
put:
tags: [Notification inbox]
summary: Mark all current-user messages as read (idempotent)
description: Requires {app}:notification:read-all.
parameters: [{$ref: '#/components/parameters/AppPath'}]
responses: {'200': {$ref: '#/components/responses/ReadAll'}}
/platform/v1/notifications:
@@ -0,0 +1,69 @@
package com.baiyue.iam.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class AdminPrototypeMenuPermissionSqlContractTest {
private static final String[] FINANCE_MENUS = {
"platform:finance:customer-account:list", "platform:finance:customer-arap:list",
"platform:finance:customer-statement:list", "platform:finance:settlement-ar:list",
"platform:finance:settlement-ap:list"
};
@Test
void declaresFinancePageMenusAndReparentsActions() throws Exception {
String sql = new ClassPathResource("db/admin-prototype-menu-permissions.sql")
.getContentAsString(StandardCharsets.UTF_8);
for (int index = 0; index < FINANCE_MENUS.length; index++) {
assertThat(sql).contains("(" + (5105040 + index) + ",0,'PLATFORM','MENU',5105000,'" + FINANCE_MENUS[index] + "'");
}
assertThat(sql)
.contains("'platform:finance:account:view' code, 5105040 parent_id")
.contains("'platform:finance:fee-source:view', 5105041")
.contains("'platform:finance:reconciliation:create', 5105042")
.contains("'platform:finance:settlement-ar:create', 5105043")
.contains("'platform:finance:settlement-ap:create', 5105044")
.contains("'platform:finance:payment:create', 5105044)")
.contains("role_code='PLATFORM_ADMIN'");
}
@Test
void retiresUnsupportedOverseasFeeCreateAndDelete() throws Exception {
String sql = new ClassPathResource("db/admin-prototype-menu-permissions.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql).contains("status='INACTIVE', deleted=1")
.contains("'platform:overseas-storage-fee:create','platform:overseas-storage-fee:delete'");
String unified = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8);
assertThat(unified).contains("<> 2")
.contains("platform:overseas-storage-fee:list', 'platform:overseas-storage-fee:create'");
}
@Test
void keepsDomesticContainerAndSupplierPermissionIdsDisjoint() throws Exception {
String sql = new ClassPathResource("db/admin-prototype-menu-permissions.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.contains("(5103070,0,'PLATFORM','API',5103010,'platform:domestic-container:list'")
.contains("(5103071,0,'PLATFORM','BUTTON',5103070,'platform:domestic-container:inbound:create'")
.contains("(5103072,0,'PLATFORM','BUTTON',5103070,'platform:domestic-container:outbound:create'")
.contains("(5103080,0,'PLATFORM','MENU',5103000,'platform:supplier:list'")
.contains("(5103081,0,'PLATFORM','BUTTON',5103080,'platform:supplier:create'")
.contains("(5103082,0,'PLATFORM','BUTTON',5103080,'platform:supplier:edit'")
.contains("(5103083,0,'PLATFORM','BUTTON',5103080,'platform:supplier:status'")
.doesNotContain("platform:supplier:view");
}
private static Path resolveUnifiedSql() {
Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
return Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
}
}
@@ -0,0 +1,36 @@
package com.baiyue.iam.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class DomesticContainerPermissionSqlContractTest {
@Test
void registersAndGrantsDedicatedPermissionsIdempotently() throws Exception {
String sql = new ClassPathResource("db/domestic-container-permissions.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql).contains("5103070", "5103071", "5103072",
"platform:domestic-container:list", "platform:domestic-container:inbound:create",
"platform:domestic-container:outbound:create", "ON DUPLICATE KEY UPDATE",
"INSERT IGNORE INTO iam_role_permission", "role_code='PLATFORM_ADMIN'", "<> 3");
assertThat(sql).contains("permission id/code conflict", "permission catalog is incomplete",
"PLATFORM_ADMIN domestic container grant is incomplete");
}
@Test
void reservesFinalIdsAndRejectsSupplierReuse() throws Exception {
String sql = new ClassPathResource("db/domestic-container-permissions.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql).contains("(5103070,0,'PLATFORM','API'", "(5103071,0,'PLATFORM','BUTTON',5103070",
"(5103072,0,'PLATFORM','BUTTON',5103070");
Path supplier = Path.of("src/main/resources/db/platform-supplier-permissions.sql");
if (!Files.exists(supplier)) supplier = Path.of("iam/src/main/resources/db/platform-supplier-permissions.sql");
if (Files.exists(supplier)) {
String supplierSql = Files.readString(supplier, StandardCharsets.UTF_8);
assertThat(supplierSql).doesNotContain("5103070", "5103071", "5103072");
}
}
}
@@ -0,0 +1,18 @@
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 PlatformSupplierPermissionSqlContractTest {
@Test void registersFourSupplierPermissionsAndGrantsPlatformAdmin() throws Exception {
String sql = new ClassPathResource("db/platform-supplier-permissions.sql").getContentAsString(StandardCharsets.UTF_8);
assertThat(sql).contains("5103080", "5103081", "5103082", "5103083",
"platform:supplier:list", "platform:supplier:create",
"platform:supplier:edit", "platform:supplier:status", "role_code='PLATFORM_ADMIN'",
"ON DUPLICATE KEY UPDATE", "INSERT IGNORE INTO iam_role_permission",
"guard_platform_supplier_permission_ids", "SIGNAL SQLSTATE '45000'",
"assert_platform_supplier_permissions");
}
}
@@ -174,9 +174,9 @@ class UnifiedOrderQueryServiceTest {
}
@Test
void historicalWaitSplitFilterForwardsInboundedAndReturnsInbounded() {
void waitSplitFilterForwardsAndReturnsWaitSplitIndependently() {
UnifiedOrderQuery query = query("WAIT_SPLIT");
var summary = summary("INBOUNDED", null);
var summary = summary("WAIT_SPLIT", "CONFIRM_PRELOAD");
when(firstleg.page(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1)));
when(orders.findByIds(eq(10001L), eq(List.of(77L)))).thenReturn(List.of(order(OrderStatus.PROCESSING)));
@@ -184,12 +184,12 @@ class UnifiedOrderQueryServiceTest {
var result = service.page(10001L, query);
assertThat(result.records()).singleElement()
.extracting(row -> row.mainStatus()).isEqualTo("INBOUNDED");
.extracting(row -> row.mainStatus()).isEqualTo("WAIT_SPLIT");
ArgumentCaptor<FirstlegUnifiedOrderClient.Query> captor =
ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class);
verify(firstleg).page(eq("internal-token"), captor.capture());
assertThat(captor.getValue().tenantId()).isEqualTo(10001L);
assertThat(captor.getValue().mainStatus()).isEqualTo("INBOUNDED");
assertThat(captor.getValue().mainStatus()).isEqualTo("WAIT_SPLIT");
}
@Test
+44 -2
View File
@@ -12,7 +12,7 @@ import process from 'node:process'
const DEFAULT_GATEWAY = 'http://8.163.30.128:9000'
const DEFAULT_USERNAME = 'platform_admin'
const DEFAULT_PASSWORD = 'ChangeMe123!'
const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full']
const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full']
const WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG'
const CUSTOMER_FIELDS = [
'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative',
@@ -406,8 +406,50 @@ export class AcceptanceRun {
this.assert(module, '客户编辑正确回显', editResponse, edited?.tenantName === editedPayload.tenantName && edited?.contactEmail === editedPayload.contactEmail && Number(edited?.paymentTermDays) === 45, `tenantId=${tenantId}`)
const persistedResponse = await this.request('GET', detailPath); const persisted = this.apiSuccess(module, '回查客户编辑结果', persistedResponse)
this.assert(module, '客户编辑已持久化', persistedResponse, persisted?.tenantName === editedPayload.tenantName && persisted?.internalRemark === editedPayload.internalRemark, `tenantId=${tenantId}`)
let customerAuditPage = null
let customerAuditResponse = null
for (let attempt = 0; attempt < 8; attempt += 1) {
customerAuditResponse = await this.request('GET', `/api/platform/v1/audit/logs?accountId=${tenantId}&eventType=PLATFORM_TENANT_UPDATE&resourceType=TENANT&resourceId=${tenantId}&page=1&size=20`)
customerAuditPage = customerAuditResponse.http === 200 && String(customerAuditResponse.body?.code) === '200' ? customerAuditResponse.body?.data : null
if (customerAuditPage?.records?.some((item) => item.eventType === 'PLATFORM_TENANT_UPDATE' && String(item.accountId) === tenantId)) break
await new Promise((resolve) => setTimeout(resolve, 500))
}
const customerAudit = this.apiSuccess(module, '按客户查询操作日志', customerAuditResponse)
const customerAuditRow = customerAudit?.records?.find((item) => item.eventType === 'PLATFORM_TENANT_UPDATE' && String(item.accountId) === tenantId)
this.assert(module, '客户操作日志包含本轮编辑事件', customerAuditResponse, Boolean(customerAuditRow), `tenantId=${tenantId}, event=${customerAuditRow?.eventType || 'missing'}`)
if (customerAuditRow?.id) {
const auditDetailResponse = await this.request('GET', `/api/platform/v1/audit/logs/${customerAuditRow.id}`)
const auditDetail = this.apiSuccess(module, '查询客户操作日志详情', auditDetailResponse)
const snapshotsAreJson = ['beforeData', 'afterData'].every((field) => {
if (typeof auditDetail?.[field] !== 'string') return false
try { JSON.parse(auditDetail[field]); return true } catch { return false }
})
const snapshotText = `${auditDetail?.beforeData || ''}${auditDetail?.afterData || ''}`
const snapshotsAreRedacted = !/(password|authorization|accessToken|refreshToken)\s*[\"']?\s*:/i.test(snapshotText)
this.assert(module, '操作日志详情返回脱敏 JSON 快照', auditDetailResponse,
snapshotsAreJson && snapshotsAreRedacted && String(auditDetail.accountId) === tenantId && String(auditDetail.resourceId) === tenantId,
`accountId=${auditDetail?.accountId ?? 'missing'}, resourceId=${auditDetail?.resourceId ?? 'missing'}, snapshotsAreJson=${snapshotsAreJson}, snapshotsAreRedacted=${snapshotsAreRedacted}`)
}
const qualificationPackage = new FormData()
const qualificationImage = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'))
qualificationPackage.append('businessLicense', new Blob([qualificationImage], { type: 'image/png' }), `business-license-${suffix}.png`)
qualificationPackage.append('legalPersonId', new Blob([qualificationImage], { type: 'image/png' }), `legal-person-id-${suffix}.png`)
qualificationPackage.append('unifiedSocialCreditCode', `91440300${suffix.replace(/[^0-9A-Z]/gi, '').toUpperCase().padStart(10, '0').slice(-10)}`)
const packageResponse = await this.requestWithBody('POST', `/api/platform/v1/files/qualifications/${tenantId}/required-package`, qualificationPackage)
this.apiSuccess(module, '上传审核所需客户资质包', packageResponse, `tenantId=${tenantId}`)
const qualificationResponse = await this.request('GET', `${detailPath}/qualifications`); const qualifications = this.apiSuccess(module, '业务概况查询资质', qualificationResponse)
this.assert(module, '资质概况结构正确', qualificationResponse, Array.isArray(qualifications), `count=${Array.isArray(qualifications) ? qualifications.length : 'n/a'}`)
const requiredQualifications = Array.isArray(qualifications)
? qualifications.filter((item) => ['BUSINESS_LICENSE', 'LEGAL_PERSON_ID'].includes(item.qualificationType)) : []
this.assert(module, '审核资质列表包含营业执照和法人身份证', qualificationResponse,
requiredQualifications.length === 2 && requiredQualifications.every((item) => typeof item.documentUrl === 'string' && item.documentUrl.length > 0),
`types=${requiredQualifications.map((item) => item.qualificationType).join(',') || 'missing'}`)
for (const qualification of requiredQualifications) {
const fileName = decodeURIComponent(new URL(qualification.documentUrl, 'http://acceptance.local').pathname.split('/').pop() || '')
const downloadResponse = await this.requestBinary('GET', `/api/platform/v1/files/qualifications/${tenantId}/${encodeURIComponent(fileName)}`)
this.assert(module, `审核人员可下载${qualification.qualificationType}`, downloadResponse,
downloadResponse.http === 200 && downloadResponse.bytes.length > 0 && /^image\/|application\/pdf/.test(downloadResponse.contentType),
`file=${fileName}, bytes=${downloadResponse.bytes.length}, contentType=${downloadResponse.contentType || 'missing'}`)
}
const memberResponse = await this.request('GET', `${detailPath}/members`); const members = this.apiSuccess(module, '业务概况查询成员', memberResponse)
const owner = members?.find?.((item) => item.memberType === 'OWNER')
this.assert(module, '联系电话账号是企业管理员', memberResponse,
+14
View File
@@ -35,3 +35,17 @@ export async function run(context) {
- 测试数据必须带 `context.marker`;可删除的清理,不能删除的最终停用。
- 缺少已确认契约时记录失败或 `deploymentBlocker`,不得模拟成功。
- 一个模块只修改自己的 `.mjs`,不要修改其他模块文件。
## 供应商模块
`suppliers.mjs` 覆盖平台公共供应商的列表、统计、新增、详情、编辑、启停及主要错误边界。执行:
```powershell
node scripts/admin-test-env-acceptance.mjs --modules suppliers
```
该命令会调用测试环境写 API,只有当前对话明确授权后才允许执行。验收供应商最终停用但不删除;静态合同可安全离线运行:
```powershell
node --test scripts/admin-test-env-modules/suppliers-contract.test.mjs
```
@@ -0,0 +1,32 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const here = path.dirname(fileURLToPath(import.meta.url))
const acceptance = fs.readFileSync(path.join(here, '..', 'admin-test-env-acceptance.mjs'), 'utf8')
const system = fs.readFileSync(path.join(here, 'system.mjs'), 'utf8')
test('客户验收覆盖审核资质上传、回查和鉴权下载', () => {
assert.match(acceptance, /files\/qualifications\/\$\{tenantId\}\/required-package/)
assert.match(acceptance, /BUSINESS_LICENSE/)
assert.match(acceptance, /LEGAL_PERSON_ID/)
assert.match(acceptance, /requestBinary\('GET', `\/api\/platform\/v1\/files\/qualifications\/\$\{tenantId\}/)
assert.match(acceptance, /downloadResponse\.bytes\.length > 0/)
})
test('系统验收覆盖后端四种权限资源类型并清理测试资源', () => {
for (const type of ['BUTTON', 'MENU', 'PAGE', 'API']) assert.match(system, new RegExp(`type: '${type}'`))
assert.match(system, /created\?\.permissionType === definition\.type/)
assert.match(system, /created\?\.httpMethod/)
assert.match(system, /DELETE.*permissions\/\$\{additionalPermissionId\}/s)
})
test('客户验收覆盖客户维度操作日志列表和详情快照', () => {
assert.match(acceptance, /audit\/logs\?accountId=\$\{tenantId\}&eventType=PLATFORM_TENANT_UPDATE&resourceType=TENANT&resourceId=\$\{tenantId\}/)
assert.match(acceptance, /PLATFORM_TENANT_UPDATE/)
assert.match(acceptance, /audit\/logs\/\$\{customerAuditRow\.id\}/)
assert.match(acceptance, /JSON\.parse\(auditDetail\[field\]\)/)
assert.match(acceptance, /snapshotsAreRedacted/)
})
@@ -0,0 +1,41 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const here = path.dirname(fileURLToPath(import.meta.url))
const moduleSource = fs.readFileSync(path.join(here, 'domestic-container.mjs'), 'utf8')
const acceptance = fs.readFileSync(path.join(here, '..', 'admin-test-env-acceptance.mjs'), 'utf8')
test('国内整柜模块仅由总编排注入 context 后执行', () => {
assert.match(moduleSource, /export async function run\(context\)/)
assert.doesNotMatch(moduleSource, /https?:\/\//)
assert.doesNotMatch(moduleSource, /ADMIN_TEST_GATEWAY|process\.env|fetch\(/)
assert.match(acceptance, /import\(new URL\(`\.\/admin-test-env-modules\/\$\{module\}\.mjs`/)
})
test('正式全量验收默认注册国内整柜模块', () => {
assert.match(acceptance, /DEFAULT_MODULES\s*=\s*\[[^\]]*['"]domestic-container['"]/s)
})
test('覆盖国内仓、待单、完成入库、部分和全部出库及列表详情流水', () => {
for (const token of ['warehouseType=DOMESTIC', "status: 'PENDING'", "status: 'COMPLETED'",
'PARTIALLY_OUTBOUND', "inventoryStatus === 'OUTBOUND'", '/inbounds?page=1', '/outbounds?page=1',
'/inventories/${inventoryId}', 'transactions?.length === 3']) assert.match(moduleSource, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
})
test('覆盖库存不足、版本冲突及两类幂等行为', () => {
assert.match(moduleSource, /库存不足禁止出库/)
assert.match(moduleSource, /过期库存版本冲突/)
assert.match(moduleSource, /同幂等键同请求重放/)
assert.match(moduleSource, /同幂等键不同请求冲突/)
assert.match(moduleSource, /inventoryVersion/)
})
test('覆盖权限拒绝与 requestId traceId', () => {
assert.match(moduleSource, /无整柜权限用户被拒绝/)
assert.match(moduleSource, /'A0403'/)
assert.match(moduleSource, /Boolean\(response\?\.requestId\).*Boolean\(response\?\.traceId\)/s)
assert.match(moduleSource, /status: 'DISABLED'/)
})
@@ -0,0 +1,152 @@
const MODULE = 'domestic-container'
const BASE = '/api/platform/v1/domestic-containers'
const PERMISSIONS = ['platform:domestic-container:list', 'platform:domestic-container:inbound:create',
'platform:domestic-container:outbound:create']
function id(value) { return value == null ? '' : String(value) }
function succeeded(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
function traced(context, label, response) {
context.assert(MODULE, `${label}返回链路标识`, response, Boolean(response?.requestId) && Boolean(response?.traceId),
`requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`)
}
function expectCode(context, label, response, code) {
context.assert(MODULE, label, response, response?.http === 200 && String(response?.body?.code) === code,
`HTTP=${response?.http}, code=${response?.body?.code || 'missing'}`)
traced(context, label, response)
}
async function api(context, method, path, body, token = context.token) {
const response = await context.request(method, path, body, token)
traced(context, `${method} ${path}`, response)
return response
}
export async function run(context) {
const marker = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-18)
const warehousesResponse = await api(context, 'GET', '/api/platform/v1/warehouse-admin?page=1&size=200&warehouseType=DOMESTIC&status=ACTIVE')
const warehouses = context.apiSuccess(MODULE, '查询平台启用国内仓', warehousesResponse)
const warehouse = warehouses?.records?.[0]
context.assert(MODULE, '平台国内仓候选有效', warehousesResponse, id(warehouse?.id) && warehouse?.warehouseType === 'DOMESTIC',
`warehouseId=${id(warehouse?.id) || 'missing'}, type=${warehouse?.warehouseType || 'missing'}`)
if (!warehouse) throw new Error('缺少启用的平台国内仓,整柜验收无法继续')
const warehouseId = id(warehouse.id)
const containerNo = `DCA${marker}`.slice(0, 32).toUpperCase()
const now = new Date().toISOString().slice(0, 19)
const invalidWarehouse = await api(context, 'POST', `${BASE}/inbounds`, {
warehouseId: '1', cargoName: marker, containerNo: `${containerNo}X`, boxCount: 1, volumeCbm: 1,
inboundTime: now, status: 'COMPLETED', remark: marker, idempotencyKey: `${marker}-invalid-warehouse`,
})
expectCode(context, '非平台国内仓禁止整柜入库', invalidWarehouse, 'A0404')
const pendingInboundBody = { warehouseId, cargoName: marker, containerNo, boxCount: 100, volumeCbm: 50,
inboundTime: now, status: 'PENDING', remark: `${marker}-pending-in`, idempotencyKey: `${marker}-pending-in` }
const pendingInbound = await api(context, 'POST', `${BASE}/inbounds`, pendingInboundBody)
context.apiSuccess(MODULE, '创建待入库整柜单', pendingInbound)
const beforeCompleted = await api(context, 'GET', `${BASE}/inventories?page=1&size=20&warehouseId=${warehouseId}&containerNo=${containerNo}`)
const beforePage = context.apiSuccess(MODULE, '待入库后查询库存', beforeCompleted)
context.assert(MODULE, 'PENDING 入库不记账', beforeCompleted, Number(beforePage?.total) === 0 && Number(beforePage?.summary?.availableBoxes) === 0,
`total=${beforePage?.total}, available=${beforePage?.summary?.availableBoxes}`)
const inboundBody = { ...pendingInboundBody, status: 'COMPLETED', remark: `${marker}-completed-in`, idempotencyKey: `${marker}-completed-in` }
const inbound = await api(context, 'POST', `${BASE}/inbounds`, inboundBody)
const inboundCreated = context.apiSuccess(MODULE, '完成整柜入库', inbound)
context.assert(MODULE, 'COMPLETED 入库返回新单', inbound, id(inboundCreated?.id) && inboundCreated?.replayed === false,
`orderId=${id(inboundCreated?.id) || 'missing'}`)
const replayInbound = await api(context, 'POST', `${BASE}/inbounds`, inboundBody)
const replayed = context.apiSuccess(MODULE, '同幂等键同请求重放', replayInbound)
context.assert(MODULE, '幂等重放返回首次单据', replayInbound,
id(replayed?.id) === id(inboundCreated?.id) && replayed?.replayed === true, `replayed=${replayed?.replayed}`)
const conflictingInbound = await api(context, 'POST', `${BASE}/inbounds`, { ...inboundBody, boxCount: 101 })
expectCode(context, '同幂等键不同请求冲突', conflictingInbound, 'A0409')
const inventoryResponse = await api(context, 'GET', `${BASE}/inventories?page=1&size=20&warehouseId=${warehouseId}&containerNo=${containerNo}`)
const inventoryPage = context.apiSuccess(MODULE, '查询完成入库库存', inventoryResponse)
let inventory = inventoryPage?.records?.[0]
context.assert(MODULE, '库存及汇总与入库一致', inventoryResponse,
Number(inventory?.inboundBoxes) === 100 && Number(inventory?.availableBoxes) === 100
&& inventory?.inventoryStatus === 'IN_STOCK' && Number(inventoryPage?.summary?.inboundBoxes) === 100,
`in=${inventory?.inboundBoxes}, available=${inventory?.availableBoxes}, status=${inventory?.inventoryStatus}`)
const inventoryId = id(inventory?.id)
if (!inventoryId) throw new Error('完成入库后未形成库存记录')
const pendingOutbound = await api(context, 'POST', `${BASE}/outbounds`, { warehouseId, containerNo, boxCount: 5,
outboundTime: now, status: 'PENDING', remark: `${marker}-pending-out`, idempotencyKey: `${marker}-pending-out` })
context.apiSuccess(MODULE, '创建待出库整柜单', pendingOutbound)
const afterPending = await api(context, 'GET', `${BASE}/inventories/${inventoryId}`)
const afterPendingDetail = context.apiSuccess(MODULE, '待出库后查询详情', afterPending)
context.assert(MODULE, 'PENDING 出库不记账', afterPending,
Number(afterPendingDetail?.inventory?.availableBoxes) === 100 && afterPendingDetail?.transactions?.length === 1,
`available=${afterPendingDetail?.inventory?.availableBoxes}, transactions=${afterPendingDetail?.transactions?.length}`)
const partial = await api(context, 'POST', `${BASE}/outbounds`, { warehouseId, containerNo, boxCount: 30,
outboundTime: now, status: 'COMPLETED', remark: `${marker}-partial`, idempotencyKey: `${marker}-partial`,
inventoryVersion: Number(afterPendingDetail.inventory.version) })
context.apiSuccess(MODULE, '完成部分出库', partial)
let detailResponse = await api(context, 'GET', `${BASE}/inventories/${inventoryId}`)
let detail = context.apiSuccess(MODULE, '查询部分出库详情和流水', detailResponse)
context.assert(MODULE, '部分出库库存和交易流水正确', detailResponse,
detail?.inventory?.inventoryStatus === 'PARTIALLY_OUTBOUND' && Number(detail?.inventory?.availableBoxes) === 70
&& detail?.transactions?.length === 2 && detail?.transactions?.[0]?.businessType === 'OUTBOUND',
`available=${detail?.inventory?.availableBoxes}, status=${detail?.inventory?.inventoryStatus}, tx=${detail?.transactions?.length}`)
const staleVersion = await api(context, 'POST', `${BASE}/outbounds`, { warehouseId, containerNo, boxCount: 1,
outboundTime: now, status: 'COMPLETED', idempotencyKey: `${marker}-stale`,
inventoryVersion: Number(afterPendingDetail.inventory.version) })
expectCode(context, '过期库存版本冲突', staleVersion, 'A0409')
const insufficient = await api(context, 'POST', `${BASE}/outbounds`, { warehouseId, containerNo, boxCount: 71,
outboundTime: now, status: 'COMPLETED', idempotencyKey: `${marker}-insufficient`,
inventoryVersion: Number(detail.inventory.version) })
expectCode(context, '库存不足禁止出库', insufficient, 'A0409')
const full = await api(context, 'POST', `${BASE}/outbounds`, { warehouseId, containerNo, boxCount: 70,
outboundTime: now, status: 'COMPLETED', remark: `${marker}-full`, idempotencyKey: `${marker}-full`,
inventoryVersion: Number(detail.inventory.version) })
context.apiSuccess(MODULE, '完成全部出库', full)
detailResponse = await api(context, 'GET', `${BASE}/inventories/${inventoryId}`)
detail = context.apiSuccess(MODULE, '查询全部出库详情', detailResponse)
context.assert(MODULE, '全部出库库存和三条交易流水正确', detailResponse,
detail?.inventory?.inventoryStatus === 'OUTBOUND' && Number(detail?.inventory?.availableBoxes) === 0
&& Number(detail?.inventory?.outboundBoxes) === 100 && detail?.transactions?.length === 3,
`available=${detail?.inventory?.availableBoxes}, outbound=${detail?.inventory?.outboundBoxes}, tx=${detail?.transactions?.length}`)
const inboundsList = await api(context, 'GET', `${BASE}/inbounds?page=1&size=20&warehouseId=${warehouseId}&containerNo=${containerNo}`)
const inboundRows = context.apiSuccess(MODULE, '筛选整柜入库单列表', inboundsList)
const outboundsList = await api(context, 'GET', `${BASE}/outbounds?page=1&size=20&warehouseId=${warehouseId}&containerNo=${containerNo}`)
const outboundRows = context.apiSuccess(MODULE, '筛选整柜出库单列表', outboundsList)
context.assert(MODULE, '列表覆盖待单和已完成单', outboundsList,
inboundRows?.records?.length === 2 && outboundRows?.records?.length === 3,
`inbounds=${inboundRows?.records?.length}, outbounds=${outboundRows?.records?.length}`)
await verifyPermissionDenial(context, marker, warehouseId, containerNo)
context.createdResources.domesticContainer = { warehouseId, inventoryId, containerNo, permissions: PERMISSIONS }
}
async function verifyPermissionDenial(context, marker, warehouseId, containerNo) {
const phone = await context.findAvailablePhone(MODULE)
const roleCode = `DC_DENY_${marker}`.slice(0, 64)
const username = `dc_deny_${marker}`.slice(0, 64).toLowerCase()
const password = `Dc${marker.slice(-8)}A1!`
const roleResponse = await context.request('POST', '/api/platform/v1/iam/roles', {
roleCode, roleName: `整柜权限拒绝-${marker}`, description: marker,
})
const role = context.apiSuccess(MODULE, '创建无整柜权限角色', roleResponse)
const roleId = id(role?.id)
if (!roleId) throw new Error('无法创建权限拒绝验收角色')
const userResponse = await context.request('POST', '/api/platform/v1/iam/users', {
username, displayName: `整柜权限拒绝-${marker}`, phone, email: `${username}@example.com`, tenantId: '0',
roleIds: [roleId], password, status: 'ACTIVE',
})
const user = context.apiSuccess(MODULE, '创建无整柜权限用户', userResponse)
const membershipId = id(user?.membershipId)
if (!membershipId) throw new Error('无法创建权限拒绝验收用户')
try {
const loginResponse = await context.request('POST', '/api/auth/login', { username, password }, '')
const token = context.apiSuccess(MODULE, '无整柜权限用户登录', loginResponse)?.accessToken
if (!token) throw new Error('权限拒绝验收用户登录失败')
const denied = await context.request('GET', `${BASE}/inventories?page=1&size=20&warehouseId=${warehouseId}&containerNo=${containerNo}`, undefined, token)
expectCode(context, '无整柜权限用户被拒绝', denied, 'A0403')
} finally {
await context.request('PUT', `/api/platform/v1/iam/users/${membershipId}/status`, { status: 'DISABLED' })
await context.request('PUT', `/api/platform/v1/iam/roles/${roleId}/status`, { status: 'DISABLED' })
}
}
@@ -31,3 +31,16 @@ test('Finance 不借用历史来源且缺 fixture 记录 coverage gap', () => {
assert.match(finance, /context\.addCoverageGap\(MODULE, ['"]payable-payment['"]/)
assert.match(finance, /context\.addCoverageGap\(MODULE, ['"]receipt-three-settlements['"]/)
})
test('客户详情财务账单验收覆盖客户过滤、详情结构与查看权限链', () => {
assert.match(finance, /\/api\/platform\/v1\/iam\/authorization\/permissions/)
assert.match(finance, /\/statements\?customerId=\$\{encodeURIComponent\(customerId\)\}/)
assert.match(finance, /statementRows\.every\(\(item\) => id\(item\?\.customerId\) === customerId\)/)
assert.match(finance, /statementRows\.some\(\(item\) => statementId\(item\) === createdStatementId\)/)
assert.match(finance, /\/reconciliation-statements\/\$\{encodeURIComponent\(createdStatementId\)\}/)
assert.match(finance, /id\(statementDetail\?\.customerId\) === customerId/)
assert.match(finance, /statementId\(statementDetail\) === createdStatementId/)
assert.match(finance, /Array\.isArray\(statementDetail\?\.lines\)/)
assert.match(finance, /Array\.isArray\(statementDetail\?\.revisions\)/)
assert.match(finance, /platform:finance:bill:view/)
})
@@ -79,6 +79,30 @@ export async function run(context) {
`first=${createdStatementId || 'missing'}, replay=${statementId(replay) || 'missing'}`, OPTIONS)
if (!createdStatementId) return
const permissionResponse = await context.request('GET', '/api/platform/v1/iam/authorization/permissions')
const permissions = context.apiSuccess(MODULE, '客户详情财务账单查看权限链可用', permissionResponse, '', OPTIONS)
const grantedPermissions = new Set(rows(permissions).map((item) => id(item?.permissionCode ?? item)))
context.assert(MODULE, '财务账单列表查看权限已授予', permissionResponse,
grantedPermissions.has('platform:finance:bill:view') || grantedPermissions.has('platform:finance:customer-statement:list'),
`permissions=${[...grantedPermissions].filter((item) => item.includes('finance')).join(',')}`, OPTIONS)
const statementListResponse = await context.request('GET', `${BASE}/statements?customerId=${encodeURIComponent(customerId)}`)
const statementList = context.apiSuccess(MODULE, '客户详情财务账单列表按客户过滤', statementListResponse,
`customerId=${customerId}`, OPTIONS)
const statementRows = rows(statementList)
context.assert(MODULE, '财务账单列表仅返回本轮客户且包含新账单', statementListResponse,
statementRows.length > 0 && statementRows.every((item) => id(item?.customerId) === customerId)
&& statementRows.some((item) => statementId(item) === createdStatementId),
`customerId=${customerId}, statementId=${createdStatementId}, rows=${statementRows.length}`, OPTIONS)
const statementDetailResponse = await context.request('GET', `${BASE}/reconciliation-statements/${encodeURIComponent(createdStatementId)}`)
const statementDetail = context.apiSuccess(MODULE, '读取客户详情财务账单详情', statementDetailResponse,
`statementId=${createdStatementId}`, OPTIONS)
context.assert(MODULE, '财务账单详情客户与明细修订结构完整', statementDetailResponse,
id(statementDetail?.customerId) === customerId && statementId(statementDetail) === createdStatementId
&& Array.isArray(statementDetail?.lines) && Array.isArray(statementDetail?.revisions),
`customerId=${id(statementDetail?.customerId) || 'missing'}, statementId=${statementId(statementDetail) || 'missing'}, lines=${Array.isArray(statementDetail?.lines)}, revisions=${Array.isArray(statementDetail?.revisions)}`, OPTIONS)
for (const action of ['SUBMIT', 'APPROVE', 'ISSUE']) {
const actionResponse = await write(context, 'POST', `${BASE}/reconciliation-statements/${createdStatementId}/actions`,
{ action, expectedVersion: Number(statement.version) }, `statement-${action.toLowerCase()}`)
@@ -16,6 +16,10 @@ test('retains all clickable container, loading-list and PDA workflow states thro
assert.match(source, /prepareInbound/)
assert.match(source, /\.\/orders\.mjs/)
assert.match(source, /\.\/inbound-measurement\.mjs/)
assert.doesNotMatch(source, /context\.request\('POST', CONTAINERS/)
assert.match(source, /\['WAIT_SPLIT', 'INBOUNDED'\]\.includes/)
const batch = source.slice(source.indexOf('async function pickAll'), source.indexOf('async function complete'))
assert.doesNotMatch(batch, /containerNo:/)
})
test('does not mutate historical fixtures or remove retained records', () => {
@@ -66,7 +66,7 @@ async function prepareInbound(context, label) {
await measurement.run(context)
const measured = context.createdResources.inboundMeasurement
const order = context.createdResources.orders
if (!measured?.inboundId || !measured?.tenantId) throw new Error(`${label} 未生成独立 INBOUNDED 入库单`)
if (!measured?.inboundId || !measured?.tenantId) throw new Error(`${label} 未生成独立 WAIT_SPLIT 入库单`)
return { marker: fixtureMarker, tenantId: id(measured.tenantId), inboundId: id(measured.inboundId),
orderId: id(order?.orderId) }
} finally {
@@ -88,29 +88,13 @@ async function readInbound(context, fixture) {
const value = context.apiSuccess(MODULE, '读取人工装柜独立入库单', response, '', OPTIONS)
const warehouseId = id(value?.summary?.warehouseId)
context.assert(MODULE, '人工装柜来源入库单可独立排柜', response,
value?.summary?.processStatus === 'INBOUNDED' && value?.summary?.measureReviewStatus === 'APPROVED'
['WAIT_SPLIT', 'INBOUNDED'].includes(value?.summary?.processStatus)
&& value?.summary?.measureReviewStatus === 'APPROVED'
&& Boolean(warehouseId),
`inboundId=${fixture.inboundId}, status=${value?.summary?.processStatus || 'missing'}`, OPTIONS)
return { ...fixture, detail: value, warehouseId }
}
async function createWaitingContainer(context, warehouseId, label) {
const value = suffix(context, label)
const response = await context.request('POST', CONTAINERS, {
containerNo: null, cabinetNo: `MAN-CAB-${value}`, warehouseId, warehouseName: '人工验收仓',
billOfLadingNo: null, vesselName: null, voyageNo: null, containerType: '40HQ',
shippingCompanyId: null, shippingCompanyName: null, departurePortCode: 'CNSZX',
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68,
maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null,
estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null,
actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
})
const container = context.apiSuccess(MODULE, `创建 ${label} 货柜`, response, '', OPTIONS)
if (!container?.id) throw new Error(`${label} 货柜创建失败`)
return container
}
async function createConfirmedContainer(context, source, label) {
const value = suffix(context, label)
const summary = source.detail.summary
@@ -195,7 +179,7 @@ async function pickAll(context, chain, label) {
const snapshot = context.apiSuccess(MODULE, `${label} 获取 PDA 库存快照`, snapshotResponse, '', OPTIONS)
const response = await post(context, `${PDA}/tasks/${chain.task.id}/batch`, {
warehouseId: chain.source.warehouseId, loadingPlanId: id(chain.task.loadingPlanId),
containerBookingId: chain.containerId, containerNo: chain.task.currentContainerNo || null,
containerBookingId: chain.containerId,
expectedVersion: Number(chain.task.version), inventorySnapshotToken: snapshot.snapshotToken,
items: chain.task.items.map((item) => ({ itemId: id(item.id), action: 'PICK',
pickedPackageCount: Number(item.plannedPackageCount), finalPickedPackageCount: null,
@@ -317,10 +301,10 @@ export async function run(context, matrix = context.createdResources.manualState
requiredActions: ['customsDeclaration'], fixtures: {}, actions: {} }
matrix.pages.pdaTasks = { requiredStatuses: PDA_STATUSES, fixtures: {} }
const warehouseId = '193000000000200001'
const waiting = await createWaitingContainer(context, warehouseId, 'WAIT')
matrix.pages.containerLoading.fixtures.WAIT_LOADING = fixture(waiting.id, 'WAIT_LOADING',
`/container-loading?containerId=${id(waiting.id)}`)
const waitingSource = await readInbound(context, await prepareInbound(context, 'WAIT'))
const waitingContainerId = await createConfirmedContainer(context, waitingSource, 'WAIT')
matrix.pages.containerLoading.fixtures.WAIT_LOADING = fixture(waitingContainerId, 'WAIT_LOADING',
`/container-loading?containerId=${waitingContainerId}`)
const editing = await createPlanTask(context, 'EDIT')
const inProgress = await claim(context, await createPlanTask(context, 'PROGRESS'), 'PROGRESS')
@@ -112,13 +112,44 @@ export async function run(context) {
`candidateFound=${Boolean(candidate)}, available=${candidate?.availablePackageCount ?? 'missing'}`, OPTIONS)
if (!candidate?.inboundDetailId) return
const foreignChannelCode = `${channelCode}-OTHER`.slice(0, 64)
const foreignBookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings', {
expectedVersion: 0,
data: { ...bookingDraft, channelCode: foreignChannelCode, channelName: `异渠道-${suffix}`, remark: `${context.marker}-other-channel` },
}, { 'Idempotency-Key': `${context.marker}-other-channel-booking-create`.slice(0, 128) })
const foreignBookingCreated = context.apiSuccess(MODULE, '创建异渠道隔离订舱', foreignBookingCreate, '', OPTIONS)
const foreignBookingId = id(foreignBookingCreated?.oceanBooking?.id)
if (!foreignBookingId) return
const foreignSubmit = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${foreignBookingId}/submit`, {
expectedVersion: Number(foreignBookingCreated?.oceanBooking?.version), reason: context.marker,
}, { 'Idempotency-Key': `${context.marker}-other-channel-booking-submit`.slice(0, 128) })
const foreignSubmitted = context.apiSuccess(MODULE, '提交异渠道隔离订舱', foreignSubmit, '', OPTIONS)
const foreignConfirm = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${foreignBookingId}/confirm`, {
expectedVersion: Number(foreignSubmitted?.oceanBooking?.version), reason: context.marker,
}, { 'Idempotency-Key': `${context.marker}-other-channel-booking-confirm`.slice(0, 128) })
const foreignConfirmed = context.apiSuccess(MODULE, '确认异渠道订舱并自动生成柜次', foreignConfirm, '', OPTIONS)
const foreignContainerId = id(foreignConfirmed?.containerAllocations?.[0]?.containerBookingId)
if (!foreignContainerId) return
const foreignPlanResponse = await request(context, 'POST', `${SMART}/plans`, {
bookingId: foreignContainerId, remark: `${context.marker}-other-channel`,
})
const foreignPlan = context.apiSuccess(MODULE, '创建异渠道智能装柜计划', foreignPlanResponse, '', OPTIONS)
const foreignAssignResponse = await request(context, 'POST', `${SMART}/plans/${foreignPlan?.id}/assignments`, {
inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1,
expectedVersion: Number(foreignPlan?.version), operationId: `${context.marker}-other-channel-assign`.slice(0, 128),
})
context.addStep(MODULE, '异渠道入库单拒绝装入当前柜次', foreignAssignResponse,
foreignAssignResponse.http === 200 && ['A0400', '400'].includes(codeOf(foreignAssignResponse)),
`HTTP=${foreignAssignResponse.http}, code=${codeOf(foreignAssignResponse)}`, OPTIONS)
const containerDetailResponse = await context.request('GET', `${CONTAINERS}/${containerId}`)
const containerDetail = context.apiSuccess(MODULE, '回查货柜详情', containerDetailResponse, '', OPTIONS)
context.assert(MODULE, '货柜详情保留候选货物路线约束', containerDetailResponse,
(!containerDetail?.destinationWarehouseCode || !candidate.destinationWarehouseCode
|| containerDetail.destinationWarehouseCode === candidate.destinationWarehouseCode)
&& containerDetail?.channelCode === channelCode
&& ['EDITING', 'WAIT_LOADING'].includes(containerDetail?.planStatus),
`planStatus=${containerDetail?.planStatus || 'missing'}`, OPTIONS)
`planStatus=${containerDetail?.planStatus || 'missing'}, channel=${containerDetail?.channelCode || 'missing'}`, OPTIONS)
const containerNo = id(containerDetail?.containerNo || containerDetail?.cabinetNo)
const planResponse = await request(context, 'POST', `${SMART}/plans`, { bookingId: containerId, remark: context.marker })
@@ -178,6 +209,12 @@ export async function run(context) {
&& Number(pushReplay?.version) === Number(pushed.version),
`planId=${id(pushReplay?.id) || 'missing'}, version=${pushReplay?.version ?? 'missing'}`, OPTIONS)
const gatedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
context.addStep(MODULE, '无真实柜号时计划可排柜但 PDA 任务不开放', gatedTaskResponse,
failure(gatedTaskResponse) || !id(gatedTaskResponse?.body?.id || gatedTaskResponse?.body?.data?.id),
`HTTP=${gatedTaskResponse.http}, code=${codeOf(gatedTaskResponse)}, task=${id(gatedTaskResponse?.body?.id || gatedTaskResponse?.body?.data?.id) || 'none'}`, OPTIONS)
const preloadPageResponse = await context.request('GET',
`${FIRST_LEG}/loading-plans?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(containerNo)}&page=1&size=20`)
const preloadPage = context.apiSuccess(MODULE, '查询本轮预装清单', preloadPageResponse, '', OPTIONS)
@@ -200,12 +237,61 @@ export async function run(context) {
const taskId = id(task?.id)
const pickItem = task?.items?.find?.((item) => id(item.loadingPlanItemId) === id(planItem.id))
context.assert(MODULE, 'PDA 任务、计划明细和待拣箱数关联正确', taskResponse,
Boolean(taskId) && id(task?.loadingPlanId) === planId && task?.taskStatus === 'LOADING'
Boolean(taskId) && id(task?.loadingPlanId) === planId
&& id(task?.containerBookingId) === containerId && Boolean(task?.cabinetNo)
&& task?.currentContainerNo === `TST-${suffix}`.slice(0, 64) && task?.taskStatus === 'LOADING'
&& task?.workflowStatus === 'UNASSIGNED'
&& Boolean(pickItem?.id) && Number(pickItem?.plannedPackageCount) === 1,
`taskId=${taskId || 'missing'}, itemId=${id(pickItem?.id) || 'missing'}`, OPTIONS)
if (!taskId || !pickItem?.id) return
const stableIdentity = {
taskId, loadingPlanId: id(task.loadingPlanId), containerBookingId: id(task.containerBookingId),
cabinetNo: id(task.cabinetNo),
}
const beforeReplacementSnapshotResponse = await context.request('GET',
`${PDA}/tasks/${taskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`)
const beforeReplacementSnapshot = context.apiSuccess(MODULE, '换柜号前读取 PDA 稳定库存快照',
beforeReplacementSnapshotResponse, '', OPTIONS)
const replacementContainerNo = `TST-R-${suffix}`.slice(0, 64)
const replacementResponse = await request(context, 'PATCH', `${CONTAINERS}/${containerId}/transport`, {
containerNo: replacementContainerNo, voyageNo: `V-${suffix}`.slice(0, 64),
version: Number(transported?.version), reason: `${context.marker}-replace-container-no`,
})
const replaced = context.apiSuccess(MODULE, 'PDA 任务创建后替换真实柜号', replacementResponse, '', OPTIONS)
context.assert(MODULE, '任务创建后真实柜号替换成功', replacementResponse,
replaced?.containerNo === replacementContainerNo,
`containerNo=${replaced?.containerNo || 'missing'}`, OPTIONS)
const clearContainerNoResponse = await request(context, 'PATCH', `${CONTAINERS}/${containerId}/transport`, {
containerNo: null, voyageNo: `V-${suffix}`.slice(0, 64),
version: Number(replaced?.version), reason: `${context.marker}-clear-container-no`,
})
context.addStep(MODULE, 'PDA 任务创建后清空真实柜号失败', clearContainerNoResponse,
clearContainerNoResponse.http === 200 && ['A0400', '400'].includes(codeOf(clearContainerNoResponse)),
`HTTP=${clearContainerNoResponse.http}, code=${codeOf(clearContainerNoResponse)}`, OPTIONS)
const replacedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const replacedTask = context.apiSuccess(MODULE, '换柜号后按稳定计划身份回查 PDA 任务', replacedTaskResponse, '', OPTIONS)
const afterReplacementSnapshotResponse = await context.request('GET',
`${PDA}/tasks/${taskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`)
const afterReplacementSnapshot = context.apiSuccess(MODULE, '换柜号后读取 PDA 稳定库存快照',
afterReplacementSnapshotResponse, '', OPTIONS)
context.assert(MODULE, '换柜号不改变任务计划柜次柜次号和库存快照身份', replacedTaskResponse,
id(replacedTask?.id) === stableIdentity.taskId
&& id(replacedTask?.loadingPlanId) === stableIdentity.loadingPlanId
&& id(replacedTask?.containerBookingId) === stableIdentity.containerBookingId
&& id(replacedTask?.cabinetNo) === stableIdentity.cabinetNo
&& replacedTask?.currentContainerNo === replacementContainerNo
&& id(afterReplacementSnapshot?.taskId) === stableIdentity.taskId
&& id(afterReplacementSnapshot?.loadingPlanId) === stableIdentity.loadingPlanId
&& id(afterReplacementSnapshot?.containerBookingId) === stableIdentity.containerBookingId
&& id(afterReplacementSnapshot?.cabinetNo) === stableIdentity.cabinetNo
&& afterReplacementSnapshot?.snapshotToken === beforeReplacementSnapshot?.snapshotToken,
`taskId=${id(replacedTask?.id) || 'missing'}, cabinetNo=${replacedTask?.cabinetNo || 'missing'}, snapshotStable=${afterReplacementSnapshot?.snapshotToken === beforeReplacementSnapshot?.snapshotToken}`, OPTIONS)
const wrongWarehouseResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=1`)
context.addStep(MODULE, 'PDA 任务仓库隔离拒绝', wrongWarehouseResponse,
@@ -242,7 +328,7 @@ export async function run(context) {
version: Number(pickItem.version), reason: null }]
const staleBatchResponse = await request(context, 'POST', batchPath, {
warehouseId, loadingPlanId: planId, containerBookingId: containerId,
containerNo: task.currentContainerNo || null, expectedVersion: Number(task.version) + 1,
expectedVersion: Number(task.version) + 1,
inventorySnapshotToken: inventory.snapshotToken, items: batchItems,
}, { 'Idempotency-Key': `${context.marker}-pda-batch-stale`.slice(0, 128) })
context.addStep(MODULE, 'PDA batch 旧任务版本返回 A0409', staleBatchResponse,
@@ -261,7 +347,7 @@ export async function run(context) {
`taskVersion=${afterFailedBatch?.version ?? 'missing'}, itemVersion=${afterFailedItem?.version ?? 'missing'}, picked=${afterFailedItem?.pickedPackageCount ?? 'missing'}`, OPTIONS)
const batchPayload = { warehouseId, loadingPlanId: planId, containerBookingId: containerId,
containerNo: task.currentContainerNo || null, expectedVersion: Number(task.version),
expectedVersion: Number(task.version),
inventorySnapshotToken: inventory.snapshotToken, items: batchItems }
const batchHeaders = { 'Idempotency-Key': `${context.marker}-pda-batch`.slice(0, 128) }
const pickResponse = await request(context, 'POST', batchPath, batchPayload, batchHeaders)
@@ -351,7 +437,8 @@ export async function run(context) {
context.createdResources.phase3ContainerLoading = {
marker: context.marker, tenantId, inboundId, warehouseId, containerBookingId: containerId,
loadingPlanId: planId, pdaTaskId: taskId,
loadingPlanId: planId, pdaTaskId: taskId, cabinetNo: stableIdentity.cabinetNo,
containerNo: replacementContainerNo, pdaIdentityStable: true,
finalStatus: 'COMPLETED',
}
}
@@ -147,6 +147,28 @@ test('container loading creates and confirms booking before scoped candidates',
assert.doesNotMatch(value, /POST', CONTAINERS, containerPayload/)
})
test('container loading enforces channel isolation and stable PDA identity across container number changes', () => {
const value = source('./phase3-container-loading.mjs')
assertOrder(value, [
'确认订舱并自动生成真实柜次',
'创建异渠道隔离订舱',
'异渠道入库单拒绝装入当前柜次',
'无真实柜号时计划可排柜但 PDA 任务不开放',
'绑定本轮真实箱号并补建 PDA 任务',
'换柜号前读取 PDA 稳定库存快照',
'PDA 任务创建后替换真实柜号',
'PDA 任务创建后清空真实柜号失败',
'换柜号后按稳定计划身份回查 PDA 任务',
'换柜号不改变任务计划柜次柜次号和库存快照身份',
])
assert.match(value, /containerDetail\?\.channelCode === channelCode/)
assert.match(value, /id\(replacedTask\?\.cabinetNo\) === stableIdentity\.cabinetNo/)
assert.match(value, /afterReplacementSnapshot\?\.snapshotToken === beforeReplacementSnapshot\?\.snapshotToken/)
assert.match(value, /pdaIdentityStable: true/)
const batch = value.slice(value.indexOf('const staleBatchResponse'), value.indexOf('const completePath'))
assert.doesNotMatch(batch, /containerNo:/)
})
test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING audit with operator metadata', () => {
const container = source('./phase3-container-loading.mjs')
const convergence = source('./phase3-pda-convergence.mjs')
@@ -0,0 +1,11 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const source=readFileSync(new URL('./suppliers.mjs',import.meta.url),'utf8')
const orchestrator=readFileSync(new URL('../admin-test-env-acceptance.mjs',import.meta.url),'utf8')
test('supplier acceptance covers full CRUD-like lifecycle and boundary failures',()=>{
for(const marker of ['列表分页与订舱筛选','查询四类供应商统计','新增供应商','查询供应商详情','编辑供应商','停用供应商','重复名称返回冲突','非订舱分类携带scope被拒绝','tenantId非0被拒绝','客户账号调用平台供应商接口被拒绝','requestId','traceId']) assert.match(source,new RegExp(marker))
})
test('supplier module is registered in the default full acceptance orchestration',()=>assert.match(orchestrator,/DEFAULT_MODULES = \[[^\]]*'suppliers'/s))
test('supplier fixture is retained only as inactive because API has no delete contract',()=>{assert.match(source,/finalStatus:inactive\?'INACTIVE'/);assert.doesNotMatch(source,/context\.request\('DELETE'/)})
@@ -0,0 +1,66 @@
const MODULE = 'suppliers'
const OPTIONS = { scenario: 'platform-public-suppliers', deploymentBlocker: true }
const BASE = '/api/platform/v1/suppliers'
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin'
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!'
function ok(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
function code(response, expected) { return response?.http === 200 && String(response?.body?.code) === expected }
function records(page) { return Array.isArray(page?.records) ? page.records : [] }
function id(value) { return value == null ? '' : String(value) }
function traced(response) { return Boolean(response?.requestId && response?.traceId) }
function query(values = {}) { const p = new URLSearchParams(); for (const [k,v] of Object.entries(values)) if (v !== '' && v != null) p.set(k,String(v)); return `${BASE}${p.size ? `?${p}` : ''}` }
function payload(marker, overrides = {}) { return { name:`供应商验收-${marker}`,category:'BOOKING',scope:'DOMESTIC',region:'宁波',legalRepresentative:'验收法人',contactName:'验收联系人',phone:'13800000000',email:'supplier-acceptance@example.com',address:'验收地址',businessLicenseNo:`ACPT${marker}`.slice(0,64),licenseFileId:null,status:'ACTIVE',settlementPeriod:'MONTHLY_30',remark:marker,...overrides } }
export async function run(context) {
const marker=context.marker.replace(/[^0-9A-Za-z]/g,'').slice(-20)
const listResponse=await context.request('GET',query({tenantId:0,category:'BOOKING',page:1,size:20}))
const list=context.apiSuccess(MODULE,'列表分页与订舱筛选',listResponse,'category=BOOKING',OPTIONS)
context.assert(MODULE,'列表分页结构完整',listResponse,Array.isArray(list?.records)&&Number(list?.page)===1&&Number(list?.size)===20&&Number(list?.total)>=0&&Number(list?.pages)>=0,`records=${records(list).length}, total=${list?.total??'missing'}`,OPTIONS)
const statsResponse=await context.request('GET',`${BASE}/stats?tenantId=0`)
const stats=context.apiSuccess(MODULE,'查询四类供应商统计',statsResponse,'',OPTIONS)
context.assert(MODULE,'统计字段完整',statsResponse,['booking','customs','trucking','certificate'].every(k=>Number(stats?.[k])>=0),JSON.stringify(stats),OPTIONS)
const createBody=payload(marker)
const createResponse=await context.request('POST',`${BASE}?tenantId=0`,createBody)
const supplierId=id(context.apiSuccess(MODULE,'新增供应商',createResponse,`name=${createBody.name}`,OPTIONS))
context.assert(MODULE,'新增返回供应商ID',createResponse,Boolean(supplierId),`supplierId=${supplierId||'missing'}`,OPTIONS)
if(!supplierId)return
let inactive=false
try {
const detailResponse=await context.request('GET',`${BASE}/${encodeURIComponent(supplierId)}?tenantId=0`)
const detail=context.apiSuccess(MODULE,'查询供应商详情',detailResponse,'',OPTIONS)
context.assert(MODULE,'详情字段与平台作用域正确',detailResponse,id(detail?.id)===supplierId&&id(detail?.tenantId)==='0'&&detail?.name===createBody.name&&detail?.scope==='DOMESTIC',`id=${id(detail?.id)}, tenantId=${id(detail?.tenantId)}`,OPTIONS)
const duplicateResponse=await context.request('POST',`${BASE}?tenantId=0`,payload(`${marker}D`,{name:createBody.name}))
context.assert(MODULE,'重复名称返回冲突并保留链路ID',duplicateResponse,code(duplicateResponse,'A0409')&&traced(duplicateResponse),`code=${duplicateResponse.body?.code}, requestId=${duplicateResponse.requestId||'missing'}, traceId=${duplicateResponse.traceId||'missing'}`,OPTIONS)
const scopeResponse=await context.request('POST',`${BASE}?tenantId=0`,payload(`${marker}S`,{name:`非法范围-${marker}`,category:'CUSTOMS',scope:'DOMESTIC'}))
context.assert(MODULE,'非订舱分类携带scope被拒绝',scopeResponse,code(scopeResponse,'A0422')&&traced(scopeResponse),`code=${scopeResponse.body?.code}, requestId=${scopeResponse.requestId||'missing'}, traceId=${scopeResponse.traceId||'missing'}`,OPTIONS)
const tenantResponse=await context.request('GET',query({tenantId:1,page:1,size:20}))
context.assert(MODULE,'tenantId非0被拒绝',tenantResponse,code(tenantResponse,'A0400')&&traced(tenantResponse),`code=${tenantResponse.body?.code}, requestId=${tenantResponse.requestId||'missing'}, traceId=${tenantResponse.traceId||'missing'}`,OPTIONS)
const merchantLogin=await context.request('POST','/api/auth/login',{username:MERCHANT_USERNAME,password:MERCHANT_PASSWORD},'')
const merchantToken=ok(merchantLogin)?merchantLogin.body?.data?.accessToken:''
const forbidden=merchantToken?await context.request('GET',query({tenantId:0,page:1,size:20}),undefined,merchantToken):merchantLogin
context.assert(MODULE,'客户账号调用平台供应商接口被拒绝',forbidden,Boolean(merchantToken)&&(forbidden.http===403||code(forbidden,'A0403'))&&traced(forbidden),`HTTP=${forbidden.http}, code=${forbidden.body?.code??'missing'}, requestId=${forbidden.requestId||'missing'}, traceId=${forbidden.traceId||'missing'}`,OPTIONS)
const updateBody=payload(marker,{name:`供应商验收已编辑-${marker}`,scope:'INTERNATIONAL',contactName:'编辑联系人',settlementPeriod:'MONTHLY_45'})
const updateResponse=await context.request('PUT',`${BASE}/${encodeURIComponent(supplierId)}?tenantId=0`,updateBody)
context.apiSuccess(MODULE,'编辑供应商',updateResponse,'',OPTIONS)
const editedResponse=await context.request('GET',`${BASE}/${encodeURIComponent(supplierId)}?tenantId=0`)
const edited=context.apiSuccess(MODULE,'回查编辑结果',editedResponse,'',OPTIONS)
context.assert(MODULE,'编辑字段已持久化',editedResponse,edited?.name===updateBody.name&&edited?.scope==='INTERNATIONAL'&&edited?.contactName==='编辑联系人'&&edited?.settlementPeriod==='MONTHLY_45',`name=${edited?.name||'missing'}`,OPTIONS)
const disableResponse=await context.request('PATCH',`${BASE}/${encodeURIComponent(supplierId)}/status?tenantId=0`,{status:'INACTIVE'})
context.apiSuccess(MODULE,'停用供应商',disableResponse,'',OPTIONS); inactive=ok(disableResponse)
const inactiveResponse=await context.request('GET',`${BASE}/${encodeURIComponent(supplierId)}?tenantId=0`)
const inactiveDetail=context.apiSuccess(MODULE,'回查停用状态',inactiveResponse,'',OPTIONS)
context.assert(MODULE,'停用状态已持久化',inactiveResponse,inactiveDetail?.status==='INACTIVE',`status=${inactiveDetail?.status||'missing'}`,OPTIONS)
} finally {
if(!inactive){const cleanup=await context.request('PATCH',`${BASE}/${encodeURIComponent(supplierId)}/status?tenantId=0`,{status:'INACTIVE'}); inactive=ok(cleanup); context.assert(MODULE,'异常路径停用验收供应商',cleanup,inactive,`supplierId=${supplierId}`,OPTIONS)}
context.createdResources.suppliers={marker:context.marker,supplierId,finalStatus:inactive?'INACTIVE':'UNKNOWN',retained:true}
}
}
+29
View File
@@ -27,6 +27,31 @@ export async function run(context) {
if (!permissionId) throw new Error('权限资源创建失败,系统模块无法继续')
context.createdResources.systemPermission = { id: permissionId, code: permissionPayload.code }
const additionalPermissionIds = []
for (const definition of [
{ type: 'MENU', routePath: `/acceptance-${suffix}`, httpMethod: null },
{ type: 'PAGE', routePath: `/acceptance-${suffix}/detail`, httpMethod: null },
{ type: 'API', routePath: `/api/platform/v1/acceptance/${suffix}`, httpMethod: 'GET' },
]) {
const payload = {
type: definition.type,
code: `platform:acceptance:${definition.type.toLowerCase()}:${suffix}`,
name: `Admin验收${definition.type}-${suffix}`,
parentId: null,
routePath: definition.routePath,
httpMethod: definition.httpMethod,
sortNo: 9999,
}
const response = await context.request('POST', '/api/platform/v1/iam/permissions', payload)
const created = context.apiSuccess(module, `创建 ${definition.type} 权限资源`, response)
const createdId = id(created?.id)
context.assert(module, `${definition.type} 类型与端点字段正确回显`, response,
createdId && created?.permissionType === definition.type && created?.routePath === definition.routePath
&& (created?.httpMethod ?? null) === definition.httpMethod,
`permissionId=${createdId || 'missing'}, type=${created?.permissionType || 'missing'}, method=${created?.httpMethod ?? 'null'}`)
if (createdId) additionalPermissionIds.push(createdId)
}
const updatePermissionResponse = await context.request('PUT', `/api/platform/v1/iam/permissions/${permissionId}`, {
name: `${permissionPayload.name}-已编辑`, parentId: null, routePath: null, httpMethod: null, sortNo: 9998,
})
@@ -140,6 +165,10 @@ export async function run(context) {
`status=${finalRole?.status ?? 'missing'}`)
const deletePermissionResponse = await context.request('DELETE', `/api/platform/v1/iam/permissions/${permissionId}`)
context.apiSuccess(module, '删除临时权限资源', deletePermissionResponse)
for (const additionalPermissionId of additionalPermissionIds) {
const response = await context.request('DELETE', `/api/platform/v1/iam/permissions/${additionalPermissionId}`)
context.apiSuccess(module, '删除四类型验收权限资源', response, `permissionId=${additionalPermissionId}`)
}
}
function requiredUserFields() {
+2 -2
View File
@@ -11,8 +11,8 @@ $ErrorActionPreference = 'Stop'
$Repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$Workspace = (Resolve-Path (Join-Path $Repo '..')).Path
$Acceptance = Join-Path $Workspace '.codex-tmp\p23-04-acceptance'
$ExpectedSha256 = '585464BE3FA0FB4141F47D0FAC57FA1FCAFCDD4596253256E8759BF930D9C7D3'
$ExpectedLines = 14433
$ExpectedSha256 = 'EB965FCBCE23B7A6E565530C13188B1C55FAB43B8FD4E3408DED91D57454A7DF'
$ExpectedLines = 14456
if ($TargetHost -notin @('127.0.0.1', 'localhost')) {
[Console]::Error.WriteLine('Refusing non-local database; only 127.0.0.1 or localhost is allowed.')
@@ -0,0 +1,100 @@
# 国内仓整柜业务前端接口交接单
## 通用约定
- Base path:`/api/platform/v1/domestic-containers`
- 浏览器继续使用 `/api` 前缀,不写死 Gateway 地址。
- 所有接口仅允许平台端登录态,公共作用域固定为 `tenant_id=0`;前端不传客户/租户字段。
- 成功响应统一为 `{ code: "200", message, data, requestId, traceId, timestamp }`。
- 时间字段使用 ISO-8601 `LocalDateTime`,例如 `2026-08-12T09:30:00`。
- `warehouseId` 必须对应平台公共、启用且 `warehouseType=DOMESTIC` 的国内仓;响应同时返回 `warehouseName` 快照。
- OpenAPI:`warehouse/src/main/resources/openapi/domestic-container-v1.yaml`。
- Warehouse 表迁移:`warehouse/src/main/resources/db/domestic-container-business.sql`。
- IAM 权限迁移:`iam/src/main/resources/db/domestic-container-permissions.sql`,最终权限 ID 固定为 `5103070`、`5103071`、`5103072`。
- 测试环境验收模块:`scripts/admin-test-env-modules/domestic-container.mjs`;合同测试:同目录 `domestic-container-contract.test.mjs`。
## 接口清单
| 用途 | 方法 | 路径 | 权限 |
|---|---|---|---|
| 入库单列表 | GET | `/inbounds` | `platform:domestic-container:list` |
| 新建入库单 | POST | `/inbounds` | `platform:domestic-container:inbound:create` |
| 出库单列表 | GET | `/outbounds` | `platform:domestic-container:list` |
| 新建出库单 | POST | `/outbounds` | `platform:domestic-container:outbound:create` |
| 库存列表及汇总 | GET | `/inventories` | `platform:domestic-container:list` |
| 库存详情/交易记录 | GET | `/inventories/{inventoryId}` | `platform:domestic-container:list` |
## 列表筛选
入库单与出库单使用相同查询参数:`page`(默认 1)、`size`(默认 20,最大 200)、`warehouseId`、`orderNo`、`containerNo`、`cargoName`、`status`。字符串按包含匹配,`status` 为精确匹配。
库存列表参数:`page`、`size`、`warehouseId`、`containerNo`、`cargoName`、`status`。库存响应的 `summary` 是当前全部筛选结果的汇总,不只汇总当前页。
## 新建入库
请求字段:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `warehouseId` | int64 | 是 | 平台国内仓 ID |
| `cargoName` | string | 是 | 货物名称,最长 128 |
| `containerNo` | string | 是 | 集装箱号,服务端转大写 |
| `boxCount` | integer | 是 | 入库箱数,大于 0 |
| `volumeCbm` | decimal | 是 | 入库体积,大于等于 0.0001,最多 4 位小数 |
| `inboundTime` | datetime | 是 | 入库操作时间 |
| `status` | enum | 是 | `PENDING` 或 `COMPLETED` |
| `remark` | string | 否 | 最长 500 |
| `idempotencyKey` | string | 是 | 每次用户提交生成的稳定唯一键,最长 128 |
`COMPLETED` 会在同一事务内增加库存并写入一条入库交易流水;`PENDING` 只创建待入库单,不影响库存。当前原型没有待单后续确认动作,本契约不提供确认/取消接口。
## 新建出库
请求字段:`warehouseId`、`containerNo`、`boxCount`、`outboundTime`、`status`、可选 `remark`、`idempotencyKey`、可选 `inventoryVersion`。
- 集装箱必须已在该国内仓形成库存;货物名称由库存快照带出,前端不提交。
- `COMPLETED` 会锁定库存、校验版本与可用箱数、原子扣减并写出库流水。
- `PENDING` 创建待出库单但不扣库存;`inventoryVersion` 可不传。
- 已读取库存详情时,`COMPLETED` 请求应回传其 `version`。版本过期返回 `A0409`,前端刷新库存后由用户重试。
- 出库数量小于可用库存后状态为 `PARTIALLY_OUTBOUND`;等于可用库存后为 `OUTBOUND`。
## 响应字段
创建响应 `data`:`id`、`orderNo`、`replayed`。`replayed=true` 表示同一幂等键及相同请求已处理,本次返回首次结果。
单据 `records[]`:`id`、`businessType`、`orderNo`、`warehouseId`、`warehouseName`、`containerNo`、`cargoName`、`boxCount`、`volumeCbm`、`operationTime`、`status`、`remark`、`version`、`createdAt`。出库单 `volumeCbm` 固定为 0,前端可不展示。
库存 `records[]`:`id`、`warehouseId`、`warehouseName`、`containerNo`、`cargoName`、`inboundBoxes`、`outboundBoxes`、`availableBoxes`、`inboundVolumeCbm`、`inventoryStatus`、`version`、`updatedAt`。
详情 `data`:`inventory`、`inboundOrderCount`、`outboundOrderCount`、`transactions[]`。交易字段为 `id`、`businessType`、`orderNo`、`quantity`、`operationTime`、`remark`,按操作时间倒序。
## 状态映射
| 后端枚举 | 页面文案 |
|---|---|
| `PENDING` | 待入库 / 待出库(按当前页签) |
| `COMPLETED` | 已入库 / 已出库(按当前页签) |
| `IN_STOCK` | 在库 |
| `PARTIALLY_OUTBOUND` | 部分出库 |
| `OUTBOUND` | 已出库 |
| `INBOUND` | 入库(交易类型) |
| `OUTBOUND` | 出库(交易类型) |
## 幂等与错误处理
- `idempotencyKey` 必须在同一操作类型内唯一。相同键、相同请求返回首次结果;相同键、不同请求返回 `A0409`。
- 前端在网络超时后必须使用原键重试,不要生成新键;用户主动再次创建时生成新键。
- 业务响应以 body `code` 判断:`200` 成功;`A0403` 无平台端身份或无权限;`A0404` 国内仓/整柜库存不存在或仓库未启用;`A0409` 幂等键冲突、处理中、库存不足或库存版本已变化;`A0422`/`A0400` 为字段格式或校验失败;`S0500`/`S0503` 为服务或基础设施异常。
- 出错时记录 `requestId`、`traceId` 和服务端 `message`,不要模拟成功或本地扣减库存。
## 权限与验收接入
Warehouse Controller 强制校验上述三条权限码。独立 IAM 迁移会幂等注册权限并授予 `PLATFORM_ADMIN`,同时检查权限 ID/编码冲突、权限数量和管理员授权数量。国内整柜保留 `5103070..5103072`;供应商权限不得复用该区间。
验收模块不会在导入时访问环境,也不包含网关或账号。只有获得测试环境写 API 授权后,由总编排显式调用:
```powershell
node scripts/admin-test-env-acceptance.mjs --modules domestic-container --output reports/admin-domestic-container.json
```
模块已安全加入 `admin-test-env-acceptance.mjs` 的 `DEFAULT_MODULES`,正式全量验收默认调用;动态模块加载逻辑无需修改。
@@ -7,7 +7,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 仓库微服务启动入口,启用 Spring Boot 自动配置并扫描仓库服务的 MyBatis Mapper。
*/
@MapperScan("com.baiyue.warehouse.mapper")
@MapperScan({"com.baiyue.warehouse.mapper", "com.baiyue.warehouse.domesticcontainer.mapper"})
@SpringBootApplication(scanBasePackages = "com.baiyue")
public class BaiyueWarehouseApplication {
public static void main(String[] args) { SpringApplication.run(BaiyueWarehouseApplication.class, args); }
@@ -15,11 +15,11 @@ import org.springframework.web.bind.annotation.*;
public class PlatformCustomerOverseasStorageFeeController {
@Resource private OverseasStorageFeeService service;
@GetMapping
@PreAuthorize("hasAuthority('platform:customer:view')")
@PreAuthorize("hasAnyAuthority('platform:customer:list','platform:customer:view')")
public ApiResponse<OverseasStorageFeeDtos.View> get(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long tenantId) { return ApiResponse.success(service.detailByTenant(tenantId)); }
@PutMapping
@PreAuthorize("hasAuthority('platform:customer:edit')")
@PreAuthorize("hasAnyAuthority('platform:overseas-storage-fee:edit','platform:customer:edit')")
public ApiResponse<Void> put(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId,
@Valid @RequestBody OverseasStorageFeeDtos.UpdateRequest request) {
if (request.customerId() != tenantId) {
@@ -0,0 +1,43 @@
package com.baiyue.warehouse.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.warehouse.dto.SupplierDtos.*;
import com.baiyue.warehouse.entity.SupplierEntity;
import com.baiyue.warehouse.service.SupplierService;
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 org.springdoc.core.annotations.ParameterObject;
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.*;
@Validated
@RestController
@Tag(name = "平台供应商")
@RequestMapping("/api/platform/v1/suppliers")
public class PlatformSupplierController {
@Resource private SupplierService service;
@GetMapping @PreAuthorize("hasAuthority('platform:supplier:list')")
@Operation(summary = "分页查询供应商")
public ApiResponse<PageResult<SupplierEntity>> page(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required=false) @Min(0) Long tenantId, @Valid @ParameterObject PageQuery query) { requirePlatform(user); requireScope(tenantId); return ApiResponse.success(service.page(query)); }
@GetMapping("/stats") @PreAuthorize("hasAuthority('platform:supplier:list')")
public ApiResponse<Stats> stats(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required=false) @Min(0) Long tenantId) { requirePlatform(user); requireScope(tenantId); return ApiResponse.success(service.stats()); }
@GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:supplier:list')")
public ApiResponse<SupplierEntity> detail(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @Min(0) long tenantId, @PathVariable long id) { requirePlatform(user); requireScope(tenantId); return ApiResponse.success(service.detail(id)); }
@PostMapping @PreAuthorize("hasAuthority('platform:supplier:create')")
public ApiResponse<Long> create(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @Min(0) long tenantId, @Valid @RequestBody SaveRequest request) { requirePlatform(user); requireScope(tenantId); return ApiResponse.success(service.create(request)); }
@PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:supplier:edit')")
public ApiResponse<Void> update(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @Min(0) long tenantId, @PathVariable long id, @Valid @RequestBody SaveRequest request) { requirePlatform(user); requireScope(tenantId); service.update(id, request); return ApiResponse.success(null); }
@PatchMapping("/{id}/status") @PreAuthorize("hasAuthority('platform:supplier:status')")
public ApiResponse<Void> status(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @Min(0) long tenantId, @PathVariable long id, @Valid @RequestBody StatusRequest request) { requirePlatform(user); requireScope(tenantId); service.changeStatus(id, request); return ApiResponse.success(null); }
private static void requirePlatform(AuthenticatedUser u) { if (u == null || !"PLATFORM".equalsIgnoreCase(u.appCode()) || !Long.valueOf(0L).equals(u.tenantId())) throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); }
private static void requireScope(Long id) { if (id != null && id != 0L) throw new BizException(ResultCode.BAD_REQUEST, "平台公共供应商作用域必须为0"); }
}
@@ -0,0 +1,68 @@
package com.baiyue.warehouse.domesticcontainer.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.warehouse.domesticcontainer.dto.DomesticContainerDtos;
import com.baiyue.warehouse.domesticcontainer.service.DomesticContainerService;
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 org.springdoc.core.annotations.ParameterObject;
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.*;
@Validated
@RestController
@RequestMapping("/api/platform/v1/domestic-containers")
@Tag(name = "Platform domestic full-container business / 平台国内仓整柜业务")
public class PlatformDomesticContainerController {
@Resource private DomesticContainerService service;
@GetMapping("/inbounds")
@PreAuthorize("hasAuthority('platform:domestic-container:list')")
@Operation(summary = "分页查询整柜入库单")
public ApiResponse<PageResult<DomesticContainerDtos.OrderView>> inbounds(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @ParameterObject DomesticContainerDtos.OrderQuery query) { requirePlatform(user); return ApiResponse.success(service.inboundPage(query)); }
@PostMapping("/inbounds")
@PreAuthorize("hasAuthority('platform:domestic-container:inbound:create')")
@Operation(summary = "新建整柜入库单", description = "COMPLETED 同事务增加库存并写入不可变交易流水;PENDING 仅创建待入库单。")
public ApiResponse<DomesticContainerDtos.CreateResult> createInbound(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody DomesticContainerDtos.CreateInboundRequest request) { requirePlatform(user); return ApiResponse.success(service.createInbound(request)); }
@GetMapping("/outbounds")
@PreAuthorize("hasAuthority('platform:domestic-container:list')")
@Operation(summary = "分页查询整柜出库单")
public ApiResponse<PageResult<DomesticContainerDtos.OrderView>> outbounds(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @ParameterObject DomesticContainerDtos.OrderQuery query) { requirePlatform(user); return ApiResponse.success(service.outboundPage(query)); }
@PostMapping("/outbounds")
@PreAuthorize("hasAuthority('platform:domestic-container:outbound:create')")
@Operation(summary = "新建整柜出库单", description = "COMPLETED 锁定库存并原子扣减,支持部分出库和全量出库;PENDING 不扣减库存。")
public ApiResponse<DomesticContainerDtos.CreateResult> createOutbound(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody DomesticContainerDtos.CreateOutboundRequest request) { requirePlatform(user); return ApiResponse.success(service.createOutbound(request)); }
@GetMapping("/inventories")
@PreAuthorize("hasAuthority('platform:domestic-container:list')")
@Operation(summary = "分页查询整柜库存和筛选范围汇总")
public ApiResponse<DomesticContainerDtos.InventoryPage> inventories(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @ParameterObject DomesticContainerDtos.InventoryQuery query) { requirePlatform(user); return ApiResponse.success(service.inventoryPageWithSummary(query)); }
@GetMapping("/inventories/{inventoryId}")
@PreAuthorize("hasAuthority('platform:domestic-container:list')")
@Operation(summary = "查询整柜库存详情和交易记录")
public ApiResponse<DomesticContainerDtos.DetailView> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Min(1) long inventoryId) { requirePlatform(user); return ApiResponse.success(service.detail(inventoryId)); }
private static void requirePlatform(AuthenticatedUser user) {
if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode()) || user.tenantId() == null || user.tenantId() != 0L)
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
}
@@ -0,0 +1,95 @@
package com.baiyue.warehouse.domesticcontainer.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Digits;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
public final class DomesticContainerDtos {
private DomesticContainerDtos() {
}
public record OrderQuery(@Min(1) long page, @Min(1) @Max(200) long size,
@Positive Long warehouseId, @Size(max = 40) String orderNo,
@Size(max = 32) String containerNo, @Size(max = 128) String cargoName,
@Pattern(regexp = "PENDING|COMPLETED") String status) {
public OrderQuery {
if (page == 0) page = 1;
if (size == 0) size = 20;
}
}
public record InventoryQuery(@Min(1) long page, @Min(1) @Max(200) long size,
@Positive Long warehouseId, @Size(max = 32) String containerNo,
@Size(max = 128) String cargoName,
@Pattern(regexp = "IN_STOCK|PARTIALLY_OUTBOUND|OUTBOUND") String status) {
public InventoryQuery {
if (page == 0) page = 1;
if (size == 0) size = 20;
}
}
public record CreateInboundRequest(
@NotNull @Positive Long warehouseId,
@NotBlank @Size(max = 128) String cargoName,
@NotBlank @Pattern(regexp = "[A-Za-z0-9-]{4,32}") String containerNo,
@NotNull @Min(1) @Max(999999999) Integer boxCount,
@NotNull @DecimalMin("0.0001") @Digits(integer = 12, fraction = 4) BigDecimal volumeCbm,
@NotNull LocalDateTime inboundTime,
@NotBlank @Pattern(regexp = "PENDING|COMPLETED") String status,
@Size(max = 500) String remark,
@NotBlank @Size(max = 128) String idempotencyKey) {
}
public record CreateOutboundRequest(
@NotNull @Positive Long warehouseId,
@NotBlank @Pattern(regexp = "[A-Za-z0-9-]{4,32}") String containerNo,
@NotNull @Min(1) @Max(999999999) Integer boxCount,
@NotNull LocalDateTime outboundTime,
@NotBlank @Pattern(regexp = "PENDING|COMPLETED") String status,
@Size(max = 500) String remark,
@NotBlank @Size(max = 128) String idempotencyKey,
@Schema(description = "库存乐观锁版本;待出库不扣减时可不传") @Min(0) Integer inventoryVersion) {
}
public record CreateResult(Long id, String orderNo, boolean replayed) {
}
public record OrderView(Long id, String businessType, String orderNo, Long warehouseId,
String warehouseName, String containerNo, String cargoName, Integer boxCount,
BigDecimal volumeCbm, LocalDateTime operationTime, String status, String remark,
Integer version, LocalDateTime createdAt) {
}
public record InventoryView(Long id, Long warehouseId, String warehouseName, String containerNo,
String cargoName, Integer inboundBoxes, Integer outboundBoxes,
Integer availableBoxes, BigDecimal inboundVolumeCbm, String inventoryStatus,
Integer version, LocalDateTime updatedAt) {
}
public record InventorySummary(long inboundBoxes, long outboundBoxes, long availableBoxes,
BigDecimal inboundVolumeCbm) {
}
public record InventoryPage(List<InventoryView> records, long page, long size, long total, long pages,
InventorySummary summary) {
}
public record TransactionView(Long id, String businessType, String orderNo, Integer quantity,
LocalDateTime operationTime, String remark) {
}
public record DetailView(InventoryView inventory, int inboundOrderCount, int outboundOrderCount,
List<TransactionView> transactions) {
}
}
@@ -0,0 +1,26 @@
package com.baiyue.warehouse.domesticcontainer.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("wh_domestic_container_idempotency")
public class DomesticContainerIdempotencyEntity {
@TableId(type = IdType.INPUT)
private Long id;
private Long tenantId;
private String operationType;
private String idempotencyKey;
private String requestHash;
private Long resourceId;
private String resourceNo;
private String operationStatus;
private LocalDateTime createdAt;
private LocalDateTime completedAt;
}
@@ -0,0 +1,28 @@
package com.baiyue.warehouse.domesticcontainer.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
@Getter
@Setter
@TableName("wh_domestic_container_inventory")
public class DomesticContainerInventoryEntity extends BaseEntity {
@TableId(type = IdType.INPUT)
private Long id;
private Long tenantId;
private Long warehouseId;
private String warehouseName;
private String containerNo;
private String cargoName;
private Integer inboundBoxes;
private Integer outboundBoxes;
private Integer availableBoxes;
private BigDecimal inboundVolumeCbm;
private String inventoryStatus;
}
@@ -0,0 +1,31 @@
package com.baiyue.warehouse.domesticcontainer.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("wh_domestic_container_order")
public class DomesticContainerOrderEntity extends BaseEntity {
@TableId(type = IdType.INPUT)
private Long id;
private Long tenantId;
private String businessType;
private String orderNo;
private Long warehouseId;
private String warehouseName;
private String containerNo;
private String cargoName;
private Integer boxCount;
private BigDecimal volumeCbm;
private LocalDateTime operationTime;
private String status;
private String remark;
}
@@ -0,0 +1,28 @@
package com.baiyue.warehouse.domesticcontainer.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("wh_domestic_container_transaction")
public class DomesticContainerTransactionEntity extends BaseEntity {
@TableId(type = IdType.INPUT)
private Long id;
private Long tenantId;
private Long inventoryId;
private Long warehouseId;
private String containerNo;
private String businessType;
private Long orderId;
private String orderNo;
private Integer quantity;
private LocalDateTime operationTime;
private String remark;
}
@@ -0,0 +1,23 @@
package com.baiyue.warehouse.domesticcontainer.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.domesticcontainer.entity.DomesticContainerIdempotencyEntity;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
public interface DomesticContainerIdempotencyMapper extends BaseMapper<DomesticContainerIdempotencyEntity> {
@Insert("INSERT IGNORE INTO wh_domestic_container_idempotency (id,tenant_id,operation_type,idempotency_key,request_hash,operation_status,created_at) VALUES (#{id},0,#{operationType},#{idempotencyKey},#{requestHash},'PROCESSING',CURRENT_TIMESTAMP(3))")
int claim(@Param("id") long id, @Param("operationType") String operationType,
@Param("idempotencyKey") String idempotencyKey, @Param("requestHash") String requestHash);
@Select("SELECT * FROM wh_domestic_container_idempotency WHERE tenant_id=0 AND operation_type=#{operationType} AND idempotency_key=#{idempotencyKey} LIMIT 1")
DomesticContainerIdempotencyEntity find(@Param("operationType") String operationType,
@Param("idempotencyKey") String idempotencyKey);
@Update("UPDATE wh_domestic_container_idempotency SET resource_id=#{resourceId},resource_no=#{resourceNo},operation_status='COMPLETED',completed_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=0 AND operation_type=#{operationType} AND idempotency_key=#{idempotencyKey} AND request_hash=#{requestHash} AND operation_status='PROCESSING'")
int complete(@Param("operationType") String operationType, @Param("idempotencyKey") String idempotencyKey,
@Param("requestHash") String requestHash, @Param("resourceId") long resourceId,
@Param("resourceNo") String resourceNo);
}
@@ -0,0 +1,15 @@
package com.baiyue.warehouse.domesticcontainer.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.domesticcontainer.entity.DomesticContainerInventoryEntity;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
public interface DomesticContainerInventoryMapper extends BaseMapper<DomesticContainerInventoryEntity> {
@Select("SELECT id FROM wh_warehouse WHERE tenant_id=0 AND id=#{warehouseId} AND warehouse_type='DOMESTIC' AND status='ACTIVE' AND deleted=0 FOR UPDATE")
Long lockWarehouse(@Param("warehouseId") long warehouseId);
@Select("SELECT * FROM wh_domestic_container_inventory WHERE tenant_id=0 AND warehouse_id=#{warehouseId} AND container_no=#{containerNo} AND deleted=0 FOR UPDATE")
DomesticContainerInventoryEntity lock(@Param("warehouseId") long warehouseId,
@Param("containerNo") String containerNo);
}
@@ -0,0 +1,7 @@
package com.baiyue.warehouse.domesticcontainer.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.domesticcontainer.entity.DomesticContainerOrderEntity;
public interface DomesticContainerOrderMapper extends BaseMapper<DomesticContainerOrderEntity> {
}
@@ -0,0 +1,7 @@
package com.baiyue.warehouse.domesticcontainer.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.domesticcontainer.entity.DomesticContainerTransactionEntity;
public interface DomesticContainerTransactionMapper extends BaseMapper<DomesticContainerTransactionEntity> {
}
@@ -0,0 +1,108 @@
package com.baiyue.warehouse.domesticcontainer.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.warehouse.domesticcontainer.dto.DomesticContainerDtos;
import com.baiyue.warehouse.domesticcontainer.entity.DomesticContainerIdempotencyEntity;
import com.baiyue.warehouse.domesticcontainer.entity.DomesticContainerInventoryEntity;
import com.baiyue.warehouse.domesticcontainer.entity.DomesticContainerOrderEntity;
import com.baiyue.warehouse.domesticcontainer.entity.DomesticContainerTransactionEntity;
import com.baiyue.warehouse.domesticcontainer.mapper.DomesticContainerIdempotencyMapper;
import com.baiyue.warehouse.domesticcontainer.mapper.DomesticContainerInventoryMapper;
import com.baiyue.warehouse.domesticcontainer.mapper.DomesticContainerOrderMapper;
import com.baiyue.warehouse.domesticcontainer.mapper.DomesticContainerTransactionMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.util.List;
@Repository
public class DomesticContainerRepository {
@Resource private DomesticContainerOrderMapper orders;
@Resource private DomesticContainerInventoryMapper inventories;
@Resource private DomesticContainerTransactionMapper transactions;
@Resource private DomesticContainerIdempotencyMapper idempotencies;
public PageResult<DomesticContainerOrderEntity> pageOrders(String type, DomesticContainerDtos.OrderQuery query) {
var wrapper = new LambdaQueryWrapper<DomesticContainerOrderEntity>()
.eq(DomesticContainerOrderEntity::getTenantId, 0L)
.eq(DomesticContainerOrderEntity::getBusinessType, type)
.eq(query.warehouseId() != null, DomesticContainerOrderEntity::getWarehouseId, query.warehouseId())
.like(StringUtils.hasText(query.orderNo()), DomesticContainerOrderEntity::getOrderNo, trim(query.orderNo()))
.like(StringUtils.hasText(query.containerNo()), DomesticContainerOrderEntity::getContainerNo, upper(query.containerNo()))
.like(StringUtils.hasText(query.cargoName()), DomesticContainerOrderEntity::getCargoName, trim(query.cargoName()))
.eq(StringUtils.hasText(query.status()), DomesticContainerOrderEntity::getStatus, query.status())
.orderByDesc(DomesticContainerOrderEntity::getOperationTime)
.orderByDesc(DomesticContainerOrderEntity::getId);
return PageResult.from(orders.selectPage(new Page<>(query.page(), query.size()), wrapper));
}
public PageResult<DomesticContainerInventoryEntity> pageInventories(DomesticContainerDtos.InventoryQuery query) {
var wrapper = inventoryWrapper(query).orderByDesc(DomesticContainerInventoryEntity::getUpdatedAt)
.orderByDesc(DomesticContainerInventoryEntity::getId);
return PageResult.from(inventories.selectPage(new Page<>(query.page(), query.size()), wrapper));
}
public List<DomesticContainerInventoryEntity> findInventories(DomesticContainerDtos.InventoryQuery query) {
return inventories.selectList(inventoryWrapper(query));
}
public DomesticContainerInventoryEntity findInventory(long id) {
return inventories.selectOne(new LambdaQueryWrapper<DomesticContainerInventoryEntity>()
.eq(DomesticContainerInventoryEntity::getTenantId, 0L)
.eq(DomesticContainerInventoryEntity::getId, id));
}
public DomesticContainerInventoryEntity lockInventory(long warehouseId, String containerNo) {
return inventories.lock(warehouseId, containerNo);
}
public void lockWarehouse(long warehouseId) { inventories.lockWarehouse(warehouseId); }
public void insertOrder(DomesticContainerOrderEntity entity) { orders.insert(entity); }
public void insertInventory(DomesticContainerInventoryEntity entity) { inventories.insert(entity); }
public int updateInventory(DomesticContainerInventoryEntity entity) { return inventories.updateById(entity); }
public void insertTransaction(DomesticContainerTransactionEntity entity) { transactions.insert(entity); }
public long countOrders(String type, long warehouseId, String containerNo) {
return orders.selectCount(new LambdaQueryWrapper<DomesticContainerOrderEntity>()
.eq(DomesticContainerOrderEntity::getTenantId, 0L)
.eq(DomesticContainerOrderEntity::getBusinessType, type)
.eq(DomesticContainerOrderEntity::getWarehouseId, warehouseId)
.eq(DomesticContainerOrderEntity::getContainerNo, containerNo));
}
public List<DomesticContainerTransactionEntity> transactions(long inventoryId) {
return transactions.selectList(new LambdaQueryWrapper<DomesticContainerTransactionEntity>()
.eq(DomesticContainerTransactionEntity::getTenantId, 0L)
.eq(DomesticContainerTransactionEntity::getInventoryId, inventoryId)
.orderByDesc(DomesticContainerTransactionEntity::getOperationTime)
.orderByDesc(DomesticContainerTransactionEntity::getId));
}
public int claim(long id, String operationType, String key, String hash) {
return idempotencies.claim(id, operationType, key, hash);
}
public DomesticContainerIdempotencyEntity findClaim(String operationType, String key) {
return idempotencies.find(operationType, key);
}
public int completeClaim(String operationType, String key, String hash, long id, String no) {
return idempotencies.complete(operationType, key, hash, id, no);
}
private LambdaQueryWrapper<DomesticContainerInventoryEntity> inventoryWrapper(DomesticContainerDtos.InventoryQuery query) {
return new LambdaQueryWrapper<DomesticContainerInventoryEntity>()
.eq(DomesticContainerInventoryEntity::getTenantId, 0L)
.eq(query.warehouseId() != null, DomesticContainerInventoryEntity::getWarehouseId, query.warehouseId())
.like(StringUtils.hasText(query.containerNo()), DomesticContainerInventoryEntity::getContainerNo, upper(query.containerNo()))
.like(StringUtils.hasText(query.cargoName()), DomesticContainerInventoryEntity::getCargoName, trim(query.cargoName()))
.eq(StringUtils.hasText(query.status()), DomesticContainerInventoryEntity::getInventoryStatus, query.status());
}
private static String trim(String value) { return value == null ? null : value.trim(); }
private static String upper(String value) { return trim(value).toUpperCase(java.util.Locale.ROOT); }
}
@@ -0,0 +1,115 @@
package com.baiyue.warehouse.domesticcontainer.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.domesticcontainer.dto.DomesticContainerDtos;
import com.baiyue.warehouse.domesticcontainer.entity.*;
import com.baiyue.warehouse.domesticcontainer.repository.DomesticContainerRepository;
import com.baiyue.warehouse.entity.WarehouseEntity;
import com.baiyue.warehouse.repository.WarehouseRepository;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.List;
@Service
public class DomesticContainerService {
private static final long PLATFORM_TENANT = 0L;
@Resource private DomesticContainerRepository repository;
@Resource private WarehouseRepository warehouseRepository;
public PageResult<DomesticContainerDtos.OrderView> inboundPage(DomesticContainerDtos.OrderQuery query) {
return mapOrders(repository.pageOrders("INBOUND", query));
}
public PageResult<DomesticContainerDtos.OrderView> outboundPage(DomesticContainerDtos.OrderQuery query) {
return mapOrders(repository.pageOrders("OUTBOUND", query));
}
public PageResult<DomesticContainerDtos.InventoryView> inventoryPage(DomesticContainerDtos.InventoryQuery query) {
return mapInventories(repository.pageInventories(query));
}
public DomesticContainerDtos.InventoryPage inventoryPageWithSummary(DomesticContainerDtos.InventoryQuery query) {
PageResult<DomesticContainerInventoryEntity> page = repository.pageInventories(query);
var summary = repository.findInventories(query).stream().reduce(new DomesticContainerDtos.InventorySummary(0,0,0,BigDecimal.ZERO),
(s, e) -> new DomesticContainerDtos.InventorySummary(s.inboundBoxes()+e.getInboundBoxes(), s.outboundBoxes()+e.getOutboundBoxes(),
s.availableBoxes()+e.getAvailableBoxes(), s.inboundVolumeCbm().add(e.getInboundVolumeCbm())),
(a,b) -> new DomesticContainerDtos.InventorySummary(a.inboundBoxes()+b.inboundBoxes(),a.outboundBoxes()+b.outboundBoxes(),a.availableBoxes()+b.availableBoxes(),a.inboundVolumeCbm().add(b.inboundVolumeCbm())));
return new DomesticContainerDtos.InventoryPage(mapInventories(page).records(), page.page(), page.size(), page.total(), page.pages(), summary);
}
@Transactional
@BusinessAudit(event = "DOMESTIC_CONTAINER_INBOUND_CREATE", resourceType = "DOMESTIC_CONTAINER_ORDER", description = "新增整柜入库单", captureBefore = false)
public DomesticContainerDtos.CreateResult createInbound(DomesticContainerDtos.CreateInboundRequest request) {
String hash = hash(request.toString());
DomesticContainerDtos.CreateResult concurrentReplay = claim("INBOUND", request.idempotencyKey(), hash);
if (concurrentReplay != null) return concurrentReplay;
WarehouseEntity warehouse = domesticWarehouse(request.warehouseId());
repository.lockWarehouse(warehouse.getId());
String container = normalize(request.containerNo());
DomesticContainerOrderEntity order = new DomesticContainerOrderEntity();
order.setId(IdWorker.getId()); order.setTenantId(PLATFORM_TENANT); order.setBusinessType("INBOUND");
order.setOrderNo(nextNo("ZGRK")); order.setWarehouseId(warehouse.getId()); order.setWarehouseName(warehouse.getWarehouseName());
order.setContainerNo(container); order.setCargoName(request.cargoName().trim()); order.setBoxCount(request.boxCount());
order.setVolumeCbm(request.volumeCbm()); order.setOperationTime(request.inboundTime()); order.setStatus(request.status()); order.setRemark(request.remark());
if ("COMPLETED".equals(request.status())) {
DomesticContainerInventoryEntity inventory = repository.lockInventory(warehouse.getId(), container);
if (inventory == null) { inventory = newInventory(warehouse, container, order); repository.insertInventory(inventory); }
else { if (!inventory.getCargoName().equals(order.getCargoName())) throw new BizException(ResultCode.CONFLICT, "同一国内仓的集装箱货物名称不一致"); inventory.setInboundBoxes(inventory.getInboundBoxes()+order.getBoxCount()); inventory.setAvailableBoxes(inventory.getAvailableBoxes()+order.getBoxCount()); inventory.setInboundVolumeCbm(inventory.getInboundVolumeCbm().add(order.getVolumeCbm())); inventory.setInventoryStatus(status(inventory)); requireUpdated(repository.updateInventory(inventory)); }
repository.insertOrder(order); transaction(inventory, order, "INBOUND");
} else repository.insertOrder(order);
requireClaimCompleted(repository.completeClaim("INBOUND", request.idempotencyKey(), hash, order.getId(), order.getOrderNo()));
return new DomesticContainerDtos.CreateResult(order.getId(), order.getOrderNo(), false);
}
@Transactional
@BusinessAudit(event = "DOMESTIC_CONTAINER_OUTBOUND_CREATE", resourceType = "DOMESTIC_CONTAINER_ORDER", description = "新增整柜出库单", captureBefore = false)
public DomesticContainerDtos.CreateResult createOutbound(DomesticContainerDtos.CreateOutboundRequest request) {
String hash = hash(request.toString());
DomesticContainerDtos.CreateResult concurrentReplay = claim("OUTBOUND", request.idempotencyKey(), hash);
if (concurrentReplay != null) return concurrentReplay;
WarehouseEntity warehouse = domesticWarehouse(request.warehouseId());
repository.lockWarehouse(warehouse.getId());
DomesticContainerInventoryEntity inventory = repository.lockInventory(warehouse.getId(), normalize(request.containerNo()));
if (inventory == null) throw new BizException(ResultCode.NOT_FOUND, "整柜库存不存在");
if ("COMPLETED".equals(request.status())) {
if (request.inventoryVersion() != null && !request.inventoryVersion().equals(inventory.getVersion())) throw new BizException(ResultCode.CONFLICT, "库存已被修改,请刷新后重试");
if (request.boxCount() > inventory.getAvailableBoxes()) throw new BizException(ResultCode.CONFLICT, "出库箱数超过可用库存");
}
DomesticContainerOrderEntity order = new DomesticContainerOrderEntity(); order.setId(IdWorker.getId()); order.setTenantId(PLATFORM_TENANT); order.setBusinessType("OUTBOUND");
order.setOrderNo(nextNo("ZGCK")); order.setWarehouseId(warehouse.getId()); order.setWarehouseName(warehouse.getWarehouseName()); order.setContainerNo(inventory.getContainerNo());
order.setCargoName(inventory.getCargoName()); order.setBoxCount(request.boxCount()); order.setVolumeCbm(BigDecimal.ZERO); order.setOperationTime(request.outboundTime()); order.setStatus(request.status()); order.setRemark(request.remark());
repository.insertOrder(order);
if ("COMPLETED".equals(request.status())) { inventory.setOutboundBoxes(inventory.getOutboundBoxes()+order.getBoxCount()); inventory.setAvailableBoxes(inventory.getAvailableBoxes()-order.getBoxCount()); inventory.setInventoryStatus(status(inventory)); requireUpdated(repository.updateInventory(inventory)); transaction(inventory, order, "OUTBOUND"); }
requireClaimCompleted(repository.completeClaim("OUTBOUND", request.idempotencyKey(), hash, order.getId(), order.getOrderNo()));
return new DomesticContainerDtos.CreateResult(order.getId(), order.getOrderNo(), false);
}
public DomesticContainerDtos.DetailView detail(long inventoryId) {
DomesticContainerInventoryEntity inventory = repository.findInventory(inventoryId);
if (inventory == null) throw new BizException(ResultCode.NOT_FOUND, "整柜库存不存在");
return new DomesticContainerDtos.DetailView(toInventory(inventory), (int) repository.countOrders("INBOUND", inventory.getWarehouseId(), inventory.getContainerNo()), (int) repository.countOrders("OUTBOUND", inventory.getWarehouseId(), inventory.getContainerNo()), repository.transactions(inventoryId).stream().map(t -> new DomesticContainerDtos.TransactionView(t.getId(), t.getBusinessType(), t.getOrderNo(), t.getQuantity(), t.getOperationTime(), t.getRemark())).toList());
}
private WarehouseEntity domesticWarehouse(long id) { WarehouseEntity w = warehouseRepository.findActiveOwned(0L, id); if (w == null || !"DOMESTIC".equals(w.getWarehouseType())) throw new BizException(ResultCode.NOT_FOUND, "平台国内仓不存在或未启用"); return w; }
private DomesticContainerInventoryEntity newInventory(WarehouseEntity w, String c, DomesticContainerOrderEntity o) { DomesticContainerInventoryEntity i = new DomesticContainerInventoryEntity(); i.setId(IdWorker.getId()); i.setTenantId(0L); i.setWarehouseId(w.getId()); i.setWarehouseName(w.getWarehouseName()); i.setContainerNo(c); i.setCargoName(o.getCargoName()); i.setInboundBoxes(o.getBoxCount()); i.setOutboundBoxes(0); i.setAvailableBoxes(o.getBoxCount()); i.setInboundVolumeCbm(o.getVolumeCbm()); i.setInventoryStatus("IN_STOCK"); return i; }
private void transaction(DomesticContainerInventoryEntity i, DomesticContainerOrderEntity o, String type) { DomesticContainerTransactionEntity t = new DomesticContainerTransactionEntity(); t.setId(IdWorker.getId()); t.setTenantId(0L); t.setInventoryId(i.getId()); t.setWarehouseId(i.getWarehouseId()); t.setContainerNo(i.getContainerNo()); t.setBusinessType(type); t.setOrderId(o.getId()); t.setOrderNo(o.getOrderNo()); t.setQuantity(o.getBoxCount()); t.setOperationTime(o.getOperationTime()); t.setRemark(o.getRemark()); repository.insertTransaction(t); }
private static String status(DomesticContainerInventoryEntity i) { return i.getAvailableBoxes() <= 0 ? "OUTBOUND" : i.getOutboundBoxes() > 0 ? "PARTIALLY_OUTBOUND" : "IN_STOCK"; }
private void requireUpdated(int count) { if (count != 1) throw new BizException(ResultCode.CONFLICT, "库存已被并发修改,请刷新后重试"); }
private void requireClaimCompleted(int count) { if (count != 1) throw new BizException(ResultCode.CONFLICT, "幂等操作状态已变化,请使用原键重试"); }
private DomesticContainerDtos.CreateResult claim(String type, String key, String hash) { try { if (repository.claim(IdWorker.getId(), type, key, hash) == 1) return null; } catch (DuplicateKeyException ignored) {} DomesticContainerIdempotencyEntity e = repository.findClaim(type, key); if (e == null || !hash.equals(e.getRequestHash())) throw new BizException(ResultCode.CONFLICT, "幂等键已用于其他请求"); if ("COMPLETED".equals(e.getOperationStatus())) return new DomesticContainerDtos.CreateResult(e.getResourceId(), e.getResourceNo(), true); throw new BizException(ResultCode.CONFLICT, "幂等请求正在处理中"); }
private String nextNo(String prefix) { return prefix + java.time.format.DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS").format(LocalDateTime.now()) + IdWorker.getIdStr().substring(10); }
private static String normalize(String s) { return s.trim().toUpperCase(java.util.Locale.ROOT); }
private static String hash(String value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw new IllegalStateException(e); } }
private PageResult<DomesticContainerDtos.OrderView> mapOrders(PageResult<DomesticContainerOrderEntity> p) { return new PageResult<>(p.records().stream().map(o -> new DomesticContainerDtos.OrderView(o.getId(),o.getBusinessType(),o.getOrderNo(),o.getWarehouseId(),o.getWarehouseName(),o.getContainerNo(),o.getCargoName(),o.getBoxCount(),o.getVolumeCbm(),o.getOperationTime(),o.getStatus(),o.getRemark(),o.getVersion(),o.getCreatedAt())).toList(),p.page(),p.size(),p.total(),p.pages()); }
private PageResult<DomesticContainerDtos.InventoryView> mapInventories(PageResult<DomesticContainerInventoryEntity> p) { return new PageResult<>(p.records().stream().map(this::toInventory).toList(),p.page(),p.size(),p.total(),p.pages()); }
private DomesticContainerDtos.InventoryView toInventory(DomesticContainerInventoryEntity i) { return new DomesticContainerDtos.InventoryView(i.getId(),i.getWarehouseId(),i.getWarehouseName(),i.getContainerNo(),i.getCargoName(),i.getInboundBoxes(),i.getOutboundBoxes(),i.getAvailableBoxes(),i.getInboundVolumeCbm(),i.getInventoryStatus(),i.getVersion(),i.getUpdatedAt()); }
}
@@ -0,0 +1,45 @@
package com.baiyue.warehouse.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public final class SupplierDtos {
private SupplierDtos() {}
public record PageQuery(@Min(1) Integer page, @Min(1) @Max(200) Integer size,
@Pattern(regexp = "BOOKING|CUSTOMS|TRUCKING|CERTIFICATE") String category,
@Pattern(regexp = "DOMESTIC|INTERNATIONAL") String scope,
@Size(max = 80) String region,
@Pattern(regexp = "ACTIVE|INACTIVE") String status,
String keyword) {
public PageQuery {
page = page == null ? 1 : page;
size = size == null ? 20 : size;
}
}
public record SaveRequest(
@NotBlank @Size(max = 160) String name,
@NotBlank @Pattern(regexp = "BOOKING|CUSTOMS|TRUCKING|CERTIFICATE") String category,
@Pattern(regexp = "DOMESTIC|INTERNATIONAL") String scope,
@NotBlank @Size(max = 80) String region,
@NotBlank @Size(max = 80) String legalRepresentative,
@NotBlank @Size(max = 80) String contactName,
@NotBlank @Size(max = 40) String phone,
@Email @Size(max = 160) String email,
@Size(max = 255) String address,
@Size(max = 64) String businessLicenseNo,
@Pattern(regexp = "\\d+") @Size(max = 128) String licenseFileId,
@NotBlank @Pattern(regexp = "ACTIVE|INACTIVE") String status,
@NotBlank @Pattern(regexp = "MONTHLY_30|MONTHLY_45|MONTHLY_60|MONTHLY_90|PER_TRANSACTION|IMMEDIATE") String settlementPeriod,
@Size(max = 500) String remark) {}
public record StatusRequest(@NotNull @Pattern(regexp = "ACTIVE|INACTIVE") String status) {}
public record Stats(long booking, long customs, long trucking, long certificate) {}
}
@@ -0,0 +1,34 @@
package com.baiyue.warehouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
/** 平台公共供应商业务档案,与往来单位严格分离。 */
@Getter
@Setter
@TableName("wh_supplier")
@Schema(name = "Supplier", description = "平台公共供应商档案")
public class SupplierEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String name;
private String category;
private String scope;
private String region;
private String legalRepresentative;
private String contactName;
private String phone;
private String email;
private String address;
private String businessLicenseNo;
private String licenseFileId;
private String status;
private String settlementPeriod;
private String remark;
}
@@ -0,0 +1,6 @@
package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.SupplierEntity;
public interface SupplierMapper extends BaseMapper<SupplierEntity> {}
@@ -0,0 +1,38 @@
package com.baiyue.warehouse.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.warehouse.dto.SupplierDtos.PageQuery;
import com.baiyue.warehouse.entity.SupplierEntity;
import com.baiyue.warehouse.mapper.SupplierMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
@Repository
public class SupplierRepository {
@Resource private SupplierMapper mapper;
public PageResult<SupplierEntity> page(long tenantId, PageQuery query) {
LambdaQueryWrapper<SupplierEntity> w = base(tenantId)
.eq(StringUtils.hasText(query.category()), SupplierEntity::getCategory, query.category())
.eq(StringUtils.hasText(query.scope()), SupplierEntity::getScope, query.scope())
.eq(StringUtils.hasText(query.region()), SupplierEntity::getRegion, query.region())
.eq(StringUtils.hasText(query.status()), SupplierEntity::getStatus, query.status())
.and(StringUtils.hasText(query.keyword()), c -> c.like(SupplierEntity::getName, query.keyword())
.or().like(SupplierEntity::getContactName, query.keyword()))
.orderByAsc(SupplierEntity::getName).orderByAsc(SupplierEntity::getId);
return PageResult.from(mapper.selectPage(new Page<>(query.page(), query.size()), w));
}
public SupplierEntity find(long tenantId, long id) { return mapper.selectOne(base(tenantId).eq(SupplierEntity::getId, id)); }
public boolean existsName(long tenantId, String name, Long excludedId) {
return mapper.selectCount(base(tenantId).eq(SupplierEntity::getName, name)
.ne(excludedId != null, SupplierEntity::getId, excludedId)) > 0;
}
public long count(long tenantId, String category) { return mapper.selectCount(base(tenantId).eq(SupplierEntity::getCategory, category)); }
public void insert(SupplierEntity entity) { mapper.insert(entity); }
public void save(SupplierEntity entity) { mapper.updateById(entity); }
private LambdaQueryWrapper<SupplierEntity> base(long tenantId) { return new LambdaQueryWrapper<SupplierEntity>().eq(SupplierEntity::getTenantId, tenantId); }
}
@@ -0,0 +1,49 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.SupplierDtos.*;
import com.baiyue.warehouse.entity.SupplierEntity;
import com.baiyue.warehouse.repository.SupplierRepository;
import jakarta.annotation.Resource;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class SupplierService {
private static final long PUBLIC_TENANT_ID = 0L;
@Resource private SupplierRepository repository;
public PageResult<SupplierEntity> page(PageQuery query) { return repository.page(PUBLIC_TENANT_ID, query); }
public SupplierEntity detail(long id) { return owned(id); }
public Stats stats() { return new Stats(repository.count(0, "BOOKING"), repository.count(0, "CUSTOMS"), repository.count(0, "TRUCKING"), repository.count(0, "CERTIFICATE")); }
@Transactional
@BusinessAudit(event = "SUPPLIER_CREATE", resourceType = "SUPPLIER", resourceId = "#request.name", description = "新增供应商", captureBefore = false)
public Long create(SaveRequest request) {
validateScope(request);
ensureName(request.name(), null);
SupplierEntity entity = new SupplierEntity(); entity.setTenantId(0L); copy(entity, request);
try { repository.insert(entity); } catch (DataIntegrityViolationException e) { throw new BizException(ResultCode.CONFLICT, "供应商名称已存在"); }
return entity.getId();
}
@Transactional
@BusinessAudit(event = "SUPPLIER_UPDATE", resourceType = "SUPPLIER", resourceId = "#id", description = "编辑供应商")
public void update(long id, SaveRequest request) {
SupplierEntity e = owned(id); validateScope(request); ensureName(request.name(), id); copy(e, request);
try { repository.save(e); } catch (DataIntegrityViolationException exception) { throw new BizException(ResultCode.CONFLICT, "供应商名称已存在"); }
}
@Transactional
@BusinessAudit(event = "SUPPLIER_STATUS_CHANGE", resourceType = "SUPPLIER", resourceId = "#id", description = "启停供应商")
public void changeStatus(long id, StatusRequest request) { SupplierEntity e = owned(id); e.setStatus(request.status()); repository.save(e); }
private SupplierEntity owned(long id) { SupplierEntity e = repository.find(0L, id); if (e == null) throw new BizException(ResultCode.NOT_FOUND, "供应商不存在"); return e; }
private void ensureName(String name, Long id) { if (repository.existsName(0L, name, id)) throw new BizException(ResultCode.CONFLICT, "供应商名称已存在"); }
private static void validateScope(SaveRequest r) { if (r.category().equals("BOOKING") != (r.scope() != null)) throw new BizException(ResultCode.VALIDATION_FAILED, "国内/国外范围仅适用于订舱供应商"); }
private static void copy(SupplierEntity e, SaveRequest r) { e.setName(r.name()); e.setCategory(r.category()); e.setScope(r.scope()); e.setRegion(r.region()); e.setLegalRepresentative(r.legalRepresentative()); e.setContactName(r.contactName()); e.setPhone(r.phone()); e.setEmail(r.email()); e.setAddress(r.address()); e.setBusinessLicenseNo(r.businessLicenseNo()); e.setLicenseFileId(r.licenseFileId()); e.setStatus(r.status()); e.setSettlementPeriod(r.settlementPeriod()); e.setRemark(r.remark()); }
}
@@ -24,7 +24,11 @@ public class WarehouseSchemaReadinessService {
"SELECT id, tenant_id, warehouse_id, pallet_id, location_id, shared_overflow, reservation_status, expires_at, version FROM wh_location_reservation LIMIT 0",
"SELECT id, tenant_id, warehouse_id, pallet_id, location_id, shared_overflow, occupancy_status, active_location_occupancy, active_pallet_occupancy, version FROM wh_location_occupancy LIMIT 0",
"SELECT id, tenant_id, customer_name, currency, version FROM wh_overseas_storage_fee_rule LIMIT 0",
"SELECT id, tenant_id, rule_id, start_day, end_day, unit_price, sort_no, version FROM wh_overseas_storage_fee_tier LIMIT 0"
"SELECT id, tenant_id, rule_id, start_day, end_day, unit_price, sort_no, version FROM wh_overseas_storage_fee_tier LIMIT 0",
"SELECT id, tenant_id, business_type, order_no, warehouse_id, container_no, box_count, status, version FROM wh_domestic_container_order LIMIT 0",
"SELECT id, tenant_id, warehouse_id, container_no, inbound_boxes, outbound_boxes, available_boxes, inventory_status, version FROM wh_domestic_container_inventory LIMIT 0",
"SELECT id, tenant_id, inventory_id, business_type, order_id, quantity, operation_time FROM wh_domestic_container_transaction LIMIT 0",
"SELECT id, tenant_id, operation_type, idempotency_key, request_hash, operation_status FROM wh_domestic_container_idempotency LIMIT 0"
);
@Resource
@@ -0,0 +1,65 @@
SET NAMES utf8mb4;
CREATE DATABASE IF NOT EXISTS baiyue_warehouse DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_warehouse;
CREATE TABLE IF NOT EXISTS wh_domestic_container_order (
id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共作用域,固定为0',
business_type VARCHAR(16) NOT NULL COMMENT 'INBOUND或OUTBOUND', order_no VARCHAR(40) NOT NULL,
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '平台国内仓ID', warehouse_name VARCHAR(120) NOT NULL,
container_no VARCHAR(32) NOT NULL, cargo_name VARCHAR(128) NOT NULL, box_count INT UNSIGNED NOT NULL,
volume_cbm DECIMAL(16,4) NOT NULL DEFAULT 0, operation_time DATETIME(3) NOT NULL,
status VARCHAR(16) NOT NULL COMMENT 'PENDING或COMPLETED', remark VARCHAR(500) NULL,
created_by BIGINT UNSIGNED NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT UNSIGNED NULL, updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT(1) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0,
PRIMARY KEY (id), UNIQUE KEY uk_dc_order_no (tenant_id, order_no, deleted),
KEY idx_dc_order_list (tenant_id, business_type, warehouse_id, status, operation_time),
KEY idx_dc_order_container (tenant_id, warehouse_id, container_no, business_type),
CONSTRAINT chk_dc_order_scope CHECK (tenant_id=0), CONSTRAINT chk_dc_order_type CHECK (business_type IN ('INBOUND','OUTBOUND')),
CONSTRAINT chk_dc_order_status CHECK (status IN ('PENDING','COMPLETED')),
CONSTRAINT chk_dc_order_quantity CHECK (box_count>0 AND volume_cbm>=0)
) ENGINE=InnoDB COMMENT='国内仓独立整柜入出库单,不复用头程装柜货柜模型';
CREATE TABLE IF NOT EXISTS wh_domestic_container_inventory (
id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
warehouse_id BIGINT UNSIGNED NOT NULL, warehouse_name VARCHAR(120) NOT NULL,
container_no VARCHAR(32) NOT NULL, cargo_name VARCHAR(128) NOT NULL,
inbound_boxes INT UNSIGNED NOT NULL, outbound_boxes INT UNSIGNED NOT NULL DEFAULT 0,
available_boxes INT UNSIGNED NOT NULL, inbound_volume_cbm DECIMAL(16,4) NOT NULL,
inventory_status VARCHAR(24) NOT NULL COMMENT 'IN_STOCK、PARTIALLY_OUTBOUND或OUTBOUND',
created_by BIGINT UNSIGNED NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT UNSIGNED NULL, updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT(1) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0,
PRIMARY KEY (id), UNIQUE KEY uk_dc_inventory_container (tenant_id, warehouse_id, container_no, deleted),
KEY idx_dc_inventory_list (tenant_id, warehouse_id, inventory_status, updated_at),
CONSTRAINT chk_dc_inventory_scope CHECK (tenant_id=0),
CONSTRAINT chk_dc_inventory_balance CHECK (inbound_boxes=outbound_boxes+available_boxes),
CONSTRAINT chk_dc_inventory_status CHECK (inventory_status IN ('IN_STOCK','PARTIALLY_OUTBOUND','OUTBOUND'))
) ENGINE=InnoDB COMMENT='平台国内仓整柜库存台账';
CREATE TABLE IF NOT EXISTS wh_domestic_container_transaction (
id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
inventory_id BIGINT UNSIGNED NOT NULL, warehouse_id BIGINT UNSIGNED NOT NULL, container_no VARCHAR(32) NOT NULL,
business_type VARCHAR(16) NOT NULL, order_id BIGINT UNSIGNED NOT NULL, order_no VARCHAR(40) NOT NULL,
quantity INT UNSIGNED NOT NULL, operation_time DATETIME(3) NOT NULL, remark VARCHAR(500) NULL,
created_by BIGINT UNSIGNED NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT UNSIGNED NULL, updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT(1) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0,
PRIMARY KEY (id), UNIQUE KEY uk_dc_transaction_order (tenant_id, business_type, order_id),
KEY idx_dc_transaction_detail (tenant_id, inventory_id, operation_time),
CONSTRAINT chk_dc_transaction_scope CHECK (tenant_id=0), CONSTRAINT chk_dc_transaction_type CHECK (business_type IN ('INBOUND','OUTBOUND')),
CONSTRAINT chk_dc_transaction_quantity CHECK (quantity>0)
) ENGINE=InnoDB COMMENT='国内仓整柜不可变进销存交易流水';
CREATE TABLE IF NOT EXISTS wh_domestic_container_idempotency (
id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
operation_type VARCHAR(16) NOT NULL, idempotency_key VARCHAR(128) NOT NULL, request_hash CHAR(64) NOT NULL,
resource_id BIGINT UNSIGNED NULL, resource_no VARCHAR(40) NULL, operation_status VARCHAR(16) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), completed_at DATETIME(3) NULL,
PRIMARY KEY (id), UNIQUE KEY uk_dc_idempotency (tenant_id, operation_type, idempotency_key),
CONSTRAINT chk_dc_idempotency_scope CHECK (tenant_id=0), CONSTRAINT chk_dc_idempotency_type CHECK (operation_type IN ('INBOUND','OUTBOUND')),
CONSTRAINT chk_dc_idempotency_status CHECK (operation_status IN ('PROCESSING','COMPLETED'))
) ENGINE=InnoDB COMMENT='国内仓整柜写操作幂等记录';
-- IAM 权限由统一部署脚本合并:platform:domestic-container:list、
-- platform:domestic-container:inbound:create、platform:domestic-container:outbound:create。
@@ -0,0 +1,91 @@
-- 平台公共供应商业务档案。可用于空库初始化,也可重复执行升级存量结构。
USE baiyue_warehouse;
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS wh_supplier (
id BIGINT UNSIGNED NOT NULL COMMENT '供应商ID',
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共作用域,固定为0',
name VARCHAR(160) NOT NULL COMMENT '供应商名称',
category VARCHAR(20) NOT NULL COMMENT 'BOOKING/CUSTOMS/TRUCKING/CERTIFICATE',
scope VARCHAR(20) NULL COMMENT '订舱分类范围:DOMESTIC/INTERNATIONAL',
region VARCHAR(80) NOT NULL COMMENT '地区',
legal_representative VARCHAR(80) NOT NULL COMMENT '法人代表',
contact_name VARCHAR(80) NOT NULL COMMENT '联系人',
phone VARCHAR(40) NOT NULL COMMENT '联系电话',
email VARCHAR(160) NULL COMMENT '电子邮箱',
address VARCHAR(255) NULL COMMENT '公司地址',
business_license_no VARCHAR(64) NULL COMMENT '营业执照号',
license_file_id VARCHAR(128) NULL COMMENT 'File服务营业执照文件ID',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT 'ACTIVE/INACTIVE',
settlement_period VARCHAR(32) NOT NULL COMMENT '结算周期',
remark VARCHAR(500) NULL COMMENT '备注',
created_by BIGINT UNSIGNED NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后修改时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
PRIMARY KEY (id),
UNIQUE KEY uk_wh_supplier_name (tenant_id,name),
KEY idx_wh_supplier_filter (tenant_id,category,status,region),
CONSTRAINT chk_wh_supplier_category CHECK (category IN ('BOOKING','CUSTOMS','TRUCKING','CERTIFICATE')),
CONSTRAINT chk_wh_supplier_scope CHECK ((category='BOOKING' AND scope IN ('DOMESTIC','INTERNATIONAL')) OR (category<>'BOOKING' AND scope IS NULL)),
CONSTRAINT chk_wh_supplier_status CHECK (status IN ('ACTIVE','INACTIVE')),
CONSTRAINT chk_wh_supplier_settlement CHECK (settlement_period IN ('MONTHLY_30','MONTHLY_45','MONTHLY_60','MONTHLY_90','PER_TRANSACTION','IMMEDIATE'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台公共供应商业务档案';
DROP PROCEDURE IF EXISTS supplier_add_column_if_missing;
DELIMITER $$
CREATE PROCEDURE supplier_add_column_if_missing(IN p_name VARCHAR(64), IN p_definition TEXT)
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND column_name=p_name) THEN
SET @supplier_ddl=CONCAT('ALTER TABLE wh_supplier ADD COLUMN ',p_name,' ',p_definition);
PREPARE supplier_statement FROM @supplier_ddl; EXECUTE supplier_statement; DEALLOCATE PREPARE supplier_statement;
END IF;
END$$
DELIMITER ;
CALL supplier_add_column_if_missing('scope', "VARCHAR(20) NULL COMMENT '订舱分类范围:DOMESTIC/INTERNATIONAL' AFTER category");
CALL supplier_add_column_if_missing('license_file_id', "VARCHAR(128) NULL COMMENT 'File服务营业执照文件ID' AFTER business_license_no");
CALL supplier_add_column_if_missing('created_by', "BIGINT UNSIGNED NULL COMMENT '创建人ID'");
CALL supplier_add_column_if_missing('created_at', "DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间'");
CALL supplier_add_column_if_missing('updated_by', "BIGINT UNSIGNED NULL COMMENT '最后修改人ID'");
CALL supplier_add_column_if_missing('updated_at', "DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后修改时间'");
CALL supplier_add_column_if_missing('deleted', "TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记'");
CALL supplier_add_column_if_missing('version', "INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本'");
DROP PROCEDURE supplier_add_column_if_missing;
SET @supplier_index_ddl=IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND index_name='uk_wh_supplier_name'),'SELECT 1','ALTER TABLE wh_supplier ADD UNIQUE KEY uk_wh_supplier_name (tenant_id,name)');
PREPARE supplier_statement FROM @supplier_index_ddl; EXECUTE supplier_statement; DEALLOCATE PREPARE supplier_statement;
SET @supplier_index_ddl=IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND index_name='idx_wh_supplier_filter'),'SELECT 1','ALTER TABLE wh_supplier ADD KEY idx_wh_supplier_filter (tenant_id,category,status,region)');
PREPARE supplier_statement FROM @supplier_index_ddl; EXECUTE supplier_statement; DEALLOCATE PREPARE supplier_statement;
DROP PROCEDURE IF EXISTS supplier_add_check_if_missing;
DELIMITER $$
CREATE PROCEDURE supplier_add_check_if_missing(IN p_name VARCHAR(64), IN p_expression TEXT)
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND constraint_name=p_name AND constraint_type='CHECK') THEN
SET @supplier_ddl=CONCAT('ALTER TABLE wh_supplier ADD CONSTRAINT ',p_name,' CHECK (',p_expression,')');
PREPARE supplier_statement FROM @supplier_ddl; EXECUTE supplier_statement; DEALLOCATE PREPARE supplier_statement;
END IF;
END$$
DELIMITER ;
CALL supplier_add_check_if_missing('chk_wh_supplier_category', "category IN ('BOOKING','CUSTOMS','TRUCKING','CERTIFICATE')");
CALL supplier_add_check_if_missing('chk_wh_supplier_scope', "(category='BOOKING' AND scope IN ('DOMESTIC','INTERNATIONAL')) OR (category<>'BOOKING' AND scope IS NULL)");
CALL supplier_add_check_if_missing('chk_wh_supplier_status', "status IN ('ACTIVE','INACTIVE')");
CALL supplier_add_check_if_missing('chk_wh_supplier_settlement', "settlement_period IN ('MONTHLY_30','MONTHLY_45','MONTHLY_60','MONTHLY_90','PER_TRANSACTION','IMMEDIATE')");
DROP PROCEDURE supplier_add_check_if_missing;
DROP PROCEDURE IF EXISTS assert_supplier_schema;
DELIMITER $$
CREATE PROCEDURE assert_supplier_schema()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier') <> 1
OR (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND index_name IN ('PRIMARY','uk_wh_supplier_name','idx_wh_supplier_filter')) <> 3
OR (SELECT COUNT(*) FROM information_schema.table_constraints WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND constraint_type='CHECK' AND constraint_name IN ('chk_wh_supplier_category','chk_wh_supplier_scope','chk_wh_supplier_status','chk_wh_supplier_settlement')) <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商表、索引或约束初始化不完整';
END IF;
END$$
DELIMITER ;
CALL assert_supplier_schema();
DROP PROCEDURE assert_supplier_schema;
@@ -0,0 +1,183 @@
openapi: 3.0.3
info:
title: Domestic Container Business API
version: 1.0.0
description: 平台国内仓独立整柜业务契约,不复用 Firstleg 装柜或货柜模型。
paths:
/api/platform/v1/domestic-containers/inbounds:
get:
summary: 查询整柜入库单
x-permission: platform:domestic-container:list
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/OrderPageResponse' } } } }
post:
summary: 新建整柜入库单
description: COMPLETED 原子增加库存并写交易流水;PENDING 只建单。
x-permission: platform:domestic-container:inbound:create
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/CreateInboundRequest' } } }
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/CreateResponse' } } } }
/api/platform/v1/domestic-containers/outbounds:
get:
summary: 查询整柜出库单
x-permission: platform:domestic-container:list
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/OrderPageResponse' } } } }
post:
summary: 新建整柜出库单
description: COMPLETED 锁库存后扣减,支持部分/全部出库;PENDING 不扣减。
x-permission: platform:domestic-container:outbound:create
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/CreateOutboundRequest' } } }
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/CreateResponse' } } } }
/api/platform/v1/domestic-containers/inventories:
get:
summary: 查询整柜库存与当前筛选汇总
x-permission: platform:domestic-container:list
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/InventoryPageResponse' } } } }
/api/platform/v1/domestic-containers/inventories/{inventoryId}:
get:
summary: 查询库存详情及不可变交易流水
x-permission: platform:domestic-container:list
parameters:
- { in: path, name: inventoryId, required: true, schema: { type: integer, format: int64 } }
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/DetailResponse' } } } }
components:
schemas:
CreateInboundRequest:
type: object
required: [warehouseId, cargoName, containerNo, boxCount, volumeCbm, inboundTime, status, idempotencyKey]
properties:
warehouseId: { type: integer, format: int64, description: 平台公共且启用的 DOMESTIC 仓库 ID }
cargoName: { type: string, maxLength: 128 }
containerNo: { type: string, maxLength: 32 }
boxCount: { type: integer, minimum: 1 }
volumeCbm: { type: number, minimum: 0.0001 }
inboundTime: { type: string, format: date-time }
status: { type: string, enum: [PENDING, COMPLETED] }
remark: { type: string, maxLength: 500 }
idempotencyKey: { type: string, maxLength: 128 }
CreateOutboundRequest:
type: object
required: [warehouseId, containerNo, boxCount, outboundTime, status, idempotencyKey]
properties:
warehouseId: { type: integer, format: int64 }
containerNo: { type: string, maxLength: 32 }
boxCount: { type: integer, minimum: 1 }
outboundTime: { type: string, format: date-time }
status: { type: string, enum: [PENDING, COMPLETED] }
remark: { type: string, maxLength: 500 }
idempotencyKey: { type: string, maxLength: 128 }
inventoryVersion: { type: integer, minimum: 0 }
ApiEnvelope:
type: object
required: [code, message, timestamp]
properties:
code: { type: string, example: '200' }
message: { type: string }
requestId: { type: string, nullable: true }
traceId: { type: string, nullable: true }
timestamp: { type: string, format: date-time }
CreateResponse:
allOf:
- { $ref: '#/components/schemas/ApiEnvelope' }
- type: object
properties:
data:
type: object
properties:
id: { type: integer, format: int64 }
orderNo: { type: string }
replayed: { type: boolean }
OrderView:
type: object
properties:
id: { type: integer, format: int64 }
businessType: { type: string, enum: [INBOUND, OUTBOUND] }
orderNo: { type: string }
warehouseId: { type: integer, format: int64 }
warehouseName: { type: string }
containerNo: { type: string }
cargoName: { type: string }
boxCount: { type: integer }
volumeCbm: { type: number }
operationTime: { type: string, format: date-time }
status: { type: string, enum: [PENDING, COMPLETED] }
remark: { type: string, nullable: true }
version: { type: integer }
createdAt: { type: string, format: date-time }
InventoryView:
type: object
properties:
id: { type: integer, format: int64 }
warehouseId: { type: integer, format: int64 }
warehouseName: { type: string }
containerNo: { type: string }
cargoName: { type: string }
inboundBoxes: { type: integer }
outboundBoxes: { type: integer }
availableBoxes: { type: integer }
inboundVolumeCbm: { type: number }
inventoryStatus: { type: string, enum: [IN_STOCK, PARTIALLY_OUTBOUND, OUTBOUND] }
version: { type: integer }
updatedAt: { type: string, format: date-time }
TransactionView:
type: object
properties:
id: { type: integer, format: int64 }
businessType: { type: string, enum: [INBOUND, OUTBOUND] }
orderNo: { type: string }
quantity: { type: integer }
operationTime: { type: string, format: date-time }
remark: { type: string, nullable: true }
OrderPageResponse:
allOf:
- { $ref: '#/components/schemas/ApiEnvelope' }
- type: object
properties:
data:
type: object
properties:
records: { type: array, items: { $ref: '#/components/schemas/OrderView' } }
page: { type: integer }
size: { type: integer }
total: { type: integer }
pages: { type: integer }
InventoryPageResponse:
allOf:
- { $ref: '#/components/schemas/ApiEnvelope' }
- type: object
properties:
data:
type: object
properties:
records: { type: array, items: { $ref: '#/components/schemas/InventoryView' } }
page: { type: integer }
size: { type: integer }
total: { type: integer }
pages: { type: integer }
summary:
type: object
properties:
inboundBoxes: { type: integer }
outboundBoxes: { type: integer }
availableBoxes: { type: integer }
inboundVolumeCbm: { type: number }
DetailResponse:
allOf:
- { $ref: '#/components/schemas/ApiEnvelope' }
- type: object
properties:
data:
type: object
properties:
inventory: { $ref: '#/components/schemas/InventoryView' }
inboundOrderCount: { type: integer }
outboundOrderCount: { type: integer }
transactions: { type: array, items: { $ref: '#/components/schemas/TransactionView' } }
@@ -0,0 +1,238 @@
# 平台供应商前端接口交接单
## 接入约定
- Base path:`/api/platform/v1/suppliers`
- 浏览器请求继续使用 Gateway `/api` 前缀,不在前端硬编码服务地址。
- 仅平台端账号可访问,供应商固定属于平台公共作用域 `tenantId=0`。
- 供应商是独立业务档案,不复用或修改往来单位 Partner。
- 所有接口使用统一 `ApiResponse<T>`:
```json
{
"code": "200",
"message": "success",
"data": {},
"requestId": null,
"traceId": null,
"timestamp": "2026-08-13T03:00:00Z"
}
```
- 普通业务失败、参数校验失败通常仍返回 HTTP 200,前端必须以 body `code === "200"` 判断业务成功。认证、网关或基础设施拒绝仍可能返回真实 HTTP 401、403、503。
- Java `Long` 标识在 JSON 中按字符串消费,包括 `id`、`tenantId`、`createdBy`、`updatedBy`,不得转成 JavaScript `number`。
## 接口清单
| 功能 | 方法与路径 | 权限码 |
|---|---|---|
| 列表 | `GET /api/platform/v1/suppliers` | `platform:supplier:list` |
| 分类统计 | `GET /api/platform/v1/suppliers/stats` | `platform:supplier:list` |
| 详情 | `GET /api/platform/v1/suppliers/{id}?tenantId=0` | `platform:supplier:list` |
| 新增 | `POST /api/platform/v1/suppliers?tenantId=0` | `platform:supplier:create` |
| 编辑 | `PUT /api/platform/v1/suppliers/{id}?tenantId=0` | `platform:supplier:edit` |
| 启停 | `PATCH /api/platform/v1/suppliers/{id}/status?tenantId=0` | `platform:supplier:status` |
列表和统计的 `tenantId` 可省略;传入时只能是 `0`。详情、新增、编辑、启停当前要求显式传 `tenantId=0`。
## 列表查询
查询参数:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `tenantId` | string | 否 | 只能为 `0` |
| `page` | integer | 否 | 默认 `1`,最小 `1` |
| `size` | integer | 否 | 默认 `20`,范围 `1..200` |
| `category` | enum | 否 | 供应商分类 |
| `scope` | enum | 否 | 仅订舱分类适用 |
| `region` | string | 否 | 地区精确匹配,最长 80 |
| `status` | enum | 否 | 合作状态 |
| `keyword` | string | 否 | 按供应商名称或联系人模糊匹配 |
分页响应 `data`:
```json
{
"records": [],
"page": 1,
"size": 20,
"total": 0,
"pages": 0
}
```
`records` 元素使用下述 Supplier 字段。`page/size/total/pages` 是整数分页元数据。
## Supplier 字段
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | string | 供应商 ID |
| `tenantId` | string | 固定为 `0` |
| `name` | string | 供应商名称,平台内唯一 |
| `category` | enum | 分类 |
| `scope` | enum/null | 订舱供应商必填;其他分类为 `null` |
| `region` | string | 地区 |
| `legalRepresentative` | string | 法人代表 |
| `contactName` | string | 联系人 |
| `phone` | string | 联系电话 |
| `email` | string/null | 电子邮箱 |
| `address` | string/null | 公司地址 |
| `businessLicenseNo` | string/null | 营业执照号/统一社会信用代码 |
| `licenseFileId` | string/null | File 服务返回的营业执照文件 ID,只允许十进制数字字符串 |
| `status` | enum | 合作状态 |
| `settlementPeriod` | enum | 结算周期 |
| `remark` | string/null | 备注 |
| `createdBy` | string/null | 创建人 ID |
| `createdAt` | datetime | 创建时间 |
| `updatedBy` | string/null | 最后修改人 ID |
| `updatedAt` | datetime | 最后修改时间 |
| `deleted` | boolean | 逻辑删除标记;正常响应为 `false` |
| `version` | integer | 乐观锁版本 |
营业执照上传由 File 服务完成。Supplier 新增或编辑只提交 `licenseFileId`;查看操作使用该 ID获取文件,不把 URL、base64 或文件二进制提交到 Supplier 接口。原型要求营业执照查看由前端使用居中弹框承载。
## 新增与编辑
新增和编辑使用相同请求体:
```json
{
"name": "示例供应商有限公司",
"category": "BOOKING",
"scope": "DOMESTIC",
"region": "宁波",
"legalRepresentative": "张三",
"contactName": "李经理",
"phone": "13800000000",
"email": "supplier@example.com",
"address": "宁波市示例路 1 号",
"businessLicenseNo": "91330000XXXXXXXXXX",
"licenseFileId": "192000000000000001",
"status": "ACTIVE",
"settlementPeriod": "MONTHLY_30",
"remark": ""
}
```
必填字段:`name`、`category`、`region`、`legalRepresentative`、`contactName`、`phone`、`status`、`settlementPeriod`。`BOOKING` 时 `scope` 必填;其他分类 `scope` 必须传 `null` 或省略。
新增成功的 `data` 是供应商 ID 字符串。编辑成功的 `data` 为 `null`。
## 启停
```json
{ "status": "INACTIVE" }
```
只接受 `ACTIVE` 或 `INACTIVE`。接口不物理删除供应商。
## 分类统计
`GET /stats` 的响应 `data`:
```json
{
"booking": 8,
"customs": 5,
"trucking": 6,
"certificate": 3
}
```
## 枚举
供应商分类:
| 值 | 展示文案 |
|---|---|
| `BOOKING` | 订舱 |
| `CUSTOMS` | 报关 |
| `TRUCKING` | 拖车 |
| `CERTIFICATE` | 证书 |
订舱范围:
| 值 | 展示文案 |
|---|---|
| `DOMESTIC` | 国内 |
| `INTERNATIONAL` | 国外 |
合作状态:
| 值 | 展示文案 |
|---|---|
| `ACTIVE` | 合作中 |
| `INACTIVE` | 停用 |
结算周期:
| 值 | 展示文案 |
|---|---|
| `MONTHLY_30` | 月结30天 |
| `MONTHLY_45` | 月结45天 |
| `MONTHLY_60` | 月结60天 |
| `MONTHLY_90` | 月结90天 |
| `PER_TRANSACTION` | 票结 |
| `IMMEDIATE` | 现结 |
## 错误码
| `code` | 场景 | 前端处理建议 |
|---|---|---|
| `200` | 成功 | 使用 `data` |
| `A0400` | `tenantId` 非 0、请求格式或查询参数非法 | 显示参数错误,不重试 |
| `A0401` | 未登录或登录态失效 | 进入统一登录失效流程 |
| `A0403` | 非平台用户或缺少对应权限 | 显示无权限 |
| `A0404` | 供应商不存在 | 提示记录不存在并刷新列表 |
| `A0409` | 供应商名称重复或并发唯一键冲突 | 保留表单并提示名称已存在 |
| `A0422` | 字段校验失败、订舱范围规则不满足 | 使用 `message`/`data` 显示字段错误 |
| `S0500` | 未预期服务异常 | 展示通用错误并记录 `requestId/traceId` |
| `S0503` | Warehouse 数据库不可用 | 展示稍后重试并记录 `requestId/traceId` |
校验错误时 `data` 可能为字段到错误信息的映射,例如:
```json
{
"code": "A0422",
"message": "name: must not be blank",
"data": { "name": "must not be blank" },
"requestId": "...",
"traceId": "...",
"timestamp": "..."
}
```
## 契约来源
- OpenAPI:`warehouse/src/main/resources/openapi/supplier-v1.yaml`
- Controller:`warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformSupplierController.java`
- DTO:`warehouse/src/main/java/com/baiyue/warehouse/dto/SupplierDtos.java`
权限码已在接口层冻结,但 IAM 权限初始化不属于本次 warehouse 文件所有权,联调前需确认 IAM 已落库并授予目标平台角色。
## 初始化与验收
- Warehouse 模块表迁移:`warehouse/src/main/resources/db/warehouse-supplier.sql`
- IAM 权限迁移:`iam/src/main/resources/db/platform-supplier-permissions.sql`
- 测试/存量环境唯一执行入口:`deploy/sql/头程数据库统一增量-MySQL8.sql`
- 权限固定 ID:`5103080..5103083`,依次对应 `list/create/edit/status`;`PLATFORM_ADMIN` 默认获得全部四项权限。
- Admin 测试环境验收模块:`scripts/admin-test-env-modules/suppliers.mjs`
- 验收模块静态契约:`scripts/admin-test-env-modules/suppliers-contract.test.mjs`
离线验证:
```powershell
mvn -pl warehouse,iam -am test
node --test scripts/admin-test-env-modules/suppliers-contract.test.mjs
pwsh scripts/local-db-full-acceptance.ps1 -DryRun
```
仅在当前对话已明确授权测试环境 API 验收后,才能运行下列命令。该模块会新增一条带本轮 marker 的供应商档案,并在结束时停用;由于供应商契约不提供删除接口,记录会保留为 `INACTIVE`。
```powershell
node scripts/admin-test-env-acceptance.mjs --modules suppliers
```
模块覆盖列表、统计、新增、详情、编辑、启停、重复名称、非法 scope、客户账号权限拒绝、`tenantId != 0` 和错误响应 `requestId/traceId`。本轮代码交付不执行该测试环境命令。
@@ -0,0 +1,84 @@
openapi: 3.0.3
info: { title: Platform Supplier API, version: 1.0.0 }
paths:
/api/platform/v1/suppliers:
get:
summary: 分页查询平台公共供应商
description: 权限 platform:supplier:list;tenantId 仅兼容 0。
security: [{ bearerAuth: [] }]
parameters:
- { $ref: '#/components/parameters/TenantId' }
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
- { name: category, in: query, schema: { $ref: '#/components/schemas/Category' } }
- { name: scope, in: query, description: 仅订舱供应商适用, schema: { $ref: '#/components/schemas/Scope' } }
- { name: region, in: query, schema: { type: string } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/Status' } }
- { name: keyword, in: query, description: 供应商名称或联系人, schema: { type: string } }
responses: { '200': { description: 统一 ApiResponse 包装的分页结果 } }
post:
summary: 新增供应商
description: 权限 platform:supplier:create。
security: [{ bearerAuth: [] }]
parameters: [{ $ref: '#/components/parameters/RequiredTenantId' }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SupplierSave' } } } }
responses: { '200': { description: 统一 ApiResponse 包装的供应商 ID } }
/api/platform/v1/suppliers/stats:
get:
summary: 查询四类供应商统计
description: 权限 platform:supplier:list。
security: [{ bearerAuth: [] }]
parameters: [{ $ref: '#/components/parameters/TenantId' }]
responses: { '200': { description: booking/customs/trucking/certificate 计数 } }
/api/platform/v1/suppliers/{id}:
parameters: [{ name: id, in: path, required: true, schema: { type: integer, format: int64 } }]
get:
summary: 查询供应商详情
description: 权限 platform:supplier:list。
security: [{ bearerAuth: [] }]
parameters: [{ $ref: '#/components/parameters/RequiredTenantId' }]
responses: { '200': { description: 统一 ApiResponse 包装的 Supplier } }
put:
summary: 编辑供应商
description: 权限 platform:supplier:edit。
security: [{ bearerAuth: [] }]
parameters: [{ $ref: '#/components/parameters/RequiredTenantId' }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SupplierSave' } } } }
responses: { '200': { description: 成功 } }
/api/platform/v1/suppliers/{id}/status:
parameters: [{ name: id, in: path, required: true, schema: { type: integer, format: int64 } }]
patch:
summary: 启用或停用供应商
description: 权限 platform:supplier:status;不物理删除。
security: [{ bearerAuth: [] }]
parameters: [{ $ref: '#/components/parameters/RequiredTenantId' }]
requestBody: { required: true, content: { application/json: { schema: { type: object, required: [status], properties: { status: { $ref: '#/components/schemas/Status' } } } } } }
responses: { '200': { description: 成功 } }
components:
parameters:
TenantId: { name: tenantId, in: query, required: false, schema: { type: integer, format: int64, enum: [0] } }
RequiredTenantId: { name: tenantId, in: query, required: true, schema: { type: integer, format: int64, enum: [0] } }
schemas:
Category: { type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE] }
Scope: { type: string, enum: [DOMESTIC, INTERNATIONAL] }
Status: { type: string, enum: [ACTIVE, INACTIVE] }
SupplierSave:
type: object
required: [name, category, region, legalRepresentative, contactName, phone, status, settlementPeriod]
properties:
name: { type: string, maxLength: 160 }
category: { $ref: '#/components/schemas/Category' }
scope: { $ref: '#/components/schemas/Scope', description: BOOKING 必填,其他分类必须为空 }
region: { type: string, maxLength: 80 }
legalRepresentative: { type: string, maxLength: 80 }
contactName: { type: string, maxLength: 80 }
phone: { type: string, maxLength: 40 }
email: { type: string, format: email, maxLength: 160 }
address: { type: string, maxLength: 255 }
businessLicenseNo: { type: string, maxLength: 64 }
licenseFileId: { type: string, pattern: '^\d+$', maxLength: 128, description: File 服务文件引用 }
status: { $ref: '#/components/schemas/Status' }
settlementPeriod: { type: string, enum: [MONTHLY_30, MONTHLY_45, MONTHLY_60, MONTHLY_90, PER_TRANSACTION, IMMEDIATE] }
remark: { type: string, maxLength: 500 }
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
@@ -0,0 +1,25 @@
package com.baiyue.warehouse.controller;
import org.junit.jupiter.api.Test;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.security.access.prepost.PreAuthorize;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
class PlatformSupplierControllerContractTest {
@Test void exposesPublicScopeAndSeparatedRoutes() {
Method page = method("page");
assertThat(page.getParameters()[1].getAnnotation(RequestParam.class).required()).isFalse();
assertThat(PlatformSupplierController.class.getAnnotation(RequestMapping.class).value()).containsExactly("/api/platform/v1/suppliers");
}
@Test void freezesSupplierPermissions() {
assertThat(method("page").getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:supplier:list')");
assertThat(method("stats").getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:supplier:list')");
assertThat(method("detail").getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:supplier:list')");
assertThat(method("create").getAnnotation(PreAuthorize.class).value()).contains("platform:supplier:create");
assertThat(method("update").getAnnotation(PreAuthorize.class).value()).contains("platform:supplier:edit");
assertThat(method("status").getAnnotation(PreAuthorize.class).value()).contains("platform:supplier:status");
}
private static Method method(String n) { return java.util.Arrays.stream(PlatformSupplierController.class.getDeclaredMethods()).filter(m -> m.getName().equals(n)).findFirst().orElseThrow(); }
}
@@ -0,0 +1,34 @@
package com.baiyue.warehouse.domesticcontainer.controller;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class PlatformDomesticContainerControllerContractTest {
@Test
void exposesOnlyDedicatedPlatformContract() {
assertThat(PlatformDomesticContainerController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/api/platform/v1/domestic-containers");
assertMethod("inbounds", GetMapping.class, "platform:domestic-container:list");
assertMethod("outbounds", GetMapping.class, "platform:domestic-container:list");
assertMethod("inventories", GetMapping.class, "platform:domestic-container:list");
assertMethod("detail", GetMapping.class, "platform:domestic-container:list");
assertMethod("createInbound", PostMapping.class, "platform:domestic-container:inbound:create");
assertMethod("createOutbound", PostMapping.class, "platform:domestic-container:outbound:create");
}
private static void assertMethod(String name, Class<? extends Annotation> mapping, String permission) {
Method method = Arrays.stream(PlatformDomesticContainerController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(name)).findFirst().orElseThrow();
assertThat(method.getAnnotation(mapping)).isNotNull();
assertThat(method.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('" + permission + "')");
}
}
@@ -0,0 +1,24 @@
package com.baiyue.warehouse.domesticcontainer.repository;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class DomesticContainerSqlContractTest {
@Test
void migrationKeepsDedicatedInventoryAndIdempotencyBoundaries() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/domestic-container-business.sql"));
assertThat(sql).contains("wh_domestic_container_order", "wh_domestic_container_inventory",
"wh_domestic_container_transaction", "wh_domestic_container_idempotency");
assertThat(sql).contains("uk_dc_inventory_container", "chk_dc_inventory_balance", "uk_dc_idempotency");
assertThat(sql).contains("CREATE TABLE IF NOT EXISTS", "version INT NOT NULL DEFAULT 0",
"uk_dc_order_no", "uk_dc_transaction_order", "request_hash CHAR(64)",
"UNIQUE KEY uk_dc_idempotency (tenant_id, operation_type, idempotency_key)",
"chk_dc_order_status", "chk_dc_idempotency_status");
assertThat(sql).doesNotContain("DROP TABLE", "TRUNCATE TABLE", "DELETE FROM");
assertThat(sql).doesNotContain("fl_container", "fl_loading", "fl_pda", "pda_");
}
}
@@ -0,0 +1,96 @@
package com.baiyue.warehouse.domesticcontainer.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.domesticcontainer.dto.DomesticContainerDtos;
import com.baiyue.warehouse.domesticcontainer.entity.DomesticContainerIdempotencyEntity;
import com.baiyue.warehouse.domesticcontainer.entity.DomesticContainerInventoryEntity;
import com.baiyue.warehouse.domesticcontainer.repository.DomesticContainerRepository;
import com.baiyue.warehouse.entity.WarehouseEntity;
import com.baiyue.warehouse.repository.WarehouseRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@SpringJUnitConfig(DomesticContainerService.class)
class DomesticContainerServiceTest {
@MockBean DomesticContainerRepository repository;
@MockBean WarehouseRepository warehouses;
@Resource DomesticContainerService service;
@Test
void completedInboundCreatesInventoryAndTransaction() {
when(warehouses.findActiveOwned(0L, 10L)).thenReturn(warehouse());
when(repository.claim(anyLong(), eq("INBOUND"), eq("in-1"), any())).thenReturn(1);
when(repository.completeClaim(eq("INBOUND"), eq("in-1"), any(), anyLong(), any())).thenReturn(1);
when(repository.lockInventory(10L, "MSCU1234567")).thenReturn(null);
var result = service.createInbound(new DomesticContainerDtos.CreateInboundRequest(10L, "家居用品", "mscu1234567", 120,
new BigDecimal("58.6"), LocalDateTime.of(2026,8,12,9,30), "COMPLETED", "整柜到仓", "in-1"));
assertThat(result.replayed()).isFalse();
verify(repository).insertInventory(any());
verify(repository).insertTransaction(any());
verify(repository).lockWarehouse(10L);
}
@Test
void pendingInboundDoesNotChangeInventory() {
when(warehouses.findActiveOwned(0L, 10L)).thenReturn(warehouse());
when(repository.claim(anyLong(), eq("INBOUND"), eq("in-2"), any())).thenReturn(1);
when(repository.completeClaim(eq("INBOUND"), eq("in-2"), any(), anyLong(), any())).thenReturn(1);
service.createInbound(new DomesticContainerDtos.CreateInboundRequest(10L, "家居用品", "MSCU1234567", 120,
new BigDecimal("58.6"), LocalDateTime.now(), "PENDING", null, "in-2"));
verify(repository, never()).insertInventory(any());
verify(repository, never()).insertTransaction(any());
}
@Test
void completedOutboundCreatesPartialOutboundBalance() {
when(warehouses.findActiveOwned(0L, 10L)).thenReturn(warehouse());
when(repository.claim(anyLong(), eq("OUTBOUND"), eq("out-1"), any())).thenReturn(1);
when(repository.completeClaim(eq("OUTBOUND"), eq("out-1"), any(), anyLong(), any())).thenReturn(1);
DomesticContainerInventoryEntity inventory = inventory(120, 0, 120, 3);
when(repository.lockInventory(10L, "MSCU1234567")).thenReturn(inventory);
when(repository.updateInventory(inventory)).thenReturn(1);
service.createOutbound(new DomesticContainerDtos.CreateOutboundRequest(10L, "MSCU1234567", 30,
LocalDateTime.now(), "COMPLETED", "客户提货", "out-1", 3));
assertThat(inventory.getOutboundBoxes()).isEqualTo(30);
assertThat(inventory.getAvailableBoxes()).isEqualTo(90);
assertThat(inventory.getInventoryStatus()).isEqualTo("PARTIALLY_OUTBOUND");
verify(repository).insertTransaction(any());
}
@Test
void rejectsOutboundBeyondAvailableInventory() {
when(warehouses.findActiveOwned(0L, 10L)).thenReturn(warehouse());
when(repository.claim(anyLong(), eq("OUTBOUND"), eq("out-2"), any())).thenReturn(1);
when(repository.lockInventory(10L, "MSCU1234567")).thenReturn(inventory(120, 100, 20, 4));
assertThatThrownBy(() -> service.createOutbound(new DomesticContainerDtos.CreateOutboundRequest(10L,
"MSCU1234567", 21, LocalDateTime.now(), "COMPLETED", null, "out-2", 4))).isInstanceOf(BizException.class);
verify(repository, never()).insertOrder(any());
}
@Test
void replaysCompletedIdempotentRequestWithoutWriting() {
DomesticContainerIdempotencyEntity replay = new DomesticContainerIdempotencyEntity();
replay.setRequestHash(hashForInbound()); replay.setOperationStatus("COMPLETED"); replay.setResourceId(99L); replay.setResourceNo("ZGRK1");
when(repository.claim(anyLong(), eq("INBOUND"), eq("same"), any())).thenReturn(0);
when(repository.findClaim("INBOUND", "same")).thenReturn(replay);
var result = service.createInbound(inboundForReplay());
assertThat(result).isEqualTo(new DomesticContainerDtos.CreateResult(99L, "ZGRK1", true));
verifyNoInteractions(warehouses);
}
private DomesticContainerDtos.CreateInboundRequest inboundForReplay() { return new DomesticContainerDtos.CreateInboundRequest(10L,"家居用品","MSCU1234567",120,new BigDecimal("58.6"),LocalDateTime.of(2026,8,12,9,30),"COMPLETED","整柜到仓","same"); }
private String hashForInbound() {
try { var digest=java.security.MessageDigest.getInstance("SHA-256"); return java.util.HexFormat.of().formatHex(digest.digest(inboundForReplay().toString().getBytes(java.nio.charset.StandardCharsets.UTF_8))); } catch(Exception e) { throw new RuntimeException(e); }
}
private static WarehouseEntity warehouse() { WarehouseEntity w=new WarehouseEntity(); w.setId(10L); w.setTenantId(0L); w.setWarehouseName("宁波国内仓"); w.setWarehouseType("DOMESTIC"); w.setStatus("ACTIVE"); return w; }
private static DomesticContainerInventoryEntity inventory(int in,int out,int available,int version) { DomesticContainerInventoryEntity i=new DomesticContainerInventoryEntity(); i.setId(20L); i.setWarehouseId(10L); i.setWarehouseName("宁波国内仓"); i.setContainerNo("MSCU1234567"); i.setCargoName("家居用品"); i.setInboundBoxes(in); i.setOutboundBoxes(out); i.setAvailableBoxes(available); i.setInboundVolumeCbm(new BigDecimal("58.6")); i.setInventoryStatus("IN_STOCK"); i.setVersion(version); return i; }
}

Some files were not shown because too many files have changed in this diff Show More