feat: support mixed-SKU dropship cartons
This commit is contained in:
@@ -10,6 +10,7 @@ import com.baiyue.order.entity.OrderCargoEntity;
|
||||
import com.baiyue.order.repository.OrderRepository;
|
||||
import com.baiyue.order.service.OrderApplicationService;
|
||||
import com.baiyue.order.service.PrintableInboundSnapshotService;
|
||||
import com.baiyue.order.service.OrderCargoPackageCounts;
|
||||
import com.baiyue.order.client.FirstlegInboundClient;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -84,7 +85,7 @@ public class InternalOrderCargoController {
|
||||
.map(cargo -> new CargoLabelBinding(cargo.getId(), orderId, cargo.getItemNo(),
|
||||
cargo.getCargoName(), cargo.getSkuNo(), cargo.getPackageCount(),
|
||||
unitsPerCarton(cargo, oneClickDropship), cargo.getLengthCm(), cargo.getWidthCm(),
|
||||
cargo.getHeightCm(), cargo.getShipmentId()))
|
||||
cargo.getHeightCm(), cargo.getShipmentId(), cargo.getPackingGroupNo()))
|
||||
.toList());
|
||||
}
|
||||
|
||||
@@ -109,9 +110,8 @@ public class InternalOrderCargoController {
|
||||
if (order == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "订单不存在或不属于当前客户");
|
||||
}
|
||||
int packageCount = orders.findCargoInheritedByOrder(tenantId, orderId).stream()
|
||||
.map(OrderCargoEntity::getPackageCount).filter(java.util.Objects::nonNull)
|
||||
.mapToInt(Integer::intValue).sum();
|
||||
int packageCount = OrderCargoPackageCounts.physical(
|
||||
orders.findCargoInheritedByOrder(tenantId, orderId), order.getFulfillmentType());
|
||||
return ApiResponse.success(OrderApplicationService.inboundCommand(order, packageCount));
|
||||
}
|
||||
|
||||
@@ -187,18 +187,18 @@ public class InternalOrderCargoController {
|
||||
String skuNo, Integer packageCount, Integer unitsPerCarton,
|
||||
java.math.BigDecimal lengthCm,
|
||||
java.math.BigDecimal widthCm, java.math.BigDecimal heightCm,
|
||||
String shipmentId) {
|
||||
String shipmentId, String packingGroupNo) {
|
||||
public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
|
||||
Integer packageCount, java.math.BigDecimal lengthCm,
|
||||
java.math.BigDecimal widthCm, java.math.BigDecimal heightCm) {
|
||||
this(cargoId, orderId, itemNo, cargoName, null, packageCount, null,
|
||||
lengthCm, widthCm, heightCm, null);
|
||||
lengthCm, widthCm, heightCm, null, null);
|
||||
}
|
||||
public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
|
||||
String skuNo, Integer packageCount, java.math.BigDecimal lengthCm,
|
||||
java.math.BigDecimal widthCm, java.math.BigDecimal heightCm) {
|
||||
this(cargoId, orderId, itemNo, cargoName, skuNo, packageCount, null,
|
||||
lengthCm, widthCm, heightCm, null);
|
||||
lengthCm, widthCm, heightCm, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -211,7 +211,9 @@ public final class OrderRequests {
|
||||
@Size(max = 64) String upcNo,
|
||||
@Size(max = 128) String cargoNameEn,
|
||||
@Size(max = 20) String customsHsCode,
|
||||
@Schema(description = "Units per package / 每箱数量;提交时必填", minimum = "1")
|
||||
@Min(1) Integer unitsPerPackage,
|
||||
@Schema(description = "Total quantity / 总数量;提交时必填且必须等于每箱数量 × 箱数", minimum = "1")
|
||||
@Min(1) Integer totalQuantity,
|
||||
@DecimalMin("0.001") BigDecimal netWeightKg,
|
||||
@DecimalMin("0.00") BigDecimal unitDeclaredValue,
|
||||
@@ -226,8 +228,25 @@ public final class OrderRequests {
|
||||
@Size(max = 64) String referenceId,
|
||||
@Schema(description = "Product image file ID / 产品图片文件ID") Long productImageFileId,
|
||||
@Schema(description = "Authoritative wood-product decision frozen with this cargo line / 木制品权威判定,随货物行冻结", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotNull Boolean woodenProduct
|
||||
@NotNull Boolean woodenProduct,
|
||||
@Schema(description = "Mixed-carton group. Cargo lines with the same value share physical cartons / 混箱组合号;相同组合号的货物行属于同一批物理箱", maxLength = 32)
|
||||
@Size(max = 32) String packingGroupNo
|
||||
) {
|
||||
public Cargo(String cargoName, String hsCode, String packageType, int packageCount,
|
||||
BigDecimal grossWeightKg, BigDecimal volumeCbm, BigDecimal lengthCm,
|
||||
BigDecimal widthCm, BigDecimal heightCm, BigDecimal declaredValue, String currency,
|
||||
boolean dangerousGoods, String cargoRemark, String skuNo, String upcNo,
|
||||
String cargoNameEn, String customsHsCode, Integer unitsPerPackage, Integer totalQuantity,
|
||||
BigDecimal netWeightKg, BigDecimal unitDeclaredValue, String material, String usage,
|
||||
String model, String brand, BigDecimal productLengthCm, BigDecimal productWidthCm,
|
||||
BigDecimal productHeightCm, String shipmentId, String referenceId,
|
||||
Long productImageFileId, Boolean woodenProduct) {
|
||||
this(cargoName, hsCode, packageType, packageCount, grossWeightKg, volumeCbm, lengthCm, widthCm,
|
||||
heightCm, declaredValue, currency, dangerousGoods, cargoRemark, skuNo, upcNo, cargoNameEn,
|
||||
customsHsCode, unitsPerPackage, totalQuantity, netWeightKg, unitDeclaredValue, material, usage,
|
||||
model, brand, productLengthCm, productWidthCm, productHeightCm, shipmentId, referenceId,
|
||||
productImageFileId, woodenProduct, null);
|
||||
}
|
||||
public Cargo(String cargoName, String hsCode, String packageType, int packageCount,
|
||||
BigDecimal grossWeightKg, BigDecimal volumeCbm, BigDecimal lengthCm,
|
||||
BigDecimal widthCm, BigDecimal heightCm, BigDecimal declaredValue, String currency,
|
||||
@@ -240,7 +259,7 @@ public final class OrderRequests {
|
||||
heightCm, declaredValue, currency, dangerousGoods, cargoRemark, skuNo, upcNo, cargoNameEn,
|
||||
customsHsCode, unitsPerPackage, totalQuantity, netWeightKg, unitDeclaredValue, material, usage,
|
||||
model, brand, productLengthCm, productWidthCm, productHeightCm, shipmentId, referenceId,
|
||||
productImageFileId, false);
|
||||
productImageFileId, false, null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import com.baiyue.common.core.model.BaseEntity; import com.baomidou.mybatisplus.
|
||||
* 申报价值及危险品标识等货物信息。</p>
|
||||
*/
|
||||
@TableName("ord_order_cargo") public class OrderCargoEntity extends BaseEntity {
|
||||
@TableId(type=IdType.ASSIGN_ID) private Long id; private Long tenantId; private Long orderId; private Integer itemNo; private String cargoName; private String hsCode; private String packageType; private Integer packageCount; private BigDecimal grossWeightKg; private BigDecimal volumeCbm; private BigDecimal lengthCm; private BigDecimal widthCm; private BigDecimal heightCm; private BigDecimal declaredValue; private String currency; private Boolean dangerousGoods; private String cargoRemark; private String skuNo; private String upcNo; private String cargoNameEn; private String customsHsCode; private Integer unitsPerPackage; private Integer totalQuantity; private BigDecimal netWeightKg; private BigDecimal unitDeclaredValue; private String material; private String productUsage; private String model; private String brand; private BigDecimal productLengthCm; private BigDecimal productWidthCm; private BigDecimal productHeightCm; private String shipmentId; private String referenceId; private Long productImageFileId; private Boolean woodenProduct;
|
||||
@TableId(type=IdType.ASSIGN_ID) private Long id; private Long tenantId; private Long orderId; private Integer itemNo; private String cargoName; private String hsCode; private String packageType; private Integer packageCount; private BigDecimal grossWeightKg; private BigDecimal volumeCbm; private BigDecimal lengthCm; private BigDecimal widthCm; private BigDecimal heightCm; private BigDecimal declaredValue; private String currency; private Boolean dangerousGoods; private String cargoRemark; private String skuNo; private String upcNo; private String cargoNameEn; private String customsHsCode; private Integer unitsPerPackage; private Integer totalQuantity; private BigDecimal netWeightKg; private BigDecimal unitDeclaredValue; private String material; private String productUsage; private String model; private String brand; private BigDecimal productLengthCm; private BigDecimal productWidthCm; private BigDecimal productHeightCm; private String shipmentId; private String referenceId; private Long productImageFileId; private Boolean woodenProduct; private String packingGroupNo;
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -84,9 +84,8 @@ public class MerchantInboundOrderListService {
|
||||
List<OrderEntity> missing, Map<Long, List<OrderCargoEntity>> cargoes) {
|
||||
try {
|
||||
List<FirstlegInboundClient.OrderApprovedInboundCommand> commands = missing.stream().map(order -> {
|
||||
int packages = cargoes.getOrDefault(order.getId(), List.of()).stream()
|
||||
.map(OrderCargoEntity::getPackageCount).filter(Objects::nonNull)
|
||||
.mapToInt(Integer::intValue).sum();
|
||||
int packages = OrderCargoPackageCounts.physical(
|
||||
cargoes.getOrDefault(order.getId(), List.of()), order.getFulfillmentType());
|
||||
return OrderApplicationService.inboundCommand(order, packages);
|
||||
}).toList();
|
||||
var response = firstleg.prepareBatch(internalToken, commands);
|
||||
@@ -105,8 +104,7 @@ public class MerchantInboundOrderListService {
|
||||
OrderPartyEntity consignee,
|
||||
OrderStatusHistoryEntity latestHistory,
|
||||
FirstlegInboundClient.InboundReference inbound) {
|
||||
int packageCount = cargoes.stream().map(OrderCargoEntity::getPackageCount)
|
||||
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
|
||||
int packageCount = OrderCargoPackageCounts.physical(cargoes, order.getFulfillmentType());
|
||||
int totalQuantity = cargoes.stream().map(OrderCargoEntity::getTotalQuantity)
|
||||
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
|
||||
BigDecimal grossWeight = sum(cargoes, OrderCargoEntity::getGrossWeightKg);
|
||||
|
||||
@@ -31,9 +31,13 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.List;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.time.*;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
@@ -81,6 +85,7 @@ public class OrderApplicationService {
|
||||
OrderRequests.SaveRequest request) {
|
||||
AddressSnapshots addressSnapshots = resolveCustomerAddresses(tenantId, request);
|
||||
validateWoodInspection(request);
|
||||
validateRequestCargoQuantities(request.cargoes(), false);
|
||||
productImageGuard.requireOwned(tenantId, request.cargoes());
|
||||
var snapshots = masterDataGuard.validate(tenantId, request);
|
||||
requirePricingChannel(tenantId, request.receivingWarehouseId(), request.channelCode(),
|
||||
@@ -122,6 +127,7 @@ public class OrderApplicationService {
|
||||
validateWoodInspection(request);
|
||||
OrderEntity order = require(tenantId, id);
|
||||
if (allowSubmitted) validateCompleteRequest(request);
|
||||
else validateRequestCargoQuantities(request.cargoes(), false);
|
||||
productImageGuard.requireOwned(tenantId, request.cargoes());
|
||||
var snapshots = masterDataGuard.validate(tenantId, request);
|
||||
OrderStatus before = order.getStatus();
|
||||
@@ -296,7 +302,8 @@ public class OrderApplicationService {
|
||||
}
|
||||
var cargoes = repository.findCargo(tenantId, order.getId());
|
||||
if (cargoes == null || cargoes.isEmpty()) throw new BizException("At least one cargo line is required");
|
||||
for (var cargo : cargoes) {
|
||||
for (int cargoIndex = 0; cargoIndex < cargoes.size(); cargoIndex++) {
|
||||
var cargo = cargoes.get(cargoIndex);
|
||||
if (!StringUtils.hasText(cargo.getCargoName()) || !StringUtils.hasText(cargo.getPackageType())
|
||||
|| cargo.getPackageCount() == null || cargo.getPackageCount() < 1
|
||||
|| cargo.getGrossWeightKg() == null || cargo.getGrossWeightKg().signum() <= 0
|
||||
@@ -304,6 +311,7 @@ public class OrderApplicationService {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"提交订单前必须完善货物品名、包装类型、箱数、毛重和体积");
|
||||
}
|
||||
validatePersistedCargoQuantity(cargo, cargoIndex, true);
|
||||
if (Boolean.TRUE.equals(cargo.getWoodenProduct()) && !Boolean.TRUE.equals(order.getCustomsInspection())) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "木制品必须选择商检");
|
||||
}
|
||||
@@ -343,6 +351,7 @@ public class OrderApplicationService {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "私人目的仓必须选择客户地址");
|
||||
}
|
||||
validateWoodInspection(request);
|
||||
validateRequestCargoQuantities(request.cargoes(), true);
|
||||
for (var cargo : request.cargoes()) {
|
||||
if (!StringUtils.hasText(cargo.cargoName()) || !StringUtils.hasText(cargo.packageType())
|
||||
|| cargo.packageCount() < 1 || cargo.grossWeightKg() == null
|
||||
@@ -352,6 +361,7 @@ public class OrderApplicationService {
|
||||
"提交订单前必须完善货物品名、包装类型、箱数、毛重和体积");
|
||||
}
|
||||
}
|
||||
validateMixedCartons(request);
|
||||
if ("FBA".equals(request.destinationType())) {
|
||||
if (!StringUtils.hasText(request.amazonWarehouseCode()) || !StringUtils.hasText(request.shipmentId())
|
||||
|| !StringUtils.hasText(request.referenceId())) {
|
||||
@@ -368,6 +378,36 @@ public class OrderApplicationService {
|
||||
}
|
||||
}
|
||||
|
||||
private void validateRequestCargoQuantities(List<OrderRequests.Cargo> cargoes, boolean required) {
|
||||
for (int index = 0; index < cargoes.size(); index++) {
|
||||
OrderRequests.Cargo cargo = cargoes.get(index);
|
||||
Integer unitsPerPackage = cargo.unitsPerPackage();
|
||||
Integer totalQuantity = cargo.totalQuantity();
|
||||
if (unitsPerPackage == null && totalQuantity == null && !required) continue;
|
||||
validateCargoQuantity(unitsPerPackage, cargo.packageCount(), totalQuantity, index, required);
|
||||
}
|
||||
}
|
||||
|
||||
private void validatePersistedCargoQuantity(OrderCargoEntity cargo, int index, boolean required) {
|
||||
validateCargoQuantity(cargo.getUnitsPerPackage(), cargo.getPackageCount(), cargo.getTotalQuantity(), index, required);
|
||||
}
|
||||
|
||||
private void validateCargoQuantity(Integer unitsPerPackage, Integer packageCount, Integer totalQuantity,
|
||||
int index, boolean required) {
|
||||
if (unitsPerPackage == null || packageCount == null || totalQuantity == null) {
|
||||
if (required || unitsPerPackage != null || totalQuantity != null) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"第 " + (index + 1) + " 条货物必须填写每箱数量、箱数和总数量");
|
||||
}
|
||||
return;
|
||||
}
|
||||
long expected = (long) unitsPerPackage * packageCount;
|
||||
if (totalQuantity.longValue() != expected) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"第 " + (index + 1) + " 条货物总数量应等于每箱数量 × 箱数(" + expected + ")");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasParty(List<OrderPartyEntity> parties, String partyType) {
|
||||
return parties != null && parties.stream()
|
||||
.anyMatch(party -> party != null && partyType.equals(party.getPartyType()));
|
||||
@@ -601,6 +641,7 @@ public class OrderApplicationService {
|
||||
item.setReferenceId(cargo.referenceId());
|
||||
item.setProductImageFileId(cargo.productImageFileId());
|
||||
item.setWoodenProduct(cargo.woodenProduct());
|
||||
item.setPackingGroupNo(normalize(cargo.packingGroupNo()));
|
||||
item.setCreatedBy(userId); item.setUpdatedBy(userId); repository.insertCargo(item);
|
||||
}
|
||||
if (addresses.sender() != null) saveParty(tenantId, userId, orderId, "SENDER", addresses.sender());
|
||||
@@ -706,8 +747,7 @@ public class OrderApplicationService {
|
||||
order.getFulfillmentType() == null ? "STANDARD" : order.getFulfillmentType(), order.getLxCode());
|
||||
}
|
||||
private void prepareDraftInbound(OrderEntity order, OrderRequests.SaveRequest request) {
|
||||
int packageCount = request.cargoes().stream().map(OrderRequests.Cargo::packageCount)
|
||||
.filter(java.util.Objects::nonNull).mapToInt(Integer::intValue).sum();
|
||||
int packageCount = physicalPackageCountFromRequests(request.cargoes(), request.fulfillmentType());
|
||||
prepareSubmittedInbound(order, packageCount);
|
||||
}
|
||||
|
||||
@@ -716,11 +756,7 @@ public class OrderApplicationService {
|
||||
* 订单审核仍由 Order 独立控制,Firstleg 不得据此推进测量审核或后续履约。
|
||||
*/
|
||||
private void prepareSubmittedInbound(OrderEntity order) {
|
||||
long total = repository.findCargo(order.getTenantId(), order.getId()).stream()
|
||||
.map(OrderCargoEntity::getPackageCount)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.mapToLong(Integer::longValue)
|
||||
.sum();
|
||||
long total = physicalPackageCountFromEntities(repository.findCargo(order.getTenantId(), order.getId()), order.getFulfillmentType());
|
||||
prepareSubmittedInbound(order, total > 0 && total <= 1_000_000 ? (int) total : null);
|
||||
}
|
||||
|
||||
@@ -745,16 +781,53 @@ public class OrderApplicationService {
|
||||
}
|
||||
}
|
||||
private int totalPackageCount(OrderEntity order) {
|
||||
long total = repository.findCargo(order.getTenantId(), order.getId()).stream()
|
||||
.map(com.baiyue.order.entity.OrderCargoEntity::getPackageCount)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.mapToLong(Integer::longValue)
|
||||
.sum();
|
||||
long total = physicalPackageCountFromEntities(repository.findCargo(order.getTenantId(), order.getId()), order.getFulfillmentType());
|
||||
if (total < 1 || total > 1_000_000) {
|
||||
throw new BizException("订单货物总箱数必须为1至1000000箱");
|
||||
}
|
||||
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()) {
|
||||
String group = normalize(cargo.packingGroupNo());
|
||||
if (group == null) continue;
|
||||
OrderRequests.Cargo first = firstByGroup.putIfAbsent(group, cargo);
|
||||
if (first != null && (first.packageCount() != cargo.packageCount()
|
||||
|| !Objects.equals(first.packageType(), cargo.packageType())
|
||||
|| !sameDecimal(first.lengthCm(), cargo.lengthCm())
|
||||
|| !sameDecimal(first.widthCm(), cargo.widthCm())
|
||||
|| !sameDecimal(first.heightCm(), cargo.heightCm()))) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"同一混箱组合的箱数、包装类型和箱规必须一致:" + group);
|
||||
}
|
||||
}
|
||||
}
|
||||
private static int physicalPackageCountFromRequests(List<OrderRequests.Cargo> cargoes, String fulfillmentType) {
|
||||
Set<String> countedGroups = new HashSet<>();
|
||||
return cargoes.stream().filter(Objects::nonNull).mapToInt(cargo -> {
|
||||
String group = normalize(cargo.packingGroupNo());
|
||||
if (!"ONE_CLICK_DROPSHIP".equals(fulfillmentType) || group == null) return cargo.packageCount();
|
||||
return countedGroups.add(group) ? cargo.packageCount() : 0;
|
||||
}).sum();
|
||||
}
|
||||
private static int physicalPackageCountFromEntities(List<OrderCargoEntity> cargoes, String fulfillmentType) {
|
||||
Set<String> countedGroups = new HashSet<>();
|
||||
return cargoes.stream().filter(Objects::nonNull).mapToInt(cargo -> {
|
||||
String group = normalize(cargo.getPackingGroupNo());
|
||||
if (!"ONE_CLICK_DROPSHIP".equals(fulfillmentType) || group == null) {
|
||||
return cargo.getPackageCount() == null ? 0 : cargo.getPackageCount();
|
||||
}
|
||||
return countedGroups.add(group) && cargo.getPackageCount() != null ? cargo.getPackageCount() : 0;
|
||||
}).sum();
|
||||
}
|
||||
private static boolean sameDecimal(java.math.BigDecimal left, java.math.BigDecimal right) {
|
||||
return left == null ? right == null : right != null && left.compareTo(right) == 0;
|
||||
}
|
||||
private static String normalize(String value) {
|
||||
return StringUtils.hasText(value) ? value.trim() : null;
|
||||
}
|
||||
private String nextNo(long orderId) {
|
||||
return "FL" + orderId;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.order.service;
|
||||
|
||||
import com.baiyue.order.entity.OrderCargoEntity;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** Calculates physical cartons without double-counting SKU lines in a mixed-carton group. */
|
||||
public final class OrderCargoPackageCounts {
|
||||
private OrderCargoPackageCounts() {}
|
||||
|
||||
public static int physical(List<OrderCargoEntity> cargoes, String fulfillmentType) {
|
||||
Set<String> countedGroups = new HashSet<>();
|
||||
return cargoes.stream().filter(Objects::nonNull).mapToInt(cargo -> {
|
||||
int count = cargo.getPackageCount() == null ? 0 : cargo.getPackageCount();
|
||||
String group = StringUtils.hasText(cargo.getPackingGroupNo()) ? cargo.getPackingGroupNo().trim() : null;
|
||||
if (!"ONE_CLICK_DROPSHIP".equals(fulfillmentType) || group == null) return count;
|
||||
return countedGroups.add(group) ? count : 0;
|
||||
}).sum();
|
||||
}
|
||||
}
|
||||
@@ -141,12 +141,9 @@ public class OrderInboundOutboxPublisher {
|
||||
}
|
||||
|
||||
private int totalPackageCount(com.baiyue.order.entity.OrderEntity order) {
|
||||
long total = repository.findCargo(order.getTenantId(), order.getId()).stream()
|
||||
.map(com.baiyue.order.entity.OrderCargoEntity::getPackageCount)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.mapToLong(Integer::longValue)
|
||||
.sum();
|
||||
return total > 0 && total <= 1_000_000 ? (int) total : 0;
|
||||
int total = OrderCargoPackageCounts.physical(
|
||||
repository.findCargo(order.getTenantId(), order.getId()), order.getFulfillmentType());
|
||||
return total > 0 && total <= 1_000_000 ? total : 0;
|
||||
}
|
||||
|
||||
private void failPermanently(OutboxEventEntity event, String reason) {
|
||||
|
||||
@@ -48,9 +48,8 @@ public class PrintableInboundSnapshotService {
|
||||
}
|
||||
}
|
||||
}
|
||||
int packageCount = orders.findCargoInheritedByOrder(tenantId, orderId).stream()
|
||||
.map(OrderCargoEntity::getPackageCount).filter(java.util.Objects::nonNull)
|
||||
.mapToInt(Integer::intValue).sum();
|
||||
int packageCount = OrderCargoPackageCounts.physical(
|
||||
orders.findCargoInheritedByOrder(tenantId, orderId), order.getFulfillmentType());
|
||||
return OrderApplicationService.inboundCommand(order, packageCount);
|
||||
}
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ public class UnifiedOrderQueryService {
|
||||
.findFirst()
|
||||
.orElse(null));
|
||||
UnifiedOrderResponse.Row summary = row(order, inbound == null ? null : inbound.summary(), latestNote,
|
||||
childCounts(List.of(order)), CargoSummary.from(orderDetail.cargoes()), creatorName,
|
||||
childCounts(List.of(order)), CargoSummary.from(orderDetail.cargoes(), order.getFulfillmentType()), creatorName,
|
||||
coLoadGroups(List.of(orderId), includeCoLoadGroups).get(orderId));
|
||||
List<UnifiedOrderResponse.TimelineEvent> timeline = new ArrayList<>();
|
||||
orderDetail.statusHistory().forEach(item -> timeline.add(new UnifiedOrderResponse.TimelineEvent(
|
||||
@@ -492,7 +492,7 @@ public class UnifiedOrderQueryService {
|
||||
private record CargoSummary(List<String> skuNos, List<String> shipmentIds, List<String> referenceIds,
|
||||
Integer totalQuantity, Integer totalPackageCount, BigDecimal grossWeightKg,
|
||||
BigDecimal netWeightKg, BigDecimal volumeCbm) {
|
||||
private static CargoSummary from(List<OrderCargoEntity> cargoes) {
|
||||
private static CargoSummary from(List<OrderCargoEntity> cargoes, String fulfillmentType) {
|
||||
List<String> skuNos = cargoes.stream().map(OrderCargoEntity::getSkuNo)
|
||||
.filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).distinct().toList();
|
||||
List<String> shipmentIds = cargoes.stream().map(OrderCargoEntity::getShipmentId)
|
||||
@@ -501,10 +501,10 @@ public class UnifiedOrderQueryService {
|
||||
.filter(StringUtils::hasText).map(String::trim).distinct().toList();
|
||||
int totalQuantity = cargoes.stream().map(OrderCargoEntity::getTotalQuantity)
|
||||
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
|
||||
List<Integer> packageCounts = cargoes.stream().map(OrderCargoEntity::getPackageCount)
|
||||
.filter(Objects::nonNull).toList();
|
||||
Integer totalPackageCount = packageCounts.isEmpty() ? null
|
||||
: Math.toIntExact(packageCounts.stream().mapToLong(Integer::longValue).sum());
|
||||
boolean hasPackageCount = cargoes.stream().anyMatch(cargo -> cargo.getPackageCount() != null);
|
||||
Integer totalPackageCount = hasPackageCount
|
||||
? OrderCargoPackageCounts.physical(cargoes, fulfillmentType)
|
||||
: null;
|
||||
BigDecimal grossWeightKg = sum(cargoes, OrderCargoEntity::getGrossWeightKg);
|
||||
BigDecimal netWeightKg = sum(cargoes, OrderCargoEntity::getNetWeightKg);
|
||||
BigDecimal volumeCbm = sum(cargoes, OrderCargoEntity::getVolumeCbm);
|
||||
|
||||
@@ -304,6 +304,7 @@ CREATE TABLE IF NOT EXISTS ord_order_cargo (
|
||||
reference_id VARCHAR(64) NULL COMMENT '货物级 Amazon Reference ID',
|
||||
product_image_file_id BIGINT UNSIGNED NULL COMMENT '文件中心产品图片逻辑ID',
|
||||
wooden_product TINYINT(1) NOT NULL DEFAULT 0 COMMENT '木制品权威判定快照;1时订单必须商检',
|
||||
packing_group_no VARCHAR(32) NULL COMMENT '混箱组合号;同组货物行共享同一批物理箱',
|
||||
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
|
||||
|
||||
@@ -60,6 +60,7 @@ CALL phase1_add_column('ord_order_cargo', 'product_height_cm', "DECIMAL(12,2) NU
|
||||
CALL phase1_add_column('ord_order_cargo', 'shipment_id', "VARCHAR(64) NULL COMMENT '本货物明细对应的Amazon Shipment ID'");
|
||||
CALL phase1_add_column('ord_order_cargo', 'reference_id', "VARCHAR(64) NULL COMMENT '本货物明细对应的Amazon Reference ID'");
|
||||
CALL phase1_add_column('ord_order_cargo', 'product_image_file_id', "BIGINT UNSIGNED NULL COMMENT 'File服务中的产品图片逻辑文件ID'");
|
||||
CALL phase1_add_column('ord_order_cargo', 'packing_group_no', "VARCHAR(32) NULL COMMENT '混箱组合号;同组货物行共享同一批物理箱'");
|
||||
|
||||
-- 货物级字段上线前,FBA 编号只保存在主单;回填后提交校验可统一按每条货物执行。
|
||||
UPDATE ord_order_cargo cargo
|
||||
|
||||
@@ -477,6 +477,7 @@ paths:
|
||||
/api/merchant/v1/orders/first-leg/{id}/submit:
|
||||
post:
|
||||
summary: 提交订单
|
||||
description: 每条货物的每箱数量、箱数和总数量均必填,且总数量必须等于每箱数量 × 箱数。
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/OrderId'
|
||||
- $ref: '#/components/parameters/IdempotencyKey'
|
||||
@@ -581,6 +582,7 @@ paths:
|
||||
/api/platform/v1/orders/first-leg/{id}/submit:
|
||||
post:
|
||||
summary: 平台代客提交指定订单
|
||||
description: 每条货物的每箱数量、箱数和总数量均必填,且总数量必须等于每箱数量 × 箱数。
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/OrderId'
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
@@ -1258,11 +1260,12 @@ components:
|
||||
lengthCm: {type: number, minimum: 0, description: 箱规长(厘米)}
|
||||
widthCm: {type: number, minimum: 0, description: 箱规宽(厘米)}
|
||||
heightCm: {type: number, minimum: 0, description: 箱规高(厘米)}
|
||||
unitsPerPackage: {type: integer, minimum: 1, description: 每箱数量}
|
||||
unitsPerPackage: {type: integer, minimum: 1, description: 每箱数量;提交时必填}
|
||||
packageCount: {type: integer, minimum: 1, description: 箱数}
|
||||
packingGroupNo: {type: string, maxLength: 32, description: 一件代发混箱组合号;相同组合号的货物行共享同一批物理箱,组内箱数、包装类型和箱规必须一致}
|
||||
grossWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: 总毛重}
|
||||
volumeCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 总体积}
|
||||
totalQuantity: {type: integer, minimum: 1, description: 总数量}
|
||||
totalQuantity: {type: integer, minimum: 1, description: 总数量;提交时必填且必须等于每箱数量 × 箱数}
|
||||
netWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: 总净重}
|
||||
unitDeclaredValue: {type: number, minimum: 0, description: 清关申报单价(USD)}
|
||||
declaredValue: {type: number, minimum: 0, description: 清关申报金额(USD)}
|
||||
@@ -1282,7 +1285,7 @@ components:
|
||||
woodenProduct: {type: boolean, description: 木制品权威判定快照;为 true 时订单 customsInspection 必须为 true}
|
||||
description: >-
|
||||
原型货物明细字段。FBA 货物的 skuNo、upcNo、shipmentId、referenceId 由服务端按每条货物校验;
|
||||
productImageFileId 必须属于当前登录客户。
|
||||
productImageFileId 必须属于当前登录客户。一件代发中,相同 packingGroupNo 的多条 SKU 行属于同一批物理箱,物理箱数只计算一次。
|
||||
FirstLegOrderSummary:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+2
@@ -52,6 +52,8 @@ class MerchantBatchDraftOpenApiContractTest {
|
||||
assertThat(openapi)
|
||||
.contains("/api/merchant/v1/orders/first-leg:")
|
||||
.contains("/api/merchant/v1/orders/first-leg/{id}/submit:")
|
||||
.contains("总数量必须等于每箱数量 × 箱数")
|
||||
.contains("总数量;提交时必填且必须等于每箱数量 × 箱数")
|
||||
.contains("$ref: '#/components/schemas/FirstLegOrderDraft'");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,21 @@ class OrderApplicationServiceStateTest {
|
||||
&& "自动化联调测试,请勿实际出货".equals(saved.getCargoRemark())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void creatingDraftRejectsMismatchedCargoTotalQuantity() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
OrderRequests.SaveRequest request = requestWithCargoQuantities(5, 24, 119);
|
||||
|
||||
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
|
||||
.createDraft(10001L, 2L, request, "mismatched-quantity-on-create"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("第 1 条货物总数量应等于每箱数量 × 箱数(120)")
|
||||
.extracting(exception -> ((BizException) exception).getResultCode())
|
||||
.isEqualTo(ResultCode.VALIDATION_FAILED);
|
||||
|
||||
verify(repository, never()).insertOrder(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void creatingDraftValidatesCurrentPriceBookChannelBeforePersistence() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
@@ -194,6 +209,22 @@ class OrderApplicationServiceStateTest {
|
||||
ordered.verify(repository).saveOrder(10001L, order);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatingDraftRejectsMismatchedCargoTotalQuantity() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
OrderEntity order = persistedOrder(OrderStatus.DRAFT);
|
||||
when(repository.findOwned(10001L, 1L)).thenReturn(order);
|
||||
|
||||
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
|
||||
.updateDraft(10001L, 2L, 1L, requestWithCargoQuantities(5, 24, 119)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("第 1 条货物总数量应等于每箱数量 × 箱数(120)")
|
||||
.extracting(exception -> ((BizException) exception).getResultCode())
|
||||
.isEqualTo(ResultCode.VALIDATION_FAILED);
|
||||
|
||||
verify(repository, never()).saveOrder(anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformCanEditSubmittedOrderWithoutRollingBackReviewStatus() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
@@ -584,6 +615,8 @@ class OrderApplicationServiceStateTest {
|
||||
cargo.setPackageCount(1);
|
||||
cargo.setGrossWeightKg(BigDecimal.ONE);
|
||||
cargo.setVolumeCbm(BigDecimal.ONE);
|
||||
cargo.setUnitsPerPackage(1);
|
||||
cargo.setTotalQuantity(1);
|
||||
when(repository.findOwned(20002L, 1L)).thenReturn(order);
|
||||
when(repository.findCargo(20002L, 1L)).thenReturn(List.of(cargo));
|
||||
when(repository.findParties(20002L, 1L)).thenReturn(List.of());
|
||||
@@ -617,6 +650,27 @@ class OrderApplicationServiceStateTest {
|
||||
verify(repository, never()).saveOrder(anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void submittingDraftRejectsPersistedMismatchedCargoTotalQuantity() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS");
|
||||
OrderCargoEntity cargo = completeCargo();
|
||||
cargo.setPackageCount(5);
|
||||
cargo.setUnitsPerPackage(24);
|
||||
cargo.setTotalQuantity(119);
|
||||
when(repository.findOwned(20002L, 1L)).thenReturn(order);
|
||||
when(repository.findCargo(20002L, 1L)).thenReturn(List.of(cargo));
|
||||
|
||||
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
|
||||
.transit(20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("第 1 条货物总数量应等于每箱数量 × 箱数(120)")
|
||||
.extracting(exception -> ((BizException) exception).getResultCode())
|
||||
.isEqualTo(ResultCode.VALIDATION_FAILED);
|
||||
|
||||
verify(repository, never()).saveOrder(anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatingAnotherCustomersOrderStopsBeforePersistence() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
@@ -672,7 +726,7 @@ class OrderApplicationServiceStateTest {
|
||||
var cargo = new OrderRequests.Cargo(
|
||||
"货物", null, "CARTON", 1, BigDecimal.ONE, BigDecimal.ONE,
|
||||
null, null, null, null, null, false, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null,
|
||||
null, 1, 1, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null);
|
||||
return new OrderRequests.SaveRequest(
|
||||
7L, "客户", "SEA", null, "US", null, "目的仓", null, null,
|
||||
@@ -699,7 +753,7 @@ class OrderApplicationServiceStateTest {
|
||||
var cargo = new OrderRequests.Cargo(
|
||||
"货物", null, "CARTON", 1, BigDecimal.ONE, BigDecimal.ONE,
|
||||
null, null, null, null, null, false, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null,
|
||||
null, 1, 1, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null);
|
||||
return new OrderRequests.SaveRequest(
|
||||
customerId, "当前企业", "SEA", null, "US", null, "目的仓", null, null,
|
||||
@@ -722,6 +776,25 @@ class OrderApplicationServiceStateTest {
|
||||
amazonWarehouseCode, shipmentId, referenceId);
|
||||
}
|
||||
|
||||
private OrderRequests.SaveRequest requestWithCargoQuantities(int packageCount, Integer unitsPerPackage,
|
||||
Integer totalQuantity) {
|
||||
OrderRequests.SaveRequest source = request();
|
||||
var cargo = new OrderRequests.Cargo(
|
||||
"货物", null, "CARTON", packageCount, BigDecimal.ONE, BigDecimal.ONE,
|
||||
null, null, null, null, null, false, null, null, null, null,
|
||||
null, unitsPerPackage, totalQuantity, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null);
|
||||
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(), List.of(cargo),
|
||||
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());
|
||||
}
|
||||
|
||||
private OrderMasterDataGuard.Snapshots snapshots() {
|
||||
return snapshots(null);
|
||||
}
|
||||
@@ -758,6 +831,8 @@ class OrderApplicationServiceStateTest {
|
||||
cargo.setPackageCount(1);
|
||||
cargo.setGrossWeightKg(BigDecimal.ONE);
|
||||
cargo.setVolumeCbm(BigDecimal.ONE);
|
||||
cargo.setUnitsPerPackage(1);
|
||||
cargo.setTotalQuantity(1);
|
||||
return cargo;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.order.service;
|
||||
|
||||
import com.baiyue.order.entity.OrderCargoEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class OrderCargoPackageCountsTest {
|
||||
@Test
|
||||
void sameMixedCartonGroupCountsPhysicalCartonsOnce() {
|
||||
OrderCargoEntity skuA = cargo(10, "A");
|
||||
OrderCargoEntity skuB = cargo(10, "A");
|
||||
OrderCargoEntity separate = cargo(4, null);
|
||||
|
||||
assertThat(OrderCargoPackageCounts.physical(
|
||||
List.of(skuA, skuB, separate), "ONE_CLICK_DROPSHIP")).isEqualTo(14);
|
||||
assertThat(OrderCargoPackageCounts.physical(
|
||||
List.of(skuA, skuB, separate), "STANDARD")).isEqualTo(24);
|
||||
}
|
||||
|
||||
private OrderCargoEntity cargo(int count, String group) {
|
||||
OrderCargoEntity value = new OrderCargoEntity();
|
||||
value.setPackageCount(count);
|
||||
value.setPackingGroupNo(group);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user