✨ 导入运单支持未结算整批删除并调整结算单导入模板

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-20 14:43:51 +08:00
parent 939569e623
commit 604ae47b4f
5 changed files with 212 additions and 68 deletions
@@ -20,6 +20,12 @@ public class WaybillImportBatchVO extends WaybillImportBatch {
private static final long serialVersionUID = 1L;
private String importTypeName;
private String statusName;
/** 结算状态:unsettled/partial/settled */
@Schema(description = "结算状态")
private String settlementStatus;
/** 结算状态名称:未结算/部分结算/已结算 */
@Schema(description = "结算状态名称")
private String settlementStatusName;
private String createUserName;
private String updateUserName;
}
@@ -37,7 +37,7 @@ import java.io.Serializable;
import java.math.BigDecimal;
/**
* 导入结算单 Excel 模板(固定列 + 运输单价 + *运输费 + 示例费用项)
* 导入结算单 Excel 固定列模型(失败明细兜底导出;费用项由用户在原模板中自行增列)。
*
* @author Chill
*/
@@ -96,10 +96,6 @@ public class SettlementImportTemplateExcel implements Serializable {
private BigDecimal unitPrice;
@ExcelProperty("*运输费")
private BigDecimal transportFee;
@ExcelProperty("装卸费")
private BigDecimal loadingFee;
@ExcelProperty("过路费")
private BigDecimal tollFee;
/** 模板已去掉结算金额列;仅失败明细内部保留,不导出为表头 */
@ExcelIgnore
private BigDecimal settlementAmount;
@@ -47,12 +47,12 @@ import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* 导入结算单模板导出:填写说明 + 数据表头 + 运输方式下拉 + 系统费用项列。
* 导入结算单模板导出:填写说明 + 固定数据表头 + 运输方式下拉。
* <p>
* 模板不预置费用项列;用户可在「*运输费」与后续固定列之间自行插入系统已启用的费用项列。
*
* @author Chill
*/
@@ -93,15 +93,18 @@ public final class SettlementImportTemplateExporter {
"同一运单标识号"
);
private static final List<String> DEFAULT_FEE_ITEMS = List.of("装卸费", "过路费");
private SettlementImportTemplateExporter() {
}
public static void export(HttpServletResponse response, List<String> transportTypeOptions,
List<String> feeItemNames) {
List<String> headers = buildHeaders(feeItemNames);
List<String> transportOptions = defaultTransportOptions(transportTypeOptions);
List<String> headers = buildHeaders();
// 运输方式由调用方传入业务字典 transport_type 的字典名称,禁止写死
List<String> transportOptions = transportTypeOptions == null ? List.of() : transportTypeOptions.stream()
.filter(item -> item != null && !item.isBlank())
.map(String::trim)
.distinct()
.toList();
try (Workbook workbook = new XSSFWorkbook()) {
writeInstructionSheet(workbook, transportOptions, feeItemNames);
Sheet dataSheet = workbook.createSheet("导入结算单模板");
@@ -127,20 +130,9 @@ public final class SettlementImportTemplateExporter {
}
}
private static List<String> buildHeaders(List<String> feeItemNames) {
/** 固定列模板:不预置费用项列,由用户自行插入。 */
private static List<String> buildHeaders() {
List<String> headers = new ArrayList<>(FIXED_HEADERS_BEFORE_FEE);
Set<String> feeNames = new LinkedHashSet<>();
if (feeItemNames != null) {
for (String name : feeItemNames) {
if (name != null && !name.isBlank()) {
feeNames.add(name.trim());
}
}
}
if (feeNames.isEmpty()) {
feeNames.addAll(DEFAULT_FEE_ITEMS);
}
headers.addAll(feeNames);
headers.addAll(FIXED_HEADERS_AFTER_FEE);
return headers;
}
@@ -154,18 +146,24 @@ public final class SettlementImportTemplateExporter {
titleStyle.setFont(titleFont);
List<String> lines = new ArrayList<>();
lines.add("导入结算单填写说明");
lines.add("1. 请不要修改「导入结算单模板」工作表的表头;带 * 号为必填项。");
lines.add("2. 运输方式请从下拉项中选择,须与系统字典一致。");
lines.add("1. 请不要修改「导入结算单模板」工作表的固定表头;带 * 号为必填项。");
lines.add("2. 运输方式请从下拉项中选择,须与业务字典 transport_type 的字典名称一致。");
lines.add("3. 日期请填写 yyyy-MM-dd(如 2026-01-01);Excel 日期单元格也可直接选择。");
lines.add("4. 里程(km) 可为空;填写时须为正数。");
lines.add("5. 「*运输费」之后、「*实际发货时间」之前的列为费用项,列名须与系统费用项主数据名称完全一致;金额不为 0 时若不存在或已停用则无法导入。");
lines.add("5. 「*运输费」之后、「*实际发货时间」之前可按需自行插入费用项列,列名须与系统已启用费用项名称完全一致;金额不为 0 时若不存在或已停用则无法导入。");
lines.add("6. 模板不含结算金额列;导入后系统按「运输费 + 各费用项」自动合计结算金额。");
lines.add("7. 当前可选运输方式:" + String.join("、", transportOptions) + "。");
if (transportOptions != null && !transportOptions.isEmpty()) {
lines.add("7. 当前可选运输方式(业务字典 transport_type 名称):"
+ String.join("、", transportOptions) + "。");
} else {
lines.add("7. 运输方式请从业务字典 transport_type 维护的名称中选择。");
}
if (feeItemNames != null && !feeItemNames.isEmpty()) {
lines.add("8. 当前启用费用项示例:" + String.join("、", feeItemNames.stream().limit(30).toList())
lines.add("8. 费用项请按实际需要在模板中自行增列,列名须与系统已启用费用项一致;当前系统已启用费用项:"
+ String.join("、", feeItemNames.stream().limit(30).toList())
+ (feeItemNames.size() > 30 ? " 等" : "") + "。");
} else {
lines.add("8. 模板默认包含装卸费、过路费列,可按系统费用项主数据增删费用列。");
lines.add("8. 费用项请按实际需要在模板中自行增列,列名须与系统已启用的费用项主数据名称完全一致。");
}
for (int index = 0; index < lines.size(); index++) {
Row row = sheet.createRow(index);
@@ -201,10 +199,4 @@ public final class SettlementImportTemplateExporter {
return style;
}
private static List<String> defaultTransportOptions(List<String> transportTypeOptions) {
if (transportTypeOptions != null && !transportTypeOptions.isEmpty()) {
return transportTypeOptions;
}
return List.of("公路运输", "铁路运输", "水路运输", "航空运输");
}
}
@@ -41,7 +41,6 @@ import org.springblade.core.tool.utils.BeanUtil;
import org.springblade.core.tool.utils.DateUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.DictBizCache;
import org.springblade.system.cache.DictCache;
import org.springblade.system.cache.UserCache;
import org.springblade.system.feign.ISysClient;
import org.springblade.system.pojo.entity.DictBiz;
@@ -824,7 +823,7 @@ public class SettlementImportBatchServiceImpl
for (Map.Entry<String, BigDecimal> entry : feeItems.entrySet()) {
String name = normalizeFeeName(entry.getKey());
BigDecimal amount = entry.getValue() == null ? BigDecimal.ZERO : entry.getValue();
// 模板自带的装卸费、过路费等列未填写时金额为 0,不要求费用项主数据存在同名档案
// 用户自行增列的费用项金额为 0 时跳过校验;金额不为 0 须匹配系统已启用费用项
if (Func.isEmpty(name) || amount.compareTo(BigDecimal.ZERO) == 0) {
continue;
}
@@ -1149,9 +1148,6 @@ public class SettlementImportBatchServiceImpl
excel.setMileage(decimalValue(row, "mileage", "里程(km)", "里程"));
excel.setUnitPrice(decimalValue(row, "unitPrice", "运输单价", "单价"));
excel.setTransportFee(decimalValue(row, "transportFee", "*运输费", "运输费"));
Map<String, BigDecimal> feeItems = extractFeeItems(row, List.of("装卸费", "过路费"));
excel.setLoadingFee(feeItems.get("装卸费"));
excel.setTollFee(feeItems.get("过路费"));
excel.setSettlementAmount(decimalValue(row, "settlementAmount", "结算金额"));
excel.setActualStartDate(stringValue(row, "actualStartDate", "*实际发货时间", "实际发货时间"));
excel.setActualEndDate(stringValue(row, "actualEndDate", "*实际完成时间", "实际完成时间"));
@@ -1330,34 +1326,26 @@ public class SettlementImportBatchServiceImpl
return sanitized;
}
/**
* 运输方式取业务字典 transport_type 的字典名称(dictValue),不写死枚举。
*/
private List<String> loadTransportTypeLabels() {
LinkedHashSet<String> labels = new LinkedHashSet<>();
try {
List<DictBiz> dictBizList = DictBizCache.getList("transport_type");
if (Func.isNotEmpty(dictBizList)) {
dictBizList.stream()
.map(DictBiz::getDictValue)
.filter(dict -> dict != null && Func.isNotEmpty(dict.getDictValue())
&& !"-1".equals(Func.toStr(dict.getDictKey())))
.map(dict -> dict.getDictValue().trim())
.filter(Func::isNotEmpty)
.map(String::trim)
.forEach(labels::add);
}
} catch (Exception ignored) {
// 业务字典不可用时继续读取系统字典
}
try {
List<org.springblade.system.pojo.entity.Dict> dictList = DictCache.getList("transport_type");
if (Func.isNotEmpty(dictList)) {
dictList.stream()
.map(org.springblade.system.pojo.entity.Dict::getDictValue)
.filter(Func::isNotEmpty)
.map(String::trim)
.forEach(labels::add);
}
} catch (Exception ignored) {
// 忽略
} catch (Exception exception) {
throw new ServiceException("业务字典 transport_type 读取失败,请检查业务字典配置");
}
if (labels.isEmpty()) {
return List.of("公路运输", "铁路运输", "水路运输", "航空运输");
throw new ServiceException("业务字典 transport_type 未配置运输方式,请先维护字典名称");
}
return new ArrayList<>(labels);
}
@@ -30,7 +30,9 @@ import org.springblade.transport.excel.WaybillImportBatchExcel;
import org.springblade.transport.mapper.WaybillImportBatchMapper;
import org.springblade.transport.pojo.dto.WaybillImportBatchRequest;
import org.springblade.transport.pojo.entity.CustomerArchive;
import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.ProjectApply;
import org.springblade.transport.pojo.entity.ReceivablePayableDetail;
import org.springblade.transport.pojo.entity.TransportPlan;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.entity.WaybillImportBatch;
@@ -76,6 +78,13 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
private static final String STATUS_COMPLETED = "completed";
private static final String IMPORT_TYPE_WAYBILL = "waybill";
private static final String IMPORT_TYPE_SETTLEMENT = "settlement";
/** 批次结算状态:全部运单均未进入预/正式结算。 */
private static final String SETTLEMENT_UNSETTLED = "unsettled";
/** 批次结算状态:部分运单已进入结算。 */
private static final String SETTLEMENT_PARTIAL = "partial";
/** 批次结算状态:全部运单均已进入结算。 */
private static final String SETTLEMENT_SETTLED = "settled";
private static final String LOADING_DATA_SOURCE_IMPORT = "批量导入";
/** 车牌号校验正则:首位汉字,次位大写字母,总长度7或8位 */
private static final Pattern VEHICLE_NO_PATTERN = Pattern.compile("^[一-龥][A-Z][A-Z0-9]{5,6}$");
/** 中国机动车号牌省份简称。 */
@@ -358,6 +367,7 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
}
IPage<WaybillImportBatch> entityPage = page(page, queryWrapper);
List<WaybillImportBatchVO> records = entityPage.getRecords().stream().map(this::toVO).toList();
fillSettlementStatus(records);
Page<WaybillImportBatchVO> resultPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
resultPage.setRecords(records);
return resultPage;
@@ -389,18 +399,33 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
List<WaybillImportBatch> batches = listByIds(idList).stream()
.filter(batch -> !Objects.equals(batch.getIsDeleted(), 1))
.toList();
// 与前端一致:仅草稿可删;选中含已完成/进行中时整批拒绝
boolean hasNonDraft = batches.stream()
.anyMatch(batch -> !STATUS_DRAFT.equals(batch.getImportStatus()));
if (hasNonDraft) {
throw new ServiceException("已完成运单不可删除");
if (Func.isEmpty(batches)) {
throw new ServiceException("运单批次不存在或已删除");
}
Map<Long, List<Waybill>> waybillsByBatchId = listWaybillsByBatchIds(
batches.stream().map(WaybillImportBatch::getId).toList());
List<Long> allWaybillIds = waybillsByBatchId.values().stream()
.flatMap(List::stream)
.map(Waybill::getId)
.filter(Objects::nonNull)
.toList();
Set<Long> settledWaybillIds = receivablePayableDetailService.settlementLinkedWaybillIds(allWaybillIds);
// 仅未结算批次可整批删除;选中含部分结算/已结算时整批拒绝
for (WaybillImportBatch batch : batches) {
List<Waybill> waybills = waybillsByBatchId.getOrDefault(batch.getId(), List.of());
String settlementStatus = resolveSettlementStatus(waybills, settledWaybillIds);
if (!SETTLEMENT_UNSETTLED.equals(settlementStatus)) {
throw new ServiceException("仅未结算的运单批次可以删除");
}
}
BusinessRemoveResultVO result = new BusinessRemoveResultVO();
for (WaybillImportBatch batch : batches) {
List<Waybill> waybills = waybillService.list(Wrappers.<Waybill>lambdaQuery()
.eq(Waybill::getImportBatchId, batch.getId()).eq(Waybill::getIsDeleted, 0));
if (Func.isNotEmpty(waybills)) {
waybillService.deleteLogic(waybills.stream().map(Waybill::getId).toList());
List<Waybill> waybills = waybillsByBatchId.getOrDefault(batch.getId(), List.of());
List<Long> waybillIds = waybills.stream().map(Waybill::getId).filter(Objects::nonNull).toList();
if (Func.isNotEmpty(waybillIds)) {
clearUnsettledReceivableDetails(waybillIds);
clearImportedLoadings(waybills);
waybillService.deleteLogic(waybillIds);
}
deleteLogic(List.of(batch.getId()));
result.setSuccessCount(result.getSuccessCount() + 1);
@@ -671,11 +696,148 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
if (vo == null) throw new ServiceException("运单批次数据转换失败");
vo.setImportTypeName(IMPORT_TYPE_SETTLEMENT.equals(batch.getImportType()) ? "结算单" : "运单");
vo.setStatusName(importStatusName(batch.getImportStatus()));
vo.setSettlementStatus(SETTLEMENT_UNSETTLED);
vo.setSettlementStatusName(settlementStatusName(SETTLEMENT_UNSETTLED));
vo.setCreateUserName(UserCache.getUserRealName(batch.getCreateUser()));
vo.setUpdateUserName(UserCache.getUserRealName(batch.getUpdateUser()));
return vo;
}
/**
* 按批次下运单是否已关联预/正式结算单,汇总结算状态:
* 全部未关联=未结算,部分关联=部分结算,全部关联=已结算。
*/
private void fillSettlementStatus(List<WaybillImportBatchVO> records) {
if (Func.isEmpty(records)) {
return;
}
List<Long> batchIds = records.stream().map(WaybillImportBatchVO::getId).filter(Objects::nonNull).toList();
Map<Long, List<Waybill>> waybillsByBatchId = listWaybillsByBatchIds(batchIds);
List<Long> allWaybillIds = waybillsByBatchId.values().stream()
.flatMap(List::stream)
.map(Waybill::getId)
.filter(Objects::nonNull)
.toList();
Set<Long> settledWaybillIds = receivablePayableDetailService.settlementLinkedWaybillIds(allWaybillIds);
for (WaybillImportBatchVO record : records) {
List<Waybill> waybills = waybillsByBatchId.getOrDefault(record.getId(), List.of());
String settlementStatus = resolveSettlementStatus(waybills, settledWaybillIds);
record.setSettlementStatus(settlementStatus);
record.setSettlementStatusName(settlementStatusName(settlementStatus));
}
}
private Map<Long, List<Waybill>> listWaybillsByBatchIds(List<Long> batchIds) {
if (Func.isEmpty(batchIds)) {
return Map.of();
}
List<Waybill> waybills = waybillService.list(Wrappers.<Waybill>lambdaQuery()
.select(Waybill::getId, Waybill::getImportBatchId, Waybill::getLoadingNo)
.in(Waybill::getImportBatchId, batchIds)
.eq(Waybill::getIsDeleted, 0));
return waybills.stream()
.filter(waybill -> waybill.getImportBatchId() != null)
.collect(Collectors.groupingBy(Waybill::getImportBatchId));
}
private String resolveSettlementStatus(List<Waybill> waybills, Set<Long> settledWaybillIds) {
if (Func.isEmpty(waybills)) {
return SETTLEMENT_UNSETTLED;
}
int settledCount = 0;
for (Waybill waybill : waybills) {
if (waybill.getId() != null && settledWaybillIds.contains(waybill.getId())) {
settledCount++;
}
}
if (settledCount <= 0) {
return SETTLEMENT_UNSETTLED;
}
if (settledCount >= waybills.size()) {
return SETTLEMENT_SETTLED;
}
return SETTLEMENT_PARTIAL;
}
private String settlementStatusName(String settlementStatus) {
if (SETTLEMENT_SETTLED.equals(settlementStatus)) {
return "已结算";
}
if (SETTLEMENT_PARTIAL.equals(settlementStatus)) {
return "部分结算";
}
return "未结算";
}
/** 删除批次前清理尚未进入预/正式结算的应收应付明细,避免残留待结算数据。 */
private void clearUnsettledReceivableDetails(List<Long> waybillIds) {
if (Func.isEmpty(waybillIds)) {
return;
}
List<ReceivablePayableDetail> details = receivablePayableDetailService.list(
Wrappers.<ReceivablePayableDetail>lambdaQuery()
.in(ReceivablePayableDetail::getWaybillId, waybillIds)
.eq(ReceivablePayableDetail::getIsDeleted, 0)
.and(wrapper -> wrapper.isNull(ReceivablePayableDetail::getPreSettlementNo)
.or().eq(ReceivablePayableDetail::getPreSettlementNo, ""))
.and(wrapper -> wrapper.isNull(ReceivablePayableDetail::getFormalSettlementNo)
.or().eq(ReceivablePayableDetail::getFormalSettlementNo, "")));
if (Func.isNotEmpty(details)) {
receivablePayableDetailService.deleteLogic(
details.stream().map(ReceivablePayableDetail::getId).toList());
}
}
/** 清理本批次运单关联的批量导入配载单(配载单运单均属本批时整单删除)。 */
private void clearImportedLoadings(List<Waybill> waybills) {
if (Func.isEmpty(waybills)) {
return;
}
Set<Long> batchWaybillIds = waybills.stream()
.map(Waybill::getId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(HashSet::new));
Set<String> loadingNos = waybills.stream()
.map(Waybill::getLoadingNo)
.filter(Func::isNotEmpty)
.collect(Collectors.toCollection(HashSet::new));
if (Func.isEmpty(loadingNos)) {
return;
}
List<LoadingManage> loadings = loadingManageService.list(Wrappers.<LoadingManage>lambdaQuery()
.in(LoadingManage::getLoadingNo, loadingNos)
.eq(LoadingManage::getDataSource, LOADING_DATA_SOURCE_IMPORT)
.eq(LoadingManage::getIsDeleted, 0));
List<Long> deleteLoadingIds = new ArrayList<>();
for (LoadingManage loading : loadings) {
List<Long> loadingWaybillIds = parseLoadingWaybillIds(loading.getWaybillIdsJson());
if (Func.isEmpty(loadingWaybillIds) || batchWaybillIds.containsAll(loadingWaybillIds)) {
deleteLoadingIds.add(loading.getId());
}
}
if (Func.isNotEmpty(deleteLoadingIds)) {
loadingManageService.deleteLogic(deleteLoadingIds);
}
}
private List<Long> parseLoadingWaybillIds(String waybillIdsJson) {
if (Func.isEmpty(waybillIdsJson)) {
return List.of();
}
try {
Object parsed = JsonUtil.parse(waybillIdsJson, List.class);
if (parsed instanceof List<?> list) {
return list.stream()
.filter(Objects::nonNull)
.map(item -> Long.valueOf(String.valueOf(item)))
.toList();
}
} catch (Exception ignored) {
// 兼容历史逗号分隔存储
}
return Func.toLongList(waybillIdsJson);
}
@Override
public String nextBatchNo() {
return nextCode();