Merge remote-tracking branch 'origin/feature/20261019-bfi-all' into feature/hcs-api-p0

# Conflicts:
#	blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java
This commit is contained in:
刘泉佳
2026-10-22 09:39:06 +08:00
250 changed files with 23455 additions and 1479 deletions
@@ -309,6 +309,12 @@ public class BfiProperties {
*/
private String invoiceTypes = "";
/**
* 客商单位过滤开关:开启后只同步开票单位(销方)与受票单位(购方)税号均已登记在
* 客商发票信息(blade_customer_invoice_info)中的发票,前端票池展示查询同用此开关过滤,默认关闭
*/
private boolean customerUnitsOnly = false;
/**
* 同步 single-flight 锁租约秒数,须大于最坏一轮耗时;进程异常退出时锁在租约到期后自动释放
*/
@@ -32,6 +32,9 @@ public final class KingdeeLockConstant {
/** 来源操作类型:单据删除(解锁回调携带 DELETE 时 WLTMS 废除源单并还原关联数据) */
public static final String OPERATION_DELETE = "DELETE";
/** 来源操作类型:结算调整重推(结算调整单重推后解锁原结算单旧锁记录,非金蝶回调) */
public static final String OPERATION_ADJUST_REPUSH = "ADJUST_REPUSH";
/** 系统标识:WLTMS(运输管理系统,即本系统) */
public static final String SYSTEM_WLTMS = "WLTMS";
@@ -31,9 +31,21 @@ package org.springblade.transport.constant;
*/
public final class TransportBusiConstant {
/** 付款类型:项目预付。 */
public static final String PAYMENT_TYPE_PROJECT_ADVANCE = "project_advance";
/** 付款类型:进度预付。 */
public static final String PAYMENT_TYPE_PROGRESS_ADVANCE = "progress_advance";
/** 付款类型:结算付款(关联正式结算单的正式付款)。 */
public static final String PAYMENT_TYPE_SETTLEMENT = "settlement_payment";
/** 结算单类型:预结算。 */
public static final String SETTLEMENT_BILL_PRE = "pre";
/** 结算单类型:正式结算。 */
public static final String SETTLEMENT_BILL_FORMAL = "formal";
/** 审批状态:草稿(允许编辑、删除)。 */
public static final String DRAFT = "draft";
@@ -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")
@@ -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)")
@@ -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("来源系统标识错误");
}
}
}
@@ -84,10 +84,9 @@ public class InvoiceApplicationController extends BladeController {
public R<IPage<Map<String, Object>>> settlementCandidates(Query query,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String contractCategory,
@RequestParam(defaultValue = "receivable") String settlementType,
@RequestParam(defaultValue = "unreceived") String invoiceStatus) {
@RequestParam(defaultValue = "receivable") String settlementType) {
return R.data(invoiceApplicationService.settlementCandidates(
Condition.getPage(query), keyword, contractCategory, settlementType, invoiceStatus));
Condition.getPage(query), keyword, contractCategory, settlementType));
}
@GetMapping("/settlement-details")
@@ -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));
}
}
@@ -34,6 +34,7 @@ import org.springblade.transport.pojo.dto.PaymentApplicationSaveRequest;
import org.springblade.transport.pojo.dto.PaymentApplicationStatusRequest;
import org.springblade.transport.pojo.vo.PaymentApplicationReferenceAmountVO;
import org.springblade.transport.pojo.vo.PaymentApplicationVO;
import org.springblade.transport.pojo.vo.ProjectAdvanceWriteOffOptionVO;
import org.springblade.transport.service.IPaymentApplicationService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@@ -67,6 +68,17 @@ public class PaymentApplicationController extends BladeController {
@RequestParam Long referenceId, @RequestParam(required = false) Long excludeId) {
return R.data(paymentApplicationService.referenceAmount(paymentType, referenceId, excludeId));
}
@GetMapping("/project-advance-write-off-options")
@ApiOperationSupport(order = 3)
@Operation(summary = "按合同查询可选项目预付核销列表")
public R<List<ProjectAdvanceWriteOffOptionVO>> projectAdvanceWriteOffOptions(
@RequestParam Long contractId,
@RequestParam(required = false) String excludeSettlementType,
@RequestParam(required = false) Long excludeSettlementId) {
return R.data(paymentApplicationService.projectAdvanceWriteOffOptions(
contractId, excludeSettlementType, excludeSettlementId));
}
@PostMapping("/save")
@ApiOperationSupport(order = 4)
@Operation(summary = "保存付款申请")
@@ -94,6 +94,13 @@ public class PreSettlementController extends BladeController {
return R.data(preSettlementService.contractOptions(keyword));
}
@GetMapping("/next-no")
@ApiOperationSupport(order = 4)
@Operation(summary = "获取最新预结算单号")
public R<String> nextNo() {
return R.data(preSettlementService.nextNo());
}
@GetMapping("/fee-options")
@ApiOperationSupport(order = 4)
@Operation(summary = "费用类型及费用项")
@@ -7,6 +7,8 @@ import org.springblade.core.mp.support.Condition;
import org.springblade.core.mp.support.Query;
import org.springblade.core.secure.annotation.PreAuth;
import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.listener.KingdeeApprovalSyncListener;
import org.springblade.transport.pojo.dto.SettlementAdjustmentSaveRequest;
import org.springblade.transport.pojo.dto.SettlementAdjustmentStatusRequest;
import org.springblade.transport.pojo.entity.SettlementAdjustment;
@@ -33,7 +35,20 @@ public class SettlementAdjustmentController extends BladeController {
@PostMapping("/save") public R<Long> save(@RequestBody SettlementAdjustmentSaveRequest request) { return R.data(service.saveDraft(request)); }
@PostMapping("/remove") public R remove(@RequestParam Long id) { service.removeDraft(id); return R.success("删除成功"); }
@PostMapping("/submit") public R submit(@RequestBody SettlementAdjustmentStatusRequest request) { service.submit(request); return R.success("提交成功"); }
@PostMapping("/approve") public R approve(@RequestBody SettlementAdjustmentStatusRequest request) { service.approve(request); return R.success("审批通过"); }
@PostMapping("/approve") public R approve(@RequestBody SettlementAdjustmentStatusRequest request) {
String hint = service.approve(request);
// 未推送场景自动推送失败原因以警告文案返回(data=false 供前端区分提示样式)
String syncFailure = KingdeeApprovalSyncListener.pollSyncFailure();
if (Func.isNotBlank(syncFailure)) {
return R.data(Boolean.FALSE, "审批通过,但自动同步金蝶失败:" + syncFailure);
}
if (Func.isNotBlank(hint)) {
return R.data(Boolean.TRUE, "审批通过;" + hint);
}
return R.success("审批通过");
}
@PostMapping("/return") public R returnBill(@RequestBody SettlementAdjustmentStatusRequest request) { service.returnBill(request); return R.success("已驳回"); }
@PostMapping("/repush") public R<String> repush(@RequestParam Long id) { return R.data(service.repush(id)); }
@GetMapping("/kingdee-writeoff-precheck") public R<Map<String, Object>> kingdeeWriteOffPrecheck(@RequestParam Long formalSettlementId) { return R.data(service.kingdeeWriteOffPrecheck(formalSettlementId)); }
@PostMapping("/sync-kingdee") public R<String> syncKingdee(@RequestParam Long id) { return R.data(service.syncKingdee(id)); }
}
@@ -0,0 +1,162 @@
/**
* 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 com.baomidou.mybatisplus.core.metadata.IPage;
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.mp.support.Condition;
import org.springblade.core.mp.support.Query;
import org.springblade.core.secure.annotation.PreAuth;
import org.springblade.core.tool.api.R;
import org.springblade.transport.pojo.dto.SettlementImportBatchRequest;
import org.springblade.transport.pojo.dto.SettlementImportVoidRequest;
import org.springblade.transport.pojo.vo.SettlementImportBatchVO;
import org.springblade.transport.pojo.vo.WaybillImportValidateResultVO;
import org.springblade.transport.service.ISettlementImportBatchService;
import org.springframework.web.bind.annotation.GetMapping;
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.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.Map;
/**
* 导入结算单控制器
*
* @author Chill
*/
@RestController
@AllArgsConstructor
@PreAuth(menu = "settlement_import")
@RequestMapping("settlement-import")
@Tag(name = "导入结算单", description = "导入结算单管理")
public class SettlementImportController extends BladeController {
private final ISettlementImportBatchService settlementImportBatchService;
@GetMapping("/options")
@ApiOperationSupport(order = 1)
@Operation(summary = "筛选项与表单选项")
public R<Map<String, List<Map<String, Object>>>> options() {
return R.data(settlementImportBatchService.options());
}
@GetMapping("/next-code")
@ApiOperationSupport(order = 2)
@Operation(summary = "下一个批次号")
public R<String> nextCode() {
return R.data(settlementImportBatchService.nextBatchNo());
}
@GetMapping("/list")
@ApiOperationSupport(order = 3)
@Operation(summary = "导入结算单批次分页")
public R<IPage<SettlementImportBatchVO>> list(SettlementImportBatchRequest request, Query query) {
return R.data(settlementImportBatchService.page(Condition.getPage(query), request));
}
@GetMapping("/detail")
@ApiOperationSupport(order = 4)
@Operation(summary = "导入结算单批次详情")
public R<SettlementImportBatchVO> detail(@RequestParam Long id) {
return R.data(settlementImportBatchService.detail(id));
}
@GetMapping("/export-template")
@ApiOperationSupport(order = 5)
@Operation(summary = "导出导入模板")
public void exportTemplate(HttpServletResponse response) {
settlementImportBatchService.exportTemplate(response);
}
@PostMapping("/parse")
@ApiOperationSupport(order = 6)
@Operation(summary = "解析导入 Excel")
public R<Map<String, Object>> parse(@RequestPart("file") MultipartFile file) {
return R.data(settlementImportBatchService.parse(file));
}
@PostMapping("/validate")
@ApiOperationSupport(order = 7)
@Operation(summary = "校验导入数据")
public R<WaybillImportValidateResultVO> validate(@RequestBody SettlementImportBatchRequest request) {
return R.data(settlementImportBatchService.validate(request));
}
@PostMapping("/export-failures")
@ApiOperationSupport(order = 8)
@Operation(summary = "导出失败明细")
public void exportFailures(@RequestBody SettlementImportBatchRequest request, HttpServletResponse response) {
settlementImportBatchService.exportFailures(request, null, response);
}
@PostMapping("/draft")
@ApiOperationSupport(order = 9)
@Operation(summary = "保存草稿")
public R<SettlementImportBatchVO> draft(@RequestBody SettlementImportBatchRequest request) {
return R.data(settlementImportBatchService.saveDraft(request));
}
@PostMapping("/confirm")
@ApiOperationSupport(order = 10)
@Operation(summary = "确认导入结算单")
public R<SettlementImportBatchVO> confirm(@RequestBody SettlementImportBatchRequest request) {
return R.data(settlementImportBatchService.confirm(request));
}
@PostMapping("/void")
@ApiOperationSupport(order = 11)
@Operation(summary = "作废导入结算单批次")
public R voidBatch(@RequestBody SettlementImportVoidRequest request) {
settlementImportBatchService.voidBatch(request);
return R.success("作废成功");
}
@PostMapping("/remove")
@ApiOperationSupport(order = 12)
@Operation(summary = "删除草稿批次")
public R remove(@RequestParam Long id) {
settlementImportBatchService.removeDraft(id);
return R.success("删除成功");
}
@GetMapping("/history-waybills")
@ApiOperationSupport(order = 13)
@Operation(summary = "历史运单可选列表")
public R<List<Map<String, Object>>> historyWaybills(SettlementImportBatchRequest request) {
return R.data(settlementImportBatchService.historyWaybills(request));
}
}
@@ -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")
@@ -0,0 +1,496 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.excel;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.DateUtil;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.springblade.common.excel.ImportCellReadException;
import org.springblade.core.log.exception.ServiceException;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* 导入结算单 Excel 读取器(Apache POI)。
* <p>
* 定位「*运输费」或「运输费」列,其后直到日期/备注等固定列(兼容旧模板中的「结算金额」)为动态费用列。
* 结算金额不由模板填写,解析时按「运输费 + 各费用项」自动合计。
*
* @author Chill
*/
public final class SettlementImportFeeReader {
private static final DataFormatter DATA_FORMATTER = new DataFormatter(Locale.CHINA);
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final Set<String> DATE_FIELDS = Set.of(
"actualStartDate", "actualEndDate", "planStartDate", "planEndDate"
);
private static final Set<String> OPTIONAL_DECIMAL_FIELDS = Set.of("mileage", "unitPrice");
/** 费用列结束边界:其后为固定业务列;含旧模板「结算金额」以便兼容历史文件 */
private static final Set<String> FEE_END_HEADERS = Set.of(
"结算金额",
"*实际发货时间", "实际发货时间",
"*实际完成时间", "实际完成时间",
"预计发货时间", "预计完成时间",
"备注", "同一运单标识号"
);
private SettlementImportFeeReader() {
}
/**
* 解析结果
*/
public static final class ParseResult {
private final List<Map<String, Object>> rows;
private final List<String> feeItemNames;
private final List<String> warnings;
private final List<String> headers;
public ParseResult(List<Map<String, Object>> rows, List<String> feeItemNames, List<String> warnings,
List<String> headers) {
this.rows = rows;
this.feeItemNames = feeItemNames;
this.warnings = warnings;
this.headers = headers;
}
public List<Map<String, Object>> getRows() {
return rows;
}
public List<String> getFeeItemNames() {
return feeItemNames;
}
public List<String> getWarnings() {
return warnings;
}
public List<String> getHeaders() {
return headers;
}
}
public static ParseResult read(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new ServiceException("上传文件不能为空");
}
try (InputStream inputStream = file.getInputStream(); Workbook workbook = WorkbookFactory.create(inputStream)) {
Sheet sheet = resolveDataSheet(workbook);
Row headerRow = sheet.getRow(sheet.getFirstRowNum());
if (headerRow == null) {
throw new ServiceException("未识别到表头,请使用标准导入结算单模板");
}
Map<Integer, String> headers = readHeaderMap(headerRow);
int transportFeeColumn = findTransportFeeColumn(headers);
if (transportFeeColumn < 0) {
throw new ServiceException("未找到「*运输费」或「运输费」列");
}
List<Integer> feeColumns = new ArrayList<>();
List<String> feeItemNames = new ArrayList<>();
int endColumn = findFeeEndColumn(headers, transportFeeColumn);
for (int column = transportFeeColumn + 1; column < endColumn; column++) {
String name = headers.get(column);
if (name == null || name.isBlank() || FEE_END_HEADERS.contains(name)) {
continue;
}
feeColumns.add(column);
feeItemNames.add(name);
}
List<Map<String, Object>> rows = new ArrayList<>();
int firstDataRow = sheet.getFirstRowNum() + 1;
int lastRow = sheet.getLastRowNum();
for (int rowIndex = firstDataRow; rowIndex <= lastRow; rowIndex++) {
Row row = sheet.getRow(rowIndex);
if (row == null) {
continue;
}
Map<String, Object> mapped = mapRow(headers, feeColumns, row, rowIndex);
if (mapped != null) {
rows.add(mapped);
}
}
return new ParseResult(rows, feeItemNames, List.of(), new ArrayList<>(headers.values()));
} catch (ServiceException exception) {
throw exception;
} catch (Exception exception) {
throw new ServiceException("读取导入结算单 Excel 失败:" + exception.getMessage());
}
}
/** 优先取含「*运输费/运输费」表头的工作表,否则取第一个非空表。 */
private static Sheet resolveDataSheet(Workbook workbook) {
Sheet fallback = null;
for (int index = 0; index < workbook.getNumberOfSheets(); index++) {
Sheet sheet = workbook.getSheetAt(index);
if (sheet == null) {
continue;
}
Row headerRow = sheet.getRow(sheet.getFirstRowNum());
if (headerRow == null) {
continue;
}
Map<Integer, String> headers = readHeaderMap(headerRow);
if (findTransportFeeColumn(headers) >= 0) {
return sheet;
}
if (fallback == null && !headers.isEmpty()) {
fallback = sheet;
}
}
if (fallback != null) {
return fallback;
}
throw new ServiceException("Excel 中未找到有效工作表");
}
private static Map<Integer, String> readHeaderMap(Row headerRow) {
Map<Integer, String> headers = new LinkedHashMap<>();
short lastCell = headerRow.getLastCellNum();
for (int column = 0; column < lastCell; column++) {
String value = normalizeHeader(cellString(headerRow.getCell(column)));
if (value != null && !value.isBlank()) {
headers.put(column, value);
}
}
return headers;
}
/** 去掉 BOM/不可见空白,全角星号转半角,便于匹配「*运输费」。 */
private static String normalizeHeader(String raw) {
if (raw == null) {
return null;
}
String text = raw.replace("\uFEFF", "")
.replace('\u00A0', ' ')
.replace('*', '*')
.trim();
return text.isEmpty() ? null : text;
}
private static Map<String, Object> mapRow(Map<Integer, String> headers, List<Integer> feeColumns,
Row row, int excelRowIndex) {
Map<String, Object> mapped = new LinkedHashMap<>();
Map<String, BigDecimal> feeItems = new LinkedHashMap<>();
for (Map.Entry<Integer, String> entry : headers.entrySet()) {
int column = entry.getKey();
String trimmedHeader = entry.getValue();
Cell poiCell = row.getCell(column);
if (feeColumns.contains(column)) {
feeItems.put(trimmedHeader, decimal(cellString(poiCell), trimmedHeader, excelRowIndex));
continue;
}
String field = FIXED_HEADER_MAP.get(trimmedHeader);
if (field == null) {
mapped.put(trimmedHeader, blankToNull(cellString(poiCell)));
continue;
}
if (DATE_FIELDS.contains(field)) {
mapped.put(field, dateCellString(poiCell));
} else if (OPTIONAL_DECIMAL_FIELDS.contains(field)) {
mapped.put(field, optionalDecimal(cellString(poiCell), trimmedHeader, excelRowIndex));
} else if ("transportFee".equals(field) || "quantity".equals(field)) {
mapped.put(field, decimal(cellString(poiCell), trimmedHeader, excelRowIndex));
} else if ("settlementAmount".equals(field)) {
// 旧模板可能仍含结算金额列,读入后忽略,统一按运输费+费用项自动合计
continue;
} else if ("serialNumber".equals(field)) {
mapped.put(field, intValue(cellString(poiCell)));
} else {
mapped.put(field, blankToNull(cellString(poiCell)));
}
mapped.put(trimmedHeader, mapped.get(field));
}
boolean hasBusinessValue = mapped.values().stream().anyMatch(value -> {
if (value == null) {
return false;
}
if (value instanceof String text) {
return !text.isBlank();
}
return true;
}) || feeItems.values().stream().anyMatch(amount -> amount != null && amount.compareTo(BigDecimal.ZERO) != 0);
if (!hasBusinessValue) {
return null;
}
BigDecimal transportFee = toDecimal(mapped.get("transportFee"));
BigDecimal feeItemTotal = feeItems.values().stream()
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(2, RoundingMode.HALF_UP);
BigDecimal settlementAmount = transportFee.add(feeItemTotal).setScale(2, RoundingMode.HALF_UP);
mapped.put("settlementAmount", settlementAmount);
mapped.put("结算金额", settlementAmount);
mapped.put("feeItems", feeItems);
mapped.put("freight", transportFee);
mapped.put("运费", transportFee);
mapped.put("*运输费", transportFee);
mapped.put("运输费", transportFee);
mapped.put("otherFeeTotal", feeItemTotal);
mapped.put("其他费用合计", feeItemTotal);
mapped.put("freightTotal", settlementAmount);
mapped.put("运费合计", settlementAmount);
if (mapped.get("unitPrice") != null) {
mapped.put("单价", mapped.get("unitPrice"));
mapped.put("运输单价", mapped.get("unitPrice"));
}
return mapped;
}
private static int findTransportFeeColumn(Map<Integer, String> headers) {
int starred = findColumnExact(headers, "*运输费");
if (starred >= 0) {
return starred;
}
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()))
.mapToInt(Map.Entry::getKey)
.findFirst()
.orElse(-1);
}
private static String cellString(Cell cell) {
if (cell == null) {
return null;
}
String text = DATA_FORMATTER.formatCellValue(cell);
if (text == null) {
return null;
}
text = text.trim();
return text.isEmpty() ? null : text;
}
/**
* 读取日期单元格:Excel 日期序列优先格式化为 yyyy-MM-dd;
* 文本短日期(如 1/1/26、10/10/2026)归一为四位年,供后续 LenientDateParser 识别。
*/
private static String dateCellString(Cell cell) {
if (cell == null) {
return null;
}
CellType cellType = cell.getCellType();
if (cellType == CellType.FORMULA) {
cellType = cell.getCachedFormulaResultType();
}
if (cellType == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) {
Date date = cell.getDateCellValue();
if (date == null) {
return null;
}
LocalDate localDate = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
return DATE_FORMATTER.format(localDate);
}
String text = cellString(cell);
return normalizeExcelShortDate(text);
}
/** 将 Excel 常见短日期文本归一为 yyyy-MM-dd;无法识别时原样返回交由校验层报错。 */
private static String normalizeExcelShortDate(String text) {
if (text == null || text.isBlank()) {
return null;
}
String normalized = text.trim().replace('.', '/').replace('-', '/');
String[] parts = normalized.split("[/\\s]+");
if (parts.length < 3) {
return text.trim();
}
try {
int first = Integer.parseInt(parts[0]);
int second = Integer.parseInt(parts[1]);
int third = Integer.parseInt(parts[2].replaceAll("[^0-9].*$", ""));
int year;
int month;
int day;
if (parts[0].length() == 4) {
year = first;
month = second;
day = third;
} else if (parts[2].length() == 4 || parts[2].length() == 2) {
// M/d/yy 或 M/d/yyyy(Excel 中文本/短日期常见)
month = first;
day = second;
year = parts[2].length() == 2 ? 2000 + third : third;
if (parts[2].length() == 2 && third >= 100) {
return text.trim();
}
} else {
return text.trim();
}
LocalDate localDate = LocalDate.of(year, month, day);
return DATE_FORMATTER.format(localDate);
} catch (Exception exception) {
return text.trim();
}
}
private static BigDecimal decimal(String cell, String columnName, Integer excelRowIndex) {
if (cell == null || cell.isBlank()) {
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
}
String normalized = cell.trim().replace(",", "");
try {
return new BigDecimal(normalized).setScale(2, RoundingMode.HALF_UP);
} catch (NumberFormatException exception) {
throw new ImportCellReadException(excelRowIndex, columnName, cell.trim());
}
}
/** 可选数值:空白返回 null,避免空里程被当成 0 触发「必须为正数」。 */
private static BigDecimal optionalDecimal(String cell, String columnName, Integer excelRowIndex) {
if (cell == null || cell.isBlank()) {
return null;
}
return decimal(cell, columnName, excelRowIndex);
}
private static Integer intValue(String cell) {
if (cell == null || cell.isBlank()) {
return null;
}
try {
return new BigDecimal(cell.trim().replace(",", "")).intValue();
} catch (NumberFormatException exception) {
return null;
}
}
private static String blankToNull(String cell) {
if (cell == null) {
return null;
}
String text = cell.trim();
return text.isEmpty() ? null : text;
}
private static BigDecimal toDecimal(Object value) {
if (value == null) {
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
}
if (value instanceof BigDecimal bigDecimal) {
return bigDecimal;
}
try {
return new BigDecimal(String.valueOf(value).trim()).setScale(2, RoundingMode.HALF_UP);
} catch (NumberFormatException exception) {
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
}
}
private static final Map<String, String> FIXED_HEADER_MAP = buildFixedHeaderMap();
private static Map<String, String> buildFixedHeaderMap() {
Map<String, String> map = new LinkedHashMap<>();
map.put("序号", "serialNumber");
map.put("原始单号", "originalNo");
map.put("配载标识号", "loadingIdentifier");
map.put("*车牌号/船班号/船次号/班次号", "vehicleNo");
map.put("车牌号/船班号/船次号/班次号", "vehicleNo");
map.put("*车牌号/航班号/船号/班列号", "vehicleNo");
map.put("车牌号/航班号/船号/班列号", "vehicleNo");
map.put("*运输方式", "transportType");
map.put("运输方式", "transportType");
map.put("运输类型", "transportType");
map.put("*运输类型", "transportType");
map.put("司机/船长姓名", "driverName");
map.put("司机/船长手机号", "driverPhone");
map.put("*发货地址", "departureAddress");
map.put("发货地址", "departureAddress");
map.put("发货联系人", "departureContact");
map.put("发货联系人电话", "departurePhone");
map.put("*到货地址", "arrivalAddress");
map.put("到货地址", "arrivalAddress");
map.put("收货联系人", "arrivalContact");
map.put("收货联系人电话", "arrivalPhone");
map.put("*货物名称", "cargoName");
map.put("货物名称", "cargoName");
map.put("*货物类型", "cargoType");
map.put("货物类型", "cargoType");
map.put("包装", "packageType");
map.put("*数量", "quantity");
map.put("数量", "quantity");
map.put("*数量单位", "quantityUnit");
map.put("数量单位", "quantityUnit");
map.put("规格", "specification");
map.put("型号", "model");
map.put("里程(km)", "mileage");
map.put("里程", "mileage");
map.put("运输单价", "unitPrice");
map.put("单价", "unitPrice");
map.put("*运输费", "transportFee");
map.put("运输费", "transportFee");
map.put("结算金额", "settlementAmount");
map.put("*实际发货时间", "actualStartDate");
map.put("实际发货时间", "actualStartDate");
map.put("*实际完成时间", "actualEndDate");
map.put("实际完成时间", "actualEndDate");
map.put("预计发货时间", "planStartDate");
map.put("预计完成时间", "planEndDate");
map.put("备注", "remark");
map.put("同一运单标识号", "waybillIdentifier");
return map;
}
}
@@ -0,0 +1,118 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.excel;
import cn.idev.excel.annotation.ExcelIgnore;
import cn.idev.excel.annotation.ExcelProperty;
import cn.idev.excel.annotation.write.style.ColumnWidth;
import cn.idev.excel.annotation.write.style.ContentRowHeight;
import cn.idev.excel.annotation.write.style.HeadRowHeight;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
/**
* 导入结算单 Excel 固定列模型(失败明细兜底导出;费用项由用户在原模板中自行增列)。
*
* @author Chill
*/
@Data
@ColumnWidth(22)
@HeadRowHeight(28)
@ContentRowHeight(18)
public class SettlementImportTemplateExcel implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@ExcelProperty("序号")
private Integer serialNumber;
@ExcelProperty("原始单号")
private String originalNo;
@ExcelProperty("配载标识号")
private String loadingIdentifier;
@ExcelProperty("*车牌号/航班号/船号/班列号")
private String vehicleNo;
@ExcelProperty("*运输方式")
private String transportType;
@ExcelProperty("司机/船长姓名")
private String driverName;
@ExcelProperty("司机/船长手机号")
private String driverPhone;
@ExcelProperty("*发货地址")
private String departureAddress;
@ExcelProperty("发货联系人")
private String departureContact;
@ExcelProperty("发货联系人电话")
private String departurePhone;
@ExcelProperty("*到货地址")
private String arrivalAddress;
@ExcelProperty("收货联系人")
private String arrivalContact;
@ExcelProperty("收货联系人电话")
private String arrivalPhone;
@ExcelProperty("*货物名称")
private String cargoName;
@ExcelProperty("*货物类型")
private String cargoType;
@ExcelProperty("包装")
private String packageType;
@ExcelProperty("*数量")
private BigDecimal quantity;
@ExcelProperty("*数量单位")
private String quantityUnit;
@ExcelProperty("规格")
private String specification;
@ExcelProperty("型号")
private String model;
@ExcelProperty("里程(km)")
private BigDecimal mileage;
@ExcelProperty("运输单价")
private BigDecimal unitPrice;
@ExcelProperty("*运输费")
private BigDecimal transportFee;
/** 模板已去掉结算金额列;仅失败明细内部保留,不导出为表头 */
@ExcelIgnore
private BigDecimal settlementAmount;
@ExcelProperty("*实际发货时间")
private String actualStartDate;
@ExcelProperty("*实际完成时间")
private String actualEndDate;
@ExcelProperty("预计发货时间")
private String planStartDate;
@ExcelProperty("预计完成时间")
private String planEndDate;
@ExcelProperty("备注")
private String remark;
@ExcelProperty("同一运单标识号")
private String waybillIdentifier;
/** 导入失败原因(不导出到模板,仅用于失败明细) */
@ExcelIgnore
private String errorMessage;
}
@@ -0,0 +1,202 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.excel;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.DataValidation;
import org.apache.poi.ss.usermodel.DataValidationConstraint;
import org.apache.poi.ss.usermodel.DataValidationHelper;
import org.apache.poi.ss.usermodel.FillPatternType;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.IndexedColors;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.VerticalAlignment;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.util.CellRangeAddressList;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springblade.core.log.exception.ServiceException;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
/**
* 导入结算单模板导出:填写说明 + 固定数据表头 + 运输方式下拉。
* <p>
* 模板不预置费用项列;用户可在「*运输费」与后续固定列之间自行插入系统已启用的费用项列。
*
* @author Chill
*/
public final class SettlementImportTemplateExporter {
private static final List<String> FIXED_HEADERS_BEFORE_FEE = List.of(
"序号",
"原始单号",
"配载标识号",
"*车牌号/航班号/船号/班列号",
"*运输方式",
"司机/船长姓名",
"司机/船长手机号",
"*发货地址",
"发货联系人",
"发货联系人电话",
"*到货地址",
"收货联系人",
"收货联系人电话",
"*货物名称",
"*货物类型",
"包装",
"*数量",
"*数量单位",
"规格",
"型号",
"里程(km)",
"运输单价",
"*运输费"
);
private static final List<String> FIXED_HEADERS_AFTER_FEE = List.of(
"*实际发货时间",
"*实际完成时间",
"预计发货时间",
"预计完成时间",
"备注",
"同一运单标识号"
);
private SettlementImportTemplateExporter() {
}
public static void export(HttpServletResponse response, List<String> transportTypeOptions,
List<String> feeItemNames) {
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("导入结算单模板");
CellStyle headerStyle = createHeaderStyle(workbook);
Row headerRow = dataSheet.createRow(0);
for (int index = 0; index < headers.size(); index++) {
Cell cell = headerRow.createCell(index);
cell.setCellValue(headers.get(index));
cell.setCellStyle(headerStyle);
dataSheet.setColumnWidth(index, Math.min(28, Math.max(12, headers.get(index).length() + 4)) * 256);
}
int transportTypeColumn = headers.indexOf("*运输方式");
if (transportTypeColumn >= 0 && !transportOptions.isEmpty()) {
addDropdown(dataSheet, transportTypeColumn, transportOptions);
}
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setHeader("Content-disposition",
"attachment;filename=" + URLEncoder.encode("导入结算单模板", StandardCharsets.UTF_8) + ".xlsx");
workbook.write(response.getOutputStream());
} catch (IOException exception) {
throw new ServiceException("导出导入结算单模板失败:" + exception.getMessage());
}
}
/** 固定列模板:不预置费用项列,由用户自行插入。 */
private static List<String> buildHeaders() {
List<String> headers = new ArrayList<>(FIXED_HEADERS_BEFORE_FEE);
headers.addAll(FIXED_HEADERS_AFTER_FEE);
return headers;
}
private static void writeInstructionSheet(Workbook workbook, List<String> transportOptions,
List<String> feeItemNames) {
Sheet sheet = workbook.createSheet("填写说明");
CellStyle titleStyle = workbook.createCellStyle();
Font titleFont = workbook.createFont();
titleFont.setBold(true);
titleStyle.setFont(titleFont);
List<String> lines = new ArrayList<>();
lines.add("导入结算单填写说明");
lines.add("1. 请不要修改「导入结算单模板」工作表的固定表头;带 * 号为必填项。");
lines.add("2. 运输方式请从下拉项中选择,须与业务字典 transport_type 的字典名称一致。");
lines.add("3. 日期请填写 yyyy-MM-dd(如 2026-01-01);Excel 日期单元格也可直接选择。");
lines.add("4. 里程(km) 可为空;填写时须为正数。");
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())
+ (feeItemNames.size() > 30 ? " 等" : "") + "。");
} else {
lines.add("8. 费用项请按实际需要在模板中自行增列,列名须与系统已启用的费用项主数据名称完全一致。");
}
for (int index = 0; index < lines.size(); index++) {
Row row = sheet.createRow(index);
Cell cell = row.createCell(0);
cell.setCellValue(lines.get(index));
if (index == 0) {
cell.setCellStyle(titleStyle);
}
}
sheet.setColumnWidth(0, 120 * 256);
}
private static void addDropdown(Sheet sheet, int columnIndex, List<String> options) {
DataValidationHelper helper = sheet.getDataValidationHelper();
String[] values = options.toArray(new String[0]);
DataValidationConstraint constraint = helper.createExplicitListConstraint(values);
CellRangeAddressList addressList = new CellRangeAddressList(1, 2000, columnIndex, columnIndex);
DataValidation validation = helper.createValidation(constraint, addressList);
validation.setEmptyCellAllowed(true);
validation.setShowErrorBox(true);
sheet.addValidationData(validation);
}
private static CellStyle createHeaderStyle(Workbook workbook) {
CellStyle style = workbook.createCellStyle();
style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
style.setAlignment(HorizontalAlignment.CENTER);
style.setVerticalAlignment(VerticalAlignment.CENTER);
Font font = workbook.createFont();
font.setBold(true);
style.setFont(font);
return style;
}
}
@@ -27,9 +27,20 @@ package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springblade.transport.pojo.entity.BillPayment;
/** 汇票付款 Mapper。 @author Chill */
@Mapper
public interface BillPaymentMapper extends BaseMapper<BillPayment> {
/**
* 查询指定租户、指定日期前缀下已使用的最大汇票付款流水号。
* 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(payment_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_bill_payment WHERE tenant_id = #{tenantId} "
+ "AND payment_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
}
@@ -101,8 +101,7 @@
approved_time,
kingdee_status,
kingdee_fail_reason,
kingdee_sync_time
approved_time,
kingdee_sync_time,
(
SELECT s.review_status
FROM blade_customer_credit_score s
@@ -29,4 +29,13 @@ public interface FormalSettlementMapper extends BaseMapper<FormalSettlement> {
/** 恢复逻辑删除的正式结算单主记录。 */
@Update("UPDATE blade_formal_settlement SET is_deleted = 0 WHERE tenant_id = #{tenantId} AND id = #{id} AND is_deleted = 1")
int restoreByIdIncludingDeleted(@Param("tenantId") String tenantId, @Param("id") Long id);
/**
* 查询指定租户、指定日期前缀下已使用的最大正式结算单流水号。
* 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(formal_settlement_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_formal_settlement WHERE tenant_id = #{tenantId} "
+ "AND formal_settlement_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
}
@@ -10,9 +10,20 @@ package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springblade.transport.pojo.entity.FormalSettlementPayment;
/** 正式结算付款申请 Mapper。 @author Chill */
@Mapper
public interface FormalSettlementPaymentMapper extends BaseMapper<FormalSettlementPayment> {
/**
* 查询指定租户、指定日期前缀下已使用的最大付款申请流水号。
* 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(payment_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_formal_settlement_payment WHERE tenant_id = #{tenantId} "
+ "AND payment_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
}
@@ -27,6 +27,8 @@ package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springblade.transport.pojo.entity.InvoiceApplication;
/**
@@ -36,4 +38,13 @@ import org.springblade.transport.pojo.entity.InvoiceApplication;
*/
@Mapper
public interface InvoiceApplicationMapper extends BaseMapper<InvoiceApplication> {
/**
* 查询指定租户、指定日期前缀下已使用的最大开票申请流水号。
* 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(application_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_invoice_application WHERE tenant_id = #{tenantId} "
+ "AND application_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
}
@@ -44,11 +44,13 @@ public interface KingdeeInvoicePoolMapper extends BaseMapper<KingdeeInvoicePool>
/**
* 金蝶票据池镜像统一分页查询:仅出可用票(剩余可用金额>0 且未使用),认领/登记标记由 EXISTS 子查询直出
*
* @param page 分页参数
* @param query 统一查询条件(已在上层完成 trim 与日期格式校验)
* @param tenantId 当前租户
* @param page 分页参数
* @param query 统一查询条件(已在上层完成 trim 与日期格式校验)
* @param tenantId 当前租户
* @param customerUnitsOnly 客商单位过滤开关:开启时只出销/购方税号均登记在客商发票信息中的发票(与定时同步同开关)
* @return 统一视图分页
*/
IPage<KingdeeInvoicePoolVO> selectInvoicePoolPage(IPage<KingdeeInvoicePoolVO> page,
@Param("query") KingdeeInvoicePoolQueryDTO query, @Param("tenantId") String tenantId);
@Param("query") KingdeeInvoicePoolQueryDTO query, @Param("tenantId") String tenantId,
@Param("customerUnitsOnly") boolean customerUnitsOnly);
}
@@ -39,12 +39,24 @@
AND p.available_amount &gt; 0 AND p.amount_use_status = '0'
</sql>
<!-- 客商单位过滤:开票单位(销方)与受票单位(购方)税号均须登记在有效客商发票信息中
(与定时同步拉取过滤同开关同口径;自定义 SQL 无租户插件,手工带租户与逻辑删除) -->
<sql id="customerUnitsFilter">
AND EXISTS (SELECT 1 FROM blade_customer_invoice_info ci
WHERE ci.is_deleted = 0 AND ci.status = 1 AND ci.tenant_id = p.tenant_id AND ci.tax_no = p.saler_tax_no)
AND EXISTS (SELECT 1 FROM blade_customer_invoice_info cj
WHERE cj.is_deleted = 0 AND cj.status = 1 AND cj.tenant_id = p.tenant_id AND cj.tax_no = p.buyer_tax_no)
</sql>
<select id="selectInvoicePoolPage" resultType="org.springblade.transport.pojo.vo.KingdeeInvoicePoolVO">
SELECT
<include refid="invoicePoolVoColumns"/>
FROM blade_kingdee_invoice_pool p
WHERE p.is_deleted = 0 AND p.status = 1 AND p.tenant_id = #{tenantId}
<include refid="usableFilter"/>
<if test="customerUnitsOnly">
<include refid="customerUnitsFilter"/>
</if>
<if test="query.invoiceNo != null and query.invoiceNo != ''">
AND p.invoice_no LIKE CONCAT('%', #{query.invoiceNo}, '%')
</if>
@@ -21,9 +21,20 @@ package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springblade.transport.pojo.entity.PaymentApplication;
/** 付款申请 Mapper。 @author Chill */
@Mapper
public interface PaymentApplicationMapper extends BaseMapper<PaymentApplication> {
/**
* 查询指定租户、指定日期前缀下已使用的最大付款申请流水号。
* 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(payment_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_payment_application WHERE tenant_id = #{tenantId} "
+ "AND payment_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
}
@@ -10,6 +10,8 @@ package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springblade.transport.pojo.entity.PreSettlementAdvance;
/**
@@ -19,4 +21,12 @@ import org.springblade.transport.pojo.entity.PreSettlementAdvance;
*/
@Mapper
public interface PreSettlementAdvanceMapper extends BaseMapper<PreSettlementAdvance> {
/**
* 查询预付单号最大流水(含逻辑删除)。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(advance_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_pre_settlement_advance WHERE tenant_id = #{tenantId} "
+ "AND advance_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
}
@@ -10,6 +10,8 @@ package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springblade.transport.pojo.entity.PreSettlement;
/**
@@ -19,4 +21,20 @@ import org.springblade.transport.pojo.entity.PreSettlement;
*/
@Mapper
public interface PreSettlementMapper extends BaseMapper<PreSettlement> {
/**
* 查询预结算单号最大流水(含逻辑删除)。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(pre_settlement_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_pre_settlement WHERE tenant_id = #{tenantId} "
+ "AND pre_settlement_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxPreSettlementDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
/**
* 查询预结算转正式结算单号最大流水(含逻辑删除)。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(formal_settlement_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_pre_settlement WHERE tenant_id = #{tenantId} "
+ "AND formal_settlement_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxFormalSettlementDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
}
@@ -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">
@@ -2,7 +2,19 @@ package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springblade.transport.pojo.entity.SettlementAdjustment;
@Mapper
public interface SettlementAdjustmentMapper extends BaseMapper<SettlementAdjustment> {}
public interface SettlementAdjustmentMapper extends BaseMapper<SettlementAdjustment> {
/**
* 查询指定租户、指定日期前缀下已使用的最大结算调整单流水号。
* 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(adjustment_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_settlement_adjustment WHERE tenant_id = #{tenantId} "
+ "AND adjustment_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
}
@@ -0,0 +1,50 @@
/**
* 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.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springblade.transport.pojo.entity.SettlementImportBatch;
/**
* 导入结算单批次 Mapper
*
* @author Chill
*/
@Mapper
public interface SettlementImportBatchMapper extends BaseMapper<SettlementImportBatch> {
/**
* 查询指定租户、指定日期前缀下已使用的最大批次流水号。
* 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(batch_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_settlement_import_batch WHERE tenant_id = #{tenantId} "
+ "AND batch_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
}
@@ -0,0 +1,51 @@
/**
* 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>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.springblade.transport.pojo.entity.SettlementProjectAdvanceWriteOff;
import java.math.BigDecimal;
import java.util.Collection;
import java.util.List;
import java.util.Map;
/**
* 结算单项目预付核销 Mapper
*
* @author Chill
*/
@Mapper
public interface SettlementProjectAdvanceWriteOffMapper extends BaseMapper<SettlementProjectAdvanceWriteOff> {
/**
* 统计项目预付已核销金额(排除作废结算单,可排除当前结算单)。
*/
BigDecimal sumWriteOffByPaymentApplication(@Param("paymentApplicationId") Long paymentApplicationId,
@Param("excludeSettlementType") String excludeSettlementType,
@Param("excludeSettlementId") Long excludeSettlementId);
/**
* 统计结算单核销合计。
*/
BigDecimal sumWriteOffBySettlement(@Param("settlementType") String settlementType,
@Param("settlementId") Long settlementId);
/**
* 按结算单集合分组统计核销合计(一次 group-by,供批量刷新与列表页使用,避免逐单 N+1)。
*
* @return settlementId / totalAmount 两列
*/
List<Map<String, Object>> sumWriteOffBySettlementIds(@Param("settlementType") String settlementType,
@Param("settlementIds") Collection<Long> settlementIds);
}
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.springblade.transport.mapper.SettlementProjectAdvanceWriteOffMapper">
<select id="sumWriteOffByPaymentApplication" resultType="java.math.BigDecimal">
SELECT COALESCE(SUM(w.write_off_amount), 0)
FROM blade_settlement_project_advance_write_off w
LEFT JOIN blade_pre_settlement p
ON w.settlement_type = 'pre'
AND w.settlement_id = p.id
AND p.is_deleted = 0
LEFT JOIN blade_formal_settlement f
ON w.settlement_type = 'formal'
AND w.settlement_id = f.id
AND f.is_deleted = 0
WHERE w.is_deleted = 0
AND w.payment_application_id = #{paymentApplicationId}
AND (
(w.settlement_type = 'pre' AND p.id IS NOT NULL AND IFNULL(p.approval_status, '') &lt;&gt; 'voided')
OR (w.settlement_type = 'formal' AND f.id IS NOT NULL AND IFNULL(f.approval_status, '') &lt;&gt; 'voided')
)
<if test="excludeSettlementType != null and excludeSettlementId != null">
AND NOT (w.settlement_type = #{excludeSettlementType} AND w.settlement_id = #{excludeSettlementId})
</if>
</select>
<select id="sumWriteOffBySettlement" resultType="java.math.BigDecimal">
SELECT COALESCE(SUM(write_off_amount), 0)
FROM blade_settlement_project_advance_write_off
WHERE is_deleted = 0
AND settlement_type = #{settlementType}
AND settlement_id = #{settlementId}
</select>
<select id="sumWriteOffBySettlementIds" resultType="java.util.HashMap">
SELECT settlement_id AS settlementId, COALESCE(SUM(write_off_amount), 0) AS totalAmount
FROM blade_settlement_project_advance_write_off
WHERE is_deleted = 0
AND settlement_type = #{settlementType}
AND settlement_id IN
<foreach collection="settlementIds" item="settlementId" open="(" separator="," close=")">
#{settlementId}
</foreach>
GROUP BY settlement_id
</select>
</mapper>
@@ -22,4 +22,13 @@ public interface TransportReconciliationMapper extends BaseMapper<TransportRecon
/** 恢复逻辑删除的运输对账单主记录。 */
@Update("UPDATE blade_transport_reconciliation SET is_deleted = 0 WHERE tenant_id = #{tenantId} AND id = #{id} AND is_deleted = 1")
int restoreByIdIncludingDeleted(@Param("tenantId") String tenantId, @Param("id") Long id);
/**
* 查询指定租户、指定日期前缀下已使用的最大对账单流水号。
* 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(reconciliation_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_transport_reconciliation WHERE tenant_id = #{tenantId} "
+ "AND reconciliation_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
}
@@ -1,6 +1,7 @@
package org.springblade.transport.mk;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.tenant.annotation.TenantIgnore;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.pojo.dto.BillPaymentStatusRequest;
@@ -287,6 +288,7 @@ public final class MkProcessBizHandlers {
}
}
@Slf4j
@Component
@TenantIgnore
@RequiredArgsConstructor
@@ -307,6 +309,12 @@ public final class MkProcessBizHandlers {
public boolean syncFromProcessInfo(Long id, Object processInfo) {
FormalSettlement entity = service.getById(id);
if (entity == null) {
log.warn("MK同步当前节点失败,正式结算单不存在 id={}", id);
return false;
}
// 已终态单据禁止再写回审批中,避免流程结束后的节点刷新把状态打回 reviewing
if ("approved".equals(entity.getApprovalStatus()) || "returned".equals(entity.getApprovalStatus())
|| "voided".equals(entity.getApprovalStatus())) {
return false;
}
String node = MkProcessNodeHelper.currentNode(processInfo);
@@ -327,8 +335,12 @@ public final class MkProcessBizHandlers {
@Override
public boolean approveFromProcess(Long id, String processorName) {
FormalSettlement entity = service.getById(id);
if (entity == null || "approved".equals(entity.getApprovalStatus())) {
return entity != null;
if (entity == null) {
log.error("MK审批通过同步失败,正式结算单不存在 id={}", id);
return false;
}
if ("approved".equals(entity.getApprovalStatus())) {
return true;
}
// 统一走 service.approve:与手动审批同一入口(补变更记录),审批事务提交后自动触发金蝶推送事件;
// 仅审批中单据允许通过,重复回调被状态校验拒绝,不重复推送
@@ -347,8 +359,12 @@ public final class MkProcessBizHandlers {
@Override
public boolean rejectFromProcess(Long id, String processorName) {
FormalSettlement entity = service.getById(id);
if (entity == null || "returned".equals(entity.getApprovalStatus())) {
return entity != null;
if (entity == null) {
log.error("MK审批驳回同步失败,正式结算单不存在 id={}", id);
return false;
}
if ("returned".equals(entity.getApprovalStatus())) {
return true;
}
entity.setApprovalStatus("returned");
entity.setCurrentNode("审核不通过");
@@ -422,10 +438,19 @@ public final class MkProcessBizHandlers {
if (entity == null || "returned".equals(entity.getApprovalStatus())) {
return entity != null;
}
entity.setApprovalStatus("returned");
entity.setCurrentNode("审核不通过");
entity.setCurrentProcessor(MkProcessNodeHelper.text(processorName, "系统"));
return service.updateById(entity);
if ("reviewing".equals(entity.getApprovalStatus())) {
PaymentApplicationStatusRequest request = new PaymentApplicationStatusRequest();
request.setId(id);
service.returnBill(request);
}
PaymentApplication updated = service.getById(id);
if (updated == null) {
return false;
}
updated.setApprovalStatus("returned");
updated.setCurrentNode("审核不通过");
updated.setCurrentProcessor(MkProcessNodeHelper.text(processorName, "系统"));
return service.updateById(updated);
}
}
@@ -4,10 +4,12 @@ import com.alibaba.fastjson2.JSON;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.tenant.TenantUtil;
import org.springblade.core.tenant.annotation.TenantIgnore;
import org.springblade.core.tool.api.FR;
import org.springblade.core.tool.utils.StringUtil;
import org.springblade.process.feign.IBusinessProcessClient;
import org.springblade.process.pojo.vo.MkFormBindVO;
import org.springframework.stereotype.Service;
import java.util.Collections;
@@ -69,9 +71,9 @@ public class MkProcessMessageService {
log.warn("公开页流程消息未找到 processId,跳过查询当前节点 bizType={}", bizType);
return;
}
String bizIdText = firstText(asMap(body == null ? null : body.get("formData")), "id", "formInstanceId");
Long bizId = resolveBizId(asMap(body == null ? null : body.get("formData")));
queryCurrentNodes(bizType, processId, loginName);
scheduleProcessInfo(handler, bizType, processId, loginName, bizIdText, formValues);
scheduleProcessInfo(handler, bizType, processId, loginName, bizId, formValues);
}
private void queryCurrentNodes(String bizType, String processId, String loginName) {
@@ -86,36 +88,35 @@ public class MkProcessMessageService {
}
private void scheduleProcessInfo(IMkProcessBizHandler handler, String bizType, String processId,
String loginName, String bizIdText, Map<String, Object> formValues) {
String loginName, Long bizId, Map<String, Object> formValues) {
Map<String, Object> formValuesCopy = new HashMap<>(formValues == null ? Map.of() : formValues);
log.info("公开页将在{}秒后查询流程实例详情 bizType={} processId={}", PROCESS_INFO_DELAY_SECONDS, bizType, processId);
log.info("公开页将在{}秒后查询流程实例详情 bizType={} processId={} bizId={}",
PROCESS_INFO_DELAY_SECONDS, bizType, processId, bizId);
// 延迟任务跑在独立线程,请求态 @TenantIgnore 已丢失,必须显式 ignore,否则 getById 可能因租户条件查空
scheduler.schedule(
() -> queryProcessInfo(handler, bizType, processId, loginName, bizIdText, formValuesCopy),
() -> TenantUtil.ignore(() -> queryProcessInfo(handler, bizType, processId, loginName, bizId, formValuesCopy)),
PROCESS_INFO_DELAY_SECONDS,
TimeUnit.SECONDS
);
}
private void queryProcessInfo(IMkProcessBizHandler handler, String bizType, String processId,
String loginName, String bizIdText, Map<String, Object> formValues) {
String loginName, Long bizId, Map<String, Object> formValues) {
try {
FR<Object> processInfoResult = businessProcessClient.getProcessInfo(processId, loginName);
Object processInfo = processInfoResult == null ? null : processInfoResult.getData();
log.info("公开页流程消息流程实例详情 bizType={} processId={} loginName={} result={}",
bizType, processId, loginName, JSON.toJSONString(processInfo));
log.info("公开页流程消息流程实例详情 bizType={} processId={} loginName={} bizId={} result={}",
bizType, processId, loginName, bizId, JSON.toJSONString(processInfo));
if (isProcessFinished(processInfo)) {
applyProcessResult(handler, bizType, bizIdText, processId, formValues, true);
applyProcessResult(handler, bizType, bizId, processId, formValues, true);
} else if (MkProcessNodeHelper.isProcessRejected(processInfo)) {
applyProcessResult(handler, bizType, bizIdText, processId, formValues, false);
} else if (StringUtil.isNotBlank(bizIdText)) {
try {
handler.syncFromProcessInfo(Long.valueOf(bizIdText), processInfo);
} catch (NumberFormatException e) {
log.warn("公开页流程消息业务id格式不正确 bizType={} id={}", bizType, bizIdText);
}
applyProcessResult(handler, bizType, bizId, processId, formValues, false);
} else if (bizId != null) {
handler.syncFromProcessInfo(bizId, processInfo);
}
} catch (Exception e) {
log.error("公开页查询流程实例详情失败 bizType={} processId={} loginName={}", bizType, processId, loginName, e);
log.error("公开页查询流程实例详情失败 bizType={} processId={} loginName={} bizId={}",
bizType, processId, loginName, bizId, e);
}
}
@@ -123,21 +124,24 @@ public class MkProcessMessageService {
return "30".equals(firstText(asMap(processInfo), "fdProcessStatus"));
}
private void applyProcessResult(IMkProcessBizHandler handler, String bizType, String bizIdText,
private void applyProcessResult(IMkProcessBizHandler handler, String bizType, Long bizId,
String processId, Map<String, Object> formValues, boolean approved) {
String action = approved ? "审核通过" : "审核驳回";
if (StringUtil.isBlank(bizIdText)) {
log.warn("流程{}但未找到业务id,跳过同步 bizType={} processId={}", action, bizType, processId);
if (bizId == null) {
log.warn("流程{}但未解析到业务主键,跳过同步 bizType={} processId={}", action, bizType, processId);
return;
}
try {
String processorName = firstText(formValues, "mkUserName", "mkLoginName");
Long bizId = Long.valueOf(bizIdText);
if (approved) {
handler.approveFromProcess(bizId, processorName);
} else {
handler.rejectFromProcess(bizId, processorName);
boolean synced = approved
? handler.approveFromProcess(bizId, processorName)
: handler.rejectFromProcess(bizId, processorName);
if (!synced) {
log.error("流程{}同步业务状态失败(业务单据不存在或未更新) bizType={} bizId={} processId={}",
action, bizType, bizId, processId);
return;
}
log.info("流程{}同步业务状态成功 bizType={} bizId={} processId={}", action, bizType, bizId, processId);
try {
FR<Boolean> markResult = businessProcessClient.markProcessFinished(processId, approved);
log.info("公开页标记业务流程完结 bizType={} processId={} approved={} result={}",
@@ -146,13 +150,56 @@ public class MkProcessMessageService {
log.error("公开页标记业务流程完结失败 bizType={} processId={} approved={}",
bizType, processId, approved, markException);
}
} catch (NumberFormatException e) {
log.warn("流程{}但业务id格式不正确 bizType={} id={}", action, bizType, bizIdText);
} catch (Exception e) {
log.error("流程{}同步业务状态失败 bizType={} id={} processId={}", action, bizType, bizIdText, processId, e);
log.error("流程{}同步业务状态失败 bizType={} bizId={} processId={}", action, bizType, bizId, processId, e);
}
}
/**
* 公开页 formData 里的 id / formInstanceId 可能是表单实例 id(与业务主键不同),
* 须先按 MkFormBind 反查业务主键,与 detail 接口保持一致。
*/
private Long resolveBizId(Map<String, Object> formData) {
String formInstanceIdText = firstText(formData, "formInstanceId");
String idText = firstText(formData, "id");
Long resolved = resolveByFormInstanceId(formInstanceIdText);
if (resolved != null) {
return resolved;
}
resolved = resolveByFormInstanceId(idText);
if (resolved != null) {
return resolved;
}
if (StringUtil.isNotBlank(idText)) {
try {
return Long.valueOf(idText);
} catch (NumberFormatException e) {
log.warn("公开页流程消息业务id格式不正确 id={}", idText);
}
}
return null;
}
private Long resolveByFormInstanceId(String formInstanceIdText) {
if (StringUtil.isBlank(formInstanceIdText)) {
return null;
}
try {
Long formInstanceId = Long.valueOf(formInstanceIdText);
FR<MkFormBindVO> result = businessProcessClient.resolveByFormInstanceId(formInstanceId);
if (result != null && result.isSuccess() && result.getData() != null && result.getData().getBizId() != null) {
Long bizId = result.getData().getBizId();
log.info("公开页按表单实例id解析业务主键 formInstanceId={} bizId={}", formInstanceId, bizId);
return bizId;
}
} catch (NumberFormatException e) {
log.warn("公开页表单实例id格式不正确 formInstanceId={}", formInstanceIdText);
} catch (Exception e) {
log.warn("公开页按表单实例id解析业务主键失败 formInstanceId={}", formInstanceIdText, e);
}
return null;
}
private static Map<String, Object> asMap(Object value) {
if (!(value instanceof Map<?, ?> map)) {
return Collections.emptyMap();
@@ -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);
}
@@ -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>
@@ -130,9 +177,9 @@ public interface IDriverWaybillService {
boolean isNodePunched(Long waybillId, String nodeCode);
/**
* 断言当前登录司机对该运单有归属:运单车牌须落在司机档案自述绑定的车牌内。
* 断言当前登录司机对该运单有归属:运单司机手机号须等于当前用户手机号。
* <p>
* 无司机档案、未绑定车辆、车牌不匹配都会抛异常。管理端与调度员账号没有司机档案,
* 无司机档案、无手机号、手机号不匹配都会抛异常。管理端与调度员账号没有司机档案,
* 会停在「未找到当前登录司机档案」——这是刻意的,不提供代报。
*/
void assertCurrentDriverOwnsWaybill(Long waybillId);
@@ -24,6 +24,8 @@ import org.springblade.transport.pojo.dto.FormalSettlementPaymentRequest;
import org.springblade.transport.pojo.dto.FormalSettlementBatchPaymentRequest;
import org.springblade.transport.pojo.dto.FormalSettlementInvoiceClaimRequest;
import java.util.Map;
/**
* 正式结算单服务
*
@@ -93,4 +95,29 @@ public interface IFormalSettlementService extends BaseService<FormalSettlement>
String applyPayment(FormalSettlementPaymentRequest request);
List<String> applyPayments(FormalSettlementBatchPaymentRequest request);
void claimInvoices(FormalSettlementInvoiceClaimRequest request);
/**
* 结算调整重推:克隆原正式结算单为新单(含明细、明细费项、汇总费项、来源预结算、附件、汇率),
* 新单置审批通过、未同步金蝶,来源原单号指向原单;下游关联(开票申请/收款认领/对账)不迁移。
* 不推送金蝶,由调用方在事务外调用 syncKingdee 推送
*
* @param sourceSettlementId 原正式结算单id
* @return 新单id与「原明细/原费项 -&gt; 新明细/新费项」id映射(供调整明细应用到新单)
*/
CloneResult cloneFormalSettlement(Long sourceSettlementId);
/**
* 结算调整重推收尾:原单标记「已调整」、旧锁解锁、追加重推变更记录(不改原单业务数据)
*
* @param settlementId 原正式结算单id
* @param adjustmentNo 结算调整单号
* @param newSettlementNo 重推生成的新单号
*/
void markSettlementAdjusted(Long settlementId, String adjustmentNo, String newSettlementNo);
/**
* 克隆结果:新单id + 原明细id-&gt;新明细id + 原费项id-&gt;新费项id
*/
record CloneResult(Long settlementId, Map<Long, Long> detailIdMap, Map<Long, Long> feeIdMap) {
}
}
@@ -45,7 +45,7 @@ public interface IInvoiceApplicationService extends BaseService<InvoiceApplicati
IPage<InvoiceApplicationVO> selectPage(IPage<InvoiceApplication> page, InvoiceApplicationVO query);
InvoiceApplicationVO detail(Long id);
IPage<Map<String, Object>> settlementCandidates(IPage<?> page, String keyword, String contractCategory,
String settlementType, String invoiceStatus);
String settlementType);
List<FormalSettlementDetail> settlementDetails(String settlementIds);
Map<String, Object> receiverInformation(String settlementIds);
Long saveDraft(InvoiceApplicationSaveRequest request);
@@ -9,8 +9,10 @@
package org.springblade.transport.service;
import com.baomidou.mybatisplus.extension.service.IService;
import org.springblade.core.mp.base.BaseEntity;
import org.springblade.thirdparty.bfi.pojo.dto.BfiBillLockDTO;
import org.springblade.transport.pojo.entity.KingdeeBillLock;
import org.springblade.transport.pojo.vo.KingdeeUnlockMark;
import java.util.List;
import java.util.Map;
@@ -39,8 +41,10 @@ public interface IKingdeeBillLockService extends IService<KingdeeBillLock> {
String lockAfterPush(String sourceBillType, Long sourceBillId, String sourceBillNo, String targetBillType, List<String> targetBillNos);
/**
* 金蝶解锁回调(unLockNotifyBusiData):校验来源/目标系统与 lockAction,
* targetBillNo 支持逗号拼接批量,逐张按 targetBillType+targetBillNo 定位最新 locked 锁记录;
* 金蝶解锁回调(unLockNotifyBusiData):校验来源/目标系统与 lockAction。
* 注意回调方向与锁定相反:回调 sourceBillNo=金蝶单号(即锁记录 targetBillNo,逗号拼接批量)、
* targetBillNo=WLTMS 单号(即锁记录 sourceBillNo)、targetBillType 可能为空,
* 故按金蝶单号+我方单号双条件反查最新 locked 锁记录;
* operationType=DELETE 时按单据类型分发废除源单并还原关联数据
*
* @param param 金蝶回调参数
@@ -56,6 +60,17 @@ public interface IKingdeeBillLockService extends IService<KingdeeBillLock> {
*/
void checkEditable(String sourceBillType, Long sourceBillId);
/**
* 结算调整重推解锁:把源单当前「锁定中」的锁记录标记为 unlocked(非金蝶回调,仅本地解锁留痕),
* 供原结算单被调整重推生成新单后解除旧锁使用;新单另行落新锁记录
*
* @param sourceBillType WLTMS 单据类型
* @param sourceBillId WLTMS 业务主键
* @param operator 操作人
* @return 解锁的记录数
*/
int unlockByAdjustRepush(String sourceBillType, Long sourceBillId, String operator);
/**
* 批量查源单当前锁状态(供列表/详情页填充展示标识):
* 每源单取最新一条非终态锁记录(locked/failed),unlocked 为终态不返回(前端视为无锁/已解锁)
@@ -66,6 +81,29 @@ public interface IKingdeeBillLockService extends IService<KingdeeBillLock> {
*/
Map<Long, KingdeeBillLock> mapActiveLocks(String sourceBillType, List<Long> sourceBillIds);
/**
* 批量查源单解锁终态留痕(供列表/详情页填充解锁标识):
* 每源单取最新一条 unlocked 锁记录(付款申请解锁后不作废,页面据此区分「金蝶删单/解锁」与普通推送失败)
*
* @param sourceBillType WLTMS 单据类型
* @param sourceBillIds 源单主键集合
* @return sourceBillId -&gt; 最新 unlocked 锁记录,无解锁留痕的源单不在 Map 中
*/
Map<Long, KingdeeBillLock> mapUnlockedLocks(String sourceBillType, List<Long> sourceBillIds);
/**
* 批量填充金蝶解锁终态标识(通用):对无活跃锁的行反查最新 unlocked 锁记录并回填解锁标识
* (解锁时间/操作人/动作名称),供前端展示明显解锁标识;有活跃锁的单必非解锁终态,跳过。
* 与 mapActiveLocks 配对在各单据 fillKingdeeLockStatus 中调用,列表/详情共用;
* 后续单据(如收票单)接入锁单时,VO 实现 KingdeeUnlockMark 即可复用
*
* @param sourceBillType WLTMS 单据类型
* @param rows 已填充活跃锁标识的 VO 行
* @param activeLockMap 活跃锁 Map(mapActiveLocks 返回值)
* @param &lt;T&gt; 实现 KingdeeUnlockMark 的 VO 类型(须继承 BaseEntity 以取主键)
*/
<T extends BaseEntity & KingdeeUnlockMark> void fillUnlockMarks(String sourceBillType, List<T> rows, Map<Long, KingdeeBillLock> activeLockMap);
/**
* 锁定补偿:扫 failed 且 try_count 未达重试上限(RETRY_MAX_COUNT)的锁记录,逐张重调 lockBusiData
* (接口 1对1:单次一个源单号对一个金蝶单号,单张成败互不影响;定时任务定时触发,亦可人工触发);
@@ -63,9 +63,12 @@ public interface IManageWaybillService {
String exception, String transportType, String startDate, String endDate);
/**
* 调度端运单详情(不校验司机归属)
* 调度端运次详情(不校验司机归属)
* <p>
* 传运次主键:配载单主键或未配载运单主键,都按字符串。已拒单的子运单按运次取不到(运次口径
* 不含已拒子单),此时按运单主键回落成单票视图,供重新派单等从运单进入的场景使用。
*/
AdminWaybillDetailVO detail(Long id);
AdminWaybillDetailVO detail(String tripId);
/**
* 待处理运单(待接单 / 运输中;可筛需重新派单)
@@ -28,6 +28,7 @@ import org.springblade.transport.pojo.dto.PaymentApplicationStatusRequest;
import org.springblade.transport.pojo.entity.PaymentApplication;
import org.springblade.transport.pojo.vo.PaymentApplicationReferenceAmountVO;
import org.springblade.transport.pojo.vo.PaymentApplicationVO;
import org.springblade.transport.pojo.vo.ProjectAdvanceWriteOffOptionVO;
import java.util.List;
@@ -36,6 +37,11 @@ public interface IPaymentApplicationService extends BaseService<PaymentApplicati
IPage<PaymentApplicationVO> selectPage(IPage<PaymentApplication> page, PaymentApplicationVO query);
PaymentApplicationVO detail(Long id);
PaymentApplicationReferenceAmountVO referenceAmount(String paymentType, Long referenceId, Long excludeId);
/** 按合同查询可选项目预付核销列表。 */
List<ProjectAdvanceWriteOffOptionVO> projectAdvanceWriteOffOptions(Long contractId,
String excludeSettlementType,
Long excludeSettlementId);
Long saveDraft(PaymentApplicationSaveRequest request);
void removeDraft(Long id);
void submit(PaymentApplicationStatusRequest request);
@@ -33,6 +33,11 @@ public interface IPreSettlementService extends BaseService<PreSettlement> {
PreSettlementVO detail(Long id);
/**
* 预览下一条预结算单号。仅供新增页面展示,真正落库的单号仍由保存时生成。
*/
String nextNo();
default List<Map<String, Object>> contractOptions(String keyword) {
return contractOptions(keyword, null);
}
@@ -101,4 +101,19 @@ public interface IReceivablePayableDetailService extends BaseService<ReceivableP
/** 关闭总单调度后生成总单客户合同应收,并按所属运单记录的承运商合同生成应付。 */
void generateForClosedMasterOrder(MasterOrder masterOrder);
/**
* 结算导入:按导入单据构建/刷新「结算导入」来源的应收应付明细(运输费+费用项金额以导入数据为准)。
* <p>
* 历史运单若已有系统生成明细,不覆盖原明细,另建结算导入明细;运单已进入结算则拒绝重复导入。
* feeRows 每项含 waybillId、transportFee、unitPrice、feeItems(Map)、remark。
* </p>
*
* @param settlementType 结算类型 receivable/payable
* @param contractId 结算合同ID
* @param feeRows 费用行
* @return 明细主键列表(新建或刷新后的结算导入明细)
*/
List<Long> createDetailsFromSettlementImport(String settlementType, Long contractId,
List<Map<String, Object>> feeRows);
}
@@ -0,0 +1,77 @@
/**
* 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.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import jakarta.servlet.http.HttpServletResponse;
import org.springblade.core.mp.base.BaseService;
import org.springblade.transport.pojo.dto.SettlementImportBatchRequest;
import org.springblade.transport.pojo.dto.SettlementImportVoidRequest;
import org.springblade.transport.pojo.entity.SettlementImportBatch;
import org.springblade.transport.pojo.vo.SettlementImportBatchVO;
import org.springblade.transport.pojo.vo.WaybillImportValidateResultVO;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.Map;
/**
* 导入结算单批次服务
*
* @author Chill
*/
public interface ISettlementImportBatchService extends BaseService<SettlementImportBatch> {
Map<String, List<Map<String, Object>>> options();
String nextBatchNo();
IPage<SettlementImportBatchVO> page(IPage<SettlementImportBatch> page, SettlementImportBatchRequest request);
SettlementImportBatchVO detail(Long id);
void exportTemplate(HttpServletResponse response);
Map<String, Object> parse(MultipartFile file);
WaybillImportValidateResultVO validate(SettlementImportBatchRequest request);
void exportFailures(SettlementImportBatchRequest request, MultipartFile file, HttpServletResponse response);
SettlementImportBatchVO saveDraft(SettlementImportBatchRequest request);
SettlementImportBatchVO confirm(SettlementImportBatchRequest request);
void voidBatch(SettlementImportVoidRequest request);
/** 删除草稿批次(仅 draft 可删)。 */
void removeDraft(Long id);
/**
* 历史运单可选列表:相同项目、客户合同、承运商合同下,尚未生成对应应收/应付明细的运单。
*/
List<Map<String, Object>> historyWaybills(SettlementImportBatchRequest request);
}
@@ -0,0 +1,70 @@
/**
* 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>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service;
import org.springblade.transport.pojo.dto.SettlementProjectAdvanceWriteOffRequest;
import org.springblade.transport.pojo.entity.SettlementProjectAdvanceWriteOff;
import org.springblade.transport.pojo.vo.ProjectAdvanceWriteOffOptionVO;
import java.math.BigDecimal;
import java.util.Collection;
import java.util.List;
import java.util.Map;
/**
* 结算单项目预付核销服务
*
* @author Chill
*/
public interface ISettlementProjectAdvanceWriteOffService {
/**
* 按合同查询可选项目预付(审批通过、金蝶已付款且仍有可核销余额)。
*/
List<ProjectAdvanceWriteOffOptionVO> listOptions(Long contractId, String excludeSettlementType,
Long excludeSettlementId);
/**
* 查询结算单已保存的核销明细(含动态可核销金额)。
*/
List<SettlementProjectAdvanceWriteOff> listBySettlement(String settlementType, Long settlementId);
/**
* 结算单核销金额合计。
*/
BigDecimal sumBySettlement(String settlementType, Long settlementId);
/**
* 按结算单集合分组统计核销合计(一次 group-by,供批量刷新与列表页使用)。
*/
Map<Long, BigDecimal> sumBySettlementIds(String settlementType, Collection<Long> settlementIds);
/**
* 覆盖保存核销明细,并校验不超过可核销金额与结算单金额。
*/
void replaceWriteOffs(String settlementType, Long settlementId, Long contractId,
BigDecimal settlementAmount, List<SettlementProjectAdvanceWriteOffRequest> writeOffs);
/**
* 物理清理结算单核销明细(草稿删除时)。
*/
void deleteBySettlement(String settlementType, Long settlementId);
/**
* 删除指定结算单集合上的核销明细(核销行转移/结算单作废联动),返回删除行数。
*/
int deleteBySettlementIds(String settlementType, Collection<Long> settlementIds);
/**
* 按付款申请删除其核销明细(付款申请作废联动),返回受影响的正式结算单id。
*/
List<Long> deleteByPaymentApplicationIds(Collection<Long> paymentApplicationIds);
}
@@ -0,0 +1,42 @@
/**
* 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.service;
/**
* 按合同结算规则自动生成预结算单、正式结算单
*
* @author Chill
*/
public interface ISettlementRuleAutoGenerateService {
/**
* 扫描已开启自动生成的合同,为截至昨日已关闭的账期生成结算草稿。
*
* @return 本次生成结果摘要
*/
String generateDueSettlements();
}
@@ -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);
}
@@ -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);
@@ -46,11 +46,11 @@ public record KingdeeSyncContext(Map<Long, ContractManage> contractCache,
Map<Long, List<FormalSettlementDetail>> settlementDetailCache,
Map<Long, FormalSettlement> settlementCache,
Map<Long, CustomerReceiptAccount> receiptAccountCache,
Map<String, String> feeCategoryByName) {
Map<String, String> feeCategoryByName, Map<Long, String> paymentNoCache) {
/** 空上下文:缓存可写但初始为空,首条记录命中后全批复用 */
public static KingdeeSyncContext empty() {
return new KingdeeSyncContext(new HashMap<>(), new HashMap<>(), new HashMap<>(),
new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>());
new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>());
}
}
@@ -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,22 +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
.gt(BillLedger::getAvailableBalance, BigDecimal.ZERO)
.and(available -> available
.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) {
@@ -136,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) {
@@ -145,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));
@@ -309,10 +309,11 @@ public class BillPaymentServiceImpl extends BaseServiceImpl<BillPaymentMapper, B
return request.getId();
}
/** 生成当日汇票付款单号;按最大流水号 +1,且计入逻辑删除数据 */
private synchronized String nextNo() {
String prefix = "HP" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
return prefix + String.format("%05d", count(Wrappers.<BillPayment>lambdaQuery()
.likeRight(BillPayment::getPaymentNo, prefix)) + 1);
Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix);
return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1);
}
private BigDecimal positive(BigDecimal value, String name) {
@@ -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());
@@ -30,6 +30,7 @@ import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.tool.utils.BeanUtil;
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.transport.excel.CommonRouteExportExcel;
@@ -217,12 +218,32 @@ public class CommonRouteServiceImpl extends BaseServiceImpl<CommonRouteMapper, C
commonRoute.setArrivalPhone(TransportBusinessSupport.trimToNull(commonRoute.getArrivalPhone()));
commonRoute.setDeptName(TransportBusinessSupport.trimToNull(commonRoute.getDeptName()));
commonRoute.setRemark(TransportBusinessSupport.trimToNull(commonRoute.getRemark()));
if (Func.isEmpty(commonRoute.getDeptId())) {
// 仅新增时绑定组织;编辑沿用原组织(submit 中已从旧记录写回)。
if (Func.isEmpty(commonRoute.getId())) {
bindDeptOnCreate(commonRoute);
}
if (commonRoute.getStatus() == null) { commonRoute.setStatus(1); }
}
/**
* 新增绑定所属组织:普通用户始终落当前组织;超管可按入参组织保存,未传时回落当前组织。
*/
private void bindDeptOnCreate(CommonRoute commonRoute) {
if (commonRoute == null) {
return;
}
boolean administrator = TransportBusinessSupport.isAdministrator();
if (!administrator || Func.isEmpty(commonRoute.getDeptId()) || commonRoute.getDeptId() <= 0) {
Dept dept = TransportBusinessSupport.currentDept("常用线路");
commonRoute.setDeptId(dept.getId());
commonRoute.setDeptName(dept.getDeptName());
return;
}
if (commonRoute.getStatus() == null) { commonRoute.setStatus(1); }
Dept dept = SysCache.getDept(commonRoute.getDeptId());
if (Func.isEmpty(dept)) {
throw new ServiceException("所属组织不存在或已删除");
}
commonRoute.setDeptName(TransportBusinessSupport.trimToNull(dept.getDeptName()));
}
private void validate(CommonRoute commonRoute) {
@@ -326,6 +326,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());
@@ -467,9 +468,11 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
} else if (Func.isNotEmpty(contractManage.getOrganizationId())) {
queryWrapper.eq(ContractManage::getOrganizationId, contractManage.getOrganizationId());
}
queryWrapper.like(Func.isNotEmpty(contractManage.getContractNo()), ContractManage::getContractNo, contractManage.getContractNo())
queryWrapper.eq(Func.isNotEmpty(contractManage.getProjectId()), ContractManage::getProjectId, contractManage.getProjectId())
.like(Func.isNotEmpty(contractManage.getContractNo()), ContractManage::getContractNo, contractManage.getContractNo())
.like(Func.isNotEmpty(contractManage.getContractName()), ContractManage::getContractName, contractManage.getContractName())
.like(Func.isNotEmpty(contractManage.getProjectName()), ContractManage::getProjectName, contractManage.getProjectName())
.like(Func.isNotEmpty(contractManage.getProjectName()) && Func.isEmpty(contractManage.getProjectId()),
ContractManage::getProjectName, contractManage.getProjectName())
.like(Func.isNotEmpty(contractManage.getOrganizationName()), ContractManage::getOrganizationName, contractManage.getOrganizationName())
.eq(Func.isNotEmpty(contractManage.getContractCategory()), ContractManage::getContractCategory, contractManage.getContractCategory())
.eq(Func.isNotEmpty(contractManage.getSignType()), ContractManage::getSignType, contractManage.getSignType())
@@ -555,8 +558,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());
@@ -564,7 +566,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());
@@ -583,6 +585,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)) {
@@ -606,7 +630,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)) {
@@ -625,6 +650,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);
@@ -1063,6 +1099,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());
@@ -168,7 +168,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
*/
private static final String APPROVAL_REJECTED = "rejected";
/** 客商所属组织固定为顶层组织 */
private static final String FIXED_OWNER_DEPT_NAME = "桂物物流集团";
private static final String FIXED_OWNER_DEPT_NAME = "广西现代物流集团有限公司";
/**
* 金蝶协同状态:未推送(新增客商默认值,审批通过后参与推送)
@@ -277,7 +277,8 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
boolean created = Func.isEmpty(customer.getId());
CustomerArchive before = created ? null : ensureCustomerAccessible(customer.getId());
if (created) {
customer.setCustomerCode(nextCustomerCode());
// 新增时金蝶已有客商,前端回填金蝶客商编码,建档直接沿用;未回填则自动生成
customer.setCustomerCode(Func.isEmpty(customer.getCustomerCode()) ? this.nextCustomerCode() : customer.getCustomerCode());
}
CustomerArchive entity = Objects.requireNonNull(BeanUtil.copyProperties(customer, CustomerArchive.class));
boolean result = saveOrUpdate(entity);
@@ -515,10 +516,10 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
after.setKingdeeSyncTime(LocalDateTime.now());
BfiCustomerVO kingdeeCustomer = queryKingdeeCustomer(customer.getUnifiedCreditCode());
if (kingdeeCustomer != null && BfiConstant.BILL_STATUS_AUDITED.equals(kingdeeCustomer.getStatus())) {
// 金蝶已有已审核档案:跳过推送
// 金蝶已有已审核档案:跳过推送,客商编号采纳金蝶客商编码
after.setKingdeeStatus(KINGDEE_STATUS_EXIST);
after.setKingdeeFailReason(null);
addChangeRecord(id, "金蝶已存在该客商档案,跳过推送", customer, after);
addChangeRecord(id, "金蝶已存在该客商档案,跳过推送" + this.adoptKingdeeCode(customer, after, kingdeeCustomer.getNumber()), customer, after);
updateById(after);
return after;
}
@@ -617,6 +618,31 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
return rows == null || rows.isEmpty() ? null : rows.get(0);
}
/**
* 金蝶已存在档案时,客商编号(customer_code)采纳金蝶客商编码,后续业务接口沿用客商编号即拿到金蝶编码;
* 金蝶编码为空或与当前编号一致时无需处理;同租户下已被其他客商占用时跳过回填,避免违反 uk_customer_archive_code 唯一约束
*
* @param customer 变更前档案
* @param after 变更后档案(编码采纳结果回填其上)
* @param kingdeeNumber A902-1 查询返回的金蝶客商编码
* @return 变更说明后缀,无需处理返回空串
*/
private String adoptKingdeeCode(CustomerArchive customer, CustomerArchive after, String kingdeeNumber) {
if (Func.isEmpty(kingdeeNumber) || kingdeeNumber.equals(customer.getCustomerCode())) {
return "";
}
long occupied = count(Wrappers.<CustomerArchive>lambdaQuery()
.eq(CustomerArchive::getCustomerCode, kingdeeNumber)
.ne(CustomerArchive::getId, customer.getId()));
if (occupied > 0) {
log.warn("金蝶客商编码 {} 已被其他客商占用,跳过采纳客商编号,id={},customerCode={}",
kingdeeNumber, customer.getId(), customer.getCustomerCode());
return ",金蝶客商编码 " + kingdeeNumber + " 已被占用未同步";
}
after.setCustomerCode(kingdeeNumber);
return ",客商编号已同步为金蝶客商编码:" + kingdeeNumber;
}
private BfiBillSaveResultItemVO firstSaveItem(BfiBillSaveResultVO result) {
if (result == null || result.getResult() == null || result.getResult().isEmpty()) {
return null;
@@ -739,11 +765,11 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
CustomerArchive after = copyCustomer(customer);
after.setKingdeeSyncTime(LocalDateTime.now());
if (BfiConstant.BILL_STATUS_AUDITED.equals(kingdeeCustomer.getStatus())) {
// 金蝶已存在已审核档案:跳过推送,直接建档
// 金蝶已存在已审核档案:跳过推送直接建档,客商编号采纳金蝶客商编码
after.setKingdeeStatus(KINGDEE_STATUS_EXIST);
after.setKingdeeFailReason(null);
existed++;
collectChangeRecord(changeRecords, customer.getId(), "金蝶已存在该客商档案,跳过推送", customer, after);
collectChangeRecord(changeRecords, customer.getId(), "金蝶已存在该客商档案,跳过推送" + this.adoptKingdeeCode(customer, after, kingdeeCustomer.getNumber()), customer, after);
} else {
// 金蝶侧暂存/已提交:审核中,置失败待重推
after.setKingdeeStatus(KINGDEE_STATUS_FAILED);
@@ -821,6 +847,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
CustomerArchive after = copyCustomer(customer);
after.setKingdeeStatus(KINGDEE_STATUS_EXIST);
after.setKingdeeFailReason(null);
this.adoptKingdeeCode(customer, after, kingdeeCustomer.getNumber());
updateList.add(after);
collectChangeRecord(changeRecords, customer.getId(), "金蝶客商已审核,推送最终成功", customer, after);
log.info("金蝶客商已审核,推送最终成功并升级为金蝶已有档案,id={},unifiedCreditCode={}",
@@ -953,6 +980,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
}
private void prepare(CustomerArchiveVO customer) {
customer.setCustomerCode(trimToNull(customer.getCustomerCode()));
customer.setShortName(trimToNull(customer.getShortName()));
customer.setFullName(trimToEmpty(customer.getFullName()));
customer.setCustomerKind(normalizeCustomerKind(customer.getCustomerKind()));
@@ -1022,6 +1050,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
throw new ServiceException("固定营业期限必须填写营业期限截止日");
}
validateLength(customer.getShortName(), 20, "客商简称最多20个汉字");
validateLength(customer.getCustomerCode(), 20, "客商编号最多20个字符");
validateLength(customer.getFullName(), 100, "客商全称最多100个字符");
validateLength(customer.getLegalPerson(), 10, "法人/负责人最多10个汉字");
validateLength(customer.getContactPhone(), 11, "联系电话必须为11位手机号");
@@ -2210,7 +2239,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
}
/**
* 客商所属组织固定为顶层「桂物物流集团」,忽略前端传入的其他组织。
* 客商所属组织固定为顶层「广西现代物流集团有限公司」,忽略前端传入的其他组织。
*/
private void applyFixedOwnerDept(CustomerArchiveVO customer) {
customer.setDeptName(FIXED_OWNER_DEPT_NAME);
@@ -113,6 +113,8 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> imp
Driver driver = new Driver();
driver.setId(id);
driver.setStatus(status);
// 只改 status 也必须带上 driving_vehicle 原值,理由见 carryDrivingVehicle
carryDrivingVehicle(id, driver);
return updateById(driver);
}
@@ -446,9 +448,26 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> imp
Driver patch = new Driver();
patch.setId(driver.getId());
patch.setUserId(userId);
// 同上:这次只改 user_id,不带 driving_vehicle 原值同样会把车辆绑定清掉
carryDrivingVehicle(driver.getId(), patch);
updateById(patch);
}
/**
* 把库里的 driving_vehicle 原值带进 patch。
* <p>
* 该列标了 {@code FieldStrategy.ALWAYS},更新时忽略 null 直接写:patch 里不带这一列,
* 一次只改别的列的更新就会把司机的车辆绑定清成 NULL,而司机端车辆列表正是按这一列
* 的车牌匹配出来的(清空即「暂无绑定车辆」)。读不到该司机时保持原样,
* 让更新自己去失败。
*/
private void carryDrivingVehicle(Long driverId, Driver patch) {
Driver current = getById(driverId);
if (current != null) {
patch.setDrivingVehicle(current.getDrivingVehicle());
}
}
private Long defaultZero(Long value) {
return value == null ? 0L : value;
}
@@ -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);
}
// 节点可以为空:全局入口(首页快捷格、打卡页底部按钮)上报的异常不指定环节,
// 它无从定位阻断区间,由门禁按「阻断该运单全部未打卡节点」处理。
// 节点非空时才要求它确属该运单;节点名取自过程配置而非请求体,避免落进一个该节点并不叫的名字。
@@ -38,8 +38,6 @@ import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.BeanUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.SysCache;
import org.springblade.system.pojo.entity.Dept;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyOpenApiQueryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyQueryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplySaveDTO;
@@ -98,6 +96,7 @@ import org.springblade.transport.pojo.vo.InvoiceApplicationSheetVO;
import org.springblade.transport.pojo.vo.InvoiceApplicationVO;
import org.springblade.transport.service.IInvoiceApplicationService;
import org.springblade.transport.service.IKingdeeBillLockService;
import org.springblade.transport.service.KingdeeSyncContext;
import org.springblade.transport.support.KingdeeApBillSyncSupport;
import org.springblade.transport.support.KingdeeArBillSyncSupport;
import org.springblade.transport.support.KingdeeSyncLockSupport;
@@ -203,6 +202,8 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
vo.setKingdeeLockFailReason(lockRecord.getFailReason());
vo.setKingdeeTryCount(lockRecord.getTryCount());
}
// 金蝶删单解锁会作废源单,按 unlocked 锁记录留痕补「金蝶删单」标识,区分正常作废与金蝶删单
kingdeeBillLockService.fillUnlockMarks(KingdeeLockConstant.SOURCE_INVOICE_APPLY, rows, lockMap);
}
/**
@@ -231,9 +232,23 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
InvoiceApplication entity = existing(id);
InvoiceApplicationVO vo = toListVO(entity);
fillKingdeeLockStatus(List.of(vo));
vo.setSettlements(settlementRelationMapper.selectList(Wrappers.<InvoiceApplicationSettlement>lambdaQuery()
.eq(InvoiceApplicationSettlement::getInvoiceApplicationId, id)
.orderByAsc(InvoiceApplicationSettlement::getCreateTime)));
// 剩余可开票金额按当前占用实时重算(排除本申请),避免编辑时展示保存时点的过期额度
List<InvoiceApplicationSettlement> relations = settlementRelationMapper.selectList(
Wrappers.<InvoiceApplicationSettlement>lambdaQuery()
.eq(InvoiceApplicationSettlement::getInvoiceApplicationId, id)
.orderByAsc(InvoiceApplicationSettlement::getCreateTime));
if (!relations.isEmpty()) {
Map<Long, FormalSettlement> settlementMap = formalSettlementMapper.selectBatchIds(
relations.stream().map(InvoiceApplicationSettlement::getFormalSettlementId).distinct().toList())
.stream().collect(Collectors.toMap(FormalSettlement::getId, Function.identity(), (first, duplicate) -> first));
relations.forEach(relation -> {
FormalSettlement settlement = settlementMap.get(relation.getFormalSettlementId());
if (settlement != null) {
relation.setAvailableInvoiceAmount(availableAmount(settlement, id));
}
});
}
vo.setSettlements(relations);
List<InvoiceApplicationSheet> sheets = sheetMapper.selectList(Wrappers.<InvoiceApplicationSheet>lambdaQuery()
.eq(InvoiceApplicationSheet::getInvoiceApplicationId, id).orderByAsc(InvoiceApplicationSheet::getSheetNo));
vo.setSheets(sheets.stream().map(sheet -> {
@@ -255,9 +270,8 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
@Override
public IPage<Map<String, Object>> settlementCandidates(IPage<?> page, String keyword,
String contractCategory, String settlementType, String invoiceStatus) {
String contractCategory, String settlementType) {
String candidateSettlementType = Func.isEmpty(settlementType) ? "receivable" : settlementType;
String candidateInvoiceStatus = Func.isEmpty(invoiceStatus) ? "unreceived" : invoiceStatus;
List<Long> contractIds = Func.isEmpty(contractCategory) ? List.of()
: contractManageMapper.selectList(Wrappers.<ContractManage>lambdaQuery()
.select(ContractManage::getId)
@@ -269,7 +283,6 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
}
List<FormalSettlement> settlements = formalSettlementMapper.selectList(Wrappers.<FormalSettlement>lambdaQuery()
.eq(FormalSettlement::getSettlementType, candidateSettlementType)
.eq(FormalSettlement::getInvoiceStatus, candidateInvoiceStatus)
.eq(FormalSettlement::getApprovalStatus, TransportBusiConstant.APPROVED)
.eq(FormalSettlement::getStatus, 1)
.in(Func.isNotEmpty(contractCategory), FormalSettlement::getContractId, contractIds)
@@ -337,6 +350,7 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
row.put("receiverName", settlement.getPayerName());
row.put("settlementAmount", money(settlement.getSettlementAmount()));
row.put("availableInvoiceAmount", available);
row.put("invoicedAmount", money(settlement.getSettlementAmount()).subtract(available));
return row;
}
@@ -402,28 +416,31 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
.collect(Collectors.toMap(InvoiceApplicationSaveRequest.SettlementRow::getSettlementId,
Function.identity(), (first, duplicate) -> first, LinkedHashMap::new)).values().stream().toList();
List<FormalSettlement> settlements = requestedRows.stream().map(row -> availableSettlement(row.getSettlementId())).toList();
if (settlements.stream().anyMatch(settlement -> !"receivable".equals(settlement.getSettlementType())
|| (creating && !"unreceived".equals(settlement.getInvoiceStatus())))) {
throw new ServiceException("只能选择审批通过、未作废、未收票的应收正式结算单");
if (settlements.stream().anyMatch(settlement -> !"receivable".equals(settlement.getSettlementType()))) {
throw new ServiceException("只能选择审批通过、未作废的应收正式结算单");
}
assertCompatible(settlements);
FormalSettlement first = settlements.get(0);
Map<Long, FormalSettlement> settlementMap = settlements.stream()
.collect(Collectors.toMap(FormalSettlement::getId, Function.identity()));
BigDecimal totalAvailable = BigDecimal.ZERO;
for (InvoiceApplicationSaveRequest.SettlementRow row : requestedRows) {
FormalSettlement settlement = settlementMap.get(row.getSettlementId());
BigDecimal available = availableAmount(settlement, entity.getId());
BigDecimal allocated = nonNegative(row.getAllocatedInvoiceAmount(), "分摊发票金额");
if (allocated.compareTo(available) > 0) {
throw new ServiceException("结算单" + settlement.getFormalSettlementNo() + "的分摊金额超过剩余可开票金额");
}
totalAvailable = totalAvailable.add(available);
}
BigDecimal lineTotal = validateSheets(request.getSheets());
if (lineTotal.compareTo(BigDecimal.ZERO) <= 0) {
throw new ServiceException("本次开票金额必须大于0");
}
// 本次开票金额按选择顺序自动分摊到各结算单并登记累计占用,摊不完即超过可开票金额
BigDecimal totalAvailable = BigDecimal.ZERO;
BigDecimal remaining = lineTotal;
for (InvoiceApplicationSaveRequest.SettlementRow row : requestedRows) {
FormalSettlement settlement = settlementMap.get(row.getSettlementId());
BigDecimal available = availableAmount(settlement, entity.getId());
totalAvailable = totalAvailable.add(available);
BigDecimal allocated = available.min(remaining);
row.setAllocatedInvoiceAmount(allocated);
remaining = remaining.subtract(allocated);
}
if (remaining.compareTo(BigDecimal.ZERO) > 0) {
throw new ServiceException("本次开票金额不能超过可开票金额");
}
CustomerArchive customer = findCustomer(first.getPayerName());
if (customer == null) throw new ServiceException("未找到正式结算单受票方对应的客商档案");
List<CustomerInvoiceInfoVO> invoiceInfos = activeInvoiceInfoVOs(customer.getId());
@@ -699,7 +716,7 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
@Override
@Transactional(rollbackFor = Exception.class)
public String doSyncKingdee(Long id) {
InvoiceApplication entity = existing(id);
InvoiceApplication entity = lockedExisting(id);
if (!TransportBusiConstant.APPROVED.equals(entity.getApprovalStatus())) throw new ServiceException("仅审批通过的开票申请允许同步金蝶");
// 已同步/已开票幂等返回(开票完成为终态);星瀚内码由附件补传分支经 A303-4 反查回填
if (KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(entity.getKingdeeStatus())
@@ -801,10 +818,10 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
}
if (Func.isBlank(entity.getDepartmentEmails())) throw new ServiceException("推送金蝶开票申请失败:部门邮箱(发票推送邮箱)不能为空");
if (Func.isBlank(entity.getTaxpayerNo())) throw new ServiceException("推送金蝶开票申请失败:购方纳税人识别号不能为空");
Dept dept = entity.getDeptId() == null ? null : SysCache.getDept(entity.getDeptId());
if (dept == null || Func.isBlank(dept.getKingdeeDeptCode())) {
throw new ServiceException("推送金蝶开票申请失败:申请部门[" + Func.toStr(entity.getDeptId()) + "]未维护金蝶组织编码,请检查部门数据");
}
// 金蝶组织编码:项目业务部门反查唯一平台公司 → 平台公司金蝶组织编码;申请部门编码:项目业务部门自身金蝶编码(与付款申请单部门.编码同源,共用上下文缓存)
KingdeeSyncContext kingdeeContext = KingdeeSyncContext.empty();
String kingdeeOrgNumber = kingdeeApBillSyncSupport.kingdeeOrgNumber(entity.getProjectId(), kingdeeContext);
String kingdeeDeptNumber = kingdeeApBillSyncSupport.kingdeeDeptNumber(entity.getProjectId(), kingdeeContext);
CustomerArchive customer = entity.getReceiverCustomerId() == null ? null : customerArchiveMapper.selectById(entity.getReceiverCustomerId());
if (customer == null || Func.isBlank(customer.getCustomerCode())) {
throw new ServiceException("推送金蝶开票申请失败:受票方客户未在客商档案中维护客商编码,请补充后重试");
@@ -835,7 +852,7 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
+ "]无商品行,无法组装分录");
}
BfiInvoiceApplySaveDTO param = this.buildInvoiceApplyParam(entity, sheetBillNo, seller, salerAddr, salerBank,
dept, customer, firstArSrcCode, money(sheet.getInvoiceAmount()), items);
kingdeeDeptNumber, kingdeeOrgNumber, customer, firstArSrcCode, money(sheet.getInvoiceAmount()), items);
log.info("金蝶开票申请推送:开票申请[{}]发票张[{}],billno=[{}],价税合计=[{}],分录{}条",
entity.getApplicationNo(), sheet.getSheetNo(), sheetBillNo, sheet.getInvoiceAmount(), items.size());
BfiBillSaveResultVO saveResult = bfiService.saveInvoiceApply(param);
@@ -884,17 +901,18 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
* 组装 A303-1 单张开票申请单入参(字段映射见设计文档 6.3/6.4),
* 购销方等主表公共字段随开票申请取数,价税合计与单据体分录取本张发票
*
* @param orgNumber 金蝶组织编码(项目业务部门反查平台公司)
* @param firstArSrcCode 上游来源单据号:首张关联结算单的金蝶应收单防重键 awrp_src_code(多张结算单取第一张)
*/
private BfiInvoiceApplySaveDTO buildInvoiceApplyParam(InvoiceApplication entity, String billNo,
CustomerArchive seller, String salerAddr, String salerBank, Dept dept, CustomerArchive customer,
CustomerArchive seller, String salerAddr, String salerBank, String deptNumber, String orgNumber, CustomerArchive customer,
String firstArSrcCode, BigDecimal sheetAmount, List<BfiInvoiceApplySaveEntryDTO> items) {
BfiInvoiceApplySaveDTO param = new BfiInvoiceApplySaveDTO();
// ==================== 以下为【必填】字段(BfiInvoiceApplySaveDTO 标注),非必填字段先注释保留 ====================
// 【必填】组织.编码:申请部门 deptId → 金蝶组织编码
param.setOrgidNumber(dept.getKingdeeDeptCode());
// 【必填】申请部门.编码:同上
param.setAwrpOrgfieldNumber(dept.getKingdeeDeptCode());
// 【必填】组织.编码:项目业务部门反查唯一平台公司 → 平台公司金蝶组织编码
param.setOrgidNumber(orgNumber);
// 【必填】申请部门.编码:项目业务部门金蝶编码(与付款申请单 awrp_department_number 同源)
param.setAwrpOrgfieldNumber(deptNumber);
// 【必填】业务单据编号(候选键):单张发票候选 billno(多张发票时开票申请单号-张次序号)
param.setBillno(billNo);
// 【必填】单据日期:申请日期 yyyy-MM-dd HH:mm:ss
@@ -1670,6 +1688,14 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
return entity;
}
/** 行锁读:同一单据并发推送串行化(锁随 doSyncKingdee 事务提交释放,后者读到已同步状态幂等返回) */
private InvoiceApplication lockedExisting(Long id) {
InvoiceApplication entity = baseMapper.selectOne(Wrappers.<InvoiceApplication>lambdaQuery()
.eq(InvoiceApplication::getId, id).last("FOR UPDATE"));
if (entity == null || Objects.equals(entity.getIsDeleted(), 1)) throw new ServiceException("开票申请不存在");
return entity;
}
private InvoiceApplication editable(Long id) {
InvoiceApplication entity = existing(id);
if (!List.of(TransportBusiConstant.DRAFT, TransportBusiConstant.RETURNED).contains(entity.getApprovalStatus())) throw new ServiceException("当前状态不可编辑");
@@ -1680,10 +1706,11 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
return ids == null ? List.of() : Func.toLongList(ids).stream().distinct().toList();
}
private String nextNo() {
/** 生成当日开票申请单号;按最大流水号 +1,且计入逻辑删除数据 */
private synchronized String nextNo() {
String prefix = "KP-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
return prefix + String.format("%05d", count(Wrappers.<InvoiceApplication>lambdaQuery()
.likeRight(InvoiceApplication::getApplicationNo, prefix)) + 1);
Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix);
return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1);
}
private String normalizeDepartmentEmails(String value, List<CustomerInvoiceInfoVO> invoiceInfos) {
@@ -64,6 +64,7 @@ import org.springblade.transport.mapper.FormalSettlementMapper;
import org.springblade.transport.mapper.InvoiceReceiptMapper;
import org.springblade.transport.mapper.InvoiceReceiptRecordMapper;
import org.springblade.transport.mapper.InvoiceReceiptSettlementMapper;
import org.springblade.transport.mapper.CustomerInvoiceInfoMapper;
import org.springblade.transport.mapper.KingdeeInvoicePoolMapper;
import org.springblade.transport.pojo.dto.InvoiceReceiptSaveRequest;
import org.springblade.transport.pojo.dto.InvoiceReceiptStatusRequest;
@@ -71,6 +72,7 @@ import org.springblade.transport.pojo.dto.KingdeeInvoicePoolQueryDTO;
import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.entity.CustomerArchive;
import org.springblade.transport.pojo.entity.CustomerContact;
import org.springblade.transport.pojo.entity.CustomerInvoiceInfo;
import org.springblade.transport.pojo.entity.FormalSettlement;
import org.springblade.transport.pojo.entity.FormalSettlementDetail;
import org.springblade.transport.pojo.entity.InvoiceReceipt;
@@ -91,6 +93,7 @@ import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import java.io.IOException;
import java.io.InputStream;
@@ -150,8 +153,11 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
private final FormalSettlementDetailMapper formalSettlementDetailMapper;
private final CustomerArchiveMapper customerArchiveMapper;
private final CustomerContactMapper customerContactMapper;
private final CustomerInvoiceInfoMapper customerInvoiceInfoMapper;
private final IBfiService bfiService;
private final BfiProperties bfiProperties;
/** 逐单推送事务模板:批量编排逐单开短事务,行锁跨远程调用,单笔失败只回滚自身不中断整批 */
private final TransactionTemplate transactionTemplate;
private final KingdeeApBillSyncSupport kingdeeApBillSyncSupport;
private final KingdeeReceivedInvoiceSyncSupport kingdeeReceivedInvoiceSyncSupport;
private final KingdeeAttachmentSyncSupport kingdeeAttachmentSyncSupport;
@@ -247,9 +253,10 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
return page;
}
/** 本地镜像真分页(可用过滤/认领登记标记均在 SQL 内直出) */
/** 本地镜像真分页(可用过滤/认领登记标记均在 SQL 内直出;客商单位过滤与定时同步同开关同口径) */
private IPage<KingdeeInvoicePoolVO> selectLocalPoolPage(KingdeeInvoicePoolQueryDTO query, long current, long size) {
return invoicePoolMapper.selectInvoicePoolPage(new Page<>(current, size), query, AuthUtil.getTenantId());
return invoicePoolMapper.selectInvoicePoolPage(new Page<>(current, size), query, AuthUtil.getTenantId(),
bfiProperties.getInvoiceReceipt().isCustomerUnitsOnly());
}
/**
@@ -608,12 +615,16 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
List<String> failures = new ArrayList<>();
for (InvoiceReceipt receipt : receipts) {
try {
pushOneReceivedInvoice(planByReceipt.get(receipt.getId()), kingdeeRowByInvoiceNo, apBillNoBySrcCode, context, autoSync);
// 逐单单据级事务:推送方法首行 FOR UPDATE 串行化同一登记的并发推送,单笔失败只回滚自身不中断整批
transactionTemplate.executeWithoutResult(status -> pushOneReceivedInvoice(
planByReceipt.get(receipt.getId()), kingdeeRowByInvoiceNo, apBillNoBySrcCode, context, autoSync));
success++;
} catch (RuntimeException pushException) {
failures.add("发票" + Func.toStrWithEmpty(receipt.getInvoiceNo(), String.valueOf(receipt.getId()))
+ ":" + pushException.getMessage());
log.warn("推送金蝶收票单失败,收票登记id={}", receipt.getId(), pushException);
// 单笔事务已回滚,事务外独立补落"推送失败"状态,纳入定时重推(与付款/开票失败口径统一)
markReceivedInvoicePushFailed(receipt, planByReceipt.get(receipt.getId()), pushException, autoSync);
}
}
String summary = "推送金蝶收票单完成:本批 " + receipts.size() + " 笔,成功 " + success + " 笔";
@@ -674,7 +685,21 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
*/
private String pushOneReceivedInvoice(ReceivedInvoicePushPlan plan, Map<String, BfiReceivedInvoiceVO> kingdeeRows,
Map<String, String> apBillNoBySrcCode, KingdeeSyncContext context, boolean autoSync) {
InvoiceReceipt receipt = plan.receipt();
// 行锁读:同一登记并发推送串行化(锁随本单事务提交释放,事务由批量编排的 TransactionTemplate 开启)
InvoiceReceipt receipt = baseMapper.selectOne(Wrappers.<InvoiceReceipt>lambdaQuery()
.eq(InvoiceReceipt::getId, plan.receipt().getId()).last("FOR UPDATE"));
if (receipt == null || Objects.equals(receipt.getIsDeleted(), 1)) {
throw new ServiceException("收票登记不存在");
}
// 锁后重查推送状态:并发场景另一事务已完成推送则幂等跳过,不再重复调金蝶
if (KingdeeBfiConstant.RECEIVED_PUSH_PUSHED.equals(receipt.getKingdeePushStatus())
|| KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED.equals(receipt.getKingdeePushStatus())) {
log.info("收票登记[{}]金蝶收票单已推送(状态[{}]),幂等跳过", receipt.getInvoiceNo(), receipt.getKingdeePushStatus());
return "发票" + Func.toStrWithEmpty(receipt.getInvoiceNo(), String.valueOf(receipt.getId())) + "已推送金蝶,幂等跳过";
}
// 计划内登记实体以行锁后最新数据替换(并发下推送状态可能已被其他事务改写)
plan = new ReceivedInvoicePushPlan(receipt, plan.mirror(), plan.settlement(), plan.details(),
plan.apBillSrcCode(), plan.blockedReason());
log.info("收票登记[{}]推送金蝶收票单开始:id={},推送状态={},应付单防重键={}", receipt.getInvoiceNo(), receipt.getId(),
receipt.getKingdeePushStatus(), plan.apBillSrcCode());
if (Func.isNotBlank(plan.blockedReason())) {
@@ -765,6 +790,25 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
return "发票" + receipt.getInvoiceNo() + "推送成功,金蝶收票单[" + billNo + "]已指定应付单";
}
/**
* 单笔推送失败后事务外独立补落"推送失败"状态:事务版推送中的失败状态写随本单事务回滚,
* 由批量编排的 catch 在事务外重写,纳入定时重推循环;手动前置拦截路径保持不动状态(仅逐单报因)
*
* @param receipt 收票登记(批量预取实体)
* @param plan 推送计划
* @param pushException 推送异常
* @param autoSync 自动推送路径标记
*/
private void markReceivedInvoicePushFailed(InvoiceReceipt receipt, ReceivedInvoicePushPlan plan,
RuntimeException pushException, boolean autoSync) {
if (plan.blockedReason() != null && !autoSync) {
return;
}
applyReceivedInvoicePushResult(receipt, receipt.getKingdeeReceivedInvoiceNo(), receipt.getKingdeeReceivedInvoiceId(),
KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
truncatePushMsg("推送金蝶收票单失败:" + pushException.getMessage()), true);
}
/**
* 附件联带上传(A902):A303-2 保存成功(含幂等预检命中)后调用,上传登记附件与镜像发票文件;
* billPkId 缺失跳过待补传;异常独立捕获,绝不影响推送状态与整批进度
@@ -1252,20 +1296,25 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
LocalDateTime windowStart = LocalDateTime.now().minusDays(bfiProperties.getInvoiceReceipt().getIncrementDays());
LocalDateTime windowEnd = LocalDateTime.now();
List<String> invoiceTypes = this.poolInvoiceTypeCodes();
// 客商单位过滤集:开关开启时一次性载入有效客商发票信息税号(销/购方税号都须命中才入镜像),整轮共享避免逐页查询
Set<String> customerTaxNos = bfiProperties.getInvoiceReceipt().isCustomerUnitsOnly() ? this.customerInvoiceTaxNos() : null;
SyncStats stats = new SyncStats();
log.info("金蝶全票池同步开始,增量窗口 {} ~ {},发票种类:{},单页 {} 条最多 {} 页(触顶自动二分切片)",
log.info("金蝶全票池同步开始,增量窗口 {} ~ {},发票种类:{},客商单位过滤:{},单页 {} 条最多 {} 页(触顶自动二分切片)",
windowStart.format(POOL_TIME_FORMATTER), windowEnd.format(POOL_TIME_FORMATTER),
invoiceTypes.isEmpty() ? "不限制" : invoiceTypes, POOL_PAGE_SIZE, MAX_POOL_PAGES);
invoiceTypes.isEmpty() ? "不限制" : invoiceTypes, customerTaxNos != null ? "开启" : "关闭",
POOL_PAGE_SIZE, MAX_POOL_PAGES);
if (invoiceTypes.isEmpty()) {
this.syncWindowSlice(windowStart, windowEnd, null, stats);
this.syncWindowSlice(windowStart, windowEnd, null, customerTaxNos, stats);
} else {
// 单值参数拆轮:每个票种编码独立完整切片续扫,互不挤占翻页额度
for (String invoiceType : invoiceTypes) {
this.syncWindowSlice(windowStart, windowEnd, invoiceType, stats);
this.syncWindowSlice(windowStart, windowEnd, invoiceType, customerTaxNos, stats);
}
}
String summary = "金蝶全票池同步:翻页" + stats.pageCount + "页切片" + stats.sliceCount + "片拉取"
+ stats.pulledCount + "张发票,镜像新增" + stats.created + "更新" + stats.updated + "无变化"
+ stats.pulledCount + "张发票"
+ (stats.customerFiltered > 0 ? "(客商单位过滤" + stats.customerFiltered + "张)" : "")
+ ",镜像新增" + stats.created + "更新" + stats.updated + "无变化"
+ stats.mirrorUnchanged + ",补绑" + stats.bound + ",登记无变化跳过" + stats.unchanged
+ ",失败" + stats.failed + ",耗时" + (System.currentTimeMillis() - startMillis) + "ms";
log.info("金蝶全票池同步结束,{}", summary);
@@ -1288,6 +1337,8 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
private static final class SyncStats {
/** A801-1 原始拉取张数 */
int pulledCount;
/** 客商单位过滤剔除张数(销/购方税号未同时登记在客商发票信息中,开关开启时统计) */
int customerFiltered;
/** 镜像新增条数(票池有、本地无) */
int created;
/** 镜像有变化并重写条数 */
@@ -1311,11 +1362,13 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
* 触顶说明该时间片数据 >2.5 万,从中点二分为两片递归续扫(递归深度受时间粒度自然收敛,
* 单片 500 条以内 1 页即短页终止)
*
* @param sliceStart 切片起点(含)
* @param sliceEnd 切片终点(不含,避免与下一片边界重复拉取)
* @param stats 整轮统计累加器
* @param sliceStart 切片起点(含)
* @param sliceEnd 切片终点(不含,避免与下一片边界重复拉取)
* @param customerTaxNos 客商发票信息税号集,null 表示客商单位过滤关闭(整轮共享)
* @param stats 整轮统计累加器
*/
private void syncWindowSlice(LocalDateTime sliceStart, LocalDateTime sliceEnd, String invoiceType, SyncStats stats) {
private void syncWindowSlice(LocalDateTime sliceStart, LocalDateTime sliceEnd, String invoiceType,
Set<String> customerTaxNos, SyncStats stats) {
// 二分收敛保护:切片粒度细到 1 秒仍触顶说明远端异常(同秒 >2.5 万条不可能),不再二分直接记漏
if (!sliceStart.isBefore(sliceEnd)) {
log.error("金蝶全票池同步切片区间为空({} ~ {}),远端 updateTime 粒度异常,放弃该切片", sliceStart, sliceEnd);
@@ -1329,6 +1382,13 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
}
stats.pageCount++;
stats.pulledCount += rows.size();
// 客商单位过滤:销/购方税号未同时登记在客商发票信息中的发票不入镜像;
// 短页判定用原始条数,过滤后的空页不能提前终止翻页
int rawPageSize = rows.size();
if (customerTaxNos != null) {
rows = rows.stream().filter(row -> this.bothUnitsRegistered(row, customerTaxNos)).toList();
stats.customerFiltered += rawPageSize - rows.size();
}
BatchUpsertResult upsertResult = this.batchUpsertInvoicePool(rows);
stats.created += upsertResult.created();
stats.updated += upsertResult.updated();
@@ -1340,8 +1400,8 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
stats.unchanged += refreshResult.unchanged();
stats.failed += refreshResult.failed();
}
// 远程短页:该片到底,正常终止
if (rows.size() < POOL_PAGE_SIZE) {
// 远程短页:该片到底,正常终止(按原始拉取条数判断,客商过滤不改变翻页节奏)
if (rawPageSize < POOL_PAGE_SIZE) {
stats.sliceCount++;
return;
}
@@ -1359,13 +1419,34 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
}
log.warn("金蝶全票池同步切片 {} ~ {} 触达翻页保护 {} 页({} 条),二分续扫 [{} ~ {}) + [{} ~ {})",
sliceStart, sliceEnd, MAX_POOL_PAGES, MAX_POOL_PAGES * POOL_PAGE_SIZE, sliceStart, mid, mid, sliceEnd);
this.syncWindowSlice(sliceStart, mid, invoiceType, stats);
this.syncWindowSlice(mid, sliceEnd, invoiceType, stats);
this.syncWindowSlice(sliceStart, mid, invoiceType, customerTaxNos, stats);
this.syncWindowSlice(mid, sliceEnd, invoiceType, customerTaxNos, stats);
return;
}
stats.sliceCount++;
}
/**
* 有效客商发票信息税号集(状态正常且未删除,MP 租户插件自动隔离租户):
* 客商单位过滤开关开启时,同步拉取与票池展示查询共用的单位白名单
*/
private Set<String> customerInvoiceTaxNos() {
return customerInvoiceInfoMapper.selectList(Wrappers.<CustomerInvoiceInfo>lambdaQuery()
.eq(CustomerInvoiceInfo::getStatus, 1)
.eq(CustomerInvoiceInfo::getIsDeleted, 0)).stream()
.map(CustomerInvoiceInfo::getTaxNo)
.filter(StringUtil::isNotBlank)
.collect(Collectors.toSet());
}
/**
* 开票单位(销方)与受票单位(购方)税号是否均已登记在客商发票信息中;税号缺失视为未登记(开关开启时不入镜像)
*/
private boolean bothUnitsRegistered(BfiInvoicePoolVO row, Set<String> customerTaxNos) {
return StringUtil.isNotBlank(row.getSalerTaxNo()) && StringUtil.isNotBlank(row.getBuyerTaxNo())
&& customerTaxNos.contains(row.getSalerTaxNo()) && customerTaxNos.contains(row.getBuyerTaxNo());
}
/** 已登记票跟随刷新结果统计(绑定/刷新/无变化/失败) */
private record RefreshResult(int bound, int unchanged, int failed) {
}
@@ -13,6 +13,7 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseEntity;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.core.tool.utils.SpringUtil;
@@ -29,6 +30,7 @@ import org.springblade.transport.pojo.entity.FormalSettlement;
import org.springblade.transport.pojo.entity.InvoiceApplication;
import org.springblade.transport.pojo.entity.KingdeeBillLock;
import org.springblade.transport.pojo.entity.PaymentApplication;
import org.springblade.transport.pojo.vo.KingdeeUnlockMark;
import org.springblade.transport.service.IFormalSettlementService;
import org.springblade.transport.service.IInvoiceApplicationService;
import org.springblade.transport.service.IKingdeeBillLockService;
@@ -152,8 +154,8 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl<KingdeeBillLockMappe
public int onUnlockNotify(BfiBillLockDTO param) {
// 总开关关闭:只应答不处理(返回 0 与「未匹配」同构,金蝶侧不会触发重试)
if (!bfiProperties.getBillLock().isEnabled()) {
log.warn("金蝶单据锁定总开关关闭,跳过解锁回调处理:operationType={},targetBillNo={}",
param == null ? null : param.getOperationType(), param == null ? null : param.getTargetBillNo());
log.warn("金蝶单据锁定总开关关闭,跳过解锁回调处理:operationType={},金蝶单号={}",
param == null ? null : param.getOperationType(), param == null ? null : param.getSourceBillNo());
return 0;
}
if (param == null || !KingdeeLockConstant.SYSTEM_XH.equals(param.getSourceSystem())
@@ -161,35 +163,41 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl<KingdeeBillLockMappe
|| !KingdeeLockConstant.ACTION_UNLOCK.equals(param.getLockAction())) {
throw new ServiceException("金蝶解锁回调参数非法:sourceSystem须为XH、targetSystem须为WLTMS、lockAction须为UNLOCK");
}
if (Func.isBlank(param.getTargetBillNo())) {
throw new ServiceException("金蝶解锁回调参数非法:targetBillNo不能为空");
if (Func.isBlank(param.getSourceBillNo()) || Func.isBlank(param.getTargetBillNo())) {
throw new ServiceException("金蝶解锁回调参数非法:sourceBillNo、targetBillNo不能为空");
}
// 批量回调:targetBillNo 逗号拼接,逐张处理;未匹配锁定中记录的幂等跳过(重复通知防金蝶重试风暴)
int processed = 0;
for (String targetBillNo : Func.toStrList(param.getTargetBillNo())) {
KingdeeBillLock lockRecord = this.getOne(Wrappers.<KingdeeBillLock>lambdaQuery()
.eq(KingdeeBillLock::getTargetBillType, param.getTargetBillType())
.eq(KingdeeBillLock::getTargetBillNo, targetBillNo)
.orderByDesc(KingdeeBillLock::getId)
.last("limit 1"), false);
// 回调方向与锁定相反:回调 sourceBillNo=金蝶单号(即锁记录 targetBillNo)、targetBillNo=WLTMS 单号(即锁记录 sourceBillNo)、
// targetBillType 可能为空,故按金蝶单号+我方单号双条件反查;未匹配锁定中记录的幂等跳过(重复通知防金蝶重试风暴)
// 一次 IN 查询取回候选记录、内存取各单号最新一条,批量更新解锁状态,避免逐张查询/更新的 N+1
List<String> kingdeeBillNos = Func.toStrList(param.getSourceBillNo());
Map<String, KingdeeBillLock> latestLockMap = this.list(Wrappers.<KingdeeBillLock>lambdaQuery()
.in(KingdeeBillLock::getTargetBillNo, kingdeeBillNos)
.eq(KingdeeBillLock::getSourceBillNo, param.getTargetBillNo())
.orderByDesc(KingdeeBillLock::getId))
.stream()
.collect(Collectors.toMap(KingdeeBillLock::getTargetBillNo, Function.identity(), (first, second) -> first));
List<KingdeeBillLock> unlockRecords = new ArrayList<>();
for (String kingdeeBillNo : kingdeeBillNos) {
KingdeeBillLock lockRecord = latestLockMap.get(kingdeeBillNo);
if (lockRecord == null || !KingdeeLockConstant.STATUS_LOCKED.equals(lockRecord.getLockStatus())) {
log.warn("金蝶解锁回调未匹配锁定中的锁记录(幂等跳过):targetBillType={},targetBillNo={}",
param.getTargetBillType(), targetBillNo);
log.warn("金蝶解锁回调未匹配锁定中的锁记录(幂等跳过):金蝶单号={},回调targetBillNo={}",
kingdeeBillNo, param.getTargetBillNo());
continue;
}
lockRecord.setLockStatus(KingdeeLockConstant.STATUS_UNLOCKED);
lockRecord.setOperationType(param.getOperationType());
lockRecord.setUnlockTime(LocalDateTime.now());
lockRecord.setNotifyOperator(param.getOperator());
this.updateById(lockRecord);
processed++;
unlockRecords.add(lockRecord);
log.info("金蝶单据[{}:{}]解锁回调落库:operationType={},操作人={}",
lockRecord.getSourceBillType(), lockRecord.getSourceBillNo(), param.getOperationType(), param.getOperator());
if (KingdeeLockConstant.OPERATION_DELETE.equals(param.getOperationType())) {
this.restoreOnKingdeeDelete(lockRecord, param.getOperator());
}
}
return processed;
this.updateBatchById(unlockRecords);
// DELETE 分发废除还原:按源单逐张执行(各单据类型还原动作为独立业务事务,无法合并)
if (KingdeeLockConstant.OPERATION_DELETE.equals(param.getOperationType())) {
unlockRecords.forEach(lockRecord -> this.restoreOnKingdeeDelete(lockRecord, param.getOperator()));
}
return unlockRecords.size();
}
/**
@@ -243,6 +251,28 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl<KingdeeBillLockMappe
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public int unlockByAdjustRepush(String sourceBillType, Long sourceBillId, String operator) {
List<KingdeeBillLock> lockedRecords = this.list(Wrappers.<KingdeeBillLock>lambdaQuery()
.eq(KingdeeBillLock::getSourceBillType, sourceBillType)
.eq(KingdeeBillLock::getSourceBillId, sourceBillId)
.eq(KingdeeBillLock::getLockStatus, KingdeeLockConstant.STATUS_LOCKED));
if (Func.isEmpty(lockedRecords)) {
return 0;
}
LocalDateTime unlockTime = LocalDateTime.now();
for (KingdeeBillLock lockRecord : lockedRecords) {
lockRecord.setLockStatus(KingdeeLockConstant.STATUS_UNLOCKED);
lockRecord.setOperationType(KingdeeLockConstant.OPERATION_ADJUST_REPUSH);
lockRecord.setUnlockTime(unlockTime);
lockRecord.setNotifyOperator(Func.isEmpty(operator) ? "system" : operator);
}
this.updateBatchById(lockedRecords);
log.info("结算调整重推解锁旧锁:源单[{}:{}],解锁{}张,操作人={}", sourceBillType, sourceBillId, lockedRecords.size(), operator);
return lockedRecords.size();
}
@Override
public void checkEditable(String sourceBillType, Long sourceBillId) {
long lockedCount = this.count(Wrappers.<KingdeeBillLock>lambdaQuery()
@@ -268,6 +298,47 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl<KingdeeBillLockMappe
return lockRecords.stream().collect(Collectors.toMap(KingdeeBillLock::getSourceBillId, Function.identity(), (first, second) -> second));
}
@Override
public Map<Long, KingdeeBillLock> mapUnlockedLocks(String sourceBillType, List<Long> sourceBillIds) {
if (Func.isEmpty(sourceBillIds)) {
return Map.of();
}
List<KingdeeBillLock> lockRecords = this.list(Wrappers.<KingdeeBillLock>lambdaQuery()
.eq(KingdeeBillLock::getSourceBillType, sourceBillType)
.in(KingdeeBillLock::getSourceBillId, sourceBillIds)
.eq(KingdeeBillLock::getLockStatus, KingdeeLockConstant.STATUS_UNLOCKED)
.orderByDesc(KingdeeBillLock::getId));
// 降序取回后首个生效:每源单保留最新一条解锁留痕
return lockRecords.stream().collect(Collectors.toMap(KingdeeBillLock::getSourceBillId, Function.identity(), (first, second) -> first));
}
@Override
public <T extends BaseEntity & KingdeeUnlockMark> void fillUnlockMarks(String sourceBillType, List<T> rows, Map<Long, KingdeeBillLock> activeLockMap) {
if (Func.isEmpty(rows)) {
return;
}
List<Long> unlockedIds = rows.stream()
.filter(row -> !activeLockMap.containsKey(row.getId()))
.map(BaseEntity::getId).toList();
if (Func.isEmpty(unlockedIds)) {
return;
}
Map<Long, KingdeeBillLock> unlockedMap = this.mapUnlockedLocks(sourceBillType, unlockedIds);
for (T row : rows) {
KingdeeBillLock unlockRecord = unlockedMap.get(row.getId());
if (unlockRecord == null) {
continue;
}
row.setKingdeeUnlockTime(unlockRecord.getUnlockTime());
row.setKingdeeUnlockOperator(unlockRecord.getNotifyOperator());
row.setKingdeeUnlockOperationName(switch (Func.toStr(unlockRecord.getOperationType())) {
case KingdeeLockConstant.OPERATION_DELETE -> "金蝶删单";
case KingdeeLockConstant.OPERATION_ADJUST_REPUSH -> "结算调整重推";
default -> "金蝶锁单";
});
}
}
/**
* 锁定补偿:扫 failed 且 try_count 未达重试上限(RETRY_MAX_COUNT)的锁记录,逐张重调 lockBusiData
* (接口 1对1:单次一个源单号对一个金蝶单号,单张成败互不影响,由定时任务定时触发,亦可人工触发)。
@@ -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())
@@ -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;
@@ -36,6 +36,7 @@ import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.BeanUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.constant.TransportBusiConstant;
import org.springblade.transport.mapper.PreSettlementAdvanceMapper;
import org.springblade.transport.mapper.PreSettlementChangeRecordMapper;
import org.springblade.transport.mapper.PreSettlementDetailFeeMapper;
@@ -67,6 +68,7 @@ import org.springblade.transport.service.IContractManageService;
import org.springblade.transport.service.ICustomerArchiveService;
import org.springblade.transport.service.IPreSettlementService;
import org.springblade.transport.service.IProjectApplyService;
import org.springblade.transport.service.ISettlementProjectAdvanceWriteOffService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.wrapper.PreSettlementWrapper;
import org.springblade.system.cache.UserCache;
@@ -83,7 +85,6 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
@@ -124,6 +125,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
private final ICustomerArchiveService customerArchiveService;
private final IWaybillService waybillService;
private final ISysClient sysClient;
private final ISettlementProjectAdvanceWriteOffService projectAdvanceWriteOffService;
@Override
public IPage<PreSettlementVO> selectPage(IPage<PreSettlement> page, PreSettlementVO query) {
@@ -163,7 +165,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
Map<String, String> categoryNames = DictBizCache.getList("fee_category").stream()
.collect(Collectors.toMap(DictBiz::getDictKey, DictBiz::getDictValue, (first, second) -> first,
LinkedHashMap::new));
org.springblade.core.tool.api.R<List<FeeItem>> response = sysClient.getFeeItems();
org.springblade.core.tool.api.R<List<FeeItem>> response = sysClient.getFeeItems(false);
if (response == null || !response.isSuccess() || response.getData() == null) {
throw new ServiceException("费用项基础档案读取失败,请稍后重试");
}
@@ -184,6 +186,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
PreSettlementVO vo = PreSettlementWrapper.build().entityVO(settlement);
fillPaymentApplicationAmounts(List.of(vo));
List<PreSettlementDetail> details = listDetails(id);
fillDetailBillingTypes(details);
vo.setDetails(details);
List<Long> detailIds = details.stream().map(PreSettlementDetail::getId).toList();
Map<Long, List<PreSettlementDetailFee>> feeMap = Func.isEmpty(detailIds) ? new HashMap<>()
@@ -198,14 +201,24 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
List<PreSettlementAdvance> advances = listAdvances(id);
advances.forEach(advance -> advance.setCreateUserName(UserCache.getUserRealName(advance.getCreateUser())));
vo.setAdvances(advances);
vo.setProjectAdvanceWriteOffs(projectAdvanceWriteOffService.listBySettlement(
TransportBusiConstant.SETTLEMENT_BILL_PRE, id));
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;
}
@Override
public String nextNo() {
return nextPreSettlementNo();
}
@Override
public List<Map<String, Object>> contractOptions(String keyword, Long projectId) {
LambdaQueryWrapper<ContractManage> wrapper = Wrappers.<ContractManage>lambdaQuery()
@@ -297,7 +310,9 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
IPage<ReceivablePayableDetail> sourcePage = sourceDetailMapper.selectPage(
new Page<>(page.getCurrent(), page.getSize()), wrapper);
Page<Map<String, Object>> result = new Page<>(sourcePage.getCurrent(), sourcePage.getSize(), sourcePage.getTotal());
result.setRecords(sourcePage.getRecords().stream().map(this::candidateMap).toList());
List<Map<String, Object>> records = sourcePage.getRecords().stream().map(this::candidateMap).toList();
fillCandidateBillingTypes(records);
result.setRecords(records);
return result;
}
@@ -343,9 +358,17 @@ 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,
settlement.getId(),
settlement.getContractId(),
settlement.getSettlementAmount(),
request.getProjectAdvanceWriteOffs());
if (created) {
saveChange(settlement.getId(), "结算单基本信息", null, "新增",
"新增预结算单" + settlement.getPreSettlementNo(), "");
@@ -362,6 +385,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
}
releaseSourceDetails(settlement, listDetails(id));
deleteChildren(id);
projectAdvanceWriteOffService.deleteBySettlement(TransportBusiConstant.SETTLEMENT_BILL_PRE, id);
deleteLogic(List.of(id));
}
@@ -406,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
@@ -444,6 +467,9 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
settlement.setCurrentProcessor(AuthUtil.getUserName());
settlement.setVoidReason(reason);
updateById(settlement);
// 联动删除项目预付核销行,避免残留记录与可核销占用
this.projectAdvanceWriteOffService.deleteBySettlementIds(
TransportBusiConstant.SETTLEMENT_BILL_PRE, List.of(settlement.getId()));
releaseSourceDetails(settlement, listDetails(settlement.getId()));
saveChange(settlement.getId(), "结算单基本信息", null, "调整", "作废预结算单", reason);
}
@@ -599,7 +625,9 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
if (Func.isEmpty(request.getRows())) {
throw new ServiceException("请填写需要调整的费用行");
}
String changeReason = limitRemark(request.getChangeReason(), 200);
// 弹窗底部备注非必填,写入结算明细备注
String detailRemark = limitRemark(request.getChangeReason(), 200);
String beforeDetailRemark = detail.getRemark();
Map<Long, PreSettlementDetailFee> existingMap = detailFees(detail.getId()).stream()
.collect(Collectors.toMap(PreSettlementDetailFee::getId, Function.identity()));
if (request.getRows().size() != existingMap.size()) {
@@ -619,7 +647,6 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
BigDecimal beforeFreight = fee.getFreightAmount();
BigDecimal beforeAmount = money(fee.getSettlementAmountTax());
BigDecimal beforeNoTaxAmount = fee.getSettlementAmountNoTax();
String beforeRemark = fee.getRemark();
Map<String, BigDecimal> beforeFeeItems = parseFeeItems(fee.getFeeItemsJson());
fee.setTransportQuantity(nonNegative(requestRow.getTransportQuantity(), "运输总量"));
fee.setMileage(nonNegative(requestRow.getMileage(), "里程"));
@@ -632,7 +659,6 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
fee.setSettlementAmountNoTax(requestRow.getSettlementAmountNoTax() == null ? null
: money(requestRow.getSettlementAmountNoTax()));
fee.setAdjustAmount(afterAmount.subtract(money(fee.getOriginalAmount())));
fee.setRemark(limitRemark(requestRow.getRemark(), 200));
detailFeeMapper.updateById(fee);
String prefix = "【" + firstNotEmpty(fee.getCargoName(), fee.getLineNo()) + "】";
appendChange(changes, beforeData, afterData, prefix + "运输总量", beforeQuantity, fee.getTransportQuantity());
@@ -647,16 +673,19 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
appendChange(changes, beforeData, afterData, prefix + "结算金额(含税)", beforeAmount, afterAmount);
appendChange(changes, beforeData, afterData, prefix + "结算金额(不含税)", beforeNoTaxAmount,
fee.getSettlementAmountNoTax());
appendTextChange(changes, beforeData, afterData, prefix + "备注", beforeRemark, fee.getRemark());
}
appendTextChange(changes, beforeData, afterData, "备注", beforeDetailRemark, detailRemark);
if (changes.isEmpty()) {
throw new ServiceException("未修改任何结算明细费用");
}
refreshDetail(detail);
// refreshDetail 会按费用行回填备注,需在其后覆盖为弹窗底部备注
detail.setRemark(detailRemark);
detailMapper.updateById(detail);
rebuildSummaryFees(settlement.getId(), true);
refreshSettlementAmount(settlement);
saveChange(settlement.getId(), "结算明细项", detail.getLineNo(), "调整",
String.join(";", changes), changeReason, beforeData, afterData);
String.join(";", changes), detailRemark, beforeData, afterData);
}
@Override
@@ -735,6 +764,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
result.put("quantityUnit", source.getQuantityUnit());
result.put("mileage", source.getMileage());
result.put("batchNo", source.getBatchNo());
result.put("billingType", "");
result.put("unitPrice", source.getUnitPrice());
result.put("freightAmount", money(source.getFreightAmount()));
result.put("feeItemsJson", source.getFeeItemsJson());
@@ -748,6 +778,35 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
return result;
}
/** 候选明细补充计费方式(货物费用计费类型去重汇总) */
private void fillCandidateBillingTypes(List<Map<String, Object>> records) {
if (records == null || records.isEmpty()) {
return;
}
List<Long> detailIds = records.stream()
.map(item -> Func.toLong(item.get("id")))
.filter(id -> id != null && id > 0)
.distinct()
.toList();
if (detailIds.isEmpty()) {
return;
}
Map<Long, String> billingTypeMap = sourceFeeMapper.selectList(
Wrappers.<ReceivablePayableCargoFee>lambdaQuery()
.in(ReceivablePayableCargoFee::getDetailId, detailIds)
.orderByAsc(ReceivablePayableCargoFee::getLineNo))
.stream()
.filter(fee -> Func.isNotEmpty(fee.getBillingType()) && !"-".equals(fee.getBillingType()))
.collect(Collectors.groupingBy(ReceivablePayableCargoFee::getDetailId,
Collectors.mapping(ReceivablePayableCargoFee::getBillingType,
Collectors.collectingAndThen(Collectors.toCollection(LinkedHashSet::new),
values -> String.join("、", values)))));
for (Map<String, Object> record : records) {
Long detailId = Func.toLong(record.get("id"));
record.put("billingType", billingTypeMap.getOrDefault(detailId, ""));
}
}
private void synchronizeDetails(PreSettlement settlement, List<Long> requestedIds, boolean allowSourceMismatch) {
List<Long> distinctIds = requestedIds.stream().filter(Objects::nonNull).distinct().toList();
List<PreSettlementDetail> existingDetails = listDetails(settlement.getId());
@@ -984,13 +1043,30 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
summaryFeeMapper.delete(Wrappers.<PreSettlementSummaryFee>lambdaQuery()
.eq(PreSettlementSummaryFee::getPreSettlementId, settlementId)
.eq(PreSettlementSummaryFee::getManualFlag, 0));
int lineNo = 1;
for (Map.Entry<String, BigDecimal> entry : aggregates.entrySet()) {
// 尽量沿用已有系统归集行的序号,避免重建后与手工费用交错顺序被打乱。
List<Map.Entry<String, BigDecimal>> orderedAggregates = new ArrayList<>(aggregates.entrySet());
orderedAggregates.sort((left, right) -> {
PreSettlementSummaryFee leftOld = existingGenerated.get(left.getKey());
PreSettlementSummaryFee rightOld = existingGenerated.get(right.getKey());
Integer leftLineNo = leftOld == null ? null : leftOld.getLineNo();
Integer rightLineNo = rightOld == null ? null : rightOld.getLineNo();
if (leftLineNo == null && rightLineNo == null) {
return 0;
}
if (leftLineNo == null) {
return 1;
}
if (rightLineNo == null) {
return -1;
}
return Integer.compare(leftLineNo, rightLineNo);
});
for (Map.Entry<String, BigDecimal> entry : orderedAggregates) {
String[] keyParts = entry.getKey().split("\\|", 2);
PreSettlementSummaryFee old = existingGenerated.get(entry.getKey());
PreSettlementSummaryFee row = new PreSettlementSummaryFee();
row.setPreSettlementId(settlementId);
row.setLineNo(lineNo++);
row.setLineNo(old == null || old.getLineNo() == null ? Integer.MAX_VALUE : old.getLineNo());
row.setFeeType(keyParts[0]);
row.setFeeItem(keyParts.length > 1 ? keyParts[1] : "");
// 原金额是明细首次生成时的快照,多次调整后不得被当前结算金额覆盖。
@@ -1006,7 +1082,14 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
}
private void applySummaryRequest(Long settlementId, List<PreSettlementSaveRequest.SummaryFee> requestRows) {
if (requestRows == null) return;
if (requestRows == null) {
return;
}
// 空列表表示沿用 rebuildSummaryFees 结果(如更换合同后),不得再按空请求删光合计行。
if (requestRows.isEmpty()) {
renumberSummaryFees(settlementId);
return;
}
Map<String, Set<String>> allowedManualFees = new LinkedHashMap<>();
if (requestRows.stream().anyMatch(row -> Integer.valueOf(1).equals(row.getManualFlag()))) {
for (Map<String, Object> option : feeOptions()) {
@@ -1026,6 +1109,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
List<PreSettlementSummaryFee> availableGeneratedRows = new ArrayList<>(existingGeneratedRows);
Set<Long> retainedManualIds = new LinkedHashSet<>();
Set<Long> retainedGeneratedIds = new LinkedHashSet<>();
int lineNo = 1;
for (PreSettlementSaveRequest.SummaryFee requestRow : requestRows) {
if (Integer.valueOf(1).equals(requestRow.getManualFlag())) {
String feeType = requiredText(requestRow.getFeeType(), "费用类型");
@@ -1042,11 +1126,15 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
&& Objects.equals(item.getFeeItem(), requestRow.getFeeItem()))
.findFirst().orElse(null);
}
if (row == null && requestRow.getId() == null) row = new PreSettlementSummaryFee();
if (row == null && requestRow.getId() == null) {
row = new PreSettlementSummaryFee();
}
if (row == null || (row.getId() != null && !Integer.valueOf(1).equals(row.getManualFlag()))) {
throw new ServiceException("存在无效的手工费用行");
}
boolean creating = row.getId() == null;
row.setPreSettlementId(settlementId);
row.setLineNo(lineNo++);
row.setFeeType(feeType);
row.setFeeItem(feeItem);
row.setOriginalAmount(BigDecimal.ZERO.setScale(2));
@@ -1054,10 +1142,12 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
row.setSettlementAmount(row.getAdjustAmount());
row.setRemark(limitRemark(requestRow.getRemark(), 50));
row.setManualFlag(1);
if (row.getId() == null) summaryFeeMapper.insert(row); else summaryFeeMapper.updateById(row);
if (creating) {
summaryFeeMapper.insert(row);
} else {
summaryFeeMapper.updateById(row);
}
retainedManualIds.add(row.getId());
saveChange(settlementId, "合计费用项", null, requestRow.getId() == null ? "新增" : "调整",
row.getFeeItem() + "金额" + row.getSettlementAmount(), "");
continue;
}
String feeItem = requiredText(requestRow.getFeeItem(), "费用项");
@@ -1077,27 +1167,21 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
row.setPreSettlementId(settlementId);
row.setManualFlag(0);
}
row.setLineNo(lineNo++);
row.setFeeType(feeType);
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) {
saveChange(settlementId, "合计费用项", null, "调整",
"【调整金额】从【" + before + "】调整为【" + row.getAdjustAmount() + "】", "");
}
}
for (PreSettlementSummaryFee manualRow : manualRows) {
if (!retainedManualIds.contains(manualRow.getId())) {
summaryFeeMapper.deleteById(manualRow.getId());
saveChange(settlementId, "合计费用项", null, "删除",
"删除" + manualRow.getFeeItem() + "费用" + manualRow.getSettlementAmount(), "");
}
}
for (PreSettlementSummaryFee generatedRow : existingGeneratedRows) {
@@ -1105,32 +1189,63 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
summaryFeeMapper.deleteById(generatedRow.getId());
}
}
renumberSummaryFees(settlementId);
// lineNo 已按前端提交顺序写入,不再按旧序号重排,避免手工费用与系统归集费用顺序被打乱。
}
private void refreshDetail(PreSettlementDetail detail) {
List<PreSettlementDetailFee> fees = detailFees(detail.getId());
BigDecimal transportQuantity = fees.stream().map(PreSettlementDetailFee::getTransportQuantity)
.filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal mileage = fees.stream().map(PreSettlementDetailFee::getMileage)
.filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal freightAmount = fees.stream().map(PreSettlementDetailFee::getFreightAmount)
.map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal originalAmount = fees.stream().map(PreSettlementDetailFee::getOriginalAmount)
.map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal settlementAmountTax = fees.stream().map(PreSettlementDetailFee::getSettlementAmountTax)
.map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal settlementAmountNoTax = fees.stream().map(PreSettlementDetailFee::getSettlementAmountNoTax)
.filter(Objects::nonNull).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add);
Map<String, BigDecimal> feeItems = new LinkedHashMap<>();
fees.forEach(fee -> parseFeeItems(fee.getFeeItemsJson()).forEach((name, amount) ->
feeItems.merge(name, money(amount), BigDecimal::add)));
// 运输单价无法按行简单求和,与前端暂存逻辑一致:取首行费用单价。
BigDecimal unitPrice = fees.stream().map(PreSettlementDetailFee::getUnitPrice)
.filter(Objects::nonNull).findFirst().orElse(null);
String remark = fees.stream().map(PreSettlementDetailFee::getRemark)
.filter(Func::isNotEmpty).findFirst().orElse(detail.getRemark());
detail.setTransportQuantity(money(transportQuantity));
detail.setMileage(mileage);
detail.setUnitPrice(unitPrice);
detail.setFreightAmount(money(freightAmount));
detail.setOriginalAmount(money(originalAmount));
detail.setAdjustAmount(money(settlementAmountTax.subtract(originalAmount)));
detail.setSettlementAmountTax(money(settlementAmountTax));
detail.setSettlementAmountNoTax(fees.stream().anyMatch(fee -> fee.getSettlementAmountNoTax() != null)
? money(settlementAmountNoTax) : null);
detail.setFeeItemsJson(JsonUtil.toJson(feeItems));
detail.setRemark(limitRemark(remark, 200));
detailMapper.updateById(detail);
}
/**
* 结算金额以结算合计为准(含手工新增费用),与详情页合计及付款申请取数口径一致。
* 合计行为空时回退为明细含税金额合计。
*/
private void refreshSettlementAmount(PreSettlement settlement) {
BigDecimal settlementAmount = listDetails(settlement.getId()).stream()
.map(PreSettlementDetail::getSettlementAmountTax).map(this::money)
.reduce(BigDecimal.ZERO, BigDecimal::add);
List<PreSettlementSummaryFee> summaryFees = listSummaryFees(settlement.getId());
BigDecimal settlementAmount;
if (!summaryFees.isEmpty()) {
settlementAmount = summaryFees.stream()
.map(PreSettlementSummaryFee::getSettlementAmount)
.map(this::money)
.reduce(BigDecimal.ZERO, BigDecimal::add);
} else {
settlementAmount = listDetails(settlement.getId()).stream()
.map(PreSettlementDetail::getSettlementAmountTax)
.map(this::money)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
settlement.setSettlementAmount(money(settlementAmount));
BigDecimal rate = normalizeRate(settlement.getExchangeRate(), settlement.getCurrency());
settlement.setExchangeRate(rate);
@@ -1349,6 +1464,34 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
.eq(PreSettlementChangeRecord::getPreSettlementId, settlementId));
}
/** 预结算明细展示计费方式:按来源应收应付货物费用的计费类型汇总。 */
private void fillDetailBillingTypes(List<PreSettlementDetail> details) {
if (details == null || details.isEmpty()) {
return;
}
List<Long> sourceDetailIds = details.stream()
.map(PreSettlementDetail::getSourceDetailId)
.filter(Objects::nonNull)
.distinct()
.toList();
if (sourceDetailIds.isEmpty()) {
return;
}
Map<Long, String> billingTypeMap = sourceFeeMapper.selectList(
Wrappers.<ReceivablePayableCargoFee>lambdaQuery()
.in(ReceivablePayableCargoFee::getDetailId, sourceDetailIds)
.orderByAsc(ReceivablePayableCargoFee::getLineNo))
.stream()
.filter(fee -> Func.isNotEmpty(fee.getBillingType()) && !"-".equals(fee.getBillingType()))
.collect(Collectors.groupingBy(ReceivablePayableCargoFee::getDetailId,
Collectors.mapping(ReceivablePayableCargoFee::getBillingType,
Collectors.collectingAndThen(Collectors.toCollection(LinkedHashSet::new),
values -> String.join("、", values)))));
for (PreSettlementDetail detail : details) {
detail.setBillingType(billingTypeMap.getOrDefault(detail.getSourceDetailId(), ""));
}
}
private List<PreSettlementDetail> listDetails(Long settlementId) {
return detailMapper.selectList(Wrappers.<PreSettlementDetail>lambdaQuery()
.eq(PreSettlementDetail::getPreSettlementId, settlementId)
@@ -1388,6 +1531,89 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
}
}
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<>();
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);
}
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);
}
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,
String content, String reason) {
saveChange(settlementId, changeType, lineNo, operationType, content, reason, null, null);
@@ -1602,44 +1828,25 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
return String.valueOf(feeType) + "|" + String.valueOf(feeItem);
}
/** 生成当日预结算单号;按最大流水号 +1,且计入逻辑删除数据 */
private synchronized String nextPreSettlementNo() {
return nextDailyCode("YJ", PreSettlement::getPreSettlementNo);
String prefix = "YJ" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
Long maxSequence = baseMapper.selectMaxPreSettlementDailySequence(AuthUtil.getTenantId(), prefix);
return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1);
}
/** 生成预结算转正式结算单号;按最大流水号 +1,且计入逻辑删除数据 */
private synchronized String nextFormalSettlementNo() {
return nextDailyCode("ZJ", PreSettlement::getFormalSettlementNo);
String prefix = "ZJ" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
Long maxSequence = baseMapper.selectMaxFormalSettlementDailySequence(AuthUtil.getTenantId(), prefix);
return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1);
}
/** 生成当日预付单号;按最大流水号 +1,且计入逻辑删除数据 */
private synchronized String nextAdvanceNo() {
String prefix = "YF" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
PreSettlementAdvance latest = advanceMapper.selectOne(Wrappers.<PreSettlementAdvance>lambdaQuery()
.likeRight(PreSettlementAdvance::getAdvanceNo, prefix)
.orderByDesc(PreSettlementAdvance::getAdvanceNo)
.last("limit 1"));
return prefix + String.format("%05d", nextSequence(latest == null ? null : latest.getAdvanceNo(), prefix));
}
private String nextDailyCode(String code, Function<PreSettlement, String> getter) {
String prefix = code + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
PreSettlement latest = list(Wrappers.<PreSettlement>lambdaQuery()
.and(query -> query.likeRight(PreSettlement::getPreSettlementNo, prefix)
.or().likeRight(PreSettlement::getFormalSettlementNo, prefix))
.orderByDesc(PreSettlement::getCreateTime)
.last("limit 1")).stream().max(Comparator.comparing(item -> {
String value = getter.apply(item);
return value == null ? "" : value;
})).orElse(null);
String latestCode = latest == null ? null : getter.apply(latest);
return prefix + String.format("%05d", nextSequence(latestCode, prefix));
}
private int nextSequence(String latestCode, String prefix) {
if (latestCode == null || !latestCode.startsWith(prefix)) return 1;
try {
return Integer.parseInt(latestCode.substring(prefix.length())) + 1;
} catch (NumberFormatException exception) {
return 1;
}
Long maxSequence = advanceMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix);
return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1);
}
}
@@ -83,6 +83,7 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
private static final String STATUS_APPROVED = "approved";
private static final String STATUS_REJECTED = "rejected";
private static final String STATUS_CHANGE_REVIEWING = "change_reviewing";
private static final String STATUS_CHANGE_APPROVED = "change_approved";
private static final String STATUS_VOIDED = "voided";
private static final String EFFECTIVE_TEMPORARY = "temporary";
private static final String EFFECTIVE_FORMAL = "formal";
@@ -167,7 +168,7 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
appendProjectChangeRecord(projectApply, CHANGE_TYPE_DRAFT, null, STATUS_DRAFT, "草稿",
buildProjectChangeSnapshot(beforeRecord), buildProjectChangeSnapshot(projectApply));
}
return saveOrUpdate(projectApply);
return persistProjectApply(projectApply);
}
@Override
@@ -186,7 +187,7 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
projectApply.setApprovalStatus(STATUS_REVIEWING);
projectApply.setCurrentNode(resolveApprovalNode(projectApply));
projectApply.setCurrentProcessor("待处理");
return saveOrUpdate(projectApply);
return persistProjectApply(projectApply);
}
@Override
@@ -207,7 +208,7 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
if (!List.of(STATUS_REVIEWING, STATUS_CHANGE_REVIEWING).contains(projectApply.getApprovalStatus())) {
throw new ServiceException("当前状态不允许审批通过");
}
projectApply.setApprovalStatus(STATUS_CHANGE_REVIEWING.equals(projectApply.getApprovalStatus()) ? "change_approved" : STATUS_APPROVED);
projectApply.setApprovalStatus(STATUS_CHANGE_REVIEWING.equals(projectApply.getApprovalStatus()) ? STATUS_CHANGE_APPROVED : STATUS_APPROVED);
projectApply.setEffectiveType(EFFECTIVE_FORMAL);
projectApply.setCurrentNode("审批通过");
projectApply.setCurrentProcessor(AuthUtil.getUserName());
@@ -247,7 +248,7 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
@Transactional(rollbackFor = Exception.class)
public boolean voidProject(Long id, String reason) {
ProjectApply projectApply = loadExists(id);
if (!Objects.equals(projectApply.getApprovalStatus(), STATUS_APPROVED)) {
if (!List.of(STATUS_APPROVED, STATUS_CHANGE_APPROVED).contains(projectApply.getApprovalStatus())) {
throw new ServiceException("仅审批通过项目允许作废");
}
projectApply.setApprovalStatus(STATUS_VOIDED);
@@ -263,6 +264,7 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
// 变更草稿仅更新业务字段,不生成变更记录;变更记录在 submitChange 时落库
ProjectApply oldRecord = loadChangeable(projectApply.getId());
prepare(projectApply);
normalizeUndertakeDept(projectApply);
copyChangeFields(oldRecord, projectApply);
validateSubmit(oldRecord);
validateChangeLength(oldRecord);
@@ -275,6 +277,7 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
ProjectApply oldRecord = loadChangeable(projectApply.getId());
Map<String, Object> beforeData = buildProjectChangeSnapshot(oldRecord);
prepare(projectApply);
normalizeUndertakeDept(projectApply);
copyChangeFields(oldRecord, projectApply);
validateSubmit(oldRecord);
validateChange(oldRecord, projectApply.getChangeType());
@@ -382,13 +385,13 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
}
if (Boolean.TRUE.equals(projectApply.getTemporaryCreditLimitSelectable())) {
queryWrapper.eq(ProjectApply::getEffectiveType, EFFECTIVE_FORMAL)
.in(ProjectApply::getApprovalStatus, STATUS_APPROVED, "change_approved");
.in(ProjectApply::getApprovalStatus, STATUS_APPROVED, STATUS_CHANGE_APPROVED);
}
if (Boolean.TRUE.equals(projectApply.getContractSelectable())) {
// 正式审批通过项目 + 未作废的临时项目均可被合同选择
queryWrapper.and(wrapper -> wrapper
.nested(formal -> formal.eq(ProjectApply::getEffectiveType, EFFECTIVE_FORMAL)
.in(ProjectApply::getApprovalStatus, STATUS_APPROVED, "change_approved"))
.in(ProjectApply::getApprovalStatus, STATUS_APPROVED, STATUS_CHANGE_APPROVED))
.or(temporary -> temporary.eq(ProjectApply::getEffectiveType, EFFECTIVE_TEMPORARY)
.ne(ProjectApply::getApprovalStatus, STATUS_VOIDED)));
}
@@ -582,6 +585,11 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
}
private void copyChangeFields(ProjectApply target, ProjectApply source) {
// 项目变更允许调整业务部门、平台公司
target.setBusinessDeptId(source.getBusinessDeptId());
target.setBusinessDeptName(source.getBusinessDeptName());
target.setUndertakeDeptId(source.getUndertakeDeptId());
target.setUndertakeDeptName(source.getUndertakeDeptName());
target.setFundLimit(source.getFundLimit());
target.setReceivableLimit(source.getReceivableLimit());
target.setReceivableDays(source.getReceivableDays());
@@ -795,6 +803,8 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
private void validateLength(ProjectApply projectApply) {
TransportBusinessSupport.validateLength(projectApply.getProjectName(), 50, "项目名称不能超过50个字");
TransportBusinessSupport.validateLength(projectApply.getProjectShortName(), 20, "项目简称不能超过20个字");
TransportBusinessSupport.validateLength(projectApply.getUndertakeDeptId(), 500, "平台公司选择过多,请减少后再提交");
TransportBusinessSupport.validateLength(projectApply.getUndertakeDeptName(), 500, "平台公司名称过长,请减少选择后再提交");
TransportBusinessSupport.validateLength(projectApply.getSourceRemark(), 300, "项目由来说明不能超过300个字");
TransportBusinessSupport.validateLength(projectApply.getCargoQuantity(), 100, "预估货物数量不能超过100个字");
TransportBusinessSupport.validateLength(projectApply.getTransportRoute(), 100, "运输线路不能超过100个字");
@@ -896,7 +906,9 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
}
private void validateChangeable(ProjectApply projectApply) {
if (!Objects.equals(projectApply.getApprovalStatus(), STATUS_APPROVED) || !Objects.equals(projectApply.getEffectiveType(), EFFECTIVE_FORMAL)) {
// 首次立项通过(approved)与历次变更通过(change_approved)均可再次发起变更
boolean approved = List.of(STATUS_APPROVED, STATUS_CHANGE_APPROVED).contains(projectApply.getApprovalStatus());
if (!approved || !Objects.equals(projectApply.getEffectiveType(), EFFECTIVE_FORMAL)) {
throw new ServiceException("仅正式生效且审批通过的项目允许发起变更");
}
}
@@ -1046,6 +1058,37 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
});
}
private boolean persistProjectApply(ProjectApply projectApply) {
try {
return saveOrUpdate(projectApply);
} catch (RuntimeException exception) {
throw translateUndertakeDeptPersistError(exception, projectApply);
}
}
/**
* 多选平台公司写入失败时,提示执行字段扩容脚本(原列为 bigint / varchar(100))。
*/
private RuntimeException translateUndertakeDeptPersistError(RuntimeException exception, ProjectApply projectApply) {
String message = Func.toStr(exception.getMessage());
Throwable cause = exception.getCause();
while (cause != null) {
message = message + " " + Func.toStr(cause.getMessage());
cause = cause.getCause();
}
String lowerMessage = message.toLowerCase();
boolean multiUndertake = Func.isNotEmpty(projectApply.getUndertakeDeptId())
&& (projectApply.getUndertakeDeptId().contains(",") || projectApply.getUndertakeDeptId().contains(","));
boolean columnHint = lowerMessage.contains("undertake_dept")
|| lowerMessage.contains("incorrect integer")
|| lowerMessage.contains("data truncation")
|| lowerMessage.contains("data too long");
if (multiUndertake && columnHint) {
return new ServiceException("平台公司多选保存失败,请先执行脚本 blade_project_apply_undertake_dept_multi_20261014.sql 扩容字段后重试");
}
return exception;
}
private void normalizeUndertakeDept(ProjectApply projectApply) {
if (projectApply == null) {
return;
@@ -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) {
@@ -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) {
@@ -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;
}
@@ -607,14 +605,22 @@ public class ReceivablePayableDetailServiceImpl
BigDecimal transportQuantity = money(adjusted.getTransportQuantity());
BigDecimal mileage = money(adjusted.getMileage());
BigDecimal freightAmount = money(adjusted.getFreightAmount());
BigDecimal unitPrice = adjusted.getUnitPrice() == null ? null : money(adjusted.getUnitPrice());
Map<String, BigDecimal> effectiveFeeItems = feeItems;
boolean billingBasisChanged = money(existing.getTransportQuantity()).compareTo(transportQuantity) != 0
|| money(existing.getMileage()).compareTo(mileage) != 0;
if (billingBasisChanged) {
boolean quantityChanged = money(existing.getTransportQuantity()).compareTo(transportQuantity) != 0;
boolean mileageChanged = money(existing.getMileage()).compareTo(mileage) != 0;
boolean unitPriceChanged = money(existing.getUnitPrice()).compareTo(money(unitPrice)) != 0;
if (quantityChanged || mileageChanged || unitPriceChanged) {
String changedField = quantityChanged && !mileageChanged ? "transportQuantity"
: mileageChanged && !quantityChanged ? "mileage"
: unitPriceChanged && !quantityChanged && !mileageChanged ? "unitPrice" : null;
AdjustedFeeCalculation calculation = calculateAdjustedFee(detail, existing,
transportQuantity, mileage, freightAmount, feeItems);
transportQuantity, mileage, freightAmount, feeItems, unitPrice, changedField);
freightAmount = calculation.freightAmount();
effectiveFeeItems = calculation.feeItems();
if (calculation.unitPrice() != null) {
adjusted.setUnitPrice(calculation.unitPrice().setScale(2, RoundingMode.HALF_UP));
}
}
appendChange(changes, "规格", existing.getSpecification(), adjusted.getSpecification());
appendChange(changes, "型号", existing.getModel(), adjusted.getModel());
@@ -678,15 +684,21 @@ public class ReceivablePayableDetailServiceImpl
}
validateNonNegative(request.getTransportQuantity(), "运输量");
validateNonNegative(request.getMileage(), "里程");
validateNonNegative(request.getUnitPrice(), "运输单价");
AdjustedFeeCalculation calculation = calculateAdjustedFee(detail, fee,
money(request.getTransportQuantity()), money(request.getMileage()),
money(request.getFreightAmount()), normalizeFeeItems(request.getFeeItems()));
money(request.getFreightAmount()), normalizeFeeItems(request.getFeeItems()),
request.getUnitPrice() == null ? null : money(request.getUnitPrice()),
request.getChangedField());
ReceivablePayableCargoFeeVO result = Objects.requireNonNull(
BeanUtil.copyProperties(fee, ReceivablePayableCargoFeeVO.class));
result.setTransportQuantity(request.getTransportQuantity());
result.setMileage(request.getMileage());
result.setFreightAmount(calculation.freightAmount());
result.setFeeItems(new LinkedHashMap<>(calculation.feeItems()));
if (calculation.unitPrice() != null) {
result.setUnitPrice(calculation.unitPrice().setScale(2, RoundingMode.HALF_UP));
}
BigDecimal afterAmount = adjustedAfterAmount(calculation.freightAmount(), calculation.feeItems());
result.setAdjustAmount(afterAmount.subtract(money(fee.getOriginalAmount())));
result.setAfterAmount(afterAmount);
@@ -836,6 +848,12 @@ public class ReceivablePayableDetailServiceImpl
if (existsByWaybill(waybill.getId(), targetSettlementType)) {
continue;
}
if ("payable".equals(targetSettlementType)
&& trimToNull(waybill.getCarrierName()) != null
&& !matchesWaybillCarrier(contract, waybill)) {
throw new ServiceException("运单【" + Func.toStr(waybill.getWaybillNo(), String.valueOf(waybill.getId()))
+ "】的承运商不是所选承运商合同的乙方,不能使用项目内其他承运商合同生成应付");
}
ReceivablePayableDetail detail = buildDetail(waybill, contract, request.getBillingPlanId(), targetSettlementType);
detail.setSourceType(SOURCE_MANUAL_GENERATION);
save(detail);
@@ -847,6 +865,186 @@ public class ReceivablePayableDetailServiceImpl
}
}
private static final String SOURCE_SETTLEMENT_IMPORT = "结算导入";
@Override
@Transactional(rollbackFor = Exception.class)
public List<Long> createDetailsFromSettlementImport(String settlementType, Long contractId,
List<Map<String, Object>> feeRows) {
if (Func.isEmpty(settlementType) || Func.isEmpty(contractId)) {
throw new ServiceException("结算类型与合同不能为空");
}
if (Func.isEmpty(feeRows)) {
return List.of();
}
ContractManage contract = contractManageService.getById(contractId);
if (contract == null || Objects.equals(contract.getIsDeleted(), 1)) {
throw new ServiceException("合同不存在");
}
String targetSettlementType = settlementType(settlementType);
Long currentUserId = AuthUtil.getUserId();
Date generateTime = new Date();
List<Long> createdIds = new ArrayList<>();
for (Map<String, Object> feeRow : feeRows) {
if (feeRow == null) {
continue;
}
Long waybillId = toLong(feeRow.get("waybillId"));
if (waybillId == null) {
continue;
}
Waybill waybill = waybillService.getById(waybillId);
if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) {
throw new ServiceException("运单不存在:" + waybillId);
}
assertWaybillNotSettledForImport(waybill, targetSettlementType);
BigDecimal transportFee = money(decimal(feeRow.get("transportFee")));
BigDecimal unitPrice = money(decimal(feeRow.get("unitPrice")));
Map<String, BigDecimal> feeItems = normalizeFeeItems(toFeeItemMap(feeRow.get("feeItems")));
BigDecimal feeItemTotal = feeItems.values().stream().map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal afterAmount = transportFee.add(feeItemTotal).setScale(2, RoundingMode.HALF_UP);
ReceivablePayableCargoFee cargoFee = buildSettlementImportCargoFee(
waybill, transportFee, unitPrice, feeItems, afterAmount, stringValue(feeRow.get("remark")));
ReceivablePayableDetail importDetail = getOne(Wrappers.<ReceivablePayableDetail>lambdaQuery()
.eq(ReceivablePayableDetail::getWaybillId, waybillId)
.eq(ReceivablePayableDetail::getSettlementType, targetSettlementType)
.eq(ReceivablePayableDetail::getSourceType, SOURCE_SETTLEMENT_IMPORT)
.eq(ReceivablePayableDetail::getIsDeleted, 0)
.orderByDesc(ReceivablePayableDetail::getCreateTime)
.last("limit 1"), false);
if (importDetail != null) {
refreshSettlementImportDetail(importDetail, waybill, contract, targetSettlementType,
cargoFee, unitPrice, stringValue(feeRow.get("remark")), currentUserId, generateTime);
createdIds.add(importDetail.getId());
continue;
}
ReceivablePayableDetail detail = buildDetail(waybill, contract, targetSettlementType,
List.of(cargoFee), unitPrice);
detail.setSourceType(SOURCE_SETTLEMENT_IMPORT);
detail.setSettlementStatus("pending");
if (Func.isNotEmpty(stringValue(feeRow.get("remark")))) {
detail.setRemark(stringValue(feeRow.get("remark")));
}
saveAutomaticDetail(detail, List.of(cargoFee), false, currentUserId, generateTime);
createdIds.add(detail.getId());
}
return createdIds;
}
/** 运单在该结算类型下已进入预/正式结算时,禁止再导入。 */
private void assertWaybillNotSettledForImport(Waybill waybill, String settlementType) {
List<ReceivablePayableDetail> details = list(Wrappers.<ReceivablePayableDetail>lambdaQuery()
.eq(ReceivablePayableDetail::getWaybillId, waybill.getId())
.eq(ReceivablePayableDetail::getSettlementType, settlementType)
.eq(ReceivablePayableDetail::getIsDeleted, 0));
for (ReceivablePayableDetail detail : details) {
String settlementStatus = Func.toStr(detail.getSettlementStatus(), "pending");
if (Func.isNotEmpty(detail.getFormalSettlementNo())
|| Func.isNotEmpty(detail.getPreSettlementNo())
|| List.of("pre_settled", "formal_settled", "closed").contains(settlementStatus)) {
throw new ServiceException("运单" + Func.toStr(waybill.getWaybillNo(), String.valueOf(waybill.getId()))
+ "已进入结算,无法重复导入");
}
}
}
private ReceivablePayableCargoFee buildSettlementImportCargoFee(Waybill waybill, BigDecimal transportFee,
BigDecimal unitPrice, Map<String, BigDecimal> feeItems,
BigDecimal afterAmount, String remark) {
ReceivablePayableCargoFee cargoFee = new ReceivablePayableCargoFee();
cargoFee.setWaybillId(waybill.getId());
cargoFee.setLineNo("0001");
cargoFee.setDataSource(FEE_SOURCE_MANUAL);
cargoFee.setCargoName(waybill.getCargoName());
cargoFee.setCargoType(waybill.getCargoType());
cargoFee.setSpecification(waybill.getSpecification());
cargoFee.setModel(waybill.getModel());
cargoFee.setBillingFactor("按重量");
cargoFee.setBillingType("固定单价");
cargoFee.setTransportQuantity(money(waybill.getQuantity()));
cargoFee.setQuantityUnit(waybill.getQuantityUnit());
cargoFee.setPriceUnit(waybill.getPriceUnit());
cargoFee.setUnitPrice(unitPrice);
cargoFee.setMileage(normalizeGeneratedMileage(waybill.getMileage()));
cargoFee.setFreightAmount(transportFee);
cargoFee.setFeeItemsJson(JsonUtil.toJson(feeItems));
cargoFee.setOriginalAmount(afterAmount);
cargoFee.setAdjustAmount(BigDecimal.ZERO.setScale(2));
cargoFee.setAfterAmount(afterAmount);
cargoFee.setRemark(remark);
return cargoFee;
}
/** 刷新已有结算导入明细:费用项目与金额始终覆盖为本次导入数据。 */
private void refreshSettlementImportDetail(ReceivablePayableDetail detail, Waybill waybill, ContractManage contract,
String settlementType, ReceivablePayableCargoFee cargoFee,
BigDecimal unitPrice, String remark, Long currentUserId, Date generateTime) {
ReceivablePayableDetail rebuilt = buildDetail(waybill, contract, settlementType, List.of(cargoFee), unitPrice);
detail.setFreightAmount(rebuilt.getFreightAmount());
detail.setOtherFeeAmount(rebuilt.getOtherFeeAmount());
detail.setTotalAmount(rebuilt.getTotalAmount());
detail.setUnitPrice(rebuilt.getUnitPrice());
detail.setCurrency(rebuilt.getCurrency());
detail.setFeeItemsJson(rebuilt.getFeeItemsJson());
detail.setContractId(rebuilt.getContractId());
detail.setContractNo(rebuilt.getContractNo());
detail.setContractName(rebuilt.getContractName());
detail.setCustomerName(rebuilt.getCustomerName());
detail.setSourceType(SOURCE_SETTLEMENT_IMPORT);
detail.setSettlementStatus("pending");
if (Func.isNotEmpty(remark)) {
detail.setRemark(remark);
}
updateById(detail);
cargoFeeMapper.delete(Wrappers.<ReceivablePayableCargoFee>lambdaQuery()
.eq(ReceivablePayableCargoFee::getDetailId, detail.getId()));
ReceivablePayableCargoFee copy = BeanUtil.copyProperties(cargoFee, ReceivablePayableCargoFee.class);
copy.setId(null);
copy.setDetailId(detail.getId());
fillGeneratedAuditFields(copy, currentUserId, generateTime);
cargoFeeMapper.insert(copy);
}
@SuppressWarnings("unchecked")
private Map<String, BigDecimal> toFeeItemMap(Object value) {
Map<String, BigDecimal> result = new LinkedHashMap<>();
if (!(value instanceof Map<?, ?> raw)) {
return result;
}
raw.forEach((key, amount) -> {
if (key == null) {
return;
}
result.put(String.valueOf(key), decimal(amount));
});
return result;
}
private Long toLong(Object value) {
if (value == null || "".equals(value)) {
return null;
}
if (value instanceof Number number) {
return number.longValue();
}
try {
return Long.valueOf(String.valueOf(value).trim());
} catch (NumberFormatException exception) {
return null;
}
}
private String stringValue(Object value) {
if (value == null) {
return null;
}
String text = String.valueOf(value).trim();
return text.isEmpty() ? null : text;
}
@Override
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW,
rollbackFor = Exception.class)
@@ -884,11 +1082,20 @@ public class ReceivablePayableDetailServiceImpl
List<Waybill> payableWaybills = waybills.stream()
.filter(waybill -> !isSelfTransport(waybill)).toList();
if (payableWaybills.isEmpty()) return;
List<Waybill> carrierMatchedWaybills = payableWaybills.stream()
.filter(waybill -> matchesWaybillCarrier(carrierContract, waybill)).toList();
List<Waybill> otherCarrierWaybills = payableWaybills.stream()
.filter(waybill -> !matchesWaybillCarrier(carrierContract, waybill)).toList();
if (!otherCarrierWaybills.isEmpty()) {
generateAutomaticWaybillDetails(otherCarrierWaybills, false,
this::resolveCarrierContract, currentUserId, generateTime);
}
if (carrierMatchedWaybills.isEmpty()) return;
generateAutomaticDetail(carrierContract, "payable",
() -> existsByLoading(loadingNo, "payable"), payableWaybills,
() -> existsByLoading(loadingNo, "payable"), carrierMatchedWaybills,
(fees, unitPrice) -> {
normalizeWaybillFeeLines(fees);
return buildLoadingDetail(loadingNo, carrierContract, payableWaybills, fees, unitPrice);
return buildLoadingDetail(loadingNo, carrierContract, carrierMatchedWaybills, fees, unitPrice);
},
false, currentUserId, generateTime);
}
@@ -938,22 +1145,54 @@ public class ReceivablePayableDetailServiceImpl
private ContractManage resolveWaybillCarrierContract(Waybill waybill) {
if (Func.isNotEmpty(waybill.getLoadingNo())) return null;
return resolveCarrierContract(waybill);
}
/**
* 应付只认运单选择的承运商。
* 运单已记录承运商合同时,乙方必须就是该承运商;否则按承运商名称回查同一项目下的承运商合同。
* 不允许落到项目里其他承运商合同的客商。
*/
private ContractManage resolveCarrierContract(Waybill waybill) {
if (waybill == null || isSelfTransport(waybill)) {
return null;
}
String carrierName = trimToNull(waybill.getCarrierName());
if (Func.isNotEmpty(waybill.getCarrierContractId())) {
ContractManage contract = contractManageService.getById(waybill.getCarrierContractId());
if (contract == null || !"承运商合同".equals(contract.getContractCategory())) {
ContractManage recorded = contractManageService.getById(waybill.getCarrierContractId());
if (recorded == null || !"承运商合同".equals(recorded.getContractCategory())) {
throw new ServiceException("运单记录的承运商合同不存在或合同类别不正确");
}
return contract;
if (carrierName == null || matchesWaybillCarrier(recorded, waybill)) {
return recorded;
}
}
return findCarrierContract(waybill);
ContractManage matched = findCarrierContract(waybill);
if (matched != null && matchesWaybillCarrier(matched, waybill)) {
return matched;
}
return null;
}
private boolean matchesWaybillCarrier(ContractManage contract, Waybill waybill) {
if (contract == null || waybill == null || !"承运商合同".equals(contract.getContractCategory())) {
return false;
}
if (waybill.getProjectId() != null && contract.getProjectId() != null
&& !Objects.equals(waybill.getProjectId(), contract.getProjectId())) {
return false;
}
String carrierName = trimToNull(waybill.getCarrierName());
String partyB = trimToNull(contract.getPartyB());
return carrierName != null && carrierName.equals(partyB);
}
private ContractManage findCarrierContract(Waybill waybill) {
if (waybill.getProjectId() == null || Func.isEmpty(waybill.getContractId())
|| Func.isEmpty(waybill.getCarrierName())) {
String carrierName = trimToNull(waybill.getCarrierName());
if (waybill.getProjectId() == null || Func.isEmpty(waybill.getContractId()) || carrierName == null) {
return null;
}
// 承运商合同甲方应与运单绑定的客户合同甲方一致,不能直接使用运单的客户名称拼接字段。
// 承运商合同甲方应与运单绑定的客户合同甲方一致,乙方必须是运单选择的承运商。
ContractManage customerContract = contractManageService.getById(waybill.getContractId());
if (customerContract == null || Func.isEmpty(customerContract.getPartyA())) {
return null;
@@ -962,13 +1201,38 @@ public class ReceivablePayableDetailServiceImpl
.eq(ContractManage::getIsDeleted, 0)
.eq(ContractManage::getProjectId, waybill.getProjectId())
.eq(ContractManage::getPartyA, customerContract.getPartyA())
.eq(ContractManage::getPartyB, waybill.getCarrierName())
.eq(ContractManage::getPartyB, carrierName)
.eq(ContractManage::getContractCategory, "承运商合同")
.and(wrapper -> wrapper.isNull(ContractManage::getContractStage)
.or().ne(ContractManage::getContractStage, "terminated"))
.orderByDesc(ContractManage::getCreateTime), false);
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String text = value.trim();
return text.isEmpty() ? null : text;
}
/**
* 应付客商取运单选择的承运商,避免写成项目内其他承运商合同的乙方。
*/
private String resolveCounterpartyName(Waybill waybill, ContractManage contract, String settlementType) {
if ("payable".equals(settlementType)) {
String carrierName = trimToNull(waybill == null ? null : waybill.getCarrierName());
if (carrierName != null) {
return carrierName;
}
return contract == null ? null : contract.getPartyB();
}
if (contract != null && Func.isNotEmpty(contract.getPartyA())) {
return contract.getPartyA();
}
return waybill == null ? null : waybill.getCustomerName();
}
@Override
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW,
rollbackFor = Exception.class)
@@ -1021,12 +1285,24 @@ public class ReceivablePayableDetailServiceImpl
.collect(Collectors.toMap(ContractManage::getId, contract -> contract));
Map<Long, List<Waybill>> carrierWaybills = waybills.stream().collect(Collectors.groupingBy(
Waybill::getCarrierContractId, LinkedHashMap::new, Collectors.toList()));
List<Waybill> mismatchedCarrierWaybills = waybills.stream()
.filter(waybill -> !matchesWaybillCarrier(carrierContracts.get(waybill.getCarrierContractId()), waybill))
.toList();
if (!mismatchedCarrierWaybills.isEmpty()) {
generateAutomaticWaybillDetails(mismatchedCarrierWaybills, false,
this::resolveCarrierContract, currentUserId, generateTime);
}
for (Map.Entry<Long, List<Waybill>> entry : carrierWaybills.entrySet()) {
ContractManage carrierContract = carrierContracts.get(entry.getKey());
if (carrierContract == null || !"承运商合同".equals(carrierContract.getContractCategory())) {
throw new ServiceException("总单【" + masterOrder.getMasterNo() + "】记录的承运商合同不存在或合同类别不正确");
}
List<Waybill> currentCarrierWaybills = entry.getValue();
List<Waybill> currentCarrierWaybills = entry.getValue().stream()
.filter(waybill -> matchesWaybillCarrier(carrierContract, waybill))
.toList();
if (currentCarrierWaybills.isEmpty()) {
continue;
}
generateAutomaticDetail(carrierContract, "payable",
() -> existsByMasterOrderContract(masterOrder.getMasterNo(), "payable", carrierContract.getId()),
currentCarrierWaybills,
@@ -1455,9 +1731,7 @@ public class ReceivablePayableDetailServiceImpl
detail.setDeptId(contract == null ? waybill.getDeptId() : contract.getOrganizationId());
detail.setDeptName(contract == null ? waybill.getDeptName() : contract.getOrganizationName());
detail.setFeeDate(waybill.getEndDate() == null ? LocalDate.now() : waybill.getEndDate());
detail.setCustomerName(contract == null
? ("payable".equals(settlementType) ? waybill.getCarrierName() : waybill.getCustomerName())
: ("payable".equals(settlementType) ? contract.getPartyB() : contract.getPartyA()));
detail.setCustomerName(resolveCounterpartyName(waybill, contract, settlementType));
detail.setContractId(contract == null ? waybill.getContractId() : contract.getId());
detail.setContractNo(contract == null ? null : contract.getContractNo());
detail.setContractName(contract == null ? waybill.getContractName() : contract.getContractName());
@@ -1472,8 +1746,8 @@ public class ReceivablePayableDetailServiceImpl
detail.setArrivalContact(waybill.getArrivalContact());
detail.setArrivalPhone(waybill.getArrivalPhone());
detail.setTransportType(waybill.getTransportType());
detail.setCargoName(waybill.getCargoName());
detail.setCargoType(waybill.getCargoType());
detail.setCargoName(joinDistinctGoods(waybill, "cargoName", waybill.getCargoName()));
detail.setCargoType(joinDistinctGoods(waybill, "cargoType", waybill.getCargoType()));
detail.setTransportQuantity(resolveWaybillTransportQuantity(waybill));
detail.setQuantityUnit(waybill.getQuantityUnit());
detail.setMileage(normalizeGeneratedMileage(waybill.getMileage()));
@@ -1488,6 +1762,21 @@ public class ReceivablePayableDetailServiceImpl
return detail;
}
/** 一单多货时主表只保留首行货物,应付明细改记全部货物名称、货物类型。 */
private String joinDistinctGoods(Waybill waybill, String field, String fallback) {
LinkedHashSet<String> values = new LinkedHashSet<>();
for (Map<String, Object> goods : parseList(waybill.getGoodsJson())) {
String value = stringValue(goods, field, "");
if (Func.isNotEmpty(value)) {
values.add(value.trim());
}
}
if (values.isEmpty()) {
return fallback;
}
return String.join(",", values);
}
/**
* 多货物运单优先按 goodsJson 数量合计作为运输总量,避免主表 quantity 只落了首行。
*/
@@ -1677,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<>();
@@ -1733,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,
@@ -2254,10 +2561,16 @@ public class ReceivablePayableDetailServiceImpl
private AdjustedFeeCalculation calculateAdjustedFee(ReceivablePayableDetail detail,
ReceivablePayableCargoFee fee, BigDecimal transportQuantity,
BigDecimal mileage, BigDecimal freightAmount,
Map<String, BigDecimal> feeItems) {
Map<String, BigDecimal> feeItems, BigDecimal unitPrice,
String changedField) {
if (isManualFee(fee)) {
throw new ServiceException("手工费用不支持按合同计费规则试算");
}
boolean transportChanged = Func.isEmpty(changedField)
|| List.of("transportQuantity", "mileage", "unitPrice").contains(changedField);
if (!transportChanged) {
return new AdjustedFeeCalculation(freightAmount, new LinkedHashMap<>(feeItems), null);
}
Waybill adjustedWaybill = adjustedWaybill(detail, fee, transportQuantity, mileage);
List<Map<String, Object>> rules = parseList(fee.getBillingRulesJson());
if (rules.isEmpty()) {
@@ -2273,26 +2586,94 @@ public class ReceivablePayableDetailServiceImpl
if (rules.isEmpty()) {
throw new ServiceException("未找到费用明细对应的合同计费规则,请先更新费用");
}
Map<String, BigDecimal> calculatedAmounts = new LinkedHashMap<>();
BigDecimal calculatedFreight = BigDecimal.ZERO;
boolean freightRuleMatched = false;
Map<String, BigDecimal> calculatedFeeItems = new LinkedHashMap<>(feeItems);
BigDecimal calculatedFreight = null;
BigDecimal resolvedUnitPrice = null;
boolean calculated = false;
for (Map<String, Object> rule : rules) {
BigDecimal amount = calculateRule(rule, adjustedWaybill);
if (amount == null) continue;
if (!shouldRecalculateRule(rule, changedField)) {
continue;
}
String type = stringValue(rule, "billingType", "");
boolean freightRule = isFreightRule(rule);
Map<String, Object> effectiveRule = rule;
if (freightRule && unitPriceAdjustable(type) && unitPrice != null) {
effectiveRule = new LinkedHashMap<>(rule);
effectiveRule.put("unitPrice", unitPrice);
resolvedUnitPrice = unitPrice;
}
BigDecimal amount = calculateRule(effectiveRule, adjustedWaybill);
if (amount == null) {
continue;
}
calculated = true;
String feeItem = stringValue(rule, "feeItem", "费用");
calculatedAmounts.merge(feeItem, amount, BigDecimal::add);
if (isFreightRule(rule)) {
calculatedFreight = calculatedFreight.add(amount);
freightRuleMatched = true;
calculatedFeeItems.put(feeItem, amount);
if (freightRule) {
calculatedFreight = (calculatedFreight == null ? BigDecimal.ZERO : calculatedFreight).add(amount);
if (intervalBilling(type)) {
resolvedUnitPrice = matchedIntervalUnitPrice(rule, adjustedWaybill);
}
}
}
if (calculatedAmounts.isEmpty()) {
if (!calculated && Func.isEmpty(changedField)) {
throw new ServiceException("费用明细对应的合同计费规则无法试算,请先更新费用");
}
Map<String, BigDecimal> calculatedFeeItems = new LinkedHashMap<>(feeItems);
calculatedAmounts.forEach(calculatedFeeItems::put);
return new AdjustedFeeCalculation(freightRuleMatched ? calculatedFreight : freightAmount,
calculatedFeeItems);
return new AdjustedFeeCalculation(calculatedFreight == null ? freightAmount : calculatedFreight,
calculatedFeeItems, resolvedUnitPrice);
}
/**
* 运费在运输总量、里程、可调整单价变化后按方案重算。
* 其他费用仅在计费要素使用了本次变化的运输总量或里程时重算,便于保留手工修改的费用总额。
*/
private boolean shouldRecalculateRule(Map<String, Object> rule, String changedField) {
String element = stringValue(rule, "billingElement", "");
String type = stringValue(rule, "billingType", "");
if (isFreightRule(rule)) {
return !"unitPrice".equals(changedField) || unitPriceAdjustable(type);
}
if ("unitPrice".equals(changedField) || Func.isEmpty(changedField)) {
return Func.isEmpty(changedField) && (usesTransportQuantity(element) || usesMileage(element));
}
if ("transportQuantity".equals(changedField)) {
return usesTransportQuantity(element);
}
if ("mileage".equals(changedField)) {
return usesMileage(element);
}
return false;
}
private boolean usesTransportQuantity(String element) {
return List.of("按重量", "按体积", "按数量", "按吨·公里").contains(element);
}
private boolean usesMileage(String element) {
return List.of("按里程", "按吨·公里").contains(element);
}
private boolean unitPriceAdjustable(String billingType) {
return !tierUnitPrice(billingType) && !intervalBilling(billingType);
}
private boolean tierUnitPrice(String billingType) {
return "阶梯单价".equals(billingType);
}
private boolean intervalBilling(String billingType) {
String type = billingType == null ? "" : billingType;
return "区间单价".equals(type) || (type.contains("区间") && type.contains("一口价"));
}
private BigDecimal matchedIntervalUnitPrice(Map<String, Object> rule, Waybill waybill) {
BigDecimal fallback = decimal(rule.get("unitPrice"));
return range(ranges(rule), measure(rule, waybill))
.map(matched -> {
BigDecimal matchedPrice = decimal(matched.get("unitPrice"));
return matchedPrice.signum() == 0 ? fallback : matchedPrice;
})
.orElse(fallback);
}
private Waybill adjustedWaybill(ReceivablePayableDetail detail, ReceivablePayableCargoFee fee,
@@ -2365,7 +2746,8 @@ public class ReceivablePayableDetailServiceImpl
return name != null && (name.contains("运费") || name.contains("运输费"));
}
private record AdjustedFeeCalculation(BigDecimal freightAmount, Map<String, BigDecimal> feeItems) {
private record AdjustedFeeCalculation(BigDecimal freightAmount, Map<String, BigDecimal> feeItems,
BigDecimal unitPrice) {
}
private void appendChange(List<String> changes, String field, BigDecimal before, BigDecimal after) {
@@ -2568,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()))
@@ -2593,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()));
@@ -2629,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()));
@@ -2704,21 +3086,29 @@ public class ReceivablePayableDetailServiceImpl
if (detailIds.isEmpty()) return;
List<ReceivablePayableCargoFee> fees = cargoFeeMapper.selectList(
Wrappers.<ReceivablePayableCargoFee>lambdaQuery()
.select(ReceivablePayableCargoFee::getDetailId, ReceivablePayableCargoFee::getCargoName)
.select(ReceivablePayableCargoFee::getDetailId, ReceivablePayableCargoFee::getCargoName,
ReceivablePayableCargoFee::getBillingType)
.in(ReceivablePayableCargoFee::getDetailId, detailIds)
.eq(ReceivablePayableCargoFee::getIsDeleted, 0)
.orderByAsc(ReceivablePayableCargoFee::getDetailId)
.orderByAsc(ReceivablePayableCargoFee::getLineNo));
Map<Long, LinkedHashSet<String>> cargoNames = new LinkedHashMap<>();
Map<Long, LinkedHashSet<String>> billingTypes = new LinkedHashMap<>();
for (ReceivablePayableCargoFee fee : fees) {
if (Func.isEmpty(fee.getCargoName())) continue;
cargoNames.computeIfAbsent(fee.getDetailId(), key -> new LinkedHashSet<>()).add(fee.getCargoName());
if (Func.isNotEmpty(fee.getCargoName())) {
cargoNames.computeIfAbsent(fee.getDetailId(), key -> new LinkedHashSet<>()).add(fee.getCargoName());
}
if (Func.isNotEmpty(fee.getBillingType()) && !"-".equals(fee.getBillingType())) {
billingTypes.computeIfAbsent(fee.getDetailId(), key -> new LinkedHashSet<>()).add(fee.getBillingType());
}
}
details.forEach(detail -> {
Set<String> names = cargoNames.get(detail.getId());
if (Func.isNotEmpty(names)) {
detail.setCargoName(String.join(",", names));
}
Set<String> types = billingTypes.get(detail.getId());
detail.setBillingType(Func.isEmpty(types) ? "" : String.join("、", types));
});
}
@@ -2924,6 +3314,7 @@ public class ReceivablePayableDetailServiceImpl
map.put("contractId", detail.getContractId());
map.put("contractNo", detail.getContractNo());
map.put("contractName", detail.getContractName());
map.put("sourceType", detail.getSourceType());
map.put("preSettlementNo", detail.getPreSettlementNo());
map.put("formalSettlementNo", detail.getFormalSettlementNo());
map.put("waybillNo", detail.getWaybillNo());
@@ -4,12 +4,18 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.core.tool.utils.SpringUtil;
import org.springblade.system.cache.UserCache;
import org.springblade.transport.constant.KingdeeBfiConstant;
import org.springblade.transport.constant.TransportBusiConstant;
import org.springblade.transport.event.KingdeeBillApprovedEvent;
import org.springblade.transport.support.KingdeeApBillSyncSupport;
import org.springblade.transport.support.KingdeeArBillSyncSupport;
import org.springblade.transport.mapper.FormalSettlementDetailFeeMapper;
import org.springblade.transport.mapper.FormalSettlementDetailMapper;
import org.springblade.transport.mapper.FormalSettlementChangeRecordMapper;
@@ -33,7 +39,10 @@ import org.springblade.transport.pojo.entity.PaymentApplicationSettlement;
import org.springblade.transport.pojo.entity.SettlementAdjustment;
import org.springblade.transport.pojo.entity.SettlementAdjustmentDetail;
import org.springblade.transport.pojo.vo.SettlementAdjustmentVO;
import org.springblade.transport.service.IFormalSettlementService;
import org.springblade.transport.service.ISettlementAdjustmentService;
import org.springblade.transport.service.ISettlementProjectAdvanceWriteOffService;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -51,6 +60,7 @@ import java.util.Set;
@Service
@RequiredArgsConstructor
@Slf4j
public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementAdjustmentMapper, SettlementAdjustment>
implements ISettlementAdjustmentService {
private static final String PAID = "paid";
@@ -63,6 +73,11 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
private final FormalSettlementPaymentMapper formalPaymentMapper;
private final PaymentApplicationMapper paymentApplicationMapper;
private final PaymentApplicationSettlementMapper paymentApplicationSettlementMapper;
private final IFormalSettlementService formalSettlementService;
private final ISettlementProjectAdvanceWriteOffService projectAdvanceWriteOffService;
private final KingdeeApBillSyncSupport kingdeeApBillSyncSupport;
private final KingdeeArBillSyncSupport kingdeeArBillSyncSupport;
private final ApplicationEventPublisher eventPublisher;
@Override
public IPage<SettlementAdjustmentVO> selectPage(IPage<SettlementAdjustment> page, SettlementAdjustmentVO query) {
@@ -204,24 +219,142 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
}
@Override public void returnBill(SettlementAdjustmentStatusRequest request) { changeStatus(request.getId(), TransportBusiConstant.REVIEWING, TransportBusiConstant.RETURNED, "已驳回", limit(request.getReason(), 200)); }
/**
* 重新推送(高危):原单已推送金蝶且旧单已在金蝶冲销后,克隆新正式结算单并应用调整,
* 以新单号组成新防重键生成全新应付/应收单,调整单改挂新单、原单标记已调整并解锁旧锁。
* <p>
* 非事务编排:克隆+应用(独立事务)→ 推送(外部远程)→ 收尾回填,推送失败不回滚克隆,
* 重试时按来源原单号幂等复用已克隆新单,从推送步骤续上。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public String repush(Long adjustmentId) {
SettlementAdjustment adjustment = existing(adjustmentId);
if (!TransportBusiConstant.APPROVED.equals(adjustment.getApprovalStatus())) throw new ServiceException("仅审批通过的结算调整单允许重新推送");
FormalSettlement formal = formalMapper.selectById(adjustment.getFormalSettlementId());
if (formal == null || !"synced".equals(formal.getKingdeeSyncStatus())) throw new ServiceException("关联正式结算单尚未推送金蝶,无需重新推送");
String kingdeeNo = "K3AP" + DateTimeFormatter.ofPattern("yyyyMMddHHmmss").format(LocalDateTime.now());
formal.setKingdeeBillNo(kingdeeNo); formal.setSyncedTime(LocalDateTime.now()); formalMapper.updateById(formal);
return kingdeeNo;
if (!TransportBusiConstant.APPROVED.equals(adjustment.getApprovalStatus())) {
throw new ServiceException("仅审批通过的结算调整单允许重新推送");
}
FormalSettlement original = formalMapper.selectById(adjustment.getFormalSettlementId());
if (original == null) {
throw new ServiceException("关联正式结算单不存在");
}
if (!KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(original.getKingdeeSyncStatus())) {
throw new ServiceException("结算单[" + Func.toStr(original.getFormalSettlementNo())
+ "]尚未推送金蝶,请先执行「推送金蝶」");
}
// 冲销硬闸门:仅当金蝶侧原应付/应收单已被冲销(部分冲销放行)才允许以新单号重推,避免重复生成财务单据
if (!this.kingdeeWrittenOff(original)) {
throw new ServiceException("结算单[" + Func.toStr(original.getFormalSettlementNo())
+ "]的原应付/应收单尚未在金蝶冲销,禁止重推");
}
// 幂等:同一原单已克隆过新单则复用(重试从推送步骤续上),避免重复建单
FormalSettlement clone = existingClone(original.getFormalSettlementNo());
if (clone == null) {
Long cloneId = self().cloneAndApplyForRepush(original.getId(), adjustment.getId());
clone = formalMapper.selectById(cloneId);
}
// 推送新单:新单号天然组成新防重键,内部完成 A102-2/A302-2 推送 + 落锁 + 附件上传
// shortcut: 成功后若再次点击重推,调整单已改挂新单且新单未冲销,会被冲销闸门拦截(无副作用);如需彻底隐藏由前端按「关联单是否克隆单」判断
String billNo = formalSettlementService.syncKingdee(clone.getId());
// 成功收尾:调整单改挂新单 + 回填金蝶单号/同步状态;原单标记已调整 + 旧锁解锁(业务数据不动)
SettlementAdjustment patch = new SettlementAdjustment();
patch.setId(adjustment.getId());
patch.setFormalSettlementId(clone.getId());
patch.setFormalSettlementNo(clone.getFormalSettlementNo());
patch.setKingdeeSyncStatus(KingdeeBfiConstant.KINGDEE_STATUS_SYNCED);
patch.setKingdeeBillNo(billNo);
updateById(patch);
formalSettlementService.markSettlementAdjusted(original.getId(), adjustment.getAdjustmentNo(), clone.getFormalSettlementNo());
return billNo;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void approve(SettlementAdjustmentStatusRequest request) {
public Long cloneAndApplyForRepush(Long originalSettlementId, Long adjustmentId) {
IFormalSettlementService.CloneResult cloneResult = formalSettlementService.cloneFormalSettlement(originalSettlementId);
FormalSettlement clone = formalMapper.selectById(cloneResult.settlementId());
applyAdjustmentToSettlement(clone, existing(adjustmentId), cloneResult.detailIdMap(), cloneResult.feeIdMap());
return cloneResult.settlementId();
}
@Override
public String syncKingdee(Long adjustmentId) {
SettlementAdjustment adjustment = existing(adjustmentId);
if (!TransportBusiConstant.APPROVED.equals(adjustment.getApprovalStatus())) {
throw new ServiceException("仅审批通过的结算调整单允许推送金蝶");
}
// 未推送/生成失败场景:推送关联正式结算单,成功后由正式结算单回填调整单金蝶单号与同步状态
return formalSettlementService.syncKingdee(adjustment.getFormalSettlementId());
}
@Override
public Map<String, Object> kingdeeWriteOffPrecheck(Long formalSettlementId) {
Map<String, Object> result = new HashMap<>();
FormalSettlement settlement = formalMapper.selectById(formalSettlementId);
if (settlement == null) {
throw new ServiceException("关联正式结算单不存在");
}
if (!KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(settlement.getKingdeeSyncStatus())) {
result.put("syncStatus", KingdeeBfiConstant.KINGDEE_STATUS_UNSYNCED);
result.put("writtenOff", null);
result.put("message", "该结算单尚未推送金蝶,审批通过后将自动推送原单");
return result;
}
result.put("syncStatus", KingdeeBfiConstant.KINGDEE_STATUS_SYNCED);
try {
boolean writtenOff = this.kingdeeWrittenOff(settlement);
result.put("writtenOff", writtenOff);
result.put("message", writtenOff
? "该结算单已推送金蝶且旧单已冲销,审批后可直接重新推送新单"
: "该结算单已推送金蝶且旧单尚未冲销,审批通过后需先由财务在金蝶完成冲销,才能重新推送");
} catch (Exception exception) {
// 预检为软提示:金蝶查询异常降级为「未校验」,不阻断建单,由审批分流与重推闸门兜底
log.warn("结算调整单建单预检:金蝶冲销状态查询失败,降级未校验,正式结算单[{}]",
settlement.getFormalSettlementNo(), exception);
result.put("writtenOff", null);
result.put("message", "金蝶冲销状态查询失败,未校验;审批后请以实际冲销情况为准");
}
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public String approve(SettlementAdjustmentStatusRequest request) {
SettlementAdjustment adjustment = existing(request.getId());
if (!TransportBusiConstant.REVIEWING.equals(adjustment.getApprovalStatus())) throw new ServiceException("仅审批中的结算调整单允许审核");
FormalSettlement formal = adjustableFormalSettlement(adjustment.getFormalSettlementId());
boolean synced = KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(formal.getKingdeeSyncStatus());
if (synced) {
// 已推送场景:原单业务数据零改动,仅追加 1 条重推提示变更记录,待财务在金蝶手工冲销旧单后重推
saveFormalChange(formal.getId(), "合计费用项", null, "调整",
"结算调整单" + adjustment.getAdjustmentNo() + "审批通过,调整金额【" + money(adjustment.getAdjustmentAmount()) + "】,待重推金蝶",
adjustment.getRemark());
} else {
// 未推送场景:回写原单费用/金额并重算结算金额,事务提交后由事件监听自动推送金蝶
applyAdjustmentToSettlement(formal, adjustment, Map.of(), Map.of());
saveFormalChange(formal.getId(), "合计费用项", null, "调整",
"调整单" + adjustment.getAdjustmentNo() + "调整金额【" + money(adjustment.getAdjustmentAmount()) + "】",
adjustment.getRemark());
}
adjustment.setKingdeeSyncStatus(formal.getKingdeeSyncStatus()); adjustment.setApprovalStatus(TransportBusiConstant.APPROVED); adjustment.setCurrentNode("审批通过"); adjustment.setCurrentProcessor(AuthUtil.getUserName()); adjustment.setApprovedTime(LocalDateTime.now()); updateById(adjustment);
if (!synced) {
// 审批事务提交后(AFTER_COMMIT)自动推原单,成功回填调整单 synced + 金蝶单号、失败置 failed
eventPublisher.publishEvent(new KingdeeBillApprovedEvent(
KingdeeBillApprovedEvent.BIZ_FORMAL_SETTLEMENT, formal.getId()));
return null;
}
return "原结算单已推送金蝶,请财务人员在金蝶手工完成旧应付/应收单冲销处理后再重新推送";
}
/**
* 应用调整到目标正式结算单(未推送场景作用于原单,重推场景作用于克隆新单):
* 回写明细费项金额、汇总费用项、重算明细与结算金额;明细/费项 id 经映射转换(原单调整作用于原单时传空映射)。
*
* @param formal 目标正式结算单(改动后需落库)
* @param adjustment 结算调整单
* @param detailIdMap 原结算明细id -&gt; 目标结算明细id(原单场景传空 Map)
* @param feeIdMap 原明细费项id -&gt; 目标明细费项id(原单场景传空 Map)
*/
private void applyAdjustmentToSettlement(FormalSettlement formal, SettlementAdjustment adjustment,
Map<Long, Long> detailIdMap, Map<Long, Long> feeIdMap) {
for (SettlementAdjustmentDetail item : detailMapper.selectList(Wrappers.<SettlementAdjustmentDetail>lambdaQuery().eq(SettlementAdjustmentDetail::getAdjustmentId, adjustment.getId()))) {
if (money(item.getAdjustmentAmountTax()).signum() == 0) continue;
if (isManualFee(item)) {
@@ -229,12 +362,14 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
"新增【" + item.getFeeItem() + "】调整费用【" + money(item.getAdjustmentAmountTax()) + "】",
Func.isEmpty(item.getRemark()) ? adjustment.getRemark() : item.getRemark());
} else {
FormalSettlementDetailFee fee = validateFee(formal.getId(), item.getFormalSettlementDetailId(), item.getFormalSettlementDetailFeeId());
Long detailId = detailIdMap.getOrDefault(item.getFormalSettlementDetailId(), item.getFormalSettlementDetailId());
Long feeId = feeIdMap.getOrDefault(item.getFormalSettlementDetailFeeId(), item.getFormalSettlementDetailFeeId());
FormalSettlementDetailFee fee = validateFee(formal.getId(), detailId, feeId);
BigDecimal beforeAmount = money(fee.getSettlementAmountTax());
fee.setSettlementAmountTax(beforeAmount.add(money(item.getAdjustmentAmountTax())));
if (item.getAdjustmentAmountNoTax() != null) fee.setSettlementAmountNoTax(money(fee.getSettlementAmountNoTax()).add(item.getAdjustmentAmountNoTax()));
fee.setAdjustAmount(money(fee.getAdjustAmount()).add(item.getAdjustmentAmountTax())); formalFeeMapper.updateById(fee);
FormalSettlementDetail formalDetail = formalDetailMapper.selectById(item.getFormalSettlementDetailId());
FormalSettlementDetail formalDetail = formalDetailMapper.selectById(detailId);
saveFormalChange(formal.getId(), "结算明细项", formalDetail == null ? null : formalDetail.getLineNo(),
"调整", "【" + (Func.isEmpty(item.getFeeItem()) ? "结算费用" : item.getFeeItem())
+ "】从【" + beforeAmount + "】调整为【" + fee.getSettlementAmountTax() + "】",
@@ -249,15 +384,46 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
detail.setAdjustAmount(detail.getSettlementAmountTax().subtract(money(detail.getOriginalAmount()))); formalDetailMapper.updateById(detail);
}
BigDecimal amount = money(formal.getSettlementAmount()).add(money(adjustment.getAdjustmentAmount()));
// 调整后结算金额不得低于项目预付核销合计,否则已付将超过结算金额且无法拦截
BigDecimal writeOffTotal = this.projectAdvanceWriteOffService.sumBySettlement(
TransportBusiConstant.SETTLEMENT_BILL_FORMAL, formal.getId());
if (money(writeOffTotal).compareTo(money(amount)) > 0) {
throw new ServiceException("调整后结算金额不能低于项目预付核销合计" + money(writeOffTotal) + ",请先调减项目预付核销");
}
formal.setSettlementAmount(amount);
formal.setLocalSettlementAmount(amount.multiply(formal.getExchangeRate() == null ? BigDecimal.ONE : formal.getExchangeRate()));
formal.setRemainingPayableAmount(amount.subtract(money(formal.getPaidAmount()))
.max(BigDecimal.ZERO));
formal.setRemainingPayableAmount(amount.subtract(money(formal.getPaidAmount())).max(BigDecimal.ZERO));
formalMapper.updateById(formal);
saveFormalChange(formal.getId(), "合计费用项", null, "调整",
"调整单" + adjustment.getAdjustmentNo() + "调整金额【" + money(adjustment.getAdjustmentAmount()) + "】",
adjustment.getRemark());
adjustment.setKingdeeSyncStatus(formal.getKingdeeSyncStatus()); adjustment.setApprovalStatus(TransportBusiConstant.APPROVED); adjustment.setCurrentNode("审批通过"); adjustment.setCurrentProcessor(AuthUtil.getUserName()); adjustment.setApprovedTime(LocalDateTime.now()); updateById(adjustment);
// 重算收付汇总(含核销计入),保持付款状态与剩余可付准确
formalSettlementService.refreshPaymentSummary(formal.getId());
}
/** 涉事事务的自代理:跨方法调用确保 {@link #cloneAndApplyForRepush} 的事务注解生效 */
private ISettlementAdjustmentService self() {
return SpringUtil.getBean(ISettlementAdjustmentService.class);
}
/** 按来源原正式结算单号查已克隆新单(重推幂等:同一原单不重复建单) */
private FormalSettlement existingClone(String originFormalSettlementNo) {
List<FormalSettlement> clones = formalMapper.selectList(Wrappers.<FormalSettlement>lambdaQuery()
.eq(FormalSettlement::getOriginFormalSettlementNo, originFormalSettlementNo)
.orderByDesc(FormalSettlement::getCreateTime));
return clones.isEmpty() ? null : clones.get(0);
}
/** 按原单旧防重键(原结算单号_明细单据号)查金蝶是否已冲销(应付 A102-4 / 应收 A302-4,部分冲销放行) */
private boolean kingdeeWrittenOff(FormalSettlement settlement) {
List<FormalSettlementDetail> details = formalDetailMapper.selectList(Wrappers.<FormalSettlementDetail>lambdaQuery()
.eq(FormalSettlementDetail::getFormalSettlementId, settlement.getId())
.eq(FormalSettlementDetail::getIsDeleted, 0)
.orderByAsc(FormalSettlementDetail::getLineNo));
if (Func.isEmpty(details)) {
return false;
}
if ("receivable".equals(settlement.getSettlementType())) {
return kingdeeArBillSyncSupport.queryArBillWrittenOff(kingdeeArBillSyncSupport.arBillSrcCode(settlement, details));
}
return kingdeeApBillSyncSupport.queryApBillWrittenOff(kingdeeApBillSyncSupport.apBillSrcCode(settlement, details));
}
private void saveFormalChange(Long settlementId, String changeType, Integer lineNo,
@@ -392,7 +558,12 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
private SettlementAdjustmentVO toVO(SettlementAdjustment item) { SettlementAdjustmentVO vo = new SettlementAdjustmentVO(); org.springframework.beans.BeanUtils.copyProperties(item, vo); vo.setCreateUserName(UserCache.getUserRealName(item.getCreateUser())); vo.setApprovalStatusName(statusName(item.getApprovalStatus())); vo.setSettlementTypeName(typeName(item.getSettlementType())); return vo; }
private String statusName(String value) { return Map.of(TransportBusiConstant.DRAFT, "草稿", TransportBusiConstant.REVIEWING, "审批中", TransportBusiConstant.APPROVED, "审批通过", TransportBusiConstant.RETURNED, "已驳回").getOrDefault(value, value); }
private String typeName(String value) { return "receivable".equals(value) ? "应收" : "payable".equals(value) ? "应付" : ""; }
private synchronized String nextNo() { String prefix = "TZ" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); long count = count(Wrappers.<SettlementAdjustment>lambdaQuery().likeRight(SettlementAdjustment::getAdjustmentNo, prefix)); return prefix + String.format("%04d", count + 1); }
/** 生成当日结算调整单号;按最大流水号 +1,且计入逻辑删除数据 */
private synchronized String nextNo() {
String prefix = "TZ" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix);
return prefix + String.format("%04d", (maxSequence == null ? 0L : maxSequence) + 1);
}
private BigDecimal money(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; }
private String requiredText(String value, String field, int max) { if (Func.isEmpty(value)) throw new ServiceException(field + "不能为空"); return limit(value.trim(), max); }
private String limit(String value, int max) { if (value != null && value.length() > max) throw new ServiceException("内容不能超过" + max + "个字"); return value; }
@@ -0,0 +1,267 @@
/**
* 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>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.UserCache;
import org.springblade.transport.constant.KingdeeBfiConstant;
import org.springblade.transport.constant.TransportBusiConstant;
import org.springblade.transport.mapper.PaymentApplicationMapper;
import org.springblade.transport.mapper.SettlementProjectAdvanceWriteOffMapper;
import org.springblade.transport.pojo.dto.SettlementProjectAdvanceWriteOffRequest;
import org.springblade.transport.pojo.entity.PaymentApplication;
import org.springblade.transport.pojo.entity.SettlementProjectAdvanceWriteOff;
import org.springblade.transport.pojo.vo.ProjectAdvanceWriteOffOptionVO;
import org.springblade.transport.service.ISettlementProjectAdvanceWriteOffService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* 结算单项目预付核销服务实现
*
* @author Chill
*/
@Service
@RequiredArgsConstructor
public class SettlementProjectAdvanceWriteOffServiceImpl implements ISettlementProjectAdvanceWriteOffService {
private final SettlementProjectAdvanceWriteOffMapper writeOffMapper;
private final PaymentApplicationMapper paymentApplicationMapper;
@Override
public List<ProjectAdvanceWriteOffOptionVO> listOptions(Long contractId, String excludeSettlementType,
Long excludeSettlementId) {
if (contractId == null) {
throw new ServiceException("请先选择合同");
}
List<PaymentApplication> applications = paymentApplicationMapper.selectList(
Wrappers.<PaymentApplication>lambdaQuery()
.eq(PaymentApplication::getContractId, contractId)
.eq(PaymentApplication::getPaymentType, TransportBusiConstant.PAYMENT_TYPE_PROJECT_ADVANCE)
.eq(PaymentApplication::getApprovalStatus, TransportBusiConstant.APPROVED)
.eq(PaymentApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_PAID)
.eq(PaymentApplication::getIsDeleted, 0)
.orderByDesc(PaymentApplication::getCreateTime));
List<ProjectAdvanceWriteOffOptionVO> options = new ArrayList<>();
for (PaymentApplication application : applications) {
BigDecimal projectAdvanceAmount = money(application.getAppliedAmount());
BigDecimal usedAmount = money(writeOffMapper.sumWriteOffByPaymentApplication(
application.getId(), excludeSettlementType, excludeSettlementId));
BigDecimal availableAmount = projectAdvanceAmount.subtract(usedAmount).max(BigDecimal.ZERO);
if (availableAmount.compareTo(BigDecimal.ZERO) <= 0) {
continue;
}
ProjectAdvanceWriteOffOptionVO option = new ProjectAdvanceWriteOffOptionVO();
option.setPaymentApplicationId(application.getId());
option.setPaymentNo(application.getPaymentNo());
option.setProjectAdvanceAmount(projectAdvanceAmount);
option.setAvailableWriteOffAmount(availableAmount);
option.setKingdeeBillNo(application.getKingdeeBillNo());
option.setCreateUserName(UserCache.getUserRealName(application.getCreateUser()));
option.setCreateTime(application.getCreateTime());
options.add(option);
}
return options;
}
@Override
public List<SettlementProjectAdvanceWriteOff> listBySettlement(String settlementType, Long settlementId) {
if (Func.isEmpty(settlementType) || settlementId == null) {
return List.of();
}
List<SettlementProjectAdvanceWriteOff> rows = writeOffMapper.selectList(
Wrappers.<SettlementProjectAdvanceWriteOff>lambdaQuery()
.eq(SettlementProjectAdvanceWriteOff::getSettlementType, settlementType)
.eq(SettlementProjectAdvanceWriteOff::getSettlementId, settlementId)
.eq(SettlementProjectAdvanceWriteOff::getIsDeleted, 0)
.orderByAsc(SettlementProjectAdvanceWriteOff::getCreateTime)
.orderByAsc(SettlementProjectAdvanceWriteOff::getId));
for (SettlementProjectAdvanceWriteOff row : rows) {
PaymentApplication application = paymentApplicationMapper.selectById(row.getPaymentApplicationId());
BigDecimal projectAdvanceAmount = application == null
? money(row.getProjectAdvanceAmount()) : money(application.getAppliedAmount());
BigDecimal usedAmount = money(writeOffMapper.sumWriteOffByPaymentApplication(
row.getPaymentApplicationId(), settlementType, settlementId));
row.setProjectAdvanceAmount(projectAdvanceAmount);
row.setAvailableWriteOffAmount(projectAdvanceAmount.subtract(usedAmount).max(BigDecimal.ZERO));
if (application != null) {
row.setPaymentNo(application.getPaymentNo());
row.setKingdeeBillNo(application.getKingdeeBillNo());
row.setCreateUserName(UserCache.getUserRealName(application.getCreateUser()));
row.setPaymentCreateTime(application.getCreateTime());
} else {
row.setCreateUserName(UserCache.getUserRealName(row.getCreateUser()));
row.setPaymentCreateTime(row.getCreateTime());
}
}
return rows;
}
@Override
public BigDecimal sumBySettlement(String settlementType, Long settlementId) {
if (Func.isEmpty(settlementType) || settlementId == null) {
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
}
return money(writeOffMapper.sumWriteOffBySettlement(settlementType, settlementId));
}
@Override
public Map<Long, BigDecimal> sumBySettlementIds(String settlementType, Collection<Long> settlementIds) {
if (Func.isEmpty(settlementType) || Func.isEmpty(settlementIds)) {
return Map.of();
}
Map<Long, BigDecimal> result = new HashMap<>();
for (Map<String, Object> row : writeOffMapper.sumWriteOffBySettlementIds(settlementType, settlementIds)) {
if (row.get("settlementId") instanceof Number settlementId && row.get("totalAmount") instanceof BigDecimal totalAmount) {
result.put(settlementId.longValue(), money(totalAmount));
}
}
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void replaceWriteOffs(String settlementType, Long settlementId, Long contractId,
BigDecimal settlementAmount, List<SettlementProjectAdvanceWriteOffRequest> writeOffs) {
if (Func.isEmpty(settlementType) || settlementId == null) {
throw new ServiceException("结算单不能为空");
}
if (contractId == null) {
throw new ServiceException("合同不能为空");
}
List<SettlementProjectAdvanceWriteOffRequest> requestRows = writeOffs == null ? List.of() : writeOffs;
BigDecimal settlementLimit = money(settlementAmount);
BigDecimal totalWriteOff = BigDecimal.ZERO;
Set<Long> paymentIds = new HashSet<>();
List<SettlementProjectAdvanceWriteOff> entities = new ArrayList<>();
for (SettlementProjectAdvanceWriteOffRequest request : requestRows) {
if (request == null || request.getPaymentApplicationId() == null) {
throw new ServiceException("请选择项目预付单");
}
if (!paymentIds.add(request.getPaymentApplicationId())) {
throw new ServiceException("同一项目预付单不可重复核销");
}
BigDecimal writeOffAmount = money(request.getWriteOffAmount());
if (writeOffAmount.compareTo(BigDecimal.ZERO) <= 0) {
throw new ServiceException("本次核销金额必须大于0");
}
PaymentApplication application = paymentApplicationMapper.selectById(request.getPaymentApplicationId());
if (application == null || Objects.equals(application.getIsDeleted(), 1)) {
throw new ServiceException("项目预付单不存在");
}
if (!TransportBusiConstant.PAYMENT_TYPE_PROJECT_ADVANCE.equals(application.getPaymentType())) {
throw new ServiceException("仅可核销项目预付单");
}
if (!TransportBusiConstant.APPROVED.equals(application.getApprovalStatus())) {
throw new ServiceException("仅可选择审核通过的项目预付单");
}
if (!KingdeeBfiConstant.KINGDEE_STATUS_PAID.equals(application.getKingdeeStatus())) {
throw new ServiceException("预付单" + Func.toStr(application.getPaymentNo(), "") + "尚未付款,项目预付须金蝶付款完成后方可核销");
}
if (!Objects.equals(contractId, application.getContractId())) {
throw new ServiceException("项目预付单与结算单合同不一致");
}
BigDecimal projectAdvanceAmount = money(application.getAppliedAmount());
BigDecimal usedAmount = money(writeOffMapper.sumWriteOffByPaymentApplication(
application.getId(), settlementType, settlementId));
BigDecimal availableAmount = projectAdvanceAmount.subtract(usedAmount).max(BigDecimal.ZERO);
if (writeOffAmount.compareTo(availableAmount) > 0) {
throw new ServiceException("预付单" + Func.toStr(application.getPaymentNo(), "")
+ "本次核销金额不能超过可核销金额");
}
totalWriteOff = totalWriteOff.add(writeOffAmount);
SettlementProjectAdvanceWriteOff entity = new SettlementProjectAdvanceWriteOff();
entity.setSettlementType(settlementType);
entity.setSettlementId(settlementId);
entity.setPaymentApplicationId(application.getId());
entity.setPaymentNo(application.getPaymentNo());
entity.setProjectAdvanceAmount(projectAdvanceAmount);
entity.setWriteOffAmount(writeOffAmount);
entity.setKingdeeBillNo(application.getKingdeeBillNo());
entity.setStatus(1);
entity.setIsDeleted(0);
entities.add(entity);
}
if (totalWriteOff.compareTo(settlementLimit) > 0) {
throw new ServiceException("项目预付核销合计不能超过结算单金额");
}
writeOffMapper.delete(Wrappers.<SettlementProjectAdvanceWriteOff>lambdaQuery()
.eq(SettlementProjectAdvanceWriteOff::getSettlementType, settlementType)
.eq(SettlementProjectAdvanceWriteOff::getSettlementId, settlementId));
for (SettlementProjectAdvanceWriteOff entity : entities) {
writeOffMapper.insert(entity);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteBySettlement(String settlementType, Long settlementId) {
if (Func.isEmpty(settlementType) || settlementId == null) {
return;
}
writeOffMapper.delete(Wrappers.<SettlementProjectAdvanceWriteOff>lambdaQuery()
.eq(SettlementProjectAdvanceWriteOff::getSettlementType, settlementType)
.eq(SettlementProjectAdvanceWriteOff::getSettlementId, settlementId));
}
@Override
@Transactional(rollbackFor = Exception.class)
public int deleteBySettlementIds(String settlementType, Collection<Long> settlementIds) {
if (Func.isEmpty(settlementType) || Func.isEmpty(settlementIds)) {
return 0;
}
return writeOffMapper.delete(Wrappers.<SettlementProjectAdvanceWriteOff>lambdaQuery()
.eq(SettlementProjectAdvanceWriteOff::getSettlementType, settlementType)
.in(SettlementProjectAdvanceWriteOff::getSettlementId, settlementIds));
}
@Override
@Transactional(rollbackFor = Exception.class)
public List<Long> deleteByPaymentApplicationIds(Collection<Long> paymentApplicationIds) {
if (Func.isEmpty(paymentApplicationIds)) {
return List.of();
}
List<SettlementProjectAdvanceWriteOff> rows = writeOffMapper.selectList(
Wrappers.<SettlementProjectAdvanceWriteOff>lambdaQuery()
.eq(SettlementProjectAdvanceWriteOff::getIsDeleted, 0)
.in(SettlementProjectAdvanceWriteOff::getPaymentApplicationId, paymentApplicationIds));
if (rows.isEmpty()) {
return List.of();
}
writeOffMapper.delete(Wrappers.<SettlementProjectAdvanceWriteOff>lambdaQuery()
.in(SettlementProjectAdvanceWriteOff::getPaymentApplicationId, paymentApplicationIds));
return rows.stream()
.filter(row -> TransportBusiConstant.SETTLEMENT_BILL_FORMAL.equals(row.getSettlementType()))
.map(SettlementProjectAdvanceWriteOff::getSettlementId)
.filter(Objects::nonNull)
.distinct()
.toList();
}
private BigDecimal money(BigDecimal value) {
return value == null ? BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)
: value.setScale(2, RoundingMode.HALF_UP);
}
}
@@ -0,0 +1,622 @@
/**
* 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.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.tenant.TenantUtil;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.constant.TransportBusiConstant;
import org.springblade.transport.mapper.PreSettlementDetailMapper;
import org.springblade.transport.mapper.ReceivablePayableDetailMapper;
import org.springblade.transport.pojo.dto.FormalSettlementSaveRequest;
import org.springblade.transport.pojo.dto.PreSettlementSaveRequest;
import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.entity.PreSettlement;
import org.springblade.transport.pojo.entity.PreSettlementDetail;
import org.springblade.transport.pojo.entity.ReceivablePayableDetail;
import org.springblade.transport.service.IContractManageService;
import org.springblade.transport.service.IFormalSettlementService;
import org.springblade.transport.service.IPreSettlementService;
import org.springblade.transport.service.ISettlementRuleAutoGenerateService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.YearMonth;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
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>
* 账期在次日零点视为关闭:日结/现结取前一日,周结和固定天数从账单开始日期顺延,
* 月结按自然月、固定截单日或自定义多周期截取。没有可归集明细时不生成空单。
* 同一明细只进入一张结算单,预结算先于正式结算执行。正式结算同时归集费用日期完整落在账期内、且尚未转正式结算的已审批预结算单。
*
* @author Chill
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SettlementRuleAutoGenerateServiceImpl implements ISettlementRuleAutoGenerateService {
private static final int QUERY_BATCH_SIZE = 500;
private static final String LOCAL_CURRENCY = "RMB";
private final IContractManageService contractManageService;
private final IPreSettlementService preSettlementService;
private final IFormalSettlementService formalSettlementService;
private final ReceivablePayableDetailMapper receivablePayableDetailMapper;
private final PreSettlementDetailMapper preSettlementDetailMapper;
private final PlatformTransactionManager transactionManager;
@Override
public synchronized String generateDueSettlements() {
LocalDate today = LocalDate.now();
List<ContractManage> contracts = TenantUtil.ignore(this::listAutoContracts);
int preCount = 0;
int formalCount = 0;
int failedCount = 0;
for (ContractManage contract : contracts) {
if (Func.isBlank(contract.getTenantId())) {
log.warn("合同[{}]缺少租户,跳过自动生成结算单", contract.getContractNo());
continue;
}
try {
int[] created = TenantUtil.use(contract.getTenantId(), () -> generateContract(contract, today));
preCount += created[0];
formalCount += created[1];
} catch (Exception exception) {
failedCount++;
log.error("合同[{}]自动生成结算单失败", contract.getContractNo(), exception);
}
}
return "扫描合同" + contracts.size() + "份,生成预结算单" + preCount + "张,正式结算单" + formalCount
+ "张,失败" + failedCount + "份";
}
private List<ContractManage> listAutoContracts() {
return contractManageService.list(Wrappers.<ContractManage>lambdaQuery()
.in(ContractManage::getApprovalStatus, "approved", "change_approved")
.in(ContractManage::getContractCategory, "客户合同", "承运商合同")
.and(query -> query.isNull(ContractManage::getContractStage)
.or().ne(ContractManage::getContractStage, "terminated"))
.and(query -> query.like(ContractManage::getPreSettlementConfigJson, "autoGenerate")
.or().like(ContractManage::getFormalSettlementConfigJson, "autoGenerate")
.or().like(ContractManage::getSettlementRuleJson, "autoGenerate")));
}
private int[] generateContract(ContractManage contract, LocalDate today) {
String settlementType = contractSettlementType(contract);
if (settlementType == null || contract.getProjectId() == null || contract.getOrganizationId() == null) {
log.warn("合同[{}]缺少结算类型、项目或所属组织,跳过自动生成", contract.getContractNo());
return new int[]{0, 0};
}
int preCount = generateBills(contract, settlementType, resolveRule(contract, true), today, true);
int formalCount = generateBills(contract, settlementType, resolveRule(contract, false), today, false);
return new int[]{preCount, formalCount};
}
private int generateBills(ContractManage contract, String settlementType, Map<String, Object> rule,
LocalDate today, boolean preBill) {
if (!enabled(rule.get("autoGenerate"))) {
return 0;
}
String cycleName = Func.toStr(rule.get("settlementType")).trim();
if (!knownCycle(cycleName)) {
log.warn("合同[{}]{}结算周期[{}]无法识别,跳过自动生成", contract.getContractNo(),
preBill ? "预" : "正式", cycleName);
return 0;
}
List<DateRange> ranges = closedPeriods(rule, today);
if (ranges.isEmpty()) {
return 0;
}
if (preBill) {
return createPreBills(contract, ranges);
}
return createFormalBills(contract, settlementType, ranges);
}
private int createPreBills(ContractManage contract, List<DateRange> ranges) {
List<ReceivablePayableDetail> details = loadPendingDetails(contract, ranges);
Map<DateRange, List<ReceivablePayableDetail>> grouped = groupDetails(ranges, details);
int created = 0;
for (Map.Entry<DateRange, List<ReceivablePayableDetail>> entry : grouped.entrySet()) {
Map<String, List<Long>> byCurrency = new LinkedHashMap<>();
for (ReceivablePayableDetail detail : entry.getValue()) {
String currency = Func.isEmpty(detail.getCurrency()) ? LOCAL_CURRENCY : detail.getCurrency();
byCurrency.computeIfAbsent(currency, key -> new ArrayList<>()).add(detail.getId());
}
for (List<Long> detailIds : byCurrency.values()) {
try {
inTransaction(() -> {
PreSettlementSaveRequest request = new PreSettlementSaveRequest();
request.setContractId(contract.getId());
request.setSourceDetailIds(detailIds);
request.setRemark(periodRemark(entry.getKey()));
preSettlementService.saveDraft(request);
});
created++;
log.info("合同[{}]自动生成预结算单,账期{}至{},明细{}条", contract.getContractNo(),
entry.getKey().start(), entry.getKey().end(), detailIds.size());
} catch (Exception exception) {
log.error("合同[{}]自动生成预结算单失败,账期{}至{}", contract.getContractNo(),
entry.getKey().start(), entry.getKey().end(), exception);
}
}
}
return created;
}
private int createFormalBills(ContractManage contract, String settlementType, List<DateRange> ranges) {
Map<DateRange, List<ReceivablePayableDetail>> detailGroups = groupDetails(ranges, loadPendingDetails(contract, ranges));
Map<DateRange, List<PreSettlement>> preGroups = groupApprovedPres(contract, settlementType, ranges);
int created = 0;
for (DateRange range : ranges) {
Map<String, List<Long>> detailIds = new LinkedHashMap<>();
for (ReceivablePayableDetail detail : detailGroups.getOrDefault(range, List.of())) {
String currency = detail.getCurrency() == null ? "" : detail.getCurrency();
detailIds.computeIfAbsent(currency, key -> new ArrayList<>()).add(detail.getId());
}
Map<String, List<Long>> preIds = new LinkedHashMap<>();
for (PreSettlement preSettlement : preGroups.getOrDefault(range, List.of())) {
String currency = preSettlement.getCurrency() == null ? "" : preSettlement.getCurrency();
preIds.computeIfAbsent(currency, key -> new ArrayList<>()).add(preSettlement.getId());
}
Set<String> currencies = new LinkedHashSet<>();
currencies.addAll(detailIds.keySet());
currencies.addAll(preIds.keySet());
for (String currency : currencies) {
List<Long> sourcePreSettlementIds = preIds.getOrDefault(currency, List.of());
List<Long> sourceDetailIds = detailIds.getOrDefault(currency, List.of());
if (sourcePreSettlementIds.isEmpty() && sourceDetailIds.isEmpty()) {
continue;
}
try {
inTransaction(() -> {
FormalSettlementSaveRequest request = new FormalSettlementSaveRequest();
request.setContractId(contract.getId());
request.setSettlementType(settlementType);
request.setSourcePreSettlementIds(sourcePreSettlementIds.isEmpty() ? null : sourcePreSettlementIds);
request.setSourceDetailIds(sourceDetailIds.isEmpty() ? null : sourceDetailIds);
request.setRemark(periodRemark(range));
formalSettlementService.saveDraft(request);
});
created++;
log.info("合同[{}]自动生成正式结算单,账期{}至{},预结算{}张,明细{}条", contract.getContractNo(),
range.start(), range.end(), sourcePreSettlementIds.size(), sourceDetailIds.size());
} catch (Exception exception) {
log.error("合同[{}]自动生成正式结算单失败,账期{}至{}", contract.getContractNo(),
range.start(), range.end(), exception);
}
}
}
return created;
}
private List<ReceivablePayableDetail> loadPendingDetails(ContractManage contract, List<DateRange> ranges) {
LocalDate minDate = ranges.get(0).start();
LocalDate maxDate = ranges.get(0).end();
for (DateRange range : ranges) {
if (range.start().isBefore(minDate)) {
minDate = range.start();
}
if (range.end().isAfter(maxDate)) {
maxDate = range.end();
}
}
LocalDate periodStart = minDate;
LocalDate periodEnd = maxDate;
return receivablePayableDetailMapper.selectList(Wrappers.<ReceivablePayableDetail>lambdaQuery()
.eq(ReceivablePayableDetail::getIsDeleted, 0)
.eq(ReceivablePayableDetail::getContractId, contract.getId())
.eq(ReceivablePayableDetail::getProjectId, contract.getProjectId())
.eq(ReceivablePayableDetail::getDeptId, contract.getOrganizationId())
.eq(ReceivablePayableDetail::getSettlementType, contractSettlementType(contract))
.eq(ReceivablePayableDetail::getSettlementStatus, "pending")
.and(query -> query.isNull(ReceivablePayableDetail::getPreSettlementNo)
.or().eq(ReceivablePayableDetail::getPreSettlementNo, ""))
.and(query -> query.isNull(ReceivablePayableDetail::getFormalSettlementNo)
.or().eq(ReceivablePayableDetail::getFormalSettlementNo, ""))
.and(query -> query.eq(ReceivablePayableDetail::getCustomerName, contract.getPartyA())
.or().eq(ReceivablePayableDetail::getCustomerName, contract.getPartyB()))
.and(query -> query.between(ReceivablePayableDetail::getFeeDate, periodStart, periodEnd)
.or(nested -> nested.isNull(ReceivablePayableDetail::getFeeDate)
.ge(ReceivablePayableDetail::getCreateTime, periodStart.atStartOfDay())
.lt(ReceivablePayableDetail::getCreateTime, periodEnd.plusDays(1).atStartOfDay()))));
}
private Map<DateRange, List<PreSettlement>> groupApprovedPres(ContractManage contract, String settlementType,
List<DateRange> ranges) {
Map<DateRange, List<PreSettlement>> grouped = new LinkedHashMap<>();
ranges.forEach(range -> grouped.put(range, new ArrayList<>()));
List<PreSettlement> preSettlements = preSettlementService.list(Wrappers.<PreSettlement>lambdaQuery()
.eq(PreSettlement::getContractId, contract.getId())
.eq(PreSettlement::getSettlementType, settlementType)
.eq(PreSettlement::getApprovalStatus, TransportBusiConstant.APPROVED)
.and(query -> query.isNull(PreSettlement::getFormalSettlementNo)
.or().eq(PreSettlement::getFormalSettlementNo, "")));
if (preSettlements.isEmpty()) {
return grouped;
}
List<Long> preSettlementIds = preSettlements.stream().map(PreSettlement::getId).toList();
Map<Long, List<Long>> sourceIdsByPre = new LinkedHashMap<>();
for (List<Long> batch : partition(preSettlementIds)) {
List<PreSettlementDetail> lines = preSettlementDetailMapper.selectList(Wrappers.<PreSettlementDetail>lambdaQuery()
.in(PreSettlementDetail::getPreSettlementId, batch)
.eq(PreSettlementDetail::getIsDeleted, 0));
for (PreSettlementDetail line : lines) {
if (line.getSourceDetailId() == null) {
continue;
}
sourceIdsByPre.computeIfAbsent(line.getPreSettlementId(), key -> new ArrayList<>()).add(line.getSourceDetailId());
}
}
Set<Long> sourceIds = new LinkedHashSet<>();
sourceIdsByPre.values().forEach(sourceIds::addAll);
Map<Long, ReceivablePayableDetail> sourceMap = new LinkedHashMap<>();
for (List<Long> batch : partition(sourceIds)) {
receivablePayableDetailMapper.selectBatchIds(batch)
.forEach(detail -> sourceMap.put(detail.getId(), detail));
}
for (PreSettlement preSettlement : preSettlements) {
List<Long> sourceDetailIds = sourceIdsByPre.getOrDefault(preSettlement.getId(), List.of());
LocalDate minDate = null;
LocalDate maxDate = null;
boolean invalid = sourceDetailIds.isEmpty();
for (Long sourceDetailId : sourceDetailIds) {
ReceivablePayableDetail source = sourceMap.get(sourceDetailId);
LocalDate businessDate = source == null ? null : businessDate(source);
if (businessDate == null) {
invalid = true;
break;
}
minDate = minDate == null || businessDate.isBefore(minDate) ? businessDate : minDate;
maxDate = maxDate == null || businessDate.isAfter(maxDate) ? businessDate : maxDate;
}
if (invalid || minDate == null || maxDate == null) {
log.warn("预结算单[{}]缺少可归集的费用日期,跳过自动转正式结算", preSettlement.getPreSettlementNo());
continue;
}
boolean matched = false;
for (DateRange range : ranges) {
if (!minDate.isBefore(range.start()) && !maxDate.isAfter(range.end())) {
grouped.get(range).add(preSettlement);
matched = true;
break;
}
}
if (!matched && !maxDate.isAfter(LocalDate.now().minusDays(1))) {
log.info("预结算单[{}]费用日期{}至{}未完整落入已关闭账期,暂不自动归集正式结算",
preSettlement.getPreSettlementNo(), minDate, maxDate);
}
}
return grouped;
}
private Map<DateRange, List<ReceivablePayableDetail>> groupDetails(List<DateRange> ranges,
List<ReceivablePayableDetail> details) {
Map<DateRange, List<ReceivablePayableDetail>> grouped = new LinkedHashMap<>();
ranges.forEach(range -> grouped.put(range, new ArrayList<>()));
for (ReceivablePayableDetail detail : details) {
LocalDate businessDate = businessDate(detail);
if (businessDate == null) {
continue;
}
for (DateRange range : ranges) {
if (!businessDate.isBefore(range.start()) && !businessDate.isAfter(range.end())) {
grouped.get(range).add(detail);
break;
}
}
}
return grouped;
}
private List<DateRange> closedPeriods(Map<String, Object> rule, LocalDate today) {
LocalDate billStart = dateValue(rule.get("billStartDate"));
LocalDate yesterday = today.minusDays(1);
if (billStart == null || yesterday.isBefore(billStart)) {
return List.of();
}
String settlementType = Func.toStr(rule.get("settlementType")).trim();
return switch (settlementType) {
case "现结", "日结" -> cyclePeriods(billStart, yesterday, 1);
case "周结" -> cyclePeriods(billStart, yesterday, 7);
case "按固定天数周期", "固定天数周期结算" -> {
Integer cycleDays = intValue(rule.get("cycleDays"));
if (cycleDays == null || cycleDays < 1 || cycleDays > 365) {
yield List.of();
}
yield cyclePeriods(billStart, yesterday, cycleDays);
}
case "月结" -> monthlyPeriods(rule, billStart, yesterday);
default -> List.of();
};
}
private List<DateRange> monthlyPeriods(Map<String, Object> rule, LocalDate billStart, LocalDate yesterday) {
String cycleType = Func.toStr(rule.get("billCycleType")).trim();
if (Func.isBlank(cycleType) || "自然月".equals(cycleType)) {
return naturalMonthPeriods(billStart, yesterday);
}
if ("固定截单日".equals(cycleType)) {
Integer cutoffDay = intValue(rule.get("billCutoffDay"));
if (cutoffDay == null || cutoffDay < 1 || cutoffDay > 31) {
return List.of();
}
return fixedCutoffPeriods(billStart, yesterday, cutoffDay);
}
if ("自定义多周期".equals(cycleType)) {
return customMonthPeriods(rule.get("customPeriods"), billStart, yesterday);
}
return List.of();
}
private List<DateRange> cyclePeriods(LocalDate billStart, LocalDate yesterday, int days) {
List<DateRange> ranges = new ArrayList<>();
LocalDate cursor = billStart;
while (true) {
LocalDate end = cursor.plusDays(days - 1L);
if (end.isAfter(yesterday)) {
break;
}
ranges.add(new DateRange(cursor, end));
cursor = end.plusDays(1);
}
return ranges;
}
private List<DateRange> naturalMonthPeriods(LocalDate billStart, LocalDate yesterday) {
List<DateRange> ranges = new ArrayList<>();
YearMonth cursor = YearMonth.from(billStart);
YearMonth lastMonth = YearMonth.from(yesterday);
while (!cursor.isAfter(lastMonth)) {
LocalDate end = cursor.atEndOfMonth();
if (!end.isAfter(yesterday) && !end.isBefore(billStart)) {
LocalDate start = cursor.atDay(1).isBefore(billStart) ? billStart : cursor.atDay(1);
ranges.add(new DateRange(start, end));
}
cursor = cursor.plusMonths(1);
}
return ranges;
}
private List<DateRange> fixedCutoffPeriods(LocalDate billStart, LocalDate yesterday, int cutoffDay) {
List<DateRange> ranges = new ArrayList<>();
YearMonth cursor = YearMonth.from(billStart).minusMonths(1);
YearMonth limit = YearMonth.from(yesterday).plusMonths(1);
LocalDate previousEnd = null;
while (!cursor.isAfter(limit)) {
LocalDate end = cursor.atDay(Math.min(cutoffDay, cursor.lengthOfMonth()));
if (!end.isBefore(billStart) && !end.isAfter(yesterday)) {
LocalDate start = previousEnd == null ? billStart : previousEnd.plusDays(1);
if (start.isBefore(billStart)) {
start = billStart;
}
if (!start.isAfter(end)) {
ranges.add(new DateRange(start, end));
}
}
if (!end.isBefore(billStart.minusMonths(1))) {
previousEnd = end;
}
cursor = cursor.plusMonths(1);
}
return ranges;
}
private List<DateRange> customMonthPeriods(Object periodsValue, LocalDate billStart, LocalDate yesterday) {
List<int[]> periods = customPeriods(periodsValue);
if (periods.isEmpty()) {
return List.of();
}
List<DateRange> ranges = new ArrayList<>();
YearMonth cursor = YearMonth.from(billStart);
YearMonth lastMonth = YearMonth.from(yesterday);
while (!cursor.isAfter(lastMonth)) {
for (int[] period : periods) {
int startDay = Math.min(period[0], cursor.lengthOfMonth());
int endDay = Math.min(period[1], cursor.lengthOfMonth());
if (startDay > endDay) {
continue;
}
LocalDate end = cursor.atDay(endDay);
if (end.isAfter(yesterday) || end.isBefore(billStart)) {
continue;
}
LocalDate start = cursor.atDay(startDay);
if (start.isBefore(billStart)) {
start = billStart;
}
if (!start.isAfter(end)) {
ranges.add(new DateRange(start, end));
}
}
cursor = cursor.plusMonths(1);
}
return ranges;
}
private List<int[]> customPeriods(Object value) {
if (!(value instanceof List<?> periodList)) {
return List.of();
}
List<int[]> periods = new ArrayList<>();
for (Object item : periodList) {
if (!(item instanceof Map<?, ?> period)) {
continue;
}
Integer startDay = intValue(period.get("startDay"));
Integer endDay = intValue(period.get("endDay"));
if (startDay == null || endDay == null || startDay < 1 || endDay > 31 || endDay < startDay) {
continue;
}
periods.add(new int[]{startDay, endDay});
}
return periods;
}
private Map<String, Object> resolveRule(ContractManage contract, boolean preRule) {
Map<String, Object> direct = parseObject(preRule ? contract.getPreSettlementConfigJson() : contract.getFormalSettlementConfigJson());
if (!direct.isEmpty()) {
return direct;
}
Map<String, Object> payload = parseObject(contract.getSettlementRuleJson());
Object nested = payload.get(preRule ? "preSettlementConfig" : "formalSettlementConfig");
if (nested instanceof Map<?, ?> nestedMap) {
Map<String, Object> result = new LinkedHashMap<>();
nestedMap.forEach((key, value) -> result.put(String.valueOf(key), value));
return result;
}
if (!payload.containsKey("preSettlementConfig") && !payload.containsKey("formalSettlementConfig")) {
return payload;
}
return Map.of();
}
private Map<String, Object> parseObject(String json) {
if (Func.isBlank(json)) {
return Map.of();
}
try {
Object parsed = JsonUtil.parse(json, Map.class);
if (!(parsed instanceof Map<?, ?> raw) || raw.isEmpty()) {
return Map.of();
}
Map<String, Object> result = new LinkedHashMap<>();
raw.forEach((key, value) -> result.put(String.valueOf(key), value));
return result;
} catch (Exception exception) {
log.warn("结算规则JSON解析失败:{}", exception.getMessage());
return Map.of();
}
}
private void inTransaction(Runnable runnable) {
new TransactionTemplate(transactionManager).executeWithoutResult(status -> runnable.run());
}
private List<List<Long>> partition(Collection<Long> ids) {
List<Long> source = ids instanceof List<Long> list ? list : new ArrayList<>(ids);
List<List<Long>> batches = new ArrayList<>();
for (int index = 0; index < source.size(); index += QUERY_BATCH_SIZE) {
batches.add(source.subList(index, Math.min(index + QUERY_BATCH_SIZE, source.size())));
}
return batches;
}
private String contractSettlementType(ContractManage contract) {
if ("客户合同".equals(contract.getContractCategory())) {
return "receivable";
}
if ("承运商合同".equals(contract.getContractCategory())) {
return "payable";
}
return null;
}
private LocalDate businessDate(ReceivablePayableDetail detail) {
if (detail.getFeeDate() != null) {
return detail.getFeeDate();
}
if (detail.getCreateTime() == null) {
return null;
}
return detail.getCreateTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
}
private String periodRemark(DateRange range) {
return "系统按结算规则自动生成(" + range.start() + "至" + range.end() + ")";
}
private boolean enabled(Object value) {
if (value instanceof Boolean enabled) {
return enabled;
}
if (value instanceof Number number) {
return number.intValue() == 1;
}
String text = value == null ? "" : String.valueOf(value).trim();
return "1".equals(text) || "true".equalsIgnoreCase(text);
}
private boolean knownCycle(String settlementType) {
return List.of("现结", "日结", "周结", "月结", "按固定天数周期", "固定天数周期结算").contains(settlementType);
}
private Integer intValue(Object value) {
if (value instanceof Number number) {
return number.intValue();
}
if (value == null || Func.isBlank(String.valueOf(value))) {
return null;
}
try {
return Integer.parseInt(String.valueOf(value).trim());
} catch (NumberFormatException exception) {
return null;
}
}
private LocalDate dateValue(Object value) {
if (value == null || Func.isBlank(String.valueOf(value))) {
return null;
}
String text = String.valueOf(value).trim();
if (text.length() >= 10) {
text = text.substring(0, 10);
}
try {
return LocalDate.parse(text);
} catch (DateTimeParseException exception) {
return null;
}
}
private record DateRange(LocalDate start, LocalDate end) {
}
}
@@ -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");
}
}
@@ -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());
}
@@ -1233,14 +1233,67 @@ public class TransportReconciliationServiceImpl
private List<TransportReconciliationInternal> assertAllMatched(TransportReconciliation bill) {
List<TransportReconciliationInternal> internals = internalRows(bill.getId());
List<TransportReconciliationExternal> externals = externalRows(bill.getId());
if (internals.isEmpty() || externals.isEmpty() || internals.size() != externals.size()
|| internals.stream().anyMatch(item -> !MATCHED.equals(item.getMatchResult()))
|| externals.stream().anyMatch(item -> !MATCHED.equals(item.getMatchStatus()) || Boolean.TRUE.equals(item.getSuspectedDuplicate()))) {
// 调整账单后旧的疑似重复标记可能已失效,按当前内容重算,避免已一一匹配仍被拦截
syncSuspectedDuplicates(externals);
if (!oneToOneMatched(internals, externals)) {
throw new ServiceException("所有内外部账单明细必须一一匹配且不存在疑似重复");
}
return internals;
}
private void syncSuspectedDuplicates(List<TransportReconciliationExternal> externals) {
Map<String, List<TransportReconciliationExternal>> groups = externals.stream()
.collect(Collectors.groupingBy(this::duplicateKey));
Set<Long> duplicateIds = new HashSet<>();
for (List<TransportReconciliationExternal> group : groups.values()) {
if (group.size() > 1) {
group.stream().map(TransportReconciliationExternal::getId).forEach(duplicateIds::add);
}
}
for (TransportReconciliationExternal external : externals) {
boolean duplicate = duplicateIds.contains(external.getId());
String matchStatus = external.getMatchStatus();
if (!duplicate && DUPLICATE.equals(matchStatus) && external.getMatchedInternalId() != null) {
matchStatus = MATCHED;
}
if (Boolean.TRUE.equals(external.getSuspectedDuplicate()) == duplicate
&& Objects.equals(matchStatus, external.getMatchStatus())) {
continue;
}
external.setSuspectedDuplicate(duplicate);
external.setMatchStatus(matchStatus);
externalMapper.update(null, Wrappers.<TransportReconciliationExternal>lambdaUpdate()
.eq(TransportReconciliationExternal::getId, external.getId())
.set(TransportReconciliationExternal::getSuspectedDuplicate, duplicate)
.set(TransportReconciliationExternal::getMatchStatus, matchStatus));
}
}
private boolean oneToOneMatched(List<TransportReconciliationInternal> internals,
List<TransportReconciliationExternal> externals) {
if (internals.isEmpty() || externals.isEmpty() || internals.size() != externals.size()) {
return false;
}
if (externals.stream().anyMatch(item -> Boolean.TRUE.equals(item.getSuspectedDuplicate()))) {
return false;
}
Map<Long, TransportReconciliationExternal> externalMap = externals.stream()
.collect(Collectors.toMap(TransportReconciliationExternal::getId, Function.identity(), (left, right) -> left));
Set<Long> usedExternalIds = new HashSet<>();
for (TransportReconciliationInternal internal : internals) {
if (!MATCHED.equals(internal.getMatchResult()) || internal.getMatchedExternalId() == null
|| !usedExternalIds.add(internal.getMatchedExternalId())) {
return false;
}
TransportReconciliationExternal external = externalMap.get(internal.getMatchedExternalId());
if (external == null || !MATCHED.equals(external.getMatchStatus())
|| !Objects.equals(external.getMatchedInternalId(), internal.getId())) {
return false;
}
}
return usedExternalIds.size() == externals.size();
}
private void refreshStats(Long id) {
TransportReconciliation bill = existing(id);
List<TransportReconciliationInternal> internals = internalRows(id);
@@ -1459,20 +1512,14 @@ public class TransportReconciliationServiceImpl
private BigDecimal sumExternalQuantity(List<TransportReconciliationExternal> rows) { return rows.stream().map(TransportReconciliationExternal::getTransportQuantity).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); }
private BigDecimal sumInternalAmount(List<TransportReconciliationInternal> rows) { return rows.stream().map(TransportReconciliationInternal::getSettlementAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); }
private BigDecimal sumExternalAmount(List<TransportReconciliationExternal> rows) { return rows.stream().map(TransportReconciliationExternal::getSettlementAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); }
/** 生成当日运输对账单号;按最大流水号 +1,且计入逻辑删除数据 */
private synchronized String nextNo() {
String prefix = "DZD" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
int sequence = list(Wrappers.<TransportReconciliation>lambdaQuery()
.select(TransportReconciliation::getReconciliationNo)
.likeRight(TransportReconciliation::getReconciliationNo, prefix))
.stream()
.map(TransportReconciliation::getReconciliationNo)
.filter(number -> number != null && number.length() == prefix.length() + 5)
.map(number -> number.substring(prefix.length()))
.filter(suffix -> suffix.chars().allMatch(Character::isDigit))
.mapToInt(Integer::parseInt)
.max()
.orElse(0) + 1;
if (sequence > 99999) throw new ServiceException("当日运输对账单号流水已用完");
Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix);
long sequence = (maxSequence == null ? 0L : maxSequence) + 1;
if (sequence > 99999) {
throw new ServiceException("当日运输对账单号流水已用完");
}
return prefix + String.format("%05d", sequence);
}
}
@@ -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())) {
@@ -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) {
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -25,11 +25,14 @@ import org.springblade.system.cache.UserCache;
import org.springblade.system.feign.ISysClient;
import org.springblade.system.pojo.entity.DictBiz;
import org.springblade.system.pojo.entity.CargoType;
import org.springblade.system.pojo.entity.MeasurementUnit;
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;
@@ -75,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}$");
/** 中国机动车号牌省份简称。 */
@@ -357,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;
@@ -385,13 +396,36 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
public BusinessRemoveResultVO removeBatches(String ids) {
List<Long> idList = Func.toLongList(ids);
if (Func.isEmpty(idList)) throw new ServiceException("请选择需要删除的运单批次");
List<WaybillImportBatch> batches = listByIds(idList).stream()
.filter(batch -> !Objects.equals(batch.getIsDeleted(), 1))
.toList();
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 : listByIds(idList)) {
if (Objects.equals(batch.getIsDeleted(), 1)) continue;
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());
for (WaybillImportBatch batch : batches) {
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);
@@ -456,7 +490,7 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
Waybill waybill = new Waybill();
waybill.setOriginalNo(stringValue(row, "originalNo"));
waybill.setLoadingIdentifier(stringValue(row, "loadingIdentifier", "配载标识号"));
waybill.setVehicleNo(stringValue(row, "vehicleNo", "车牌号/船班号/船次号/班次号", "车牌号/航班号/船号/班列号"));
waybill.setVehicleNo(vehicleNoFromRow(row));
waybill.setDriverId(longValue(row, "driverId", "司机ID"));
waybill.setDriverName(stringValue(row, "driverName", "司机/船长姓名", "司机/船长"));
waybill.setDriverPhone(stringValue(row, "driverPhone", "司机/船长手机号"));
@@ -533,6 +567,14 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
return null;
}
/** 车号列没填时保持为空,不从其它表头别名回填。 */
private String vehicleNoFromRow(Map<String, Object> row) {
if (row != null && row.containsKey("vehicleNo")) {
return stringValue(row, "vehicleNo");
}
return stringValue(row, "vehicleNo", "车牌号/船班号/船次号/班次号", "车牌号/航班号/船号/班列号", "车号");
}
private Long longValue(Map<String, Object> row, String field, String fieldName) {
String value = stringValue(row, field);
if (Func.isEmpty(value)) return null;
@@ -654,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();
@@ -741,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()) {
@@ -783,12 +959,12 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
}
// 检查同一配载标识号下车牌号是否一致
String currentVehicleNo = stringValue(row, "vehicleNo", "车牌号/船班号/船次号/班次号", "车牌号/航班号/船号/班列号");
String currentVehicleNo = vehicleNoFromRow(row);
for (Integer otherRowIndex : sameIdentifierRows) {
if (otherRowIndex.equals(rowIndex)) {
continue;
}
String otherVehicleNo = stringValue(allRows.get(otherRowIndex), "vehicleNo", "车牌号/船班号/船次号/班次号", "车牌号/航班号/船号/班列号");
String otherVehicleNo = vehicleNoFromRow(allRows.get(otherRowIndex));
if (Func.isNotEmpty(currentVehicleNo) && Func.isNotEmpty(otherVehicleNo)
&& !currentVehicleNo.equals(otherVehicleNo)) {
errors.add("同一配载标识号下,车牌号不一致");
@@ -799,7 +975,7 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
}
private void validateVehicleNo(Map<String, Object> row, List<String> errors) {
String vehicleNo = stringValue(row, "vehicleNo", "车牌号/船班号/船次号/班次号", "车牌号/航班号/船号/班列号");
String vehicleNo = vehicleNoFromRow(row);
String transportType = stringValue(row, "transportType", "运输类型", "*运输类型", "运输方式", "*运输方式");
if (Func.isEmpty(transportType)) {
@@ -911,38 +1087,14 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
}
private void validateQuantityUnit(Map<String, Object> row, List<String> quantityUnitOptions, List<String> errors) {
String quantityUnit = stringValue(row, "quantityUnit", "数量单位");
String quantityUnit = stringValue(row, "quantityUnit", "数量单位", "*数量单位");
if (Func.isEmpty(quantityUnit)) {
return;
}
if (!quantityUnitOptions.contains(quantityUnit)) {
errors.add("数量单位必须为系统枚举值之一");
}
}
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("运费相关字段格式不正确");
String normalized = quantityUnit.trim();
boolean matched = quantityUnitOptions.stream().anyMatch(option -> option.equalsIgnoreCase(normalized));
if (!matched) {
errors.add("数量单位「" + normalized + "」未在计量单位主数据中匹配");
}
}
@@ -1045,12 +1197,12 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
}
// 检查同一运单标识号下车牌号是否一致
String currentVehicleNo = stringValue(row, "vehicleNo", "车牌号/船班号/船次号/班次号", "车牌号/航班号/船号/班列号");
String currentVehicleNo = vehicleNoFromRow(row);
for (Integer otherRowIndex : sameIdentifierRows) {
if (otherRowIndex.equals(rowIndex)) {
continue;
}
String otherVehicleNo = stringValue(allRows.get(otherRowIndex), "vehicleNo", "车牌号/船班号/船次号/班次号", "车牌号/航班号/船号/班列号");
String otherVehicleNo = vehicleNoFromRow(allRows.get(otherRowIndex));
if (Func.isNotEmpty(currentVehicleNo) && Func.isNotEmpty(otherVehicleNo)
&& !currentVehicleNo.equals(otherVehicleNo)) {
errors.add("同一运单标识号下,车牌号必须一致");
@@ -1178,9 +1330,28 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
.orElse(normalized);
}
/** 数量单位从计量单位主数据读取(单位名称、编码均可匹配)。 */
private List<String> loadQuantityUnitOptions() {
// 常见数量单位
return List.of("吨", "千克", "立方米", "件", "箱", "台", "个", "升", "米", "平方米");
R<List<MeasurementUnit>> response = sysClient.getMeasurementUnits();
if (response == null || !response.isSuccess() || response.getData() == null) {
throw new ServiceException("计量单位基础数据读取失败,请稍后重试");
}
Set<String> options = new HashSet<>();
for (MeasurementUnit unit : response.getData()) {
if (unit == null || (unit.getStatus() != null && !Objects.equals(unit.getStatus(), 1))) {
continue;
}
if (Func.isNotEmpty(unit.getUnitName())) {
options.add(unit.getUnitName().trim());
}
if (Func.isNotEmpty(unit.getUnitCode())) {
options.add(unit.getUnitCode().trim());
}
}
if (options.isEmpty()) {
throw new ServiceException("计量单位主数据为空,请先在基础数据中配置计量单位");
}
return new ArrayList<>(options);
}
/**
@@ -1190,7 +1361,7 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
WaybillImportBatchExcel excel = new WaybillImportBatchExcel();
excel.setOriginalNo(stringValue(row, "originalNo"));
excel.setLoadingIdentifier(stringValue(row, "loadingIdentifier", "配载标识号"));
excel.setVehicleNo(stringValue(row, "vehicleNo", "车牌号/船班号/船次号/班次号", "车牌号/航班号/船号/班列号"));
excel.setVehicleNo(vehicleNoFromRow(row));
excel.setTransportType(stringValue(row, "transportType", "运输类型", "*运输类型", "运输方式", "*运输方式"));
excel.setDriverName(stringValue(row, "driverName", "司机/船长姓名", "司机/船长"));
excel.setDriverPhone(stringValue(row, "driverPhone", "司机/船长手机号"));
@@ -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) {
@@ -1211,15 +1202,14 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
@Override
public List<WaybillExcel> exportWaybill(WaybillVO waybill, String ids) {
TransportBusinessSupport.validateAllDept(waybill.getAllDept(), "运单管理");
List<Long> idList = Func.toLongList(ids);
LambdaQueryWrapper<Waybill> queryWrapper;
if (Func.isNotEmpty(idList)) {
queryWrapper = Wrappers.<Waybill>lambdaQuery().eq(Waybill::getIsDeleted, 0);
if (!Objects.equals(waybill.getAllDept(), 1)) {
queryWrapper.eq(Waybill::getDeptId, TransportBusinessSupport.currentDeptId("运单管理"));
} else if (Func.isNotEmpty(waybill.getDeptId())) {
queryWrapper.eq(Waybill::getDeptId, waybill.getDeptId());
List<Long> deptIdList = TransportBusinessSupport.resolveDeptFilterIds(
waybill.getAllDept(), waybill.getDeptId(), "运单管理");
if (Func.isNotEmpty(deptIdList)) {
queryWrapper.eq(Waybill::getDeptId, deptIdList.get(0));
}
queryWrapper.in(Waybill::getId, idList).orderByDesc(Waybill::getCreateTime);
} else {
@@ -1496,7 +1486,8 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
@Override
@Transactional(rollbackFor = Exception.class)
public boolean complete(Long id) {
return doComplete(loadEditable(id, true));
// 暂时取消组织校验:历史运单 deptId 可能为 -1 / 与当前组织不一致,完成时不再拦截
return doComplete(loadEditable(id, false));
}
@Override
@@ -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,并检查生成应收应付明细。
*/
@@ -1604,12 +1650,12 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
}
private LambdaQueryWrapper<Waybill> buildQuery(WaybillVO waybill) {
TransportBusinessSupport.validateAllDept(waybill.getAllDept(), "运单管理");
LambdaQueryWrapper<Waybill> queryWrapper = Wrappers.<Waybill>lambdaQuery().eq(Waybill::getIsDeleted, 0);
if (!Objects.equals(waybill.getAllDept(), 1)) {
queryWrapper.eq(Waybill::getDeptId, TransportBusinessSupport.currentDeptId("运单管理"));
} else if (Func.isNotEmpty(waybill.getDeptId())) {
queryWrapper.eq(Waybill::getDeptId, waybill.getDeptId());
// 与常用线路等模块一致:超管默认不按组织过滤;普通用户 allDept!=1 时强制当前组织
List<Long> deptIdList = TransportBusinessSupport.resolveDeptFilterIds(
waybill.getAllDept(), waybill.getDeptId(), "运单管理");
if (Func.isNotEmpty(deptIdList)) {
queryWrapper.eq(Waybill::getDeptId, deptIdList.get(0));
}
if (Func.isNotEmpty(waybill.getWaybillNo())) {
queryWrapper.eq(Waybill::getWaybillNo, waybill.getWaybillNo());
@@ -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());
}
@@ -32,11 +32,9 @@ import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.tool.api.R;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.DictBizCache;
import org.springblade.system.cache.SysCache;
import org.springblade.system.feign.ISysClient;
import org.springblade.system.pojo.entity.Dept;
import org.springblade.system.pojo.entity.DictBiz;
import org.springblade.system.pojo.entity.FeeItem;
import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillQueryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveDTO;
@@ -102,8 +100,10 @@ public class KingdeeApBillSyncSupport {
private static final int PAY_RESULT_PAGE_SIZE = 200;
/** 结算明细行(运费)计费方案匹配费用项(运输类无税率残差组标识用) */
private static final String FREIGHT_FEE_ITEM = "运输费";
/** 业务字典:费用类型(fee_category),费用项档案分类编码 -> 名称 */
private static final String FEE_CATEGORY_DICT = "fee_category";
/** 费用大类编码(业务字典 fee_category 键值):运输 */
public static final String FEE_CATEGORY_FREIGHT = "A01";
/** 费用大类编码(业务字典 fee_category 键值):装卸 */
public static final String FEE_CATEGORY_LOADING = "A10";
private final IBfiService bfiService;
private final ContractManageMapper contractManageMapper;
@@ -200,6 +200,33 @@ public class KingdeeApBillSyncSupport {
return null;
}
/**
* A102-4 冲销状态查询:按防重键查询财务应付单,返回该防重键下是否存在「已被冲销」单据
* (BfiApBillVO.hadwrittenoff=true,部分冲销同样视为已冲销)。
* 结算调整重推硬闸门用:仅当原单已被冲销才允许以新来源单据号重推新应付单
*
* @param awrpSrcCode 财务应付单防重键
* @return true=金蝶侧原应付单已被冲销
*/
public boolean queryApBillWrittenOff(String awrpSrcCode) {
if (Func.isBlank(awrpSrcCode)) {
return false;
}
BfiApBillQueryDTO param = new BfiApBillQueryDTO();
param.setAwrpSrcCode(List.of(awrpSrcCode));
param.setAwrpSrcSys(bfiProperties.getSrcSys());
for (BfiApBillVO apBill : BfiUtil.queryAllPages(pageNo -> {
param.setPageNo(pageNo);
param.setPageSize(PAY_RESULT_PAGE_SIZE);
return bfiService.queryApBill(param);
})) {
if (awrpSrcCode.equals(apBill.getAwrpSrcCode()) && Boolean.TRUE.equals(apBill.getHadwrittenoff())) {
return true;
}
}
return false;
}
// ==================== A102-2 财务应付单保存 ====================
/**
@@ -233,7 +260,7 @@ public class KingdeeApBillSyncSupport {
if (Func.isBlank(billtypeNumber)) {
throw new ServiceException("推送金蝶财务应付单失败:单据类型[" + KingdeeBfiConstant.BFI_AP_BILL_BILLTYPE_NAME + "]未在映射字典中维护");
}
String orgNumber = bfiOrgNumber(contract);
String orgNumber = bfiOrgNumber(contract, context);
// 币种取合同结算币种
String currency = bfiCurrency(contract);
// 单据日期:yyyy-MM-dd HH:mm:ss(各接口统一格式),流程拆分后以同步当日作为应付单据日期
@@ -397,7 +424,7 @@ public class KingdeeApBillSyncSupport {
ContractManage contract = requireContract(settlement.getContractId(), context);
// 合同计费方案规则:费用类型/费用项 -> 税率(拆行分组用)
Map<String, BigDecimal> taxRateByRule = taxRateByRule(contract);
// 费用项档案:费用项名称 -> 费用类型名称(费用大类判定,字典 fee_category 翻译)
// 费用项档案:费用项名称 -> 费用大类编码(费用大类判定)
Map<String, String> feeCategoryByName = feeCategoryByName(context);
EntryResolveContext resolveContext = new EntryResolveContext(settlement, project, taxRateByRule, feeCategoryByName);
// 结算明细费用行:一车多货按货物拆分的费用快照行
@@ -513,7 +540,7 @@ public class KingdeeApBillSyncSupport {
String feeType = Func.toStr(summaryFee.getFeeType()).trim();
String feeItem = Func.toStr(summaryFee.getFeeItem()).trim();
String sourceDesc = "结算合计[" + feeType + "-" + feeItem + "]";
// 非运费类(费用项档案分类/费用类型/费用项名称均不含“运费/运输费”)统一映射装卸物料
// 非运费类(费用项档案分类编码非 A01 且名称无运费字样)统一映射装卸物料
boolean loadingFee = !isFreightCategory(feeType, feeItem, context.feeCategoryByName());
String materialNumber = resolveMaterialNumber(loadingFee, context.project(), fallbackTransportType, sourceDesc);
// 结算合计费用项行无运输量,数量默认 1
@@ -640,12 +667,12 @@ public class KingdeeApBillSyncSupport {
/**
* 费用项按费用大类/税率拆分(纯函数):
* 费用项经费用项档案/业务字典判为装卸大类(名称含“装卸”的费用类型)、其余归运输大类;
* 费用项经费用项档案判为装卸大类(费用大类编码 A10)、其余归运输大类;
* 各大类内按计费方案规则税率分组(未配置税率的费用项归入无税率组),
* 费用项金额合计与结算金额的残差并入运输类无税率组,保证拆分后各行金额合计与结算金额勾稽
*
* @param feeItems 费用项名称 -> 金额(费用项JSON解析结果,保持行序)
* @param feeCategoryByName 费用项档案:费用项名称 -> 费用类型名称(空 Map 时回退名称关键字判定)
* @param feeCategoryByName 费用项档案:费用项名称 -> 费用大类编码(空 Map 时回退名称关键字判定)
* @param taxRateByRule 合同计费方案:费用类型/费用项 -> 税率(百分数)
* @param rowAmount 货物费用行结算金额(含税),残差勾稽基准
* @return 拆分行列表(保持费用项出现顺序)
@@ -693,29 +720,33 @@ public class KingdeeApBillSyncSupport {
}
/**
* 装卸大类判定:费用项在费用项档案(blade_fee_item)中的费用类型名称(业务字典 fee_category 翻译)
* 含“装卸”归装卸大类;档案未维护该费用项时回退费用项名称关键字判定
* 装卸大类判定:费用项在费用项档案(blade_fee_item)中的费用大类编码为 A10 归装卸大类;
* 档案未维护该费用项时回退费用项名称关键字判定
*/
static boolean isLoadingCategory(String feeItemName, Map<String, String> feeCategoryByName) {
String categoryName = feeItemName == null || feeCategoryByName == null ? null : feeCategoryByName.get(feeItemName);
if (Func.isNotBlank(categoryName)) {
return categoryName.contains("装卸");
String categoryCode = feeItemName == null || feeCategoryByName == null ? null : feeCategoryByName.get(feeItemName);
if (Func.isNotBlank(categoryCode)) {
return FEE_CATEGORY_LOADING.equals(categoryCode);
}
return isLoadingFeeItem(feeItemName);
}
/** 运费类费用判定(关键字兜底):名称含“运费”或“运输费” */
/** 运费类费用判定(关键字兜底):名称含“运费”或“运输”(业务字典 fee_category 运输类的字典值为“运输”) */
static boolean isFreightFeeText(String name) {
return name != null && (name.contains("运费") || name.contains("运输费"));
return name != null && (name.contains("运费") || name.contains("运输"));
}
/**
* 运费类判定(手动添加费用行物料映射用):费用项档案分类名称、费用类型、费用项名称任一
* 含“运费/运输费”即归运费类映射运输物料;档案未维护时退化为名称关键字判定(与原口径一致)
* 运费类判定(手动添加费用行物料映射用):费用项档案维护的费用大类编码为 A01 归运费类映射运输物料;
* 档案未维护时回退行费用类型编码与费用项名称关键字判定
*/
static boolean isFreightCategory(String feeTypeName, String feeItemName, Map<String, String> feeCategoryByName) {
String categoryName = feeItemName == null || feeCategoryByName == null ? null : feeCategoryByName.get(feeItemName);
return isFreightFeeText(categoryName) || isFreightFeeText(feeTypeName) || isFreightFeeText(feeItemName);
public static boolean isFreightCategory(String feeTypeCode, String feeItemName, Map<String, String> feeCategoryByName) {
String categoryCode = feeItemName == null || feeCategoryByName == null ? null : feeCategoryByName.get(feeItemName);
if (Func.isNotBlank(categoryCode)) {
// 档案维护的费用项以档案分类编码为准
return FEE_CATEGORY_FREIGHT.equals(categoryCode);
}
return FEE_CATEGORY_FREIGHT.equals(feeTypeCode) || isFreightFeeText(feeItemName);
}
/**
@@ -817,8 +848,8 @@ public class KingdeeApBillSyncSupport {
}
/**
* 费用项档案分类:费用项名称 -> 费用类型名称(blade_fee_item.fee_category 编码经业务字典
* {@link #FEE_CATEGORY_DICT} 翻译;批量同步时全批共享一次加载结果),费用大类判定用;
* 费用项档案分类:费用项名称 -> 费用大类编码(blade_fee_item.fee_category,业务字典 fee_category 键值,
* 运输 A01 / 装卸 A10;批量同步时全批共享一次加载结果),费用大类判定用;
* 档案读取失败直接拦截
*/
public Map<String, String> feeCategoryByName(KingdeeSyncContext context) {
@@ -826,9 +857,7 @@ public class KingdeeApBillSyncSupport {
if (!cache.isEmpty()) {
return cache;
}
Map<String, String> categoryNames = DictBizCache.getList(FEE_CATEGORY_DICT).stream()
.collect(Collectors.toMap(DictBiz::getDictKey, DictBiz::getDictValue, (first, second) -> first));
R<List<FeeItem>> response = sysClient.getFeeItems();
R<List<FeeItem>> response = sysClient.getFeeItems(false);
if (response == null || !response.isSuccess() || Func.isEmpty(response.getData())) {
throw new ServiceException("推送金蝶失败:费用项基础档案读取失败,请稍后重试");
}
@@ -837,10 +866,9 @@ public class KingdeeApBillSyncSupport {
if (feeItem == null || Func.isBlank(feeItem.getName()) || Func.isBlank(feeItem.getFeeCategory())) {
continue;
}
loaded.putIfAbsent(feeItem.getName().trim(),
categoryNames.getOrDefault(feeItem.getFeeCategory().trim(), feeItem.getFeeCategory().trim()));
loaded.putIfAbsent(feeItem.getName().trim(), feeItem.getFeeCategory().trim());
}
log.info("费用项档案加载:{}项(费用项名称 -> 费用类型名称)", loaded.size());
log.info("费用项档案加载:{}项(费用项名称 -> 费用大类编码)", loaded.size());
cache.putAll(loaded);
return cache;
}
@@ -860,7 +888,7 @@ public class KingdeeApBillSyncSupport {
BfiBillSaveResultItemVO item = saveResult.getResult().get(0);
if (!Boolean.TRUE.equals(item.getBillStatus())) {
String errorText = Func.isBlank(item.getErrorText()) ? "未知错误" : item.getErrorText();
throw new ServiceException(action + "失败:" + errorText);
throw new ServiceException(action + "失败:" + errorText + item.getFailMark());
}
}
@@ -925,24 +953,75 @@ public class KingdeeApBillSyncSupport {
}
/**
* 合同所属组织编码
* 合同金蝶组织编码:合同关联项目 → 项目业务部门沿部门树向上反查唯一平台公司 → 平台公司金蝶组织编码
*
* @param contract 合同实体
* @param context 金蝶同步上下文(缓存载体)
* @return 组织编码
*/
public String bfiOrgNumber(ContractManage contract) {
Dept dept = contract.getOrganizationId() == null ? null : SysCache.getDept(contract.getOrganizationId());
// dept为空,直接报错
if (dept == null) {
throw new ServiceException("推送金蝶失败,合同所属组织不存在,请检查合同所属组织配置");
public String bfiOrgNumber(ContractManage contract, KingdeeSyncContext context) {
if (contract.getProjectId() == null) {
throw new ServiceException("推送金蝶失败,合同[" + Func.toStr(contract.getContractNo()) + "]未关联项目,无法确定金蝶组织编码");
}
// kingdeeDeptCode为空,直接报错
if (Func.isBlank(dept.getKingdeeDeptCode())) {
throw new ServiceException("推送金蝶失败,合同所属组织编码不能为空,请检查组织[" + dept.getDeptName() + "]是否维护金蝶组织编码");
return this.kingdeeOrgNumber(contract.getProjectId(), context);
}
/**
* 金蝶组织编码:项目业务部门沿部门树向上反查唯一平台公司(是否平台公司=是),取该平台公司的金蝶组织编码;
* 项目未关联/未维护业务部门/业务部门未归属平台公司/平台公司未维护金蝶组织编码时拦截
*
* @param projectId 项目id
* @param context 金蝶同步上下文(缓存载体)
* @return 组织编码
*/
public String kingdeeOrgNumber(Long projectId, KingdeeSyncContext context) {
ProjectApply project = this.requireOrgProject(projectId, context);
// 沿部门树向上反查平台公司,上限 20 级防脏数据成环死循环
Dept dept = SysCache.getDept(project.getBusinessDeptId());
for (int depth = 0; dept != null && depth < 20; depth++) {
if (Objects.equals(dept.getIsPlatformCompany(), 1)) {
if (Func.isBlank(dept.getKingdeeDeptCode())) {
throw new ServiceException("推送金蝶失败,平台公司[" + dept.getDeptName() + "]未维护金蝶组织编码,请检查部门数据");
}
return dept.getKingdeeDeptCode();
}
dept = dept.getParentId() == null ? null : SysCache.getDept(dept.getParentId());
}
throw new ServiceException("推送金蝶失败,项目业务部门[" + Func.toStr(project.getBusinessDeptName()) + "]未归属平台公司,无法确定金蝶组织编码");
}
/**
* 金蝶部门编码:项目业务部门自身的金蝶编码(blade_dept.kingdee_dept_code,不上溯平台公司),
* 用于付款申请单 awrp_department_number(部门.编码);项目未关联/未维护业务部门/业务部门未维护金蝶编码时拦截
*
* @param projectId 项目id
* @param context 金蝶同步上下文(缓存载体)
* @return 部门编码
*/
public String kingdeeDeptNumber(Long projectId, KingdeeSyncContext context) {
ProjectApply project = this.requireOrgProject(projectId, context);
Dept dept = SysCache.getDept(project.getBusinessDeptId());
if (dept == null || Func.isBlank(dept.getKingdeeDeptCode())) {
throw new ServiceException("推送金蝶失败,项目业务部门[" + Func.toStr(project.getBusinessDeptName()) + "]未维护金蝶编码,请检查部门数据");
}
return dept.getKingdeeDeptCode();
}
/** 金蝶组织/部门编码共用校验:项目存在且已维护业务部门 */
private ProjectApply requireOrgProject(Long projectId, KingdeeSyncContext context) {
if (projectId == null) {
throw new ServiceException("推送金蝶失败,单据未关联项目,无法确定金蝶组织编码");
}
ProjectApply project = context.projectCache().computeIfAbsent(projectId, projectApplyMapper::selectById);
if (project == null) {
throw new ServiceException("推送金蝶失败,项目[" + projectId + "]不存在,无法确定金蝶组织编码");
}
if (project.getBusinessDeptId() == null) {
throw new ServiceException("推送金蝶失败,项目[" + Func.toStr(project.getProjectName()) + "]未维护业务部门,无法确定金蝶组织编码");
}
return project;
}
/**
* 合同结算币种转金蝶币别代码(RMB 统一映射 CNY)
*
@@ -955,7 +1034,7 @@ public class KingdeeApBillSyncSupport {
return "RMB".equalsIgnoreCase(currency) ? "CNY" : currency;
}
/** 财务应付单往来类型:合同类别为“客户合同”时为 bd_customer(客户),否则 bd_supplier(供应商) */
/** 财务应付单/付款申请/应收单/收票单统一往来类型:固定 bd_customer(按合同类别区分供应商的逻辑已注释停用,恢复时同步影响付款申请) */
public String bfiAsstactType(ContractManage contract) {
return KingdeeBfiConstant.BFI_CUSTOMER_ASSTACT_TYPE;
/*return "客户合同".equals(contract.getContractCategory())
@@ -138,6 +138,33 @@ public class KingdeeArBillSyncSupport {
return null;
}
/**
* A302-4 冲销状态查询:按防重键查询财务应收单,返回该防重键下是否存在「已被冲销」单据
* (BfiArBillVO.hadwrittenoff=true,部分冲销同样视为已冲销)。
* 结算调整重推硬闸门用:仅当原单已被冲销才允许以新来源单据号重推新应收单
*
* @param arBillSrcCode 财务应收单防重键
* @return true=金蝶侧原应收单已被冲销
*/
public boolean queryArBillWrittenOff(String arBillSrcCode) {
if (Func.isBlank(arBillSrcCode)) {
return false;
}
BfiArBillQueryDTO param = new BfiArBillQueryDTO();
param.setAwrpSrcCode(List.of(arBillSrcCode));
param.setAwrpSrcSys(bfiProperties.getSrcSys());
for (BfiArBillVO arBill : BfiUtil.queryAllPages(pageNo -> {
param.setPageNo(pageNo);
param.setPageSize(AR_RESULT_PAGE_SIZE);
return bfiService.queryArBill(param);
})) {
if (arBillSrcCode.equals(arBill.getAwrpSrcCode()) && Boolean.TRUE.equals(arBill.getHadwrittenoff())) {
return true;
}
}
return false;
}
// ==================== A302-2 财务应收单保存 ====================
/**
@@ -170,7 +197,7 @@ public class KingdeeArBillSyncSupport {
if (Func.isBlank(billtypeNumber)) {
throw new ServiceException("推送金蝶财务应收单失败:单据类型[" + KingdeeBfiConstant.BFI_AR_BILL_BILLTYPE_NAME + "]未在映射字典中维护");
}
String orgNumber = kingdeeApBillSyncSupport.bfiOrgNumber(contract);
String orgNumber = kingdeeApBillSyncSupport.bfiOrgNumber(contract, context);
// 币种取合同结算币种
String currency = kingdeeApBillSyncSupport.bfiCurrency(contract);
// 单据日期:yyyy-MM-dd HH:mm:ss(各接口统一格式),以同步当日作为应收单据日期
@@ -193,8 +220,8 @@ public class KingdeeArBillSyncSupport {
param.setRecorgNumber(orgNumber);
// 【必填】单据日期
param.setBizdate(billDateText);
// 【必填】往来类型:应收固定客户
param.setAsstacttype(KingdeeBfiConstant.BFI_CUSTOMER_ASSTACT_TYPE);
// 【必填】往来类型:统一固定客户(KingdeeApBillSyncSupport#bfiAsstactType,与应付单/付款申请同口径)
param.setAsstacttype(kingdeeApBillSyncSupport.bfiAsstactType(contract));
// 【必填】往来户.编码:结算单付款方(客户)
param.setAsstactNumber(customerCode);
// 【必填】付款方式(CASH:现销, CREDIT:赊销),现销由金蝶自动推收款
@@ -183,10 +183,10 @@ public class KingdeeReceivedInvoiceSyncSupport {
param.setInvoicetypef7Number(mirror.getInvoiceTypeCode());
// 【必填】付款方式:(与应付单一致,不允许金蝶自动推付款)
param.setPaymenttype(BfiConstant.PAY_MODE_CASH);
// 【必填】往来类型:进项收票固定供应商
param.setAsstacttype(BfiConstant.ASSTACT_TYPE_SUPPLIER);
// 【必填】结算组织.编码:合同所属组织(与应付单同源)
param.setOrgNumber(kingdeeApBillSyncSupport.bfiOrgNumber(contract));
// 【必填】往来类型:统一固定客户(KingdeeApBillSyncSupport#bfiAsstactType,与应付单/付款申请同口径)
param.setAsstacttype(kingdeeApBillSyncSupport.bfiAsstactType(contract));
// 【必填】结算组织.编码:合同关联项目业务部门反查平台公司(与应付单同源)
param.setOrgNumber(kingdeeApBillSyncSupport.bfiOrgNumber(contract, context));
// 【必填】结算币别:合同结算币种
param.setCurrencyNumber(kingdeeApBillSyncSupport.bfiCurrency(contract));
// 【必填】往来户.编码:合同乙方(开票供应商)客商编码
@@ -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);
}
}
@@ -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();
}
}
@@ -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) {
@@ -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);
}
@@ -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);
}
}
}
@@ -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"));
@@ -0,0 +1,59 @@
/**
* 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.task;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.transport.service.ISettlementRuleAutoGenerateService;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 合同结算规则定时生成结算单。
* <p>
* 每天零点执行:预结算或正式结算配置开启自动生成时,按账单开始日期和结算周期归集已关闭账期的运单明细。
*
* @author Chill
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SettlementRuleAutoGenerateTask {
private final ISettlementRuleAutoGenerateService settlementRuleAutoGenerateService;
/** 每天 00:00 生成到期的预结算单、正式结算单 */
@Scheduled(cron = "${blade.settlement.auto-generate-cron:0 0 0 * * ?}")
public void generateDueSettlements() {
try {
String summary = settlementRuleAutoGenerateService.generateDueSettlements();
log.info("定时自动生成结算单:{}", summary);
} catch (Exception exception) {
log.error("定时自动生成结算单异常", exception);
}
}
}
@@ -45,6 +45,7 @@ public class FormalSettlementWrapper extends BaseEntityWrapper<FormalSettlement,
case "voided" -> "已作废";
default -> entity.getApprovalStatus();
});
vo.setAdjustStatusName("adjusted".equals(entity.getAdjustStatus()) ? "已调整" : null);
return vo;
}
}
@@ -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;