fix(phase3): close loading and dispatch flows
This commit is contained in:
+1
-1
@@ -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,
|
||||
|
||||
+5
-5
@@ -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);
|
||||
|
||||
+4
-4
@@ -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);
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+16
@@ -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;
|
||||
|
||||
+3
-3
@@ -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); }
|
||||
}
|
||||
|
||||
+21
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
+15
-2
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -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));
|
||||
|
||||
@@ -43,6 +43,13 @@ public interface OrderFulfillmentSplitMapper extends BaseMapper<OrderFulfillment
|
||||
@Select("SELECT COALESCE(MAX(split_sequence),0) FROM ord_order WHERE tenant_id=#{tenantId} AND (id=#{parentOrderId} OR parent_order_id=#{parentOrderId}) AND deleted=0")
|
||||
Integer maxSplitSequence(@Param("tenantId") long tenantId, @Param("parentOrderId") long parentOrderId);
|
||||
|
||||
@Update("UPDATE ord_order SET version=version+1, updated_by=#{operatorId}, updated_at=CURRENT_TIMESTAMP "
|
||||
+ "WHERE tenant_id=#{tenantId} AND id=#{parentOrderId} AND version=#{expectedVersion} AND deleted=0")
|
||||
int incrementParentVersion(@Param("tenantId") long tenantId,
|
||||
@Param("parentOrderId") long parentOrderId,
|
||||
@Param("expectedVersion") int expectedVersion,
|
||||
@Param("operatorId") long operatorId);
|
||||
|
||||
@Select("SELECT s.container_booking_id AS containerBookingId,s.tenant_id AS tenantId,s.parent_order_id AS parentOrderId,p.order_no AS parentOrderNo,s.child_order_id AS childOrderId,c.order_no AS childOrderNo,s.source_order_id AS sourceOrderId,so.order_no AS sourceOrderNo,s.split_role AS splitRole,CASE WHEN s.split_role='LOADED' THEN s.loaded_package_count ELSE s.remaining_package_count END AS packageCount,s.loading_plan_id AS loadingPlanId FROM ord_order_fulfillment_split s JOIN ord_order p ON p.id=s.parent_order_id AND p.tenant_id=s.tenant_id JOIN ord_order c ON c.id=s.child_order_id AND c.tenant_id=s.tenant_id LEFT JOIN ord_order so ON so.id=s.source_order_id AND so.tenant_id=s.tenant_id WHERE s.container_booking_id=#{containerBookingId} ORDER BY s.tenant_id,s.id")
|
||||
List<OrderSplitDtos.ContainerSlice> findByContainer(@Param("containerBookingId") long containerBookingId);
|
||||
|
||||
|
||||
@@ -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<OrderEntity> guard = new LambdaUpdateWrapper<OrderEntity>()
|
||||
.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());
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) })
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user