优化
This commit is contained in:
+6
@@ -27,6 +27,11 @@ public interface LoadingModelBridgeMapper extends BaseMapper<PreloadLoadingPlanL
|
||||
int allocatedPackages(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
|
||||
@Param("loadingPlanId") long loadingPlanId);
|
||||
|
||||
@Select("SELECT account_id tenantId,inbound_id inboundId,COALESCE(SUM(package_count),0) allocatedPackageCount "
|
||||
+ "FROM fl_loading_plan_item WHERE loading_plan_id=#{loadingPlanId} AND deleted=0 "
|
||||
+ "GROUP BY account_id,inbound_id HAVING SUM(package_count)>0")
|
||||
List<AllocationScope> allocatedScopes(@Param("loadingPlanId") long loadingPlanId);
|
||||
|
||||
@Update("UPDATE fl_preload_plan SET updated_by=#{operatorId},updated_by_name=#{operatorName},"
|
||||
+ "version=version+1 WHERE id=#{preloadPlanId} AND tenant_id=#{tenantId} "
|
||||
+ "AND inbound_id=#{inboundId} AND status='PENDING' AND version=#{expectedVersion}")
|
||||
@@ -70,4 +75,5 @@ public interface LoadingModelBridgeMapper extends BaseMapper<PreloadLoadingPlanL
|
||||
record PreloadSnapshot(Long id, Long tenantId, Long inboundId, String status, Integer version) {}
|
||||
record LoadingPlanSnapshot(Long id, Long containerBookingId, String containerNo,
|
||||
String status, Integer version) {}
|
||||
record AllocationScope(Long tenantId, Long inboundId, Integer allocatedPackageCount) {}
|
||||
}
|
||||
|
||||
+2
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
|
||||
import com.baiyue.firstleg.loading.bridge.entity.PreloadLoadingPlanLinkEntity;
|
||||
import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper;
|
||||
import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.AllocationScope;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@@ -24,6 +25,7 @@ public class LoadingModelBridgeRepository {
|
||||
public int allocatedPackages(long tenantId, long inboundId, long loadingPlanId) {
|
||||
return mapper.allocatedPackages(tenantId, inboundId, loadingPlanId);
|
||||
}
|
||||
public List<AllocationScope> allocatedScopes(long loadingPlanId) { return mapper.allocatedScopes(loadingPlanId); }
|
||||
|
||||
public PreloadLoadingPlanLinkEntity byPair(long tenantId, long inboundId, long loadingPlanId) {
|
||||
return mapper.selectOne(new LambdaQueryWrapper<PreloadLoadingPlanLinkEntity>()
|
||||
|
||||
+35
@@ -7,6 +7,7 @@ import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.BindRequest
|
||||
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
|
||||
import com.baiyue.firstleg.loading.bridge.entity.PreloadLoadingPlanLinkEntity;
|
||||
import com.baiyue.firstleg.loading.bridge.repository.LoadingModelBridgeRepository;
|
||||
import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.AllocationScope;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -68,6 +69,40 @@ public class LoadingModelBridgeService {
|
||||
return requireLink(tenantId, inboundId, loadingPlanId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates every preload link required by a pushed loading plan before PDA can start.
|
||||
* The caller owns the surrounding push transaction, so a missing preload or stale
|
||||
* version rolls back the push and cannot leave a LOADING task without a link.
|
||||
*/
|
||||
@Transactional
|
||||
public List<Link> bindAllocatedBeforeLoading(long loadingPlanId, long operatorId,
|
||||
String operatorName, String orchestrationKey) {
|
||||
var loadingPlan = repository.loadingPlan(loadingPlanId);
|
||||
if (loadingPlan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在");
|
||||
List<AllocationScope> scopes = repository.allocatedScopes(loadingPlanId);
|
||||
if (scopes.isEmpty()) throw conflict("装柜计划尚未分配入库单货物,不能开始PDA作业");
|
||||
if (!"EDITING".equals(loadingPlan.status())) {
|
||||
if (Set.of("LOADING", "COMPLETED").contains(loadingPlan.status())
|
||||
&& scopes.stream().allMatch(scope -> repository.byPair(
|
||||
scope.tenantId(), scope.inboundId(), loadingPlanId) != null)) {
|
||||
return repository.byLoadingPlan(loadingPlanId);
|
||||
}
|
||||
throw conflict("PDA开始前的预装关联不完整,禁止在LOADING后补关联");
|
||||
}
|
||||
for (AllocationScope scope : scopes) {
|
||||
var preload = repository.preload(scope.tenantId(), scope.inboundId());
|
||||
var currentPlan = repository.loadingPlan(loadingPlanId);
|
||||
if (preload == null || currentPlan == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "入库单预装计划不存在");
|
||||
}
|
||||
String key = "AUTO:" + hash(orchestrationKey + "|" + scope.tenantId() + "|"
|
||||
+ scope.inboundId() + "|" + loadingPlanId).substring(0, 60);
|
||||
bind(scope.tenantId(), scope.inboundId(), loadingPlanId, operatorId, operatorName, key,
|
||||
new BindRequest(preload.version(), currentPlan.version()));
|
||||
}
|
||||
return repository.byLoadingPlan(loadingPlanId);
|
||||
}
|
||||
|
||||
public List<Link> byInbound(long tenantId, long inboundId) { return repository.byInbound(tenantId, inboundId); }
|
||||
public List<Link> byLoadingPlan(long loadingPlanId) { return repository.byLoadingPlan(loadingPlanId); }
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.*;
|
||||
import com.baiyue.firstleg.client.OrderCargoClient;
|
||||
import com.baiyue.firstleg.entity.*;
|
||||
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
||||
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -33,6 +34,7 @@ public class SmartLoadingService {
|
||||
@Resource private SmartLoadingRepository repository;
|
||||
@Resource private OrderCargoClient orderCargoClient;
|
||||
@Resource private LoadingPickService loadingPickService;
|
||||
@Resource private LoadingModelBridgeService loadingModelBridge;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
public SmartLoadingService() { }
|
||||
@@ -233,6 +235,7 @@ public class SmartLoadingService {
|
||||
throw validation("录入真实柜号后才能推送预装清单并开启PDA作业");
|
||||
}
|
||||
if (key.equals(plan.getPushIdempotencyKey())) {
|
||||
loadingModelBridge.bindAllocatedBeforeLoading(plan.getId(), operatorId, operatorName, key);
|
||||
loadingPickService.beginIfReady(plan.getContainerBookingId(), operatorId, operatorName);
|
||||
return view(plan, booking);
|
||||
}
|
||||
@@ -246,6 +249,7 @@ public class SmartLoadingService {
|
||||
} catch (DuplicateKeyException e) { throw conflict("幂等键已用于其他推送"); }
|
||||
history(planId, null, "PUSH_PRELOAD", "EDITING", "EDITING", key, operatorId, operatorName);
|
||||
LoadingPlanEntity pushed = repository.plan(planId);
|
||||
loadingModelBridge.bindAllocatedBeforeLoading(planId, operatorId, operatorName, key);
|
||||
loadingPickService.beginIfReady(pushed.getContainerBookingId(), operatorId, operatorName);
|
||||
return view(pushed, requireBooking(pushed.getContainerBookingId()));
|
||||
}
|
||||
|
||||
@@ -363,6 +363,8 @@ paths:
|
||||
description: >-
|
||||
需要 platform:firstleg:smart-loading:push 权限。真实柜号必须已录入,
|
||||
否则不得推送预装清单或创建 PDA作业任务;计划必须处于 EDITING 且版本匹配。
|
||||
推送事务会先为所有已分配入库单建立 PENDING 预装计划与 EDITING 柜次计划的正式关联,
|
||||
任一关联缺失、版本冲突或分配数量为零都会整体失败并禁止 PDA/LOADING;不得在 LOADING 后补关联。
|
||||
parameters:
|
||||
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {$ref: '#/components/parameters/IdempotencyKey'}
|
||||
|
||||
@@ -2,6 +2,7 @@ openapi: 3.0.3
|
||||
info:
|
||||
title: Phase 3 Preload and Container Loading Bridge API
|
||||
version: 1.0.0
|
||||
description: Links must be created while preload=PENDING and loadingPlan=EDITING. Smart-loading push creates all allocated links atomically before PDA task creation; LOADING plans cannot be repaired through post-hoc binding.
|
||||
paths:
|
||||
/api/platform/v1/inbounds/{inboundId}/preload/loading-plans:
|
||||
get:
|
||||
@@ -12,7 +13,7 @@ paths:
|
||||
/api/platform/v1/inbounds/{inboundId}/preload/loading-plans/{loadingPlanId}:
|
||||
post:
|
||||
summary: Atomically link an inbound preload and a container loading plan
|
||||
description: Both optimistic-lock versions must match; either conflict rolls back the whole link operation.
|
||||
description: Both optimistic-lock versions must match; either conflict rolls back the whole link operation. This endpoint never permits post-hoc binding after the loading plan enters LOADING.
|
||||
parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/LoadingPlanId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody:
|
||||
required: true
|
||||
|
||||
+49
@@ -4,6 +4,8 @@ import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.BindRequest
|
||||
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
|
||||
import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.LoadingPlanSnapshot;
|
||||
import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.PreloadSnapshot;
|
||||
import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.AllocationScope;
|
||||
import com.baiyue.firstleg.loading.bridge.entity.PreloadLoadingPlanLinkEntity;
|
||||
import com.baiyue.firstleg.loading.bridge.repository.LoadingModelBridgeRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -42,6 +44,53 @@ class LoadingModelBridgeServiceTest {
|
||||
verify(repository).touchLoadingPlan(40, 3, 7, "admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindsEveryAllocatedInboundBeforeLoadingStarts() {
|
||||
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3));
|
||||
when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8)));
|
||||
when(repository.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "PENDING", 2));
|
||||
when(repository.allocatedPackages(10, 20, 40)).thenReturn(8);
|
||||
when(repository.touchPreload(30, 10, 20, 2, 7, "admin")).thenReturn(1);
|
||||
when(repository.touchLoadingPlan(40, 3, 7, "admin")).thenReturn(1);
|
||||
Link linked = link("PENDING", 3, "EDITING", 4, 8);
|
||||
when(repository.byInbound(10, 20)).thenReturn(List.of(linked));
|
||||
when(repository.byLoadingPlan(40)).thenReturn(List.of(linked));
|
||||
|
||||
List<Link> result = service.bindAllocatedBeforeLoading(40, 7, "admin", "push-1");
|
||||
|
||||
assertThat(result).containsExactly(linked);
|
||||
verify(repository).insert(any());
|
||||
verify(repository).touchPreload(30, 10, 20, 2, 7, "admin");
|
||||
verify(repository).touchLoadingPlan(40, 3, 7, "admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesPostHocBindingAfterLoadingStarts() {
|
||||
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 4));
|
||||
when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8)));
|
||||
|
||||
assertThatThrownBy(() -> service.bindAllocatedBeforeLoading(40, 7, "admin", "push-1"))
|
||||
.hasMessageContaining("禁止在LOADING后补关联");
|
||||
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadingReplayOnlyReadsAlreadyCompleteLinks() {
|
||||
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 4));
|
||||
when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8)));
|
||||
PreloadLoadingPlanLinkEntity existing = new PreloadLoadingPlanLinkEntity();
|
||||
when(repository.byPair(10, 20, 40)).thenReturn(existing);
|
||||
Link linked = link("PENDING", 3, "LOADING", 4, 8);
|
||||
when(repository.byLoadingPlan(40)).thenReturn(List.of(linked));
|
||||
|
||||
assertThat(service.bindAllocatedBeforeLoading(40, 7, "admin", "push-1"))
|
||||
.containsExactly(linked);
|
||||
|
||||
verify(repository, never()).insert(any());
|
||||
verify(repository, never()).touchLoadingPlan(anyLong(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondVersionConflictDoesNotProceedAsSuccess() {
|
||||
when(repository.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "PENDING", 2));
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.baiyue.firstleg.entity.LoadingPlanEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
|
||||
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
||||
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import feign.FeignException;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -41,6 +42,8 @@ import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -51,6 +54,7 @@ class SmartLoadingServiceTest {
|
||||
@MockBean private SmartLoadingRepository repository;
|
||||
@MockBean private OrderCargoClient cargoClient;
|
||||
@MockBean private LoadingPickService loadingPickService;
|
||||
@MockBean private LoadingModelBridgeService loadingModelBridge;
|
||||
@Resource private SmartLoadingService service;
|
||||
|
||||
@TestConfiguration
|
||||
@@ -384,6 +388,29 @@ class SmartLoadingServiceTest {
|
||||
|
||||
assertThat(result.status()).isEqualTo("EDITING");
|
||||
verify(repository).push(1, "key", 3, 7, "u");
|
||||
var ordered = inOrder(loadingModelBridge, loadingPickService);
|
||||
ordered.verify(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key");
|
||||
ordered.verify(loadingPickService).beginIfReady(99, 7, "u");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rollsBackPushOrchestrationBeforePdaWhenBindingFails() {
|
||||
LoadingPlanEntity editing = plan("EDITING", 3);
|
||||
LoadingPlanEntity pushed = plan("EDITING", 4);
|
||||
pushed.setPushIdempotencyKey("key");
|
||||
when(repository.plan(1)).thenReturn(editing, pushed);
|
||||
when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1)));
|
||||
when(repository.booking(99)).thenReturn(booking());
|
||||
when(repository.push(1, "key", 3, 7, "u")).thenReturn(1);
|
||||
doThrow(new BizException(ResultCode.CONFLICT, "预装计划不存在"))
|
||||
.when(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key");
|
||||
|
||||
BizException error = catchThrowableOfType(
|
||||
() -> service.pushPreload(1, "key", new PushRequest(3), 7, "u"), BizException.class);
|
||||
|
||||
assertThat(error.getMessage()).contains("预装计划不存在");
|
||||
verify(repository).push(1, "key", 3, 7, "u");
|
||||
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user