支持私人地址人工头程报价以闭合预装费用前置

This commit is contained in:
2026-11-04 12:10:14 +08:00
parent 78ecd2e039
commit 6e451a82da
4 changed files with 116 additions and 21 deletions
+1 -1
View File
@@ -31,7 +31,7 @@
| 称重确认与整票库位块 | `service/MeasurementLocationOccupationService.java` 的 `ensureForConfirmedWeight/ensureForSubmission`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`client/WarehousePalletLocationClient.java`;Warehouse 块服务见 `../warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java`,接口与上架引导见 `src/main/resources/openapi/firstleg-v1.yaml` |
| 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` |
| 客户四档箱规与默认算法 | `controller/PlatformCustomerDimensionConfigController.java`、`service/CustomerDimensionConfigService.java`、`repository/CustomerDimensionConfigRepository.java`;固定 `SMALL/MEDIUM/LARGE/EXTRA_LARGE`,默认 `ACTUAL` 不调整尺寸,可启用的调整算法为 `ADD/MULTIPLY` |
| FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值;`POST /fees/preview` 与正式计价共用算法,只读返回当前选择值的计费量、参考单价和合计,不生成费用版本或改变履约、财务状态 |
| FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值;私人地址 `PRIVATE` 使用 `/fees/manual-quote` 生成人工头程报价,按客户计费方式计算计费量且附加费为零,预览返回 `priceMatched=false`,报价后沿用费用版本、幂等与预装前初始费用校验;`POST /fees/preview` 与正式计价共用算法,只读返回当前选择值的计费量、参考单价和合计,不生成费用版本或改变履约、财务状态 |
| 客户专属价格簿、柜级阶梯运费与调价边界 | `controller/PlatformPriceBookController.java`、`service/PlatformPriceBookService.java`、`pricing/ActivePriceBookEstimator.java`、`service/FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`;公共价格簿固定 `customerId=0`,客户专属版本按客户独立发布与替换,计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。`REGION_WEIGHT_TIER` 只对同柜同客户中当前费用快照同为区域重量阶梯的有效成员聚合实际计费量命中一个阶梯,再按这些成员的计费量守恒分摊;同柜允许与 `FBA_WAREHOUSE_RATE` 等固定模型混装,固定模型成员不得参与阶梯聚合或被重算。装柜成员变化调用 `recalculateUnlockedContainerCustomerFees`,已确认/已发送或客户已确认的版本不得改写。人工调整以系统基准量为中心限制上下 10%(含边界),保存版本、原因和二次确认;一件代发 `WAIT_PALLETIZE` 可在预装确认前试算,`markInboundCalculated` 只更新费用并保留该托盘化状态 |
| 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;`PRIVATE_LAST_MILE_DELIVERY` 是独立私人仓尾程派送费,只允许 `destinationType=PRIVATE`,按稳定费用代码校验而不按中文名判断;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` |
| 入库/柜次直接已付快照与凭证校验 | `service/FeePaymentSnapshotValidator.java`、`client/FeePaymentVoucherFileClient.java`;写入入口分别为 `InboundFeeDetailService.java`、`ContainerFeeDetailService.java`,File 业务类型固定 `FINANCE_VOUCHER` |
@@ -73,6 +73,9 @@ public class FbaPricingService {
BigDecimal volume = selectedVolume(inbound, request.selectedVolumeCbm());
PricingPolicy policy = pricingPolicy(inbound,
request.pricingAt() == null ? LocalDateTime.now(clock) : request.pricingAt());
if ("PRIVATE".equals(inbound.getDestinationType())) {
return manualPreview(weight, volume, policy, request.selectedUnitPrice());
}
try {
ActivePriceBookEstimator.Estimate estimate = estimateFee(tenantId, inbound, policy, weight, volume);
BigDecimal additional = estimate.rate().getAdditionalFee() == null ? BigDecimal.ZERO
@@ -91,15 +94,20 @@ public class FbaPricingService {
if (exception.getResultCode() != ResultCode.NOT_FOUND || !"FBA".equals(inbound.getDestinationType())) {
throw exception;
}
BigDecimal factor = new BigDecimal("KG".equals(policy.chargeMode()) ? "167" : "363");
return new FeePreviewResponse(weight, volume, manualChargeableQuantity(policy.chargeMode(), weight, volume),
policy.chargeMode(), factor, 6, "HALF_UP", null,
BigDecimal.ZERO.setScale(MONEY_SCALE), request.selectedUnitPrice() == null ? null
: manualChargeableQuantity(policy.chargeMode(), weight, volume).multiply(request.selectedUnitPrice())
.setScale(MONEY_SCALE, RoundingMode.HALF_UP), "CNY", false);
return manualPreview(weight, volume, policy, request.selectedUnitPrice());
}
}
private FeePreviewResponse manualPreview(BigDecimal weight, BigDecimal volume, PricingPolicy policy,
BigDecimal selectedUnitPrice) {
BigDecimal quantity = manualChargeableQuantity(policy.chargeMode(), weight, volume);
BigDecimal amount = selectedUnitPrice == null ? null
: quantity.multiply(selectedUnitPrice).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
BigDecimal factor = new BigDecimal("KG".equals(policy.chargeMode()) ? "167" : "363");
return new FeePreviewResponse(weight, volume, quantity, policy.chargeMode(), factor, 6, "HALF_UP",
null, BigDecimal.ZERO.setScale(MONEY_SCALE), amount, "CNY", false);
}
@Transactional
public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId,
LocalDateTime requestedAt, String idempotencyKey) {
@@ -133,6 +141,9 @@ public class FbaPricingService {
if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), null);
InboundPricingEntity inbound = requireEligibleInbound(tenantId, inboundId);
if ("PRIVATE".equals(inbound.getDestinationType())) {
throw conflict("私人地址请使用人工头程报价");
}
InboundFeeEntity published = null;
if ("SENT".equals(inbound.getFeeStatus())) {
published = repository.findLatestFeeForUpdate(tenantId, inboundId);
@@ -206,7 +217,9 @@ public class FbaPricingService {
if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), null);
InboundPricingEntity inbound = requireEligibleInbound(tenantId, inboundId);
if (!"FBA".equals(inbound.getDestinationType())) throw conflict("仅FBA头程运费支持未命中人工定价");
if (!"FBA".equals(inbound.getDestinationType()) && !"PRIVATE".equals(inbound.getDestinationType())) {
throw conflict("仅FBA未命中报价或私人地址支持人工头程定价");
}
var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId);
if (measurement == null || measurementRepository.listItems(tenantId, measurement.getId()).isEmpty()) {
throw conflict("实际测量明细不存在,不能生成人工头程费用");
@@ -218,14 +231,17 @@ public class FbaPricingService {
}
LocalDateTime now = LocalDateTime.now(clock);
PricingPolicy policy = pricingPolicy(inbound, now);
try {
priceBookEstimator.estimate(tenantId, new EstimateRequest(
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null,
pricingTaxMode(inbound), policy.chargeMode(), request.selectedWeightKg(), request.selectedVolumeCbm()));
throw conflict("当前条件已有匹配价格,请使用正常试算并调整单价");
} catch (BizException exception) {
if (exception.getResultCode() != ResultCode.NOT_FOUND) throw exception;
// 私人地址使用人工报价;FBA仍须先证明价格表未命中,避免绕开有效报价。
if ("FBA".equals(inbound.getDestinationType())) {
try {
priceBookEstimator.estimate(tenantId, new EstimateRequest(
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null,
pricingTaxMode(inbound), policy.chargeMode(), request.selectedWeightKg(), request.selectedVolumeCbm()));
throw conflict("当前条件已有匹配价格,请使用正常试算并调整单价");
} catch (BizException exception) {
if (exception.getResultCode() != ResultCode.NOT_FOUND) throw exception;
}
}
BigDecimal weight = request.selectedWeightKg().setScale(3, RoundingMode.HALF_UP);
BigDecimal volume = request.selectedVolumeCbm().setScale(4, RoundingMode.HALF_UP);
@@ -264,7 +280,8 @@ public class FbaPricingService {
throw conflict("费用已被其他操作员修改,请刷新后重试");
}
repository.insertFee(fee);
InboundFeeItemEntity item = item(tenantId, fee, 1, "FBA_BASE", "FBA基础运费", subtotal, now);
String feeName = "PRIVATE".equals(inbound.getDestinationType()) ? "头程基础运费" : "FBA基础运费";
InboundFeeItemEntity item = item(tenantId, fee, 1, "FBA_BASE", feeName, subtotal, now);
item.setQuantity(quantity); item.setUnit(unit); item.setUnitPrice(request.unitPrice());
item.setAutomaticFlag(false); item.setRemark(reason);
repository.insertItem(item);
@@ -784,8 +801,9 @@ public class FbaPricingService {
private InboundPricingEntity requireApprovedInbound(long tenantId, long inboundId) {
InboundPricingEntity inbound = repository.findInbound(tenantId, inboundId);
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
if (!"FBA".equals(inbound.getDestinationType()) && !"OVERSEAS".equals(inbound.getDestinationType())) {
throw conflict("当前目的地类型不支持自动计费");
if (!"FBA".equals(inbound.getDestinationType()) && !"OVERSEAS".equals(inbound.getDestinationType())
&& !"PRIVATE".equals(inbound.getDestinationType())) {
throw conflict("当前目的地类型不支持头程计费");
}
if (inbound.getWarehouseId() == null || !StringUtils.hasText(inbound.getChannelCode())) {
throw conflict("入库单缺少起运仓或渠道匹配维度");
@@ -163,8 +163,8 @@ paths:
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}}
/api/platform/v1/inbounds/{inboundId}/fees/manual-quote:
post:
summary: FBA价格未命中时人工确定头程单价
description: 仅FBA且价格查询返回未命中时可用;按客户计费方式与选定测量重量、体积计算计费量,附加费为零。生成正式头程费用快照,后续复用确认与发送;需要 firstleg:inbound:confirm-send-fee 权限。
summary: 人工确定FBA未命中报价或私人地址头程单价
description: FBA须价格查询返回未命中;私人地址PRIVATE按人工单价报价,预览返回计费量且priceMatched=false。按客户计费方式与选定测量重量、体积计算计费量,附加费为零。生成CALCULATED正式头程费用快照并保留WAIT_SPLIT,后续复用版本、确认、发送与幂等流程,预装确认继续校验初始费用已完成;需要 firstleg:inbound:confirm-send-fee 权限。
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ManualQuoteRequest'}}}}
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}}
@@ -483,6 +483,83 @@ class FbaPricingServiceTest {
assertThat(feeItem.getValue().getRemark()).isEqualTo("FBA价格未维护");
}
@Test
void previewsPrivateAddressManualQuantityWithoutLookingUpWarehouseRates() {
InboundPricingEntity inbound = inbound("62.500", "0.3840");
inbound.setDestinationType("PRIVATE");
inbound.setDestinationCode(null);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
var result = service.preview(TENANT, INBOUND, new FeePreviewRequest(NOW,
new BigDecimal("62.500"), new BigDecimal("0.3840"), null, null, new BigDecimal("12.2")));
assertThat(result.priceMatched()).isFalse();
assertThat(result.chargeableQuantity()).isEqualByComparingTo("64.128000");
assertThat(result.totalAmount()).isEqualByComparingTo("782.36");
org.mockito.Mockito.verifyNoInteractions(priceBookEstimator, feeDetails, financeOutbox);
verify(repository, never()).insertFee(any());
}
@Test
void createsPrivateAddressManualQuoteAndPreservesWaitSplit() {
readyRepository();
InboundPricingEntity inbound = inbound("62.500", "0.3840");
inbound.setDestinationType("PRIVATE");
inbound.setDestinationCode(null);
inbound.setProcessStatus("WAIT_SPLIT");
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(301L);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(measurementRepository.findLatestMeasurement(TENANT, INBOUND)).thenReturn(measurement);
when(measurementRepository.listItems(TENANT, 301L)).thenReturn(List.of(new InboundMeasurementItemEntity()));
var request = new ManualQuoteRequest(new BigDecimal("62.500"), new BigDecimal("0.3840"),
new BigDecimal("12.2"), "私人地址人工报价");
var result = service.manualQuote(TENANT, OPERATOR, INBOUND, request, "private-manual");
assertThat(result.rateCode()).isEqualTo("MANUAL_QUOTE");
assertThat(result.feeStatus()).isEqualTo("CALCULATED");
assertThat(result.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
assertThat(result.totalAmount()).isEqualByComparingTo("782.36");
ArgumentCaptor<InboundFeeEntity> fee = ArgumentCaptor.forClass(InboundFeeEntity.class);
verify(repository).insertFee(fee.capture());
assertThat(fee.getValue().getDestinationType()).isEqualTo("PRIVATE");
assertThat(fee.getValue().getCustomerId()).isEqualTo(TENANT);
assertThat(fee.getValue().getAdditionalAmount()).isEqualByComparingTo("0");
ArgumentCaptor<InboundFeeItemEntity> item = ArgumentCaptor.forClass(InboundFeeItemEntity.class);
verify(repository).insertItem(item.capture());
assertThat(item.getValue().getFeeName()).isEqualTo("头程基础运费");
assertThat(item.getValue().getAutomaticFlag()).isFalse();
org.mockito.Mockito.verifyNoInteractions(priceBookEstimator, financeOutbox);
ArgumentCaptor<InboundPricingIdempotencyEntity> command =
ArgumentCaptor.forClass(InboundPricingIdempotencyEntity.class);
verify(repository).insertIdempotency(command.capture());
command.getValue().setStatus("COMPLETED");
command.getValue().setResultResourceId(result.feeId());
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_MANUAL_QUOTE", "private-manual"))
.thenReturn(command.getValue());
when(repository.findFee(TENANT, result.feeId())).thenReturn(fee.getValue());
when(repository.findBaseItem(TENANT, result.feeId())).thenReturn(item.getValue());
var replay = service.manualQuote(TENANT, OPERATOR, INBOUND, request, "private-manual");
assertThat(replay.feeId()).isEqualTo(result.feeId());
verify(repository, times(1)).insertFee(any());
verify(repository, times(1)).insertIdempotency(any());
}
@Test
void rejectsAutomaticPricingForPrivateAddress() {
readyRepository();
InboundPricingEntity inbound = inbound("62.500", "0.3840");
inbound.setDestinationType("PRIVATE");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "private-auto"))
.isInstanceOf(BizException.class).hasMessageContaining("私人地址请使用人工头程报价");
verify(repository, never()).insertFee(any());
org.mockito.Mockito.verifyNoInteractions(priceBookEstimator);
}
@Test
@DisplayName("已有匹配价格时拒绝绕过价格表创建人工报价")
void rejectsManualQuoteWhenRateExists() {