入库修改

This commit is contained in:
2026-08-20 13:34:34 +08:00
parent 3b19a1bc16
commit b139255d3b
24 changed files with 528 additions and 62 deletions
@@ -155,6 +155,8 @@ Integration 负责百度 OCR、重量识别和 Amazon Logistics/SP-API 等第三
Event Bridge 负责接收国内外服务器之间的必要业务事件,并通过 Inbox 实现幂等。事件载荷遵循 `common-event-contract`,包含事件 ID、来源和目标区域、业务键、版本、时间及 Payload 哈希;头程与尾程不共享 MySQL、Redis、Nacos 或内部接口。
头程装柜完成事件以实际集装箱为顶层批次,`orders[]` 中每项是一张实际发运的来源入仓单;一个柜可包含多张入仓单。每个货物明细携带来源明细 ID、实际装运箱数和完整的国内箱唛池。国内不要求在装柜时逐箱扫描冻结,完整箱唛池可以大于本柜实际装运箱数;海外按实际首次扫描的箱唛确认归属和实收,未扫描箱唛保留给后续子订单。海外将同柜各来源入仓单分别生成入库计划,但以同一集装箱号关联,并且只允许同一目的仓的箱子合托。
## 6. 配置、部署和辅助目录
### `config/`
@@ -0,0 +1,21 @@
package com.baiyue.firstleg.mapper;
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 LoadingCartonManifestMapper {
@Select("""
SELECT box_code FROM fl_inbound_carton_label
WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND inbound_detail_id=#{inboundDetailId}
AND batch_status='ACTIVE'
ORDER BY carton_index,id
""")
List<String> labelPool(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("inboundDetailId") long inboundDetailId);
}
@@ -21,7 +21,7 @@ public interface LoadingCompletedEventMapper {
COALESCE(root_order.order_id, o.order_id) AS root_order_id,
o.inbound_code, o.business_type, o.destination_warehouse_id, o.destination_code AS destination_warehouse_code,
o.destination_name AS destination_warehouse_name,
i.inbound_detail_id AS source_inbound_detail_id, i.sku_code,
i.id AS loading_plan_item_id, i.inbound_detail_id AS source_inbound_detail_id, i.sku_code,
i.product_name_zh, i.product_name_en, i.package_count, i.product_quantity,
i.pallet_count, i.gross_weight_kg, i.net_weight_kg, i.dimensional_weight_kg,
i.volume_cbm, i.fba_tracking_no, i.shipment_no, i.shipping_mark
@@ -65,6 +65,7 @@ public interface LoadingCompletedEventMapper {
private Integer aggregateVersion; private String containerNo; private String cabinetNo;
private Long warehouseId; private String warehouseName; private Long accountId;
private Long sourceInboundId; private Long rootInboundId; private Long sourceOrderId;
private Long loadingPlanItemId;
private String sourceOrderNo; private Long rootOrderId; private String inboundCode;
private Long destinationWarehouseId; private String destinationWarehouseCode;
private String destinationWarehouseName; private Long sourceInboundDetailId; private String skuCode;
@@ -15,6 +15,7 @@ import com.baiyue.firstleg.mapper.InboundOutboxMapper;
import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper;
import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row;
import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow;
import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -37,26 +38,29 @@ import java.util.Objects;
public class LoadingCompletedOutboxService {
private static final long HANDOVER_VERSION = 2L;
@Resource private LoadingCompletedEventMapper snapshotMapper;
@Resource private LoadingCartonManifestMapper cartonManifestMapper;
@Resource private InboundOutboxMapper outboxMapper;
@Resource private ObjectMapper objectMapper;
public void publish(long planId) {
List<Row> rows = snapshotMapper.rows(planId);
if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件");
Map<Long, List<String>> cartonMarks = cartonLabelPools(rows);
List<PalletRow> palletRows = snapshotMapper.palletRows(planId);
rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId,
java.util.TreeMap::new, java.util.stream.Collectors.toList()))
.forEach((accountId, accountRows) -> insert(envelope(accountId, accountRows,
palletRows.stream().filter(item -> accountId.equals(item.getAccountId())).toList())));
palletRows.stream().filter(item -> accountId.equals(item.getAccountId())).toList(), cartonMarks)));
}
private EventEnvelope envelope(long accountId, List<Row> rows, List<PalletRow> palletRows) {
private EventEnvelope envelope(long accountId, List<Row> rows, List<PalletRow> palletRows,
Map<Long, List<String>> cartonMarks) {
Row first = rows.get(0);
Map<Long, List<Row>> byInbound = rows.stream().collect(java.util.stream.Collectors.groupingBy(
Row::getSourceInboundId, java.util.TreeMap::new, java.util.stream.Collectors.toList()));
List<FirstLegLoadingPlanCompletedV2.Order> orders = byInbound.values().stream()
.map(value -> order(value, palletRows.stream()
.filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList())).toList();
.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);
@@ -70,7 +74,8 @@ public class LoadingCompletedOutboxService {
traceId, eventKey, null, payloadNode, hash).requireValid();
}
private FirstLegLoadingPlanCompletedV2.Order order(List<Row> values, List<PalletRow> palletRows) {
private FirstLegLoadingPlanCompletedV2.Order order(List<Row> values, List<PalletRow> palletRows,
Map<Long, List<String>> cartonMarks) {
List<Row> sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList();
Row first = sorted.get(0);
List<FirstLegLoadingPlanCompletedV2.Item> items = sorted.stream().map(row ->
@@ -79,7 +84,8 @@ public class LoadingCompletedOutboxService {
row.getPalletCount(), row.getGrossWeightKg(), row.getNetWeightKg(), row.getDimensionalWeightKg(),
row.getVolumeCbm(), string(row.getDestinationWarehouseId()), row.getDestinationWarehouseCode(),
row.getDestinationWarehouseName(), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark(),
row.getCustomerRemark(), row.getInternalRemark(), row.getWarehouseRemark()))
row.getCustomerRemark(), row.getInternalRemark(), row.getWarehouseRemark(), null,
cartonMarks.getOrDefault(row.getLoadingPlanItemId(), List.of())))
.toList();
String orderType = dropship(first.getBusinessType()) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG";
List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> pallets = dropship(first.getBusinessType())
@@ -96,6 +102,21 @@ public class LoadingCompletedOutboxService {
sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), orderType, pallets);
}
/**
* 国内只确认本柜实装箱数,不扫每张箱唛。事件携带全量可用标签池,海外首扫才确权具体箱唛。
*/
private Map<Long, List<String>> cartonLabelPools(List<Row> rows) {
Map<Long, List<String>> result = new java.util.LinkedHashMap<>();
for (Row row : rows) {
if (dropship(row.getBusinessType())) continue;
List<String> labels = Objects.requireNonNullElse(cartonManifestMapper.labelPool(row.getAccountId(),
row.getSourceInboundId(), row.getSourceInboundDetailId()), List.of());
if (labels.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜明细没有已打印的可用箱唛池,不能生成海外交接事件");
result.put(row.getLoadingPlanItemId(), labels);
}
return result;
}
private List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> palletSnapshots(List<Row> values, List<PalletRow> source) {
if (source.isEmpty()) return List.of();
Map<String, List<PalletRow>> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy(
@@ -7,6 +7,7 @@ import com.baiyue.firstleg.mapper.InboundOutboxMapper;
import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper;
import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row;
import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow;
import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -19,19 +20,23 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class LoadingCompletedOutboxServiceTest {
private final LoadingCompletedEventMapper snapshots = mock(LoadingCompletedEventMapper.class);
private final InboundOutboxMapper outbox = mock(InboundOutboxMapper.class);
private final LoadingCartonManifestMapper manifests = mock(LoadingCartonManifestMapper.class);
private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
private final LoadingCompletedOutboxService service = new LoadingCompletedOutboxService();
@BeforeEach void setup() {
ReflectionTestUtils.setField(service,"snapshotMapper",snapshots);
ReflectionTestUtils.setField(service,"cartonManifestMapper",manifests);
ReflectionTestUtils.setField(service,"outboxMapper",outbox);
ReflectionTestUtils.setField(service,"objectMapper",mapper);
when(manifests.labelPool(anyLong(), anyLong(), anyLong())).thenReturn(List.of("BOX-POOL-1"));
}
@Test void groupsByAccountAndKeepsAllOrdersAndItemsStable() throws Exception {
@@ -87,12 +92,13 @@ class LoadingCompletedOutboxServiceTest {
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.setSourceOrderId(inbound+1000); r.setSourceOrderNo("FL"+inbound); r.setRootOrderId(1099L);
r.setInboundCode("RK"+inbound); r.setDestinationWarehouseId(93L); r.setDestinationWarehouseCode("ONT8");
r.setInboundCode("RK"+inbound); r.setLoadingPlanItemId(detail); r.setDestinationWarehouseId(93L); r.setDestinationWarehouseCode("ONT8");
r.setDestinationWarehouseName("ONT8"); r.setSourceInboundDetailId(detail); r.setSkuCode("SKU"+detail);
r.setPackageCount(1); r.setProductQuantity(10); r.setPalletCount(1); r.setGrossWeightKg(BigDecimal.ONE);
r.setNetWeightKg(BigDecimal.ONE); 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);
@@ -6,6 +6,7 @@ import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest;
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
import io.swagger.v3.oas.annotations.Operation;
@@ -25,7 +26,7 @@ public class InboundPlanController {
@Resource private InboundFbaAppointmentService appointmentService;
@PostMapping("/convert")
@Operation(summary = "Convert the entire container to one inbound plan")
@Operation(summary = "Convert one inbound order to one inbound plan")
public ApiResponse<InboundPlanResponse> convert(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ConvertInboundPlanRequest request) {
return ApiResponse.success(service.convert(scope(user, request.customerId()), request.orderId()));
@@ -39,6 +40,14 @@ public class InboundPlanController {
return ApiResponse.success(service.detailByPlanNo(scope(user, customerId), planNo));
}
@PostMapping("/{planNo}/pallets/supplement")
@Operation(summary = "Add one printable supplemental pallet for a plan destination warehouse")
public ApiResponse<InboundPlanResponse.Pallet> supplementPallet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@Valid @RequestBody SupplementPalletRequest request) {
return ApiResponse.success(service.supplementPallet(scope(user, request.customerId()), planNo, request));
}
@PostMapping("/fba-appointments/batch")
@Operation(summary = "Create one FBA appointment for selected inbound lines")
public ApiResponse<FbaAppointmentResponse> batchFbaAppointment(
@@ -8,6 +8,7 @@ public record InboundPlanResponse(
Long id,
String planNo,
String customerId,
Long sourceOrderId,
String containerNo,
String status,
int lineCount,
@@ -0,0 +1,7 @@
package com.baiyue.lastleg.inboundplan.dto;
import jakarta.validation.constraints.NotBlank;
/** 托盘纸不足时,按计划内目的仓补打一张空托盘标签。 */
public record SupplementPalletRequest(@NotBlank String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, String customerId) {}
@@ -15,6 +15,7 @@ public class InboundPlan {
private Long id;
private String planNo;
private String customerId;
private Long sourceOrderId;
private String containerNo;
private String status;
private Integer lineCount;
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.inboundplan.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** 本柜在国内冻结、由海外首次扫码确权的箱级作业事实。 */
@Data
@TableName("ll_inbound_plan_carton")
public class InboundPlanCarton {
@TableId(type = IdType.INPUT)
private Long id;
private String customerId;
private Long orderId;
private String sourceInboundDetailId;
private String skuCode;
private String cartonMark;
private String destinationWarehouseId;
private String destinationWarehouseCode;
private String destinationWarehouseName;
private Long planId;
private Long lineId;
private Long planPalletId;
private Long warehousePalletId;
private Long warehouseLocationId;
private Long pdaTaskId;
private String status;
private Integer version;
private LocalDateTime boundAt;
private LocalDateTime receivedAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,34 @@
package com.baiyue.lastleg.inboundplan.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface InboundPlanCartonMapper extends BaseMapper<InboundPlanCarton> {
@Select("""
SELECT l.* FROM ll_inbound_plan_line l
JOIN ll_order_detail d ON d.order_id=l.order_id
WHERE l.plan_id=#{planId}
AND d.source_inbound_detail_id=#{sourceInboundDetailId}
LIMIT 1
""")
com.baiyue.lastleg.inboundplan.entity.InboundPlanLine findPlanLine(@Param("planId") Long planId,
@Param("sourceInboundDetailId") String sourceInboundDetailId);
@Select("""
SELECT COUNT(*) FROM ll_inbound_plan_carton
WHERE plan_id=#{planId} AND line_id=#{lineId} AND status='ON_PALLET'
""")
long scannedCount(@Param("planId") Long planId, @Param("lineId") Long lineId);
@Select("""
SELECT EXISTS(SELECT 1 FROM ll_inbound_plan_line l
JOIN ll_order_detail d ON d.order_id=l.order_id
JOIN ll_inbound_plan_carton c ON c.source_inbound_detail_id=d.source_inbound_detail_id
WHERE l.plan_id=#{planId})
""")
boolean hasLabelPool(@Param("planId") Long planId);
}
@@ -8,6 +8,6 @@ import org.apache.ibatis.annotations.Select;
@Mapper
public interface InboundPlanMapper extends BaseMapper<InboundPlan> {
@Select("SELECT * FROM ll_inbound_plan WHERE customer_id=#{customerId} AND container_no=#{containerNo} LIMIT 1")
InboundPlan findByContainer(@Param("customerId") String customerId, @Param("containerNo") String containerNo);
@Select("SELECT * FROM ll_inbound_plan WHERE customer_id=#{customerId} AND source_order_id=#{sourceOrderId} LIMIT 1")
InboundPlan findBySourceOrder(@Param("customerId") String customerId, @Param("sourceOrderId") Long sourceOrderId);
}
@@ -29,12 +29,12 @@ public interface InboundPlanOrderMapper {
"<> COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) THEN 1 ELSE 0 END) " +
"AS destinationMismatchCount " +
"FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id " +
"WHERE o.customer_id=#{customerId} AND o.container_no=#{containerNo} " +
"WHERE o.customer_id=#{customerId} AND o.id=#{orderId} " +
"GROUP BY o.id,o.customer_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.fba_warehouse_code," +
"o.transfer_warehouse_id,o.transfer_warehouse_code,o.transfer_warehouse_name " +
"ORDER BY o.id FOR UPDATE")
List<SourceOrderSnapshot> lockContainerOrders(@Param("customerId") String customerId,
@Param("containerNo") String containerNo);
List<SourceOrderSnapshot> lockOrder(@Param("customerId") String customerId,
@Param("orderId") Long orderId);
@Update("UPDATE ll_order SET inbound_order_no=#{planNo},current_status='INBOUND_PLANNED'," +
"pallet_merge_flag='MERGED',pallet_count=#{palletCount},updated_at=#{updatedAt} " +
@@ -62,6 +62,24 @@ public class InboundPdaController {
return ApiResponse.success(service.pick(scope(user, customerId), planNo, allocationId, request));
}
@PostMapping("/cartons/scan")
public ApiResponse<PdaResponses.Carton> scanCarton(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@RequestParam(required = false) String customerId,
@Valid @RequestBody PdaRequests.ScanCarton request) {
requireOperate(user);
return ApiResponse.success(service.scanCarton(scope(user, customerId), planNo, request));
}
@PostMapping("/cartons/remove")
public ApiResponse<PdaResponses.Carton> removeCarton(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@RequestParam(required = false) String customerId,
@Valid @RequestBody PdaRequests.RemoveCarton request) {
requireOperate(user);
return ApiResponse.success(service.removeCarton(scope(user, customerId), planNo, request));
}
@GetMapping("/allocations/{allocationId}/merge-candidates")
public ApiResponse<List<PdaResponses.MergeCandidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo,
@@ -16,4 +16,10 @@ public final class PdaRequests {
public record ConfirmMerge(@Positive long sourceAllocationId, @Positive int packageCount,
@NotBlank String idempotencyKey, String operatorId, String operatorName) {}
public record ScanCarton(@NotBlank String cartonMark, @Positive long planPalletId,
@NotBlank String idempotencyKey, String operatorId, String operatorName) {}
public record RemoveCarton(@NotBlank String cartonMark, @NotBlank String idempotencyKey,
String operatorId, String operatorName) {}
}
@@ -23,4 +23,7 @@ public final class PdaResponses {
public record Merge(String result, String mergeGroupNo, long sourceOrderId, long targetOrderId,
int movedPackageCount, BigDecimal movedVolumeCbm,
boolean sourcePalletScrapped) {}
public record Carton(String result, String cartonMark, long planPalletId, long allocationId,
int palletActualPackageCount) {}
}
@@ -9,6 +9,8 @@ import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
@@ -29,6 +31,7 @@ public class PdaWorkflowRepository {
@Resource private InboundPdaTaskMapper tasks;
@Resource private InboundPdaOperationMapper operations;
@Resource private InboundPalletMergeMapper merges;
@Resource private InboundPlanCartonMapper cartons;
public InboundPlan lockPlan(String customerId, String planNo) {
return plans.selectOne(new LambdaQueryWrapper<InboundPlan>()
@@ -45,6 +48,27 @@ public class PdaWorkflowRepository {
.eq(InboundPlanPalletAllocation::getId, allocationId).last("FOR UPDATE"));
}
public InboundPlanPalletAllocation lockAllocation(Long planId, Long lineId, Long palletId) {
return allocations.selectOne(new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getPlanId, planId).eq(InboundPlanPalletAllocation::getLineId, lineId)
.eq(InboundPlanPalletAllocation::getPalletId, palletId).last("FOR UPDATE"));
}
public InboundPlanCarton lockCarton(String customerId, String cartonMark) {
return cartons.selectOne(new LambdaQueryWrapper<InboundPlanCarton>()
.eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, cartonMark)
.last("FOR UPDATE"));
}
public List<InboundPlanCarton> cartons(Long planId) {
return cartons.selectList(new LambdaQueryWrapper<InboundPlanCarton>().eq(InboundPlanCarton::getPlanId, planId));
}
public InboundPlanLine lineForCarton(Long planId, InboundPlanCarton carton) {
return cartons.findPlanLine(planId, carton.getSourceInboundDetailId());
}
public long scannedCartons(Long planId, Long lineId) { return cartons.scannedCount(planId, lineId); }
public boolean hasCartonLabelPool(Long planId) { return cartons.hasLabelPool(planId); }
public InboundPlanPallet lockPallet(Long planId, Long palletId) {
return pallets.selectOne(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getPlanId, planId).eq(InboundPlanPallet::getId, palletId).last("FOR UPDATE"));
@@ -76,4 +100,5 @@ public class PdaWorkflowRepository {
public void update(InboundPdaTask value) { tasks.updateById(value); }
public void update(InboundPlanPallet value) { pallets.updateById(value); }
public void update(InboundPlanPalletAllocation value) { allocations.updateById(value); }
public void update(InboundPlanCarton value) { cartons.updateById(value); }
}
@@ -5,6 +5,7 @@ import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests;
import com.baiyue.lastleg.inboundplan.pda.dto.PdaResponses;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge;
@@ -99,9 +100,13 @@ public class InboundPdaWorkflowService {
&& Objects.equals(pallet.getWarehouseLocationId(), request.locationId())) return;
throw conflict("Planned pallet is already assigned to another warehouse resource");
}
String businessId = planNo + ":" + planPalletId;
String destinationKey = destinationKey(pallet);
String businessId = plan.getContainerNo() + ":" + destinationKey;
// A container is unloaded by destination pallet, not by inbound-plan pallet.
// The stable reservation key lets each plan from the same container reuse it.
String reservationKey = "INBOUND_CONTAINER_DESTINATION_PALLET:" + businessId;
long lockId = warehouse.reserve(customerId, request.warehouseId(), request.palletId(), request.locationId(),
"INBOUND_PLAN_PALLET", businessId, request.idempotencyKey());
"INBOUND_CONTAINER_DESTINATION_PALLET", businessId, reservationKey);
pallet.setWarehousePalletId(request.palletId());
pallet.setWarehouseLocationId(request.locationId());
pallet.setWarehouseResourceLockId(lockId);
@@ -128,6 +133,7 @@ public class InboundPdaWorkflowService {
public PdaResponses.Operation count(String customerId, String planNo, long allocationId,
PdaRequests.PackageOperation request) {
InboundPlan plan = requirePlan(customerId, planNo);
requireLegacyCounting(plan);
InboundPdaOperation replay = replay(customerId, "COUNT", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Operation.class);
InboundPdaTask task = requireActiveTask(customerId, plan);
@@ -155,6 +161,7 @@ public class InboundPdaWorkflowService {
public PdaResponses.Operation pick(String customerId, String planNo, long allocationId,
PdaRequests.PackageOperation request) {
InboundPlan plan = requirePlan(customerId, planNo);
requireLegacyCounting(plan);
InboundPdaOperation replay = replay(customerId, "PICK", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Operation.class);
InboundPdaTask task = requireActiveTask(customerId, plan);
@@ -193,6 +200,90 @@ public class InboundPdaWorkflowService {
return result;
}
/** 海外首次扫描箱唛:仅接受本柜国内冻结且已归属本计划的箱。 */
@Transactional
public PdaResponses.Carton scanCarton(String customerId, String planNo, PdaRequests.ScanCarton request) {
InboundPlan plan = requirePlan(customerId, planNo);
InboundPdaOperation replay = replay(customerId, "SCAN_CARTON", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Carton.class);
InboundPdaTask task = requireActiveTask(customerId, plan);
InboundPlanCarton carton = repository.lockCarton(customerId, request.cartonMark());
if (carton == null || (carton.getPlanId() != null && !Objects.equals(carton.getPlanId(), plan.getId()))) {
throw notFound("Carton mark is not available to this inbound plan");
}
if (!("UNASSIGNED".equals(carton.getStatus()) || "EXPECTED".equals(carton.getStatus()))) {
throw conflict("Carton mark has already been scanned; remove it before scanning again");
}
InboundPlanPallet pallet = requirePallet(plan.getId(), request.planPalletId());
if (isNotAssigned(pallet)) throw conflict("Planned pallet must be assigned and locked before carton scanning");
if (!sameWarehouse(carton, pallet)) throw conflict("Carton and pallet destination warehouses must match");
InboundPlanLine line = carton.getLineId() == null
? repository.lineForCarton(plan.getId(), carton)
: requireLine(plan.getId(), carton.getLineId());
if (line == null) throw notFound("Carton mark does not belong to cargo in this inbound plan");
if (repository.scannedCartons(plan.getId(), line.getId()) >= value(line.getRequiredPackageCount())) {
throw conflict("This inbound plan line has reached its required carton count");
}
InboundPlanPalletAllocation allocation = repository.lockAllocation(plan.getId(), line.getId(), pallet.getId());
if (allocation == null) {
allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId()); allocation.setPlanId(plan.getId());
allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(ZERO_VOLUME);
allocation.setPlannedPackageCount(0); allocation.setCountedPackageCount(0); allocation.setPickedPackageCount(0);
allocation.setActualVolumeCbm(ZERO_VOLUME); allocation.setStatus("PICKING"); allocation.setVersion(0); repository.insert(allocation);
}
BigDecimal perPackage = perPackageVolume(line);
carton.setOrderId(line.getOrderId()); carton.setPlanId(plan.getId()); carton.setLineId(line.getId());
carton.setPlanPalletId(pallet.getId()); carton.setWarehousePalletId(pallet.getWarehousePalletId());
carton.setWarehouseLocationId(pallet.getWarehouseLocationId()); carton.setPdaTaskId(task.getId());
carton.setStatus("ON_PALLET"); carton.setBoundAt(LocalDateTime.now()); carton.setUpdatedAt(LocalDateTime.now());
carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton);
allocation.setCountedPackageCount(value(allocation.getCountedPackageCount()) + 1);
allocation.setPickedPackageCount(value(allocation.getPickedPackageCount()) + 1);
allocation.setActualVolumeCbm(volume(allocation.getActualVolumeCbm()).add(perPackage).setScale(4, RoundingMode.HALF_UP));
allocation.setStatus(value(allocation.getPickedPackageCount()) >= value(allocation.getPlannedPackageCount()) ? "PICKED" : "PICKING");
bump(allocation); repository.update(allocation);
pallet.setActualPackageCount(value(pallet.getActualPackageCount()) + 1);
pallet.setActualVolumeCbm(volume(pallet.getActualVolumeCbm()).add(perPackage).setScale(4, RoundingMode.HALF_UP));
repository.update(pallet); startPicking(task, plan, request.operatorId(), request.operatorName());
PdaResponses.Carton result = new PdaResponses.Carton("SCANNED", carton.getCartonMark(), pallet.getId(),
allocation.getId(), pallet.getActualPackageCount());
saveOperation(customerId, task.getId(), allocation.getId(), "SCAN_CARTON", request.idempotencyKey(), request, result);
return result;
}
@Transactional
public PdaResponses.Carton removeCarton(String customerId, String planNo, PdaRequests.RemoveCarton request) {
InboundPlan plan = requirePlan(customerId, planNo);
InboundPdaOperation replay = replay(customerId, "REMOVE_CARTON", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Carton.class);
InboundPdaTask task = requireActiveTask(customerId, plan);
InboundPlanCarton carton = repository.lockCarton(customerId, request.cartonMark());
if (carton == null || !Objects.equals(carton.getPlanId(), plan.getId())) throw notFound("Carton mark is not expected by this inbound plan");
if (!"ON_PALLET".equals(carton.getStatus()) || carton.getPlanPalletId() == null) throw conflict("Only a carton currently on a pallet may be removed");
InboundPlanPallet pallet = requirePallet(plan.getId(), carton.getPlanPalletId());
InboundPlanLine line = requireLine(plan.getId(), carton.getLineId());
InboundPlanPalletAllocation allocation = repository.lockAllocation(plan.getId(), line.getId(), pallet.getId());
if (allocation == null || value(allocation.getPickedPackageCount()) <= 0 || value(pallet.getActualPackageCount()) <= 0) {
throw conflict("Carton pallet allocation is inconsistent");
}
BigDecimal perPackage = perPackageVolume(line);
allocation.setCountedPackageCount(Math.max(0, value(allocation.getCountedPackageCount()) - 1));
allocation.setPickedPackageCount(value(allocation.getPickedPackageCount()) - 1);
allocation.setActualVolumeCbm(nonNegative(volume(allocation.getActualVolumeCbm()).subtract(perPackage)));
allocation.setStatus(value(allocation.getPickedPackageCount()) == 0 ? "PENDING_COUNT" : "PICKING");
bump(allocation); repository.update(allocation);
pallet.setActualPackageCount(value(pallet.getActualPackageCount()) - 1);
pallet.setActualVolumeCbm(nonNegative(volume(pallet.getActualVolumeCbm()).subtract(perPackage))); repository.update(pallet);
carton.setOrderId(null); carton.setPlanId(null); carton.setLineId(null); carton.setPlanPalletId(null);
carton.setWarehousePalletId(null); carton.setWarehouseLocationId(null); carton.setPdaTaskId(null);
carton.setStatus("UNASSIGNED"); carton.setBoundAt(null); carton.setVersion(value(carton.getVersion()) + 1);
carton.setUpdatedAt(LocalDateTime.now()); repository.update(carton);
PdaResponses.Carton result = new PdaResponses.Carton("REMOVED", carton.getCartonMark(), pallet.getId(),
allocation.getId(), pallet.getActualPackageCount());
saveOperation(customerId, task.getId(), allocation.getId(), "REMOVE_CARTON", request.idempotencyKey(), request, result);
return result;
}
@Transactional
public List<PdaResponses.MergeCandidate> mergeCandidates(String customerId, String planNo,
long allocationId) {
@@ -205,6 +296,7 @@ public class InboundPdaWorkflowService {
public PdaResponses.Merge merge(String customerId, String planNo, long targetAllocationId,
PdaRequests.ConfirmMerge request) {
InboundPlan plan = requirePlan(customerId, planNo);
requireLegacyCounting(plan);
InboundPdaOperation replay = replay(customerId, "MERGE", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Merge.class);
InboundPdaTask task = requireActiveTask(customerId, plan);
@@ -292,8 +384,16 @@ public class InboundPdaWorkflowService {
throw conflict("All line picked package totals must exactly equal required package totals");
}
}
List<InboundPlanCarton> cartons = repository.cartons(plan.getId());
if (!cartons.isEmpty() && cartons.stream().anyMatch(carton -> !"ON_PALLET".equals(carton.getStatus()))) {
throw conflict("All expected carton marks must be scanned onto a pallet before stock-in");
}
LocalDateTime now = LocalDateTime.now();
stockReceipts.stockIn(customerId, plan, task, lines, allocations, repository.pallets(plan.getId()), now);
for (InboundPlanCarton carton : cartons) {
carton.setStatus("AVAILABLE"); carton.setReceivedAt(now); carton.setUpdatedAt(now);
carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton);
}
task.setStatus("COMPLETED");
task.setCompletedAt(now);
task.setUpdatedAt(now);
@@ -387,6 +487,21 @@ public class InboundPdaWorkflowService {
return leftKey != null && leftKey.equals(rightKey);
}
private boolean sameWarehouse(InboundPlanCarton carton, InboundPlanPallet pallet) {
String cartonKey = carton.getDestinationWarehouseId() != null
? carton.getDestinationWarehouseId() : carton.getDestinationWarehouseCode();
String palletKey = pallet.getDestinationWarehouseId() != null
? pallet.getDestinationWarehouseId() : pallet.getDestinationWarehouseCode();
return cartonKey != null && cartonKey.equals(palletKey);
}
private String destinationKey(InboundPlanPallet pallet) {
String value = pallet.getDestinationWarehouseId() != null
? pallet.getDestinationWarehouseId() : pallet.getDestinationWarehouseCode();
if (value == null || value.isBlank()) throw conflict("Planned pallet destination warehouse is empty");
return value;
}
private void validateDestinationWarehouse(InboundPlanPallet pallet, long warehouseId) {
String destinationId = pallet.getDestinationWarehouseId();
if (destinationId == null || destinationId.isBlank()) return;
@@ -413,6 +528,12 @@ public class InboundPdaWorkflowService {
repository.update(plan);
}
private void requireLegacyCounting(InboundPlan plan) {
if (repository.hasCartonLabelPool(plan.getId())) {
throw conflict("This inbound plan has a carton manifest and must be operated by carton scanning");
}
}
private boolean isNotAssigned(InboundPlanPallet pallet) {
return pallet.getWarehousePalletId() == null || pallet.getWarehouseLocationId() == null
|| pallet.getWarehouseResourceLockId() == null
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
@@ -37,17 +38,13 @@ public class InboundPlanService {
@Transactional
public InboundPlanResponse convert(String customerId, Long selectedOrderId) {
String containerNo = trimToNull(orderMapper.findContainerNo(customerId, selectedOrderId));
if (containerNo == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Order does not exist in this tenant or container number is empty");
List<SourceOrderSnapshot> orders = orderMapper.lockOrder(customerId, selectedOrderId);
if (orders == null || orders.isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound order not found");
}
List<SourceOrderSnapshot> orders = orderMapper.lockContainerOrders(customerId, containerNo);
if (orders.isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Container orders not found");
}
InboundPlan existing = planMapper.findByContainer(customerId, containerNo);
String containerNo = trimToNull(orders.get(0).containerNo());
if (containerNo == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Inbound order container number is empty");
InboundPlan existing = planMapper.findBySourceOrder(customerId, selectedOrderId);
if (existing != null) {
return detail(customerId, existing);
}
@@ -58,6 +55,7 @@ public class InboundPlanService {
plan.setId(IdWorker.getId());
plan.setPlanNo("LIP" + plan.getId());
plan.setCustomerId(customerId);
plan.setSourceOrderId(selectedOrderId);
plan.setContainerNo(containerNo);
plan.setStatus("PENDING_ASSIGNMENT");
plan.setLineCount(orders.size());
@@ -92,11 +90,12 @@ public class InboundPlanService {
throw new IllegalStateException("Failed to update order " + order.orderId());
}
orderMapper.insertHistory(order.orderId(), order.currentStatus(),
"Container converted to inbound plan " + plan.getPlanNo(), now);
"Inbound order converted to inbound plan " + plan.getPlanNo(), now);
}
return detail(customerId, plan);
}
@Transactional(readOnly = true)
public InboundPlanResponse detailByPlanNo(String customerId, String planNo) {
InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper<InboundPlan>()
@@ -107,6 +106,32 @@ public class InboundPlanService {
return detail(customerId, plan);
}
@Transactional
public InboundPlanResponse.Pallet supplementPallet(String customerId, String planNo, SupplementPalletRequest request) {
InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo).last("FOR UPDATE"));
if (plan == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found");
if ("COMPLETED".equals(plan.getStatus())) throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed plan cannot add pallets");
List<InboundPlanLine> lines = lineMapper.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getPlanId, plan.getId()));
boolean destinationExists = lines.stream().anyMatch(line -> request.destinationWarehouseId().equals(line.getDestinationWarehouseId()));
if (!destinationExists) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Destination warehouse is not part of this inbound plan");
int sequence = palletMapper.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getPlanId, plan.getId())).stream().map(InboundPlanPallet::getSequenceNo)
.filter(Objects::nonNull).max(Integer::compareTo).orElse(0) + 1;
InboundPlanPallet pallet = new InboundPlanPallet();
pallet.setId(IdWorker.getId()); pallet.setPlanId(plan.getId()); pallet.setPalletNo(plan.getPlanNo() + "-P" + String.format("%03d", sequence));
pallet.setDestinationWarehouseId(request.destinationWarehouseId()); pallet.setDestinationWarehouseCode(request.destinationWarehouseCode());
pallet.setDestinationWarehouseName(request.destinationWarehouseName()); pallet.setCapacityCbm(PalletAllocator.PALLET_CAPACITY_CBM);
pallet.setAllocatedVolumeCbm(BigDecimal.ZERO); pallet.setSequenceNo(sequence); pallet.setAssignmentStatus("UNASSIGNED");
pallet.setActualVolumeCbm(BigDecimal.ZERO); pallet.setActualPackageCount(0); palletMapper.insert(pallet);
plan.setPalletCount(plan.getPalletCount() + 1); plan.setUpdatedAt(LocalDateTime.now()); planMapper.updateById(plan);
return new InboundPlanResponse.Pallet(pallet.getId(), pallet.getPalletNo(), pallet.getDestinationWarehouseId(),
pallet.getDestinationWarehouseCode(), pallet.getDestinationWarehouseName(), pallet.getCapacityCbm(),
pallet.getAllocatedVolumeCbm(), pallet.getSequenceNo(), null, null, null, pallet.getAssignmentStatus(),
null, pallet.getActualVolumeCbm(), pallet.getActualPackageCount());
}
private Map<Long, InboundPlanLine> createLines(InboundPlan plan, List<SourceOrderSnapshot> orders) {
Map<Long, InboundPlanLine> result = new HashMap<>();
for (SourceOrderSnapshot order : orders) {
@@ -211,7 +236,8 @@ public class InboundPlanService {
new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getPlanId, plan.getId())
.orderByAsc(InboundPlanPalletAllocation::getId));
return new InboundPlanResponse(plan.getId(), plan.getPlanNo(), plan.getCustomerId(), plan.getContainerNo(),
return new InboundPlanResponse(plan.getId(), plan.getPlanNo(), plan.getCustomerId(), plan.getSourceOrderId(),
plan.getContainerNo(),
plan.getStatus(), plan.getLineCount(), plan.getPalletCount(), plan.getTotalVolumeCbm(),
lines.stream().map(line -> new InboundPlanResponse.Line(line.getId(), line.getOrderId(), line.getInboundCode(), line.getFbaWarehouseCode(),
line.getDestinationWarehouseId(), line.getDestinationWarehouseCode(), line.getDestinationWarehouseName(),
@@ -5,6 +5,9 @@ import com.baiyue.lastleg.entity.*;
import com.baiyue.lastleg.mapper.*;
import com.baiyue.lastleg.dropship.dto.DropshipRequests;
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.DeserializationFeature;
import jakarta.annotation.Resource;
@@ -25,6 +28,8 @@ public class LoadingCompletedConsumer {
@Resource private LastlegOrderMapper orderMapper;
@Resource private LastlegOrderDetailMapper detailMapper;
@Resource private LastlegOrderStatusHistoryMapper historyMapper;
@Resource private InboundPlanCartonMapper cartonMapper;
@Resource private InboundPlanService inboundPlans;
@Resource private ObjectMapper objectMapper;
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private DropshipOrderService dropshipOrders;
@@ -32,7 +37,8 @@ public class LoadingCompletedConsumer {
@Transactional
public int consume(EventEnvelope envelope) {
envelope.requireValid();
if(!EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(envelope.eventType())||envelope.eventVersion()!=1)
if (!EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(envelope.eventType())
|| (envelope.eventVersion() != 1 && envelope.eventVersion() != 2))
throw new IllegalArgumentException("不支持的尾程交接事件");
if (envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS)
throw new IllegalArgumentException("尾程交接事件区域无效");
@@ -71,6 +77,8 @@ public class LoadingCompletedConsumer {
try {
ObjectMapper compatibleMapper = objectMapper.copy()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
// V2 only adds immutable item remarks; the current tail-leg snapshot model
// intentionally ignores those fields until its schema exposes them.
return compatibleMapper.treeToValue(envelope.payload(), FirstLegLoadingPlanCompletedV1.class);
}
catch(Exception exception) { throw new IllegalArgumentException("尾程事件负载无效",exception); }
@@ -133,10 +141,12 @@ public class LoadingCompletedConsumer {
detail.setDestinationWarehouseId(item.destinationWarehouseId()); detail.setDestinationWarehouseCode(item.destinationWarehouseCode());
detail.setDestinationWarehouseName(item.destinationWarehouseName()); detail.setFbaTrackingNo(item.fbaTrackingNo());
detail.setShipmentNo(item.shipmentNo()); detail.setShippingMark(item.shippingMark()); detailMapper.insert(detail);
createCartonLabelPool(customerId, item);
}
LastlegOrderStatusHistory history=new LastlegOrderStatusHistory();
history.setOrderId(order.getId()); history.setToStatus("PENDING_ASSIGNMENT"); history.setEventId(event.eventId());
history.setRemark("头程装柜完成自动生成"); history.setCreatedAt(LocalDateTime.now()); historyMapper.insert(history);
if ("FIRST_LEG".equals(orderType)) inboundPlans.convert(customerId, order.getId());
if ("ONE_CLICK_DROPSHIP".equals(orderType)) {
long warehouseId;
try { warehouseId = Long.parseLong(payload.warehouseId()); }
@@ -146,6 +156,24 @@ public class LoadingCompletedConsumer {
}
}
private void createCartonLabelPool(String customerId, FirstLegLoadingPlanCompletedV1.Item item) {
List<String> marks = item.cartonMarks() == null ? List.of() : item.cartonMarks();
if (marks.isEmpty()) return; // 历史 V1 事件保持原有按箱数入库路径。
if (marks.stream().anyMatch(mark -> mark == null || mark.isBlank()) || marks.stream().distinct().count() != marks.size()) {
throw new IllegalArgumentException("装柜事件的箱唛池不可包含空值或重复箱唛");
}
for (String mark : marks) {
InboundPlanCarton carton = new InboundPlanCarton();
carton.setId(IdWorker.getId()); carton.setCustomerId(customerId);
carton.setSourceInboundDetailId(item.sourceInboundDetailId()); carton.setSkuCode(item.skuCode());
carton.setCartonMark(mark); carton.setDestinationWarehouseId(item.destinationWarehouseId());
carton.setDestinationWarehouseCode(item.destinationWarehouseCode());
carton.setDestinationWarehouseName(item.destinationWarehouseName()); carton.setStatus("UNASSIGNED");
carton.setVersion(0); carton.setUpdatedAt(LocalDateTime.now());
try { cartonMapper.insert(carton); } catch (DuplicateKeyException ignored) { }
}
}
private List<DropshipRequests.Pallet> pallets(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) {
if (source.pallets() != null && !source.pallets().isEmpty()) {
return source.pallets().stream().map(pallet -> new DropshipRequests.Pallet(pallet.palletNo(),
@@ -335,6 +335,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan (
id BIGINT NOT NULL COMMENT '入库计划ID(雪花ID)',
plan_no VARCHAR(32) NOT NULL COMMENT '入库单号',
customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识',
source_order_id BIGINT NOT NULL COMMENT '来源尾程订单ID,一张入仓单对应一张入库计划',
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态',
line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量',
@@ -344,8 +345,9 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan (
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_inbound_plan_no (plan_no),
UNIQUE KEY uk_ll_inbound_plan_container (customer_id, container_no)
) ENGINE=InnoDB COMMENT='尾程入库计划表:每个租户的每个集装箱生成一张入库单';
UNIQUE KEY uk_ll_inbound_plan_source_order (customer_id, source_order_id),
KEY idx_ll_inbound_plan_container (customer_id, container_no)
) ENGINE=InnoDB COMMENT='尾程入库计划表:每张来源入仓单生成一张入库计划,同柜计划共享集装箱号';
CREATE TABLE IF NOT EXISTS ll_inbound_plan_line (
id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)',
@@ -362,6 +364,22 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_line (
KEY idx_ll_inbound_line_plan (plan_id)
) ENGINE=InnoDB COMMENT='尾程入库计划明细表:按入仓码和尾程订单维度记录';
CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton (
id BIGINT NOT NULL COMMENT '箱唛作业主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
order_id BIGINT NULL COMMENT '海外首扫确权后的尾程订单ID', source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入库明细ID',
sku_code VARCHAR(128) NULL COMMENT 'SKU编码快照', carton_mark VARCHAR(128) NOT NULL COMMENT '国内冻结箱唛',
destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照',
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', plan_id BIGINT NULL COMMENT '海外入库计划ID',
line_id BIGINT NULL COMMENT '入库计划明细ID', plan_pallet_id BIGINT NULL COMMENT '海外计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '海外实物托盘ID',
warehouse_location_id BIGINT NULL COMMENT '海外库位ID', pda_task_id BIGINT NULL COMMENT '首次扫码PDA任务ID',
status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
bound_at DATETIME(3) NULL COMMENT '绑定托盘时间', received_at DATETIME(3) NULL COMMENT '完成入库时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_inbound_carton_mark(customer_id,carton_mark),
KEY idx_ll_inbound_carton_plan(plan_id,status), KEY idx_ll_inbound_carton_pallet(plan_pallet_id,status),
KEY idx_ll_inbound_carton_order(order_id,status)
) ENGINE=InnoDB COMMENT='柜次冻结箱唛及海外入库箱级作业账';
CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet (
id BIGINT NOT NULL COMMENT '计划托盘ID(雪花ID)',
plan_id BIGINT NOT NULL COMMENT '入库计划ID',
@@ -390,12 +408,13 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation (
KEY idx_ll_inbound_allocation_pallet (pallet_id)
) ENGINE=InnoDB COMMENT='尾程入库计划托盘分配表:记录入仓码明细与计划托盘的分配关系';
-- 修复既有入库计划表中的英文注释。
ALTER TABLE ll_inbound_plan COMMENT='尾程入库计划表:每个租户的每个集装箱生成一张入库单';
-- 修复既有入库计划表的注释;历史“每柜一张计划”记录不在这里猜测拆分。
ALTER TABLE ll_inbound_plan COMMENT='尾程入库计划表:每张来源入仓单生成一张入库计划,同柜计划共享集装箱号';
ALTER TABLE ll_inbound_plan
MODIFY COLUMN id BIGINT NOT NULL COMMENT '入库计划ID(雪花ID)',
MODIFY COLUMN plan_no VARCHAR(32) NOT NULL COMMENT '入库单号',
MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识',
MODIFY COLUMN source_order_id BIGINT NOT NULL COMMENT '来源尾程订单ID,一张入仓单对应一张入库计划',
MODIFY COLUMN container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态',
MODIFY COLUMN line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量',
@@ -4,6 +4,7 @@ import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation;
@@ -94,7 +95,8 @@ class InboundPdaWorkflowServiceTest {
targetPallet.setWarehouseResourceLockId(null);
targetPallet.setAssignmentStatus("PENDING_ASSIGNMENT");
when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet);
when(warehouse.reserve("100", 88L, 188L, 288L, "INBOUND_PLAN_PALLET", "LIP1:21", "assign-1"))
when(warehouse.reserve("100", 88L, 188L, 288L, "INBOUND_CONTAINER_DESTINATION_PALLET", "CONT-1:88",
"INBOUND_CONTAINER_DESTINATION_PALLET:CONT-1:88"))
.thenReturn(999L);
when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet));
@@ -125,6 +127,66 @@ class InboundPdaWorkflowServiceTest {
targetPallet.getWarehouseLocationId(), "assign-wrong-warehouse")));
}
@Test
void cartonScanBindsOnlyExpectedCartonAndRemoveMakesItScannableAgain() {
InboundPlanCarton carton = carton("BOX-001", "UNASSIGNED", targetLine.getId(), "WH-A");
activeTask();
when(repository.lockCarton("100", "BOX-001")).thenReturn(carton);
when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet);
when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine);
when(repository.lockAllocation(plan.getId(), targetLine.getId(), targetPallet.getId())).thenReturn(target);
var scanned = service.scanCarton("100", "LIP1",
new PdaRequests.ScanCarton("BOX-001", targetPallet.getId(), "scan-1", "7", "worker"));
assertEquals("SCANNED", scanned.result());
assertEquals("ON_PALLET", carton.getStatus());
assertEquals(1, carton.getVersion());
assertEquals(1, target.getPickedPackageCount());
verify(repository).update(carton);
var removed = service.removeCarton("100", "LIP1",
new PdaRequests.RemoveCarton("BOX-001", "remove-1", "7", "worker"));
assertEquals("REMOVED", removed.result());
assertEquals("UNASSIGNED", carton.getStatus());
assertEquals(0, target.getPickedPackageCount());
assertEquals(3, targetPallet.getActualPackageCount());
}
@Test
void cartonScanRejectsDuplicateAndWrongDestination() {
InboundPlanCarton repeated = carton("BOX-002", "ON_PALLET", targetLine.getId(), "WH-A");
activeTask();
when(repository.lockCarton("100", "BOX-002")).thenReturn(repeated);
assertThrows(ResponseStatusException.class, () -> service.scanCarton("100", "LIP1",
new PdaRequests.ScanCarton("BOX-002", targetPallet.getId(), "scan-repeat", "7", "worker")));
InboundPlanCarton mismatched = carton("BOX-003", "UNASSIGNED", targetLine.getId(), "WH-B");
when(repository.lockCarton("100", "BOX-003")).thenReturn(mismatched);
when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet);
assertThrows(ResponseStatusException.class, () -> service.scanCarton("100", "LIP1",
new PdaRequests.ScanCarton("BOX-003", targetPallet.getId(), "scan-destination", "7", "worker")));
}
@Test
void cartonScanUsesSourceInboundDetailIdWhenOrdersShareSkuAndDestination() {
InboundPlanCarton carton = carton("BOX-004", "UNASSIGNED", null, "WH-A");
carton.setSkuCode("SKU-SHARED");
carton.setSourceInboundDetailId("SOURCE-DETAIL-SECOND");
activeTask();
when(repository.lockCarton("100", "BOX-004")).thenReturn(carton);
when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet);
when(repository.lineForCarton(plan.getId(), carton)).thenReturn(sourceLine);
when(repository.lockAllocation(plan.getId(), sourceLine.getId(), targetPallet.getId())).thenReturn(null);
service.scanCarton("100", "LIP1",
new PdaRequests.ScanCarton("BOX-004", targetPallet.getId(), "scan-second-order", "7", "worker"));
assertEquals(sourceLine.getId(), carton.getLineId());
assertEquals(sourceLine.getOrderId(), carton.getOrderId());
verify(repository).lineForCarton(plan.getId(), carton);
}
@Test
void countMustPrecedePickAndPickedBoxesDriveActualVolume() {
activeTask();
@@ -313,6 +375,7 @@ class InboundPdaWorkflowServiceTest {
value.setId(1L);
value.setCustomerId("100");
value.setPlanNo("LIP1");
value.setContainerNo("CONT-1");
value.setStatus("ASSIGNED");
return value;
}
@@ -371,4 +434,12 @@ class InboundPdaWorkflowServiceTest {
value.setVersion(0);
return value;
}
private InboundPlanCarton carton(String mark, String status, Long lineId, String warehouseId) {
InboundPlanCarton value = new InboundPlanCarton();
value.setId(41L); value.setCustomerId("100"); value.setPlanId(plan.getId()); value.setLineId(lineId);
value.setCartonMark(mark); value.setDestinationWarehouseId(warehouseId); value.setStatus(status); value.setVersion(0);
if ("ON_PALLET".equals(status)) value.setPlanPalletId(targetPallet.getId());
return value;
}
}
@@ -56,13 +56,10 @@ class InboundPlanServiceTest {
}
@Test
void convertsWholeContainerInOneTransaction() throws Exception {
void convertsOnlyTheSelectedInboundOrderToItsOwnPlan() throws Exception {
assertNotNull(InboundPlanService.class.getMethod("convert", String.class, Long.class)
.getAnnotation(Transactional.class));
when(orderMapper.findContainerNo("100", 2L)).thenReturn("CONT-1");
when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(
order(1L, "RK-1", "1.5000", "WH-A"),
order(2L, "RK-2", "1.0000", "WH-A")));
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(order(2L, "RK-2", "1.0000", "WH-A")));
when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()))
.thenReturn(1);
@@ -70,26 +67,29 @@ class InboundPlanServiceTest {
ArgumentCaptor<InboundPlan> plan = ArgumentCaptor.forClass(InboundPlan.class);
verify(planMapper).insert(plan.capture());
assertEquals(2, plan.getValue().getLineCount());
assertEquals(2L, plan.getValue().getSourceOrderId());
assertEquals(1, plan.getValue().getLineCount());
assertEquals("PENDING_ASSIGNMENT", plan.getValue().getStatus());
assertEquals(2, plan.getValue().getPalletCount());
assertEquals(new BigDecimal("2.5000"), plan.getValue().getTotalVolumeCbm());
verify(palletMapper, times(2)).insert(any(InboundPlanPallet.class));
assertEquals(1, plan.getValue().getPalletCount());
assertEquals(new BigDecimal("1.0000"), plan.getValue().getTotalVolumeCbm());
verify(palletMapper).insert(any(InboundPlanPallet.class));
ArgumentCaptor<InboundPlanLine> lines = ArgumentCaptor.forClass(InboundPlanLine.class);
verify(lineMapper, times(2)).insert(lines.capture());
assertEquals(25, lines.getAllValues().stream().mapToInt(InboundPlanLine::getRequiredPackageCount).sum());
verify(lineMapper).insert(lines.capture());
assertEquals(10, lines.getValue().getRequiredPackageCount());
assertEquals(2L, lines.getValue().getOrderId());
ArgumentCaptor<InboundPlanPalletAllocation> allocations =
ArgumentCaptor.forClass(InboundPlanPalletAllocation.class);
verify(allocationMapper, times(3)).insert(allocations.capture());
assertEquals(25, allocations.getAllValues().stream()
verify(allocationMapper).insert(allocations.capture());
assertEquals(10, allocations.getAllValues().stream()
.mapToInt(InboundPlanPalletAllocation::getPlannedPackageCount).sum());
assertEquals(0, allocations.getAllValues().stream()
.mapToInt(InboundPlanPalletAllocation::getPickedPackageCount).sum());
assertEquals(3, allocations.getAllValues().stream()
assertEquals(1, allocations.getAllValues().stream()
.filter(item -> "PENDING_COUNT".equals(item.getStatus())).count());
verify(orderMapper, times(2)).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any());
verify(orderMapper, times(2)).insertHistory(any(), any(), any(), any());
verify(orderMapper).lockContainerOrders("100", "CONT-1");
verify(orderMapper).markPlanned(any(), org.mockito.ArgumentMatchers.eq(2L), any(), org.mockito.ArgumentMatchers.anyInt(), any());
verify(orderMapper).insertHistory(org.mockito.ArgumentMatchers.eq(2L), any(), any(), any());
verify(orderMapper).lockOrder("100", 2L);
assertEquals(2L, result.sourceOrderId());
assertEquals("CONT-1", result.containerNo());
}
@@ -104,21 +104,22 @@ class InboundPlanServiceTest {
existing.setLineCount(1);
existing.setPalletCount(1);
existing.setTotalVolumeCbm(BigDecimal.ONE);
when(orderMapper.findContainerNo("100", 1L)).thenReturn("CONT-1");
when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(order(1L, "RK-1", "1", "WH-A")));
when(planMapper.findByContainer("100", "CONT-1")).thenReturn(existing);
existing.setSourceOrderId(1L);
when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(order(1L, "RK-1", "1", "WH-A")));
when(planMapper.findBySourceOrder("100", 1L)).thenReturn(existing);
assertEquals("LIP9", service.convert("100", 1L).planNo());
var result = service.convert("100", 1L);
assertEquals("LIP9", result.planNo());
assertEquals(1L, result.sourceOrderId());
verify(planMapper, never()).insert(any(InboundPlan.class));
verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any());
}
@Test
void rejectsMissingDestinationWarehouse() {
when(orderMapper.findContainerNo("100", 1L)).thenReturn("CONT-1");
SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1",
"PENDING_ASSIGNMENT", null, BigDecimal.ONE, 1, null, null, null, 1, 0);
when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(invalid));
when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(invalid));
assertThrows(ResponseStatusException.class, () -> service.convert("100", 1L));
verify(planMapper, never()).insert(any(InboundPlan.class));
@@ -126,10 +127,9 @@ class InboundPlanServiceTest {
@Test
void rejectsTransferWarehouseThatDiffersFromCargoSnapshots() {
when(orderMapper.findContainerNo("100", 1L)).thenReturn("CONT-1");
SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1",
"DESTINATION_ASSIGNED", null, BigDecimal.ONE, 1, "WH-B", "WH-B", "Warehouse B", 1, 1);
when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(invalid));
when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(invalid));
assertThrows(ResponseStatusException.class, () -> service.convert("100", 1L));
verify(planMapper, never()).insert(any(InboundPlan.class));
@@ -1,13 +1,24 @@
package com.baiyue.lastleg.service;
import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*;
import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*;
@ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class)
class LoadingCompletedConsumerTest {
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock DropshipOrderService dropshipOrders; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); }
@Test void createsOneOrderPerInboundAndAllSnapshots(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); }
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); }
@Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); }
@Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); }
@Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); }
@Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); }
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet)); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
@Test void consumesV2LoadingEventAndKeepsDropshipSnapshot(){
EventEnvelope source=event();
JsonNode node=source.payload().deepCopy();
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0).path("items").get(0)).put("customerRemark","fragile");
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(),
source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),
2L,source.traceId(),source.correlationId(),source.causationId(),node,hash);
assertEquals(2,service.consume(v2));
verify(orders,times(2)).insert(any(LastlegOrder.class));
}
private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU1","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1)),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2)))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); }
}