From ed5b9fe77f51c46527d23053e8f9dd313d80f25b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 9 Aug 2026 11:21:38 +0800 Subject: [PATCH] feat(firstleg): converge phase3 loading models and pda --- deploy/sql/头程数据库统一增量-MySQL8.sql | 156 +++++++++++++- .../PlatformLoadingPdaController.java | 48 ++++- .../baiyue/firstleg/dto/LoadingPickDtos.java | 37 +++- .../entity/LoadingPickAssignmentEntity.java | 23 ++ .../entity/LoadingPickOperationEntity.java | 23 ++ .../entity/LoadingPickReviewEntity.java | 22 ++ .../PlatformLoadingModelBridgeController.java | 52 +++++ .../bridge/dto/LoadingModelBridgeDtos.java | 29 +++ .../entity/PreloadLoadingPlanLinkEntity.java | 26 +++ .../mapper/LoadingModelBridgeMapper.java | 73 +++++++ .../LoadingModelBridgeRepository.java | 54 +++++ .../service/LoadingModelBridgeService.java | 119 +++++++++++ .../mapper/InboundLoadingPickTaskMapper.java | 12 ++ .../service/LoadingDeliveryService.java | 54 +++-- .../mapper/LoadingPickAssignmentMapper.java | 26 +++ .../mapper/LoadingPickItemMapper.java | 57 +++++ .../mapper/LoadingPickOperationMapper.java | 9 + .../mapper/LoadingPickReviewMapper.java | 16 ++ .../mapper/LoadingPickTaskMapper.java | 8 + .../repository/LoadingPickRepository.java | 98 +++++++++ .../firstleg/service/LoadingPickService.java | 198 ++++++++++++++++-- .../split/service/SplitPreloadService.java | 4 + .../db/phase3-loading-model-bridge-schema.sql | 65 ++++++ .../db/phase3-pda-convergence-schema.sql | 133 ++++++++++++ .../openapi/phase3-loading-delivery-v1.yaml | 8 +- .../phase3-loading-model-bridge-v1.yaml | 58 +++++ .../openapi/phase3-pda-convergence-v1.yaml | 87 ++++++++ .../PdaConvergenceOpenApiContractTest.java | 27 +++ ...tformLoadingPdaControllerContractTest.java | 30 +++ .../LoadingModelBridgeContractTest.java | 35 ++++ .../LoadingModelBridgeServiceTest.java | 74 +++++++ .../LoadingDeliveryOpenApiContractTest.java | 4 +- .../PdaConvergenceSqlContractTest.java | 54 +++++ .../service/LoadingDeliveryServiceTest.java | 32 ++- .../Phase3FinalUnifiedSqlContractTest.java | 9 +- .../service/LoadingPickServiceTest.java | 52 ++++- .../service/SplitPreloadServiceTest.java | 4 + .../admin-test-env-modules/ocean-bookings.mjs | 5 +- .../phase3-container-loading.mjs | 33 ++- .../admin-test-env-modules/phase3-customs.mjs | 9 +- .../phase3-loading-model-bridge.mjs | 55 ++++- .../admin-test-env-modules/phase3-loading.mjs | 122 ++++++++++- .../phase3-outbound-dispatch.mjs | 6 +- .../phase3-pda-convergence.mjs | 65 +++++- .../phase3-port-schedules.mjs | 5 +- .../service-center-flow.mjs | 8 +- 46 files changed, 2028 insertions(+), 96 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickAssignmentEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickOperationEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickReviewEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/controller/PlatformLoadingModelBridgeController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/dto/LoadingModelBridgeDtos.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/entity/PreloadLoadingPlanLinkEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/repository/LoadingModelBridgeRepository.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeService.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickAssignmentMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickOperationMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickReviewMapper.java create mode 100644 firstleg/src/main/resources/db/phase3-loading-model-bridge-schema.sql create mode 100644 firstleg/src/main/resources/db/phase3-pda-convergence-schema.sql create mode 100644 firstleg/src/main/resources/openapi/phase3-loading-model-bridge-v1.yaml create mode 100644 firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/LoadingModelBridgeContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/loading/repository/PdaConvergenceSqlContractTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 6796dde57..a4b5642bd 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -8389,6 +8389,141 @@ SET @ddl = IF( 'SELECT 1'); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +/* Phase 3: link inbound preload plans to one or more container loading plans. */ +CREATE TABLE IF NOT EXISTS fl_preload_loading_plan_link ( + id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + inbound_id BIGINT UNSIGNED NOT NULL, + preload_plan_id BIGINT UNSIGNED NOT NULL, + loading_plan_id BIGINT UNSIGNED NOT NULL, + idempotency_key VARCHAR(128) NOT NULL, + request_hash CHAR(64) NOT NULL, + created_by BIGINT UNSIGNED NOT NULL, + created_by_name VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_preload_loading_pair (tenant_id, inbound_id, loading_plan_id), + UNIQUE KEY uk_preload_loading_idempotency (created_by, idempotency_key), + KEY idx_preload_loading_preload (preload_plan_id, loading_plan_id), + KEY idx_preload_loading_plan (loading_plan_id, tenant_id, inbound_id), + CONSTRAINT fk_preload_loading_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_preload_loading_preload FOREIGN KEY (preload_plan_id) REFERENCES fl_preload_plan (id), + CONSTRAINT fk_preload_loading_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), + CONSTRAINT chk_preload_loading_tenant CHECK (tenant_id > 0) +) ENGINE=InnoDB COMMENT='入库单预装与柜次装柜计划多对多关联'; + +INSERT IGNORE INTO fl_preload_loading_plan_link + (id, tenant_id, inbound_id, preload_plan_id, loading_plan_id, idempotency_key, + request_hash, created_by, created_by_name, created_at) +SELECT CAST(CONV(SUBSTRING(SHA2(CONCAT('PRELOAD_LOADING|', item.account_id, '|', item.inbound_id, + '|', item.loading_plan_id), 256), 1, 15), 16, 10) AS UNSIGNED), + item.account_id, item.inbound_id, preload.id, item.loading_plan_id, + CONCAT('BACKFILL-', item.loading_plan_id, '-', item.inbound_id), + SHA2(CONCAT('BACKFILL|', item.account_id, '|', item.inbound_id, '|', item.loading_plan_id), 256), + preload.created_by, preload.created_by_name, LEAST(preload.created_at, plan.created_at) + FROM (SELECT DISTINCT account_id, inbound_id, loading_plan_id + FROM fl_loading_plan_item WHERE deleted = 0) item + JOIN fl_preload_plan preload ON preload.tenant_id = item.account_id + AND preload.inbound_id = item.inbound_id + JOIN fl_loading_plan plan ON plan.id = item.loading_plan_id; + +/* Phase 3: PDA assignment, immutable task operations and review records. */ +CREATE TABLE IF NOT EXISTS fl_loading_pick_assignment ( + pick_task_id BIGINT UNSIGNED NOT NULL, + assignee_id BIGINT UNSIGNED NOT NULL, + assignee_name VARCHAR(64) NOT NULL, + assigned_by BIGINT UNSIGNED NOT NULL, + assigned_by_name VARCHAR(64) NULL, + assignment_type VARCHAR(16) NOT NULL, + version INT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (pick_task_id), + KEY idx_loading_pick_assignee (assignee_id, updated_at), + CONSTRAINT fk_loading_pick_assignment_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), + CONSTRAINT chk_loading_pick_assignment_type CHECK (assignment_type IN ('CLAIMED', 'ASSIGNED')) +) ENGINE=InnoDB COMMENT='柜次PDA任务领取与作业员分配'; + +CREATE TABLE IF NOT EXISTS fl_loading_pick_review ( + pick_task_id BIGINT UNSIGNED NOT NULL, + review_status VARCHAR(16) NOT NULL DEFAULT 'APPROVED', + reviewed_by BIGINT UNSIGNED NOT NULL, + reviewed_by_name VARCHAR(64) NULL, + remark VARCHAR(500) NULL, + version INT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (pick_task_id), + KEY idx_loading_pick_reviewer (reviewed_by, created_at), + CONSTRAINT fk_loading_pick_review_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), + CONSTRAINT chk_loading_pick_review_status CHECK (review_status = 'APPROVED') +) ENGINE=InnoDB COMMENT='柜次PDA任务完成复核'; + +CREATE TABLE IF NOT EXISTS fl_loading_pick_operation ( + id BIGINT UNSIGNED NOT NULL, + pick_task_id BIGINT UNSIGNED NOT NULL, + operation_id VARCHAR(128) NOT NULL, + request_hash CHAR(64) NOT NULL, + action_type VARCHAR(16) NOT NULL, + operator_id BIGINT UNSIGNED NOT NULL, + operator_name VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_loading_pick_operation_id (operation_id), + KEY idx_loading_pick_operation_task (pick_task_id, created_at), + CONSTRAINT fk_loading_pick_operation_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), + CONSTRAINT chk_loading_pick_operation_action CHECK (action_type IN ('CLAIM', 'ASSIGN', 'REVIEW')) +) ENGINE=InnoDB COMMENT='柜次PDA任务级幂等审计'; + +DROP TRIGGER IF EXISTS trg_loading_pick_review_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_pick_review_before_update BEFORE UPDATE ON fl_loading_pick_review +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PDA复核记录不可修改'; END$$ +DELIMITER ; +DROP TRIGGER IF EXISTS trg_loading_pick_review_before_delete; +DELIMITER $$ +CREATE TRIGGER trg_loading_pick_review_before_delete BEFORE DELETE ON fl_loading_pick_review +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PDA复核记录不可删除'; END$$ +DELIMITER ; +DROP TRIGGER IF EXISTS trg_loading_pick_operation_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_pick_operation_before_update BEFORE UPDATE ON fl_loading_pick_operation +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PDA任务操作历史不可修改'; END$$ +DELIMITER ; +DROP TRIGGER IF EXISTS trg_loading_pick_operation_before_delete; +DELIMITER $$ +CREATE TRIGGER trg_loading_pick_operation_before_delete BEFORE DELETE ON fl_loading_pick_operation +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PDA任务操作历史不可删除'; END$$ +DELIMITER ; + +USE baiyue_iam; +DROP PROCEDURE IF EXISTS assert_phase3_pda_permission_ids; +DELIMITER $$ +CREATE PROCEDURE assert_phase3_pda_permission_ids() +BEGIN + IF EXISTS (SELECT 1 FROM iam_permission WHERE id BETWEEN 5102190 AND 5102193 + AND permission_code NOT IN ('platform:firstleg:pda:view','platform:firstleg:pda:assign', + 'platform:firstleg:pda:work','platform:firstleg:pda:review')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PDA权限ID已被其他资源占用'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase3_pda_permission_ids(); +DROP PROCEDURE IF EXISTS assert_phase3_pda_permission_ids; +INSERT INTO iam_permission + (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) +VALUES + (5102190,0,'PLATFORM','MENU',5102094,'platform:firstleg:pda:view','PDA装柜拣货','/first-leg/pda-loading','GET',10,'ACTIVE',1,0), + (5102191,0,'PLATFORM','BUTTON',5102190,'platform:firstleg:pda:assign','分配PDA任务',NULL,'POST',20,'ACTIVE',1,0), + (5102192,0,'PLATFORM','BUTTON',5102190,'platform:firstleg:pda:work','执行PDA拣货',NULL,'POST',30,'ACTIVE',1,0), + (5102193,0,'PLATFORM','BUTTON',5102190,'platform:firstleg:pda:review','复核PDA任务',NULL,'POST',40,'ACTIVE',1,0) +ON DUPLICATE KEY UPDATE permission_code=VALUES(permission_code),permission_name=VALUES(permission_name), + parent_id=VALUES(parent_id),route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),status='ACTIVE',built_in=1,deleted=0; +INSERT IGNORE INTO iam_role_permission(role_id,permission_id,tenant_id) +SELECT r.id,p.id,0 FROM iam_role r JOIN iam_permission p ON p.id BETWEEN 5102190 AND 5102193 +WHERE r.tenant_id=0 AND r.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') AND p.tenant_id=0 AND p.deleted=0; +USE baiyue_firstleg; + DROP PROCEDURE IF EXISTS assert_phase3_final_schema; DELIMITER $$ CREATE PROCEDURE assert_phase3_final_schema() @@ -8398,7 +8533,9 @@ BEGIN 'fl_outbound_dispatch','fl_outbound_dispatch_history','fl_outbound_dispatch_operation', 'fl_customs_checklist_version','fl_customs_case','fl_customs_document','fl_customs_supplement_request', 'fl_customs_submission_batch','fl_customs_receipt','fl_customs_history','fl_customs_operation', - 'fl_port_schedule','fl_port_schedule_history','fl_port_schedule_operation')) <> 14 THEN + 'fl_port_schedule','fl_port_schedule_history','fl_port_schedule_operation', + 'fl_preload_loading_plan_link','fl_loading_pick_assignment','fl_loading_pick_review', + 'fl_loading_pick_operation')) <> 18 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期出仓、报关或船期表未完整创建'; END IF; IF (SELECT COUNT(*) FROM information_schema.statistics @@ -8422,13 +8559,17 @@ BEGIN 'fk_customs_document_case','fk_customs_supplement_case','fk_customs_batch_case', 'fk_customs_receipt_case','fk_customs_receipt_batch','fk_customs_history_case', 'fk_port_schedule_booking','fk_port_schedule_container','fk_port_schedule_history', - 'fk_port_schedule_operation')) <> 13 THEN + 'fk_port_schedule_operation','fk_preload_loading_inbound','fk_preload_loading_preload', + 'fk_preload_loading_plan','fk_loading_pick_assignment_task','fk_loading_pick_review_task', + 'fk_loading_pick_operation_task')) <> 19 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期关键外键未完整创建'; END IF; IF (SELECT COUNT(*) FROM information_schema.triggers WHERE trigger_schema='baiyue_firstleg' AND trigger_name IN ( 'trg_port_schedule_before_insert','trg_port_schedule_before_update', - 'trg_port_schedule_history_before_update','trg_port_schedule_history_before_delete')) <> 4 THEN + 'trg_port_schedule_history_before_update','trg_port_schedule_history_before_delete', + 'trg_loading_pick_review_before_update','trg_loading_pick_review_before_delete', + 'trg_loading_pick_operation_before_update','trg_loading_pick_operation_before_delete')) <> 8 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:港务船期归属或历史保护触发器未完整创建'; END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' @@ -8439,6 +8580,10 @@ BEGIN AND id BETWEEN 5102180 AND 5102183 AND permission_code LIKE 'platform:firstleg:port-schedule:%') <> 4 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期出仓、报关或船期权限未完整写入'; END IF; + IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' + AND id BETWEEN 5102190 AND 5102193 AND permission_code LIKE 'platform:firstleg:pda:%') <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:PDA权限未完整写入'; + END IF; IF (SELECT COUNT(DISTINCT CONCAT(r.role_code,'|',p.permission_code)) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id @@ -8446,7 +8591,8 @@ BEGIN WHERE r.tenant_id=0 AND r.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') AND (p.permission_code LIKE 'platform:firstleg:outbound-dispatch:%' OR p.permission_code LIKE 'platform:firstleg:customs:%' - OR p.permission_code LIKE 'platform:firstleg:port-schedule:%')) <> 30 THEN + OR p.permission_code LIKE 'platform:firstleg:port-schedule:%' + OR p.permission_code LIKE 'platform:firstleg:pda:%')) <> 38 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期出仓、报关或船期角色授权未完整写入'; END IF; END$$ @@ -8456,4 +8602,4 @@ DROP PROCEDURE IF EXISTS assert_phase3_final_schema; -- WAIT_SPLIT 是二期当前有效终点;统一迁移不得改写主表或历史状态。 SELECT 'PASS' AS migration_status, - '统一增量全部执行完成;箱号映射、单箱测量、三期订舱、拖车预约、出仓、费用、公共资源及可靠审计结构校验通过' AS message; + '统一增量全部执行完成;箱号映射、单箱测量、三期订舱、拖车预约、出仓、报关、船期、装柜模型桥接、PDA收敛、费用、公共资源及可靠审计结构校验通过' AS message; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java index dbe9c39e8..26bc461fa 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java @@ -5,6 +5,11 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.Operation; +import java.util.List; import com.baiyue.firstleg.dto.LoadingPickDtos.Task; import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.LoadingPickService; @@ -31,7 +36,7 @@ public class PlatformLoadingPdaController { private LoadingPickService service; @GetMapping("/tasks") - @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") + @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String containerNo, @RequestParam @Positive long warehouseId) { @@ -39,8 +44,26 @@ public class PlatformLoadingPdaController { return ApiResponse.success(service.task(containerNo, warehouseId)); } + @PostMapping("/tasks/{taskId}/claim") + @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") + public ApiResponse claim(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @Valid @RequestBody ClaimRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.claim(taskId, request, user.userId(), user.username())); + } + + @PostMapping("/tasks/{taskId}/assign") + @PreAuthorize("hasAuthority('platform:firstleg:pda:assign')") + public ApiResponse assign(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @Valid @RequestBody AssignRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.assign(taskId, request, user.userId(), user.username())); + } + @PostMapping("/tasks/{taskId}/items/{itemId}/pick") - @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") + @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long taskId, @PathVariable @Positive long itemId, @@ -50,7 +73,7 @@ public class PlatformLoadingPdaController { } @PostMapping("/tasks/{taskId}/items/{itemId}/partial") - @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") + @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") public ApiResponse partial(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long taskId, @PathVariable @Positive long itemId, @@ -60,7 +83,7 @@ public class PlatformLoadingPdaController { } @PostMapping("/tasks/{taskId}/items/{itemId}/skip") - @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") + @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") public ApiResponse skip(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long taskId, @PathVariable @Positive long itemId, @@ -68,4 +91,21 @@ public class PlatformLoadingPdaController { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.skip(taskId, itemId, request, user.userId(), user.username())); } + + @PostMapping("/tasks/{taskId}/review") + @PreAuthorize("hasAuthority('platform:firstleg:pda:review')") + public ApiResponse review(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @Valid @RequestBody ReviewRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.review(taskId, request, user.userId(), user.username())); + } + + @GetMapping("/tasks/{taskId}/operations") + @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") + public ApiResponse> operations(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.operations(taskId)); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java index 62c14bfac..bfa426126 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java @@ -7,6 +7,7 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import java.util.List; +import java.time.LocalDateTime; /** PDA 装柜拣货接口模型。 */ public final class LoadingPickDtos { @@ -41,12 +42,46 @@ public final class LoadingPickDtos { ) { } + public record ClaimRequest( + @NotNull @Min(0) Integer expectedAssignmentVersion, + @NotBlank @Size(max = 128) String operationId + ) { + } + + public record AssignRequest( + @NotNull @Min(1) Long assigneeId, + @NotBlank @Size(max = 64) String assigneeName, + @NotNull @Min(0) Integer expectedAssignmentVersion, + @NotBlank @Size(max = 128) String operationId + ) { + } + + public record ReviewRequest( + @NotNull @Min(0) Integer expectedTaskVersion, + @NotBlank @Size(max = 128) String operationId, + @Size(max = 500) String remark + ) { + } + public record PickItem(Long id, Long loadingPlanItemId, Long inboundId, Long inboundDetailId, Integer plannedPackageCount, Integer pickedPackageCount, String itemStatus, Integer version) { } + public record Assignment(Long assigneeId, String assigneeName, Long assignedBy, + String assignedByName, String assignmentType, Integer version) { + } + + public record Review(String status, Long reviewedBy, String reviewedByName, + String remark, Integer version) { + } + + public record Operation(Long id, Long pickTaskId, String operationId, String actionType, + Long operatorId, String operatorName, LocalDateTime createdAt) { + } + public record Task(Long id, Long loadingPlanId, String containerNo, String taskStatus, - Integer version, List items) { + String workflowStatus, Integer version, Assignment assignment, + Review review, List items) { } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickAssignmentEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickAssignmentEntity.java new file mode 100644 index 000000000..3319fcd90 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickAssignmentEntity.java @@ -0,0 +1,23 @@ +package com.baiyue.firstleg.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("fl_loading_pick_assignment") +public class LoadingPickAssignmentEntity { + @TableId private Long pickTaskId; + private Long assigneeId; + private String assigneeName; + private Long assignedBy; + private String assignedByName; + private String assignmentType; + private Integer version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickOperationEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickOperationEntity.java new file mode 100644 index 000000000..e12d0043d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickOperationEntity.java @@ -0,0 +1,23 @@ +package com.baiyue.firstleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("fl_loading_pick_operation") +public class LoadingPickOperationEntity { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private Long pickTaskId; + private String operationId; + private String requestHash; + private String actionType; + private Long operatorId; + private String operatorName; + private LocalDateTime createdAt; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickReviewEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickReviewEntity.java new file mode 100644 index 000000000..fc66b7e78 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickReviewEntity.java @@ -0,0 +1,22 @@ +package com.baiyue.firstleg.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("fl_loading_pick_review") +public class LoadingPickReviewEntity { + @TableId private Long pickTaskId; + private String reviewStatus; + private Long reviewedBy; + private String reviewedByName; + private String remark; + private Integer version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/controller/PlatformLoadingModelBridgeController.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/controller/PlatformLoadingModelBridgeController.java new file mode 100644 index 000000000..957db7d3d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/controller/PlatformLoadingModelBridgeController.java @@ -0,0 +1,52 @@ +package com.baiyue.firstleg.loading.bridge.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.BindRequest; +import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link; +import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Validated +@RestController +public class PlatformLoadingModelBridgeController { + @Resource private LoadingModelBridgeService service; + + @PostMapping("/api/platform/v1/inbounds/{inboundId}/preload/loading-plans/{loadingPlanId}") + @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") + public ApiResponse bind( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam @Positive long tenantId, + @PathVariable @Positive long inboundId, @PathVariable @Positive long loadingPlanId, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody BindRequest request) { + FirstlegScopeGuard.platform(user, tenantId); + return ApiResponse.success(service.bind(tenantId, inboundId, loadingPlanId, user.userId(), + user.username(), idempotencyKey, request)); + } + + @GetMapping("/api/platform/v1/inbounds/{inboundId}/preload/loading-plans") + @PreAuthorize("hasAuthority('firstleg:inbound:preload-view')") + public ApiResponse> byInbound( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam @Positive long tenantId, + @PathVariable @Positive long inboundId) { + FirstlegScopeGuard.platform(user, tenantId); + return ApiResponse.success(service.byInbound(tenantId, inboundId)); + } + + @GetMapping("/api/platform/v1/first-leg/smart-loading/plans/{loadingPlanId}/preloads") + @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") + public ApiResponse> byLoadingPlan( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long loadingPlanId) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.byLoadingPlan(loadingPlanId)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/dto/LoadingModelBridgeDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/dto/LoadingModelBridgeDtos.java new file mode 100644 index 000000000..be0cefbef --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/dto/LoadingModelBridgeDtos.java @@ -0,0 +1,29 @@ +package com.baiyue.firstleg.loading.bridge.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDateTime; + +public final class LoadingModelBridgeDtos { + private LoadingModelBridgeDtos() {} + + public record BindRequest( + @NotNull @Min(0) Integer expectedPreloadVersion, + @NotNull @Min(0) Integer expectedLoadingPlanVersion) {} + + public record Link( + Long id, + Long tenantId, + Long inboundId, + Long preloadPlanId, + String preloadStatus, + Integer preloadVersion, + Long loadingPlanId, + Long containerBookingId, + String containerNo, + String loadingPlanStatus, + Integer loadingPlanVersion, + Integer allocatedPackageCount, + LocalDateTime linkedAt) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/entity/PreloadLoadingPlanLinkEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/entity/PreloadLoadingPlanLinkEntity.java new file mode 100644 index 000000000..f137d9e02 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/entity/PreloadLoadingPlanLinkEntity.java @@ -0,0 +1,26 @@ +package com.baiyue.firstleg.loading.bridge.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("fl_preload_loading_plan_link") +public class PreloadLoadingPlanLinkEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long tenantId; + private Long inboundId; + private Long preloadPlanId; + private Long loadingPlanId; + private String idempotencyKey; + private String requestHash; + private Long createdBy; + private String createdByName; + private LocalDateTime createdAt; +} 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 new file mode 100644 index 000000000..a24748e68 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java @@ -0,0 +1,73 @@ +package com.baiyue.firstleg.loading.bridge.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link; +import com.baiyue.firstleg.loading.bridge.entity.PreloadLoadingPlanLinkEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; + +@Mapper +public interface LoadingModelBridgeMapper extends BaseMapper { + @Select("SELECT id,tenant_id tenantId,inbound_id inboundId,status,version " + + "FROM fl_preload_plan WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId}") + PreloadSnapshot preload(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); + + @Select("SELECT p.id,p.container_booking_id containerBookingId,b.container_no containerNo," + + "p.plan_status status,p.version FROM fl_loading_plan p JOIN fl_container_booking b " + + "ON b.id=p.container_booking_id WHERE p.id=#{loadingPlanId}") + LoadingPlanSnapshot loadingPlan(@Param("loadingPlanId") long loadingPlanId); + + @Select("SELECT COALESCE(SUM(package_count),0) FROM fl_loading_plan_item " + + "WHERE loading_plan_id=#{loadingPlanId} AND account_id=#{tenantId} " + + "AND inbound_id=#{inboundId} AND deleted=0") + int allocatedPackages(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, + @Param("loadingPlanId") long loadingPlanId); + + @Update("UPDATE fl_preload_plan SET updated_by=#{operatorId},updated_by_name=#{operatorName}," + + "version=version+1 WHERE id=#{preloadPlanId} AND tenant_id=#{tenantId} " + + "AND inbound_id=#{inboundId} AND status='PENDING' AND version=#{expectedVersion}") + int touchPreload(@Param("preloadPlanId") long preloadPlanId, @Param("tenantId") long tenantId, + @Param("inboundId") long inboundId, @Param("expectedVersion") int expectedVersion, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + + @Update("UPDATE fl_loading_plan SET updated_by=#{operatorId},updated_by_name=#{operatorName}," + + "version=version+1 WHERE id=#{loadingPlanId} AND plan_status='EDITING' " + + "AND version=#{expectedVersion}") + int touchLoadingPlan(@Param("loadingPlanId") long loadingPlanId, + @Param("expectedVersion") int expectedVersion, + @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName); + + @Select(LINK_SELECT + " WHERE link.tenant_id=#{tenantId} AND link.inbound_id=#{inboundId}" + + " ORDER BY link.created_at,link.id") + List byInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); + + @Select(LINK_SELECT + " WHERE link.loading_plan_id=#{loadingPlanId}" + + " ORDER BY link.tenant_id,link.inbound_id,link.id") + List byLoadingPlan(@Param("loadingPlanId") long loadingPlanId); + + String LINK_SELECT = """ + SELECT link.id,link.tenant_id tenantId,link.inbound_id inboundId, + link.preload_plan_id preloadPlanId,preload.status preloadStatus, + preload.version preloadVersion,link.loading_plan_id loadingPlanId, + plan.container_booking_id containerBookingId,booking.container_no containerNo, + plan.plan_status loadingPlanStatus,plan.version loadingPlanVersion, + COALESCE((SELECT SUM(item.package_count) FROM fl_loading_plan_item item + WHERE item.loading_plan_id=link.loading_plan_id AND item.account_id=link.tenant_id + AND item.inbound_id=link.inbound_id AND item.deleted=0),0) allocatedPackageCount, + link.created_at linkedAt + FROM fl_preload_loading_plan_link link + JOIN fl_preload_plan preload ON preload.id=link.preload_plan_id + AND preload.tenant_id=link.tenant_id AND preload.inbound_id=link.inbound_id + JOIN fl_loading_plan plan ON plan.id=link.loading_plan_id + JOIN fl_container_booking booking ON booking.id=plan.container_booking_id + """; + + record PreloadSnapshot(Long id, Long tenantId, Long inboundId, String status, Integer version) {} + record LoadingPlanSnapshot(Long id, Long containerBookingId, String containerNo, + String status, Integer version) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/repository/LoadingModelBridgeRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/repository/LoadingModelBridgeRepository.java new file mode 100644 index 000000000..b00c4449b --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/repository/LoadingModelBridgeRepository.java @@ -0,0 +1,54 @@ +package com.baiyue.firstleg.loading.bridge.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link; +import com.baiyue.firstleg.loading.bridge.entity.PreloadLoadingPlanLinkEntity; +import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public class LoadingModelBridgeRepository { + @Resource private LoadingModelBridgeMapper mapper; + + public LoadingModelBridgeMapper.PreloadSnapshot preload(long tenantId, long inboundId) { + return mapper.preload(tenantId, inboundId); + } + + public LoadingModelBridgeMapper.LoadingPlanSnapshot loadingPlan(long loadingPlanId) { + return mapper.loadingPlan(loadingPlanId); + } + + public int allocatedPackages(long tenantId, long inboundId, long loadingPlanId) { + return mapper.allocatedPackages(tenantId, inboundId, loadingPlanId); + } + + public PreloadLoadingPlanLinkEntity byPair(long tenantId, long inboundId, long loadingPlanId) { + return mapper.selectOne(new LambdaQueryWrapper() + .eq(PreloadLoadingPlanLinkEntity::getTenantId, tenantId) + .eq(PreloadLoadingPlanLinkEntity::getInboundId, inboundId) + .eq(PreloadLoadingPlanLinkEntity::getLoadingPlanId, loadingPlanId)); + } + + public PreloadLoadingPlanLinkEntity byIdempotency(long operatorId, String key) { + return mapper.selectOne(new LambdaQueryWrapper() + .eq(PreloadLoadingPlanLinkEntity::getCreatedBy, operatorId) + .eq(PreloadLoadingPlanLinkEntity::getIdempotencyKey, key)); + } + + public void insert(PreloadLoadingPlanLinkEntity value) { mapper.insert(value); } + + public int touchPreload(long preloadPlanId, long tenantId, long inboundId, int version, + long operatorId, String operatorName) { + return mapper.touchPreload(preloadPlanId, tenantId, inboundId, version, operatorId, operatorName); + } + + public int touchLoadingPlan(long loadingPlanId, int version, long operatorId, String operatorName) { + return mapper.touchLoadingPlan(loadingPlanId, version, operatorId, operatorName); + } + + public List byInbound(long tenantId, long inboundId) { return mapper.byInbound(tenantId, inboundId); } + public List byLoadingPlan(long loadingPlanId) { return mapper.byLoadingPlan(loadingPlanId); } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeService.java new file mode 100644 index 000000000..f86fd38f0 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeService.java @@ -0,0 +1,119 @@ +package com.baiyue.firstleg.loading.bridge.service; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.BindRequest; +import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link; +import com.baiyue.firstleg.loading.bridge.entity.PreloadLoadingPlanLinkEntity; +import com.baiyue.firstleg.loading.bridge.repository.LoadingModelBridgeRepository; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +@Service +public class LoadingModelBridgeService { + @Resource private LoadingModelBridgeRepository repository; + + @Transactional + @BusinessAudit(event = "PRELOAD_LOADING_PLAN_BIND", resourceType = "LOADING_PLAN", + resourceId = "#loadingPlanId", description = "关联入库预装与柜次装柜计划") + public Link bind(long tenantId, long inboundId, long loadingPlanId, long operatorId, + String operatorName, String idempotencyKey, BindRequest request) { + String key = validateKey(idempotencyKey); + String requestHash = hash(tenantId + "|" + inboundId + "|" + loadingPlanId + "|" + + request.expectedPreloadVersion() + "|" + request.expectedLoadingPlanVersion()); + PreloadLoadingPlanLinkEntity replay = repository.byIdempotency(operatorId, key); + if (replay != null) { + if (!requestHash.equals(replay.getRequestHash())) throw conflict("Idempotency-Key已用于不同关联请求"); + return requireLink(tenantId, inboundId, loadingPlanId); + } + if (repository.byPair(tenantId, inboundId, loadingPlanId) != null) { + return requireLink(tenantId, inboundId, loadingPlanId); + } + + var preload = repository.preload(tenantId, inboundId); + if (preload == null) throw new BizException(ResultCode.NOT_FOUND, "入库单预装计划不存在"); + var loadingPlan = repository.loadingPlan(loadingPlanId); + if (loadingPlan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在"); + if (!"PENDING".equals(preload.status()) || !"EDITING".equals(loadingPlan.status())) { + throw conflict("仅待确认预装和编辑中柜次计划可以建立关联"); + } + if (repository.allocatedPackages(tenantId, inboundId, loadingPlanId) <= 0) { + throw conflict("柜次计划尚未分配当前入库单货物"); + } + + PreloadLoadingPlanLinkEntity link = new PreloadLoadingPlanLinkEntity(); + link.setTenantId(tenantId); link.setInboundId(inboundId); link.setPreloadPlanId(preload.id()); + link.setLoadingPlanId(loadingPlanId); link.setIdempotencyKey(key); link.setRequestHash(requestHash); + link.setCreatedBy(operatorId); link.setCreatedByName(operatorName); + try { + repository.insert(link); + } catch (DuplicateKeyException exception) { + throw conflict("相同预装与柜次关联正在处理中,请刷新后重试"); + } + if (repository.touchPreload(preload.id(), tenantId, inboundId, request.expectedPreloadVersion(), + operatorId, operatorName) != 1) throw concurrent(); + if (repository.touchLoadingPlan(loadingPlanId, request.expectedLoadingPlanVersion(), + operatorId, operatorName) != 1) throw concurrent(); + return requireLink(tenantId, inboundId, loadingPlanId); + } + + public List byInbound(long tenantId, long inboundId) { return repository.byInbound(tenantId, inboundId); } + public List byLoadingPlan(long loadingPlanId) { return repository.byLoadingPlan(loadingPlanId); } + + public void requireStarted(long tenantId, long inboundId) { + List links = requireLinks(tenantId, inboundId); + if (links.stream().anyMatch(link -> link.allocatedPackageCount() == null || link.allocatedPackageCount() <= 0 + || !Set.of("LOADING", "COMPLETED").contains(link.loadingPlanStatus()))) { + throw conflict("关联柜次尚未全部开始装柜"); + } + } + + public void requireCompleted(long tenantId, long inboundId) { + List links = requireLinks(tenantId, inboundId); + if (links.stream().anyMatch(link -> link.allocatedPackageCount() == null || link.allocatedPackageCount() <= 0 + || !"COMPLETED".equals(link.loadingPlanStatus()))) { + throw conflict("关联柜次尚未全部完成装柜"); + } + } + + private List requireLinks(long tenantId, long inboundId) { + List links = repository.byInbound(tenantId, inboundId); + if (links.isEmpty()) throw conflict("入库单尚未关联柜次装柜计划"); + return links; + } + + private Link requireLink(long tenantId, long inboundId, long loadingPlanId) { + return repository.byInbound(tenantId, inboundId).stream() + .filter(link -> Objects.equals(link.loadingPlanId(), loadingPlanId)).findFirst() + .orElseThrow(() -> new BizException(ResultCode.NOT_FOUND, "预装与柜次关联不存在")); + } + + private static String validateKey(String key) { + String value = key == null ? null : key.trim(); + if (!StringUtils.hasText(value) || value.length() > 128) { + throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key不能为空且长度不能超过128"); + } + return value; + } + + private static String hash(String value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (Exception impossible) { throw new IllegalStateException(impossible); } + } + + private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } + private static BizException concurrent() { return conflict("数据已被其他人修改,请刷新后重试"); } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/InboundLoadingPickTaskMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/InboundLoadingPickTaskMapper.java index 9e71d55d6..2f7a1d649 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/InboundLoadingPickTaskMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/InboundLoadingPickTaskMapper.java @@ -5,9 +5,21 @@ import com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Update; +import org.apache.ibatis.annotations.Insert; @Mapper public interface InboundLoadingPickTaskMapper extends BaseMapper { + @Insert("INSERT INTO fl_inbound_loading_pick_task " + + "(id,tenant_id,inbound_id,status,version,expected_count,picked_count,remark,updated_by,updated_by_name) " + + "VALUES (#{id},#{tenantId},#{inboundId},#{status},0,#{expectedCount},#{pickedCount},'柜次PDA自动汇总',#{operatorId},#{operatorName}) " + + "ON DUPLICATE KEY UPDATE status=VALUES(status),expected_count=VALUES(expected_count)," + + "picked_count=VALUES(picked_count),remark='柜次PDA自动汇总',updated_by=VALUES(updated_by)," + + "updated_by_name=VALUES(updated_by_name),version=version+1") + int upsertProjection(@Param("id") long id, @Param("tenantId") long tenantId, + @Param("inboundId") long inboundId, @Param("status") String status, + @Param("expectedCount") int expectedCount, @Param("pickedCount") int pickedCount, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + @Update("UPDATE fl_inbound_loading_pick_task SET status=#{toStatus}, updated_by=#{operatorId}, updated_by_name=#{operatorName}, " + "remark=COALESCE(#{remark}, remark), expected_count=COALESCE(#{expectedCount}, expected_count), " + "picked_count=COALESCE(#{pickedCount}, picked_count), version=version+1 WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} " diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java index 255e660a6..0f694a866 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java @@ -14,7 +14,9 @@ import com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity; import com.baiyue.firstleg.loading.domain.PickingStatus; import com.baiyue.firstleg.loading.dto.LoadingResponses.PickingResponse; import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.Snapshot; +import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; import com.baiyue.firstleg.loading.repository.LoadingRepository; +import com.baiyue.firstleg.repository.LoadingPickRepository; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import feign.FeignException; @@ -41,6 +43,8 @@ public class LoadingDeliveryService { private static final String COMPLETE_DELIVERY = "COMPLETE_DELIVERY"; @Resource private LoadingRepository repository; + @Resource private LoadingPickRepository loadingPicks; + @Resource private LoadingModelBridgeService loadingModelBridge; @Resource private InboundStatusHistoryMapper histories; @Resource private LoadingEvidenceFileClient files; @Resource private ObjectMapper objectMapper; @@ -100,10 +104,12 @@ public class LoadingDeliveryService { if (replay != null) return replay; Snapshot inbound = requireInbound(tenantId, inboundId); if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) { + loadingModelBridge.requireCompleted(tenantId, inboundId); return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, new LoadingActionResponse(inbound.processStatus(), evidenceResponse(tenantId, inboundId))); } if (!"LOADING".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜中"); + loadingModelBridge.requireCompleted(tenantId, inboundId); requireCompletedPicking(tenantId, inboundId); requireCompletionEvidence(tenantId, inboundId); if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent(); @@ -142,11 +148,8 @@ public class LoadingDeliveryService { LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "START_PICKING", key, hash); if (replay != null) return pickResponse(tenantId, inboundId); requireLoadingStage(tenantId, inboundId); - if (request.expectedCount() == null || request.expectedCount() <= 0 - || request.pickedCount() == null || request.pickedCount() < 0 - || request.pickedCount() > request.expectedCount()) { - throw new BizException(ResultCode.BAD_REQUEST, "拣货计划数量必须大于0且已拣数量不能超过计划数量"); - } + CanonicalPicking canonical = canonicalPicking(tenantId, inboundId); + requireMatchingPickingRequest(request, canonical); InboundLoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId); if (task == null) { if (request.expectedVersion() != 0) throw concurrent(); @@ -172,10 +175,9 @@ public class LoadingDeliveryService { LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "COMPLETE_PICKING", key, hash); if (replay != null) return pickResponse(tenantId, inboundId); requireLoadingStage(tenantId, inboundId); - if (request.expectedCount() == null || request.expectedCount() <= 0 - || request.pickedCount() == null || !request.expectedCount().equals(request.pickedCount())) { - throw new BizException(ResultCode.CONFLICT, "拣货复核数量必须与计划数量一致"); - } + CanonicalPicking canonical = canonicalPicking(tenantId, inboundId); + requireMatchingPickingRequest(request, canonical); + if (!canonical.completed()) throw new BizException(ResultCode.CONFLICT, "柜次PDA任务尚未完成复核"); InboundLoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId); if (task == null || !PickingStatus.IN_PROGRESS.equals(task.getStatus())) { throw new BizException(ResultCode.CONFLICT, task == null ? "拣货任务尚未开始" : "拣货任务不在作业中"); @@ -204,14 +206,40 @@ public class LoadingDeliveryService { } private void requireCompletedPicking(long tenantId, long inboundId) { - InboundLoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId); - if (task == null || !PickingStatus.COMPLETED.equals(task.getStatus()) - || task.getExpectedCount() == null || task.getExpectedCount() <= 0 - || !task.getExpectedCount().equals(task.getPickedCount())) { + if (!canonicalPicking(tenantId, inboundId).completed()) { throw new BizException(ResultCode.CONFLICT, "PDA拣货复核尚未完成"); } } + private CanonicalPicking canonicalPicking(long tenantId, long inboundId) { + var progress = loadingPicks.inboundProgress(tenantId, inboundId); + int expected = progress == null || progress.getExpectedCount() == null ? 0 : progress.getExpectedCount(); + int picked = progress == null || progress.getPickedCount() == null ? 0 : progress.getPickedCount(); + int tasks = progress == null || progress.getTaskCount() == null ? 0 : progress.getTaskCount(); + int completed = progress == null || progress.getCompletedTaskCount() == null + ? 0 : progress.getCompletedTaskCount(); + int unresolved = progress == null || progress.getUnresolvedCount() == null + ? 0 : progress.getUnresolvedCount(); + if (expected <= 0 || tasks <= 0) throw new BizException(ResultCode.CONFLICT, "未找到真实柜次PDA拣货任务"); + return new CanonicalPicking(expected, picked, tasks, completed, unresolved); + } + + private static void requireMatchingPickingRequest(PickingRequest request, CanonicalPicking canonical) { + if (request.expectedCount() == null || request.pickedCount() == null + || request.expectedCount() != canonical.expectedCount() + || request.pickedCount() != canonical.pickedCount()) { + throw new BizException(ResultCode.CONFLICT, "拣货数量与柜次PDA真实汇总不一致"); + } + } + + private record CanonicalPicking(int expectedCount, int pickedCount, int taskCount, + int completedTaskCount, int unresolvedCount) { + boolean completed() { + return expectedCount > 0 && expectedCount == pickedCount && taskCount > 0 + && completedTaskCount == taskCount && unresolvedCount == 0; + } + } + private void requireCompletionEvidence(long tenantId, long inboundId) { Map values = repository.listEvidence(tenantId, inboundId).stream() .collect(Collectors.toMap(LoadingEvidenceEntity::getEvidenceType, Function.identity(), (left, right) -> right)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickAssignmentMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickAssignmentMapper.java new file mode 100644 index 000000000..1b2a56037 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickAssignmentMapper.java @@ -0,0 +1,26 @@ +package com.baiyue.firstleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface LoadingPickAssignmentMapper extends BaseMapper { + @Insert("INSERT INTO fl_loading_pick_assignment " + + "(pick_task_id,assignee_id,assignee_name,assigned_by,assigned_by_name,assignment_type,version) " + + "VALUES (#{taskId},#{assigneeId},#{assigneeName},#{operatorId},#{operatorName},#{type},0)") + int create(@Param("taskId") long taskId, @Param("assigneeId") long assigneeId, + @Param("assigneeName") String assigneeName, @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName, @Param("type") String type); + + @Update("UPDATE fl_loading_pick_assignment SET assignee_id=#{assigneeId},assignee_name=#{assigneeName}," + + "assigned_by=#{operatorId},assigned_by_name=#{operatorName},assignment_type=#{type},version=version+1 " + + "WHERE pick_task_id=#{taskId} AND version=#{expectedVersion}") + int reassign(@Param("taskId") long taskId, @Param("assigneeId") long assigneeId, + @Param("assigneeName") String assigneeName, @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName, @Param("type") String type, + @Param("expectedVersion") int expectedVersion); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java index bd92dbbed..111bc28ca 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java @@ -9,6 +9,63 @@ import org.apache.ibatis.annotations.Update; @Mapper public interface LoadingPickItemMapper extends BaseMapper { + @Update("UPDATE fl_loading_plan_item SET package_count=#{pickedCount},updated_by=#{operatorId}," + + "updated_by_name=#{operatorName},version=version+1 WHERE id=#{planItemId} AND deleted=0 " + + "AND package_count=#{plannedCount}") + int settlePartialPlanItem(@Param("planItemId") long planItemId, + @Param("pickedCount") int pickedCount, + @Param("plannedCount") int plannedCount, + @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName); + + @Update("UPDATE fl_loading_pick_item SET planned_package_count=picked_package_count," + + "item_status='PICKED',version=version+1 WHERE id=#{itemId} AND item_status='PARTIAL' " + + "AND picked_package_count>0 AND picked_package_countplan_item.package_count THEN 1 ELSE 0 END) unresolvedCount + FROM fl_loading_plan_item plan_item + JOIN fl_inbound_order inbound_order ON inbound_order.id=plan_item.inbound_id + AND inbound_order.tenant_id=#{tenantId} AND inbound_order.deleted=0 + LEFT JOIN fl_loading_pick_item pick_item ON pick_item.loading_plan_item_id=plan_item.id + LEFT JOIN fl_loading_pick_task pick_task ON pick_task.id=pick_item.pick_task_id + WHERE plan_item.account_id=#{tenantId} AND plan_item.inbound_id=#{inboundId} AND plan_item.deleted=0 + """) + InboundProgress selectInboundProgress(@Param("tenantId") long tenantId, + @Param("inboundId") long inboundId); + + interface InboundProgress { + Integer getExpectedCount(); + Integer getPickedCount(); + Integer getTaskCount(); + Integer getCompletedTaskCount(); + Integer getUnresolvedCount(); + } + + @Select(""" + SELECT DISTINCT plan_item.account_id tenantId,plan_item.inbound_id inboundId + FROM fl_loading_pick_item pick_item + JOIN fl_loading_plan_item plan_item ON plan_item.id=pick_item.loading_plan_item_id + WHERE pick_item.pick_task_id=#{taskId} + """) + java.util.List selectInboundOwners(@Param("taskId") long taskId); + + record InboundOwner(Long tenantId, Long inboundId) {} + @Select(""" SELECT pick_item.* FROM fl_loading_pick_item pick_item diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickOperationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickOperationMapper.java new file mode 100644 index 000000000..943ab6389 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickOperationMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.firstleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.entity.LoadingPickOperationEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface LoadingPickOperationMapper extends BaseMapper { +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickReviewMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickReviewMapper.java new file mode 100644 index 000000000..35327c370 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickReviewMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.firstleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.entity.LoadingPickReviewEntity; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface LoadingPickReviewMapper extends BaseMapper { + @Insert("INSERT INTO fl_loading_pick_review " + + "(pick_task_id,review_status,reviewed_by,reviewed_by_name,remark,version) " + + "VALUES (#{taskId},'APPROVED',#{operatorId},#{operatorName},#{remark},0)") + int approve(@Param("taskId") long taskId, @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName, @Param("remark") String remark); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java index db7bf82a7..b64df8b23 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java @@ -5,9 +5,17 @@ import com.baiyue.firstleg.entity.LoadingPickTaskEntity; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; @Mapper public interface LoadingPickTaskMapper extends BaseMapper { + @Select("SELECT * FROM fl_loading_pick_task WHERE id=#{taskId} FOR UPDATE") + LoadingPickTaskEntity selectForUpdate(@Param("taskId") long taskId); + + @Update("UPDATE fl_loading_pick_task SET version=version+1 WHERE id=#{taskId} " + + "AND task_status='LOADING' AND version=#{expectedVersion}") + int touch(@Param("taskId") long taskId, @Param("expectedVersion") int expectedVersion); + @Select(""" SELECT DISTINCT task.* FROM fl_loading_pick_task task diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java index d404ba6fe..1092164d6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java @@ -8,6 +8,14 @@ import com.baiyue.firstleg.mapper.LoadingPickHistoryMapper; import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import com.baiyue.firstleg.mapper.LoadingPickItemMapper; import com.baiyue.firstleg.mapper.LoadingPickTaskMapper; +import com.baiyue.firstleg.mapper.LoadingPickAssignmentMapper; +import com.baiyue.firstleg.mapper.LoadingPickOperationMapper; +import com.baiyue.firstleg.mapper.LoadingPickReviewMapper; +import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity; +import com.baiyue.firstleg.entity.LoadingPickOperationEntity; +import com.baiyue.firstleg.entity.LoadingPickReviewEntity; +import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; @@ -22,11 +30,27 @@ public class LoadingPickRepository { private LoadingPickItemMapper items; @Resource private LoadingPickHistoryMapper histories; + @Resource + private LoadingPickAssignmentMapper assignments; + @Resource + private LoadingPickReviewMapper reviews; + @Resource + private LoadingPickOperationMapper operations; + @Resource + private InboundLoadingPickTaskMapper inboundProjections; public LoadingPickTaskEntity task(long taskId) { return tasks.selectById(taskId); } + public LoadingPickTaskEntity taskForUpdate(long taskId) { + return tasks.selectForUpdate(taskId); + } + + public int touchTask(long taskId, int expectedVersion) { + return tasks.touch(taskId, expectedVersion); + } + public LoadingPickTaskEntity taskByPlan(long planId) { return tasks.selectOne(new LambdaQueryWrapper() .eq(LoadingPickTaskEntity::getLoadingPlanId, planId)); @@ -111,4 +135,78 @@ public class LoadingPickRepository { public void deleteItem(long itemId) { items.deleteById(itemId); } + + public LoadingPickAssignmentEntity assignment(long taskId) { + return assignments.selectById(taskId); + } + + public int createAssignment(long taskId, long assigneeId, String assigneeName, + long operatorId, String operatorName, String type) { + return assignments.create(taskId, assigneeId, assigneeName, operatorId, operatorName, type); + } + + public int reassign(long taskId, long assigneeId, String assigneeName, long operatorId, + String operatorName, String type, int expectedVersion) { + return assignments.reassign(taskId, assigneeId, assigneeName, operatorId, operatorName, + type, expectedVersion); + } + + public LoadingPickReviewEntity review(long taskId) { + return reviews.selectById(taskId); + } + + public int approve(long taskId, long operatorId, String operatorName, String remark) { + return reviews.approve(taskId, operatorId, operatorName, remark); + } + + public LoadingPickOperationEntity operation(String operationId) { + return operations.selectOne(new LambdaQueryWrapper() + .eq(LoadingPickOperationEntity::getOperationId, operationId)); + } + + public void insert(LoadingPickOperationEntity operation) { + operations.insert(operation); + } + + public List operations(long taskId) { + return operations.selectList(new LambdaQueryWrapper() + .eq(LoadingPickOperationEntity::getPickTaskId, taskId) + .orderByAsc(LoadingPickOperationEntity::getCreatedAt)); + } + + public LoadingPickItemMapper.InboundProgress inboundProgress(long tenantId, long inboundId) { + return items.selectInboundProgress(tenantId, inboundId); + } + + public int settlePartial(long planItemId, long pickItemId, int pickedCount, int plannedCount, + long operatorId, String operatorName) { + if (items.settlePartialPlanItem(planItemId, pickedCount, plannedCount, + operatorId, operatorName) != 1) { + return 0; + } + return items.settlePartialPickItem(pickItemId); + } + + public int settleSkipped(long planItemId, long operatorId, String operatorName) { + return items.settleSkippedPlanItem(planItemId, operatorId, operatorName); + } + + public void syncInboundProjections(long taskId, long operatorId, String operatorName) { + for (LoadingPickItemMapper.InboundOwner owner : items.selectInboundOwners(taskId)) { + LoadingPickItemMapper.InboundProgress progress = inboundProgress(owner.tenantId(), owner.inboundId()); + int taskCount = value(progress == null ? null : progress.getTaskCount()); + int completed = value(progress == null ? null : progress.getCompletedTaskCount()); + int unresolved = value(progress == null ? null : progress.getUnresolvedCount()); + int expected = value(progress == null ? null : progress.getExpectedCount()); + int picked = value(progress == null ? null : progress.getPickedCount()); + String status = taskCount > 0 && completed == taskCount && unresolved == 0 && expected == picked + ? "COMPLETED" : picked > 0 ? "IN_PROGRESS" : "PENDING"; + inboundProjections.upsertProjection(IdWorker.getId(), owner.tenantId(), owner.inboundId(), status, + expected, picked, operatorId, operatorName); + } + } + + private static int value(Integer value) { + return value == null ? 0 : value; + } } 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 0521490c3..2260afb84 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -7,8 +7,17 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.Task; +import com.baiyue.firstleg.dto.LoadingPickDtos.Assignment; +import com.baiyue.firstleg.dto.LoadingPickDtos.Review; +import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.Operation; +import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity; import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; import com.baiyue.firstleg.entity.LoadingPickItemEntity; +import com.baiyue.firstleg.entity.LoadingPickOperationEntity; +import com.baiyue.firstleg.entity.LoadingPickReviewEntity; import com.baiyue.firstleg.entity.LoadingPickTaskEntity; import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import com.baiyue.firstleg.repository.LoadingPickRepository; @@ -55,6 +64,7 @@ public class LoadingPickService { for (LoadingPlanItemEntity planItem : planItems) { syncItem(task.getId(), planItem); } + repository.syncInboundProjections(task.getId(), operatorId, operatorName); } @Transactional @@ -79,11 +89,59 @@ public class LoadingPickService { return view(task, warehouseId); } + @Transactional + public Task claim(long taskId, ClaimRequest request, long operatorId, String operatorName) { + String operationId = operation(request.operationId()); + String requestHash = hash("CLAIM|" + taskId + '|' + request.expectedAssignmentVersion()); + Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId); + if (replay != null) return replay; + LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId); + LoadingPickAssignmentEntity existing = repository.assignment(taskId); + if (existing == null) { + if (request.expectedAssignmentVersion() != 0 + || repository.createAssignment(taskId, operatorId, operatorName, operatorId, + operatorName, "CLAIMED") != 1) { + throw conflict("PDA任务已被其他作业员领取"); + } + } else { + if (!Long.valueOf(operatorId).equals(existing.getAssigneeId())) { + throw conflict("PDA任务已分配给其他作业员"); + } + if (!request.expectedAssignmentVersion().equals(existing.getVersion())) { + throw conflict("PDA任务分配版本已变化"); + } + } + insertOperation(taskId, operationId, requestHash, "CLAIM", operatorId, operatorName); + return view(task, null); + } + + @Transactional + public Task assign(long taskId, AssignRequest request, long operatorId, String operatorName) { + String operationId = operation(request.operationId()); + String assigneeName = request.assigneeName().trim(); + String requestHash = hash("ASSIGN|" + taskId + '|' + request.assigneeId() + '|' + + assigneeName + '|' + request.expectedAssignmentVersion()); + Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId); + if (replay != null) return replay; + LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId); + LoadingPickAssignmentEntity existing = repository.assignment(taskId); + int changed = existing == null + ? request.expectedAssignmentVersion() == 0 + ? repository.createAssignment(taskId, request.assigneeId(), assigneeName, operatorId, + operatorName, "ASSIGNED") : 0 + : repository.reassign(taskId, request.assigneeId(), assigneeName, operatorId, operatorName, + "ASSIGNED", request.expectedAssignmentVersion()); + if (changed != 1) throw conflict("PDA任务分配版本已变化"); + insertOperation(taskId, operationId, requestHash, "ASSIGN", operatorId, operatorName); + return view(task, null); + } + @Transactional public Task pick(long taskId, long itemId, PickRequest request, long operatorId, String operatorName) { String operationId = request.operationId().trim(); String requestHash = hash(taskId + "|" + itemId + "|" + request.warehouseId() + "|" + request.pickedPackageCount()); + requireNoTaskOperation(operationId); LoadingPickHistoryEntity replay = repository.history(operationId); if (replay != null) { if (!replay.getPickTaskId().equals(taskId) || !replay.getPickItemId().equals(itemId) @@ -92,10 +150,7 @@ public class LoadingPickService { } return view(requiredTask(taskId), request.warehouseId()); } - LoadingPickTaskEntity task = requiredTask(taskId); - if (!"LOADING".equals(task.getTaskStatus())) { - throw conflict("装柜任务已完成,不能继续拣货"); - } + LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId); LoadingPickItemEntity item = repository.item(taskId, itemId, request.warehouseId()); if (item == null) { throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在"); @@ -113,7 +168,7 @@ public class LoadingPickService { history.setOperatorId(operatorId); history.setOperatorName(operatorName); repository.insert(history); - finishIfResolved(task, operatorId, operatorName); + touchAndSync(task, operatorId, operatorName); return view(requiredTask(taskId), request.warehouseId()); } @@ -123,19 +178,20 @@ public class LoadingPickService { String operationId = request.operationId().trim(); String requestHash = hash("PARTIAL|" + taskId + "|" + itemId + "|" + request.warehouseId() + "|" + request.finalPickedPackageCount()); + requireNoTaskOperation(operationId); LoadingPickHistoryEntity replay = repository.history(operationId); if (replay != null) { assertReplay(replay, taskId, itemId, requestHash); return view(requiredTask(taskId), request.warehouseId()); } - LoadingPickTaskEntity task = requiredLoadingTask(taskId); + LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId); requireWarehouseItem(taskId, itemId, request.warehouseId()); if (repository.partial(itemId, request.finalPickedPackageCount(), request.version(), request.warehouseId()) != 1) { throw conflict("部分拣货箱数必须大于已拣箱数且小于计划箱数,或明细版本已变化"); } insertHistory(taskId, itemId, operationId, requestHash, "PARTIAL", request.finalPickedPackageCount(), null, operatorId, operatorName); - finishIfResolved(task, operatorId, operatorName); + touchAndSync(task, operatorId, operatorName); return view(requiredTask(taskId), request.warehouseId()); } @@ -145,26 +201,68 @@ public class LoadingPickService { String reason = trim(request.reason()); String requestHash = hash("SKIP|" + taskId + "|" + itemId + "|" + request.warehouseId() + "|" + reason); + requireNoTaskOperation(operationId); LoadingPickHistoryEntity replay = repository.history(operationId); if (replay != null) { assertReplay(replay, taskId, itemId, requestHash); return view(requiredTask(taskId), request.warehouseId()); } - LoadingPickTaskEntity task = requiredLoadingTask(taskId); + LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId); requireWarehouseItem(taskId, itemId, request.warehouseId()); if (repository.skip(itemId, request.version(), request.warehouseId()) != 1) { throw conflict("跳拣仅允许未开始拣货的待拣明细,或明细版本已变化"); } insertHistory(taskId, itemId, operationId, requestHash, "SKIP", 0, reason, operatorId, operatorName); - finishIfResolved(task, operatorId, operatorName); + touchAndSync(task, operatorId, operatorName); return view(requiredTask(taskId), request.warehouseId()); } - private void finishIfResolved(LoadingPickTaskEntity task, long operatorId, String operatorName) { - if (repository.allPlanItemsPicked(task.getLoadingPlanId())) { - loadingListService.finishFromPda(task.getLoadingPlanId(), task.getId(), operatorId, operatorName); + @Transactional + public Task review(long taskId, ReviewRequest request, long operatorId, String operatorName) { + String operationId = operation(request.operationId()); + String remark = trim(request.remark()); + String requestHash = hash("REVIEW|" + taskId + '|' + request.expectedTaskVersion() + '|' + remark); + Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId); + if (replay != null) return replay; + LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId); + if (!request.expectedTaskVersion().equals(task.getVersion())) { + throw conflict("PDA任务版本已变化,请刷新后复核"); } + if (repository.review(taskId) != null) throw conflict("PDA任务已经复核"); + List taskItems = repository.items(taskId); + PdaPickSettlementPlan.Resolution resolution = PdaPickSettlementPlan.resolve(taskItems); + for (List lines : resolution.partialByInbound().values()) { + for (PdaPickSettlementPlan.SplitLine line : lines) { + if (repository.settlePartial(line.loadingPlanItemId(), line.pickItemId(), + line.pickedPackageCount(), line.pickedPackageCount() + line.remainingPackageCount(), + operatorId, operatorName) != 1) { + throw conflict("部分拣货结算版本已变化"); + } + } + } + for (Long planItemId : resolution.skippedPlanItemIds()) { + if (repository.settleSkipped(planItemId, operatorId, operatorName) != 1) { + throw conflict("跳拣结算版本已变化"); + } + } + if (!repository.allPlanItemsPicked(task.getLoadingPlanId())) { + throw conflict("柜次仍有未完成PDA拣货的箱数"); + } + if (repository.approve(taskId, operatorId, operatorName, remark) != 1) { + throw conflict("PDA任务已经复核"); + } + insertOperation(taskId, operationId, requestHash, "REVIEW", operatorId, operatorName); + loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName); + repository.syncInboundProjections(taskId, operatorId, operatorName); + return view(requiredTask(taskId), null); + } + + public List operations(long taskId) { + requiredTask(taskId); + return repository.operations(taskId).stream().map(item -> new Operation(item.getId(), item.getPickTaskId(), + item.getOperationId(), item.getActionType(), item.getOperatorId(), item.getOperatorName(), + item.getCreatedAt())).toList(); } @Transactional @@ -218,19 +316,89 @@ public class LoadingPickService { return task; } + private LoadingPickTaskEntity requiredLoadingTaskForUpdate(long taskId) { + LoadingPickTaskEntity task = repository.taskForUpdate(taskId); + if (task == null) throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在"); + if (!"LOADING".equals(task.getTaskStatus())) throw conflict("装柜任务已完成,不能继续拣货"); + return task; + } + + private LoadingPickTaskEntity requiredAssignedLoadingTaskForUpdate(long taskId, long operatorId) { + LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId); + LoadingPickAssignmentEntity assignment = repository.assignment(taskId); + if (assignment == null) throw conflict("请先领取PDA拣货任务"); + if (!Long.valueOf(operatorId).equals(assignment.getAssigneeId())) { + throw conflict("PDA拣货任务已分配给其他作业员"); + } + return task; + } + private void requireWarehouseItem(long taskId, long itemId, long warehouseId) { if (repository.item(taskId, itemId, warehouseId) == null) { throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在或不属于当前仓库"); } } - private Task view(LoadingPickTaskEntity task, long warehouseId) { - List items = repository.items(task.getId(), warehouseId).stream() + private Task view(LoadingPickTaskEntity task, Long warehouseId) { + List source = warehouseId == null + ? repository.items(task.getId()) : repository.items(task.getId(), warehouseId); + List items = source.stream() .map(item -> new PickItem(item.getId(), item.getLoadingPlanItemId(), item.getInboundId(), item.getInboundDetailId(), item.getPlannedPackageCount(), item.getPickedPackageCount(), item.getItemStatus(), item.getVersion())).toList(); + LoadingPickAssignmentEntity assigned = repository.assignment(task.getId()); + LoadingPickReviewEntity reviewed = repository.review(task.getId()); + Assignment assignment = assigned == null ? null : new Assignment(assigned.getAssigneeId(), + assigned.getAssigneeName(), assigned.getAssignedBy(), assigned.getAssignedByName(), + assigned.getAssignmentType(), assigned.getVersion()); + Review review = reviewed == null ? null : new Review(reviewed.getReviewStatus(), + reviewed.getReviewedBy(), reviewed.getReviewedByName(), reviewed.getRemark(), reviewed.getVersion()); + String workflowStatus = "COMPLETED".equals(task.getTaskStatus()) ? "COMPLETED" + : assigned == null ? "UNASSIGNED" + : repository.allTaskItemsResolved(task.getId()) ? "AWAITING_REVIEW" : "IN_PROGRESS"; return new Task(task.getId(), task.getLoadingPlanId(), task.getContainerNo(), task.getTaskStatus(), - task.getVersion(), items); + workflowStatus, task.getVersion(), assignment, review, items); + } + + private void touchAndSync(LoadingPickTaskEntity task, long operatorId, String operatorName) { + if (repository.touchTask(task.getId(), task.getVersion()) != 1) { + throw conflict("PDA任务版本已变化"); + } + repository.syncInboundProjections(task.getId(), operatorId, operatorName); + } + + private Task replayTaskOperation(long taskId, String operationId, String requestHash, long operatorId) { + if (repository.history(operationId) != null) throw conflict("operationId已用于明细拣货操作"); + LoadingPickOperationEntity replay = repository.operation(operationId); + if (replay == null) return null; + if (!Long.valueOf(taskId).equals(replay.getPickTaskId()) + || !Long.valueOf(operatorId).equals(replay.getOperatorId()) + || !requestHash.equals(replay.getRequestHash())) { + throw conflict("operationId已用于不同PDA任务操作"); + } + return view(requiredTask(taskId), null); + } + + private void insertOperation(long taskId, String operationId, String requestHash, String actionType, + long operatorId, String operatorName) { + LoadingPickOperationEntity entity = new LoadingPickOperationEntity(); + entity.setPickTaskId(taskId); entity.setOperationId(operationId); entity.setRequestHash(requestHash); + entity.setActionType(actionType); entity.setOperatorId(operatorId); entity.setOperatorName(operatorName); + try { + repository.insert(entity); + } catch (DuplicateKeyException exception) { + throw conflict("operationId已被并发使用"); + } + } + + private void requireNoTaskOperation(String operationId) { + if (repository.operation(operationId) != null) throw conflict("operationId已用于PDA任务操作"); + } + + private static String operation(String value) { + String result = trim(value); + if (!StringUtils.hasText(result)) throw new BizException(ResultCode.BAD_REQUEST, "operationId不能为空"); + return result; } private void insertHistory(long taskId, long itemId, String operationId, String requestHash, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java index 33112be3d..97fba7bb1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java @@ -3,6 +3,7 @@ package com.baiyue.firstleg.split.service; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; +import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; import com.baiyue.firstleg.split.domain.PreloadStatus; import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest; @@ -33,6 +34,7 @@ public class SplitPreloadService { private static final String CONFIRM_PRELOAD = "CONFIRM_PRELOAD"; @Resource private SplitRepository repository; + @Resource private LoadingModelBridgeService loadingModelBridge; @Resource private InboundStatusHistoryMapper histories; @Resource private ObjectMapper objectMapper; @@ -100,6 +102,7 @@ public class SplitPreloadService { if (replay != null) return replay; InboundSnapshot inbound = requireInbound(tenantId, inboundId); if ("LOADING".equals(inbound.processStatus())) { + loadingModelBridge.requireStarted(tenantId, inboundId); return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key, new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId)))); } @@ -108,6 +111,7 @@ public class SplitPreloadService { if (PreloadStatus.CONFIRMED.equals(plan.getStatus())) { throw new BizException(ResultCode.CONFLICT, "预装清单已确认但订单状态尚未推进,请刷新重试"); } + loadingModelBridge.requireStarted(tenantId, inboundId); if (repository.changePlanStatus(tenantId, inboundId, plan.getStatus(), PreloadStatus.CONFIRMED, operatorId, operatorName, request.expectedVersion()) != 1) { throw concurrent(); diff --git a/firstleg/src/main/resources/db/phase3-loading-model-bridge-schema.sql b/firstleg/src/main/resources/db/phase3-loading-model-bridge-schema.sql new file mode 100644 index 000000000..ac6af695f --- /dev/null +++ b/firstleg/src/main/resources/db/phase3-loading-model-bridge-schema.sql @@ -0,0 +1,65 @@ +SET NAMES utf8mb4; +USE baiyue_firstleg; + +/* 入库单级预装与柜次级装柜计划的多对多关联;一单多柜和拼柜均通过关联行表达。 */ +CREATE TABLE IF NOT EXISTS fl_preload_loading_plan_link ( + id BIGINT UNSIGNED NOT NULL COMMENT '关联ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '入库单客户隔离ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + preload_plan_id BIGINT UNSIGNED NOT NULL COMMENT '入库单级预装计划ID', + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '柜次级装柜计划ID', + idempotency_key VARCHAR(128) NOT NULL COMMENT '建立关联幂等键', + request_hash CHAR(64) NOT NULL COMMENT '建立关联请求SHA-256', + created_by BIGINT UNSIGNED NOT NULL, + created_by_name VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_preload_loading_pair (tenant_id, inbound_id, loading_plan_id), + UNIQUE KEY uk_preload_loading_idempotency (created_by, idempotency_key), + KEY idx_preload_loading_preload (preload_plan_id, loading_plan_id), + KEY idx_preload_loading_plan (loading_plan_id, tenant_id, inbound_id), + CONSTRAINT fk_preload_loading_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_preload_loading_preload FOREIGN KEY (preload_plan_id) REFERENCES fl_preload_plan (id), + CONSTRAINT fk_preload_loading_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), + CONSTRAINT chk_preload_loading_tenant CHECK (tenant_id > 0) +) ENGINE=InnoDB COMMENT='入库单预装与柜次装柜计划多对多关联'; + +/* 兼容已存在且可由活动装柜明细无歧义推导的关联。 */ +INSERT IGNORE INTO fl_preload_loading_plan_link + (id, tenant_id, inbound_id, preload_plan_id, loading_plan_id, idempotency_key, + request_hash, created_by, created_by_name, created_at) +SELECT CAST(CONV(SUBSTRING(SHA2(CONCAT('PRELOAD_LOADING|', item.account_id, '|', item.inbound_id, + '|', item.loading_plan_id), 256), 1, 15), 16, 10) AS UNSIGNED), + item.account_id, item.inbound_id, preload.id, item.loading_plan_id, + CONCAT('BACKFILL-', item.loading_plan_id, '-', item.inbound_id), + SHA2(CONCAT('BACKFILL|', item.account_id, '|', item.inbound_id, '|', item.loading_plan_id), 256), + preload.created_by, preload.created_by_name, LEAST(preload.created_at, plan.created_at) + FROM (SELECT DISTINCT account_id, inbound_id, loading_plan_id + FROM fl_loading_plan_item WHERE deleted = 0) item + JOIN fl_preload_plan preload ON preload.tenant_id = item.account_id + AND preload.inbound_id = item.inbound_id + JOIN fl_loading_plan plan ON plan.id = item.loading_plan_id; + +DROP PROCEDURE IF EXISTS assert_phase3_loading_model_bridge; +DELIMITER $$ +CREATE PROCEDURE assert_phase3_loading_model_bridge() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'fl_preload_loading_plan_link') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜模型桥接表创建失败'; + END IF; + IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'fl_preload_loading_plan_link' + AND index_name IN ('uk_preload_loading_pair', 'uk_preload_loading_idempotency', + 'idx_preload_loading_preload', 'idx_preload_loading_plan')) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜模型桥接索引不完整'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.key_column_usage + WHERE constraint_schema = DATABASE() AND table_name = 'fl_preload_loading_plan_link' + AND referenced_table_name IN ('fl_inbound_order', 'fl_preload_plan', 'fl_loading_plan')) <> 3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜模型桥接外键不完整'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase3_loading_model_bridge(); +DROP PROCEDURE IF EXISTS assert_phase3_loading_model_bridge; diff --git a/firstleg/src/main/resources/db/phase3-pda-convergence-schema.sql b/firstleg/src/main/resources/db/phase3-pda-convergence-schema.sql new file mode 100644 index 000000000..a207bbb7e --- /dev/null +++ b/firstleg/src/main/resources/db/phase3-pda-convergence-schema.sql @@ -0,0 +1,133 @@ +SET NAMES utf8mb4; +USE baiyue_firstleg; + +CREATE TABLE IF NOT EXISTS fl_loading_pick_assignment ( + pick_task_id BIGINT UNSIGNED NOT NULL, + assignee_id BIGINT UNSIGNED NOT NULL, + assignee_name VARCHAR(64) NOT NULL, + assigned_by BIGINT UNSIGNED NOT NULL, + assigned_by_name VARCHAR(64) NULL, + assignment_type VARCHAR(16) NOT NULL COMMENT 'CLAIMED/ASSIGNED', + version INT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (pick_task_id), + KEY idx_loading_pick_assignee (assignee_id, updated_at), + CONSTRAINT fk_loading_pick_assignment_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), + CONSTRAINT chk_loading_pick_assignment_type CHECK (assignment_type IN ('CLAIMED', 'ASSIGNED')) +) ENGINE=InnoDB COMMENT='柜次PDA任务领取与作业员分配'; + +CREATE TABLE IF NOT EXISTS fl_loading_pick_review ( + pick_task_id BIGINT UNSIGNED NOT NULL, + review_status VARCHAR(16) NOT NULL DEFAULT 'APPROVED', + reviewed_by BIGINT UNSIGNED NOT NULL, + reviewed_by_name VARCHAR(64) NULL, + remark VARCHAR(500) NULL, + version INT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (pick_task_id), + KEY idx_loading_pick_reviewer (reviewed_by, created_at), + CONSTRAINT fk_loading_pick_review_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), + CONSTRAINT chk_loading_pick_review_status CHECK (review_status = 'APPROVED') +) ENGINE=InnoDB COMMENT='柜次PDA任务完成复核'; + +CREATE TABLE IF NOT EXISTS fl_loading_pick_operation ( + id BIGINT UNSIGNED NOT NULL, + pick_task_id BIGINT UNSIGNED NOT NULL, + operation_id VARCHAR(128) NOT NULL, + request_hash CHAR(64) NOT NULL, + action_type VARCHAR(16) NOT NULL COMMENT 'CLAIM/ASSIGN/REVIEW', + operator_id BIGINT UNSIGNED NOT NULL, + operator_name VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_loading_pick_operation_id (operation_id), + KEY idx_loading_pick_operation_task (pick_task_id, created_at), + CONSTRAINT fk_loading_pick_operation_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), + CONSTRAINT chk_loading_pick_operation_action CHECK (action_type IN ('CLAIM', 'ASSIGN', 'REVIEW')) +) ENGINE=InnoDB COMMENT='柜次PDA任务级幂等审计'; + +DROP TRIGGER IF EXISTS trg_loading_pick_review_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_pick_review_before_update +BEFORE UPDATE ON fl_loading_pick_review +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA复核记录不可修改'; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_pick_review_before_delete; +DELIMITER $$ +CREATE TRIGGER trg_loading_pick_review_before_delete +BEFORE DELETE ON fl_loading_pick_review +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA复核记录不可删除'; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_pick_operation_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_pick_operation_before_update +BEFORE UPDATE ON fl_loading_pick_operation +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA任务操作历史不可修改'; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_pick_operation_before_delete; +DELIMITER $$ +CREATE TRIGGER trg_loading_pick_operation_before_delete +BEFORE DELETE ON fl_loading_pick_operation +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA任务操作历史不可删除'; +END$$ +DELIMITER ; + +USE baiyue_iam; + +DROP PROCEDURE IF EXISTS assert_phase3_pda_permission_ids; +DELIMITER $$ +CREATE PROCEDURE assert_phase3_pda_permission_ids() +BEGIN + IF EXISTS ( + SELECT 1 FROM iam_permission + WHERE id BETWEEN 5102190 AND 5102193 + AND permission_code NOT IN ( + 'platform:firstleg:pda:view', 'platform:firstleg:pda:assign', + 'platform:firstleg:pda:work', 'platform:firstleg:pda:review' + ) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA权限ID已被其他资源占用'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase3_pda_permission_ids(); +DROP PROCEDURE assert_phase3_pda_permission_ids; + +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, + permission_name, route_path, http_method, sort_no, status, built_in, deleted) +VALUES + (5102190, 0, 'PLATFORM', 'MENU', 5102094, 'platform:firstleg:pda:view', + 'PDA装柜拣货', '/first-leg/pda-loading', 'GET', 10, 'ACTIVE', 1, 0), + (5102191, 0, 'PLATFORM', 'BUTTON', 5102190, 'platform:firstleg:pda:assign', + '分配PDA任务', NULL, 'POST', 20, 'ACTIVE', 1, 0), + (5102192, 0, 'PLATFORM', 'BUTTON', 5102190, 'platform:firstleg:pda:work', + '执行PDA拣货', NULL, 'POST', 30, 'ACTIVE', 1, 0), + (5102193, 0, 'PLATFORM', 'BUTTON', 5102190, 'platform:firstleg:pda:review', + '复核PDA任务', NULL, 'POST', 40, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE + permission_name=VALUES(permission_name), parent_id=VALUES(parent_id), route_path=VALUES(route_path), + http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0; + +INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT role.id, permission.id, 0 + FROM iam_role role + JOIN iam_permission permission ON permission.id BETWEEN 5102190 AND 5102193 + WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR') + AND role.deleted=0 AND permission.deleted=0; diff --git a/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml b/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml index 2d9953559..23c075fd5 100644 --- a/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml @@ -20,7 +20,9 @@ paths: '200': {description: Business envelope; ownership, type and version failures use business error codes} /api/platform/v1/inbounds/{inboundId}/loading/picking/start: post: - summary: Start the inbound-level PDA picking review task + summary: Refresh the compatibility projection from canonical container PDA tasks + deprecated: true + description: expectedCount and pickedCount must exactly match the server-derived container-task aggregate. parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true @@ -30,7 +32,9 @@ paths: '200': {description: Business envelope containing IN_PROGRESS task} /api/platform/v1/inbounds/{inboundId}/loading/picking/complete: post: - summary: Complete a started PDA picking task when planned and picked counts match + summary: Mark the compatibility projection complete after all container PDA tasks are reviewed + deprecated: true + description: This endpoint cannot complete canonical picking; counts must match the server aggregate and every related container task must already be completed. parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true 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 new file mode 100644 index 000000000..65a66d892 --- /dev/null +++ b/firstleg/src/main/resources/openapi/phase3-loading-model-bridge-v1.yaml @@ -0,0 +1,58 @@ +openapi: 3.0.3 +info: + title: Phase 3 Preload and Container Loading Bridge API + version: 1.0.0 +paths: + /api/platform/v1/inbounds/{inboundId}/preload/loading-plans: + get: + summary: Query all container loading plans linked to one inbound preload + parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}] + responses: + '200': {description: Business envelope containing zero or more stable links} + /api/platform/v1/inbounds/{inboundId}/preload/loading-plans/{loadingPlanId}: + post: + summary: Atomically link an inbound preload and a container loading plan + description: Both optimistic-lock versions must match; either conflict rolls back the whole link operation. + parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/LoadingPlanId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: + required: true + content: + application/json: {schema: {$ref: '#/components/schemas/BindRequest'}} + responses: + '200': {description: Business envelope containing the persisted link or a business conflict} + /api/platform/v1/first-leg/smart-loading/plans/{loadingPlanId}/preloads: + get: + summary: Query all inbound preloads linked to one container loading plan + parameters: [{$ref: '#/components/parameters/LoadingPlanId'}] + responses: + '200': {description: Business envelope containing links across authorized customers} +components: + parameters: + InboundId: {name: inboundId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + LoadingPlanId: {name: loadingPlanId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + TenantId: {name: tenantId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} + IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} + schemas: + BindRequest: + type: object + required: [expectedPreloadVersion, expectedLoadingPlanVersion] + properties: + expectedPreloadVersion: {type: integer, minimum: 0} + expectedLoadingPlanVersion: {type: integer, minimum: 0} + Link: + type: object + required: [id, tenantId, inboundId, preloadPlanId, preloadStatus, preloadVersion, loadingPlanId, containerBookingId, loadingPlanStatus, loadingPlanVersion, allocatedPackageCount, linkedAt] + properties: + id: {type: integer, format: int64} + tenantId: {type: integer, format: int64} + inboundId: {type: integer, format: int64} + preloadPlanId: {type: integer, format: int64} + preloadStatus: {type: string, enum: [PENDING, CONFIRMING, CONFIRMED]} + preloadVersion: {type: integer, minimum: 0} + loadingPlanId: {type: integer, format: int64} + containerBookingId: {type: integer, format: int64} + containerNo: {type: string, nullable: true} + loadingPlanStatus: {type: string, enum: [EDITING, LOADING, COMPLETED]} + loadingPlanVersion: {type: integer, minimum: 0} + allocatedPackageCount: {type: integer, minimum: 1} + linkedAt: {type: string, format: date-time} diff --git a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml new file mode 100644 index 000000000..28d1d2c48 --- /dev/null +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -0,0 +1,87 @@ +openapi: 3.0.3 +info: + title: Phase 3 Container PDA Picking API + version: 1.0.0 +paths: + /api/platform/v1/first-leg/pda-loading/tasks: + get: + summary: Find the active container picking task in one platform warehouse + parameters: + - {name: containerNo, in: query, required: true, schema: {type: string}} + - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} + responses: + '200': {description: Task with assignment, review and item-level versions} + /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/claim: + post: + summary: Atomically claim an unassigned task for the authenticated operator + parameters: [{$ref: '#/components/parameters/TaskId'}] + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/ClaimRequest'}}} + responses: + '200': {description: Claimed task or A0409 when another operator owns it} + /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/assign: + post: + summary: Assign or reassign a task with optimistic locking + parameters: [{$ref: '#/components/parameters/TaskId'}] + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/AssignRequest'}}} + responses: + '200': {description: Assigned task or A0409 on assignment version conflict} + /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/items/{itemId}/pick: + post: + summary: Increment a picked item owned by the authenticated assignee + parameters: [{$ref: '#/components/parameters/TaskId'}, {$ref: '#/components/parameters/ItemId'}] + responses: {'200': {description: Updated task}} + /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/items/{itemId}/partial: + post: + summary: Record the final partial quantity for one assigned item + parameters: [{$ref: '#/components/parameters/TaskId'}, {$ref: '#/components/parameters/ItemId'}] + responses: {'200': {description: Task awaiting review when every item is resolved}} + /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/items/{itemId}/skip: + post: + summary: Resolve an untouched assigned item as skipped + parameters: [{$ref: '#/components/parameters/TaskId'}, {$ref: '#/components/parameters/ItemId'}] + responses: {'200': {description: Task awaiting review when every item is resolved}} + /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/review: + post: + summary: Review all resolved items, settle partial and skipped quantities, then complete the plan + parameters: [{$ref: '#/components/parameters/TaskId'}] + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/ReviewRequest'}}} + responses: + '200': {description: Completed task or A0409 when items or task version changed} + /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/operations: + get: + summary: Read immutable task-level claim, assignment and review audit records + parameters: [{$ref: '#/components/parameters/TaskId'}] + responses: + '200': {description: Ordered operation history} +components: + parameters: + TaskId: {name: taskId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + ItemId: {name: itemId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + schemas: + ClaimRequest: + type: object + required: [expectedAssignmentVersion, operationId] + properties: + expectedAssignmentVersion: {type: integer, minimum: 0} + operationId: {type: string, maxLength: 128} + AssignRequest: + type: object + required: [assigneeId, assigneeName, expectedAssignmentVersion, operationId] + properties: + assigneeId: {type: integer, format: int64, minimum: 1} + assigneeName: {type: string, maxLength: 64} + expectedAssignmentVersion: {type: integer, minimum: 0} + operationId: {type: string, maxLength: 128} + ReviewRequest: + type: object + required: [expectedTaskVersion, operationId] + properties: + expectedTaskVersion: {type: integer, minimum: 0} + operationId: {type: string, maxLength: 128} + remark: {type: string, maxLength: 500} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java new file mode 100644 index 000000000..890a564c5 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java @@ -0,0 +1,27 @@ +package com.baiyue.firstleg.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class PdaConvergenceOpenApiContractTest { + @Test + void publishesClaimAssignWorkAndReviewRoutes() throws Exception { + String yaml = new ClassPathResource("openapi/phase3-pda-convergence-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(yaml) + .contains("/api/platform/v1/first-leg/pda-loading/tasks:") + .contains("/tasks/{taskId}/claim:") + .contains("/tasks/{taskId}/assign:") + .contains("/tasks/{taskId}/items/{itemId}/pick:") + .contains("/tasks/{taskId}/items/{itemId}/partial:") + .contains("/tasks/{taskId}/items/{itemId}/skip:") + .contains("/tasks/{taskId}/review:") + .contains("/tasks/{taskId}/operations:") + .contains("expectedAssignmentVersion") + .contains("expectedTaskVersion"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java new file mode 100644 index 000000000..d6fe41f1d --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java @@ -0,0 +1,30 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.dto.LoadingPickDtos.*; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlatformLoadingPdaControllerContractTest { + @Test + void exposesAssignmentWorkAndReviewWithDedicatedAuthorities() throws Exception { + authority("task", "platform:firstleg:pda:view", AuthenticatedUser.class, String.class, long.class); + authority("claim", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, ClaimRequest.class); + authority("assign", "platform:firstleg:pda:assign", AuthenticatedUser.class, long.class, AssignRequest.class); + authority("pick", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, long.class, PickRequest.class); + authority("partial", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, long.class, PartialPickRequest.class); + authority("skip", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, long.class, SkipRequest.class); + authority("review", "platform:firstleg:pda:review", AuthenticatedUser.class, long.class, ReviewRequest.class); + authority("operations", "platform:firstleg:pda:view", AuthenticatedUser.class, long.class); + } + + private void authority(String name, String expected, Class... parameterTypes) throws Exception { + Method method = PlatformLoadingPdaController.class.getDeclaredMethod(name, parameterTypes); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('" + expected + "')"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/LoadingModelBridgeContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/LoadingModelBridgeContractTest.java new file mode 100644 index 000000000..08d7cbd9a --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/LoadingModelBridgeContractTest.java @@ -0,0 +1,35 @@ +package com.baiyue.firstleg.loading.bridge; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class LoadingModelBridgeContractTest { + @Test + void moduleSqlUsesExplicitManyToManyLinksAndRepeatableBackfill() throws Exception { + String sql = new ClassPathResource("db/phase3-loading-model-bridge-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(sql) + .contains("CREATE TABLE IF NOT EXISTS fl_preload_loading_plan_link") + .contains("UNIQUE KEY uk_preload_loading_pair (tenant_id, inbound_id, loading_plan_id)") + .contains("FOREIGN KEY (preload_plan_id) REFERENCES fl_preload_plan (id)") + .contains("FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id)") + .contains("INSERT IGNORE INTO fl_preload_loading_plan_link") + .doesNotContain("ALTER TABLE fl_preload_plan ADD COLUMN loading_plan_id"); + } + + @Test + void openApiDocumentsAtomicBindAndBothDirections() throws Exception { + String yaml = new ClassPathResource("openapi/phase3-loading-model-bridge-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(yaml) + .contains("/api/platform/v1/inbounds/{inboundId}/preload/loading-plans:") + .contains("/api/platform/v1/inbounds/{inboundId}/preload/loading-plans/{loadingPlanId}:") + .contains("/api/platform/v1/first-leg/smart-loading/plans/{loadingPlanId}/preloads:") + .contains("required: [expectedPreloadVersion, expectedLoadingPlanVersion]") + .contains("either conflict rolls back the whole link operation"); + } +} 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 new file mode 100644 index 000000000..f4fbaf46d --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java @@ -0,0 +1,74 @@ +package com.baiyue.firstleg.loading.bridge.service; + +import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.BindRequest; +import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link; +import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.LoadingPlanSnapshot; +import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.PreloadSnapshot; +import com.baiyue.firstleg.loading.bridge.repository.LoadingModelBridgeRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class LoadingModelBridgeServiceTest { + @Mock LoadingModelBridgeRepository repository; + @InjectMocks LoadingModelBridgeService service; + + @Test + void bindsManyToManyModelsWithBothOptimisticLocks() { + when(repository.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "PENDING", 2)); + when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3)); + when(repository.allocatedPackages(10, 20, 40)).thenReturn(8); + when(repository.touchPreload(30, 10, 20, 2, 7, "admin")).thenReturn(1); + when(repository.touchLoadingPlan(40, 3, 7, "admin")).thenReturn(1); + Link linked = link("PENDING", 3, "EDITING", 4, 8); + when(repository.byInbound(10, 20)).thenReturn(List.of(linked)); + + Link result = service.bind(10, 20, 40, 7, "admin", "bridge-1", new BindRequest(2, 3)); + + assertThat(result.loadingPlanId()).isEqualTo(40); + verify(repository).insert(any()); + verify(repository).touchPreload(30, 10, 20, 2, 7, "admin"); + verify(repository).touchLoadingPlan(40, 3, 7, "admin"); + } + + @Test + void secondVersionConflictDoesNotProceedAsSuccess() { + when(repository.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "PENDING", 2)); + when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3)); + when(repository.allocatedPackages(10, 20, 40)).thenReturn(8); + when(repository.touchPreload(30, 10, 20, 2, 7, "admin")).thenReturn(1); + when(repository.touchLoadingPlan(40, 3, 7, "admin")).thenReturn(0); + + assertThatThrownBy(() -> service.bind(10, 20, 40, 7, "admin", "bridge-2", new BindRequest(2, 3))) + .hasMessageContaining("刷新后重试"); + verify(repository, never()).byInbound(10, 20); + } + + @Test + void terminalGateRequiresEveryLinkedContainerPlanCompleted() { + when(repository.byInbound(10, 20)).thenReturn(List.of( + link("CONFIRMED", 4, "COMPLETED", 7, 5), + new Link(2L, 10L, 20L, 30L, "CONFIRMED", 4, 41L, 51L, "C2", + "LOADING", 6, 3, LocalDateTime.now()))); + + assertThatThrownBy(() -> service.requireCompleted(10, 20)) + .hasMessageContaining("尚未全部完成装柜"); + } + + private static Link link(String preloadStatus, int preloadVersion, String loadingStatus, + int loadingVersion, int packages) { + return new Link(1L, 10L, 20L, 30L, preloadStatus, preloadVersion, 40L, 50L, "C1", + loadingStatus, loadingVersion, packages, LocalDateTime.now()); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java index 8f36491ef..f0800264e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java @@ -21,6 +21,8 @@ class LoadingDeliveryOpenApiContractTest { .contains("required: [expectedVersion, evidenceType, fileId]") .contains("enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL]") .contains("Required when evidenceType is SEAL") - .contains("Complete a started PDA picking task"); + .contains("compatibility projection") + .contains("must exactly match the server-derived container-task aggregate") + .contains("cannot complete canonical picking"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/repository/PdaConvergenceSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/repository/PdaConvergenceSqlContractTest.java new file mode 100644 index 000000000..8bdf3a4ba --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/repository/PdaConvergenceSqlContractTest.java @@ -0,0 +1,54 @@ +package com.baiyue.firstleg.loading.repository; + +import com.baiyue.firstleg.mapper.LoadingPickItemMapper; +import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper; +import org.junit.jupiter.api.Test; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Select; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class PdaConvergenceSqlContractTest { + @Test + void hasIndependentAssignmentReviewOperationAuditAndPermissionGrants() throws Exception { + String sql = new ClassPathResource("db/phase3-pda-convergence-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(sql) + .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_assignment") + .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_review") + .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_operation") + .contains("UNIQUE KEY uk_loading_pick_operation_id") + .contains("trg_loading_pick_review_before_update") + .contains("trg_loading_pick_operation_before_delete") + .contains("5102190, 0, 'PLATFORM'") + .contains("5102193, 0, 'PLATFORM'") + .contains("role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')") + .contains("platform:firstleg:pda:view") + .contains("platform:firstleg:pda:assign") + .contains("platform:firstleg:pda:work") + .contains("platform:firstleg:pda:review"); + } + + @Test + void derivesInboundProjectionFromCanonicalContainerItems() throws Exception { + String aggregate = String.join(" ", LoadingPickItemMapper.class + .getDeclaredMethod("selectInboundProgress", long.class, long.class) + .getAnnotation(Select.class).value()); + String upsert = InboundLoadingPickTaskMapper.class.getDeclaredMethod("upsertProjection", + long.class, long.class, long.class, String.class, int.class, int.class, long.class, String.class) + .getAnnotation(Insert.class).value()[0]; + assertThat(aggregate) + .contains("fl_loading_plan_item") + .contains("fl_loading_pick_item") + .contains("fl_loading_pick_task") + .contains("plan_item.account_id=#{tenantId}") + .contains("pick_task.task_status='COMPLETED'"); + assertThat(upsert) + .contains("INSERT INTO fl_inbound_loading_pick_task") + .contains("ON DUPLICATE KEY UPDATE") + .contains("柜次PDA自动汇总"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java index 4f00e1bc6..9cbdd6682 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java @@ -8,8 +8,10 @@ import com.baiyue.firstleg.loading.dto.LoadingRequests.PickingRequest; import com.baiyue.firstleg.loading.dto.LoadingRequests.UpsertEvidenceRequest; import com.baiyue.firstleg.loading.dto.LoadingResponses.LoadingActionResponse; import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity; -import com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity; import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.Snapshot; +import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; +import com.baiyue.firstleg.mapper.LoadingPickItemMapper; +import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.loading.repository.LoadingRepository; import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; import com.fasterxml.jackson.databind.ObjectMapper; @@ -30,6 +32,8 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class LoadingDeliveryServiceTest { @Mock LoadingRepository repository; + @Mock LoadingModelBridgeService loadingModelBridge; + @Mock LoadingPickRepository loadingPicks; @Mock InboundStatusHistoryMapper histories; @Mock LoadingEvidenceFileClient files; @InjectMocks LoadingDeliveryService service; @@ -48,9 +52,13 @@ class LoadingDeliveryServiceTest { void completesLoadingOnlyAfterPickingAndFiveEvidenceFiles() { when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3)); - InboundLoadingPickTaskEntity pick = new InboundLoadingPickTaskEntity(); - pick.setStatus("COMPLETED"); pick.setExpectedCount(5); pick.setPickedCount(5); - when(repository.findPickTask(10, 20)).thenReturn(pick); + LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class); + when(progress.getExpectedCount()).thenReturn(5); + when(progress.getPickedCount()).thenReturn(5); + when(progress.getTaskCount()).thenReturn(1); + when(progress.getCompletedTaskCount()).thenReturn(1); + when(progress.getUnresolvedCount()).thenReturn(0); + when(loadingPicks.inboundProgress(10, 20)).thenReturn(progress); when(repository.listEvidence(10, 20)).thenReturn(List.of( evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null), evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null), @@ -64,6 +72,7 @@ class LoadingDeliveryServiceTest { when(repository.completeLoading(10, 20, 7, 3)).thenReturn(1); LoadingActionResponse result = service.completeLoading(10, 20, 7, "admin", "k1", new CompleteLoadingRequest(3, null)); assertThat(result.mainStatus()).isEqualTo("LOADED"); + verify(loadingModelBridge).requireCompleted(10, 20); verify(histories).insert(any(InboundStatusHistoryEntity.class)); } @@ -71,9 +80,13 @@ class LoadingDeliveryServiceTest { void rejectsCompletionWhenEvidenceIsIncomplete() { when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3)); - InboundLoadingPickTaskEntity pick = new InboundLoadingPickTaskEntity(); - pick.setStatus("COMPLETED"); pick.setExpectedCount(5); pick.setPickedCount(5); - when(repository.findPickTask(10, 20)).thenReturn(pick); + LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class); + when(progress.getExpectedCount()).thenReturn(5); + when(progress.getPickedCount()).thenReturn(5); + when(progress.getTaskCount()).thenReturn(1); + when(progress.getCompletedTaskCount()).thenReturn(1); + when(progress.getUnresolvedCount()).thenReturn(0); + when(loadingPicks.inboundProgress(10, 20)).thenReturn(progress); when(repository.listEvidence(10, 20)).thenReturn(List.of(evidence("SEAL", 5L, "LOCK-001"))); assertThatThrownBy(() -> service.completeLoading(10, 20, 7, "admin", "k-missing", @@ -111,13 +124,12 @@ class LoadingDeliveryServiceTest { } @Test - void rejectsCompletingPickingBeforeTaskStarts() { + void rejectsCompletingPickingWithoutCanonicalContainerTask() { when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3)); - when(repository.findPickTask(10, 20)).thenReturn(null); assertThatThrownBy(() -> service.completePicking(10, 20, 7, "admin", "k-picking", - new PickingRequest(0, 1, 1, null))).hasMessageContaining("尚未开始"); + new PickingRequest(0, 1, 1, null))).hasMessageContaining("真实柜次"); verify(repository, never()).createPickTask(any()); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index 32b885683..84cc84700 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -23,10 +23,15 @@ class Phase3FinalUnifiedSqlContractTest { .contains("CREATE TABLE IF NOT EXISTS fl_customs_receipt") .contains("CREATE TABLE IF NOT EXISTS fl_port_schedule") .contains("CREATE TABLE IF NOT EXISTS fl_port_schedule_history") + .contains("CREATE TABLE IF NOT EXISTS fl_preload_loading_plan_link") + .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_assignment") + .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_review") + .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_operation") .contains("uk_loading_evidence_seal (tenant_id,seal_no)") .contains("CALL assert_phase3_final_schema()") .contains("trg_port_schedule_history_before_update") - .contains("trg_port_schedule_history_before_delete"); + .contains("trg_port_schedule_history_before_delete") + .contains("trg_loading_pick_operation_before_delete"); } @Test @@ -36,10 +41,12 @@ class Phase3FinalUnifiedSqlContractTest { for (int id = 5102160; id <= 5102164; id++) assertThat(sql).contains(Integer.toString(id)); for (int id = 5102170; id <= 5102175; id++) assertThat(sql).contains(Integer.toString(id)); for (int id = 5102180; id <= 5102183; id++) assertThat(sql).contains(Integer.toString(id)); + for (int id = 5102190; id <= 5102193; id++) assertThat(sql).contains(Integer.toString(id)); assertThat(sql) .contains("platform:firstleg:outbound-dispatch:%") .contains("platform:firstleg:customs:%") .contains("platform:firstleg:port-schedule:%") + .contains("platform:firstleg:pda:%") .contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')") .contains("角色授权未完整写入"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 341421946..7a3028749 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -3,7 +3,10 @@ package com.baiyue.firstleg.service; import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest; import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; +import com.baiyue.firstleg.entity.LoadingPickOperationEntity; +import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity; import com.baiyue.firstleg.entity.LoadingPickItemEntity; import com.baiyue.firstleg.entity.LoadingPickTaskEntity; import com.baiyue.firstleg.repository.LoadingPickRepository; @@ -34,9 +37,12 @@ class LoadingPickServiceTest { LoadingPickItemEntity item = item(); when(repository.history("pick-1")).thenReturn(null); when(repository.task(20L)).thenReturn(task); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.assignment(20L)).thenReturn(assignment()); when(repository.item(20L, 30L, 101L)).thenReturn(item); when(repository.pick(30L, 2, 0, 101L)).thenReturn(1); when(repository.allPlanItemsPicked(10L)).thenReturn(false); + when(repository.touchTask(20L, 0)).thenReturn(1); when(repository.items(20L, 101L)).thenReturn(List.of(item)); service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-1"), 9L, "operator"); @@ -50,14 +56,16 @@ class LoadingPickServiceTest { LoadingPickItemEntity item = item(); when(repository.history("pick-2")).thenReturn(null); when(repository.task(20L)).thenReturn(task); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.assignment(20L)).thenReturn(assignment()); when(repository.item(20L, 30L, 101L)).thenReturn(item); when(repository.pick(30L, 2, 0, 101L)).thenReturn(1); - when(repository.allPlanItemsPicked(10L)).thenReturn(true); when(repository.items(20L, 101L)).thenReturn(List.of(item)); + when(repository.touchTask(20L, 0)).thenReturn(1); service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-2"), 9L, "operator"); - verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); + verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator"); verify(repository).insert(any(LoadingPickHistoryEntity.class)); } @@ -67,14 +75,16 @@ class LoadingPickServiceTest { LoadingPickItemEntity item = item(); when(repository.history("partial-1")).thenReturn(null); when(repository.task(20L)).thenReturn(task); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.assignment(20L)).thenReturn(assignment()); when(repository.item(20L, 30L, 101L)).thenReturn(item); when(repository.partial(30L, 1, 0, 101L)).thenReturn(1); - when(repository.allPlanItemsPicked(10L)).thenReturn(true); when(repository.items(20L, 101L)).thenReturn(List.of(item)); + when(repository.touchTask(20L, 0)).thenReturn(1); service.partial(20L, 30L, new PartialPickRequest(101L, 1, List.of(), 0, "partial-1"), 9L, "operator"); - verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); + verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator"); } @Test @@ -83,14 +93,37 @@ class LoadingPickServiceTest { LoadingPickItemEntity item = item(); when(repository.history("skip-1")).thenReturn(null); when(repository.task(20L)).thenReturn(task); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.assignment(20L)).thenReturn(assignment()); when(repository.item(20L, 30L, 101L)).thenReturn(item); when(repository.skip(30L, 0, 101L)).thenReturn(1); - when(repository.allPlanItemsPicked(10L)).thenReturn(true); when(repository.items(20L, 101L)).thenReturn(List.of(item)); + when(repository.touchTask(20L, 0)).thenReturn(1); service.skip(20L, 30L, new SkipRequest(101L, 0, "skip-1", "fixture unavailable"), 9L, "operator"); + verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator"); + } + + @Test + void reviewSettlesPartialItemsBeforeCompletingPlan() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity item = item(); + item.setPlannedPackageCount(5); + item.setPickedPackageCount(3); + item.setItemStatus("PARTIAL"); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.task(20L)).thenReturn(task); + when(repository.items(20L)).thenReturn(List.of(item)); + when(repository.settlePartial(40L, 30L, 3, 5, 9L, "operator")).thenReturn(1); + when(repository.allPlanItemsPicked(10L)).thenReturn(true); + when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1); + + service.review(20L, new ReviewRequest(0, "review-1", "checked"), 9L, "operator"); + + verify(repository).insert(any(LoadingPickOperationEntity.class)); verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); + verify(repository).syncInboundProjections(20L, 9L, "operator"); } private static LoadingPickTaskEntity task() { @@ -116,4 +149,13 @@ class LoadingPickServiceTest { item.setVersion(0); return item; } + + private static LoadingPickAssignmentEntity assignment() { + LoadingPickAssignmentEntity assignment = new LoadingPickAssignmentEntity(); + assignment.setPickTaskId(20L); + assignment.setAssigneeId(9L); + assignment.setAssigneeName("operator"); + assignment.setVersion(0); + return assignment; + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java index 09dd84108..c9f434fab 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java @@ -1,6 +1,7 @@ package com.baiyue.firstleg.split.service; import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; +import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest; import com.baiyue.firstleg.split.dto.SplitRequests.PreloadItem; import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest; @@ -30,6 +31,7 @@ import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class SplitPreloadServiceTest { @Mock SplitRepository repository; + @Mock LoadingModelBridgeService loadingModelBridge; @Mock InboundStatusHistoryMapper histories; @InjectMocks SplitPreloadService service; private final ObjectMapper objectMapper = new ObjectMapper(); @@ -61,11 +63,13 @@ class SplitPreloadServiceTest { plan.setTenantId(10L); plan.setInboundId(20L); plan.setStatus("CONFIRMING"); plan.setVersion(1); plan.setItemsJson("[]"); when(repository.findPlan(10, 20)).thenReturn(plan); + org.mockito.Mockito.doNothing().when(loadingModelBridge).requireStarted(10, 20); when(repository.changePlanStatus(10, 20, "CONFIRMING", "CONFIRMED", 7, "admin", 1)).thenReturn(1); when(repository.beginLoading(10, 20, 7, 4)).thenReturn(1); SplitActionResponse result = service.confirmPreload(10, 20, 7, "admin", "k3", new ConfirmPreloadRequest(1)); assertThat(result.mainStatus()).isEqualTo("LOADING"); + verify(loadingModelBridge).requireStarted(10, 20); verify(repository).beginLoading(10, 20, 7, 4); } } diff --git a/scripts/admin-test-env-modules/ocean-bookings.mjs b/scripts/admin-test-env-modules/ocean-bookings.mjs index 071e46480..f89126656 100644 --- a/scripts/admin-test-env-modules/ocean-bookings.mjs +++ b/scripts/admin-test-env-modules/ocean-bookings.mjs @@ -20,7 +20,8 @@ async function createBooking(context, suffix, key) { return request(context, 'PO function step(context, name, r, pass, evidence, options = OPTIONS) { context.addStep(MODULE, name, r, pass, evidence, { ...options, deploymentBlocker: options.deploymentBlocker && !pass }) } export async function run(context) { - const containerFlow = context.createdResources?.phase3ContainerLoading + const containerFlow = context.createdResources?.phase3Loading + || context.createdResources?.phase3ContainerLoading const suffix = context.marker.replace(/\D/g, '').slice(-10) const list = await request(context, 'GET', `${BASE}?page=1&size=5&keyword=${encodeURIComponent(context.marker)}`) if (missing(list) || list.http === 0) { @@ -58,7 +59,7 @@ export async function run(context) { const changed = await request(context, 'POST', `${path}/change-request`, { expectedVersion: version, vesselName: `改船-${suffix}`, voyageNo: `CV${suffix}`, reason: context.marker }, { 'Idempotency-Key': `${context.marker}-change` }) context.apiSuccess(MODULE, '申请改船', changed, '', OPTIONS); version = Number(data(changed)?.oceanBooking?.version) const changeConfirmed = await action('确认改船', 'change-confirm', version, 'confirm change') - if (containerFlow?.finalStatus === 'COMPLETED') { + if (containerFlow?.finalStatus === 'COMPLETED' || containerFlow?.finalStatus === 'LOADED') { context.assert(MODULE, '订舱与本轮柜次客户归属一致', changed, id(containerFlow?.tenantId) === id(changeConfirmed?.oceanBooking?.customerId), `bookingCustomerId=${id(changeConfirmed?.oceanBooking?.customerId) || 'missing'}, containerTenantId=${id(containerFlow?.tenantId) || 'missing'}`, OPTIONS) diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index f96601f25..5047c3ed5 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -174,6 +174,7 @@ export async function run(context) { const pickItem = task?.items?.find?.((item) => id(item.loadingPlanItemId) === id(planItem.id)) context.assert(MODULE, 'PDA 任务、计划明细和待拣箱数关联正确', taskResponse, Boolean(taskId) && id(task?.loadingPlanId) === planId && task?.taskStatus === 'LOADING' + && task?.workflowStatus === 'UNASSIGNED' && Boolean(pickItem?.id) && Number(pickItem?.plannedPackageCount) === 1, `taskId=${taskId || 'missing'}, itemId=${id(pickItem?.id) || 'missing'}`, OPTIONS) if (!taskId || !pickItem?.id) return @@ -183,6 +184,15 @@ export async function run(context) { context.addStep(MODULE, 'PDA 任务仓库隔离拒绝', wrongWarehouseResponse, failure(wrongWarehouseResponse), `HTTP=${wrongWarehouseResponse.http}, code=${codeOf(wrongWarehouseResponse)}`, OPTIONS) + const claimResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/claim`, { + expectedAssignmentVersion: 0, operationId: `${context.marker}-pda-claim`.slice(0, 128), + }) + const claimed = context.apiSuccess(MODULE, '领取本轮柜次 PDA 任务', claimResponse, '', OPTIONS) + context.assert(MODULE, 'PDA 任务只分配给当前登录作业员', claimResponse, + claimed?.workflowStatus === 'IN_PROGRESS' && id(claimed?.assignment?.assigneeId) + && Number(claimed?.assignment?.version) === 0, + `workflow=${claimed?.workflowStatus || 'missing'}, assigneeId=${id(claimed?.assignment?.assigneeId) || 'missing'}`, OPTIONS) + const stalePickResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, { warehouseId, pickedPackageCount: 1, locationIds: [], version: Number(pickItem.version) + 1, operationId: `${context.marker}-pda-stale`.slice(0, 128), @@ -197,18 +207,28 @@ export async function run(context) { } const pickResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, pickPayload) const picked = context.apiSuccess(MODULE, '执行本轮 PDA 全量拣货', pickResponse, '', OPTIONS) - context.assert(MODULE, '最后一箱拣货后 PDA 任务进入 COMPLETED', pickResponse, - picked?.taskStatus === 'COMPLETED' + context.assert(MODULE, '最后一箱拣货后 PDA 任务等待复核', pickResponse, + picked?.taskStatus === 'LOADING' && picked?.workflowStatus === 'AWAITING_REVIEW' && picked?.items?.every?.((item) => Number(item.pickedPackageCount) === Number(item.plannedPackageCount)), - `taskStatus=${picked?.taskStatus || 'missing'}`, OPTIONS) + `taskStatus=${picked?.taskStatus || 'missing'}, workflow=${picked?.workflowStatus || 'missing'}`, OPTIONS) const pickReplayResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, pickPayload) const pickReplay = context.apiSuccess(MODULE, 'PDA operationId 幂等重放', pickReplayResponse, '', OPTIONS) - context.assert(MODULE, 'PDA 重放保持已完成任务和拣货数', pickReplayResponse, - pickReplay?.taskStatus === 'COMPLETED' + context.assert(MODULE, 'PDA 重放保持待复核任务和拣货数', pickReplayResponse, + pickReplay?.taskStatus === 'LOADING' && pickReplay?.workflowStatus === 'AWAITING_REVIEW' && pickReplay?.items?.every?.((item) => Number(item.pickedPackageCount) === Number(item.plannedPackageCount)), `taskStatus=${pickReplay?.taskStatus || 'missing'}`, OPTIONS) + const reviewResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/review`, { + expectedTaskVersion: Number(picked?.version), operationId: `${context.marker}-pda-review`.slice(0, 128), + remark: context.marker, + }) + const reviewed = context.apiSuccess(MODULE, '复核本轮柜次 PDA 任务', reviewResponse, '', OPTIONS) + context.assert(MODULE, '复核后任务与柜次计划进入真实完成态', reviewResponse, + reviewed?.taskStatus === 'COMPLETED' && reviewed?.workflowStatus === 'COMPLETED' + && reviewed?.review?.status === 'APPROVED' && Boolean(reviewed?.review?.reviewedBy), + `taskStatus=${reviewed?.taskStatus || 'missing'}, review=${reviewed?.review?.status || 'missing'}`, OPTIONS) + const finalPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`) const finalPlan = context.apiSuccess(MODULE, '回查柜次装柜计划终态', finalPlanResponse, '', OPTIONS) context.assert(MODULE, '装柜计划进入 COMPLETED 且明细已冻结', finalPlanResponse, @@ -243,6 +263,7 @@ export async function run(context) { context.createdResources.phase3ContainerLoading = { marker: context.marker, tenantId, inboundId, warehouseId, containerBookingId: containerId, - loadingPlanId: planId, pdaTaskId: taskId, finalStatus: 'COMPLETED', + loadingPlanId: planId, pdaTaskId: taskId, pdaReviewVersion: reviewed?.review?.version, + finalStatus: 'COMPLETED', } } diff --git a/scripts/admin-test-env-modules/phase3-customs.mjs b/scripts/admin-test-env-modules/phase3-customs.mjs index 72cafe855..f05f0e13b 100644 --- a/scripts/admin-test-env-modules/phase3-customs.mjs +++ b/scripts/admin-test-env-modules/phase3-customs.mjs @@ -68,13 +68,16 @@ async function detail(context, caseId) { } export async function run(context) { - const fixture = context.createdResources?.orchestrationFixtures?.phase3Container - const container = context.createdResources?.phase3ContainerLoading + const fixture = context.createdResources?.orchestrationFixtures?.inboundMeasurement + || context.createdResources?.orchestrationFixtures?.phase3Container + const container = context.createdResources?.phase3Loading + || context.createdResources?.phase3ContainerLoading const tenantId = id(fixture?.tenantId) const orderId = id(fixture?.orderId) const inboundId = id(fixture?.inboundId) const containerBookingId = id(container?.containerBookingId) - if (!tenantId || !orderId || !inboundId || !containerBookingId || container?.finalStatus !== 'COMPLETED') { + if (!tenantId || !orderId || !inboundId || !containerBookingId + || !['COMPLETED', 'LOADED'].includes(container?.finalStatus)) { context.addStep(MODULE, '取得本轮客户/订单/入库单/柜次隔离 fixture', null, false, `tenantId=${tenantId || 'missing'}, orderId=${orderId || 'missing'}, inboundId=${inboundId || 'missing'}, containerBookingId=${containerBookingId || 'missing'}, containerStatus=${container?.finalStatus || 'missing'}`, OPTIONS) return diff --git a/scripts/admin-test-env-modules/phase3-loading-model-bridge.mjs b/scripts/admin-test-env-modules/phase3-loading-model-bridge.mjs index 2a974d660..1b366e48b 100644 --- a/scripts/admin-test-env-modules/phase3-loading-model-bridge.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-model-bridge.mjs @@ -1,10 +1,49 @@ -import { registerPendingContract } from './_phase3-pending-contract.mjs' - +const MODULE = 'phase3-loading-model-bridge' +const OPTIONS = { scenario: 'parallel-loading-models', deploymentBlocker: true } +function id(value) { return value == null ? '' : String(value) } +function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } +function failure(response) { return [400, 401, 403, 404, 409, 503].includes(response?.http) || (response?.http === 200 && codeOf(response) !== '200') } +async function request(context, method, path, body, headers = {}) { + try { + const response = await fetch(`${context.options.gateway}${path}`, { method, + headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`, + ...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers }, + body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000) }) + const raw = await response.text(); let payload = null + try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ } + return { method, path, http: response.status, body: payload, raw: payload === null ? raw.slice(0, 500) : null, + requestId: payload?.requestId || response.headers.get('x-request-id'), traceId: payload?.traceId || response.headers.get('x-trace-id') } + } catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } } +} export async function run(context) { - registerPendingContract(context, { - module: 'phase3-loading-model-bridge', scenario: 'parallel-loading-models', name: '入库单级预装与柜次级智能装柜串联', - reason: 'fl_preload_plan 按入库单推进 LOADING/LOADED,fl_loading_plan 按柜次推进 EDITING/LOADING/COMPLETED;两套公开 API 不会互相创建、绑定或校验终态', category: 'contract-gap', - fixtures: ['phase3Loading', 'phase3ContainerLoading'], contractSource: '待新增两套装柜模型关联键与联动契约', - assertions: ['同一 inbound 与柜次计划双向可查', '任一侧版本冲突不产生部分更新', '双终态一致后方可进入出仓门禁'], - }) + const fixture = context.createdResources?.phase3Loading + const tenantId = id(fixture?.tenantId); const inboundId = id(fixture?.inboundId) + const loadingPlanId = id(fixture?.loadingPlanId) + if (!tenantId || !inboundId || !loadingPlanId || fixture?.marker !== context.marker) { + context.addCoverageGap(MODULE, OPTIONS.scenario, '入库单级预装与柜次级智能装柜串联', + '本轮未取得同一 marker 的预装/柜次计划关联 fixture,未使用历史资源', 'fixture', + { activation: { state: 'pending-fixture', assertions: ['双向关联可查', '双版本冲突原子回滚', '终态门禁一致'] } }) + return + } + context.addCoverageGap(MODULE, OPTIONS.scenario, '入库单级预装与柜次级智能装柜串联', + '桥接契约、SQL和真实断言已完成,待目标提交部署并取得本模块真实 HTTP PASS 后删除此项', + 'deployment-validation-pending', { activation: { state: 'pending-deployment-validation' } }) + const inboundPath = `/api/platform/v1/inbounds/${inboundId}/preload/loading-plans?tenantId=${encodeURIComponent(tenantId)}` + const inboundResponse = await request(context, 'GET', inboundPath) + const inboundLinks = context.apiSuccess(MODULE, '从入库侧查询预装与柜次关联', inboundResponse, '', OPTIONS) + const link = Array.isArray(inboundLinks) ? inboundLinks.find((item) => id(item.loadingPlanId) === loadingPlanId) : null + context.assert(MODULE, '入库侧关联只返回本轮柜次和客户隔离键', inboundResponse, + Boolean(link) && id(link.inboundId) === inboundId && id(link.tenantId) === tenantId + && Number(link.allocatedPackageCount) > 0, `links=${inboundLinks?.length ?? 'n/a'}`, OPTIONS) + const planResponse = await request(context, 'GET', `/api/platform/v1/first-leg/smart-loading/plans/${loadingPlanId}/preloads`) + const planLinks = context.apiSuccess(MODULE, '从柜次侧查询反向预装关联', planResponse, '', OPTIONS) + context.assert(MODULE, '柜次侧反查到同一预装计划', planResponse, + Array.isArray(planLinks) && planLinks.some((item) => id(item.inboundId) === inboundId + && id(item.preloadPlanId) === id(fixture.preloadPlanId)), `links=${planLinks?.length ?? 'n/a'}`, OPTIONS) + const forged = await request(context, 'POST', + `/api/platform/v1/inbounds/${inboundId}/preload/loading-plans/${loadingPlanId}?tenantId=${encodeURIComponent(tenantId)}`, + { expectedPreloadVersion: 0, expectedLoadingPlanVersion: 0 }, + { 'Idempotency-Key': `${context.marker}-bridge-bind`.slice(0, 128) }) + context.addStep(MODULE, '桥接同幂等键不同版本请求拒绝且不产生部分更新', forged, failure(forged), + `HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS) } diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 670018fcd..7f57fa985 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -83,6 +83,52 @@ export async function run(context) { const base = `/api/platform/v1/inbounds/${inboundId}` const suffix = context.marker.replace(/\D/g, '').slice(-10) + const warehouseId = id(initial?.summary?.warehouseId) + const smartLoadingBase = '/api/platform/v1/first-leg/smart-loading' + const pdaBase = '/api/platform/v1/first-leg/pda-loading' + const candidateResponse = await context.request('GET', `${smartLoadingBase}/candidates?warehouseId=${encodeURIComponent(warehouseId)}`) + const candidates = context.apiSuccess(MODULE, '查询本轮入库单柜次装柜候选', candidateResponse, '', OPTIONS) + const candidate = Array.isArray(candidates) ? candidates.find((item) => id(item.inboundId) === inboundId + && Number(item.availablePackageCount) > 0) : null + context.assert(MODULE, '柜次装柜候选与入库单、客户和仓库一致', candidateResponse, + Boolean(candidate?.inboundDetailId) && id(candidate?.accountId) === tenantId + && id(candidate?.warehouseId) === warehouseId, + `candidate=${Boolean(candidate)}, warehouseId=${warehouseId || 'missing'}`, OPTIONS) + if (!candidate?.inboundDetailId || !warehouseId) return + + const containerNo = `P3B-${suffix}` + const containerResponse = await request(context, 'POST', '/api/platform/v1/containers', { + containerNo, cabinetNo: `P3BI-${suffix}`, warehouseId, + warehouseName: initial?.summary?.warehouseName || null, billOfLadingNo: `P3BL-${suffix}`, + vesselName: `P3-BRIDGE-${suffix}`, voyageNo: `P3V-${suffix}`, containerType: '40HQ', + shippingCompanyName: `P3-SHIPPING-${suffix}`, departurePortCode: 'CNSZX', departurePortName: '深圳', + destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', + destinationWarehouseCode: candidate.destinationWarehouseCode || null, + destinationWarehouseName: candidate.destinationWarehouseName || null, + capacityCbm: 100, maxWeightKg: 100000, loadingAt: '2026-09-02T08:00:00', + estimatedSailingAt: '2026-09-03T08:00:00', estimatedPortArrivalAt: '2026-09-20T08:00:00', + estimatedWarehouseArrivalAt: '2026-09-21T08:00:00', + }) + const container = context.apiSuccess(MODULE, '创建与入库预装串联的柜次', containerResponse, '', OPTIONS) + const containerBookingId = id(container?.id) + if (!containerBookingId) return + + const loadingPlanResponse = await request(context, 'POST', `${smartLoadingBase}/plans`, { + bookingId: containerBookingId, remark: context.marker, + }) + const loadingPlan = context.apiSuccess(MODULE, '创建柜次级智能装柜计划', loadingPlanResponse, '', OPTIONS) + const loadingPlanId = id(loadingPlan?.id) + if (!loadingPlanId) return + const assignmentResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/assignments`, { + inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1, + expectedVersion: Number(loadingPlan.version), operationId: `${context.marker}-bridge-assign`.slice(0, 128), + }) + const assignedLoadingPlan = context.apiSuccess(MODULE, '将本轮入库货物分配到柜次计划', assignmentResponse, '', OPTIONS) + context.assert(MODULE, '柜次计划明细和版本真实持久化', assignmentResponse, + assignedLoadingPlan?.items?.some?.((item) => id(item.inboundId) === inboundId && Number(item.packageCount) === 1) + && Number(assignedLoadingPlan?.version) === 1, + `loadingPlanId=${loadingPlanId}, version=${assignedLoadingPlan?.version ?? 'missing'}`, OPTIONS) + const smart = await request(context, 'POST', `${base}/split/smart?tenantId=${encodeURIComponent(tenantId)}`, { expectedVersion: 0, items: [{ containerCode: `P3-${suffix}`, cargoReference: `REF-${suffix}`, packageCount: 1, volumeCbm: 0.01, remark: context.marker }], @@ -104,11 +150,31 @@ export async function run(context) { && replay.body?.data?.preloadPlan?.items?.length === 1, `HTTP=${replay.http}, code=${codeOf(replay)}, planId=${id(replay.body?.data?.preloadPlan?.id) || 'missing'}`, OPTIONS) + const bindResponse = await request(context, 'POST', + `${base}/preload/loading-plans/${loadingPlanId}?tenantId=${encodeURIComponent(tenantId)}`, { + expectedPreloadVersion: Number(smartData.preloadPlan.version), + expectedLoadingPlanVersion: Number(assignedLoadingPlan.version), + }, { 'Idempotency-Key': `${context.marker}-bridge-bind`.slice(0, 128) }) + const link = context.apiSuccess(MODULE, '原子关联入库预装与柜次装柜计划', bindResponse, '', OPTIONS) + context.assert(MODULE, '桥接双版本递增且关联归属唯一', bindResponse, + id(link?.inboundId) === inboundId && id(link?.loadingPlanId) === loadingPlanId + && id(link?.preloadPlanId) === planId && Number(link?.allocatedPackageCount) === 1 + && Number(link?.preloadVersion) === 1 && Number(link?.loadingPlanVersion) === 2, + `linkId=${id(link?.id) || 'missing'}, preloadVersion=${link?.preloadVersion ?? 'missing'}, loadingVersion=${link?.loadingPlanVersion ?? 'missing'}`, OPTIONS) + if (!link?.id) return + + const pushResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/push-preload`, + { expectedVersion: Number(link.loadingPlanVersion) }, + { 'Idempotency-Key': `${context.marker}-bridge-push`.slice(0, 128) }) + const pushedLoadingPlan = context.apiSuccess(MODULE, '桥接后推送柜次计划并生成真实 PDA 任务', pushResponse, '', OPTIONS) + context.assert(MODULE, '关联柜次计划进入 LOADING', pushResponse, + pushedLoadingPlan?.status === 'LOADING', `status=${pushedLoadingPlan?.status || 'missing'}`, OPTIONS) + const confirming = await request(context, 'POST', `${base}/preload/confirming?tenantId=${encodeURIComponent(tenantId)}`, - { expectedVersion: Number(smartData.preloadPlan.version) }, { 'Idempotency-Key': `${context.marker}-p3-confirming`.slice(0, 128) }) + { expectedVersion: Number(link.preloadVersion) }, { 'Idempotency-Key': `${context.marker}-p3-confirming`.slice(0, 128) }) const confirmingData = context.apiSuccess(MODULE, '提交预装清单确认', confirming, '', OPTIONS) context.assert(MODULE, '预装清单进入 CONFIRMING 且版本递增', confirming, - confirmingData?.preloadPlan?.status === 'CONFIRMING' && Number(confirmingData?.preloadPlan?.version) === 1, + confirmingData?.preloadPlan?.status === 'CONFIRMING' && Number(confirmingData?.preloadPlan?.version) === 2, `status=${confirmingData?.preloadPlan?.status || 'missing'}, version=${confirmingData?.preloadPlan?.version ?? 'missing'}`, OPTIONS) if (confirmingData?.preloadPlan?.status !== 'CONFIRMING') return @@ -117,7 +183,7 @@ export async function run(context) { const confirmed = context.apiSuccess(MODULE, '确认预装并进入装柜中', confirm, '', OPTIONS) context.assert(MODULE, '预装确认推进主状态 LOADING 和计划 CONFIRMED', confirm, confirmed?.mainStatus === 'LOADING' && confirmed?.preloadPlan?.status === 'CONFIRMED' - && Number(confirmed?.preloadPlan?.version) === 2, + && Number(confirmed?.preloadPlan?.version) === 3, `mainStatus=${confirmed?.mainStatus || 'missing'}, planStatus=${confirmed?.preloadPlan?.status || 'missing'}, version=${confirmed?.preloadPlan?.version ?? 'missing'}`, OPTIONS) if (confirmed?.mainStatus !== 'LOADING') return @@ -142,13 +208,58 @@ export async function run(context) { context.addStep(MODULE, '未完成 PDA 拣货时拒绝完成装柜', blockedBeforePicking, failure(blockedBeforePicking), `HTTP=${blockedBeforePicking.http}, code=${codeOf(blockedBeforePicking)}`, OPTIONS) + const canonicalTaskResponse = await context.request('GET', + `${pdaBase}/tasks?containerNo=${encodeURIComponent(containerNo)}&warehouseId=${encodeURIComponent(warehouseId)}`) + const canonicalTask = context.apiSuccess(MODULE, '读取柜次级 PDA 唯一事实任务', canonicalTaskResponse, '', OPTIONS) + const canonicalTaskId = id(canonicalTask?.id) + const canonicalItem = canonicalTask?.items?.find?.((item) => id(item.inboundId) === inboundId) + context.assert(MODULE, '柜次 PDA 任务与桥接入库明细一致', canonicalTaskResponse, + Boolean(canonicalTaskId) && id(canonicalTask?.loadingPlanId) === loadingPlanId + && canonicalTask?.workflowStatus === 'UNASSIGNED' && Boolean(canonicalItem?.id), + `taskId=${canonicalTaskId || 'missing'}, itemId=${id(canonicalItem?.id) || 'missing'}`, OPTIONS) + if (!canonicalTaskId || !canonicalItem?.id) return + + const claim = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/claim`, { + expectedAssignmentVersion: 0, operationId: `${context.marker}-bridge-claim`.slice(0, 128), + }) + const claimed = context.apiSuccess(MODULE, '领取柜次 PDA 任务', claim, '', OPTIONS) + context.assert(MODULE, '柜次 PDA 任务绑定当前作业员', claim, + claimed?.workflowStatus === 'IN_PROGRESS' && Boolean(claimed?.assignment?.assigneeId), + `workflow=${claimed?.workflowStatus || 'missing'}`, OPTIONS) + + const canonicalPick = await request(context, 'POST', + `${pdaBase}/tasks/${canonicalTaskId}/items/${id(canonicalItem.id)}/pick`, { + warehouseId, pickedPackageCount: 1, locationIds: [], version: Number(canonicalItem.version), + operationId: `${context.marker}-bridge-pick`.slice(0, 128), + }) + const pickedCanonical = context.apiSuccess(MODULE, '执行柜次 PDA 明细拣货', canonicalPick, '', OPTIONS) + context.assert(MODULE, '柜次 PDA 明细完成后等待复核', canonicalPick, + pickedCanonical?.workflowStatus === 'AWAITING_REVIEW' && pickedCanonical?.taskStatus === 'LOADING', + `workflow=${pickedCanonical?.workflowStatus || 'missing'}`, OPTIONS) + + const canonicalReview = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/review`, { + expectedTaskVersion: Number(pickedCanonical?.version), + operationId: `${context.marker}-bridge-review`.slice(0, 128), remark: context.marker, + }) + const reviewedCanonical = context.apiSuccess(MODULE, '复核柜次 PDA 并完成柜次计划', canonicalReview, '', OPTIONS) + context.assert(MODULE, '柜次 PDA 复核成为装柜完成真实门禁', canonicalReview, + reviewedCanonical?.taskStatus === 'COMPLETED' && reviewedCanonical?.workflowStatus === 'COMPLETED' + && reviewedCanonical?.review?.status === 'APPROVED', + `taskStatus=${reviewedCanonical?.taskStatus || 'missing'}`, OPTIONS) + + const forgedPickingStart = await request(context, 'POST', `${base}/loading/picking/start?tenantId=${encodeURIComponent(tenantId)}`, + { expectedVersion: 0, expectedCount: 1, pickedCount: 0, remark: 'forged-client-count' }, + { 'Idempotency-Key': `${context.marker}-p3-picking-forged-start`.slice(0, 128) }) + context.addStep(MODULE, '入库级 PDA 汇总拒绝客户端伪造数量', forgedPickingStart, failure(forgedPickingStart), + `HTTP=${forgedPickingStart.http}, code=${codeOf(forgedPickingStart)}`, OPTIONS) + const pickingStart = await request(context, 'POST', `${base}/loading/picking/start?tenantId=${encodeURIComponent(tenantId)}`, - { expectedVersion: 0, expectedCount: 1, pickedCount: 0, remark: context.marker }, + { expectedVersion: 0, expectedCount: 1, pickedCount: 1, remark: context.marker }, { 'Idempotency-Key': `${context.marker}-p3-picking-start`.slice(0, 128) }) const pickingStarted = context.apiSuccess(MODULE, '开始本轮 PDA 拣货任务', pickingStart, '', OPTIONS) context.assert(MODULE, 'PDA 拣货任务处于 IN_PROGRESS 且计划数量正确', pickingStart, pickingStarted?.status === 'IN_PROGRESS' && Number(pickingStarted?.version) === 0 - && Number(pickingStarted?.expectedCount) === 1 && Number(pickingStarted?.pickedCount) === 0, + && Number(pickingStarted?.expectedCount) === 1 && Number(pickingStarted?.pickedCount) === 1, `status=${pickingStarted?.status || 'missing'}, version=${pickingStarted?.version ?? 'missing'}`, OPTIONS) const pickingMismatch = await request(context, 'POST', `${base}/loading/picking/complete?tenantId=${encodeURIComponent(tenantId)}`, @@ -240,6 +351,7 @@ export async function run(context) { `status=${finalData?.summary?.processStatus || 'missing'}, version=${finalData?.summary?.version ?? 'missing'}, histories=${finalData?.statusHistory?.length ?? 'n/a'}`, OPTIONS) context.createdResources.phase3Loading = { tenantId, inboundId, preloadPlanId: planId, evidenceIds: evidenceItems.map((item) => id(item.id)), + loadingPlanId, containerBookingId, containerNo, warehouseId, canonicalPdaTaskId: canonicalTaskId, pickingTaskId: id(pickingCompleted?.id), marker: context.marker, finalStatus: 'LOADED', } diff --git a/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs b/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs index 44ea75832..2ea109a06 100644 --- a/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs +++ b/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs @@ -28,10 +28,12 @@ async function request(context, method, path, body, headers = {}) { } export async function run(context) { - const fixture = context.createdResources?.phase3ContainerLoading + const fixture = context.createdResources?.phase3Loading + || context.createdResources?.phase3ContainerLoading const inboundId = id(fixture?.inboundId) const containerBookingId = id(fixture?.containerBookingId) - if (!inboundId || !containerBookingId || fixture?.marker !== context.marker) { + if (!inboundId || !containerBookingId || !['COMPLETED', 'LOADED'].includes(fixture?.finalStatus) + || fixture?.marker !== context.marker) { context.addCoverageGap(MODULE, OPTIONS.scenario, '国内出仓主聚合真实闭环', '本轮未取得 marker 隔离的柜次装柜 fixture,未使用历史资源', 'fixture') return diff --git a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs index 096a2bfe0..41ae2fe13 100644 --- a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs +++ b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs @@ -1,10 +1,59 @@ -import { registerPendingContract } from './_phase3-pending-contract.mjs' - +const MODULE = 'phase3-pda-convergence' +const OPTIONS = { scenario: 'pda-picking-convergence', deploymentBlocker: true } +function id(value) { return value == null ? '' : String(value) } +function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } +function failure(response) { return [400, 401, 403, 404, 409, 503].includes(response?.http) || (response?.http === 200 && codeOf(response) !== '200') } +async function request(context, method, path, body, headers = {}) { + try { + const response = await fetch(`${context.options.gateway}${path}`, { method, + headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`, + ...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers }, + body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000) }) + const raw = await response.text(); let payload = null + try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ } + return { method, path, http: response.status, body: payload, raw: payload === null ? raw.slice(0, 500) : null, + requestId: payload?.requestId || response.headers.get('x-request-id'), traceId: payload?.traceId || response.headers.get('x-trace-id') } + } catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } } +} export async function run(context) { - registerPendingContract(context, { - module: 'phase3-pda-convergence', scenario: 'pda-picking-convergence', name: 'PDA 柜次拣货与入库单汇总拣货收敛', - reason: '柜次级 /first-leg/pda-loading 和入库单级 /inbounds/{id}/loading/picking 是两套独立任务;当前无领取、作业员分配、复核和统一完成门禁', category: 'contract-gap', - fixtures: ['phase3Loading', 'phase3ContainerLoading'], contractSource: '待新增 PDA 任务领取、分配、复核与汇总契约', - assertions: ['任务领取和作业员隔离', '柜次明细与入库汇总数量一致', '复核完成幂等且成为装柜门禁前置'], - }) + const fixture = context.createdResources?.phase3Loading + const tenantId = id(fixture?.tenantId); const inboundId = id(fixture?.inboundId) + const taskId = id(fixture?.canonicalPdaTaskId); const containerNo = fixture?.containerNo + const warehouseId = id(fixture?.warehouseId) + if (!tenantId || !inboundId || !taskId || !containerNo || !warehouseId || fixture?.marker !== context.marker) { + context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 柜次拣货与入库单汇总拣货收敛', + '本轮未取得 marker 隔离的柜次 PDA 与入库汇总 fixture,未使用历史资源', 'fixture') + return + } + context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 柜次拣货与入库单汇总拣货收敛', + 'PDA唯一事实源、作业员隔离、复核和入库聚合真实断言已完成,待目标提交部署并取得真实 HTTP PASS 后删除此项', + 'deployment-validation-pending', { activation: { state: 'pending-deployment-validation' } }) + const taskResponse = await request(context, 'GET', + `/api/platform/v1/first-leg/pda-loading/tasks?containerNo=${encodeURIComponent(containerNo)}&warehouseId=${encodeURIComponent(warehouseId)}`) + const task = context.apiSuccess(MODULE, '回查柜次 PDA 唯一事实任务', taskResponse, '', OPTIONS) + context.assert(MODULE, '柜次任务已领取、复核且明细数量一致', taskResponse, + id(task?.id) === taskId && task?.taskStatus === 'COMPLETED' && task?.workflowStatus === 'COMPLETED' + && Boolean(task?.assignment?.assigneeId) && task?.review?.status === 'APPROVED' + && task?.items?.every?.((item) => Number(item.plannedPackageCount) === Number(item.pickedPackageCount)), + `taskStatus=${task?.taskStatus || 'missing'}, workflow=${task?.workflowStatus || 'missing'}`, OPTIONS) + const projectionResponse = await request(context, 'GET', + `/api/platform/v1/inbounds/${inboundId}/loading/picking?tenantId=${encodeURIComponent(tenantId)}`) + const projection = context.apiSuccess(MODULE, '查询入库级 PDA 服务端聚合投影', projectionResponse, '', OPTIONS) + context.assert(MODULE, '入库投影与柜次事实数量和终态一致', projectionResponse, + projection?.status === 'COMPLETED' && Number(projection?.expectedCount) > 0 + && Number(projection?.expectedCount) === Number(projection?.pickedCount), + `status=${projection?.status || 'missing'}, expected=${projection?.expectedCount ?? 'missing'}, picked=${projection?.pickedCount ?? 'missing'}`, OPTIONS) + const operationsResponse = await request(context, 'GET', + `/api/platform/v1/first-leg/pda-loading/tasks/${taskId}/operations`) + const operations = context.apiSuccess(MODULE, '查询柜次 PDA 不可变操作历史', operationsResponse, '', OPTIONS) + context.assert(MODULE, '领取和复核操作历史含操作者与时间', operationsResponse, + Array.isArray(operations) && ['CLAIM', 'REVIEW'].every((action) => operations.some((item) => item.actionType === action + && Boolean(item.operatorId) && Boolean(item.createdAt))), + `actions=${Array.isArray(operations) ? operations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS) + const forged = await request(context, 'POST', + `/api/platform/v1/inbounds/${inboundId}/loading/picking/start?tenantId=${encodeURIComponent(tenantId)}`, + { expectedVersion: Number(projection?.version), expectedCount: 999, pickedCount: 999, remark: 'forged' }, + { 'Idempotency-Key': `${context.marker}-pda-forged-projection`.slice(0, 128) }) + context.addStep(MODULE, '入库兼容入口拒绝客户端伪造汇总数量', forged, failure(forged), + `HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS) } diff --git a/scripts/admin-test-env-modules/phase3-port-schedules.mjs b/scripts/admin-test-env-modules/phase3-port-schedules.mjs index 217fd8efb..37515f8b2 100644 --- a/scripts/admin-test-env-modules/phase3-port-schedules.mjs +++ b/scripts/admin-test-env-modules/phase3-port-schedules.mjs @@ -85,8 +85,9 @@ export async function run(context) { const ocean = await ensureOceanBooking(context, suffix) if (!ocean?.id) return - const flow = context.createdResources?.phase3ContainerLoading - const containerBookingId = flow?.finalStatus === 'COMPLETED' && id(flow?.tenantId) === '10001' + const flow = context.createdResources?.phase3Loading + || context.createdResources?.phase3ContainerLoading + const containerBookingId = ['COMPLETED', 'LOADED'].includes(flow?.finalStatus) ? id(flow.containerBookingId) : null const knownAllocation = context.createdResources?.oceanBooking?.id === ocean.id && containerBookingId ? containerBookingId : null diff --git a/scripts/admin-test-env-modules/service-center-flow.mjs b/scripts/admin-test-env-modules/service-center-flow.mjs index 630c11175..3afe75ede 100644 --- a/scripts/admin-test-env-modules/service-center-flow.mjs +++ b/scripts/admin-test-env-modules/service-center-flow.mjs @@ -8,8 +8,10 @@ function codeOf(response) { return response?.body?.code == null ? 'missing' : St export async function run(context) { const ocean = context.createdResources?.oceanBooking - const containerFlow = context.createdResources?.phase3ContainerLoading - const containerFixture = context.createdResources?.orchestrationFixtures?.phase3Container + const containerFlow = context.createdResources?.phase3Loading + || context.createdResources?.phase3ContainerLoading + const containerFixture = context.createdResources?.orchestrationFixtures?.inboundMeasurement + || context.createdResources?.orchestrationFixtures?.phase3Container if (!ocean?.id || ocean.finalStatus !== 'RELEASED') { context.addStep(MODULE, '跨服务链路前置订舱终态可用', null, false, '本轮海运订舱模块未返回 RELEASED 隔离记录,未伪造关联拖车预约', OPTIONS) @@ -22,7 +24,7 @@ export async function run(context) { const customerId = id(oceanDetail?.oceanBooking?.customerId) context.assert(MODULE, '跨服务链路订舱客户归属有效', oceanResponse, Boolean(customerId), `customerId=${customerId || 'missing'}`, OPTIONS) if (!customerId) return - const linkContainer = containerFlow?.finalStatus === 'COMPLETED' + const linkContainer = (containerFlow?.finalStatus === 'COMPLETED' || containerFlow?.finalStatus === 'LOADED') && id(containerFlow?.tenantId) === customerId && Boolean(containerFlow?.containerBookingId) && Boolean(containerFlow?.loadingPlanId)