Files
baiyue-firstleg/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java
T

572 lines
34 KiB
Java

package com.baiyue.firstleg.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventTypes;
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.baiyue.firstleg.mapper.LoadingCartonManifestMapper;
import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper.LabelRow;
import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper;
import com.baiyue.firstleg.loading.mapper.CabinetLoadingEvidenceMapper;
import com.baiyue.firstleg.loading.entity.CabinetLoadingEvidenceEntity;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.apache.ibatis.annotations.Select;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
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 LoadingPalletLabelMapper palletLabels = mock(LoadingPalletLabelMapper.class);
private final LoadingPalletLabelService palletLabelService = mock(LoadingPalletLabelService.class);
private final CabinetLoadingEvidenceMapper cabinetEvidence = mock(CabinetLoadingEvidenceMapper.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,"palletLabelMapper",palletLabels);
ReflectionTestUtils.setField(service,"palletLabelService",palletLabelService);
ReflectionTestUtils.setField(service,"cabinetEvidenceMapper",cabinetEvidence);
ReflectionTestUtils.setField(service,"outboxMapper",outbox);
ReflectionTestUtils.setField(service,"objectMapper",mapper);
when(palletLabels.labels(anyLong())).thenReturn(List.of());
when(cabinetEvidence.byContainer(anyLong())).thenReturn(List.of(
evidence("PHOTO_STEP_1", 1), evidence("PHOTO_STEP_2", 2), evidence("PHOTO_STEP_3", 3),
evidence("PHOTO_STEP_4", 4), evidence("SEAL", 5)));
when(manifests.labelPool(anyLong(), anyLong())).thenAnswer(invocation -> List.of(
label(101, "BOX-101"), label(102, "BOX-102"),
label(202, "BOX-202"), label(302, "BOX-302")));
}
private static CabinetLoadingEvidenceEntity evidence(String type, long fileId) {
CabinetLoadingEvidenceEntity value = new CabinetLoadingEvidenceEntity();
value.setEvidenceType(type); value.setFileId(fileId); value.setFileName(type + ".jpg");
value.setStatus("ACTIVE"); value.setActiveFlag(1);
return value;
}
@Test void groupsByAccountAndKeepsAllOrdersAndItemsStable() throws Exception {
when(snapshots.rows(90L)).thenReturn(List.of(row(22,300,302),row(11,200,202),row(11,100,102),row(11,100,101)));
when(snapshots.palletRows(90L)).thenReturn(List.of(
pallet(11,100,1,"PLT-01",1,101,"SKU101",1,10,"BOX-101"),
pallet(11,100,1,"PLT-01",1,102,"SKU102",1,10,"BOX-102"),
pallet(11,100,2,"PLT-02",1,102,"SKU102",1,10,"BOX-102")));
service.publish(90L);
ArgumentCaptor<InboundOutboxEntity> captor=ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(outbox,org.mockito.Mockito.times(2)).insert(captor.capture());
List<InboundOutboxEntity> rows=captor.getAllValues();
assertThat(rows).extracting(InboundOutboxEntity::getTenantId).containsExactly(11L,22L);
EventEnvelope first=mapper.readValue(rows.get(0).getPayloadJson(),EventEnvelope.class);
assertThat(first.eventType()).isEqualTo(EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED);
assertThat(first.tenantId()).isEqualTo("11");
assertThat(first.payload().get("orders")).hasSize(2);
assertThat(first.payload().at("/orders/0/sourceInboundId").asText()).isEqualTo("100");
assertThat(first.payload().at("/orders/0/items/0/sourceInboundDetailId").asText()).isEqualTo("101");
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(first.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2);
assertThat(first.payload().at("/orders/0/packageCount").asInt()).isEqualTo(2);
assertThat(first.payload().at("/orders/0/productQuantity").asInt()).isEqualTo(20);
assertThat(first.payload().at("/orders/0/volumeCbm").decimalValue()).isEqualByComparingTo("2");
assertThat(first.payload().at("/orders/0/volumeWeightKg").decimalValue()).isEqualByComparingTo("4");
assertThat(first.payload().at("/estimatedPortArrivalAt").asText()).isEqualTo("2026-09-30T12:00");
assertThat(first.payload().at("/actualPortArrivalAt").asText()).isEqualTo("2026-10-01T08:30");
assertThat(first.payload().at("/estimatedWarehouseArrivalAt").asText()).isEqualTo("2026-10-03T09:00");
assertThat(first.payload().at("/sourceContainerBookingId").asText()).isEqualTo("91");
assertThat(first.payload().get("loadingEvidence")).hasSize(5);
assertThat(first.payload().at("/loadingEvidence/0/evidenceType").asText()).isEqualTo("PHOTO_STEP_1");
assertThat(first.payload().at("/loadingEvidence/4/evidenceType").asText()).isEqualTo("SEAL");
assertThat(first.payload().has("containerBookingId")).isFalse();
assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:3");
assertThat(first.eventVersion()).isEqualTo(3);
}
/** 非 ISO 柜号能生成当前 V3 事件,并保持冻结柜号原值。 */
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "LOCAL-2026-001"})
void publishesNonIsoContainerNumber(String containerNo) throws Exception {
Row value = row(11, 100, 101);
value.setContainerNo(containerNo);
when(snapshots.rows(90L)).thenReturn(List.of(value));
service.publish(90L);
ArgumentCaptor<InboundOutboxEntity> captured = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(outbox).insert(captured.capture());
EventEnvelope event = mapper.readValue(captured.getValue().getPayloadJson(), EventEnvelope.class);
assertThat(event.eventVersion()).isEqualTo(3);
assertThat(event.payload().path("containerNo").asText()).isEqualTo(containerNo);
}
@Test void requiresFrozenPhysicalContainerNumberBeforePublishing() {
Row value = row(11, 100, 101); value.setContainerNo(" ");
when(snapshots.rows(90L)).thenReturn(List.of(value));
assertThatThrownBy(() -> service.publish(90L))
.hasMessageContaining("真实集装箱号");
verify(outbox, org.mockito.Mockito.never()).insert(
org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
}
@Test void rejectsNonCanonicalContainerNumberAndDuplicateEventKeyPayloadDrift() {
Row value = row(11, 100, 101); value.setContainerNo(" zcsu7881970 ");
when(snapshots.rows(90L)).thenReturn(List.of(value));
assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("真实集装箱号");
value.setContainerNo("ZCSU7881970");
InboundOutboxEntity existing = new InboundOutboxEntity(); existing.setPayloadHash("different");
org.mockito.Mockito.when(outbox.insert(org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class)))
.thenThrow(new org.springframework.dao.DuplicateKeyException("duplicate"));
org.mockito.Mockito.when(outbox.selectByEventKey(org.mockito.ArgumentMatchers.anyString()))
.thenReturn(existing);
assertThatThrownBy(() -> service.publish(90L))
.hasMessageContaining("eventKey").hasMessageContaining("漂移");
}
@Test void rejectsOrdinaryItemWhenSkuToCartonMappingCannotBeBuilt() {
Row value = row(11, 100, 101);
value.setSkuCode(null);
when(snapshots.rows(90L)).thenReturn(List.of(value));
assertThatThrownBy(() -> service.publish(90L))
.hasMessageContaining("缺少 SKU");
}
@Test void rejectsMissingTrustedPlanItemCustomerName() {
Row value = row(11, 100, 101);
value.setCustomerName(" ");
when(snapshots.rows(90L)).thenReturn(List.of(value));
assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("缺少客户名称快照");
}
@Test void publishesFrozenPlanItemCustomerSnapshotWithoutDependingOnBookingOwner() throws Exception {
Row value = row(11, 100, 101);
value.setCustomerName("南玻万");
value.setDestinationType("FBA");
when(snapshots.rows(90L)).thenReturn(List.of(value));
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.tenantId()).isEqualTo("11");
assertThat(event.payload().at("/orders/0/customerName").asText()).isEqualTo("南玻万");
}
@Test void fbaFreezesPackageCountWithoutRequiringCartonLabels() throws Exception {
Row value = row(11, 100, 101);
value.setDestinationType("FBA");
when(snapshots.rows(90L)).thenReturn(List.of(value));
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/destinationType").asText()).isEqualTo("FBA");
assertThat(event.payload().at("/orders/0/items/0/cartonMarks")).isEmpty();
verify(manifests, org.mockito.Mockito.never()).labelPool(11L, 100L);
}
@Test void rejectsUnfrozenDestinationType() {
Row value = row(11, 100, 101);
value.setDestinationType(null);
when(snapshots.rows(90L)).thenReturn(List.of(value));
assertThatThrownBy(() -> service.publish(90L))
.hasMessageContaining("目的类型必须唯一且为 FBA 或 OVERSEAS");
verify(outbox, org.mockito.Mockito.never()).insert(
org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
}
@Test void freezesExactlyLoadedPackagesFromLabelPoolDeterministically() throws Exception {
Row value = row(11, 100, 101);
value.setPackageCount(2);
when(snapshots.rows(90L)).thenReturn(List.of(value));
when(manifests.labelPool(11L, 100L)).thenReturn(List.of(
label(101, "BOX-001"), label(101, "BOX-002"),
label(101, "BOX-003"), label(101, "BOX-004")));
service.publish(90L);
service.publish(90L);
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(outbox, org.mockito.Mockito.times(2)).insert(captor.capture());
EventEnvelope first = mapper.readValue(captor.getAllValues().get(0).getPayloadJson(), EventEnvelope.class);
EventEnvelope second = mapper.readValue(captor.getAllValues().get(1).getPayloadJson(), EventEnvelope.class);
assertThat(first.payload().at("/orders/0/items/0/cartonMarks")).hasSize(2);
assertThat(first.payload().at("/orders/0/items/0/cartonMarks"))
.isEqualTo(second.payload().at("/orders/0/items/0/cartonMarks"));
}
@Test void publishesOnlyTheActuallyLoadedFragmentMeasurements() throws Exception {
Row loadedFragment = row(11, 100, 101);
loadedFragment.setPackageCount(4);
loadedFragment.setGrossWeightKg(new BigDecimal("40"));
loadedFragment.setNetWeightKg(new BigDecimal("36"));
loadedFragment.setDimensionalWeightKg(new BigDecimal("80"));
loadedFragment.setVolumeCbm(new BigDecimal("4"));
when(snapshots.rows(90L)).thenReturn(List.of(loadedFragment));
when(manifests.labelPool(11L, 100L)).thenReturn(List.of(
label(101, "BOX-001"), label(101, "BOX-002"),
label(101, "BOX-003"), label(101, "BOX-004")));
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/packageCount").asInt()).isEqualTo(4);
assertThat(event.payload().at("/orders/0/grossWeightKg").decimalValue()).isEqualByComparingTo("40");
assertThat(event.payload().at("/orders/0/volumeWeightKg").decimalValue()).isEqualByComparingTo("80");
assertThat(event.payload().at("/orders/0/volumeCbm").decimalValue()).isEqualByComparingTo("4");
assertThat(event.payload().at("/orders/0/items/0/packageCount").asInt()).isEqualTo(4);
assertThat(event.payload().at("/orders/0/items/0/grossWeightKg").decimalValue()).isEqualByComparingTo("40");
assertThat(event.payload().at("/orders/0/items/0/volumeCbm").decimalValue()).isEqualByComparingTo("4");
}
@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 keepsMissingPalletAndMeasurementFactsNullInsteadOfInventingZero() throws Exception {
Row value = row(11, 100, 101);
value.setProductQuantity(null); value.setVolumeCbm(null); value.setDimensionalWeightKg(null);
value.setEstimatedPortArrivalAt(null); value.setActualPortArrivalAt(null);
value.setEstimatedWarehouseArrivalAt(null);
when(snapshots.rows(90L)).thenReturn(List.of(value));
when(snapshots.palletRows(90L)).thenReturn(List.of());
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/palletCount").isNull()).isTrue();
assertThat(event.payload().at("/orders/0/productQuantity").isNull()).isTrue();
assertThat(event.payload().at("/orders/0/volumeCbm").isNull()).isTrue();
assertThat(event.payload().at("/orders/0/volumeWeightKg").isNull()).isTrue();
assertThat(event.payload().at("/estimatedPortArrivalAt").isNull()).isTrue();
assertThat(event.payload().at("/actualPortArrivalAt").isNull()).isTrue();
assertThat(event.payload().at("/estimatedWarehouseArrivalAt").isNull()).isTrue();
}
@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);
when(snapshots.rows(90L)).thenReturn(List.of(first, second));
when(manifests.labelPool(11L, 100L)).thenReturn(List.of(
label(201, "BOX-001"), label(202, "BOX-002"), label(203, "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);
List<String> firstMarks = mapper.convertValue(event.payload().at("/orders/0/items/0/cartonMarks"), List.class);
List<String> secondMarks = mapper.convertValue(event.payload().at("/orders/0/items/1/cartonMarks"), List.class);
assertThat(firstMarks).hasSize(1);
assertThat(secondMarks).hasSize(2);
assertThat(firstMarks).doesNotContainAnyElementsOf(secondMarks);
}
@Test void snapshotSupportsReadOnlyPreflightBeforeFinalStatusMutation() throws Exception {
String rowsSql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("rows", long.class)
.getAnnotation(Select.class).value()).replaceAll("\\s+", " ");
String palletsSql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("palletRows", long.class)
.getAnnotation(Select.class).value()).replaceAll("\\s+", " ");
String actualPalletsSql = String.join(" ", LoadingCompletedEventMapper.class
.getMethod("actualPalletRows", long.class).getAnnotation(Select.class).value())
.replaceAll("\\s+", " ");
String workPalletCountSql = String.join(" ", LoadingCompletedEventMapper.class
.getMethod("workPalletCount", long.class).getAnnotation(Select.class).value())
.replaceAll("\\s+", " ");
String openWorkPalletCountSql = String.join(" ", LoadingCompletedEventMapper.class
.getMethod("openWorkPalletCount", long.class).getAnnotation(Select.class).value())
.replaceAll("\\s+", " ");
assertThat(rowsSql).doesNotContain("p.plan_status = 'COMPLETED'")
.doesNotContain("o.process_status = 'LOADED'")
.contains("i.customer_name")
.doesNotContain("b.customer_id AS booking_customer_id")
.doesNotContain("b.customer_name")
.contains("b.estimated_port_arrival_at", "b.actual_port_arrival_at")
.contains("b.estimated_warehouse_arrival_at");
for (String sql : List.of(rowsSql, palletsSql)) {
assertThat(sql)
.contains("actual_pick.loading_plan_item_id = i.id")
.contains("actual_pick.item_status IN ('PICKED','PARTIAL')")
.contains("actual_pick.picked_package_count > 0")
.contains("actual_pick.picked_package_count = i.package_count")
.contains("actual_task.id = actual_pick.pick_task_id")
.contains("actual_task.loading_plan_id =")
.doesNotContain("'SKIPPED'")
.doesNotContain("'PENDING'")
.doesNotContain("'PICKING'");
}
assertThat(palletsSql)
.contains("a.pallet_id", "a.reservation_id")
.contains("label.tenant_id = i.account_id")
.contains("label.inbound_detail_id = i.inbound_detail_id")
.contains("label.batch_status = 'ACTIVE'")
.doesNotContain("label.inbound_id = i.inbound_id");
assertThat(actualPalletsSql)
.contains("pallet.pallet_no AS pallet_code")
.contains("item.pallet_id=pallet.id")
.contains("LEFT JOIN fl_dropship_pallet_carton_sku carton_sku")
.contains("COALESCE(carton_sku.inbound_detail_id,carton.inbound_detail_id)")
.doesNotContain("pallet.pallet_code");
assertThat(workPalletCountSql).contains("i.pallet_id=p.id");
assertThat(openWorkPalletCountSql).contains("i.pallet_id=p.id");
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans = {true, false})
void freezesActualSkuCartonsAndRejectsInferredPalletSources(boolean actualBinding) throws Exception {
Row first = row(11, 100, 101); first.setFulfillmentType("ONE_CLICK_DROPSHIP");
first.setSkuCode("SAME-SKU"); first.setProductQuantity(10);
Row second = row(11, 100, 102); second.setFulfillmentType("ONE_CLICK_DROPSHIP");
second.setSkuCode("SAME-SKU"); second.setProductQuantity(20);
when(snapshots.rows(90L)).thenReturn(List.of(second, first));
if (actualBinding) {
when(snapshots.workPalletCount(90L)).thenReturn(1);
when(snapshots.actualPalletRows(90L)).thenReturn(List.of(
actualPallet(11,100,"DP-260909-0001",102,"SAME-SKU","BOX-102",20),
actualPallet(11,100,"DP-260909-0001",101,"SAME-SKU","BOX-101",10)));
} else {
when(snapshots.palletRows(90L)).thenReturn(List.of(
pallet(11,100,1,"DP-260909-0001",2,102,"SAME-SKU",1,20,"BOX-102"),
pallet(11,100,1,"DP-260909-0001",2,101,"SAME-SKU",1,10,"BOX-101")));
assertThatThrownBy(() -> service.publish(90L)).isInstanceOf(com.baiyue.common.core.exception.BizException.class)
.hasMessageContaining("真实逐箱实收");
verify(outbox, org.mockito.Mockito.never()).insert(org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
return;
}
service.publish(90L);
service.publish(90L);
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(outbox, org.mockito.Mockito.times(2)).insert(captor.capture());
EventEnvelope event = mapper.readValue(captor.getAllValues().get(0).getPayloadJson(), EventEnvelope.class);
var frozen = event.payload().at("/orders/0/pallets/0/skuPackages");
assertThat(frozen).hasSize(2);
for (var sku : frozen) {
String detail = sku.path("sourceInboundDetailId").asText();
assertThat(detail).isIn("101", "102");
assertThat(sku.path("skuCode").asText()).isEqualTo("SAME-SKU");
assertThat(sku.path("cartonMarks")).hasSize(1);
assertThat(sku.path("cartonMarks").get(0).asText()).isEqualTo("BOX-" + detail);
assertThat(sku.path("productQuantity").asInt()).isEqualTo("101".equals(detail) ? 10 : 20);
assertThat(sku.path("cartonQuantities").path("BOX-" + detail).asInt()).isEqualTo("101".equals(detail) ? 10 : 20);
}
assertThat(frozen.get(0).path("sourceInboundDetailId"))
.isNotEqualTo(frozen.get(1).path("sourceInboundDetailId"));
assertThat(captor.getAllValues().get(0).getPayloadHash()).isEqualTo(captor.getAllValues().get(1).getPayloadHash());
}
@Test void publishesDropshipPalletSkuAndCartonSnapshots() throws Exception {
Row first = row(11, 100, 101); first.setFulfillmentType("ONE_CLICK_DROPSHIP");
first.setPackageCount(2); first.setProductQuantity(20); first.setSkuCode("SKU-A");
Row second = row(11, 100, 102); second.setFulfillmentType("ONE_CLICK_DROPSHIP");
second.setPackageCount(1); second.setProductQuantity(5); second.setSkuCode("SKU-B");
when(snapshots.rows(90L)).thenReturn(List.of(first, second));
when(snapshots.workPalletCount(90L)).thenReturn(2);
when(snapshots.actualPalletRows(90L)).thenReturn(List.of(
actualPallet(11,100,"PLT-01",101,"SKU-A","BOX-001",7),
actualPallet(11,100,"PLT-01",101,"SKU-A","BOX-002",13),
actualPallet(11,100,"PLT-02",102,"SKU-B","BOX-003",5)));
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/pallets/0/skuPackages/0/cartonQuantities/BOX-001").asInt()).isEqualTo(7);
assertThat(event.payload().at("/orders/0/pallets/0/skuPackages/0/cartonQuantities/BOX-002").asInt()).isEqualTo(13);
assertThat(event.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2);
}
@Test
void multiSkuCartonsKeepSkuSnapshotsButCountPhysicalPackagesOnce() throws Exception {
Row first = row(11, 100, 101); first.setFulfillmentType("ONE_CLICK_DROPSHIP");
first.setPackageCount(2); first.setProductQuantity(20); first.setSkuCode("SKU-A");
Row second = row(11, 100, 102); second.setFulfillmentType("ONE_CLICK_DROPSHIP");
second.setPackageCount(2); second.setProductQuantity(10); second.setSkuCode("SKU-B");
when(snapshots.rows(90L)).thenReturn(List.of(first, second));
when(snapshots.workPalletCount(90L)).thenReturn(1);
when(snapshots.actualPalletRows(90L)).thenReturn(List.of(
actualPallet(11,100,"DP-260916-0009",101,"SKU-A","BOX-001",10),
actualPallet(11,100,"DP-260916-0009",102,"SKU-B","BOX-001",5),
actualPallet(11,100,"DP-260916-0009",101,"SKU-A","BOX-002",10),
actualPallet(11,100,"DP-260916-0009",102,"SKU-B","BOX-002",5)));
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/packageCount").asInt()).isEqualTo(2);
assertThat(event.payload().at("/orders/0/items")).hasSize(2);
assertThat(event.payload().at("/orders/0/pallets/0/manifestPackageCount").asInt()).isEqualTo(2);
assertThat(event.payload().at("/orders/0/pallets/0/skuPackages")).hasSize(2);
assertThat(event.payload().at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2);
assertThat(event.payload().at("/orders/0/pallets/0/skuPackages/1/cartonMarks")).hasSize(2);
}
@Test void publishesExactPdaCartonToPalletBindingsWithoutSequentialRedistribution() throws Exception {
Row first = row(11, 100, 101); first.setFulfillmentType("ONE_CLICK_DROPSHIP");
first.setPackageCount(2); first.setProductQuantity(20); first.setSkuCode("SKU-A");
Row second = row(11, 100, 102); second.setFulfillmentType("ONE_CLICK_DROPSHIP");
second.setPackageCount(2); second.setProductQuantity(10); second.setSkuCode("SKU-B");
when(snapshots.rows(90L)).thenReturn(List.of(first, second));
when(snapshots.workPalletCount(90L)).thenReturn(2);
when(snapshots.actualPalletRows(90L)).thenReturn(List.of(
actualPallet(11,100,"PLT-01",101,"SKU-A","BOX-A2",10),
actualPallet(11,100,"PLT-01",102,"SKU-B","BOX-B1",5),
actualPallet(11,100,"PLT-02",101,"SKU-A","BOX-A1",10),
actualPallet(11,100,"PLT-02",102,"SKU-B","BOX-B2",5)));
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/pallets/0/skuPackages/0/cartonMarks/0").asText())
.isEqualTo("BOX-A2");
assertThat(event.payload().at("/orders/0/pallets/1/skuPackages/0/cartonMarks/0").asText())
.isEqualTo("BOX-A1");
assertThat(event.payload().at("/orders/0/pallets/0/manifestHash").asText())
.matches("[0-9a-f]{64}")
.doesNotStartWith("sha256:");
}
@Test void mergesOneDropshipSourceDetailSplitAcrossTwoPalletsBeforePublishing() throws Exception {
Row first = row(11, 100, 101); first.setFulfillmentType("ONE_CLICK_DROPSHIP");
Row second = row(11, 100, 101); second.setFulfillmentType("ONE_CLICK_DROPSHIP");
second.setLoadingPlanItemId(102L);
when(snapshots.rows(90L)).thenReturn(List.of(first, second));
when(snapshots.workPalletCount(90L)).thenReturn(2);
when(snapshots.actualPalletRows(90L)).thenReturn(List.of(
actualPallet(11,100,"DP-260914-0021",101,"SKU101","BOX-001",10),
actualPallet(11,100,"DP-260914-0022",101,"SKU101","BOX-002",10)));
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/items")).hasSize(1);
assertThat(event.payload().at("/orders/0/items/0/sourceInboundDetailId").asText()).isEqualTo("101");
assertThat(event.payload().at("/orders/0/items/0/packageCount").asInt()).isEqualTo(2);
assertThat(event.payload().at("/orders/0/items/0/productQuantity").asInt()).isEqualTo(20);
assertThat(event.payload().at("/orders/0/pallets")).hasSize(2);
assertThat(event.payload().at("/orders/0/pallets/0/skuPackages/0/sourceInboundDetailId").asText())
.isEqualTo("101");
assertThat(event.payload().at("/orders/0/pallets/1/skuPackages/0/sourceInboundDetailId").asText())
.isEqualTo("101");
}
@Test
void pricingBusinessTypeCannotTurnAStandardOrderIntoDropship() throws Exception {
Row value = row(11, 100, 101);
value.setBusinessType("ONE_CLICK_DROPSHIP");
value.setFulfillmentType("STANDARD");
when(snapshots.rows(90L)).thenReturn(List.of(value));
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("FIRST_LEG");
assertThat(event.payload().at("/orders/0/pallets")).isEmpty();
}
@Test
void rejectsMixedFulfillmentTypesWithinOneInbound() {
Row first = row(11, 100, 101);
Row second = row(11, 100, 102);
second.setFulfillmentType("ONE_CLICK_DROPSHIP");
when(snapshots.rows(90L)).thenReturn(List.of(first, second));
assertThatThrownBy(() -> service.publish(90L))
.hasMessageContaining("履约类型必须唯一");
verify(outbox, org.mockito.Mockito.never()).insert(
org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
}
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("ZCSU7881970"); r.setCabinetNo("CAB"); r.setWarehouseId(92L);
r.setWarehouseName("WH"); r.setCustomerName("客户-" + account);
r.setEstimatedPortArrivalAt(java.time.LocalDateTime.of(2026, 9, 30, 12, 0));
r.setActualPortArrivalAt(java.time.LocalDateTime.of(2026, 10, 1, 8, 30));
r.setEstimatedWarehouseArrivalAt(java.time.LocalDateTime.of(2026, 10, 3, 9, 0));
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);
r.setDestinationType("OVERSEAS");
r.setFulfillmentType("STANDARD");
r.setPackageCount(1); r.setProductQuantity(10); r.setPalletCount(1); r.setGrossWeightKg(BigDecimal.ONE);
r.setNetWeightKg(BigDecimal.ONE); r.setDimensionalWeightKg(new BigDecimal("2"));
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.setPalletId(inbound * 100 + allocation); row.setReservationId(inbound * 1000 + allocation);
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;
}
private PalletRow actualPallet(long account,long inbound,String palletNo,long detail,
String sku,String boxCode,int actualSkuQuantity) {
PalletRow row = pallet(account,inbound,0,palletNo,1,detail,sku,1,1,boxCode);
row.setActualBinding(true);
row.setPalletId(Integer.toUnsignedLong(palletNo.hashCode()) + 1L);
row.setActualSkuQuantity(actualSkuQuantity);
return row;
}
private LabelRow label(long detailId, String boxCode) {
LabelRow row = new LabelRow(); row.setInboundDetailId(detailId); row.setBoxCode(boxCode); return row;
}
}