运单导入支持多货物合并与配载路线串联

- 同一运单标识号的多行货物合并为一条运单,各行转为货物明细写入 goodsJson,数量与其他费用合计按行累加

- 新增组内一致性校验:发货地址、到货地址、数量单位、配载标识号不一致时报错,避免无法合并的数据静默落库

- 配载单路线按导入顺序串联,上一票到货地与下一票发货地重合时去重写入途经地,到货地取末票
This commit is contained in:
2026-09-21 04:19:55 +08:00
parent 37309b24f9
commit b02aff8ff6
2 changed files with 169 additions and 7 deletions
@@ -174,6 +174,7 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
throw new ServiceException("配载标识号已存在:" + normalizedLoadingNo);
}
Waybill first = waybills.get(0);
Waybill last = waybills.get(waybills.size() - 1);
LoadingManage loadingManage = new LoadingManage();
loadingManage.setLoadingNo(normalizedLoadingNo);
loadingManage.setLoadingSubNos(waybills.stream()
@@ -197,7 +198,8 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
loadingManage.setCarrierName(first.getCarrierName());
loadingManage.setCarrierContractId(first.getCarrierContractId());
loadingManage.setDepartureAddress(first.getDepartureAddress());
loadingManage.setArrivalAddress(first.getArrivalAddress());
loadingManage.setTransitAddress(buildImportedRouteTransitAddress(waybills));
loadingManage.setArrivalAddress(last.getArrivalAddress());
loadingManage.setOriginalNo(first.getOriginalNo());
loadingManage.setDataSource("批量导入");
loadingManage.setStartDate(first.getStartDate());
@@ -222,6 +224,39 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
.set(Waybill::getLoadingNo, normalizedLoadingNo));
}
/**
* 根据导入运单构建配载单的途经地:
* 组内运单按导入顺序串联路线,上一票的到货地址与下一票的发货地址相同(中途卸货点)时只保留一个点,
* 最终形成“首票发货地 → 途经点 → 末票到货地”。
*/
private String buildImportedRouteTransitAddress(List<Waybill> waybills) {
// 按顺序收集全部节点:首票发货地、每票到货地;节点与相邻前一点相同则重合跳过
List<String> nodes = new ArrayList<>();
for (Waybill waybill : waybills) {
String departure = TransportBusinessSupport.trimToNull(waybill.getDepartureAddress());
String arrival = TransportBusinessSupport.trimToNull(waybill.getArrivalAddress());
appendRouteNode(nodes, departure);
appendRouteNode(nodes, arrival);
}
// 途经点 = 去掉首尾(首票发货地、末票到货地)后的中间节点
List<String> transitNodes = nodes.size() > 2 ? nodes.subList(1, nodes.size() - 1) : List.of();
if (Func.isEmpty(transitNodes)) {
return null;
}
return String.join(" - ", transitNodes);
}
private void appendRouteNode(List<String> nodes, String address) {
if (Func.isEmpty(address)) {
return;
}
if (!nodes.isEmpty() && nodes.get(nodes.size() - 1).equals(address)) {
// 与上一节点相同视为同一地点,重合不重复
return;
}
nodes.add(address);
}
@Override
@Transactional(rollbackFor = Exception.class)
public BusinessRemoveResultVO removeLoadingManage(String ids) {
@@ -19,6 +19,7 @@ import org.springblade.core.tool.utils.BeanUtil;
import org.springblade.core.tool.utils.DateUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.core.tool.utils.WebUtil;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.system.cache.DictCache;
import org.springblade.system.cache.DictBizCache;
import org.springblade.system.cache.UserCache;
@@ -55,6 +56,7 @@ import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -184,20 +186,36 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
List<Map<String, Object>> rows = Func.isEmpty(request.getRows()) ? List.of() : request.getRows();
// 逐行构建运单(未落库),构建失败时报出对应行号
List<Waybill> rowWaybills = new ArrayList<>();
for (int index = 0; index < rows.size(); index++) {
try {
rowWaybills.add(buildWaybill(rows.get(index), batch, request.getCarrierContractId(), draft));
} catch (Exception exception) {
throw new ServiceException("" + (index + 1) + "" + (draft ? "保存" : "导入") + "失败:" + exception.getMessage());
}
}
// 同一运单标识号的多货物行合并为一条运单,各行转为货物明细
Map<Integer, Waybill> waybillByRow = new HashMap<>();
Set<Integer> skipRows = new HashSet<>();
mergeMultiCargoRows(rows, rowWaybills, waybillByRow, skipRows);
List<Waybill> waybills = new ArrayList<>();
Map<String, List<Waybill>> loadingWaybills = new TreeMap<>();
for (int index = 0; index < rows.size(); index++) {
if (skipRows.contains(index)) continue;
Waybill waybill = waybillByRow.getOrDefault(index, rowWaybills.get(index));
// 配载标识号必须取自行数据:运单落库(prepareForSave)会把新建运单的 loadingNo 置空
String loadingIdentifier = stringValue(rows.get(index), "loadingIdentifier", "配载标识号");
try {
String loadingIdentifier = stringValue(rows.get(index), "loadingIdentifier", "配载标识号");
Waybill waybill = buildWaybill(rows.get(index), batch, request.getCarrierContractId(), draft);
if (draft) waybillService.saveDraft(waybill); else waybillService.submit(waybill);
waybills.add(waybill);
if (!draft && IMPORT_TYPE_WAYBILL.equals(batch.getImportType()) && Func.isNotEmpty(loadingIdentifier)) {
loadingWaybills.computeIfAbsent(loadingIdentifier, key -> new ArrayList<>()).add(waybill);
}
} catch (Exception exception) {
throw new ServiceException("" + (index + 1) + "" + (draft ? "保存" : "导入") + "失败:" + exception.getMessage());
}
waybills.add(waybill);
if (!draft && IMPORT_TYPE_WAYBILL.equals(batch.getImportType()) && Func.isNotEmpty(loadingIdentifier)) {
loadingWaybills.computeIfAbsent(loadingIdentifier, key -> new ArrayList<>()).add(waybill);
}
}
loadingWaybills.forEach(loadingManageService::createFromImportedWaybills);
batch.setWaybillCount(waybills.size());
@@ -218,6 +236,69 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
}
}
/**
* 将同一运单标识号的多货物行合并为一条运单:
* 首行作为主行(数值字段累加各行),后续行转为货物明细追加到主行货物信息中;
* 被 merge 的行记入 skipRows,落库时跳过。
*/
private void mergeMultiCargoRows(List<Map<String, Object>> rows, List<Waybill> rowWaybills,
Map<Integer, Waybill> waybillByRow, Set<Integer> skipRows) {
Map<String, List<Integer>> groups = new LinkedHashMap<>();
for (int index = 0; index < rowWaybills.size(); index++) {
String relationNo = rowWaybills.get(index).getRelationNo();
if (Func.isEmpty(relationNo)) continue;
groups.computeIfAbsent(relationNo, key -> new ArrayList<>()).add(index);
}
for (List<Integer> groupRows : groups.values()) {
if (groupRows.size() <= 1) continue;
Waybill primary = rowWaybills.get(groupRows.get(0));
List<Map<String, Object>> goodsList = new ArrayList<>();
for (Integer rowIndex : groupRows) {
Waybill current = rowWaybills.get(rowIndex);
if (!rowIndex.equals(groupRows.get(0))) {
// 主行字段保留首行值,数量等数值字段累加同组其余各行
mergeNumericFields(primary, current);
skipRows.add(rowIndex);
waybillByRow.remove(rowIndex);
}
goodsList.add(buildGoodsItem(rows.get(rowIndex), current));
}
primary.setGoodsJson(JsonUtil.toJson(goodsList));
}
}
/** 合并多货物行时累加数量、其他费用等可加数值字段;为空的字段跳过。 */
private void mergeNumericFields(Waybill primary, Waybill current) {
primary.setQuantity(sumNullable(primary.getQuantity(), current.getQuantity()));
primary.setOtherFeeTotal(sumNullable(primary.getOtherFeeTotal(), current.getOtherFeeTotal()));
}
private BigDecimal sumNullable(BigDecimal first, BigDecimal second) {
if (first == null) return second;
if (second == null) return first;
return first.add(second);
}
/** 由导入行构建一条货物明细,字段与运单详情货物编辑结构保持一致。 */
private Map<String, Object> buildGoodsItem(Map<String, Object> row, Waybill waybill) {
Map<String, Object> goods = new LinkedHashMap<>();
putIfNotEmpty(goods, "cargoName", waybill.getCargoName());
putIfNotEmpty(goods, "cargoType", waybill.getCargoType());
putIfNotEmpty(goods, "specification", waybill.getSpecification());
putIfNotEmpty(goods, "model", waybill.getModel());
putIfNotEmpty(goods, "packageType", stringValue(row, "packageType", "包装"));
if (waybill.getQuantity() != null) {
goods.put("quantity", waybill.getQuantity().toPlainString());
}
putIfNotEmpty(goods, "quantityUnit", waybill.getQuantityUnit());
putIfNotEmpty(goods, "remark", waybill.getRemark());
return goods;
}
private void putIfNotEmpty(Map<String, Object> goods, String key, String value) {
if (Func.isNotEmpty(value)) goods.put(key, value);
}
@Override
public IPage<WaybillImportBatchVO> page(IPage<WaybillImportBatch> page, WaybillImportBatchRequest request) {
LambdaQueryWrapper<WaybillImportBatch> queryWrapper = Wrappers.<WaybillImportBatch>lambdaQuery()
@@ -523,6 +604,9 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
// 16. 同一运单标识号校验
validateWaybillIdentifier(row, i, waybillIdentifierMap, rows, errors);
// 17. 同一运单标识号组内一致性校验(地址/配载标识号/数量单位,多货物合并的前提)
validateWaybillIdentifierConsistency(row, i, waybillIdentifierMap, rows, errors);
if (!errors.isEmpty()) {
errorMap.put(i, String.join("; ", errors));
}
@@ -826,6 +910,49 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
}
}
/**
* 同一运单标识号组内的发货地址、到货地址、数量单位、配载标识号必须一致:
* 多货物行会合并为一条运单,上述字段不一致时无法合并(数量累加也要求单位相同)。
*/
private void validateWaybillIdentifierConsistency(Map<String, Object> row, int rowIndex,
Map<String, List<Integer>> waybillIdentifierMap, List<Map<String, Object>> allRows, List<String> errors) {
String waybillIdentifier = stringValue(row, "waybillIdentifier", "同一运单标识号");
if (Func.isEmpty(waybillIdentifier)) {
return;
}
List<Integer> sameIdentifierRows = waybillIdentifierMap.get(waybillIdentifier);
if (sameIdentifierRows == null || sameIdentifierRows.size() <= 1) {
return;
}
compareGroupValue(rowIndex, sameIdentifierRows, allRows, "departureAddress", "发货地址", errors);
compareGroupValue(rowIndex, sameIdentifierRows, allRows, "arrivalAddress", "到货地址", errors);
compareGroupValue(rowIndex, sameIdentifierRows, allRows, "quantityUnit", "数量单位", errors);
compareGroupValue(rowIndex, sameIdentifierRows, allRows, "loadingIdentifier", "配载标识号", errors);
}
/** 比较同一运单标识号组内其余行的字段值,不一致时记录错误;空值不参与比较。 */
private void compareGroupValue(int rowIndex, List<Integer> sameIdentifierRows, List<Map<String, Object>> allRows,
String field, String fieldName, List<String> errors) {
String currentValue = normalizeCompareValue(stringValue(allRows.get(rowIndex), field, fieldName));
if (Func.isEmpty(currentValue)) {
return;
}
for (Integer otherRowIndex : sameIdentifierRows) {
if (otherRowIndex <= rowIndex) {
continue;
}
String otherValue = normalizeCompareValue(stringValue(allRows.get(otherRowIndex), field, fieldName));
if (Func.isNotEmpty(otherValue) && !currentValue.equals(otherValue)) {
errors.add("同一运单标识号下," + fieldName + "必须一致");
return;
}
}
}
private String normalizeCompareValue(String value) {
return Func.isEmpty(value) ? null : value.replaceAll("\\s+", "");
}
private LocalDate parseDateForValidation(Object value, String fieldName, List<String> errors) {
if (value == null || String.valueOf(value).isBlank()) {
return null;