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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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(),
|
||||
|
||||
@@ -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'}
|
||||
|
||||
+16
-4
@@ -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",
|
||||
|
||||
+9
-2
@@ -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");
|
||||
|
||||
+2
@@ -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")
|
||||
|
||||
+5
@@ -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() {
|
||||
|
||||
+84
-4
@@ -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);
|
||||
|
||||
+12
-9
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user