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

# Conflicts:
#	lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java
#	lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java
#	warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java
This commit is contained in:
2026-09-09 18:20:40 +08:00
58 changed files with 1787 additions and 932 deletions
@@ -147,13 +147,20 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Reserved pallet customer binding is invalid");
}
WarehousePalletData pallet = lookupPallet(palletCode).data();
if (pallet.id() != palletId || pallet.warehouseId() != warehouseId || pallet.locationId() != locationId
WarehousePalletReference pallet;
try {
pallet = reference(lookupPallet(palletCode).data());
} catch (ResponseStatusException exception) {
if (exception.getStatusCode().value() != 404) throw exception;
// 已生成计划可能保留早期展示码;确认过的资源绑定仍是唯一可信物理身份。
pallet = resolvePallet(palletId);
}
if (pallet.palletId() != palletId || pallet.warehouseId() != warehouseId || pallet.locationId() != locationId
|| !"OCCUPIED".equals(pallet.status())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Scanned pallet does not match the reserved inbound plan resource");
}
return reference(pallet);
return pallet;
}
@Override
@@ -58,7 +58,8 @@ public interface WarehouseResourceGateway {
java.math.BigDecimal maxLoadKg, String status) { }
record PalletBatchEnsure(long warehouseId, String batchKey, List<PalletBatchItemRequest> items) { }
record PalletBatchItemRequest(long customerId, String businessKey, String inboundPlanNo,
int sequenceNo, String palletType, String fbaWarehouseCode) { }
int sequenceNo, String palletType, String fbaWarehouseCode,
String palletCode) { }
record PalletBatchCommand(long warehouseId, String batchKey, String commandKey) { }
record PalletBatchCompensation(long warehouseId, String batchKey, String commandKey, String reason) { }
record PalletBatchResponse(long batchId, String batchKey, String status, int attemptNo,
@@ -117,7 +117,7 @@ public class ContainerInboundPlanConversionCoordinator {
preparation.warehouseId(), command.getBatchKey(), preparation.items().stream().map(item ->
new WarehouseResourceGateway.PalletBatchItemRequest(Long.parseLong(item.customerId()),
item.businessKey(), item.planNo(), item.globalSequenceNo(), "STANDARD",
item.fbaWarehouseCode())).toList());
item.fbaWarehouseCode(), item.palletCode())).toList());
WarehouseResourceGateway.PalletBatchResponse reservation;
try {
reservation = warehouse.ensurePalletBatch(ensure);
@@ -207,10 +207,15 @@ public class ContainerInboundPlanConversionCoordinator {
.flatMap(group -> group.orders().stream()).filter(item -> "READY".equals(item.readiness())).toList()) {
long planId = IdWorker.getId();
String planNo = numbers.next("INBOUND_PLAN", order.destinationWarehouseCode(), planId);
List<String> sourcePalletNos = plans.sourcePalletNos(
order.customerId(), Long.parseLong(order.orderId()), order.orderType());
if (sourcePalletNos.size() != order.suggestedPalletCount()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "头程托盘码数量与柜级预览不一致");
}
for (int localSequence = 1; localSequence <= order.suggestedPalletCount(); localSequence++) {
items.add(new PreparedItem(order.customerId(), Long.parseLong(order.orderId()), planId, planNo,
localSequence, ++globalSequence, "CIP:" + generationId + ":" + globalSequence,
trimToNull(order.fbaWarehouseCode())));
trimToNull(order.fbaWarehouseCode()), sourcePalletNos.get(localSequence - 1)));
}
}
Preparation preparation = new Preparation(warehouseId, List.copyOf(items));
@@ -373,5 +378,5 @@ public class ContainerInboundPlanConversionCoordinator {
public record Preparation(long warehouseId, List<PreparedItem> items) { }
public record PreparedItem(String customerId, long orderId, long planId, String planNo,
int localSequenceNo, int globalSequenceNo, String businessKey,
String fbaWarehouseCode) { }
String fbaWarehouseCode, String palletCode) { }
}
@@ -27,6 +27,7 @@ import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import com.baiyue.common.event.ContainerNumbers;
import com.baiyue.lastleg.entity.LastlegOrderCommandRecord;
import com.baiyue.lastleg.mapper.LastlegOrderCommandRecordMapper;
import com.baiyue.lastleg.mapper.LastlegSourcePalletMapper;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -65,8 +66,16 @@ public class InboundPlanService {
@Resource private DropshipOrderService dropshipOrders;
@Resource private WarehouseResourceGateway warehouseGateway;
@Resource private LastlegOrderCommandRecordMapper commandMapper;
@Resource private LastlegSourcePalletMapper sourcePalletMapper;
@Resource private ObjectMapper objectMapper;
public List<String> sourcePalletNos(String customerId, long orderId, String orderType) {
return "ONE_CLICK_DROPSHIP".equals(orderType)
? dropshipOrders.palletSnapshots(customerId, orderId).stream()
.map(DropshipPalletSnapshot::sourcePalletNo).toList()
: sourcePalletMapper.palletNos(customerId, orderId);
}
@Transactional
public InboundPlanResponse convertManual(ManualInboundPlanRequest request, String idempotencyKey,
String operatorId) {
@@ -393,6 +402,10 @@ public class InboundPlanService {
PalletAllocator.Result allocation = wholePallets == null && manual == null ? PalletAllocator.allocate(orders.stream()
.map(order -> new PalletAllocator.LineInput(order.orderId(), warehouseKey(order), volume(order),
order.packageCount(), order.recommendedPalletCount())).toList()) : null;
if (allocation != null && sourcePalletNos != null && !sourcePalletNos.isEmpty()
&& sourcePalletNos.size() != allocation.pallets().size()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "头程托盘码数量与尾程计划托盘数不一致");
}
if (wholePallets != null) validateWholePallets(orders, wholePallets);
plan.setPalletCount(manual != null ? manual.palletCount()
: wholePallets == null ? allocation.pallets().size() : wholePallets.size());
@@ -479,6 +492,7 @@ public class InboundPlanService {
List<Integer> pendingIndexes = new ArrayList<>();
List<List<SourceOrderSnapshot>> snapshots = new ArrayList<>();
List<List<WholePalletInput>> wholePallets = new ArrayList<>();
List<List<String>> standardPalletNos = new ArrayList<>();
for (int index = 0; index < refs.size(); index++) {
InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(index);
InboundPlan existing = existingPlans.get(index);
@@ -517,6 +531,11 @@ public class InboundPlanService {
validateWholePallets(orders, sourcePallets);
}
wholePallets.add(sourcePallets);
List<String> standardNos = "ONE_CLICK_DROPSHIP".equals(ref.orderType()) ? List.of()
: sourcePalletMapper.palletNos(ref.customerId(), ref.orderId());
if (sourcePallets == null && standardNos.isEmpty()) throw new ResponseStatusException(
HttpStatus.CONFLICT, "散货订单缺少头程托盘码快照");
standardPalletNos.add(standardNos);
}
requireSingleWarehouseId(warehouseIds);
@@ -531,7 +550,7 @@ public class InboundPlanService {
Map<Integer, WarehouseResourceGateway.PalletBatchItem> reserved = prepared == null ? Map.of()
: prepared.pallets().stream().collect(Collectors.toMap(PreparedPallet::sequenceNo,
PreparedPallet::reservation));
created.put(refIndex, createPlan(ref.customerId(), ref.orderId(), snapshots.get(pendingIndex), List.of(),
created.put(refIndex, createPlan(ref.customerId(), ref.orderId(), snapshots.get(pendingIndex), standardPalletNos.get(pendingIndex),
wholePallets.get(pendingIndex), null, null, prepared, reserved));
}
List<InboundPlanResponse> result = new ArrayList<>(refs.size());
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.mapper;
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 java.util.List;
@Mapper
public interface LastlegSourcePalletMapper {
@Insert("""
INSERT INTO ll_order_source_pallet
(id,customer_id,order_id,source_pallet_no,sequence_no,source_event_id,created_at)
VALUES (#{id},#{customerId},#{orderId},#{palletNo},#{sequenceNo},#{eventId},CURRENT_TIMESTAMP(3))
""")
int insert(@Param("id") long id, @Param("customerId") String customerId,
@Param("orderId") long orderId, @Param("palletNo") String palletNo,
@Param("sequenceNo") int sequenceNo, @Param("eventId") String eventId);
@Select("""
SELECT source_pallet_no FROM ll_order_source_pallet
WHERE customer_id=#{customerId} AND order_id=#{orderId}
ORDER BY sequence_no,id
""")
List<String> palletNos(@Param("customerId") String customerId, @Param("orderId") long orderId);
}
@@ -45,6 +45,7 @@ public class LoadingCompletedConsumer {
@Resource private DropshipOrderService dropshipOrders;
@Resource private InventoryCommandService inventory;
@Resource private ContainerShippingGateMapper shippingGates;
@Resource private LastlegSourcePalletMapper sourcePallets;
@Transactional(isolation = Isolation.READ_COMMITTED)
public int consume(EventEnvelope envelope) {
@@ -278,6 +279,20 @@ public class LoadingCompletedConsumer {
Map<String, List<String>> dropshipLabels = "ONE_CLICK_DROPSHIP".equals(orderType)
&& "OVERSEAS".equals(destinationType) ? dropshipReceivingLabels(source, rawSource) : Map.of();
if ("FIRST_LEG".equals(orderType) && event.eventVersion() >= 2) {
List<FirstLegLoadingPlanCompletedV1.PalletSnapshot> pallets = source.pallets() == null ? List.of() : source.pallets();
Set<String> palletNos = new HashSet<>();
for (int palletIndex = 0; palletIndex < pallets.size(); palletIndex++) {
String palletNo = pallets.get(palletIndex).palletNo();
if (palletNo == null || palletNo.isBlank() || palletNo.length() > 128 || !palletNos.add(palletNo)) {
throw new IllegalArgumentException("散货来源托盘码为空、重复或过长");
}
sourcePallets.insert(IdWorker.getId(), customerId, order.getId(), palletNo.trim(), palletIndex + 1, event.eventId());
}
if (!pallets.isEmpty() && order.getPalletCount() > 0 && pallets.size() != order.getPalletCount()) {
throw new IllegalArgumentException("散货来源托盘码数量与托盘数不一致");
}
}
for(var item:source.items()) {
LastlegOrderDetail detail=new LastlegOrderDetail();
detail.setCustomerId(customerId);
@@ -112,6 +112,22 @@ CREATE TABLE IF NOT EXISTS ll_order (
KEY idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)
) ENGINE=InnoDB COMMENT='尾程订单主表';
CREATE TABLE IF NOT EXISTS ll_order_source_pallet (
id BIGINT NOT NULL COMMENT '来源托盘快照ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
order_id BIGINT NOT NULL COMMENT '尾程订单ID',
source_pallet_no VARCHAR(128) NOT NULL COMMENT '头程冻结托盘码',
sequence_no INT NOT NULL COMMENT '订单内稳定顺序',
source_event_id VARCHAR(128) NOT NULL COMMENT '来源装柜完成事件ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY(id),
UNIQUE KEY uk_ll_source_pallet_no(source_pallet_no),
UNIQUE KEY uk_ll_source_pallet_sequence(customer_id,order_id,sequence_no),
KEY idx_ll_source_pallet_order(customer_id,order_id),
CONSTRAINT fk_ll_source_pallet_order FOREIGN KEY(order_id) REFERENCES ll_order(id)
) ENGINE=InnoDB COMMENT='头程交接的不可变托盘码快照';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='pallet_merge_batch_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN pallet_merge_batch_id VARCHAR(64) NULL COMMENT ''平台预标记合托分组号'' AFTER pallet_merge_flag'));
PREPARE ll_order_merge_batch_ddl FROM @ddl; EXECUTE ll_order_merge_batch_ddl; DEALLOCATE PREPARE ll_order_merge_batch_ddl;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='hold_chargeable'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN hold_chargeable TINYINT NOT NULL DEFAULT 0 COMMENT ''当前暂扣是否计费:0否1是'' AFTER detained'));
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.pda.gateway;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.http.HttpStatus;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
@@ -116,6 +117,23 @@ class HttpWarehouseResourceGatewayTest {
server.verify();
}
@Test
void resolveReservedFallsBackToConfirmedPhysicalBindingForEarlierPlanDisplayCode() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/IP-ACY2-20260909-166786-P001"))
.andRespond(withStatus(HttpStatus.NOT_FOUND));
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/id/31"))
.andRespond(withSuccess("""
{"code":"200","data":{"palletId":31,"palletCode":"PLT-US-LAX-01-31",
"warehouseId":21,"locationId":41,"status":"OCCUPIED","version":3,
"reservable":false,"reservationBlockReason":"PALLET_NOT_IDLE"}}
""", MediaType.APPLICATION_JSON));
var result = gateway.resolveReservedPallet("IP-ACY2-20260909-166786-P001", "11", 21L, 31L, 41L, 51L);
assertThat(result.palletId()).isEqualTo(31L);
server.verify();
}
@Test
void resolveReservedRejectsDifferentPhysicalBinding() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-RESERVED"))
@@ -221,7 +239,7 @@ class HttpWarehouseResourceGatewayTest {
var result = gateway.ensurePalletBatch(new WarehouseResourceGateway.PalletBatchEnsure(31L, "BATCH-1",
List.of(new WarehouseResourceGateway.PalletBatchItemRequest(
100L, "CIP:1:1", "IP-21", 1, "STANDARD", "ONT8"))));
100L, "CIP:1:1", "IP-21", 1, "STANDARD", "ONT8", "CONT-ONT8-001"))));
assertThat(result.batchId()).isEqualTo(41L);
assertThat(result.items().get(0).resourceLockId()).isEqualTo(71L);
@@ -117,7 +117,7 @@ class ContainerInboundPlanConversionCoordinatorTest {
@Test
void reservationMustMatchEveryStableItemIdentity() throws Exception {
var expected = new ContainerInboundPlanConversionCoordinator.PreparedItem(
"100", 11L, 21L, "IP-21", 1, 1, "CIP:1:1", "ONT8");
"100", 11L, 21L, "IP-21", 1, 1, "CIP:1:1", "ONT8", "CONT-ONT8-001");
var preparation = new ContainerInboundPlanConversionCoordinator.Preparation(31L, List.of(expected));
var response = new WarehouseResourceGateway.PalletBatchResponse(41L, "BATCH-1", "RESERVED", 1,
false, List.of(new WarehouseResourceGateway.PalletBatchItem(
@@ -239,6 +239,8 @@ class ContainerInboundPlanConversionCoordinatorTest {
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview);
when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class)))
.thenReturn("IP-FBA", "IP-OVERSEAS");
when(plans.sourcePalletNos("100", 11L, "ONE_CLICK_DROPSHIP")).thenReturn(List.of("MSCU6639870-ABE3-001"));
when(plans.sourcePalletNos("200", 12L, "ONE_CLICK_DROPSHIP")).thenReturn(List.of("MSCU6639870-US-LAX-01-001"));
ContainerPlanConversionCommand command = ReflectionTestUtils.invokeMethod(coordinator, "prepareCommand",
1L, 2L, BOOKING_ID, "FIRST-KEY", request,
@@ -291,7 +293,7 @@ class ContainerInboundPlanConversionCoordinatorTest {
private void setOnePreparedItem(ContainerPlanConversionCommand command) throws Exception {
var item = new ContainerInboundPlanConversionCoordinator.PreparedItem(
"100", 11L, 21L, "IP-21", 1, 1, "CIP:1:1", "ONT8");
"100", 11L, 21L, "IP-21", 1, 1, "CIP:1:1", "ONT8", "CONT-ONT8-001");
command.setPreparationJson(objectMapper.writeValueAsString(
new ContainerInboundPlanConversionCoordinator.Preparation(31L, List.of(item))));
}
@@ -18,6 +18,7 @@ import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot;
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.mapper.LastlegOrderCommandRecordMapper;
import com.baiyue.lastleg.mapper.LastlegSourcePalletMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -38,6 +39,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
@@ -55,6 +57,7 @@ class InboundPlanServiceTest {
@Mock DropshipOrderService dropshipOrders;
@Mock WarehouseResourceGateway warehouseGateway;
@Mock LastlegOrderCommandRecordMapper commandMapper;
@Mock LastlegSourcePalletMapper sourcePalletMapper;
private InboundPlanService service;
@@ -70,11 +73,14 @@ class InboundPlanServiceTest {
ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders);
ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway);
ReflectionTestUtils.setField(service, "commandMapper", commandMapper);
ReflectionTestUtils.setField(service, "sourcePalletMapper", sourcePalletMapper);
ReflectionTestUtils.setField(service, "objectMapper", JsonMapper.builder().findAndAddModules().build());
ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator());
org.mockito.Mockito.lenient().when(lineMapper.selectList(any())).thenReturn(List.of());
org.mockito.Mockito.lenient().when(palletMapper.selectList(any())).thenReturn(List.of());
org.mockito.Mockito.lenient().when(allocationMapper.selectList(any())).thenReturn(List.of());
org.mockito.Mockito.lenient().when(sourcePalletMapper.palletNos(any(), anyLong()))
.thenAnswer(invocation -> List.of("MSCU6639870-WH-" + invocation.getArgument(1)));
org.mockito.Mockito.lenient().when(orderMapper.findReceivingWarehouse(any(), any())).thenReturn(
new InboundPlanOrderMapper.ReceivingWarehouseSnapshot(
"193000000000200003", "US-LAX-01", "洛杉矶仓"));