feat(firstleg): converge phase3 loading models and pda
This commit is contained in:
@@ -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;
|
||||
|
||||
+44
-4
@@ -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;
|
||||
}
|
||||
+52
@@ -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));
|
||||
}
|
||||
}
|
||||
+29
@@ -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) {}
|
||||
}
|
||||
+26
@@ -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;
|
||||
}
|
||||
+73
@@ -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) {}
|
||||
}
|
||||
+54
@@ -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); }
|
||||
}
|
||||
+119
@@ -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("数据已被其他人修改,请刷新后重试"); }
|
||||
}
|
||||
+12
@@ -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} "
|
||||
|
||||
+41
-13
@@ -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}
|
||||
+27
@@ -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");
|
||||
}
|
||||
}
|
||||
+30
@@ -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 + "')");
|
||||
}
|
||||
}
|
||||
+35
@@ -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");
|
||||
}
|
||||
}
|
||||
+74
@@ -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());
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+54
@@ -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自动汇总");
|
||||
}
|
||||
}
|
||||
+22
-10
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
+8
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user