fix(lastleg): unblock inbound plan handoff
This commit is contained in:
+11
-15
@@ -16,10 +16,16 @@ public record FirstLegLoadingPlanCompletedV1(
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets,
|
||||
List<Item> items, String customerId, String customerName, String orderType, List<PalletSnapshot> pallets,
|
||||
String destinationType) {
|
||||
public Order {
|
||||
items = items == null ? List.of() : items;
|
||||
if (customerId == null || customerId.isBlank()) {
|
||||
throw new IllegalArgumentException("customerId is required");
|
||||
}
|
||||
if (customerName == null || customerName.isBlank()) {
|
||||
throw new IllegalArgumentException("customerName is required");
|
||||
}
|
||||
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
|
||||
pallets = pallets == null ? List.of() : pallets;
|
||||
}
|
||||
@@ -29,23 +35,13 @@ public record FirstLegLoadingPlanCompletedV1(
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
|
||||
List<Item> items, String customerId, String customerName,
|
||||
String orderType, List<PalletSnapshot> pallets) {
|
||||
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||
grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null);
|
||||
}
|
||||
public Order(String sourceInboundId, String rootInboundId,
|
||||
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||
String inboundCode, String destinationWarehouseId,
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||
List<Item> items) {
|
||||
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null);
|
||||
grossWeightKg, netWeightKg, volumeCbm, items, customerId, customerName,
|
||||
orderType, pallets, null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-16
@@ -15,11 +15,17 @@ public record FirstLegLoadingPlanCompletedV2(
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets,
|
||||
List<Item> items, String customerId, String customerName, String orderType, List<PalletSnapshot> pallets,
|
||||
/** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */
|
||||
String destinationType) {
|
||||
public Order {
|
||||
items = items == null ? List.of() : items;
|
||||
if (customerId == null || customerId.isBlank()) {
|
||||
throw new IllegalArgumentException("customerId is required");
|
||||
}
|
||||
if (customerName == null || customerName.isBlank()) {
|
||||
throw new IllegalArgumentException("customerName is required");
|
||||
}
|
||||
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
|
||||
pallets = pallets == null ? List.of() : pallets;
|
||||
}
|
||||
@@ -30,24 +36,13 @@ public record FirstLegLoadingPlanCompletedV2(
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
|
||||
List<Item> items, String customerId, String customerName,
|
||||
String orderType, List<PalletSnapshot> pallets) {
|
||||
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||
grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null);
|
||||
}
|
||||
|
||||
public Order(String sourceInboundId, String rootInboundId,
|
||||
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||
String inboundCode, String destinationWarehouseId,
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||
List<Item> items) {
|
||||
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null);
|
||||
grossWeightKg, netWeightKg, volumeCbm, items, customerId, customerName,
|
||||
orderType, pallets, null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Baiyue Cross-region Event Contracts
|
||||
version: 1.0.0
|
||||
paths: {}
|
||||
components:
|
||||
schemas:
|
||||
FirstLegLoadingPlanCompletedV2:
|
||||
type: object
|
||||
required: [loadingPlanId, containerBookingId, orders]
|
||||
properties:
|
||||
loadingPlanId: {type: string}
|
||||
containerBookingId: {type: string}
|
||||
containerNo: {type: string}
|
||||
cabinetNo: {type: string}
|
||||
warehouseId: {type: string}
|
||||
warehouseName: {type: string}
|
||||
orders:
|
||||
type: array
|
||||
items: {$ref: '#/components/schemas/FirstLegLoadingPlanCompletedOrderV2'}
|
||||
FirstLegLoadingPlanCompletedOrderV2:
|
||||
type: object
|
||||
required: [sourceInboundId, customerId, customerName, packageCount, items, orderType, pallets, destinationType]
|
||||
properties:
|
||||
sourceInboundId: {type: string}
|
||||
sourceOrderId: {type: string}
|
||||
sourceOrderNo: {type: string}
|
||||
inboundCode: {type: string}
|
||||
customerId: {type: string, minLength: 1, description: Trusted customer identity frozen by Firstleg}
|
||||
customerName: {type: string, minLength: 1, maxLength: 128, description: Immutable trusted container/customer name snapshot; consumers must not query domestic IAM}
|
||||
packageCount: {type: integer, minimum: 1}
|
||||
items: {type: array, items: {type: object}}
|
||||
orderType: {type: string, enum: [FIRST_LEG, ONE_CLICK_DROPSHIP]}
|
||||
pallets: {type: array, items: {type: object}}
|
||||
destinationType: {type: string, enum: [FBA, OVERSEAS]}
|
||||
+4
-3
@@ -19,7 +19,7 @@ class FirstLegLoadingPlanCompletedV1Test {
|
||||
var order = new FirstLegLoadingPlanCompletedV1.Order("2088000000000000003", "2088000000000000004",
|
||||
"2088000000000000005", "FL-1", "2088000000000000006", "RK-1", "2088000000000000002",
|
||||
"ONT8", "ONT8", 2, 20, 1, new BigDecimal("10.500"), new BigDecimal("9.500"),
|
||||
new BigDecimal("0.200"), List.of(item));
|
||||
new BigDecimal("0.200"), List.of(item), "9001", "客户甲", "FIRST_LEG", List.of());
|
||||
var payload = new FirstLegLoadingPlanCompletedV1("2088000000000000007", "P-1", "2088000000000000008",
|
||||
"CONT-1", "CAB-1", "2088000000000000009", "中转仓", List.of(order));
|
||||
String json = mapper.writeValueAsString(payload);
|
||||
@@ -35,7 +35,7 @@ class FirstLegLoadingPlanCompletedV1Test {
|
||||
"SKU-2", "产品2", 1, 5, List.of("BOX-003"));
|
||||
var order = new FirstLegLoadingPlanCompletedV1.Order("100", "100", "200", "FL-200", "200",
|
||||
"RK-100", "300", "ONT8", "ONT8", 3, 25, 2, BigDecimal.ONE,
|
||||
BigDecimal.ONE, BigDecimal.ONE, List.of(), "9001", "ONE_CLICK_DROPSHIP",
|
||||
BigDecimal.ONE, BigDecimal.ONE, List.of(), "9001", "客户甲", "ONE_CLICK_DROPSHIP",
|
||||
List.of(new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-01", List.of(sku1)),
|
||||
new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-02", List.of(sku2))));
|
||||
var payload = new FirstLegLoadingPlanCompletedV1("1", "LP-1", "2", "CONT-1", null,
|
||||
@@ -53,7 +53,8 @@ class FirstLegLoadingPlanCompletedV1Test {
|
||||
String json = """
|
||||
{"loadingPlanId":"1","planNo":"LP-1","containerBookingId":"2","containerNo":"CONT-1",
|
||||
"warehouseId":"3","warehouseName":"海外仓","orders":[{"sourceInboundId":"100",
|
||||
"destinationWarehouseId":"3","packageCount":1,"items":[],"destinationType":"OVERSEAS"}]}
|
||||
"destinationWarehouseId":"3","packageCount":1,"items":[],"customerId":"9001",
|
||||
"customerName":"客户甲","destinationType":"OVERSEAS"}]}
|
||||
""";
|
||||
|
||||
FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class);
|
||||
|
||||
+32
-4
@@ -4,8 +4,10 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class FirstLegLoadingPlanCompletedV2Test {
|
||||
@Test
|
||||
@@ -14,7 +16,8 @@ class FirstLegLoadingPlanCompletedV2Test {
|
||||
2, null, null, null, null, null, null, null, null, null, null, null,
|
||||
"客户备注", "内部备注", "仓库备注");
|
||||
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
|
||||
null, null, null, 1, 2, null, null, null, null, List.of(item));
|
||||
null, null, null, 1, 2, null, null, null, null, List.of(item),
|
||||
"11", "客户甲", "FIRST_LEG", List.of());
|
||||
var payload = new FirstLegLoadingPlanCompletedV2("4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order));
|
||||
String json = new ObjectMapper().writeValueAsString(payload);
|
||||
assertThat(json).contains("customerRemark", "internalRemark", "warehouseRemark");
|
||||
@@ -29,13 +32,14 @@ class FirstLegLoadingPlanCompletedV2Test {
|
||||
var pallet = new FirstLegLoadingPlanCompletedV2.PalletSnapshot("PLT-01", List.of(sku));
|
||||
var order = new FirstLegLoadingPlanCompletedV2.Order(
|
||||
"2", "2", "3", "ORD", "3", "RK", null, null, null,
|
||||
2, 20, 1, null, null, null, List.of(), "11", "ONE_CLICK_DROPSHIP", List.of(pallet));
|
||||
2, 20, 1, null, null, null, List.of(), "11", "客户甲", "ONE_CLICK_DROPSHIP", List.of(pallet));
|
||||
var payload = new FirstLegLoadingPlanCompletedV2(
|
||||
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order));
|
||||
|
||||
var json = new ObjectMapper().valueToTree(payload);
|
||||
|
||||
assertThat(json.at("/orders/0/customerId").asText()).isEqualTo("11");
|
||||
assertThat(json.at("/orders/0/customerName").asText()).isEqualTo("客户甲");
|
||||
assertThat(json.at("/orders/0/orderType").asText()).isEqualTo("ONE_CLICK_DROPSHIP");
|
||||
assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2);
|
||||
}
|
||||
@@ -44,7 +48,7 @@ class FirstLegLoadingPlanCompletedV2Test {
|
||||
void carriesFrozenDestinationType() {
|
||||
var order = new FirstLegLoadingPlanCompletedV2.Order(
|
||||
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "FBA ONT8",
|
||||
2, 20, 0, null, null, null, List.of(), "11", "FIRST_LEG", List.of(), "FBA");
|
||||
2, 20, 0, null, null, null, List.of(), "11", "客户甲", "FIRST_LEG", List.of(), "FBA");
|
||||
|
||||
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
|
||||
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)));
|
||||
@@ -58,7 +62,8 @@ class FirstLegLoadingPlanCompletedV2Test {
|
||||
20, null, null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, List.of("HDHK260711126-01", "HDHK260711126-02"));
|
||||
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
|
||||
null, null, null, 2, 20, null, null, null, null, List.of(item));
|
||||
null, null, null, 2, 20, null, null, null, null, List.of(item),
|
||||
"11", "客户甲", "FIRST_LEG", List.of());
|
||||
|
||||
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
|
||||
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)));
|
||||
@@ -66,4 +71,27 @@ class FirstLegLoadingPlanCompletedV2Test {
|
||||
assertThat(json.at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU-PSC2");
|
||||
assertThat(json.at("/orders/0/items/0/cartonMarks")).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingImmutableCustomerName() {
|
||||
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.Order(
|
||||
"2", "2", "3", "ORD", "3", "RK", null, null, null,
|
||||
1, 1, 0, null, null, null, List.of(), "11", " ", "FIRST_LEG", List.of()))
|
||||
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("customerName");
|
||||
}
|
||||
|
||||
@Test
|
||||
void openApiRequiresImmutableCustomerNameNextToCustomerId() throws Exception {
|
||||
String yaml;
|
||||
try (var input = FirstLegLoadingPlanCompletedV2Test.class.getResourceAsStream(
|
||||
"/openapi/cross-region-events-v1.yaml")) {
|
||||
assertThat(input).isNotNull();
|
||||
yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
assertThat(yaml)
|
||||
.contains("required: [sourceInboundId, customerId, customerName")
|
||||
.contains("customerId: {type: string, minLength: 1")
|
||||
.contains("customerName: {type: string, minLength: 1, maxLength: 128")
|
||||
.contains("consumers must not query domestic IAM");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,6 +498,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type'));
|
||||
PREPARE phase4_destination_type_stmt FROM @ddl; EXECUTE phase4_destination_type_stmt; DEALLOCATE PREPARE phase4_destination_type_stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_order_no_daily_sequence (
|
||||
business_date DATE NOT NULL COMMENT '业务日期(Asia/Shanghai)',
|
||||
last_sequence INT NOT NULL COMMENT '当日已分配尾程单流水号',
|
||||
PRIMARY KEY(business_date)
|
||||
) ENGINE=InnoDB COMMENT='尾程单号按日原子流水';
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='customer_id'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id'));
|
||||
|
||||
@@ -125,12 +125,18 @@ public final class LoadingPickDtos {
|
||||
Long operatorId, String operatorName, LocalDateTime createdAt) {
|
||||
}
|
||||
|
||||
public record CompletionCommand(Long id, String operationId, String status, Integer expectedTaskVersion, String remark,
|
||||
Integer attemptCount, LocalDateTime nextRetryAt, String failureStage,
|
||||
String failureCode, String requestId, String traceId,
|
||||
LocalDateTime completedAt) {
|
||||
}
|
||||
|
||||
public record Task(Long id, Long loadingPlanId, Long containerBookingId, Long inboundId,
|
||||
Integer inboundVersion, Integer loadingVersion, String cabinetNo,
|
||||
String currentContainerNo, String sourceType, String recordStatus,
|
||||
Long mergedIntoContainerBookingId, String taskStatus,
|
||||
String workflowStatus, Integer version, Assignment assignment,
|
||||
Review review, List<PickItem> items) {
|
||||
Review review, CompletionCommand completionCommand, List<PickItem> items) {
|
||||
}
|
||||
|
||||
/** PDA 工作台只展示当前作业员可领取或已领取的进行中任务。 */
|
||||
|
||||
@@ -15,6 +15,7 @@ public interface LoadingCompletedEventMapper {
|
||||
@Select("""
|
||||
SELECT p.id AS loading_plan_id, p.plan_no, p.container_booking_id, p.version AS aggregate_version,
|
||||
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
|
||||
b.customer_id AS booking_customer_id, b.customer_name,
|
||||
i.account_id, i.inbound_id AS source_inbound_id,
|
||||
COALESCE(o.root_inbound_id, o.id) AS root_inbound_id,
|
||||
o.order_id AS source_order_id, o.order_no AS source_order_no,
|
||||
@@ -79,7 +80,8 @@ public interface LoadingCompletedEventMapper {
|
||||
class Row {
|
||||
private Long loadingPlanId; private String planNo; private Long containerBookingId;
|
||||
private Integer aggregateVersion; private String containerNo; private String cabinetNo;
|
||||
private Long warehouseId; private String warehouseName; private Long accountId;
|
||||
private Long warehouseId; private String warehouseName; private Long bookingCustomerId;
|
||||
private String customerName; private Long accountId;
|
||||
private Long sourceInboundId; private Long rootInboundId; private Long sourceOrderId;
|
||||
private Long loadingPlanItemId;
|
||||
private String sourceOrderNo; private Long rootOrderId; private String inboundCode;
|
||||
|
||||
@@ -33,6 +33,9 @@ public interface LoadingCompletionCommandMapper extends BaseMapper<LoadingComple
|
||||
""")
|
||||
LoadingCompletionCommandEntity findActiveByTask(@Param("taskId") long taskId);
|
||||
|
||||
@Select("SELECT * FROM fl_loading_completion_command WHERE pick_task_id=#{taskId} ORDER BY created_at DESC LIMIT 1")
|
||||
LoadingCompletionCommandEntity findLatestByTask(@Param("taskId") long taskId);
|
||||
|
||||
@Select("""
|
||||
SELECT * FROM fl_loading_completion_command
|
||||
WHERE command_status IN ('PROCESSING','RETRYABLE') AND next_retry_at<=CURRENT_TIMESTAMP(3)
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ public class LoadingCompletionCommandRepository {
|
||||
public int insert(LoadingCompletionCommandEntity command) { return mapper.insertCommand(command); }
|
||||
public LoadingCompletionCommandEntity operation(String operationId) { return mapper.findByOperation(operationId); }
|
||||
public LoadingCompletionCommandEntity active(long taskId) { return mapper.findActiveByTask(taskId); }
|
||||
public LoadingCompletionCommandEntity latest(long taskId) { return mapper.findLatestByTask(taskId); }
|
||||
public List<LoadingCompletionCommandEntity> due(int limit) { return mapper.findDue(limit); }
|
||||
public boolean claim(long id, String worker, LocalDateTime leaseUntil) { return mapper.claim(id, worker, leaseUntil) == 1; }
|
||||
public boolean renew(long id, String worker, LocalDateTime leaseUntil) { return mapper.renew(id, worker, leaseUntil) == 1; }
|
||||
|
||||
+21
-2
@@ -37,7 +37,7 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** 在装柜完成事务内按客户写跨区业务 Outbox。 */
|
||||
/** 在装柜完成事务内按客户及可信客户名称快照写跨区业务 Outbox。 */
|
||||
@Service
|
||||
public class LoadingCompletedOutboxService {
|
||||
private static final long HANDOVER_VERSION = 2L;
|
||||
@@ -49,6 +49,7 @@ public class LoadingCompletedOutboxService {
|
||||
public void publish(long planId) {
|
||||
List<Row> rows = snapshotMapper.rows(planId);
|
||||
if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件");
|
||||
validateCustomerSnapshots(rows);
|
||||
Map<Long, List<String>> cartonMarks = cartonLabelPools(rows);
|
||||
List<PalletRow> palletRows = snapshotMapper.palletRows(planId);
|
||||
rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId,
|
||||
@@ -104,10 +105,28 @@ public class LoadingCompletedOutboxService {
|
||||
first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount),
|
||||
sumNullableInt(sorted, Row::getProductQuantity), "ONE_CLICK_DROPSHIP".equals(orderType)
|
||||
? pallets.size() : 0, sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg),
|
||||
sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), orderType, pallets,
|
||||
sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), first.getCustomerName().trim(), orderType, pallets,
|
||||
destinationType(sorted));
|
||||
}
|
||||
|
||||
private void validateCustomerSnapshots(List<Row> rows) {
|
||||
for (Row row : rows) {
|
||||
if (row.getAccountId() == null || !Objects.equals(row.getAccountId(), row.getBookingCustomerId())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "装柜计划客户与柜次客户快照不一致,不能生成尾程交接事件");
|
||||
}
|
||||
if (row.getCustomerName() == null || row.getCustomerName().isBlank()) {
|
||||
throw new BizException(ResultCode.CONFLICT, "柜次缺少客户名称快照,不能生成尾程交接事件");
|
||||
}
|
||||
}
|
||||
rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId)).forEach((accountId, values) -> {
|
||||
Set<String> names = values.stream().map(Row::getCustomerName).map(String::trim)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
if (names.size() != 1) {
|
||||
throw new BizException(ResultCode.CONFLICT, "同一客户事件存在多个客户名称快照");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 国内按实装箱数从可用标签池稳定随机抽取箱唛,重复发布同一计划时结果不变。 */
|
||||
private Map<Long, List<String>> cartonLabelPools(List<Row> rows) {
|
||||
Map<Long, List<String>> result = new java.util.LinkedHashMap<>();
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.Operation;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.CompletionCommand;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.InventoryResource;
|
||||
@@ -837,12 +838,17 @@ public class LoadingPickService {
|
||||
.filter(value -> detailInboundId.equals(value.inboundId())).findFirst().orElse(null);
|
||||
var inbound = owner == null || owner.tenantId() == null || owner.inboundId() == null
|
||||
? null : repository.inbound(owner.tenantId(), owner.inboundId());
|
||||
LoadingCompletionCommandEntity command = completionCommands.latest(task.getId());
|
||||
CompletionCommand completionCommand = command == null ? null : new CompletionCommand(command.getId(),
|
||||
command.getOperationId(), command.getCommandStatus(), command.getExpectedTaskVersion(), command.getRemark(),
|
||||
command.getAttemptCount(), command.getNextRetryAt(), command.getFailureStage(), command.getFailureCode(),
|
||||
command.getFailureRequestId(), command.getFailureTraceId(), command.getCompletedAt());
|
||||
return new Task(task.getId(), task.getLoadingPlanId(), task.getContainerBookingId(),
|
||||
owner == null ? null : owner.inboundId(), inbound == null ? null : inbound.version(),
|
||||
inbound == null ? null : inbound.version(), task.getCabinetNo(),
|
||||
booking.getContainerNo(), booking.getSourceType(), booking.getRecordStatus(),
|
||||
booking.getMergedIntoContainerBookingId(), task.getTaskStatus(),
|
||||
workflowStatus, task.getVersion(), assignment, review, items);
|
||||
workflowStatus, task.getVersion(), assignment, review, completionCommand, items);
|
||||
}
|
||||
|
||||
private List<BatchLine> validateBatchLines(LoadingPickTaskEntity task, BatchRequest request) {
|
||||
|
||||
@@ -141,6 +141,7 @@ components:
|
||||
mergedIntoContainerBookingId: {type: integer, format: int64, nullable: true, description: Always null for the surviving ACTIVE operational container; populated only on hidden MERGED source history}
|
||||
taskStatus: {type: string, enum: [LOADING, COMPLETING, COMPLETION_FAILED, COMPLETED]}
|
||||
workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW, PROCESSING, COMPLETION_FAILED, COMPLETED]}
|
||||
completionCommand: {$ref: '#/components/schemas/CompletionCommand', nullable: true}
|
||||
version: {type: integer, minimum: 0}
|
||||
assignment: {type: object, nullable: true}
|
||||
review: {type: object, nullable: true}
|
||||
@@ -236,6 +237,22 @@ components:
|
||||
expectedTaskVersion: {type: integer, minimum: 0}
|
||||
operationId: {type: string, maxLength: 128}
|
||||
remark: {type: string, maxLength: 500}
|
||||
CompletionCommand:
|
||||
type: object
|
||||
required: [id, operationId, status, expectedTaskVersion, attemptCount]
|
||||
properties:
|
||||
id: {type: integer, format: int64}
|
||||
operationId: {type: string, maxLength: 128, description: Retry must reuse this exact identity}
|
||||
status: {type: string, enum: [PROCESSING, RETRYABLE, FAILED, COMPLETED]}
|
||||
expectedTaskVersion: {type: integer, minimum: 0, description: Retry must preserve this request-hashed version}
|
||||
remark: {type: string, maxLength: 500, nullable: true, description: Retry must preserve this request-hashed remark}
|
||||
attemptCount: {type: integer, minimum: 0}
|
||||
nextRetryAt: {type: string, format: date-time, nullable: true}
|
||||
failureStage: {type: string, nullable: true}
|
||||
failureCode: {type: string, nullable: true}
|
||||
requestId: {type: string, nullable: true}
|
||||
traceId: {type: string, nullable: true}
|
||||
completedAt: {type: string, format: date-time, nullable: true}
|
||||
CompleteLoadingRequest:
|
||||
type: object
|
||||
required: [expectedTaskVersion, operationId]
|
||||
|
||||
+1
-1
@@ -308,7 +308,7 @@ class SmartLoadingSqlContractTest {
|
||||
.contains("container_booking_id BIGINT UNSIGNED NOT NULL")
|
||||
.contains("cabinet_no VARCHAR(64) NOT NULL")
|
||||
.contains("container_no VARCHAR(64) NULL")
|
||||
.contains("task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING'")
|
||||
.contains("task_status VARCHAR(24) NOT NULL DEFAULT 'LOADING'")
|
||||
.contains("UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id)")
|
||||
.contains("UNIQUE KEY uk_loading_pick_task_booking (container_booking_id)")
|
||||
.contains("KEY idx_loading_pick_container (container_no, task_status, started_at)")
|
||||
|
||||
+35
-2
@@ -59,6 +59,7 @@ class LoadingCompletedOutboxServiceTest {
|
||||
assertThat(first.payload().at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU101");
|
||||
assertThat(first.payload().at("/orders/0/items/0/cartonMarks/0").asText()).isEqualTo("BOX-101");
|
||||
assertThat(first.payload().at("/orders/0/destinationType").asText()).isEqualTo("OVERSEAS");
|
||||
assertThat(first.payload().at("/orders/0/customerName").asText()).isEqualTo("客户-11");
|
||||
assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2");
|
||||
assertThat(first.eventVersion()).isEqualTo(2);
|
||||
}
|
||||
@@ -72,6 +73,22 @@ class LoadingCompletedOutboxServiceTest {
|
||||
.hasMessageContaining("缺少 SKU");
|
||||
}
|
||||
|
||||
@Test void rejectsMissingTrustedBookingCustomerName() {
|
||||
Row value = row(11, 100, 101);
|
||||
value.setCustomerName(" ");
|
||||
when(snapshots.rows(90L)).thenReturn(List.of(value));
|
||||
|
||||
assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("缺少客户名称快照");
|
||||
}
|
||||
|
||||
@Test void rejectsBookingCustomerIdThatDoesNotMatchAccountEvent() {
|
||||
Row value = row(11, 100, 101);
|
||||
value.setBookingCustomerId(22L);
|
||||
when(snapshots.rows(90L)).thenReturn(List.of(value));
|
||||
|
||||
assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("客户与柜次客户快照不一致");
|
||||
}
|
||||
|
||||
@Test void fbaFreezesPackageCountWithoutRequiringCartonLabels() throws Exception {
|
||||
Row value = row(11, 100, 101);
|
||||
value.setDestinationType("FBA");
|
||||
@@ -118,6 +135,19 @@ class LoadingCompletedOutboxServiceTest {
|
||||
.isEqualTo(second.payload().at("/orders/0/items/0/cartonMarks"));
|
||||
}
|
||||
|
||||
@Test void immutableCustomerNameParticipatesInPayloadHash() {
|
||||
Row value = row(11, 100, 101);
|
||||
when(snapshots.rows(90L)).thenReturn(List.of(value));
|
||||
service.publish(90L);
|
||||
value.setCustomerName("客户甲-更名快照");
|
||||
service.publish(90L);
|
||||
|
||||
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
|
||||
verify(outbox, org.mockito.Mockito.times(2)).insert(captor.capture());
|
||||
assertThat(captor.getAllValues().get(0).getPayloadHash())
|
||||
.isNotEqualTo(captor.getAllValues().get(1).getPayloadHash());
|
||||
}
|
||||
|
||||
@Test void fillsCollapsedMeasurementDetailFromUnusedInboundLabelsWithoutDuplicates() throws Exception {
|
||||
Row first = row(11, 100, 101); first.setPackageCount(1);
|
||||
Row second = row(11, 100, 101); second.setLoadingPlanItemId(102L); second.setPackageCount(2);
|
||||
@@ -144,7 +174,9 @@ class LoadingCompletedOutboxServiceTest {
|
||||
.getAnnotation(Select.class).value()).replaceAll("\\s+", " ");
|
||||
|
||||
assertThat(rowsSql).contains("p.plan_status = 'COMPLETED'")
|
||||
.contains("o.process_status = 'LOADED'");
|
||||
.contains("o.process_status = 'LOADED'")
|
||||
.contains("b.customer_id AS booking_customer_id")
|
||||
.contains("b.customer_name");
|
||||
for (String sql : List.of(rowsSql, palletsSql)) {
|
||||
assertThat(sql)
|
||||
.contains("actual_pick.loading_plan_item_id = i.id")
|
||||
@@ -219,7 +251,8 @@ class LoadingCompletedOutboxServiceTest {
|
||||
private Row row(long account,long inbound,long detail) {
|
||||
Row r=new Row(); r.setLoadingPlanId(90L); r.setPlanNo("P90"); r.setContainerBookingId(91L);
|
||||
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.setWarehouseName("WH"); r.setBookingCustomerId(account); r.setCustomerName("客户-" + account);
|
||||
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.setLoadingPlanItemId(detail); r.setDestinationWarehouseId(93L); r.setDestinationWarehouseCode("ONT8");
|
||||
r.setDestinationWarehouseName("ONT8"); r.setSourceInboundDetailId(detail); r.setSkuCode("SKU"+detail);
|
||||
|
||||
+6
-2
@@ -50,12 +50,16 @@ class TaillegRealProducerAcceptanceTest {
|
||||
assertEquals(2, jdbc.queryForObject("SELECT JSON_LENGTH(JSON_EXTRACT(payload_json,'$.payload.orders')) " +
|
||||
"FROM fl_inbound_outbox WHERE event_key=?", Integer.class,
|
||||
eventKey));
|
||||
assertEquals("真实验收客户", jdbc.queryForObject(
|
||||
"SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.orders[0].customerName')) " +
|
||||
"FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey));
|
||||
}
|
||||
|
||||
private void seedCompletedLoadingPlan() {
|
||||
jdbc.update("INSERT IGNORE INTO fl_container_booking(id,booking_no,container_no,cabinet_no,warehouse_id," +
|
||||
"warehouse_name,container_type,created_by,updated_by) VALUES(?,?,?,?,?,?,?,1,1)",
|
||||
BOOKING, MARKER + "-BOOK", MARKER + "-CNTR", MARKER + "-CAB", 92L, "真实验收海外仓", "40HQ");
|
||||
"warehouse_name,customer_id,customer_name,container_type,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,1,1)",
|
||||
BOOKING, MARKER + "-BOOK", MARKER + "-CNTR", MARKER + "-CAB", 92L, "真实验收海外仓",
|
||||
CUSTOMER, "真实验收客户", "40HQ");
|
||||
jdbc.update("INSERT IGNORE INTO fl_loading_plan(id,plan_no,container_booking_id,plan_status,created_by," +
|
||||
"updated_by,version) VALUES(?,?,?,'LOADING',1,1,0)", PLAN, MARKER + "-PLAN", BOOKING);
|
||||
seedInbound(FIRST_INBOUND, 990022221L, MARKER + "-FIRST-IN", "FBA_SEA", "STANDARD", "LOADED", "ONT8");
|
||||
|
||||
+9
-1
@@ -49,7 +49,7 @@ public class InboundPlanController {
|
||||
+ OverseasPermissions.INBOUND_ORDER_CREATE)
|
||||
public ApiResponse<InboundPlanResponse> convert(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody ConvertInboundPlanRequest request) {
|
||||
return ApiResponse.success(service.convert(scope(user, request.customerId()), request.orderId()));
|
||||
return ApiResponse.success(service.convert(convertScope(user, request.customerId()), request.orderId()));
|
||||
}
|
||||
|
||||
@GetMapping("/{planNo}")
|
||||
@@ -118,4 +118,12 @@ public class InboundPlanController {
|
||||
}
|
||||
return String.valueOf(user.tenantId());
|
||||
}
|
||||
|
||||
private String convertScope(AuthenticatedUser user, String requestedCustomer) {
|
||||
if (user != null && "PLATFORM".equals(user.appCode()) && user.tenantId() != null && user.tenantId() == 0L
|
||||
&& (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer.trim()))) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "平台生成入库计划必须指定客户");
|
||||
}
|
||||
return scope(user, requestedCustomer);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,9 +1,10 @@
|
||||
package com.baiyue.lastleg.inboundplan.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record ConvertInboundPlanRequest(
|
||||
@NotNull Long orderId,
|
||||
String customerId
|
||||
@Size(max = 128) String customerId
|
||||
) {
|
||||
}
|
||||
|
||||
+3
@@ -59,6 +59,9 @@ public class InboundPlanService {
|
||||
|
||||
@Transactional
|
||||
public InboundPlanResponse convert(String customerId, Long selectedOrderId) {
|
||||
if (customerId == null || customerId.isBlank() || "0".equals(customerId.trim())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "平台生成入库计划必须指定客户");
|
||||
}
|
||||
List<SourceOrderSnapshot> orders = orderMapper.lockOrder(customerId, selectedOrderId);
|
||||
if (orders == null || orders.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound order not found");
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.lastleg.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Mapper
|
||||
public interface LastlegOrderNoDailySequenceMapper {
|
||||
@Insert("INSERT INTO ll_order_no_daily_sequence(business_date,last_sequence) " +
|
||||
"VALUES(#{businessDate},LAST_INSERT_ID(1)) " +
|
||||
"ON DUPLICATE KEY UPDATE last_sequence=LAST_INSERT_ID(last_sequence+1)")
|
||||
int nextValue(@Param("businessDate") LocalDate businessDate);
|
||||
|
||||
@Select("SELECT LAST_INSERT_ID()")
|
||||
long currentValue();
|
||||
}
|
||||
+16
-22
@@ -1,35 +1,29 @@
|
||||
package com.baiyue.lastleg.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderNoDailySequenceMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
@Component
|
||||
public class LastlegOrderNoGenerator {
|
||||
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
|
||||
private static final DateTimeFormatter BUSINESS_DATE = DateTimeFormatter.ofPattern("yyMMdd");
|
||||
@Resource private LastlegOrderNoDailySequenceMapper sequenceMapper;
|
||||
|
||||
public String next() {
|
||||
return "LMO" + IdWorker.getIdStr();
|
||||
return next(LocalDate.now(BUSINESS_ZONE));
|
||||
}
|
||||
|
||||
/** The physical container number is the tail-leg business order grouping boundary. */
|
||||
public String forContainerNo(String containerNo) {
|
||||
if (containerNo == null || containerNo.isBlank()) {
|
||||
throw new IllegalArgumentException("containerNo is required");
|
||||
}
|
||||
String normalized = containerNo.trim();
|
||||
if (normalized.matches("[0-9]{1,29}")) {
|
||||
return "LMO" + normalized;
|
||||
}
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(normalized.getBytes(StandardCharsets.UTF_8));
|
||||
String digits = new BigInteger(1, digest).toString();
|
||||
return "LMO" + digits.substring(0, Math.min(29, digits.length()));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", exception);
|
||||
String next(LocalDate businessDate) {
|
||||
sequenceMapper.nextValue(businessDate);
|
||||
long sequence = sequenceMapper.currentValue();
|
||||
if (sequence < 1 || sequence > 9999) {
|
||||
throw new IllegalStateException("尾程单日流水超出 0001-9999 范围: " + businessDate);
|
||||
}
|
||||
return "LMO-" + BUSINESS_DATE.format(businessDate) + "-" + String.format("%04d", sequence);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -83,7 +83,7 @@ public class LoadingCompletedConsumer {
|
||||
shippingGates.initialize(payload.containerBookingId(), envelope.tenantId(), "",
|
||||
payload.containerNo(), 0, envelope.handoverVersion());
|
||||
|
||||
String orderNo = orderNoGenerator.forContainerNo(payload.containerNo());
|
||||
String orderNo = orderNoGenerator.next();
|
||||
JsonNode rawOrders = envelope.payload().path("orders");
|
||||
for (int index = 0; index < payload.orders().size(); index++) {
|
||||
var source = payload.orders().get(index);
|
||||
@@ -183,6 +183,7 @@ public class LoadingCompletedConsumer {
|
||||
order.setInboundCode(source.inboundCode()); order.setSourceRegion(event.sourceRegion().name());
|
||||
order.setSourceInboundId(source.sourceInboundId()); order.setRootInboundId(source.rootInboundId());
|
||||
order.setSourceOrderId(source.sourceOrderId()); order.setSourceOrderNo(source.sourceOrderNo()); order.setRootOrderId(source.rootOrderId());
|
||||
order.setCustomerName(source.customerName());
|
||||
order.setSourceWarehouseId(payload.warehouseId()); order.setSourceWarehouseName(payload.warehouseName());
|
||||
order.setOverseasWarehouseId(null); order.setOverseasWarehouseName(null);
|
||||
order.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(payload.containerNo());
|
||||
|
||||
@@ -35,6 +35,12 @@ CREATE TABLE IF NOT EXISTS ll_container_shipping_gate (
|
||||
KEY idx_ll_shipping_gate_version(container_booking_id,aggregate_version,node_sequence)
|
||||
) ENGINE=InnoDB COMMENT='海外PDA柜号船节点收货资格快照';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_order_no_daily_sequence (
|
||||
business_date DATE NOT NULL COMMENT '业务日期(Asia/Shanghai)',
|
||||
last_sequence INT NOT NULL COMMENT '当日已分配尾程单流水号',
|
||||
PRIMARY KEY(business_date)
|
||||
) ENGINE=InnoDB COMMENT='尾程单号按日原子流水';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_order (
|
||||
id BIGINT NOT NULL COMMENT '雪花 ID',
|
||||
order_no VARCHAR(32) NOT NULL COMMENT '尾程单号',
|
||||
|
||||
@@ -156,6 +156,22 @@ paths:
|
||||
- { name: createdFrom, in: query, schema: { type: string, format: date-time } }
|
||||
- { name: createdTo, in: query, schema: { type: string, format: date-time } }
|
||||
responses: { '200': { description: 统一 ApiResponse 包装的尾程订单分页;列表行 destinationType 为 FBA 或 OVERSEAS } }
|
||||
/api/platform/v1/last-leg/inbound-plans/convert:
|
||||
post:
|
||||
summary: 平台生成入库计划
|
||||
description: 权限 platform:overseas-warehouse:inbound-order:create;必须同时传入真实 customerId 与 orderId,服务端按该客户和订单 ID 锁定归属,缺失 customerId 返回 A0400。
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [customerId, orderId]
|
||||
properties:
|
||||
customerId: { type: string, pattern: '^[1-9][0-9]*$' }
|
||||
orderId: { type: string, pattern: '^[1-9][0-9]*$' }
|
||||
responses: { '200': { description: 统一 ApiResponse 包装的入库计划详情 } }
|
||||
/api/platform/v1/last-leg/dispatches:
|
||||
get:
|
||||
summary: 分页查询排车单
|
||||
|
||||
+30
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.controller;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
|
||||
import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest;
|
||||
import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest;
|
||||
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
|
||||
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
|
||||
@@ -20,6 +21,7 @@ import java.time.LocalDateTime;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@@ -57,6 +59,34 @@ class InboundPlanControllerTest {
|
||||
verify(appointmentService).update("100", "APT-1", request);
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformConversionRequiresAnExplicitCustomer() {
|
||||
assertThatThrownBy(() -> controller.convert(platform(), new ConvertInboundPlanRequest(2L, null)))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("平台生成入库计划必须指定客户");
|
||||
verify(service, never()).convert(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformConversionPassesTheProvidedLongCustomerId() {
|
||||
ConvertInboundPlanRequest request = new ConvertInboundPlanRequest(9223372036854775807L,
|
||||
"193000000000200003");
|
||||
|
||||
controller.convert(platform(), request);
|
||||
|
||||
verify(service).convert("193000000000200003", 9223372036854775807L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void customerConversionIgnoresRequestedCustomerAndUsesAuthenticatedScope() {
|
||||
AuthenticatedUser customer = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of());
|
||||
ConvertInboundPlanRequest request = new ConvertInboundPlanRequest(2L, "200");
|
||||
|
||||
controller.convert(customer, request);
|
||||
|
||||
verify(service).convert("100", 2L);
|
||||
}
|
||||
|
||||
private static AuthenticatedUser platform() {
|
||||
return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB",
|
||||
Set.of("platform:overseas-warehouse:inbound-order:list"));
|
||||
|
||||
+20
@@ -161,6 +161,26 @@ class InboundPlanServiceTest {
|
||||
verify(planMapper, never()).insert(any(InboundPlan.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesMissingCustomerScopeBeforeLookingUpAnOrder() {
|
||||
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convert(null, 2L));
|
||||
|
||||
assertEquals(400, error.getStatusCode().value());
|
||||
assertEquals("平台生成入库计划必须指定客户", error.getReason());
|
||||
verify(orderMapper, never()).lockOrder(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrongCustomerCannotResolveAnOrderOutsideItsScope() {
|
||||
when(orderMapper.lockOrder("200", 2L)).thenReturn(List.of());
|
||||
|
||||
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convert("200", 2L));
|
||||
|
||||
assertEquals(404, error.getStatusCode().value());
|
||||
verify(orderMapper).lockOrder("200", 2L);
|
||||
verify(planMapper, never()).insert(any(InboundPlan.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTransferWarehouseThatDiffersFromCargoSnapshots() {
|
||||
SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1",
|
||||
|
||||
+49
-24
@@ -1,31 +1,56 @@
|
||||
package com.baiyue.lastleg.service;
|
||||
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderNoDailySequenceMapper;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.stream.IntStream;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class LastlegOrderNoGeneratorTest {
|
||||
@Test void actualContainerNumberIsStableAndShared() {
|
||||
LastlegOrderNoGenerator generator = new LastlegOrderNoGenerator();
|
||||
assertEquals("LMO2087379674555461633", generator.forContainerNo("2087379674555461633"));
|
||||
assertEquals(generator.forContainerNo("MSCU1"), new LastlegOrderNoGenerator().forContainerNo("MSCU1"));
|
||||
assertNotEquals(generator.forContainerNo("MSCU1"), generator.forContainerNo("CAB1"));
|
||||
assertTrue(generator.forContainerNo("MSCU1234567890").length() <= 32);
|
||||
assertThrows(IllegalArgumentException.class, () -> generator.forContainerNo(" "));
|
||||
@Mock LastlegOrderNoDailySequenceMapper sequenceMapper;
|
||||
private LastlegOrderNoGenerator generator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
generator = new LastlegOrderNoGenerator();
|
||||
ReflectionTestUtils.setField(generator, "sequenceMapper", sequenceMapper);
|
||||
}
|
||||
|
||||
@Test void remainsUniqueAcrossInstancesAndConcurrentCalls() throws Exception {
|
||||
LastlegOrderNoGenerator beforeRestart=new LastlegOrderNoGenerator();
|
||||
String first=beforeRestart.next();
|
||||
LastlegOrderNoGenerator afterRestart=new LastlegOrderNoGenerator();
|
||||
assertNotEquals(first,afterRestart.next());
|
||||
ExecutorService pool=Executors.newFixedThreadPool(8);
|
||||
try {
|
||||
var tasks=IntStream.range(0,1000).mapToObj(i->(Callable<String>)afterRestart::next).toList();
|
||||
Set<String> ids=pool.invokeAll(tasks).stream().map(future->{
|
||||
try { return future.get(); } catch(Exception exception) { throw new RuntimeException(exception); }
|
||||
}).collect(java.util.stream.Collectors.toSet());
|
||||
assertEquals(1000,ids.size());
|
||||
} finally { pool.shutdownNow(); }
|
||||
@Test
|
||||
void usesShanghaiBusinessDateAndDatabaseAllocatedSequence() {
|
||||
LocalDate date = LocalDate.of(2026, 8, 27);
|
||||
when(sequenceMapper.currentValue()).thenReturn(1L, 2L);
|
||||
|
||||
assertEquals("LMO-260827-0001", generator.next(date));
|
||||
assertEquals("LMO-260827-0002", generator.next(date));
|
||||
verify(sequenceMapper, times(2)).nextValue(date);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapperUsesOneAtomicDatabaseOperationForConcurrentInstances() throws Exception {
|
||||
Insert insert = LastlegOrderNoDailySequenceMapper.class
|
||||
.getMethod("nextValue", LocalDate.class).getAnnotation(Insert.class);
|
||||
|
||||
assertTrue(insert.value()[0].contains("ON DUPLICATE KEY UPDATE"));
|
||||
assertTrue(insert.value()[0].contains("LAST_INSERT_ID(last_sequence+1)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDailySequenceBeyondFourDigits() {
|
||||
when(sequenceMapper.currentValue()).thenReturn(10_000L);
|
||||
assertThrows(IllegalStateException.class, () -> generator.next(LocalDate.of(2026, 8, 27)));
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -2,9 +2,9 @@ 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.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; 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 LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; 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,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); 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 createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor<InventoryCommandRequests.InTransit> inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); 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.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); 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 LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock LastlegOrderNoDailySequenceMapper sequences; 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,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); 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 createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor<InventoryCommandRequests.InTransit> inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); 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.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("LMO-[0-9]{6}-0001")); }
|
||||
@Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","100","","MSCU1",0L,1L); }
|
||||
@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)); }
|
||||
@@ -17,14 +17,14 @@ class LoadingCompletedConsumerTest {
|
||||
assertTrue(table.contains("customer_id")); assertFalse(table.contains("tenant_id"));
|
||||
}
|
||||
@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),"FBA"); 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()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); 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 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","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); 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()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); 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 oneDropshipInboundWithMultipleSkusCreatesOneOrderAndMultipleSkuDetails(){
|
||||
var itemA=new FirstLegLoadingPlanCompletedV1.Item("d-a","SKU-A","产品A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-A","MARK-A",null,List.of("BOX-A1","BOX-A2"));
|
||||
var itemB=new FirstLegLoadingPlanCompletedV1.Item("d-b","SKU-B","产品B","Product B",3,15,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-B","MARK-B",null,List.of("BOX-B1","BOX-B2","BOX-B3"));
|
||||
var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(
|
||||
new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","产品A",2,20,List.of("BOX-A1","BOX-A2")),
|
||||
new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-B","产品B",3,15,List.of("BOX-B1","BOX-B2","BOX-B3"))));
|
||||
var order=new FirstLegLoadingPlanCompletedV1.Order("in-multi","root-multi","ord-multi","FL-MULTI","root-ord-multi","RK-MULTI","wh-1","ONT8","ONT8",5,35,1,BigDecimal.valueOf(2),BigDecimal.valueOf(2),BigDecimal.valueOf(2),List.of(itemA,itemB),"100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA");
|
||||
var order=new FirstLegLoadingPlanCompletedV1.Order("in-multi","root-multi","ord-multi","FL-MULTI","root-ord-multi","RK-MULTI","wh-1","ONT8","ONT8",5,35,1,BigDecimal.valueOf(2),BigDecimal.valueOf(2),BigDecimal.valueOf(2),List.of(itemA,itemB),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA");
|
||||
EventEnvelope event=eventForPayload(new FirstLegLoadingPlanCompletedV1("plan-multi","LP-MULTI","book-multi","CONT-MULTI","CAB-MULTI","92","Warehouse",List.of(order)),2);
|
||||
|
||||
assertEquals(1,service.consume(event));
|
||||
@@ -112,6 +112,6 @@ class LoadingCompletedConsumerTest {
|
||||
assertTrue(captured.getAllValues().stream().allMatch(order->"OVERSEAS".equals(order.getDestinationType())));
|
||||
verify(cartons,times(8)).insert(any(InboundPlanCarton.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); }
|
||||
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),"100","Customer 100","FIRST_LEG",List.of()),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),"100","Customer 100","FIRST_LEG",List.of()))); 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); }
|
||||
private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user