This commit is contained in:
2026-08-08 13:32:20 +08:00
parent 96788e10de
commit 3203adb5a7
73 changed files with 44331 additions and 26 deletions
@@ -0,0 +1,37 @@
package com.baiyue.warehouse.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.OverseasStorageFeeDtos;
import com.baiyue.warehouse.service.OverseasStorageFeeService;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@RestController
@RequestMapping("/internal/warehouse/v1/customer-overseas-storage-fees")
public class InternalOverseasStorageFeeController {
@Resource private OverseasStorageFeeService service;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
private byte[] expected;
@PostConstruct void init() { expected = internalToken.getBytes(StandardCharsets.UTF_8); }
@PostMapping("/defaults")
public ApiResponse<OverseasStorageFeeDtos.DefaultInitializationResponse> defaults(
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@RequestBody OverseasStorageFeeDtos.DefaultInitializationRequest request) {
verify(token); return ApiResponse.success(service.initializeDefaults(request));
}
@DeleteMapping("/defaults")
public ApiResponse<Void> compensate(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@RequestBody OverseasStorageFeeDtos.CompensationRequest request) {
verify(token); service.compensateDefaults(request); return ApiResponse.success(null);
}
private void verify(String token) {
if (!MessageDigest.isEqual(expected, token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8)))
throw new BizException(ResultCode.FORBIDDEN, "无效的内部服务凭据");
}
}
@@ -0,0 +1,38 @@
package com.baiyue.warehouse.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.warehouse.dto.OverseasStorageFeeDtos;
import com.baiyue.warehouse.service.OverseasStorageFeeService;
import com.baiyue.common.security.AuthenticatedUser;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/platform/v1/iam/tenants/{tenantId}/overseas-storage-fees")
public class PlatformCustomerOverseasStorageFeeController {
@Resource private OverseasStorageFeeService service;
@GetMapping
@PreAuthorize("hasAuthority('platform:customer:view')")
public ApiResponse<OverseasStorageFeeDtos.View> get(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long tenantId) { return ApiResponse.success(service.detailByTenant(tenantId)); }
@PutMapping
@PreAuthorize("hasAuthority('platform:customer:edit')")
public ApiResponse<Void> put(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId,
@Valid @RequestBody OverseasStorageFeeDtos.UpdateRequest request) {
if (request.customerId() != tenantId) {
throw new com.baiyue.common.core.exception.BizException(
com.baiyue.common.core.api.ResultCode.VALIDATION_FAILED, "客户ID与路径不一致");
}
if (request.tiers() == null || request.tiers().size() != 9
|| request.tiers().stream().filter(t -> "STORAGE".equals(t.feeType())).count() != 5
|| request.tiers().stream().filter(t -> "RETURN_STORAGE".equals(t.feeType())).count() != 4) {
throw new com.baiyue.common.core.exception.BizException(
com.baiyue.common.core.api.ResultCode.VALIDATION_FAILED, "客户费用必须包含仓租5档和退货仓储费4档");
}
OverseasStorageFeeDtos.View view = service.detailByTenant(tenantId);
service.update(view.id(), request); return ApiResponse.success(null);
}
}
@@ -41,11 +41,16 @@ public final class OverseasStorageFeeDtos {
@Schema(name = "OverseasStorageFeeTierRequest", description = "仓储费阶梯")
public record TierRequest(
@NotBlank @Pattern(regexp = "^[A-Z_]+$") String feeType,
@NotBlank @Pattern(regexp = "^[A-Z]+$") String billingUnit,
@NotNull @Min(1) @Max(36500) Integer startDay,
@NotNull @Min(1) @Max(36500) Integer endDay,
@NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 12, fraction = 6)
@Min(1) @Max(36500) Integer endDay,
@NotNull @DecimalMin(value = "0", inclusive = true) @Digits(integer = 12, fraction = 6)
BigDecimal unitPrice
) {
public TierRequest(Integer startDay, Integer endDay, BigDecimal unitPrice) {
this("STORAGE", "CBM", startDay, endDay, unitPrice);
}
}
@Schema(name = "OverseasStorageFeeCreateRequest", description = "新增海外仓阶梯费用")
@@ -68,7 +73,11 @@ public final class OverseasStorageFeeDtos {
}
@Schema(name = "OverseasStorageFeeTierView", description = "仓储费阶梯明细")
public record TierView(Long id, Integer startDay, Integer endDay, BigDecimal unitPrice, Integer sortNo) {
public record TierView(Long id, Integer startDay, Integer endDay, BigDecimal unitPrice, Integer sortNo,
String feeType, String billingUnit) {
public TierView(Long id, Integer startDay, Integer endDay, BigDecimal unitPrice, Integer sortNo) {
this(id, startDay, endDay, unitPrice, sortNo, "STORAGE", "CBM");
}
}
@Schema(name = "OverseasStorageFeeView", description = "海外仓阶梯费用详情")
@@ -83,4 +92,9 @@ public final class OverseasStorageFeeDtos {
LocalDateTime updatedAt
) {
}
public record DefaultInitializationRequest(@NotNull @Positive Long tenantId, @NotBlank String initializationKey,
Long operatorId, String customerName) {}
public record DefaultInitializationResponse(String configId, boolean createdByThisInitialization) {}
public record CompensationRequest(@NotNull @Positive Long tenantId, @NotBlank String initializationKey) {}
}
@@ -17,4 +17,5 @@ public class OverseasStorageFeeRuleEntity extends BaseEntity {
private Long tenantId;
private String customerName;
private String currency;
private String initializationKey;
}
@@ -20,6 +20,8 @@ public class OverseasStorageFeeTierEntity extends BaseEntity {
private Long ruleId;
private Integer startDay;
private Integer endDay;
private String feeType;
private String billingUnit;
private BigDecimal unitPrice;
private Integer sortNo;
}
@@ -38,6 +38,11 @@ public class OverseasStorageFeeRepository {
public OverseasStorageFeeRuleEntity find(long id) {
return ruleMapper.selectById(id);
}
public OverseasStorageFeeRuleEntity findByTenantAndKey(long tenantId, String key) {
return ruleMapper.selectOne(new LambdaQueryWrapper<OverseasStorageFeeRuleEntity>()
.eq(OverseasStorageFeeRuleEntity::getTenantId, tenantId)
.eq(OverseasStorageFeeRuleEntity::getInitializationKey, key));
}
public boolean existsCustomer(long customerId, Long excludedId) {
return ruleMapper.selectCount(new LambdaQueryWrapper<OverseasStorageFeeRuleEntity>()
@@ -18,6 +18,7 @@ import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.math.BigDecimal;
/** 海外仓阶梯费用管理服务。 */
@Service
@@ -40,6 +41,35 @@ public class OverseasStorageFeeService {
return view(rule, repository.findTiers(List.of(id)));
}
public OverseasStorageFeeDtos.View detailByTenant(long tenantId) {
OverseasStorageFeeRuleEntity rule = repository.page(new OverseasStorageFeeDtos.PageQuery(1, 1, tenantId, null))
.records().stream().findFirst().orElseThrow(() -> new BizException(ResultCode.NOT_FOUND, "海外仓费用不存在"));
return detail(rule.getId());
}
@Transactional
public OverseasStorageFeeDtos.DefaultInitializationResponse initializeDefaults(
OverseasStorageFeeDtos.DefaultInitializationRequest request) {
OverseasStorageFeeRuleEntity existing = repository.findByTenantAndKey(request.tenantId(), request.initializationKey());
if (existing != null && !Boolean.TRUE.equals(existing.getDeleted())) {
return new OverseasStorageFeeDtos.DefaultInitializationResponse(String.valueOf(existing.getId()), false);
}
OverseasStorageFeeRuleEntity rule = new OverseasStorageFeeRuleEntity();
rule.setInitializationKey(request.initializationKey());
copy(rule, request.tenantId(), request.customerName() == null ? "" : request.customerName(), "USD");
repository.insertRule(rule);
repository.insertTiers(tierEntities(rule.getId(), request.tenantId(), defaultTiers()));
return new OverseasStorageFeeDtos.DefaultInitializationResponse(String.valueOf(rule.getId()), true);
}
@Transactional
public void compensateDefaults(OverseasStorageFeeDtos.CompensationRequest request) {
OverseasStorageFeeRuleEntity rule = repository.findByTenantAndKey(request.tenantId(), request.initializationKey());
if (rule == null || rule.getVersion() != 0 || !isDefault(rule)) return;
repository.deleteTiers(rule.getId());
repository.deleteRule(rule.getId(), 0);
}
@Transactional
@BusinessAudit(event = "OVERSEAS_STORAGE_FEE_CREATE", resourceType = "OVERSEAS_STORAGE_FEE",
description = "新增海外仓阶梯费用", captureBefore = false)
@@ -110,19 +140,31 @@ public class OverseasStorageFeeService {
throw new BizException(ResultCode.VALIDATION_FAILED, "阶梯费用明细必须为1至100组");
}
if (source.stream().anyMatch(tier -> tier == null || tier.startDay() == null
|| tier.endDay() == null || tier.unitPrice() == null
|| tier.unitPrice().signum() <= 0)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "阶梯天数和仓储费标准必须为有效正数");
|| tier.unitPrice() == null || tier.unitPrice().signum() < 0)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "阶梯天数和仓储费标准必须有效");
}
if (source.stream().anyMatch(t -> !("STORAGE".equals(t.feeType()) && "CBM".equals(t.billingUnit()))
&& !("RETURN_STORAGE".equals(t.feeType()) && "PALLET".equals(t.billingUnit())))) {
throw new BizException(ResultCode.VALIDATION_FAILED, "费用类型和计费单位不匹配");
}
List<OverseasStorageFeeDtos.TierRequest> tiers = new ArrayList<>(source);
tiers.sort(Comparator.comparingInt(OverseasStorageFeeDtos.TierRequest::startDay));
int expectedStart = 1;
for (OverseasStorageFeeDtos.TierRequest tier : tiers) {
if (tier.startDay() != expectedStart || tier.endDay() < tier.startDay()) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"阶梯必须从第1天开始,且区间连续、不能重叠或倒置");
tiers.sort(Comparator.comparing(OverseasStorageFeeDtos.TierRequest::feeType)
.thenComparingInt(OverseasStorageFeeDtos.TierRequest::startDay));
for (String feeType : List.of("STORAGE", "RETURN_STORAGE")) {
List<OverseasStorageFeeDtos.TierRequest> group = tiers.stream().filter(t -> feeType.equals(t.feeType())).toList();
if (source.size() == 9 && (("STORAGE".equals(feeType) && group.size() != 5)
|| ("RETURN_STORAGE".equals(feeType) && group.size() != 4)))
throw new BizException(ResultCode.VALIDATION_FAILED, "仓租必须5档,退货仓储费必须4档");
int expectedStart = 1;
for (int i = 0; i < group.size(); i++) {
OverseasStorageFeeDtos.TierRequest tier = group.get(i);
if (tier.startDay() != expectedStart || (tier.endDay() != null && tier.endDay() < tier.startDay()))
throw new BizException(ResultCode.VALIDATION_FAILED, "阶梯必须从第1天开始且连续");
if (tier.endDay() == null && i != group.size() - 1)
throw new BizException(ResultCode.VALIDATION_FAILED, "无上限阶梯必须为最后一档");
if (tier.endDay() == null) break;
expectedStart = tier.endDay() + 1;
}
expectedStart = tier.endDay() + 1;
}
return List.copyOf(tiers);
}
@@ -144,6 +186,8 @@ public class OverseasStorageFeeService {
tier.setRuleId(ruleId);
tier.setStartDay(request.startDay());
tier.setEndDay(request.endDay());
tier.setFeeType(request.feeType());
tier.setBillingUnit(request.billingUnit());
tier.setUnitPrice(request.unitPrice());
tier.setSortNo(index + 1);
result.add(tier);
@@ -155,7 +199,35 @@ public class OverseasStorageFeeService {
List<OverseasStorageFeeTierEntity> tiers) {
return new OverseasStorageFeeDtos.View(rule.getId(), rule.getTenantId(), rule.getCustomerName(),
rule.getCurrency(), tiers.stream().map(tier -> new OverseasStorageFeeDtos.TierView(
tier.getId(), tier.getStartDay(), tier.getEndDay(), tier.getUnitPrice(), tier.getSortNo()))
tier.getId(), tier.getStartDay(), tier.getEndDay(), tier.getUnitPrice(), tier.getSortNo(),
tier.getFeeType(), tier.getBillingUnit()))
.toList(), rule.getVersion(), rule.getCreatedAt(), rule.getUpdatedAt());
}
private static List<OverseasStorageFeeDtos.TierRequest> defaultTiers() {
return List.of(
new OverseasStorageFeeDtos.TierRequest("STORAGE", "CBM", 1, 7, BigDecimal.ZERO),
new OverseasStorageFeeDtos.TierRequest("STORAGE", "CBM", 8, 30, new BigDecimal("0.8")),
new OverseasStorageFeeDtos.TierRequest("STORAGE", "CBM", 31, 60, BigDecimal.ONE),
new OverseasStorageFeeDtos.TierRequest("STORAGE", "CBM", 61, 90, new BigDecimal("1.5")),
new OverseasStorageFeeDtos.TierRequest("STORAGE", "CBM", 91, null, new BigDecimal("2")),
new OverseasStorageFeeDtos.TierRequest("RETURN_STORAGE", "PALLET", 1, 30, new BigDecimal("1.5")),
new OverseasStorageFeeDtos.TierRequest("RETURN_STORAGE", "PALLET", 31, 60, new BigDecimal("2")),
new OverseasStorageFeeDtos.TierRequest("RETURN_STORAGE", "PALLET", 61, 90, new BigDecimal("3")),
new OverseasStorageFeeDtos.TierRequest("RETURN_STORAGE", "PALLET", 91, null, new BigDecimal("4")));
}
private boolean isDefault(OverseasStorageFeeRuleEntity rule) {
if (!"USD".equals(rule.getCurrency())) return false;
List<OverseasStorageFeeTierEntity> actual = repository.findTiers(List.of(rule.getId()));
List<OverseasStorageFeeDtos.TierRequest> expected = defaultTiers();
if (actual.size() != expected.size()) return false;
for (int i = 0; i < expected.size(); i++) {
var e = expected.get(i); var a = actual.get(i);
if (!e.feeType().equals(a.getFeeType()) || !e.billingUnit().equals(a.getBillingUnit())
|| !e.startDay().equals(a.getStartDay()) || !java.util.Objects.equals(e.endDay(), a.getEndDay())
|| e.unitPrice().compareTo(a.getUnitPrice()) != 0) return false;
}
return true;
}
}
@@ -48,6 +48,22 @@ CALL add_column_if_missing('wh_warehouse', 'updated_by', 'BIGINT UNSIGNED NULL')
CALL add_column_if_missing('wh_warehouse', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)');
CALL add_column_if_missing('wh_warehouse', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0');
CALL add_column_if_missing('wh_warehouse', 'version', 'INT NOT NULL DEFAULT 0');
CALL add_column_if_missing('wh_overseas_storage_fee_rule', 'initialization_key', 'VARCHAR(191) NULL');
CALL add_column_if_missing('wh_overseas_storage_fee_tier', 'fee_type', 'VARCHAR(32) NOT NULL DEFAULT ''STORAGE''');
CALL add_column_if_missing('wh_overseas_storage_fee_tier', 'billing_unit', 'VARCHAR(16) NOT NULL DEFAULT ''CBM''');
-- 海外仓费用旧表兼容:允许免费档和“第 N 天以上”,并同步检查约束。
ALTER TABLE wh_overseas_storage_fee_tier
MODIFY COLUMN end_day SMALLINT UNSIGNED NULL COMMENT '结束天,含边界;NULL表示以上',
MODIFY COLUMN unit_price DECIMAL(18,6) NOT NULL COMMENT '仓储费标准,可为0';
ALTER TABLE wh_overseas_storage_fee_tier
DROP CHECK chk_overseas_fee_tier_days,
DROP CHECK chk_overseas_fee_tier_price,
ADD CONSTRAINT chk_overseas_fee_tier_days CHECK (start_day >= 1 AND (end_day IS NULL OR end_day >= start_day)),
ADD CONSTRAINT chk_overseas_fee_tier_price CHECK (unit_price >= 0);
ALTER TABLE wh_overseas_storage_fee_tier
DROP INDEX uk_overseas_fee_tier_start,
ADD UNIQUE KEY uk_overseas_fee_tier_start (rule_id, fee_type, active_start_day);
CALL add_column_if_missing('wh_warehouse_address', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id');
CALL add_column_if_missing('wh_warehouse_address', 'warehouse_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER tenant_id');
@@ -75,3 +75,27 @@ CREATE TABLE IF NOT EXISTS wh_service_capability (
) ENGINE=InnoDB COMMENT='仓库服务能力表:声明仓库可提供的收货、仓储、拣包、退货等能力及截单时间';
-- 不使用跨服务数据库外键;仓库聚合内的归属由服务代码通过 tenant_id 与 warehouse_id 共同校验。
CREATE TABLE IF NOT EXISTS wh_overseas_storage_fee_rule (
id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL, customer_name VARCHAR(128) NOT NULL,
currency CHAR(3) NOT NULL DEFAULT 'USD', initialization_key VARCHAR(191) NULL,
created_by BIGINT UNSIGNED NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT UNSIGNED NULL, updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT(1) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0,
active_customer_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN deleted=0 THEN tenant_id ELSE NULL END) STORED,
PRIMARY KEY(id), UNIQUE KEY uk_overseas_fee_active_customer(active_customer_id), KEY idx_overseas_fee_page(deleted,updated_at,id)
) ENGINE=InnoDB COMMENT='客户海外仓费用规则';
CREATE TABLE IF NOT EXISTS wh_overseas_storage_fee_tier (
id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL, rule_id BIGINT UNSIGNED NOT NULL,
start_day SMALLINT UNSIGNED NOT NULL, end_day SMALLINT UNSIGNED NULL,
fee_type VARCHAR(32) NOT NULL DEFAULT 'STORAGE', billing_unit VARCHAR(16) NOT NULL DEFAULT 'CBM',
unit_price DECIMAL(18,6) NOT NULL, sort_no SMALLINT UNSIGNED NOT NULL,
created_by BIGINT UNSIGNED NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT UNSIGNED NULL, updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT(1) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0,
active_start_day SMALLINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN deleted=0 THEN start_day ELSE NULL END) STORED,
PRIMARY KEY(id), UNIQUE KEY uk_overseas_fee_tier_start(rule_id,fee_type,active_start_day), KEY idx_overseas_fee_tier_rule(tenant_id,rule_id,deleted,sort_no,id),
CONSTRAINT chk_overseas_fee_tier_days CHECK (start_day>=1 AND (end_day IS NULL OR end_day>=start_day)),
CONSTRAINT chk_overseas_fee_tier_price CHECK (unit_price>=0)
) ENGINE=InnoDB COMMENT='客户海外仓费用阶梯';
@@ -94,6 +94,19 @@ class OverseasStorageFeeServiceTest {
verify(repository, never()).updateRule(any());
}
@Test
void initializesNineDefaultTiersIdempotently() {
doAnswer(invocation -> { OverseasStorageFeeRuleEntity rule = invocation.getArgument(0); rule.setId(77L); return null; })
.when(repository).insertRule(any());
var request = new OverseasStorageFeeDtos.DefaultInitializationRequest(10001L, "fee:10001", 1L, "客户");
var first = service.initializeDefaults(request);
assertThat(first.createdByThisInitialization()).isTrue();
verify(repository).insertTiers(org.mockito.ArgumentMatchers.argThat(list -> list.size() == 9));
when(repository.findByTenantAndKey(10001L, "fee:10001")).thenReturn(rule());
var second = service.initializeDefaults(request);
assertThat(second.createdByThisInitialization()).isFalse();
}
private static OverseasStorageFeeDtos.TierRequest tier(int start, int end, String price) {
return new OverseasStorageFeeDtos.TierRequest(start, end, new BigDecimal(price));
}