Compare commits

..
Author SHA1 Message Date
liuyihe 6b077c20db 暂存提交 2026-08-19 09:28:40 +08:00
liuyihe 0192380e81 暂存提交 2026-08-14 15:15:43 +08:00
13 changed files with 279 additions and 13 deletions
@@ -22,8 +22,20 @@ public final class EventPayloadCanonicalizer {
if (payload == null || payload.isNull()) {
throw new IllegalArgumentException("事件负载不能为空");
}
return write(sort(payload));
}
/** 仅用于接收规范化数字规则上线前已经生成的历史摘要。 */
public static byte[] canonicalizeLegacy(JsonNode payload) {
if (payload == null || payload.isNull()) {
throw new IllegalArgumentException("事件负载不能为空");
}
return write(sortLegacy(payload));
}
private static byte[] write(JsonNode payload) {
try {
return OBJECT_MAPPER.writeValueAsBytes(sort(payload));
return OBJECT_MAPPER.writeValueAsBytes(payload);
} catch (JsonProcessingException exception) {
throw new IllegalArgumentException("事件负载无法规范化", exception);
}
@@ -43,6 +55,26 @@ public final class EventPayloadCanonicalizer {
node.forEach(child -> result.add(sort(child)));
return result;
}
if (node.isNumber()) {
return com.fasterxml.jackson.databind.node.DecimalNode.valueOf(node.decimalValue().stripTrailingZeros());
}
return node;
}
private static JsonNode sortLegacy(JsonNode node) {
if (node.isObject()) {
var names = new java.util.ArrayList<String>();
node.fieldNames().forEachRemaining(names::add);
names.sort(String::compareTo);
var result = OBJECT_MAPPER.createObjectNode();
names.forEach(name -> result.set(name, sortLegacy(node.get(name))));
return result;
}
if (node.isArray()) {
var result = OBJECT_MAPPER.createArrayNode();
node.forEach(child -> result.add(sortLegacy(child)));
return result;
}
return node;
}
}
@@ -16,7 +16,24 @@ public record FirstLegLoadingPlanCompletedV1(
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items) {
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
public Order {
items = items == null ? List.of() : items;
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
pallets = pallets == null ? List.of() : pallets;
}
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());
}
}
public record Item(
@@ -27,6 +44,31 @@ public record FirstLegLoadingPlanCompletedV1(
BigDecimal dimensionalWeightKg, BigDecimal volumeCbm,
String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, String fbaTrackingNo,
String shipmentNo, String shippingMark) {
String shipmentNo, String shippingMark, String palletNo, List<String> cartonMarks) {
public Item {
cartonMarks = cartonMarks == null ? List.of() : cartonMarks;
}
public Item(String sourceInboundDetailId, String skuCode,
String productNameZh, String productNameEn,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg,
BigDecimal dimensionalWeightKg, BigDecimal volumeCbm,
String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, String fbaTrackingNo,
String shipmentNo, String shippingMark) {
this(sourceInboundDetailId, skuCode, productNameZh, productNameEn, packageCount,
productQuantity, palletCount, grossWeightKg, netWeightKg,
dimensionalWeightKg, volumeCbm, destinationWarehouseId,
destinationWarehouseCode, destinationWarehouseName, fbaTrackingNo,
shipmentNo, shippingMark, null, List.of());
}
}
/** 一键代发入仓单的托盘冻结快照。 */
public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {}
/** 托盘下 SKU、箱数、数量及箱唛冻结快照。 */
public record SkuPackageSnapshot(String skuCode, String productName,
int packageCount, int productQuantity,
List<String> cartonMarks) {}
}
@@ -0,0 +1,29 @@
package com.baiyue.common.event;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
class EventPayloadCanonicalizerTest {
private final ObjectMapper json = new ObjectMapper();
@Test
void equivalentNumberLexemesHaveTheSameCanonicalFormAndHash() throws Exception {
var scaled = json.readTree("{\"weight\":30.0,\"volume\":0.3150,\"quantity\":3}");
var normalized = json.readTree("{\"quantity\":3.0,\"volume\":0.315,\"weight\":3E+1}");
assertArrayEquals(EventPayloadCanonicalizer.canonicalize(scaled), EventPayloadCanonicalizer.canonicalize(normalized));
assertEquals(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(scaled)),
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(normalized)));
}
@Test
void negativeZeroIsZeroAndLegacyHashRemainsAvailable() throws Exception {
var negativeZero = json.readTree("{\"value\":-0.000}");
var zero = json.readTree("{\"value\":0}");
assertArrayEquals(EventPayloadCanonicalizer.canonicalize(negativeZero), EventPayloadCanonicalizer.canonicalize(zero));
assertNotEquals(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(negativeZero)),
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalizeLegacy(negativeZero)));
}
}
@@ -26,4 +26,25 @@ class FirstLegLoadingPlanCompletedV1Test {
assertThat(mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class)).isEqualTo(payload);
assertThat(json).contains("2088000000000000003", "sourceInboundDetailId");
}
@Test
void dropshipPayloadKeepsMultiplePalletsAndCartonMarks() throws Exception {
var sku1 = new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot(
"SKU-1", "产品1", 2, 20, List.of("BOX-001", "BOX-002"));
var sku2 = new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot(
"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",
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,
"3", "海外仓", List.of(order));
String json = mapper.writeValueAsString(payload);
FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class);
assertThat(restored).isEqualTo(payload);
assertThat(json).contains("ONE_CLICK_DROPSHIP", "PLT-01", "BOX-003", "customerId");
}
}
@@ -14959,6 +14959,8 @@ USE baiyue_firstleg;
-- 标准跨区事件摘要包含 sha256: 前缀,共 71 字符;兼容既有 Finance 64 位摘要。
ALTER TABLE fl_inbound_outbox
MODIFY COLUMN payload_hash VARCHAR(71) NULL COMMENT '事件规范化负载SHA-256;标准跨区事件含sha256:前缀';
ALTER TABLE fl_inbound_outbox
MODIFY COLUMN payload_json LONGTEXT NOT NULL COMMENT '事件负载快照;文本类型保留跨区事件摘要所依赖的数值词法';
CALL baiyue_firstleg.assert_unified_final_schema();
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
SELECT 'PASS' AS migration_status,
@@ -35,7 +35,9 @@ public class EventInboxService {
if (!envelope.targetRegion().name().equalsIgnoreCase(localRegion)) {
throw new IllegalArgumentException("事件目标区域与当前事件桥区域不一致");
}
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) {
boolean currentHash = EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash());
boolean legacyHash = EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalizeLegacy(envelope.payload()), envelope.payloadHash());
if (!currentHash && !legacyHash) {
throw new IllegalArgumentException("payloadHash 与规范化事件负载不一致");
}
BridgeInbox existing = inboxStore.findByEventId(envelope.eventId());
@@ -8,7 +8,7 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
/** 将本地业务 Outbox 投递到跨区事件桥。 */
@FeignClient(name = "event-bridge-service", contextId = "loadingCompletedEventBridgeClient")
@FeignClient(name = "event-bridge-service", contextId = "loadingCompletedEventBridgeClient", url = "${baiyue.lastleg.event-bridge-url:}")
public interface EventBridgeClient {
@PostMapping("/internal/event-bridge/events")
ApiResponse<Acknowledgement> receive(
@@ -19,7 +19,7 @@ public interface LoadingCompletedEventMapper {
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,
COALESCE(root_order.order_id, o.order_id) AS root_order_id,
o.inbound_code, o.destination_warehouse_id, o.destination_code AS destination_warehouse_code,
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.product_name_zh, i.product_name_en, i.package_count, i.product_quantity,
@@ -36,6 +36,27 @@ public interface LoadingCompletedEventMapper {
""")
List<Row> rows(@Param("planId") long planId);
/** 读取一键代发托盘及其箱唛快照;普通订单不会使用该结果。 */
@Select("""
SELECT i.account_id, i.inbound_id AS source_inbound_id,
a.pallet_code, a.allocation_no, a.picked_package_count AS allocated_package_count,
i.inbound_detail_id AS source_inbound_detail_id, i.sku_code,
i.product_name_zh, i.package_count, i.product_quantity,
label.box_code
FROM fl_loading_plan_item i
JOIN fl_inbound_pallet_allocation a
ON a.tenant_id = i.account_id AND a.inbound_id = i.inbound_id
AND a.allocation_status IN ('CONFIRMED','TRANSFERRED','RELEASED')
AND a.picked_package_count > 0
LEFT JOIN fl_inbound_carton_label label
ON label.tenant_id = i.account_id AND label.inbound_id = i.inbound_id
AND label.inbound_detail_id = i.inbound_detail_id AND label.batch_status = 'ACTIVE'
WHERE i.loading_plan_id = #{planId} AND i.deleted = 0
ORDER BY i.account_id, i.inbound_id, a.allocation_no,
i.inbound_detail_id, label.carton_index, label.id
""")
List<PalletRow> palletRows(@Param("planId") long planId);
@Getter @Setter
class Row {
private Long loadingPlanId; private String planNo; private Long containerBookingId;
@@ -49,5 +70,14 @@ public interface LoadingCompletedEventMapper {
private Integer productQuantity; private Integer palletCount; private BigDecimal grossWeightKg;
private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm;
private String fbaTrackingNo; private String shipmentNo; private String shippingMark;
private String businessType;
}
@Getter @Setter
class PalletRow {
private Long accountId; private Long sourceInboundId; private String palletCode;
private Integer allocationNo; private Integer allocatedPackageCount;
private Long sourceInboundDetailId; private String skuCode; private String productNameZh;
private Integer packageCount; private Integer productQuantity; private String boxCode;
}
}
@@ -14,6 +14,7 @@ import com.baiyue.firstleg.entity.InboundOutboxEntity;
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.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -29,6 +30,7 @@ import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/** 在装柜完成事务内按客户写跨区业务 Outbox。 */
@Service
@@ -41,17 +43,20 @@ public class LoadingCompletedOutboxService {
public void publish(long planId) {
List<Row> rows = snapshotMapper.rows(planId);
if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件");
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)));
.forEach((accountId, accountRows) -> insert(envelope(accountId, accountRows,
palletRows.stream().filter(item -> accountId.equals(item.getAccountId())).toList())));
}
private EventEnvelope envelope(long accountId, List<Row> rows) {
private EventEnvelope envelope(long accountId, List<Row> rows, List<PalletRow> palletRows) {
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<FirstLegLoadingPlanCompletedV1.Order> orders = byInbound.values().stream()
.map(this::order).toList();
.map(value -> order(value, palletRows.stream()
.filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList())).toList();
var payload = new FirstLegLoadingPlanCompletedV1(String.valueOf(first.getLoadingPlanId()),
first.getPlanNo(), string(first.getContainerBookingId()), first.getContainerNo(), first.getCabinetNo(),
string(first.getWarehouseId()), first.getWarehouseName(), orders);
@@ -65,7 +70,7 @@ public class LoadingCompletedOutboxService {
traceId, eventKey, null, payloadNode, hash).requireValid();
}
private FirstLegLoadingPlanCompletedV1.Order order(List<Row> values) {
private FirstLegLoadingPlanCompletedV1.Order order(List<Row> values, List<PalletRow> palletRows) {
List<Row> sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList();
Row first = sorted.get(0);
List<FirstLegLoadingPlanCompletedV1.Item> items = sorted.stream().map(row ->
@@ -75,14 +80,81 @@ public class LoadingCompletedOutboxService {
row.getVolumeCbm(), string(row.getDestinationWarehouseId()), row.getDestinationWarehouseCode(),
row.getDestinationWarehouseName(), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark()))
.toList();
String orderType = dropship(first.getBusinessType()) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG";
List<FirstLegLoadingPlanCompletedV1.PalletSnapshot> pallets = dropship(first.getBusinessType())
? palletSnapshots(sorted, palletRows) : List.of();
if ("ONE_CLICK_DROPSHIP".equals(orderType) && pallets.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "一键代发入仓单缺少托盘冻结快照");
}
return new FirstLegLoadingPlanCompletedV1.Order(string(first.getSourceInboundId()),
string(first.getRootInboundId()), string(first.getSourceOrderId()), first.getSourceOrderNo(),
string(first.getRootOrderId()), first.getInboundCode(), string(first.getDestinationWarehouseId()),
first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount),
sumNullableInt(sorted, Row::getProductQuantity), sumNullableInt(sorted, Row::getPalletCount),
sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg), sum(sorted, Row::getVolumeCbm), items);
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);
}
private List<FirstLegLoadingPlanCompletedV1.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(
p -> (p.getAllocationNo() == null ? "" : p.getAllocationNo()) + "|" + p.getPalletCode(),
LinkedHashMap::new, java.util.stream.Collectors.toList()));
int loadedPackages = byPallet.values().stream().map(list -> list.get(0)).map(PalletRow::getAllocatedPackageCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
if (loadedPackages != sumInt(values, Row::getPackageCount)) {
throw new BizException(ResultCode.CONFLICT, "一键代发实装箱数与托盘已拣箱数不一致");
}
Map<Long, DetailSnapshot> details = new LinkedHashMap<>();
values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).forEach(row ->
details.putIfAbsent(row.getSourceInboundDetailId(), new DetailSnapshot(row.getSourceInboundDetailId(),
row.getSkuCode(), row.getProductNameZh(), row.getPackageCount(), row.getProductQuantity(), new ArrayList<>())));
source.stream().filter(item -> item.getBoxCode() != null).forEach(item -> {
DetailSnapshot detail = details.get(item.getSourceInboundDetailId());
if (detail != null && !detail.marks().contains(item.getBoxCode())) detail.marks().add(item.getBoxCode());
});
Map<Long, Integer> remainingPackages = details.values().stream().collect(java.util.stream.Collectors.toMap(
DetailSnapshot::detailId, DetailSnapshot::packageCount, (a,b)->a, LinkedHashMap::new));
Map<Long, Integer> remainingMarks = details.values().stream().collect(java.util.stream.Collectors.toMap(
DetailSnapshot::detailId, ignored -> 0, (a,b)->a, LinkedHashMap::new));
List<FirstLegLoadingPlanCompletedV1.PalletSnapshot> result = new ArrayList<>();
for (List<PalletRow> palletRows : byPallet.values()) {
PalletRow first = palletRows.get(0);
int capacity = first.getAllocatedPackageCount() == null ? 0 : first.getAllocatedPackageCount();
List<FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot> sku = new ArrayList<>();
for (DetailSnapshot detail : details.values()) {
int remaining = remainingPackages.getOrDefault(detail.detailId(), 0);
if (capacity <= 0 || remaining <= 0) continue;
int count = Math.min(capacity, remaining);
int totalQuantity = detail.productQuantity() == null ? count
: (int) Math.round((double) detail.productQuantity() * count / Math.max(1, detail.packageCount()));
int markStart = remainingMarks.getOrDefault(detail.detailId(), 0);
int markEnd = Math.min(detail.marks().size(), markStart + count);
List<String> marks = new ArrayList<>(detail.marks().subList(markStart, markEnd));
if (marks.size() != count) throw new BizException(ResultCode.CONFLICT, "一键代发箱唛数量与托盘箱数不一致");
sku.add(new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot(detail.skuCode(),
detail.productName(), count, totalQuantity, marks));
remainingPackages.put(detail.detailId(), remaining - count);
remainingMarks.put(detail.detailId(), markEnd);
capacity -= count;
}
if (!sku.isEmpty()) result.add(new FirstLegLoadingPlanCompletedV1.PalletSnapshot(
first.getPalletCode() == null ? "PALLET-" + first.getAllocationNo() : first.getPalletCode(), sku));
}
if (remainingPackages.values().stream().mapToInt(Integer::intValue).sum() != 0) {
throw new BizException(ResultCode.CONFLICT, "一键代发托盘未覆盖全部实装货物明细");
}
return result;
}
private static boolean dropship(String value) {
return value != null && ("ONE_CLICK_DROPSHIP".equalsIgnoreCase(value)
|| value.toUpperCase(java.util.Locale.ROOT).contains("DROPSHIP"));
}
private record DetailSnapshot(Long detailId, String skuCode, String productName,
Integer packageCount, Integer productQuantity, List<String> marks) {}
private void insert(EventEnvelope envelope) {
InboundOutboxEntity value = new InboundOutboxEntity();
value.setId(IdWorker.getId()); value.setTenantId(Long.valueOf(envelope.tenantId()));
@@ -28,6 +28,8 @@ baiyue:
security:
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long}
lastleg:
event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL:http://127.0.0.1:9312}
clients:
order:
url: ${ORDER_SERVICE_URL:}
@@ -733,7 +733,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_outbox (
aggregate_type VARCHAR(32) NOT NULL DEFAULT 'INBOUND' COMMENT '聚合类型',
aggregate_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
payload_json JSON NOT NULL COMMENT '事件负载快照',
payload_json LONGTEXT NOT NULL COMMENT '事件负载快照;文本类型保留跨区事件摘要所依赖的数值词法',
publish_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '事件发布状态:PENDING待发布、PUBLISHED已发布、FAILED发布失败',
retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
next_retry_at DATETIME(3) NULL COMMENT '下次重试时间',
@@ -1,2 +1,4 @@
-- 装柜完成跨区事件使用标准 sha256: 摘要,共 71 字符。
ALTER TABLE fl_inbound_outbox MODIFY COLUMN payload_hash VARCHAR(71) NULL COMMENT '事件规范化负载SHA-256;标准跨区事件含sha256:前缀';
-- MySQL JSON 会重写 0.3150 等数值词法,导致入库前计算的 payloadHash 失效。
ALTER TABLE fl_inbound_outbox MODIFY COLUMN payload_json LONGTEXT NOT NULL COMMENT '事件负载快照;文本类型保留跨区事件摘要所依赖的数值词法';
@@ -6,6 +6,7 @@ import com.baiyue.firstleg.entity.InboundOutboxEntity;
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.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -48,6 +49,29 @@ class LoadingCompletedOutboxServiceTest {
assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:1");
}
@Test void publishesDropshipPalletSkuAndCartonSnapshots() throws Exception {
Row first = row(11, 100, 101); first.setBusinessType("ONE_CLICK_DROPSHIP");
first.setPackageCount(2); first.setProductQuantity(20); first.setSkuCode("SKU-A");
Row second = row(11, 100, 102); second.setBusinessType("ONE_CLICK_DROPSHIP");
second.setPackageCount(1); second.setProductQuantity(5); second.setSkuCode("SKU-B");
when(snapshots.rows(90L)).thenReturn(List.of(first, second));
when(snapshots.palletRows(90L)).thenReturn(List.of(
pallet(11,100,1,"PLT-01",2,101,"SKU-A",2,20,"BOX-001"),
pallet(11,100,1,"PLT-01",2,101,"SKU-A",2,20,"BOX-002"),
pallet(11,100,2,"PLT-02",1,102,"SKU-B",1,5,"BOX-003")));
service.publish(90L);
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(outbox).insert(captor.capture());
EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class);
assertThat(event.payload().at("/orders/0/orderType").asText()).isEqualTo("ONE_CLICK_DROPSHIP");
assertThat(event.payload().at("/orders/0/customerId").asText()).isEqualTo("11");
assertThat(event.payload().at("/orders/0/pallets")).hasSize(2);
assertThat(event.payload().at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2);
assertThat(event.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2);
}
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);
@@ -58,4 +82,12 @@ class LoadingCompletedOutboxServiceTest {
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);
row.setAllocationNo(allocation); row.setPalletCode(palletNo); row.setAllocatedPackageCount(allocated);
row.setSourceInboundDetailId(detail); row.setSkuCode(sku); row.setProductNameZh("产品");
row.setPackageCount(packages); row.setProductQuantity(quantity); row.setBoxCode(boxCode); return row;
}
}