From 539030d2b6d8554061253e76bc56c6379fe0ebf5 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 20:01:14 +0800 Subject: [PATCH 1/2] fix(file): return controlled missing package errors --- .../file/controller/PlatformQualificationFileController.java | 2 +- .../baiyue/file/controller/QualificationFileController.java | 2 +- .../baiyue/file/controller/QualificationFileContractTest.java | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java b/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java index e301f5f94..44ed58565 100644 --- a/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java +++ b/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java @@ -87,7 +87,7 @@ public class PlatformQualificationFileController { public ApiResponse uploadRequiredPackage( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, - @RequestPart("businessLicense") MultipartFile businessLicense, + @RequestPart(value = "businessLicense", required = false) MultipartFile businessLicense, @RequestPart(value = "legalPersonId", required = false) MultipartFile legalPersonId, @RequestParam String unifiedSocialCreditCode, @RequestParam(required = false) LocalDate businessLicenseValidTo, diff --git a/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java b/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java index 058abedca..b4cf838f0 100644 --- a/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java +++ b/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java @@ -78,7 +78,7 @@ public class QualificationFileController { @PreAuthorize("hasAuthority('merchant:qualification:upload')") public ApiResponse uploadRequiredPackage( @AuthenticationPrincipal AuthenticatedUser user, - @RequestPart("businessLicense") MultipartFile businessLicense, + @RequestPart(value = "businessLicense", required = false) MultipartFile businessLicense, @RequestPart(value = "legalPersonId", required = false) MultipartFile legalPersonId, @RequestParam String unifiedSocialCreditCode, @RequestParam(required = false) LocalDate businessLicenseValidTo, diff --git a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java index 773d0eefd..297cfd856 100644 --- a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java +++ b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java @@ -83,7 +83,7 @@ class QualificationFileContractTest { } @Test - void requiredPackageKeepsBusinessLicenseRequiredAndMakesLegalPersonIdOptional() { + void requiredPackageLetsServiceReturnControlledValidationForMissingParts() { assertRequiredPackageParts(QualificationFileController.class); assertRequiredPackageParts(PlatformQualificationFileController.class); } @@ -316,7 +316,7 @@ class QualificationFileContractTest { .map(parameter -> parameter.getAnnotation(RequestPart.class)) .filter(java.util.Objects::nonNull) .collect(java.util.stream.Collectors.toMap(RequestPart::value, RequestPart::required)); - assertEquals(Boolean.TRUE, parts.get("businessLicense")); + assertEquals(Boolean.FALSE, parts.get("businessLicense")); assertEquals(Boolean.FALSE, parts.get("legalPersonId")); } From 8e17401f37422179d246b8f72dad630dc23a80c2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 20:07:38 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 56 ++--- ...erchantOrderBatchImportFileController.java | 59 +++++ .../openapi/order-batch-import-file-v1.yaml | 18 ++ ...cationAndOrderFileOpenApiContractTest.java | 2 + ...antOrderBatchImportFileControllerTest.java | 63 +++++ firstleg/AGENTS.md | 2 +- .../PlatformLoadingListController.java | 4 +- .../PlatformSmartLoadingController.java | 4 - .../baiyue/firstleg/dto/LoadingListDtos.java | 1 - .../baiyue/firstleg/dto/SmartLoadingDtos.java | 2 - .../firstleg/entity/LoadingPlanEntity.java | 1 - .../mapper/LoadingModelBridgeMapper.java | 9 +- .../service/LoadingModelBridgeService.java | 7 +- .../mapper/LoadingListQueryMapper.java | 20 +- .../firstleg/mapper/LoadingPlanMapper.java | 9 +- .../repository/LoadingListRepository.java | 6 +- .../repository/SmartLoadingRepository.java | 3 - .../firstleg/service/ContainerService.java | 5 +- .../firstleg/service/LoadingListService.java | 3 +- .../firstleg/service/LoadingPickService.java | 2 +- .../firstleg/service/SmartLoadingService.java | 71 +----- .../db/phase3-smart-loading-schema.sql | 42 ++-- .../main/resources/openapi/firstleg-v1.yaml | 25 -- .../resources/openapi/loading-lists-v1.yaml | 7 +- .../phase3-loading-model-bridge-v1.yaml | 2 +- ...ormSmartLoadingControllerContractTest.java | 12 +- .../LoadingModelBridgeServiceTest.java | 4 +- .../LoadingListQueryMapperContractTest.java | 10 +- .../SmartLoadingSqlContractTest.java | 15 +- .../service/ContainerServiceTest.java | 10 +- .../service/LoadingListServiceTest.java | 39 +-- .../service/SmartLoadingServiceTest.java | 227 +----------------- scripts/agents-index-contract.ps1 | 3 +- 34 files changed, 244 insertions(+), 501 deletions(-) create mode 100644 file/src/main/java/com/baiyue/file/controller/MerchantOrderBatchImportFileController.java create mode 100644 file/src/test/java/com/baiyue/file/controller/MerchantOrderBatchImportFileControllerTest.java diff --git a/AGENTS.md b/AGENTS.md index b76a2f16b..71d29a4b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ | 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`,不得等部署启动发现 | | 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `/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/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` | | 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`TruckAppointmentService.java`、`outbound/dispatch/`、`customs/`;拖车只绑定 `RELEASED` 订舱,不携带客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,订舱文件联查 `file/AGENTS.md` | diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 18bfe84a6..b3ad4cdd3 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -5015,10 +5015,6 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan ( container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID', 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_name VARCHAR(64) NULL COMMENT '预装完成操作人名称快照', preload_completed_at DATETIME(3) NULL COMMENT '预装完成时间', @@ -5037,7 +5033,6 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan ( PRIMARY KEY (id), UNIQUE KEY uk_loading_plan_no (plan_no), 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), CONSTRAINT fk_loading_plan_booking FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id), @@ -5589,10 +5584,6 @@ BEGIN IF OLD.plan_status = 'LOADING' AND ( 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.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.created_by <=> NEW.created_by) OR NOT (OLD.created_by_name <=> NEW.created_by_name) @@ -5612,12 +5603,7 @@ BEGIN ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '同状态乐观锁触碰不得修改阶段审计字段'; END IF; - IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' 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 OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN IF EXISTS (SELECT 1 FROM fl_loading_plan_item item WHERE item.loading_plan_id = OLD.id AND item.deleted = 0 AND NOT EXISTS ( @@ -5998,6 +5984,13 @@ DELIMITER ; CALL 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 (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, deleted) @@ -6012,14 +6005,10 @@ VALUES '编辑货柜', 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), - (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), (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), (5102100, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:preload:list', '预装清单', '/first-leg/preload-list', 'GET', 60, 'ACTIVE', 1, 0), (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:create', 'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list', - 'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign', - 'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push', + 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete', 'platform:firstleg:preload:list', 'platform:firstleg:preload:view', 'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete', 'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list', @@ -6174,12 +6162,11 @@ BEGIN AND permission_code IN ( 'platform:firstleg:container:list', 'platform:firstleg:container:view', 'platform:firstleg:container:create', 'platform:firstleg:container:edit', - 'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:create', - 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete', - 'platform:firstleg:smart-loading:push', 'platform:firstleg:preload:list', + 'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:assign', + 'platform:firstleg:smart-loading:delete', 'platform:firstleg:preload:list', 'platform:firstleg:preload:view', 'platform:firstleg:preload:edit', '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 = '智能装柜迁移失败:平台权限资源未完整创建'; END IF; 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:create', 'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list', - 'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign', - 'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push', + 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete', 'platform:firstleg:preload:list', 'platform:firstleg:preload:view', 'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete', '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 = '智能装柜迁移失败:平台角色授权未完整写入'; END IF; END$$ @@ -7795,10 +7781,8 @@ VALUES (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), (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), - (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) + (5102098, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:delete', '删除装柜明细', NULL, 'DELETE', 40, 'ACTIVE', 1, 0) ON DUPLICATE KEY UPDATE permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), permission_name = VALUES(permission_name), route_path = VALUES(route_path), @@ -7866,8 +7850,7 @@ SELECT role.id, permission.id, role.tenant_id AND permission.permission_code IN ( 'platform:firstleg:container:view', 'platform:firstleg:container:create', 'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list', - 'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign', - 'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push' + 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete' ) 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_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_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', 'updated_at', '装柜计划最后更新时间'); 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 = 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 = 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 = 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' SET MESSAGE_TEXT = '统一迁移失败:智能装柜权限 ID 或父子关系不正确'; END IF; diff --git a/file/src/main/java/com/baiyue/file/controller/MerchantOrderBatchImportFileController.java b/file/src/main/java/com/baiyue/file/controller/MerchantOrderBatchImportFileController.java new file mode 100644 index 000000000..beab17f86 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/controller/MerchantOrderBatchImportFileController.java @@ -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 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(); + } +} diff --git a/file/src/main/resources/openapi/order-batch-import-file-v1.yaml b/file/src/main/resources/openapi/order-batch-import-file-v1.yaml index 816004a54..ea3601679 100644 --- a/file/src/main/resources/openapi/order-batch-import-file-v1.yaml +++ b/file/src/main/resources/openapi/order-batch-import-file-v1.yaml @@ -8,6 +8,23 @@ info: x-lifecycle-owner: ORDER x-file-object-semantics: IMMUTABLE_CREATE_ONLY 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}: post: summary: Order上传批量导入源文件或错误报告 @@ -62,6 +79,7 @@ paths: schema: {type: string, format: binary} components: securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} internalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token} parameters: OperatorId: diff --git a/file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java b/file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java index dca674082..3d365911d 100644 --- a/file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java +++ b/file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java @@ -27,6 +27,8 @@ class QualificationAndOrderFileOpenApiContractTest { assertThat(yaml) .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("pattern: '^OBI-[0-9A-F]{20}$'") .contains("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") diff --git a/file/src/test/java/com/baiyue/file/controller/MerchantOrderBatchImportFileControllerTest.java b/file/src/test/java/com/baiyue/file/controller/MerchantOrderBatchImportFileControllerTest.java new file mode 100644 index 000000000..43b3d5e52 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/controller/MerchantOrderBatchImportFileControllerTest.java @@ -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()); + } +} diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 21a29622d..38656bc78 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -28,7 +28,7 @@ | 财务费用来源快照 | `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` | | 财务整柜成本分摊快照 | `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` 可用入仓片段与满箱数;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 | | 货柜与智能排柜 | `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 的当前唯一版本 | diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java index 59eb7bf0e..ccfaa1ba3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java @@ -72,7 +72,7 @@ public class PlatformLoadingListController { return ApiResponse.success(service.lifecycle(planId, warehouseId)); } - @Operation(summary = "修改装柜运输字段", description = "已推送后可在装柜完成前首次补录集装箱号;已有柜号的纠正仅限 EDITING 且 PDA 尚未领取或产生物理作业事实,并必须提供原因和版本;柜号不能清空。") + @Operation(summary = "修改装柜运输字段", description = "可在装柜完成前首次补录集装箱号;已有柜号的纠正仅限 EDITING 且 PDA 尚未领取或产生物理作业事实,并必须提供原因和版本;柜号不能清空。") @PatchMapping("/loading-plans/{planId}/transport") @PreAuthorize("hasAuthority('platform:firstleg:preload:edit')") public ApiResponse updateTransport( @@ -86,7 +86,7 @@ public class PlatformLoadingListController { 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") @PreAuthorize("hasAuthority('platform:firstleg:preload:edit')") public ApiResponse confirmPreload( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java index abf0ed8e4..1b80a9684 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java @@ -18,8 +18,6 @@ public class PlatformSmartLoadingController { public ApiResponse 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')") public ApiResponse 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 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')") public ApiResponse 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')") @@ -36,6 +34,4 @@ public class PlatformSmartLoadingController { public ApiResponse 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')") public ApiResponse 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 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()));} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java index 1e7ef50eb..58dbe8e4e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java @@ -85,7 +85,6 @@ public final class LoadingListDtos { } public record PreloadReadiness( - boolean preloadPushed, boolean containerNoReady, int preloadLinkCount, int confirmedPreloadCount, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java index ce77047fd..bb9c3b274 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java @@ -15,7 +15,6 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){} BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal unitVolumeCbm, Long coLoadGroupId, String groupNo, Integer memberCount, Integer groupTotalBoxes, 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, @NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedVersion,@NotBlank @Size(max=128) String operationId) {} 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, @NotNull @Min(0) Integer expectedPlanVersion, @NotNull @Min(0) Integer expectedItemVersion, @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, Integer version,Boolean canAssign,Boolean canEdit,String blockedReason,String finalCompletionStatus, Long pdaTaskId,String pdaTaskStatus,Integer pdaTaskVersion,List items) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanEntity.java index 7c699544a..981010ce1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanEntity.java @@ -3,7 +3,6 @@ import com.baomidou.mybatisplus.annotation.*; import lombok.Getter; import lombo @Getter @Setter @TableName("fl_loading_plan") public class LoadingPlanEntity { @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 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; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java index 44699456d..979de001e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java @@ -17,7 +17,7 @@ public interface LoadingModelBridgeMapper extends BaseMapper requireAllocatedLinksForConfirmation(long loadingPlanId) { var loadingPlan = repository.loadingPlan(loadingPlanId); if (loadingPlan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在"); - if (loadingPlan.preloadPushedAt() == null || !"EDITING".equals(loadingPlan.status())) { - throw conflict("仅已推送且仍为EDITING的预装计划可以确认"); + if (!"EDITING".equals(loadingPlan.status())) { + throw conflict("仅编辑中的预装计划可以确认"); } List scopes = repository.allocatedScopes(loadingPlanId); if (scopes.isEmpty()) throw conflict("装柜计划尚未分配入库单货物"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java index cadc31d22..97c77cfaa 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java @@ -21,7 +21,7 @@ import java.util.List; public interface LoadingListQueryMapper { String AGGREGATE_COLUMNS = """ 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.vessel_name, b.voyage_no, b.container_type, (SELECT COUNT(*) FROM fl_preload_loading_plan_link preload_link @@ -49,7 +49,7 @@ public interface LoadingListQueryMapper { String AGGREGATE_GROUP = """ 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.vessel_name, b.voyage_no, b.container_type """; @@ -61,10 +61,7 @@ public interface LoadingListQueryMapper { FROM fl_loading_plan p 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 - WHERE p.preload_pushed_at IS NOT NULL - 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) + WHERE p.plan_status IN ('EDITING', 'LOADING') AND p.plan_status = #{query.status} 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}) @@ -227,7 +224,7 @@ public interface LoadingListQueryMapper { UPDATE fl_loading_plan SET updated_by = #{operatorId}, updated_by_name = #{operatorName}, 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 NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task 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}, b.updated_by = #{operatorId}, b.updated_by_name = #{operatorName}, 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 JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.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}, p.updated_at=CURRENT_TIMESTAMP(3), p.version=p.version+1 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 """) int touchForInitialContainerFill(@Param("planId") long planId, @@ -286,7 +283,7 @@ public interface LoadingListQueryMapper { b.updated_by=#{operatorId}, b.updated_by_name=#{operatorName}, b.updated_at=CURRENT_TIMESTAMP(3), b.version=b.version+1 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 """) int fillInitialContainerNo(@Param("planId") long planId, @@ -331,7 +328,7 @@ public interface LoadingListQueryMapper { UPDATE fl_loading_plan SET updated_by = #{operatorId}, updated_by_name = #{operatorName}, 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} """) int touchForPreloadConfirmation(@Param("planId") long planId, @@ -385,7 +382,6 @@ public interface LoadingListQueryMapper { private String createdByName; private LocalDateTime createdAt; private Integer version; - private LocalDateTime preloadPushedAt; private String containerNo; private String cabinetNo; private Long warehouseId; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanMapper.java index 9673ba880..59d07e48f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanMapper.java @@ -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} """) 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 fl_loading_plan SET plan_status='LOADING',updated_by=#{operatorId},updated_by_name=#{operatorName}, 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); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java index b1fbd5d5f..33289ea30 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java @@ -148,7 +148,6 @@ public class LoadingListRepository { } private PreloadReadiness readiness(AggregateRow row) { - boolean pushed = row.getPreloadPushedAt() != null; boolean containerReady = org.springframework.util.StringUtils.hasText(row.getContainerNo()); int linkCount = Math.toIntExact(value(row.getPreloadLinkCount())); int confirmedCount = Math.toIntExact(value(row.getConfirmedPreloadCount())); @@ -157,19 +156,18 @@ public class LoadingListRepository { boolean editing = "EDITING".equals(row.getPlanStatus()); boolean taskCreated = row.getPdaTaskId() != null; java.util.ArrayList blockers = new java.util.ArrayList<>(); - if (!pushed) blockers.add("PRELOAD_NOT_PUSHED"); if (!editing) blockers.add("LOADING_PLAN_NOT_EDITING"); if (linkCount == 0) blockers.add("PRELOAD_LINK_MISSING"); if (!oceanLinked) blockers.add("OCEAN_BOOKING_NOT_LINKED"); else if (!released) blockers.add("OCEAN_BOOKING_NOT_RELEASED"); if (taskCreated) blockers.add("PDA_TASK_ALREADY_CREATED"); boolean allConfirmed = linkCount > 0 && confirmedCount == linkCount; - boolean prerequisitesReady = pushed && editing && linkCount > 0 && released; + boolean prerequisitesReady = editing && linkCount > 0 && released; boolean canConfirm = prerequisitesReady && !taskCreated && !allConfirmed; if (allConfirmed) blockers.add("PRELOAD_ALREADY_CONFIRMED"); String confirmationStatus = allConfirmed ? "CONFIRMED" : 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.getPdaTaskStatus(), confirmationStatus, canConfirm, prerequisitesReady && allConfirmed, blockers); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java index 89d263e49..ec03685cc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java @@ -99,9 +99,6 @@ public class SmartLoadingRepository { return items.updateWarehouseRemark(planId, itemId, touchedPlanVersion, expectedItemVersion, 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) { return plans.startPicking(planId, operatorId, operatorName); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index e24973abb..afa4f201a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -38,6 +38,7 @@ public class ContainerService { @Resource private OceanBookingRepository oceanBookings; @Resource private WarehouseSupplierSnapshotService suppliers; @Resource private FirstlegDocumentNumberGenerator documentNumbers; + @Resource private SmartLoadingService smartLoadingService; @Value("${baiyue.security.internal-service-token:}") private String internalToken; @Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId; public PageResult page(Query query) { @@ -63,7 +64,9 @@ public class ContainerService { value.setContainerType(specification.containerType()); value.setCapacityCbm(specification.capacityCbm()); value.setMaxWeightKg(specification.maxWeightKg()); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); 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 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index ff26f6356..44e95aa08 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -593,9 +593,8 @@ public class LoadingListService { throw conflict("仅装柜完成前允许首次补录真实集装箱号"); } if (!initialContainerFill && !"EDITING".equals(row.getPlanStatus())) { - throw conflict("仅已推送且PDA尚未开始的预装计划可纠错真实集装箱号"); + throw conflict("仅PDA尚未开始的编辑中预装计划可纠错真实集装箱号"); } - if (row.getPreloadPushedAt() == null) throw conflict("仅已推送的预装清单可维护运输字段"); if (!initialContainerFill && repository.hasPdaWorkStarted(row.getPlanId())) { throw conflict("PDA任务已领取、已开始作业或已有物理作业事实,不能修改真实集装箱号"); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index 37cf9cbd4..3ce02627b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -117,7 +117,7 @@ public class LoadingPickService { var booking = smartLoadingRepository.booking(containerBookingId); if (booking == null) return; 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()); if (links.isEmpty() || links.stream().anyMatch(link -> !"CONFIRMED".equals(link.preloadStatus()))) return; var allocation = oceanBookingRepository.allocationByContainer(containerBookingId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 4c8ea0803..9072bb1a1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -34,7 +34,7 @@ import java.sql.SQLException; import java.time.LocalDateTime; import java.util.*; -/** 智能装柜的候选、推荐/人工分配、删除和不可逆推送编排。 */ +/** 智能装柜的自动计划、候选、推荐、人工分配和删除编排。 */ @Service public class SmartLoadingService { private static final BigDecimal CAPACITY_LIMIT_RATIO = new BigDecimal("1.10"); @@ -129,15 +129,13 @@ public class SmartLoadingService { } @Transactional - @BusinessAudit(event = "SMART_LOADING_PLAN_CREATE", resourceType = "LOADING_PLAN", - description = "创建智能装柜计划", captureBefore = false) - public Plan create(long bookingId, String remark, long operatorId, String operatorName) { + public Plan createForContainer(long bookingId, long operatorId, String operatorName) { ContainerBookingEntity booking = requireBooking(bookingId); LoadingPlanEntity existing = repository.planByBooking(bookingId); if (existing != null) return draftView(existing, booking); LoadingPlanEntity plan = new LoadingPlanEntity(); 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.setUpdatedByName(operatorName); plan.setVersion(0); try { @@ -542,69 +540,6 @@ public class SmartLoadingService { 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 items = repository.items(planId); - List 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 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 enrichColoadCandidates(ContainerBookingEntity booking, LoadingPlanEntity plan, List values) { Map snapshots = new HashMap<>(); diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 48ff9d99c..5f13bef87 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -223,10 +223,6 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan ( plan_no VARCHAR(40) NOT NULL COMMENT '平台唯一装柜计划号', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID', 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_name VARCHAR(64) NULL COMMENT '预装完成操作人名称快照', preload_completed_at DATETIME(3) NULL COMMENT '预装完成时间', @@ -244,7 +240,6 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan ( PRIMARY KEY (id), UNIQUE KEY uk_loading_plan_no (plan_no), 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), CONSTRAINT fk_loading_plan_booking FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id), @@ -833,10 +828,6 @@ BEGIN IF OLD.plan_status = 'LOADING' AND ( 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.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.created_by <=> NEW.created_by) OR NOT (OLD.created_by_name <=> NEW.created_by_name) @@ -856,12 +847,7 @@ BEGIN ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '同状态乐观锁触碰不得修改阶段审计字段'; END IF; - IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' 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 OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN IF EXISTS (SELECT 1 FROM fl_loading_plan_item item WHERE item.loading_plan_id = OLD.id AND item.deleted = 0 AND NOT EXISTS ( @@ -1324,6 +1310,13 @@ DELIMITER ; CALL 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 (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, deleted) @@ -1338,14 +1331,10 @@ VALUES '编辑货柜', 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), - (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), (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), (5102100, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:preload:list', '预装清单', '/first-leg/preload-list', 'GET', 60, 'ACTIVE', 1, 0), (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:create', 'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list', - 'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign', - 'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push', + 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete', 'platform:firstleg:preload:list', 'platform:firstleg:preload:view', 'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete', 'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list', @@ -1506,12 +1494,11 @@ BEGIN AND permission_code IN ( 'platform:firstleg:container:list', 'platform:firstleg:container:view', 'platform:firstleg:container:create', 'platform:firstleg:container:edit', - 'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:create', - 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete', - 'platform:firstleg:smart-loading:push', 'platform:firstleg:preload:list', + 'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:assign', + 'platform:firstleg:smart-loading:delete', 'platform:firstleg:preload:list', 'platform:firstleg:preload:view', 'platform:firstleg:preload:edit', '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 = '智能装柜迁移失败:平台权限资源未完整创建'; END IF; 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:create', 'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list', - 'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign', - 'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push', + 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete', 'platform:firstleg:preload:list', 'platform:firstleg:preload:view', 'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete', '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 = '智能装柜迁移失败:平台角色授权未完整写入'; END IF; END$$ diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index c7e759c28..f8a26e50f 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -523,26 +523,6 @@ paths: content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingWarehouseRemarkRequest'}}} responses: '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: post: summary: 提交一条第三方箱规测量结果和一张图片 @@ -1651,11 +1631,6 @@ components: properties: version: {type: integer, minimum: 0} data: {$ref: '#/components/schemas/ContainerSaveRequest'} - SmartLoadingPushRequest: - type: object - required: [expectedVersion] - properties: - expectedVersion: {type: integer, minimum: 0} SmartLoadingAssignRequest: type: object required: [inboundId, inboundDetailId, packageCount, expectedVersion, operationId] diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index e69d52c52..4a9021268 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -49,7 +49,7 @@ paths: patch: summary: 修改预装运输信息 description: >- - 已推送后可在 EDITING 或 LOADING 阶段首次补录真实集装箱号;已有柜号的纠正仅限仍为 EDITING、PDA任务尚未领取且无任何物理作业事实时,并必须提供原因和当前版本; + 可在 EDITING 或 LOADING 阶段首次补录真实集装箱号;已有柜号的纠正仅限仍为 EDITING、PDA任务尚未领取且无任何物理作业事实时,并必须提供原因和当前版本; containerNo 为 null 表示保持当前值,非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位,空白字符串非法且禁止清空。 x-permission: platform:firstleg:preload:edit parameters: @@ -301,9 +301,8 @@ components: readiness: {$ref: '#/components/schemas/PreloadReadiness'} PreloadReadiness: type: object - required: [preloadPushed, containerNoReady, preloadLinkCount, confirmedPreloadCount, confirmationStatus, canConfirm, readyForPda, blockingReasons] + required: [containerNoReady, preloadLinkCount, confirmedPreloadCount, confirmationStatus, canConfirm, readyForPda, blockingReasons] properties: - preloadPushed: {type: boolean, description: 是否已完成 push-preload} containerNoReady: {type: boolean, description: 是否已录入真实集装箱号 containerNo} preloadLinkCount: {type: integer, format: int32, minimum: 0, description: 计划关联的入库预装票据数} confirmedPreloadCount: {type: integer, format: int32, minimum: 0, description: 已确认预装票据数} @@ -317,7 +316,7 @@ components: blockingReasons: type: array 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: type: object required: [expectedVersion, operationReason] diff --git a/firstleg/src/main/resources/openapi/phase3-loading-model-bridge-v1.yaml b/firstleg/src/main/resources/openapi/phase3-loading-model-bridge-v1.yaml index 37506b612..c58145a03 100644 --- a/firstleg/src/main/resources/openapi/phase3-loading-model-bridge-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-loading-model-bridge-v1.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: Phase 3 Preload and Container Loading Bridge API 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: /api/platform/v1/inbounds/{inboundId}/preload/loading-plans: get: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java index 833d7de2b..9123c2cd4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java @@ -32,17 +32,11 @@ class PlatformSmartLoadingControllerContractTest { 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("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")); - assertThat(yaml).contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/push-preload:") - .contains("不要求真实柜号") - .contains("不创建 PDA 作业任务") - .contains("已关联 RELEASED 订舱时才创建待领取 PDA 任务") - .contains("领取后才进入 LOADING") - .doesNotContain("已关联 CONFIRMED/RELEASED 订舱") - .doesNotContain("真实柜号必须已录入"); + assertThat(yaml).doesNotContain("/push-preload:") + .doesNotContain("SmartLoadingPushRequest:"); } @Test void openApiFreezesIncrementalMutationAndCapabilityContract() throws Exception { String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml")); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java index 05b512eca..55d570cc0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java @@ -169,7 +169,7 @@ class LoadingModelBridgeServiceTest { @Test void confirmationGateRejectsIncompleteAllocatedLinks() { 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( new AllocationScope(10L, 20L, 8), new AllocationScope(11L, 21L, 4))); when(repository.byLoadingPlan(40)).thenReturn(List.of( @@ -193,7 +193,7 @@ class LoadingModelBridgeServiceTest { @Test void bindsConfirmingPreloadToRunningPlanAndConfirmsIt() { 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.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "CONFIRMING", 4)); when(repository.confirmIncrementalPreload(30L, 10L, 20L, 4, 7L, "admin")).thenReturn(1); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java index ee7e3fb69..f24d554ff 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java @@ -34,9 +34,9 @@ class LoadingListQueryMapperContractTest { .contains("FROM fl_loading_plan p") .contains("JOIN fl_container_booking b") .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("active_item.deleted = 0") + .doesNotContain("active_item.deleted = 0") .contains("fl_preload_loading_plan_link") .contains("fl_ocean_booking_container_allocation") .contains("fl_loading_pick_task") @@ -88,14 +88,14 @@ class LoadingListQueryMapperContractTest { assertThat(touch) .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("version = #{expectedVersion}") .contains("version = version + 1"); assertThat(transport) .contains("SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}") .contains("p.plan_status='EDITING'") - .contains("preload_pushed_at IS NOT NULL") + .doesNotContain("preload_pushed_at") .doesNotContain("#{containerNo} IS NOT NULL") .contains("fl_loading_pick_task", "fl_loading_pick_assignment") .doesNotContain("DELETE") @@ -122,7 +122,7 @@ class LoadingListQueryMapperContractTest { .contains("version = version + 1"); assertThat(confirmPlan) .contains("plan_status = 'EDITING'") - .contains("preload_pushed_at IS NOT NULL") + .doesNotContain("preload_pushed_at") .contains("version = #{expectedVersion}") .contains("version = version + 1"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index cd495d25e..ed67c38d5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -20,9 +20,9 @@ class SmartLoadingSqlContractTest { private static final Set PHASE3_PERMISSIONS = Set.of( "platform:firstleg:container:list", "platform:firstleg:container:view", "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:push", "platform:firstleg:preload:list", + "platform:firstleg:preload:list", "platform:firstleg:preload:view", "platform:firstleg:preload:edit", "platform:firstleg:preload:complete", "platform:firstleg:preload:confirm", "platform:firstleg:loading-list:list", "platform:firstleg:loading-list:view"); @@ -60,7 +60,7 @@ class SmartLoadingSqlContractTest { } @Test - @DisplayName("活动明细唯一锁、乐观锁和双层幂等字段完整") + @DisplayName("活动明细唯一锁、乐观锁和操作幂等字段完整") void shouldProvideConcurrencyAndIdempotencyBoundaries() throws Exception { assertContainerLifecycleSchema(migration()); assertContainerLifecycleSchema(unifiedMigration()); @@ -71,7 +71,6 @@ class SmartLoadingSqlContractTest { .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_cabinet (cabinet_no)") - .contains("UNIQUE KEY uk_loading_plan_push_key (push_idempotency_key)") .contains("UNIQUE KEY uk_loading_history_operation (operation_id)") .contains("version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'"); } @@ -156,7 +155,7 @@ class SmartLoadingSqlContractTest { void shouldProvidePlatformPermissions() throws Exception { assertThat(migration()) .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:loading-list:view") .contains("role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')") @@ -165,7 +164,7 @@ class SmartLoadingSqlContractTest { } @Test - @DisplayName("模块脚本和统一脚本均校验16项权限与两个角色32项授权") + @DisplayName("模块脚本和统一脚本均校验14项权限与两个角色28项授权") void shouldAssertCompletePermissionGrants() throws Exception { assertPermissionContract(migration()); assertPermissionContract(unifiedMigration()); @@ -192,8 +191,6 @@ class SmartLoadingSqlContractTest { .doesNotContain("旧版本在推送预装时即写入 LOADING") .doesNotContain("修复旧版本“推送预装即拣货中”的状态污染") .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("SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3))") .contains("已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数") @@ -345,7 +342,7 @@ class SmartLoadingSqlContractTest { int assertionStart = sql.lastIndexOf("IF (SELECT COUNT(DISTINCT CONCAT", message); String assertion = sql.substring(assertionStart, message); assertThat(permissions(assertion)).isEqualTo(PHASE3_PERMISSIONS); - assertThat(assertion).contains("<> 32 THEN"); + assertThat(assertion).contains("<> 28 THEN"); } private static Set permissions(String sql) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index 221e6e480..63adffc8a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -45,12 +45,17 @@ class ContainerServiceTest { @MockBean private OceanBookingRepository oceanBookings; @MockBean private WarehouseSupplierSnapshotService suppliers; @MockBean private FirstlegDocumentNumberGenerator documentNumbers; + @MockBean private SmartLoadingService smartLoadingService; @Resource private ContainerService service; @BeforeEach void setUpWarehouse() { when(documentNumbers.next("CB")).thenReturn("CB-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 -> activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓")); when(suppliers.trucking(202L, "拖车公司A")) @@ -77,7 +82,10 @@ class ContainerServiceTest { assertThat(saved.getWarehouseId()).isEqualTo(101L); assertThat(saved.getWarehouseName()).isEqualTo("上海中转仓"); 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 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index 9a7c18f29..82ae7df19 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -85,11 +85,9 @@ class LoadingListServiceTest { @Test void shouldOnlyUpdateTransportFieldsWithOptimisticLockAndHistory() { AggregateRow before = row("EDITING", 3); - before.setPreloadPushedAt(java.time.LocalDateTime.now()); before.setContainerNo("MSCU6639870"); before.setVoyageNo("001E"); AggregateRow after = row("EDITING", 4); - after.setPreloadPushedAt(before.getPreloadPushedAt()); after.setContainerNo("CSQU3054383"); after.setVoyageNo("023E"); when(repository.history(anyString())).thenReturn(null); @@ -112,7 +110,6 @@ class LoadingListServiceTest { @Test void shouldNotWritePlanOrHistoryWhenTransportFieldsAreUnchanged() { AggregateRow before = row("EDITING", 3); - before.setPreloadPushedAt(java.time.LocalDateTime.now()); before.setContainerNo("MSCU6639870"); before.setVoyageNo("023E"); when(repository.aggregate(10L)).thenReturn(before); @@ -132,10 +129,8 @@ class LoadingListServiceTest { @Test void pushedEditingPreloadCanCorrectExistingContainerNumberBeforePdaClaim() { AggregateRow before = row("EDITING", 3); - before.setPreloadPushedAt(java.time.LocalDateTime.now()); before.setContainerNo("MSCU6639870"); AggregateRow after = row("EDITING", 4); - after.setPreloadPushedAt(before.getPreloadPushedAt()); after.setContainerNo("CSQU3054383"); when(repository.aggregate(10L)).thenReturn(before, after); when(repository.updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator")).thenReturn(1); @@ -149,18 +144,18 @@ class LoadingListServiceTest { } @Test - void editingPlanCannotReceiveTransportBeforePush() { + void editingPlanCanCorrectTransportWithoutPushStep() { AggregateRow before = row("EDITING", 3); 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", - new TransportUpdateRequest("CSQU3054383", null, 3, "纠错柜号"))) - .isInstanceOf(BizException.class).hasMessageContaining("已推送"); + service.updateTransport(10L, 100L, 9L, "operator", "editing-key", + new TransportUpdateRequest("CSQU3054383", null, 3, "纠错柜号")); - verify(repository, never()).updateTransport(eq(10L), eq(3), anyString(), - org.mockito.ArgumentMatchers.isNull(), eq(9L), anyString()); - verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString()); + verify(repository).updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator"); } @Test @@ -179,7 +174,6 @@ class LoadingListServiceTest { @Test void shouldRejectBlankContainerNumberInsteadOfClearingAuthorityField() { AggregateRow before = row("EDITING", 3); - before.setPreloadPushedAt(java.time.LocalDateTime.now()); before.setContainerNo("MSCU6639870"); when(repository.aggregate(10L)).thenReturn(before); @@ -195,7 +189,6 @@ class LoadingListServiceTest { @Test void unclaimedPdaTaskStillAllowsCorrectionButClaimOrPhysicalWorkRejectsIt() { AggregateRow before = row("EDITING", 3); - before.setPreloadPushedAt(java.time.LocalDateTime.now()); before.setContainerNo("MSCU6639870"); when(repository.aggregate(10L)).thenReturn(before); when(repository.hasPdaWorkStarted(10L)).thenReturn(true); @@ -210,12 +203,9 @@ class LoadingListServiceTest { @Test void loadingPlanRejectsTransportUpdateButPushedEditingPlanAllowsFirstContainerFill() { AggregateRow loading = row("LOADING", 3); - loading.setPreloadPushedAt(java.time.LocalDateTime.now()); loading.setContainerNo("MSCU6639870"); AggregateRow missing = row("EDITING", 3); - missing.setPreloadPushedAt(java.time.LocalDateTime.now()); AggregateRow afterFill = row("EDITING", 4); - afterFill.setPreloadPushedAt(missing.getPreloadPushedAt()); afterFill.setContainerNo("CSQU3054383"); when(repository.aggregate(10L)).thenReturn(loading, missing, afterFill); when(repository.fillInitialContainerNo(10L, 3, "CSQU3054383", null, 9L, "operator")) @@ -236,9 +226,7 @@ class LoadingListServiceTest { @Test void loadingPlanWithClaimedAndPickedPdaTaskStillAllowsInitialContainerFill() { AggregateRow before = row("LOADING", 7); - before.setPreloadPushedAt(java.time.LocalDateTime.now()); AggregateRow after = row("LOADING", 8); - after.setPreloadPushedAt(before.getPreloadPushedAt()); after.setContainerNo("CSQU3054383"); when(repository.aggregate(10L)).thenReturn(before, after); when(repository.hasPdaWorkStarted(10L)).thenReturn(true); @@ -260,7 +248,6 @@ class LoadingListServiceTest { @Test void correctingExistingContainerNumberStillRequiresReason() { AggregateRow before = row("EDITING", 3); - before.setPreloadPushedAt(java.time.LocalDateTime.now()); before.setContainerNo("MSCU6639870"); when(repository.aggregate(10L)).thenReturn(before); @@ -274,7 +261,6 @@ class LoadingListServiceTest { @Test void rejectsGlobalUniqueConflictIncludingConcurrentConstraintRace() { AggregateRow before = row("EDITING", 3); - before.setPreloadPushedAt(java.time.LocalDateTime.now()); before.setContainerNo("MSCU6639870"); when(repository.aggregate(10L)).thenReturn(before); when(containerRepository.containerNoExists("CSQU3054383", 30L)).thenReturn(true); @@ -329,10 +315,8 @@ class LoadingListServiceTest { @Test void shouldReplaySameTransportRequestWithoutSecondMutation() { AggregateRow before = row("EDITING", 3); - before.setPreloadPushedAt(java.time.LocalDateTime.now()); before.setContainerNo("MSCU6639870"); AggregateRow after = row("EDITING", 4); - after.setPreloadPushedAt(before.getPreloadPushedAt()); after.setContainerNo("CSQU3054383"); after.setVoyageNo("023E"); when(repository.history(anyString())).thenReturn(null); @@ -361,7 +345,6 @@ class LoadingListServiceTest { @Test void reusedTransportKeyWithDifferentPayloadFailsBeforeMutation() { AggregateRow before = row("EDITING", 3); - before.setPreloadPushedAt(java.time.LocalDateTime.now()); before.setContainerNo("MSCU6639870"); HistoryRow replay = new HistoryRow(); replay.setAfterSnapshot("{\"requestHash\":\"different\"}"); @@ -765,9 +748,7 @@ class LoadingListServiceTest { @Test void confirmsEveryLinkedPreloadAndCreatesSinglePdaTaskAtomically() { AggregateRow before = row("EDITING", 3); - before.setPreloadPushedAt(java.time.LocalDateTime.now()); AggregateRow after = row("EDITING", 4); - after.setPreloadPushedAt(before.getPreloadPushedAt()); List links = List.of( new Link(1L, 101L, 201L, 301L, "PENDING", 2, 10L, 30L, "MSCU-001", "EDITING", 3, 4, null), @@ -850,7 +831,6 @@ class LoadingListServiceTest { @Test void rejectsNewConfirmationKeyAfterPlanWasAlreadyConfirmed() { AggregateRow confirmed = row("EDITING", 4); - confirmed.setPreloadPushedAt(java.time.LocalDateTime.now()); when(repository.history(anyString())).thenReturn(null); when(repository.aggregate(10L)).thenReturn(confirmed); when(loadingPickService.requirePreloadConfirmationReady(10L)) @@ -872,7 +852,6 @@ class LoadingListServiceTest { @Test void confirmationGateFailureLeavesPlanAndEveryPreloadUntouched() { AggregateRow before = row("EDITING", 3); - before.setPreloadPushedAt(java.time.LocalDateTime.now()); when(repository.history(anyString())).thenReturn(null); when(repository.aggregate(10L)).thenReturn(before); when(loadingPickService.requirePreloadConfirmationReady(10L)) @@ -904,7 +883,7 @@ class LoadingListServiceTest { private static PreloadReadiness readiness(String confirmationStatus, boolean canConfirm, 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, readyForPda, List.of()); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index c2eb225b3..18ac003b2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -9,7 +9,6 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.AdjustRequest; import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import com.baiyue.firstleg.dto.SmartLoadingDtos.DeleteRequest; 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.WarehouseRemarkRequest; import com.baiyue.firstleg.entity.ContainerBookingEntity; @@ -403,29 +402,6 @@ class SmartLoadingServiceTest { 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 void removesEveryGroupMemberAndUnlocksOnlyAfterAllRowsAreRemoved() { LoadingPlanEntity plan = plan("EDITING", 0); @@ -548,7 +524,7 @@ class SmartLoadingServiceTest { when(repository.planByBooking(99L)).thenReturn(plan); 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); verify(repository, never()).insert(any(LoadingPlanEntity.class)); @@ -565,7 +541,7 @@ class SmartLoadingServiceTest { }).when(repository).insert(any(LoadingPlanEntity.class)); 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"); } @@ -1076,205 +1052,6 @@ class SmartLoadingServiceTest { 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 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() { LoadingPlanEntity plan = plan("EDITING", 0); when(repository.plan(1)).thenReturn(plan, plan("EDITING", 1)); diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 84a547c04..ea94cfff9 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -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 'excluding bookings that already have a non-terminal truck appointment' '拖车创建候选过滤' 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 $adminAgents 'PDA 最终完成前必须补齐' 'Admin 真实柜号最终门禁' Assert-Contains $pdaAgents '当前 `taskId + warehouseId` 走任务专用 API' '国内 PDA 柜级证据入口'