feat: support mixed-SKU dropship cartons

This commit is contained in:
2026-09-11 15:38:19 +08:00
parent 0db11517db
commit cc7c2f0954
42 changed files with 1263 additions and 128 deletions
@@ -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);
+1
View File
@@ -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:
@@ -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;
}
}