572 lines
34 KiB
Java
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;
|
|
}
|
|
}
|