Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test
This commit is contained in:
@@ -200,6 +200,7 @@ public final class LoadingListDtos {
|
||||
public record History(
|
||||
Long historyId,
|
||||
Long itemId,
|
||||
String operationId,
|
||||
String actionType,
|
||||
String fromStatus,
|
||||
String toStatus,
|
||||
|
||||
@@ -125,12 +125,18 @@ public final class LoadingPickDtos {
|
||||
Long operatorId, String operatorName, LocalDateTime createdAt) {
|
||||
}
|
||||
|
||||
public record CompletionCommand(Long id, String operationId, String status, Integer expectedTaskVersion, String remark,
|
||||
Integer attemptCount, LocalDateTime nextRetryAt, String failureStage,
|
||||
String failureCode, String requestId, String traceId,
|
||||
LocalDateTime completedAt) {
|
||||
}
|
||||
|
||||
public record Task(Long id, Long loadingPlanId, Long containerBookingId, Long inboundId,
|
||||
Integer inboundVersion, Integer loadingVersion, String cabinetNo,
|
||||
String currentContainerNo, String sourceType, String recordStatus,
|
||||
Long mergedIntoContainerBookingId, String taskStatus,
|
||||
String workflowStatus, Integer version, Assignment assignment,
|
||||
Review review, List<PickItem> items) {
|
||||
Review review, CompletionCommand completionCommand, List<PickItem> items) {
|
||||
}
|
||||
|
||||
/** PDA 工作台只展示当前作业员可领取或已领取的进行中任务。 */
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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;
|
||||
|
||||
/** Durable PDA completion command. Cross-service work is never executed in the acceptance transaction. */
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_loading_completion_command")
|
||||
public class LoadingCompletionCommandEntity {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private Long pickTaskId;
|
||||
private String operationId;
|
||||
private String requestHash;
|
||||
private String commandStatus;
|
||||
private Integer expectedTaskVersion;
|
||||
private String remark;
|
||||
private Long operatorId;
|
||||
private String operatorName;
|
||||
private Integer attemptCount;
|
||||
private LocalDateTime nextRetryAt;
|
||||
private String leaseOwner;
|
||||
private LocalDateTime leaseUntil;
|
||||
private String failureStage;
|
||||
private String failureCode;
|
||||
private String failureRequestId;
|
||||
private String failureTraceId;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
private LocalDateTime completedAt;
|
||||
}
|
||||
@@ -61,6 +61,17 @@ public class LoadingRepository {
|
||||
.eq(LoadingEvidenceEntity::getSealNo, sealNo));
|
||||
}
|
||||
|
||||
public LoadingEvidenceEntity findActiveEvidenceByFile(long tenantId, long inboundId,
|
||||
String evidenceType, long fileId) {
|
||||
return evidences.selectOne(new LambdaQueryWrapper<LoadingEvidenceEntity>()
|
||||
.eq(LoadingEvidenceEntity::getTenantId, tenantId)
|
||||
.eq(LoadingEvidenceEntity::getInboundId, inboundId)
|
||||
.eq(LoadingEvidenceEntity::getEvidenceType, evidenceType)
|
||||
.eq(LoadingEvidenceEntity::getFileId, fileId)
|
||||
.eq(LoadingEvidenceEntity::getStatus, "ACTIVE")
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
public void createEvidence(LoadingEvidenceEntity entity) { evidences.insert(entity); }
|
||||
|
||||
public int voidEvidence(long tenantId, long inboundId, long evidenceId, String reason,
|
||||
|
||||
+62
-14
@@ -34,6 +34,7 @@ import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -74,6 +75,18 @@ public class LoadingDeliveryService {
|
||||
throw new BizException(ResultCode.CONFLICT, "锁码已用于其他装柜任务");
|
||||
}
|
||||
}
|
||||
LoadingEvidenceEntity existingFile = repository.findActiveEvidenceByFile(
|
||||
tenantId, inboundId, request.evidenceType(), request.fileId());
|
||||
if (existingFile != null) {
|
||||
String requestedSealNo = LoadingEvidenceType.SEAL.equals(request.evidenceType())
|
||||
? request.sealNo().trim() : null;
|
||||
if (!Objects.equals(existingFile.getSealNo(), requestedSealNo)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "该带锁码照已绑定其他锁码,请先作废后重新拍摄");
|
||||
}
|
||||
LoadingActionResponse result = new LoadingActionResponse(
|
||||
requireInbound(tenantId, inboundId).processStatus(), evidenceResponse(tenantId, inboundId));
|
||||
return complete(tenantId, inboundId, operatorId, UPSERT_EVIDENCE, key, result);
|
||||
}
|
||||
var file = validateFile(tenantId, inboundId, request.fileId(), request.evidenceType());
|
||||
String fileName = file.fileName();
|
||||
String sealNo = LoadingEvidenceType.SEAL.equals(request.evidenceType()) ? request.sealNo().trim() : null;
|
||||
@@ -103,23 +116,33 @@ public class LoadingDeliveryService {
|
||||
private LoadingActionResponse completeLoading(long tenantId, long inboundId, long evidenceInboundId,
|
||||
long operatorId, String operatorName, String key,
|
||||
CompleteLoadingRequest request) {
|
||||
return completeLoading(tenantId, inboundId, evidenceInboundId, operatorId, operatorName,
|
||||
key, request, false);
|
||||
}
|
||||
|
||||
private LoadingActionResponse completeLoading(long tenantId, long inboundId, long evidenceInboundId,
|
||||
long operatorId, String operatorName, String key,
|
||||
CompleteLoadingRequest request,
|
||||
boolean acceptedPdaCompletion) {
|
||||
String hash = hash(request);
|
||||
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, hash);
|
||||
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, COMPLETE_LOADING,
|
||||
key, hash, acceptedPdaCompletion);
|
||||
if (replay != null) return replay;
|
||||
Snapshot inbound = requireInbound(tenantId, inboundId);
|
||||
CompletionTarget target = repository.completionTarget(tenantId, inboundId);
|
||||
if (target != null) {
|
||||
return completeFulfillmentSlice(tenantId, evidenceInboundId, operatorId, operatorName, key, request, target);
|
||||
return completeFulfillmentSlice(tenantId, evidenceInboundId, operatorId, operatorName,
|
||||
key, request, target, acceptedPdaCompletion);
|
||||
}
|
||||
if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) {
|
||||
loadingModelBridge.requireCompleted(tenantId, inboundId);
|
||||
requireLoadingPlanGate(tenantId, inboundId, acceptedPdaCompletion);
|
||||
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);
|
||||
requireLoadingPlanGate(tenantId, inboundId, acceptedPdaCompletion);
|
||||
completionGate.requireReleasedBooking(tenantId, inboundId);
|
||||
requireCompletedPicking(tenantId, inboundId);
|
||||
requirePickingGate(tenantId, inboundId, acceptedPdaCompletion);
|
||||
requireCompletionEvidence(tenantId, evidenceInboundId);
|
||||
if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent();
|
||||
addHistory(tenantId, inboundId, "LOADING", "LOADED", COMPLETE_LOADING, request.remark(), operatorId, operatorName);
|
||||
@@ -132,12 +155,14 @@ public class LoadingDeliveryService {
|
||||
* Resolve and lock the canonical completion row after settlement, then retain optimistic locking
|
||||
* with that current server version instead of reusing the pre-settlement task snapshot.
|
||||
*/
|
||||
@Transactional
|
||||
public LoadingActionResponse completeAfterPdaSettlement(long tenantId, long inboundId,
|
||||
long operatorId, String operatorName,
|
||||
String key, String remark) {
|
||||
return completeAfterPdaSettlement(tenantId, inboundId, inboundId, operatorId, operatorName, key, remark);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public LoadingActionResponse completeAfterPdaSettlement(long tenantId, long inboundId,
|
||||
long evidenceInboundId, long operatorId,
|
||||
String operatorName, String key, String remark) {
|
||||
@@ -148,7 +173,7 @@ public class LoadingDeliveryService {
|
||||
throw new BizException(ResultCode.CONFLICT, "PDA结算后的实装履约分片不存在,请刷新后重试");
|
||||
}
|
||||
return completeLoading(tenantId, inboundId, evidenceInboundId, operatorId, operatorName, key,
|
||||
new CompleteLoadingRequest(current.version(), remark));
|
||||
new CompleteLoadingRequest(current.version(), remark), true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,7 +197,8 @@ public class LoadingDeliveryService {
|
||||
private LoadingActionResponse completeFulfillmentSlice(long tenantId, long evidenceInboundId,
|
||||
long operatorId, String operatorName, String key,
|
||||
CompleteLoadingRequest request,
|
||||
CompletionTarget target) {
|
||||
CompletionTarget target,
|
||||
boolean acceptedPdaCompletion) {
|
||||
if ("LOADED".equals(target.loadedChildStatus())) {
|
||||
return complete(tenantId, evidenceInboundId, operatorId, COMPLETE_LOADING, key,
|
||||
new LoadingActionResponse("LOADED", evidenceResponse(tenantId, evidenceInboundId)));
|
||||
@@ -180,9 +206,9 @@ public class LoadingDeliveryService {
|
||||
if (!"LOADING".equals(target.loadedChildStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "实装履约分片不在装柜中");
|
||||
}
|
||||
loadingModelBridge.requireCompleted(tenantId, target.sourceInboundId());
|
||||
requireLoadingPlanGate(tenantId, target.sourceInboundId(), acceptedPdaCompletion);
|
||||
completionGate.requireReleasedBooking(tenantId, target.sourceInboundId());
|
||||
requireCompletedPicking(tenantId, target.sourceInboundId());
|
||||
requirePickingGate(tenantId, target.sourceInboundId(), acceptedPdaCompletion);
|
||||
requireCompletionEvidence(tenantId, evidenceInboundId);
|
||||
if (repository.completeLoadedChild(tenantId, target.sourceInboundId(), operatorId,
|
||||
request.expectedVersion()) != 1) throw concurrent();
|
||||
@@ -295,9 +321,21 @@ public class LoadingDeliveryService {
|
||||
task.getExpectedCount(), task.getPickedCount(), task.getRemark());
|
||||
}
|
||||
|
||||
private void requireCompletedPicking(long tenantId, long inboundId) {
|
||||
if (!canonicalPicking(tenantId, inboundId).completed()) {
|
||||
throw new BizException(ResultCode.CONFLICT, "PDA拣货复核尚未完成");
|
||||
/** worker 先完成各入库单,之后才完成柜次计划;这里不能循环要求计划已经 COMPLETED。 */
|
||||
private void requireLoadingPlanGate(long tenantId, long inboundId, boolean acceptedPdaCompletion) {
|
||||
if (acceptedPdaCompletion) {
|
||||
loadingModelBridge.requireStarted(tenantId, inboundId);
|
||||
return;
|
||||
}
|
||||
loadingModelBridge.requireCompleted(tenantId, inboundId);
|
||||
}
|
||||
|
||||
/** completion worker 已持有 durable lease;此时 task 尚为 COMPLETING,不能循环要求最终 COMPLETED。 */
|
||||
private void requirePickingGate(long tenantId, long inboundId, boolean acceptedPdaCompletion) {
|
||||
CanonicalPicking picking = canonicalPicking(tenantId, inboundId);
|
||||
if (acceptedPdaCompletion ? !picking.resolved() : !picking.completed()) {
|
||||
throw new BizException(ResultCode.CONFLICT, acceptedPdaCompletion
|
||||
? "PDA实装明细尚未全部解决" : "PDA拣货复核尚未完成");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,9 +362,12 @@ public class LoadingDeliveryService {
|
||||
|
||||
private record CanonicalPicking(int expectedCount, int pickedCount, int taskCount,
|
||||
int completedTaskCount, int unresolvedCount) {
|
||||
boolean completed() {
|
||||
boolean resolved() {
|
||||
return expectedCount > 0 && expectedCount == pickedCount && taskCount > 0
|
||||
&& completedTaskCount == taskCount && unresolvedCount == 0;
|
||||
&& unresolvedCount == 0;
|
||||
}
|
||||
boolean completed() {
|
||||
return resolved() && completedTaskCount == taskCount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,10 +459,17 @@ public class LoadingDeliveryService {
|
||||
|
||||
private LoadingActionResponse claimOrReplay(long tenantId, long inboundId, long operatorId,
|
||||
String operation, String key, String hash) {
|
||||
return claimOrReplay(tenantId, inboundId, operatorId, operation, key, hash, false);
|
||||
}
|
||||
|
||||
private LoadingActionResponse claimOrReplay(long tenantId, long inboundId, long operatorId,
|
||||
String operation, String key, String hash,
|
||||
boolean resumeProcessing) {
|
||||
validateKey(key);
|
||||
if (repository.claimOperation(tenantId, inboundId, operatorId, operation, key, hash)) return null;
|
||||
LoadingOperationEntity existing = repository.findOperation(tenantId, inboundId, operatorId, operation, key);
|
||||
if (existing == null || !hash.equals(existing.getRequestHash())) throw new BizException(ResultCode.CONFLICT, "幂等键已用于其他请求");
|
||||
if (resumeProcessing && "PROCESSING".equals(existing.getStatus())) return null;
|
||||
if (!"SUCCESS".equals(existing.getStatus()) || !StringUtils.hasText(existing.getResultJson())) throw new BizException(ResultCode.CONFLICT, "相同请求正在处理中,请稍后重试");
|
||||
try { return objectMapper.readValue(existing.getResultJson(), LoadingActionResponse.class); }
|
||||
catch (JsonProcessingException e) { throw new IllegalStateException("无法读取装柜幂等结果", e); }
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** 读取 PDA 结算后的最终实装入仓单和冻结货物快照。 */
|
||||
@@ -15,6 +16,9 @@ public interface LoadingCompletedEventMapper {
|
||||
@Select("""
|
||||
SELECT p.id AS loading_plan_id, p.plan_no, p.container_booking_id, p.version AS aggregate_version,
|
||||
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
|
||||
b.customer_id AS booking_customer_id, b.customer_name,
|
||||
b.estimated_port_arrival_at, b.actual_port_arrival_at,
|
||||
b.estimated_warehouse_arrival_at,
|
||||
i.account_id, i.inbound_id AS source_inbound_id,
|
||||
COALESCE(o.root_inbound_id, o.id) AS root_inbound_id,
|
||||
o.order_id AS source_order_id, o.order_no AS source_order_no,
|
||||
@@ -49,7 +53,8 @@ public interface LoadingCompletedEventMapper {
|
||||
/** 读取一键代发托盘及其箱唛快照;普通订单不会使用该结果。 */
|
||||
@Select("""
|
||||
SELECT i.account_id, i.inbound_id AS source_inbound_id,
|
||||
a.pallet_code, a.allocation_no, a.picked_package_count AS allocated_package_count,
|
||||
a.pallet_id, a.reservation_id, a.pallet_code, a.allocation_no,
|
||||
a.picked_package_count AS allocated_package_count,
|
||||
i.inbound_detail_id AS source_inbound_detail_id, i.sku_code,
|
||||
i.product_name_zh, i.package_count, i.product_quantity,
|
||||
label.box_code
|
||||
@@ -79,7 +84,10 @@ public interface LoadingCompletedEventMapper {
|
||||
class Row {
|
||||
private Long loadingPlanId; private String planNo; private Long containerBookingId;
|
||||
private Integer aggregateVersion; private String containerNo; private String cabinetNo;
|
||||
private Long warehouseId; private String warehouseName; private Long accountId;
|
||||
private Long warehouseId; private String warehouseName; private Long bookingCustomerId;
|
||||
private String customerName; private Long accountId;
|
||||
private LocalDateTime estimatedPortArrivalAt; private LocalDateTime actualPortArrivalAt;
|
||||
private LocalDateTime estimatedWarehouseArrivalAt;
|
||||
private Long sourceInboundId; private Long rootInboundId; private Long sourceOrderId;
|
||||
private Long loadingPlanItemId;
|
||||
private String sourceOrderNo; private Long rootOrderId; private String inboundCode;
|
||||
@@ -95,7 +103,8 @@ public interface LoadingCompletedEventMapper {
|
||||
|
||||
@Getter @Setter
|
||||
class PalletRow {
|
||||
private Long accountId; private Long sourceInboundId; private String palletCode;
|
||||
private Long accountId; private Long sourceInboundId; private Long palletId;
|
||||
private Long reservationId; private String palletCode;
|
||||
private Integer allocationNo; private Integer allocatedPackageCount;
|
||||
private Long sourceInboundDetailId; private String skuCode; private String productNameZh;
|
||||
private Integer packageCount; private Integer productQuantity; private String boxCode;
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
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.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface LoadingCompletionCommandMapper extends BaseMapper<LoadingCompletionCommandEntity> {
|
||||
@Insert("""
|
||||
INSERT IGNORE INTO fl_loading_completion_command
|
||||
(id,pick_task_id,operation_id,request_hash,command_status,expected_task_version,remark,
|
||||
operator_id,operator_name,attempt_count,next_retry_at)
|
||||
VALUES
|
||||
(#{id},#{pickTaskId},#{operationId},#{requestHash},'PROCESSING',#{expectedTaskVersion},#{remark},
|
||||
#{operatorId},#{operatorName},0,CURRENT_TIMESTAMP(3))
|
||||
""")
|
||||
int insertCommand(LoadingCompletionCommandEntity command);
|
||||
|
||||
@Select("SELECT * FROM fl_loading_completion_command WHERE operation_id=#{operationId}")
|
||||
LoadingCompletionCommandEntity findByOperation(@Param("operationId") String operationId);
|
||||
|
||||
@Select("""
|
||||
SELECT * FROM fl_loading_completion_command
|
||||
WHERE pick_task_id=#{taskId} AND command_status IN ('PROCESSING','RETRYABLE','FAILED')
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
""")
|
||||
LoadingCompletionCommandEntity findActiveByTask(@Param("taskId") long taskId);
|
||||
|
||||
@Select("SELECT * FROM fl_loading_completion_command WHERE pick_task_id=#{taskId} ORDER BY created_at DESC LIMIT 1")
|
||||
LoadingCompletionCommandEntity findLatestByTask(@Param("taskId") long taskId);
|
||||
|
||||
@Select("""
|
||||
SELECT * FROM fl_loading_completion_command
|
||||
WHERE command_status IN ('PROCESSING','RETRYABLE') AND next_retry_at<=CURRENT_TIMESTAMP(3)
|
||||
AND (lease_until IS NULL OR lease_until<CURRENT_TIMESTAMP(3))
|
||||
ORDER BY next_retry_at,id LIMIT #{limit}
|
||||
""")
|
||||
List<LoadingCompletionCommandEntity> findDue(@Param("limit") int limit);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_loading_completion_command
|
||||
SET command_status='PROCESSING',lease_owner=#{worker},lease_until=#{leaseUntil},
|
||||
attempt_count=attempt_count+1,updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE id=#{id} AND command_status IN ('PROCESSING','RETRYABLE')
|
||||
AND next_retry_at<=CURRENT_TIMESTAMP(3)
|
||||
AND (lease_until IS NULL OR lease_until<CURRENT_TIMESTAMP(3))
|
||||
""")
|
||||
int claim(@Param("id") long id, @Param("worker") String worker,
|
||||
@Param("leaseUntil") LocalDateTime leaseUntil);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_loading_completion_command SET lease_until=#{leaseUntil},updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE id=#{id} AND command_status='PROCESSING' AND lease_owner=#{worker}
|
||||
""")
|
||||
int renew(@Param("id") long id, @Param("worker") String worker,
|
||||
@Param("leaseUntil") LocalDateTime leaseUntil);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_loading_completion_command
|
||||
SET command_status='COMPLETED',completed_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),
|
||||
lease_owner=NULL,lease_until=NULL,failure_stage=NULL,failure_code=NULL,
|
||||
failure_request_id=NULL,failure_trace_id=NULL
|
||||
WHERE id=#{id} AND command_status='PROCESSING' AND lease_owner=#{worker}
|
||||
""")
|
||||
int complete(@Param("id") long id, @Param("worker") String worker);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_loading_completion_command
|
||||
SET command_status=#{status},next_retry_at=#{nextRetryAt},updated_at=CURRENT_TIMESTAMP(3),
|
||||
lease_owner=NULL,lease_until=NULL,failure_stage=#{stage},failure_code=#{code},
|
||||
failure_request_id=#{requestId},failure_trace_id=#{traceId}
|
||||
WHERE id=#{id} AND command_status='PROCESSING' AND lease_owner=#{worker}
|
||||
""")
|
||||
int fail(@Param("id") long id, @Param("worker") String worker, @Param("status") String status,
|
||||
@Param("nextRetryAt") LocalDateTime nextRetryAt, @Param("stage") String stage,
|
||||
@Param("code") String code, @Param("requestId") String requestId, @Param("traceId") String traceId);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_loading_completion_command
|
||||
SET command_status='RETRYABLE',next_retry_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),
|
||||
lease_owner=NULL,lease_until=NULL
|
||||
WHERE id=#{id} AND command_status='FAILED'
|
||||
""")
|
||||
int resume(@Param("id") long id);
|
||||
|
||||
@Select("""
|
||||
SELECT checkpoint_stage FROM fl_loading_completion_checkpoint
|
||||
WHERE command_id=#{commandId} AND tenant_id=#{tenantId} AND inbound_id=#{inboundId}
|
||||
""")
|
||||
String checkpoint(@Param("commandId") long commandId, @Param("tenantId") long tenantId,
|
||||
@Param("inboundId") long inboundId);
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO fl_loading_completion_checkpoint
|
||||
(command_id,tenant_id,inbound_id,owner_sequence,checkpoint_stage)
|
||||
VALUES (#{commandId},#{tenantId},#{inboundId},#{sequence},#{stage})
|
||||
ON DUPLICATE KEY UPDATE checkpoint_stage=VALUES(checkpoint_stage),updated_at=CURRENT_TIMESTAMP(3)
|
||||
""")
|
||||
int checkpointCompleted(@Param("commandId") long commandId, @Param("tenantId") long tenantId,
|
||||
@Param("inboundId") long inboundId, @Param("sequence") int sequence,
|
||||
@Param("stage") String stage);
|
||||
}
|
||||
@@ -232,6 +232,7 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
|
||||
/**
|
||||
* 以结算后的装柜计划事实为完成口径,不能只检查当前 PDA 任务中的明细。
|
||||
* PARTIAL 在本查询前已把计划箱数收敛为实际拣货箱数;收敛前的严格小于关系由结算计划校验。
|
||||
* 异步完成期间任务会在 COMPLETING/COMPLETION_FAILED 间切换,不能把任务状态当成明细未完成事实。
|
||||
*/
|
||||
@Select("""
|
||||
SELECT COUNT(*)
|
||||
@@ -244,7 +245,6 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
|
||||
JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id
|
||||
WHERE pick_item.loading_plan_item_id = plan_item.id
|
||||
AND pick_task.loading_plan_id = #{planId}
|
||||
AND pick_task.task_status = 'LOADING'
|
||||
AND (
|
||||
(pick_item.item_status = 'PICKED'
|
||||
AND pick_item.picked_package_count = plan_item.package_count)
|
||||
|
||||
@@ -19,6 +19,29 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
|
||||
+ "AND task_status='LOADING' AND version=#{expectedVersion}")
|
||||
int touch(@Param("taskId") long taskId, @Param("expectedVersion") int expectedVersion);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_loading_pick_task SET task_status='COMPLETING',version=version+1
|
||||
WHERE id=#{taskId} AND task_status='LOADING' AND version=#{expectedVersion}
|
||||
""")
|
||||
int acceptCompletion(@Param("taskId") long taskId, @Param("expectedVersion") int expectedVersion);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_loading_pick_task
|
||||
SET task_status='COMPLETED',completed_by=#{operatorId},completed_by_name=#{operatorName},
|
||||
completed_at=CURRENT_TIMESTAMP(3),version=version+1
|
||||
WHERE id=#{taskId} AND task_status='COMPLETING'
|
||||
""")
|
||||
int completeAccepted(@Param("taskId") long taskId, @Param("operatorId") long operatorId,
|
||||
@Param("operatorName") String operatorName);
|
||||
|
||||
@Update("UPDATE fl_loading_pick_task SET task_status='COMPLETION_FAILED',version=version+1 "
|
||||
+ "WHERE id=#{taskId} AND task_status='COMPLETING'")
|
||||
int failCompletion(@Param("taskId") long taskId);
|
||||
|
||||
@Update("UPDATE fl_loading_pick_task SET task_status='COMPLETING',version=version+1 "
|
||||
+ "WHERE id=#{taskId} AND task_status='COMPLETION_FAILED'")
|
||||
int resumeCompletion(@Param("taskId") long taskId);
|
||||
|
||||
@Select("""
|
||||
SELECT DISTINCT task.*
|
||||
FROM fl_loading_pick_task task
|
||||
@@ -60,6 +83,8 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
|
||||
booking.merged_into_container_booking_id AS mergedIntoContainerBookingId,
|
||||
booking.warehouse_name AS warehouseName, task.task_status AS taskStatus,
|
||||
CASE WHEN task.task_status = 'COMPLETED' THEN 'COMPLETED'
|
||||
WHEN task.task_status = 'COMPLETING' THEN 'PROCESSING'
|
||||
WHEN task.task_status = 'COMPLETION_FAILED' THEN 'COMPLETION_FAILED'
|
||||
WHEN assignment.pick_task_id IS NULL THEN 'UNASSIGNED'
|
||||
WHEN SUM(CASE WHEN pick_item.item_status IN ('PENDING', 'PICKING') THEN 1 ELSE 0 END) = 0
|
||||
THEN 'AWAITING_REVIEW'
|
||||
@@ -72,7 +97,7 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
|
||||
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
|
||||
JOIN fl_loading_pick_item pick_item ON pick_item.pick_task_id = task.id
|
||||
LEFT JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id = task.id
|
||||
WHERE task.task_status = 'LOADING' AND booking.record_status = 'ACTIVE'
|
||||
WHERE task.task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED') AND booking.record_status = 'ACTIVE'
|
||||
AND (assignment.pick_task_id IS NULL OR assignment.assignee_id = #{operatorId})
|
||||
<if test="keyword != null and keyword != ''">
|
||||
AND (booking.container_no LIKE CONCAT('%', #{keyword}, '%')
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity;
|
||||
import com.baiyue.firstleg.mapper.LoadingCompletionCommandMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public class LoadingCompletionCommandRepository {
|
||||
@Resource private LoadingCompletionCommandMapper mapper;
|
||||
|
||||
public int insert(LoadingCompletionCommandEntity command) { return mapper.insertCommand(command); }
|
||||
public LoadingCompletionCommandEntity operation(String operationId) { return mapper.findByOperation(operationId); }
|
||||
public LoadingCompletionCommandEntity active(long taskId) { return mapper.findActiveByTask(taskId); }
|
||||
public LoadingCompletionCommandEntity latest(long taskId) { return mapper.findLatestByTask(taskId); }
|
||||
public List<LoadingCompletionCommandEntity> due(int limit) { return mapper.findDue(limit); }
|
||||
public boolean claim(long id, String worker, LocalDateTime leaseUntil) { return mapper.claim(id, worker, leaseUntil) == 1; }
|
||||
public boolean renew(long id, String worker, LocalDateTime leaseUntil) { return mapper.renew(id, worker, leaseUntil) == 1; }
|
||||
public boolean complete(long id, String worker) { return mapper.complete(id, worker) == 1; }
|
||||
public boolean fail(long id, String worker, String status, LocalDateTime nextRetryAt, String stage,
|
||||
String code, String requestId, String traceId) {
|
||||
return mapper.fail(id, worker, status, nextRetryAt, stage, code, requestId, traceId) == 1;
|
||||
}
|
||||
public boolean resume(long id) { return mapper.resume(id) == 1; }
|
||||
public String checkpoint(long commandId, long tenantId, long inboundId) {
|
||||
return mapper.checkpoint(commandId, tenantId, inboundId);
|
||||
}
|
||||
public void checkpoint(long commandId, long tenantId, long inboundId, int sequence, String stage) {
|
||||
mapper.checkpointCompleted(commandId, tenantId, inboundId, sequence, stage);
|
||||
}
|
||||
}
|
||||
@@ -188,7 +188,7 @@ public class LoadingListRepository {
|
||||
|
||||
private List<History> histories(long planId) {
|
||||
return mapper.selectHistories(planId).stream().map(row -> new History(row.getHistoryId(), row.getItemId(),
|
||||
row.getActionType(), row.getFromStatus(), row.getToStatus(), row.getBeforeSnapshot(),
|
||||
row.getOperationId(), row.getActionType(), row.getFromStatus(), row.getToStatus(), row.getBeforeSnapshot(),
|
||||
row.getAfterSnapshot(), row.getOperatorId(), row.getOperatorName(), row.getOperationReason(),
|
||||
row.getOccurredAt())).toList();
|
||||
}
|
||||
|
||||
@@ -57,6 +57,18 @@ public class LoadingPickRepository {
|
||||
return tasks.touch(taskId, expectedVersion);
|
||||
}
|
||||
|
||||
public int acceptCompletion(long taskId, int expectedVersion) {
|
||||
return tasks.acceptCompletion(taskId, expectedVersion);
|
||||
}
|
||||
|
||||
public int completeAccepted(long taskId, long operatorId, String operatorName) {
|
||||
return tasks.completeAccepted(taskId, operatorId, operatorName);
|
||||
}
|
||||
|
||||
public int failCompletion(long taskId) { return tasks.failCompletion(taskId); }
|
||||
|
||||
public int resumeCompletion(long taskId) { return tasks.resumeCompletion(taskId); }
|
||||
|
||||
public LoadingPickTaskEntity taskByPlan(long planId) {
|
||||
return tasks.selectOne(new LambdaQueryWrapper<LoadingPickTaskEntity>()
|
||||
.eq(LoadingPickTaskEntity::getLoadingPlanId, planId));
|
||||
|
||||
+34
-5
@@ -37,7 +37,7 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** 在装柜完成事务内按客户写跨区业务 Outbox。 */
|
||||
/** 在装柜完成事务内按客户及可信客户名称快照写跨区业务 Outbox。 */
|
||||
@Service
|
||||
public class LoadingCompletedOutboxService {
|
||||
private static final long HANDOVER_VERSION = 2L;
|
||||
@@ -49,6 +49,7 @@ public class LoadingCompletedOutboxService {
|
||||
public void publish(long planId) {
|
||||
List<Row> rows = snapshotMapper.rows(planId);
|
||||
if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件");
|
||||
validateCustomerSnapshots(rows);
|
||||
Map<Long, List<String>> cartonMarks = cartonLabelPools(rows);
|
||||
List<PalletRow> palletRows = snapshotMapper.palletRows(planId);
|
||||
rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId,
|
||||
@@ -67,7 +68,8 @@ public class LoadingCompletedOutboxService {
|
||||
.filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(), cartonMarks)).toList();
|
||||
var payload = new FirstLegLoadingPlanCompletedV2(String.valueOf(first.getLoadingPlanId()),
|
||||
first.getPlanNo(), string(first.getContainerBookingId()), first.getContainerNo(), first.getCabinetNo(),
|
||||
string(first.getWarehouseId()), first.getWarehouseName(), orders);
|
||||
string(first.getWarehouseId()), first.getWarehouseName(), iso(first.getEstimatedPortArrivalAt()),
|
||||
iso(first.getActualPortArrivalAt()), iso(first.getEstimatedWarehouseArrivalAt()), orders);
|
||||
JsonNode payloadNode = objectMapper.valueToTree(payload);
|
||||
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode));
|
||||
String eventKey = "FIRSTLEG_LOADING_COMPLETED:" + first.getLoadingPlanId() + ':' + accountId + ':' + HANDOVER_VERSION;
|
||||
@@ -102,12 +104,38 @@ public class LoadingCompletedOutboxService {
|
||||
string(first.getRootInboundId()), string(first.getSourceOrderId()), first.getSourceOrderNo(),
|
||||
string(first.getRootOrderId()), first.getInboundCode(), string(first.getDestinationWarehouseId()),
|
||||
first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount),
|
||||
sumNullableInt(sorted, Row::getProductQuantity), "ONE_CLICK_DROPSHIP".equals(orderType)
|
||||
? pallets.size() : 0, sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg),
|
||||
sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), orderType, pallets,
|
||||
sumNullableInt(sorted, Row::getProductQuantity), actualPalletCount(palletRows),
|
||||
sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg),
|
||||
sum(sorted, Row::getVolumeCbm), sum(sorted, Row::getDimensionalWeightKg), items,
|
||||
string(first.getAccountId()), first.getCustomerName().trim(), orderType, pallets,
|
||||
destinationType(sorted));
|
||||
}
|
||||
|
||||
/** 只按 Warehouse 已分配的物理 palletId 计数;无分配事实时保持 null。 */
|
||||
private static Integer actualPalletCount(List<PalletRow> palletRows) {
|
||||
Set<Long> palletIds = palletRows.stream().map(PalletRow::getPalletId)
|
||||
.filter(Objects::nonNull).filter(value -> value > 0).collect(java.util.stream.Collectors.toSet());
|
||||
return palletIds.isEmpty() ? null : palletIds.size();
|
||||
}
|
||||
|
||||
private void validateCustomerSnapshots(List<Row> rows) {
|
||||
for (Row row : rows) {
|
||||
if (row.getAccountId() == null || !Objects.equals(row.getAccountId(), row.getBookingCustomerId())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "装柜计划客户与柜次客户快照不一致,不能生成尾程交接事件");
|
||||
}
|
||||
if (row.getCustomerName() == null || row.getCustomerName().isBlank()) {
|
||||
throw new BizException(ResultCode.CONFLICT, "柜次缺少客户名称快照,不能生成尾程交接事件");
|
||||
}
|
||||
}
|
||||
rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId)).forEach((accountId, values) -> {
|
||||
Set<String> names = values.stream().map(Row::getCustomerName).map(String::trim)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
if (names.size() != 1) {
|
||||
throw new BizException(ResultCode.CONFLICT, "同一客户事件存在多个客户名称快照");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 国内按实装箱数从可用标签池稳定随机抽取箱唛,重复发布同一计划时结果不变。 */
|
||||
private Map<Long, List<String>> cartonLabelPools(List<Row> rows) {
|
||||
Map<Long, List<String>> result = new java.util.LinkedHashMap<>();
|
||||
@@ -256,6 +284,7 @@ public class LoadingCompletedOutboxService {
|
||||
private String json(Object value) { try { return objectMapper.writeValueAsString(value); }
|
||||
catch (JsonProcessingException e) { throw new BizException(ResultCode.INTERNAL_ERROR, "装柜完成事件序列化失败"); } }
|
||||
private static String string(Long value) { return value == null ? null : String.valueOf(value); }
|
||||
private static String iso(java.time.LocalDateTime value) { return value == null ? null : value.toString(); }
|
||||
private static int sumInt(List<Row> rows, java.util.function.Function<Row,Integer> getter) { return rows.stream().map(getter).filter(java.util.Objects::nonNull).mapToInt(Integer::intValue).sum(); }
|
||||
private static Integer sumNullableInt(List<Row> rows, java.util.function.Function<Row,Integer> getter) { List<Integer> values=rows.stream().map(getter).filter(java.util.Objects::nonNull).toList(); return values.isEmpty()?null:values.stream().mapToInt(Integer::intValue).sum(); }
|
||||
private static BigDecimal sum(List<Row> rows, java.util.function.Function<Row,BigDecimal> getter) { List<BigDecimal> values=rows.stream().map(getter).filter(java.util.Objects::nonNull).toList(); return values.isEmpty()?null:values.stream().reduce(BigDecimal.ZERO, BigDecimal::add); }
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity;
|
||||
import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository;
|
||||
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Lease-based recovery worker for PDA completion commands. */
|
||||
@Service
|
||||
public class LoadingCompletionWorker {
|
||||
private static final Logger log = LoggerFactory.getLogger(LoadingCompletionWorker.class);
|
||||
private static final int MAX_ATTEMPTS = 5;
|
||||
private final String workerId = "pda-completion-" + UUID.randomUUID();
|
||||
|
||||
@Resource private LoadingCompletionCommandRepository commands;
|
||||
@Resource private LoadingPickRepository picks;
|
||||
@Resource private LoadingPickService loadingPicks;
|
||||
|
||||
@Scheduled(fixedDelayString = "${baiyue.firstleg.pda-completion.fixed-delay-ms:2000}")
|
||||
public void dispatch() {
|
||||
for (LoadingCompletionCommandEntity candidate : commands.due(10)) {
|
||||
if (!commands.claim(candidate.getId(), workerId, LocalDateTime.now().plusMinutes(10))) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
loadingPicks.processCompletion(candidate, workerId);
|
||||
} catch (RuntimeException exception) {
|
||||
handleFailure(candidate, exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleFailure(LoadingCompletionCommandEntity command, RuntimeException exception) {
|
||||
Throwable cause = exception instanceof LoadingPickService.CompletionProcessingException
|
||||
&& exception.getCause() != null ? exception.getCause() : exception;
|
||||
String stage = exception instanceof LoadingPickService.CompletionProcessingException staged
|
||||
? staged.stage() : "UNEXPECTED";
|
||||
String code = cause instanceof BizException business
|
||||
? business.getResultCode().code() : "S0500";
|
||||
String requestId = cause instanceof PdaPickSettlementService.SettlementFailure remote
|
||||
? remote.requestId() : null;
|
||||
String traceId = cause instanceof PdaPickSettlementService.SettlementFailure remote
|
||||
? remote.traceId() : null;
|
||||
int attempt = command.getAttemptCount() == null ? 1 : command.getAttemptCount() + 1;
|
||||
boolean terminal = attempt >= MAX_ATTEMPTS;
|
||||
LocalDateTime retryAt = LocalDateTime.now().plusSeconds(Math.min(300L, 15L << Math.min(attempt - 1, 4)));
|
||||
boolean recorded = commands.fail(command.getId(), workerId, terminal ? "FAILED" : "RETRYABLE", retryAt,
|
||||
stage, code, requestId, traceId);
|
||||
if (terminal && recorded) {
|
||||
picks.failCompletion(command.getPickTaskId());
|
||||
}
|
||||
log.warn("pda_completion_worker_failure commandId={} taskId={} operationId={} stage={} code={} "
|
||||
+ "requestId={} traceId={} attempt={} terminal={}", command.getId(), command.getPickTaskId(),
|
||||
command.getOperationId(), stage, code, requestId, traceId, attempt, terminal, cause);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.Operation;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.CompletionCommand;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.InventoryResource;
|
||||
@@ -26,6 +27,7 @@ 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.LoadingCompletionCommandEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPickReviewEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
|
||||
@@ -39,6 +41,7 @@ import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
|
||||
import com.baiyue.firstleg.mapper.LoadingPickItemMapper.CartonLabelRow;
|
||||
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
|
||||
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||
import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository;
|
||||
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
||||
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -66,6 +69,7 @@ import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.time.LocalDateTime;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
|
||||
/** 装柜中 PDA 拣货任务及明细同步。 */
|
||||
@Service
|
||||
@@ -76,6 +80,8 @@ public class LoadingPickService {
|
||||
@Resource
|
||||
private LoadingPickRepository repository;
|
||||
@Resource
|
||||
private LoadingCompletionCommandRepository completionCommands;
|
||||
@Resource
|
||||
private SmartLoadingRepository smartLoadingRepository;
|
||||
@Resource
|
||||
private OceanBookingRepository oceanBookingRepository;
|
||||
@@ -440,9 +446,31 @@ public class LoadingPickService {
|
||||
String operationId = operation(request.operationId());
|
||||
String remark = trim(request.remark());
|
||||
String requestHash = hash("COMPLETE|" + taskId + '|' + request.expectedTaskVersion() + '|' + remark);
|
||||
Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId);
|
||||
if (replay != null) return replay;
|
||||
LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId);
|
||||
LoadingCompletionCommandEntity replay = completionCommands.operation(operationId);
|
||||
if (replay != null) {
|
||||
requireSameCompletionCommand(replay, taskId, requestHash, operatorId);
|
||||
if ("FAILED".equals(replay.getCommandStatus())) {
|
||||
LoadingPickTaskEntity failed = requiredTaskForUpdate(taskId);
|
||||
if (!"COMPLETION_FAILED".equals(failed.getTaskStatus())
|
||||
|| !completionCommands.resume(replay.getId()) || repository.resumeCompletion(taskId) != 1) {
|
||||
throw conflict("装柜完成命令状态已变化,请刷新后重试");
|
||||
}
|
||||
}
|
||||
return view(requiredTask(taskId), null);
|
||||
}
|
||||
LoadingPickTaskEntity task = requiredTaskForUpdate(taskId);
|
||||
LoadingCompletionCommandEntity concurrentReplay = completionCommands.operation(operationId);
|
||||
if (concurrentReplay != null) {
|
||||
requireSameCompletionCommand(concurrentReplay, taskId, requestHash, operatorId);
|
||||
return view(task, null);
|
||||
}
|
||||
LoadingCompletionCommandEntity active = completionCommands.active(taskId);
|
||||
if (active != null) {
|
||||
throw conflict("已有其他装柜完成命令正在处理");
|
||||
}
|
||||
if (!"LOADING".equals(task.getTaskStatus())) {
|
||||
throw conflict("装柜任务正在完成或已经完成");
|
||||
}
|
||||
if (!request.expectedTaskVersion().equals(task.getVersion())) {
|
||||
throw conflict("PDA任务版本已变化,请刷新后复核");
|
||||
}
|
||||
@@ -454,40 +482,122 @@ public class LoadingPickService {
|
||||
.filter(owner -> owner.tenantId() != null && owner.inboundId() != null)
|
||||
.toList();
|
||||
if (discoveredOwners.isEmpty()) throw conflict("PDA任务未关联有效入库单");
|
||||
LoadingPickItemMapper.InboundOwner evidenceOwner = discoveredOwners.get(0);
|
||||
List<LoadingPickItemMapper.InboundOwner> owners = discoveredOwners.stream()
|
||||
LoadingCompletionCommandEntity command = new LoadingCompletionCommandEntity();
|
||||
command.setId(IdWorker.getId());
|
||||
command.setPickTaskId(taskId);
|
||||
command.setOperationId(operationId);
|
||||
command.setRequestHash(requestHash);
|
||||
command.setExpectedTaskVersion(request.expectedTaskVersion());
|
||||
command.setRemark(remark);
|
||||
command.setOperatorId(operatorId);
|
||||
command.setOperatorName(trim(operatorName));
|
||||
if (completionCommands.insert(command) != 1) {
|
||||
LoadingCompletionCommandEntity concurrent = completionCommands.operation(operationId);
|
||||
requireSameCompletionCommand(concurrent, taskId, requestHash, operatorId);
|
||||
return view(requiredTask(taskId), null);
|
||||
}
|
||||
if (repository.acceptCompletion(taskId, task.getVersion()) != 1) {
|
||||
throw conflict("PDA任务版本已变化,请刷新后复核");
|
||||
}
|
||||
return view(requiredTask(taskId), null);
|
||||
}
|
||||
|
||||
/** Called only by the completion worker, after its durable lease has committed. */
|
||||
public void processCompletion(LoadingCompletionCommandEntity command, String worker) {
|
||||
long taskId = command.getPickTaskId();
|
||||
LoadingPickTaskEntity task = requiredTask(taskId);
|
||||
List<LoadingPickItemMapper.InboundOwner> owners = repository.inboundOwners(taskId).stream()
|
||||
.filter(owner -> owner.tenantId() != null && owner.inboundId() != null)
|
||||
.sorted(Comparator.comparing(LoadingPickItemMapper.InboundOwner::tenantId)
|
||||
.thenComparing(LoadingPickItemMapper.InboundOwner::inboundId))
|
||||
.toList();
|
||||
for (LoadingPickItemMapper.InboundOwner owner : owners) {
|
||||
LoadingInboundMapper.Snapshot snapshot = repository.inboundForUpdate(owner.tenantId(), owner.inboundId());
|
||||
if (snapshot == null) throw conflict("PDA任务关联入库单不存在");
|
||||
completionStage(taskId, "完成前业务门禁", () ->
|
||||
loadingDeliveryService.requirePdaCompletionReady(owner.tenantId(), owner.inboundId(),
|
||||
evidenceOwner.inboundId()));
|
||||
if (owners.isEmpty()) throw new CompletionProcessingException("OWNER_DISCOVERY", conflict("PDA任务未关联有效入库单"));
|
||||
long evidenceInboundId = owners.get(0).inboundId();
|
||||
for (int index = 0; index < owners.size(); index++) {
|
||||
requireCompletionLease(command.getId(), worker);
|
||||
LoadingPickItemMapper.InboundOwner owner = owners.get(index);
|
||||
String checkpoint = completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId());
|
||||
if (!checkpointAtLeast(checkpoint, "READY")) {
|
||||
runCompletionStage("OWNER_READY", () -> loadingDeliveryService.requirePdaCompletionReady(
|
||||
owner.tenantId(), owner.inboundId(), evidenceInboundId));
|
||||
requireCompletionLease(command.getId(), worker);
|
||||
completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1, "READY");
|
||||
}
|
||||
if (!checkpointAtLeast(checkpoint, "SETTLED")) {
|
||||
runCompletionStage("WAREHOUSE_SETTLEMENT", () -> settlementService.settleOwner(
|
||||
taskId, owner.inboundId(), command.getOperatorId(), command.getOperatorName()));
|
||||
requireCompletionLease(command.getId(), worker);
|
||||
completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1, "SETTLED");
|
||||
}
|
||||
if (!checkpointAtLeast(checkpoint, "INBOUND_COMPLETED")) {
|
||||
runCompletionStage("INBOUND_COMPLETE", () -> loadingDeliveryService.completeAfterPdaSettlement(
|
||||
owner.tenantId(), owner.inboundId(), evidenceInboundId, command.getOperatorId(),
|
||||
command.getOperatorName(), "PDA-" + command.getOperationId() + '-' + owner.inboundId(),
|
||||
command.getRemark()));
|
||||
requireCompletionLease(command.getId(), worker);
|
||||
completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1,
|
||||
"INBOUND_COMPLETED");
|
||||
}
|
||||
}
|
||||
completionStage(taskId, "拆单与库位结算", () ->
|
||||
settlementService.settle(taskId, operatorId, operatorName));
|
||||
if (!repository.allPlanItemsPicked(task.getLoadingPlanId())) {
|
||||
throw conflict("柜次仍有未完成PDA拣货的箱数");
|
||||
throw new CompletionProcessingException("PLAN_GATE", conflict("柜次仍有未完成PDA拣货的箱数"));
|
||||
}
|
||||
if (repository.approve(taskId, operatorId, operatorName, remark) != 1) {
|
||||
throw conflict("PDA任务已经复核");
|
||||
if (repository.review(taskId) == null && repository.approve(taskId, command.getOperatorId(),
|
||||
command.getOperatorName(), command.getRemark()) != 1) {
|
||||
throw new CompletionProcessingException("TASK_REVIEW", conflict("PDA任务已经复核"));
|
||||
}
|
||||
insertOperation(taskId, operationId, requestHash, "COMPLETE_LOADING", operatorId, operatorName);
|
||||
completionStage(taskId, "装柜计划完成", () ->
|
||||
loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName));
|
||||
repository.syncInboundProjections(taskId, operatorId, operatorName);
|
||||
for (LoadingPickItemMapper.InboundOwner owner : owners) {
|
||||
completionStage(taskId, "入库单状态完成", () ->
|
||||
loadingDeliveryService.completeAfterPdaSettlement(owner.tenantId(), owner.inboundId(),
|
||||
evidenceOwner.inboundId(), operatorId, operatorName,
|
||||
"PDA-" + operationId + '-' + owner.inboundId(), remark));
|
||||
if (repository.operation(command.getOperationId()) == null) {
|
||||
insertOperation(taskId, command.getOperationId(), command.getRequestHash(), "COMPLETE_LOADING",
|
||||
command.getOperatorId(), command.getOperatorName());
|
||||
}
|
||||
completionStage(taskId, "完成事件发布", () ->
|
||||
loadingListService.publishCompletedEvent(task.getLoadingPlanId()));
|
||||
completeTruckAfterCommit(task.getContainerBookingId(), taskId, operatorId, operatorName);
|
||||
return view(requiredTask(taskId), null);
|
||||
runCompletionStage("PLAN_COMPLETE", () -> loadingListService.finishFromPda(task.getLoadingPlanId(), taskId,
|
||||
command.getOperatorId(), command.getOperatorName()));
|
||||
repository.syncInboundProjections(taskId, command.getOperatorId(), command.getOperatorName());
|
||||
runCompletionStage("COMPLETED_EVENT", () -> loadingListService.publishCompletedEvent(task.getLoadingPlanId()));
|
||||
requireCompletionLease(command.getId(), worker);
|
||||
LoadingPickTaskEntity current = requiredTask(taskId);
|
||||
if (!"COMPLETED".equals(current.getTaskStatus())
|
||||
&& repository.completeAccepted(taskId, command.getOperatorId(), command.getOperatorName()) != 1) {
|
||||
throw new CompletionProcessingException("TASK_FINALIZE", conflict("PDA任务完成状态已变化"));
|
||||
}
|
||||
if (!completionCommands.complete(command.getId(), worker)) {
|
||||
throw new CompletionProcessingException("COMMAND_FINALIZE", conflict("装柜完成命令租约已变化"));
|
||||
}
|
||||
completeTruckAfterCommit(task.getContainerBookingId(), taskId, command.getOperatorId(), command.getOperatorName());
|
||||
}
|
||||
|
||||
private void requireCompletionLease(long commandId, String worker) {
|
||||
if (!completionCommands.renew(commandId, worker, LocalDateTime.now().plusMinutes(10))) {
|
||||
throw new CompletionProcessingException("LEASE_LOST", conflict("装柜完成命令租约已变化"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireSameCompletionCommand(LoadingCompletionCommandEntity command, long taskId,
|
||||
String requestHash, long operatorId) {
|
||||
if (command == null || !Long.valueOf(taskId).equals(command.getPickTaskId())
|
||||
|| !Long.valueOf(operatorId).equals(command.getOperatorId())
|
||||
|| !requestHash.equals(command.getRequestHash())) {
|
||||
throw conflict("operationId已用于不同装柜完成命令");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean checkpointAtLeast(String checkpoint, String target) {
|
||||
List<String> stages = List.of("READY", "SETTLED", "INBOUND_COMPLETED");
|
||||
return checkpoint != null && stages.indexOf(checkpoint) >= stages.indexOf(target);
|
||||
}
|
||||
|
||||
private static void runCompletionStage(String stage, Runnable action) {
|
||||
try {
|
||||
action.run();
|
||||
} catch (RuntimeException exception) {
|
||||
throw new CompletionProcessingException(stage, exception);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class CompletionProcessingException extends RuntimeException {
|
||||
private final String stage;
|
||||
CompletionProcessingException(String stage, RuntimeException cause) { super(cause); this.stage = stage; }
|
||||
public String stage() { return stage; }
|
||||
}
|
||||
|
||||
private void completionStage(long taskId, String stage, Runnable action) {
|
||||
@@ -692,6 +802,13 @@ public class LoadingPickService {
|
||||
}
|
||||
|
||||
private Task view(LoadingPickTaskEntity task, Long warehouseId) {
|
||||
var booking = repository.activeBookingByTask(task.getId());
|
||||
if (booking == null || !Objects.equals(task.getContainerBookingId(), booking.getId())) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在或柜次已非有效存续记录");
|
||||
}
|
||||
if (warehouseId != null && !Objects.equals(warehouseId, booking.getWarehouseId())) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在或不属于当前仓库");
|
||||
}
|
||||
List<LoadingPickItemEntity> source = warehouseId == null
|
||||
? repository.items(task.getId()) : repository.items(task.getId(), warehouseId);
|
||||
Map<Long, List<CartonLabelRow>> cartonsByItem = repository.cartonLabels(task.getId()).stream()
|
||||
@@ -714,24 +831,28 @@ public class LoadingPickService {
|
||||
Review review = reviewed == null ? null : new Review(reviewed.getReviewStatus(),
|
||||
reviewed.getReviewedBy(), reviewed.getReviewedByName(), reviewed.getRemark(), reviewed.getVersion());
|
||||
String workflowStatus = "COMPLETED".equals(task.getTaskStatus()) ? "COMPLETED"
|
||||
: "COMPLETING".equals(task.getTaskStatus()) ? "PROCESSING"
|
||||
: "COMPLETION_FAILED".equals(task.getTaskStatus()) ? "COMPLETION_FAILED"
|
||||
: assigned == null ? "UNASSIGNED"
|
||||
: repository.allTaskItemsResolved(task.getId()) ? "AWAITING_REVIEW" : "IN_PROGRESS";
|
||||
var booking = repository.activeBookingByTask(task.getId());
|
||||
if (booking == null || !Objects.equals(task.getContainerBookingId(), booking.getId())) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在或柜次已非有效存续记录");
|
||||
}
|
||||
var owners = repository.inboundOwners(task.getId());
|
||||
Long detailInboundId = source.isEmpty() ? null : source.get(0).getInboundId();
|
||||
var owner = detailInboundId == null ? null : owners.stream()
|
||||
.filter(value -> detailInboundId.equals(value.inboundId())).findFirst().orElse(null);
|
||||
var inbound = owner == null || owner.tenantId() == null || owner.inboundId() == null
|
||||
? null : repository.inbound(owner.tenantId(), owner.inboundId());
|
||||
LoadingCompletionCommandEntity command = "LOADING".equals(task.getTaskStatus())
|
||||
? null : completionCommands.latest(task.getId());
|
||||
CompletionCommand completionCommand = command == null ? null : new CompletionCommand(command.getId(),
|
||||
command.getOperationId(), command.getCommandStatus(), command.getExpectedTaskVersion(), command.getRemark(),
|
||||
command.getAttemptCount(), command.getNextRetryAt(), command.getFailureStage(), command.getFailureCode(),
|
||||
command.getFailureRequestId(), command.getFailureTraceId(), command.getCompletedAt());
|
||||
return new Task(task.getId(), task.getLoadingPlanId(), task.getContainerBookingId(),
|
||||
owner == null ? null : owner.inboundId(), inbound == null ? null : inbound.version(),
|
||||
inbound == null ? null : inbound.version(), task.getCabinetNo(),
|
||||
booking.getContainerNo(), booking.getSourceType(), booking.getRecordStatus(),
|
||||
booking.getMergedIntoContainerBookingId(), task.getTaskStatus(),
|
||||
workflowStatus, task.getVersion(), assignment, review, items);
|
||||
workflowStatus, task.getVersion(), assignment, review, completionCommand, items);
|
||||
}
|
||||
|
||||
private List<BatchLine> validateBatchLines(LoadingPickTaskEntity task, BatchRequest request) {
|
||||
|
||||
@@ -18,6 +18,8 @@ import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
@@ -32,6 +34,7 @@ import java.util.stream.Collectors;
|
||||
/** PDA 任务结算:按柜次生成实装/余量履约分片,并结算测量与库位资源。 */
|
||||
@Service
|
||||
public class PdaPickSettlementService {
|
||||
private static final Logger log = LoggerFactory.getLogger(PdaPickSettlementService.class);
|
||||
private static final long PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID = 0L;
|
||||
private static final String SOURCE_SERVICE = "firstleg-service";
|
||||
|
||||
@@ -82,6 +85,35 @@ public class PdaPickSettlementService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Worker boundary: one inbound owner per transaction, so a cabinet never holds 15 owner locks at once. */
|
||||
@Transactional
|
||||
public void settleOwner(long taskId, long inboundId, long operatorId, String operatorName) {
|
||||
if (!picks.allTaskItemsResolved(taskId)) {
|
||||
throw conflict("PDA任务仍有未处理明细");
|
||||
}
|
||||
List<LoadingPickItemEntity> taskItems = picks.items(taskId);
|
||||
Map<Long, Integer> pickedByInbound = pickedByInbound(taskItems);
|
||||
Integer pickedPackageCount = pickedByInbound.get(inboundId);
|
||||
if (pickedPackageCount == null) {
|
||||
throw conflict("PDA任务不包含指定入库单");
|
||||
}
|
||||
PdaPickSettlementPlan.Resolution resolution = PdaPickSettlementPlan.resolve(taskItems);
|
||||
for (Long planItemId : resolution.skippedPlanItemIds()) {
|
||||
int changed = splits.removeSkippedPlanItem(planItemId, operatorId, operatorName);
|
||||
if (changed != 0 && changed != 1) {
|
||||
throw conflict("跳单明细已被并发修改,请刷新后重试");
|
||||
}
|
||||
}
|
||||
boolean newlySplit = settleInbound(inboundId, pickedPackageCount, taskItems, taskId, operatorId, operatorName);
|
||||
if (newlySplit) {
|
||||
for (PdaPickSettlementPlan.SplitLine line : resolution.partialByInbound()
|
||||
.getOrDefault(inboundId, List.of())) {
|
||||
requireAffected(splits.convergePartialPlanItem(line.loadingPlanItemId(), operatorId, operatorName),
|
||||
"部分拣货装柜明细");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean settleInbound(long parentInboundId, int pickedPackageCount,
|
||||
List<LoadingPickItemEntity> taskItems, long taskId,
|
||||
long operatorId, String operatorName) {
|
||||
@@ -379,7 +411,7 @@ public class PdaPickSettlementService {
|
||||
|
||||
private static List<Long> releasedReservationIds(List<InboundPalletAllocationEntity> allocations) {
|
||||
return allocations.stream().filter(item -> remaining(item) == 0).map(PdaPickSettlementService::reservationId)
|
||||
.sorted().toList();
|
||||
.distinct().sorted().toList();
|
||||
}
|
||||
|
||||
private static List<WarehousePalletLocationClient.SettlementTransferItem> remainingTransferItems(
|
||||
@@ -422,18 +454,51 @@ public class PdaPickSettlementService {
|
||||
operatorId, operatorName, "PDA-SETTLE-" + taskId + '-' + parentInboundId,
|
||||
releasedReservationIds, remainingItems));
|
||||
} catch (FeignException exception) {
|
||||
log.warn("PDA warehouse settlement transport failure taskId={} sourceInboundId={} operationId={} "
|
||||
+ "httpStatus={} releasedReservationIds={} remainingReservations={}",
|
||||
taskId, parentInboundId, "PDA-SETTLE-" + taskId + '-' + parentInboundId,
|
||||
exception.status(), releasedReservationIds, settlementTransferFacts(remainingItems));
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓储服务不可用,无法结算PDA库位资源");
|
||||
}
|
||||
if (response == null || !response.successful() || response.data() == null
|
||||
|| response.data().released() == null || response.data().transferred() == null
|
||||
|| !sameReleasedReservations(releasedReservationIds, response.data().released())
|
||||
|| !sameTransferredResources(remainingItems, response.data().transferred())) {
|
||||
log.warn("PDA warehouse settlement rejected taskId={} sourceInboundId={} operationId={} "
|
||||
+ "responseCode={} requestId={} traceId={} expectedAction=RELEASE_OR_TRANSFER "
|
||||
+ "releasedReservationIds={} remainingReservations={}",
|
||||
taskId, parentInboundId, "PDA-SETTLE-" + taskId + '-' + parentInboundId,
|
||||
response == null ? null : response.code(), response == null ? null : response.requestId(),
|
||||
response == null ? null : response.traceId(), releasedReservationIds,
|
||||
settlementTransferFacts(remainingItems));
|
||||
ResultCode code = response == null ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.of(response.code());
|
||||
throw new BizException(code, response == null || response.message() == null
|
||||
? "仓储服务原子结算PDA库位资源失败" : response.message());
|
||||
throw new SettlementFailure(code, response == null || response.message() == null
|
||||
? "仓储服务原子结算PDA库位资源失败" : response.message(),
|
||||
response == null ? null : response.requestId(), response == null ? null : response.traceId());
|
||||
}
|
||||
}
|
||||
|
||||
/** Carries only correlation facts needed by the durable command diagnosis record. */
|
||||
public static final class SettlementFailure extends BizException {
|
||||
private final String requestId;
|
||||
private final String traceId;
|
||||
|
||||
SettlementFailure(ResultCode code, String message, String requestId, String traceId) {
|
||||
super(code, message);
|
||||
this.requestId = requestId;
|
||||
this.traceId = traceId;
|
||||
}
|
||||
|
||||
public String requestId() { return requestId; }
|
||||
public String traceId() { return traceId; }
|
||||
}
|
||||
|
||||
private static List<String> settlementTransferFacts(
|
||||
List<WarehousePalletLocationClient.SettlementTransferItem> remainingItems) {
|
||||
return remainingItems.stream().map(item -> item.reservationId() + ":" + item.remainingPackageCount())
|
||||
.sorted().toList();
|
||||
}
|
||||
|
||||
private long existingSettlementTarget(long sourceInboundId, String idempotencyKey) {
|
||||
try {
|
||||
ApiResponse<Long> response = warehouse.settlementTarget(internalToken, sourceInboundId, idempotencyKey);
|
||||
|
||||
@@ -564,7 +564,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_task (
|
||||
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '稳定柜次ID',
|
||||
cabinet_no VARCHAR(64) NOT NULL COMMENT '稳定内部柜次号快照',
|
||||
container_no VARCHAR(64) NULL COMMENT '废弃兼容字段;不得作为任务身份或随运输号变更重写',
|
||||
task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING' COMMENT 'PDA任务状态:LOADING拣货装柜中、COMPLETED已完成',
|
||||
task_status VARCHAR(24) NOT NULL DEFAULT 'LOADING' COMMENT 'PDA任务状态:LOADING、COMPLETING、COMPLETION_FAILED、COMPLETED',
|
||||
started_by BIGINT UNSIGNED NOT NULL COMMENT '开始装柜人',
|
||||
started_by_name VARCHAR(64) NULL COMMENT '开始装柜人名称',
|
||||
started_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '开始时间',
|
||||
@@ -577,9 +577,52 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_task (
|
||||
UNIQUE KEY uk_loading_pick_task_booking (container_booking_id),
|
||||
KEY idx_loading_pick_container (container_no, task_status, started_at),
|
||||
CONSTRAINT fk_loading_pick_task_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
|
||||
CONSTRAINT chk_loading_pick_task_status CHECK (task_status IN ('LOADING', 'COMPLETED'))
|
||||
CONSTRAINT chk_loading_pick_task_status CHECK (task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED','COMPLETED'))
|
||||
) ENGINE=InnoDB COMMENT='PDA装柜拣货任务';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_loading_completion_command (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '可靠装柜完成命令ID',
|
||||
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA任务ID',
|
||||
operation_id VARCHAR(128) NOT NULL COMMENT '客户端全局幂等操作ID',
|
||||
request_hash CHAR(64) NOT NULL COMMENT '规范化请求摘要',
|
||||
command_status VARCHAR(16) NOT NULL COMMENT 'PROCESSING、RETRYABLE、FAILED、COMPLETED',
|
||||
expected_task_version INT UNSIGNED NOT NULL COMMENT '受理时客户端任务版本',
|
||||
remark VARCHAR(500) NULL COMMENT '完成备注',
|
||||
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
|
||||
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
|
||||
attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'worker认领次数',
|
||||
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次处理时间',
|
||||
lease_owner VARCHAR(128) NULL COMMENT 'worker租约持有者',
|
||||
lease_until DATETIME(3) NULL COMMENT 'worker租约截止时间',
|
||||
failure_stage VARCHAR(40) NULL COMMENT '脱敏失败阶段',
|
||||
failure_code VARCHAR(32) NULL COMMENT '下游或业务错误码',
|
||||
failure_request_id VARCHAR(64) NULL COMMENT '下游请求ID',
|
||||
failure_trace_id VARCHAR(64) NULL COMMENT '下游链路ID',
|
||||
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),
|
||||
completed_at DATETIME(3) NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_loading_completion_operation (operation_id),
|
||||
KEY idx_loading_completion_dispatch (command_status,next_retry_at,lease_until),
|
||||
CONSTRAINT fk_loading_completion_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task(id),
|
||||
CONSTRAINT chk_loading_completion_status CHECK (command_status IN ('PROCESSING','RETRYABLE','FAILED','COMPLETED'))
|
||||
) ENGINE=InnoDB COMMENT='PDA装柜完成可靠异步命令';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_loading_completion_checkpoint (
|
||||
command_id BIGINT UNSIGNED NOT NULL,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
inbound_id BIGINT UNSIGNED NOT NULL,
|
||||
owner_sequence INT UNSIGNED NOT NULL,
|
||||
checkpoint_stage VARCHAR(24) NOT NULL COMMENT 'READY、SETTLED、INBOUND_COMPLETED',
|
||||
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 (command_id,tenant_id,inbound_id),
|
||||
KEY idx_loading_completion_owner (command_id,owner_sequence),
|
||||
CONSTRAINT fk_loading_completion_checkpoint_command FOREIGN KEY (command_id)
|
||||
REFERENCES fl_loading_completion_command(id),
|
||||
CONSTRAINT chk_loading_completion_checkpoint CHECK (checkpoint_stage IN ('READY','SETTLED','INBOUND_COMPLETED'))
|
||||
) ENGINE=InnoDB COMMENT='PDA装柜完成按入库单恢复检查点';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_loading_pick_item (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID',
|
||||
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID',
|
||||
@@ -819,7 +862,6 @@ BEGIN
|
||||
JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id
|
||||
WHERE pick_item.loading_plan_item_id = item.id
|
||||
AND pick_task.loading_plan_id = OLD.id
|
||||
AND pick_task.task_status = 'LOADING'
|
||||
AND (
|
||||
(pick_item.item_status = 'PICKED'
|
||||
AND pick_item.picked_package_count = item.package_count)
|
||||
@@ -1018,8 +1060,10 @@ BEGIN
|
||||
OR NOT (NEW.cabinet_no <=> OLD.cabinet_no) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务稳定归属不可修改';
|
||||
END IF;
|
||||
IF NOT (OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING', 'COMPLETED')) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务状态只允许完成';
|
||||
IF NOT ((OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING','COMPLETING'))
|
||||
OR (OLD.task_status = 'COMPLETING' AND NEW.task_status IN ('COMPLETING','COMPLETION_FAILED','COMPLETED'))
|
||||
OR (OLD.task_status = 'COMPLETION_FAILED' AND NEW.task_status = 'COMPLETING')) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务状态流转非法';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
@@ -427,10 +427,11 @@ components:
|
||||
itemVersion: {type: integer, format: int32, minimum: 0, description: 明细乐观锁版本;更新仓库备注时作为 expectedItemVersion}
|
||||
OperationHistory:
|
||||
type: object
|
||||
required: [historyId, actionType, operatorId, occurredAt]
|
||||
required: [historyId, operationId, actionType, operatorId, occurredAt]
|
||||
properties:
|
||||
historyId: {$ref: '#/components/schemas/LongId'}
|
||||
itemId: {$ref: '#/components/schemas/NullableLongId'}
|
||||
operationId: {type: string, minLength: 1, maxLength: 128, description: 稳定业务动作或幂等事件标识;客户端据此识别重放,不按展示文案去重}
|
||||
actionType: {type: string, description: 操作类型}
|
||||
fromStatus: {type: string, nullable: true, description: 操作前状态}
|
||||
toStatus: {type: string, nullable: true, description: 操作后状态}
|
||||
|
||||
@@ -106,13 +106,13 @@ paths:
|
||||
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete:
|
||||
post:
|
||||
summary: PDA拣货全部处理后人工确认装柜完成
|
||||
description: PDA拣货只锁定数量和仓储资源。本接口先无写入校验全部关联入库单的RELEASED订舱、稳定bridge和五类ACTIVE证据;拖车状态不是前置门禁。若当前稳定containerBookingId没有有效拖车则直接继续;恰有一条有效拖车时由服务端加锁推进为COMPLETED并写历史;多条有效拖车或并发修改返回A0409且全部Firstleg本地状态回滚。随后在同一事务内结算资源、完成task/plan并推进inbound为LOADED。同operationId重放返回冻结结果,不重复推进拖车或写历史。
|
||||
description: 短事务校验任务版本、全部明细和operationId请求摘要,持久化可靠完成命令并将任务置为COMPLETING后立即返回PROCESSING事实。后台worker按稳定入库单顺序以checkpoint恢复Order、Warehouse、入库状态、计划和事件;请求事务内不调用任何远程服务。同operationId重放立即返回相同PROCESSING或最终结果;不同operationId在处理期间返回A0409。
|
||||
parameters: [{$ref: '#/components/parameters/TaskId'}]
|
||||
requestBody:
|
||||
required: true
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/CompleteLoadingRequest'}}}
|
||||
responses:
|
||||
'200': {description: 完成任务;存在未处理明细或版本冲突时返回A0409}
|
||||
'200': {description: 已可靠受理;taskStatus=COMPLETING且workflowStatus=PROCESSING,或幂等返回最终COMPLETED结果;冲突返回A0409}
|
||||
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/operations:
|
||||
get:
|
||||
summary: Read immutable task-level claim, assignment and review audit records
|
||||
@@ -139,8 +139,9 @@ components:
|
||||
sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: Source of the surviving stable container identity}
|
||||
recordStatus: {type: string, enum: [ACTIVE], description: Task detail is served only from the ACTIVE container booking currently owned by the loading plan}
|
||||
mergedIntoContainerBookingId: {type: integer, format: int64, nullable: true, description: Always null for the surviving ACTIVE operational container; populated only on hidden MERGED source history}
|
||||
taskStatus: {type: string, enum: [LOADING, COMPLETED]}
|
||||
workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW, COMPLETED]}
|
||||
taskStatus: {type: string, enum: [LOADING, COMPLETING, COMPLETION_FAILED, COMPLETED]}
|
||||
workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW, PROCESSING, COMPLETION_FAILED, COMPLETED]}
|
||||
completionCommand: {$ref: '#/components/schemas/CompletionCommand', nullable: true}
|
||||
version: {type: integer, minimum: 0}
|
||||
assignment: {type: object, nullable: true}
|
||||
review: {type: object, nullable: true}
|
||||
@@ -181,8 +182,8 @@ components:
|
||||
mergedIntoContainerBookingId: {type: integer, format: int64, nullable: true, description: Always null for an ACTIVE pending task}
|
||||
warehouseId: {type: integer, format: int64}
|
||||
warehouseName: {type: string, nullable: true}
|
||||
taskStatus: {type: string, enum: [LOADING]}
|
||||
workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW], description: Server projection aligned with task detail; assigned LOADING tasks with pendingItemCount=0 remain visible as AWAITING_REVIEW}
|
||||
taskStatus: {type: string, enum: [LOADING, COMPLETING, COMPLETION_FAILED]}
|
||||
workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW, PROCESSING, COMPLETION_FAILED], description: Server projection aligned with task detail; completion acceptance is returned immediately as PROCESSING}
|
||||
version: {type: integer, minimum: 0}
|
||||
assignmentVersion: {type: integer, minimum: 0}
|
||||
itemCount: {type: integer, minimum: 1}
|
||||
@@ -236,6 +237,22 @@ components:
|
||||
expectedTaskVersion: {type: integer, minimum: 0}
|
||||
operationId: {type: string, maxLength: 128}
|
||||
remark: {type: string, maxLength: 500}
|
||||
CompletionCommand:
|
||||
type: object
|
||||
required: [id, operationId, status, expectedTaskVersion, attemptCount]
|
||||
properties:
|
||||
id: {type: integer, format: int64}
|
||||
operationId: {type: string, maxLength: 128, description: Retry must reuse this exact identity}
|
||||
status: {type: string, enum: [PROCESSING, RETRYABLE, FAILED, COMPLETED]}
|
||||
expectedTaskVersion: {type: integer, minimum: 0, description: Retry must preserve this request-hashed version}
|
||||
remark: {type: string, maxLength: 500, nullable: true, description: Retry must preserve this request-hashed remark}
|
||||
attemptCount: {type: integer, minimum: 0}
|
||||
nextRetryAt: {type: string, format: date-time, nullable: true}
|
||||
failureStage: {type: string, nullable: true}
|
||||
failureCode: {type: string, nullable: true}
|
||||
requestId: {type: string, nullable: true}
|
||||
traceId: {type: string, nullable: true}
|
||||
completedAt: {type: string, format: date-time, nullable: true}
|
||||
CompleteLoadingRequest:
|
||||
type: object
|
||||
required: [expectedTaskVersion, operationId]
|
||||
|
||||
+1
-1
@@ -180,7 +180,7 @@ class LoadingListControllerContractTest {
|
||||
"itemId", "accountId", "inboundId", "inboundDetailId", "skuCode",
|
||||
"destinationWarehouse", "productQuantity", "frozenAt");
|
||||
assertThat(properties(schemas, "OperationHistory")).containsKeys(
|
||||
"historyId", "itemId", "actionType", "fromStatus", "toStatus",
|
||||
"historyId", "itemId", "operationId", "actionType", "fromStatus", "toStatus",
|
||||
"beforeSnapshot", "afterSnapshot", "operatorId", "occurredAt");
|
||||
assertThat(properties(schemas, "TransportUpdateRequest")).containsOnlyKeys(
|
||||
"containerNo", "voyageNo", "expectedVersion", "operationReason");
|
||||
|
||||
+2
-1
@@ -39,7 +39,8 @@ class PdaConvergenceOpenApiContractTest {
|
||||
.contains("sourceType", "recordStatus", "mergedIntoContainerBookingId")
|
||||
.contains("Task detail is served only from the ACTIVE container booking")
|
||||
.contains("Enforced by the server-side pending-task query")
|
||||
.contains("assigned LOADING tasks with pendingItemCount=0 remain visible as AWAITING_REVIEW")
|
||||
.contains("completion acceptance is returned immediately as PROCESSING")
|
||||
.contains("taskStatus=COMPLETING且workflowStatus=PROCESSING")
|
||||
.contains("CompleteLoadingRequest");
|
||||
|
||||
String taskLookup = yaml.substring(
|
||||
|
||||
+93
-1
@@ -25,6 +25,7 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
@@ -195,6 +196,65 @@ class LoadingDeliveryServiceTest {
|
||||
verify(repository).findInboundForUpdate(10, 20);
|
||||
verify(repository).completeLoadedChild(10, 20, 7, 7);
|
||||
verify(repository, never()).completeLoadedChild(10, 20, 7, 6);
|
||||
verify(loadingModelBridge).requireStarted(10, 20);
|
||||
verify(loadingModelBridge, never()).requireCompleted(10, 20);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pdaFinalizeResumesSameProcessingClaimLeftByAnEarlierWorkerFailure() {
|
||||
readyForPdaFinalizeFacts();
|
||||
CompletionTarget settled = new CompletionTarget(20L, null, 20L,
|
||||
"LOADING", 7, "LOADING", 7);
|
||||
when(repository.completionTarget(10, 20)).thenReturn(settled);
|
||||
when(repository.findInboundForUpdate(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7));
|
||||
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7));
|
||||
when(repository.completeLoadedChild(10, 20, 7, 7)).thenReturn(1);
|
||||
AtomicReference<String> hash = new AtomicReference<>();
|
||||
when(repository.claimOperation(eq(10L), eq(20L), eq(7L), eq("COMPLETE_LOADING"),
|
||||
eq("pda-resume"), anyString())).thenAnswer(invocation -> {
|
||||
hash.set(invocation.getArgument(5));
|
||||
return false;
|
||||
});
|
||||
when(repository.findOperation(10L, 20L, 7L, "COMPLETE_LOADING", "pda-resume"))
|
||||
.thenAnswer(invocation -> processingOperation(hash.get()));
|
||||
|
||||
LoadingActionResponse result = service.completeAfterPdaSettlement(
|
||||
10, 20, 7, "admin", "pda-resume", "完成");
|
||||
|
||||
assertThat(result.mainStatus()).isEqualTo("LOADED");
|
||||
verify(repository).completeLoadedChild(10, 20, 7, 7);
|
||||
verify(repository).completeOperation(eq(10L), eq(20L), eq(7L),
|
||||
eq("COMPLETE_LOADING"), eq("pda-resume"), anyString());
|
||||
verify(loadingModelBridge).requireStarted(10, 20);
|
||||
verify(loadingModelBridge, never()).requireCompleted(10, 20);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pdaFinalizeConvergesAnAlreadyLoadedInboundBeforeThePlanIsCompleted() {
|
||||
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()))
|
||||
.thenReturn(true);
|
||||
when(repository.findInboundForUpdate(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADED", 8));
|
||||
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADED", 8));
|
||||
when(repository.listEvidence(10, 20)).thenReturn(List.of());
|
||||
|
||||
LoadingActionResponse result = service.completeAfterPdaSettlement(
|
||||
10, 20, 7, "admin", "pda-already-loaded", "完成");
|
||||
|
||||
assertThat(result.mainStatus()).isEqualTo("LOADED");
|
||||
verify(loadingModelBridge).requireStarted(10, 20);
|
||||
verify(loadingModelBridge, never()).requireCompleted(10, 20);
|
||||
verify(repository).completeOperation(eq(10L), eq(20L), eq(7L),
|
||||
eq("COMPLETE_LOADING"), eq("pda-already-loaded"), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pdaFinalizeEntryPointsAreTransactional() throws Exception {
|
||||
assertThat(LoadingDeliveryService.class.getMethod("completeAfterPdaSettlement",
|
||||
long.class, long.class, long.class, String.class, String.class, String.class)
|
||||
.getAnnotation(Transactional.class)).isNotNull();
|
||||
assertThat(LoadingDeliveryService.class.getMethod("completeAfterPdaSettlement",
|
||||
long.class, long.class, long.class, long.class, String.class, String.class, String.class)
|
||||
.getAnnotation(Transactional.class)).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -213,6 +273,8 @@ class LoadingDeliveryServiceTest {
|
||||
assertThat(result.mainStatus()).isEqualTo("LOADED");
|
||||
verify(repository).findInboundForUpdate(10, 21);
|
||||
verify(repository).completeLoadedChild(10, 20, 7, 2);
|
||||
verify(loadingModelBridge).requireStarted(10, 20);
|
||||
verify(loadingModelBridge, never()).requireCompleted(10, 20);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -350,6 +412,24 @@ class LoadingDeliveryServiceTest {
|
||||
"PHOTO_STEP_1".equals(value.getEvidenceType()) && "ACTIVE".equals(value.getStatus())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void replaysTheSameActiveEvidenceFileWithoutAppendingAnotherRecord() {
|
||||
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
|
||||
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
|
||||
LoadingEvidenceEntity existing = evidence("SEAL", 11L, "LOCK-001");
|
||||
existing.setId(99L); existing.setInboundId(20L); existing.setTenantId(10L); existing.setStatus("ACTIVE");
|
||||
when(repository.findEvidenceBySealNo(10, "LOCK-001")).thenReturn(existing);
|
||||
when(repository.findActiveEvidenceByFile(10, 20, "SEAL", 11L)).thenReturn(existing);
|
||||
when(repository.listEvidence(10, 20)).thenReturn(List.of(existing));
|
||||
|
||||
LoadingActionResponse result = service.upsertEvidence(10, 20, 7, "admin", "same-seal-photo",
|
||||
new UpsertEvidenceRequest(0, "SEAL", 11L, null, "LOCK-001", null));
|
||||
|
||||
assertThat(result.evidences().evidences()).hasSize(1);
|
||||
verify(repository, never()).createEvidence(any());
|
||||
verify(files, never()).validate(anyString(), anyLong(), anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void voidsPhotoWithReasonAndKeepsTheRecord() {
|
||||
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
|
||||
@@ -418,11 +498,16 @@ class LoadingDeliveryServiceTest {
|
||||
private void readyForPdaFinalize() {
|
||||
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()))
|
||||
.thenReturn(true);
|
||||
readyForPdaFinalizeFacts();
|
||||
}
|
||||
|
||||
private void readyForPdaFinalizeFacts() {
|
||||
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);
|
||||
// completion worker runs before task finalization; accepted COMPLETING is not COMPLETED yet.
|
||||
when(progress.getCompletedTaskCount()).thenReturn(0);
|
||||
when(progress.getUnresolvedCount()).thenReturn(0);
|
||||
when(loadingPicks.inboundProgress(10, 20)).thenReturn(progress);
|
||||
when(repository.listEvidence(10, 20)).thenReturn(List.of(
|
||||
@@ -444,4 +529,11 @@ class LoadingDeliveryServiceTest {
|
||||
value.setResultJson(resultJson);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static LoadingOperationEntity processingOperation(String requestHash) {
|
||||
LoadingOperationEntity value = new LoadingOperationEntity();
|
||||
value.setRequestHash(requestHash);
|
||||
value.setStatus("PROCESSING");
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class LoadingCompletionSqlContractTest {
|
||||
@Test
|
||||
void baselineAndUnifiedSqlDefineDurableCompletionStateMachine() throws Exception {
|
||||
assertContract(Files.readString(Path.of("src", "main", "resources", "db",
|
||||
"phase3-smart-loading-schema.sql"), StandardCharsets.UTF_8));
|
||||
assertContract(Files.readString(Path.of("..", "deploy", "sql",
|
||||
"头程数据库统一增量-MySQL8.sql"), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static void assertContract(String sql) {
|
||||
assertThat(sql)
|
||||
.contains("fl_loading_completion_command")
|
||||
.contains("fl_loading_completion_checkpoint")
|
||||
.contains("uk_loading_completion_operation")
|
||||
.contains("idx_loading_completion_dispatch")
|
||||
.contains("PROCESSING","RETRYABLE","FAILED","COMPLETED")
|
||||
.contains("COMPLETING","COMPLETION_FAILED")
|
||||
.contains("READY","SETTLED","INBOUND_COMPLETED")
|
||||
.contains("lease_owner","lease_until","next_retry_at")
|
||||
.contains("failure_stage","failure_code","failure_request_id","failure_trace_id");
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -63,7 +63,7 @@ class LoadingPickItemMapperContractTest {
|
||||
.contains("plan_item.loading_plan_id = #{planId}")
|
||||
.contains("plan_item.deleted = 0")
|
||||
.contains("pick_task.loading_plan_id = #{planId}")
|
||||
.contains("pick_task.task_status = 'LOADING'")
|
||||
.doesNotContain("pick_task.task_status")
|
||||
.contains("pick_item.item_status = 'PICKED'")
|
||||
.contains("pick_item.picked_package_count = plan_item.package_count")
|
||||
.contains("pick_item.item_status = 'PARTIAL'")
|
||||
|
||||
+3
-1
@@ -27,12 +27,14 @@ class LoadingPickPdaTaskSqlContractTest {
|
||||
.getAnnotation(Select.class).value()[0];
|
||||
|
||||
assertThat(sql)
|
||||
.contains("task.task_status = 'LOADING'")
|
||||
.contains("task.task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED')")
|
||||
.contains("booking.record_status = 'ACTIVE'")
|
||||
.contains("booking.source_type AS sourceType")
|
||||
.contains("booking.record_status AS recordStatus")
|
||||
.contains("booking.merged_into_container_booking_id AS mergedIntoContainerBookingId")
|
||||
.contains("WHEN task.task_status = 'COMPLETED' THEN 'COMPLETED'")
|
||||
.contains("WHEN task.task_status = 'COMPLETING' THEN 'PROCESSING'")
|
||||
.contains("WHEN task.task_status = 'COMPLETION_FAILED' THEN 'COMPLETION_FAILED'")
|
||||
.contains("WHEN assignment.pick_task_id IS NULL THEN 'UNASSIGNED'")
|
||||
.contains("pick_item.item_status IN ('PENDING', 'PICKING')")
|
||||
.contains("THEN 'AWAITING_REVIEW'")
|
||||
|
||||
+8
-3
@@ -229,10 +229,15 @@ class SmartLoadingSqlContractTest {
|
||||
}
|
||||
|
||||
private static void assertLoadingCompletionTrigger(String sql) {
|
||||
assertThat(sql)
|
||||
int triggerStart = sql.indexOf("CREATE TRIGGER trg_loading_plan_before_update");
|
||||
int triggerEnd = sql.indexOf("DROP TRIGGER IF EXISTS trg_loading_plan_after_update", triggerStart);
|
||||
assertThat(triggerStart).isGreaterThanOrEqualTo(0);
|
||||
assertThat(triggerEnd).isGreaterThan(triggerStart);
|
||||
String beforeUpdateTrigger = sql.substring(triggerStart, triggerEnd);
|
||||
assertThat(beforeUpdateTrigger)
|
||||
.contains("AND NOT EXISTS (")
|
||||
.contains("pick_task.loading_plan_id = OLD.id")
|
||||
.contains("pick_task.task_status = 'LOADING'")
|
||||
.doesNotContain("pick_task.task_status")
|
||||
.contains("pick_item.item_status = 'PICKED'")
|
||||
.contains("pick_item.item_status = 'PARTIAL'")
|
||||
.contains("pick_item.picked_package_count > 0")
|
||||
@@ -308,7 +313,7 @@ class SmartLoadingSqlContractTest {
|
||||
.contains("container_booking_id BIGINT UNSIGNED NOT NULL")
|
||||
.contains("cabinet_no VARCHAR(64) NOT NULL")
|
||||
.contains("container_no VARCHAR(64) NULL")
|
||||
.contains("task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING'")
|
||||
.contains("task_status VARCHAR(24) NOT NULL DEFAULT 'LOADING'")
|
||||
.contains("UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id)")
|
||||
.contains("UNIQUE KEY uk_loading_pick_task_booking (container_booking_id)")
|
||||
.contains("KEY idx_loading_pick_container (container_no, task_status, started_at)")
|
||||
|
||||
+78
-3
@@ -45,6 +45,10 @@ class LoadingCompletedOutboxServiceTest {
|
||||
|
||||
@Test void groupsByAccountAndKeepsAllOrdersAndItemsStable() throws Exception {
|
||||
when(snapshots.rows(90L)).thenReturn(List.of(row(22,300,302),row(11,200,202),row(11,100,102),row(11,100,101)));
|
||||
when(snapshots.palletRows(90L)).thenReturn(List.of(
|
||||
pallet(11,100,1,"PLT-01",1,101,"SKU101",1,10,"BOX-101"),
|
||||
pallet(11,100,1,"PLT-01",1,102,"SKU102",1,10,"BOX-102"),
|
||||
pallet(11,100,2,"PLT-02",1,102,"SKU102",1,10,"BOX-102")));
|
||||
service.publish(90L);
|
||||
ArgumentCaptor<InboundOutboxEntity> captor=ArgumentCaptor.forClass(InboundOutboxEntity.class);
|
||||
verify(outbox,org.mockito.Mockito.times(2)).insert(captor.capture());
|
||||
@@ -59,6 +63,15 @@ class LoadingCompletedOutboxServiceTest {
|
||||
assertThat(first.payload().at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU101");
|
||||
assertThat(first.payload().at("/orders/0/items/0/cartonMarks/0").asText()).isEqualTo("BOX-101");
|
||||
assertThat(first.payload().at("/orders/0/destinationType").asText()).isEqualTo("OVERSEAS");
|
||||
assertThat(first.payload().at("/orders/0/customerName").asText()).isEqualTo("客户-11");
|
||||
assertThat(first.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2);
|
||||
assertThat(first.payload().at("/orders/0/packageCount").asInt()).isEqualTo(2);
|
||||
assertThat(first.payload().at("/orders/0/productQuantity").asInt()).isEqualTo(20);
|
||||
assertThat(first.payload().at("/orders/0/volumeCbm").decimalValue()).isEqualByComparingTo("2");
|
||||
assertThat(first.payload().at("/orders/0/volumeWeightKg").decimalValue()).isEqualByComparingTo("4");
|
||||
assertThat(first.payload().at("/estimatedPortArrivalAt").asText()).isEqualTo("2026-09-30T12:00");
|
||||
assertThat(first.payload().at("/actualPortArrivalAt").asText()).isEqualTo("2026-10-01T08:30");
|
||||
assertThat(first.payload().at("/estimatedWarehouseArrivalAt").asText()).isEqualTo("2026-10-03T09:00");
|
||||
assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2");
|
||||
assertThat(first.eventVersion()).isEqualTo(2);
|
||||
}
|
||||
@@ -72,6 +85,22 @@ class LoadingCompletedOutboxServiceTest {
|
||||
.hasMessageContaining("缺少 SKU");
|
||||
}
|
||||
|
||||
@Test void rejectsMissingTrustedBookingCustomerName() {
|
||||
Row value = row(11, 100, 101);
|
||||
value.setCustomerName(" ");
|
||||
when(snapshots.rows(90L)).thenReturn(List.of(value));
|
||||
|
||||
assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("缺少客户名称快照");
|
||||
}
|
||||
|
||||
@Test void rejectsBookingCustomerIdThatDoesNotMatchAccountEvent() {
|
||||
Row value = row(11, 100, 101);
|
||||
value.setBookingCustomerId(22L);
|
||||
when(snapshots.rows(90L)).thenReturn(List.of(value));
|
||||
|
||||
assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("客户与柜次客户快照不一致");
|
||||
}
|
||||
|
||||
@Test void fbaFreezesPackageCountWithoutRequiringCartonLabels() throws Exception {
|
||||
Row value = row(11, 100, 101);
|
||||
value.setDestinationType("FBA");
|
||||
@@ -118,6 +147,41 @@ class LoadingCompletedOutboxServiceTest {
|
||||
.isEqualTo(second.payload().at("/orders/0/items/0/cartonMarks"));
|
||||
}
|
||||
|
||||
@Test void immutableCustomerNameParticipatesInPayloadHash() {
|
||||
Row value = row(11, 100, 101);
|
||||
when(snapshots.rows(90L)).thenReturn(List.of(value));
|
||||
service.publish(90L);
|
||||
value.setCustomerName("客户甲-更名快照");
|
||||
service.publish(90L);
|
||||
|
||||
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
|
||||
verify(outbox, org.mockito.Mockito.times(2)).insert(captor.capture());
|
||||
assertThat(captor.getAllValues().get(0).getPayloadHash())
|
||||
.isNotEqualTo(captor.getAllValues().get(1).getPayloadHash());
|
||||
}
|
||||
|
||||
@Test void keepsMissingPalletAndMeasurementFactsNullInsteadOfInventingZero() throws Exception {
|
||||
Row value = row(11, 100, 101);
|
||||
value.setProductQuantity(null); value.setVolumeCbm(null); value.setDimensionalWeightKg(null);
|
||||
value.setEstimatedPortArrivalAt(null); value.setActualPortArrivalAt(null);
|
||||
value.setEstimatedWarehouseArrivalAt(null);
|
||||
when(snapshots.rows(90L)).thenReturn(List.of(value));
|
||||
when(snapshots.palletRows(90L)).thenReturn(List.of());
|
||||
|
||||
service.publish(90L);
|
||||
|
||||
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
|
||||
verify(outbox).insert(captor.capture());
|
||||
EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class);
|
||||
assertThat(event.payload().at("/orders/0/palletCount").isNull()).isTrue();
|
||||
assertThat(event.payload().at("/orders/0/productQuantity").isNull()).isTrue();
|
||||
assertThat(event.payload().at("/orders/0/volumeCbm").isNull()).isTrue();
|
||||
assertThat(event.payload().at("/orders/0/volumeWeightKg").isNull()).isTrue();
|
||||
assertThat(event.payload().at("/estimatedPortArrivalAt").isNull()).isTrue();
|
||||
assertThat(event.payload().at("/actualPortArrivalAt").isNull()).isTrue();
|
||||
assertThat(event.payload().at("/estimatedWarehouseArrivalAt").isNull()).isTrue();
|
||||
}
|
||||
|
||||
@Test void fillsCollapsedMeasurementDetailFromUnusedInboundLabelsWithoutDuplicates() throws Exception {
|
||||
Row first = row(11, 100, 101); first.setPackageCount(1);
|
||||
Row second = row(11, 100, 101); second.setLoadingPlanItemId(102L); second.setPackageCount(2);
|
||||
@@ -144,7 +208,11 @@ class LoadingCompletedOutboxServiceTest {
|
||||
.getAnnotation(Select.class).value()).replaceAll("\\s+", " ");
|
||||
|
||||
assertThat(rowsSql).contains("p.plan_status = 'COMPLETED'")
|
||||
.contains("o.process_status = 'LOADED'");
|
||||
.contains("o.process_status = 'LOADED'")
|
||||
.contains("b.customer_id AS booking_customer_id")
|
||||
.contains("b.customer_name")
|
||||
.contains("b.estimated_port_arrival_at", "b.actual_port_arrival_at")
|
||||
.contains("b.estimated_warehouse_arrival_at");
|
||||
for (String sql : List.of(rowsSql, palletsSql)) {
|
||||
assertThat(sql)
|
||||
.contains("actual_pick.loading_plan_item_id = i.id")
|
||||
@@ -158,6 +226,7 @@ class LoadingCompletedOutboxServiceTest {
|
||||
.doesNotContain("'PICKING'");
|
||||
}
|
||||
assertThat(palletsSql)
|
||||
.contains("a.pallet_id", "a.reservation_id")
|
||||
.contains("label.tenant_id = i.account_id")
|
||||
.contains("label.inbound_detail_id = i.inbound_detail_id")
|
||||
.contains("label.batch_status = 'ACTIVE'")
|
||||
@@ -219,20 +288,26 @@ class LoadingCompletedOutboxServiceTest {
|
||||
private Row row(long account,long inbound,long detail) {
|
||||
Row r=new Row(); r.setLoadingPlanId(90L); r.setPlanNo("P90"); r.setContainerBookingId(91L);
|
||||
r.setAggregateVersion(8); r.setContainerNo("CONT"); r.setCabinetNo("CAB"); r.setWarehouseId(92L);
|
||||
r.setWarehouseName("WH"); r.setAccountId(account); r.setSourceInboundId(inbound); r.setRootInboundId(99L);
|
||||
r.setWarehouseName("WH"); r.setBookingCustomerId(account); r.setCustomerName("客户-" + account);
|
||||
r.setEstimatedPortArrivalAt(java.time.LocalDateTime.of(2026, 9, 30, 12, 0));
|
||||
r.setActualPortArrivalAt(java.time.LocalDateTime.of(2026, 10, 1, 8, 30));
|
||||
r.setEstimatedWarehouseArrivalAt(java.time.LocalDateTime.of(2026, 10, 3, 9, 0));
|
||||
r.setAccountId(account); r.setSourceInboundId(inbound); r.setRootInboundId(99L);
|
||||
r.setSourceOrderId(inbound+1000); r.setSourceOrderNo("FL"+inbound); r.setRootOrderId(1099L);
|
||||
r.setInboundCode("RK"+inbound); r.setLoadingPlanItemId(detail); r.setDestinationWarehouseId(93L); r.setDestinationWarehouseCode("ONT8");
|
||||
r.setDestinationWarehouseName("ONT8"); r.setSourceInboundDetailId(detail); r.setSkuCode("SKU"+detail);
|
||||
r.setDestinationType("OVERSEAS");
|
||||
r.setFulfillmentType("STANDARD");
|
||||
r.setPackageCount(1); r.setProductQuantity(10); r.setPalletCount(1); r.setGrossWeightKg(BigDecimal.ONE);
|
||||
r.setNetWeightKg(BigDecimal.ONE); r.setVolumeCbm(BigDecimal.ONE); return r;
|
||||
r.setNetWeightKg(BigDecimal.ONE); r.setDimensionalWeightKg(new BigDecimal("2"));
|
||||
r.setVolumeCbm(BigDecimal.ONE); return r;
|
||||
}
|
||||
|
||||
|
||||
private PalletRow pallet(long account,long inbound,int allocation,String palletNo,int allocated,
|
||||
long detail,String sku,int packages,int quantity,String boxCode) {
|
||||
PalletRow row = new PalletRow(); row.setAccountId(account); row.setSourceInboundId(inbound);
|
||||
row.setPalletId(inbound * 100 + allocation); row.setReservationId(inbound * 1000 + allocation);
|
||||
row.setAllocationNo(allocation); row.setPalletCode(palletNo); row.setAllocatedPackageCount(allocated);
|
||||
row.setSourceInboundDetailId(detail); row.setSkuCode(sku); row.setProductNameZh("产品");
|
||||
row.setPackageCount(packages); row.setProductQuantity(quantity); row.setBoxCode(boxCode); return row;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity;
|
||||
import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository;
|
||||
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(LoadingCompletionWorker.class)
|
||||
class LoadingCompletionWorkerTest {
|
||||
@MockBean private LoadingCompletionCommandRepository commands;
|
||||
@MockBean private LoadingPickRepository picks;
|
||||
@MockBean private LoadingPickService loadingPicks;
|
||||
@jakarta.annotation.Resource private LoadingCompletionWorker worker;
|
||||
|
||||
@Test
|
||||
void transientFailureIsReleasedForBackoffRetry() {
|
||||
LoadingCompletionCommandEntity command = command(1);
|
||||
when(commands.due(10)).thenReturn(List.of(command));
|
||||
when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true);
|
||||
doThrow(new LoadingPickService.CompletionProcessingException("WAREHOUSE_SETTLEMENT",
|
||||
new BizException(ResultCode.SERVICE_UNAVAILABLE, "warehouse timeout")))
|
||||
.when(loadingPicks).processCompletion(eq(command), any(String.class));
|
||||
|
||||
when(commands.fail(eq(900L), any(String.class), eq("RETRYABLE"), any(LocalDateTime.class),
|
||||
eq("WAREHOUSE_SETTLEMENT"), eq(ResultCode.SERVICE_UNAVAILABLE.code()), eq(null), eq(null)))
|
||||
.thenReturn(true);
|
||||
|
||||
worker.dispatch();
|
||||
|
||||
verify(commands).fail(eq(900L), any(String.class), eq("RETRYABLE"), any(LocalDateTime.class),
|
||||
eq("WAREHOUSE_SETTLEMENT"), eq(ResultCode.SERVICE_UNAVAILABLE.code()), eq(null), eq(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fifthFailureBecomesTerminalAndMarksTaskFailed() {
|
||||
LoadingCompletionCommandEntity command = command(4);
|
||||
when(commands.due(10)).thenReturn(List.of(command));
|
||||
when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true);
|
||||
doThrow(new LoadingPickService.CompletionProcessingException("PLAN_COMPLETE",
|
||||
new BizException(ResultCode.CONFLICT, "plan conflict")))
|
||||
.when(loadingPicks).processCompletion(eq(command), any(String.class));
|
||||
|
||||
when(commands.fail(eq(900L), any(String.class), eq("FAILED"), any(LocalDateTime.class),
|
||||
eq("PLAN_COMPLETE"), eq(ResultCode.CONFLICT.code()), eq(null), eq(null))).thenReturn(true);
|
||||
|
||||
worker.dispatch();
|
||||
|
||||
verify(commands).fail(eq(900L), any(String.class), eq("FAILED"), any(LocalDateTime.class),
|
||||
eq("PLAN_COMPLETE"), eq(ResultCode.CONFLICT.code()), eq(null), eq(null));
|
||||
verify(picks).failCompletion(20L);
|
||||
}
|
||||
|
||||
private static LoadingCompletionCommandEntity command(int attemptCount) {
|
||||
LoadingCompletionCommandEntity command = new LoadingCompletionCommandEntity();
|
||||
command.setId(900L);
|
||||
command.setPickTaskId(20L);
|
||||
command.setOperationId("complete-1");
|
||||
command.setAttemptCount(attemptCount);
|
||||
return command;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
|
||||
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
|
||||
@@ -26,6 +27,7 @@ import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
|
||||
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
|
||||
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
|
||||
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||
import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository;
|
||||
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
||||
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
|
||||
@@ -34,6 +36,7 @@ import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
@@ -44,6 +47,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
@@ -53,6 +57,7 @@ import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.clearInvocations;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@SpringJUnitConfig(LoadingPickService.class)
|
||||
@@ -60,6 +65,8 @@ class LoadingPickServiceTest {
|
||||
@MockBean
|
||||
private LoadingPickRepository repository;
|
||||
@MockBean
|
||||
private LoadingCompletionCommandRepository completionCommands;
|
||||
@MockBean
|
||||
private SmartLoadingRepository smartLoadingRepository;
|
||||
@MockBean
|
||||
private OceanBookingRepository oceanBookingRepository;
|
||||
@@ -85,9 +92,13 @@ class LoadingPickServiceTest {
|
||||
ContainerBookingEntity booking = new ContainerBookingEntity();
|
||||
booking.setId(99L);
|
||||
booking.setContainerNo("CSLU1234567");
|
||||
booking.setWarehouseId(101L);
|
||||
booking.setSourceType("SIMULATION");
|
||||
booking.setRecordStatus("ACTIVE");
|
||||
when(repository.activeBookingByTask(20L)).thenReturn(booking);
|
||||
when(completionCommands.insert(any())).thenReturn(1);
|
||||
when(completionCommands.renew(anyLong(), anyString(), any(java.time.LocalDateTime.class))).thenReturn(true);
|
||||
when(repository.acceptCompletion(anyLong(), anyInt())).thenReturn(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -232,6 +243,7 @@ class LoadingPickServiceTest {
|
||||
task.setContainerBookingId(40L); task.setCabinetNo("CAB-001");
|
||||
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
|
||||
booking.setId(40L); booking.setContainerNo("MSCU-NEW");
|
||||
booking.setWarehouseId(101L);
|
||||
booking.setSourceType("SIMULATION"); booking.setRecordStatus("ACTIVE");
|
||||
when(repository.taskByCabinet("CAB-001", 101L)).thenReturn(task);
|
||||
when(repository.taskByPlan(10L, 101L)).thenReturn(task);
|
||||
@@ -414,6 +426,37 @@ class LoadingPickServiceTest {
|
||||
assertThat(result.sourceType()).isEqualTo("SIMULATION");
|
||||
assertThat(result.recordStatus()).isEqualTo("ACTIVE");
|
||||
assertThat(result.mergedIntoContainerBookingId()).isNull();
|
||||
verify(completionCommands, never()).latest(20L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadingTaskDetailDoesNotDependOnCompletionCommandStorage() {
|
||||
LoadingPickTaskEntity task = task();
|
||||
when(repository.task(20L)).thenReturn(task);
|
||||
when(repository.items(20L, 101L)).thenReturn(List.of(item()));
|
||||
when(repository.cartonLabels(20L)).thenReturn(List.of());
|
||||
when(repository.inboundOwners(20L)).thenReturn(List.of());
|
||||
doThrow(new IllegalStateException("completion command schema unavailable"))
|
||||
.when(completionCommands).latest(20L);
|
||||
|
||||
var result = service.task(20L, 101L);
|
||||
|
||||
assertThat(result.taskStatus()).isEqualTo("LOADING");
|
||||
assertThat(result.completionCommand()).isNull();
|
||||
verify(completionCommands, never()).latest(20L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void taskDetailRejectsWarehouseThatDoesNotOwnTheContainer() {
|
||||
LoadingPickTaskEntity task = task();
|
||||
when(repository.task(20L)).thenReturn(task);
|
||||
|
||||
assertThatThrownBy(() -> service.task(20L, 999L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不属于当前仓库");
|
||||
|
||||
verify(repository, never()).items(20L, 999L);
|
||||
verify(repository, never()).cartonLabels(20L);
|
||||
verify(completionCommands, never()).latest(20L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -488,6 +531,7 @@ class LoadingPickServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
|
||||
void manualCompletionSettlesResolvedItemsCompletesPlanAndReplaysSameOperation() {
|
||||
LoadingPickTaskEntity task = task();
|
||||
LoadingPickItemEntity item = item();
|
||||
@@ -538,6 +582,7 @@ class LoadingPickServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
|
||||
void conflictingTruckAppointmentsDoNotBlockPdaCompletion() {
|
||||
LoadingPickTaskEntity task = task();
|
||||
LoadingPickItemEntity item = item();
|
||||
@@ -568,6 +613,7 @@ class LoadingPickServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
|
||||
void doesNotPublishCompletedEventWhenInboundFinalizationFails() {
|
||||
LoadingPickTaskEntity task = task();
|
||||
when(repository.taskForUpdate(20L)).thenReturn(task);
|
||||
@@ -592,6 +638,7 @@ class LoadingPickServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
|
||||
void translatesUnexpectedSettlementFailureIntoActionableStageMessage() {
|
||||
LoadingPickTaskEntity task = task();
|
||||
when(repository.taskForUpdate(20L)).thenReturn(task);
|
||||
@@ -613,6 +660,199 @@ class LoadingPickServiceTest {
|
||||
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
|
||||
void warehouseSettlementConflictLeavesCompletionClaimAndDownstreamFinalizationUnwritten() {
|
||||
LoadingPickTaskEntity task = task();
|
||||
task.setVersion(1);
|
||||
when(repository.taskForUpdate(20L)).thenReturn(task);
|
||||
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
|
||||
var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L);
|
||||
when(repository.inboundOwners(20L)).thenReturn(List.of(owner));
|
||||
when(repository.inboundForUpdate(101L, 50L)).thenReturn(
|
||||
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7));
|
||||
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
|
||||
"释放库位资源时发生并发变更"))
|
||||
.when(settlementService).settle(20L, 9L, "operator");
|
||||
|
||||
assertThatThrownBy(() -> service.complete(20L,
|
||||
new CompleteLoadingRequest(1, "complete-real-a0409", "PDA确认装柜完成"), 9L, "operator"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("释放库位资源时发生并发变更");
|
||||
|
||||
verify(repository, never()).approve(anyLong(), anyLong(), anyString(), anyString());
|
||||
verify(repository, never()).insert(any(LoadingPickOperationEntity.class));
|
||||
verify(repository, never()).syncInboundProjections(anyLong(), anyLong(), anyString());
|
||||
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
|
||||
verify(loadingListService, never()).publishCompletedEvent(anyLong());
|
||||
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(),
|
||||
anyLong(), anyString(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
|
||||
void currentCompleteRequestFinishesAfterUniqueResourceSettlementSucceeds() {
|
||||
LoadingPickTaskEntity task = task();
|
||||
task.setVersion(1);
|
||||
LoadingPickItemEntity item = item();
|
||||
when(repository.taskForUpdate(20L)).thenReturn(task);
|
||||
when(repository.task(20L)).thenReturn(task);
|
||||
when(repository.items(20L)).thenReturn(List.of(item));
|
||||
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
|
||||
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
|
||||
when(repository.approve(20L, 9L, "operator", "PDA确认装柜完成")).thenReturn(1);
|
||||
var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L);
|
||||
when(repository.inboundOwners(20L)).thenReturn(List.of(owner));
|
||||
when(repository.inboundForUpdate(101L, 50L)).thenReturn(
|
||||
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7));
|
||||
|
||||
Task result = service.complete(20L, new CompleteLoadingRequest(1,
|
||||
"complete-1000001-2092817774453780481-2092787765815078913--45b8eb0887ff317c",
|
||||
"PDA确认装柜完成"), 9L, "operator");
|
||||
|
||||
assertThat(result.id()).isEqualTo(20L);
|
||||
verify(settlementService).settle(20L, 9L, "operator");
|
||||
verify(repository).insert(org.mockito.ArgumentMatchers.argThat(
|
||||
(LoadingPickOperationEntity operation) -> "COMPLETE_LOADING".equals(operation.getActionType())));
|
||||
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
|
||||
verify(loadingListService).publishCompletedEvent(10L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameOperationIdReplayReturnsProcessingWithoutAnotherTaskForUpdate() {
|
||||
LoadingPickTaskEntity loading = task();
|
||||
LoadingPickTaskEntity completing = task();
|
||||
completing.setTaskStatus("COMPLETING");
|
||||
completing.setVersion(1);
|
||||
AtomicReference<com.baiyue.firstleg.entity.LoadingCompletionCommandEntity> accepted = new AtomicReference<>();
|
||||
when(completionCommands.operation("async-complete-1")).thenAnswer(invocation -> accepted.get());
|
||||
doAnswer(invocation -> {
|
||||
accepted.set(invocation.getArgument(0));
|
||||
return 1;
|
||||
}).when(completionCommands).insert(any());
|
||||
when(repository.taskForUpdate(20L)).thenReturn(loading);
|
||||
when(repository.task(20L)).thenReturn(completing);
|
||||
when(repository.items(20L)).thenReturn(List.of(item()));
|
||||
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
|
||||
when(repository.inboundOwners(20L)).thenReturn(List.of(new LoadingPickItemMapper.InboundOwner(101L, 50L)));
|
||||
|
||||
Task first = service.complete(20L,
|
||||
new CompleteLoadingRequest(0, "async-complete-1", "checked"), 9L, "operator");
|
||||
verify(repository).taskForUpdate(20L);
|
||||
clearInvocations(repository);
|
||||
Task replay = org.junit.jupiter.api.Assertions.assertTimeout(java.time.Duration.ofMillis(200), () ->
|
||||
service.complete(20L,
|
||||
new CompleteLoadingRequest(0, "async-complete-1", "checked"), 9L, "operator"));
|
||||
|
||||
assertThat(first.taskStatus()).isEqualTo("COMPLETING");
|
||||
assertThat(first.workflowStatus()).isEqualTo("PROCESSING");
|
||||
assertThat(replay.taskStatus()).isEqualTo("COMPLETING");
|
||||
verify(repository, never()).taskForUpdate(20L);
|
||||
verify(completionCommands, times(1)).insert(any());
|
||||
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
|
||||
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentOperationIdIsRejectedImmediatelyWithConflict() {
|
||||
// different operationId is a canonical A0409 CONFLICT while the durable command is active.
|
||||
LoadingPickTaskEntity loading = task();
|
||||
when(repository.taskForUpdate(20L)).thenReturn(loading);
|
||||
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
|
||||
when(repository.inboundOwners(20L)).thenReturn(List.of(new LoadingPickItemMapper.InboundOwner(101L, 50L)));
|
||||
var active = new com.baiyue.firstleg.entity.LoadingCompletionCommandEntity();
|
||||
active.setOperationId("already-processing");
|
||||
when(completionCommands.active(20L)).thenReturn(active);
|
||||
|
||||
assertThatThrownBy(() -> service.complete(20L,
|
||||
new CompleteLoadingRequest(0, "different-operation", "checked"), 9L, "operator"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("其他装柜完成命令");
|
||||
|
||||
verify(completionCommands, never()).insert(any());
|
||||
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedSameOperationIdCanResumeWithoutCreatingAnotherCommand() {
|
||||
LoadingPickTaskEntity failed = task();
|
||||
failed.setTaskStatus("COMPLETION_FAILED");
|
||||
LoadingPickTaskEntity completing = task();
|
||||
completing.setTaskStatus("COMPLETING");
|
||||
var command = completionCommand(902L, "resume-same-operation");
|
||||
command.setRequestHash("48ea6be16079a761f4e77abcf58efaafebc2ab629250ce06118315232e9f3070");
|
||||
command.setCommandStatus("FAILED");
|
||||
command.setExpectedTaskVersion(0);
|
||||
when(completionCommands.operation("resume-same-operation")).thenReturn(command);
|
||||
when(completionCommands.resume(902L)).thenReturn(true);
|
||||
when(repository.taskForUpdate(20L)).thenReturn(failed);
|
||||
when(repository.resumeCompletion(20L)).thenReturn(1);
|
||||
when(repository.task(20L)).thenReturn(completing);
|
||||
when(repository.items(20L)).thenReturn(List.of(item()));
|
||||
when(repository.inboundOwners(20L)).thenReturn(List.of(new LoadingPickItemMapper.InboundOwner(101L, 50L)));
|
||||
|
||||
Task result = service.complete(20L,
|
||||
new CompleteLoadingRequest(0, "resume-same-operation", "checked"), 9L, "operator");
|
||||
|
||||
assertThat(result.workflowStatus()).isEqualTo("PROCESSING");
|
||||
verify(completionCommands).resume(902L);
|
||||
verify(repository).resumeCompletion(20L);
|
||||
verify(completionCommands, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void workerProcessesFifteenInboundOwnersInStableOrderAndFinalizesOnce() {
|
||||
LoadingPickTaskEntity completing = task();
|
||||
completing.setTaskStatus("COMPLETING");
|
||||
when(repository.task(20L)).thenReturn(completing);
|
||||
List<LoadingPickItemMapper.InboundOwner> owners = java.util.stream.IntStream.range(0, 15)
|
||||
.mapToObj(index -> new LoadingPickItemMapper.InboundOwner(101L, 1014L - index)).toList();
|
||||
when(repository.inboundOwners(20L)).thenReturn(owners);
|
||||
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
|
||||
when(repository.review(20L)).thenReturn(null);
|
||||
when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1);
|
||||
when(repository.completeAccepted(20L, 9L, "operator")).thenReturn(1);
|
||||
when(completionCommands.complete(900L, "worker-1")).thenReturn(true);
|
||||
var command = completionCommand(900L, "async-15");
|
||||
|
||||
service.processCompletion(command, "worker-1");
|
||||
|
||||
ArgumentCaptor<Long> inboundIds = ArgumentCaptor.forClass(Long.class);
|
||||
verify(settlementService, times(15)).settleOwner(eq(20L), inboundIds.capture(), eq(9L), eq("operator"));
|
||||
assertThat(inboundIds.getAllValues()).containsExactly(
|
||||
1000L, 1001L, 1002L, 1003L, 1004L, 1005L, 1006L, 1007L,
|
||||
1008L, 1009L, 1010L, 1011L, 1012L, 1013L, 1014L);
|
||||
verify(loadingListService).publishCompletedEvent(10L);
|
||||
verify(repository).completeAccepted(20L, 9L, "operator");
|
||||
verify(completionCommands).complete(900L, "worker-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void workerRestartResumesFromOwnerCheckpointsWithoutRepeatingSettlements() {
|
||||
LoadingPickTaskEntity completing = task();
|
||||
completing.setTaskStatus("COMPLETING");
|
||||
when(repository.task(20L)).thenReturn(completing);
|
||||
when(repository.inboundOwners(20L)).thenReturn(List.of(
|
||||
new LoadingPickItemMapper.InboundOwner(101L, 50L),
|
||||
new LoadingPickItemMapper.InboundOwner(101L, 60L)));
|
||||
when(completionCommands.checkpoint(901L, 101L, 50L)).thenReturn("INBOUND_COMPLETED");
|
||||
when(completionCommands.checkpoint(901L, 101L, 60L)).thenReturn("SETTLED");
|
||||
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
|
||||
when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1);
|
||||
when(repository.completeAccepted(20L, 9L, "operator")).thenReturn(1);
|
||||
when(completionCommands.complete(901L, "worker-2")).thenReturn(true);
|
||||
|
||||
service.processCompletion(completionCommand(901L, "async-resume"), "worker-2");
|
||||
|
||||
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
|
||||
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(eq(101L), eq(50L), anyLong(),
|
||||
anyLong(), anyString(), anyString(), anyString());
|
||||
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 60L, 50L, 9L, "operator",
|
||||
"PDA-async-resume-60", "checked");
|
||||
verify(loadingListService).publishCompletedEvent(10L);
|
||||
verify(repository).insert(org.mockito.ArgumentMatchers.<LoadingPickOperationEntity>argThat(
|
||||
operation -> "COMPLETE_LOADING".equals(operation.getActionType())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void atomicallySubmitsCompleteBatchAndReplaysSameResult() {
|
||||
LoadingPickTaskEntity task = task();
|
||||
@@ -814,6 +1054,21 @@ class LoadingPickServiceTest {
|
||||
when(repository.items(20L, 101L)).thenReturn(List.of(item));
|
||||
}
|
||||
|
||||
private static com.baiyue.firstleg.entity.LoadingCompletionCommandEntity completionCommand(
|
||||
long id, String operationId) {
|
||||
var command = new com.baiyue.firstleg.entity.LoadingCompletionCommandEntity();
|
||||
command.setId(id);
|
||||
command.setPickTaskId(20L);
|
||||
command.setOperationId(operationId);
|
||||
command.setRequestHash("hash");
|
||||
command.setCommandStatus("PROCESSING");
|
||||
command.setOperatorId(9L);
|
||||
command.setOperatorName("operator");
|
||||
command.setRemark("checked");
|
||||
command.setAttemptCount(1);
|
||||
return command;
|
||||
}
|
||||
|
||||
private static LoadingPickTaskEntity task() {
|
||||
LoadingPickTaskEntity task = new LoadingPickTaskEntity();
|
||||
task.setId(20L);
|
||||
|
||||
+6
-3
@@ -179,10 +179,13 @@ class PdaPickSettlementServiceTest {
|
||||
when(splits.lockParent(31L)).thenReturn(parent(20));
|
||||
when(splits.lockLatestApprovedItemsForInbound(10001L, 31L))
|
||||
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
|
||||
when(allocations.lockConfirmed(10001L, 31L))
|
||||
.thenReturn(List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10)));
|
||||
InboundPalletAllocationEntity first = allocation(1L, 1, 501L, 10);
|
||||
InboundPalletAllocationEntity second = allocation(2L, 2, 501L, 10);
|
||||
first.setPalletId(801L);
|
||||
second.setPalletId(801L);
|
||||
when(allocations.lockConfirmed(10001L, 31L)).thenReturn(List.of(first, second));
|
||||
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
||||
new WarehousePalletLocationClient.SettlementResult(List.of(released(91L, 501L), released(92L, 502L)),
|
||||
new WarehousePalletLocationClient.SettlementResult(List.of(released(91L, 501L), released(92L, 501L)),
|
||||
List.of())));
|
||||
writes(0, 20);
|
||||
|
||||
|
||||
+32
-8
@@ -11,6 +11,7 @@ import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
@SpringBootTest(classes = TaillegRealProducerAcceptanceTest.AcceptanceApplication.class,
|
||||
webEnvironment = SpringBootTest.WebEnvironment.NONE, properties = {
|
||||
@@ -50,12 +51,29 @@ class TaillegRealProducerAcceptanceTest {
|
||||
assertEquals(2, jdbc.queryForObject("SELECT JSON_LENGTH(JSON_EXTRACT(payload_json,'$.payload.orders')) " +
|
||||
"FROM fl_inbound_outbox WHERE event_key=?", Integer.class,
|
||||
eventKey));
|
||||
assertEquals("真实验收客户", jdbc.queryForObject(
|
||||
"SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.orders[0].customerName')) " +
|
||||
"FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey));
|
||||
assertEquals("2026-09-30T12:00", jdbc.queryForObject(
|
||||
"SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.estimatedPortArrivalAt')) " +
|
||||
"FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey));
|
||||
assertNull(jdbc.queryForObject(
|
||||
"SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.actualPortArrivalAt')) " +
|
||||
"FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey));
|
||||
assertEquals(1, jdbc.queryForObject(
|
||||
"SELECT JSON_EXTRACT(payload_json,'$.payload.orders[0].palletCount') " +
|
||||
"FROM fl_inbound_outbox WHERE event_key=?", Integer.class, eventKey));
|
||||
assertEquals("35.0", jdbc.queryForObject(
|
||||
"SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.orders[0].volumeWeightKg')) " +
|
||||
"FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey));
|
||||
}
|
||||
|
||||
private void seedCompletedLoadingPlan() {
|
||||
jdbc.update("INSERT IGNORE INTO fl_container_booking(id,booking_no,container_no,cabinet_no,warehouse_id," +
|
||||
"warehouse_name,container_type,created_by,updated_by) VALUES(?,?,?,?,?,?,?,1,1)",
|
||||
BOOKING, MARKER + "-BOOK", MARKER + "-CNTR", MARKER + "-CAB", 92L, "真实验收海外仓", "40HQ");
|
||||
"warehouse_name,customer_id,customer_name,estimated_port_arrival_at,container_type,created_by,updated_by) " +
|
||||
"VALUES(?,?,?,?,?,?,?,?,?,?,1,1)",
|
||||
BOOKING, MARKER + "-BOOK", MARKER + "-CNTR", MARKER + "-CAB", 92L, "真实验收海外仓",
|
||||
CUSTOMER, "真实验收客户", java.time.LocalDateTime.of(2026, 9, 30, 12, 0), "40HQ");
|
||||
jdbc.update("INSERT IGNORE INTO fl_loading_plan(id,plan_no,container_booking_id,plan_status,created_by," +
|
||||
"updated_by,version) VALUES(?,?,?,'LOADING',1,1,0)", PLAN, MARKER + "-PLAN", BOOKING);
|
||||
seedInbound(FIRST_INBOUND, 990022221L, MARKER + "-FIRST-IN", "FBA_SEA", "STANDARD", "LOADED", "ONT8");
|
||||
@@ -66,12 +84,8 @@ class TaillegRealProducerAcceptanceTest {
|
||||
MARKER + "-FIRST-IN", "FIRST-BOX-" + i, i, 3);
|
||||
for (int i = 1; i <= 2; i++) seedLabel(990022550L + i, DROPSHIP_INBOUND, 990022222L, 990022422L,
|
||||
MARKER + "-DROP-IN", "DROP-BOX-" + i, i, 2);
|
||||
jdbc.update("INSERT IGNORE INTO fl_inbound_pallet_allocation(id,tenant_id,inbound_id,allocation_no," +
|
||||
"warehouse_id,warehouse_resource_tenant_id,pallet_id,pallet_code,pallet_capacity_cbm," +
|
||||
"allocated_volume_cbm,allocated_package_count,picked_package_count,location_id,location_code," +
|
||||
"reservation_no,allocation_status) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED')",
|
||||
990022621L, CUSTOMER, DROPSHIP_INBOUND, 1, 92L, 0L, 990022721L, MARKER + "-PALLET-D",
|
||||
1.2, 0.4, 2, 2, 990022821L, "A-01", MARKER + "-RES");
|
||||
seedAllocation(990022620L, FIRST_INBOUND, 990022720L, 990022820L, 3, "F");
|
||||
seedAllocation(990022621L, DROPSHIP_INBOUND, 990022721L, 990022821L, 2, "D");
|
||||
jdbc.update("INSERT INTO fl_loading_pick_task(id,loading_plan_id,container_booking_id,cabinet_no," +
|
||||
"container_no,task_status,started_by,started_by_name) VALUES(?,?,?,?,?,'LOADING',1,'真实验收')",
|
||||
990022901L, PLAN, BOOKING, MARKER + "-CAB", MARKER + "-CNTR");
|
||||
@@ -80,6 +94,16 @@ class TaillegRealProducerAcceptanceTest {
|
||||
jdbc.update("UPDATE fl_loading_plan SET plan_status='COMPLETED',confirmed_at=NOW(3),version=version+1 WHERE id=?", PLAN);
|
||||
}
|
||||
|
||||
private void seedAllocation(long id, long inboundId, long palletId, long locationId,
|
||||
int packages, String suffix) {
|
||||
jdbc.update("INSERT IGNORE INTO fl_inbound_pallet_allocation(id,tenant_id,inbound_id,allocation_no," +
|
||||
"warehouse_id,warehouse_resource_tenant_id,pallet_id,pallet_code,pallet_capacity_cbm," +
|
||||
"allocated_volume_cbm,allocated_package_count,picked_package_count,location_id,location_code," +
|
||||
"reservation_id,reservation_no,allocation_status) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED')",
|
||||
id, CUSTOMER, inboundId, 1, 92L, 0L, palletId, MARKER + "-PALLET-" + suffix,
|
||||
1.2, 0.4, packages, packages, locationId, "A-01", id + 1000, MARKER + "-RES-" + suffix);
|
||||
}
|
||||
|
||||
private void seedPickItem(long id, long planItemId, long inboundId, long detailId, int packages) {
|
||||
jdbc.update("INSERT INTO fl_loading_pick_item(id,pick_task_id,loading_plan_item_id,inbound_id," +
|
||||
"inbound_detail_id,planned_package_count,picked_package_count,item_status) VALUES(?,?,?,?,?,?,?,'PICKED')",
|
||||
|
||||
Reference in New Issue
Block a user