优化
This commit is contained in:
@@ -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;
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -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));
|
||||
}
|
||||
|
||||
+20
-5
@@ -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));
|
||||
}
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+6
-4
@@ -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) {}
|
||||
}
|
||||
|
||||
+30
-1
@@ -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,
|
||||
|
||||
+3
-3
@@ -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) {
|
||||
|
||||
+3
-3
@@ -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) {}
|
||||
}
|
||||
+1
-1
@@ -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}}}
|
||||
+18
-1
@@ -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 + "')");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+4
-4
@@ -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")
|
||||
|
||||
+2
-2
@@ -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"),
|
||||
|
||||
+1
-1
@@ -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"),
|
||||
|
||||
+25
@@ -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) {
|
||||
|
||||
+2
-2
@@ -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:
|
||||
|
||||
+69
@@ -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");
|
||||
}
|
||||
}
|
||||
+36
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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); }
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
+43
@@ -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"); }
|
||||
}
|
||||
+68
@@ -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, "仅允许平台端访问");
|
||||
}
|
||||
}
|
||||
+95
@@ -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) {
|
||||
}
|
||||
}
|
||||
+26
@@ -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;
|
||||
}
|
||||
+28
@@ -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;
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
+28
@@ -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;
|
||||
}
|
||||
+23
@@ -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);
|
||||
}
|
||||
+15
@@ -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);
|
||||
}
|
||||
+7
@@ -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> {
|
||||
}
|
||||
+7
@@ -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> {
|
||||
}
|
||||
+108
@@ -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); }
|
||||
}
|
||||
+115
@@ -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()); }
|
||||
}
|
||||
+5
-1
@@ -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 }
|
||||
+25
@@ -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(); }
|
||||
}
|
||||
+34
@@ -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 + "')");
|
||||
}
|
||||
}
|
||||
+24
@@ -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_");
|
||||
}
|
||||
}
|
||||
+96
@@ -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
Reference in New Issue
Block a user