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:
+6
@@ -309,6 +309,12 @@ public class BfiProperties {
|
||||
*/
|
||||
private String invoiceTypes = "";
|
||||
|
||||
/**
|
||||
* 客商单位过滤开关:开启后只同步开票单位(销方)与受票单位(购方)税号均已登记在
|
||||
* 客商发票信息(blade_customer_invoice_info)中的发票,前端票池展示查询同用此开关过滤,默认关闭
|
||||
*/
|
||||
private boolean customerUnitsOnly = false;
|
||||
|
||||
/**
|
||||
* 同步 single-flight 锁租约秒数,须大于最坏一轮耗时;进程异常退出时锁在租约到期后自动释放
|
||||
*/
|
||||
|
||||
+3
@@ -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";
|
||||
|
||||
|
||||
+12
@@ -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";
|
||||
|
||||
|
||||
+7
-4
@@ -61,8 +61,9 @@ public class BillLedgerController extends BladeController {
|
||||
@ApiOperationSupport(order = 4)
|
||||
@Operation(summary = "付款申请可用汇票")
|
||||
public R<List<BillLedgerVO>> availableOptions(@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) Long deptId, @RequestParam(required = false) Long selectedId) {
|
||||
return R.data(billLedgerService.availableOptions(keyword, deptId, selectedId));
|
||||
@RequestParam(required = false) Long deptId, @RequestParam(required = false) Long selectedId,
|
||||
@RequestParam(required = false) String payerName, @RequestParam(required = false) String payeeName) {
|
||||
return R.data(billLedgerService.availableOptions(keyword, deptId, selectedId, payerName, payeeName));
|
||||
}
|
||||
|
||||
@GetMapping("/available-page")
|
||||
@@ -70,8 +71,10 @@ public class BillLedgerController extends BladeController {
|
||||
@Operation(summary = "付款申请可用汇票分页")
|
||||
public R<IPage<BillLedgerVO>> availablePage(Query pageQuery,
|
||||
@RequestParam(required = false) String keyword, @RequestParam(required = false) Long deptId,
|
||||
@RequestParam(required = false) Long selectedId) {
|
||||
return R.data(billLedgerService.availablePage(Condition.getPage(pageQuery), keyword, deptId, selectedId));
|
||||
@RequestParam(required = false) Long selectedId, @RequestParam(required = false) String payerName,
|
||||
@RequestParam(required = false) String payeeName) {
|
||||
return R.data(billLedgerService.availablePage(Condition.getPage(pageQuery), keyword, deptId, selectedId,
|
||||
payerName, payeeName));
|
||||
}
|
||||
|
||||
@PostMapping("/submit")
|
||||
|
||||
+59
@@ -105,6 +105,65 @@ public class DriverWaybillController extends BladeController {
|
||||
return R.data(driverWaybillService.detail(id));
|
||||
}
|
||||
|
||||
@GetMapping("/task/current")
|
||||
@ApiOperationSupport(order = 21)
|
||||
@Operation(summary = "首页:当前运输中运次", description = "当前用户手机号可见的运输中运次(有配载单的一趟车算一个)最新一条")
|
||||
public R<DriverWaybillCardVO> tripCurrentTask() {
|
||||
return R.data(driverWaybillService.tripCurrentTask());
|
||||
}
|
||||
|
||||
@GetMapping("/task/pending-preview")
|
||||
@ApiOperationSupport(order = 22)
|
||||
@Operation(summary = "首页:待接运次预览", description = "当前用户手机号可见的待接运次预览与总数(按运次计)")
|
||||
public R<DriverWaybillPreviewVO> tripPendingPreview(
|
||||
@Parameter(description = "预览条数,默认 2") @RequestParam(required = false) Integer size) {
|
||||
return R.data(driverWaybillService.tripPendingPreview(size));
|
||||
}
|
||||
|
||||
@GetMapping("/task/counts")
|
||||
@ApiOperationSupport(order = 23)
|
||||
@Operation(summary = "运次 Tab 统计", description = "按运次统计:全部 = 待接单 + 进行中 + 已完成(不含已取消)")
|
||||
public R<DriverWaybillTabCountsVO> tripCounts() {
|
||||
return R.data(driverWaybillService.tripTabCounts());
|
||||
}
|
||||
|
||||
@GetMapping("/task/page")
|
||||
@ApiOperationSupport(order = 24)
|
||||
@Operation(summary = "运次分页列表", description = "一页即若干个运次;status:空=全部,0待接单,1运输中,2已完成;keyword 支持运单号/配载单号/起终点/车牌")
|
||||
public R<IPage<DriverWaybillCardVO>> tripPage(
|
||||
@Parameter(description = "当前页") @RequestParam(required = false) Integer current,
|
||||
@Parameter(description = "每页运次数") @RequestParam(required = false) Integer size,
|
||||
@Parameter(description = "状态:0待接单/1运输中/2已完成,不传为全部") @RequestParam(required = false) String status,
|
||||
@Parameter(description = "关键字:运单号/配载单号/起终点/车牌") @RequestParam(required = false) String keyword) {
|
||||
Integer statusCode = parseStatus(status);
|
||||
return R.data(driverWaybillService.tripPage(current, size, statusCode, keyword));
|
||||
}
|
||||
|
||||
@GetMapping("/task/detail")
|
||||
@ApiOperationSupport(order = 25)
|
||||
@Operation(summary = "司机运次详情", description = "按运次主键(字符串)取整趟车;有配载单时下发站点 ①②③(stations)与每站货物条目")
|
||||
public R<DriverWaybillCardVO> tripDetail(
|
||||
@Parameter(description = "运次主键(配载单主键或运单主键)", required = true) @RequestParam String id) {
|
||||
return R.data(driverWaybillService.tripDetail(id));
|
||||
}
|
||||
|
||||
@PostMapping("/task/accept")
|
||||
@ApiOperationSupport(order = 26)
|
||||
@Operation(summary = "运次确认接单", description = "一次接单把该运次全部子运单置为已接单;任一子单已接单即视为整单已接单")
|
||||
public R tripAccept(
|
||||
@Parameter(description = "运次主键(配载单主键或运单主键)", required = true) @RequestParam String id) {
|
||||
return R.status(driverWaybillService.tripAccept(id));
|
||||
}
|
||||
|
||||
@PostMapping("/task/reject")
|
||||
@ApiOperationSupport(order = 27)
|
||||
@Operation(summary = "运次拒绝接单", description = "整单拒绝:该运次全部未终态子运单一起置为已拒绝,此后该运次对本人不可见")
|
||||
public R tripReject(
|
||||
@Parameter(description = "运次主键(配载单主键或运单主键)", required = true) @RequestParam String id,
|
||||
@Parameter(description = "拒绝原因") @RequestParam(required = false) String reason) {
|
||||
return R.status(driverWaybillService.tripReject(id, reason));
|
||||
}
|
||||
|
||||
@PostMapping("/track")
|
||||
@ApiOperationSupport(order = 11)
|
||||
@Operation(summary = "司机端行驶轨迹", description = "按当前用户手机号校验归属后,根据打卡记录/计划时间自动推导起止日期并查询 LBS 轨迹(WGS-84)")
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.controller;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.tenant.annotation.TenantIgnore;
|
||||
import org.springblade.core.tool.api.R;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* HCS 合规回调 Mock 端点
|
||||
* <p>
|
||||
* 合同法务审批结果回调(D103)、付款法务审批结果回调(D104)、项目金蝶同步结果回调(D902)、
|
||||
* 合同金蝶同步结果回传接收(D903)四个入站回调的纯 mock 实现:仅校验来源系统标识后返回固定结果,
|
||||
* 不读写业务数据、不联动状态。真实处理逻辑保留在对应 Support 中,联调时把端点切回
|
||||
* {@link } 并调用 Support 方法即可。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@TenantIgnore
|
||||
@RequestMapping("hcs")
|
||||
@Tag(name = "HCS合规回调Mock", description = "HCS合规回调Mock端点(仅自测,返回固定 mock 数据)")
|
||||
public class HcsMockController {
|
||||
|
||||
/**
|
||||
* 来源系统标识(合规方约定值,与 third-party.hcs.src-system 默认值一致)
|
||||
*/
|
||||
private static final String SOURCE_SYSTEM = "WLTMS";
|
||||
|
||||
/**
|
||||
* 合同法务审批结果回调(D103)
|
||||
*/
|
||||
@PostMapping("/contractLegal/result")
|
||||
@ApiOperationSupport(order = 15)
|
||||
@Operation(summary = "合同法务审批结果回调Mock", description = "D103 合规推送合同特批(金蝶同步)结果,仅校验来源系统并返回 mock 数据")
|
||||
public R<String> contractLegalResult(@RequestBody(required = false) Map<String, Object> param) {
|
||||
this.checkSourceSystem("合同法务审批结果", param);
|
||||
return R.data("mock特批结果已处理:approved");
|
||||
}
|
||||
|
||||
/**
|
||||
* 付款法务审批结果回调(D104)
|
||||
*/
|
||||
@PostMapping("/paymentLegal/result")
|
||||
@ApiOperationSupport(order = 16)
|
||||
@Operation(summary = "付款法务审批结果回调Mock", description = "D104 合规推送付款特批结果,仅校验来源系统并返回 mock 数据")
|
||||
public R<String> paymentLegalResult(@RequestBody(required = false) Map<String, Object> param) {
|
||||
this.checkSourceSystem("付款法务审批结果", param);
|
||||
return R.data("mock付款特批结果接收已处理:approved");
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目金蝶同步结果回调(D902)
|
||||
*/
|
||||
@PostMapping("/projectLegal/result")
|
||||
@ApiOperationSupport(order = 18)
|
||||
@Operation(summary = "项目金蝶同步结果回调Mock", description = "D902 合规推送项目金蝶同步结果,仅校验来源系统并返回 mock 数据")
|
||||
public R<String> projectLegalResult(@RequestBody(required = false) Map<String, Object> param) {
|
||||
this.checkSourceSystem("项目金蝶同步结果", param);
|
||||
return R.data("mock项目金蝶同步结果接收已处理:approved");
|
||||
}
|
||||
|
||||
/**
|
||||
* 合同金蝶同步结果回传接收(D903)
|
||||
*/
|
||||
@PostMapping("/contractKingdee/result")
|
||||
@ApiOperationSupport(order = 19)
|
||||
@Operation(summary = "合同金蝶同步结果回传接收Mock", description = "D903 合规推送合同金蝶最终同步结果,仅校验来源系统并返回 mock 数据")
|
||||
public R<String> contractKingdeeResult(@RequestBody(required = false) Map<String, Object> param) {
|
||||
this.checkSourceSystem("合同金蝶同步结果", param);
|
||||
return R.data("mock合同金蝶同步结果接收处理:approved");
|
||||
}
|
||||
|
||||
/**
|
||||
* 来源系统校验:仅接受合规方约定的来源标识,防伪造回传
|
||||
*
|
||||
* @param bizName 业务名称(日志用)
|
||||
* @param param 回调报文
|
||||
*/
|
||||
private void checkSourceSystem(String bizName, Map<String, Object> param) {
|
||||
String sourceSystem = param == null ? null : Objects.toString(param.get("sourceSystem"), null);
|
||||
if (!StrUtil.equalsIgnoreCase(sourceSystem, SOURCE_SYSTEM)) {
|
||||
log.warn("{}推送来源系统标识错误:sourceSystem={}", bizName, sourceSystem);
|
||||
throw new ServiceException("来源系统标识错误");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -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")
|
||||
|
||||
+14
-3
@@ -39,6 +39,7 @@ import org.springblade.transport.pojo.vo.AdminVehicleOptionVO;
|
||||
import org.springblade.transport.pojo.vo.AdminWaybillCardVO;
|
||||
import org.springblade.transport.pojo.vo.AdminWaybillDetailVO;
|
||||
import org.springblade.transport.pojo.vo.WaybillPunchRecordsVO;
|
||||
import org.springblade.transport.pojo.vo.WaybillTrackVO;
|
||||
import org.springblade.transport.service.IManageWaybillService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -69,7 +70,7 @@ public class ManageWaybillController extends BladeController {
|
||||
|
||||
@GetMapping("/stats")
|
||||
@ApiOperationSupport(order = 1)
|
||||
@Operation(summary = "运单状态统计", description = "待接单=pending,运输中=running,已完成=completed;租户内不过滤组织(小程序调度账号组织常与运单不一致);在途异常=异常处置状态≠已完成")
|
||||
@Operation(summary = "运单状态统计", description = "待接单=pending,运输中=running,已完成=completed;只统计本组织的运单(超级管理员不过滤组织);在途异常=异常处置状态≠已完成")
|
||||
public R<AdminHomeStatsVO> stats() {
|
||||
return R.data(manageWaybillService.stats());
|
||||
}
|
||||
@@ -99,9 +100,9 @@ public class ManageWaybillController extends BladeController {
|
||||
|
||||
@GetMapping("/detail")
|
||||
@ApiOperationSupport(order = 4)
|
||||
@Operation(summary = "运单详情", description = "调度端查看运单详情(含 punchNodes / enrouteRecords),不校验司机归属与组织;字段对齐小程序 pages/waybill/detail")
|
||||
@Operation(summary = "运次详情", description = "调度端查看整趟车详情(含 stations / punchNodes / enrouteRecords),不校验司机归属与组织;id 传运次主键字符串,已拒单的运单主键回落成单票视图")
|
||||
public R<AdminWaybillDetailVO> detail(
|
||||
@Parameter(description = "运单ID", required = true) @RequestParam Long id) {
|
||||
@Parameter(description = "运次主键(配载单主键或运单主键,字符串)", required = true) @RequestParam String id) {
|
||||
return R.data(manageWaybillService.detail(id));
|
||||
}
|
||||
|
||||
@@ -159,4 +160,14 @@ public class ManageWaybillController extends BladeController {
|
||||
return R.status(waybillService.savePunchRecord(request));
|
||||
}
|
||||
|
||||
@PostMapping("/track")
|
||||
@ApiOperationSupport(order = 11)
|
||||
@Operation(summary = "车辆历史轨迹", description = "小程序调度端:仅需登录态,按运单绑定车牌和日期区间查询 LBS 轨迹")
|
||||
public R<WaybillTrackVO> track(
|
||||
@Parameter(description = "运单ID", required = true) @RequestParam Long id,
|
||||
@Parameter(description = "开始日期 YYYY-MM-DD", required = true) @RequestParam String startDate,
|
||||
@Parameter(description = "结束日期 YYYY-MM-DD", required = true) @RequestParam String endDate) {
|
||||
return R.data(waybillService.trackVehicle(id, startDate, endDate));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+12
@@ -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 = "保存付款申请")
|
||||
|
||||
+7
@@ -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 = "费用类型及费用项")
|
||||
|
||||
+16
-1
@@ -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)); }
|
||||
}
|
||||
|
||||
+162
@@ -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));
|
||||
}
|
||||
}
|
||||
+7
@@ -373,6 +373,13 @@ public class WaybillController extends BladeController {
|
||||
return R.data(waybillService.batchComplete(ids));
|
||||
}
|
||||
|
||||
@PostMapping("/confirm-return")
|
||||
@ApiOperationSupport(order = 24)
|
||||
@Operation(summary = "确认回单", description = "回单节点要求业务员确认时用:该运单的司机打卡节点全部已打卡且无未了结异常,才置为已完成")
|
||||
public R confirmReturn(@Parameter(description = "主键", required = true) @RequestParam Long id) {
|
||||
return R.status(waybillService.confirmReturnReceipt(id));
|
||||
}
|
||||
|
||||
@PostMapping("/road-loading")
|
||||
@ApiOperationSupport(order = 23)
|
||||
@Operation(summary = "公路配载", description = "传入ids")
|
||||
|
||||
+496
@@ -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;
|
||||
}
|
||||
}
|
||||
+118
@@ -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;
|
||||
}
|
||||
+202
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
|
||||
+1
-2
@@ -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
|
||||
|
||||
+9
@@ -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);
|
||||
}
|
||||
|
||||
+11
@@ -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);
|
||||
}
|
||||
|
||||
+11
@@ -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);
|
||||
}
|
||||
|
||||
+6
-4
@@ -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);
|
||||
}
|
||||
|
||||
+12
@@ -39,12 +39,24 @@
|
||||
AND p.available_amount > 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>
|
||||
|
||||
+11
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
+18
@@ -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);
|
||||
}
|
||||
|
||||
+2
-1
@@ -102,7 +102,8 @@
|
||||
FROM blade_receipt_claim c
|
||||
INNER JOIN blade_kingdee_receipt_flow f ON f.id = c.receipt_flow_id
|
||||
WHERE <include refid="claimRecordConditions"/>
|
||||
ORDER BY c.create_time DESC
|
||||
<!-- 认领时间倒序:存量认领记录的 create_time 为空,排序改以认领日期为主、id 兜同日先后 -->
|
||||
ORDER BY c.claim_date DESC, c.id DESC
|
||||
</select>
|
||||
|
||||
<select id="selectClaimRecordDetail" resultType="org.springblade.transport.pojo.vo.ReceiptClaimRecordVO">
|
||||
|
||||
+13
-1
@@ -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);
|
||||
}
|
||||
|
||||
+50
@@ -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);
|
||||
}
|
||||
+51
@@ -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);
|
||||
|
||||
}
|
||||
+47
@@ -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, '') <> 'voided')
|
||||
OR (w.settlement_type = 'formal' AND f.id IS NOT NULL AND IFNULL(f.approval_status, '') <> '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>
|
||||
+9
@@ -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);
|
||||
}
|
||||
|
||||
+33
-8
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+75
-28
@@ -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();
|
||||
|
||||
+3
-2
@@ -18,8 +18,9 @@ public interface IBillLedgerService extends BaseService<BillLedger> {
|
||||
IPage<BillLedgerVO> selectPage(IPage<BillLedger> page, BillLedgerVO query);
|
||||
BillLedgerVO detail(Long id);
|
||||
Map<String, Long> expiryCounts();
|
||||
List<BillLedgerVO> availableOptions(String keyword, Long deptId, Long selectedId);
|
||||
IPage<BillLedgerVO> availablePage(IPage<BillLedger> page, String keyword, Long deptId, Long selectedId);
|
||||
List<BillLedgerVO> availableOptions(String keyword, Long deptId, Long selectedId, String payerName, String payeeName);
|
||||
IPage<BillLedgerVO> availablePage(IPage<BillLedger> page, String keyword, Long deptId, Long selectedId,
|
||||
String payerName, String payeeName);
|
||||
Long submit(BillLedgerSaveRequest request);
|
||||
void removeLedger(Long id);
|
||||
}
|
||||
|
||||
+49
-2
@@ -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);
|
||||
|
||||
+27
@@ -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与「原明细/原费项 -> 新明细/新费项」id映射(供调整明细应用到新单)
|
||||
*/
|
||||
CloneResult cloneFormalSettlement(Long sourceSettlementId);
|
||||
|
||||
/**
|
||||
* 结算调整重推收尾:原单标记「已调整」、旧锁解锁、追加重推变更记录(不改原单业务数据)
|
||||
*
|
||||
* @param settlementId 原正式结算单id
|
||||
* @param adjustmentNo 结算调整单号
|
||||
* @param newSettlementNo 重推生成的新单号
|
||||
*/
|
||||
void markSettlementAdjusted(Long settlementId, String adjustmentNo, String newSettlementNo);
|
||||
|
||||
/**
|
||||
* 克隆结果:新单id + 原明细id->新明细id + 原费项id->新费项id
|
||||
*/
|
||||
record CloneResult(Long settlementId, Map<Long, Long> detailIdMap, Map<Long, Long> feeIdMap) {
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+40
-2
@@ -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 -> 最新 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 <T> 实现 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:单次一个源单号对一个金蝶单号,单张成败互不影响;定时任务定时触发,亦可人工触发);
|
||||
|
||||
+5
-2
@@ -63,9 +63,12 @@ public interface IManageWaybillService {
|
||||
String exception, String transportType, String startDate, String endDate);
|
||||
|
||||
/**
|
||||
* 调度端运单详情(不校验司机归属)
|
||||
* 调度端运次详情(不校验司机归属)
|
||||
* <p>
|
||||
* 传运次主键:配载单主键或未配载运单主键,都按字符串。已拒单的子运单按运次取不到(运次口径
|
||||
* 不含已拒子单),此时按运单主键回落成单票视图,供重新派单等从运单进入的场景使用。
|
||||
*/
|
||||
AdminWaybillDetailVO detail(Long id);
|
||||
AdminWaybillDetailVO detail(String tripId);
|
||||
|
||||
/**
|
||||
* 待处理运单(待接单 / 运输中;可筛需重新派单)
|
||||
|
||||
+6
@@ -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);
|
||||
|
||||
+5
@@ -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);
|
||||
}
|
||||
|
||||
+15
@@ -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);
|
||||
}
|
||||
|
||||
+77
@@ -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);
|
||||
}
|
||||
+70
@@ -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);
|
||||
|
||||
}
|
||||
+42
@@ -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();
|
||||
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import org.springblade.transport.pojo.vo.TripQueryVO;
|
||||
import org.springblade.transport.pojo.vo.TripTabCountsVO;
|
||||
import org.springblade.transport.pojo.vo.TripVO;
|
||||
|
||||
/**
|
||||
* 运次读服务
|
||||
* <p>
|
||||
* 「一卡一运次」的取数、状态聚合、接单需求合并与分页只在这里实现一次,司机端与管理端列表都调它,
|
||||
* 各自只做字段裁剪。聚合全程只读,不在读路径上写业务状态——分档只按过程配置与接单态推导,
|
||||
* 不按库里的业务状态,也不借读路径做惰性校正。
|
||||
*/
|
||||
public interface ITripQueryService {
|
||||
|
||||
/**
|
||||
* 运次分页:一页就是若干个运次(有配载单的一趟车算一个)。
|
||||
* <p>
|
||||
* 库层只取回收口与行级筛选命中的运单行,分组、分档、筛选、排序与分页都在服务层算完再做。
|
||||
*
|
||||
* @param query 查询条件,收口由 driverPhone 或 allDrivers 给出
|
||||
* @return 运次分页
|
||||
*/
|
||||
IPage<TripVO> page(TripQueryVO query);
|
||||
|
||||
/**
|
||||
* 运次 Tab 统计:全部 / 待接单 / 进行中 / 已完成,分档口径与 page 完全一致
|
||||
*
|
||||
* @param query 查询条件,口径同 page
|
||||
* @return 运次计数
|
||||
*/
|
||||
TripTabCountsVO tabCounts(TripQueryVO query);
|
||||
|
||||
/**
|
||||
* 按运次主键取单个运次(含子运单),主键为字符串
|
||||
*
|
||||
* @param query 查询条件,tripId 必填
|
||||
* @return 运次
|
||||
*/
|
||||
TripVO detail(TripQueryVO query);
|
||||
|
||||
}
|
||||
+8
@@ -113,6 +113,14 @@ public interface IWaybillService extends BaseService<Waybill> {
|
||||
*/
|
||||
boolean completeWithoutDeptCheck(Long id);
|
||||
|
||||
/**
|
||||
* 确认回单:回单节点配成「需业务员确认」时,内部人员确认后把运单置为已完成。
|
||||
* <p>
|
||||
* 比 {@link #complete(Long)} 多两道校验:该运单的司机打卡节点(在途除外)必须全部已打卡,
|
||||
* 且没有未了结异常——不挡住就等于借这个入口绕过打卡门禁与异常门禁。
|
||||
*/
|
||||
boolean confirmReturnReceipt(Long id);
|
||||
|
||||
BusinessRemoveResultVO batchComplete(String ids);
|
||||
LoadingManageVO roadLoading(String ids);
|
||||
|
||||
|
||||
+2
-2
@@ -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<>());
|
||||
}
|
||||
}
|
||||
|
||||
+42
-8
@@ -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));
|
||||
|
||||
+3
-2
@@ -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) {
|
||||
|
||||
+4
@@ -354,6 +354,10 @@ public class CertAuditServiceImpl implements ICertAuditService {
|
||||
requireIdCardNotTaken(snapshot.getIdCardNo(), driver.getId());
|
||||
driver.setDriverName(snapshot.getRealName());
|
||||
driver.setIdCardNo(snapshot.getIdCardNo());
|
||||
driver.setBirthday(snapshot.getBirthday());
|
||||
// 空串不是「没填」:框架的 not_null 策略只跳过 null,写空串会把管理端填过的住址清掉
|
||||
driver.setAddressRegion(blankToNull(snapshot.getAddressRegion()));
|
||||
driver.setAddress(blankToNull(snapshot.getAddress()));
|
||||
driver.setIdCardFront(snapshot.getIdCardFrontUrl());
|
||||
driver.setIdCardBack(snapshot.getIdCardBackUrl());
|
||||
driver.setIdCardValidStart(snapshot.getIdCardValidStart());
|
||||
|
||||
+23
-2
@@ -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) {
|
||||
|
||||
+43
-6
@@ -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());
|
||||
|
||||
+36
-7
@@ -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);
|
||||
|
||||
+19
@@ -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;
|
||||
}
|
||||
|
||||
+630
-145
File diff suppressed because it is too large
Load Diff
+11
@@ -144,6 +144,17 @@ public class ExceptionDisposalServiceImpl
|
||||
// 整单异常同样受这条约束:没有节点不等于没有上报人。
|
||||
driverWaybillService.assertCurrentDriverOwnsWaybill(disposal.getWaybillId());
|
||||
|
||||
// 上报门禁之零:运单得先在路上,且不能已经结束。
|
||||
// 判据与司机打卡同一来源;归属校验会顺手把「无需接单」的待执行运单校正为进行中,
|
||||
// 所以状态要在它之后重新读,不能用 fillFromWaybill 里校正前的那份对象。
|
||||
Waybill reportWaybill = waybillService.getById(disposal.getWaybillId());
|
||||
String blockedReason = reportWaybill == null
|
||||
? null
|
||||
: WaybillProcessSupport.reportBlockedReason(reportWaybill.getBusinessStatus());
|
||||
if (blockedReason != null) {
|
||||
throw new ServiceException(blockedReason);
|
||||
}
|
||||
|
||||
// 节点可以为空:全局入口(首页快捷格、打卡页底部按钮)上报的异常不指定环节,
|
||||
// 它无从定位阻断区间,由门禁按「阻断该运单全部未打卡节点」处理。
|
||||
// 节点非空时才要求它确属该运单;节点名取自过程配置而非请求体,避免落进一个该节点并不叫的名字。
|
||||
|
||||
+620
-47
File diff suppressed because it is too large
Load Diff
+62
-35
@@ -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) {
|
||||
|
||||
+98
-17
@@ -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) {
|
||||
}
|
||||
|
||||
+91
-20
@@ -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:单次一个源单号对一个金蝶单号,单张成败互不影响,由定时任务定时触发,亦可人工触发)。
|
||||
|
||||
+166
-33
@@ -28,7 +28,10 @@ import org.springblade.transport.pojo.vo.LoadingCarrierContractVO;
|
||||
import org.springblade.transport.pojo.vo.LoadingManageVO;
|
||||
import org.springblade.transport.service.IContractManageService;
|
||||
import org.springblade.transport.service.ILoadingManageService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.IReceivablePayableDetailService;
|
||||
import org.springblade.transport.support.LiveProcessConfigSupport;
|
||||
import org.springblade.transport.support.LoadableAdmissionSupport;
|
||||
import org.springblade.transport.support.TransportBusinessSupport;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
import org.springblade.transport.wrapper.LoadingManageWrapper;
|
||||
@@ -43,8 +46,10 @@ import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -76,6 +81,9 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
@Lazy
|
||||
private IReceivablePayableDetailService receivablePayableDetailService;
|
||||
|
||||
@Resource
|
||||
private IProcessConfigService processConfigService;
|
||||
|
||||
@Override
|
||||
public IPage<LoadingManageVO> selectLoadingManagePage(IPage<LoadingManage> page, LoadingManageVO loadingManage) {
|
||||
IPage<LoadingManage> entityPage = page(page, buildQuery(loadingManage));
|
||||
@@ -147,9 +155,12 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
prepare(loadingManage);
|
||||
validate(loadingManage, false);
|
||||
List<Long> oldWaybillIds = new ArrayList<>();
|
||||
boolean ownChildrenAdmitted = false;
|
||||
if (Func.isNotEmpty(loadingManage.getId())) {
|
||||
LoadingManage oldRecord = loadEditable(loadingManage.getId(), true);
|
||||
oldWaybillIds = waybillIds(oldRecord.getWaybillIdsJson());
|
||||
// 草稿只预占运单号、未过判据,草稿转正式不得沿用本单豁免
|
||||
ownChildrenAdmitted = !STATUS_DRAFT.equals(oldRecord.getBusinessStatus());
|
||||
loadingManage.setLoadingNo(oldRecord.getLoadingNo());
|
||||
loadingManage.setDeptId(oldRecord.getDeptId());
|
||||
loadingManage.setDeptName(oldRecord.getDeptName());
|
||||
@@ -160,10 +171,10 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
if (Func.isEmpty(loadingManage.getLoadingNo())) {
|
||||
loadingManage.setLoadingNo(nextCode());
|
||||
}
|
||||
validateWaybillsAvailable(loadingManage, waybillIds(loadingManage.getWaybillIdsJson()));
|
||||
validateCarrierContract(loadingManage, true);
|
||||
List<Waybill> waybills = validateWaybillsAvailable(loadingManage, waybillIds(loadingManage.getWaybillIdsJson()), ownChildrenAdmitted);
|
||||
validateCarrierContract(loadingManage, true, waybills);
|
||||
boolean result = saveOrUpdate(loadingManage);
|
||||
syncAssociatedWaybills(loadingManage, STATUS_PENDING, oldWaybillIds);
|
||||
writeWaybillsForDriver(loadingManage, oldWaybillIds, waybills);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -409,11 +420,10 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
loadingManage.setDeptId(oldRecord.getDeptId());
|
||||
loadingManage.setDeptName(oldRecord.getDeptName());
|
||||
loadingManage.setBusinessStatus(STATUS_PENDING);
|
||||
validateWaybillsAvailable(loadingManage, waybillIds(loadingManage.getWaybillIdsJson()));
|
||||
validateCarrierContract(loadingManage, true);
|
||||
List<Waybill> waybills = validateWaybillsAvailable(loadingManage, waybillIds(loadingManage.getWaybillIdsJson()), true);
|
||||
validateCarrierContract(loadingManage, true, waybills);
|
||||
boolean result = updateById(loadingManage);
|
||||
syncAssociatedWaybills(loadingManage, STATUS_PENDING, oldWaybillIds);
|
||||
clearAssociatedWaybillAcceptRecords(loadingManage);
|
||||
writeWaybillsForDriver(loadingManage, oldWaybillIds, waybills);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -735,29 +745,64 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
TransportBusinessSupport.validateLength(loadingManage.getTaskRemark(), 500, "任务备注不能超过500字");
|
||||
}
|
||||
|
||||
private void validateWaybillsAvailable(LoadingManage loadingManage, List<Long> waybillIds) {
|
||||
private List<Waybill> validateWaybillsAvailable(LoadingManage loadingManage, List<Long> waybillIds, boolean ownChildrenAdmitted) {
|
||||
if (Func.isEmpty(waybillIds)) {
|
||||
throw new ServiceException("请选择待配载运单");
|
||||
}
|
||||
List<Waybill> waybills = waybillMapper.selectList(Wrappers.<Waybill>lambdaQuery()
|
||||
.eq(Waybill::getIsDeleted, 0)
|
||||
.in(Waybill::getId, waybillIds));
|
||||
List<Waybill> waybills = loadWaybills(waybillIds);
|
||||
if (waybills.size() != waybillIds.size()) {
|
||||
throw new ServiceException("待配载运单不存在或已删除");
|
||||
}
|
||||
String ownLoadingNo = loadingManage.getLoadingNo();
|
||||
for (Waybill waybill : waybills) {
|
||||
TransportBusinessSupport.assertCurrentDept(waybill.getDeptId(), "运单管理");
|
||||
if (Func.isNotEmpty(waybill.getLoadingNo()) && !Objects.equals(waybill.getLoadingNo(), loadingManage.getLoadingNo())) {
|
||||
throw new ServiceException("运单" + waybill.getWaybillNo() + "已关联其他配载单");
|
||||
if (ownChildrenAdmitted && LoadableAdmissionSupport.isOwnChild(waybill, ownLoadingNo)) {
|
||||
continue;
|
||||
}
|
||||
if ((Objects.equals(waybill.getBusinessStatus(), STATUS_COMPLETED) || Objects.equals(waybill.getBusinessStatus(), STATUS_CANCELLED))
|
||||
&& !Objects.equals(waybill.getLoadingNo(), loadingManage.getLoadingNo())) {
|
||||
throw new ServiceException("运单" + waybill.getWaybillNo() + "当前状态不允许配载");
|
||||
LoadableAdmissionSupport.UnloadableReason reason =
|
||||
LoadableAdmissionSupport.unloadableReason(waybill, ownLoadingNo);
|
||||
if (reason != null) {
|
||||
throw new ServiceException(unloadableMessage(waybill, reason));
|
||||
}
|
||||
}
|
||||
validateSameProject(waybills);
|
||||
return waybills;
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐张准入失败的可读原因:判据只给原因枚举,运单号与文案在这里拼。
|
||||
*/
|
||||
private String unloadableMessage(Waybill waybill, LoadableAdmissionSupport.UnloadableReason reason) {
|
||||
String prefix = "运单" + waybill.getWaybillNo();
|
||||
return prefix + switch (reason) {
|
||||
case NOT_ROAD -> "运输方式不是公路,不可配载";
|
||||
case NOT_PENDING -> "当前状态不是待执行,不可配载";
|
||||
case ON_OTHER_MANIFEST -> "已关联其他配载单";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 一张配载单的全部子运单必须属于同一个项目,否则点名与其它子运单项目不同的运单。
|
||||
* <p>
|
||||
* 查询条件表达不了跨行约束,只能整批取回后在内存里比。
|
||||
*/
|
||||
private void validateSameProject(List<Waybill> waybills) {
|
||||
Long projectId = waybills.get(0).getProjectId();
|
||||
List<String> others = waybills.stream()
|
||||
.filter(waybill -> !Objects.equals(waybill.getProjectId(), projectId))
|
||||
.map(Waybill::getWaybillNo)
|
||||
.toList();
|
||||
if (Func.isNotEmpty(others)) {
|
||||
throw new ServiceException("配载单的子运单必须属于同一项目,运单"
|
||||
+ String.join("、", others) + "与其它子运单不属于同一项目");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateCarrierContract(LoadingManage loadingManage, boolean required) {
|
||||
validateCarrierContract(loadingManage, required, null);
|
||||
}
|
||||
|
||||
private void validateCarrierContract(LoadingManage loadingManage, boolean required, List<Waybill> waybills) {
|
||||
if (!Objects.equals(loadingManage.getCarrierType(), "承运商")) {
|
||||
loadingManage.setCarrierContractId(null);
|
||||
if (!Objects.equals(loadingManage.getCarrierType(), CARRIER_SELF)) {
|
||||
@@ -772,10 +817,24 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
loadingManage.setCarrierName(null);
|
||||
return;
|
||||
}
|
||||
ContractManage carrierContract = availableCarrierContracts(projectIdsByWaybills(loadingManage)).stream()
|
||||
.filter(contract -> Objects.equals(contract.getId(), loadingManage.getCarrierContractId()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new ServiceException("所选承运商合同不存在、未审核通过或已失效"));
|
||||
ContractManage carrierContract = availableCarrierContractById(loadingManage.getCarrierContractId());
|
||||
if (carrierContract == null) {
|
||||
throw new ServiceException("所选承运商合同不存在、未审核通过或已失效");
|
||||
}
|
||||
List<Waybill> childWaybills = waybills == null
|
||||
? loadWaybills(waybillIds(loadingManage.getWaybillIdsJson()))
|
||||
: waybills;
|
||||
// 合同自带项目与乙方:合同的项目不属于本单子运单的项目,等于换了项目却没换乙方
|
||||
if (!projectIdsOf(childWaybills).contains(carrierContract.getProjectId())) {
|
||||
String waybillNos = childWaybills.stream()
|
||||
.filter(waybill -> !Objects.equals(waybill.getProjectId(), carrierContract.getProjectId()))
|
||||
.map(Waybill::getWaybillNo)
|
||||
.filter(Func::isNotEmpty)
|
||||
.collect(Collectors.joining("、"));
|
||||
throw new ServiceException(Func.isEmpty(waybillNos)
|
||||
? "所选承运商合同不属于本项目"
|
||||
: "所选承运商合同不属于运单" + waybillNos + "的项目");
|
||||
}
|
||||
loadingManage.setCarrierName(carrierContract.getPartyB());
|
||||
}
|
||||
|
||||
@@ -792,34 +851,98 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
}
|
||||
}
|
||||
|
||||
private List<Long> projectIdsByWaybills(LoadingManage loadingManage) {
|
||||
List<Long> waybillIds = waybillIds(loadingManage.getWaybillIdsJson());
|
||||
private List<Waybill> loadWaybills(List<Long> waybillIds) {
|
||||
if (Func.isEmpty(waybillIds)) {
|
||||
return List.of();
|
||||
}
|
||||
return waybillMapper.selectList(Wrappers.<Waybill>lambdaQuery()
|
||||
.select(Waybill::getProjectId)
|
||||
.eq(Waybill::getIsDeleted, 0)
|
||||
.in(Waybill::getId, waybillIds))
|
||||
.stream().map(Waybill::getProjectId).filter(Objects::nonNull).distinct().toList();
|
||||
.in(Waybill::getId, waybillIds));
|
||||
}
|
||||
|
||||
private List<Long> projectIdsOf(List<Waybill> waybills) {
|
||||
return waybills.stream().map(Waybill::getProjectId).filter(Objects::nonNull).distinct().toList();
|
||||
}
|
||||
|
||||
private List<ContractManage> availableCarrierContracts(List<Long> projectIds) {
|
||||
if (Func.isEmpty(projectIds)) {
|
||||
return List.of();
|
||||
}
|
||||
return contractManageService.list(Wrappers.<ContractManage>lambdaQuery()
|
||||
.eq(ContractManage::getIsDeleted, 0)
|
||||
.eq(ContractManage::getStatus, 1)
|
||||
.eq(ContractManage::getContractCategory, "承运商合同")
|
||||
return contractManageService.list(availableCarrierContractQuery()
|
||||
.in(ContractManage::getProjectId, projectIds)
|
||||
.in(ContractManage::getApprovalStatus, "approved", "change_approved")
|
||||
.and(wrapper -> wrapper.isNull(ContractManage::getContractStage)
|
||||
.or().ne(ContractManage::getContractStage, "terminated"))
|
||||
.orderByDesc(ContractManage::getCreateTime));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按主键取一张可用的承运商合同。
|
||||
* <p>
|
||||
* 不按项目过滤,才能把「合同不可用」与「合同不属于本项目」分成两种提示。
|
||||
* 取不到可用的合同返回 null。
|
||||
*/
|
||||
private ContractManage availableCarrierContractById(Long contractId) {
|
||||
if (Func.isEmpty(contractId)) {
|
||||
return null;
|
||||
}
|
||||
return contractManageService.list(availableCarrierContractQuery()
|
||||
.eq(ContractManage::getId, contractId)
|
||||
.orderByDesc(ContractManage::getCreateTime))
|
||||
.stream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<ContractManage> availableCarrierContractQuery() {
|
||||
return Wrappers.<ContractManage>lambdaQuery()
|
||||
.eq(ContractManage::getIsDeleted, 0)
|
||||
.eq(ContractManage::getStatus, 1)
|
||||
.eq(ContractManage::getContractCategory, "承运商合同")
|
||||
.in(ContractManage::getApprovalStatus, "approved", "change_approved")
|
||||
.and(wrapper -> wrapper.isNull(ContractManage::getContractStage)
|
||||
.or().ne(ContractManage::getContractStage, "terminated"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 配载提交与重新派单共用:子运单状态逐张按该票自己项目的实时过程配置推导后落库,再清除接单事实。
|
||||
* <p>
|
||||
* 推导按「接单事实已清、尚未接单」的口径取值,故清接单事实放在状态写入之后,两者口径一致。
|
||||
*/
|
||||
private void writeWaybillsForDriver(LoadingManage loadingManage, List<Long> oldWaybillIds, List<Waybill> waybills) {
|
||||
syncAssociatedWaybills(loadingManage, deriveWaybillBusinessStatuses(waybills), oldWaybillIds);
|
||||
// 接单事实不清,被拒的票对新司机不可见——司机端一切入口都排除已拒绝的运单
|
||||
clearAssociatedWaybillAcceptRecords(loadingManage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 子运单业务状态逐张按各自项目的实时过程配置推导:需要接单 → 待执行,不需要 → 进行中。
|
||||
* <p>
|
||||
* 配置取法与运单读路径同一处:项目生效配置优先,取不到时才回退该运单的过程配置快照;
|
||||
* 快照只作兜底、永不优先,否则项目改配置后写入值与读时的归一化结果会分叉。
|
||||
* 项目为空且快照也为空 → 视为不需要接单 → 进行中。
|
||||
*/
|
||||
private Map<Long, String> deriveWaybillBusinessStatuses(List<Waybill> waybills) {
|
||||
Map<Long, String> businessStatusByWaybillId = new LinkedHashMap<>();
|
||||
if (Func.isEmpty(waybills)) {
|
||||
return businessStatusByWaybillId;
|
||||
}
|
||||
Map<Long, String> liveConfigByProject = LiveProcessConfigSupport.byProjects(processConfigService,
|
||||
waybills.stream().map(Waybill::getProjectId).toList());
|
||||
for (Waybill waybill : waybills) {
|
||||
String processJson = LiveProcessConfigSupport.resolve(
|
||||
liveConfigByProject.get(waybill.getProjectId()), waybill.getProcessJson());
|
||||
// 以「待执行 + 待接单」为输入走读路径同一个归一化:需要接单留下待执行,不需要接单提升为进行中
|
||||
businessStatusByWaybillId.put(waybill.getId(), WaybillProcessSupport.normalizeBusinessStatus(
|
||||
STATUS_PENDING, processJson, WaybillProcessSupport.ACCEPT_PENDING));
|
||||
}
|
||||
return businessStatusByWaybillId;
|
||||
}
|
||||
|
||||
private void syncAssociatedWaybills(LoadingManage loadingManage, String status, List<Long> oldWaybillIds) {
|
||||
Map<Long, String> statusByWaybillId = new LinkedHashMap<>();
|
||||
for (Long waybillId : waybillIds(loadingManage.getWaybillIdsJson())) {
|
||||
statusByWaybillId.put(waybillId, status);
|
||||
}
|
||||
syncAssociatedWaybills(loadingManage, statusByWaybillId, oldWaybillIds);
|
||||
}
|
||||
|
||||
private void syncAssociatedWaybills(LoadingManage loadingManage, Map<Long, String> statusByWaybillId, List<Long> oldWaybillIds) {
|
||||
List<Long> newWaybillIds = waybillIds(loadingManage.getWaybillIdsJson());
|
||||
if (Func.isNotEmpty(oldWaybillIds)) {
|
||||
List<Long> removedIds = new ArrayList<>(oldWaybillIds);
|
||||
@@ -829,8 +952,18 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
if (Func.isEmpty(newWaybillIds)) {
|
||||
return;
|
||||
}
|
||||
Map<String, List<Long>> waybillIdsByStatus = new LinkedHashMap<>();
|
||||
for (Long waybillId : newWaybillIds) {
|
||||
waybillIdsByStatus
|
||||
.computeIfAbsent(statusByWaybillId.getOrDefault(waybillId, STATUS_PENDING), key -> new ArrayList<>())
|
||||
.add(waybillId);
|
||||
}
|
||||
waybillIdsByStatus.forEach((status, ids) -> updateAssociatedWaybillFields(loadingManage, ids, status));
|
||||
}
|
||||
|
||||
private void updateAssociatedWaybillFields(LoadingManage loadingManage, List<Long> waybillIds, String status) {
|
||||
waybillMapper.update(null, Wrappers.<Waybill>lambdaUpdate()
|
||||
.in(Waybill::getId, newWaybillIds)
|
||||
.in(Waybill::getId, waybillIds)
|
||||
.set(Waybill::getLoadingNo, loadingManage.getLoadingNo())
|
||||
.set(Waybill::getBusinessStatus, status)
|
||||
.set(Waybill::getCarrierType, loadingManage.getCarrierType())
|
||||
|
||||
+649
-145
@@ -23,16 +23,19 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.utils.DateUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.cache.UserDisplayCache;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.LoadingManage;
|
||||
import org.springblade.transport.pojo.entity.RiskDisposal;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.AdminDriverOptionVO;
|
||||
@@ -44,12 +47,21 @@ import org.springblade.transport.pojo.vo.AdminVehicleOptionVO;
|
||||
import org.springblade.transport.pojo.vo.AdminWaybillCardVO;
|
||||
import org.springblade.transport.pojo.vo.AdminWaybillDetailVO;
|
||||
import org.springblade.transport.pojo.vo.DriverWaybillCardVO;
|
||||
import org.springblade.transport.pojo.vo.TripQueryVO;
|
||||
import org.springblade.transport.pojo.vo.TripStationVO;
|
||||
import org.springblade.transport.pojo.vo.TripTabCountsVO;
|
||||
import org.springblade.transport.pojo.vo.TripVO;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IDriverWaybillService;
|
||||
import org.springblade.transport.service.IExceptionDisposalService;
|
||||
import org.springblade.transport.service.ILoadingManageService;
|
||||
import org.springblade.transport.service.IManageWaybillService;
|
||||
import org.springblade.transport.service.IRiskDisposalService;
|
||||
import org.springblade.transport.service.ITripQueryService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.TransportBusinessSupport;
|
||||
import org.springblade.transport.support.WaybillCargoSupport;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -58,16 +70,24 @@ import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 调度端:运单状态统计 + 异常/风险角标 + 待处理事项 + 运单列表
|
||||
* 调度端:运次状态统计 + 异常/风险角标 + 待处理事项 + 运次列表
|
||||
* <p>
|
||||
* 列表与详情与司机端同粒度:取数、状态聚合、接单需求合并都调共享运次读服务,
|
||||
* 本类只做字段裁剪与调度端独有的筛选(组织类型 / 异常 / 创建时间 / 待重新派单)。
|
||||
* <p>
|
||||
* 小程序调度账号(如「小程序管理」)组织常与运单业务组织不一致,故不做 dept 过滤,
|
||||
* 仅依赖租户隔离,口径接近后台 {@code /waybill-manage/list?allDept=1}。
|
||||
@@ -102,6 +122,11 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
private static final int DEFAULT_PAGE_SIZE = 10;
|
||||
private static final int MAX_PAGE_SIZE = 50;
|
||||
|
||||
/** 待重新派单的排序键:运次内子单被拒的时刻取最大值 */
|
||||
private static final String SQL_MAX_REJECT_TIME = "MAX(driver_reject_time)";
|
||||
/** 待重新派单的排序键:运次内子单创建时刻取最大值(与运次列表同一排序口径) */
|
||||
private static final String SQL_MAX_CREATE_TIME = "MAX(create_time)";
|
||||
|
||||
private static final DateTimeFormatter DATE_MD = DateTimeFormatter.ofPattern("MM-dd");
|
||||
private static final DateTimeFormatter DATE_YMD = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
@@ -110,13 +135,19 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
private final IExceptionDisposalService exceptionDisposalService;
|
||||
private final IRiskDisposalService riskDisposalService;
|
||||
private final IDriverWaybillService driverWaybillService;
|
||||
private final ITripQueryService tripQueryService;
|
||||
private final ILoadingManageService loadingManageService;
|
||||
|
||||
@Override
|
||||
public AdminHomeStatsVO stats() {
|
||||
AdminHomeStatsVO vo = new AdminHomeStatsVO();
|
||||
vo.setPendingAccept(countWaybillByStatus(STATUS_PENDING));
|
||||
vo.setTransporting(countWaybillByStatus(STATUS_RUNNING));
|
||||
vo.setCompleted(countWaybillByStatus(STATUS_COMPLETED));
|
||||
// 三档状态按运次计:有配载单的一趟车算一个,与列表和司机端同一份聚合
|
||||
TripQueryVO query = TripQueryVO.forAdmin();
|
||||
query.setDeptIds(manageableDeptIds());
|
||||
TripTabCountsVO tripCounts = tripQueryService.tabCounts(query);
|
||||
vo.setPendingAccept(tripCounts.getPending());
|
||||
vo.setTransporting(tripCounts.getDoing());
|
||||
vo.setCompleted(tripCounts.getDone());
|
||||
vo.setException(countIncompleteExceptions());
|
||||
return vo;
|
||||
}
|
||||
@@ -144,57 +175,46 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
|
||||
Set<Long> exceptionWaybillIds = loadIncompleteExceptionWaybillIds();
|
||||
if (EXCEPTION_YES.equals(exception) && exceptionWaybillIds.isEmpty()) {
|
||||
Page<AdminWaybillCardVO> emptyVo = new Page<>(pageNo, pageSize, 0);
|
||||
emptyVo.setRecords(List.of());
|
||||
return emptyVo;
|
||||
return emptyCardPage(pageNo, pageSize);
|
||||
}
|
||||
|
||||
LambdaQueryWrapper<Waybill> wrapper = scopedWaybillQuery();
|
||||
applyStatusFilter(wrapper, status);
|
||||
applyExceptionFilter(wrapper, exception, exceptionWaybillIds);
|
||||
applyTransportTypeFilter(wrapper, transportType);
|
||||
applyKeywordFilter(wrapper, keyword);
|
||||
applyCreateTimeFilter(wrapper, startDate, endDate);
|
||||
wrapper.orderByDesc(Waybill::getCreateTime);
|
||||
|
||||
IPage<Waybill> entityPage = waybillService.page(new Page<>(pageNo, pageSize), wrapper);
|
||||
List<Long> pageIds = entityPage.getRecords().stream()
|
||||
.map(Waybill::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
Set<Long> pageExceptionIds = pageIds.isEmpty()
|
||||
? Collections.emptySet()
|
||||
: exceptionWaybillIds.stream().filter(pageIds::contains).collect(Collectors.toSet());
|
||||
|
||||
Page<AdminWaybillCardVO> voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
|
||||
voPage.setRecords(entityPage.getRecords().stream()
|
||||
.map(w -> toCard(w, pageExceptionIds.contains(w.getId())))
|
||||
.toList());
|
||||
return voPage;
|
||||
// 组织类型 / 异常 / 创建时间都是子单行级条件,与状态、关键字、分页一起交给运次读服务,
|
||||
// 一次取数取完整趟车:分组、分档、筛选、排序与分页都在它那边算完
|
||||
TripQueryVO query = TripQueryVO.forAdmin();
|
||||
query.setDeptIds(manageableDeptIds());
|
||||
query.setStatus(toTripStatus(status));
|
||||
query.setKeyword(keyword);
|
||||
query.setTransportOrgType(transportType);
|
||||
query.setException(exception);
|
||||
query.setExceptionWaybillIds(exceptionWaybillIds);
|
||||
query.setCreatedFrom(parseDayStart(startDate));
|
||||
query.setCreatedTo(parseDayEnd(endDate));
|
||||
query.setCurrent(pageNo);
|
||||
query.setSize(pageSize);
|
||||
IPage<TripVO> trips = tripQueryService.page(query);
|
||||
return tripCardPage(trips.getRecords(), pageNo, pageSize, trips.getTotal(), exceptionWaybillIds);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AdminWaybillDetailVO detail(Long id) {
|
||||
public AdminWaybillDetailVO detail(String tripId) {
|
||||
Long id = parseId(tripId);
|
||||
if (id == null) {
|
||||
throw new org.springblade.core.log.exception.ServiceException("运单ID不能为空");
|
||||
throw new ServiceException("运次不存在或无权操作");
|
||||
}
|
||||
TripVO trip = visibleTrip(tripId);
|
||||
if (trip != null) {
|
||||
return tripDetail(trip);
|
||||
}
|
||||
// 已拒单的子运单按运次取不到(运次口径不含已拒子单),此时按运单主键回落,供重新派单等从运单进入的场景使用
|
||||
Waybill waybill = waybillService.getById(id);
|
||||
if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) {
|
||||
throw new org.springblade.core.log.exception.ServiceException("运单不存在");
|
||||
if (waybill != null && !Objects.equals(waybill.getIsDeleted(), 1)) {
|
||||
return waybillDetail(waybill);
|
||||
}
|
||||
boolean hasException = false;
|
||||
Long exceptionId = null;
|
||||
ExceptionDisposal latest = exceptionDisposalService.getOne(Wrappers.<ExceptionDisposal>lambdaQuery()
|
||||
.eq(ExceptionDisposal::getWaybillId, id)
|
||||
.in(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING, DISPOSAL_PROCESSING)
|
||||
.orderByDesc(ExceptionDisposal::getReportTime)
|
||||
.orderByDesc(ExceptionDisposal::getCreateTime)
|
||||
.last("LIMIT 1"));
|
||||
if (latest != null) {
|
||||
hasException = true;
|
||||
exceptionId = latest.getId();
|
||||
LoadingManage loading = loadingManageService.getById(id);
|
||||
if (loading == null) {
|
||||
throw new ServiceException("运次不存在或无权操作");
|
||||
}
|
||||
return toDetail(waybill, hasException, exceptionId);
|
||||
return loadingDetail(loading, visibleChildren(loading.getLoadingNo()));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -202,32 +222,521 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
int pageNo = current == null || current < 1 ? 1 : current;
|
||||
int pageSize = size == null || size < 1 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE);
|
||||
|
||||
LambdaQueryWrapper<Waybill> wrapper = scopedWaybillQuery()
|
||||
.in(Waybill::getBusinessStatus, STATUS_PENDING, STATUS_RUNNING);
|
||||
if (Boolean.TRUE.equals(needReassign)) {
|
||||
wrapper.eq(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED);
|
||||
} else if (Boolean.FALSE.equals(needReassign)) {
|
||||
wrapper.and(w -> w.isNull(Waybill::getDriverAcceptStatus)
|
||||
.or().ne(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED));
|
||||
// 待重新派单必须按「子单被拒」找:运次口径把已拒子单排除在外,整单拒绝后这趟车在运次里已经不存在
|
||||
return reassignPendingPage(pageNo, pageSize, keyword);
|
||||
}
|
||||
applyKeywordFilter(wrapper, keyword);
|
||||
wrapper.orderByDesc(Waybill::getUpdateTime).orderByDesc(Waybill::getCreateTime);
|
||||
|
||||
IPage<Waybill> entityPage = waybillService.page(new Page<>(pageNo, pageSize), wrapper);
|
||||
Set<Long> exceptionWaybillIds = loadIncompleteExceptionWaybillIds();
|
||||
List<Long> pageIds = entityPage.getRecords().stream()
|
||||
.map(Waybill::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
Set<Long> pageExceptionIds = pageIds.isEmpty()
|
||||
? Collections.emptySet()
|
||||
: exceptionWaybillIds.stream().filter(pageIds::contains).collect(Collectors.toSet());
|
||||
TripQueryVO query = TripQueryVO.forAdmin();
|
||||
query.setDeptIds(manageableDeptIds());
|
||||
query.setKeyword(keyword);
|
||||
// 待处理只要待接单与进行中两档;「未拒单」也是交给运次读服务按子单行筛
|
||||
query.setStatuses(List.of(0, 1));
|
||||
query.setExcludeRejectedTrips(Boolean.FALSE.equals(needReassign));
|
||||
query.setCurrent(pageNo);
|
||||
query.setSize(pageSize);
|
||||
IPage<TripVO> trips = tripQueryService.page(query);
|
||||
return tripCardPage(trips.getRecords(), pageNo, pageSize, trips.getTotal(), exceptionWaybillIds);
|
||||
}
|
||||
|
||||
Page<AdminWaybillCardVO> voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
|
||||
voPage.setRecords(entityPage.getRecords().stream()
|
||||
.map(w -> toCard(w, pageExceptionIds.contains(w.getId())))
|
||||
.toList());
|
||||
return voPage;
|
||||
private Page<AdminWaybillCardVO> emptyCardPage(int pageNo, int pageSize) {
|
||||
Page<AdminWaybillCardVO> page = new Page<>(pageNo, pageSize, 0);
|
||||
page.setRecords(List.of());
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次卡片页:一次算清这一页的重派标记与异常,再逐条拼卡片
|
||||
*/
|
||||
private Page<AdminWaybillCardVO> tripCardPage(List<TripVO> trips, int pageNo, int pageSize, long total,
|
||||
Set<Long> exceptionWaybillIds) {
|
||||
List<AdminWaybillCardVO> cards = new ArrayList<>(trips.size());
|
||||
if (!trips.isEmpty()) {
|
||||
Set<String> reassignKeys = loadReassignTripKeys(
|
||||
trips.stream().map(this::tripKeyOf).collect(Collectors.toCollection(LinkedHashSet::new)));
|
||||
for (TripVO trip : trips) {
|
||||
cards.add(toTripCard(trip, exceptionWaybillIds, reassignKeys));
|
||||
}
|
||||
}
|
||||
Page<AdminWaybillCardVO> page = new Page<>(pageNo, pageSize, total);
|
||||
page.setRecords(cards);
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次卡片:展示单号、整趟车起/经/终与站点数、货物汇总、计划时间都取运次字段。
|
||||
* 旧字段 id / waybillNo 保留运单语义(未配载就是这张运单,配载取第一张可见子运单),
|
||||
* 打卡订正、重新派单等按运单进入的入口仍靠它们。
|
||||
*/
|
||||
private AdminWaybillCardVO toTripCard(TripVO trip, Set<Long> exceptionWaybillIds,
|
||||
Set<String> reassignTripIds) {
|
||||
AdminWaybillCardVO card = new AdminWaybillCardVO();
|
||||
List<TripVO.TripWaybillVO> children = trip.getWaybills() == null ? List.of() : trip.getWaybills();
|
||||
Long firstChildId = children.isEmpty() ? null : parseId(children.get(0).getId());
|
||||
boolean loaded = Func.isNotEmpty(trip.getLoadingNo());
|
||||
|
||||
card.setTripId(trip.getId());
|
||||
card.setTripNo(Func.toStr(trip.getTripNo(), ""));
|
||||
card.setLoadingNo(Func.toStr(trip.getLoadingNo(), ""));
|
||||
card.setId(firstChildId == null ? parseId(trip.getId()) : firstChildId);
|
||||
card.setWaybillNo(children.isEmpty() ? card.getTripNo() : Func.toStr(children.get(0).getWaybillNo(), ""));
|
||||
card.setWaybillCount(trip.getWaybillCount());
|
||||
card.setTransportMode(Func.toStr(trip.getTransportType(), ""));
|
||||
card.setTransportType(loaded ? TRANSPORT_LOAD : TRANSPORT_COMMON);
|
||||
card.setFromName(Func.toStrWithEmpty(trip.getDepartureName(), Func.toStr(trip.getDepartureAddress(), "")));
|
||||
card.setToName(Func.toStrWithEmpty(trip.getArrivalName(), Func.toStr(trip.getArrivalAddress(), "")));
|
||||
card.setViaName(Func.toStr(trip.getTransitName(), ""));
|
||||
card.setStationCount(trip.getStationCount());
|
||||
card.setCargo(Func.toStr(trip.getCargoSummary(), ""));
|
||||
card.setCargoSummary(Func.toStr(trip.getCargoSummary(), ""));
|
||||
String weight = formatWeight(trip.getTotalQuantity(), trip.getQuantityUnit());
|
||||
card.setWeight(weight);
|
||||
card.setTotalWeight(weight);
|
||||
// 计划时间两条路同源:配载运次取配载单,未配载运次取那张运单自己的
|
||||
card.setPlanTime(formatLocalDate(trip.getEstimatedStartDate()));
|
||||
card.setPlanTimeEnd(formatLocalDate(trip.getEstimatedEndDate()));
|
||||
card.setStatus(trip.getStatus());
|
||||
card.setDriverName(Func.toStr(trip.getDriverName(), ""));
|
||||
card.setVehicleNo(Func.toStr(trip.getVehicleNo(), ""));
|
||||
card.setCreateTime(formatDateTime(trip.getSortTime()));
|
||||
card.setNeedReassign(reassignTripIds.contains(tripKeyOf(trip)));
|
||||
card.setHasException(hasChildException(children.stream().map(TripVO.TripWaybillVO::getId).toList(), exceptionWaybillIds));
|
||||
card.setBuyerPaid(false);
|
||||
return card;
|
||||
}
|
||||
|
||||
/** 这一页里带「子单被拒」标记的运次主键:判据与重派待办一致(已拒单 + 未取消未完成) */
|
||||
private Set<String> loadReassignTripKeys(Set<String> tripIds) {
|
||||
if (Func.isEmpty(tripIds)) {
|
||||
return Set.of();
|
||||
}
|
||||
// 被拒子单一次取回后在服务层换算运次键
|
||||
return waybillService.list(pendingReassignQuery().select(Waybill::getId, Waybill::getLoadingNo))
|
||||
.stream()
|
||||
.map(this::tripKeyOf)
|
||||
.filter(tripIds::contains)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private boolean hasRejectedChild(String tripId) {
|
||||
return Func.isNotEmpty(tripId) && !loadReassignTripKeys(Set.of(tripId)).isEmpty();
|
||||
}
|
||||
|
||||
/** 运次里有没有未完成的异常:异常仍是运单级的事实,落到运次上按「任一子单有」算 */
|
||||
private boolean hasChildException(Collection<String> childIds, Set<Long> exceptionWaybillIds) {
|
||||
if (exceptionWaybillIds.isEmpty() || childIds.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return childIds.stream().map(this::parseId).filter(Objects::nonNull).anyMatch(exceptionWaybillIds::contains);
|
||||
}
|
||||
|
||||
/** 小程序 status → 运次状态:0待接单 / 1进行中 / 2已完成 */
|
||||
private Integer toTripStatus(String status) {
|
||||
if (Func.isBlank(status)) {
|
||||
return null;
|
||||
}
|
||||
return switch (status.trim()) {
|
||||
case "0", STATUS_PENDING -> 0;
|
||||
case "1", STATUS_RUNNING, "transporting", "doing" -> 1;
|
||||
case "2", STATUS_COMPLETED, "done" -> 2;
|
||||
case "3", STATUS_CANCELLED -> 3;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private Date parseDayStart(String date) {
|
||||
if (Func.isBlank(date)) {
|
||||
return null;
|
||||
}
|
||||
return DateUtil.parse(date.trim() + " 00:00:00", DateUtil.PATTERN_DATETIME);
|
||||
}
|
||||
|
||||
private Date parseDayEnd(String date) {
|
||||
if (Func.isBlank(date)) {
|
||||
return null;
|
||||
}
|
||||
return DateUtil.parse(date.trim() + " 23:59:59", DateUtil.PATTERN_DATETIME);
|
||||
}
|
||||
|
||||
private Long parseId(String value) {
|
||||
if (Func.isEmpty(value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(value.trim());
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 运次的键:配载取配载单号,未配载取 W + 运单主键,与运次读服务同一个口径 */
|
||||
private String tripKeyOf(Waybill waybill) {
|
||||
return waybill == null ? null : TripAggregation.keyOf(waybill.getLoadingNo(), waybill.getId());
|
||||
}
|
||||
|
||||
/** 运次的键:同上,运次卡片取运次主键换算 */
|
||||
private String tripKeyOf(TripVO trip) {
|
||||
return trip == null ? null : TripAggregation.keyOf(trip.getLoadingNo(), parseId(trip.getId()));
|
||||
}
|
||||
|
||||
/* ===========================================================================
|
||||
* 详情:按运次展示;已拒单等取不到运次的场景回落成运单视图
|
||||
* =========================================================================*/
|
||||
|
||||
/** 按运次主键取可见运次;取不到(已拒子单、不是运次主键)返回空,交给运单级回落 */
|
||||
private TripVO visibleTrip(String tripId) {
|
||||
try {
|
||||
TripQueryVO query = TripQueryVO.forAdmin();
|
||||
query.setTripId(tripId);
|
||||
return tripQueryService.detail(query);
|
||||
} catch (ServiceException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次详情:与司机端接单页同一口径(单号取配载单号,站点 ①②③ 与每站货物条目都带运单号)。
|
||||
* 打卡节点取第一张可见子运单:管理端打卡订正仍按运单走,逐子单的打卡视图不在运次这一层。
|
||||
*/
|
||||
private AdminWaybillDetailVO tripDetail(TripVO trip) {
|
||||
List<TripVO.TripWaybillVO> children = trip.getWaybills() == null ? List.of() : trip.getWaybills();
|
||||
Long firstChildId = children.isEmpty() ? null : parseId(children.get(0).getId());
|
||||
List<TripStationVO> stations = trip.getStations() == null ? List.of() : trip.getStations();
|
||||
List<Long> scopeIds = new ArrayList<>();
|
||||
children.forEach(child -> {
|
||||
Long childId = parseId(child.getId());
|
||||
if (childId != null) {
|
||||
scopeIds.add(childId);
|
||||
}
|
||||
});
|
||||
if (scopeIds.isEmpty() && parseId(trip.getId()) != null) {
|
||||
scopeIds.add(parseId(trip.getId()));
|
||||
}
|
||||
|
||||
AdminWaybillDetailVO detail = new AdminWaybillDetailVO();
|
||||
detail.setId(firstChildId == null ? parseId(trip.getId()) : firstChildId);
|
||||
detail.setWaybillNo(children.isEmpty() ? Func.toStr(trip.getTripNo(), "") : Func.toStr(children.get(0).getWaybillNo(), ""));
|
||||
detail.setStatus(trip.getStatus());
|
||||
detail.setTransportMode(Func.toStr(trip.getTransportType(), ""));
|
||||
detail.setTransportType(toTransportTypeLabel(Func.toStr(trip.getTransportType(), "")));
|
||||
detail.setTransportOrgType(Func.isEmpty(trip.getLoadingNo()) ? TRANSPORT_COMMON : TRANSPORT_LOAD);
|
||||
detail.setTripId(trip.getId());
|
||||
detail.setLoadingNo(Func.toStr(trip.getLoadingNo(), ""));
|
||||
detail.setTripNo(Func.toStr(trip.getTripNo(), ""));
|
||||
detail.setViaName(Func.toStr(trip.getTransitName(), ""));
|
||||
detail.setStationCount(trip.getStationCount());
|
||||
detail.setCargoSummary(Func.toStr(trip.getCargoSummary(), ""));
|
||||
String weight = formatWeight(trip.getTotalQuantity(), trip.getQuantityUnit());
|
||||
detail.setCargoName(Func.toStr(trip.getCargoName(), ""));
|
||||
detail.setCargoNames(trip.getCargoNames() == null ? List.of() : trip.getCargoNames());
|
||||
detail.setCargoQuantity(weight);
|
||||
detail.setWeight(weight);
|
||||
detail.setTotalWeight(weight);
|
||||
String fromAddr = Func.toStr(trip.getDepartureAddress(), "");
|
||||
String toAddr = Func.toStr(trip.getArrivalAddress(), "");
|
||||
detail.setFromName(Func.toStrWithEmpty(trip.getDepartureName(), fromAddr));
|
||||
detail.setToName(Func.toStrWithEmpty(trip.getArrivalName(), toAddr));
|
||||
detail.setFromAddress(Func.toStrWithEmpty(fromAddr, detail.getFromName()));
|
||||
detail.setToAddress(Func.toStrWithEmpty(toAddr, detail.getToName()));
|
||||
detail.setPickupAddress(detail.getFromAddress());
|
||||
detail.setUnloadAddress(detail.getToAddress());
|
||||
detail.setPlanShipTime(formatLocalDate(trip.getEstimatedStartDate()));
|
||||
detail.setPlanFinishTime(formatLocalDate(trip.getEstimatedEndDate()));
|
||||
detail.setCarrierName("");
|
||||
detail.setDriverName(Func.toStr(trip.getDriverName(), ""));
|
||||
detail.setDriverPhone(Func.toStr(trip.getDriverPhone(), ""));
|
||||
detail.setVehicleNo(Func.toStr(trip.getVehicleNo(), ""));
|
||||
detail.setRemark(Func.toStr(trip.getTaskRemark(), ""));
|
||||
detail.setNeedReassign(hasRejectedChild(tripKeyOf(trip)));
|
||||
detail.setAcceptStatus(Func.toStr(trip.getAcceptStatus(), ""));
|
||||
detail.setRejectReason("");
|
||||
detail.setStations(stations);
|
||||
detail.setRoutePoints(toRoutePoints(stations));
|
||||
fillException(detail, scopeIds);
|
||||
fillPunchSnapshot(detail, firstChildId);
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运单主键回落:重新派单、异常处置等按运单进入的场景仍按运单展示;
|
||||
* 它属于配载单时补上整趟车的展示字段(配载单号、站点 ①②③、计划时间),与列表和司机端同一口径。
|
||||
*/
|
||||
private AdminWaybillDetailVO waybillDetail(Waybill waybill) {
|
||||
Set<Long> scopeIds = new LinkedHashSet<>();
|
||||
scopeIds.add(waybill.getId());
|
||||
AdminWaybillDetailVO detail = toDetail(waybill, false, null);
|
||||
if (Func.isEmpty(waybill.getLoadingNo())) {
|
||||
fillException(detail, scopeIds);
|
||||
return detail;
|
||||
}
|
||||
List<Waybill> children = visibleChildren(waybill.getLoadingNo());
|
||||
children.forEach(child -> scopeIds.add(child.getId()));
|
||||
LoadingManage loading = findLoading(waybill.getLoadingNo());
|
||||
if (loading != null) {
|
||||
// 整趟车的子单都被拒时组里为空,站点退回配载单自己的发货/途经/到货
|
||||
List<TripStationVO> stations = TripStationAssembler.assemble(loading, children);
|
||||
detail.setTripId(String.valueOf(loading.getId()));
|
||||
detail.setLoadingNo(loading.getLoadingNo());
|
||||
detail.setTripNo(loading.getLoadingNo());
|
||||
detail.setTransportOrgType(TRANSPORT_LOAD);
|
||||
detail.setViaName(TripStationAssembler.transitName(stations));
|
||||
detail.setStationCount(stations.size());
|
||||
detail.setStations(stations);
|
||||
detail.setPlanShipTime(formatLocalDate(loading.getEstimatedStartDate()));
|
||||
detail.setPlanFinishTime(formatLocalDate(loading.getEstimatedEndDate()));
|
||||
detail.setFromName(Func.toStrWithEmpty(TripStationAssembler.departureName(stations), detail.getFromName()));
|
||||
detail.setToName(Func.toStrWithEmpty(TripStationAssembler.arrivalName(stations), detail.getToName()));
|
||||
if (Func.isEmpty(detail.getDriverName())) {
|
||||
detail.setDriverName(Func.toStr(loading.getDriverName(), ""));
|
||||
}
|
||||
if (Func.isEmpty(detail.getVehicleNo())) {
|
||||
detail.setVehicleNo(Func.toStr(loading.getVehicleNo(), ""));
|
||||
}
|
||||
}
|
||||
fillException(detail, scopeIds);
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按配载单主键取整趟车:整单被拒时子单为空,站点与计划时间仍按配载单展示
|
||||
*/
|
||||
private AdminWaybillDetailVO loadingDetail(LoadingManage loading, List<Waybill> children) {
|
||||
List<TripStationVO> stations = TripStationAssembler.assemble(loading, children);
|
||||
List<Long> scopeIds = children.stream().map(Waybill::getId).filter(Objects::nonNull).toList();
|
||||
Waybill head = children.isEmpty() ? null : children.get(0);
|
||||
|
||||
AdminWaybillDetailVO detail = new AdminWaybillDetailVO();
|
||||
detail.setId(head == null ? loading.getId() : head.getId());
|
||||
detail.setWaybillNo(head == null ? loading.getLoadingNo() : Func.toStr(head.getWaybillNo(), loading.getLoadingNo()));
|
||||
detail.setStatus(TripAggregation.appStatus(tripBusinessStatus(children)));
|
||||
detail.setTransportMode(Func.toStr(loading.getTransportType(), ""));
|
||||
detail.setTransportType(toTransportTypeLabel(Func.toStr(loading.getTransportType(), "")));
|
||||
detail.setTransportOrgType(TRANSPORT_LOAD);
|
||||
detail.setTripId(String.valueOf(loading.getId()));
|
||||
detail.setLoadingNo(loading.getLoadingNo());
|
||||
detail.setTripNo(loading.getLoadingNo());
|
||||
detail.setViaName(TripStationAssembler.transitName(stations));
|
||||
detail.setStationCount(stations.size());
|
||||
detail.setCargoName(Func.toStrWithEmpty(loading.getCargoName(), head == null ? "" : Func.toStr(head.getCargoName(), "")));
|
||||
String weight = head == null ? "" : formatWeight(head.getQuantity(), head.getQuantityUnit());
|
||||
detail.setCargoQuantity(weight);
|
||||
detail.setWeight(weight);
|
||||
detail.setTotalWeight(weight);
|
||||
detail.setFromName(TripStationAssembler.departureName(stations));
|
||||
detail.setToName(TripStationAssembler.arrivalName(stations));
|
||||
detail.setFromAddress(Func.toStr(loading.getDepartureAddress(), ""));
|
||||
detail.setToAddress(Func.toStr(loading.getArrivalAddress(), ""));
|
||||
detail.setPickupAddress(detail.getFromAddress());
|
||||
detail.setUnloadAddress(detail.getToAddress());
|
||||
detail.setPlanShipTime(formatLocalDate(loading.getEstimatedStartDate()));
|
||||
detail.setPlanFinishTime(formatLocalDate(loading.getEstimatedEndDate()));
|
||||
detail.setCarrierName("");
|
||||
detail.setDriverName(Func.toStr(loading.getDriverName(), ""));
|
||||
detail.setDriverPhone(Func.toStr(loading.getDriverPhone(), ""));
|
||||
detail.setVehicleNo(Func.toStr(loading.getVehicleNo(), ""));
|
||||
detail.setRemark(Func.toStr(loading.getTaskRemark(), ""));
|
||||
detail.setNeedReassign(true);
|
||||
detail.setAcceptStatus(Func.toStr(TripAggregation.acceptStatus(children), ""));
|
||||
detail.setRejectReason("");
|
||||
detail.setStations(stations);
|
||||
detail.setRoutePoints(toRoutePoints(stations));
|
||||
fillException(detail, scopeIds);
|
||||
fillPunchSnapshot(detail, head == null ? null : head.getId());
|
||||
return detail;
|
||||
}
|
||||
|
||||
/** 一组子运单的业务状态:全部终态取已完成/已取消,否则有待执行取待执行、其余进行中 */
|
||||
private String tripBusinessStatus(List<Waybill> children) {
|
||||
if (children.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
boolean requireAccept = TripAggregation.requiresAccept(children);
|
||||
boolean accepted = WaybillProcessSupport.ACCEPT_ACCEPTED.equals(TripAggregation.acceptStatus(children));
|
||||
return TripAggregation.statusOf(children, requireAccept, accepted);
|
||||
}
|
||||
|
||||
private List<Waybill> visibleChildren(String loadingNo) {
|
||||
if (Func.isEmpty(loadingNo)) {
|
||||
return List.of();
|
||||
}
|
||||
return waybillService.list(Wrappers.<Waybill>lambdaQuery()
|
||||
.eq(Waybill::getLoadingNo, loadingNo)
|
||||
.in(Waybill::getBusinessStatus, TripAggregation.CHILD_STATUSES)
|
||||
.and(visible -> visible.isNull(Waybill::getDriverAcceptStatus)
|
||||
.or().ne(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED))
|
||||
.orderByAsc(Waybill::getCreateTime));
|
||||
}
|
||||
|
||||
private LoadingManage findLoading(String loadingNo) {
|
||||
if (Func.isEmpty(loadingNo)) {
|
||||
return null;
|
||||
}
|
||||
return loadingManageService.getOne(Wrappers.<LoadingManage>lambdaQuery()
|
||||
.eq(LoadingManage::getLoadingNo, loadingNo)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
/** 未完成的异常处置:整趟车取最新一条,与旧单票详情同口径 */
|
||||
private void fillException(AdminWaybillDetailVO detail, Collection<Long> waybillIds) {
|
||||
ExceptionDisposal latest = latestException(waybillIds);
|
||||
detail.setHasException(latest != null);
|
||||
detail.setExceptionId(latest == null ? null : latest.getId());
|
||||
}
|
||||
|
||||
private ExceptionDisposal latestException(Collection<Long> waybillIds) {
|
||||
if (Func.isEmpty(waybillIds)) {
|
||||
return null;
|
||||
}
|
||||
return exceptionDisposalService.getOne(Wrappers.<ExceptionDisposal>lambdaQuery()
|
||||
.in(ExceptionDisposal::getWaybillId, waybillIds)
|
||||
.in(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING, DISPOSAL_PROCESSING)
|
||||
.orderByDesc(ExceptionDisposal::getReportTime)
|
||||
.orderByDesc(ExceptionDisposal::getCreateTime)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
/** 打卡节点与途打卡记录复用司机端组装;管理端打卡订正按运单走,故传第一张可见子运单 */
|
||||
private void fillPunchSnapshot(AdminWaybillDetailVO detail, Long waybillId) {
|
||||
if (waybillId == null) {
|
||||
return;
|
||||
}
|
||||
DriverWaybillCardVO punch = driverWaybillService.detailPunchSnapshot(waybillId);
|
||||
if (punch == null) {
|
||||
return;
|
||||
}
|
||||
detail.setPunchNodes(punch.getPunchNodes());
|
||||
detail.setEnrouteRecords(punch.getEnrouteRecords());
|
||||
if (punch.getRoutePoints() != null && !punch.getRoutePoints().isEmpty()) {
|
||||
detail.setRoutePoints(punch.getRoutePoints().stream().map(p -> {
|
||||
AdminWaybillDetailVO.AdminRoutePointVO rp = new AdminWaybillDetailVO.AdminRoutePointVO();
|
||||
rp.setName(p.getName());
|
||||
rp.setAddress(p.getAddress());
|
||||
rp.setStatus(p.getStatus());
|
||||
return rp;
|
||||
}).toList());
|
||||
}
|
||||
}
|
||||
|
||||
/** 点位列:整趟车的全部站点,站序即途经顺序 */
|
||||
private List<AdminWaybillDetailVO.AdminRoutePointVO> toRoutePoints(List<TripStationVO> stations) {
|
||||
if (Func.isEmpty(stations)) {
|
||||
return List.of();
|
||||
}
|
||||
return stations.stream().map(station -> {
|
||||
AdminWaybillDetailVO.AdminRoutePointVO point = new AdminWaybillDetailVO.AdminRoutePointVO();
|
||||
point.setName(Func.toStr(station.getName(), ""));
|
||||
point.setAddress(Func.toStrWithEmpty(station.getAddress(), point.getName()));
|
||||
point.setStatus("pending");
|
||||
return point;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 待重新派单列表:按库层分组把「同一配载单被拒的多张子单」收成一条,未配载运单各自一条。
|
||||
* 分页与总数都在库层按运次完成,避免整单拒绝后同一趟车重复出现。
|
||||
*/
|
||||
private IPage<AdminWaybillCardVO> reassignPendingPage(int pageNo, int pageSize, String keyword) {
|
||||
QueryWrapper<Waybill> wrapper = reassignGroupQuery();
|
||||
applyGroupKeyword(wrapper, keyword);
|
||||
wrapper.orderByDesc(SQL_MAX_REJECT_TIME);
|
||||
|
||||
Page<Waybill> rows = new Page<>(pageNo, pageSize);
|
||||
waybillService.page(rows, wrapper);
|
||||
|
||||
Set<Long> exceptionWaybillIds = loadIncompleteExceptionWaybillIds();
|
||||
Page<AdminWaybillCardVO> page = new Page<>(pageNo, pageSize, rows.getTotal());
|
||||
page.setRecords(toReassignCards(rows.getRecords(), exceptionWaybillIds));
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* 库层按运次分组的「待重新派单」查询:一条即一个运次,段二不用再回查子单。
|
||||
* 展示用字段一律包聚合函数取组内代表值,既满足 ONLY_FULL_GROUP_BY,也让一条待办自带它要显示的信息。
|
||||
*/
|
||||
private QueryWrapper<Waybill> reassignGroupQuery() {
|
||||
// 分组键只在 TripAggregation 声明一次:库层分组与服务层分组的「运次」必须是同一个东西
|
||||
QueryWrapper<Waybill> wrapper = Wrappers.query();
|
||||
wrapper.select("MIN(id) AS id", "MIN(NULLIF(loading_no, '')) AS loading_no", "MIN(waybill_no) AS waybill_no",
|
||||
"MIN(transport_type) AS transport_type", "MIN(business_status) AS business_status",
|
||||
"MIN(departure_name) AS departure_name", "MIN(arrival_name) AS arrival_name",
|
||||
"MIN(cargo_name) AS cargo_name", "MIN(quantity) AS quantity", "MIN(quantity_unit) AS quantity_unit",
|
||||
"MIN(estimated_start_time) AS estimated_start_time", "MIN(estimated_end_time) AS estimated_end_time",
|
||||
"MIN(driver_name) AS driver_name", "MIN(vehicle_no) AS vehicle_no",
|
||||
"MIN(driver_reject_reason) AS driver_reject_reason",
|
||||
SQL_MAX_REJECT_TIME + " AS driver_reject_time", SQL_MAX_CREATE_TIME + " AS create_time");
|
||||
wrapper.eq("driver_accept_status", ACCEPT_REJECTED);
|
||||
wrapper.in("business_status", STATUS_PENDING, STATUS_RUNNING);
|
||||
// 这条查询不经运次读服务,组织收口得自己带上,否则待重派列表与首页重派角标会出别的组织的单
|
||||
List<Long> deptIds = manageableDeptIds();
|
||||
if (Func.isNotEmpty(deptIds)) {
|
||||
wrapper.in("dept_id", deptIds);
|
||||
}
|
||||
wrapper.groupBy(TripAggregation.SQL_TRIP_KEY);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/** 一条待办 = 一个运次:整趟车取配载单的单号与站点,未配载就是这张运单本身 */
|
||||
private List<AdminWaybillCardVO> toReassignCards(List<Waybill> rows, Set<Long> exceptionWaybillIds) {
|
||||
if (Func.isEmpty(rows)) {
|
||||
return List.of();
|
||||
}
|
||||
Set<String> loadingNos = rows.stream().map(Waybill::getLoadingNo).filter(Func::isNotEmpty)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
Map<String, LoadingManage> loadings = loadingsByNo(loadingNos);
|
||||
|
||||
List<AdminWaybillCardVO> cards = new ArrayList<>(rows.size());
|
||||
for (Waybill row : rows) {
|
||||
String loadingNo = Func.toStr(row.getLoadingNo(), "");
|
||||
AdminWaybillCardVO card = toCard(row, hasExceptionIds(row, exceptionWaybillIds));
|
||||
card.setTripId(String.valueOf(Func.isEmpty(loadingNo) ? row.getId() : loadingId(loadings.get(loadingNo), row.getId())));
|
||||
card.setTripNo(Func.isEmpty(loadingNo) ? Func.toStr(row.getWaybillNo(), "") : loadingNo);
|
||||
card.setLoadingNo(loadingNo);
|
||||
card.setWaybillCount(1);
|
||||
card.setCargoSummary(Func.toStr(row.getCargoName(), ""));
|
||||
card.setTotalWeight(formatWeight(row.getQuantity(), row.getQuantityUnit()));
|
||||
card.setNeedReassign(true);
|
||||
if (Func.isNotEmpty(loadingNo)) {
|
||||
List<TripStationVO> stations = TripStationAssembler.assemble(loadings.get(loadingNo), List.of());
|
||||
card.setTransportType(TRANSPORT_LOAD);
|
||||
card.setFromName(TripStationAssembler.departureName(stations));
|
||||
card.setToName(TripStationAssembler.arrivalName(stations));
|
||||
card.setViaName(TripStationAssembler.transitName(stations));
|
||||
card.setStationCount(stations.size());
|
||||
} else {
|
||||
card.setTransportType(TRANSPORT_COMMON);
|
||||
card.setStationCount(2);
|
||||
}
|
||||
card.setCreateTime(formatDateTime(row.getCreateTime()));
|
||||
cards.add(card);
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
|
||||
private Map<String, LoadingManage> loadingsByNo(Set<String> loadingNos) {
|
||||
if (loadingNos.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
return loadingManageService.list(Wrappers.<LoadingManage>lambdaQuery().in(LoadingManage::getLoadingNo, loadingNos))
|
||||
.stream()
|
||||
.collect(Collectors.toMap(LoadingManage::getLoadingNo, Function.identity(), (first, ignored) -> first));
|
||||
}
|
||||
|
||||
private Long loadingId(LoadingManage loading, Long fallback) {
|
||||
return loading == null || loading.getId() == null ? fallback : loading.getId();
|
||||
}
|
||||
|
||||
/** 分组查询的关键字:运单号 / 司机 / 车牌,与列表关键字口径一致 */
|
||||
private void applyGroupKeyword(QueryWrapper<Waybill> wrapper, String keyword) {
|
||||
if (Func.isBlank(keyword)) {
|
||||
return;
|
||||
}
|
||||
String key = keyword.trim();
|
||||
wrapper.and(w -> w.like("waybill_no", key).or().like("driver_name", key).or().like("vehicle_no", key));
|
||||
}
|
||||
|
||||
private boolean hasExceptionIds(Waybill waybill, Set<Long> exceptionWaybillIds) {
|
||||
return waybill != null && waybill.getId() != null && exceptionWaybillIds.contains(waybill.getId());
|
||||
}
|
||||
|
||||
private AdminWaybillDetailVO toDetail(Waybill waybill, boolean hasException, Long exceptionId) {
|
||||
@@ -250,9 +759,12 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
detail.setPickupAddress(fromAddr);
|
||||
detail.setUnloadAddress(toAddr);
|
||||
|
||||
String cargo = Func.toStr(waybill.getCargoName(), "");
|
||||
String weight = formatWeight(waybill.getQuantity(), waybill.getQuantityUnit());
|
||||
detail.setCargoName(cargo);
|
||||
// 货名与合计重量取运单上录入的全部货物:主表 cargo_name 只是首行、quantity 是各行合计而单位只是首行的
|
||||
List<WaybillCargoSupport.Cargo> cargos = WaybillCargoSupport.of(waybill);
|
||||
List<String> cargoNames = WaybillCargoSupport.cargoNames(cargos);
|
||||
detail.setCargoName(String.join("、", cargoNames));
|
||||
detail.setCargoNames(cargoNames);
|
||||
String weight = cargoWeight(cargos, formatWeight(waybill.getQuantity(), waybill.getQuantityUnit()));
|
||||
detail.setCargoQuantity(weight);
|
||||
detail.setWeight(weight);
|
||||
detail.setTotalWeight(weight);
|
||||
@@ -371,82 +883,33 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
};
|
||||
}
|
||||
|
||||
private long countWaybillByStatus(String status) {
|
||||
return waybillService.count(Wrappers.<Waybill>lambdaQuery()
|
||||
.eq(Waybill::getBusinessStatus, status));
|
||||
/**
|
||||
* 调度端可见组织:普通用户(含各级角色)只出自己所属组织,超级管理员不过滤。
|
||||
* <p>
|
||||
* 小程序不校验「所属组织不能为空」:账号没有组织时不按组织收口,避免首页、待处理等接口直接报错。
|
||||
* 详情与重新派单本身不走这里。
|
||||
*/
|
||||
private List<Long> manageableDeptIds() {
|
||||
if (TransportBusinessSupport.isAdministrator()) {
|
||||
return List.of();
|
||||
}
|
||||
Long deptId = Func.firstLong(AuthUtil.getDeptId());
|
||||
if (Func.isEmpty(deptId) || deptId <= 0) {
|
||||
return List.of();
|
||||
}
|
||||
return List.of(deptId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序调度端不做组织过滤。
|
||||
* 「小程序管理」等账号 JWT/档案 dept 常与运单业务组织不一致,按 dept 过滤会导致统计全 0;
|
||||
* 与后台 allDept=1 一致,仅依赖租户隔离(MyBatis-Plus TenantLine)。
|
||||
* 已拒子单的查询底:只有租户与逻辑删除(MyBatis-Plus 自动补),不带组织。
|
||||
* <p>
|
||||
* 它服务的是「这个运次里有没有被拒的子单」,可见范围由调用方先定好——列表传的运次已经收过组织,
|
||||
* 详情按主键进入不收组织。这里再按组织收一次,详情里别的组织的运次会丢掉重派标记。
|
||||
*/
|
||||
private LambdaQueryWrapper<Waybill> scopedWaybillQuery() {
|
||||
private LambdaQueryWrapper<Waybill> rejectedChildQuery() {
|
||||
return Wrappers.<Waybill>lambdaQuery();
|
||||
}
|
||||
|
||||
private void applyStatusFilter(LambdaQueryWrapper<Waybill> wrapper, String status) {
|
||||
String businessStatus = toBusinessStatus(status);
|
||||
if (Func.isNotBlank(businessStatus)) {
|
||||
wrapper.eq(Waybill::getBusinessStatus, businessStatus);
|
||||
}
|
||||
}
|
||||
|
||||
private void applyExceptionFilter(LambdaQueryWrapper<Waybill> wrapper, String exception, Set<Long> exceptionWaybillIds) {
|
||||
if (EXCEPTION_YES.equals(exception)) {
|
||||
wrapper.in(Waybill::getId, exceptionWaybillIds);
|
||||
} else if (EXCEPTION_NO.equals(exception) && !exceptionWaybillIds.isEmpty()) {
|
||||
wrapper.notIn(Waybill::getId, exceptionWaybillIds);
|
||||
}
|
||||
}
|
||||
|
||||
private void applyTransportTypeFilter(LambdaQueryWrapper<Waybill> wrapper, String transportType) {
|
||||
if (TRANSPORT_LOAD.equals(transportType)) {
|
||||
wrapper.isNotNull(Waybill::getLoadingNo).ne(Waybill::getLoadingNo, "");
|
||||
} else if (TRANSPORT_COMMON.equals(transportType)) {
|
||||
wrapper.and(w -> w.isNull(Waybill::getLoadingNo).or().eq(Waybill::getLoadingNo, ""));
|
||||
}
|
||||
}
|
||||
|
||||
private void applyKeywordFilter(LambdaQueryWrapper<Waybill> wrapper, String keyword) {
|
||||
if (Func.isBlank(keyword)) {
|
||||
return;
|
||||
}
|
||||
String key = keyword.trim();
|
||||
wrapper.and(w -> w.like(Waybill::getWaybillNo, key)
|
||||
.or().like(Waybill::getDriverName, key)
|
||||
.or().like(Waybill::getVehicleNo, key));
|
||||
}
|
||||
|
||||
private void applyCreateTimeFilter(LambdaQueryWrapper<Waybill> wrapper, String startDate, String endDate) {
|
||||
if (Func.isNotBlank(startDate)) {
|
||||
Date start = DateUtil.parse(startDate.trim() + " 00:00:00", DateUtil.PATTERN_DATETIME);
|
||||
if (start != null) {
|
||||
wrapper.ge(Waybill::getCreateTime, start);
|
||||
}
|
||||
}
|
||||
if (Func.isNotBlank(endDate)) {
|
||||
Date end = DateUtil.parse(endDate.trim() + " 23:59:59", DateUtil.PATTERN_DATETIME);
|
||||
if (end != null) {
|
||||
wrapper.le(Waybill::getCreateTime, end);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 小程序 status → 后端 businessStatus */
|
||||
private String toBusinessStatus(String status) {
|
||||
if (Func.isBlank(status)) {
|
||||
return null;
|
||||
}
|
||||
return switch (status.trim()) {
|
||||
case "0", STATUS_PENDING -> STATUS_PENDING;
|
||||
case "1", STATUS_RUNNING, "transporting", "doing" -> STATUS_RUNNING;
|
||||
case "2", STATUS_COMPLETED, "done" -> STATUS_COMPLETED;
|
||||
case "3", STATUS_CANCELLED -> STATUS_CANCELLED;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private Set<Long> loadIncompleteExceptionWaybillIds() {
|
||||
List<ExceptionDisposal> list = exceptionDisposalService.list(Wrappers.<ExceptionDisposal>lambdaQuery()
|
||||
.select(ExceptionDisposal::getWaybillId)
|
||||
@@ -546,6 +1009,33 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
return Func.isBlank(unit) ? qty : qty + unit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重量文案:全部货物同单位时按货物行相加,单位不一致时不出合计
|
||||
* <p>
|
||||
* 主表 quantity 是各行数量的合计、quantity_unit 只是首行的单位,「20堆 + 50方」会变成「70堆」。
|
||||
* 拿明细算不出合计(有货物没填数量或单位)时退回主表那份汇总值,不让重量凭空消失。
|
||||
*/
|
||||
private String cargoWeight(List<WaybillCargoSupport.Cargo> cargos, String fallback) {
|
||||
if (cargos.isEmpty()) {
|
||||
return fallback;
|
||||
}
|
||||
String unit = null;
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (WaybillCargoSupport.Cargo cargo : cargos) {
|
||||
String cargoUnit = Func.toStr(cargo.quantityUnit(), "");
|
||||
if (cargo.quantity() == null || Func.isEmpty(cargoUnit)) {
|
||||
return fallback;
|
||||
}
|
||||
if (unit == null) {
|
||||
unit = cargoUnit;
|
||||
} else if (!unit.equals(cargoUnit)) {
|
||||
return "";
|
||||
}
|
||||
total = total.add(cargo.quantity());
|
||||
}
|
||||
return formatWeight(total, unit);
|
||||
}
|
||||
|
||||
private String formatLocalDate(LocalDate date) {
|
||||
if (date == null) {
|
||||
return "";
|
||||
@@ -570,13 +1060,20 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
.eq(RiskDisposal::getDisposalStatus, RISK_PENDING));
|
||||
}
|
||||
|
||||
/** 待重新派单:司机已拒单,且运单尚未取消/完成 */
|
||||
/**
|
||||
* 待重新派单的运次数:整单拒绝会把一张配载单的多张子运单一起写成已拒绝,按运次键去重后只算一个。
|
||||
* <p>
|
||||
* 这里不走运次读服务:它的可见口径已排除被拒子单,整单拒绝的配载单在运次里根本不存在;
|
||||
* 分组键用与运次读服务同一个表达式,两处的运次才是同一个东西。
|
||||
*/
|
||||
private long countPendingReassignments() {
|
||||
return waybillService.count(pendingReassignQuery());
|
||||
Page<Waybill> rows = new Page<>(1, 1);
|
||||
waybillService.page(rows, reassignGroupQuery());
|
||||
return rows.getTotal();
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<Waybill> pendingReassignQuery() {
|
||||
return Wrappers.<Waybill>lambdaQuery()
|
||||
return rejectedChildQuery()
|
||||
.eq(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED)
|
||||
.in(Waybill::getBusinessStatus, STATUS_PENDING, STATUS_RUNNING);
|
||||
}
|
||||
@@ -611,11 +1108,17 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 待重新派单的待办:一条 = 一个运次。
|
||||
* <p>
|
||||
* 整单拒绝会把一张配载单的多张子运单一起写成已拒绝,按运次键分组后同一趟车只出 1 条;
|
||||
* 未配载运单自成运次,各自一条。排序与取值都在库层按运次完成,与待处理列表、角标同一口径。
|
||||
*/
|
||||
private List<TimedTodo> buildReassignFeed() {
|
||||
List<Waybill> list = waybillService.list(pendingReassignQuery()
|
||||
.orderByDesc(Waybill::getDriverRejectTime)
|
||||
List<Waybill> rows = waybillService.list(reassignGroupQuery()
|
||||
.orderByDesc(SQL_MAX_REJECT_TIME)
|
||||
.last("LIMIT " + FEED_LIMIT));
|
||||
return list.stream()
|
||||
return rows.stream()
|
||||
.map(waybill -> new TimedTodo(toReassignTodoItem(waybill), rejectTimeOf(waybill)))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
@@ -649,7 +1152,8 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
item.setTitle("重新派单待处理");
|
||||
item.setTimeAgo(formatTimeAgo(rejectTimeOf(waybill)));
|
||||
item.setDesc(buildReassignDesc(waybill));
|
||||
item.setWaybillNo(Func.toStr(waybill.getWaybillNo(), ""));
|
||||
// 一条待办即一个运次:有配载单的展示配载单号;跳转参数仍是运单主键,该页据此回显并直接重派
|
||||
item.setWaybillNo(Func.toStrWithEmpty(waybill.getLoadingNo(), Func.toStr(waybill.getWaybillNo(), "")));
|
||||
item.setActionLabel("重新派单");
|
||||
item.setTargetUrl(String.format(REASSIGN_TARGET_URL, waybill.getId()));
|
||||
return item;
|
||||
|
||||
+890
-76
File diff suppressed because it is too large
Load Diff
+265
-58
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+50
-7
@@ -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;
|
||||
|
||||
+20
-5
@@ -252,8 +252,8 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl<ReceiptClaimM
|
||||
BigDecimal flowClaimedAfter = money(flow.getClaimedAmount())
|
||||
.subtract(money(claim.getClaimAmount())).max(BigDecimal.ZERO);
|
||||
flow.setClaimedAmount(flowClaimedAfter);
|
||||
// 认领状态对照公告未认领金额重算(作废分摊不计入累计)
|
||||
flow.setClaimStatus(amountClaimStatus(flowClaimedAfter, flow.getUnclaimedAmount()));
|
||||
// 认领状态对照可认领上限重算(作废分摊不计入累计)
|
||||
flow.setClaimStatus(amountClaimStatus(flowClaimedAfter, claimCeiling(flow)));
|
||||
receiptFlowMapper.updateById(flow);
|
||||
|
||||
// 作废不生成金蝶认领冲单:金蝶单号/状态保留原值(未推送为空,已推送为真实收款单信息),
|
||||
@@ -411,13 +411,28 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl<ReceiptClaimM
|
||||
}
|
||||
|
||||
/**
|
||||
* 累计认领金额 → 流水认领状态:对照公告未认领金额判定(与收款流水侧口径一致)
|
||||
* 累计认领金额 → 流水认领状态:对照可认领上限判定(与收款流水侧口径一致)
|
||||
*/
|
||||
private String amountClaimStatus(BigDecimal claimedAmount, BigDecimal unclaimedAmount) {
|
||||
private String amountClaimStatus(BigDecimal claimedAmount, BigDecimal claimCeiling) {
|
||||
if (claimedAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return "unclaimed";
|
||||
}
|
||||
return claimedAmount.compareTo(money(unclaimedAmount)) >= 0 ? "claimed" : "partial";
|
||||
return claimedAmount.compareTo(money(claimCeiling)) >= 0 ? "claimed" : "partial";
|
||||
}
|
||||
|
||||
/**
|
||||
* 可认领上限:公告未认领金额与收款金额取较小正值(与收款流水侧口径一致)
|
||||
*/
|
||||
private BigDecimal claimCeiling(KingdeeReceiptFlow flow) {
|
||||
BigDecimal unclaimed = money(flow.getUnclaimedAmount());
|
||||
BigDecimal receipt = money(flow.getReceiptAmount());
|
||||
if (receipt.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return unclaimed;
|
||||
}
|
||||
if (unclaimed.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return unclaimed;
|
||||
}
|
||||
return unclaimed.min(receipt);
|
||||
}
|
||||
|
||||
private String amountStatus(BigDecimal paidAmount, BigDecimal settlementAmount) {
|
||||
|
||||
+126
-20
@@ -194,12 +194,16 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
.eq(ReceiptFlowRecord::getReceiptFlowId, id)
|
||||
.in(ReceiptFlowRecord::getActionType, List.of("claim", "push"))
|
||||
.orderByDesc(ReceiptFlowRecord::getCreateTime)));
|
||||
// 继续认领:带回本流水历史关联且仍有剩余可认领金额的结算单
|
||||
vo.setRelatedSettlements(relatedSettlementCandidates(id));
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> settlementCandidates(String keyword, Long flowId) {
|
||||
existing(flowId);
|
||||
// 本流水历史关联结算单优先保留(避免仅按创建时间 limit 截断后选不到)
|
||||
Set<Long> relatedIds = relatedSettlementIds(flowId);
|
||||
List<FormalSettlement> settlements = formalSettlementMapper.selectList(
|
||||
Wrappers.<FormalSettlement>lambdaQuery()
|
||||
.eq(FormalSettlement::getSettlementType, RECEIVABLE)
|
||||
@@ -210,7 +214,24 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
.or().like(FormalSettlement::getProjectName, keyword)
|
||||
.or().like(FormalSettlement::getContractName, keyword))
|
||||
.orderByDesc(FormalSettlement::getCreateTime)
|
||||
.last("limit 200"));
|
||||
.last("limit 500"));
|
||||
if (!relatedIds.isEmpty()) {
|
||||
Set<Long> loadedIds = settlements.stream().map(FormalSettlement::getId)
|
||||
.filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
List<Long> missingIds = relatedIds.stream().filter(id -> !loadedIds.contains(id)).toList();
|
||||
if (!missingIds.isEmpty()) {
|
||||
List<FormalSettlement> relatedSettlements = formalSettlementMapper.selectList(
|
||||
Wrappers.<FormalSettlement>lambdaQuery()
|
||||
.in(FormalSettlement::getId, missingIds)
|
||||
.eq(FormalSettlement::getSettlementType, RECEIVABLE)
|
||||
.eq(FormalSettlement::getApprovalStatus, APPROVED)
|
||||
.eq(FormalSettlement::getStatus, 1));
|
||||
List<FormalSettlement> merged = new ArrayList<>(relatedSettlements.size() + settlements.size());
|
||||
merged.addAll(relatedSettlements);
|
||||
merged.addAll(settlements);
|
||||
settlements = merged;
|
||||
}
|
||||
}
|
||||
// 候选结算单的累计认领金额一次聚合查询(避免逐单 N+1)
|
||||
Map<Long, BigDecimal> claimedMap = receiptClaimedAmounts(settlements.stream()
|
||||
.map(FormalSettlement::getId).toList());
|
||||
@@ -218,6 +239,14 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
.map(settlement -> candidateRow(settlement,
|
||||
claimedMap.getOrDefault(settlement.getId(), BigDecimal.ZERO)))
|
||||
.filter(row -> ((BigDecimal) row.get("remainingReceiptAmount")).compareTo(BigDecimal.ZERO) > 0)
|
||||
.sorted((left, right) -> {
|
||||
boolean leftRelated = relatedIds.contains(toLong(left.get("id")));
|
||||
boolean rightRelated = relatedIds.contains(toLong(right.get("id")));
|
||||
if (leftRelated == rightRelated) {
|
||||
return 0;
|
||||
}
|
||||
return leftRelated ? -1 : 1;
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -234,6 +263,7 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
Wrappers.<ReceiptClaimSettlement>lambdaQuery()
|
||||
.eq(ReceiptClaimSettlement::getFormalSettlementId, formalSettlementId)
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)
|
||||
.eq(ReceiptClaimSettlement::getIsDeleted, 0)
|
||||
.orderByDesc(ReceiptClaimSettlement::getCreateTime));
|
||||
if (relations.isEmpty()) {
|
||||
return List.of();
|
||||
@@ -315,19 +345,19 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
allocatedTotal = allocatedTotal.add(allocated);
|
||||
}
|
||||
|
||||
// 公告未认领金额(认领上限与认领完成判定基准,A301-2 口径)
|
||||
// 认领上限:公告未认领金额与收款金额取较小值(避免未认领金额异常偏大导致无法认领完成)
|
||||
if (flow.getUnclaimedAmount() == null) {
|
||||
throw new ServiceException("公告未认领金额缺失,请先手动同步金蝶收款流水");
|
||||
}
|
||||
BigDecimal unclaimedAmount = money(flow.getUnclaimedAmount());
|
||||
if (unclaimedAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new ServiceException("该流水公告未认领金额为0,无需认领");
|
||||
BigDecimal claimCeiling = claimCeiling(flow);
|
||||
if (claimCeiling.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new ServiceException("该流水可认领金额为0,无需认领");
|
||||
}
|
||||
// 流水认领前已认领金额
|
||||
BigDecimal previousFlowClaimed = flowClaimedAmount(flow.getId());
|
||||
if (previousFlowClaimed.add(allocatedTotal).compareTo(unclaimedAmount) > 0) {
|
||||
throw new ServiceException("累计认领金额不能超过公告未认领金额,剩余可认领 "
|
||||
+ unclaimedAmount.subtract(previousFlowClaimed).max(BigDecimal.ZERO));
|
||||
if (previousFlowClaimed.add(allocatedTotal).compareTo(claimCeiling) > 0) {
|
||||
throw new ServiceException("累计认领金额不能超过可认领上限,剩余可认领 "
|
||||
+ claimCeiling.subtract(previousFlowClaimed).max(BigDecimal.ZERO));
|
||||
}
|
||||
|
||||
// 当前用户部门
|
||||
@@ -380,6 +410,9 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
relation.setClaimedReceiptAmount(previousClaimed);
|
||||
// 本次分摊金额
|
||||
relation.setAllocatedReceiptAmount(allocated);
|
||||
// 有效分摊(与汇总/候选查询 status=1 口径一致)
|
||||
relation.setStatus(1);
|
||||
relation.setIsDeleted(0);
|
||||
claimSettlementMapper.insert(relation);
|
||||
}
|
||||
|
||||
@@ -390,8 +423,8 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
String fromStatus = normalizeClaimStatus(flow.getClaimStatus());
|
||||
// 认领后累计金额
|
||||
BigDecimal claimedAfter = previousFlowClaimed.add(allocatedTotal);
|
||||
// 认领后状态(对照公告未认领金额)
|
||||
String toStatus = amountClaimStatus(claimedAfter, unclaimedAmount);
|
||||
// 认领后状态(对照可认领上限)
|
||||
String toStatus = amountClaimStatus(claimedAfter, claimCeiling);
|
||||
// 更新流水已认领金额
|
||||
flow.setClaimedAmount(claimedAfter);
|
||||
// 更新流水认领状态
|
||||
@@ -499,9 +532,9 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
|
||||
// 复制公告数据到实体(不覆盖本地认领字段)
|
||||
copyAnnounceRow(entity, announce, detailSerialNo, receiptAmount);
|
||||
// 根据本地累计认领金额与最新公告未认领金额重新计算认领状态
|
||||
// 根据本地累计认领金额与可认领上限重新计算认领状态
|
||||
entity.setClaimStatus(amountClaimStatus(money(entity.getClaimedAmount()),
|
||||
entity.getUnclaimedAmount()));
|
||||
claimCeiling(entity)));
|
||||
|
||||
if (created) {
|
||||
toInsert.add(entity);
|
||||
@@ -621,6 +654,46 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 本流水历史关联过的结算单 id(有效分摊)
|
||||
*/
|
||||
private Set<Long> relatedSettlementIds(Long flowId) {
|
||||
if (flowId == null) {
|
||||
return Set.of();
|
||||
}
|
||||
return claimSettlementMapper.selectList(Wrappers.<ReceiptClaimSettlement>lambdaQuery()
|
||||
.select(ReceiptClaimSettlement::getFormalSettlementId)
|
||||
.eq(ReceiptClaimSettlement::getReceiptFlowId, flowId)
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)
|
||||
.eq(ReceiptClaimSettlement::getIsDeleted, 0)).stream()
|
||||
.map(ReceiptClaimSettlement::getFormalSettlementId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toCollection(HashSet::new));
|
||||
}
|
||||
|
||||
/**
|
||||
* 继续认领预填:本流水历史关联且仍有剩余可认领金额的结算单
|
||||
*/
|
||||
private List<Map<String, Object>> relatedSettlementCandidates(Long flowId) {
|
||||
Set<Long> relatedIds = relatedSettlementIds(flowId);
|
||||
if (relatedIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<FormalSettlement> settlements = formalSettlementMapper.selectList(
|
||||
Wrappers.<FormalSettlement>lambdaQuery()
|
||||
.in(FormalSettlement::getId, relatedIds)
|
||||
.eq(FormalSettlement::getSettlementType, RECEIVABLE)
|
||||
.eq(FormalSettlement::getApprovalStatus, APPROVED)
|
||||
.eq(FormalSettlement::getStatus, 1));
|
||||
Map<Long, BigDecimal> claimedMap = receiptClaimedAmounts(settlements.stream()
|
||||
.map(FormalSettlement::getId).toList());
|
||||
return settlements.stream()
|
||||
.map(settlement -> candidateRow(settlement,
|
||||
claimedMap.getOrDefault(settlement.getId(), BigDecimal.ZERO)))
|
||||
.filter(row -> ((BigDecimal) row.get("remainingReceiptAmount")).compareTo(BigDecimal.ZERO) > 0)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量聚合结算单的累计有效认领金额:一次 IN 查询按结算单分组求和,避免逐单 N+1
|
||||
*/
|
||||
@@ -631,7 +704,8 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
}
|
||||
return claimSettlementMapper.selectList(Wrappers.<ReceiptClaimSettlement>lambdaQuery()
|
||||
.in(ReceiptClaimSettlement::getFormalSettlementId, ids)
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)).stream()
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)
|
||||
.eq(ReceiptClaimSettlement::getIsDeleted, 0)).stream()
|
||||
.collect(Collectors.groupingBy(ReceiptClaimSettlement::getFormalSettlementId,
|
||||
Collectors.reducing(BigDecimal.ZERO,
|
||||
item -> money(item.getAllocatedReceiptAmount()), BigDecimal::add)));
|
||||
@@ -640,12 +714,42 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
private BigDecimal flowClaimedAmount(Long flowId) {
|
||||
return claimSettlementMapper.selectList(Wrappers.<ReceiptClaimSettlement>lambdaQuery()
|
||||
.eq(ReceiptClaimSettlement::getReceiptFlowId, flowId)
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)).stream()
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)
|
||||
.eq(ReceiptClaimSettlement::getIsDeleted, 0)).stream()
|
||||
.map(ReceiptClaimSettlement::getAllocatedReceiptAmount)
|
||||
.map(this::money)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
}
|
||||
|
||||
/**
|
||||
* 可认领上限:公告未认领金额与收款金额取较小正值。
|
||||
* 主口径仍为 A301-2 公告未认领金额;与收款金额取 min,避免未认领金额异常偏大导致无法进入「认领完成」。
|
||||
*/
|
||||
private BigDecimal claimCeiling(KingdeeReceiptFlow flow) {
|
||||
BigDecimal unclaimed = money(flow.getUnclaimedAmount());
|
||||
BigDecimal receipt = money(flow.getReceiptAmount());
|
||||
if (receipt.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return unclaimed;
|
||||
}
|
||||
if (unclaimed.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return unclaimed;
|
||||
}
|
||||
return unclaimed.min(receipt);
|
||||
}
|
||||
|
||||
private Long toLong(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Long longValue) {
|
||||
return longValue;
|
||||
}
|
||||
if (value instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
return Func.toLong(String.valueOf(value));
|
||||
}
|
||||
|
||||
private KingdeeReceiptFlow lockedFlow(Long flowId) {
|
||||
KingdeeReceiptFlow flow = baseMapper.selectOne(Wrappers.<KingdeeReceiptFlow>lambdaQuery()
|
||||
.eq(KingdeeReceiptFlow::getId, flowId)
|
||||
@@ -824,18 +928,20 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
flow.setClaimMe(announce.getClaimme());
|
||||
BigDecimal claimedTotal = flowClaimedAmount(flow.getId());
|
||||
flow.setClaimedAmount(claimedTotal);
|
||||
String claimStatus = amountClaimStatus(claimedTotal, flow.getUnclaimedAmount());
|
||||
BigDecimal claimCeiling = claimCeiling(flow);
|
||||
String claimStatus = amountClaimStatus(claimedTotal, claimCeiling);
|
||||
flow.setClaimStatus(claimStatus);
|
||||
updateById(flow);
|
||||
if (!CLAIMED.equals(claimStatus)) {
|
||||
return ReceiptClaimPushResult.fail("累计认领金额(" + claimedTotal + ")与公告未认领金额("
|
||||
+ money(flow.getUnclaimedAmount()) + ")不一致,不满足推送条件");
|
||||
return ReceiptClaimPushResult.fail("累计认领金额(" + claimedTotal + ")与可认领上限("
|
||||
+ claimCeiling + ")不一致,不满足推送条件");
|
||||
}
|
||||
// 分录取该流水全部有效分摊行(结算单id升序,跨认领主单归集)
|
||||
List<ReceiptClaimSettlement> allocations = claimSettlementMapper.selectList(
|
||||
Wrappers.<ReceiptClaimSettlement>lambdaQuery()
|
||||
.eq(ReceiptClaimSettlement::getReceiptFlowId, flow.getId())
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)
|
||||
.eq(ReceiptClaimSettlement::getIsDeleted, 0)
|
||||
.orderByAsc(ReceiptClaimSettlement::getFormalSettlementId));
|
||||
if (allocations.isEmpty()) {
|
||||
return ReceiptClaimPushResult.fail("该流水无有效认领分摊,无法推送");
|
||||
@@ -1075,13 +1181,13 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
}
|
||||
|
||||
/**
|
||||
* 累计认领金额 → 流水认领状态:对照公告未认领金额判定(claimed 即「认领完成」,唯一可推送状态)
|
||||
* 累计认领金额 → 流水认领状态:对照可认领上限判定(claimed 即「认领完成」,唯一可推送状态)
|
||||
*/
|
||||
private String amountClaimStatus(BigDecimal claimedAmount, BigDecimal unclaimedAmount) {
|
||||
private String amountClaimStatus(BigDecimal claimedAmount, BigDecimal claimCeiling) {
|
||||
if (claimedAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return UNCLAIMED;
|
||||
}
|
||||
return claimedAmount.compareTo(money(unclaimedAmount)) >= 0 ? CLAIMED : PARTIAL;
|
||||
return claimedAmount.compareTo(money(claimCeiling)) >= 0 ? CLAIMED : PARTIAL;
|
||||
}
|
||||
|
||||
private String amountStatus(BigDecimal claimedAmount, BigDecimal settlementAmount) {
|
||||
|
||||
+442
-51
@@ -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());
|
||||
|
||||
+188
-17
@@ -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 -> 目标结算明细id(原单场景传空 Map)
|
||||
* @param feeIdMap 原明细费项id -> 目标明细费项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; }
|
||||
|
||||
+1405
File diff suppressed because it is too large
Load Diff
+267
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+622
@@ -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) {
|
||||
}
|
||||
|
||||
}
|
||||
+2
-4
@@ -246,10 +246,8 @@ public class TemporaryCreditLimitServiceImpl extends BaseServiceImpl<TemporaryCr
|
||||
TransportBusinessSupport.validateRequired(temporaryCreditLimit.getProjectCode(), "项目编号不能为空");
|
||||
TransportBusinessSupport.validateRequired(temporaryCreditLimit.getUndertakeDeptName(), "承办部门不能为空");
|
||||
TransportBusinessSupport.validateRequired(temporaryCreditLimit.getValidUntil() == null ? null : temporaryCreditLimit.getValidUntil().toString(), "请选择申请有效期至");
|
||||
if (temporaryCreditLimit.getProjectFundLimit() == null || temporaryCreditLimit.getUsedFundLimit() == null || temporaryCreditLimit.getRemainingFundLimit() == null) {
|
||||
throw new ServiceException("项目额度数据异常,请重新选择项目");
|
||||
}
|
||||
if (temporaryCreditLimit.getRemainingFundLimit().compareTo(BigDecimal.ZERO) < 0) {
|
||||
if (temporaryCreditLimit.getRemainingFundLimit() != null
|
||||
&& temporaryCreditLimit.getRemainingFundLimit().compareTo(BigDecimal.ZERO) < 0) {
|
||||
throw new ServiceException("项目剩余额度不能小于0");
|
||||
}
|
||||
}
|
||||
|
||||
+31
-1
@@ -81,6 +81,8 @@ public class TransportPlanServiceImpl extends BaseServiceImpl<TransportPlanMappe
|
||||
|
||||
@Override
|
||||
public IPage<TransportPlanVO> selectTransportPlanPage(IPage<TransportPlan> page, TransportPlanVO transportPlan) {
|
||||
// 历史手工创建未写入 dept_id,allDept=0 按组织过滤时会被排除
|
||||
backfillMissingDept(transportPlan);
|
||||
IPage<TransportPlan> entityPage = page(page, buildQuery(transportPlan));
|
||||
IPage<TransportPlanVO> resultPage = TransportPlanWrapper.build().pageVO(entityPage);
|
||||
fillCustomerNamesFromContracts(resultPage.getRecords());
|
||||
@@ -151,6 +153,12 @@ public class TransportPlanServiceImpl extends BaseServiceImpl<TransportPlanMappe
|
||||
if (created) {
|
||||
transportPlan.setPlanNo(nextCode());
|
||||
}
|
||||
// 新建或历史空组织一律落到当前组织,避免列表按 dept_id 过滤后看不到
|
||||
if (created || Func.isEmpty(transportPlan.getDeptId()) || transportPlan.getDeptId() <= 0) {
|
||||
Dept dept = TransportBusinessSupport.currentDept("运输计划");
|
||||
transportPlan.setDeptId(dept.getId());
|
||||
transportPlan.setDeptName(dept.getDeptName());
|
||||
}
|
||||
validate(transportPlan);
|
||||
return saveOrUpdate(transportPlan);
|
||||
}
|
||||
@@ -662,11 +670,33 @@ public class TransportPlanServiceImpl extends BaseServiceImpl<TransportPlanMappe
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把所属组织为空、且创建组织等于当前组织的运输计划回填 dept_id,使 allDept=0 列表能查到。
|
||||
*/
|
||||
private void backfillMissingDept(TransportPlanVO transportPlan) {
|
||||
if (Objects.equals(transportPlan.getAllDept(), 1)) {
|
||||
return;
|
||||
}
|
||||
Dept dept = TransportBusinessSupport.currentDept("运输计划");
|
||||
if (dept == null || dept.getId() == null) {
|
||||
return;
|
||||
}
|
||||
update(Wrappers.<TransportPlan>lambdaUpdate()
|
||||
.set(TransportPlan::getDeptId, dept.getId())
|
||||
.set(TransportPlan::getDeptName, dept.getDeptName())
|
||||
.isNull(TransportPlan::getDeptId)
|
||||
.eq(TransportPlan::getCreateDept, dept.getId())
|
||||
.eq(TransportPlan::getIsDeleted, 0));
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<TransportPlan> buildQuery(TransportPlanVO transportPlan) {
|
||||
TransportBusinessSupport.validateAllDept(transportPlan.getAllDept(), "运输计划");
|
||||
LambdaQueryWrapper<TransportPlan> queryWrapper = Wrappers.<TransportPlan>lambdaQuery().eq(TransportPlan::getIsDeleted, 0);
|
||||
if (!Objects.equals(transportPlan.getAllDept(), 1)) {
|
||||
queryWrapper.eq(TransportPlan::getDeptId, TransportBusinessSupport.currentDeptId("运输计划"));
|
||||
Long currentDeptId = TransportBusinessSupport.currentDeptId("运输计划");
|
||||
// dept_id 为空的历史数据按创建组织归入当前组织
|
||||
queryWrapper.and(wrapper -> wrapper.eq(TransportPlan::getDeptId, currentDeptId)
|
||||
.or(item -> item.isNull(TransportPlan::getDeptId).eq(TransportPlan::getCreateDept, currentDeptId)));
|
||||
} else if (Func.isNotEmpty(transportPlan.getDeptId())) {
|
||||
queryWrapper.eq(TransportPlan::getDeptId, transportPlan.getDeptId());
|
||||
}
|
||||
|
||||
+62
-15
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -291,13 +291,13 @@ public class TransportVehicleServiceImpl extends BaseServiceImpl<TransportVehicl
|
||||
if (Func.isEmpty(vehicle.getDrivingLicenseNo())) {
|
||||
throw new ServiceException("行驶证档案编号不能为空");
|
||||
}
|
||||
if (Func.isEmpty(vehicle.getDrivingLicenseEndDate())) {
|
||||
if (vehicle.getDrivingLicenseLongTerm() != 1 && Func.isEmpty(vehicle.getDrivingLicenseEndDate())) {
|
||||
throw new ServiceException("行驶证有效期不能为空");
|
||||
}
|
||||
if (Func.isEmpty(vehicle.getRoadTransportCertNo())) {
|
||||
throw new ServiceException("道路运输证号不能为空");
|
||||
}
|
||||
if (Func.isEmpty(vehicle.getRoadTransportCertEndDate())) {
|
||||
if (vehicle.getRoadTransportCertLongTerm() != 1 && Func.isEmpty(vehicle.getRoadTransportCertEndDate())) {
|
||||
throw new ServiceException("道路运输证有效期不能为空");
|
||||
}
|
||||
if (vehicle.getAnnualReviewLongTerm() != 1 && Func.isEmpty(vehicle.getAnnualReviewEndDate())) {
|
||||
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 运次聚合口径:四档状态 + 接单需求 OR + 运次键
|
||||
* <p>
|
||||
* 四档与接单需求都按子运单的过程配置与接单态在这里推导,Tab 过滤与卡片状态用同一份推导,
|
||||
* 库层不再按已落库的业务状态分档——业务状态靠读取时的惰性校正维持,与过程配置不一致是常态。
|
||||
* <p>
|
||||
* 运次键与它的库层表达式只在这里声明一次:库层分组(重新派单待办)与服务层分组必须是同一个键。
|
||||
*/
|
||||
final class TripAggregation {
|
||||
|
||||
/** 运单业务状态 */
|
||||
static final String PENDING = "pending";
|
||||
static final String WAITING_DISPATCH = "waiting_dispatch";
|
||||
static final String DISPATCHING = "dispatching";
|
||||
static final String RUNNING = "running";
|
||||
static final String COMPLETED = "completed";
|
||||
static final String CANCELLED = "cancelled";
|
||||
|
||||
/** 待接单档:与运单侧 Tab「待接单」取同一组状态 */
|
||||
static final List<String> PENDING_STATUSES = List.of(PENDING, WAITING_DISPATCH, DISPATCHING);
|
||||
/** 终态档 */
|
||||
static final List<String> TERMINAL_STATUSES = List.of(COMPLETED, CANCELLED);
|
||||
/**
|
||||
* 进运次的子单状态:草稿尚未派单、不成运次;已取消要进,
|
||||
* 否则「子单全已取消」会被算成运输中而不是已取消。
|
||||
*/
|
||||
static final List<String> CHILD_STATUSES = List.of(
|
||||
PENDING, WAITING_DISPATCH, DISPATCHING, RUNNING, COMPLETED, CANCELLED
|
||||
);
|
||||
|
||||
/** 待重新派单的口径:子单被拒且这趟车还没结束 */
|
||||
static final List<String> REASSIGN_STATUSES = List.of(PENDING, RUNNING);
|
||||
|
||||
/**
|
||||
* 运次键的库层表达式:有配载按配载单号成组,未配载每张运单自成一组(W 前缀避免与配载单号撞键)。
|
||||
* 服务层换 {@link #keyOf(String, Long)} 算同一个键。
|
||||
*/
|
||||
static final String SQL_TRIP_KEY = "IFNULL(NULLIF(loading_no, ''), CONCAT('W', id))";
|
||||
|
||||
private TripAggregation() {
|
||||
}
|
||||
|
||||
/** 运次键:与 {@link #SQL_TRIP_KEY} 同一个口径 */
|
||||
static String keyOf(String loadingNo, Long id) {
|
||||
return Func.isEmpty(loadingNo) ? "W" + id : loadingNo;
|
||||
}
|
||||
|
||||
/** 运次的四档状态:0 待接单 / 1 运输中 / 2 已完成 / 3 已取消 */
|
||||
static Integer appStatus(String status) {
|
||||
return switch (Func.toStr(status, "")) {
|
||||
case PENDING -> 0;
|
||||
case RUNNING -> 1;
|
||||
case COMPLETED -> 2;
|
||||
case CANCELLED -> 3;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次状态:终态优先——子单全为终态时有已完成取已完成、否则已取消;
|
||||
* 否则需接单且未接单取待接单,其余取运输中。
|
||||
* <p>
|
||||
* 子单集合为空时无从判定,返回空。
|
||||
*/
|
||||
static String statusOf(List<Waybill> children, boolean requireAccept, boolean accepted) {
|
||||
String status = classify(count(children));
|
||||
if (status == null || TERMINAL_STATUSES.contains(status)) {
|
||||
return status;
|
||||
}
|
||||
if (!requireAccept) {
|
||||
return RUNNING;
|
||||
}
|
||||
return accepted ? RUNNING : PENDING;
|
||||
}
|
||||
|
||||
/**
|
||||
* 子单聚合结果:子单总数、终态数、已完成数、待接单档数
|
||||
*/
|
||||
static Counters count(List<Waybill> children) {
|
||||
int total = 0;
|
||||
int terminal = 0;
|
||||
int completed = 0;
|
||||
int pending = 0;
|
||||
for (Waybill child : children) {
|
||||
String status = child == null ? null : child.getBusinessStatus();
|
||||
total++;
|
||||
if (status != null && TERMINAL_STATUSES.contains(status)) {
|
||||
terminal++;
|
||||
}
|
||||
if (COMPLETED.equals(status)) {
|
||||
completed++;
|
||||
}
|
||||
if (status != null && PENDING_STATUSES.contains(status)) {
|
||||
pending++;
|
||||
}
|
||||
}
|
||||
return new Counters(total, terminal, completed, pending);
|
||||
}
|
||||
|
||||
private static String classify(Counters counters) {
|
||||
if (counters.total() == 0) {
|
||||
return null;
|
||||
}
|
||||
if (counters.terminal() == counters.total()) {
|
||||
return counters.completed() > 0 ? COMPLETED : CANCELLED;
|
||||
}
|
||||
return counters.pending() > 0 ? PENDING : RUNNING;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次是否需要接单:任一子运单的过程配置有启用的接单节点即为是。
|
||||
* <p>
|
||||
* 跨项目的运次常遇到「要接单的票」与「不要接单的票」混在一起,按 OR 合并后整单按需接单处理。
|
||||
*/
|
||||
static boolean requiresAccept(List<Waybill> children) {
|
||||
return children.stream()
|
||||
.anyMatch(child -> child != null
|
||||
&& WaybillProcessSupport.requiresDriverAcceptConfirmation(child.getProcessJson()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次接单态:任一子单已接单即为已接单(一次接单把整趟车的子单置为已接单),
|
||||
* 否则有子单待接单为待接单,无从判定时为空。
|
||||
*/
|
||||
static String acceptStatus(List<Waybill> children) {
|
||||
boolean anyPending = false;
|
||||
for (Waybill child : children) {
|
||||
String status = child == null ? null : child.getDriverAcceptStatus();
|
||||
if (WaybillProcessSupport.ACCEPT_ACCEPTED.equals(status)) {
|
||||
return WaybillProcessSupport.ACCEPT_ACCEPTED;
|
||||
}
|
||||
if (WaybillProcessSupport.ACCEPT_PENDING.equals(status)) {
|
||||
anyPending = true;
|
||||
}
|
||||
}
|
||||
return anyPending ? WaybillProcessSupport.ACCEPT_PENDING : null;
|
||||
}
|
||||
|
||||
/** 子单聚合计数 */
|
||||
record Counters(int total, int terminal, int completed, int pending) {
|
||||
}
|
||||
|
||||
}
|
||||
+849
@@ -0,0 +1,849 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.pojo.entity.LoadingManage;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.CargoLineVO;
|
||||
import org.springblade.transport.pojo.vo.TripQueryVO;
|
||||
import org.springblade.transport.pojo.vo.TripStationVO;
|
||||
import org.springblade.transport.pojo.vo.TripTabCountsVO;
|
||||
import org.springblade.transport.pojo.vo.TripVO;
|
||||
import org.springblade.transport.service.ILoadingManageService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.ITripQueryService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.LiveProcessConfigSupport;
|
||||
import org.springblade.transport.support.WaybillCargoSupport;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 运次读服务实现
|
||||
* <p>
|
||||
* 取数两段,段内都是批量查询:段一一次取回收口与行级筛选命中的可见运单行,分组、分档、排序与分页
|
||||
* 都在服务层算完再做;段二按这一页运次的配载单号批量取配载单。子运单不再回查——段一拿回来的行就是它们。
|
||||
* <p>
|
||||
* 分档只按过程配置与接单态推导,与卡片状态同一份口径:库里的业务状态靠读取时的惰性校正维持,
|
||||
* 拿它分档会让「归在待接单的运次、卡片显示运输中」。分页因此必须在过滤后的完整运次序列上做,
|
||||
* 先分页再过滤会把该在第一页的运次挤到后面几页。
|
||||
* <p>
|
||||
* 可见运次=派给该司机的未配载运单(每张自成运次),加上配载单派给该司机、且至少有一张可见子运单的运次。
|
||||
* 司机侧可见范围与运单列表同口径:司机手机号命中且未被该司机拒单;子单里已拒单的不进运次,
|
||||
* 于是整趟车被拒单后整个运次自然消失。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TripQueryServiceImpl implements ITripQueryService {
|
||||
|
||||
private static final int DEFAULT_PAGE_SIZE = 10;
|
||||
private static final int MAX_PAGE_SIZE = 50;
|
||||
|
||||
/** 运输组织类型:有配载单为公路配载,否则普通运输 */
|
||||
private static final String TRANSPORT_LOAD = "load";
|
||||
private static final String TRANSPORT_COMMON = "common";
|
||||
|
||||
/** 未配载运次的键前缀:与 TripAggregation.SQL_TRIP_KEY 的 W 前缀同一个口径 */
|
||||
private static final String LONE_KEY_PREFIX = "W";
|
||||
|
||||
/** 异常筛选的取值:命中未完成异常的子单 */
|
||||
private static final String EXCEPTION_YES = "exception";
|
||||
private static final String EXCEPTION_NO = "normal";
|
||||
|
||||
/** 关键字在子单上匹配的字段 */
|
||||
private static final String[] KEYWORD_COLUMNS = {
|
||||
"waybill_no", "loading_no", "departure_name", "arrival_name",
|
||||
"departure_address", "arrival_address", "vehicle_no"
|
||||
};
|
||||
|
||||
private final IWaybillService waybillService;
|
||||
private final ILoadingManageService loadingManageService;
|
||||
private final IProcessConfigService processConfigService;
|
||||
|
||||
@Override
|
||||
public IPage<TripVO> page(TripQueryVO query) {
|
||||
TripQueryVO condition = condition(query);
|
||||
int pageNo = pageNo(condition.getCurrent());
|
||||
int pageSize = pageSize(condition.getSize());
|
||||
if (!scoped(condition)) {
|
||||
// 收口缺失时返回空页,不能退回「不按司机收口」:那等于把别人的运次发出去
|
||||
return new Page<>(pageNo, pageSize);
|
||||
}
|
||||
List<TripRows> trips = trips(condition);
|
||||
Page<TripVO> page = new Page<>(pageNo, pageSize, trips.size());
|
||||
int from = Math.min((pageNo - 1) * pageSize, trips.size());
|
||||
int to = Math.min(from + pageSize, trips.size());
|
||||
page.setRecords(assemble(trips.subList(from, to)));
|
||||
return page;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TripTabCountsVO tabCounts(TripQueryVO query) {
|
||||
TripQueryVO condition = condition(query);
|
||||
TripTabCountsVO counts = new TripTabCountsVO();
|
||||
if (!scoped(condition)) {
|
||||
return counts;
|
||||
}
|
||||
// 一次取数算清四档:分档口径与列表、卡片同一份推导,不再按库里的业务状态分批计数
|
||||
long pending = 0;
|
||||
long doing = 0;
|
||||
long done = 0;
|
||||
for (TripRows trip : trips(withoutTab(condition))) {
|
||||
Integer tier = trip.tier();
|
||||
if (tier == null) {
|
||||
continue;
|
||||
}
|
||||
switch (tier) {
|
||||
case 0 -> pending++;
|
||||
case 1 -> doing++;
|
||||
case 2 -> done++;
|
||||
default -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
counts.setPending(pending);
|
||||
counts.setDoing(doing);
|
||||
counts.setDone(done);
|
||||
counts.setAll(pending + doing + done);
|
||||
return counts;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TripVO detail(TripQueryVO query) {
|
||||
TripQueryVO condition = condition(query);
|
||||
Long id = tripId(condition.getTripId());
|
||||
if (id == null || !scoped(condition)) {
|
||||
throw new ServiceException("运次不存在或无权操作");
|
||||
}
|
||||
LoadingManage loading = visibleLoading(id, condition);
|
||||
if (loading != null) {
|
||||
List<Waybill> children = loadChildren(Set.of(loading.getLoadingNo()), Set.of())
|
||||
.getOrDefault(loading.getLoadingNo(), List.of());
|
||||
if (children.isEmpty()) {
|
||||
// 子单全部被拒单(或已释放)的配载运次对司机不可见,与列表口径一致
|
||||
throw new ServiceException("运次不存在或无权操作");
|
||||
}
|
||||
// 详情与列表同一份分档口径:接单需求也按项目生效中的配置判,否则列表说待接单、详情与接收入口说无需接单
|
||||
applyLiveProcessConfig(children);
|
||||
return loadingTrip(loading, children, true);
|
||||
}
|
||||
Waybill waybill = visibleWaybill(id, condition);
|
||||
if (waybill == null) {
|
||||
throw new ServiceException("运次不存在或无权操作");
|
||||
}
|
||||
applyLiveProcessConfig(List.of(waybill));
|
||||
return waybillTrip(waybill, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部可见运次:一次取回运单行后在服务层分组、分档、筛选并按排序键倒序
|
||||
* <p>
|
||||
* 分档用过程配置与接单态推导,于是 Tab 与卡片必然同档;行级筛选(组织类型 / 异常 / 创建时间)落在库层,
|
||||
* 命中的行同时决定这一趟车的子单集合与分档。
|
||||
*/
|
||||
private List<TripRows> trips(TripQueryVO condition) {
|
||||
Map<String, List<Waybill>> grouped = new LinkedHashMap<>();
|
||||
for (Waybill row : visibleRows(condition)) {
|
||||
grouped.computeIfAbsent(TripAggregation.keyOf(row.getLoadingNo(), row.getId()),
|
||||
ignored -> new ArrayList<>()).add(row);
|
||||
}
|
||||
Set<String> excluded = condition.isExcludeRejectedTrips() ? rejectedTripKeys() : Set.of();
|
||||
Set<Integer> tiers = tiers(condition);
|
||||
List<TripRows> trips = new ArrayList<>(grouped.size());
|
||||
grouped.forEach((key, rows) -> {
|
||||
if (excluded.contains(key)) {
|
||||
return;
|
||||
}
|
||||
List<Waybill> children = sortedChildren(rows);
|
||||
if (!inScope(children, condition)) {
|
||||
return;
|
||||
}
|
||||
boolean requireAccept = TripAggregation.requiresAccept(children);
|
||||
boolean accepted = WaybillProcessSupport.ACCEPT_ACCEPTED.equals(TripAggregation.acceptStatus(children));
|
||||
String status = TripAggregation.statusOf(children, requireAccept, accepted);
|
||||
Integer tier = TripAggregation.appStatus(status);
|
||||
if (tier == null || !tiers.contains(tier)) {
|
||||
return;
|
||||
}
|
||||
trips.add(new TripRows(key, rows.get(0).getLoadingNo(), children, status, tier, maxCreateTime(children)));
|
||||
});
|
||||
// 排序键是子单 create_time 的最大值,倒序;键相同再按运次键定序,免得同分页里顺序漂移
|
||||
trips.sort(Comparator.comparing(TripRows::sortTime, Comparator.nullsLast(Comparator.reverseOrder()))
|
||||
.thenComparing(TripRows::key));
|
||||
return trips;
|
||||
}
|
||||
|
||||
/** 子单按创建时刻升序:站点拼装与货物汇总都按这个顺序 */
|
||||
private List<Waybill> sortedChildren(List<Waybill> rows) {
|
||||
List<Waybill> children = new ArrayList<>(rows);
|
||||
children.sort(Comparator.comparing(Waybill::getCreateTime, Comparator.nullsLast(Comparator.naturalOrder())));
|
||||
return children;
|
||||
}
|
||||
|
||||
/**
|
||||
* 段一:一次取回该收口与行级筛选命中的可见运单行
|
||||
* <p>
|
||||
* 只 select 拼装与分档用得上的列:运单表上有几张大 JSON 列,整行拖回不值得。
|
||||
* goods_json 必须随行取回——卡片上的货名、货物汇总与合计重量都以它为准,一张运单可以录多条货物,
|
||||
* 主表 cargo_name 只落了第一行。可见口径与运单列表同一条:状态进运次的才取,被该司机拒单的不取。
|
||||
*/
|
||||
private List<Waybill> visibleRows(TripQueryVO condition) {
|
||||
LambdaQueryWrapper<Waybill> wrapper = Wrappers.<Waybill>lambdaQuery()
|
||||
.select(Waybill::getId, Waybill::getLoadingNo, Waybill::getWaybillNo, Waybill::getBusinessStatus,
|
||||
Waybill::getDriverAcceptStatus, Waybill::getProcessJson, Waybill::getProjectId, Waybill::getProjectName,
|
||||
Waybill::getCargoName, Waybill::getCargoType, Waybill::getQuantity, Waybill::getQuantityUnit,
|
||||
// 多货物运单的主表字段只有首行,货物明细只能从 goods_json 拿
|
||||
Waybill::getGoodsJson,
|
||||
Waybill::getDepartureName, Waybill::getDepartureAddress, Waybill::getArrivalName,
|
||||
Waybill::getArrivalAddress, Waybill::getDriverName, Waybill::getDriverPhone, Waybill::getVehicleNo,
|
||||
Waybill::getCustomerName, Waybill::getTransportType, Waybill::getTaskRemark,
|
||||
// 未配载运次没有配载单可依,计划时间要取运单自己的,故必须随行取回
|
||||
Waybill::getEstimatedStartTime, Waybill::getEstimatedEndTime, Waybill::getCreateTime)
|
||||
.in(Waybill::getBusinessStatus, TripAggregation.CHILD_STATUSES)
|
||||
.and(visible -> visible.isNull(Waybill::getDriverAcceptStatus)
|
||||
.or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED));
|
||||
applyScope(wrapper, condition);
|
||||
applyDeptScope(wrapper, condition);
|
||||
applyOrgTypeFilter(wrapper, condition);
|
||||
applyKeyword(wrapper, condition.getKeyword());
|
||||
List<Waybill> rows = waybillService.list(wrapper);
|
||||
applyLiveProcessConfig(rows);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组织收口:条件里给了组织 ID 集合时只出这些组织的运单行,空集合表示不过滤。
|
||||
* <p>
|
||||
* 与司机手机号收口一样落在库层的运单行上:分档、分页、总数都由它缩过的行集合算出来。
|
||||
* 读服务不自己解析当前用户的组织——它同时服务司机端与管理端,可见范围由调用方算好再传。
|
||||
*/
|
||||
private void applyDeptScope(LambdaQueryWrapper<Waybill> wrapper, TripQueryVO condition) {
|
||||
if (Func.isEmpty(condition.getDeptIds())) {
|
||||
return;
|
||||
}
|
||||
wrapper.in(Waybill::getDeptId, condition.getDeptIds());
|
||||
}
|
||||
|
||||
/**
|
||||
* 运输组织类型按子单行筛:它在同一趟车里必然是同一个值(配载的每张子单带同一个配载单号,
|
||||
* 未配载的每张自成一趟车),所以这一层不会把一趟车拆开。
|
||||
*/
|
||||
private void applyOrgTypeFilter(LambdaQueryWrapper<Waybill> wrapper, TripQueryVO condition) {
|
||||
if (TRANSPORT_LOAD.equals(condition.getTransportOrgType())) {
|
||||
wrapper.apply("IFNULL(loading_no, '') <> ''");
|
||||
} else if (TRANSPORT_COMMON.equals(condition.getTransportOrgType())) {
|
||||
wrapper.apply("IFNULL(loading_no, '') = ''");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 这趟车在不在筛选范围内:异常按「任一子单命中」,创建时间按子单 create_time 的最大值(运次列表的排序键,
|
||||
* 也是调度端卡片上显示的创建时间)。两个判据都按整趟车的子单集合算——先按筛选把子单集合缩小再分档,
|
||||
* 会让同一趟车归在某一档、卡片却显示另一档。
|
||||
* <p>
|
||||
* 筛选只决定这趟车在不在范围内,分档仍看它完整的可见子单集合。
|
||||
*/
|
||||
private boolean inScope(List<Waybill> children, TripQueryVO condition) {
|
||||
Set<Long> exceptionIds = condition.getExceptionWaybillIds();
|
||||
if (Func.isNotEmpty(exceptionIds)) {
|
||||
boolean hit = children.stream().map(Waybill::getId).anyMatch(exceptionIds::contains);
|
||||
if (EXCEPTION_YES.equals(condition.getException()) && !hit) {
|
||||
return false;
|
||||
}
|
||||
if (EXCEPTION_NO.equals(condition.getException()) && hit) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Date created = maxCreateTime(children);
|
||||
if (condition.getCreatedFrom() != null
|
||||
&& (created == null || created.before(condition.getCreatedFrom()))) {
|
||||
return false;
|
||||
}
|
||||
return condition.getCreatedTo() == null
|
||||
|| (created != null && !created.after(condition.getCreatedTo()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按单票路径的口径把生效中的过程配置写回内存里的行:项目启用中的配置优先,它没有可打卡节点时回退运单快照。
|
||||
* <p>
|
||||
* 只改内存里的对象,分档与卡片下发都读它,不落库;生效配置一次取回后按项目复用,不逐条回查。
|
||||
* 取法与二选一规则都在 {@link LiveProcessConfigSupport},这里只做批量写回。
|
||||
*/
|
||||
private void applyLiveProcessConfig(List<Waybill> rows) {
|
||||
if (Func.isEmpty(rows)) {
|
||||
return;
|
||||
}
|
||||
Set<Long> projectIds = rows.stream().map(Waybill::getProjectId).filter(Objects::nonNull)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
if (projectIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<Long, String> live = LiveProcessConfigSupport.byProjects(processConfigService, projectIds);
|
||||
if (live.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (Waybill row : rows) {
|
||||
String liveJson = live.get(row.getProjectId());
|
||||
if (Func.isNotEmpty(liveJson)) {
|
||||
row.setProcessJson(LiveProcessConfigSupport.resolveForPunch(liveJson, row.getProcessJson()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 有子单被拒、且这趟车还没结束的运次键:整单拒绝后这趟车不再算待处理
|
||||
*/
|
||||
private Set<String> rejectedTripKeys() {
|
||||
return waybillService.list(Wrappers.<Waybill>lambdaQuery()
|
||||
.select(Waybill::getId, Waybill::getLoadingNo)
|
||||
.eq(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED)
|
||||
.in(Waybill::getBusinessStatus, TripAggregation.REASSIGN_STATUSES))
|
||||
.stream()
|
||||
.map(row -> TripAggregation.keyOf(row.getLoadingNo(), row.getId()))
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
/**
|
||||
* 要哪几档:多档优先,其次单档,都不传按「全部」=待接单/进行中/已完成(不含已取消)
|
||||
*/
|
||||
private Set<Integer> tiers(TripQueryVO condition) {
|
||||
if (Func.isNotEmpty(condition.getStatuses())) {
|
||||
return new LinkedHashSet<>(condition.getStatuses());
|
||||
}
|
||||
if (condition.getStatus() != null) {
|
||||
return Set.of(condition.getStatus());
|
||||
}
|
||||
return Set.of(0, 1, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次可见范围:配载段按配载单的司机手机号收口,未配载段按运单的司机手机号收口。
|
||||
* <p>
|
||||
* 手机号允许带空格,与运单列表的收口判据一致。
|
||||
*/
|
||||
private void applyScope(LambdaQueryWrapper<Waybill> wrapper, TripQueryVO condition) {
|
||||
if (condition.isAllDrivers()) {
|
||||
return;
|
||||
}
|
||||
String phone = Func.toStr(condition.getDriverPhone(), "").trim();
|
||||
wrapper.and(scope -> scope
|
||||
.apply("(IFNULL(loading_no, '') <> '' AND loading_no IN (SELECT lm.loading_no FROM blade_loading_manage lm"
|
||||
+ " WHERE lm.is_deleted = 0 AND lm.driver_phone = {0}))", phone)
|
||||
.or(lone -> lone
|
||||
.apply("IFNULL(loading_no, '') = ''")
|
||||
.and(row -> row.eq(Waybill::getDriverPhone, phone)
|
||||
.or().apply("REPLACE(IFNULL(driver_phone, ''), ' ', '') = {0}", phone))));
|
||||
}
|
||||
|
||||
/**
|
||||
* 关键字按运次匹配:任一子单或配载单号命中即整趟车命中。
|
||||
* <p>
|
||||
* 先按关键字查出命中的运次键,再要求这一行的所属运次在其中——关键字不缩小分组内的子单集合,
|
||||
* 否则状态与排序会按命中的那一部分子单算。
|
||||
*/
|
||||
private void applyKeyword(LambdaQueryWrapper<Waybill> wrapper, String keyword) {
|
||||
if (Func.isEmpty(keyword)) {
|
||||
return;
|
||||
}
|
||||
Set<String> keys = keywordTripKeys(keyword.trim());
|
||||
if (keys.isEmpty()) {
|
||||
wrapper.apply("1 = 0");
|
||||
return;
|
||||
}
|
||||
Set<String> loadingNos = keys.stream().filter(key -> !key.startsWith(LONE_KEY_PREFIX))
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
Set<Long> loneIds = keys.stream().filter(key -> key.startsWith(LONE_KEY_PREFIX))
|
||||
.map(key -> key.substring(LONE_KEY_PREFIX.length()))
|
||||
.map(this::tripId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
wrapper.and(matched -> {
|
||||
if (!loadingNos.isEmpty()) {
|
||||
matched.in(Waybill::getLoadingNo, loadingNos);
|
||||
}
|
||||
if (!loneIds.isEmpty()) {
|
||||
if (!loadingNos.isEmpty()) {
|
||||
matched.or();
|
||||
}
|
||||
matched.apply("IFNULL(loading_no, '') = ''").in(Waybill::getId, loneIds);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 关键字命中的运次键:在子单上按关键字找一遍,再换算成运次键 */
|
||||
private Set<String> keywordTripKeys(String keyword) {
|
||||
String like = "%" + keyword + "%";
|
||||
Object[] params = new Object[KEYWORD_COLUMNS.length];
|
||||
StringBuilder matched = new StringBuilder();
|
||||
for (int i = 0; i < KEYWORD_COLUMNS.length; i++) {
|
||||
if (i > 0) {
|
||||
matched.append(" OR ");
|
||||
}
|
||||
matched.append(KEYWORD_COLUMNS[i]).append(" LIKE {").append(i).append('}');
|
||||
params[i] = like;
|
||||
}
|
||||
return waybillService.list(Wrappers.<Waybill>lambdaQuery()
|
||||
.select(Waybill::getId, Waybill::getLoadingNo)
|
||||
.apply("(" + matched + ")", params))
|
||||
.stream()
|
||||
.map(row -> TripAggregation.keyOf(row.getLoadingNo(), row.getId()))
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把这一页的运次组装成运次:先批量取配载单,再逐趟拼装
|
||||
* <p>
|
||||
* 子运单就是段一取回来的那些行,不再回查:回查会拿回未经行级筛选的子单,
|
||||
* 卡片的状态与货物汇总就会与分档、与筛选结果对不上。
|
||||
*/
|
||||
private List<TripVO> assemble(List<TripRows> trips) {
|
||||
if (Func.isEmpty(trips)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
Set<String> loadingNos = trips.stream()
|
||||
.map(TripRows::loadingNo)
|
||||
.filter(Func::isNotEmpty)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
Map<String, LoadingManage> loadings = loadLoadings(loadingNos);
|
||||
List<TripVO> result = new ArrayList<>(trips.size());
|
||||
for (TripRows trip : trips) {
|
||||
if (Func.isEmpty(trip.loadingNo())) {
|
||||
result.add(waybillTrip(trip.children().get(0), false));
|
||||
continue;
|
||||
}
|
||||
LoadingManage loading = loadings.get(trip.loadingNo());
|
||||
if (loading == null) {
|
||||
// 配载单被并发删除:不给出没有主键的运次
|
||||
continue;
|
||||
}
|
||||
result.add(loadingTrip(loading, trip.children(), false));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 段二:按本页运次的配载单号一次取回配载单
|
||||
*/
|
||||
private Map<String, LoadingManage> loadLoadings(Set<String> loadingNos) {
|
||||
if (loadingNos.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
return loadingManageService.list(Wrappers.<LoadingManage>lambdaQuery()
|
||||
.in(LoadingManage::getLoadingNo, loadingNos))
|
||||
.stream()
|
||||
.collect(Collectors.toMap(LoadingManage::getLoadingNo, Function.identity(), (first, ignored) -> first));
|
||||
}
|
||||
|
||||
/**
|
||||
* 段二:按本页运次的配载单号与未配载运单主键一次取回子运单,不逐条查
|
||||
* <p>
|
||||
* 子单归属一律以配载单号为准:库层就是按配载单号分组聚合的,服务层复核用别的关联关系,
|
||||
* 同一页里就会出现「Tab 说它在进行中、卡片按子单算出来是待接单」。
|
||||
* 配载单上的运单号集合只是派单时的快照,子单被释放(配载单号清空)后不再属于该运次。
|
||||
*/
|
||||
private Map<String, List<Waybill>> loadChildren(Set<String> loadingNos, Set<Long> loneIds) {
|
||||
if (loadingNos.isEmpty() && loneIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
List<Waybill> children = waybillService.list(Wrappers.<Waybill>lambdaQuery()
|
||||
.in(Waybill::getBusinessStatus, TripAggregation.CHILD_STATUSES)
|
||||
.and(visible -> visible.isNull(Waybill::getDriverAcceptStatus)
|
||||
.or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED))
|
||||
.and(scope -> scope.in(Func.isNotEmpty(loadingNos), Waybill::getLoadingNo, loadingNos)
|
||||
.or(Func.isNotEmpty(loneIds), lone -> lone.in(Waybill::getId, loneIds))));
|
||||
Map<String, List<Waybill>> grouped = new LinkedHashMap<>();
|
||||
for (Waybill child : children) {
|
||||
String key = Func.isEmpty(child.getLoadingNo()) ? String.valueOf(child.getId()) : child.getLoadingNo();
|
||||
grouped.computeIfAbsent(key, ignored -> new ArrayList<>()).add(child);
|
||||
}
|
||||
grouped.values().forEach(group -> group.sort(
|
||||
Comparator.comparing(Waybill::getCreateTime, Comparator.nullsLast(Comparator.naturalOrder()))));
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配载运次:司机、车辆、路线取配载单(配载单是运输执行主体),状态与接单需求由子单聚合
|
||||
*/
|
||||
private TripVO loadingTrip(LoadingManage loading, List<Waybill> children, boolean withPayload) {
|
||||
TripVO trip = new TripVO();
|
||||
trip.setId(tripIdOf(loading.getId()));
|
||||
trip.setLoadingNo(loading.getLoadingNo());
|
||||
trip.setTripNo(loading.getLoadingNo());
|
||||
trip.setTransportOrgType(TRANSPORT_LOAD);
|
||||
trip.setDriverName(Func.toStr(loading.getDriverName(), ""));
|
||||
trip.setDriverPhone(Func.toStr(loading.getDriverPhone(), ""));
|
||||
trip.setVehicleNo(Func.toStr(loading.getVehicleNo(), ""));
|
||||
trip.setTrailerVehicleNo(Func.toStr(loading.getTrailerVehicleNo(), ""));
|
||||
trip.setProjectName(Func.toStr(loading.getProjectName(), ""));
|
||||
trip.setCustomerName(Func.toStr(loading.getCustomerName(), ""));
|
||||
trip.setTransportType(Func.toStr(loading.getTransportType(), ""));
|
||||
trip.setCargoName(firstNotEmpty(loading.getCargoName(), children, Waybill::getCargoName));
|
||||
trip.setCargoType(firstNotEmpty(loading.getCargoType(), children, Waybill::getCargoType));
|
||||
trip.setDepartureAddress(Func.toStr(loading.getDepartureAddress(), ""));
|
||||
trip.setTransitAddress(Func.toStr(loading.getTransitAddress(), ""));
|
||||
trip.setArrivalAddress(Func.toStr(loading.getArrivalAddress(), ""));
|
||||
trip.setEstimatedStartDate(loading.getEstimatedStartDate());
|
||||
trip.setEstimatedEndDate(loading.getEstimatedEndDate());
|
||||
trip.setTaskRemark(Func.toStr(loading.getTaskRemark(), ""));
|
||||
if (withPayload) {
|
||||
trip.setRouteJson(loading.getRouteJson());
|
||||
trip.setGoodsJson(loading.getGoodsJson());
|
||||
}
|
||||
fillAggregation(trip, children);
|
||||
fillRouteAndCargo(trip, loading, children, withPayload);
|
||||
return trip;
|
||||
}
|
||||
|
||||
/**
|
||||
* 未配载运次:整张运单就是这一趟车
|
||||
*/
|
||||
private TripVO waybillTrip(Waybill waybill, boolean withPayload) {
|
||||
TripVO trip = new TripVO();
|
||||
trip.setId(tripIdOf(waybill.getId()));
|
||||
trip.setTripNo(waybill.getWaybillNo());
|
||||
trip.setTransportOrgType(TRANSPORT_COMMON);
|
||||
trip.setDriverName(Func.toStr(waybill.getDriverName(), ""));
|
||||
trip.setDriverPhone(Func.toStr(waybill.getDriverPhone(), ""));
|
||||
trip.setVehicleNo(Func.toStr(waybill.getVehicleNo(), ""));
|
||||
trip.setProjectName(Func.toStr(waybill.getProjectName(), ""));
|
||||
trip.setCustomerName(Func.toStr(waybill.getCustomerName(), ""));
|
||||
trip.setTransportType(Func.toStr(waybill.getTransportType(), ""));
|
||||
trip.setCargoName(Func.toStr(waybill.getCargoName(), ""));
|
||||
trip.setCargoType(Func.toStr(waybill.getCargoType(), ""));
|
||||
trip.setDepartureAddress(Func.toStr(waybill.getDepartureAddress(), ""));
|
||||
trip.setArrivalAddress(Func.toStr(waybill.getArrivalAddress(), ""));
|
||||
// 未配载没有配载单可依,计划时间只能取这张运单自己的
|
||||
trip.setEstimatedStartDate(waybill.getEstimatedStartTime());
|
||||
trip.setEstimatedEndDate(waybill.getEstimatedEndTime());
|
||||
trip.setTaskRemark(Func.toStr(waybill.getTaskRemark(), ""));
|
||||
if (withPayload) {
|
||||
trip.setRouteJson(waybill.getRouteJson());
|
||||
trip.setGoodsJson(waybill.getGoodsJson());
|
||||
}
|
||||
fillAggregation(trip, List.of(waybill));
|
||||
fillRouteAndCargo(trip, null, List.of(waybill), withPayload);
|
||||
return trip;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚合出运次的状态、接单需求、接单态与排序键:与分档用同一份推导,Tab 与卡片因此必然同档
|
||||
*/
|
||||
private void fillAggregation(TripVO trip, List<Waybill> children) {
|
||||
String acceptStatus = TripAggregation.acceptStatus(children);
|
||||
boolean requireAccept = TripAggregation.requiresAccept(children);
|
||||
boolean accepted = WaybillProcessSupport.ACCEPT_ACCEPTED.equals(acceptStatus);
|
||||
trip.setAcceptStatus(acceptStatus);
|
||||
trip.setRequireAccept(requireAccept);
|
||||
trip.setStatus(TripAggregation.appStatus(TripAggregation.statusOf(children, requireAccept, accepted)));
|
||||
trip.setWaybillCount(children.size());
|
||||
trip.setSortTime(maxCreateTime(children));
|
||||
trip.setWaybills(children.stream().map(this::toWaybillVO).toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点与货物都按整趟车算:站点明细只在详情下发,列表带走起/经/终、站点数与货物汇总
|
||||
*/
|
||||
private void fillRouteAndCargo(TripVO trip, LoadingManage loading, List<Waybill> children, boolean withPayload) {
|
||||
List<TripStationVO> stations = TripStationAssembler.assemble(loading, children);
|
||||
trip.setDepartureName(TripStationAssembler.departureName(stations));
|
||||
trip.setTransitName(TripStationAssembler.transitName(stations));
|
||||
trip.setArrivalName(TripStationAssembler.arrivalName(stations));
|
||||
trip.setStationCount(stations.size());
|
||||
if (withPayload) {
|
||||
trip.setStations(stations);
|
||||
}
|
||||
List<WaybillCargoSupport.Cargo> cargos = children.stream()
|
||||
.flatMap(child -> WaybillCargoSupport.of(child).stream())
|
||||
.toList();
|
||||
List<String> cargoNames = WaybillCargoSupport.cargoNames(cargos);
|
||||
// 运次级货名覆盖整趟车:主表 cargo_name 只是首行,配载单的 cargo_name 也是派单时的快照
|
||||
trip.setCargoNames(cargoNames);
|
||||
if (!cargoNames.isEmpty()) {
|
||||
trip.setCargoName(String.join("、", cargoNames));
|
||||
}
|
||||
List<String> cargoTypes = cargos.stream()
|
||||
.map(WaybillCargoSupport.Cargo::cargoType)
|
||||
.filter(Func::isNotEmpty)
|
||||
.distinct()
|
||||
.toList();
|
||||
trip.setCargoTypes(cargoTypes);
|
||||
if (!cargoTypes.isEmpty()) {
|
||||
trip.setCargoType(String.join("、", cargoTypes));
|
||||
}
|
||||
trip.setCargoSummary(cargoSummary(children));
|
||||
trip.setCargos(cargoLines(cargos));
|
||||
fillTotalQuantity(trip, children);
|
||||
fillWeighing(trip, cargos);
|
||||
}
|
||||
|
||||
/**
|
||||
* 货物汇总:同名货物合并且单位一致时取数量合计,没有数量的只列货名
|
||||
* <p>
|
||||
* 一条运单可以有多条货物,明细在 goods_json;主表 cargo_name 只落了第一行,拿它汇总等于把其余货物丢掉。
|
||||
*/
|
||||
private String cargoSummary(List<Waybill> children) {
|
||||
Map<String, Map<String, BigDecimal>> grouped = new LinkedHashMap<>();
|
||||
for (Waybill child : children) {
|
||||
List<WaybillCargoSupport.Cargo> cargos = WaybillCargoSupport.of(child);
|
||||
if (cargos.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
for (WaybillCargoSupport.Cargo cargo : cargos) {
|
||||
String name = cargo.displayName();
|
||||
String unit = Func.toStr(cargo.quantityUnit(), "");
|
||||
Map<String, BigDecimal> byUnit = grouped.computeIfAbsent(name, key -> new LinkedHashMap<>());
|
||||
BigDecimal quantity = cargo.quantity() == null ? BigDecimal.ZERO : cargo.quantity();
|
||||
byUnit.merge(unit, quantity, BigDecimal::add);
|
||||
}
|
||||
}
|
||||
List<String> parts = new ArrayList<>();
|
||||
grouped.forEach((name, byUnit) -> byUnit.forEach((unit, quantity) ->
|
||||
parts.add(quantity.signum() == 0 ? name : name + " " + formatQuantity(quantity, unit))));
|
||||
return String.join("、", parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 货物明细行:与 cargoSummary 同一套合并口径,但数量与单位分开留,供详情页逐行展示
|
||||
*/
|
||||
private List<CargoLineVO> cargoLines(List<WaybillCargoSupport.Cargo> cargos) {
|
||||
List<CargoLineVO> lines = new ArrayList<>();
|
||||
for (WaybillCargoSupport.Line line : WaybillCargoSupport.lines(cargos)) {
|
||||
CargoLineVO vo = new CargoLineVO();
|
||||
vo.setCargoName(line.name());
|
||||
vo.setQuantity(line.quantity() == null ? "" : formatQuantity(line.quantity(), line.unit()));
|
||||
lines.add(vo);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合计重量:只有全部货物同单位时才能相加,单位不一致时留空(汇总文案里仍分单位列出)
|
||||
* <p>
|
||||
* 按货物行算而不是按运单行算:一张运单里的多条货物可能是不同的单位与数量,
|
||||
* 主表 quantity 是这些行的合计、quantity_unit 只是首行的单位,拿它当合计会把「20堆 + 50方」说成「70堆」。
|
||||
*/
|
||||
private void fillTotalQuantity(TripVO trip, List<Waybill> children) {
|
||||
String unit = null;
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
boolean any = false;
|
||||
for (Waybill child : children) {
|
||||
for (WaybillCargoSupport.Cargo cargo : WaybillCargoSupport.of(child)) {
|
||||
String cargoUnit = Func.toStr(cargo.quantityUnit(), "");
|
||||
if (cargo.quantity() == null || Func.isEmpty(cargoUnit)) {
|
||||
return;
|
||||
}
|
||||
if (unit == null) {
|
||||
unit = cargoUnit;
|
||||
} else if (!unit.equals(cargoUnit)) {
|
||||
return;
|
||||
}
|
||||
total = total.add(cargo.quantity());
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
if (!any || unit == null) {
|
||||
return;
|
||||
}
|
||||
trip.setTotalQuantity(total);
|
||||
trip.setQuantityUnit(unit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计重合计:卡片上那个「货物重量」,口径见 WaybillCargoSupport#weighing
|
||||
*/
|
||||
private void fillWeighing(TripVO trip, List<WaybillCargoSupport.Cargo> cargos) {
|
||||
WaybillCargoSupport.Quantity weighing = WaybillCargoSupport.weighing(cargos);
|
||||
if (weighing == null) {
|
||||
return;
|
||||
}
|
||||
trip.setWeighingQuantity(weighing.value());
|
||||
trip.setWeighingUnit(weighing.unit());
|
||||
}
|
||||
|
||||
private String formatQuantity(BigDecimal quantity, String unit) {
|
||||
String value = quantity.stripTrailingZeros().toPlainString();
|
||||
return Func.isEmpty(unit) ? value : value + unit;
|
||||
}
|
||||
|
||||
private TripVO.TripWaybillVO toWaybillVO(Waybill waybill) {
|
||||
TripVO.TripWaybillVO child = new TripVO.TripWaybillVO();
|
||||
child.setId(tripIdOf(waybill.getId()));
|
||||
child.setWaybillNo(waybill.getWaybillNo());
|
||||
child.setBusinessStatus(waybill.getBusinessStatus());
|
||||
child.setAcceptStatus(waybill.getDriverAcceptStatus());
|
||||
child.setRequireAccept(WaybillProcessSupport.requiresDriverAcceptConfirmation(waybill.getProcessJson()));
|
||||
child.setProjectId(waybill.getProjectId());
|
||||
child.setProjectName(waybill.getProjectName());
|
||||
// 货名按运单上实际录入的货物给全:主表 cargo_name 只是首行,多货物运单会漏掉其余货物
|
||||
List<WaybillCargoSupport.Cargo> cargos = WaybillCargoSupport.of(waybill);
|
||||
child.setCargoName(String.join("、", WaybillCargoSupport.cargoNames(cargos)));
|
||||
child.setCargoNames(WaybillCargoSupport.cargoNames(cargos));
|
||||
child.setCargoTypes(cargos.stream()
|
||||
.map(WaybillCargoSupport.Cargo::cargoType)
|
||||
.filter(Func::isNotEmpty)
|
||||
.distinct()
|
||||
.toList());
|
||||
child.setCargoType(waybill.getCargoType());
|
||||
child.setQuantity(waybill.getQuantity());
|
||||
child.setQuantityUnit(waybill.getQuantityUnit());
|
||||
child.setDepartureName(waybill.getDepartureName());
|
||||
child.setDepartureAddress(waybill.getDepartureAddress());
|
||||
child.setArrivalName(waybill.getArrivalName());
|
||||
child.setArrivalAddress(waybill.getArrivalAddress());
|
||||
child.setCreateTime(waybill.getCreateTime());
|
||||
return child;
|
||||
}
|
||||
|
||||
/** 排序键:子单 create_time 的最大值 */
|
||||
private Date maxCreateTime(List<Waybill> children) {
|
||||
return children.stream()
|
||||
.map(Waybill::getCreateTime)
|
||||
.filter(Objects::nonNull)
|
||||
.max(Comparator.naturalOrder())
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private String firstNotEmpty(String preferred, List<Waybill> children, Function<Waybill, String> getter) {
|
||||
if (Func.isNotEmpty(preferred)) {
|
||||
return preferred;
|
||||
}
|
||||
return children.stream().map(getter).filter(Func::isNotEmpty).findFirst().orElse("");
|
||||
}
|
||||
|
||||
/**
|
||||
* 按运次主键取配载单:主键命中配载单即配载运次,再按司机收口
|
||||
*/
|
||||
private LoadingManage visibleLoading(Long id, TripQueryVO condition) {
|
||||
LoadingManage loading = loadingManageService.getById(id);
|
||||
if (loading == null) {
|
||||
return null;
|
||||
}
|
||||
if (condition.isAllDrivers()) {
|
||||
return loading;
|
||||
}
|
||||
return samePhone(loading.getDriverPhone(), condition.getDriverPhone()) ? loading : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按运次主键取未配载运单:必须还没配载、在可见状态里,且属于收口范围
|
||||
*/
|
||||
private Waybill visibleWaybill(Long id, TripQueryVO condition) {
|
||||
Waybill waybill = waybillService.getById(id);
|
||||
if (waybill == null
|
||||
|| Func.isNotEmpty(waybill.getLoadingNo())
|
||||
|| !TripAggregation.CHILD_STATUSES.contains(waybill.getBusinessStatus())
|
||||
|| WaybillProcessSupport.ACCEPT_REJECTED.equals(waybill.getDriverAcceptStatus())) {
|
||||
return null;
|
||||
}
|
||||
if (condition.isAllDrivers()) {
|
||||
return waybill;
|
||||
}
|
||||
return samePhone(waybill.getDriverPhone(), condition.getDriverPhone()) ? waybill : null;
|
||||
}
|
||||
|
||||
private boolean scoped(TripQueryVO condition) {
|
||||
return condition.isAllDrivers() || Func.isNotEmpty(Func.toStr(condition.getDriverPhone(), "").trim());
|
||||
}
|
||||
|
||||
private boolean samePhone(String left, String right) {
|
||||
String expected = Func.toStr(right, "").trim().replace(" ", "");
|
||||
if (Func.isEmpty(expected)) {
|
||||
return false;
|
||||
}
|
||||
return expected.equals(Func.toStr(left, "").trim().replace(" ", ""));
|
||||
}
|
||||
|
||||
private TripQueryVO condition(TripQueryVO query) {
|
||||
return query == null ? new TripQueryVO() : query;
|
||||
}
|
||||
|
||||
/** 去掉 Tab 条件、其余照搬:统计四档时不按某一档收窄 */
|
||||
private TripQueryVO withoutTab(TripQueryVO source) {
|
||||
TripQueryVO copy = new TripQueryVO();
|
||||
copy.setDriverPhone(source.getDriverPhone());
|
||||
copy.setAllDrivers(source.isAllDrivers());
|
||||
copy.setDeptIds(source.getDeptIds());
|
||||
copy.setKeyword(source.getKeyword());
|
||||
copy.setTransportOrgType(source.getTransportOrgType());
|
||||
copy.setException(source.getException());
|
||||
copy.setExceptionWaybillIds(source.getExceptionWaybillIds());
|
||||
copy.setCreatedFrom(source.getCreatedFrom());
|
||||
copy.setCreatedTo(source.getCreatedTo());
|
||||
copy.setExcludeRejectedTrips(source.isExcludeRejectedTrips());
|
||||
return copy;
|
||||
}
|
||||
|
||||
private Long tripId(String value) {
|
||||
if (Func.isEmpty(value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(value.trim());
|
||||
} catch (NumberFormatException ignored) {
|
||||
// 运次主键只有配载单与运单两种数字雪花号,其余一律当作不存在的运次
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 运次主键按字符串下发:19 位雪花号按数字下发会被前端取整 */
|
||||
private String tripIdOf(Long id) {
|
||||
return id == null ? null : String.valueOf(id);
|
||||
}
|
||||
|
||||
/** 一趟车:运次键、子单行(段一取回的、含行级筛选后的)、派生状态与排序键 */
|
||||
private record TripRows(String key, String loadingNo, List<Waybill> children, String status, Integer tier,
|
||||
Date sortTime) {
|
||||
}
|
||||
|
||||
private int pageNo(Integer current) {
|
||||
return current == null || current < 1 ? 1 : current;
|
||||
}
|
||||
|
||||
private int pageSize(Integer size) {
|
||||
if (size == null || size < 1) {
|
||||
return DEFAULT_PAGE_SIZE;
|
||||
}
|
||||
return Math.min(size, MAX_PAGE_SIZE);
|
||||
}
|
||||
|
||||
}
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import org.springblade.core.tool.jackson.JsonUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.pojo.entity.LoadingManage;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.TripStationVO;
|
||||
import org.springblade.transport.support.WaybillCargoSupport;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 运次站点拼装:把一趟车的实际途经顺序与每站的装卸、货物条目拼出来
|
||||
* <p>
|
||||
* 站点顺序优先取配载单的路线信息(有序站点数组);站点标记「装N / 卸N」的 N 指向配载单运单集合里的第 N 张子运单。
|
||||
* 没有路线信息时回退到子运单的起讫点去重成站(保留首次出现顺序),再退到配载单自己的发货/途经/到货地址;
|
||||
* 都没有则给空站点数组,前端按空站点渲染而不是崩。
|
||||
* <p>
|
||||
* 货物条目取子运单而不是配载单的货物信息:配载单的货物条数与子运单不一定同数,行序也对不上站点编号。
|
||||
*/
|
||||
final class TripStationAssembler {
|
||||
|
||||
private static final String LOAD = "load";
|
||||
private static final String UNLOAD = "unload";
|
||||
private static final String LOAD_UNLOAD = "load_unload";
|
||||
/** 途经站点之间的连接符 */
|
||||
private static final String TRANSIT_JOINER = " → ";
|
||||
/** 站点去重用的键分隔符:站名与地址都为空的两个点不该被当成同一个 */
|
||||
private static final String KEY_SEPARATOR = "\u0000";
|
||||
|
||||
private TripStationAssembler() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼装整趟车的站点,站序从 1 开始
|
||||
*
|
||||
* @param loading 配载单,未配载传 null
|
||||
* @param children 该运次的可见子运单
|
||||
*/
|
||||
static List<TripStationVO> assemble(LoadingManage loading, List<Waybill> children) {
|
||||
List<TripStationVO> stations = fromRoute(loading, children);
|
||||
if (stations.isEmpty()) {
|
||||
stations = fromWaybillPoints(children);
|
||||
}
|
||||
if (stations.isEmpty() && loading != null) {
|
||||
stations = fromLoadingAddresses(loading);
|
||||
}
|
||||
for (int i = 0; i < stations.size(); i++) {
|
||||
stations.get(i).setIndex(i + 1);
|
||||
}
|
||||
return stations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 起点名称:首站站名,没有站点时为空
|
||||
*/
|
||||
static String departureName(List<TripStationVO> stations) {
|
||||
return stations.isEmpty() ? "" : Func.toStr(stations.get(0).getName(), "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 终点名称:末站站名,没有站点时为空
|
||||
*/
|
||||
static String arrivalName(List<TripStationVO> stations) {
|
||||
return stations.isEmpty() ? "" : Func.toStr(stations.get(stations.size() - 1).getName(), "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 途经站点:中间各站按顺序连接,不足三站为空
|
||||
*/
|
||||
static String transitName(List<TripStationVO> stations) {
|
||||
if (stations.size() < 3) {
|
||||
return "";
|
||||
}
|
||||
List<String> names = new ArrayList<>();
|
||||
for (int i = 1; i < stations.size() - 1; i++) {
|
||||
String name = Func.toStr(stations.get(i).getName(), "");
|
||||
if (Func.isNotEmpty(name)) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
return String.join(TRANSIT_JOINER, names);
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点来自配载单的路线信息:按数组顺序成站,标记里第 N 张子运单按配载单运单集合的顺序取
|
||||
*/
|
||||
private static List<TripStationVO> fromRoute(LoadingManage loading, List<Waybill> children) {
|
||||
if (loading == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
List<Map<String, Object>> route = objectList(loading.getRouteJson());
|
||||
if (route.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
List<String> waybillIds = stringList(loading.getWaybillIdsJson());
|
||||
Map<String, Waybill> byId = new LinkedHashMap<>();
|
||||
for (Waybill child : children) {
|
||||
if (child.getId() != null) {
|
||||
byId.put(String.valueOf(child.getId()), child);
|
||||
}
|
||||
}
|
||||
List<TripStationVO> stations = new ArrayList<>();
|
||||
for (Map<String, Object> element : route) {
|
||||
String address = stringValue(element.get("address"));
|
||||
String display = stringValue(element.get("displayAddress"));
|
||||
TripStationVO station = new TripStationVO();
|
||||
station.setName(Func.toStrWithEmpty(display, address));
|
||||
station.setAddress(Func.toStrWithEmpty(address, station.getName()));
|
||||
List<String> tags = stringList(element.get("tags"));
|
||||
station.setTags(tags);
|
||||
List<TripStationVO.TripCargoVO> cargos = new ArrayList<>();
|
||||
boolean load = false;
|
||||
boolean unload = false;
|
||||
for (String tag : tags) {
|
||||
boolean loadTag = tag.contains("装");
|
||||
boolean unloadTag = tag.contains("卸");
|
||||
if (!loadTag && !unloadTag) {
|
||||
continue;
|
||||
}
|
||||
Waybill child = childOfTag(tag, waybillIds, byId);
|
||||
if (child == null) {
|
||||
continue;
|
||||
}
|
||||
load |= loadTag;
|
||||
unload |= unloadTag;
|
||||
addCargo(cargos, child, loadTag ? LOAD : UNLOAD);
|
||||
if (loadTag && unloadTag) {
|
||||
addCargo(cargos, child, UNLOAD);
|
||||
}
|
||||
}
|
||||
fillOperation(station, load, unload);
|
||||
station.setCargos(cargos);
|
||||
stations.add(station);
|
||||
}
|
||||
return stations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点回退到子运单的起讫点:按子运单顺序每张的起点、终点依次成站,同名同址只留首次出现
|
||||
*/
|
||||
private static List<TripStationVO> fromWaybillPoints(List<Waybill> children) {
|
||||
Map<String, TripStationVO> stations = new LinkedHashMap<>();
|
||||
Map<String, boolean[]> operations = new LinkedHashMap<>();
|
||||
for (Waybill child : children) {
|
||||
String fromName = pointName(child.getDepartureName(), child.getDepartureAddress());
|
||||
String fromAddress = Func.toStrWithEmpty(child.getDepartureAddress(), fromName);
|
||||
String toName = pointName(child.getArrivalName(), child.getArrivalAddress());
|
||||
String toAddress = Func.toStrWithEmpty(child.getArrivalAddress(), toName);
|
||||
point(stations, operations, fromName, fromAddress, true, child);
|
||||
point(stations, operations, toName, toAddress, false, child);
|
||||
}
|
||||
operations.forEach((key, flags) -> fillOperation(stations.get(key), flags[0], flags[1]));
|
||||
return new ArrayList<>(stations.values());
|
||||
}
|
||||
|
||||
private static void point(Map<String, TripStationVO> stations, Map<String, boolean[]> operations,
|
||||
String name, String address, boolean load, Waybill child) {
|
||||
if (Func.isEmpty(name) && Func.isEmpty(address)) {
|
||||
return;
|
||||
}
|
||||
String key = name + KEY_SEPARATOR + address;
|
||||
TripStationVO station = stations.get(key);
|
||||
if (station == null) {
|
||||
station = new TripStationVO();
|
||||
station.setName(name);
|
||||
station.setAddress(address);
|
||||
station.setTags(new ArrayList<>());
|
||||
station.setCargos(new ArrayList<>());
|
||||
stations.put(key, station);
|
||||
operations.put(key, new boolean[2]);
|
||||
}
|
||||
boolean[] flags = operations.get(key);
|
||||
flags[0] |= load;
|
||||
flags[1] |= !load;
|
||||
addCargo(station.getCargos(), child, load ? LOAD : UNLOAD);
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点再退到配载单自己的发货 / 途经 / 到货地址
|
||||
*/
|
||||
private static List<TripStationVO> fromLoadingAddresses(LoadingManage loading) {
|
||||
List<TripStationVO> stations = new ArrayList<>();
|
||||
for (String address : List.of(
|
||||
Func.toStr(loading.getDepartureAddress(), ""),
|
||||
Func.toStr(loading.getTransitAddress(), ""),
|
||||
Func.toStr(loading.getArrivalAddress(), ""))) {
|
||||
if (Func.isEmpty(address)) {
|
||||
continue;
|
||||
}
|
||||
TripStationVO station = new TripStationVO();
|
||||
station.setName(address);
|
||||
station.setAddress(address);
|
||||
station.setTags(new ArrayList<>());
|
||||
station.setCargos(new ArrayList<>());
|
||||
stations.add(station);
|
||||
}
|
||||
return stations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点标记里第 N 张子运单:N 是配载单运单集合里的位次,不是可见子单里的位次
|
||||
*/
|
||||
private static Waybill childOfTag(String tag, List<String> waybillIds, Map<String, Waybill> byId) {
|
||||
String digits = tag.replaceAll("\\D+", "");
|
||||
if (Func.isEmpty(digits)) {
|
||||
return null;
|
||||
}
|
||||
int order = Func.toInt(digits, 0);
|
||||
if (order < 1 || order > waybillIds.size()) {
|
||||
return null;
|
||||
}
|
||||
return byId.get(waybillIds.get(order - 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条子运单在该站的货物条目:运单上录了几条货物就出几条,同一张运单的多条货物不能被折成一行,
|
||||
* 否则站点上看不出这票到底装/卸了哪几种货。去重键含货名,站点标记重复出现时不会重复列出。
|
||||
* <p>
|
||||
* 一条货物都没录的运单仍出一行(货名为空):站点上的「这站有这张票」由这一行承载,
|
||||
* 只按货名判断有没有货物会让没填货名的单在任务安排里凭空消失。
|
||||
*/
|
||||
private static void addCargo(List<TripStationVO.TripCargoVO> cargos, Waybill child, String operation) {
|
||||
if (child == null) {
|
||||
return;
|
||||
}
|
||||
String waybillId = child.getId() == null ? null : String.valueOf(child.getId());
|
||||
List<WaybillCargoSupport.Cargo> items = WaybillCargoSupport.of(child);
|
||||
if (items.isEmpty()) {
|
||||
// 一条货物都没录:站点条目仍要有这一行,否则任务安排里这票凭空消失
|
||||
TripStationVO.TripCargoVO cargo = new TripStationVO.TripCargoVO();
|
||||
cargo.setWaybillId(waybillId);
|
||||
cargo.setWaybillNo(child.getWaybillNo());
|
||||
cargo.setCargoName("");
|
||||
cargo.setCustomerName(child.getCustomerName());
|
||||
cargo.setOperation(operation);
|
||||
cargo.setProjectName(child.getProjectName());
|
||||
cargos.add(cargo);
|
||||
return;
|
||||
}
|
||||
for (WaybillCargoSupport.Cargo item : items) {
|
||||
String cargoName = item.displayName();
|
||||
boolean exists = cargos.stream().anyMatch(cargo ->
|
||||
Objects.equals(cargo.getWaybillId(), waybillId)
|
||||
&& Objects.equals(cargo.getOperation(), operation)
|
||||
&& Objects.equals(cargo.getCargoName(), cargoName));
|
||||
if (exists) {
|
||||
continue;
|
||||
}
|
||||
TripStationVO.TripCargoVO cargo = new TripStationVO.TripCargoVO();
|
||||
cargo.setWaybillId(waybillId);
|
||||
cargo.setWaybillNo(child.getWaybillNo());
|
||||
cargo.setCargoName(cargoName);
|
||||
cargo.setCustomerName(child.getCustomerName());
|
||||
cargo.setQuantity(item.quantity());
|
||||
cargo.setQuantityUnit(item.quantityUnit());
|
||||
cargo.setOperation(operation);
|
||||
cargo.setProjectName(child.getProjectName());
|
||||
cargos.add(cargo);
|
||||
}
|
||||
}
|
||||
|
||||
private static void fillOperation(TripStationVO station, boolean load, boolean unload) {
|
||||
if (load && unload) {
|
||||
station.setOperationType(LOAD_UNLOAD);
|
||||
station.setOperationTypeLabel("装卸");
|
||||
} else if (load) {
|
||||
station.setOperationType(LOAD);
|
||||
station.setOperationTypeLabel("装货");
|
||||
} else if (unload) {
|
||||
station.setOperationType(UNLOAD);
|
||||
station.setOperationTypeLabel("卸货");
|
||||
} else {
|
||||
station.setOperationType(null);
|
||||
station.setOperationTypeLabel("");
|
||||
}
|
||||
}
|
||||
|
||||
private static String pointName(String name, String address) {
|
||||
return Func.toStrWithEmpty(name, Func.toStr(address, ""));
|
||||
}
|
||||
|
||||
private static List<Map<String, Object>> objectList(String json) {
|
||||
if (Func.isEmpty(json)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
try {
|
||||
Object parsed = JsonUtil.parse(json, List.class);
|
||||
if (parsed instanceof List<?> list) {
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (Object item : list) {
|
||||
if (item instanceof Map<?, ?> map) {
|
||||
Map<String, Object> element = new LinkedHashMap<>();
|
||||
map.forEach((key, value) -> element.put(String.valueOf(key), value));
|
||||
result.add(element);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 路线信息坏了不该让整趟车读不出来,按没有路线信息回退
|
||||
}
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
private static List<String> stringList(Object value) {
|
||||
List<String> result = new ArrayList<>();
|
||||
if (value instanceof List<?> list) {
|
||||
for (Object item : list) {
|
||||
String text = stringValue(item);
|
||||
if (Func.isNotEmpty(text)) {
|
||||
result.add(text);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
String text = stringValue(value);
|
||||
if (Func.isEmpty(text)) {
|
||||
return result;
|
||||
}
|
||||
try {
|
||||
Object parsed = JsonUtil.parse(text, List.class);
|
||||
if (parsed instanceof List<?> list) {
|
||||
for (Object item : list) {
|
||||
String item2 = stringValue(item);
|
||||
if (Func.isNotEmpty(item2)) {
|
||||
result.add(item2);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 不是 JSON 数组就当逗号分隔处理
|
||||
}
|
||||
return Func.toStrList(",", text);
|
||||
}
|
||||
|
||||
private static String stringValue(Object value) {
|
||||
return value == null ? "" : String.valueOf(value).trim();
|
||||
}
|
||||
|
||||
}
|
||||
+224
-53
@@ -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", "司机/船长手机号"));
|
||||
|
||||
+92
-40
@@ -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());
|
||||
}
|
||||
|
||||
+119
-40
@@ -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())
|
||||
|
||||
+30
-3
@@ -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:赊销),现销由金蝶自动推收款
|
||||
|
||||
+4
-4
@@ -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));
|
||||
// 【必填】往来户.编码:合同乙方(开票供应商)客商编码
|
||||
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.support;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.pojo.entity.ProcessConfig;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 过程配置的取法与「生效配置还是运单快照」的二选一规则,只此一处。
|
||||
* <p>
|
||||
* 生效配置({@code blade_process_config})按项目生效,是活的:改一次,该项目下所有运单下次读取
|
||||
* 都按新配置呈现;运单创建时另存一份 {@code process_json} 快照,只在生效配置取不到时兜底。
|
||||
* 生效配置按更新时间倒序取第一条命中该项目的,项目集合是逗号串,故先 LIKE 粗筛再按逗号分段精确比对 ——
|
||||
* 只靠 LIKE 会把「1234」命中到「12345」上。
|
||||
* <p>
|
||||
* 两个二选一版本的区别是**得到什么结果**,不是风格:
|
||||
* <ul>
|
||||
* <li>{@link #resolve} 生效配置优先 —— 接单需求、节点门禁、节点订正这些判断节点存不存在的路径用它。
|
||||
* 生效配置是权威,即使它没配可打卡节点也不能被快照顶掉,否则判断依据会随时间漂移。</li>
|
||||
* <li>{@link #resolveForPunch} 生效配置没有可打卡节点时回退快照 —— 司机端打卡页与列表卡用它。
|
||||
* 配置被改坏(节点全删)时,司机端仍能按快照渲染出打卡节点,不至于空白。</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class LiveProcessConfigSupport {
|
||||
|
||||
private LiveProcessConfigSupport() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个项目的生效配置 JSON,没有则返回 null
|
||||
*
|
||||
* @param service 过程配置服务
|
||||
* @param projectId 项目主键
|
||||
*/
|
||||
public static String byProject(IProcessConfigService service, Long projectId) {
|
||||
if (service == null || projectId == null) {
|
||||
return null;
|
||||
}
|
||||
String projectIdStr = String.valueOf(projectId);
|
||||
return service.list(Wrappers.<ProcessConfig>lambdaQuery()
|
||||
.eq(ProcessConfig::getStatus, 1)
|
||||
.eq(ProcessConfig::getIsDeleted, 0)
|
||||
.like(ProcessConfig::getProjectIds, projectIdStr)
|
||||
.orderByDesc(ProcessConfig::getUpdateTime)
|
||||
.orderByDesc(ProcessConfig::getCreateTime))
|
||||
.stream()
|
||||
.filter(config -> containsProjectId(config.getProjectIds(), projectIdStr))
|
||||
.map(ProcessConfig::getNodeConfigJson)
|
||||
.filter(Func::isNotEmpty)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一批项目的生效配置:项目主键 → 节点配置 JSON,一次查全再按项目匹配,供列表页避免逐条回查
|
||||
*
|
||||
* @param service 过程配置服务
|
||||
* @param projectIds 项目主键集合,允许含 null
|
||||
*/
|
||||
public static Map<Long, String> byProjects(IProcessConfigService service, Collection<Long> projectIds) {
|
||||
Map<Long, String> live = new LinkedHashMap<>();
|
||||
if (service == null || Func.isEmpty(projectIds)) {
|
||||
return live;
|
||||
}
|
||||
Set<Long> wanted = new LinkedHashSet<>();
|
||||
projectIds.stream().filter(Objects::nonNull).forEach(wanted::add);
|
||||
if (wanted.isEmpty()) {
|
||||
return live;
|
||||
}
|
||||
List<ProcessConfig> configs = service.list(Wrappers.<ProcessConfig>lambdaQuery()
|
||||
.eq(ProcessConfig::getStatus, 1)
|
||||
.eq(ProcessConfig::getIsDeleted, 0)
|
||||
.orderByDesc(ProcessConfig::getUpdateTime)
|
||||
.orderByDesc(ProcessConfig::getCreateTime));
|
||||
if (Func.isEmpty(configs)) {
|
||||
return live;
|
||||
}
|
||||
for (Long projectId : wanted) {
|
||||
String projectIdStr = String.valueOf(projectId);
|
||||
for (ProcessConfig config : configs) {
|
||||
if (Func.isEmpty(config.getNodeConfigJson())
|
||||
|| !containsProjectId(config.getProjectIds(), projectIdStr)) {
|
||||
continue;
|
||||
}
|
||||
live.put(projectId, config.getNodeConfigJson());
|
||||
break;
|
||||
}
|
||||
}
|
||||
return live;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生效配置优先,取不到时回退运单快照
|
||||
*
|
||||
* @param liveJson 生效配置 JSON,可为空
|
||||
* @param snapshot 运单快照 JSON,可为空
|
||||
*/
|
||||
public static String resolve(String liveJson, String snapshot) {
|
||||
return Func.isNotEmpty(liveJson) ? liveJson : snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生效配置优先;生效配置没有可打卡节点时回退快照,两边都没有节点就按生效配置来
|
||||
*
|
||||
* @param liveJson 生效配置 JSON,可为空
|
||||
* @param snapshot 运单快照 JSON,可为空
|
||||
*/
|
||||
public static String resolveForPunch(String liveJson, String snapshot) {
|
||||
if (Func.isEmpty(liveJson)) {
|
||||
return snapshot;
|
||||
}
|
||||
if (!WaybillProcessSupport.listDriverPunchNodes(liveJson).isEmpty()) {
|
||||
return liveJson;
|
||||
}
|
||||
if (Func.isNotEmpty(snapshot) && !WaybillProcessSupport.listDriverPunchNodes(snapshot).isEmpty()) {
|
||||
return snapshot;
|
||||
}
|
||||
return liveJson;
|
||||
}
|
||||
|
||||
/** 项目集合是逗号串,必须按逗号分段精确比对 */
|
||||
private static boolean containsProjectId(String projectIds, String projectId) {
|
||||
if (Func.isEmpty(projectIds) || Func.isEmpty(projectId)) {
|
||||
return false;
|
||||
}
|
||||
return Arrays.stream(projectIds.split(","))
|
||||
.map(String::trim)
|
||||
.anyMatch(projectId::equals);
|
||||
}
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.support;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* 可配载准入判据:公路 + 待执行 + 未关联其它配载单。
|
||||
* <p>
|
||||
* 接单状态不进判据:拒单票照样可配载。配载提交会把它的接单事实清成待接单,
|
||||
* 否则新司机看不到这张票——司机端入口一律排除已拒绝的运单。
|
||||
* <p>
|
||||
* 候选列表取数与配载提交/编辑/重新派单的校验共用这一份定义。
|
||||
*/
|
||||
public final class LoadableAdmissionSupport {
|
||||
|
||||
/** 可配载只放行待执行:已完成、已取消、草稿与调度中间态一律不可配载 */
|
||||
public static final String LOADABLE_BUSINESS_STATUS = WaybillProcessSupport.STATUS_PENDING;
|
||||
|
||||
/**
|
||||
* 公路运输方式的同义写法,按「包含」比对,与前端运单管理页的识别口径一致。
|
||||
* <p>
|
||||
* 运输方式字典只有 road/railway/river/ocean/air,而库里存在「公路运输」「水路运输」这类中文取值,
|
||||
* 因此词表写在代码里,不从字典取。
|
||||
*/
|
||||
public static final List<String> ROAD_TRANSPORT_KEYWORDS = List.of("公路", "道路", "汽车", "陆运", "road", "highway");
|
||||
|
||||
/** 公路运输方式的历史短码,按整体相等比对(按包含比对会误伤含 gl 的其它取值) */
|
||||
public static final List<String> ROAD_TRANSPORT_CODES = List.of("gl");
|
||||
|
||||
private LoadableAdmissionSupport() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 不可配载的原因,按判定顺序排列:公路 → 待执行 → 未关联其它配载单。
|
||||
* <p>
|
||||
* 判据只产出原因,中文文案由调用方映射——面向用户的措辞不进这一层。
|
||||
*/
|
||||
public enum UnloadableReason {
|
||||
|
||||
/** 运输方式不是公路 */
|
||||
NOT_ROAD,
|
||||
|
||||
/** 业务状态不是待执行 */
|
||||
NOT_PENDING,
|
||||
|
||||
/** 已关联其它配载单 */
|
||||
ON_OTHER_MANIFEST
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否公路运输。未知或为空一律按非公路处理。
|
||||
*
|
||||
* @param transportType 运单的运输方式取值
|
||||
* @return true-公路
|
||||
*/
|
||||
public static boolean isRoadTransport(String transportType) {
|
||||
String value = text(transportType).toLowerCase(Locale.ROOT);
|
||||
if (value.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (ROAD_TRANSPORT_CODES.contains(value)) {
|
||||
return true;
|
||||
}
|
||||
for (String keyword : ROAD_TRANSPORT_KEYWORDS) {
|
||||
if (value.contains(keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 该运单此刻是否可被配载。
|
||||
* <p>
|
||||
* ownLoadingNo 是正在编辑或重新派单的配载单号:运单已挂在本单上不算冲突,
|
||||
* 否则编辑保存会把自己的子运单判为不可用。
|
||||
*
|
||||
* @param waybill 运单
|
||||
* @param ownLoadingNo 本配载单号;新建时传 null
|
||||
* @return true-可配载
|
||||
*/
|
||||
public static boolean isLoadable(Waybill waybill, String ownLoadingNo) {
|
||||
return unloadableReason(waybill, ownLoadingNo) == null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 该运单此刻不可配载的原因。
|
||||
*
|
||||
* @param waybill 运单
|
||||
* @param ownLoadingNo 本配载单号;新建时传 null
|
||||
* @return 命中的第一条原因;可配载时返回 null
|
||||
*/
|
||||
public static UnloadableReason unloadableReason(Waybill waybill, String ownLoadingNo) {
|
||||
if (waybill == null || !isRoadTransport(waybill.getTransportType())) {
|
||||
return UnloadableReason.NOT_ROAD;
|
||||
}
|
||||
if (!LOADABLE_BUSINESS_STATUS.equals(text(waybill.getBusinessStatus()))) {
|
||||
return UnloadableReason.NOT_PENDING;
|
||||
}
|
||||
String loadingNo = text(waybill.getLoadingNo());
|
||||
if (loadingNo.isEmpty() || loadingNo.equals(text(ownLoadingNo))) {
|
||||
return null;
|
||||
}
|
||||
return UnloadableReason.ON_OTHER_MANIFEST;
|
||||
}
|
||||
|
||||
/**
|
||||
* 该运单是否已挂在本配载单上。
|
||||
* <p>
|
||||
* 本单既有子运单在先前提交时已过准入判据,重新提交/编辑时不再重复判:
|
||||
* 不需接单项目的票提交后即为进行中,重复判会让本单再也保存不了,重新派单也无从下手。
|
||||
*
|
||||
* @param waybill 运单
|
||||
* @param ownLoadingNo 本配载单号;新建时传 null
|
||||
* @return true-已是本单子运单
|
||||
*/
|
||||
public static boolean isOwnChild(Waybill waybill, String ownLoadingNo) {
|
||||
if (waybill == null) {
|
||||
return false;
|
||||
}
|
||||
String own = text(ownLoadingNo);
|
||||
return !own.isEmpty() && own.equals(text(waybill.getLoadingNo()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把整条可配载判据加到运单查询上:公路 + 待执行 + 未配载。
|
||||
*
|
||||
* @param wrapper 运单列表查询条件
|
||||
*/
|
||||
public static void applyLoadableConditions(LambdaQueryWrapper<Waybill> wrapper) {
|
||||
applyRoadTransportCondition(wrapper);
|
||||
wrapper.eq(Waybill::getBusinessStatus, LOADABLE_BUSINESS_STATUS);
|
||||
wrapper.and(nested -> nested.isNull(Waybill::getLoadingNo).or().eq(Waybill::getLoadingNo, ""));
|
||||
}
|
||||
|
||||
private static void applyRoadTransportCondition(LambdaQueryWrapper<Waybill> wrapper) {
|
||||
wrapper.and(nested -> {
|
||||
boolean started = false;
|
||||
for (String keyword : ROAD_TRANSPORT_KEYWORDS) {
|
||||
if (started) {
|
||||
nested.or();
|
||||
}
|
||||
nested.like(Waybill::getTransportType, keyword);
|
||||
started = true;
|
||||
}
|
||||
for (String code : ROAD_TRANSPORT_CODES) {
|
||||
nested.or().eq(Waybill::getTransportType, code);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static String text(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
}
|
||||
+6
-1
@@ -279,6 +279,10 @@ public final class OcrFieldSupport {
|
||||
Map<String, List<String>> aliases = new LinkedHashMap<>();
|
||||
aliases.put("realName", List.of("姓名"));
|
||||
aliases.put("idCardNo", List.of("公民身份号码", "公民身份证号码", "身份证号", "身份证号码"));
|
||||
// 身份证正面的「出生」是一整串日期(1990年1月1日),按 birthday 归一成 yyyy-MM-dd;
|
||||
// 住址是身份证上的登记住址,写进 driver.address
|
||||
aliases.put("birthday", List.of("出生", "出生年月", "出生日期"));
|
||||
aliases.put("address", List.of("住址", "地址"));
|
||||
aliases.put("idCardValidStart", List.of("签发日期", "有效起始日期", "有效期限起"));
|
||||
aliases.put("idCardValidEnd", List.of("失效日期", "有效期限", "有效期限止"));
|
||||
return aliases;
|
||||
@@ -319,7 +323,8 @@ public final class OcrFieldSupport {
|
||||
}
|
||||
|
||||
private static boolean isDateField(String key) {
|
||||
return key.endsWith("Date") || key.endsWith("ValidStart") || key.endsWith("ValidEnd");
|
||||
// birthday 的语义是日期但拼写不以 Date 结尾,漏了它会把「1990年1月1日」原样下发
|
||||
return "birthday".equals(key) || key.endsWith("Date") || key.endsWith("ValidStart") || key.endsWith("ValidEnd");
|
||||
}
|
||||
|
||||
private static boolean isNumberField(String key) {
|
||||
|
||||
+36
-3
@@ -28,7 +28,9 @@ import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.constant.RoleConstant;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.cache.SysCache;
|
||||
import org.springblade.system.cache.UserCache;
|
||||
import org.springblade.system.pojo.entity.Dept;
|
||||
import org.springblade.system.pojo.entity.User;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
@@ -50,6 +52,8 @@ public final class TransportBusinessSupport {
|
||||
public static final String DATA_SOURCE_EXTERNAL = "外部系统";
|
||||
|
||||
private static final Pattern PHONE_PATTERN = Pattern.compile("^(1\\d{10}|0\\d{2,3}-?\\d{7,8})$");
|
||||
/** 用户档案「数据权限范围」的取值:全部组织,与组织树过滤口径一致 */
|
||||
private static final int DATA_SCOPE_ALL = 2;
|
||||
private static final BigDecimal MIN_LONGITUDE = new BigDecimal("-180");
|
||||
private static final BigDecimal MAX_LONGITUDE = new BigDecimal("180");
|
||||
private static final BigDecimal MIN_LATITUDE = new BigDecimal("-90");
|
||||
@@ -76,6 +80,26 @@ public final class TransportBusinessSupport {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否被授予跨组织数据权限:超级管理员,或用户档案「数据权限范围」为全部组织。
|
||||
* <p>
|
||||
* 「数据权限范围」在用户管理里配置,组织树已按它过滤(DeptServiceImpl 的组织树数据权限);
|
||||
* 运输业务的列表过滤与写入校验必须与组织树同一口径。
|
||||
*
|
||||
* @return true-不限组织
|
||||
*/
|
||||
public static boolean hasAllDeptScope() {
|
||||
if (isAdministrator()) {
|
||||
return true;
|
||||
}
|
||||
Long userId = AuthUtil.getUserId();
|
||||
if (Func.isEmpty(userId) || userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
User user = UserCache.getUser(userId);
|
||||
return Objects.equals(user == null ? null : user.getDataScopeRange(), DATA_SCOPE_ALL);
|
||||
}
|
||||
|
||||
public static Long currentDeptId(String moduleName) {
|
||||
Long deptId = Func.firstLong(AuthUtil.getDeptId());
|
||||
if (Func.isEmpty(deptId) || deptId <= 0) {
|
||||
@@ -92,14 +116,22 @@ public final class TransportBusinessSupport {
|
||||
return dept;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验目标业务数据属于当前用户可操作的组织。
|
||||
* <p>
|
||||
* 超管与「数据权限范围=全部组织」的用户不限组织;其余用户仍严格要求与当前会话组织相等。
|
||||
*/
|
||||
public static void assertCurrentDept(Long deptId, String moduleName) {
|
||||
if (hasAllDeptScope()) {
|
||||
return;
|
||||
}
|
||||
if (!Objects.equals(deptId, currentDeptId(moduleName))) {
|
||||
throw new ServiceException("无权操作其他组织" + moduleName);
|
||||
}
|
||||
}
|
||||
|
||||
public static void validateAllDept(Integer allDept, String moduleName) {
|
||||
if (Objects.equals(allDept, 1) && !isAdministrator()) {
|
||||
if (Objects.equals(allDept, 1) && !hasAllDeptScope()) {
|
||||
throw new ServiceException("无权查看全部组织" + moduleName);
|
||||
}
|
||||
}
|
||||
@@ -107,7 +139,8 @@ public final class TransportBusinessSupport {
|
||||
/**
|
||||
* 解析列表组织筛选 ID。
|
||||
* <p>
|
||||
* 普通用户 allDept!=1 时强制当前组织;超管默认不按组织过滤,仅在 allDept=1 且传入 deptId 时精确匹配该组织。
|
||||
* 普通用户 allDept!=1 时强制当前组织;超管与「全部组织」用户默认不按组织过滤,
|
||||
* 仅在 allDept=1 且传入 deptId 时精确匹配该组织。
|
||||
*
|
||||
* @param allDept 是否查看全部组织
|
||||
* @param deptId 筛选组织 ID
|
||||
@@ -116,7 +149,7 @@ public final class TransportBusinessSupport {
|
||||
*/
|
||||
public static List<Long> resolveDeptFilterIds(Integer allDept, Long deptId, String moduleName) {
|
||||
validateAllDept(allDept, moduleName);
|
||||
if (isAdministrator()) {
|
||||
if (hasAllDeptScope()) {
|
||||
if (Objects.equals(allDept, 1) && Func.isNotEmpty(deptId) && deptId > 0) {
|
||||
return Collections.singletonList(deptId);
|
||||
}
|
||||
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.support;
|
||||
|
||||
import org.springblade.core.tool.jackson.JsonUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 运单货物明细的取法,只此一处。
|
||||
* <p>
|
||||
* 一张运单可以录多条货物,明细落在 {@code goods_json};主表的 {@code cargo_name} / {@code cargo_type} /
|
||||
* {@code quantity} 只是给列表与报表用的首行汇总(数量是各行合计),拿它当货物清单会把其余货物丢掉。
|
||||
* 所以「这张运单装了什么」一律以 goods_json 为准,主表字段只在 goods_json 缺失或解析不出任何货名时兜底。
|
||||
* <p>
|
||||
* 货名也可能是主表里一格多名的历史写法(顿号、逗号分隔),按同一口径摊平。
|
||||
*/
|
||||
public final class WaybillCargoSupport {
|
||||
|
||||
/** 计重时唯一允许跨货物相加的单位:其他单位只在「全部货物同单位」时才合计 */
|
||||
private static final String UNIT_TON = "吨";
|
||||
|
||||
/** 货物没有填名称时的展示名:宁可让司机看到「货物」,也不要出现空行 */
|
||||
private static final String FALLBACK_NAME = "货物";
|
||||
|
||||
/** 货名分隔符:历史数据里顿号、中英文逗号都出现过 */
|
||||
private static final String NAME_SEPARATOR_REGEX = "[、,,]";
|
||||
|
||||
/** 明细里可能承载数量的字段,按优先级取第一个有值的 */
|
||||
private static final String[] QUANTITY_KEYS = {"quantity", "cargoQuantity", "goodsQuantity"};
|
||||
|
||||
/** 明细里可能承载单位的字段,按优先级取第一个有值的 */
|
||||
private static final String[] UNIT_KEYS = {"quantityUnit", "goodsQuantityUnit", "unit"};
|
||||
|
||||
/** 明细里可能承载名称的字段,按优先级取第一个有值的 */
|
||||
private static final String[] NAME_KEYS = {"cargoName", "goodsName"};
|
||||
|
||||
/** 明细里可能承载类型的字段,按优先级取第一个有值的 */
|
||||
private static final String[] TYPE_KEYS = {"cargoType", "goodsType"};
|
||||
|
||||
private WaybillCargoSupport() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 一张运单的货物条目:goods_json 能解析出货物就用它,否则回退到主表的货物字段。
|
||||
* <p>
|
||||
* 每条明细至少出一条:货名、数量都为空的行也保留,让「这张票录了一条空货物」与「这条货物没填数量」可区分。
|
||||
*/
|
||||
public static List<Cargo> of(Waybill waybill) {
|
||||
if (waybill == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<Cargo> detail = parseDetail(waybill.getGoodsJson());
|
||||
if (!detail.isEmpty()) {
|
||||
return detail;
|
||||
}
|
||||
return fallback(waybill);
|
||||
}
|
||||
|
||||
/**
|
||||
* 货名清单:同名的只留一次,保持录入顺序;档口与报表需要横向摊平时用它
|
||||
*/
|
||||
public static List<String> cargoNames(List<Cargo> cargos) {
|
||||
List<String> names = new ArrayList<>();
|
||||
for (Cargo cargo : cargos) {
|
||||
String name = Func.toStr(cargo.cargoName(), "").trim();
|
||||
if (Func.isNotEmpty(name) && !names.contains(name)) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 goods_json 为货物条目;字段缺失、JSON 非法或解析不出任何条目都返回空集合,由调用方兜底
|
||||
*/
|
||||
private static List<Cargo> parseDetail(String goodsJson) {
|
||||
if (Func.isEmpty(goodsJson)) {
|
||||
return List.of();
|
||||
}
|
||||
List<Map> rows;
|
||||
try {
|
||||
rows = JsonUtil.parseArray(goodsJson, Map.class);
|
||||
} catch (Exception exception) {
|
||||
// 历史脏数据:JSON 坏了就当没有明细,回退主表字段,不让列表与详情因一条脏数据打不开
|
||||
return List.of();
|
||||
}
|
||||
if (Func.isEmpty(rows)) {
|
||||
return List.of();
|
||||
}
|
||||
List<Cargo> cargos = new ArrayList<>(rows.size());
|
||||
for (Map row : rows) {
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
cargos.add(new Cargo(
|
||||
firstText(row, NAME_KEYS),
|
||||
firstText(row, TYPE_KEYS),
|
||||
quantityOf(row),
|
||||
firstText(row, UNIT_KEYS)));
|
||||
}
|
||||
return cargos;
|
||||
}
|
||||
|
||||
/**
|
||||
* 回退:主表货名按历史分隔符摊平,每一段配同一个数量与单位(主表数量是各行合计,摊到哪一段都不准,
|
||||
* 故只给一段配数量;多段说明主表数量不可拆分,宁可只展示货名也不重复同一份合计)
|
||||
*/
|
||||
private static List<Cargo> fallback(Waybill waybill) {
|
||||
List<String> names = splitCargoNames(waybill.getCargoName());
|
||||
if (names.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<Cargo> cargos = new ArrayList<>(names.size());
|
||||
boolean first = true;
|
||||
for (String name : names) {
|
||||
cargos.add(new Cargo(
|
||||
name,
|
||||
Func.toStr(waybill.getCargoType(), ""),
|
||||
first ? waybill.getQuantity() : null,
|
||||
first ? Func.toStr(waybill.getQuantityUnit(), "") : ""));
|
||||
first = false;
|
||||
}
|
||||
return cargos;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计重合计:卡片上那个「货物重量」
|
||||
* <p>
|
||||
* 全部货物同单位时就是该单位合计;单位不一致时只合计「吨」项,其余单位不参与——
|
||||
* 「100吨 + 20堆」报 100 吨,「20堆 + 50方」没有吨项就不出合计。
|
||||
* 有货物缺数量时该单位合计不完整,宁可不报也不报一个偏小的数。
|
||||
*
|
||||
* @return 有可用合计时返回数量与单位,否则 null
|
||||
*/
|
||||
public static Quantity weighing(List<Cargo> cargos) {
|
||||
Map<String, BigDecimal> totals = new LinkedHashMap<>();
|
||||
Set<String> incomplete = new HashSet<>();
|
||||
for (Cargo cargo : cargos) {
|
||||
String unit = Func.toStr(cargo.quantityUnit(), "");
|
||||
if (Func.isEmpty(unit)) {
|
||||
continue;
|
||||
}
|
||||
if (cargo.quantity() == null) {
|
||||
incomplete.add(unit);
|
||||
continue;
|
||||
}
|
||||
totals.merge(unit, cargo.quantity(), BigDecimal::add);
|
||||
}
|
||||
if (totals.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String unit;
|
||||
if (totals.size() == 1) {
|
||||
unit = totals.keySet().iterator().next();
|
||||
} else {
|
||||
unit = totals.containsKey(UNIT_TON) ? UNIT_TON : null;
|
||||
}
|
||||
if (unit == null || incomplete.contains(unit)) {
|
||||
return null;
|
||||
}
|
||||
return new Quantity(totals.get(unit), unit);
|
||||
}
|
||||
|
||||
/** 计重合计的数量与单位 */
|
||||
public record Quantity(BigDecimal value, String unit) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 货物明细行:一条货物一行,同名同单位的合并数量,按录入顺序
|
||||
* <p>
|
||||
* 与 {@link #cargoSummary(List)} 的合并口径一致,但数量与单位保持分开,供详情页逐行展示。
|
||||
* 数量为空的货物只留名称,不按 0 参与合并。
|
||||
*/
|
||||
public static List<Line> lines(List<Cargo> cargos) {
|
||||
Map<String, Integer> index = new LinkedHashMap<>();
|
||||
List<Line> lines = new ArrayList<>();
|
||||
for (Cargo cargo : cargos) {
|
||||
String name = cargo.displayName();
|
||||
String unit = Func.toStr(cargo.quantityUnit(), "");
|
||||
String key = name + '\u0000' + unit;
|
||||
Integer at = index.get(key);
|
||||
if (at == null) {
|
||||
index.put(key, lines.size());
|
||||
lines.add(new Line(name, cargo.quantity(), unit));
|
||||
continue;
|
||||
}
|
||||
Line merged = lines.get(at);
|
||||
if (cargo.quantity() == null) {
|
||||
continue;
|
||||
}
|
||||
BigDecimal quantity = merged.quantity() == null
|
||||
? cargo.quantity()
|
||||
: merged.quantity().add(cargo.quantity());
|
||||
lines.set(at, new Line(name, quantity, unit));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** 一条货物明细:数量可能为空(录入时只填了名称) */
|
||||
public record Line(String name, BigDecimal quantity, String unit) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 主表货名按顿号、逗号摊平;空格与空段去掉,全空时给空集合
|
||||
*/
|
||||
public static List<String> splitCargoNames(String cargoName) {
|
||||
if (Func.isEmpty(cargoName)) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> names = new ArrayList<>();
|
||||
for (String part : cargoName.split(NAME_SEPARATOR_REGEX)) {
|
||||
String name = part.trim();
|
||||
if (Func.isNotEmpty(name)) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
private static String firstText(Map row, String... keys) {
|
||||
for (String key : keys) {
|
||||
Object value = row.get(key);
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
String text = String.valueOf(value).trim();
|
||||
if (Func.isNotEmpty(text)) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static BigDecimal quantityOf(Map row) {
|
||||
for (String key : QUANTITY_KEYS) {
|
||||
Object value = row.get(key);
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
String text = String.valueOf(value).trim();
|
||||
if (Func.isEmpty(text)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
BigDecimal quantity = new BigDecimal(text);
|
||||
// 录入端用 -1 表示「未填」,落到汇总里会把合计算成负数
|
||||
if (quantity.compareTo(BigDecimal.ONE.negate()) == 0) {
|
||||
continue;
|
||||
}
|
||||
return quantity;
|
||||
} catch (NumberFormatException exception) {
|
||||
// 这一字段不是数字:继续试下一个候选字段
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运单上的一条货物
|
||||
* <p>
|
||||
* 数量与单位可能为空:录货物时允许只填名称。展示端要把「没填数量」与「数量为 0」区分开。
|
||||
*/
|
||||
public record Cargo(String cargoName, String cargoType, BigDecimal quantity, String quantityUnit) {
|
||||
|
||||
/**
|
||||
* 展示用货名:空名称回退成「货物」
|
||||
*/
|
||||
public String displayName() {
|
||||
return Func.toStrWithEmpty(cargoName, FALLBACK_NAME);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+135
-4
@@ -38,6 +38,7 @@ import java.util.Date;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -132,6 +133,81 @@ public final class WaybillProcessSupport {
|
||||
return ACCEPT_REJECTED.equalsIgnoreCase(stringVal(driverAcceptStatus));
|
||||
}
|
||||
|
||||
/**
|
||||
* 运单已结束:已完成或已取消。此后不再接受司机打卡。
|
||||
* <p>
|
||||
* 只认这两个状态,大小写不敏感。草稿、待执行、待调度、调度中的运单司机仍要能打卡,
|
||||
* 所以不能按 {@link #isTerminalBusinessStatus} 的「终态或调度中间态」口径判定。
|
||||
*/
|
||||
public static boolean isPunchEnded(String businessStatus) {
|
||||
String status = stringVal(businessStatus);
|
||||
return "completed".equalsIgnoreCase(status) || "cancelled".equalsIgnoreCase(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束态下的打卡拒绝文案;非结束态返回 null。
|
||||
*/
|
||||
public static String punchEndedReason(String businessStatus) {
|
||||
String status = stringVal(businessStatus);
|
||||
if ("completed".equalsIgnoreCase(status)) {
|
||||
return "运单已完成";
|
||||
}
|
||||
if ("cancelled".equalsIgnoreCase(status)) {
|
||||
return "运单已取消";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运单已开始运输:只有进行中可以打卡(在途与普通节点同一判据)。
|
||||
* <p>
|
||||
* 草稿、待执行(待接单)、待调度、调度中都还没上路,任何打卡都没有承载物。
|
||||
* 判据是校正后落库的状态:无需接单的待执行运单在 {@link #normalizeBusinessStatus}
|
||||
* 里已被提升为进行中,未提升的待执行就是真的还没接单。
|
||||
*/
|
||||
public static boolean isTransitPunchStarted(String businessStatus) {
|
||||
return STATUS_RUNNING.equalsIgnoreCase(stringVal(businessStatus));
|
||||
}
|
||||
|
||||
/**
|
||||
* 未开始运输时的打卡拒绝文案;进行中与已结束返回 null(结束文案由 {@link #punchEndedReason} 给)。
|
||||
*/
|
||||
public static String punchNotStartedReason(String businessStatus) {
|
||||
if (isTransitPunchStarted(businessStatus) || isPunchEnded(businessStatus)) {
|
||||
return null;
|
||||
}
|
||||
return "运单尚未开始运输,暂不可打卡";
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定自然日是否已打过在途卡。
|
||||
* <p>
|
||||
* 与运单状态无关,只回答「这一天打过没有」。{@link TransitCheckinDecision#doneToday()}
|
||||
* 只在进行中分支计算,状态一非进行中就回到 false,两者不可互相替代。
|
||||
*/
|
||||
public static boolean isPunchedOn(Date lastPunchAt, LocalDateTime now) {
|
||||
LocalDate day = (now == null ? LocalDateTime.now() : now).toLocalDate();
|
||||
LocalDate last = toLocalDate(lastPunchAt);
|
||||
return last != null && last.equals(day);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报异常前的运单状态门禁原因;可上报时返回 null。
|
||||
* <p>
|
||||
* 与打卡共用「已开始运输」判据,但文案说的是能不能报,不是能不能打。
|
||||
* 结束优先于未开始:已完成/已取消的运单先说结束,不再提「等你上路」。
|
||||
*/
|
||||
public static String reportBlockedReason(String businessStatus) {
|
||||
String endedReason = punchEndedReason(businessStatus);
|
||||
if (endedReason != null) {
|
||||
return endedReason + ",不能上报异常";
|
||||
}
|
||||
if (!isTransitPunchStarted(businessStatus)) {
|
||||
return "运单尚未开始运输,暂不可上报异常";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static boolean isTerminalBusinessStatus(String businessStatus) {
|
||||
return "draft".equals(businessStatus)
|
||||
|| "completed".equals(businessStatus)
|
||||
@@ -197,15 +273,20 @@ public final class WaybillProcessSupport {
|
||||
int frequencyDays = parsePositiveInt(transit.get("frequencyDays"), 1);
|
||||
String timeStart = normalizeHm(stringVal(transit.get("timeStart")), "00:00");
|
||||
String timeEnd = normalizeHm(stringVal(transit.get("timeEnd")), "23:59");
|
||||
// punch=是即展示卡片;非进行中仅不可作为「今日待打」
|
||||
if (!STATUS_RUNNING.equals(businessStatus)) {
|
||||
return new TransitCheckinDecision(true, true, false, false, frequencyDays, timeStart, timeEnd);
|
||||
// 结束不可逆,没有解除途径:卡片仍下发,但不可提交,也不算今日待打
|
||||
if (isPunchEnded(businessStatus)) {
|
||||
return new TransitCheckinDecision(false, true, false, false, frequencyDays, timeStart, timeEnd);
|
||||
}
|
||||
// punch=是即展示卡片;未开始运输(草稿/待执行/待调度/调度中)的卡片仍下发,
|
||||
// 但不可提交——打卡的前提是运单已上路(判据同时用于节点打卡)。
|
||||
if (!isTransitPunchStarted(businessStatus)) {
|
||||
return new TransitCheckinDecision(false, true, false, false, frequencyDays, timeStart, timeEnd);
|
||||
}
|
||||
|
||||
LocalDateTime current = now == null ? LocalDateTime.now() : now;
|
||||
LocalDate today = current.toLocalDate();
|
||||
LocalDate lastDate = toLocalDate(lastPunchAt);
|
||||
boolean doneToday = lastDate != null && lastDate.equals(today);
|
||||
boolean doneToday = isPunchedOn(lastPunchAt, current);
|
||||
boolean dueByFrequency;
|
||||
if (lastDate == null) {
|
||||
dueByFrequency = true;
|
||||
@@ -419,6 +500,28 @@ public final class WaybillProcessSupport {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
|
||||
/**
|
||||
* 顺序门禁点名的那一个前置节点名称:取**最先未完成**的。
|
||||
* <p>
|
||||
* 它才是司机现在能去打卡的那一步——后面那些未完成的前置节点自己也还打不了,
|
||||
* 一起列出来既长,又不是一个可执行的下一步。
|
||||
*
|
||||
* @param processJson 过程配置
|
||||
* @param nodeKeys 未完成的前置节点 key,即 {@link #unfinishedPrerequisiteKeys} 的返回
|
||||
*/
|
||||
public static String firstUnfinishedPrerequisiteName(String processJson, Collection<String> nodeKeys) {
|
||||
if (nodeKeys == null || nodeKeys.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
for (Map<String, Object> node : listDriverPunchNodes(processJson)) {
|
||||
String key = nodeKey(node);
|
||||
if (nodeKeys.contains(key)) {
|
||||
return nodeName(node);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 两个节点编码是否指同一个节点:两侧去空白、忽略大小写。
|
||||
* <p>
|
||||
@@ -637,6 +740,34 @@ public final class WaybillProcessSupport {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 还没打卡的司机打卡节点名称(在途除外):回单由业务员确认前,这些节点必须都打过卡,否则
|
||||
* 「确认回单」就成了绕过打卡门禁的通道——完成接口本身不要求节点打卡。
|
||||
* <p>
|
||||
* 在途是频次打卡节点、不是必经的一步,故不参与;空列表表示该打卡的都打完了。
|
||||
*
|
||||
* @param punchedKeys 已打卡的节点编码与名称,**小写**(库里的 node_code / node_name 都可能被用来对节点)
|
||||
*/
|
||||
public static List<String> missingPunchNodeNames(String processJson, Collection<String> punchedKeys) {
|
||||
List<Map<String, Object>> punchConfigs = listDriverPunchNodes(processJson);
|
||||
if (punchConfigs.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Collection<String> punched = punchedKeys == null ? Collections.emptySet() : punchedKeys;
|
||||
List<String> missing = new ArrayList<>();
|
||||
for (Map<String, Object> node : punchConfigs) {
|
||||
if (isTransitNode(node)) {
|
||||
continue;
|
||||
}
|
||||
String code = nodeKey(node).toLowerCase(Locale.ROOT);
|
||||
String name = nodeName(node).toLowerCase(Locale.ROOT);
|
||||
if (!punched.contains(code) && !punched.contains(name)) {
|
||||
missing.add(nodeName(node));
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
private static boolean isTransitNode(Map<String, Object> node) {
|
||||
String key = stringVal(node.get("key"));
|
||||
String name = stringVal(node.get("name"));
|
||||
|
||||
+59
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+1
@@ -45,6 +45,7 @@ public class FormalSettlementWrapper extends BaseEntityWrapper<FormalSettlement,
|
||||
case "voided" -> "已作废";
|
||||
default -> entity.getApprovalStatus();
|
||||
});
|
||||
vo.setAdjustStatusName("adjusted".equals(entity.getAdjustStatus()) ? "已调整" : null);
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
|
||||
+7
-2
@@ -70,10 +70,15 @@ public class ReceiptFlowWrapper extends BaseEntityWrapper<KingdeeReceiptFlow, Re
|
||||
case "failed" -> "处理失败";
|
||||
default -> "未推送";
|
||||
});
|
||||
// 剩余可认领金额 = 公告未认领金额 − 累计认领金额(认领上限为公告口径)
|
||||
// 剩余可认领金额 = 可认领上限 − 累计认领金额(上限取公告未认领与收款金额较小值)
|
||||
BigDecimal unclaimedAmount = money(entity.getUnclaimedAmount());
|
||||
BigDecimal receiptAmount = money(entity.getReceiptAmount());
|
||||
BigDecimal claimCeiling = unclaimedAmount;
|
||||
if (receiptAmount.compareTo(BigDecimal.ZERO) > 0 && unclaimedAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||
claimCeiling = unclaimedAmount.min(receiptAmount);
|
||||
}
|
||||
BigDecimal claimedAmount = money(entity.getClaimedAmount());
|
||||
vo.setRemainingAmount(unclaimedAmount.subtract(claimedAmount).max(BigDecimal.ZERO));
|
||||
vo.setRemainingAmount(claimCeiling.subtract(claimedAmount).max(BigDecimal.ZERO));
|
||||
vo.setCreateUserName(UserCache.getUserRealName(entity.getCreateUser()));
|
||||
vo.setUpdateUserName(UserCache.getUserRealName(entity.getUpdateUser()));
|
||||
return vo;
|
||||
|
||||
+21
-2
@@ -86,11 +86,11 @@ class KingdeeEntrySplitTest {
|
||||
assertEquals(new BigDecimal("800"), splits.get(1).getAmount());
|
||||
}
|
||||
|
||||
/** 费用项档案分类判定:名称不含“装卸”的费用项,档案费用类型名称含“装卸”即归装卸大类 */
|
||||
/** 费用项档案分类判定:名称不含“装卸”的费用项,档案费用大类编码 A10 即归装卸大类 */
|
||||
@Test
|
||||
void loadingCategoryFromFeeItemArchive() {
|
||||
Map<String, BigDecimal> feeItems = Map.of("吊装作业", new BigDecimal("500"));
|
||||
Map<String, String> feeCategoryByName = Map.of("吊装作业", "装卸");
|
||||
Map<String, String> feeCategoryByName = Map.of("吊装作业", "A10");
|
||||
List<FeeLineSplit> splits = KingdeeApBillSyncSupport.splitFeeItems(feeItems, feeCategoryByName, Map.of(), new BigDecimal("500"));
|
||||
assertEquals(1, splits.size());
|
||||
assertTrue(splits.get(0).isLoading());
|
||||
@@ -166,4 +166,23 @@ class KingdeeEntrySplitTest {
|
||||
List<FeeLineSplit> splits = KingdeeApBillSyncSupport.splitFeeItems(feeItems, Map.of(), Map.of(), BigDecimal.ZERO);
|
||||
assertTrue(splits.isEmpty());
|
||||
}
|
||||
|
||||
/** 手工合计行运费类判定:费用项档案分类编码 A01 归运费类映射运输物料(SJS2026101900004 车辆维修费误映射装卸物料回归用例) */
|
||||
@Test
|
||||
void freightCategoryMatchesArchiveCode() {
|
||||
Map<String, String> feeCategoryByName = Map.of(
|
||||
"车辆维修费", "A01",
|
||||
"人工装卸费用", "A10");
|
||||
assertTrue(KingdeeApBillSyncSupport.isFreightCategory("A01", "车辆维修费", feeCategoryByName));
|
||||
assertFalse(KingdeeApBillSyncSupport.isFreightCategory("A10", "人工装卸费用", feeCategoryByName));
|
||||
// 档案编码优先于名称关键字:A02 档案的“装卸搬运作业费”不归装卸大类
|
||||
Map<String, BigDecimal> feeItems = Map.of("装卸搬运作业费", new BigDecimal("100"));
|
||||
List<FeeLineSplit> splits = KingdeeApBillSyncSupport.splitFeeItems(feeItems,
|
||||
Map.of("装卸搬运作业费", "A02"), Map.of(), new BigDecimal("100"));
|
||||
assertFalse(splits.get(0).isLoading());
|
||||
// 档案未维护:回退行费用类型编码与名称关键字判定
|
||||
assertTrue(KingdeeApBillSyncSupport.isFreightCategory("A01", "车辆维修费", Map.of()));
|
||||
assertFalse(KingdeeApBillSyncSupport.isFreightCategory("A10", "人工装卸费用", Map.of()));
|
||||
assertTrue(KingdeeApBillSyncSupport.isFreightCategory("", "运输费", Map.of()));
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -110,7 +110,7 @@ class CertAuditServiceImplTest {
|
||||
ArgumentCaptor<TransportVehicle> captor = ArgumentCaptor.forClass(TransportVehicle.class);
|
||||
verify(transportVehicleService).updateById(captor.capture());
|
||||
TransportVehicle patched = captor.getValue();
|
||||
assertEquals("桂物物流集团", patched.getOwnerName(), "所有人要跟着生效值更新");
|
||||
assertEquals("广西现代物流集团有限公司", patched.getOwnerName(), "所有人要跟着生效值更新");
|
||||
assertEquals("管理挂靠", patched.getBusinessRelation());
|
||||
assertEquals(18000, patched.getApprovedLoadKg());
|
||||
assertEquals(RECORD_ID, patched.getEffectiveCertRecordId(), "内容与指针必须是同一次 UPDATE");
|
||||
@@ -441,7 +441,7 @@ class CertAuditServiceImplTest {
|
||||
*/
|
||||
static String completeVehicleSnapshot() {
|
||||
return "{\"drivingLicenseNo\":\"450100123456\",\"approvedLoadKg\":18000,"
|
||||
+ "\"ownerName\":\"桂物物流集团\",\"businessRelation\":\"管理挂靠\","
|
||||
+ "\"ownerName\":\"广西现代物流集团有限公司\",\"businessRelation\":\"管理挂靠\","
|
||||
+ "\"vehicleType\":\"重型货车\",\"driveLicenseImage\":\"http://oss/xsz.jpg\"}";
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -464,13 +464,13 @@ class DriverCertServiceImplTest {
|
||||
assertEquals("重型货车", vehicleCaptor.getValue().getVehicleType());
|
||||
assertNull(vehicleCaptor.getValue().getEffectiveCertRecordId(),
|
||||
"新建的车不得自带生效快照——是否已认证只能由审核通过决定");
|
||||
assertEquals("桂物物流集团", vehicleCaptor.getValue().getOrganizationName(),
|
||||
assertEquals("广西现代物流集团有限公司", vehicleCaptor.getValue().getOrganizationName(),
|
||||
"所属组织必须从司机档案带出:该列 NOT NULL 且无默认值,空着这条 INSERT 直接报错");
|
||||
assertEquals("管理挂靠", vehicleCaptor.getValue().getBusinessRelation(),
|
||||
"业务关系由司机在表单里选,服务端照实落库、不替用户猜");
|
||||
assertNotNull(vehicleCaptor.getValue().getRegistrationNo(),
|
||||
"机动车登记编号该列 NOT NULL:司机端选填,缺值要写空串而不是 null");
|
||||
assertEquals("桂物物流集团", vehicleCaptor.getValue().getOwnerName(),
|
||||
assertEquals("广西现代物流集团有限公司", vehicleCaptor.getValue().getOwnerName(),
|
||||
"所有人是行驶证上的登记主体,与所属组织是两个字段");
|
||||
|
||||
// 绑定:司机的 driving_vehicle 被追加该车牌
|
||||
@@ -663,7 +663,7 @@ class DriverCertServiceImplTest {
|
||||
VehicleCertSubmitRequest request = new VehicleCertSubmitRequest();
|
||||
request.setPlateNo(plateNo);
|
||||
request.setVehicleType("重型货车");
|
||||
request.setOwnerName("桂物物流集团");
|
||||
request.setOwnerName("广西现代物流集团有限公司");
|
||||
request.setPlateColor("黄牌");
|
||||
request.setEnergyType("柴油");
|
||||
request.setBusinessRelation("管理挂靠");
|
||||
@@ -691,7 +691,7 @@ class DriverCertServiceImplTest {
|
||||
driver.setDriverName("张三");
|
||||
// 车辆表的 organization_name 是 NOT NULL 且无默认值:司机缺组织时建车必然失败,
|
||||
// 故建车链路要求它非空,fixture 也必须给
|
||||
driver.setOrganizationName("桂物物流集团");
|
||||
driver.setOrganizationName("广西现代物流集团有限公司");
|
||||
driver.setDrivingVehicle(drivingVehicle);
|
||||
return driver;
|
||||
}
|
||||
|
||||
+6
-1
@@ -28,6 +28,7 @@ import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.service.ITripQueryService;
|
||||
import org.springblade.transport.support.CertificationGate;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -58,6 +59,7 @@ class DriverNodePunchPhotoTest {
|
||||
private static final Long WAYBILL_ID = 5601L;
|
||||
private static final Long USER_ID = 7001L;
|
||||
private static final String PLATE = "桂AAW3255";
|
||||
private static final String PHONE = "13800138003";
|
||||
private static final String CLOCK_PHOTO = "https://oss/clock.jpg";
|
||||
private static final String WAYBILL_PHOTO = "https://oss/waybill.jpg";
|
||||
private static final String RECEIPT_PHOTO = "https://oss/receipt.jpg";
|
||||
@@ -99,7 +101,8 @@ class DriverNodePunchPhotoTest {
|
||||
mock(CertificationGate.class),
|
||||
mock(WaybillEnroutePunchMapper.class),
|
||||
nodePunchMapper,
|
||||
exceptionDisposalMapper);
|
||||
exceptionDisposalMapper,
|
||||
mock(ITripQueryService.class));
|
||||
|
||||
when(driverService.getOne(any())).thenReturn(driver());
|
||||
when(waybillService.getOne(any())).thenReturn(waybill());
|
||||
@@ -308,6 +311,7 @@ class DriverNodePunchPhotoTest {
|
||||
driver.setId(9001L);
|
||||
driver.setUserId(USER_ID);
|
||||
driver.setDrivingVehicle(PLATE);
|
||||
driver.setMobile(PHONE);
|
||||
return driver;
|
||||
}
|
||||
|
||||
@@ -316,6 +320,7 @@ class DriverNodePunchPhotoTest {
|
||||
waybill.setId(WAYBILL_ID);
|
||||
waybill.setWaybillNo("YD202610070001");
|
||||
waybill.setVehicleNo(PLATE);
|
||||
waybill.setDriverPhone(PHONE);
|
||||
waybill.setProcessJson(PROCESS_JSON);
|
||||
waybill.setBusinessStatus("running");
|
||||
waybill.setIsDeleted(0);
|
||||
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springblade.system.feign.IMpUserClient;
|
||||
import org.springblade.transport.mapper.ExceptionDisposalMapper;
|
||||
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
|
||||
import org.springblade.transport.mapper.WaybillNodePunchMapper;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
|
||||
import org.springblade.transport.pojo.entity.WaybillNodePunch;
|
||||
import org.springblade.transport.pojo.vo.DriverPunchNodeVO;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.service.ITripQueryService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.CertificationGate;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 末位打卡卡上的「完成运单」标记:打这一张卡这张运单就完成了,客户端据此把按钮文案从「提交打卡」改成「完成运单」。
|
||||
* <p>
|
||||
* 判据必须与 {@code submitNode} 的自动完成同一份——末位可打卡节点 + 到货/卸货/签收 + 回单不要求业务员确认;
|
||||
* 两处不同源就会出现「按钮写着完成运单、打下去这张运单没完成」。接单与回单不是司机打卡节点,不参与排位。
|
||||
*/
|
||||
class DriverPunchFinishFlagTest {
|
||||
|
||||
private static final Long WAYBILL_ID = 5601L;
|
||||
|
||||
private static final String NO_RETURN_JSON = """
|
||||
[{"key":"accept","name":"接单","type":"confirm","enabled":true,"punch":true},
|
||||
{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}]
|
||||
""";
|
||||
|
||||
private static final String RETURN_NO_CONFIRM_JSON = """
|
||||
[{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"no_confirm_complete"}]
|
||||
""";
|
||||
|
||||
private static final String RETURN_NEED_CONFIRM_JSON = """
|
||||
[{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}]
|
||||
""";
|
||||
|
||||
/** 末位可打卡节点是「到场」:不在到货/卸货/签收之列,打完不算完成 */
|
||||
private static final String LAST_NODE_NOT_DELIVERY_JSON = """
|
||||
[{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"arrive_scene","name":"到场","type":"location","enabled":true,"punch":true}]
|
||||
""";
|
||||
|
||||
private IWaybillService waybillService;
|
||||
private WaybillNodePunchMapper nodePunchMapper;
|
||||
private DriverWaybillServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class);
|
||||
TableInfoHelper.initTableInfo(assistant, WaybillEnroutePunch.class);
|
||||
TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
waybillService = mock(IWaybillService.class);
|
||||
nodePunchMapper = mock(WaybillNodePunchMapper.class);
|
||||
WaybillEnroutePunchMapper enroutePunchMapper = mock(WaybillEnroutePunchMapper.class);
|
||||
ExceptionDisposalMapper exceptionDisposalMapper = mock(ExceptionDisposalMapper.class);
|
||||
service = new DriverWaybillServiceImpl(
|
||||
waybillService,
|
||||
mock(IDriverService.class),
|
||||
mock(IMpUserClient.class),
|
||||
mock(IProcessConfigService.class),
|
||||
mock(ITransportVehicleService.class),
|
||||
mock(CertificationGate.class),
|
||||
enroutePunchMapper,
|
||||
nodePunchMapper,
|
||||
exceptionDisposalMapper,
|
||||
mock(ITripQueryService.class));
|
||||
|
||||
when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
when(enroutePunchMapper.selectOne(any())).thenReturn(null);
|
||||
when(enroutePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("末位是签收且没有回单节点:只有它带完成标记,装货与接单都不带")
|
||||
void lastDeliveryNodeCarriesFinishFlag() {
|
||||
Map<String, DriverPunchNodeVO> nodes = punchNodes(NO_RETURN_JSON);
|
||||
|
||||
assertTrue(Boolean.TRUE.equals(nodes.get("sign").getFinishOnPunch()), "签收是末位到货类节点,打完即完成");
|
||||
assertFalse(Boolean.TRUE.equals(nodes.get("load").getFinishOnPunch()), "装货不是末位");
|
||||
assertFalse(nodes.containsKey("accept"), "接单不是司机打卡节点,不出现在卡片里");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("回单要求业务员确认:末位卡不带完成标记(司机打完不置完成,等业务员确认)")
|
||||
void returnNeedingConfirmationTakesTheFlagAway() {
|
||||
Map<String, DriverPunchNodeVO> nodes = punchNodes(RETURN_NEED_CONFIRM_JSON);
|
||||
|
||||
assertFalse(Boolean.TRUE.equals(nodes.get("sign").getFinishOnPunch()),
|
||||
"回单要业务员确认时,签收打完不算完成——按钮文案也不能写成完成运单");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("回单不要求业务员确认:末位卡仍带完成标记")
|
||||
void returnWithoutConfirmationKeepsTheFlag() {
|
||||
Map<String, DriverPunchNodeVO> nodes = punchNodes(RETURN_NO_CONFIRM_JSON);
|
||||
|
||||
assertTrue(Boolean.TRUE.equals(nodes.get("sign").getFinishOnPunch()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("末位节点不是到货/卸货/签收:不带完成标记")
|
||||
void lastNodeOutsideDeliveryDoesNotCarryFinishFlag() {
|
||||
Map<String, DriverPunchNodeVO> nodes = punchNodes(LAST_NODE_NOT_DELIVERY_JSON);
|
||||
|
||||
assertFalse(Boolean.TRUE.equals(nodes.get("arrive_scene").getFinishOnPunch()),
|
||||
"到场不在到货/卸货/签收之列,打完不置完成");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
/** 用给定过程配置快照取这趟车下发的节点卡,按 key 索引 */
|
||||
private Map<String, DriverPunchNodeVO> punchNodes(String processJson) {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(WAYBILL_ID);
|
||||
waybill.setWaybillNo("YD202610180001");
|
||||
waybill.setProcessJson(processJson);
|
||||
waybill.setBusinessStatus("running");
|
||||
waybill.setIsDeleted(0);
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill);
|
||||
|
||||
return service.detailPunchSnapshot(WAYBILL_ID).getPunchNodes().stream()
|
||||
.collect(Collectors.toMap(DriverPunchNodeVO::getKey, Function.identity()));
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user