From b0fd797cd2c15715d39eb59cd935401a2ade9bf1 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 17:42:20 +0800 Subject: [PATCH] feat(firstleg): support customer price books --- deploy/sql/头程数据库统一增量-MySQL8.sql | 2 +- .../PlatformPriceBookController.java | 6 ++- .../baiyue/firstleg/dto/PriceBookDtos.java | 1 + .../firstleg/entity/CarrierFbaRateEntity.java | 4 +- .../out/persistence/PriceBookQueryMapper.java | 20 ++++++--- .../application/PriceBookQueryService.java | 3 ++ .../port/in/PriceBookQueryUseCase.java | 1 + .../port/out/PriceBookQueryReader.java | 1 + .../pricing/ActivePriceBookEstimator.java | 6 ++- .../repository/PriceBookRepository.java | 33 +++++++++++---- .../firstleg/service/FbaPricingService.java | 2 +- .../service/MerchantPriceBookService.java | 6 +-- .../OrderChannelChangePricingService.java | 2 +- .../service/PlatformPriceBookService.java | 42 ++++++++++++++----- .../resources/db/phase2-inbound-schema.sql | 2 +- .../resources/openapi/fba-pricing-v1.yaml | 8 +++- .../PriceBookQueryServiceTest.java | 3 ++ .../service/FbaPricingServiceTest.java | 18 ++++---- .../service/MerchantPriceBookServiceTest.java | 19 +++++---- .../OrderChannelChangePricingServiceTest.java | 7 ++-- .../service/PlatformPriceBookServiceTest.java | 18 +++++++- 21 files changed, 145 insertions(+), 59 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index a1f23b92b..fef4f9ee8 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -3783,7 +3783,7 @@ CREATE TABLE IF NOT EXISTS fl_price_warehouse_mapping ( CREATE TABLE IF NOT EXISTS fl_price_book_version ( id BIGINT UNSIGNED NOT NULL COMMENT '整表价格版本ID', - tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共价格作用域固定为0', + tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '报价作用域:0平台公共,正数为客户专属', version_no INT UNSIGNED NOT NULL COMMENT '整表版本号', original_file_name VARCHAR(255) NOT NULL COMMENT '原始Excel文件名', source_file_id BIGINT UNSIGNED NULL COMMENT 'File服务原始文件ID', diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformPriceBookController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformPriceBookController.java index ecb4991ea..af13b112d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformPriceBookController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformPriceBookController.java @@ -38,9 +38,10 @@ public class PlatformPriceBookController { @Operation(summary = "查询船期价格表版本") public ApiResponse> 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 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}") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java index b81b8b94f..f0e0f4317 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java @@ -23,6 +23,7 @@ public final class PriceBookDtos { public record PriceBookVersionSummary( Long id, + Long customerId, Integer versionNo, String originalFileName, Long sourceFileId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java index 16956b682..0a82a59ba 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java @@ -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) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java index 8b708f74d..7c01a22c5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java @@ -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} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java index 094003ec2..e3f07440e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java @@ -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 channels(long versionId, long originWarehouseId) { return reader.findChannels(versionId, originWarehouseId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java index 980c86ad7..681279a2e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java @@ -9,6 +9,7 @@ import java.util.List; public interface PriceBookQueryUseCase { Long activeVersionId(); + Long effectiveVersionId(long customerId); List channels(long versionId, long originWarehouseId); List channelTaxModes(long versionId, long originWarehouseId); List fbaRates(long versionId, long originWarehouseId, String channelCode, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java index 04d852aab..d12366820 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java @@ -9,6 +9,7 @@ import java.util.List; public interface PriceBookQueryReader { Long findActiveVersionId(); + Long findEffectiveVersionId(long customerId); List findChannels(long versionId, long originWarehouseId); List findChannelTaxModes(long versionId, long originWarehouseId); List findFbaRates(long versionId, long originWarehouseId, String channelCode, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java index aca7ccbcf..6b7930f75 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java @@ -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, "当前没有生效的船期价格版本"); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookRepository.java index 188b90172..7c03889ce 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookRepository.java @@ -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() - .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() - .eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) .eq(PriceBookVersionEntity::getId, id) .eq(PriceBookVersionEntity::getDeleted, false)); } public List listVersions(String status) { + return listVersions(PriceBookVersionEntity.PLATFORM_SCOPE, status); + } + public List listVersions(long customerId, String status) { return versions.selectList(new LambdaQueryWrapper() - .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() - .eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) + .eq(PriceBookVersionEntity::getTenantId, customerId) .eq(PriceBookVersionEntity::getStatus, "ACTIVE") .eq(PriceBookVersionEntity::getDeleted, false) .last("FOR UPDATE")); } public List listActiveForUpdate() { + return listActiveForUpdate(PriceBookVersionEntity.PLATFORM_SCOPE); + } + public List listActiveForUpdate(long customerId) { return versions.selectList(new LambdaQueryWrapper() - .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() - .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() - .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() - .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; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index 7183beaba..501fd42d8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -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(), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java index a4e5e6a9d..250b57c32 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java @@ -32,7 +32,7 @@ public class MerchantPriceBookService { public List 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 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, "所选渠道已下线,请重新选择"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OrderChannelChangePricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OrderChannelChangePricingService.java index 49614ba3d..eb2013660 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/OrderChannelChangePricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OrderChannelChangePricingService.java @@ -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)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/PlatformPriceBookService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/PlatformPriceBookService.java index ee121a0a2..9adcf4617 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/PlatformPriceBookService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/PlatformPriceBookService.java @@ -72,13 +72,26 @@ public class PlatformPriceBookService { @Value("${baiyue.security.internal-service-token}") private String internalToken; public List list(PriceBookStatus status) { + return list(PriceBookVersionEntity.PLATFORM_SCOPE, status); + } + + public List 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 channelRows = repository.listChannels(versionId); List 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 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 channels = repository.listChannels(versionId).stream().map(this::channelDto).toList(); List 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(), diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index 894c8e38d..d6945fe11 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -533,7 +533,7 @@ CREATE TABLE IF NOT EXISTS fl_price_warehouse_mapping ( CREATE TABLE IF NOT EXISTS fl_price_book_version ( id BIGINT UNSIGNED NOT NULL COMMENT '整表价格版本ID', - tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共价格作用域固定为0', + tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '报价作用域:0平台公共,正数为客户专属', version_no INT UNSIGNED NOT NULL COMMENT '整表版本号', original_file_name VARCHAR(255) NOT NULL COMMENT '原始Excel文件名', source_file_id BIGINT UNSIGNED NULL COMMENT 'File服务原始文件ID', diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index 240b9e4e1..580aaad9b 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -72,6 +72,7 @@ paths: summary: 查询船期价格表版本 description: 需要 firstleg:fba-pricing:rate:view 权限;返回待确认上传、当前生效和历史替换版本。 parameters: + - {name: customerId, in: query, schema: {type: string, pattern: '^(0|[1-9][0-9]*)$', default: '0'}, description: 0 表示公共报价;正数表示客户专属报价} - {name: status, in: query, schema: {$ref: '#/components/schemas/PriceBookStatus'}} responses: '200': @@ -84,7 +85,9 @@ paths: '403': {description: 无权限} post: summary: 上传船期价格表并生成待确认记录 - description: 仅上传和解析,生成待确认上传记录,不改变当前生效版本;需要 firstleg:fba-pricing:rate:edit 权限。 + description: 仅上传和解析,生成待确认上传记录,不改变当前生效版本;customerId=0 为公共报价,customerId>0 为该客户的专属报价;需要 firstleg:fba-pricing:rate:edit 权限。 + parameters: + - {name: customerId, in: query, schema: {type: string, pattern: '^(0|[1-9][0-9]*)$', default: '0'}} requestBody: required: true content: @@ -105,7 +108,7 @@ paths: /api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/publish: post: summary: 确认并立即生效船期价格表版本 - description: 只确认上传表中的仓库映射,不允许在线改价;阻断错误必须为零。发布事务将上一 ACTIVE 版本标记为 SUPERSEDED,始终只保留一份 ACTIVE。 + description: 只确认上传表中的仓库映射,不允许在线改价;阻断错误必须为零。发布事务只替换同 customerId 作用域的上一 ACTIVE 版本。计价时优先使用客户专属 ACTIVE 版本,不存在时回退公共 ACTIVE 版本。 parameters: [{$ref: '#/components/parameters/VersionId'}] requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PriceBookConfirmationRequest'}}}} responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} @@ -545,6 +548,7 @@ components: type: object properties: id: {type: string, pattern: '^[1-9][0-9]*$'} + customerId: {type: string, pattern: '^(0|[1-9][0-9]*)$', description: 0 为公共报价,正数为客户专属报价} versionNo: {type: integer} originalFileName: {type: string, maxLength: 255} sourceFileId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryServiceTest.java index db339e20a..6ae21e583 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryServiceTest.java @@ -25,11 +25,14 @@ class PriceBookQueryServiceTest { void delegatesStableReadOperationsToTheOutputPort() { ChannelPrefixRow prefix = new ChannelPrefixRow("SEA", "IN", "OUT"); when(reader.findActiveVersionId()).thenReturn(9L); + when(reader.findEffectiveVersionId(10001L)).thenReturn(10L); when(reader.findChannelPrefix("SEA")).thenReturn(prefix); assertThat(service.activeVersionId()).isEqualTo(9L); + assertThat(service.effectiveVersionId(10001L)).isEqualTo(10L); assertThat(service.channelPrefix("SEA")).isSameAs(prefix); verify(reader).findActiveVersionId(); + verify(reader).findEffectiveVersionId(10001L); verify(reader).findChannelPrefix("SEA"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index bd95d463f..5b206acf2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -122,7 +122,7 @@ class FbaPricingServiceTest { inbound.setCarrierId(null); inbound.setCarrierName(null); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); - when(priceBookEstimator.estimate(any())).thenReturn( + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( estimate("KG", "167.017", "2.345678", "391.77", "10.00")); FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-1"); @@ -164,7 +164,7 @@ class FbaPricingServiceTest { when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); when(measurementRepository.findLatestMeasurement(TENANT, INBOUND)).thenReturn(measurement); when(measurementRepository.listItems(TENANT, 301L)).thenReturn(List.of(item)); - when(priceBookEstimator.estimate(any())).thenReturn( + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( estimate("CBM", "1.0019", "100", "100.19", "0")); service.calculate(TENANT, OPERATOR, INBOUND, NOW, @@ -202,7 +202,7 @@ class FbaPricingServiceTest { InboundPricingEntity inbound = inbound("200.000", "0.2000"); inbound.setCustomerId(null); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); - when(priceBookEstimator.estimate(any())).thenReturn( + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( estimate("KG", "200.000", "2.000000", "400.00", "0.00")); assertThat(service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-no-customer")) @@ -223,7 +223,7 @@ class FbaPricingServiceTest { rule.setId(601L); rule.setRuleCode("CBM-RULE"); rule.setRuleVersion(1); rule.setChargeMode("CBM"); rule.setDefaultRule(true); when(repository.matchRules(eq(inbound), any())).thenReturn(List.of(rule)); - when(priceBookEstimator.estimate(any())).thenReturn( + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( estimate("CBM", "1.0001", "100.000000", "100.01", "0.00")); FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-2"); @@ -245,7 +245,7 @@ class FbaPricingServiceTest { InboundPricingEntity inbound = inbound("100.000", "1.0000"); inbound.setProcessStatus("WAIT_SPLIT"); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); - when(priceBookEstimator.estimate(any())).thenReturn( + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( estimate("KG", "167.000", "2.000000", "334.00", "0.00")); FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "wait-split"); @@ -304,11 +304,11 @@ class FbaPricingServiceTest { InboundPricingEntity inbound = inbound("10", "1"); inbound.setMeasureReviewStatus("REJECTED"); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); - when(priceBookEstimator.estimate(any())).thenReturn( + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( estimate("CBM", "1.0000", "10", "10", "0")); assertThat(service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-3")).isNotNull(); - verify(priceBookEstimator).estimate(any()); + verify(priceBookEstimator).estimate(eq(TENANT), any()); } @Test @@ -323,7 +323,7 @@ class FbaPricingServiceTest { when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent); when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2); when(repository.supersedeCurrent(TENANT, 501L, "SENT")).thenReturn(1); - when(priceBookEstimator.estimate(any())).thenReturn( + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( estimate("CBM", "1.0000", "10", "10", "0")); FeeSnapshotResponse response = service.calculate( @@ -365,7 +365,7 @@ class FbaPricingServiceTest { readyRepository(); InboundPricingEntity inbound = inbound("10", "1"); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); - when(priceBookEstimator.estimate(any())).thenThrow( + when(priceBookEstimator.estimate(anyLong(), any())).thenThrow( new BizException(ResultCode.NOT_FOUND, "当前条件没有匹配价格")); assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "none")) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java index 8ead0e597..b660e0a4e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java @@ -29,6 +29,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -44,7 +45,7 @@ class MerchantPriceBookServiceTest { @Test void shouldResolveNormalAndOverseasInboundCodePrefixesFromChannelCatalog() { - when(repository.activeVersionId()).thenReturn(7L); + when(repository.effectiveVersionId(anyLong())).thenReturn(7L); when(repository.channelAvailable(7L, 10L, "MATSON_CLX_TRUCK")).thenReturn(true); when(repository.channels(7L, 10L)).thenReturn(List.of( channel(7L, "MATSON_CLX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE))); @@ -59,7 +60,7 @@ class MerchantPriceBookServiceTest { @Test void shouldFallBackToNormalPrefixWhenChannelHasNoOverseasVariant() { - when(repository.activeVersionId()).thenReturn(7L); + when(repository.effectiveVersionId(anyLong())).thenReturn(7L); when(repository.channelAvailable(7L, 10L, "OA_HOUSTON_TRUCK")).thenReturn(true); when(repository.channels(7L, 10L)).thenReturn(List.of( channel(7L, "OA_HOUSTON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE))); @@ -72,7 +73,7 @@ class MerchantPriceBookServiceTest { @Test void shouldValidateLegacyChannelWithoutRequiringAnInboundCodePrefix() { - when(repository.activeVersionId()).thenReturn(7L); + when(repository.effectiveVersionId(anyLong())).thenReturn(7L); when(repository.channelAvailable(7L, 10L, "LEGACY_CHANNEL")).thenReturn(true); var response = service.requireAvailableChannel( 10001L, 10L, "LEGACY_CHANNEL", "FBA", false); @@ -84,7 +85,7 @@ class MerchantPriceBookServiceTest { @Test void shouldReturnEmptyChannelsWithoutActiveVersion() { - when(repository.activeVersionId()).thenReturn(null); + when(repository.effectiveVersionId(anyLong())).thenReturn(null); assertThat(service.channels(10001L, 10L)).isEmpty(); verify(repository, never()).channels(1L, 10L); @@ -93,7 +94,7 @@ class MerchantPriceBookServiceTest { @Test void shouldExposeOnlyChannelsFromLatestActiveVersionAfterSwitch() { - when(repository.activeVersionId()).thenReturn(1L, 2L); + when(repository.effectiveVersionId(anyLong())).thenReturn(1L, 2L); when(repository.channels(1L, 10L)).thenReturn(List.of(channel(1L, "OLD", PriceModel.FBA_WAREHOUSE_RATE))); when(repository.channels(2L, 10L)).thenReturn(List.of(channel(2L, "NEW", PriceModel.FBA_WAREHOUSE_RATE))); @@ -105,7 +106,7 @@ class MerchantPriceBookServiceTest { @Test void shouldExposeDistinctPricingTaxModesForActiveOriginChannels() { - when(repository.activeVersionId()).thenReturn(7L); + when(repository.effectiveVersionId(anyLong())).thenReturn(7L); when(repository.channels(7L, 10L)).thenReturn(List.of( channel(7L, "MATSON_CLX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE), channel(7L, "OA_PARCEL", PriceModel.REGION_WEIGHT_TIER))); @@ -124,7 +125,7 @@ class MerchantPriceBookServiceTest { @Test void shouldKeepActiveChannelWhenItsTaxModeProjectionIsEmpty() { - when(repository.activeVersionId()).thenReturn(7L); + when(repository.effectiveVersionId(anyLong())).thenReturn(7L); when(repository.channels(7L, 10L)).thenReturn(List.of( channel(7L, "MATSON_CLX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE))); when(repository.channelTaxModes(7L, 10L)).thenReturn(List.of()); @@ -136,7 +137,7 @@ class MerchantPriceBookServiceTest { @Test void shouldRejectForgedOrRemovedChannel() { - when(repository.activeVersionId()).thenReturn(2L); + when(repository.effectiveVersionId(anyLong())).thenReturn(2L); when(repository.channels(2L, 10L)).thenReturn(List.of(channel(2L, "ACTIVE_CHANNEL", PriceModel.FBA_WAREHOUSE_RATE))); @@ -282,7 +283,7 @@ class MerchantPriceBookServiceTest { } private void stubChannel(long versionId, String code, PriceModel model) { - when(repository.activeVersionId()).thenReturn(versionId); + when(repository.effectiveVersionId(anyLong())).thenReturn(versionId); when(repository.channels(versionId, 10L)).thenReturn(List.of(channel(versionId, code, model))); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OrderChannelChangePricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OrderChannelChangePricingServiceTest.java index e21fcfdaf..ef8b72bd3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/OrderChannelChangePricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OrderChannelChangePricingServiceTest.java @@ -18,6 +18,7 @@ import java.math.BigDecimal; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -48,7 +49,7 @@ class OrderChannelChangePricingServiceTest { InboundPricingEntity inbound = inbound("CALCULATED"); when(repository.findInboundByOrder(10001L, 77L)).thenReturn(inbound); when(repository.findLatestFee(10001L, 88L)).thenReturn(fee(91L, "CALCULATED")); - when(estimator.estimate(any())).thenReturn(estimate()); + when(estimator.estimate(anyLong(), any())).thenReturn(estimate()); var response = service.validate(request(4)); @@ -64,7 +65,7 @@ class OrderChannelChangePricingServiceTest { InboundFeeEntity fee = fee(91L, "CALCULATED"); when(repository.findInboundByOrderForUpdate(10001L, 77L)).thenReturn(inbound); when(repository.findLatestFeeForUpdate(10001L, 88L)).thenReturn(fee); - when(estimator.estimate(any())).thenReturn(estimate()); + when(estimator.estimate(anyLong(), any())).thenReturn(estimate()); when(repository.supersedeCurrent(10001L, 91L, "CALCULATED")).thenReturn(1); when(repository.applyOrderChannelChange(inbound, "MATSON_NEW", "新渠道")).thenReturn(1); when(repository.completeIdempotency(any(), org.mockito.ArgumentMatchers.eq(4L), any())).thenReturn(1); @@ -88,7 +89,7 @@ class OrderChannelChangePricingServiceTest { token.setRequestHash(requestHash(request(4))); when(repository.findInboundByOrderForUpdate(10001L, 77L)).thenReturn(inbound); when(repository.findIdempotency(10001L, 0L, "ORDER_CHANNEL_CHANGE", "77:4")).thenReturn(token); - when(estimator.estimate(any())).thenReturn(estimate()); + when(estimator.estimate(anyLong(), any())).thenReturn(estimate()); assertThat(service.apply(request(4)).orderVersion()).isEqualTo(4L); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java index 42652dc2d..5729b9a57 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java @@ -64,6 +64,22 @@ class PlatformPriceBookServiceTest { ReflectionTestUtils.setField(service, "internalToken", "internal-token"); } + @Test + void listsCustomerSpecificVersionsWithinRequestedScope() { + PriceBookVersionEntity customerVersion = version(21L, PriceBookStatus.ACTIVE); + customerVersion.setTenantId(10001L); + when(repository.listVersions(10001L, PriceBookStatus.ACTIVE.name())) + .thenReturn(List.of(customerVersion)); + + var result = service.list(10001L, PriceBookStatus.ACTIVE); + + assertThat(result).singleElement().satisfies(summary -> { + assertThat(summary.id()).isEqualTo(21L); + assertThat(summary.customerId()).isEqualTo(10001L); + }); + verify(repository).listVersions(10001L, PriceBookStatus.ACTIVE.name()); + } + @Test void fileServiceFailureMarksAllocatedVersionFailed() { executeTransactionsImmediately(); @@ -121,7 +137,7 @@ class PlatformPriceBookServiceTest { assertThat(Arrays.stream(PlatformPriceBookService.class.getDeclaredMethods()) .filter(method -> java.lang.reflect.Modifier.isPublic(method.getModifiers())) .map(java.lang.reflect.Method::getName).toList()) - .containsExactlyInAnyOrder("list", "upload", "detail", "publish", "source"); + .containsOnly("list", "upload", "detail", "publish", "source"); } @Test