Merge remote-tracking branch 'origin/test' into dev

This commit is contained in:
2026-09-08 17:59:53 +08:00
43 changed files with 867 additions and 73 deletions
@@ -3783,7 +3783,7 @@ CREATE TABLE IF NOT EXISTS fl_price_warehouse_mapping (
CREATE TABLE IF NOT EXISTS fl_price_book_version (
id BIGINT UNSIGNED NOT NULL COMMENT '整表价格版本ID',
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共价格作用域固定为0',
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '报价作用域:0平台公共,正数为客户专属',
version_no INT UNSIGNED NOT NULL COMMENT '整表版本号',
original_file_name VARCHAR(255) NOT NULL COMMENT '原始Excel文件名',
source_file_id BIGINT UNSIGNED NULL COMMENT 'File服务原始文件ID',
@@ -0,0 +1,18 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.dto.OrderApprovedInboundCommand;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
@FeignClient(name = "order-service", contextId = "printableOrderInboundClient",
url = "${baiyue.clients.order.url:}", path = "/internal/order/v1/cargoes")
public interface PrintableOrderInboundClient {
@PostMapping("/{tenantId}/{orderId}/printable-inbound-snapshot")
ApiResponse<OrderApprovedInboundCommand> reserve(
@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId,
@PathVariable long orderId);
}
@@ -38,9 +38,10 @@ public class PlatformPriceBookController {
@Operation(summary = "查询船期价格表版本")
public ApiResponse<java.util.List<PriceBookDtos.PriceBookVersionSummary>> list(
@AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user,
@RequestParam(defaultValue = "0") long customerId,
@RequestParam(required = false) PriceBookStatus status) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.list(status));
return ApiResponse.success(service.list(customerId, status));
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -48,9 +49,10 @@ public class PlatformPriceBookController {
@BusinessAudit(event = "FBA_PRICE_BOOK_UPLOAD", resourceType = "PRICE_BOOK_VERSION", description = "上传船期价格表并等待确认")
public ApiResponse<PriceBookDtos.PriceBookVersionDetail> upload(
@AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user,
@RequestParam(defaultValue = "0") long customerId,
@RequestPart("file") MultipartFile file) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.upload(user.userId(), file));
return ApiResponse.success(service.upload(user.userId(), customerId, file));
}
@GetMapping("/{versionId}")
@@ -23,6 +23,7 @@ public final class PriceBookDtos {
public record PriceBookVersionSummary(
Long id,
Long customerId,
Integer versionNo,
String originalFileName,
Long sourceFileId,
@@ -10,12 +10,12 @@ import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 船司 FBA 目的仓价格的不可变版本。 */
/** 公共或客户专属的头程报价不可变版本。 */
@Getter
@Setter
@TableName("fl_carrier_fba_rate")
public class CarrierFbaRateEntity extends BaseEntity {
/** 船司价格是平台公共主数据,不绑定任何客户。 */
/** 0 为平台公共报价,正数为客户专属报价作用域。 */
public static final long PLATFORM_SCOPE = 0L;
@TableId(type = IdType.ASSIGN_ID)
@@ -32,6 +32,16 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader {
""")
Long findActiveVersionId();
@Select("""
SELECT id
FROM fl_price_book_version
WHERE tenant_id IN (0, #{customerId}) AND status = 'ACTIVE' AND deleted = 0
ORDER BY CASE WHEN tenant_id = #{customerId} THEN 0 ELSE 1 END,
published_at DESC, version_no DESC
LIMIT 1
""")
Long findEffectiveVersionId(@Param("customerId") long customerId);
@Select("""
SELECT c.price_book_version_id AS priceBookVersionId,
c.channel_code AS channelCode, c.channel_name AS channelName,
@@ -48,7 +58,7 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader {
SELECT 1 FROM fl_carrier_fba_rate r
WHERE r.price_book_version_id = c.price_book_version_id
AND r.channel_code = c.channel_code
AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId}
AND r.origin_warehouse_id = #{originWarehouseId}
AND r.status = 'ACTIVE' AND r.deleted = 0
)
ORDER BY c.id
@@ -60,7 +70,7 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader {
SELECT r.channel_code AS channelCode, r.pricing_tax_mode AS pricingTaxMode
FROM fl_carrier_fba_rate r
WHERE r.price_book_version_id = #{versionId}
AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId}
AND r.origin_warehouse_id = #{originWarehouseId}
AND r.status = 'ACTIVE' AND r.deleted = 0
AND r.pricing_tax_mode IS NOT NULL
GROUP BY r.channel_code, r.pricing_tax_mode
@@ -83,7 +93,7 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader {
r.effective_from AS effectiveFrom, r.effective_to AS effectiveTo
FROM fl_carrier_fba_rate r
WHERE r.price_book_version_id = #{versionId}
AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId}
AND r.origin_warehouse_id = #{originWarehouseId}
AND r.channel_code = #{channelCode} AND r.destination_type = 'FBA'
AND UPPER(r.destination_code) = #{destinationCode}
AND r.pricing_tax_mode = #{pricingTaxMode}
@@ -109,7 +119,7 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader {
r.effective_from AS effectiveFrom, r.effective_to AS effectiveTo
FROM fl_carrier_fba_rate r
WHERE r.price_book_version_id = #{versionId}
AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId}
AND r.origin_warehouse_id = #{originWarehouseId}
AND r.channel_code = #{channelCode} AND r.destination_type = 'OVERSEAS'
AND r.destination_warehouse_id = #{destinationWarehouseId}
AND r.pricing_tax_mode = #{pricingTaxMode}
@@ -135,7 +145,7 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader {
r.effective_from AS effectiveFrom, r.effective_to AS effectiveTo
FROM fl_carrier_fba_rate r
WHERE r.price_book_version_id = #{versionId}
AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId}
AND r.origin_warehouse_id = #{originWarehouseId}
AND r.channel_code = #{channelCode}
AND r.price_model = 'REGION_WEIGHT_TIER'
AND r.region_code = #{regionCode}
@@ -16,6 +16,9 @@ public class PriceBookQueryService implements PriceBookQueryUseCase {
@Resource private PriceBookQueryReader reader;
@Override public Long activeVersionId() { return reader.findActiveVersionId(); }
@Override public Long effectiveVersionId(long customerId) {
return reader.findEffectiveVersionId(customerId);
}
@Override public List<ChannelRow> channels(long versionId, long originWarehouseId) {
return reader.findChannels(versionId, originWarehouseId);
}
@@ -9,6 +9,7 @@ import java.util.List;
public interface PriceBookQueryUseCase {
Long activeVersionId();
Long effectiveVersionId(long customerId);
List<ChannelRow> channels(long versionId, long originWarehouseId);
List<ChannelTaxModeRow> channelTaxModes(long versionId, long originWarehouseId);
List<RateRow> fbaRates(long versionId, long originWarehouseId, String channelCode,
@@ -9,6 +9,7 @@ import java.util.List;
public interface PriceBookQueryReader {
Long findActiveVersionId();
Long findEffectiveVersionId(long customerId);
List<ChannelRow> findChannels(long versionId, long originWarehouseId);
List<ChannelTaxModeRow> findChannelTaxModes(long versionId, long originWarehouseId);
List<RateRow> findFbaRates(long versionId, long originWarehouseId, String channelCode,
@@ -27,10 +27,14 @@ public class ActivePriceBookEstimator {
@Resource private PriceBookWarehouseRegionResolver warehouseRegionResolver;
public Estimate estimate(EstimateRequest request) {
return estimate(0L, request);
}
public Estimate estimate(long customerId, EstimateRequest request) {
if (request == null || request.originWarehouseId() == null || request.originWarehouseId() <= 0) {
throw validation("请选择有效起运仓");
}
Long versionId = repository.activeVersionId();
Long versionId = repository.effectiveVersionId(customerId);
if (versionId == null) {
throw new BizException(ResultCode.NOT_FOUND, "当前没有生效的船期价格版本");
}
@@ -32,39 +32,50 @@ public class PriceBookRepository {
public int insertVersion(PriceBookVersionEntity value) { return versions.insert(value); }
public int updateVersion(PriceBookVersionEntity value) {
return versions.update(value, new LambdaUpdateWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(PriceBookVersionEntity::getTenantId, value.getTenantId())
.eq(PriceBookVersionEntity::getId, value.getId()));
}
public PriceBookVersionEntity findVersion(long id) {
return versions.selectOne(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(PriceBookVersionEntity::getId, id)
.eq(PriceBookVersionEntity::getDeleted, false));
}
public List<PriceBookVersionEntity> listVersions(String status) {
return listVersions(PriceBookVersionEntity.PLATFORM_SCOPE, status);
}
public List<PriceBookVersionEntity> listVersions(long customerId, String status) {
return versions.selectList(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(PriceBookVersionEntity::getTenantId, customerId)
.eq(status != null && !status.isBlank(), PriceBookVersionEntity::getStatus, status)
.eq(PriceBookVersionEntity::getDeleted, false)
.orderByDesc(PriceBookVersionEntity::getCreatedAt));
}
public PriceBookVersionEntity findActiveForUpdate() {
return findActiveForUpdate(PriceBookVersionEntity.PLATFORM_SCOPE);
}
public PriceBookVersionEntity findActiveForUpdate(long customerId) {
return versions.selectOne(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(PriceBookVersionEntity::getTenantId, customerId)
.eq(PriceBookVersionEntity::getStatus, "ACTIVE")
.eq(PriceBookVersionEntity::getDeleted, false)
.last("FOR UPDATE"));
}
public List<PriceBookVersionEntity> listActiveForUpdate() {
return listActiveForUpdate(PriceBookVersionEntity.PLATFORM_SCOPE);
}
public List<PriceBookVersionEntity> listActiveForUpdate(long customerId) {
return versions.selectList(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(PriceBookVersionEntity::getTenantId, customerId)
.eq(PriceBookVersionEntity::getStatus, "ACTIVE")
.eq(PriceBookVersionEntity::getDeleted, false)
.last("FOR UPDATE"));
}
public PriceBookVersionEntity findActive() {
return findActive(PriceBookVersionEntity.PLATFORM_SCOPE);
}
public PriceBookVersionEntity findActive(long customerId) {
return versions.selectOne(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(PriceBookVersionEntity::getTenantId, customerId)
.eq(PriceBookVersionEntity::getStatus, "ACTIVE")
.eq(PriceBookVersionEntity::getDeleted, false));
}
@@ -74,14 +85,20 @@ public class PriceBookRepository {
* ACTIVE version exists (both transactions could otherwise pass the check).
*/
public void lockPlatformVersions() {
lockVersions(PriceBookVersionEntity.PLATFORM_SCOPE);
}
public void lockVersions(long customerId) {
versions.selectList(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(PriceBookVersionEntity::getTenantId, customerId)
.eq(PriceBookVersionEntity::getDeleted, false)
.last("FOR UPDATE"));
}
public int nextVersionNo() {
return nextVersionNo(PriceBookVersionEntity.PLATFORM_SCOPE);
}
public int nextVersionNo(long customerId) {
PriceBookVersionEntity latest = versions.selectOne(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(PriceBookVersionEntity::getTenantId, customerId)
.orderByDesc(PriceBookVersionEntity::getVersionNo).last("LIMIT 1 FOR UPDATE"));
return latest == null ? 1 : latest.getVersionNo() + 1;
}
@@ -117,7 +117,7 @@ public class FbaPricingService {
if (incomplete) throw conflict("外箱规数据或绑定的计算规则不完整,不能生成对客费用");
}
PricingPolicy policy = pricingPolicy(inbound, requestedAt == null ? LocalDateTime.now(clock) : requestedAt);
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(new EstimateRequest(
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(tenantId, new EstimateRequest(
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null,
pricingTaxMode(inbound), policy.chargeMode(),
@@ -5,6 +5,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.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.client.PrintableOrderInboundClient;
import com.baiyue.firstleg.dto.InboundCartonLabels;
import com.baiyue.firstleg.entity.InboundCartonLabelEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
@@ -35,6 +36,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
@Resource private InboundCartonLabelRepository repository;
@Resource private OrderCargoClient orderCargoClient;
@Resource private PrintableOrderInboundClient printableOrderInboundClient;
@Resource private InboundOrderCreationService inboundOrderCreationService;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
@@ -105,7 +107,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
private InboundOrderEntity prepareMissingInbound(long tenantId, long orderId) {
try {
ApiResponse<com.baiyue.firstleg.dto.OrderApprovedInboundCommand> response =
orderCargoClient.inboundSnapshot(internalToken, tenantId, orderId);
printableOrderInboundClient.reserve(internalToken, tenantId, orderId);
if (response == null || !response.successful() || response.data() == null) {
throw new BizException(ResultCode.NOT_FOUND, "订单不存在或无法生成预入库单");
}
@@ -32,7 +32,7 @@ public class MerchantPriceBookService {
public List<CurrentChannel> channels(long merchantTenantId, long originWarehouseId) {
requireMerchant(merchantTenantId);
if (originWarehouseId <= 0) throw validation("请选择有效起运仓");
Long versionId = repository.activeVersionId();
Long versionId = repository.effectiveVersionId(merchantTenantId);
if (versionId == null) return List.of();
List<ChannelRow> channels = repository.channels(versionId, originWarehouseId);
if (channels.isEmpty()) return List.of();
@@ -46,7 +46,7 @@ public class MerchantPriceBookService {
&& request.chargeMode() != null && !"KG".equals(request.chargeMode())) {
throw validation("包税报价必须按KG计费");
}
ActivePriceBookEstimator.Estimate estimate = estimator.estimate(request);
ActivePriceBookEstimator.Estimate estimate = estimator.estimate(merchantTenantId, request);
return new EstimateResponse(estimate.priceBookVersionId(), estimate.channelCode(), estimate.priceModel(),
estimate.pricingTaxMode(), estimate.chargeableQuantity(), estimate.chargeUnit(), estimate.unitPrice(),
estimate.amount(), estimate.currency(), DISCLAIMER);
@@ -56,7 +56,7 @@ public class MerchantPriceBookService {
String destinationType, boolean requireInboundCodePrefix) {
requireMerchant(merchantTenantId);
if (originWarehouseId <= 0) throw validation("请选择有效起运仓");
Long versionId = repository.activeVersionId();
Long versionId = repository.effectiveVersionId(merchantTenantId);
String normalized = normalizeChannel(channelCode);
if (versionId == null || !repository.channelAvailable(versionId, originWarehouseId, normalized)) {
throw new BizException(ResultCode.CONFLICT, "所选渠道已下线,请重新选择");
@@ -117,7 +117,7 @@ public class OrderChannelChangePricingService {
request.channelCode(), request.destinationType(), false);
BigDecimal weight = positive(inbound.getActualNetWeightKg(), ELIGIBILITY_WEIGHT);
BigDecimal volume = positive(inbound.getActualVolumeCbm(), ELIGIBILITY_VOLUME);
return estimator.estimate(new EstimateRequest(request.originWarehouseId(), request.channelCode(),
return estimator.estimate(request.tenantId(), new EstimateRequest(request.originWarehouseId(), request.channelCode(),
request.destinationCode(), PricingDestinationType.valueOf(request.destinationType()),
request.destinationWarehouseId(), inbound.getDestinationName(), null,
PricingTaxMode.valueOf(request.pricingTaxMode()), weight, volume));
@@ -72,13 +72,26 @@ public class PlatformPriceBookService {
@Value("${baiyue.security.internal-service-token}") private String internalToken;
public List<PriceBookDtos.PriceBookVersionSummary> list(PriceBookStatus status) {
return list(PriceBookVersionEntity.PLATFORM_SCOPE, status);
}
public List<PriceBookDtos.PriceBookVersionSummary> list(long customerId, PriceBookStatus status) {
requireScope(customerId);
String persistedStatus = status == null ? null : status.name();
return repository.listVersions(persistedStatus).stream().map(this::summary).toList();
return (customerId == PriceBookVersionEntity.PLATFORM_SCOPE
? repository.listVersions(persistedStatus) : repository.listVersions(customerId, persistedStatus))
.stream().map(this::summary).toList();
}
public PriceBookDtos.PriceBookVersionDetail upload(long operatorId, MultipartFile file) {
return upload(operatorId, PriceBookVersionEntity.PLATFORM_SCOPE, file);
}
public PriceBookDtos.PriceBookVersionDetail upload(long operatorId, long customerId, MultipartFile file) {
requireScope(customerId);
requireFile(file);
PriceBookVersionEntity version = transactions.execute(status -> createVersion(operatorId, file.getOriginalFilename()));
PriceBookVersionEntity version = transactions.execute(status -> createVersion(
operatorId, customerId, file.getOriginalFilename()));
if (version == null) throw internal("价格版本创建失败");
Path temp = null;
try {
@@ -123,7 +136,8 @@ public class PlatformPriceBookService {
List<PriceBookChannelEntity> channelRows = repository.listChannels(versionId);
List<PriceBookDtos.ParseIssue> issues = new ArrayList<>(readIssues(version.getParseIssuesJson()));
if (PriceBookStatus.PENDING_CONFIRMATION.name().equals(version.getStatus())) {
PriceBookVersionEntity active = repository.findActive();
PriceBookVersionEntity active = version.getTenantId() == PriceBookVersionEntity.PLATFORM_SCOPE
? repository.findActive() : repository.findActive(version.getTenantId());
if (active != null && !Objects.equals(active.getId(), versionId)) {
Set<String> current = channelRows.stream().map(value -> normalizeChannel(value.getChannelCode())).collect(java.util.stream.Collectors.toSet());
repository.listChannels(active.getId()).stream()
@@ -140,7 +154,9 @@ public class PlatformPriceBookService {
@Transactional(noRollbackFor = BizException.class)
public PriceBookDtos.PriceBookVersionDetail publish(long versionId, long operatorId,
PriceBookConfirmationRequest confirmation) {
repository.lockPlatformVersions();
PriceBookVersionEntity candidate = requireVersion(versionId);
if (candidate.getTenantId() == PriceBookVersionEntity.PLATFORM_SCOPE) repository.lockPlatformVersions();
else repository.lockVersions(candidate.getTenantId());
PriceBookVersionEntity version = requirePendingConfirmation(versionId);
List<PriceBookDtos.ParsedChannel> channels = repository.listChannels(versionId).stream().map(this::channelDto).toList();
List<PriceBookDtos.ParsedRate> rates = repository.listRates(versionId).stream().map(this::rateDto).toList();
@@ -158,7 +174,8 @@ public class PlatformPriceBookService {
version.setChannelCount(channels.size());
version.setRateCount(mapped.rates().size());
LocalDateTime now = LocalDateTime.now();
repository.listActiveForUpdate().stream()
(version.getTenantId() == PriceBookVersionEntity.PLATFORM_SCOPE
? repository.listActiveForUpdate() : repository.listActiveForUpdate(version.getTenantId())).stream()
.filter(active -> !Objects.equals(active.getId(), versionId))
.forEach(active -> repository.supersede(active, now, operatorId));
repository.activate(version, now, operatorId);
@@ -182,10 +199,11 @@ public class PlatformPriceBookService {
}
}
private PriceBookVersionEntity createVersion(long operatorId, String originalFileName) {
private PriceBookVersionEntity createVersion(long operatorId, long customerId, String originalFileName) {
PriceBookVersionEntity value = new PriceBookVersionEntity();
value.setTenantId(PriceBookVersionEntity.PLATFORM_SCOPE);
value.setVersionNo(repository.nextVersionNo());
value.setTenantId(customerId);
value.setVersionNo(customerId == PriceBookVersionEntity.PLATFORM_SCOPE
? repository.nextVersionNo() : repository.nextVersionNo(customerId));
value.setOriginalFileName(safeFileName(originalFileName));
value.setStatus(PriceBookStatus.PENDING_CONFIRMATION.name());
value.setBlockingErrorCount(0); value.setWarningCount(0); value.setChannelCount(0); value.setRateCount(0);
@@ -228,7 +246,7 @@ public class PlatformPriceBookService {
private CarrierFbaRateEntity rateEntity(PriceBookVersionEntity version, PriceBookDtos.ParsedRate parsedRate,
long operatorId, int number, LocalDateTime now) {
CarrierFbaRateEntity value = new CarrierFbaRateEntity();
value.setTenantId(CarrierFbaRateEntity.PLATFORM_SCOPE); value.setPriceBookVersionId(version.getId());
value.setTenantId(version.getTenantId()); value.setPriceBookVersionId(version.getId());
value.setRateCode("PB-" + version.getVersionNo() + "-" + number); value.setRateVersion(1);
value.setOriginWarehouseId(parsedRate.originWarehouseId()); value.setOriginWarehouseCode(parsedRate.originWarehouseCode().trim());
value.setOriginWarehouseName(parsedRate.originWarehouseName().trim()); value.setBusinessType("FBA_SEA");
@@ -540,12 +558,16 @@ public class PlatformPriceBookService {
private PriceBookDtos.PriceBookVersionSummary summary(PriceBookVersionEntity value) {
PriceBookStatus status = PriceBookStatus.valueOf(value.getStatus());
return new PriceBookDtos.PriceBookVersionSummary(value.getId(), value.getVersionNo(), value.getOriginalFileName(),
return new PriceBookDtos.PriceBookVersionSummary(value.getId(), value.getTenantId(), value.getVersionNo(), value.getOriginalFileName(),
value.getSourceFileId(), value.getSourceSha256(), status,
value.getChannelCount(), value.getRateCount(), value.getBlockingErrorCount(), value.getWarningCount(),
value.getParseIssuesJson(), value.getCreatedBy(), value.getCreatedAt(), value.getPublishedBy(),
value.getPublishedAt(), value.getSupersededAt());
}
private static void requireScope(long customerId) {
if (customerId < 0) throw new BizException(ResultCode.VALIDATION_FAILED, "客户ID不能为负数");
}
private PriceBookDtos.ParsedChannel channelDto(PriceBookChannelEntity value) {
return new PriceBookDtos.ParsedChannel(value.getChannelCode(), value.getChannelName(), PriceModel.valueOf(value.getPriceModel()),
value.getSourceSheetName(), value.getEstimatedDepartureDate(), value.getEstimatedDepartureText(),
@@ -534,7 +534,7 @@ CREATE TABLE IF NOT EXISTS fl_price_warehouse_mapping (
CREATE TABLE IF NOT EXISTS fl_price_book_version (
id BIGINT UNSIGNED NOT NULL COMMENT '整表价格版本ID',
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共价格作用域固定为0',
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '报价作用域:0平台公共,正数为客户专属',
version_no INT UNSIGNED NOT NULL COMMENT '整表版本号',
original_file_name VARCHAR(255) NOT NULL COMMENT '原始Excel文件名',
source_file_id BIGINT UNSIGNED NULL COMMENT 'File服务原始文件ID',
@@ -72,6 +72,7 @@ paths:
summary: 查询船期价格表版本
description: 需要 firstleg:fba-pricing:rate:view 权限;返回待确认上传、当前生效和历史替换版本。
parameters:
- {name: customerId, in: query, schema: {type: string, pattern: '^(0|[1-9][0-9]*)$', default: '0'}, description: 0 表示公共报价;正数表示客户专属报价}
- {name: status, in: query, schema: {$ref: '#/components/schemas/PriceBookStatus'}}
responses:
'200':
@@ -84,7 +85,9 @@ paths:
'403': {description: 无权限}
post:
summary: 上传船期价格表并生成待确认记录
description: 仅上传和解析,生成待确认上传记录,不改变当前生效版本;需要 firstleg:fba-pricing:rate:edit 权限。
description: 仅上传和解析,生成待确认上传记录,不改变当前生效版本;customerId=0 为公共报价,customerId>0 为该客户的专属报价;需要 firstleg:fba-pricing:rate:edit 权限。
parameters:
- {name: customerId, in: query, schema: {type: string, pattern: '^(0|[1-9][0-9]*)$', default: '0'}}
requestBody:
required: true
content:
@@ -105,7 +108,7 @@ paths:
/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/publish:
post:
summary: 确认并立即生效船期价格表版本
description: 只确认上传表中的仓库映射,不允许在线改价;阻断错误必须为零。发布事务将上一 ACTIVE 版本标记为 SUPERSEDED,始终只保留一份 ACTIVE。
description: 只确认上传表中的仓库映射,不允许在线改价;阻断错误必须为零。发布事务只替换同 customerId 作用域的上一 ACTIVE 版本。计价时优先使用客户专属 ACTIVE 版本,不存在时回退公共 ACTIVE 版本。
parameters: [{$ref: '#/components/parameters/VersionId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PriceBookConfirmationRequest'}}}}
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
@@ -545,6 +548,7 @@ components:
type: object
properties:
id: {type: string, pattern: '^[1-9][0-9]*$'}
customerId: {type: string, pattern: '^(0|[1-9][0-9]*)$', description: 0 为公共报价,正数为客户专属报价}
versionNo: {type: integer}
originalFileName: {type: string, maxLength: 255}
sourceFileId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
@@ -25,11 +25,14 @@ class PriceBookQueryServiceTest {
void delegatesStableReadOperationsToTheOutputPort() {
ChannelPrefixRow prefix = new ChannelPrefixRow("SEA", "IN", "OUT");
when(reader.findActiveVersionId()).thenReturn(9L);
when(reader.findEffectiveVersionId(10001L)).thenReturn(10L);
when(reader.findChannelPrefix("SEA")).thenReturn(prefix);
assertThat(service.activeVersionId()).isEqualTo(9L);
assertThat(service.effectiveVersionId(10001L)).isEqualTo(10L);
assertThat(service.channelPrefix("SEA")).isSameAs(prefix);
verify(reader).findActiveVersionId();
verify(reader).findEffectiveVersionId(10001L);
verify(reader).findChannelPrefix("SEA");
}
@@ -122,7 +122,7 @@ class FbaPricingServiceTest {
inbound.setCarrierId(null);
inbound.setCarrierName(null);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(priceBookEstimator.estimate(any())).thenReturn(
when(priceBookEstimator.estimate(anyLong(), any())).thenReturn(
estimate("KG", "167.017", "2.345678", "391.77", "10.00"));
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-1");
@@ -164,7 +164,7 @@ class FbaPricingServiceTest {
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(measurementRepository.findLatestMeasurement(TENANT, INBOUND)).thenReturn(measurement);
when(measurementRepository.listItems(TENANT, 301L)).thenReturn(List.of(item));
when(priceBookEstimator.estimate(any())).thenReturn(
when(priceBookEstimator.estimate(anyLong(), any())).thenReturn(
estimate("CBM", "1.0019", "100", "100.19", "0"));
service.calculate(TENANT, OPERATOR, INBOUND, NOW,
@@ -202,7 +202,7 @@ class FbaPricingServiceTest {
InboundPricingEntity inbound = inbound("200.000", "0.2000");
inbound.setCustomerId(null);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(priceBookEstimator.estimate(any())).thenReturn(
when(priceBookEstimator.estimate(anyLong(), any())).thenReturn(
estimate("KG", "200.000", "2.000000", "400.00", "0.00"));
assertThat(service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-no-customer"))
@@ -223,7 +223,7 @@ class FbaPricingServiceTest {
rule.setId(601L); rule.setRuleCode("CBM-RULE"); rule.setRuleVersion(1);
rule.setChargeMode("CBM"); rule.setDefaultRule(true);
when(repository.matchRules(eq(inbound), any())).thenReturn(List.of(rule));
when(priceBookEstimator.estimate(any())).thenReturn(
when(priceBookEstimator.estimate(anyLong(), any())).thenReturn(
estimate("CBM", "1.0001", "100.000000", "100.01", "0.00"));
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-2");
@@ -245,7 +245,7 @@ class FbaPricingServiceTest {
InboundPricingEntity inbound = inbound("100.000", "1.0000");
inbound.setProcessStatus("WAIT_SPLIT");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(priceBookEstimator.estimate(any())).thenReturn(
when(priceBookEstimator.estimate(anyLong(), any())).thenReturn(
estimate("KG", "167.000", "2.000000", "334.00", "0.00"));
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "wait-split");
@@ -304,11 +304,11 @@ class FbaPricingServiceTest {
InboundPricingEntity inbound = inbound("10", "1");
inbound.setMeasureReviewStatus("REJECTED");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(priceBookEstimator.estimate(any())).thenReturn(
when(priceBookEstimator.estimate(anyLong(), any())).thenReturn(
estimate("CBM", "1.0000", "10", "10", "0"));
assertThat(service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-3")).isNotNull();
verify(priceBookEstimator).estimate(any());
verify(priceBookEstimator).estimate(eq(TENANT), any());
}
@Test
@@ -323,7 +323,7 @@ class FbaPricingServiceTest {
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent);
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
when(repository.supersedeCurrent(TENANT, 501L, "SENT")).thenReturn(1);
when(priceBookEstimator.estimate(any())).thenReturn(
when(priceBookEstimator.estimate(anyLong(), any())).thenReturn(
estimate("CBM", "1.0000", "10", "10", "0"));
FeeSnapshotResponse response = service.calculate(
@@ -365,7 +365,7 @@ class FbaPricingServiceTest {
readyRepository();
InboundPricingEntity inbound = inbound("10", "1");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(priceBookEstimator.estimate(any())).thenThrow(
when(priceBookEstimator.estimate(anyLong(), any())).thenThrow(
new BizException(ResultCode.NOT_FOUND, "当前条件没有匹配价格"));
assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "none"))
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.client.PrintableOrderInboundClient;
import com.baiyue.firstleg.entity.InboundCartonLabelEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.InboundCartonLabelRepository;
@@ -28,6 +29,7 @@ import static org.mockito.Mockito.when;
class InboundCartonLabelServiceTest {
@Mock private InboundCartonLabelRepository repository;
@Mock private OrderCargoClient orderCargoClient;
@Mock private PrintableOrderInboundClient printableOrderInboundClient;
@Mock private InboundOrderCreationService inboundOrderCreationService;
private InboundCartonLabelService service;
@@ -36,6 +38,7 @@ class InboundCartonLabelServiceTest {
service = new InboundCartonLabelService();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "orderCargoClient", orderCargoClient);
ReflectionTestUtils.setField(service, "printableOrderInboundClient", printableOrderInboundClient);
ReflectionTestUtils.setField(service, "inboundOrderCreationService", inboundOrderCreationService);
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
}
@@ -222,7 +225,7 @@ class InboundCartonLabelServiceTest {
@Test
void rejectsAnOrderWithoutAnOwnedPreparingInbound() {
when(repository.findByOrder(10001L, 60001L)).thenReturn(null);
when(orderCargoClient.inboundSnapshot("internal-token", 10001L, 60001L))
when(printableOrderInboundClient.reserve("internal-token", 10001L, 60001L))
.thenThrow(org.mockito.Mockito.mock(feign.FeignException.class));
assertThatThrownBy(() -> service.requireInboundIdByOrder(10001L, 60001L))
@@ -239,7 +242,7 @@ class InboundCartonLabelServiceTest {
null, null, null, 2);
InboundOrderEntity repaired = inbound();
when(repository.findByOrder(10001L, 60001L)).thenReturn(null, repaired);
when(orderCargoClient.inboundSnapshot("internal-token", 10001L, 60001L))
when(printableOrderInboundClient.reserve("internal-token", 10001L, 60001L))
.thenReturn(ApiResponse.success(snapshot));
assertThat(service.requireInboundIdByOrder(10001L, 60001L)).isEqualTo(70001L);
@@ -29,6 +29,7 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -44,7 +45,7 @@ class MerchantPriceBookServiceTest {
@Test
void shouldResolveNormalAndOverseasInboundCodePrefixesFromChannelCatalog() {
when(repository.activeVersionId()).thenReturn(7L);
when(repository.effectiveVersionId(anyLong())).thenReturn(7L);
when(repository.channelAvailable(7L, 10L, "MATSON_CLX_TRUCK")).thenReturn(true);
when(repository.channels(7L, 10L)).thenReturn(List.of(
channel(7L, "MATSON_CLX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE)));
@@ -59,7 +60,7 @@ class MerchantPriceBookServiceTest {
@Test
void shouldFallBackToNormalPrefixWhenChannelHasNoOverseasVariant() {
when(repository.activeVersionId()).thenReturn(7L);
when(repository.effectiveVersionId(anyLong())).thenReturn(7L);
when(repository.channelAvailable(7L, 10L, "OA_HOUSTON_TRUCK")).thenReturn(true);
when(repository.channels(7L, 10L)).thenReturn(List.of(
channel(7L, "OA_HOUSTON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE)));
@@ -72,7 +73,7 @@ class MerchantPriceBookServiceTest {
@Test
void shouldValidateLegacyChannelWithoutRequiringAnInboundCodePrefix() {
when(repository.activeVersionId()).thenReturn(7L);
when(repository.effectiveVersionId(anyLong())).thenReturn(7L);
when(repository.channelAvailable(7L, 10L, "LEGACY_CHANNEL")).thenReturn(true);
var response = service.requireAvailableChannel(
10001L, 10L, "LEGACY_CHANNEL", "FBA", false);
@@ -84,7 +85,7 @@ class MerchantPriceBookServiceTest {
@Test
void shouldReturnEmptyChannelsWithoutActiveVersion() {
when(repository.activeVersionId()).thenReturn(null);
when(repository.effectiveVersionId(anyLong())).thenReturn(null);
assertThat(service.channels(10001L, 10L)).isEmpty();
verify(repository, never()).channels(1L, 10L);
@@ -93,7 +94,7 @@ class MerchantPriceBookServiceTest {
@Test
void shouldExposeOnlyChannelsFromLatestActiveVersionAfterSwitch() {
when(repository.activeVersionId()).thenReturn(1L, 2L);
when(repository.effectiveVersionId(anyLong())).thenReturn(1L, 2L);
when(repository.channels(1L, 10L)).thenReturn(List.of(channel(1L, "OLD", PriceModel.FBA_WAREHOUSE_RATE)));
when(repository.channels(2L, 10L)).thenReturn(List.of(channel(2L, "NEW", PriceModel.FBA_WAREHOUSE_RATE)));
@@ -105,7 +106,7 @@ class MerchantPriceBookServiceTest {
@Test
void shouldExposeDistinctPricingTaxModesForActiveOriginChannels() {
when(repository.activeVersionId()).thenReturn(7L);
when(repository.effectiveVersionId(anyLong())).thenReturn(7L);
when(repository.channels(7L, 10L)).thenReturn(List.of(
channel(7L, "MATSON_CLX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE),
channel(7L, "OA_PARCEL", PriceModel.REGION_WEIGHT_TIER)));
@@ -124,7 +125,7 @@ class MerchantPriceBookServiceTest {
@Test
void shouldKeepActiveChannelWhenItsTaxModeProjectionIsEmpty() {
when(repository.activeVersionId()).thenReturn(7L);
when(repository.effectiveVersionId(anyLong())).thenReturn(7L);
when(repository.channels(7L, 10L)).thenReturn(List.of(
channel(7L, "MATSON_CLX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE)));
when(repository.channelTaxModes(7L, 10L)).thenReturn(List.of());
@@ -136,7 +137,7 @@ class MerchantPriceBookServiceTest {
@Test
void shouldRejectForgedOrRemovedChannel() {
when(repository.activeVersionId()).thenReturn(2L);
when(repository.effectiveVersionId(anyLong())).thenReturn(2L);
when(repository.channels(2L, 10L)).thenReturn(List.of(channel(2L, "ACTIVE_CHANNEL",
PriceModel.FBA_WAREHOUSE_RATE)));
@@ -282,7 +283,7 @@ class MerchantPriceBookServiceTest {
}
private void stubChannel(long versionId, String code, PriceModel model) {
when(repository.activeVersionId()).thenReturn(versionId);
when(repository.effectiveVersionId(anyLong())).thenReturn(versionId);
when(repository.channels(versionId, 10L)).thenReturn(List.of(channel(versionId, code, model)));
}
@@ -18,6 +18,7 @@ import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -48,7 +49,7 @@ class OrderChannelChangePricingServiceTest {
InboundPricingEntity inbound = inbound("CALCULATED");
when(repository.findInboundByOrder(10001L, 77L)).thenReturn(inbound);
when(repository.findLatestFee(10001L, 88L)).thenReturn(fee(91L, "CALCULATED"));
when(estimator.estimate(any())).thenReturn(estimate());
when(estimator.estimate(anyLong(), any())).thenReturn(estimate());
var response = service.validate(request(4));
@@ -64,7 +65,7 @@ class OrderChannelChangePricingServiceTest {
InboundFeeEntity fee = fee(91L, "CALCULATED");
when(repository.findInboundByOrderForUpdate(10001L, 77L)).thenReturn(inbound);
when(repository.findLatestFeeForUpdate(10001L, 88L)).thenReturn(fee);
when(estimator.estimate(any())).thenReturn(estimate());
when(estimator.estimate(anyLong(), any())).thenReturn(estimate());
when(repository.supersedeCurrent(10001L, 91L, "CALCULATED")).thenReturn(1);
when(repository.applyOrderChannelChange(inbound, "MATSON_NEW", "新渠道")).thenReturn(1);
when(repository.completeIdempotency(any(), org.mockito.ArgumentMatchers.eq(4L), any())).thenReturn(1);
@@ -88,7 +89,7 @@ class OrderChannelChangePricingServiceTest {
token.setRequestHash(requestHash(request(4)));
when(repository.findInboundByOrderForUpdate(10001L, 77L)).thenReturn(inbound);
when(repository.findIdempotency(10001L, 0L, "ORDER_CHANNEL_CHANGE", "77:4")).thenReturn(token);
when(estimator.estimate(any())).thenReturn(estimate());
when(estimator.estimate(anyLong(), any())).thenReturn(estimate());
assertThat(service.apply(request(4)).orderVersion()).isEqualTo(4L);
@@ -64,6 +64,22 @@ class PlatformPriceBookServiceTest {
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
}
@Test
void listsCustomerSpecificVersionsWithinRequestedScope() {
PriceBookVersionEntity customerVersion = version(21L, PriceBookStatus.ACTIVE);
customerVersion.setTenantId(10001L);
when(repository.listVersions(10001L, PriceBookStatus.ACTIVE.name()))
.thenReturn(List.of(customerVersion));
var result = service.list(10001L, PriceBookStatus.ACTIVE);
assertThat(result).singleElement().satisfies(summary -> {
assertThat(summary.id()).isEqualTo(21L);
assertThat(summary.customerId()).isEqualTo(10001L);
});
verify(repository).listVersions(10001L, PriceBookStatus.ACTIVE.name());
}
@Test
void fileServiceFailureMarksAllocatedVersionFailed() {
executeTransactionsImmediately();
@@ -121,7 +137,7 @@ class PlatformPriceBookServiceTest {
assertThat(Arrays.stream(PlatformPriceBookService.class.getDeclaredMethods())
.filter(method -> java.lang.reflect.Modifier.isPublic(method.getModifiers()))
.map(java.lang.reflect.Method::getName).toList())
.containsExactlyInAnyOrder("list", "upload", "detail", "publish", "source");
.containsOnly("list", "upload", "detail", "publish", "source");
}
@Test
@@ -15,6 +15,7 @@ import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
@@ -43,6 +44,7 @@ public class InboundStockReceiptService {
@Resource private InboundStockReceiptMapper receipts;
@Resource private ReceivingSkuReceiptMapper receivingSkuReceipts;
@Resource private InventoryCommandService inventory;
@Resource private WarehouseResourceGateway warehouseGateway;
@Resource private DropshipOrderService dropshipOrders;
@Resource private LastlegOrderStatusHistoryMapper orderHistories;
@@ -237,13 +239,20 @@ public class InboundStockReceiptService {
if (overseasWarehouseId.equals(trim(order.getSourceWarehouseId()))) {
throw conflict("Actual overseas warehouse must not use the domestic source warehouse");
}
WarehouseResourceGateway.WarehouseLocationReference location = warehouseGateway.resolveLocation(
Long.parseLong(overseasWarehouseId), pallet.getWarehouseLocationId());
if (location == null || location.locationId() != pallet.getWarehouseLocationId()) {
throw conflict("Assigned warehouse location could not be resolved for stock-in");
}
String warehouseLocationCode = required(location.locationCode(),
"Assigned warehouse location code is required for stock-in");
InventoryCommandRequests.StockIn stockIn = new InventoryCommandRequests.StockIn(null, customerName,
overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName,
line.getDestinationWarehouseId(), destinationCode,
line.getDestinationWarehouseName(), order.getId(), plan.getPlanNo(), line.getInboundCode(),
sku.skuCode(), sku.productNameZh(), sku.productNameEn(),
pallet.getWarehousePalletId(), pallet.getPalletNo(), plan.getPlanNo(),
pallet.getWarehouseLocationId(), null, packages, order.getOrderNo(),
pallet.getWarehouseLocationId(), warehouseLocationCode, packages, order.getOrderNo(),
order.getSourceEventId(), operationKey,
"PDA inbound completion for " + line.getInboundCode());
InventoryCommandResponse result = Boolean.TRUE.equals(order.getDetained())
@@ -14,6 +14,7 @@ import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
@@ -50,6 +51,7 @@ class InboundStockReceiptServiceTest {
@Mock InboundStockReceiptMapper receipts;
@Mock ReceivingSkuReceiptMapper receivingSkuReceipts;
@Mock InventoryCommandService inventory;
@Mock WarehouseResourceGateway warehouseGateway;
@Mock DropshipOrderService dropshipOrders;
@Mock LastlegOrderStatusHistoryMapper orderHistories;
private InboundStockReceiptService service;
@@ -62,6 +64,12 @@ class InboundStockReceiptServiceTest {
ReflectionTestUtils.setField(service, "receipts", receipts);
ReflectionTestUtils.setField(service, "receivingSkuReceipts", receivingSkuReceipts);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway);
org.mockito.Mockito.lenient().when(warehouseGateway.resolveLocation(eq(900L), anyLong())).thenAnswer(invocation -> {
long locationId = invocation.getArgument(1);
return new WarehouseResourceGateway.WarehouseLocationReference(locationId,
"PDA-LAX-" + locationId, "STORAGE", BigDecimal.TEN, "OCCUPIED");
});
ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders);
ReflectionTestUtils.setField(service, "orderHistories", orderHistories);
when(receipts.listTask("100", 2L)).thenReturn(List.of());
@@ -88,6 +96,7 @@ class InboundStockReceiptServiceTest {
assertEquals("900", request.getValue().overseasWarehouseId());
assertEquals("US-LAX-01", request.getValue().overseasWarehouseCode());
assertEquals("Los Angeles", request.getValue().overseasWarehouseName());
assertEquals("PDA-LAX-3001", request.getValue().warehouseLocationCode());
assertEquals("PLT-21", request.getValue().palletNo());
ArgumentCaptor<InboundStockReceipt> receipt = ArgumentCaptor.forClass(InboundStockReceipt.class);
verify(receipts).insert(receipt.capture());
@@ -9,10 +9,12 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.entity.OrderCargoEntity;
import com.baiyue.order.repository.OrderRepository;
import com.baiyue.order.service.OrderApplicationService;
import com.baiyue.order.service.PrintableInboundSnapshotService;
import com.baiyue.order.client.FirstlegInboundClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -29,6 +31,7 @@ import java.util.stream.Collectors;
@RequestMapping("/internal/order/v1/cargoes")
public class InternalOrderCargoController {
@Resource private OrderRepository orders;
@Resource private PrintableInboundSnapshotService printableInboundSnapshots;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
private byte[] expectedToken;
@@ -112,6 +115,18 @@ public class InternalOrderCargoController {
return ApiResponse.success(OrderApplicationService.inboundCommand(order, packageCount));
}
@PostMapping("/{tenantId}/{orderId}/printable-inbound-snapshot")
public ApiResponse<FirstlegInboundClient.OrderApprovedInboundCommand> printableInboundSnapshot(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long tenantId,
@PathVariable long orderId) {
requireToken(token);
if (tenantId <= 0 || orderId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "租户和订单ID必须大于0");
}
return ApiResponse.success(printableInboundSnapshots.reserve(tenantId, orderId));
}
@GetMapping("/{tenantId}/{orderId}/measurement-declaration")
public ApiResponse<MeasurementDeclarationSnapshot> measurementDeclaration(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@@ -6,6 +6,7 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.order.dto.BatchOrderReviewResponse;
import com.baiyue.order.dto.BatchOrderReviewPreviewResponse;
import com.baiyue.order.dto.OrderDetailResponse;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.dto.InboundCodePolicyResponse;
@@ -17,6 +18,7 @@ import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.service.OrderApplicationService;
import com.baiyue.order.service.OrderScopeGuard;
import com.baiyue.order.service.InboundCodeAssociationService;
import com.baiyue.order.service.BatchOrderReviewPreviewService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
@@ -51,6 +53,8 @@ public class PlatformFirstLegOrderController {
private OrderApplicationService service;
@Resource
private InboundCodeAssociationService inboundCodeAssociations;
@Resource
private BatchOrderReviewPreviewService batchReviewPreview;
@GetMapping("/inbound-code-policy")
@PreAuthorize("hasAnyAuthority('platform:order:list', 'platform:order:create', 'platform:order:edit', 'platform:order:submit')")
@@ -192,15 +196,26 @@ public class PlatformFirstLegOrderController {
}
@BusinessAudit(event = "ORDER_BATCH_APPROVE", resourceType = "FIRST_LEG_ORDER_BATCH",
resourceId = "#request.orderIds", tenantId = "#tenantId", description = "批量审核通过订单")
@Operation(summary = "批量审核通过订单", description = "单租户模式下逐单独立审核,返回成功和失败明细。")
resourceId = "#request.items", tenantId = "#tenantId", description = "批量审核通过订单")
@Operation(summary = "批量审核通过订单", description = "单租户模式下逐票校验权限、状态及预览版本并独立提交;允许部分成功,返回成功和失败明细。")
@PostMapping("/batch-approve")
@PreAuthorize("hasAuthority('platform:order:approve')")
public ApiResponse<BatchOrderReviewResponse> batchApprove(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@Valid @RequestBody OrderRequests.BatchReviewRequest request) {
return batchTransit(user, tenantId, request.orderIds(), OrderStatus.APPROVED, null);
return batchApproveTransit(user, tenantId, request.items());
}
@Operation(summary = "批量审核预览", description = "一次批量查询返回审核弹窗所需订单与中文货物摘要,固定两次数据库查询,不逐票读取详情。")
@PostMapping("/batch-review-preview")
@PreAuthorize("hasAuthority('platform:order:approve')")
public ApiResponse<BatchOrderReviewPreviewResponse> batchReviewPreview(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@Valid @RequestBody OrderRequests.BatchReviewRequest request) {
long scopedTenantId = OrderScopeGuard.platform(user, tenantId);
return ApiResponse.success(batchReviewPreview.preview(scopedTenantId, request.items()));
}
@BusinessAudit(event = "ORDER_REJECT", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Reject order / 驳回订单")
@@ -320,4 +335,23 @@ public class PlatformFirstLegOrderController {
return ApiResponse.success(new BatchOrderReviewResponse(orderIds.size(), successOrderIds.size(),
failedOrders.size(), List.copyOf(successOrderIds), List.copyOf(failedOrders)));
}
private ApiResponse<BatchOrderReviewResponse> batchApproveTransit(
AuthenticatedUser user, long tenantId, List<OrderRequests.BatchReviewItem> items) {
long scopedTenantId = OrderScopeGuard.platform(user, tenantId);
List<Long> successOrderIds = new ArrayList<>();
List<BatchOrderReviewResponse.FailedOrder> failedOrders = new ArrayList<>();
for (OrderRequests.BatchReviewItem item : items) {
try {
service.transit(scopedTenantId, user.userId(), user.username(), item.orderId(),
OrderStatus.APPROVED, null, item.orderVersion());
successOrderIds.add(item.orderId());
} catch (BizException exception) {
failedOrders.add(new BatchOrderReviewResponse.FailedOrder(
item.orderId(), exception.getResultCode().code(), exception.getMessage()));
}
}
return ApiResponse.success(new BatchOrderReviewResponse(items.size(), successOrderIds.size(),
failedOrders.size(), List.copyOf(successOrderIds), List.copyOf(failedOrders)));
}
}
@@ -0,0 +1,28 @@
package com.baiyue.order.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
/** 平台批量审核前的一次性稳定展示快照。 */
@Schema(description = "订单批量审核预览")
public record BatchOrderReviewPreviewResponse(
int total,
int approvableCount,
List<Item> items
) {
public record Item(
Long orderId,
Integer orderVersion,
String inboundCode,
Long customerId,
String customerName,
List<String> chineseGoodsNames,
String channelCode,
String channelName,
String destinationWarehouseCode,
String destinationWarehouseName,
boolean canApprove,
String blockReason
) {}
}
@@ -268,15 +268,22 @@ public final class OrderRequests {
@Schema(name = "FirstLegOrderBatchReviewRequest", description = "单租户订单批量审核请求")
public record BatchReviewRequest(
@Schema(description = "订单 ID 列表,单次最多 100 条", requiredMode = Schema.RequiredMode.REQUIRED)
@NotEmpty @Size(max = 100) List<@NotNull @Min(1) Long> orderIds
@Schema(description = "订单及列表快照版本,单次最多 100 条", requiredMode = Schema.RequiredMode.REQUIRED)
@NotEmpty @Size(max = 100) List<@Valid @NotNull BatchReviewItem> items
) {
@AssertTrue(message = "订单 ID 不能重复")
public boolean hasDistinctOrderIds() {
return orderIds == null || orderIds.size() == orderIds.stream().distinct().count();
return items == null || items.size() == items.stream()
.map(item -> item == null ? null : item.orderId()).distinct().count();
}
}
@Schema(name = "FirstLegOrderBatchReviewItem", description = "批量审核订单及其列表快照版本")
public record BatchReviewItem(
@NotNull @Min(1) Long orderId,
@NotNull @Min(0) Integer orderVersion
) {}
@Schema(name = "FirstLegOrderBatchRejectRequest", description = "单租户订单批量驳回请求")
public record BatchRejectRequest(
@Schema(description = "订单 ID 列表,单次最多 100 条", requiredMode = Schema.RequiredMode.REQUIRED)
@@ -54,6 +54,17 @@ public class OrderRepository {
.eq(OrderEntity::getId, order.getId())
.eq(OrderEntity::getTenantId, tenantId));
}
public int assignInboundCodeIfBlank(long tenantId, long orderId, String inboundCode) {
return orderMapper.update(null, new LambdaUpdateWrapper<OrderEntity>()
.eq(OrderEntity::getTenantId, tenantId)
.eq(OrderEntity::getId, orderId)
.eq(OrderEntity::getDeleted, false)
.and(wrapper -> wrapper.isNull(OrderEntity::getInboundCode)
.or().eq(OrderEntity::getInboundCode, ""))
.set(OrderEntity::getInboundCode, inboundCode)
.set(OrderEntity::getUpdatedAt, LocalDateTime.now())
.setSql("version = version + 1"));
}
public int updateDeclarationHeader(long tenantId, long orderId, int expectedVersion, long operatorId,
String customerRemark, LocalDateTime expectedArrivalAt,
java.math.BigDecimal estimatedVolumeCbm,
@@ -0,0 +1,63 @@
package com.baiyue.order.service;
import com.baiyue.order.dto.BatchOrderReviewPreviewResponse;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.entity.OrderCargoEntity;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.repository.OrderRepository;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/** 用固定两次批量查询生成审核弹窗快照,禁止前端逐票查询详情。 */
@Service
public class BatchOrderReviewPreviewService {
@Resource
private OrderRepository orders;
public BatchOrderReviewPreviewResponse preview(long tenantId, List<OrderRequests.BatchReviewItem> requested) {
List<Long> orderIds = requested.stream().map(OrderRequests.BatchReviewItem::orderId).toList();
Map<Long, OrderEntity> byId = new LinkedHashMap<>();
orders.findByIds(tenantId, orderIds).forEach(order -> byId.put(order.getId(), order));
Map<Long, List<String>> goodsNames = new LinkedHashMap<>();
for (OrderCargoEntity cargo : orders.findCargoByOrderIds(tenantId, orderIds)) {
String name = cargo.getCargoName() == null ? null : cargo.getCargoName().trim();
if (name == null || name.isEmpty()) continue;
List<String> names = goodsNames.computeIfAbsent(cargo.getOrderId(), ignored -> new ArrayList<>());
if (!names.contains(name)) names.add(name);
}
List<BatchOrderReviewPreviewResponse.Item> items = requested.stream()
.map(item -> toItem(item, byId.get(item.orderId()), goodsNames.getOrDefault(item.orderId(), List.of())))
.toList();
int approvableCount = (int) items.stream().filter(BatchOrderReviewPreviewResponse.Item::canApprove).count();
return new BatchOrderReviewPreviewResponse(items.size(), approvableCount, items);
}
private static BatchOrderReviewPreviewResponse.Item toItem(OrderRequests.BatchReviewItem requested,
OrderEntity order,
List<String> goodsNames) {
if (order == null) {
return new BatchOrderReviewPreviewResponse.Item(requested.orderId(), requested.orderVersion(),
null, null, null, List.of(), null, null, null, null, false,
"订单不存在或不属于当前客户");
}
String blockReason = null;
if (order.getStatus() != OrderStatus.SUBMITTED) {
blockReason = "当前订单状态不可审核";
} else if (!Objects.equals(order.getVersion(), requested.orderVersion())) {
blockReason = "订单版本已变化,请刷新后重试";
}
return new BatchOrderReviewPreviewResponse.Item(order.getId(), order.getVersion(), order.getInboundCode(),
order.getCustomerId(), order.getCustomerName(), List.copyOf(goodsNames), order.getChannelCode(),
order.getChannelName(), order.getAmazonWarehouseCode(), order.getDestinationWarehouseName(),
blockReason == null, blockReason);
}
}
@@ -148,7 +148,16 @@ public class OrderApplicationService {
@Transactional
public void transit(long tenantId, long userId, String username, long id, OrderStatus target, String reason) {
transit(tenantId, userId, username, id, target, reason, null);
}
@Transactional
public void transit(long tenantId, long userId, String username, long id, OrderStatus target, String reason,
Integer expectedVersion) {
OrderEntity order = require(tenantId, id);
if (expectedVersion != null && !expectedVersion.equals(order.getVersion())) {
throw new BizException(ResultCode.CONFLICT, "订单版本已变化,请刷新后重试");
}
if (target == OrderStatus.SUBMITTED) eligibilityGuard.requireApproved(tenantId);
OrderStatus from = order.getStatus();
if (!allowed(from, target)) throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
@@ -0,0 +1,64 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.FirstlegInboundClient;
import com.baiyue.order.config.InboundCodeModeProvider;
import com.baiyue.order.entity.OrderCargoEntity;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.repository.OrderRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
/** 为草稿箱唛打印预留稳定入仓码;订单提交时继续复用该码。 */
@Service
public class PrintableInboundSnapshotService {
private final OrderRepository orders;
private final OrderPricingChannelGuard pricingChannels;
private final InboundCodeModeProvider inboundCodeMode;
private final OrderApplicationService orderApplication;
public PrintableInboundSnapshotService(OrderRepository orders,
OrderPricingChannelGuard pricingChannels,
InboundCodeModeProvider inboundCodeMode,
OrderApplicationService orderApplication) {
this.orders = orders;
this.pricingChannels = pricingChannels;
this.inboundCodeMode = inboundCodeMode;
this.orderApplication = orderApplication;
}
@Transactional
public FirstlegInboundClient.OrderApprovedInboundCommand reserve(long tenantId, long orderId) {
OrderEntity order = requireOwned(tenantId, orderId);
if (!StringUtils.hasText(order.getInboundCode())) {
if (inboundCodeMode.legacyManual()) {
throw new BizException(ResultCode.VALIDATION_FAILED, "当前系统使用旧系统入仓码,请先填写入仓码");
}
String prefix = pricingChannels.requireAvailable(tenantId, order.getReceivingWarehouseId(),
order.getChannelCode(), order.getDestinationType());
String allocated = orderApplication.nextInboundCode(prefix);
if (orders.assignInboundCodeIfBlank(tenantId, orderId, allocated) == 1) {
order.setInboundCode(allocated);
} else {
order = requireOwned(tenantId, orderId);
if (!StringUtils.hasText(order.getInboundCode())) {
throw new BizException(ResultCode.CONFLICT, "入仓码预留冲突,请刷新后重试");
}
}
}
int packageCount = orders.findCargoInheritedByOrder(tenantId, orderId).stream()
.map(OrderCargoEntity::getPackageCount).filter(java.util.Objects::nonNull)
.mapToInt(Integer::intValue).sum();
return OrderApplicationService.inboundCommand(order, packageCount);
}
private OrderEntity requireOwned(long tenantId, long orderId) {
OrderEntity order = orders.findOwned(tenantId, orderId);
if (order == null) {
throw new BizException(ResultCode.NOT_FOUND, "订单不存在或不属于当前客户");
}
return order;
}
}
@@ -28,6 +28,23 @@ paths:
application/json:
schema: {$ref: '#/components/schemas/CargoLabelBindingListEnvelope'}
'403': {description: 内部服务令牌无效}
/internal/order/v1/cargoes/{tenantId}/{orderId}/printable-inbound-snapshot:
post:
security: []
summary: 为 Firstleg 箱唛打印读取并预留稳定入仓快照
description: >-
仅接受内部服务令牌。若 AUTO 模式草稿尚无入仓码,则按订单渠道原子预留全局唯一入仓码;
并发重放返回已胜出的同一码,后续订单提交继续复用,避免已打印二维码失效。
parameters:
- name: X-Internal-Service-Token
in: header
required: true
schema: {type: string}
- {name: tenantId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: orderId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
'200': {description: 返回带稳定入仓码及总箱数的订单快照}
'403': {description: 内部服务令牌无效}
/internal/order/v1/pending-review-orders:
get:
security: []
@@ -491,6 +508,45 @@ paths:
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 需要 platform:order:create 权限}
/api/platform/v1/orders/first-leg/batch-review-preview:
post:
tags: [Platform first-leg orders]
summary: 批量审核预览
description: >-
在一个 HTTP 请求中按目标客户批量返回审核弹窗所需稳定快照。服务端固定使用订单与货物两次批量查询,
中文品名按货物 itemNo 顺序去重;不得由前端逐票请求详情。orderId 仅供路由,主要展示使用 inboundCode;
canApprove=false 时必须展示 blockReason。请求中的 orderVersion 用于识别列表快照是否过期。
parameters:
- $ref: '#/components/parameters/TenantId'
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/BatchReviewRequest'}
responses:
'200':
description: 返回请求顺序一致的批量审核预览
content:
application/json:
schema: {$ref: '#/components/schemas/BatchReviewPreviewEnvelope'}
'403': {description: 需要平台身份和 platform:order:approve 权限}
/api/platform/v1/orders/first-leg/batch-approve:
post:
tags: [Platform first-leg orders]
summary: 批量审核通过订单
description: >-
单客户范围内逐票重新校验权限、状态和 orderVersion,每票独立事务;允许部分成功,
failedOrders 返回未成功订单及业务原因,成功订单不会因其他订单失败而回滚。
parameters:
- $ref: '#/components/parameters/TenantId'
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/BatchReviewRequest'}
responses:
'200': {description: 返回部分成功语义的逐票处理汇总}
'403': {description: 需要平台身份和 platform:order:approve 权限}
/api/platform/v1/orders/first-leg/tenant-statistics:
get:
tags: [Platform first-leg orders]
@@ -635,6 +691,54 @@ components:
widthCm: {type: number, nullable: true}
heightCm: {type: number, nullable: true}
shipmentId: {type: string, nullable: true, description: 当前货物行的 FBA 货件号快照,不以订单级字段回退}
BatchReviewRequest:
type: object
required: [items]
properties:
items:
type: array
minItems: 1
maxItems: 100
uniqueItems: true
items: {$ref: '#/components/schemas/BatchReviewRequestItem'}
BatchReviewRequestItem:
type: object
required: [orderId, orderVersion]
properties:
orderId: {type: string, pattern: '^[1-9][0-9]*$', description: 仅供路由关联,不作为主要展示键}
orderVersion: {type: integer, minimum: 0, description: 统一订单列表返回的 Order 乐观锁版本}
BatchReviewPreviewEnvelope:
type: object
required: [code, data]
properties:
code: {type: string, example: "200"}
message: {type: string, nullable: true}
data: {$ref: '#/components/schemas/BatchReviewPreview'}
BatchReviewPreview:
type: object
required: [total, approvableCount, items]
properties:
total: {type: integer, minimum: 1, maximum: 100}
approvableCount: {type: integer, minimum: 0, maximum: 100}
items:
type: array
items: {$ref: '#/components/schemas/BatchReviewPreviewItem'}
BatchReviewPreviewItem:
type: object
required: [orderId, orderVersion, chineseGoodsNames, canApprove]
properties:
orderId: {type: string, pattern: '^[1-9][0-9]*$', description: 仅供路由关联,不作为主要展示键}
orderVersion: {type: integer, minimum: 0}
inboundCode: {type: string, nullable: true, description: 权威入仓码;缺失时为 null,不以内部 ID 回退}
customerId: {type: string, nullable: true, pattern: '^[1-9][0-9]*$'}
customerName: {type: string, nullable: true}
chineseGoodsNames: {type: array, items: {type: string}, description: 按订单货物顺序去重的中文品名}
channelCode: {type: string, nullable: true}
channelName: {type: string, nullable: true}
destinationWarehouseCode: {type: string, nullable: true, description: Order 保存的目的仓业务编码;缺失时不以内部 ID 回退}
destinationWarehouseName: {type: string, nullable: true}
canApprove: {type: boolean}
blockReason: {type: string, nullable: true}
DomesticWarehouseOrderExportBatchRequest:
type: object
required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit]
@@ -0,0 +1,30 @@
package com.baiyue.order.controller;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class BatchReviewOpenApiContractTest {
@Test
void documentsSingleRequestPreviewFieldsAndVersionedPartialApproval() throws Exception {
String yaml = Files.readString(Path.of("src/main/resources/openapi/order-v1.yaml"));
assertThat(yaml)
.contains("/api/platform/v1/orders/first-leg/batch-review-preview:")
.contains("/api/platform/v1/orders/first-leg/batch-approve:")
.contains("inboundCode:")
.contains("customerName:")
.contains("chineseGoodsNames:")
.contains("channelCode:")
.contains("channelName:")
.contains("destinationWarehouseCode:")
.contains("destinationWarehouseName:")
.contains("orderVersion:")
.contains("canApprove:")
.contains("blockReason:")
.contains("允许部分成功");
}
}
@@ -5,6 +5,8 @@ import com.baiyue.order.entity.OrderCargoEntity;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderInternalNoteEntity;
import com.baiyue.order.repository.OrderRepository;
import com.baiyue.order.service.OrderApplicationService;
import com.baiyue.order.service.PrintableInboundSnapshotService;
import org.junit.jupiter.api.Test;
import jakarta.annotation.Resource;
import org.springframework.boot.test.mock.mockito.MockBean;
@@ -23,11 +25,13 @@ import java.lang.reflect.Method;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@SpringJUnitConfig(InternalOrderCargoController.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
class InternalOrderCargoControllerTest {
@MockBean private OrderRepository orders;
@MockBean private PrintableInboundSnapshotService printableInboundSnapshots;
@Resource private InternalOrderCargoController controller;
@Test
@@ -206,6 +210,24 @@ class InternalOrderCargoControllerTest {
verify(orders).findOwned(11L, 21L);
}
@Test
void reservesPrintableSnapshotThroughExplicitCommandRoute() throws Exception {
OrderEntity order = new OrderEntity();
order.setTenantId(11L); order.setId(21L); order.setDestinationType("FBA");
order.setTransportMode("SEA");
var snapshot = OrderApplicationService.inboundCommand(order, 3);
when(printableInboundSnapshots.reserve(11L, 21L)).thenReturn(snapshot);
var response = controller.printableInboundSnapshot("internal-token", 11L, 21L);
assertThat(response.data()).isSameAs(snapshot);
verify(printableInboundSnapshots).reserve(11L, 21L);
Method route = InternalOrderCargoController.class.getMethod(
"printableInboundSnapshot", String.class, long.class, long.class);
assertThat(route.getAnnotation(PostMapping.class).value())
.containsExactly("/{tenantId}/{orderId}/printable-inbound-snapshot");
}
@Test
void measurementDeclarationUsesOrderReferenceAndSumsCargoFacts() {
OrderEntity order = new OrderEntity();
@@ -4,10 +4,12 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.order.dto.BatchOrderReviewResponse;
import com.baiyue.order.dto.BatchOrderReviewPreviewResponse;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.service.OrderApplicationService;
import com.baiyue.order.service.InboundCodeAssociationService;
import com.baiyue.order.service.BatchOrderReviewPreviewService;
import org.junit.jupiter.api.Test;
import jakarta.annotation.Resource;
import org.springframework.boot.test.mock.mockito.MockBean;
@@ -23,11 +25,13 @@ import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(PlatformFirstLegOrderController.class)
class PlatformOrderPageControllerTest {
@MockBean private OrderApplicationService service;
@MockBean private InboundCodeAssociationService inboundCodeAssociations;
@MockBean private BatchOrderReviewPreviewService batchReviewPreview;
@Resource private PlatformFirstLegOrderController controller;
private final OrderRequests.QueryRequest query = new OrderRequests.QueryRequest(
null, null, null, null, null, null, 1, 20);
@@ -77,10 +81,13 @@ class PlatformOrderPageControllerTest {
@Test
void batchApproveReturnsPartialFailureAndContinuesRemainingOrders() {
doThrow(new BizException(ResultCode.CONFLICT, "订单已审核"))
.when(service).transit(10001L, 1L, "operator", 102L, OrderStatus.APPROVED, null);
.when(service).transit(10001L, 1L, "operator", 102L, OrderStatus.APPROVED, null, 4);
var response = controller.batchApprove(user("PLATFORM", 0L), 10001L,
new OrderRequests.BatchReviewRequest(List.of(101L, 102L, 103L)));
new OrderRequests.BatchReviewRequest(List.of(
new OrderRequests.BatchReviewItem(101L, 3),
new OrderRequests.BatchReviewItem(102L, 4),
new OrderRequests.BatchReviewItem(103L, 5))));
BatchOrderReviewResponse result = response.data();
assertThat(result.total()).isEqualTo(3);
@@ -90,8 +97,8 @@ class PlatformOrderPageControllerTest {
assertThat(failure.code()).isEqualTo(ResultCode.CONFLICT.code());
assertThat(failure.message()).isEqualTo("订单已审核");
});
verify(service).transit(10001L, 1L, "operator", 101L, OrderStatus.APPROVED, null);
verify(service).transit(10001L, 1L, "operator", 103L, OrderStatus.APPROVED, null);
verify(service).transit(10001L, 1L, "operator", 101L, OrderStatus.APPROVED, null, 3);
verify(service).transit(10001L, 1L, "operator", 103L, OrderStatus.APPROVED, null, 5);
}
@Test
@@ -103,6 +110,20 @@ class PlatformOrderPageControllerTest {
verify(service).transit(10001L, 1L, "operator", 102L, OrderStatus.REJECTED, "资料不完整");
}
@Test
void batchReviewPreviewUsesOneControllerCallAndExactCustomerScope() {
var request = new OrderRequests.BatchReviewRequest(List.of(
new OrderRequests.BatchReviewItem(101L, 3),
new OrderRequests.BatchReviewItem(102L, 4)));
var preview = new BatchOrderReviewPreviewResponse(2, 1, List.of());
when(batchReviewPreview.preview(10001L, request.items())).thenReturn(preview);
var response = controller.batchReviewPreview(user("PLATFORM", 0L), 10001L, request);
assertThat(response.data()).isSameAs(preview);
verify(batchReviewPreview).preview(10001L, request.items());
}
private AuthenticatedUser user(String appCode, Long tenantId) {
return new AuthenticatedUser(1L, tenantId, null, null, "operator", appCode, "WEB", Set.of());
}
@@ -0,0 +1,21 @@
package com.baiyue.order.controller;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class PrintableInboundSnapshotOpenApiContractTest {
@Test
void documentsTheMutatingInternalCommandAsPost() throws Exception {
try (var stream = getClass().getResourceAsStream("/openapi/order-v1.yaml")) {
assertThat(stream).isNotNull();
String yaml = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
assertThat(yaml).contains(
"/internal/order/v1/cargoes/{tenantId}/{orderId}/printable-inbound-snapshot:",
"若 AUTO 模式草稿尚无入仓码,则按订单渠道原子预留全局唯一入仓码",
"后续订单提交继续复用");
}
}
}
@@ -12,6 +12,20 @@ import static org.assertj.core.api.Assertions.assertThat;
class OrderRequestsValidationTest {
private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
@Test
void batchReviewRequiresDistinctOrderIdsAndNonNegativeVersions() {
var duplicate = new OrderRequests.BatchReviewRequest(List.of(
new OrderRequests.BatchReviewItem(101L, 1),
new OrderRequests.BatchReviewItem(101L, 2)));
var invalidVersion = new OrderRequests.BatchReviewRequest(List.of(
new OrderRequests.BatchReviewItem(102L, -1)));
assertThat(validator.validate(duplicate))
.anyMatch(violation -> violation.getMessage().contains("订单 ID 不能重复"));
assertThat(validator.validate(invalidVersion))
.anyMatch(violation -> violation.getPropertyPath().toString().contains("orderVersion"));
}
@Test
void carrierSnapshotIsOptionalWhenCustomerCreatesAnOrderDraft() {
assertThat(validator.validate(request(null, null, "TAX_INCLUDED"))).isEmpty();
@@ -0,0 +1,93 @@
package com.baiyue.order.service;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.entity.OrderCargoEntity;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.repository.OrderRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
class BatchOrderReviewPreviewServiceTest {
private final OrderRepository orders = mock(OrderRepository.class);
private final BatchOrderReviewPreviewService service = new BatchOrderReviewPreviewService();
@BeforeEach
void setUp() {
org.springframework.test.util.ReflectionTestUtils.setField(service, "orders", orders);
}
@Test
void returnsStableRowsWithOrderedDistinctChineseGoodsUsingTwoBatchQueries() {
OrderEntity eligible = order(101L, 3, OrderStatus.SUBMITTED, "RK-101", "客户甲");
OrderEntity stale = order(102L, 8, OrderStatus.SUBMITTED, "RK-102", "客户乙");
when(orders.findByIds(11L, List.of(101L, 102L))).thenReturn(List.of(stale, eligible));
when(orders.findCargoByOrderIds(11L, List.of(101L, 102L))).thenReturn(List.of(
cargo(101L, " 蓝牙音箱 "), cargo(101L, "蓝牙音箱"), cargo(101L, "充电器"),
cargo(102L, "数据线")));
var result = service.preview(11L, List.of(
new OrderRequests.BatchReviewItem(101L, 3),
new OrderRequests.BatchReviewItem(102L, 7)));
assertThat(result.total()).isEqualTo(2);
assertThat(result.approvableCount()).isEqualTo(1);
assertThat(result.items()).extracting(item -> item.orderId()).containsExactly(101L, 102L);
assertThat(result.items().get(0).chineseGoodsNames()).containsExactly("蓝牙音箱", "充电器");
assertThat(result.items().get(0).destinationWarehouseCode()).isEqualTo("ONT8");
assertThat(result.items().get(0).canApprove()).isTrue();
assertThat(result.items().get(1).orderVersion()).isEqualTo(8);
assertThat(result.items().get(1).blockReason()).contains("版本已变化");
verify(orders).findByIds(11L, List.of(101L, 102L));
verify(orders).findCargoByOrderIds(11L, List.of(101L, 102L));
verifyNoMoreInteractions(orders);
}
@Test
void keepsUnknownAndWrongStatusRowsWithoutLeakingAnotherCustomer() {
OrderEntity approved = order(101L, 3, OrderStatus.APPROVED, "RK-101", "客户甲");
when(orders.findByIds(11L, List.of(999L, 101L))).thenReturn(List.of(approved));
when(orders.findCargoByOrderIds(11L, List.of(999L, 101L))).thenReturn(List.of());
var result = service.preview(11L, List.of(
new OrderRequests.BatchReviewItem(999L, 1),
new OrderRequests.BatchReviewItem(101L, 3)));
assertThat(result.items().get(0).inboundCode()).isNull();
assertThat(result.items().get(0).blockReason()).contains("不存在或不属于当前客户");
assertThat(result.items().get(1).blockReason()).contains("状态不可审核");
assertThat(result.approvableCount()).isZero();
}
private static OrderEntity order(long id, int version, OrderStatus status,
String inboundCode, String customerName) {
OrderEntity order = new OrderEntity();
order.setId(id);
order.setTenantId(11L);
order.setVersion(version);
order.setStatus(status);
order.setInboundCode(inboundCode);
order.setCustomerId(11L);
order.setCustomerName(customerName);
order.setChannelCode("FBA-SEA");
order.setChannelName("美森海运");
order.setAmazonWarehouseCode("ONT8");
order.setDestinationWarehouseName("ONT8 亚马逊仓");
return order;
}
private static OrderCargoEntity cargo(long orderId, String name) {
OrderCargoEntity cargo = new OrderCargoEntity();
cargo.setOrderId(orderId);
cargo.setCargoName(name);
return cargo;
}
}
@@ -235,6 +235,24 @@ class OrderApplicationServiceStateTest {
&& event.getPayloadJson().contains("\"totalPackageCount\":4")));
}
@Test
void batchApprovalRejectsStalePreviewVersionBeforeChangingOrder() {
OrderRepository repository = mock(OrderRepository.class);
OrderEntity order = persistedOrder(OrderStatus.SUBMITTED);
order.setVersion(6);
when(repository.findOwned(10001L, 1L)).thenReturn(order);
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class),
mock(OrderMasterDataGuard.class)).transit(
10001L, 2L, "u", 1L, OrderStatus.APPROVED, null, 5))
.isInstanceOf(BizException.class)
.hasMessage("订单版本已变化,请刷新后重试");
assertThat(order.getStatus()).isEqualTo(OrderStatus.SUBMITTED);
verify(repository, never()).saveOrder(anyLong(), any());
verify(repository, never()).insertOutbox(any());
}
@Test
void submittingDraftGeneratesAndPersistsInboundCode() {
OrderRepository repository = mock(OrderRepository.class);
@@ -0,0 +1,113 @@
package com.baiyue.order.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.config.InboundCodeModeProvider;
import com.baiyue.order.entity.OrderCargoEntity;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.repository.OrderRepository;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class PrintableInboundSnapshotServiceTest {
@Test
void reservesStableCodeForDraftAndReturnsPrintableSnapshot() {
OrderRepository orders = mock(OrderRepository.class);
OrderPricingChannelGuard pricing = mock(OrderPricingChannelGuard.class);
OrderApplicationService application = mock(OrderApplicationService.class);
OrderEntity order = draft();
OrderCargoEntity cargo = new OrderCargoEntity();
cargo.setPackageCount(3);
when(orders.findOwned(11L, 21L)).thenReturn(order);
when(pricing.requireAvailable(11L, 41L, "CH", "FBA")).thenReturn("PCHK");
when(application.nextInboundCode("PCHK")).thenReturn("PCHK2609080001");
when(orders.assignInboundCodeIfBlank(11L, 21L, "PCHK2609080001")).thenReturn(1);
when(orders.findCargoInheritedByOrder(11L, 21L)).thenReturn(List.of(cargo));
var result = service(orders, pricing, application, "AUTO").reserve(11L, 21L);
assertThat(result.inboundCode()).isEqualTo("PCHK2609080001");
assertThat(result.totalPackageCount()).isEqualTo(3);
verify(orders).assignInboundCodeIfBlank(11L, 21L, "PCHK2609080001");
}
@Test
void reusesCodeAlreadyReservedByAConcurrentRequest() {
OrderRepository orders = mock(OrderRepository.class);
OrderPricingChannelGuard pricing = mock(OrderPricingChannelGuard.class);
OrderApplicationService application = mock(OrderApplicationService.class);
OrderEntity initial = draft();
OrderEntity winner = draft();
winner.setInboundCode("PCHK2609080002");
when(orders.findOwned(11L, 21L)).thenReturn(initial, winner);
when(pricing.requireAvailable(11L, 41L, "CH", "FBA")).thenReturn("PCHK");
when(application.nextInboundCode("PCHK")).thenReturn("PCHK2609080001");
when(orders.assignInboundCodeIfBlank(11L, 21L, "PCHK2609080001")).thenReturn(0);
when(orders.findCargoInheritedByOrder(11L, 21L)).thenReturn(List.of());
var result = service(orders, pricing, application, "AUTO").reserve(11L, 21L);
assertThat(result.inboundCode()).isEqualTo("PCHK2609080002");
}
@Test
void existingCodeDoesNotAllocateAnotherSequence() {
OrderRepository orders = mock(OrderRepository.class);
OrderPricingChannelGuard pricing = mock(OrderPricingChannelGuard.class);
OrderApplicationService application = mock(OrderApplicationService.class);
OrderEntity order = draft();
order.setInboundCode("PCHK2609080001");
when(orders.findOwned(11L, 21L)).thenReturn(order);
when(orders.findCargoInheritedByOrder(11L, 21L)).thenReturn(List.of());
var result = service(orders, pricing, application, "AUTO").reserve(11L, 21L);
assertThat(result.inboundCode()).isEqualTo("PCHK2609080001");
verify(application, never()).nextInboundCode(org.mockito.ArgumentMatchers.anyString());
}
@Test
void legacyManualModeRejectsMissingCodeWithoutWriting() {
OrderRepository orders = mock(OrderRepository.class);
OrderPricingChannelGuard pricing = mock(OrderPricingChannelGuard.class);
OrderApplicationService application = mock(OrderApplicationService.class);
when(orders.findOwned(11L, 21L)).thenReturn(draft());
assertThatThrownBy(() -> service(orders, pricing, application, "LEGACY_MANUAL").reserve(11L, 21L))
.isInstanceOf(BizException.class)
.hasMessage("当前系统使用旧系统入仓码,请先填写入仓码");
verify(orders, never()).assignInboundCodeIfBlank(
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(),
org.mockito.ArgumentMatchers.anyString());
}
private PrintableInboundSnapshotService service(OrderRepository orders,
OrderPricingChannelGuard pricing,
OrderApplicationService application,
String mode) {
return new PrintableInboundSnapshotService(
orders, pricing, new InboundCodeModeProvider(mode), application);
}
private OrderEntity draft() {
OrderEntity order = new OrderEntity();
order.setId(21L);
order.setTenantId(11L);
order.setOrderNo("FL21");
order.setCustomerId(11L);
order.setReceivingWarehouseId(41L);
order.setChannelCode("CH");
order.setDestinationType("FBA");
order.setTransportMode("SEA");
order.setFulfillmentType("STANDARD");
return order;
}
}