支持私人地址人工头程报价以闭合预装费用前置
This commit is contained in:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user