diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java index 7da941d3a..80ced992c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java @@ -15,7 +15,7 @@ public class CustomsDispatchGateContributor implements OutboundDispatchGateContr @Override public GateItem assess(Context context) { - if (context.tenantId() <= 0 || context.customerId() <= 0 || context.tenantId() != context.customerId()) { + if (context.tenantId() <= 0 || context.customerId() <= 0) { return gate(false, "INVALID_CUSTOMER_SCOPE"); } CustomsCaseEntity value = repository.gateCase(context.tenantId(), context.orderId(), context.inboundId(), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java index 85e33f742..5c283b841 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java @@ -17,7 +17,7 @@ public final class LoadingListDtos { public record PreloadQuery( @Size(max = 128) String keyword, String status, - @NotNull @Min(1) Long warehouseId, + @Min(1) Long warehouseId, LocalDateTime createdFrom, LocalDateTime createdTo, @Min(1) long page, @@ -31,7 +31,7 @@ public final class LoadingListDtos { public record ConfirmedQuery( @Size(max = 128) String keyword, - @NotNull @Min(1) Long warehouseId, + @Min(1) Long warehouseId, LocalDateTime createdFrom, LocalDateTime createdTo, @Min(1) long page, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java index 5458d4847..c519670c0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java @@ -39,25 +39,25 @@ public interface OutboundDispatchReadMapper { SELECT booking.id,booking.status FROM fl_ocean_booking_container_allocation allocation JOIN fl_ocean_booking booking ON booking.id=allocation.ocean_booking_id - WHERE allocation.container_booking_id=#{containerBookingId} AND booking.customer_id=#{customerId} + WHERE allocation.container_booking_id=#{containerBookingId} AND booking.customer_id=#{tenantId} ORDER BY booking.updated_at DESC,booking.id DESC LIMIT 1 """) - IdStatus ocean(@Param("containerBookingId") long containerBookingId, @Param("customerId") long customerId); + IdStatus ocean(@Param("containerBookingId") long containerBookingId, @Param("tenantId") long tenantId); @Select(""" SELECT id,status FROM fl_truck_appointment WHERE container_booking_id=#{containerBookingId} AND loading_plan_id=#{loadingPlanId} - AND customer_id=#{customerId} + AND customer_id=#{tenantId} ORDER BY updated_at DESC,id DESC LIMIT 1 """) IdStatus truck(@Param("containerBookingId") long containerBookingId, @Param("loadingPlanId") long loadingPlanId, - @Param("customerId") long customerId); + @Param("tenantId") long tenantId); @Select(""" SELECT COUNT(DISTINCT CASE WHEN status='ACTIVE' AND file_id IS NOT NULL AND evidence_type IN ('PHOTO_STEP_1','PHOTO_STEP_2','PHOTO_STEP_3','PHOTO_STEP_4','SEAL') THEN evidence_type END) evidenceCount, MAX(CASE WHEN status='ACTIVE' AND evidence_type='SEAL' AND file_id IS NOT NULL AND seal_no IS NOT NULL - AND seal_no<>'' THEN 1 ELSE 0 END) validSeal + AND seal_no<>'' THEN 1 ELSE 0 END) validSeal FROM fl_loading_evidence WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} """) Evidence evidence(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java index c0d53c5d3..dc882f01c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java @@ -26,11 +26,11 @@ public class OutboundDispatchRepository { return reads.bindings(query.orderId(), query.inboundId(), query.containerBookingId()); } public OutboundDispatchReadMapper.IdStatus pick(long planId) { return reads.pick(planId); } - public OutboundDispatchReadMapper.IdStatus ocean(long containerId, long customerId) { - return reads.ocean(containerId, customerId); + public OutboundDispatchReadMapper.IdStatus ocean(long containerId, long tenantId) { + return reads.ocean(containerId, tenantId); } - public OutboundDispatchReadMapper.IdStatus truck(long containerId, long planId, long customerId) { - return reads.truck(containerId, planId, customerId); + public OutboundDispatchReadMapper.IdStatus truck(long containerId, long planId, long tenantId) { + return reads.truck(containerId, planId, tenantId); } public OutboundDispatchReadMapper.Evidence evidence(long tenantId, long inboundId) { return reads.evidence(tenantId, inboundId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java index 0c389fd05..c90c6e4e7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java @@ -102,8 +102,8 @@ public class OutboundDispatchService { private Aggregate aggregate(OutboundDispatchReadMapper.Binding binding) { var pick = repository.pick(binding.loadingPlanId()); - var ocean = repository.ocean(binding.containerBookingId(), binding.customerId()); - var truck = repository.truck(binding.containerBookingId(), binding.loadingPlanId(), binding.customerId()); + var ocean = repository.ocean(binding.containerBookingId(), binding.tenantId()); + var truck = repository.truck(binding.containerBookingId(), binding.loadingPlanId(), binding.tenantId()); var evidence = repository.evidence(binding.tenantId(), binding.inboundId()); var dispatch = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId()); GateResult gate = gate(binding, pick, ocean, truck, evidence); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/PortScheduleDispatchGateContributor.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/PortScheduleDispatchGateContributor.java index e9de77cb4..09b3fdcd5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/PortScheduleDispatchGateContributor.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/PortScheduleDispatchGateContributor.java @@ -21,7 +21,7 @@ public class PortScheduleDispatchGateContributor implements OutboundDispatchGate @Override public GateItem assess(Context context) { - if (context.tenantId() <= 0 || context.customerId() <= 0 || context.tenantId() != context.customerId()) { + if (context.tenantId() <= 0 || context.customerId() <= 0) { return gate(false, "INVALID_CUSTOMER_SCOPE"); } if (context.oceanBookingId() == null || context.oceanBookingId() <= 0) { diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index 1c66ee379..38d0bea12 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -7,7 +7,7 @@ paths: /api/platform/v1/first-leg/loading-plans: get: summary: 分页查询预装清单 - description: 仓库作业清单必须按仓库查询,返回该仓库全部客户的装柜计划;客户账号仅用于展示货物归属。 + description: 默认返回全部仓库的装柜计划;可按仓库筛选,客户账号仅用于展示货物归属。 x-permission: platform:firstleg:preload:list parameters: - {$ref: '#/components/parameters/Page'} @@ -17,7 +17,7 @@ paths: in: query description: 预装状态 schema: {type: string, enum: [LOADING]} - - {$ref: '#/components/parameters/WarehouseId'} + - {$ref: '#/components/parameters/OptionalWarehouseId'} - {$ref: '#/components/parameters/CreatedFrom'} - {$ref: '#/components/parameters/CreatedTo'} responses: @@ -49,13 +49,13 @@ paths: /api/platform/v1/first-leg/loading-lists: get: summary: 分页查询装柜清单 - description: 必须按仓库查询,只返回该仓库的 COMPLETED 装柜计划,包含全部客户单据。 + description: 默认返回全部仓库的 COMPLETED 装柜计划;可按仓库筛选,包含全部客户单据。 x-permission: platform:firstleg:loading-list:list parameters: - {$ref: '#/components/parameters/Page'} - {$ref: '#/components/parameters/Size'} - {$ref: '#/components/parameters/Keyword'} - - {$ref: '#/components/parameters/WarehouseId'} + - {$ref: '#/components/parameters/OptionalWarehouseId'} - {$ref: '#/components/parameters/CreatedFrom'} - {$ref: '#/components/parameters/CreatedTo'} responses: @@ -103,6 +103,12 @@ components: required: true description: 仓库 ID;同一仓库内全部客户单据均可见 schema: {$ref: '#/components/schemas/LongId'} + OptionalWarehouseId: + name: warehouseId + in: query + required: false + description: 可选仓库 ID;不传时查询全部仓库 + schema: {$ref: '#/components/schemas/LongId'} CreatedFrom: name: createdFrom in: query diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index f754f83e9..5d023612c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -13,9 +13,12 @@ import org.springframework.core.io.ClassPathResource; import org.springframework.security.access.prepost.PreAuthorize; import java.lang.reflect.Method; +import java.lang.reflect.RecordComponent; import java.nio.charset.StandardCharsets; import java.util.Map; +import jakarta.validation.constraints.NotNull; + import static org.assertj.core.api.Assertions.assertThat; @DisplayName("预装与装柜清单控制器契约") @@ -70,6 +73,12 @@ class LoadingListControllerContractTest { .noneMatch(method -> method.getName().equals("complete")); } + @Test + void listWarehouseFilterIsOptional() { + assertThat(component(PreloadQuery.class, "warehouseId").getAnnotation(NotNull.class)).isNull(); + assertThat(component(ConfirmedQuery.class, "warehouseId").getAnnotation(NotNull.class)).isNull(); + } + @Test void yamlParses() throws Exception { String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml") @@ -108,6 +117,13 @@ class LoadingListControllerContractTest { return stringMap(stringMap(schemas.get(schemaName)).get("properties")); } + private static RecordComponent component(Class type, String name) { + return java.util.Arrays.stream(type.getRecordComponents()) + .filter(component -> component.getName().equals(name)) + .findFirst() + .orElseThrow(); + } + @SuppressWarnings("unchecked") private static Map stringMap(Object value) { return (Map) value; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java index 2036e89bf..25644bae5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java @@ -16,9 +16,9 @@ class CustomsDispatchGateContributorTest { @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } - @Test void failsClosedWhenCaseMissingOrScopeMismatches() { + @Test void failsClosedWhenCaseMissingOrScopeIsInvalid() { assertThat(contributor.assess(context()).passed()).isFalse(); - assertThat(contributor.assess(new Context(10001L, 10002L, 21L, 31L, 81L, 91L, null)).actual()) + assertThat(contributor.assess(new Context(0L, 10002L, 21L, 31L, 81L, 91L, null)).actual()) .isEqualTo("INVALID_CUSTOMER_SCOPE"); } @@ -30,5 +30,5 @@ class CustomsDispatchGateContributorTest { assertThat(contributor.assess(context()).passed()).isTrue(); } - private static Context context() { return new Context(10001L, 10001L, 21L, 31L, 81L, 91L, null); } + private static Context context() { return new Context(10001L, 10002L, 21L, 31L, 81L, 91L, null); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java index 94940c43e..3ba8de3e7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java @@ -16,4 +16,25 @@ class OutboundDispatchReadMapperContractTest { .contains("ORDER BY started_at DESC,id DESC") .doesNotContain("created_at"); } + + @Test + void evidenceQueryUsesNativeSqlComparisonInsteadOfXmlEntities() throws Exception { + String sql = OutboundDispatchReadMapper.class.getMethod("evidence", long.class, long.class) + .getAnnotation(Select.class).value()[0]; + + assertThat(sql) + .contains("seal_no<>''") + .doesNotContain("<", ">"); + } + + @Test + void oceanAndTruckQueriesUseTenantIsolationKey() throws Exception { + String oceanSql = OutboundDispatchReadMapper.class.getMethod("ocean", long.class, long.class) + .getAnnotation(Select.class).value()[0]; + String truckSql = OutboundDispatchReadMapper.class.getMethod("truck", long.class, long.class, long.class) + .getAnnotation(Select.class).value()[0]; + + assertThat(oceanSql).contains("booking.customer_id=#{tenantId}").doesNotContain("#{customerId}"); + assertThat(truckSql).contains("customer_id=#{tenantId}").doesNotContain("#{customerId}"); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java index 4965dd26f..d8fe59eee 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java @@ -32,6 +32,19 @@ class OutboundDispatchServiceTest { .extracting(GateItem::code).containsExactlyInAnyOrder("CUSTOMS_CLEARANCE", "SAILING_SCHEDULE"); } + @Test + void aggregateUsesTenantKeyWhenCustomerEntityIdDiffers() { + OutboundDispatchService service = new OutboundDispatchService(repository, List.of()); + stubReadyChain(); + + service.checkGate(new Query(null, 11L, 22L)); + + verify(repository).ocean(22L, 7L); + verify(repository).truck(22L, 44L, 7L); + verify(repository, never()).ocean(22L, 8L); + verify(repository, never()).truck(22L, 44L, 8L); + } + @Test void confirmAdvancesDispatchWithoutMutatingLoadedInbound() { OutboundDispatchService service = new OutboundDispatchService(repository, @@ -109,8 +122,8 @@ class OutboundDispatchServiceTest { private void stubReadyChain() { when(repository.bindings(any())).thenReturn(List.of(binding())); when(repository.pick(44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(55L, "COMPLETED")); - when(repository.ocean(22L, 8L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(66L, "RELEASED")); - when(repository.truck(22L, 44L, 8L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "DEPARTED")); + when(repository.ocean(22L, 7L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(66L, "RELEASED")); + when(repository.truck(22L, 44L, 7L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "DEPARTED")); when(repository.evidence(7L, 11L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1)); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/PortScheduleDispatchGateContributorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/PortScheduleDispatchGateContributorTest.java index 1143a9533..d0525672e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/PortScheduleDispatchGateContributorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/PortScheduleDispatchGateContributorTest.java @@ -25,11 +25,11 @@ class PortScheduleDispatchGateContributorTest { @Test void usesFixedCodeAndFailsClosedBeforeQueryForInvalidContext() { assertThat(contributor.code()).isEqualTo("SAILING_SCHEDULE"); - assertThat(contributor.assess(new Context(10001L, 10002L, 1L, 2L, 3L, 4L, 5L)).actual()) + assertThat(contributor.assess(new Context(0L, 10002L, 1L, 2L, 3L, 4L, 5L)).actual()) .isEqualTo("INVALID_CUSTOMER_SCOPE"); - assertThat(contributor.assess(new Context(10001L, 10001L, 1L, 2L, 3L, 4L, null)).actual()) + assertThat(contributor.assess(new Context(10001L, 10002L, 1L, 2L, 3L, 4L, null)).actual()) .isEqualTo("MISSING_OCEAN_BOOKING"); - assertThat(contributor.assess(new Context(10001L, 10001L, 1L, 2L, 0L, 4L, 5L)).actual()) + assertThat(contributor.assess(new Context(10001L, 10002L, 1L, 2L, 0L, 4L, 5L)).actual()) .isEqualTo("MISSING_CONTAINER_BOOKING"); verify(repository, never()).latestForDispatch(10001L, 5L, 3L); } @@ -90,7 +90,7 @@ class PortScheduleDispatchGateContributorTest { } private static Context context() { - return new Context(10001L, 10001L, 1L, 2L, 3L, 4L, 5L); + return new Context(10001L, 10002L, 1L, 2L, 3L, 4L, 5L); } private static PortScheduleEntity ready() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index 79e77ed9f..c4605f2d4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -166,6 +166,20 @@ class LoadingListServiceTest { "plan".equals(value.keyword()))); } + @Test + void listQueriesAllowAllWarehouses() { + when(repository.preloadPage(org.mockito.ArgumentMatchers.any())).thenReturn( + new PageResult<>(List.of(), 1, 20, 0, 0)); + when(repository.confirmedPage(org.mockito.ArgumentMatchers.any())).thenReturn( + new PageResult<>(List.of(), 1, 20, 0, 0)); + + service.preloadPage(new PreloadQuery(null, "LOADING", null, null, null, 1, 20)); + service.confirmedPage(new ConfirmedQuery(null, null, null, null, 1, 20)); + + verify(repository).preloadPage(org.mockito.ArgumentMatchers.argThat(value -> value.warehouseId() == null)); + verify(repository).confirmedPage(org.mockito.ArgumentMatchers.argThat(value -> value.warehouseId() == null)); + } + @Test void rejectsPlanOutsideRequestedWarehouse() { when(repository.aggregate(10L)).thenReturn(row("LOADING", 1)); diff --git a/order/src/main/java/com/baiyue/order/mapper/OrderFulfillmentSplitMapper.java b/order/src/main/java/com/baiyue/order/mapper/OrderFulfillmentSplitMapper.java index 6d6620cee..04a91f8e3 100644 --- a/order/src/main/java/com/baiyue/order/mapper/OrderFulfillmentSplitMapper.java +++ b/order/src/main/java/com/baiyue/order/mapper/OrderFulfillmentSplitMapper.java @@ -43,6 +43,13 @@ public interface OrderFulfillmentSplitMapper extends BaseMapper findByContainer(@Param("containerBookingId") long containerBookingId); diff --git a/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java b/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java index 88691a2e5..5449aebf3 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java @@ -7,7 +7,6 @@ import com.baiyue.order.entity.OrderEntity; import com.baiyue.order.mapper.OrderFulfillmentSplitMapper; import com.baiyue.order.mapper.OrderMapper; import com.baiyue.order.entity.OrderFulfillmentSplitEntity; -import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; @@ -59,13 +58,9 @@ public class OrderFulfillmentSplitService { OrderSplitDtos.Child remainder = req.remainingPackageCount() > 0 ? createChild(req, parent, next, "REMAINDER", req.remainingPackageCount(), hash, operatorId, rows) : null; for (OrderFulfillmentSplitEntity row : rows) splits.insert(row); - parent.setVersion(parentVersion + 1); - LambdaUpdateWrapper guard = new LambdaUpdateWrapper() - .eq(OrderEntity::getId, parent.getId()) - .eq(OrderEntity::getTenantId, parent.getTenantId()) - .eq(OrderEntity::getVersion, parentVersion); - int updated = orders.update(parent, guard); + int updated = splits.incrementParentVersion(parent.getTenantId(), parent.getId(), parentVersion, operatorId); if (updated != 1) throw new BizException(ResultCode.CONFLICT, "父订单版本已变化,请刷新后重试"); + parent.setVersion(parentVersion + 1); return new OrderSplitDtos.Response(parent.getId(), parent.getOrderNo(), parent.getVersion(), loaded, remainder, req.sourcePackageCount(), req.loadedPackageCount(), req.remainingPackageCount(), req.loadingPlanId(), req.containerBookingId(), req.idempotencyKey(), LocalDateTime.now()); } diff --git a/order/src/test/java/com/baiyue/order/service/OrderFulfillmentSplitContractTest.java b/order/src/test/java/com/baiyue/order/service/OrderFulfillmentSplitContractTest.java index ad8b064fa..59781383e 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderFulfillmentSplitContractTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderFulfillmentSplitContractTest.java @@ -1,13 +1,29 @@ package com.baiyue.order.service; +import com.baiyue.order.mapper.OrderFulfillmentSplitMapper; import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; +import org.apache.ibatis.annotations.Update; import java.nio.charset.StandardCharsets; import static org.assertj.core.api.Assertions.assertThat; class OrderFulfillmentSplitContractTest { + @Test + void parentVersionUsesOneAtomicCompareAndIncrementStatement() throws Exception { + Update update = OrderFulfillmentSplitMapper.class + .getMethod("incrementParentVersion", long.class, long.class, int.class, long.class) + .getAnnotation(Update.class); + + assertThat(String.join(" ", update.value())) + .contains("SET version=version+1") + .contains("tenant_id=#{tenantId}") + .contains("id=#{parentOrderId}") + .contains("version=#{expectedVersion}") + .contains("deleted=0"); + } + @Test void schemaRequiresRealSourceOrderAndStableCabinetLedger() throws Exception { String sql = new ClassPathResource("db/phase3-order-parent-child-schema.sql") diff --git a/order/src/test/java/com/baiyue/order/service/OrderFulfillmentSplitServiceTest.java b/order/src/test/java/com/baiyue/order/service/OrderFulfillmentSplitServiceTest.java index fb39688ba..0ff28a7aa 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderFulfillmentSplitServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderFulfillmentSplitServiceTest.java @@ -7,7 +7,6 @@ import com.baiyue.order.entity.OrderFulfillmentSplitEntity; import com.baiyue.order.entity.OrderStatus; import com.baiyue.order.mapper.OrderFulfillmentSplitMapper; import com.baiyue.order.mapper.OrderMapper; -import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -46,10 +45,11 @@ class OrderFulfillmentSplitServiceTest { when(splits.maxSplitSequence(10, 100)).thenReturn(0); when(orders.insert(any(OrderEntity.class))).thenReturn(1); when(splits.insert(any(OrderFulfillmentSplitEntity.class))).thenReturn(1); - when(orders.update(any(OrderEntity.class), any(Wrapper.class))).thenReturn(1); + when(splits.incrementParentVersion(10, 100, 3, 9)).thenReturn(1); OrderSplitDtos.Response response = service.split(request(100L, 20, 8, 12, "key-1"), 9); + assertThat(response.parentVersion()).isEqualTo(4); assertThat(response.loadedChild().sourceOrderId()).isEqualTo(100L); assertThat(response.loadedChild().loadingPlanId()).isEqualTo(701L); assertThat(response.remainderChild().containerBookingId()).isEqualTo(801L); @@ -60,6 +60,25 @@ class OrderFulfillmentSplitServiceTest { assertThat(row.getSourceDetailJson()).contains("sourceDetailId", "packageCount"); assertThat(row.getRequestHash()).hasSize(64); }); + verify(splits).incrementParentVersion(10, 100, 3, 9); + } + + @Test + void rejectsConcurrentParentVersionChangeWithoutPublishingNextVersion() { + OrderEntity parent = parent(); + when(splits.findByIdempotency(10, 100, "key-race")).thenReturn(List.of()); + when(splits.lockOrder(10, 100)).thenReturn(parent); + when(splits.consumedSourceCount(10, 100, 100)).thenReturn(0); + when(splits.maxSplitSequence(10, 100)).thenReturn(0); + when(orders.insert(any(OrderEntity.class))).thenReturn(1); + when(splits.insert(any(OrderFulfillmentSplitEntity.class))).thenReturn(1); + when(splits.incrementParentVersion(10, 100, 3, 9)).thenReturn(0); + + assertThatThrownBy(() -> service.split(request(100L, 20, 8, 12, "key-race"), 9)) + .isInstanceOf(BizException.class).hasMessageContaining("父订单版本已变化"); + + assertThat(parent.getVersion()).isEqualTo(3); + verify(splits).incrementParentVersion(10, 100, 3, 9); } @Test diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index de85ef1f5..8648bce08 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises' const MODULE = 'inbound-measurement' const SCENARIO = 'mojia-carton-measurement' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const DEFAULT_PALLET_CAPACITY_CBM = 1.2 function asId(value) { return value == null ? '' : String(value) } function asNumber(value) { return Number(value) } @@ -23,6 +24,41 @@ function validPdf(response) { && response.bytes?.length > 4 && new TextDecoder().decode(response.bytes.slice(0, 4)) === '%PDF' } +async function provisionWarehouseResources(context, { inboundId, warehouseId, totalVolumeCbm }) { + const palletCount = Math.ceil(Number(totalVolumeCbm) / DEFAULT_PALLET_CAPACITY_CBM) + if (!warehouseId || !Number.isFinite(palletCount) || palletCount < 1) return false + + const suffix = `${String(context.marker).replace(/\D/g, '').slice(-8)}${String(inboundId).slice(-6)}` + const resources = [] + for (let index = 1; index <= palletCount; index += 1) { + const resourceSuffix = `${suffix}${String(index).padStart(2, '0')}` + const palletResponse = await context.request('POST', '/api/platform/v1/pallets?tenantId=0', { + warehouseId, palletType: 'STANDARD', capacityCbm: DEFAULT_PALLET_CAPACITY_CBM, + weightKg: 8, color: '蓝色', remark: `${context.marker}-INBOUND-${inboundId}`, + }) + const palletId = asId(context.apiSuccess(MODULE, `创建测量占用托盘 ${index}/${palletCount}`, + palletResponse, `warehouseId=${warehouseId}`, OPTIONS)) + + const locationResponse = await context.request('POST', '/api/platform/v1/locations?tenantId=0', { + warehouseId, zoneCode: `E2E-${String(context.marker).slice(-8)}`, zoneName: '自动化验收区', + locationCode: `E2E-${resourceSuffix}`, locationName: `测量验收库位-${index}`, + locationType: 'PALLET', maxWeightKg: 1000, status: 'AVAILABLE', + remark: `${context.marker}-INBOUND-${inboundId}`, + }) + const locationId = asId(context.apiSuccess(MODULE, `创建测量占用库位 ${index}/${palletCount}`, + locationResponse, `warehouseId=${warehouseId}`, OPTIONS)) + if (!palletId || !locationId) return false + resources.push({ palletId, locationId }) + } + + context.createdResources.inboundWarehouseResources ??= {} + context.createdResources.inboundWarehouseResources[inboundId] = { + marker: context.marker, tenantId: '0', warehouseId: asId(warehouseId), palletCount, + palletCapacityCbm: DEFAULT_PALLET_CAPACITY_CBM, resources, + } + return true +} + function findIsolatedInbound(context) { const roots = [context.createdResources, context.context] const visited = new Set() @@ -311,6 +347,18 @@ export async function run(context) { return } + const warehouseResourcesReady = await provisionWarehouseResources(context, { + inboundId, warehouseId: asId(summary?.warehouseId), totalVolumeCbm: totalVolume, + }) + context.assert(MODULE, '测量提交前已创建足量真实托盘和库位', draftResponse, + warehouseResourcesReady, + `warehouseId=${asId(summary?.warehouseId) || 'missing'}, palletCount=${Math.ceil(totalVolume / DEFAULT_PALLET_CAPACITY_CBM)}`, + OPTIONS) + if (!warehouseResourcesReady) { + finishScenario(context, start) + return + } + const submitResponse = await context.request('POST', `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/submit?${query(tenantId)}`, { measurementId: draft.measurementId, version: Number(draft.version) }) diff --git a/scripts/admin-test-env-modules/phase3-customs.mjs b/scripts/admin-test-env-modules/phase3-customs.mjs index f05f0e13b..514cb326d 100644 --- a/scripts/admin-test-env-modules/phase3-customs.mjs +++ b/scripts/admin-test-env-modules/phase3-customs.mjs @@ -73,16 +73,39 @@ export async function run(context) { const container = context.createdResources?.phase3Loading || context.createdResources?.phase3ContainerLoading const tenantId = id(fixture?.tenantId) - const orderId = id(fixture?.orderId) + const parentOrderId = id(fixture?.orderId) const inboundId = id(fixture?.inboundId) const containerBookingId = id(container?.containerBookingId) - if (!tenantId || !orderId || !inboundId || !containerBookingId + const loadingPlanId = id(container?.loadingPlanId) + if (!tenantId || !parentOrderId || !inboundId || !containerBookingId || !['COMPLETED', 'LOADED'].includes(container?.finalStatus)) { context.addStep(MODULE, '取得本轮客户/订单/入库单/柜次隔离 fixture', null, false, - `tenantId=${tenantId || 'missing'}, orderId=${orderId || 'missing'}, inboundId=${inboundId || 'missing'}, containerBookingId=${containerBookingId || 'missing'}, containerStatus=${container?.finalStatus || 'missing'}`, OPTIONS) + `tenantId=${tenantId || 'missing'}, parentOrderId=${parentOrderId || 'missing'}, inboundId=${inboundId || 'missing'}, containerBookingId=${containerBookingId || 'missing'}, loadingPlanId=${loadingPlanId || 'missing'}, containerStatus=${container?.finalStatus || 'missing'}`, OPTIONS) return } + const slicesPath = `/api/platform/v1/orders/container-bookings/${encodeURIComponent(containerBookingId)}/fulfillment-slices?tenantId=${encodeURIComponent(tenantId)}` + const slicesResponse = await context.request('GET', slicesPath) + const slices = context.apiSuccess(MODULE, '读取柜次真实履约分片以确定报关订单', slicesResponse, + `containerBookingId=${containerBookingId}, tenantId=${tenantId}`, OPTIONS) + const loadedSlices = Array.isArray(slices) + ? slices.filter((item) => id(item?.tenantId) === tenantId + && id(item?.containerBookingId) === containerBookingId + && id(item?.parentOrderId) === parentOrderId + && item?.splitRole === 'LOADED' + && (!loadingPlanId || id(item?.loadingPlanId) === loadingPlanId)) + : [] + const loadedSlice = loadedSlices.length === 1 ? loadedSlices[0] : null + const orderId = id(loadedSlice?.childOrderId) + context.assert(MODULE, '报关绑定柜次实际承载的唯一 LOADED 子订单', slicesResponse, + loadedSlices.length === 1 && Boolean(orderId) && orderId !== parentOrderId + && id(loadedSlice?.parentOrderId) === parentOrderId + && id(loadedSlice?.containerBookingId) === containerBookingId + && id(loadedSlice?.tenantId) === tenantId + && (!loadingPlanId || id(loadedSlice?.loadingPlanId) === loadingPlanId), + `parentOrderId=${parentOrderId}, childOrderId=${orderId || 'missing'}, containerBookingId=${containerBookingId}, loadingPlanId=${id(loadedSlice?.loadingPlanId) || 'missing'}, matches=${loadedSlices.length}`, OPTIONS) + if (!orderId || loadedSlices.length !== 1 || orderId === parentOrderId) return + const loginResponse = await request(context, 'POST', '/api/auth/login', { username: MERCHANT_USERNAME, password: MERCHANT_PASSWORD, }, '') @@ -114,12 +137,12 @@ export async function run(context) { { 'Idempotency-Key': key(context, 'case') }) const created = context.apiSuccess(MODULE, '创建客户订单柜次关联报关协同单', caseResponse, '', OPTIONS) const caseId = id(created?.customsCase?.id) - context.assert(MODULE, '报关单绑定本轮客户、订单、入库单、柜次和清单版本', caseResponse, + context.assert(MODULE, '报关单绑定本轮 LOADED 子订单、入库单、柜次和清单版本', caseResponse, Boolean(caseId) && id(created?.customsCase?.tenantId) === tenantId && id(created?.customsCase?.orderId) === orderId && id(created?.customsCase?.inboundId) === inboundId && id(created?.customsCase?.containerBookingId) === containerBookingId && id(created?.customsCase?.checklistVersionId) === checklistVersionId && created?.customsCase?.status === 'DOCUMENTS_PENDING' && Number(created?.customsCase?.version) === 0, - `caseId=${caseId || 'missing'}, status=${created?.customsCase?.status || 'missing'}`, OPTIONS) + `caseId=${caseId || 'missing'}, parentOrderId=${parentOrderId}, childOrderId=${orderId}, status=${created?.customsCase?.status || 'missing'}`, OPTIONS) if (!caseId) return const caseReplay = await request(context, 'POST', `${PLATFORM}/cases`, casePayload, context.token, @@ -282,7 +305,7 @@ export async function run(context) { `HTTP=${forgedReference.http}, code=${codeOf(forgedReference)}`, OPTIONS) context.createdResources.phase3Customs = { - marker: context.marker, tenantId, orderId, inboundId, containerBookingId, + marker: context.marker, tenantId, parentOrderId, orderId, inboundId, containerBookingId, loadingPlanId, customsCaseId: caseId, submissionBatchId: batchId, releasedReceiptId: releasedId, finalStatus: 'CLEARANCE_COMPLETED', }