fix(phase3): close loading and dispatch flows

This commit is contained in:
2026-08-10 17:42:17 +08:00
parent 074a95d82b
commit 84209bffd7
19 changed files with 221 additions and 43 deletions
@@ -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(),
@@ -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,
@@ -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&lt;&gt;'' 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);
@@ -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);
@@ -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);
@@ -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) {
@@ -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
@@ -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<String, Object> stringMap(Object value) {
return (Map<String, Object>) value;
@@ -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); }
}
@@ -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("&lt;", "&gt;");
}
@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}");
}
}
@@ -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));
}
@@ -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() {
@@ -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));