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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user