diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java index 54fb3d8a9..43973be5d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java @@ -4,7 +4,9 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.PriceBookChannelValidation; +import com.baiyue.firstleg.dto.OrderChannelChangePricing; import com.baiyue.firstleg.service.MerchantPriceBookService; +import com.baiyue.firstleg.service.OrderChannelChangePricingService; import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; import jakarta.validation.Valid; @@ -22,6 +24,7 @@ import java.security.MessageDigest; @RequestMapping("/internal/firstleg/v1/pricing") public class InternalPriceBookController { @Resource private MerchantPriceBookService service; + @Resource private OrderChannelChangePricingService channelChanges; @Value("${baiyue.security.internal-service-token}") private String internalToken; private byte[] expectedToken; @@ -30,6 +33,22 @@ public class InternalPriceBookController { expectedToken = internalToken.getBytes(StandardCharsets.UTF_8); } + @PostMapping("/order-channel-changes/validate") + public ApiResponse validateOrderChannelChange( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody OrderChannelChangePricing.Request request) { + requireToken(token); + return ApiResponse.success(channelChanges.validate(request)); + } + + @PostMapping("/order-channel-changes/apply") + public ApiResponse applyOrderChannelChange( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody OrderChannelChangePricing.Request request) { + requireToken(token); + return ApiResponse.success(channelChanges.apply(request)); + } + @PostMapping("/channels/validate") public ApiResponse validateChannel( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderChannelChangePricing.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderChannelChangePricing.java new file mode 100644 index 000000000..e7ce31ce3 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderChannelChangePricing.java @@ -0,0 +1,36 @@ +package com.baiyue.firstleg.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** Order 审核后变更渠道时使用的 Firstleg 计价门禁与同步契约。 */ +public final class OrderChannelChangePricing { + private OrderChannelChangePricing() {} + + public record Request( + @Min(1) long tenantId, + @Min(1) long orderId, + @Min(0) long orderVersion, + @Min(1) long originWarehouseId, + @NotBlank @Size(max = 64) String channelCode, + @NotBlank @Size(max = 128) String channelName, + @NotNull @Pattern(regexp = "FBA|OVERSEAS") String destinationType, + Long destinationWarehouseId, + @Size(max = 64) String destinationCode, + @NotNull @Pattern(regexp = "TAX_INCLUDED|TAX_EXCLUDED") String pricingTaxMode + ) {} + + public record Response( + long inboundId, + long orderId, + long orderVersion, + String channelCode, + Long priceBookVersionId, + String feeStatus, + boolean calculatedFeeWillBeInvalidated, + Long invalidatedFeeId + ) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPricingEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPricingEntity.java index a8429d117..7e4d21022 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPricingEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPricingEntity.java @@ -17,6 +17,7 @@ public class InboundPricingEntity extends BaseEntity { @TableId(type = IdType.ASSIGN_ID) private Long id; private Long tenantId; + private Long orderId; private String inboundCode; private String processStatus; private String measureReviewStatus; @@ -27,6 +28,7 @@ public class InboundPricingEntity extends BaseEntity { private String warehouseName; private String businessType; private String channelCode; + private String channelName; private Long carrierId; private String carrierName; private String taxMode; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java index d2cdc3d62..5a9a0f1fe 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java @@ -119,6 +119,19 @@ public class FbaPricingRepository { .eq(InboundPricingEntity::getId, inboundId)); } + public InboundPricingEntity findInboundByOrderForUpdate(long tenantId, long orderId) { + return inbounds.selectOne(new LambdaQueryWrapper() + .eq(InboundPricingEntity::getTenantId, tenantId) + .eq(InboundPricingEntity::getOrderId, orderId) + .last("FOR UPDATE")); + } + + public InboundPricingEntity findInboundByOrder(long tenantId, long orderId) { + return inbounds.selectOne(new LambdaQueryWrapper() + .eq(InboundPricingEntity::getTenantId, tenantId) + .eq(InboundPricingEntity::getOrderId, orderId)); + } + public List matchRules(InboundPricingEntity inbound, LocalDateTime at) { return rules.selectList(new LambdaQueryWrapper() .eq(CustomerChargeRuleEntity::getTenantId, inbound.getTenantId()) @@ -178,6 +191,31 @@ public class FbaPricingRepository { .orderByDesc(InboundFeeEntity::getFeeVersion).last("LIMIT 1")); } + public InboundFeeEntity findLatestFeeForUpdate(long tenantId, long inboundId) { + return fees.selectOne(new LambdaQueryWrapper() + .eq(InboundFeeEntity::getTenantId, tenantId) + .eq(InboundFeeEntity::getInboundId, inboundId) + .orderByDesc(InboundFeeEntity::getFeeVersion).last("LIMIT 1 FOR UPDATE")); + } + + public int applyOrderChannelChange(InboundPricingEntity inbound, String channelCode, String channelName) { + LambdaUpdateWrapper update = new LambdaUpdateWrapper() + .eq(InboundPricingEntity::getTenantId, inbound.getTenantId()) + .eq(InboundPricingEntity::getId, inbound.getId()) + .in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED")) + .set(InboundPricingEntity::getChannelCode, channelCode) + .set(InboundPricingEntity::getChannelName, channelName) + .set(InboundPricingEntity::getFeeStatus, "UNPRICED") + .set(InboundPricingEntity::getChargeableWeightKg, null) + .set(InboundPricingEntity::getChargeableVolumeCbm, null) + .set(InboundPricingEntity::getUpdatedAt, LocalDateTime.now()) + .setSql("version = version + 1"); + if ("FEE_CONFIRMING".equals(inbound.getProcessStatus())) { + update.set(InboundPricingEntity::getProcessStatus, "WAIT_PRICING"); + } + return inbounds.update(null, update); + } + public InboundFeeItemEntity findBaseItem(long tenantId, long feeId) { return items.selectOne(new LambdaQueryWrapper() .eq(InboundFeeItemEntity::getTenantId, tenantId) @@ -302,6 +340,16 @@ public class FbaPricingRepository { public void insertIdempotency(InboundPricingIdempotencyEntity value) { idempotencies.insert(value); } + public InboundPricingIdempotencyEntity findLatestCompletedOrderChannelChange(long tenantId, long inboundId) { + return idempotencies.selectOne(new LambdaQueryWrapper() + .eq(InboundPricingIdempotencyEntity::getTenantId, tenantId) + .eq(InboundPricingIdempotencyEntity::getInboundId, inboundId) + .eq(InboundPricingIdempotencyEntity::getOperation, "ORDER_CHANNEL_CHANGE") + .eq(InboundPricingIdempotencyEntity::getStatus, "COMPLETED") + .orderByDesc(InboundPricingIdempotencyEntity::getResultResourceId) + .last("LIMIT 1")); + } + public int completeIdempotency(InboundPricingIdempotencyEntity value, long resourceId, LocalDateTime now) { return idempotencies.update(null, new LambdaUpdateWrapper() .eq(InboundPricingIdempotencyEntity::getTenantId, value.getTenantId()) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OrderChannelChangePricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OrderChannelChangePricingService.java new file mode 100644 index 000000000..eb20ed3cc --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OrderChannelChangePricingService.java @@ -0,0 +1,173 @@ +package com.baiyue.firstleg.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.domain.PricingDestinationType; +import com.baiyue.firstleg.domain.PricingTaxMode; +import com.baiyue.firstleg.dto.OrderChannelChangePricing.Request; +import com.baiyue.firstleg.dto.OrderChannelChangePricing.Response; +import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest; +import com.baiyue.firstleg.entity.InboundFeeEntity; +import com.baiyue.firstleg.entity.InboundPricingEntity; +import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; +import com.baiyue.firstleg.pricing.ActivePriceBookEstimator; +import com.baiyue.firstleg.repository.FbaPricingRepository; +import com.baiyue.firstleg.repository.FeeItemRepository; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.HexFormat; +import java.util.Objects; + +@Service +public class OrderChannelChangePricingService { + private static final BigDecimal ELIGIBILITY_WEIGHT = new BigDecimal("1000000"); + private static final BigDecimal ELIGIBILITY_VOLUME = BigDecimal.ONE; + @Resource private FbaPricingRepository repository; + @Resource private FeeItemRepository feeItems; + @Resource private MerchantPriceBookService priceBooks; + @Resource private ActivePriceBookEstimator estimator; + + @Transactional(readOnly = true) + public Response validate(Request request) { + InboundPricingEntity inbound = requiredInbound(request, false); + InboundFeeEntity fee = repository.findLatestFee(request.tenantId(), inbound.getId()); + requireReversible(inbound, fee); + var estimate = requireEligiblePrice(request, inbound); + return response(request, inbound, estimate.priceBookVersionId(), fee, null); + } + + @Transactional + public Response apply(Request request) { + InboundPricingEntity inbound = requiredInbound(request, true); + String key = request.orderId() + ":" + request.orderVersion(); + String requestHash = hash(request); + InboundPricingIdempotencyEntity replay = repository.findIdempotency( + request.tenantId(), 0L, "ORDER_CHANNEL_CHANGE", key); + if (replay != null) { + if (!requestHash.equals(replay.getRequestHash())) throw conflict("同一订单版本已用于不同渠道变更"); + if (!"COMPLETED".equals(replay.getStatus())) throw conflict("相同渠道变更正在处理中"); + var estimate = requireEligiblePrice(request, inbound); + return new Response(inbound.getId(), request.orderId(), request.orderVersion(), estimate.channelCode(), + estimate.priceBookVersionId(), inbound.getFeeStatus(), false, null); + } + InboundPricingIdempotencyEntity latest = repository.findLatestCompletedOrderChannelChange( + request.tenantId(), inbound.getId()); + if (latest != null && latest.getResultResourceId() > request.orderVersion()) { + throw conflict("订单渠道版本已推进,请勿重放旧版本"); + } + + InboundFeeEntity fee = repository.findLatestFeeForUpdate(request.tenantId(), inbound.getId()); + requireReversible(inbound, fee); + var estimate = requireEligiblePrice(request, inbound); + Long invalidatedFeeId = fee != null && "CALCULATED".equals(fee.getFeeStatus()) ? fee.getId() : null; + if (invalidatedFeeId != null) { + if (repository.supersedeCurrent(request.tenantId(), invalidatedFeeId, "CALCULATED") != 1) { + throw conflict("费用预估状态已变化,渠道变更未应用"); + } + feeItems.supersedeFreight(request.tenantId(), inbound.getId(), 0L, 0L, LocalDateTime.now()); + } + if (repository.applyOrderChannelChange(inbound, estimate.channelCode(), request.channelName()) != 1) { + throw conflict("入库单费用状态已变化,渠道变更未应用"); + } + + InboundPricingIdempotencyEntity token = new InboundPricingIdempotencyEntity(); + token.setId(IdWorker.getId()); + token.setTenantId(request.tenantId()); + token.setOperation("ORDER_CHANNEL_CHANGE"); + token.setOperatorId(0L); + token.setInboundId(inbound.getId()); + token.setIdempotencyKey(key); + token.setRequestHash(requestHash); + token.setStatus("PROCESSING"); + token.setCreatedAt(LocalDateTime.now()); + token.setUpdatedAt(LocalDateTime.now()); + repository.insertIdempotency(token); + if (repository.completeIdempotency(token, request.orderVersion(), LocalDateTime.now()) != 1) { + throw conflict("渠道变更幂等状态已变化"); + } + return new Response(inbound.getId(), request.orderId(), request.orderVersion(), estimate.channelCode(), + estimate.priceBookVersionId(), "UNPRICED", invalidatedFeeId != null, invalidatedFeeId); + } + + private InboundPricingEntity requiredInbound(Request request, boolean lock) { + InboundPricingEntity inbound = lock + ? repository.findInboundByOrderForUpdate(request.tenantId(), request.orderId()) + : repository.findInboundByOrder(request.tenantId(), request.orderId()); + if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "订单对应入库单不存在,无法校验费用门禁"); + if (!Objects.equals(inbound.getWarehouseId(), request.originWarehouseId()) + || !Objects.equals(inbound.getDestinationType(), request.destinationType()) + || !Objects.equals(inbound.getDestinationWarehouseId(), request.destinationWarehouseId()) + || !sameText(inbound.getDestinationCode(), request.destinationCode()) + || !Objects.equals(inbound.getPricingTaxMode(), request.pricingTaxMode())) { + throw conflict("订单计价维度与 Firstleg 入库快照不一致"); + } + return inbound; + } + + private ActivePriceBookEstimator.Estimate requireEligiblePrice(Request request, InboundPricingEntity inbound) { + priceBooks.requireAvailableChannel(request.tenantId(), request.originWarehouseId(), + 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(), + request.destinationCode(), PricingDestinationType.valueOf(request.destinationType()), + request.destinationWarehouseId(), inbound.getDestinationName(), null, + PricingTaxMode.valueOf(request.pricingTaxMode()), weight, volume)); + } + + private void requireReversible(InboundPricingEntity inbound, InboundFeeEntity fee) { + String feeStatus = fee == null ? inbound.getFeeStatus() : fee.getFeeStatus(); + if ("CONFIRMED".equals(feeStatus) || "SENT".equals(feeStatus) + || feeItems.findPublishedFreight(inbound.getTenantId(), inbound.getId()) != null) { + throw conflict("费用已确认或已发布,渠道不可变更;请先按费用作废流程处理"); + } + if (!"UNPRICED".equals(inbound.getFeeStatus()) && !"CALCULATED".equals(inbound.getFeeStatus())) { + throw conflict("当前费用状态不可逆,渠道不可变更"); + } + } + + private static Response response(Request request, InboundPricingEntity inbound, Long versionId, + InboundFeeEntity fee, Long invalidatedFeeId) { + String status = fee != null && ("CALCULATED".equals(fee.getFeeStatus()) + || "CONFIRMED".equals(fee.getFeeStatus()) || "SENT".equals(fee.getFeeStatus())) + ? fee.getFeeStatus() : inbound.getFeeStatus(); + boolean calculated = "CALCULATED".equals(status); + return new Response(inbound.getId(), request.orderId(), request.orderVersion(), request.channelCode().trim().toUpperCase(), + versionId, status, calculated, invalidatedFeeId); + } + + private static BigDecimal positive(BigDecimal value, BigDecimal fallback) { + return value != null && value.signum() > 0 ? value : fallback; + } + + private static boolean sameText(String left, String right) { + String a = left == null ? null : left.trim().toUpperCase(); + String b = right == null ? null : right.trim().toUpperCase(); + return Objects.equals(a, b); + } + + private static String hash(Request request) { + try { + String value = request.tenantId() + ":" + request.orderId() + ":" + request.orderVersion() + ":" + + request.originWarehouseId() + ":" + request.channelCode().trim().toUpperCase() + ":" + + request.destinationType() + ":" + request.destinationWarehouseId() + ":" + + request.destinationCode() + ":" + request.pricingTaxMode(); + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("JVM不支持SHA-256", exception); + } + } + + private static BizException conflict(String message) { + return new BizException(ResultCode.CONFLICT, message); + } +} diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 877b07eab..55a733e37 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -70,6 +70,39 @@ paths: requireInboundCodePrefix: {type: boolean} responses: '200': {description: 成功时 data 返回 channelCode;requireInboundCodePrefix=true 时同时返回 inboundCodePrefix} + /internal/firstleg/v1/pricing/order-channel-changes/validate: + post: + summary: Order 提交渠道变更前校验 Firstleg 计价资格与费用可逆性 + description: >- + 按客户、订单、国内收货仓、目的仓类型/标识、报价包税方式和新渠道校验当前生效价格。 + 若已有 CONFIRMED/SENT 费用或 PUBLISHED 运费明细,返回冲突并明确要求先走费用作废流程; + CALCULATED 仅在响应中标记将失效,本接口不写数据。 + security: + - internalServiceToken: [] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/OrderChannelChangePricingRequest'} + responses: + '200': {description: 成功时 data 返回价格版本、费用状态及是否需失效未确认预估} + /internal/firstleg/v1/pricing/order-channel-changes/apply: + post: + summary: 按订单版本幂等应用渠道变更并失效未确认价格预估 + description: >- + 仅供 Order 在渠道变更成功后调用。tenantId + orderId + orderVersion 构成幂等边界; + 旧版本重放不会覆盖新版本。只将 CALCULATED 费用及草稿运费标记为 SUPERSEDED, + 入库单恢复 UNPRICED 并可重新计价;实际净重、实际体积和测量审核事实保持不变。 + 已确认、已发送或已发布费用一律拒绝,不能由本命令覆盖。 + security: + - internalServiceToken: [] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/OrderChannelChangePricingRequest'} + responses: + '200': {description: 成功或同版本同请求幂等重放,data 返回应用版本和失效费用 ID} /internal/firstleg/v1/inbounds/{inboundId}/fees/{feeId}/source-snapshot: get: summary: Finance 按入库费用来源版本精确回查不可变快照 @@ -1009,6 +1042,33 @@ components: required: true schema: {type: string, pattern: '^[1-9][0-9]*$'} schemas: + OrderChannelChangePricingRequest: + type: object + additionalProperties: false + required: [tenantId, orderId, orderVersion, originWarehouseId, channelCode, channelName, destinationType, pricingTaxMode] + properties: + tenantId: {type: integer, format: int64, minimum: 1} + orderId: {type: integer, format: int64, minimum: 1} + orderVersion: {type: integer, format: int64, minimum: 0, description: Order 渠道变更完成后的聚合版本;用于幂等与拒绝旧事件} + originWarehouseId: {type: integer, format: int64, minimum: 1} + channelCode: {type: string, minLength: 1, maxLength: 64} + channelName: {type: string, minLength: 1, maxLength: 128} + destinationType: {type: string, enum: [FBA, OVERSEAS]} + destinationWarehouseId: {type: integer, format: int64, minimum: 1, nullable: true} + destinationCode: {type: string, maxLength: 64, nullable: true} + pricingTaxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED]} + OrderChannelChangePricingResponse: + type: object + required: [inboundId, orderId, orderVersion, channelCode, feeStatus, calculatedFeeWillBeInvalidated] + properties: + inboundId: {type: integer, format: int64} + orderId: {type: integer, format: int64} + orderVersion: {type: integer, format: int64} + channelCode: {type: string} + priceBookVersionId: {type: integer, format: int64, nullable: true} + feeStatus: {type: string, enum: [UNPRICED, CALCULATED]} + calculatedFeeWillBeInvalidated: {type: boolean} + invalidatedFeeId: {type: integer, format: int64, nullable: true} UnifiedMainStatus: type: string enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING, LOADED, DELIVERED] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java index 5a9a269a2..c9480da62 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java @@ -120,6 +120,10 @@ class PriceBookContractTest { .getContentAsString(StandardCharsets.UTF_8); assertThat(firstlegOpenapi) .contains("/internal/firstleg/v1/pricing/channels/validate:") + .contains("/internal/firstleg/v1/pricing/order-channel-changes/validate:") + .contains("/internal/firstleg/v1/pricing/order-channel-changes/apply:") + .contains("tenantId + orderId + orderVersion 构成幂等边界") + .contains("实际净重、实际体积和测量审核事实保持不变") .contains("required: [tenantId, originWarehouseId, channelCode, destinationType, requireInboundCodePrefix]") .contains("LEGACY_MANUAL 模式仅校验渠道可用性"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java index aa7a047c6..f9996b3f3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java @@ -41,7 +41,6 @@ class LoadingListRepositoryTest { row.setVersion(1); row.setContainerNo("MSCU-001"); row.setCabinetNo("CAB-001"); - row.setPreloadPushedAt(java.time.LocalDateTime.now()); row.setPreloadLinkCount(2L); row.setConfirmedPreloadCount(1L); row.setOceanBookingId(81L); @@ -81,7 +80,6 @@ class LoadingListRepositoryTest { row.setPlanNo("LP-41"); row.setPlanStatus("EDITING"); row.setVersion(1); - row.setPreloadPushedAt(java.time.LocalDateTime.now()); row.setPreloadLinkCount(2L); row.setConfirmedPreloadCount(0L); row.setOceanBookingId(81L); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index 97b020c93..cd70df2b6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -155,7 +155,7 @@ class Phase3FinalUnifiedSqlContractTest { .contains("存在缺失中文注释的业务字段") .doesNotContain("CREATE PROCEDURE upgrade_column_comment"); assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(144); - assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2378); + assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2374); assertThat(sql) .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'") .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_name'") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 10332299f..206ad7295 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -129,7 +129,6 @@ class LoadingPickServiceTest { booking.setId(40L); var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity(); plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING"); - plan.setPreloadPushedAt(java.time.LocalDateTime.now()); var allocation = new OceanBookingContainerAllocationEntity(); allocation.setOceanBookingId(90L); var ocean = new OceanBookingEntity(); ocean.setId(90L); ocean.setStatus("RELEASED"); when(smartLoadingRepository.booking(40L)).thenReturn(booking); @@ -157,7 +156,6 @@ class LoadingPickServiceTest { booking.setId(40L); booking.setContainerNo("MSCU-001"); var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity(); plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING"); - plan.setPreloadPushedAt(java.time.LocalDateTime.now()); var allocation = new OceanBookingContainerAllocationEntity(); allocation.setOceanBookingId(90L); var ocean = new OceanBookingEntity(); ocean.setId(90L); ocean.setStatus("CONFIRMED"); when(smartLoadingRepository.booking(40L)).thenReturn(booking); @@ -178,7 +176,6 @@ class LoadingPickServiceTest { booking.setId(40L); booking.setContainerNo("MSCU-001"); var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity(); plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING"); - plan.setPreloadPushedAt(java.time.LocalDateTime.now()); when(smartLoadingRepository.booking(40L)).thenReturn(booking); when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan); when(loadingModelBridge.byLoadingPlan(10L)).thenReturn(List.of( @@ -244,7 +241,6 @@ class LoadingPickServiceTest { booking.setId(40L); booking.setContainerNo("MSCU-001"); var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity(); plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING"); - plan.setPreloadPushedAt(java.time.LocalDateTime.now()); var allocation = new OceanBookingContainerAllocationEntity(); allocation.setOceanBookingId(90L); var ocean = new OceanBookingEntity(); ocean.setId(90L); ocean.setStatus("RELEASED"); when(smartLoadingRepository.booking(40L)).thenReturn(booking); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OrderChannelChangePricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OrderChannelChangePricingServiceTest.java new file mode 100644 index 000000000..25685cfe4 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OrderChannelChangePricingServiceTest.java @@ -0,0 +1,164 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.OrderChannelChangePricing.Request; +import com.baiyue.firstleg.entity.InboundFeeEntity; +import com.baiyue.firstleg.entity.InboundPricingEntity; +import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; +import com.baiyue.firstleg.pricing.ActivePriceBookEstimator; +import com.baiyue.firstleg.repository.FbaPricingRepository; +import com.baiyue.firstleg.repository.FeeItemRepository; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +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.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(OrderChannelChangePricingService.class) +class OrderChannelChangePricingServiceTest { + @MockBean private FbaPricingRepository repository; + @MockBean private FeeItemRepository feeItems; + @MockBean private MerchantPriceBookService priceBooks; + @MockBean private ActivePriceBookEstimator estimator; + @Resource private OrderChannelChangePricingService service; + + @Test + void rejectsConfirmedFeeBeforeChannelCanChange() { + InboundPricingEntity inbound = inbound("CONFIRMED"); + InboundFeeEntity fee = fee(91L, "CONFIRMED"); + when(repository.findInboundByOrder(10001L, 77L)).thenReturn(inbound); + when(repository.findLatestFee(10001L, 88L)).thenReturn(fee); + + assertThatThrownBy(() -> service.validate(request(4))) + .isInstanceOf(BizException.class) + .hasMessageContaining("费用已确认或已发布"); + verify(estimator, never()).estimate(any()); + } + + @Test + void validatesExactPriceAndMarksCalculatedEstimateForInvalidation() { + 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()); + + var response = service.validate(request(4)); + + assertThat(response.priceBookVersionId()).isEqualTo(12L); + assertThat(response.calculatedFeeWillBeInvalidated()).isTrue(); + assertThat(response.feeStatus()).isEqualTo("CALCULATED"); + verify(priceBooks).requireAvailableChannel(10001L, 9L, "MATSON_NEW", "FBA", false); + } + + @Test + void appliesByOrderVersionAndOnlyInvalidatesCalculatedFacts() { + InboundPricingEntity inbound = inbound("CALCULATED"); + 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(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); + + var response = service.apply(request(4)); + + assertThat(response.feeStatus()).isEqualTo("UNPRICED"); + assertThat(response.invalidatedFeeId()).isEqualTo(91L); + assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("37"); + assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("0.102"); + verify(feeItems).supersedeFreight( + org.mockito.ArgumentMatchers.eq(10001L), org.mockito.ArgumentMatchers.eq(88L), + org.mockito.ArgumentMatchers.eq(0L), org.mockito.ArgumentMatchers.eq(0L), any()); + verify(repository).insertIdempotency(any()); + } + + @Test + void sameOrderVersionReplaysWithoutInvalidatingAgain() { + InboundPricingEntity inbound = inbound("UNPRICED"); + InboundPricingIdempotencyEntity token = token(4L, "COMPLETED"); + 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()); + + assertThat(service.apply(request(4)).orderVersion()).isEqualTo(4L); + + verify(repository, never()).supersedeCurrent(any(Long.class), any(Long.class), any()); + verify(repository, never()).applyOrderChannelChange(any(), any(), any()); + } + + @Test + void rejectsOlderOrderVersionAfterNewerVersionWasApplied() { + InboundPricingEntity inbound = inbound("UNPRICED"); + when(repository.findInboundByOrderForUpdate(10001L, 77L)).thenReturn(inbound); + when(repository.findLatestCompletedOrderChannelChange(10001L, 88L)) + .thenReturn(token(5L, "COMPLETED")); + + assertThatThrownBy(() -> service.apply(request(4))) + .isInstanceOf(BizException.class) + .hasMessageContaining("订单渠道版本已推进"); + verify(estimator, never()).estimate(any()); + } + + private static Request request(long orderVersion) { + return new Request(10001L, 77L, orderVersion, 9L, "MATSON_NEW", "新渠道", + "FBA", 501L, "ONT8", "TAX_INCLUDED"); + } + + private static InboundPricingEntity inbound(String feeStatus) { + InboundPricingEntity value = new InboundPricingEntity(); + value.setId(88L); + value.setTenantId(10001L); + value.setOrderId(77L); + value.setWarehouseId(9L); + value.setDestinationType("FBA"); + value.setDestinationWarehouseId(501L); + value.setDestinationCode("ONT8"); + value.setDestinationName("ONT8"); + value.setPricingTaxMode("TAX_INCLUDED"); + value.setProcessStatus("WAIT_SPLIT"); + value.setFeeStatus(feeStatus); + value.setActualNetWeightKg(new BigDecimal("37")); + value.setActualVolumeCbm(new BigDecimal("0.102")); + return value; + } + + private static InboundFeeEntity fee(long id, String status) { + InboundFeeEntity value = new InboundFeeEntity(); + value.setId(id); + value.setInboundId(88L); + value.setFeeStatus(status); + return value; + } + + private static ActivePriceBookEstimator.Estimate estimate() { + return new ActivePriceBookEstimator.Estimate(12L, "MATSON_NEW", null, null, null, + BigDecimal.ONE, "KG", BigDecimal.ONE, BigDecimal.ONE, "CNY", BigDecimal.ONE, 6, "HALF_UP"); + } + + private static InboundPricingIdempotencyEntity token(long version, String status) { + InboundPricingIdempotencyEntity value = new InboundPricingIdempotencyEntity(); + value.setResultResourceId(version); + value.setStatus(status); + return value; + } + + private static String requestHash(Request request) { + try { + var method = OrderChannelChangePricingService.class.getDeclaredMethod("hash", Request.class); + method.setAccessible(true); + return (String) method.invoke(null, request); + } catch (ReflectiveOperationException exception) { + throw new AssertionError(exception); + } + } +}