Merge branch 'test' of http://git.buggoglobal.com/java-project/baiyue-firstleg into dev
This commit is contained in:
@@ -22,7 +22,7 @@ public interface IamOnboardingClient {
|
||||
@PathVariable("authUserId") Long authUserId);
|
||||
|
||||
record OnboardingRequest(Long authUserId, String displayName, String companyName,
|
||||
String legalPerson, String legalPhone, String contactEmail,
|
||||
String legalPerson, String administratorPhone, String contactEmail,
|
||||
String countryCode, String address) { }
|
||||
|
||||
record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus,
|
||||
|
||||
@@ -77,7 +77,7 @@ public class AuthController {
|
||||
@SecurityRequirements
|
||||
@PostMapping("/register")
|
||||
@Operation(summary = "Merchant registration / 客户注册",
|
||||
description = "Create a login account and an enterprise profile awaiting the required business license and legal-person ID; other qualification files are optional.")
|
||||
description = "Create a login account and enterprise profile. The supplied phone initializes both the first administrator login phone and company contact phone; they are maintained independently after creation. Only the business license and unified social credit code are required for review; legal-person ID is optional.")
|
||||
public ApiResponse<MerchantRegisterResponse> register(@Valid @RequestBody MerchantRegisterRequest request) {
|
||||
return ApiResponse.success(registrationService.register(request));
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ public final class AccountManagementDtos {
|
||||
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
|
||||
@NotBlank String currentPassword) { }
|
||||
public record UpdateAccountPhoneRequest(
|
||||
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String phone) { }
|
||||
@Pattern(regexp = "^1[3-9]\\d{9}$") String phone) { }
|
||||
public record UpdateAccountProfileRequest(@NotBlank @Size(max = 64) String displayName) { }
|
||||
public record UpdateAccountStatusRequest(@NotBlank String status) { }
|
||||
public record RevokeAccountSessionsRequest(
|
||||
|
||||
@@ -13,7 +13,8 @@ public record MerchantRegisterRequest(
|
||||
@NotBlank @Size(max = 64) String displayName,
|
||||
@NotBlank @Size(max = 128) String companyName,
|
||||
@NotBlank @Size(max = 64) String legalPerson,
|
||||
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String legalPhone,
|
||||
@Schema(description = "首个企业管理员的登录手机号;注册时同时初始化企业联系电话,创建后两者独立维护")
|
||||
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
|
||||
@Email @Size(max = 128) String contactEmail,
|
||||
@Size(min = 2, max = 2) String countryCode,
|
||||
@Size(max = 255) String address
|
||||
|
||||
@@ -151,9 +151,9 @@ public class AccountManagementService {
|
||||
description = "同步受管账号手机号", captureAfter = false)
|
||||
public void updatePhone(Long accountId, UpdateAccountPhoneRequest request) {
|
||||
AuthAccount account = required(accountId);
|
||||
String phone = request.phone().trim();
|
||||
String phone = request.phone() == null ? null : request.phone().trim();
|
||||
if (Objects.equals(account.getPhone(), phone)) return;
|
||||
if (repository.existsByPhoneForOtherAccount(phone, accountId)) {
|
||||
if (phone != null && repository.existsByPhoneForOtherAccount(phone, accountId)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "手机号已存在");
|
||||
}
|
||||
account.setPhone(phone);
|
||||
|
||||
@@ -38,7 +38,7 @@ public class MerchantRegistrationService {
|
||||
if (accountRepository.existsByUsername(request.username())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "用户名已存在");
|
||||
}
|
||||
if (accountRepository.existsByPhone(request.legalPhone())) {
|
||||
if (accountRepository.existsByPhone(request.phone())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "手机号已存在");
|
||||
}
|
||||
long accountId = IdWorker.getId();
|
||||
@@ -49,7 +49,7 @@ public class MerchantRegistrationService {
|
||||
try {
|
||||
var response = onboardingClient.register(internalToken,
|
||||
new IamOnboardingClient.OnboardingRequest(accountId, request.displayName(),
|
||||
request.companyName(), request.legalPerson(), request.legalPhone(),
|
||||
request.companyName(), request.legalPerson(), request.phone(),
|
||||
request.contactEmail(), request.countryCode(), request.address()));
|
||||
if (response == null) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration failed");
|
||||
@@ -84,7 +84,7 @@ public class MerchantRegistrationService {
|
||||
AuthAccount account = new AuthAccount();
|
||||
account.setId(accountId);
|
||||
account.setUsername(request.username());
|
||||
account.setPhone(request.legalPhone());
|
||||
account.setPhone(request.phone());
|
||||
account.setPasswordHash(passwordHash);
|
||||
account.setDisplayName(request.displayName());
|
||||
account.setAccountStatus("ACTIVE");
|
||||
|
||||
@@ -7,11 +7,21 @@ import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class LoginIdentifierContractTest {
|
||||
|
||||
@Test
|
||||
void registrationExposesAnAccountPhoneInsteadOfALegalPersonPhoneAlias() {
|
||||
var componentNames = Arrays.stream(MerchantRegisterRequest.class.getRecordComponents())
|
||||
.map(component -> component.getName())
|
||||
.toList();
|
||||
|
||||
assertThat(componentNames).contains("phone").doesNotContain("legalPhone");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mainlandMobileRuleRejectsPlaceholderNumbersAcrossJavaAndSql() throws Exception {
|
||||
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.CreateAccountRequest;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountProfileRequest;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountPhoneRequest;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountStatusRequest;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountScopeRequest;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.ChangeLoginIdentifiersRequest;
|
||||
@@ -173,6 +174,17 @@ class AccountManagementServiceTest {
|
||||
verify(sessions).revokeAll(10L);
|
||||
}
|
||||
|
||||
@Test void managedPhoneChangeOrRemovalRevokesOnlyThatAccountsSessions() {
|
||||
account.setPhone("13800138000");
|
||||
|
||||
service.updatePhone(10L, new UpdateAccountPhoneRequest(null));
|
||||
|
||||
assertEquals(null, account.getPhone());
|
||||
assertEquals(3, account.getTokenVersion());
|
||||
verify(repository).save(account);
|
||||
verify(sessions).revokeAll(10L);
|
||||
}
|
||||
|
||||
@Test void duplicatePhoneDoesNotChangeLoginIdentifiers() {
|
||||
account.setUsername("old.user");
|
||||
account.setPhone("13800138000");
|
||||
|
||||
@@ -54,6 +54,8 @@ class MerchantRegistrationServiceTest {
|
||||
assertEquals(30001L, storedAccount.getValue().getDefaultMembershipId());
|
||||
assertEquals("MERCHANT", storedAccount.getValue().getDefaultAppCode());
|
||||
assertEquals("ACTIVE", storedAccount.getValue().getAccountStatus());
|
||||
assertEquals("13800138000", storedAccount.getValue().getPhone());
|
||||
assertEquals("13800138000", onboardingRequest.getValue().administratorPhone());
|
||||
assertEquals("encoded-password", storedAccount.getValue().getPasswordHash());
|
||||
assertEquals("LOGIN_AND_COMPLETE_QUALIFICATIONS", result.nextStep());
|
||||
assertEquals("PENDING", result.dimensionInitializationStatus());
|
||||
|
||||
+19
@@ -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<OrderChannelChangePricing.Response> 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<OrderChannelChangePricing.Response> 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<PriceBookChannelValidation.Response> validateChannel(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -119,6 +119,19 @@ public class FbaPricingRepository {
|
||||
.eq(InboundPricingEntity::getId, inboundId));
|
||||
}
|
||||
|
||||
public InboundPricingEntity findInboundByOrderForUpdate(long tenantId, long orderId) {
|
||||
return inbounds.selectOne(new LambdaQueryWrapper<InboundPricingEntity>()
|
||||
.eq(InboundPricingEntity::getTenantId, tenantId)
|
||||
.eq(InboundPricingEntity::getOrderId, orderId)
|
||||
.last("FOR UPDATE"));
|
||||
}
|
||||
|
||||
public InboundPricingEntity findInboundByOrder(long tenantId, long orderId) {
|
||||
return inbounds.selectOne(new LambdaQueryWrapper<InboundPricingEntity>()
|
||||
.eq(InboundPricingEntity::getTenantId, tenantId)
|
||||
.eq(InboundPricingEntity::getOrderId, orderId));
|
||||
}
|
||||
|
||||
public List<CustomerChargeRuleEntity> matchRules(InboundPricingEntity inbound, LocalDateTime at) {
|
||||
return rules.selectList(new LambdaQueryWrapper<CustomerChargeRuleEntity>()
|
||||
.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<InboundFeeEntity>()
|
||||
.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<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
|
||||
.eq(InboundPricingEntity::getTenantId, inbound.getTenantId())
|
||||
.eq(InboundPricingEntity::getId, inbound.getId())
|
||||
.in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED", "VOIDED"))
|
||||
.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<InboundFeeItemEntity>()
|
||||
.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<InboundPricingIdempotencyEntity>()
|
||||
.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<InboundPricingIdempotencyEntity>()
|
||||
.eq(InboundPricingIdempotencyEntity::getTenantId, value.getTenantId())
|
||||
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
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())
|
||||
&& !"VOIDED".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);
|
||||
}
|
||||
}
|
||||
@@ -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 并可重新计价;实际净重、实际体积和测量审核事实保持不变。
|
||||
已确认、已发送或已发布费用一律拒绝,不能由本命令覆盖;费用正式作废为 VOIDED 后可重新计价。
|
||||
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, VOIDED]}
|
||||
calculatedFeeWillBeInvalidated: {type: boolean}
|
||||
invalidatedFeeId: {type: integer, format: int64, nullable: true}
|
||||
UnifiedMainStatus:
|
||||
type: string
|
||||
enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING, LOADED, DELIVERED]
|
||||
|
||||
@@ -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 模式仅校验渠道可用性");
|
||||
}
|
||||
|
||||
+164
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,15 +96,15 @@ public class PlatformTenantController {
|
||||
return ApiResponse.success(service.dimensionInitializationStatus(tenantId));
|
||||
}
|
||||
|
||||
@GetMapping("/tenants/contact-phone-availability")
|
||||
@PreAuthorize("hasAnyAuthority('platform:customer:create','platform:customer:edit')")
|
||||
@Operation(summary = "校验客户联系电话唯一性")
|
||||
public ApiResponse<Boolean> contactPhoneAvailability(
|
||||
@RequestParam String contactPhone,
|
||||
@RequestParam(required = false) Long excludeTenantId,
|
||||
@GetMapping("/tenants/administrator-phone-availability")
|
||||
@PreAuthorize("hasAuthority('platform:customer:create')")
|
||||
@Operation(summary = "校验首个管理员登录手机号唯一性",
|
||||
description = "仅用于创建客户前校验 Auth 登录手机号;企业联系电话本身不要求唯一。")
|
||||
public ApiResponse<Boolean> administratorPhoneAvailability(
|
||||
@RequestParam @Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
TenantGuard.tenant(user, "PLATFORM");
|
||||
return ApiResponse.success(service.contactPhoneAndUsernameAvailable(contactPhone, excludeTenantId));
|
||||
return ApiResponse.success(service.administratorPhoneAvailable(phone));
|
||||
}
|
||||
|
||||
@GetMapping("/tenants/{tenantId:\\d+}/members")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.baiyue.iam.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
@@ -18,6 +19,7 @@ public final class MemberDtos {
|
||||
@NotBlank @Size(min = 8, max = 64)
|
||||
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password,
|
||||
@NotBlank @Size(max = 64) String displayName,
|
||||
@Schema(description = "成员登录手机号;同步 Auth 且与公司联系电话独立")
|
||||
@Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
|
||||
@Email @Size(max = 128) String email,
|
||||
@NotBlank String memberType,
|
||||
@@ -30,6 +32,7 @@ public final class MemberDtos {
|
||||
|
||||
public record UpdateMemberRequest(
|
||||
@NotBlank @Size(max = 64) String displayName,
|
||||
@Schema(description = "成员登录手机号;变更时同步 Auth、撤销该账号全部会话并审计")
|
||||
@Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
|
||||
@Email @Size(max = 128) String email,
|
||||
@NotBlank String memberType,
|
||||
|
||||
@@ -14,7 +14,7 @@ public final class OnboardingDtos {
|
||||
@NotBlank String displayName,
|
||||
@NotBlank String companyName,
|
||||
@NotBlank String legalPerson,
|
||||
@NotBlank String legalPhone,
|
||||
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String administratorPhone,
|
||||
String contactEmail,
|
||||
String countryCode,
|
||||
String address
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.baiyue.iam.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
@@ -16,6 +17,7 @@ public final class PlatformUserManagementDtos {
|
||||
public record CreateUserRequest(
|
||||
@NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
|
||||
@NotBlank @Size(max = 64) String displayName,
|
||||
@Schema(description = "平台账号登录手机号;与企业联系电话独立")
|
||||
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") String phone,
|
||||
@Email @Size(max = 128) String email,
|
||||
@NotNull Long tenantId,
|
||||
@@ -32,6 +34,7 @@ public final class PlatformUserManagementDtos {
|
||||
|
||||
public record UpdateUserRequest(
|
||||
@NotBlank @Size(max = 64) String displayName,
|
||||
@Schema(description = "平台账号登录手机号;变更时同步 Auth、撤销该账号全部会话并审计")
|
||||
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") String phone,
|
||||
@Email @Size(max = 128) String email,
|
||||
@NotNull Long tenantId,
|
||||
|
||||
@@ -27,8 +27,8 @@ public final class TenantDtos {
|
||||
@Schema(description = "Legal entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName,
|
||||
@Schema(description = "Legal representative / 法定代表人", example = "张三") @NotBlank @Size(max = 64) String legalRepresentative,
|
||||
@Schema(description = "Primary contact / 联系人", example = "张三") @NotBlank String contactName,
|
||||
@Schema(description = "Contact phone and administrator username / 联系电话及管理员用户名", example = "13800138000")
|
||||
@NotBlank @Pattern(regexp = "[0-9-]{4,32}") String contactPhone,
|
||||
@Schema(description = "Company contact phone; also initializes the first administrator login phone at creation, then maintained independently / 公司联系电话;创建时同时初始化首个管理员登录手机号,之后独立维护", example = "13800138000")
|
||||
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String contactPhone,
|
||||
@Schema(description = "Contact email / 联系邮箱", example = "ops@example.com") @Email String contactEmail,
|
||||
@NotBlank @Size(max = 64) String servicePackage,
|
||||
@Size(max = 64) String contactWechat,
|
||||
@@ -48,7 +48,8 @@ public final class TenantDtos {
|
||||
@Schema(description = "Legal entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName,
|
||||
@Schema(description = "Legal representative / 法定代表人", example = "张三") @NotBlank @Size(max = 64) String legalRepresentative,
|
||||
@Schema(description = "Primary contact / 联系人", example = "李四") @NotBlank String contactName,
|
||||
@Schema(description = "Contact phone / 联系电话", example = "13900139000") @NotBlank String contactPhone,
|
||||
@Schema(description = "Company contact phone, never used to resolve login identity / 公司联系电话,不作为登录标识", example = "0755-12345678")
|
||||
@NotBlank @Pattern(regexp = "[0-9-]{4,32}") String contactPhone,
|
||||
@Schema(description = "Contact email / 联系邮箱", example = "service@example.com") @Email String contactEmail
|
||||
) { }
|
||||
|
||||
@@ -59,6 +60,7 @@ public final class TenantDtos {
|
||||
@NotBlank String legalName,
|
||||
@NotBlank @Size(max = 64) String legalRepresentative,
|
||||
@NotBlank String contactName,
|
||||
@Schema(description = "Company contact phone, independent from every member login phone / 公司联系电话,与所有成员登录手机号独立")
|
||||
@NotBlank @Pattern(regexp = "[0-9-]{4,32}") String contactPhone,
|
||||
@Email String contactEmail,
|
||||
@NotBlank @Size(max = 64) String servicePackage,
|
||||
@@ -120,14 +122,14 @@ public final class TenantDtos {
|
||||
public record ApprovalResponse(
|
||||
@Schema(description = "Tenant ID / 企业租户 ID", example = "190000000000000001") Long tenantId,
|
||||
@Schema(description = "Whether this approval created the administrator / 本次审核是否新建管理员") boolean administratorCreated,
|
||||
@Schema(description = "Contact phone used as login username; null when an owner already exists / 使用联系电话作为登录账号,已有所有者时为空", nullable = true) String administratorUsername,
|
||||
@Schema(description = "Generated first-administrator username; independent from company contact phone / 自动生成的首个管理员用户名,与公司联系电话独立", nullable = true) String administratorUsername,
|
||||
@Schema(description = "One-time temporary password; only returned when created / 一次性初始密码,仅新建时返回", format = "password", nullable = true) String temporaryPassword
|
||||
) { }
|
||||
|
||||
@Schema(name = "TenantCreationResponse", description = "Created enterprise draft and one-time administrator credentials / 新建企业草稿及一次性管理员凭据")
|
||||
public record CreationResponse(
|
||||
@Schema(description = "Created enterprise draft / 新建企业草稿") TenantResponse tenant,
|
||||
@Schema(description = "Contact phone used as login username / 使用联系电话作为登录账号") String administratorUsername,
|
||||
@Schema(description = "Generated administrator username, independent from company contact phone / 自动生成的管理员用户名,与公司联系电话独立") String administratorUsername,
|
||||
@Schema(description = "One-time temporary password / 一次性初始密码", format = "password") String temporaryPassword
|
||||
) { }
|
||||
|
||||
|
||||
@@ -35,19 +35,6 @@ public class TenantRepository {
|
||||
.ne(IamTenant::getId, tenantId)) > 0;
|
||||
}
|
||||
|
||||
public boolean existsByContactPhone(String contactPhone) {
|
||||
return tenantMapper.selectCount(new LambdaQueryWrapper<IamTenant>()
|
||||
.eq(IamTenant::getTenantType, "MERCHANT")
|
||||
.eq(IamTenant::getContactPhone, contactPhone)) > 0;
|
||||
}
|
||||
|
||||
public boolean existsByContactPhoneForOtherTenant(String contactPhone, long tenantId) {
|
||||
return tenantMapper.selectCount(new LambdaQueryWrapper<IamTenant>()
|
||||
.eq(IamTenant::getTenantType, "MERCHANT")
|
||||
.eq(IamTenant::getContactPhone, contactPhone)
|
||||
.ne(IamTenant::getId, tenantId)) > 0;
|
||||
}
|
||||
|
||||
public IamTenant findTenant(Long tenantId) { return tenantMapper.selectById(tenantId); }
|
||||
public List<IamTenant> findMerchantsByIds(Collection<Long> tenantIds) {
|
||||
if (tenantIds == null || tenantIds.isEmpty()) return List.of();
|
||||
|
||||
@@ -74,7 +74,7 @@ public class MerchantOnboardingService {
|
||||
tenant.setLegalName(request.companyName());
|
||||
tenant.setLegalRepresentative(request.legalPerson());
|
||||
tenant.setContactName(request.displayName());
|
||||
tenant.setContactPhone(request.legalPhone());
|
||||
tenant.setContactPhone(request.administratorPhone());
|
||||
tenant.setContactEmail(request.contactEmail());
|
||||
tenant.setDeleted(false);
|
||||
tenant.setVersion(0);
|
||||
@@ -86,7 +86,7 @@ public class MerchantOnboardingService {
|
||||
user.setAuthUserId(request.authUserId());
|
||||
user.setUserType("ENTERPRISE");
|
||||
user.setDisplayName(request.displayName());
|
||||
user.setPhone(request.legalPhone());
|
||||
user.setPhone(request.administratorPhone());
|
||||
user.setEmail(request.contactEmail());
|
||||
user.setStatus("ACTIVE");
|
||||
userMapper.insert(user);
|
||||
|
||||
+13
-17
@@ -88,7 +88,8 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
|
||||
long userId = IdWorker.getId();
|
||||
long membershipId = IdWorker.getId();
|
||||
String password = temporaryPassword();
|
||||
String username = createAuthAccountWithUniqueUsername(authUserId, password, tenant, membershipId);
|
||||
String username = createAuthAccount(authUserId, password, tenant, membershipId,
|
||||
tenant.getContactPhone(), tenant.getContactName());
|
||||
ProvisioningResult result = ProvisioningResult.created(authUserId, username, password);
|
||||
|
||||
try {
|
||||
@@ -153,8 +154,8 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
|
||||
AuthAccountClient.AccountSummary account = findAuthAccount(user.getAuthUserId());
|
||||
if (account == null) {
|
||||
String password = temporaryPassword();
|
||||
String username = createAuthAccountWithUniqueUsername(user.getAuthUserId(), password,
|
||||
tenant, owner.getId());
|
||||
String username = createAuthAccount(user.getAuthUserId(), password, tenant, owner.getId(),
|
||||
user.getPhone(), user.getDisplayName());
|
||||
publishInitialSecurityProjection(user.getAuthUserId(), tenant.getId());
|
||||
return ProvisioningResult.created(user.getAuthUserId(), username, password);
|
||||
}
|
||||
@@ -204,19 +205,13 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
|
||||
if (result.created()) compensate(result.authUserId());
|
||||
}
|
||||
|
||||
private String createAuthAccountWithUniqueUsername(long authUserId, String password,
|
||||
IamTenant tenant, long membershipId) {
|
||||
String username = username(tenant.getContactPhone());
|
||||
createAuthAccount(authUserId, username, password, tenant, membershipId);
|
||||
return username;
|
||||
}
|
||||
|
||||
private void createAuthAccount(long authUserId, String username, String password,
|
||||
IamTenant tenant, long membershipId) {
|
||||
private String createAuthAccount(long authUserId, String password, IamTenant tenant, long membershipId,
|
||||
String loginPhone, String displayName) {
|
||||
String username = generatedUsername(authUserId);
|
||||
try {
|
||||
ApiResponse<AuthAccountClient.AccountSummary> response = authClient.create(internalToken,
|
||||
new AuthAccountClient.CreateAccountRequest(authUserId, username, tenant.getContactPhone(), password,
|
||||
tenant.getContactName(), tenant.getId(), membershipId, "MERCHANT", "ACTIVE"));
|
||||
new AuthAccountClient.CreateAccountRequest(authUserId, username, loginPhone, password,
|
||||
displayName, tenant.getId(), membershipId, "MERCHANT", "ACTIVE"));
|
||||
if (response == null) throw unavailable();
|
||||
if (!response.successful()) {
|
||||
ResultCode code = ResultCode.of(response.code());
|
||||
@@ -224,9 +219,10 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
|
||||
? code.message() : response.message());
|
||||
}
|
||||
if (response.data() == null) throw unavailable();
|
||||
return username;
|
||||
} catch (FeignException exception) {
|
||||
if (exception.status() == 409) {
|
||||
throw new BizException(ResultCode.CONFLICT, "联系电话已被其他账号使用,不能作为企业管理员登录用户名");
|
||||
throw new BizException(ResultCode.CONFLICT, "管理员用户名或登录手机号已被其他账号使用");
|
||||
}
|
||||
throw unavailable();
|
||||
} catch (BizException exception) {
|
||||
@@ -267,8 +263,8 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
|
||||
return new String(value);
|
||||
}
|
||||
|
||||
private String username(String contactPhone) {
|
||||
return contactPhone == null ? "" : contactPhone.trim();
|
||||
private String generatedUsername(long authUserId) {
|
||||
return "merchant_" + authUserId;
|
||||
}
|
||||
|
||||
private char pick(char[] source) { return source[random.nextInt(source.length)]; }
|
||||
|
||||
@@ -162,7 +162,7 @@ public class TenantService {
|
||||
@Transactional
|
||||
public CreationResponse register(RegisterRequest request, long operatorId) {
|
||||
String contactPhone = normalizeContactPhone(request.contactPhone());
|
||||
ensureContactPhoneAvailable(contactPhone, null);
|
||||
ensureAdministratorPhoneAvailable(contactPhone);
|
||||
IamTenant tenant = new IamTenant();
|
||||
tenant.setId(IdWorker.getId());
|
||||
tenant.setTenantCode("TEN" + tenant.getId());
|
||||
@@ -364,24 +364,17 @@ public class TenantService {
|
||||
return dimensionRuleInitialization.status(tenantId);
|
||||
}
|
||||
|
||||
public boolean contactPhoneAvailable(String contactPhone, Long tenantId) {
|
||||
String normalized = normalizeContactPhone(contactPhone);
|
||||
return tenantId == null
|
||||
? !repository.existsByContactPhone(normalized)
|
||||
: !repository.existsByContactPhoneForOtherTenant(normalized, tenantId);
|
||||
}
|
||||
|
||||
public boolean contactPhoneAndUsernameAvailable(String contactPhone, Long tenantId) {
|
||||
String normalized = normalizeContactPhone(contactPhone);
|
||||
if (!contactPhoneAvailable(normalized, tenantId)) return false;
|
||||
/** Platform creation uses the same initial value for company contact and owner login phone. */
|
||||
public boolean administratorPhoneAvailable(String phone) {
|
||||
String normalized = normalizeContactPhone(phone);
|
||||
ApiResponse<Boolean> response;
|
||||
try {
|
||||
response = authClient.availability(internalToken, normalized);
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用,不能校验联系电话");
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用,不能校验管理员登录手机号");
|
||||
}
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用,不能校验联系电话");
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用,不能校验管理员登录手机号");
|
||||
}
|
||||
return response.data();
|
||||
}
|
||||
@@ -393,13 +386,6 @@ public class TenantService {
|
||||
IamTenant tenant = requireMerchant(tenantId);
|
||||
ensureNotArchived(tenant);
|
||||
String contactPhone = normalizeContactPhone(request.contactPhone());
|
||||
String existingContactPhone = normalizeContactPhone(tenant.getContactPhone());
|
||||
if (!contactPhone.equals(existingContactPhone)) {
|
||||
if ("ACTIVE".equals(tenant.getStatus()) || "DISABLED".equals(tenant.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "客户已开户,联系电话作为登录用户名不能直接修改");
|
||||
}
|
||||
ensureContactPhoneAvailable(contactPhone, tenantId);
|
||||
}
|
||||
tenant.setTenantName(request.tenantName());
|
||||
tenant.setLegalName(request.legalName());
|
||||
tenant.setLegalRepresentative(request.legalRepresentative());
|
||||
@@ -446,7 +432,6 @@ public class TenantService {
|
||||
if (repository.findPendingReviewByTenant(tenantId) != null) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Enterprise information is already under review / 客户资料已在审核中");
|
||||
}
|
||||
ensureContactPhoneAvailable(normalizeContactPhone(tenant.getContactPhone()), tenantId);
|
||||
if (tenant.getUnifiedSocialCreditCode() == null
|
||||
|| !tenant.getUnifiedSocialCreditCode().matches("[0-9A-Z]{18}")) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST,
|
||||
@@ -477,7 +462,7 @@ public class TenantService {
|
||||
tenant.setLegalName(request.legalName());
|
||||
tenant.setLegalRepresentative(request.legalRepresentative());
|
||||
tenant.setContactName(request.contactName());
|
||||
tenant.setContactPhone(request.contactPhone());
|
||||
tenant.setContactPhone(normalizeContactPhone(request.contactPhone()));
|
||||
tenant.setContactEmail(request.contactEmail());
|
||||
repository.saveTenant(tenant);
|
||||
return merchantVisibleResponse(tenant);
|
||||
@@ -497,9 +482,9 @@ public class TenantService {
|
||||
return contactPhone == null ? "" : contactPhone.trim();
|
||||
}
|
||||
|
||||
private void ensureContactPhoneAvailable(String contactPhone, Long tenantId) {
|
||||
if (!contactPhoneAvailable(contactPhone, tenantId)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "联系电话已被其他客户使用,不能作为登录用户名");
|
||||
private void ensureAdministratorPhoneAvailable(String phone) {
|
||||
if (!administratorPhoneAvailable(phone)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "管理员登录手机号已被其他账号使用");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant (
|
||||
legal_representative VARCHAR(64) NULL COMMENT '法定代表人',
|
||||
unified_social_credit_code VARCHAR(32) NULL COMMENT '统一社会信用代码',
|
||||
contact_name VARCHAR(64) NULL COMMENT '企业联系人',
|
||||
contact_phone VARCHAR(32) NULL COMMENT '联系电话',
|
||||
contact_phone VARCHAR(32) NULL COMMENT '公司联系电话,非登录标识且不要求跨企业唯一',
|
||||
contact_email VARCHAR(128) NULL COMMENT '联系邮箱',
|
||||
service_package VARCHAR(64) NOT NULL DEFAULT '基础版' COMMENT '客户服务套餐',
|
||||
contact_wechat VARCHAR(64) NULL COMMENT '联系微信',
|
||||
@@ -119,7 +119,7 @@ CREATE TABLE IF NOT EXISTS iam_user (
|
||||
auth_user_id BIGINT NOT NULL COMMENT '认证服务账号逻辑ID',
|
||||
user_type VARCHAR(20) NOT NULL DEFAULT 'ENTERPRISE' COMMENT '用户类型:ENTERPRISE企业用户、PLATFORM平台用户',
|
||||
display_name VARCHAR(64) NOT NULL COMMENT '用户展示名称',
|
||||
phone VARCHAR(32) NULL COMMENT '手机号',
|
||||
phone VARCHAR(32) NULL COMMENT '用户登录手机号,与企业联系电话独立',
|
||||
email VARCHAR(128) NULL COMMENT '邮箱',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '用户资料状态:ACTIVE正常、DISABLED停用',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
|
||||
@@ -67,16 +67,16 @@ paths:
|
||||
'401': {$ref: '#/components/responses/Unauthorized'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
'409': {$ref: '#/components/responses/Conflict'}
|
||||
/api/platform/v1/iam/tenants/contact-phone-availability:
|
||||
/api/platform/v1/iam/tenants/administrator-phone-availability:
|
||||
get:
|
||||
tags: [Platform Tenant / 平台租户]
|
||||
summary: 校验客户联系电话唯一性
|
||||
summary: 校验首个管理员登录手机号唯一性
|
||||
description: 仅在创建客户前校验 Auth 登录手机号;公司联系电话不要求跨企业唯一。
|
||||
parameters:
|
||||
- {name: contactPhone, in: query, required: true, schema: {type: string}}
|
||||
- {name: excludeTenantId, in: query, required: false, schema: {type: integer, format: int64}}
|
||||
- {name: phone, in: query, required: true, schema: {type: string, pattern: '^1[3-9]\d{9}$'}}
|
||||
responses:
|
||||
'200':
|
||||
description: 返回联系电话是否可作为登录用户名
|
||||
description: 返回手机号是否可作为首个管理员登录手机号
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -211,7 +211,7 @@ paths:
|
||||
post:
|
||||
tags: [Platform Tenant / 平台租户]
|
||||
summary: 提交企业客户审核
|
||||
description: 校验营业执照和法人身份证已上传后创建审核单,并把客户状态改为 `PENDING`;保存资料和上传文件均不会自动调用本接口。
|
||||
description: 校验营业执照和统一社会信用代码后创建审核单,并把客户状态改为 `PENDING`;法人身份证选填,保存资料和上传文件均不会自动调用本接口。
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/TenantSuccess'}
|
||||
'400': {$ref: '#/components/responses/BadRequest'}
|
||||
@@ -683,7 +683,7 @@ paths:
|
||||
post:
|
||||
tags: [IAM Resources / IAM 资源]
|
||||
summary: Submit enterprise qualifications for review / 提交企业资质审核
|
||||
description: Uploading or replacing a qualification keeps the tenant in draft state. This endpoint creates the review after the business license and legal-person ID have been provided; the freight-forwarder certificate and bank account license are optional. / 上传或替换资质不会自动送审;营业执照和法人身份证齐全后由此接口创建审核单,货代资质证书和银行开户许可证选填。
|
||||
description: Uploading or replacing a qualification keeps the tenant in draft state. This endpoint requires only the business license and unified social credit code; legal-person ID, freight-forwarder certificate and bank account license are optional. / 上传或替换资质不会自动送审;本接口只要求营业执照和统一社会信用代码,法人身份证、货代资质证书和银行开户许可证选填。
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/TenantSuccess'}
|
||||
'400': {$ref: '#/components/responses/BadRequest'}
|
||||
@@ -813,8 +813,8 @@ paths:
|
||||
/internal/iam/required-qualification-packages:
|
||||
post:
|
||||
tags: [IAM Resources / IAM 资源]
|
||||
summary: 商户必填资质包登记
|
||||
description: 仅供 File Service 调用;在一个 IAM 事务中登记营业执照、统一社会信用代码和法人身份证。
|
||||
summary: 商户营业执照资质包登记
|
||||
description: 仅供 File Service 调用;在一个 IAM 事务中登记必填营业执照与统一社会信用代码,法人身份证可选。
|
||||
security: []
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/InternalServiceToken'}
|
||||
@@ -833,8 +833,8 @@ paths:
|
||||
/internal/iam/platform-required-qualification-packages:
|
||||
post:
|
||||
tags: [IAM Resources / IAM 资源]
|
||||
summary: 平台代客户必填资质包登记
|
||||
description: 仅供 File Service 调用;与商户入口使用相同的三项事务保存规则。
|
||||
summary: 平台代客户登记营业执照资质包
|
||||
description: 仅供 File Service 调用;与商户入口使用相同规则,营业执照与统一社会信用代码必填,法人身份证可选。
|
||||
security: []
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/InternalServiceToken'}
|
||||
@@ -1318,7 +1318,7 @@ components:
|
||||
legalName: {type: string, example: 深圳虫虫出海科技有限公司}
|
||||
legalRepresentative: {type: string, maxLength: 64, example: 张三}
|
||||
contactName: {type: string, example: 李四}
|
||||
contactPhone: {type: string, example: '13800138000'}
|
||||
contactPhone: {type: string, pattern: '^1[3-9]\d{9}$', example: '13800138000', description: 创建时同时初始化公司联系电话与首个管理员登录手机号;创建后二者独立维护}
|
||||
contactEmail: {type: string, format: email, example: contact@example.com}
|
||||
contactWechat: {type: string, maxLength: 64}
|
||||
servicePackage: {type: string, maxLength: 64, example: 基础版}
|
||||
@@ -1338,7 +1338,7 @@ components:
|
||||
legalName: {type: string}
|
||||
legalRepresentative: {type: string, maxLength: 64}
|
||||
contactName: {type: string}
|
||||
contactPhone: {type: string}
|
||||
contactPhone: {type: string, pattern: '^[0-9-]{4,32}$', description: 公司联系电话,不作为登录标识且不要求跨企业唯一}
|
||||
contactEmail: {type: string, format: email}
|
||||
UpdateTenantRequest:
|
||||
type: object
|
||||
@@ -1348,7 +1348,7 @@ components:
|
||||
legalName: {type: string}
|
||||
legalRepresentative: {type: string, maxLength: 64}
|
||||
contactName: {type: string}
|
||||
contactPhone: {type: string}
|
||||
contactPhone: {type: string, pattern: '^[0-9-]{4,32}$', description: 公司联系电话,与管理员及成员登录手机号独立且不要求跨企业唯一}
|
||||
contactEmail: {type: string, format: email}
|
||||
contactWechat: {type: string, maxLength: 64}
|
||||
servicePackage: {type: string, maxLength: 64, example: 基础版}
|
||||
@@ -1398,7 +1398,7 @@ components:
|
||||
properties:
|
||||
username: {type: string, pattern: '^[A-Za-z][A-Za-z0-9_.@-]{3,63}$'}
|
||||
displayName: {type: string, maxLength: 64}
|
||||
phone: {type: string, pattern: '^1[3-9]\\d{9}$'}
|
||||
phone: {type: string, pattern: '^1[3-9]\\d{9}$', description: 平台账号登录手机号;修改时同步 Auth、撤销该账号会话并审计}
|
||||
email: {type: string, format: email, maxLength: 128}
|
||||
tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台技术作用域 0}
|
||||
roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}}
|
||||
@@ -1410,7 +1410,7 @@ components:
|
||||
required: [displayName, phone, tenantId, roleIds]
|
||||
properties:
|
||||
displayName: {type: string, maxLength: 64}
|
||||
phone: {type: string, pattern: '^1[3-9]\\d{9}$'}
|
||||
phone: {type: string, pattern: '^1[3-9]\\d{9}$', description: 平台账号登录手机号;与任何企业联系电话无关}
|
||||
email: {type: string, format: email, maxLength: 128}
|
||||
tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台技术作用域 0}
|
||||
roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}}
|
||||
@@ -1424,7 +1424,7 @@ components:
|
||||
username: {type: string, pattern: '^[A-Za-z][A-Za-z0-9_.@-]{3,63}$'}
|
||||
password: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true}
|
||||
displayName: {type: string, maxLength: 64}
|
||||
phone: {type: string, pattern: '^1[3-9]\d{9}$'}
|
||||
phone: {type: string, pattern: '^1[3-9]\d{9}$', description: 企业成员登录手机号;修改时同步 Auth、撤销该账号会话并审计}
|
||||
email: {type: string, format: email, maxLength: 128}
|
||||
memberNo: {type: string, maxLength: 64}
|
||||
roleIds: {type: array, items: {type: integer, format: int64}}
|
||||
@@ -1433,7 +1433,7 @@ components:
|
||||
required: [displayName, memberNo, roleIds]
|
||||
properties:
|
||||
displayName: {type: string, maxLength: 64}
|
||||
phone: {type: string, pattern: '^1[3-9]\d{9}$'}
|
||||
phone: {type: string, pattern: '^1[3-9]\d{9}$', description: 企业成员登录手机号;与公司联系电话无关}
|
||||
email: {type: string, format: email, maxLength: 128}
|
||||
memberNo: {type: string, maxLength: 64}
|
||||
roleIds: {type: array, items: {type: integer, format: int64}}
|
||||
|
||||
@@ -71,6 +71,10 @@ class IamOpenApiContractTest {
|
||||
.contains("UpdateMemberRequest:")
|
||||
.contains("membershipId: {type: integer, format: int64}")
|
||||
.contains("roleIds: {type: array, items: {type: integer, format: int64}}")
|
||||
.contains("/api/platform/v1/iam/tenants/administrator-phone-availability:")
|
||||
.doesNotContain("/api/platform/v1/iam/tenants/contact-phone-availability:")
|
||||
.contains("公司联系电话,不作为登录标识且不要求跨企业唯一")
|
||||
.contains("法人身份证、货代资质证书和银行开户许可证选填")
|
||||
.contains("'422': {$ref: '#/components/responses/UnprocessableEntity'}");
|
||||
|
||||
Map<String, Object> openApi = new Yaml().load(document);
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.iam.repository;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class IdentityContactDecouplingContractTest {
|
||||
|
||||
@Test
|
||||
void baselineKeepsCompanyContactNonUniqueAndUserPhoneAsLoginIdentity() throws Exception {
|
||||
String sql = new ClassPathResource("db/iam.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(sql)
|
||||
.contains("公司联系电话,非登录标识且不要求跨企业唯一")
|
||||
.contains("用户登录手机号,与企业联系电话独立")
|
||||
.doesNotContain("UNIQUE KEY uk_iam_tenant_contact_phone")
|
||||
.doesNotContain("UNIQUE KEY uk_tenant_contact_phone");
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -88,7 +88,7 @@ class TenantAdministratorProvisioningServiceTest {
|
||||
var result = service.initialize(context());
|
||||
|
||||
assertTrue(result.created());
|
||||
assertEquals("13800138000", result.username());
|
||||
assertEquals("merchant_" + result.authUserId(), result.username());
|
||||
assertNotNull(result.temporaryPassword());
|
||||
assertEquals(16, result.temporaryPassword().length());
|
||||
assertTrue(result.temporaryPassword().matches("^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z0-9]+$"));
|
||||
@@ -97,6 +97,7 @@ class TenantAdministratorProvisioningServiceTest {
|
||||
ArgumentCaptor.forClass(AuthAccountClient.CreateAccountRequest.class);
|
||||
verify(auth).create(eq("internal-token"), account.capture());
|
||||
assertEquals(result.username(), account.getValue().username());
|
||||
assertEquals("13800138000", account.getValue().phone());
|
||||
assertEquals(result.temporaryPassword(), account.getValue().password());
|
||||
assertEquals("MERCHANT", account.getValue().appCode());
|
||||
assertEquals(tenant.getId(), account.getValue().tenantId());
|
||||
@@ -243,7 +244,7 @@ class TenantAdministratorProvisioningServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void usernameConflictStopsProvisioningInsteadOfChangingTheContactPhoneUsername() {
|
||||
void loginPhoneConflictStopsProvisioningWithoutCouplingCompanyContactToUsername() {
|
||||
org.mockito.Mockito.doReturn(ApiResponse.failure(com.baiyue.common.core.api.ResultCode.CONFLICT,
|
||||
"登录账号已存在", null, null)).when(auth).create(anyString(), any());
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import com.baiyue.iam.repository.MemberRepository;
|
||||
import com.baiyue.iam.mapper.IamRoleMapper;
|
||||
import com.baiyue.iam.client.AuthAccountClient;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.boot.test.mock.mockito.SpyBean;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
@@ -60,6 +61,12 @@ class TenantServiceTest {
|
||||
@SpyBean private TenantAdministratorProvisioningService provisioning;
|
||||
@Resource private TenantService service;
|
||||
|
||||
@BeforeEach
|
||||
void allowUnusedAdministratorPhone() {
|
||||
when(authClient.availability(eq("internal-token"), any()))
|
||||
.thenReturn(com.baiyue.common.core.api.ApiResponse.success(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pageMerchantsMapsTenantEntitiesToApiResponses() {
|
||||
|
||||
@@ -172,25 +179,25 @@ class TenantServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void activeCustomerCannotChangeTheContactPhoneUsedAsItsLoginUsername() {
|
||||
void activeCustomerCanChangeCompanyContactPhoneWithoutChangingAnyLoginIdentity() {
|
||||
IamTenant tenant = merchant(10001L, "ACTIVE");
|
||||
when(repository.findTenant(10001L)).thenReturn(tenant);
|
||||
|
||||
BizException exception = assertThrows(BizException.class, () -> service.updateMerchant(10001L,
|
||||
var updated = service.updateMerchant(10001L,
|
||||
new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13900139000", null,
|
||||
"基础版", null, null, 30, null, null, null, "CNY", null, null)));
|
||||
"基础版", null, null, 30, null, null, null, "CNY", null, null));
|
||||
|
||||
assertEquals(ResultCode.CONFLICT, exception.getResultCode());
|
||||
assertEquals("13800138000", tenant.getContactPhone());
|
||||
verify(repository, never()).saveTenant(tenant);
|
||||
assertEquals("13900139000", updated.contactPhone());
|
||||
verify(repository).saveTenant(tenant);
|
||||
verify(authClient, never()).phone(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void contactPhoneAvailabilityAlsoChecksTheAuthUsernameFactSource() {
|
||||
void administratorPhoneAvailabilityChecksOnlyTheAuthLoginFactSource() {
|
||||
when(authClient.availability("internal-token", "13800138000"))
|
||||
.thenReturn(com.baiyue.common.core.api.ApiResponse.success(false));
|
||||
|
||||
assertEquals(false, service.contactPhoneAndUsernameAvailable("13800138000", null));
|
||||
assertEquals(false, service.administratorPhoneAvailable("13800138000"));
|
||||
verify(authClient).availability("internal-token", "13800138000");
|
||||
}
|
||||
|
||||
@@ -303,7 +310,7 @@ class TenantServiceTest {
|
||||
@Test
|
||||
void platformRegistrationCreatesDraftWithoutCreatingReviewTicket() {
|
||||
doReturn(TenantAdministratorProvisioningService.ProvisioningResult.created(
|
||||
81L, "13800138000", "Password1234"))
|
||||
81L, "merchant_81", "Password1234"))
|
||||
.when(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L));
|
||||
|
||||
var created = service.register(new RegisterRequest(
|
||||
@@ -316,7 +323,7 @@ class TenantServiceTest {
|
||||
assertEquals("PENDING", created.tenant().dimensionInitializationStatus());
|
||||
assertEquals("Bob", created.tenant().legalRepresentative());
|
||||
assertEquals("基础版", created.tenant().servicePackage());
|
||||
assertEquals("13800138000", created.administratorUsername());
|
||||
assertEquals("merchant_81", created.administratorUsername());
|
||||
assertEquals("Password1234", created.temporaryPassword());
|
||||
verify(repository).insertTenant(argThat(tenant -> Long.valueOf(9001L).equals(tenant.getCreatedBy())
|
||||
&& Long.valueOf(9001L).equals(tenant.getUpdatedBy())));
|
||||
@@ -342,7 +349,7 @@ class TenantServiceTest {
|
||||
@Test
|
||||
void platformRegistrationAlwaysCreatesTheDraftWithoutACreditCode() {
|
||||
doReturn(TenantAdministratorProvisioningService.ProvisioningResult.created(
|
||||
81L, "13800138000", "Password1234"))
|
||||
81L, "merchant_81", "Password1234"))
|
||||
.when(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L));
|
||||
|
||||
var created = service.register(new RegisterRequest(
|
||||
|
||||
@@ -36,12 +36,22 @@ public interface FirstlegInboundClient {
|
||||
ApiResponse<Void> updateDeclaration(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody OrderDeclarationDtos.UpdatedEvent event);
|
||||
|
||||
@PostMapping("/order-channel-changes/validate")
|
||||
ApiResponse<OrderChannelChangeValidationResponse> validateOrderChannelChange(
|
||||
@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody OrderChannelChangeValidationRequest request);
|
||||
|
||||
record InboundReferenceRequest(long tenantId, List<Long> orderIds) {
|
||||
}
|
||||
|
||||
record InboundReference(Long orderId, Long inboundId, String inboundCode, String processStatus) {
|
||||
}
|
||||
|
||||
record OrderChannelChangeValidationRequest(long tenantId, long orderId, long orderVersion,
|
||||
String channelCode) {}
|
||||
record OrderChannelChangeValidationResponse(long orderId, long orderVersion,
|
||||
String channelCode, String mainStatus) {}
|
||||
|
||||
record OrderApprovedInboundCommand(
|
||||
long tenantId, long orderId, String orderNo, String inboundCode, Long customerId, String customerName,
|
||||
Long warehouseId, String warehouseName, Long deliveryWarehouseId, String deliveryWarehouseName,
|
||||
|
||||
@@ -13,7 +13,24 @@ public interface FirstlegPricingClient {
|
||||
ApiResponse<ChannelValidationResponse> validateChannel(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody ChannelValidationRequest request);
|
||||
|
||||
@PostMapping("/order-channel-changes/validate")
|
||||
ApiResponse<OrderChannelChangeResponse> validateOrderChannelChange(
|
||||
@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody OrderChannelChangeRequest request);
|
||||
|
||||
@PostMapping("/order-channel-changes/apply")
|
||||
ApiResponse<OrderChannelChangeResponse> applyOrderChannelChange(
|
||||
@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody OrderChannelChangeRequest request);
|
||||
|
||||
record ChannelValidationRequest(long tenantId, long originWarehouseId, String channelCode,
|
||||
String destinationType, boolean requireInboundCodePrefix) {}
|
||||
record ChannelValidationResponse(String channelCode, String inboundCodePrefix) {}
|
||||
record OrderChannelChangeRequest(long tenantId, long orderId, long orderVersion,
|
||||
long originWarehouseId, String channelCode, String channelName,
|
||||
String destinationType, Long destinationWarehouseId,
|
||||
String destinationCode, String pricingTaxMode) {}
|
||||
record OrderChannelChangeResponse(long inboundId, long orderId, long orderVersion,
|
||||
String channelCode, Long priceBookVersionId, String feeStatus,
|
||||
boolean calculatedFeeWillBeInvalidated, Long invalidatedFeeId) {}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ public class OrderDeclarationUpdateService {
|
||||
|
||||
@Resource private OrderRepository repository;
|
||||
@Resource private OrderPricingChannelGuard pricingChannelGuard;
|
||||
@Resource private UnifiedOrderQueryService unifiedOrders;
|
||||
@Resource private OrderFulfillmentChannelChangeGuard fulfillmentChannelGuard;
|
||||
@Resource private ObjectMapper json;
|
||||
|
||||
@Transactional
|
||||
@@ -70,12 +70,16 @@ public class OrderDeclarationUpdateService {
|
||||
boolean channelChangedNow = !Objects.equals(channelCode, order.getChannelCode())
|
||||
|| !Objects.equals(channelName, order.getChannelName());
|
||||
if (channelChangedNow) {
|
||||
pricingChannelGuard.requireAvailable(tenantId, order.getReceivingWarehouseId(), channelCode,
|
||||
order.getDestinationType());
|
||||
if (order.getStatus() == OrderStatus.APPROVED) {
|
||||
long nextVersion = (long) request.expectedVersion() + 1;
|
||||
fulfillmentChannelGuard.requireEligible(order, nextVersion, channelCode);
|
||||
pricingChannelGuard.requireOrderChannelChangeEligible(order, nextVersion, channelCode, channelName);
|
||||
} else {
|
||||
pricingChannelGuard.requireAvailable(tenantId, order.getReceivingWarehouseId(), channelCode,
|
||||
order.getDestinationType());
|
||||
}
|
||||
}
|
||||
|
||||
if (order.getStatus() == OrderStatus.APPROVED) requireReversibleFulfillment(tenantId, orderId);
|
||||
|
||||
List<OrderDeclarationDtos.FieldChange> changes = new ArrayList<>();
|
||||
change(changes, null, "customerRemark", order.getCustomerRemark(), request.customerRemark());
|
||||
change(changes, null, "expectedArrivalAt", order.getExpectedArrivalAt(), request.expectedArrivalAt());
|
||||
@@ -109,17 +113,6 @@ public class OrderDeclarationUpdateService {
|
||||
request.expectedVersion(), version, changedBefore, channelChanged, List.copyOf(changes));
|
||||
}
|
||||
|
||||
private void requireReversibleFulfillment(long tenantId, long orderId) {
|
||||
var row = unifiedOrders.platformDetail(tenantId, orderId).summary();
|
||||
boolean hasIrreversibleFact = row.measurementCompletedAt() != null
|
||||
|| row.preload() != null || row.loading() != null || row.coLoadGroup() != null
|
||||
|| row.feeReviewStatus() != null && !"UNRECORDED".equals(row.feeReviewStatus());
|
||||
if (hasIrreversibleFact || !"WAIT_MEASURE".equals(row.mainStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT,
|
||||
"订单已产生测量、分柜、装柜或计费事实,不能修改客户申报资料");
|
||||
}
|
||||
}
|
||||
|
||||
private void collect(List<OrderDeclarationDtos.FieldChange> changes, OrderCargoEntity before,
|
||||
OrderDeclarationDtos.Cargo after) {
|
||||
int item = after.itemNo();
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.baiyue.order.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.client.FirstlegInboundClient;
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import feign.FeignException;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** 审核后渠道变更的 Firstleg 同柜、分柜、预装及装柜可逆性门禁。 */
|
||||
@Service
|
||||
public class OrderFulfillmentChannelChangeGuard {
|
||||
@Resource private FirstlegInboundClient client;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
|
||||
public void requireEligible(OrderEntity order, long nextOrderVersion, String channelCode) {
|
||||
try {
|
||||
ApiResponse<FirstlegInboundClient.OrderChannelChangeValidationResponse> response =
|
||||
client.validateOrderChannelChange(internalToken,
|
||||
new FirstlegInboundClient.OrderChannelChangeValidationRequest(
|
||||
order.getTenantId(), order.getId(), nextOrderVersion, channelCode));
|
||||
if (response == null) throw unavailable();
|
||||
if (!response.successful()) {
|
||||
ResultCode code = ResultCode.of(response.code());
|
||||
String message = response.message() == null || response.message().isBlank()
|
||||
? code.message() : response.message();
|
||||
throw new BizException(code, message);
|
||||
}
|
||||
var data = response.data();
|
||||
if (data == null || data.orderId() != order.getId()
|
||||
|| data.orderVersion() != nextOrderVersion || !channelCode.equals(data.channelCode())) {
|
||||
throw unavailable();
|
||||
}
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (FeignException exception) {
|
||||
throw unavailable();
|
||||
} catch (RuntimeException exception) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
private BizException unavailable() {
|
||||
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程履约渠道校验服务暂时不可用");
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import jakarta.annotation.Resource;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.order.client.FirstlegInboundClient;
|
||||
import com.baiyue.order.client.FirstlegPricingClient;
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import com.baiyue.order.entity.OutboxEventEntity;
|
||||
import com.baiyue.order.dto.OrderDeclarationDtos;
|
||||
import com.baiyue.order.repository.OrderRepository;
|
||||
@@ -21,6 +23,7 @@ import java.time.LocalDateTime;
|
||||
public class OrderInboundOutboxPublisher {
|
||||
@Resource private OrderRepository repository;
|
||||
@Resource private FirstlegInboundClient client;
|
||||
@Resource private FirstlegPricingClient pricingClient;
|
||||
@Resource private ObjectMapper json;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
|
||||
@@ -91,6 +94,25 @@ public class OrderInboundOutboxPublisher {
|
||||
failPermanently(event, "declaration event ownership does not match payload");
|
||||
return;
|
||||
}
|
||||
if (containsChannelChange(command)) {
|
||||
OrderEntity order = repository.findOwned(event.getTenantId(), event.getAggregateId());
|
||||
if (order == null) {
|
||||
failPermanently(event, "order snapshot is unavailable for channel change");
|
||||
return;
|
||||
}
|
||||
var pricingCommand = new FirstlegPricingClient.OrderChannelChangeRequest(
|
||||
order.getTenantId(), order.getId(), command.version(), order.getReceivingWarehouseId(),
|
||||
command.channelCode(), command.channelName(), order.getDestinationType(),
|
||||
order.getDestinationWarehouseId(), order.getAmazonWarehouseCode(), order.getPricingTaxMode());
|
||||
ApiResponse<FirstlegPricingClient.OrderChannelChangeResponse> pricingResponse =
|
||||
pricingClient.applyOrderChannelChange(internalToken, pricingCommand);
|
||||
if (pricingResponse == null || !pricingResponse.successful() || pricingResponse.data() == null
|
||||
|| pricingResponse.data().orderId() != order.getId()
|
||||
|| pricingResponse.data().orderVersion() != command.version()
|
||||
|| !command.channelCode().equals(pricingResponse.data().channelCode())) {
|
||||
throw new IllegalStateException("头程渠道重新计价同步失败");
|
||||
}
|
||||
}
|
||||
ApiResponse<Void> response = client.updateDeclaration(internalToken, command);
|
||||
if (response == null || !response.successful()) throw new IllegalStateException("头程申报同步失败");
|
||||
repository.markOutboxPublished(event.getTenantId(), event.getId(), LocalDateTime.now());
|
||||
@@ -101,6 +123,11 @@ public class OrderInboundOutboxPublisher {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean containsChannelChange(OrderDeclarationDtos.UpdatedEvent command) {
|
||||
return command.changes() != null && command.changes().stream()
|
||||
.anyMatch(change -> "channelCode".equals(change.field()) || "channelName".equals(change.field()));
|
||||
}
|
||||
|
||||
private boolean incomplete(FirstlegInboundClient.OrderApprovedInboundCommand command) {
|
||||
return command.orderNo() == null || command.orderNo().isBlank()
|
||||
|| command.inboundCode() == null || command.inboundCode().isBlank()
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.client.FirstlegPricingClient;
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import feign.FeignException;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -24,6 +25,29 @@ public class OrderPricingChannelGuard {
|
||||
validate(tenantId, originWarehouseId, channelCode, destinationType, false);
|
||||
}
|
||||
|
||||
public void requireOrderChannelChangeEligible(OrderEntity order, long nextOrderVersion,
|
||||
String channelCode, String channelName) {
|
||||
try {
|
||||
ApiResponse<FirstlegPricingClient.OrderChannelChangeResponse> response =
|
||||
client.validateOrderChannelChange(internalToken, new FirstlegPricingClient.OrderChannelChangeRequest(
|
||||
order.getTenantId(), order.getId(), nextOrderVersion, order.getReceivingWarehouseId(),
|
||||
channelCode, channelName, order.getDestinationType(), order.getDestinationWarehouseId(),
|
||||
order.getAmazonWarehouseCode(), order.getPricingTaxMode()));
|
||||
requireSuccessful(response);
|
||||
if (response.data() == null || response.data().orderId() != order.getId()
|
||||
|| response.data().orderVersion() != nextOrderVersion
|
||||
|| !channelCode.equals(response.data().channelCode())) {
|
||||
throw unavailable();
|
||||
}
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (FeignException exception) {
|
||||
throw unavailable();
|
||||
} catch (RuntimeException exception) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
private String validate(long tenantId, long originWarehouseId, String channelCode,
|
||||
String destinationType, boolean requireInboundCodePrefix) {
|
||||
try {
|
||||
@@ -33,12 +57,7 @@ public class OrderPricingChannelGuard {
|
||||
if (response == null) {
|
||||
throw unavailable();
|
||||
}
|
||||
if (!response.successful()) {
|
||||
ResultCode code = ResultCode.of(response.code());
|
||||
String message = response.message() == null || response.message().isBlank()
|
||||
? code.message() : response.message();
|
||||
throw new BizException(code, message);
|
||||
}
|
||||
requireSuccessful(response);
|
||||
if (response.data() == null || requireInboundCodePrefix
|
||||
&& (response.data().inboundCodePrefix() == null || response.data().inboundCodePrefix().isBlank())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "所选渠道未配置入仓码前缀");
|
||||
@@ -53,6 +72,16 @@ public class OrderPricingChannelGuard {
|
||||
}
|
||||
}
|
||||
|
||||
private void requireSuccessful(ApiResponse<?> response) {
|
||||
if (response == null) throw unavailable();
|
||||
if (!response.successful()) {
|
||||
ResultCode code = ResultCode.of(response.code());
|
||||
String message = response.message() == null || response.message().isBlank()
|
||||
? code.message() : response.message();
|
||||
throw new BizException(code, message);
|
||||
}
|
||||
}
|
||||
|
||||
private BizException unavailable() {
|
||||
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程价格渠道校验服务暂时不可用");
|
||||
}
|
||||
|
||||
@@ -212,8 +212,14 @@ paths:
|
||||
description: >-
|
||||
需要 platform:order:declaration:edit。货物使用订单内 itemNo 定位,不接受货物数据库 ID;
|
||||
不允许修改客户、国内收货仓、订单号、入仓码、状态、仓库/PDA/设备实测、测量审核或计费事实。
|
||||
审核通过订单仅在尚无测量、同柜组、分柜、装柜或计费事实时受理,并通过
|
||||
ORDER_DECLARATION_UPDATED_V1 Outbox 快照同步 Firstleg;消费者按 eventId 幂等、version 顺序应用。
|
||||
已有测量或计费事实不阻断客户申报快照更新,详情继续以最新申报与既有实测分别展示;
|
||||
只有渠道发生变化时才在写入前校验同柜/分柜/装柜可逆性和重新计价资格,任何校验失败均不写入。
|
||||
正式门禁分别为 /internal/firstleg/v1/inbounds/order-channel-changes/validate 与
|
||||
/internal/firstleg/v1/pricing/order-channel-changes/validate,两个服务均失败关闭。
|
||||
国内收货仓或中转仓如需变化必须重新下单。成功后通过 ORDER_DECLARATION_UPDATED_V1 Outbox
|
||||
同步 Firstleg;消费者按 eventId 幂等、version 顺序刷新申报快照,不覆盖实测、箱码或已确认费用。
|
||||
本次 changes 含渠道字段时,同版本 Outbox 还必须调用
|
||||
/internal/firstleg/v1/pricing/order-channel-changes/apply,使未确认价格预估显式失效;普通申报更新不得调用。
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/UnifiedOrderId'
|
||||
- $ref: '#/components/parameters/UnifiedTenantId'
|
||||
@@ -223,7 +229,7 @@ paths:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/OrderDeclarationUpdateRequest'}
|
||||
responses:
|
||||
'200': {description: 成功返回新版本、渠道历史标记和字段级前后值;版本冲突或存在不可逆履约事实时响应体业务码为 A0409}
|
||||
'200': {description: 成功返回新版本、渠道历史标记和字段级前后值;版本冲突、渠道与不可逆履约或已确认费用不兼容时响应体业务码为 A0409}
|
||||
'403': {description: 无权限或非平台身份}
|
||||
/api/merchant/v1/orders:
|
||||
get:
|
||||
|
||||
+26
-2
@@ -1,10 +1,13 @@
|
||||
package com.baiyue.order.controller;
|
||||
|
||||
import com.baiyue.order.dto.OrderDeclarationDtos;
|
||||
import com.baiyue.order.client.FirstlegInboundClient;
|
||||
import com.baiyue.order.client.FirstlegPricingClient;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -26,7 +29,9 @@ class OrderDeclarationUpdateContractTest {
|
||||
assertThat(OrderDeclarationDtos.UpdateRequest.class.getRecordComponents())
|
||||
.extracting(component -> component.getName())
|
||||
.containsExactly("expectedVersion", "customerRemark", "expectedArrivalAt",
|
||||
"channelCode", "channelName", "cargoes");
|
||||
"channelCode", "channelName", "cargoes")
|
||||
.doesNotContain("receivingWarehouseId", "receivingWarehouseName",
|
||||
"destinationWarehouseId", "destinationWarehouseName", "inboundCode", "orderNo");
|
||||
assertThat(OrderDeclarationDtos.Cargo.class.getRecordComponents())
|
||||
.extracting(component -> component.getName())
|
||||
.contains("itemNo", "cargoName", "cargoNameEn", "skuNo", "model", "packageCount",
|
||||
@@ -46,7 +51,26 @@ class OrderDeclarationUpdateContractTest {
|
||||
Map<String, Object> row = map(map(schemas.get("UnifiedOrderRow")).get("properties"));
|
||||
assertThat(row).containsKeys("expectedVolumeCbm", "actualVolumeCbm", "expectedArrivalAt", "channelChanged");
|
||||
assertThat(map(row.get("actualVolumeCbm")).get("description").toString()).contains("仅 Firstleg 实测");
|
||||
assertThat(source).contains("ORDER_DECLARATION_UPDATED_V1", "platform:order:declaration:edit", "A0409");
|
||||
assertThat(source).contains("ORDER_DECLARATION_UPDATED_V1", "platform:order:declaration:edit", "A0409",
|
||||
"/internal/firstleg/v1/inbounds/order-channel-changes/validate",
|
||||
"/internal/firstleg/v1/pricing/order-channel-changes/validate",
|
||||
"/internal/firstleg/v1/pricing/order-channel-changes/apply");
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvedChannelChangeUsesFormalFulfillmentAndPricingValidationContracts() throws Exception {
|
||||
var fulfillment = FirstlegInboundClient.class.getDeclaredMethod("validateOrderChannelChange",
|
||||
String.class, FirstlegInboundClient.OrderChannelChangeValidationRequest.class);
|
||||
var pricing = FirstlegPricingClient.class.getDeclaredMethod("validateOrderChannelChange",
|
||||
String.class, FirstlegPricingClient.OrderChannelChangeRequest.class);
|
||||
var applyPricing = FirstlegPricingClient.class.getDeclaredMethod("applyOrderChannelChange",
|
||||
String.class, FirstlegPricingClient.OrderChannelChangeRequest.class);
|
||||
assertThat(fulfillment.getAnnotation(PostMapping.class).value())
|
||||
.containsExactly("/order-channel-changes/validate");
|
||||
assertThat(pricing.getAnnotation(PostMapping.class).value())
|
||||
.containsExactly("/order-channel-changes/validate");
|
||||
assertThat(applyPricing.getAnnotation(PostMapping.class).value())
|
||||
.containsExactly("/order-channel-changes/apply");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+42
-29
@@ -2,7 +2,6 @@ package com.baiyue.order.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.dto.OrderDeclarationDtos;
|
||||
import com.baiyue.order.dto.UnifiedOrderResponse;
|
||||
import com.baiyue.order.entity.OrderCargoEntity;
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import com.baiyue.order.entity.OrderStatus;
|
||||
@@ -33,7 +32,7 @@ import static org.mockito.Mockito.when;
|
||||
class OrderDeclarationUpdateServiceTest {
|
||||
@MockBean private OrderRepository repository;
|
||||
@MockBean private OrderPricingChannelGuard pricingChannelGuard;
|
||||
@MockBean private UnifiedOrderQueryService unifiedOrders;
|
||||
@MockBean private OrderFulfillmentChannelChangeGuard fulfillmentChannelGuard;
|
||||
@MockBean private ObjectMapper json;
|
||||
@Resource private OrderDeclarationUpdateService service;
|
||||
|
||||
@@ -76,45 +75,59 @@ class OrderDeclarationUpdateServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvedOrderWithMeasurementFactIsAtomicallyRejected() {
|
||||
OrderEntity order = order(OrderStatus.APPROVED, 3);
|
||||
when(repository.findOwned(100L, 200L)).thenReturn(order);
|
||||
when(repository.findCargo(100L, 200L)).thenReturn(List.of(cargo()));
|
||||
UnifiedOrderResponse.Detail detail = org.mockito.Mockito.mock(UnifiedOrderResponse.Detail.class);
|
||||
UnifiedOrderResponse.Row row = org.mockito.Mockito.mock(UnifiedOrderResponse.Row.class);
|
||||
when(detail.summary()).thenReturn(row);
|
||||
when(row.mainStatus()).thenReturn("WAIT_MEASURE");
|
||||
when(row.measurementCompletedAt()).thenReturn(LocalDateTime.of(2026, 9, 1, 12, 0));
|
||||
when(unifiedOrders.platformDetail(100L, 200L)).thenReturn(detail);
|
||||
|
||||
assertThatThrownBy(() -> service.update(100L, 200L, 700L, "平台操作员",
|
||||
request(3, "OLD", "旧渠道", "备注")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("测量、分柜、装柜或计费事实");
|
||||
verify(repository, never()).updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(),
|
||||
any(), any(), any(), any(), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reversibleApprovedUpdateProducesVersionedOutboxSnapshot() throws Exception {
|
||||
void approvedOrderCanRefreshCustomerDeclarationWithoutTouchingMeasurementFacts() throws Exception {
|
||||
OrderEntity order = order(OrderStatus.APPROVED, 3);
|
||||
when(repository.findOwned(100L, 200L)).thenReturn(order);
|
||||
when(repository.findCargo(100L, 200L)).thenReturn(List.of(cargo()));
|
||||
when(repository.updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(), any(), any(),
|
||||
any(), any(), anyBoolean())).thenReturn(1);
|
||||
when(repository.updateDeclarationCargo(anyLong(), anyLong(), any())).thenReturn(1);
|
||||
UnifiedOrderResponse.Detail detail = org.mockito.Mockito.mock(UnifiedOrderResponse.Detail.class);
|
||||
UnifiedOrderResponse.Row row = org.mockito.Mockito.mock(UnifiedOrderResponse.Row.class);
|
||||
when(detail.summary()).thenReturn(row);
|
||||
when(row.mainStatus()).thenReturn("WAIT_MEASURE");
|
||||
when(row.feeReviewStatus()).thenReturn("UNRECORDED");
|
||||
when(unifiedOrders.platformDetail(100L, 200L)).thenReturn(detail);
|
||||
when(json.writeValueAsString(any())).thenReturn("{\"schemaVersion\":1}");
|
||||
|
||||
service.update(100L, 200L, 700L, "平台操作员", request(3, "OLD", "旧渠道", "新备注"));
|
||||
var response = service.update(100L, 200L, 700L, "平台操作员",
|
||||
request(3, "OLD", "旧渠道", "测量后更新申报"));
|
||||
|
||||
assertThat(response.version()).isEqualTo(4);
|
||||
verify(pricingChannelGuard, never()).requireAvailable(anyLong(), anyLong(), anyString(), anyString());
|
||||
verify(repository).insertOutbox(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvedChannelChangePassesBothGatesBeforeVersionedOutboxSnapshot() throws Exception {
|
||||
OrderEntity order = order(OrderStatus.APPROVED, 3);
|
||||
when(repository.findOwned(100L, 200L)).thenReturn(order);
|
||||
when(repository.findCargo(100L, 200L)).thenReturn(List.of(cargo()));
|
||||
when(repository.updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(), any(), any(),
|
||||
any(), any(), anyBoolean())).thenReturn(1);
|
||||
when(repository.updateDeclarationCargo(anyLong(), anyLong(), any())).thenReturn(1);
|
||||
when(json.writeValueAsString(any())).thenReturn("{\"schemaVersion\":1}");
|
||||
|
||||
service.update(100L, 200L, 700L, "平台操作员", request(3, "NEW", "新渠道", "新备注"));
|
||||
|
||||
verify(fulfillmentChannelGuard).requireEligible(order, 4L, "NEW");
|
||||
verify(pricingChannelGuard).requireOrderChannelChangeEligible(order, 4L, "NEW", "新渠道");
|
||||
verify(repository).insertOutbox(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvedChannelChangeRunsBothRemoteGatesBeforeAnyWrite() {
|
||||
OrderEntity order = order(OrderStatus.APPROVED, 3);
|
||||
when(repository.findOwned(100L, 200L)).thenReturn(order);
|
||||
when(repository.findCargo(100L, 200L)).thenReturn(List.of(cargo()));
|
||||
org.mockito.Mockito.doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
|
||||
"装柜已开始,渠道不可变更"))
|
||||
.when(fulfillmentChannelGuard).requireEligible(order, 4L, "NEW");
|
||||
|
||||
assertThatThrownBy(() -> service.update(100L, 200L, 700L, "平台操作员",
|
||||
request(3, "NEW", "新渠道", "备注")))
|
||||
.isInstanceOf(BizException.class).hasMessage("装柜已开始,渠道不可变更");
|
||||
|
||||
verify(fulfillmentChannelGuard).requireEligible(order, 4L, "NEW");
|
||||
verify(pricingChannelGuard, never()).requireOrderChannelChangeEligible(any(), anyLong(), anyString(), anyString());
|
||||
verify(repository, never()).updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(),
|
||||
any(), any(), any(), any(), anyBoolean());
|
||||
}
|
||||
|
||||
private OrderEntity order(OrderStatus status, int version) {
|
||||
OrderEntity order = new OrderEntity();
|
||||
order.setId(200L); order.setTenantId(100L); order.setOrderNo("ORD-200");
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.baiyue.order.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.client.FirstlegInboundClient;
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(OrderFulfillmentChannelChangeGuard.class)
|
||||
@TestPropertySource(properties = "baiyue.security.internal-service-token=token")
|
||||
class OrderFulfillmentChannelChangeGuardTest {
|
||||
@MockBean private FirstlegInboundClient client;
|
||||
@Resource private OrderFulfillmentChannelChangeGuard guard;
|
||||
|
||||
@Test
|
||||
void validatesNextOrderVersionAgainstFirstlegFulfillmentFacts() {
|
||||
OrderEntity order = order();
|
||||
var request = new FirstlegInboundClient.OrderChannelChangeValidationRequest(11L, 21L, 5L, "MATSON_TRUCK");
|
||||
when(client.validateOrderChannelChange("token", request)).thenReturn(ApiResponse.success(
|
||||
new FirstlegInboundClient.OrderChannelChangeValidationResponse(21L, 5L, "MATSON_TRUCK", "WAIT_SPLIT")));
|
||||
|
||||
guard.requireEligible(order, 5L, "MATSON_TRUCK");
|
||||
|
||||
verify(client).validateOrderChannelChange("token", request);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsIrreversibleLoadingBusinessErrorAndFailsBeforeOrderWrite() {
|
||||
OrderEntity order = order();
|
||||
var request = new FirstlegInboundClient.OrderChannelChangeValidationRequest(11L, 21L, 5L, "MATSON_TRUCK");
|
||||
when(client.validateOrderChannelChange("token", request)).thenReturn(
|
||||
ApiResponse.failure(ResultCode.CONFLICT, "装柜已开始,渠道不可变更", null, null));
|
||||
|
||||
assertThatThrownBy(() -> guard.requireEligible(order, 5L, "MATSON_TRUCK"))
|
||||
.isInstanceOf(BizException.class).hasMessage("装柜已开始,渠道不可变更");
|
||||
}
|
||||
|
||||
private OrderEntity order() {
|
||||
OrderEntity order = new OrderEntity();
|
||||
order.setId(21L); order.setTenantId(11L);
|
||||
return order;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.baiyue.order.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.order.client.FirstlegInboundClient;
|
||||
import com.baiyue.order.client.FirstlegPricingClient;
|
||||
import com.baiyue.order.entity.OutboxEventEntity;
|
||||
import com.baiyue.order.repository.OrderRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -130,7 +131,7 @@ class OrderInboundOutboxPublisherTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void declarationUpdateEventIsDeliveredByVersionedContract() {
|
||||
void ordinaryDeclarationUpdateDoesNotInvalidatePricing() {
|
||||
String payload = "{\"schemaVersion\":1,\"eventId\":\"evt-1\",\"tenantId\":11,"
|
||||
+ "\"orderId\":21,\"orderNo\":\"ORD-21\",\"inboundCode\":\"RK-21\","
|
||||
+ "\"version\":4,\"operatorId\":7,\"channelCode\":\"SEA\","
|
||||
@@ -140,9 +141,53 @@ class OrderInboundOutboxPublisherTest {
|
||||
|
||||
fixture.publisher.publishApprovedOrders();
|
||||
|
||||
verify(fixture.client).updateDeclaration(eq("internal-token"), any());
|
||||
verify(fixture.client).updateDeclaration(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(command ->
|
||||
command.version() == 4 && command.channelChanged()
|
||||
&& "SEA".equals(command.channelCode()) && "RK-21".equals(command.inboundCode())));
|
||||
verify(fixture.repository).markOutboxPublished(eq(11L), eq(41L), any(LocalDateTime.class));
|
||||
verify(fixture.client, never()).create(any(), any());
|
||||
verify(fixture.pricingClient, never()).applyOrderChannelChange(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void channelChangeAppliesPricingInvalidationBySameOrderVersionBeforeDelivery() {
|
||||
String payload = "{\"schemaVersion\":1,\"eventId\":\"evt-2\",\"tenantId\":11,"
|
||||
+ "\"orderId\":21,\"orderNo\":\"ORD-21\",\"inboundCode\":\"RK-21\","
|
||||
+ "\"version\":4,\"operatorId\":7,\"channelCode\":\"SEA\","
|
||||
+ "\"channelName\":\"海运\",\"channelChanged\":true,\"cargoes\":[],"
|
||||
+ "\"changes\":[{\"field\":\"channelCode\",\"beforeValue\":\"OLD\",\"afterValue\":\"SEA\"}]}";
|
||||
Fixture fixture = fixture(event("ORDER_DECLARATION_UPDATED_V1", 11L, 21L, payload));
|
||||
when(fixture.pricingClient.applyOrderChannelChange(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
||||
new FirstlegPricingClient.OrderChannelChangeResponse(
|
||||
51L, 21L, 4L, "SEA", 61L, "UNPRICED", true, 71L)));
|
||||
when(fixture.client.updateDeclaration(eq("internal-token"), any())).thenReturn(ApiResponse.success(null));
|
||||
|
||||
fixture.publisher.publishApprovedOrders();
|
||||
|
||||
verify(fixture.pricingClient).applyOrderChannelChange(eq("internal-token"),
|
||||
org.mockito.ArgumentMatchers.argThat(command -> command.orderVersion() == 4L
|
||||
&& command.orderId() == 21L && "SEA".equals(command.channelCode())));
|
||||
verify(fixture.client).updateDeclaration(eq("internal-token"), any());
|
||||
verify(fixture.repository).markOutboxPublished(eq(11L), eq(41L), any(LocalDateTime.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void channelPricingApplyFailureKeepsOutboxPendingAndDoesNotAdvanceFulfillmentSnapshot() {
|
||||
String payload = "{\"schemaVersion\":1,\"eventId\":\"evt-3\",\"tenantId\":11,"
|
||||
+ "\"orderId\":21,\"orderNo\":\"ORD-21\",\"inboundCode\":\"RK-21\","
|
||||
+ "\"version\":4,\"operatorId\":7,\"channelCode\":\"SEA\","
|
||||
+ "\"channelName\":\"海运\",\"channelChanged\":true,\"cargoes\":[],"
|
||||
+ "\"changes\":[{\"field\":\"channelName\",\"beforeValue\":\"旧渠道\",\"afterValue\":\"海运\"}]}";
|
||||
Fixture fixture = fixture(event("ORDER_DECLARATION_UPDATED_V1", 11L, 21L, payload));
|
||||
when(fixture.pricingClient.applyOrderChannelChange(eq("internal-token"), any())).thenReturn(
|
||||
ApiResponse.failure(com.baiyue.common.core.api.ResultCode.CONFLICT,
|
||||
"费用已确认或已发布,渠道不可变更", null, null));
|
||||
|
||||
fixture.publisher.publishApprovedOrders();
|
||||
|
||||
verify(fixture.repository).markOutboxRetry(eq(11L), eq(41L), any(LocalDateTime.class));
|
||||
verify(fixture.client, never()).updateDeclaration(any(), any());
|
||||
verify(fixture.repository, never()).markOutboxPublished(anyLong(), anyLong(), any());
|
||||
}
|
||||
|
||||
private Fixture fixture(OutboxEventEntity event) {
|
||||
@@ -152,6 +197,7 @@ class OrderInboundOutboxPublisherTest {
|
||||
private Fixture fixture(OutboxEventEntity event, String authoritativeTaxMode) {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
FirstlegInboundClient client = mock(FirstlegInboundClient.class);
|
||||
FirstlegPricingClient pricingClient = mock(FirstlegPricingClient.class);
|
||||
when(repository.pendingInboundEvents(50)).thenReturn(List.of(event));
|
||||
if (event.getTenantId() != null && event.getAggregateId() != null) {
|
||||
var order = new com.baiyue.order.entity.OrderEntity();
|
||||
@@ -163,6 +209,8 @@ class OrderInboundOutboxPublisherTest {
|
||||
order.setReceivingWarehouseId(31L);
|
||||
order.setReceivingWarehouseName("测试仓");
|
||||
order.setDestinationType("OVERSEAS");
|
||||
order.setDestinationWarehouseId(41L);
|
||||
order.setAmazonWarehouseCode("ONT8");
|
||||
order.setTransportMode("SEA");
|
||||
order.setTaxMode(authoritativeTaxMode);
|
||||
order.setPricingTaxMode("TAX_EXCLUDED");
|
||||
@@ -177,9 +225,10 @@ class OrderInboundOutboxPublisherTest {
|
||||
OrderInboundOutboxPublisher publisher = new OrderInboundOutboxPublisher();
|
||||
ReflectionTestUtils.setField(publisher, "repository", repository);
|
||||
ReflectionTestUtils.setField(publisher, "client", client);
|
||||
ReflectionTestUtils.setField(publisher, "pricingClient", pricingClient);
|
||||
ReflectionTestUtils.setField(publisher, "json", new ObjectMapper());
|
||||
ReflectionTestUtils.setField(publisher, "internalToken", "internal-token");
|
||||
return new Fixture(publisher, repository, client);
|
||||
return new Fixture(publisher, repository, client, pricingClient);
|
||||
}
|
||||
|
||||
private OutboxEventEntity event(String eventType, Long tenantId, Long aggregateId, String payload) {
|
||||
@@ -207,6 +256,6 @@ class OrderInboundOutboxPublisherTest {
|
||||
}
|
||||
|
||||
private record Fixture(OrderInboundOutboxPublisher publisher, OrderRepository repository,
|
||||
FirstlegInboundClient client) {
|
||||
FirstlegInboundClient client, FirstlegPricingClient pricingClient) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.client.FirstlegPricingClient;
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import feign.FeignException;
|
||||
import feign.Request;
|
||||
import feign.Response;
|
||||
@@ -65,6 +66,24 @@ class OrderPricingChannelGuardTest {
|
||||
.isEqualTo(ResultCode.CONFLICT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvedChannelChangeUsesPricingAndConfirmedFeeGate() {
|
||||
OrderEntity order = new OrderEntity();
|
||||
order.setId(21L); order.setTenantId(11L); order.setReceivingWarehouseId(31L);
|
||||
order.setDestinationType("FBA"); order.setDestinationWarehouseId(41L);
|
||||
order.setAmazonWarehouseCode("ONT8"); order.setPricingTaxMode("TAX_INCLUDED");
|
||||
var request = new FirstlegPricingClient.OrderChannelChangeRequest(
|
||||
11L, 21L, 5L, 31L, "MATSON_TRUCK", "美森海卡",
|
||||
"FBA", 41L, "ONT8", "TAX_INCLUDED");
|
||||
when(client.validateOrderChannelChange("token", request)).thenReturn(ApiResponse.success(
|
||||
new FirstlegPricingClient.OrderChannelChangeResponse(
|
||||
51L, 21L, 5L, "MATSON_TRUCK", 61L, "CALCULATED", true, null)));
|
||||
|
||||
guard.requireOrderChannelChangeEligible(order, 5L, "MATSON_TRUCK", "美森海卡");
|
||||
|
||||
verify(client).validateOrderChannelChange("token", request);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullResponseMapsToServiceUnavailable() {
|
||||
var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK", "FBA", true);
|
||||
|
||||
Reference in New Issue
Block a user