Merge remote-tracking branch 'origin/test' into dev

# Conflicts:
#	AGENTS.md
#	scripts/agents-index-contract.ps1
This commit is contained in:
2026-09-04 14:50:25 +08:00
11 changed files with 256 additions and 24 deletions
@@ -401,6 +401,21 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
Candidate candidate(@Param("planId") long planId,@Param("warehouseId") long warehouseId,
@Param("inboundId") long inboundId,@Param("detailId") long detailId);
@Select("""
SELECT SUM(i.package_count)
FROM fl_inbound_measurement m
JOIN fl_inbound_measurement_item i
ON i.tenant_id=m.tenant_id AND i.measurement_id=m.id
WHERE m.tenant_id=#{accountId} AND m.inbound_id=#{inboundId} AND m.status='APPROVED'
AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2
WHERE m2.tenant_id=m.tenant_id AND m2.inbound_id=m.inbound_id
AND m2.status='APPROVED')
AND i.inbound_detail_id=#{detailId} AND i.package_count>0
""")
Integer measuredPackageCount(@Param("accountId") long accountId,
@Param("inboundId") long inboundId,
@Param("detailId") long detailId);
@lombok.Getter @lombok.Setter class ChannelContainerCountRow {
private String channelCode;
private Long availableContainerCount;
@@ -65,6 +65,9 @@ public class SmartLoadingRepository {
public Candidate candidate(long planId, long warehouseId, long inboundId, long detailId) {
return candidates.candidate(planId, warehouseId, inboundId, detailId);
}
public Integer measuredPackageCount(long accountId, long inboundId, long detailId) {
return candidates.measuredPackageCount(accountId, inboundId, detailId);
}
public InboundOrderEntity inbound(long accountId, long inboundId) {
return inbounds.selectOne(new LambdaQueryWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, accountId)
@@ -1429,7 +1429,7 @@ public class SmartLoadingService {
int picked = task.pickedByPlanItem().getOrDefault(item.getId(), 0);
item.setPickedPackageCount(picked);
item.setMinPackageCount(Math.max(1, picked));
int total = adjustmentTotal(p.getId(), b.getWarehouseId(), item);
int total = adjustmentTotal(item);
item.setTotalPackageCount(total);
item.setRemainingPackageCount(Math.max(0, total - item.getPackageCount()));
item.setCanAdjust(mutable);
@@ -1441,18 +1441,21 @@ public class SmartLoadingService {
b.getContainerNo(),b.getVoyageNo(),p.getVersion(),mutable,mutable,blockedReason,finalStatus,
task.taskId(),task.taskStatus(),task.taskVersion(),planCoLoadGroups(p.getId()),items);
}
private int adjustmentTotal(long planId, long warehouseId, LoadingPlanItemEntity item) {
private int adjustmentTotal(LoadingPlanItemEntity item) {
int current = item.getPackageCount() == null ? 0 : item.getPackageCount();
Candidate measured = repository.candidate(planId, warehouseId, item.getInboundId(), item.getInboundDetailId());
if (measured != null) {
return current + Math.max(0, measured.availablePackageCount() == null
? 0 : measured.availablePackageCount());
}
InboundOrderEntity inbound = repository.inbound(item.getAccountId(), item.getInboundId());
if (inbound == null || !Set.of("PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING",
"MEASURE_REJECTED", "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING")
.contains(inbound.getProcessStatus())) return current;
try {
if (item.getAccountId() == null || item.getInboundId() == null
|| item.getInboundDetailId() == null) return current;
InboundOrderEntity inbound = repository.inbound(item.getAccountId(), item.getInboundId());
if (inbound == null) return current;
if (Set.of("WAIT_SPLIT", "LOADING").contains(inbound.getProcessStatus())) {
Integer measured = repository.measuredPackageCount(item.getAccountId(), item.getInboundId(),
item.getInboundDetailId());
return Math.max(current, measured == null ? 0 : measured);
}
if (!Set.of("PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING",
"MEASURE_REJECTED", "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING")
.contains(inbound.getProcessStatus()) || item.getOrderId() == null) return current;
OrderCargoClient.CargoBinding cargo = loadCargo(item.getAccountId(), item.getOrderId(), item.getInboundDetailId());
return Math.max(current, cargo.originalPackageCount() == null ? 0 : cargo.originalPackageCount());
} catch (RuntimeException ignored) {
@@ -738,7 +738,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation (
PRIMARY KEY (id),
UNIQUE KEY uk_pallet_allocation_no (tenant_id, inbound_id, allocation_no),
UNIQUE KEY uk_pallet_allocation_slot (tenant_id, inbound_id, block_id, slot_no),
UNIQUE KEY uk_pallet_allocation_weight (tenant_id, weight_result_id),
UNIQUE KEY uk_pallet_allocation_weight (tenant_id, inbound_id, weight_result_id),
KEY idx_allocation_location (tenant_id, warehouse_id, location_id, allocation_status),
KEY idx_allocation_reservation (reservation_id),
CONSTRAINT fk_allocation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
@@ -2210,7 +2210,7 @@ components:
items: {type: string, pattern: '^[1-9][0-9]*$'}
SmartLoadingPlanItem:
type: object
required: [id, packageCount, version, pickedPackageCount, minPackageCount, canAdjust, canRemove]
required: [id, packageCount, totalPackageCount, remainingPackageCount, version, pickedPackageCount, minPackageCount, canAdjust, canRemove]
properties:
id: {type: string}
inboundId: {type: string}
@@ -2222,6 +2222,8 @@ components:
internalRemark: {type: string, nullable: true, readOnly: true, description: 来源为订单最新内部备注,不使用 cargoRemark}
warehouseRemark: {type: string, nullable: true, description: 仓库人员逐条维护,可在 EDITING/LOADING 修改}
packageCount: {type: integer, minimum: 1}
totalPackageCount: {type: integer, minimum: 1, description: 当前入仓明细可在计划内分配的总箱数,包含已用与未用箱数}
remainingPackageCount: {type: integer, minimum: 0, description: 当前入仓明细尚未分配到本计划的箱数}
version: {type: integer, minimum: 0, description: itemVersion,调整/删除写请求使用此值}
pickedPackageCount: {type: integer, minimum: 0}
minPackageCount: {type: integer, minimum: 1}
@@ -185,9 +185,12 @@ class PdaSplitSettlementSqlContractTest {
assertThat(moduleSql)
.contains("UNIQUE KEY uk_pallet_allocation_slot (tenant_id, inbound_id, block_id, slot_no)")
.contains("UNIQUE KEY uk_pallet_allocation_weight (tenant_id, inbound_id, weight_result_id)")
.doesNotContain("UNIQUE KEY uk_pallet_allocation_slot (tenant_id, block_id, slot_no)");
assertThat(unifiedSql)
.contains("'tenant_id,inbound_id,block_id,slot_no'")
.contains("ADD UNIQUE KEY uk_pallet_allocation_slot (tenant_id,inbound_id,block_id,slot_no)");
.contains("ADD UNIQUE KEY uk_pallet_allocation_slot (tenant_id,inbound_id,block_id,slot_no)")
.contains("'tenant_id,inbound_id,weight_result_id'")
.contains("ADD UNIQUE KEY uk_pallet_allocation_weight (tenant_id,inbound_id,weight_result_id)");
}
}
@@ -0,0 +1,164 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.client.PendingReviewOrderClient;
import com.baiyue.firstleg.dto.OrderApprovedInboundCommand;
import com.baiyue.firstleg.dto.SmartLoadingDtos.AdjustRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Plan;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.loading.coload.port.ColoadGroupAssignmentPort;
import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import com.baiyue.firstleg.split.service.SplitPreloadService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.dao.DataAccessResourceFailureException;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.nullable;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class SmartLoadingProvisionalAdjustmentTest {
@Mock private SmartLoadingRepository repository;
@Mock private OrderCargoClient orderCargoClient;
@Mock private PendingReviewOrderClient pendingReviewOrders;
@Mock private InboundOrderCreationService inboundOrderCreationService;
@Mock private LoadingPickService loadingPickService;
@Mock private LoadingModelBridgeService loadingModelBridge;
@Mock private SplitPreloadService splitPreloadService;
@Mock private ColoadGroupAssignmentPort coloadGroups;
@Mock private PriceBookQueryUseCase priceBooks;
@Mock private FirstlegDocumentNumberGenerator documentNumbers;
@Spy private ObjectMapper objectMapper = new ObjectMapper();
@InjectMocks private SmartLoadingService service;
private LoadingPlanItemEntity item;
private InboundOrderEntity inbound;
@BeforeEach
void setUp() {
item = new LoadingPlanItemEntity();
item.setId(8L);
item.setLoadingPlanId(1L);
item.setAccountId(9L);
item.setInboundId(10L);
item.setInboundDetailId(20L);
item.setOrderId(30L);
item.setInboundCode("I10");
item.setPackageCount(20);
item.setVersion(0);
inbound = new InboundOrderEntity();
inbound.setTenantId(9L);
inbound.setId(10L);
inbound.setProcessStatus("PREPARING");
when(loadingPickService.mutationSnapshot(anyLong())).thenReturn(
new LoadingPickService.MutationSnapshot(null, null, null, Map.of()));
}
@Test
void adjustsProvisionalInboundFromTwentyToFiftyWithinDeclaredHundredBoxes() {
LoadingPlanEntity plan = plan(103);
ContainerBookingEntity booking = booking();
OrderCargoClient.CargoBinding cargo = new OrderCargoClient.CargoBinding(
20L, 30L, 1, "货物", 100, new BigDecimal("200"), new BigDecimal("50"));
OrderApprovedInboundCommand snapshot = new OrderApprovedInboundCommand(
9L, 30L, "O30", "I10", 9L, "客户", 100L, "仓库",
null, null, null, "FIRST_LEG", "FBA", "FBA渠道", null, null, null,
null, null, false, "FBA", 200L, "FBA1", "FBA仓", null, null, null, 100);
when(repository.plan(1L)).thenReturn(plan, plan(104));
when(repository.item(1L, 8L)).thenReturn(item);
when(repository.booking(99L)).thenReturn(booking);
when(repository.inbound(9L, 10L)).thenReturn(inbound);
when(orderCargoClient.detail(nullable(String.class), eq(9L), eq(30L), eq(20L)))
.thenReturn(ApiResponse.success(cargo));
when(orderCargoClient.inboundSnapshot(nullable(String.class), eq(9L), eq(30L)))
.thenReturn(ApiResponse.success(snapshot));
when(repository.items(1L)).thenReturn(List.of(item));
when(repository.touch(1L, 103, 7L, "u")).thenReturn(1);
when(repository.update(item)).thenReturn(1);
Plan result = service.adjust(1L, 8L,
new AdjustRequest(50, 103, 0, "adjust-provisional"), 7L, "u");
assertThat(item.getPackageCount()).isEqualTo(50);
assertThat(result.items()).singleElement().satisfies(updated -> {
assertThat(updated.getTotalPackageCount()).isEqualTo(100);
assertThat(updated.getRemainingPackageCount()).isEqualTo(50);
});
}
@Test
void planReadFallsBackToAssignedCountWhenQuantityEnrichmentFails() {
LoadingPlanEntity plan = plan(103);
when(repository.planByBooking(99L)).thenReturn(plan);
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of(item));
when(repository.inbound(9L, 10L))
.thenThrow(new DataAccessResourceFailureException("read query failed"));
Plan result = service.draftByBooking(99L);
assertThat(result.items()).singleElement().satisfies(updated -> {
assertThat(updated.getTotalPackageCount()).isEqualTo(20);
assertThat(updated.getRemainingPackageCount()).isZero();
});
}
@Test
void planReadUsesApprovedMeasurementWithoutCallingTheLockedAdjustmentQuery() {
inbound.setProcessStatus("WAIT_SPLIT");
when(repository.planByBooking(99L)).thenReturn(plan(103));
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of(item));
when(repository.inbound(9L, 10L)).thenReturn(inbound);
when(repository.measuredPackageCount(9L, 10L, 20L)).thenReturn(100);
Plan result = service.draftByBooking(99L);
assertThat(result.items()).singleElement().satisfies(updated -> {
assertThat(updated.getTotalPackageCount()).isEqualTo(100);
assertThat(updated.getRemainingPackageCount()).isEqualTo(80);
});
}
private static LoadingPlanEntity plan(int version) {
LoadingPlanEntity value = new LoadingPlanEntity();
value.setId(1L);
value.setContainerBookingId(99L);
value.setPlanStatus("EDITING");
value.setVersion(version);
return value;
}
private static ContainerBookingEntity booking() {
ContainerBookingEntity value = new ContainerBookingEntity();
value.setId(99L);
value.setWarehouseId(100L);
value.setChannelCode("FBA");
value.setDestinationWarehouseCode("FBA1");
value.setCapacityCbm(new BigDecimal("60"));
value.setMaxWeightKg(new BigDecimal("20000"));
return value;
}
}