费用提交

This commit is contained in:
2026-10-26 13:41:43 +08:00
parent 0fe1d10566
commit 761fe3686e
171 changed files with 8750 additions and 386 deletions
@@ -19,6 +19,14 @@ public class InternalOverseasStorageFeeController {
@Value("${baiyue.security.internal-service-token}") private String internalToken;
private byte[] expected;
@PostConstruct void init() { expected = internalToken.getBytes(StandardCharsets.UTF_8); }
/** 内部计租只读入口,先校验服务身份再查询客户配置。 */
@GetMapping("/{customerId}/pricing")
public ApiResponse<OverseasStorageFeeDtos.PricingView> pricing(
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable long customerId) {
verify(token); return ApiResponse.success(service.pricing(customerId));
}
@PostMapping("/defaults")
public ApiResponse<OverseasStorageFeeDtos.DefaultInitializationResponse> defaults(
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@@ -97,4 +97,9 @@ public final class OverseasStorageFeeDtos {
Long operatorId, String customerName) {}
public record DefaultInitializationResponse(String configId, boolean createdByThisInitialization) {}
public record CompensationRequest(@NotNull @Positive Long tenantId, @NotBlank String initializationKey) {}
/** 计租专用的客户配置一致性快照;默认档位不生成配置记录。 */
@Schema(name = "OverseasStorageFeePricingView", description = "客户海外仓计租配置快照")
public record PricingView(String configId, Long customerId, Integer version, String currency,
List<TierView> tiers, String source) {}
}
@@ -38,6 +38,12 @@ public class OverseasStorageFeeRepository {
public OverseasStorageFeeRuleEntity find(long id) {
return ruleMapper.selectById(id);
}
/** 按客户读取唯一有效配置,不借用其他客户或默认初始化标识。 */
public OverseasStorageFeeRuleEntity findByCustomer(long customerId) {
return ruleMapper.selectOne(new LambdaQueryWrapper<OverseasStorageFeeRuleEntity>()
.eq(OverseasStorageFeeRuleEntity::getTenantId, customerId));
}
public OverseasStorageFeeRuleEntity findByTenantAndKey(long tenantId, String key) {
return ruleMapper.selectOne(new LambdaQueryWrapper<OverseasStorageFeeRuleEntity>()
.eq(OverseasStorageFeeRuleEntity::getTenantId, tenantId)
@@ -12,6 +12,7 @@ import jakarta.annotation.Resource;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.annotation.Isolation;
import java.util.ArrayList;
import java.util.Comparator;
@@ -26,6 +27,53 @@ public class OverseasStorageFeeService {
@Resource
private OverseasStorageFeeRepository repository;
/** 同一可重复读快照中读取主档和阶梯;异常配置拒绝计租,不写入或修补存量。 */
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public OverseasStorageFeeDtos.PricingView pricing(long customerId) {
if (customerId <= 0) throw new BizException(ResultCode.VALIDATION_FAILED, "客户ID必须为正数");
var rule = repository.findByCustomer(customerId);
if (rule == null) return new OverseasStorageFeeDtos.PricingView(null, customerId, null, "USD",
pricingViews(tierEntities(0, customerId, defaultTiers())), "DEFAULT");
if (!Long.valueOf(customerId).equals(rule.getTenantId()) || rule.getId() == null
|| rule.getVersion() == null || rule.getVersion() < 0 || Boolean.TRUE.equals(rule.getDeleted())
|| rule.getCurrency() == null || !rule.getCurrency().matches("[A-Z]{3}"))
throw new BizException(ResultCode.CONFLICT, "客户仓租配置无效,请先核对配置");
var effective = new ArrayList<>(repository.findTiers(List.of(rule.getId())));
if (effective.stream().anyMatch(tier -> tier == null || !rule.getId().equals(tier.getRuleId())
|| !Long.valueOf(customerId).equals(tier.getTenantId())))
throw new BizException(ResultCode.CONFLICT, "客户仓租阶梯归属不一致");
if (effective.stream().anyMatch(tier -> tier.getStartDay() == null || tier.getStartDay() < 1
|| tier.getStartDay() > 36500 || tier.getEndDay() != null && (tier.getEndDay() < 1 || tier.getEndDay() > 36500)))
throw new BizException(ResultCode.CONFLICT, "客户仓租阶梯天数无效");
for (String type : List.of("STORAGE", "RETURN_STORAGE")) {
if (effective.stream().noneMatch(tier -> type.equals(tier.getFeeType()))) {
if (!"USD".equals(rule.getCurrency()))
throw new BizException(ResultCode.CONFLICT, "非美元仓租配置缺少计租类型,请先补全配置");
effective.addAll(tierEntities(rule.getId(), customerId,
defaultTiers().stream().filter(tier -> type.equals(tier.feeType())).toList()));
}
}
var normalized = normalize(effective.stream().map(tier -> new OverseasStorageFeeDtos.TierRequest(
tier.getFeeType(), tier.getBillingUnit(), tier.getStartDay(), tier.getEndDay(), tier.getUnitPrice())).toList());
for (String type : List.of("STORAGE", "RETURN_STORAGE")) {
var group = normalized.stream().filter(tier -> type.equals(tier.feeType())).toList();
if (group.get(group.size() - 1).endDay() != null)
throw new BizException(ResultCode.CONFLICT, "客户仓租阶梯必须包含无上限尾档");
}
effective.sort(Comparator.comparingInt((OverseasStorageFeeTierEntity tier) ->
"STORAGE".equals(tier.getFeeType()) ? 0 : 1).thenComparingInt(OverseasStorageFeeTierEntity::getStartDay));
return new OverseasStorageFeeDtos.PricingView(String.valueOf(rule.getId()), customerId, rule.getVersion(),
rule.getCurrency(), pricingViews(effective), "CUSTOMER");
}
/** 转换为稳定顺序的只读计租档位,补齐默认组的档位ID保持为空。 */
private static List<OverseasStorageFeeDtos.TierView> pricingViews(List<OverseasStorageFeeTierEntity> tiers) {
var result = new ArrayList<OverseasStorageFeeDtos.TierView>();
for (var tier : tiers) result.add(new OverseasStorageFeeDtos.TierView(tier.getId(), tier.getStartDay(),
tier.getEndDay(), tier.getUnitPrice(), result.size() + 1, tier.getFeeType(), tier.getBillingUnit()));
return List.copyOf(result);
}
public PageResult<OverseasStorageFeeDtos.View> page(OverseasStorageFeeDtos.PageQuery query) {
PageResult<OverseasStorageFeeRuleEntity> source = repository.page(query);
Map<Long, List<OverseasStorageFeeTierEntity>> tiers = repository.findTiers(
@@ -59,6 +59,7 @@ BEGIN
END$$
DELIMITER ;
CALL supplier_add_column_if_missing('license_file_id', "VARCHAR(128) NULL COMMENT 'File服务营业执照文件ID' AFTER business_license_no");
CALL supplier_add_column_if_missing('trucking_scope', "VARCHAR(16) NULL COMMENT '拖车类型:DOMESTIC国内拖车、OVERSEAS海外车队;非拖车为空' AFTER category");
CALL supplier_add_column_if_missing('created_by', "BIGINT UNSIGNED NULL COMMENT '创建人ID'");
CALL supplier_add_column_if_missing('created_at', "DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间'");
CALL supplier_add_column_if_missing('updated_by', "BIGINT UNSIGNED NULL COMMENT '最后修改人ID'");
@@ -3,6 +3,31 @@ info:
title: Warehouse catalog and receiving options
version: 1.0.0
paths:
/internal/warehouse/v1/customer-overseas-storage-fees/{customerId}/pricing:
get:
summary: 读取客户海外仓自动计租配置
description: 内部只读接口,不经公网网关。按客户读取同一REPEATABLE_READ快照中的配置及阶梯,不创建或修改配置。无配置返回完整USD默认中转及退货两表;USD配置漏组仅补齐缺失组。非USD配置漏组、已配置组不连续或缺少无上限尾档时明确失败,不能因读取异常降级为默认价。完整客户配置保留原币种,由调用方按确认汇率换算。
security:
- internalServiceToken: []
parameters:
- name: customerId
in: path
required: true
schema: {type: string, pattern: '^[1-9][0-9]*$'}
responses:
'200':
description: 统一业务响应,code为200时返回完整计租快照;客户ID或配置非法返回业务失败code,不返回默认价。
content:
application/json:
schema:
type: object
required: [code]
properties:
code: {type: string}
message: {type: string}
data:
$ref: '#/components/schemas/OverseasStorageFeePricingView'
'403': {description: 内部服务凭据缺失或错误}
/internal/warehouses/platform-metadata/batch:
post:
summary: 内部批量读取公共仓库历史展示资料
@@ -94,6 +119,31 @@ paths:
'200': {description: 返回仓库 ID、编码、名称、类型和国家编码}
components:
schemas:
OverseasStorageFeePricingView:
type: object
required: [customerId, currency, tiers, source]
properties:
configId: {type: string, nullable: true, description: 客户配置ID,无配置时为空}
customerId: {type: string, pattern: '^[1-9][0-9]*$'}
version: {type: integer, nullable: true, minimum: 0, description: 客户配置版本,无配置时为空}
currency: {type: string, pattern: '^[A-Z]{3}$'}
source: {type: string, enum: [CUSTOMER, DEFAULT], description: CUSTOMER包含仅补全缺失默认组的USD客户配置}
tiers:
type: array
minItems: 2
items:
$ref: '#/components/schemas/OverseasStorageFeePricingTier'
OverseasStorageFeePricingTier:
type: object
required: [startDay, unitPrice, sortNo, feeType, billingUnit]
properties:
id: {type: string, nullable: true, description: 客户阶梯ID,默认或补齐档位为空}
startDay: {type: integer, minimum: 1}
endDay: {type: integer, nullable: true, minimum: 1, description: 含当日,空值为无上限尾档}
unitPrice: {type: number, minimum: 0, description: 每CBM每天或每托每天的单价,币种取快照currency}
sortNo: {type: integer, minimum: 1}
feeType: {type: string, enum: [STORAGE, RETURN_STORAGE], description: STORAGE中转仓租,RETURN_STORAGE退货仓租}
billingUnit: {type: string, enum: [CBM, PALLET]}
ReceivingWarehouseOption:
type: object
required: [id, customerId, warehouseCode, warehouseName, warehouseType, status]
@@ -0,0 +1,28 @@
package com.baiyue.warehouse.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.OverseasStorageFeeDtos;
import com.baiyue.warehouse.service.OverseasStorageFeeService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.bind.annotation.GetMapping;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class InternalOverseasStoragePricingControllerTest {
@Test void pricingRequiresInternalTokenBeforeReadingCustomerAndReturnsSnapshot() throws Exception {
var service=mock(OverseasStorageFeeService.class);
var controller=new InternalOverseasStorageFeeController();
ReflectionTestUtils.setField(controller,"service",service);
ReflectionTestUtils.setField(controller,"internalToken","internal-test"); controller.init();
assertThatThrownBy(() -> controller.pricing(null,17)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> controller.pricing("wrong",17)).isInstanceOf(BizException.class);
verifyNoInteractions(service);
var expected=new OverseasStorageFeeDtos.PricingView(null,17L,null,"USD",List.of(),"DEFAULT");
when(service.pricing(17)).thenReturn(expected);
assertThat(controller.pricing("internal-test",17).data()).isSameAs(expected);
assertThat(InternalOverseasStorageFeeController.class.getMethod("pricing",String.class,long.class)
.getAnnotation(GetMapping.class).value()).containsExactly("/{customerId}/pricing");
}
}
@@ -0,0 +1,140 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.entity.OverseasStorageFeeRuleEntity;
import com.baiyue.warehouse.entity.OverseasStorageFeeTierEntity;
import com.baiyue.warehouse.repository.OverseasStorageFeeRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class OverseasStoragePricingTest {
private final OverseasStorageFeeRepository repository = mock(OverseasStorageFeeRepository.class);
private final OverseasStorageFeeService service = new OverseasStorageFeeService();
@BeforeEach void setup() { ReflectionTestUtils.setField(service, "repository", repository); }
@Test void absentConfigurationReturnsBothConfirmedDefaultTablesWithoutWriting() {
var pricing = service.pricing(17);
assertThat(pricing.source()).isEqualTo("DEFAULT");
assertThat(pricing.customerId()).isEqualTo(17);
assertThat(pricing.configId()).isNull(); assertThat(pricing.version()).isNull();
assertThat(pricing.currency()).isEqualTo("USD");
assertThat(pricing.tiers()).hasSize(9).allSatisfy(tier -> assertThat(tier.id()).isNull());
assertThat(pricing.tiers().stream().filter(tier -> "STORAGE".equals(tier.feeType())).toList())
.extracting(tier -> tier.unitPrice().stripTrailingZeros()).containsExactly(
BigDecimal.ZERO, new BigDecimal("0.8"), BigDecimal.ONE, new BigDecimal("1.5"), new BigDecimal("2"));
assertThat(pricing.tiers().stream().filter(tier -> "RETURN_STORAGE".equals(tier.feeType())).toList())
.extracting(tier -> tier.unitPrice().stripTrailingZeros()).containsExactly(
new BigDecimal("1.5"), new BigDecimal("2"), new BigDecimal("3"), new BigDecimal("4"));
assertThat(pricing.tiers().get(0).startDay()).isEqualTo(1);
assertThat(pricing.tiers().get(0).endDay()).isEqualTo(7);
verify(repository).findByCustomer(17); verifyNoMoreInteractions(repository);
}
@Test void configuredCustomerKeepsVersionCurrencyAndPricesInSortedOrder() {
configured("CNY", List.of(tier("RETURN_STORAGE",1,null,"13"),tier("STORAGE",8,null,"6"),tier("STORAGE",1,7,"0")));
var pricing = service.pricing(17);
assertThat(pricing.source()).isEqualTo("CUSTOMER");
assertThat(pricing.configId()).isEqualTo("51"); assertThat(pricing.version()).isEqualTo(3);
assertThat(pricing.currency()).isEqualTo("CNY");
assertThat(pricing.tiers()).extracting(tier -> tier.unitPrice().toPlainString()).containsExactly("0","6","13");
verify(repository).findByCustomer(17); verify(repository).findTiers(List.of(51L)); verifyNoMoreInteractions(repository);
}
@ParameterizedTest @ValueSource(strings={"STORAGE","RETURN_STORAGE"})
void dollarConfigurationCompletesOnlyMissingGroup(String existingType) {
configured("USD",List.of(tier(existingType,1,null,"9")));
var pricing=service.pricing(17);
assertThat(pricing.tiers().stream().filter(tier -> existingType.equals(tier.feeType())).toList())
.singleElement().satisfies(tier -> assertThat(tier.unitPrice()).isEqualByComparingTo("9"));
assertThat(pricing.tiers().stream().filter(tier -> !existingType.equals(tier.feeType())).toList())
.hasSize("STORAGE".equals(existingType)?4:5).allSatisfy(tier -> assertThat(tier.id()).isNull());
}
@Test void foreignCurrencyMissingGroupCannotMixDollarDefaults() {
configured("CNY",List.of(tier("STORAGE",1,null,"9")));
assertThatThrownBy(() -> service.pricing(17)).isInstanceOf(BizException.class).hasMessageContaining("非美元");
}
@Test void finiteLastBandRefusesBillingInsteadOfZeroOrDefaultPrice() {
configured("USD",List.of(tier("STORAGE",1,30,"9")));
assertThatThrownBy(() -> service.pricing(17)).isInstanceOf(BizException.class).hasMessageContaining("无上限");
}
@Test void gapAndUnknownUnitRefuseBilling() {
configured("USD",List.of(tier("STORAGE",1,7,"0"),tier("STORAGE",9,null,"9")));
assertThatThrownBy(() -> service.pricing(17)).isInstanceOf(BizException.class).hasMessageContaining("连续");
var invalid=tier("STORAGE",1,null,"9"); invalid.setBillingUnit("PALLET");
when(repository.findTiers(List.of(51L))).thenReturn(List.of(invalid));
assertThatThrownBy(() -> service.pricing(17)).isInstanceOf(BizException.class).hasMessageContaining("不匹配");
}
@Test void wrongCustomerTierRefusesBilling() {
var wrong=tier("STORAGE",1,null,"9"); wrong.setTenantId(18L);
configured("USD",List.of(wrong));
assertThatThrownBy(() -> service.pricing(17)).isInstanceOf(BizException.class).hasMessageContaining("归属");
}
@Test void configurationReadFailurePropagatesWithoutDefaultFallback() {
when(repository.findByCustomer(17)).thenThrow(new IllegalStateException("database unavailable"));
assertThatThrownBy(() -> service.pricing(17)).isInstanceOf(IllegalStateException.class);
}
@ParameterizedTest @ValueSource(longs={0,-1})
void invalidCustomerNeverQueries(long customer) {
assertThatThrownBy(() -> service.pricing(customer)).isInstanceOf(BizException.class);
verifyNoInteractions(repository);
}
@Test void pricingRequiresReadOnlyRepeatableSnapshot() throws Exception {
var transaction=OverseasStorageFeeService.class.getMethod("pricing",long.class).getAnnotation(Transactional.class);
assertThat(transaction.readOnly()).isTrue(); assertThat(transaction.isolation()).isEqualTo(Isolation.REPEATABLE_READ);
}
@Test void ruleAndTierReadsShareOneActiveRepeatableReadTransactionConnection() {
var data=new org.springframework.jdbc.datasource.DriverManagerDataSource(
"jdbc:h2:mem:pricing-snapshot-"+java.util.UUID.randomUUID(),"sa","");
var transactionManager=new org.springframework.jdbc.datasource.DataSourceTransactionManager(data);
var proxy=new org.springframework.aop.framework.ProxyFactory(service); proxy.setProxyTargetClass(true);
proxy.addAdvice(new org.springframework.transaction.interceptor.TransactionInterceptor(transactionManager,
new org.springframework.transaction.annotation.AnnotationTransactionAttributeSource()));
var transactional=(OverseasStorageFeeService)proxy.getProxy();
var connection=new java.util.concurrent.atomic.AtomicReference<java.sql.Connection>();
when(repository.findByCustomer(17)).thenAnswer(invocation -> {
assertThat(org.springframework.transaction.support.TransactionSynchronizationManager.isActualTransactionActive()).isTrue();
assertThat(org.springframework.transaction.support.TransactionSynchronizationManager.isCurrentTransactionReadOnly()).isTrue();
var current=org.springframework.jdbc.datasource.DataSourceUtils.getConnection(data); connection.set(current);
assertThat(current.getTransactionIsolation()).isEqualTo(java.sql.Connection.TRANSACTION_REPEATABLE_READ);
var rule=new OverseasStorageFeeRuleEntity(); rule.setId(51L); rule.setTenantId(17L);
rule.setCurrency("USD"); rule.setVersion(3); return rule;
});
when(repository.findTiers(List.of(51L))).thenAnswer(invocation -> {
assertThat(org.springframework.jdbc.datasource.DataSourceUtils.getConnection(data)).isSameAs(connection.get());
assertThat(org.springframework.transaction.support.TransactionSynchronizationManager.isActualTransactionActive()).isTrue();
return List.of(tier("STORAGE",1,null,"9"),tier("RETURN_STORAGE",1,null,"8"));
});
assertThat(transactional.pricing(17).version()).isEqualTo(3);
assertThat(org.springframework.transaction.support.TransactionSynchronizationManager.isActualTransactionActive()).isFalse();
}
private void configured(String currency,List<OverseasStorageFeeTierEntity> tiers) {
var rule=new OverseasStorageFeeRuleEntity(); rule.setId(51L); rule.setTenantId(17L);
rule.setCurrency(currency); rule.setVersion(3);
when(repository.findByCustomer(17)).thenReturn(rule); when(repository.findTiers(List.of(51L))).thenReturn(tiers);
}
private OverseasStorageFeeTierEntity tier(String type,int start,Integer end,String price) {
var tier=new OverseasStorageFeeTierEntity(); tier.setId(71L); tier.setRuleId(51L); tier.setTenantId(17L);
tier.setFeeType(type); tier.setBillingUnit("STORAGE".equals(type)?"CBM":"PALLET");
tier.setStartDay(start); tier.setEndDay(end); tier.setUnitPrice(new BigDecimal(price)); return tier;
}
}