This commit is contained in:
2026-09-01 20:07:38 +08:00
parent 539030d2b6
commit 8e17401f37
34 changed files with 244 additions and 501 deletions
+1 -1
View File
@@ -27,7 +27,7 @@
| Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 | | Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 |
| Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 | 所有 Mapper 显式 `@Mapper`,启动类按服务根包注解过滤扫描;先运行 `node scripts/mapper-scan-contract.mjs`,再按目标模块执行 `test-compile/test`,不得等部署启动发现 | | Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 | 所有 Mapper 显式 `@Mapper`,启动类按服务根包注解过滤扫描;先运行 `node scripts/mapper-scan-contract.mjs`,再按目标模块执行 `test-compile/test`,不得等部署启动发现 |
| 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `<service>/scripts/` | 先读 `scripts/AGENTS.md` 和 `scripts/README.md`;Admin HTTP 内部模块继续读 `scripts/admin-test-env-modules/README.md`。新增入口必须登记用途、目标环境、数据副作用和报告位置,并通过 `scripts/script-documentation-contract.test.mjs` | | 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `<service>/scripts/` | 先读 `scripts/AGENTS.md` 和 `scripts/README.md`;Admin HTTP 内部模块继续读 `scripts/admin-test-env-modules/README.md`。新增入口必须登记用途、目标环境、数据副作用和报告位置,并通过 `scripts/script-documentation-contract.test.mjs` |
| 分柜、柜次、智能排柜、预装/装柜清单、真实柜号后补、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位;预装/正式详情由 `/loading-plans/{planId}/lifecycle` 解析。真实集装箱号不阻塞订舱关联、计划确认或 PDA task 创建,可在已 push 的 `EDITING/LOADING` 首次补录,但必须在 PDA 最终完成前存在 | | 分柜、柜次、智能排柜、预装/装柜清单、真实柜号后补、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位;创建柜次时原子生成唯一预装计划,加入入仓码时同步建立关联,不设推送步骤;预装/正式详情由 `/loading-plans/{planId}/lifecycle` 解析。真实集装箱号不阻塞订舱关联、计划确认或 PDA task 创建,可在 `EDITING/LOADING` 首次补录,但必须在 PDA 最终完成前存在 |
| 合柜报关 / 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`、`src/main/resources/openapi/coload-groups-v1.yaml`;按订单建组并冻结全部可用入仓片段,仅要求同国内仓、同渠道 | | 合柜报关 / 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`、`src/main/resources/openapi/coload-groups-v1.yaml`;按订单建组并冻结全部可用入仓片段,仅要求同国内仓、同渠道 |
| 智能装柜入仓码候选与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` | | 智能装柜入仓码候选与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` |
| 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`TruckAppointmentService.java`、`outbound/dispatch/`、`customs/`;拖车只绑定 `RELEASED` 订舱,不携带客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,订舱文件联查 `file/AGENTS.md` | | 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`TruckAppointmentService.java`、`outbound/dispatch/`、`customs/`;拖车只绑定 `RELEASED` 订舱,不携带客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,订舱文件联查 `file/AGENTS.md` |
@@ -5015,10 +5015,6 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan (
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
plan_status VARCHAR(24) NOT NULL DEFAULT 'EDITING' plan_status VARCHAR(24) NOT NULL DEFAULT 'EDITING'
COMMENT '装柜计划状态:EDITING编辑中、LOADING装柜中、COMPLETED装柜完成;建议须经人工确认', COMMENT '装柜计划状态:EDITING编辑中、LOADING装柜中、COMPLETED装柜完成;建议须经人工确认',
push_idempotency_key VARCHAR(128) NULL COMMENT '推送预装幂等键',
preload_pushed_by BIGINT UNSIGNED NULL COMMENT '推送预装操作人ID',
preload_pushed_by_name VARCHAR(64) NULL COMMENT '推送预装操作人名称快照',
preload_pushed_at DATETIME(3) NULL COMMENT '开始装柜并进入LOADING的时间',
preload_completed_by BIGINT UNSIGNED NULL COMMENT '预装完成操作人ID', preload_completed_by BIGINT UNSIGNED NULL COMMENT '预装完成操作人ID',
preload_completed_by_name VARCHAR(64) NULL COMMENT '预装完成操作人名称快照', preload_completed_by_name VARCHAR(64) NULL COMMENT '预装完成操作人名称快照',
preload_completed_at DATETIME(3) NULL COMMENT '预装完成时间', preload_completed_at DATETIME(3) NULL COMMENT '预装完成时间',
@@ -5037,7 +5033,6 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan (
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_loading_plan_no (plan_no), UNIQUE KEY uk_loading_plan_no (plan_no),
UNIQUE KEY uk_loading_plan_booking (container_booking_id), UNIQUE KEY uk_loading_plan_booking (container_booking_id),
UNIQUE KEY uk_loading_plan_push_key (push_idempotency_key),
KEY idx_loading_plan_status (plan_status, updated_at, id), KEY idx_loading_plan_status (plan_status, updated_at, id),
CONSTRAINT fk_loading_plan_booking FOREIGN KEY (container_booking_id) CONSTRAINT fk_loading_plan_booking FOREIGN KEY (container_booking_id)
REFERENCES fl_container_booking (id), REFERENCES fl_container_booking (id),
@@ -5589,10 +5584,6 @@ BEGIN
IF OLD.plan_status = 'LOADING' AND ( IF OLD.plan_status = 'LOADING' AND (
NOT (OLD.id <=> NEW.id) OR NOT (OLD.plan_no <=> NEW.plan_no) NOT (OLD.id <=> NEW.id) OR NOT (OLD.plan_no <=> NEW.plan_no)
OR NOT (OLD.container_booking_id <=> NEW.container_booking_id) OR NOT (OLD.container_booking_id <=> NEW.container_booking_id)
OR NOT (OLD.push_idempotency_key <=> NEW.push_idempotency_key)
OR NOT (OLD.preload_pushed_by <=> NEW.preload_pushed_by)
OR NOT (OLD.preload_pushed_by_name <=> NEW.preload_pushed_by_name)
OR NOT (OLD.preload_pushed_at <=> NEW.preload_pushed_at)
OR NOT (OLD.remark <=> NEW.remark) OR NOT (OLD.remark <=> NEW.remark)
OR NOT (OLD.created_by <=> NEW.created_by) OR NOT (OLD.created_by <=> NEW.created_by)
OR NOT (OLD.created_by_name <=> NEW.created_by_name) OR NOT (OLD.created_by_name <=> NEW.created_by_name)
@@ -5612,12 +5603,7 @@ BEGIN
) THEN ) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '同状态乐观锁触碰不得修改阶段审计字段'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '同状态乐观锁触碰不得修改阶段审计字段';
END IF; END IF;
IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN
IF NEW.push_idempotency_key IS NULL OR CHAR_LENGTH(TRIM(NEW.push_idempotency_key)) = 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '开始装柜必须提供幂等键';
END IF;
SET NEW.preload_pushed_at = COALESCE(NEW.preload_pushed_at, CURRENT_TIMESTAMP(3));
ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN
IF EXISTS (SELECT 1 FROM fl_loading_plan_item item IF EXISTS (SELECT 1 FROM fl_loading_plan_item item
WHERE item.loading_plan_id = OLD.id AND item.deleted = 0 WHERE item.loading_plan_id = OLD.id AND item.deleted = 0
AND NOT EXISTS ( AND NOT EXISTS (
@@ -5998,6 +5984,13 @@ DELIMITER ;
CALL assert_smart_loading_permission_ids_available(); CALL assert_smart_loading_permission_ids_available();
DROP PROCEDURE assert_smart_loading_permission_ids_available; DROP PROCEDURE assert_smart_loading_permission_ids_available;
DELETE role_permission FROM iam_role_permission role_permission
JOIN iam_permission permission ON permission.id = role_permission.permission_id
WHERE permission.tenant_id = 0 AND permission.permission_code IN (
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:push');
DELETE FROM iam_permission WHERE tenant_id = 0 AND permission_code IN (
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:push');
INSERT INTO iam_permission INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, (id, tenant_id, app_code, permission_type, parent_id, permission_code,
permission_name, route_path, http_method, sort_no, status, built_in, deleted) permission_name, route_path, http_method, sort_no, status, built_in, deleted)
@@ -6012,14 +6005,10 @@ VALUES
'编辑货柜', NULL, 'PUT', 30, 'ACTIVE', 1, 0), '编辑货柜', NULL, 'PUT', 30, 'ACTIVE', 1, 0),
(5102094, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:smart-loading:list', (5102094, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:smart-loading:list',
'智能装柜', '/first-leg/smart-loading', 'GET', 50, 'ACTIVE', 1, 0), '智能装柜', '/first-leg/smart-loading', 'GET', 50, 'ACTIVE', 1, 0),
(5102096, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:create',
'创建智能装柜计划', NULL, 'POST', 20, 'ACTIVE', 1, 0),
(5102097, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:assign', (5102097, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:assign',
'分配装柜明细', NULL, 'POST', 30, 'ACTIVE', 1, 0), '分配装柜明细', NULL, 'POST', 30, 'ACTIVE', 1, 0),
(5102098, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:delete', (5102098, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:delete',
'删除装柜明细', NULL, 'DELETE', 40, 'ACTIVE', 1, 0), '删除装柜明细', NULL, 'DELETE', 40, 'ACTIVE', 1, 0),
(5102099, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:push',
'推送预装', NULL, 'POST', 50, 'ACTIVE', 1, 0),
(5102100, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:preload:list', (5102100, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:preload:list',
'预装清单', '/first-leg/preload-list', 'GET', 60, 'ACTIVE', 1, 0), '预装清单', '/first-leg/preload-list', 'GET', 60, 'ACTIVE', 1, 0),
(5102102, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:view', (5102102, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:view',
@@ -6049,8 +6038,7 @@ FROM iam_role role
'platform:firstleg:container:list', 'platform:firstleg:container:view', 'platform:firstleg:container:list', 'platform:firstleg:container:view',
'platform:firstleg:container:create', 'platform:firstleg:container:create',
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list', 'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push',
'platform:firstleg:preload:list', 'platform:firstleg:preload:view', 'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete', 'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list', 'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
@@ -6174,12 +6162,11 @@ BEGIN
AND permission_code IN ( AND permission_code IN (
'platform:firstleg:container:list', 'platform:firstleg:container:view', 'platform:firstleg:container:list', 'platform:firstleg:container:view',
'platform:firstleg:container:create', 'platform:firstleg:container:edit', 'platform:firstleg:container:create', 'platform:firstleg:container:edit',
'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:assign',
'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:delete', 'platform:firstleg:preload:list',
'platform:firstleg:smart-loading:push', 'platform:firstleg:preload:list',
'platform:firstleg:preload:view', 'platform:firstleg:preload:edit', 'platform:firstleg:preload:view', 'platform:firstleg:preload:edit',
'platform:firstleg:preload:complete', 'platform:firstleg:preload:confirm', 'platform:firstleg:preload:complete', 'platform:firstleg:preload:confirm',
'platform:firstleg:loading-list:list', 'platform:firstleg:loading-list:view')) <> 16 THEN 'platform:firstleg:loading-list:list', 'platform:firstleg:loading-list:view')) <> 14 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台权限资源未完整创建'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台权限资源未完整创建';
END IF; END IF;
IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code)) IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code))
@@ -6192,12 +6179,11 @@ BEGIN
'platform:firstleg:container:list', 'platform:firstleg:container:view', 'platform:firstleg:container:list', 'platform:firstleg:container:view',
'platform:firstleg:container:create', 'platform:firstleg:container:create',
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list', 'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push',
'platform:firstleg:preload:list', 'platform:firstleg:preload:view', 'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete', 'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list', 'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
'platform:firstleg:loading-list:view')) <> 32 THEN 'platform:firstleg:loading-list:view')) <> 28 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台角色授权未完整写入'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台角色授权未完整写入';
END IF; END IF;
END$$ END$$
@@ -7795,10 +7781,8 @@ VALUES
(5102092, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:create', '建立货柜', NULL, 'POST', 20, 'ACTIVE', 1, 0), (5102092, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:create', '建立货柜', NULL, 'POST', 20, 'ACTIVE', 1, 0),
(5102093, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:edit', '编辑货柜', NULL, 'PUT', 30, 'ACTIVE', 1, 0), (5102093, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:edit', '编辑货柜', NULL, 'PUT', 30, 'ACTIVE', 1, 0),
(5102094, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:smart-loading:list', '智能装柜', '/first-leg/smart-loading', 'GET', 50, 'ACTIVE', 1, 0), (5102094, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:smart-loading:list', '智能装柜', '/first-leg/smart-loading', 'GET', 50, 'ACTIVE', 1, 0),
(5102096, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:create', '创建智能装柜计划', NULL, 'POST', 20, 'ACTIVE', 1, 0),
(5102097, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:assign', '分配装柜明细', NULL, 'POST', 30, 'ACTIVE', 1, 0), (5102097, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:assign', '分配装柜明细', NULL, 'POST', 30, 'ACTIVE', 1, 0),
(5102098, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:delete', '删除装柜明细', NULL, 'DELETE', 40, 'ACTIVE', 1, 0), (5102098, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:delete', '删除装柜明细', NULL, 'DELETE', 40, 'ACTIVE', 1, 0)
(5102099, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:push', '推送预装', NULL, 'POST', 50, '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),
permission_name = VALUES(permission_name), route_path = VALUES(route_path), permission_name = VALUES(permission_name), route_path = VALUES(route_path),
@@ -7866,8 +7850,7 @@ SELECT role.id, permission.id, role.tenant_id
AND permission.permission_code IN ( AND permission.permission_code IN (
'platform:firstleg:container:view', 'platform:firstleg:container:create', 'platform:firstleg:container:view', 'platform:firstleg:container:create',
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list', 'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete'
'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push'
) )
WHERE role.tenant_id = 0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR'); WHERE role.tenant_id = 0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR');
@@ -13536,10 +13519,6 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'plan_status', '
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'preload_completed_at', '预装完成时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'preload_completed_at', '预装完成时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'preload_completed_by', '预装完成操作人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'preload_completed_by', '预装完成操作人ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'preload_completed_by_name', '预装完成操作人名称快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'preload_completed_by_name', '预装完成操作人名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'preload_pushed_at', '开始装柜并进入LOADING的时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'preload_pushed_by', '推送预装操作人ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'preload_pushed_by_name', '推送预装操作人名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'push_idempotency_key', '推送预装幂等键');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'remark', '平台装柜计划备注'); CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'remark', '平台装柜计划备注');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'updated_at', '装柜计划最后更新时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'updated_at', '装柜计划最后更新时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'updated_by', '最后修改人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'updated_by', '最后修改人ID');
@@ -14974,10 +14953,9 @@ BEGIN
OR (id = 5102092 AND permission_code = 'platform:firstleg:container:create' AND parent_id = 5102090) OR (id = 5102092 AND permission_code = 'platform:firstleg:container:create' AND parent_id = 5102090)
OR (id = 5102093 AND permission_code = 'platform:firstleg:container:edit' AND parent_id = 5102090) OR (id = 5102093 AND permission_code = 'platform:firstleg:container:edit' AND parent_id = 5102090)
OR (id = 5102094 AND permission_code = 'platform:firstleg:smart-loading:list' AND parent_id = 5102000) OR (id = 5102094 AND permission_code = 'platform:firstleg:smart-loading:list' AND parent_id = 5102000)
OR (id = 5102096 AND permission_code = 'platform:firstleg:smart-loading:create' AND parent_id = 5102094)
OR (id = 5102097 AND permission_code = 'platform:firstleg:smart-loading:assign' AND parent_id = 5102094) OR (id = 5102097 AND permission_code = 'platform:firstleg:smart-loading:assign' AND parent_id = 5102094)
OR (id = 5102098 AND permission_code = 'platform:firstleg:smart-loading:delete' AND parent_id = 5102094) OR (id = 5102098 AND permission_code = 'platform:firstleg:smart-loading:delete' AND parent_id = 5102094)
OR (id = 5102099 AND permission_code = 'platform:firstleg:smart-loading:push' AND parent_id = 5102094))) <> 8 THEN )) <> 6 THEN
SIGNAL SQLSTATE '45000' SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:智能装柜权限 ID 或父子关系不正确'; SET MESSAGE_TEXT = '统一迁移失败:智能装柜权限 ID 或父子关系不正确';
END IF; END IF;
@@ -0,0 +1,59 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.file.service.InternalBusinessFileService;
import jakarta.annotation.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/api/merchant/v1/files/order-batch-imports")
public class MerchantOrderBatchImportFileController {
private static final String ERROR_REPORT = "ORDER_BATCH_IMPORT_ERROR_REPORT";
@Resource private InternalBusinessFileService files;
@GetMapping("/{batchNo}/error-reports/{fileId}")
@PreAuthorize("hasAuthority('merchant:order:create')")
public ResponseEntity<byte[]> downloadErrorReport(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String batchNo,
@PathVariable long fileId) {
var value = files.download(merchantTenant(user), ERROR_REPORT, batchNo, fileId);
MediaType contentType;
try {
contentType = MediaType.parseMediaType(value.contentType());
} catch (IllegalArgumentException exception) {
contentType = MediaType.APPLICATION_OCTET_STREAM;
}
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.contentType(contentType)
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(value.fileName(), StandardCharsets.UTF_8).build().toString())
.contentLength(value.content().length)
.body(value.content());
}
private static long merchantTenant(AuthenticatedUser user) {
if (user == null || !"MERCHANT".equalsIgnoreCase(user.appCode())
|| user.tenantId() == null || user.tenantId() <= 0
|| user.userId() == null || user.userId() <= 0) {
throw new BizException(ResultCode.FORBIDDEN, "Merchant identity is required");
}
return user.tenantId();
}
}
@@ -8,6 +8,23 @@ info:
x-lifecycle-owner: ORDER x-lifecycle-owner: ORDER
x-file-object-semantics: IMMUTABLE_CREATE_ONLY x-file-object-semantics: IMMUTABLE_CREATE_ONLY
paths: paths:
/api/merchant/v1/files/order-batch-imports/{batchNo}/error-reports/{fileId}:
get:
summary: 客户下载本客户批量导入错误报告
description: 需要merchant:order:create;客户作用域只取登录态,文件类别固定ORDER_BATCH_IMPORT_ERROR_REPORT。
security: [{bearerAuth: []}]
parameters:
- {$ref: '#/components/parameters/BatchNo'}
- {$ref: '#/components/parameters/FileId'}
responses:
'200':
description: 精确归属的XLSX错误报告
headers:
Cache-Control: {schema: {type: string, enum: [no-store]}}
Content-Disposition: {schema: {type: string}}
content:
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet:
schema: {type: string, format: binary}
/internal/file/v1/business-files/{tenantId}/{businessType}/{batchNo}: /internal/file/v1/business-files/{tenantId}/{businessType}/{batchNo}:
post: post:
summary: Order上传批量导入源文件或错误报告 summary: Order上传批量导入源文件或错误报告
@@ -62,6 +79,7 @@ paths:
schema: {type: string, format: binary} schema: {type: string, format: binary}
components: components:
securitySchemes: securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
internalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token} internalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token}
parameters: parameters:
OperatorId: OperatorId:
@@ -27,6 +27,8 @@ class QualificationAndOrderFileOpenApiContractTest {
assertThat(yaml) assertThat(yaml)
.contains("ORDER_BATCH_IMPORT_SOURCE", "ORDER_BATCH_IMPORT_ERROR_REPORT") .contains("ORDER_BATCH_IMPORT_SOURCE", "ORDER_BATCH_IMPORT_ERROR_REPORT")
.contains("/api/merchant/v1/files/order-batch-imports/{batchNo}/error-reports/{fileId}:")
.contains("需要merchant:order:create")
.contains("ownerScope: {type: string, enum: [CUSTOMER]}") .contains("ownerScope: {type: string, enum: [CUSTOMER]}")
.contains("pattern: '^OBI-[0-9A-F]{20}$'") .contains("pattern: '^OBI-[0-9A-F]{20}$'")
.contains("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") .contains("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
@@ -0,0 +1,63 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.file.service.InternalBusinessFileService;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class MerchantOrderBatchImportFileControllerTest {
@Test
void downloadsOnlyTheAuthenticatedCustomersErrorReportAsNoStoreXlsx() {
var files = mock(InternalBusinessFileService.class);
var controller = new MerchantOrderBatchImportFileController();
ReflectionTestUtils.setField(controller, "files", files);
String batchNo = "OBI-0123456789ABCDEF0123";
byte[] content = {0x50, 0x4b, 0x03, 0x04};
when(files.download(10001L, "ORDER_BATCH_IMPORT_ERROR_REPORT", batchNo, 81L))
.thenReturn(new InternalBusinessFileService.Download("order-batch-errors.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content));
var response = controller.downloadErrorReport(merchant(), batchNo, 81L);
assertThat(response.getBody()).containsExactly(content);
assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.parseMediaType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
assertThat(response.getHeaders().getCacheControl()).contains("no-store");
assertThat(response.getHeaders().getContentDisposition().getType()).isEqualTo("attachment");
assertThat(response.getHeaders().getContentDisposition().getFilename()).isEqualTo("order-batch-errors.xlsx");
verify(files).download(10001L, "ORDER_BATCH_IMPORT_ERROR_REPORT", batchNo, 81L);
}
@Test
void routeRequiresOrderCreateAndRejectsPlatformIdentity() throws Exception {
var method = MerchantOrderBatchImportFileController.class.getMethod("downloadErrorReport",
AuthenticatedUser.class, String.class, long.class);
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('merchant:order:create')");
var files = mock(InternalBusinessFileService.class);
var controller = new MerchantOrderBatchImportFileController();
ReflectionTestUtils.setField(controller, "files", files);
var platform = new AuthenticatedUser(1L, 0L, 0L, 1L, "admin", "PLATFORM", "WEB", Set.of());
assertThatThrownBy(() -> controller.downloadErrorReport(platform,
"OBI-0123456789ABCDEF0123", 81L)).isInstanceOf(BizException.class);
verifyNoInteractions(files);
}
private static AuthenticatedUser merchant() {
return new AuthenticatedUser(2L, 10001L, 10001L, 2L, "merchant", "MERCHANT", "WEB", Set.of());
}
}
+1 -1
View File
@@ -28,7 +28,7 @@
| 财务费用来源快照 | `controller/InternalFinanceFeeSourceController.java`;入库费用与柜次费用分别按来源 ID、版本向 Finance 提供不可变快照 | | 财务费用来源快照 | `controller/InternalFinanceFeeSourceController.java`;入库费用与柜次费用分别按来源 ID、版本向 Finance 提供不可变快照 |
| 柜次费用明细 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` | | 柜次费用明细 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` |
| 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` | | 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` |
| 分柜、预装、真实柜号后补与正式装柜清单生命周期 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`,详情阶段先查 `/loading-plans/{planId}/lifecycle`。同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST`。订舱关联、预装确认和 PDA task 创建不依赖真实集装箱号;已 push 后可在 `EDITING/LOADING` 首次补录,最终 PDA 完成前必须存在;已有值纠正仅限 `EDITING` 且任务未领取、无物理作业事实 | | 分柜、预装、真实柜号后补与正式装柜清单生命周期 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`,详情阶段先查 `/loading-plans/{planId}/lifecycle`。创建柜次时原子生成唯一预装计划,加入入仓码明细时同步创建入库预装聚合与关联,不设推送步骤;同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST`。订舱关联、预装确认和 PDA task 创建不依赖真实集装箱号;可在 `EDITING/LOADING` 首次补录,最终 PDA 完成前必须存在;已有值纠正仅限 `EDITING` 且任务未领取、无物理作业事实 |
| 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端冻结每票全部 `WAIT_SPLIT` 可用入仓片段与满箱数;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 | | 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端冻结每票全部 `WAIT_SPLIT` 可用入仓片段与满箱数;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 |
| 货柜与智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`loading/`;按入仓码候选与箱数分配分别从 `/inbound-candidates`、`/plans/{planId}/inbound-assignments` 进入,明细规格拆分留在服务端 | | 货柜与智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`loading/`;按入仓码候选与箱数分配分别从 `/inbound-candidates`、`/plans/{planId}/inbound-assignments` 进入,明细规格拆分留在服务端 |
| 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 | | 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 |
@@ -72,7 +72,7 @@ public class PlatformLoadingListController {
return ApiResponse.success(service.lifecycle(planId, warehouseId)); return ApiResponse.success(service.lifecycle(planId, warehouseId));
} }
@Operation(summary = "修改装柜运输字段", description = "已推送后可在装柜完成前首次补录集装箱号;已有柜号的纠正仅限 EDITING 且 PDA 尚未领取或产生物理作业事实,并必须提供原因和版本;柜号不能清空。") @Operation(summary = "修改装柜运输字段", description = "可在装柜完成前首次补录集装箱号;已有柜号的纠正仅限 EDITING 且 PDA 尚未领取或产生物理作业事实,并必须提供原因和版本;柜号不能清空。")
@PatchMapping("/loading-plans/{planId}/transport") @PatchMapping("/loading-plans/{planId}/transport")
@PreAuthorize("hasAuthority('platform:firstleg:preload:edit')") @PreAuthorize("hasAuthority('platform:firstleg:preload:edit')")
public ApiResponse<PreloadDetail> updateTransport( public ApiResponse<PreloadDetail> updateTransport(
@@ -86,7 +86,7 @@ public class PlatformLoadingListController {
planId, warehouseId, user.userId(), user.username(), idempotencyKey, request)); planId, warehouseId, user.userId(), user.username(), idempotencyKey, request));
} }
@Operation(summary = "确认整份预装计划", description = "按 loadingPlanId 原子确认全部关联入库预装票据;要求已推送且关联订舱已放舱,真实集装箱号可在PDA确认装柜完成前补录。成功后唯一创建待领取 PDA 任务,claim 后才进入 LOADING。") @Operation(summary = "确认整份预装计划", description = "按 loadingPlanId 原子确认全部已分配入库预装票据;要求关联订舱已放舱,真实集装箱号可在PDA确认装柜完成前补录。成功后唯一创建待领取 PDA 任务,claim 后才进入 LOADING。")
@PostMapping("/loading-plans/{planId}/confirm") @PostMapping("/loading-plans/{planId}/confirm")
@PreAuthorize("hasAuthority('platform:firstleg:preload:edit')") @PreAuthorize("hasAuthority('platform:firstleg:preload:edit')")
public ApiResponse<PreloadConfirmationResponse> confirmPreload( public ApiResponse<PreloadConfirmationResponse> confirmPreload(
@@ -18,8 +18,6 @@ public class PlatformSmartLoadingController {
public ApiResponse<Plan> draftByBooking(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long bookingId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.draftByBooking(bookingId));} public ApiResponse<Plan> draftByBooking(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long bookingId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.draftByBooking(bookingId));}
@GetMapping("/plans/{planId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") @GetMapping("/plans/{planId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
public ApiResponse<Plan> detail(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.detail(planId));} public ApiResponse<Plan> detail(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.detail(planId));}
@PostMapping("/plans") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:create')")
public ApiResponse<Plan> create(@AuthenticationPrincipal AuthenticatedUser u,@Valid @RequestBody CreateRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.create(r.bookingId(),r.remark(),u.userId(),u.username()));}
@PostMapping("/plans/{planId}/assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") @PostMapping("/plans/{planId}/assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Plan> assign(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assign(planId,r,u.userId(),u.username()));} public ApiResponse<Plan> assign(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assign(planId,r,u.userId(),u.username()));}
@PostMapping("/plans/{planId}/inbound-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") @PostMapping("/plans/{planId}/inbound-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
@@ -36,6 +34,4 @@ public class PlatformSmartLoadingController {
public ApiResponse<Plan> warehouseRemark(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody WarehouseRemarkRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.updateWarehouseRemark(planId,itemId,r,u.userId(),u.username()));} public ApiResponse<Plan> warehouseRemark(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody WarehouseRemarkRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.updateWarehouseRemark(planId,itemId,r,u.userId(),u.username()));}
@DeleteMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')") @DeleteMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')")
public ApiResponse<Plan> delete(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody DeleteRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.delete(planId,itemId,r,u.userId(),u.username()));} public ApiResponse<Plan> delete(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody DeleteRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.delete(planId,itemId,r,u.userId(),u.username()));}
@PostMapping("/plans/{planId}/push-preload") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:push')")
public ApiResponse<Plan> push(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@RequestHeader("Idempotency-Key") String key,@Valid @RequestBody PushRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.pushPreload(planId,key,r,u.userId(),u.username()));}
} }
@@ -85,7 +85,6 @@ public final class LoadingListDtos {
} }
public record PreloadReadiness( public record PreloadReadiness(
boolean preloadPushed,
boolean containerNoReady, boolean containerNoReady,
int preloadLinkCount, int preloadLinkCount,
int confirmedPreloadCount, int confirmedPreloadCount,
@@ -15,7 +15,6 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal unitVolumeCbm, BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal unitVolumeCbm,
Long coLoadGroupId, String groupNo, Integer memberCount, Integer groupTotalBoxes, Long coLoadGroupId, String groupNo, Integer memberCount, Integer groupTotalBoxes,
Boolean locked, String blockReason) {} Boolean locked, String blockReason) {}
public record CreateRequest(@NotNull @Positive Long bookingId, @Size(max=500) String remark) {}
public record AssignRequest(@NotNull @Positive Long inboundId,@NotNull @Positive Long inboundDetailId, public record AssignRequest(@NotNull @Positive Long inboundId,@NotNull @Positive Long inboundDetailId,
@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedVersion,@NotBlank @Size(max=128) String operationId) {} @NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedVersion,@NotBlank @Size(max=128) String operationId) {}
public record AssignInboundRequest(@NotNull @Positive Long inboundId, public record AssignInboundRequest(@NotNull @Positive Long inboundId,
@@ -35,7 +34,6 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
public record WarehouseRemarkRequest(@Size(max=1000) String warehouseRemark, public record WarehouseRemarkRequest(@Size(max=1000) String warehouseRemark,
@NotNull @Min(0) Integer expectedPlanVersion, @NotNull @Min(0) Integer expectedItemVersion, @NotNull @Min(0) Integer expectedPlanVersion, @NotNull @Min(0) Integer expectedItemVersion,
@NotBlank @Size(max=128) String operationId) {} @NotBlank @Size(max=128) String operationId) {}
public record PushRequest(@NotNull @Min(0) Integer expectedVersion) {}
public record Plan(Long id,String planNo,String status,Long bookingId,String containerNo,String voyageNo, public record Plan(Long id,String planNo,String status,Long bookingId,String containerNo,String voyageNo,
Integer version,Boolean canAssign,Boolean canEdit,String blockedReason,String finalCompletionStatus, Integer version,Boolean canAssign,Boolean canEdit,String blockedReason,String finalCompletionStatus,
Long pdaTaskId,String pdaTaskStatus,Integer pdaTaskVersion,List<LoadingPlanItemEntity> items) {} Long pdaTaskId,String pdaTaskStatus,Integer pdaTaskVersion,List<LoadingPlanItemEntity> items) {}
@@ -3,7 +3,6 @@ import com.baomidou.mybatisplus.annotation.*; import lombok.Getter; import lombo
@Getter @Setter @TableName("fl_loading_plan") @Getter @Setter @TableName("fl_loading_plan")
public class LoadingPlanEntity { public class LoadingPlanEntity {
@TableId(type=IdType.ASSIGN_ID) private Long id; private String planNo; private Long containerBookingId; private String planStatus; @TableId(type=IdType.ASSIGN_ID) private Long id; private String planNo; private Long containerBookingId; private String planStatus;
private String pushIdempotencyKey; private Long preloadPushedBy; private String preloadPushedByName; private LocalDateTime preloadPushedAt;
private Long preloadCompletedBy; private String preloadCompletedByName; private LocalDateTime preloadCompletedAt; private Long confirmedBy; private Long preloadCompletedBy; private String preloadCompletedByName; private LocalDateTime preloadCompletedAt; private Long confirmedBy;
private String confirmedByName; private LocalDateTime confirmedAt; private String remark; private Long createdBy; private String createdByName; private String confirmedByName; private LocalDateTime confirmedAt; private String remark; private Long createdBy; private String createdByName;
private LocalDateTime createdAt; private Long updatedBy; private String updatedByName; private LocalDateTime updatedAt; @Version private Integer version; private LocalDateTime createdAt; private Long updatedBy; private String updatedByName; private LocalDateTime updatedAt; @Version private Integer version;
@@ -17,7 +17,7 @@ public interface LoadingModelBridgeMapper extends BaseMapper<PreloadLoadingPlanL
PreloadSnapshot preload(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); PreloadSnapshot preload(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
@Select("SELECT p.id,p.container_booking_id containerBookingId,b.container_no containerNo," @Select("SELECT p.id,p.container_booking_id containerBookingId,b.container_no containerNo,"
+ "p.plan_status status,p.version,p.preload_pushed_at preloadPushedAt FROM fl_loading_plan p JOIN fl_container_booking b " + "p.plan_status status,p.version FROM fl_loading_plan p JOIN fl_container_booking b "
+ "ON b.id=p.container_booking_id WHERE p.id=#{loadingPlanId}") + "ON b.id=p.container_booking_id WHERE p.id=#{loadingPlanId}")
LoadingPlanSnapshot loadingPlan(@Param("loadingPlanId") long loadingPlanId); LoadingPlanSnapshot loadingPlan(@Param("loadingPlanId") long loadingPlanId);
@@ -86,11 +86,6 @@ public interface LoadingModelBridgeMapper extends BaseMapper<PreloadLoadingPlanL
record PreloadSnapshot(Long id, Long tenantId, Long inboundId, String status, Integer version) {} record PreloadSnapshot(Long id, Long tenantId, Long inboundId, String status, Integer version) {}
record LoadingPlanSnapshot(Long id, Long containerBookingId, String containerNo, record LoadingPlanSnapshot(Long id, Long containerBookingId, String containerNo,
String status, Integer version, java.time.LocalDateTime preloadPushedAt) { String status, Integer version) {}
public LoadingPlanSnapshot(Long id, Long containerBookingId, String containerNo,
String status, Integer version) {
this(id, containerBookingId, containerNo, status, version, null);
}
}
record AllocationScope(Long tenantId, Long inboundId, Integer allocatedPackageCount) {} record AllocationScope(Long tenantId, Long inboundId, Integer allocatedPackageCount) {}
} }
@@ -109,14 +109,13 @@ public class LoadingModelBridgeService {
return repository.byLoadingPlan(loadingPlanId); return repository.byLoadingPlan(loadingPlanId);
} }
/** Adds a newly allocated inbound to an already pushed EDITING/LOADING plan. */ /** Adds a newly allocated inbound to the container's automatically created plan. */
@Transactional @Transactional
public void bindIncrementalAllocation(long loadingPlanId, long tenantId, long inboundId, public void bindIncrementalAllocation(long loadingPlanId, long tenantId, long inboundId,
long operatorId, String operatorName, String operationId) { long operatorId, String operatorName, String operationId) {
if (repository.byPair(tenantId, inboundId, loadingPlanId) != null) return; if (repository.byPair(tenantId, inboundId, loadingPlanId) != null) return;
var plan = repository.loadingPlan(loadingPlanId); var plan = repository.loadingPlan(loadingPlanId);
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在"); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在");
if (plan.preloadPushedAt() == null) return;
if (!Set.of("EDITING", "LOADING").contains(plan.status())) { if (!Set.of("EDITING", "LOADING").contains(plan.status())) {
throw conflict("PDA任务正在最终确认或已经完成,不能新增预装关联"); throw conflict("PDA任务正在最终确认或已经完成,不能新增预装关联");
} }
@@ -168,8 +167,8 @@ public class LoadingModelBridgeService {
public List<Link> requireAllocatedLinksForConfirmation(long loadingPlanId) { public List<Link> requireAllocatedLinksForConfirmation(long loadingPlanId) {
var loadingPlan = repository.loadingPlan(loadingPlanId); var loadingPlan = repository.loadingPlan(loadingPlanId);
if (loadingPlan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在"); if (loadingPlan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在");
if (loadingPlan.preloadPushedAt() == null || !"EDITING".equals(loadingPlan.status())) { if (!"EDITING".equals(loadingPlan.status())) {
throw conflict("仅已推送且仍为EDITING的预装计划可以确认"); throw conflict("仅编辑中的预装计划可以确认");
} }
List<AllocationScope> scopes = repository.allocatedScopes(loadingPlanId); List<AllocationScope> scopes = repository.allocatedScopes(loadingPlanId);
if (scopes.isEmpty()) throw conflict("装柜计划尚未分配入库单货物"); if (scopes.isEmpty()) throw conflict("装柜计划尚未分配入库单货物");
@@ -21,7 +21,7 @@ import java.util.List;
public interface LoadingListQueryMapper { public interface LoadingListQueryMapper {
String AGGREGATE_COLUMNS = """ String AGGREGATE_COLUMNS = """
p.id AS plan_id, p.plan_no, p.container_booking_id, p.plan_status, p.remark, p.id AS plan_id, p.plan_no, p.container_booking_id, p.plan_status, p.remark,
p.created_by, p.created_by_name, p.created_at, p.version, p.preload_pushed_at, p.created_by, p.created_by_name, p.created_at, p.version,
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name, b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
b.vessel_name, b.voyage_no, b.container_type, b.vessel_name, b.voyage_no, b.container_type,
(SELECT COUNT(*) FROM fl_preload_loading_plan_link preload_link (SELECT COUNT(*) FROM fl_preload_loading_plan_link preload_link
@@ -49,7 +49,7 @@ public interface LoadingListQueryMapper {
String AGGREGATE_GROUP = """ String AGGREGATE_GROUP = """
p.id, p.plan_no, p.container_booking_id, p.plan_status, p.remark, p.id, p.plan_no, p.container_booking_id, p.plan_status, p.remark,
p.created_by, p.created_by_name, p.created_at, p.version, p.preload_pushed_at, p.created_by, p.created_by_name, p.created_at, p.version,
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name, b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
b.vessel_name, b.voyage_no, b.container_type b.vessel_name, b.voyage_no, b.container_type
"""; """;
@@ -61,10 +61,7 @@ public interface LoadingListQueryMapper {
FROM fl_loading_plan p FROM fl_loading_plan p
JOIN fl_container_booking b ON b.id = p.container_booking_id JOIN fl_container_booking b ON b.id = p.container_booking_id
LEFT JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0 LEFT JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0
WHERE p.preload_pushed_at IS NOT NULL WHERE p.plan_status IN ('EDITING', 'LOADING')
AND p.plan_status IN ('EDITING', 'LOADING')
AND EXISTS (SELECT 1 FROM fl_loading_plan_item active_item
WHERE active_item.loading_plan_id = p.id AND active_item.deleted = 0)
<if test="query.status != null and query.status != ''">AND p.plan_status = #{query.status}</if> <if test="query.status != null and query.status != ''">AND p.plan_status = #{query.status}</if>
<if test="query.customerId != null">AND EXISTS (SELECT 1 FROM fl_loading_plan_item customer_item WHERE customer_item.loading_plan_id = p.id AND customer_item.deleted = 0 AND customer_item.account_id = #{query.customerId})</if> <if test="query.customerId != null">AND EXISTS (SELECT 1 FROM fl_loading_plan_item customer_item WHERE customer_item.loading_plan_id = p.id AND customer_item.deleted = 0 AND customer_item.account_id = #{query.customerId})</if>
<if test="query.keyword != null and query.keyword != ''"> <if test="query.keyword != null and query.keyword != ''">
@@ -227,7 +224,7 @@ public interface LoadingListQueryMapper {
UPDATE fl_loading_plan UPDATE fl_loading_plan
SET updated_by = #{operatorId}, updated_by_name = #{operatorName}, SET updated_by = #{operatorId}, updated_by_name = #{operatorName},
updated_at = CURRENT_TIMESTAMP(3), version = version + 1 updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{planId} AND plan_status = 'EDITING' AND preload_pushed_at IS NOT NULL WHERE id = #{planId} AND plan_status = 'EDITING'
AND version = #{expectedVersion} AND version = #{expectedVersion}
AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task
JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.id JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.id
@@ -249,7 +246,7 @@ public interface LoadingListQueryMapper {
SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}, SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo},
b.updated_by = #{operatorId}, b.updated_by_name = #{operatorName}, b.updated_by = #{operatorId}, b.updated_by_name = #{operatorName},
b.updated_at = CURRENT_TIMESTAMP(3), b.version = b.version + 1 b.updated_at = CURRENT_TIMESTAMP(3), b.version = b.version + 1
WHERE p.id = #{planId} AND p.plan_status='EDITING' AND p.preload_pushed_at IS NOT NULL WHERE p.id = #{planId} AND p.plan_status='EDITING'
AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task
JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.id JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.id
WHERE task.loading_plan_id=p.id) WHERE task.loading_plan_id=p.id)
@@ -271,7 +268,7 @@ public interface LoadingListQueryMapper {
SET p.updated_by=#{operatorId}, p.updated_by_name=#{operatorName}, SET p.updated_by=#{operatorId}, p.updated_by_name=#{operatorName},
p.updated_at=CURRENT_TIMESTAMP(3), p.version=p.version+1 p.updated_at=CURRENT_TIMESTAMP(3), p.version=p.version+1
WHERE p.id=#{planId} AND p.plan_status IN ('EDITING','LOADING') WHERE p.id=#{planId} AND p.plan_status IN ('EDITING','LOADING')
AND p.preload_pushed_at IS NOT NULL AND p.version=#{expectedVersion} AND p.version=#{expectedVersion}
AND b.container_no IS NULL AND b.container_no IS NULL
""") """)
int touchForInitialContainerFill(@Param("planId") long planId, int touchForInitialContainerFill(@Param("planId") long planId,
@@ -286,7 +283,7 @@ public interface LoadingListQueryMapper {
b.updated_by=#{operatorId}, b.updated_by_name=#{operatorName}, b.updated_by=#{operatorId}, b.updated_by_name=#{operatorName},
b.updated_at=CURRENT_TIMESTAMP(3), b.version=b.version+1 b.updated_at=CURRENT_TIMESTAMP(3), b.version=b.version+1
WHERE p.id=#{planId} AND p.plan_status IN ('EDITING','LOADING') WHERE p.id=#{planId} AND p.plan_status IN ('EDITING','LOADING')
AND p.preload_pushed_at IS NOT NULL AND b.container_no IS NULL AND b.container_no IS NULL
AND #{containerNo} IS NOT NULL AND #{containerNo} IS NOT NULL
""") """)
int fillInitialContainerNo(@Param("planId") long planId, int fillInitialContainerNo(@Param("planId") long planId,
@@ -331,7 +328,7 @@ public interface LoadingListQueryMapper {
UPDATE fl_loading_plan UPDATE fl_loading_plan
SET updated_by = #{operatorId}, updated_by_name = #{operatorName}, SET updated_by = #{operatorId}, updated_by_name = #{operatorName},
updated_at = CURRENT_TIMESTAMP(3), version = version + 1 updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{planId} AND plan_status = 'EDITING' AND preload_pushed_at IS NOT NULL WHERE id = #{planId} AND plan_status = 'EDITING'
AND version = #{expectedVersion} AND version = #{expectedVersion}
""") """)
int touchForPreloadConfirmation(@Param("planId") long planId, int touchForPreloadConfirmation(@Param("planId") long planId,
@@ -385,7 +382,6 @@ public interface LoadingListQueryMapper {
private String createdByName; private String createdByName;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private Integer version; private Integer version;
private LocalDateTime preloadPushedAt;
private String containerNo; private String containerNo;
private String cabinetNo; private String cabinetNo;
private Long warehouseId; private Long warehouseId;
@@ -7,17 +7,10 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstl
WHERE id=#{planId} AND plan_status IN ('EDITING','LOADING') AND version=#{expectedVersion} WHERE id=#{planId} AND plan_status IN ('EDITING','LOADING') AND version=#{expectedVersion}
""") """)
int touchEditing(@Param("planId") long planId,@Param("expectedVersion") int expectedVersion,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName); int touchEditing(@Param("planId") long planId,@Param("expectedVersion") int expectedVersion,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_loading_plan SET push_idempotency_key=#{key},
preload_pushed_by=#{operatorId},preload_pushed_by_name=#{operatorName},preload_pushed_at=CURRENT_TIMESTAMP(3),
updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{planId} AND plan_status='EDITING' AND push_idempotency_key IS NULL AND version=#{expectedVersion}
""")
int pushPreload(@Param("planId") long planId,@Param("key") String key,@Param("expectedVersion") int expectedVersion,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Update(""" @Update("""
UPDATE fl_loading_plan SET plan_status='LOADING',updated_by=#{operatorId},updated_by_name=#{operatorName}, UPDATE fl_loading_plan SET plan_status='LOADING',updated_by=#{operatorId},updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3),version=version+1 updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{planId} AND plan_status='EDITING' AND preload_pushed_at IS NOT NULL WHERE id=#{planId} AND plan_status='EDITING'
""") """)
int startPicking(@Param("planId") long planId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName); int startPicking(@Param("planId") long planId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
} }
@@ -148,7 +148,6 @@ public class LoadingListRepository {
} }
private PreloadReadiness readiness(AggregateRow row) { private PreloadReadiness readiness(AggregateRow row) {
boolean pushed = row.getPreloadPushedAt() != null;
boolean containerReady = org.springframework.util.StringUtils.hasText(row.getContainerNo()); boolean containerReady = org.springframework.util.StringUtils.hasText(row.getContainerNo());
int linkCount = Math.toIntExact(value(row.getPreloadLinkCount())); int linkCount = Math.toIntExact(value(row.getPreloadLinkCount()));
int confirmedCount = Math.toIntExact(value(row.getConfirmedPreloadCount())); int confirmedCount = Math.toIntExact(value(row.getConfirmedPreloadCount()));
@@ -157,19 +156,18 @@ public class LoadingListRepository {
boolean editing = "EDITING".equals(row.getPlanStatus()); boolean editing = "EDITING".equals(row.getPlanStatus());
boolean taskCreated = row.getPdaTaskId() != null; boolean taskCreated = row.getPdaTaskId() != null;
java.util.ArrayList<String> blockers = new java.util.ArrayList<>(); java.util.ArrayList<String> blockers = new java.util.ArrayList<>();
if (!pushed) blockers.add("PRELOAD_NOT_PUSHED");
if (!editing) blockers.add("LOADING_PLAN_NOT_EDITING"); if (!editing) blockers.add("LOADING_PLAN_NOT_EDITING");
if (linkCount == 0) blockers.add("PRELOAD_LINK_MISSING"); if (linkCount == 0) blockers.add("PRELOAD_LINK_MISSING");
if (!oceanLinked) blockers.add("OCEAN_BOOKING_NOT_LINKED"); if (!oceanLinked) blockers.add("OCEAN_BOOKING_NOT_LINKED");
else if (!released) blockers.add("OCEAN_BOOKING_NOT_RELEASED"); else if (!released) blockers.add("OCEAN_BOOKING_NOT_RELEASED");
if (taskCreated) blockers.add("PDA_TASK_ALREADY_CREATED"); if (taskCreated) blockers.add("PDA_TASK_ALREADY_CREATED");
boolean allConfirmed = linkCount > 0 && confirmedCount == linkCount; boolean allConfirmed = linkCount > 0 && confirmedCount == linkCount;
boolean prerequisitesReady = pushed && editing && linkCount > 0 && released; boolean prerequisitesReady = editing && linkCount > 0 && released;
boolean canConfirm = prerequisitesReady && !taskCreated && !allConfirmed; boolean canConfirm = prerequisitesReady && !taskCreated && !allConfirmed;
if (allConfirmed) blockers.add("PRELOAD_ALREADY_CONFIRMED"); if (allConfirmed) blockers.add("PRELOAD_ALREADY_CONFIRMED");
String confirmationStatus = allConfirmed ? "CONFIRMED" String confirmationStatus = allConfirmed ? "CONFIRMED"
: confirmedCount > 0 ? "PARTIALLY_CONFIRMED" : "PENDING"; : confirmedCount > 0 ? "PARTIALLY_CONFIRMED" : "PENDING";
return new PreloadReadiness(pushed, containerReady, linkCount, confirmedCount, return new PreloadReadiness(containerReady, linkCount, confirmedCount,
row.getOceanBookingId(), row.getOceanBookingStatus(), row.getPdaTaskId(), row.getOceanBookingId(), row.getOceanBookingStatus(), row.getPdaTaskId(),
row.getPdaTaskStatus(), confirmationStatus, canConfirm, row.getPdaTaskStatus(), confirmationStatus, canConfirm,
prerequisitesReady && allConfirmed, blockers); prerequisitesReady && allConfirmed, blockers);
@@ -99,9 +99,6 @@ public class SmartLoadingRepository {
return items.updateWarehouseRemark(planId, itemId, touchedPlanVersion, expectedItemVersion, return items.updateWarehouseRemark(planId, itemId, touchedPlanVersion, expectedItemVersion,
remark, operatorId, operatorName); remark, operatorId, operatorName);
} }
public int push(long planId, String key, int version, long operatorId, String operatorName) {
return plans.pushPreload(planId, key, version, operatorId, operatorName);
}
public int startPicking(long planId, long operatorId, String operatorName) { public int startPicking(long planId, long operatorId, String operatorName) {
return plans.startPicking(planId, operatorId, operatorName); return plans.startPicking(planId, operatorId, operatorName);
} }
@@ -38,6 +38,7 @@ public class ContainerService {
@Resource private OceanBookingRepository oceanBookings; @Resource private OceanBookingRepository oceanBookings;
@Resource private WarehouseSupplierSnapshotService suppliers; @Resource private WarehouseSupplierSnapshotService suppliers;
@Resource private FirstlegDocumentNumberGenerator documentNumbers; @Resource private FirstlegDocumentNumberGenerator documentNumbers;
@Resource private SmartLoadingService smartLoadingService;
@Value("${baiyue.security.internal-service-token:}") private String internalToken; @Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId; @Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
public PageResult<ContainerBookingEntity> page(Query query) { public PageResult<ContainerBookingEntity> page(Query query) {
@@ -63,7 +64,9 @@ public class ContainerService {
value.setContainerType(specification.containerType()); value.setCapacityCbm(specification.capacityCbm()); value.setContainerType(specification.containerType()); value.setCapacityCbm(specification.capacityCbm());
value.setMaxWeightKg(specification.maxWeightKg()); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setMaxWeightKg(specification.maxWeightKg()); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); repository.insert(value); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); repository.insert(value);
value.setPlanStatus(SmartLoadingStatus.WAIT_LOADING.name()); return value; var plan = smartLoadingService.createForContainer(value.getId(), operatorId, operatorName);
value.setPlanId(plan.id()); value.setPlanNo(plan.planNo()); value.setPlanStatus(plan.status());
return value;
} }
@Transactional @Transactional
@@ -593,9 +593,8 @@ public class LoadingListService {
throw conflict("仅装柜完成前允许首次补录真实集装箱号"); throw conflict("仅装柜完成前允许首次补录真实集装箱号");
} }
if (!initialContainerFill && !"EDITING".equals(row.getPlanStatus())) { if (!initialContainerFill && !"EDITING".equals(row.getPlanStatus())) {
throw conflict("仅已推送且PDA尚未开始的预装计划可纠错真实集装箱号"); throw conflict("仅PDA尚未开始的编辑中预装计划可纠错真实集装箱号");
} }
if (row.getPreloadPushedAt() == null) throw conflict("仅已推送的预装清单可维护运输字段");
if (!initialContainerFill && repository.hasPdaWorkStarted(row.getPlanId())) { if (!initialContainerFill && repository.hasPdaWorkStarted(row.getPlanId())) {
throw conflict("PDA任务已领取、已开始作业或已有物理作业事实,不能修改真实集装箱号"); throw conflict("PDA任务已领取、已开始作业或已有物理作业事实,不能修改真实集装箱号");
} }
@@ -117,7 +117,7 @@ public class LoadingPickService {
var booking = smartLoadingRepository.booking(containerBookingId); var booking = smartLoadingRepository.booking(containerBookingId);
if (booking == null) return; if (booking == null) return;
var plan = smartLoadingRepository.planByBooking(containerBookingId); var plan = smartLoadingRepository.planByBooking(containerBookingId);
if (plan == null || plan.getPreloadPushedAt() == null || !"EDITING".equals(plan.getPlanStatus())) return; if (plan == null || !"EDITING".equals(plan.getPlanStatus())) return;
var links = loadingModelBridge.byLoadingPlan(plan.getId()); var links = loadingModelBridge.byLoadingPlan(plan.getId());
if (links.isEmpty() || links.stream().anyMatch(link -> !"CONFIRMED".equals(link.preloadStatus()))) return; if (links.isEmpty() || links.stream().anyMatch(link -> !"CONFIRMED".equals(link.preloadStatus()))) return;
var allocation = oceanBookingRepository.allocationByContainer(containerBookingId); var allocation = oceanBookingRepository.allocationByContainer(containerBookingId);
@@ -34,7 +34,7 @@ import java.sql.SQLException;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.*; import java.util.*;
/** 智能装柜的候选、推荐/人工分配、删除和不可逆推送编排。 */ /** 智能装柜的自动计划、候选、推荐、人工分配和删除编排。 */
@Service @Service
public class SmartLoadingService { public class SmartLoadingService {
private static final BigDecimal CAPACITY_LIMIT_RATIO = new BigDecimal("1.10"); private static final BigDecimal CAPACITY_LIMIT_RATIO = new BigDecimal("1.10");
@@ -129,15 +129,13 @@ public class SmartLoadingService {
} }
@Transactional @Transactional
@BusinessAudit(event = "SMART_LOADING_PLAN_CREATE", resourceType = "LOADING_PLAN", public Plan createForContainer(long bookingId, long operatorId, String operatorName) {
description = "创建智能装柜计划", captureBefore = false)
public Plan create(long bookingId, String remark, long operatorId, String operatorName) {
ContainerBookingEntity booking = requireBooking(bookingId); ContainerBookingEntity booking = requireBooking(bookingId);
LoadingPlanEntity existing = repository.planByBooking(bookingId); LoadingPlanEntity existing = repository.planByBooking(bookingId);
if (existing != null) return draftView(existing, booking); if (existing != null) return draftView(existing, booking);
LoadingPlanEntity plan = new LoadingPlanEntity(); LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setPlanNo(documentNumbers.next("LP")); plan.setPlanNo(documentNumbers.next("LP"));
plan.setContainerBookingId(bookingId); plan.setPlanStatus("EDITING"); plan.setRemark(trim(remark)); plan.setContainerBookingId(bookingId); plan.setPlanStatus("EDITING");
plan.setCreatedBy(operatorId); plan.setCreatedByName(operatorName); plan.setUpdatedBy(operatorId); plan.setCreatedBy(operatorId); plan.setCreatedByName(operatorName); plan.setUpdatedBy(operatorId);
plan.setUpdatedByName(operatorName); plan.setVersion(0); plan.setUpdatedByName(operatorName); plan.setVersion(0);
try { try {
@@ -542,69 +540,6 @@ public class SmartLoadingService {
return view(repository.plan(planId), requireBooking(plan.getContainerBookingId())); return view(repository.plan(planId), requireBooking(plan.getContainerBookingId()));
} }
@Transactional
@BusinessAudit(event = "SMART_LOADING_PUSH_PRELOAD", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "推送智能装柜计划到预装清单")
public Plan pushPreload(long planId, String idempotencyKey, PushRequest request, long operatorId, String operatorName) {
String key = trim(idempotencyKey);
if (!StringUtils.hasText(key) || key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "必须提供有效 Idempotency-Key");
String requestHash = sha256("PUSH_PRELOAD|" + planId + '|' + request.expectedVersion());
LoadingPlanEntity plan = repository.plan(planId); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在");
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
if (key.equals(plan.getPushIdempotencyKey())) {
requirePushReplay(planId, key, requestHash);
return view(plan, booking);
}
if (StringUtils.hasText(plan.getPushIdempotencyKey())) throw conflict("预装清单已推送,不能使用新的幂等键重复推送");
if (repository.operation(key) != null) throw conflict("Idempotency-Key已用于不同推送请求");
if (!"EDITING".equals(plan.getPlanStatus()) || !Objects.equals(plan.getVersion(), request.expectedVersion())) throw conflict("仅编辑中的计划可开始装柜且版本必须匹配");
List<LoadingPlanItemEntity> items = repository.items(planId);
List<GroupSnapshot> groups = requireCompleteColoadAllocations(planId, items);
ensurePendingPreloads(items, operatorId, operatorName);
checkPlanCapacity(booking, items);
try {
if (repository.push(planId, key, request.expectedVersion(), operatorId, operatorName) != 1) throw conflict("装柜计划已被并发修改");
} catch (DuplicateKeyException e) { throw conflict("幂等键已用于其他推送"); }
try {
history(planId, null, "PUSH_PRELOAD", "EDITING", "EDITING", key,
json(values("version", request.expectedVersion())),
json(values("requestHash", requestHash)), operatorId, operatorName);
} catch (DuplicateKeyException exception) {
throw conflict("Idempotency-Key已用于不同推送请求");
}
LoadingPlanEntity pushed = repository.plan(planId);
loadingModelBridge.bindAllocatedBeforeLoading(planId, operatorId, operatorName, key);
for (GroupSnapshot group : groups) {
// The physical group remains LOCKED through PDA work; it is consumed only after the
// full group has been picked and the cabinet completion worker reaches its final stage.
requireSameGroup(group, requiredGroup(group.members().get(0).tenantId(),
group.members().get(0).inboundId()), planId);
}
return view(pushed, requireBooking(pushed.getContainerBookingId()));
}
private void requirePushReplay(long planId, String key, String requestHash) {
LoadingPlanHistoryEntity history = repository.operation(key);
if (history == null
|| !Objects.equals(history.getLoadingPlanId(), planId)
|| !Objects.equals(history.getActionType(), "PUSH_PRELOAD")
|| history.getAfterSnapshot() == null
|| !sameRequestHash(history.getAfterSnapshot(), requestHash)) {
throw conflict("Idempotency-Key已用于不同推送请求");
}
}
private void ensurePendingPreloads(List<LoadingPlanItemEntity> items, long operatorId, String operatorName) {
if (items.isEmpty()) throw validation("装柜明细不能为空");
if (items.stream().anyMatch(item -> item.getAccountId() == null || item.getInboundId() == null)) {
throw conflict("装柜明细缺少客户或入库单稳定归属,不能推送预装计划");
}
items.stream().collect(java.util.stream.Collectors.toMap(
item -> item.getAccountId() + "|" + item.getInboundId(), item -> item, (left, right) -> left))
.values().forEach(item -> splitPreloadService.ensurePendingForLoadingAllocation(
item.getAccountId(), item.getInboundId(), operatorId, operatorName));
}
private List<Candidate> enrichColoadCandidates(ContainerBookingEntity booking, LoadingPlanEntity plan, private List<Candidate> enrichColoadCandidates(ContainerBookingEntity booking, LoadingPlanEntity plan,
List<Candidate> values) { List<Candidate> values) {
Map<String, GroupSnapshot> snapshots = new HashMap<>(); Map<String, GroupSnapshot> snapshots = new HashMap<>();
@@ -223,10 +223,6 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan (
plan_no VARCHAR(40) NOT NULL COMMENT '平台唯一装柜计划号', plan_no VARCHAR(40) NOT NULL COMMENT '平台唯一装柜计划号',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
plan_status VARCHAR(24) NOT NULL DEFAULT 'EDITING' COMMENT '装柜计划状态:EDITING编辑中、LOADING装柜中、COMPLETED装柜完成;建议须经人工确认', plan_status VARCHAR(24) NOT NULL DEFAULT 'EDITING' COMMENT '装柜计划状态:EDITING编辑中、LOADING装柜中、COMPLETED装柜完成;建议须经人工确认',
push_idempotency_key VARCHAR(128) NULL COMMENT '推送预装幂等键',
preload_pushed_by BIGINT UNSIGNED NULL COMMENT '推送预装操作人ID',
preload_pushed_by_name VARCHAR(64) NULL COMMENT '推送预装操作人名称快照',
preload_pushed_at DATETIME(3) NULL COMMENT '开始装柜并进入LOADING的时间',
preload_completed_by BIGINT UNSIGNED NULL COMMENT '预装完成操作人ID', preload_completed_by BIGINT UNSIGNED NULL COMMENT '预装完成操作人ID',
preload_completed_by_name VARCHAR(64) NULL COMMENT '预装完成操作人名称快照', preload_completed_by_name VARCHAR(64) NULL COMMENT '预装完成操作人名称快照',
preload_completed_at DATETIME(3) NULL COMMENT '预装完成时间', preload_completed_at DATETIME(3) NULL COMMENT '预装完成时间',
@@ -244,7 +240,6 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan (
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_loading_plan_no (plan_no), UNIQUE KEY uk_loading_plan_no (plan_no),
UNIQUE KEY uk_loading_plan_booking (container_booking_id), UNIQUE KEY uk_loading_plan_booking (container_booking_id),
UNIQUE KEY uk_loading_plan_push_key (push_idempotency_key),
KEY idx_loading_plan_status (plan_status, updated_at, id), KEY idx_loading_plan_status (plan_status, updated_at, id),
CONSTRAINT fk_loading_plan_booking FOREIGN KEY (container_booking_id) CONSTRAINT fk_loading_plan_booking FOREIGN KEY (container_booking_id)
REFERENCES fl_container_booking (id), REFERENCES fl_container_booking (id),
@@ -833,10 +828,6 @@ BEGIN
IF OLD.plan_status = 'LOADING' AND ( IF OLD.plan_status = 'LOADING' AND (
NOT (OLD.id <=> NEW.id) OR NOT (OLD.plan_no <=> NEW.plan_no) NOT (OLD.id <=> NEW.id) OR NOT (OLD.plan_no <=> NEW.plan_no)
OR NOT (OLD.container_booking_id <=> NEW.container_booking_id) OR NOT (OLD.container_booking_id <=> NEW.container_booking_id)
OR NOT (OLD.push_idempotency_key <=> NEW.push_idempotency_key)
OR NOT (OLD.preload_pushed_by <=> NEW.preload_pushed_by)
OR NOT (OLD.preload_pushed_by_name <=> NEW.preload_pushed_by_name)
OR NOT (OLD.preload_pushed_at <=> NEW.preload_pushed_at)
OR NOT (OLD.remark <=> NEW.remark) OR NOT (OLD.remark <=> NEW.remark)
OR NOT (OLD.created_by <=> NEW.created_by) OR NOT (OLD.created_by <=> NEW.created_by)
OR NOT (OLD.created_by_name <=> NEW.created_by_name) OR NOT (OLD.created_by_name <=> NEW.created_by_name)
@@ -856,12 +847,7 @@ BEGIN
) THEN ) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '同状态乐观锁触碰不得修改阶段审计字段'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '同状态乐观锁触碰不得修改阶段审计字段';
END IF; END IF;
IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN
IF NEW.push_idempotency_key IS NULL OR CHAR_LENGTH(TRIM(NEW.push_idempotency_key)) = 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '开始装柜必须提供幂等键';
END IF;
SET NEW.preload_pushed_at = COALESCE(NEW.preload_pushed_at, CURRENT_TIMESTAMP(3));
ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN
IF EXISTS (SELECT 1 FROM fl_loading_plan_item item IF EXISTS (SELECT 1 FROM fl_loading_plan_item item
WHERE item.loading_plan_id = OLD.id AND item.deleted = 0 WHERE item.loading_plan_id = OLD.id AND item.deleted = 0
AND NOT EXISTS ( AND NOT EXISTS (
@@ -1324,6 +1310,13 @@ DELIMITER ;
CALL relocate_legacy_inbound_loading_permissions(); CALL relocate_legacy_inbound_loading_permissions();
DROP PROCEDURE relocate_legacy_inbound_loading_permissions; DROP PROCEDURE relocate_legacy_inbound_loading_permissions;
DELETE role_permission FROM iam_role_permission role_permission
JOIN iam_permission permission ON permission.id = role_permission.permission_id
WHERE permission.tenant_id = 0 AND permission.permission_code IN (
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:push');
DELETE FROM iam_permission WHERE tenant_id = 0 AND permission_code IN (
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:push');
INSERT INTO iam_permission INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, (id, tenant_id, app_code, permission_type, parent_id, permission_code,
permission_name, route_path, http_method, sort_no, status, built_in, deleted) permission_name, route_path, http_method, sort_no, status, built_in, deleted)
@@ -1338,14 +1331,10 @@ VALUES
'编辑货柜', NULL, 'PUT', 30, 'ACTIVE', 1, 0), '编辑货柜', NULL, 'PUT', 30, 'ACTIVE', 1, 0),
(5102094, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:smart-loading:list', (5102094, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:smart-loading:list',
'智能装柜', '/first-leg/smart-loading', 'GET', 50, 'ACTIVE', 1, 0), '智能装柜', '/first-leg/smart-loading', 'GET', 50, 'ACTIVE', 1, 0),
(5102096, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:create',
'创建智能装柜计划', NULL, 'POST', 20, 'ACTIVE', 1, 0),
(5102097, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:assign', (5102097, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:assign',
'分配装柜明细', NULL, 'POST', 30, 'ACTIVE', 1, 0), '分配装柜明细', NULL, 'POST', 30, 'ACTIVE', 1, 0),
(5102098, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:delete', (5102098, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:delete',
'删除装柜明细', NULL, 'DELETE', 40, 'ACTIVE', 1, 0), '删除装柜明细', NULL, 'DELETE', 40, 'ACTIVE', 1, 0),
(5102099, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:push',
'推送预装', NULL, 'POST', 50, 'ACTIVE', 1, 0),
(5102100, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:preload:list', (5102100, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:preload:list',
'预装清单', '/first-leg/preload-list', 'GET', 60, 'ACTIVE', 1, 0), '预装清单', '/first-leg/preload-list', 'GET', 60, 'ACTIVE', 1, 0),
(5102102, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:view', (5102102, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:view',
@@ -1375,8 +1364,7 @@ FROM iam_role role
'platform:firstleg:container:list', 'platform:firstleg:container:view', 'platform:firstleg:container:list', 'platform:firstleg:container:view',
'platform:firstleg:container:create', 'platform:firstleg:container:create',
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list', 'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push',
'platform:firstleg:preload:list', 'platform:firstleg:preload:view', 'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete', 'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list', 'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
@@ -1506,12 +1494,11 @@ BEGIN
AND permission_code IN ( AND permission_code IN (
'platform:firstleg:container:list', 'platform:firstleg:container:view', 'platform:firstleg:container:list', 'platform:firstleg:container:view',
'platform:firstleg:container:create', 'platform:firstleg:container:edit', 'platform:firstleg:container:create', 'platform:firstleg:container:edit',
'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:assign',
'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:delete', 'platform:firstleg:preload:list',
'platform:firstleg:smart-loading:push', 'platform:firstleg:preload:list',
'platform:firstleg:preload:view', 'platform:firstleg:preload:edit', 'platform:firstleg:preload:view', 'platform:firstleg:preload:edit',
'platform:firstleg:preload:complete', 'platform:firstleg:preload:confirm', 'platform:firstleg:preload:complete', 'platform:firstleg:preload:confirm',
'platform:firstleg:loading-list:list', 'platform:firstleg:loading-list:view')) <> 16 THEN 'platform:firstleg:loading-list:list', 'platform:firstleg:loading-list:view')) <> 14 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台权限资源未完整创建'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台权限资源未完整创建';
END IF; END IF;
IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code)) IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code))
@@ -1524,12 +1511,11 @@ BEGIN
'platform:firstleg:container:list', 'platform:firstleg:container:view', 'platform:firstleg:container:list', 'platform:firstleg:container:view',
'platform:firstleg:container:create', 'platform:firstleg:container:create',
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list', 'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push',
'platform:firstleg:preload:list', 'platform:firstleg:preload:view', 'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete', 'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list', 'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
'platform:firstleg:loading-list:view')) <> 32 THEN 'platform:firstleg:loading-list:view')) <> 28 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台角色授权未完整写入'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台角色授权未完整写入';
END IF; END IF;
END$$ END$$
@@ -523,26 +523,6 @@ paths:
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingWarehouseRemarkRequest'}}} content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingWarehouseRemarkRequest'}}}
responses: responses:
'200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan} '200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan}
/api/platform/v1/first-leg/smart-loading/plans/{planId}/push-preload:
post:
summary: 推送智能装柜计划到预装清单
description: >-
需要 platform:firstleg:smart-loading:push 权限。push-preload 不要求真实柜号,
只冻结 EDITING 柜次计划的明细并建立稳定预装关联,不创建 PDA 作业任务;计划必须处于 EDITING 且版本匹配。
预装页面随后补录真实柜号;只有预装 CONFIRMED、真实柜号存在、已关联 RELEASED 订舱时才创建待领取 PDA 任务,领取后才进入 LOADING。
智能装柜首次分配会创建入库单级 PENDING 预装聚合;push 会在同一事务内对全部实际分配再次确保该聚合存在且仍为 PENDING,
但不会覆盖 CONFIRMING/CONFIRMED 记录,也不会在 LOADING 后补建。
推送事务会先为所有已分配入库单建立 PENDING 预装计划与 EDITING 柜次计划的正式关联,
任一关联缺失、版本冲突或分配数量为零都会整体失败并禁止 PDA/LOADING;不得在 LOADING 后补关联。
parameters:
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {$ref: '#/components/parameters/IdempotencyKey'}
requestBody:
required: true
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingPushRequest'}}}
responses:
'200': {description: 推送成功或业务失败,按响应体 code 判断}
'403': {description: 无智能装柜推送权限}
/openapi/v1/inbound-measurements: /openapi/v1/inbound-measurements:
post: post:
summary: 提交一条第三方箱规测量结果和一张图片 summary: 提交一条第三方箱规测量结果和一张图片
@@ -1651,11 +1631,6 @@ components:
properties: properties:
version: {type: integer, minimum: 0} version: {type: integer, minimum: 0}
data: {$ref: '#/components/schemas/ContainerSaveRequest'} data: {$ref: '#/components/schemas/ContainerSaveRequest'}
SmartLoadingPushRequest:
type: object
required: [expectedVersion]
properties:
expectedVersion: {type: integer, minimum: 0}
SmartLoadingAssignRequest: SmartLoadingAssignRequest:
type: object type: object
required: [inboundId, inboundDetailId, packageCount, expectedVersion, operationId] required: [inboundId, inboundDetailId, packageCount, expectedVersion, operationId]
@@ -49,7 +49,7 @@ paths:
patch: patch:
summary: 修改预装运输信息 summary: 修改预装运输信息
description: >- description: >-
已推送后可在 EDITING 或 LOADING 阶段首次补录真实集装箱号;已有柜号的纠正仅限仍为 EDITING、PDA任务尚未领取且无任何物理作业事实时,并必须提供原因和当前版本; 可在 EDITING 或 LOADING 阶段首次补录真实集装箱号;已有柜号的纠正仅限仍为 EDITING、PDA任务尚未领取且无任何物理作业事实时,并必须提供原因和当前版本;
containerNo 为 null 表示保持当前值,非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位,空白字符串非法且禁止清空。 containerNo 为 null 表示保持当前值,非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位,空白字符串非法且禁止清空。
x-permission: platform:firstleg:preload:edit x-permission: platform:firstleg:preload:edit
parameters: parameters:
@@ -301,9 +301,8 @@ components:
readiness: {$ref: '#/components/schemas/PreloadReadiness'} readiness: {$ref: '#/components/schemas/PreloadReadiness'}
PreloadReadiness: PreloadReadiness:
type: object type: object
required: [preloadPushed, containerNoReady, preloadLinkCount, confirmedPreloadCount, confirmationStatus, canConfirm, readyForPda, blockingReasons] required: [containerNoReady, preloadLinkCount, confirmedPreloadCount, confirmationStatus, canConfirm, readyForPda, blockingReasons]
properties: properties:
preloadPushed: {type: boolean, description: 是否已完成 push-preload}
containerNoReady: {type: boolean, description: 是否已录入真实集装箱号 containerNo} containerNoReady: {type: boolean, description: 是否已录入真实集装箱号 containerNo}
preloadLinkCount: {type: integer, format: int32, minimum: 0, description: 计划关联的入库预装票据数} preloadLinkCount: {type: integer, format: int32, minimum: 0, description: 计划关联的入库预装票据数}
confirmedPreloadCount: {type: integer, format: int32, minimum: 0, description: 已确认预装票据数} confirmedPreloadCount: {type: integer, format: int32, minimum: 0, description: 已确认预装票据数}
@@ -317,7 +316,7 @@ components:
blockingReasons: blockingReasons:
type: array type: array
description: 阻止计划确认的稳定原因码 description: 阻止计划确认的稳定原因码
items: {type: string, enum: [PRELOAD_NOT_PUSHED, LOADING_PLAN_NOT_EDITING, PRELOAD_LINK_MISSING, OCEAN_BOOKING_NOT_LINKED, OCEAN_BOOKING_NOT_RELEASED, PDA_TASK_ALREADY_CREATED, PRELOAD_ALREADY_CONFIRMED]} items: {type: string, enum: [LOADING_PLAN_NOT_EDITING, PRELOAD_LINK_MISSING, OCEAN_BOOKING_NOT_LINKED, OCEAN_BOOKING_NOT_RELEASED, PDA_TASK_ALREADY_CREATED, PRELOAD_ALREADY_CONFIRMED]}
ConfirmPreloadRequest: ConfirmPreloadRequest:
type: object type: object
required: [expectedVersion, operationReason] required: [expectedVersion, operationReason]
@@ -2,7 +2,7 @@ openapi: 3.0.3
info: info:
title: Phase 3 Preload and Container Loading Bridge API title: Phase 3 Preload and Container Loading Bridge API
version: 1.0.0 version: 1.0.0
description: Links must be created while preload=PENDING and loadingPlan=EDITING. Smart-loading push creates all allocated links atomically before any PDA task creation; push-preload never requires containerNo and never advances LOADING. LOADING plans cannot be repaired through post-hoc binding. description: The container loading plan is created atomically with the container. Adding an inbound allocation creates its preload aggregate and link in the same transaction while loadingPlan=EDITING or LOADING; plan confirmation remains the gate before PDA task creation.
paths: paths:
/api/platform/v1/inbounds/{inboundId}/preload/loading-plans: /api/platform/v1/inbounds/{inboundId}/preload/loading-plans:
get: get:
@@ -32,17 +32,11 @@ class PlatformSmartLoadingControllerContractTest {
authority("adjust","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,long.class,AdjustRequest.class); authority("adjust","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,long.class,AdjustRequest.class);
authority("warehouseRemark","platform:firstleg:preload:edit",AuthenticatedUser.class,long.class,long.class,WarehouseRemarkRequest.class); authority("warehouseRemark","platform:firstleg:preload:edit",AuthenticatedUser.class,long.class,long.class,WarehouseRemarkRequest.class);
authority("delete","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,long.class,DeleteRequest.class); authority("delete","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,long.class,DeleteRequest.class);
authority("push","platform:firstleg:smart-loading:push",AuthenticatedUser.class,long.class,String.class,PushRequest.class);
} }
@Test void openApiPushDoesNotRequireContainerNumberOrCreatePda() throws Exception { @Test void openApiHasNoManualCreateOrPushStep() throws Exception {
String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml")); String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml"));
assertThat(yaml).contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/push-preload:") assertThat(yaml).doesNotContain("/push-preload:")
.contains("不要求真实柜号") .doesNotContain("SmartLoadingPushRequest:");
.contains("不创建 PDA 作业任务")
.contains("已关联 RELEASED 订舱时才创建待领取 PDA 任务")
.contains("领取后才进入 LOADING")
.doesNotContain("已关联 CONFIRMED/RELEASED 订舱")
.doesNotContain("真实柜号必须已录入");
} }
@Test void openApiFreezesIncrementalMutationAndCapabilityContract() throws Exception { @Test void openApiFreezesIncrementalMutationAndCapabilityContract() throws Exception {
String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml")); String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml"));
@@ -169,7 +169,7 @@ class LoadingModelBridgeServiceTest {
@Test @Test
void confirmationGateRejectsIncompleteAllocatedLinks() { void confirmationGateRejectsIncompleteAllocatedLinks() {
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot( when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(
40L, 50L, "C1", "EDITING", 3, LocalDateTime.now())); 40L, 50L, "C1", "EDITING", 3));
when(repository.allocatedScopes(40)).thenReturn(List.of( when(repository.allocatedScopes(40)).thenReturn(List.of(
new AllocationScope(10L, 20L, 8), new AllocationScope(11L, 21L, 4))); new AllocationScope(10L, 20L, 8), new AllocationScope(11L, 21L, 4)));
when(repository.byLoadingPlan(40)).thenReturn(List.of( when(repository.byLoadingPlan(40)).thenReturn(List.of(
@@ -193,7 +193,7 @@ class LoadingModelBridgeServiceTest {
@Test @Test
void bindsConfirmingPreloadToRunningPlanAndConfirmsIt() { void bindsConfirmingPreloadToRunningPlanAndConfirmsIt() {
when(repository.byPair(10, 20, 40)).thenReturn(null); when(repository.byPair(10, 20, 40)).thenReturn(null);
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 3, LocalDateTime.now())); when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 3));
when(repository.allocatedPackages(10, 20, 40)).thenReturn(2); when(repository.allocatedPackages(10, 20, 40)).thenReturn(2);
when(repository.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "CONFIRMING", 4)); when(repository.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "CONFIRMING", 4));
when(repository.confirmIncrementalPreload(30L, 10L, 20L, 4, 7L, "admin")).thenReturn(1); when(repository.confirmIncrementalPreload(30L, 10L, 20L, 4, 7L, "admin")).thenReturn(1);
@@ -34,9 +34,9 @@ class LoadingListQueryMapperContractTest {
.contains("FROM fl_loading_plan p") .contains("FROM fl_loading_plan p")
.contains("JOIN fl_container_booking b") .contains("JOIN fl_container_booking b")
.contains("LEFT JOIN fl_loading_plan_item i") .contains("LEFT JOIN fl_loading_plan_item i")
.contains("p.preload_pushed_at IS NOT NULL") .doesNotContain("preload_pushed_at")
.contains("p.plan_status IN ('EDITING', 'LOADING')") .contains("p.plan_status IN ('EDITING', 'LOADING')")
.contains("active_item.deleted = 0") .doesNotContain("active_item.deleted = 0")
.contains("fl_preload_loading_plan_link") .contains("fl_preload_loading_plan_link")
.contains("fl_ocean_booking_container_allocation") .contains("fl_ocean_booking_container_allocation")
.contains("fl_loading_pick_task") .contains("fl_loading_pick_task")
@@ -88,14 +88,14 @@ class LoadingListQueryMapperContractTest {
assertThat(touch) assertThat(touch)
.contains("plan_status = 'EDITING'") .contains("plan_status = 'EDITING'")
.contains("preload_pushed_at IS NOT NULL") .doesNotContain("preload_pushed_at")
.contains("fl_loading_pick_assignment", "item.item_status<>'PENDING'") .contains("fl_loading_pick_assignment", "item.item_status<>'PENDING'")
.contains("version = #{expectedVersion}") .contains("version = #{expectedVersion}")
.contains("version = version + 1"); .contains("version = version + 1");
assertThat(transport) assertThat(transport)
.contains("SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}") .contains("SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}")
.contains("p.plan_status='EDITING'") .contains("p.plan_status='EDITING'")
.contains("preload_pushed_at IS NOT NULL") .doesNotContain("preload_pushed_at")
.doesNotContain("#{containerNo} IS NOT NULL") .doesNotContain("#{containerNo} IS NOT NULL")
.contains("fl_loading_pick_task", "fl_loading_pick_assignment") .contains("fl_loading_pick_task", "fl_loading_pick_assignment")
.doesNotContain("DELETE") .doesNotContain("DELETE")
@@ -122,7 +122,7 @@ class LoadingListQueryMapperContractTest {
.contains("version = version + 1"); .contains("version = version + 1");
assertThat(confirmPlan) assertThat(confirmPlan)
.contains("plan_status = 'EDITING'") .contains("plan_status = 'EDITING'")
.contains("preload_pushed_at IS NOT NULL") .doesNotContain("preload_pushed_at")
.contains("version = #{expectedVersion}") .contains("version = #{expectedVersion}")
.contains("version = version + 1"); .contains("version = version + 1");
} }
@@ -20,9 +20,9 @@ class SmartLoadingSqlContractTest {
private static final Set<String> PHASE3_PERMISSIONS = Set.of( private static final Set<String> PHASE3_PERMISSIONS = Set.of(
"platform:firstleg:container:list", "platform:firstleg:container:view", "platform:firstleg:container:list", "platform:firstleg:container:view",
"platform:firstleg:container:create", "platform:firstleg:container:edit", "platform:firstleg:container:create", "platform:firstleg:container:edit",
"platform:firstleg:smart-loading:list", "platform:firstleg:smart-loading:create", "platform:firstleg:smart-loading:list",
"platform:firstleg:smart-loading:assign", "platform:firstleg:smart-loading:delete", "platform:firstleg:smart-loading:assign", "platform:firstleg:smart-loading:delete",
"platform:firstleg:smart-loading:push", "platform:firstleg:preload:list", "platform:firstleg:preload:list",
"platform:firstleg:preload:view", "platform:firstleg:preload:edit", "platform:firstleg:preload:view", "platform:firstleg:preload:edit",
"platform:firstleg:preload:complete", "platform:firstleg:preload:confirm", "platform:firstleg:preload:complete", "platform:firstleg:preload:confirm",
"platform:firstleg:loading-list:list", "platform:firstleg:loading-list:view"); "platform:firstleg:loading-list:list", "platform:firstleg:loading-list:view");
@@ -60,7 +60,7 @@ class SmartLoadingSqlContractTest {
} }
@Test @Test
@DisplayName("活动明细唯一锁、乐观锁和双层幂等字段完整") @DisplayName("活动明细唯一锁、乐观锁和操作幂等字段完整")
void shouldProvideConcurrencyAndIdempotencyBoundaries() throws Exception { void shouldProvideConcurrencyAndIdempotencyBoundaries() throws Exception {
assertContainerLifecycleSchema(migration()); assertContainerLifecycleSchema(migration());
assertContainerLifecycleSchema(unifiedMigration()); assertContainerLifecycleSchema(unifiedMigration());
@@ -71,7 +71,6 @@ class SmartLoadingSqlContractTest {
.contains("KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id)") .contains("KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id)")
.contains("UNIQUE KEY uk_container_booking_no (booking_no)") .contains("UNIQUE KEY uk_container_booking_no (booking_no)")
.contains("UNIQUE KEY uk_container_booking_cabinet (cabinet_no)") .contains("UNIQUE KEY uk_container_booking_cabinet (cabinet_no)")
.contains("UNIQUE KEY uk_loading_plan_push_key (push_idempotency_key)")
.contains("UNIQUE KEY uk_loading_history_operation (operation_id)") .contains("UNIQUE KEY uk_loading_history_operation (operation_id)")
.contains("version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'"); .contains("version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'");
} }
@@ -156,7 +155,7 @@ class SmartLoadingSqlContractTest {
void shouldProvidePlatformPermissions() throws Exception { void shouldProvidePlatformPermissions() throws Exception {
assertThat(migration()) assertThat(migration())
.contains("platform:firstleg:container:view") .contains("platform:firstleg:container:view")
.contains("platform:firstleg:smart-loading:push") .doesNotContain("'platform:firstleg:smart-loading:push', '推送预装'")
.contains("platform:firstleg:preload:confirm") .contains("platform:firstleg:preload:confirm")
.contains("platform:firstleg:loading-list:view") .contains("platform:firstleg:loading-list:view")
.contains("role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')") .contains("role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')")
@@ -165,7 +164,7 @@ class SmartLoadingSqlContractTest {
} }
@Test @Test
@DisplayName("模块脚本和统一脚本均校验16项权限与两个角色32项授权") @DisplayName("模块脚本和统一脚本均校验14项权限与两个角色28项授权")
void shouldAssertCompletePermissionGrants() throws Exception { void shouldAssertCompletePermissionGrants() throws Exception {
assertPermissionContract(migration()); assertPermissionContract(migration());
assertPermissionContract(unifiedMigration()); assertPermissionContract(unifiedMigration());
@@ -192,8 +191,6 @@ class SmartLoadingSqlContractTest {
.doesNotContain("旧版本在推送预装时即写入 LOADING") .doesNotContain("旧版本在推送预装时即写入 LOADING")
.doesNotContain("修复旧版本“推送预装即拣货中”的状态污染") .doesNotContain("修复旧版本“推送预装即拣货中”的状态污染")
.contains("SET MESSAGE_TEXT = '装柜状态只允许单向推进'") .contains("SET MESSAGE_TEXT = '装柜状态只允许单向推进'")
.contains("IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN")
.contains("ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN")
.contains("IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN") .contains("IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN")
.contains("SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3))") .contains("SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3))")
.contains("已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数") .contains("已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数")
@@ -345,7 +342,7 @@ class SmartLoadingSqlContractTest {
int assertionStart = sql.lastIndexOf("IF (SELECT COUNT(DISTINCT CONCAT", message); int assertionStart = sql.lastIndexOf("IF (SELECT COUNT(DISTINCT CONCAT", message);
String assertion = sql.substring(assertionStart, message); String assertion = sql.substring(assertionStart, message);
assertThat(permissions(assertion)).isEqualTo(PHASE3_PERMISSIONS); assertThat(permissions(assertion)).isEqualTo(PHASE3_PERMISSIONS);
assertThat(assertion).contains("<> 32 THEN"); assertThat(assertion).contains("<> 28 THEN");
} }
private static Set<String> permissions(String sql) { private static Set<String> permissions(String sql) {
@@ -45,12 +45,17 @@ class ContainerServiceTest {
@MockBean private OceanBookingRepository oceanBookings; @MockBean private OceanBookingRepository oceanBookings;
@MockBean private WarehouseSupplierSnapshotService suppliers; @MockBean private WarehouseSupplierSnapshotService suppliers;
@MockBean private FirstlegDocumentNumberGenerator documentNumbers; @MockBean private FirstlegDocumentNumberGenerator documentNumbers;
@MockBean private SmartLoadingService smartLoadingService;
@Resource private ContainerService service; @Resource private ContainerService service;
@BeforeEach @BeforeEach
void setUpWarehouse() { void setUpWarehouse() {
when(documentNumbers.next("CB")).thenReturn("CB-260818-0001"); when(documentNumbers.next("CB")).thenReturn("CB-260818-0001");
when(documentNumbers.next("CAB")).thenReturn("CAB-260818-0001"); when(documentNumbers.next("CAB")).thenReturn("CAB-260818-0001");
when(smartLoadingService.createForContainer(anyLong(), anyLong(), anyString())).thenAnswer(invocation ->
new com.baiyue.firstleg.dto.SmartLoadingDtos.Plan(501L, "LP-260818-0001", "EDITING",
invocation.getArgument(0, Long.class), null, null, 0, true, true, null,
"NOT_STARTED", null, null, null, List.of()));
when(warehouses.active(anyString(), anyLong(), anyLong())).thenAnswer(invocation -> when(warehouses.active(anyString(), anyLong(), anyLong())).thenAnswer(invocation ->
activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓")); activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓"));
when(suppliers.trucking(202L, "拖车公司A")) when(suppliers.trucking(202L, "拖车公司A"))
@@ -77,7 +82,10 @@ class ContainerServiceTest {
assertThat(saved.getWarehouseId()).isEqualTo(101L); assertThat(saved.getWarehouseId()).isEqualTo(101L);
assertThat(saved.getWarehouseName()).isEqualTo("上海中转仓"); assertThat(saved.getWarehouseName()).isEqualTo("上海中转仓");
assertThat(saved.getCapacityCbm()).isEqualByComparingTo("76.0000"); assertThat(saved.getCapacityCbm()).isEqualByComparingTo("76.0000");
assertThat(saved.getPlanStatus()).isEqualTo("WAIT_LOADING"); assertThat(saved.getPlanId()).isEqualTo(501L);
assertThat(saved.getPlanNo()).isEqualTo("LP-260818-0001");
assertThat(saved.getPlanStatus()).isEqualTo("EDITING");
verify(smartLoadingService).createForContainer(saved.getId(), 9L, "admin");
} }
@Test @Test
@@ -85,11 +85,9 @@ class LoadingListServiceTest {
@Test @Test
void shouldOnlyUpdateTransportFieldsWithOptimisticLockAndHistory() { void shouldOnlyUpdateTransportFieldsWithOptimisticLockAndHistory() {
AggregateRow before = row("EDITING", 3); AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870"); before.setContainerNo("MSCU6639870");
before.setVoyageNo("001E"); before.setVoyageNo("001E");
AggregateRow after = row("EDITING", 4); AggregateRow after = row("EDITING", 4);
after.setPreloadPushedAt(before.getPreloadPushedAt());
after.setContainerNo("CSQU3054383"); after.setContainerNo("CSQU3054383");
after.setVoyageNo("023E"); after.setVoyageNo("023E");
when(repository.history(anyString())).thenReturn(null); when(repository.history(anyString())).thenReturn(null);
@@ -112,7 +110,6 @@ class LoadingListServiceTest {
@Test @Test
void shouldNotWritePlanOrHistoryWhenTransportFieldsAreUnchanged() { void shouldNotWritePlanOrHistoryWhenTransportFieldsAreUnchanged() {
AggregateRow before = row("EDITING", 3); AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870"); before.setContainerNo("MSCU6639870");
before.setVoyageNo("023E"); before.setVoyageNo("023E");
when(repository.aggregate(10L)).thenReturn(before); when(repository.aggregate(10L)).thenReturn(before);
@@ -132,10 +129,8 @@ class LoadingListServiceTest {
@Test @Test
void pushedEditingPreloadCanCorrectExistingContainerNumberBeforePdaClaim() { void pushedEditingPreloadCanCorrectExistingContainerNumberBeforePdaClaim() {
AggregateRow before = row("EDITING", 3); AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870"); before.setContainerNo("MSCU6639870");
AggregateRow after = row("EDITING", 4); AggregateRow after = row("EDITING", 4);
after.setPreloadPushedAt(before.getPreloadPushedAt());
after.setContainerNo("CSQU3054383"); after.setContainerNo("CSQU3054383");
when(repository.aggregate(10L)).thenReturn(before, after); when(repository.aggregate(10L)).thenReturn(before, after);
when(repository.updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator")).thenReturn(1); when(repository.updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator")).thenReturn(1);
@@ -149,18 +144,18 @@ class LoadingListServiceTest {
} }
@Test @Test
void editingPlanCannotReceiveTransportBeforePush() { void editingPlanCanCorrectTransportWithoutPushStep() {
AggregateRow before = row("EDITING", 3); AggregateRow before = row("EDITING", 3);
before.setContainerNo("MSCU6639870"); before.setContainerNo("MSCU6639870");
when(repository.aggregate(10L)).thenReturn(before); AggregateRow after = row("EDITING", 4);
after.setContainerNo("CSQU3054383");
when(repository.aggregate(10L)).thenReturn(before, after);
when(repository.updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator")).thenReturn(1);
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "editing-key", service.updateTransport(10L, 100L, 9L, "operator", "editing-key",
new TransportUpdateRequest("CSQU3054383", null, 3, "纠错柜号"))) new TransportUpdateRequest("CSQU3054383", null, 3, "纠错柜号"));
.isInstanceOf(BizException.class).hasMessageContaining("已推送");
verify(repository, never()).updateTransport(eq(10L), eq(3), anyString(), verify(repository).updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator");
org.mockito.ArgumentMatchers.isNull(), eq(9L), anyString());
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
} }
@Test @Test
@@ -179,7 +174,6 @@ class LoadingListServiceTest {
@Test @Test
void shouldRejectBlankContainerNumberInsteadOfClearingAuthorityField() { void shouldRejectBlankContainerNumberInsteadOfClearingAuthorityField() {
AggregateRow before = row("EDITING", 3); AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870"); before.setContainerNo("MSCU6639870");
when(repository.aggregate(10L)).thenReturn(before); when(repository.aggregate(10L)).thenReturn(before);
@@ -195,7 +189,6 @@ class LoadingListServiceTest {
@Test @Test
void unclaimedPdaTaskStillAllowsCorrectionButClaimOrPhysicalWorkRejectsIt() { void unclaimedPdaTaskStillAllowsCorrectionButClaimOrPhysicalWorkRejectsIt() {
AggregateRow before = row("EDITING", 3); AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870"); before.setContainerNo("MSCU6639870");
when(repository.aggregate(10L)).thenReturn(before); when(repository.aggregate(10L)).thenReturn(before);
when(repository.hasPdaWorkStarted(10L)).thenReturn(true); when(repository.hasPdaWorkStarted(10L)).thenReturn(true);
@@ -210,12 +203,9 @@ class LoadingListServiceTest {
@Test @Test
void loadingPlanRejectsTransportUpdateButPushedEditingPlanAllowsFirstContainerFill() { void loadingPlanRejectsTransportUpdateButPushedEditingPlanAllowsFirstContainerFill() {
AggregateRow loading = row("LOADING", 3); AggregateRow loading = row("LOADING", 3);
loading.setPreloadPushedAt(java.time.LocalDateTime.now());
loading.setContainerNo("MSCU6639870"); loading.setContainerNo("MSCU6639870");
AggregateRow missing = row("EDITING", 3); AggregateRow missing = row("EDITING", 3);
missing.setPreloadPushedAt(java.time.LocalDateTime.now());
AggregateRow afterFill = row("EDITING", 4); AggregateRow afterFill = row("EDITING", 4);
afterFill.setPreloadPushedAt(missing.getPreloadPushedAt());
afterFill.setContainerNo("CSQU3054383"); afterFill.setContainerNo("CSQU3054383");
when(repository.aggregate(10L)).thenReturn(loading, missing, afterFill); when(repository.aggregate(10L)).thenReturn(loading, missing, afterFill);
when(repository.fillInitialContainerNo(10L, 3, "CSQU3054383", null, 9L, "operator")) when(repository.fillInitialContainerNo(10L, 3, "CSQU3054383", null, 9L, "operator"))
@@ -236,9 +226,7 @@ class LoadingListServiceTest {
@Test @Test
void loadingPlanWithClaimedAndPickedPdaTaskStillAllowsInitialContainerFill() { void loadingPlanWithClaimedAndPickedPdaTaskStillAllowsInitialContainerFill() {
AggregateRow before = row("LOADING", 7); AggregateRow before = row("LOADING", 7);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
AggregateRow after = row("LOADING", 8); AggregateRow after = row("LOADING", 8);
after.setPreloadPushedAt(before.getPreloadPushedAt());
after.setContainerNo("CSQU3054383"); after.setContainerNo("CSQU3054383");
when(repository.aggregate(10L)).thenReturn(before, after); when(repository.aggregate(10L)).thenReturn(before, after);
when(repository.hasPdaWorkStarted(10L)).thenReturn(true); when(repository.hasPdaWorkStarted(10L)).thenReturn(true);
@@ -260,7 +248,6 @@ class LoadingListServiceTest {
@Test @Test
void correctingExistingContainerNumberStillRequiresReason() { void correctingExistingContainerNumberStillRequiresReason() {
AggregateRow before = row("EDITING", 3); AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870"); before.setContainerNo("MSCU6639870");
when(repository.aggregate(10L)).thenReturn(before); when(repository.aggregate(10L)).thenReturn(before);
@@ -274,7 +261,6 @@ class LoadingListServiceTest {
@Test @Test
void rejectsGlobalUniqueConflictIncludingConcurrentConstraintRace() { void rejectsGlobalUniqueConflictIncludingConcurrentConstraintRace() {
AggregateRow before = row("EDITING", 3); AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870"); before.setContainerNo("MSCU6639870");
when(repository.aggregate(10L)).thenReturn(before); when(repository.aggregate(10L)).thenReturn(before);
when(containerRepository.containerNoExists("CSQU3054383", 30L)).thenReturn(true); when(containerRepository.containerNoExists("CSQU3054383", 30L)).thenReturn(true);
@@ -329,10 +315,8 @@ class LoadingListServiceTest {
@Test @Test
void shouldReplaySameTransportRequestWithoutSecondMutation() { void shouldReplaySameTransportRequestWithoutSecondMutation() {
AggregateRow before = row("EDITING", 3); AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870"); before.setContainerNo("MSCU6639870");
AggregateRow after = row("EDITING", 4); AggregateRow after = row("EDITING", 4);
after.setPreloadPushedAt(before.getPreloadPushedAt());
after.setContainerNo("CSQU3054383"); after.setContainerNo("CSQU3054383");
after.setVoyageNo("023E"); after.setVoyageNo("023E");
when(repository.history(anyString())).thenReturn(null); when(repository.history(anyString())).thenReturn(null);
@@ -361,7 +345,6 @@ class LoadingListServiceTest {
@Test @Test
void reusedTransportKeyWithDifferentPayloadFailsBeforeMutation() { void reusedTransportKeyWithDifferentPayloadFailsBeforeMutation() {
AggregateRow before = row("EDITING", 3); AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870"); before.setContainerNo("MSCU6639870");
HistoryRow replay = new HistoryRow(); HistoryRow replay = new HistoryRow();
replay.setAfterSnapshot("{\"requestHash\":\"different\"}"); replay.setAfterSnapshot("{\"requestHash\":\"different\"}");
@@ -765,9 +748,7 @@ class LoadingListServiceTest {
@Test @Test
void confirmsEveryLinkedPreloadAndCreatesSinglePdaTaskAtomically() { void confirmsEveryLinkedPreloadAndCreatesSinglePdaTaskAtomically() {
AggregateRow before = row("EDITING", 3); AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
AggregateRow after = row("EDITING", 4); AggregateRow after = row("EDITING", 4);
after.setPreloadPushedAt(before.getPreloadPushedAt());
List<Link> links = List.of( List<Link> links = List.of(
new Link(1L, 101L, 201L, 301L, "PENDING", 2, 10L, 30L, new Link(1L, 101L, 201L, 301L, "PENDING", 2, 10L, 30L,
"MSCU-001", "EDITING", 3, 4, null), "MSCU-001", "EDITING", 3, 4, null),
@@ -850,7 +831,6 @@ class LoadingListServiceTest {
@Test @Test
void rejectsNewConfirmationKeyAfterPlanWasAlreadyConfirmed() { void rejectsNewConfirmationKeyAfterPlanWasAlreadyConfirmed() {
AggregateRow confirmed = row("EDITING", 4); AggregateRow confirmed = row("EDITING", 4);
confirmed.setPreloadPushedAt(java.time.LocalDateTime.now());
when(repository.history(anyString())).thenReturn(null); when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(confirmed); when(repository.aggregate(10L)).thenReturn(confirmed);
when(loadingPickService.requirePreloadConfirmationReady(10L)) when(loadingPickService.requirePreloadConfirmationReady(10L))
@@ -872,7 +852,6 @@ class LoadingListServiceTest {
@Test @Test
void confirmationGateFailureLeavesPlanAndEveryPreloadUntouched() { void confirmationGateFailureLeavesPlanAndEveryPreloadUntouched() {
AggregateRow before = row("EDITING", 3); AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
when(repository.history(anyString())).thenReturn(null); when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(before); when(repository.aggregate(10L)).thenReturn(before);
when(loadingPickService.requirePreloadConfirmationReady(10L)) when(loadingPickService.requirePreloadConfirmationReady(10L))
@@ -904,7 +883,7 @@ class LoadingListServiceTest {
private static PreloadReadiness readiness(String confirmationStatus, boolean canConfirm, private static PreloadReadiness readiness(String confirmationStatus, boolean canConfirm,
boolean readyForPda, Long taskId) { boolean readyForPda, Long taskId) {
return new PreloadReadiness(true, true, 2, 2, 90L, "RELEASED", taskId, return new PreloadReadiness(true, 2, 2, 90L, "RELEASED", taskId,
taskId == null ? null : "LOADING", confirmationStatus, canConfirm, taskId == null ? null : "LOADING", confirmationStatus, canConfirm,
readyForPda, List.of()); readyForPda, List.of());
} }
@@ -9,7 +9,6 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.AdjustRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate;
import com.baiyue.firstleg.dto.SmartLoadingDtos.DeleteRequest; import com.baiyue.firstleg.dto.SmartLoadingDtos.DeleteRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Plan; import com.baiyue.firstleg.dto.SmartLoadingDtos.Plan;
import com.baiyue.firstleg.dto.SmartLoadingDtos.PushRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.RecommendRequest; import com.baiyue.firstleg.dto.SmartLoadingDtos.RecommendRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.WarehouseRemarkRequest; import com.baiyue.firstleg.dto.SmartLoadingDtos.WarehouseRemarkRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.ContainerBookingEntity;
@@ -403,29 +402,6 @@ class SmartLoadingServiceTest {
verify(loadingPickService, never()).remove(anyLong()); verify(loadingPickService, never()).remove(anyLong());
} }
@Test
void pushPreloadRejectsIncompleteGroupBeforeAnyLifecycleOrPlanWrite() {
LoadingPlanEntity plan = plan("EDITING", 0);
LoadingPlanItemEntity first = item(8L, 9L, 20L, 4);
GroupSnapshot group = group("LOCKED", 1L,
new Member(9L, 30L, 10L, "IN-10", 4), new Member(19L, 31L, 11L, "IN-11", 6));
when(repository.plan(1L)).thenReturn(plan);
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of(first));
when(repository.activeItemsByInbound(9L, 10L)).thenReturn(List.of(first));
when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group);
assertThatThrownBy(() -> service.pushPreload(1L, "push-incomplete-group",
new PushRequest(0), 7L, "u"))
.isInstanceOf(BizException.class).hasMessageContaining("全部在同一柜");
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
verify(splitPreloadService, never()).ensurePendingForLoadingAllocation(
anyLong(), anyLong(), anyLong(), anyString());
verify(loadingModelBridge, never()).bindAllocatedBeforeLoading(
anyLong(), anyLong(), anyString(), anyString());
}
@Test @Test
void removesEveryGroupMemberAndUnlocksOnlyAfterAllRowsAreRemoved() { void removesEveryGroupMemberAndUnlocksOnlyAfterAllRowsAreRemoved() {
LoadingPlanEntity plan = plan("EDITING", 0); LoadingPlanEntity plan = plan("EDITING", 0);
@@ -548,7 +524,7 @@ class SmartLoadingServiceTest {
when(repository.planByBooking(99L)).thenReturn(plan); when(repository.planByBooking(99L)).thenReturn(plan);
when(repository.items(1L)).thenReturn(List.of()); when(repository.items(1L)).thenReturn(List.of());
Plan result = service.create(99L, "备注", 7L, "u"); Plan result = service.createForContainer(99L, 7L, "u");
assertThat(result.id()).isEqualTo(1L); assertThat(result.id()).isEqualTo(1L);
verify(repository, never()).insert(any(LoadingPlanEntity.class)); verify(repository, never()).insert(any(LoadingPlanEntity.class));
@@ -565,7 +541,7 @@ class SmartLoadingServiceTest {
}).when(repository).insert(any(LoadingPlanEntity.class)); }).when(repository).insert(any(LoadingPlanEntity.class));
when(repository.items(1L)).thenReturn(List.of()); when(repository.items(1L)).thenReturn(List.of());
Plan result = service.create(99L, "备注", 7L, "u"); Plan result = service.createForContainer(99L, 7L, "u");
assertThat(result.planNo()).isEqualTo("LP-260818-0001"); assertThat(result.planNo()).isEqualTo("LP-260818-0001");
} }
@@ -1076,205 +1052,6 @@ class SmartLoadingServiceTest {
verify(repository).remove(1, 8, 0, 7, "u"); verify(repository).remove(1, 8, 0, 7, "u");
} }
@Test
void pushesIdempotently() {
LoadingPlanEntity editing = plan("EDITING", 3);
LoadingPlanEntity pushed = plan("EDITING", 4);
pushed.setPushIdempotencyKey("key");
when(repository.plan(1)).thenReturn(editing, pushed);
when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1)));
when(repository.booking(99)).thenReturn(booking());
when(repository.push(1, "key", 3, 7, "u")).thenReturn(1);
Plan result = service.pushPreload(1, "key", new PushRequest(3), 7, "u");
assertThat(result.status()).isEqualTo("EDITING");
verify(repository).push(1, "key", 3, 7, "u");
ArgumentCaptor<LoadingPlanHistoryEntity> history = ArgumentCaptor.forClass(LoadingPlanHistoryEntity.class);
verify(repository).insert(history.capture());
assertThat(history.getValue().getActionType()).isEqualTo("PUSH_PRELOAD");
assertThat(history.getValue().getAfterSnapshot())
.contains(sha256("PUSH_PRELOAD|1|3"));
var ordered = inOrder(splitPreloadService, loadingModelBridge);
ordered.verify(splitPreloadService).ensurePendingForLoadingAllocation(9L, 10L, 7L, "u");
ordered.verify(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key");
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
@Test
void exactPushReplayIsReadOnly() {
LoadingPlanEntity pushed = plan("EDITING", 4);
pushed.setPushIdempotencyKey("key");
when(repository.plan(1)).thenReturn(pushed);
when(repository.booking(99)).thenReturn(booking());
when(repository.operation("key"))
.thenReturn(history(1, null, "PUSH_PRELOAD", sha256("PUSH_PRELOAD|1|3")));
Plan replay = service.pushPreload(1, "key", new PushRequest(3), 7, "u");
assertThat(replay.version()).isEqualTo(4);
verify(splitPreloadService, never()).ensurePendingForLoadingAllocation(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingModelBridge, never()).bindAllocatedBeforeLoading(anyLong(), anyLong(), anyString(), anyString());
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
}
@Test
void samePushKeyWithDifferentRequestIsRejectedWithoutWrites() {
LoadingPlanEntity pushed = plan("EDITING", 4);
pushed.setPushIdempotencyKey("key");
when(repository.plan(1)).thenReturn(pushed);
when(repository.booking(99)).thenReturn(booking());
when(repository.operation("key"))
.thenReturn(history(1, null, "PUSH_PRELOAD", sha256("PUSH_PRELOAD|1|3")));
BizException error = catchThrowableOfType(
() -> service.pushPreload(1, "key", new PushRequest(4), 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
assertThat(error.getMessage()).contains("不同推送请求");
verify(repository, never()).items(anyLong());
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
verifyNoPushSideEffects();
}
@Test
void newPushKeyAfterSuccessfulPushIsRejectedWithoutWrites() {
LoadingPlanEntity pushed = plan("EDITING", 4);
pushed.setPushIdempotencyKey("first-key");
when(repository.plan(1)).thenReturn(pushed);
when(repository.booking(99)).thenReturn(booking());
BizException error = catchThrowableOfType(
() -> service.pushPreload(1, "new-key", new PushRequest(3), 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
assertThat(error.getMessage()).contains("新的幂等键");
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
verifyNoPushSideEffects();
}
@Test
void pushKeyUsedByAnotherPlanOperationIsRejectedBeforeWrites() {
LoadingPlanEntity editing = plan("EDITING", 3);
when(repository.plan(1)).thenReturn(editing);
when(repository.booking(99)).thenReturn(booking());
when(repository.operation("used-key"))
.thenReturn(history(1, 8L, "REMOVE_ITEM", sha256("DELETE|1|8|3|0")));
BizException error = catchThrowableOfType(
() -> service.pushPreload(1, "used-key", new PushRequest(3), 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
assertThat(error.getMessage()).contains("不同推送请求");
verify(repository, never()).items(anyLong());
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
verifyNoPushSideEffects();
}
@Test
void bindingFailureStopsBeforePdaAndPropagatesForTransactionRollback() {
LoadingPlanEntity editing = plan("EDITING", 3);
LoadingPlanEntity pushed = plan("EDITING", 4);
pushed.setPushIdempotencyKey("key");
when(repository.plan(1)).thenReturn(editing, pushed);
when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1)));
when(repository.booking(99)).thenReturn(booking());
when(repository.push(1, "key", 3, 7, "u")).thenReturn(1);
doThrow(new BizException(ResultCode.CONFLICT, "预装计划不存在"))
.when(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key");
BizException error = catchThrowableOfType(
() -> service.pushPreload(1, "key", new PushRequest(3), 7, "u"), BizException.class);
assertThat(error.getMessage()).contains("预装计划不存在");
verify(repository).push(1, "key", 3, 7, "u");
verify(repository).insert(any(LoadingPlanHistoryEntity.class));
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
@Test
void concurrentPushKeyClaimReturnsConflictBeforeBridgeAndPda() {
LoadingPlanEntity editing = plan("EDITING", 3);
when(repository.plan(1)).thenReturn(editing);
when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1)));
when(repository.booking(99)).thenReturn(booking());
when(repository.push(1, "race-key", 3, 7, "u")).thenReturn(1);
doThrow(new DuplicateKeyException("duplicate operation_id"))
.when(repository).insert(any(LoadingPlanHistoryEntity.class));
BizException error = catchThrowableOfType(
() -> service.pushPreload(1, "race-key", new PushRequest(3), 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
assertThat(error.getMessage()).contains("不同推送请求");
verify(loadingModelBridge, never()).bindAllocatedBeforeLoading(anyLong(), anyLong(), anyString(), anyString());
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
@Test
void rejectsOverweightPlanBeforePush() {
LoadingPlanEntity editing = plan("EDITING", 1);
ContainerBookingEntity booking = booking();
booking.setMaxWeightKg(new BigDecimal("1"));
LoadingPlanItemEntity overweight = item(8, 9, 20, 1);
overweight.setGrossWeightKg(new BigDecimal("2"));
when(repository.plan(1)).thenReturn(editing);
when(repository.booking(99)).thenReturn(booking);
when(repository.items(1)).thenReturn(List.of(overweight));
BizException error = catchThrowableOfType(
() -> service.pushPreload(1, "weight-key", new PushRequest(1), 7, "u"),
BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
assertThat(error.getMessage()).contains("重量").contains("110%");
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
@Test
void pushesWithoutContainerNumberAndDoesNotOpenPda() {
LoadingPlanEntity editing = plan("EDITING", 1);
ContainerBookingEntity booking = booking();
booking.setContainerNo(null);
when(repository.plan(1)).thenReturn(editing, plan("EDITING", 2));
when(repository.booking(99)).thenReturn(booking);
when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1)));
when(repository.push(1, "no-container-key", 1, 7, "u")).thenReturn(1);
service.pushPreload(1, "no-container-key", new PushRequest(1), 7, "u");
verify(repository).push(1, "no-container-key", 1, 7, "u");
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
@Test
void exactReplayWhenContainerNumberIsStillMissingIsReadOnly() {
LoadingPlanEntity pushed = plan("EDITING", 2);
pushed.setPushIdempotencyKey("replay-key");
ContainerBookingEntity booking = booking();
booking.setContainerNo(" ");
when(repository.plan(1)).thenReturn(pushed);
when(repository.booking(99)).thenReturn(booking);
when(repository.operation("replay-key"))
.thenReturn(history(1, null, "PUSH_PRELOAD", sha256("PUSH_PRELOAD|1|2")));
service.pushPreload(1, "replay-key", new PushRequest(2), 7, "u");
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
private void verifyNoPushSideEffects() {
verify(splitPreloadService, never()).ensurePendingForLoadingAllocation(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingModelBridge, never()).bindAllocatedBeforeLoading(anyLong(), anyLong(), anyString(), anyString());
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
private void stubAssignment() { private void stubAssignment() {
LoadingPlanEntity plan = plan("EDITING", 0); LoadingPlanEntity plan = plan("EDITING", 0);
when(repository.plan(1)).thenReturn(plan, plan("EDITING", 1)); when(repository.plan(1)).thenReturn(plan, plan("EDITING", 1));
+2 -1
View File
@@ -161,7 +161,8 @@ Assert-Contains $truckOpenApi 'Each booking may have at most one non-terminal ap
Assert-Contains $truckOpenApi 'PDA loading completion does not change a booking-level truck appointment' '拖车与 PDA 生命周期解耦' Assert-Contains $truckOpenApi 'PDA loading completion does not change a booking-level truck appointment' '拖车与 PDA 生命周期解耦'
Assert-Contains $truckOpenApi 'excluding bookings that already have a non-terminal truck appointment' '拖车创建候选过滤' Assert-Contains $truckOpenApi 'excluding bookings that already have a non-terminal truck appointment' '拖车创建候选过滤'
Assert-Contains $loadingListsOpenApi '真实集装箱号可在PDA确认装柜完成前补录' '预装真实柜号晚录门禁' Assert-Contains $loadingListsOpenApi '真实集装箱号可在PDA确认装柜完成前补录' '预装真实柜号晚录门禁'
Assert-Contains $loadingListsOpenApi '已推送后可在 EDITING 或 LOADING 阶段首次补录真实集装箱号' '预装真实柜号首次补录阶段' Assert-Contains $loadingListsOpenApi '可在 EDITING 或 LOADING 阶段首次补录真实集装箱号' '预装真实柜号首次补录阶段'
Assert-Contains $adminAgents '创建柜次时自动生成唯一预装计划' 'Admin 自动预装计划流程'
Assert-Contains $loadingListsOpenApi '不依赖真实集装箱号,task 已创建后仍为 true' 'PDA task 不依赖真实柜号' Assert-Contains $loadingListsOpenApi '不依赖真实集装箱号,task 已创建后仍为 true' 'PDA task 不依赖真实柜号'
Assert-Contains $adminAgents 'PDA 最终完成前必须补齐' 'Admin 真实柜号最终门禁' Assert-Contains $adminAgents 'PDA 最终完成前必须补齐' 'Admin 真实柜号最终门禁'
Assert-Contains $pdaAgents '当前 `taskId + warehouseId` 走任务专用 API' '国内 PDA 柜级证据入口' Assert-Contains $pdaAgents '当前 `taskId + warehouseId` 走任务专用 API' '国内 PDA 柜级证据入口'