This commit is contained in:
2026-08-05 09:11:26 +08:00
parent d544bbd0c5
commit 3791fee285
45 changed files with 1187 additions and 126 deletions
@@ -2,6 +2,8 @@ package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.FbaPricingDtos.CarrierRateVersionRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.CustomerRuleVersionRequest;
@@ -36,10 +38,12 @@ public class PlatformFbaMasterDataController {
@GetMapping("/customer-rules")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rule:view')")
@Operation(summary = "查询客户计费规则版本", description = "默认查询全部企业;tenantId 可用于筛选目标企业")
public ApiResponse<List<CustomerChargeRuleEntity>> rules(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) Long tenantId,
@RequestParam(required = false) String ruleCode) {
return ApiResponse.success(service.listRules(FirstlegScopeGuard.platform(user, tenantId), ruleCode));
return ApiResponse.success(service.listRules(optionalPlatformTenant(user, tenantId), ruleCode));
}
@GetMapping("/customer-rules/{id}")
@@ -105,4 +109,11 @@ public class PlatformFbaMasterDataController {
service.retireRate(FirstlegScopeGuard.platform(user, tenantId), id, user.userId());
return ApiResponse.success(null);
}
private static Long optionalPlatformTenant(AuthenticatedUser user, Long tenantId) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
return tenantId == null ? null : FirstlegScopeGuard.platform(user, tenantId);
}
}
@@ -10,10 +10,13 @@ import com.baiyue.firstleg.dto.InboundOrderQuery;
import com.baiyue.firstleg.dto.InboundOrderSummary;
import com.baiyue.firstleg.service.InboundOrderQueryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -22,6 +25,7 @@ import org.springframework.web.bind.annotation.RestController;
/** 平台端入库单列表和详情只读接口。 */
@Tag(name = "平台入库单", description = "二期入库单列表、页签、筛选和只读聚合详情")
@Validated
@RestController
@RequestMapping("/api/platform/inbounds")
public class PlatformInboundOrderController {
@@ -31,14 +35,16 @@ public class PlatformInboundOrderController {
this.service = service;
}
@Operation(summary = "分页查询入库单", description = "按目标租户和二期状态页签分页查询入库单快照。")
@Operation(summary = "分页查询入库单",
description = "默认分页查询全部企业入库单;可按目标租户和二期状态页签筛选入库单快照。")
@GetMapping
@PreAuthorize("hasAuthority('platform:firstleg:inbound:list')")
public ApiResponse<PageResult<InboundOrderSummary>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@Parameter(description = "可选目标企业租户ID;不传时查询全部企业")
@RequestParam(required = false) @Min(1) Long tenantId,
@Valid InboundOrderQuery query) {
requirePlatformTenant(user, tenantId);
requirePlatform(user);
return ApiResponse.success(service.page(tenantId, query));
}
@@ -57,11 +63,15 @@ public class PlatformInboundOrderController {
}
private static void requirePlatformTenant(AuthenticatedUser user, long tenantId) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
requirePlatform(user);
if (tenantId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户");
}
}
private static void requirePlatform(AuthenticatedUser user) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
}
}
@@ -8,7 +8,7 @@ import java.time.LocalDateTime;
/** 入库列表行,所有名称和运输信息均为业务快照。 */
public record InboundOrderSummary(
Long id, Long orderId, String orderNo, String inboundCode,
Long id, Long tenantId, Long orderId, String orderNo, String inboundCode,
String processStatus, String orderStatusSnapshot, String measureReviewStatus,
String feeStatus, String feeReviewStatus, String notificationStatus,
Integer version,
@@ -33,7 +33,7 @@ public record InboundOrderSummary(
public static InboundOrderSummary from(
InboundOrderEntity order, InboundTransportSnapshotEntity transport, String locationCodes) {
return new InboundOrderSummary(
order.getId(), order.getOrderId(), order.getOrderNo(), order.getInboundCode(),
order.getId(), order.getTenantId(), order.getOrderId(), order.getOrderNo(), order.getInboundCode(),
order.getProcessStatus(), order.getOrderStatusSnapshot(), order.getMeasureReviewStatus(),
order.getFeeStatus(), order.getFeeReviewStatus(), order.getNotificationStatus(),
order.getVersion(),
@@ -21,7 +21,7 @@ import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
/** FBA 计费仓储,所有业务查询都强制带租户条件。 */
/** FBA 计费仓储;平台客户规则列表可跨企业读取,其他业务操作强制带租户条件。 */
@Repository
public class FbaPricingRepository {
private final CustomerChargeRuleMapper rules;
@@ -61,9 +61,9 @@ public class FbaPricingRepository {
.eq(CarrierFbaRateEntity::getId, id));
}
public List<CustomerChargeRuleEntity> listRules(long tenantId, String ruleCode) {
public List<CustomerChargeRuleEntity> listRules(Long tenantId, String ruleCode) {
return rules.selectList(new LambdaQueryWrapper<CustomerChargeRuleEntity>()
.eq(CustomerChargeRuleEntity::getTenantId, tenantId)
.eq(tenantId != null, CustomerChargeRuleEntity::getTenantId, tenantId)
.eq(ruleCode != null && !ruleCode.isBlank(), CustomerChargeRuleEntity::getRuleCode, ruleCode)
.orderByAsc(CustomerChargeRuleEntity::getRuleCode)
.orderByDesc(CustomerChargeRuleEntity::getRuleVersion));
@@ -26,7 +26,7 @@ import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/** 入库单只读仓储,所有查询均以租户作为首要隔离条件。 */
/** 入库单只读仓储;平台分页可跨企业查询,详情继续强制租户隔离。 */
@Repository
public class InboundOrderRepository {
private final InboundOrderMapper orders;
@@ -51,9 +51,9 @@ public class InboundOrderRepository {
this.histories = histories;
}
public PageResult<InboundOrderSummary> page(long tenantId, InboundOrderQuery query) {
public PageResult<InboundOrderSummary> page(Long tenantId, InboundOrderQuery query) {
LambdaQueryWrapper<InboundOrderEntity> wrapper = new LambdaQueryWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
.eq(tenantId != null, InboundOrderEntity::getTenantId, tenantId)
.likeRight(StringUtils.hasText(query.inboundCode()), InboundOrderEntity::getInboundCode,
query.inboundCode())
.likeRight(StringUtils.hasText(query.orderNo()), InboundOrderEntity::getOrderNo, query.orderNo())
@@ -85,9 +85,10 @@ public class InboundOrderRepository {
.orderByDesc(InboundOrderEntity::getCreatedAt)
.orderByDesc(InboundOrderEntity::getId);
Page<InboundOrderEntity> result = orders.selectPage(new Page<>(query.page(), query.size()), wrapper);
List<Long> ids = result.getRecords().stream().map(InboundOrderEntity::getId).toList();
Map<Long, InboundTransportSnapshotEntity> transportByInbound = transportMap(tenantId, ids);
Map<Long, String> locationCodesByInbound = locationCodesMap(tenantId, ids);
Map<Long, Long> tenantByInbound = result.getRecords().stream()
.collect(Collectors.toMap(InboundOrderEntity::getId, InboundOrderEntity::getTenantId));
Map<Long, InboundTransportSnapshotEntity> transportByInbound = transportMap(tenantId, tenantByInbound);
Map<Long, String> locationCodesByInbound = locationCodesMap(tenantId, tenantByInbound);
List<InboundOrderSummary> records = result.getRecords().stream()
.map(order -> InboundOrderSummary.from(order, transportByInbound.get(order.getId()),
locationCodesByInbound.get(order.getId())))
@@ -156,27 +157,31 @@ public class InboundOrderRepository {
palletSummaries, fileSummaries, noteSummaries, historySummaries);
}
private Map<Long, InboundTransportSnapshotEntity> transportMap(long tenantId, List<Long> inboundIds) {
if (inboundIds.isEmpty()) {
private Map<Long, InboundTransportSnapshotEntity> transportMap(
Long tenantId, Map<Long, Long> tenantByInbound) {
if (tenantByInbound.isEmpty()) {
return Map.of();
}
return transports.selectList(new LambdaQueryWrapper<InboundTransportSnapshotEntity>()
.eq(InboundTransportSnapshotEntity::getTenantId, tenantId)
.in(InboundTransportSnapshotEntity::getInboundId, inboundIds))
.stream().collect(Collectors.toMap(InboundTransportSnapshotEntity::getInboundId,
.eq(tenantId != null, InboundTransportSnapshotEntity::getTenantId, tenantId)
.in(InboundTransportSnapshotEntity::getInboundId, tenantByInbound.keySet()))
.stream()
.filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId()))
.collect(Collectors.toMap(InboundTransportSnapshotEntity::getInboundId,
Function.identity(), (left, right) -> left));
}
private Map<Long, String> locationCodesMap(long tenantId, List<Long> inboundIds) {
if (inboundIds.isEmpty()) {
private Map<Long, String> locationCodesMap(Long tenantId, Map<Long, Long> tenantByInbound) {
if (tenantByInbound.isEmpty()) {
return Map.of();
}
return pallets.selectList(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
.in(InboundPalletAllocationEntity::getInboundId, inboundIds)
.eq(tenantId != null, InboundPalletAllocationEntity::getTenantId, tenantId)
.in(InboundPalletAllocationEntity::getInboundId, tenantByInbound.keySet())
.orderByAsc(InboundPalletAllocationEntity::getInboundId)
.orderByAsc(InboundPalletAllocationEntity::getAllocationNo))
.stream()
.filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId()))
.filter(item -> StringUtils.hasText(item.getLocationCode()))
.collect(Collectors.groupingBy(InboundPalletAllocationEntity::getInboundId,
java.util.LinkedHashMap::new,
@@ -184,6 +189,10 @@ public class InboundOrderRepository {
Collectors.joining("、"))));
}
private static boolean owns(Map<Long, Long> tenantByInbound, Long inboundId, Long tenantId) {
return inboundId != null && tenantId != null && tenantId.equals(tenantByInbound.get(inboundId));
}
private static <T> LambdaQueryWrapper<T> owned(
com.baomidou.mybatisplus.core.toolkit.support.SFunction<T, ?> tenantColumn,
com.baomidou.mybatisplus.core.toolkit.support.SFunction<T, ?> inboundColumn,
@@ -98,7 +98,7 @@ public class FbaMasterDataService {
return value;
}
public List<CustomerChargeRuleEntity> listRules(long tenantId, String code) {
public List<CustomerChargeRuleEntity> listRules(Long tenantId, String code) {
return repository.listRules(tenantId, code);
}
@@ -21,7 +21,7 @@ public class InboundOrderQueryService {
this.repository = repository;
}
public PageResult<InboundOrderSummary> page(long tenantId, InboundOrderQuery query) {
public PageResult<InboundOrderSummary> page(Long tenantId, InboundOrderQuery query) {
validateQuery(query);
return repository.page(tenantId, query);
}
@@ -10,7 +10,9 @@ paths:
get:
summary: 查询客户计费规则版本
description: 需要 firstleg:fba-pricing:rule:view 权限
parameters: [{$ref: '#/components/parameters/TenantId'}]
parameters:
- {$ref: '#/components/parameters/OptionalTenantId'}
- {name: ruleCode, in: query, schema: {type: string, maxLength: 50}}
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
/api/platform/v1/fba-pricing/customer-rules/versions:
post:
@@ -87,9 +89,10 @@ components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
parameters:
TenantId: {name: tenantId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
Id: {name: id, in: path, required: true, schema: {type: integer, format: int64}}
InboundId: {name: inboundId, in: path, required: true, schema: {type: integer, format: int64}}
OptionalTenantId: {name: tenantId, in: query, required: false, description: 不传时查询全部企业, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
TenantId: {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
Id: {name: id, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
InboundId: {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
schemas:
AdjustFeeRequest:
@@ -107,7 +110,7 @@ components:
required: [ruleCode, customerId, customerName, businessType, chargeMode, taxMode, conversionFactor, roundingScale, roundingMode, currency, effectiveFrom]
properties:
ruleCode: {type: string, maxLength: 50}
customerId: {type: integer, format: int64}
customerId: {type: string, pattern: '^[1-9][0-9]*$'}
customerName: {type: string, maxLength: 128}
businessType: {type: string, maxLength: 32}
chargeMode: {type: string, enum: [KG, CBM]}
@@ -123,11 +126,11 @@ components:
required: [rateCode, originWarehouseId, originWarehouseName, businessType, channelCode, carrierId, carrierName, destinationCode, chargeUnit, unitPrice, currency, additionalFee, effectiveFrom]
properties:
rateCode: {type: string, maxLength: 50}
originWarehouseId: {type: integer, format: int64}
originWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
originWarehouseName: {type: string}
businessType: {type: string}
channelCode: {type: string}
carrierId: {type: integer, format: int64}
carrierId: {type: string, pattern: '^[1-9][0-9]*$'}
carrierName: {type: string}
destinationCode: {type: string}
chargeUnit: {type: string, enum: [KG, CBM]}
@@ -127,12 +127,16 @@ paths:
/api/platform/inbounds:
get:
summary: 分页查询入库单
description: 需要 platform:firstleg:inbound:list 权限;仅使用 V2 四个页签
description: 需要 platform:firstleg:inbound:list 权限;默认查询全部企业,可选 tenantId 筛选;仅使用 V2 四个页签
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: tenantId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: tab, in: query, schema: {type: string, enum: [WAIT_MEASURE, WAIT_REVIEW, WAIT_FEE_SEND, INBOUNDED]}}
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'200':
description: 成功或业务失败,按响应体 code 判断;每条记录携带后续租户隔离操作所需的 tenantId
content:
application/json:
schema: {$ref: '#/components/schemas/InboundOrderPageEnvelope'}
'403': {description: 无查询权限}
/api/platform/inbounds/{inboundId}:
get:
@@ -251,6 +255,34 @@ components:
in: query
schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}
schemas:
InboundOrderPageEnvelope:
type: object
properties:
code: {type: string, example: '200'}
message: {type: string}
data: {$ref: '#/components/schemas/InboundOrderPage'}
InboundOrderPage:
type: object
properties:
records:
type: array
items: {$ref: '#/components/schemas/InboundOrderSummary'}
page: {type: integer, minimum: 1}
size: {type: integer, minimum: 1}
total: {type: integer, format: int64, minimum: 0}
pages: {type: integer, format: int64, minimum: 0}
InboundOrderSummary:
type: object
required: [id, tenantId]
properties:
id: {type: string, pattern: '^[1-9][0-9]*$'}
tenantId: {type: string, pattern: '^[1-9][0-9]*$', description: 记录所属企业租户 ID,用于详情和处理接口的租户隔离}
orderId: {type: string, nullable: true}
orderNo: {type: string, nullable: true}
inboundCode: {type: string, nullable: true}
processStatus: {type: string}
customerId: {type: string, nullable: true}
companyId: {type: string, nullable: true}
InboundMeasurementCallbackRequest:
type: object
required: [eventNo, externalDetailNo, warehouseCode, inboundCode, inboundDetailId, deviceCode, measuredAt, boxSpecification, packageCount, lengthCm, widthCm, heightCm]
@@ -3,13 +3,30 @@ package com.baiyue.firstleg.controller;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.web.bind.annotation.RequestParam;
import java.nio.charset.StandardCharsets;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("FBA计费OpenAPI契约")
class FbaPricingContractTest {
@Test
@DisplayName("只有客户规则列表允许省略企业参数")
void onlyCustomerRuleListAllowsOptionalTenant() {
Method rules = method("rules");
assertThat(rules.getParameterTypes()[1]).isEqualTo(Long.class);
assertThat(rules.getParameters()[1].getAnnotation(RequestParam.class).required()).isFalse();
for (String scopedMethod : new String[] {"rule", "createRule", "retireRule", "rates",
"rate", "createRate", "retireRate"}) {
Method method = method(scopedMethod);
assertThat(method.getParameterTypes()[1]).isEqualTo(long.class);
assertThat(method.getParameters()[1].getAnnotation(RequestParam.class).required()).isTrue();
}
}
@Test
@DisplayName("契约声明自动发布、已发布费用调整和幂等头")
void documentsPricingLifecycle() throws Exception {
@@ -28,5 +45,26 @@ class FbaPricingContractTest {
.doesNotContain("应收")
.doesNotContain("应付")
.doesNotContain("待客户确认");
String customerRules = openapi.substring(
openapi.indexOf("/api/platform/v1/fba-pricing/customer-rules:"),
openapi.indexOf("/api/platform/v1/fba-pricing/customer-rules/versions:"));
assertThat(customerRules)
.contains("OptionalTenantId")
.contains("name: ruleCode")
.doesNotContain("#/components/parameters/Page")
.doesNotContain("#/components/parameters/Size")
.doesNotContain("name: customerName")
.doesNotContain("name: chargeMode")
.doesNotContain("name: status");
assertThat(openapi)
.contains("OptionalTenantId: {name: tenantId, in: query, required: false")
.contains("schema: {type: string, pattern: '^[1-9][0-9]*$'}")
.doesNotContain("format: int64");
}
private static Method method(String name) {
return java.util.Arrays.stream(PlatformFbaMasterDataController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(name))
.findFirst().orElseThrow();
}
}
@@ -5,6 +5,7 @@ import com.baiyue.firstleg.dto.InboundOrderQuery;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestParam;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
@@ -15,21 +16,40 @@ class InboundOrderControllerContractTest {
@Test
void shouldDeclareReadOnlyPermissions() throws Exception {
assertAuthority("page", "hasAuthority('platform:firstleg:inbound:list')",
AuthenticatedUser.class, long.class, InboundOrderQuery.class);
AuthenticatedUser.class, Long.class, InboundOrderQuery.class);
assertAuthority("detail", "hasAuthority('platform:firstleg:inbound:view')",
AuthenticatedUser.class, long.class, long.class);
Method page = PlatformInboundOrderController.class.getDeclaredMethod(
"page", AuthenticatedUser.class, Long.class, InboundOrderQuery.class);
assertThat(page.getParameters()[1].getAnnotation(RequestParam.class).required()).isFalse();
Method detail = PlatformInboundOrderController.class.getDeclaredMethod(
"detail", AuthenticatedUser.class, long.class, long.class);
assertThat(detail.getParameters()[1].getAnnotation(RequestParam.class).required()).isTrue();
}
@Test
void shouldDocumentOnlyV2TabsAndStringLongIds() throws Exception {
String openapi = new ClassPathResource("openapi/firstleg-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
String inboundList = openapi.substring(
openapi.indexOf("/api/platform/inbounds:"),
openapi.indexOf("/api/platform/inbounds/{inboundId}:"));
String taskList = openapi.substring(
openapi.indexOf("/api/platform/v1/first-leg/tasks:"),
openapi.indexOf("/api/platform/v1/first-leg/tasks/{taskId}:"));
assertThat(openapi)
.contains("/api/platform/inbounds:")
.contains("/api/platform/inbounds/{inboundId}:")
.contains("required: false")
.contains("InboundOrderSummary:")
.contains("required: [id, tenantId]")
.contains("tenantId: {type: string, pattern: '^[1-9][0-9]*$', description:")
.contains("[WAIT_MEASURE, WAIT_REVIEW, WAIT_FEE_SEND, INBOUNDED]")
.contains("platform:firstleg:inbound:list")
.doesNotContain("WAIT_CUSTOMER_CONFIRM");
assertThat(inboundList).contains("#/components/schemas/InboundOrderPageEnvelope");
assertThat(taskList).doesNotContain("InboundOrderPageEnvelope");
}
private static void assertAuthority(String methodName, String value, Class<?>... types)
@@ -0,0 +1,84 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.mapper.CarrierFbaRateMapper;
import com.baiyue.firstleg.mapper.CustomerChargeRuleMapper;
import com.baiyue.firstleg.mapper.InboundFeeItemMapper;
import com.baiyue.firstleg.mapper.InboundFeeMapper;
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
import com.baiyue.firstleg.mapper.InboundPricingIdempotencyMapper;
import com.baiyue.firstleg.mapper.InboundPricingMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.Collection;
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.when;
class FbaPricingRepositoryRuleListTest {
@BeforeAll
static void initializeEntityMetadata() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "test"),
CustomerChargeRuleEntity.class);
}
@Test
void platformRuleListOmitsTenantConditionWhenTenantIsAbsent() {
assertThat(queryValues(null, null)).isEmpty();
}
@Test
void platformRuleListKeepsTenantAndRuleCodeFiltersWhenProvided() {
assertThat(queryValues(10001L, "CR-1")).containsExactlyInAnyOrder(10001L, "CR-1");
}
@Test
void internalVersionCalculationKeepsTenantAndRuleCodeFilters() {
CustomerChargeRuleMapper rules = mock(CustomerChargeRuleMapper.class);
CustomerChargeRuleEntity current = new CustomerChargeRuleEntity();
current.setRuleVersion(3);
when(rules.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(current));
FbaPricingRepository repository = repository(rules);
int result = repository.nextRuleVersion(10001L, "CR-1");
@SuppressWarnings("unchecked")
ArgumentCaptor<LambdaQueryWrapper<CustomerChargeRuleEntity>> wrapper =
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(rules).selectList(wrapper.capture());
wrapper.getValue().getSqlSegment();
assertThat(wrapper.getValue().getParamNameValuePairs().values())
.containsExactlyInAnyOrder(10001L, "CR-1");
assertThat(result).isEqualTo(4);
}
private static Collection<Object> queryValues(Long tenantId, String ruleCode) {
CustomerChargeRuleMapper rules = mock(CustomerChargeRuleMapper.class);
when(rules.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of());
FbaPricingRepository repository = repository(rules);
repository.listRules(tenantId, ruleCode);
@SuppressWarnings("unchecked")
ArgumentCaptor<LambdaQueryWrapper<CustomerChargeRuleEntity>> wrapper =
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(rules).selectList(wrapper.capture());
wrapper.getValue().getSqlSegment();
return wrapper.getValue().getParamNameValuePairs().values();
}
private static FbaPricingRepository repository(CustomerChargeRuleMapper rules) {
return new FbaPricingRepository(rules, mock(CarrierFbaRateMapper.class),
mock(InboundPricingMapper.class), mock(InboundFeeMapper.class), mock(InboundFeeItemMapper.class),
mock(InboundPricingIdempotencyMapper.class), mock(InboundOutboxMapper.class));
}
}
@@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.firstleg.dto.InboundOrderQuery;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity;
import com.baiyue.firstleg.mapper.InboundFileMapper;
import com.baiyue.firstleg.mapper.InboundInternalNoteMapper;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
@@ -73,8 +74,10 @@ class InboundOrderRepositoryTest {
Page<InboundOrderEntity> page = new Page<>(1, 20);
InboundOrderEntity first = new InboundOrderEntity();
first.setId(1L);
first.setTenantId(10001L);
InboundOrderEntity second = new InboundOrderEntity();
second.setId(2L);
second.setTenantId(10001L);
page.setRecords(java.util.List.of(first, second));
when(orders.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(page);
when(pallets.selectList(any(Wrapper.class))).thenReturn(java.util.List.of(
@@ -104,12 +107,85 @@ class InboundOrderRepositoryTest {
.contains("ORDER BY inbound_id ASC,allocation_no ASC");
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void shouldQueryAndAggregateCrossTenantPageByInboundOwnership() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), "test"), InboundOrderEntity.class);
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), "test"),
InboundTransportSnapshotEntity.class);
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), "test"),
InboundPalletAllocationEntity.class);
InboundOrderMapper orders = mock(InboundOrderMapper.class);
InboundTransportSnapshotMapper transports = mock(InboundTransportSnapshotMapper.class);
InboundPalletAllocationMapper pallets = mock(InboundPalletAllocationMapper.class);
Page<InboundOrderEntity> page = new Page<>(1, 20);
page.setRecords(java.util.List.of(order(1L, 10001L), order(2L, 10002L)));
when(orders.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(page);
when(transports.selectList(any(Wrapper.class))).thenReturn(java.util.List.of(
transport(1L, 10001L, "T1"),
transport(2L, 10002L, "T2"),
transport(1L, 10002L, "WRONG")));
when(pallets.selectList(any(Wrapper.class))).thenReturn(java.util.List.of(
allocation(1L, 10001L, 1, "A1"),
allocation(2L, 10002L, 1, "B1"),
allocation(1L, 10002L, 2, "WRONG")));
InboundOrderRepository repository = new InboundOrderRepository(
orders, transports, pallets, mock(InboundFileMapper.class),
mock(InboundInternalNoteMapper.class), mock(InboundStatusHistoryMapper.class));
var result = repository.page(null, new InboundOrderQuery(
null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, 1, 20));
assertThat(result.records()).extracting(record -> record.tenantId())
.containsExactly(10001L, 10002L);
assertThat(result.records()).extracting(record -> record.loadingStatus())
.containsExactly("T1", "T2");
assertThat(result.records()).extracting(record -> record.locationCodes())
.containsExactly("A1", "B1");
ArgumentCaptor<Wrapper<InboundOrderEntity>> orderCaptor = ArgumentCaptor.forClass(Wrapper.class);
verify(orders).selectPage(any(Page.class), orderCaptor.capture());
assertThat(orderCaptor.getValue().getCustomSqlSegment()).doesNotContain("tenant_id");
ArgumentCaptor<Wrapper<InboundTransportSnapshotEntity>> transportCaptor =
ArgumentCaptor.forClass(Wrapper.class);
verify(transports).selectList(transportCaptor.capture());
assertThat(transportCaptor.getValue().getCustomSqlSegment())
.doesNotContain("tenant_id")
.contains("inbound_id IN");
}
private static InboundPalletAllocationEntity allocation(
long inboundId, int allocationNo, String locationCode) {
return allocation(inboundId, 10001L, allocationNo, locationCode);
}
private static InboundPalletAllocationEntity allocation(
long inboundId, long tenantId, int allocationNo, String locationCode) {
InboundPalletAllocationEntity entity = new InboundPalletAllocationEntity();
entity.setInboundId(inboundId);
entity.setTenantId(tenantId);
entity.setAllocationNo(allocationNo);
entity.setLocationCode(locationCode);
return entity;
}
private static InboundOrderEntity order(long id, long tenantId) {
InboundOrderEntity entity = new InboundOrderEntity();
entity.setId(id);
entity.setTenantId(tenantId);
return entity;
}
private static InboundTransportSnapshotEntity transport(
long inboundId, long tenantId, String loadingStatus) {
InboundTransportSnapshotEntity entity = new InboundTransportSnapshotEntity();
entity.setInboundId(inboundId);
entity.setTenantId(tenantId);
entity.setLoadingStatus(loadingStatus);
return entity;
}
}
@@ -16,12 +16,27 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@DisplayName("FBA计费基础资料版本")
class FbaMasterDataServiceTest {
private static final LocalDateTime AUGUST = LocalDateTime.of(2026, 8, 1, 0, 0);
@Test
@DisplayName("平台列表不传企业时委托仓储查询全部企业规则")
void listsRulesAcrossTenantsWithoutTenantFilter() {
FbaPricingRepository repository = mock(FbaPricingRepository.class);
List<CustomerChargeRuleEntity> expected = List.of(activeRule(AUGUST, null));
when(repository.listRules(null, "CR-1")).thenReturn(expected);
List<CustomerChargeRuleEntity> result =
new FbaMasterDataService(repository).listRules(null, "CR-1");
assertThat(result).isSameAs(expected);
verify(repository).listRules(null, "CR-1");
}
@Test
@DisplayName("半开有效期首尾相接不视为重叠")
void acceptsAdjacentHalfOpenPeriods() {
@@ -21,6 +21,17 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class InboundOrderQueryServiceTest {
@Test
void shouldQueryAllPlatformTenantsWhenTenantFilterIsMissing() {
InboundOrderRepository repository = mock(InboundOrderRepository.class);
InboundOrderQuery query = query(null, null, null, null);
PageResult<InboundOrderSummary> expected = new PageResult<>(List.of(), 1, 20, 0, 0);
when(repository.page(null, query)).thenReturn(expected);
assertThat(new InboundOrderQueryService(repository).page(null, query)).isSameAs(expected);
verify(repository).page(null, query);
}
@Test
void shouldKeepV2TabMappingWithoutCustomerConfirmation() {
assertThat(InboundListTab.WAIT_MEASURE.processStatuses())