diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 6c4bfd8b8..fb1ef1fef 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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', diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/PrintableOrderInboundClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/PrintableOrderInboundClient.java new file mode 100644 index 000000000..b28eedff7 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/PrintableOrderInboundClient.java @@ -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 reserve( + @RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long tenantId, + @PathVariable long orderId); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformPriceBookController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformPriceBookController.java index ecb4991ea..af13b112d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformPriceBookController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformPriceBookController.java @@ -38,9 +38,10 @@ public class PlatformPriceBookController { @Operation(summary = "查询船期价格表版本") public ApiResponse> 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 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}") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java index b81b8b94f..f0e0f4317 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java @@ -23,6 +23,7 @@ public final class PriceBookDtos { public record PriceBookVersionSummary( Long id, + Long customerId, Integer versionNo, String originalFileName, Long sourceFileId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java index 16956b682..0a82a59ba 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java @@ -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) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java index 8b708f74d..7c01a22c5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java @@ -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} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java index 094003ec2..e3f07440e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java @@ -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 channels(long versionId, long originWarehouseId) { return reader.findChannels(versionId, originWarehouseId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java index 980c86ad7..681279a2e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java @@ -9,6 +9,7 @@ import java.util.List; public interface PriceBookQueryUseCase { Long activeVersionId(); + Long effectiveVersionId(long customerId); List channels(long versionId, long originWarehouseId); List channelTaxModes(long versionId, long originWarehouseId); List fbaRates(long versionId, long originWarehouseId, String channelCode, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java index 04d852aab..d12366820 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java @@ -9,6 +9,7 @@ import java.util.List; public interface PriceBookQueryReader { Long findActiveVersionId(); + Long findEffectiveVersionId(long customerId); List findChannels(long versionId, long originWarehouseId); List findChannelTaxModes(long versionId, long originWarehouseId); List findFbaRates(long versionId, long originWarehouseId, String channelCode, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java index aca7ccbcf..6b7930f75 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java @@ -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, "当前没有生效的船期价格版本"); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookRepository.java index 188b90172..7c03889ce 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookRepository.java @@ -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() - .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() - .eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) .eq(PriceBookVersionEntity::getId, id) .eq(PriceBookVersionEntity::getDeleted, false)); } public List listVersions(String status) { + return listVersions(PriceBookVersionEntity.PLATFORM_SCOPE, status); + } + public List listVersions(long customerId, String status) { return versions.selectList(new LambdaQueryWrapper() - .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() - .eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) + .eq(PriceBookVersionEntity::getTenantId, customerId) .eq(PriceBookVersionEntity::getStatus, "ACTIVE") .eq(PriceBookVersionEntity::getDeleted, false) .last("FOR UPDATE")); } public List listActiveForUpdate() { + return listActiveForUpdate(PriceBookVersionEntity.PLATFORM_SCOPE); + } + public List listActiveForUpdate(long customerId) { return versions.selectList(new LambdaQueryWrapper() - .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() - .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() - .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() - .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; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index 7183beaba..501fd42d8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -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(), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java index 3cbe48376..9de556f17 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java @@ -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 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, "订单不存在或无法生成预入库单"); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java index a4e5e6a9d..250b57c32 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java @@ -32,7 +32,7 @@ public class MerchantPriceBookService { public List 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 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, "所选渠道已下线,请重新选择"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OrderChannelChangePricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OrderChannelChangePricingService.java index 49614ba3d..eb2013660 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/OrderChannelChangePricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OrderChannelChangePricingService.java @@ -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)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/PlatformPriceBookService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/PlatformPriceBookService.java index ee121a0a2..9adcf4617 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/PlatformPriceBookService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/PlatformPriceBookService.java @@ -72,13 +72,26 @@ public class PlatformPriceBookService { @Value("${baiyue.security.internal-service-token}") private String internalToken; public List list(PriceBookStatus status) { + return list(PriceBookVersionEntity.PLATFORM_SCOPE, status); + } + + public List 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 channelRows = repository.listChannels(versionId); List 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 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 channels = repository.listChannels(versionId).stream().map(this::channelDto).toList(); List 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(), diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index 53574b9b2..4f7282aa1 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -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', diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index 240b9e4e1..580aaad9b 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -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} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryServiceTest.java index db339e20a..6ae21e583 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryServiceTest.java @@ -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"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index bd95d463f..5b206acf2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -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")) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java index 868af9ece..a2a0b002a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java @@ -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); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java index 8ead0e597..b660e0a4e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java @@ -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))); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OrderChannelChangePricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OrderChannelChangePricingServiceTest.java index e21fcfdaf..ef8b72bd3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/OrderChannelChangePricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OrderChannelChangePricingServiceTest.java @@ -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); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java index 42652dc2d..5729b9a57 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java @@ -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 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java index e511e9880..3888863a8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java @@ -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()) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java index c4035d8f9..12aabff78 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java @@ -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 receipt = ArgumentCaptor.forClass(InboundStockReceipt.class); verify(receipts).insert(receipt.capture()); diff --git a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java index 82da1afa9..2b1dbf0a8 100644 --- a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java +++ b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java @@ -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 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 measurementDeclaration( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java index 56b740cfc..3eab42d11 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java @@ -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 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 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 batchApproveTransit( + AuthenticatedUser user, long tenantId, List items) { + long scopedTenantId = OrderScopeGuard.platform(user, tenantId); + List successOrderIds = new ArrayList<>(); + List 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))); + } } diff --git a/order/src/main/java/com/baiyue/order/dto/BatchOrderReviewPreviewResponse.java b/order/src/main/java/com/baiyue/order/dto/BatchOrderReviewPreviewResponse.java new file mode 100644 index 000000000..f2737c4f5 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/dto/BatchOrderReviewPreviewResponse.java @@ -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 items +) { + public record Item( + Long orderId, + Integer orderVersion, + String inboundCode, + Long customerId, + String customerName, + List chineseGoodsNames, + String channelCode, + String channelName, + String destinationWarehouseCode, + String destinationWarehouseName, + boolean canApprove, + String blockReason + ) {} +} diff --git a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java index 670a89c7d..70ef87ce4 100644 --- a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java +++ b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java @@ -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) diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index e9987383c..40c1b715a 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -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() + .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, diff --git a/order/src/main/java/com/baiyue/order/service/BatchOrderReviewPreviewService.java b/order/src/main/java/com/baiyue/order/service/BatchOrderReviewPreviewService.java new file mode 100644 index 000000000..07b3fc6e8 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/BatchOrderReviewPreviewService.java @@ -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 requested) { + List orderIds = requested.stream().map(OrderRequests.BatchReviewItem::orderId).toList(); + Map byId = new LinkedHashMap<>(); + orders.findByIds(tenantId, orderIds).forEach(order -> byId.put(order.getId(), order)); + + Map> 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 names = goodsNames.computeIfAbsent(cargo.getOrderId(), ignored -> new ArrayList<>()); + if (!names.contains(name)) names.add(name); + } + + List 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 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); + } +} diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index f7654985b..bbc2390d9 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -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, diff --git a/order/src/main/java/com/baiyue/order/service/PrintableInboundSnapshotService.java b/order/src/main/java/com/baiyue/order/service/PrintableInboundSnapshotService.java new file mode 100644 index 000000000..a789cf392 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/PrintableInboundSnapshotService.java @@ -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; + } +} diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index b248af4c5..787828a1c 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -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] diff --git a/order/src/test/java/com/baiyue/order/controller/BatchReviewOpenApiContractTest.java b/order/src/test/java/com/baiyue/order/controller/BatchReviewOpenApiContractTest.java new file mode 100644 index 000000000..e054e5759 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/controller/BatchReviewOpenApiContractTest.java @@ -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("允许部分成功"); + } +} diff --git a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java index e186cfe94..f6a6486bf 100644 --- a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java +++ b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java @@ -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(); diff --git a/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java b/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java index 320a1c5bc..28d51a583 100644 --- a/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java +++ b/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java @@ -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()); } diff --git a/order/src/test/java/com/baiyue/order/controller/PrintableInboundSnapshotOpenApiContractTest.java b/order/src/test/java/com/baiyue/order/controller/PrintableInboundSnapshotOpenApiContractTest.java new file mode 100644 index 000000000..c9a8e9554 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/controller/PrintableInboundSnapshotOpenApiContractTest.java @@ -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 模式草稿尚无入仓码,则按订单渠道原子预留全局唯一入仓码", + "后续订单提交继续复用"); + } + } +} diff --git a/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java b/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java index f58d48fe2..37f938c0b 100644 --- a/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java +++ b/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java @@ -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(); diff --git a/order/src/test/java/com/baiyue/order/service/BatchOrderReviewPreviewServiceTest.java b/order/src/test/java/com/baiyue/order/service/BatchOrderReviewPreviewServiceTest.java new file mode 100644 index 000000000..5980456e3 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/BatchOrderReviewPreviewServiceTest.java @@ -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; + } +} diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index 7675f999a..c959de48a 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -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); diff --git a/order/src/test/java/com/baiyue/order/service/PrintableInboundSnapshotServiceTest.java b/order/src/test/java/com/baiyue/order/service/PrintableInboundSnapshotServiceTest.java new file mode 100644 index 000000000..5676c6cf7 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/PrintableInboundSnapshotServiceTest.java @@ -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; + } +}