Merge remote-tracking branch 'origin/test' into dev

This commit is contained in:
2026-09-03 20:59:58 +08:00
12 changed files with 318 additions and 13 deletions
@@ -67,7 +67,17 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
public record WarehouseRemarkRequest(@Size(max=1000) String warehouseRemark, public record WarehouseRemarkRequest(@Size(max=1000) String warehouseRemark,
@NotNull @Min(0) Integer expectedPlanVersion, @NotNull @Min(0) Integer expectedItemVersion, @NotNull @Min(0) Integer expectedPlanVersion, @NotNull @Min(0) Integer expectedItemVersion,
@NotBlank @Size(max=128) String operationId) {} @NotBlank @Size(max=128) String operationId) {}
public record PlanCoLoadGroup(Long groupId,String groupNo,Long lockedPlanId,Integer memberOrderCount,
Integer inboundFragmentCount,Integer fullPackageCount,List<Long> memberInboundIds) {
public PlanCoLoadGroup { memberInboundIds=memberInboundIds==null?List.of():List.copyOf(memberInboundIds); }
}
public record Plan(Long id,String planNo,String status,Long bookingId,String containerNo,String voyageNo, public record Plan(Long id,String planNo,String status,Long bookingId,String containerNo,String voyageNo,
Integer version,Boolean canAssign,Boolean canEdit,String blockedReason,String finalCompletionStatus, Integer version,Boolean canAssign,Boolean canEdit,String blockedReason,String finalCompletionStatus,
Long pdaTaskId,String pdaTaskStatus,Integer pdaTaskVersion,List<LoadingPlanItemEntity> items) {} Long pdaTaskId,String pdaTaskStatus,Integer pdaTaskVersion,List<PlanCoLoadGroup> coLoadGroups,
List<LoadingPlanItemEntity> items) {
public Plan {
coLoadGroups=coLoadGroups==null?List.of():List.copyOf(coLoadGroups);
items=items==null?List.of():List.copyOf(items);
}
}
} }
@@ -12,6 +12,22 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
""") """)
List<ChannelContainerCountRow> channelContainerCounts(@Param("warehouseId") long warehouseId); List<ChannelContainerCountRow> channelContainerCounts(@Param("warehouseId") long warehouseId);
@Select("""
SELECT DISTINCT group_row.id groupId,group_row.group_no groupNo,
group_row.locked_plan_id lockedPlanId,member.order_id orderId,
member.inbound_id inboundId,member.full_package_count fullPackageCount
FROM fl_loading_plan_item plan_item
JOIN fl_coload_group_member member
ON member.tenant_id=plan_item.account_id AND member.inbound_id=plan_item.inbound_id
AND member.active_flag=1
JOIN fl_coload_group group_row ON group_row.id=member.group_id
WHERE plan_item.loading_plan_id=#{planId} AND plan_item.deleted=0
AND group_row.locked_plan_id=#{planId}
AND group_row.group_status IN ('LOCKED','CONSUMED')
ORDER BY group_row.id,member.order_id,member.inbound_id
""")
List<PlanColoadMemberRow> planColoadMembers(@Param("planId") long planId);
@Select(""" @Select("""
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId, SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode, o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode,
@@ -142,10 +158,9 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
AND SUM(CASE WHEN LOWER(eligible.inboundCode) LIKE CONCAT('%',LOWER(#{query.inboundCode}),'%') THEN 1 ELSE 0 END)&gt;0 AND SUM(CASE WHEN LOWER(eligible.inboundCode) LIKE CONCAT('%',LOWER(#{query.inboundCode}),'%') THEN 1 ELSE 0 END)&gt;0
</if> </if>
<if test="query.expectedArrivalStart != null or query.expectedArrivalEnd != null"> <if test="query.expectedArrivalStart != null or query.expectedArrivalEnd != null">
AND SUM(CASE WHEN eligible.expectedArrivalAt IS NOT NULL AND MIN(eligible.expectedArrivalAt) IS NOT NULL
<if test="query.expectedArrivalStart != null">AND DATE(eligible.expectedArrivalAt)&gt;=#{query.expectedArrivalStart}</if> <if test="query.expectedArrivalStart != null">AND DATE(MIN(eligible.expectedArrivalAt))&gt;=#{query.expectedArrivalStart}</if>
<if test="query.expectedArrivalEnd != null">AND DATE(eligible.expectedArrivalAt)&lt;=#{query.expectedArrivalEnd}</if> <if test="query.expectedArrivalEnd != null">AND DATE(MIN(eligible.expectedArrivalAt))&lt;=#{query.expectedArrivalEnd}</if>
THEN 1 ELSE 0 END)&gt;0
</if> </if>
ORDER BY sortArrivalAt IS NULL,sortArrivalAt,sortInboundId ORDER BY sortArrivalAt IS NULL,sortArrivalAt,sortInboundId
</script> </script>
@@ -328,4 +343,6 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
} }
record CandidateUnit(Long groupId,Long accountId,Long inboundId, record CandidateUnit(Long groupId,Long accountId,Long inboundId,
LocalDateTime sortArrivalAt,Long sortInboundId) {} LocalDateTime sortArrivalAt,Long sortInboundId) {}
record PlanColoadMemberRow(Long groupId,String groupNo,Long lockedPlanId,
Long orderId,Long inboundId,Integer fullPackageCount) {}
} }
@@ -19,6 +19,7 @@ import com.baiyue.firstleg.mapper.LoadingPlanMapper;
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper; import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper;
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.ChannelContainerCountRow; import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.ChannelContainerCountRow;
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.CandidateUnit; import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.CandidateUnit;
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.PlanColoadMemberRow;
import com.baiyue.firstleg.mapper.InboundOrderMapper; import com.baiyue.firstleg.mapper.InboundOrderMapper;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -56,6 +57,9 @@ public class SmartLoadingRepository {
public List<ChannelContainerCountRow> channelContainerCounts(long warehouseId) { public List<ChannelContainerCountRow> channelContainerCounts(long warehouseId) {
return candidates.channelContainerCounts(warehouseId); return candidates.channelContainerCounts(warehouseId);
} }
public List<PlanColoadMemberRow> planColoadMembers(long planId) {
return candidates.planColoadMembers(planId);
}
public Candidate candidate(long planId, long warehouseId, long inboundId, long detailId) { public Candidate candidate(long planId, long warehouseId, long inboundId, long detailId) {
return candidates.candidate(planId, warehouseId, inboundId, detailId); return candidates.candidate(planId, warehouseId, inboundId, detailId);
} }
@@ -14,6 +14,7 @@ import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.GroupSnapshot;
import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.Member; import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.Member;
import com.baiyue.firstleg.loading.coload.port.ColoadGroupAssignmentPort; import com.baiyue.firstleg.loading.coload.port.ColoadGroupAssignmentPort;
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.CandidateUnit; import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.CandidateUnit;
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.PlanColoadMemberRow;
import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase;
import com.baiyue.firstleg.split.service.SplitPreloadService; import com.baiyue.firstleg.split.service.SplitPreloadService;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
@@ -235,12 +236,14 @@ public class SmartLoadingService {
return actual; return actual;
} }
@Transactional(readOnly = true)
public Plan draftByBooking(long bookingId) { public Plan draftByBooking(long bookingId) {
ContainerBookingEntity booking = requireBooking(bookingId); ContainerBookingEntity booking = requireBooking(bookingId);
LoadingPlanEntity plan = repository.planByBooking(bookingId); LoadingPlanEntity plan = repository.planByBooking(bookingId);
return draftView(plan, booking); return draftView(plan, booking);
} }
@Transactional(readOnly = true)
public Plan detail(long planId) { public Plan detail(long planId) {
LoadingPlanEntity plan = repository.plan(planId); LoadingPlanEntity plan = repository.plan(planId);
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在"); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在");
@@ -964,7 +967,22 @@ public class SmartLoadingService {
: "COMPLETED".equals(task.taskStatus()) ? "COMPLETED" : "IN_PROGRESS"; : "COMPLETED".equals(task.taskStatus()) ? "COMPLETED" : "IN_PROGRESS";
return new Plan(p.getId(),p.getPlanNo(),p.getPlanStatus(),p.getContainerBookingId(), return new Plan(p.getId(),p.getPlanNo(),p.getPlanStatus(),p.getContainerBookingId(),
b.getContainerNo(),b.getVoyageNo(),p.getVersion(),mutable,mutable,blockedReason,finalStatus, b.getContainerNo(),b.getVoyageNo(),p.getVersion(),mutable,mutable,blockedReason,finalStatus,
task.taskId(),task.taskStatus(),task.taskVersion(),items); task.taskId(),task.taskStatus(),task.taskVersion(),planCoLoadGroups(p.getId()),items);
}
private List<PlanCoLoadGroup> planCoLoadGroups(long planId) {
Map<Long,List<PlanColoadMemberRow>> grouped=repository.planColoadMembers(planId).stream()
.collect(java.util.stream.Collectors.groupingBy(PlanColoadMemberRow::groupId,
LinkedHashMap::new,java.util.stream.Collectors.toList()));
return grouped.values().stream().map(rows->{
PlanColoadMemberRow first=rows.get(0);
LinkedHashMap<Long,Integer> packagesByInbound=new LinkedHashMap<>();
rows.forEach(row->packagesByInbound.putIfAbsent(row.inboundId(),row.fullPackageCount()));
int orderCount=(int)rows.stream().map(PlanColoadMemberRow::orderId).distinct().count();
int packages=packagesByInbound.values().stream().filter(Objects::nonNull)
.mapToInt(Integer::intValue).sum();
return new PlanCoLoadGroup(first.groupId(),first.groupNo(),first.lockedPlanId(),orderCount,
packagesByInbound.size(),packages,List.copyOf(packagesByInbound.keySet()));
}).toList();
} }
private Plan draftView(LoadingPlanEntity plan, ContainerBookingEntity booking) { private Plan draftView(LoadingPlanEntity plan, ContainerBookingEntity booking) {
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "该货柜尚未创建智能装柜草稿"); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "该货柜尚未创建智能装柜草稿");
@@ -2035,7 +2035,7 @@ components:
version: {type: integer, minimum: 0, description: 柜次版本} version: {type: integer, minimum: 0, description: 柜次版本}
SmartLoadingPlan: SmartLoadingPlan:
type: object type: object
required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, items] required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, coLoadGroups, items]
properties: properties:
id: {type: string} id: {type: string}
planNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号} planNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号}
@@ -2051,9 +2051,28 @@ components:
pdaTaskId: {type: string, nullable: true} pdaTaskId: {type: string, nullable: true}
pdaTaskStatus: {type: string, nullable: true, enum: [LOADING, COMPLETED], description: LOADING task 在增量后保持原 taskId;COMPLETED 永久拒绝增量} pdaTaskStatus: {type: string, nullable: true, enum: [LOADING, COMPLETED], description: LOADING task 在增量后保持原 taskId;COMPLETED 永久拒绝增量}
pdaTaskVersion: {type: integer, minimum: 0, nullable: true, description: 服务端同步后最新 taskVersion;Admin 写操作不提交该值} pdaTaskVersion: {type: integer, minimum: 0, nullable: true, description: 服务端同步后最新 taskVersion;Admin 写操作不提交该值}
coLoadGroups:
type: array
description: 当前计划内同柜不可拆组的完整冻结快照;随计划一次返回,调用方不得按 inboundId 扇出重建
items: {$ref: '#/components/schemas/SmartLoadingPlanCoLoadGroup'}
items: items:
type: array type: array
items: {$ref: '#/components/schemas/SmartLoadingPlanItem'} items: {$ref: '#/components/schemas/SmartLoadingPlanItem'}
SmartLoadingPlanCoLoadGroup:
type: object
required: [groupId, groupNo, lockedPlanId, memberOrderCount, inboundFragmentCount, fullPackageCount, memberInboundIds]
properties:
groupId: {type: string, pattern: '^[1-9][0-9]*$'}
groupNo: {type: string, minLength: 1}
lockedPlanId: {type: string, pattern: '^[1-9][0-9]*$'}
memberOrderCount: {type: integer, minimum: 1, description: 组内去重订单数}
inboundFragmentCount: {type: integer, minimum: 1, description: 组内去重当前有效入仓片段数}
fullPackageCount: {type: integer, minimum: 1, description: 全部成员冻结满箱数之和}
memberInboundIds:
type: array
minItems: 1
uniqueItems: true
items: {type: string, pattern: '^[1-9][0-9]*$'}
SmartLoadingPlanItem: SmartLoadingPlanItem:
type: object type: object
required: [id, packageCount, version, pickedPackageCount, minPackageCount, canAdjust, canRemove] required: [id, packageCount, version, pickedPackageCount, minPackageCount, canAdjust, canRemove]
@@ -78,5 +78,13 @@ class PlatformSmartLoadingControllerContractTest {
.contains("回到 IN_PROGRESS") .contains("回到 IN_PROGRESS")
.contains("COMPLETED 永久拒绝增量"); .contains("COMPLETED 永久拒绝增量");
} }
@Test void planContractEmbedsColoadGroupsWithoutPerInboundFanout() throws Exception {
String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml"));
assertThat(yaml)
.contains("required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, coLoadGroups, items]")
.contains("SmartLoadingPlanCoLoadGroup:")
.contains("required: [groupId, groupNo, lockedPlanId, memberOrderCount, inboundFragmentCount, fullPackageCount, memberInboundIds]")
.contains("调用方不得按 inboundId 扇出重建");
}
private void authority(String method,String permission,Class<?>...types)throws Exception{Method m=PlatformSmartLoadingController.class.getDeclaredMethod(method,types);assertThat(m.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('"+permission+"')");} private void authority(String method,String permission,Class<?>...types)throws Exception{Method m=PlatformSmartLoadingController.class.getDeclaredMethod(method,types);assertThat(m.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('"+permission+"')");}
} }
@@ -14,6 +14,8 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery;
import com.baiyue.firstleg.dto.SmartLoadingDtos.InboundCandidateQuery; import com.baiyue.firstleg.dto.SmartLoadingDtos.InboundCandidateQuery;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.time.LocalDate;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -98,6 +100,39 @@ class SmartLoadingCandidateMapperContractTest {
.doesNotContain("fl_container_booking"); .doesNotContain("fl_container_booking");
} }
@Test
void globalCandidateDateFilterUsesTheSameRepresentativeArrivalAsTheReturnedUnit() throws Exception {
Method units = SmartLoadingCandidateMapper.class.getDeclaredMethod("globalCandidateUnits",
Page.class, InboundCandidateQuery.class, Set.class);
String sql = String.join(" ", units.getAnnotation(Select.class).value());
assertThat(sql)
.contains("MIN(eligible.expectedArrivalAt) sortArrivalAt")
.contains("MIN(eligible.expectedArrivalAt) IS NOT NULL")
.contains("DATE(MIN(eligible.expectedArrivalAt))&gt;=#{query.expectedArrivalStart}")
.contains("DATE(MIN(eligible.expectedArrivalAt))&lt;=#{query.expectedArrivalEnd}")
.doesNotContain("SUM(CASE WHEN eligible.expectedArrivalAt IS NOT NULL");
}
@Test
void representativeArrivalControlsWhetherTheWholeGroupMatchesTheClosedDateRange() {
List<LocalDateTime> groupArrivals = List.of(
LocalDateTime.parse("2026-09-01T09:00:00"),
LocalDateTime.parse("2026-09-10T09:00:00"));
LocalDate representative = groupArrivals.stream().min(LocalDateTime::compareTo)
.orElseThrow().toLocalDate();
assertThat(matchesClosedRange(representative,
LocalDate.parse("2026-09-10"), LocalDate.parse("2026-09-10")))
.as("another member matching must not admit a group whose MIN arrival is outside")
.isFalse();
assertThat(matchesClosedRange(representative,
LocalDate.parse("2026-09-01"), LocalDate.parse("2026-09-01")))
.as("a matching MIN arrival admits the whole group")
.isTrue();
}
@Test @Test
void globalCandidateDynamicSqlRendersForStandaloneAndGroupedUnits() throws Exception { void globalCandidateDynamicSqlRendersForStandaloneAndGroupedUnits() throws Exception {
Configuration configuration = new Configuration(); Configuration configuration = new Configuration();
@@ -110,7 +145,10 @@ class SmartLoadingCandidateMapperContractTest {
null, 1, 20); null, 1, 20);
String renderedUnits = driver.createSqlSource(configuration, unitScript, Map.class) String renderedUnits = driver.createSqlSource(configuration, unitScript, Map.class)
.getBoundSql(Map.of("query", query, "actualChannels", Set.of("MATSON_CLX_TRUCK"))).getSql(); .getBoundSql(Map.of("query", query, "actualChannels", Set.of("MATSON_CLX_TRUCK"))).getSql();
assertThat(renderedUnits).contains("o.channel_code IN", "DATE(eligible.expectedArrivalAt)"); assertThat(renderedUnits)
.contains("o.channel_code IN", "MIN(eligible.expectedArrivalAt) IS NOT NULL")
.contains("DATE(MIN(eligible.expectedArrivalAt))>=", "DATE(MIN(eligible.expectedArrivalAt))<=")
.doesNotContain("SUM(CASE WHEN eligible.expectedArrivalAt IS NOT NULL");
Method details = SmartLoadingCandidateMapper.class.getDeclaredMethod( Method details = SmartLoadingCandidateMapper.class.getDeclaredMethod(
"globalCandidatesByUnits", List.class); "globalCandidatesByUnits", List.class);
@@ -190,4 +228,25 @@ class SmartLoadingCandidateMapperContractTest {
.contains("GROUP BY b.channel_code") .contains("GROUP BY b.channel_code")
.doesNotContain("'COMPLETED'"); .doesNotContain("'COMPLETED'");
} }
@Test
void planColoadSnapshotUsesOnePlanScopedBatchQuery() throws Exception {
Method method=SmartLoadingCandidateMapper.class.getDeclaredMethod("planColoadMembers",long.class);
String sql=String.join(" ",method.getAnnotation(Select.class).value());
assertThat(sql)
.contains("plan_item.loading_plan_id=#{planId}")
.contains("plan_item.deleted=0")
.contains("member.tenant_id=plan_item.account_id")
.contains("member.inbound_id=plan_item.inbound_id")
.contains("member.active_flag=1")
.contains("group_row.locked_plan_id=#{planId}")
.contains("group_row.group_status IN ('LOCKED','CONSUMED')")
.contains("SELECT DISTINCT")
.doesNotContain("WHERE member.inbound_id=#{inboundId}");
}
private static boolean matchesClosedRange(LocalDate arrival, LocalDate start, LocalDate end) {
return arrival != null && !arrival.isBefore(start) && !arrival.isAfter(end);
}
} }
@@ -55,7 +55,7 @@ class ContainerServiceTest {
when(smartLoadingService.createForContainer(anyLong(), anyLong(), anyString())).thenAnswer(invocation -> when(smartLoadingService.createForContainer(anyLong(), anyLong(), anyString())).thenAnswer(invocation ->
new com.baiyue.firstleg.dto.SmartLoadingDtos.Plan(501L, "LP-260818-0001", "EDITING", new com.baiyue.firstleg.dto.SmartLoadingDtos.Plan(501L, "LP-260818-0001", "EDITING",
invocation.getArgument(0, Long.class), null, null, 0, true, true, null, invocation.getArgument(0, Long.class), null, null, 0, true, true, null,
"NOT_STARTED", null, null, null, List.of())); "NOT_STARTED", null, null, null, List.of(), List.of()));
when(warehouses.active(anyString(), anyLong(), anyLong())).thenAnswer(invocation -> when(warehouses.active(anyString(), anyLong(), anyLong())).thenAnswer(invocation ->
activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓")); activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓"));
when(suppliers.trucking(202L, "拖车公司A")) when(suppliers.trucking(202L, "拖车公司A"))
@@ -31,6 +31,7 @@ import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.C
import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase;
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.ChannelContainerCountRow; import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.ChannelContainerCountRow;
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.CandidateUnit; import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.CandidateUnit;
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.PlanColoadMemberRow;
import com.baiyue.firstleg.split.service.SplitPreloadService; import com.baiyue.firstleg.split.service.SplitPreloadService;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException; import feign.FeignException;
@@ -116,6 +117,32 @@ class SmartLoadingServiceTest {
assertThat(service.detail(1L).bookingId()).isEqualTo(99L); assertThat(service.detail(1L).bookingId()).isEqualTo(99L);
} }
@Test
void returnsPlanColoadGroupsWithOneBatchLookup() {
LoadingPlanEntity plan = plan("EDITING", 2);
when(repository.plan(1L)).thenReturn(plan);
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of());
when(repository.planColoadMembers(1L)).thenReturn(List.of(
new PlanColoadMemberRow(700L,"CLG-700",1L,30L,10L,4),
new PlanColoadMemberRow(700L,"CLG-700",1L,30L,11L,6),
new PlanColoadMemberRow(700L,"CLG-700",1L,31L,12L,5)));
Plan result=service.detail(1L);
assertThat(result.coLoadGroups()).singleElement().satisfies(group->{
assertThat(group.groupId()).isEqualTo(700L);
assertThat(group.groupNo()).isEqualTo("CLG-700");
assertThat(group.lockedPlanId()).isEqualTo(1L);
assertThat(group.memberOrderCount()).isEqualTo(2);
assertThat(group.inboundFragmentCount()).isEqualTo(3);
assertThat(group.fullPackageCount()).isEqualTo(15);
assertThat(group.memberInboundIds()).containsExactly(10L,11L,12L);
});
verify(repository).planColoadMembers(1L);
verify(coloadGroups,never()).findByInbound(anyLong(),anyLong());
}
@Test @Test
void restoresLoadingPlanForContinuedAssignment() { void restoresLoadingPlanForContinuedAssignment() {
LoadingPlanEntity plan = plan("LOADING", 3); LoadingPlanEntity plan = plan("LOADING", 3);
@@ -208,8 +235,8 @@ class SmartLoadingServiceTest {
Candidate second = withGroup(withArrival(withChannel(candidate(19L, 11L, 21L, 31L, 6), Candidate second = withGroup(withArrival(withChannel(candidate(19L, 11L, 21L, 31L, 6),
"MATSON_CLX_TRUCK", "美森正班海卡"), "2026-09-10T09:00:00"), 2, 10); "MATSON_CLX_TRUCK", "美森正班海卡"), "2026-09-10T09:00:00"), 2, 10);
InboundCandidateQuery query = new InboundCandidateQuery( InboundCandidateQuery query = new InboundCandidateQuery(
List.of("MATSON_CLX"), 100L, null, null, LocalDate.parse("2026-09-10"), List.of("MATSON_CLX"), 100L, null, null, LocalDate.parse("2026-09-01"),
LocalDate.parse("2026-09-10"), null, 1, 1); LocalDate.parse("2026-09-01"), null, 1, 1);
CandidateUnit unit = new CandidateUnit(700L, null, null, CandidateUnit unit = new CandidateUnit(700L, null, null,
LocalDateTime.parse("2026-09-01T09:00:00"), 10L); LocalDateTime.parse("2026-09-01T09:00:00"), 10L);
when(repository.globalCandidateUnits(eq(query), anySet())) when(repository.globalCandidateUnits(eq(query), anySet()))
@@ -323,6 +323,7 @@ public class MerchantBatchImportService {
if (items.size() >= MAX_ROWS) throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 最多 50 条订单"); if (items.size() >= MAX_ROWS) throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 最多 50 条订单");
String rowId = "excel-row-" + (rowNo + 1); String rowId = "excel-row-" + (rowNo + 1);
try { try {
validateRequiredColumns(row);
var draft = json.createObjectNode(); var draft = json.createObjectNode();
draft.put("customerName", customerName); draft.put("customerName", customerName);
draft.put("tradeTerm", "FOB"); draft.put("tradeTerm", "FOB");
@@ -347,6 +348,8 @@ public class MerchantBatchImportService {
} }
validateDestination(draft.path("destinationType").asText(null), validateDestination(draft.path("destinationType").asText(null),
fbaSelected, overseasSelected, privateSelected); fbaSelected, overseasSelected, privateSelected);
validatePickup(draft.path("pickupMethod").asText(null),
draft.hasNonNull("requestedPickupAt"), draft.hasNonNull("senderAddressId"));
draft.set("cargoes", json.createArrayNode().add(cargo)); draft.set("cargoes", json.createArrayNode().add(cargo));
items.add(new MerchantBatchDraftDtos.Item(rowId, items.add(new MerchantBatchDraftDtos.Item(rowId,
json.treeToValue(draft, OrderRequests.SaveRequest.class))); json.treeToValue(draft, OrderRequests.SaveRequest.class)));
@@ -496,6 +499,15 @@ public class MerchantBatchImportService {
return false; return false;
} }
private static void validateRequiredColumns(Row row) {
for (int col = 0; col < COLUMNS.size(); col++) {
Column column = COLUMNS.get(col);
if (column.required() && !StringUtils.hasText(text(row.getCell(col)))) {
throw new BizException(ResultCode.VALIDATION_FAILED, column.title() + "不能为空");
}
}
}
private static int excelRowNumber(String clientRowId) { private static int excelRowNumber(String clientRowId) {
String prefix = "excel-row-"; String prefix = "excel-row-";
if (!StringUtils.hasText(clientRowId) || !clientRowId.startsWith(prefix)) { if (!StringUtils.hasText(clientRowId) || !clientRowId.startsWith(prefix)) {
@@ -576,6 +588,16 @@ public class MerchantBatchImportService {
}); });
} }
private static void validatePickup(String pickupMethod, boolean requestedPickupAt, boolean senderAddress) {
if (!"PICKUP".equals(pickupMethod)) return;
if (!requestedPickupAt) {
throw new BizException(ResultCode.VALIDATION_FAILED, "平台上门提货时预约提货时间不能为空");
}
if (!senderAddress) {
throw new BizException(ResultCode.VALIDATION_FAILED, "平台上门提货时取货地址不能为空");
}
}
private static String label(String name, String code) { private static String label(String name, String code) {
if (!StringUtils.hasText(name)) return code; if (!StringUtils.hasText(name)) return code;
return name.equalsIgnoreCase(code) ? code : name + "|" + code; return name.equalsIgnoreCase(code) ? code : name + "|" + code;
@@ -62,6 +62,29 @@ class MerchantBatchDraftServiceTest {
assertThat(response.items().get(1).errorMessage()).isEqualTo("SKU不能为空"); assertThat(response.items().get(1).errorMessage()).isEqualTo("SKU不能为空");
} }
@Test
void partialModeReturnsA0422WithoutCreatingAParsedInvalidRow() {
var response = service.create(10L, 20L, "batch-key", List.of(
new MerchantBatchDraftDtos.Item("excel-row-9", null, "货物名称不能为空")));
assertThat(response.succeeded()).isZero();
assertThat(response.failed()).isEqualTo(1);
assertThat(response.items().get(0).errorCode()).isEqualTo("A0422");
assertThat(response.items().get(0).errorMessage()).isEqualTo("货物名称不能为空");
verify(itemService, never()).create(anyLong(), anyLong(), isNull(), any(), anyString());
}
@Test
void atomicModeRejectsWholeBatchBeforeCreatingWhenOneParsedRowIsInvalid() {
assertThatThrownBy(() -> service.createAtomic(10L, 20L, null, "batch-key", List.of(
item("excel-row-3"),
new MerchantBatchDraftDtos.Item("excel-row-9", null, "货物名称不能为空"))))
.isInstanceOf(BizException.class)
.hasMessage("货物名称不能为空");
verify(itemService, never()).createJoined(anyLong(), anyLong(), isNull(), any(), anyString());
}
@Test @Test
void rejectsDuplicateRowWithoutCreatingItTwice() { void rejectsDuplicateRowWithoutCreatingItTwice() {
doAnswer(invocation -> order(101L, "FL101", "RK-101")) doAnswer(invocation -> order(101L, "FL101", "RK-101"))
@@ -11,6 +11,9 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
@@ -20,6 +23,7 @@ import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -132,13 +136,54 @@ class MerchantBatchImportServiceTest {
} }
} }
@ParameterizedTest(name = "required column {0} rejects blank cell")
@MethodSource("requiredColumns")
void importRejectsEveryRequiredTemplateColumnWhenItIsBlank(int column, String title) throws Exception {
byte[] content = service.template(88L, "Bearer test");
try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(content)); var out = new ByteArrayOutputStream()) {
var sheet = workbook.getSheet("订单导入");
var row = sheet.getRow(2);
fillValidSelfExpressFbaRow(row);
row.getCell(column).setCellValue(" ");
assertThat(requiredHeaderIndexes(sheet)).containsExactlyElementsOf(
requiredColumns().map(arguments -> (Integer) arguments.get()[0]).toList());
workbook.write(out);
var item = service.parse(88L, "Bearer test", out.toByteArray()).get(0);
assertThat(item.draft()).isNull();
assertThat(item.parseError()).isEqualTo(title + "不能为空");
}
}
@ParameterizedTest(name = "PICKUP rejects missing {0}")
@MethodSource("pickupConditionalColumns")
void importRejectsMissingPickupConditionalFields(String title, int column, String message) throws Exception {
byte[] content = service.template(88L, "Bearer test");
try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(content)); var out = new ByteArrayOutputStream()) {
var row = workbook.getSheet("订单导入").getRow(2);
fillValidSelfExpressFbaRow(row);
row.getCell(2).setCellValue("平台上门提货");
row.getCell(9).setCellValue(LocalDateTime.of(2026, 9, 10, 14, 30));
row.getCell(29).setCellValue("工厂|张三|深圳|科技园1号");
row.getCell(column).setCellValue(" ");
workbook.write(out);
var item = service.parse(88L, "Bearer test", out.toByteArray()).get(0);
assertThat(item.draft()).isNull();
assertThat(item.parseError()).isEqualTo(message);
}
}
@Test @Test
void importRejectsOverseasWarehouseWhenDestinationTypeIsFba() throws Exception { void importRejectsOverseasWarehouseWhenDestinationTypeIsFba() throws Exception {
byte[] content = service.template(88L, "Bearer test"); byte[] content = service.template(88L, "Bearer test");
try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(content)); var out = new ByteArrayOutputStream()) { try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(content)); var out = new ByteArrayOutputStream()) {
var row = workbook.getSheet("订单导入").getRow(2); var row = workbook.getSheet("订单导入").getRow(2);
row.createCell(5).setCellValue("亚马逊FBA"); fillValidSelfExpressFbaRow(row);
row.createCell(7).setCellValue("洛杉矶海外仓|US-LAX"); row.getCell(6).setBlank();
row.getCell(7).setCellValue("洛杉矶海外仓|US-LAX");
workbook.write(out); workbook.write(out);
var item = service.parse(88L, "Bearer test", out.toByteArray()).get(0); var item = service.parse(88L, "Bearer test", out.toByteArray()).get(0);
assertThat(item.draft()).isNull(); assertThat(item.draft()).isNull();
@@ -205,6 +250,59 @@ class MerchantBatchImportServiceTest {
return false; return false;
} }
private static void fillValidSelfExpressFbaRow(org.apache.poi.ss.usermodel.Row row) {
row.getCell(0).setCellValue("海运");
row.getCell(2).setCellValue("自行快递");
row.getCell(3).setCellValue("义乌收货仓|CN-YW");
row.getCell(4).setCellValue("美森海运|US-SEA");
row.getCell(5).setCellValue("亚马逊FBA");
row.getCell(6).setCellValue("ATL6");
row.getCell(10).setCellValue("蓝牙音箱");
row.getCell(11).setCellValue("纸箱");
row.getCell(12).setCellValue("1");
row.getCell(13).setCellValue("1.2");
row.getCell(14).setCellValue("0.1");
row.getCell(16).setCellValue("否");
row.getCell(17).setCellValue("否");
row.getCell(22).setCellValue("买单报关");
row.getCell(23).setCellValue("不包税");
}
private static List<Integer> requiredHeaderIndexes(org.apache.poi.ss.usermodel.Sheet sheet) {
List<Integer> indexes = new ArrayList<>();
var header = sheet.getRow(1);
for (int column = 0; column < header.getLastCellNum(); column++) {
if (header.getCell(column).getStringCellValue().endsWith(" *")) indexes.add(column);
}
return indexes;
}
private static Stream<Arguments> requiredColumns() {
return Stream.of(
Arguments.of(0, "运输方式"),
Arguments.of(2, "提货方式"),
Arguments.of(3, "国内收货仓"),
Arguments.of(4, "运输渠道"),
Arguments.of(5, "目的类型"),
Arguments.of(10, "货物名称"),
Arguments.of(11, "包装类型"),
Arguments.of(12, "包装数量"),
Arguments.of(13, "毛重(千克)"),
Arguments.of(14, "体积(立方米)"),
Arguments.of(16, "是否木制品"),
Arguments.of(17, "是否危险品"),
Arguments.of(22, "报关方式"),
Arguments.of(23, "计价税模式")
);
}
private static Stream<Arguments> pickupConditionalColumns() {
return Stream.of(
Arguments.of("预约提货时间", 9, "平台上门提货时预约提货时间不能为空"),
Arguments.of("取货地址", 29, "平台上门提货时取货地址不能为空")
);
}
private static List<String> rowValues(org.apache.poi.ss.usermodel.Row row) { private static List<String> rowValues(org.apache.poi.ss.usermodel.Row row) {
List<String> values = new ArrayList<>(); List<String> values = new ArrayList<>();
for (var cell : row) values.add(cell.getStringCellValue()); for (var cell : row) values.add(cell.getStringCellValue());