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');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
/* Phase 3: link inbound preload plans to one or more container loading plans. */
CREATE TABLE IF NOT EXISTS fl_preload_loading_plan_link (
id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL,
preload_plan_id BIGINT UNSIGNED NOT NULL,
loading_plan_id BIGINT UNSIGNED NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_preload_loading_pair (tenant_id, inbound_id, loading_plan_id),
UNIQUE KEY uk_preload_loading_idempotency (created_by, idempotency_key),
KEY idx_preload_loading_preload (preload_plan_id, loading_plan_id),
KEY idx_preload_loading_plan (loading_plan_id, tenant_id, inbound_id),
CONSTRAINT fk_preload_loading_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id),
CONSTRAINT fk_preload_loading_preload FOREIGN KEY (preload_plan_id) REFERENCES fl_preload_plan (id),
CONSTRAINT fk_preload_loading_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
CONSTRAINT chk_preload_loading_tenant CHECK (tenant_id > 0)
) ENGINE=InnoDB COMMENT='入库单预装与柜次装柜计划多对多关联';
INSERT IGNORE INTO fl_preload_loading_plan_link
(id, tenant_id, inbound_id, preload_plan_id, loading_plan_id, idempotency_key,
request_hash, created_by, created_by_name, created_at)
SELECT CAST(CONV(SUBSTRING(SHA2(CONCAT('PRELOAD_LOADING|', item.account_id, '|', item.inbound_id,
'|', item.loading_plan_id), 256), 1, 15), 16, 10) AS UNSIGNED),
item.account_id, item.inbound_id, preload.id, item.loading_plan_id,
CONCAT('BACKFILL-', item.loading_plan_id, '-', item.inbound_id),
SHA2(CONCAT('BACKFILL|', item.account_id, '|', item.inbound_id, '|', item.loading_plan_id), 256),
preload.created_by, preload.created_by_name, LEAST(preload.created_at, plan.created_at)
FROM (SELECT DISTINCT account_id, inbound_id, loading_plan_id
FROM fl_loading_plan_item WHERE deleted = 0) item
JOIN fl_preload_plan preload ON preload.tenant_id = item.account_id
AND preload.inbound_id = item.inbound_id
JOIN fl_loading_plan plan ON plan.id = item.loading_plan_id;
/* Phase 3: PDA assignment, immutable task operations and review records. */
CREATE TABLE IF NOT EXISTS fl_loading_pick_assignment (
pick_task_id BIGINT UNSIGNED NOT NULL,
assignee_id BIGINT UNSIGNED NOT NULL,
assignee_name VARCHAR(64) NOT NULL,
assigned_by BIGINT UNSIGNED NOT NULL,
assigned_by_name VARCHAR(64) NULL,
assignment_type VARCHAR(16) NOT NULL,
version INT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (pick_task_id),
KEY idx_loading_pick_assignee (assignee_id, updated_at),
CONSTRAINT fk_loading_pick_assignment_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
CONSTRAINT chk_loading_pick_assignment_type CHECK (assignment_type IN ('CLAIMED', 'ASSIGNED'))
) ENGINE=InnoDB COMMENT='柜次PDA任务领取与作业员分配';
CREATE TABLE IF NOT EXISTS fl_loading_pick_review (
pick_task_id BIGINT UNSIGNED NOT NULL,
review_status VARCHAR(16) NOT NULL DEFAULT 'APPROVED',
reviewed_by BIGINT UNSIGNED NOT NULL,
reviewed_by_name VARCHAR(64) NULL,
remark VARCHAR(500) NULL,
version INT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (pick_task_id),
KEY idx_loading_pick_reviewer (reviewed_by, created_at),
CONSTRAINT fk_loading_pick_review_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
CONSTRAINT chk_loading_pick_review_status CHECK (review_status = 'APPROVED')
) ENGINE=InnoDB COMMENT='柜次PDA任务完成复核';
CREATE TABLE IF NOT EXISTS fl_loading_pick_operation (
id BIGINT UNSIGNED NOT NULL,
pick_task_id BIGINT UNSIGNED NOT NULL,
operation_id VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
action_type VARCHAR(16) NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operator_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_loading_pick_operation_id (operation_id),
KEY idx_loading_pick_operation_task (pick_task_id, created_at),
CONSTRAINT fk_loading_pick_operation_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
CONSTRAINT chk_loading_pick_operation_action CHECK (action_type IN ('CLAIM', 'ASSIGN', 'REVIEW'))
) ENGINE=InnoDB COMMENT='柜次PDA任务级幂等审计';
DROP TRIGGER IF EXISTS trg_loading_pick_review_before_update;
DELIMITER $$
CREATE TRIGGER trg_loading_pick_review_before_update BEFORE UPDATE ON fl_loading_pick_review
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PDA复核记录不可修改'; END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_loading_pick_review_before_delete;
DELIMITER $$
CREATE TRIGGER trg_loading_pick_review_before_delete BEFORE DELETE ON fl_loading_pick_review
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PDA复核记录不可删除'; END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_loading_pick_operation_before_update;
DELIMITER $$
CREATE TRIGGER trg_loading_pick_operation_before_update BEFORE UPDATE ON fl_loading_pick_operation
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PDA任务操作历史不可修改'; END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_loading_pick_operation_before_delete;
DELIMITER $$
CREATE TRIGGER trg_loading_pick_operation_before_delete BEFORE DELETE ON fl_loading_pick_operation
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PDA任务操作历史不可删除'; END$$
DELIMITER ;
USE baiyue_iam;
DROP PROCEDURE IF EXISTS assert_phase3_pda_permission_ids;
DELIMITER $$
CREATE PROCEDURE assert_phase3_pda_permission_ids()
BEGIN
IF EXISTS (SELECT 1 FROM iam_permission WHERE id BETWEEN 5102190 AND 5102193
AND permission_code NOT IN ('platform:firstleg:pda:view','platform:firstleg:pda:assign',
'platform:firstleg:pda:work','platform:firstleg:pda:review')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PDA权限ID已被其他资源占用';
END IF;
END$$
DELIMITER ;
CALL assert_phase3_pda_permission_ids();
DROP PROCEDURE IF EXISTS assert_phase3_pda_permission_ids;
INSERT INTO iam_permission
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
VALUES
(5102190,0,'PLATFORM','MENU',5102094,'platform:firstleg:pda:view','PDA装柜拣货','/first-leg/pda-loading','GET',10,'ACTIVE',1,0),
(5102191,0,'PLATFORM','BUTTON',5102190,'platform:firstleg:pda:assign','分配PDA任务',NULL,'POST',20,'ACTIVE',1,0),
(5102192,0,'PLATFORM','BUTTON',5102190,'platform:firstleg:pda:work','执行PDA拣货',NULL,'POST',30,'ACTIVE',1,0),
(5102193,0,'PLATFORM','BUTTON',5102190,'platform:firstleg:pda:review','复核PDA任务',NULL,'POST',40,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE permission_code=VALUES(permission_code),permission_name=VALUES(permission_name),
parent_id=VALUES(parent_id),route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),status='ACTIVE',built_in=1,deleted=0;
INSERT IGNORE INTO iam_role_permission(role_id,permission_id,tenant_id)
SELECT r.id,p.id,0 FROM iam_role r JOIN iam_permission p ON p.id BETWEEN 5102190 AND 5102193
WHERE r.tenant_id=0 AND r.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') AND p.tenant_id=0 AND p.deleted=0;
USE baiyue_firstleg;
DROP PROCEDURE IF EXISTS assert_phase3_final_schema;
DELIMITER $$
CREATE PROCEDURE assert_phase3_final_schema()
@@ -8398,7 +8533,9 @@ BEGIN
'fl_outbound_dispatch','fl_outbound_dispatch_history','fl_outbound_dispatch_operation',
'fl_customs_checklist_version','fl_customs_case','fl_customs_document','fl_customs_supplement_request',
'fl_customs_submission_batch','fl_customs_receipt','fl_customs_history','fl_customs_operation',
'fl_port_schedule','fl_port_schedule_history','fl_port_schedule_operation')) <> 14 THEN
'fl_port_schedule','fl_port_schedule_history','fl_port_schedule_operation',
'fl_preload_loading_plan_link','fl_loading_pick_assignment','fl_loading_pick_review',
'fl_loading_pick_operation')) <> 18 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期出仓、报关或船期表未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.statistics
@@ -8422,13 +8559,17 @@ BEGIN
'fk_customs_document_case','fk_customs_supplement_case','fk_customs_batch_case',
'fk_customs_receipt_case','fk_customs_receipt_batch','fk_customs_history_case',
'fk_port_schedule_booking','fk_port_schedule_container','fk_port_schedule_history',
'fk_port_schedule_operation')) <> 13 THEN
'fk_port_schedule_operation','fk_preload_loading_inbound','fk_preload_loading_preload',
'fk_preload_loading_plan','fk_loading_pick_assignment_task','fk_loading_pick_review_task',
'fk_loading_pick_operation_task')) <> 19 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期关键外键未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.triggers
WHERE trigger_schema='baiyue_firstleg' AND trigger_name IN (
'trg_port_schedule_before_insert','trg_port_schedule_before_update',
'trg_port_schedule_history_before_update','trg_port_schedule_history_before_delete')) <> 4 THEN
'trg_port_schedule_history_before_update','trg_port_schedule_history_before_delete',
'trg_loading_pick_review_before_update','trg_loading_pick_review_before_delete',
'trg_loading_pick_operation_before_update','trg_loading_pick_operation_before_delete')) <> 8 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:港务船期归属或历史保护触发器未完整创建';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM'
@@ -8439,6 +8580,10 @@ BEGIN
AND id BETWEEN 5102180 AND 5102183 AND permission_code LIKE 'platform:firstleg:port-schedule:%') <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期出仓、报关或船期权限未完整写入';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM'
AND id BETWEEN 5102190 AND 5102193 AND permission_code LIKE 'platform:firstleg:pda:%') <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:PDA权限未完整写入';
END IF;
IF (SELECT COUNT(DISTINCT CONCAT(r.role_code,'|',p.permission_code))
FROM baiyue_iam.iam_role_permission rp
JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id
@@ -8446,7 +8591,8 @@ BEGIN
WHERE r.tenant_id=0 AND r.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
AND (p.permission_code LIKE 'platform:firstleg:outbound-dispatch:%'
OR p.permission_code LIKE 'platform:firstleg:customs:%'
OR p.permission_code LIKE 'platform:firstleg:port-schedule:%')) <> 30 THEN
OR p.permission_code LIKE 'platform:firstleg:port-schedule:%'
OR p.permission_code LIKE 'platform:firstleg:pda:%')) <> 38 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期出仓、报关或船期角色授权未完整写入';
END IF;
END$$
@@ -8456,4 +8602,4 @@ DROP PROCEDURE IF EXISTS assert_phase3_final_schema;
-- WAIT_SPLIT 是二期当前有效终点;统一迁移不得改写主表或历史状态。
SELECT 'PASS' AS migration_status,
'统一增量全部执行完成;箱号映射、单箱测量、三期订舱、拖车预约、出仓、费用、公共资源及可靠审计结构校验通过' AS message;
'统一增量全部执行完成;箱号映射、单箱测量、三期订舱、拖车预约、出仓、报关、船期、装柜模型桥接、PDA收敛、费用、公共资源及可靠审计结构校验通过' AS message;
@@ -5,6 +5,11 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Operation;
import java.util.List;
import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.LoadingPickService;
@@ -31,7 +36,7 @@ public class PlatformLoadingPdaController {
private LoadingPickService service;
@GetMapping("/tasks")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<Task> task(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String containerNo,
@RequestParam @Positive long warehouseId) {
@@ -39,8 +44,26 @@ public class PlatformLoadingPdaController {
return ApiResponse.success(service.task(containerNo, warehouseId));
}
@PostMapping("/tasks/{taskId}/claim")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<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")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> pick(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@PathVariable @Positive long itemId,
@@ -50,7 +73,7 @@ public class PlatformLoadingPdaController {
}
@PostMapping("/tasks/{taskId}/items/{itemId}/partial")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> partial(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@PathVariable @Positive long itemId,
@@ -60,7 +83,7 @@ public class PlatformLoadingPdaController {
}
@PostMapping("/tasks/{taskId}/items/{itemId}/skip")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> skip(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@PathVariable @Positive long itemId,
@@ -68,4 +91,21 @@ public class PlatformLoadingPdaController {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.skip(taskId, itemId, request, user.userId(), user.username()));
}
@PostMapping("/tasks/{taskId}/review")
@PreAuthorize("hasAuthority('platform:firstleg:pda:review')")
public ApiResponse<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 java.util.List;
import java.time.LocalDateTime;
/** PDA 装柜拣货接口模型。 */
public final class LoadingPickDtos {
@@ -41,12 +42,46 @@ public final class LoadingPickDtos {
) {
}
public record ClaimRequest(
@NotNull @Min(0) Integer expectedAssignmentVersion,
@NotBlank @Size(max = 128) String operationId
) {
}
public record AssignRequest(
@NotNull @Min(1) Long assigneeId,
@NotBlank @Size(max = 64) String assigneeName,
@NotNull @Min(0) Integer expectedAssignmentVersion,
@NotBlank @Size(max = 128) String operationId
) {
}
public record ReviewRequest(
@NotNull @Min(0) Integer expectedTaskVersion,
@NotBlank @Size(max = 128) String operationId,
@Size(max = 500) String remark
) {
}
public record PickItem(Long id, Long loadingPlanItemId, Long inboundId, Long inboundDetailId,
Integer plannedPackageCount, Integer pickedPackageCount,
String itemStatus, Integer version) {
}
public record Assignment(Long assigneeId, String assigneeName, Long assignedBy,
String assignedByName, String assignmentType, Integer version) {
}
public record Review(String status, Long reviewedBy, String reviewedByName,
String remark, Integer version) {
}
public record Operation(Long id, Long pickTaskId, String operationId, String actionType,
Long operatorId, String operatorName, LocalDateTime createdAt) {
}
public record Task(Long id, Long loadingPlanId, String containerNo, String taskStatus,
Integer version, List<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.Param;
import org.apache.ibatis.annotations.Update;
import org.apache.ibatis.annotations.Insert;
@Mapper
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}, "
+ "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} "
@@ -14,7 +14,9 @@ import com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity;
import com.baiyue.firstleg.loading.domain.PickingStatus;
import com.baiyue.firstleg.loading.dto.LoadingResponses.PickingResponse;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.Snapshot;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.loading.repository.LoadingRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
@@ -41,6 +43,8 @@ public class LoadingDeliveryService {
private static final String COMPLETE_DELIVERY = "COMPLETE_DELIVERY";
@Resource private LoadingRepository repository;
@Resource private LoadingPickRepository loadingPicks;
@Resource private LoadingModelBridgeService loadingModelBridge;
@Resource private InboundStatusHistoryMapper histories;
@Resource private LoadingEvidenceFileClient files;
@Resource private ObjectMapper objectMapper;
@@ -100,10 +104,12 @@ public class LoadingDeliveryService {
if (replay != null) return replay;
Snapshot inbound = requireInbound(tenantId, inboundId);
if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) {
loadingModelBridge.requireCompleted(tenantId, inboundId);
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse(inbound.processStatus(), evidenceResponse(tenantId, inboundId)));
}
if (!"LOADING".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜中");
loadingModelBridge.requireCompleted(tenantId, inboundId);
requireCompletedPicking(tenantId, inboundId);
requireCompletionEvidence(tenantId, inboundId);
if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent();
@@ -142,11 +148,8 @@ public class LoadingDeliveryService {
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "START_PICKING", key, hash);
if (replay != null) return pickResponse(tenantId, inboundId);
requireLoadingStage(tenantId, inboundId);
if (request.expectedCount() == null || request.expectedCount() <= 0
|| request.pickedCount() == null || request.pickedCount() < 0
|| request.pickedCount() > request.expectedCount()) {
throw new BizException(ResultCode.BAD_REQUEST, "拣货计划数量必须大于0且已拣数量不能超过计划数量");
}
CanonicalPicking canonical = canonicalPicking(tenantId, inboundId);
requireMatchingPickingRequest(request, canonical);
InboundLoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
if (task == null) {
if (request.expectedVersion() != 0) throw concurrent();
@@ -172,10 +175,9 @@ public class LoadingDeliveryService {
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "COMPLETE_PICKING", key, hash);
if (replay != null) return pickResponse(tenantId, inboundId);
requireLoadingStage(tenantId, inboundId);
if (request.expectedCount() == null || request.expectedCount() <= 0
|| request.pickedCount() == null || !request.expectedCount().equals(request.pickedCount())) {
throw new BizException(ResultCode.CONFLICT, "拣货复核数量必须与计划数量一致");
}
CanonicalPicking canonical = canonicalPicking(tenantId, inboundId);
requireMatchingPickingRequest(request, canonical);
if (!canonical.completed()) throw new BizException(ResultCode.CONFLICT, "柜次PDA任务尚未完成复核");
InboundLoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
if (task == null || !PickingStatus.IN_PROGRESS.equals(task.getStatus())) {
throw new BizException(ResultCode.CONFLICT, task == null ? "拣货任务尚未开始" : "拣货任务不在作业中");
@@ -204,14 +206,40 @@ public class LoadingDeliveryService {
}
private void requireCompletedPicking(long tenantId, long inboundId) {
InboundLoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
if (task == null || !PickingStatus.COMPLETED.equals(task.getStatus())
|| task.getExpectedCount() == null || task.getExpectedCount() <= 0
|| !task.getExpectedCount().equals(task.getPickedCount())) {
if (!canonicalPicking(tenantId, inboundId).completed()) {
throw new BizException(ResultCode.CONFLICT, "PDA拣货复核尚未完成");
}
}
private CanonicalPicking canonicalPicking(long tenantId, long inboundId) {
var progress = loadingPicks.inboundProgress(tenantId, inboundId);
int expected = progress == null || progress.getExpectedCount() == null ? 0 : progress.getExpectedCount();
int picked = progress == null || progress.getPickedCount() == null ? 0 : progress.getPickedCount();
int tasks = progress == null || progress.getTaskCount() == null ? 0 : progress.getTaskCount();
int completed = progress == null || progress.getCompletedTaskCount() == null
? 0 : progress.getCompletedTaskCount();
int unresolved = progress == null || progress.getUnresolvedCount() == null
? 0 : progress.getUnresolvedCount();
if (expected <= 0 || tasks <= 0) throw new BizException(ResultCode.CONFLICT, "未找到真实柜次PDA拣货任务");
return new CanonicalPicking(expected, picked, tasks, completed, unresolved);
}
private static void requireMatchingPickingRequest(PickingRequest request, CanonicalPicking canonical) {
if (request.expectedCount() == null || request.pickedCount() == null
|| request.expectedCount() != canonical.expectedCount()
|| request.pickedCount() != canonical.pickedCount()) {
throw new BizException(ResultCode.CONFLICT, "拣货数量与柜次PDA真实汇总不一致");
}
}
private record CanonicalPicking(int expectedCount, int pickedCount, int taskCount,
int completedTaskCount, int unresolvedCount) {
boolean completed() {
return expectedCount > 0 && expectedCount == pickedCount && taskCount > 0
&& completedTaskCount == taskCount && unresolvedCount == 0;
}
}
private void requireCompletionEvidence(long tenantId, long inboundId) {
Map<String, LoadingEvidenceEntity> values = repository.listEvidence(tenantId, inboundId).stream()
.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
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 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.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
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 DISTINCT 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.mapper.LoadingPickItemMapper;
import com.baiyue.firstleg.mapper.LoadingPickTaskMapper;
import com.baiyue.firstleg.mapper.LoadingPickAssignmentMapper;
import com.baiyue.firstleg.mapper.LoadingPickOperationMapper;
import com.baiyue.firstleg.mapper.LoadingPickReviewMapper;
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
import com.baiyue.firstleg.entity.LoadingPickOperationEntity;
import com.baiyue.firstleg.entity.LoadingPickReviewEntity;
import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
@@ -22,11 +30,27 @@ public class LoadingPickRepository {
private LoadingPickItemMapper items;
@Resource
private LoadingPickHistoryMapper histories;
@Resource
private LoadingPickAssignmentMapper assignments;
@Resource
private LoadingPickReviewMapper reviews;
@Resource
private LoadingPickOperationMapper operations;
@Resource
private InboundLoadingPickTaskMapper inboundProjections;
public LoadingPickTaskEntity task(long taskId) {
return tasks.selectById(taskId);
}
public LoadingPickTaskEntity taskForUpdate(long taskId) {
return tasks.selectForUpdate(taskId);
}
public int touchTask(long taskId, int expectedVersion) {
return tasks.touch(taskId, expectedVersion);
}
public LoadingPickTaskEntity taskByPlan(long planId) {
return tasks.selectOne(new LambdaQueryWrapper<LoadingPickTaskEntity>()
.eq(LoadingPickTaskEntity::getLoadingPlanId, planId));
@@ -111,4 +135,78 @@ public class LoadingPickRepository {
public void deleteItem(long itemId) {
items.deleteById(itemId);
}
public LoadingPickAssignmentEntity assignment(long taskId) {
return assignments.selectById(taskId);
}
public int createAssignment(long taskId, long assigneeId, String assigneeName,
long operatorId, String operatorName, String type) {
return assignments.create(taskId, assigneeId, assigneeName, operatorId, operatorName, type);
}
public int reassign(long taskId, long assigneeId, String assigneeName, long operatorId,
String operatorName, String type, int expectedVersion) {
return assignments.reassign(taskId, assigneeId, assigneeName, operatorId, operatorName,
type, expectedVersion);
}
public LoadingPickReviewEntity review(long taskId) {
return reviews.selectById(taskId);
}
public int approve(long taskId, long operatorId, String operatorName, String remark) {
return reviews.approve(taskId, operatorId, operatorName, remark);
}
public LoadingPickOperationEntity operation(String operationId) {
return operations.selectOne(new LambdaQueryWrapper<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.SkipRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
import com.baiyue.firstleg.dto.LoadingPickDtos.Assignment;
import com.baiyue.firstleg.dto.LoadingPickDtos.Review;
import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Operation;
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import com.baiyue.firstleg.entity.LoadingPickOperationEntity;
import com.baiyue.firstleg.entity.LoadingPickReviewEntity;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.repository.LoadingPickRepository;
@@ -55,6 +64,7 @@ public class LoadingPickService {
for (LoadingPlanItemEntity planItem : planItems) {
syncItem(task.getId(), planItem);
}
repository.syncInboundProjections(task.getId(), operatorId, operatorName);
}
@Transactional
@@ -79,11 +89,59 @@ public class LoadingPickService {
return view(task, warehouseId);
}
@Transactional
public Task claim(long taskId, ClaimRequest request, long operatorId, String operatorName) {
String operationId = operation(request.operationId());
String requestHash = hash("CLAIM|" + taskId + '|' + request.expectedAssignmentVersion());
Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId);
if (replay != null) return replay;
LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId);
LoadingPickAssignmentEntity existing = repository.assignment(taskId);
if (existing == null) {
if (request.expectedAssignmentVersion() != 0
|| repository.createAssignment(taskId, operatorId, operatorName, operatorId,
operatorName, "CLAIMED") != 1) {
throw conflict("PDA任务已被其他作业员领取");
}
} else {
if (!Long.valueOf(operatorId).equals(existing.getAssigneeId())) {
throw conflict("PDA任务已分配给其他作业员");
}
if (!request.expectedAssignmentVersion().equals(existing.getVersion())) {
throw conflict("PDA任务分配版本已变化");
}
}
insertOperation(taskId, operationId, requestHash, "CLAIM", operatorId, operatorName);
return view(task, null);
}
@Transactional
public Task assign(long taskId, AssignRequest request, long operatorId, String operatorName) {
String operationId = operation(request.operationId());
String assigneeName = request.assigneeName().trim();
String requestHash = hash("ASSIGN|" + taskId + '|' + request.assigneeId() + '|'
+ assigneeName + '|' + request.expectedAssignmentVersion());
Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId);
if (replay != null) return replay;
LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId);
LoadingPickAssignmentEntity existing = repository.assignment(taskId);
int changed = existing == null
? request.expectedAssignmentVersion() == 0
? repository.createAssignment(taskId, request.assigneeId(), assigneeName, operatorId,
operatorName, "ASSIGNED") : 0
: repository.reassign(taskId, request.assigneeId(), assigneeName, operatorId, operatorName,
"ASSIGNED", request.expectedAssignmentVersion());
if (changed != 1) throw conflict("PDA任务分配版本已变化");
insertOperation(taskId, operationId, requestHash, "ASSIGN", operatorId, operatorName);
return view(task, null);
}
@Transactional
public Task pick(long taskId, long itemId, PickRequest request, long operatorId, String operatorName) {
String operationId = request.operationId().trim();
String requestHash = hash(taskId + "|" + itemId + "|" + request.warehouseId()
+ "|" + request.pickedPackageCount());
requireNoTaskOperation(operationId);
LoadingPickHistoryEntity replay = repository.history(operationId);
if (replay != null) {
if (!replay.getPickTaskId().equals(taskId) || !replay.getPickItemId().equals(itemId)
@@ -92,10 +150,7 @@ public class LoadingPickService {
}
return view(requiredTask(taskId), request.warehouseId());
}
LoadingPickTaskEntity task = requiredTask(taskId);
if (!"LOADING".equals(task.getTaskStatus())) {
throw conflict("装柜任务已完成,不能继续拣货");
}
LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId);
LoadingPickItemEntity item = repository.item(taskId, itemId, request.warehouseId());
if (item == null) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在");
@@ -113,7 +168,7 @@ public class LoadingPickService {
history.setOperatorId(operatorId);
history.setOperatorName(operatorName);
repository.insert(history);
finishIfResolved(task, operatorId, operatorName);
touchAndSync(task, operatorId, operatorName);
return view(requiredTask(taskId), request.warehouseId());
}
@@ -123,19 +178,20 @@ public class LoadingPickService {
String operationId = request.operationId().trim();
String requestHash = hash("PARTIAL|" + taskId + "|" + itemId + "|" + request.warehouseId()
+ "|" + request.finalPickedPackageCount());
requireNoTaskOperation(operationId);
LoadingPickHistoryEntity replay = repository.history(operationId);
if (replay != null) {
assertReplay(replay, taskId, itemId, requestHash);
return view(requiredTask(taskId), request.warehouseId());
}
LoadingPickTaskEntity task = requiredLoadingTask(taskId);
LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId);
requireWarehouseItem(taskId, itemId, request.warehouseId());
if (repository.partial(itemId, request.finalPickedPackageCount(), request.version(), request.warehouseId()) != 1) {
throw conflict("部分拣货箱数必须大于已拣箱数且小于计划箱数,或明细版本已变化");
}
insertHistory(taskId, itemId, operationId, requestHash, "PARTIAL",
request.finalPickedPackageCount(), null, operatorId, operatorName);
finishIfResolved(task, operatorId, operatorName);
touchAndSync(task, operatorId, operatorName);
return view(requiredTask(taskId), request.warehouseId());
}
@@ -145,26 +201,68 @@ public class LoadingPickService {
String reason = trim(request.reason());
String requestHash = hash("SKIP|" + taskId + "|" + itemId + "|" + request.warehouseId()
+ "|" + reason);
requireNoTaskOperation(operationId);
LoadingPickHistoryEntity replay = repository.history(operationId);
if (replay != null) {
assertReplay(replay, taskId, itemId, requestHash);
return view(requiredTask(taskId), request.warehouseId());
}
LoadingPickTaskEntity task = requiredLoadingTask(taskId);
LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId);
requireWarehouseItem(taskId, itemId, request.warehouseId());
if (repository.skip(itemId, request.version(), request.warehouseId()) != 1) {
throw conflict("跳拣仅允许未开始拣货的待拣明细,或明细版本已变化");
}
insertHistory(taskId, itemId, operationId, requestHash, "SKIP", 0, reason,
operatorId, operatorName);
finishIfResolved(task, operatorId, operatorName);
touchAndSync(task, operatorId, operatorName);
return view(requiredTask(taskId), request.warehouseId());
}
private void finishIfResolved(LoadingPickTaskEntity task, long operatorId, String operatorName) {
if (repository.allPlanItemsPicked(task.getLoadingPlanId())) {
loadingListService.finishFromPda(task.getLoadingPlanId(), task.getId(), operatorId, operatorName);
@Transactional
public Task review(long taskId, ReviewRequest request, long operatorId, String operatorName) {
String operationId = operation(request.operationId());
String remark = trim(request.remark());
String requestHash = hash("REVIEW|" + taskId + '|' + request.expectedTaskVersion() + '|' + remark);
Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId);
if (replay != null) return replay;
LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId);
if (!request.expectedTaskVersion().equals(task.getVersion())) {
throw conflict("PDA任务版本已变化,请刷新后复核");
}
if (repository.review(taskId) != null) throw conflict("PDA任务已经复核");
List<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
@@ -218,19 +316,89 @@ public class LoadingPickService {
return task;
}
private LoadingPickTaskEntity requiredLoadingTaskForUpdate(long taskId) {
LoadingPickTaskEntity task = repository.taskForUpdate(taskId);
if (task == null) throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在");
if (!"LOADING".equals(task.getTaskStatus())) throw conflict("装柜任务已完成,不能继续拣货");
return task;
}
private LoadingPickTaskEntity requiredAssignedLoadingTaskForUpdate(long taskId, long operatorId) {
LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId);
LoadingPickAssignmentEntity assignment = repository.assignment(taskId);
if (assignment == null) throw conflict("请先领取PDA拣货任务");
if (!Long.valueOf(operatorId).equals(assignment.getAssigneeId())) {
throw conflict("PDA拣货任务已分配给其他作业员");
}
return task;
}
private void requireWarehouseItem(long taskId, long itemId, long warehouseId) {
if (repository.item(taskId, itemId, warehouseId) == null) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在或不属于当前仓库");
}
}
private Task view(LoadingPickTaskEntity task, long warehouseId) {
List<PickItem> items = repository.items(task.getId(), warehouseId).stream()
private Task view(LoadingPickTaskEntity task, Long warehouseId) {
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(),
item.getInboundDetailId(), item.getPlannedPackageCount(), item.getPickedPackageCount(),
item.getItemStatus(), item.getVersion())).toList();
LoadingPickAssignmentEntity assigned = repository.assignment(task.getId());
LoadingPickReviewEntity reviewed = repository.review(task.getId());
Assignment assignment = assigned == null ? null : new Assignment(assigned.getAssigneeId(),
assigned.getAssigneeName(), assigned.getAssignedBy(), assigned.getAssignedByName(),
assigned.getAssignmentType(), assigned.getVersion());
Review review = reviewed == null ? null : new Review(reviewed.getReviewStatus(),
reviewed.getReviewedBy(), reviewed.getReviewedByName(), reviewed.getRemark(), reviewed.getVersion());
String workflowStatus = "COMPLETED".equals(task.getTaskStatus()) ? "COMPLETED"
: assigned == null ? "UNASSIGNED"
: repository.allTaskItemsResolved(task.getId()) ? "AWAITING_REVIEW" : "IN_PROGRESS";
return new Task(task.getId(), task.getLoadingPlanId(), task.getContainerNo(), task.getTaskStatus(),
task.getVersion(), items);
workflowStatus, task.getVersion(), assignment, review, items);
}
private void touchAndSync(LoadingPickTaskEntity task, long operatorId, String operatorName) {
if (repository.touchTask(task.getId(), task.getVersion()) != 1) {
throw conflict("PDA任务版本已变化");
}
repository.syncInboundProjections(task.getId(), operatorId, operatorName);
}
private Task replayTaskOperation(long taskId, String operationId, String requestHash, long operatorId) {
if (repository.history(operationId) != null) throw conflict("operationId已用于明细拣货操作");
LoadingPickOperationEntity replay = repository.operation(operationId);
if (replay == null) return null;
if (!Long.valueOf(taskId).equals(replay.getPickTaskId())
|| !Long.valueOf(operatorId).equals(replay.getOperatorId())
|| !requestHash.equals(replay.getRequestHash())) {
throw conflict("operationId已用于不同PDA任务操作");
}
return view(requiredTask(taskId), null);
}
private void insertOperation(long taskId, String operationId, String requestHash, String actionType,
long operatorId, String operatorName) {
LoadingPickOperationEntity entity = new LoadingPickOperationEntity();
entity.setPickTaskId(taskId); entity.setOperationId(operationId); entity.setRequestHash(requestHash);
entity.setActionType(actionType); entity.setOperatorId(operatorId); entity.setOperatorName(operatorName);
try {
repository.insert(entity);
} catch (DuplicateKeyException exception) {
throw conflict("operationId已被并发使用");
}
}
private void requireNoTaskOperation(String operationId) {
if (repository.operation(operationId) != null) throw conflict("operationId已用于PDA任务操作");
}
private static String operation(String value) {
String result = trim(value);
if (!StringUtils.hasText(result)) throw new BizException(ResultCode.BAD_REQUEST, "operationId不能为空");
return result;
}
private void insertHistory(long taskId, long itemId, String operationId, String requestHash,
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.split.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.split.domain.PreloadStatus;
import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest;
@@ -33,6 +34,7 @@ public class SplitPreloadService {
private static final String CONFIRM_PRELOAD = "CONFIRM_PRELOAD";
@Resource private SplitRepository repository;
@Resource private LoadingModelBridgeService loadingModelBridge;
@Resource private InboundStatusHistoryMapper histories;
@Resource private ObjectMapper objectMapper;
@@ -100,6 +102,7 @@ public class SplitPreloadService {
if (replay != null) return replay;
InboundSnapshot inbound = requireInbound(tenantId, inboundId);
if ("LOADING".equals(inbound.processStatus())) {
loadingModelBridge.requireStarted(tenantId, inboundId);
return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key,
new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId))));
}
@@ -108,6 +111,7 @@ public class SplitPreloadService {
if (PreloadStatus.CONFIRMED.equals(plan.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "预装清单已确认但订单状态尚未推进,请刷新重试");
}
loadingModelBridge.requireStarted(tenantId, inboundId);
if (repository.changePlanStatus(tenantId, inboundId, plan.getStatus(), PreloadStatus.CONFIRMED,
operatorId, operatorName, request.expectedVersion()) != 1) {
throw concurrent();
@@ -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}
/api/platform/v1/inbounds/{inboundId}/loading/picking/start:
post:
summary: Start the inbound-level PDA picking review task
summary: Refresh the compatibility projection from canonical container PDA tasks
deprecated: true
description: expectedCount and pickedCount must exactly match the server-derived container-task aggregate.
parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody:
required: true
@@ -30,7 +32,9 @@ paths:
'200': {description: Business envelope containing IN_PROGRESS task}
/api/platform/v1/inbounds/{inboundId}/loading/picking/complete:
post:
summary: Complete a started PDA picking task when planned and picked counts match
summary: Mark the compatibility projection complete after all container PDA tasks are reviewed
deprecated: true
description: This endpoint cannot complete canonical picking; counts must match the server aggregate and every related container task must already be completed.
parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody:
required: true
@@ -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("enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL]")
.contains("Required when evidenceType is SEAL")
.contains("Complete a started PDA picking task");
.contains("compatibility projection")
.contains("must exactly match the server-derived container-task aggregate")
.contains("cannot complete canonical picking");
}
}
@@ -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.LoadingResponses.LoadingActionResponse;
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
import com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.Snapshot;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.loading.repository.LoadingRepository;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -30,6 +32,8 @@ import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class LoadingDeliveryServiceTest {
@Mock LoadingRepository repository;
@Mock LoadingModelBridgeService loadingModelBridge;
@Mock LoadingPickRepository loadingPicks;
@Mock InboundStatusHistoryMapper histories;
@Mock LoadingEvidenceFileClient files;
@InjectMocks LoadingDeliveryService service;
@@ -48,9 +52,13 @@ class LoadingDeliveryServiceTest {
void completesLoadingOnlyAfterPickingAndFiveEvidenceFiles() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
InboundLoadingPickTaskEntity pick = new InboundLoadingPickTaskEntity();
pick.setStatus("COMPLETED"); pick.setExpectedCount(5); pick.setPickedCount(5);
when(repository.findPickTask(10, 20)).thenReturn(pick);
LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class);
when(progress.getExpectedCount()).thenReturn(5);
when(progress.getPickedCount()).thenReturn(5);
when(progress.getTaskCount()).thenReturn(1);
when(progress.getCompletedTaskCount()).thenReturn(1);
when(progress.getUnresolvedCount()).thenReturn(0);
when(loadingPicks.inboundProgress(10, 20)).thenReturn(progress);
when(repository.listEvidence(10, 20)).thenReturn(List.of(
evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null),
evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null),
@@ -64,6 +72,7 @@ class LoadingDeliveryServiceTest {
when(repository.completeLoading(10, 20, 7, 3)).thenReturn(1);
LoadingActionResponse result = service.completeLoading(10, 20, 7, "admin", "k1", new CompleteLoadingRequest(3, null));
assertThat(result.mainStatus()).isEqualTo("LOADED");
verify(loadingModelBridge).requireCompleted(10, 20);
verify(histories).insert(any(InboundStatusHistoryEntity.class));
}
@@ -71,9 +80,13 @@ class LoadingDeliveryServiceTest {
void rejectsCompletionWhenEvidenceIsIncomplete() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
InboundLoadingPickTaskEntity pick = new InboundLoadingPickTaskEntity();
pick.setStatus("COMPLETED"); pick.setExpectedCount(5); pick.setPickedCount(5);
when(repository.findPickTask(10, 20)).thenReturn(pick);
LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class);
when(progress.getExpectedCount()).thenReturn(5);
when(progress.getPickedCount()).thenReturn(5);
when(progress.getTaskCount()).thenReturn(1);
when(progress.getCompletedTaskCount()).thenReturn(1);
when(progress.getUnresolvedCount()).thenReturn(0);
when(loadingPicks.inboundProgress(10, 20)).thenReturn(progress);
when(repository.listEvidence(10, 20)).thenReturn(List.of(evidence("SEAL", 5L, "LOCK-001")));
assertThatThrownBy(() -> service.completeLoading(10, 20, 7, "admin", "k-missing",
@@ -111,13 +124,12 @@ class LoadingDeliveryServiceTest {
}
@Test
void rejectsCompletingPickingBeforeTaskStarts() {
void rejectsCompletingPickingWithoutCanonicalContainerTask() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
when(repository.findPickTask(10, 20)).thenReturn(null);
assertThatThrownBy(() -> service.completePicking(10, 20, 7, "admin", "k-picking",
new PickingRequest(0, 1, 1, null))).hasMessageContaining("尚未开始");
new PickingRequest(0, 1, 1, null))).hasMessageContaining("真实柜次");
verify(repository, never()).createPickTask(any());
}
@@ -23,10 +23,15 @@ class Phase3FinalUnifiedSqlContractTest {
.contains("CREATE TABLE IF NOT EXISTS fl_customs_receipt")
.contains("CREATE TABLE IF NOT EXISTS fl_port_schedule")
.contains("CREATE TABLE IF NOT EXISTS fl_port_schedule_history")
.contains("CREATE TABLE IF NOT EXISTS fl_preload_loading_plan_link")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_assignment")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_review")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_operation")
.contains("uk_loading_evidence_seal (tenant_id,seal_no)")
.contains("CALL assert_phase3_final_schema()")
.contains("trg_port_schedule_history_before_update")
.contains("trg_port_schedule_history_before_delete");
.contains("trg_port_schedule_history_before_delete")
.contains("trg_loading_pick_operation_before_delete");
}
@Test
@@ -36,10 +41,12 @@ class Phase3FinalUnifiedSqlContractTest {
for (int id = 5102160; id <= 5102164; id++) assertThat(sql).contains(Integer.toString(id));
for (int id = 5102170; id <= 5102175; id++) assertThat(sql).contains(Integer.toString(id));
for (int id = 5102180; id <= 5102183; id++) assertThat(sql).contains(Integer.toString(id));
for (int id = 5102190; id <= 5102193; id++) assertThat(sql).contains(Integer.toString(id));
assertThat(sql)
.contains("platform:firstleg:outbound-dispatch:%")
.contains("platform:firstleg:customs:%")
.contains("platform:firstleg:port-schedule:%")
.contains("platform:firstleg:pda:%")
.contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')")
.contains("角色授权未完整写入");
}
@@ -3,7 +3,10 @@ package com.baiyue.firstleg.service;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest;
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPickOperationEntity;
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.repository.LoadingPickRepository;
@@ -34,9 +37,12 @@ class LoadingPickServiceTest {
LoadingPickItemEntity item = item();
when(repository.history("pick-1")).thenReturn(null);
when(repository.task(20L)).thenReturn(task);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.item(20L, 30L, 101L)).thenReturn(item);
when(repository.pick(30L, 2, 0, 101L)).thenReturn(1);
when(repository.allPlanItemsPicked(10L)).thenReturn(false);
when(repository.touchTask(20L, 0)).thenReturn(1);
when(repository.items(20L, 101L)).thenReturn(List.of(item));
service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-1"), 9L, "operator");
@@ -50,14 +56,16 @@ class LoadingPickServiceTest {
LoadingPickItemEntity item = item();
when(repository.history("pick-2")).thenReturn(null);
when(repository.task(20L)).thenReturn(task);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.item(20L, 30L, 101L)).thenReturn(item);
when(repository.pick(30L, 2, 0, 101L)).thenReturn(1);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.items(20L, 101L)).thenReturn(List.of(item));
when(repository.touchTask(20L, 0)).thenReturn(1);
service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-2"), 9L, "operator");
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator");
verify(repository).insert(any(LoadingPickHistoryEntity.class));
}
@@ -67,14 +75,16 @@ class LoadingPickServiceTest {
LoadingPickItemEntity item = item();
when(repository.history("partial-1")).thenReturn(null);
when(repository.task(20L)).thenReturn(task);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.item(20L, 30L, 101L)).thenReturn(item);
when(repository.partial(30L, 1, 0, 101L)).thenReturn(1);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.items(20L, 101L)).thenReturn(List.of(item));
when(repository.touchTask(20L, 0)).thenReturn(1);
service.partial(20L, 30L, new PartialPickRequest(101L, 1, List.of(), 0, "partial-1"), 9L, "operator");
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator");
}
@Test
@@ -83,14 +93,37 @@ class LoadingPickServiceTest {
LoadingPickItemEntity item = item();
when(repository.history("skip-1")).thenReturn(null);
when(repository.task(20L)).thenReturn(task);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.item(20L, 30L, 101L)).thenReturn(item);
when(repository.skip(30L, 0, 101L)).thenReturn(1);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.items(20L, 101L)).thenReturn(List.of(item));
when(repository.touchTask(20L, 0)).thenReturn(1);
service.skip(20L, 30L, new SkipRequest(101L, 0, "skip-1", "fixture unavailable"), 9L, "operator");
verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator");
}
@Test
void reviewSettlesPartialItemsBeforeCompletingPlan() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
item.setPlannedPackageCount(5);
item.setPickedPackageCount(3);
item.setItemStatus("PARTIAL");
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.task(20L)).thenReturn(task);
when(repository.items(20L)).thenReturn(List.of(item));
when(repository.settlePartial(40L, 30L, 3, 5, 9L, "operator")).thenReturn(1);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1);
service.review(20L, new ReviewRequest(0, "review-1", "checked"), 9L, "operator");
verify(repository).insert(any(LoadingPickOperationEntity.class));
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
verify(repository).syncInboundProjections(20L, 9L, "operator");
}
private static LoadingPickTaskEntity task() {
@@ -116,4 +149,13 @@ class LoadingPickServiceTest {
item.setVersion(0);
return item;
}
private static LoadingPickAssignmentEntity assignment() {
LoadingPickAssignmentEntity assignment = new LoadingPickAssignmentEntity();
assignment.setPickTaskId(20L);
assignment.setAssigneeId(9L);
assignment.setAssigneeName("operator");
assignment.setVersion(0);
return assignment;
}
}
@@ -1,6 +1,7 @@
package com.baiyue.firstleg.split.service;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest;
import com.baiyue.firstleg.split.dto.SplitRequests.PreloadItem;
import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest;
@@ -30,6 +31,7 @@ import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class SplitPreloadServiceTest {
@Mock SplitRepository repository;
@Mock LoadingModelBridgeService loadingModelBridge;
@Mock InboundStatusHistoryMapper histories;
@InjectMocks SplitPreloadService service;
private final ObjectMapper objectMapper = new ObjectMapper();
@@ -61,11 +63,13 @@ class SplitPreloadServiceTest {
plan.setTenantId(10L); plan.setInboundId(20L); plan.setStatus("CONFIRMING"); plan.setVersion(1);
plan.setItemsJson("[]");
when(repository.findPlan(10, 20)).thenReturn(plan);
org.mockito.Mockito.doNothing().when(loadingModelBridge).requireStarted(10, 20);
when(repository.changePlanStatus(10, 20, "CONFIRMING", "CONFIRMED", 7, "admin", 1)).thenReturn(1);
when(repository.beginLoading(10, 20, 7, 4)).thenReturn(1);
SplitActionResponse result = service.confirmPreload(10, 20, 7, "admin", "k3",
new ConfirmPreloadRequest(1));
assertThat(result.mainStatus()).isEqualTo("LOADING");
verify(loadingModelBridge).requireStarted(10, 20);
verify(repository).beginLoading(10, 20, 7, 4);
}
}
@@ -20,7 +20,8 @@ async function createBooking(context, suffix, key) { return request(context, 'PO
function step(context, name, r, pass, evidence, options = OPTIONS) { context.addStep(MODULE, name, r, pass, evidence, { ...options, deploymentBlocker: options.deploymentBlocker && !pass }) }
export async function run(context) {
const containerFlow = context.createdResources?.phase3ContainerLoading
const containerFlow = context.createdResources?.phase3Loading
|| context.createdResources?.phase3ContainerLoading
const suffix = context.marker.replace(/\D/g, '').slice(-10)
const list = await request(context, 'GET', `${BASE}?page=1&size=5&keyword=${encodeURIComponent(context.marker)}`)
if (missing(list) || list.http === 0) {
@@ -58,7 +59,7 @@ export async function run(context) {
const changed = await request(context, 'POST', `${path}/change-request`, { expectedVersion: version, vesselName: `改船-${suffix}`, voyageNo: `CV${suffix}`, reason: context.marker }, { 'Idempotency-Key': `${context.marker}-change` })
context.apiSuccess(MODULE, '申请改船', changed, '', OPTIONS); version = Number(data(changed)?.oceanBooking?.version)
const changeConfirmed = await action('确认改船', 'change-confirm', version, 'confirm change')
if (containerFlow?.finalStatus === 'COMPLETED') {
if (containerFlow?.finalStatus === 'COMPLETED' || containerFlow?.finalStatus === 'LOADED') {
context.assert(MODULE, '订舱与本轮柜次客户归属一致', changed,
id(containerFlow?.tenantId) === id(changeConfirmed?.oceanBooking?.customerId),
`bookingCustomerId=${id(changeConfirmed?.oceanBooking?.customerId) || 'missing'}, containerTenantId=${id(containerFlow?.tenantId) || 'missing'}`, OPTIONS)
@@ -174,6 +174,7 @@ export async function run(context) {
const pickItem = task?.items?.find?.((item) => id(item.loadingPlanItemId) === id(planItem.id))
context.assert(MODULE, 'PDA 任务、计划明细和待拣箱数关联正确', taskResponse,
Boolean(taskId) && id(task?.loadingPlanId) === planId && task?.taskStatus === 'LOADING'
&& task?.workflowStatus === 'UNASSIGNED'
&& Boolean(pickItem?.id) && Number(pickItem?.plannedPackageCount) === 1,
`taskId=${taskId || 'missing'}, itemId=${id(pickItem?.id) || 'missing'}`, OPTIONS)
if (!taskId || !pickItem?.id) return
@@ -183,6 +184,15 @@ export async function run(context) {
context.addStep(MODULE, 'PDA 任务仓库隔离拒绝', wrongWarehouseResponse,
failure(wrongWarehouseResponse), `HTTP=${wrongWarehouseResponse.http}, code=${codeOf(wrongWarehouseResponse)}`, OPTIONS)
const claimResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/claim`, {
expectedAssignmentVersion: 0, operationId: `${context.marker}-pda-claim`.slice(0, 128),
})
const claimed = context.apiSuccess(MODULE, '领取本轮柜次 PDA 任务', claimResponse, '', OPTIONS)
context.assert(MODULE, 'PDA 任务只分配给当前登录作业员', claimResponse,
claimed?.workflowStatus === 'IN_PROGRESS' && id(claimed?.assignment?.assigneeId)
&& Number(claimed?.assignment?.version) === 0,
`workflow=${claimed?.workflowStatus || 'missing'}, assigneeId=${id(claimed?.assignment?.assigneeId) || 'missing'}`, OPTIONS)
const stalePickResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, {
warehouseId, pickedPackageCount: 1, locationIds: [], version: Number(pickItem.version) + 1,
operationId: `${context.marker}-pda-stale`.slice(0, 128),
@@ -197,18 +207,28 @@ export async function run(context) {
}
const pickResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, pickPayload)
const picked = context.apiSuccess(MODULE, '执行本轮 PDA 全量拣货', pickResponse, '', OPTIONS)
context.assert(MODULE, '最后一箱拣货后 PDA 任务进入 COMPLETED', pickResponse,
picked?.taskStatus === 'COMPLETED'
context.assert(MODULE, '最后一箱拣货后 PDA 任务等待复核', pickResponse,
picked?.taskStatus === 'LOADING' && picked?.workflowStatus === 'AWAITING_REVIEW'
&& picked?.items?.every?.((item) => Number(item.pickedPackageCount) === Number(item.plannedPackageCount)),
`taskStatus=${picked?.taskStatus || 'missing'}`, OPTIONS)
`taskStatus=${picked?.taskStatus || 'missing'}, workflow=${picked?.workflowStatus || 'missing'}`, OPTIONS)
const pickReplayResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, pickPayload)
const pickReplay = context.apiSuccess(MODULE, 'PDA operationId 幂等重放', pickReplayResponse, '', OPTIONS)
context.assert(MODULE, 'PDA 重放保持已完成任务和拣货数', pickReplayResponse,
pickReplay?.taskStatus === 'COMPLETED'
context.assert(MODULE, 'PDA 重放保持待复核任务和拣货数', pickReplayResponse,
pickReplay?.taskStatus === 'LOADING' && pickReplay?.workflowStatus === 'AWAITING_REVIEW'
&& pickReplay?.items?.every?.((item) => Number(item.pickedPackageCount) === Number(item.plannedPackageCount)),
`taskStatus=${pickReplay?.taskStatus || 'missing'}`, OPTIONS)
const reviewResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/review`, {
expectedTaskVersion: Number(picked?.version), operationId: `${context.marker}-pda-review`.slice(0, 128),
remark: context.marker,
})
const reviewed = context.apiSuccess(MODULE, '复核本轮柜次 PDA 任务', reviewResponse, '', OPTIONS)
context.assert(MODULE, '复核后任务与柜次计划进入真实完成态', reviewResponse,
reviewed?.taskStatus === 'COMPLETED' && reviewed?.workflowStatus === 'COMPLETED'
&& reviewed?.review?.status === 'APPROVED' && Boolean(reviewed?.review?.reviewedBy),
`taskStatus=${reviewed?.taskStatus || 'missing'}, review=${reviewed?.review?.status || 'missing'}`, OPTIONS)
const finalPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`)
const finalPlan = context.apiSuccess(MODULE, '回查柜次装柜计划终态', finalPlanResponse, '', OPTIONS)
context.assert(MODULE, '装柜计划进入 COMPLETED 且明细已冻结', finalPlanResponse,
@@ -243,6 +263,7 @@ export async function run(context) {
context.createdResources.phase3ContainerLoading = {
marker: context.marker, tenantId, inboundId, warehouseId, containerBookingId: containerId,
loadingPlanId: planId, pdaTaskId: taskId, finalStatus: 'COMPLETED',
loadingPlanId: planId, pdaTaskId: taskId, pdaReviewVersion: reviewed?.review?.version,
finalStatus: 'COMPLETED',
}
}
@@ -68,13 +68,16 @@ async function detail(context, caseId) {
}
export async function run(context) {
const fixture = context.createdResources?.orchestrationFixtures?.phase3Container
const container = context.createdResources?.phase3ContainerLoading
const fixture = context.createdResources?.orchestrationFixtures?.inboundMeasurement
|| context.createdResources?.orchestrationFixtures?.phase3Container
const container = context.createdResources?.phase3Loading
|| context.createdResources?.phase3ContainerLoading
const tenantId = id(fixture?.tenantId)
const orderId = id(fixture?.orderId)
const inboundId = id(fixture?.inboundId)
const containerBookingId = id(container?.containerBookingId)
if (!tenantId || !orderId || !inboundId || !containerBookingId || container?.finalStatus !== 'COMPLETED') {
if (!tenantId || !orderId || !inboundId || !containerBookingId
|| !['COMPLETED', 'LOADED'].includes(container?.finalStatus)) {
context.addStep(MODULE, '取得本轮客户/订单/入库单/柜次隔离 fixture', null, false,
`tenantId=${tenantId || 'missing'}, orderId=${orderId || 'missing'}, inboundId=${inboundId || 'missing'}, containerBookingId=${containerBookingId || 'missing'}, containerStatus=${container?.finalStatus || 'missing'}`, OPTIONS)
return
@@ -1,10 +1,49 @@
import { registerPendingContract } from './_phase3-pending-contract.mjs'
const MODULE = 'phase3-loading-model-bridge'
const OPTIONS = { scenario: 'parallel-loading-models', deploymentBlocker: true }
function id(value) { return value == null ? '' : String(value) }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
function failure(response) { return [400, 401, 403, 404, 409, 503].includes(response?.http) || (response?.http === 200 && codeOf(response) !== '200') }
async function request(context, method, path, body, headers = {}) {
try {
const response = await fetch(`${context.options.gateway}${path}`, { method,
headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`,
...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers },
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000) })
const raw = await response.text(); let payload = null
try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ }
return { method, path, http: response.status, body: payload, raw: payload === null ? raw.slice(0, 500) : null,
requestId: payload?.requestId || response.headers.get('x-request-id'), traceId: payload?.traceId || response.headers.get('x-trace-id') }
} catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } }
}
export async function run(context) {
registerPendingContract(context, {
module: 'phase3-loading-model-bridge', scenario: 'parallel-loading-models', name: '入库单级预装与柜次级智能装柜串联',
reason: 'fl_preload_plan 按入库单推进 LOADING/LOADED,fl_loading_plan 按柜次推进 EDITING/LOADING/COMPLETED;两套公开 API 不会互相创建、绑定或校验终态', category: 'contract-gap',
fixtures: ['phase3Loading', 'phase3ContainerLoading'], contractSource: '待新增两套装柜模型关联键与联动契约',
assertions: ['同一 inbound 与柜次计划双向可查', '任一侧版本冲突不产生部分更新', '双终态一致后方可进入出仓门禁'],
})
const fixture = context.createdResources?.phase3Loading
const tenantId = id(fixture?.tenantId); const inboundId = id(fixture?.inboundId)
const loadingPlanId = id(fixture?.loadingPlanId)
if (!tenantId || !inboundId || !loadingPlanId || fixture?.marker !== context.marker) {
context.addCoverageGap(MODULE, OPTIONS.scenario, '入库单级预装与柜次级智能装柜串联',
'本轮未取得同一 marker 的预装/柜次计划关联 fixture,未使用历史资源', 'fixture',
{ activation: { state: 'pending-fixture', assertions: ['双向关联可查', '双版本冲突原子回滚', '终态门禁一致'] } })
return
}
context.addCoverageGap(MODULE, OPTIONS.scenario, '入库单级预装与柜次级智能装柜串联',
'桥接契约、SQL和真实断言已完成,待目标提交部署并取得本模块真实 HTTP PASS 后删除此项',
'deployment-validation-pending', { activation: { state: 'pending-deployment-validation' } })
const inboundPath = `/api/platform/v1/inbounds/${inboundId}/preload/loading-plans?tenantId=${encodeURIComponent(tenantId)}`
const inboundResponse = await request(context, 'GET', inboundPath)
const inboundLinks = context.apiSuccess(MODULE, '从入库侧查询预装与柜次关联', inboundResponse, '', OPTIONS)
const link = Array.isArray(inboundLinks) ? inboundLinks.find((item) => id(item.loadingPlanId) === loadingPlanId) : null
context.assert(MODULE, '入库侧关联只返回本轮柜次和客户隔离键', inboundResponse,
Boolean(link) && id(link.inboundId) === inboundId && id(link.tenantId) === tenantId
&& Number(link.allocatedPackageCount) > 0, `links=${inboundLinks?.length ?? 'n/a'}`, OPTIONS)
const planResponse = await request(context, 'GET', `/api/platform/v1/first-leg/smart-loading/plans/${loadingPlanId}/preloads`)
const planLinks = context.apiSuccess(MODULE, '从柜次侧查询反向预装关联', planResponse, '', OPTIONS)
context.assert(MODULE, '柜次侧反查到同一预装计划', planResponse,
Array.isArray(planLinks) && planLinks.some((item) => id(item.inboundId) === inboundId
&& id(item.preloadPlanId) === id(fixture.preloadPlanId)), `links=${planLinks?.length ?? 'n/a'}`, OPTIONS)
const forged = await request(context, 'POST',
`/api/platform/v1/inbounds/${inboundId}/preload/loading-plans/${loadingPlanId}?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedPreloadVersion: 0, expectedLoadingPlanVersion: 0 },
{ 'Idempotency-Key': `${context.marker}-bridge-bind`.slice(0, 128) })
context.addStep(MODULE, '桥接同幂等键不同版本请求拒绝且不产生部分更新', forged, failure(forged),
`HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS)
}
@@ -83,6 +83,52 @@ export async function run(context) {
const base = `/api/platform/v1/inbounds/${inboundId}`
const suffix = context.marker.replace(/\D/g, '').slice(-10)
const warehouseId = id(initial?.summary?.warehouseId)
const smartLoadingBase = '/api/platform/v1/first-leg/smart-loading'
const pdaBase = '/api/platform/v1/first-leg/pda-loading'
const candidateResponse = await context.request('GET', `${smartLoadingBase}/candidates?warehouseId=${encodeURIComponent(warehouseId)}`)
const candidates = context.apiSuccess(MODULE, '查询本轮入库单柜次装柜候选', candidateResponse, '', OPTIONS)
const candidate = Array.isArray(candidates) ? candidates.find((item) => id(item.inboundId) === inboundId
&& Number(item.availablePackageCount) > 0) : null
context.assert(MODULE, '柜次装柜候选与入库单、客户和仓库一致', candidateResponse,
Boolean(candidate?.inboundDetailId) && id(candidate?.accountId) === tenantId
&& id(candidate?.warehouseId) === warehouseId,
`candidate=${Boolean(candidate)}, warehouseId=${warehouseId || 'missing'}`, OPTIONS)
if (!candidate?.inboundDetailId || !warehouseId) return
const containerNo = `P3B-${suffix}`
const containerResponse = await request(context, 'POST', '/api/platform/v1/containers', {
containerNo, cabinetNo: `P3BI-${suffix}`, warehouseId,
warehouseName: initial?.summary?.warehouseName || null, billOfLadingNo: `P3BL-${suffix}`,
vesselName: `P3-BRIDGE-${suffix}`, voyageNo: `P3V-${suffix}`, containerType: '40HQ',
shippingCompanyName: `P3-SHIPPING-${suffix}`, departurePortCode: 'CNSZX', departurePortName: '深圳',
destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
destinationWarehouseCode: candidate.destinationWarehouseCode || null,
destinationWarehouseName: candidate.destinationWarehouseName || null,
capacityCbm: 100, maxWeightKg: 100000, loadingAt: '2026-09-02T08:00:00',
estimatedSailingAt: '2026-09-03T08:00:00', estimatedPortArrivalAt: '2026-09-20T08:00:00',
estimatedWarehouseArrivalAt: '2026-09-21T08:00:00',
})
const container = context.apiSuccess(MODULE, '创建与入库预装串联的柜次', containerResponse, '', OPTIONS)
const containerBookingId = id(container?.id)
if (!containerBookingId) return
const loadingPlanResponse = await request(context, 'POST', `${smartLoadingBase}/plans`, {
bookingId: containerBookingId, remark: context.marker,
})
const loadingPlan = context.apiSuccess(MODULE, '创建柜次级智能装柜计划', loadingPlanResponse, '', OPTIONS)
const loadingPlanId = id(loadingPlan?.id)
if (!loadingPlanId) return
const assignmentResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/assignments`, {
inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1,
expectedVersion: Number(loadingPlan.version), operationId: `${context.marker}-bridge-assign`.slice(0, 128),
})
const assignedLoadingPlan = context.apiSuccess(MODULE, '将本轮入库货物分配到柜次计划', assignmentResponse, '', OPTIONS)
context.assert(MODULE, '柜次计划明细和版本真实持久化', assignmentResponse,
assignedLoadingPlan?.items?.some?.((item) => id(item.inboundId) === inboundId && Number(item.packageCount) === 1)
&& Number(assignedLoadingPlan?.version) === 1,
`loadingPlanId=${loadingPlanId}, version=${assignedLoadingPlan?.version ?? 'missing'}`, OPTIONS)
const smart = await request(context, 'POST', `${base}/split/smart?tenantId=${encodeURIComponent(tenantId)}`, {
expectedVersion: 0,
items: [{ containerCode: `P3-${suffix}`, cargoReference: `REF-${suffix}`, packageCount: 1, volumeCbm: 0.01, remark: context.marker }],
@@ -104,11 +150,31 @@ export async function run(context) {
&& replay.body?.data?.preloadPlan?.items?.length === 1,
`HTTP=${replay.http}, code=${codeOf(replay)}, planId=${id(replay.body?.data?.preloadPlan?.id) || 'missing'}`, OPTIONS)
const bindResponse = await request(context, 'POST',
`${base}/preload/loading-plans/${loadingPlanId}?tenantId=${encodeURIComponent(tenantId)}`, {
expectedPreloadVersion: Number(smartData.preloadPlan.version),
expectedLoadingPlanVersion: Number(assignedLoadingPlan.version),
}, { 'Idempotency-Key': `${context.marker}-bridge-bind`.slice(0, 128) })
const link = context.apiSuccess(MODULE, '原子关联入库预装与柜次装柜计划', bindResponse, '', OPTIONS)
context.assert(MODULE, '桥接双版本递增且关联归属唯一', bindResponse,
id(link?.inboundId) === inboundId && id(link?.loadingPlanId) === loadingPlanId
&& id(link?.preloadPlanId) === planId && Number(link?.allocatedPackageCount) === 1
&& Number(link?.preloadVersion) === 1 && Number(link?.loadingPlanVersion) === 2,
`linkId=${id(link?.id) || 'missing'}, preloadVersion=${link?.preloadVersion ?? 'missing'}, loadingVersion=${link?.loadingPlanVersion ?? 'missing'}`, OPTIONS)
if (!link?.id) return
const pushResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/push-preload`,
{ expectedVersion: Number(link.loadingPlanVersion) },
{ 'Idempotency-Key': `${context.marker}-bridge-push`.slice(0, 128) })
const pushedLoadingPlan = context.apiSuccess(MODULE, '桥接后推送柜次计划并生成真实 PDA 任务', pushResponse, '', OPTIONS)
context.assert(MODULE, '关联柜次计划进入 LOADING', pushResponse,
pushedLoadingPlan?.status === 'LOADING', `status=${pushedLoadingPlan?.status || 'missing'}`, OPTIONS)
const confirming = await request(context, 'POST', `${base}/preload/confirming?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: Number(smartData.preloadPlan.version) }, { 'Idempotency-Key': `${context.marker}-p3-confirming`.slice(0, 128) })
{ expectedVersion: Number(link.preloadVersion) }, { 'Idempotency-Key': `${context.marker}-p3-confirming`.slice(0, 128) })
const confirmingData = context.apiSuccess(MODULE, '提交预装清单确认', confirming, '', OPTIONS)
context.assert(MODULE, '预装清单进入 CONFIRMING 且版本递增', confirming,
confirmingData?.preloadPlan?.status === 'CONFIRMING' && Number(confirmingData?.preloadPlan?.version) === 1,
confirmingData?.preloadPlan?.status === 'CONFIRMING' && Number(confirmingData?.preloadPlan?.version) === 2,
`status=${confirmingData?.preloadPlan?.status || 'missing'}, version=${confirmingData?.preloadPlan?.version ?? 'missing'}`, OPTIONS)
if (confirmingData?.preloadPlan?.status !== 'CONFIRMING') return
@@ -117,7 +183,7 @@ export async function run(context) {
const confirmed = context.apiSuccess(MODULE, '确认预装并进入装柜中', confirm, '', OPTIONS)
context.assert(MODULE, '预装确认推进主状态 LOADING 和计划 CONFIRMED', confirm,
confirmed?.mainStatus === 'LOADING' && confirmed?.preloadPlan?.status === 'CONFIRMED'
&& Number(confirmed?.preloadPlan?.version) === 2,
&& Number(confirmed?.preloadPlan?.version) === 3,
`mainStatus=${confirmed?.mainStatus || 'missing'}, planStatus=${confirmed?.preloadPlan?.status || 'missing'}, version=${confirmed?.preloadPlan?.version ?? 'missing'}`, OPTIONS)
if (confirmed?.mainStatus !== 'LOADING') return
@@ -142,13 +208,58 @@ export async function run(context) {
context.addStep(MODULE, '未完成 PDA 拣货时拒绝完成装柜', blockedBeforePicking, failure(blockedBeforePicking),
`HTTP=${blockedBeforePicking.http}, code=${codeOf(blockedBeforePicking)}`, OPTIONS)
const canonicalTaskResponse = await context.request('GET',
`${pdaBase}/tasks?containerNo=${encodeURIComponent(containerNo)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const canonicalTask = context.apiSuccess(MODULE, '读取柜次级 PDA 唯一事实任务', canonicalTaskResponse, '', OPTIONS)
const canonicalTaskId = id(canonicalTask?.id)
const canonicalItem = canonicalTask?.items?.find?.((item) => id(item.inboundId) === inboundId)
context.assert(MODULE, '柜次 PDA 任务与桥接入库明细一致', canonicalTaskResponse,
Boolean(canonicalTaskId) && id(canonicalTask?.loadingPlanId) === loadingPlanId
&& canonicalTask?.workflowStatus === 'UNASSIGNED' && Boolean(canonicalItem?.id),
`taskId=${canonicalTaskId || 'missing'}, itemId=${id(canonicalItem?.id) || 'missing'}`, OPTIONS)
if (!canonicalTaskId || !canonicalItem?.id) return
const claim = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/claim`, {
expectedAssignmentVersion: 0, operationId: `${context.marker}-bridge-claim`.slice(0, 128),
})
const claimed = context.apiSuccess(MODULE, '领取柜次 PDA 任务', claim, '', OPTIONS)
context.assert(MODULE, '柜次 PDA 任务绑定当前作业员', claim,
claimed?.workflowStatus === 'IN_PROGRESS' && Boolean(claimed?.assignment?.assigneeId),
`workflow=${claimed?.workflowStatus || 'missing'}`, OPTIONS)
const canonicalPick = await request(context, 'POST',
`${pdaBase}/tasks/${canonicalTaskId}/items/${id(canonicalItem.id)}/pick`, {
warehouseId, pickedPackageCount: 1, locationIds: [], version: Number(canonicalItem.version),
operationId: `${context.marker}-bridge-pick`.slice(0, 128),
})
const pickedCanonical = context.apiSuccess(MODULE, '执行柜次 PDA 明细拣货', canonicalPick, '', OPTIONS)
context.assert(MODULE, '柜次 PDA 明细完成后等待复核', canonicalPick,
pickedCanonical?.workflowStatus === 'AWAITING_REVIEW' && pickedCanonical?.taskStatus === 'LOADING',
`workflow=${pickedCanonical?.workflowStatus || 'missing'}`, OPTIONS)
const canonicalReview = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/review`, {
expectedTaskVersion: Number(pickedCanonical?.version),
operationId: `${context.marker}-bridge-review`.slice(0, 128), remark: context.marker,
})
const reviewedCanonical = context.apiSuccess(MODULE, '复核柜次 PDA 并完成柜次计划', canonicalReview, '', OPTIONS)
context.assert(MODULE, '柜次 PDA 复核成为装柜完成真实门禁', canonicalReview,
reviewedCanonical?.taskStatus === 'COMPLETED' && reviewedCanonical?.workflowStatus === 'COMPLETED'
&& reviewedCanonical?.review?.status === 'APPROVED',
`taskStatus=${reviewedCanonical?.taskStatus || 'missing'}`, OPTIONS)
const forgedPickingStart = await request(context, 'POST', `${base}/loading/picking/start?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: 0, expectedCount: 1, pickedCount: 0, remark: 'forged-client-count' },
{ 'Idempotency-Key': `${context.marker}-p3-picking-forged-start`.slice(0, 128) })
context.addStep(MODULE, '入库级 PDA 汇总拒绝客户端伪造数量', forgedPickingStart, failure(forgedPickingStart),
`HTTP=${forgedPickingStart.http}, code=${codeOf(forgedPickingStart)}`, OPTIONS)
const pickingStart = await request(context, 'POST', `${base}/loading/picking/start?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: 0, expectedCount: 1, pickedCount: 0, remark: context.marker },
{ expectedVersion: 0, expectedCount: 1, pickedCount: 1, remark: context.marker },
{ 'Idempotency-Key': `${context.marker}-p3-picking-start`.slice(0, 128) })
const pickingStarted = context.apiSuccess(MODULE, '开始本轮 PDA 拣货任务', pickingStart, '', OPTIONS)
context.assert(MODULE, 'PDA 拣货任务处于 IN_PROGRESS 且计划数量正确', pickingStart,
pickingStarted?.status === 'IN_PROGRESS' && Number(pickingStarted?.version) === 0
&& Number(pickingStarted?.expectedCount) === 1 && Number(pickingStarted?.pickedCount) === 0,
&& Number(pickingStarted?.expectedCount) === 1 && Number(pickingStarted?.pickedCount) === 1,
`status=${pickingStarted?.status || 'missing'}, version=${pickingStarted?.version ?? 'missing'}`, OPTIONS)
const pickingMismatch = await request(context, 'POST', `${base}/loading/picking/complete?tenantId=${encodeURIComponent(tenantId)}`,
@@ -240,6 +351,7 @@ export async function run(context) {
`status=${finalData?.summary?.processStatus || 'missing'}, version=${finalData?.summary?.version ?? 'missing'}, histories=${finalData?.statusHistory?.length ?? 'n/a'}`, OPTIONS)
context.createdResources.phase3Loading = {
tenantId, inboundId, preloadPlanId: planId, evidenceIds: evidenceItems.map((item) => id(item.id)),
loadingPlanId, containerBookingId, containerNo, warehouseId, canonicalPdaTaskId: canonicalTaskId,
pickingTaskId: id(pickingCompleted?.id), marker: context.marker, finalStatus: 'LOADED',
}
@@ -28,10 +28,12 @@ async function request(context, method, path, body, headers = {}) {
}
export async function run(context) {
const fixture = context.createdResources?.phase3ContainerLoading
const fixture = context.createdResources?.phase3Loading
|| context.createdResources?.phase3ContainerLoading
const inboundId = id(fixture?.inboundId)
const containerBookingId = id(fixture?.containerBookingId)
if (!inboundId || !containerBookingId || fixture?.marker !== context.marker) {
if (!inboundId || !containerBookingId || !['COMPLETED', 'LOADED'].includes(fixture?.finalStatus)
|| fixture?.marker !== context.marker) {
context.addCoverageGap(MODULE, OPTIONS.scenario, '国内出仓主聚合真实闭环',
'本轮未取得 marker 隔离的柜次装柜 fixture,未使用历史资源', 'fixture')
return
@@ -1,10 +1,59 @@
import { registerPendingContract } from './_phase3-pending-contract.mjs'
const MODULE = 'phase3-pda-convergence'
const OPTIONS = { scenario: 'pda-picking-convergence', deploymentBlocker: true }
function id(value) { return value == null ? '' : String(value) }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
function failure(response) { return [400, 401, 403, 404, 409, 503].includes(response?.http) || (response?.http === 200 && codeOf(response) !== '200') }
async function request(context, method, path, body, headers = {}) {
try {
const response = await fetch(`${context.options.gateway}${path}`, { method,
headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`,
...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers },
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000) })
const raw = await response.text(); let payload = null
try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ }
return { method, path, http: response.status, body: payload, raw: payload === null ? raw.slice(0, 500) : null,
requestId: payload?.requestId || response.headers.get('x-request-id'), traceId: payload?.traceId || response.headers.get('x-trace-id') }
} catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } }
}
export async function run(context) {
registerPendingContract(context, {
module: 'phase3-pda-convergence', scenario: 'pda-picking-convergence', name: 'PDA 柜次拣货与入库单汇总拣货收敛',
reason: '柜次级 /first-leg/pda-loading 和入库单级 /inbounds/{id}/loading/picking 是两套独立任务;当前无领取、作业员分配、复核和统一完成门禁', category: 'contract-gap',
fixtures: ['phase3Loading', 'phase3ContainerLoading'], contractSource: '待新增 PDA 任务领取、分配、复核与汇总契约',
assertions: ['任务领取和作业员隔离', '柜次明细与入库汇总数量一致', '复核完成幂等且成为装柜门禁前置'],
})
const fixture = context.createdResources?.phase3Loading
const tenantId = id(fixture?.tenantId); const inboundId = id(fixture?.inboundId)
const taskId = id(fixture?.canonicalPdaTaskId); const containerNo = fixture?.containerNo
const warehouseId = id(fixture?.warehouseId)
if (!tenantId || !inboundId || !taskId || !containerNo || !warehouseId || fixture?.marker !== context.marker) {
context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 柜次拣货与入库单汇总拣货收敛',
'本轮未取得 marker 隔离的柜次 PDA 与入库汇总 fixture,未使用历史资源', 'fixture')
return
}
context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 柜次拣货与入库单汇总拣货收敛',
'PDA唯一事实源、作业员隔离、复核和入库聚合真实断言已完成,待目标提交部署并取得真实 HTTP PASS 后删除此项',
'deployment-validation-pending', { activation: { state: 'pending-deployment-validation' } })
const taskResponse = await request(context, 'GET',
`/api/platform/v1/first-leg/pda-loading/tasks?containerNo=${encodeURIComponent(containerNo)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const task = context.apiSuccess(MODULE, '回查柜次 PDA 唯一事实任务', taskResponse, '', OPTIONS)
context.assert(MODULE, '柜次任务已领取、复核且明细数量一致', taskResponse,
id(task?.id) === taskId && task?.taskStatus === 'COMPLETED' && task?.workflowStatus === 'COMPLETED'
&& Boolean(task?.assignment?.assigneeId) && task?.review?.status === 'APPROVED'
&& task?.items?.every?.((item) => Number(item.plannedPackageCount) === Number(item.pickedPackageCount)),
`taskStatus=${task?.taskStatus || 'missing'}, workflow=${task?.workflowStatus || 'missing'}`, OPTIONS)
const projectionResponse = await request(context, 'GET',
`/api/platform/v1/inbounds/${inboundId}/loading/picking?tenantId=${encodeURIComponent(tenantId)}`)
const projection = context.apiSuccess(MODULE, '查询入库级 PDA 服务端聚合投影', projectionResponse, '', OPTIONS)
context.assert(MODULE, '入库投影与柜次事实数量和终态一致', projectionResponse,
projection?.status === 'COMPLETED' && Number(projection?.expectedCount) > 0
&& Number(projection?.expectedCount) === Number(projection?.pickedCount),
`status=${projection?.status || 'missing'}, expected=${projection?.expectedCount ?? 'missing'}, picked=${projection?.pickedCount ?? 'missing'}`, OPTIONS)
const operationsResponse = await request(context, 'GET',
`/api/platform/v1/first-leg/pda-loading/tasks/${taskId}/operations`)
const operations = context.apiSuccess(MODULE, '查询柜次 PDA 不可变操作历史', operationsResponse, '', OPTIONS)
context.assert(MODULE, '领取和复核操作历史含操作者与时间', operationsResponse,
Array.isArray(operations) && ['CLAIM', 'REVIEW'].every((action) => operations.some((item) => item.actionType === action
&& Boolean(item.operatorId) && Boolean(item.createdAt))),
`actions=${Array.isArray(operations) ? operations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS)
const forged = await request(context, 'POST',
`/api/platform/v1/inbounds/${inboundId}/loading/picking/start?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: Number(projection?.version), expectedCount: 999, pickedCount: 999, remark: 'forged' },
{ 'Idempotency-Key': `${context.marker}-pda-forged-projection`.slice(0, 128) })
context.addStep(MODULE, '入库兼容入口拒绝客户端伪造汇总数量', forged, failure(forged),
`HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS)
}
@@ -85,8 +85,9 @@ export async function run(context) {
const ocean = await ensureOceanBooking(context, suffix)
if (!ocean?.id) return
const flow = context.createdResources?.phase3ContainerLoading
const containerBookingId = flow?.finalStatus === 'COMPLETED' && id(flow?.tenantId) === '10001'
const flow = context.createdResources?.phase3Loading
|| context.createdResources?.phase3ContainerLoading
const containerBookingId = ['COMPLETED', 'LOADED'].includes(flow?.finalStatus)
? id(flow.containerBookingId) : null
const knownAllocation = context.createdResources?.oceanBooking?.id === ocean.id && containerBookingId
? containerBookingId : null
@@ -8,8 +8,10 @@ function codeOf(response) { return response?.body?.code == null ? 'missing' : St
export async function run(context) {
const ocean = context.createdResources?.oceanBooking
const containerFlow = context.createdResources?.phase3ContainerLoading
const containerFixture = context.createdResources?.orchestrationFixtures?.phase3Container
const containerFlow = context.createdResources?.phase3Loading
|| context.createdResources?.phase3ContainerLoading
const containerFixture = context.createdResources?.orchestrationFixtures?.inboundMeasurement
|| context.createdResources?.orchestrationFixtures?.phase3Container
if (!ocean?.id || ocean.finalStatus !== 'RELEASED') {
context.addStep(MODULE, '跨服务链路前置订舱终态可用', null, false,
'本轮海运订舱模块未返回 RELEASED 隔离记录,未伪造关联拖车预约', OPTIONS)
@@ -22,7 +24,7 @@ export async function run(context) {
const customerId = id(oceanDetail?.oceanBooking?.customerId)
context.assert(MODULE, '跨服务链路订舱客户归属有效', oceanResponse, Boolean(customerId), `customerId=${customerId || 'missing'}`, OPTIONS)
if (!customerId) return
const linkContainer = containerFlow?.finalStatus === 'COMPLETED'
const linkContainer = (containerFlow?.finalStatus === 'COMPLETED' || containerFlow?.finalStatus === 'LOADED')
&& id(containerFlow?.tenantId) === customerId
&& Boolean(containerFlow?.containerBookingId)
&& Boolean(containerFlow?.loadingPlanId)