This commit is contained in:
2026-08-13 14:37:51 +08:00
parent bc62650ca9
commit 65cd7e0a39
63 changed files with 380 additions and 226 deletions
+6 -6
View File
@@ -49,13 +49,13 @@
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 - 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
- 全量验证:`mvn -DskipTests validate`、`mvn test`;单服务优先 `mvn -pl <module> -am test`。不要在本地启动 Java/Nacos/数据库,除非用户明确要求。 - 全量验证:`mvn -DskipTests validate`、`mvn test`;单服务优先 `mvn -pl <module> -am test`。不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
## 全量测试脚本路由 ## 全量 HTTP 验收脚本路由
- 本地后端静态/单元回归:在 `baiyue-firstleg/` 执行 `pwsh scripts/backend-full-regression.ps1 -DryRun` 先检查覆盖文件,再按需去掉 `-DryRun` 执行 `order + firstleg` Maven 测试;脚本不访问数据库、Gateway 或测试环境。 - 全量验收唯一指向测试环境 HTTP:平台端执行 `scripts/admin-test-env-acceptance.mjs`,客户端执行 `scripts/client-test-env-acceptance.mjs`;二期兼容场景才单独执行 `scripts/client-phase2-test-env-acceptance.mjs`。
- 本地前端回归:在 `baiyue-firstleg/` 执行 `pwsh scripts/frontend-full-regression.ps1 -DryRun` 先检查入口,再按需执行 Admin/Client 的 `pnpm test` 和 `pnpm typecheck`;脚本不启动开发服务器、不访问 Gateway。 - Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules <name>`,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。
- 测试环境 API 全量验收:`scripts/admin-test-env-acceptance.mjs`、`scripts/client-test-env-acceptance.mjs`、`scripts/phase2-test-env-acceptance.ps1` 及 `scripts/admin-test-env-modules/` 属于有数据写入的场景脚本,会创建测试客户、订单或业务记录;只有用户在当前对话明确授权测试环境验收时才执行,并使用报告目录留存结果。 - 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。
- `scripts/local-db-full-acceptance.ps1` 仅允许 `127.0.0.1`/`localhost` 数据库,先用 `-DryRun` 校验统一 SQL 快照和注释门禁;不得改参数连接远程或测试数据库。 - 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。
- 验收脚本按“先本地静态回归,再测试环境 API 验收,最后人工浏览器样式验收”顺序使用;脚本通过不等于页面视觉、权限和真实数据链路全部通过。 - HTTP 全量通过后,才进入 Admin/Client 真实页面调用、浏览器样式和 PDA 页面/设备验收;自动化 HTTP 通过不代表视觉验收完成。
## 阶段文档入口 ## 阶段文档入口
@@ -2401,7 +2401,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID,不建立跨库外键', order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID,不建立跨库外键',
order_no VARCHAR(32) NOT NULL COMMENT '订单号快照', order_no VARCHAR(32) NOT NULL COMMENT '订单号快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '客户内唯一入仓码', inbound_code VARCHAR(40) NOT NULL COMMENT '客户内唯一入仓码',
process_status VARCHAR(24) NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态:WAIT_MEASURE待测量、MEASURING测量中、MEASURE_PENDING待审核、MEASURE_REJECTED测量驳回、WAIT_PRICING待计费、FEE_CONFIRMING费用确认中、INBOUNDING入库收口中、INBOUNDED已入库、WAIT_SPLIT历史待分柜', process_status VARCHAR(24) NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态:WAIT_MEASURE待测量、MEASURING测量中、MEASURE_PENDING待审核、MEASURE_REJECTED测量驳回、WAIT_PRICING待计费、FEE_CONFIRMING费用确认中、INBOUNDING入库收口中、WAIT_SPLIT待分柜',
order_status_snapshot VARCHAR(24) NOT NULL COMMENT '订单状态快照', order_status_snapshot VARCHAR(24) NOT NULL COMMENT '订单状态快照',
measure_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '测量审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回', measure_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '测量审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回',
fee_status VARCHAR(24) NOT NULL DEFAULT 'UNPRICED' COMMENT '费用状态:UNPRICED未计费、CALCULATED已计算、CONFIRMED已确认、SENT已发送、VOIDED已作废', fee_status VARCHAR(24) NOT NULL DEFAULT 'UNPRICED' COMMENT '费用状态:UNPRICED未计费、CALCULATED已计算、CONFIRMED已确认、SENT已发送、VOIDED已作废',
@@ -12162,7 +12162,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'package_count'
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'pallet_count', '已分配总托盘数快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'pallet_count', '已分配总托盘数快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'parent_inbound_id', '直属主入仓单ID;主单为空'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'parent_inbound_id', '直属主入仓单ID;主单为空');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'pricing_tax_mode', '报价包税维度:TAX_INCLUDED/TAX_EXCLUDED'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'pricing_tax_mode', '报价包税维度:TAX_INCLUDED/TAX_EXCLUDED');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'process_status', '入库流程状态:WAIT_MEASURE待测量、MEASURING测量中、MEASURE_PENDING待审核、MEASURE_REJECTED测量驳回、WAIT_PRICING待计费、FEE_CONFIRMING费用确认中、INBOUNDING入库收口中、INBOUNDED已入库、WAIT_SPLIT历史待分柜'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'process_status', '入库流程状态:WAIT_MEASURE待测量、MEASURING测量中、MEASURE_PENDING待审核、MEASURE_REJECTED测量驳回、WAIT_PRICING待计费、FEE_CONFIRMING费用确认中、INBOUNDING入库收口中、WAIT_SPLIT待分柜');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'public_remark', '客户可见备注'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'public_remark', '客户可见备注');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'root_inbound_id', '根主入仓单ID;存量主单可为空,按自身ID解释'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'root_inbound_id', '根主入仓单ID;存量主单可为空,按自身ID解释');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'split_purpose', '履约分片用途:MASTER、LOADED、REMAINDER'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'split_purpose', '履约分片用途:MASTER、LOADED、REMAINDER');
@@ -13889,7 +13889,6 @@ 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) (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted)
VALUES VALUES
(5105000, 0, 'PLATFORM', 'MENU', 0, 'platform:finance', '财务管理', '/finance', NULL, 60, 'ACTIVE', 0), (5105000, 0, 'PLATFORM', 'MENU', 0, 'platform:finance', '财务管理', '/finance', NULL, 60, 'ACTIVE', 0),
(5105010, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:view', '查看账单', NULL, 'GET', 10, 'ACTIVE', 0),
(5105011, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:create', '创建账单', NULL, 'POST', 20, 'ACTIVE', 0), (5105011, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:create', '创建账单', NULL, 'POST', 20, 'ACTIVE', 0),
(5105012, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:update', '修改草稿账单', NULL, 'PUT', 30, 'ACTIVE', 0), (5105012, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:update', '修改草稿账单', NULL, 'PUT', 30, 'ACTIVE', 0),
(5105013, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:issue', '出账', NULL, 'POST', 40, 'ACTIVE', 0), (5105013, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:issue', '出账', NULL, 'POST', 40, 'ACTIVE', 0),
@@ -13925,8 +13924,6 @@ BEGIN
OR (id = 5105030 AND permission_code <> 'platform:finance:reconciliation:void') OR (id = 5105030 AND permission_code <> 'platform:finance:reconciliation:void')
OR (id = 5105031 AND permission_code <> 'platform:finance:settlement-ar:create') OR (id = 5105031 AND permission_code <> 'platform:finance:settlement-ar:create')
OR (id = 5105032 AND permission_code <> 'platform:finance:settlement-ap:create') OR (id = 5105032 AND permission_code <> 'platform:finance:settlement-ap:create')
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 = 5105035 AND permission_code <> 'platform:finance:payment:create')
OR (id = 5105036 AND permission_code <> 'platform:finance:settlement:void') 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:account:view' AND id <> 5105022)
@@ -13940,8 +13937,6 @@ BEGIN
OR (permission_code = 'platform:finance:reconciliation:void' AND id <> 5105030) OR (permission_code = 'platform:finance:reconciliation:void' AND id <> 5105030)
OR (permission_code = 'platform:finance:settlement-ar:create' AND id <> 5105031) OR (permission_code = 'platform:finance:settlement-ar:create' AND id <> 5105031)
OR (permission_code = 'platform:finance:settlement-ap:create' AND id <> 5105032) OR (permission_code = 'platform:finance:settlement-ap:create' AND id <> 5105032)
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 (permission_code = 'platform:finance:payment:create' AND id <> 5105035)
OR (permission_code = 'platform:finance:settlement:void' AND id <> 5105036) OR (permission_code = 'platform:finance:settlement:void' AND id <> 5105036)
OR (id BETWEEN 5105022 AND 5105036 OR (id BETWEEN 5105022 AND 5105036
@@ -13970,8 +13965,6 @@ BEGIN
(5105030, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:reconciliation:void', '作废或重开客户对账单', NULL, 'POST', 210, 'ACTIVE', 1, 0), (5105030, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:reconciliation:void', '作废或重开客户对账单', NULL, 'POST', 210, 'ACTIVE', 1, 0),
(5105031, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement-ar:create', '生成应收结算单', NULL, 'POST', 220, 'ACTIVE', 1, 0), (5105031, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement-ar:create', '生成应收结算单', NULL, 'POST', 220, 'ACTIVE', 1, 0),
(5105032, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement-ap:create', '生成应付结算单', NULL, 'POST', 230, 'ACTIVE', 1, 0), (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) (5105036, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement:void', '作废或重开结算单', NULL, 'POST', 270, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
@@ -14001,10 +13994,8 @@ BEGIN
'platform:finance:reconciliation:void', 'platform:finance:reconciliation:void',
'platform:finance:settlement-ar:create', 'platform:finance:settlement-ar:create',
'platform:finance:settlement-ap:create', 'platform:finance:settlement-ap:create',
'platform:finance:settlement-ar:view',
'platform:finance:settlement-ap:view',
'platform:finance:payment:create', 'platform:finance:payment:create',
'platform:finance:settlement:void')) <> 15 THEN 'platform:finance:settlement:void')) <> 13 THEN
SIGNAL SQLSTATE '45000' SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'finance G1-G4 permission catalog is incomplete'; SET MESSAGE_TEXT = 'finance G1-G4 permission catalog is incomplete';
END IF; END IF;
@@ -14017,7 +14008,7 @@ INSERT INTO baiyue_iam.iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000001, id, 0 FROM baiyue_iam.iam_permission SELECT 4000001, id, 0 FROM baiyue_iam.iam_permission
WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN ( WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN (
'platform:finance', 'platform:finance',
'platform:finance:bill:view', 'platform:finance:bill:create', 'platform:finance:bill:create',
'platform:finance:bill:update', 'platform:finance:bill:issue', 'platform:finance:bill:update', 'platform:finance:bill:issue',
'platform:finance:bill:adjust', 'platform:finance:bill:void', 'platform:finance:bill:adjust', 'platform:finance:bill:void',
'platform:finance:receipt:view', 'platform:finance:receipt:create', 'platform:finance:receipt:view', 'platform:finance:receipt:create',
@@ -14032,8 +14023,7 @@ WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN (
'platform:finance:reconciliation:create', 'platform:finance:reconciliation:review', 'platform:finance:reconciliation:create', 'platform:finance:reconciliation:review',
'platform:finance:reconciliation:issue', 'platform:finance:reconciliation:adjust', 'platform:finance:reconciliation:issue', 'platform:finance:reconciliation:adjust',
'platform:finance:reconciliation:void', 'platform:finance:settlement-ar:create', 'platform:finance:reconciliation:void', 'platform:finance:settlement-ar:create',
'platform:finance:settlement-ap:create', 'platform:finance:settlement-ar:view', 'platform:finance:settlement-ap:create', 'platform:finance:payment:create',
'platform:finance:settlement-ap:view', 'platform:finance:payment:create',
'platform:finance:settlement:void') 'platform:finance:settlement:void')
ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id); ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id);
INSERT INTO baiyue_iam.iam_role_permission (role_id, permission_id, tenant_id) INSERT INTO baiyue_iam.iam_role_permission (role_id, permission_id, tenant_id)
@@ -14045,13 +14035,13 @@ ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id);
DELIMITER $$ DELIMITER $$
CREATE PROCEDURE assert_finance_permissions() CREATE PROCEDURE assert_finance_permissions()
BEGIN 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) <> 27 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) <> 24 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台 Finance 权限不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台 Finance 权限不完整';
END IF; 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 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
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户 Finance 只读权限不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户 Finance 只读权限不完整';
END IF; END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND (p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 27 THEN IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND (p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 25 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员 Finance 授权不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员 Finance 授权不完整';
END IF; END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000101 AND rp.tenant_id=10001 AND (p.permission_code='merchant:finance:statement' OR p.permission_code LIKE 'merchant:finance:%')) <> 3 THEN IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000101 AND rp.tenant_id=10001 AND (p.permission_code='merchant:finance:statement' OR p.permission_code LIKE 'merchant:finance:%')) <> 3 THEN
@@ -14431,7 +14421,7 @@ BEGIN
END$$ END$$
DELIMITER ; DELIMITER ;
-- 二、三期对外统一使用 INBOUNDED;历史 WAIT_SPLIT 仅由查询适配为 INBOUNDED,统一迁移不改写主表或历史状态。 -- 二、三期入库完成后的唯一状态为 WAIT_SPLIT。
USE baiyue_firstleg; USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_container_exception_history ( CREATE TABLE IF NOT EXISTS fl_container_exception_history (
id BIGINT UNSIGNED NOT NULL COMMENT '异常历史ID', id BIGINT UNSIGNED NOT NULL COMMENT '异常历史ID',
@@ -14491,7 +14481,14 @@ UPDATE iam_permission SET status='INACTIVE', deleted=1 WHERE tenant_id=0 AND app
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) 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' 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'); 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'; 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-ap:create',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';
DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id
WHERE p.tenant_id=0 AND p.app_code='PLATFORM'
AND p.permission_code IN('platform:finance:bill:view','platform:finance:settlement-ar:view','platform:finance:settlement-ap:view');
UPDATE iam_permission SET status='INACTIVE',deleted=1
WHERE tenant_id=0 AND app_code='PLATFORM'
AND permission_code IN('platform:finance:bill:view','platform:finance:settlement-ar:view','platform:finance:settlement-ap:view');
INSERT INTO baiyue_iam.iam_permission 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) (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted)
@@ -14701,6 +14698,6 @@ WHERE p.permission_code IN('platform:firstleg:container:view','platform:firstleg
UPDATE iam_permission SET status='INACTIVE',deleted=1 UPDATE iam_permission SET status='INACTIVE',deleted=1
WHERE permission_code IN('platform:firstleg:container:view','platform:firstleg:preload:view','platform:firstleg:loading-list:view','platform:supplier:view'); WHERE permission_code IN('platform:firstleg:container:view','platform:firstleg:preload:view','platform:firstleg:loading-list:view','platform:supplier:view');
-- 新完成入库记录使用 WAIT_SPLIT;存量 INBOUNDED 继续作为可分柜兼容状态,统一迁移不改写主表或历史状态。 -- 入库完成后的唯一状态为 WAIT_SPLIT。
SELECT 'PASS' AS migration_status, SELECT 'PASS' AS migration_status,
'统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源及可靠审计结构校验通过' AS message; '统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源及可靠审计结构校验通过' AS message;
+3 -3
View File
@@ -54,7 +54,7 @@
| `PROCESSING` | 履约处理中。 | | `PROCESSING` | 履约处理中。 |
| `COMPLETED` | 订单完成。 | | `COMPLETED` | 订单完成。 |
统一订单列表的主状态是展示分组,不等于订单表的内部状态:`PENDING_REVIEW`、`WAIT_MEASURE`、`INBOUNDED`、`LOADING`、`LOADED`、`DELIVERED`。 统一订单列表的主状态是展示分组,不等于订单表的内部状态:`PENDING_REVIEW`、`WAIT_MEASURE`、`WAIT_SPLIT`、`LOADING`、`LOADED`、`DELIVERED`。
## 仓库与库位 ## 仓库与库位
@@ -89,13 +89,13 @@
| `WAIT_PRICING` | 测量通过或异常放行,等待计费。 | | `WAIT_PRICING` | 测量通过或异常放行,等待计费。 |
| `FEE_CONFIRMING` | 已生成费用,等待费用确认和发送。 | | `FEE_CONFIRMING` | 已生成费用,等待费用确认和发送。 |
| `INBOUNDING` | 费用已确认,正在占用托盘、库位并完成入库。 | | `INBOUNDING` | 费用已确认,正在占用托盘、库位并完成入库。 |
| `INBOUNDED` | 入库完成,可进入智能装柜或预装确认;费用状态独立维护。 | | `WAIT_SPLIT` | 入库完成,可进入智能装柜或预装确认;费用状态独立维护。 |
| `LOADING` | 已进入装柜作业,PDA 可执行拣货。 | | `LOADING` | 已进入装柜作业,PDA 可执行拣货。 |
| `LOADED` | 装柜完成,等待派送完成。 | | `LOADED` | 装柜完成,等待派送完成。 |
| `DELIVERED` | 已完成派送。 | | `DELIVERED` | 已完成派送。 |
| `VOIDED` | 已作废,不允许继续处理。 | | `VOIDED` | 已作废,不允许继续处理。 |
主链路为:`WAIT_MEASURE` -> `MEASURING` -> `MEASURE_PENDING` -> `WAIT_PRICING` -> `FEE_CONFIRMING` -> `INBOUNDING` -> `INBOUNDED` -> `LOADING` -> `LOADED` -> `DELIVERED`。测量驳回走 `MEASURE_PENDING` -> `MEASURE_REJECTED`;异常放行后回到 `WAIT_PRICING`。 主链路为:`WAIT_MEASURE` -> `MEASURING` -> `MEASURE_PENDING` -> `WAIT_PRICING` -> `FEE_CONFIRMING` -> `INBOUNDING` -> `WAIT_SPLIT` -> `LOADING` -> `LOADED` -> `DELIVERED`。测量驳回走 `MEASURE_PENDING` -> `MEASURE_REJECTED`;异常放行后回到 `WAIT_PRICING`。
### 测量、重量和费用状态 ### 测量、重量和费用状态
@@ -27,7 +27,7 @@ public class FinancePhase1OperationController {
public FinancePhase1OperationController(FinancePhase1OperationService service) { this.service = service; } public FinancePhase1OperationController(FinancePhase1OperationService service) { this.service = service; }
@GetMapping("/accounts") @GetMapping("/accounts")
@PreAuthorize("hasAnyAuthority('platform:finance:customer-account:list','platform:finance:account:view')") @PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
public Object listDerivedCustomerAccounts(@RequestParam(defaultValue = "1") int page, public Object listDerivedCustomerAccounts(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize, @RequestParam(defaultValue = "20") int pageSize,
@RequestParam(required = false) String keyword, @RequestParam(required = false) String keyword,
@@ -36,7 +36,7 @@ public class FinancePhase1OperationController {
} }
@GetMapping("/accounts/{customerId}/ledger") @GetMapping("/accounts/{customerId}/ledger")
@PreAuthorize("hasAnyAuthority('platform:finance:customer-account:list','platform:finance:account:view')") @PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
public Object listDerivedCustomerAccountLedger(@PathVariable long customerId, public Object listDerivedCustomerAccountLedger(@PathVariable long customerId,
@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize, @RequestParam(defaultValue = "20") int pageSize,
@@ -47,7 +47,7 @@ public class FinancePhase1OperationController {
} }
@GetMapping("/fee-sources/{feeSourceId}") @GetMapping("/fee-sources/{feeSourceId}")
@PreAuthorize("hasAnyAuthority('platform:finance:customer-arap:list','platform:finance:fee-source:view')") @PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
public ApiResponse<?> getFinanceFeeSource(@PathVariable long feeSourceId) { public ApiResponse<?> getFinanceFeeSource(@PathVariable long feeSourceId) {
return FinanceResponses.success(service.getFinanceFeeSource(feeSourceId)); return FinanceResponses.success(service.getFinanceFeeSource(feeSourceId));
} }
@@ -41,7 +41,7 @@ public class FinanceSettlementController {
} }
@GetMapping("/receivable-settlements") @GetMapping("/receivable-settlements")
@PreAuthorize("hasAnyAuthority('platform:finance:settlement-ar:list','platform:finance:settlement-ar:view')") @PreAuthorize("hasAuthority('platform:finance:settlement-ar:list')")
@Operation(summary = "查询正式应收结算单") @Operation(summary = "查询正式应收结算单")
public SettlementListEnvelope receivableSettlements( public SettlementListEnvelope receivableSettlements(
@Parameter(description = "可选客户筛选;省略时查询全部客户") @Parameter(description = "可选客户筛选;省略时查询全部客户")
@@ -50,7 +50,7 @@ public class FinanceSettlementController {
} }
@GetMapping("/payable-settlements") @GetMapping("/payable-settlements")
@PreAuthorize("hasAnyAuthority('platform:finance:settlement-ap:list','platform:finance:settlement-ap:view')") @PreAuthorize("hasAuthority('platform:finance:settlement-ap:list')")
@Operation(summary = "查询正式应付结算单") @Operation(summary = "查询正式应付结算单")
public SettlementListEnvelope payableSettlements( public SettlementListEnvelope payableSettlements(
@Parameter(description = "可选客户筛选;省略时查询全部客户") @Parameter(description = "可选客户筛选;省略时查询全部客户")
@@ -77,17 +77,14 @@ public class FinanceSettlementController {
} }
@GetMapping("/settlements/{settlementId}") @GetMapping("/settlements/{settlementId}")
@PreAuthorize("hasAnyAuthority('platform:finance:settlement-ar:list','platform:finance:settlement-ar:view','platform:finance:settlement-ap:list','platform:finance:settlement-ap:view')") @PreAuthorize("hasAnyAuthority('platform:finance:settlement-ar:list','platform:finance:settlement-ap:list')")
public SettlementDetailEnvelope getSettlement(@AuthenticationPrincipal AuthenticatedUser user, public SettlementDetailEnvelope getSettlement(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long settlementId) { @PathVariable long settlementId) {
SettlementDetail detail = service.getSettlement(settlementId); SettlementDetail detail = service.getSettlement(settlementId);
String direction = detail.summary().direction(); String direction = detail.summary().direction();
String listPermission = "RECEIVABLE".equals(direction) String listPermission = "RECEIVABLE".equals(direction)
? "platform:finance:settlement-ar:list" : "platform:finance:settlement-ap:list"; ? "platform:finance:settlement-ar:list" : "platform:finance:settlement-ap:list";
String legacyViewPermission = "RECEIVABLE".equals(direction) if (user == null || !user.permissions().contains(listPermission)) {
? "platform:finance:settlement-ar:view" : "platform:finance:settlement-ap:view";
if (user == null || (!user.permissions().contains(listPermission)
&& !user.permissions().contains(legacyViewPermission))) {
throw new AccessDeniedException("无权查看该方向结算单"); throw new AccessDeniedException("无权查看该方向结算单");
} }
return success(detail); return success(detail);
@@ -45,7 +45,7 @@ public class PlatformFinanceController {
} }
@GetMapping("/customer-accounts/{customerId}") @GetMapping("/customer-accounts/{customerId}")
@PreAuthorize("hasAnyAuthority('platform:finance:customer-account:list','platform:finance:account:view','platform:finance:bill:view')") @PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
@Operation(summary = "客户人工应收账户及流水") @Operation(summary = "客户人工应收账户及流水")
public ApiResponse<?> customerAccount(@PathVariable long customerId) { public ApiResponse<?> customerAccount(@PathVariable long customerId) {
return FinanceResponses.success(queryService.customerAccount(customerId)); return FinanceResponses.success(queryService.customerAccount(customerId));
@@ -59,7 +59,7 @@ public class PlatformFinanceController {
} }
@GetMapping("/fee-pool") @GetMapping("/fee-pool")
@PreAuthorize("hasAnyAuthority('platform:finance:customer-arap:list','platform:finance:bill:view')") @PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
@Operation(summary = "客户应收应付费用池") @Operation(summary = "客户应收应付费用池")
public ApiResponse<?> feePool( public ApiResponse<?> feePool(
@Parameter(description = "可选客户筛选;省略时查询全部客户") @Parameter(description = "可选客户筛选;省略时查询全部客户")
@@ -69,7 +69,7 @@ public class PlatformFinanceController {
} }
@GetMapping("/statements") @GetMapping("/statements")
@PreAuthorize("hasAnyAuthority('platform:finance:customer-statement:list','platform:finance:bill:view')") @PreAuthorize("hasAuthority('platform:finance:customer-statement:list')")
@Operation(summary = "客户对账单") @Operation(summary = "客户对账单")
public ApiResponse<?> statements( public ApiResponse<?> statements(
@Parameter(description = "可选客户筛选;省略时查询全部客户") @Parameter(description = "可选客户筛选;省略时查询全部客户")
@@ -94,7 +94,7 @@ public class PlatformFinanceController {
public ApiResponse<?> terms(@PathVariable long tenantId) { return FinanceResponses.success(termsView(tenantId, service.customerTerms(tenantId))); } public ApiResponse<?> terms(@PathVariable long tenantId) { return FinanceResponses.success(termsView(tenantId, service.customerTerms(tenantId))); }
@GetMapping("/bills") @GetMapping("/bills")
@PreAuthorize("hasAnyAuthority('platform:finance:customer-statement:list','platform:finance:bill:view')") @PreAuthorize("hasAuthority('platform:finance:customer-statement:list')")
@Operation(summary = "查询平台账单") @Operation(summary = "查询平台账单")
public ApiResponse<?> bills(@RequestParam long customerId) { return FinanceResponses.success(queryService.statements(customerId, false)); } public ApiResponse<?> bills(@RequestParam long customerId) { return FinanceResponses.success(queryService.statements(customerId, false)); }
@@ -122,11 +122,11 @@ public class PlatformFinanceController {
} }
@GetMapping("/bills/{billId}") @GetMapping("/bills/{billId}")
@PreAuthorize("hasAnyAuthority('platform:finance:customer-statement:list','platform:finance:bill:view')") @PreAuthorize("hasAuthority('platform:finance:customer-statement:list')")
public ApiResponse<?> bill(@PathVariable long billId) { return FinanceResponses.success(queryService.platformStatement(billId)); } public ApiResponse<?> bill(@PathVariable long billId) { return FinanceResponses.success(queryService.platformStatement(billId)); }
@GetMapping("/bills/{billId}/revisions") @GetMapping("/bills/{billId}/revisions")
@PreAuthorize("hasAnyAuthority('platform:finance:customer-statement:list','platform:finance:bill:view')") @PreAuthorize("hasAuthority('platform:finance:customer-statement:list')")
public ApiResponse<?> revisions(@PathVariable long billId, @RequestParam long customerId) { public ApiResponse<?> revisions(@PathVariable long billId, @RequestParam long customerId) {
return FinanceResponses.success(queryService.billRevisions(customerId, billId)); return FinanceResponses.success(queryService.billRevisions(customerId, billId));
} }
@@ -36,7 +36,7 @@ public class ReconciliationStatementController {
} }
@GetMapping("/reconciliation-statements/{statementId}") @GetMapping("/reconciliation-statements/{statementId}")
@PreAuthorize("hasAnyAuthority('platform:finance:customer-statement:list','platform:finance:bill:view')") @PreAuthorize("hasAuthority('platform:finance:customer-statement:list')")
public ApiResponse<ReconciliationDetail> getReconciliationStatement(@PathVariable long statementId) { public ApiResponse<ReconciliationDetail> getReconciliationStatement(@PathVariable long statementId) {
return FinanceResponses.success(service.detailForPlatform(statementId)); return FinanceResponses.success(service.detailForPlatform(statementId));
} }
@@ -15,20 +15,20 @@ class FinancePhase1PermissionContractTest {
@Test @Test
void freezesG1AndG2ControllerPermissionsAndHttpActions() throws Exception { void freezesG1AndG2ControllerPermissionsAndHttpActions() throws Exception {
assertEndpoint("listDerivedCustomerAccounts", GetMapping.class, assertEndpoint("listDerivedCustomerAccounts", GetMapping.class,
"platform:finance:customer-account:list", "platform:finance:account:view", "platform:finance:customer-account:list",
int.class, int.class, String.class, String.class); int.class, int.class, String.class, String.class);
assertEndpoint("listDerivedCustomerAccountLedger", GetMapping.class, assertEndpoint("listDerivedCustomerAccountLedger", GetMapping.class,
"platform:finance:customer-account:list", "platform:finance:account:view", "platform:finance:customer-account:list",
long.class, int.class, int.class, long.class, int.class, int.class,
String.class, java.time.LocalDate.class, java.time.LocalDate.class); String.class, java.time.LocalDate.class, java.time.LocalDate.class);
assertEndpoint("getFinanceFeeSource", GetMapping.class, assertEndpoint("getFinanceFeeSource", GetMapping.class,
"platform:finance:customer-arap:list", "platform:finance:fee-source:view", long.class); "platform:finance:customer-arap:list", long.class);
assertEndpoint("reviewFinanceFeeSource", PostMapping.class, assertEndpoint("reviewFinanceFeeSource", PostMapping.class,
"platform:finance:fee-source:review", null, "platform:finance:fee-source:review",
com.baiyue.common.security.AuthenticatedUser.class, long.class, String.class, com.baiyue.common.security.AuthenticatedUser.class, long.class, String.class,
com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest.class); com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest.class);
assertEndpoint("updateFeeSourceSettlementInclusion", PutMapping.class, assertEndpoint("updateFeeSourceSettlementInclusion", PutMapping.class,
"platform:finance:fee-source:settlement", null, "platform:finance:fee-source:settlement",
com.baiyue.common.security.AuthenticatedUser.class, long.class, String.class, com.baiyue.common.security.AuthenticatedUser.class, long.class, String.class,
com.baiyue.finance.dto.FinancePhase1Dtos.SettlementInclusionRequest.class); com.baiyue.finance.dto.FinancePhase1Dtos.SettlementInclusionRequest.class);
} }
@@ -36,13 +36,10 @@ class FinancePhase1PermissionContractTest {
private static void assertEndpoint(String methodName, private static void assertEndpoint(String methodName,
Class<? extends java.lang.annotation.Annotation> mapping, Class<? extends java.lang.annotation.Annotation> mapping,
String authority, String authority,
String compatibleAuthority,
Class<?>... parameterTypes) throws Exception { Class<?>... parameterTypes) throws Exception {
Method method = FinancePhase1OperationController.class.getMethod(methodName, parameterTypes); Method method = FinancePhase1OperationController.class.getMethod(methodName, parameterTypes);
assertThat(method.isAnnotationPresent(mapping)).isTrue(); assertThat(method.isAnnotationPresent(mapping)).isTrue();
assertThat(method.getAnnotation(PreAuthorize.class).value()).contains(authority); assertThat(method.getAnnotation(PreAuthorize.class).value()).contains(authority);
if (compatibleAuthority != null) { assertThat(method.getAnnotation(PreAuthorize.class).value()).doesNotContain(":view");
assertThat(method.getAnnotation(PreAuthorize.class).value()).contains(compatibleAuthority);
}
} }
} }
@@ -2,6 +2,8 @@ package com.baiyue.finance;
import com.baiyue.finance.controller.PlatformFinanceController; import com.baiyue.finance.controller.PlatformFinanceController;
import com.baiyue.finance.controller.FinanceSettlementController; import com.baiyue.finance.controller.FinanceSettlementController;
import com.baiyue.finance.controller.FinancePhase1OperationController;
import com.baiyue.finance.controller.ReconciliationStatementController;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
@@ -30,10 +32,35 @@ class FinancePlatformListContractTest {
Map<String, String> expected = Map.of( Map<String, String> expected = Map.of(
"feePool", "platform:finance:customer-arap:list", "feePool", "platform:finance:customer-arap:list",
"statements", "platform:finance:customer-statement:list", "statements", "platform:finance:customer-statement:list",
"receivableSettlements", "platform:finance:settlement-ar:view", "receivableSettlements", "platform:finance:settlement-ar:list",
"payableSettlements", "platform:finance:settlement-ap:view"); "payableSettlements", "platform:finance:settlement-ap:list");
for (var entry : expected.entrySet()) { for (var entry : expected.entrySet()) {
assertThat(find(entry.getKey()).getAnnotation(PreAuthorize.class).value()).contains(entry.getValue()); assertThat(find(entry.getKey()).getAnnotation(PreAuthorize.class).value())
.contains(entry.getValue())
.doesNotContain(":view");
}
}
@Test
void platformReadEndpointsDoNotAcceptLegacyViewAuthorities() {
String[] legacyAuthorities = {
"platform:finance:account:view",
"platform:finance:fee-source:view",
"platform:finance:bill:view",
"platform:finance:settlement-ar:view",
"platform:finance:settlement-ap:view"
};
for (Class<?> controller : new Class<?>[]{
PlatformFinanceController.class,
FinancePhase1OperationController.class,
ReconciliationStatementController.class,
FinanceSettlementController.class
}) {
for (Method method : controller.getDeclaredMethods()) {
PreAuthorize permission = method.getAnnotation(PreAuthorize.class);
if (permission == null) continue;
assertThat(permission.value()).doesNotContain(legacyAuthorities);
}
} }
} }
@@ -54,7 +54,7 @@ class FinanceSettlementBehaviorTest {
FinanceSettlementController controller = new FinanceSettlementController(controllerService); FinanceSettlementController controller = new FinanceSettlementController(controllerService);
SettlementDetail payable = detail("PAYABLE", "ISSUED", BigDecimal.ZERO, new BigDecimal("100.00"), 0); SettlementDetail payable = detail("PAYABLE", "ISSUED", BigDecimal.ZERO, new BigDecimal("100.00"), 0);
when(controllerService.getSettlement(88L)).thenReturn(payable); when(controllerService.getSettlement(88L)).thenReturn(payable);
AuthenticatedUser apViewer = user(Set.of("platform:finance:settlement-ap:view")); AuthenticatedUser apViewer = user(Set.of("platform:finance:settlement-ap:list"));
var response = controller.getSettlement(apViewer, 88L); var response = controller.getSettlement(apViewer, 88L);
@@ -62,7 +62,7 @@ class FinanceSettlementBehaviorTest {
assertThat(response.data()).isSameAs(payable); assertThat(response.data()).isSameAs(payable);
assertThat(response.requestId()).isNotBlank(); assertThat(response.requestId()).isNotBlank();
assertThatThrownBy(() -> controller.getSettlement( assertThatThrownBy(() -> controller.getSettlement(
user(Set.of("platform:finance:settlement-ar:view")), 88L)).isInstanceOf(AccessDeniedException.class); user(Set.of("platform:finance:settlement-ar:list")), 88L)).isInstanceOf(AccessDeniedException.class);
} }
@Test @Test
@@ -29,7 +29,7 @@ public class MerchantInboundBillingController {
@GetMapping("/{orderId}/inbound-billing") @GetMapping("/{orderId}/inbound-billing")
@PreAuthorize("hasAuthority('merchant:order:view')") @PreAuthorize("hasAuthority('merchant:order:view')")
@Operation(summary = "查询订单入库结果和最新费用", @Operation(summary = "查询订单入库结果和最新费用",
description = "客户只读查看已达到 INBOUNDED + APPROVED + CONFIRMED 的二期费用,不需要确认费用。") description = "客户只读查看已达到 WAIT_SPLIT + APPROVED + CONFIRMED 的二期费用,不需要确认费用。")
public ApiResponse<MerchantInboundBillingResponse> find( public ApiResponse<MerchantInboundBillingResponse> find(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long orderId) { @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long orderId) {
return ApiResponse.success(service.find(FirstlegScopeGuard.merchant(user), orderId)); return ApiResponse.success(service.find(FirstlegScopeGuard.merchant(user), orderId));
@@ -51,7 +51,7 @@ public class PlatformInboundMeasurementReviewController {
@Valid @RequestBody MeasurementReviewRequest request) { @Valid @RequestBody MeasurementReviewRequest request) {
validateScope(user, tenantId); validateScope(user, tenantId);
service.approve(tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request); service.approve(tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request);
// 统一订单主线要求测量审核后直接进入 INBOUNDED,不把计费、库位或其他资料 // 统一订单主线要求测量审核后直接进入 WAIT_SPLIT,不把计费、库位或其他资料
// 作为本版本的强制门禁。保留原 FeeSnapshotResponse 泛型以兼容既有客户端。 // 作为本版本的强制门禁。保留原 FeeSnapshotResponse 泛型以兼容既有客户端。
return ApiResponse.success(null); return ApiResponse.success(null);
} }
@@ -10,8 +10,8 @@ public enum InboundListTab {
WAIT_REVIEW(List.of("MEASURE_PENDING")), WAIT_REVIEW(List.of("MEASURE_PENDING")),
/** 待发送费用:等待计费、费用确认或正在完成入库。 */ /** 待发送费用:等待计费、费用确认或正在完成入库。 */
WAIT_FEE_SEND(List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING")), WAIT_FEE_SEND(List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING")),
/** 已入库:费用已发送且入库完成。 */ /** 待分柜:入库和费用发布完成,等待形成有效分柜方案。 */
INBOUNDED(List.of("INBOUNDED")); WAIT_SPLIT(List.of("WAIT_SPLIT"));
private final List<String> processStatuses; private final List<String> processStatuses;
@@ -18,8 +18,6 @@ public final class InboundProcessStatus {
public static final String FEE_CONFIRMING = "FEE_CONFIRMING"; public static final String FEE_CONFIRMING = "FEE_CONFIRMING";
/** 费用已确认,正在执行托盘、库位占用和入库收口。 */ /** 费用已确认,正在执行托盘、库位占用和入库收口。 */
public static final String INBOUNDING = "INBOUNDING"; public static final String INBOUNDING = "INBOUNDING";
/** 历史已入库终态;存量数据仍允许进入分柜。 */
public static final String INBOUNDED = "INBOUNDED";
/** 入库和费用发布完成,等待分柜。 */ /** 入库和费用发布完成,等待分柜。 */
public static final String WAIT_SPLIT = "WAIT_SPLIT"; public static final String WAIT_SPLIT = "WAIT_SPLIT";
/** 已进入装柜作业,PDA 可执行拣货。 */ /** 已进入装柜作业,PDA 可执行拣货。 */
@@ -33,7 +31,7 @@ public final class InboundProcessStatus {
public static final Set<String> VALUES = Set.of( public static final Set<String> VALUES = Set.of(
WAIT_MEASURE, MEASURING, MEASURE_PENDING, MEASURE_REJECTED, WAIT_MEASURE, MEASURING, MEASURE_PENDING, MEASURE_REJECTED,
WAIT_PRICING, FEE_CONFIRMING, INBOUNDING, INBOUNDED, WAIT_SPLIT, WAIT_PRICING, FEE_CONFIRMING, INBOUNDING, WAIT_SPLIT,
LOADING, LOADED, DELIVERED, VOIDED LOADING, LOADED, DELIVERED, VOIDED
); );
@@ -14,7 +14,7 @@ public record UnifiedInboundQuery(
Long tenantId, Long tenantId,
@Size(max = 64) String keyword, @Size(max = 64) String keyword,
@Size(max = 64) String orderNo, @Size(max = 64) String orderNo,
@Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", @Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "WAIT_SPLIT",
"LOADING", "LOADED", "DELIVERED"}) "LOADING", "LOADED", "DELIVERED"})
@Size(max = 32) String mainStatus, @Size(max = 32) String mainStatus,
Long customerId, Long customerId,
@@ -62,7 +62,7 @@ public interface LoadingInboundMapper {
AND loaded.process_status<>'LOADED' AND loaded.deleted=0 AND loaded.process_status<>'LOADED' AND loaded.deleted=0
SET root.process_status='LOADED', root.updated_by=#{operatorId}, root.version=root.version+1 SET root.process_status='LOADED', root.updated_by=#{operatorId}, root.version=root.version+1
WHERE root.tenant_id=#{tenantId} AND root.id=#{rootInboundId} AND root.deleted=0 WHERE root.tenant_id=#{tenantId} AND root.id=#{rootInboundId} AND root.deleted=0
AND root.inbound_role='MASTER' AND root.process_status IN ('INBOUNDED','LOADING') AND root.inbound_role='MASTER' AND root.process_status IN ('WAIT_SPLIT','LOADING')
AND remainder.id IS NULL AND loaded.id IS NULL AND remainder.id IS NULL AND loaded.id IS NULL
""") """)
int aggregateRootLoaded(@Param("tenantId") long tenantId, int aggregateRootLoaded(@Param("tenantId") long tenantId,
@@ -46,7 +46,7 @@ public interface InboundSplitSettlementMapper {
express_tracking_no,public_remark,created_by,created_by_name,created_at,updated_by,updated_at,deleted,version, express_tracking_no,public_remark,created_by,created_by_name,created_at,updated_by,updated_at,deleted,version,
inbound_role,parent_inbound_id,root_inbound_id,split_sequence,split_status,split_purpose) inbound_role,parent_inbound_id,root_inbound_id,split_sequence,split_status,split_purpose)
SELECT #{childId},tenant_id,task_id,#{childOrderId},#{childOrderNo},#{childInboundCode}, SELECT #{childId},tenant_id,task_id,#{childOrderId},#{childOrderNo},#{childInboundCode},
CASE WHEN #{splitPurpose}='LOADED' THEN 'LOADING' ELSE 'INBOUNDED' END,order_status_snapshot, CASE WHEN #{splitPurpose}='LOADED' THEN 'LOADING' ELSE 'WAIT_SPLIT' END,order_status_snapshot,
'APPROVED',fee_status,fee_review_status,notification_status,expected_arrival_at,customer_id,customer_name, 'APPROVED',fee_status,fee_review_status,notification_status,expected_arrival_at,customer_id,customer_name,
company_id,company_name,warehouse_id,warehouse_name,delivery_warehouse_id,delivery_warehouse_name,business_type, company_id,company_name,warehouse_id,warehouse_name,delivery_warehouse_id,delivery_warehouse_name,business_type,
channel_code,channel_name,carrier_id,carrier_name,tax_mode,pricing_tax_mode,container_mode,customs_inspection, channel_code,channel_name,carrier_id,carrier_name,tax_mode,pricing_tax_mode,container_mode,customs_inspection,
@@ -151,7 +151,7 @@ public interface InboundSplitSettlementMapper {
SET package_count = GREATEST(COALESCE(package_count, total_package_count) - #{packageCount}, 0), SET package_count = GREATEST(COALESCE(package_count, total_package_count) - #{packageCount}, 0),
split_status = 'PARTIALLY_SPLIT', updated_by = #{operatorId}, version = version + 1 split_status = 'PARTIALLY_SPLIT', updated_by = #{operatorId}, version = version + 1
WHERE id = #{parentInboundId} AND tenant_id = #{accountId} AND deleted = 0 WHERE id = #{parentInboundId} AND tenant_id = #{accountId} AND deleted = 0
AND inbound_role = 'MASTER' AND process_status = 'INBOUNDED' AND version = #{expectedVersion} AND inbound_role = 'MASTER' AND process_status = 'WAIT_SPLIT' AND version = #{expectedVersion}
""") """)
int markParentPartiallySplit(@Param("accountId") long accountId, @Param("parentInboundId") long parentInboundId, int markParentPartiallySplit(@Param("accountId") long accountId, @Param("parentInboundId") long parentInboundId,
@Param("packageCount") int packageCount, @Param("operatorId") long operatorId, @Param("packageCount") int packageCount, @Param("operatorId") long operatorId,
@@ -160,7 +160,7 @@ public interface InboundSplitSettlementMapper {
@Update("UPDATE fl_inbound_order SET package_count=#{loadedPackageCount},process_status='LOADING'," @Update("UPDATE fl_inbound_order SET package_count=#{loadedPackageCount},process_status='LOADING',"
+ "split_status='FULLY_SPLIT',updated_by=#{operatorId},version=version+1 " + "split_status='FULLY_SPLIT',updated_by=#{operatorId},version=version+1 "
+ "WHERE id=#{sourceInboundId} AND tenant_id=#{accountId} AND deleted=0 " + "WHERE id=#{sourceInboundId} AND tenant_id=#{accountId} AND deleted=0 "
+ "AND process_status IN ('INBOUNDED','LOADING') AND split_status='NOT_SPLIT' " + "AND process_status IN ('WAIT_SPLIT','LOADING') AND split_status='NOT_SPLIT' "
+ "AND COALESCE(package_count,total_package_count)>=#{loadedPackageCount} AND version=#{expectedVersion}") + "AND COALESCE(package_count,total_package_count)>=#{loadedPackageCount} AND version=#{expectedVersion}")
int markSourceLoaded(@Param("accountId") long accountId, @Param("sourceInboundId") long sourceInboundId, int markSourceLoaded(@Param("accountId") long accountId, @Param("sourceInboundId") long sourceInboundId,
@Param("loadedPackageCount") int loadedPackageCount, @Param("loadedPackageCount") int loadedPackageCount,
@@ -23,7 +23,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2 AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2
WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED') WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED')
JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id
WHERE o.warehouse_id=#{warehouseId} AND o.process_status IN ('WAIT_SPLIT','INBOUNDED') WHERE o.warehouse_id=#{warehouseId} AND o.process_status='WAIT_SPLIT'
AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT')) AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT'))
AND i.inbound_detail_id IS NOT NULL AND i.inbound_detail_id IS NOT NULL
AND i.package_count>COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li AND i.package_count>COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
@@ -61,7 +61,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2 AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2
WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED') WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED')
JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id
WHERE o.warehouse_id=#{warehouseId} AND o.id=#{inboundId} AND o.process_status IN ('WAIT_SPLIT','INBOUNDED') WHERE o.warehouse_id=#{warehouseId} AND o.id=#{inboundId} AND o.process_status='WAIT_SPLIT'
AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT')) AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT'))
AND i.inbound_detail_id=#{detailId} AND i.inbound_detail_id=#{detailId}
ORDER BY m.id DESC LIMIT 1 FOR UPDATE ORDER BY m.id DESC LIMIT 1 FOR UPDATE
@@ -209,7 +209,7 @@ public class FbaPricingRepository {
.eq(InboundPricingEntity::getProcessStatus, processStatus) .eq(InboundPricingEntity::getProcessStatus, processStatus)
.in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED")) .in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED"))
.set(InboundPricingEntity::getFeeStatus, "CALCULATED"); .set(InboundPricingEntity::getFeeStatus, "CALCULATED");
if (!List.of("WAIT_SPLIT", "INBOUNDED").contains(processStatus)) { if (!"WAIT_SPLIT".equals(processStatus)) {
update.set(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING"); update.set(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING");
} }
if ("KG".equals(chargeMode)) update.set(InboundPricingEntity::getChargeableWeightKg, chargeable); if ("KG".equals(chargeMode)) update.set(InboundPricingEntity::getChargeableWeightKg, chargeable);
@@ -229,7 +229,7 @@ public class FbaPricingRepository {
public int markInboundConfirmed(long tenantId, long inboundId) { public int markInboundConfirmed(long tenantId, long inboundId) {
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>() return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId) .eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
.in(InboundPricingEntity::getProcessStatus, List.of("FEE_CONFIRMING", "WAIT_SPLIT", "INBOUNDED")) .in(InboundPricingEntity::getProcessStatus, List.of("FEE_CONFIRMING", "WAIT_SPLIT"))
.eq(InboundPricingEntity::getFeeStatus, "CALCULATED") .eq(InboundPricingEntity::getFeeStatus, "CALCULATED")
.set(InboundPricingEntity::getFeeStatus, "CONFIRMED")); .set(InboundPricingEntity::getFeeStatus, "CONFIRMED"));
} }
@@ -58,7 +58,7 @@ public class AutomaticInboundCompletionService {
} }
private static boolean completed(InboundOrderEntity order) { private static boolean completed(InboundOrderEntity order) {
return ("WAIT_SPLIT".equals(order.getProcessStatus()) || "INBOUNDED".equals(order.getProcessStatus())) return "WAIT_SPLIT".equals(order.getProcessStatus())
&& "SENT".equals(order.getFeeStatus()); && "SENT".equals(order.getFeeStatus());
} }
@@ -209,7 +209,7 @@ public class FbaPricingService {
private InboundPricingEntity requireEligibleInbound(long tenantId, long inboundId) { private InboundPricingEntity requireEligibleInbound(long tenantId, long inboundId) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId); InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
if (!List.of("WAIT_PRICING", "FEE_CONFIRMING", "WAIT_SPLIT", "INBOUNDED").contains(inbound.getProcessStatus()) if (!List.of("WAIT_PRICING", "FEE_CONFIRMING", "WAIT_SPLIT").contains(inbound.getProcessStatus())
|| !List.of("UNPRICED", "CALCULATED").contains(inbound.getFeeStatus())) { || !List.of("UNPRICED", "CALCULATED").contains(inbound.getFeeStatus())) {
throw conflict("当前入库单状态不允许费用试算"); throw conflict("当前入库单状态不允许费用试算");
} }
@@ -235,7 +235,7 @@ public class FbaPricingService {
} }
private static boolean isCompletedInbound(String processStatus) { private static boolean isCompletedInbound(String processStatus) {
return "WAIT_SPLIT".equals(processStatus) || "INBOUNDED".equals(processStatus); return "WAIT_SPLIT".equals(processStatus);
} }
private static PricingDestinationType destinationType(InboundPricingEntity inbound) { private static PricingDestinationType destinationType(InboundPricingEntity inbound) {
@@ -114,7 +114,7 @@ public class MerchantInboundBillingService {
} }
private static boolean isPublished(InboundOrderEntity inbound) { private static boolean isPublished(InboundOrderEntity inbound) {
return (InboundProcessStatus.INBOUNDED.equals(inbound.getProcessStatus()) return (InboundProcessStatus.WAIT_SPLIT.equals(inbound.getProcessStatus())
|| "WAIT_SPLIT".equals(inbound.getProcessStatus())) || "WAIT_SPLIT".equals(inbound.getProcessStatus()))
&& "APPROVED".equals(inbound.getMeasureReviewStatus()) && "APPROVED".equals(inbound.getMeasureReviewStatus())
&& "CONFIRMED".equals(inbound.getFeeStatus()); && "CONFIRMED".equals(inbound.getFeeStatus());
@@ -98,7 +98,7 @@ public class PdaPickSettlementService {
ParentRow parent = rootInboundId == parentInboundId ? root : splits.lockParent(parentInboundId); ParentRow parent = rootInboundId == parentInboundId ? root : splits.lockParent(parentInboundId);
if (parent == null || parent.tenantId() == null || parent.tenantId() <= 0 if (parent == null || parent.tenantId() == null || parent.tenantId() <= 0
|| !("MASTER".equals(parent.inboundRole()) || "CHILD".equals(parent.inboundRole())) || !("MASTER".equals(parent.inboundRole()) || "CHILD".equals(parent.inboundRole()))
|| !("INBOUNDED".equals(parent.processStatus()) || "LOADING".equals(parent.processStatus())) || !("WAIT_SPLIT".equals(parent.processStatus()) || "LOADING".equals(parent.processStatus()))
|| ("MASTER".equals(parent.inboundRole()) && "FULLY_SPLIT".equals(parent.splitStatus()))) { || ("MASTER".equals(parent.inboundRole()) && "FULLY_SPLIT".equals(parent.splitStatus()))) {
throw conflict("待拆分主入库单不在可拆分状态"); throw conflict("待拆分主入库单不在可拆分状态");
} }
@@ -28,7 +28,7 @@ import java.util.stream.Collectors;
@Service @Service
public class UnifiedInboundQueryService { public class UnifiedInboundQueryService {
private static final Set<String> MAIN_STATUSES = Set.of( private static final Set<String> MAIN_STATUSES = Set.of(
"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"); "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED");
private static final Set<String> REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED"); private static final Set<String> REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED");
@Resource private InboundOrderRepository inbounds; @Resource private InboundOrderRepository inbounds;
@@ -140,7 +140,8 @@ public class UnifiedInboundQueryService {
return switch (status) { return switch (status) {
case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
"WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING" -> "WAIT_MEASURE"; "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING" -> "WAIT_MEASURE";
case "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED" -> status; case "WAIT_SPLIT" -> "WAIT_SPLIT";
case "LOADING", "LOADED", "DELIVERED" -> status;
default -> null; default -> null;
}; };
} }
@@ -150,7 +151,7 @@ public class UnifiedInboundQueryService {
return switch (mainStatus) { return switch (mainStatus) {
case "PENDING_REVIEW" -> "APPROVE_ORDER"; case "PENDING_REVIEW" -> "APPROVE_ORDER";
case "WAIT_MEASURE" -> "MEASURE_PENDING".equals(processStatus) ? "CONFIRM_MEASUREMENT" : null; case "WAIT_MEASURE" -> "MEASURE_PENDING".equals(processStatus) ? "CONFIRM_MEASUREMENT" : null;
case "INBOUNDED", "WAIT_SPLIT" -> !("INBOUNDED".equals(processStatus) || "WAIT_SPLIT".equals(processStatus)) ? null case "WAIT_SPLIT" -> !"WAIT_SPLIT".equals(processStatus) ? null
: "CONFIRMED".equals(preloadStatus) ? null : "CONFIRMED".equals(preloadStatus) ? null
: preloadStatus == null ? "SMART_SPLIT" : "CONFIRM_PRELOAD"; : preloadStatus == null ? "SMART_SPLIT" : "CONFIRM_PRELOAD";
case "LOADING" -> "COMPLETE_LOADING"; case "LOADING" -> "COMPLETE_LOADING";
@@ -169,7 +170,6 @@ public class UnifiedInboundQueryService {
return switch (mainStatus) { return switch (mainStatus) {
case "WAIT_MEASURE" -> List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", case "WAIT_MEASURE" -> List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
"WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING"); "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING");
case "INBOUNDED" -> List.of("INBOUNDED");
case "WAIT_SPLIT" -> List.of("WAIT_SPLIT"); case "WAIT_SPLIT" -> List.of("WAIT_SPLIT");
case "LOADING", "LOADED", "DELIVERED" -> List.of(mainStatus); case "LOADING", "LOADED", "DELIVERED" -> List.of(mainStatus);
default -> List.of("__NO_MATCH__"); default -> List.of("__NO_MATCH__");
@@ -13,7 +13,7 @@ public interface SplitInboundMapper {
@Update("UPDATE fl_inbound_order SET process_status='LOADING', updated_by=#{operatorId}, " @Update("UPDATE fl_inbound_order SET process_status='LOADING', updated_by=#{operatorId}, "
+ "version=version+1 WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 " + "version=version+1 WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 "
+ "AND process_status IN ('WAIT_SPLIT','INBOUNDED') AND version=#{expectedVersion}") + "AND process_status='WAIT_SPLIT' AND version=#{expectedVersion}")
int beginLoading(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, int beginLoading(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion); @Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion);
@@ -177,7 +177,7 @@ public class SplitPreloadService {
} }
private static boolean isWaitingSplit(String status) { private static boolean isWaitingSplit(String status) {
return "WAIT_SPLIT".equals(status) || "INBOUNDED".equals(status); return "WAIT_SPLIT".equals(status);
} }
private void addHistory(long tenantId, long inboundId, String from, String to, String action, private void addHistory(long tenantId, long inboundId, String from, String to, String action,
@@ -11,7 +11,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID,不建立跨库外键', order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID,不建立跨库外键',
order_no VARCHAR(32) NOT NULL COMMENT '订单号快照', order_no VARCHAR(32) NOT NULL COMMENT '订单号快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '客户内唯一入仓码', inbound_code VARCHAR(40) NOT NULL COMMENT '客户内唯一入仓码',
process_status VARCHAR(24) NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态:WAIT_MEASURE待测量、MEASURING测量中、MEASURE_PENDING待审核、MEASURE_REJECTED测量驳回、WAIT_PRICING待计费、FEE_CONFIRMING费用确认中、INBOUNDING入库收口中、INBOUNDED已入库、WAIT_SPLIT历史待分柜', process_status VARCHAR(24) NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态:WAIT_MEASURE待测量、MEASURING测量中、MEASURE_PENDING待审核、MEASURE_REJECTED测量驳回、WAIT_PRICING待计费、FEE_CONFIRMING费用确认中、INBOUNDING入库收口中、WAIT_SPLIT待分柜',
order_status_snapshot VARCHAR(24) NOT NULL COMMENT '订单状态快照', order_status_snapshot VARCHAR(24) NOT NULL COMMENT '订单状态快照',
measure_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '测量审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回', measure_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '测量审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回',
fee_status VARCHAR(24) NOT NULL DEFAULT 'UNPRICED' COMMENT '费用状态:UNPRICED未计费、CALCULATED已计算、CONFIRMED已确认、SENT已发送、VOIDED已作废', fee_status VARCHAR(24) NOT NULL DEFAULT 'UNPRICED' COMMENT '费用状态:UNPRICED未计费、CALCULATED已计算、CONFIRMED已确认、SENT已发送、VOIDED已作废',
@@ -1,10 +1,9 @@
-- 新完成入库记录使用 WAIT_SPLIT,存量 INBOUNDED 继续作为可分柜兼容状态。 -- 入库完成后的唯一状态为 WAIT_SPLIT。
-- 本脚本不改写业务主表或历史状态,避免影响既有审计记录。
USE baiyue_firstleg; USE baiyue_firstleg;
DROP PROCEDURE IF EXISTS assert_inbounded_compatibility; DROP PROCEDURE IF EXISTS assert_wait_split_status;
DELIMITER $$ DELIMITER $$
CREATE PROCEDURE assert_inbounded_compatibility() CREATE PROCEDURE assert_wait_split_status()
BEGIN BEGIN
IF (SELECT COUNT(*) FROM information_schema.columns IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_order' WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_order'
@@ -13,9 +12,9 @@ BEGIN
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_status_history' WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_status_history'
AND column_name IN ('from_status', 'to_status')) <> 2 THEN AND column_name IN ('from_status', 'to_status')) <> 2 THEN
SIGNAL SQLSTATE '45000' SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'WAIT_SPLIT/INBOUNDED 兼容校验失败:入库状态字段不完整'; SET MESSAGE_TEXT = 'WAIT_SPLIT 状态校验失败:入库状态字段不完整';
END IF; END IF;
END$$ END$$
DELIMITER ; DELIMITER ;
CALL assert_inbounded_compatibility(); CALL assert_wait_split_status();
DROP PROCEDURE assert_inbounded_compatibility; DROP PROCEDURE assert_wait_split_status;
@@ -130,14 +130,14 @@ paths:
/api/platform/v1/inbounds/{inboundId}/fees/calculate: /api/platform/v1/inbounds/{inboundId}/fees/calculate:
post: post:
summary: 按审核通过测量快照试算 FBA 费用 summary: 按审核通过测量快照试算 FBA 费用
description: 仅匹配当前唯一 ACTIVE 船期价格表中的 ACTIVE 价格行,按起运仓、渠道、目的仓、pricingTaxMode 和计费单位估价;不再以 carrierId 或客户计费规则作为前置。海派还需按FBA区域和重量阶梯匹配;测量审核后即使主流程已到 INBOUNDED,也可独立试算且不回退主流程状态;需要 firstleg:fba-pricing:calculate 权限。 description: 仅匹配当前唯一 ACTIVE 船期价格表中的 ACTIVE 价格行,按起运仓、渠道、目的仓、pricingTaxMode 和计费单位估价;不再以 carrierId 或客户计费规则作为前置。海派还需按FBA区域和重量阶梯匹配;测量审核后即使主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态;需要 firstleg:fba-pricing:calculate 权限。
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {content: {application/json: {schema: {$ref: '#/components/schemas/CalculateRequest'}}}} requestBody: {content: {application/json: {schema: {$ref: '#/components/schemas/CalculateRequest'}}}}
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
/api/platform/v1/inbounds/{inboundId}/fees/confirm: /api/platform/v1/inbounds/{inboundId}/fees/confirm:
post: post:
summary: 确认最新费用快照 summary: 确认最新费用快照
description: 确认费用只更新费用状态;主流程已到 INBOUNDED 时保持不变。 description: 确认费用只更新费用状态;主流程已到 WAIT_SPLIT 时保持不变。
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
/api/platform/v1/inbounds/{inboundId}/fees/send: /api/platform/v1/inbounds/{inboundId}/fees/send:
@@ -154,7 +154,7 @@ paths:
/api/platform/v1/inbounds/{inboundId}/fees/published: /api/platform/v1/inbounds/{inboundId}/fees/published:
put: put:
summary: 调整已发布费用 summary: 调整已发布费用
description: 需要 firstleg:fba-pricing:adjust 权限;生成新 SENT 版本并将旧版本标记 SUPERSEDED,不回退 INBOUNDED 状态。 description: 需要 firstleg:fba-pricing:adjust 权限;生成新 SENT 版本并将旧版本标记 SUPERSEDED,不回退 WAIT_SPLIT 状态。
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: requestBody:
required: true required: true
@@ -68,7 +68,7 @@ paths:
post: post:
summary: 按统一订单条件分页查询履约摘要(Order 内部) summary: 按统一订单条件分页查询履约摘要(Order 内部)
description: >- description: >-
mainStatus 接受七态产品状态;WAIT_SPLIT 为正式待分柜状态,INBOUNDED 仅兼容存量已入库记录。 mainStatus 接受六态产品状态;WAIT_SPLIT 为入库完成后的唯一待分柜状态。
计费与入库处理中状态在完成前仍归并为 WAIT_MEASURE,不提前开放分柜。 计费与入库处理中状态在完成前仍归并为 WAIT_MEASURE,不提前开放分柜。
security: security:
- internalServiceToken: [] - internalServiceToken: []
@@ -102,7 +102,7 @@ paths:
/internal/firstleg/v1/inbounds/unified-orders/detail: /internal/firstleg/v1/inbounds/unified-orders/detail:
post: post:
summary: 按订单 ID 查询统一履约详情(Order 内部) summary: 按订单 ID 查询统一履约详情(Order 内部)
description: 时间线保留 Firstleg 内部 fromStatus/toStatus;summary.mainStatus 使用七态产品状态。 description: 时间线保留 Firstleg 内部 fromStatus/toStatus;summary.mainStatus 使用六态产品状态。
security: security:
- internalServiceToken: [] - internalServiceToken: []
requestBody: requestBody:
@@ -390,7 +390,7 @@ paths:
description: 需要 platform:firstleg:inbound:list 权限;默认查询全部企业,可选 tenantId 筛选;仅使用 V2 四个页签 description: 需要 platform:firstleg:inbound:list 权限;默认查询全部企业,可选 tenantId 筛选;仅使用 V2 四个页签
parameters: parameters:
- {name: tenantId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: tenantId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: tab, in: query, schema: {type: string, enum: [WAIT_MEASURE, WAIT_REVIEW, WAIT_FEE_SEND, INBOUNDED]}} - {name: tab, in: query, schema: {type: string, enum: [WAIT_MEASURE, WAIT_REVIEW, WAIT_FEE_SEND, WAIT_SPLIT]}}
responses: responses:
'200': '200':
description: 成功或业务失败,按响应体 code 判断;每条记录携带后续客户隔离操作所需的 tenantId,该字段当前表示客户 ID description: 成功或业务失败,按响应体 code 判断;每条记录携带后续客户隔离操作所需的 tenantId,该字段当前表示客户 ID
@@ -565,7 +565,7 @@ paths:
/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing: /api/merchant/v1/first-leg/orders/{orderId}/inbound-billing:
get: get:
summary: 客户查询已完成入库结果和最新已发布费用 summary: 客户查询已完成入库结果和最新已发布费用
description: 需要 merchant:order:view 权限;客户只读查看,不需要确认费用,仅 INBOUNDED + APPROVED + CONFIRMED 的二期终态可见。 description: 需要 merchant:order:view 权限;客户只读查看,不需要确认费用,仅 WAIT_SPLIT + APPROVED + CONFIRMED 的二期终态可见。
parameters: parameters:
- {name: orderId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: orderId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses: responses:
@@ -656,8 +656,8 @@ components:
schemas: schemas:
UnifiedMainStatus: UnifiedMainStatus:
type: string type: string
enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED] enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING, LOADED, DELIVERED]
description: 七态产品主状态;WAIT_SPLIT 为正式待分柜状态,INBOUNDED 仅兼容存量已入库记录 description: 六态产品主状态;WAIT_SPLIT 为入库完成后的唯一待分柜状态
UnifiedInboundQuery: UnifiedInboundQuery:
type: object type: object
properties: properties:
@@ -666,9 +666,9 @@ components:
orderNo: {type: string, maxLength: 64, nullable: true} orderNo: {type: string, maxLength: 64, nullable: true}
mainStatus: mainStatus:
type: string type: string
enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, LOADING, LOADED, DELIVERED, WAIT_SPLIT] enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING, LOADED, DELIVERED]
nullable: true nullable: true
description: WAIT_SPLIT 与 INBOUNDED 为独立筛选值 description: WAIT_SPLIT 为唯一待分柜筛选值
customerId: {type: integer, format: int64, nullable: true} customerId: {type: integer, format: int64, nullable: true}
receivingWarehouseId: {type: integer, format: int64, nullable: true} receivingWarehouseId: {type: integer, format: int64, nullable: true}
destinationWarehouseId: {type: integer, format: int64, nullable: true} destinationWarehouseId: {type: integer, format: int64, nullable: true}
@@ -767,7 +767,7 @@ components:
orderId: {type: integer, format: int64, nullable: true} orderId: {type: integer, format: int64, nullable: true}
orderNo: {type: string, nullable: true} orderNo: {type: string, nullable: true}
inboundCode: {type: string, nullable: true} inboundCode: {type: string, nullable: true}
processStatus: {type: string, nullable: true, description: Firstleg 内部状态;新入库完成记录进入 WAIT_SPLIT,历史记录可能为 INBOUNDED} processStatus: {type: string, nullable: true, description: Firstleg 内部状态;入库完成记录进入 WAIT_SPLIT}
orderStatusSnapshot: {type: string, nullable: true} orderStatusSnapshot: {type: string, nullable: true}
measureReviewStatus: {type: string, nullable: true} measureReviewStatus: {type: string, nullable: true}
feeStatus: {type: string, nullable: true} feeStatus: {type: string, nullable: true}
@@ -48,9 +48,9 @@ class FbaPricingContractTest {
.contains("/api/platform/v1/inbounds/{inboundId}/fees/send:") .contains("/api/platform/v1/inbounds/{inboundId}/fees/send:")
.contains("/api/platform/v1/inbounds/{inboundId}/fees/published:") .contains("/api/platform/v1/inbounds/{inboundId}/fees/published:")
.contains("Idempotency-Key") .contains("Idempotency-Key")
.contains("主流程已到 INBOUNDED,也可独立试算且不回退主流程状态") .contains("主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态")
.contains("该动作涉及后续仓储流程,本轮二期验收不调用") .contains("该动作涉及后续仓储流程,本轮二期验收不调用")
.contains("不回退 INBOUNDED 状态") .contains("不回退 WAIT_SPLIT 状态")
.doesNotContain("应收") .doesNotContain("应收")
.doesNotContain("应付") .doesNotContain("应付")
.doesNotContain("待客户确认"); .doesNotContain("待客户确认");
@@ -42,7 +42,7 @@ class InboundOrderControllerContractTest {
openapi.indexOf("/api/platform/v1/first-leg/tasks:"), openapi.indexOf("/api/platform/v1/first-leg/tasks:"),
openapi.indexOf("/api/platform/v1/first-leg/tasks/{taskId}:")); openapi.indexOf("/api/platform/v1/first-leg/tasks/{taskId}:"));
assertThat(inboundList) assertThat(inboundList)
.doesNotContain("WAIT_SPLIT"); .contains("WAIT_SPLIT");
assertThat(openapi) assertThat(openapi)
.contains("/api/platform/inbounds:") .contains("/api/platform/inbounds:")
.contains("/api/platform/inbounds/{inboundId}:") .contains("/api/platform/inbounds/{inboundId}:")
@@ -53,7 +53,7 @@ class InboundOrderControllerContractTest {
.contains("required: [id, tenantId]") .contains("required: [id, tenantId]")
.contains("pricingTaxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED]") .contains("pricingTaxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED]")
.contains("tenantId: {type: string, pattern: '^[1-9][0-9]*$', description:") .contains("tenantId: {type: string, pattern: '^[1-9][0-9]*$', description:")
.contains("[WAIT_MEASURE, WAIT_REVIEW, WAIT_FEE_SEND, INBOUNDED]") .contains("[WAIT_MEASURE, WAIT_REVIEW, WAIT_FEE_SEND, WAIT_SPLIT]")
.contains("platform:firstleg:inbound:list") .contains("platform:firstleg:inbound:list")
.doesNotContain("WAIT_CUSTOMER_CONFIRM"); .doesNotContain("WAIT_CUSTOMER_CONFIRM");
assertThat(inboundList).contains("#/components/schemas/InboundOrderPageEnvelope"); assertThat(inboundList).contains("#/components/schemas/InboundOrderPageEnvelope");
@@ -54,7 +54,7 @@ class InternalInboundOrderReferenceControllerTest {
@Test @Test
void unifiedQueryRequiresInternalTokenAndForwardsBatchRequest() { void unifiedQueryRequiresInternalTokenAndForwardsBatchRequest() {
UnifiedInboundQuery request = new UnifiedInboundQuery( UnifiedInboundQuery request = new UnifiedInboundQuery(
10001L, null, null, "INBOUNDED", null, null, null, 10001L, null, null, "WAIT_SPLIT", null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
null, null, 1, 20); null, null, 1, 20);
when(unifiedQueryService.page(request)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); when(unifiedQueryService.page(request)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
@@ -38,7 +38,7 @@ class MerchantInboundBillingControllerContractTest {
String openapi = new ClassPathResource("openapi/firstleg-v1.yaml") String openapi = new ClassPathResource("openapi/firstleg-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8); .getContentAsString(StandardCharsets.UTF_8);
assertThat(openapi).contains("/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing:") assertThat(openapi).contains("/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing:")
.contains("INBOUNDED + APPROVED + CONFIRMED"); .contains("WAIT_SPLIT + APPROVED + CONFIRMED");
} }
@Test @Test
@@ -12,7 +12,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class UnifiedInboundOpenApiContractTest { class UnifiedInboundOpenApiContractTest {
@Test @Test
void documentsInternalUnifiedOrderEndpointsAndSevenStatuses() throws Exception { void documentsInternalUnifiedOrderEndpointsAndSixStatuses() throws Exception {
String yaml = new ClassPathResource("openapi/firstleg-v1.yaml") String yaml = new ClassPathResource("openapi/firstleg-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8); .getContentAsString(StandardCharsets.UTF_8);
Map<String, Object> document = stringMap(new Yaml().load(yaml)); Map<String, Object> document = stringMap(new Yaml().load(yaml));
@@ -25,13 +25,13 @@ class UnifiedInboundOpenApiContractTest {
Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas")); Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas"));
Map<String, Object> responseStatus = stringMap(schemas.get("UnifiedMainStatus")); Map<String, Object> responseStatus = stringMap(schemas.get("UnifiedMainStatus"));
assertThat(stringList(responseStatus.get("enum"))).containsExactly( assertThat(stringList(responseStatus.get("enum"))).containsExactly(
"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"); "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED");
assertThat(responseStatus.get("description").toString()).contains("WAIT_SPLIT", "INBOUNDED"); assertThat(responseStatus.get("description").toString()).contains("WAIT_SPLIT", "唯一待分柜状态");
Map<String, Object> query = stringMap(schemas.get("UnifiedInboundQuery")); Map<String, Object> query = stringMap(schemas.get("UnifiedInboundQuery"));
Map<String, Object> queryStatus = stringMap(stringMap(query.get("properties")).get("mainStatus")); Map<String, Object> queryStatus = stringMap(stringMap(query.get("properties")).get("mainStatus"));
assertThat(stringList(queryStatus.get("enum"))).contains("WAIT_SPLIT"); assertThat(stringList(queryStatus.get("enum"))).contains("WAIT_SPLIT");
assertThat(queryStatus.get("description").toString()).contains("WAIT_SPLIT", "INBOUNDED", "独立"); assertThat(queryStatus.get("description").toString()).contains("WAIT_SPLIT", "唯一待分柜筛选值");
Map<String, Object> inbound = stringMap(schemas.get("UnifiedInboundOrderSummary")); Map<String, Object> inbound = stringMap(schemas.get("UnifiedInboundOrderSummary"));
assertThat(stringMap(inbound.get("properties"))).containsKeys( assertThat(stringMap(inbound.get("properties"))).containsKeys(
@@ -86,7 +86,7 @@ class LoadingDeliveryServiceTest {
@Test @Test
void completesLoadedFulfillmentSliceBeforeAggregatingRoot() { void completesLoadedFulfillmentSliceBeforeAggregatingRoot() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "INBOUNDED", 3)); when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "WAIT_SPLIT", 3));
when(repository.completionTarget(10, 20)).thenReturn(new CompletionTarget( when(repository.completionTarget(10, 20)).thenReturn(new CompletionTarget(
20L, null, 20L, "LOADING", 3, "LOADING", 3)); 20L, null, 20L, "LOADING", 3, "LOADING", 3));
LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class); LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class);
@@ -180,7 +180,7 @@ class LoadingDeliveryServiceTest {
@Test @Test
void keepsHistoricalLoadedChildAsCompletionTarget() { void keepsHistoricalLoadedChildAsCompletionTarget() {
CompletionTarget historical = new CompletionTarget( CompletionTarget historical = new CompletionTarget(
20L, 21L, 20L, "INBOUNDED", 3, "LOADING", 1); 20L, 21L, 20L, "WAIT_SPLIT", 3, "LOADING", 1);
assertThat(historical.completionInboundId()).isEqualTo(21L); assertThat(historical.completionInboundId()).isEqualTo(21L);
} }
@@ -156,7 +156,7 @@ class FbaPricingRepositoryRuleListTest {
when(pricing.update(org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.any())) when(pricing.update(org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.any()))
.thenReturn(1); .thenReturn(1);
assertThat(repository.markInboundFeeSent(10001L, 88L, "INBOUNDED")).isEqualTo(1); assertThat(repository.markInboundFeeSent(10001L, 88L, "WAIT_SPLIT")).isEqualTo(1);
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
ArgumentCaptor<LambdaUpdateWrapper<InboundPricingEntity>> wrapper = ArgumentCaptor<LambdaUpdateWrapper<InboundPricingEntity>> wrapper =
@@ -164,7 +164,7 @@ class FbaPricingRepositoryRuleListTest {
verify(pricing).update(org.mockito.ArgumentMatchers.isNull(), wrapper.capture()); verify(pricing).update(org.mockito.ArgumentMatchers.isNull(), wrapper.capture());
assertThat(wrapper.getValue().getSqlSet()).contains("feeStatus").doesNotContain("processStatus"); assertThat(wrapper.getValue().getSqlSet()).contains("feeStatus").doesNotContain("processStatus");
assertThat(wrapper.getValue().getCustomSqlSegment()).contains("tenantId", "id", "processStatus", "feeStatus"); assertThat(wrapper.getValue().getCustomSqlSegment()).contains("tenantId", "id", "processStatus", "feeStatus");
assertThat(params(wrapper.getValue()).values()).contains(10001L, 88L, "INBOUNDED", "CONFIRMED", "SENT"); assertThat(params(wrapper.getValue()).values()).contains(10001L, 88L, "WAIT_SPLIT", "CONFIRMED", "SENT");
} }
@Test @Test
@@ -11,23 +11,23 @@ import static org.assertj.core.api.Assertions.assertThat;
class InboundStatusUnificationSqlContractTest { class InboundStatusUnificationSqlContractTest {
@Test @Test
void preservesHistoricalWaitSplitWithoutDataRewrite() throws Exception { void enforcesCanonicalWaitSplitStatusWithoutCompatibilityRewrite() throws Exception {
String module = normalize(new ClassPathResource("db/phase3-inbounded-status-unification.sql") String module = normalize(new ClassPathResource("db/phase3-inbounded-status-unification.sql")
.getContentAsString(StandardCharsets.UTF_8)).trim(); .getContentAsString(StandardCharsets.UTF_8)).trim();
String unified = normalize(Files.readString(unifiedPath(), StandardCharsets.UTF_8)); String unified = normalize(Files.readString(unifiedPath(), StandardCharsets.UTF_8));
assertThat(module) assertThat(module)
.contains("CREATE PROCEDURE assert_inbounded_compatibility()") .contains("CREATE PROCEDURE assert_wait_split_status()")
.contains("新完成入库记录使用 WAIT_SPLIT") .contains("入库完成后的唯一状态为 WAIT_SPLIT")
.contains("存量 INBOUNDED") .doesNotContain("兼容")
.doesNotContain("UPDATE fl_inbound_order") .doesNotContain("UPDATE fl_inbound_order")
.doesNotContain("UPDATE fl_inbound_status_history"); .doesNotContain("UPDATE fl_inbound_status_history");
assertThat(unified) assertThat(unified)
.contains("新完成入库记录使用 WAIT_SPLIT") .contains("入库完成后的唯一状态为 WAIT_SPLIT")
.contains("存量 INBOUNDED") .doesNotContain("历史 WAIT_SPLIT")
.doesNotContain("SET process_status = 'INBOUNDED'") .doesNotContain("SET process_status = 'WAIT_SPLIT'")
.doesNotContain("SET from_status = 'INBOUNDED'") .doesNotContain("SET from_status = 'WAIT_SPLIT'")
.doesNotContain("SET to_status = 'INBOUNDED'"); .doesNotContain("SET to_status = 'WAIT_SPLIT'");
} }
private static Path unifiedPath() { private static Path unifiedPath() {
@@ -76,7 +76,7 @@ class PdaSplitSettlementSqlContractTest {
.getAnnotation(Insert.class).value()[0]; .getAnnotation(Insert.class).value()[0];
assertThat(clone) assertThat(clone)
.contains("#{childOrderId},#{childOrderNo}") .contains("#{childOrderId},#{childOrderNo}")
.contains("CASE WHEN #{splitPurpose}='LOADED' THEN 'LOADING' ELSE 'INBOUNDED' END"); .contains("CASE WHEN #{splitPurpose}='LOADED' THEN 'LOADING' ELSE 'WAIT_SPLIT' END");
} }
@Test @Test
@@ -89,7 +89,7 @@ class PdaSplitSettlementSqlContractTest {
long.class, long.class, int.class, long.class, int.class) long.class, long.class, int.class, long.class, int.class)
.getAnnotation(Update.class).value()[0]; .getAnnotation(Update.class).value()[0];
assertThat(markSource) assertThat(markSource)
.contains("process_status IN ('INBOUNDED','LOADING')") .contains("process_status IN ('WAIT_SPLIT','LOADING')")
.contains("package_count=#{loadedPackageCount}") .contains("package_count=#{loadedPackageCount}")
.contains("split_status='FULLY_SPLIT'"); .contains("split_status='FULLY_SPLIT'");
} }
@@ -84,7 +84,7 @@ class Phase3FinalUnifiedSqlContractTest {
"-- 来源:iam/src/main/resources/db/phase3-warehouse-roles.sql", "-- 来源:iam/src/main/resources/db/phase3-warehouse-roles.sql",
"firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql"); "firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql");
assertBlock(unified, "-- 来源:iam/src/main/resources/db/phase3-warehouse-roles.sql", assertBlock(unified, "-- 来源:iam/src/main/resources/db/phase3-warehouse-roles.sql",
"-- 新完成入库记录使用 WAIT_SPLIT;存量 INBOUNDED 继续作为可分柜兼容状态,统一迁移不改写主表或历史状态。", "-- 入库完成后的唯一状态为 WAIT_SPLIT。",
"iam/src/main/resources/db/phase3-warehouse-roles.sql"); "iam/src/main/resources/db/phase3-warehouse-roles.sql");
} }
@@ -21,7 +21,7 @@ class SmartLoadingCandidateMapperContractTest {
.contains("m2.status='APPROVED'") .contains("m2.status='APPROVED'")
.contains("i.inbound_detail_id IS NOT NULL") .contains("i.inbound_detail_id IS NOT NULL")
.contains("o.warehouse_id=#{warehouseId}") .contains("o.warehouse_id=#{warehouseId}")
.contains("o.process_status IN ('WAIT_SPLIT','INBOUNDED')") .contains("o.process_status='WAIT_SPLIT'")
.contains("SELECT SUM(li.package_count)") .contains("SELECT SUM(li.package_count)")
.contains("li.deleted=0") .contains("li.deleted=0")
.contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC"); .contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC");
@@ -38,7 +38,7 @@ class SmartLoadingCandidateMapperContractTest {
.contains("m.status='APPROVED'") .contains("m.status='APPROVED'")
.contains("SELECT MAX(m2.id)") .contains("SELECT MAX(m2.id)")
.contains("o.warehouse_id=#{warehouseId}") .contains("o.warehouse_id=#{warehouseId}")
.contains("o.process_status IN ('WAIT_SPLIT','INBOUNDED')") .contains("o.process_status='WAIT_SPLIT'")
.contains("SELECT SUM(li.package_count)") .contains("SELECT SUM(li.package_count)")
.contains("FOR UPDATE"); .contains("FOR UPDATE");
} }
@@ -32,7 +32,7 @@ class AutomaticInboundCompletionServiceTest {
order("FEE_CONFIRMING", "CALCULATED", 5), order("FEE_CONFIRMING", "CALCULATED", 5),
order("FEE_CONFIRMING", "CONFIRMED", 6), order("FEE_CONFIRMING", "CONFIRMED", 6),
order("INBOUNDING", "CONFIRMED", 7), order("INBOUNDING", "CONFIRMED", 7),
order("INBOUNDED", "SENT", 8)); order("WAIT_SPLIT", "SENT", 8));
FeeSnapshotResponse result = service.completeApproved(10001L, 88L, 9L, "客服", 30L, 4); FeeSnapshotResponse result = service.completeApproved(10001L, 88L, 9L, "客服", 30L, 4);
@@ -48,7 +48,7 @@ class AutomaticInboundCompletionServiceTest {
@Test @Test
void shouldHandleCase02() { void shouldHandleCase02() {
FeeSnapshotResponse fee = fee(501L); FeeSnapshotResponse fee = fee(501L);
when(workflow.findOwned(10001L, 88L)).thenReturn(order("INBOUNDED", "SENT", 8)); when(workflow.findOwned(10001L, 88L)).thenReturn(order("WAIT_SPLIT", "SENT", 8));
when(pricing.latest(10001L, 88L)).thenReturn(fee); when(pricing.latest(10001L, 88L)).thenReturn(fee);
assertThat(service.completeApproved(10001L, 88L, 9L, "客服", 30L, 4)).isSameAs(fee); assertThat(service.completeApproved(10001L, 88L, 9L, "客服", 30L, 4)).isSameAs(fee);
@@ -65,7 +65,7 @@ class AutomaticInboundCompletionServiceTest {
FeeSnapshotResponse fee = fee(501L); FeeSnapshotResponse fee = fee(501L);
when(pricing.latest(10001L, 88L)).thenReturn(fee); when(pricing.latest(10001L, 88L)).thenReturn(fee);
when(workflow.findOwned(10001L, 88L)).thenReturn( when(workflow.findOwned(10001L, 88L)).thenReturn(
order("INBOUNDING", "CONFIRMED", 7), order("INBOUNDED", "SENT", 8)); order("INBOUNDING", "CONFIRMED", 7), order("WAIT_SPLIT", "SENT", 8));
service.completeApproved(10001L, 88L, 9L, "客服", 30L, 4); service.completeApproved(10001L, 88L, 9L, "客服", 30L, 4);
@@ -185,7 +185,7 @@ class FbaPricingServiceTest {
void returnsOriginalSnapshotForRepeatedCalculation() { void returnsOriginalSnapshotForRepeatedCalculation() {
InboundPricingIdempotencyEntity completed = completed("FEE_CALCULATE", 501L); InboundPricingIdempotencyEntity completed = completed("FEE_CALCULATE", 501L);
InboundPricingEntity inbound = inbound("10", "1"); InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("INBOUNDED"); inbound.setProcessStatus("WAIT_SPLIT");
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CALCULATE", "same")).thenReturn(completed); when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CALCULATE", "same")).thenReturn(completed);
when(repository.findFee(TENANT, 501L)).thenReturn(savedFee("CALCULATED")); when(repository.findFee(TENANT, 501L)).thenReturn(savedFee("CALCULATED"));
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
@@ -195,7 +195,7 @@ class FbaPricingServiceTest {
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "same"); FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "same");
assertThat(response.feeId()).isEqualTo(501L); assertThat(response.feeId()).isEqualTo(501L);
assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED"); assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
verify(repository, never()).matchRules(any(), any()); verify(repository, never()).matchRules(any(), any());
verify(repository, never()).insertFee(any()); verify(repository, never()).insertFee(any());
} }
@@ -236,7 +236,7 @@ class FbaPricingServiceTest {
@DisplayName("已入库费用发送只发布费用且不回退主流程状态") @DisplayName("已入库费用发送只发布费用且不回退主流程状态")
void sendsFeeAfterInboundWithoutRollingBackProcessStatus() { void sendsFeeAfterInboundWithoutRollingBackProcessStatus() {
InboundPricingEntity inbound = inbound("10", "1"); InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("INBOUNDED"); inbound.setProcessStatus("WAIT_SPLIT");
inbound.setFeeStatus("CONFIRMED"); inbound.setFeeStatus("CONFIRMED");
InboundFeeEntity confirmed = savedFee("CONFIRMED"); InboundFeeEntity confirmed = savedFee("CONFIRMED");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
@@ -245,14 +245,14 @@ class FbaPricingServiceTest {
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; }) doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; })
.when(repository).insertIdempotency(any()); .when(repository).insertIdempotency(any());
when(repository.sendFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1); when(repository.sendFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1);
when(repository.markInboundFeeSent(TENANT, INBOUND, "INBOUNDED")).thenReturn(1); when(repository.markInboundFeeSent(TENANT, INBOUND, "WAIT_SPLIT")).thenReturn(1);
when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1); when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1);
when(repository.findFee(TENANT, 501L)).thenReturn(confirmed); when(repository.findFee(TENANT, 501L)).thenReturn(confirmed);
FeeSnapshotResponse response = service.send(TENANT, OPERATOR, INBOUND, "send-inbounded"); FeeSnapshotResponse response = service.send(TENANT, OPERATOR, INBOUND, "send-inbounded");
assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED"); assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
verify(repository).markInboundFeeSent(TENANT, INBOUND, "INBOUNDED"); verify(repository).markInboundFeeSent(TENANT, INBOUND, "WAIT_SPLIT");
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.PUBLISHED), verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.PUBLISHED),
eq(TENANT), eq(INBOUND), eq(501L), eq(1), eq(NOW)); eq(TENANT), eq(INBOUND), eq(501L), eq(1), eq(NOW));
} }
@@ -274,7 +274,7 @@ class FbaPricingServiceTest {
@DisplayName("已发布费用可生成新版本且不回退已入库状态") @DisplayName("已发布费用可生成新版本且不回退已入库状态")
void adjustsPublishedFeeWithoutRollingBackInbound() { void adjustsPublishedFeeWithoutRollingBackInbound() {
InboundPricingEntity inbound = inbound("10", "1"); InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("INBOUNDED"); inbound.setProcessStatus("WAIT_SPLIT");
inbound.setFeeStatus("SENT"); inbound.setFeeStatus("SENT");
InboundFeeEntity current = savedFee("SENT"); InboundFeeEntity current = savedFee("SENT");
AtomicReference<InboundFeeEntity> inserted = new AtomicReference<>(); AtomicReference<InboundFeeEntity> inserted = new AtomicReference<>();
@@ -301,8 +301,8 @@ class FbaPricingServiceTest {
assertThat(response.feeVersion()).isEqualTo(2); assertThat(response.feeVersion()).isEqualTo(2);
assertThat(response.feeStatus()).isEqualTo("SENT"); assertThat(response.feeStatus()).isEqualTo("SENT");
assertThat(response.totalAmount()).isEqualByComparingTo("120.00"); assertThat(response.totalAmount()).isEqualByComparingTo("120.00");
assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED"); assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
assertThat(inbound.getProcessStatus()).isEqualTo("INBOUNDED"); assertThat(inbound.getProcessStatus()).isEqualTo("WAIT_SPLIT");
verify(repository).supersedeSent(TENANT, 501L); verify(repository).supersedeSent(TENANT, 501L);
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.ADJUSTED), verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.ADJUSTED),
eq(TENANT), eq(INBOUND), eq(502L), eq(2), eq(NOW)); eq(TENANT), eq(INBOUND), eq(502L), eq(2), eq(NOW));
@@ -506,7 +506,7 @@ class InboundMeasurementServiceTest {
@Test @Test
void editingAndResubmittingInboundMeasurementDoesNotRollBackMainStatus() { void editingAndResubmittingInboundMeasurementDoesNotRollBackMainStatus() {
InboundOrderEntity inbound = inbound("INBOUNDED"); InboundOrderEntity inbound = inbound("WAIT_SPLIT");
inbound.setMeasureReviewStatus("APPROVED"); inbound.setMeasureReviewStatus("APPROVED");
InboundMeasurementEntity measurement = new InboundMeasurementEntity(); InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(30L); measurement.setId(30L);
@@ -524,7 +524,7 @@ class InboundMeasurementServiceTest {
InboundMeasurementResponses.MeasurementSubmitted result = service.submit(1L, 10L, 9L, InboundMeasurementResponses.MeasurementSubmitted result = service.submit(1L, 10L, 9L,
new InboundMeasurementRequests.SubmitRequest(30L, 2)); new InboundMeasurementRequests.SubmitRequest(30L, 2));
assertThat(result.processStatus()).isEqualTo("INBOUNDED"); assertThat(result.processStatus()).isEqualTo("WAIT_SPLIT");
assertThat(result.measureReviewStatus()).isEqualTo("APPROVED"); assertThat(result.measureReviewStatus()).isEqualTo("APPROVED");
assertThat(measurement.getStatus()).isEqualTo("APPROVED"); assertThat(measurement.getStatus()).isEqualTo("APPROVED");
verify(repository, never()).updateMeasurement(any()); verify(repository, never()).updateMeasurement(any());
@@ -42,7 +42,7 @@ class InboundOrderQueryServiceTest {
@Test @Test
void shouldAcceptInboundedAsThePhase2TerminalStatusFilter() { void shouldAcceptInboundedAsThePhase2TerminalStatusFilter() {
InboundOrderQuery query = new InboundOrderQuery(null, null, "INBOUNDED", null, InboundOrderQuery query = new InboundOrderQuery(null, null, "WAIT_SPLIT", null,
"APPROVED", "CONFIRMED", null, null, null, null, null, null, null, "APPROVED", "CONFIRMED", null, null, null, null, null, null, null,
null, null, null, null, 1, 200); null, null, null, null, 1, 200);
PageResult<InboundOrderSummary> expected = new PageResult<>(List.of(), 1, 200, 0, 0); PageResult<InboundOrderSummary> expected = new PageResult<>(List.of(), 1, 200, 0, 0);
@@ -59,7 +59,7 @@ class InboundOrderQueryServiceTest {
assertThat(InboundListTab.WAIT_REVIEW.processStatuses()).containsExactly("MEASURE_PENDING"); assertThat(InboundListTab.WAIT_REVIEW.processStatuses()).containsExactly("MEASURE_PENDING");
assertThat(InboundListTab.WAIT_FEE_SEND.processStatuses()) assertThat(InboundListTab.WAIT_FEE_SEND.processStatuses())
.containsExactly("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING"); .containsExactly("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING");
assertThat(InboundListTab.INBOUNDED.processStatuses()).containsExactly("INBOUNDED"); assertThat(InboundListTab.WAIT_SPLIT.processStatuses()).containsExactly("WAIT_SPLIT");
assertThat(List.of(InboundListTab.values())).extracting(Enum::name) assertThat(List.of(InboundListTab.values())).extracting(Enum::name)
.doesNotContain("WAIT_CUSTOMER_CONFIRM"); .doesNotContain("WAIT_CUSTOMER_CONFIRM");
} }
@@ -36,13 +36,13 @@ class MerchantInboundBillingServiceTest {
@Test @Test
void shouldHandleCase01() { void shouldHandleCase01() {
InboundOrderEntity inbound = inbound("INBOUNDED", "APPROVED", "CONFIRMED"); InboundOrderEntity inbound = inbound("WAIT_SPLIT", "APPROVED", "CONFIRMED");
InboundOrderDetail detail = new InboundOrderDetail(mock(com.baiyue.firstleg.dto.InboundOrderSummary.class), InboundOrderDetail detail = new InboundOrderDetail(mock(com.baiyue.firstleg.dto.InboundOrderSummary.class),
"客户备注", List.of(), List.of( "客户备注", List.of(), List.of(
new InboundOrderDetail.FileSummary(1L, "RELATED", "public.pdf", "PUBLIC", 1, 1L, null), new InboundOrderDetail.FileSummary(1L, "RELATED", "public.pdf", "PUBLIC", 1, 1L, null),
new InboundOrderDetail.FileSummary(2L, "INTERNAL", "internal.pdf", "INTERNAL", 1, 1L, null)), new InboundOrderDetail.FileSummary(2L, "INTERNAL", "internal.pdf", "INTERNAL", 1, 1L, null)),
List.of(new InboundOrderDetail.InternalNoteSummary(1L, "内部备注", 1L, "客服", null)), List.of(new InboundOrderDetail.InternalNoteSummary(1L, "内部备注", 1L, "客服", null)),
List.of(new InboundOrderDetail.StatusHistorySummary(null, "INBOUNDED", "COMPLETE", List.of(new InboundOrderDetail.StatusHistorySummary(null, "WAIT_SPLIT", "COMPLETE",
"内部原因", 1L, "客服", null))); "内部原因", 1L, "客服", null)));
InboundMeasurementResponses.Detail measurement = mock(InboundMeasurementResponses.Detail.class); InboundMeasurementResponses.Detail measurement = mock(InboundMeasurementResponses.Detail.class);
FeeSnapshotResponse fee = mock(FeeSnapshotResponse.class); FeeSnapshotResponse fee = mock(FeeSnapshotResponse.class);
@@ -142,7 +142,7 @@ class MerchantInboundBillingServiceTest {
@Test @Test
void shouldRejectHistoricalPublishedStatusCombination() { void shouldRejectHistoricalPublishedStatusCombination() {
InboundOrderEntity inbound = inbound("INBOUNDED", "APPROVED", "SENT"); InboundOrderEntity inbound = inbound("WAIT_SPLIT", "APPROVED", "SENT");
when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound); when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound);
assertThatThrownBy(() -> service.find(10001L, 77L)) assertThatThrownBy(() -> service.find(10001L, 77L))
@@ -180,7 +180,7 @@ class PdaPickSettlementServiceTest {
void usesTotalPackageCountWhenUnsplitMeasuredInboundHasZeroAvailableColumn() { void usesTotalPackageCountWhenUnsplitMeasuredInboundHasZeroAvailableColumn() {
LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 20, 20, "PICKED"); LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 20, 20, "PICKED");
resolved(List.of(item)); resolved(List.of(item));
when(splits.lockParent(31L)).thenReturn(parent(20, 0, "INBOUNDED", "NOT_SPLIT")); when(splits.lockParent(31L)).thenReturn(parent(20, 0, "WAIT_SPLIT", "NOT_SPLIT"));
when(splits.lockLatestApprovedItemsForInbound(10001L, 31L)) when(splits.lockLatestApprovedItemsForInbound(10001L, 31L))
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20))); .thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
when(allocations.lockConfirmed(10001L, 31L)) when(allocations.lockConfirmed(10001L, 31L))
@@ -336,7 +336,7 @@ class PdaPickSettlementServiceTest {
"IN-001", "MASTER", 31L, "LOADING", 5, 5, "FULLY_SPLIT", 4)); "IN-001", "MASTER", 31L, "LOADING", 5, 5, "FULLY_SPLIT", 4));
when(splits.lockParent(inboundId)).thenReturn(new ParentRow(inboundId, 10001L, 902L, when(splits.lockParent(inboundId)).thenReturn(new ParentRow(inboundId, 10001L, 902L,
"ORDER-REMAINDER", "IN-001-" + (sourceVersion - 1), "CHILD", 31L, "ORDER-REMAINDER", "IN-001-" + (sourceVersion - 1), "CHILD", 31L,
"INBOUNDED", packageCount, packageCount, "NOT_SPLIT", sourceVersion)); "WAIT_SPLIT", packageCount, packageCount, "NOT_SPLIT", sourceVersion));
when(splits.lockLatestApprovedItemsForInbound(10001L, inboundId)) when(splits.lockLatestApprovedItemsForInbound(10001L, inboundId))
.thenReturn(List.of(new MeasurementItemRow(measurementItemId, measurementId, detailId, packageCount))); .thenReturn(List.of(new MeasurementItemRow(measurementItemId, measurementId, detailId, packageCount)));
when(allocations.lockConfirmed(10001L, inboundId)).thenAnswer(invocation -> when(allocations.lockConfirmed(10001L, inboundId)).thenAnswer(invocation ->
@@ -408,7 +408,7 @@ class PdaPickSettlementServiceTest {
} }
private static ParentRow parent(int packageCount) { private static ParentRow parent(int packageCount) {
return parent(packageCount, packageCount, "INBOUNDED", "NOT_SPLIT"); return parent(packageCount, packageCount, "WAIT_SPLIT", "NOT_SPLIT");
} }
private static ParentRow parent(int totalPackageCount, int packageCount, String processStatus, String splitStatus) { private static ParentRow parent(int totalPackageCount, int packageCount, String processStatus, String splitStatus) {
@@ -32,7 +32,6 @@ class UnifiedInboundQueryServiceTest {
assertThat(UnifiedInboundQueryService.mainStatus("FEE_CONFIRMING")).isEqualTo("WAIT_MEASURE"); assertThat(UnifiedInboundQueryService.mainStatus("FEE_CONFIRMING")).isEqualTo("WAIT_MEASURE");
assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDING")).isEqualTo("WAIT_MEASURE"); assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDING")).isEqualTo("WAIT_MEASURE");
assertThat(UnifiedInboundQueryService.mainStatus("WAIT_SPLIT")).isEqualTo("WAIT_SPLIT"); assertThat(UnifiedInboundQueryService.mainStatus("WAIT_SPLIT")).isEqualTo("WAIT_SPLIT");
assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDED")).isEqualTo("INBOUNDED");
assertThat(UnifiedInboundQueryService.mainStatus("LOADING")).isEqualTo("LOADING"); assertThat(UnifiedInboundQueryService.mainStatus("LOADING")).isEqualTo("LOADING");
assertThat(UnifiedInboundQueryService.mainStatus("LOADED")).isEqualTo("LOADED"); assertThat(UnifiedInboundQueryService.mainStatus("LOADED")).isEqualTo("LOADED");
assertThat(UnifiedInboundQueryService.mainStatus("DELIVERED")).isEqualTo("DELIVERED"); assertThat(UnifiedInboundQueryService.mainStatus("DELIVERED")).isEqualTo("DELIVERED");
@@ -44,25 +43,21 @@ class UnifiedInboundQueryServiceTest {
assertThat(UnifiedInboundQueryService.nextAction("WAIT_MEASURE", "MEASURING", null)).isNull(); assertThat(UnifiedInboundQueryService.nextAction("WAIT_MEASURE", "MEASURING", null)).isNull();
assertThat(UnifiedInboundQueryService.nextAction("WAIT_MEASURE", "MEASURE_PENDING", null)) assertThat(UnifiedInboundQueryService.nextAction("WAIT_MEASURE", "MEASURE_PENDING", null))
.isEqualTo("CONFIRM_MEASUREMENT"); .isEqualTo("CONFIRM_MEASUREMENT");
assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDED", null))
.isEqualTo("SMART_SPLIT");
assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDED", "PENDING"))
.isEqualTo("CONFIRM_PRELOAD");
assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDING", null)).isNull();
assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDED", "CONFIRMED")).isNull();
assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "WAIT_SPLIT", null)) assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "WAIT_SPLIT", null))
.isEqualTo("SMART_SPLIT"); .isEqualTo("SMART_SPLIT");
assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "WAIT_SPLIT", "PENDING"))
.isEqualTo("CONFIRM_PRELOAD");
assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "INBOUNDING", null)).isNull();
assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "WAIT_SPLIT", "CONFIRMED")).isNull();
assertThat(UnifiedInboundQueryService.nextAction("LOADED", "LOADED", null)).isNull(); assertThat(UnifiedInboundQueryService.nextAction("LOADED", "LOADED", null)).isNull();
assertThat(UnifiedInboundQueryService.nextAction("DELIVERED", "DELIVERED", null)).isNull(); assertThat(UnifiedInboundQueryService.nextAction("DELIVERED", "DELIVERED", null)).isNull();
} }
@Test @Test
void inboundedAndWaitSplitFiltersRemainDistinct() throws Exception { void waitSplitFilterUsesOnlyCanonicalStatus() throws Exception {
var method = UnifiedInboundQueryService.class.getDeclaredMethod("internalStatuses", String.class); var method = UnifiedInboundQueryService.class.getDeclaredMethod("internalStatuses", String.class);
method.setAccessible(true); method.setAccessible(true);
assertThat(method.invoke(new UnifiedInboundQueryService(), "INBOUNDED"))
.isEqualTo(java.util.List.of("INBOUNDED"));
assertThat(method.invoke(new UnifiedInboundQueryService(), "WAIT_SPLIT")) assertThat(method.invoke(new UnifiedInboundQueryService(), "WAIT_SPLIT"))
.isEqualTo(java.util.List.of("WAIT_SPLIT")); .isEqualTo(java.util.List.of("WAIT_SPLIT"));
} }
@@ -70,14 +65,14 @@ class UnifiedInboundQueryServiceTest {
@Test @Test
void phaseTwoMeasurementStatusesDoNotRequireUndeployedPhaseThreeTables() { void phaseTwoMeasurementStatusesDoNotRequireUndeployedPhaseThreeTables() {
assertThat(UnifiedInboundQueryService.requiresPhaseThreeSummary("WAIT_MEASURE")).isFalse(); assertThat(UnifiedInboundQueryService.requiresPhaseThreeSummary("WAIT_MEASURE")).isFalse();
assertThat(UnifiedInboundQueryService.requiresPhaseThreeSummary("INBOUNDED")).isFalse(); assertThat(UnifiedInboundQueryService.requiresPhaseThreeSummary("WAIT_SPLIT")).isFalse();
assertThat(UnifiedInboundQueryService.requiresPhaseThreeSummary("LOADING")).isTrue(); assertThat(UnifiedInboundQueryService.requiresPhaseThreeSummary("LOADING")).isTrue();
assertThat(UnifiedInboundQueryService.requiresPhaseThreeSummary("LOADED")).isTrue(); assertThat(UnifiedInboundQueryService.requiresPhaseThreeSummary("LOADED")).isTrue();
assertThat(UnifiedInboundQueryService.requiresPhaseThreeSummary("DELIVERED")).isTrue(); assertThat(UnifiedInboundQueryService.requiresPhaseThreeSummary("DELIVERED")).isTrue();
} }
@ParameterizedTest @ParameterizedTest
@ValueSource(strings = {"WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT"}) @ValueSource(strings = {"WAIT_MEASURE", "WAIT_SPLIT"})
void phaseTwoDetailDoesNotQueryPhaseThreeTables(String processStatus) { void phaseTwoDetailDoesNotQueryPhaseThreeTables(String processStatus) {
InboundOrderRepository inbounds = mock(InboundOrderRepository.class); InboundOrderRepository inbounds = mock(InboundOrderRepository.class);
UnifiedInboundReadRepository operations = mock(UnifiedInboundReadRepository.class); UnifiedInboundReadRepository operations = mock(UnifiedInboundReadRepository.class);
@@ -31,7 +31,20 @@ ON DUPLICATE KEY UPDATE
UPDATE iam_permission UPDATE iam_permission
SET status='INACTIVE', deleted=1 SET status='INACTIVE', deleted=1
WHERE tenant_id=0 AND app_code='PLATFORM' WHERE tenant_id=0 AND app_code='PLATFORM'
AND permission_code IN ('platform:overseas-storage-fee:create','platform:overseas-storage-fee:delete'); AND permission_code IN (
'platform:overseas-storage-fee:create','platform:overseas-storage-fee:delete',
'platform:finance:bill:view','platform:finance:account:view',
'platform:finance:fee-source:view','platform:finance:settlement-ar:view',
'platform:finance:settlement-ap:view');
DELETE rp
FROM iam_role_permission rp
JOIN iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id
WHERE p.tenant_id=0 AND p.app_code='PLATFORM'
AND p.permission_code IN (
'platform:finance:bill:view','platform:finance:account:view',
'platform:finance:fee-source:view','platform:finance:settlement-ar:view',
'platform:finance:settlement-ap:view');
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3)
@@ -60,9 +73,7 @@ UPDATE iam_permission p
WHERE p.tenant_id=0 AND p.app_code='PLATFORM'; WHERE p.tenant_id=0 AND p.app_code='PLATFORM';
UPDATE iam_permission p UPDATE iam_permission p
JOIN (SELECT 'platform:finance:account:view' code, 5105040 parent_id UNION ALL JOIN (SELECT 'platform:finance:fee-source:review' code, 5105041 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:fee-source:settlement', 5105041 UNION ALL
SELECT 'platform:finance:reconciliation:create', 5105042 UNION ALL SELECT 'platform:finance:reconciliation:create', 5105042 UNION ALL
SELECT 'platform:finance:reconciliation:review', 5105042 UNION ALL SELECT 'platform:finance:reconciliation:review', 5105042 UNION ALL
@@ -70,9 +81,7 @@ UPDATE iam_permission p
SELECT 'platform:finance:reconciliation:adjust', 5105042 UNION ALL SELECT 'platform:finance:reconciliation:adjust', 5105042 UNION ALL
SELECT 'platform:finance:reconciliation:void', 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: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:create', 5105044 UNION ALL
SELECT 'platform:finance:settlement-ap:view', 5105044 UNION ALL
SELECT 'platform:finance:payment:create', 5105044) mapping SELECT 'platform:finance:payment:create', 5105044) mapping
ON mapping.code=p.permission_code ON mapping.code=p.permission_code
SET p.parent_id=mapping.parent_id SET p.parent_id=mapping.parent_id
@@ -11,16 +11,12 @@ BEGIN
SELECT 1 SELECT 1
FROM iam_permission FROM iam_permission
WHERE (id = 5105000 AND permission_code <> 'platform:finance') WHERE (id = 5105000 AND permission_code <> 'platform:finance')
OR (id = 5105022 AND permission_code <> 'platform:finance:account:view')
OR (id = 5105023 AND permission_code <> 'platform:finance:fee-source:view')
OR (id = 5105024 AND permission_code <> 'platform:finance:fee-source:review') OR (id = 5105024 AND permission_code <> 'platform:finance:fee-source:review')
OR (id = 5105025 AND permission_code <> 'platform:finance:fee-source:settlement') OR (id = 5105025 AND permission_code <> 'platform:finance:fee-source:settlement')
OR (permission_code = 'platform:finance' AND id <> 5105000) OR (permission_code = 'platform:finance' AND id <> 5105000)
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) OR (permission_code = 'platform:finance:fee-source:review' AND id <> 5105024)
OR (permission_code = 'platform:finance:fee-source:settlement' AND id <> 5105025) OR (permission_code = 'platform:finance:fee-source:settlement' AND id <> 5105025)
OR (id IN (5105000, 5105022, 5105023, 5105024, 5105025) OR (id IN (5105000, 5105024, 5105025)
AND (tenant_id <> 0 OR app_code <> 'PLATFORM')) AND (tenant_id <> 0 OR app_code <> 'PLATFORM'))
) THEN ) THEN
SIGNAL SQLSTATE '45000' SIGNAL SQLSTATE '45000'
@@ -33,10 +29,6 @@ BEGIN
VALUES VALUES
(5105000, 0, 'PLATFORM', 'MENU', 0, 'platform:finance', (5105000, 0, 'PLATFORM', 'MENU', 0, 'platform:finance',
'财务管理', '/finance', NULL, 60, 'ACTIVE', 1, 0), '财务管理', '/finance', NULL, 60, 'ACTIVE', 1, 0),
(5105022, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:account:view',
'查看客户财务账户及流水', NULL, 'GET', 130, 'ACTIVE', 1, 0),
(5105023, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:view',
'查看财务费用来源', NULL, 'GET', 140, 'ACTIVE', 1, 0),
(5105024, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:review', (5105024, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:review',
'审核财务费用来源', NULL, 'POST', 150, 'ACTIVE', 1, 0), '审核财务费用来源', NULL, 'POST', 150, 'ACTIVE', 1, 0),
(5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:settlement', (5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:settlement',
@@ -47,6 +39,11 @@ BEGIN
http_method = VALUES(http_method), sort_no = VALUES(sort_no), http_method = VALUES(http_method), sort_no = VALUES(sort_no),
status = 'ACTIVE', built_in = 1, deleted = 0; status = 'ACTIVE', built_in = 1, deleted = 0;
DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id
WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_code IN ('platform:finance:account:view','platform:finance:fee-source:view');
UPDATE iam_permission SET status='INACTIVE', deleted=1
WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN ('platform:finance:account:view','platform:finance:fee-source:view');
IF (SELECT COUNT(*) IF (SELECT COUNT(*)
FROM iam_permission FROM iam_permission
WHERE tenant_id = 0 WHERE tenant_id = 0
@@ -57,10 +54,8 @@ BEGIN
AND built_in = 1 AND built_in = 1
AND deleted = 0 AND deleted = 0
AND permission_code IN ( AND permission_code IN (
'platform:finance:account:view',
'platform:finance:fee-source:view',
'platform:finance:fee-source:review', 'platform:finance:fee-source:review',
'platform:finance:fee-source:settlement')) <> 4 THEN 'platform:finance:fee-source:settlement')) <> 2 THEN
SIGNAL SQLSTATE '45000' SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'finance G1/G2 permission catalog is incomplete'; SET MESSAGE_TEXT = 'finance G1/G2 permission catalog is incomplete';
END IF; END IF;
@@ -17,8 +17,6 @@ IF EXISTS (
OR (id = 5105030 AND permission_code <> 'platform:finance:reconciliation:void') OR (id = 5105030 AND permission_code <> 'platform:finance:reconciliation:void')
OR (id = 5105031 AND permission_code <> 'platform:finance:settlement-ar:create') OR (id = 5105031 AND permission_code <> 'platform:finance:settlement-ar:create')
OR (id = 5105032 AND permission_code <> 'platform:finance:settlement-ap:create') OR (id = 5105032 AND permission_code <> 'platform:finance:settlement-ap:create')
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 = 5105035 AND permission_code <> 'platform:finance:payment:create')
OR (permission_code = 'platform:finance:reconciliation:create' AND id <> 5105026) OR (permission_code = 'platform:finance:reconciliation:create' AND id <> 5105026)
OR (permission_code = 'platform:finance:reconciliation:review' AND id <> 5105027) OR (permission_code = 'platform:finance:reconciliation:review' AND id <> 5105027)
@@ -27,8 +25,6 @@ IF EXISTS (
OR (permission_code = 'platform:finance:reconciliation:void' AND id <> 5105030) OR (permission_code = 'platform:finance:reconciliation:void' AND id <> 5105030)
OR (permission_code = 'platform:finance:settlement-ar:create' AND id <> 5105031) OR (permission_code = 'platform:finance:settlement-ar:create' AND id <> 5105031)
OR (permission_code = 'platform:finance:settlement-ap:create' AND id <> 5105032) OR (permission_code = 'platform:finance:settlement-ap:create' AND id <> 5105032)
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 (permission_code = 'platform:finance:payment:create' AND id <> 5105035)
OR (id BETWEEN 5105026 AND 5105035 AND (tenant_id <> 0 OR app_code <> 'PLATFORM')) OR (id BETWEEN 5105026 AND 5105035 AND (tenant_id <> 0 OR app_code <> 'PLATFORM'))
) THEN ) THEN
@@ -46,8 +42,6 @@ VALUES
(5105030, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:reconciliation:void', '作废或重开客户对账单', NULL, 'POST', 210, 'ACTIVE', 1, 0), (5105030, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:reconciliation:void', '作废或重开客户对账单', NULL, 'POST', 210, 'ACTIVE', 1, 0),
(5105031, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement-ar:create', '生成应收结算单', NULL, 'POST', 220, 'ACTIVE', 1, 0), (5105031, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement-ar:create', '生成应收结算单', NULL, 'POST', 220, 'ACTIVE', 1, 0),
(5105032, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement-ap:create', '生成应付结算单', NULL, 'POST', 230, 'ACTIVE', 1, 0), (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)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
@@ -55,13 +49,24 @@ ON DUPLICATE KEY UPDATE
http_method = VALUES(http_method), sort_no = VALUES(sort_no), http_method = VALUES(http_method), sort_no = VALUES(sort_no),
status = 'ACTIVE', built_in = 1, deleted = 0; status = 'ACTIVE', built_in = 1, deleted = 0;
DELETE rp
FROM iam_role_permission rp
JOIN iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id
WHERE p.tenant_id=0 AND p.app_code='PLATFORM'
AND p.permission_code IN ('platform:finance:settlement-ar:view','platform:finance:settlement-ap:view');
UPDATE iam_permission
SET status='INACTIVE', deleted=1
WHERE tenant_id=0 AND app_code='PLATFORM'
AND permission_code IN ('platform:finance:settlement-ar:view','platform:finance:settlement-ap:view');
IF (SELECT COUNT(*) FROM iam_permission IF (SELECT COUNT(*) FROM iam_permission
WHERE tenant_id=0 AND app_code='PLATFORM' AND parent_id=5105000 WHERE tenant_id=0 AND app_code='PLATFORM' AND parent_id=5105000
AND permission_code IN ('platform:finance:reconciliation:create','platform:finance:reconciliation:review', AND permission_code IN ('platform:finance:reconciliation:create','platform:finance:reconciliation:review',
'platform:finance:reconciliation:issue','platform:finance:reconciliation:adjust','platform:finance:reconciliation:void', '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:create','platform:finance:settlement-ap:create',
'platform:finance:settlement-ar:view','platform:finance:settlement-ap:view','platform:finance:payment:create') 'platform:finance:payment:create')
AND status='ACTIVE' AND deleted=0) <> 10 THEN AND status='ACTIVE' AND deleted=0) <> 8 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='finance G3/G4 permission catalog is incomplete'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='finance G3/G4 permission catalog is incomplete';
END IF; END IF;
@@ -31,6 +31,10 @@ class AdminPrototypeMenuPermissionSqlContractTest {
.contains("'platform:finance:settlement-ar:create', 5105043") .contains("'platform:finance:settlement-ar:create', 5105043")
.contains("'platform:finance:settlement-ap:create', 5105044") .contains("'platform:finance:settlement-ap:create', 5105044")
.contains("'platform:finance:payment:create', 5105044)") .contains("'platform:finance:payment:create', 5105044)")
.contains("'platform:finance:bill:view','platform:finance:settlement-ar:view'")
.contains("'platform:finance:settlement-ap:view'")
.doesNotContain("SELECT 'platform:finance:settlement-ar:view', 5105043")
.doesNotContain("SELECT 'platform:finance:settlement-ap:view', 5105044")
.contains("role_code='PLATFORM_ADMIN'"); .contains("role_code='PLATFORM_ADMIN'");
} }
@@ -62,14 +62,15 @@ class FinanceG1G2PermissionSqlContractTest {
.contains("NULL, 'POST', 150, 'ACTIVE', 1, 0") .contains("NULL, 'POST', 150, 'ACTIVE', 1, 0")
.contains("NULL, 'PUT', 160, 'ACTIVE', 1, 0") .contains("NULL, 'PUT', 160, 'ACTIVE', 1, 0")
.contains("permission_code IN (\n 'platform:finance',") .contains("permission_code IN (\n 'platform:finance',")
.contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 27") .contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 24")
.contains("平台管理员 Finance 授权不完整") .contains("平台管理员 Finance 授权不完整")
.contains("Finance G1-G4"); .contains("Finance G1-G4");
assertThat(financeSection) assertThat(financeSection)
.contains("SELECT 4000001, id, 0 FROM baiyue_iam.iam_permission") .contains("SELECT 4000001, id, 0 FROM baiyue_iam.iam_permission")
.contains("platform:finance:account:view") .contains("platform:finance:account:view");
.contains("platform:finance:settlement-ar:view") assertThat(sql)
.contains("platform:finance:settlement-ap:view"); .contains("platform:finance:settlement-ar:list")
.contains("platform:finance:settlement-ap:list");
} }
private static Path resolveUnifiedSql() { private static Path resolveUnifiedSql() {
@@ -14,8 +14,7 @@ class FinanceG3G4PermissionSqlContractTest {
"platform:finance:reconciliation:create", "platform:finance:reconciliation:review", "platform:finance:reconciliation:create", "platform:finance:reconciliation:review",
"platform:finance:reconciliation:issue", "platform:finance:reconciliation:adjust", "platform:finance:reconciliation:issue", "platform:finance:reconciliation:adjust",
"platform:finance:reconciliation:void", "platform:finance:settlement-ar:create", "platform:finance:reconciliation:void", "platform:finance:settlement-ar:create",
"platform:finance:settlement-ap:create", "platform:finance:settlement-ar:view", "platform:finance:settlement-ap:create", "platform:finance:payment:create"
"platform:finance:settlement-ap:view", "platform:finance:payment:create"
}; };
@Test @Test
@@ -23,9 +22,7 @@ class FinanceG3G4PermissionSqlContractTest {
String sql = new ClassPathResource("db/finance-g3-g4-permissions.sql") String sql = new ClassPathResource("db/finance-g3-g4-permissions.sql")
.getContentAsString(StandardCharsets.UTF_8); .getContentAsString(StandardCharsets.UTF_8);
for (int index = 0; index < CODES.length; index++) { for (String code : CODES) assertThat(sql).contains(code);
assertThat(sql).contains("(" + (5105026 + index) + ", 0, 'PLATFORM', 'BUTTON', 5105000, '" + CODES[index] + "'");
}
assertThat(sql).contains("SIGNAL SQLSTATE '45000'") assertThat(sql).contains("SIGNAL SQLSTATE '45000'")
.contains("id = 5105026 AND permission_code <> 'platform:finance:reconciliation:create'") .contains("id = 5105026 AND permission_code <> 'platform:finance:reconciliation:create'")
.contains("permission_code = 'platform:finance:payment:create' AND id <> 5105035") .contains("permission_code = 'platform:finance:payment:create' AND id <> 5105035")
@@ -33,7 +30,10 @@ class FinanceG3G4PermissionSqlContractTest {
.contains("finance G3/G4 permission id/code conflict") .contains("finance G3/G4 permission id/code conflict")
.contains("permission catalog is incomplete") .contains("permission catalog is incomplete")
.contains("finance permission parent is invalid") .contains("finance permission parent is invalid")
.contains("CALL migrate_finance_g3_g4_permissions()"); .contains("CALL migrate_finance_g3_g4_permissions()")
.contains("status='INACTIVE', deleted=1")
.doesNotContain("'platform:finance:settlement-ar:view', '查看应收结算单'")
.doesNotContain("'platform:finance:settlement-ap:view', '查看应付结算单'");
} }
@Test @Test
@@ -45,12 +45,13 @@ class FinanceG3G4PermissionSqlContractTest {
assertThat(financeSection) assertThat(financeSection)
.contains("migrate_finance_g1_g4_permissions") .contains("migrate_finance_g1_g4_permissions")
.contains("finance G1-G4 permission id/code conflict") .contains("finance G1-G4 permission id/code conflict")
.contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 27"); .contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 24");
assertThat(financeSection) assertThat(financeSection)
.contains("SELECT 4000001, id, 0 FROM baiyue_iam.iam_permission") .contains("SELECT 4000001, id, 0 FROM baiyue_iam.iam_permission")
.contains("platform:finance:reconciliation:create") .contains("platform:finance:reconciliation:create");
.contains("platform:finance:settlement-ar:view") assertThat(sql)
.contains("platform:finance:settlement-ap:view"); .contains("platform:finance:settlement-ar:list")
.contains("platform:finance:settlement-ap:list");
} }
private static Path resolveUnifiedSql() { private static Path resolveUnifiedSql() {
+29 -4
View File
@@ -1,4 +1,29 @@
# 后端全量回归 # 全量 HTTP 验收脚本索引
全量验收只认测试环境 HTTP 脚本。它们通过 Gateway 调用已部署服务,按业务模块执行真实登录、权限、状态、幂等、跨服务和数据链路检查;脚本通过后,剩余工作是 Admin/Client 的真实接口调用与浏览器样式验收,以及 PDA 页面/设备验收。
| 验收目标 | 唯一入口 | 数据副作用 | 覆盖范围 |
|---|---|---|---|
| 平台后台全模块 HTTP 验收 | `scripts/admin-test-env-acceptance.mjs` | 会创建、更新或最终停用测试数据 | `--modules` 默认列表中的平台客户、仓储、订单、入库、分柜、装柜、船期、拖车、报关、财务、通知、统计等模块 |
| 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、入库和权限数据 | 注册/审核、客户隔离、订单、入库计费、文件、财务和客户侧权限链路 |
| Client 二期兼容 HTTP 验收 | `scripts/client-phase2-test-env-acceptance.mjs` | 会写入测试环境业务数据 | 二期客户入库、测量、费用和权限兼容场景;不是替代 Client 全量入口 |
执行前必须确认目标环境、账号和数据清理/停用策略,并获得当前对话的测试环境验收授权。`admin-test-env-modules/*contract.test.mjs` 只验证模块脚本结构,不能代替 HTTP 验收。
推荐顺序:部署服务 → Admin 全模块 HTTP 验收 → Client 完整 HTTP 链路验收 → 前端真实页面调用和样式验收 → PDA 页面/设备验收。任何本地单测通过都不能替代 HTTP 全量验收。
## 本地质量检查(非全量验收)
以下命令只用于代码质量和契约回归,不访问测试环境,不代表服务全量可用:
| 检查 | 入口 |
|---|---|
| 后端 Maven 契约/单元测试 | `scripts/backend-full-regression.ps1` |
| Admin/Client 本地测试与类型检查 | `scripts/frontend-full-regression.ps1` |
| PDA 本地页面和状态测试 | `../apps/pda-uniapp/tests/*.test.mjs` |
| 本地数据库 SQL 静态/受控验收 | `scripts/local-db-full-acceptance.ps1` |
## 后端全量回归
## 入口 ## 入口
@@ -21,7 +46,7 @@
脚本会检查 `pnpm` 和目标应用的 `package.json`;任一命令失败时立即透传退出码,不能同时跳过 Admin 和 Client。 脚本会检查 `pnpm` 和目标应用的 `package.json`;任一命令失败时立即透传退出码,不能同时跳过 Admin 和 Client。
脚本固定运行: 后端脚本固定运行:
```text ```text
mvn -f <仓库根>/pom.xml -pl order,firstleg,iam,gateway,file,warehouse -am test mvn -f <仓库根>/pom.xml -pl order,firstleg,iam,gateway,file,warehouse -am test
@@ -65,8 +90,8 @@ Dry-run 只检查根 `pom.xml` 和关键回归测试文件并打印 Maven 命令
正式入口是 `local-db-full-acceptance.ps1`,只允许连接本机 `127.0.0.1` 或 `localhost`,默认端口 `3306`;测试库 `8.163.30.128` 会立即以退出码 2 拒绝。脚本固定校验统一 SQL SHA-256 `BE7C5C98712D5202F5D187EA9A37C64EAB9F6AA9AE772C89A51F186507B1AE53` 和 14706 行,并静态确认中文注释升级采用校验入队、每表一次 ALTER、结构签名复核、九库覆盖断言及唯一最终 `PASS`。 正式入口是 `local-db-full-acceptance.ps1`,只允许连接本机 `127.0.0.1` 或 `localhost`,默认端口 `3306`;测试库 `8.163.30.128` 会立即以退出码 2 拒绝。脚本固定校验统一 SQL SHA-256 `BE7C5C98712D5202F5D187EA9A37C64EAB9F6AA9AE772C89A51F186507B1AE53` 和 14706 行,并静态确认中文注释升级采用校验入队、每表一次 ALTER、结构签名复核、九库覆盖断言及唯一最终 `PASS`。
```powershell ```powershell
.\local-db-full-acceptance.ps1 -DryRun .\scripts\local-db-full-acceptance.ps1 -DryRun
.\local-db-full-acceptance.ps1 .\scripts\local-db-full-acceptance.ps1
``` ```
Dry-run 只校验 SQL 输入并打印空库、复跑、两类 picking 旧结构、WAIT_SPLIT/IAM、海外仓旧结构、中断恢复和最终结构的执行顺序,不启动 MySQL、不写库。真实运行每阶段写入 `.codex-tmp/p23-04-acceptance/acceptance-run-*.log`,不使用 `--force`,任一 SQL 错误立即停止并透传退出码,最后执行 `verify-final.sql`。脚本不保存或传入数据库密码,使用本机默认认证环境。 Dry-run 只校验 SQL 输入并打印空库、复跑、两类 picking 旧结构、WAIT_SPLIT/IAM、海外仓旧结构、中断恢复和最终结构的执行顺序,不启动 MySQL、不写库。真实运行每阶段写入 `.codex-tmp/p23-04-acceptance/acceptance-run-*.log`,不使用 `--force`,任一 SQL 错误立即停止并透传退出码,最后执行 `verify-final.sql`。脚本不保存或传入数据库密码,使用本机默认认证环境。
+8 -2
View File
@@ -510,7 +510,10 @@ export class AcceptanceRun {
async preparePhase3ContainerFixture() { async preparePhase3ContainerFixture() {
const originalMarker = this.marker const originalMarker = this.marker
const fixtureMarker = `${originalMarker}-CAB` // Keep the shared orchestration marker stable. Downstream loading,
// overview and finance modules select fixtures by context.marker; using a
// private -CAB suffix here made the producer invisible to those modules.
const fixtureMarker = originalMarker
const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement'] const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement']
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound'] const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']
const createdSnapshot = new Map(createdKeys.map((key) => [key, { const createdSnapshot = new Map(createdKeys.map((key) => [key, {
@@ -525,7 +528,10 @@ export class AcceptanceRun {
await this.executeModule('orders-phase3-container-fixture', () => orders.run(this)) await this.executeModule('orders-phase3-container-fixture', () => orders.run(this))
const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url)) const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url))
await this.executeModule('inbound-measurement-phase3-container-fixture', () => measurement.run(this)) await this.executeModule('inbound-measurement-phase3-container-fixture', () => measurement.run(this))
const fixture = this.createdResources.inboundMeasurement || this.createdResources.orders const fixture = this.createdResources.inboundMeasurement
if (!fixture?.inboundId || fixture.finalStatus !== 'APPROVED' || fixture.processStatus !== 'WAIT_SPLIT') {
throw new Error(`独立柜次测量 fixture 未进入 WAIT_SPLIT,禁止回退使用 WAIT_PRICING 订单: processStatus=${fixture?.processStatus || 'missing'}, measurementStatus=${fixture?.finalStatus || 'missing'}`)
}
this.createdResources.orchestrationFixtures = { this.createdResources.orchestrationFixtures = {
...(this.createdResources.orchestrationFixtures || {}), ...(this.createdResources.orchestrationFixtures || {}),
phase3Container: fixture ? { phase3Container: fixture ? {
@@ -403,6 +403,14 @@ export async function run(context) {
'使用订单域真实 waybillId、shipmentId 或 orderNo,不使用数据库主键冒充运单标识', OPTIONS) '使用订单域真实 waybillId、shipmentId 或 orderNo,不使用数据库主键冒充运单标识', OPTIONS)
context.createdResources.financeFullFixtures = { context.createdResources.financeFullFixtures = {
marker: context.marker, customerId, marker: context.marker, customerId,
prerequisites: {
container: containerOwned && Boolean(containerFixture?.feeSourceId) && allocations.length > 0
? { ready: true, category: null }
: { ready: false, category: 'container-prerequisite', evidence: '缺少同 marker 已完成柜次、有效 containerBookingId、按柜费用来源或真实分摊' },
financeSources: fixtures.length > 0
? { ready: true, category: null }
: { ready: false, category: 'finance-source-prerequisite', evidence: '本轮 marker 未产生可消费 Finance 费用来源' },
},
orders: measured.map((item) => ({ orderId: id(item.orderId), orderNo: item.orderNo || null, orders: measured.map((item) => ({ orderId: id(item.orderId), orderNo: item.orderNo || null,
shipmentId: item.shipmentId ? id(item.shipmentId) : null, shipmentId: item.shipmentId ? id(item.shipmentId) : null,
waybillId: item.waybillId ? id(item.waybillId) : (item.shipmentId ? id(item.shipmentId) : null), inboundId: id(item.inboundId), waybillId: item.waybillId ? id(item.waybillId) : (item.shipmentId ? id(item.shipmentId) : null), inboundId: id(item.inboundId),
@@ -323,20 +323,25 @@ export async function run(context) {
finish(context, start) finish(context, start)
return return
} }
fixture.root.reconciliationPrerequisite = null
const permissionResponse = await context.request('GET', '/api/platform/v1/iam/authorization/permissions') const permissionResponse = await context.request('GET', '/api/platform/v1/iam/authorization/permissions')
const permissions = context.apiSuccess(MODULE, '查询当前账号 Finance 有效权限', permissionResponse, '', OPTIONS) const permissions = context.apiSuccess(MODULE, '查询当前账号 Finance 有效权限', permissionResponse, '', OPTIONS)
const granted = new Set(list(permissions).map(String)) const granted = new Set(list(permissions).map(String))
if (granted.has(LEGACY_STATEMENT_VIEW_PERMISSION)) { if (granted.has(LEGACY_STATEMENT_VIEW_PERMISSION)) {
fixture.root.reconciliationPrerequisite = { ready: false, category: 'permission-prerequisite',
required: [STATEMENT_LIST_PERMISSION], legacy: [LEGACY_STATEMENT_VIEW_PERMISSION] }
blocker(context, '客户对账单旧 view 权限兼容阻断', blocker(context, '客户对账单旧 view 权限兼容阻断',
`检测到旧权限=${LEGACY_STATEMENT_VIEW_PERMISSION};列表和详情只能以正式权限=${STATEMENT_LIST_PERMISSION} 验收,旧 view 不得作为通过依据`, `检测到旧权限=${LEGACY_STATEMENT_VIEW_PERMISSION};列表和详情只能以正式权限=${STATEMENT_LIST_PERMISSION} 验收,旧 view 不得作为通过依据;需由 IAM 角色初始化为验收账号授予 ${STATEMENT_LIST_PERMISSION} 并移除旧资源,Finance 脚本不兼容旧权限`,
'legacy-customer-statement-view-permission', permissionResponse) 'legacy-customer-statement-view-permission', permissionResponse)
finish(context, start) finish(context, start)
return return
} }
const missingPermissions = REQUIRED_PERMISSIONS.filter((permission) => !granted.has(permission)) const missingPermissions = REQUIRED_PERMISSIONS.filter((permission) => !granted.has(permission))
if (missingPermissions.length > 0) { if (missingPermissions.length > 0) {
fixture.root.reconciliationPrerequisite = { ready: false, category: 'permission-prerequisite',
required: missingPermissions, legacy: [] }
blocker(context, 'Finance 写权限门禁', blocker(context, 'Finance 写权限门禁',
`缺少权限=${missingPermissions.join(',')}; 对账接口将返回 A0403,禁止继续写入`, `缺少正式权限资源=${missingPermissions.join(',')}; 需由 IAM 角色初始化授予验收账号;对账接口将返回 A0403,禁止继续写入`,
'missing-finance-write-permissions', permissionResponse) 'missing-finance-write-permissions', permissionResponse)
finish(context, start) finish(context, start)
return return
@@ -353,6 +358,8 @@ export async function run(context) {
const invalid = fixture.sources.filter((source) => !source.feeSourceId || !source.customerId const invalid = fixture.sources.filter((source) => !source.feeSourceId || !source.customerId
|| !source.businessDate || !source.waybillId || source.marker !== context.marker) || !source.businessDate || !source.waybillId || source.marker !== context.marker)
if (invalid.length > 0) { if (invalid.length > 0) {
fixture.root.reconciliationPrerequisite = { ready: false, category: 'finance-source-prerequisite',
invalidSourceCount: invalid.length }
blocker(context, 'Fixture 来源契约完整', blocker(context, 'Fixture 来源契约完整',
`存在 ${invalid.length} 条来源缺少 feeSourceId/customerId/businessDate/运单标识/marker`, `存在 ${invalid.length} 条来源缺少 feeSourceId/customerId/businessDate/运单标识/marker`,
'invalid-finance-fixture-contract') 'invalid-finance-fixture-contract')
@@ -364,6 +371,8 @@ export async function run(context) {
&& source.direction === 'RECEIVABLE' && source.currency === 'CNY') && source.direction === 'RECEIVABLE' && source.currency === 'CNY')
const businessDates = new Set(receivables.map((source) => source.businessDate)) const businessDates = new Set(receivables.map((source) => source.businessDate))
if (receivables.length < 4 || businessDates.size < 2) { if (receivables.length < 4 || businessDates.size < 2) {
fixture.root.reconciliationPrerequisite = { ready: false, category: 'finance-source-prerequisite',
receivableCount: receivables.length, businessDateCount: businessDates.size }
blocker(context, '三张 AR 与日期范围 Fixture 门禁', blocker(context, '三张 AR 与日期范围 Fixture 门禁',
`至少需要同客户 4 条 CNY 应收来源且跨 2 个真实 businessDate;actual sources=${receivables.length}, dates=${businessDates.size}`, `至少需要同客户 4 条 CNY 应收来源且跨 2 个真实 businessDate;actual sources=${receivables.length}, dates=${businessDates.size}`,
'insufficient-receivable-date-fixture') 'insufficient-receivable-date-fixture')
@@ -125,6 +125,9 @@ test('AR/AP 数组为空或不完整时原子阻断且不查询、不写入', as
assert.equal(empty.gaps.length, 1) assert.equal(empty.gaps.length, 1)
assert.equal(empty.steps[0].passed, false) assert.equal(empty.steps[0].passed, false)
assert.match(empty.steps[0].evidence, /actual AR=0, AP=0/) assert.match(empty.steps[0].evidence, /actual AR=0, AP=0/)
assert.match(empty.steps[0].evidence, /上游 Finance fixture 前置条件未满足,非结算服务故障/)
assert.match(empty.gaps[0][4], /prerequisite/)
assert.equal(empty.calls.some((call) => call.method === 'POST'), false)
const partial = fakeContext({ receivableSettlements: [ const partial = fakeContext({ receivableSettlements: [
{ settlementId: '101', statementId: '11', direction: 'RECEIVABLE', status: 'ISSUED' }, { settlementId: '101', statementId: '11', direction: 'RECEIVABLE', status: 'ISSUED' },
@@ -133,4 +136,6 @@ test('AR/AP 数组为空或不完整时原子阻断且不查询、不写入', as
assert.equal(partial.calls.length, 0) assert.equal(partial.calls.length, 0)
assert.equal(partial.gaps.length, 1) assert.equal(partial.gaps.length, 1)
assert.match(partial.steps[0].evidence, /actual AR=1, AP=1/) assert.match(partial.steps[0].evidence, /actual AR=1, AP=1/)
assert.match(partial.steps[0].evidence, /receivable-settlement-prerequisite/)
assert.equal(partial.calls.some((call) => call.method === 'POST'), false)
}) })
@@ -128,9 +128,17 @@ export async function run(context) {
const arSettlementIds = new Set(validReceivables.map(settlementId)) const arSettlementIds = new Set(validReceivables.map(settlementId))
const arStatementIds = new Set(validReceivables.map(statementId)) const arStatementIds = new Set(validReceivables.map(statementId))
if (validReceivables.length !== 3 || arSettlementIds.size !== 3 || arStatementIds.size !== 3 || validPayables.length !== 1) { if (validReceivables.length !== 3 || arSettlementIds.size !== 3 || arStatementIds.size !== 3 || validPayables.length !== 1) {
const upstream = fixtures.reconciliationPrerequisite?.ready === false
? fixtures.reconciliationPrerequisite
: Object.values(fixtures.prerequisites || {}).find((item) => item?.ready === false)
const missing = []
if (statements.filter((item) => item.status === 'ISSUED').length < 3) missing.push('reconciliation-issued-statement-prerequisite')
if (validReceivables.length !== 3) missing.push('receivable-settlement-prerequisite')
if (validPayables.length !== 1) missing.push('payable-settlement-prerequisite')
const category = upstream?.category || (missing.length === 1 ? missing[0] : 'upstream-finance-fixture-prerequisite')
gap(context, '取得完整收付结算 Fixture', 'three AR and one AP settlements', gap(context, '取得完整收付结算 Fixture', 'three AR and one AP settlements',
`需要 3 张关联本轮 ISSUED 对账单的独立 AR 和 1 张带 supplierId 的 AP;actual AR=${validReceivables.length}, AP=${validPayables.length}`, `上游 Finance fixture 前置条件未满足,非结算服务故障;需要 3 张关联本轮 ISSUED 对账单的独立 AR 和 1 张带 supplierId 的 AP;actual AR=${validReceivables.length}, AP=${validPayables.length}; prerequisite=${category}; missing=${missing.join(',') || 'unknown'}`,
'incomplete-settlement-fixture') category)
return return
} }
@@ -24,6 +24,19 @@ function validPdf(response) {
&& response.bytes?.length > 4 && new TextDecoder().decode(response.bytes.slice(0, 4)) === '%PDF' && response.bytes?.length > 4 && new TextDecoder().decode(response.bytes.slice(0, 4)) === '%PDF'
} }
async function waitForCompletedMeasurement(context, measurementPath, attempts = 30) {
let response = null
let measurement = null
for (let attempt = 1; attempt <= attempts; attempt += 1) {
response = await context.request('GET', measurementPath)
measurement = response?.http === 200 && String(response?.body?.code) === '200'
? response.body.data : null
if (measurement?.processStatus === 'WAIT_SPLIT') return { response, measurement, attempts: attempt }
if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, 1_000))
}
return { response, measurement, attempts }
}
async function provisionWarehouseResources(context, { inboundId, warehouseId, totalVolumeCbm }) { async function provisionWarehouseResources(context, { inboundId, warehouseId, totalVolumeCbm }) {
const palletCount = Math.ceil(Number(totalVolumeCbm) / DEFAULT_PALLET_CAPACITY_CBM) const palletCount = Math.ceil(Number(totalVolumeCbm) / DEFAULT_PALLET_CAPACITY_CBM)
if (!warehouseId || !Number.isFinite(palletCount) || palletCount < 1) return false if (!warehouseId || !Number.isFinite(palletCount) || palletCount < 1) return false
@@ -394,8 +407,13 @@ export async function run(context) {
context.apiSuccess(MODULE, 'Admin 审核通过墨家测量', approvalResponse, context.apiSuccess(MODULE, 'Admin 审核通过墨家测量', approvalResponse,
`measurementId=${asId(draft.measurementId)}`, OPTIONS) `measurementId=${asId(draft.measurementId)}`, OPTIONS)
const finalResponse = await context.request('GET', measurementPath) // Measurement approval may enqueue pricing/inbound completion. Poll the
const finalMeasurement = context.apiSuccess(MODULE, '查询审核后的最终测量状态', finalResponse, '', OPTIONS) // public projection instead of treating the transient WAIT_PRICING state as
// a smart-loading candidate or manufacturing fixture state in the script.
const completed = await waitForCompletedMeasurement(context, measurementPath)
const finalResponse = completed.response
const finalMeasurement = context.apiSuccess(MODULE, '查询审核后的最终测量状态', finalResponse,
`pollAttempts=${completed.attempts}`, OPTIONS)
context.assert(MODULE, '审核后入库与测量状态闭环且结果未变化', finalResponse, context.assert(MODULE, '审核后入库与测量状态闭环且结果未变化', finalResponse,
finalMeasurement?.processStatus === 'WAIT_SPLIT' && finalMeasurement?.measureReviewStatus === 'APPROVED' finalMeasurement?.processStatus === 'WAIT_SPLIT' && finalMeasurement?.measureReviewStatus === 'APPROVED'
&& finalMeasurement?.measurementStatus === 'APPROVED' && finalMeasurement?.measurementStatus === 'APPROVED'
@@ -407,6 +425,7 @@ export async function run(context) {
context.createdResources.inboundMeasurement = { context.createdResources.inboundMeasurement = {
marker: context.marker, tenantId, inboundId, measurementId: asId(draft.measurementId), marker: context.marker, tenantId, inboundId, measurementId: asId(draft.measurementId),
boxCode, finalStatus: finalMeasurement?.measurementStatus || null, boxCode, finalStatus: finalMeasurement?.measurementStatus || null,
processStatus: finalMeasurement?.processStatus || null,
} }
finishScenario(context, start) finishScenario(context, start)
} }
@@ -34,14 +34,20 @@ async function request(context, method, path, body, headers = {}) {
} }
} }
function fixture(context) { export function selectContainerLoadingFixture(context) {
return context.createdResources?.orchestrationFixtures?.phase3Container const candidates = [
|| context.createdResources?.inboundMeasurement context.createdResources?.orchestrationFixtures?.phase3Container,
|| context.createdResources?.orders context.createdResources?.inboundMeasurement,
context.createdResources?.orders,
].filter(Boolean)
// Finance and downstream modules temporarily switch context.marker for
// their own isolated runs. Never select the older CAB fixture merely because
// it is first in the object; ownership is the marker contract.
return candidates.find((candidate) => candidate.marker === context.marker) || candidates[0]
} }
export async function run(context) { export async function run(context) {
const source = fixture(context) const source = selectContainerLoadingFixture(context)
const tenantId = id(source?.tenantId) const tenantId = id(source?.tenantId)
const inboundId = id(source?.inboundId) const inboundId = id(source?.inboundId)
if (!tenantId || !inboundId || source?.marker !== context.marker) { if (!tenantId || !inboundId || source?.marker !== context.marker) {
@@ -4,6 +4,7 @@ import test from 'node:test'
import { selectCustomsFlow } from './phase3-customs.mjs' import { selectCustomsFlow } from './phase3-customs.mjs'
import { buildFinalizeResource } from './phase3-loading-finalize.mjs' import { buildFinalizeResource } from './phase3-loading-finalize.mjs'
import { selectContainerLoadingFixture } from './phase3-container-loading.mjs'
import { AcceptanceRun } from '../admin-test-env-acceptance.mjs' import { AcceptanceRun } from '../admin-test-env-acceptance.mjs'
function source(name) { function source(name) {
@@ -126,6 +127,7 @@ test('PDA inventory snapshot gates batch and records the only safe mutation gap'
test('container loading creates and confirms booking before scoped candidates', () => { test('container loading creates and confirms booking before scoped candidates', () => {
const value = source('./phase3-container-loading.mjs') const value = source('./phase3-container-loading.mjs')
assert.match(value, /candidates\.find\(\(candidate\) => candidate\.marker === context\.marker\)/)
assertOrder(value, [ assertOrder(value, [
"const bookingCreate = await request", "const bookingCreate = await request",
"const submit = await request", "const submit = await request",
@@ -147,6 +149,33 @@ test('container loading creates and confirms booking before scoped candidates',
assert.doesNotMatch(value, /POST', CONTAINERS, containerPayload/) assert.doesNotMatch(value, /POST', CONTAINERS, containerPayload/)
}) })
test('container loading selects the current marker instead of a stale CAB fixture', () => {
const selected = selectContainerLoadingFixture({
marker: 'ADMIN-RUN-AUTO1',
createdResources: {
orchestrationFixtures: {
phase3Container: { marker: 'ADMIN-RUN-CAB', inboundId: 'CAB-INBOUND' },
},
inboundMeasurement: { marker: 'ADMIN-RUN-AUTO1', inboundId: 'FINANCE-INBOUND' },
orders: { marker: 'ADMIN-RUN-AUTO1', inboundId: 'FINANCE-ORDER-INBOUND' },
},
})
assert.equal(selected.inboundId, 'FINANCE-INBOUND')
assert.equal(selected.marker, 'ADMIN-RUN-AUTO1')
})
test('container loading keeps an unmatched candidate so marker failure stays observable', () => {
const selected = selectContainerLoadingFixture({
marker: 'ADMIN-RUN-AUTO1',
createdResources: {
orchestrationFixtures: {
phase3Container: { marker: 'ADMIN-RUN-CAB', inboundId: 'CAB-INBOUND' },
},
},
})
assert.equal(selected.marker, 'ADMIN-RUN-CAB')
})
test('container loading enforces channel isolation and stable PDA identity across container number changes', () => { test('container loading enforces channel isolation and stable PDA identity across container number changes', () => {
const value = source('./phase3-container-loading.mjs') const value = source('./phase3-container-loading.mjs')
assertOrder(value, [ assertOrder(value, [
@@ -269,6 +298,20 @@ test('phase3 loading provisions its own marker-isolated WAIT_SPLIT fixture', asy
assert.equal(run.marker.includes('-LOAD'), false) assert.equal(run.marker.includes('-LOAD'), false)
}) })
test('container fixture keeps the run marker and does not publish incomplete WAIT_PRICING resources', () => {
const entry = source('../admin-test-env-acceptance.mjs')
assert.match(entry, /const fixtureMarker = originalMarker/)
assert.match(entry, /fixture\.finalStatus !== 'APPROVED'/)
assert.match(entry, /禁止回退使用 WAIT_PRICING 订单/)
})
test('measurement fixture polls asynchronous completion before exporting loading identity', () => {
const measurement = source('./inbound-measurement.mjs')
assert.match(measurement, /waitForCompletedMeasurement/)
assert.match(measurement, /measurement\?\.processStatus === 'WAIT_SPLIT'/)
assert.match(measurement, /setTimeout\(resolve, 1_000\)/)
})
test('customs and finalize consume the same loading inbound identity', () => { test('customs and finalize consume the same loading inbound identity', () => {
const loadingFixture = { marker: 'RUN-1', tenantId: 10001, orderId: 101, inboundId: 201 } const loadingFixture = { marker: 'RUN-1', tenantId: 10001, orderId: 101, inboundId: 201 }
const loading = { const loading = {