🐛 修复车辆长期有效、结算导入与调度端轨迹鉴权

车辆勾选长期有效不再强校验有效期;结算导入模板去掉结算金额并自动合计,取消运费合计勾稽;调度端所属组织为空不拦截,并新增仅登录态的 manage/track。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-19 23:28:50 +08:00
parent c31e5f98ed
commit 1b69d2b792
9 changed files with 75 additions and 59 deletions
@@ -39,6 +39,7 @@ import org.springblade.transport.pojo.vo.AdminVehicleOptionVO;
import org.springblade.transport.pojo.vo.AdminWaybillCardVO;
import org.springblade.transport.pojo.vo.AdminWaybillDetailVO;
import org.springblade.transport.pojo.vo.WaybillPunchRecordsVO;
import org.springblade.transport.pojo.vo.WaybillTrackVO;
import org.springblade.transport.service.IManageWaybillService;
import org.springblade.transport.service.IWaybillService;
import org.springframework.web.bind.annotation.GetMapping;
@@ -159,4 +160,14 @@ public class ManageWaybillController extends BladeController {
return R.status(waybillService.savePunchRecord(request));
}
@PostMapping("/track")
@ApiOperationSupport(order = 11)
@Operation(summary = "车辆历史轨迹", description = "小程序调度端:仅需登录态,按运单绑定车牌和日期区间查询 LBS 轨迹")
public R<WaybillTrackVO> track(
@Parameter(description = "运单ID", required = true) @RequestParam Long id,
@Parameter(description = "开始日期 YYYY-MM-DD", required = true) @RequestParam String startDate,
@Parameter(description = "结束日期 YYYY-MM-DD", required = true) @RequestParam String endDate) {
return R.data(waybillService.trackVehicle(id, startDate, endDate));
}
}
@@ -54,7 +54,8 @@ import java.util.Set;
/**
* 导入结算单 Excel 读取器(Apache POI)。
* <p>
* 定位「*运输费」或「运输费」列,其后直到「结算金额」为动态费用列。
* 定位「*运输费」或「运输费」列,其后直到日期/备注等固定列(兼容旧模板中的「结算金额」)为动态费用列。
* 结算金额不由模板填写,解析时按「运输费 + 各费用项」自动合计。
*
* @author Chill
*/
@@ -66,6 +67,14 @@ public final class SettlementImportFeeReader {
"actualStartDate", "actualEndDate", "planStartDate", "planEndDate"
);
private static final Set<String> OPTIONAL_DECIMAL_FIELDS = Set.of("mileage", "unitPrice");
/** 费用列结束边界:其后为固定业务列;含旧模板「结算金额」以便兼容历史文件 */
private static final Set<String> FEE_END_HEADERS = Set.of(
"结算金额",
"*实际发货时间", "实际发货时间",
"*实际完成时间", "实际完成时间",
"预计发货时间", "预计完成时间",
"备注", "同一运单标识号"
);
private SettlementImportFeeReader() {
}
@@ -119,15 +128,12 @@ public final class SettlementImportFeeReader {
if (transportFeeColumn < 0) {
throw new ServiceException("未找到「*运输费」或「运输费」列");
}
int settlementAmountColumn = findColumnExact(headers, "结算金额");
List<Integer> feeColumns = new ArrayList<>();
List<String> feeItemNames = new ArrayList<>();
int endColumn = settlementAmountColumn > transportFeeColumn
? settlementAmountColumn
: headers.keySet().stream().mapToInt(Integer::intValue).max().orElse(transportFeeColumn) + 1;
int endColumn = findFeeEndColumn(headers, transportFeeColumn);
for (int column = transportFeeColumn + 1; column < endColumn; column++) {
String name = headers.get(column);
if (name == null || name.isBlank() || "结算金额".equals(name)) {
if (name == null || name.isBlank() || FEE_END_HEADERS.contains(name)) {
continue;
}
feeColumns.add(column);
@@ -229,7 +235,8 @@ public final class SettlementImportFeeReader {
} else if ("transportFee".equals(field) || "quantity".equals(field)) {
mapped.put(field, decimal(cellString(poiCell), trimmedHeader, excelRowIndex));
} else if ("settlementAmount".equals(field)) {
mapped.put(field, optionalDecimal(cellString(poiCell), trimmedHeader, excelRowIndex));
// 旧模板可能仍含结算金额列,读入后忽略,统一按运输费+费用项自动合计
continue;
} else if ("serialNumber".equals(field)) {
mapped.put(field, intValue(cellString(poiCell)));
} else {
@@ -255,9 +262,7 @@ public final class SettlementImportFeeReader {
BigDecimal feeItemTotal = feeItems.values().stream()
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(2, RoundingMode.HALF_UP);
BigDecimal settlementAmount = mapped.get("settlementAmount") == null
? transportFee.add(feeItemTotal).setScale(2, RoundingMode.HALF_UP)
: toDecimal(mapped.get("settlementAmount"));
BigDecimal settlementAmount = transportFee.add(feeItemTotal).setScale(2, RoundingMode.HALF_UP);
mapped.put("settlementAmount", settlementAmount);
mapped.put("结算金额", settlementAmount);
mapped.put("feeItems", feeItems);
@@ -284,6 +289,21 @@ public final class SettlementImportFeeReader {
return findColumnExact(headers, "运输费");
}
/** 费用列右边界:取运输费之后第一个固定结束表头列下标;无则取表头最大下标 + 1。 */
private static int findFeeEndColumn(Map<Integer, String> headers, int transportFeeColumn) {
int endColumn = headers.keySet().stream().mapToInt(Integer::intValue).max().orElse(transportFeeColumn) + 1;
for (Map.Entry<Integer, String> entry : headers.entrySet()) {
int column = entry.getKey();
if (column <= transportFeeColumn) {
continue;
}
if (FEE_END_HEADERS.contains(entry.getValue()) && column < endColumn) {
endColumn = column;
}
}
return endColumn;
}
private static int findColumnExact(Map<Integer, String> headers, String header) {
return headers.entrySet().stream()
.filter(entry -> header.equals(entry.getValue()))
@@ -100,7 +100,8 @@ public class SettlementImportTemplateExcel implements Serializable {
private BigDecimal loadingFee;
@ExcelProperty("过路费")
private BigDecimal tollFee;
@ExcelProperty("结算金额")
/** 模板已去掉结算金额列;仅失败明细内部保留,不导出为表头 */
@ExcelIgnore
private BigDecimal settlementAmount;
@ExcelProperty("*实际发货时间")
private String actualStartDate;
@@ -85,7 +85,6 @@ public final class SettlementImportTemplateExporter {
);
private static final List<String> FIXED_HEADERS_AFTER_FEE = List.of(
"结算金额",
"*实际发货时间",
"*实际完成时间",
"预计发货时间",
@@ -159,8 +158,8 @@ public final class SettlementImportTemplateExporter {
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("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())
@@ -350,9 +350,14 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
/**
* 按打卡记录 / 计划时间推导轨迹查询起止日期
* <p>
* TODO(临时):当前仅查当天,便于联调 LBS;恢复正式逻辑时删掉本段 early-return。
*/
private LocalDate[] resolveTrackDateRange(Waybill waybill) {
LocalDate today = LocalDate.now();
// TEMP: 行驶轨迹时间段临时改为仅当天
return new LocalDate[]{today, today};
/*
List<LocalDate> punchDates = new ArrayList<>();
nodePunchMapper.selectList(Wrappers.<WaybillNodePunch>lambdaQuery()
.eq(WaybillNodePunch::getWaybillId, waybill.getId())
@@ -384,6 +389,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
return new LocalDate[]{waybill.getStartDate(), today};
}
return new LocalDate[]{today.minusDays(3), today};
*/
}
@Override
@@ -886,11 +886,18 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
/**
* 调度端可见组织:普通用户(含各级角色)只出自己所属组织,超级管理员不过滤。
* <p>
* 与后台运单列表同一条规则({@link TransportBusinessSupport#resolveDeptFilterIds}),
* 小程序端不提供「全部组织」开关,故两个入参固定传空;详情与重新派单不按组织收口。
* 小程序不校验「所属组织不能为空」:账号没有组织时不按组织收口,避免首页、待处理等接口直接报错。
* 详情与重新派单本身不走这里。
*/
private List<Long> manageableDeptIds() {
return TransportBusinessSupport.resolveDeptFilterIds(null, null, "运单管理");
if (TransportBusinessSupport.isAdministrator()) {
return List.of();
}
Long deptId = Func.firstLong(AuthUtil.getDeptId());
if (Func.isEmpty(deptId) || deptId <= 0) {
return List.of();
}
return List.of(deptId);
}
/**
@@ -291,13 +291,13 @@ public class TransportVehicleServiceImpl extends BaseServiceImpl<TransportVehicl
if (Func.isEmpty(vehicle.getDrivingLicenseNo())) {
throw new ServiceException("行驶证档案编号不能为空");
}
if (Func.isEmpty(vehicle.getDrivingLicenseEndDate())) {
if (vehicle.getDrivingLicenseLongTerm() != 1 && Func.isEmpty(vehicle.getDrivingLicenseEndDate())) {
throw new ServiceException("行驶证有效期不能为空");
}
if (Func.isEmpty(vehicle.getRoadTransportCertNo())) {
throw new ServiceException("道路运输证号不能为空");
}
if (Func.isEmpty(vehicle.getRoadTransportCertEndDate())) {
if (vehicle.getRoadTransportCertLongTerm() != 1 && Func.isEmpty(vehicle.getRoadTransportCertEndDate())) {
throw new ServiceException("道路运输证有效期不能为空");
}
if (vehicle.getAnnualReviewLongTerm() != 1 && Func.isEmpty(vehicle.getAnnualReviewEndDate())) {
@@ -758,25 +758,22 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
// 10. 里程校验
validatePositiveNumber(row, "mileage", "里程(km)", errors);
// 11. 运费合计校验
validateFreightTotal(row, errors);
// 12. 实际发货时间校验
// 11. 实际发货时间校验
validateActualStartDate(row, isDraft, errors);
// 13. 实际完成时间校验
// 12. 实际完成时间校验
validateActualEndDate(row, isDraft, errors);
// 14. 预计发货时间校验
// 13. 预计发货时间校验
validatePlanStartDate(row, errors);
// 15. 预计完成时间校验
// 14. 预计完成时间校验
validatePlanEndDate(row, errors);
// 16. 同一运单标识号校验
// 15. 同一运单标识号校验
validateWaybillIdentifier(row, i, waybillIdentifierMap, rows, errors);
// 17. 同一运单标识号组内一致性校验(地址/配载标识号/数量单位,多货物合并的前提)
// 16. 同一运单标识号组内一致性校验(地址/配载标识号/数量单位,多货物合并的前提)
validateWaybillIdentifierConsistency(row, i, waybillIdentifierMap, rows, errors);
if (!errors.isEmpty()) {
@@ -939,31 +936,6 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
}
}
private void validateFreightTotal(Map<String, Object> row, List<String> errors) {
String freightTotalStr = stringValue(row, "freightTotal", "运费合计");
if (Func.isEmpty(freightTotalStr) || isImportOptionalNumberEmpty(freightTotalStr)) {
return;
}
try {
BigDecimal freightTotal = new BigDecimal(freightTotalStr);
String freightStr = stringValue(row, "freight", "运费");
String otherFeeTotalStr = stringValue(row, "otherFeeTotal", "其他费用合计");
BigDecimal freight = Func.isNotEmpty(freightStr) && !isImportOptionalNumberEmpty(freightStr)
? new BigDecimal(freightStr) : BigDecimal.ZERO;
BigDecimal otherFeeTotal = Func.isNotEmpty(otherFeeTotalStr) && !isImportOptionalNumberEmpty(otherFeeTotalStr)
? new BigDecimal(otherFeeTotalStr) : BigDecimal.ZERO;
BigDecimal calculatedTotal = freight.add(otherFeeTotal);
if (freightTotal.compareTo(calculatedTotal) != 0) {
errors.add("运费合计应等于运费+其他费用合计");
}
} catch (NumberFormatException e) {
errors.add("运费相关字段格式不正确");
}
}
private void validateActualStartDate(Map<String, Object> row, boolean isDraft, List<String> errors) {
Object actualStartDate = row.get("actualStartDate");
if (actualStartDate == null || String.valueOf(actualStartDate).isBlank()) {
@@ -52,7 +52,7 @@ import static org.mockito.Mockito.when;
* 三个取数入口必须同一条规则——列表、待处理、首页三档统计各走一次查询,漏一处就会出现
* 「统计说 46 张、列表只出 18 张」这种对不上的数;待重新派单另走库层分组查询,也要单独带上。
* <p>
* 组织取不到时报错而不是放行:放行等于把全租户的运单发给一个没有组织的账号。
* 所属组织为空时不拦截:小程序调度端不要求账号必须挂组织。
*/
class ManageWaybillOrgScopeTest {
@@ -190,13 +190,13 @@ class ManageWaybillOrgScopeTest {
}
@Test
@DisplayName("组织取不到时报错,不退回不带组织条件")
void blankDeptFailsClosed() {
@DisplayName("组织取不到时不拦截,小程序接口不按组织收口")
void blankDeptDoesNotFail() {
authUtil.when(AuthUtil::getDeptId).thenReturn("");
assertThrows(ServiceException.class,
() -> service.pageList(1, 10, null, null, null, null, null, null),
"没有组织的账号不能拿到全租户运单");
service.pageList(1, 10, null, null, null, null, null, null);
assertTrue(capturedPageQuery().getDeptIds().isEmpty(), "没有组织时不报错、不按组织过滤");
}
// ------------------------------------------------------------ 夹具