Merge remote-tracking branch 'origin/test' into dev
This commit is contained in:
@@ -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);
|
||||
}
|
||||
+4
-2
@@ -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)
|
||||
|
||||
+15
-5
@@ -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}
|
||||
|
||||
+3
@@ -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);
|
||||
}
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+1
@@ -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, "所选渠道已下线,请重新选择");
|
||||
|
||||
+1
-1
@@ -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}
|
||||
|
||||
+3
@@ -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"))
|
||||
|
||||
+5
-2
@@ -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);
|
||||
|
||||
+10
-9
@@ -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)));
|
||||
}
|
||||
|
||||
|
||||
+4
-3
@@ -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);
|
||||
|
||||
|
||||
+17
-1
@@ -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
|
||||
|
||||
+10
-1
@@ -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())
|
||||
|
||||
+9
@@ -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,
|
||||
|
||||
+37
-3
@@ -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();
|
||||
|
||||
+25
-4
@@ -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());
|
||||
}
|
||||
|
||||
+21
@@ -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);
|
||||
|
||||
+113
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user