Merge remote-tracking branch 'origin/test' into dev
This commit is contained in:
@@ -67,7 +67,17 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
|
||||
public record WarehouseRemarkRequest(@Size(max=1000) String warehouseRemark,
|
||||
@NotNull @Min(0) Integer expectedPlanVersion, @NotNull @Min(0) Integer expectedItemVersion,
|
||||
@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,
|
||||
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);
|
||||
|
||||
@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 o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
|
||||
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)>0
|
||||
</if>
|
||||
<if test="query.expectedArrivalStart != null or query.expectedArrivalEnd != null">
|
||||
AND SUM(CASE WHEN eligible.expectedArrivalAt IS NOT NULL
|
||||
<if test="query.expectedArrivalStart != null">AND DATE(eligible.expectedArrivalAt)>=#{query.expectedArrivalStart}</if>
|
||||
<if test="query.expectedArrivalEnd != null">AND DATE(eligible.expectedArrivalAt)<=#{query.expectedArrivalEnd}</if>
|
||||
THEN 1 ELSE 0 END)>0
|
||||
AND MIN(eligible.expectedArrivalAt) IS NOT NULL
|
||||
<if test="query.expectedArrivalStart != null">AND DATE(MIN(eligible.expectedArrivalAt))>=#{query.expectedArrivalStart}</if>
|
||||
<if test="query.expectedArrivalEnd != null">AND DATE(MIN(eligible.expectedArrivalAt))<=#{query.expectedArrivalEnd}</if>
|
||||
</if>
|
||||
ORDER BY sortArrivalAt IS NULL,sortArrivalAt,sortInboundId
|
||||
</script>
|
||||
@@ -328,4 +343,6 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
|
||||
}
|
||||
record CandidateUnit(Long groupId,Long accountId,Long inboundId,
|
||||
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.ChannelContainerCountRow;
|
||||
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.CandidateUnit;
|
||||
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.PlanColoadMemberRow;
|
||||
import com.baiyue.firstleg.mapper.InboundOrderMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -56,6 +57,9 @@ public class SmartLoadingRepository {
|
||||
public List<ChannelContainerCountRow> channelContainerCounts(long 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) {
|
||||
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.port.ColoadGroupAssignmentPort;
|
||||
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.split.service.SplitPreloadService;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
@@ -235,12 +236,14 @@ public class SmartLoadingService {
|
||||
return actual;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Plan draftByBooking(long bookingId) {
|
||||
ContainerBookingEntity booking = requireBooking(bookingId);
|
||||
LoadingPlanEntity plan = repository.planByBooking(bookingId);
|
||||
return draftView(plan, booking);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Plan detail(long planId) {
|
||||
LoadingPlanEntity plan = repository.plan(planId);
|
||||
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在");
|
||||
@@ -964,7 +967,22 @@ public class SmartLoadingService {
|
||||
: "COMPLETED".equals(task.taskStatus()) ? "COMPLETED" : "IN_PROGRESS";
|
||||
return new Plan(p.getId(),p.getPlanNo(),p.getPlanStatus(),p.getContainerBookingId(),
|
||||
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) {
|
||||
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "该货柜尚未创建智能装柜草稿");
|
||||
|
||||
@@ -2035,7 +2035,7 @@ components:
|
||||
version: {type: integer, minimum: 0, description: 柜次版本}
|
||||
SmartLoadingPlan:
|
||||
type: object
|
||||
required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, items]
|
||||
required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, coLoadGroups, items]
|
||||
properties:
|
||||
id: {type: string}
|
||||
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}
|
||||
pdaTaskStatus: {type: string, nullable: true, enum: [LOADING, COMPLETED], description: LOADING task 在增量后保持原 taskId;COMPLETED 永久拒绝增量}
|
||||
pdaTaskVersion: {type: integer, minimum: 0, nullable: true, description: 服务端同步后最新 taskVersion;Admin 写操作不提交该值}
|
||||
coLoadGroups:
|
||||
type: array
|
||||
description: 当前计划内同柜不可拆组的完整冻结快照;随计划一次返回,调用方不得按 inboundId 扇出重建
|
||||
items: {$ref: '#/components/schemas/SmartLoadingPlanCoLoadGroup'}
|
||||
items:
|
||||
type: array
|
||||
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:
|
||||
type: object
|
||||
required: [id, packageCount, version, pickedPackageCount, minPackageCount, canAdjust, canRemove]
|
||||
|
||||
+8
@@ -78,5 +78,13 @@ class PlatformSmartLoadingControllerContractTest {
|
||||
.contains("回到 IN_PROGRESS")
|
||||
.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+"')");}
|
||||
}
|
||||
|
||||
+60
-1
@@ -14,6 +14,8 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.InboundCandidateQuery;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -98,6 +100,39 @@ class SmartLoadingCandidateMapperContractTest {
|
||||
.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))>=#{query.expectedArrivalStart}")
|
||||
.contains("DATE(MIN(eligible.expectedArrivalAt))<=#{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
|
||||
void globalCandidateDynamicSqlRendersForStandaloneAndGroupedUnits() throws Exception {
|
||||
Configuration configuration = new Configuration();
|
||||
@@ -110,7 +145,10 @@ class SmartLoadingCandidateMapperContractTest {
|
||||
null, 1, 20);
|
||||
String renderedUnits = driver.createSqlSource(configuration, unitScript, Map.class)
|
||||
.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(
|
||||
"globalCandidatesByUnits", List.class);
|
||||
@@ -190,4 +228,25 @@ class SmartLoadingCandidateMapperContractTest {
|
||||
.contains("GROUP BY b.channel_code")
|
||||
.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 ->
|
||||
new com.baiyue.firstleg.dto.SmartLoadingDtos.Plan(501L, "LP-260818-0001", "EDITING",
|
||||
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 ->
|
||||
activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓"));
|
||||
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.mapper.SmartLoadingCandidateMapper.ChannelContainerCountRow;
|
||||
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.CandidateUnit;
|
||||
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.PlanColoadMemberRow;
|
||||
import com.baiyue.firstleg.split.service.SplitPreloadService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import feign.FeignException;
|
||||
@@ -116,6 +117,32 @@ class SmartLoadingServiceTest {
|
||||
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
|
||||
void restoresLoadingPlanForContinuedAssignment() {
|
||||
LoadingPlanEntity plan = plan("LOADING", 3);
|
||||
@@ -208,8 +235,8 @@ class SmartLoadingServiceTest {
|
||||
Candidate second = withGroup(withArrival(withChannel(candidate(19L, 11L, 21L, 31L, 6),
|
||||
"MATSON_CLX_TRUCK", "美森正班海卡"), "2026-09-10T09:00:00"), 2, 10);
|
||||
InboundCandidateQuery query = new InboundCandidateQuery(
|
||||
List.of("MATSON_CLX"), 100L, null, null, LocalDate.parse("2026-09-10"),
|
||||
LocalDate.parse("2026-09-10"), null, 1, 1);
|
||||
List.of("MATSON_CLX"), 100L, null, null, LocalDate.parse("2026-09-01"),
|
||||
LocalDate.parse("2026-09-01"), null, 1, 1);
|
||||
CandidateUnit unit = new CandidateUnit(700L, null, null,
|
||||
LocalDateTime.parse("2026-09-01T09:00:00"), 10L);
|
||||
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 条订单");
|
||||
String rowId = "excel-row-" + (rowNo + 1);
|
||||
try {
|
||||
validateRequiredColumns(row);
|
||||
var draft = json.createObjectNode();
|
||||
draft.put("customerName", customerName);
|
||||
draft.put("tradeTerm", "FOB");
|
||||
@@ -347,6 +348,8 @@ public class MerchantBatchImportService {
|
||||
}
|
||||
validateDestination(draft.path("destinationType").asText(null),
|
||||
fbaSelected, overseasSelected, privateSelected);
|
||||
validatePickup(draft.path("pickupMethod").asText(null),
|
||||
draft.hasNonNull("requestedPickupAt"), draft.hasNonNull("senderAddressId"));
|
||||
draft.set("cargoes", json.createArrayNode().add(cargo));
|
||||
items.add(new MerchantBatchDraftDtos.Item(rowId,
|
||||
json.treeToValue(draft, OrderRequests.SaveRequest.class)));
|
||||
@@ -496,6 +499,15 @@ public class MerchantBatchImportService {
|
||||
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) {
|
||||
String prefix = "excel-row-";
|
||||
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) {
|
||||
if (!StringUtils.hasText(name)) return code;
|
||||
return name.equalsIgnoreCase(code) ? code : name + "|" + code;
|
||||
|
||||
@@ -62,6 +62,29 @@ class MerchantBatchDraftServiceTest {
|
||||
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
|
||||
void rejectsDuplicateRowWithoutCreatingItTwice() {
|
||||
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.junit.jupiter.api.BeforeEach;
|
||||
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 java.io.ByteArrayInputStream;
|
||||
@@ -20,6 +23,7 @@ import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
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
|
||||
void importRejectsOverseasWarehouseWhenDestinationTypeIsFba() 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);
|
||||
row.createCell(5).setCellValue("亚马逊FBA");
|
||||
row.createCell(7).setCellValue("洛杉矶海外仓|US-LAX");
|
||||
fillValidSelfExpressFbaRow(row);
|
||||
row.getCell(6).setBlank();
|
||||
row.getCell(7).setCellValue("洛杉矶海外仓|US-LAX");
|
||||
workbook.write(out);
|
||||
var item = service.parse(88L, "Bearer test", out.toByteArray()).get(0);
|
||||
assertThat(item.draft()).isNull();
|
||||
@@ -205,6 +250,59 @@ class MerchantBatchImportServiceTest {
|
||||
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) {
|
||||
List<String> values = new ArrayList<>();
|
||||
for (var cell : row) values.add(cell.getStringCellValue());
|
||||
|
||||
Reference in New Issue
Block a user