This commit is contained in:
2026-09-30 17:13:11 +08:00
parent babc7de4b6
commit 32fb9ec7b3
26 changed files with 1484 additions and 70 deletions
@@ -27,6 +27,7 @@ import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
@@ -406,6 +407,9 @@ public class OrderApplicationService {
throw new BizException(ResultCode.VALIDATION_FAILED, "木制品必须选择商检");
}
}
validateMixedCartons(order.getFulfillmentType(), cargoes.stream().map(cargo -> new MixedCarton(
cargo.getPackingGroupNo(), cargo.getPackageCount(), cargo.getPackageType(), cargo.getLengthCm(),
cargo.getWidthCm(), cargo.getHeightCm(), cargo.getGrossWeightKg(), cargo.getNetWeightKg(), cargo.getVolumeCbm())).toList());
var parties = repository.findParties(tenantId, order.getId());
if ("PICKUP".equals(order.getPickupMethod()) && !hasParty(parties, "SENDER")) {
throw new BizException(ResultCode.VALIDATION_FAILED, "上门提货时必须填写发货信息");
@@ -887,13 +891,21 @@ public class OrderApplicationService {
return (int) total;
}
private void validateMixedCartons(OrderRequests.SaveRequest request) {
if (!"ONE_CLICK_DROPSHIP".equals(request.fulfillmentType())) return;
Map<String, OrderRequests.Cargo> firstByGroup = new LinkedHashMap<>();
for (OrderRequests.Cargo cargo : request.cargoes()) {
validateMixedCartons(request.fulfillmentType(), request.cargoes().stream().map(cargo -> new MixedCarton(
cargo.packingGroupNo(), cargo.packageCount(), cargo.packageType(), cargo.lengthCm(),
cargo.widthCm(), cargo.heightCm(), cargo.grossWeightKg(), cargo.netWeightKg(), cargo.volumeCbm())).toList());
}
private record MixedCarton(String packingGroupNo, Integer packageCount, String packageType,
BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm) { }
private void validateMixedCartons(String fulfillmentType, List<MixedCarton> cargoes) {
if (!"ONE_CLICK_DROPSHIP".equals(fulfillmentType)) return;
Map<String, MixedCarton> firstByGroup = new LinkedHashMap<>();
for (MixedCarton cargo : cargoes) {
String group = normalize(cargo.packingGroupNo());
if (group == null) continue;
OrderRequests.Cargo first = firstByGroup.putIfAbsent(group, cargo);
if (first != null && (first.packageCount() != cargo.packageCount()
MixedCarton first = firstByGroup.putIfAbsent(group, cargo);
if (first != null && (!Objects.equals(first.packageCount(), cargo.packageCount())
|| !Objects.equals(first.packageType(), cargo.packageType())
|| !sameDecimal(first.lengthCm(), cargo.lengthCm())
|| !sameDecimal(first.widthCm(), cargo.widthCm())
@@ -441,6 +441,85 @@ class OrderApplicationServiceStateTest {
verify(repository, never()).insertHistory(any());
}
@Test
void submittingPersistedMixedCartonRejectsEveryPhysicalMismatchWithoutWritingForBothTaxModes() {
List<java.util.function.Consumer<OrderCargoEntity>> conflicts = List.of(
cargo -> cargo.setLengthCm(new BigDecimal("31")),
cargo -> { cargo.setPackageCount(3); cargo.setTotalQuantity(cargo.getUnitsPerPackage() * 3); },
cargo -> cargo.setPackageType("PALLET"),
cargo -> cargo.setWidthCm(new BigDecimal("21")),
cargo -> cargo.setHeightCm(new BigDecimal("21")),
cargo -> cargo.setGrossWeightKg(new BigDecimal("21")),
cargo -> cargo.setNetWeightKg(new BigDecimal("19")),
cargo -> cargo.setVolumeCbm(new BigDecimal("0.025")));
for (String taxMode : List.of("BUY_DECLARATION", "TAX_REFUND")) {
for (var conflict : conflicts) {
OrderRepository repository = mock(OrderRepository.class);
OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS");
order.setFulfillmentType("ONE_CLICK_DROPSHIP"); order.setTaxMode(taxMode);
OrderCargoEntity third = mixedCargo("SKU-3", 10); conflict.accept(third);
when(repository.findOwned(20002L, 1L)).thenReturn(order);
when(repository.findCargo(20002L, 1L)).thenReturn(List.of(
mixedCargo("SKU-1", 10), mixedCargo("SKU-2", 11), third));
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
.submit(20002L, 2L, "u", 1L, "mixed-carton-conflict"))
.isInstanceOf(BizException.class).hasMessage("同箱 SKU 的箱数、包装类型、箱规、重量和体积必须一致")
.extracting(exception -> ((BizException) exception).getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
assertThat(order.getStatus()).isEqualTo(OrderStatus.DRAFT);
assertThat(order.getSubmittedAt()).isNull(); assertThat(order.getInboundCode()).isNull();
verify(repository, never()).saveOrder(anyLong(), any());
verify(repository, never()).insertHistory(any()); verify(repository, never()).insertOutbox(any());
verify(repository, never()).completeIdempotency(anyLong(), any(), anyLong());
}
}
}
@Test
void submittingConsistentMixedCartonAllowsDifferentSkuQuantitiesAndDecimalScalesAndCountsBoxesOnce() {
OrderRepository repository = mock(OrderRepository.class);
OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS");
order.setFulfillmentType("ONE_CLICK_DROPSHIP");
OrderCargoEntity third = mixedCargo("SKU-3", 12);
third.setPackingGroupNo(" GROUP-1 "); third.setLengthCm(new BigDecimal("30.00"));
third.setGrossWeightKg(new BigDecimal("20.0")); third.setVolumeCbm(new BigDecimal("0.0240"));
when(repository.findOwned(20002L, 1L)).thenReturn(order);
when(repository.findCargo(20002L, 1L)).thenReturn(List.of(mixedCargo("SKU-1", 10), mixedCargo("SKU-2", 11), third));
when(repository.findParties(20002L, 1L)).thenReturn(List.of());
when(repository.saveOrder(20002L, order)).thenReturn(1);
service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
.transit(20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null);
assertThat(order.getStatus()).isEqualTo(OrderStatus.SUBMITTED);
verify(repository).saveOrder(20002L, order);
verify(repository).insertOutbox(argThat(event -> event.getPayloadJson().contains("\"totalPackageCount\":2")));
}
@Test
void submittedMixedCartonEditorRejectsThirdSkuLengthConflictBeforeReplacingCargo() {
OrderRepository repository = mock(OrderRepository.class);
OrderEntity order = persistedOrder(OrderStatus.SUBMITTED);
when(repository.findOwned(10001L, 1L)).thenReturn(order);
var request = mixedCartonRequest(31);
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
.updateSubmittedOrder(10001L, 2L, 1L, request))
.isInstanceOf(BizException.class).hasMessage("同箱 SKU 的箱数、包装类型、箱规、重量和体积必须一致")
.extracting(exception -> ((BizException) exception).getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
verify(repository, never()).saveOrder(anyLong(), any());
verify(repository, never()).replaceDraftChildren(anyLong(), anyLong());
verify(repository, never()).insertHistory(any()); verify(repository, never()).insertOutbox(any());
}
@Test
void mixedCartonDraftStillAllowsIncompletePhysicalGroupBeforeSubmission() {
OrderRepository repository = mock(OrderRepository.class);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
var request = mixedCartonRequest(31);
when(masterData.validate(10001L, request)).thenReturn(snapshots());
when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1);
service(repository, mock(OrderEligibilityGuard.class), masterData)
.createDraft(10001L, 2L, request, "mixed-carton-draft");
verify(repository).insertOrder(argThat(order -> order.getStatus() == OrderStatus.DRAFT));
}
@Test
void submittingDraftPreparesInboundForWarehouseMeasurementBeforeReview() {
OrderRepository repository = mock(OrderRepository.class);
@@ -1013,6 +1092,39 @@ class OrderApplicationServiceStateTest {
return cargo;
}
private OrderCargoEntity mixedCargo(String skuNo, int unitsPerPackage) {
OrderCargoEntity cargo = completeCargo();
cargo.setSkuNo(skuNo); cargo.setPackingGroupNo("GROUP-1"); cargo.setPackageCount(2);
cargo.setUnitsPerPackage(unitsPerPackage); cargo.setTotalQuantity(unitsPerPackage * 2);
cargo.setLengthCm(new BigDecimal("30")); cargo.setWidthCm(new BigDecimal("20")); cargo.setHeightCm(new BigDecimal("20"));
cargo.setGrossWeightKg(new BigDecimal("20")); cargo.setNetWeightKg(new BigDecimal("18"));
cargo.setVolumeCbm(new BigDecimal("0.024"));
return cargo;
}
private OrderRequests.SaveRequest mixedCartonRequest(int thirdLength) {
OrderRequests.SaveRequest source = request();
var mapper = new ObjectMapper();
var cargoes = java.util.stream.IntStream.rangeClosed(1, 3).mapToObj(index -> {
var json = mapper.valueToTree(source.cargoes().get(0));
var cargo = (com.fasterxml.jackson.databind.node.ObjectNode) json;
cargo.put("skuNo", "SKU-" + index); cargo.put("packingGroupNo", "GROUP-1"); cargo.put("packageCount", 2);
cargo.put("unitsPerPackage", index == 2 ? 11 : 10); cargo.put("totalQuantity", index == 2 ? 22 : 20);
cargo.put("lengthCm", index == 3 ? thirdLength : 30); cargo.put("widthCm", 20); cargo.put("heightCm", 20);
cargo.put("grossWeightKg", 20); cargo.put("netWeightKg", 18); cargo.put("volumeCbm", new BigDecimal("0.024"));
return mapper.convertValue(cargo, OrderRequests.Cargo.class);
}).toList();
return new OrderRequests.SaveRequest(
source.customerId(), source.customerName(), source.transportMode(), source.tradeTerm(),
source.destinationCountryCode(), source.destinationWarehouseId(), source.destinationWarehouseName(),
source.expectedShipDate(), source.customerRemark(), source.sender(), source.receiver(), cargoes,
source.pickupMethod(), source.requestedPickupAt(), source.receivingWarehouseId(),
source.receivingWarehouseName(), source.channelCode(), source.channelName(), source.carrierId(),
source.carrierName(), source.taxMode(), source.pricingTaxMode(), source.containerMode(),
source.customsInspection(), source.destinationType(), source.amazonWarehouseCode(), source.shipmentId(),
source.referenceId(), source.inboundCode(), "ONE_CLICK_DROPSHIP");
}
private OrderEntity persistedOrder(OrderStatus status) {
OrderEntity order = new OrderEntity();
order.setId(1L);