二期内容提交
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
package com.baiyue.firstleg.config;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DisplayName("入库动态配置")
|
||||
class InboundPropertiesTest {
|
||||
@Test
|
||||
@DisplayName("Nacos缺失时异常放行默认关闭")
|
||||
void shouldDisableRejectedMeasurementReleaseByDefault() {
|
||||
assertThat(new InboundProperties().isAllowRejectedMeasurement()).isFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
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("FBA计费OpenAPI契约")
|
||||
class FbaPricingContractTest {
|
||||
@Test
|
||||
@DisplayName("契约声明自动发布、已发布费用调整和幂等头")
|
||||
void documentsPricingLifecycle() throws Exception {
|
||||
String openapi = new ClassPathResource("openapi/fba-pricing-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(openapi)
|
||||
.contains("/api/platform/v1/fba-pricing/customer-rules/versions:")
|
||||
.contains("/api/platform/v1/fba-pricing/carrier-rates/versions:")
|
||||
.contains("/api/platform/v1/inbounds/{inboundId}/fees/calculate:")
|
||||
.contains("/api/platform/v1/inbounds/{inboundId}/fees/confirm:")
|
||||
.contains("/api/platform/v1/inbounds/{inboundId}/fees/send:")
|
||||
.contains("/api/platform/v1/inbounds/{inboundId}/fees/published:")
|
||||
.contains("Idempotency-Key")
|
||||
.contains("客服测量审核通过后自动计费、发布并完成入库")
|
||||
.contains("不回退 INBOUNDED 状态")
|
||||
.doesNotContain("应收")
|
||||
.doesNotContain("应付")
|
||||
.doesNotContain("待客户确认");
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.dto.ExceptionReleaseRequest;
|
||||
import com.baiyue.firstleg.dto.MeasurementReviewRequest;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DisplayName("入库测量审核控制器契约")
|
||||
class InboundMeasurementReviewContractTest {
|
||||
@Test
|
||||
@DisplayName("审核、放行和历史接口声明独立按钮权限")
|
||||
void shouldDeclareButtonPermissions() throws Exception {
|
||||
assertAuthority("approve", "hasAuthority('firstleg:inbound:measurement-review')",
|
||||
AuthenticatedUser.class, long.class, long.class, String.class, MeasurementReviewRequest.class);
|
||||
assertAuthority("reject", "hasAuthority('firstleg:inbound:measurement-review')",
|
||||
AuthenticatedUser.class, long.class, long.class, String.class, MeasurementReviewRequest.class);
|
||||
assertAuthority("exceptionRelease", "hasAuthority('firstleg:inbound:exception-release')",
|
||||
AuthenticatedUser.class, long.class, long.class, String.class, ExceptionReleaseRequest.class);
|
||||
assertAuthority("history", "hasAuthority('firstleg:inbound:measurement-review-history')",
|
||||
AuthenticatedUser.class, long.class, long.class, long.class, long.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("OpenAPI不包含待客户确认并声明幂等和字符串长ID")
|
||||
void shouldDocumentReviewContract() throws Exception {
|
||||
String openapi = new ClassPathResource("openapi/firstleg-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(openapi)
|
||||
.contains("/api/platform/v1/inbounds/{inboundId}/measurements/approve:")
|
||||
.contains("/api/platform/v1/inbounds/{inboundId}/measurements/reject:")
|
||||
.contains("firstleg:inbound:exception-release")
|
||||
.contains("Idempotency-Key")
|
||||
.contains("name: inboundId")
|
||||
.contains("schema: {type: string, pattern:")
|
||||
.doesNotContain("待客户确认");
|
||||
}
|
||||
|
||||
private void assertAuthority(String name, String expression, Class<?>... types) throws Exception {
|
||||
Method method = PlatformInboundMeasurementReviewController.class.getDeclaredMethod(name, types);
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value()).isEqualTo(expression);
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.dto.InboundOrderQuery;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class InboundOrderControllerContractTest {
|
||||
@Test
|
||||
void shouldDeclareReadOnlyPermissions() throws Exception {
|
||||
assertAuthority("page", "hasAuthority('platform:firstleg:inbound:list')",
|
||||
AuthenticatedUser.class, long.class, InboundOrderQuery.class);
|
||||
assertAuthority("detail", "hasAuthority('platform:firstleg:inbound:view')",
|
||||
AuthenticatedUser.class, long.class, long.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDocumentOnlyV2TabsAndStringLongIds() throws Exception {
|
||||
String openapi = new ClassPathResource("openapi/firstleg-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(openapi)
|
||||
.contains("/api/platform/inbounds:")
|
||||
.contains("/api/platform/inbounds/{inboundId}:")
|
||||
.contains("[WAIT_MEASURE, WAIT_REVIEW, WAIT_FEE_SEND, INBOUNDED]")
|
||||
.contains("platform:firstleg:inbound:list")
|
||||
.doesNotContain("WAIT_CUSTOMER_CONFIRM");
|
||||
}
|
||||
|
||||
private static void assertAuthority(String methodName, String value, Class<?>... types)
|
||||
throws NoSuchMethodException {
|
||||
Method method = PlatformInboundOrderController.class.getDeclaredMethod(methodName, types);
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value()).isEqualTo(value);
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.service.MerchantInboundBillingService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class MerchantInboundBillingControllerContractTest {
|
||||
private final MerchantInboundBillingController controller =
|
||||
new MerchantInboundBillingController(mock(MerchantInboundBillingService.class));
|
||||
|
||||
@Test
|
||||
void 客户费用查询复用订单查看权限且不提供确认接口() throws Exception {
|
||||
var method = MerchantInboundBillingController.class.getDeclaredMethod(
|
||||
"find", AuthenticatedUser.class, long.class);
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('merchant:order:view')");
|
||||
assertThat(MerchantInboundBillingController.class.getDeclaredMethods())
|
||||
.noneMatch(candidate -> candidate.getName().toLowerCase().contains("confirm"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 平台身份不能越权调用客户费用接口() {
|
||||
var platform = new AuthenticatedUser(1L, 0L, 0L, 1L,
|
||||
"admin", "PLATFORM", "WEB", Set.of());
|
||||
assertThatThrownBy(() -> controller.find(platform, 77L)).isInstanceOf(BizException.class);
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DisplayName("第三方测量开放接口契约")
|
||||
class OpenApiInboundMeasurementControllerContractTest {
|
||||
@Test
|
||||
@DisplayName("回调以原始字符串接收报文,保证签名正文不被重新序列化")
|
||||
void shouldReceiveRawRequestBody() throws Exception {
|
||||
Method method = OpenApiInboundMeasurementController.class.getDeclaredMethod("receive",
|
||||
String.class, String.class, String.class, String.class, String.class, String.class);
|
||||
assertThat(method.getParameters()[5].getAnnotation(RequestBody.class)).isNotNull();
|
||||
assertThat(method.getParameters()[5].getType()).isEqualTo(String.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("静态OpenAPI声明回调、状态查询、签名和三层幂等")
|
||||
void shouldDocumentMeasurementOpenApiContract() throws Exception {
|
||||
String openapi = new ClassPathResource("openapi/firstleg-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(openapi)
|
||||
.contains("/openapi/v1/inbound-measurements:")
|
||||
.contains("/openapi/v1/inbound-measurements/events/{eventNo}:")
|
||||
.contains("X-Signature")
|
||||
.contains("Idempotency-Key")
|
||||
.contains("appKey + eventNo")
|
||||
.contains("Nonce")
|
||||
.contains("不改变入库单状态");
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
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 java.lang.reflect.Method;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DisplayName("平台入库测量接口权限契约")
|
||||
class PlatformInboundMeasurementControllerContractTest {
|
||||
@Test
|
||||
void everyMeasurementOperationUsesDedicatedButtonPermission() throws Exception {
|
||||
assertAuthority("detail", "hasAuthority('platform:firstleg:inbound:measure:view')",
|
||||
AuthenticatedUser.class, long.class, long.class);
|
||||
assertAuthority("saveDraft", "hasAuthority('platform:firstleg:inbound:measure:edit')",
|
||||
AuthenticatedUser.class, long.class, long.class,
|
||||
InboundMeasurementRequests.SaveDraftRequest.class);
|
||||
assertAuthority("submit", "hasAuthority('platform:firstleg:inbound:measure:submit')",
|
||||
AuthenticatedUser.class, long.class, long.class,
|
||||
InboundMeasurementRequests.SubmitRequest.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')",
|
||||
AuthenticatedUser.class, long.class, long.class, long.class,
|
||||
InboundMeasurementRequests.ConfirmWeightResultRequest.class);
|
||||
}
|
||||
|
||||
private void assertAuthority(String methodName, String expected, Class<?>... parameterTypes)
|
||||
throws NoSuchMethodException {
|
||||
Method method = PlatformInboundMeasurementController.class
|
||||
.getDeclaredMethod(methodName, parameterTypes);
|
||||
PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class);
|
||||
assertThat(preAuthorize).isNotNull();
|
||||
assertThat(preAuthorize.value()).isEqualTo(expected);
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.firstleg.dto.InboundOrderQuery;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
|
||||
import com.baiyue.firstleg.mapper.InboundFileMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundInternalNoteMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundOrderMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundTransportSnapshotMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class InboundOrderRepositoryTest {
|
||||
@Test
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
void shouldApplyTenantCompanyAndWarehouseFilters() {
|
||||
TableInfoHelper.initTableInfo(
|
||||
new MapperBuilderAssistant(new MybatisConfiguration(), "test"), InboundOrderEntity.class);
|
||||
InboundOrderMapper orders = mock(InboundOrderMapper.class);
|
||||
when(orders.selectPage(any(Page.class), any(Wrapper.class)))
|
||||
.thenReturn(new Page<InboundOrderEntity>(1, 20));
|
||||
InboundOrderRepository repository = new InboundOrderRepository(
|
||||
orders,
|
||||
mock(InboundTransportSnapshotMapper.class),
|
||||
mock(InboundPalletAllocationMapper.class),
|
||||
mock(InboundFileMapper.class),
|
||||
mock(InboundInternalNoteMapper.class),
|
||||
mock(InboundStatusHistoryMapper.class));
|
||||
InboundOrderQuery query = new InboundOrderQuery(
|
||||
null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, 20001L, 30001L, null, null, null, 1, 20);
|
||||
|
||||
repository.page(10001L, query);
|
||||
|
||||
ArgumentCaptor<Wrapper<InboundOrderEntity>> captor = ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(orders).selectPage(any(Page.class), captor.capture());
|
||||
assertThat(captor.getValue().getCustomSqlSegment())
|
||||
.contains("tenant_id")
|
||||
.contains("company_id")
|
||||
.contains("warehouse_id")
|
||||
.contains("ORDER BY created_at DESC,id DESC");
|
||||
LambdaQueryWrapper<InboundOrderEntity> wrapper =
|
||||
(LambdaQueryWrapper<InboundOrderEntity>) captor.getValue();
|
||||
assertThat(wrapper.getParamNameValuePairs().values())
|
||||
.contains(10001L, 20001L, 30001L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
void shouldBatchAggregateLocationCodesInAllocationOrderIncludingA0() {
|
||||
TableInfoHelper.initTableInfo(
|
||||
new MapperBuilderAssistant(new MybatisConfiguration(), "test"), InboundOrderEntity.class);
|
||||
TableInfoHelper.initTableInfo(
|
||||
new MapperBuilderAssistant(new MybatisConfiguration(), "test"),
|
||||
InboundPalletAllocationEntity.class);
|
||||
InboundOrderMapper orders = mock(InboundOrderMapper.class);
|
||||
InboundPalletAllocationMapper pallets = mock(InboundPalletAllocationMapper.class);
|
||||
Page<InboundOrderEntity> page = new Page<>(1, 20);
|
||||
InboundOrderEntity first = new InboundOrderEntity();
|
||||
first.setId(1L);
|
||||
InboundOrderEntity second = new InboundOrderEntity();
|
||||
second.setId(2L);
|
||||
page.setRecords(java.util.List.of(first, second));
|
||||
when(orders.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(page);
|
||||
when(pallets.selectList(any(Wrapper.class))).thenReturn(java.util.List.of(
|
||||
allocation(1L, 1, "A0"),
|
||||
allocation(1L, 2, "A2"),
|
||||
allocation(2L, 1, "B1")));
|
||||
InboundOrderRepository repository = new InboundOrderRepository(
|
||||
orders,
|
||||
mock(InboundTransportSnapshotMapper.class),
|
||||
pallets,
|
||||
mock(InboundFileMapper.class),
|
||||
mock(InboundInternalNoteMapper.class),
|
||||
mock(InboundStatusHistoryMapper.class));
|
||||
|
||||
var result = repository.page(10001L, new InboundOrderQuery(
|
||||
null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null, 1, 20));
|
||||
|
||||
assertThat(result.records()).extracting(record -> record.locationCodes())
|
||||
.containsExactly("A0、A2", "B1");
|
||||
ArgumentCaptor<Wrapper<InboundPalletAllocationEntity>> captor =
|
||||
ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(pallets, times(1)).selectList(captor.capture());
|
||||
assertThat(captor.getValue().getCustomSqlSegment())
|
||||
.contains("tenant_id")
|
||||
.contains("inbound_id IN")
|
||||
.contains("ORDER BY inbound_id ASC,allocation_no ASC");
|
||||
}
|
||||
|
||||
private static InboundPalletAllocationEntity allocation(
|
||||
long inboundId, int allocationNo, String locationCode) {
|
||||
InboundPalletAllocationEntity entity = new InboundPalletAllocationEntity();
|
||||
entity.setInboundId(inboundId);
|
||||
entity.setAllocationNo(allocationNo);
|
||||
entity.setLocationCode(locationCode);
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
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 InboundWorkflowSqlContractTest {
|
||||
@Test
|
||||
@DisplayName("基础表和增量迁移包含审核历史、幂等、Outbox和放行索引")
|
||||
void shouldProvideWorkflowPersistenceBoundaries() throws Exception {
|
||||
String schema = new ClassPathResource("db/phase2-inbound-schema.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
String migration = new ClassPathResource("db/phase2-inbound-workflow-migration.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(schema)
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_measure_review_record")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_idempotency")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_outbox")
|
||||
.contains("idx_review_exception_release");
|
||||
assertThat(migration)
|
||||
.contains("information_schema.statistics")
|
||||
.contains("idx_review_exception_release");
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package com.baiyue.firstleg.security;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.config.MeasurementOpenApiProperties;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
@DisplayName("第三方测量请求签名校验")
|
||||
class MeasurementRequestAuthenticatorTest {
|
||||
private static final Instant NOW = Instant.parse("2026-08-01T02:30:00Z");
|
||||
private static final String SECRET = "test-secret-at-least-32-bytes-long";
|
||||
|
||||
@Test
|
||||
@DisplayName("合法签名按六行规范串校验并占用Nonce")
|
||||
void shouldAcceptValidSignatureAndClaimNonce() {
|
||||
MeasurementOpenApiProperties properties = properties();
|
||||
NonceReplayGuard guard = (appKey, nonce, ttl) -> true;
|
||||
MeasurementRequestAuthenticator authenticator = new MeasurementRequestAuthenticator(
|
||||
properties, guard, Clock.fixed(NOW, ZoneOffset.UTC));
|
||||
String body = "{\"eventNo\":\"M1\"}";
|
||||
String timestamp = String.valueOf(NOW.toEpochMilli());
|
||||
String canonical = MeasurementRequestAuthenticator.canonicalString("POST",
|
||||
"/openapi/v1/inbound-measurements", timestamp, "nonce-1", "idem-1",
|
||||
MeasurementRequestAuthenticator.sha256Hex(body));
|
||||
String signature = MeasurementRequestAuthenticator.hmacHex(SECRET, canonical);
|
||||
|
||||
AuthenticatedMeasurementApp app = authenticator.authenticate("POST",
|
||||
"/openapi/v1/inbound-measurements",
|
||||
new MeasurementRequestHeaders("device-provider", timestamp, "nonce-1", signature, "idem-1"),
|
||||
body, true);
|
||||
|
||||
assertThat(app.tenantId()).isEqualTo(10001L);
|
||||
assertThat(app.warehouseBindings()).containsEntry("CN-YW-01", 30001L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("非法签名返回统一未认证业务码且不占用Nonce")
|
||||
void shouldRejectInvalidSignatureBeforeClaimingNonce() {
|
||||
int[] claims = {0};
|
||||
NonceReplayGuard guard = (appKey, nonce, ttl) -> {
|
||||
claims[0]++;
|
||||
return true;
|
||||
};
|
||||
MeasurementRequestAuthenticator authenticator = new MeasurementRequestAuthenticator(
|
||||
properties(), guard, Clock.fixed(NOW, ZoneOffset.UTC));
|
||||
|
||||
assertThatThrownBy(() -> authenticator.authenticate("POST", "/openapi/v1/inbound-measurements",
|
||||
new MeasurementRequestHeaders("device-provider", String.valueOf(NOW.toEpochMilli()),
|
||||
"nonce-1", "00", "idem-1"), "{}", true))
|
||||
.isInstanceOf(BizException.class)
|
||||
.satisfies(exception -> assertThat(((BizException) exception).getResultCode())
|
||||
.isEqualTo(ResultCode.UNAUTHORIZED));
|
||||
assertThat(claims[0]).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("相同应用重复Nonce被拒绝")
|
||||
void shouldRejectReplayedNonce() {
|
||||
MeasurementRequestAuthenticator authenticator = new MeasurementRequestAuthenticator(
|
||||
properties(), (appKey, nonce, ttl) -> false, Clock.fixed(NOW, ZoneOffset.UTC));
|
||||
String timestamp = String.valueOf(NOW.toEpochMilli());
|
||||
String body = "{}";
|
||||
String canonical = MeasurementRequestAuthenticator.canonicalString("POST",
|
||||
"/openapi/v1/inbound-measurements", timestamp, "nonce-1", "idem-1",
|
||||
MeasurementRequestAuthenticator.sha256Hex(body));
|
||||
|
||||
assertThatThrownBy(() -> authenticator.authenticate("POST", "/openapi/v1/inbound-measurements",
|
||||
new MeasurementRequestHeaders("device-provider", timestamp, "nonce-1",
|
||||
MeasurementRequestAuthenticator.hmacHex(SECRET, canonical), "idem-1"), body, true))
|
||||
.isInstanceOf(BizException.class)
|
||||
.satisfies(exception -> assertThat(((BizException) exception).getResultCode())
|
||||
.isEqualTo(ResultCode.CONFLICT));
|
||||
}
|
||||
|
||||
private MeasurementOpenApiProperties properties() {
|
||||
MeasurementOpenApiProperties properties = new MeasurementOpenApiProperties();
|
||||
properties.setEnabled(true);
|
||||
MeasurementOpenApiProperties.AppCredential credential = new MeasurementOpenApiProperties.AppCredential();
|
||||
credential.setSecret(SECRET);
|
||||
credential.setTenantId(10001L);
|
||||
credential.setSubjectId(90001L);
|
||||
credential.setWarehouseBindings(Map.of("CN-YW-01", 30001L));
|
||||
properties.setApps(Map.of("device-provider", credential));
|
||||
return properties;
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class AutomaticInboundCompletionServiceTest {
|
||||
private final FbaPricingService pricing = mock(FbaPricingService.class);
|
||||
private final InboundWorkflowRepository workflow = mock(InboundWorkflowRepository.class);
|
||||
private final InboundCompletionOrchestrator completion = mock(InboundCompletionOrchestrator.class);
|
||||
private final AutomaticInboundCompletionService service =
|
||||
new AutomaticInboundCompletionService(pricing, workflow, completion);
|
||||
|
||||
@Test
|
||||
void 客服审核通过后自动计费发布并完成入库() {
|
||||
FeeSnapshotResponse fee = fee(501L);
|
||||
when(pricing.latest(10001L, 88L)).thenReturn(fee);
|
||||
when(workflow.findOwned(10001L, 88L)).thenReturn(
|
||||
order("WAIT_PRICING", "UNPRICED", 4),
|
||||
order("FEE_CONFIRMING", "CALCULATED", 5),
|
||||
order("FEE_CONFIRMING", "CONFIRMED", 6),
|
||||
order("INBOUNDING", "CONFIRMED", 7),
|
||||
order("INBOUNDED", "SENT", 8));
|
||||
|
||||
FeeSnapshotResponse result = service.completeApproved(10001L, 88L, 9L, "客服", 30L, 4);
|
||||
|
||||
assertThat(result).isSameAs(fee);
|
||||
verify(pricing).calculate(10001L, 9L, 88L, null, "auto-88-30-4-calculate");
|
||||
verify(pricing).confirm(10001L, 9L, 88L, "auto-88-30-4-confirm");
|
||||
verify(completion).beginSendFeeAndInbound(10001L, 9L, "客服", 88L, 501L, 6,
|
||||
"auto-88-30-4-begin");
|
||||
verify(completion).completeSendFeeAndInbound(10001L, 9L, "客服", 88L, 501L, 7,
|
||||
"auto-88-30-4-complete");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 已完成入库的审核重放只返回最新费用() {
|
||||
FeeSnapshotResponse fee = fee(501L);
|
||||
when(workflow.findOwned(10001L, 88L)).thenReturn(order("INBOUNDED", "SENT", 8));
|
||||
when(pricing.latest(10001L, 88L)).thenReturn(fee);
|
||||
|
||||
assertThat(service.completeApproved(10001L, 88L, 9L, "客服", 30L, 4)).isSameAs(fee);
|
||||
|
||||
verify(pricing, never()).calculate(anyLong(), anyLong(), anyLong(),
|
||||
org.mockito.ArgumentMatchers.any(), anyString());
|
||||
verify(pricing, never()).confirm(anyLong(), anyLong(), anyLong(), anyString());
|
||||
verify(completion, never()).beginSendFeeAndInbound(anyLong(), anyLong(), anyString(),
|
||||
anyLong(), anyLong(), org.mockito.ArgumentMatchers.anyInt(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void 中断在入库处理中时续跑完成而不重复计费() {
|
||||
FeeSnapshotResponse fee = fee(501L);
|
||||
when(pricing.latest(10001L, 88L)).thenReturn(fee);
|
||||
when(workflow.findOwned(10001L, 88L)).thenReturn(
|
||||
order("INBOUNDING", "CONFIRMED", 7), order("INBOUNDED", "SENT", 8));
|
||||
|
||||
service.completeApproved(10001L, 88L, 9L, "客服", 30L, 4);
|
||||
|
||||
verify(completion).completeSendFeeAndInbound(10001L, 9L, "客服", 88L, 501L, 7,
|
||||
"auto-88-30-4-complete");
|
||||
verify(pricing, never()).calculate(anyLong(), anyLong(), anyLong(),
|
||||
org.mockito.ArgumentMatchers.any(), anyString());
|
||||
verify(pricing, never()).confirm(anyLong(), anyLong(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
private static InboundOrderEntity order(String process, String feeStatus, int version) {
|
||||
InboundOrderEntity order = new InboundOrderEntity();
|
||||
order.setProcessStatus(process);
|
||||
order.setFeeStatus(feeStatus);
|
||||
order.setVersion(version);
|
||||
return order;
|
||||
}
|
||||
|
||||
private static FeeSnapshotResponse fee(long feeId) {
|
||||
FeeSnapshotResponse fee = mock(FeeSnapshotResponse.class);
|
||||
when(fee.feeId()).thenReturn(feeId);
|
||||
return fee;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.CustomerRuleVersionRequest;
|
||||
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
|
||||
import com.baiyue.firstleg.repository.FbaPricingRepository;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
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.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@DisplayName("FBA计费基础资料版本")
|
||||
class FbaMasterDataServiceTest {
|
||||
private static final LocalDateTime AUGUST = LocalDateTime.of(2026, 8, 1, 0, 0);
|
||||
|
||||
@Test
|
||||
@DisplayName("半开有效期首尾相接不视为重叠")
|
||||
void acceptsAdjacentHalfOpenPeriods() {
|
||||
FbaPricingRepository repository = mock(FbaPricingRepository.class);
|
||||
CustomerChargeRuleEntity old = activeRule(AUGUST.minusMonths(1), AUGUST);
|
||||
when(repository.listRules(1L, null)).thenReturn(List.of(old));
|
||||
when(repository.listRules(1L, "CR-1")).thenReturn(List.of(old));
|
||||
when(repository.nextRuleVersion(1L, "CR-1")).thenReturn(2);
|
||||
doAnswer(invocation -> { ((CustomerChargeRuleEntity) invocation.getArgument(0)).setId(10L); return null; })
|
||||
.when(repository).insertRule(any());
|
||||
|
||||
CustomerChargeRuleEntity created = new FbaMasterDataService(repository)
|
||||
.createRuleVersion(1L, 2L, request(AUGUST, AUGUST.plusMonths(1)));
|
||||
|
||||
assertThat(created.getId()).isEqualTo(10L);
|
||||
assertThat(created.getRuleVersion()).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("任意重叠有效期在保存阶段拒绝避免多规则匹配")
|
||||
void rejectsOverlappingPeriods() {
|
||||
FbaPricingRepository repository = mock(FbaPricingRepository.class);
|
||||
when(repository.listRules(1L, null)).thenReturn(List.of(
|
||||
activeRule(AUGUST, AUGUST.plusMonths(1))));
|
||||
|
||||
assertThatThrownBy(() -> new FbaMasterDataService(repository)
|
||||
.createRuleVersion(1L, 2L, request(AUGUST.plusDays(1), AUGUST.plusMonths(2))))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("有效期重叠");
|
||||
}
|
||||
|
||||
private CustomerRuleVersionRequest request(LocalDateTime from, LocalDateTime to) {
|
||||
return new CustomerRuleVersionRequest("CR-1", 100L, "客户A", "FBA_SEA", "KG",
|
||||
"TAX_INCLUDED", new BigDecimal("167"), 3, "CEILING", "USD", from, to, null);
|
||||
}
|
||||
|
||||
private CustomerChargeRuleEntity activeRule(LocalDateTime from, LocalDateTime to) {
|
||||
CustomerChargeRuleEntity value = new CustomerChargeRuleEntity();
|
||||
value.setRuleCode("CR-1"); value.setRuleVersion(1); value.setCustomerId(100L);
|
||||
value.setBusinessType("FBA_SEA"); value.setTaxMode("TAX_INCLUDED"); value.setStatus("ACTIVE");
|
||||
value.setEffectiveFrom(from); value.setEffectiveTo(to); return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
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.repository.FbaPricingRepository;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
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;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
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;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@DisplayName("FBA费用状态机")
|
||||
class FbaPricingServiceTest {
|
||||
private static final long TENANT = 10001L;
|
||||
private static final long OPERATOR = 20001L;
|
||||
private static final long INBOUND = 30001L;
|
||||
private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 3, 10, 0);
|
||||
|
||||
@Test
|
||||
@DisplayName("包税KG按配置系数取体积重较大值并按规则向上舍入")
|
||||
void calculatesTaxIncludedKgWithConfiguredFactorAndPrecision() {
|
||||
FbaPricingRepository repository = readyRepository();
|
||||
InboundPricingEntity inbound = inbound("100.000", "1.0001");
|
||||
CustomerChargeRuleEntity rule = rule("KG", "167.000000", 3, "CEILING");
|
||||
CarrierFbaRateEntity rate = rate("KG", "2.345678", "10.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));
|
||||
|
||||
FeeSnapshotResponse response = service(repository).calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-1");
|
||||
|
||||
assertThat(response.convertedQuantity()).isEqualByComparingTo("167.017");
|
||||
assertThat(response.chargeableQuantity()).isEqualByComparingTo("167.017");
|
||||
assertThat(response.subtotalAmount()).isEqualByComparingTo("391.77");
|
||||
assertThat(response.totalAmount()).isEqualByComparingTo("401.77");
|
||||
assertThat(response.nextProcessStatus()).isEqualTo("FEE_CONFIRMING");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("不包税CBM按配置系数折算且精度边界向上取整")
|
||||
void calculatesTaxExcludedCbmAtRoundingBoundary() {
|
||||
FbaPricingRepository repository = readyRepository();
|
||||
InboundPricingEntity inbound = inbound("363.001", "0.5000");
|
||||
CustomerChargeRuleEntity rule = rule("CBM", "363.000000", 4, "CEILING");
|
||||
CarrierFbaRateEntity rate = rate("CBM", "100.000000", "0.00");
|
||||
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));
|
||||
|
||||
FeeSnapshotResponse response = service(repository).calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-2");
|
||||
|
||||
assertThat(response.convertedQuantity()).isEqualByComparingTo("1.0001");
|
||||
assertThat(response.chargeableQuantity()).isEqualByComparingTo("1.0001");
|
||||
assertThat(response.totalAmount()).isEqualByComparingTo("100.01");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("审核未通过的测量快照禁止计费")
|
||||
void rejectsUnapprovedMeasurementSnapshot() {
|
||||
FbaPricingRepository repository = readyRepository();
|
||||
InboundPricingEntity inbound = inbound("10", "1");
|
||||
inbound.setMeasureReviewStatus("REJECTED");
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
|
||||
assertThatThrownBy(() -> service(repository).calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-3"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("审核通过");
|
||||
verify(repository, never()).matchRules(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("无客户规则和多客户规则都明确拒绝")
|
||||
void rejectsMissingAndAmbiguousRules() {
|
||||
FbaPricingRepository missing = readyRepository();
|
||||
InboundPricingEntity inbound = inbound("10", "1");
|
||||
when(missing.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
when(missing.matchRules(inbound, NOW)).thenReturn(List.of());
|
||||
assertThatThrownBy(() -> service(missing).calculate(TENANT, OPERATOR, INBOUND, NOW, "none"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("未匹配到有效客户计费规则");
|
||||
|
||||
FbaPricingRepository ambiguous = readyRepository();
|
||||
when(ambiguous.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
when(ambiguous.matchRules(inbound, NOW)).thenReturn(List.of(
|
||||
rule("KG", "167", 3, "CEILING"), rule("KG", "168", 3, "CEILING")));
|
||||
assertThatThrownBy(() -> service(ambiguous).calculate(TENANT, OPERATOR, INBOUND, NOW, "many"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("多条有效客户计费规则");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("同一幂等键重复试算直接返回原费用快照")
|
||||
void returnsOriginalSnapshotForRepeatedCalculation() {
|
||||
FbaPricingRepository repository = mock(FbaPricingRepository.class);
|
||||
InboundPricingIdempotencyEntity completed = completed("FEE_CALCULATE", 501L);
|
||||
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CALCULATE", "same")).thenReturn(completed);
|
||||
when(repository.findFee(TENANT, 501L)).thenReturn(savedFee("CALCULATED"));
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound("10", "1"));
|
||||
InboundFeeItemEntity item = new InboundFeeItemEntity(); item.setUnitPrice(new BigDecimal("2.00"));
|
||||
when(repository.findBaseItem(TENANT, 501L)).thenReturn(item);
|
||||
|
||||
FeeSnapshotResponse response = service(repository).calculate(TENANT, OPERATOR, INBOUND, NOW, "same");
|
||||
|
||||
assertThat(response.feeId()).isEqualTo(501L);
|
||||
verify(repository, never()).matchRules(any(), any());
|
||||
verify(repository, never()).insertFee(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("重复发送只返回首次结果且不会重复写Outbox")
|
||||
void sendIsIdempotentAndDoesNotDuplicateOutbox() {
|
||||
FbaPricingRepository repository = mock(FbaPricingRepository.class);
|
||||
InboundPricingEntity inbound = inbound("10", "1");
|
||||
InboundFeeEntity confirmed = savedFee("CONFIRMED");
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(confirmed);
|
||||
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_SEND", "send-once"))
|
||||
.thenReturn(null, completed("FEE_SEND", 501L));
|
||||
when(repository.sendFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1);
|
||||
when(repository.markInboundWaitingPallet(TENANT, INBOUND)).thenReturn(1);
|
||||
when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1);
|
||||
when(repository.findFee(TENANT, 501L)).thenReturn(confirmed);
|
||||
InboundFeeItemEntity item = new InboundFeeItemEntity(); item.setUnitPrice(new BigDecimal("2.00"));
|
||||
when(repository.findBaseItem(TENANT, 501L)).thenReturn(item);
|
||||
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; })
|
||||
.when(repository).insertIdempotency(any());
|
||||
|
||||
FbaPricingService service = service(repository);
|
||||
service.send(TENANT, OPERATOR, INBOUND, "send-once");
|
||||
service.send(TENANT, OPERATOR, INBOUND, "send-once");
|
||||
|
||||
verify(repository, times(1)).insertOutbox(any());
|
||||
verify(repository, times(1)).sendFee(TENANT, 501L, OPERATOR, NOW);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("同一幂等键用于不同试算时点会冲突")
|
||||
void rejectsReusedIdempotencyKeyWithDifferentRequest() {
|
||||
FbaPricingRepository repository = mock(FbaPricingRepository.class);
|
||||
InboundPricingIdempotencyEntity completed = completed("FEE_CALCULATE", 501L);
|
||||
completed.setRequestHash("different");
|
||||
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CALCULATE", "same")).thenReturn(completed);
|
||||
|
||||
assertThatThrownBy(() -> service(repository).calculate(TENANT, OPERATOR, INBOUND, NOW, "same"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(exception -> ((BizException) exception).getResultCode())
|
||||
.isEqualTo(ResultCode.CONFLICT);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已发布费用可生成新版本且不回退已入库状态")
|
||||
void adjustsPublishedFeeWithoutRollingBackInbound() {
|
||||
FbaPricingRepository repository = mock(FbaPricingRepository.class);
|
||||
InboundPricingEntity inbound = inbound("10", "1");
|
||||
inbound.setProcessStatus("INBOUNDED");
|
||||
inbound.setFeeStatus("SENT");
|
||||
InboundFeeEntity current = savedFee("SENT");
|
||||
AtomicReference<InboundFeeEntity> inserted = new AtomicReference<>();
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current);
|
||||
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_ADJUST", "adjust-1")).thenReturn(null);
|
||||
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(901L); return null; })
|
||||
.when(repository).insertIdempotency(any());
|
||||
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
|
||||
when(repository.supersedeSent(TENANT, 501L)).thenReturn(1);
|
||||
doAnswer(invocation -> {
|
||||
InboundFeeEntity fee = invocation.getArgument(0);
|
||||
fee.setId(502L);
|
||||
inserted.set(fee);
|
||||
return null;
|
||||
}).when(repository).insertFee(any());
|
||||
when(repository.completeIdempotency(any(), eq(502L), eq(NOW))).thenReturn(1);
|
||||
when(repository.findFee(TENANT, 502L)).thenAnswer(invocation -> inserted.get());
|
||||
|
||||
FeeSnapshotResponse response = service(repository).adjustPublished(TENANT, OPERATOR, INBOUND,
|
||||
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
|
||||
new BigDecimal("100.00"), new BigDecimal("20.00"), "客服修正"), "adjust-1");
|
||||
|
||||
assertThat(response.feeVersion()).isEqualTo(2);
|
||||
assertThat(response.feeStatus()).isEqualTo("SENT");
|
||||
assertThat(response.totalAmount()).isEqualByComparingTo("120.00");
|
||||
assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED");
|
||||
assertThat(inbound.getProcessStatus()).isEqualTo("INBOUNDED");
|
||||
verify(repository).supersedeSent(TENANT, 501L);
|
||||
verify(repository).insertOutbox(any());
|
||||
}
|
||||
|
||||
private FbaPricingRepository readyRepository() {
|
||||
FbaPricingRepository repository = mock(FbaPricingRepository.class);
|
||||
when(repository.findIdempotency(anyLong(), anyLong(), any(), any())).thenReturn(null);
|
||||
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; })
|
||||
.when(repository).insertIdempotency(any());
|
||||
doAnswer(invocation -> { ((InboundFeeEntity) invocation.getArgument(0)).setId(501L); return null; })
|
||||
.when(repository).insertFee(any());
|
||||
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(1);
|
||||
when(repository.markInboundCalculated(eq(TENANT), eq(INBOUND), any(), any())).thenReturn(1);
|
||||
when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1);
|
||||
return repository;
|
||||
}
|
||||
|
||||
private FbaPricingService service(FbaPricingRepository repository) {
|
||||
return new FbaPricingService(repository,
|
||||
Clock.fixed(Instant.parse("2026-08-03T02:00:00Z"), ZoneId.of("Asia/Shanghai")));
|
||||
}
|
||||
|
||||
private InboundPricingEntity inbound(String weight, String volume) {
|
||||
InboundPricingEntity value = new InboundPricingEntity();
|
||||
value.setId(INBOUND); value.setTenantId(TENANT); value.setInboundCode("RK-001");
|
||||
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.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 InboundPricingIdempotencyEntity completed(String operation, long feeId) {
|
||||
InboundPricingIdempotencyEntity value = new InboundPricingIdempotencyEntity();
|
||||
value.setId(900L); value.setOperation(operation); value.setStatus("COMPLETED");
|
||||
value.setResultResourceId(feeId);
|
||||
// 试算重复用例的摘要由服务算法固定;其余用例在首次调用后才返回该记录。
|
||||
value.setRequestHash("FEE_SEND".equals(operation)
|
||||
? "3be2051b9bc4c06a163cb3099fd7b3f828b51307cc54d3cfc232c11353c61046"
|
||||
: "8aa8a9861d4cac0c6001bb902754c152c6211ba57e1fad9160b1caef651e3d65");
|
||||
return value;
|
||||
}
|
||||
|
||||
private InboundFeeEntity savedFee(String status) {
|
||||
InboundFeeEntity value = new InboundFeeEntity();
|
||||
value.setId(501L); value.setTenantId(TENANT); value.setInboundId(INBOUND); value.setFeeNo("FBAF-30001-1");
|
||||
value.setFeeVersion(1); value.setFeeStatus(status); value.setChargeRuleId(101L);
|
||||
value.setRuleCode("CR-001"); value.setChargeRuleVersion(1); value.setCarrierRateId(201L);
|
||||
value.setRateCode("RT-001"); value.setCarrierRateVersion(1); value.setCustomerId(11L);
|
||||
value.setCustomerName("客户A"); value.setOriginWarehouseId(21L); value.setOriginWarehouseName("义乌仓");
|
||||
value.setBusinessType("FBA_SEA"); value.setChannelCode("SEA-TAX"); value.setCarrierId(31L);
|
||||
value.setCarrierName("船司A"); value.setDestinationCode("ONT8"); value.setTaxMode("TAX_INCLUDED");
|
||||
value.setChargeMode("KG"); value.setConversionFactor(new BigDecimal("167")); value.setRoundingScale(3);
|
||||
value.setRoundingMode("CEILING"); value.setActualWeightKg(new BigDecimal("10"));
|
||||
value.setActualVolumeCbm(BigDecimal.ONE); value.setConvertedQuantity(new BigDecimal("167"));
|
||||
value.setChargeableQuantity(new BigDecimal("167")); value.setChargeUnit("KG");
|
||||
value.setSubtotalAmount(new BigDecimal("334")); value.setAdditionalAmount(BigDecimal.ZERO);
|
||||
value.setTotalAmount(new BigDecimal("334")); value.setCurrency("USD"); value.setCalculatedAt(NOW);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.entity.InboundFeeEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@DisplayName("发送费用并入库状态编排")
|
||||
class InboundCompletionOrchestratorTest {
|
||||
@Test
|
||||
@DisplayName("审核通过且费用和库位引用已准备时进入INBOUNDING")
|
||||
void shouldBeginInboundAfterAllGuardsPass() {
|
||||
Fixture fixture = fixture("APPROVED", false);
|
||||
when(fixture.repository.beginInbound(10001L, 10L, 9L, 4)).thenReturn(1);
|
||||
|
||||
fixture.service.beginSendFeeAndInbound(10001L, 9L, "操作员", 10L, 30L, 4, "send-1");
|
||||
|
||||
verify(fixture.repository).beginInbound(10001L, 10L, 9L, 4);
|
||||
verify(fixture.repository).insertHistory(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测量驳回且无异常放行记录时禁止发送费用")
|
||||
void shouldBlockRejectedMeasurementWithoutRelease() {
|
||||
Fixture fixture = fixture("REJECTED", true);
|
||||
when(fixture.repository.hasExceptionRelease(10001L, 10L)).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> fixture.service.beginSendFeeAndInbound(
|
||||
10001L, 9L, "操作员", 10L, 30L, 4, "send-1"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(error -> ((BizException) error).getResultCode())
|
||||
.isEqualTo(ResultCode.CONFLICT);
|
||||
verify(fixture.repository, never()).beginInbound(anyLong(), anyLong(), anyLong(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("异常放行标记必须与费用快照一致")
|
||||
void shouldRequireFeeSnapshotReleaseFlag() {
|
||||
Fixture fixture = fixture("REJECTED", false);
|
||||
when(fixture.repository.hasExceptionRelease(10001L, 10L)).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> fixture.service.beginSendFeeAndInbound(
|
||||
10001L, 9L, "操作员", 10L, 30L, 4, "send-1"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("异常放行标记");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("完成阶段原子发送费用、完成入库并写待投递事件")
|
||||
void shouldCompleteInboundAndWriteOutbox() {
|
||||
Fixture fixture = fixture("APPROVED", false);
|
||||
fixture.order.setProcessStatus("INBOUNDING");
|
||||
when(fixture.repository.completeInbound(10001L, 10L, 9L, 5)).thenReturn(1);
|
||||
when(fixture.repository.markFeeSent(10001L, 10L, 30L, 9L)).thenReturn(1);
|
||||
|
||||
fixture.service.completeSendFeeAndInbound(
|
||||
10001L, 9L, "操作员", 10L, 30L, 5, "complete-1");
|
||||
|
||||
verify(fixture.repository).completeInbound(10001L, 10L, 9L, 5);
|
||||
verify(fixture.repository).markFeeSent(10001L, 10L, 30L, 9L);
|
||||
verify(fixture.repository).insertOutbox(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("条件更新失败时报告并发冲突且不发送费用")
|
||||
void shouldRejectConcurrentCompletion() {
|
||||
Fixture fixture = fixture("APPROVED", false);
|
||||
fixture.order.setProcessStatus("INBOUNDING");
|
||||
when(fixture.repository.completeInbound(10001L, 10L, 9L, 5)).thenReturn(0);
|
||||
|
||||
assertThatThrownBy(() -> fixture.service.completeSendFeeAndInbound(
|
||||
10001L, 9L, "操作员", 10L, 30L, 5, "complete-1"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(error -> ((BizException) error).getResultCode())
|
||||
.isEqualTo(ResultCode.CONFLICT);
|
||||
verify(fixture.repository, never()).markFeeSent(anyLong(), anyLong(), anyLong(), anyLong());
|
||||
verify(fixture.repository, never()).insertOutbox(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("只有仓库预占已补偿后才通过显式方法回退")
|
||||
void shouldRollbackOnlyInboundingState() {
|
||||
Fixture fixture = fixture("APPROVED", false);
|
||||
when(fixture.repository.rollbackInbound(10001L, 10L, 9L, 5)).thenReturn(1);
|
||||
|
||||
fixture.service.rollbackSendFeeAndInbound(
|
||||
10001L, 9L, "操作员", 10L, 5, "仓库确认失败且已释放预占", "rollback-1");
|
||||
|
||||
verify(fixture.repository).rollbackInbound(10001L, 10L, 9L, 5);
|
||||
verify(fixture.repository).insertHistory(any());
|
||||
}
|
||||
|
||||
private Fixture fixture(String reviewStatus, boolean feeRelease) {
|
||||
InboundWorkflowRepository repository = mock(InboundWorkflowRepository.class);
|
||||
InboundIdempotencyService idempotency = mock(InboundIdempotencyService.class);
|
||||
when(idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()))
|
||||
.thenReturn(new InboundIdempotencyService.Context(1L, false));
|
||||
InboundOrderEntity order = new InboundOrderEntity();
|
||||
order.setProcessStatus("FEE_CONFIRMING");
|
||||
order.setFeeStatus("CONFIRMED");
|
||||
order.setMeasureReviewStatus(reviewStatus);
|
||||
InboundFeeEntity fee = new InboundFeeEntity();
|
||||
fee.setExceptionRelease(feeRelease);
|
||||
when(repository.findOwned(10001L, 10L)).thenReturn(order);
|
||||
when(repository.findConfirmedFee(10001L, 10L, 30L)).thenReturn(fee);
|
||||
when(repository.countPreparedAllocations(10001L, 10L)).thenReturn(1L);
|
||||
return new Fixture(new InboundCompletionOrchestrator(repository, idempotency), repository, order);
|
||||
}
|
||||
|
||||
private record Fixture(InboundCompletionOrchestrator service,
|
||||
InboundWorkflowRepository repository,
|
||||
InboundOrderEntity order) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.entity.InboundIdempotencyEntity;
|
||||
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
|
||||
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.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@DisplayName("入库写操作幂等边界")
|
||||
class InboundIdempotencyServiceTest {
|
||||
@Test
|
||||
@DisplayName("同键同请求成功记录可重放")
|
||||
void shouldReplayCompletedRequest() {
|
||||
InboundWorkflowRepository repository = mock(InboundWorkflowRepository.class);
|
||||
doThrow(new DuplicateKeyException("duplicate")).when(repository).insertIdempotency(any());
|
||||
InboundIdempotencyEntity existing = existing("SUCCESS");
|
||||
when(repository.findIdempotency(10001L, "MEASURE_APPROVE", 9L, "key-1"))
|
||||
.thenReturn(existing);
|
||||
InboundIdempotencyService service = new InboundIdempotencyService(repository);
|
||||
|
||||
InboundIdempotencyService.Context context = service.begin(
|
||||
10001L, 9L, 10L, "MEASURE_APPROVE", "key-1", "10|20|3|");
|
||||
|
||||
assertThat(context.replayed()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("同键不同请求返回冲突")
|
||||
void shouldRejectDifferentRequestForSameKey() {
|
||||
InboundWorkflowRepository repository = mock(InboundWorkflowRepository.class);
|
||||
doThrow(new DuplicateKeyException("duplicate")).when(repository).insertIdempotency(any());
|
||||
InboundIdempotencyEntity existing = existing("SUCCESS");
|
||||
existing.setRequestHash("different-hash");
|
||||
when(repository.findIdempotency(10001L, "MEASURE_APPROVE", 9L, "key-1"))
|
||||
.thenReturn(existing);
|
||||
InboundIdempotencyService service = new InboundIdempotencyService(repository);
|
||||
|
||||
assertThatThrownBy(() -> service.begin(
|
||||
10001L, 9L, 10L, "MEASURE_APPROVE", "key-1", "10|20|4|"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(error -> ((BizException) error).getResultCode())
|
||||
.isEqualTo(ResultCode.CONFLICT);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("新请求完成时只允许PROCESSING条件更新一次")
|
||||
void shouldCompleteProcessingRecordOnce() {
|
||||
InboundWorkflowRepository repository = mock(InboundWorkflowRepository.class);
|
||||
when(repository.completeIdempotency(1L, "{\"inboundId\":\"10\"}")).thenReturn(1);
|
||||
InboundIdempotencyService service = new InboundIdempotencyService(repository);
|
||||
|
||||
service.complete(new InboundIdempotencyService.Context(1L, false), 10L);
|
||||
|
||||
verify(repository).completeIdempotency(1L, "{\"inboundId\":\"10\"}");
|
||||
}
|
||||
|
||||
private InboundIdempotencyEntity existing(String status) {
|
||||
InboundIdempotencyEntity record = new InboundIdempotencyEntity();
|
||||
record.setId(1L);
|
||||
record.setStatus(status);
|
||||
// 由被测服务对规范化请求计算的 SHA-256。
|
||||
record.setRequestHash("8482715c52a4611811574b1ca2dcbf031c765de767e052d4bb6d62139c10ee34");
|
||||
return record;
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.firstleg.dto.OpenApiMeasurementDtos.EventResponse;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOrderLookupEntity;
|
||||
import com.baiyue.firstleg.entity.ThirdPartyCallLogEntity;
|
||||
import com.baiyue.firstleg.repository.InboundMeasurementOpenApiRepository;
|
||||
import com.baiyue.firstleg.security.AuthenticatedMeasurementApp;
|
||||
import com.baiyue.firstleg.security.MeasurementRequestAuthenticator;
|
||||
import com.baiyue.firstleg.security.MeasurementRequestHeaders;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.validation.Validation;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.transaction.TransactionStatus;
|
||||
import org.springframework.transaction.support.TransactionCallback;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatNoException;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
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.verifyNoMoreInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@DisplayName("第三方测量开放接口服务")
|
||||
class InboundMeasurementOpenApiServiceTest {
|
||||
private static final String BODY = """
|
||||
{"eventNo":"M202608010001","warehouseCode":"CN-YW-01","inboundCode":"RK202608010001",
|
||||
"deviceCode":"DEVICE-01","measuredAt":"2026-08-01T10:30:00+08:00",
|
||||
"items":[{"itemNo":"001","lengthCm":50.20,"widthCm":40.10,"heightCm":30.00,
|
||||
"quantity":5,"boxSpecification":"50.2x40.1x30cm"}]}
|
||||
""";
|
||||
|
||||
@Test
|
||||
@DisplayName("回调只写批次明细和幂等结果,不存在入库主单update入口")
|
||||
void shouldPersistMeasurementsWithoutUpdatingInboundOrder() {
|
||||
Fixture fixture = fixture();
|
||||
InboundOrderLookupEntity inbound = inbound();
|
||||
when(fixture.repository.findInbound(10001L, "RK202608010001")).thenReturn(inbound);
|
||||
|
||||
EventResponse response = fixture.service.receive(headers("nonce-1"), BODY);
|
||||
|
||||
assertThat(response.accepted()).isTrue();
|
||||
assertThat(response.status()).isEqualTo("RECEIVED");
|
||||
verify(fixture.repository).insertMeasurement(any(InboundMeasurementEntity.class));
|
||||
verify(fixture.repository).insertItem(any());
|
||||
assertThatNoException().isThrownBy(() ->
|
||||
InboundMeasurementOpenApiRepository.class.getDeclaredMethod("findInbound", long.class, String.class));
|
||||
assertThat(InboundMeasurementOpenApiRepository.class.getDeclaredMethods())
|
||||
.noneMatch(method -> method.getName().toLowerCase().contains("updateinbound"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("重复Idempotency-Key且报文一致时返回首次结果,不重复写测量")
|
||||
void shouldReturnFirstResultForDuplicateCallback() throws Exception {
|
||||
Fixture fixture = fixture();
|
||||
ObjectMapper mapper = objectMapper();
|
||||
EventResponse first = new EventResponse("M202608010001", "RK202608010001", true,
|
||||
"RECEIVED", OffsetDateTime.parse("2026-08-01T02:30:00Z"));
|
||||
ThirdPartyCallLogEntity existing = new ThirdPartyCallLogEntity();
|
||||
existing.setRequestHash(MeasurementRequestAuthenticator.sha256Hex(BODY));
|
||||
existing.setResultJson(mapper.writeValueAsString(first));
|
||||
existing.setProcessingStatus("ACCEPTED");
|
||||
when(fixture.logs.claimIdempotency(1L)).thenReturn(false);
|
||||
when(fixture.logs.findIdempotencyOwner(10001L, "device-provider", "idem-1")).thenReturn(existing);
|
||||
|
||||
EventResponse duplicate = fixture.service.receive(headers("nonce-2"), BODY);
|
||||
|
||||
assertThat(duplicate).isEqualTo(first);
|
||||
verify(fixture.repository, never()).insertMeasurement(any());
|
||||
verify(fixture.repository, never()).insertItem(any());
|
||||
}
|
||||
|
||||
private Fixture fixture() {
|
||||
InboundMeasurementOpenApiRepository repository = mock(InboundMeasurementOpenApiRepository.class);
|
||||
MeasurementRequestAuthenticator authenticator = mock(MeasurementRequestAuthenticator.class);
|
||||
ThirdPartyCallLogService logs = mock(ThirdPartyCallLogService.class);
|
||||
TransactionTemplate transactions = mock(TransactionTemplate.class);
|
||||
when(logs.start(anyString(), anyString(), any(), anyString())).thenReturn(1L);
|
||||
when(logs.claimIdempotency(1L)).thenReturn(true);
|
||||
when(authenticator.authenticate(anyString(), anyString(), any(), anyString(), anyBoolean()))
|
||||
.thenReturn(new AuthenticatedMeasurementApp("device-provider", 10001L, 90001L,
|
||||
Map.of("CN-YW-01", 30001L)));
|
||||
when(transactions.execute(any())).thenAnswer(invocation -> {
|
||||
@SuppressWarnings("unchecked")
|
||||
TransactionCallback<EventResponse> callback = invocation.getArgument(0);
|
||||
return callback.doInTransaction(mock(TransactionStatus.class));
|
||||
});
|
||||
InboundMeasurementOpenApiService service = new InboundMeasurementOpenApiService(repository,
|
||||
authenticator, logs, objectMapper(),
|
||||
Validation.buildDefaultValidatorFactory().getValidator(), transactions);
|
||||
return new Fixture(repository, logs, service);
|
||||
}
|
||||
|
||||
private ObjectMapper objectMapper() {
|
||||
return new ObjectMapper().findAndRegisterModules();
|
||||
}
|
||||
|
||||
private InboundOrderLookupEntity inbound() {
|
||||
InboundOrderLookupEntity inbound = new InboundOrderLookupEntity();
|
||||
inbound.setId(70001L);
|
||||
inbound.setTenantId(10001L);
|
||||
inbound.setInboundCode("RK202608010001");
|
||||
inbound.setWarehouseId(30001L);
|
||||
inbound.setProcessStatus("WAIT_MEASURE");
|
||||
inbound.setDeleted(0);
|
||||
return inbound;
|
||||
}
|
||||
|
||||
private MeasurementRequestHeaders headers(String nonce) {
|
||||
return new MeasurementRequestHeaders("device-provider", "1785551400000", nonce, "signature", "idem-1");
|
||||
}
|
||||
|
||||
private record Fixture(InboundMeasurementOpenApiRepository repository,
|
||||
ThirdPartyCallLogService logs,
|
||||
InboundMeasurementOpenApiService service) {
|
||||
}
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
|
||||
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
|
||||
import com.baiyue.firstleg.entity.InboundWeightRevisionEntity;
|
||||
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
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.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@DisplayName("平台入库测量应用服务")
|
||||
class InboundMeasurementServiceTest {
|
||||
private InboundMeasurementRepository repository;
|
||||
private InboundMeasurementService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
repository = mock(InboundMeasurementRepository.class);
|
||||
service = new InboundMeasurementService(repository, mock(MeasurementBatchService.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void correctingWeightKeepsOcrOriginalAndWritesImmutableRevision() {
|
||||
InboundOrderEntity inbound = inbound("MEASURING");
|
||||
InboundWeightResultEntity weight = weight(20L, "12.500", "KG", "PENDING_CONFIRM", 4);
|
||||
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
|
||||
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
|
||||
when(repository.updateWeight(weight)).thenReturn(true);
|
||||
|
||||
service.correctWeightResult(1L, 10L, 20L, 9L, "操作员",
|
||||
new InboundMeasurementRequests.CorrectWeightResultRequest(
|
||||
4, new BigDecimal("13.200"), "kg", "秤面显示修正"));
|
||||
|
||||
assertThat(weight.getOcrWeightValue()).isEqualByComparingTo("12.500");
|
||||
assertThat(weight.getOcrWeightUnit()).isEqualTo("KG");
|
||||
assertThat(weight.getAdjustedWeightValue()).isEqualByComparingTo("13.200");
|
||||
ArgumentCaptor<InboundWeightRevisionEntity> revision =
|
||||
ArgumentCaptor.forClass(InboundWeightRevisionEntity.class);
|
||||
verify(repository).insertRevision(revision.capture());
|
||||
assertThat(revision.getValue().getRevisionNo()).isEqualTo(5);
|
||||
assertThat(revision.getValue().getBeforeWeightValue()).isEqualByComparingTo("12.500");
|
||||
assertThat(revision.getValue().getAfterWeightValue()).isEqualByComparingTo("13.200");
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailReturnsStandardPalletRecommendationWithoutAllocatingPallet() {
|
||||
when(repository.findInbound(1L, 10L)).thenReturn(inbound("MEASURE_PENDING"));
|
||||
when(repository.listWeights(1L, 10L)).thenReturn(List.of());
|
||||
|
||||
InboundMeasurementResponses.Detail result = service.detail(1L, 10L);
|
||||
|
||||
assertThat(result.recommendedPalletType()).isEqualTo("STANDARD");
|
||||
verify(repository, never()).updateInbound(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmingLbWeightConvertsToKgAndRecalculatesOnlyConfirmedImages() {
|
||||
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
|
||||
InboundWeightResultEntity current = weight(20L, "10.000", "LB", "PENDING_CONFIRM", 0);
|
||||
InboundWeightResultEntity previous = weight(21L, "2.000", "KG", "CONFIRMED", 1);
|
||||
previous.setFinalWeightKg(new BigDecimal("2.000"));
|
||||
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
|
||||
when(repository.findWeight(1L, 10L, 20L)).thenReturn(current);
|
||||
when(repository.updateWeight(current)).thenReturn(true);
|
||||
when(repository.listWeights(1L, 10L)).thenReturn(List.of(current, previous));
|
||||
when(repository.updateInbound(inbound)).thenReturn(true);
|
||||
|
||||
InboundMeasurementResponses.WeightResult response = service.confirmWeightResult(
|
||||
1L, 10L, 20L, 9L, "操作员",
|
||||
new InboundMeasurementRequests.ConfirmWeightResultRequest(0));
|
||||
|
||||
assertThat(response.finalWeightKg()).isEqualByComparingTo("4.536");
|
||||
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("6.536");
|
||||
assertThat(inbound.getProcessStatus()).isEqualTo("MEASURING");
|
||||
verify(repository).insertRevision(any(InboundWeightRevisionEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitAdvancesOnlyToMeasurePendingAfterAllWeightsConfirmed() {
|
||||
InboundOrderEntity inbound = inbound("MEASURING");
|
||||
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
|
||||
measurement.setId(30L);
|
||||
measurement.setTenantId(1L);
|
||||
measurement.setInboundId(10L);
|
||||
measurement.setStatus("DRAFT");
|
||||
measurement.setVersion(2);
|
||||
measurement.setTotalPackageCount(3);
|
||||
measurement.setTotalVolumeCbm(new BigDecimal("1.2345"));
|
||||
measurement.setVolumeWeightKg(new BigDecimal("206.162"));
|
||||
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
|
||||
item.setSourceImageFileId(500L);
|
||||
InboundWeightResultEntity weight = weight(20L, "20.000", "KG", "CONFIRMED", 1);
|
||||
weight.setFinalWeightKg(new BigDecimal("20.000"));
|
||||
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
|
||||
when(repository.findMeasurement(1L, 10L, 30L)).thenReturn(measurement);
|
||||
when(repository.listItems(1L, 30L)).thenReturn(List.of(item));
|
||||
when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight));
|
||||
when(repository.updateMeasurement(measurement)).thenReturn(true);
|
||||
when(repository.updateInbound(inbound)).thenReturn(true);
|
||||
|
||||
InboundMeasurementResponses.MeasurementSubmitted result = service.submit(
|
||||
1L, 10L, 9L, new InboundMeasurementRequests.SubmitRequest(30L, 2));
|
||||
|
||||
assertThat(result.processStatus()).isEqualTo("MEASURE_PENDING");
|
||||
assertThat(result.recommendedPalletType()).isEqualTo("STANDARD");
|
||||
assertThat(inbound.getMeasureReviewStatus()).isEqualTo("PENDING");
|
||||
assertThat(measurement.getStatus()).isEqualTo("SUBMITTED");
|
||||
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("20.000");
|
||||
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("1.2345");
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitRejectsAnyUnconfirmedWeightWithoutChangingState() {
|
||||
InboundOrderEntity inbound = inbound("MEASURING");
|
||||
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
|
||||
measurement.setId(30L);
|
||||
measurement.setStatus("DRAFT");
|
||||
measurement.setVersion(0);
|
||||
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
|
||||
item.setSourceImageFileId(500L);
|
||||
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
|
||||
when(repository.findMeasurement(1L, 10L, 30L)).thenReturn(measurement);
|
||||
when(repository.listItems(1L, 30L)).thenReturn(List.of(item));
|
||||
when(repository.listWeights(1L, 10L)).thenReturn(List.of(
|
||||
weight(20L, "20", "KG", "PENDING_CONFIRM", 0)));
|
||||
|
||||
assertThatThrownBy(() -> service.submit(1L, 10L, 9L,
|
||||
new InboundMeasurementRequests.SubmitRequest(30L, 0)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("必须先完成确认");
|
||||
verify(repository, never()).updateMeasurement(any());
|
||||
verify(repository, never()).updateInbound(any());
|
||||
}
|
||||
|
||||
private InboundOrderEntity inbound(String status) {
|
||||
InboundOrderEntity inbound = new InboundOrderEntity();
|
||||
inbound.setId(10L);
|
||||
inbound.setTenantId(1L);
|
||||
inbound.setProcessStatus(status);
|
||||
inbound.setVersion(0);
|
||||
return inbound;
|
||||
}
|
||||
|
||||
private InboundWeightResultEntity weight(long id, String value, String unit,
|
||||
String status, int version) {
|
||||
InboundWeightResultEntity weight = new InboundWeightResultEntity();
|
||||
weight.setId(id);
|
||||
weight.setTenantId(1L);
|
||||
weight.setInboundId(10L);
|
||||
weight.setFileId(id + 100L);
|
||||
weight.setOcrWeightValue(new BigDecimal(value));
|
||||
weight.setOcrWeightUnit(unit);
|
||||
weight.setRecognitionStatus(status);
|
||||
weight.setVersion(version);
|
||||
return weight;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.domain.InboundListTab;
|
||||
import com.baiyue.firstleg.dto.InboundOrderDetail;
|
||||
import com.baiyue.firstleg.dto.InboundOrderQuery;
|
||||
import com.baiyue.firstleg.dto.InboundOrderSummary;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.repository.InboundOrderRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
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 InboundOrderQueryServiceTest {
|
||||
@Test
|
||||
void shouldKeepV2TabMappingWithoutCustomerConfirmation() {
|
||||
assertThat(InboundListTab.WAIT_MEASURE.processStatuses())
|
||||
.containsExactly("WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
|
||||
assertThat(InboundListTab.WAIT_REVIEW.processStatuses()).containsExactly("MEASURE_PENDING");
|
||||
assertThat(InboundListTab.WAIT_FEE_SEND.processStatuses())
|
||||
.containsExactly("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING");
|
||||
assertThat(InboundListTab.INBOUNDED.processStatuses()).containsExactly("INBOUNDED");
|
||||
assertThat(List.of(InboundListTab.values())).extracting(Enum::name)
|
||||
.doesNotContain("WAIT_CUSTOMER_CONFIRM");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectConflictingTabAndStatus() {
|
||||
InboundOrderRepository repository = mock(InboundOrderRepository.class);
|
||||
InboundOrderQuery query = query("WAIT_MEASURE", InboundListTab.WAIT_MEASURE, null, null);
|
||||
assertThatThrownBy(() -> new InboundOrderQueryService(repository).page(10001L, query))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(exception -> ((BizException) exception).getResultCode())
|
||||
.isEqualTo(ResultCode.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectReversedArrivalRange() {
|
||||
InboundOrderRepository repository = mock(InboundOrderRepository.class);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
assertThatThrownBy(() -> new InboundOrderQueryService(repository)
|
||||
.page(10001L, query(null, null, now, now.minusDays(1))))
|
||||
.isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldLoadDetailOnlyAfterTenantOwnedOrderExists() {
|
||||
InboundOrderRepository repository = mock(InboundOrderRepository.class);
|
||||
InboundOrderEntity order = new InboundOrderEntity();
|
||||
order.setId(9L);
|
||||
InboundOrderDetail detail = new InboundOrderDetail(null, null, List.of(), List.of(), List.of(), List.of());
|
||||
when(repository.findOwned(10001L, 9L)).thenReturn(order);
|
||||
when(repository.detail(10001L, order)).thenReturn(detail);
|
||||
|
||||
assertThat(new InboundOrderQueryService(repository).detail(10001L, 9L)).isSameAs(detail);
|
||||
verify(repository).findOwned(10001L, 9L);
|
||||
verify(repository).detail(10001L, order);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnBusinessNotFoundForCrossTenantOrMissingOrder() {
|
||||
InboundOrderRepository repository = mock(InboundOrderRepository.class);
|
||||
assertThatThrownBy(() -> new InboundOrderQueryService(repository).detail(10002L, 9L))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(exception -> ((BizException) exception).getResultCode())
|
||||
.isEqualTo(ResultCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
private static InboundOrderQuery query(String status, InboundListTab tab,
|
||||
LocalDateTime from, LocalDateTime to) {
|
||||
return new InboundOrderQuery(null, null, status, null, null, null, null, tab,
|
||||
from, to, null, null, null, null, null, null, null, 1, 20);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.MeasurementBatchCommand;
|
||||
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.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
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.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@DisplayName("测量批次共享领域服务")
|
||||
class MeasurementBatchServiceTest {
|
||||
private InboundMeasurementRepository repository;
|
||||
private MeasurementBatchService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
repository = mock(InboundMeasurementRepository.class);
|
||||
service = new MeasurementBatchService(repository);
|
||||
when(repository.findInbound(1L, 10L)).thenReturn(inbound());
|
||||
org.mockito.Mockito.doAnswer(invocation -> {
|
||||
InboundMeasurementEntity entity = invocation.getArgument(0);
|
||||
entity.setId(100L);
|
||||
return null;
|
||||
}).when(repository).insertMeasurement(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void manualAndExternalUseSameTablesAndExternalSaveNeverAdvancesInbound() {
|
||||
MeasurementBatchCommand command = new MeasurementBatchCommand(
|
||||
1L, 10L, null, 0, "THIRD_PARTY", "DEVICE_VENDOR", "EVENT-001",
|
||||
"D-01", "YW-01", LocalDateTime.now(), "{}", null, null, null,
|
||||
null, 0L, List.of(item(null)));
|
||||
|
||||
InboundMeasurementEntity saved = service.saveBatch(command);
|
||||
|
||||
assertThat(saved.getSourceType()).isEqualTo("THIRD_PARTY");
|
||||
assertThat(saved.getStatus()).isEqualTo("RECEIVED");
|
||||
assertThat(saved.getTotalPackageCount()).isEqualTo(2);
|
||||
ArgumentCaptor<List<InboundMeasurementItemEntity>> items = ArgumentCaptor.forClass(List.class);
|
||||
verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L),
|
||||
org.mockito.ArgumentMatchers.eq(100L), items.capture());
|
||||
assertThat(items.getValue().get(0).getUnitVolumeCbm()).isEqualByComparingTo("0.0240");
|
||||
assertThat(items.getValue().get(0).getTotalVolumeCbm()).isEqualByComparingTo("0.0480");
|
||||
assertThat(items.getValue().get(0).getGirthCm()).isEqualByComparingTo("140.000");
|
||||
assertThat(items.getValue().get(0).getUnitWeightKg()).isNull();
|
||||
verify(repository, never()).updateInbound(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void manualDraftCalculatesWeightVolumeAndVolumeWeightOnServer() {
|
||||
MeasurementBatchCommand command = new MeasurementBatchCommand(
|
||||
1L, 10L, null, 0, "MANUAL", "PLATFORM", null, null, null,
|
||||
LocalDateTime.now(), null, "CKW-1", new BigDecimal("20"),
|
||||
new BigDecimal("0.1"), "服装", 9L, List.of(item(new BigDecimal("3.25"))));
|
||||
|
||||
InboundMeasurementEntity saved = service.saveBatch(command);
|
||||
|
||||
assertThat(saved.getStatus()).isEqualTo("DRAFT");
|
||||
assertThat(saved.getTotalVolumeCbm()).isEqualByComparingTo("0.0480");
|
||||
assertThat(saved.getVolumeWeightKg()).isEqualByComparingTo("8.016");
|
||||
ArgumentCaptor<List<InboundMeasurementItemEntity>> items = ArgumentCaptor.forClass(List.class);
|
||||
verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L),
|
||||
org.mockito.ArgumentMatchers.eq(100L), items.capture());
|
||||
assertThat(items.getValue().get(0).getTotalWeightKg()).isEqualByComparingTo("6.500");
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleDraftVersionIsRejectedBeforeReplacingItems() {
|
||||
InboundMeasurementEntity existing = new InboundMeasurementEntity();
|
||||
existing.setId(100L);
|
||||
existing.setTenantId(1L);
|
||||
existing.setInboundId(10L);
|
||||
existing.setSourceType("MANUAL");
|
||||
existing.setStatus("DRAFT");
|
||||
existing.setVersion(3);
|
||||
when(repository.findMeasurement(1L, 10L, 100L)).thenReturn(existing);
|
||||
MeasurementBatchCommand command = new MeasurementBatchCommand(
|
||||
1L, 10L, 100L, 2, "MANUAL", "PLATFORM", null, null, null,
|
||||
LocalDateTime.now(), null, null, null, null, null, 9L,
|
||||
List.of(item(BigDecimal.ONE)));
|
||||
|
||||
assertThatThrownBy(() -> service.saveBatch(command))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("其他人修改");
|
||||
verify(repository, never()).replaceItems(
|
||||
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), any());
|
||||
}
|
||||
|
||||
private MeasurementBatchCommand.Item item(BigDecimal unitWeight) {
|
||||
return new MeasurementBatchCommand.Item(
|
||||
"001", "A", 2, new BigDecimal("40"), new BigDecimal("30"),
|
||||
new BigDecimal("20"), unitWeight, 500L, null,
|
||||
new BigDecimal("41"), new BigDecimal("31"), new BigDecimal("21"), "备注");
|
||||
}
|
||||
|
||||
private InboundOrderEntity inbound() {
|
||||
InboundOrderEntity inbound = new InboundOrderEntity();
|
||||
inbound.setId(10L);
|
||||
inbound.setTenantId(1L);
|
||||
inbound.setProcessStatus("WAIT_MEASURE");
|
||||
inbound.setVersion(0);
|
||||
return inbound;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
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.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@DisplayName("入库测量审核服务")
|
||||
class MeasurementReviewServiceTest {
|
||||
@Test
|
||||
@DisplayName("审核通过是测量到计费的唯一正常推进关口")
|
||||
void shouldApproveOnlyPendingSubmittedMeasurement() {
|
||||
Fixture fixture = fixture(false);
|
||||
when(fixture.repository.transitionReview(10001L, 10L, 9L, 3, true)).thenReturn(1);
|
||||
when(fixture.repository.transitionMeasurement(10001L, 10L, 20L, "APPROVED")).thenReturn(1);
|
||||
|
||||
fixture.service.approve(10001L, 9L, "审核员", 10L, "approve-1",
|
||||
new MeasurementReviewRequest(20L, 3, null));
|
||||
|
||||
verify(fixture.repository).transitionReview(10001L, 10L, 9L, 3, true);
|
||||
verify(fixture.repository).transitionMeasurement(10001L, 10L, 20L, "APPROVED");
|
||||
verify(fixture.repository).insertReview(any());
|
||||
verify(fixture.repository).insertHistory(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("驳回原因必填且状态推进为测量驳回")
|
||||
void shouldRequireRejectReason() {
|
||||
Fixture fixture = fixture(false);
|
||||
assertThatThrownBy(() -> fixture.service.reject(10001L, 9L, "审核员", 10L, "reject-1",
|
||||
new MeasurementReviewRequest(20L, 3, " ")))
|
||||
.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 shouldReplaySameReviewRequest() {
|
||||
Fixture fixture = fixture(false);
|
||||
when(fixture.idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()))
|
||||
.thenReturn(new InboundIdempotencyService.Context(1L, true));
|
||||
|
||||
fixture.service.approve(10001L, 9L, "审核员", 10L, "approve-1",
|
||||
new MeasurementReviewRequest(20L, 3, null));
|
||||
|
||||
verify(fixture.repository, never()).findOwned(anyLong(), anyLong());
|
||||
verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("开关关闭时驳回单不能异常放行")
|
||||
void shouldBlockExceptionReleaseByDefault() {
|
||||
Fixture fixture = fixture(false);
|
||||
assertThatThrownBy(() -> fixture.service.exceptionRelease(10001L, 9L, "主管", 10L,
|
||||
"release-1", new ExceptionReleaseRequest(20L, 3, "客户确认紧急入库")))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(error -> ((BizException) error).getResultCode())
|
||||
.isEqualTo(ResultCode.CONFLICT);
|
||||
verify(fixture.repository, never()).releaseRejected(anyLong(), anyLong(), anyLong(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("开关开启时仅放行已驳回单并保留驳回事实")
|
||||
void shouldReleaseRejectedMeasurementWithHistory() {
|
||||
Fixture fixture = fixture(true);
|
||||
fixture.order.setProcessStatus("MEASURE_REJECTED");
|
||||
fixture.order.setMeasureReviewStatus("REJECTED");
|
||||
fixture.measurement.setStatus("REJECTED");
|
||||
when(fixture.repository.releaseRejected(10001L, 10L, 9L, 3)).thenReturn(1);
|
||||
|
||||
fixture.service.exceptionRelease(10001L, 9L, "主管", 10L, "release-1",
|
||||
new ExceptionReleaseRequest(20L, 3, "紧急放行"));
|
||||
|
||||
verify(fixture.repository).releaseRejected(10001L, 10L, 9L, 3);
|
||||
verify(fixture.repository, never()).transitionMeasurement(anyLong(), anyLong(), anyLong(), anyString());
|
||||
verify(fixture.repository).insertReview(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("并发审核只有一个条件更新成功")
|
||||
void shouldAllowOnlyOneConcurrentReview() throws Exception {
|
||||
Fixture fixture = fixture(false);
|
||||
AtomicInteger attempts = new AtomicInteger();
|
||||
CountDownLatch bothArrived = new CountDownLatch(2);
|
||||
when(fixture.repository.transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean()))
|
||||
.thenAnswer(invocation -> {
|
||||
bothArrived.countDown();
|
||||
bothArrived.await();
|
||||
return attempts.getAndIncrement() == 0 ? 1 : 0;
|
||||
});
|
||||
when(fixture.repository.transitionMeasurement(anyLong(), anyLong(), anyLong(), anyString())).thenReturn(1);
|
||||
var executor = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Callable<String> call = () -> {
|
||||
try {
|
||||
fixture.service.approve(10001L, 9L, "审核员", 10L,
|
||||
"approve-" + Thread.currentThread().getId(),
|
||||
new MeasurementReviewRequest(20L, 3, null));
|
||||
return "SUCCESS";
|
||||
} catch (BizException error) {
|
||||
return error.getResultCode().name();
|
||||
}
|
||||
};
|
||||
List<Future<String>> results = executor.invokeAll(List.of(call, call));
|
||||
assertThat(results.get(0).get() + "," + results.get(1).get())
|
||||
.contains("SUCCESS").contains("CONFLICT");
|
||||
assertThat(attempts).hasValue(2);
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private Fixture fixture(boolean allowRelease) {
|
||||
InboundWorkflowRepository repository = mock(InboundWorkflowRepository.class);
|
||||
InboundIdempotencyService idempotency = mock(InboundIdempotencyService.class);
|
||||
when(idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()))
|
||||
.thenReturn(new InboundIdempotencyService.Context(1L, false));
|
||||
InboundProperties properties = new InboundProperties();
|
||||
properties.setAllowRejectedMeasurement(allowRelease);
|
||||
InboundOrderEntity order = new InboundOrderEntity();
|
||||
order.setProcessStatus("MEASURE_PENDING");
|
||||
order.setMeasureReviewStatus("PENDING");
|
||||
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
|
||||
measurement.setStatus("SUBMITTED");
|
||||
when(repository.findOwned(10001L, 10L)).thenReturn(order);
|
||||
when(repository.findMeasurement(10001L, 10L, 20L)).thenReturn(measurement);
|
||||
return new Fixture(new MeasurementReviewService(repository, idempotency, properties),
|
||||
repository, idempotency, order, measurement);
|
||||
}
|
||||
|
||||
private record Fixture(MeasurementReviewService service, InboundWorkflowRepository repository,
|
||||
InboundIdempotencyService idempotency, InboundOrderEntity order,
|
||||
InboundMeasurementEntity measurement) {
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
|
||||
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class MeasurementSubmissionServiceTest {
|
||||
private final InboundMeasurementService measurements = mock(InboundMeasurementService.class);
|
||||
private final MeasurementLocationOccupationService locations = mock(MeasurementLocationOccupationService.class);
|
||||
private final MeasurementSubmissionService service = new MeasurementSubmissionService(measurements, locations);
|
||||
|
||||
@Test
|
||||
void 测量提交与库位占用处于同一事务边界() throws Exception {
|
||||
assertThat(MeasurementSubmissionService.class
|
||||
.getDeclaredMethod("submit", long.class, long.class, long.class,
|
||||
InboundMeasurementRequests.SubmitRequest.class)
|
||||
.getAnnotation(Transactional.class)).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 测量完成后立即占用库位再返回待客服审核() {
|
||||
var request = new InboundMeasurementRequests.SubmitRequest(30L, 2);
|
||||
var submitted = new InboundMeasurementResponses.MeasurementSubmitted(
|
||||
30L, 3, "MEASURE_PENDING", "PENDING", "STANDARD", null, null, null);
|
||||
when(measurements.submit(10001L, 88L, 9L, request)).thenReturn(submitted);
|
||||
|
||||
assertThat(service.submit(10001L, 88L, 9L, request)).isSameAs(submitted);
|
||||
|
||||
verify(locations).occupy(10001L, 88L, 9L, 30L, 3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 库位占用失败向上抛出以触发测量事务回滚() {
|
||||
var request = new InboundMeasurementRequests.SubmitRequest(30L, 2);
|
||||
var submitted = new InboundMeasurementResponses.MeasurementSubmitted(
|
||||
30L, 3, "MEASURE_PENDING", "PENDING", "STANDARD", null, null, null);
|
||||
when(measurements.submit(10001L, 88L, 9L, request)).thenReturn(submitted);
|
||||
doThrow(new IllegalStateException("warehouse unavailable"))
|
||||
.when(locations).occupy(10001L, 88L, 9L, 30L, 3);
|
||||
|
||||
assertThatThrownBy(() -> service.submit(10001L, 88L, 9L, request))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
|
||||
import com.baiyue.firstleg.dto.InboundOrderDetail;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.repository.InboundOrderRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class MerchantInboundBillingServiceTest {
|
||||
private final InboundOrderRepository orders = mock(InboundOrderRepository.class);
|
||||
private final FbaPricingService pricing = mock(FbaPricingService.class);
|
||||
private final MerchantInboundBillingService service = new MerchantInboundBillingService(orders, pricing);
|
||||
|
||||
@Test
|
||||
void 客户只能看到本租户已完成入库且已发布的费用() {
|
||||
InboundOrderEntity inbound = inbound("INBOUNDED", "SENT");
|
||||
InboundOrderDetail detail = mock(InboundOrderDetail.class);
|
||||
FeeSnapshotResponse fee = mock(FeeSnapshotResponse.class);
|
||||
when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound);
|
||||
when(orders.detail(10001L, inbound)).thenReturn(detail);
|
||||
when(pricing.latest(10001L, 88L)).thenReturn(fee);
|
||||
|
||||
var result = service.find(10001L, 77L);
|
||||
|
||||
assertThat(result.inbound()).isSameAs(detail);
|
||||
assertThat(result.latestFee()).isSameAs(fee);
|
||||
verify(orders).findByOrderId(10001L, 77L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 客服尚未完成发布时客户不可见中间费用() {
|
||||
InboundOrderEntity inbound = inbound("FEE_CONFIRMING", "CONFIRMED");
|
||||
when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound);
|
||||
|
||||
assertThatThrownBy(() -> service.find(10001L, 77L))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("尚未由客服审核并发布");
|
||||
verify(pricing, never()).latest(10001L, 88L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 使用租户条件查询防止跨租户读取订单费用() {
|
||||
when(orders.findByOrderId(20002L, 77L)).thenReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> service.find(20002L, 77L)).isInstanceOf(BizException.class);
|
||||
|
||||
verify(orders).findByOrderId(20002L, 77L);
|
||||
verify(pricing, never()).latest(20002L, 88L);
|
||||
}
|
||||
|
||||
private static InboundOrderEntity inbound(String process, String feeStatus) {
|
||||
InboundOrderEntity inbound = new InboundOrderEntity();
|
||||
inbound.setId(88L);
|
||||
inbound.setProcessStatus(process);
|
||||
inbound.setFeeStatus(feeStatus);
|
||||
return inbound;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user