Test #1

Merged
admin merged 1726 commits from test into main 2026-09-17 11:52:45 +08:00
21 changed files with 145 additions and 59 deletions
Showing only changes of commit b0fd797cd2 - Show all commits
@@ -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',
@@ -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(),
@@ -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',
@@ -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}
@@ -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");
}
@@ -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"))
@@ -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)));
}
@@ -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);
@@ -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