Merge branch 'test' into test-02huogui

# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
#	firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java
#	firstleg/src/main/resources/application.yml
#	firstleg/src/main/resources/openapi/fba-pricing-v1.yaml
#	firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java
#	order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java
#	order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java
This commit is contained in:
2026-08-07 19:49:09 +08:00
192 changed files with 10729 additions and 391 deletions
@@ -0,0 +1,62 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.InitializeRequest;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.InitializeResponse;
import com.baiyue.firstleg.service.CustomerDimensionConfigService;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(InternalCustomerDimensionConfigController.class)
class CustomerDimensionConfigControllerContractTest {
@Resource
private InternalCustomerDimensionConfigController internalController;
@MockBean
private CustomerDimensionConfigService service;
@BeforeEach
void initializeToken() {
ReflectionTestUtils.setField(internalController, "internalToken", "test-token");
internalController.initializeToken();
}
@Test
void internalInitializationRequiresTokenAndReturnsCreationIdentity() {
InitializeRequest request = new InitializeRequest(8L, 100L, "registration-1");
when(service.initializeDefault(8L, 100L, "registration-1"))
.thenReturn(new InitializeResponse(91L, true));
assertThatThrownBy(() -> internalController.initializeDefault("wrong", request))
.isInstanceOf(BizException.class);
assertThat(internalController.initializeDefault("test-token", request).data())
.isEqualTo(new InitializeResponse(91L, true));
verify(service).initializeDefault(8L, 100L, "registration-1");
}
@Test
void platformControllerKeepsScopeAndExistingPermissions() throws Exception {
RequestMapping mapping = PlatformCustomerDimensionConfigController.class
.getAnnotation(RequestMapping.class);
assertThat(mapping.value()).containsExactly("/api/platform/v1/first-leg/customer-dimension-config");
assertThat(PlatformCustomerDimensionConfigController.class
.getDeclaredMethod("get", com.baiyue.common.security.AuthenticatedUser.class, long.class)
.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('firstleg:dimension-config:view')");
assertThat(PlatformCustomerDimensionConfigController.class
.getDeclaredMethod("update", com.baiyue.common.security.AuthenticatedUser.class,
long.class, com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest.class)
.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('firstleg:dimension-config:edit')");
}
}
@@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@@ -19,6 +20,8 @@ class InboundOrderControllerContractTest {
AuthenticatedUser.class, Long.class, InboundOrderQuery.class);
assertAuthority("detail", "hasAuthority('platform:firstleg:inbound:view')",
AuthenticatedUser.class, long.class, long.class);
assertAuthority("printCartonLabels", "hasAuthority('platform:firstleg:inbound:view')",
AuthenticatedUser.class, long.class, long.class, List.class, String.class);
Method page = PlatformInboundOrderController.class.getDeclaredMethod(
"page", AuthenticatedUser.class, Long.class, InboundOrderQuery.class);
@@ -41,9 +44,12 @@ class InboundOrderControllerContractTest {
assertThat(openapi)
.contains("/api/platform/inbounds:")
.contains("/api/platform/inbounds/{inboundId}:")
.contains("/api/platform/inbounds/{inboundId}/carton-label-pdf:")
.contains("/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/carton-scans:")
.contains("required: false")
.contains("InboundOrderSummary:")
.contains("required: [id, tenantId]")
.contains("pricingTaxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED]")
.contains("tenantId: {type: string, pattern: '^[1-9][0-9]*$', description:")
.contains("[WAIT_MEASURE, WAIT_REVIEW, WAIT_FEE_SEND, INBOUNDED]")
.contains("platform:firstleg:inbound:list")
@@ -0,0 +1,91 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.CartonMeasurementCommand;
import com.baiyue.firstleg.dto.CartonMeasurementResult;
import com.baiyue.firstleg.service.CartonMeasurementService;
import com.baiyue.firstleg.service.InboundMeasurementService;
import com.baiyue.firstleg.service.MeasurementSubmissionService;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.mock.web.MockPart;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class PlatformCartonScanControllerTest {
@Test
void bindsJsonDataAndImageToTheFormalCartonMeasurementCommand() throws Exception {
CartonMeasurementService cartonMeasurements = mock(CartonMeasurementService.class);
PlatformInboundMeasurementController controller = new PlatformInboundMeasurementController();
ReflectionTestUtils.setField(controller, "service", mock(InboundMeasurementService.class));
ReflectionTestUtils.setField(controller, "submission", mock(MeasurementSubmissionService.class));
ReflectionTestUtils.setField(controller, "cartonMeasurements", cartonMeasurements);
AuthenticatedUser user = new AuthenticatedUser(90001L, 0L, 0L, null,
"platform_admin", "PLATFORM", "WEB", Set.of("platform:firstleg:inbound:measure:edit"));
MockMvc mvc = MockMvcBuilders.standaloneSetup(controller)
.setCustomArgumentResolvers(authenticationPrincipal(user))
.build();
when(cartonMeasurements.upsertForInbound(eq(10001L), eq(90001L), eq(70001L), any()))
.thenReturn(new CartonMeasurementResult(30001L, 1, 40001L, "RK-001#5-1", 1,
new BigDecimal("60.1"), new BigDecimal("40.2"), new BigDecimal("30.3"),
new BigDecimal("12.5"), 99001L, "SIM-01",
LocalDateTime.parse("2026-08-07T10:30:00"), true));
MockPart data = new MockPart("data", "data", ("""
{"boxCode":"RK-001#5-1","length":60.1,"width":40.2,"height":30.3,
"weight":12.5,"machineNo":"SIM-01","measuredAt":"2026-08-07T10:30:00"}
""").getBytes(java.nio.charset.StandardCharsets.UTF_8));
data.getHeaders().setContentType(MediaType.APPLICATION_JSON);
MockMultipartFile image = new MockMultipartFile(
"image", "carton.jpg", MediaType.IMAGE_JPEG_VALUE, new byte[] {1, 2, 3});
mvc.perform(multipart("/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/carton-scans", 70001L)
.file(image).part(data).param("tenantId", "10001"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value("200"))
.andExpect(jsonPath("$.data.boxCode").value("RK-001#5-1"));
ArgumentCaptor<CartonMeasurementCommand> command = ArgumentCaptor.forClass(CartonMeasurementCommand.class);
verify(cartonMeasurements).upsertForInbound(
eq(10001L), eq(90001L), eq(70001L), command.capture());
assertThat(command.getValue().machineNo()).isEqualTo("SIM-01");
assertThat(command.getValue().image().getOriginalFilename()).isEqualTo("carton.jpg");
}
private HandlerMethodArgumentResolver authenticationPrincipal(AuthenticatedUser user) {
return new HandlerMethodArgumentResolver() {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(AuthenticationPrincipal.class);
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
org.springframework.web.bind.support.WebDataBinderFactory binderFactory) {
return user;
}
};
}
}
@@ -5,6 +5,7 @@ import com.baiyue.firstleg.dto.InboundMeasurementRequests;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.multipart.MultipartFile;
import java.lang.reflect.Method;
@@ -22,13 +23,20 @@ class PlatformInboundMeasurementControllerContractTest {
assertAuthority("submit", "hasAuthority('platform:firstleg:inbound:measure:submit')",
AuthenticatedUser.class, long.class, long.class,
InboundMeasurementRequests.SubmitRequest.class);
assertAuthority("updateConfirmedPackageCount", "hasAuthority('platform:firstleg:inbound:measure:edit')",
AuthenticatedUser.class, long.class, long.class, long.class,
InboundMeasurementRequests.ConfirmedPackageCountRequest.class);
assertAuthority("simulateCartonScan", "hasAuthority('platform:firstleg:inbound:measure:edit')",
AuthenticatedUser.class, long.class, long.class,
InboundMeasurementRequests.CartonScanRequest.class, MultipartFile.class);
assertAuthority("createWeightResult", "hasAuthority('platform:firstleg:inbound:weight:edit')",
AuthenticatedUser.class, long.class, long.class,
InboundMeasurementRequests.CreateWeightResultRequest.class);
assertAuthority("correctWeightResult", "hasAuthority('platform:firstleg:inbound:weight:edit')",
AuthenticatedUser.class, long.class, long.class, long.class,
InboundMeasurementRequests.CorrectWeightResultRequest.class);
assertAuthority("confirmWeightResult", "hasAuthority('platform:firstleg:inbound:weight:confirm')",
assertAuthority("confirmWeightResult", "hasAuthority('platform:firstleg:inbound:weight:confirm')"
+ " and (!#request.hasAdjustment() or hasAuthority('platform:firstleg:inbound:weight:edit'))",
AuthenticatedUser.class, long.class, long.class, long.class,
InboundMeasurementRequests.ConfirmWeightResultRequest.class);
}
@@ -0,0 +1,100 @@
package com.baiyue.firstleg.controller;
import com.baiyue.firstleg.domain.PriceBookChannelCatalog;
import com.baiyue.firstleg.domain.PriceBookStatus;
import com.baiyue.firstleg.domain.PriceModel;
import com.baiyue.firstleg.domain.PricingTaxMode;
import com.baiyue.firstleg.domain.PricingDestinationType;
import com.baiyue.firstleg.dto.PriceBookDtos;
import com.baiyue.firstleg.mapper.PriceBookQueryMapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("船期价格表公共契约")
class PriceBookContractTest {
@Test
@DisplayName("固定十五个渠道且海派使用区域重量模型")
void keepsStableChannelCatalog() {
assertThat(PriceBookChannelCatalog.CHANNELS).hasSize(15);
assertThat(PriceBookChannelCatalog.CHANNELS.stream()
.map(PriceBookChannelCatalog.ChannelDefinition::channelCode))
.containsExactly(
"MATSON_CLX_TRUCK", "MATSON_MAX_TRUCK", "EXX_TRUCK", "HEDE_TRUCK", "EXX_RAIL_NY",
"OA_LA_TRUCK", "OA_LA_FULL_TRUCK", "OA_CHICAGO_RAIL", "OA_HOUSTON_TRUCK",
"OA_NEW_YORK_TRUCK", "OA_SAVANNAH_TRUCK", "MATSON_CLX_PARCEL", "MATSON_MAX_PARCEL",
"HEDE_PARCEL", "OA_PARCEL");
assertThat(PriceBookChannelCatalog.CHANNELS.stream()
.filter(channel -> channel.priceModel() == PriceModel.REGION_WEIGHT_TIER))
.extracting(PriceBookChannelCatalog.ChannelDefinition::channelCode)
.containsExactly("MATSON_CLX_PARCEL", "MATSON_MAX_PARCEL", "HEDE_PARCEL", "OA_PARCEL");
}
@Test
@DisplayName("报价包税维度独立于订单税模式")
void keepsPricingTaxModeSeparate() {
assertThat(PriceBookStatus.values()).containsExactly(
PriceBookStatus.PENDING_CONFIRMATION, PriceBookStatus.ACTIVE,
PriceBookStatus.SUPERSEDED, PriceBookStatus.FAILED);
assertThat(PricingTaxMode.values()).containsExactly(PricingTaxMode.TAX_INCLUDED, PricingTaxMode.TAX_EXCLUDED);
assertThat(PricingDestinationType.values()).containsExactly(PricingDestinationType.FBA, PricingDestinationType.OVERSEAS);
assertThat(PriceBookDtos.ParsedRate.class.getRecordComponents())
.extracting(component -> component.getName())
.contains("pricingTaxMode", "originWarehouseId", "destinationType", "destinationCode", "destinationWarehouseId", "destinationName", "regionCode");
assertThat(PriceBookDtos.EstimateRequest.class.getRecordComponents())
.extracting(component -> component.getName())
.contains("destinationType", "destinationWarehouseId", "destinationName");
assertThat(PriceBookDtos.CurrentChannel.class.getRecordComponents())
.extracting(component -> component.getName())
.contains("pricingTaxModes");
}
@Test
@DisplayName("渠道税模式只汇总当前版本和起运仓的生效价格行")
void scopesChannelPricingTaxModesToActiveRows() throws Exception {
Select select = PriceBookQueryMapper.class
.getMethod("findChannelTaxModes", long.class, long.class)
.getAnnotation(Select.class);
assertThat(String.join("\n", select.value()))
.contains("r.price_book_version_id = #{versionId}")
.contains("r.origin_warehouse_id = #{originWarehouseId}")
.contains("r.status = 'ACTIVE'")
.contains("r.deleted = 0")
.contains("r.pricing_tax_mode IS NOT NULL")
.contains("GROUP BY r.channel_code, r.pricing_tax_mode");
}
@Test
@DisplayName("OpenAPI冻结待确认上传、发布和客户参考估价路径")
void documentsPriceBookLifecycle() throws Exception {
String openapi = new ClassPathResource("openapi/fba-pricing-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(openapi)
.contains("/api/platform/v1/fba-pricing/carrier-rates/price-books:")
.contains("/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}:")
.contains("/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/publish:")
.contains("/api/merchant/v1/first-leg/pricing/channels:")
.contains("/api/merchant/v1/first-leg/pricing/estimate:")
.contains("PriceBookStatus")
.contains("FBA_WAREHOUSE_RATE")
.contains("REGION_WEIGHT_TIER")
.contains("pricingTaxMode")
.contains("PricingDestinationType")
.contains("MerchantPricingChannel")
.contains("pricingTaxModes")
.contains("destinationWarehouseId")
.contains("parseIssuesJson")
.contains("PriceBookVersionDetail")
.contains("PriceBookParseIssue")
.contains("/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/source")
.contains("确认并立即生效")
.contains("SUPERSEDED");
}
}
@@ -0,0 +1,67 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.math.BigDecimal;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
class ConfirmWeightResultRequestValidationTest {
private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
@Test
void acceptsConfirmationWithoutAdjustment() {
var request = new InboundMeasurementRequests.ConfirmWeightResultRequest(
0, null, null, null);
assertThat(validator.validate(request)).isEmpty();
assertThat(request.hasAdjustment()).isFalse();
}
@Test
void acceptsDocumentedWeightBoundaries() {
var minimum = new InboundMeasurementRequests.ConfirmWeightResultRequest(
0, new BigDecimal("0.001"), "KG", "最小值");
var maximum = new InboundMeasurementRequests.ConfirmWeightResultRequest(
Integer.MAX_VALUE, new BigDecimal("999999999999999.999"), "LB", "最大值");
assertThat(validator.validate(minimum)).isEmpty();
assertThat(validator.validate(maximum)).isEmpty();
assertThat(minimum.hasAdjustment()).isTrue();
assertThat(maximum.hasAdjustment()).isTrue();
}
@ParameterizedTest
@MethodSource("invalidRequests")
void rejectsInvalidFieldBoundaries(
InboundMeasurementRequests.ConfirmWeightResultRequest request,
String invalidField) {
assertThat(validator.validate(request))
.extracting(violation -> violation.getPropertyPath().toString())
.contains(invalidField);
}
private static Stream<Arguments> invalidRequests() {
return Stream.of(
Arguments.of(request(-1, "1.000", "KG", "原因"), "version"),
Arguments.of(request(0, "0.000", "KG", "原因"), "adjustedWeightValue"),
Arguments.of(request(0, "-1.000", "KG", "原因"), "adjustedWeightValue"),
Arguments.of(request(0, "1.0001", "KG", "原因"), "adjustedWeightValue"),
Arguments.of(request(0, "1000000000000000.000", "KG", "原因"), "adjustedWeightValue"),
Arguments.of(request(0, "1.000", "KILOGRAMS", "原因"), "adjustedWeightUnit"),
Arguments.of(request(0, "1.000", "KG", "x".repeat(501)), "adjustmentReason")
);
}
private static InboundMeasurementRequests.ConfirmWeightResultRequest request(
int version, String value, String unit, String reason) {
return new InboundMeasurementRequests.ConfirmWeightResultRequest(
version, new BigDecimal(value), unit, reason);
}
}
@@ -0,0 +1,22 @@
package com.baiyue.firstleg.mapper;
import org.apache.ibatis.annotations.Delete;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("价格版本渠道 Mapper 契约")
class PriceBookChannelMapperTest {
@Test
@DisplayName("重新生成版本快照时使用物理删除避免唯一键冲突")
void shouldPhysicallyDeleteVersionChannels() throws Exception {
Method method = PriceBookChannelMapper.class
.getMethod("physicallyDeleteByPriceBookVersionId", long.class);
assertThat(method.getAnnotation(Delete.class).value())
.containsExactly("DELETE FROM fl_price_book_channel WHERE price_book_version_id = #{versionId}");
}
}
@@ -0,0 +1,50 @@
package com.baiyue.firstleg.pricing;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class FbaWarehouseRateEstimatorTest {
private final FbaWarehouseRateEstimator estimator = new FbaWarehouseRateEstimator();
@Test
void shouldUseGreaterOfActualAndVolumeWeightForKgRate() {
var estimate = estimator.estimate(rate("KG", "12.50"),
new BigDecimal("20"), new BigDecimal("0.20"));
assertThat(estimate.quantity()).isEqualByComparingTo("33.400000");
assertThat(estimate.unit()).isEqualTo("KG");
assertThat(estimate.unitPrice()).isEqualByComparingTo("12.50");
assertThat(estimate.amount()).isEqualByComparingTo("417.50");
assertThat(estimate.currency()).isEqualTo("CNY");
}
@Test
void shouldUseGreaterOfVolumeAndWeightConversionForCbmRate() {
var estimate = estimator.estimate(rate("CBM", "880"),
new BigDecimal("363"), new BigDecimal("0.50"));
assertThat(estimate.quantity()).isEqualByComparingTo("1.000000");
assertThat(estimate.amount()).isEqualByComparingTo("880.00");
}
@Test
void shouldRejectMissingWeightAndVolume() {
assertThatThrownBy(() -> estimator.estimate(rate("KG", "10"), null, BigDecimal.ZERO))
.isInstanceOf(BizException.class)
.hasMessageContaining("至少填写一项");
}
private static RateRow rate(String unit, String price) {
RateRow row = new RateRow();
row.setChargeUnit(unit);
row.setUnitPrice(new BigDecimal(price));
row.setCurrency("CNY");
return row;
}
}
@@ -0,0 +1,35 @@
package com.baiyue.firstleg.pricing;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.domain.PriceRegion;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class PriceRegionResolverTest {
private final PriceRegionResolver resolver = new PriceRegionResolver();
@ParameterizedTest
@CsvSource({
"90001, WEST",
"60601, CENTRAL",
"10001, EAST",
"'US 46201', EAST"
})
void shouldResolveUnitedStatesPostalCode(String postalCode, PriceRegion expected) {
assertThat(resolver.fromPostalCode(postalCode)).isEqualTo(expected);
}
@ParameterizedTest
@NullSource
@ValueSource(strings = {"", "USA"})
void shouldRejectPostalCodeWithoutDigits(String postalCode) {
assertThatThrownBy(() -> resolver.fromPostalCode(postalCode))
.isInstanceOf(BizException.class)
.hasMessageContaining("无法确定海派区域");
}
}
@@ -0,0 +1,53 @@
package com.baiyue.firstleg.pricing;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class RegionWeightTierEstimatorTest {
private final RegionWeightTierEstimator estimator = new RegionWeightTierEstimator();
private final List<RateRow> tiers = List.of(tier("12", "15"), tier("51", "13"), tier("101", "11"));
@Test
void shouldChooseHighestTierNotExceedingChargeableWeightAtBoundaries() {
assertThat(estimator.estimate(tiers, new BigDecimal("12"), null).unitPrice())
.isEqualByComparingTo("15");
assertThat(estimator.estimate(tiers, new BigDecimal("50.999999"), null).unitPrice())
.isEqualByComparingTo("15");
assertThat(estimator.estimate(tiers, new BigDecimal("51"), null).unitPrice())
.isEqualByComparingTo("13");
assertThat(estimator.estimate(tiers, new BigDecimal("101"), null).unitPrice())
.isEqualByComparingTo("11");
}
@Test
void shouldUseVolumeWeightBeforeSelectingTier() {
var estimate = estimator.estimate(tiers, new BigDecimal("20"), new BigDecimal("0.61"));
assertThat(estimate.quantity()).isEqualByComparingTo("101.870000");
assertThat(estimate.unitPrice()).isEqualByComparingTo("11");
assertThat(estimate.amount()).isEqualByComparingTo("1120.57");
}
@Test
void shouldRejectWeightBelowLowestTier() {
assertThatThrownBy(() -> estimator.estimate(tiers, new BigDecimal("11.999999"), null))
.isInstanceOf(BizException.class)
.hasMessageContaining("最低重量阶梯");
}
private static RateRow tier(String from, String price) {
RateRow row = new RateRow();
row.setChargeUnit("KG");
row.setWeightFromKg(new BigDecimal(from));
row.setUnitPrice(new BigDecimal(price));
row.setCurrency("CNY");
return row;
}
}
@@ -0,0 +1,206 @@
package com.baiyue.firstleg.pricing.importer;
import com.baiyue.firstleg.domain.PricingTaxMode;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PriceWorkbookParserTest {
private static final List<String> STANDARD_SHEETS = List.of(
"美森正班海卡", "美森加班海卡", "EXX海卡", "合德海卡", "纽约EXX海铁",
"洛杉矶普船海卡", "洛杉矶普船全卡", "芝加哥普船海铁", "休斯顿普船海卡",
"纽约普船海卡", "萨凡纳普船海卡");
@Test
void parsesFifteenChannelsAndIgnoresMalformedQuickQueryTable() throws Exception {
Path workbook = malformedWorkbook();
PriceWorkbookParseResult result = new PriceWorkbookParser().parse(workbook);
assertEquals(15, result.channels().size());
assertFalse(result.hasBlockingErrors());
assertTrue(result.ignoredSheets().contains("快速查询"));
PriceWorkbookParseResult.ParsedChannel matson = result.channels().stream()
.filter(channel -> channel.channelCode().equals("MATSON_CLX_TRUCK")).findFirst().orElseThrow();
List<PriceWorkbookParseResult.ParsedRate> ont = matson.rates().stream()
.filter(rate -> "ONT8".equals(rate.destinationCode())).toList();
assertEquals(3, ont.size());
assertTrue(ont.stream().anyMatch(rate -> rate.originWarehouseKey().equals("YIWU")
&& rate.taxMode() == PricingTaxMode.TAX_EXCLUDED
&& rate.chargeUnit().equals("CBM")
&& rate.unitPrice().compareTo(new BigDecimal("1790")) == 0));
assertTrue(ont.stream().anyMatch(rate -> rate.originWarehouseKey().equals("DONGGUAN")
&& rate.taxMode() == PricingTaxMode.TAX_INCLUDED
&& rate.unitPrice().compareTo(new BigDecimal("10.7")) == 0));
PriceWorkbookParseResult.ParsedChannel parcel = result.channels().stream()
.filter(channel -> channel.channelCode().equals("MATSON_CLX_PARCEL")).findFirst().orElseThrow();
assertEquals(18, parcel.rates().size());
assertTrue(parcel.rates().stream().allMatch(rate -> rate.taxMode() == PricingTaxMode.TAX_INCLUDED));
assertTrue(parcel.rates().stream().anyMatch(rate -> rate.originWarehouseKey().equals("DONGGUAN")
&& rate.regionCode().equals("EAST")
&& rate.minimumWeightKg().compareTo(BigDecimal.valueOf(101)) == 0), parcel.rates().toString());
PriceWorkbookParseResult.ParsedChannel hedeParcel = result.channels().stream()
.filter(channel -> channel.channelCode().equals("HEDE_PARCEL")).findFirst().orElseThrow();
assertTrue(hedeParcel.rates().stream().anyMatch(rate -> rate.originWarehouseKey().equals("YIWU")
&& rate.regionCode().equals("WEST")
&& rate.minimumWeightKg().compareTo(BigDecimal.valueOf(12)) == 0
&& rate.unitPrice().compareTo(new BigDecimal("15.8")) == 0), hedeParcel.rates().toString());
}
@Test
void keepsUnknownDestinationAsBlockingMappingIssueInsteadOfDroppingTheRow() throws Exception {
Path workbook = minimalStandardWorkbook("未知仓组", "1790", "10.2", "10.7");
PriceWorkbookParseResult result = new PriceWorkbookParser().parse(workbook);
assertTrue(result.hasBlockingErrors());
assertTrue(result.issues().stream().anyMatch(issue -> issue.code().equals("DESTINATION_MAPPING_REQUIRED")));
assertTrue(result.channels().stream().flatMap(channel -> channel.rates().stream())
.anyMatch(rate -> rate.rawDestinationGroup().equals("未知仓组") && rate.destinationCode() == null));
}
@Test
void parsesPlatformOverseasWarehouseRowsWithoutGuessingWarehouseIdOrCode() throws Exception {
Path workbook = minimalStandardWorkbook("海外仓(洛杉矶仓)", "1790", "10.2", "10.7");
PriceWorkbookParseResult result = new PriceWorkbookParser().parse(workbook);
assertFalse(result.hasBlockingErrors());
List<PriceWorkbookParseResult.ParsedRate> rates = result.channels().stream()
.flatMap(channel -> channel.rates().stream()).toList();
assertEquals(3, rates.size());
assertTrue(rates.stream().allMatch(rate -> "OVERSEAS".equals(rate.destinationType())
&& "洛杉矶仓".equals(rate.destinationName())
&& rate.destinationCode() == null
&& rate.rawDestinationGroup().equals("海外仓(洛杉矶仓)")));
}
@Test
void parsesTheRealWorkbookWhenAnExternalPathIsProvided() throws Exception {
String configured = System.getProperty("price.workbook");
Assumptions.assumeTrue(configured != null && !configured.isBlank());
PriceWorkbookParseResult result = new PriceWorkbookParser().parse(Path.of(configured));
System.out.printf("REAL_WORKBOOK channels=%d rates=%d issues=%d blocking=%d ignored=%s%n",
result.channels().size(), result.rateCount(), result.issues().size(),
result.issues().stream().filter(issue -> issue.severity() == PriceWorkbookParseResult.Severity.ERROR).count(),
result.ignoredSheets());
result.issues().forEach(issue -> System.out.printf("REAL_ISSUE %s %s sheet=%s row=%s raw=%s%n",
issue.severity(), issue.code(), issue.sheetName(), issue.rowNumber(), issue.rawValue()));
assertEquals(15, result.channels().size());
assertTrue(result.channels().stream().flatMap(channel -> channel.rates().stream())
.anyMatch(rate -> "ONT8".equals(rate.destinationCode())));
assertTrue(result.channels().stream().anyMatch(channel -> channel.channelCode().equals("OA_PARCEL")));
assertFalse(result.hasBlockingErrors());
assertFalse(result.issues().stream().anyMatch(issue ->
issue.code().equals("OVERSEAS_WAREHOUSE_NOT_INCLUDED")));
assertTrue(result.channels().stream().flatMap(channel -> channel.rates().stream())
.anyMatch(rate -> "OVERSEAS".equals(rate.destinationType())
&& "洛杉矶仓".equals(rate.destinationName())
&& rate.destinationCode() == null));
assertTrue(result.channels().stream().flatMap(channel -> channel.rates().stream())
.anyMatch(rate -> "OVERSEAS".equals(rate.destinationType())
&& "纽约仓".equals(rate.destinationName())
&& rate.destinationCode() == null));
}
private static Path malformedWorkbook() throws IOException {
Path valid = minimalWorkbook();
Path malformed = Files.createTempFile("price-parser-malformed-", ".xlsx");
try (ZipInputStream input = new ZipInputStream(Files.newInputStream(valid));
OutputStream output = Files.newOutputStream(malformed);
ZipOutputStream zip = new ZipOutputStream(output)) {
ZipEntry entry;
while ((entry = input.getNextEntry()) != null) {
zip.putNextEntry(new ZipEntry(entry.getName()));
input.transferTo(zip);
zip.closeEntry();
}
zip.putNextEntry(new ZipEntry("xl/tables/table1.xml"));
zip.write("<table xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" ref=\"5:31\"/>".getBytes());
zip.closeEntry();
} finally {
Files.deleteIfExists(valid);
}
return malformed;
}
private static Path minimalWorkbook() throws IOException {
Path file = Files.createTempFile("price-parser-", ".xlsx");
try (XSSFWorkbook workbook = new XSSFWorkbook()) {
for (int i = 0; i < STANDARD_SHEETS.size(); i++) {
String sheetName = STANDARD_SHEETS.get(i);
var sheet = workbook.createSheet(sheetName);
sheet.createRow(1).createCell(0).setCellValue("船名航次:DANIEL K. INOUYE V.100E 上海出,预计开船日期:2026年8月12日");
boolean shifted = sheetName.equals("纽约EXX海铁");
int base = shifted ? 1 : 0;
sheet.createRow(3).createCell(base).setCellValue("FBA仓库");
sheet.getRow(3).createCell(base + 1).setCellValue("华东仓(义乌)");
sheet.getRow(3).createCell(base + 3).setCellValue("华南仓(深圳)");
var row = sheet.createRow(5);
row.createCell(base).setCellValue("ONT8/LGB8");
row.createCell(base + 1).setCellValue(1790);
row.createCell(base + 2).setCellValue(10.2);
row.createCell(base + 3).setCellValue(10.7);
row.createCell(base + 4).setCellValue("20天左右");
row.createCell(base + 5).setCellValue("周日全天");
row.createCell(base + 6).setCellValue("MATSON 上海周三开");
}
var parcel = workbook.createSheet("海派");
parcel.createRow(4).createCell(2).setCellValue("MASTON 周三开");
parcel.getRow(4).createCell(5).setCellValue("MASTON 周四开");
parcel.getRow(4).createCell(8).setCellValue("合德 周四开");
parcel.getRow(4).createCell(11).setCellValue("普船统配");
parcel.createRow(5).createCell(2).setCellValue("船开后15天左右");
parcel.createRow(8).createCell(0).setCellValue("美西");
parcel.createRow(9).createCell(0).setCellValue("美中");
parcel.createRow(10).createCell(0).setCellValue("美东");
for (int row = 8; row <= 10; row++) {
for (int col = 2; col <= 13; col++) parcel.getRow(row).createCell(col).setCellValue(15 + col / 10.0);
}
parcel.createRow(13).createCell(2).setCellValue("MASTON 周三开");
parcel.createRow(14).createCell(2).setCellValue("船开后15天左右");
parcel.createRow(17).createCell(0).setCellValue("美西");
parcel.createRow(18).createCell(0).setCellValue("美中");
parcel.createRow(19).createCell(0).setCellValue("美东");
for (int row = 17; row <= 19; row++) {
for (int col = 2; col <= 13; col++) parcel.getRow(row).createCell(col).setCellValue(16 + col / 10.0);
}
var quick = workbook.createSheet("快速查询");
quick.createRow(4).createCell(0).setCellValue("列1");
try (OutputStream output = Files.newOutputStream(file)) { workbook.write(output); }
}
return file;
}
private static Path minimalStandardWorkbook(String destination, String east, String yiwuTax, String dongguanTax) throws IOException {
Path file = Files.createTempFile("price-parser-unknown-", ".xlsx");
try (XSSFWorkbook workbook = new XSSFWorkbook()) {
var sheet = workbook.createSheet("美森正班海卡");
sheet.createRow(3).createCell(0).setCellValue("FBA仓库");
var row = sheet.createRow(5);
row.createCell(0).setCellValue(destination);
row.createCell(1).setCellValue(Double.parseDouble(east));
row.createCell(2).setCellValue(Double.parseDouble(yiwuTax));
row.createCell(3).setCellValue(Double.parseDouble(dongguanTax));
try (OutputStream output = Files.newOutputStream(file)) { workbook.write(output); }
}
return file;
}
}
@@ -0,0 +1,65 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.Test;
import org.apache.ibatis.annotations.Delete;
import org.springframework.core.io.ClassPathResource;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class CustomerDimensionConfigSqlContractTest {
@Test
void moduleMigrationDefinesUniqueCustomerConfigAndDefault() throws IOException {
String sql = new ClassPathResource("db/customer-dimension-config.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fl_customer_dimension_config")
.contains("edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000")
.contains("initialization_key VARCHAR(128) NOT NULL")
.contains("UNIQUE KEY uk_customer_dimension_config_tenant (tenant_id)");
}
@Test
void unifiedMigrationIncludesConfigAndFinalAssertion() throws IOException {
String sql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS baiyue_firstleg.fl_customer_dimension_config")
.contains("客户尺寸计算配置表未创建")
.contains("uk_customer_dimension_config_tenant")
.contains("CREATE PROCEDURE unified_guard_dimension_config_permissions()")
.contains("id = 5102111 AND COALESCE(permission_code, '') <> 'firstleg:dimension-config:view'")
.contains("permission_code = 'firstleg:dimension-config:view' AND id <> 5102111")
.contains("id = 5102112 AND COALESCE(permission_code, '') <> 'firstleg:dimension-config:edit'")
.contains("permission_code = 'firstleg:dimension-config:edit' AND id <> 5102112")
.contains("(5102111, 0, 'PLATFORM', 'BUTTON', 5100010")
.contains("(5102112, 0, 'PLATFORM', 'BUTTON', 5100010")
.contains("r.role_code = 'PLATFORM_ADMIN'")
.contains("r.role_code = 'ORDER_OPERATOR'")
.contains("客户尺寸计算配置权限父节点企业客户不存在")
.contains("客户尺寸计算配置权限码、固定ID或父节点不正确")
.contains("平台管理员未获得客户尺寸计算配置读写权限")
.contains("订单运营角色的客户尺寸计算配置权限不是只读");
}
@Test
void compensationDeleteRequiresExactKeyDefaultValueAndUnmodifiedVersion() throws Exception {
Delete annotation = com.baiyue.firstleg.mapper.CustomerDimensionConfigMapper.class
.getDeclaredMethod("deleteUnchangedDefault", long.class, long.class,
String.class, java.math.BigDecimal.class)
.getAnnotation(Delete.class);
String sql = String.join(" ", annotation.value());
assertThat(sql)
.contains("tenant_id = #{tenantId}")
.contains("initialization_key = #{initializationKey}")
.contains("edge_addition_cm = #{edgeAdditionCm}")
.contains("version = 0");
}
}
@@ -0,0 +1,35 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class InboundCartonLabelSqlContractTest {
@Test
void baseSchemaAndExistingDatabaseMigrationKeepStableCartonKeys() throws Exception {
String schema = resource("db/phase2-inbound-schema.sql");
String migration = resource("db/phase2-inbound-carton-label.sql");
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"));
assertThat(schema).contains("CREATE TABLE IF NOT EXISTS fl_inbound_carton_label")
.contains("UNIQUE KEY uk_carton_label_box_code (tenant_id, box_code)")
.contains("UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, carton_index)")
.contains("inbound_detail_id BIGINT UNSIGNED NOT NULL");
assertThat(migration).contains("CREATE TABLE IF NOT EXISTS fl_inbound_carton_label")
.contains("固定二维码内容:入仓码#总箱数-箱下标")
.contains("idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id)");
assertThat(unified).contains("CREATE TABLE IF NOT EXISTS fl_inbound_carton_label")
.contains("uk_carton_label_box_code")
.contains("uk_carton_label_index")
.contains("稳定箱号映射表未创建");
}
private String resource(String path) throws Exception {
return new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
}
}
@@ -5,6 +5,8 @@ import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
@@ -38,4 +40,27 @@ class InboundWorkflowSqlContractTest {
.contains("SET warehouse_resource_tenant_id = 0")
.contains("MODIFY COLUMN warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL");
}
@Test
@DisplayName("单箱当前测量记录包含箱号、设备元数据和唯一约束")
void shouldPersistCurrentCartonMeasurementFields() throws Exception {
String schema = new ClassPathResource("db/phase2-inbound-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
String migration = new ClassPathResource("db/phase2-carton-measurement-current-record.sql")
.getContentAsString(StandardCharsets.UTF_8);
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"));
assertThat(schema)
.contains("box_code VARCHAR(128)")
.contains("machine_no VARCHAR(64)")
.contains("device_measured_at DATETIME(3)")
.contains("uk_measure_box_code");
assertThat(migration)
.contains("phase2_add_carton_measurement_column")
.contains("uk_measure_box_code")
.contains("information_schema.STATISTICS");
assertThat(unified)
.contains("CALL phase2_measure_add_column('fl_inbound_measurement_item', 'box_code'")
.contains("uk_measure_box_code")
.contains("单箱当前测量字段未完整创建");
}
}
@@ -0,0 +1,63 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("船期价格表SQL契约")
class PriceBookSqlContractTest {
@Test
@DisplayName("基线包含整表版本、固定渠道、起运仓别名和两类价格明细")
void shouldProvidePriceBookTables() throws Exception {
String schema = new ClassPathResource("db/phase2-inbound-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
String migration = new ClassPathResource("db/phase2-fba-pricing-migration.sql")
.getContentAsString(StandardCharsets.UTF_8);
String channelTable = schema.substring(
schema.indexOf("CREATE TABLE IF NOT EXISTS fl_price_book_channel"),
schema.indexOf("ENGINE=InnoDB COMMENT='价格版本渠道及船期快照'")
);
String rateTable = schema.substring(
schema.indexOf("CREATE TABLE IF NOT EXISTS fl_carrier_fba_rate"),
schema.indexOf("ENGINE=InnoDB COMMENT='船司FBA目的仓价格版本'")
);
assertThat(schema)
.contains("CREATE TABLE IF NOT EXISTS fl_price_channel_catalog")
.contains("CREATE TABLE IF NOT EXISTS fl_price_origin_alias")
.contains("CREATE TABLE IF NOT EXISTS fl_price_warehouse_mapping")
.contains("华南仓(深圳/东莞)")
.contains("CREATE TABLE IF NOT EXISTS fl_price_book_version")
.contains("CREATE TABLE IF NOT EXISTS fl_price_book_channel")
.contains("price_book_version_id")
.contains("price_model VARCHAR(32)")
.contains("pricing_tax_mode VARCHAR(16)")
.contains("source_sheet_name VARCHAR(128)")
.contains("source_row_number INT UNSIGNED")
.contains("destination_type VARCHAR(16)")
.contains("destination_warehouse_id BIGINT UNSIGNED")
.contains("destination_name VARCHAR(128)")
.contains("parse_issues_json JSON NULL")
.contains("DEFAULT 'PENDING_CONFIRMATION'");
assertThat(schema)
.contains("'ORIGIN_CODE', 'CN-YIWU-01', 193000000000200001")
.contains("'ORIGIN_CODE', 'CN-DONGGUAN-01', 193000000000200002")
.contains("'OVERSEAS_NAME', '洛杉矶仓', 193000000000200003")
.contains("'OVERSEAS_NAME', '纽约仓', 193000000000200004");
assertThat(channelTable)
.contains("created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID'")
.contains("updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID'")
.contains("deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记'")
.contains("version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'");
assertThat(rateTable)
.contains("status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE'")
.contains("PENDING_CONFIRMATION、ACTIVE或SUPERSEDED");
assertThat(migration)
.doesNotContain("fl_price_book_version")
.doesNotContain("'pricing_tax_mode'")
.doesNotContain("'price_book_version_id'");
}
}
@@ -0,0 +1,196 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.MeasurementImageClient;
import com.baiyue.firstleg.dto.CartonMeasurementCommand;
import com.baiyue.firstleg.dto.CartonMeasurementResult;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionCallback;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.mock;
class CartonMeasurementServiceTest {
private final InboundMeasurementRepository repository = mock(InboundMeasurementRepository.class);
@SuppressWarnings("unchecked")
private final ObjectProvider<CartonLabelBindingResolver> resolverProvider = mock(ObjectProvider.class);
private final CartonLabelBindingResolver resolver = mock(CartonLabelBindingResolver.class);
private final MeasurementImageClient images = mock(MeasurementImageClient.class);
private final TransactionTemplate transactions = mock(TransactionTemplate.class);
private CartonMeasurementService service;
private final AtomicReference<InboundMeasurementItemEntity> insertedItem = new AtomicReference<>();
@BeforeEach
void setUp() {
service = new CartonMeasurementService(repository, resolverProvider, images, transactions);
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
when(resolverProvider.getIfAvailable()).thenReturn(resolver);
when(resolver.resolveByBoxCode(10001L, "RK-001#5-1"))
.thenReturn(new CartonLabelBindingResolver.Binding(70001L, 80001L, "RK-001", 1, 5, "50x40x30cm"));
when(repository.findInbound(10001L, 70001L)).thenReturn(inbound("WAIT_MEASURE"));
when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound("WAIT_MEASURE"));
when(images.upload(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any()))
.thenReturn(ApiResponse.success(new MeasurementImageClient.UploadResult(
99001L, "measure.jpg", "image/jpeg", 10L, "NOT_REQUIRED", null, null, null, null)));
when(transactions.execute(any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
TransactionCallback<CartonMeasurementResult> callback = invocation.getArgument(0);
return callback.doInTransaction(mock(org.springframework.transaction.TransactionStatus.class));
});
doAnswer(invocation -> {
insertedItem.set(invocation.getArgument(0));
return null;
}).when(repository).insertItem(any());
}
@Test
void firstScanCreatesOneRecordWithConfirmedCountOne() {
InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 0);
when(repository.findItemByBoxCode(10001L, "RK-001#5-1")).thenReturn(null);
when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(measurement);
when(repository.listItems(10001L, 30001L)).thenAnswer(invocation -> List.of(insertedItem.get()));
when(repository.updateMeasurement(measurement)).thenReturn(true);
CartonMeasurementResult result = service.upsert(10001L, 90001L, command());
assertThat(result.created()).isTrue();
assertThat(result.confirmedPackageCount()).isEqualTo(1);
assertThat(result.boxCode()).isEqualTo("RK-001#5-1");
assertThat(insertedItem.get().getMeasurementId()).isEqualTo(30001L);
assertThat(insertedItem.get().getTotalVolumeCbm()).isEqualByComparingTo("0.0733");
}
@Test
void repeatedScanReplacesDeviceValuesButPreservesManualCountAndOldImageIsDeleted() {
InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 4);
InboundMeasurementItemEntity existing = item(40001L, 30001L, 7, 88001L);
when(repository.findItemByBoxCode(10001L, "RK-001#5-1")).thenReturn(existing);
when(repository.findMeasurement(10001L, 70001L, 30001L)).thenReturn(measurement);
when(repository.updateItem(existing)).thenReturn(true);
when(repository.listItems(10001L, 30001L)).thenReturn(List.of(existing));
when(repository.updateMeasurement(measurement)).thenReturn(true);
CartonMeasurementResult result = service.upsert(10001L, 90001L, command());
assertThat(result.created()).isFalse();
assertThat(result.confirmedPackageCount()).isEqualTo(7);
assertThat(existing.getLengthCm()).isEqualByComparingTo("60.100");
assertThat(existing.getSourceImageFileId()).isEqualTo(99001L);
verify(images).delete("internal-token", 90001L, 10001L, 70001L, 88001L);
}
@Test
void submittedInboundRejectsDeviceBeforeUploadingImage() {
when(repository.findInbound(10001L, 70001L)).thenReturn(inbound("MEASURE_PENDING"));
assertThatThrownBy(() -> service.upsert(10001L, 90001L, command()))
.isInstanceOf(BizException.class).hasMessageContaining("不能新增或覆盖");
verify(images, never()).upload(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any());
}
@Test
void platformSimulationRejectsBoxCodeFromAnotherInboundBeforeUploadingImage() {
assertThatThrownBy(() -> service.upsertForInbound(10001L, 90001L, 70002L, command()))
.isInstanceOf(BizException.class)
.hasMessageContaining("箱号不属于当前入库单");
verify(images, never()).upload(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any());
}
@Test
void confirmedPackageCountUsesMeasurementOptimisticVersion() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 2);
InboundMeasurementItemEntity item = item(40001L, 30001L, 1, 99001L);
when(repository.findItem(10001L, 40001L)).thenReturn(item);
when(repository.findMeasurement(10001L, 70001L, 30001L)).thenReturn(measurement);
when(repository.updateItem(item)).thenReturn(true);
when(repository.listItems(10001L, 30001L)).thenReturn(List.of(item));
when(repository.updateMeasurement(measurement)).thenReturn(true);
CartonMeasurementResult result = service.updateConfirmedPackageCount(
10001L, 70001L, 40001L, 2, 5);
assertThat(result.confirmedPackageCount()).isEqualTo(5);
assertThat(item.getPackageCount()).isEqualTo(5);
verify(repository).updateMeasurement(measurement);
}
@Test
void confirmedPackageCountCanBeCorrectedAfterSubmissionWithoutChangingOriginalPackageCount() {
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
InboundMeasurementEntity measurement = measurement(30001L, "SUBMITTED", 2);
measurement.setTotalPackageCount(1);
measurement.setTotalVolumeCbm(new BigDecimal("0.0600"));
measurement.setVolumeWeightKg(new BigDecimal("10.020"));
InboundMeasurementItemEntity item = item(40001L, 30001L, 1, 99001L);
when(repository.findInbound(10001L, 70001L)).thenReturn(inbound);
when(repository.findItem(10001L, 40001L)).thenReturn(item);
when(repository.findMeasurement(10001L, 70001L, 30001L)).thenReturn(measurement);
when(repository.updateItem(item)).thenReturn(true);
when(repository.listItems(10001L, 30001L)).thenReturn(List.of(item));
when(repository.updateMeasurement(measurement)).thenReturn(true);
when(repository.updateInbound(inbound)).thenReturn(true);
service.updateConfirmedPackageCount(10001L, 70001L, 40001L, 2, 5);
assertThat(inbound.getTotalPackageCount()).isEqualTo(5);
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("0.3000");
assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("50.100");
verify(repository).updateInbound(inbound);
}
private CartonMeasurementCommand command() {
return new CartonMeasurementCommand("RK-001#5-1", new BigDecimal("60.100"),
new BigDecimal("40.200"), new BigDecimal("30.300"), new BigDecimal("12.500"),
"MJ-01", LocalDateTime.parse("2026-08-07T10:30:00"),
new MockMultipartFile("image", "measure.jpg", "image/jpeg", "image".getBytes()));
}
private InboundOrderEntity inbound(String status) {
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(70001L); inbound.setTenantId(10001L); inbound.setProcessStatus(status);
inbound.setTotalPackageCount(5); inbound.setVersion(0);
return inbound;
}
private InboundMeasurementEntity measurement(long id, String status, int version) {
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(id); measurement.setTenantId(10001L); measurement.setInboundId(70001L);
measurement.setStatus(status); measurement.setVersion(version);
return measurement;
}
private InboundMeasurementItemEntity item(long id, long measurementId, int packageCount, long imageId) {
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setId(id); item.setTenantId(10001L); item.setMeasurementId(measurementId);
item.setBoxCode("RK-001#5-1"); item.setPackageCount(packageCount);
item.setLengthCm(new BigDecimal("50")); item.setWidthCm(new BigDecimal("40"));
item.setHeightCm(new BigDecimal("30")); item.setUnitWeightKg(new BigDecimal("10"));
item.setSourceImageFileId(imageId); item.setItemNo("B1"); item.setBoxSpecification("50x40x30cm");
return item;
}
}
@@ -0,0 +1,109 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.InitializeResponse;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest;
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(CustomerDimensionConfigService.class)
class CustomerDimensionConfigServiceTest {
@MockBean
private CustomerDimensionConfigRepository repository;
@Resource
private CustomerDimensionConfigService service;
@Test
void initializesTwoCentimeterDefaultExactlyOnce() {
CustomerDimensionConfigEntity persisted = config(91L, 8L, "2.000", 0, "registration-1");
when(repository.find(8L)).thenReturn(null, persisted);
when(repository.insertDefault(anyLong(), eq(8L), eq(new BigDecimal("2.000")),
eq("默认每侧加宽 2cm"), eq("registration-1"), eq(100L))).thenReturn(true);
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-1");
assertThat(result.createdByThisInitialization()).isTrue();
assertThat(result.configId()).isEqualTo(91L);
}
@Test
void retryAfterLostResponseKeepsCreationOwnershipForSameKey() {
when(repository.find(8L)).thenReturn(config(91L, 8L, "2.000", 0, "registration-1"));
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-1");
assertThat(result).isEqualTo(new InitializeResponse(91L, true));
verify(repository, never()).insertDefault(anyLong(), anyLong(),
eq(new BigDecimal("2.000")), eq("默认每侧加宽 2cm"),
eq("registration-1"), anyLong());
}
@Test
void differentInitializationKeyDoesNotTakeOwnershipOrOverwrite() {
when(repository.find(8L)).thenReturn(config(91L, 8L, "2.000", 0, "registration-1"));
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-2");
assertThat(result).isEqualTo(new InitializeResponse(91L, false));
verify(repository, never()).insertDefault(anyLong(), anyLong(),
eq(new BigDecimal("2.000")), eq("默认每侧加宽 2cm"),
eq("registration-2"), anyLong());
}
@Test
void rejectsStaleUpdateVersion() {
when(repository.find(8L)).thenReturn(config(91L, 8L, "2.000", 3, "registration-1"));
assertThatThrownBy(() -> service.update(8L, 100L,
new UpdateRequest(new BigDecimal("2.500"), "新配置", 2)))
.isInstanceOf(BizException.class)
.hasMessageContaining("刷新后重试");
verify(repository, never()).update(org.mockito.ArgumentMatchers.any());
}
@Test
void compensationUsesTenantKeyDefaultValueAndUnmodifiedVersion() {
when(repository.find(8L)).thenReturn(config(91L, 8L, "2.000", 0, "registration-1"));
service.compensateDefault(8L, "registration-1");
verify(repository).deleteUnchangedDefault(
8L, 91L, "registration-1", new BigDecimal("2.000"));
}
@Test
void editedConfigIsNotCompensatedByDelayedRollback() {
when(repository.find(8L)).thenReturn(config(91L, 8L, "2.500", 1, "registration-1"));
service.compensateDefault(8L, "registration-1");
verify(repository).deleteUnchangedDefault(
8L, 91L, "registration-1", new BigDecimal("2.000"));
}
private static CustomerDimensionConfigEntity config(long id, long tenantId,
String edgeAddition, int version,
String initializationKey) {
CustomerDimensionConfigEntity value = new CustomerDimensionConfigEntity();
value.setId(id);
value.setTenantId(tenantId);
value.setEdgeAdditionCm(new BigDecimal(edgeAddition));
value.setVersion(version);
value.setInitializationKey(initializationKey);
return value;
}
}
@@ -2,14 +2,16 @@ package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.domain.PriceModel;
import com.baiyue.firstleg.domain.PricingTaxMode;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundFeeItemEntity;
import com.baiyue.firstleg.entity.InboundPricingEntity;
import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -24,7 +26,6 @@ import java.time.Clock;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
@@ -33,7 +34,6 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -48,6 +48,7 @@ class FbaPricingServiceTest {
private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 3, 10, 0);
@MockBean private FbaPricingRepository repository;
@MockBean private InboundFeeDetailService feeDetails;
@MockBean private ActivePriceBookEstimator priceBookEstimator;
@Resource private FbaPricingService service;
@Test
@@ -63,15 +64,15 @@ class FbaPricingServiceTest {
}
@Test
@DisplayName("包税KG按配置系数取体积重较大值并按规则向上舍入")
void calculatesTaxIncludedKgWithConfiguredFactorAndPrecision() {
@DisplayName("包税KG按生效价格表计费且不依赖客户规则或承运商")
void calculatesTaxIncludedKgWithoutCustomerRuleOrCarrier() {
readyRepository();
InboundPricingEntity inbound = inbound("100.000", "1.0001");
CustomerChargeRuleEntity rule = rule("KG", "167.000000", 3, "CEILING");
CarrierFbaRateEntity rate = rate("KG", "2.345678", "10.00");
inbound.setCarrierId(null);
inbound.setCarrierName(null);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.matchRules(inbound, NOW)).thenReturn(List.of(rule));
when(repository.matchRates(inbound, "KG", NOW)).thenReturn(List.of(rate));
when(priceBookEstimator.estimate(any())).thenReturn(
estimate("KG", "167.017", "2.345678", "391.77", "10.00"));
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-1");
@@ -79,19 +80,26 @@ class FbaPricingServiceTest {
assertThat(response.chargeableQuantity()).isEqualByComparingTo("167.017");
assertThat(response.subtotalAmount()).isEqualByComparingTo("391.77");
assertThat(response.totalAmount()).isEqualByComparingTo("401.77");
assertThat(response.priceBookVersionId()).isEqualTo(701L);
assertThat(response.pricingTaxMode()).isEqualTo("TAX_INCLUDED");
assertThat(response.nextProcessStatus()).isEqualTo("FEE_CONFIRMING");
ArgumentCaptor<InboundFeeEntity> fee = ArgumentCaptor.forClass(InboundFeeEntity.class);
verify(repository).insertFee(fee.capture());
assertThat(fee.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION");
assertThat(fee.getValue().getPricingTaxMode()).isEqualTo("TAX_INCLUDED");
assertThat(fee.getValue().getChargeRuleId()).isNull();
assertThat(fee.getValue().getCarrierId()).isNull();
}
@Test
@DisplayName("不包税CBM按配置系数折算且精度边界向上取整")
void calculatesTaxExcludedCbmAtRoundingBoundary() {
@DisplayName("不包税CBM使用生效价格表返回的计费量")
void calculatesTaxExcludedCbmFromActivePriceBook() {
readyRepository();
InboundPricingEntity inbound = inbound("363.001", "0.5000");
CustomerChargeRuleEntity rule = rule("CBM", "363.000000", 4, "CEILING");
CarrierFbaRateEntity rate = rate("CBM", "100.000000", "0.00");
inbound.setPricingTaxMode("TAX_EXCLUDED");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.matchRules(inbound, NOW)).thenReturn(List.of(rule));
when(repository.matchRates(inbound, "CBM", NOW)).thenReturn(List.of(rate));
when(priceBookEstimator.estimate(any())).thenReturn(
estimate("CBM", "1.0001", "100.000000", "100.01", "0.00"));
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-2");
@@ -109,8 +117,8 @@ class FbaPricingServiceTest {
CustomerChargeRuleEntity rule = rule("KG", "167.000000", 3, "CEILING");
CarrierFbaRateEntity rate = rate("KG", "2.000000", "0.00");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.matchRules(inbound, NOW)).thenReturn(List.of(rule));
when(repository.matchRates(inbound, "KG", NOW)).thenReturn(List.of(rate));
when(priceBookEstimator.estimate(any())).thenReturn(
estimate("KG", "167.000", "2.000000", "334.00", "0.00"));
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "wait-split");
@@ -156,26 +164,21 @@ class FbaPricingServiceTest {
assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-3"))
.isInstanceOf(BizException.class)
.hasMessageContaining("审核通过");
verify(repository, never()).matchRules(any(), any());
verify(priceBookEstimator, never()).estimate(any());
}
@Test
@DisplayName("无客户规则和多客户规则都明确拒绝")
void rejectsMissingAndAmbiguousRules() {
@DisplayName("当前生效价格表无匹配价格时明确拒绝")
void rejectsMissingActivePriceBookRate() {
readyRepository();
InboundPricingEntity inbound = inbound("10", "1");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.matchRules(inbound, NOW)).thenReturn(List.of());
assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "none"))
.isInstanceOf(BizException.class).hasMessageContaining("未匹配到有效客户计费规则");
when(priceBookEstimator.estimate(any())).thenThrow(
new BizException(ResultCode.NOT_FOUND, "当前条件没有匹配价格"));
org.mockito.Mockito.reset(repository);
readyRepository();
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.matchRules(inbound, NOW)).thenReturn(List.of(
rule("KG", "167", 3, "CEILING"), rule("KG", "168", 3, "CEILING")));
assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "many"))
.isInstanceOf(BizException.class).hasMessageContaining("多条有效客户计费规则");
assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "none"))
.isInstanceOf(BizException.class).hasMessageContaining("没有匹配价格");
verify(repository, never()).insertFee(any());
}
@Test
@@ -299,29 +302,25 @@ class FbaPricingServiceTest {
value.setProcessStatus("WAIT_PRICING"); value.setMeasureReviewStatus("APPROVED");
value.setFeeStatus("UNPRICED"); value.setCustomerId(11L); value.setCustomerName("客户A");
value.setWarehouseId(21L); value.setWarehouseName("义乌仓"); value.setBusinessType("FBA_SEA");
value.setChannelCode("SEA-TAX"); value.setCarrierId(31L); value.setCarrierName("船司A");
value.setTaxMode("TAX_INCLUDED"); value.setDestinationType("FBA"); value.setDestinationCode("ONT8");
value.setChannelCode("MATSON_TRUCK"); value.setCarrierId(31L); value.setCarrierName("船司A");
value.setTaxMode("BUY_DECLARATION"); value.setPricingTaxMode("TAX_INCLUDED");
value.setDestinationType("FBA"); value.setDestinationCode("ONT8");
value.setActualGrossWeightKg(new BigDecimal(weight)); value.setActualVolumeCbm(new BigDecimal(volume));
return value;
}
private CustomerChargeRuleEntity rule(String mode, String factor, int scale, String rounding) {
CustomerChargeRuleEntity value = new CustomerChargeRuleEntity();
value.setId(101L); value.setRuleCode("CR-001"); value.setRuleVersion(1);
value.setChargeMode(mode); value.setConversionFactor(new BigDecimal(factor));
value.setRoundingScale(scale); value.setRoundingMode(rounding); value.setCurrency("USD");
return value;
}
private CarrierFbaRateEntity rate(String unit, String price, String additional) {
CarrierFbaRateEntity value = new CarrierFbaRateEntity();
value.setId(201L); value.setRateCode("RT-001"); value.setRateVersion(1);
value.setOriginWarehouseId(21L); value.setOriginWarehouseName("义乌仓");
value.setChannelCode("SEA-TAX"); value.setCarrierId(31L); value.setCarrierName("船司A");
value.setDestinationCode("ONT8"); value.setChargeUnit(unit); value.setUnitPrice(new BigDecimal(price));
value.setCurrency("USD"); value.setAdditionalFee(new BigDecimal(additional));
value.setEffectiveFrom(NOW.minusDays(1)); value.setEffectiveTo(NOW.plusDays(1));
return value;
private ActivePriceBookEstimator.Estimate estimate(String unit, String quantity, String price,
String amount, String additional) {
RateRow rate = new RateRow();
rate.setId(201L); rate.setPriceBookVersionId(701L); rate.setRateCode("PB-7-1"); rate.setRateVersion(1);
rate.setOriginWarehouseId(21L); rate.setOriginWarehouseName("义乌仓"); rate.setChannelCode("MATSON_TRUCK");
rate.setPriceModel(PriceModel.FBA_WAREHOUSE_RATE.name()); rate.setDestinationType("FBA");
rate.setDestinationCode("ONT8"); rate.setPricingTaxMode("TAX_INCLUDED"); rate.setChargeUnit(unit);
rate.setUnitPrice(new BigDecimal(price)); rate.setCurrency("USD");
rate.setAdditionalFee(new BigDecimal(additional)); rate.setEffectiveFrom(NOW.minusDays(1));
return new ActivePriceBookEstimator.Estimate(701L, "MATSON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE,
PricingTaxMode.TAX_INCLUDED, rate, new BigDecimal(quantity), unit,
new BigDecimal(price), new BigDecimal(amount), "USD");
}
private InboundPricingIdempotencyEntity completed(String operation, long feeId) {
@@ -0,0 +1,89 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.PrintPdfClient;
import com.baiyue.firstleg.dto.InboundCartonLabels;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
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.verify;
import static org.mockito.Mockito.when;
class InboundCartonLabelPrintServiceTest {
private final InboundCartonLabelService labels = mock(InboundCartonLabelService.class);
private final PrintPdfClient print = mock(PrintPdfClient.class);
private InboundCartonLabelPrintService service;
@BeforeEach
void setUp() {
service = new InboundCartonLabelPrintService(labels, print, new ObjectMapper());
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
}
@Test
void forwardsStableLabelsAndOnlySafePdfHeaders() {
when(labels.list(10001L, 70001L, List.of(1))).thenReturn(batch());
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_PDF);
headers.setContentDispositionFormData("attachment", "labels.pdf");
headers.set("X-Print-Page-Count", "1");
headers.set("X-Internal-Service-Token", "must-not-leak");
when(print.generateInboundMarks(org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()))
.thenReturn(new ResponseEntity<>(new byte[] {1, 2, 3}, headers,
org.springframework.http.HttpStatus.OK));
ResponseEntity<byte[]> response = service.generate(10001L, 70001L, List.of(1), "request-1");
ArgumentCaptor<PrintPdfClient.InboundMarkPdfRequest> request =
ArgumentCaptor.forClass(PrintPdfClient.InboundMarkPdfRequest.class);
verify(print).generateInboundMarks(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.eq("request-1"), request.capture());
assertThat(request.getValue().downloadFileName()).isEqualTo("PCHK2608040174-19件-箱唛.pdf");
assertThat(request.getValue().labels()).singleElement().satisfies(label -> {
assertThat(label.qrContent()).isEqualTo("PCHK2608040174#19-1");
assertThat(label.cartonText()).isEqualTo("CTNS:19-1");
});
assertThat(response.getBody()).containsExactly(1, 2, 3);
assertThat(response.getHeaders().getFirst("X-Print-Page-Count")).isEqualTo("1");
assertThat(response.getHeaders()).doesNotContainKey("X-Internal-Service-Token");
}
@Test
void convertsPrintJsonBusinessFailureInsteadOfReturningItAsPdf() throws Exception {
when(labels.list(10001L, 70001L, null)).thenReturn(batch());
byte[] error = "{\"code\":\"A0422\",\"message\":\"标签字段超出模板限制\","
.concat("\"data\":null,\"requestId\":\"req-2\",\"traceId\":\"trace-2\"}")
.getBytes(java.nio.charset.StandardCharsets.UTF_8);
when(print.generateInboundMarks(org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.any()))
.thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(error));
assertThatThrownBy(() -> service.generate(10001L, 70001L, null, null))
.isInstanceOf(BizException.class)
.hasMessageContaining("标签字段超出模板限制")
.extracting(value -> ((BizException) value).getResultCode())
.isEqualTo(ResultCode.VALIDATION_FAILED);
}
private InboundCartonLabels.Batch batch() {
InboundCartonLabels.Label label = new InboundCartonLabels.Label(
1L, 2L, 1, "音箱", 19, "PCHK2608040174#19-1", 19, 1,
"50x40x30cm", new BigDecimal("50"), new BigDecimal("40"), new BigDecimal("30"),
"PCHK2608040174", "MCC1", "CTNS:19-1", "洛杉矶普船海卡");
return new InboundCartonLabels.Batch(70001L, 80001L, "PCHK2608040174", 19, List.of(label));
}
}
@@ -0,0 +1,189 @@
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.entity.InboundCartonLabelEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.InboundCartonLabelRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.ArrayList;
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.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InboundCartonLabelServiceTest {
@Mock private InboundCartonLabelRepository repository;
@Mock private OrderCargoClient orderCargoClient;
private InboundCartonLabelService service;
@BeforeEach
void setUp() {
service = new InboundCartonLabelService();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "orderCargoClient", orderCargoClient);
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
}
@Test
void firstAllocationUsesCargoOrderAndFreezesEveryPhysicalCarton() {
InboundOrderEntity inbound = inbound();
when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound);
when(repository.findByInbound(10001L, 70001L)).thenReturn(new ArrayList<>());
var second = cargo(80002L, 2, 2, "50", "30", "20");
var first = cargo(80001L, 1, 3, "60", "40", "30");
when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L))
.thenReturn(ApiResponse.success(List.of(second, first)));
when(repository.initializeOriginalPackageCount(10001L, 70001L, 5)).thenReturn(true);
List<InboundCartonLabelEntity> inserted = new ArrayList<>();
org.mockito.Mockito.doAnswer(invocation -> {
inserted.add(invocation.getArgument(0));
return null;
}).when(repository).insert(any());
var batch = service.getOrCreate(10001L, 70001L);
assertThat(batch.totalCount()).isEqualTo(5);
assertThat(batch.labels()).extracting(label -> label.boxCode())
.containsExactly("PCHK2608040174#5-1", "PCHK2608040174#5-2", "PCHK2608040174#5-3",
"PCHK2608040174#5-4", "PCHK2608040174#5-5");
assertThat(batch.labels()).extracting(label -> label.inboundDetailId())
.containsExactly(80001L, 80001L, 80001L, 80002L, 80002L);
assertThat(batch.labels().get(0).boxSpecification()).isEqualTo("60x40x30cm");
assertThat(batch.labels().get(0).cartonText()).isEqualTo("CTNS:5-1");
assertThat(inbound.getTotalPackageCount()).isEqualTo(5);
assertThat(inserted).hasSize(5);
verify(repository).initializeOriginalPackageCount(10001L, 70001L, 5);
}
@Test
void repeatedAndPartialPrintsReadFrozenMappingWithoutReallocation() {
InboundOrderEntity inbound = inbound();
inbound.setTotalPackageCount(5);
List<InboundCartonLabelEntity> existing = labels(inbound, 5);
when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound);
when(repository.findByInbound(10001L, 70001L)).thenReturn(existing);
var batch = service.list(10001L, 70001L, List.of(5, 2, 2));
assertThat(batch.totalCount()).isEqualTo(5);
assertThat(batch.labels()).extracting(label -> label.cartonIndex()).containsExactly(2, 5);
verify(orderCargoClient, never()).listForCartonLabels(any(String.class),
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong());
verify(repository, never()).insert(any());
}
@Test
void refusesFrozenMappingWhenOriginalPackageCountDoesNotMatch() {
InboundOrderEntity inbound = inbound();
inbound.setTotalPackageCount(4);
when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound);
when(repository.findByInbound(10001L, 70001L)).thenReturn(labels(inbound, 5));
assertThatThrownBy(() -> service.getOrCreate(10001L, 70001L))
.isInstanceOf(BizException.class)
.hasMessageContaining("订单总箱数与已冻结箱号数量不一致");
verify(repository, never()).initializeOriginalPackageCount(
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(),
org.mockito.ArgumentMatchers.anyInt());
}
@Test
void resolvesBoxCodeInsideCustomerBoundary() {
InboundCartonLabelEntity label = labels(inbound(), 1).get(0);
when(repository.findByBoxCode(10001L, "PCHK2608040174#1-1")).thenReturn(label);
var resolved = service.resolveByBoxCode(10001L, " PCHK2608040174#1-1 ");
assertThat(resolved.inboundId()).isEqualTo(70001L);
assertThat(resolved.inboundDetailId()).isEqualTo(80001L);
assertThat(resolved.inboundCode()).isEqualTo("PCHK2608040174");
assertThat(resolved.cartonIndex()).isEqualTo(1);
assertThat(resolved.totalCartons()).isEqualTo(1);
assertThat(resolved.boxSpecification()).isEqualTo("60x40x30cm");
verify(repository).findByBoxCode(10001L, "PCHK2608040174#1-1");
}
@Test
void exposesDetailedResolutionForFirstlegConsumers() {
InboundCartonLabelEntity label = labels(inbound(), 1).get(0);
when(repository.findByBoxCode(10001L, "PCHK2608040174#1-1")).thenReturn(label);
var resolved = service.resolveDetailsByBoxCode(10001L, "PCHK2608040174#1-1");
assertThat(resolved.orderId()).isEqualTo(60001L);
assertThat(resolved.boxCode()).isEqualTo("PCHK2608040174#1-1");
assertThat(resolved.cargoItemNo()).isEqualTo(1);
}
@Test
void refusesFirstAllocationAfterMeasurementSubmission() {
InboundOrderEntity inbound = inbound();
inbound.setProcessStatus("MEASURE_PENDING");
when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound);
when(repository.findByInbound(10001L, 70001L)).thenReturn(List.of());
assertThatThrownBy(() -> service.getOrCreate(10001L, 70001L))
.isInstanceOf(BizException.class)
.hasMessageContaining("不允许首次生成箱号");
verify(orderCargoClient, never()).listForCartonLabels(any(String.class),
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong());
}
private InboundOrderEntity inbound() {
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(70001L);
inbound.setTenantId(10001L);
inbound.setOrderId(60001L);
inbound.setInboundCode("PCHK2608040174");
inbound.setProcessStatus("WAIT_MEASURE");
inbound.setWarehouseName("MCC1");
inbound.setChannelName("洛杉矶普船海卡");
return inbound;
}
private OrderCargoClient.CargoLabelBinding cargo(long id, int itemNo, int count,
String length, String width, String height) {
return new OrderCargoClient.CargoLabelBinding(id, 60001L, itemNo, "货物" + itemNo, count,
new BigDecimal(length), new BigDecimal(width), new BigDecimal(height));
}
private List<InboundCartonLabelEntity> labels(InboundOrderEntity inbound, int total) {
List<InboundCartonLabelEntity> values = new ArrayList<>();
for (int index = 1; index <= total; index++) {
InboundCartonLabelEntity label = new InboundCartonLabelEntity();
label.setId((long) index);
label.setTenantId(inbound.getTenantId());
label.setInboundId(inbound.getId());
label.setOrderId(inbound.getOrderId());
label.setInboundDetailId(80001L);
label.setCargoItemNo(1);
label.setCargoName("货物1");
label.setDetailPackageCount(total);
label.setInboundCode(inbound.getInboundCode());
label.setBoxCode(inbound.getInboundCode() + "#" + total + "-" + index);
label.setTotalCartonCount(total);
label.setCartonIndex(index);
label.setBoxSpecification("60x40x30cm");
label.setLengthCm(new BigDecimal("60"));
label.setWidthCm(new BigDecimal("40"));
label.setHeightCm(new BigDecimal("30"));
values.add(label);
}
return values;
}
}
@@ -16,6 +16,8 @@ import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@@ -25,11 +27,13 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.tuple;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -86,6 +90,24 @@ class InboundMeasurementServiceTest {
verify(repository, never()).updateInbound(any());
}
@Test
void creatingWeightResultNormalizesProviderConfidenceAtBusinessBoundary() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
InboundMeasurementResponses.WeightResult result = service.createWeightResult(
1L, 10L, 9L,
new InboundMeasurementRequests.CreateWeightResultRequest(
120L, "FILE_WEIGHT_OCR", "285.5", new BigDecimal("285.500"), "KG",
new BigDecimal("0.5097191334")));
ArgumentCaptor<InboundWeightResultEntity> saved =
ArgumentCaptor.forClass(InboundWeightResultEntity.class);
verify(repository).insertWeight(saved.capture());
assertThat(saved.getValue().getConfidence()).isEqualByComparingTo("0.50972");
assertThat(result.confidence()).isEqualByComparingTo("0.50972");
}
@Test
void confirmingLbWeightConvertsToKgAndRecalculatesOnlyConfirmedImages() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
@@ -100,7 +122,7 @@ class InboundMeasurementServiceTest {
InboundMeasurementResponses.WeightResult response = service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(0));
new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null));
assertThat(response.finalWeightKg()).isEqualByComparingTo("4.536");
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("6.536");
@@ -108,6 +130,238 @@ class InboundMeasurementServiceTest {
verify(repository).insertRevision(any(InboundWeightRevisionEntity.class));
}
@ParameterizedTest
@CsvSource({
"kg,2.000",
"KGS,2.000",
"lb,0.907",
"LBS,0.907",
"' lbs ',0.907"
})
void confirmingAcceptsSupportedUnitAliases(String unit, String expectedKg) {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
InboundWeightResultEntity weight = weight(20L, "1.000", "KG", "PENDING_CONFIRM", 0);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
when(repository.updateWeight(weight)).thenReturn(true);
when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight));
when(repository.updateInbound(inbound)).thenReturn(true);
InboundMeasurementResponses.WeightResult response = service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(
0, new BigDecimal("2.000"), unit, "单位边界测试"));
assertThat(response.adjustedWeightUnit()).isEqualTo(
unit.trim().toUpperCase().startsWith("LB") ? "LB" : "KG");
assertThat(response.finalWeightKg()).isEqualByComparingTo(expectedKg);
assertThat(response.version()).isEqualTo(2);
}
@Test
void confirmingAcceptsSmallestRepresentableKgWeight() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
InboundWeightResultEntity weight = weight(20L, "0.001", "KG", "PENDING_CONFIRM", 0);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
when(repository.updateWeight(weight)).thenReturn(true);
when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight));
when(repository.updateInbound(inbound)).thenReturn(true);
InboundMeasurementResponses.WeightResult response = service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null));
assertThat(response.finalWeightKg()).isEqualByComparingTo("0.001");
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("0.001");
}
@Test
void confirmingRejectsLbWeightThatRoundsToZeroKgBeforePersisting() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
InboundWeightResultEntity weight = weight(20L, "1.000", "KG", "PENDING_CONFIRM", 0);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
assertThatThrownBy(() -> service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(
0, new BigDecimal("0.001"), "LB", "极小重量边界测试")))
.isInstanceOf(BizException.class)
.hasMessageContaining("换算后重量不能小于 0.001 KG");
verify(repository, never()).updateWeight(any());
verify(repository, never()).insertRevision(any());
verify(repository, never()).updateInbound(any());
}
@Test
void confirmingRejectsOcrLbWeightThatRoundsToZeroKg() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
InboundWeightResultEntity weight = weight(20L, "0.001", "LB", "PENDING_CONFIRM", 0);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
assertThatThrownBy(() -> service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("换算后重量不能小于 0.001 KG");
verify(repository, never()).updateWeight(any());
verify(repository, never()).insertRevision(any());
verify(repository, never()).updateInbound(any());
}
@Test
void confirmingRejectsUnsupportedUnitBeforePersisting() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "PENDING_CONFIRM", 0);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
assertThatThrownBy(() -> service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(
0, new BigDecimal("286.125"), "G", "错误单位测试")))
.isInstanceOf(BizException.class)
.hasMessageContaining("重量单位仅支持 KG 或 LB");
verify(repository, never()).updateWeight(any());
verify(repository, never()).insertRevision(any());
}
@Test
void confirmingAdjustedWeightWritesCorrectionAndConfirmationInOneTransaction() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "PENDING_CONFIRM", 0);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
when(repository.updateWeight(weight)).thenReturn(true);
when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight));
when(repository.updateInbound(inbound)).thenReturn(true);
InboundMeasurementResponses.WeightResult response = service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(
0, new BigDecimal("286.125"), "kg", "OCR 原值 285.500 KG,人工确认 286.125 KG"));
assertThat(response.adjustedWeightValue()).isEqualByComparingTo("286.125");
assertThat(response.finalWeightKg()).isEqualByComparingTo("286.125");
assertThat(response.recognitionStatus()).isEqualTo("CONFIRMED");
assertThat(response.version()).isEqualTo(2);
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("286.125");
verify(repository, times(2)).updateWeight(weight);
ArgumentCaptor<InboundWeightRevisionEntity> revisions =
ArgumentCaptor.forClass(InboundWeightRevisionEntity.class);
verify(repository, times(2)).insertRevision(revisions.capture());
assertThat(revisions.getAllValues()).extracting(
InboundWeightRevisionEntity::getRevisionNo,
InboundWeightRevisionEntity::getAction)
.containsExactly(tuple(1, "CORRECT"), tuple(2, "CONFIRM"));
assertThat(revisions.getAllValues().get(0).getReason())
.isEqualTo("OCR 原值 285.500 KG,人工确认 286.125 KG");
}
@Test
void confirmingRejectsPartialAdjustmentBeforePersisting() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "PENDING_CONFIRM", 0);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
assertThatThrownBy(() -> service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(
0, new BigDecimal("286.125"), null, null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("有效重量、单位和修改原因");
verify(repository, never()).updateWeight(any());
verify(repository, never()).insertRevision(any());
}
@Test
void confirmingRejectsEveryPartialAdjustmentCombinationBeforePersisting() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "PENDING_CONFIRM", 0);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
List<InboundMeasurementRequests.ConfirmWeightResultRequest> partialRequests = List.of(
new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, "KG", null),
new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, "人工修正"),
new InboundMeasurementRequests.ConfirmWeightResultRequest(
0, new BigDecimal("286.125"), " ", " ")
);
partialRequests.forEach(request -> assertThatThrownBy(() -> service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员", request))
.isInstanceOf(BizException.class)
.hasMessageContaining("有效重量、单位和修改原因"));
verify(repository, never()).updateWeight(any());
verify(repository, never()).insertRevision(any());
verify(repository, never()).updateInbound(any());
}
@Test
void confirmingAnAlreadyConfirmedWeightWithCurrentVersionIsIdempotent() {
InboundOrderEntity inbound = inbound("MEASURING");
InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "CONFIRMED", 2);
weight.setFinalWeightKg(new BigDecimal("285.500"));
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
InboundMeasurementResponses.WeightResult response = service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(2, null, null, null));
assertThat(response.recognitionStatus()).isEqualTo("CONFIRMED");
assertThat(response.version()).isEqualTo(2);
verify(repository, never()).updateWeight(any());
verify(repository, never()).insertRevision(any());
verify(repository, never()).updateInbound(any());
}
@Test
void confirmingWithStaleVersionDoesNotTreatRequestAsIdempotent() {
InboundOrderEntity inbound = inbound("MEASURING");
InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "CONFIRMED", 2);
weight.setFinalWeightKg(new BigDecimal("285.500"));
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
assertThatThrownBy(() -> service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(1, null, null, null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("版本已变化");
verify(repository, never()).updateWeight(any());
verify(repository, never()).insertRevision(any());
}
@Test
void confirmingAdjustedWeightStopsBeforeRecalculationWhenConfirmUpdateConflicts() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "PENDING_CONFIRM", 0);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
when(repository.updateWeight(weight)).thenReturn(true, false);
assertThatThrownBy(() -> service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(
0, new BigDecimal("286.125"), "KG", "并发冲突测试")))
.isInstanceOf(BizException.class)
.hasMessageContaining("版本已变化");
verify(repository, times(2)).updateWeight(weight);
verify(repository, times(1)).insertRevision(any(InboundWeightRevisionEntity.class));
verify(repository, never()).listWeights(anyLong(), anyLong());
verify(repository, never()).updateInbound(any());
}
@Test
void submitAdvancesOnlyToMeasurePendingAfterAllWeightsConfirmed() {
InboundOrderEntity inbound = inbound("MEASURING");
@@ -121,6 +375,7 @@ class InboundMeasurementServiceTest {
measurement.setTotalVolumeCbm(new BigDecimal("1.2345"));
measurement.setVolumeWeightKg(new BigDecimal("206.162"));
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setPackageCount(3);
item.setSourceImageFileId(500L);
InboundWeightResultEntity weight = weight(20L, "20.000", "KG", "CONFIRMED", 1);
weight.setFinalWeightKg(new BigDecimal("20.000"));
@@ -150,6 +405,7 @@ class InboundMeasurementServiceTest {
measurement.setStatus("DRAFT");
measurement.setVersion(0);
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setPackageCount(3);
item.setSourceImageFileId(500L);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findMeasurement(1L, 10L, 30L)).thenReturn(measurement);
@@ -227,6 +483,7 @@ class InboundMeasurementServiceTest {
inbound.setId(10L);
inbound.setTenantId(1L);
inbound.setProcessStatus(status);
inbound.setTotalPackageCount(3);
inbound.setVersion(0);
return inbound;
}
@@ -2,6 +2,8 @@ package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baiyue.firstleg.dto.OrderApprovedInboundCommand;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
@@ -14,7 +16,10 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -46,14 +51,52 @@ class InboundOrderCreationServiceTest {
verify(histories, times(1)).insert(any(InboundStatusHistoryEntity.class));
assertThat(inboundCaptor.getValue().getInboundCode()).isEqualTo("RK-CUSTOM-77");
assertThat(inboundCaptor.getValue().getCarrierId()).isNull();
assertThat(inboundCaptor.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION");
assertThat(inboundCaptor.getValue().getPricingTaxMode()).isEqualTo("TAX_EXCLUDED");
assertThat(createdId).isEqualTo(inboundCaptor.getValue().getId());
assertThat(replayedId).isEqualTo(900L);
}
@Test
void shouldRejectReplayWithDifferentInboundCode() {
InboundOrderEntity existing = inbound(900L, "RK-OLD", "UNPRICED", null, null);
when(orders.selectOne(any(Wrapper.class))).thenReturn(existing);
assertThatThrownBy(() -> service.createFromApprovedOrder(approvedCommand("RK-NEW")))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
verify(orders, never()).insert(any(InboundOrderEntity.class));
}
@Test
void shouldConvergeOnConcurrentDuplicateInsertWinner() {
InboundOrderEntity winner = inbound(901L, "RK-CUSTOM-77", "UNPRICED", null, null);
when(orders.selectOne(any(Wrapper.class))).thenReturn(null, winner);
doThrow(new org.springframework.dao.DuplicateKeyException("concurrent insert"))
.when(orders).insert(any(InboundOrderEntity.class));
assertThat(service.createFromApprovedOrder(approvedCommand("RK-CUSTOM-77"))).isEqualTo(901L);
verify(histories, never()).insert(any(InboundStatusHistoryEntity.class));
}
private static InboundOrderEntity inbound(long id, String code, String feeStatus,
Long carrierId, String carrierName) {
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(id);
inbound.setTenantId(10001L);
inbound.setOrderId(77L);
inbound.setInboundCode(code);
inbound.setFeeStatus(feeStatus);
inbound.setCarrierId(carrierId);
inbound.setCarrierName(carrierName);
return inbound;
}
private static OrderApprovedInboundCommand approvedCommand(String inboundCode) {
return new OrderApprovedInboundCommand(
10001L, 77L, "FL-77", inboundCode, 7L, "测试客户", 9L, "义乌仓",
null, null, null, "FBA", "AIR", "空运", null, null,
null, null, null, null, null, null, null, null, null, null);
"BUY_DECLARATION", "TAX_EXCLUDED", null, null, null, null, null, null, null, null, null);
}
}
@@ -6,6 +6,7 @@ import com.baiyue.firstleg.config.InboundProperties;
import com.baiyue.firstleg.dto.ExceptionReleaseRequest;
import com.baiyue.firstleg.dto.MeasurementReviewRequest;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
@@ -79,6 +80,23 @@ class MeasurementReviewServiceTest {
assertThat(history.getValue().getToStatus()).isEqualTo("INBOUNDED");
}
@Test
@DisplayName("确认箱数合计不等于订单总箱数时不能审核通过")
void shouldRejectApprovalWhenConfirmedPackageCountDoesNotMatchOrder() {
Fixture fixture = fixture(false);
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setPackageCount(4);
when(fixture.repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item));
assertThatThrownBy(() -> fixture.service.approve(10001L, 9L, "审核员", 10L, "approve-mismatch",
new MeasurementReviewRequest(20L, 3, null)))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.VALIDATION_FAILED);
verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean());
}
@Test
@DisplayName("驳回原因必填且状态推进为测量驳回")
void shouldRequireRejectReason() {
@@ -175,10 +193,15 @@ class MeasurementReviewServiceTest {
InboundOrderEntity order = new InboundOrderEntity();
order.setProcessStatus("MEASURE_PENDING");
order.setMeasureReviewStatus("PENDING");
order.setTotalPackageCount(5);
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(20L);
measurement.setStatus("SUBMITTED");
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setPackageCount(5);
when(repository.findOwned(10001L, 10L)).thenReturn(order);
when(repository.findMeasurement(10001L, 10L, 20L)).thenReturn(measurement);
when(repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item));
return new Fixture(service, repository, idempotency, order, measurement);
}
@@ -0,0 +1,273 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.PriceBookWarehouseClient;
import com.baiyue.firstleg.domain.PriceModel;
import com.baiyue.firstleg.domain.PriceRegion;
import com.baiyue.firstleg.domain.PricingDestinationType;
import com.baiyue.firstleg.domain.PricingTaxMode;
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest;
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
import com.baiyue.firstleg.pricing.FbaWarehouseRateEstimator;
import com.baiyue.firstleg.pricing.PriceBookWarehouseRegionResolver;
import com.baiyue.firstleg.pricing.PriceRegionResolver;
import com.baiyue.firstleg.pricing.RegionWeightTierEstimator;
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow;
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
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.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig({MerchantPriceBookService.class, ActivePriceBookEstimator.class,
FbaWarehouseRateEstimator.class, RegionWeightTierEstimator.class, PriceRegionResolver.class,
PriceBookWarehouseRegionResolver.class})
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
class MerchantPriceBookServiceTest {
@MockBean private PriceBookQueryRepository repository;
@MockBean private PriceBookWarehouseClient warehouses;
@Resource private MerchantPriceBookService service;
@Test
void shouldReturnEmptyChannelsWithoutActiveVersion() {
when(repository.activeVersionId()).thenReturn(null);
assertThat(service.channels(10001L, 10L)).isEmpty();
verify(repository, never()).channels(1L, 10L);
verify(repository, never()).channelTaxModes(1L, 10L);
}
@Test
void shouldExposeOnlyChannelsFromLatestActiveVersionAfterSwitch() {
when(repository.activeVersionId()).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)));
assertThat(service.channels(10001L, 10L)).extracting(value -> value.channelCode())
.containsExactly("OLD");
assertThat(service.channels(10001L, 10L)).extracting(value -> value.channelCode())
.containsExactly("NEW");
}
@Test
void shouldExposeDistinctPricingTaxModesForActiveOriginChannels() {
when(repository.activeVersionId()).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)));
when(repository.channelTaxModes(7L, 10L)).thenReturn(List.of(
taxMode("MATSON_CLX_TRUCK", PricingTaxMode.TAX_EXCLUDED),
taxMode("MATSON_CLX_TRUCK", PricingTaxMode.TAX_INCLUDED),
taxMode("MATSON_CLX_TRUCK", PricingTaxMode.TAX_INCLUDED),
taxMode("OA_PARCEL", PricingTaxMode.TAX_INCLUDED)));
var channels = service.channels(10001L, 10L);
assertThat(channels.get(0).pricingTaxModes())
.containsExactly(PricingTaxMode.TAX_INCLUDED, PricingTaxMode.TAX_EXCLUDED);
assertThat(channels.get(1).pricingTaxModes()).containsExactly(PricingTaxMode.TAX_INCLUDED);
}
@Test
void shouldKeepActiveChannelWhenItsTaxModeProjectionIsEmpty() {
when(repository.activeVersionId()).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());
assertThat(service.channels(10001L, 10L)).singleElement()
.extracting(value -> value.pricingTaxModes())
.isEqualTo(List.of());
}
@Test
void shouldRejectForgedOrRemovedChannel() {
when(repository.activeVersionId()).thenReturn(2L);
when(repository.channels(2L, 10L)).thenReturn(List.of(channel(2L, "ACTIVE_CHANNEL",
PriceModel.FBA_WAREHOUSE_RATE)));
assertThatThrownBy(() -> service.estimate(10001L,
request("FORGED", PricingDestinationType.FBA, "ONT8", null, null,
PricingTaxMode.TAX_INCLUDED, "10", "0.1")))
.isInstanceOf(BizException.class)
.hasMessageContaining("已下线");
}
@Test
void shouldEstimateFbaDestinationWithIndependentPricingTaxMode() {
stubChannel(7L, "MATSON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE);
when(repository.fbaRates(7L, 10L, "MATSON_TRUCK", "ONT8", "TAX_INCLUDED"))
.thenReturn(List.of(rate(PriceModel.FBA_WAREHOUSE_RATE, "KG", null, "12.50")));
var result = service.estimate(10001L,
request(" matson_truck ", PricingDestinationType.FBA, "ont8", null, null,
PricingTaxMode.TAX_INCLUDED, "20", "0.2"));
assertThat(result.priceBookVersionId()).isEqualTo(7L);
assertThat(result.pricingTaxMode()).isEqualTo(PricingTaxMode.TAX_INCLUDED);
assertThat(result.chargeableQuantity()).isEqualByComparingTo("33.400000");
assertThat(result.estimatedAmount()).isEqualByComparingTo("417.50");
assertThat(result.disclaimer()).isEqualTo(MerchantPriceBookService.DISCLAIMER);
}
@Test
void shouldEstimateBoundOverseasWarehouse() {
stubChannel(7L, "EXX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE);
when(repository.overseasRates(7L, 10L, "EXX_TRUCK", 901L, "TAX_EXCLUDED"))
.thenReturn(List.of(rate(PriceModel.FBA_WAREHOUSE_RATE, "CBM", null, "880")));
var result = service.estimate(10001L,
request("EXX_TRUCK", PricingDestinationType.OVERSEAS, null, 901L, null,
PricingTaxMode.TAX_EXCLUDED, "363", "0.5"));
assertThat(result.chargeUnit()).isEqualTo("CBM");
assertThat(result.chargeableQuantity()).isEqualByComparingTo("1.000000");
assertThat(result.estimatedAmount()).isEqualByComparingTo("880.00");
}
@Test
void shouldUseExplicitRegionForTierEstimate() {
stubChannel(8L, "MATSON_PARCEL", PriceModel.REGION_WEIGHT_TIER);
when(repository.regionTiers(8L, 10L, "MATSON_PARCEL", "WEST", "TAX_INCLUDED"))
.thenReturn(tiers());
var result = service.estimate(10001L,
request("MATSON_PARCEL", PricingDestinationType.FBA, "ONT8", null, PriceRegion.WEST,
PricingTaxMode.TAX_INCLUDED, "51", null));
assertThat(result.priceModel()).isEqualTo(PriceModel.REGION_WEIGHT_TIER);
assertThat(result.unitPrice()).isEqualByComparingTo("13");
assertThat(result.estimatedAmount()).isEqualByComparingTo("663.00");
}
@Test
void shouldUseIndependentPricingTaxModeForTierEstimateWhenConfigured() {
stubChannel(8L, "MATSON_PARCEL", PriceModel.REGION_WEIGHT_TIER);
when(repository.regionTiers(8L, 10L, "MATSON_PARCEL", "WEST", "TAX_INCLUDED"))
.thenReturn(tiers());
var result = service.estimate(10001L,
request("MATSON_PARCEL", PricingDestinationType.FBA, "ONT8", null, PriceRegion.WEST,
PricingTaxMode.TAX_INCLUDED, "51", null));
assertThat(result.pricingTaxMode()).isEqualTo(PricingTaxMode.TAX_INCLUDED);
verify(repository).regionTiers(8L, 10L, "MATSON_PARCEL", "WEST", "TAX_INCLUDED");
}
@Test
void shouldResolveTierRegionFromPlatformFbaWarehousePostalCode() {
stubChannel(8L, "MATSON_PARCEL", PriceModel.REGION_WEIGHT_TIER);
when(repository.regionTiers(8L, 10L, "MATSON_PARCEL", "EAST", "TAX_INCLUDED"))
.thenReturn(tiers());
var warehouse = new PriceBookWarehouseClient.Warehouse(501L, 0L, "FBA-NJ", "NJ FBA",
"FBA", "US", "ACTIVE");
var address = new PriceBookWarehouseClient.Address("US", "NJ", "Newark", null,
"07114", "address", null);
when(warehouses.active("internal-token", 0L, 501L))
.thenReturn(ApiResponse.success(new PriceBookWarehouseClient.Detail(warehouse, List.of(address))));
var result = service.estimate(10001L,
request("MATSON_PARCEL", PricingDestinationType.FBA, null, 501L, null,
PricingTaxMode.TAX_INCLUDED, "12", null));
assertThat(result.unitPrice()).isEqualByComparingTo("15");
verify(repository).regionTiers(8L, 10L, "MATSON_PARCEL", "EAST", "TAX_INCLUDED");
}
@Test
void shouldRejectMissingRequiredEstimateFields() {
stubChannel(7L, "MATSON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE);
assertThatThrownBy(() -> service.estimate(10001L,
request("MATSON_TRUCK", PricingDestinationType.FBA, "ONT8", null, null,
null, "10", null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("包税或不包税");
}
@Test
void shouldNotGuessRegionWithoutExplicitRegionOrFbaWarehouse() {
stubChannel(8L, "MATSON_PARCEL", PriceModel.REGION_WEIGHT_TIER);
assertThatThrownBy(() -> service.estimate(10001L,
request("MATSON_PARCEL", PricingDestinationType.FBA, "ONT8", null, null,
PricingTaxMode.TAX_INCLUDED, "51", null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("目的仓区域或平台FBA仓");
}
@Test
void shouldRejectTierEstimateWithoutPricingTaxMode() {
stubChannel(8L, "MATSON_PARCEL", PriceModel.REGION_WEIGHT_TIER);
assertThatThrownBy(() -> service.estimate(10001L,
request("MATSON_PARCEL", PricingDestinationType.FBA, "ONT8", null, PriceRegion.WEST,
null, "51", null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("包税或不包税");
}
private void stubChannel(long versionId, String code, PriceModel model) {
when(repository.activeVersionId()).thenReturn(versionId);
when(repository.channels(versionId, 10L)).thenReturn(List.of(channel(versionId, code, model)));
}
private static EstimateRequest request(String channelCode, PricingDestinationType destinationType,
String destinationCode, Long destinationWarehouseId,
PriceRegion destinationRegion, PricingTaxMode pricingTaxMode,
String weight, String volume) {
return new EstimateRequest(10L, channelCode, destinationCode, destinationType,
destinationWarehouseId, null, destinationRegion, pricingTaxMode,
decimal(weight), decimal(volume));
}
private static ChannelRow channel(long versionId, String code, PriceModel model) {
ChannelRow row = new ChannelRow();
row.setPriceBookVersionId(versionId);
row.setChannelCode(code);
row.setChannelName(code);
row.setPriceModel(model.name());
return row;
}
private static ChannelTaxModeRow taxMode(String channelCode, PricingTaxMode pricingTaxMode) {
ChannelTaxModeRow row = new ChannelTaxModeRow();
row.setChannelCode(channelCode);
row.setPricingTaxMode(pricingTaxMode.name());
return row;
}
private static List<RateRow> tiers() {
return List.of(
rate(PriceModel.REGION_WEIGHT_TIER, "KG", "12", "15"),
rate(PriceModel.REGION_WEIGHT_TIER, "KG", "51", "13"),
rate(PriceModel.REGION_WEIGHT_TIER, "KG", "101", "11"));
}
private static RateRow rate(PriceModel model, String unit, String from, String price) {
RateRow row = new RateRow();
row.setPriceModel(model.name());
row.setChargeUnit(unit);
row.setWeightFromKg(decimal(from));
row.setUnitPrice(new BigDecimal(price));
row.setCurrency("CNY");
return row;
}
private static BigDecimal decimal(String value) {
return value == null ? null : new BigDecimal(value);
}
}
@@ -0,0 +1,346 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.PriceBookFileClient;
import com.baiyue.firstleg.client.PriceBookWarehouseClient;
import com.baiyue.firstleg.domain.PriceBookStatus;
import com.baiyue.firstleg.domain.PriceModel;
import com.baiyue.firstleg.domain.PricingDestinationType;
import com.baiyue.firstleg.domain.PricingTaxMode;
import com.baiyue.firstleg.dto.PriceBookConfirmationRequest;
import com.baiyue.firstleg.dto.PriceBookDtos;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
import com.baiyue.firstleg.entity.PriceBookChannelEntity;
import com.baiyue.firstleg.entity.PriceBookVersionEntity;
import com.baiyue.firstleg.entity.PriceChannelCatalogEntity;
import com.baiyue.firstleg.entity.PriceWarehouseMappingEntity;
import com.baiyue.firstleg.repository.PriceBookRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionCallback;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.util.Arrays;
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.any;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PlatformPriceBookServiceTest {
@Mock private PriceBookRepository repository;
@Mock private PriceBookFileClient files;
@Mock private PriceBookWarehouseClient warehouses;
@Mock private TransactionTemplate transactions;
@Mock private TransactionStatus transactionStatus;
private PlatformPriceBookService service;
@BeforeEach
void setUp() {
service = new PlatformPriceBookService();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "files", files);
ReflectionTestUtils.setField(service, "warehouses", warehouses);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
ReflectionTestUtils.setField(service, "transactions", transactions);
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
}
@Test
void fileServiceFailureMarksAllocatedVersionFailed() {
executeTransactionsImmediately();
org.mockito.Mockito.doAnswer(invocation -> {
PriceBookVersionEntity value = invocation.getArgument(0);
value.setId(9007199254740993L);
return 1;
}).when(repository).insertVersion(any());
when(repository.nextVersionNo()).thenReturn(1);
when(files.upload(any(), any(Long.class), any(Long.class), any())).thenReturn(null);
var upload = new MockMultipartFile("file", "price.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", new byte[]{1});
assertThatThrownBy(() -> service.upload(7L, upload))
.isInstanceOf(BizException.class)
.hasMessageContaining("原文件保存失败");
ArgumentCaptor<PriceBookVersionEntity> saved = ArgumentCaptor.forClass(PriceBookVersionEntity.class);
verify(repository).updateVersion(saved.capture());
assertThat(saved.getValue().getId()).isEqualTo(9007199254740993L);
assertThat(saved.getValue().getStatus()).isEqualTo(PriceBookStatus.FAILED.name());
assertThat(saved.getValue().getBlockingErrorCount()).isEqualTo(1);
}
@Test
void parseFailureKeepsImmutableSourceFileMetadataOnFailedVersion() {
executeTransactionsImmediately();
org.mockito.Mockito.doAnswer(invocation -> {
PriceBookVersionEntity value = invocation.getArgument(0);
value.setId(88L);
return 1;
}).when(repository).insertVersion(any());
when(repository.nextVersionNo()).thenReturn(2);
when(files.upload(any(), any(Long.class), any(Long.class), any())).thenReturn(ApiResponse.success(
new PriceBookFileClient.FileMetadata(501L, 88L, "price.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 3L, "abc123", "FBA_PRICE_BOOK_SOURCE")));
when(files.download(any(), any(Long.class), any(Long.class), any(Long.class)))
.thenReturn(ResponseEntity.ok(new byte[]{1, 2, 3}));
var invalid = new MockMultipartFile("file", "price.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", new byte[]{1, 2, 3});
assertThatThrownBy(() -> service.upload(7L, invalid))
.isInstanceOf(BizException.class);
ArgumentCaptor<PriceBookVersionEntity> saved = ArgumentCaptor.forClass(PriceBookVersionEntity.class);
verify(repository).updateVersion(saved.capture());
assertThat(saved.getValue().getStatus()).isEqualTo(PriceBookStatus.FAILED.name());
assertThat(saved.getValue().getSourceFileId()).isEqualTo(501L);
assertThat(saved.getValue().getSourceSha256()).isEqualTo("abc123");
assertThat(saved.getValue().getParseIssuesJson()).contains("UPLOAD_OR_PARSE_FAILED");
}
@Test
void exposesOnlyUploadConfirmationAndReadOperations() {
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");
}
@Test
void pendingDetailWarnsThatMissingActiveChannelWillBeRemoved() {
PriceBookVersionEntity pendingVersion = version(13L, PriceBookStatus.PENDING_CONFIRMATION);
PriceBookVersionEntity active = version(12L, PriceBookStatus.ACTIVE);
when(repository.findVersion(13L)).thenReturn(pendingVersion);
when(repository.findActive()).thenReturn(active);
when(repository.listChannels(13L)).thenReturn(List.of());
when(repository.listChannels(12L)).thenReturn(List.of(channel("MATSON_CLX_TRUCK")));
when(repository.listRates(13L)).thenReturn(List.of());
var detail = service.detail(13L);
assertThat(detail.version().status()).isEqualTo(PriceBookStatus.PENDING_CONFIRMATION);
assertThat(detail.issues()).extracting(PriceBookDtos.ParseIssue::issueCode)
.containsExactly("CHANNEL_MISSING_FROM_PENDING_UPLOAD");
}
@Test
void sourceDownloadIsProxiedWithSafeAttachmentHeaders() {
PriceBookVersionEntity version = version(14L, PriceBookStatus.SUPERSEDED);
version.setSourceFileId(601L);
version.setOriginalFileName("历史价格.xlsx");
when(repository.findVersion(14L)).thenReturn(version);
when(files.download("internal-token", 7L, 14L, 601L))
.thenReturn(ResponseEntity.ok().header(HttpHeaders.CONTENT_TYPE, "application/octet-stream")
.body(new byte[]{9, 8, 7}));
ResponseEntity<byte[]> response = service.source(14L, 7L);
assertThat(response.getBody()).containsExactly(9, 8, 7);
assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION))
.startsWith("attachment;").contains("filename*=");
assertThat(response.getHeaders().getFirst("X-Content-Type-Options")).isEqualTo("nosniff");
}
@Test
void confirmationMapsWarehousesBeforeReplacingActiveVersion() {
PriceBookVersionEntity pendingVersion = version(21L, PriceBookStatus.PENDING_CONFIRMATION);
PriceBookVersionEntity active = version(20L, PriceBookStatus.ACTIVE);
when(repository.findVersion(21L)).thenReturn(pendingVersion);
when(repository.listChannels(21L)).thenReturn(List.of(channel("MATSON_CLX_TRUCK")));
when(repository.listRates(21L)).thenReturn(List.of(rate(77L)));
when(repository.activeCatalog()).thenReturn(List.of(catalog("MATSON_CLX_TRUCK")));
when(repository.listActiveForUpdate()).thenReturn(List.of(active));
when(warehouses.active("internal-token", 0L, 501L)).thenReturn(ApiResponse.success(
new PriceBookWarehouseClient.Detail(new PriceBookWarehouseClient.Warehouse(501L, 0L,
"CN-YIWU-01", "义乌仓", "DOMESTIC", "CN", "ACTIVE"), List.of())));
org.mockito.Mockito.doAnswer(invocation -> {
PriceBookVersionEntity value = invocation.getArgument(0);
value.setStatus(PriceBookStatus.ACTIVE.name());
return 1;
}).when(repository).activate(any(), any(), any(Long.class));
service.publish(21L, 7L, confirmation("CN-YIWU-01", 501L));
InOrder order = inOrder(repository);
order.verify(repository).lockPlatformVersions();
order.verify(repository).findVersion(21L);
verify(repository).deletePendingChildren(21L);
verify(repository).supersede(any(PriceBookVersionEntity.class), any(), any(Long.class));
verify(repository).activate(any(PriceBookVersionEntity.class), any(), any(Long.class));
ArgumentCaptor<CarrierFbaRateEntity> pendingRate = ArgumentCaptor.forClass(CarrierFbaRateEntity.class);
verify(repository).insertRate(pendingRate.capture());
assertThat(pendingRate.getValue().getStatus()).isEqualTo(PriceBookStatus.PENDING_CONFIRMATION.name());
}
@Test
void missingWarehouseMappingCannotPublish() {
PriceBookVersionEntity pendingVersion = version(22L, PriceBookStatus.PENDING_CONFIRMATION);
when(repository.findVersion(22L)).thenReturn(pendingVersion);
when(repository.listChannels(22L)).thenReturn(List.of(channel("MATSON_CLX_TRUCK")));
when(repository.listRates(22L)).thenReturn(List.of(rate(78L)));
when(repository.activeCatalog()).thenReturn(List.of(catalog("MATSON_CLX_TRUCK")));
assertThatThrownBy(() -> service.publish(22L, 7L,
new PriceBookConfirmationRequest(List.of(), List.of())))
.isInstanceOf(BizException.class)
.hasMessageContaining("阻断错误");
verify(repository, never()).activate(any(), any(), any(Long.class));
verify(repository, never()).deletePendingChildren(22L);
}
@Test
void previewDoesNotTreatDifferentUnmappedOriginsAsDuplicateRates() {
CarrierFbaRateEntity yiwu = rate(79L);
CarrierFbaRateEntity dongguan = rate(80L);
dongguan.setOriginWarehouseCode("CN-DONGGUAN-01");
dongguan.setOriginWarehouseName("东莞仓");
when(repository.activeCatalog()).thenReturn(List.of(catalog("MATSON_CLX_TRUCK")));
PriceBookDtos.ParsedChannel parsedChannel = ReflectionTestUtils.invokeMethod(
service, "channelDto", channel("MATSON_CLX_TRUCK"));
PriceBookDtos.ParsedRate parsedYiwu = ReflectionTestUtils.invokeMethod(service, "rateDto", yiwu);
PriceBookDtos.ParsedRate parsedDongguan = ReflectionTestUtils.invokeMethod(service, "rateDto", dongguan);
@SuppressWarnings("unchecked")
List<PriceBookDtos.ParseIssue> issues = ReflectionTestUtils.invokeMethod(
service,
"validate",
List.of(parsedChannel),
List.of(parsedYiwu, parsedDongguan)
);
assertThat(issues).extracting(PriceBookDtos.ParseIssue::issueCode)
.contains("ORIGIN_MAPPING_REQUIRED")
.doesNotContain("DUPLICATE_RATE");
}
@Test
void initializedWarehouseMappingsBindFixedDomesticAndOverseasWarehouses() {
CarrierFbaRateEntity overseasRate = rate(81L);
overseasRate.setDestinationType(PricingDestinationType.OVERSEAS.name());
overseasRate.setDestinationCode(null);
overseasRate.setDestinationName("洛杉矶仓");
PriceBookDtos.ParsedRate parsed = ReflectionTestUtils.invokeMethod(service, "rateDto", overseasRate);
when(repository.activeWarehouseMappings()).thenReturn(List.of(
warehouseMapping("ORIGIN_CODE", "CN-YIWU-01", 193000000000200001L, "CN-YIWU-01", "DOMESTIC"),
warehouseMapping("OVERSEAS_NAME", "洛杉矶仓", 193000000000200003L, "US-LAX-01", "OVERSEAS")
));
@SuppressWarnings("unchecked")
List<PriceBookDtos.ParsedRate> mapped = ReflectionTestUtils.invokeMethod(
service, "applyInitializedWarehouseMappings", List.of(parsed));
assertThat(mapped).singleElement().satisfies(rate -> {
assertThat(rate.originWarehouseId()).isEqualTo(193000000000200001L);
assertThat(rate.destinationWarehouseId()).isEqualTo(193000000000200003L);
assertThat(rate.destinationCode()).isEqualTo("US-LAX-01");
});
}
@Test
void duplicatePublishIsRejectedAfterPublishLock() {
when(repository.findVersion(31L)).thenReturn(version(31L, PriceBookStatus.ACTIVE));
assertThatThrownBy(() -> service.publish(31L, 7L, confirmation("CN-YIWU-01", 501L)))
.isInstanceOf(BizException.class)
.hasMessageContaining("待确认上传记录");
InOrder order = inOrder(repository);
order.verify(repository).lockPlatformVersions();
order.verify(repository).findVersion(31L);
verify(repository, never()).activate(any(), any(), any(Long.class));
}
private static PriceBookVersionEntity version(long id, PriceBookStatus status) {
PriceBookVersionEntity value = new PriceBookVersionEntity();
value.setId(id);
value.setTenantId(0L);
value.setVersionNo((int) id);
value.setOriginalFileName("price.xlsx");
value.setStatus(status.name());
value.setChannelCount(0);
value.setRateCount(0);
value.setBlockingErrorCount(0);
value.setWarningCount(0);
value.setDeleted(false);
return value;
}
private static PriceBookChannelEntity channel(String code) {
PriceBookChannelEntity value = new PriceBookChannelEntity();
value.setChannelCode(code);
value.setChannelName(code);
value.setPriceModel(PriceModel.FBA_WAREHOUSE_RATE.name());
value.setSourceSheetName(code);
return value;
}
private static PriceChannelCatalogEntity catalog(String code) {
PriceChannelCatalogEntity value = new PriceChannelCatalogEntity();
value.setChannelCode(code);
value.setPriceModel(PriceModel.FBA_WAREHOUSE_RATE.name());
value.setStatus("ACTIVE");
return value;
}
private static CarrierFbaRateEntity rate(long id) {
CarrierFbaRateEntity value = new CarrierFbaRateEntity();
value.setId(id);
value.setChannelCode("MATSON_CLX_TRUCK");
value.setPriceModel(PriceModel.FBA_WAREHOUSE_RATE.name());
value.setOriginWarehouseCode("CN-YIWU-01");
value.setOriginWarehouseName("义乌仓");
value.setDestinationType(PricingDestinationType.FBA.name());
value.setDestinationCode("ONT8");
value.setPricingTaxMode(PricingTaxMode.TAX_INCLUDED.name());
value.setChargeUnit("KG");
value.setUnitPrice(new BigDecimal("10"));
value.setCurrency("CNY");
value.setSourceSheetName("美森正班海卡");
value.setSourceRowNumber(5);
value.setSourceDestinationText("ONT8");
return value;
}
private static PriceBookConfirmationRequest confirmation(String code, long id) {
return new PriceBookConfirmationRequest(
List.of(new PriceBookConfirmationRequest.OriginMapping(code, id)), List.of());
}
private static PriceWarehouseMappingEntity warehouseMapping(String sourceType, String sourceValue, long warehouseId,
String warehouseCode, String warehouseType) {
PriceWarehouseMappingEntity value = new PriceWarehouseMappingEntity();
value.setSourceType(sourceType);
value.setSourceValue(sourceValue);
value.setWarehouseId(warehouseId);
value.setWarehouseCode(warehouseCode);
value.setWarehouseType(warehouseType);
value.setStatus("ACTIVE");
return value;
}
private void executeTransactionsImmediately() {
when(transactions.execute(any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
TransactionCallback<Object> callback = invocation.getArgument(0);
return callback.doInTransaction(transactionStatus);
});
}
}