1
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
@@ -69,6 +70,8 @@ public class ContainerBookingEntity {
|
||||
@TableField(exist = false) private Long planId;
|
||||
@TableField(exist = false) private String planNo;
|
||||
@TableField(exist = false) private String planStatus;
|
||||
/** Actual completion timestamp projected from the associated loading plan. */
|
||||
@JsonIgnore @TableField(exist = false) private LocalDateTime completedLoadingAt;
|
||||
@TableField(exist = false) private Boolean exceptionActive;
|
||||
@TableField(exist = false) private String destinationClearanceDocumentStatus;
|
||||
@TableField(exist = false) private String customsDeclarationDocumentStatus;
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ public interface LoadingModelBridgeMapper extends BaseMapper<PreloadLoadingPlanL
|
||||
@Update("UPDATE fl_preload_plan SET status='CONFIRMED',confirmed_by=#{operatorId},"
|
||||
+ "confirmed_by_name=#{operatorName},confirmed_at=NOW(),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 tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND status IN ('PENDING','CONFIRMING') "
|
||||
+ "AND version=#{expectedVersion}")
|
||||
int confirmIncrementalPreload(@Param("preloadPlanId") long preloadPlanId,
|
||||
@Param("tenantId") long tenantId,
|
||||
|
||||
+4
-2
@@ -125,7 +125,9 @@ public class LoadingModelBridgeService {
|
||||
}
|
||||
var preload = repository.preload(tenantId, inboundId);
|
||||
if (preload == null) throw new BizException(ResultCode.NOT_FOUND, "入库单预装计划不存在");
|
||||
if (!Set.of("PENDING", "CONFIRMED").contains(preload.status())) {
|
||||
boolean running = "LOADING".equals(plan.status());
|
||||
if (!Set.of("PENDING", "CONFIRMED").contains(preload.status())
|
||||
&& !(running && "CONFIRMING".equals(preload.status()))) {
|
||||
throw conflict("新增入库单预装状态不允许进入当前柜次");
|
||||
}
|
||||
PreloadLoadingPlanLinkEntity link = new PreloadLoadingPlanLinkEntity();
|
||||
@@ -145,7 +147,7 @@ public class LoadingModelBridgeService {
|
||||
// A running PDA task has already passed plan-level confirmation. Adding an inbound is the
|
||||
// explicit Admin approval for that new allocation, so its fresh PENDING preload joins the
|
||||
// same confirmed plan atomically instead of leaving an unreachable pending aggregate.
|
||||
if ("LOADING".equals(plan.status()) && "PENDING".equals(preload.status())
|
||||
if (running && Set.of("PENDING", "CONFIRMING").contains(preload.status())
|
||||
&& repository.confirmIncrementalPreload(preload.id(), tenantId, inboundId,
|
||||
preload.version(), operatorId, operatorName) != 1) {
|
||||
throw conflict("新增入库单预装确认并发冲突,请刷新后重试");
|
||||
|
||||
@@ -16,6 +16,7 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT b.*, p.id AS plan_id, p.plan_no AS plan_no,
|
||||
CASE WHEN p.plan_status = 'COMPLETED' THEN COALESCE(p.confirmed_at, p.updated_at) END AS completed_loading_at,
|
||||
CASE WHEN (SELECT eh.action FROM fl_container_exception_history eh
|
||||
WHERE eh.container_booking_id=b.id ORDER BY eh.occurred_at DESC,eh.id DESC LIMIT 1)='MARK'
|
||||
THEN TRUE ELSE FALSE END AS exception_active,
|
||||
@@ -77,6 +78,7 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
|
||||
|
||||
@Select("""
|
||||
SELECT b.*, p.id AS plan_id, p.plan_no AS plan_no,
|
||||
CASE WHEN p.plan_status = 'COMPLETED' THEN COALESCE(p.confirmed_at, p.updated_at) END AS completed_loading_at,
|
||||
CASE WHEN (SELECT eh.action FROM fl_container_exception_history eh
|
||||
WHERE eh.container_booking_id=b.id ORDER BY eh.occurred_at DESC,eh.id DESC LIMIT 1)='MARK'
|
||||
THEN TRUE ELSE FALSE END AS exception_active,
|
||||
|
||||
@@ -13,9 +13,12 @@ import org.springframework.stereotype.Repository;
|
||||
public class ContainerRepository {
|
||||
@Resource private ContainerBookingMapper mapper;
|
||||
public PageResult<ContainerBookingEntity> page(Query query) {
|
||||
return PageResult.from(mapper.selectBookingPage(new Page<>(query.page(), query.size()), query));
|
||||
Page<ContainerBookingEntity> result = mapper.selectBookingPage(
|
||||
new Page<>(query.page(), query.size()), query);
|
||||
result.getRecords().forEach(this::resolveLoadingTime);
|
||||
return PageResult.from(result);
|
||||
}
|
||||
public ContainerBookingEntity find(long id) { return mapper.selectBooking(id); }
|
||||
public ContainerBookingEntity find(long id) { return resolveLoadingTime(mapper.selectBooking(id)); }
|
||||
public ContainerBookingEntity findForUpdate(long id) { return mapper.selectBookingForUpdate(id); }
|
||||
public ContainerBookingEntity findActiveForUpdate(long id) { return mapper.selectActiveBookingForUpdate(id); }
|
||||
public boolean containerNoExists(String no, Long excludedId) {
|
||||
@@ -56,4 +59,12 @@ public class ContainerRepository {
|
||||
return mapper.syncReleasedTransport(oceanBookingId, billOfLadingNo, vesselName, voyageNo,
|
||||
estimatedSailingAt, operatorId, operatorName);
|
||||
}
|
||||
|
||||
private ContainerBookingEntity resolveLoadingTime(ContainerBookingEntity value) {
|
||||
if (value != null && value.getLoadingAt() == null
|
||||
&& "COMPLETED".equals(value.getPlanStatus())) {
|
||||
value.setLoadingAt(value.getCompletedLoadingAt());
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,7 +244,8 @@ public class FbaPricingRepository {
|
||||
public int markInboundConfirmed(long tenantId, long inboundId) {
|
||||
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
|
||||
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
|
||||
.in(InboundPricingEntity::getProcessStatus, List.of("FEE_CONFIRMING", "WAIT_SPLIT"))
|
||||
.in(InboundPricingEntity::getProcessStatus,
|
||||
List.of("FEE_CONFIRMING", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"))
|
||||
.eq(InboundPricingEntity::getFeeStatus, "CALCULATED")
|
||||
.set(InboundPricingEntity::getFeeStatus, "CONFIRMED"));
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import org.springframework.util.StringUtils;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/** 分柜、预装清单及其主状态推进;不校验资料完整度和客户确认。 */
|
||||
@Service
|
||||
@@ -73,8 +74,8 @@ public class SplitPreloadService {
|
||||
|
||||
/**
|
||||
* A running PDA task may restore or add an unpicked allocation after the plan-level preload
|
||||
* confirmation. CONFIRMING remains immutable; CONFIRMED is accepted because the caller owns
|
||||
* the active PDA task lock and will synchronise the restored loading item immediately.
|
||||
* confirmation. A running PDA task owns the mutation lock, so CONFIRMING and CONFIRMED
|
||||
* lifecycles may be reused while the restored loading item is synchronised atomically.
|
||||
*/
|
||||
@Transactional
|
||||
public void ensureForRunningLoadingAllocation(long tenantId, long inboundId, long operatorId,
|
||||
@@ -96,7 +97,8 @@ public class SplitPreloadService {
|
||||
}
|
||||
if (plan != null) {
|
||||
if (!PreloadStatus.PENDING.equals(plan.getStatus())
|
||||
&& !(running && PreloadStatus.CONFIRMED.equals(plan.getStatus()))) {
|
||||
&& !(running && Set.of(PreloadStatus.CONFIRMING, PreloadStatus.CONFIRMED)
|
||||
.contains(plan.getStatus()))) {
|
||||
throw new BizException(ResultCode.CONFLICT, "预装计划已进入确认流程,不能修改智能装柜分配");
|
||||
}
|
||||
return;
|
||||
|
||||
+14
@@ -190,6 +190,20 @@ class LoadingModelBridgeServiceTest {
|
||||
.hasMessageContaining("尚未全部完成装柜");
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindsConfirmingPreloadToRunningPlanAndConfirmsIt() {
|
||||
when(repository.byPair(10, 20, 40)).thenReturn(null);
|
||||
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 3, LocalDateTime.now()));
|
||||
when(repository.allocatedPackages(10, 20, 40)).thenReturn(2);
|
||||
when(repository.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "CONFIRMING", 4));
|
||||
when(repository.confirmIncrementalPreload(30L, 10L, 20L, 4, 7L, "admin")).thenReturn(1);
|
||||
|
||||
service.bindIncrementalAllocation(40L, 10L, 20L, 7L, "admin", "assign-confirming");
|
||||
|
||||
verify(repository).insert(any());
|
||||
verify(repository).confirmIncrementalPreload(30L, 10L, 20L, 4, 7L, "admin");
|
||||
}
|
||||
|
||||
private static Link link(String preloadStatus, int preloadVersion, String loadingStatus,
|
||||
int loadingVersion, int packages) {
|
||||
return new Link(1L, 10L, 20L, 30L, preloadStatus, preloadVersion, 40L, 50L, "C1",
|
||||
|
||||
+2
@@ -26,6 +26,8 @@ class ContainerBookingMapperContractTest {
|
||||
.contains("fl_loading_plan")
|
||||
.contains("p.id AS plan_id")
|
||||
.contains("p.plan_no AS plan_no")
|
||||
.contains("completed_loading_at")
|
||||
.contains("p.confirmed_at")
|
||||
.contains("fl_container_exception_history")
|
||||
.contains("destination_clearance_document_status")
|
||||
.contains("delivery_order_document_status")
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ContainerRepositoryLoadingTimeTest {
|
||||
@Mock private ContainerBookingMapper mapper;
|
||||
@InjectMocks private ContainerRepository repository;
|
||||
|
||||
@Test
|
||||
void projectsPlanConfirmationTimeWhenCompletedBookingHasNoSchedule() {
|
||||
LocalDateTime completedAt = LocalDateTime.of(2026, 8, 27, 10, 15);
|
||||
ContainerBookingEntity booking = new ContainerBookingEntity();
|
||||
booking.setPlanStatus("COMPLETED");
|
||||
booking.setCompletedLoadingAt(completedAt);
|
||||
when(mapper.selectBooking(7L)).thenReturn(booking);
|
||||
|
||||
ContainerBookingEntity result = repository.find(7L);
|
||||
|
||||
assertThat(result.getLoadingAt()).isEqualTo(completedAt);
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesExistingScheduleForUncompletedBooking() {
|
||||
LocalDateTime plannedAt = LocalDateTime.of(2026, 8, 28, 9, 0);
|
||||
ContainerBookingEntity booking = new ContainerBookingEntity();
|
||||
booking.setPlanStatus("LOADING");
|
||||
booking.setLoadingAt(plannedAt);
|
||||
booking.setCompletedLoadingAt(LocalDateTime.of(2026, 8, 27, 10, 15));
|
||||
when(mapper.selectBooking(8L)).thenReturn(booking);
|
||||
|
||||
ContainerBookingEntity result = repository.find(8L);
|
||||
|
||||
assertThat(result.getLoadingAt()).isEqualTo(plannedAt);
|
||||
}
|
||||
}
|
||||
+17
@@ -28,6 +28,9 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
@@ -184,6 +187,20 @@ class FbaPricingRepositoryRuleListTest {
|
||||
.contains(10001L, 88L, "FEE_CONFIRMING", "CONFIRMED", "SENT", "WAIT_PALLET");
|
||||
}
|
||||
|
||||
@Test
|
||||
void feeConfirmationAllowsPostLoadingInboundStatuses() {
|
||||
String source;
|
||||
try {
|
||||
source = Files.readString(Path.of(
|
||||
"src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java"),
|
||||
StandardCharsets.UTF_8);
|
||||
} catch (java.io.IOException exception) {
|
||||
throw new AssertionError(exception);
|
||||
}
|
||||
assertThat(source)
|
||||
.contains("List.of(\"FEE_CONFIRMING\", \"WAIT_SPLIT\", \"LOADING\", \"LOADED\", \"DELIVERED\")");
|
||||
}
|
||||
|
||||
private Collection<Object> queryValues(Long tenantId, String ruleCode) {
|
||||
when(rules.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of());
|
||||
|
||||
|
||||
@@ -172,6 +172,33 @@ class FbaPricingServiceTest {
|
||||
verify(repository).markInboundConfirmed(TENANT, INBOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("装柜完成后的自动费用仍可确认")
|
||||
void confirmsCalculatedFeeAfterLoadingCompleted() {
|
||||
InboundPricingEntity inbound = inbound("10.000", "1.0000");
|
||||
inbound.setProcessStatus("LOADED");
|
||||
inbound.setFeeStatus("CALCULATED");
|
||||
InboundFeeEntity fee = savedFee("CALCULATED");
|
||||
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(fee);
|
||||
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CONFIRM", "confirm-loaded"))
|
||||
.thenReturn(null);
|
||||
doAnswer(invocation -> {
|
||||
((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L);
|
||||
return null;
|
||||
}).when(repository).insertIdempotency(any());
|
||||
when(repository.confirmFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1);
|
||||
when(repository.markInboundConfirmed(TENANT, INBOUND)).thenReturn(1);
|
||||
when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1);
|
||||
when(repository.findFee(TENANT, 501L)).thenReturn(fee);
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
|
||||
FeeSnapshotResponse response = service.confirm(
|
||||
TENANT, OPERATOR, INBOUND, "confirm-loaded");
|
||||
|
||||
assertThat(response.nextProcessStatus()).isEqualTo("LOADED");
|
||||
verify(repository).markInboundConfirmed(TENANT, INBOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("审核未通过的测量快照禁止计费")
|
||||
void rejectsUnapprovedMeasurementSnapshot() {
|
||||
|
||||
@@ -380,6 +380,30 @@ class SmartLoadingServiceTest {
|
||||
anyLong(), anyLong(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reusesConfirmingPreloadWhenAddingToRunningPlan() {
|
||||
LoadingPlanEntity loading = plan("LOADING", 5);
|
||||
LoadingPlanEntity after = plan("LOADING", 6);
|
||||
when(repository.plan(1)).thenReturn(loading, after);
|
||||
when(repository.booking(99)).thenReturn(booking());
|
||||
when(repository.touch(1, 5, 7, "u")).thenReturn(1);
|
||||
when(repository.items(1)).thenReturn(List.of());
|
||||
when(repository.candidate(1, 100, 10, 20)).thenReturn(candidate(10));
|
||||
when(repository.activeItem(1L, 9L, 20L)).thenReturn(null);
|
||||
when(loadingPickService.mutationSnapshot(1L)).thenReturn(
|
||||
new LoadingPickService.MutationSnapshot(77L, "LOADING", 6, Map.of()));
|
||||
when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(ApiResponse.success(
|
||||
new OrderCargoClient.CargoBinding(20L, 30L, 1, "goods", 1,
|
||||
BigDecimal.ONE, BigDecimal.ONE, null, null, null, null, null, null)));
|
||||
|
||||
Plan result = service.assign(1, new AssignRequest(10L, 20L, 1, 5, "assign-confirming"), 7, "u");
|
||||
|
||||
assertThat(result.status()).isEqualTo("LOADING");
|
||||
verify(splitPreloadService).ensureForRunningLoadingAllocation(9L, 10L, 7L, "u");
|
||||
verify(loadingModelBridge).bindIncrementalAllocation(1L, 9L, 10L,
|
||||
7L, "u", "assign-confirming");
|
||||
}
|
||||
|
||||
@Test
|
||||
void replaysBeforeVersionCheck() {
|
||||
AssignRequest request = new AssignRequest(10L, 20L, 2, 0, "assign-replay");
|
||||
|
||||
+4
-3
@@ -129,13 +129,14 @@ class SplitPreloadServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void runningLoadingAllocationStillRejectsConfirmingPreload() {
|
||||
void runningLoadingAllocationMayReuseConfirmingPreload() {
|
||||
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3));
|
||||
PreloadPlanEntity plan = new PreloadPlanEntity();
|
||||
plan.setStatus("CONFIRMING");
|
||||
when(repository.findPlan(10, 20)).thenReturn(plan);
|
||||
|
||||
assertThatThrownBy(() -> service.ensureForRunningLoadingAllocation(10, 20, 7, "admin"))
|
||||
.hasMessageContaining("已进入确认流程");
|
||||
service.ensureForRunningLoadingAllocation(10, 20, 7, "admin");
|
||||
|
||||
verify(repository, never()).createPlan(any());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user