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
@@ -7627,6 +7627,15 @@ SELECT 193000000001000111, 0, 'FBA_HEAD_FREIGHT', '头程运费', 'INTERNATIONAL
0, 'system', '2026-08-14 00:00:00.000', 0, 'system', '2026-08-14 00:00:00.000'
WHERE NOT EXISTS (SELECT 1 FROM fl_fee_item WHERE fee_code = 'FBA_HEAD_FREIGHT');
INSERT INTO fl_fee_item (id, tenant_id, fee_code, category, trade_scope, fee_type_name,
pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable,
allocation_fee, description, status, created_by, created_by_name, created_at,
updated_by, updated_by_name, updated_at, deleted, version)
SELECT 193000000001000112, 0, 'PRIVATE_LAST_MILE_DELIVERY', '尾程派送费', 'INTERNATIONAL',
'私人仓尾程派送费', 0, 0, 1, 1, 0, '仅用于destinationType=PRIVATE的入库单',
'ACTIVE', 0, 'SYSTEM', CURRENT_TIMESTAMP(3), 0, 'SYSTEM', CURRENT_TIMESTAMP(3), 0, 0
WHERE NOT EXISTS (SELECT 1 FROM fl_fee_item WHERE fee_code = 'PRIVATE_LAST_MILE_DELIVERY');
-- ============================================================================
-- 2026-08-06:三期分柜、预装、拣货、装柜和派送当前基线
-- ============================================================================
@@ -15553,6 +15562,11 @@ BEGIN
AND category = '头程运费' AND trade_scope = 'INTERNATIONAL' AND fee_type_name = 'FBA头程运费'
AND pickup_payable = 0 AND pickup_receivable = 0 AND warehouse_payable = 0
AND warehouse_receivable = 1 AND allocation_fee = 0 AND status = 'ACTIVE' AND deleted = 0) <> 1
OR (SELECT COUNT(*) FROM baiyue_firstleg.fl_fee_item
WHERE tenant_id = 0 AND fee_code = 'PRIVATE_LAST_MILE_DELIVERY'
AND category = '尾程派送费' AND trade_scope = 'INTERNATIONAL'
AND fee_type_name = '私人仓尾程派送费' AND warehouse_payable = 1
AND warehouse_receivable = 1 AND status = 'ACTIVE' AND deleted = 0) <> 1
OR EXISTS (
SELECT 1
FROM baiyue_firstleg.fl_inbound_fee_detail detail
@@ -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(),
@@ -673,6 +673,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee (
actual_weight_kg DECIMAL(18,3) NOT NULL COMMENT '试算时实际重量kg',
actual_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '试算时实际体积m3',
converted_quantity DECIMAL(18,4) NOT NULL COMMENT '换算后的重量或体积',
baseline_chargeable_quantity DECIMAL(18,6) NOT NULL COMMENT '系统按权威测量与生效报价计算的原始计费量',
tolerance_percent DECIMAL(5,2) NOT NULL DEFAULT 10.00 COMMENT '人工调整允许的最大偏差百分比,边界包含',
chargeable_quantity DECIMAL(18,4) NOT NULL COMMENT '最终计费量',
charge_unit VARCHAR(16) NOT NULL COMMENT '计费单位',
currency CHAR(3) NOT NULL COMMENT '币种',
@@ -702,6 +704,32 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee (
CONSTRAINT fk_fee_carrier_rate FOREIGN KEY (carrier_rate_id) REFERENCES fl_carrier_fba_rate (id)
) ENGINE=InnoDB COMMENT='入库头程费用计算和确认快照';
SET @fee_baseline_quantity_sql = IF(
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='fl_inbound_fee' AND column_name='baseline_chargeable_quantity'),
'SELECT 1',
'ALTER TABLE fl_inbound_fee ADD COLUMN baseline_chargeable_quantity DECIMAL(18,6) NULL COMMENT ''系统按权威测量与生效报价计算的原始计费量'' AFTER converted_quantity'
);
PREPARE fee_baseline_quantity_stmt FROM @fee_baseline_quantity_sql;
EXECUTE fee_baseline_quantity_stmt;
DEALLOCATE PREPARE fee_baseline_quantity_stmt;
UPDATE fl_inbound_fee
SET baseline_chargeable_quantity = chargeable_quantity
WHERE baseline_chargeable_quantity IS NULL;
ALTER TABLE fl_inbound_fee
MODIFY COLUMN baseline_chargeable_quantity DECIMAL(18,6) NOT NULL
COMMENT '系统按权威测量与生效报价计算的原始计费量';
SET @fee_tolerance_percent_sql = IF(
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='fl_inbound_fee' AND column_name='tolerance_percent'),
'SELECT 1',
'ALTER TABLE fl_inbound_fee ADD COLUMN tolerance_percent DECIMAL(5,2) NOT NULL DEFAULT 10.00 COMMENT ''人工调整允许的最大偏差百分比,边界包含'' AFTER baseline_chargeable_quantity'
);
PREPARE fee_tolerance_percent_stmt FROM @fee_tolerance_percent_sql;
EXECUTE fee_tolerance_percent_stmt;
DEALLOCATE PREPARE fee_tolerance_percent_stmt;
CREATE TABLE IF NOT EXISTS fl_inbound_fee_item (
id BIGINT UNSIGNED NOT NULL COMMENT '费用明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
@@ -471,6 +471,15 @@ SELECT 193000000001000111, 0, 'FBA_HEAD_FREIGHT', '头程运费', 'INTERNATIONAL
0, 'system', '2026-08-14 00:00:00.000', 0, 'system', '2026-08-14 00:00:00.000'
WHERE NOT EXISTS (SELECT 1 FROM fl_fee_item WHERE fee_code = 'FBA_HEAD_FREIGHT');
INSERT INTO fl_fee_item (id, tenant_id, fee_code, category, trade_scope, fee_type_name,
pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable,
allocation_fee, description, status, created_by, created_by_name, created_at,
updated_by, updated_by_name, updated_at, deleted, version)
SELECT 193000000001000112, 0, 'PRIVATE_LAST_MILE_DELIVERY', '尾程派送费', 'INTERNATIONAL',
'私人仓尾程派送费', 0, 0, 1, 1, 0, '仅用于destinationType=PRIVATE的入库单',
'ACTIVE', 0, 'SYSTEM', CURRENT_TIMESTAMP(3), 0, 'SYSTEM', CURRENT_TIMESTAMP(3), 0, 0
WHERE NOT EXISTS (SELECT 1 FROM fl_fee_item WHERE fee_code = 'PRIVATE_LAST_MILE_DELIVERY');
DROP PROCEDURE IF EXISTS phase2_publish_assert_fee_schema;
DELIMITER $$
CREATE PROCEDURE phase2_publish_assert_fee_schema()
@@ -491,11 +500,15 @@ BEGIN
OR (SELECT COUNT(*) FROM fl_fee_item WHERE tenant_id = 0 AND status = 'ACTIVE' AND deleted = 0
AND id BETWEEN 193000000001000001 AND 193000000001000110
AND fee_code = CONCAT('FEE_TYPE_', LPAD(id - 193000000001000000, 3, '0'))) <> 110
OR (SELECT COUNT(*) FROM fl_fee_item WHERE tenant_id = 0 AND fee_code = 'FBA_HEAD_FREIGHT'
AND category = '头程运费' AND trade_scope = 'INTERNATIONAL' AND fee_type_name = 'FBA头程运费'
AND pickup_payable = 0 AND pickup_receivable = 0 AND warehouse_payable = 0
AND warehouse_receivable = 1 AND allocation_fee = 0 AND status = 'ACTIVE' AND deleted = 0) <> 1
OR EXISTS (SELECT 1 FROM fl_inbound_fee_detail d JOIN fl_fee_item f ON f.id = d.fee_item_id
OR (SELECT COUNT(*) FROM fl_fee_item WHERE tenant_id = 0 AND fee_code = 'FBA_HEAD_FREIGHT'
AND category = '头程运费' AND trade_scope = 'INTERNATIONAL' AND fee_type_name = 'FBA头程运费'
AND pickup_payable = 0 AND pickup_receivable = 0 AND warehouse_payable = 0
AND warehouse_receivable = 1 AND allocation_fee = 0 AND status = 'ACTIVE' AND deleted = 0) <> 1
OR (SELECT COUNT(*) FROM fl_fee_item WHERE tenant_id = 0 AND fee_code = 'PRIVATE_LAST_MILE_DELIVERY'
AND category = '尾程派送费' AND trade_scope = 'INTERNATIONAL'
AND fee_type_name = '私人仓尾程派送费' AND warehouse_payable = 1
AND warehouse_receivable = 1 AND status = 'ACTIVE' AND deleted = 0) <> 1
OR EXISTS (SELECT 1 FROM fl_inbound_fee_detail d JOIN fl_fee_item f ON f.id = d.fee_item_id
WHERE d.tenant_id = 0 OR f.tenant_id <> 0) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二期迁移失败:费用项目未完成平台公共作用域或明细跨客户引用校验';
END IF;
@@ -165,7 +165,7 @@ paths:
/api/platform/v1/inbounds/{inboundId}/fees/published:
put:
summary: 调整初算或已发布费用
description: 需要 firstleg:fba-pricing:adjust 权限;人工修改计费量、单价和附加费,小计与总额由服务端重算并生成新版本,不回退 WAIT_SPLIT 状态。调整 CALCULATED/CONFIRMED 时生成新的 CALCULATED 版本;调整 SENT 时生成新的 SENT 版本并发布调整事件。
description: 需要 firstleg:fba-pricing:adjust 权限;计费量只能在系统原计费量上下10%范围内(含边界),请求必须携带当前费用版本、必填原因及二次确认标志,越界或并发冲突返回A0409。小计与总额由服务端重算并生成新版本,不回退 WAIT_SPLIT 状态。调整 CALCULATED/CONFIRMED 时生成新的 CALCULATED 版本;调整 SENT 时生成新的 SENT 版本并发布调整事件。
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody:
required: true
@@ -184,7 +184,7 @@ paths:
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailListApiResponse'}}}}, '403': {description: 无权限}}
post:
summary: 新增 MANUAL 其他费用
description: 只允许创建 MANUAL 来源;FBA_PRICING 由系统从 fl_inbound_fee 派生。
description: 只允许创建 MANUAL 来源;FBA_PRICING 由系统从 fl_inbound_fee 派生。费用项目 PRIVATE_LAST_MILE_DELIVERY 是独立的私人仓尾程派送费,仅 destinationType=PRIVATE 的入库单可创建;服务端按稳定 feeCode 校验,不按中文名称判断。
parameters: [{$ref: '#/components/parameters/AccountId'}, {$ref: '#/components/parameters/InboundId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailSaveRequest'}}}}
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailApiResponse'}}}}, '403': {description: 无权限}}
@@ -330,6 +330,10 @@ components:
actualWeightKg: {type: number, nullable: true}
actualVolumeCbm: {type: number, nullable: true}
convertedQuantity: {type: number, nullable: true}
baselineChargeableQuantity: {type: number, description: 系统按权威测量与生效报价计算的原计费量,人工调整后保持不变}
lowerChargeableQuantity: {type: number, description: 允许人工调整的计费量下界,包含边界}
upperChargeableQuantity: {type: number, description: 允许人工调整的计费量上界,包含边界}
tolerancePercent: {type: number, enum: [10.00], description: 相对系统原计费量允许的上下偏差百分比}
chargeableQuantity: {type: number, nullable: true}
chargeUnit: {type: string, nullable: true}
unitPrice: {type: number, nullable: true}
@@ -466,16 +470,22 @@ components:
timestamp: {type: string, format: date-time, nullable: true}
AdjustFeeRequest:
type: object
required: [expectedFeeVersion, chargeableQuantity, unitPrice, additionalAmount, reason]
required: [expectedFeeVersion, chargeableQuantity, unitPrice, additionalAmount, reason, toleranceConfirmed]
properties:
expectedFeeVersion: {type: integer, minimum: 1}
chargeableQuantity: {type: number, minimum: 0, exclusiveMinimum: true, description: 计费量,单位沿用当前费用快照的 chargeUnit}
unitPrice: {type: number, minimum: 0, description: 人工确认单价}
additionalAmount: {type: number, minimum: 0, multipleOf: 0.01}
reason: {type: string, minLength: 1, maxLength: 500}
toleranceConfirmed: {type: boolean, enum: [true], description: 前端展示上下界并由操作员二次确认后固定传true;服务端仍会重新校验范围与版本}
CalculateRequest:
type: object
properties: {pricingAt: {type: string, format: date-time, description: 缺省为服务端当前时间}}
properties:
pricingAt: {type: string, format: date-time, description: 缺省为服务端当前时间}
selectedWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true, description: 选定的权威重量快照}
selectedVolumeCbm: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true, description: 选定的权威体积快照}
weightSource: {type: string, maxLength: 32, nullable: true}
volumeSource: {type: string, maxLength: 32, nullable: true}
CustomerRuleVersionRequest:
type: object
required: [ruleCode, customerId, customerName, businessType, chargeMode, taxMode, conversionFactor, roundingScale, roundingMode, currency, effectiveFrom]
@@ -668,5 +678,6 @@ components:
destinationName: {type: string, maxLength: 128, nullable: true}
destinationRegion: {type: string, enum: [WEST, CENTRAL, EAST]}
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
chargeMode: {type: string, enum: [KG, CBM], description: 包税报价固定为KG;不包税报价必须与客户当前计费规则一致}
actualWeightKg: {type: number, minimum: 0}
actualVolumeCbm: {type: number, minimum: 0}
@@ -916,6 +916,8 @@ paths:
grossWeightKg/netWeightKg。两类证据不得合并为无明细归属的统一图片列表。
actualGrossWeightKg 是服务端确认后的实测毛重,actualNetWeightKg 是扣除托盘皮重后的货物净重;
两者语义独立,消费者不得互相回退,也不得根据图片或 OCR 值自行重算。
inboundCode 是当前入库详情唯一入仓码;每条 items 按 inboundDetailId 返回 Order 权威
fbaShipmentNo,并保留独立 scannedShipmentId,匹配失败返回空且禁止两者互相回退或覆盖。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -931,7 +933,9 @@ paths:
summary: 保存测量草稿
description: >-
原单据号码、申报毛重和申报体积由服务端从客户订单生成快照,客户端不得提交覆盖;
actualGoodsName 默认使用客户货物品名汇总,但允许测量人员修正。
actualGoodsName 默认使用客户货物品名汇总,但允许测量人员修正。仅允许 WAIT_MEASURE、MEASURING
或 MEASURE_REJECTED 状态保存;进入 MEASURE_PENDING 后整批测量冻结,确认箱数修正使用
measurement/items/{itemId}/confirmed-package-count 明细专用接口。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -1750,10 +1754,12 @@ components:
type: object
description: >-
单条尺寸测量明细。id 是稳定 measurementItemId;intelligentImageFileId 仅表示该明细的
尺寸测量原图,不得与 weightResults.fileId 混用。
尺寸测量原图,不得与 weightResults.fileId 混用。fbaShipmentNo 是按 inboundDetailId
从 Order 货物明细实时读取的权威货件编号;scannedShipmentId 是独立设备实扫证据,二者不得回退或覆盖。
required: [id]
properties:
id: {type: string, pattern: '^[1-9][0-9]*$', description: 稳定 measurementItemId}
inboundCode: {type: string, nullable: true, description: 当前测量详情唯一权威入仓码,与根对象inboundCode一致}
inboundDetailId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
itemNo: {type: string, nullable: true}
boxSpecification: {type: string, nullable: true}
@@ -1782,7 +1788,8 @@ components:
coefficientMode: {type: string, enum: [ACTUAL, ADD, MULTIPLY], nullable: true}
remark: {type: string, nullable: true}
boxCode: {type: string, nullable: true}
scannedShipmentId: {type: string, nullable: true}
fbaShipmentNo: {type: string, nullable: true, description: Order货物明细按inboundDetailId匹配的权威FBA货件编号;匹配失败为空}
scannedShipmentId: {type: string, nullable: true, description: 设备实扫Shipment ID;仅作现场证据,不覆盖或回退fbaShipmentNo}
confirmedPackageCount: {type: integer, nullable: true}
machineNo: {type: string, nullable: true}
deviceMeasuredAt: {type: string, format: date-time, nullable: true}
@@ -1794,7 +1801,7 @@ components:
required: [inboundId, inboundVersion, items, weightResults]
properties:
inboundId: {type: string, pattern: '^[1-9][0-9]*$'}
inboundCode: {type: string, nullable: true}
inboundCode: {type: string, nullable: true, description: 当前测量详情唯一入仓码,UI业务主展示字段}
companyName: {type: string, nullable: true}
channelCode: {type: string, nullable: true}
channelName: {type: string, nullable: true}
@@ -361,6 +361,8 @@ components:
itemId: {$ref: '#/components/schemas/LongId'}
accountId: {$ref: '#/components/schemas/LongId'}
customerName: {type: string, nullable: true, description: 客户名称快照}
customsTaxMode: {type: string, enum: [TAX_REFUND, BUY_DECLARATION], nullable: true, description: 订单/入仓片段冻结的报关方式;不根据退税文件推断}
taxRefundFlag: {type: boolean, nullable: true, description: customsTaxMode为TAX_REFUND时为true、BUY_DECLARATION时为false;缺少权威事实时为null}
orderId: {$ref: '#/components/schemas/LongId'}
orderNo: {type: string, description: 订单号快照}
inboundId: {$ref: '#/components/schemas/LongId'}
@@ -32,8 +32,10 @@ class InboundMeasurementEvidenceOpenApiContractTest {
Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas"));
Map<String, Object> detailProperties = properties(schemas, "InboundMeasurementDetail");
assertThat(detailProperties).containsKeys(
"actualGrossWeightKg", "actualNetWeightKg", "latestDeclaration", "items", "weightResults",
"inboundCode", "actualGrossWeightKg", "actualNetWeightKg", "latestDeclaration", "items", "weightResults",
"palletLocationGuide");
assertThat(stringMap(detailProperties.get("inboundCode")).get("description").toString())
.contains("唯一入仓码", "UI业务主展示字段");
assertThat(stringMap(detailProperties.get("latestDeclaration")))
.containsEntry("$ref", "#/components/schemas/InboundMeasurementLatestDeclaration");
assertThat(properties(schemas, "InboundMeasurementLatestDeclaration"))
@@ -49,8 +51,11 @@ class InboundMeasurementEvidenceOpenApiContractTest {
Map<String, Object> draftProperties = properties(schemas, "InboundMeasurementSaveDraftRequest");
assertThat(draftProperties).containsKeys("measurementId", "version", "actualGoodsName", "items")
.doesNotContainKeys("originalDocumentNo", "originalGrossWeightKg", "originalVolumeCbm");
assertThat(paths).containsKey(
"/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/draft");
String draftPath = "/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/draft";
assertThat(paths).containsKey(draftPath);
Map<String, Object> draftOperation = stringMap(stringMap(paths.get(draftPath)).get("put"));
assertThat(draftOperation.get("description").toString())
.contains("MEASURE_PENDING", "冻结", "package-count");
assertThat(stringMap(stringMap(draftProperties.get("items")).get("items")))
.containsEntry("$ref", "#/components/schemas/InboundMeasurementItemRequest");
assertThat(properties(schemas, "InboundMeasurementItemRequest")).containsKeys(
@@ -58,7 +63,14 @@ class InboundMeasurementEvidenceOpenApiContractTest {
"widthCm", "heightCm", "intelligentImageFileId", "remark");
Map<String, Object> itemProperties = properties(schemas, "InboundMeasurementItem");
assertThat(itemProperties).containsKeys("id", "intelligentImageFileId");
assertThat(itemProperties).containsKeys(
"id", "inboundCode", "inboundDetailId", "intelligentImageFileId", "fbaShipmentNo", "scannedShipmentId");
assertThat(stringMap(itemProperties.get("inboundCode")).get("description").toString())
.contains("唯一权威入仓码", "根对象inboundCode一致");
assertThat(stringMap(itemProperties.get("fbaShipmentNo")).get("description").toString())
.contains("Order货物明细", "inboundDetailId", "匹配失败为空");
assertThat(stringMap(itemProperties.get("scannedShipmentId")).get("description").toString())
.contains("设备实扫", "不覆盖或回退fbaShipmentNo");
Map<String, Object> weightProperties = properties(schemas, "InboundWeightResult");
assertThat(weightProperties).containsKeys(
"id", "fileId", "recognitionStatus", "grossWeightKg", "netWeightKg", "confirmedAt",
@@ -16,6 +16,7 @@ import org.springframework.security.access.prepost.PreAuthorize;
import java.lang.reflect.Method;
import java.lang.reflect.RecordComponent;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import jakarta.validation.constraints.NotNull;
@@ -145,9 +146,15 @@ class LoadingListControllerContractTest {
.doesNotContainKey("internalContainerNo");
assertThat(properties(schemas, "ConfirmedSummary"))
.containsKeys("planId", "containerBookingId", "loadingListNo");
assertThat(properties(schemas, "LoadingItem")).containsKeys(
"itemId", "accountId", "inboundId", "inboundDetailId", "skuCode",
Map<String, Object> loadingItem = properties(schemas, "LoadingItem");
assertThat(loadingItem).containsKeys(
"itemId", "accountId", "customsTaxMode", "taxRefundFlag", "inboundId", "inboundDetailId", "skuCode",
"destinationWarehouse", "productQuantity", "frozenAt");
assertThat(stringMap(loadingItem.get("customsTaxMode")))
.containsEntry("nullable", true)
.containsEntry("enum", List.of("TAX_REFUND", "BUY_DECLARATION"));
assertThat(stringMap(loadingItem.get("taxRefundFlag")))
.containsEntry("nullable", true);
assertThat(properties(schemas, "OperationHistory")).containsKeys(
"historyId", "itemId", "operationId", "actionType", "fromStatus", "toStatus",
"beforeSnapshot", "afterSnapshot", "operatorId", "occurredAt");
@@ -24,6 +24,8 @@ class FeeItemMigrationSqlContractTest {
.contains("matched_seed.trade_scope = f.trade_scope")
.contains("193000000001000111, 0, 'FBA_HEAD_FREIGHT'")
.contains("fee_code = 'FBA_HEAD_FREIGHT'")
.contains("193000000001000112, 0, 'PRIVATE_LAST_MILE_DELIVERY'")
.contains("fee_code = 'PRIVATE_LAST_MILE_DELIVERY'")
.contains("warehouse_receivable = 1")
.doesNotContain("LEFT JOIN tmp_fee_item_seed by_code")
.doesNotContain("LEFT JOIN tmp_fee_item_seed by_semantic")
@@ -59,6 +59,11 @@ class LoadingListQueryMapperContractTest {
String items = sql("selectItems", Select.class);
assertThat(items)
.contains("i.order_id", "i.order_no", "b.container_no")
.contains("inbound.tax_mode AS customs_tax_mode")
.contains("WHEN 'TAX_REFUND' THEN TRUE")
.contains("WHEN 'BUY_DECLARATION' THEN FALSE")
.contains("ELSE NULL")
.doesNotContain("fl_customs_container_document", "fl_customs_container_document_link")
.contains("b.record_status='ACTIVE'")
.contains("NULLIF(i.pallet_count,0)")
.contains("fl_inbound_weight_result weight")
@@ -5,6 +5,7 @@ import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.ItemRow;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -96,4 +97,47 @@ class LoadingListRepositoryTest {
var confirmed = repository.preloadDetail(row).summary().readiness();
assertThat(confirmed.readyForPda()).isTrue();
}
@Test
void mapsFrozenCustomsTaxFactsPerInboundAcrossCustomers() {
AggregateRow aggregate = new AggregateRow();
aggregate.setPlanId(41L);
aggregate.setContainerBookingId(61L);
aggregate.setPlanNo("LP-41");
aggregate.setPlanStatus("COMPLETED");
aggregate.setVersion(3);
ItemRow refund = item(101L, 11L, 201L, "IN-REFUND", "TAX_REFUND", true);
ItemRow declaration = item(102L, 12L, 202L, "IN-BUY", "BUY_DECLARATION", false);
ItemRow unknown = item(103L, 12L, 203L, "IN-UNKNOWN", null, null);
when(mapper.selectItems(41L)).thenReturn(List.of(refund, declaration, unknown));
when(mapper.selectHistories(41L)).thenReturn(List.of());
var items = repository.confirmedDetail(aggregate).items();
assertThat(items).extracting(value -> value.inboundCode(), value -> value.accountId(),
value -> value.customsTaxMode(), value -> value.taxRefundFlag())
.containsExactly(
org.assertj.core.groups.Tuple.tuple("IN-REFUND", 11L, "TAX_REFUND", true),
org.assertj.core.groups.Tuple.tuple("IN-BUY", 12L, "BUY_DECLARATION", false),
org.assertj.core.groups.Tuple.tuple("IN-UNKNOWN", 12L, null, null));
}
private static ItemRow item(long itemId, long accountId, long inboundId, String inboundCode,
String customsTaxMode, Boolean taxRefundFlag) {
ItemRow row = new ItemRow();
row.setItemId(itemId);
row.setAccountId(accountId);
row.setCustomerName("customer-" + accountId);
row.setCustomsTaxMode(customsTaxMode);
row.setTaxRefundFlag(taxRefundFlag);
row.setOrderId(300L + inboundId);
row.setOrderNo("ORDER-" + inboundId);
row.setInboundId(inboundId);
row.setInboundDetailId(400L + inboundId);
row.setInboundCode(inboundCode);
row.setPackageCount(1);
row.setLoadSequence(1);
row.setItemVersion(0);
return row;
}
}
@@ -11,6 +11,7 @@ import com.baiyue.firstleg.entity.InboundPricingEntity;
import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow;
@@ -37,6 +38,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.never;
@@ -217,6 +219,10 @@ class FbaPricingServiceTest {
InboundPricingEntity inbound = inbound("363.001", "0.5000");
inbound.setPricingTaxMode("TAX_EXCLUDED");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity();
rule.setId(601L); rule.setRuleCode("CBM-RULE"); rule.setRuleVersion(1);
rule.setChargeMode("CBM"); rule.setDefaultRule(true);
when(repository.matchRules(eq(inbound), any())).thenReturn(List.of(rule));
when(priceBookEstimator.estimate(any())).thenReturn(
estimate("CBM", "1.0001", "100.000000", "100.01", "0.00"));
@@ -228,6 +234,8 @@ class FbaPricingServiceTest {
assertThat(response.conversionFactor()).isEqualByComparingTo("363");
assertThat(response.roundingScale()).isEqualTo(6);
assertThat(response.roundingMode()).isEqualTo("HALF_UP");
assertThat(response.chargeRuleId()).isEqualTo(601L);
assertThat(response.ruleCode()).isEqualTo("CBM-RULE");
}
@Test
@@ -483,15 +491,19 @@ class FbaPricingServiceTest {
FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND,
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
1, new BigDecimal("12.5"), new BigDecimal("8.00"),
new BigDecimal("20.00"), "客服修正"), "adjust-1");
1, new BigDecimal("160"), new BigDecimal("8.00"),
new BigDecimal("20.00"), "客服修正", true), "adjust-1");
assertThat(response.feeVersion()).isEqualTo(2);
assertThat(response.feeStatus()).isEqualTo("SENT");
assertThat(response.totalAmount()).isEqualByComparingTo("120.00");
assertThat(response.totalAmount()).isEqualByComparingTo("1300.00");
assertThat(response.baselineChargeableQuantity()).isEqualByComparingTo("167.000000");
assertThat(response.lowerChargeableQuantity()).isEqualByComparingTo("150.300000");
assertThat(response.upperChargeableQuantity()).isEqualByComparingTo("183.700000");
assertThat(response.tolerancePercent()).isEqualByComparingTo("10.00");
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
assertThat(inbound.getProcessStatus()).isEqualTo("WAIT_SPLIT");
assertThat(inserted.get().getChargeableQuantity()).isEqualByComparingTo("12.5000");
assertThat(inserted.get().getChargeableQuantity()).isEqualByComparingTo("160.0000");
verify(repository).supersedeCurrent(TENANT, 501L, "SENT");
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.ADJUSTED),
eq(TENANT), eq(INBOUND), eq(502L), eq(2), eq(NOW));
@@ -538,7 +550,7 @@ class FbaPricingServiceTest {
assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND,
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
1, new BigDecimal("12.5"), new BigDecimal("8.00"),
BigDecimal.ZERO, "客服修正"), "locked-adjust"))
BigDecimal.ZERO, "客服修正", true), "locked-adjust"))
.isInstanceOf(BizException.class)
.hasMessageContaining("客户已确认");
@@ -574,11 +586,11 @@ class FbaPricingServiceTest {
FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND,
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
1, new BigDecimal("12.5"), new BigDecimal("8.00"),
new BigDecimal("20.00"), "客服修正"), "adjust-calculated");
1, new BigDecimal("160"), new BigDecimal("8.00"),
new BigDecimal("20.00"), "客服修正", true), "adjust-calculated");
assertThat(response.feeStatus()).isEqualTo("CALCULATED");
assertThat(response.totalAmount()).isEqualByComparingTo("120.00");
assertThat(response.totalAmount()).isEqualByComparingTo("1300.00");
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
verify(repository).supersedeCurrent(TENANT, 501L, "CALCULATED");
verify(feeDetails).materializeCalculatedFreight(TENANT, INBOUND, 503L, OPERATOR, NOW);
@@ -598,7 +610,7 @@ class FbaPricingServiceTest {
assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND,
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
1, new BigDecimal("12.5"), new BigDecimal("8.00"),
BigDecimal.ZERO, "客服修正"), "stale-adjust"))
BigDecimal.ZERO, "客服修正", true), "stale-adjust"))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
@@ -607,6 +619,64 @@ class FbaPricingServiceTest {
verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), any());
}
@Test
@DisplayName("人工计费量允许上下10%含边界")
void acceptsInclusiveToleranceBoundaries() {
InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("WAIT_SPLIT");
InboundFeeEntity current = savedFee("CALCULATED");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current);
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_ADJUST", "lower-bound")).thenReturn(null);
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(903L); return null; })
.when(repository).insertIdempotency(any());
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
when(repository.supersedeCurrent(TENANT, 501L, "CALCULATED")).thenReturn(1);
when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("CALCULATED"), eq("KG"), any()))
.thenReturn(1);
doAnswer(invocation -> { ((InboundFeeEntity) invocation.getArgument(0)).setId(504L); return null; })
.when(repository).insertFee(any());
when(repository.completeIdempotency(any(), eq(504L), eq(NOW))).thenReturn(1);
when(repository.findFee(TENANT, 504L)).thenAnswer(invocation -> current);
service.adjustPublished(TENANT, OPERATOR, INBOUND,
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
1, new BigDecimal("150.3"), new BigDecimal("2"), BigDecimal.ZERO,
"下界确认", true), "lower-bound");
verify(repository).insertFee(argThat(fee ->
fee.getChargeableQuantity().compareTo(new BigDecimal("150.3000")) == 0));
}
@Test
@DisplayName("人工计费量越过10%或未二次确认返回冲突")
void rejectsOutOfToleranceOrUnconfirmedAdjustment() {
InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("WAIT_SPLIT");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(savedFee("CALCULATED"));
when(repository.findIdempotency(anyLong(), anyLong(), any(), any())).thenReturn(null);
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(904L); return null; })
.when(repository).insertIdempotency(any());
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND,
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
1, new BigDecimal("183.7001"), new BigDecimal("2"), BigDecimal.ZERO,
"越界", true), "outside"))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND,
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
1, new BigDecimal("167"), new BigDecimal("2"), BigDecimal.ZERO,
"未确认", false), "unconfirmed"))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), any());
}
private void readyRepository() {
when(repository.findIdempotency(anyLong(), anyLong(), any(), any())).thenReturn(null);
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; })
@@ -337,6 +337,29 @@ class FeeManagementServiceTest {
.hasMessageContaining("仓储应收/应付");
}
@Test
@DisplayName("私人仓尾程派送费只允许PRIVATE目的地")
void restrictsPrivateLastMileDeliveryFeeByDestinationType() {
InboundOrderEntity fbaInbound = inbound();
fbaInbound.setDestinationType("FBA");
FeeItemEntity privateDelivery = item(true, true);
privateDelivery.setFeeCode("PRIVATE_LAST_MILE_DELIVERY");
when(repository.findInbound(10001L, 21L)).thenReturn(fbaInbound);
when(repository.findItem(11L)).thenReturn(privateDelivery);
assertThatThrownBy(() -> details.create(10001L, 21L, 9L,
detailRequest("RECEIVABLE")))
.isInstanceOf(BizException.class)
.hasMessageContaining("目的地类型为PRIVATE");
verify(repository, never()).insertDetail(any());
fbaInbound.setDestinationType("PRIVATE");
doAnswer(invocation -> { ((InboundFeeDetailEntity) invocation.getArgument(0)).setId(32L); return null; })
.when(repository).insertDetail(any());
assertThat(details.create(10001L, 21L, 9L, detailRequest("RECEIVABLE")).getId())
.isEqualTo(32L);
}
@Test
@DisplayName("审核只更新待审核明细")
void approvesPendingDetail() {
@@ -75,8 +75,8 @@ class InboundMeasurementServiceTest {
"REF-50", new BigDecimal("2000"), new BigDecimal("20"), "123123")));
when(orderCargoClient.listForCartonLabels("internal-token", 1L, 80L)).thenReturn(
ApiResponse.success(List.of(new OrderCargoClient.CargoLabelBinding(
80001L, 80L, 1, "123123", 3, new BigDecimal("40"),
new BigDecimal("30"), new BigDecimal("20")))));
80001L, 80L, 1, "123123", null, 3, new BigDecimal("40"),
new BigDecimal("30"), new BigDecimal("20"), "FBA-DECLARED-001"))));
}
@Test
@@ -196,7 +196,9 @@ class InboundMeasurementServiceTest {
item.setId(40L);
item.setTenantId(1L);
item.setMeasurementId(30L);
item.setInboundDetailId(80001L);
item.setSourceImageFileId(140L);
item.setScannedShipmentId("FBA-SCANNED-001");
item.setOuterLengthCm(new BigDecimal("64.000"));
item.setOuterWidthCm(new BigDecimal("54.000"));
item.setOuterHeightCm(new BigDecimal("54.000"));
@@ -217,11 +219,15 @@ class InboundMeasurementServiceTest {
InboundMeasurementResponses.Detail result = service.detail(1L, 10L);
assertThat(result.inboundCode()).isEqualTo("RK-001");
assertThat(result.actualGrossWeightKg()).isEqualByComparingTo("30.000");
assertThat(result.actualNetWeightKg()).isEqualByComparingTo("11.500");
assertThat(result.items()).singleElement().satisfies(dimension -> {
assertThat(dimension.id()).isEqualTo(40L);
assertThat(dimension.inboundCode()).isEqualTo("RK-001");
assertThat(dimension.intelligentImageFileId()).isEqualTo(140L);
assertThat(dimension.fbaShipmentNo()).isEqualTo("FBA-DECLARED-001");
assertThat(dimension.scannedShipmentId()).isEqualTo("FBA-SCANNED-001");
assertThat(dimension.outerLengthCm()).isNull();
assertThat(dimension.outerWidthCm()).isNull();
assertThat(dimension.outerHeightCm()).isNull();
@@ -246,6 +252,63 @@ class InboundMeasurementServiceTest {
});
}
@Test
void detailMapsMultipleDeclaredShipmentIdsByInboundDetailWithoutFallingBackToScannedValue() {
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(30L);
InboundMeasurementItemEntity first = new InboundMeasurementItemEntity();
first.setId(41L);
first.setInboundDetailId(80001L);
first.setScannedShipmentId("SCAN-DIFFERENT-001");
InboundMeasurementItemEntity second = new InboundMeasurementItemEntity();
second.setId(42L);
second.setInboundDetailId(80002L);
InboundMeasurementItemEntity unmatched = new InboundMeasurementItemEntity();
unmatched.setId(43L);
unmatched.setInboundDetailId(89999L);
unmatched.setScannedShipmentId("SCAN-ONLY-999");
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findLatestMeasurement(1L, 10L)).thenReturn(measurement);
when(repository.listItems(1L, 30L)).thenReturn(List.of(first, second, unmatched));
when(repository.listWeights(1L, 10L)).thenReturn(List.of());
when(orderCargoClient.listForCartonLabels("internal-token", 1L, 80L)).thenReturn(
ApiResponse.success(List.of(
new OrderCargoClient.CargoLabelBinding(80001L, 80L, 1, "货物1", null,
1, null, null, null, "FBA-DECLARED-001"),
new OrderCargoClient.CargoLabelBinding(80002L, 80L, 2, "货物2", null,
1, null, null, null, "FBA-DECLARED-002"))));
InboundMeasurementResponses.Detail result = service.detail(1L, 10L);
assertThat(result.inboundCode()).isEqualTo("RK-001");
assertThat(result.items())
.extracting(InboundMeasurementResponses.Item::inboundDetailId,
InboundMeasurementResponses.Item::inboundCode,
InboundMeasurementResponses.Item::fbaShipmentNo,
InboundMeasurementResponses.Item::scannedShipmentId)
.containsExactly(
tuple(80001L, "RK-001", "FBA-DECLARED-001", "SCAN-DIFFERENT-001"),
tuple(80002L, "RK-001", "FBA-DECLARED-002", null),
tuple(89999L, "RK-001", null, "SCAN-ONLY-999"));
verify(orderCargoClient, times(1)).listForCartonLabels("internal-token", 1L, 80L);
}
@Test
void detailRejectsCargoBatchWhoseOrderOwnershipDoesNotMatchInbound() {
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.listWeights(1L, 10L)).thenReturn(List.of());
when(orderCargoClient.listForCartonLabels("internal-token", 1L, 80L)).thenReturn(
ApiResponse.success(List.of(new OrderCargoClient.CargoLabelBinding(
80001L, 81L, 1, "其他订单货物", null, 1, null, null, null,
"FBA-OTHER-001"))));
assertThatThrownBy(() -> service.detail(1L, 10L))
.isInstanceOf(BizException.class)
.hasMessageContaining("货物明细归属不一致");
}
@Test
void detailReturnsWarehouseOuterDimensionsOnlyAfterManualOverrideWasSaved() {
InboundOrderEntity inbound = inbound("MEASURING");
@@ -333,8 +396,8 @@ class InboundMeasurementServiceTest {
"NEW-REF", new BigDecimal("40.000"), new BigDecimal("0.8000"), "最新申报品名")));
when(orderCargoClient.listForCartonLabels("internal-token", 1L, 80L)).thenReturn(
ApiResponse.success(List.of(new OrderCargoClient.CargoLabelBinding(
80001L, 80L, 1, "最新申报品名", 4, new BigDecimal("50"),
new BigDecimal("40"), new BigDecimal("30")))));
80001L, 80L, 1, "最新申报品名", null, 4, new BigDecimal("50"),
new BigDecimal("40"), new BigDecimal("30"), "FBA-LATEST-001"))));
InboundMeasurementResponses.Detail result = service.detail(1L, 10L);
@@ -356,6 +419,7 @@ class InboundMeasurementServiceTest {
assertThat(result.items()).singleElement().satisfies(measured -> {
assertThat(measured.id()).isEqualTo(40L);
assertThat(measured.boxCode()).isEqualTo("RK-001#2-1");
assertThat(measured.fbaShipmentNo()).isEqualTo("FBA-LATEST-001");
assertThat(measured.packageCount()).isEqualTo(2);
assertThat(measured.lengthCm()).isEqualByComparingTo("45");
});
@@ -1116,6 +1180,21 @@ class InboundMeasurementServiceTest {
verify(orderCargoClient).detail("internal-token", 1L, 80L, 80001L);
}
@Test
void savingDraftCannotReopenSubmittedMeasurementWhileReviewIsPending() {
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
inbound.setMeasureReviewStatus("PENDING");
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
assertThatThrownBy(() -> service.saveDraft(1L, 10L, 9L,
new InboundMeasurementRequests.SaveDraftRequest(30L, 10, null, List.of())))
.isInstanceOf(BizException.class)
.hasMessageContaining("已提交待审核");
verifyNoInteractions(batchService);
verify(orderCargoClient, never()).detail(any(), anyLong(), anyLong(), anyLong());
}
@Test
void savingDraftRejectsOverlongDefaultGoodsNameBeforeDatabaseWrite() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
@@ -1157,6 +1236,7 @@ class InboundMeasurementServiceTest {
inbound.setTenantId(1L);
inbound.setOrderId(80L);
inbound.setWarehouseId(100L);
inbound.setInboundCode("RK-001");
inbound.setProcessStatus(status);
inbound.setTotalPackageCount(3);
inbound.setVersion(0);
@@ -392,28 +392,31 @@ class MeasurementBatchServiceTest {
}
@Test
void approvedManualMeasurementCanBeEditedAsDraftBeforeReviewCompletes() {
void submittedMeasurementCannotBeResetToDraftWhileReviewIsPending() {
InboundMeasurementEntity existing = new InboundMeasurementEntity();
existing.setId(100L);
existing.setTenantId(1L);
existing.setInboundId(10L);
existing.setSourceType("MANUAL");
existing.setStatus("APPROVED");
existing.setStatus("SUBMITTED");
existing.setVersion(3);
InboundOrderEntity inbound = inbound();
inbound.setProcessStatus("MEASURE_PENDING");
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findMeasurement(1L, 10L, 100L)).thenReturn(existing);
when(repository.updateMeasurement(existing)).thenReturn(true);
MeasurementBatchCommand command = new MeasurementBatchCommand(
1L, 10L, 100L, 3, "MANUAL", "PLATFORM", null, null, null,
LocalDateTime.now(), null, "CKW-2", new BigDecimal("21"),
new BigDecimal("0.2"), "服装", 9L, List.of(item(BigDecimal.ONE)));
InboundMeasurementEntity saved = service.saveBatch(command);
assertThat(saved.getStatus()).isEqualTo("DRAFT");
verify(repository).updateMeasurement(existing);
verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L),
org.mockito.ArgumentMatchers.eq(100L), any());
assertThatThrownBy(() -> service.saveBatch(command))
.isInstanceOf(BizException.class)
.hasMessageContaining("已提交批次必须保持冻结");
assertThat(existing.getStatus()).isEqualTo("SUBMITTED");
verify(repository, never()).updateMeasurement(any());
verify(repository, never()).replaceItems(
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), any());
}
@Test
@@ -292,6 +292,7 @@ class MerchantPriceBookServiceTest {
String weight, String volume) {
return new EstimateRequest(10L, channelCode, destinationCode, destinationType,
destinationWarehouseId, null, destinationRegion, pricingTaxMode,
pricingTaxMode == PricingTaxMode.TAX_INCLUDED ? "KG" : "CBM",
decimal(weight), decimal(volume));
}
@@ -81,7 +81,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.getHeightCm(), cargo.getShipmentId()))
.toList());
}
@@ -171,18 +171,19 @@ public class InternalOrderCargoController {
public record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
String skuNo, Integer packageCount, Integer unitsPerCarton,
java.math.BigDecimal lengthCm,
java.math.BigDecimal widthCm, java.math.BigDecimal heightCm) {
java.math.BigDecimal widthCm, java.math.BigDecimal heightCm,
String shipmentId) {
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);
lengthCm, widthCm, heightCm, 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);
lengthCm, widthCm, heightCm, null);
}
}
@@ -6,6 +6,28 @@ info:
security:
- bearerAuth: []
paths:
/internal/order/v1/cargoes/{tenantId}/{orderId}:
get:
security: []
summary: Firstleg 批量读取订单货物绑定
description: >-
仅接受内部服务令牌。按 tenantId 与 orderId 严格限定订单范围,并按订单货物顺序一次返回全部货物;
cargoId 是 Firstleg inboundDetailId 的绑定键,shipmentId 仅取对应货物行快照,
不使用订单顶层货件号回退,也不提供逐货物查询替代本批量契约。
parameters:
- name: X-Internal-Service-Token
in: header
required: true
schema: {type: string}
- {name: tenantId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: orderId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
'200':
description: 当前订单范围内的货物绑定列表
content:
application/json:
schema: {$ref: '#/components/schemas/CargoLabelBindingListEnvelope'}
'403': {description: 内部服务令牌无效}
/internal/order/v1/pending-review-orders:
get:
security: []
@@ -590,6 +612,29 @@ components:
scheme: bearer
bearerFormat: JWT
schemas:
CargoLabelBindingListEnvelope:
type: object
required: [code, data]
properties:
code: {type: string, example: "200"}
message: {type: string, nullable: true}
data:
type: array
items: {$ref: '#/components/schemas/CargoLabelBinding'}
CargoLabelBinding:
type: object
required: [cargoId, orderId, itemNo, cargoName, packageCount]
properties:
cargoId: {type: string, pattern: '^[1-9][0-9]*$', description: Firstleg inboundDetailId 的绑定键}
orderId: {type: string, pattern: '^[1-9][0-9]*$'}
itemNo: {type: integer, minimum: 1}
cargoName: {type: string}
skuNo: {type: string, nullable: true}
packageCount: {type: integer, minimum: 1}
lengthCm: {type: number, nullable: true}
widthCm: {type: number, nullable: true}
heightCm: {type: number, nullable: true}
shipmentId: {type: string, nullable: true, description: 当前货物行的 FBA 货件号快照,不以订单级字段回退}
DomesticWarehouseOrderExportBatchRequest:
type: object
required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit]
@@ -92,6 +92,8 @@ class InternalOrderCargoControllerTest {
OrderCargoEntity second = cargo(32L, 2, 2, "50", "30", "20");
OrderEntity order = new OrderEntity(); order.setId(21L); order.setFulfillmentType("STANDARD");
when(orders.findOwned(11L, 21L)).thenReturn(order);
first.setShipmentId("FBA-SHIPMENT-31");
second.setShipmentId("FBA-SHIPMENT-32");
when(orders.findCargoInheritedByOrder(11L, 21L)).thenReturn(List.of(first, second));
var response = controller.listForCartonLabels("internal-token", 11L, 21L);
@@ -104,6 +106,8 @@ class InternalOrderCargoControllerTest {
assertThat(response.data().get(1).unitsPerCarton()).isNull();
assertThat(response.data().get(0).lengthCm()).isEqualByComparingTo("60");
verify(orders).findOwned(11L, 21L);
assertThat(response.data()).extracting(InternalOrderCargoController.CargoLabelBinding::shipmentId)
.containsExactly("FBA-SHIPMENT-31", "FBA-SHIPMENT-32");
verify(orders).findCargoInheritedByOrder(11L, 21L);
}