入库修改
This commit is contained in:
@@ -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;
|
||||
|
||||
+26
-5
@@ -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
-1
@@ -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);
|
||||
|
||||
+10
-1
@@ -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(
|
||||
|
||||
+1
@@ -8,6 +8,7 @@ public record InboundPlanResponse(
|
||||
Long id,
|
||||
String planNo,
|
||||
String customerId,
|
||||
Long sourceOrderId,
|
||||
String containerNo,
|
||||
String status,
|
||||
int lineCount,
|
||||
|
||||
+7
@@ -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;
|
||||
|
||||
+35
@@ -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;
|
||||
}
|
||||
+34
@@ -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);
|
||||
}
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
|
||||
+3
-3
@@ -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} " +
|
||||
|
||||
+18
@@ -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) {}
|
||||
}
|
||||
|
||||
+25
@@ -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); }
|
||||
}
|
||||
|
||||
+123
-2
@@ -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
|
||||
|
||||
+38
-12
@@ -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(),
|
||||
|
||||
+29
-1
@@ -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 '入仓码明细数量',
|
||||
|
||||
+72
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+25
-25
@@ -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));
|
||||
|
||||
+15
-4
@@ -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); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user