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:
+62
@@ -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')");
|
||||
}
|
||||
}
|
||||
+6
@@ -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")
|
||||
|
||||
+91
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -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");
|
||||
}
|
||||
}
|
||||
+67
@@ -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;
|
||||
}
|
||||
}
|
||||
+206
@@ -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;
|
||||
}
|
||||
}
|
||||
+65
@@ -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");
|
||||
}
|
||||
}
|
||||
+35
@@ -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);
|
||||
}
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
}
|
||||
+109
@@ -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) {
|
||||
|
||||
+89
@@ -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));
|
||||
}
|
||||
}
|
||||
+189
@@ -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;
|
||||
}
|
||||
}
|
||||
+258
-1
@@ -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;
|
||||
}
|
||||
|
||||
+44
-1
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user