Merge remote-tracking branch 'origin/master' into feature/20261019-bfi-all
# Conflicts: # blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java
This commit is contained in:
+7
-4
@@ -61,8 +61,9 @@ public class BillLedgerController extends BladeController {
|
||||
@ApiOperationSupport(order = 4)
|
||||
@Operation(summary = "付款申请可用汇票")
|
||||
public R<List<BillLedgerVO>> availableOptions(@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) Long deptId, @RequestParam(required = false) Long selectedId) {
|
||||
return R.data(billLedgerService.availableOptions(keyword, deptId, selectedId));
|
||||
@RequestParam(required = false) Long deptId, @RequestParam(required = false) Long selectedId,
|
||||
@RequestParam(required = false) String payerName, @RequestParam(required = false) String payeeName) {
|
||||
return R.data(billLedgerService.availableOptions(keyword, deptId, selectedId, payerName, payeeName));
|
||||
}
|
||||
|
||||
@GetMapping("/available-page")
|
||||
@@ -70,8 +71,10 @@ public class BillLedgerController extends BladeController {
|
||||
@Operation(summary = "付款申请可用汇票分页")
|
||||
public R<IPage<BillLedgerVO>> availablePage(Query pageQuery,
|
||||
@RequestParam(required = false) String keyword, @RequestParam(required = false) Long deptId,
|
||||
@RequestParam(required = false) Long selectedId) {
|
||||
return R.data(billLedgerService.availablePage(Condition.getPage(pageQuery), keyword, deptId, selectedId));
|
||||
@RequestParam(required = false) Long selectedId, @RequestParam(required = false) String payerName,
|
||||
@RequestParam(required = false) String payeeName) {
|
||||
return R.data(billLedgerService.availablePage(Condition.getPage(pageQuery), keyword, deptId, selectedId,
|
||||
payerName, payeeName));
|
||||
}
|
||||
|
||||
@PostMapping("/submit")
|
||||
|
||||
+59
@@ -105,6 +105,65 @@ public class DriverWaybillController extends BladeController {
|
||||
return R.data(driverWaybillService.detail(id));
|
||||
}
|
||||
|
||||
@GetMapping("/task/current")
|
||||
@ApiOperationSupport(order = 21)
|
||||
@Operation(summary = "首页:当前运输中运次", description = "当前用户手机号可见的运输中运次(有配载单的一趟车算一个)最新一条")
|
||||
public R<DriverWaybillCardVO> tripCurrentTask() {
|
||||
return R.data(driverWaybillService.tripCurrentTask());
|
||||
}
|
||||
|
||||
@GetMapping("/task/pending-preview")
|
||||
@ApiOperationSupport(order = 22)
|
||||
@Operation(summary = "首页:待接运次预览", description = "当前用户手机号可见的待接运次预览与总数(按运次计)")
|
||||
public R<DriverWaybillPreviewVO> tripPendingPreview(
|
||||
@Parameter(description = "预览条数,默认 2") @RequestParam(required = false) Integer size) {
|
||||
return R.data(driverWaybillService.tripPendingPreview(size));
|
||||
}
|
||||
|
||||
@GetMapping("/task/counts")
|
||||
@ApiOperationSupport(order = 23)
|
||||
@Operation(summary = "运次 Tab 统计", description = "按运次统计:全部 = 待接单 + 进行中 + 已完成(不含已取消)")
|
||||
public R<DriverWaybillTabCountsVO> tripCounts() {
|
||||
return R.data(driverWaybillService.tripTabCounts());
|
||||
}
|
||||
|
||||
@GetMapping("/task/page")
|
||||
@ApiOperationSupport(order = 24)
|
||||
@Operation(summary = "运次分页列表", description = "一页即若干个运次;status:空=全部,0待接单,1运输中,2已完成;keyword 支持运单号/配载单号/起终点/车牌")
|
||||
public R<IPage<DriverWaybillCardVO>> tripPage(
|
||||
@Parameter(description = "当前页") @RequestParam(required = false) Integer current,
|
||||
@Parameter(description = "每页运次数") @RequestParam(required = false) Integer size,
|
||||
@Parameter(description = "状态:0待接单/1运输中/2已完成,不传为全部") @RequestParam(required = false) String status,
|
||||
@Parameter(description = "关键字:运单号/配载单号/起终点/车牌") @RequestParam(required = false) String keyword) {
|
||||
Integer statusCode = parseStatus(status);
|
||||
return R.data(driverWaybillService.tripPage(current, size, statusCode, keyword));
|
||||
}
|
||||
|
||||
@GetMapping("/task/detail")
|
||||
@ApiOperationSupport(order = 25)
|
||||
@Operation(summary = "司机运次详情", description = "按运次主键(字符串)取整趟车;有配载单时下发站点 ①②③(stations)与每站货物条目")
|
||||
public R<DriverWaybillCardVO> tripDetail(
|
||||
@Parameter(description = "运次主键(配载单主键或运单主键)", required = true) @RequestParam String id) {
|
||||
return R.data(driverWaybillService.tripDetail(id));
|
||||
}
|
||||
|
||||
@PostMapping("/task/accept")
|
||||
@ApiOperationSupport(order = 26)
|
||||
@Operation(summary = "运次确认接单", description = "一次接单把该运次全部子运单置为已接单;任一子单已接单即视为整单已接单")
|
||||
public R tripAccept(
|
||||
@Parameter(description = "运次主键(配载单主键或运单主键)", required = true) @RequestParam String id) {
|
||||
return R.status(driverWaybillService.tripAccept(id));
|
||||
}
|
||||
|
||||
@PostMapping("/task/reject")
|
||||
@ApiOperationSupport(order = 27)
|
||||
@Operation(summary = "运次拒绝接单", description = "整单拒绝:该运次全部未终态子运单一起置为已拒绝,此后该运次对本人不可见")
|
||||
public R tripReject(
|
||||
@Parameter(description = "运次主键(配载单主键或运单主键)", required = true) @RequestParam String id,
|
||||
@Parameter(description = "拒绝原因") @RequestParam(required = false) String reason) {
|
||||
return R.status(driverWaybillService.tripReject(id, reason));
|
||||
}
|
||||
|
||||
@PostMapping("/track")
|
||||
@ApiOperationSupport(order = 11)
|
||||
@Operation(summary = "司机端行驶轨迹", description = "按当前用户手机号校验归属后,根据打卡记录/计划时间自动推导起止日期并查询 LBS 轨迹(WGS-84)")
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 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.controller;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.tenant.annotation.TenantIgnore;
|
||||
import org.springblade.core.tool.api.R;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* HCS 合规回调 Mock 端点
|
||||
* <p>
|
||||
* 合同法务审批结果回调(D103)、付款法务审批结果回调(D104)、项目金蝶同步结果回调(D902)、
|
||||
* 合同金蝶同步结果回传接收(D903)四个入站回调的纯 mock 实现:仅校验来源系统标识后返回固定结果,
|
||||
* 不读写业务数据、不联动状态。真实处理逻辑保留在对应 Support 中,联调时把端点切回
|
||||
* {@link } 并调用 Support 方法即可。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@TenantIgnore
|
||||
@RequestMapping("hcs")
|
||||
@Tag(name = "HCS合规回调Mock", description = "HCS合规回调Mock端点(仅自测,返回固定 mock 数据)")
|
||||
public class HcsMockController {
|
||||
|
||||
/**
|
||||
* 来源系统标识(合规方约定值,与 third-party.hcs.src-system 默认值一致)
|
||||
*/
|
||||
private static final String SOURCE_SYSTEM = "WLTMS";
|
||||
|
||||
/**
|
||||
* 合同法务审批结果回调(D103)
|
||||
*/
|
||||
@PostMapping("/contractLegal/result")
|
||||
@ApiOperationSupport(order = 15)
|
||||
@Operation(summary = "合同法务审批结果回调Mock", description = "D103 合规推送合同特批(金蝶同步)结果,仅校验来源系统并返回 mock 数据")
|
||||
public R<String> contractLegalResult(@RequestBody(required = false) Map<String, Object> param) {
|
||||
this.checkSourceSystem("合同法务审批结果", param);
|
||||
return R.data("mock特批结果已处理:approved");
|
||||
}
|
||||
|
||||
/**
|
||||
* 付款法务审批结果回调(D104)
|
||||
*/
|
||||
@PostMapping("/paymentLegal/result")
|
||||
@ApiOperationSupport(order = 16)
|
||||
@Operation(summary = "付款法务审批结果回调Mock", description = "D104 合规推送付款特批结果,仅校验来源系统并返回 mock 数据")
|
||||
public R<String> paymentLegalResult(@RequestBody(required = false) Map<String, Object> param) {
|
||||
this.checkSourceSystem("付款法务审批结果", param);
|
||||
return R.data("mock付款特批结果接收已处理:approved");
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目金蝶同步结果回调(D902)
|
||||
*/
|
||||
@PostMapping("/projectLegal/result")
|
||||
@ApiOperationSupport(order = 18)
|
||||
@Operation(summary = "项目金蝶同步结果回调Mock", description = "D902 合规推送项目金蝶同步结果,仅校验来源系统并返回 mock 数据")
|
||||
public R<String> projectLegalResult(@RequestBody(required = false) Map<String, Object> param) {
|
||||
this.checkSourceSystem("项目金蝶同步结果", param);
|
||||
return R.data("mock项目金蝶同步结果接收已处理:approved");
|
||||
}
|
||||
|
||||
/**
|
||||
* 合同金蝶同步结果回传接收(D903)
|
||||
*/
|
||||
@PostMapping("/contractKingdee/result")
|
||||
@ApiOperationSupport(order = 19)
|
||||
@Operation(summary = "合同金蝶同步结果回传接收Mock", description = "D903 合规推送合同金蝶最终同步结果,仅校验来源系统并返回 mock 数据")
|
||||
public R<String> contractKingdeeResult(@RequestBody(required = false) Map<String, Object> param) {
|
||||
this.checkSourceSystem("合同金蝶同步结果", param);
|
||||
return R.data("mock合同金蝶同步结果接收处理:approved");
|
||||
}
|
||||
|
||||
/**
|
||||
* 来源系统校验:仅接受合规方约定的来源标识,防伪造回传
|
||||
*
|
||||
* @param bizName 业务名称(日志用)
|
||||
* @param param 回调报文
|
||||
*/
|
||||
private void checkSourceSystem(String bizName, Map<String, Object> param) {
|
||||
String sourceSystem = param == null ? null : Objects.toString(param.get("sourceSystem"), null);
|
||||
if (!StrUtil.equalsIgnoreCase(sourceSystem, SOURCE_SYSTEM)) {
|
||||
log.warn("{}推送来源系统标识错误:sourceSystem={}", bizName, sourceSystem);
|
||||
throw new ServiceException("来源系统标识错误");
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-3
@@ -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;
|
||||
@@ -69,7 +70,7 @@ public class ManageWaybillController extends BladeController {
|
||||
|
||||
@GetMapping("/stats")
|
||||
@ApiOperationSupport(order = 1)
|
||||
@Operation(summary = "运单状态统计", description = "待接单=pending,运输中=running,已完成=completed;租户内不过滤组织(小程序调度账号组织常与运单不一致);在途异常=异常处置状态≠已完成")
|
||||
@Operation(summary = "运单状态统计", description = "待接单=pending,运输中=running,已完成=completed;只统计本组织的运单(超级管理员不过滤组织);在途异常=异常处置状态≠已完成")
|
||||
public R<AdminHomeStatsVO> stats() {
|
||||
return R.data(manageWaybillService.stats());
|
||||
}
|
||||
@@ -99,9 +100,9 @@ public class ManageWaybillController extends BladeController {
|
||||
|
||||
@GetMapping("/detail")
|
||||
@ApiOperationSupport(order = 4)
|
||||
@Operation(summary = "运单详情", description = "调度端查看运单详情(含 punchNodes / enrouteRecords),不校验司机归属与组织;字段对齐小程序 pages/waybill/detail")
|
||||
@Operation(summary = "运次详情", description = "调度端查看整趟车详情(含 stations / punchNodes / enrouteRecords),不校验司机归属与组织;id 传运次主键字符串,已拒单的运单主键回落成单票视图")
|
||||
public R<AdminWaybillDetailVO> detail(
|
||||
@Parameter(description = "运单ID", required = true) @RequestParam Long id) {
|
||||
@Parameter(description = "运次主键(配载单主键或运单主键,字符串)", required = true) @RequestParam String id) {
|
||||
return R.data(manageWaybillService.detail(id));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+7
@@ -373,6 +373,13 @@ public class WaybillController extends BladeController {
|
||||
return R.data(waybillService.batchComplete(ids));
|
||||
}
|
||||
|
||||
@PostMapping("/confirm-return")
|
||||
@ApiOperationSupport(order = 24)
|
||||
@Operation(summary = "确认回单", description = "回单节点要求业务员确认时用:该运单的司机打卡节点全部已打卡且无未了结异常,才置为已完成")
|
||||
public R confirmReturn(@Parameter(description = "主键", required = true) @RequestParam Long id) {
|
||||
return R.status(waybillService.confirmReturnReceipt(id));
|
||||
}
|
||||
|
||||
@PostMapping("/road-loading")
|
||||
@ApiOperationSupport(order = 23)
|
||||
@Operation(summary = "公路配载", description = "传入ids")
|
||||
|
||||
+30
-10
@@ -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()))
|
||||
|
||||
+3
-6
@@ -37,7 +37,7 @@ import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 导入结算单 Excel 模板(固定列 + 运输单价 + *运输费 + 示例费用项)
|
||||
* 导入结算单 Excel 固定列模型(失败明细兜底导出;费用项由用户在原模板中自行增列)。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@@ -96,11 +96,8 @@ public class SettlementImportTemplateExcel implements Serializable {
|
||||
private BigDecimal unitPrice;
|
||||
@ExcelProperty("*运输费")
|
||||
private BigDecimal transportFee;
|
||||
@ExcelProperty("装卸费")
|
||||
private BigDecimal loadingFee;
|
||||
@ExcelProperty("过路费")
|
||||
private BigDecimal tollFee;
|
||||
@ExcelProperty("结算金额")
|
||||
/** 模板已去掉结算金额列;仅失败明细内部保留,不导出为表头 */
|
||||
@ExcelIgnore
|
||||
private BigDecimal settlementAmount;
|
||||
@ExcelProperty("*实际发货时间")
|
||||
private String actualStartDate;
|
||||
|
||||
+25
-34
@@ -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
|
||||
*/
|
||||
@@ -85,7 +85,6 @@ public final class SettlementImportTemplateExporter {
|
||||
);
|
||||
|
||||
private static final List<String> FIXED_HEADERS_AFTER_FEE = List.of(
|
||||
"结算金额",
|
||||
"*实际发货时间",
|
||||
"*实际完成时间",
|
||||
"预计发货时间",
|
||||
@@ -94,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("导入结算单模板");
|
||||
@@ -128,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;
|
||||
}
|
||||
@@ -155,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("6. 结算金额 = 运输费 + 各费用项合计,系统会按此重算。");
|
||||
lines.add("7. 当前可选运输方式:" + String.join("、", transportOptions) + "。");
|
||||
lines.add("5. 「*运输费」之后、「*实际发货时间」之前可按需自行插入费用项列,列名须与系统已启用费用项名称完全一致;金额不为 0 时若不存在或已停用则无法导入。");
|
||||
lines.add("6. 模板不含结算金额列;导入后系统按「运输费 + 各费用项」自动合计结算金额。");
|
||||
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);
|
||||
@@ -202,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("公路运输", "铁路运输", "水路运输", "航空运输");
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -102,7 +102,8 @@
|
||||
FROM blade_receipt_claim c
|
||||
INNER JOIN blade_kingdee_receipt_flow f ON f.id = c.receipt_flow_id
|
||||
WHERE <include refid="claimRecordConditions"/>
|
||||
ORDER BY c.create_time DESC
|
||||
<!-- 认领时间倒序:存量认领记录的 create_time 为空,排序改以认领日期为主、id 兜同日先后 -->
|
||||
ORDER BY c.claim_date DESC, c.id DESC
|
||||
</select>
|
||||
|
||||
<select id="selectClaimRecordDetail" resultType="org.springblade.transport.pojo.vo.ReceiptClaimRecordVO">
|
||||
|
||||
+3
-2
@@ -18,8 +18,9 @@ public interface IBillLedgerService extends BaseService<BillLedger> {
|
||||
IPage<BillLedgerVO> selectPage(IPage<BillLedger> page, BillLedgerVO query);
|
||||
BillLedgerVO detail(Long id);
|
||||
Map<String, Long> expiryCounts();
|
||||
List<BillLedgerVO> availableOptions(String keyword, Long deptId, Long selectedId);
|
||||
IPage<BillLedgerVO> availablePage(IPage<BillLedger> page, String keyword, Long deptId, Long selectedId);
|
||||
List<BillLedgerVO> availableOptions(String keyword, Long deptId, Long selectedId, String payerName, String payeeName);
|
||||
IPage<BillLedgerVO> availablePage(IPage<BillLedger> page, String keyword, Long deptId, Long selectedId,
|
||||
String payerName, String payeeName);
|
||||
Long submit(BillLedgerSaveRequest request);
|
||||
void removeLedger(Long id);
|
||||
}
|
||||
|
||||
+47
@@ -71,6 +71,53 @@ public interface IDriverWaybillService {
|
||||
*/
|
||||
DriverWaybillCardVO detail(Long id);
|
||||
|
||||
/**
|
||||
* 当前登录司机的运输中运次(一趟车,最多一条)
|
||||
*/
|
||||
DriverWaybillCardVO tripCurrentTask();
|
||||
|
||||
/**
|
||||
* 当前登录司机的待接运次预览
|
||||
*
|
||||
* @param size 预览条数,默认 2
|
||||
*/
|
||||
DriverWaybillPreviewVO tripPendingPreview(Integer size);
|
||||
|
||||
/**
|
||||
* 运次 Tab 统计:全部 / 待接单 / 进行中 / 已完成(全部 = 前三者之和,不含已取消)
|
||||
*/
|
||||
DriverWaybillTabCountsVO tripTabCounts();
|
||||
|
||||
/**
|
||||
* 司机运次分页:一页即若干个运次(有配载单的一趟车算一个)
|
||||
*
|
||||
* @param current 当前页,从 1 开始
|
||||
* @param size 每页条数(按运次,默认 10)
|
||||
* @param status 小程序状态:空=全部,0待接单,1运输中,2已完成
|
||||
* @param keyword 关键字(运单号 / 配载单号 / 起终点 / 车牌,可选)
|
||||
*/
|
||||
IPage<DriverWaybillCardVO> tripPage(Integer current, Integer size, Integer status, String keyword);
|
||||
|
||||
/**
|
||||
* 司机运次详情:按运次主键(字符串)取整趟车,有配载单时下发站点 ①②③
|
||||
*/
|
||||
DriverWaybillCardVO tripDetail(String tripId);
|
||||
|
||||
/**
|
||||
* 运次确认接单:一次把该运次全部子运单置为已接单,司机字段不动
|
||||
*
|
||||
* @param tripId 运次主键(配载单主键或运单主键)
|
||||
*/
|
||||
boolean tripAccept(String tripId);
|
||||
|
||||
/**
|
||||
* 运次拒绝接单:整单拒绝,该运次此后对本人不可见,等调度重新派单
|
||||
*
|
||||
* @param tripId 运次主键(配载单主键或运单主键)
|
||||
* @param reason 拒绝原因,可空,最长 200 字
|
||||
*/
|
||||
boolean tripReject(String tripId, String reason);
|
||||
|
||||
/**
|
||||
* 司机端运单行驶轨迹:按打卡记录 / 计划时间自动推导起止日期后查询 LBS 轨迹。
|
||||
* <p>
|
||||
|
||||
+5
-2
@@ -63,9 +63,12 @@ public interface IManageWaybillService {
|
||||
String exception, String transportType, String startDate, String endDate);
|
||||
|
||||
/**
|
||||
* 调度端运单详情(不校验司机归属)
|
||||
* 调度端运次详情(不校验司机归属)
|
||||
* <p>
|
||||
* 传运次主键:配载单主键或未配载运单主键,都按字符串。已拒单的子运单按运次取不到(运次口径
|
||||
* 不含已拒子单),此时按运单主键回落成单票视图,供重新派单等从运单进入的场景使用。
|
||||
*/
|
||||
AdminWaybillDetailVO detail(Long id);
|
||||
AdminWaybillDetailVO detail(String tripId);
|
||||
|
||||
/**
|
||||
* 待处理运单(待接单 / 运输中;可筛需重新派单)
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 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>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import org.springblade.transport.pojo.vo.TripQueryVO;
|
||||
import org.springblade.transport.pojo.vo.TripTabCountsVO;
|
||||
import org.springblade.transport.pojo.vo.TripVO;
|
||||
|
||||
/**
|
||||
* 运次读服务
|
||||
* <p>
|
||||
* 「一卡一运次」的取数、状态聚合、接单需求合并与分页只在这里实现一次,司机端与管理端列表都调它,
|
||||
* 各自只做字段裁剪。聚合全程只读,不在读路径上写业务状态——分档只按过程配置与接单态推导,
|
||||
* 不按库里的业务状态,也不借读路径做惰性校正。
|
||||
*/
|
||||
public interface ITripQueryService {
|
||||
|
||||
/**
|
||||
* 运次分页:一页就是若干个运次(有配载单的一趟车算一个)。
|
||||
* <p>
|
||||
* 库层只取回收口与行级筛选命中的运单行,分组、分档、筛选、排序与分页都在服务层算完再做。
|
||||
*
|
||||
* @param query 查询条件,收口由 driverPhone 或 allDrivers 给出
|
||||
* @return 运次分页
|
||||
*/
|
||||
IPage<TripVO> page(TripQueryVO query);
|
||||
|
||||
/**
|
||||
* 运次 Tab 统计:全部 / 待接单 / 进行中 / 已完成,分档口径与 page 完全一致
|
||||
*
|
||||
* @param query 查询条件,口径同 page
|
||||
* @return 运次计数
|
||||
*/
|
||||
TripTabCountsVO tabCounts(TripQueryVO query);
|
||||
|
||||
/**
|
||||
* 按运次主键取单个运次(含子运单),主键为字符串
|
||||
*
|
||||
* @param query 查询条件,tripId 必填
|
||||
* @return 运次
|
||||
*/
|
||||
TripVO detail(TripQueryVO query);
|
||||
|
||||
}
|
||||
+8
@@ -113,6 +113,14 @@ public interface IWaybillService extends BaseService<Waybill> {
|
||||
*/
|
||||
boolean completeWithoutDeptCheck(Long id);
|
||||
|
||||
/**
|
||||
* 确认回单:回单节点配成「需业务员确认」时,内部人员确认后把运单置为已完成。
|
||||
* <p>
|
||||
* 比 {@link #complete(Long)} 多两道校验:该运单的司机打卡节点(在途除外)必须全部已打卡,
|
||||
* 且没有未了结异常——不挡住就等于借这个入口绕过打卡门禁与异常门禁。
|
||||
*/
|
||||
boolean confirmReturnReceipt(Long id);
|
||||
|
||||
BusinessRemoveResultVO batchComplete(String ids);
|
||||
LoadingManageVO roadLoading(String ids);
|
||||
|
||||
|
||||
+38
-9
@@ -86,14 +86,15 @@ public class BillLedgerServiceImpl extends BaseServiceImpl<BillLedgerMapper, Bil
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BillLedgerVO> availableOptions(String keyword, Long deptId, Long selectedId) {
|
||||
return availableList(keyword, deptId, selectedId);
|
||||
public List<BillLedgerVO> availableOptions(String keyword, Long deptId, Long selectedId, String payerName,
|
||||
String payeeName) {
|
||||
return availableList(keyword, deptId, selectedId, payerName, payeeName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public IPage<BillLedgerVO> availablePage(IPage<BillLedger> page, String keyword, Long deptId,
|
||||
Long selectedId) {
|
||||
List<BillLedgerVO> available = availableList(keyword, deptId, selectedId);
|
||||
Long selectedId, String payerName, String payeeName) {
|
||||
List<BillLedgerVO> available = availableList(keyword, deptId, selectedId, payerName, payeeName);
|
||||
long current = Math.max(page.getCurrent(), 1);
|
||||
long size = Math.max(page.getSize(), 1);
|
||||
long from = Math.min((current - 1) * size, available.size());
|
||||
@@ -103,27 +104,50 @@ public class BillLedgerServiceImpl extends BaseServiceImpl<BillLedgerMapper, Bil
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<BillLedgerVO> availableList(String keyword, Long deptId, Long selectedId) {
|
||||
private List<BillLedgerVO> availableList(String keyword, Long deptId, Long selectedId, String payerName,
|
||||
String payeeName) {
|
||||
LocalDate today = LocalDate.now();
|
||||
// 付款申请仅可选:收票类型、未到期、有可用余额;已选中的汇票始终带回以便回显
|
||||
// 未到期且有余额;收票且部门可用,或出票/收票单位与结算收付款方一致(开票、收票都可)
|
||||
return list(Wrappers.<BillLedger>lambdaQuery()
|
||||
.and(Func.isNotEmpty(keyword), wrapper -> wrapper.like(BillLedger::getBillNo, keyword)
|
||||
.or().like(BillLedger::getIssuerName, keyword)
|
||||
.or().like(BillLedger::getReceiverName, keyword))
|
||||
.and(wrapper -> wrapper
|
||||
.and(available -> available
|
||||
.eq(BillLedger::getBillType, "received")
|
||||
.ge(BillLedger::getMaturityDate, today)
|
||||
.gt(BillLedger::getAvailableBalance, BigDecimal.ZERO))
|
||||
.or(selectedId != null, child -> child.eq(BillLedger::getId, selectedId)))
|
||||
.orderByAsc(BillLedger::getMaturityDate)
|
||||
.orderByDesc(BillLedger::getCreateTime)).stream()
|
||||
.filter(item -> selectedId != null && Objects.equals(item.getId(), selectedId)
|
||||
|| departmentAvailable(item, deptId))
|
||||
|| selectableBill(item, deptId, payerName, payeeName))
|
||||
.map(item -> BillLedgerWrapper.build().entityVO(item))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 收票且当前部门在可用范围内,或出票单位、收票单位与结算付款方、收款方一致(顺序不限)。
|
||||
*/
|
||||
private boolean selectableBill(BillLedger ledger, Long deptId, String payerName, String payeeName) {
|
||||
boolean receivedAndDept = "received".equals(ledger.getBillType()) && departmentAvailable(ledger, deptId);
|
||||
return receivedAndDept || partiesMatch(ledger.getIssuerName(), ledger.getReceiverName(), payerName, payeeName);
|
||||
}
|
||||
|
||||
private boolean partiesMatch(String issuerName, String receiverName, String payerName, String payeeName) {
|
||||
String issuer = trimName(issuerName);
|
||||
String receiver = trimName(receiverName);
|
||||
String payer = trimName(payerName);
|
||||
String payee = trimName(payeeName);
|
||||
if (issuer.isEmpty() || receiver.isEmpty() || payer.isEmpty() || payee.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return (issuer.equals(payer) && receiver.equals(payee)) || (issuer.equals(payee) && receiver.equals(payer));
|
||||
}
|
||||
|
||||
private String trimName(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long submit(BillLedgerSaveRequest request) {
|
||||
@@ -141,6 +165,11 @@ public class BillLedgerServiceImpl extends BaseServiceImpl<BillLedgerMapper, Bil
|
||||
}
|
||||
CustomerArchive issuer = customer(request.getIssuerId(), "出票单位");
|
||||
CustomerArchive feeBearer = customer(request.getFeeBearerId(), "费用承担方");
|
||||
String receiverName = required(request.getReceiverName(), "收票单位", 100);
|
||||
if (!Objects.equals(feeBearer.getId(), issuer.getId())
|
||||
&& !customerName(feeBearer).equals(receiverName)) {
|
||||
throw new ServiceException("费用承担方只能选择收票单位或出票单位");
|
||||
}
|
||||
BigDecimal faceAmount = positive(request.getFaceAmount(), "票面金额").setScale(2, RoundingMode.HALF_UP);
|
||||
BigDecimal usedAmount = entity.getId() == null ? BigDecimal.ZERO : activeUsedAmount(entity.getId());
|
||||
if (faceAmount.compareTo(usedAmount) < 0) {
|
||||
@@ -150,7 +179,7 @@ public class BillLedgerServiceImpl extends BaseServiceImpl<BillLedgerMapper, Bil
|
||||
entity.setBillNo(billNo);
|
||||
entity.setIssuerId(issuer.getId());
|
||||
entity.setIssuerName(customerName(issuer));
|
||||
entity.setReceiverName(required(request.getReceiverName(), "收票单位", 100));
|
||||
entity.setReceiverName(receiverName);
|
||||
entity.setBillType(request.getBillType());
|
||||
entity.setFaceAmount(faceAmount);
|
||||
entity.setAvailableBalance(faceAmount.subtract(usedAmount).setScale(2, RoundingMode.HALF_UP));
|
||||
|
||||
+4
@@ -354,6 +354,10 @@ public class CertAuditServiceImpl implements ICertAuditService {
|
||||
requireIdCardNotTaken(snapshot.getIdCardNo(), driver.getId());
|
||||
driver.setDriverName(snapshot.getRealName());
|
||||
driver.setIdCardNo(snapshot.getIdCardNo());
|
||||
driver.setBirthday(snapshot.getBirthday());
|
||||
// 空串不是「没填」:框架的 not_null 策略只跳过 null,写空串会把管理端填过的住址清掉
|
||||
driver.setAddressRegion(blankToNull(snapshot.getAddressRegion()));
|
||||
driver.setAddress(blankToNull(snapshot.getAddress()));
|
||||
driver.setIdCardFront(snapshot.getIdCardFrontUrl());
|
||||
driver.setIdCardBack(snapshot.getIdCardBackUrl());
|
||||
driver.setIdCardValidStart(snapshot.getIdCardValidStart());
|
||||
|
||||
+39
-4
@@ -298,6 +298,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
validateBillingPlans(request.getBillingPlanJson());
|
||||
validatePaymentRatios(request.getPaymentRatioJson());
|
||||
Map<String, Object> beforeData = buildContractChangeSnapshot(source);
|
||||
applyContractNo(source, request.getContractNo(), false);
|
||||
source.setContractName(request.getContractName()); source.setPartyB(request.getPartyB());
|
||||
source.setStartDate(request.getStartDate()); source.setEndDate(request.getEndDate()); source.setContractFormat(request.getContractFormat());
|
||||
source.setSettlementMode(request.getSettlementMode()); source.setLegalSealFlag(request.getLegalSealFlag()); source.setCopyCount(request.getCopyCount());
|
||||
@@ -507,8 +508,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
contractManage.setOrganizationId(TransportBusinessSupport.currentDeptId("合同管理"));
|
||||
contractManage.setOrganizationName(TransportBusinessSupport.currentDept("合同管理").getDeptName());
|
||||
}
|
||||
// 新增始终按部门规则生成合同编号,避免前端预览号并发冲突
|
||||
contractManage.setContractNo(nextContractNo(contractManage.getOrganizationId()));
|
||||
applyContractNo(contractManage, contractManage.getContractNo(), true);
|
||||
if (Func.isEmpty(contractManage.getHandlerUserId())) {
|
||||
contractManage.setHandlerUserId(AuthUtil.getUserId());
|
||||
contractManage.setHandlerUserName(AuthUtil.getUserName());
|
||||
@@ -516,7 +516,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
contractManage.setArchiveStatus(ARCHIVE_STATUS_PENDING);
|
||||
} else {
|
||||
ContractManage oldRecord = loadExists(contractManage.getId());
|
||||
contractManage.setContractNo(oldRecord.getContractNo());
|
||||
applyContractNo(contractManage, contractManage.getContractNo(), false);
|
||||
if (Func.isEmpty(contractManage.getOrganizationId())) {
|
||||
contractManage.setOrganizationId(oldRecord.getOrganizationId());
|
||||
contractManage.setOrganizationName(oldRecord.getOrganizationName());
|
||||
@@ -535,6 +535,28 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存合同编号。新增未填写时按部门规则生成,已填写或编辑时保留手工编号并校验唯一。
|
||||
*/
|
||||
private void applyContractNo(ContractManage contractManage, String contractNo, boolean generateWhenEmpty) {
|
||||
String normalizedContractNo = TransportBusinessSupport.trimToNull(contractNo);
|
||||
if (Func.isEmpty(normalizedContractNo)) {
|
||||
if (!generateWhenEmpty) {
|
||||
throw new ServiceException("请输入合同编号");
|
||||
}
|
||||
normalizedContractNo = nextContractNo(contractManage.getOrganizationId());
|
||||
}
|
||||
TransportBusinessSupport.validateLength(normalizedContractNo, 100, "合同编号不能超过100字");
|
||||
Long duplicatedCount = count(Wrappers.<ContractManage>lambdaQuery()
|
||||
.eq(ContractManage::getIsDeleted, 0)
|
||||
.eq(ContractManage::getContractNo, normalizedContractNo)
|
||||
.ne(Func.isNotEmpty(contractManage.getId()), ContractManage::getId, contractManage.getId()));
|
||||
if (duplicatedCount != null && duplicatedCount > 0) {
|
||||
throw new ServiceException("合同编号已存在");
|
||||
}
|
||||
contractManage.setContractNo(normalizedContractNo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized String nextContractNo(Long organizationId) {
|
||||
if (Func.isEmpty(organizationId)) {
|
||||
@@ -558,7 +580,8 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
.orderByDesc(ContractManage::getContractNo)
|
||||
.last("LIMIT 50"));
|
||||
Pattern pattern = Pattern.compile("^" + Pattern.quote(prefix) + "(" + year + ")(\\d{3})号$");
|
||||
int next = 1;
|
||||
// 当年尚无匹配编号时,从组织配置的初始流水号起编;否则在已有最大流水号上加1
|
||||
int next = resolveContractInitialSerial(dept);
|
||||
for (ContractManage item : latestList) {
|
||||
String contractNo = TransportBusinessSupport.trimToNull(item.getContractNo());
|
||||
if (Func.isEmpty(contractNo)) {
|
||||
@@ -577,6 +600,17 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
return head + String.format("%03d", next) + "号";
|
||||
}
|
||||
|
||||
private int resolveContractInitialSerial(Dept dept) {
|
||||
Integer serial = dept.getContractInitialSerial();
|
||||
if (serial == null || serial < 1) {
|
||||
return 1;
|
||||
}
|
||||
if (serial > 999) {
|
||||
throw new ServiceException("组织机构中配置的合同初始流水号不能超过999");
|
||||
}
|
||||
return serial;
|
||||
}
|
||||
|
||||
private void refreshArchiveStatusOnApprove(ContractManage contractManage) {
|
||||
if (hasSealArchiveFile(contractManage.getContractFileJson())) {
|
||||
contractManage.setArchiveStatus(ARCHIVE_STATUS_ARCHIVED);
|
||||
@@ -1015,6 +1049,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
|
||||
private Map<String, Object> buildContractChangeSnapshot(ContractManage contractManage) {
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("contractNo", contractManage.getContractNo());
|
||||
data.put("contractName", contractManage.getContractName());
|
||||
data.put("partyB", contractManage.getPartyB());
|
||||
data.put("startDate", contractManage.getStartDate());
|
||||
|
||||
+507
-84
@@ -28,6 +28,7 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.secure.BladeUser;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.jackson.JsonUtil;
|
||||
import org.springblade.core.tool.utils.DateUtil;
|
||||
@@ -45,24 +46,31 @@ import org.springblade.transport.pojo.dto.PunchPhotoDTO;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.TransportVehicle;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.support.CertificationGate;
|
||||
import org.springblade.transport.support.VehiclePlateSupport;
|
||||
import org.springblade.transport.pojo.entity.ProcessConfig;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
|
||||
import org.springblade.transport.pojo.entity.WaybillNodePunch;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.support.CertificationGate;
|
||||
import org.springblade.transport.support.LiveProcessConfigSupport;
|
||||
import org.springblade.transport.support.VehiclePlateSupport;
|
||||
import org.springblade.transport.support.WaybillCargoSupport;
|
||||
import org.springblade.transport.pojo.vo.DriverEnrouteRecordVO;
|
||||
import org.springblade.transport.pojo.vo.DriverNodePunchVO;
|
||||
import org.springblade.transport.pojo.vo.DriverPunchNodeVO;
|
||||
import org.springblade.transport.pojo.vo.DriverPunchPhotoVO;
|
||||
import org.springblade.transport.pojo.vo.CargoLineVO;
|
||||
import org.springblade.transport.pojo.vo.DriverWaybillCardVO;
|
||||
import org.springblade.transport.pojo.vo.DriverWaybillPreviewVO;
|
||||
import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO;
|
||||
import org.springblade.transport.pojo.vo.TripQueryVO;
|
||||
import org.springblade.transport.pojo.vo.TripStationVO;
|
||||
import org.springblade.transport.pojo.vo.TripTabCountsVO;
|
||||
import org.springblade.transport.pojo.vo.TripVO;
|
||||
import org.springblade.transport.pojo.vo.WaybillTrackVO;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IDriverWaybillService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.ITripQueryService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -130,6 +138,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
/** 被异常阻断时的不可打卡原因(司机端展示) */
|
||||
private static final String LOCK_REASON_EXCEPTION = "该运单存在未处理异常,处理完成后才能打卡";
|
||||
|
||||
/** 运次状态码:0 待接单 / 1 运输中 / 2 已完成 / 3 已取消,与运单侧同一套 */
|
||||
private static final int TRIP_STATUS_PENDING = 0;
|
||||
private static final int TRIP_STATUS_RUNNING = 1;
|
||||
|
||||
private final IWaybillService waybillService;
|
||||
private final IDriverService driverService;
|
||||
private final IMpUserClient mpUserClient;
|
||||
@@ -139,6 +151,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
private final WaybillEnroutePunchMapper enroutePunchMapper;
|
||||
private final WaybillNodePunchMapper nodePunchMapper;
|
||||
private final ExceptionDisposalMapper exceptionDisposalMapper;
|
||||
private final ITripQueryService tripQueryService;
|
||||
|
||||
@Override
|
||||
public DriverWaybillCardVO currentTask() {
|
||||
@@ -252,6 +265,81 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
return toCard(normalizeAcceptStatus(waybill), true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DriverWaybillCardVO tripCurrentTask() {
|
||||
String phone = currentUserPhone();
|
||||
if (Func.isEmpty(phone)) {
|
||||
return null;
|
||||
}
|
||||
TripQueryVO query = TripQueryVO.forDriver(phone);
|
||||
query.setStatus(TRIP_STATUS_RUNNING);
|
||||
query.setCurrent(1);
|
||||
query.setSize(1);
|
||||
IPage<TripVO> page = tripQueryService.page(query);
|
||||
return page.getRecords().isEmpty() ? null : toTripCard(page.getRecords().get(0), false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DriverWaybillPreviewVO tripPendingPreview(Integer size) {
|
||||
DriverWaybillPreviewVO preview = new DriverWaybillPreviewVO();
|
||||
String phone = currentUserPhone();
|
||||
if (Func.isEmpty(phone)) {
|
||||
return preview;
|
||||
}
|
||||
TripQueryVO query = TripQueryVO.forDriver(phone);
|
||||
query.setStatus(TRIP_STATUS_PENDING);
|
||||
query.setCurrent(1);
|
||||
query.setSize(normalizePreviewSize(size));
|
||||
IPage<TripVO> page = tripQueryService.page(query);
|
||||
preview.setTotal(page.getTotal());
|
||||
preview.setRecords(page.getRecords().stream()
|
||||
.map(trip -> toTripCard(trip, false))
|
||||
.collect(Collectors.toList()));
|
||||
return preview;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DriverWaybillTabCountsVO tripTabCounts() {
|
||||
DriverWaybillTabCountsVO counts = new DriverWaybillTabCountsVO();
|
||||
String phone = currentUserPhone();
|
||||
if (Func.isEmpty(phone)) {
|
||||
return counts;
|
||||
}
|
||||
TripTabCountsVO tripCounts = tripQueryService.tabCounts(TripQueryVO.forDriver(phone));
|
||||
counts.setPending(tripCounts.getPending());
|
||||
counts.setDoing(tripCounts.getDoing());
|
||||
counts.setDone(tripCounts.getDone());
|
||||
counts.setAll(tripCounts.getAll());
|
||||
return counts;
|
||||
}
|
||||
|
||||
@Override
|
||||
public IPage<DriverWaybillCardVO> tripPage(Integer current, Integer size, Integer status, String keyword) {
|
||||
String phone = currentUserPhone();
|
||||
if (Func.isEmpty(phone)) {
|
||||
return new Page<>(normalizePageNo(current), normalizePageSize(size));
|
||||
}
|
||||
TripQueryVO query = TripQueryVO.forDriver(phone);
|
||||
query.setStatus(status);
|
||||
query.setKeyword(keyword);
|
||||
query.setCurrent(current);
|
||||
query.setSize(size);
|
||||
IPage<TripVO> trips = tripQueryService.page(query);
|
||||
Page<DriverWaybillCardVO> page = new Page<>(trips.getCurrent(), trips.getSize(), trips.getTotal());
|
||||
page.setRecords(trips.getRecords().stream()
|
||||
.map(trip -> toTripCard(trip, false))
|
||||
.collect(Collectors.toList()));
|
||||
return page;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DriverWaybillCardVO tripDetail(String tripId) {
|
||||
requireCurrentDriver();
|
||||
TripQueryVO query = TripQueryVO.forDriver(currentUserPhone());
|
||||
query.setTripId(tripId);
|
||||
return toTripCard(tripQueryService.detail(query), true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public WaybillTrackVO track(Long id) {
|
||||
requireCurrentDriver();
|
||||
@@ -262,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())
|
||||
@@ -296,6 +389,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
return new LocalDate[]{waybill.getStartDate(), today};
|
||||
}
|
||||
return new LocalDate[]{today.minusDays(3), today};
|
||||
*/
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -318,17 +412,21 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
}
|
||||
Driver driver = requireCurrentDriver();
|
||||
// 在途打卡单独保留原取单判据,不排除已拒单(见 scopedQuery 的重载说明)
|
||||
Waybill waybill = loadDriverWaybill(dto.getWaybillId(), true);
|
||||
// 加行锁:重复打卡是先查后插,并发请求读不到对方未提交的流水,不锁会各插一条
|
||||
Waybill waybill = loadDriverWaybill(dto.getWaybillId(), true, true);
|
||||
waybill = normalizeAcceptStatus(waybill);
|
||||
assertPunchNotEnded(waybill.getBusinessStatus());
|
||||
assertPunchStarted(waybill.getBusinessStatus());
|
||||
|
||||
String processJson = resolveProcessJson(waybill);
|
||||
Date lastPunchAt = findLastPunchTime(waybill.getId());
|
||||
Date lastPunchAt = findLastPunchTime(waybill.getId(), true);
|
||||
WaybillProcessSupport.TransitCheckinDecision decision = WaybillProcessSupport.evaluateTransitCheckin(
|
||||
processJson, waybill.getBusinessStatus(), lastPunchAt, LocalDateTime.now());
|
||||
if (!decision.punchEnabled()) {
|
||||
throw new ServiceException("该运单未启用在途打卡");
|
||||
}
|
||||
if (decision.doneToday()) {
|
||||
// 重复校验按「自然日是否打过」独立判:decision.doneToday() 只在进行中分支计算
|
||||
if (WaybillProcessSupport.isPunchedOn(lastPunchAt, null)) {
|
||||
throw new ServiceException("今日已完成在途打卡,不可重复打卡");
|
||||
}
|
||||
// 在途也是过程节点之一,占过程配置顺序里的位置,同样受异常门禁约束
|
||||
@@ -369,6 +467,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
Driver driver = requireCurrentDriver();
|
||||
Waybill waybill = loadDriverWaybill(dto.getWaybillId());
|
||||
waybill = normalizeAcceptStatus(waybill);
|
||||
assertPunchNotEnded(waybill.getBusinessStatus());
|
||||
assertPunchStarted(waybill.getBusinessStatus());
|
||||
|
||||
String processJson = resolveProcessJson(waybill);
|
||||
Map<String, Object> nodeCfg = findPunchNodeConfig(processJson, nodeCode);
|
||||
@@ -557,12 +657,150 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean tripAccept(String tripId) {
|
||||
Driver driver = requireCurrentDriver();
|
||||
// 认证门槛按整车一次过:一次接单只校验一次司机与车辆
|
||||
assertCertified(driver);
|
||||
List<TripVO.TripWaybillVO> children = tripChildren(tripId);
|
||||
assertTripAcceptable(children);
|
||||
List<Long> pendingIds = writeCandidateIds(children, "当前运单状态不允许接单");
|
||||
// 更新痕迹与单票接单一致:单票走 updateById,写前会把 update_time 刷成提交时刻、
|
||||
// 把 update_user 填成当前用户(无登录态则不填);这里一次写完,显式补上同样两列,
|
||||
// 时刻取与接单时刻同一个 now,操作人取同一个登录用户
|
||||
Date now = new Date();
|
||||
Long operatorId = currentOperatorId();
|
||||
return waybillService.update(Wrappers.<Waybill>lambdaUpdate()
|
||||
.set(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_ACCEPTED)
|
||||
.set(Waybill::getDriverAcceptTime, now)
|
||||
.set(Waybill::getDriverAcceptDriverId, driver.getId())
|
||||
.set(Waybill::getDriverRejectTime, null)
|
||||
.set(Waybill::getDriverRejectReason, null)
|
||||
.set(Waybill::getBusinessStatus, STATUS_RUNNING)
|
||||
.set(Waybill::getUpdateTime, now)
|
||||
.set(operatorId != null, Waybill::getUpdateUser, operatorId)
|
||||
.in(Waybill::getId, pendingIds));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean tripReject(String tripId, String reason) {
|
||||
requireCurrentDriver();
|
||||
List<TripVO.TripWaybillVO> children = tripChildren(tripId);
|
||||
assertTripRejectable(children);
|
||||
String rejectReason = Func.isEmpty(reason) ? null : reason.trim();
|
||||
if (Func.isNotEmpty(rejectReason) && rejectReason.length() > 200) {
|
||||
throw new ServiceException("拒绝原因不能超过200字");
|
||||
}
|
||||
List<Long> pendingIds = writeCandidateIds(children, "当前运单状态不允许拒绝接单");
|
||||
// 整单拒绝落在每张未终态子运单上,一次一条语句写完,拒单时刻与更新痕迹全趟车一致
|
||||
Date now = new Date();
|
||||
Long operatorId = currentOperatorId();
|
||||
return waybillService.update(Wrappers.<Waybill>lambdaUpdate()
|
||||
.set(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED)
|
||||
.set(Waybill::getDriverAcceptTime, null)
|
||||
.set(Waybill::getDriverAcceptDriverId, null)
|
||||
.set(Waybill::getDriverRejectTime, now)
|
||||
.set(Waybill::getDriverRejectReason, rejectReason)
|
||||
.set(Waybill::getBusinessStatus, STATUS_PENDING)
|
||||
.set(Waybill::getUpdateTime, now)
|
||||
.set(operatorId != null, Waybill::getUpdateUser, operatorId)
|
||||
.in(Waybill::getId, pendingIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前登录用户主键:与框架写入时填充更新人的口径一致——没有登录态就不填
|
||||
*/
|
||||
private Long currentOperatorId() {
|
||||
BladeUser operator = AuthUtil.getUser();
|
||||
return operator == null ? null : operator.getUserId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次 → 子运单集合:配载的运次走运次读服务解析(配载单主键命中后按配载单号取子运单),
|
||||
* 未配载的就是那张运单本身。收口与列表一致——别人的运次在这里已经被挡住。
|
||||
* <p>
|
||||
* 子单集合是司机可见的那一批:已拒单的子单不在其中,否则接单会把本人拒掉的单重新拉回来。
|
||||
*/
|
||||
private List<TripVO.TripWaybillVO> tripChildren(String tripId) {
|
||||
TripQueryVO query = TripQueryVO.forDriver(currentUserPhone());
|
||||
query.setTripId(tripId);
|
||||
TripVO trip = tripQueryService.detail(query);
|
||||
List<TripVO.TripWaybillVO> children = trip.getWaybills() == null ? List.of() : trip.getWaybills();
|
||||
if (children.isEmpty()) {
|
||||
throw new ServiceException("运次不存在或无权操作");
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
/**
|
||||
* 接单校验按整车:有子单没走完才可接单,任一子单的项目要接单整趟车就要接单,任一子单已接单即整单已接单
|
||||
*/
|
||||
private void assertTripAcceptable(List<TripVO.TripWaybillVO> children) {
|
||||
if (children.stream().allMatch(this::isTerminalChild)) {
|
||||
throw new ServiceException("当前运单状态不允许接单");
|
||||
}
|
||||
if (children.stream().noneMatch(child -> Boolean.TRUE.equals(child.getRequireAccept()))) {
|
||||
throw new ServiceException("该运单无需确认接单");
|
||||
}
|
||||
if (children.stream().anyMatch(child -> WaybillProcessSupport.isAccepted(child.getAcceptStatus()))) {
|
||||
throw new ServiceException("该运单已接单");
|
||||
}
|
||||
if (children.stream().anyMatch(child -> WaybillProcessSupport.isRejected(child.getAcceptStatus()))) {
|
||||
throw new ServiceException("该运单已拒单,请联系调度重新派单");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒单校验按整车:有子单没走完才可拒绝,任一子单已接单则整单不能拒绝
|
||||
*/
|
||||
private void assertTripRejectable(List<TripVO.TripWaybillVO> children) {
|
||||
if (children.stream().allMatch(this::isTerminalChild)) {
|
||||
throw new ServiceException("当前运单状态不允许拒绝接单");
|
||||
}
|
||||
if (children.stream().noneMatch(child -> Boolean.TRUE.equals(child.getRequireAccept()))) {
|
||||
throw new ServiceException("该运单无需确认接单");
|
||||
}
|
||||
if (children.stream().anyMatch(child -> WaybillProcessSupport.isAccepted(child.getAcceptStatus()))) {
|
||||
throw new ServiceException("该运单已接单,无法拒绝");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 可写入的子运单:已走完的子单不动——把已完成/已取消的子单改回运输中或待执行等于回退历史状态
|
||||
*/
|
||||
private List<Long> writeCandidateIds(List<TripVO.TripWaybillVO> children, String emptyMessage) {
|
||||
List<Long> ids = children.stream()
|
||||
.filter(child -> !isTerminalChild(child))
|
||||
.map(child -> parseId(child.getId()))
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
if (ids.isEmpty()) {
|
||||
throw new ServiceException(emptyMessage);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private boolean isTerminalChild(TripVO.TripWaybillVO child) {
|
||||
return WaybillProcessSupport.isTerminalBusinessStatus(child.getBusinessStatus());
|
||||
}
|
||||
|
||||
private Waybill loadDriverWaybill(Long id) {
|
||||
return loadDriverWaybill(id, false);
|
||||
}
|
||||
|
||||
/** @param includeRejected 仅 submitEnroute 传 true,见 scopedQuery 的重载说明 */
|
||||
private Waybill loadDriverWaybill(Long id, boolean includeRejected) {
|
||||
return loadDriverWaybill(id, includeRejected, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param forUpdate 加行锁,仅 submitEnroute 使用:「同一天是否打过」是查完再插,
|
||||
* 不加锁时并发的两个请求都读不到对方未提交的流水,会各插一条。
|
||||
* 锁的是运单行,同一运单的打卡提交因此串行。
|
||||
*/
|
||||
private Waybill loadDriverWaybill(Long id, boolean includeRejected, boolean forUpdate) {
|
||||
if (id == null) {
|
||||
throw new ServiceException("运单ID不能为空");
|
||||
}
|
||||
@@ -570,8 +808,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
if (Func.isEmpty(phone)) {
|
||||
throw new ServiceException("当前用户手机号为空,无法查找运单");
|
||||
}
|
||||
// MySQL 的 FOR UPDATE 必须跟在 LIMIT 之后
|
||||
String tail = forUpdate ? "LIMIT 1 FOR UPDATE" : "LIMIT 1";
|
||||
Waybill waybill = waybillService.getOne(scopedQuery(phone, includeRejected)
|
||||
.eq(Waybill::getId, id).last("LIMIT 1"));
|
||||
.eq(Waybill::getId, id).last(tail));
|
||||
if (waybill == null) {
|
||||
throw new ServiceException("运单不存在或无权操作");
|
||||
}
|
||||
@@ -748,7 +988,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
return toCard(waybill, false);
|
||||
}
|
||||
|
||||
private DriverWaybillCardVO toCard(Waybill waybill, boolean withEnrouteRecords) {
|
||||
private DriverWaybillCardVO toCard(Waybill waybill, boolean withDetail) {
|
||||
DriverWaybillCardVO card = new DriverWaybillCardVO();
|
||||
card.setId(waybill.getId());
|
||||
card.setWaybillNo(waybill.getWaybillNo());
|
||||
@@ -758,9 +998,18 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
card.setToName(Func.toStrWithEmpty(waybill.getArrivalName(), Func.toStr(waybill.getArrivalAddress(), "")));
|
||||
card.setFromAddress(Func.toStrWithEmpty(waybill.getDepartureAddress(), card.getFromName()));
|
||||
card.setToAddress(Func.toStrWithEmpty(waybill.getArrivalAddress(), card.getToName()));
|
||||
card.setCargoNames(splitCargoNames(waybill.getCargoName()));
|
||||
card.setCargoCategory(Func.toStr(waybill.getCargoType(), ""));
|
||||
card.setWeight(formatWeight(waybill.getQuantity(), waybill.getQuantityUnit()));
|
||||
// 货名取运单上录入的全部货物:主表 cargo_name 只落了第一行,直接拿它会漏掉这张票的其余货物
|
||||
List<WaybillCargoSupport.Cargo> cargos = WaybillCargoSupport.of(waybill);
|
||||
List<String> cargoNames = WaybillCargoSupport.cargoNames(cargos);
|
||||
card.setCargoNames(cargoNames);
|
||||
card.setCargoCategory(cargos.stream()
|
||||
.map(WaybillCargoSupport.Cargo::cargoType)
|
||||
.filter(Func::isNotEmpty)
|
||||
.distinct()
|
||||
.collect(Collectors.joining("、")));
|
||||
// 计重合计:全部货物同单位时就是该单位合计,单位不一致时只有吨项参与(口径见 WaybillCargoSupport#weighing)
|
||||
WaybillCargoSupport.Quantity weighing = WaybillCargoSupport.weighing(cargos);
|
||||
card.setWeight(weighing == null ? "" : formatWeight(weighing.value(), weighing.unit()));
|
||||
card.setStatus(toAppStatus(waybill.getBusinessStatus()));
|
||||
String createTime = formatDateTime(waybill.getCreateTime());
|
||||
card.setCreateTime(createTime);
|
||||
@@ -777,20 +1026,21 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
card.setRejectReason(waybill.getDriverRejectReason());
|
||||
|
||||
// 详情页字段(列表也可带上,体积很小)
|
||||
String cargoName = Func.toStr(waybill.getCargoName(), "");
|
||||
String cargoName = String.join("、", cargoNames);
|
||||
String weightText = card.getWeight();
|
||||
card.setCargoName(cargoName);
|
||||
card.setPickupAddress(card.getFromAddress());
|
||||
card.setUnloadAddress(card.getToAddress());
|
||||
card.setCargoQuantity(weightText);
|
||||
card.setTotalWeight(weightText);
|
||||
card.setCargos(withDetail ? cargoLines(cargos) : null);
|
||||
card.setTransportType(toTransportTypeLabel(waybill.getTransportType()));
|
||||
card.setTransportOrgType(Func.isNotBlank(waybill.getLoadingNo()) ? TRANSPORT_LOAD : TRANSPORT_COMMON);
|
||||
card.setPlanShipTime(formatLocalDateYmd(waybill.getEstimatedStartTime()));
|
||||
card.setPlanFinishTime(formatLocalDateYmd(waybill.getEstimatedEndTime()));
|
||||
card.setRemark(Func.toStr(waybill.getRemark(), ""));
|
||||
|
||||
if (withEnrouteRecords) {
|
||||
if (withDetail) {
|
||||
Date lastPunchAt = findLastPunchTime(waybill.getId());
|
||||
WaybillProcessSupport.TransitCheckinDecision transit = WaybillProcessSupport.evaluateTransitCheckin(
|
||||
processJson, waybill.getBusinessStatus(), lastPunchAt, LocalDateTime.now());
|
||||
@@ -828,6 +1078,143 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
return List.of(load, unload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次卡片
|
||||
* <p>
|
||||
* 未配载的运次就是一张运单:详情照运单详情下发(打卡节点、在途打卡、过程配置都还在),
|
||||
* 只是状态按运次口径只读推导,不走会落库的接单态归一化。
|
||||
* 配载的运次按配载单下发整趟车的信息,运单级字段(运费、当前节点、打卡节点)留空——
|
||||
* 一趟车里每张子单各有各的项目配置与打卡节点。
|
||||
*/
|
||||
private DriverWaybillCardVO toTripCard(TripVO trip, boolean withDetail) {
|
||||
DriverWaybillCardVO card = Func.isEmpty(trip.getLoadingNo()) && withDetail
|
||||
? unloadedTripDetail(trip)
|
||||
: new DriverWaybillCardVO();
|
||||
fillTripFields(card, trip, withDetail);
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* 未配载运次的详情照运单详情拼,只读:不调用 normalizeAcceptStatus
|
||||
*/
|
||||
private DriverWaybillCardVO unloadedTripDetail(TripVO trip) {
|
||||
Long waybillId = parseId(trip.getId());
|
||||
Waybill waybill = waybillId == null ? null : waybillService.getById(waybillId);
|
||||
return waybill == null ? new DriverWaybillCardVO() : toCard(waybill, true);
|
||||
}
|
||||
|
||||
private void fillTripFields(DriverWaybillCardVO card, TripVO trip, boolean withDetail) {
|
||||
List<TripVO.TripWaybillVO> children = trip.getWaybills() == null ? List.of() : trip.getWaybills();
|
||||
card.setTripId(trip.getId());
|
||||
card.setTripNo(Func.toStr(trip.getTripNo(), ""));
|
||||
card.setLoadingNo(Func.toStr(trip.getLoadingNo(), ""));
|
||||
// 旧字段 id / waybillNo 保留原语义:未配载就是这张运单,配载取下第一张可见子运单(运次操作请用 tripId)
|
||||
card.setId(parseId(children.isEmpty() ? trip.getId() : children.get(0).getId()));
|
||||
card.setWaybillNo(children.isEmpty() ? card.getTripNo() : Func.toStr(children.get(0).getWaybillNo(), ""));
|
||||
card.setStatus(trip.getStatus());
|
||||
card.setRequireAccept(trip.getRequireAccept());
|
||||
card.setAcceptStatus(trip.getAcceptStatus());
|
||||
card.setTransportOrgType(trip.getTransportOrgType());
|
||||
card.setTransportType(toTransportTypeLabel(trip.getTransportType()));
|
||||
card.setDriverName(Func.toStr(trip.getDriverName(), ""));
|
||||
card.setDriverPhone(Func.toStr(trip.getDriverPhone(), ""));
|
||||
card.setVehicleNo(Func.toStr(trip.getVehicleNo(), ""));
|
||||
card.setCreateTime(formatDateTime(trip.getSortTime()));
|
||||
card.setPublishTime(card.getCreateTime());
|
||||
// 计划时间两条路同源:配载运次取配载单,未配载运次取那张运单自己的。
|
||||
// 未配载详情进来时卡片已照运单填过,运次级为空就保留它,不覆盖成空
|
||||
if (trip.getEstimatedStartDate() != null) {
|
||||
card.setPlanShipTime(formatLocalDateYmd(trip.getEstimatedStartDate()));
|
||||
}
|
||||
if (trip.getEstimatedEndDate() != null) {
|
||||
card.setPlanFinishTime(formatLocalDateYmd(trip.getEstimatedEndDate()));
|
||||
}
|
||||
// 备注只有配载运次有运次级的值,未配载的保留运单卡片自己带来的备注
|
||||
if (Func.isNotEmpty(card.getLoadingNo())) {
|
||||
card.setRemark(Func.toStr(trip.getTaskRemark(), ""));
|
||||
}
|
||||
card.setFromName(Func.toStrWithEmpty(trip.getDepartureName(), Func.toStr(trip.getDepartureAddress(), "")));
|
||||
card.setFromAddress(Func.toStrWithEmpty(trip.getDepartureAddress(), card.getFromName()));
|
||||
card.setToName(Func.toStrWithEmpty(trip.getArrivalName(), Func.toStr(trip.getArrivalAddress(), "")));
|
||||
card.setToAddress(Func.toStrWithEmpty(trip.getArrivalAddress(), card.getToName()));
|
||||
card.setViaName(Func.toStr(trip.getTransitName(), ""));
|
||||
card.setStationCount(trip.getStationCount());
|
||||
card.setPickupAddress(card.getFromAddress());
|
||||
card.setUnloadAddress(card.getToAddress());
|
||||
// 货名取运次上算好的整趟车清单:它按子单录入的货物明细摊平去重,主表 cargo_name 只是首行。
|
||||
// 运次清单为空时按子单卡片自己的货名兜底,不让卡片上的货物行整个消失
|
||||
List<String> cargoNames = trip.getCargoNames() == null ? List.of() : trip.getCargoNames();
|
||||
if (cargoNames.isEmpty()) {
|
||||
cargoNames = children.stream()
|
||||
.flatMap(child -> splitCargoNames(child.getCargoName()).stream())
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
card.setCargoNames(cargoNames);
|
||||
card.setCargoName(String.join("、", cargoNames));
|
||||
List<String> cargoTypes = trip.getCargoTypes() == null ? List.of() : trip.getCargoTypes();
|
||||
if (cargoTypes.isEmpty()) {
|
||||
cargoTypes = children.stream()
|
||||
.map(TripVO.TripWaybillVO::getCargoType)
|
||||
.filter(Func::isNotEmpty)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
card.setCargoCategory(String.join("、", cargoTypes));
|
||||
card.setCargoSummary(Func.toStr(trip.getCargoSummary(), ""));
|
||||
// 计重合计:同单位时就是该单位合计,单位不一致时只有吨项参与(见 TripQueryServiceImpl#fillWeighing);
|
||||
// 都没有就留空,卡片上只剩货名,不拿汇总串冒充重量
|
||||
String weight = trip.getWeighingQuantity() == null
|
||||
? ""
|
||||
: formatWeight(trip.getWeighingQuantity(), trip.getWeighingUnit());
|
||||
card.setWeight(weight);
|
||||
card.setCargoQuantity(weight);
|
||||
card.setTotalWeight(weight);
|
||||
if (withDetail) {
|
||||
card.setStations(trip.getStations());
|
||||
card.setRoutePoints(toTripRoutePoints(trip.getStations()));
|
||||
card.setCargos(trip.getCargos());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情里的点位列:整趟车的全部站点,站序即途经顺序
|
||||
*/
|
||||
private List<DriverWaybillCardVO.DriverRoutePointVO> toTripRoutePoints(List<TripStationVO> stations) {
|
||||
if (Func.isEmpty(stations)) {
|
||||
return List.of();
|
||||
}
|
||||
return stations.stream().map(station -> {
|
||||
DriverWaybillCardVO.DriverRoutePointVO point = new DriverWaybillCardVO.DriverRoutePointVO();
|
||||
point.setName(Func.toStr(station.getName(), ""));
|
||||
point.setAddress(Func.toStrWithEmpty(station.getAddress(), point.getName()));
|
||||
point.setStatus("pending");
|
||||
return point;
|
||||
}).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private Long parseId(String value) {
|
||||
if (Func.isEmpty(value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(value.trim());
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private int normalizePageNo(Integer current) {
|
||||
return current == null || current < 1 ? 1 : current;
|
||||
}
|
||||
|
||||
private int normalizePageSize(Integer size) {
|
||||
if (size == null || size < 1) {
|
||||
return DEFAULT_PAGE_SIZE;
|
||||
}
|
||||
return Math.min(size, MAX_PAGE_SIZE);
|
||||
}
|
||||
|
||||
private String toTransportTypeLabel(String transportType) {
|
||||
if (Func.isBlank(transportType)) {
|
||||
return "";
|
||||
@@ -850,55 +1237,16 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 优先用项目启用中的过程配置;若动态配置无打卡节点,回退运单快照 processJson,
|
||||
* 避免项目配置改坏后司机端打卡页空白。
|
||||
* 优先用项目启用中的过程配置;若动态配置无打卡节点,回退运单快照,
|
||||
* 避免项目配置改坏后司机端打卡页空白。取法与规则都在 {@link LiveProcessConfigSupport}。
|
||||
*/
|
||||
private String resolveProcessJson(Waybill waybill) {
|
||||
if (waybill == null) {
|
||||
return null;
|
||||
}
|
||||
String snapshot = waybill.getProcessJson();
|
||||
String live = loadLiveProcessConfigJson(waybill.getProjectId());
|
||||
if (Func.isNotEmpty(live)) {
|
||||
if (!WaybillProcessSupport.listDriverPunchNodes(live).isEmpty()) {
|
||||
return live;
|
||||
}
|
||||
// 动态配置存在但无可打卡节点:仍回退快照
|
||||
if (Func.isNotEmpty(snapshot)
|
||||
&& !WaybillProcessSupport.listDriverPunchNodes(snapshot).isEmpty()) {
|
||||
return snapshot;
|
||||
}
|
||||
return live;
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private String loadLiveProcessConfigJson(Long projectId) {
|
||||
if (projectId == null) {
|
||||
return null;
|
||||
}
|
||||
String projectIdStr = String.valueOf(projectId);
|
||||
return processConfigService.list(Wrappers.<ProcessConfig>lambdaQuery()
|
||||
.eq(ProcessConfig::getStatus, 1)
|
||||
.eq(ProcessConfig::getIsDeleted, 0)
|
||||
.like(ProcessConfig::getProjectIds, projectIdStr)
|
||||
.orderByDesc(ProcessConfig::getUpdateTime)
|
||||
.orderByDesc(ProcessConfig::getCreateTime))
|
||||
.stream()
|
||||
.filter(cfg -> containsProjectId(cfg.getProjectIds(), projectIdStr))
|
||||
.map(ProcessConfig::getNodeConfigJson)
|
||||
.filter(Func::isNotEmpty)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private boolean containsProjectId(String projectIds, String projectId) {
|
||||
if (Func.isEmpty(projectIds) || Func.isEmpty(projectId)) {
|
||||
return false;
|
||||
}
|
||||
return Arrays.stream(projectIds.split(","))
|
||||
.map(String::trim)
|
||||
.anyMatch(projectId::equals);
|
||||
return LiveProcessConfigSupport.resolveForPunch(
|
||||
LiveProcessConfigSupport.byProject(processConfigService, waybill.getProjectId()),
|
||||
waybill.getProcessJson());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -998,6 +1346,32 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言运单未结束。
|
||||
* <p>
|
||||
* 运单已完成/已取消后不再接受司机打卡:在途与节点都拦,且先于其它门禁。
|
||||
* 结束不可逆,没有解除途径——处理完异常、等到第二天都还是打不了。
|
||||
*/
|
||||
private void assertPunchNotEnded(String businessStatus) {
|
||||
String reason = WaybillProcessSupport.punchEndedReason(businessStatus);
|
||||
if (reason != null) {
|
||||
throw new ServiceException(reason);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言运单已开始运输。
|
||||
* <p>
|
||||
* 只有进行中的运单能打:草稿、待执行(待接单)、待调度、调度中都还没上路。
|
||||
* 与「运单已结束」分开判:结束没有解除途径,未开始会随接单/调度自行变为进行中。
|
||||
*/
|
||||
private void assertPunchStarted(String businessStatus) {
|
||||
String reason = WaybillProcessSupport.punchNotStartedReason(businessStatus);
|
||||
if (reason != null) {
|
||||
throw new ServiceException(reason);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言指定节点未被异常门禁阻断。
|
||||
* <p>
|
||||
@@ -1030,7 +1404,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
Set<String> unfinished = WaybillProcessSupport.unfinishedPrerequisiteKeys(
|
||||
processJson, punched, WaybillProcessSupport.nodeKey(nodeCfg));
|
||||
if (!unfinished.isEmpty()) {
|
||||
throw new ServiceException("请先完成「" + prerequisiteNames(processJson, unfinished) + "」打卡");
|
||||
throw new ServiceException("请先完成「"
|
||||
+ WaybillProcessSupport.firstUnfinishedPrerequisiteName(processJson, unfinished) + "」打卡");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1191,6 +1566,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
latestEnroute = enroutes.get(enroutes.size() - 1);
|
||||
}
|
||||
Map<String, WaybillNodePunch> latestNodePunchByCode = latestNodePunchMap(waybill.getId());
|
||||
// 未开始运输的运单在途卡仍下发,但不可提交:司机要看得到这张卡,才知道为什么不能打
|
||||
String notStartedReason = WaybillProcessSupport.punchNotStartedReason(waybill.getBusinessStatus());
|
||||
|
||||
for (Map<String, Object> cfg : punchConfigs) {
|
||||
boolean isTransit = WaybillProcessSupport.isTransitNodePublic(cfg);
|
||||
@@ -1218,7 +1595,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
vo.setCargoTypes(Collections.emptyList());
|
||||
vo.setVisible(true);
|
||||
vo.setDone(done);
|
||||
vo.setActionable(!done);
|
||||
vo.setActionable(!done && notStartedReason == null);
|
||||
if (notStartedReason != null) {
|
||||
vo.setLockReason(notStartedReason);
|
||||
}
|
||||
if (done && latestEnroute != null) {
|
||||
vo.setCheckinTime(latestEnroute.getTime());
|
||||
vo.setCheckinPlace(latestEnroute.getAddress());
|
||||
@@ -1239,7 +1619,16 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
boolean done = punched != null;
|
||||
vo.setVisible(true);
|
||||
vo.setDone(done);
|
||||
vo.setActionable(!done);
|
||||
vo.setActionable(!done && notStartedReason == null);
|
||||
if (notStartedReason != null && !done) {
|
||||
vo.setLockReason(notStartedReason);
|
||||
}
|
||||
// 末位卡上打一下就完成这张运单:判据与下方 submitNode 的自动完成同一份
|
||||
// (末位可打卡节点 + 到货/卸货/签收 + 回单不要求业务员确认),客户端只按它把按钮文案改成「完成运单」。
|
||||
// 两处判据必须同源,否则会出现「按钮写着完成运单、打下去这张运单没完成」。
|
||||
vo.setFinishOnPunch(WaybillProcessSupport.isLastDriverPunchNode(processJson, vo.getKey())
|
||||
&& WaybillProcessSupport.isDeliveryNode(vo.getKey(), vo.getName())
|
||||
&& WaybillProcessSupport.returnNeedsNoConfirmation(processJson));
|
||||
if (done) {
|
||||
if (punched.getPunchTime() != null) {
|
||||
LocalDateTime ldt = punched.getPunchTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
|
||||
@@ -1268,6 +1657,11 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
if (Boolean.TRUE.equals(vo.getDone()) || !blockedKeys.contains(vo.getKey())) {
|
||||
continue;
|
||||
}
|
||||
// 未开始运输是更根本的拒绝:运单状态不变,处理完异常也打不了。
|
||||
// 这里不能再改写成异常文案,否则会暗示「处理完就能打」。
|
||||
if (notStartedReason != null) {
|
||||
continue;
|
||||
}
|
||||
vo.setBlocked(true);
|
||||
vo.setActionable(false);
|
||||
if (blocker != null) {
|
||||
@@ -1296,14 +1690,36 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
}
|
||||
vo.setBlockedByOrder(true);
|
||||
vo.setActionable(false);
|
||||
vo.setOrderLockReason("请先完成「" + prerequisiteNames(processJson, unfinished) + "」打卡");
|
||||
vo.setOrderLockReason("请先完成「"
|
||||
+ WaybillProcessSupport.firstUnfinishedPrerequisiteName(processJson, unfinished) + "」打卡");
|
||||
}
|
||||
|
||||
// 已结束的运单不再接受打卡:卡片留着给司机回看,未打卡的节点一律不可提交。
|
||||
// 文案压过异常与顺序门禁——结束没有解除途径,说「处理完就能打」是错的。
|
||||
// 已打卡的节点只置 ended:它们要继续可展开回看照片与时间,原因文案也不再适用。
|
||||
String endedReason = WaybillProcessSupport.punchEndedReason(waybill.getBusinessStatus());
|
||||
if (endedReason != null) {
|
||||
for (DriverPunchNodeVO vo : nodes) {
|
||||
vo.setEnded(true);
|
||||
if (!Boolean.TRUE.equals(vo.getDone())) {
|
||||
vo.setActionable(false);
|
||||
vo.setLockReason(endedReason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 是否允许展开:顺序门禁不允许展开(还没轮到,展开也没用);
|
||||
// 异常门禁允许展开——那里出过事,司机需要看到回填内容与「异常详情」。
|
||||
// 已结束的运单:在途卡一律不可展开(结束之后没有在途打卡可做,展开也无内容);
|
||||
// 未打卡的节点展开同样一无所有;已打卡的普通节点保留展开,供司机回看时间与照片。
|
||||
for (DriverPunchNodeVO vo : nodes) {
|
||||
boolean blockedByOrder = Boolean.TRUE.equals(vo.getBlockedByOrder());
|
||||
vo.setExpandable(!blockedByOrder);
|
||||
boolean ended = Boolean.TRUE.equals(vo.getEnded());
|
||||
boolean done = Boolean.TRUE.equals(vo.getDone());
|
||||
boolean transitCard = Boolean.TRUE.equals(vo.getTransit());
|
||||
// 未开始运输的未打卡卡片:打不了也展不开——展开是空表单,状态由锁文案说明
|
||||
boolean notStartedLocked = !done && notStartedReason != null;
|
||||
vo.setExpandable(!blockedByOrder && !notStartedLocked && (!ended || (done && !transitCard)));
|
||||
}
|
||||
|
||||
// 「异常」标签:只挂**存在未处理完成异常**的节点(与异常门禁同一口径,结案即消失)。
|
||||
@@ -1336,6 +1752,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
|
||||
for (DriverPunchNodeVO vo : nodes) {
|
||||
if (!Boolean.TRUE.equals(vo.getDone())
|
||||
&& !Boolean.TRUE.equals(vo.getEnded())
|
||||
&& !Boolean.TRUE.equals(vo.getBlocked())
|
||||
&& !Boolean.TRUE.equals(vo.getBlockedByOrder())) {
|
||||
vo.setDefaultExpanded(true);
|
||||
@@ -1345,18 +1762,6 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/** 把前置节点 key 拼成给人看的名称列表,如「装货、发货」 */
|
||||
private String prerequisiteNames(String processJson, Set<String> nodeKeys) {
|
||||
List<String> names = new ArrayList<>();
|
||||
for (Map<String, Object> node : WaybillProcessSupport.listDriverPunchNodes(processJson)) {
|
||||
String key = WaybillProcessSupport.nodeKey(node);
|
||||
if (nodeKeys.contains(key)) {
|
||||
names.add(WaybillProcessSupport.nodeName(node));
|
||||
}
|
||||
}
|
||||
return names.isEmpty() ? "" : String.join("、", names);
|
||||
}
|
||||
|
||||
private Map<String, Object> findPunchNodeConfig(String processJson, String nodeCode) {
|
||||
List<Map<String, Object>> punchConfigs = WaybillProcessSupport.listDriverPunchNodes(processJson);
|
||||
for (Map<String, Object> cfg : punchConfigs) {
|
||||
@@ -1548,13 +1953,21 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
}
|
||||
|
||||
private Date findLastPunchTime(Long waybillId) {
|
||||
return findLastPunchTime(waybillId, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param forUpdate 仅 submitEnroute:与运单行锁配对。不加锁读走的是事务快照,
|
||||
* 并发提交的第一笔已提交后,第二笔可能仍读到旧快照而看不到它。
|
||||
*/
|
||||
private Date findLastPunchTime(Long waybillId, boolean forUpdate) {
|
||||
if (waybillId == null) {
|
||||
return null;
|
||||
}
|
||||
WaybillEnroutePunch latest = enroutePunchMapper.selectOne(Wrappers.<WaybillEnroutePunch>lambdaQuery()
|
||||
.eq(WaybillEnroutePunch::getWaybillId, waybillId)
|
||||
.orderByDesc(WaybillEnroutePunch::getPunchTime)
|
||||
.last("LIMIT 1"));
|
||||
.last(forUpdate ? "LIMIT 1 FOR UPDATE" : "LIMIT 1"));
|
||||
return latest == null ? null : latest.getPunchTime();
|
||||
}
|
||||
|
||||
@@ -1597,16 +2010,26 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 一格多名的历史写法(顿号、逗号分隔)摊平成货名清单,与货物明细同一个拆分口径
|
||||
*/
|
||||
private List<String> splitCargoNames(String cargoName) {
|
||||
if (Func.isEmpty(cargoName)) {
|
||||
return Collections.emptyList();
|
||||
List<String> names = WaybillCargoSupport.splitCargoNames(cargoName);
|
||||
return names.isEmpty() ? List.of() : names;
|
||||
}
|
||||
|
||||
/**
|
||||
* 货物明细行:一条货物一行,数量带单位
|
||||
*/
|
||||
private List<CargoLineVO> cargoLines(List<WaybillCargoSupport.Cargo> cargos) {
|
||||
List<CargoLineVO> lines = new ArrayList<>();
|
||||
for (WaybillCargoSupport.Line line : WaybillCargoSupport.lines(cargos)) {
|
||||
CargoLineVO vo = new CargoLineVO();
|
||||
vo.setCargoName(line.name());
|
||||
vo.setQuantity(line.quantity() == null ? "" : formatWeight(line.quantity(), line.unit()));
|
||||
lines.add(vo);
|
||||
}
|
||||
String normalized = cargoName.replace(",", ",").replace("、", ",");
|
||||
List<String> names = Func.toStrList(",", normalized).stream()
|
||||
.map(String::trim)
|
||||
.filter(Func::isNotEmpty)
|
||||
.collect(Collectors.toList());
|
||||
return names.isEmpty() ? List.of(cargoName.trim()) : names;
|
||||
return lines;
|
||||
}
|
||||
|
||||
private String formatWeight(BigDecimal quantity, String unit) {
|
||||
|
||||
+11
@@ -144,6 +144,17 @@ public class ExceptionDisposalServiceImpl
|
||||
// 整单异常同样受这条约束:没有节点不等于没有上报人。
|
||||
driverWaybillService.assertCurrentDriverOwnsWaybill(disposal.getWaybillId());
|
||||
|
||||
// 上报门禁之零:运单得先在路上,且不能已经结束。
|
||||
// 判据与司机打卡同一来源;归属校验会顺手把「无需接单」的待执行运单校正为进行中,
|
||||
// 所以状态要在它之后重新读,不能用 fillFromWaybill 里校正前的那份对象。
|
||||
Waybill reportWaybill = waybillService.getById(disposal.getWaybillId());
|
||||
String blockedReason = reportWaybill == null
|
||||
? null
|
||||
: WaybillProcessSupport.reportBlockedReason(reportWaybill.getBusinessStatus());
|
||||
if (blockedReason != null) {
|
||||
throw new ServiceException(blockedReason);
|
||||
}
|
||||
|
||||
// 节点可以为空:全局入口(首页快捷格、打卡页底部按钮)上报的异常不指定环节,
|
||||
// 它无从定位阻断区间,由门禁按「阻断该运单全部未打卡节点」处理。
|
||||
// 节点非空时才要求它确属该运单;节点名取自过程配置而非请求体,避免落进一个该节点并不叫的名字。
|
||||
|
||||
+4
-2
@@ -1772,7 +1772,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
Map<Long, BigDecimal> receiptClaimPaidBySettlement = hasReceivable
|
||||
? receiptClaimSettlementMapper.selectList(Wrappers.<ReceiptClaimSettlement>lambdaQuery()
|
||||
.in(ReceiptClaimSettlement::getFormalSettlementId, settledIds)
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)).stream()
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)
|
||||
.eq(ReceiptClaimSettlement::getIsDeleted, 0)).stream()
|
||||
.collect(Collectors.groupingBy(ReceiptClaimSettlement::getFormalSettlementId,
|
||||
Collectors.reducing(BigDecimal.ZERO,
|
||||
item -> money(item.getAllocatedReceiptAmount()), BigDecimal::add)))
|
||||
@@ -2204,7 +2205,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
: receiptClaimSettlementMapper.selectList(
|
||||
Wrappers.<ReceiptClaimSettlement>lambdaQuery()
|
||||
.eq(ReceiptClaimSettlement::getFormalSettlementId, settlement.getId())
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1))
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)
|
||||
.eq(ReceiptClaimSettlement::getIsDeleted, 0))
|
||||
.stream().map(ReceiptClaimSettlement::getAllocatedReceiptAmount).map(this::money)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
}
|
||||
|
||||
+166
-33
@@ -28,7 +28,10 @@ import org.springblade.transport.pojo.vo.LoadingCarrierContractVO;
|
||||
import org.springblade.transport.pojo.vo.LoadingManageVO;
|
||||
import org.springblade.transport.service.IContractManageService;
|
||||
import org.springblade.transport.service.ILoadingManageService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.IReceivablePayableDetailService;
|
||||
import org.springblade.transport.support.LiveProcessConfigSupport;
|
||||
import org.springblade.transport.support.LoadableAdmissionSupport;
|
||||
import org.springblade.transport.support.TransportBusinessSupport;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
import org.springblade.transport.wrapper.LoadingManageWrapper;
|
||||
@@ -43,8 +46,10 @@ import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -76,6 +81,9 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
@Lazy
|
||||
private IReceivablePayableDetailService receivablePayableDetailService;
|
||||
|
||||
@Resource
|
||||
private IProcessConfigService processConfigService;
|
||||
|
||||
@Override
|
||||
public IPage<LoadingManageVO> selectLoadingManagePage(IPage<LoadingManage> page, LoadingManageVO loadingManage) {
|
||||
IPage<LoadingManage> entityPage = page(page, buildQuery(loadingManage));
|
||||
@@ -147,9 +155,12 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
prepare(loadingManage);
|
||||
validate(loadingManage, false);
|
||||
List<Long> oldWaybillIds = new ArrayList<>();
|
||||
boolean ownChildrenAdmitted = false;
|
||||
if (Func.isNotEmpty(loadingManage.getId())) {
|
||||
LoadingManage oldRecord = loadEditable(loadingManage.getId(), true);
|
||||
oldWaybillIds = waybillIds(oldRecord.getWaybillIdsJson());
|
||||
// 草稿只预占运单号、未过判据,草稿转正式不得沿用本单豁免
|
||||
ownChildrenAdmitted = !STATUS_DRAFT.equals(oldRecord.getBusinessStatus());
|
||||
loadingManage.setLoadingNo(oldRecord.getLoadingNo());
|
||||
loadingManage.setDeptId(oldRecord.getDeptId());
|
||||
loadingManage.setDeptName(oldRecord.getDeptName());
|
||||
@@ -160,10 +171,10 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
if (Func.isEmpty(loadingManage.getLoadingNo())) {
|
||||
loadingManage.setLoadingNo(nextCode());
|
||||
}
|
||||
validateWaybillsAvailable(loadingManage, waybillIds(loadingManage.getWaybillIdsJson()));
|
||||
validateCarrierContract(loadingManage, true);
|
||||
List<Waybill> waybills = validateWaybillsAvailable(loadingManage, waybillIds(loadingManage.getWaybillIdsJson()), ownChildrenAdmitted);
|
||||
validateCarrierContract(loadingManage, true, waybills);
|
||||
boolean result = saveOrUpdate(loadingManage);
|
||||
syncAssociatedWaybills(loadingManage, STATUS_PENDING, oldWaybillIds);
|
||||
writeWaybillsForDriver(loadingManage, oldWaybillIds, waybills);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -409,11 +420,10 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
loadingManage.setDeptId(oldRecord.getDeptId());
|
||||
loadingManage.setDeptName(oldRecord.getDeptName());
|
||||
loadingManage.setBusinessStatus(STATUS_PENDING);
|
||||
validateWaybillsAvailable(loadingManage, waybillIds(loadingManage.getWaybillIdsJson()));
|
||||
validateCarrierContract(loadingManage, true);
|
||||
List<Waybill> waybills = validateWaybillsAvailable(loadingManage, waybillIds(loadingManage.getWaybillIdsJson()), true);
|
||||
validateCarrierContract(loadingManage, true, waybills);
|
||||
boolean result = updateById(loadingManage);
|
||||
syncAssociatedWaybills(loadingManage, STATUS_PENDING, oldWaybillIds);
|
||||
clearAssociatedWaybillAcceptRecords(loadingManage);
|
||||
writeWaybillsForDriver(loadingManage, oldWaybillIds, waybills);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -735,29 +745,64 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
TransportBusinessSupport.validateLength(loadingManage.getTaskRemark(), 500, "任务备注不能超过500字");
|
||||
}
|
||||
|
||||
private void validateWaybillsAvailable(LoadingManage loadingManage, List<Long> waybillIds) {
|
||||
private List<Waybill> validateWaybillsAvailable(LoadingManage loadingManage, List<Long> waybillIds, boolean ownChildrenAdmitted) {
|
||||
if (Func.isEmpty(waybillIds)) {
|
||||
throw new ServiceException("请选择待配载运单");
|
||||
}
|
||||
List<Waybill> waybills = waybillMapper.selectList(Wrappers.<Waybill>lambdaQuery()
|
||||
.eq(Waybill::getIsDeleted, 0)
|
||||
.in(Waybill::getId, waybillIds));
|
||||
List<Waybill> waybills = loadWaybills(waybillIds);
|
||||
if (waybills.size() != waybillIds.size()) {
|
||||
throw new ServiceException("待配载运单不存在或已删除");
|
||||
}
|
||||
String ownLoadingNo = loadingManage.getLoadingNo();
|
||||
for (Waybill waybill : waybills) {
|
||||
TransportBusinessSupport.assertCurrentDept(waybill.getDeptId(), "运单管理");
|
||||
if (Func.isNotEmpty(waybill.getLoadingNo()) && !Objects.equals(waybill.getLoadingNo(), loadingManage.getLoadingNo())) {
|
||||
throw new ServiceException("运单" + waybill.getWaybillNo() + "已关联其他配载单");
|
||||
if (ownChildrenAdmitted && LoadableAdmissionSupport.isOwnChild(waybill, ownLoadingNo)) {
|
||||
continue;
|
||||
}
|
||||
if ((Objects.equals(waybill.getBusinessStatus(), STATUS_COMPLETED) || Objects.equals(waybill.getBusinessStatus(), STATUS_CANCELLED))
|
||||
&& !Objects.equals(waybill.getLoadingNo(), loadingManage.getLoadingNo())) {
|
||||
throw new ServiceException("运单" + waybill.getWaybillNo() + "当前状态不允许配载");
|
||||
LoadableAdmissionSupport.UnloadableReason reason =
|
||||
LoadableAdmissionSupport.unloadableReason(waybill, ownLoadingNo);
|
||||
if (reason != null) {
|
||||
throw new ServiceException(unloadableMessage(waybill, reason));
|
||||
}
|
||||
}
|
||||
validateSameProject(waybills);
|
||||
return waybills;
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐张准入失败的可读原因:判据只给原因枚举,运单号与文案在这里拼。
|
||||
*/
|
||||
private String unloadableMessage(Waybill waybill, LoadableAdmissionSupport.UnloadableReason reason) {
|
||||
String prefix = "运单" + waybill.getWaybillNo();
|
||||
return prefix + switch (reason) {
|
||||
case NOT_ROAD -> "运输方式不是公路,不可配载";
|
||||
case NOT_PENDING -> "当前状态不是待执行,不可配载";
|
||||
case ON_OTHER_MANIFEST -> "已关联其他配载单";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 一张配载单的全部子运单必须属于同一个项目,否则点名与其它子运单项目不同的运单。
|
||||
* <p>
|
||||
* 查询条件表达不了跨行约束,只能整批取回后在内存里比。
|
||||
*/
|
||||
private void validateSameProject(List<Waybill> waybills) {
|
||||
Long projectId = waybills.get(0).getProjectId();
|
||||
List<String> others = waybills.stream()
|
||||
.filter(waybill -> !Objects.equals(waybill.getProjectId(), projectId))
|
||||
.map(Waybill::getWaybillNo)
|
||||
.toList();
|
||||
if (Func.isNotEmpty(others)) {
|
||||
throw new ServiceException("配载单的子运单必须属于同一项目,运单"
|
||||
+ String.join("、", others) + "与其它子运单不属于同一项目");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateCarrierContract(LoadingManage loadingManage, boolean required) {
|
||||
validateCarrierContract(loadingManage, required, null);
|
||||
}
|
||||
|
||||
private void validateCarrierContract(LoadingManage loadingManage, boolean required, List<Waybill> waybills) {
|
||||
if (!Objects.equals(loadingManage.getCarrierType(), "承运商")) {
|
||||
loadingManage.setCarrierContractId(null);
|
||||
if (!Objects.equals(loadingManage.getCarrierType(), CARRIER_SELF)) {
|
||||
@@ -772,10 +817,24 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
loadingManage.setCarrierName(null);
|
||||
return;
|
||||
}
|
||||
ContractManage carrierContract = availableCarrierContracts(projectIdsByWaybills(loadingManage)).stream()
|
||||
.filter(contract -> Objects.equals(contract.getId(), loadingManage.getCarrierContractId()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new ServiceException("所选承运商合同不存在、未审核通过或已失效"));
|
||||
ContractManage carrierContract = availableCarrierContractById(loadingManage.getCarrierContractId());
|
||||
if (carrierContract == null) {
|
||||
throw new ServiceException("所选承运商合同不存在、未审核通过或已失效");
|
||||
}
|
||||
List<Waybill> childWaybills = waybills == null
|
||||
? loadWaybills(waybillIds(loadingManage.getWaybillIdsJson()))
|
||||
: waybills;
|
||||
// 合同自带项目与乙方:合同的项目不属于本单子运单的项目,等于换了项目却没换乙方
|
||||
if (!projectIdsOf(childWaybills).contains(carrierContract.getProjectId())) {
|
||||
String waybillNos = childWaybills.stream()
|
||||
.filter(waybill -> !Objects.equals(waybill.getProjectId(), carrierContract.getProjectId()))
|
||||
.map(Waybill::getWaybillNo)
|
||||
.filter(Func::isNotEmpty)
|
||||
.collect(Collectors.joining("、"));
|
||||
throw new ServiceException(Func.isEmpty(waybillNos)
|
||||
? "所选承运商合同不属于本项目"
|
||||
: "所选承运商合同不属于运单" + waybillNos + "的项目");
|
||||
}
|
||||
loadingManage.setCarrierName(carrierContract.getPartyB());
|
||||
}
|
||||
|
||||
@@ -792,34 +851,98 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
}
|
||||
}
|
||||
|
||||
private List<Long> projectIdsByWaybills(LoadingManage loadingManage) {
|
||||
List<Long> waybillIds = waybillIds(loadingManage.getWaybillIdsJson());
|
||||
private List<Waybill> loadWaybills(List<Long> waybillIds) {
|
||||
if (Func.isEmpty(waybillIds)) {
|
||||
return List.of();
|
||||
}
|
||||
return waybillMapper.selectList(Wrappers.<Waybill>lambdaQuery()
|
||||
.select(Waybill::getProjectId)
|
||||
.eq(Waybill::getIsDeleted, 0)
|
||||
.in(Waybill::getId, waybillIds))
|
||||
.stream().map(Waybill::getProjectId).filter(Objects::nonNull).distinct().toList();
|
||||
.in(Waybill::getId, waybillIds));
|
||||
}
|
||||
|
||||
private List<Long> projectIdsOf(List<Waybill> waybills) {
|
||||
return waybills.stream().map(Waybill::getProjectId).filter(Objects::nonNull).distinct().toList();
|
||||
}
|
||||
|
||||
private List<ContractManage> availableCarrierContracts(List<Long> projectIds) {
|
||||
if (Func.isEmpty(projectIds)) {
|
||||
return List.of();
|
||||
}
|
||||
return contractManageService.list(Wrappers.<ContractManage>lambdaQuery()
|
||||
.eq(ContractManage::getIsDeleted, 0)
|
||||
.eq(ContractManage::getStatus, 1)
|
||||
.eq(ContractManage::getContractCategory, "承运商合同")
|
||||
return contractManageService.list(availableCarrierContractQuery()
|
||||
.in(ContractManage::getProjectId, projectIds)
|
||||
.in(ContractManage::getApprovalStatus, "approved", "change_approved")
|
||||
.and(wrapper -> wrapper.isNull(ContractManage::getContractStage)
|
||||
.or().ne(ContractManage::getContractStage, "terminated"))
|
||||
.orderByDesc(ContractManage::getCreateTime));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按主键取一张可用的承运商合同。
|
||||
* <p>
|
||||
* 不按项目过滤,才能把「合同不可用」与「合同不属于本项目」分成两种提示。
|
||||
* 取不到可用的合同返回 null。
|
||||
*/
|
||||
private ContractManage availableCarrierContractById(Long contractId) {
|
||||
if (Func.isEmpty(contractId)) {
|
||||
return null;
|
||||
}
|
||||
return contractManageService.list(availableCarrierContractQuery()
|
||||
.eq(ContractManage::getId, contractId)
|
||||
.orderByDesc(ContractManage::getCreateTime))
|
||||
.stream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<ContractManage> availableCarrierContractQuery() {
|
||||
return Wrappers.<ContractManage>lambdaQuery()
|
||||
.eq(ContractManage::getIsDeleted, 0)
|
||||
.eq(ContractManage::getStatus, 1)
|
||||
.eq(ContractManage::getContractCategory, "承运商合同")
|
||||
.in(ContractManage::getApprovalStatus, "approved", "change_approved")
|
||||
.and(wrapper -> wrapper.isNull(ContractManage::getContractStage)
|
||||
.or().ne(ContractManage::getContractStage, "terminated"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 配载提交与重新派单共用:子运单状态逐张按该票自己项目的实时过程配置推导后落库,再清除接单事实。
|
||||
* <p>
|
||||
* 推导按「接单事实已清、尚未接单」的口径取值,故清接单事实放在状态写入之后,两者口径一致。
|
||||
*/
|
||||
private void writeWaybillsForDriver(LoadingManage loadingManage, List<Long> oldWaybillIds, List<Waybill> waybills) {
|
||||
syncAssociatedWaybills(loadingManage, deriveWaybillBusinessStatuses(waybills), oldWaybillIds);
|
||||
// 接单事实不清,被拒的票对新司机不可见——司机端一切入口都排除已拒绝的运单
|
||||
clearAssociatedWaybillAcceptRecords(loadingManage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 子运单业务状态逐张按各自项目的实时过程配置推导:需要接单 → 待执行,不需要 → 进行中。
|
||||
* <p>
|
||||
* 配置取法与运单读路径同一处:项目生效配置优先,取不到时才回退该运单的过程配置快照;
|
||||
* 快照只作兜底、永不优先,否则项目改配置后写入值与读时的归一化结果会分叉。
|
||||
* 项目为空且快照也为空 → 视为不需要接单 → 进行中。
|
||||
*/
|
||||
private Map<Long, String> deriveWaybillBusinessStatuses(List<Waybill> waybills) {
|
||||
Map<Long, String> businessStatusByWaybillId = new LinkedHashMap<>();
|
||||
if (Func.isEmpty(waybills)) {
|
||||
return businessStatusByWaybillId;
|
||||
}
|
||||
Map<Long, String> liveConfigByProject = LiveProcessConfigSupport.byProjects(processConfigService,
|
||||
waybills.stream().map(Waybill::getProjectId).toList());
|
||||
for (Waybill waybill : waybills) {
|
||||
String processJson = LiveProcessConfigSupport.resolve(
|
||||
liveConfigByProject.get(waybill.getProjectId()), waybill.getProcessJson());
|
||||
// 以「待执行 + 待接单」为输入走读路径同一个归一化:需要接单留下待执行,不需要接单提升为进行中
|
||||
businessStatusByWaybillId.put(waybill.getId(), WaybillProcessSupport.normalizeBusinessStatus(
|
||||
STATUS_PENDING, processJson, WaybillProcessSupport.ACCEPT_PENDING));
|
||||
}
|
||||
return businessStatusByWaybillId;
|
||||
}
|
||||
|
||||
private void syncAssociatedWaybills(LoadingManage loadingManage, String status, List<Long> oldWaybillIds) {
|
||||
Map<Long, String> statusByWaybillId = new LinkedHashMap<>();
|
||||
for (Long waybillId : waybillIds(loadingManage.getWaybillIdsJson())) {
|
||||
statusByWaybillId.put(waybillId, status);
|
||||
}
|
||||
syncAssociatedWaybills(loadingManage, statusByWaybillId, oldWaybillIds);
|
||||
}
|
||||
|
||||
private void syncAssociatedWaybills(LoadingManage loadingManage, Map<Long, String> statusByWaybillId, List<Long> oldWaybillIds) {
|
||||
List<Long> newWaybillIds = waybillIds(loadingManage.getWaybillIdsJson());
|
||||
if (Func.isNotEmpty(oldWaybillIds)) {
|
||||
List<Long> removedIds = new ArrayList<>(oldWaybillIds);
|
||||
@@ -829,8 +952,18 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
if (Func.isEmpty(newWaybillIds)) {
|
||||
return;
|
||||
}
|
||||
Map<String, List<Long>> waybillIdsByStatus = new LinkedHashMap<>();
|
||||
for (Long waybillId : newWaybillIds) {
|
||||
waybillIdsByStatus
|
||||
.computeIfAbsent(statusByWaybillId.getOrDefault(waybillId, STATUS_PENDING), key -> new ArrayList<>())
|
||||
.add(waybillId);
|
||||
}
|
||||
waybillIdsByStatus.forEach((status, ids) -> updateAssociatedWaybillFields(loadingManage, ids, status));
|
||||
}
|
||||
|
||||
private void updateAssociatedWaybillFields(LoadingManage loadingManage, List<Long> waybillIds, String status) {
|
||||
waybillMapper.update(null, Wrappers.<Waybill>lambdaUpdate()
|
||||
.in(Waybill::getId, newWaybillIds)
|
||||
.in(Waybill::getId, waybillIds)
|
||||
.set(Waybill::getLoadingNo, loadingManage.getLoadingNo())
|
||||
.set(Waybill::getBusinessStatus, status)
|
||||
.set(Waybill::getCarrierType, loadingManage.getCarrierType())
|
||||
|
||||
+649
-145
@@ -23,16 +23,19 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.utils.DateUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.cache.UserDisplayCache;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.LoadingManage;
|
||||
import org.springblade.transport.pojo.entity.RiskDisposal;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.AdminDriverOptionVO;
|
||||
@@ -44,12 +47,21 @@ 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.DriverWaybillCardVO;
|
||||
import org.springblade.transport.pojo.vo.TripQueryVO;
|
||||
import org.springblade.transport.pojo.vo.TripStationVO;
|
||||
import org.springblade.transport.pojo.vo.TripTabCountsVO;
|
||||
import org.springblade.transport.pojo.vo.TripVO;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IDriverWaybillService;
|
||||
import org.springblade.transport.service.IExceptionDisposalService;
|
||||
import org.springblade.transport.service.ILoadingManageService;
|
||||
import org.springblade.transport.service.IManageWaybillService;
|
||||
import org.springblade.transport.service.IRiskDisposalService;
|
||||
import org.springblade.transport.service.ITripQueryService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.TransportBusinessSupport;
|
||||
import org.springblade.transport.support.WaybillCargoSupport;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -58,16 +70,24 @@ import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 调度端:运单状态统计 + 异常/风险角标 + 待处理事项 + 运单列表
|
||||
* 调度端:运次状态统计 + 异常/风险角标 + 待处理事项 + 运次列表
|
||||
* <p>
|
||||
* 列表与详情与司机端同粒度:取数、状态聚合、接单需求合并都调共享运次读服务,
|
||||
* 本类只做字段裁剪与调度端独有的筛选(组织类型 / 异常 / 创建时间 / 待重新派单)。
|
||||
* <p>
|
||||
* 小程序调度账号(如「小程序管理」)组织常与运单业务组织不一致,故不做 dept 过滤,
|
||||
* 仅依赖租户隔离,口径接近后台 {@code /waybill-manage/list?allDept=1}。
|
||||
@@ -102,6 +122,11 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
private static final int DEFAULT_PAGE_SIZE = 10;
|
||||
private static final int MAX_PAGE_SIZE = 50;
|
||||
|
||||
/** 待重新派单的排序键:运次内子单被拒的时刻取最大值 */
|
||||
private static final String SQL_MAX_REJECT_TIME = "MAX(driver_reject_time)";
|
||||
/** 待重新派单的排序键:运次内子单创建时刻取最大值(与运次列表同一排序口径) */
|
||||
private static final String SQL_MAX_CREATE_TIME = "MAX(create_time)";
|
||||
|
||||
private static final DateTimeFormatter DATE_MD = DateTimeFormatter.ofPattern("MM-dd");
|
||||
private static final DateTimeFormatter DATE_YMD = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
@@ -110,13 +135,19 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
private final IExceptionDisposalService exceptionDisposalService;
|
||||
private final IRiskDisposalService riskDisposalService;
|
||||
private final IDriverWaybillService driverWaybillService;
|
||||
private final ITripQueryService tripQueryService;
|
||||
private final ILoadingManageService loadingManageService;
|
||||
|
||||
@Override
|
||||
public AdminHomeStatsVO stats() {
|
||||
AdminHomeStatsVO vo = new AdminHomeStatsVO();
|
||||
vo.setPendingAccept(countWaybillByStatus(STATUS_PENDING));
|
||||
vo.setTransporting(countWaybillByStatus(STATUS_RUNNING));
|
||||
vo.setCompleted(countWaybillByStatus(STATUS_COMPLETED));
|
||||
// 三档状态按运次计:有配载单的一趟车算一个,与列表和司机端同一份聚合
|
||||
TripQueryVO query = TripQueryVO.forAdmin();
|
||||
query.setDeptIds(manageableDeptIds());
|
||||
TripTabCountsVO tripCounts = tripQueryService.tabCounts(query);
|
||||
vo.setPendingAccept(tripCounts.getPending());
|
||||
vo.setTransporting(tripCounts.getDoing());
|
||||
vo.setCompleted(tripCounts.getDone());
|
||||
vo.setException(countIncompleteExceptions());
|
||||
return vo;
|
||||
}
|
||||
@@ -144,57 +175,46 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
|
||||
Set<Long> exceptionWaybillIds = loadIncompleteExceptionWaybillIds();
|
||||
if (EXCEPTION_YES.equals(exception) && exceptionWaybillIds.isEmpty()) {
|
||||
Page<AdminWaybillCardVO> emptyVo = new Page<>(pageNo, pageSize, 0);
|
||||
emptyVo.setRecords(List.of());
|
||||
return emptyVo;
|
||||
return emptyCardPage(pageNo, pageSize);
|
||||
}
|
||||
|
||||
LambdaQueryWrapper<Waybill> wrapper = scopedWaybillQuery();
|
||||
applyStatusFilter(wrapper, status);
|
||||
applyExceptionFilter(wrapper, exception, exceptionWaybillIds);
|
||||
applyTransportTypeFilter(wrapper, transportType);
|
||||
applyKeywordFilter(wrapper, keyword);
|
||||
applyCreateTimeFilter(wrapper, startDate, endDate);
|
||||
wrapper.orderByDesc(Waybill::getCreateTime);
|
||||
|
||||
IPage<Waybill> entityPage = waybillService.page(new Page<>(pageNo, pageSize), wrapper);
|
||||
List<Long> pageIds = entityPage.getRecords().stream()
|
||||
.map(Waybill::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
Set<Long> pageExceptionIds = pageIds.isEmpty()
|
||||
? Collections.emptySet()
|
||||
: exceptionWaybillIds.stream().filter(pageIds::contains).collect(Collectors.toSet());
|
||||
|
||||
Page<AdminWaybillCardVO> voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
|
||||
voPage.setRecords(entityPage.getRecords().stream()
|
||||
.map(w -> toCard(w, pageExceptionIds.contains(w.getId())))
|
||||
.toList());
|
||||
return voPage;
|
||||
// 组织类型 / 异常 / 创建时间都是子单行级条件,与状态、关键字、分页一起交给运次读服务,
|
||||
// 一次取数取完整趟车:分组、分档、筛选、排序与分页都在它那边算完
|
||||
TripQueryVO query = TripQueryVO.forAdmin();
|
||||
query.setDeptIds(manageableDeptIds());
|
||||
query.setStatus(toTripStatus(status));
|
||||
query.setKeyword(keyword);
|
||||
query.setTransportOrgType(transportType);
|
||||
query.setException(exception);
|
||||
query.setExceptionWaybillIds(exceptionWaybillIds);
|
||||
query.setCreatedFrom(parseDayStart(startDate));
|
||||
query.setCreatedTo(parseDayEnd(endDate));
|
||||
query.setCurrent(pageNo);
|
||||
query.setSize(pageSize);
|
||||
IPage<TripVO> trips = tripQueryService.page(query);
|
||||
return tripCardPage(trips.getRecords(), pageNo, pageSize, trips.getTotal(), exceptionWaybillIds);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AdminWaybillDetailVO detail(Long id) {
|
||||
public AdminWaybillDetailVO detail(String tripId) {
|
||||
Long id = parseId(tripId);
|
||||
if (id == null) {
|
||||
throw new org.springblade.core.log.exception.ServiceException("运单ID不能为空");
|
||||
throw new ServiceException("运次不存在或无权操作");
|
||||
}
|
||||
TripVO trip = visibleTrip(tripId);
|
||||
if (trip != null) {
|
||||
return tripDetail(trip);
|
||||
}
|
||||
// 已拒单的子运单按运次取不到(运次口径不含已拒子单),此时按运单主键回落,供重新派单等从运单进入的场景使用
|
||||
Waybill waybill = waybillService.getById(id);
|
||||
if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) {
|
||||
throw new org.springblade.core.log.exception.ServiceException("运单不存在");
|
||||
if (waybill != null && !Objects.equals(waybill.getIsDeleted(), 1)) {
|
||||
return waybillDetail(waybill);
|
||||
}
|
||||
boolean hasException = false;
|
||||
Long exceptionId = null;
|
||||
ExceptionDisposal latest = exceptionDisposalService.getOne(Wrappers.<ExceptionDisposal>lambdaQuery()
|
||||
.eq(ExceptionDisposal::getWaybillId, id)
|
||||
.in(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING, DISPOSAL_PROCESSING)
|
||||
.orderByDesc(ExceptionDisposal::getReportTime)
|
||||
.orderByDesc(ExceptionDisposal::getCreateTime)
|
||||
.last("LIMIT 1"));
|
||||
if (latest != null) {
|
||||
hasException = true;
|
||||
exceptionId = latest.getId();
|
||||
LoadingManage loading = loadingManageService.getById(id);
|
||||
if (loading == null) {
|
||||
throw new ServiceException("运次不存在或无权操作");
|
||||
}
|
||||
return toDetail(waybill, hasException, exceptionId);
|
||||
return loadingDetail(loading, visibleChildren(loading.getLoadingNo()));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -202,32 +222,521 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
int pageNo = current == null || current < 1 ? 1 : current;
|
||||
int pageSize = size == null || size < 1 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE);
|
||||
|
||||
LambdaQueryWrapper<Waybill> wrapper = scopedWaybillQuery()
|
||||
.in(Waybill::getBusinessStatus, STATUS_PENDING, STATUS_RUNNING);
|
||||
if (Boolean.TRUE.equals(needReassign)) {
|
||||
wrapper.eq(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED);
|
||||
} else if (Boolean.FALSE.equals(needReassign)) {
|
||||
wrapper.and(w -> w.isNull(Waybill::getDriverAcceptStatus)
|
||||
.or().ne(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED));
|
||||
// 待重新派单必须按「子单被拒」找:运次口径把已拒子单排除在外,整单拒绝后这趟车在运次里已经不存在
|
||||
return reassignPendingPage(pageNo, pageSize, keyword);
|
||||
}
|
||||
applyKeywordFilter(wrapper, keyword);
|
||||
wrapper.orderByDesc(Waybill::getUpdateTime).orderByDesc(Waybill::getCreateTime);
|
||||
|
||||
IPage<Waybill> entityPage = waybillService.page(new Page<>(pageNo, pageSize), wrapper);
|
||||
Set<Long> exceptionWaybillIds = loadIncompleteExceptionWaybillIds();
|
||||
List<Long> pageIds = entityPage.getRecords().stream()
|
||||
.map(Waybill::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
Set<Long> pageExceptionIds = pageIds.isEmpty()
|
||||
? Collections.emptySet()
|
||||
: exceptionWaybillIds.stream().filter(pageIds::contains).collect(Collectors.toSet());
|
||||
TripQueryVO query = TripQueryVO.forAdmin();
|
||||
query.setDeptIds(manageableDeptIds());
|
||||
query.setKeyword(keyword);
|
||||
// 待处理只要待接单与进行中两档;「未拒单」也是交给运次读服务按子单行筛
|
||||
query.setStatuses(List.of(0, 1));
|
||||
query.setExcludeRejectedTrips(Boolean.FALSE.equals(needReassign));
|
||||
query.setCurrent(pageNo);
|
||||
query.setSize(pageSize);
|
||||
IPage<TripVO> trips = tripQueryService.page(query);
|
||||
return tripCardPage(trips.getRecords(), pageNo, pageSize, trips.getTotal(), exceptionWaybillIds);
|
||||
}
|
||||
|
||||
Page<AdminWaybillCardVO> voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
|
||||
voPage.setRecords(entityPage.getRecords().stream()
|
||||
.map(w -> toCard(w, pageExceptionIds.contains(w.getId())))
|
||||
.toList());
|
||||
return voPage;
|
||||
private Page<AdminWaybillCardVO> emptyCardPage(int pageNo, int pageSize) {
|
||||
Page<AdminWaybillCardVO> page = new Page<>(pageNo, pageSize, 0);
|
||||
page.setRecords(List.of());
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次卡片页:一次算清这一页的重派标记与异常,再逐条拼卡片
|
||||
*/
|
||||
private Page<AdminWaybillCardVO> tripCardPage(List<TripVO> trips, int pageNo, int pageSize, long total,
|
||||
Set<Long> exceptionWaybillIds) {
|
||||
List<AdminWaybillCardVO> cards = new ArrayList<>(trips.size());
|
||||
if (!trips.isEmpty()) {
|
||||
Set<String> reassignKeys = loadReassignTripKeys(
|
||||
trips.stream().map(this::tripKeyOf).collect(Collectors.toCollection(LinkedHashSet::new)));
|
||||
for (TripVO trip : trips) {
|
||||
cards.add(toTripCard(trip, exceptionWaybillIds, reassignKeys));
|
||||
}
|
||||
}
|
||||
Page<AdminWaybillCardVO> page = new Page<>(pageNo, pageSize, total);
|
||||
page.setRecords(cards);
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次卡片:展示单号、整趟车起/经/终与站点数、货物汇总、计划时间都取运次字段。
|
||||
* 旧字段 id / waybillNo 保留运单语义(未配载就是这张运单,配载取第一张可见子运单),
|
||||
* 打卡订正、重新派单等按运单进入的入口仍靠它们。
|
||||
*/
|
||||
private AdminWaybillCardVO toTripCard(TripVO trip, Set<Long> exceptionWaybillIds,
|
||||
Set<String> reassignTripIds) {
|
||||
AdminWaybillCardVO card = new AdminWaybillCardVO();
|
||||
List<TripVO.TripWaybillVO> children = trip.getWaybills() == null ? List.of() : trip.getWaybills();
|
||||
Long firstChildId = children.isEmpty() ? null : parseId(children.get(0).getId());
|
||||
boolean loaded = Func.isNotEmpty(trip.getLoadingNo());
|
||||
|
||||
card.setTripId(trip.getId());
|
||||
card.setTripNo(Func.toStr(trip.getTripNo(), ""));
|
||||
card.setLoadingNo(Func.toStr(trip.getLoadingNo(), ""));
|
||||
card.setId(firstChildId == null ? parseId(trip.getId()) : firstChildId);
|
||||
card.setWaybillNo(children.isEmpty() ? card.getTripNo() : Func.toStr(children.get(0).getWaybillNo(), ""));
|
||||
card.setWaybillCount(trip.getWaybillCount());
|
||||
card.setTransportMode(Func.toStr(trip.getTransportType(), ""));
|
||||
card.setTransportType(loaded ? TRANSPORT_LOAD : TRANSPORT_COMMON);
|
||||
card.setFromName(Func.toStrWithEmpty(trip.getDepartureName(), Func.toStr(trip.getDepartureAddress(), "")));
|
||||
card.setToName(Func.toStrWithEmpty(trip.getArrivalName(), Func.toStr(trip.getArrivalAddress(), "")));
|
||||
card.setViaName(Func.toStr(trip.getTransitName(), ""));
|
||||
card.setStationCount(trip.getStationCount());
|
||||
card.setCargo(Func.toStr(trip.getCargoSummary(), ""));
|
||||
card.setCargoSummary(Func.toStr(trip.getCargoSummary(), ""));
|
||||
String weight = formatWeight(trip.getTotalQuantity(), trip.getQuantityUnit());
|
||||
card.setWeight(weight);
|
||||
card.setTotalWeight(weight);
|
||||
// 计划时间两条路同源:配载运次取配载单,未配载运次取那张运单自己的
|
||||
card.setPlanTime(formatLocalDate(trip.getEstimatedStartDate()));
|
||||
card.setPlanTimeEnd(formatLocalDate(trip.getEstimatedEndDate()));
|
||||
card.setStatus(trip.getStatus());
|
||||
card.setDriverName(Func.toStr(trip.getDriverName(), ""));
|
||||
card.setVehicleNo(Func.toStr(trip.getVehicleNo(), ""));
|
||||
card.setCreateTime(formatDateTime(trip.getSortTime()));
|
||||
card.setNeedReassign(reassignTripIds.contains(tripKeyOf(trip)));
|
||||
card.setHasException(hasChildException(children.stream().map(TripVO.TripWaybillVO::getId).toList(), exceptionWaybillIds));
|
||||
card.setBuyerPaid(false);
|
||||
return card;
|
||||
}
|
||||
|
||||
/** 这一页里带「子单被拒」标记的运次主键:判据与重派待办一致(已拒单 + 未取消未完成) */
|
||||
private Set<String> loadReassignTripKeys(Set<String> tripIds) {
|
||||
if (Func.isEmpty(tripIds)) {
|
||||
return Set.of();
|
||||
}
|
||||
// 被拒子单一次取回后在服务层换算运次键
|
||||
return waybillService.list(pendingReassignQuery().select(Waybill::getId, Waybill::getLoadingNo))
|
||||
.stream()
|
||||
.map(this::tripKeyOf)
|
||||
.filter(tripIds::contains)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private boolean hasRejectedChild(String tripId) {
|
||||
return Func.isNotEmpty(tripId) && !loadReassignTripKeys(Set.of(tripId)).isEmpty();
|
||||
}
|
||||
|
||||
/** 运次里有没有未完成的异常:异常仍是运单级的事实,落到运次上按「任一子单有」算 */
|
||||
private boolean hasChildException(Collection<String> childIds, Set<Long> exceptionWaybillIds) {
|
||||
if (exceptionWaybillIds.isEmpty() || childIds.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return childIds.stream().map(this::parseId).filter(Objects::nonNull).anyMatch(exceptionWaybillIds::contains);
|
||||
}
|
||||
|
||||
/** 小程序 status → 运次状态:0待接单 / 1进行中 / 2已完成 */
|
||||
private Integer toTripStatus(String status) {
|
||||
if (Func.isBlank(status)) {
|
||||
return null;
|
||||
}
|
||||
return switch (status.trim()) {
|
||||
case "0", STATUS_PENDING -> 0;
|
||||
case "1", STATUS_RUNNING, "transporting", "doing" -> 1;
|
||||
case "2", STATUS_COMPLETED, "done" -> 2;
|
||||
case "3", STATUS_CANCELLED -> 3;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private Date parseDayStart(String date) {
|
||||
if (Func.isBlank(date)) {
|
||||
return null;
|
||||
}
|
||||
return DateUtil.parse(date.trim() + " 00:00:00", DateUtil.PATTERN_DATETIME);
|
||||
}
|
||||
|
||||
private Date parseDayEnd(String date) {
|
||||
if (Func.isBlank(date)) {
|
||||
return null;
|
||||
}
|
||||
return DateUtil.parse(date.trim() + " 23:59:59", DateUtil.PATTERN_DATETIME);
|
||||
}
|
||||
|
||||
private Long parseId(String value) {
|
||||
if (Func.isEmpty(value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(value.trim());
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 运次的键:配载取配载单号,未配载取 W + 运单主键,与运次读服务同一个口径 */
|
||||
private String tripKeyOf(Waybill waybill) {
|
||||
return waybill == null ? null : TripAggregation.keyOf(waybill.getLoadingNo(), waybill.getId());
|
||||
}
|
||||
|
||||
/** 运次的键:同上,运次卡片取运次主键换算 */
|
||||
private String tripKeyOf(TripVO trip) {
|
||||
return trip == null ? null : TripAggregation.keyOf(trip.getLoadingNo(), parseId(trip.getId()));
|
||||
}
|
||||
|
||||
/* ===========================================================================
|
||||
* 详情:按运次展示;已拒单等取不到运次的场景回落成运单视图
|
||||
* =========================================================================*/
|
||||
|
||||
/** 按运次主键取可见运次;取不到(已拒子单、不是运次主键)返回空,交给运单级回落 */
|
||||
private TripVO visibleTrip(String tripId) {
|
||||
try {
|
||||
TripQueryVO query = TripQueryVO.forAdmin();
|
||||
query.setTripId(tripId);
|
||||
return tripQueryService.detail(query);
|
||||
} catch (ServiceException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次详情:与司机端接单页同一口径(单号取配载单号,站点 ①②③ 与每站货物条目都带运单号)。
|
||||
* 打卡节点取第一张可见子运单:管理端打卡订正仍按运单走,逐子单的打卡视图不在运次这一层。
|
||||
*/
|
||||
private AdminWaybillDetailVO tripDetail(TripVO trip) {
|
||||
List<TripVO.TripWaybillVO> children = trip.getWaybills() == null ? List.of() : trip.getWaybills();
|
||||
Long firstChildId = children.isEmpty() ? null : parseId(children.get(0).getId());
|
||||
List<TripStationVO> stations = trip.getStations() == null ? List.of() : trip.getStations();
|
||||
List<Long> scopeIds = new ArrayList<>();
|
||||
children.forEach(child -> {
|
||||
Long childId = parseId(child.getId());
|
||||
if (childId != null) {
|
||||
scopeIds.add(childId);
|
||||
}
|
||||
});
|
||||
if (scopeIds.isEmpty() && parseId(trip.getId()) != null) {
|
||||
scopeIds.add(parseId(trip.getId()));
|
||||
}
|
||||
|
||||
AdminWaybillDetailVO detail = new AdminWaybillDetailVO();
|
||||
detail.setId(firstChildId == null ? parseId(trip.getId()) : firstChildId);
|
||||
detail.setWaybillNo(children.isEmpty() ? Func.toStr(trip.getTripNo(), "") : Func.toStr(children.get(0).getWaybillNo(), ""));
|
||||
detail.setStatus(trip.getStatus());
|
||||
detail.setTransportMode(Func.toStr(trip.getTransportType(), ""));
|
||||
detail.setTransportType(toTransportTypeLabel(Func.toStr(trip.getTransportType(), "")));
|
||||
detail.setTransportOrgType(Func.isEmpty(trip.getLoadingNo()) ? TRANSPORT_COMMON : TRANSPORT_LOAD);
|
||||
detail.setTripId(trip.getId());
|
||||
detail.setLoadingNo(Func.toStr(trip.getLoadingNo(), ""));
|
||||
detail.setTripNo(Func.toStr(trip.getTripNo(), ""));
|
||||
detail.setViaName(Func.toStr(trip.getTransitName(), ""));
|
||||
detail.setStationCount(trip.getStationCount());
|
||||
detail.setCargoSummary(Func.toStr(trip.getCargoSummary(), ""));
|
||||
String weight = formatWeight(trip.getTotalQuantity(), trip.getQuantityUnit());
|
||||
detail.setCargoName(Func.toStr(trip.getCargoName(), ""));
|
||||
detail.setCargoNames(trip.getCargoNames() == null ? List.of() : trip.getCargoNames());
|
||||
detail.setCargoQuantity(weight);
|
||||
detail.setWeight(weight);
|
||||
detail.setTotalWeight(weight);
|
||||
String fromAddr = Func.toStr(trip.getDepartureAddress(), "");
|
||||
String toAddr = Func.toStr(trip.getArrivalAddress(), "");
|
||||
detail.setFromName(Func.toStrWithEmpty(trip.getDepartureName(), fromAddr));
|
||||
detail.setToName(Func.toStrWithEmpty(trip.getArrivalName(), toAddr));
|
||||
detail.setFromAddress(Func.toStrWithEmpty(fromAddr, detail.getFromName()));
|
||||
detail.setToAddress(Func.toStrWithEmpty(toAddr, detail.getToName()));
|
||||
detail.setPickupAddress(detail.getFromAddress());
|
||||
detail.setUnloadAddress(detail.getToAddress());
|
||||
detail.setPlanShipTime(formatLocalDate(trip.getEstimatedStartDate()));
|
||||
detail.setPlanFinishTime(formatLocalDate(trip.getEstimatedEndDate()));
|
||||
detail.setCarrierName("");
|
||||
detail.setDriverName(Func.toStr(trip.getDriverName(), ""));
|
||||
detail.setDriverPhone(Func.toStr(trip.getDriverPhone(), ""));
|
||||
detail.setVehicleNo(Func.toStr(trip.getVehicleNo(), ""));
|
||||
detail.setRemark(Func.toStr(trip.getTaskRemark(), ""));
|
||||
detail.setNeedReassign(hasRejectedChild(tripKeyOf(trip)));
|
||||
detail.setAcceptStatus(Func.toStr(trip.getAcceptStatus(), ""));
|
||||
detail.setRejectReason("");
|
||||
detail.setStations(stations);
|
||||
detail.setRoutePoints(toRoutePoints(stations));
|
||||
fillException(detail, scopeIds);
|
||||
fillPunchSnapshot(detail, firstChildId);
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运单主键回落:重新派单、异常处置等按运单进入的场景仍按运单展示;
|
||||
* 它属于配载单时补上整趟车的展示字段(配载单号、站点 ①②③、计划时间),与列表和司机端同一口径。
|
||||
*/
|
||||
private AdminWaybillDetailVO waybillDetail(Waybill waybill) {
|
||||
Set<Long> scopeIds = new LinkedHashSet<>();
|
||||
scopeIds.add(waybill.getId());
|
||||
AdminWaybillDetailVO detail = toDetail(waybill, false, null);
|
||||
if (Func.isEmpty(waybill.getLoadingNo())) {
|
||||
fillException(detail, scopeIds);
|
||||
return detail;
|
||||
}
|
||||
List<Waybill> children = visibleChildren(waybill.getLoadingNo());
|
||||
children.forEach(child -> scopeIds.add(child.getId()));
|
||||
LoadingManage loading = findLoading(waybill.getLoadingNo());
|
||||
if (loading != null) {
|
||||
// 整趟车的子单都被拒时组里为空,站点退回配载单自己的发货/途经/到货
|
||||
List<TripStationVO> stations = TripStationAssembler.assemble(loading, children);
|
||||
detail.setTripId(String.valueOf(loading.getId()));
|
||||
detail.setLoadingNo(loading.getLoadingNo());
|
||||
detail.setTripNo(loading.getLoadingNo());
|
||||
detail.setTransportOrgType(TRANSPORT_LOAD);
|
||||
detail.setViaName(TripStationAssembler.transitName(stations));
|
||||
detail.setStationCount(stations.size());
|
||||
detail.setStations(stations);
|
||||
detail.setPlanShipTime(formatLocalDate(loading.getEstimatedStartDate()));
|
||||
detail.setPlanFinishTime(formatLocalDate(loading.getEstimatedEndDate()));
|
||||
detail.setFromName(Func.toStrWithEmpty(TripStationAssembler.departureName(stations), detail.getFromName()));
|
||||
detail.setToName(Func.toStrWithEmpty(TripStationAssembler.arrivalName(stations), detail.getToName()));
|
||||
if (Func.isEmpty(detail.getDriverName())) {
|
||||
detail.setDriverName(Func.toStr(loading.getDriverName(), ""));
|
||||
}
|
||||
if (Func.isEmpty(detail.getVehicleNo())) {
|
||||
detail.setVehicleNo(Func.toStr(loading.getVehicleNo(), ""));
|
||||
}
|
||||
}
|
||||
fillException(detail, scopeIds);
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按配载单主键取整趟车:整单被拒时子单为空,站点与计划时间仍按配载单展示
|
||||
*/
|
||||
private AdminWaybillDetailVO loadingDetail(LoadingManage loading, List<Waybill> children) {
|
||||
List<TripStationVO> stations = TripStationAssembler.assemble(loading, children);
|
||||
List<Long> scopeIds = children.stream().map(Waybill::getId).filter(Objects::nonNull).toList();
|
||||
Waybill head = children.isEmpty() ? null : children.get(0);
|
||||
|
||||
AdminWaybillDetailVO detail = new AdminWaybillDetailVO();
|
||||
detail.setId(head == null ? loading.getId() : head.getId());
|
||||
detail.setWaybillNo(head == null ? loading.getLoadingNo() : Func.toStr(head.getWaybillNo(), loading.getLoadingNo()));
|
||||
detail.setStatus(TripAggregation.appStatus(tripBusinessStatus(children)));
|
||||
detail.setTransportMode(Func.toStr(loading.getTransportType(), ""));
|
||||
detail.setTransportType(toTransportTypeLabel(Func.toStr(loading.getTransportType(), "")));
|
||||
detail.setTransportOrgType(TRANSPORT_LOAD);
|
||||
detail.setTripId(String.valueOf(loading.getId()));
|
||||
detail.setLoadingNo(loading.getLoadingNo());
|
||||
detail.setTripNo(loading.getLoadingNo());
|
||||
detail.setViaName(TripStationAssembler.transitName(stations));
|
||||
detail.setStationCount(stations.size());
|
||||
detail.setCargoName(Func.toStrWithEmpty(loading.getCargoName(), head == null ? "" : Func.toStr(head.getCargoName(), "")));
|
||||
String weight = head == null ? "" : formatWeight(head.getQuantity(), head.getQuantityUnit());
|
||||
detail.setCargoQuantity(weight);
|
||||
detail.setWeight(weight);
|
||||
detail.setTotalWeight(weight);
|
||||
detail.setFromName(TripStationAssembler.departureName(stations));
|
||||
detail.setToName(TripStationAssembler.arrivalName(stations));
|
||||
detail.setFromAddress(Func.toStr(loading.getDepartureAddress(), ""));
|
||||
detail.setToAddress(Func.toStr(loading.getArrivalAddress(), ""));
|
||||
detail.setPickupAddress(detail.getFromAddress());
|
||||
detail.setUnloadAddress(detail.getToAddress());
|
||||
detail.setPlanShipTime(formatLocalDate(loading.getEstimatedStartDate()));
|
||||
detail.setPlanFinishTime(formatLocalDate(loading.getEstimatedEndDate()));
|
||||
detail.setCarrierName("");
|
||||
detail.setDriverName(Func.toStr(loading.getDriverName(), ""));
|
||||
detail.setDriverPhone(Func.toStr(loading.getDriverPhone(), ""));
|
||||
detail.setVehicleNo(Func.toStr(loading.getVehicleNo(), ""));
|
||||
detail.setRemark(Func.toStr(loading.getTaskRemark(), ""));
|
||||
detail.setNeedReassign(true);
|
||||
detail.setAcceptStatus(Func.toStr(TripAggregation.acceptStatus(children), ""));
|
||||
detail.setRejectReason("");
|
||||
detail.setStations(stations);
|
||||
detail.setRoutePoints(toRoutePoints(stations));
|
||||
fillException(detail, scopeIds);
|
||||
fillPunchSnapshot(detail, head == null ? null : head.getId());
|
||||
return detail;
|
||||
}
|
||||
|
||||
/** 一组子运单的业务状态:全部终态取已完成/已取消,否则有待执行取待执行、其余进行中 */
|
||||
private String tripBusinessStatus(List<Waybill> children) {
|
||||
if (children.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
boolean requireAccept = TripAggregation.requiresAccept(children);
|
||||
boolean accepted = WaybillProcessSupport.ACCEPT_ACCEPTED.equals(TripAggregation.acceptStatus(children));
|
||||
return TripAggregation.statusOf(children, requireAccept, accepted);
|
||||
}
|
||||
|
||||
private List<Waybill> visibleChildren(String loadingNo) {
|
||||
if (Func.isEmpty(loadingNo)) {
|
||||
return List.of();
|
||||
}
|
||||
return waybillService.list(Wrappers.<Waybill>lambdaQuery()
|
||||
.eq(Waybill::getLoadingNo, loadingNo)
|
||||
.in(Waybill::getBusinessStatus, TripAggregation.CHILD_STATUSES)
|
||||
.and(visible -> visible.isNull(Waybill::getDriverAcceptStatus)
|
||||
.or().ne(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED))
|
||||
.orderByAsc(Waybill::getCreateTime));
|
||||
}
|
||||
|
||||
private LoadingManage findLoading(String loadingNo) {
|
||||
if (Func.isEmpty(loadingNo)) {
|
||||
return null;
|
||||
}
|
||||
return loadingManageService.getOne(Wrappers.<LoadingManage>lambdaQuery()
|
||||
.eq(LoadingManage::getLoadingNo, loadingNo)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
/** 未完成的异常处置:整趟车取最新一条,与旧单票详情同口径 */
|
||||
private void fillException(AdminWaybillDetailVO detail, Collection<Long> waybillIds) {
|
||||
ExceptionDisposal latest = latestException(waybillIds);
|
||||
detail.setHasException(latest != null);
|
||||
detail.setExceptionId(latest == null ? null : latest.getId());
|
||||
}
|
||||
|
||||
private ExceptionDisposal latestException(Collection<Long> waybillIds) {
|
||||
if (Func.isEmpty(waybillIds)) {
|
||||
return null;
|
||||
}
|
||||
return exceptionDisposalService.getOne(Wrappers.<ExceptionDisposal>lambdaQuery()
|
||||
.in(ExceptionDisposal::getWaybillId, waybillIds)
|
||||
.in(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING, DISPOSAL_PROCESSING)
|
||||
.orderByDesc(ExceptionDisposal::getReportTime)
|
||||
.orderByDesc(ExceptionDisposal::getCreateTime)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
/** 打卡节点与途打卡记录复用司机端组装;管理端打卡订正按运单走,故传第一张可见子运单 */
|
||||
private void fillPunchSnapshot(AdminWaybillDetailVO detail, Long waybillId) {
|
||||
if (waybillId == null) {
|
||||
return;
|
||||
}
|
||||
DriverWaybillCardVO punch = driverWaybillService.detailPunchSnapshot(waybillId);
|
||||
if (punch == null) {
|
||||
return;
|
||||
}
|
||||
detail.setPunchNodes(punch.getPunchNodes());
|
||||
detail.setEnrouteRecords(punch.getEnrouteRecords());
|
||||
if (punch.getRoutePoints() != null && !punch.getRoutePoints().isEmpty()) {
|
||||
detail.setRoutePoints(punch.getRoutePoints().stream().map(p -> {
|
||||
AdminWaybillDetailVO.AdminRoutePointVO rp = new AdminWaybillDetailVO.AdminRoutePointVO();
|
||||
rp.setName(p.getName());
|
||||
rp.setAddress(p.getAddress());
|
||||
rp.setStatus(p.getStatus());
|
||||
return rp;
|
||||
}).toList());
|
||||
}
|
||||
}
|
||||
|
||||
/** 点位列:整趟车的全部站点,站序即途经顺序 */
|
||||
private List<AdminWaybillDetailVO.AdminRoutePointVO> toRoutePoints(List<TripStationVO> stations) {
|
||||
if (Func.isEmpty(stations)) {
|
||||
return List.of();
|
||||
}
|
||||
return stations.stream().map(station -> {
|
||||
AdminWaybillDetailVO.AdminRoutePointVO point = new AdminWaybillDetailVO.AdminRoutePointVO();
|
||||
point.setName(Func.toStr(station.getName(), ""));
|
||||
point.setAddress(Func.toStrWithEmpty(station.getAddress(), point.getName()));
|
||||
point.setStatus("pending");
|
||||
return point;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 待重新派单列表:按库层分组把「同一配载单被拒的多张子单」收成一条,未配载运单各自一条。
|
||||
* 分页与总数都在库层按运次完成,避免整单拒绝后同一趟车重复出现。
|
||||
*/
|
||||
private IPage<AdminWaybillCardVO> reassignPendingPage(int pageNo, int pageSize, String keyword) {
|
||||
QueryWrapper<Waybill> wrapper = reassignGroupQuery();
|
||||
applyGroupKeyword(wrapper, keyword);
|
||||
wrapper.orderByDesc(SQL_MAX_REJECT_TIME);
|
||||
|
||||
Page<Waybill> rows = new Page<>(pageNo, pageSize);
|
||||
waybillService.page(rows, wrapper);
|
||||
|
||||
Set<Long> exceptionWaybillIds = loadIncompleteExceptionWaybillIds();
|
||||
Page<AdminWaybillCardVO> page = new Page<>(pageNo, pageSize, rows.getTotal());
|
||||
page.setRecords(toReassignCards(rows.getRecords(), exceptionWaybillIds));
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* 库层按运次分组的「待重新派单」查询:一条即一个运次,段二不用再回查子单。
|
||||
* 展示用字段一律包聚合函数取组内代表值,既满足 ONLY_FULL_GROUP_BY,也让一条待办自带它要显示的信息。
|
||||
*/
|
||||
private QueryWrapper<Waybill> reassignGroupQuery() {
|
||||
// 分组键只在 TripAggregation 声明一次:库层分组与服务层分组的「运次」必须是同一个东西
|
||||
QueryWrapper<Waybill> wrapper = Wrappers.query();
|
||||
wrapper.select("MIN(id) AS id", "MIN(NULLIF(loading_no, '')) AS loading_no", "MIN(waybill_no) AS waybill_no",
|
||||
"MIN(transport_type) AS transport_type", "MIN(business_status) AS business_status",
|
||||
"MIN(departure_name) AS departure_name", "MIN(arrival_name) AS arrival_name",
|
||||
"MIN(cargo_name) AS cargo_name", "MIN(quantity) AS quantity", "MIN(quantity_unit) AS quantity_unit",
|
||||
"MIN(estimated_start_time) AS estimated_start_time", "MIN(estimated_end_time) AS estimated_end_time",
|
||||
"MIN(driver_name) AS driver_name", "MIN(vehicle_no) AS vehicle_no",
|
||||
"MIN(driver_reject_reason) AS driver_reject_reason",
|
||||
SQL_MAX_REJECT_TIME + " AS driver_reject_time", SQL_MAX_CREATE_TIME + " AS create_time");
|
||||
wrapper.eq("driver_accept_status", ACCEPT_REJECTED);
|
||||
wrapper.in("business_status", STATUS_PENDING, STATUS_RUNNING);
|
||||
// 这条查询不经运次读服务,组织收口得自己带上,否则待重派列表与首页重派角标会出别的组织的单
|
||||
List<Long> deptIds = manageableDeptIds();
|
||||
if (Func.isNotEmpty(deptIds)) {
|
||||
wrapper.in("dept_id", deptIds);
|
||||
}
|
||||
wrapper.groupBy(TripAggregation.SQL_TRIP_KEY);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/** 一条待办 = 一个运次:整趟车取配载单的单号与站点,未配载就是这张运单本身 */
|
||||
private List<AdminWaybillCardVO> toReassignCards(List<Waybill> rows, Set<Long> exceptionWaybillIds) {
|
||||
if (Func.isEmpty(rows)) {
|
||||
return List.of();
|
||||
}
|
||||
Set<String> loadingNos = rows.stream().map(Waybill::getLoadingNo).filter(Func::isNotEmpty)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
Map<String, LoadingManage> loadings = loadingsByNo(loadingNos);
|
||||
|
||||
List<AdminWaybillCardVO> cards = new ArrayList<>(rows.size());
|
||||
for (Waybill row : rows) {
|
||||
String loadingNo = Func.toStr(row.getLoadingNo(), "");
|
||||
AdminWaybillCardVO card = toCard(row, hasExceptionIds(row, exceptionWaybillIds));
|
||||
card.setTripId(String.valueOf(Func.isEmpty(loadingNo) ? row.getId() : loadingId(loadings.get(loadingNo), row.getId())));
|
||||
card.setTripNo(Func.isEmpty(loadingNo) ? Func.toStr(row.getWaybillNo(), "") : loadingNo);
|
||||
card.setLoadingNo(loadingNo);
|
||||
card.setWaybillCount(1);
|
||||
card.setCargoSummary(Func.toStr(row.getCargoName(), ""));
|
||||
card.setTotalWeight(formatWeight(row.getQuantity(), row.getQuantityUnit()));
|
||||
card.setNeedReassign(true);
|
||||
if (Func.isNotEmpty(loadingNo)) {
|
||||
List<TripStationVO> stations = TripStationAssembler.assemble(loadings.get(loadingNo), List.of());
|
||||
card.setTransportType(TRANSPORT_LOAD);
|
||||
card.setFromName(TripStationAssembler.departureName(stations));
|
||||
card.setToName(TripStationAssembler.arrivalName(stations));
|
||||
card.setViaName(TripStationAssembler.transitName(stations));
|
||||
card.setStationCount(stations.size());
|
||||
} else {
|
||||
card.setTransportType(TRANSPORT_COMMON);
|
||||
card.setStationCount(2);
|
||||
}
|
||||
card.setCreateTime(formatDateTime(row.getCreateTime()));
|
||||
cards.add(card);
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
|
||||
private Map<String, LoadingManage> loadingsByNo(Set<String> loadingNos) {
|
||||
if (loadingNos.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
return loadingManageService.list(Wrappers.<LoadingManage>lambdaQuery().in(LoadingManage::getLoadingNo, loadingNos))
|
||||
.stream()
|
||||
.collect(Collectors.toMap(LoadingManage::getLoadingNo, Function.identity(), (first, ignored) -> first));
|
||||
}
|
||||
|
||||
private Long loadingId(LoadingManage loading, Long fallback) {
|
||||
return loading == null || loading.getId() == null ? fallback : loading.getId();
|
||||
}
|
||||
|
||||
/** 分组查询的关键字:运单号 / 司机 / 车牌,与列表关键字口径一致 */
|
||||
private void applyGroupKeyword(QueryWrapper<Waybill> wrapper, String keyword) {
|
||||
if (Func.isBlank(keyword)) {
|
||||
return;
|
||||
}
|
||||
String key = keyword.trim();
|
||||
wrapper.and(w -> w.like("waybill_no", key).or().like("driver_name", key).or().like("vehicle_no", key));
|
||||
}
|
||||
|
||||
private boolean hasExceptionIds(Waybill waybill, Set<Long> exceptionWaybillIds) {
|
||||
return waybill != null && waybill.getId() != null && exceptionWaybillIds.contains(waybill.getId());
|
||||
}
|
||||
|
||||
private AdminWaybillDetailVO toDetail(Waybill waybill, boolean hasException, Long exceptionId) {
|
||||
@@ -250,9 +759,12 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
detail.setPickupAddress(fromAddr);
|
||||
detail.setUnloadAddress(toAddr);
|
||||
|
||||
String cargo = Func.toStr(waybill.getCargoName(), "");
|
||||
String weight = formatWeight(waybill.getQuantity(), waybill.getQuantityUnit());
|
||||
detail.setCargoName(cargo);
|
||||
// 货名与合计重量取运单上录入的全部货物:主表 cargo_name 只是首行、quantity 是各行合计而单位只是首行的
|
||||
List<WaybillCargoSupport.Cargo> cargos = WaybillCargoSupport.of(waybill);
|
||||
List<String> cargoNames = WaybillCargoSupport.cargoNames(cargos);
|
||||
detail.setCargoName(String.join("、", cargoNames));
|
||||
detail.setCargoNames(cargoNames);
|
||||
String weight = cargoWeight(cargos, formatWeight(waybill.getQuantity(), waybill.getQuantityUnit()));
|
||||
detail.setCargoQuantity(weight);
|
||||
detail.setWeight(weight);
|
||||
detail.setTotalWeight(weight);
|
||||
@@ -371,82 +883,33 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
};
|
||||
}
|
||||
|
||||
private long countWaybillByStatus(String status) {
|
||||
return waybillService.count(Wrappers.<Waybill>lambdaQuery()
|
||||
.eq(Waybill::getBusinessStatus, status));
|
||||
/**
|
||||
* 调度端可见组织:普通用户(含各级角色)只出自己所属组织,超级管理员不过滤。
|
||||
* <p>
|
||||
* 小程序不校验「所属组织不能为空」:账号没有组织时不按组织收口,避免首页、待处理等接口直接报错。
|
||||
* 详情与重新派单本身不走这里。
|
||||
*/
|
||||
private List<Long> manageableDeptIds() {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序调度端不做组织过滤。
|
||||
* 「小程序管理」等账号 JWT/档案 dept 常与运单业务组织不一致,按 dept 过滤会导致统计全 0;
|
||||
* 与后台 allDept=1 一致,仅依赖租户隔离(MyBatis-Plus TenantLine)。
|
||||
* 已拒子单的查询底:只有租户与逻辑删除(MyBatis-Plus 自动补),不带组织。
|
||||
* <p>
|
||||
* 它服务的是「这个运次里有没有被拒的子单」,可见范围由调用方先定好——列表传的运次已经收过组织,
|
||||
* 详情按主键进入不收组织。这里再按组织收一次,详情里别的组织的运次会丢掉重派标记。
|
||||
*/
|
||||
private LambdaQueryWrapper<Waybill> scopedWaybillQuery() {
|
||||
private LambdaQueryWrapper<Waybill> rejectedChildQuery() {
|
||||
return Wrappers.<Waybill>lambdaQuery();
|
||||
}
|
||||
|
||||
private void applyStatusFilter(LambdaQueryWrapper<Waybill> wrapper, String status) {
|
||||
String businessStatus = toBusinessStatus(status);
|
||||
if (Func.isNotBlank(businessStatus)) {
|
||||
wrapper.eq(Waybill::getBusinessStatus, businessStatus);
|
||||
}
|
||||
}
|
||||
|
||||
private void applyExceptionFilter(LambdaQueryWrapper<Waybill> wrapper, String exception, Set<Long> exceptionWaybillIds) {
|
||||
if (EXCEPTION_YES.equals(exception)) {
|
||||
wrapper.in(Waybill::getId, exceptionWaybillIds);
|
||||
} else if (EXCEPTION_NO.equals(exception) && !exceptionWaybillIds.isEmpty()) {
|
||||
wrapper.notIn(Waybill::getId, exceptionWaybillIds);
|
||||
}
|
||||
}
|
||||
|
||||
private void applyTransportTypeFilter(LambdaQueryWrapper<Waybill> wrapper, String transportType) {
|
||||
if (TRANSPORT_LOAD.equals(transportType)) {
|
||||
wrapper.isNotNull(Waybill::getLoadingNo).ne(Waybill::getLoadingNo, "");
|
||||
} else if (TRANSPORT_COMMON.equals(transportType)) {
|
||||
wrapper.and(w -> w.isNull(Waybill::getLoadingNo).or().eq(Waybill::getLoadingNo, ""));
|
||||
}
|
||||
}
|
||||
|
||||
private void applyKeywordFilter(LambdaQueryWrapper<Waybill> wrapper, String keyword) {
|
||||
if (Func.isBlank(keyword)) {
|
||||
return;
|
||||
}
|
||||
String key = keyword.trim();
|
||||
wrapper.and(w -> w.like(Waybill::getWaybillNo, key)
|
||||
.or().like(Waybill::getDriverName, key)
|
||||
.or().like(Waybill::getVehicleNo, key));
|
||||
}
|
||||
|
||||
private void applyCreateTimeFilter(LambdaQueryWrapper<Waybill> wrapper, String startDate, String endDate) {
|
||||
if (Func.isNotBlank(startDate)) {
|
||||
Date start = DateUtil.parse(startDate.trim() + " 00:00:00", DateUtil.PATTERN_DATETIME);
|
||||
if (start != null) {
|
||||
wrapper.ge(Waybill::getCreateTime, start);
|
||||
}
|
||||
}
|
||||
if (Func.isNotBlank(endDate)) {
|
||||
Date end = DateUtil.parse(endDate.trim() + " 23:59:59", DateUtil.PATTERN_DATETIME);
|
||||
if (end != null) {
|
||||
wrapper.le(Waybill::getCreateTime, end);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 小程序 status → 后端 businessStatus */
|
||||
private String toBusinessStatus(String status) {
|
||||
if (Func.isBlank(status)) {
|
||||
return null;
|
||||
}
|
||||
return switch (status.trim()) {
|
||||
case "0", STATUS_PENDING -> STATUS_PENDING;
|
||||
case "1", STATUS_RUNNING, "transporting", "doing" -> STATUS_RUNNING;
|
||||
case "2", STATUS_COMPLETED, "done" -> STATUS_COMPLETED;
|
||||
case "3", STATUS_CANCELLED -> STATUS_CANCELLED;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private Set<Long> loadIncompleteExceptionWaybillIds() {
|
||||
List<ExceptionDisposal> list = exceptionDisposalService.list(Wrappers.<ExceptionDisposal>lambdaQuery()
|
||||
.select(ExceptionDisposal::getWaybillId)
|
||||
@@ -546,6 +1009,33 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
return Func.isBlank(unit) ? qty : qty + unit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重量文案:全部货物同单位时按货物行相加,单位不一致时不出合计
|
||||
* <p>
|
||||
* 主表 quantity 是各行数量的合计、quantity_unit 只是首行的单位,「20堆 + 50方」会变成「70堆」。
|
||||
* 拿明细算不出合计(有货物没填数量或单位)时退回主表那份汇总值,不让重量凭空消失。
|
||||
*/
|
||||
private String cargoWeight(List<WaybillCargoSupport.Cargo> cargos, String fallback) {
|
||||
if (cargos.isEmpty()) {
|
||||
return fallback;
|
||||
}
|
||||
String unit = null;
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (WaybillCargoSupport.Cargo cargo : cargos) {
|
||||
String cargoUnit = Func.toStr(cargo.quantityUnit(), "");
|
||||
if (cargo.quantity() == null || Func.isEmpty(cargoUnit)) {
|
||||
return fallback;
|
||||
}
|
||||
if (unit == null) {
|
||||
unit = cargoUnit;
|
||||
} else if (!unit.equals(cargoUnit)) {
|
||||
return "";
|
||||
}
|
||||
total = total.add(cargo.quantity());
|
||||
}
|
||||
return formatWeight(total, unit);
|
||||
}
|
||||
|
||||
private String formatLocalDate(LocalDate date) {
|
||||
if (date == null) {
|
||||
return "";
|
||||
@@ -570,13 +1060,20 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
.eq(RiskDisposal::getDisposalStatus, RISK_PENDING));
|
||||
}
|
||||
|
||||
/** 待重新派单:司机已拒单,且运单尚未取消/完成 */
|
||||
/**
|
||||
* 待重新派单的运次数:整单拒绝会把一张配载单的多张子运单一起写成已拒绝,按运次键去重后只算一个。
|
||||
* <p>
|
||||
* 这里不走运次读服务:它的可见口径已排除被拒子单,整单拒绝的配载单在运次里根本不存在;
|
||||
* 分组键用与运次读服务同一个表达式,两处的运次才是同一个东西。
|
||||
*/
|
||||
private long countPendingReassignments() {
|
||||
return waybillService.count(pendingReassignQuery());
|
||||
Page<Waybill> rows = new Page<>(1, 1);
|
||||
waybillService.page(rows, reassignGroupQuery());
|
||||
return rows.getTotal();
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<Waybill> pendingReassignQuery() {
|
||||
return Wrappers.<Waybill>lambdaQuery()
|
||||
return rejectedChildQuery()
|
||||
.eq(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED)
|
||||
.in(Waybill::getBusinessStatus, STATUS_PENDING, STATUS_RUNNING);
|
||||
}
|
||||
@@ -611,11 +1108,17 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 待重新派单的待办:一条 = 一个运次。
|
||||
* <p>
|
||||
* 整单拒绝会把一张配载单的多张子运单一起写成已拒绝,按运次键分组后同一趟车只出 1 条;
|
||||
* 未配载运单自成运次,各自一条。排序与取值都在库层按运次完成,与待处理列表、角标同一口径。
|
||||
*/
|
||||
private List<TimedTodo> buildReassignFeed() {
|
||||
List<Waybill> list = waybillService.list(pendingReassignQuery()
|
||||
.orderByDesc(Waybill::getDriverRejectTime)
|
||||
List<Waybill> rows = waybillService.list(reassignGroupQuery()
|
||||
.orderByDesc(SQL_MAX_REJECT_TIME)
|
||||
.last("LIMIT " + FEED_LIMIT));
|
||||
return list.stream()
|
||||
return rows.stream()
|
||||
.map(waybill -> new TimedTodo(toReassignTodoItem(waybill), rejectTimeOf(waybill)))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
@@ -649,7 +1152,8 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
item.setTitle("重新派单待处理");
|
||||
item.setTimeAgo(formatTimeAgo(rejectTimeOf(waybill)));
|
||||
item.setDesc(buildReassignDesc(waybill));
|
||||
item.setWaybillNo(Func.toStr(waybill.getWaybillNo(), ""));
|
||||
// 一条待办即一个运次:有配载单的展示配载单号;跳转参数仍是运单主键,该页据此回显并直接重派
|
||||
item.setWaybillNo(Func.toStrWithEmpty(waybill.getLoadingNo(), Func.toStr(waybill.getWaybillNo(), "")));
|
||||
item.setActionLabel("重新派单");
|
||||
item.setTargetUrl(String.format(REASSIGN_TARGET_URL, waybill.getId()));
|
||||
return item;
|
||||
|
||||
+230
-51
@@ -321,14 +321,15 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
BigDecimal writeOffAmount = preSettlementReference
|
||||
? projectAdvanceWriteOffService.sumBySettlement(TransportBusiConstant.SETTLEMENT_BILL_PRE, referenceId)
|
||||
: projectAdvanceWriteOffService.sumBySettlement(TransportBusiConstant.SETTLEMENT_BILL_FORMAL, referenceId);
|
||||
// 预结算转正式时,来源预结算的已付预付款已计入正式结算单已付,尾款可付金额需同步扣减
|
||||
BigDecimal advancePaidAmount = preSettlementReference ? BigDecimal.ZERO
|
||||
: this.formalSettlementAdvancePaid(referenceId);
|
||||
// 正式结算尾款还需扣减来源预结算进度预付(与正式结算剩余应付口径一致)
|
||||
BigDecimal advanceOccupied = preSettlementReference
|
||||
? BigDecimal.ZERO
|
||||
: formalSettlementAdvanceOccupied(referenceId);
|
||||
BigDecimal occupiedTotal = cumulativeAppliedAmount.add(writeOffAmount).add(advanceOccupied);
|
||||
PaymentApplicationReferenceAmountVO amount = new PaymentApplicationReferenceAmountVO();
|
||||
amount.setSettlementAmount(settlementAmount);
|
||||
amount.setCumulativeAppliedAmount(cumulativeAppliedAmount.add(writeOffAmount).add(advancePaidAmount));
|
||||
amount.setPayableAmount(settlementAmount.subtract(cumulativeAppliedAmount).subtract(writeOffAmount)
|
||||
.subtract(advancePaidAmount).max(BigDecimal.ZERO));
|
||||
amount.setCumulativeAppliedAmount(occupiedTotal);
|
||||
amount.setPayableAmount(settlementAmount.subtract(occupiedTotal).max(BigDecimal.ZERO));
|
||||
amount.setPaymentSequence(resolvePaymentSequence(preSettlementReference, referenceId, excludeId));
|
||||
return amount;
|
||||
}
|
||||
@@ -373,8 +374,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
matchedInvoiceAmount = matchedInvoiceAmount.add(money(item.getMatchedAmount()));
|
||||
}
|
||||
entity.setMatchedInvoiceAmount(matchedInvoiceAmount);
|
||||
// 进度预付只关联预结算单。编辑回传的 settlementId 可能是预结算单 ID,不能拿去查正式结算单。
|
||||
normalizeSettlementReference(request);
|
||||
// 进度预付只关联预结算单。编辑回传的 settlementId 可能是预结算单 ID 或已失效的正式结算单 ID。
|
||||
normalizeSettlementReference(request, entity);
|
||||
if (TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT.equals(request.getPaymentType()) && request.getSettlementId() == null
|
||||
&& request.getSettlementIds() != null && !request.getSettlementIds().isEmpty()) {
|
||||
request.setSettlementId(request.getSettlementIds().get(0));
|
||||
@@ -1894,62 +1895,162 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
}
|
||||
|
||||
/**
|
||||
* 进度预付只认预结算单。尾款付款若同时带回一个并不存在的正式结算单 ID,改为按预结算单保存。
|
||||
* 进度预付只认预结算单;尾款付款若 settlementId 实际是预结算单 ID 或正式结算单已不存在,改为按预结算单保存。
|
||||
*/
|
||||
private void normalizeSettlementReference(PaymentApplicationSaveRequest request) {
|
||||
if ("progress_advance".equals(request.getPaymentType())) {
|
||||
if (request.getPreSettlementId() == null && request.getPreSettlementIds() != null) {
|
||||
request.setPreSettlementId(request.getPreSettlementIds().stream().filter(Objects::nonNull).findFirst().orElse(null));
|
||||
private void normalizeSettlementReference(PaymentApplicationSaveRequest request, PaymentApplication entity) {
|
||||
if ("progress_advance".equals(request.getPaymentType())
|
||||
|| (entity != null && "progress_advance".equals(entity.getPaymentType())
|
||||
&& Func.isEmpty(request.getPaymentType()))) {
|
||||
if (Func.isEmpty(request.getPaymentType())) {
|
||||
request.setPaymentType("progress_advance");
|
||||
}
|
||||
if (request.getPreSettlementId() == null) {
|
||||
request.setPreSettlementId(request.getSettlementId());
|
||||
Long preSettlementId = resolveProgressPreSettlementId(request, entity);
|
||||
request.setPreSettlementId(preSettlementId);
|
||||
if (preSettlementId == null) {
|
||||
request.setPreSettlementIds(null);
|
||||
} else if (request.getPreSettlementIds() == null || request.getPreSettlementIds().stream().noneMatch(Objects::nonNull)) {
|
||||
request.setPreSettlementIds(List.of(preSettlementId));
|
||||
}
|
||||
request.setSettlementId(null);
|
||||
request.setSettlementIds(null);
|
||||
return;
|
||||
}
|
||||
if (!TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT.equals(request.getPaymentType()) || request.getSettlementId() == null) {
|
||||
if (!TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT.equals(request.getPaymentType())) {
|
||||
return;
|
||||
}
|
||||
boolean hasPreSettlement = request.getPreSettlementId() != null
|
||||
|| (request.getPreSettlementIds() != null && request.getPreSettlementIds().stream().anyMatch(Objects::nonNull));
|
||||
if (!hasPreSettlement) {
|
||||
// settlementIds 中若含无效正式结算单 ID,先尝试纠正为预结算
|
||||
if (request.getSettlementId() == null && request.getSettlementIds() != null
|
||||
&& !request.getSettlementIds().isEmpty()) {
|
||||
request.setSettlementId(request.getSettlementIds().stream().filter(Objects::nonNull).findFirst().orElse(null));
|
||||
}
|
||||
if (request.getSettlementId() == null) {
|
||||
return;
|
||||
}
|
||||
FormalSettlement formalSettlement = formalSettlementMapper.selectById(request.getSettlementId());
|
||||
if (formalSettlement == null || Objects.equals(formalSettlement.getIsDeleted(), 1)) {
|
||||
if (formalSettlement != null && !Objects.equals(formalSettlement.getIsDeleted(), 1)) {
|
||||
return;
|
||||
}
|
||||
// 正式结算单不存在:若该 ID 实为预结算单,或请求已带预结算引用,则清空正式结算引用
|
||||
PreSettlement preSettlement = preSettlementMapper.selectById(request.getSettlementId());
|
||||
boolean isPreSettlement = preSettlement != null && !Objects.equals(preSettlement.getIsDeleted(), 1);
|
||||
boolean hasPreSettlement = request.getPreSettlementId() != null
|
||||
|| (request.getPreSettlementIds() != null && request.getPreSettlementIds().stream().anyMatch(Objects::nonNull));
|
||||
if (isPreSettlement && request.getPreSettlementId() == null) {
|
||||
request.setPreSettlementId(request.getSettlementId());
|
||||
if (request.getPreSettlementIds() == null || request.getPreSettlementIds().isEmpty()) {
|
||||
request.setPreSettlementIds(List.of(request.getSettlementId()));
|
||||
}
|
||||
}
|
||||
if (isPreSettlement || hasPreSettlement) {
|
||||
request.setSettlementId(null);
|
||||
request.setSettlementIds(null);
|
||||
}
|
||||
}
|
||||
|
||||
/** 预结算付款不挂正式结算单时,清掉历史误写入的正式结算单 ID,避免再次提交按正式结算单刷新。 */
|
||||
/**
|
||||
* 预结算付款不挂正式结算单时,清掉历史误写入的正式结算单 ID,避免再次提交按正式结算单刷新。
|
||||
*/
|
||||
private void clearDetachedFormalReference(PaymentApplication entity) {
|
||||
if (entity.getId() == null || entity.getPreSettlementId() == null || entity.getSettlementId() != null) {
|
||||
if (entity.getId() == null) {
|
||||
return;
|
||||
}
|
||||
boolean progressAdvance = "progress_advance".equals(entity.getPaymentType());
|
||||
boolean preOnly = entity.getPreSettlementId() != null && entity.getSettlementId() == null;
|
||||
if (!progressAdvance && !preOnly) {
|
||||
return;
|
||||
}
|
||||
baseMapper.update(null, Wrappers.<PaymentApplication>lambdaUpdate()
|
||||
.eq(PaymentApplication::getId, entity.getId())
|
||||
.setSql("settlement_id = null, settlement_no = null"));
|
||||
entity.setSettlementId(null);
|
||||
entity.setSettlementNo(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 进度预付驳回重提时,请求里的结算主键可能是失效正式结算单或精度失真后的 ID。
|
||||
* 依次用预结算主键、正式结算位上的预结算主键、单据已保存的预结算主键和预结算单号找回。
|
||||
*/
|
||||
private Long resolveProgressPreSettlementId(PaymentApplicationSaveRequest request, PaymentApplication entity) {
|
||||
List<Long> candidates = new java.util.ArrayList<>();
|
||||
if (request.getPreSettlementId() != null) {
|
||||
candidates.add(request.getPreSettlementId());
|
||||
}
|
||||
if (request.getPreSettlementIds() != null) {
|
||||
request.getPreSettlementIds().stream().filter(Objects::nonNull).forEach(candidates::add);
|
||||
}
|
||||
if (request.getSettlementId() != null) {
|
||||
candidates.add(request.getSettlementId());
|
||||
}
|
||||
if (request.getSettlementIds() != null) {
|
||||
request.getSettlementIds().stream().filter(Objects::nonNull).forEach(candidates::add);
|
||||
}
|
||||
if (entity != null && entity.getPreSettlementId() != null) {
|
||||
candidates.add(entity.getPreSettlementId());
|
||||
}
|
||||
for (Long candidate : candidates) {
|
||||
PreSettlement preSettlement = preSettlementMapper.selectById(candidate);
|
||||
if (preSettlement != null && !Objects.equals(preSettlement.getIsDeleted(), 1)) {
|
||||
return preSettlement.getId();
|
||||
}
|
||||
}
|
||||
String preSettlementNo = request.getPreSettlementNo();
|
||||
if (Func.isEmpty(preSettlementNo) && entity != null) {
|
||||
preSettlementNo = entity.getPreSettlementNo();
|
||||
}
|
||||
if (Func.isNotEmpty(preSettlementNo)) {
|
||||
String settlementNo = preSettlementNo.split("、")[0].trim();
|
||||
PreSettlement preSettlement = preSettlementMapper.selectOne(Wrappers.<PreSettlement>lambdaQuery()
|
||||
.eq(PreSettlement::getPreSettlementNo, settlementNo)
|
||||
.eq(PreSettlement::getIsDeleted, 0)
|
||||
.last("limit 1"));
|
||||
if (preSettlement != null) {
|
||||
return preSettlement.getId();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void fillReference(PaymentApplication entity, PaymentApplicationSaveRequest request) {
|
||||
if ("project_advance".equals(request.getPaymentType())) {
|
||||
if ("progress_advance".equals(request.getPaymentType())) {
|
||||
Long preSettlementId = resolveProgressPreSettlementId(request, entity);
|
||||
request.setPreSettlementId(preSettlementId);
|
||||
if (preSettlementId == null) {
|
||||
throw new ServiceException("进度预付必须关联预结算单");
|
||||
}
|
||||
request.setSettlementId(null);
|
||||
request.setSettlementIds(null);
|
||||
fillPreSettlementReference(entity, requireApprovedPreSettlement(request.getPreSettlementId()));
|
||||
} else if ("project_advance".equals(request.getPaymentType())) {
|
||||
ProjectApply project = projectApplyMapper.selectById(request.getProjectId());
|
||||
if (project == null || Objects.equals(project.getIsDeleted(), 1)) throw new ServiceException("所属项目不存在");
|
||||
entity.setSettlementId(null); entity.setSettlementNo(null); entity.setPreSettlementId(null); entity.setPreSettlementNo(null);
|
||||
entity.setProjectId(request.getProjectId()); entity.setProjectName(request.getProjectName()); entity.setDeptId(request.getDeptId()); entity.setDeptName(request.getDeptName()); entity.setContractId(request.getContractId()); entity.setContractNo(request.getContractNo()); entity.setContractName(request.getContractName()); entity.setPayerName(request.getPayerName()); entity.setPayeeName(request.getPayeeName()); entity.setSettlementAmount(request.getSettlementAmount()); entity.setPayableAmount(money(project.getFundDemand()).multiply(BigDecimal.valueOf(10000))); entity.setBillType(Func.isEmpty(project.getSettlementMode()) ? null : "项目预付");
|
||||
} else if (request.getSettlementId() != null) {
|
||||
FormalSettlement source = formalSettlementMapper.selectById(request.getSettlementId());
|
||||
if (source == null) throw new ServiceException("正式结算单不存在");
|
||||
if (!TransportBusiConstant.APPROVED.equals(source.getApprovalStatus())) throw new ServiceException("只能选择审批通过、未作废的正式结算单");
|
||||
entity.setSettlementId(source.getId()); entity.setSettlementNo(source.getFormalSettlementNo()); entity.setPreSettlementId(null); entity.setPreSettlementNo(null);
|
||||
entity.setProjectId(source.getProjectId()); entity.setProjectName(source.getProjectName()); entity.setDeptId(source.getDeptId()); entity.setDeptName(source.getDeptName()); entity.setContractId(source.getContractId()); entity.setContractNo(source.getContractNo()); entity.setContractName(source.getContractName()); entity.setPayerName(source.getPayerName()); entity.setPayeeName(source.getPayeeName()); entity.setSettlementAmount(source.getSettlementAmount()); entity.setPayableAmount(payableOfFormalSettlement(source.getId(), source.getSettlementAmount(), entity.getId())); entity.setBillType("正式结算单");
|
||||
if (source == null || Objects.equals(source.getIsDeleted(), 1)) {
|
||||
// 驳回重提等场景:settlementId 可能误存为预结算单 ID,降级走预结算分支
|
||||
PreSettlement preSettlement = preSettlementMapper.selectById(request.getSettlementId());
|
||||
if (preSettlement != null && !Objects.equals(preSettlement.getIsDeleted(), 1)) {
|
||||
request.setPreSettlementId(preSettlement.getId());
|
||||
request.setSettlementId(null);
|
||||
fillPreSettlementReference(entity, preSettlement);
|
||||
} else if (request.getPreSettlementId() != null) {
|
||||
request.setSettlementId(null);
|
||||
fillPreSettlementReference(entity, requireApprovedPreSettlement(request.getPreSettlementId()));
|
||||
} else if (entity.getPreSettlementId() != null) {
|
||||
request.setSettlementId(null);
|
||||
request.setPreSettlementId(entity.getPreSettlementId());
|
||||
fillPreSettlementReference(entity, requireApprovedPreSettlement(entity.getPreSettlementId()));
|
||||
} else {
|
||||
throw new ServiceException("正式结算单不存在");
|
||||
}
|
||||
} else {
|
||||
if (!TransportBusiConstant.APPROVED.equals(source.getApprovalStatus())) throw new ServiceException("只能选择审批通过、未作废的正式结算单");
|
||||
entity.setSettlementId(source.getId()); entity.setSettlementNo(source.getFormalSettlementNo()); entity.setPreSettlementId(null); entity.setPreSettlementNo(null);
|
||||
entity.setProjectId(source.getProjectId()); entity.setProjectName(source.getProjectName()); entity.setDeptId(source.getDeptId()); entity.setDeptName(source.getDeptName()); entity.setContractId(source.getContractId()); entity.setContractNo(source.getContractNo()); entity.setContractName(source.getContractName()); entity.setPayerName(source.getPayerName()); entity.setPayeeName(source.getPayeeName()); entity.setSettlementAmount(source.getSettlementAmount()); entity.setPayableAmount(payableOfFormalSettlement(source.getId(), source.getSettlementAmount(), entity.getId())); entity.setBillType("正式结算单");
|
||||
}
|
||||
} else if (request.getPreSettlementId() != null) {
|
||||
PreSettlement source = preSettlementMapper.selectById(request.getPreSettlementId());
|
||||
if (source == null) throw new ServiceException("预结算单不存在");
|
||||
if (!TransportBusiConstant.APPROVED.equals(source.getApprovalStatus())) throw new ServiceException("只能选择审批通过、未作废的预结算单");
|
||||
entity.setPreSettlementId(source.getId()); entity.setPreSettlementNo(source.getPreSettlementNo()); entity.setSettlementId(null); entity.setSettlementNo(null); entity.setProjectId(source.getProjectId()); entity.setProjectName(source.getProjectName()); entity.setDeptId(source.getDeptId()); entity.setDeptName(source.getDeptName()); entity.setContractId(source.getContractId()); entity.setContractNo(source.getContractNo()); entity.setContractName(source.getContractName()); entity.setPayerName(source.getPayerName()); entity.setPayeeName(source.getPayeeName()); entity.setSettlementAmount(source.getSettlementAmount()); entity.setPayableAmount(payableOfPreSettlement(source.getId(), source.getSettlementAmount(), entity.getId())); entity.setBillType("预结算单");
|
||||
fillPreSettlementReference(entity, requireApprovedPreSettlement(request.getPreSettlementId()));
|
||||
} else {
|
||||
entity.setProjectId(request.getProjectId()); entity.setProjectName(request.getProjectName()); entity.setDeptId(request.getDeptId()); entity.setDeptName(request.getDeptName()); entity.setContractId(request.getContractId()); entity.setContractNo(request.getContractNo()); entity.setContractName(request.getContractName()); entity.setPayerName(request.getPayerName()); entity.setPayeeName(request.getPayeeName()); entity.setSettlementAmount(request.getSettlementAmount()); entity.setPayableAmount(request.getPayableAmount()); entity.setBillType(request.getBillType());
|
||||
}
|
||||
@@ -1958,6 +2059,36 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
&& money(entity.getPayableAmount()).compareTo(BigDecimal.ZERO) > 0) throw new ServiceException("申请付款金额不能超过可付款金额");
|
||||
}
|
||||
|
||||
private PreSettlement requireApprovedPreSettlement(Long preSettlementId) {
|
||||
PreSettlement source = preSettlementMapper.selectById(preSettlementId);
|
||||
if (source == null || Objects.equals(source.getIsDeleted(), 1)) {
|
||||
throw new ServiceException("预结算单不存在");
|
||||
}
|
||||
if (!TransportBusiConstant.APPROVED.equals(source.getApprovalStatus())) {
|
||||
throw new ServiceException("只能选择审批通过、未作废的预结算单");
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
private void fillPreSettlementReference(PaymentApplication entity, PreSettlement source) {
|
||||
entity.setPreSettlementId(source.getId());
|
||||
entity.setPreSettlementNo(source.getPreSettlementNo());
|
||||
entity.setSettlementId(null);
|
||||
entity.setSettlementNo(null);
|
||||
entity.setProjectId(source.getProjectId());
|
||||
entity.setProjectName(source.getProjectName());
|
||||
entity.setDeptId(source.getDeptId());
|
||||
entity.setDeptName(source.getDeptName());
|
||||
entity.setContractId(source.getContractId());
|
||||
entity.setContractNo(source.getContractNo());
|
||||
entity.setContractName(source.getContractName());
|
||||
entity.setPayerName(source.getPayerName());
|
||||
entity.setPayeeName(source.getPayeeName());
|
||||
entity.setSettlementAmount(source.getSettlementAmount());
|
||||
entity.setPayableAmount(payableOfPreSettlement(source.getId(), source.getSettlementAmount(), entity.getId()));
|
||||
entity.setBillType("预结算单");
|
||||
}
|
||||
|
||||
private List<FormalSettlement> resolveFormalSettlements(PaymentApplicationSaveRequest request, PaymentApplication entity) {
|
||||
if (!TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT.equals(request.getPaymentType())) return List.of();
|
||||
List<Long> ids = request.getSettlementIds() == null ? List.of() : request.getSettlementIds();
|
||||
@@ -2361,28 +2492,43 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
return relationAmount.add(legacyAmount);
|
||||
}
|
||||
|
||||
/** 正式结算可付款 = 结算金额 − 已申请付款 − 项目预付核销 − 来源预结算已付预付款 */
|
||||
/**
|
||||
* 正式结算可付款 = 结算金额 − 已申请付款 − 项目预付核销 − 来源预结算进度预付占用。
|
||||
* 进度预付占用与正式结算台账 remainingPayable 口径对齐,避免转正式后仍可按全额申请尾款。
|
||||
*/
|
||||
private BigDecimal payableOfFormalSettlement(Long settlementId, BigDecimal settlementAmount, Long excludeId) {
|
||||
return money(settlementAmount)
|
||||
.subtract(cumulativeFormalSettlementApplied(settlementId, excludeId))
|
||||
.subtract(projectAdvanceWriteOffService.sumBySettlement(
|
||||
TransportBusiConstant.SETTLEMENT_BILL_FORMAL, settlementId))
|
||||
.subtract(this.formalSettlementAdvancePaid(settlementId))
|
||||
.subtract(formalSettlementAdvanceOccupied(settlementId))
|
||||
.max(BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
/** 正式结算单来源预结算的已付预付款合计(转正式时已计入结算单已付) */
|
||||
private BigDecimal formalSettlementAdvancePaid(Long formalSettlementId) {
|
||||
if (formalSettlementId == null) {
|
||||
return BigDecimal.ZERO;
|
||||
/**
|
||||
* 正式结算来源预结算的进度预付占用合计:优先取来源行快照,快照为空时回退预结算实时已申请/已付。
|
||||
*/
|
||||
private BigDecimal formalSettlementAdvanceOccupied(Long settlementId) {
|
||||
if (settlementId == null) {
|
||||
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
return formalSettlementSourceMapper.selectList(Wrappers.<FormalSettlementSource>lambdaQuery()
|
||||
.select(FormalSettlementSource::getAdvancePaidAmount)
|
||||
.eq(FormalSettlementSource::getFormalSettlementId, formalSettlementId)
|
||||
.eq(FormalSettlementSource::getIsDeleted, 0)).stream()
|
||||
.map(FormalSettlementSource::getAdvancePaidAmount)
|
||||
.map(this::money)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
List<FormalSettlementSource> sources = formalSettlementSourceMapper.selectList(
|
||||
Wrappers.<FormalSettlementSource>lambdaQuery()
|
||||
.eq(FormalSettlementSource::getFormalSettlementId, settlementId)
|
||||
.eq(FormalSettlementSource::getIsDeleted, 0));
|
||||
if (sources.isEmpty()) {
|
||||
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (FormalSettlementSource source : sources) {
|
||||
BigDecimal occupied = money(source.getAdvanceAppliedAmount()).max(money(source.getAdvancePaidAmount()));
|
||||
if (occupied.compareTo(BigDecimal.ZERO) <= 0 && source.getPreSettlementId() != null) {
|
||||
occupied = findPreSettlementApplied(source.getPreSettlementId())
|
||||
.max(findPreSettlementPaid(source.getPreSettlementId()));
|
||||
}
|
||||
total = total.add(occupied);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** 预结算可付款 = 结算金额 − 已申请付款 − 项目预付核销 */
|
||||
@@ -2401,7 +2547,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
return;
|
||||
}
|
||||
BillLedger ledger = billLedgerMapper.selectById(request.getBillLedgerId());
|
||||
validateBill(ledger, entity.getDeptId(), entity.getAppliedAmount(), true, entity.getId());
|
||||
validateBill(ledger, entity.getDeptId(), entity.getAppliedAmount(), true, entity.getId(),
|
||||
entity.getSettlementId(), entity.getPreSettlementId());
|
||||
entity.setBillLedgerId(ledger.getId());
|
||||
entity.setBillNo(ledger.getBillNo());
|
||||
}
|
||||
@@ -2409,25 +2556,57 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
private void validateSelectedBill(PaymentApplication entity, boolean locked) {
|
||||
if (!isBillPayment(entity.getPaymentMethod())) return;
|
||||
BillLedger ledger = locked ? lockedBill(entity.getBillLedgerId()) : billLedgerMapper.selectById(entity.getBillLedgerId());
|
||||
validateBill(ledger, entity.getDeptId(), entity.getAppliedAmount(), true, entity.getId());
|
||||
validateBill(ledger, entity.getDeptId(), entity.getAppliedAmount(), true, entity.getId(),
|
||||
entity.getSettlementId(), entity.getPreSettlementId());
|
||||
}
|
||||
|
||||
private void validateBill(BillLedger ledger, Long deptId, BigDecimal amount, boolean checkMaturity, Long paymentApplicationId) {
|
||||
private void validateBill(BillLedger ledger, Long deptId, BigDecimal amount, boolean checkMaturity,
|
||||
Long paymentApplicationId, Long settlementId, Long preSettlementId) {
|
||||
if (ledger == null || Objects.equals(ledger.getIsDeleted(), 1)) throw new ServiceException("所选汇票台账不存在");
|
||||
if (!"received".equals(ledger.getBillType())) {
|
||||
throw new ServiceException("汇票付款仅可选择收票类型的汇票");
|
||||
boolean partyMatched = settlementPartiesMatch(ledger, settlementId, preSettlementId);
|
||||
if (!"received".equals(ledger.getBillType()) && !partyMatched) {
|
||||
throw new ServiceException("汇票付款仅可选择收票类型的汇票,或出票单位与收票单位均为当前结算收付款方的汇票");
|
||||
}
|
||||
if (checkMaturity && (ledger.getMaturityDate() == null || ledger.getMaturityDate().isBefore(LocalDate.now()))) {
|
||||
throw new ServiceException("所选汇票已到期");
|
||||
}
|
||||
BigDecimal available = money(ledger.getAvailableBalance()).add(occupiedAmount(paymentApplicationId, ledger.getId()));
|
||||
if (money(amount).compareTo(available) > 0) throw new ServiceException("申请付款金额超过汇票可用余额");
|
||||
if (!departmentAvailable(ledger, deptId)) throw new ServiceException("当前使用部门不在汇票可用部门范围内");
|
||||
if (!partyMatched && !departmentAvailable(ledger, deptId)) throw new ServiceException("当前使用部门不在汇票可用部门范围内");
|
||||
}
|
||||
|
||||
/** 出票单位、收票单位与结算付款方、收款方一致(顺序不限) */
|
||||
private boolean settlementPartiesMatch(BillLedger ledger, Long settlementId, Long preSettlementId) {
|
||||
String payerName = null;
|
||||
String payeeName = null;
|
||||
if (settlementId != null) {
|
||||
FormalSettlement settlement = formalSettlementMapper.selectById(settlementId);
|
||||
if (settlement != null && !Objects.equals(settlement.getIsDeleted(), 1)) {
|
||||
payerName = settlement.getPayerName();
|
||||
payeeName = settlement.getPayeeName();
|
||||
}
|
||||
}
|
||||
if ((payerName == null || payeeName == null) && preSettlementId != null) {
|
||||
PreSettlement settlement = preSettlementMapper.selectById(preSettlementId);
|
||||
if (settlement != null && !Objects.equals(settlement.getIsDeleted(), 1)) {
|
||||
payerName = settlement.getPayerName();
|
||||
payeeName = settlement.getPayeeName();
|
||||
}
|
||||
}
|
||||
String issuer = ledger.getIssuerName() == null ? "" : ledger.getIssuerName().trim();
|
||||
String receiver = ledger.getReceiverName() == null ? "" : ledger.getReceiverName().trim();
|
||||
String payer = payerName == null ? "" : payerName.trim();
|
||||
String payee = payeeName == null ? "" : payeeName.trim();
|
||||
if (issuer.isEmpty() || receiver.isEmpty() || payer.isEmpty() || payee.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return (issuer.equals(payer) && receiver.equals(payee)) || (issuer.equals(payee) && receiver.equals(payer));
|
||||
}
|
||||
|
||||
private void useBillBalance(PaymentApplication entity) {
|
||||
BillLedger ledger = lockedBill(entity.getBillLedgerId());
|
||||
validateBill(ledger, entity.getDeptId(), entity.getAppliedAmount(), true, null);
|
||||
validateBill(ledger, entity.getDeptId(), entity.getAppliedAmount(), true, null,
|
||||
entity.getSettlementId(), entity.getPreSettlementId());
|
||||
BillLedgerUsage exists = lockedBillUsage(entity.getId());
|
||||
if (exists != null && TransportBusiConstant.APPROVED.equals(exists.getUsageStatus())) {
|
||||
throw new ServiceException("该付款申请已生成汇票使用记录");
|
||||
|
||||
+82
-65
@@ -206,6 +206,9 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
vo.setChangeRecords(changeRecordMapper.selectList(Wrappers.<PreSettlementChangeRecord>lambdaQuery()
|
||||
.eq(PreSettlementChangeRecord::getPreSettlementId, id)
|
||||
.eq(PreSettlementChangeRecord::getIsDeleted, 0)
|
||||
.and(wrapper -> wrapper.isNull(PreSettlementChangeRecord::getChangeContent)
|
||||
.or()
|
||||
.ne(PreSettlementChangeRecord::getChangeContent, "预结算审批通过"))
|
||||
.orderByDesc(PreSettlementChangeRecord::getChangeTime)));
|
||||
vo.setPrintTemplates(resolvePrintTemplates(settlement));
|
||||
return vo;
|
||||
@@ -355,8 +358,10 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
synchronizeDetails(settlement, request.getSourceDetailIds(), Boolean.TRUE.equals(request.getAllowSourceMismatch()));
|
||||
}
|
||||
settlement.setExchangeRate(normalizeRate(request.getExchangeRate(), settlement.getCurrency()));
|
||||
List<PreSettlementSummaryFee> summaryBefore = created ? new ArrayList<>() : listSummaryFees(settlement.getId());
|
||||
rebuildSummaryFees(settlement.getId(), true);
|
||||
applySummaryRequest(settlement.getId(), contractChanged ? List.of() : request.getSummaryFees());
|
||||
recordSummaryFeeChanges(settlement.getId(), summaryBefore);
|
||||
refreshSettlementAmount(settlement);
|
||||
projectAdvanceWriteOffService.replaceWriteOffs(
|
||||
TransportBusiConstant.SETTLEMENT_BILL_PRE,
|
||||
@@ -425,7 +430,6 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
settlement.setCurrentProcessor(AuthUtil.getUserName());
|
||||
settlement.setApprovedTime(LocalDateTime.now());
|
||||
updateById(settlement);
|
||||
saveChange(settlement.getId(), "结算单基本信息", null, "调整", "预结算审批通过", request.getReason());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1129,11 +1133,6 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
throw new ServiceException("存在无效的手工费用行");
|
||||
}
|
||||
boolean creating = row.getId() == null;
|
||||
String beforeFeeType = creating ? null : row.getFeeType();
|
||||
String beforeFeeItem = creating ? null : row.getFeeItem();
|
||||
BigDecimal beforeAdjustAmount = creating ? null : money(row.getAdjustAmount());
|
||||
BigDecimal beforeSettlementAmount = creating ? null : money(row.getSettlementAmount());
|
||||
String beforeRemark = creating ? null : row.getRemark();
|
||||
row.setPreSettlementId(settlementId);
|
||||
row.setLineNo(lineNo++);
|
||||
row.setFeeType(feeType);
|
||||
@@ -1149,8 +1148,6 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
summaryFeeMapper.updateById(row);
|
||||
}
|
||||
retainedManualIds.add(row.getId());
|
||||
saveManualSummaryFeeChange(settlementId, creating, beforeFeeType, beforeFeeItem,
|
||||
beforeAdjustAmount, beforeSettlementAmount, beforeRemark, row);
|
||||
continue;
|
||||
}
|
||||
String feeItem = requiredText(requestRow.getFeeItem(), "费用项");
|
||||
@@ -1175,45 +1172,16 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
row.setFeeItem(feeItem);
|
||||
// 生成费用行的三个金额必须来源于结算明细快照,不能接受前端回传值覆盖原金额。
|
||||
// rebuildSummaryFees 已按明细重建 original/adjust/settlement,此处仅保留备注。
|
||||
BigDecimal before = money(row.getAdjustAmount());
|
||||
row.setOriginalAmount(money(row.getOriginalAmount()));
|
||||
row.setSettlementAmount(money(row.getSettlementAmount()));
|
||||
row.setAdjustAmount(money(row.getSettlementAmount().subtract(row.getOriginalAmount())));
|
||||
row.setRemark(limitRemark(requestRow.getRemark(), 50));
|
||||
if (row.getId() == null) summaryFeeMapper.insert(row); else summaryFeeMapper.updateById(row);
|
||||
retainedGeneratedIds.add(row.getId());
|
||||
if (before.compareTo(row.getAdjustAmount()) != 0) {
|
||||
Map<String, Object> beforeData = new LinkedHashMap<>();
|
||||
Map<String, Object> afterData = new LinkedHashMap<>();
|
||||
beforeData.put("费用项", row.getFeeItem());
|
||||
beforeData.put("调整金额", before);
|
||||
beforeData.put("结算金额", money(row.getOriginalAmount().add(before)));
|
||||
afterData.put("费用项", row.getFeeItem());
|
||||
afterData.put("调整金额", row.getAdjustAmount());
|
||||
afterData.put("结算金额", money(row.getSettlementAmount()));
|
||||
saveChange(settlementId, "合计费用项", row.getLineNo(), "调整",
|
||||
"【" + row.getFeeItem() + "-调整金额】从【" + before + "】调整为【" + row.getAdjustAmount() + "】",
|
||||
"", beforeData, afterData);
|
||||
}
|
||||
}
|
||||
for (PreSettlementSummaryFee manualRow : manualRows) {
|
||||
if (!retainedManualIds.contains(manualRow.getId())) {
|
||||
summaryFeeMapper.deleteById(manualRow.getId());
|
||||
Map<String, Object> beforeData = new LinkedHashMap<>();
|
||||
Map<String, Object> afterData = new LinkedHashMap<>();
|
||||
beforeData.put("费用类型", manualRow.getFeeType());
|
||||
beforeData.put("费用项", manualRow.getFeeItem());
|
||||
beforeData.put("调整金额", money(manualRow.getAdjustAmount()));
|
||||
beforeData.put("结算金额", money(manualRow.getSettlementAmount()));
|
||||
beforeData.put("备注", manualRow.getRemark());
|
||||
afterData.put("费用类型", "");
|
||||
afterData.put("费用项", "");
|
||||
afterData.put("调整金额", BigDecimal.ZERO.setScale(2));
|
||||
afterData.put("结算金额", BigDecimal.ZERO.setScale(2));
|
||||
afterData.put("备注", "");
|
||||
saveChange(settlementId, "合计费用项", manualRow.getLineNo(), "删除",
|
||||
"删除合计费用项【" + manualRow.getFeeItem() + "】金额【" + money(manualRow.getSettlementAmount()) + "】",
|
||||
"", beforeData, afterData);
|
||||
}
|
||||
}
|
||||
for (PreSettlementSummaryFee generatedRow : existingGeneratedRows) {
|
||||
@@ -1563,38 +1531,87 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
}
|
||||
}
|
||||
|
||||
private void saveManualSummaryFeeChange(Long settlementId, boolean creating, String beforeFeeType,
|
||||
String beforeFeeItem, BigDecimal beforeAdjustAmount, BigDecimal beforeSettlementAmount, String beforeRemark,
|
||||
PreSettlementSummaryFee row) {
|
||||
List<String> changes = new ArrayList<>();
|
||||
private void recordSummaryFeeChanges(Long settlementId, List<PreSettlementSummaryFee> beforeRows) {
|
||||
List<Map<String, Object>> beforeLines = summaryFeeLines(beforeRows);
|
||||
List<Map<String, Object>> afterLines = summaryFeeLines(listSummaryFees(settlementId));
|
||||
if (beforeLines.equals(afterLines)) {
|
||||
return;
|
||||
}
|
||||
String content = summaryFeeChangeContent(beforeLines, afterLines);
|
||||
if (Func.isEmpty(content)) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> beforeData = new LinkedHashMap<>();
|
||||
Map<String, Object> afterData = new LinkedHashMap<>();
|
||||
if (creating) {
|
||||
changes.add("新增合计费用项【" + row.getFeeItem() + "】金额【" + money(row.getSettlementAmount()) + "】");
|
||||
beforeData.put("费用类型", "");
|
||||
beforeData.put("费用项", "");
|
||||
beforeData.put("调整金额", BigDecimal.ZERO.setScale(2));
|
||||
beforeData.put("结算金额", BigDecimal.ZERO.setScale(2));
|
||||
beforeData.put("备注", "");
|
||||
afterData.put("费用类型", row.getFeeType());
|
||||
afterData.put("费用项", row.getFeeItem());
|
||||
afterData.put("调整金额", money(row.getAdjustAmount()));
|
||||
afterData.put("结算金额", money(row.getSettlementAmount()));
|
||||
afterData.put("备注", row.getRemark());
|
||||
saveChange(settlementId, "合计费用项", row.getLineNo(), "新增", String.join(";", changes), "",
|
||||
beforeData, afterData);
|
||||
return;
|
||||
beforeData.put("结算合计", beforeLines);
|
||||
afterData.put("结算合计", afterLines);
|
||||
String operationType = beforeLines.isEmpty() ? "新增" : afterLines.isEmpty() ? "删除" : "调整";
|
||||
saveChange(settlementId, "结算合计", null, operationType, content, "", beforeData, afterData);
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> summaryFeeLines(List<PreSettlementSummaryFee> rows) {
|
||||
Map<String, String> categoryNames = DictBizCache.getList("fee_category").stream()
|
||||
.collect(Collectors.toMap(DictBiz::getDictKey, DictBiz::getDictValue, (first, second) -> first));
|
||||
List<Map<String, Object>> lines = new ArrayList<>();
|
||||
for (PreSettlementSummaryFee row : rows) {
|
||||
String feeType = row.getFeeType() == null ? "" : row.getFeeType();
|
||||
Map<String, Object> line = new LinkedHashMap<>();
|
||||
line.put("来源", Integer.valueOf(1).equals(row.getManualFlag()) ? "手工新增" : "系统归集");
|
||||
line.put("费用类型", categoryNames.getOrDefault(feeType, feeType));
|
||||
line.put("费用项", row.getFeeItem() == null ? "" : row.getFeeItem());
|
||||
line.put("原金额", money(row.getOriginalAmount()));
|
||||
line.put("调整金额", money(row.getAdjustAmount()));
|
||||
line.put("结算金额", money(row.getSettlementAmount()));
|
||||
line.put("备注", row.getRemark() == null ? "" : row.getRemark());
|
||||
lines.add(line);
|
||||
}
|
||||
appendTextChange(changes, beforeData, afterData, "费用类型", beforeFeeType, row.getFeeType());
|
||||
appendTextChange(changes, beforeData, afterData, "费用项", beforeFeeItem, row.getFeeItem());
|
||||
appendChange(changes, beforeData, afterData, "调整金额", beforeAdjustAmount, row.getAdjustAmount());
|
||||
appendChange(changes, beforeData, afterData, "结算金额", beforeSettlementAmount, row.getSettlementAmount());
|
||||
appendTextChange(changes, beforeData, afterData, "备注", beforeRemark, row.getRemark());
|
||||
if (changes.isEmpty()) {
|
||||
return;
|
||||
return lines;
|
||||
}
|
||||
|
||||
private String summaryFeeChangeContent(List<Map<String, Object>> beforeLines, List<Map<String, Object>> afterLines) {
|
||||
Map<String, Map<String, Object>> beforeMap = indexSummaryLines(beforeLines);
|
||||
Map<String, Map<String, Object>> afterMap = indexSummaryLines(afterLines);
|
||||
List<String> changes = new ArrayList<>();
|
||||
afterMap.forEach((key, afterLine) -> {
|
||||
Map<String, Object> beforeLine = beforeMap.get(key);
|
||||
String label = summaryLineLabel(afterLine);
|
||||
if (beforeLine == null) {
|
||||
changes.add("新增结算合计【" + label + "】结算金额【" + afterLine.get("结算金额") + "】");
|
||||
return;
|
||||
}
|
||||
if (!Objects.equals(beforeLine.get("原金额"), afterLine.get("原金额"))
|
||||
|| !Objects.equals(beforeLine.get("调整金额"), afterLine.get("调整金额"))
|
||||
|| !Objects.equals(beforeLine.get("结算金额"), afterLine.get("结算金额"))
|
||||
|| !Objects.equals(String.valueOf(beforeLine.get("备注")), String.valueOf(afterLine.get("备注")))) {
|
||||
changes.add("调整结算合计【" + label + "】结算金额从【" + beforeLine.get("结算金额")
|
||||
+ "】调整为【" + afterLine.get("结算金额") + "】");
|
||||
}
|
||||
});
|
||||
beforeMap.forEach((key, beforeLine) -> {
|
||||
if (!afterMap.containsKey(key)) {
|
||||
changes.add("删除结算合计【" + summaryLineLabel(beforeLine) + "】结算金额【" + beforeLine.get("结算金额") + "】");
|
||||
}
|
||||
});
|
||||
return String.join(";", changes);
|
||||
}
|
||||
|
||||
private Map<String, Map<String, Object>> indexSummaryLines(List<Map<String, Object>> lines) {
|
||||
Map<String, Map<String, Object>> indexed = new LinkedHashMap<>();
|
||||
Map<String, Integer> counts = new HashMap<>();
|
||||
for (Map<String, Object> line : lines) {
|
||||
String base = String.valueOf(line.get("来源")) + "|" + line.get("费用类型") + "|" + line.get("费用项");
|
||||
int index = counts.merge(base, 1, Integer::sum);
|
||||
indexed.put(base + "#" + index, line);
|
||||
}
|
||||
saveChange(settlementId, "合计费用项", row.getLineNo(), "调整", String.join(";", changes), "",
|
||||
beforeData, afterData);
|
||||
return indexed;
|
||||
}
|
||||
|
||||
private String summaryLineLabel(Map<String, Object> line) {
|
||||
String feeType = String.valueOf(line.getOrDefault("费用类型", ""));
|
||||
String feeItem = String.valueOf(line.getOrDefault("费用项", ""));
|
||||
String source = String.valueOf(line.getOrDefault("来源", ""));
|
||||
String name = feeType.isBlank() ? feeItem : feeType + "-" + feeItem;
|
||||
return source + " " + name;
|
||||
}
|
||||
|
||||
private void saveChange(Long settlementId, String changeType, Integer lineNo, String operationType,
|
||||
|
||||
+20
-5
@@ -252,8 +252,8 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl<ReceiptClaimM
|
||||
BigDecimal flowClaimedAfter = money(flow.getClaimedAmount())
|
||||
.subtract(money(claim.getClaimAmount())).max(BigDecimal.ZERO);
|
||||
flow.setClaimedAmount(flowClaimedAfter);
|
||||
// 认领状态对照公告未认领金额重算(作废分摊不计入累计)
|
||||
flow.setClaimStatus(amountClaimStatus(flowClaimedAfter, flow.getUnclaimedAmount()));
|
||||
// 认领状态对照可认领上限重算(作废分摊不计入累计)
|
||||
flow.setClaimStatus(amountClaimStatus(flowClaimedAfter, claimCeiling(flow)));
|
||||
receiptFlowMapper.updateById(flow);
|
||||
|
||||
// 作废不生成金蝶认领冲单:金蝶单号/状态保留原值(未推送为空,已推送为真实收款单信息),
|
||||
@@ -411,13 +411,28 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl<ReceiptClaimM
|
||||
}
|
||||
|
||||
/**
|
||||
* 累计认领金额 → 流水认领状态:对照公告未认领金额判定(与收款流水侧口径一致)
|
||||
* 累计认领金额 → 流水认领状态:对照可认领上限判定(与收款流水侧口径一致)
|
||||
*/
|
||||
private String amountClaimStatus(BigDecimal claimedAmount, BigDecimal unclaimedAmount) {
|
||||
private String amountClaimStatus(BigDecimal claimedAmount, BigDecimal claimCeiling) {
|
||||
if (claimedAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return "unclaimed";
|
||||
}
|
||||
return claimedAmount.compareTo(money(unclaimedAmount)) >= 0 ? "claimed" : "partial";
|
||||
return claimedAmount.compareTo(money(claimCeiling)) >= 0 ? "claimed" : "partial";
|
||||
}
|
||||
|
||||
/**
|
||||
* 可认领上限:公告未认领金额与收款金额取较小正值(与收款流水侧口径一致)
|
||||
*/
|
||||
private BigDecimal claimCeiling(KingdeeReceiptFlow flow) {
|
||||
BigDecimal unclaimed = money(flow.getUnclaimedAmount());
|
||||
BigDecimal receipt = money(flow.getReceiptAmount());
|
||||
if (receipt.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return unclaimed;
|
||||
}
|
||||
if (unclaimed.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return unclaimed;
|
||||
}
|
||||
return unclaimed.min(receipt);
|
||||
}
|
||||
|
||||
private String amountStatus(BigDecimal paidAmount, BigDecimal settlementAmount) {
|
||||
|
||||
+126
-20
@@ -194,12 +194,16 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
.eq(ReceiptFlowRecord::getReceiptFlowId, id)
|
||||
.in(ReceiptFlowRecord::getActionType, List.of("claim", "push"))
|
||||
.orderByDesc(ReceiptFlowRecord::getCreateTime)));
|
||||
// 继续认领:带回本流水历史关联且仍有剩余可认领金额的结算单
|
||||
vo.setRelatedSettlements(relatedSettlementCandidates(id));
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> settlementCandidates(String keyword, Long flowId) {
|
||||
existing(flowId);
|
||||
// 本流水历史关联结算单优先保留(避免仅按创建时间 limit 截断后选不到)
|
||||
Set<Long> relatedIds = relatedSettlementIds(flowId);
|
||||
List<FormalSettlement> settlements = formalSettlementMapper.selectList(
|
||||
Wrappers.<FormalSettlement>lambdaQuery()
|
||||
.eq(FormalSettlement::getSettlementType, RECEIVABLE)
|
||||
@@ -210,7 +214,24 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
.or().like(FormalSettlement::getProjectName, keyword)
|
||||
.or().like(FormalSettlement::getContractName, keyword))
|
||||
.orderByDesc(FormalSettlement::getCreateTime)
|
||||
.last("limit 200"));
|
||||
.last("limit 500"));
|
||||
if (!relatedIds.isEmpty()) {
|
||||
Set<Long> loadedIds = settlements.stream().map(FormalSettlement::getId)
|
||||
.filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
List<Long> missingIds = relatedIds.stream().filter(id -> !loadedIds.contains(id)).toList();
|
||||
if (!missingIds.isEmpty()) {
|
||||
List<FormalSettlement> relatedSettlements = formalSettlementMapper.selectList(
|
||||
Wrappers.<FormalSettlement>lambdaQuery()
|
||||
.in(FormalSettlement::getId, missingIds)
|
||||
.eq(FormalSettlement::getSettlementType, RECEIVABLE)
|
||||
.eq(FormalSettlement::getApprovalStatus, APPROVED)
|
||||
.eq(FormalSettlement::getStatus, 1));
|
||||
List<FormalSettlement> merged = new ArrayList<>(relatedSettlements.size() + settlements.size());
|
||||
merged.addAll(relatedSettlements);
|
||||
merged.addAll(settlements);
|
||||
settlements = merged;
|
||||
}
|
||||
}
|
||||
// 候选结算单的累计认领金额一次聚合查询(避免逐单 N+1)
|
||||
Map<Long, BigDecimal> claimedMap = receiptClaimedAmounts(settlements.stream()
|
||||
.map(FormalSettlement::getId).toList());
|
||||
@@ -218,6 +239,14 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
.map(settlement -> candidateRow(settlement,
|
||||
claimedMap.getOrDefault(settlement.getId(), BigDecimal.ZERO)))
|
||||
.filter(row -> ((BigDecimal) row.get("remainingReceiptAmount")).compareTo(BigDecimal.ZERO) > 0)
|
||||
.sorted((left, right) -> {
|
||||
boolean leftRelated = relatedIds.contains(toLong(left.get("id")));
|
||||
boolean rightRelated = relatedIds.contains(toLong(right.get("id")));
|
||||
if (leftRelated == rightRelated) {
|
||||
return 0;
|
||||
}
|
||||
return leftRelated ? -1 : 1;
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -234,6 +263,7 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
Wrappers.<ReceiptClaimSettlement>lambdaQuery()
|
||||
.eq(ReceiptClaimSettlement::getFormalSettlementId, formalSettlementId)
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)
|
||||
.eq(ReceiptClaimSettlement::getIsDeleted, 0)
|
||||
.orderByDesc(ReceiptClaimSettlement::getCreateTime));
|
||||
if (relations.isEmpty()) {
|
||||
return List.of();
|
||||
@@ -315,19 +345,19 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
allocatedTotal = allocatedTotal.add(allocated);
|
||||
}
|
||||
|
||||
// 公告未认领金额(认领上限与认领完成判定基准,A301-2 口径)
|
||||
// 认领上限:公告未认领金额与收款金额取较小值(避免未认领金额异常偏大导致无法认领完成)
|
||||
if (flow.getUnclaimedAmount() == null) {
|
||||
throw new ServiceException("公告未认领金额缺失,请先手动同步金蝶收款流水");
|
||||
}
|
||||
BigDecimal unclaimedAmount = money(flow.getUnclaimedAmount());
|
||||
if (unclaimedAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new ServiceException("该流水公告未认领金额为0,无需认领");
|
||||
BigDecimal claimCeiling = claimCeiling(flow);
|
||||
if (claimCeiling.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new ServiceException("该流水可认领金额为0,无需认领");
|
||||
}
|
||||
// 流水认领前已认领金额
|
||||
BigDecimal previousFlowClaimed = flowClaimedAmount(flow.getId());
|
||||
if (previousFlowClaimed.add(allocatedTotal).compareTo(unclaimedAmount) > 0) {
|
||||
throw new ServiceException("累计认领金额不能超过公告未认领金额,剩余可认领 "
|
||||
+ unclaimedAmount.subtract(previousFlowClaimed).max(BigDecimal.ZERO));
|
||||
if (previousFlowClaimed.add(allocatedTotal).compareTo(claimCeiling) > 0) {
|
||||
throw new ServiceException("累计认领金额不能超过可认领上限,剩余可认领 "
|
||||
+ claimCeiling.subtract(previousFlowClaimed).max(BigDecimal.ZERO));
|
||||
}
|
||||
|
||||
// 当前用户部门
|
||||
@@ -380,6 +410,9 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
relation.setClaimedReceiptAmount(previousClaimed);
|
||||
// 本次分摊金额
|
||||
relation.setAllocatedReceiptAmount(allocated);
|
||||
// 有效分摊(与汇总/候选查询 status=1 口径一致)
|
||||
relation.setStatus(1);
|
||||
relation.setIsDeleted(0);
|
||||
claimSettlementMapper.insert(relation);
|
||||
}
|
||||
|
||||
@@ -390,8 +423,8 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
String fromStatus = normalizeClaimStatus(flow.getClaimStatus());
|
||||
// 认领后累计金额
|
||||
BigDecimal claimedAfter = previousFlowClaimed.add(allocatedTotal);
|
||||
// 认领后状态(对照公告未认领金额)
|
||||
String toStatus = amountClaimStatus(claimedAfter, unclaimedAmount);
|
||||
// 认领后状态(对照可认领上限)
|
||||
String toStatus = amountClaimStatus(claimedAfter, claimCeiling);
|
||||
// 更新流水已认领金额
|
||||
flow.setClaimedAmount(claimedAfter);
|
||||
// 更新流水认领状态
|
||||
@@ -499,9 +532,9 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
|
||||
// 复制公告数据到实体(不覆盖本地认领字段)
|
||||
copyAnnounceRow(entity, announce, detailSerialNo, receiptAmount);
|
||||
// 根据本地累计认领金额与最新公告未认领金额重新计算认领状态
|
||||
// 根据本地累计认领金额与可认领上限重新计算认领状态
|
||||
entity.setClaimStatus(amountClaimStatus(money(entity.getClaimedAmount()),
|
||||
entity.getUnclaimedAmount()));
|
||||
claimCeiling(entity)));
|
||||
|
||||
if (created) {
|
||||
toInsert.add(entity);
|
||||
@@ -621,6 +654,46 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 本流水历史关联过的结算单 id(有效分摊)
|
||||
*/
|
||||
private Set<Long> relatedSettlementIds(Long flowId) {
|
||||
if (flowId == null) {
|
||||
return Set.of();
|
||||
}
|
||||
return claimSettlementMapper.selectList(Wrappers.<ReceiptClaimSettlement>lambdaQuery()
|
||||
.select(ReceiptClaimSettlement::getFormalSettlementId)
|
||||
.eq(ReceiptClaimSettlement::getReceiptFlowId, flowId)
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)
|
||||
.eq(ReceiptClaimSettlement::getIsDeleted, 0)).stream()
|
||||
.map(ReceiptClaimSettlement::getFormalSettlementId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toCollection(HashSet::new));
|
||||
}
|
||||
|
||||
/**
|
||||
* 继续认领预填:本流水历史关联且仍有剩余可认领金额的结算单
|
||||
*/
|
||||
private List<Map<String, Object>> relatedSettlementCandidates(Long flowId) {
|
||||
Set<Long> relatedIds = relatedSettlementIds(flowId);
|
||||
if (relatedIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<FormalSettlement> settlements = formalSettlementMapper.selectList(
|
||||
Wrappers.<FormalSettlement>lambdaQuery()
|
||||
.in(FormalSettlement::getId, relatedIds)
|
||||
.eq(FormalSettlement::getSettlementType, RECEIVABLE)
|
||||
.eq(FormalSettlement::getApprovalStatus, APPROVED)
|
||||
.eq(FormalSettlement::getStatus, 1));
|
||||
Map<Long, BigDecimal> claimedMap = receiptClaimedAmounts(settlements.stream()
|
||||
.map(FormalSettlement::getId).toList());
|
||||
return settlements.stream()
|
||||
.map(settlement -> candidateRow(settlement,
|
||||
claimedMap.getOrDefault(settlement.getId(), BigDecimal.ZERO)))
|
||||
.filter(row -> ((BigDecimal) row.get("remainingReceiptAmount")).compareTo(BigDecimal.ZERO) > 0)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量聚合结算单的累计有效认领金额:一次 IN 查询按结算单分组求和,避免逐单 N+1
|
||||
*/
|
||||
@@ -631,7 +704,8 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
}
|
||||
return claimSettlementMapper.selectList(Wrappers.<ReceiptClaimSettlement>lambdaQuery()
|
||||
.in(ReceiptClaimSettlement::getFormalSettlementId, ids)
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)).stream()
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)
|
||||
.eq(ReceiptClaimSettlement::getIsDeleted, 0)).stream()
|
||||
.collect(Collectors.groupingBy(ReceiptClaimSettlement::getFormalSettlementId,
|
||||
Collectors.reducing(BigDecimal.ZERO,
|
||||
item -> money(item.getAllocatedReceiptAmount()), BigDecimal::add)));
|
||||
@@ -640,12 +714,42 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
private BigDecimal flowClaimedAmount(Long flowId) {
|
||||
return claimSettlementMapper.selectList(Wrappers.<ReceiptClaimSettlement>lambdaQuery()
|
||||
.eq(ReceiptClaimSettlement::getReceiptFlowId, flowId)
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)).stream()
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)
|
||||
.eq(ReceiptClaimSettlement::getIsDeleted, 0)).stream()
|
||||
.map(ReceiptClaimSettlement::getAllocatedReceiptAmount)
|
||||
.map(this::money)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
}
|
||||
|
||||
/**
|
||||
* 可认领上限:公告未认领金额与收款金额取较小正值。
|
||||
* 主口径仍为 A301-2 公告未认领金额;与收款金额取 min,避免未认领金额异常偏大导致无法进入「认领完成」。
|
||||
*/
|
||||
private BigDecimal claimCeiling(KingdeeReceiptFlow flow) {
|
||||
BigDecimal unclaimed = money(flow.getUnclaimedAmount());
|
||||
BigDecimal receipt = money(flow.getReceiptAmount());
|
||||
if (receipt.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return unclaimed;
|
||||
}
|
||||
if (unclaimed.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return unclaimed;
|
||||
}
|
||||
return unclaimed.min(receipt);
|
||||
}
|
||||
|
||||
private Long toLong(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Long longValue) {
|
||||
return longValue;
|
||||
}
|
||||
if (value instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
return Func.toLong(String.valueOf(value));
|
||||
}
|
||||
|
||||
private KingdeeReceiptFlow lockedFlow(Long flowId) {
|
||||
KingdeeReceiptFlow flow = baseMapper.selectOne(Wrappers.<KingdeeReceiptFlow>lambdaQuery()
|
||||
.eq(KingdeeReceiptFlow::getId, flowId)
|
||||
@@ -824,18 +928,20 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
flow.setClaimMe(announce.getClaimme());
|
||||
BigDecimal claimedTotal = flowClaimedAmount(flow.getId());
|
||||
flow.setClaimedAmount(claimedTotal);
|
||||
String claimStatus = amountClaimStatus(claimedTotal, flow.getUnclaimedAmount());
|
||||
BigDecimal claimCeiling = claimCeiling(flow);
|
||||
String claimStatus = amountClaimStatus(claimedTotal, claimCeiling);
|
||||
flow.setClaimStatus(claimStatus);
|
||||
updateById(flow);
|
||||
if (!CLAIMED.equals(claimStatus)) {
|
||||
return ReceiptClaimPushResult.fail("累计认领金额(" + claimedTotal + ")与公告未认领金额("
|
||||
+ money(flow.getUnclaimedAmount()) + ")不一致,不满足推送条件");
|
||||
return ReceiptClaimPushResult.fail("累计认领金额(" + claimedTotal + ")与可认领上限("
|
||||
+ claimCeiling + ")不一致,不满足推送条件");
|
||||
}
|
||||
// 分录取该流水全部有效分摊行(结算单id升序,跨认领主单归集)
|
||||
List<ReceiptClaimSettlement> allocations = claimSettlementMapper.selectList(
|
||||
Wrappers.<ReceiptClaimSettlement>lambdaQuery()
|
||||
.eq(ReceiptClaimSettlement::getReceiptFlowId, flow.getId())
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)
|
||||
.eq(ReceiptClaimSettlement::getIsDeleted, 0)
|
||||
.orderByAsc(ReceiptClaimSettlement::getFormalSettlementId));
|
||||
if (allocations.isEmpty()) {
|
||||
return ReceiptClaimPushResult.fail("该流水无有效认领分摊,无法推送");
|
||||
@@ -1075,13 +1181,13 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
}
|
||||
|
||||
/**
|
||||
* 累计认领金额 → 流水认领状态:对照公告未认领金额判定(claimed 即「认领完成」,唯一可推送状态)
|
||||
* 累计认领金额 → 流水认领状态:对照可认领上限判定(claimed 即「认领完成」,唯一可推送状态)
|
||||
*/
|
||||
private String amountClaimStatus(BigDecimal claimedAmount, BigDecimal unclaimedAmount) {
|
||||
private String amountClaimStatus(BigDecimal claimedAmount, BigDecimal claimCeiling) {
|
||||
if (claimedAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return UNCLAIMED;
|
||||
}
|
||||
return claimedAmount.compareTo(money(unclaimedAmount)) >= 0 ? CLAIMED : PARTIAL;
|
||||
return claimedAmount.compareTo(money(claimCeiling)) >= 0 ? CLAIMED : PARTIAL;
|
||||
}
|
||||
|
||||
private String amountStatus(BigDecimal claimedAmount, BigDecimal settlementAmount) {
|
||||
|
||||
+27
-11
@@ -424,9 +424,7 @@ public class ReceivablePayableDetailServiceImpl
|
||||
}
|
||||
});
|
||||
ReceivablePayableFeeDetailVO result = buildFeeDetail(rows);
|
||||
LinkedHashSet<String> feeItemNames = new LinkedHashSet<>(contractFeeItemNames(detail.getContractId()));
|
||||
feeItemNames.addAll(result.getFeeItemNames());
|
||||
result.setFeeItemNames(new ArrayList<>(feeItemNames));
|
||||
// 只展示本明细已生成的费用项。合同上其他计费方案的费用项不能叠到当前明细。
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1968,21 +1966,35 @@ public class ReceivablePayableDetailServiceImpl
|
||||
}
|
||||
|
||||
private List<ReceivablePayableCargoFee> calculatedFees(Waybill waybill, ContractManage contract, String planId) {
|
||||
return calculatedFees(waybill, contract, planId, false);
|
||||
return calculatedFees(waybill, contract, planId, false, false);
|
||||
}
|
||||
|
||||
private List<ReceivablePayableCargoFee> calculatedFees(Waybill waybill, ContractManage contract, String planId, boolean matchOnly) {
|
||||
private List<ReceivablePayableCargoFee> calculatedFees(Waybill waybill, ContractManage contract, String planId,
|
||||
boolean matchOnly) {
|
||||
return calculatedFees(waybill, contract, planId, matchOnly, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param strictPlan 更新费用时为 true:选定方案有规则但未命中时,不再回退运单历史费用,避免旧费用项叠加
|
||||
*/
|
||||
private List<ReceivablePayableCargoFee> calculatedFees(Waybill waybill, ContractManage contract, String planId,
|
||||
boolean matchOnly, boolean strictPlan) {
|
||||
List<Map<String, Object>> plans = parseList(contract == null ? null : contract.getBillingPlanJson());
|
||||
Map<String, Object> plan = "__matched__".equals(planId)
|
||||
? resolveDefaultBillingPlan(plans, waybill.getTransportType())
|
||||
: resolveBillingPlan(plans, planId);
|
||||
return calculatedFees(waybill, plan, matchOnly);
|
||||
return calculatedFees(waybill, plan, matchOnly, strictPlan);
|
||||
}
|
||||
|
||||
private List<ReceivablePayableCargoFee> calculatedFees(Waybill waybill, Map<String, Object> plan,
|
||||
boolean matchOnly) {
|
||||
return calculatedFees(waybill, plan, matchOnly, false);
|
||||
}
|
||||
|
||||
private List<ReceivablePayableCargoFee> calculatedFees(Waybill waybill, Map<String, Object> plan,
|
||||
boolean matchOnly, boolean strictPlan) {
|
||||
if (plan == null || !(plan.get("rules") instanceof List<?>)) {
|
||||
return matchOnly ? List.of() : buildCargoFees(waybill);
|
||||
return matchOnly || strictPlan ? List.of() : buildCargoFees(waybill);
|
||||
}
|
||||
Map<List<String>, ReceivablePayableCargoFee> feesByCargo = new LinkedHashMap<>();
|
||||
Map<List<String>, Map<String, BigDecimal>> feeItemsByCargo = new LinkedHashMap<>();
|
||||
@@ -2024,7 +2036,11 @@ public class ReceivablePayableDetailServiceImpl
|
||||
ReceivablePayableCargoFee fee = result.get(index);
|
||||
fee.setLineNo(String.format("%04d", index + 1));
|
||||
}
|
||||
return result.isEmpty() && !matchOnly ? buildCargoFees(waybill) : result;
|
||||
// 已选定计费方案时,只保留该方案算出的费用项,不用运单上的历史费用回填。
|
||||
if (strictPlan || matchOnly) {
|
||||
return result;
|
||||
}
|
||||
return result.isEmpty() ? buildCargoFees(waybill) : result;
|
||||
}
|
||||
|
||||
private ReceivablePayableCargoFee buildCalculatedCargoFee(Waybill waybill, Waybill feeWaybill,
|
||||
@@ -2934,7 +2950,7 @@ public class ReceivablePayableDetailServiceImpl
|
||||
throw new ServiceException("关联运单不存在");
|
||||
}
|
||||
ContractManage contract = contractManageService.getById(detail.getContractId());
|
||||
List<ReceivablePayableCargoFee> fees = calculatedFees(waybill, contract, billingPlanId);
|
||||
List<ReceivablePayableCargoFee> fees = calculatedFees(waybill, contract, billingPlanId, false, true);
|
||||
cargoFeeMapper.delete(Wrappers.<ReceivablePayableCargoFee>lambdaQuery().eq(ReceivablePayableCargoFee::getDetailId, detail.getId()));
|
||||
fees.forEach(fee -> { fee.setDetailId(detail.getId()); cargoFeeMapper.insert(fee); });
|
||||
BigDecimal freight = fees.stream().map(fee -> money(fee.getFreightAmount()))
|
||||
@@ -2959,7 +2975,7 @@ public class ReceivablePayableDetailServiceImpl
|
||||
ContractManage contract = contractManageService.getById(detail.getContractId());
|
||||
List<Waybill> masterGoods = masterOrderGoods(masterOrder);
|
||||
List<ReceivablePayableCargoFee> fees = masterGoods.stream()
|
||||
.flatMap(goods -> calculatedFees(goods, contract, billingPlanId).stream()).toList();
|
||||
.flatMap(goods -> calculatedFees(goods, contract, billingPlanId, false, true).stream()).toList();
|
||||
normalizeMasterFeeLines(fees);
|
||||
cargoFeeMapper.delete(Wrappers.<ReceivablePayableCargoFee>lambdaQuery()
|
||||
.eq(ReceivablePayableCargoFee::getDetailId, detail.getId()));
|
||||
@@ -2995,7 +3011,7 @@ public class ReceivablePayableDetailServiceImpl
|
||||
}
|
||||
ContractManage contract = contractManageService.getById(detail.getContractId());
|
||||
List<ReceivablePayableCargoFee> fees = waybills.stream()
|
||||
.flatMap(waybill -> calculatedFees(waybill, contract, billingPlanId).stream()).toList();
|
||||
.flatMap(waybill -> calculatedFees(waybill, contract, billingPlanId, false, true).stream()).toList();
|
||||
normalizeWaybillFeeLines(fees);
|
||||
cargoFeeMapper.delete(Wrappers.<ReceivablePayableCargoFee>lambdaQuery()
|
||||
.eq(ReceivablePayableCargoFee::getDetailId, detail.getId()));
|
||||
|
||||
+10
-22
@@ -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);
|
||||
}
|
||||
|
||||
+2
-4
@@ -246,10 +246,8 @@ public class TemporaryCreditLimitServiceImpl extends BaseServiceImpl<TemporaryCr
|
||||
TransportBusinessSupport.validateRequired(temporaryCreditLimit.getProjectCode(), "项目编号不能为空");
|
||||
TransportBusinessSupport.validateRequired(temporaryCreditLimit.getUndertakeDeptName(), "承办部门不能为空");
|
||||
TransportBusinessSupport.validateRequired(temporaryCreditLimit.getValidUntil() == null ? null : temporaryCreditLimit.getValidUntil().toString(), "请选择申请有效期至");
|
||||
if (temporaryCreditLimit.getProjectFundLimit() == null || temporaryCreditLimit.getUsedFundLimit() == null || temporaryCreditLimit.getRemainingFundLimit() == null) {
|
||||
throw new ServiceException("项目额度数据异常,请重新选择项目");
|
||||
}
|
||||
if (temporaryCreditLimit.getRemainingFundLimit().compareTo(BigDecimal.ZERO) < 0) {
|
||||
if (temporaryCreditLimit.getRemainingFundLimit() != null
|
||||
&& temporaryCreditLimit.getRemainingFundLimit().compareTo(BigDecimal.ZERO) < 0) {
|
||||
throw new ServiceException("项目剩余额度不能小于0");
|
||||
}
|
||||
}
|
||||
|
||||
+31
-1
@@ -81,6 +81,8 @@ public class TransportPlanServiceImpl extends BaseServiceImpl<TransportPlanMappe
|
||||
|
||||
@Override
|
||||
public IPage<TransportPlanVO> selectTransportPlanPage(IPage<TransportPlan> page, TransportPlanVO transportPlan) {
|
||||
// 历史手工创建未写入 dept_id,allDept=0 按组织过滤时会被排除
|
||||
backfillMissingDept(transportPlan);
|
||||
IPage<TransportPlan> entityPage = page(page, buildQuery(transportPlan));
|
||||
IPage<TransportPlanVO> resultPage = TransportPlanWrapper.build().pageVO(entityPage);
|
||||
fillCustomerNamesFromContracts(resultPage.getRecords());
|
||||
@@ -151,6 +153,12 @@ public class TransportPlanServiceImpl extends BaseServiceImpl<TransportPlanMappe
|
||||
if (created) {
|
||||
transportPlan.setPlanNo(nextCode());
|
||||
}
|
||||
// 新建或历史空组织一律落到当前组织,避免列表按 dept_id 过滤后看不到
|
||||
if (created || Func.isEmpty(transportPlan.getDeptId()) || transportPlan.getDeptId() <= 0) {
|
||||
Dept dept = TransportBusinessSupport.currentDept("运输计划");
|
||||
transportPlan.setDeptId(dept.getId());
|
||||
transportPlan.setDeptName(dept.getDeptName());
|
||||
}
|
||||
validate(transportPlan);
|
||||
return saveOrUpdate(transportPlan);
|
||||
}
|
||||
@@ -662,11 +670,33 @@ public class TransportPlanServiceImpl extends BaseServiceImpl<TransportPlanMappe
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把所属组织为空、且创建组织等于当前组织的运输计划回填 dept_id,使 allDept=0 列表能查到。
|
||||
*/
|
||||
private void backfillMissingDept(TransportPlanVO transportPlan) {
|
||||
if (Objects.equals(transportPlan.getAllDept(), 1)) {
|
||||
return;
|
||||
}
|
||||
Dept dept = TransportBusinessSupport.currentDept("运输计划");
|
||||
if (dept == null || dept.getId() == null) {
|
||||
return;
|
||||
}
|
||||
update(Wrappers.<TransportPlan>lambdaUpdate()
|
||||
.set(TransportPlan::getDeptId, dept.getId())
|
||||
.set(TransportPlan::getDeptName, dept.getDeptName())
|
||||
.isNull(TransportPlan::getDeptId)
|
||||
.eq(TransportPlan::getCreateDept, dept.getId())
|
||||
.eq(TransportPlan::getIsDeleted, 0));
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<TransportPlan> buildQuery(TransportPlanVO transportPlan) {
|
||||
TransportBusinessSupport.validateAllDept(transportPlan.getAllDept(), "运输计划");
|
||||
LambdaQueryWrapper<TransportPlan> queryWrapper = Wrappers.<TransportPlan>lambdaQuery().eq(TransportPlan::getIsDeleted, 0);
|
||||
if (!Objects.equals(transportPlan.getAllDept(), 1)) {
|
||||
queryWrapper.eq(TransportPlan::getDeptId, TransportBusinessSupport.currentDeptId("运输计划"));
|
||||
Long currentDeptId = TransportBusinessSupport.currentDeptId("运输计划");
|
||||
// dept_id 为空的历史数据按创建组织归入当前组织
|
||||
queryWrapper.and(wrapper -> wrapper.eq(TransportPlan::getDeptId, currentDeptId)
|
||||
.or(item -> item.isNull(TransportPlan::getDeptId).eq(TransportPlan::getCreateDept, currentDeptId)));
|
||||
} else if (Func.isNotEmpty(transportPlan.getDeptId())) {
|
||||
queryWrapper.eq(TransportPlan::getDeptId, transportPlan.getDeptId());
|
||||
}
|
||||
|
||||
+2
-2
@@ -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())) {
|
||||
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* 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>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 运次聚合口径:四档状态 + 接单需求 OR + 运次键
|
||||
* <p>
|
||||
* 四档与接单需求都按子运单的过程配置与接单态在这里推导,Tab 过滤与卡片状态用同一份推导,
|
||||
* 库层不再按已落库的业务状态分档——业务状态靠读取时的惰性校正维持,与过程配置不一致是常态。
|
||||
* <p>
|
||||
* 运次键与它的库层表达式只在这里声明一次:库层分组(重新派单待办)与服务层分组必须是同一个键。
|
||||
*/
|
||||
final class TripAggregation {
|
||||
|
||||
/** 运单业务状态 */
|
||||
static final String PENDING = "pending";
|
||||
static final String WAITING_DISPATCH = "waiting_dispatch";
|
||||
static final String DISPATCHING = "dispatching";
|
||||
static final String RUNNING = "running";
|
||||
static final String COMPLETED = "completed";
|
||||
static final String CANCELLED = "cancelled";
|
||||
|
||||
/** 待接单档:与运单侧 Tab「待接单」取同一组状态 */
|
||||
static final List<String> PENDING_STATUSES = List.of(PENDING, WAITING_DISPATCH, DISPATCHING);
|
||||
/** 终态档 */
|
||||
static final List<String> TERMINAL_STATUSES = List.of(COMPLETED, CANCELLED);
|
||||
/**
|
||||
* 进运次的子单状态:草稿尚未派单、不成运次;已取消要进,
|
||||
* 否则「子单全已取消」会被算成运输中而不是已取消。
|
||||
*/
|
||||
static final List<String> CHILD_STATUSES = List.of(
|
||||
PENDING, WAITING_DISPATCH, DISPATCHING, RUNNING, COMPLETED, CANCELLED
|
||||
);
|
||||
|
||||
/** 待重新派单的口径:子单被拒且这趟车还没结束 */
|
||||
static final List<String> REASSIGN_STATUSES = List.of(PENDING, RUNNING);
|
||||
|
||||
/**
|
||||
* 运次键的库层表达式:有配载按配载单号成组,未配载每张运单自成一组(W 前缀避免与配载单号撞键)。
|
||||
* 服务层换 {@link #keyOf(String, Long)} 算同一个键。
|
||||
*/
|
||||
static final String SQL_TRIP_KEY = "IFNULL(NULLIF(loading_no, ''), CONCAT('W', id))";
|
||||
|
||||
private TripAggregation() {
|
||||
}
|
||||
|
||||
/** 运次键:与 {@link #SQL_TRIP_KEY} 同一个口径 */
|
||||
static String keyOf(String loadingNo, Long id) {
|
||||
return Func.isEmpty(loadingNo) ? "W" + id : loadingNo;
|
||||
}
|
||||
|
||||
/** 运次的四档状态:0 待接单 / 1 运输中 / 2 已完成 / 3 已取消 */
|
||||
static Integer appStatus(String status) {
|
||||
return switch (Func.toStr(status, "")) {
|
||||
case PENDING -> 0;
|
||||
case RUNNING -> 1;
|
||||
case COMPLETED -> 2;
|
||||
case CANCELLED -> 3;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次状态:终态优先——子单全为终态时有已完成取已完成、否则已取消;
|
||||
* 否则需接单且未接单取待接单,其余取运输中。
|
||||
* <p>
|
||||
* 子单集合为空时无从判定,返回空。
|
||||
*/
|
||||
static String statusOf(List<Waybill> children, boolean requireAccept, boolean accepted) {
|
||||
String status = classify(count(children));
|
||||
if (status == null || TERMINAL_STATUSES.contains(status)) {
|
||||
return status;
|
||||
}
|
||||
if (!requireAccept) {
|
||||
return RUNNING;
|
||||
}
|
||||
return accepted ? RUNNING : PENDING;
|
||||
}
|
||||
|
||||
/**
|
||||
* 子单聚合结果:子单总数、终态数、已完成数、待接单档数
|
||||
*/
|
||||
static Counters count(List<Waybill> children) {
|
||||
int total = 0;
|
||||
int terminal = 0;
|
||||
int completed = 0;
|
||||
int pending = 0;
|
||||
for (Waybill child : children) {
|
||||
String status = child == null ? null : child.getBusinessStatus();
|
||||
total++;
|
||||
if (status != null && TERMINAL_STATUSES.contains(status)) {
|
||||
terminal++;
|
||||
}
|
||||
if (COMPLETED.equals(status)) {
|
||||
completed++;
|
||||
}
|
||||
if (status != null && PENDING_STATUSES.contains(status)) {
|
||||
pending++;
|
||||
}
|
||||
}
|
||||
return new Counters(total, terminal, completed, pending);
|
||||
}
|
||||
|
||||
private static String classify(Counters counters) {
|
||||
if (counters.total() == 0) {
|
||||
return null;
|
||||
}
|
||||
if (counters.terminal() == counters.total()) {
|
||||
return counters.completed() > 0 ? COMPLETED : CANCELLED;
|
||||
}
|
||||
return counters.pending() > 0 ? PENDING : RUNNING;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次是否需要接单:任一子运单的过程配置有启用的接单节点即为是。
|
||||
* <p>
|
||||
* 跨项目的运次常遇到「要接单的票」与「不要接单的票」混在一起,按 OR 合并后整单按需接单处理。
|
||||
*/
|
||||
static boolean requiresAccept(List<Waybill> children) {
|
||||
return children.stream()
|
||||
.anyMatch(child -> child != null
|
||||
&& WaybillProcessSupport.requiresDriverAcceptConfirmation(child.getProcessJson()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次接单态:任一子单已接单即为已接单(一次接单把整趟车的子单置为已接单),
|
||||
* 否则有子单待接单为待接单,无从判定时为空。
|
||||
*/
|
||||
static String acceptStatus(List<Waybill> children) {
|
||||
boolean anyPending = false;
|
||||
for (Waybill child : children) {
|
||||
String status = child == null ? null : child.getDriverAcceptStatus();
|
||||
if (WaybillProcessSupport.ACCEPT_ACCEPTED.equals(status)) {
|
||||
return WaybillProcessSupport.ACCEPT_ACCEPTED;
|
||||
}
|
||||
if (WaybillProcessSupport.ACCEPT_PENDING.equals(status)) {
|
||||
anyPending = true;
|
||||
}
|
||||
}
|
||||
return anyPending ? WaybillProcessSupport.ACCEPT_PENDING : null;
|
||||
}
|
||||
|
||||
/** 子单聚合计数 */
|
||||
record Counters(int total, int terminal, int completed, int pending) {
|
||||
}
|
||||
|
||||
}
|
||||
+849
@@ -0,0 +1,849 @@
|
||||
/**
|
||||
* 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>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.pojo.entity.LoadingManage;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.CargoLineVO;
|
||||
import org.springblade.transport.pojo.vo.TripQueryVO;
|
||||
import org.springblade.transport.pojo.vo.TripStationVO;
|
||||
import org.springblade.transport.pojo.vo.TripTabCountsVO;
|
||||
import org.springblade.transport.pojo.vo.TripVO;
|
||||
import org.springblade.transport.service.ILoadingManageService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.ITripQueryService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.LiveProcessConfigSupport;
|
||||
import org.springblade.transport.support.WaybillCargoSupport;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 运次读服务实现
|
||||
* <p>
|
||||
* 取数两段,段内都是批量查询:段一一次取回收口与行级筛选命中的可见运单行,分组、分档、排序与分页
|
||||
* 都在服务层算完再做;段二按这一页运次的配载单号批量取配载单。子运单不再回查——段一拿回来的行就是它们。
|
||||
* <p>
|
||||
* 分档只按过程配置与接单态推导,与卡片状态同一份口径:库里的业务状态靠读取时的惰性校正维持,
|
||||
* 拿它分档会让「归在待接单的运次、卡片显示运输中」。分页因此必须在过滤后的完整运次序列上做,
|
||||
* 先分页再过滤会把该在第一页的运次挤到后面几页。
|
||||
* <p>
|
||||
* 可见运次=派给该司机的未配载运单(每张自成运次),加上配载单派给该司机、且至少有一张可见子运单的运次。
|
||||
* 司机侧可见范围与运单列表同口径:司机手机号命中且未被该司机拒单;子单里已拒单的不进运次,
|
||||
* 于是整趟车被拒单后整个运次自然消失。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TripQueryServiceImpl implements ITripQueryService {
|
||||
|
||||
private static final int DEFAULT_PAGE_SIZE = 10;
|
||||
private static final int MAX_PAGE_SIZE = 50;
|
||||
|
||||
/** 运输组织类型:有配载单为公路配载,否则普通运输 */
|
||||
private static final String TRANSPORT_LOAD = "load";
|
||||
private static final String TRANSPORT_COMMON = "common";
|
||||
|
||||
/** 未配载运次的键前缀:与 TripAggregation.SQL_TRIP_KEY 的 W 前缀同一个口径 */
|
||||
private static final String LONE_KEY_PREFIX = "W";
|
||||
|
||||
/** 异常筛选的取值:命中未完成异常的子单 */
|
||||
private static final String EXCEPTION_YES = "exception";
|
||||
private static final String EXCEPTION_NO = "normal";
|
||||
|
||||
/** 关键字在子单上匹配的字段 */
|
||||
private static final String[] KEYWORD_COLUMNS = {
|
||||
"waybill_no", "loading_no", "departure_name", "arrival_name",
|
||||
"departure_address", "arrival_address", "vehicle_no"
|
||||
};
|
||||
|
||||
private final IWaybillService waybillService;
|
||||
private final ILoadingManageService loadingManageService;
|
||||
private final IProcessConfigService processConfigService;
|
||||
|
||||
@Override
|
||||
public IPage<TripVO> page(TripQueryVO query) {
|
||||
TripQueryVO condition = condition(query);
|
||||
int pageNo = pageNo(condition.getCurrent());
|
||||
int pageSize = pageSize(condition.getSize());
|
||||
if (!scoped(condition)) {
|
||||
// 收口缺失时返回空页,不能退回「不按司机收口」:那等于把别人的运次发出去
|
||||
return new Page<>(pageNo, pageSize);
|
||||
}
|
||||
List<TripRows> trips = trips(condition);
|
||||
Page<TripVO> page = new Page<>(pageNo, pageSize, trips.size());
|
||||
int from = Math.min((pageNo - 1) * pageSize, trips.size());
|
||||
int to = Math.min(from + pageSize, trips.size());
|
||||
page.setRecords(assemble(trips.subList(from, to)));
|
||||
return page;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TripTabCountsVO tabCounts(TripQueryVO query) {
|
||||
TripQueryVO condition = condition(query);
|
||||
TripTabCountsVO counts = new TripTabCountsVO();
|
||||
if (!scoped(condition)) {
|
||||
return counts;
|
||||
}
|
||||
// 一次取数算清四档:分档口径与列表、卡片同一份推导,不再按库里的业务状态分批计数
|
||||
long pending = 0;
|
||||
long doing = 0;
|
||||
long done = 0;
|
||||
for (TripRows trip : trips(withoutTab(condition))) {
|
||||
Integer tier = trip.tier();
|
||||
if (tier == null) {
|
||||
continue;
|
||||
}
|
||||
switch (tier) {
|
||||
case 0 -> pending++;
|
||||
case 1 -> doing++;
|
||||
case 2 -> done++;
|
||||
default -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
counts.setPending(pending);
|
||||
counts.setDoing(doing);
|
||||
counts.setDone(done);
|
||||
counts.setAll(pending + doing + done);
|
||||
return counts;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TripVO detail(TripQueryVO query) {
|
||||
TripQueryVO condition = condition(query);
|
||||
Long id = tripId(condition.getTripId());
|
||||
if (id == null || !scoped(condition)) {
|
||||
throw new ServiceException("运次不存在或无权操作");
|
||||
}
|
||||
LoadingManage loading = visibleLoading(id, condition);
|
||||
if (loading != null) {
|
||||
List<Waybill> children = loadChildren(Set.of(loading.getLoadingNo()), Set.of())
|
||||
.getOrDefault(loading.getLoadingNo(), List.of());
|
||||
if (children.isEmpty()) {
|
||||
// 子单全部被拒单(或已释放)的配载运次对司机不可见,与列表口径一致
|
||||
throw new ServiceException("运次不存在或无权操作");
|
||||
}
|
||||
// 详情与列表同一份分档口径:接单需求也按项目生效中的配置判,否则列表说待接单、详情与接收入口说无需接单
|
||||
applyLiveProcessConfig(children);
|
||||
return loadingTrip(loading, children, true);
|
||||
}
|
||||
Waybill waybill = visibleWaybill(id, condition);
|
||||
if (waybill == null) {
|
||||
throw new ServiceException("运次不存在或无权操作");
|
||||
}
|
||||
applyLiveProcessConfig(List.of(waybill));
|
||||
return waybillTrip(waybill, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部可见运次:一次取回运单行后在服务层分组、分档、筛选并按排序键倒序
|
||||
* <p>
|
||||
* 分档用过程配置与接单态推导,于是 Tab 与卡片必然同档;行级筛选(组织类型 / 异常 / 创建时间)落在库层,
|
||||
* 命中的行同时决定这一趟车的子单集合与分档。
|
||||
*/
|
||||
private List<TripRows> trips(TripQueryVO condition) {
|
||||
Map<String, List<Waybill>> grouped = new LinkedHashMap<>();
|
||||
for (Waybill row : visibleRows(condition)) {
|
||||
grouped.computeIfAbsent(TripAggregation.keyOf(row.getLoadingNo(), row.getId()),
|
||||
ignored -> new ArrayList<>()).add(row);
|
||||
}
|
||||
Set<String> excluded = condition.isExcludeRejectedTrips() ? rejectedTripKeys() : Set.of();
|
||||
Set<Integer> tiers = tiers(condition);
|
||||
List<TripRows> trips = new ArrayList<>(grouped.size());
|
||||
grouped.forEach((key, rows) -> {
|
||||
if (excluded.contains(key)) {
|
||||
return;
|
||||
}
|
||||
List<Waybill> children = sortedChildren(rows);
|
||||
if (!inScope(children, condition)) {
|
||||
return;
|
||||
}
|
||||
boolean requireAccept = TripAggregation.requiresAccept(children);
|
||||
boolean accepted = WaybillProcessSupport.ACCEPT_ACCEPTED.equals(TripAggregation.acceptStatus(children));
|
||||
String status = TripAggregation.statusOf(children, requireAccept, accepted);
|
||||
Integer tier = TripAggregation.appStatus(status);
|
||||
if (tier == null || !tiers.contains(tier)) {
|
||||
return;
|
||||
}
|
||||
trips.add(new TripRows(key, rows.get(0).getLoadingNo(), children, status, tier, maxCreateTime(children)));
|
||||
});
|
||||
// 排序键是子单 create_time 的最大值,倒序;键相同再按运次键定序,免得同分页里顺序漂移
|
||||
trips.sort(Comparator.comparing(TripRows::sortTime, Comparator.nullsLast(Comparator.reverseOrder()))
|
||||
.thenComparing(TripRows::key));
|
||||
return trips;
|
||||
}
|
||||
|
||||
/** 子单按创建时刻升序:站点拼装与货物汇总都按这个顺序 */
|
||||
private List<Waybill> sortedChildren(List<Waybill> rows) {
|
||||
List<Waybill> children = new ArrayList<>(rows);
|
||||
children.sort(Comparator.comparing(Waybill::getCreateTime, Comparator.nullsLast(Comparator.naturalOrder())));
|
||||
return children;
|
||||
}
|
||||
|
||||
/**
|
||||
* 段一:一次取回该收口与行级筛选命中的可见运单行
|
||||
* <p>
|
||||
* 只 select 拼装与分档用得上的列:运单表上有几张大 JSON 列,整行拖回不值得。
|
||||
* goods_json 必须随行取回——卡片上的货名、货物汇总与合计重量都以它为准,一张运单可以录多条货物,
|
||||
* 主表 cargo_name 只落了第一行。可见口径与运单列表同一条:状态进运次的才取,被该司机拒单的不取。
|
||||
*/
|
||||
private List<Waybill> visibleRows(TripQueryVO condition) {
|
||||
LambdaQueryWrapper<Waybill> wrapper = Wrappers.<Waybill>lambdaQuery()
|
||||
.select(Waybill::getId, Waybill::getLoadingNo, Waybill::getWaybillNo, Waybill::getBusinessStatus,
|
||||
Waybill::getDriverAcceptStatus, Waybill::getProcessJson, Waybill::getProjectId, Waybill::getProjectName,
|
||||
Waybill::getCargoName, Waybill::getCargoType, Waybill::getQuantity, Waybill::getQuantityUnit,
|
||||
// 多货物运单的主表字段只有首行,货物明细只能从 goods_json 拿
|
||||
Waybill::getGoodsJson,
|
||||
Waybill::getDepartureName, Waybill::getDepartureAddress, Waybill::getArrivalName,
|
||||
Waybill::getArrivalAddress, Waybill::getDriverName, Waybill::getDriverPhone, Waybill::getVehicleNo,
|
||||
Waybill::getCustomerName, Waybill::getTransportType, Waybill::getTaskRemark,
|
||||
// 未配载运次没有配载单可依,计划时间要取运单自己的,故必须随行取回
|
||||
Waybill::getEstimatedStartTime, Waybill::getEstimatedEndTime, Waybill::getCreateTime)
|
||||
.in(Waybill::getBusinessStatus, TripAggregation.CHILD_STATUSES)
|
||||
.and(visible -> visible.isNull(Waybill::getDriverAcceptStatus)
|
||||
.or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED));
|
||||
applyScope(wrapper, condition);
|
||||
applyDeptScope(wrapper, condition);
|
||||
applyOrgTypeFilter(wrapper, condition);
|
||||
applyKeyword(wrapper, condition.getKeyword());
|
||||
List<Waybill> rows = waybillService.list(wrapper);
|
||||
applyLiveProcessConfig(rows);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组织收口:条件里给了组织 ID 集合时只出这些组织的运单行,空集合表示不过滤。
|
||||
* <p>
|
||||
* 与司机手机号收口一样落在库层的运单行上:分档、分页、总数都由它缩过的行集合算出来。
|
||||
* 读服务不自己解析当前用户的组织——它同时服务司机端与管理端,可见范围由调用方算好再传。
|
||||
*/
|
||||
private void applyDeptScope(LambdaQueryWrapper<Waybill> wrapper, TripQueryVO condition) {
|
||||
if (Func.isEmpty(condition.getDeptIds())) {
|
||||
return;
|
||||
}
|
||||
wrapper.in(Waybill::getDeptId, condition.getDeptIds());
|
||||
}
|
||||
|
||||
/**
|
||||
* 运输组织类型按子单行筛:它在同一趟车里必然是同一个值(配载的每张子单带同一个配载单号,
|
||||
* 未配载的每张自成一趟车),所以这一层不会把一趟车拆开。
|
||||
*/
|
||||
private void applyOrgTypeFilter(LambdaQueryWrapper<Waybill> wrapper, TripQueryVO condition) {
|
||||
if (TRANSPORT_LOAD.equals(condition.getTransportOrgType())) {
|
||||
wrapper.apply("IFNULL(loading_no, '') <> ''");
|
||||
} else if (TRANSPORT_COMMON.equals(condition.getTransportOrgType())) {
|
||||
wrapper.apply("IFNULL(loading_no, '') = ''");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 这趟车在不在筛选范围内:异常按「任一子单命中」,创建时间按子单 create_time 的最大值(运次列表的排序键,
|
||||
* 也是调度端卡片上显示的创建时间)。两个判据都按整趟车的子单集合算——先按筛选把子单集合缩小再分档,
|
||||
* 会让同一趟车归在某一档、卡片却显示另一档。
|
||||
* <p>
|
||||
* 筛选只决定这趟车在不在范围内,分档仍看它完整的可见子单集合。
|
||||
*/
|
||||
private boolean inScope(List<Waybill> children, TripQueryVO condition) {
|
||||
Set<Long> exceptionIds = condition.getExceptionWaybillIds();
|
||||
if (Func.isNotEmpty(exceptionIds)) {
|
||||
boolean hit = children.stream().map(Waybill::getId).anyMatch(exceptionIds::contains);
|
||||
if (EXCEPTION_YES.equals(condition.getException()) && !hit) {
|
||||
return false;
|
||||
}
|
||||
if (EXCEPTION_NO.equals(condition.getException()) && hit) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Date created = maxCreateTime(children);
|
||||
if (condition.getCreatedFrom() != null
|
||||
&& (created == null || created.before(condition.getCreatedFrom()))) {
|
||||
return false;
|
||||
}
|
||||
return condition.getCreatedTo() == null
|
||||
|| (created != null && !created.after(condition.getCreatedTo()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按单票路径的口径把生效中的过程配置写回内存里的行:项目启用中的配置优先,它没有可打卡节点时回退运单快照。
|
||||
* <p>
|
||||
* 只改内存里的对象,分档与卡片下发都读它,不落库;生效配置一次取回后按项目复用,不逐条回查。
|
||||
* 取法与二选一规则都在 {@link LiveProcessConfigSupport},这里只做批量写回。
|
||||
*/
|
||||
private void applyLiveProcessConfig(List<Waybill> rows) {
|
||||
if (Func.isEmpty(rows)) {
|
||||
return;
|
||||
}
|
||||
Set<Long> projectIds = rows.stream().map(Waybill::getProjectId).filter(Objects::nonNull)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
if (projectIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<Long, String> live = LiveProcessConfigSupport.byProjects(processConfigService, projectIds);
|
||||
if (live.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (Waybill row : rows) {
|
||||
String liveJson = live.get(row.getProjectId());
|
||||
if (Func.isNotEmpty(liveJson)) {
|
||||
row.setProcessJson(LiveProcessConfigSupport.resolveForPunch(liveJson, row.getProcessJson()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 有子单被拒、且这趟车还没结束的运次键:整单拒绝后这趟车不再算待处理
|
||||
*/
|
||||
private Set<String> rejectedTripKeys() {
|
||||
return waybillService.list(Wrappers.<Waybill>lambdaQuery()
|
||||
.select(Waybill::getId, Waybill::getLoadingNo)
|
||||
.eq(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED)
|
||||
.in(Waybill::getBusinessStatus, TripAggregation.REASSIGN_STATUSES))
|
||||
.stream()
|
||||
.map(row -> TripAggregation.keyOf(row.getLoadingNo(), row.getId()))
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
/**
|
||||
* 要哪几档:多档优先,其次单档,都不传按「全部」=待接单/进行中/已完成(不含已取消)
|
||||
*/
|
||||
private Set<Integer> tiers(TripQueryVO condition) {
|
||||
if (Func.isNotEmpty(condition.getStatuses())) {
|
||||
return new LinkedHashSet<>(condition.getStatuses());
|
||||
}
|
||||
if (condition.getStatus() != null) {
|
||||
return Set.of(condition.getStatus());
|
||||
}
|
||||
return Set.of(0, 1, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次可见范围:配载段按配载单的司机手机号收口,未配载段按运单的司机手机号收口。
|
||||
* <p>
|
||||
* 手机号允许带空格,与运单列表的收口判据一致。
|
||||
*/
|
||||
private void applyScope(LambdaQueryWrapper<Waybill> wrapper, TripQueryVO condition) {
|
||||
if (condition.isAllDrivers()) {
|
||||
return;
|
||||
}
|
||||
String phone = Func.toStr(condition.getDriverPhone(), "").trim();
|
||||
wrapper.and(scope -> scope
|
||||
.apply("(IFNULL(loading_no, '') <> '' AND loading_no IN (SELECT lm.loading_no FROM blade_loading_manage lm"
|
||||
+ " WHERE lm.is_deleted = 0 AND lm.driver_phone = {0}))", phone)
|
||||
.or(lone -> lone
|
||||
.apply("IFNULL(loading_no, '') = ''")
|
||||
.and(row -> row.eq(Waybill::getDriverPhone, phone)
|
||||
.or().apply("REPLACE(IFNULL(driver_phone, ''), ' ', '') = {0}", phone))));
|
||||
}
|
||||
|
||||
/**
|
||||
* 关键字按运次匹配:任一子单或配载单号命中即整趟车命中。
|
||||
* <p>
|
||||
* 先按关键字查出命中的运次键,再要求这一行的所属运次在其中——关键字不缩小分组内的子单集合,
|
||||
* 否则状态与排序会按命中的那一部分子单算。
|
||||
*/
|
||||
private void applyKeyword(LambdaQueryWrapper<Waybill> wrapper, String keyword) {
|
||||
if (Func.isEmpty(keyword)) {
|
||||
return;
|
||||
}
|
||||
Set<String> keys = keywordTripKeys(keyword.trim());
|
||||
if (keys.isEmpty()) {
|
||||
wrapper.apply("1 = 0");
|
||||
return;
|
||||
}
|
||||
Set<String> loadingNos = keys.stream().filter(key -> !key.startsWith(LONE_KEY_PREFIX))
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
Set<Long> loneIds = keys.stream().filter(key -> key.startsWith(LONE_KEY_PREFIX))
|
||||
.map(key -> key.substring(LONE_KEY_PREFIX.length()))
|
||||
.map(this::tripId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
wrapper.and(matched -> {
|
||||
if (!loadingNos.isEmpty()) {
|
||||
matched.in(Waybill::getLoadingNo, loadingNos);
|
||||
}
|
||||
if (!loneIds.isEmpty()) {
|
||||
if (!loadingNos.isEmpty()) {
|
||||
matched.or();
|
||||
}
|
||||
matched.apply("IFNULL(loading_no, '') = ''").in(Waybill::getId, loneIds);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 关键字命中的运次键:在子单上按关键字找一遍,再换算成运次键 */
|
||||
private Set<String> keywordTripKeys(String keyword) {
|
||||
String like = "%" + keyword + "%";
|
||||
Object[] params = new Object[KEYWORD_COLUMNS.length];
|
||||
StringBuilder matched = new StringBuilder();
|
||||
for (int i = 0; i < KEYWORD_COLUMNS.length; i++) {
|
||||
if (i > 0) {
|
||||
matched.append(" OR ");
|
||||
}
|
||||
matched.append(KEYWORD_COLUMNS[i]).append(" LIKE {").append(i).append('}');
|
||||
params[i] = like;
|
||||
}
|
||||
return waybillService.list(Wrappers.<Waybill>lambdaQuery()
|
||||
.select(Waybill::getId, Waybill::getLoadingNo)
|
||||
.apply("(" + matched + ")", params))
|
||||
.stream()
|
||||
.map(row -> TripAggregation.keyOf(row.getLoadingNo(), row.getId()))
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把这一页的运次组装成运次:先批量取配载单,再逐趟拼装
|
||||
* <p>
|
||||
* 子运单就是段一取回来的那些行,不再回查:回查会拿回未经行级筛选的子单,
|
||||
* 卡片的状态与货物汇总就会与分档、与筛选结果对不上。
|
||||
*/
|
||||
private List<TripVO> assemble(List<TripRows> trips) {
|
||||
if (Func.isEmpty(trips)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
Set<String> loadingNos = trips.stream()
|
||||
.map(TripRows::loadingNo)
|
||||
.filter(Func::isNotEmpty)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
Map<String, LoadingManage> loadings = loadLoadings(loadingNos);
|
||||
List<TripVO> result = new ArrayList<>(trips.size());
|
||||
for (TripRows trip : trips) {
|
||||
if (Func.isEmpty(trip.loadingNo())) {
|
||||
result.add(waybillTrip(trip.children().get(0), false));
|
||||
continue;
|
||||
}
|
||||
LoadingManage loading = loadings.get(trip.loadingNo());
|
||||
if (loading == null) {
|
||||
// 配载单被并发删除:不给出没有主键的运次
|
||||
continue;
|
||||
}
|
||||
result.add(loadingTrip(loading, trip.children(), false));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 段二:按本页运次的配载单号一次取回配载单
|
||||
*/
|
||||
private Map<String, LoadingManage> loadLoadings(Set<String> loadingNos) {
|
||||
if (loadingNos.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
return loadingManageService.list(Wrappers.<LoadingManage>lambdaQuery()
|
||||
.in(LoadingManage::getLoadingNo, loadingNos))
|
||||
.stream()
|
||||
.collect(Collectors.toMap(LoadingManage::getLoadingNo, Function.identity(), (first, ignored) -> first));
|
||||
}
|
||||
|
||||
/**
|
||||
* 段二:按本页运次的配载单号与未配载运单主键一次取回子运单,不逐条查
|
||||
* <p>
|
||||
* 子单归属一律以配载单号为准:库层就是按配载单号分组聚合的,服务层复核用别的关联关系,
|
||||
* 同一页里就会出现「Tab 说它在进行中、卡片按子单算出来是待接单」。
|
||||
* 配载单上的运单号集合只是派单时的快照,子单被释放(配载单号清空)后不再属于该运次。
|
||||
*/
|
||||
private Map<String, List<Waybill>> loadChildren(Set<String> loadingNos, Set<Long> loneIds) {
|
||||
if (loadingNos.isEmpty() && loneIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
List<Waybill> children = waybillService.list(Wrappers.<Waybill>lambdaQuery()
|
||||
.in(Waybill::getBusinessStatus, TripAggregation.CHILD_STATUSES)
|
||||
.and(visible -> visible.isNull(Waybill::getDriverAcceptStatus)
|
||||
.or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED))
|
||||
.and(scope -> scope.in(Func.isNotEmpty(loadingNos), Waybill::getLoadingNo, loadingNos)
|
||||
.or(Func.isNotEmpty(loneIds), lone -> lone.in(Waybill::getId, loneIds))));
|
||||
Map<String, List<Waybill>> grouped = new LinkedHashMap<>();
|
||||
for (Waybill child : children) {
|
||||
String key = Func.isEmpty(child.getLoadingNo()) ? String.valueOf(child.getId()) : child.getLoadingNo();
|
||||
grouped.computeIfAbsent(key, ignored -> new ArrayList<>()).add(child);
|
||||
}
|
||||
grouped.values().forEach(group -> group.sort(
|
||||
Comparator.comparing(Waybill::getCreateTime, Comparator.nullsLast(Comparator.naturalOrder()))));
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配载运次:司机、车辆、路线取配载单(配载单是运输执行主体),状态与接单需求由子单聚合
|
||||
*/
|
||||
private TripVO loadingTrip(LoadingManage loading, List<Waybill> children, boolean withPayload) {
|
||||
TripVO trip = new TripVO();
|
||||
trip.setId(tripIdOf(loading.getId()));
|
||||
trip.setLoadingNo(loading.getLoadingNo());
|
||||
trip.setTripNo(loading.getLoadingNo());
|
||||
trip.setTransportOrgType(TRANSPORT_LOAD);
|
||||
trip.setDriverName(Func.toStr(loading.getDriverName(), ""));
|
||||
trip.setDriverPhone(Func.toStr(loading.getDriverPhone(), ""));
|
||||
trip.setVehicleNo(Func.toStr(loading.getVehicleNo(), ""));
|
||||
trip.setTrailerVehicleNo(Func.toStr(loading.getTrailerVehicleNo(), ""));
|
||||
trip.setProjectName(Func.toStr(loading.getProjectName(), ""));
|
||||
trip.setCustomerName(Func.toStr(loading.getCustomerName(), ""));
|
||||
trip.setTransportType(Func.toStr(loading.getTransportType(), ""));
|
||||
trip.setCargoName(firstNotEmpty(loading.getCargoName(), children, Waybill::getCargoName));
|
||||
trip.setCargoType(firstNotEmpty(loading.getCargoType(), children, Waybill::getCargoType));
|
||||
trip.setDepartureAddress(Func.toStr(loading.getDepartureAddress(), ""));
|
||||
trip.setTransitAddress(Func.toStr(loading.getTransitAddress(), ""));
|
||||
trip.setArrivalAddress(Func.toStr(loading.getArrivalAddress(), ""));
|
||||
trip.setEstimatedStartDate(loading.getEstimatedStartDate());
|
||||
trip.setEstimatedEndDate(loading.getEstimatedEndDate());
|
||||
trip.setTaskRemark(Func.toStr(loading.getTaskRemark(), ""));
|
||||
if (withPayload) {
|
||||
trip.setRouteJson(loading.getRouteJson());
|
||||
trip.setGoodsJson(loading.getGoodsJson());
|
||||
}
|
||||
fillAggregation(trip, children);
|
||||
fillRouteAndCargo(trip, loading, children, withPayload);
|
||||
return trip;
|
||||
}
|
||||
|
||||
/**
|
||||
* 未配载运次:整张运单就是这一趟车
|
||||
*/
|
||||
private TripVO waybillTrip(Waybill waybill, boolean withPayload) {
|
||||
TripVO trip = new TripVO();
|
||||
trip.setId(tripIdOf(waybill.getId()));
|
||||
trip.setTripNo(waybill.getWaybillNo());
|
||||
trip.setTransportOrgType(TRANSPORT_COMMON);
|
||||
trip.setDriverName(Func.toStr(waybill.getDriverName(), ""));
|
||||
trip.setDriverPhone(Func.toStr(waybill.getDriverPhone(), ""));
|
||||
trip.setVehicleNo(Func.toStr(waybill.getVehicleNo(), ""));
|
||||
trip.setProjectName(Func.toStr(waybill.getProjectName(), ""));
|
||||
trip.setCustomerName(Func.toStr(waybill.getCustomerName(), ""));
|
||||
trip.setTransportType(Func.toStr(waybill.getTransportType(), ""));
|
||||
trip.setCargoName(Func.toStr(waybill.getCargoName(), ""));
|
||||
trip.setCargoType(Func.toStr(waybill.getCargoType(), ""));
|
||||
trip.setDepartureAddress(Func.toStr(waybill.getDepartureAddress(), ""));
|
||||
trip.setArrivalAddress(Func.toStr(waybill.getArrivalAddress(), ""));
|
||||
// 未配载没有配载单可依,计划时间只能取这张运单自己的
|
||||
trip.setEstimatedStartDate(waybill.getEstimatedStartTime());
|
||||
trip.setEstimatedEndDate(waybill.getEstimatedEndTime());
|
||||
trip.setTaskRemark(Func.toStr(waybill.getTaskRemark(), ""));
|
||||
if (withPayload) {
|
||||
trip.setRouteJson(waybill.getRouteJson());
|
||||
trip.setGoodsJson(waybill.getGoodsJson());
|
||||
}
|
||||
fillAggregation(trip, List.of(waybill));
|
||||
fillRouteAndCargo(trip, null, List.of(waybill), withPayload);
|
||||
return trip;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚合出运次的状态、接单需求、接单态与排序键:与分档用同一份推导,Tab 与卡片因此必然同档
|
||||
*/
|
||||
private void fillAggregation(TripVO trip, List<Waybill> children) {
|
||||
String acceptStatus = TripAggregation.acceptStatus(children);
|
||||
boolean requireAccept = TripAggregation.requiresAccept(children);
|
||||
boolean accepted = WaybillProcessSupport.ACCEPT_ACCEPTED.equals(acceptStatus);
|
||||
trip.setAcceptStatus(acceptStatus);
|
||||
trip.setRequireAccept(requireAccept);
|
||||
trip.setStatus(TripAggregation.appStatus(TripAggregation.statusOf(children, requireAccept, accepted)));
|
||||
trip.setWaybillCount(children.size());
|
||||
trip.setSortTime(maxCreateTime(children));
|
||||
trip.setWaybills(children.stream().map(this::toWaybillVO).toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点与货物都按整趟车算:站点明细只在详情下发,列表带走起/经/终、站点数与货物汇总
|
||||
*/
|
||||
private void fillRouteAndCargo(TripVO trip, LoadingManage loading, List<Waybill> children, boolean withPayload) {
|
||||
List<TripStationVO> stations = TripStationAssembler.assemble(loading, children);
|
||||
trip.setDepartureName(TripStationAssembler.departureName(stations));
|
||||
trip.setTransitName(TripStationAssembler.transitName(stations));
|
||||
trip.setArrivalName(TripStationAssembler.arrivalName(stations));
|
||||
trip.setStationCount(stations.size());
|
||||
if (withPayload) {
|
||||
trip.setStations(stations);
|
||||
}
|
||||
List<WaybillCargoSupport.Cargo> cargos = children.stream()
|
||||
.flatMap(child -> WaybillCargoSupport.of(child).stream())
|
||||
.toList();
|
||||
List<String> cargoNames = WaybillCargoSupport.cargoNames(cargos);
|
||||
// 运次级货名覆盖整趟车:主表 cargo_name 只是首行,配载单的 cargo_name 也是派单时的快照
|
||||
trip.setCargoNames(cargoNames);
|
||||
if (!cargoNames.isEmpty()) {
|
||||
trip.setCargoName(String.join("、", cargoNames));
|
||||
}
|
||||
List<String> cargoTypes = cargos.stream()
|
||||
.map(WaybillCargoSupport.Cargo::cargoType)
|
||||
.filter(Func::isNotEmpty)
|
||||
.distinct()
|
||||
.toList();
|
||||
trip.setCargoTypes(cargoTypes);
|
||||
if (!cargoTypes.isEmpty()) {
|
||||
trip.setCargoType(String.join("、", cargoTypes));
|
||||
}
|
||||
trip.setCargoSummary(cargoSummary(children));
|
||||
trip.setCargos(cargoLines(cargos));
|
||||
fillTotalQuantity(trip, children);
|
||||
fillWeighing(trip, cargos);
|
||||
}
|
||||
|
||||
/**
|
||||
* 货物汇总:同名货物合并且单位一致时取数量合计,没有数量的只列货名
|
||||
* <p>
|
||||
* 一条运单可以有多条货物,明细在 goods_json;主表 cargo_name 只落了第一行,拿它汇总等于把其余货物丢掉。
|
||||
*/
|
||||
private String cargoSummary(List<Waybill> children) {
|
||||
Map<String, Map<String, BigDecimal>> grouped = new LinkedHashMap<>();
|
||||
for (Waybill child : children) {
|
||||
List<WaybillCargoSupport.Cargo> cargos = WaybillCargoSupport.of(child);
|
||||
if (cargos.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
for (WaybillCargoSupport.Cargo cargo : cargos) {
|
||||
String name = cargo.displayName();
|
||||
String unit = Func.toStr(cargo.quantityUnit(), "");
|
||||
Map<String, BigDecimal> byUnit = grouped.computeIfAbsent(name, key -> new LinkedHashMap<>());
|
||||
BigDecimal quantity = cargo.quantity() == null ? BigDecimal.ZERO : cargo.quantity();
|
||||
byUnit.merge(unit, quantity, BigDecimal::add);
|
||||
}
|
||||
}
|
||||
List<String> parts = new ArrayList<>();
|
||||
grouped.forEach((name, byUnit) -> byUnit.forEach((unit, quantity) ->
|
||||
parts.add(quantity.signum() == 0 ? name : name + " " + formatQuantity(quantity, unit))));
|
||||
return String.join("、", parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 货物明细行:与 cargoSummary 同一套合并口径,但数量与单位分开留,供详情页逐行展示
|
||||
*/
|
||||
private List<CargoLineVO> cargoLines(List<WaybillCargoSupport.Cargo> cargos) {
|
||||
List<CargoLineVO> lines = new ArrayList<>();
|
||||
for (WaybillCargoSupport.Line line : WaybillCargoSupport.lines(cargos)) {
|
||||
CargoLineVO vo = new CargoLineVO();
|
||||
vo.setCargoName(line.name());
|
||||
vo.setQuantity(line.quantity() == null ? "" : formatQuantity(line.quantity(), line.unit()));
|
||||
lines.add(vo);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合计重量:只有全部货物同单位时才能相加,单位不一致时留空(汇总文案里仍分单位列出)
|
||||
* <p>
|
||||
* 按货物行算而不是按运单行算:一张运单里的多条货物可能是不同的单位与数量,
|
||||
* 主表 quantity 是这些行的合计、quantity_unit 只是首行的单位,拿它当合计会把「20堆 + 50方」说成「70堆」。
|
||||
*/
|
||||
private void fillTotalQuantity(TripVO trip, List<Waybill> children) {
|
||||
String unit = null;
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
boolean any = false;
|
||||
for (Waybill child : children) {
|
||||
for (WaybillCargoSupport.Cargo cargo : WaybillCargoSupport.of(child)) {
|
||||
String cargoUnit = Func.toStr(cargo.quantityUnit(), "");
|
||||
if (cargo.quantity() == null || Func.isEmpty(cargoUnit)) {
|
||||
return;
|
||||
}
|
||||
if (unit == null) {
|
||||
unit = cargoUnit;
|
||||
} else if (!unit.equals(cargoUnit)) {
|
||||
return;
|
||||
}
|
||||
total = total.add(cargo.quantity());
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
if (!any || unit == null) {
|
||||
return;
|
||||
}
|
||||
trip.setTotalQuantity(total);
|
||||
trip.setQuantityUnit(unit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计重合计:卡片上那个「货物重量」,口径见 WaybillCargoSupport#weighing
|
||||
*/
|
||||
private void fillWeighing(TripVO trip, List<WaybillCargoSupport.Cargo> cargos) {
|
||||
WaybillCargoSupport.Quantity weighing = WaybillCargoSupport.weighing(cargos);
|
||||
if (weighing == null) {
|
||||
return;
|
||||
}
|
||||
trip.setWeighingQuantity(weighing.value());
|
||||
trip.setWeighingUnit(weighing.unit());
|
||||
}
|
||||
|
||||
private String formatQuantity(BigDecimal quantity, String unit) {
|
||||
String value = quantity.stripTrailingZeros().toPlainString();
|
||||
return Func.isEmpty(unit) ? value : value + unit;
|
||||
}
|
||||
|
||||
private TripVO.TripWaybillVO toWaybillVO(Waybill waybill) {
|
||||
TripVO.TripWaybillVO child = new TripVO.TripWaybillVO();
|
||||
child.setId(tripIdOf(waybill.getId()));
|
||||
child.setWaybillNo(waybill.getWaybillNo());
|
||||
child.setBusinessStatus(waybill.getBusinessStatus());
|
||||
child.setAcceptStatus(waybill.getDriverAcceptStatus());
|
||||
child.setRequireAccept(WaybillProcessSupport.requiresDriverAcceptConfirmation(waybill.getProcessJson()));
|
||||
child.setProjectId(waybill.getProjectId());
|
||||
child.setProjectName(waybill.getProjectName());
|
||||
// 货名按运单上实际录入的货物给全:主表 cargo_name 只是首行,多货物运单会漏掉其余货物
|
||||
List<WaybillCargoSupport.Cargo> cargos = WaybillCargoSupport.of(waybill);
|
||||
child.setCargoName(String.join("、", WaybillCargoSupport.cargoNames(cargos)));
|
||||
child.setCargoNames(WaybillCargoSupport.cargoNames(cargos));
|
||||
child.setCargoTypes(cargos.stream()
|
||||
.map(WaybillCargoSupport.Cargo::cargoType)
|
||||
.filter(Func::isNotEmpty)
|
||||
.distinct()
|
||||
.toList());
|
||||
child.setCargoType(waybill.getCargoType());
|
||||
child.setQuantity(waybill.getQuantity());
|
||||
child.setQuantityUnit(waybill.getQuantityUnit());
|
||||
child.setDepartureName(waybill.getDepartureName());
|
||||
child.setDepartureAddress(waybill.getDepartureAddress());
|
||||
child.setArrivalName(waybill.getArrivalName());
|
||||
child.setArrivalAddress(waybill.getArrivalAddress());
|
||||
child.setCreateTime(waybill.getCreateTime());
|
||||
return child;
|
||||
}
|
||||
|
||||
/** 排序键:子单 create_time 的最大值 */
|
||||
private Date maxCreateTime(List<Waybill> children) {
|
||||
return children.stream()
|
||||
.map(Waybill::getCreateTime)
|
||||
.filter(Objects::nonNull)
|
||||
.max(Comparator.naturalOrder())
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private String firstNotEmpty(String preferred, List<Waybill> children, Function<Waybill, String> getter) {
|
||||
if (Func.isNotEmpty(preferred)) {
|
||||
return preferred;
|
||||
}
|
||||
return children.stream().map(getter).filter(Func::isNotEmpty).findFirst().orElse("");
|
||||
}
|
||||
|
||||
/**
|
||||
* 按运次主键取配载单:主键命中配载单即配载运次,再按司机收口
|
||||
*/
|
||||
private LoadingManage visibleLoading(Long id, TripQueryVO condition) {
|
||||
LoadingManage loading = loadingManageService.getById(id);
|
||||
if (loading == null) {
|
||||
return null;
|
||||
}
|
||||
if (condition.isAllDrivers()) {
|
||||
return loading;
|
||||
}
|
||||
return samePhone(loading.getDriverPhone(), condition.getDriverPhone()) ? loading : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按运次主键取未配载运单:必须还没配载、在可见状态里,且属于收口范围
|
||||
*/
|
||||
private Waybill visibleWaybill(Long id, TripQueryVO condition) {
|
||||
Waybill waybill = waybillService.getById(id);
|
||||
if (waybill == null
|
||||
|| Func.isNotEmpty(waybill.getLoadingNo())
|
||||
|| !TripAggregation.CHILD_STATUSES.contains(waybill.getBusinessStatus())
|
||||
|| WaybillProcessSupport.ACCEPT_REJECTED.equals(waybill.getDriverAcceptStatus())) {
|
||||
return null;
|
||||
}
|
||||
if (condition.isAllDrivers()) {
|
||||
return waybill;
|
||||
}
|
||||
return samePhone(waybill.getDriverPhone(), condition.getDriverPhone()) ? waybill : null;
|
||||
}
|
||||
|
||||
private boolean scoped(TripQueryVO condition) {
|
||||
return condition.isAllDrivers() || Func.isNotEmpty(Func.toStr(condition.getDriverPhone(), "").trim());
|
||||
}
|
||||
|
||||
private boolean samePhone(String left, String right) {
|
||||
String expected = Func.toStr(right, "").trim().replace(" ", "");
|
||||
if (Func.isEmpty(expected)) {
|
||||
return false;
|
||||
}
|
||||
return expected.equals(Func.toStr(left, "").trim().replace(" ", ""));
|
||||
}
|
||||
|
||||
private TripQueryVO condition(TripQueryVO query) {
|
||||
return query == null ? new TripQueryVO() : query;
|
||||
}
|
||||
|
||||
/** 去掉 Tab 条件、其余照搬:统计四档时不按某一档收窄 */
|
||||
private TripQueryVO withoutTab(TripQueryVO source) {
|
||||
TripQueryVO copy = new TripQueryVO();
|
||||
copy.setDriverPhone(source.getDriverPhone());
|
||||
copy.setAllDrivers(source.isAllDrivers());
|
||||
copy.setDeptIds(source.getDeptIds());
|
||||
copy.setKeyword(source.getKeyword());
|
||||
copy.setTransportOrgType(source.getTransportOrgType());
|
||||
copy.setException(source.getException());
|
||||
copy.setExceptionWaybillIds(source.getExceptionWaybillIds());
|
||||
copy.setCreatedFrom(source.getCreatedFrom());
|
||||
copy.setCreatedTo(source.getCreatedTo());
|
||||
copy.setExcludeRejectedTrips(source.isExcludeRejectedTrips());
|
||||
return copy;
|
||||
}
|
||||
|
||||
private Long tripId(String value) {
|
||||
if (Func.isEmpty(value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(value.trim());
|
||||
} catch (NumberFormatException ignored) {
|
||||
// 运次主键只有配载单与运单两种数字雪花号,其余一律当作不存在的运次
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 运次主键按字符串下发:19 位雪花号按数字下发会被前端取整 */
|
||||
private String tripIdOf(Long id) {
|
||||
return id == null ? null : String.valueOf(id);
|
||||
}
|
||||
|
||||
/** 一趟车:运次键、子单行(段一取回的、含行级筛选后的)、派生状态与排序键 */
|
||||
private record TripRows(String key, String loadingNo, List<Waybill> children, String status, Integer tier,
|
||||
Date sortTime) {
|
||||
}
|
||||
|
||||
private int pageNo(Integer current) {
|
||||
return current == null || current < 1 ? 1 : current;
|
||||
}
|
||||
|
||||
private int pageSize(Integer size) {
|
||||
if (size == null || size < 1) {
|
||||
return DEFAULT_PAGE_SIZE;
|
||||
}
|
||||
return Math.min(size, MAX_PAGE_SIZE);
|
||||
}
|
||||
|
||||
}
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* 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>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import org.springblade.core.tool.jackson.JsonUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.pojo.entity.LoadingManage;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.TripStationVO;
|
||||
import org.springblade.transport.support.WaybillCargoSupport;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 运次站点拼装:把一趟车的实际途经顺序与每站的装卸、货物条目拼出来
|
||||
* <p>
|
||||
* 站点顺序优先取配载单的路线信息(有序站点数组);站点标记「装N / 卸N」的 N 指向配载单运单集合里的第 N 张子运单。
|
||||
* 没有路线信息时回退到子运单的起讫点去重成站(保留首次出现顺序),再退到配载单自己的发货/途经/到货地址;
|
||||
* 都没有则给空站点数组,前端按空站点渲染而不是崩。
|
||||
* <p>
|
||||
* 货物条目取子运单而不是配载单的货物信息:配载单的货物条数与子运单不一定同数,行序也对不上站点编号。
|
||||
*/
|
||||
final class TripStationAssembler {
|
||||
|
||||
private static final String LOAD = "load";
|
||||
private static final String UNLOAD = "unload";
|
||||
private static final String LOAD_UNLOAD = "load_unload";
|
||||
/** 途经站点之间的连接符 */
|
||||
private static final String TRANSIT_JOINER = " → ";
|
||||
/** 站点去重用的键分隔符:站名与地址都为空的两个点不该被当成同一个 */
|
||||
private static final String KEY_SEPARATOR = "\u0000";
|
||||
|
||||
private TripStationAssembler() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼装整趟车的站点,站序从 1 开始
|
||||
*
|
||||
* @param loading 配载单,未配载传 null
|
||||
* @param children 该运次的可见子运单
|
||||
*/
|
||||
static List<TripStationVO> assemble(LoadingManage loading, List<Waybill> children) {
|
||||
List<TripStationVO> stations = fromRoute(loading, children);
|
||||
if (stations.isEmpty()) {
|
||||
stations = fromWaybillPoints(children);
|
||||
}
|
||||
if (stations.isEmpty() && loading != null) {
|
||||
stations = fromLoadingAddresses(loading);
|
||||
}
|
||||
for (int i = 0; i < stations.size(); i++) {
|
||||
stations.get(i).setIndex(i + 1);
|
||||
}
|
||||
return stations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 起点名称:首站站名,没有站点时为空
|
||||
*/
|
||||
static String departureName(List<TripStationVO> stations) {
|
||||
return stations.isEmpty() ? "" : Func.toStr(stations.get(0).getName(), "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 终点名称:末站站名,没有站点时为空
|
||||
*/
|
||||
static String arrivalName(List<TripStationVO> stations) {
|
||||
return stations.isEmpty() ? "" : Func.toStr(stations.get(stations.size() - 1).getName(), "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 途经站点:中间各站按顺序连接,不足三站为空
|
||||
*/
|
||||
static String transitName(List<TripStationVO> stations) {
|
||||
if (stations.size() < 3) {
|
||||
return "";
|
||||
}
|
||||
List<String> names = new ArrayList<>();
|
||||
for (int i = 1; i < stations.size() - 1; i++) {
|
||||
String name = Func.toStr(stations.get(i).getName(), "");
|
||||
if (Func.isNotEmpty(name)) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
return String.join(TRANSIT_JOINER, names);
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点来自配载单的路线信息:按数组顺序成站,标记里第 N 张子运单按配载单运单集合的顺序取
|
||||
*/
|
||||
private static List<TripStationVO> fromRoute(LoadingManage loading, List<Waybill> children) {
|
||||
if (loading == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
List<Map<String, Object>> route = objectList(loading.getRouteJson());
|
||||
if (route.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
List<String> waybillIds = stringList(loading.getWaybillIdsJson());
|
||||
Map<String, Waybill> byId = new LinkedHashMap<>();
|
||||
for (Waybill child : children) {
|
||||
if (child.getId() != null) {
|
||||
byId.put(String.valueOf(child.getId()), child);
|
||||
}
|
||||
}
|
||||
List<TripStationVO> stations = new ArrayList<>();
|
||||
for (Map<String, Object> element : route) {
|
||||
String address = stringValue(element.get("address"));
|
||||
String display = stringValue(element.get("displayAddress"));
|
||||
TripStationVO station = new TripStationVO();
|
||||
station.setName(Func.toStrWithEmpty(display, address));
|
||||
station.setAddress(Func.toStrWithEmpty(address, station.getName()));
|
||||
List<String> tags = stringList(element.get("tags"));
|
||||
station.setTags(tags);
|
||||
List<TripStationVO.TripCargoVO> cargos = new ArrayList<>();
|
||||
boolean load = false;
|
||||
boolean unload = false;
|
||||
for (String tag : tags) {
|
||||
boolean loadTag = tag.contains("装");
|
||||
boolean unloadTag = tag.contains("卸");
|
||||
if (!loadTag && !unloadTag) {
|
||||
continue;
|
||||
}
|
||||
Waybill child = childOfTag(tag, waybillIds, byId);
|
||||
if (child == null) {
|
||||
continue;
|
||||
}
|
||||
load |= loadTag;
|
||||
unload |= unloadTag;
|
||||
addCargo(cargos, child, loadTag ? LOAD : UNLOAD);
|
||||
if (loadTag && unloadTag) {
|
||||
addCargo(cargos, child, UNLOAD);
|
||||
}
|
||||
}
|
||||
fillOperation(station, load, unload);
|
||||
station.setCargos(cargos);
|
||||
stations.add(station);
|
||||
}
|
||||
return stations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点回退到子运单的起讫点:按子运单顺序每张的起点、终点依次成站,同名同址只留首次出现
|
||||
*/
|
||||
private static List<TripStationVO> fromWaybillPoints(List<Waybill> children) {
|
||||
Map<String, TripStationVO> stations = new LinkedHashMap<>();
|
||||
Map<String, boolean[]> operations = new LinkedHashMap<>();
|
||||
for (Waybill child : children) {
|
||||
String fromName = pointName(child.getDepartureName(), child.getDepartureAddress());
|
||||
String fromAddress = Func.toStrWithEmpty(child.getDepartureAddress(), fromName);
|
||||
String toName = pointName(child.getArrivalName(), child.getArrivalAddress());
|
||||
String toAddress = Func.toStrWithEmpty(child.getArrivalAddress(), toName);
|
||||
point(stations, operations, fromName, fromAddress, true, child);
|
||||
point(stations, operations, toName, toAddress, false, child);
|
||||
}
|
||||
operations.forEach((key, flags) -> fillOperation(stations.get(key), flags[0], flags[1]));
|
||||
return new ArrayList<>(stations.values());
|
||||
}
|
||||
|
||||
private static void point(Map<String, TripStationVO> stations, Map<String, boolean[]> operations,
|
||||
String name, String address, boolean load, Waybill child) {
|
||||
if (Func.isEmpty(name) && Func.isEmpty(address)) {
|
||||
return;
|
||||
}
|
||||
String key = name + KEY_SEPARATOR + address;
|
||||
TripStationVO station = stations.get(key);
|
||||
if (station == null) {
|
||||
station = new TripStationVO();
|
||||
station.setName(name);
|
||||
station.setAddress(address);
|
||||
station.setTags(new ArrayList<>());
|
||||
station.setCargos(new ArrayList<>());
|
||||
stations.put(key, station);
|
||||
operations.put(key, new boolean[2]);
|
||||
}
|
||||
boolean[] flags = operations.get(key);
|
||||
flags[0] |= load;
|
||||
flags[1] |= !load;
|
||||
addCargo(station.getCargos(), child, load ? LOAD : UNLOAD);
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点再退到配载单自己的发货 / 途经 / 到货地址
|
||||
*/
|
||||
private static List<TripStationVO> fromLoadingAddresses(LoadingManage loading) {
|
||||
List<TripStationVO> stations = new ArrayList<>();
|
||||
for (String address : List.of(
|
||||
Func.toStr(loading.getDepartureAddress(), ""),
|
||||
Func.toStr(loading.getTransitAddress(), ""),
|
||||
Func.toStr(loading.getArrivalAddress(), ""))) {
|
||||
if (Func.isEmpty(address)) {
|
||||
continue;
|
||||
}
|
||||
TripStationVO station = new TripStationVO();
|
||||
station.setName(address);
|
||||
station.setAddress(address);
|
||||
station.setTags(new ArrayList<>());
|
||||
station.setCargos(new ArrayList<>());
|
||||
stations.add(station);
|
||||
}
|
||||
return stations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点标记里第 N 张子运单:N 是配载单运单集合里的位次,不是可见子单里的位次
|
||||
*/
|
||||
private static Waybill childOfTag(String tag, List<String> waybillIds, Map<String, Waybill> byId) {
|
||||
String digits = tag.replaceAll("\\D+", "");
|
||||
if (Func.isEmpty(digits)) {
|
||||
return null;
|
||||
}
|
||||
int order = Func.toInt(digits, 0);
|
||||
if (order < 1 || order > waybillIds.size()) {
|
||||
return null;
|
||||
}
|
||||
return byId.get(waybillIds.get(order - 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条子运单在该站的货物条目:运单上录了几条货物就出几条,同一张运单的多条货物不能被折成一行,
|
||||
* 否则站点上看不出这票到底装/卸了哪几种货。去重键含货名,站点标记重复出现时不会重复列出。
|
||||
* <p>
|
||||
* 一条货物都没录的运单仍出一行(货名为空):站点上的「这站有这张票」由这一行承载,
|
||||
* 只按货名判断有没有货物会让没填货名的单在任务安排里凭空消失。
|
||||
*/
|
||||
private static void addCargo(List<TripStationVO.TripCargoVO> cargos, Waybill child, String operation) {
|
||||
if (child == null) {
|
||||
return;
|
||||
}
|
||||
String waybillId = child.getId() == null ? null : String.valueOf(child.getId());
|
||||
List<WaybillCargoSupport.Cargo> items = WaybillCargoSupport.of(child);
|
||||
if (items.isEmpty()) {
|
||||
// 一条货物都没录:站点条目仍要有这一行,否则任务安排里这票凭空消失
|
||||
TripStationVO.TripCargoVO cargo = new TripStationVO.TripCargoVO();
|
||||
cargo.setWaybillId(waybillId);
|
||||
cargo.setWaybillNo(child.getWaybillNo());
|
||||
cargo.setCargoName("");
|
||||
cargo.setCustomerName(child.getCustomerName());
|
||||
cargo.setOperation(operation);
|
||||
cargo.setProjectName(child.getProjectName());
|
||||
cargos.add(cargo);
|
||||
return;
|
||||
}
|
||||
for (WaybillCargoSupport.Cargo item : items) {
|
||||
String cargoName = item.displayName();
|
||||
boolean exists = cargos.stream().anyMatch(cargo ->
|
||||
Objects.equals(cargo.getWaybillId(), waybillId)
|
||||
&& Objects.equals(cargo.getOperation(), operation)
|
||||
&& Objects.equals(cargo.getCargoName(), cargoName));
|
||||
if (exists) {
|
||||
continue;
|
||||
}
|
||||
TripStationVO.TripCargoVO cargo = new TripStationVO.TripCargoVO();
|
||||
cargo.setWaybillId(waybillId);
|
||||
cargo.setWaybillNo(child.getWaybillNo());
|
||||
cargo.setCargoName(cargoName);
|
||||
cargo.setCustomerName(child.getCustomerName());
|
||||
cargo.setQuantity(item.quantity());
|
||||
cargo.setQuantityUnit(item.quantityUnit());
|
||||
cargo.setOperation(operation);
|
||||
cargo.setProjectName(child.getProjectName());
|
||||
cargos.add(cargo);
|
||||
}
|
||||
}
|
||||
|
||||
private static void fillOperation(TripStationVO station, boolean load, boolean unload) {
|
||||
if (load && unload) {
|
||||
station.setOperationType(LOAD_UNLOAD);
|
||||
station.setOperationTypeLabel("装卸");
|
||||
} else if (load) {
|
||||
station.setOperationType(LOAD);
|
||||
station.setOperationTypeLabel("装货");
|
||||
} else if (unload) {
|
||||
station.setOperationType(UNLOAD);
|
||||
station.setOperationTypeLabel("卸货");
|
||||
} else {
|
||||
station.setOperationType(null);
|
||||
station.setOperationTypeLabel("");
|
||||
}
|
||||
}
|
||||
|
||||
private static String pointName(String name, String address) {
|
||||
return Func.toStrWithEmpty(name, Func.toStr(address, ""));
|
||||
}
|
||||
|
||||
private static List<Map<String, Object>> objectList(String json) {
|
||||
if (Func.isEmpty(json)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
try {
|
||||
Object parsed = JsonUtil.parse(json, List.class);
|
||||
if (parsed instanceof List<?> list) {
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (Object item : list) {
|
||||
if (item instanceof Map<?, ?> map) {
|
||||
Map<String, Object> element = new LinkedHashMap<>();
|
||||
map.forEach((key, value) -> element.put(String.valueOf(key), value));
|
||||
result.add(element);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 路线信息坏了不该让整趟车读不出来,按没有路线信息回退
|
||||
}
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
private static List<String> stringList(Object value) {
|
||||
List<String> result = new ArrayList<>();
|
||||
if (value instanceof List<?> list) {
|
||||
for (Object item : list) {
|
||||
String text = stringValue(item);
|
||||
if (Func.isNotEmpty(text)) {
|
||||
result.add(text);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
String text = stringValue(value);
|
||||
if (Func.isEmpty(text)) {
|
||||
return result;
|
||||
}
|
||||
try {
|
||||
Object parsed = JsonUtil.parse(text, List.class);
|
||||
if (parsed instanceof List<?> list) {
|
||||
for (Object item : list) {
|
||||
String item2 = stringValue(item);
|
||||
if (Func.isNotEmpty(item2)) {
|
||||
result.add(item2);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 不是 JSON 数组就当逗号分隔处理
|
||||
}
|
||||
return Func.toStrList(",", text);
|
||||
}
|
||||
|
||||
private static String stringValue(Object value) {
|
||||
return value == null ? "" : String.valueOf(value).trim();
|
||||
}
|
||||
|
||||
}
|
||||
+177
-43
@@ -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();
|
||||
@@ -758,25 +920,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 +1098,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()) {
|
||||
|
||||
+81
-29
@@ -40,12 +40,13 @@ import org.springblade.thirdparty.lbs.feign.ILbsClient;
|
||||
import org.springblade.thirdparty.lbs.pojo.dto.LbsLocateRequest;
|
||||
import org.springblade.thirdparty.lbs.pojo.vo.LbsLocateResponse;
|
||||
import org.springblade.transport.excel.WaybillExcel;
|
||||
import org.springblade.transport.mapper.ExceptionDisposalMapper;
|
||||
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
|
||||
import org.springblade.transport.mapper.WaybillMapper;
|
||||
import org.springblade.transport.mapper.WaybillNodePunchMapper;
|
||||
import org.springblade.transport.pojo.entity.LoadingManage;
|
||||
import org.springblade.transport.pojo.entity.ContractManage;
|
||||
import org.springblade.transport.pojo.entity.ProcessConfig;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.ProjectApply;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
|
||||
@@ -66,6 +67,8 @@ import org.springblade.transport.service.IProjectApplyService;
|
||||
import org.springblade.transport.service.IReceivablePayableDetailService;
|
||||
import org.springblade.transport.service.IContractManageService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.LiveProcessConfigSupport;
|
||||
import org.springblade.transport.support.LoadableAdmissionSupport;
|
||||
import org.springblade.transport.support.TransportBusinessSupport;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
import org.springblade.transport.wrapper.WaybillWrapper;
|
||||
@@ -82,6 +85,7 @@ import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
@@ -125,6 +129,9 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
@jakarta.annotation.Resource
|
||||
private WaybillEnroutePunchMapper waybillEnroutePunchMapper;
|
||||
|
||||
@jakarta.annotation.Resource
|
||||
private ExceptionDisposalMapper exceptionDisposalMapper;
|
||||
|
||||
@jakarta.annotation.Resource
|
||||
private ILbsClient lbsClient;
|
||||
|
||||
@@ -822,27 +829,20 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 动态过程配置优先,回退运单快照 */
|
||||
/**
|
||||
* 生效配置优先,回退运单快照。
|
||||
* <p>
|
||||
* 取法与二选一规则在 {@link LiveProcessConfigSupport};这里不用「生效配置没有可打卡节点就回退快照」
|
||||
* 那一版——接单需求、节点门禁、节点订正都读这份配置判断节点存不存在,生效配置是权威,
|
||||
* 不能因为快照里有节点而被顶掉。
|
||||
*/
|
||||
private String resolveLiveProcessJson(Waybill waybill) {
|
||||
if (waybill.getProjectId() != null) {
|
||||
String projectId = String.valueOf(waybill.getProjectId());
|
||||
String live = processConfigService.list(Wrappers.<ProcessConfig>lambdaQuery()
|
||||
.eq(ProcessConfig::getStatus, 1)
|
||||
.eq(ProcessConfig::getIsDeleted, 0)
|
||||
.like(ProcessConfig::getProjectIds, projectId)
|
||||
.orderByDesc(ProcessConfig::getUpdateTime)
|
||||
.orderByDesc(ProcessConfig::getCreateTime))
|
||||
.stream()
|
||||
.filter(cfg -> containsProjectId(cfg.getProjectIds(), projectId))
|
||||
.map(ProcessConfig::getNodeConfigJson)
|
||||
.filter(Func::isNotEmpty)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (Func.isNotEmpty(live)) {
|
||||
return live;
|
||||
}
|
||||
if (waybill == null) {
|
||||
return null;
|
||||
}
|
||||
return waybill.getProcessJson();
|
||||
return LiveProcessConfigSupport.resolve(
|
||||
LiveProcessConfigSupport.byProject(processConfigService, waybill.getProjectId()),
|
||||
waybill.getProcessJson());
|
||||
}
|
||||
|
||||
private Map<String, List<String>> buildVoucherTypesIndex(String processJson) {
|
||||
@@ -1175,15 +1175,6 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
waybill.setDriverRejectReason(null);
|
||||
}
|
||||
|
||||
private boolean containsProjectId(String projectIds, String projectId) {
|
||||
if (Func.isEmpty(projectIds)) {
|
||||
return false;
|
||||
}
|
||||
return List.of(projectIds.split(",")).stream()
|
||||
.map(String::trim)
|
||||
.anyMatch(projectId::equals);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public BusinessRemoveResultVO removeWaybill(String ids) {
|
||||
@@ -1512,6 +1503,61 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
return doComplete(waybill);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean confirmReturnReceipt(Long id) {
|
||||
Waybill waybill = loadEditable(id, true);
|
||||
String processJson = LiveProcessConfigSupport.resolve(
|
||||
LiveProcessConfigSupport.byProject(processConfigService, waybill.getProjectId()),
|
||||
waybill.getProcessJson());
|
||||
if (WaybillProcessSupport.returnNeedsNoConfirmation(processJson)) {
|
||||
// 不要求确认时司机打完末位卡就已经完成了,运单不会停在进行中等确认;配置对不上要说清,
|
||||
// 否则用户会以为是确认没生效
|
||||
throw new ServiceException("该运单的回单不需要业务员确认");
|
||||
}
|
||||
assertPunchNodesFinished(waybill.getId(), processJson);
|
||||
assertNoUnresolvedException(waybill.getId());
|
||||
return doComplete(waybill);
|
||||
}
|
||||
|
||||
/**
|
||||
* 司机打卡节点(在途除外)必须全部已打卡:完成接口本身不要求节点打卡,
|
||||
* 不挡住就能从「确认回单」绕过打卡门禁。
|
||||
*/
|
||||
private void assertPunchNodesFinished(Long waybillId, String processJson) {
|
||||
List<String> missing = WaybillProcessSupport.missingPunchNodeNames(processJson, punchedNodeKeys(waybillId));
|
||||
if (!missing.isEmpty()) {
|
||||
throw new ServiceException("该运单还有未打卡的节点:" + String.join("、", missing));
|
||||
}
|
||||
}
|
||||
|
||||
/** 该运单已打卡的节点编码与名称(小写):打卡记录里两者都写过,对节点时任一命中即可 */
|
||||
private Set<String> punchedNodeKeys(Long waybillId) {
|
||||
Set<String> keys = new LinkedHashSet<>();
|
||||
List<WaybillNodePunch> punches = waybillNodePunchMapper.selectList(Wrappers.<WaybillNodePunch>lambdaQuery()
|
||||
.eq(WaybillNodePunch::getWaybillId, waybillId));
|
||||
for (WaybillNodePunch punch : punches) {
|
||||
if (Func.isNotEmpty(punch.getNodeCode())) {
|
||||
keys.add(punch.getNodeCode().trim().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
if (Func.isNotEmpty(punch.getNodeName())) {
|
||||
keys.add(punch.getNodeName().trim().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** 存在未了结异常时不允许置完成:判据与司机端完成同一道门(处置单达终态才算解除) */
|
||||
private void assertNoUnresolvedException(Long waybillId) {
|
||||
Long unresolved = exceptionDisposalMapper.selectCount(Wrappers.<ExceptionDisposal>lambdaQuery()
|
||||
.eq(ExceptionDisposal::getWaybillId, waybillId)
|
||||
.eq(ExceptionDisposal::getIsDeleted, 0)
|
||||
.notIn(ExceptionDisposal::getDisposalStatus, WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES));
|
||||
if (unresolved != null && unresolved > 0) {
|
||||
throw new ServiceException("该运单存在未处理异常,处理完成后才能确认回单");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成运单核心逻辑:状态改为 completed,并检查生成应收应付明细。
|
||||
*/
|
||||
@@ -1691,6 +1737,12 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
if (Objects.equals(waybill.getOnlyUnassignedLoading(), 1)) {
|
||||
queryWrapper.and(wrapper -> wrapper.isNull(Waybill::getLoadingNo).or().eq(Waybill::getLoadingNo, ""));
|
||||
}
|
||||
if (Func.isNotEmpty(waybill.getIds())) {
|
||||
queryWrapper.in(Waybill::getId, waybill.getIds());
|
||||
}
|
||||
if (Objects.equals(waybill.getLoadableOnly(), 1)) {
|
||||
LoadableAdmissionSupport.applyLoadableConditions(queryWrapper);
|
||||
}
|
||||
if (Func.isNotEmpty(waybill.getBatchNo())) {
|
||||
queryWrapper.like(Waybill::getBatchNo, waybill.getBatchNo());
|
||||
}
|
||||
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* 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>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.support;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.pojo.entity.ProcessConfig;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 过程配置的取法与「生效配置还是运单快照」的二选一规则,只此一处。
|
||||
* <p>
|
||||
* 生效配置({@code blade_process_config})按项目生效,是活的:改一次,该项目下所有运单下次读取
|
||||
* 都按新配置呈现;运单创建时另存一份 {@code process_json} 快照,只在生效配置取不到时兜底。
|
||||
* 生效配置按更新时间倒序取第一条命中该项目的,项目集合是逗号串,故先 LIKE 粗筛再按逗号分段精确比对 ——
|
||||
* 只靠 LIKE 会把「1234」命中到「12345」上。
|
||||
* <p>
|
||||
* 两个二选一版本的区别是**得到什么结果**,不是风格:
|
||||
* <ul>
|
||||
* <li>{@link #resolve} 生效配置优先 —— 接单需求、节点门禁、节点订正这些判断节点存不存在的路径用它。
|
||||
* 生效配置是权威,即使它没配可打卡节点也不能被快照顶掉,否则判断依据会随时间漂移。</li>
|
||||
* <li>{@link #resolveForPunch} 生效配置没有可打卡节点时回退快照 —— 司机端打卡页与列表卡用它。
|
||||
* 配置被改坏(节点全删)时,司机端仍能按快照渲染出打卡节点,不至于空白。</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class LiveProcessConfigSupport {
|
||||
|
||||
private LiveProcessConfigSupport() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个项目的生效配置 JSON,没有则返回 null
|
||||
*
|
||||
* @param service 过程配置服务
|
||||
* @param projectId 项目主键
|
||||
*/
|
||||
public static String byProject(IProcessConfigService service, Long projectId) {
|
||||
if (service == null || projectId == null) {
|
||||
return null;
|
||||
}
|
||||
String projectIdStr = String.valueOf(projectId);
|
||||
return service.list(Wrappers.<ProcessConfig>lambdaQuery()
|
||||
.eq(ProcessConfig::getStatus, 1)
|
||||
.eq(ProcessConfig::getIsDeleted, 0)
|
||||
.like(ProcessConfig::getProjectIds, projectIdStr)
|
||||
.orderByDesc(ProcessConfig::getUpdateTime)
|
||||
.orderByDesc(ProcessConfig::getCreateTime))
|
||||
.stream()
|
||||
.filter(config -> containsProjectId(config.getProjectIds(), projectIdStr))
|
||||
.map(ProcessConfig::getNodeConfigJson)
|
||||
.filter(Func::isNotEmpty)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一批项目的生效配置:项目主键 → 节点配置 JSON,一次查全再按项目匹配,供列表页避免逐条回查
|
||||
*
|
||||
* @param service 过程配置服务
|
||||
* @param projectIds 项目主键集合,允许含 null
|
||||
*/
|
||||
public static Map<Long, String> byProjects(IProcessConfigService service, Collection<Long> projectIds) {
|
||||
Map<Long, String> live = new LinkedHashMap<>();
|
||||
if (service == null || Func.isEmpty(projectIds)) {
|
||||
return live;
|
||||
}
|
||||
Set<Long> wanted = new LinkedHashSet<>();
|
||||
projectIds.stream().filter(Objects::nonNull).forEach(wanted::add);
|
||||
if (wanted.isEmpty()) {
|
||||
return live;
|
||||
}
|
||||
List<ProcessConfig> configs = service.list(Wrappers.<ProcessConfig>lambdaQuery()
|
||||
.eq(ProcessConfig::getStatus, 1)
|
||||
.eq(ProcessConfig::getIsDeleted, 0)
|
||||
.orderByDesc(ProcessConfig::getUpdateTime)
|
||||
.orderByDesc(ProcessConfig::getCreateTime));
|
||||
if (Func.isEmpty(configs)) {
|
||||
return live;
|
||||
}
|
||||
for (Long projectId : wanted) {
|
||||
String projectIdStr = String.valueOf(projectId);
|
||||
for (ProcessConfig config : configs) {
|
||||
if (Func.isEmpty(config.getNodeConfigJson())
|
||||
|| !containsProjectId(config.getProjectIds(), projectIdStr)) {
|
||||
continue;
|
||||
}
|
||||
live.put(projectId, config.getNodeConfigJson());
|
||||
break;
|
||||
}
|
||||
}
|
||||
return live;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生效配置优先,取不到时回退运单快照
|
||||
*
|
||||
* @param liveJson 生效配置 JSON,可为空
|
||||
* @param snapshot 运单快照 JSON,可为空
|
||||
*/
|
||||
public static String resolve(String liveJson, String snapshot) {
|
||||
return Func.isNotEmpty(liveJson) ? liveJson : snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生效配置优先;生效配置没有可打卡节点时回退快照,两边都没有节点就按生效配置来
|
||||
*
|
||||
* @param liveJson 生效配置 JSON,可为空
|
||||
* @param snapshot 运单快照 JSON,可为空
|
||||
*/
|
||||
public static String resolveForPunch(String liveJson, String snapshot) {
|
||||
if (Func.isEmpty(liveJson)) {
|
||||
return snapshot;
|
||||
}
|
||||
if (!WaybillProcessSupport.listDriverPunchNodes(liveJson).isEmpty()) {
|
||||
return liveJson;
|
||||
}
|
||||
if (Func.isNotEmpty(snapshot) && !WaybillProcessSupport.listDriverPunchNodes(snapshot).isEmpty()) {
|
||||
return snapshot;
|
||||
}
|
||||
return liveJson;
|
||||
}
|
||||
|
||||
/** 项目集合是逗号串,必须按逗号分段精确比对 */
|
||||
private static boolean containsProjectId(String projectIds, String projectId) {
|
||||
if (Func.isEmpty(projectIds) || Func.isEmpty(projectId)) {
|
||||
return false;
|
||||
}
|
||||
return Arrays.stream(projectIds.split(","))
|
||||
.map(String::trim)
|
||||
.anyMatch(projectId::equals);
|
||||
}
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* 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>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.support;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* 可配载准入判据:公路 + 待执行 + 未关联其它配载单。
|
||||
* <p>
|
||||
* 接单状态不进判据:拒单票照样可配载。配载提交会把它的接单事实清成待接单,
|
||||
* 否则新司机看不到这张票——司机端入口一律排除已拒绝的运单。
|
||||
* <p>
|
||||
* 候选列表取数与配载提交/编辑/重新派单的校验共用这一份定义。
|
||||
*/
|
||||
public final class LoadableAdmissionSupport {
|
||||
|
||||
/** 可配载只放行待执行:已完成、已取消、草稿与调度中间态一律不可配载 */
|
||||
public static final String LOADABLE_BUSINESS_STATUS = WaybillProcessSupport.STATUS_PENDING;
|
||||
|
||||
/**
|
||||
* 公路运输方式的同义写法,按「包含」比对,与前端运单管理页的识别口径一致。
|
||||
* <p>
|
||||
* 运输方式字典只有 road/railway/river/ocean/air,而库里存在「公路运输」「水路运输」这类中文取值,
|
||||
* 因此词表写在代码里,不从字典取。
|
||||
*/
|
||||
public static final List<String> ROAD_TRANSPORT_KEYWORDS = List.of("公路", "道路", "汽车", "陆运", "road", "highway");
|
||||
|
||||
/** 公路运输方式的历史短码,按整体相等比对(按包含比对会误伤含 gl 的其它取值) */
|
||||
public static final List<String> ROAD_TRANSPORT_CODES = List.of("gl");
|
||||
|
||||
private LoadableAdmissionSupport() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 不可配载的原因,按判定顺序排列:公路 → 待执行 → 未关联其它配载单。
|
||||
* <p>
|
||||
* 判据只产出原因,中文文案由调用方映射——面向用户的措辞不进这一层。
|
||||
*/
|
||||
public enum UnloadableReason {
|
||||
|
||||
/** 运输方式不是公路 */
|
||||
NOT_ROAD,
|
||||
|
||||
/** 业务状态不是待执行 */
|
||||
NOT_PENDING,
|
||||
|
||||
/** 已关联其它配载单 */
|
||||
ON_OTHER_MANIFEST
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否公路运输。未知或为空一律按非公路处理。
|
||||
*
|
||||
* @param transportType 运单的运输方式取值
|
||||
* @return true-公路
|
||||
*/
|
||||
public static boolean isRoadTransport(String transportType) {
|
||||
String value = text(transportType).toLowerCase(Locale.ROOT);
|
||||
if (value.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (ROAD_TRANSPORT_CODES.contains(value)) {
|
||||
return true;
|
||||
}
|
||||
for (String keyword : ROAD_TRANSPORT_KEYWORDS) {
|
||||
if (value.contains(keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 该运单此刻是否可被配载。
|
||||
* <p>
|
||||
* ownLoadingNo 是正在编辑或重新派单的配载单号:运单已挂在本单上不算冲突,
|
||||
* 否则编辑保存会把自己的子运单判为不可用。
|
||||
*
|
||||
* @param waybill 运单
|
||||
* @param ownLoadingNo 本配载单号;新建时传 null
|
||||
* @return true-可配载
|
||||
*/
|
||||
public static boolean isLoadable(Waybill waybill, String ownLoadingNo) {
|
||||
return unloadableReason(waybill, ownLoadingNo) == null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 该运单此刻不可配载的原因。
|
||||
*
|
||||
* @param waybill 运单
|
||||
* @param ownLoadingNo 本配载单号;新建时传 null
|
||||
* @return 命中的第一条原因;可配载时返回 null
|
||||
*/
|
||||
public static UnloadableReason unloadableReason(Waybill waybill, String ownLoadingNo) {
|
||||
if (waybill == null || !isRoadTransport(waybill.getTransportType())) {
|
||||
return UnloadableReason.NOT_ROAD;
|
||||
}
|
||||
if (!LOADABLE_BUSINESS_STATUS.equals(text(waybill.getBusinessStatus()))) {
|
||||
return UnloadableReason.NOT_PENDING;
|
||||
}
|
||||
String loadingNo = text(waybill.getLoadingNo());
|
||||
if (loadingNo.isEmpty() || loadingNo.equals(text(ownLoadingNo))) {
|
||||
return null;
|
||||
}
|
||||
return UnloadableReason.ON_OTHER_MANIFEST;
|
||||
}
|
||||
|
||||
/**
|
||||
* 该运单是否已挂在本配载单上。
|
||||
* <p>
|
||||
* 本单既有子运单在先前提交时已过准入判据,重新提交/编辑时不再重复判:
|
||||
* 不需接单项目的票提交后即为进行中,重复判会让本单再也保存不了,重新派单也无从下手。
|
||||
*
|
||||
* @param waybill 运单
|
||||
* @param ownLoadingNo 本配载单号;新建时传 null
|
||||
* @return true-已是本单子运单
|
||||
*/
|
||||
public static boolean isOwnChild(Waybill waybill, String ownLoadingNo) {
|
||||
if (waybill == null) {
|
||||
return false;
|
||||
}
|
||||
String own = text(ownLoadingNo);
|
||||
return !own.isEmpty() && own.equals(text(waybill.getLoadingNo()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把整条可配载判据加到运单查询上:公路 + 待执行 + 未配载。
|
||||
*
|
||||
* @param wrapper 运单列表查询条件
|
||||
*/
|
||||
public static void applyLoadableConditions(LambdaQueryWrapper<Waybill> wrapper) {
|
||||
applyRoadTransportCondition(wrapper);
|
||||
wrapper.eq(Waybill::getBusinessStatus, LOADABLE_BUSINESS_STATUS);
|
||||
wrapper.and(nested -> nested.isNull(Waybill::getLoadingNo).or().eq(Waybill::getLoadingNo, ""));
|
||||
}
|
||||
|
||||
private static void applyRoadTransportCondition(LambdaQueryWrapper<Waybill> wrapper) {
|
||||
wrapper.and(nested -> {
|
||||
boolean started = false;
|
||||
for (String keyword : ROAD_TRANSPORT_KEYWORDS) {
|
||||
if (started) {
|
||||
nested.or();
|
||||
}
|
||||
nested.like(Waybill::getTransportType, keyword);
|
||||
started = true;
|
||||
}
|
||||
for (String code : ROAD_TRANSPORT_CODES) {
|
||||
nested.or().eq(Waybill::getTransportType, code);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static String text(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
}
|
||||
+6
-1
@@ -279,6 +279,10 @@ public final class OcrFieldSupport {
|
||||
Map<String, List<String>> aliases = new LinkedHashMap<>();
|
||||
aliases.put("realName", List.of("姓名"));
|
||||
aliases.put("idCardNo", List.of("公民身份号码", "公民身份证号码", "身份证号", "身份证号码"));
|
||||
// 身份证正面的「出生」是一整串日期(1990年1月1日),按 birthday 归一成 yyyy-MM-dd;
|
||||
// 住址是身份证上的登记住址,写进 driver.address
|
||||
aliases.put("birthday", List.of("出生", "出生年月", "出生日期"));
|
||||
aliases.put("address", List.of("住址", "地址"));
|
||||
aliases.put("idCardValidStart", List.of("签发日期", "有效起始日期", "有效期限起"));
|
||||
aliases.put("idCardValidEnd", List.of("失效日期", "有效期限", "有效期限止"));
|
||||
return aliases;
|
||||
@@ -319,7 +323,8 @@ public final class OcrFieldSupport {
|
||||
}
|
||||
|
||||
private static boolean isDateField(String key) {
|
||||
return key.endsWith("Date") || key.endsWith("ValidStart") || key.endsWith("ValidEnd");
|
||||
// birthday 的语义是日期但拼写不以 Date 结尾,漏了它会把「1990年1月1日」原样下发
|
||||
return "birthday".equals(key) || key.endsWith("Date") || key.endsWith("ValidStart") || key.endsWith("ValidEnd");
|
||||
}
|
||||
|
||||
private static boolean isNumberField(String key) {
|
||||
|
||||
+36
-3
@@ -28,7 +28,9 @@ import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.constant.RoleConstant;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.cache.SysCache;
|
||||
import org.springblade.system.cache.UserCache;
|
||||
import org.springblade.system.pojo.entity.Dept;
|
||||
import org.springblade.system.pojo.entity.User;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
@@ -50,6 +52,8 @@ public final class TransportBusinessSupport {
|
||||
public static final String DATA_SOURCE_EXTERNAL = "外部系统";
|
||||
|
||||
private static final Pattern PHONE_PATTERN = Pattern.compile("^(1\\d{10}|0\\d{2,3}-?\\d{7,8})$");
|
||||
/** 用户档案「数据权限范围」的取值:全部组织,与组织树过滤口径一致 */
|
||||
private static final int DATA_SCOPE_ALL = 2;
|
||||
private static final BigDecimal MIN_LONGITUDE = new BigDecimal("-180");
|
||||
private static final BigDecimal MAX_LONGITUDE = new BigDecimal("180");
|
||||
private static final BigDecimal MIN_LATITUDE = new BigDecimal("-90");
|
||||
@@ -76,6 +80,26 @@ public final class TransportBusinessSupport {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否被授予跨组织数据权限:超级管理员,或用户档案「数据权限范围」为全部组织。
|
||||
* <p>
|
||||
* 「数据权限范围」在用户管理里配置,组织树已按它过滤(DeptServiceImpl 的组织树数据权限);
|
||||
* 运输业务的列表过滤与写入校验必须与组织树同一口径。
|
||||
*
|
||||
* @return true-不限组织
|
||||
*/
|
||||
public static boolean hasAllDeptScope() {
|
||||
if (isAdministrator()) {
|
||||
return true;
|
||||
}
|
||||
Long userId = AuthUtil.getUserId();
|
||||
if (Func.isEmpty(userId) || userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
User user = UserCache.getUser(userId);
|
||||
return Objects.equals(user == null ? null : user.getDataScopeRange(), DATA_SCOPE_ALL);
|
||||
}
|
||||
|
||||
public static Long currentDeptId(String moduleName) {
|
||||
Long deptId = Func.firstLong(AuthUtil.getDeptId());
|
||||
if (Func.isEmpty(deptId) || deptId <= 0) {
|
||||
@@ -92,14 +116,22 @@ public final class TransportBusinessSupport {
|
||||
return dept;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验目标业务数据属于当前用户可操作的组织。
|
||||
* <p>
|
||||
* 超管与「数据权限范围=全部组织」的用户不限组织;其余用户仍严格要求与当前会话组织相等。
|
||||
*/
|
||||
public static void assertCurrentDept(Long deptId, String moduleName) {
|
||||
if (hasAllDeptScope()) {
|
||||
return;
|
||||
}
|
||||
if (!Objects.equals(deptId, currentDeptId(moduleName))) {
|
||||
throw new ServiceException("无权操作其他组织" + moduleName);
|
||||
}
|
||||
}
|
||||
|
||||
public static void validateAllDept(Integer allDept, String moduleName) {
|
||||
if (Objects.equals(allDept, 1) && !isAdministrator()) {
|
||||
if (Objects.equals(allDept, 1) && !hasAllDeptScope()) {
|
||||
throw new ServiceException("无权查看全部组织" + moduleName);
|
||||
}
|
||||
}
|
||||
@@ -107,7 +139,8 @@ public final class TransportBusinessSupport {
|
||||
/**
|
||||
* 解析列表组织筛选 ID。
|
||||
* <p>
|
||||
* 普通用户 allDept!=1 时强制当前组织;超管默认不按组织过滤,仅在 allDept=1 且传入 deptId 时精确匹配该组织。
|
||||
* 普通用户 allDept!=1 时强制当前组织;超管与「全部组织」用户默认不按组织过滤,
|
||||
* 仅在 allDept=1 且传入 deptId 时精确匹配该组织。
|
||||
*
|
||||
* @param allDept 是否查看全部组织
|
||||
* @param deptId 筛选组织 ID
|
||||
@@ -116,7 +149,7 @@ public final class TransportBusinessSupport {
|
||||
*/
|
||||
public static List<Long> resolveDeptFilterIds(Integer allDept, Long deptId, String moduleName) {
|
||||
validateAllDept(allDept, moduleName);
|
||||
if (isAdministrator()) {
|
||||
if (hasAllDeptScope()) {
|
||||
if (Objects.equals(allDept, 1) && Func.isNotEmpty(deptId) && deptId > 0) {
|
||||
return Collections.singletonList(deptId);
|
||||
}
|
||||
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* 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>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.support;
|
||||
|
||||
import org.springblade.core.tool.jackson.JsonUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 运单货物明细的取法,只此一处。
|
||||
* <p>
|
||||
* 一张运单可以录多条货物,明细落在 {@code goods_json};主表的 {@code cargo_name} / {@code cargo_type} /
|
||||
* {@code quantity} 只是给列表与报表用的首行汇总(数量是各行合计),拿它当货物清单会把其余货物丢掉。
|
||||
* 所以「这张运单装了什么」一律以 goods_json 为准,主表字段只在 goods_json 缺失或解析不出任何货名时兜底。
|
||||
* <p>
|
||||
* 货名也可能是主表里一格多名的历史写法(顿号、逗号分隔),按同一口径摊平。
|
||||
*/
|
||||
public final class WaybillCargoSupport {
|
||||
|
||||
/** 计重时唯一允许跨货物相加的单位:其他单位只在「全部货物同单位」时才合计 */
|
||||
private static final String UNIT_TON = "吨";
|
||||
|
||||
/** 货物没有填名称时的展示名:宁可让司机看到「货物」,也不要出现空行 */
|
||||
private static final String FALLBACK_NAME = "货物";
|
||||
|
||||
/** 货名分隔符:历史数据里顿号、中英文逗号都出现过 */
|
||||
private static final String NAME_SEPARATOR_REGEX = "[、,,]";
|
||||
|
||||
/** 明细里可能承载数量的字段,按优先级取第一个有值的 */
|
||||
private static final String[] QUANTITY_KEYS = {"quantity", "cargoQuantity", "goodsQuantity"};
|
||||
|
||||
/** 明细里可能承载单位的字段,按优先级取第一个有值的 */
|
||||
private static final String[] UNIT_KEYS = {"quantityUnit", "goodsQuantityUnit", "unit"};
|
||||
|
||||
/** 明细里可能承载名称的字段,按优先级取第一个有值的 */
|
||||
private static final String[] NAME_KEYS = {"cargoName", "goodsName"};
|
||||
|
||||
/** 明细里可能承载类型的字段,按优先级取第一个有值的 */
|
||||
private static final String[] TYPE_KEYS = {"cargoType", "goodsType"};
|
||||
|
||||
private WaybillCargoSupport() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 一张运单的货物条目:goods_json 能解析出货物就用它,否则回退到主表的货物字段。
|
||||
* <p>
|
||||
* 每条明细至少出一条:货名、数量都为空的行也保留,让「这张票录了一条空货物」与「这条货物没填数量」可区分。
|
||||
*/
|
||||
public static List<Cargo> of(Waybill waybill) {
|
||||
if (waybill == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<Cargo> detail = parseDetail(waybill.getGoodsJson());
|
||||
if (!detail.isEmpty()) {
|
||||
return detail;
|
||||
}
|
||||
return fallback(waybill);
|
||||
}
|
||||
|
||||
/**
|
||||
* 货名清单:同名的只留一次,保持录入顺序;档口与报表需要横向摊平时用它
|
||||
*/
|
||||
public static List<String> cargoNames(List<Cargo> cargos) {
|
||||
List<String> names = new ArrayList<>();
|
||||
for (Cargo cargo : cargos) {
|
||||
String name = Func.toStr(cargo.cargoName(), "").trim();
|
||||
if (Func.isNotEmpty(name) && !names.contains(name)) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 goods_json 为货物条目;字段缺失、JSON 非法或解析不出任何条目都返回空集合,由调用方兜底
|
||||
*/
|
||||
private static List<Cargo> parseDetail(String goodsJson) {
|
||||
if (Func.isEmpty(goodsJson)) {
|
||||
return List.of();
|
||||
}
|
||||
List<Map> rows;
|
||||
try {
|
||||
rows = JsonUtil.parseArray(goodsJson, Map.class);
|
||||
} catch (Exception exception) {
|
||||
// 历史脏数据:JSON 坏了就当没有明细,回退主表字段,不让列表与详情因一条脏数据打不开
|
||||
return List.of();
|
||||
}
|
||||
if (Func.isEmpty(rows)) {
|
||||
return List.of();
|
||||
}
|
||||
List<Cargo> cargos = new ArrayList<>(rows.size());
|
||||
for (Map row : rows) {
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
cargos.add(new Cargo(
|
||||
firstText(row, NAME_KEYS),
|
||||
firstText(row, TYPE_KEYS),
|
||||
quantityOf(row),
|
||||
firstText(row, UNIT_KEYS)));
|
||||
}
|
||||
return cargos;
|
||||
}
|
||||
|
||||
/**
|
||||
* 回退:主表货名按历史分隔符摊平,每一段配同一个数量与单位(主表数量是各行合计,摊到哪一段都不准,
|
||||
* 故只给一段配数量;多段说明主表数量不可拆分,宁可只展示货名也不重复同一份合计)
|
||||
*/
|
||||
private static List<Cargo> fallback(Waybill waybill) {
|
||||
List<String> names = splitCargoNames(waybill.getCargoName());
|
||||
if (names.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<Cargo> cargos = new ArrayList<>(names.size());
|
||||
boolean first = true;
|
||||
for (String name : names) {
|
||||
cargos.add(new Cargo(
|
||||
name,
|
||||
Func.toStr(waybill.getCargoType(), ""),
|
||||
first ? waybill.getQuantity() : null,
|
||||
first ? Func.toStr(waybill.getQuantityUnit(), "") : ""));
|
||||
first = false;
|
||||
}
|
||||
return cargos;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计重合计:卡片上那个「货物重量」
|
||||
* <p>
|
||||
* 全部货物同单位时就是该单位合计;单位不一致时只合计「吨」项,其余单位不参与——
|
||||
* 「100吨 + 20堆」报 100 吨,「20堆 + 50方」没有吨项就不出合计。
|
||||
* 有货物缺数量时该单位合计不完整,宁可不报也不报一个偏小的数。
|
||||
*
|
||||
* @return 有可用合计时返回数量与单位,否则 null
|
||||
*/
|
||||
public static Quantity weighing(List<Cargo> cargos) {
|
||||
Map<String, BigDecimal> totals = new LinkedHashMap<>();
|
||||
Set<String> incomplete = new HashSet<>();
|
||||
for (Cargo cargo : cargos) {
|
||||
String unit = Func.toStr(cargo.quantityUnit(), "");
|
||||
if (Func.isEmpty(unit)) {
|
||||
continue;
|
||||
}
|
||||
if (cargo.quantity() == null) {
|
||||
incomplete.add(unit);
|
||||
continue;
|
||||
}
|
||||
totals.merge(unit, cargo.quantity(), BigDecimal::add);
|
||||
}
|
||||
if (totals.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String unit;
|
||||
if (totals.size() == 1) {
|
||||
unit = totals.keySet().iterator().next();
|
||||
} else {
|
||||
unit = totals.containsKey(UNIT_TON) ? UNIT_TON : null;
|
||||
}
|
||||
if (unit == null || incomplete.contains(unit)) {
|
||||
return null;
|
||||
}
|
||||
return new Quantity(totals.get(unit), unit);
|
||||
}
|
||||
|
||||
/** 计重合计的数量与单位 */
|
||||
public record Quantity(BigDecimal value, String unit) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 货物明细行:一条货物一行,同名同单位的合并数量,按录入顺序
|
||||
* <p>
|
||||
* 与 {@link #cargoSummary(List)} 的合并口径一致,但数量与单位保持分开,供详情页逐行展示。
|
||||
* 数量为空的货物只留名称,不按 0 参与合并。
|
||||
*/
|
||||
public static List<Line> lines(List<Cargo> cargos) {
|
||||
Map<String, Integer> index = new LinkedHashMap<>();
|
||||
List<Line> lines = new ArrayList<>();
|
||||
for (Cargo cargo : cargos) {
|
||||
String name = cargo.displayName();
|
||||
String unit = Func.toStr(cargo.quantityUnit(), "");
|
||||
String key = name + '\u0000' + unit;
|
||||
Integer at = index.get(key);
|
||||
if (at == null) {
|
||||
index.put(key, lines.size());
|
||||
lines.add(new Line(name, cargo.quantity(), unit));
|
||||
continue;
|
||||
}
|
||||
Line merged = lines.get(at);
|
||||
if (cargo.quantity() == null) {
|
||||
continue;
|
||||
}
|
||||
BigDecimal quantity = merged.quantity() == null
|
||||
? cargo.quantity()
|
||||
: merged.quantity().add(cargo.quantity());
|
||||
lines.set(at, new Line(name, quantity, unit));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** 一条货物明细:数量可能为空(录入时只填了名称) */
|
||||
public record Line(String name, BigDecimal quantity, String unit) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 主表货名按顿号、逗号摊平;空格与空段去掉,全空时给空集合
|
||||
*/
|
||||
public static List<String> splitCargoNames(String cargoName) {
|
||||
if (Func.isEmpty(cargoName)) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> names = new ArrayList<>();
|
||||
for (String part : cargoName.split(NAME_SEPARATOR_REGEX)) {
|
||||
String name = part.trim();
|
||||
if (Func.isNotEmpty(name)) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
private static String firstText(Map row, String... keys) {
|
||||
for (String key : keys) {
|
||||
Object value = row.get(key);
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
String text = String.valueOf(value).trim();
|
||||
if (Func.isNotEmpty(text)) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static BigDecimal quantityOf(Map row) {
|
||||
for (String key : QUANTITY_KEYS) {
|
||||
Object value = row.get(key);
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
String text = String.valueOf(value).trim();
|
||||
if (Func.isEmpty(text)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
BigDecimal quantity = new BigDecimal(text);
|
||||
// 录入端用 -1 表示「未填」,落到汇总里会把合计算成负数
|
||||
if (quantity.compareTo(BigDecimal.ONE.negate()) == 0) {
|
||||
continue;
|
||||
}
|
||||
return quantity;
|
||||
} catch (NumberFormatException exception) {
|
||||
// 这一字段不是数字:继续试下一个候选字段
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运单上的一条货物
|
||||
* <p>
|
||||
* 数量与单位可能为空:录货物时允许只填名称。展示端要把「没填数量」与「数量为 0」区分开。
|
||||
*/
|
||||
public record Cargo(String cargoName, String cargoType, BigDecimal quantity, String quantityUnit) {
|
||||
|
||||
/**
|
||||
* 展示用货名:空名称回退成「货物」
|
||||
*/
|
||||
public String displayName() {
|
||||
return Func.toStrWithEmpty(cargoName, FALLBACK_NAME);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+135
-4
@@ -38,6 +38,7 @@ import java.util.Date;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -132,6 +133,81 @@ public final class WaybillProcessSupport {
|
||||
return ACCEPT_REJECTED.equalsIgnoreCase(stringVal(driverAcceptStatus));
|
||||
}
|
||||
|
||||
/**
|
||||
* 运单已结束:已完成或已取消。此后不再接受司机打卡。
|
||||
* <p>
|
||||
* 只认这两个状态,大小写不敏感。草稿、待执行、待调度、调度中的运单司机仍要能打卡,
|
||||
* 所以不能按 {@link #isTerminalBusinessStatus} 的「终态或调度中间态」口径判定。
|
||||
*/
|
||||
public static boolean isPunchEnded(String businessStatus) {
|
||||
String status = stringVal(businessStatus);
|
||||
return "completed".equalsIgnoreCase(status) || "cancelled".equalsIgnoreCase(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束态下的打卡拒绝文案;非结束态返回 null。
|
||||
*/
|
||||
public static String punchEndedReason(String businessStatus) {
|
||||
String status = stringVal(businessStatus);
|
||||
if ("completed".equalsIgnoreCase(status)) {
|
||||
return "运单已完成";
|
||||
}
|
||||
if ("cancelled".equalsIgnoreCase(status)) {
|
||||
return "运单已取消";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运单已开始运输:只有进行中可以打卡(在途与普通节点同一判据)。
|
||||
* <p>
|
||||
* 草稿、待执行(待接单)、待调度、调度中都还没上路,任何打卡都没有承载物。
|
||||
* 判据是校正后落库的状态:无需接单的待执行运单在 {@link #normalizeBusinessStatus}
|
||||
* 里已被提升为进行中,未提升的待执行就是真的还没接单。
|
||||
*/
|
||||
public static boolean isTransitPunchStarted(String businessStatus) {
|
||||
return STATUS_RUNNING.equalsIgnoreCase(stringVal(businessStatus));
|
||||
}
|
||||
|
||||
/**
|
||||
* 未开始运输时的打卡拒绝文案;进行中与已结束返回 null(结束文案由 {@link #punchEndedReason} 给)。
|
||||
*/
|
||||
public static String punchNotStartedReason(String businessStatus) {
|
||||
if (isTransitPunchStarted(businessStatus) || isPunchEnded(businessStatus)) {
|
||||
return null;
|
||||
}
|
||||
return "运单尚未开始运输,暂不可打卡";
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定自然日是否已打过在途卡。
|
||||
* <p>
|
||||
* 与运单状态无关,只回答「这一天打过没有」。{@link TransitCheckinDecision#doneToday()}
|
||||
* 只在进行中分支计算,状态一非进行中就回到 false,两者不可互相替代。
|
||||
*/
|
||||
public static boolean isPunchedOn(Date lastPunchAt, LocalDateTime now) {
|
||||
LocalDate day = (now == null ? LocalDateTime.now() : now).toLocalDate();
|
||||
LocalDate last = toLocalDate(lastPunchAt);
|
||||
return last != null && last.equals(day);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报异常前的运单状态门禁原因;可上报时返回 null。
|
||||
* <p>
|
||||
* 与打卡共用「已开始运输」判据,但文案说的是能不能报,不是能不能打。
|
||||
* 结束优先于未开始:已完成/已取消的运单先说结束,不再提「等你上路」。
|
||||
*/
|
||||
public static String reportBlockedReason(String businessStatus) {
|
||||
String endedReason = punchEndedReason(businessStatus);
|
||||
if (endedReason != null) {
|
||||
return endedReason + ",不能上报异常";
|
||||
}
|
||||
if (!isTransitPunchStarted(businessStatus)) {
|
||||
return "运单尚未开始运输,暂不可上报异常";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static boolean isTerminalBusinessStatus(String businessStatus) {
|
||||
return "draft".equals(businessStatus)
|
||||
|| "completed".equals(businessStatus)
|
||||
@@ -197,15 +273,20 @@ public final class WaybillProcessSupport {
|
||||
int frequencyDays = parsePositiveInt(transit.get("frequencyDays"), 1);
|
||||
String timeStart = normalizeHm(stringVal(transit.get("timeStart")), "00:00");
|
||||
String timeEnd = normalizeHm(stringVal(transit.get("timeEnd")), "23:59");
|
||||
// punch=是即展示卡片;非进行中仅不可作为「今日待打」
|
||||
if (!STATUS_RUNNING.equals(businessStatus)) {
|
||||
return new TransitCheckinDecision(true, true, false, false, frequencyDays, timeStart, timeEnd);
|
||||
// 结束不可逆,没有解除途径:卡片仍下发,但不可提交,也不算今日待打
|
||||
if (isPunchEnded(businessStatus)) {
|
||||
return new TransitCheckinDecision(false, true, false, false, frequencyDays, timeStart, timeEnd);
|
||||
}
|
||||
// punch=是即展示卡片;未开始运输(草稿/待执行/待调度/调度中)的卡片仍下发,
|
||||
// 但不可提交——打卡的前提是运单已上路(判据同时用于节点打卡)。
|
||||
if (!isTransitPunchStarted(businessStatus)) {
|
||||
return new TransitCheckinDecision(false, true, false, false, frequencyDays, timeStart, timeEnd);
|
||||
}
|
||||
|
||||
LocalDateTime current = now == null ? LocalDateTime.now() : now;
|
||||
LocalDate today = current.toLocalDate();
|
||||
LocalDate lastDate = toLocalDate(lastPunchAt);
|
||||
boolean doneToday = lastDate != null && lastDate.equals(today);
|
||||
boolean doneToday = isPunchedOn(lastPunchAt, current);
|
||||
boolean dueByFrequency;
|
||||
if (lastDate == null) {
|
||||
dueByFrequency = true;
|
||||
@@ -419,6 +500,28 @@ public final class WaybillProcessSupport {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
|
||||
/**
|
||||
* 顺序门禁点名的那一个前置节点名称:取**最先未完成**的。
|
||||
* <p>
|
||||
* 它才是司机现在能去打卡的那一步——后面那些未完成的前置节点自己也还打不了,
|
||||
* 一起列出来既长,又不是一个可执行的下一步。
|
||||
*
|
||||
* @param processJson 过程配置
|
||||
* @param nodeKeys 未完成的前置节点 key,即 {@link #unfinishedPrerequisiteKeys} 的返回
|
||||
*/
|
||||
public static String firstUnfinishedPrerequisiteName(String processJson, Collection<String> nodeKeys) {
|
||||
if (nodeKeys == null || nodeKeys.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
for (Map<String, Object> node : listDriverPunchNodes(processJson)) {
|
||||
String key = nodeKey(node);
|
||||
if (nodeKeys.contains(key)) {
|
||||
return nodeName(node);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 两个节点编码是否指同一个节点:两侧去空白、忽略大小写。
|
||||
* <p>
|
||||
@@ -637,6 +740,34 @@ public final class WaybillProcessSupport {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 还没打卡的司机打卡节点名称(在途除外):回单由业务员确认前,这些节点必须都打过卡,否则
|
||||
* 「确认回单」就成了绕过打卡门禁的通道——完成接口本身不要求节点打卡。
|
||||
* <p>
|
||||
* 在途是频次打卡节点、不是必经的一步,故不参与;空列表表示该打卡的都打完了。
|
||||
*
|
||||
* @param punchedKeys 已打卡的节点编码与名称,**小写**(库里的 node_code / node_name 都可能被用来对节点)
|
||||
*/
|
||||
public static List<String> missingPunchNodeNames(String processJson, Collection<String> punchedKeys) {
|
||||
List<Map<String, Object>> punchConfigs = listDriverPunchNodes(processJson);
|
||||
if (punchConfigs.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Collection<String> punched = punchedKeys == null ? Collections.emptySet() : punchedKeys;
|
||||
List<String> missing = new ArrayList<>();
|
||||
for (Map<String, Object> node : punchConfigs) {
|
||||
if (isTransitNode(node)) {
|
||||
continue;
|
||||
}
|
||||
String code = nodeKey(node).toLowerCase(Locale.ROOT);
|
||||
String name = nodeName(node).toLowerCase(Locale.ROOT);
|
||||
if (!punched.contains(code) && !punched.contains(name)) {
|
||||
missing.add(nodeName(node));
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
private static boolean isTransitNode(Map<String, Object> node) {
|
||||
String key = stringVal(node.get("key"));
|
||||
String name = stringVal(node.get("name"));
|
||||
|
||||
+7
-2
@@ -70,10 +70,15 @@ public class ReceiptFlowWrapper extends BaseEntityWrapper<KingdeeReceiptFlow, Re
|
||||
case "failed" -> "处理失败";
|
||||
default -> "未推送";
|
||||
});
|
||||
// 剩余可认领金额 = 公告未认领金额 − 累计认领金额(认领上限为公告口径)
|
||||
// 剩余可认领金额 = 可认领上限 − 累计认领金额(上限取公告未认领与收款金额较小值)
|
||||
BigDecimal unclaimedAmount = money(entity.getUnclaimedAmount());
|
||||
BigDecimal receiptAmount = money(entity.getReceiptAmount());
|
||||
BigDecimal claimCeiling = unclaimedAmount;
|
||||
if (receiptAmount.compareTo(BigDecimal.ZERO) > 0 && unclaimedAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||
claimCeiling = unclaimedAmount.min(receiptAmount);
|
||||
}
|
||||
BigDecimal claimedAmount = money(entity.getClaimedAmount());
|
||||
vo.setRemainingAmount(unclaimedAmount.subtract(claimedAmount).max(BigDecimal.ZERO));
|
||||
vo.setRemainingAmount(claimCeiling.subtract(claimedAmount).max(BigDecimal.ZERO));
|
||||
vo.setCreateUserName(UserCache.getUserRealName(entity.getCreateUser()));
|
||||
vo.setUpdateUserName(UserCache.getUserRealName(entity.getUpdateUser()));
|
||||
return vo;
|
||||
|
||||
Reference in New Issue
Block a user