From 78a73a1b0e5c8a685705fdabcb4d12b4a22769ce Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 17:07:06 +0800 Subject: [PATCH 1/5] feat(firstleg): expose loading item tax mode --- .../baiyue/firstleg/dto/LoadingListDtos.java | 2 + .../mapper/LoadingListQueryMapper.java | 11 ++++- .../repository/LoadingListRepository.java | 3 +- .../resources/openapi/loading-lists-v1.yaml | 2 + .../LoadingListControllerContractTest.java | 11 ++++- .../LoadingListQueryMapperContractTest.java | 5 +++ .../repository/LoadingListRepositoryTest.java | 44 +++++++++++++++++++ 7 files changed, 74 insertions(+), 4 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java index 58dbe8e4e..ee98f5bc5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java @@ -177,6 +177,8 @@ public final class LoadingListDtos { Long itemId, Long accountId, String customerName, + String customsTaxMode, + Boolean taxRefundFlag, Long orderId, String orderNo, Long inboundId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java index 97c77cfaa..db85de82e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java @@ -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.package_count, @@ -409,6 +416,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; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java index 33289ea30..16503bed5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java @@ -184,7 +184,8 @@ public class LoadingListRepository { private List items(List 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(), integer(row.getPackageCount()), integer(row.getPalletCount()), diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index 4a9021268..8dce29cbf 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -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'} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index 21563a38f..aca53edea 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -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 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"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java index f24d554ff..659703cb6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java @@ -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") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java index f9996b3f3..63c6fffe0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java @@ -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; + } } From 2e60f2cd773a44f3ad64bc95bfc1b0c926c89d8e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 17:13:43 +0800 Subject: [PATCH 2/5] feat(firstleg): enforce pricing adjustment bounds --- .../baiyue/firstleg/dto/FbaPricingDtos.java | 8 +- .../baiyue/firstleg/dto/PriceBookDtos.java | 13 ++- .../firstleg/entity/InboundFeeEntity.java | 4 + .../pricing/ActivePriceBookEstimator.java | 18 ++++ .../repository/FbaPricingRepository.java | 2 +- .../firstleg/service/FbaPricingService.java | 72 ++++++++++++++- .../service/MerchantPriceBookService.java | 4 + .../resources/db/phase2-inbound-schema.sql | 28 ++++++ .../resources/openapi/fba-pricing-v1.yaml | 17 +++- .../service/FbaPricingServiceTest.java | 88 +++++++++++++++++-- .../service/MerchantPriceBookServiceTest.java | 1 + 11 files changed, 236 insertions(+), 19 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java index 6ceff5682..84aa64bde 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java @@ -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, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java index cad3a507c..b81b8b94f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java @@ -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, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeEntity.java index 667d3a527..bf8158474 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeEntity.java @@ -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; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java index 9eba2b66d..aca7ccbcf 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java @@ -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 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, "价格模型与渠道配置不一致"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java index 26c6ffcda..867e31cde 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java @@ -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) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index 5a36673a5..7183beaba 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -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 rules = repository.matchRules(inbound, at); + if (rules == null || rules.isEmpty()) { + throw conflict("客户未配置当前不包税报价的计费方式"); + } + List 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"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java index d913613d4..a4e5e6a9d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java @@ -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(), diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index 76de2bd92..894c8e38d 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -672,6 +672,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 '币种', @@ -701,6 +703,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和数据隔离键', diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index ec62caa77..df24f0f7b 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -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 @@ -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} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index 19954f276..bd95d463f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -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; }) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java index 8f6329212..8ead0e597 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java @@ -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)); } From e597bebafdaa3a35605c4112abca962a0782b147 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 17:17:57 +0800 Subject: [PATCH 3/5] feat(order): expose cargo shipment binding --- .../InternalOrderCargoController.java | 7 +-- .../src/main/resources/openapi/order-v1.yaml | 45 +++++++++++++++++++ .../InternalOrderCargoControllerTest.java | 4 ++ 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java index aff44bcb5..0f3d06b38 100644 --- a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java +++ b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java @@ -75,7 +75,7 @@ public class InternalOrderCargoController { return ApiResponse.success(orders.findCargoInheritedByOrder(tenantId, orderId).stream() .map(cargo -> new CargoLabelBinding(cargo.getId(), orderId, cargo.getItemNo(), cargo.getCargoName(), cargo.getSkuNo(), cargo.getPackageCount(), cargo.getLengthCm(), - cargo.getWidthCm(), cargo.getHeightCm())) + cargo.getWidthCm(), cargo.getHeightCm(), cargo.getShipmentId())) .toList()); } @@ -156,11 +156,12 @@ public class InternalOrderCargoController { public record 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) { + 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, lengthCm, widthCm, heightCm); + this(cargoId, orderId, itemNo, cargoName, null, packageCount, lengthCm, widthCm, heightCm, null); } } diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 5a2af485c..b1ea0db3c 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -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] diff --git a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java index 97be8050c..0de14a34c 100644 --- a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java +++ b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java @@ -89,6 +89,8 @@ class InternalOrderCargoControllerTest { void listsCargoInRepositoryOrderForStableCartonAllocation() { OrderCargoEntity first = cargo(31L, 1, 3, "60", "40", "30"); OrderCargoEntity second = cargo(32L, 2, 2, "50", "30", "20"); + 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); @@ -98,6 +100,8 @@ class InternalOrderCargoControllerTest { .containsExactly(31L, 32L); assertThat(response.data().get(0).packageCount()).isEqualTo(3); assertThat(response.data().get(0).lengthCm()).isEqualByComparingTo("60"); + assertThat(response.data()).extracting(InternalOrderCargoController.CargoLabelBinding::shipmentId) + .containsExactly("FBA-SHIPMENT-31", "FBA-SHIPMENT-32"); verify(orders).findCargoInheritedByOrder(11L, 21L); } From ba81dece5e039ca8d4826ed5afa8d28771986921 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 17:24:29 +0800 Subject: [PATCH 4/5] feat(firstleg): expose authoritative measurement identifiers --- .../firstleg/client/OrderCargoClient.java | 7 +- .../PlatformInboundMeasurementController.java | 6 +- .../dto/InboundMeasurementResponses.java | 5 ++ .../service/InboundMeasurementService.java | 61 ++++++++++--- .../service/MeasurementBatchService.java | 13 +-- .../main/resources/openapi/firstleg-v1.yaml | 15 +++- ...easurementEvidenceOpenApiContractTest.java | 20 ++++- .../InboundMeasurementServiceTest.java | 88 ++++++++++++++++++- .../service/MeasurementBatchServiceTest.java | 21 +++-- 9 files changed, 190 insertions(+), 46 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java index ac30d0298..4508179b8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java @@ -63,12 +63,13 @@ public interface OrderCargoClient { } record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, - String skuNo, Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm, - BigDecimal heightCm) { + String skuNo, Integer packageCount, 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, lengthCm, widthCm, heightCm); + this(cargoId, orderId, itemNo, cargoName, null, + packageCount, lengthCm, widthCm, heightCm, null); } } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java index c83eb68ad..569cfa5e5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java @@ -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 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')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java index 8d118da90..7934f7b4f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java @@ -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, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index 50e3a0a46..66c4c636a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -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 MEASUREMENT_DRAFT_EDITABLE_STATUSES = Set.of( + "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); private static final Set 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 allocationSlots, Map 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 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 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 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 fbaShipmentNos + ) { + } + private void requireConfirmedPackageTotal(InboundOrderEntity inbound, List items) { if (inbound.getTotalPackageCount() == null || inbound.getTotalPackageCount() < 1) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java index 3246f5cdd..bad5a21ad 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java @@ -42,6 +42,8 @@ public class MeasurementBatchService { static final String STATUS_DRAFT = "DRAFT"; /** 已接收第三方测量数据,待平台提交审核。 */ static final String STATUS_RECEIVED = "RECEIVED"; + private static final Set 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 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)) { diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 1252ac4b4..bb9ebde90 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -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]*$'}} @@ -1749,10 +1753,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} @@ -1781,7 +1787,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} @@ -1793,7 +1800,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} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java index c9adf167b..b033f0119 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java @@ -32,8 +32,10 @@ class InboundMeasurementEvidenceOpenApiContractTest { Map schemas = stringMap(stringMap(document.get("components")).get("schemas")); Map 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 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 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 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 weightProperties = properties(schemas, "InboundWeightResult"); assertThat(weightProperties).containsKeys( "id", "fileId", "recognitionStatus", "grossWeightKg", "netWeightKg", "confirmedAt", diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java index ab0c01789..65b4a70dd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -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); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java index ebdb1dccc..4ba98e21f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java @@ -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 From 442518c382e3c28c89e381b4312885fb71d406e9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 17:27:20 +0800 Subject: [PATCH 5/5] feat(firstleg): add private warehouse delivery fee --- deploy/sql/头程数据库统一增量-MySQL8.sql | 14 +++++++++++ .../service/InboundFeeDetailService.java | 9 ++++++++ ...se2-published-freight-detail-migration.sql | 23 +++++++++++++++---- .../resources/openapi/fba-pricing-v1.yaml | 2 +- .../FeeItemMigrationSqlContractTest.java | 2 ++ .../service/FeeManagementServiceTest.java | 23 +++++++++++++++++++ 6 files changed, 67 insertions(+), 6 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index ac44ef196..a1f23b92b 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -7681,6 +7681,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:三期分柜、预装、拣货、装柜和派送当前基线 -- ============================================================================ @@ -15607,6 +15616,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 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java index b35a25a7a..de61d512c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java @@ -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)) { diff --git a/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql b/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql index 4fa48c82d..0e115c876 100644 --- a/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql +++ b/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql @@ -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; diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index df24f0f7b..240b9e4e1 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -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: 无权限}} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java index 38a44e5ce..144a900f1 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java @@ -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") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java index 6588c9a40..a0fdc7b00 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java @@ -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() {