Merge remote-tracking branch 'origin/test' into dev

# Conflicts:
#	firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java
#	order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java
#	order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java
This commit is contained in:
2026-09-08 17:34:12 +08:00
35 changed files with 621 additions and 79 deletions
@@ -64,12 +64,13 @@ public interface OrderCargoClient {
record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
String skuNo, Integer packageCount, Integer unitsPerCarton,
BigDecimal lengthCm, BigDecimal widthCm,
BigDecimal heightCm) {
BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm,
String shipmentId) {
public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm,
BigDecimal heightCm) {
this(cargoId, orderId, itemNo, cargoName, null, packageCount, null, lengthCm, widthCm, heightCm);
this(cargoId, orderId, itemNo, cargoName, null,
packageCount, null, lengthCm, widthCm, heightCm, null);
}
}
}
@@ -49,7 +49,8 @@ public class PlatformInboundMeasurementController {
@Resource private MeasurementSubmissionService submission;
@Resource private CartonMeasurementService cartonMeasurements;
@Operation(summary = "查询入库测量详情", description = "返回原单、实测箱规、审核快照和 OCR 重量结果。")
@Operation(summary = "查询入库测量详情", description = "返回唯一入仓码、原单、实测箱规、审核快照和 OCR 重量结果;"
+ "箱规行按 inboundDetailId 返回 Order 权威 FBA 货件编号,并保留独立设备实扫值。")
@GetMapping("/measurement")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:view')")
public ApiResponse<InboundMeasurementResponses.Detail> detail(
@@ -78,7 +79,8 @@ public class PlatformInboundMeasurementController {
@BusinessAudit(event = "INBOUND_MEASUREMENT_DRAFT_SAVE", resourceType = "INBOUND",
resourceId = "#inboundId", description = "保存入库测量草稿")
@Operation(summary = "保存测量草稿", description = "保存人工测量批次和服务端计算明细,不推进入库状态。")
@Operation(summary = "保存测量草稿", description = "仅在待测量、测量中或审核驳回状态保存人工测量批次和服务端计算明细;"
+ "进入待审核后批次冻结,确认箱数修正须使用 measurement/items/{itemId}/confirmed-package-count 专用接口。")
@PutMapping("/measurement/draft")
@PreAuthorize("hasAnyAuthority('platform:firstleg:inbound:warehouse-suggestion:edit',"
+ " 'platform:firstleg:inbound:measure:edit')")
@@ -7,6 +7,7 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
@@ -80,7 +81,8 @@ public final class FbaPricingDtos {
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal chargeableQuantity,
@NotNull @DecimalMin("0.00") BigDecimal unitPrice,
@NotNull @DecimalMin("0.00") BigDecimal additionalAmount,
@NotBlank @Size(max = 500) String reason
@NotBlank @Size(max = 500) String reason,
@NotNull @AssertTrue Boolean toleranceConfirmed
) {}
public record FeeSnapshotResponse(
@@ -96,7 +98,9 @@ public final class FbaPricingDtos {
@NotNull BigDecimal conversionFactor, @NotNull Integer roundingScale,
@NotBlank String roundingMode,
BigDecimal actualWeightKg, BigDecimal actualVolumeCbm,
BigDecimal convertedQuantity, BigDecimal chargeableQuantity, String chargeUnit,
BigDecimal convertedQuantity, BigDecimal baselineChargeableQuantity,
BigDecimal lowerChargeableQuantity, BigDecimal upperChargeableQuantity,
BigDecimal tolerancePercent, BigDecimal chargeableQuantity, String chargeUnit,
BigDecimal unitPrice, BigDecimal subtotalAmount, BigDecimal additionalAmount,
BigDecimal totalAmount, String currency,
LocalDateTime rateEffectiveFrom, LocalDateTime rateEffectiveTo,
@@ -75,6 +75,8 @@ public final class InboundMeasurementResponses {
public record Item(
Long id,
@Schema(description = "当前测量详情唯一权威入仓码,与根对象 inboundCode 一致")
String inboundCode,
Long inboundDetailId,
String itemNo,
String boxSpecification,
@@ -99,6 +101,9 @@ public final class InboundMeasurementResponses {
String coefficientMode,
String remark,
String boxCode,
@Schema(description = "Order货物明细按inboundDetailId匹配的权威FBA货件编号;匹配失败为空,禁止回退设备实扫值")
String fbaShipmentNo,
@Schema(description = "设备从Amazon FBA箱标实扫的Shipment ID;仅作现场证据,不覆盖或回退权威货件编号")
String scannedShipmentId,
Integer confirmedPackageCount,
String machineNo,
@@ -177,6 +177,8 @@ public final class LoadingListDtos {
Long itemId,
Long accountId,
String customerName,
String customsTaxMode,
Boolean taxRefundFlag,
Long orderId,
String orderNo,
Long inboundId,
@@ -119,9 +119,20 @@ public final class PriceBookDtos {
@Size(max = 128) String destinationName,
PriceRegion destinationRegion,
PricingTaxMode pricingTaxMode,
@Pattern(regexp = "KG|CBM") String chargeMode,
@DecimalMin(value = "0", inclusive = true) BigDecimal actualWeightKg,
@DecimalMin(value = "0", inclusive = true) BigDecimal actualVolumeCbm
) {}
) {
public EstimateRequest(Long originWarehouseId, String channelCode, String destinationCode,
PricingDestinationType destinationType, Long destinationWarehouseId,
String destinationName, PriceRegion destinationRegion,
PricingTaxMode pricingTaxMode, BigDecimal actualWeightKg,
BigDecimal actualVolumeCbm) {
this(originWarehouseId, channelCode, destinationCode, destinationType,
destinationWarehouseId, destinationName, destinationRegion, pricingTaxMode,
null, actualWeightKg, actualVolumeCbm);
}
}
public record EstimateResponse(
Long priceBookVersionId,
@@ -54,6 +54,10 @@ public class InboundFeeEntity {
private BigDecimal actualWeightKg;
private BigDecimal actualVolumeCbm;
private BigDecimal convertedQuantity;
/** 系统按权威测量与生效报价计算的原始计费量;人工调整不得改变。 */
private BigDecimal baselineChargeableQuantity;
/** 人工计费量相对系统基准允许的最大偏差百分比。 */
private BigDecimal tolerancePercent;
private BigDecimal chargeableQuantity;
private String chargeUnit;
private String currency;
@@ -168,7 +168,14 @@ public interface LoadingListQueryMapper {
@Param("operatorName") String operatorName);
@Select("""
SELECT i.id AS item_id, i.account_id, i.customer_name, i.order_id, i.order_no,
SELECT i.id AS item_id, i.account_id, i.customer_name,
inbound.tax_mode AS customs_tax_mode,
CASE inbound.tax_mode
WHEN 'TAX_REFUND' THEN TRUE
WHEN 'BUY_DECLARATION' THEN FALSE
ELSE NULL
END AS tax_refund_flag,
i.order_id, i.order_no,
i.inbound_id, i.inbound_detail_id, i.inbound_code, b.container_no,
i.sku_code, i.destination_warehouse_name, i.product_name_zh,
product_name_en, product_image_url, i.unit_type, i.pallet_id,
@@ -410,6 +417,8 @@ public interface LoadingListQueryMapper {
private Long itemId;
private Long accountId;
private String customerName;
private String customsTaxMode;
private Boolean taxRefundFlag;
private Long orderId;
private String orderNo;
private Long inboundId;
@@ -65,6 +65,8 @@ public class ActivePriceBookEstimator {
} else {
throw validation("私人地址暂不支持参考估价");
}
String chargeMode = requiredChargeMode(request);
rates = rates.stream().filter(value -> chargeMode.equals(value.getChargeUnit())).toList();
RateRow rate = uniqueRate(rates);
requireModel(rate, PriceModel.FBA_WAREHOUSE_RATE);
var estimate = fbaEstimator.estimate(rate, request.actualWeightKg(), request.actualVolumeCbm());
@@ -81,6 +83,9 @@ public class ActivePriceBookEstimator {
if (request.pricingTaxMode() == null) {
throw validation("请选择包税或不包税报价");
}
if (StringUtils.hasText(request.chargeMode()) && !"KG".equals(request.chargeMode())) {
throw validation("区域重量阶梯仅支持KG计费");
}
PriceRegion region = request.destinationRegion() == null
? resolveWarehouseRegion(request.destinationWarehouseId()) : request.destinationRegion();
List<RateRow> tiers = repository.regionTiers(versionId, request.originWarehouseId(), channelCode,
@@ -113,6 +118,19 @@ public class ActivePriceBookEstimator {
return rates.get(0);
}
private static String requiredChargeMode(EstimateRequest request) {
if (request.pricingTaxMode() == PricingTaxMode.TAX_INCLUDED) {
if (StringUtils.hasText(request.chargeMode()) && !"KG".equals(request.chargeMode())) {
throw validation("包税报价必须按KG计费");
}
return "KG";
}
if (!StringUtils.hasText(request.chargeMode())) {
throw validation("不包税报价请选择客户计费方式");
}
return request.chargeMode();
}
private static void requireModel(RateRow rate, PriceModel expected) {
if (rate == null || !expected.name().equals(rate.getPriceModel())) {
throw new BizException(ResultCode.CONFLICT, "价格模型与渠道配置不一致");
@@ -171,7 +171,7 @@ public class FbaPricingRepository {
.eq(CustomerChargeRuleEntity::getTenantId, inbound.getTenantId())
.eq(CustomerChargeRuleEntity::getCustomerId, inbound.getTenantId())
.eq(CustomerChargeRuleEntity::getBusinessType, inbound.getBusinessType())
.eq(CustomerChargeRuleEntity::getTaxMode, inbound.getTaxMode())
.eq(CustomerChargeRuleEntity::getTaxMode, inbound.getPricingTaxMode())
.eq(CustomerChargeRuleEntity::getStatus, "ACTIVE")
.le(CustomerChargeRuleEntity::getEffectiveFrom, at)
.and(w -> w.isNull(CustomerChargeRuleEntity::getEffectiveTo)
@@ -184,7 +184,8 @@ public class LoadingListRepository {
private List<Item> items(List<ItemRow> rows) {
return rows.stream().map(row -> new Item(row.getItemId(), row.getAccountId(), row.getCustomerName(),
row.getOrderId(), row.getOrderNo(), row.getInboundId(), row.getInboundDetailId(),
row.getCustomsTaxMode(), row.getTaxRefundFlag(), row.getOrderId(), row.getOrderNo(),
row.getInboundId(), row.getInboundDetailId(),
row.getInboundCode(), row.getContainerNo(), row.getSkuCode(),
row.getDestinationWarehouseName(), row.getProductNameZh(), row.getProductNameEn(),
row.getProductImageUrl(), row.getUnitType(), row.getPalletId(), row.getPalletNo(),
@@ -35,6 +35,8 @@ import java.util.List;
@Service
public class FbaPricingService {
private static final int MONEY_SCALE = 2;
private static final BigDecimal ADJUST_TOLERANCE_PERCENT = new BigDecimal("10.00");
private static final BigDecimal ONE_HUNDRED = new BigDecimal("100");
private static final long AUTOMATIC_CONFIRM_OPERATOR_ID = 0L;
private static final String AUTOMATIC_CONFIRM_OPERATOR_NAME = "装柜完成自动确认";
@Resource
@@ -114,10 +116,12 @@ public class FbaPricingService {
|| !StringUtils.hasText(item.getCalculationRuleVersion())));
if (incomplete) throw conflict("外箱规数据或绑定的计算规则不完整,不能生成对客费用");
}
PricingPolicy policy = pricingPolicy(inbound, requestedAt == null ? LocalDateTime.now(clock) : requestedAt);
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(new EstimateRequest(
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null,
pricingTaxMode(inbound), selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg,
pricingTaxMode(inbound), policy.chargeMode(),
selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg,
selectedVolumeCbm == null ? inbound.getActualVolumeCbm() : selectedVolumeCbm));
RateRow rate = estimate.rate();
BigDecimal weight = (selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg).setScale(3, RoundingMode.HALF_UP);
@@ -133,6 +137,13 @@ public class FbaPricingService {
InboundFeeEntity fee = snapshot(tenantId, operatorId, inbound, estimate, rate, version,
weight, volume, converted, chargeable, subtotal, additional, total, now);
if (policy.rule() != null) {
fee.setChargeRuleId(policy.rule().getId());
fee.setChargeRuleVersion(policy.rule().getRuleVersion());
fee.setRuleCode(policy.rule().getRuleCode());
fee.setRuleEffectiveFrom(policy.rule().getEffectiveFrom());
fee.setRuleEffectiveTo(policy.rule().getEffectiveTo());
}
fee.setMeasurementId(measurement == null ? null : measurement.getId());
fee.setMeasurementSource("OUTER".equalsIgnoreCase(volumeSource) ? "OUTER" : volumeSource);
if (published != null) {
@@ -283,7 +294,8 @@ public class FbaPricingService {
}
String discriminator = current.getId() + ":" + request.expectedFeeVersion() + ":"
+ request.chargeableQuantity() + ":" + request.unitPrice() + ":"
+ request.additionalAmount() + ":" + request.reason().trim();
+ request.additionalAmount() + ":" + request.reason().trim() + ":"
+ request.toleranceConfirmed();
InboundPricingIdempotencyEntity token = beginIdempotency(tenantId, operatorId, inboundId,
"FEE_ADJUST", idempotencyKey, hash("ADJUST", inboundId, discriminator));
if ("COMPLETED".equals(token.getStatus())) {
@@ -297,6 +309,17 @@ public class FbaPricingService {
adjusted.setId(null);
adjusted.setFeeNo("FBAF-" + inboundId + "-" + nextVersion);
adjusted.setFeeVersion(nextVersion);
BigDecimal baseline = baseline(current);
ToleranceRange range = toleranceRange(baseline);
if (request.chargeableQuantity().compareTo(range.lower()) < 0
|| request.chargeableQuantity().compareTo(range.upper()) > 0) {
throw conflict("调整计费量必须在系统原计费量±10%范围内(含边界)");
}
if (!Boolean.TRUE.equals(request.toleranceConfirmed())) {
throw conflict("请二次确认计费量调整范围后再提交");
}
adjusted.setBaselineChargeableQuantity(baseline);
adjusted.setTolerancePercent(ADJUST_TOLERANCE_PERCENT);
adjusted.setChargeableQuantity(request.chargeableQuantity().setScale(4, RoundingMode.HALF_UP));
adjusted.setConvertedQuantity(adjusted.getChargeableQuantity());
adjusted.setSubtotalAmount(adjusted.getChargeableQuantity().multiply(request.unitPrice())
@@ -401,6 +424,27 @@ public class FbaPricingService {
}
}
private PricingPolicy pricingPolicy(InboundPricingEntity inbound, LocalDateTime at) {
PricingTaxMode taxMode = pricingTaxMode(inbound);
if (taxMode == PricingTaxMode.TAX_INCLUDED) return new PricingPolicy("KG", null);
List<com.baiyue.firstleg.entity.CustomerChargeRuleEntity> rules = repository.matchRules(inbound, at);
if (rules == null || rules.isEmpty()) {
throw conflict("客户未配置当前不包税报价的计费方式");
}
List<com.baiyue.firstleg.entity.CustomerChargeRuleEntity> defaults = rules.stream()
.filter(rule -> Boolean.TRUE.equals(rule.getDefaultRule())).toList();
com.baiyue.firstleg.entity.CustomerChargeRuleEntity selected = defaults.size() == 1
? defaults.get(0) : rules.size() == 1 ? rules.get(0) : null;
if (selected == null) throw conflict("客户当前不包税计费规则匹配不唯一");
if (!List.of("KG", "CBM").contains(selected.getChargeMode())) {
throw conflict("客户计费方式配置无效");
}
return new PricingPolicy(selected.getChargeMode(), selected);
}
private record PricingPolicy(String chargeMode,
com.baiyue.firstleg.entity.CustomerChargeRuleEntity rule) { }
private InboundPricingIdempotencyEntity beginIdempotency(long tenantId, long operatorId,
long inboundId, String operation,
String key, String hash) {
@@ -456,6 +500,8 @@ public class FbaPricingService {
fee.setRateEffectiveFrom(rate.getEffectiveFrom()); fee.setRateEffectiveTo(rate.getEffectiveTo());
fee.setChargeMode(estimate.chargeUnit()); fee.setConversionFactor(estimate.conversionFactor());
fee.setActualWeightKg(weight); fee.setActualVolumeCbm(volume); fee.setConvertedQuantity(converted);
fee.setBaselineChargeableQuantity(chargeable);
fee.setTolerancePercent(ADJUST_TOLERANCE_PERCENT);
fee.setChargeableQuantity(chargeable); fee.setChargeUnit(estimate.chargeUnit()); fee.setCurrency(rate.getCurrency());
fee.setSubtotalAmount(subtotal); fee.setAdditionalAmount(additional); fee.setTotalAmount(total);
fee.setFeeStatus("CALCULATED"); fee.setReviewStatus("NOT_SUBMITTED"); fee.setExceptionRelease(false);
@@ -497,6 +543,8 @@ public class FbaPricingService {
private FeeSnapshotResponse response(InboundPricingEntity inbound, InboundFeeEntity fee,
BigDecimal unitPrice, String nextStatus) {
BigDecimal baseline = baseline(fee);
ToleranceRange range = toleranceRange(baseline);
return new FeeSnapshotResponse(fee.getId(), fee.getFeeNo(), fee.getFeeVersion(), fee.getFeeStatus(),
fee.getInboundId(), inbound == null ? null : inbound.getInboundCode(),
fee.getMeasurementId(), fee.getMeasurementSource(),
@@ -508,7 +556,9 @@ public class FbaPricingService {
fee.getTaxMode(), fee.getPricingTaxMode(), fee.getChargeMode(),
fee.getConversionFactor(),
fee.getRoundingScale(), fee.getRoundingMode(), fee.getActualWeightKg(), fee.getActualVolumeCbm(),
fee.getConvertedQuantity(), fee.getChargeableQuantity(), fee.getChargeUnit(), unitPrice,
fee.getConvertedQuantity(), baseline, range.lower(), range.upper(),
fee.getTolerancePercent() == null ? ADJUST_TOLERANCE_PERCENT : fee.getTolerancePercent(),
fee.getChargeableQuantity(), fee.getChargeUnit(), unitPrice,
fee.getSubtotalAmount(), fee.getAdditionalAmount(), fee.getTotalAmount(), fee.getCurrency(),
fee.getRateEffectiveFrom(), fee.getRateEffectiveTo(), fee.getCalculatedAt(), fee.getConfirmedAt(),
fee.getSentAt(), nextStatus,
@@ -516,6 +566,22 @@ public class FbaPricingService {
fee.getCustomerConfirmedBy(), fee.getCustomerConfirmedByName(), fee.getCustomerConfirmedAt());
}
private static BigDecimal baseline(InboundFeeEntity fee) {
BigDecimal value = fee.getBaselineChargeableQuantity();
if (value == null) value = fee.getChargeableQuantity();
if (value == null || value.signum() <= 0) throw conflict("系统原计费量必须大于零");
return value.setScale(6, RoundingMode.HALF_UP);
}
private static ToleranceRange toleranceRange(BigDecimal baseline) {
BigDecimal delta = baseline.multiply(ADJUST_TOLERANCE_PERCENT)
.divide(ONE_HUNDRED, 6, RoundingMode.HALF_UP);
return new ToleranceRange(baseline.subtract(delta).setScale(6, RoundingMode.HALF_UP),
baseline.add(delta).setScale(6, RoundingMode.HALF_UP));
}
private record ToleranceRange(BigDecimal lower, BigDecimal upper) { }
private static String hash(String operation, long inboundId, String discriminator) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
@@ -23,6 +23,7 @@ import java.util.List;
@Service
public class InboundFeeDetailService {
private static final String FREIGHT_FEE_CODE = "FBA_HEAD_FREIGHT";
private static final String PRIVATE_LAST_MILE_DELIVERY_FEE_CODE = "PRIVATE_LAST_MILE_DELIVERY";
@Resource
private FeeItemRepository repository;
@Resource
@@ -379,6 +380,7 @@ public class InboundFeeDetailService {
private void apply(InboundFeeDetailEntity value, InboundOrderEntity inbound,
FeeItemEntity item, SaveRequest request, long operatorId) {
ensureDestinationApplicable(item, inbound);
ensureWarehouseApplicable(item, request.direction());
value.setFeeItemId(item.getId());
value.setInboundCode(inbound.getInboundCode());
@@ -408,6 +410,13 @@ public class InboundFeeDetailService {
value.setUpdatedBy(operatorId);
}
private static void ensureDestinationApplicable(FeeItemEntity item, InboundOrderEntity inbound) {
if (PRIVATE_LAST_MILE_DELIVERY_FEE_CODE.equals(item.getFeeCode())
&& !"PRIVATE".equals(inbound.getDestinationType())) {
throw conflict("私人仓尾程派送费只能绑定目的地类型为PRIVATE的入库单");
}
}
private void applySupplierSnapshot(InboundFeeDetailEntity value, FeeItemEntity item,
String direction, Long supplierId) {
if (!"PAYABLE".equals(direction)) {
@@ -48,6 +48,8 @@ public class InboundMeasurementService {
private static final String WEIGHT_PENDING_CONFIRM = "PENDING_CONFIRM";
/** OCR 结果无效或不完整,必须人工录入或修正。 */
private static final String WEIGHT_NEEDS_MANUAL = "NEEDS_MANUAL";
private static final Set<String> MEASUREMENT_DRAFT_EDITABLE_STATUSES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
private static final Set<String> WEIGHT_EDITABLE_STATUSES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
/** 测量完成后的默认普通托盘类型;实际拆托和库位占用由提交编排服务执行。 */
@@ -100,7 +102,7 @@ public class InboundMeasurementService {
com.baiyue.firstleg.entity.InboundPalletAllocationEntity::getWeightResultId,
java.util.function.Function.identity(), (left, right) -> left));
MeasureReviewRecordEntity review = repository.findLatestReview(tenantId, inbound.getId());
InboundMeasurementResponses.DeclarationSnapshot latestDeclaration = loadLatestDeclaration(inbound);
LatestDeclaration latestDeclaration = loadLatestDeclaration(inbound);
return toDetail(inbound, measurement, items, weights, allocationSlots, allocationsByWeight,
review, latestDeclaration);
}
@@ -122,6 +124,10 @@ public class InboundMeasurementService {
long tenantId, long inboundId, long operatorId,
InboundMeasurementRequests.SaveDraftRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
if (!MEASUREMENT_DRAFT_EDITABLE_STATUSES.contains(inbound.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT,
"测量已提交待审核,不能再保存整批测量数据;如需修改请先退回后重新提交");
}
validateInboundDetails(tenantId, inbound, request.items());
request.items().stream().map(InboundMeasurementRequests.ItemRequest::chargeRuleId)
.filter(java.util.Objects::nonNull).distinct().forEach(ruleId -> {
@@ -468,7 +474,8 @@ public class InboundMeasurementService {
List<com.baiyue.firstleg.entity.InboundPalletAllocationEntity> allocationSlots,
Map<Long, com.baiyue.firstleg.entity.InboundPalletAllocationEntity> allocationsByWeight,
MeasureReviewRecordEntity review,
InboundMeasurementResponses.DeclarationSnapshot latestDeclaration) {
LatestDeclaration latestDeclaration) {
InboundMeasurementResponses.DeclarationSnapshot declaration = latestDeclaration.snapshot();
return new InboundMeasurementResponses.Detail(
inbound.getId(), inbound.getInboundCode(), inbound.getCompanyName(), inbound.getChannelCode(),
inbound.getChannelName(), inbound.getProcessStatus(), inbound.getMeasureReviewStatus(),
@@ -480,21 +487,22 @@ public class InboundMeasurementService {
inbound.getVersion(),
measurement == null ? null : measurement.getVersion(),
measurement != null && StringUtils.hasText(measurement.getOriginalDocumentNo())
? measurement.getOriginalDocumentNo() : latestDeclaration.referenceId(),
? measurement.getOriginalDocumentNo() : declaration.referenceId(),
measurement != null && measurement.getOriginalGrossWeightKg() != null
? measurement.getOriginalGrossWeightKg() : latestDeclaration.grossWeightKg(),
? measurement.getOriginalGrossWeightKg() : declaration.grossWeightKg(),
measurement != null && measurement.getOriginalVolumeCbm() != null
? measurement.getOriginalVolumeCbm() : latestDeclaration.volumeCbm(),
latestDeclaration.totalPackageCount(),
? measurement.getOriginalVolumeCbm() : declaration.volumeCbm(),
declaration.totalPackageCount(),
measurement == null ? inbound.getTotalPackageCount() : measurement.getTotalPackageCount(),
inbound.getActualGrossWeightKg(),
inbound.getActualNetWeightKg(),
measurement == null ? inbound.getActualVolumeCbm() : measurement.getTotalVolumeCbm(),
measurement == null ? inbound.getVolumeWeightKg() : measurement.getVolumeWeightKg(),
measurement != null && StringUtils.hasText(measurement.getActualGoodsName())
? measurement.getActualGoodsName() : latestDeclaration.goodsName(),
latestDeclaration,
items.stream().map(this::toItem).toList(), weights.stream()
? measurement.getActualGoodsName() : declaration.goodsName(),
declaration,
items.stream().map(item -> toItem(item, inbound.getInboundCode(), item.getInboundDetailId() == null
? null : latestDeclaration.fbaShipmentNos().get(item.getInboundDetailId()))).toList(), weights.stream()
.map(weight -> toWeight(weight, allocationsByWeight.get(weight.getId()))).toList(),
toPalletLocationGuide(allocationSlots));
}
@@ -526,10 +534,14 @@ public class InboundMeasurementService {
* Latest customer declaration is a live, read-only Order view. It must never be persisted over
* the measurement batch, carton-label mapping, weight results or review decision.
*/
private InboundMeasurementResponses.DeclarationSnapshot loadLatestDeclaration(
private LatestDeclaration loadLatestDeclaration(
InboundOrderEntity inbound) {
OrderCargoClient.MeasurementDeclarationSnapshot aggregate = loadDeclaration(inbound);
List<OrderCargoClient.CargoLabelBinding> cargoes = loadDeclarationCargoes(inbound);
if (cargoes.stream().anyMatch(cargo -> cargo == null || cargo.cargoId() == null
|| cargo.cargoId() <= 0 || !java.util.Objects.equals(cargo.orderId(), inbound.getOrderId()))) {
throw new BizException(ResultCode.CONFLICT, "订单服务返回的货物明细归属不一致");
}
long packages = cargoes.stream()
.map(OrderCargoClient.CargoLabelBinding::packageCount)
.filter(java.util.Objects::nonNull)
@@ -538,7 +550,7 @@ public class InboundMeasurementService {
if (packages > Integer.MAX_VALUE) {
throw new BizException(ResultCode.VALIDATION_FAILED, "客户最新申报总箱数超过系统上限");
}
return new InboundMeasurementResponses.DeclarationSnapshot(
InboundMeasurementResponses.DeclarationSnapshot snapshot = new InboundMeasurementResponses.DeclarationSnapshot(
aggregate.referenceId(), (int) packages, aggregate.originalGrossWeightKg(),
aggregate.originalVolumeCbm(), aggregate.defaultActualGoodsName(),
cargoes.stream()
@@ -550,6 +562,19 @@ public class InboundMeasurementService {
cargo.cargoId(), cargo.itemNo(), cargo.cargoName(), cargo.packageCount(),
cargo.lengthCm(), cargo.widthCm(), cargo.heightCm()))
.toList());
Map<Long, String> fbaShipmentNos = cargoes.stream()
.filter(cargo -> cargo.cargoId() != null && StringUtils.hasText(cargo.shipmentId()))
.collect(java.util.stream.Collectors.toMap(
OrderCargoClient.CargoLabelBinding::cargoId,
cargo -> cargo.shipmentId().trim(),
(left, right) -> {
if (!java.util.Objects.equals(left, right)) {
throw new BizException(ResultCode.CONFLICT,
"订单同一货物明细返回多个FBA货件编号");
}
return left;
}, java.util.LinkedHashMap::new));
return new LatestDeclaration(snapshot, Map.copyOf(fbaShipmentNos));
}
private List<OrderCargoClient.CargoLabelBinding> loadDeclarationCargoes(InboundOrderEntity inbound) {
@@ -601,9 +626,11 @@ public class InboundMeasurementService {
return value;
}
private InboundMeasurementResponses.Item toItem(InboundMeasurementItemEntity item) {
private InboundMeasurementResponses.Item toItem(InboundMeasurementItemEntity item,
String inboundCode,
String fbaShipmentNo) {
return new InboundMeasurementResponses.Item(
item.getId(), item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(),
item.getId(), inboundCode, item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(),
item.getLengthCm(), item.getWidthCm(), item.getHeightCm(), item.getGirthCm(),
item.getUnitVolumeCbm(),
item.getTotalVolumeCbm(), item.getVolumeWeightKg(), item.getSourceImageFileId(),
@@ -612,11 +639,17 @@ public class InboundMeasurementService {
item.getOuterLengthCm(), item.getOuterWidthCm(), item.getOuterHeightCm(),
item.getCalculationRuleVersion(),
item.getChargeRuleId(), item.getCoefficientMode(),
item.getRemark(), item.getBoxCode(), item.getScannedShipmentId(),
item.getRemark(), item.getBoxCode(), fbaShipmentNo, item.getScannedShipmentId(),
item.getPackageCount(),
item.getMachineNo(), item.getDeviceMeasuredAt(), item.getUpdatedAt());
}
private record LatestDeclaration(
InboundMeasurementResponses.DeclarationSnapshot snapshot,
Map<Long, String> fbaShipmentNos
) {
}
private void requireConfirmedPackageTotal(InboundOrderEntity inbound,
List<InboundMeasurementItemEntity> items) {
if (inbound.getTotalPackageCount() == null || inbound.getTotalPackageCount() < 1) {
@@ -42,6 +42,8 @@ public class MeasurementBatchService {
static final String STATUS_DRAFT = "DRAFT";
/** 已接收第三方测量数据,待平台提交审核。 */
static final String STATUS_RECEIVED = "RECEIVED";
private static final Set<String> EDITABLE_INBOUND_STATUSES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167");
private static final BigDecimal MAX_RAW_VOLUME_CBM = new BigDecimal("999999999999.999999");
private static final BigDecimal MAX_TOTAL_VOLUME_CBM = new BigDecimal("99999999999999.9999");
@@ -60,6 +62,10 @@ public class MeasurementBatchService {
if (inbound == null) {
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
}
if (!EDITABLE_INBOUND_STATUSES.contains(inbound.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT,
"当前入库状态不允许保存测量批次,已提交批次必须保持冻结");
}
validateSource(command);
List<InboundMeasurementItemEntity> calculatedItems = calculateItems(
command, dimensionWeighting.load(command.tenantId()));
@@ -71,12 +77,7 @@ public class MeasurementBatchService {
repository.insertMeasurement(measurement);
} else {
measurement = requireEditableBatch(command);
// 修正待测量或待审核资料时重新形成可提交的内部草稿;已入库订单只更新资料,
// 不回退其主状态,也不把已经审核的批次重新标成待审核。
if (Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED")
.contains(inbound.getProcessStatus())) {
measurement.setStatus(STATUS_DRAFT);
}
measurement.setStatus(STATUS_DRAFT);
applyHeader(measurement, command);
applyTotals(measurement, calculatedItems);
if (!repository.updateMeasurement(measurement)) {
@@ -42,6 +42,10 @@ public class MerchantPriceBookService {
public EstimateResponse estimate(long merchantTenantId, EstimateRequest request) {
requireMerchant(merchantTenantId);
if (request.pricingTaxMode() == PricingTaxMode.TAX_INCLUDED
&& request.chargeMode() != null && !"KG".equals(request.chargeMode())) {
throw validation("包税报价必须按KG计费");
}
ActivePriceBookEstimator.Estimate estimate = estimator.estimate(request);
return new EstimateResponse(estimate.priceBookVersionId(), estimate.channelCode(), estimate.priceModel(),
estimate.pricingTaxMode(), estimate.chargeableQuantity(), estimate.chargeUnit(), estimate.unitPrice(),