feat(firstleg): converge phase3 loading models and pda

This commit is contained in:
2026-08-09 11:21:38 +08:00
parent 9b88fb4b9e
commit ed5b9fe77f
46 changed files with 2028 additions and 96 deletions
@@ -8389,6 +8389,141 @@ SET @ddl = IF(
'SELECT 1'); 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; 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; DROP PROCEDURE IF EXISTS assert_phase3_final_schema;
DELIMITER $$ DELIMITER $$
CREATE PROCEDURE assert_phase3_final_schema() CREATE PROCEDURE assert_phase3_final_schema()
@@ -8398,7 +8533,9 @@ BEGIN
'fl_outbound_dispatch','fl_outbound_dispatch_history','fl_outbound_dispatch_operation', '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_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_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='统一迁移失败:三期出仓、报关或船期表未完整创建'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期出仓、报关或船期表未完整创建';
END IF; END IF;
IF (SELECT COUNT(*) FROM information_schema.statistics 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_document_case','fk_customs_supplement_case','fk_customs_batch_case',
'fk_customs_receipt_case','fk_customs_receipt_batch','fk_customs_history_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_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='统一迁移失败:三期关键外键未完整创建'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期关键外键未完整创建';
END IF; END IF;
IF (SELECT COUNT(*) FROM information_schema.triggers IF (SELECT COUNT(*) FROM information_schema.triggers
WHERE trigger_schema='baiyue_firstleg' AND trigger_name IN ( WHERE trigger_schema='baiyue_firstleg' AND trigger_name IN (
'trg_port_schedule_before_insert','trg_port_schedule_before_update', '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='统一迁移失败:港务船期归属或历史保护触发器未完整创建'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:港务船期归属或历史保护触发器未完整创建';
END IF; END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' 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 AND id BETWEEN 5102180 AND 5102183 AND permission_code LIKE 'platform:firstleg:port-schedule:%') <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期出仓、报关或船期权限未完整写入'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期出仓、报关或船期权限未完整写入';
END IF; 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)) IF (SELECT COUNT(DISTINCT CONCAT(r.role_code,'|',p.permission_code))
FROM baiyue_iam.iam_role_permission rp 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 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') WHERE r.tenant_id=0 AND r.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
AND (p.permission_code LIKE 'platform:firstleg:outbound-dispatch:%' AND (p.permission_code LIKE 'platform:firstleg:outbound-dispatch:%'
OR p.permission_code LIKE 'platform:firstleg:customs:%' 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='统一迁移失败:三期出仓、报关或船期角色授权未完整写入'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期出仓、报关或船期角色授权未完整写入';
END IF; END IF;
END$$ END$$
@@ -8456,4 +8602,4 @@ DROP PROCEDURE IF EXISTS assert_phase3_final_schema;
-- WAIT_SPLIT 是二期当前有效终点;统一迁移不得改写主表或历史状态。 -- WAIT_SPLIT 是二期当前有效终点;统一迁移不得改写主表或历史状态。
SELECT 'PASS' AS migration_status, SELECT 'PASS' AS migration_status,
'统一增量全部执行完成;箱号映射、单箱测量、三期订舱、拖车预约、出仓、费用、公共资源及可靠审计结构校验通过' AS message; '统一增量全部执行完成;箱号映射、单箱测量、三期订舱、拖车预约、出仓、报关、船期、装柜模型桥接、PDA收敛、费用、公共资源及可靠审计结构校验通过' AS message;
@@ -5,6 +5,11 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest; 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.dto.LoadingPickDtos.Task;
import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.LoadingPickService; import com.baiyue.firstleg.service.LoadingPickService;
@@ -31,7 +36,7 @@ public class PlatformLoadingPdaController {
private LoadingPickService service; private LoadingPickService service;
@GetMapping("/tasks") @GetMapping("/tasks")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") @PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<Task> task(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<Task> task(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String containerNo, @RequestParam String containerNo,
@RequestParam @Positive long warehouseId) { @RequestParam @Positive long warehouseId) {
@@ -39,8 +44,26 @@ public class PlatformLoadingPdaController {
return ApiResponse.success(service.task(containerNo, warehouseId)); return ApiResponse.success(service.task(containerNo, warehouseId));
} }
@PostMapping("/tasks/{taskId}/claim")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> 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<Task> 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") @PostMapping("/tasks/{taskId}/items/{itemId}/pick")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") @PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> pick(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<Task> pick(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId, @PathVariable @Positive long taskId,
@PathVariable @Positive long itemId, @PathVariable @Positive long itemId,
@@ -50,7 +73,7 @@ public class PlatformLoadingPdaController {
} }
@PostMapping("/tasks/{taskId}/items/{itemId}/partial") @PostMapping("/tasks/{taskId}/items/{itemId}/partial")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") @PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> partial(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<Task> partial(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId, @PathVariable @Positive long taskId,
@PathVariable @Positive long itemId, @PathVariable @Positive long itemId,
@@ -60,7 +83,7 @@ public class PlatformLoadingPdaController {
} }
@PostMapping("/tasks/{taskId}/items/{itemId}/skip") @PostMapping("/tasks/{taskId}/items/{itemId}/skip")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") @PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> skip(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<Task> skip(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId, @PathVariable @Positive long taskId,
@PathVariable @Positive long itemId, @PathVariable @Positive long itemId,
@@ -68,4 +91,21 @@ public class PlatformLoadingPdaController {
FirstlegScopeGuard.platform(user); FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.skip(taskId, itemId, request, user.userId(), user.username())); return ApiResponse.success(service.skip(taskId, itemId, request, user.userId(), user.username()));
} }
@PostMapping("/tasks/{taskId}/review")
@PreAuthorize("hasAuthority('platform:firstleg:pda:review')")
public ApiResponse<Task> 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<List<Operation>> operations(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.operations(taskId));
}
} }
@@ -7,6 +7,7 @@ import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import java.util.List; import java.util.List;
import java.time.LocalDateTime;
/** PDA 装柜拣货接口模型。 */ /** PDA 装柜拣货接口模型。 */
public final class LoadingPickDtos { 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, public record PickItem(Long id, Long loadingPlanItemId, Long inboundId, Long inboundDetailId,
Integer plannedPackageCount, Integer pickedPackageCount, Integer plannedPackageCount, Integer pickedPackageCount,
String itemStatus, Integer version) { 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, public record Task(Long id, Long loadingPlanId, String containerNo, String taskStatus,
Integer version, List<PickItem> items) { String workflowStatus, Integer version, Assignment assignment,
Review review, List<PickItem> items) {
} }
} }
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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<Link> 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<List<Link>> 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<List<Link>> byLoadingPlan(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long loadingPlanId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.byLoadingPlan(loadingPlanId));
}
}
@@ -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) {}
}
@@ -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;
}
@@ -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<PreloadLoadingPlanLinkEntity> {
@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<Link> 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<Link> 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) {}
}
@@ -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<PreloadLoadingPlanLinkEntity>()
.eq(PreloadLoadingPlanLinkEntity::getTenantId, tenantId)
.eq(PreloadLoadingPlanLinkEntity::getInboundId, inboundId)
.eq(PreloadLoadingPlanLinkEntity::getLoadingPlanId, loadingPlanId));
}
public PreloadLoadingPlanLinkEntity byIdempotency(long operatorId, String key) {
return mapper.selectOne(new LambdaQueryWrapper<PreloadLoadingPlanLinkEntity>()
.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<Link> byInbound(long tenantId, long inboundId) { return mapper.byInbound(tenantId, inboundId); }
public List<Link> byLoadingPlan(long loadingPlanId) { return mapper.byLoadingPlan(loadingPlanId); }
}
@@ -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<Link> byInbound(long tenantId, long inboundId) { return repository.byInbound(tenantId, inboundId); }
public List<Link> byLoadingPlan(long loadingPlanId) { return repository.byLoadingPlan(loadingPlanId); }
public void requireStarted(long tenantId, long inboundId) {
List<Link> 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<Link> links = requireLinks(tenantId, inboundId);
if (links.stream().anyMatch(link -> link.allocatedPackageCount() == null || link.allocatedPackageCount() <= 0
|| !"COMPLETED".equals(link.loadingPlanStatus()))) {
throw conflict("关联柜次尚未全部完成装柜");
}
}
private List<Link> requireLinks(long tenantId, long inboundId) {
List<Link> 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("数据已被其他人修改,请刷新后重试"); }
}
@@ -5,9 +5,21 @@ import com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update; import org.apache.ibatis.annotations.Update;
import org.apache.ibatis.annotations.Insert;
@Mapper @Mapper
public interface InboundLoadingPickTaskMapper extends BaseMapper<InboundLoadingPickTaskEntity> { public interface InboundLoadingPickTaskMapper extends BaseMapper<InboundLoadingPickTaskEntity> {
@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}, " @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), " + "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} " + "picked_count=COALESCE(#{pickedCount}, picked_count), version=version+1 WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} "
@@ -14,7 +14,9 @@ import com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity;
import com.baiyue.firstleg.loading.domain.PickingStatus; import com.baiyue.firstleg.loading.domain.PickingStatus;
import com.baiyue.firstleg.loading.dto.LoadingResponses.PickingResponse; import com.baiyue.firstleg.loading.dto.LoadingResponses.PickingResponse;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.Snapshot; 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.loading.repository.LoadingRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException; import feign.FeignException;
@@ -41,6 +43,8 @@ public class LoadingDeliveryService {
private static final String COMPLETE_DELIVERY = "COMPLETE_DELIVERY"; private static final String COMPLETE_DELIVERY = "COMPLETE_DELIVERY";
@Resource private LoadingRepository repository; @Resource private LoadingRepository repository;
@Resource private LoadingPickRepository loadingPicks;
@Resource private LoadingModelBridgeService loadingModelBridge;
@Resource private InboundStatusHistoryMapper histories; @Resource private InboundStatusHistoryMapper histories;
@Resource private LoadingEvidenceFileClient files; @Resource private LoadingEvidenceFileClient files;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@@ -100,10 +104,12 @@ public class LoadingDeliveryService {
if (replay != null) return replay; if (replay != null) return replay;
Snapshot inbound = requireInbound(tenantId, inboundId); Snapshot inbound = requireInbound(tenantId, inboundId);
if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) { if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) {
loadingModelBridge.requireCompleted(tenantId, inboundId);
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse(inbound.processStatus(), evidenceResponse(tenantId, inboundId))); new LoadingActionResponse(inbound.processStatus(), evidenceResponse(tenantId, inboundId)));
} }
if (!"LOADING".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜中"); if (!"LOADING".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜中");
loadingModelBridge.requireCompleted(tenantId, inboundId);
requireCompletedPicking(tenantId, inboundId); requireCompletedPicking(tenantId, inboundId);
requireCompletionEvidence(tenantId, inboundId); requireCompletionEvidence(tenantId, inboundId);
if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent(); 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); LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "START_PICKING", key, hash);
if (replay != null) return pickResponse(tenantId, inboundId); if (replay != null) return pickResponse(tenantId, inboundId);
requireLoadingStage(tenantId, inboundId); requireLoadingStage(tenantId, inboundId);
if (request.expectedCount() == null || request.expectedCount() <= 0 CanonicalPicking canonical = canonicalPicking(tenantId, inboundId);
|| request.pickedCount() == null || request.pickedCount() < 0 requireMatchingPickingRequest(request, canonical);
|| request.pickedCount() > request.expectedCount()) {
throw new BizException(ResultCode.BAD_REQUEST, "拣货计划数量必须大于0且已拣数量不能超过计划数量");
}
InboundLoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId); InboundLoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
if (task == null) { if (task == null) {
if (request.expectedVersion() != 0) throw concurrent(); if (request.expectedVersion() != 0) throw concurrent();
@@ -172,10 +175,9 @@ public class LoadingDeliveryService {
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "COMPLETE_PICKING", key, hash); LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "COMPLETE_PICKING", key, hash);
if (replay != null) return pickResponse(tenantId, inboundId); if (replay != null) return pickResponse(tenantId, inboundId);
requireLoadingStage(tenantId, inboundId); requireLoadingStage(tenantId, inboundId);
if (request.expectedCount() == null || request.expectedCount() <= 0 CanonicalPicking canonical = canonicalPicking(tenantId, inboundId);
|| request.pickedCount() == null || !request.expectedCount().equals(request.pickedCount())) { requireMatchingPickingRequest(request, canonical);
throw new BizException(ResultCode.CONFLICT, "拣货复核数量必须与计划数量一致"); if (!canonical.completed()) throw new BizException(ResultCode.CONFLICT, "柜次PDA任务尚未完成复核");
}
InboundLoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId); InboundLoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
if (task == null || !PickingStatus.IN_PROGRESS.equals(task.getStatus())) { if (task == null || !PickingStatus.IN_PROGRESS.equals(task.getStatus())) {
throw new BizException(ResultCode.CONFLICT, task == null ? "拣货任务尚未开始" : "拣货任务不在作业中"); throw new BizException(ResultCode.CONFLICT, task == null ? "拣货任务尚未开始" : "拣货任务不在作业中");
@@ -204,14 +206,40 @@ public class LoadingDeliveryService {
} }
private void requireCompletedPicking(long tenantId, long inboundId) { private void requireCompletedPicking(long tenantId, long inboundId) {
InboundLoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId); if (!canonicalPicking(tenantId, inboundId).completed()) {
if (task == null || !PickingStatus.COMPLETED.equals(task.getStatus())
|| task.getExpectedCount() == null || task.getExpectedCount() <= 0
|| !task.getExpectedCount().equals(task.getPickedCount())) {
throw new BizException(ResultCode.CONFLICT, "PDA拣货复核尚未完成"); 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) { private void requireCompletionEvidence(long tenantId, long inboundId) {
Map<String, LoadingEvidenceEntity> values = repository.listEvidence(tenantId, inboundId).stream() Map<String, LoadingEvidenceEntity> values = repository.listEvidence(tenantId, inboundId).stream()
.collect(Collectors.toMap(LoadingEvidenceEntity::getEvidenceType, Function.identity(), (left, right) -> right)); .collect(Collectors.toMap(LoadingEvidenceEntity::getEvidenceType, Function.identity(), (left, right) -> right));
@@ -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<LoadingPickAssignmentEntity> {
@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);
}
@@ -9,6 +9,63 @@ import org.apache.ibatis.annotations.Update;
@Mapper @Mapper
public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity> { public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity> {
@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_count<planned_package_count")
int settlePartialPickItem(@Param("itemId") long itemId);
@Update("UPDATE fl_loading_plan_item SET deleted=1,removed_by=#{operatorId},"
+ "removed_by_name=#{operatorName},removed_at=CURRENT_TIMESTAMP(3),updated_by=#{operatorId},"
+ "updated_by_name=#{operatorName},version=version+1 WHERE id=#{planItemId} AND deleted=0 "
+ "AND EXISTS (SELECT 1 FROM fl_loading_pick_item pick_item WHERE pick_item.loading_plan_item_id=#{planItemId} "
+ "AND pick_item.item_status='SKIPPED' AND pick_item.picked_package_count=0)")
int settleSkippedPlanItem(@Param("planItemId") long planItemId,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Select("""
SELECT COALESCE(SUM(plan_item.package_count),0) expectedCount,
COALESCE(SUM(pick_item.picked_package_count),0) pickedCount,
COUNT(DISTINCT pick_task.id) taskCount,
COUNT(DISTINCT CASE WHEN pick_task.task_status='COMPLETED' THEN pick_task.id END) completedTaskCount,
SUM(CASE WHEN pick_item.id IS NULL OR pick_item.picked_package_count<>plan_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<InboundOwner> selectInboundOwners(@Param("taskId") long taskId);
record InboundOwner(Long tenantId, Long inboundId) {}
@Select(""" @Select("""
SELECT pick_item.* SELECT pick_item.*
FROM fl_loading_pick_item pick_item FROM fl_loading_pick_item pick_item
@@ -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<LoadingPickOperationEntity> {
}
@@ -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<LoadingPickReviewEntity> {
@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);
}
@@ -5,9 +5,17 @@ import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper @Mapper
public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity> { public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity> {
@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("""
SELECT DISTINCT task.* SELECT DISTINCT task.*
FROM fl_loading_pick_task task FROM fl_loading_pick_task task
@@ -8,6 +8,14 @@ import com.baiyue.firstleg.mapper.LoadingPickHistoryMapper;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper; import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
import com.baiyue.firstleg.mapper.LoadingPickTaskMapper; 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 jakarta.annotation.Resource;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@@ -22,11 +30,27 @@ public class LoadingPickRepository {
private LoadingPickItemMapper items; private LoadingPickItemMapper items;
@Resource @Resource
private LoadingPickHistoryMapper histories; private LoadingPickHistoryMapper histories;
@Resource
private LoadingPickAssignmentMapper assignments;
@Resource
private LoadingPickReviewMapper reviews;
@Resource
private LoadingPickOperationMapper operations;
@Resource
private InboundLoadingPickTaskMapper inboundProjections;
public LoadingPickTaskEntity task(long taskId) { public LoadingPickTaskEntity task(long taskId) {
return tasks.selectById(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) { public LoadingPickTaskEntity taskByPlan(long planId) {
return tasks.selectOne(new LambdaQueryWrapper<LoadingPickTaskEntity>() return tasks.selectOne(new LambdaQueryWrapper<LoadingPickTaskEntity>()
.eq(LoadingPickTaskEntity::getLoadingPlanId, planId)); .eq(LoadingPickTaskEntity::getLoadingPlanId, planId));
@@ -111,4 +135,78 @@ public class LoadingPickRepository {
public void deleteItem(long itemId) { public void deleteItem(long itemId) {
items.deleteById(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<LoadingPickOperationEntity>()
.eq(LoadingPickOperationEntity::getOperationId, operationId));
}
public void insert(LoadingPickOperationEntity operation) {
operations.insert(operation);
}
public List<LoadingPickOperationEntity> operations(long taskId) {
return operations.selectList(new LambdaQueryWrapper<LoadingPickOperationEntity>()
.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;
}
} }
@@ -7,8 +7,17 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Task; 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.LoadingPickHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPickItemEntity; 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.LoadingPickTaskEntity;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.LoadingPickRepository;
@@ -55,6 +64,7 @@ public class LoadingPickService {
for (LoadingPlanItemEntity planItem : planItems) { for (LoadingPlanItemEntity planItem : planItems) {
syncItem(task.getId(), planItem); syncItem(task.getId(), planItem);
} }
repository.syncInboundProjections(task.getId(), operatorId, operatorName);
} }
@Transactional @Transactional
@@ -79,11 +89,59 @@ public class LoadingPickService {
return view(task, warehouseId); 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 @Transactional
public Task pick(long taskId, long itemId, PickRequest request, long operatorId, String operatorName) { public Task pick(long taskId, long itemId, PickRequest request, long operatorId, String operatorName) {
String operationId = request.operationId().trim(); String operationId = request.operationId().trim();
String requestHash = hash(taskId + "|" + itemId + "|" + request.warehouseId() String requestHash = hash(taskId + "|" + itemId + "|" + request.warehouseId()
+ "|" + request.pickedPackageCount()); + "|" + request.pickedPackageCount());
requireNoTaskOperation(operationId);
LoadingPickHistoryEntity replay = repository.history(operationId); LoadingPickHistoryEntity replay = repository.history(operationId);
if (replay != null) { if (replay != null) {
if (!replay.getPickTaskId().equals(taskId) || !replay.getPickItemId().equals(itemId) if (!replay.getPickTaskId().equals(taskId) || !replay.getPickItemId().equals(itemId)
@@ -92,10 +150,7 @@ public class LoadingPickService {
} }
return view(requiredTask(taskId), request.warehouseId()); return view(requiredTask(taskId), request.warehouseId());
} }
LoadingPickTaskEntity task = requiredTask(taskId); LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId);
if (!"LOADING".equals(task.getTaskStatus())) {
throw conflict("装柜任务已完成,不能继续拣货");
}
LoadingPickItemEntity item = repository.item(taskId, itemId, request.warehouseId()); LoadingPickItemEntity item = repository.item(taskId, itemId, request.warehouseId());
if (item == null) { if (item == null) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在"); throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在");
@@ -113,7 +168,7 @@ public class LoadingPickService {
history.setOperatorId(operatorId); history.setOperatorId(operatorId);
history.setOperatorName(operatorName); history.setOperatorName(operatorName);
repository.insert(history); repository.insert(history);
finishIfResolved(task, operatorId, operatorName); touchAndSync(task, operatorId, operatorName);
return view(requiredTask(taskId), request.warehouseId()); return view(requiredTask(taskId), request.warehouseId());
} }
@@ -123,19 +178,20 @@ public class LoadingPickService {
String operationId = request.operationId().trim(); String operationId = request.operationId().trim();
String requestHash = hash("PARTIAL|" + taskId + "|" + itemId + "|" + request.warehouseId() String requestHash = hash("PARTIAL|" + taskId + "|" + itemId + "|" + request.warehouseId()
+ "|" + request.finalPickedPackageCount()); + "|" + request.finalPickedPackageCount());
requireNoTaskOperation(operationId);
LoadingPickHistoryEntity replay = repository.history(operationId); LoadingPickHistoryEntity replay = repository.history(operationId);
if (replay != null) { if (replay != null) {
assertReplay(replay, taskId, itemId, requestHash); assertReplay(replay, taskId, itemId, requestHash);
return view(requiredTask(taskId), request.warehouseId()); return view(requiredTask(taskId), request.warehouseId());
} }
LoadingPickTaskEntity task = requiredLoadingTask(taskId); LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId);
requireWarehouseItem(taskId, itemId, request.warehouseId()); requireWarehouseItem(taskId, itemId, request.warehouseId());
if (repository.partial(itemId, request.finalPickedPackageCount(), request.version(), request.warehouseId()) != 1) { if (repository.partial(itemId, request.finalPickedPackageCount(), request.version(), request.warehouseId()) != 1) {
throw conflict("部分拣货箱数必须大于已拣箱数且小于计划箱数,或明细版本已变化"); throw conflict("部分拣货箱数必须大于已拣箱数且小于计划箱数,或明细版本已变化");
} }
insertHistory(taskId, itemId, operationId, requestHash, "PARTIAL", insertHistory(taskId, itemId, operationId, requestHash, "PARTIAL",
request.finalPickedPackageCount(), null, operatorId, operatorName); request.finalPickedPackageCount(), null, operatorId, operatorName);
finishIfResolved(task, operatorId, operatorName); touchAndSync(task, operatorId, operatorName);
return view(requiredTask(taskId), request.warehouseId()); return view(requiredTask(taskId), request.warehouseId());
} }
@@ -145,26 +201,68 @@ public class LoadingPickService {
String reason = trim(request.reason()); String reason = trim(request.reason());
String requestHash = hash("SKIP|" + taskId + "|" + itemId + "|" + request.warehouseId() String requestHash = hash("SKIP|" + taskId + "|" + itemId + "|" + request.warehouseId()
+ "|" + reason); + "|" + reason);
requireNoTaskOperation(operationId);
LoadingPickHistoryEntity replay = repository.history(operationId); LoadingPickHistoryEntity replay = repository.history(operationId);
if (replay != null) { if (replay != null) {
assertReplay(replay, taskId, itemId, requestHash); assertReplay(replay, taskId, itemId, requestHash);
return view(requiredTask(taskId), request.warehouseId()); return view(requiredTask(taskId), request.warehouseId());
} }
LoadingPickTaskEntity task = requiredLoadingTask(taskId); LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId);
requireWarehouseItem(taskId, itemId, request.warehouseId()); requireWarehouseItem(taskId, itemId, request.warehouseId());
if (repository.skip(itemId, request.version(), request.warehouseId()) != 1) { if (repository.skip(itemId, request.version(), request.warehouseId()) != 1) {
throw conflict("跳拣仅允许未开始拣货的待拣明细,或明细版本已变化"); throw conflict("跳拣仅允许未开始拣货的待拣明细,或明细版本已变化");
} }
insertHistory(taskId, itemId, operationId, requestHash, "SKIP", 0, reason, insertHistory(taskId, itemId, operationId, requestHash, "SKIP", 0, reason,
operatorId, operatorName); operatorId, operatorName);
finishIfResolved(task, operatorId, operatorName); touchAndSync(task, operatorId, operatorName);
return view(requiredTask(taskId), request.warehouseId()); return view(requiredTask(taskId), request.warehouseId());
} }
private void finishIfResolved(LoadingPickTaskEntity task, long operatorId, String operatorName) { @Transactional
if (repository.allPlanItemsPicked(task.getLoadingPlanId())) { public Task review(long taskId, ReviewRequest request, long operatorId, String operatorName) {
loadingListService.finishFromPda(task.getLoadingPlanId(), task.getId(), operatorId, 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<LoadingPickItemEntity> taskItems = repository.items(taskId);
PdaPickSettlementPlan.Resolution resolution = PdaPickSettlementPlan.resolve(taskItems);
for (List<PdaPickSettlementPlan.SplitLine> 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<Operation> 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 @Transactional
@@ -218,19 +316,89 @@ public class LoadingPickService {
return task; 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) { private void requireWarehouseItem(long taskId, long itemId, long warehouseId) {
if (repository.item(taskId, itemId, warehouseId) == null) { if (repository.item(taskId, itemId, warehouseId) == null) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在或不属于当前仓库"); throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在或不属于当前仓库");
} }
} }
private Task view(LoadingPickTaskEntity task, long warehouseId) { private Task view(LoadingPickTaskEntity task, Long warehouseId) {
List<PickItem> items = repository.items(task.getId(), warehouseId).stream() List<LoadingPickItemEntity> source = warehouseId == null
? repository.items(task.getId()) : repository.items(task.getId(), warehouseId);
List<PickItem> items = source.stream()
.map(item -> new PickItem(item.getId(), item.getLoadingPlanItemId(), item.getInboundId(), .map(item -> new PickItem(item.getId(), item.getLoadingPlanItemId(), item.getInboundId(),
item.getInboundDetailId(), item.getPlannedPackageCount(), item.getPickedPackageCount(), item.getInboundDetailId(), item.getPlannedPackageCount(), item.getPickedPackageCount(),
item.getItemStatus(), item.getVersion())).toList(); 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(), 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, private void insertHistory(long taskId, long itemId, String operationId, String requestHash,
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.split.service;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.split.domain.PreloadStatus; import com.baiyue.firstleg.split.domain.PreloadStatus;
import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest; import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest;
@@ -33,6 +34,7 @@ public class SplitPreloadService {
private static final String CONFIRM_PRELOAD = "CONFIRM_PRELOAD"; private static final String CONFIRM_PRELOAD = "CONFIRM_PRELOAD";
@Resource private SplitRepository repository; @Resource private SplitRepository repository;
@Resource private LoadingModelBridgeService loadingModelBridge;
@Resource private InboundStatusHistoryMapper histories; @Resource private InboundStatusHistoryMapper histories;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@@ -100,6 +102,7 @@ public class SplitPreloadService {
if (replay != null) return replay; if (replay != null) return replay;
InboundSnapshot inbound = requireInbound(tenantId, inboundId); InboundSnapshot inbound = requireInbound(tenantId, inboundId);
if ("LOADING".equals(inbound.processStatus())) { if ("LOADING".equals(inbound.processStatus())) {
loadingModelBridge.requireStarted(tenantId, inboundId);
return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key, return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key,
new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId)))); new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId))));
} }
@@ -108,6 +111,7 @@ public class SplitPreloadService {
if (PreloadStatus.CONFIRMED.equals(plan.getStatus())) { if (PreloadStatus.CONFIRMED.equals(plan.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "预装清单已确认但订单状态尚未推进,请刷新重试"); throw new BizException(ResultCode.CONFLICT, "预装清单已确认但订单状态尚未推进,请刷新重试");
} }
loadingModelBridge.requireStarted(tenantId, inboundId);
if (repository.changePlanStatus(tenantId, inboundId, plan.getStatus(), PreloadStatus.CONFIRMED, if (repository.changePlanStatus(tenantId, inboundId, plan.getStatus(), PreloadStatus.CONFIRMED,
operatorId, operatorName, request.expectedVersion()) != 1) { operatorId, operatorName, request.expectedVersion()) != 1) {
throw concurrent(); throw concurrent();
@@ -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;
@@ -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;
@@ -20,7 +20,9 @@ paths:
'200': {description: Business envelope; ownership, type and version failures use business error codes} '200': {description: Business envelope; ownership, type and version failures use business error codes}
/api/platform/v1/inbounds/{inboundId}/loading/picking/start: /api/platform/v1/inbounds/{inboundId}/loading/picking/start:
post: 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'}] parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: requestBody:
required: true required: true
@@ -30,7 +32,9 @@ paths:
'200': {description: Business envelope containing IN_PROGRESS task} '200': {description: Business envelope containing IN_PROGRESS task}
/api/platform/v1/inbounds/{inboundId}/loading/picking/complete: /api/platform/v1/inbounds/{inboundId}/loading/picking/complete:
post: 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'}] parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: requestBody:
required: true required: true
@@ -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}
@@ -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}
@@ -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");
}
}
@@ -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 + "')");
}
}
@@ -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");
}
}
@@ -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());
}
}
@@ -21,6 +21,8 @@ class LoadingDeliveryOpenApiContractTest {
.contains("required: [expectedVersion, evidenceType, fileId]") .contains("required: [expectedVersion, evidenceType, fileId]")
.contains("enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL]") .contains("enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL]")
.contains("Required when evidenceType is 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");
} }
} }
@@ -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自动汇总");
}
}
@@ -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.LoadingRequests.UpsertEvidenceRequest;
import com.baiyue.firstleg.loading.dto.LoadingResponses.LoadingActionResponse; import com.baiyue.firstleg.loading.dto.LoadingResponses.LoadingActionResponse;
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity; 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.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.loading.repository.LoadingRepository;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -30,6 +32,8 @@ import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class LoadingDeliveryServiceTest { class LoadingDeliveryServiceTest {
@Mock LoadingRepository repository; @Mock LoadingRepository repository;
@Mock LoadingModelBridgeService loadingModelBridge;
@Mock LoadingPickRepository loadingPicks;
@Mock InboundStatusHistoryMapper histories; @Mock InboundStatusHistoryMapper histories;
@Mock LoadingEvidenceFileClient files; @Mock LoadingEvidenceFileClient files;
@InjectMocks LoadingDeliveryService service; @InjectMocks LoadingDeliveryService service;
@@ -48,9 +52,13 @@ class LoadingDeliveryServiceTest {
void completesLoadingOnlyAfterPickingAndFiveEvidenceFiles() { void completesLoadingOnlyAfterPickingAndFiveEvidenceFiles() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3)); when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
InboundLoadingPickTaskEntity pick = new InboundLoadingPickTaskEntity(); LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class);
pick.setStatus("COMPLETED"); pick.setExpectedCount(5); pick.setPickedCount(5); when(progress.getExpectedCount()).thenReturn(5);
when(repository.findPickTask(10, 20)).thenReturn(pick); 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( when(repository.listEvidence(10, 20)).thenReturn(List.of(
evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null), evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null),
evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, 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); when(repository.completeLoading(10, 20, 7, 3)).thenReturn(1);
LoadingActionResponse result = service.completeLoading(10, 20, 7, "admin", "k1", new CompleteLoadingRequest(3, null)); LoadingActionResponse result = service.completeLoading(10, 20, 7, "admin", "k1", new CompleteLoadingRequest(3, null));
assertThat(result.mainStatus()).isEqualTo("LOADED"); assertThat(result.mainStatus()).isEqualTo("LOADED");
verify(loadingModelBridge).requireCompleted(10, 20);
verify(histories).insert(any(InboundStatusHistoryEntity.class)); verify(histories).insert(any(InboundStatusHistoryEntity.class));
} }
@@ -71,9 +80,13 @@ class LoadingDeliveryServiceTest {
void rejectsCompletionWhenEvidenceIsIncomplete() { void rejectsCompletionWhenEvidenceIsIncomplete() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3)); when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
InboundLoadingPickTaskEntity pick = new InboundLoadingPickTaskEntity(); LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class);
pick.setStatus("COMPLETED"); pick.setExpectedCount(5); pick.setPickedCount(5); when(progress.getExpectedCount()).thenReturn(5);
when(repository.findPickTask(10, 20)).thenReturn(pick); 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"))); when(repository.listEvidence(10, 20)).thenReturn(List.of(evidence("SEAL", 5L, "LOCK-001")));
assertThatThrownBy(() -> service.completeLoading(10, 20, 7, "admin", "k-missing", assertThatThrownBy(() -> service.completeLoading(10, 20, 7, "admin", "k-missing",
@@ -111,13 +124,12 @@ class LoadingDeliveryServiceTest {
} }
@Test @Test
void rejectsCompletingPickingBeforeTaskStarts() { void rejectsCompletingPickingWithoutCanonicalContainerTask() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3)); 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", 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()); verify(repository, never()).createPickTask(any());
} }
@@ -23,10 +23,15 @@ class Phase3FinalUnifiedSqlContractTest {
.contains("CREATE TABLE IF NOT EXISTS fl_customs_receipt") .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")
.contains("CREATE TABLE IF NOT EXISTS fl_port_schedule_history") .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("uk_loading_evidence_seal (tenant_id,seal_no)")
.contains("CALL assert_phase3_final_schema()") .contains("CALL assert_phase3_final_schema()")
.contains("trg_port_schedule_history_before_update") .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 @Test
@@ -36,10 +41,12 @@ class Phase3FinalUnifiedSqlContractTest {
for (int id = 5102160; id <= 5102164; id++) assertThat(sql).contains(Integer.toString(id)); 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 = 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 = 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) assertThat(sql)
.contains("platform:firstleg:outbound-dispatch:%") .contains("platform:firstleg:outbound-dispatch:%")
.contains("platform:firstleg:customs:%") .contains("platform:firstleg:customs:%")
.contains("platform:firstleg:port-schedule:%") .contains("platform:firstleg:port-schedule:%")
.contains("platform:firstleg:pda:%")
.contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')") .contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')")
.contains("角色授权未完整写入"); .contains("角色授权未完整写入");
} }
@@ -3,7 +3,10 @@ package com.baiyue.firstleg.service;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest; 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.LoadingPickHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPickOperationEntity;
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
import com.baiyue.firstleg.entity.LoadingPickItemEntity; import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity; import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.LoadingPickRepository;
@@ -34,9 +37,12 @@ class LoadingPickServiceTest {
LoadingPickItemEntity item = item(); LoadingPickItemEntity item = item();
when(repository.history("pick-1")).thenReturn(null); when(repository.history("pick-1")).thenReturn(null);
when(repository.task(20L)).thenReturn(task); 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.item(20L, 30L, 101L)).thenReturn(item);
when(repository.pick(30L, 2, 0, 101L)).thenReturn(1); when(repository.pick(30L, 2, 0, 101L)).thenReturn(1);
when(repository.allPlanItemsPicked(10L)).thenReturn(false); when(repository.allPlanItemsPicked(10L)).thenReturn(false);
when(repository.touchTask(20L, 0)).thenReturn(1);
when(repository.items(20L, 101L)).thenReturn(List.of(item)); when(repository.items(20L, 101L)).thenReturn(List.of(item));
service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-1"), 9L, "operator"); service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-1"), 9L, "operator");
@@ -50,14 +56,16 @@ class LoadingPickServiceTest {
LoadingPickItemEntity item = item(); LoadingPickItemEntity item = item();
when(repository.history("pick-2")).thenReturn(null); when(repository.history("pick-2")).thenReturn(null);
when(repository.task(20L)).thenReturn(task); 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.item(20L, 30L, 101L)).thenReturn(item);
when(repository.pick(30L, 2, 0, 101L)).thenReturn(1); 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.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"); 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)); verify(repository).insert(any(LoadingPickHistoryEntity.class));
} }
@@ -67,14 +75,16 @@ class LoadingPickServiceTest {
LoadingPickItemEntity item = item(); LoadingPickItemEntity item = item();
when(repository.history("partial-1")).thenReturn(null); when(repository.history("partial-1")).thenReturn(null);
when(repository.task(20L)).thenReturn(task); 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.item(20L, 30L, 101L)).thenReturn(item);
when(repository.partial(30L, 1, 0, 101L)).thenReturn(1); 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.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"); 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 @Test
@@ -83,14 +93,37 @@ class LoadingPickServiceTest {
LoadingPickItemEntity item = item(); LoadingPickItemEntity item = item();
when(repository.history("skip-1")).thenReturn(null); when(repository.history("skip-1")).thenReturn(null);
when(repository.task(20L)).thenReturn(task); 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.item(20L, 30L, 101L)).thenReturn(item);
when(repository.skip(30L, 0, 101L)).thenReturn(1); 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.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"); 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(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
verify(repository).syncInboundProjections(20L, 9L, "operator");
} }
private static LoadingPickTaskEntity task() { private static LoadingPickTaskEntity task() {
@@ -116,4 +149,13 @@ class LoadingPickServiceTest {
item.setVersion(0); item.setVersion(0);
return item; return item;
} }
private static LoadingPickAssignmentEntity assignment() {
LoadingPickAssignmentEntity assignment = new LoadingPickAssignmentEntity();
assignment.setPickTaskId(20L);
assignment.setAssigneeId(9L);
assignment.setAssigneeName("operator");
assignment.setVersion(0);
return assignment;
}
} }
@@ -1,6 +1,7 @@
package com.baiyue.firstleg.split.service; package com.baiyue.firstleg.split.service;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; 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.ConfirmPreloadRequest;
import com.baiyue.firstleg.split.dto.SplitRequests.PreloadItem; import com.baiyue.firstleg.split.dto.SplitRequests.PreloadItem;
import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest; import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest;
@@ -30,6 +31,7 @@ import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class SplitPreloadServiceTest { class SplitPreloadServiceTest {
@Mock SplitRepository repository; @Mock SplitRepository repository;
@Mock LoadingModelBridgeService loadingModelBridge;
@Mock InboundStatusHistoryMapper histories; @Mock InboundStatusHistoryMapper histories;
@InjectMocks SplitPreloadService service; @InjectMocks SplitPreloadService service;
private final ObjectMapper objectMapper = new ObjectMapper(); 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.setTenantId(10L); plan.setInboundId(20L); plan.setStatus("CONFIRMING"); plan.setVersion(1);
plan.setItemsJson("[]"); plan.setItemsJson("[]");
when(repository.findPlan(10, 20)).thenReturn(plan); 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.changePlanStatus(10, 20, "CONFIRMING", "CONFIRMED", 7, "admin", 1)).thenReturn(1);
when(repository.beginLoading(10, 20, 7, 4)).thenReturn(1); when(repository.beginLoading(10, 20, 7, 4)).thenReturn(1);
SplitActionResponse result = service.confirmPreload(10, 20, 7, "admin", "k3", SplitActionResponse result = service.confirmPreload(10, 20, 7, "admin", "k3",
new ConfirmPreloadRequest(1)); new ConfirmPreloadRequest(1));
assertThat(result.mainStatus()).isEqualTo("LOADING"); assertThat(result.mainStatus()).isEqualTo("LOADING");
verify(loadingModelBridge).requireStarted(10, 20);
verify(repository).beginLoading(10, 20, 7, 4); verify(repository).beginLoading(10, 20, 7, 4);
} }
} }
@@ -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 }) } 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) { 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 suffix = context.marker.replace(/\D/g, '').slice(-10)
const list = await request(context, 'GET', `${BASE}?page=1&size=5&keyword=${encodeURIComponent(context.marker)}`) const list = await request(context, 'GET', `${BASE}?page=1&size=5&keyword=${encodeURIComponent(context.marker)}`)
if (missing(list) || list.http === 0) { 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` }) 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) context.apiSuccess(MODULE, '申请改船', changed, '', OPTIONS); version = Number(data(changed)?.oceanBooking?.version)
const changeConfirmed = await action('确认改船', 'change-confirm', version, 'confirm change') const changeConfirmed = await action('确认改船', 'change-confirm', version, 'confirm change')
if (containerFlow?.finalStatus === 'COMPLETED') { if (containerFlow?.finalStatus === 'COMPLETED' || containerFlow?.finalStatus === 'LOADED') {
context.assert(MODULE, '订舱与本轮柜次客户归属一致', changed, context.assert(MODULE, '订舱与本轮柜次客户归属一致', changed,
id(containerFlow?.tenantId) === id(changeConfirmed?.oceanBooking?.customerId), id(containerFlow?.tenantId) === id(changeConfirmed?.oceanBooking?.customerId),
`bookingCustomerId=${id(changeConfirmed?.oceanBooking?.customerId) || 'missing'}, containerTenantId=${id(containerFlow?.tenantId) || 'missing'}`, OPTIONS) `bookingCustomerId=${id(changeConfirmed?.oceanBooking?.customerId) || 'missing'}, containerTenantId=${id(containerFlow?.tenantId) || 'missing'}`, OPTIONS)
@@ -174,6 +174,7 @@ export async function run(context) {
const pickItem = task?.items?.find?.((item) => id(item.loadingPlanItemId) === id(planItem.id)) const pickItem = task?.items?.find?.((item) => id(item.loadingPlanItemId) === id(planItem.id))
context.assert(MODULE, 'PDA 任务、计划明细和待拣箱数关联正确', taskResponse, context.assert(MODULE, 'PDA 任务、计划明细和待拣箱数关联正确', taskResponse,
Boolean(taskId) && id(task?.loadingPlanId) === planId && task?.taskStatus === 'LOADING' Boolean(taskId) && id(task?.loadingPlanId) === planId && task?.taskStatus === 'LOADING'
&& task?.workflowStatus === 'UNASSIGNED'
&& Boolean(pickItem?.id) && Number(pickItem?.plannedPackageCount) === 1, && Boolean(pickItem?.id) && Number(pickItem?.plannedPackageCount) === 1,
`taskId=${taskId || 'missing'}, itemId=${id(pickItem?.id) || 'missing'}`, OPTIONS) `taskId=${taskId || 'missing'}, itemId=${id(pickItem?.id) || 'missing'}`, OPTIONS)
if (!taskId || !pickItem?.id) return if (!taskId || !pickItem?.id) return
@@ -183,6 +184,15 @@ export async function run(context) {
context.addStep(MODULE, 'PDA 任务仓库隔离拒绝', wrongWarehouseResponse, context.addStep(MODULE, 'PDA 任务仓库隔离拒绝', wrongWarehouseResponse,
failure(wrongWarehouseResponse), `HTTP=${wrongWarehouseResponse.http}, code=${codeOf(wrongWarehouseResponse)}`, OPTIONS) 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`, { const stalePickResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, {
warehouseId, pickedPackageCount: 1, locationIds: [], version: Number(pickItem.version) + 1, warehouseId, pickedPackageCount: 1, locationIds: [], version: Number(pickItem.version) + 1,
operationId: `${context.marker}-pda-stale`.slice(0, 128), 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 pickResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, pickPayload)
const picked = context.apiSuccess(MODULE, '执行本轮 PDA 全量拣货', pickResponse, '', OPTIONS) const picked = context.apiSuccess(MODULE, '执行本轮 PDA 全量拣货', pickResponse, '', OPTIONS)
context.assert(MODULE, '最后一箱拣货后 PDA 任务进入 COMPLETED', pickResponse, context.assert(MODULE, '最后一箱拣货后 PDA 任务等待复核', pickResponse,
picked?.taskStatus === 'COMPLETED' picked?.taskStatus === 'LOADING' && picked?.workflowStatus === 'AWAITING_REVIEW'
&& picked?.items?.every?.((item) => Number(item.pickedPackageCount) === Number(item.plannedPackageCount)), && 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 pickReplayResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, pickPayload)
const pickReplay = context.apiSuccess(MODULE, 'PDA operationId 幂等重放', pickReplayResponse, '', OPTIONS) const pickReplay = context.apiSuccess(MODULE, 'PDA operationId 幂等重放', pickReplayResponse, '', OPTIONS)
context.assert(MODULE, 'PDA 重放保持已完成任务和拣货数', pickReplayResponse, context.assert(MODULE, 'PDA 重放保持待复核任务和拣货数', pickReplayResponse,
pickReplay?.taskStatus === 'COMPLETED' pickReplay?.taskStatus === 'LOADING' && pickReplay?.workflowStatus === 'AWAITING_REVIEW'
&& pickReplay?.items?.every?.((item) => Number(item.pickedPackageCount) === Number(item.plannedPackageCount)), && pickReplay?.items?.every?.((item) => Number(item.pickedPackageCount) === Number(item.plannedPackageCount)),
`taskStatus=${pickReplay?.taskStatus || 'missing'}`, OPTIONS) `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 finalPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`)
const finalPlan = context.apiSuccess(MODULE, '回查柜次装柜计划终态', finalPlanResponse, '', OPTIONS) const finalPlan = context.apiSuccess(MODULE, '回查柜次装柜计划终态', finalPlanResponse, '', OPTIONS)
context.assert(MODULE, '装柜计划进入 COMPLETED 且明细已冻结', finalPlanResponse, context.assert(MODULE, '装柜计划进入 COMPLETED 且明细已冻结', finalPlanResponse,
@@ -243,6 +263,7 @@ export async function run(context) {
context.createdResources.phase3ContainerLoading = { context.createdResources.phase3ContainerLoading = {
marker: context.marker, tenantId, inboundId, warehouseId, containerBookingId: containerId, marker: context.marker, tenantId, inboundId, warehouseId, containerBookingId: containerId,
loadingPlanId: planId, pdaTaskId: taskId, finalStatus: 'COMPLETED', loadingPlanId: planId, pdaTaskId: taskId, pdaReviewVersion: reviewed?.review?.version,
finalStatus: 'COMPLETED',
} }
} }
@@ -68,13 +68,16 @@ async function detail(context, caseId) {
} }
export async function run(context) { export async function run(context) {
const fixture = context.createdResources?.orchestrationFixtures?.phase3Container const fixture = context.createdResources?.orchestrationFixtures?.inboundMeasurement
const container = context.createdResources?.phase3ContainerLoading || context.createdResources?.orchestrationFixtures?.phase3Container
const container = context.createdResources?.phase3Loading
|| context.createdResources?.phase3ContainerLoading
const tenantId = id(fixture?.tenantId) const tenantId = id(fixture?.tenantId)
const orderId = id(fixture?.orderId) const orderId = id(fixture?.orderId)
const inboundId = id(fixture?.inboundId) const inboundId = id(fixture?.inboundId)
const containerBookingId = id(container?.containerBookingId) 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, context.addStep(MODULE, '取得本轮客户/订单/入库单/柜次隔离 fixture', null, false,
`tenantId=${tenantId || 'missing'}, orderId=${orderId || 'missing'}, inboundId=${inboundId || 'missing'}, containerBookingId=${containerBookingId || 'missing'}, containerStatus=${container?.finalStatus || 'missing'}`, OPTIONS) `tenantId=${tenantId || 'missing'}, orderId=${orderId || 'missing'}, inboundId=${inboundId || 'missing'}, containerBookingId=${containerBookingId || 'missing'}, containerStatus=${container?.finalStatus || 'missing'}`, OPTIONS)
return return
@@ -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) { export async function run(context) {
registerPendingContract(context, { const fixture = context.createdResources?.phase3Loading
module: 'phase3-loading-model-bridge', scenario: 'parallel-loading-models', name: '入库单级预装与柜次级智能装柜串联', const tenantId = id(fixture?.tenantId); const inboundId = id(fixture?.inboundId)
reason: 'fl_preload_plan 按入库单推进 LOADING/LOADED,fl_loading_plan 按柜次推进 EDITING/LOADING/COMPLETED;两套公开 API 不会互相创建、绑定或校验终态', category: 'contract-gap', const loadingPlanId = id(fixture?.loadingPlanId)
fixtures: ['phase3Loading', 'phase3ContainerLoading'], contractSource: '待新增两套装柜模型关联键与联动契约', if (!tenantId || !inboundId || !loadingPlanId || fixture?.marker !== context.marker) {
assertions: ['同一 inbound 与柜次计划双向可查', '任一侧版本冲突不产生部分更新', '双终态一致后方可进入出仓门禁'], 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)
} }
@@ -83,6 +83,52 @@ export async function run(context) {
const base = `/api/platform/v1/inbounds/${inboundId}` const base = `/api/platform/v1/inbounds/${inboundId}`
const suffix = context.marker.replace(/\D/g, '').slice(-10) 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)}`, { const smart = await request(context, 'POST', `${base}/split/smart?tenantId=${encodeURIComponent(tenantId)}`, {
expectedVersion: 0, expectedVersion: 0,
items: [{ containerCode: `P3-${suffix}`, cargoReference: `REF-${suffix}`, packageCount: 1, volumeCbm: 0.01, remark: context.marker }], 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, && replay.body?.data?.preloadPlan?.items?.length === 1,
`HTTP=${replay.http}, code=${codeOf(replay)}, planId=${id(replay.body?.data?.preloadPlan?.id) || 'missing'}`, OPTIONS) `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)}`, 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) const confirmingData = context.apiSuccess(MODULE, '提交预装清单确认', confirming, '', OPTIONS)
context.assert(MODULE, '预装清单进入 CONFIRMING 且版本递增', confirming, 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) `status=${confirmingData?.preloadPlan?.status || 'missing'}, version=${confirmingData?.preloadPlan?.version ?? 'missing'}`, OPTIONS)
if (confirmingData?.preloadPlan?.status !== 'CONFIRMING') return if (confirmingData?.preloadPlan?.status !== 'CONFIRMING') return
@@ -117,7 +183,7 @@ export async function run(context) {
const confirmed = context.apiSuccess(MODULE, '确认预装并进入装柜中', confirm, '', OPTIONS) const confirmed = context.apiSuccess(MODULE, '确认预装并进入装柜中', confirm, '', OPTIONS)
context.assert(MODULE, '预装确认推进主状态 LOADING 和计划 CONFIRMED', confirm, context.assert(MODULE, '预装确认推进主状态 LOADING 和计划 CONFIRMED', confirm,
confirmed?.mainStatus === 'LOADING' && confirmed?.preloadPlan?.status === 'CONFIRMED' 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) `mainStatus=${confirmed?.mainStatus || 'missing'}, planStatus=${confirmed?.preloadPlan?.status || 'missing'}, version=${confirmed?.preloadPlan?.version ?? 'missing'}`, OPTIONS)
if (confirmed?.mainStatus !== 'LOADING') return if (confirmed?.mainStatus !== 'LOADING') return
@@ -142,13 +208,58 @@ export async function run(context) {
context.addStep(MODULE, '未完成 PDA 拣货时拒绝完成装柜', blockedBeforePicking, failure(blockedBeforePicking), context.addStep(MODULE, '未完成 PDA 拣货时拒绝完成装柜', blockedBeforePicking, failure(blockedBeforePicking),
`HTTP=${blockedBeforePicking.http}, code=${codeOf(blockedBeforePicking)}`, OPTIONS) `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)}`, 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) }) { 'Idempotency-Key': `${context.marker}-p3-picking-start`.slice(0, 128) })
const pickingStarted = context.apiSuccess(MODULE, '开始本轮 PDA 拣货任务', pickingStart, '', OPTIONS) const pickingStarted = context.apiSuccess(MODULE, '开始本轮 PDA 拣货任务', pickingStart, '', OPTIONS)
context.assert(MODULE, 'PDA 拣货任务处于 IN_PROGRESS 且计划数量正确', pickingStart, context.assert(MODULE, 'PDA 拣货任务处于 IN_PROGRESS 且计划数量正确', pickingStart,
pickingStarted?.status === 'IN_PROGRESS' && Number(pickingStarted?.version) === 0 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) `status=${pickingStarted?.status || 'missing'}, version=${pickingStarted?.version ?? 'missing'}`, OPTIONS)
const pickingMismatch = await request(context, 'POST', `${base}/loading/picking/complete?tenantId=${encodeURIComponent(tenantId)}`, 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) `status=${finalData?.summary?.processStatus || 'missing'}, version=${finalData?.summary?.version ?? 'missing'}, histories=${finalData?.statusHistory?.length ?? 'n/a'}`, OPTIONS)
context.createdResources.phase3Loading = { context.createdResources.phase3Loading = {
tenantId, inboundId, preloadPlanId: planId, evidenceIds: evidenceItems.map((item) => id(item.id)), 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', pickingTaskId: id(pickingCompleted?.id), marker: context.marker, finalStatus: 'LOADED',
} }
@@ -28,10 +28,12 @@ async function request(context, method, path, body, headers = {}) {
} }
export async function run(context) { export async function run(context) {
const fixture = context.createdResources?.phase3ContainerLoading const fixture = context.createdResources?.phase3Loading
|| context.createdResources?.phase3ContainerLoading
const inboundId = id(fixture?.inboundId) const inboundId = id(fixture?.inboundId)
const containerBookingId = id(fixture?.containerBookingId) 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, '国内出仓主聚合真实闭环', context.addCoverageGap(MODULE, OPTIONS.scenario, '国内出仓主聚合真实闭环',
'本轮未取得 marker 隔离的柜次装柜 fixture,未使用历史资源', 'fixture') '本轮未取得 marker 隔离的柜次装柜 fixture,未使用历史资源', 'fixture')
return return
@@ -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) { export async function run(context) {
registerPendingContract(context, { const fixture = context.createdResources?.phase3Loading
module: 'phase3-pda-convergence', scenario: 'pda-picking-convergence', name: 'PDA 柜次拣货与入库单汇总拣货收敛', const tenantId = id(fixture?.tenantId); const inboundId = id(fixture?.inboundId)
reason: '柜次级 /first-leg/pda-loading 和入库单级 /inbounds/{id}/loading/picking 是两套独立任务;当前无领取、作业员分配、复核和统一完成门禁', category: 'contract-gap', const taskId = id(fixture?.canonicalPdaTaskId); const containerNo = fixture?.containerNo
fixtures: ['phase3Loading', 'phase3ContainerLoading'], contractSource: '待新增 PDA 任务领取、分配、复核与汇总契约', const warehouseId = id(fixture?.warehouseId)
assertions: ['任务领取和作业员隔离', '柜次明细与入库汇总数量一致', '复核完成幂等且成为装柜门禁前置'], 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)
} }
@@ -85,8 +85,9 @@ export async function run(context) {
const ocean = await ensureOceanBooking(context, suffix) const ocean = await ensureOceanBooking(context, suffix)
if (!ocean?.id) return if (!ocean?.id) return
const flow = context.createdResources?.phase3ContainerLoading const flow = context.createdResources?.phase3Loading
const containerBookingId = flow?.finalStatus === 'COMPLETED' && id(flow?.tenantId) === '10001' || context.createdResources?.phase3ContainerLoading
const containerBookingId = ['COMPLETED', 'LOADED'].includes(flow?.finalStatus)
? id(flow.containerBookingId) : null ? id(flow.containerBookingId) : null
const knownAllocation = context.createdResources?.oceanBooking?.id === ocean.id && containerBookingId const knownAllocation = context.createdResources?.oceanBooking?.id === ocean.id && containerBookingId
? containerBookingId : null ? containerBookingId : null
@@ -8,8 +8,10 @@ function codeOf(response) { return response?.body?.code == null ? 'missing' : St
export async function run(context) { export async function run(context) {
const ocean = context.createdResources?.oceanBooking const ocean = context.createdResources?.oceanBooking
const containerFlow = context.createdResources?.phase3ContainerLoading const containerFlow = context.createdResources?.phase3Loading
const containerFixture = context.createdResources?.orchestrationFixtures?.phase3Container || context.createdResources?.phase3ContainerLoading
const containerFixture = context.createdResources?.orchestrationFixtures?.inboundMeasurement
|| context.createdResources?.orchestrationFixtures?.phase3Container
if (!ocean?.id || ocean.finalStatus !== 'RELEASED') { if (!ocean?.id || ocean.finalStatus !== 'RELEASED') {
context.addStep(MODULE, '跨服务链路前置订舱终态可用', null, false, context.addStep(MODULE, '跨服务链路前置订舱终态可用', null, false,
'本轮海运订舱模块未返回 RELEASED 隔离记录,未伪造关联拖车预约', OPTIONS) '本轮海运订舱模块未返回 RELEASED 隔离记录,未伪造关联拖车预约', OPTIONS)
@@ -22,7 +24,7 @@ export async function run(context) {
const customerId = id(oceanDetail?.oceanBooking?.customerId) const customerId = id(oceanDetail?.oceanBooking?.customerId)
context.assert(MODULE, '跨服务链路订舱客户归属有效', oceanResponse, Boolean(customerId), `customerId=${customerId || 'missing'}`, OPTIONS) context.assert(MODULE, '跨服务链路订舱客户归属有效', oceanResponse, Boolean(customerId), `customerId=${customerId || 'missing'}`, OPTIONS)
if (!customerId) return if (!customerId) return
const linkContainer = containerFlow?.finalStatus === 'COMPLETED' const linkContainer = (containerFlow?.finalStatus === 'COMPLETED' || containerFlow?.finalStatus === 'LOADED')
&& id(containerFlow?.tenantId) === customerId && id(containerFlow?.tenantId) === customerId
&& Boolean(containerFlow?.containerBookingId) && Boolean(containerFlow?.containerBookingId)
&& Boolean(containerFlow?.loadingPlanId) && Boolean(containerFlow?.loadingPlanId)