✨ 完善导入结算单模板校验、草稿明细与历史运单选择

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-18 20:35:28 +08:00
parent 583bab1a4f
commit ddaa6f2c45
9 changed files with 573 additions and 24 deletions
@@ -107,6 +107,9 @@ public class SettlementImportBatch extends TenantEntity {
@Schema(description = "动态费用列名JSON")
private String feeItemNamesJson;
@Schema(description = "导入明细行JSON")
private String rowsJson;
@Schema(description = "备注")
private String remark;
@@ -32,6 +32,7 @@ import org.springblade.transport.pojo.entity.SettlementImportBatch;
import java.io.Serial;
import java.util.List;
import java.util.Map;
/**
* 导入结算单批次视图
@@ -66,4 +67,7 @@ public class SettlementImportBatchVO extends SettlementImportBatch {
@Schema(description = "动态费用列名")
private List<String> feeItemNames;
@Schema(description = "导入明细行")
private List<Map<String, Object>> rows;
}
@@ -144,4 +144,19 @@ public class SettlementImportController extends BladeController {
settlementImportBatchService.voidBatch(request);
return R.success("作废成功");
}
@PostMapping("/remove")
@ApiOperationSupport(order = 12)
@Operation(summary = "删除草稿批次")
public R remove(@RequestParam Long id) {
settlementImportBatchService.removeDraft(id);
return R.success("删除成功");
}
@GetMapping("/history-waybills")
@ApiOperationSupport(order = 13)
@Operation(summary = "历史运单可选列表")
public R<List<Map<String, Object>>> historyWaybills(SettlementImportBatchRequest request) {
return R.data(settlementImportBatchService.historyWaybills(request));
}
}
@@ -26,7 +26,9 @@
package org.springblade.transport.excel;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.DateUtil;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
@@ -38,11 +40,16 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* 导入结算单 Excel 读取器(Apache POI)。
@@ -54,6 +61,11 @@ import java.util.Map;
public final class SettlementImportFeeReader {
private static final DataFormatter DATA_FORMATTER = new DataFormatter(Locale.CHINA);
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final Set<String> DATE_FIELDS = Set.of(
"actualStartDate", "actualEndDate", "planStartDate", "planEndDate"
);
private static final Set<String> OPTIONAL_DECIMAL_FIELDS = Set.of("mileage", "unitPrice");
private SettlementImportFeeReader() {
}
@@ -193,23 +205,26 @@ public final class SettlementImportFeeReader {
for (Map.Entry<Integer, String> entry : headers.entrySet()) {
int column = entry.getKey();
String trimmedHeader = entry.getValue();
String cell = cellString(row.getCell(column));
Cell poiCell = row.getCell(column);
if (feeColumns.contains(column)) {
feeItems.put(trimmedHeader, decimal(cell, trimmedHeader, excelRowIndex));
feeItems.put(trimmedHeader, decimal(cellString(poiCell), trimmedHeader, excelRowIndex));
continue;
}
String field = FIXED_HEADER_MAP.get(trimmedHeader);
if (field == null) {
continue;
}
if ("transportFee".equals(field) || "unitPrice".equals(field)
|| "quantity".equals(field) || "mileage".equals(field)
if (DATE_FIELDS.contains(field)) {
mapped.put(field, dateCellString(poiCell));
} else if (OPTIONAL_DECIMAL_FIELDS.contains(field)) {
mapped.put(field, optionalDecimal(cellString(poiCell), trimmedHeader, excelRowIndex));
} else if ("transportFee".equals(field) || "quantity".equals(field)
|| "settlementAmount".equals(field)) {
mapped.put(field, decimal(cell, trimmedHeader, excelRowIndex));
mapped.put(field, decimal(cellString(poiCell), trimmedHeader, excelRowIndex));
} else if ("serialNumber".equals(field)) {
mapped.put(field, intValue(cell));
mapped.put(field, intValue(cellString(poiCell)));
} else {
mapped.put(field, blankToNull(cell));
mapped.put(field, blankToNull(cellString(poiCell)));
}
mapped.put(trimmedHeader, mapped.get(field));
}
@@ -278,6 +293,69 @@ public final class SettlementImportFeeReader {
return text.isEmpty() ? null : text;
}
/**
* 读取日期单元格:Excel 日期序列优先格式化为 yyyy-MM-dd;
* 文本短日期(如 1/1/26、10/10/2026)归一为四位年,供后续 LenientDateParser 识别。
*/
private static String dateCellString(Cell cell) {
if (cell == null) {
return null;
}
CellType cellType = cell.getCellType();
if (cellType == CellType.FORMULA) {
cellType = cell.getCachedFormulaResultType();
}
if (cellType == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) {
Date date = cell.getDateCellValue();
if (date == null) {
return null;
}
LocalDate localDate = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
return DATE_FORMATTER.format(localDate);
}
String text = cellString(cell);
return normalizeExcelShortDate(text);
}
/** 将 Excel 常见短日期文本归一为 yyyy-MM-dd;无法识别时原样返回交由校验层报错。 */
private static String normalizeExcelShortDate(String text) {
if (text == null || text.isBlank()) {
return null;
}
String normalized = text.trim().replace('.', '/').replace('-', '/');
String[] parts = normalized.split("[/\\s]+");
if (parts.length < 3) {
return text.trim();
}
try {
int first = Integer.parseInt(parts[0]);
int second = Integer.parseInt(parts[1]);
int third = Integer.parseInt(parts[2].replaceAll("[^0-9].*$", ""));
int year;
int month;
int day;
if (parts[0].length() == 4) {
year = first;
month = second;
day = third;
} else if (parts[2].length() == 4 || parts[2].length() == 2) {
// M/d/yy 或 M/d/yyyy(Excel 中文本/短日期常见)
month = first;
day = second;
year = parts[2].length() == 2 ? 2000 + third : third;
if (parts[2].length() == 2 && third >= 100) {
return text.trim();
}
} else {
return text.trim();
}
LocalDate localDate = LocalDate.of(year, month, day);
return DATE_FORMATTER.format(localDate);
} catch (Exception exception) {
return text.trim();
}
}
private static BigDecimal decimal(String cell, String columnName, Integer excelRowIndex) {
if (cell == null || cell.isBlank()) {
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
@@ -290,6 +368,14 @@ public final class SettlementImportFeeReader {
}
}
/** 可选数值:空白返回 null,避免空里程被当成 0 触发「必须为正数」。 */
private static BigDecimal optionalDecimal(String cell, String columnName, Integer excelRowIndex) {
if (cell == null || cell.isBlank()) {
return null;
}
return decimal(cell, columnName, excelRowIndex);
}
private static Integer intValue(String cell) {
if (cell == null || cell.isBlank()) {
return null;
@@ -0,0 +1,211 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.excel;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.DataValidation;
import org.apache.poi.ss.usermodel.DataValidationConstraint;
import org.apache.poi.ss.usermodel.DataValidationHelper;
import org.apache.poi.ss.usermodel.FillPatternType;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.IndexedColors;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.VerticalAlignment;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.util.CellRangeAddressList;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springblade.core.log.exception.ServiceException;
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;
/**
* 导入结算单模板导出:填写说明 + 数据表头 + 运输方式下拉 + 系统费用项列。
*
* @author Chill
*/
public final class SettlementImportTemplateExporter {
private static final List<String> FIXED_HEADERS_BEFORE_FEE = List.of(
"序号",
"原始单号",
"配载标识号",
"*车牌号/航班号/船号/班列号",
"*运输方式",
"司机/船长姓名",
"司机/船长手机号",
"*发货地址",
"发货联系人",
"发货联系人电话",
"*到货地址",
"收货联系人",
"收货联系人电话",
"*货物名称",
"*货物类型",
"包装",
"*数量",
"*数量单位",
"规格",
"型号",
"里程(km)",
"运输单价",
"*运输费"
);
private static final List<String> FIXED_HEADERS_AFTER_FEE = List.of(
"结算金额",
"*实际发货时间",
"*实际完成时间",
"预计发货时间",
"预计完成时间",
"备注",
"同一运单标识号"
);
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);
try (Workbook workbook = new XSSFWorkbook()) {
writeInstructionSheet(workbook, transportOptions, feeItemNames);
Sheet dataSheet = workbook.createSheet("导入结算单模板");
CellStyle headerStyle = createHeaderStyle(workbook);
Row headerRow = dataSheet.createRow(0);
for (int index = 0; index < headers.size(); index++) {
Cell cell = headerRow.createCell(index);
cell.setCellValue(headers.get(index));
cell.setCellStyle(headerStyle);
dataSheet.setColumnWidth(index, Math.min(28, Math.max(12, headers.get(index).length() + 4)) * 256);
}
int transportTypeColumn = headers.indexOf("*运输方式");
if (transportTypeColumn >= 0 && !transportOptions.isEmpty()) {
addDropdown(dataSheet, transportTypeColumn, transportOptions);
}
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setHeader("Content-disposition",
"attachment;filename=" + URLEncoder.encode("导入结算单模板", StandardCharsets.UTF_8) + ".xlsx");
workbook.write(response.getOutputStream());
} catch (IOException exception) {
throw new ServiceException("导出导入结算单模板失败:" + exception.getMessage());
}
}
private static List<String> buildHeaders(List<String> feeItemNames) {
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;
}
private static void writeInstructionSheet(Workbook workbook, List<String> transportOptions,
List<String> feeItemNames) {
Sheet sheet = workbook.createSheet("填写说明");
CellStyle titleStyle = workbook.createCellStyle();
Font titleFont = workbook.createFont();
titleFont.setBold(true);
titleStyle.setFont(titleFont);
List<String> lines = new ArrayList<>();
lines.add("导入结算单填写说明");
lines.add("1. 请不要修改「导入结算单模板」工作表的表头;带 * 号为必填项。");
lines.add("2. 运输方式请从下拉项中选择,须与系统字典一致。");
lines.add("3. 日期请填写 yyyy-MM-dd(如 2026-01-01);Excel 日期单元格也可直接选择。");
lines.add("4. 里程(km) 可为空;填写时须为正数。");
lines.add("5. 「*运输费」之后、「结算金额」之前的列为费用项,列名须与系统费用项主数据名称完全一致;金额不为 0 时若不存在或已停用则无法导入。");
lines.add("6. 结算金额 = 运输费 + 各费用项合计,系统会按此重算。");
lines.add("7. 当前可选运输方式:" + String.join("、", transportOptions) + "。");
if (feeItemNames != null && !feeItemNames.isEmpty()) {
lines.add("8. 当前启用费用项示例:" + String.join("、", feeItemNames.stream().limit(30).toList())
+ (feeItemNames.size() > 30 ? " 等" : "") + "。");
} else {
lines.add("8. 模板默认包含装卸费、过路费列,可按系统费用项主数据增删费用列。");
}
for (int index = 0; index < lines.size(); index++) {
Row row = sheet.createRow(index);
Cell cell = row.createCell(0);
cell.setCellValue(lines.get(index));
if (index == 0) {
cell.setCellStyle(titleStyle);
}
}
sheet.setColumnWidth(0, 120 * 256);
}
private static void addDropdown(Sheet sheet, int columnIndex, List<String> options) {
DataValidationHelper helper = sheet.getDataValidationHelper();
String[] values = options.toArray(new String[0]);
DataValidationConstraint constraint = helper.createExplicitListConstraint(values);
CellRangeAddressList addressList = new CellRangeAddressList(1, 2000, columnIndex, columnIndex);
DataValidation validation = helper.createValidation(constraint, addressList);
validation.setEmptyCellAllowed(true);
validation.setShowErrorBox(true);
sheet.addValidationData(validation);
}
private static CellStyle createHeaderStyle(Workbook workbook) {
CellStyle style = workbook.createCellStyle();
style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
style.setAlignment(HorizontalAlignment.CENTER);
style.setVerticalAlignment(VerticalAlignment.CENTER);
Font font = workbook.createFont();
font.setBold(true);
style.setFont(font);
return style;
}
private static List<String> defaultTransportOptions(List<String> transportTypeOptions) {
if (transportTypeOptions != null && !transportTypeOptions.isEmpty()) {
return transportTypeOptions;
}
return List.of("公路运输", "铁路运输", "水路运输", "航空运输");
}
}
@@ -66,4 +66,12 @@ public interface ISettlementImportBatchService extends BaseService<SettlementImp
SettlementImportBatchVO confirm(SettlementImportBatchRequest request);
void voidBatch(SettlementImportVoidRequest request);
/** 删除草稿批次(仅 draft 可删)。 */
void removeDraft(Long id);
/**
* 历史运单可选列表:相同项目、客户合同、承运商合同下,尚未生成对应应收/应付明细的运单。
*/
List<Map<String, Object>> historyWaybills(SettlementImportBatchRequest request);
}
@@ -32,7 +32,6 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springblade.common.excel.ImportFailureExcelUtil;
import org.springblade.core.excel.util.ExcelUtil;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.secure.utils.AuthUtil;
@@ -41,12 +40,16 @@ import org.springblade.core.tool.jackson.JsonUtil;
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;
import org.springblade.system.pojo.entity.FeeItem;
import org.springblade.transport.constant.TransportBusiConstant;
import org.springblade.transport.excel.SettlementImportFeeReader;
import org.springblade.transport.excel.SettlementImportTemplateExcel;
import org.springblade.transport.excel.SettlementImportTemplateExporter;
import org.springblade.transport.mapper.SettlementImportBatchMapper;
import org.springblade.transport.pojo.dto.FormalSettlementSaveRequest;
import org.springblade.transport.pojo.dto.FormalSettlementStatusRequest;
@@ -186,6 +189,7 @@ public class SettlementImportBatchServiceImpl
.eq(SettlementImportBatch::getIsDeleted, 0)
.like(Func.isNotEmpty(request.getBatchNo()), SettlementImportBatch::getBatchNo, request.getBatchNo())
.eq(Func.isNotEmpty(request.getSettlementType()), SettlementImportBatch::getSettlementType, request.getSettlementType())
.eq(Func.isNotEmpty(request.getImportStatus()), SettlementImportBatch::getImportStatus, request.getImportStatus())
.like(Func.isNotEmpty(request.getProjectName()), SettlementImportBatch::getProjectName, request.getProjectName())
.like(Func.isNotEmpty(request.getCustomerName()), SettlementImportBatch::getCustomerName, request.getCustomerName())
.like(Func.isNotEmpty(request.getCarrierName()), SettlementImportBatch::getCarrierName, request.getCarrierName())
@@ -204,13 +208,18 @@ public class SettlementImportBatchServiceImpl
if (batch == null || Objects.equals(batch.getIsDeleted(), 1)) {
throw new ServiceException("导入结算单批次不存在");
}
return toVO(batch);
SettlementImportBatchVO vo = toVO(batch);
List<Map<String, Object>> rows = parseRowsJson(batch.getRowsJson());
if (Func.isEmpty(rows) && !STATUS_DRAFT.equals(batch.getImportStatus())) {
rows = buildRowsFromWaybills(listBatchWaybills(batch));
}
vo.setRows(rows);
return vo;
}
@Override
public void exportTemplate(HttpServletResponse response) {
List<SettlementImportTemplateExcel> sample = new ArrayList<>();
ExcelUtil.export(response, "导入结算单模板", "导入结算单模板", sample, SettlementImportTemplateExcel.class);
SettlementImportTemplateExporter.export(response, loadTransportTypeLabels(), loadEnabledFeeItemNames());
}
@Override
@@ -270,6 +279,7 @@ public class SettlementImportBatchServiceImpl
@Override
@Transactional(rollbackFor = Exception.class)
public SettlementImportBatchVO saveDraft(SettlementImportBatchRequest request) {
prepareSettlementRows(request);
SettlementImportBatch batch = buildDraftBatch(request);
if (Func.isNotEmpty(request.getId())) {
SettlementImportBatch oldBatch = getById(request.getId());
@@ -282,10 +292,14 @@ public class SettlementImportBatchServiceImpl
batch.setId(oldBatch.getId());
batch.setBatchNo(oldBatch.getBatchNo());
}
batch.setWaybillCount(0);
batch.setDetailCount(0);
List<Map<String, Object>> draftRows = sanitizeRowsForStorage(request.getRows());
batch.setRowsJson(JsonUtil.toJson(draftRows));
batch.setWaybillCount(primaryRows(draftRows).size());
batch.setDetailCount(draftRows.size());
saveOrUpdate(batch);
return toVO(batch);
SettlementImportBatchVO vo = toVO(batch);
vo.setRows(draftRows);
return vo;
}
@Override
@@ -399,8 +413,73 @@ public class SettlementImportBatchServiceImpl
}
batch.setImportStatus(STATUS_COMPLETED);
batch.setFeeItemNamesJson(JsonUtil.toJson(resolveFeeItemNames(request)));
batch.setRowsJson(JsonUtil.toJson(sanitizeRowsForStorage(request.getRows())));
updateById(batch);
return toVO(batch);
SettlementImportBatchVO vo = toVO(batch);
vo.setRows(parseRowsJson(batch.getRowsJson()));
return vo;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void removeDraft(Long id) {
if (id == null) {
throw new ServiceException("请选择需要删除的批次");
}
SettlementImportBatch batch = getById(id);
if (batch == null || Objects.equals(batch.getIsDeleted(), 1)) {
throw new ServiceException("导入结算单批次不存在");
}
if (!STATUS_DRAFT.equals(batch.getImportStatus())) {
throw new ServiceException("仅草稿状态的批次允许删除");
}
deleteLogic(List.of(id));
}
@Override
public List<Map<String, Object>> historyWaybills(SettlementImportBatchRequest request) {
if (request == null || Func.isEmpty(request.getProjectId())
|| Func.isEmpty(request.getCustomerContractId())
|| Func.isEmpty(request.getCarrierContractId())) {
throw new ServiceException("请先选择项目、客户合同和承运商合同");
}
String settlementType = Func.isEmpty(request.getSettlementType())
? TYPE_RECEIVABLE : request.getSettlementType();
if (!TYPE_RECEIVABLE.equals(settlementType) && !TYPE_PAYABLE.equals(settlementType)) {
throw new ServiceException("结算类型不正确");
}
LambdaQueryWrapper<Waybill> wrapper = Wrappers.<Waybill>lambdaQuery()
.eq(Waybill::getIsDeleted, 0)
.eq(Waybill::getProjectId, request.getProjectId())
.eq(Waybill::getContractId, request.getCustomerContractId())
.eq(Waybill::getCarrierContractId, request.getCarrierContractId())
.apply("not exists (select 1 from blade_receivable_payable_detail d"
+ " where d.waybill_id = blade_waybill.id and d.settlement_type = {0}"
+ " and d.is_deleted = 0)", settlementType)
.orderByDesc(Waybill::getCreateTime)
.last("limit 500");
List<Waybill> waybills = waybillService.list(wrapper);
List<Map<String, Object>> result = new ArrayList<>();
for (Waybill waybill : waybills) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("id", waybill.getId());
item.put("waybillNo", waybill.getWaybillNo());
item.put("vehicleNo", waybill.getVehicleNo());
item.put("transportType", waybill.getTransportType());
item.put("departureAddress", waybill.getDepartureAddress());
item.put("arrivalAddress", waybill.getArrivalAddress());
item.put("cargoName", waybill.getCargoName());
item.put("cargoType", waybill.getCargoType());
item.put("quantity", waybill.getQuantity());
item.put("quantityUnit", waybill.getQuantityUnit());
item.put("model", waybill.getModel());
item.put("mileage", waybill.getMileage());
item.put("actualStartDate", waybill.getStartDate());
item.put("actualEndDate", waybill.getEndDate());
item.put("driverName", waybill.getDriverName());
result.add(item);
}
return result;
}
@Override
@@ -608,9 +687,9 @@ public class SettlementImportBatchServiceImpl
.eq(Waybill::getEndDate, endDate)
.eq(Waybill::getDepartureAddress, departureAddress)
.eq(Waybill::getArrivalAddress, arrivalAddress)
.eq(Waybill::getCargoName, cargoName)
.orderByDesc(Waybill::getCreateTime)
.last("limit 3");
.eq(Waybill::getCargoName, cargoName);
applyUnsettledWaybillFilter(wrapper, request.getSettlementType());
wrapper.orderByDesc(Waybill::getCreateTime).last("limit 3");
List<Waybill> matches = waybillService.list(wrapper);
if (Func.isEmpty(matches)) {
throw new ServiceException("未匹配到系统已有运单(项目+车号+运输完成日期+客户+承运商+起运地+目的地+货物名称)");
@@ -623,16 +702,17 @@ public class SettlementImportBatchServiceImpl
/** 手工指定运单号时,在当前项目/客户/承运商范围内精确匹配。 */
private Waybill matchHistoryWaybillByNo(SettlementImportBatchRequest request, String waybillNo) {
List<Waybill> matches = waybillService.list(Wrappers.<Waybill>lambdaQuery()
LambdaQueryWrapper<Waybill> wrapper = Wrappers.<Waybill>lambdaQuery()
.eq(Waybill::getIsDeleted, 0)
.eq(Waybill::getProjectId, request.getProjectId())
.eq(Waybill::getContractId, request.getCustomerContractId())
.eq(Waybill::getCarrierContractId, request.getCarrierContractId())
.eq(Waybill::getWaybillNo, waybillNo)
.orderByDesc(Waybill::getCreateTime)
.last("limit 2"));
.eq(Waybill::getWaybillNo, waybillNo);
applyUnsettledWaybillFilter(wrapper, request.getSettlementType());
wrapper.orderByDesc(Waybill::getCreateTime).last("limit 2");
List<Waybill> matches = waybillService.list(wrapper);
if (Func.isEmpty(matches)) {
throw new ServiceException("未匹配到运单号「" + waybillNo + "」对应的系统已有运单");
throw new ServiceException("未匹配到运单号「" + waybillNo + "」对应的未结算运单");
}
if (matches.size() > 1) {
throw new ServiceException("运单号「" + waybillNo + "」匹配到多条运单,请核对后重试");
@@ -640,6 +720,14 @@ public class SettlementImportBatchServiceImpl
return matches.get(0);
}
/** 仅保留尚未生成对应结算类型应收/应付明细的运单。 */
private void applyUnsettledWaybillFilter(LambdaQueryWrapper<Waybill> wrapper, String settlementType) {
String type = Func.isEmpty(settlementType) ? TYPE_RECEIVABLE : settlementType;
wrapper.apply("not exists (select 1 from blade_receivable_payable_detail d"
+ " where d.waybill_id = blade_waybill.id and d.settlement_type = {0}"
+ " and d.is_deleted = 0)", type);
}
private void prepareSettlementRows(SettlementImportBatchRequest request) {
if (request == null || Func.isEmpty(request.getRows())) {
return;
@@ -1161,4 +1249,123 @@ public class SettlementImportBatchServiceImpl
String merged = Func.isEmpty(original) ? "作废原因:" + reason : original + ";作废原因:" + reason;
return limit(merged, 200);
}
@SuppressWarnings("unchecked")
private List<Map<String, Object>> parseRowsJson(String rowsJson) {
if (Func.isEmpty(rowsJson)) {
return List.of();
}
List<Map> rawList = JsonUtil.parseArray(rowsJson, Map.class);
if (rawList == null || rawList.isEmpty()) {
return List.of();
}
List<Map<String, Object>> rows = new ArrayList<>();
for (Map raw : rawList) {
if (raw == null) {
continue;
}
rows.add(new LinkedHashMap<>(raw));
}
return rows;
}
private List<Map<String, Object>> sanitizeRowsForStorage(List<Map<String, Object>> rows) {
if (Func.isEmpty(rows)) {
return List.of();
}
List<Map<String, Object>> sanitized = new ArrayList<>();
for (Map<String, Object> row : rows) {
if (row == null) {
continue;
}
Map<String, Object> copy = new LinkedHashMap<>();
for (Map.Entry<String, Object> entry : row.entrySet()) {
String key = entry.getKey();
if (key == null || key.startsWith("_")) {
continue;
}
copy.put(key, entry.getValue());
}
sanitized.add(copy);
}
return sanitized;
}
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(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) {
// 忽略
}
if (labels.isEmpty()) {
return List.of("公路运输", "铁路运输", "水路运输", "航空运输");
}
return new ArrayList<>(labels);
}
private List<String> loadEnabledFeeItemNames() {
R<List<FeeItem>> response = sysClient.getFeeItems(false);
if (response == null || !response.isSuccess() || response.getData() == null) {
return List.of();
}
return response.getData().stream()
.filter(item -> item != null && (item.getStatus() == null || Objects.equals(item.getStatus(), 1)))
.map(FeeItem::getName)
.filter(Func::isNotEmpty)
.map(String::trim)
.distinct()
.toList();
}
/** 历史已完成批次无 rows_json 时,按关联运单回填查看明细。 */
private List<Map<String, Object>> buildRowsFromWaybills(List<Waybill> waybills) {
if (Func.isEmpty(waybills)) {
return List.of();
}
List<Map<String, Object>> rows = new ArrayList<>();
int serial = 1;
for (Waybill waybill : waybills) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("serialNumber", serial++);
row.put("waybillNo", waybill.getWaybillNo());
row.put("vehicleNo", waybill.getVehicleNo());
row.put("transportType", waybill.getTransportType());
row.put("driverName", waybill.getDriverName());
row.put("departureAddress", waybill.getDepartureAddress());
row.put("arrivalAddress", waybill.getArrivalAddress());
row.put("cargoName", waybill.getCargoName());
row.put("cargoType", waybill.getCargoType());
row.put("quantity", waybill.getQuantity());
row.put("quantityUnit", waybill.getQuantityUnit());
row.put("specification", waybill.getSpecification());
row.put("model", waybill.getModel());
row.put("mileage", waybill.getMileage());
row.put("unitPrice", waybill.getUnitPrice());
row.put("actualStartDate", waybill.getStartDate());
row.put("actualEndDate", waybill.getEndDate());
row.put("remark", waybill.getRemark());
rows.add(row);
}
return rows;
}
}
@@ -22,6 +22,7 @@ CREATE TABLE IF NOT EXISTS `blade_settlement_import_batch` (
`pre_settlement_id` bigint DEFAULT NULL COMMENT '预结算单ID',
`pre_settlement_no` varchar(100) DEFAULT NULL COMMENT '预结算单号',
`fee_item_names_json` longtext COMMENT '动态费用列名JSON数组',
`rows_json` longtext COMMENT '导入明细行JSON数组',
`remark` varchar(200) DEFAULT NULL COMMENT '备注',
`waybill_import_batch_id` bigint DEFAULT NULL COMMENT '关联底层运单导入批次,可空',
`create_user` bigint DEFAULT NULL COMMENT '创建人',
@@ -45,6 +46,7 @@ ALTER TABLE `blade_settlement_import_batch` ADD COLUMN `settlement_bill_type` va
ALTER TABLE `blade_settlement_import_batch` ADD COLUMN `pre_settlement_id` bigint DEFAULT NULL COMMENT '预结算单ID' AFTER `formal_settlement_no`;
ALTER TABLE `blade_settlement_import_batch` ADD COLUMN `pre_settlement_no` varchar(100) DEFAULT NULL COMMENT '预结算单号' AFTER `pre_settlement_id`;
ALTER TABLE `blade_settlement_import_batch` ADD KEY `idx_settlement_import_pre` (`pre_settlement_id`);
ALTER TABLE `blade_settlement_import_batch` ADD COLUMN `rows_json` longtext COMMENT '导入明细行JSON数组' AFTER `fee_item_names_json`;
-- 运单关联导入结算批次(可选,若列已存在可忽略报错)
ALTER TABLE `blade_waybill` ADD COLUMN `settlement_import_batch_id` bigint DEFAULT NULL COMMENT '导入结算单批次ID';
@@ -56,5 +58,11 @@ INSERT INTO `blade_menu` (`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`
(2090000000000002082,2090000000000002080,'settlement_import_add','新增','settlement_import_add','','',2,2,0,1,NULL,'',0),
(2090000000000002083,2090000000000002080,'settlement_import_detail','详情','settlement_import_detail','','',3,2,0,1,NULL,'',0),
(2090000000000002084,2090000000000002080,'settlement_import_void','作废','settlement_import_void','','',4,2,0,1,NULL,'',0),
(2090000000000002085,2090000000000002080,'settlement_import_template','下载模板','settlement_import_template','','',5,2,0,1,NULL,'',0)
(2090000000000002085,2090000000000002080,'settlement_import_template','下载模板','settlement_import_template','','',5,2,0,1,NULL,'',0),
(2090000000000002086,2090000000000002080,'settlement_import_delete','删除','settlement_import_delete','','',6,2,0,1,NULL,'',0)
ON DUPLICATE KEY UPDATE `name`=VALUES(`name`),`path`=VALUES(`path`),`is_deleted`=0;
-- 已建表环境增量菜单(已存在可忽略)
INSERT INTO `blade_menu` (`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) VALUES
(2090000000000002086,2090000000000002080,'settlement_import_delete','删除','settlement_import_delete','','',6,2,0,1,NULL,'',0)
ON DUPLICATE KEY UPDATE `name`=VALUES(`name`),`path`=VALUES(`path`),`is_deleted`=0;
@@ -0,0 +1,7 @@
-- 导入结算单:草稿明细落库 + 删除菜单
ALTER TABLE `blade_settlement_import_batch`
ADD COLUMN `rows_json` longtext COMMENT '导入明细行JSON数组' AFTER `fee_item_names_json`;
INSERT INTO `blade_menu` (`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) VALUES
(2090000000000002086,2090000000000002080,'settlement_import_delete','删除','settlement_import_delete','','',6,2,0,1,NULL,'',0)
ON DUPLICATE KEY UPDATE `name`=VALUES(`name`),`path`=VALUES(`path`),`is_deleted`=0;