费用提交

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]