diff --git a/AGENTS.md b/AGENTS.md index 76888fedc..ac0c47571 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 或权限契约的改动。 - 数据库只访问所属服务库。模块迁移位于 `/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 -am test`。不要在本地启动 Java/Nacos/数据库,除非用户明确要求。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index d3a0836ac..f8835641e 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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; diff --git a/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java b/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java index 9efc74797..32b5fdfcc 100644 --- a/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java +++ b/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java @@ -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 download( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @PathVariable String fileName) { diff --git a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java index 090a04bf1..96faadfc4 100644 --- a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java +++ b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java @@ -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; diff --git a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java index 9d58a196a..44fd69118 100644 --- a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java @@ -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); } diff --git a/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java b/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java index 8a16aea60..bfd3616e7 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java @@ -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)); } diff --git a/finance/src/main/java/com/baiyue/finance/controller/FinanceSettlementController.java b/finance/src/main/java/com/baiyue/finance/controller/FinanceSettlementController.java index f577513c7..54fdb92af 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/FinanceSettlementController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/FinanceSettlementController.java @@ -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(); diff --git a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java index 71c79b0ec..50dcc74ca 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java @@ -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)); } diff --git a/finance/src/main/java/com/baiyue/finance/controller/ReconciliationStatementController.java b/finance/src/main/java/com/baiyue/finance/controller/ReconciliationStatementController.java index 776e93b21..8280e06fe 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/ReconciliationStatementController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/ReconciliationStatementController.java @@ -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 getReconciliationStatement(@PathVariable long statementId) { return FinanceResponses.success(service.detailForPlatform(statementId)); } diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java b/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java index 660a990c1..6b702610b 100644 --- a/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java +++ b/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java @@ -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 lines) {} diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java b/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java index 778994df8..ccca2b5db 100644 --- a/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java +++ b/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java @@ -18,6 +18,7 @@ public final class FinanceSettlementDtos { public record CreateSettlementPaymentRequest(LocalDate transactionDate, BigDecimal amount, String currency, String method, String referenceNo, List 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, diff --git a/finance/src/main/java/com/baiyue/finance/dto/ReconciliationStatementDtos.java b/finance/src/main/java/com/baiyue/finance/dto/ReconciliationStatementDtos.java index d5d99f0bc..5483dcd86 100644 --- a/finance/src/main/java/com/baiyue/finance/dto/ReconciliationStatementDtos.java +++ b/finance/src/main/java/com/baiyue/finance/dto/ReconciliationStatementDtos.java @@ -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 lines, List paymentHistory, List revisions) {} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java index 03304741c..60ff59afe 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java @@ -93,22 +93,24 @@ public class FinanceQueryService { public List 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 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 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 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"), diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java index 82029af88..8785bc7e8 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java @@ -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 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 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 settlementStateSnapshot(Map settlement) { + return settlementStateSnapshot(settlement, text(settlement, "status")); + } + + private static Map settlementStateSnapshot(Map 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); diff --git a/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java index c93dac711..a8c8db808 100644 --- a/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java @@ -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) {} } diff --git a/finance/src/test/java/com/baiyue/finance/FinancePhase1PermissionContractTest.java b/finance/src/test/java/com/baiyue/finance/FinancePhase1PermissionContractTest.java index e1960d11c..b85ef3c7c 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePhase1PermissionContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePhase1PermissionContractTest.java @@ -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 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); + } } } diff --git a/finance/src/test/java/com/baiyue/finance/FinancePlatformListContractTest.java b/finance/src/test/java/com/baiyue/finance/FinancePlatformListContractTest.java index 40e04e3e2..3da8cea2d 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePlatformListContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePlatformListContractTest.java @@ -28,12 +28,12 @@ class FinancePlatformListContractTest { @Test void platformListPermissionsRemainSeparatedByReceivableAndPayableData() throws Exception { Map 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()); } } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java index 6687fa3d7..26f185f47 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java @@ -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 issued = settlementRow("PAYABLE", "ISSUED", BigDecimal.ZERO, + new BigDecimal("100.00"), 2); + Map 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 -> diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java index f8fa2dffd..e025b7d3b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java @@ -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 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) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java index 539743f42..8b9090db0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java @@ -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> 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 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(@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 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 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 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 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 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 create(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java index 7031f0115..d9fc87a4c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java @@ -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( @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( @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 declaration( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId, @RequestParam @Positive long warehouseId) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformTruckAppointmentController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformTruckAppointmentController.java index 8ac12bbf4..7a5f6ec23 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformTruckAppointmentController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformTruckAppointmentController.java @@ -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> 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 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> 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> 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 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> 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> 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 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 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 deleteAttachment(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id,@PathVariable @Positive long attachmentId){FirstlegScopeGuard.platform(u);service.deleteAttachment(id,attachmentId);return ApiResponse.success(null);} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java new file mode 100644 index 000000000..623a50887 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java @@ -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 documents, ExceptionState exception, List exceptionHistory, + List 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 photoCounts, List photos, List 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) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerTransportHistoryEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerTransportHistoryEntity.java index c79779045..d27b4782b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerTransportHistoryEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerTransportHistoryEntity.java @@ -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; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java index 3b13525db..b13344e11 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -61,6 +61,9 @@ public interface ContainerBookingMapper extends BaseMapper documents(@Param("id") long id); + @Select("SELECT * FROM fl_container_transport_history WHERE container_booking_id=#{id} ORDER BY created_at,id") List 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 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 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 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 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) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java index e9a43bec2..02269da22 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java @@ -26,7 +26,7 @@ public class PlatformOceanBookingController { @GetMapping @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')") public ApiResponse> 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(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(oceanBookingId)); } @PostMapping @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:create')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java index 40f505169..fcc1568be 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java @@ -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() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java new file mode 100644 index 000000000..504170b88 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java @@ -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 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 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(); + photos.stream().filter(photo -> "ACTIVE".equals(photo.status())) + .forEach(photo -> counts.merge(photo.evidenceType(), 1, Integer::sum)); + List 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 timeline(long id, Long taskId, List exceptionHistory) { + var result = new ArrayList(); + 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); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index 8137d71fa..ba721690d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -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 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", diff --git a/firstleg/src/main/resources/db/phase3-container-overview.sql b/firstleg/src/main/resources/db/phase3-container-overview.sql new file mode 100644 index 000000000..0b85edca2 --- /dev/null +++ b/firstleg/src/main/resources/db/phase3-container-overview.sql @@ -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 ; diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 0a24ead3c..50dcf8a08 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -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', diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 078c3c9d3..241498fae 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -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: diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index fbca8ba79..23c3b10a1 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -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'} diff --git a/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml b/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml new file mode 100644 index 000000000..3f262804d --- /dev/null +++ b/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml @@ -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}}} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java index c8f651116..84a04362a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java @@ -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 + "')"); } + } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index a9b9e88b6..4ac94979b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -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") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java index 11c9762c7..1b2fdc53a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java @@ -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 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"), diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java index e987b311b..030fb265f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java @@ -18,7 +18,7 @@ class PlatformOceanBookingControllerContractTest { .containsExactly("/api/platform/v1/ocean-bookings"); Map 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"), diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java new file mode 100644 index 000000000..1d92a7963 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java @@ -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"); + } + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java new file mode 100644 index 000000000..b2740c46f --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java @@ -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); + } + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index bd4d503f5..09a584830 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -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); diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java index 3619a387c..bf840689c 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java @@ -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 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> members( @PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) { diff --git a/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java b/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java index 94c3267a5..cc1cebc2c 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java @@ -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> 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> platformAddresses( @PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) { diff --git a/iam/src/main/java/com/baiyue/iam/notification/controller/NotificationInboxController.java b/iam/src/main/java/com/baiyue/iam/notification/controller/NotificationInboxController.java index 87d1cbdc6..e27561143 100644 --- a/iam/src/main/java/com/baiyue/iam/notification/controller/NotificationInboxController.java +++ b/iam/src/main/java/com/baiyue/iam/notification/controller/NotificationInboxController.java @@ -31,9 +31,9 @@ public class NotificationInboxController { public ApiResponse 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 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 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 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;} } diff --git a/iam/src/main/resources/db/admin-prototype-menu-permissions.sql b/iam/src/main/resources/db/admin-prototype-menu-permissions.sql new file mode 100644 index 000000000..ed408d7a8 --- /dev/null +++ b/iam/src/main/resources/db/admin-prototype-menu-permissions.sql @@ -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'; diff --git a/iam/src/main/resources/db/domestic-container-permissions.sql b/iam/src/main/resources/db/domestic-container-permissions.sql new file mode 100644 index 000000000..359668d6e --- /dev/null +++ b/iam/src/main/resources/db/domestic-container-permissions.sql @@ -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; diff --git a/iam/src/main/resources/db/notification-center.sql b/iam/src/main/resources/db/notification-center.sql index 671771fe4..829a681b9 100644 --- a/iam/src/main/resources/db/notification-center.sql +++ b/iam/src/main/resources/db/notification-center.sql @@ -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) diff --git a/iam/src/main/resources/db/platform-supplier-permissions.sql b/iam/src/main/resources/db/platform-supplier-permissions.sql new file mode 100644 index 000000000..b22ad52c3 --- /dev/null +++ b/iam/src/main/resources/db/platform-supplier-permissions.sql @@ -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; diff --git a/iam/src/main/resources/openapi/notification-v1.yaml b/iam/src/main/resources/openapi/notification-v1.yaml index a232c5190..ebb2711c3 100644 --- a/iam/src/main/resources/openapi/notification-v1.yaml +++ b/iam/src/main/resources/openapi/notification-v1.yaml @@ -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: diff --git a/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java new file mode 100644 index 000000000..60e026f90 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java @@ -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"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/DomesticContainerPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/DomesticContainerPermissionSqlContractTest.java new file mode 100644 index 000000000..41a0bf9a2 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/DomesticContainerPermissionSqlContractTest.java @@ -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"); + } + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/PlatformSupplierPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/PlatformSupplierPermissionSqlContractTest.java new file mode 100644 index 000000000..cdec2a141 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/PlatformSupplierPermissionSqlContractTest.java @@ -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"); + } +} diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index cbd5956fb..3f7077e08 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -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 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 diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index c4d76192b..af8ff6432 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -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, diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index d7388eaa6..ab99c3bf8 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -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 +``` diff --git a/scripts/admin-test-env-modules/customer-system-contract.test.mjs b/scripts/admin-test-env-modules/customer-system-contract.test.mjs new file mode 100644 index 000000000..8044764d5 --- /dev/null +++ b/scripts/admin-test-env-modules/customer-system-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/) +}) diff --git a/scripts/admin-test-env-modules/domestic-container-contract.test.mjs b/scripts/admin-test-env-modules/domestic-container-contract.test.mjs new file mode 100644 index 000000000..8cff948ba --- /dev/null +++ b/scripts/admin-test-env-modules/domestic-container-contract.test.mjs @@ -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'/) +}) diff --git a/scripts/admin-test-env-modules/domestic-container.mjs b/scripts/admin-test-env-modules/domestic-container.mjs new file mode 100644 index 000000000..d101603f5 --- /dev/null +++ b/scripts/admin-test-env-modules/domestic-container.mjs @@ -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' }) + } +} diff --git a/scripts/admin-test-env-modules/finance-contract.test.mjs b/scripts/admin-test-env-modules/finance-contract.test.mjs index 91d8cdf0e..627720221 100644 --- a/scripts/admin-test-env-modules/finance-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-contract.test.mjs @@ -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/) +}) diff --git a/scripts/admin-test-env-modules/finance.mjs b/scripts/admin-test-env-modules/finance.mjs index 3220720e0..fdab5e0c3 100644 --- a/scripts/admin-test-env-modules/finance.mjs +++ b/scripts/admin-test-env-modules/finance.mjs @@ -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()}`) diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs index d2191ef9f..88ce55674 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs @@ -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', () => { diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs index 7d8c2bf57..ff0bf4568 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs @@ -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') diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index 10e556059..0d9ec3474 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -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', } } diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 10ad99415..8d38c3ccc 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -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') diff --git a/scripts/admin-test-env-modules/suppliers-contract.test.mjs b/scripts/admin-test-env-modules/suppliers-contract.test.mjs new file mode 100644 index 000000000..1b53fa3c0 --- /dev/null +++ b/scripts/admin-test-env-modules/suppliers-contract.test.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'/)}) diff --git a/scripts/admin-test-env-modules/suppliers.mjs b/scripts/admin-test-env-modules/suppliers.mjs new file mode 100644 index 000000000..e1f499bdc --- /dev/null +++ b/scripts/admin-test-env-modules/suppliers.mjs @@ -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} + } +} diff --git a/scripts/admin-test-env-modules/system.mjs b/scripts/admin-test-env-modules/system.mjs index 7c7449af2..7a1c877b6 100644 --- a/scripts/admin-test-env-modules/system.mjs +++ b/scripts/admin-test-env-modules/system.mjs @@ -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() { diff --git a/scripts/local-db-full-acceptance.ps1 b/scripts/local-db-full-acceptance.ps1 index 7538c3c95..bcc48c924 100644 --- a/scripts/local-db-full-acceptance.ps1 +++ b/scripts/local-db-full-acceptance.ps1 @@ -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.') diff --git a/warehouse/docs/国内仓整柜业务前端接口交接单.md b/warehouse/docs/国内仓整柜业务前端接口交接单.md new file mode 100644 index 000000000..96e5c0986 --- /dev/null +++ b/warehouse/docs/国内仓整柜业务前端接口交接单.md @@ -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`,正式全量验收默认调用;动态模块加载逻辑无需修改。 diff --git a/warehouse/src/main/java/com/baiyue/warehouse/BaiyueWarehouseApplication.java b/warehouse/src/main/java/com/baiyue/warehouse/BaiyueWarehouseApplication.java index 93002b5ca..e25d32b92 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/BaiyueWarehouseApplication.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/BaiyueWarehouseApplication.java @@ -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); } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformCustomerOverseasStorageFeeController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformCustomerOverseasStorageFeeController.java index 2c68a392e..1d209e060 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformCustomerOverseasStorageFeeController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformCustomerOverseasStorageFeeController.java @@ -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 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 put(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @Valid @RequestBody OverseasStorageFeeDtos.UpdateRequest request) { if (request.customerId() != tenantId) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformSupplierController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformSupplierController.java new file mode 100644 index 000000000..3494a0768 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformSupplierController.java @@ -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> 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(@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 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 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 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 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"); } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/controller/PlatformDomesticContainerController.java b/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/controller/PlatformDomesticContainerController.java new file mode 100644 index 000000000..3e9ff030e --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/controller/PlatformDomesticContainerController.java @@ -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> 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 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> 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 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 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 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, "仅允许平台端访问"); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/dto/DomesticContainerDtos.java b/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/dto/DomesticContainerDtos.java new file mode 100644 index 000000000..5960e909c --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/dto/DomesticContainerDtos.java @@ -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 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 transactions) { + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/entity/DomesticContainerIdempotencyEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/entity/DomesticContainerIdempotencyEntity.java new file mode 100644 index 000000000..d6128d526 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/entity/DomesticContainerIdempotencyEntity.java @@ -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; +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/entity/DomesticContainerInventoryEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/entity/DomesticContainerInventoryEntity.java new file mode 100644 index 000000000..76503f9fc --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/entity/DomesticContainerInventoryEntity.java @@ -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; +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/entity/DomesticContainerOrderEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/entity/DomesticContainerOrderEntity.java new file mode 100644 index 000000000..5cc2fdef3 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/entity/DomesticContainerOrderEntity.java @@ -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; +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/entity/DomesticContainerTransactionEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/entity/DomesticContainerTransactionEntity.java new file mode 100644 index 000000000..abaa5906d --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/entity/DomesticContainerTransactionEntity.java @@ -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; +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/mapper/DomesticContainerIdempotencyMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/mapper/DomesticContainerIdempotencyMapper.java new file mode 100644 index 000000000..2b1256927 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/mapper/DomesticContainerIdempotencyMapper.java @@ -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 { + @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); +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/mapper/DomesticContainerInventoryMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/mapper/DomesticContainerInventoryMapper.java new file mode 100644 index 000000000..1b1036c66 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/mapper/DomesticContainerInventoryMapper.java @@ -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 { + @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); +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/mapper/DomesticContainerOrderMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/mapper/DomesticContainerOrderMapper.java new file mode 100644 index 000000000..8f092a11f --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/mapper/DomesticContainerOrderMapper.java @@ -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 { +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/mapper/DomesticContainerTransactionMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/mapper/DomesticContainerTransactionMapper.java new file mode 100644 index 000000000..407f07bba --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/mapper/DomesticContainerTransactionMapper.java @@ -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 { +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/repository/DomesticContainerRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/repository/DomesticContainerRepository.java new file mode 100644 index 000000000..94f5fd866 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/repository/DomesticContainerRepository.java @@ -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 pageOrders(String type, DomesticContainerDtos.OrderQuery query) { + var wrapper = new LambdaQueryWrapper() + .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 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 findInventories(DomesticContainerDtos.InventoryQuery query) { + return inventories.selectList(inventoryWrapper(query)); + } + + public DomesticContainerInventoryEntity findInventory(long id) { + return inventories.selectOne(new LambdaQueryWrapper() + .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() + .eq(DomesticContainerOrderEntity::getTenantId, 0L) + .eq(DomesticContainerOrderEntity::getBusinessType, type) + .eq(DomesticContainerOrderEntity::getWarehouseId, warehouseId) + .eq(DomesticContainerOrderEntity::getContainerNo, containerNo)); + } + + public List transactions(long inventoryId) { + return transactions.selectList(new LambdaQueryWrapper() + .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 inventoryWrapper(DomesticContainerDtos.InventoryQuery query) { + return new LambdaQueryWrapper() + .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); } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/service/DomesticContainerService.java b/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/service/DomesticContainerService.java new file mode 100644 index 000000000..edef96c24 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/service/DomesticContainerService.java @@ -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 inboundPage(DomesticContainerDtos.OrderQuery query) { + return mapOrders(repository.pageOrders("INBOUND", query)); + } + public PageResult outboundPage(DomesticContainerDtos.OrderQuery query) { + return mapOrders(repository.pageOrders("OUTBOUND", query)); + } + public PageResult inventoryPage(DomesticContainerDtos.InventoryQuery query) { + return mapInventories(repository.pageInventories(query)); + } + public DomesticContainerDtos.InventoryPage inventoryPageWithSummary(DomesticContainerDtos.InventoryQuery query) { + PageResult 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 mapOrders(PageResult 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 mapInventories(PageResult 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()); } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/SupplierDtos.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/SupplierDtos.java new file mode 100644 index 000000000..c6fbc696c --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/SupplierDtos.java @@ -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) {} +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/SupplierEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/SupplierEntity.java new file mode 100644 index 000000000..610c63ac9 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/SupplierEntity.java @@ -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; +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/SupplierMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/SupplierMapper.java new file mode 100644 index 000000000..5f7b9b61a --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/SupplierMapper.java @@ -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 {} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/SupplierRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/SupplierRepository.java new file mode 100644 index 000000000..dd0e81b0e --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/SupplierRepository.java @@ -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 page(long tenantId, PageQuery query) { + LambdaQueryWrapper 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 base(long tenantId) { return new LambdaQueryWrapper().eq(SupplierEntity::getTenantId, tenantId); } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/SupplierService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/SupplierService.java new file mode 100644 index 000000000..366047af0 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/SupplierService.java @@ -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 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()); } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java index a652cce94..d1aaa60a8 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java @@ -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 diff --git a/warehouse/src/main/resources/db/domestic-container-business.sql b/warehouse/src/main/resources/db/domestic-container-business.sql new file mode 100644 index 000000000..61a1fed75 --- /dev/null +++ b/warehouse/src/main/resources/db/domestic-container-business.sql @@ -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。 diff --git a/warehouse/src/main/resources/db/warehouse-supplier.sql b/warehouse/src/main/resources/db/warehouse-supplier.sql new file mode 100644 index 000000000..7bda2cad0 --- /dev/null +++ b/warehouse/src/main/resources/db/warehouse-supplier.sql @@ -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; diff --git a/warehouse/src/main/resources/openapi/domestic-container-v1.yaml b/warehouse/src/main/resources/openapi/domestic-container-v1.yaml new file mode 100644 index 000000000..a8a85fae5 --- /dev/null +++ b/warehouse/src/main/resources/openapi/domestic-container-v1.yaml @@ -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' } } diff --git a/warehouse/src/main/resources/openapi/supplier-frontend-handoff.md b/warehouse/src/main/resources/openapi/supplier-frontend-handoff.md new file mode 100644 index 000000000..caa72e29c --- /dev/null +++ b/warehouse/src/main/resources/openapi/supplier-frontend-handoff.md @@ -0,0 +1,238 @@ +# 平台供应商前端接口交接单 + +## 接入约定 + +- Base path:`/api/platform/v1/suppliers` +- 浏览器请求继续使用 Gateway `/api` 前缀,不在前端硬编码服务地址。 +- 仅平台端账号可访问,供应商固定属于平台公共作用域 `tenantId=0`。 +- 供应商是独立业务档案,不复用或修改往来单位 Partner。 +- 所有接口使用统一 `ApiResponse`: + +```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`。本轮代码交付不执行该测试环境命令。 diff --git a/warehouse/src/main/resources/openapi/supplier-v1.yaml b/warehouse/src/main/resources/openapi/supplier-v1.yaml new file mode 100644 index 000000000..11939c571 --- /dev/null +++ b/warehouse/src/main/resources/openapi/supplier-v1.yaml @@ -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 } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformSupplierControllerContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformSupplierControllerContractTest.java new file mode 100644 index 000000000..c5b3ed9df --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformSupplierControllerContractTest.java @@ -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(); } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/domesticcontainer/controller/PlatformDomesticContainerControllerContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/domesticcontainer/controller/PlatformDomesticContainerControllerContractTest.java new file mode 100644 index 000000000..50a66317f --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/domesticcontainer/controller/PlatformDomesticContainerControllerContractTest.java @@ -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 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 + "')"); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/domesticcontainer/repository/DomesticContainerSqlContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/domesticcontainer/repository/DomesticContainerSqlContractTest.java new file mode 100644 index 000000000..a3196da25 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/domesticcontainer/repository/DomesticContainerSqlContractTest.java @@ -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_"); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/domesticcontainer/service/DomesticContainerServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/domesticcontainer/service/DomesticContainerServiceTest.java new file mode 100644 index 000000000..7fcd2dc56 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/domesticcontainer/service/DomesticContainerServiceTest.java @@ -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; } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/SupplierSqlContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/SupplierSqlContractTest.java new file mode 100644 index 000000000..f0211202d --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/SupplierSqlContractTest.java @@ -0,0 +1,35 @@ +package com.baiyue.warehouse.repository; + +import org.junit.jupiter.api.Test; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import static org.assertj.core.api.Assertions.assertThat; + +class SupplierSqlContractTest { + @Test void freezesPublicSupplierSchemaAndBookingScope() throws IOException { + try (var stream = getClass().getResourceAsStream("/db/warehouse-supplier.sql")) { + assertThat(stream).isNotNull(); + String sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + assertThat(sql).contains("CREATE TABLE IF NOT EXISTS wh_supplier", "tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0", + "category='BOOKING' AND scope IN ('DOMESTIC','INTERNATIONAL')", "license_file_id VARCHAR(128)", + "MONTHLY_30','MONTHLY_45','MONTHLY_60','MONTHLY_90','PER_TRANSACTION','IMMEDIATE", + "created_by BIGINT UNSIGNED", "updated_by BIGINT UNSIGNED", + "supplier_add_column_if_missing", "uk_wh_supplier_name", "idx_wh_supplier_filter", + "assert_supplier_schema"); + } + } + + @Test void unifiedMigrationContainsSupplierSchemaAndPermissionContract() throws IOException { + String sql = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8); + assertThat(sql).contains("CREATE TABLE IF NOT EXISTS wh_supplier", "uk_wh_supplier_name", + "idx_wh_supplier_filter", "platform:supplier:list", "platform:supplier:create", + "platform:supplier:edit", "platform:supplier:status", "assert_platform_supplier_permissions"); + } + + private static Path resolveUnifiedSql() { + Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + return Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/SupplierServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/SupplierServiceTest.java new file mode 100644 index 000000000..5b9157c07 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/SupplierServiceTest.java @@ -0,0 +1,49 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.SupplierDtos.SaveRequest; +import com.baiyue.warehouse.entity.SupplierEntity; +import com.baiyue.warehouse.repository.SupplierRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SupplierServiceTest { + @Mock private SupplierRepository repository; + @InjectMocks private SupplierService service; + private SaveRequest booking; + + @BeforeEach void setUp() { + booking = new SaveRequest("测试供应商", "BOOKING", "DOMESTIC", "宁波", "法人", "联系人", + "13800000000", "supplier@example.com", "地址", "91330000TEST", "192000000000000001", + "ACTIVE", "MONTHLY_30", null); + } + + @Test void createsPlatformPublicBookingSupplier() { + when(repository.existsName(0L, "测试供应商", null)).thenReturn(false); + service.create(booking); + verify(repository).insert(any(SupplierEntity.class)); + } + + @Test void rejectsScopeForNonBookingSupplier() { + SaveRequest customs = new SaveRequest("报关供应商", "CUSTOMS", "DOMESTIC", "宁波", "法人", "联系人", + "13800000000", null, null, null, null, "ACTIVE", "PER_TRANSACTION", null); + assertThatThrownBy(() -> service.create(customs)).isInstanceOf(BizException.class) + .hasMessageContaining("仅适用于订舱供应商"); + } + + @Test void requiresScopeForBookingSupplier() { + SaveRequest missingScope = new SaveRequest("订舱供应商", "BOOKING", null, "宁波", "法人", "联系人", + "13800000000", null, null, null, null, "ACTIVE", "PER_TRANSACTION", null); + assertThatThrownBy(() -> service.create(missingScope)).isInstanceOf(BizException.class); + } +}