feat(firstleg): support customer price books

This commit is contained in:
2026-09-08 17:42:20 +08:00
parent 60a41ec3ea
commit b0fd797cd2
21 changed files with 145 additions and 59 deletions
@@ -38,9 +38,10 @@ public class PlatformPriceBookController {
@Operation(summary = "查询船期价格表版本")
public ApiResponse<java.util.List<PriceBookDtos.PriceBookVersionSummary>> list(
@AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user,
@RequestParam(defaultValue = "0") long customerId,
@RequestParam(required = false) PriceBookStatus status) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.list(status));
return ApiResponse.success(service.list(customerId, status));
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -48,9 +49,10 @@ public class PlatformPriceBookController {
@BusinessAudit(event = "FBA_PRICE_BOOK_UPLOAD", resourceType = "PRICE_BOOK_VERSION", description = "上传船期价格表并等待确认")
public ApiResponse<PriceBookDtos.PriceBookVersionDetail> upload(
@AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user,
@RequestParam(defaultValue = "0") long customerId,
@RequestPart("file") MultipartFile file) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.upload(user.userId(), file));
return ApiResponse.success(service.upload(user.userId(), customerId, file));
}
@GetMapping("/{versionId}")
@@ -23,6 +23,7 @@ public final class PriceBookDtos {
public record PriceBookVersionSummary(
Long id,
Long customerId,
Integer versionNo,
String originalFileName,
Long sourceFileId,
@@ -10,12 +10,12 @@ import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 船司 FBA 目的仓价格的不可变版本。 */
/** 公共或客户专属的头程报价不可变版本。 */
@Getter
@Setter
@TableName("fl_carrier_fba_rate")
public class CarrierFbaRateEntity extends BaseEntity {
/** 船司价格是平台公共主数据,不绑定任何客户。 */
/** 0 为平台公共报价,正数为客户专属报价作用域。 */
public static final long PLATFORM_SCOPE = 0L;
@TableId(type = IdType.ASSIGN_ID)
@@ -32,6 +32,16 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader {
""")
Long findActiveVersionId();
@Select("""
SELECT id
FROM fl_price_book_version
WHERE tenant_id IN (0, #{customerId}) AND status = 'ACTIVE' AND deleted = 0
ORDER BY CASE WHEN tenant_id = #{customerId} THEN 0 ELSE 1 END,
published_at DESC, version_no DESC
LIMIT 1
""")
Long findEffectiveVersionId(@Param("customerId") long customerId);
@Select("""
SELECT c.price_book_version_id AS priceBookVersionId,
c.channel_code AS channelCode, c.channel_name AS channelName,
@@ -48,7 +58,7 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader {
SELECT 1 FROM fl_carrier_fba_rate r
WHERE r.price_book_version_id = c.price_book_version_id
AND r.channel_code = c.channel_code
AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId}
AND r.origin_warehouse_id = #{originWarehouseId}
AND r.status = 'ACTIVE' AND r.deleted = 0
)
ORDER BY c.id
@@ -60,7 +70,7 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader {
SELECT r.channel_code AS channelCode, r.pricing_tax_mode AS pricingTaxMode
FROM fl_carrier_fba_rate r
WHERE r.price_book_version_id = #{versionId}
AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId}
AND r.origin_warehouse_id = #{originWarehouseId}
AND r.status = 'ACTIVE' AND r.deleted = 0
AND r.pricing_tax_mode IS NOT NULL
GROUP BY r.channel_code, r.pricing_tax_mode
@@ -83,7 +93,7 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader {
r.effective_from AS effectiveFrom, r.effective_to AS effectiveTo
FROM fl_carrier_fba_rate r
WHERE r.price_book_version_id = #{versionId}
AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId}
AND r.origin_warehouse_id = #{originWarehouseId}
AND r.channel_code = #{channelCode} AND r.destination_type = 'FBA'
AND UPPER(r.destination_code) = #{destinationCode}
AND r.pricing_tax_mode = #{pricingTaxMode}
@@ -109,7 +119,7 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader {
r.effective_from AS effectiveFrom, r.effective_to AS effectiveTo
FROM fl_carrier_fba_rate r
WHERE r.price_book_version_id = #{versionId}
AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId}
AND r.origin_warehouse_id = #{originWarehouseId}
AND r.channel_code = #{channelCode} AND r.destination_type = 'OVERSEAS'
AND r.destination_warehouse_id = #{destinationWarehouseId}
AND r.pricing_tax_mode = #{pricingTaxMode}
@@ -135,7 +145,7 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader {
r.effective_from AS effectiveFrom, r.effective_to AS effectiveTo
FROM fl_carrier_fba_rate r
WHERE r.price_book_version_id = #{versionId}
AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId}
AND r.origin_warehouse_id = #{originWarehouseId}
AND r.channel_code = #{channelCode}
AND r.price_model = 'REGION_WEIGHT_TIER'
AND r.region_code = #{regionCode}
@@ -16,6 +16,9 @@ public class PriceBookQueryService implements PriceBookQueryUseCase {
@Resource private PriceBookQueryReader reader;
@Override public Long activeVersionId() { return reader.findActiveVersionId(); }
@Override public Long effectiveVersionId(long customerId) {
return reader.findEffectiveVersionId(customerId);
}
@Override public List<ChannelRow> channels(long versionId, long originWarehouseId) {
return reader.findChannels(versionId, originWarehouseId);
}
@@ -9,6 +9,7 @@ import java.util.List;
public interface PriceBookQueryUseCase {
Long activeVersionId();
Long effectiveVersionId(long customerId);
List<ChannelRow> channels(long versionId, long originWarehouseId);
List<ChannelTaxModeRow> channelTaxModes(long versionId, long originWarehouseId);
List<RateRow> fbaRates(long versionId, long originWarehouseId, String channelCode,
@@ -9,6 +9,7 @@ import java.util.List;
public interface PriceBookQueryReader {
Long findActiveVersionId();
Long findEffectiveVersionId(long customerId);
List<ChannelRow> findChannels(long versionId, long originWarehouseId);
List<ChannelTaxModeRow> findChannelTaxModes(long versionId, long originWarehouseId);
List<RateRow> findFbaRates(long versionId, long originWarehouseId, String channelCode,
@@ -27,10 +27,14 @@ public class ActivePriceBookEstimator {
@Resource private PriceBookWarehouseRegionResolver warehouseRegionResolver;
public Estimate estimate(EstimateRequest request) {
return estimate(0L, request);
}
public Estimate estimate(long customerId, EstimateRequest request) {
if (request == null || request.originWarehouseId() == null || request.originWarehouseId() <= 0) {
throw validation("请选择有效起运仓");
}
Long versionId = repository.activeVersionId();
Long versionId = repository.effectiveVersionId(customerId);
if (versionId == null) {
throw new BizException(ResultCode.NOT_FOUND, "当前没有生效的船期价格版本");
}
@@ -32,39 +32,50 @@ public class PriceBookRepository {
public int insertVersion(PriceBookVersionEntity value) { return versions.insert(value); }
public int updateVersion(PriceBookVersionEntity value) {
return versions.update(value, new LambdaUpdateWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(PriceBookVersionEntity::getTenantId, value.getTenantId())
.eq(PriceBookVersionEntity::getId, value.getId()));
}
public PriceBookVersionEntity findVersion(long id) {
return versions.selectOne(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(PriceBookVersionEntity::getId, id)
.eq(PriceBookVersionEntity::getDeleted, false));
}
public List<PriceBookVersionEntity> listVersions(String status) {
return listVersions(PriceBookVersionEntity.PLATFORM_SCOPE, status);
}
public List<PriceBookVersionEntity> listVersions(long customerId, String status) {
return versions.selectList(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(PriceBookVersionEntity::getTenantId, customerId)
.eq(status != null && !status.isBlank(), PriceBookVersionEntity::getStatus, status)
.eq(PriceBookVersionEntity::getDeleted, false)
.orderByDesc(PriceBookVersionEntity::getCreatedAt));
}
public PriceBookVersionEntity findActiveForUpdate() {
return findActiveForUpdate(PriceBookVersionEntity.PLATFORM_SCOPE);
}
public PriceBookVersionEntity findActiveForUpdate(long customerId) {
return versions.selectOne(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(PriceBookVersionEntity::getTenantId, customerId)
.eq(PriceBookVersionEntity::getStatus, "ACTIVE")
.eq(PriceBookVersionEntity::getDeleted, false)
.last("FOR UPDATE"));
}
public List<PriceBookVersionEntity> listActiveForUpdate() {
return listActiveForUpdate(PriceBookVersionEntity.PLATFORM_SCOPE);
}
public List<PriceBookVersionEntity> listActiveForUpdate(long customerId) {
return versions.selectList(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(PriceBookVersionEntity::getTenantId, customerId)
.eq(PriceBookVersionEntity::getStatus, "ACTIVE")
.eq(PriceBookVersionEntity::getDeleted, false)
.last("FOR UPDATE"));
}
public PriceBookVersionEntity findActive() {
return findActive(PriceBookVersionEntity.PLATFORM_SCOPE);
}
public PriceBookVersionEntity findActive(long customerId) {
return versions.selectOne(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(PriceBookVersionEntity::getTenantId, customerId)
.eq(PriceBookVersionEntity::getStatus, "ACTIVE")
.eq(PriceBookVersionEntity::getDeleted, false));
}
@@ -74,14 +85,20 @@ public class PriceBookRepository {
* ACTIVE version exists (both transactions could otherwise pass the check).
*/
public void lockPlatformVersions() {
lockVersions(PriceBookVersionEntity.PLATFORM_SCOPE);
}
public void lockVersions(long customerId) {
versions.selectList(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(PriceBookVersionEntity::getTenantId, customerId)
.eq(PriceBookVersionEntity::getDeleted, false)
.last("FOR UPDATE"));
}
public int nextVersionNo() {
return nextVersionNo(PriceBookVersionEntity.PLATFORM_SCOPE);
}
public int nextVersionNo(long customerId) {
PriceBookVersionEntity latest = versions.selectOne(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(PriceBookVersionEntity::getTenantId, customerId)
.orderByDesc(PriceBookVersionEntity::getVersionNo).last("LIMIT 1 FOR UPDATE"));
return latest == null ? 1 : latest.getVersionNo() + 1;
}
@@ -117,7 +117,7 @@ public class FbaPricingService {
if (incomplete) throw conflict("外箱规数据或绑定的计算规则不完整,不能生成对客费用");
}
PricingPolicy policy = pricingPolicy(inbound, requestedAt == null ? LocalDateTime.now(clock) : requestedAt);
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(new EstimateRequest(
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(tenantId, new EstimateRequest(
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null,
pricingTaxMode(inbound), policy.chargeMode(),
@@ -32,7 +32,7 @@ public class MerchantPriceBookService {
public List<CurrentChannel> channels(long merchantTenantId, long originWarehouseId) {
requireMerchant(merchantTenantId);
if (originWarehouseId <= 0) throw validation("请选择有效起运仓");
Long versionId = repository.activeVersionId();
Long versionId = repository.effectiveVersionId(merchantTenantId);
if (versionId == null) return List.of();
List<ChannelRow> channels = repository.channels(versionId, originWarehouseId);
if (channels.isEmpty()) return List.of();
@@ -46,7 +46,7 @@ public class MerchantPriceBookService {
&& request.chargeMode() != null && !"KG".equals(request.chargeMode())) {
throw validation("包税报价必须按KG计费");
}
ActivePriceBookEstimator.Estimate estimate = estimator.estimate(request);
ActivePriceBookEstimator.Estimate estimate = estimator.estimate(merchantTenantId, request);
return new EstimateResponse(estimate.priceBookVersionId(), estimate.channelCode(), estimate.priceModel(),
estimate.pricingTaxMode(), estimate.chargeableQuantity(), estimate.chargeUnit(), estimate.unitPrice(),
estimate.amount(), estimate.currency(), DISCLAIMER);
@@ -56,7 +56,7 @@ public class MerchantPriceBookService {
String destinationType, boolean requireInboundCodePrefix) {
requireMerchant(merchantTenantId);
if (originWarehouseId <= 0) throw validation("请选择有效起运仓");
Long versionId = repository.activeVersionId();
Long versionId = repository.effectiveVersionId(merchantTenantId);
String normalized = normalizeChannel(channelCode);
if (versionId == null || !repository.channelAvailable(versionId, originWarehouseId, normalized)) {
throw new BizException(ResultCode.CONFLICT, "所选渠道已下线,请重新选择");
@@ -117,7 +117,7 @@ public class OrderChannelChangePricingService {
request.channelCode(), request.destinationType(), false);
BigDecimal weight = positive(inbound.getActualNetWeightKg(), ELIGIBILITY_WEIGHT);
BigDecimal volume = positive(inbound.getActualVolumeCbm(), ELIGIBILITY_VOLUME);
return estimator.estimate(new EstimateRequest(request.originWarehouseId(), request.channelCode(),
return estimator.estimate(request.tenantId(), new EstimateRequest(request.originWarehouseId(), request.channelCode(),
request.destinationCode(), PricingDestinationType.valueOf(request.destinationType()),
request.destinationWarehouseId(), inbound.getDestinationName(), null,
PricingTaxMode.valueOf(request.pricingTaxMode()), weight, volume));
@@ -72,13 +72,26 @@ public class PlatformPriceBookService {
@Value("${baiyue.security.internal-service-token}") private String internalToken;
public List<PriceBookDtos.PriceBookVersionSummary> list(PriceBookStatus status) {
return list(PriceBookVersionEntity.PLATFORM_SCOPE, status);
}
public List<PriceBookDtos.PriceBookVersionSummary> list(long customerId, PriceBookStatus status) {
requireScope(customerId);
String persistedStatus = status == null ? null : status.name();
return repository.listVersions(persistedStatus).stream().map(this::summary).toList();
return (customerId == PriceBookVersionEntity.PLATFORM_SCOPE
? repository.listVersions(persistedStatus) : repository.listVersions(customerId, persistedStatus))
.stream().map(this::summary).toList();
}
public PriceBookDtos.PriceBookVersionDetail upload(long operatorId, MultipartFile file) {
return upload(operatorId, PriceBookVersionEntity.PLATFORM_SCOPE, file);
}
public PriceBookDtos.PriceBookVersionDetail upload(long operatorId, long customerId, MultipartFile file) {
requireScope(customerId);
requireFile(file);
PriceBookVersionEntity version = transactions.execute(status -> createVersion(operatorId, file.getOriginalFilename()));
PriceBookVersionEntity version = transactions.execute(status -> createVersion(
operatorId, customerId, file.getOriginalFilename()));
if (version == null) throw internal("价格版本创建失败");
Path temp = null;
try {
@@ -123,7 +136,8 @@ public class PlatformPriceBookService {
List<PriceBookChannelEntity> channelRows = repository.listChannels(versionId);
List<PriceBookDtos.ParseIssue> issues = new ArrayList<>(readIssues(version.getParseIssuesJson()));
if (PriceBookStatus.PENDING_CONFIRMATION.name().equals(version.getStatus())) {
PriceBookVersionEntity active = repository.findActive();
PriceBookVersionEntity active = version.getTenantId() == PriceBookVersionEntity.PLATFORM_SCOPE
? repository.findActive() : repository.findActive(version.getTenantId());
if (active != null && !Objects.equals(active.getId(), versionId)) {
Set<String> current = channelRows.stream().map(value -> normalizeChannel(value.getChannelCode())).collect(java.util.stream.Collectors.toSet());
repository.listChannels(active.getId()).stream()
@@ -140,7 +154,9 @@ public class PlatformPriceBookService {
@Transactional(noRollbackFor = BizException.class)
public PriceBookDtos.PriceBookVersionDetail publish(long versionId, long operatorId,
PriceBookConfirmationRequest confirmation) {
repository.lockPlatformVersions();
PriceBookVersionEntity candidate = requireVersion(versionId);
if (candidate.getTenantId() == PriceBookVersionEntity.PLATFORM_SCOPE) repository.lockPlatformVersions();
else repository.lockVersions(candidate.getTenantId());
PriceBookVersionEntity version = requirePendingConfirmation(versionId);
List<PriceBookDtos.ParsedChannel> channels = repository.listChannels(versionId).stream().map(this::channelDto).toList();
List<PriceBookDtos.ParsedRate> rates = repository.listRates(versionId).stream().map(this::rateDto).toList();
@@ -158,7 +174,8 @@ public class PlatformPriceBookService {
version.setChannelCount(channels.size());
version.setRateCount(mapped.rates().size());
LocalDateTime now = LocalDateTime.now();
repository.listActiveForUpdate().stream()
(version.getTenantId() == PriceBookVersionEntity.PLATFORM_SCOPE
? repository.listActiveForUpdate() : repository.listActiveForUpdate(version.getTenantId())).stream()
.filter(active -> !Objects.equals(active.getId(), versionId))
.forEach(active -> repository.supersede(active, now, operatorId));
repository.activate(version, now, operatorId);
@@ -182,10 +199,11 @@ public class PlatformPriceBookService {
}
}
private PriceBookVersionEntity createVersion(long operatorId, String originalFileName) {
private PriceBookVersionEntity createVersion(long operatorId, long customerId, String originalFileName) {
PriceBookVersionEntity value = new PriceBookVersionEntity();
value.setTenantId(PriceBookVersionEntity.PLATFORM_SCOPE);
value.setVersionNo(repository.nextVersionNo());
value.setTenantId(customerId);
value.setVersionNo(customerId == PriceBookVersionEntity.PLATFORM_SCOPE
? repository.nextVersionNo() : repository.nextVersionNo(customerId));
value.setOriginalFileName(safeFileName(originalFileName));
value.setStatus(PriceBookStatus.PENDING_CONFIRMATION.name());
value.setBlockingErrorCount(0); value.setWarningCount(0); value.setChannelCount(0); value.setRateCount(0);
@@ -228,7 +246,7 @@ public class PlatformPriceBookService {
private CarrierFbaRateEntity rateEntity(PriceBookVersionEntity version, PriceBookDtos.ParsedRate parsedRate,
long operatorId, int number, LocalDateTime now) {
CarrierFbaRateEntity value = new CarrierFbaRateEntity();
value.setTenantId(CarrierFbaRateEntity.PLATFORM_SCOPE); value.setPriceBookVersionId(version.getId());
value.setTenantId(version.getTenantId()); value.setPriceBookVersionId(version.getId());
value.setRateCode("PB-" + version.getVersionNo() + "-" + number); value.setRateVersion(1);
value.setOriginWarehouseId(parsedRate.originWarehouseId()); value.setOriginWarehouseCode(parsedRate.originWarehouseCode().trim());
value.setOriginWarehouseName(parsedRate.originWarehouseName().trim()); value.setBusinessType("FBA_SEA");
@@ -540,12 +558,16 @@ public class PlatformPriceBookService {
private PriceBookDtos.PriceBookVersionSummary summary(PriceBookVersionEntity value) {
PriceBookStatus status = PriceBookStatus.valueOf(value.getStatus());
return new PriceBookDtos.PriceBookVersionSummary(value.getId(), value.getVersionNo(), value.getOriginalFileName(),
return new PriceBookDtos.PriceBookVersionSummary(value.getId(), value.getTenantId(), value.getVersionNo(), value.getOriginalFileName(),
value.getSourceFileId(), value.getSourceSha256(), status,
value.getChannelCount(), value.getRateCount(), value.getBlockingErrorCount(), value.getWarningCount(),
value.getParseIssuesJson(), value.getCreatedBy(), value.getCreatedAt(), value.getPublishedBy(),
value.getPublishedAt(), value.getSupersededAt());
}
private static void requireScope(long customerId) {
if (customerId < 0) throw new BizException(ResultCode.VALIDATION_FAILED, "客户ID不能为负数");
}
private PriceBookDtos.ParsedChannel channelDto(PriceBookChannelEntity value) {
return new PriceBookDtos.ParsedChannel(value.getChannelCode(), value.getChannelName(), PriceModel.valueOf(value.getPriceModel()),
value.getSourceSheetName(), value.getEstimatedDepartureDate(), value.getEstimatedDepartureText(),