🔀 合并 origin/master 到 dev
This commit is contained in:
+3
-2
@@ -309,12 +309,13 @@ public interface ISysClient {
|
||||
R<Region> getRegion(@RequestParam("code") String code);
|
||||
|
||||
/**
|
||||
* 获取启用的费用项
|
||||
* 获取费用项。默认仅返回启用档案;includeDisabled 为 true 时同时返回停用档案。
|
||||
*
|
||||
* @param includeDisabled 是否包含停用费用项
|
||||
* @return 费用项集合
|
||||
*/
|
||||
@GetMapping(FEE_ITEMS)
|
||||
R<List<FeeItem>> getFeeItems();
|
||||
R<List<FeeItem>> getFeeItems(@RequestParam(value = "includeDisabled", required = false) Boolean includeDisabled);
|
||||
|
||||
@GetMapping(CARGO_TYPES)
|
||||
R<List<CargoType>> getCargoTypes();
|
||||
|
||||
+1
-1
@@ -165,7 +165,7 @@ public class ISysClientFallback implements ISysClient {
|
||||
}
|
||||
|
||||
@Override
|
||||
public R<List<FeeItem>> getFeeItems() {
|
||||
public R<List<FeeItem>> getFeeItems(Boolean includeDisabled) {
|
||||
return R.fail("获取数据失败");
|
||||
}
|
||||
|
||||
|
||||
+15
@@ -72,4 +72,19 @@ public class OaOrgSyncPageVO implements Serializable {
|
||||
|
||||
@Schema(description = "全量同步结束后原始数据层级 JSON 文件路径")
|
||||
private String dumpFilePath;
|
||||
|
||||
@Schema(description = "当前同步的公司 OA 主键(部门阶段按公司轮询时使用)")
|
||||
private String companyOaId;
|
||||
|
||||
@Schema(description = "当前公司序号,从 1 开始")
|
||||
private Integer companyIndex;
|
||||
|
||||
@Schema(description = "待同步公司总数")
|
||||
private Integer companyTotal;
|
||||
|
||||
@Schema(description = "当前公司的部门是否已同步完")
|
||||
private Boolean companyFinished;
|
||||
|
||||
@Schema(description = "下一公司 OA 主键;当前公司完成后回传,前端据此切换并重置页码")
|
||||
private String nextCompanyOaId;
|
||||
}
|
||||
|
||||
+3
@@ -35,6 +35,7 @@ public class PaymentApplicationSaveRequest implements Serializable {
|
||||
private Long settlementId;
|
||||
private List<Long> settlementIds;
|
||||
private Long preSettlementId;
|
||||
private List<Long> preSettlementIds;
|
||||
private Long projectId;
|
||||
private String projectName;
|
||||
private Long deptId;
|
||||
@@ -60,4 +61,6 @@ public class PaymentApplicationSaveRequest implements Serializable {
|
||||
private String remark;
|
||||
private List<PaymentApplicationInvoiceRequest> invoices;
|
||||
private List<PaymentApplicationRecordRequest> paymentRecords;
|
||||
/** 多张结算单时按行提交的付款金额 */
|
||||
private List<PaymentApplicationSettlementLineRequest> settlementLines;
|
||||
}
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 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.pojo.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/** 付款申请多结算单付款明细。 @author Chill */
|
||||
@Data
|
||||
public class PaymentApplicationSettlementLineRequest implements Serializable {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
private Long formalSettlementId;
|
||||
private Long preSettlementId;
|
||||
private BigDecimal paymentRatio;
|
||||
private BigDecimal appliedAmount;
|
||||
}
|
||||
+1
-1
@@ -33,7 +33,7 @@ public class PreSettlementDetailAdjustRequest implements Serializable {
|
||||
@Schema(description = "预结算明细ID")
|
||||
private Long detailId;
|
||||
|
||||
@Schema(description = "调整原因")
|
||||
@Schema(description = "备注(写入结算明细备注,非必填)")
|
||||
private String changeReason;
|
||||
|
||||
@Schema(description = "明细费用行")
|
||||
|
||||
+6
@@ -63,4 +63,10 @@ public class ReceivablePayableFeeCalculateRequest implements Serializable {
|
||||
@Schema(description = "动态费用项目")
|
||||
private Map<String, BigDecimal> feeItems;
|
||||
|
||||
@Schema(description = "运输单价")
|
||||
private BigDecimal unitPrice;
|
||||
|
||||
@Schema(description = "本次调整字段:transportQuantity、mileage、unitPrice 或费用项名称")
|
||||
private String changedField;
|
||||
|
||||
}
|
||||
|
||||
+8
@@ -172,4 +172,12 @@ public class FormalSettlement extends TenantEntity {
|
||||
* 作废原因,驳回时记录驳回原因
|
||||
*/
|
||||
private String voidReason;
|
||||
/**
|
||||
* 来源原正式结算单号:结算调整重推生成的新单,指向被调整的原单
|
||||
*/
|
||||
private String originFormalSettlementNo;
|
||||
/**
|
||||
* 调整状态:adjusted-已被结算调整(原单标记)
|
||||
*/
|
||||
private String adjustStatus;
|
||||
}
|
||||
|
||||
+4
@@ -25,6 +25,10 @@ public class FormalSettlementChangeRecord extends TenantEntity {
|
||||
private Integer lineNo;
|
||||
private String operationType;
|
||||
private String changeContent;
|
||||
/** 变更前数据JSON */
|
||||
private String beforeData;
|
||||
/** 变更后数据JSON */
|
||||
private String afterData;
|
||||
private String changeReason;
|
||||
private String operatorName;
|
||||
private LocalDateTime changeTime;
|
||||
|
||||
+8
@@ -8,7 +8,9 @@
|
||||
*/
|
||||
package org.springblade.transport.pojo.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.springblade.core.tenant.mp.TenantEntity;
|
||||
@@ -123,6 +125,12 @@ public class FormalSettlementDetail extends TenantEntity {
|
||||
* 批次号
|
||||
*/
|
||||
private String batchNo;
|
||||
/**
|
||||
* 计费方式(来源应收应付货物费用计费类型,非本表字段)
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "计费方式")
|
||||
private String billingType;
|
||||
/**
|
||||
* 运输单价
|
||||
*/
|
||||
|
||||
+8
@@ -8,7 +8,9 @@
|
||||
*/
|
||||
package org.springblade.transport.pojo.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.springblade.core.tenant.mp.TenantEntity;
|
||||
@@ -70,6 +72,12 @@ public class FormalSettlementDetailFee extends TenantEntity {
|
||||
* 费用项JSON
|
||||
*/
|
||||
private String feeItemsJson;
|
||||
/**
|
||||
* 命中计费规则JSON(来源费用带回,非本表字段)
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "命中计费规则JSON")
|
||||
private String billingRulesJson;
|
||||
/**
|
||||
* 原金额
|
||||
*/
|
||||
|
||||
+6
@@ -1,5 +1,6 @@
|
||||
package org.springblade.transport.pojo.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
@@ -17,7 +18,12 @@ public class PaymentApplicationSettlement extends TenantEntity {
|
||||
private Long paymentApplicationId;
|
||||
private Long formalSettlementId;
|
||||
private String formalSettlementNo;
|
||||
private Long preSettlementId;
|
||||
private String preSettlementNo;
|
||||
private BigDecimal settlementAmount;
|
||||
private BigDecimal appliedAmount;
|
||||
private BigDecimal paidAmount;
|
||||
/** 查看时回填的可付款金额,不落库 */
|
||||
@TableField(exist = false)
|
||||
private BigDecimal payableAmount;
|
||||
}
|
||||
|
||||
+5
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
package org.springblade.transport.pojo.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
@@ -99,6 +100,10 @@ public class PreSettlementDetail extends TenantEntity {
|
||||
@Schema(description = "批次号")
|
||||
private String batchNo;
|
||||
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "计费方式")
|
||||
private String billingType;
|
||||
|
||||
@Schema(description = "运输单价")
|
||||
private BigDecimal unitPrice;
|
||||
|
||||
|
||||
+4
@@ -30,6 +30,10 @@ public class SettlementAdjustment extends TenantEntity {
|
||||
private String currentNode;
|
||||
private String currentProcessor;
|
||||
private String kingdeeSyncStatus;
|
||||
/**
|
||||
* 金蝶单据号(未推送场景回填原结算单金蝶单号;重推场景回填新单金蝶单号)
|
||||
*/
|
||||
private String kingdeeBillNo;
|
||||
private String attachmentsJson;
|
||||
private String remark;
|
||||
private LocalDateTime approvedTime;
|
||||
|
||||
+3
@@ -107,6 +107,9 @@ public class SettlementImportBatch extends TenantEntity {
|
||||
@Schema(description = "动态费用列名JSON")
|
||||
private String feeItemNamesJson;
|
||||
|
||||
@Schema(description = "导入明细行JSON")
|
||||
private String rowsJson;
|
||||
|
||||
@Schema(description = "备注")
|
||||
private String remark;
|
||||
|
||||
|
||||
+3
@@ -6,6 +6,8 @@
|
||||
*/
|
||||
package org.springblade.transport.pojo.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
@@ -41,6 +43,7 @@ public class TransportReconciliationExternal extends TenantEntity {
|
||||
private BigDecimal freightAmount;
|
||||
private String feeItemsJson;
|
||||
private BigDecimal settlementAmount;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private Boolean suspectedDuplicate;
|
||||
private String matchStatus;
|
||||
private Long matchedInternalId;
|
||||
|
||||
+3
@@ -58,6 +58,8 @@ public class FormalSettlementVO extends FormalSettlement implements KingdeeUnloc
|
||||
private String kingdeeUnlockOperator;
|
||||
@TableField(exist = false) @Schema(description = "解锁动作名称:金蝶删单/金蝶锁单")
|
||||
private String kingdeeUnlockOperationName;
|
||||
@TableField(exist = false) @Schema(description = "调整状态名称:adjusted-已调整")
|
||||
private String adjustStatusName;
|
||||
@TableField(exist = false) private String preSettlementNos;
|
||||
@TableField(exist = false) private String preSettlementNo;
|
||||
@TableField(exist = false) private List<FormalSettlementSource> sources;
|
||||
@@ -70,6 +72,7 @@ public class FormalSettlementVO extends FormalSettlement implements KingdeeUnloc
|
||||
@TableField(exist = false) @Schema(description = "开票申请经金蝶回写的发票结果(含文件地址,只读展示)")
|
||||
private List<InvoiceApplicationInvoice> issuedInvoices;
|
||||
@TableField(exist = false) private List<PaymentApplicationVO> paymentApplications;
|
||||
@TableField(exist = false) private List<SettlementAdvancePaymentVO> advancePayments;
|
||||
@TableField(exist = false) private List<SettlementAdjustmentVO> adjustments;
|
||||
@TableField(exist = false) private List<FormalSettlementChangeRecord> changeRecords;
|
||||
}
|
||||
|
||||
+2
@@ -27,4 +27,6 @@ public class PaymentApplicationReferenceAmountVO implements Serializable {
|
||||
@Schema(description = "结算金额") private BigDecimal settlementAmount;
|
||||
@Schema(description = "累计申请付款金额") private BigDecimal cumulativeAppliedAmount;
|
||||
@Schema(description = "可付款金额") private BigDecimal payableAmount;
|
||||
/** 该结算单当前为第几次付款(含本次,已作废不计) */
|
||||
@Schema(description = "付款次序") private Integer paymentSequence;
|
||||
}
|
||||
|
||||
+6
@@ -39,6 +39,7 @@ import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 收款流水视图实体类
|
||||
@@ -91,4 +92,9 @@ public class ReceiptFlowVO extends KingdeeReceiptFlow {
|
||||
private BigDecimal receiptAmountEnd;
|
||||
@TableField(exist = false)
|
||||
private List<ReceiptFlowRecord> claimRecords = new ArrayList<>();
|
||||
/**
|
||||
* 本流水历史关联且仍有剩余可认领金额的应收正式结算单(继续认领时预填)
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
private List<Map<String, Object>> relatedSettlements = new ArrayList<>();
|
||||
}
|
||||
|
||||
+4
@@ -73,6 +73,10 @@ public class ReceivablePayableDetailVO extends ReceivablePayableDetail {
|
||||
@Schema(description = "状态名称")
|
||||
private String settlementStatusName;
|
||||
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "计费方式")
|
||||
private String billingType;
|
||||
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "费用项目")
|
||||
private Map<String, Object> feeItems;
|
||||
|
||||
-1
@@ -17,7 +17,6 @@ public class SettlementAdjustmentVO extends SettlementAdjustment {
|
||||
private String approvalStatusName;
|
||||
private String settlementTypeName;
|
||||
private String createUserName;
|
||||
private String kingdeeBillNo;
|
||||
private List<SettlementAdjustmentDetail> details;
|
||||
private List<FormalSettlementDetail> formalDetails;
|
||||
}
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 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.pojo.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 结算单统一预付付款记录(进度预付 / 项目预付核销,供付款记录合并展示)
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "结算单统一预付付款记录")
|
||||
public class SettlementAdvancePaymentVO implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "记录类型:progress_advance 进度预付 / project_advance 项目预付核销")
|
||||
private String recordType;
|
||||
|
||||
@Schema(description = "付款单号")
|
||||
private String paymentNo;
|
||||
|
||||
@Schema(description = "申请金额(进度预付为申请预付金额,项目预付为本次核销金额)")
|
||||
private BigDecimal appliedAmount;
|
||||
|
||||
@Schema(description = "已付款金额(底层付款申请实付)")
|
||||
private BigDecimal paidAmount;
|
||||
|
||||
@Schema(description = "单据状态")
|
||||
private String billStatus;
|
||||
|
||||
@Schema(description = "金蝶单据号")
|
||||
private String kingdeeBillNo;
|
||||
|
||||
@Schema(description = "创建人姓名")
|
||||
private String createUserName;
|
||||
|
||||
@Schema(description = "创建时间(统一取底层付款申请创建时间)")
|
||||
private Date createTime;
|
||||
|
||||
}
|
||||
+4
@@ -32,6 +32,7 @@ import org.springblade.transport.pojo.entity.SettlementImportBatch;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 导入结算单批次视图
|
||||
@@ -66,4 +67,7 @@ public class SettlementImportBatchVO extends SettlementImportBatch {
|
||||
|
||||
@Schema(description = "动态费用列名")
|
||||
private List<String> feeItemNames;
|
||||
|
||||
@Schema(description = "导入明细行")
|
||||
private List<Map<String, Object>> rows;
|
||||
}
|
||||
|
||||
+34
-1
@@ -17,7 +17,40 @@ public interface ISettlementAdjustmentService {
|
||||
Long saveDraft(SettlementAdjustmentSaveRequest request);
|
||||
void removeDraft(Long id);
|
||||
void submit(SettlementAdjustmentStatusRequest request);
|
||||
void approve(SettlementAdjustmentStatusRequest request);
|
||||
/**
|
||||
* 审批通过:未推送场景回写原单并发布金蝶自动推送事件;已推送场景原单零改动仅追加变更记录。
|
||||
*
|
||||
* @param request 审批请求
|
||||
* @return 已推送场景的手工冲销提示文案,未推送场景返回 null
|
||||
*/
|
||||
String approve(SettlementAdjustmentStatusRequest request);
|
||||
void returnBill(SettlementAdjustmentStatusRequest request);
|
||||
String repush(Long adjustmentId);
|
||||
|
||||
/**
|
||||
* 选单后金蝶冲销预检(软提示,不阻断建单):未推送 / 已推送已冲销 / 已推送未冲销;
|
||||
* 金蝶查询异常降级返回「未校验」,由审批分流与重推闸门兜底
|
||||
*
|
||||
* @param formalSettlementId 选定正式结算单id
|
||||
* @return syncStatus / writtenOff / message
|
||||
*/
|
||||
Map<String, Object> kingdeeWriteOffPrecheck(Long formalSettlementId);
|
||||
|
||||
/**
|
||||
* 推送金蝶(未推送/生成失败场景):推送关联正式结算单,成功后由正式结算单回填调整单金蝶单号与同步状态
|
||||
*
|
||||
* @param adjustmentId 结算调整单id
|
||||
* @return 金蝶单据编号
|
||||
*/
|
||||
String syncKingdee(Long adjustmentId);
|
||||
|
||||
/**
|
||||
* 重推克隆+应用(独立事务,由 repush 经自代理调用):克隆原单为新正式结算单并应用调整,
|
||||
* 供推送失败重试时幂等复用
|
||||
*
|
||||
* @param originalSettlementId 原正式结算单id
|
||||
* @param adjustmentId 结算调整单id
|
||||
* @return 新正式结算单id
|
||||
*/
|
||||
Long cloneAndApplyForRepush(Long originalSettlementId, Long adjustmentId);
|
||||
}
|
||||
|
||||
+4
@@ -132,6 +132,10 @@ public class User extends TenantEntity implements IPhone {
|
||||
* 是否是oa的账号
|
||||
*/
|
||||
private Integer isOa;
|
||||
/**
|
||||
* OA登录名
|
||||
*/
|
||||
private String oaLoginId;
|
||||
/**
|
||||
* OA上次更新时间(OA 的 modified 字段),仅同步比对使用
|
||||
*/
|
||||
|
||||
-2
@@ -11,7 +11,6 @@ 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.utils.AuthUtil;
|
||||
import org.springblade.core.tool.api.R;
|
||||
import org.springblade.core.tool.utils.StringUtil;
|
||||
import org.springblade.process.pojo.dto.ApprovalDTO;
|
||||
@@ -49,7 +48,6 @@ public class BusinessProcessController extends BladeController {
|
||||
if (param == null) {
|
||||
param = new ApprovalDTO();
|
||||
}
|
||||
param.setLoginName(AuthUtil.getUserAccount());
|
||||
IPage<ApprovalVO> pages = businessProcessService.queryMkApprovalList(Condition.getPage(query), param);
|
||||
return R.data(pages);
|
||||
}
|
||||
|
||||
+2
-2
@@ -71,7 +71,7 @@ public interface IBusinessProcessService extends IService<BusinessProcess> {
|
||||
* 获取流程当前节点详情
|
||||
*
|
||||
* @param processInstanceId 流程实例id
|
||||
* @param loginName MK登录名(手机号),可为空,为空时按流程发起人解析
|
||||
* @param loginName MK登录名(OA登录名),可为空,为空时按流程发起人解析
|
||||
* @return 当前节点列表
|
||||
*/
|
||||
List<?> getCurrentNodes(String processInstanceId, String loginName);
|
||||
@@ -80,7 +80,7 @@ public interface IBusinessProcessService extends IService<BusinessProcess> {
|
||||
* 获取流程实例详情
|
||||
*
|
||||
* @param processInstanceId 流程实例id
|
||||
* @param loginName MK登录名(手机号),可为空,为空时按流程发起人解析
|
||||
* @param loginName MK登录名(OA登录名),可为空,为空时按流程发起人解析
|
||||
* @return 流程实例详情
|
||||
*/
|
||||
Object getProcessInfo(String processInstanceId, String loginName);
|
||||
|
||||
+21
-27
@@ -132,9 +132,9 @@ public class BusinessProcessServiceImpl extends ServiceImpl<BusinessProcessMappe
|
||||
public String processSubmit(MKProcessCreateDTO param) {
|
||||
AssertUtils.notNull(param, "提交流程参数不能为空");
|
||||
AssertUtils.notBlank(param.getTemplateCode(), "模板编码不能为空");
|
||||
// 前端拿到的手机号可能经 @Sensitive 脱敏(如 137****8880),这里从库取真实手机号覆盖
|
||||
String loginName = resolveCurrentUserPhone();
|
||||
AssertUtils.notBlank(loginName, "当前用户手机号为空,无法提交审核流");
|
||||
// MK loginName 使用当前用户 OA 登录名,未配置则不允许提交
|
||||
String loginName = resolveCurrentUserOaLoginId();
|
||||
AssertUtils.notBlank(loginName, "当前用户OA登录名为空,无法提交审核流");
|
||||
param.setSubmitIdentity(loginName);
|
||||
param.setLoginName(loginName);
|
||||
String bizType = param.getBizType();
|
||||
@@ -258,7 +258,7 @@ public class BusinessProcessServiceImpl extends ServiceImpl<BusinessProcessMappe
|
||||
resolvedLoginName = businessProcess.getPromoterLoginName();
|
||||
}
|
||||
if (StringUtil.isBlank(resolvedLoginName)) {
|
||||
resolvedLoginName = resolveCurrentUserPhone();
|
||||
resolvedLoginName = resolveCurrentUserOaLoginId();
|
||||
}
|
||||
if (StringUtil.isBlank(resolvedLoginName)) {
|
||||
return false;
|
||||
@@ -291,10 +291,10 @@ public class BusinessProcessServiceImpl extends ServiceImpl<BusinessProcessMappe
|
||||
}
|
||||
String loginName = businessProcess.getPromoterLoginName();
|
||||
if (StringUtil.isBlank(loginName)) {
|
||||
loginName = resolveCurrentUserPhone();
|
||||
loginName = resolveCurrentUserOaLoginId();
|
||||
}
|
||||
if (StringUtil.isBlank(loginName)) {
|
||||
log.warn("查询MK流程实例跳过,登录名为空 bizId={} processInstanceId={}",
|
||||
log.warn("查询MK流程实例跳过,OA登录名为空 bizId={} processInstanceId={}",
|
||||
bizId, businessProcess.getProcessInstanceId());
|
||||
return false;
|
||||
}
|
||||
@@ -357,9 +357,9 @@ public class BusinessProcessServiceImpl extends ServiceImpl<BusinessProcessMappe
|
||||
}
|
||||
String loginName = businessProcess.getPromoterLoginName();
|
||||
if (StringUtil.isBlank(loginName)) {
|
||||
loginName = resolveCurrentUserPhone();
|
||||
loginName = resolveCurrentUserOaLoginId();
|
||||
}
|
||||
AssertUtils.notBlank(loginName, "当前用户手机号为空,无法删除审核流");
|
||||
AssertUtils.notBlank(loginName, "当前用户OA登录名为空,无法删除审核流");
|
||||
log.info("调用mk processDelete processInstanceId={} loginName={} bizId={} formInstanceId={}",
|
||||
businessProcess.getProcessInstanceId(), loginName, bizId, businessProcess.getFormInstanceId());
|
||||
boolean deleted = mkService.processDelete(businessProcess.getProcessInstanceId(), loginName);
|
||||
@@ -563,27 +563,18 @@ public class BusinessProcessServiceImpl extends ServiceImpl<BusinessProcessMappe
|
||||
}
|
||||
|
||||
/**
|
||||
* 从当前登录用户实体读取真实手机号(库内 SM2 密文先解密,绕过接口脱敏)
|
||||
* 从当前登录用户实体读取 OA 登录名,作为 MK 接口 loginName
|
||||
*/
|
||||
private String resolveCurrentUserPhone() {
|
||||
private String resolveCurrentUserOaLoginId() {
|
||||
Long userId = AuthUtil.getUserId();
|
||||
if (userId != null) {
|
||||
User user = userService.getById(userId);
|
||||
if (user != null) {
|
||||
userService.decryptPhone(user);
|
||||
if (StringUtil.isNotBlank(user.getPhone()) && !user.getPhone().contains("*")) {
|
||||
return user.getPhone().trim();
|
||||
}
|
||||
if (StringUtil.isNotBlank(user.getAccount()) && user.getAccount().matches("^1\\d{10}$")) {
|
||||
return user.getAccount().trim();
|
||||
}
|
||||
}
|
||||
if (userId == null) {
|
||||
return null;
|
||||
}
|
||||
String account = AuthUtil.getUserAccount();
|
||||
if (StringUtil.isNotBlank(account) && account.matches("^1\\d{10}$")) {
|
||||
return account.trim();
|
||||
User user = userService.getById(userId);
|
||||
if (user == null || StringUtil.isBlank(user.getOaLoginId())) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
return user.getOaLoginId().trim();
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@@ -703,8 +694,8 @@ public class BusinessProcessServiceImpl extends ServiceImpl<BusinessProcessMappe
|
||||
processInstanceId = businessProcess.getProcessInstanceId();
|
||||
}
|
||||
try {
|
||||
String loginName = resolveCurrentUserPhone();
|
||||
AssertUtils.notBlank(loginName, "当前用户手机号为空,无法获取审核跳转地址");
|
||||
String loginName = resolveCurrentUserOaLoginId();
|
||||
AssertUtils.notBlank(loginName, "当前用户OA登录名为空,无法获取审核跳转地址");
|
||||
String mkApprovalUrl = mkService.getMKApprovalUrl(processInstanceId, loginName);
|
||||
AssertUtils.notBlank(mkApprovalUrl, "获取mk审批页面链接异常");
|
||||
return mkApprovalUrl;
|
||||
@@ -921,6 +912,9 @@ public class BusinessProcessServiceImpl extends ServiceImpl<BusinessProcessMappe
|
||||
|
||||
@Override
|
||||
public IPage<ApprovalVO> queryMkApprovalList(IPage<ApprovalVO> page, ApprovalDTO param) {
|
||||
String loginName = resolveCurrentUserOaLoginId();
|
||||
AssertUtils.notBlank(loginName, "当前用户OA登录名为空,无法查询审核列表");
|
||||
param.setLoginName(loginName);
|
||||
if (MKDoc.RELATED.getCode().equals(param.getDocType())) {
|
||||
// 我参与的,调用流程列表接口
|
||||
MKProcessDTO processParam = approvalConvert.dto2mk(param);
|
||||
|
||||
+4
-3
@@ -204,7 +204,7 @@ public class DeptController extends BladeController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从OA按页同步部门(需先完成公司同步)
|
||||
* 从OA按页同步部门(需先完成公司同步;按公司 oaId 轮询查询)
|
||||
*/
|
||||
@IsAdmin
|
||||
@PostMapping("/sync-oa-department")
|
||||
@@ -214,8 +214,9 @@ public class DeptController extends BladeController {
|
||||
@RequestParam(defaultValue = "1") Integer current,
|
||||
@RequestParam(defaultValue = "20") Integer size,
|
||||
@RequestParam(defaultValue = "false") Boolean incremental,
|
||||
@RequestParam(required = false) String modifiedSince) {
|
||||
return R.data(oaSyncService.syncDepartmentPage(current, size, Boolean.TRUE.equals(incremental), modifiedSince));
|
||||
@RequestParam(required = false) String modifiedSince,
|
||||
@RequestParam(required = false) String companyOaId) {
|
||||
return R.data(oaSyncService.syncDepartmentPage(current, size, Boolean.TRUE.equals(incremental), modifiedSince, companyOaId));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
@@ -70,6 +70,7 @@ public interface UserConvert {
|
||||
@Mapping(source = "lastname", target = "realName")
|
||||
@Mapping(source = "mobile", target = "phone")
|
||||
@Mapping(source = "email", target = "email")
|
||||
@Mapping(source = "loginid", target = "oaLoginId")
|
||||
@Mapping(source = "modified", target = "oaModified")
|
||||
User baseConvert(OAPersonResponse person);
|
||||
|
||||
|
||||
+3
-2
@@ -32,6 +32,7 @@ import org.springblade.core.tool.api.R;
|
||||
import org.springblade.system.pojo.entity.*;
|
||||
import org.springblade.system.service.*;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
@@ -218,9 +219,9 @@ public class SysClient implements ISysClient {
|
||||
|
||||
@Override
|
||||
@GetMapping(FEE_ITEMS)
|
||||
public R<List<FeeItem>> getFeeItems() {
|
||||
public R<List<FeeItem>> getFeeItems(@RequestParam(value = "includeDisabled", required = false) Boolean includeDisabled) {
|
||||
return R.data(feeItemService.list(Wrappers.<FeeItem>lambdaQuery()
|
||||
.eq(FeeItem::getStatus, 1)
|
||||
.eq(!Boolean.TRUE.equals(includeDisabled), FeeItem::getStatus, 1)
|
||||
.eq(FeeItem::getIsDeleted, 0)
|
||||
.orderByAsc(FeeItem::getFeeCategory, FeeItem::getName)));
|
||||
}
|
||||
|
||||
+6
-6
@@ -45,13 +45,13 @@ public interface UserMapper extends BaseMapper<User> {
|
||||
/**
|
||||
* 自定义分页
|
||||
*
|
||||
* @param page
|
||||
* @param user
|
||||
* @param deptIdList
|
||||
* @param tenantId
|
||||
* @return
|
||||
* @param page 分页
|
||||
* @param user 查询条件
|
||||
* @param deptId 部门 ID(含自身及子孙,由 SQL FIND_IN_SET 过滤;为空则不过滤部门)
|
||||
* @param tenantId 租户 ID
|
||||
* @return 用户列表
|
||||
*/
|
||||
List<User> selectUserPage(IPage<User> page, @Param("user") User user, @Param("deptIdList") List<Long> deptIdList, @Param("tenantId") String tenantId);
|
||||
List<User> selectUserPage(IPage<User> page, @Param("user") User user, @Param("deptId") Long deptId, @Param("tenantId") String tenantId);
|
||||
|
||||
/**
|
||||
* 获取用户
|
||||
|
||||
+13
-11
@@ -26,6 +26,7 @@
|
||||
<result column="dept_id" property="deptId"/>
|
||||
<result column="post_id" property="postId"/>
|
||||
<result column="remark" property="remark"/>
|
||||
<result column="oa_login_id" property="oaLoginId"/>
|
||||
<result column="oa_person_id" property="oaPersonId"/>
|
||||
</resultMap>
|
||||
|
||||
@@ -61,20 +62,21 @@
|
||||
<if test="user.status!=null and user.status>=0">
|
||||
and bu.status = #{user.status}
|
||||
</if>
|
||||
<if test="deptIdList!=null and deptIdList.size>0">
|
||||
<if test="deptId != null">
|
||||
and bu.id in (
|
||||
SELECT
|
||||
user_id
|
||||
FROM
|
||||
blade_user_dept
|
||||
WHERE
|
||||
status = 1 and is_deleted = 0 and dept_id IN
|
||||
<foreach collection="deptIdList" index="index" item="item" open="(" separator="," close=")">
|
||||
#{item}
|
||||
</foreach>
|
||||
select distinct bud.user_id
|
||||
from blade_user_dept bud
|
||||
where bud.status = 1
|
||||
and bud.is_deleted = 0
|
||||
and bud.dept_id in (
|
||||
select bd.id
|
||||
from blade_dept bd
|
||||
where bd.is_deleted = 0
|
||||
and (bd.id = #{deptId} or find_in_set(#{deptId}, bd.ancestors) > 0)
|
||||
)
|
||||
)
|
||||
</if>
|
||||
ORDER BY bu.id
|
||||
ORDER BY bu.create_time DESC, bu.id DESC
|
||||
</select>
|
||||
|
||||
<select id="getUser" resultMap="userResultMap">
|
||||
|
||||
+4
-3
@@ -52,13 +52,14 @@ public interface IOASyncService {
|
||||
OaOrgSyncPageVO syncCompanyPage(int current, int size, boolean incremental, String modifiedSince);
|
||||
|
||||
/**
|
||||
* 按页从 OA 部门接口同步部门;最后一页完成后更新祖级列表
|
||||
* 按页从 OA 部门接口同步部门;须先完成公司同步,再按公司 oaId 轮询查询部门
|
||||
*
|
||||
* @param current 当前页,从 1 开始
|
||||
* @param current 当前页,从 1 开始(相对当前公司)
|
||||
* @param size 每页条数
|
||||
* @param incremental 是否增量同步(按本地最大 oaModified 过滤 OA)
|
||||
* @param modifiedSince 增量起始时间;首屏可空,后续分页回传首屏返回值
|
||||
* @param companyOaId 当前公司 OA 主键;首屏可空(自动取第一家),后续回传
|
||||
* @return 本页同步结果
|
||||
*/
|
||||
OaOrgSyncPageVO syncDepartmentPage(int current, int size, boolean incremental, String modifiedSince);
|
||||
OaOrgSyncPageVO syncDepartmentPage(int current, int size, boolean incremental, String modifiedSince, String companyOaId);
|
||||
}
|
||||
|
||||
+66
-19
@@ -367,7 +367,9 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
|
||||
@Override
|
||||
public List<Dept> getDeptChild(Long deptId) {
|
||||
return baseMapper.selectList(Wrappers.<Dept>query().lambda().like(Dept::getAncestors, deptId));
|
||||
// 必须用 FIND_IN_SET:LIKE '%1%' 会误匹配雪花 ID 中的数字片段,导致子部门集合爆炸
|
||||
return baseMapper.selectList(Wrappers.<Dept>query().lambda()
|
||||
.apply("FIND_IN_SET({0}, ancestors)", String.valueOf(deptId)));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1039,7 +1041,7 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
boolean isCompany = this.isOaCompany(dept);
|
||||
boolean parentExists = isCompany
|
||||
? companyByOaId.containsKey(parentOaId)
|
||||
: departmentByOaId.containsKey(parentOaId) || companyByOaId.containsKey(parentOaId);
|
||||
: this.resolveOaDepartmentParent(dept, parentOaId, companyByOaId, departmentByOaId) != null;
|
||||
// 全量数据整理后仍找不到上级:公司、部门一律删除,不改挂保留
|
||||
if (!parentExists) {
|
||||
deleteIds.add(dept.getId());
|
||||
@@ -1063,7 +1065,7 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
}
|
||||
Dept parent = this.isOaCompany(dept)
|
||||
? companyByOaId.get(parentOaId)
|
||||
: Optional.ofNullable(departmentByOaId.get(parentOaId)).orElse(companyByOaId.get(parentOaId));
|
||||
: this.resolveOaDepartmentParent(dept, parentOaId, companyByOaId, departmentByOaId);
|
||||
if (parent != null && deleteIds.contains(parent.getId())) {
|
||||
deleteIds.add(dept.getId());
|
||||
oaCascadeChanged = true;
|
||||
@@ -1175,7 +1177,10 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 OA 上级主键解析本系统父级
|
||||
* 根据 OA 上级主键解析本系统父级。
|
||||
* <p>
|
||||
* OA 公司与部门主键同属数字空间(如公司 id=19「运输事业部」与部门 id=19「投资发展部」),
|
||||
* 解析部门上级时必须按「同属公司」消歧,禁止跨公司误挂。
|
||||
*/
|
||||
private OaParentLink resolveOaParentLink(Dept dept, Map<String, Dept> companyByOaId, Map<String, Dept> departmentByOaId) {
|
||||
String parentOaId = dept.getOaSupSubComId();
|
||||
@@ -1202,21 +1207,7 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
log.warn("部门缺少上级OA主键,跳过层级重建 oaId={} deptId={}", dept.getOaId(), dept.getId());
|
||||
return null;
|
||||
}
|
||||
boolean parentIsDepartment = StringUtil.isNotBlank(dept.getParentCode())
|
||||
&& dept.getParentCode().startsWith(OAConvertConstant.DEPARTMENT_OA_PREFIX);
|
||||
boolean parentIsCompany = StringUtil.isNotBlank(dept.getParentCode())
|
||||
&& dept.getParentCode().startsWith(OAConvertConstant.COMPANY_OA_PREFIX);
|
||||
Dept parent;
|
||||
if (parentIsDepartment) {
|
||||
parent = departmentByOaId.get(parentOaId);
|
||||
} else if (parentIsCompany) {
|
||||
parent = companyByOaId.get(parentOaId);
|
||||
} else {
|
||||
parent = departmentByOaId.get(parentOaId);
|
||||
if (parent == null) {
|
||||
parent = companyByOaId.get(parentOaId);
|
||||
}
|
||||
}
|
||||
Dept parent = this.resolveOaDepartmentParent(dept, parentOaId, companyByOaId, departmentByOaId);
|
||||
if (parent == null || parent.getId() == null || Objects.equals(parent.getId(), dept.getId())) {
|
||||
log.warn("未找到上级组织,跳过层级重建 oaId={} oaSupSubComId={} deptId={}",
|
||||
dept.getOaId(), parentOaId, dept.getId());
|
||||
@@ -1227,6 +1218,62 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
return new OaParentLink(parent.getId(), this.resolveParentCode(parent, prefix, parentOaId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析部门的 OA 上级:优先同公司下的上级部门,其次所属/同号公司;避免公司与部门 oaId 撞号误挂。
|
||||
*/
|
||||
private Dept resolveOaDepartmentParent(Dept dept, String parentOaId,
|
||||
Map<String, Dept> companyByOaId, Map<String, Dept> departmentByOaId) {
|
||||
Dept belongCompany = this.findBelongCompany(dept, companyByOaId);
|
||||
// 根部门:oaSupSubComId 存的是所属公司 OA 主键
|
||||
if (belongCompany != null && StringUtil.equals(parentOaId, belongCompany.getOaId())) {
|
||||
return belongCompany;
|
||||
}
|
||||
Dept parentDept = departmentByOaId.get(parentOaId);
|
||||
if (parentDept != null && this.isSameOaCompanyTree(dept, parentDept, companyByOaId)) {
|
||||
return parentDept;
|
||||
}
|
||||
return companyByOaId.get(parentOaId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按所属公司编码或 OA 主键定位本地公司
|
||||
*/
|
||||
private Dept findBelongCompany(Dept dept, Map<String, Dept> companyByOaId) {
|
||||
if (dept == null || companyByOaId == null || companyByOaId.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (StringUtil.isNotBlank(dept.getBelongCompanyCode())) {
|
||||
for (Dept company : companyByOaId.values()) {
|
||||
if (company != null && StringUtil.equals(company.getDeptCode(), dept.getBelongCompanyCode())) {
|
||||
return company;
|
||||
}
|
||||
}
|
||||
Dept byOaId = companyByOaId.get(dept.getBelongCompanyCode());
|
||||
if (byOaId != null) {
|
||||
return byOaId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断两个组织是否同属一家 OA 公司(用于部门 oaId 与公司 oaId 撞号消歧)
|
||||
*/
|
||||
private boolean isSameOaCompanyTree(Dept left, Dept right, Map<String, Dept> companyByOaId) {
|
||||
if (left == null || right == null) {
|
||||
return false;
|
||||
}
|
||||
if (StringUtil.isNotBlank(left.getBelongCompanyCode()) && StringUtil.isNotBlank(right.getBelongCompanyCode())) {
|
||||
return StringUtil.equals(left.getBelongCompanyCode(), right.getBelongCompanyCode());
|
||||
}
|
||||
Dept leftCompany = this.findBelongCompany(left, companyByOaId);
|
||||
Dept rightCompany = this.isOaCompany(right) ? right : this.findBelongCompany(right, companyByOaId);
|
||||
if (leftCompany == null || rightCompany == null || leftCompany.getId() == null || rightCompany.getId() == null) {
|
||||
return false;
|
||||
}
|
||||
return Objects.equals(leftCompany.getId(), rightCompany.getId());
|
||||
}
|
||||
|
||||
private String resolveParentCode(Dept parent, String prefix, String parentOaId) {
|
||||
if (StringUtil.isNotBlank(parent.getDeptCode())) {
|
||||
return parent.getDeptCode();
|
||||
|
||||
+113
-55
@@ -153,10 +153,10 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@Override
|
||||
public OaOrgSyncPageVO syncDepartmentPage(int current, int size, boolean incremental, String modifiedSince) {
|
||||
public OaOrgSyncPageVO syncDepartmentPage(int current, int size, boolean incremental, String modifiedSince, String companyOaId) {
|
||||
try {
|
||||
ComposeLogUtil.addLog(log);
|
||||
return this.syncDepartmentFromOaPage(current, size, incremental, modifiedSince);
|
||||
return this.syncDepartmentFromOaPage(current, size, incremental, modifiedSince, companyOaId);
|
||||
} finally {
|
||||
ComposeLogUtil.removeLastLog();
|
||||
}
|
||||
@@ -224,29 +224,28 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步部门
|
||||
* 同步部门:先整理已同步公司,再按公司 oaId 逐个轮询查询部门
|
||||
* @param startTime 查询开始时间
|
||||
*/
|
||||
private void syncDept(Date startTime) {
|
||||
String subCompanyIds = getOaSubCompanyIds(false);
|
||||
if (StringUtils.isEmpty(subCompanyIds)) {
|
||||
List<String> companyOaIds = listAllowedCompanyOaIds(false);
|
||||
if (CollectionUtil.isEmpty(companyOaIds)) {
|
||||
ComposeLogUtil.getLastLog().warn("未查询到子公司查询参数");
|
||||
return;
|
||||
}
|
||||
// 未处理的数据
|
||||
List<Dept> notHandleList = new ArrayList<>();
|
||||
// 1. 设置查询参数
|
||||
OADepartmentSearch departmentSearch = buildDepartmentSearch(startTime, subCompanyIds);
|
||||
// 2. 分页查询并处理数据
|
||||
OAUtils.pageSyncHandler(departmentSearch, param -> oaClient.queryDepartmentPage(new OASearch<>(param)), response -> {
|
||||
ComposeLogUtil.getLastLog().error("调用OA接口查询部门信息失败 {}", JSON.toJSONString(response));
|
||||
return new ServiceException("调用OA接口查询部门信息失败");
|
||||
}, 10000, ComposeLogUtil.getLastLog()::info).accept(list -> {
|
||||
// 处理数据
|
||||
OrgSyncCount syncCount = handleDept(list, false);
|
||||
notHandleList.addAll(syncCount.getNotHandledList());
|
||||
});
|
||||
// 3. 未处理的数据
|
||||
for (String companyOaId : companyOaIds) {
|
||||
ComposeLogUtil.getLastLog().info("按公司轮询同步部门 companyOaId={}", companyOaId);
|
||||
OADepartmentSearch departmentSearch = buildDepartmentSearch(startTime, companyOaId);
|
||||
OAUtils.pageSyncHandler(departmentSearch, param -> oaClient.queryDepartmentPage(new OASearch<>(param)), response -> {
|
||||
ComposeLogUtil.getLastLog().error("调用OA接口查询部门信息失败 companyOaId={} {}", companyOaId, JSON.toJSONString(response));
|
||||
return new ServiceException("调用OA接口查询部门信息失败");
|
||||
}, 10000, ComposeLogUtil.getLastLog()::info).accept(list -> {
|
||||
OrgSyncCount syncCount = handleDept(list, false);
|
||||
notHandleList.addAll(syncCount.getNotHandledList());
|
||||
});
|
||||
}
|
||||
if (CollectionUtil.isNotEmpty(notHandleList)) {
|
||||
ComposeLogUtil.getLastLog().warn("同步部门,还有未处理的数据:{}", JSON.toJSONString(notHandleList));
|
||||
}
|
||||
@@ -264,7 +263,7 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 OA 人员列表同步组织与人员
|
||||
* 从 OA 人员列表同步人员并绑定已有组织(不维护组织树,须先同步组织架构)
|
||||
*
|
||||
* @param startTime 增量查询开始时间,为空则全量
|
||||
* @return 处理的人员数量
|
||||
@@ -285,7 +284,7 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 按页从 OA 人员列表同步组织与人员
|
||||
* 按页从 OA 人员列表同步人员并绑定已有组织(不维护组织树,须先同步组织架构)
|
||||
*
|
||||
* @param current 当前页
|
||||
* @param size 每页条数
|
||||
@@ -398,34 +397,55 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 按页从 OA 部门接口同步部门
|
||||
* 按页从 OA 部门接口同步部门:公司数据整理完成后,按公司 oaId 逐个轮询查询
|
||||
*
|
||||
* @param current 当前页
|
||||
* @param current 当前页(相对当前公司)
|
||||
* @param size 每页条数
|
||||
* @param incremental 是否增量同步
|
||||
* @param modifiedSince 增量起始时间(后续分页回传)
|
||||
* @param companyOaId 当前公司 OA 主键;空则从第一家开始
|
||||
* @return 本页同步结果
|
||||
*/
|
||||
private OaOrgSyncPageVO syncDepartmentFromOaPage(int current, int size, boolean incremental, String modifiedSince) {
|
||||
private OaOrgSyncPageVO syncDepartmentFromOaPage(int current, int size, boolean incremental, String modifiedSince,
|
||||
String companyOaId) {
|
||||
int pageNo = current < 1 ? 1 : current;
|
||||
int pageSize = size < 1 ? 20 : Math.min(size, 200);
|
||||
String subCompanyIds = getOaSubCompanyIds(true);
|
||||
if (StringUtils.isEmpty(subCompanyIds)) {
|
||||
// 先整理已同步企业列表,再按企业轮询
|
||||
List<String> companyOaIds = listAllowedCompanyOaIds(true);
|
||||
if (CollectionUtil.isEmpty(companyOaIds)) {
|
||||
ComposeLogUtil.getLastLog().warn("未查询到已同步的OA公司,跳过部门同步");
|
||||
OaOrgSyncPageVO emptyPageVO = buildOrgSyncPageVO("department", pageNo, pageSize, 0L, 0, OrgSyncCount.empty());
|
||||
emptyPageVO.setFinished(true);
|
||||
emptyPageVO.setCompanyFinished(true);
|
||||
emptyPageVO.setCompanyIndex(0);
|
||||
emptyPageVO.setCompanyTotal(0);
|
||||
if (!incremental) {
|
||||
emptyPageVO.setDumpFilePath(oaSyncRawDumpHelper.finishOrgFullDump());
|
||||
}
|
||||
return emptyPageVO;
|
||||
}
|
||||
int companyIndex = 0;
|
||||
String currentCompanyOaId = companyOaIds.get(0);
|
||||
if (StringUtils.isNotBlank(companyOaId)) {
|
||||
int foundIndex = companyOaIds.indexOf(companyOaId.trim());
|
||||
if (foundIndex >= 0) {
|
||||
companyIndex = foundIndex;
|
||||
currentCompanyOaId = companyOaIds.get(foundIndex);
|
||||
} else {
|
||||
ComposeLogUtil.getLastLog().warn("回传公司oaId不在待同步列表中,回退第一家 companyOaId={}", companyOaId);
|
||||
}
|
||||
}
|
||||
IncrementalStart incrementalStart = resolveIncrementalStart(incremental, modifiedSince, DeptCategory.DEPT);
|
||||
OADepartmentSearch departmentSearch = buildDepartmentSearch(incrementalStart.getStartTime(), subCompanyIds);
|
||||
// 查询部门时只传当前公司的 oa_id
|
||||
OADepartmentSearch departmentSearch = buildDepartmentSearch(incrementalStart.getStartTime(), currentCompanyOaId);
|
||||
departmentSearch.setCurPage(pageNo);
|
||||
departmentSearch.setPageSize(pageSize);
|
||||
ComposeLogUtil.getLastLog().info("按公司查询OA部门 companyOaId={} companyIndex={}/{} page={}/{}",
|
||||
currentCompanyOaId, companyIndex + 1, companyOaIds.size(), pageNo, pageSize);
|
||||
OAResponse<OADepartmentResponse> oaResponse = oaClient.queryDepartmentPage(new OASearch<>(departmentSearch));
|
||||
if (oaResponse == null || !OAConstant.OA_SUCCESS_CODE.equals(oaResponse.getCode()) || oaResponse.getData() == null) {
|
||||
ComposeLogUtil.getLastLog().error("调用OA接口查询部门信息失败 {}", JSON.toJSONString(oaResponse));
|
||||
ComposeLogUtil.getLastLog().error("调用OA接口查询部门信息失败 companyOaId={} {}",
|
||||
currentCompanyOaId, JSON.toJSONString(oaResponse));
|
||||
throw new ServiceException("调用OA接口查询部门信息失败");
|
||||
}
|
||||
OAResponseData<OADepartmentResponse> responseData = oaResponse.getData();
|
||||
@@ -437,25 +457,45 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
}
|
||||
OrgSyncCount syncCount = handleDept(oaDepartments, true);
|
||||
if (CollectionUtil.isNotEmpty(syncCount.getNotHandledList())) {
|
||||
ComposeLogUtil.getLastLog().warn("同步部门,本页未处理数据:{}", JSON.toJSONString(syncCount.getNotHandledList()));
|
||||
ComposeLogUtil.getLastLog().warn("同步部门,本页未处理数据 companyOaId={}:{}",
|
||||
currentCompanyOaId, JSON.toJSONString(syncCount.getNotHandledList()));
|
||||
}
|
||||
OaOrgSyncPageVO pageVO = buildOrgSyncPageVO("department", pageNo, pageSize, totalSize, oaDepartments.size(), syncCount);
|
||||
pageVO.setModifiedSince(incrementalStart.getModifiedSince());
|
||||
if (Boolean.TRUE.equals(pageVO.getFinished())) {
|
||||
// 部门分批同步完成后,清理白名单外组织、重建层级、清理无法挂载数据并刷新祖级列表
|
||||
this.removeOutsideWhitelistOrgs();
|
||||
deptService.rebuildOaHierarchy();
|
||||
deptService.removeOaOrphansWithoutParent();
|
||||
deptService.updateAncestors(null);
|
||||
if (!incremental) {
|
||||
pageVO.setDumpFilePath(oaSyncRawDumpHelper.finishOrgFullDump());
|
||||
pageVO.setCompanyOaId(currentCompanyOaId);
|
||||
pageVO.setCompanyIndex(companyIndex + 1);
|
||||
pageVO.setCompanyTotal(companyOaIds.size());
|
||||
boolean companyPageFinished = Boolean.TRUE.equals(pageVO.getFinished());
|
||||
if (companyPageFinished) {
|
||||
if (companyIndex + 1 < companyOaIds.size()) {
|
||||
// 当前公司部门已拉完:先按 OA 主键校正父子挂载,再进入下一公司
|
||||
pageVO.setCompanyFinished(true);
|
||||
pageVO.setNextCompanyOaId(companyOaIds.get(companyIndex + 1));
|
||||
pageVO.setFinished(false);
|
||||
deptService.rebuildOaHierarchy();
|
||||
} else {
|
||||
// 全部公司轮询完成:清理白名单外组织、重建层级、清理无法挂载数据并刷新祖级列表
|
||||
pageVO.setCompanyFinished(true);
|
||||
pageVO.setNextCompanyOaId(null);
|
||||
pageVO.setFinished(true);
|
||||
this.removeOutsideWhitelistOrgs();
|
||||
deptService.rebuildOaHierarchy();
|
||||
deptService.removeOaOrphansWithoutParent();
|
||||
deptService.updateAncestors(null);
|
||||
if (!incremental) {
|
||||
pageVO.setDumpFilePath(oaSyncRawDumpHelper.finishOrgFullDump());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
pageVO.setCompanyFinished(false);
|
||||
pageVO.setNextCompanyOaId(currentCompanyOaId);
|
||||
pageVO.setFinished(false);
|
||||
}
|
||||
CacheUtil.clear(SYS_CACHE);
|
||||
CacheUtil.clear(SYS_CACHE, Boolean.FALSE);
|
||||
ComposeLogUtil.getLastLog().info("OA部门分页同步完成 mode={} {}/{},成功{},跳过{}",
|
||||
incremental ? "incremental" : "full", pageNo, totalSize,
|
||||
syncCount.getSyncedCount(), syncCount.getSkippedCount());
|
||||
ComposeLogUtil.getLastLog().info("OA部门分页同步完成 mode={} companyOaId={} company={}/{} page={}/{},成功{},跳过{}",
|
||||
incremental ? "incremental" : "full", currentCompanyOaId, companyIndex + 1, companyOaIds.size(),
|
||||
pageNo, totalSize, syncCount.getSyncedCount(), syncCount.getSkippedCount());
|
||||
return pageVO;
|
||||
}
|
||||
|
||||
@@ -604,15 +644,15 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
/**
|
||||
* 组装 OA 部门分页查询参数
|
||||
*
|
||||
* @param startTime 增量查询开始时间
|
||||
* @param subCompanyIds 子公司 id 列表
|
||||
* @param startTime 增量查询开始时间
|
||||
* @param companyOaId 当前公司 OA 主键(单次只传一家公司)
|
||||
* @return 查询参数
|
||||
*/
|
||||
private OADepartmentSearch buildDepartmentSearch(Date startTime, String subCompanyIds) {
|
||||
private OADepartmentSearch buildDepartmentSearch(Date startTime, String companyOaId) {
|
||||
OADepartmentSearch departmentSearch = new OADepartmentSearch();
|
||||
departmentSearch.setCurPage(1);
|
||||
departmentSearch.setPageSize(20);
|
||||
departmentSearch.setSubcompanyid1(subCompanyIds);
|
||||
departmentSearch.setSubcompanyid1(companyOaId);
|
||||
if (startTime != null) {
|
||||
departmentSearch.setModified(DateUtil.format(startTime, DateUtil.PATTERN_DATETIME));
|
||||
}
|
||||
@@ -766,9 +806,11 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
}
|
||||
String parentOaId = dept.getOaSupSubComId();
|
||||
Dept belongCompany = companyByOaId.values().stream()
|
||||
.filter(company -> StringUtils.equals(company.getDeptCode(), dept.getBelongCompanyCode()))
|
||||
.filter(company -> StringUtils.equals(company.getDeptCode(), dept.getBelongCompanyCode())
|
||||
|| StringUtils.equals(company.getOaId(), dept.getBelongCompanyCode()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
// 根部门:oaSupSubComId 即所属公司 OA 主键,必须挂公司(OA 公司/部门主键会撞号)
|
||||
if (belongCompany != null && (OAConstant.ROOT_COMPANY_ID.equals(parentOaId)
|
||||
|| StringUtils.equals(parentOaId, belongCompany.getOaId()))) {
|
||||
dept.setParentCode(belongCompany.getDeptCode());
|
||||
@@ -779,9 +821,19 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
if (parentDept == null) {
|
||||
parentDept = existingByOaId.get(parentOaId);
|
||||
}
|
||||
// 跨公司同号部门不可作为上级(例如总部部门 id=19 与公司 id=19)
|
||||
if (parentDept != null && StringUtils.isNotBlank(dept.getBelongCompanyCode())
|
||||
&& StringUtils.isNotBlank(parentDept.getBelongCompanyCode())
|
||||
&& !StringUtils.equals(dept.getBelongCompanyCode(), parentDept.getBelongCompanyCode())) {
|
||||
parentDept = null;
|
||||
}
|
||||
Dept parentCompany = StringUtils.isNotBlank(parentOaId) ? companyByOaId.get(parentOaId) : null;
|
||||
if (parentDept != null && StringUtils.isNotBlank(parentDept.getDeptCode())) {
|
||||
dept.setParentCode(parentDept.getDeptCode());
|
||||
dept.setParentId(parentDept.getId());
|
||||
} else if (parentCompany != null && StringUtils.isNotBlank(parentCompany.getDeptCode())) {
|
||||
dept.setParentCode(parentCompany.getDeptCode());
|
||||
dept.setParentId(parentCompany.getId());
|
||||
} else if (belongCompany != null) {
|
||||
// 上级部门尚未就绪时先挂所属公司,分批结束后按 OA 主键重建
|
||||
dept.setParentCode(belongCompany.getDeptCode());
|
||||
@@ -995,7 +1047,7 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
|
||||
/**
|
||||
* oaModified 一致则跳过更新;任一侧为空无法确认一致,执行更新以补齐字段。
|
||||
* 组织编码已按新规则重算且与库中不一致时,即使 modified 相同也要更新。
|
||||
* 组织编码已按新规则重算、或父子挂载与库中不一致时,即使 modified 相同也要更新。
|
||||
*/
|
||||
private boolean shouldSkipOaUpdate(Dept existing, Dept incoming) {
|
||||
if (existing == null) {
|
||||
@@ -1005,6 +1057,15 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
&& !StringUtils.equals(existing.getDeptCode(), incoming.getDeptCode())) {
|
||||
return false;
|
||||
}
|
||||
if (incoming != null && incoming.getParentId() != null
|
||||
&& !Objects.equals(existing.getParentId(), incoming.getParentId())) {
|
||||
return false;
|
||||
}
|
||||
if (incoming != null && StringUtils.isNotBlank(incoming.getParentCode())
|
||||
&& !StringUtils.equals(StringUtils.trimToEmpty(existing.getParentCode()),
|
||||
StringUtils.trimToEmpty(incoming.getParentCode()))) {
|
||||
return false;
|
||||
}
|
||||
String localModified = StringUtils.trimToEmpty(existing.getOaModified());
|
||||
String remoteModified = StringUtils.trimToEmpty(incoming == null ? null : incoming.getOaModified());
|
||||
if (localModified.isEmpty() || remoteModified.isEmpty()) {
|
||||
@@ -1145,12 +1206,12 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 OA 部门查询使用的子公司 id
|
||||
* 整理部门同步用的公司 OA 主键列表(公司阶段完成后调用)
|
||||
*
|
||||
* @param syncAll true=取已同步的全部 OA 公司;false=仅取字典配置的公司
|
||||
* @return 逗号分隔的 OA 公司 id
|
||||
* @return 白名单内公司 oaId 列表(去重、保序)
|
||||
*/
|
||||
private String getOaSubCompanyIds(boolean syncAll) {
|
||||
private List<String> listAllowedCompanyOaIds(boolean syncAll) {
|
||||
List<Dept> companyList;
|
||||
if (syncAll) {
|
||||
companyList = deptService.list(Wrappers.<Dept>lambdaQuery()
|
||||
@@ -1161,7 +1222,7 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
} else {
|
||||
Set<String> companyNames = this.getCompanyNames();
|
||||
if (companyNames.isEmpty()) {
|
||||
return null;
|
||||
return Collections.emptyList();
|
||||
}
|
||||
companyList = deptService.list(Wrappers.<Dept>lambdaQuery()
|
||||
.in(Dept::getDeptName, companyNames)
|
||||
@@ -1170,20 +1231,17 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
.ne(Dept::getOaId, "")
|
||||
);
|
||||
}
|
||||
Map<String, Dept> companyByOaId = new HashMap<>();
|
||||
Map<String, Dept> companyByOaId = new LinkedHashMap<>();
|
||||
for (Dept company : companyList) {
|
||||
String oaId = this.resolveOaCompanyId(company);
|
||||
if (StringUtils.isNotBlank(oaId)) {
|
||||
companyByOaId.put(oaId, company);
|
||||
companyByOaId.putIfAbsent(oaId, company);
|
||||
}
|
||||
}
|
||||
Set<String> allowedCompanyOaIds = resolveAllowedCompanyOaIds(companyByOaId);
|
||||
return companyList.stream()
|
||||
.map(this::resolveOaCompanyId)
|
||||
.filter(StringUtils::isNotBlank)
|
||||
return companyByOaId.keySet().stream()
|
||||
.filter(allowedCompanyOaIds::contains)
|
||||
.distinct()
|
||||
.collect(Collectors.joining(","));
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+324
-194
@@ -30,7 +30,6 @@ import com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springblade.common.constant.DataStatusEnum;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.constant.BladeConstant;
|
||||
import org.springblade.core.tool.utils.DigestUtil;
|
||||
@@ -53,7 +52,6 @@ import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -66,10 +64,9 @@ import java.util.stream.Collectors;
|
||||
import static org.springblade.common.constant.CommonConstant.DEFAULT_PARAM_PASSWORD;
|
||||
import static org.springblade.common.constant.CommonConstant.DEFAULT_PARAM_ROLE;
|
||||
import static org.springblade.common.constant.CommonConstant.DEFAULT_ROLE;
|
||||
import static org.springblade.common.constant.CommonConstant.YES;
|
||||
|
||||
/**
|
||||
* 从 OA 人员列表提取组织并同步人员
|
||||
* OA 人员同步:绑定已有组织机构,不维护组织树
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@@ -86,104 +83,312 @@ public class OaUserListSyncHelper {
|
||||
private final IRoleService roleService;
|
||||
|
||||
/**
|
||||
* 从人员数据提取二级公司、三级部门,并挂到集团顶级(id=1/GWWL) 下
|
||||
* 加载已有组织索引,并将人员旁路产生的 OAC/OAD 存量归并到正式组织。
|
||||
* <p>人员同步不再新建/更新组织树,须先执行组织架构同步。</p>
|
||||
*
|
||||
* @param oaPersons OA人员
|
||||
* @return 组织索引
|
||||
* @return 组织索引(仅含已有组织)
|
||||
*/
|
||||
public OaOrgIndex syncOrgsFromPersons(List<OAPersonResponse> oaPersons) {
|
||||
OaOrgIndex orgIndex = new OaOrgIndex();
|
||||
if (CollectionUtil.isEmpty(oaPersons)) {
|
||||
return orgIndex;
|
||||
}
|
||||
Date orgSyncStart = new Date();
|
||||
Dept rootCompany = this.getOrCreateRootCompany();
|
||||
String tenantId = resolveTenantId();
|
||||
Map<String, OAPersonResponse> companyPersonMap = new LinkedHashMap<>();
|
||||
Map<String, OAPersonResponse> departmentPersonMap = new LinkedHashMap<>();
|
||||
for (OAPersonResponse oaPerson : oaPersons) {
|
||||
String companyKey = resolveCompanyKey(oaPerson);
|
||||
if (StringUtils.isNotBlank(companyKey) && StringUtils.isNotBlank(oaPerson.getSubcompanyname())) {
|
||||
companyPersonMap.putIfAbsent(companyKey, oaPerson);
|
||||
}
|
||||
String departmentKey = resolveDepartmentKey(oaPerson);
|
||||
if (StringUtils.isNotBlank(departmentKey) && StringUtils.isNotBlank(oaPerson.getDepartmentname())
|
||||
&& StringUtils.isNotBlank(oaPerson.getSubcompanyname())) {
|
||||
departmentPersonMap.putIfAbsent(departmentKey, oaPerson);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Dept> existingCompanyByCode = new HashMap<>();
|
||||
Map<String, Dept> existingCompanyByName = new HashMap<>();
|
||||
deptService.list(Wrappers.<Dept>lambdaQuery()
|
||||
.eq(Dept::getParentId, rootCompany.getId())
|
||||
.eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode())
|
||||
).forEach(dept -> {
|
||||
if (StringUtils.isNotBlank(dept.getDeptCode())) {
|
||||
existingCompanyByCode.put(dept.getDeptCode(), dept);
|
||||
}
|
||||
if (StringUtils.isNotBlank(dept.getDeptName())) {
|
||||
existingCompanyByName.put(dept.getDeptName(), dept);
|
||||
}
|
||||
});
|
||||
List<Dept> addCompanies = new ArrayList<>();
|
||||
List<Dept> updateCompanies = new ArrayList<>();
|
||||
companyPersonMap.forEach((companyKey, oaPerson) -> {
|
||||
String oaCode = buildCompanyCode(oaPerson, companyKey);
|
||||
Dept existing = existingCompanyByCode.get(oaCode);
|
||||
if (existing == null) {
|
||||
existing = existingCompanyByName.get(oaPerson.getSubcompanyname());
|
||||
}
|
||||
Dept company = this.upsertOrg(oaPerson.getSubcompanyname(), oaCode, existing, rootCompany, tenantId,
|
||||
DeptCategory.COMPANY, addCompanies, updateCompanies);
|
||||
orgIndex.companyByOaId.put(companyKey, company);
|
||||
});
|
||||
this.saveOrgs(addCompanies, updateCompanies, DeptCategory.COMPANY);
|
||||
|
||||
Map<String, Dept> existingDeptByCode = new HashMap<>();
|
||||
Map<String, Dept> existingDeptByParentAndName = new HashMap<>();
|
||||
List<Long> companyIds = orgIndex.companyByOaId.values().stream()
|
||||
.map(Dept::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (CollectionUtil.isNotEmpty(companyIds)) {
|
||||
deptService.list(Wrappers.<Dept>lambdaQuery()
|
||||
.in(Dept::getParentId, companyIds)
|
||||
.eq(Dept::getDeptCategory, DeptCategory.DEPT.getCode())
|
||||
).forEach(dept -> {
|
||||
if (StringUtils.isNotBlank(dept.getDeptCode())) {
|
||||
existingDeptByCode.put(dept.getDeptCode(), dept);
|
||||
}
|
||||
if (dept.getParentId() != null && StringUtils.isNotBlank(dept.getDeptName())) {
|
||||
existingDeptByParentAndName.put(dept.getParentId() + "#" + dept.getDeptName(), dept);
|
||||
}
|
||||
});
|
||||
}
|
||||
List<Dept> addDepartments = new ArrayList<>();
|
||||
List<Dept> updateDepartments = new ArrayList<>();
|
||||
departmentPersonMap.forEach((departmentKey, oaPerson) -> {
|
||||
Dept parentCompany = orgIndex.findCompany(oaPerson);
|
||||
if (parentCompany == null || parentCompany.getId() == null) {
|
||||
return;
|
||||
}
|
||||
String oaCode = buildDepartmentCode(oaPerson, departmentKey);
|
||||
Dept existing = existingDeptByCode.get(oaCode);
|
||||
if (existing == null) {
|
||||
existing = existingDeptByParentAndName.get(parentCompany.getId() + "#" + oaPerson.getDepartmentname());
|
||||
}
|
||||
Dept department = this.upsertOrg(oaPerson.getDepartmentname(), oaCode, existing, parentCompany, tenantId,
|
||||
DeptCategory.DEPT, addDepartments, updateDepartments);
|
||||
orgIndex.deptByOaId.put(departmentKey, department);
|
||||
orgIndex.deptByCompanyAndName.put(resolveCompanyKey(oaPerson) + "#" + oaPerson.getDepartmentname(), department);
|
||||
});
|
||||
this.saveOrgs(addDepartments, updateDepartments, DeptCategory.DEPT);
|
||||
deptService.updateAncestors(orgSyncStart);
|
||||
ComposeLogUtil.getLastLog().info("同步人员提取组织完成,二级公司{}个,三级部门{}个",
|
||||
this.repairPersonBypassOrgs();
|
||||
this.loadExistingOrgIndex(orgIndex);
|
||||
ComposeLogUtil.getLastLog().info("人员同步绑定已有组织,二级公司{}个,三级部门{}个",
|
||||
orgIndex.companyByOaId.size(), orgIndex.deptByOaId.size());
|
||||
return orgIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 OAC/OAD 旁路节点上的人员关系迁移到同 oa_id 的正式组织,并逻辑删除旁路节点
|
||||
*/
|
||||
private void repairPersonBypassOrgs() {
|
||||
List<Dept> companyList = deptService.list(Wrappers.<Dept>lambdaQuery()
|
||||
.eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode()));
|
||||
List<Dept> departmentList = deptService.list(Wrappers.<Dept>lambdaQuery()
|
||||
.eq(Dept::getDeptCategory, DeptCategory.DEPT.getCode()));
|
||||
Map<String, Dept> officialCompanyByOaId = new HashMap<>();
|
||||
Map<String, Dept> officialDeptByOaId = new HashMap<>();
|
||||
Map<Long, Dept> companyById = new HashMap<>();
|
||||
for (Dept company : companyList) {
|
||||
if (company.getId() != null) {
|
||||
companyById.put(company.getId(), company);
|
||||
}
|
||||
if (!isPersonBypassCode(company.getDeptCode(), DeptCategory.COMPANY)) {
|
||||
this.putPreferredByOaId(officialCompanyByOaId, company, DeptCategory.COMPANY);
|
||||
}
|
||||
}
|
||||
for (Dept department : departmentList) {
|
||||
if (!isPersonBypassCode(department.getDeptCode(), DeptCategory.DEPT)) {
|
||||
this.putPreferredByOaId(officialDeptByOaId, department, DeptCategory.DEPT);
|
||||
}
|
||||
}
|
||||
|
||||
Set<Long> softDeleteIds = new HashSet<>();
|
||||
Set<Long> affectedUserIds = new HashSet<>();
|
||||
int migratedDeptBindingCount = 0;
|
||||
int migratedCompanyBindingCount = 0;
|
||||
|
||||
for (Dept bypass : departmentList) {
|
||||
if (!isPersonBypassCode(bypass.getDeptCode(), DeptCategory.DEPT) || bypass.getId() == null) {
|
||||
continue;
|
||||
}
|
||||
String oaId = StringUtils.trimToNull(bypass.getOaId());
|
||||
if (oaId == null) {
|
||||
oaId = extractOaIdFromBypassCode(bypass.getDeptCode(), DeptCategory.DEPT);
|
||||
}
|
||||
if (oaId == null) {
|
||||
continue;
|
||||
}
|
||||
Dept official = officialDeptByOaId.get(oaId);
|
||||
if (official == null || official.getId() == null || Objects.equals(official.getId(), bypass.getId())
|
||||
|| isPersonBypassCode(official.getDeptCode(), DeptCategory.DEPT)) {
|
||||
continue;
|
||||
}
|
||||
Dept officialCompany = resolveBelongCompany(official, companyById);
|
||||
migratedDeptBindingCount += this.migrateUserDeptBindings(bypass, official, officialCompany, affectedUserIds);
|
||||
softDeleteIds.add(bypass.getId());
|
||||
}
|
||||
|
||||
for (Dept bypass : companyList) {
|
||||
if (!isPersonBypassCode(bypass.getDeptCode(), DeptCategory.COMPANY) || bypass.getId() == null) {
|
||||
continue;
|
||||
}
|
||||
String oaId = StringUtils.trimToNull(bypass.getOaId());
|
||||
if (oaId == null) {
|
||||
oaId = extractOaIdFromBypassCode(bypass.getDeptCode(), DeptCategory.COMPANY);
|
||||
}
|
||||
if (oaId == null) {
|
||||
continue;
|
||||
}
|
||||
Dept official = officialCompanyByOaId.get(oaId);
|
||||
if (official == null || official.getId() == null || Objects.equals(official.getId(), bypass.getId())
|
||||
|| isPersonBypassCode(official.getDeptCode(), DeptCategory.COMPANY)) {
|
||||
continue;
|
||||
}
|
||||
migratedCompanyBindingCount += this.migrateUserCompanyBindings(bypass, official, affectedUserIds);
|
||||
long childCount = deptService.count(Wrappers.<Dept>lambdaQuery().eq(Dept::getParentId, bypass.getId()));
|
||||
if (childCount == 0) {
|
||||
softDeleteIds.add(bypass.getId());
|
||||
} else {
|
||||
ComposeLogUtil.getLastLog().warn("旁路公司仍有子节点,暂不删除 companyId={} deptCode={} childCount={}",
|
||||
bypass.getId(), bypass.getDeptCode(), childCount);
|
||||
}
|
||||
}
|
||||
|
||||
if (CollectionUtil.isNotEmpty(softDeleteIds)) {
|
||||
deptService.removeByIds(softDeleteIds);
|
||||
ComposeLogUtil.getLastLog().info("人员同步归并旁路组织完成,迁移部门关系{}条,迁移公司关系{}条,逻辑删除旁路节点{}个",
|
||||
migratedDeptBindingCount, migratedCompanyBindingCount, softDeleteIds.size());
|
||||
} else if (migratedDeptBindingCount > 0 || migratedCompanyBindingCount > 0) {
|
||||
ComposeLogUtil.getLastLog().info("人员同步归并旁路组织完成,迁移部门关系{}条,迁移公司关系{}条",
|
||||
migratedDeptBindingCount, migratedCompanyBindingCount);
|
||||
}
|
||||
this.refreshUserDeptFields(affectedUserIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅加载已有组织,按 oa_id / 名称建立索引供人员绑定
|
||||
*/
|
||||
private void loadExistingOrgIndex(OaOrgIndex orgIndex) {
|
||||
Map<String, Dept> companyByOaId = new HashMap<>();
|
||||
Map<String, Dept> companyByName = new HashMap<>();
|
||||
Map<Long, Dept> companyById = new HashMap<>();
|
||||
deptService.list(Wrappers.<Dept>lambdaQuery()
|
||||
.eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode())
|
||||
).forEach(dept -> {
|
||||
if (dept.getId() != null) {
|
||||
companyById.put(dept.getId(), dept);
|
||||
}
|
||||
this.putPreferredByOaKey(companyByOaId, resolveOrgOaKey(dept, DeptCategory.COMPANY), dept, DeptCategory.COMPANY);
|
||||
if (StringUtils.isNotBlank(dept.getDeptName())) {
|
||||
Dept previous = companyByName.get(dept.getDeptName());
|
||||
if (previous == null
|
||||
|| (isPersonBypassCode(previous.getDeptCode(), DeptCategory.COMPANY)
|
||||
&& !isPersonBypassCode(dept.getDeptCode(), DeptCategory.COMPANY))) {
|
||||
companyByName.put(dept.getDeptName(), dept);
|
||||
}
|
||||
}
|
||||
});
|
||||
companyByOaId.forEach((oaId, dept) -> orgIndex.companyByOaId.put(oaId, dept));
|
||||
companyByName.forEach((name, dept) -> orgIndex.companyByOaId.putIfAbsent("NAME:" + name, dept));
|
||||
|
||||
Map<String, Dept> deptByOaId = new HashMap<>();
|
||||
deptService.list(Wrappers.<Dept>lambdaQuery()
|
||||
.eq(Dept::getDeptCategory, DeptCategory.DEPT.getCode())
|
||||
).forEach(dept -> {
|
||||
this.putPreferredByOaKey(deptByOaId, resolveOrgOaKey(dept, DeptCategory.DEPT), dept, DeptCategory.DEPT);
|
||||
Dept parentCompany = resolveBelongCompany(dept, companyById);
|
||||
if (parentCompany == null || StringUtils.isBlank(dept.getDeptName())) {
|
||||
return;
|
||||
}
|
||||
String companyKey = resolveOrgOaKey(parentCompany, DeptCategory.COMPANY);
|
||||
if (companyKey == null && StringUtils.isNotBlank(parentCompany.getDeptName())) {
|
||||
companyKey = "NAME:" + parentCompany.getDeptName();
|
||||
}
|
||||
if (companyKey == null) {
|
||||
return;
|
||||
}
|
||||
String nameKey = companyKey + "#" + dept.getDeptName();
|
||||
Dept previous = orgIndex.deptByCompanyAndName.get(nameKey);
|
||||
if (previous == null
|
||||
|| (isPersonBypassCode(previous.getDeptCode(), DeptCategory.DEPT)
|
||||
&& !isPersonBypassCode(dept.getDeptCode(), DeptCategory.DEPT))) {
|
||||
orgIndex.deptByCompanyAndName.put(nameKey, dept);
|
||||
}
|
||||
});
|
||||
orgIndex.deptByOaId.putAll(deptByOaId);
|
||||
}
|
||||
|
||||
/** 优先取 oa_id;旁路空 oa_id 时从 OAC/OAD 编码后缀解析 */
|
||||
private String resolveOrgOaKey(Dept dept, DeptCategory deptCategory) {
|
||||
if (dept == null) {
|
||||
return null;
|
||||
}
|
||||
String oaId = StringUtils.trimToNull(dept.getOaId());
|
||||
if (oaId != null) {
|
||||
return oaId;
|
||||
}
|
||||
return extractOaIdFromBypassCode(dept.getDeptCode(), deptCategory);
|
||||
}
|
||||
|
||||
private int migrateUserDeptBindings(Dept bypass, Dept official, Dept officialCompany, Set<Long> affectedUserIds) {
|
||||
List<UserDept> bindingList = userDeptService.list(Wrappers.<UserDept>lambdaQuery()
|
||||
.and(wrapper -> wrapper.eq(UserDept::getDeptId, bypass.getId())
|
||||
.or().eq(UserDept::getDeptCode, bypass.getDeptCode())));
|
||||
if (CollectionUtil.isEmpty(bindingList)) {
|
||||
return 0;
|
||||
}
|
||||
Set<Long> userIds = bindingList.stream()
|
||||
.map(UserDept::getUserId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
Map<String, UserDept> existingByKey = userDeptService.list(Wrappers.<UserDept>lambdaQuery()
|
||||
.in(UserDept::getUserId, userIds)
|
||||
).stream()
|
||||
.collect(Collectors.toMap(this::getUserDeptKey, userDept -> userDept, (first, second) -> first));
|
||||
List<UserDept> updateList = new ArrayList<>();
|
||||
List<Long> removeIds = new ArrayList<>();
|
||||
for (UserDept binding : bindingList) {
|
||||
if (binding.getUserId() == null) {
|
||||
continue;
|
||||
}
|
||||
affectedUserIds.add(binding.getUserId());
|
||||
String companyCode = officialCompany != null ? officialCompany.getDeptCode() : binding.getCompanyCode();
|
||||
String companyName = officialCompany != null ? officialCompany.getDeptName() : binding.getCompanyName();
|
||||
String newKey = binding.getUserId() + StringUtils.defaultString(companyCode)
|
||||
+ StringUtils.defaultString(official.getDeptCode());
|
||||
UserDept alreadyOfficial = existingByKey.get(newKey);
|
||||
if (alreadyOfficial != null && !Objects.equals(alreadyOfficial.getId(), binding.getId())) {
|
||||
removeIds.add(binding.getId());
|
||||
continue;
|
||||
}
|
||||
binding.setDeptId(official.getId());
|
||||
binding.setDeptCode(official.getDeptCode());
|
||||
binding.setDeptName(official.getDeptName());
|
||||
if (officialCompany != null) {
|
||||
binding.setCompanyCode(companyCode);
|
||||
binding.setCompanyName(companyName);
|
||||
}
|
||||
updateList.add(binding);
|
||||
existingByKey.put(newKey, binding);
|
||||
}
|
||||
if (CollectionUtil.isNotEmpty(updateList)) {
|
||||
userDeptService.updateBatchById(updateList);
|
||||
}
|
||||
if (CollectionUtil.isNotEmpty(removeIds)) {
|
||||
userDeptService.removeByIds(removeIds);
|
||||
}
|
||||
return updateList.size() + removeIds.size();
|
||||
}
|
||||
|
||||
private int migrateUserCompanyBindings(Dept bypass, Dept official, Set<Long> affectedUserIds) {
|
||||
if (StringUtils.isBlank(bypass.getDeptCode())) {
|
||||
return 0;
|
||||
}
|
||||
List<UserDept> bindingList = userDeptService.list(Wrappers.<UserDept>lambdaQuery()
|
||||
.eq(UserDept::getCompanyCode, bypass.getDeptCode()));
|
||||
if (CollectionUtil.isEmpty(bindingList)) {
|
||||
return 0;
|
||||
}
|
||||
List<UserDept> updateList = new ArrayList<>();
|
||||
for (UserDept binding : bindingList) {
|
||||
if (binding.getUserId() != null) {
|
||||
affectedUserIds.add(binding.getUserId());
|
||||
}
|
||||
binding.setCompanyCode(official.getDeptCode());
|
||||
binding.setCompanyName(official.getDeptName());
|
||||
updateList.add(binding);
|
||||
}
|
||||
userDeptService.updateBatchById(updateList);
|
||||
return updateList.size();
|
||||
}
|
||||
|
||||
private void refreshUserDeptFields(Set<Long> userIds) {
|
||||
if (CollectionUtil.isEmpty(userIds)) {
|
||||
return;
|
||||
}
|
||||
List<UserDeptIdsVO> userDeptIdsList = userDeptService.queryUserDeptIds(userIds);
|
||||
List<User> updateUserParams = userDeptIdsList.stream()
|
||||
.filter(userDeptIds -> StringUtils.isNotBlank(userDeptIds.getDeptIds()))
|
||||
.filter(userDeptIds -> {
|
||||
int deptIdLength = userDeptIds.getDeptIds().length();
|
||||
int deptCodesLength = StringUtils.length(userDeptIds.getDeptCodes());
|
||||
return deptIdLength <= MAX_DEPT_ID_LENGTH && deptCodesLength <= MAX_DEPT_ID_LENGTH;
|
||||
})
|
||||
.map(userDeptIds -> {
|
||||
User user = new User();
|
||||
user.setId(userDeptIds.getUserId());
|
||||
user.setDeptId(userDeptIds.getDeptIds());
|
||||
user.setDeptCodes(userDeptIds.getDeptCodes());
|
||||
return user;
|
||||
}).toList();
|
||||
if (CollectionUtil.isNotEmpty(updateUserParams)) {
|
||||
userService.updateBatchById(updateUserParams);
|
||||
}
|
||||
}
|
||||
|
||||
private Dept resolveBelongCompany(Dept department, Map<Long, Dept> companyById) {
|
||||
if (department == null) {
|
||||
return null;
|
||||
}
|
||||
if (department.getParentId() != null) {
|
||||
Dept parent = companyById.get(department.getParentId());
|
||||
if (parent != null) {
|
||||
return parent;
|
||||
}
|
||||
}
|
||||
if (StringUtils.isNotBlank(department.getBelongCompanyCode())) {
|
||||
return companyById.values().stream()
|
||||
.filter(company -> department.getBelongCompanyCode().equals(company.getDeptCode()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 OAC37 / OAD37 旁路编码解析 OA 主键;哈希旁路(OACNxxx)返回 null
|
||||
*/
|
||||
private String extractOaIdFromBypassCode(String deptCode, DeptCategory deptCategory) {
|
||||
if (StringUtils.isBlank(deptCode) || !isPersonBypassCode(deptCode, deptCategory)) {
|
||||
return null;
|
||||
}
|
||||
String prefix = DeptCategory.COMPANY.equals(deptCategory)
|
||||
? OAConvertConstant.COMPANY_OA_PREFIX
|
||||
: OAConvertConstant.DEPARTMENT_OA_PREFIX;
|
||||
if (deptCode.length() <= prefix.length()) {
|
||||
return null;
|
||||
}
|
||||
String suffix = deptCode.substring(prefix.length());
|
||||
if (suffix.isEmpty() || suffix.startsWith("N")) {
|
||||
return null;
|
||||
}
|
||||
return suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步人员并绑定到三级部门
|
||||
*
|
||||
@@ -395,73 +600,47 @@ public class OaUserListSyncHelper {
|
||||
return localModified.equals(remoteModified);
|
||||
}
|
||||
|
||||
private Dept getOrCreateRootCompany() {
|
||||
return deptService.ensureOaSyncRootDept();
|
||||
private boolean isPersonBypassCode(String deptCode, DeptCategory deptCategory) {
|
||||
if (StringUtils.isBlank(deptCode)) {
|
||||
return true;
|
||||
}
|
||||
if (DeptCategory.COMPANY.equals(deptCategory)) {
|
||||
return deptCode.startsWith(OAConvertConstant.COMPANY_OA_PREFIX);
|
||||
}
|
||||
return deptCode.startsWith(OAConvertConstant.DEPARTMENT_OA_PREFIX);
|
||||
}
|
||||
|
||||
private Dept upsertOrg(String name, String oaCode, Dept existing, Dept parent, String tenantId,
|
||||
DeptCategory deptCategory, List<Dept> addList, List<Dept> updateList) {
|
||||
if (existing != null) {
|
||||
Dept updateParam = new Dept();
|
||||
updateParam.setId(existing.getId());
|
||||
updateParam.setDeptName(name);
|
||||
updateParam.setFullName(name);
|
||||
updateParam.setShortName(name);
|
||||
updateParam.setParentId(parent.getId());
|
||||
updateParam.setParentCode(parent.getDeptCode());
|
||||
updateParam.setAncestors(buildAncestors(parent));
|
||||
updateParam.setIsOa(YES);
|
||||
updateParam.setSyncTime(new Date());
|
||||
updateList.add(updateParam);
|
||||
existing.setDeptName(name);
|
||||
existing.setFullName(name);
|
||||
existing.setShortName(name);
|
||||
existing.setParentId(parent.getId());
|
||||
existing.setParentCode(parent.getDeptCode());
|
||||
existing.setAncestors(updateParam.getAncestors());
|
||||
return existing;
|
||||
/** 同一 oa_id 多条时优先正式编码,避免人员挂到 OAC/OAD 旁路重复节点。 */
|
||||
private void putPreferredByOaId(Map<String, Dept> byOaId, Dept dept, DeptCategory deptCategory) {
|
||||
if (dept == null || StringUtils.isBlank(dept.getOaId())) {
|
||||
return;
|
||||
}
|
||||
Dept dept = this.buildOrgDept(name, oaCode, parent, tenantId, deptCategory);
|
||||
dept.setId(DefaultIdentifierGenerator.getInstance().nextId(null));
|
||||
addList.add(dept);
|
||||
return dept;
|
||||
this.putPreferredByOaKey(byOaId, dept.getOaId().trim(), dept, deptCategory);
|
||||
}
|
||||
|
||||
private void saveOrgs(List<Dept> addList, List<Dept> updateList, DeptCategory deptCategory) {
|
||||
if (CollectionUtil.isNotEmpty(addList)) {
|
||||
ComposeLogUtil.getLastLog().info("批量新增{}:{}", deptCategory.getName(), addList.size());
|
||||
deptService.saveBatch(addList);
|
||||
private void putPreferredByOaKey(Map<String, Dept> byOaId, String oaKey, Dept dept, DeptCategory deptCategory) {
|
||||
if (dept == null || StringUtils.isBlank(oaKey)) {
|
||||
return;
|
||||
}
|
||||
if (CollectionUtil.isNotEmpty(updateList)) {
|
||||
ComposeLogUtil.getLastLog().info("批量修改{}:{}", deptCategory.getName(), updateList.size());
|
||||
deptService.updateBatchById(updateList);
|
||||
Dept previous = byOaId.get(oaKey);
|
||||
if (previous == null) {
|
||||
byOaId.put(oaKey, dept);
|
||||
return;
|
||||
}
|
||||
boolean previousBypass = isPersonBypassCode(previous.getDeptCode(), deptCategory);
|
||||
boolean currentBypass = isPersonBypassCode(dept.getDeptCode(), deptCategory);
|
||||
if (previousBypass && !currentBypass) {
|
||||
byOaId.put(oaKey, dept);
|
||||
}
|
||||
}
|
||||
|
||||
private Dept buildOrgDept(String name, String deptCode, Dept parent, String tenantId, DeptCategory deptCategory) {
|
||||
Dept dept = new Dept();
|
||||
dept.setTenantId(tenantId);
|
||||
dept.setParentId(parent.getId());
|
||||
dept.setParentCode(parent.getDeptCode());
|
||||
dept.setAncestors(this.buildAncestors(parent));
|
||||
dept.setDeptName(name);
|
||||
dept.setFullName(name);
|
||||
dept.setShortName(name);
|
||||
dept.setDeptCode(deptCode);
|
||||
dept.setBelongCompanyCode(DeptCategory.COMPANY.equals(deptCategory) ? deptCode : parent.getBelongCompanyCode());
|
||||
dept.setDeptCategory(deptCategory.getCode());
|
||||
dept.setSort(0);
|
||||
dept.setStatus(DataStatusEnum.ENABLE.getCode());
|
||||
dept.setIsDeleted(BladeConstant.DB_NOT_DELETED);
|
||||
dept.setIsOa(YES);
|
||||
dept.setIsPlatformCompany(0);
|
||||
dept.setSyncTime(new Date());
|
||||
return dept;
|
||||
}
|
||||
|
||||
private UserDept buildUserDept(OAPersonResponse oaPerson, Map<String, Long> userMap, OaOrgIndex orgIndex) {
|
||||
Dept department = orgIndex.findDept(oaPerson);
|
||||
if (department == null || department.getId() == null) {
|
||||
ComposeLogUtil.getLastLog().warn(
|
||||
"人员未匹配到已有部门,请先同步组织架构 account={} departmentid={} departmentname={} subcompanyid1={}",
|
||||
userConvert.resolveAccount(oaPerson), oaPerson.getDepartmentid(), oaPerson.getDepartmentname(),
|
||||
oaPerson.getSubcompanyid1());
|
||||
return null;
|
||||
}
|
||||
UserDept userDept = userConvert.person2userDept(oaPerson, userMap);
|
||||
@@ -493,55 +672,6 @@ public class OaUserListSyncHelper {
|
||||
return roleList.get(0).getId().toString();
|
||||
}
|
||||
|
||||
private String resolveCompanyKey(OAPersonResponse oaPerson) {
|
||||
if (oaPerson == null) {
|
||||
return null;
|
||||
}
|
||||
if (StringUtils.isNotBlank(oaPerson.getSubcompanyid1())) {
|
||||
return oaPerson.getSubcompanyid1().trim();
|
||||
}
|
||||
if (StringUtils.isNotBlank(oaPerson.getSubcompanyname())) {
|
||||
return "NAME:" + oaPerson.getSubcompanyname().trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String resolveDepartmentKey(OAPersonResponse oaPerson) {
|
||||
if (oaPerson == null) {
|
||||
return null;
|
||||
}
|
||||
if (StringUtils.isNotBlank(oaPerson.getDepartmentid())) {
|
||||
return oaPerson.getDepartmentid().trim();
|
||||
}
|
||||
String companyKey = resolveCompanyKey(oaPerson);
|
||||
if (StringUtils.isNotBlank(companyKey) && StringUtils.isNotBlank(oaPerson.getDepartmentname())) {
|
||||
return companyKey + ":" + oaPerson.getDepartmentname().trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String buildCompanyCode(OAPersonResponse oaPerson, String companyKey) {
|
||||
if (StringUtils.isNotBlank(oaPerson.getSubcompanyid1())) {
|
||||
return OAConvertConstant.COMPANY_OA_PREFIX + oaPerson.getSubcompanyid1().trim();
|
||||
}
|
||||
return OAConvertConstant.COMPANY_OA_PREFIX + "N" + Math.abs(companyKey.hashCode());
|
||||
}
|
||||
|
||||
private String buildDepartmentCode(OAPersonResponse oaPerson, String departmentKey) {
|
||||
if (StringUtils.isNotBlank(oaPerson.getDepartmentid())) {
|
||||
return OAConvertConstant.DEPARTMENT_OA_PREFIX + oaPerson.getDepartmentid().trim();
|
||||
}
|
||||
return OAConvertConstant.DEPARTMENT_OA_PREFIX + "N" + Math.abs(departmentKey.hashCode());
|
||||
}
|
||||
|
||||
private String buildAncestors(Dept parent) {
|
||||
String ancestors = parent.getAncestors();
|
||||
if (StringUtils.isBlank(ancestors)) {
|
||||
ancestors = String.valueOf(BladeConstant.TOP_PARENT_ID);
|
||||
}
|
||||
return ancestors + "," + parent.getId();
|
||||
}
|
||||
|
||||
private String resolveTenantId() {
|
||||
String tenantId = AuthUtil.getTenantId();
|
||||
if (StringUtils.isBlank(tenantId)) {
|
||||
|
||||
+3
-2
@@ -366,6 +366,7 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
|
||||
user.setPostId(null);
|
||||
user.setAccount(null);
|
||||
user.setIsLeader(null);
|
||||
user.setOaLoginId(null);
|
||||
CacheUtil.clear(USER_CACHE);
|
||||
return submitUserInfo(user);
|
||||
}
|
||||
@@ -407,8 +408,8 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
|
||||
@Override
|
||||
public IPage<User> selectUserPage(IPage<User> page, User user, Long deptId, String tenantId) {
|
||||
normalizeUserSearch(user);
|
||||
List<Long> deptIdList = SysCache.getDeptChildIds(deptId);
|
||||
return page.setRecords(baseMapper.selectUserPage(page, user, deptIdList, tenantId));
|
||||
// 部门过滤下推到 SQL(FIND_IN_SET),避免先展开整棵子树再拼超大 IN 列表
|
||||
return page.setRecords(baseMapper.selectUserPage(page, user, deptId, tenantId));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+6
@@ -307,6 +307,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";
|
||||
|
||||
|
||||
+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)); }
|
||||
}
|
||||
|
||||
+15
@@ -144,4 +144,19 @@ public class SettlementImportController extends BladeController {
|
||||
settlementImportBatchService.voidBatch(request);
|
||||
return R.success("作废成功");
|
||||
}
|
||||
|
||||
@PostMapping("/remove")
|
||||
@ApiOperationSupport(order = 12)
|
||||
@Operation(summary = "删除草稿批次")
|
||||
public R remove(@RequestParam Long id) {
|
||||
settlementImportBatchService.removeDraft(id);
|
||||
return R.success("删除成功");
|
||||
}
|
||||
|
||||
@GetMapping("/history-waybills")
|
||||
@ApiOperationSupport(order = 13)
|
||||
@Operation(summary = "历史运单可选列表")
|
||||
public R<List<Map<String, Object>>> historyWaybills(SettlementImportBatchRequest request) {
|
||||
return R.data(settlementImportBatchService.historyWaybills(request));
|
||||
}
|
||||
}
|
||||
|
||||
+108
-11
@@ -26,7 +26,9 @@
|
||||
package org.springblade.transport.excel;
|
||||
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.CellType;
|
||||
import org.apache.poi.ss.usermodel.DataFormatter;
|
||||
import org.apache.poi.ss.usermodel.DateUtil;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
@@ -38,11 +40,16 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import java.io.InputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 导入结算单 Excel 读取器(Apache POI)。
|
||||
@@ -54,6 +61,11 @@ import java.util.Map;
|
||||
public final class SettlementImportFeeReader {
|
||||
|
||||
private static final DataFormatter DATA_FORMATTER = new DataFormatter(Locale.CHINA);
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
private static final Set<String> DATE_FIELDS = Set.of(
|
||||
"actualStartDate", "actualEndDate", "planStartDate", "planEndDate"
|
||||
);
|
||||
private static final Set<String> OPTIONAL_DECIMAL_FIELDS = Set.of("mileage", "unitPrice");
|
||||
|
||||
private SettlementImportFeeReader() {
|
||||
}
|
||||
@@ -65,11 +77,14 @@ public final class SettlementImportFeeReader {
|
||||
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) {
|
||||
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() {
|
||||
@@ -83,6 +98,10 @@ public final class SettlementImportFeeReader {
|
||||
public List<String> getWarnings() {
|
||||
return warnings;
|
||||
}
|
||||
|
||||
public List<String> getHeaders() {
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
|
||||
public static ParseResult read(MultipartFile file) {
|
||||
@@ -128,7 +147,7 @@ public final class SettlementImportFeeReader {
|
||||
rows.add(mapped);
|
||||
}
|
||||
}
|
||||
return new ParseResult(rows, feeItemNames, List.of());
|
||||
return new ParseResult(rows, feeItemNames, List.of(), new ArrayList<>(headers.values()));
|
||||
} catch (ServiceException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
@@ -193,23 +212,28 @@ public final class SettlementImportFeeReader {
|
||||
for (Map.Entry<Integer, String> entry : headers.entrySet()) {
|
||||
int column = entry.getKey();
|
||||
String trimmedHeader = entry.getValue();
|
||||
String cell = cellString(row.getCell(column));
|
||||
Cell poiCell = row.getCell(column);
|
||||
if (feeColumns.contains(column)) {
|
||||
feeItems.put(trimmedHeader, decimal(cell, trimmedHeader, excelRowIndex));
|
||||
feeItems.put(trimmedHeader, decimal(cellString(poiCell), trimmedHeader, excelRowIndex));
|
||||
continue;
|
||||
}
|
||||
String field = FIXED_HEADER_MAP.get(trimmedHeader);
|
||||
if (field == null) {
|
||||
mapped.put(trimmedHeader, blankToNull(cellString(poiCell)));
|
||||
continue;
|
||||
}
|
||||
if ("transportFee".equals(field) || "unitPrice".equals(field)
|
||||
|| "quantity".equals(field) || "mileage".equals(field)
|
||||
|| "settlementAmount".equals(field)) {
|
||||
mapped.put(field, decimal(cell, trimmedHeader, excelRowIndex));
|
||||
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)) {
|
||||
mapped.put(field, optionalDecimal(cellString(poiCell), trimmedHeader, excelRowIndex));
|
||||
} else if ("serialNumber".equals(field)) {
|
||||
mapped.put(field, intValue(cell));
|
||||
mapped.put(field, intValue(cellString(poiCell)));
|
||||
} else {
|
||||
mapped.put(field, blankToNull(cell));
|
||||
mapped.put(field, blankToNull(cellString(poiCell)));
|
||||
}
|
||||
mapped.put(trimmedHeader, mapped.get(field));
|
||||
}
|
||||
@@ -231,7 +255,9 @@ public final class SettlementImportFeeReader {
|
||||
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);
|
||||
BigDecimal settlementAmount = mapped.get("settlementAmount") == null
|
||||
? transportFee.add(feeItemTotal).setScale(2, RoundingMode.HALF_UP)
|
||||
: toDecimal(mapped.get("settlementAmount"));
|
||||
mapped.put("settlementAmount", settlementAmount);
|
||||
mapped.put("结算金额", settlementAmount);
|
||||
mapped.put("feeItems", feeItems);
|
||||
@@ -278,6 +304,69 @@ public final class SettlementImportFeeReader {
|
||||
return text.isEmpty() ? null : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取日期单元格:Excel 日期序列优先格式化为 yyyy-MM-dd;
|
||||
* 文本短日期(如 1/1/26、10/10/2026)归一为四位年,供后续 LenientDateParser 识别。
|
||||
*/
|
||||
private static String dateCellString(Cell cell) {
|
||||
if (cell == null) {
|
||||
return null;
|
||||
}
|
||||
CellType cellType = cell.getCellType();
|
||||
if (cellType == CellType.FORMULA) {
|
||||
cellType = cell.getCachedFormulaResultType();
|
||||
}
|
||||
if (cellType == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) {
|
||||
Date date = cell.getDateCellValue();
|
||||
if (date == null) {
|
||||
return null;
|
||||
}
|
||||
LocalDate localDate = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
|
||||
return DATE_FORMATTER.format(localDate);
|
||||
}
|
||||
String text = cellString(cell);
|
||||
return normalizeExcelShortDate(text);
|
||||
}
|
||||
|
||||
/** 将 Excel 常见短日期文本归一为 yyyy-MM-dd;无法识别时原样返回交由校验层报错。 */
|
||||
private static String normalizeExcelShortDate(String text) {
|
||||
if (text == null || text.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String normalized = text.trim().replace('.', '/').replace('-', '/');
|
||||
String[] parts = normalized.split("[/\\s]+");
|
||||
if (parts.length < 3) {
|
||||
return text.trim();
|
||||
}
|
||||
try {
|
||||
int first = Integer.parseInt(parts[0]);
|
||||
int second = Integer.parseInt(parts[1]);
|
||||
int third = Integer.parseInt(parts[2].replaceAll("[^0-9].*$", ""));
|
||||
int year;
|
||||
int month;
|
||||
int day;
|
||||
if (parts[0].length() == 4) {
|
||||
year = first;
|
||||
month = second;
|
||||
day = third;
|
||||
} else if (parts[2].length() == 4 || parts[2].length() == 2) {
|
||||
// M/d/yy 或 M/d/yyyy(Excel 中文本/短日期常见)
|
||||
month = first;
|
||||
day = second;
|
||||
year = parts[2].length() == 2 ? 2000 + third : third;
|
||||
if (parts[2].length() == 2 && third >= 100) {
|
||||
return text.trim();
|
||||
}
|
||||
} else {
|
||||
return text.trim();
|
||||
}
|
||||
LocalDate localDate = LocalDate.of(year, month, day);
|
||||
return DATE_FORMATTER.format(localDate);
|
||||
} catch (Exception exception) {
|
||||
return text.trim();
|
||||
}
|
||||
}
|
||||
|
||||
private static BigDecimal decimal(String cell, String columnName, Integer excelRowIndex) {
|
||||
if (cell == null || cell.isBlank()) {
|
||||
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
||||
@@ -290,6 +379,14 @@ public final class SettlementImportFeeReader {
|
||||
}
|
||||
}
|
||||
|
||||
/** 可选数值:空白返回 null,避免空里程被当成 0 触发「必须为正数」。 */
|
||||
private static BigDecimal optionalDecimal(String cell, String columnName, Integer excelRowIndex) {
|
||||
if (cell == null || cell.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return decimal(cell, columnName, excelRowIndex);
|
||||
}
|
||||
|
||||
private static Integer intValue(String cell) {
|
||||
if (cell == null || cell.isBlank()) {
|
||||
return null;
|
||||
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.excel;
|
||||
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.CellStyle;
|
||||
import org.apache.poi.ss.usermodel.DataValidation;
|
||||
import org.apache.poi.ss.usermodel.DataValidationConstraint;
|
||||
import org.apache.poi.ss.usermodel.DataValidationHelper;
|
||||
import org.apache.poi.ss.usermodel.FillPatternType;
|
||||
import org.apache.poi.ss.usermodel.Font;
|
||||
import org.apache.poi.ss.usermodel.HorizontalAlignment;
|
||||
import org.apache.poi.ss.usermodel.IndexedColors;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.VerticalAlignment;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.ss.util.CellRangeAddressList;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 导入结算单模板导出:填写说明 + 数据表头 + 运输方式下拉 + 系统费用项列。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public final class SettlementImportTemplateExporter {
|
||||
|
||||
private static final List<String> FIXED_HEADERS_BEFORE_FEE = List.of(
|
||||
"序号",
|
||||
"原始单号",
|
||||
"配载标识号",
|
||||
"*车牌号/航班号/船号/班列号",
|
||||
"*运输方式",
|
||||
"司机/船长姓名",
|
||||
"司机/船长手机号",
|
||||
"*发货地址",
|
||||
"发货联系人",
|
||||
"发货联系人电话",
|
||||
"*到货地址",
|
||||
"收货联系人",
|
||||
"收货联系人电话",
|
||||
"*货物名称",
|
||||
"*货物类型",
|
||||
"包装",
|
||||
"*数量",
|
||||
"*数量单位",
|
||||
"规格",
|
||||
"型号",
|
||||
"里程(km)",
|
||||
"运输单价",
|
||||
"*运输费"
|
||||
);
|
||||
|
||||
private static final List<String> FIXED_HEADERS_AFTER_FEE = List.of(
|
||||
"结算金额",
|
||||
"*实际发货时间",
|
||||
"*实际完成时间",
|
||||
"预计发货时间",
|
||||
"预计完成时间",
|
||||
"备注",
|
||||
"同一运单标识号"
|
||||
);
|
||||
|
||||
private static final List<String> DEFAULT_FEE_ITEMS = List.of("装卸费", "过路费");
|
||||
|
||||
private SettlementImportTemplateExporter() {
|
||||
}
|
||||
|
||||
public static void export(HttpServletResponse response, List<String> transportTypeOptions,
|
||||
List<String> feeItemNames) {
|
||||
List<String> headers = buildHeaders(feeItemNames);
|
||||
List<String> transportOptions = defaultTransportOptions(transportTypeOptions);
|
||||
try (Workbook workbook = new XSSFWorkbook()) {
|
||||
writeInstructionSheet(workbook, transportOptions, feeItemNames);
|
||||
Sheet dataSheet = workbook.createSheet("导入结算单模板");
|
||||
CellStyle headerStyle = createHeaderStyle(workbook);
|
||||
Row headerRow = dataSheet.createRow(0);
|
||||
for (int index = 0; index < headers.size(); index++) {
|
||||
Cell cell = headerRow.createCell(index);
|
||||
cell.setCellValue(headers.get(index));
|
||||
cell.setCellStyle(headerStyle);
|
||||
dataSheet.setColumnWidth(index, Math.min(28, Math.max(12, headers.get(index).length() + 4)) * 256);
|
||||
}
|
||||
int transportTypeColumn = headers.indexOf("*运输方式");
|
||||
if (transportTypeColumn >= 0 && !transportOptions.isEmpty()) {
|
||||
addDropdown(dataSheet, transportTypeColumn, transportOptions);
|
||||
}
|
||||
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
response.setHeader("Content-disposition",
|
||||
"attachment;filename=" + URLEncoder.encode("导入结算单模板", StandardCharsets.UTF_8) + ".xlsx");
|
||||
workbook.write(response.getOutputStream());
|
||||
} catch (IOException exception) {
|
||||
throw new ServiceException("导出导入结算单模板失败:" + exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> buildHeaders(List<String> feeItemNames) {
|
||||
List<String> headers = new ArrayList<>(FIXED_HEADERS_BEFORE_FEE);
|
||||
Set<String> feeNames = new LinkedHashSet<>();
|
||||
if (feeItemNames != null) {
|
||||
for (String name : feeItemNames) {
|
||||
if (name != null && !name.isBlank()) {
|
||||
feeNames.add(name.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
if (feeNames.isEmpty()) {
|
||||
feeNames.addAll(DEFAULT_FEE_ITEMS);
|
||||
}
|
||||
headers.addAll(feeNames);
|
||||
headers.addAll(FIXED_HEADERS_AFTER_FEE);
|
||||
return headers;
|
||||
}
|
||||
|
||||
private static void writeInstructionSheet(Workbook workbook, List<String> transportOptions,
|
||||
List<String> feeItemNames) {
|
||||
Sheet sheet = workbook.createSheet("填写说明");
|
||||
CellStyle titleStyle = workbook.createCellStyle();
|
||||
Font titleFont = workbook.createFont();
|
||||
titleFont.setBold(true);
|
||||
titleStyle.setFont(titleFont);
|
||||
List<String> lines = new ArrayList<>();
|
||||
lines.add("导入结算单填写说明");
|
||||
lines.add("1. 请不要修改「导入结算单模板」工作表的表头;带 * 号为必填项。");
|
||||
lines.add("2. 运输方式请从下拉项中选择,须与系统字典一致。");
|
||||
lines.add("3. 日期请填写 yyyy-MM-dd(如 2026-01-01);Excel 日期单元格也可直接选择。");
|
||||
lines.add("4. 里程(km) 可为空;填写时须为正数。");
|
||||
lines.add("5. 「*运输费」之后、「结算金额」之前的列为费用项,列名须与系统费用项主数据名称完全一致;金额不为 0 时若不存在或已停用则无法导入。");
|
||||
lines.add("6. 结算金额 = 运输费 + 各费用项合计,系统会按此重算。");
|
||||
lines.add("7. 当前可选运输方式:" + String.join("、", transportOptions) + "。");
|
||||
if (feeItemNames != null && !feeItemNames.isEmpty()) {
|
||||
lines.add("8. 当前启用费用项示例:" + String.join("、", feeItemNames.stream().limit(30).toList())
|
||||
+ (feeItemNames.size() > 30 ? " 等" : "") + "。");
|
||||
} else {
|
||||
lines.add("8. 模板默认包含装卸费、过路费列,可按系统费用项主数据增删费用列。");
|
||||
}
|
||||
for (int index = 0; index < lines.size(); index++) {
|
||||
Row row = sheet.createRow(index);
|
||||
Cell cell = row.createCell(0);
|
||||
cell.setCellValue(lines.get(index));
|
||||
if (index == 0) {
|
||||
cell.setCellStyle(titleStyle);
|
||||
}
|
||||
}
|
||||
sheet.setColumnWidth(0, 120 * 256);
|
||||
}
|
||||
|
||||
private static void addDropdown(Sheet sheet, int columnIndex, List<String> options) {
|
||||
DataValidationHelper helper = sheet.getDataValidationHelper();
|
||||
String[] values = options.toArray(new String[0]);
|
||||
DataValidationConstraint constraint = helper.createExplicitListConstraint(values);
|
||||
CellRangeAddressList addressList = new CellRangeAddressList(1, 2000, columnIndex, columnIndex);
|
||||
DataValidation validation = helper.createValidation(constraint, addressList);
|
||||
validation.setEmptyCellAllowed(true);
|
||||
validation.setShowErrorBox(true);
|
||||
sheet.addValidationData(validation);
|
||||
}
|
||||
|
||||
private static CellStyle createHeaderStyle(Workbook workbook) {
|
||||
CellStyle style = workbook.createCellStyle();
|
||||
style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
|
||||
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
|
||||
style.setAlignment(HorizontalAlignment.CENTER);
|
||||
style.setVerticalAlignment(VerticalAlignment.CENTER);
|
||||
Font font = workbook.createFont();
|
||||
font.setBold(true);
|
||||
style.setFont(font);
|
||||
return style;
|
||||
}
|
||||
|
||||
private static List<String> defaultTransportOptions(List<String> transportTypeOptions) {
|
||||
if (transportTypeOptions != null && !transportTypeOptions.isEmpty()) {
|
||||
return transportTypeOptions;
|
||||
}
|
||||
return List.of("公路运输", "铁路运输", "水路运输", "航空运输");
|
||||
}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
|
||||
+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);
|
||||
}
|
||||
|
||||
+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);
|
||||
}
|
||||
|
||||
+11
@@ -15,6 +15,9 @@ 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
|
||||
@@ -37,4 +40,12 @@ public interface SettlementProjectAdvanceWriteOffMapper extends BaseMapper<Settl
|
||||
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);
|
||||
|
||||
}
|
||||
|
||||
+12
@@ -32,4 +32,16 @@
|
||||
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;
|
||||
@@ -270,6 +271,7 @@ public final class MkProcessBizHandlers {
|
||||
}
|
||||
}
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@TenantIgnore
|
||||
@RequiredArgsConstructor
|
||||
@@ -290,6 +292,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);
|
||||
@@ -310,8 +318,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:与手动审批同一入口(补变更记录),审批事务提交后自动触发金蝶推送事件;
|
||||
// 仅审批中单据允许通过,重复回调被状态校验拒绝,不重复推送
|
||||
@@ -330,8 +342,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("审核不通过");
|
||||
@@ -403,10 +419,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();
|
||||
|
||||
+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) {
|
||||
}
|
||||
}
|
||||
|
||||
+11
@@ -60,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 为终态不返回(前端视为无锁/已解锁)
|
||||
|
||||
+8
@@ -66,4 +66,12 @@ public interface ISettlementImportBatchService extends BaseService<SettlementImp
|
||||
SettlementImportBatchVO confirm(SettlementImportBatchRequest request);
|
||||
|
||||
void voidBatch(SettlementImportVoidRequest request);
|
||||
|
||||
/** 删除草稿批次(仅 draft 可删)。 */
|
||||
void removeDraft(Long id);
|
||||
|
||||
/**
|
||||
* 历史运单可选列表:相同项目、客户合同、承运商合同下,尚未生成对应应收/应付明细的运单。
|
||||
*/
|
||||
List<Map<String, Object>> historyWaybills(SettlementImportBatchRequest request);
|
||||
}
|
||||
|
||||
+18
-1
@@ -14,7 +14,9 @@ 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;
|
||||
|
||||
/**
|
||||
* 结算单项目预付核销服务
|
||||
@@ -24,7 +26,7 @@ import java.util.List;
|
||||
public interface ISettlementProjectAdvanceWriteOffService {
|
||||
|
||||
/**
|
||||
* 按合同查询可选项目预付(审批通过且仍有可核销余额)。
|
||||
* 按合同查询可选项目预付(审批通过、金蝶已付款且仍有可核销余额)。
|
||||
*/
|
||||
List<ProjectAdvanceWriteOffOptionVO> listOptions(Long contractId, String excludeSettlementType,
|
||||
Long excludeSettlementId);
|
||||
@@ -39,6 +41,11 @@ public interface ISettlementProjectAdvanceWriteOffService {
|
||||
*/
|
||||
BigDecimal sumBySettlement(String settlementType, Long settlementId);
|
||||
|
||||
/**
|
||||
* 按结算单集合分组统计核销合计(一次 group-by,供批量刷新与列表页使用)。
|
||||
*/
|
||||
Map<Long, BigDecimal> sumBySettlementIds(String settlementType, Collection<Long> settlementIds);
|
||||
|
||||
/**
|
||||
* 覆盖保存核销明细,并校验不超过可核销金额与结算单金额。
|
||||
*/
|
||||
@@ -50,4 +57,14 @@ public interface ISettlementProjectAdvanceWriteOffService {
|
||||
*/
|
||||
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();
|
||||
|
||||
}
|
||||
+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<>());
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -104,12 +104,17 @@ public class BillLedgerServiceImpl extends BaseServiceImpl<BillLedgerMapper, Bil
|
||||
}
|
||||
|
||||
private List<BillLedgerVO> availableList(String keyword, Long deptId, Long selectedId) {
|
||||
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
|
||||
.eq(BillLedger::getBillType, "received")
|
||||
.ge(BillLedger::getMaturityDate, today)
|
||||
.gt(BillLedger::getAvailableBalance, BigDecimal.ZERO))
|
||||
.or(selectedId != null, child -> child.eq(BillLedger::getId, selectedId)))
|
||||
.orderByAsc(BillLedger::getMaturityDate)
|
||||
.orderByDesc(BillLedger::getCreateTime)).stream()
|
||||
|
||||
+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) {
|
||||
|
||||
+555
-38
@@ -33,12 +33,14 @@ import org.springblade.transport.mapper.InvoiceApplicationMapper;
|
||||
import org.springblade.transport.mapper.InvoiceApplicationSettlementMapper;
|
||||
import org.springblade.transport.mapper.PreSettlementDetailMapper;
|
||||
import org.springblade.transport.mapper.PreSettlementDetailFeeMapper;
|
||||
import org.springblade.transport.mapper.PreSettlementAdvanceMapper;
|
||||
import org.springblade.transport.mapper.PreSettlementMapper;
|
||||
import org.springblade.transport.mapper.PreSettlementSummaryFeeMapper;
|
||||
import org.springblade.transport.mapper.PaymentApplicationMapper;
|
||||
import org.springblade.transport.mapper.PaymentApplicationInvoiceMapper;
|
||||
import org.springblade.transport.mapper.PaymentApplicationSettlementMapper;
|
||||
import org.springblade.transport.mapper.ReceiptClaimSettlementMapper;
|
||||
import org.springblade.transport.mapper.SettlementProjectAdvanceWriteOffMapper;
|
||||
import org.springblade.transport.constant.KingdeeAttachmentConstant;
|
||||
import org.springblade.transport.config.BfiProperties;
|
||||
import org.springblade.transport.constant.KingdeeBfiConstant;
|
||||
@@ -69,6 +71,7 @@ import org.springblade.transport.pojo.entity.InvoiceApplicationInvoice;
|
||||
import org.springblade.transport.pojo.entity.InvoiceApplicationSettlement;
|
||||
import org.springblade.transport.pojo.entity.ContractManage;
|
||||
import org.springblade.transport.pojo.entity.PreSettlement;
|
||||
import org.springblade.transport.pojo.entity.PreSettlementAdvance;
|
||||
import org.springblade.transport.pojo.entity.PreSettlementDetail;
|
||||
import org.springblade.transport.pojo.entity.PreSettlementDetailFee;
|
||||
import org.springblade.transport.pojo.entity.PreSettlementSummaryFee;
|
||||
@@ -79,9 +82,11 @@ import org.springblade.transport.pojo.entity.ReceiptClaimSettlement;
|
||||
import org.springblade.transport.pojo.entity.ReceivablePayableDetail;
|
||||
import org.springblade.transport.pojo.entity.ReceivablePayableCargoFee;
|
||||
import org.springblade.transport.pojo.entity.SettlementAdjustment;
|
||||
import org.springblade.transport.pojo.entity.SettlementProjectAdvanceWriteOff;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.FormalSettlementVO;
|
||||
import org.springblade.transport.pojo.vo.PreSettlementVO;
|
||||
import org.springblade.transport.pojo.vo.SettlementAdvancePaymentVO;
|
||||
import org.springblade.transport.pojo.vo.SettlementAdjustmentVO;
|
||||
import org.springblade.transport.service.IFormalSettlementService;
|
||||
import org.springblade.transport.service.IInvoiceReceiptService;
|
||||
@@ -113,6 +118,8 @@ import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
@@ -162,6 +169,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
private final PreSettlementSummaryFeeMapper preSummaryFeeMapper;
|
||||
private final PreSettlementDetailMapper preDetailMapper;
|
||||
private final PreSettlementDetailFeeMapper preDetailFeeMapper;
|
||||
private final PreSettlementAdvanceMapper preSettlementAdvanceMapper;
|
||||
private final SettlementProjectAdvanceWriteOffMapper projectAdvanceWriteOffMapper;
|
||||
private final ReceivablePayableDetailMapper receivablePayableMapper;
|
||||
private final ReceivablePayableCargoFeeMapper receivablePayableCargoFeeMapper;
|
||||
private final PaymentApplicationMapper paymentApplicationMapper;
|
||||
@@ -207,7 +216,13 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
if (ids.isEmpty()) wrapper.eq(FormalSettlement::getId, -1L); else wrapper.in(FormalSettlement::getId, ids);
|
||||
}
|
||||
IPage<FormalSettlement> result = page(page, wrapper.orderByDesc(FormalSettlement::getCreateTime));
|
||||
IPage<FormalSettlementVO> voPage = result.convert(this::toVO);
|
||||
// 本页应付结算单的项目预付核销合计一次查询,避免逐行 toVO 重算汇总时的 N+1
|
||||
Map<Long, BigDecimal> writeOffAmounts = this.projectAdvanceWriteOffService.sumBySettlementIds(
|
||||
TransportBusiConstant.SETTLEMENT_BILL_FORMAL,
|
||||
result.getRecords().stream()
|
||||
.filter(item -> "payable".equals(item.getSettlementType()))
|
||||
.map(FormalSettlement::getId).toList());
|
||||
IPage<FormalSettlementVO> voPage = result.convert(item -> this.toVO(item, writeOffAmounts));
|
||||
fillAttachmentSyncStatus(voPage.getRecords());
|
||||
fillKingdeeLockStatus(voPage.getRecords());
|
||||
return voPage;
|
||||
@@ -286,21 +301,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
default -> throw new ServiceException("结算类型不正确");
|
||||
};
|
||||
String prefix = code + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
|
||||
List<String> numbers = list(Wrappers.<FormalSettlement>lambdaQuery()
|
||||
.select(FormalSettlement::getFormalSettlementNo)
|
||||
.likeRight(FormalSettlement::getFormalSettlementNo, prefix))
|
||||
.stream().map(FormalSettlement::getFormalSettlementNo).toList();
|
||||
int sequence = 1;
|
||||
if (!numbers.isEmpty()) {
|
||||
String latest = numbers.stream().max(String::compareTo).orElse(prefix);
|
||||
try {
|
||||
sequence = Integer.parseInt(latest.substring(prefix.length())) + 1;
|
||||
} catch (RuntimeException ignored) {
|
||||
sequence = Math.toIntExact(count(Wrappers.<FormalSettlement>lambdaQuery()
|
||||
.likeRight(FormalSettlement::getFormalSettlementNo, prefix))) + 1;
|
||||
}
|
||||
}
|
||||
return prefix + String.format("%05d", sequence);
|
||||
Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix);
|
||||
return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -314,6 +316,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
List<FormalSettlementDetail> details = detailMapper.selectList(Wrappers.<FormalSettlementDetail>lambdaQuery()
|
||||
.eq(FormalSettlementDetail::getFormalSettlementId, id).orderByAsc(FormalSettlementDetail::getLineNo));
|
||||
fillDetailWaybillAddresses(details);
|
||||
fillDetailCargoNames(details);
|
||||
fillDetailBillingTypes(details);
|
||||
vo.setDetails(details);
|
||||
vo.setSummaryFees(listSummaryFees(id));
|
||||
vo.setPayments(paymentMapper.selectList(Wrappers.<FormalSettlementPayment>lambdaQuery()
|
||||
@@ -322,6 +326,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
vo.setIssuedInvoices(listIssuedInvoices(id));
|
||||
vo.setProjectAdvanceWriteOffs(projectAdvanceWriteOffService.listBySettlement(
|
||||
TransportBusiConstant.SETTLEMENT_BILL_FORMAL, id));
|
||||
vo.setAdvancePayments(this.listAdvancePayments(sources, vo.getProjectAdvanceWriteOffs()));
|
||||
Set<Long> relationApplicationIds = paymentApplicationSettlementMapper.selectList(
|
||||
Wrappers.<PaymentApplicationSettlement>lambdaQuery()
|
||||
.select(PaymentApplicationSettlement::getPaymentApplicationId)
|
||||
@@ -344,13 +349,43 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
vo.setAdjustments(settlementAdjustmentMapper.selectList(Wrappers.<SettlementAdjustment>lambdaQuery()
|
||||
.eq(SettlementAdjustment::getFormalSettlementId, id)
|
||||
.orderByDesc(SettlementAdjustment::getCreateTime)).stream()
|
||||
.map(item -> toAdjustmentVO(item, settlement.getKingdeeBillNo())).toList());
|
||||
.map(this::toAdjustmentVO).toList());
|
||||
vo.setChangeRecords(changeRecordMapper.selectList(Wrappers.<FormalSettlementChangeRecord>lambdaQuery()
|
||||
.eq(FormalSettlementChangeRecord::getFormalSettlementId, id)
|
||||
.orderByDesc(FormalSettlementChangeRecord::getChangeTime)));
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 明细展示计费方式:按来源应收应付货物费用的计费类型汇总(去重后顿号拼接)。
|
||||
*/
|
||||
private void fillDetailBillingTypes(List<FormalSettlementDetail> details) {
|
||||
if (details == null || details.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<Long> sourceDetailIds = details.stream()
|
||||
.map(FormalSettlementDetail::getSourceDetailId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (sourceDetailIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<Long, String> billingTypeMap = receivablePayableCargoFeeMapper.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 (FormalSettlementDetail detail : details) {
|
||||
detail.setBillingType(billingTypeMap.getOrDefault(detail.getSourceDetailId(), ""));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为历史正式结算明细补充运单收发货信息,保留明细中已有的快照值。
|
||||
*/
|
||||
@@ -545,12 +580,17 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
applySummaryRequest(settlement.getId(), request.getSummaryFees());
|
||||
refreshSettlementAmount(settlement);
|
||||
rebuildInvoices(settlement, request.getInvoices());
|
||||
// 预结算转正式/调整来源:先转移来源预结算的项目预付核销行(释放可核销占用),replaceWriteOffs 的余额校验才能通过
|
||||
this.projectAdvanceWriteOffService.deleteBySettlementIds(
|
||||
TransportBusiConstant.SETTLEMENT_BILL_PRE, request.getSourcePreSettlementIds());
|
||||
projectAdvanceWriteOffService.replaceWriteOffs(
|
||||
TransportBusiConstant.SETTLEMENT_BILL_FORMAL,
|
||||
settlement.getId(),
|
||||
settlement.getContractId(),
|
||||
settlement.getSettlementAmount(),
|
||||
request.getProjectAdvanceWriteOffs());
|
||||
// 核销行计入收付汇总(refreshSettlementAmount 内的刷新发生在核销落库之前,覆盖不到核销行)
|
||||
this.refreshPaymentSummary(settlement.getId());
|
||||
if (!creating && !restoring) {
|
||||
saveChange(settlement.getId(), "结算单基本信息", null, "调整",
|
||||
"保存正式结算单" + settlement.getFormalSettlementNo(), request.getRemark());
|
||||
@@ -605,6 +645,9 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
settlement.setCurrentNode("已作废");
|
||||
settlement.setVoidReason(required(limit(request.getReason(), 200), "作废原因"));
|
||||
updateById(settlement);
|
||||
// 联动删除项目预付核销行,释放可核销占用并避免残留记录
|
||||
this.projectAdvanceWriteOffService.deleteBySettlementIds(
|
||||
TransportBusiConstant.SETTLEMENT_BILL_FORMAL, List.of(settlement.getId()));
|
||||
saveChange(settlement.getId(), "结算单基本信息", null, "调整", "作废正式结算单", request.getReason());
|
||||
}
|
||||
|
||||
@@ -624,6 +667,9 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
.set(FormalSettlement::getKingdeeBillNo, null)
|
||||
.set(FormalSettlement::getKingdeeBillId, null)
|
||||
.set(FormalSettlement::getSyncedTime, null));
|
||||
// 联动删除项目预付核销行,释放可核销占用并避免残留记录
|
||||
this.projectAdvanceWriteOffService.deleteBySettlementIds(
|
||||
TransportBusiConstant.SETTLEMENT_BILL_FORMAL, List.of(id));
|
||||
// 变更流水:回调无登录态,操作人取金蝶报文(兜底 system,与 saveChange 同款)
|
||||
FormalSettlementChangeRecord record = new FormalSettlementChangeRecord();
|
||||
record.setFormalSettlementId(id);
|
||||
@@ -636,6 +682,118 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
changeRecordMapper.insert(record);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public CloneResult cloneFormalSettlement(Long sourceSettlementId) {
|
||||
FormalSettlement source = existing(sourceSettlementId);
|
||||
FormalSettlement clone = Objects.requireNonNull(BeanUtil.copyProperties(source, FormalSettlement.class));
|
||||
// 新单头:新号、直接审批通过、未同步金蝶、来源原单号;不动原单
|
||||
resetAuditFields(clone);
|
||||
clone.setFormalSettlementNo(nextNo(source.getSettlementType()));
|
||||
clone.setApprovalStatus(TransportBusiConstant.APPROVED);
|
||||
clone.setCurrentNode("审批通过");
|
||||
clone.setCurrentProcessor(AuthUtil.getUserName());
|
||||
clone.setApprovedTime(LocalDateTime.now());
|
||||
clone.setKingdeeBillNo(null);
|
||||
clone.setKingdeeBillId(null);
|
||||
clone.setKingdeeSyncStatus(KingdeeBfiConstant.KINGDEE_STATUS_UNSYNCED);
|
||||
clone.setSyncedTime(null);
|
||||
clone.setVoidReason(null);
|
||||
clone.setOriginFormalSettlementNo(source.getFormalSettlementNo());
|
||||
clone.setAdjustStatus(null);
|
||||
// 下游关联(开票申请/收款认领/对账)不迁移:发票/收付款口径归零,新单为全新独立单
|
||||
clone.setInvoiceStatus("unreceived");
|
||||
clone.setPaymentStatus("unpaid");
|
||||
clone.setInvoiceAmount(BigDecimal.ZERO.setScale(2));
|
||||
clone.setAppliedPaymentAmount(BigDecimal.ZERO.setScale(2));
|
||||
clone.setPaidAmount(BigDecimal.ZERO.setScale(2));
|
||||
clone.setRemainingPayableAmount(money(source.getSettlementAmount()));
|
||||
save(clone);
|
||||
|
||||
// 明细与明细费项克隆,记录 原id->新id 映射(调整明细应用到新单时按映射定位)
|
||||
Map<Long, Long> detailIdMap = new HashMap<>();
|
||||
Map<Long, Long> feeIdMap = new HashMap<>();
|
||||
List<FormalSettlementDetail> details = detailMapper.selectList(Wrappers.<FormalSettlementDetail>lambdaQuery()
|
||||
.eq(FormalSettlementDetail::getFormalSettlementId, sourceSettlementId)
|
||||
.eq(FormalSettlementDetail::getIsDeleted, 0)
|
||||
.orderByAsc(FormalSettlementDetail::getLineNo));
|
||||
for (FormalSettlementDetail detail : details) {
|
||||
FormalSettlementDetail cloneDetail = Objects.requireNonNull(BeanUtil.copyProperties(detail, FormalSettlementDetail.class));
|
||||
resetAuditFields(cloneDetail);
|
||||
cloneDetail.setFormalSettlementId(clone.getId());
|
||||
detailMapper.insert(cloneDetail);
|
||||
detailIdMap.put(detail.getId(), cloneDetail.getId());
|
||||
for (FormalSettlementDetailFee fee : detailFeeMapper.selectList(Wrappers.<FormalSettlementDetailFee>lambdaQuery()
|
||||
.eq(FormalSettlementDetailFee::getFormalSettlementDetailId, detail.getId())
|
||||
.orderByAsc(FormalSettlementDetailFee::getLineNo))) {
|
||||
FormalSettlementDetailFee cloneFee = Objects.requireNonNull(BeanUtil.copyProperties(fee, FormalSettlementDetailFee.class));
|
||||
resetAuditFields(cloneFee);
|
||||
cloneFee.setFormalSettlementDetailId(cloneDetail.getId());
|
||||
detailFeeMapper.insert(cloneFee);
|
||||
feeIdMap.put(fee.getId(), cloneFee.getId());
|
||||
}
|
||||
}
|
||||
// 汇总费用项克隆
|
||||
for (FormalSettlementSummaryFee summaryFee : listSummaryFees(sourceSettlementId)) {
|
||||
FormalSettlementSummaryFee cloneFee = Objects.requireNonNull(BeanUtil.copyProperties(summaryFee, FormalSettlementSummaryFee.class));
|
||||
resetAuditFields(cloneFee);
|
||||
cloneFee.setFormalSettlementId(clone.getId());
|
||||
summaryFeeMapper.insert(cloneFee);
|
||||
}
|
||||
// 来源预结算重建(接续原单来源,不重复锁定 PreSettlement.formalSettlementNo)
|
||||
for (FormalSettlementSource relation : sourceMapper.selectList(Wrappers.<FormalSettlementSource>lambdaQuery()
|
||||
.eq(FormalSettlementSource::getFormalSettlementId, sourceSettlementId))) {
|
||||
FormalSettlementSource cloneRelation = Objects.requireNonNull(BeanUtil.copyProperties(relation, FormalSettlementSource.class));
|
||||
resetAuditFields(cloneRelation);
|
||||
cloneRelation.setFormalSettlementId(clone.getId());
|
||||
sourceMapper.insert(cloneRelation);
|
||||
}
|
||||
// 核销行迁移:克隆到新单并删除原单行,避免预付可核销额度双占(原单为调整存档,收付口径以新单为准)
|
||||
List<SettlementProjectAdvanceWriteOff> writeOffRows = this.projectAdvanceWriteOffMapper.selectList(
|
||||
Wrappers.<SettlementProjectAdvanceWriteOff>lambdaQuery()
|
||||
.eq(SettlementProjectAdvanceWriteOff::getSettlementType, TransportBusiConstant.SETTLEMENT_BILL_FORMAL)
|
||||
.eq(SettlementProjectAdvanceWriteOff::getSettlementId, sourceSettlementId)
|
||||
.eq(SettlementProjectAdvanceWriteOff::getIsDeleted, 0));
|
||||
for (SettlementProjectAdvanceWriteOff writeOffRow : writeOffRows) {
|
||||
SettlementProjectAdvanceWriteOff cloneWriteOff = Objects.requireNonNull(
|
||||
BeanUtil.copyProperties(writeOffRow, SettlementProjectAdvanceWriteOff.class));
|
||||
resetAuditFields(cloneWriteOff);
|
||||
cloneWriteOff.setSettlementId(clone.getId());
|
||||
this.projectAdvanceWriteOffMapper.insert(cloneWriteOff);
|
||||
}
|
||||
this.projectAdvanceWriteOffService.deleteBySettlementIds(
|
||||
TransportBusiConstant.SETTLEMENT_BILL_FORMAL, List.of(sourceSettlementId));
|
||||
this.refreshPaymentSummaries(List.of(sourceSettlementId, clone.getId()));
|
||||
log.info("结算调整重推克隆正式结算单:原单[{}] -> 新单[{}](明细{}条,核销迁移{}条)",
|
||||
source.getFormalSettlementNo(), clone.getFormalSettlementNo(), details.size(), writeOffRows.size());
|
||||
return new CloneResult(clone.getId(), detailIdMap, feeIdMap);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void markSettlementAdjusted(Long settlementId, String adjustmentNo, String newSettlementNo) {
|
||||
existing(settlementId);
|
||||
// 原单只加标记,业务字段不动
|
||||
update(Wrappers.<FormalSettlement>lambdaUpdate()
|
||||
.eq(FormalSettlement::getId, settlementId)
|
||||
.set(FormalSettlement::getAdjustStatus, "adjusted"));
|
||||
kingdeeBillLockService.unlockByAdjustRepush(KingdeeLockConstant.SOURCE_SETTLEMENT, settlementId, AuthUtil.getUserName());
|
||||
saveChange(settlementId, "结算单基本信息", null, "调整",
|
||||
"结算调整单" + Func.toStr(adjustmentNo) + "重推生成新单" + Func.toStr(newSettlementNo) + ",原单标记已调整", null);
|
||||
}
|
||||
|
||||
/** 重推/克隆复用:清空审计与主键字段,交由 MP 自动填充 */
|
||||
private void resetAuditFields(org.springblade.core.mp.base.BaseEntity entity) {
|
||||
entity.setId(null);
|
||||
entity.setCreateUser(null);
|
||||
entity.setCreateDept(null);
|
||||
entity.setCreateTime(null);
|
||||
entity.setUpdateUser(null);
|
||||
entity.setUpdateTime(null);
|
||||
entity.setStatus(null);
|
||||
entity.setIsDeleted(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String syncKingdee(Long id) {
|
||||
String billNo;
|
||||
@@ -700,6 +858,11 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
.ne(FormalSettlement::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_SYNCED)
|
||||
.set(FormalSettlement::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED));
|
||||
if (updated > 0) {
|
||||
// 结算调整单回填:未推送场景审批通过后自动推送失败,调整单置 failed 供「推送金蝶」重试
|
||||
settlementAdjustmentMapper.update(null, Wrappers.<SettlementAdjustment>lambdaUpdate()
|
||||
.eq(SettlementAdjustment::getFormalSettlementId, id)
|
||||
.eq(SettlementAdjustment::getApprovalStatus, TransportBusiConstant.APPROVED)
|
||||
.set(SettlementAdjustment::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED));
|
||||
// 失败原因写变更记录留档(推送事务已回滚,此处独立事务);原因过长截断留档,不抛错
|
||||
String failReason = Func.toStrWithEmpty(reason, "未知异常");
|
||||
saveChange(id, "结算单基本信息", null, "调整",
|
||||
@@ -805,7 +968,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public String doSyncKingdee(Long id) {
|
||||
FormalSettlement settlement = existing(id);
|
||||
FormalSettlement settlement = lockedExisting(id);
|
||||
if (!TransportBusiConstant.APPROVED.equals(settlement.getApprovalStatus())) throw new ServiceException("仅审批通过的正式结算单允许同步金蝶");
|
||||
// 已同步幂等返回(含历史由付款申请同步联动回写的存量已同步数据);星瀚内码由附件补传分支反查回填
|
||||
if ("synced".equals(settlement.getKingdeeSyncStatus())) return settlement.getKingdeeBillNo();
|
||||
@@ -851,6 +1014,12 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
settlement.setSyncedTime(LocalDateTime.now());
|
||||
updateById(settlement);
|
||||
saveChange(settlement.getId(), "结算单基本信息", null, "调整", changeDesc, "");
|
||||
// 结算调整单回填:未推送场景审批通过后自动推送成功,回填挂在本单的已审批调整单(重推场景调整单已改挂新单,不受影响)
|
||||
settlementAdjustmentMapper.update(null, Wrappers.<SettlementAdjustment>lambdaUpdate()
|
||||
.eq(SettlementAdjustment::getFormalSettlementId, settlement.getId())
|
||||
.eq(SettlementAdjustment::getApprovalStatus, TransportBusiConstant.APPROVED)
|
||||
.set(SettlementAdjustment::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_SYNCED)
|
||||
.set(SettlementAdjustment::getKingdeeBillNo, billNo));
|
||||
log.info("正式结算单[{}]同步金蝶完成:settlementType={},billNo={},billPkId={}",
|
||||
settlement.getFormalSettlementNo(), settlement.getSettlementType(), billNo, billPkId);
|
||||
return billNo;
|
||||
@@ -1089,9 +1258,23 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
public List<FormalSettlementDetailFee> detailFees(Long detailId) {
|
||||
FormalSettlementDetail detail = detailMapper.selectById(detailId);
|
||||
if (detail == null || Objects.equals(detail.getIsDeleted(), 1)) throw new ServiceException("正式结算明细不存在");
|
||||
return detailFeeMapper.selectList(Wrappers.<FormalSettlementDetailFee>lambdaQuery()
|
||||
List<FormalSettlementDetailFee> fees = detailFeeMapper.selectList(Wrappers.<FormalSettlementDetailFee>lambdaQuery()
|
||||
.eq(FormalSettlementDetailFee::getFormalSettlementDetailId, detailId)
|
||||
.orderByAsc(FormalSettlementDetailFee::getLineNo));
|
||||
List<Long> sourceFeeIds = fees.stream().map(FormalSettlementDetailFee::getSourceFeeId)
|
||||
.filter(Objects::nonNull).toList();
|
||||
if (sourceFeeIds.isEmpty()) {
|
||||
return fees;
|
||||
}
|
||||
Map<Long, ReceivablePayableCargoFee> sourceFeeMap = receivablePayableCargoFeeMapper.selectBatchIds(sourceFeeIds).stream()
|
||||
.collect(Collectors.toMap(ReceivablePayableCargoFee::getId, Function.identity(), (left, right) -> left));
|
||||
fees.forEach(fee -> {
|
||||
ReceivablePayableCargoFee sourceFee = sourceFeeMap.get(fee.getSourceFeeId());
|
||||
if (sourceFee != null) {
|
||||
fee.setBillingRulesJson(sourceFee.getBillingRulesJson());
|
||||
}
|
||||
});
|
||||
return fees;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1101,11 +1284,15 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
if (detail == null || Objects.equals(detail.getIsDeleted(), 1)) throw new ServiceException("正式结算明细不存在");
|
||||
FormalSettlement settlement = editable(detail.getFormalSettlementId());
|
||||
if (Func.isEmpty(request.getRows())) throw new ServiceException("请填写需要调整的货物费用行");
|
||||
String reason = required(limit(request.getChangeReason(), 200), "调整原因");
|
||||
// 弹窗底部备注非必填,写入结算明细备注
|
||||
String detailRemark = limit(request.getChangeReason(), 200);
|
||||
String beforeDetailRemark = detail.getRemark() == null ? "" : detail.getRemark();
|
||||
Map<Long, FormalSettlementDetailFee> existing = detailFees(detail.getId()).stream()
|
||||
.collect(Collectors.toMap(FormalSettlementDetailFee::getId, item -> item));
|
||||
if (existing.size() != request.getRows().size()) throw new ServiceException("费用调整行数据不完整");
|
||||
List<String> changes = new java.util.ArrayList<>();
|
||||
Map<String, Object> beforeData = new LinkedHashMap<>();
|
||||
Map<String, Object> afterData = new LinkedHashMap<>();
|
||||
for (PreSettlementDetailAdjustRequest.FeeRow row : request.getRows()) {
|
||||
FormalSettlementDetailFee fee = existing.get(row.getId());
|
||||
if (fee == null) throw new ServiceException("存在无效的货物费用行");
|
||||
@@ -1118,7 +1305,6 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
fee.setSettlementAmountTax(nonNegative(row.getSettlementAmountTax(), "结算金额(含税)"));
|
||||
fee.setSettlementAmountNoTax(row.getSettlementAmountNoTax() == null ? null : nonNegative(row.getSettlementAmountNoTax(), "结算金额(不含税)"));
|
||||
fee.setAdjustAmount(fee.getSettlementAmountTax().subtract(money(fee.getOriginalAmount())));
|
||||
fee.setRemark(limit(row.getRemark(), 200));
|
||||
detailFeeMapper.updateById(fee);
|
||||
if (beforeAmount.compareTo(fee.getSettlementAmountTax()) != 0) {
|
||||
changes.add("【" + firstNotEmpty(fee.getCargoName(), fee.getLineNo())
|
||||
@@ -1128,17 +1314,31 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
}
|
||||
List<FormalSettlementDetailFee> rows = detailFees(detail.getId());
|
||||
detail.setTransportQuantity(rows.stream().map(FormalSettlementDetailFee::getTransportQuantity).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||
detail.setMileage(rows.stream().map(FormalSettlementDetailFee::getMileage).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||
// 运输单价无法按行简单求和,与前端暂存逻辑一致:取首行费用单价。
|
||||
detail.setUnitPrice(rows.stream().map(FormalSettlementDetailFee::getUnitPrice).filter(Objects::nonNull).findFirst().orElse(null));
|
||||
detail.setFreightAmount(rows.stream().map(FormalSettlementDetailFee::getFreightAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||
detail.setOriginalAmount(rows.stream().map(FormalSettlementDetailFee::getOriginalAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||
detail.setSettlementAmountTax(rows.stream().map(FormalSettlementDetailFee::getSettlementAmountTax).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||
detail.setSettlementAmountNoTax(rows.stream().map(FormalSettlementDetailFee::getSettlementAmountNoTax).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||
boolean hasNoTax = rows.stream().anyMatch(fee -> fee.getSettlementAmountNoTax() != null);
|
||||
detail.setSettlementAmountNoTax(hasNoTax
|
||||
? rows.stream().map(FormalSettlementDetailFee::getSettlementAmountNoTax).filter(Objects::nonNull).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add)
|
||||
: null);
|
||||
detail.setAdjustAmount(detail.getSettlementAmountTax().subtract(detail.getOriginalAmount()));
|
||||
detail.setRemark(reason);
|
||||
Map<String, BigDecimal> feeItems = new LinkedHashMap<>();
|
||||
rows.forEach(fee -> parseFeeItems(fee.getFeeItemsJson()).forEach((name, amount) ->
|
||||
feeItems.merge(name, money(amount), BigDecimal::add)));
|
||||
detail.setFeeItemsJson(JsonUtil.toJson(feeItems));
|
||||
detail.setRemark(detailRemark);
|
||||
detailMapper.updateById(detail);
|
||||
appendTextChange(changes, beforeData, afterData, "备注", beforeDetailRemark, detailRemark);
|
||||
if (changes.isEmpty()) {
|
||||
throw new ServiceException("未修改任何结算明细费用");
|
||||
}
|
||||
rebuildSummaryFees(settlement.getId());
|
||||
refreshSettlementAmount(settlement);
|
||||
saveChange(settlement.getId(), "结算明细项", detail.getLineNo(), "调整",
|
||||
changes.isEmpty() ? "调整结算明细费用" : String.join(";", changes), reason);
|
||||
String.join(";", changes), detailRemark, beforeData, afterData);
|
||||
}
|
||||
|
||||
private void rebuildSummaryFees(Long settlementId) {
|
||||
@@ -1313,10 +1513,18 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
// throw new ServiceException("费用类型与费用项不匹配或费用项已停用");
|
||||
// }
|
||||
FormalSettlementSummaryFee row = requestRow.getId() == null ? null : existingMap.get(requestRow.getId());
|
||||
if (row == null && requestRow.getId() == null) row = new FormalSettlementSummaryFee();
|
||||
if (row == null && requestRow.getId() == null) {
|
||||
row = new FormalSettlementSummaryFee();
|
||||
}
|
||||
if (row == null || row.getId() != null && !Integer.valueOf(1).equals(row.getManualFlag())) {
|
||||
throw new ServiceException("存在无效的手工费用行");
|
||||
}
|
||||
boolean creating = row.getId() == null;
|
||||
String beforeFeeType = creating ? null : row.getFeeType();
|
||||
String beforeFeeItem = creating ? null : row.getFeeItem();
|
||||
BigDecimal beforeAdjustAmount = creating ? null : money(row.getAdjustAmount());
|
||||
BigDecimal beforeSettlementAmount = creating ? null : money(row.getSettlementAmount());
|
||||
String beforeRemark = creating ? null : row.getRemark();
|
||||
row.setFormalSettlementId(settlementId);
|
||||
row.setFeeType(feeType);
|
||||
row.setFeeItem(feeItem);
|
||||
@@ -1325,13 +1533,15 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
row.setSettlementAmount(row.getAdjustAmount());
|
||||
row.setRemark(limit(requestRow.getRemark(), 50));
|
||||
row.setManualFlag(1);
|
||||
if (row.getId() == null) {
|
||||
if (creating) {
|
||||
row.setLineNo(nextLineNo++);
|
||||
summaryFeeMapper.insert(row);
|
||||
} else {
|
||||
summaryFeeMapper.updateById(row);
|
||||
}
|
||||
retainedManualIds.add(row.getId());
|
||||
saveManualSummaryFeeChange(settlementId, creating, beforeFeeType, beforeFeeItem,
|
||||
beforeAdjustAmount, beforeSettlementAmount, beforeRemark, row);
|
||||
continue;
|
||||
}
|
||||
FormalSettlementSummaryFee row = existingMap.get(requestRow.getId());
|
||||
@@ -1345,13 +1555,43 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
if (row == null || Integer.valueOf(1).equals(row.getManualFlag())) {
|
||||
throw new ServiceException("存在无效的结算合计行");
|
||||
}
|
||||
BigDecimal beforeAdjustAmount = money(row.getAdjustAmount());
|
||||
BigDecimal beforeSettlementAmount = money(row.getSettlementAmount());
|
||||
String beforeRemark = row.getRemark();
|
||||
row.setAdjustAmount(money(requestRow.getAdjustAmount()));
|
||||
row.setSettlementAmount(money(row.getOriginalAmount()).add(row.getAdjustAmount()));
|
||||
row.setRemark(limit(requestRow.getRemark(), 50));
|
||||
summaryFeeMapper.updateById(row);
|
||||
List<String> changes = new java.util.ArrayList<>();
|
||||
Map<String, Object> beforeData = new LinkedHashMap<>();
|
||||
Map<String, Object> afterData = new LinkedHashMap<>();
|
||||
appendChange(changes, beforeData, afterData, "调整金额", beforeAdjustAmount, row.getAdjustAmount());
|
||||
appendChange(changes, beforeData, afterData, "结算金额", beforeSettlementAmount, row.getSettlementAmount());
|
||||
appendTextChange(changes, beforeData, afterData, "备注", beforeRemark, row.getRemark());
|
||||
if (!changes.isEmpty()) {
|
||||
saveChange(settlementId, "合计费用项", row.getLineNo(), "调整", String.join(";", changes), "",
|
||||
beforeData, afterData);
|
||||
}
|
||||
}
|
||||
for (FormalSettlementSummaryFee manualRow : existingManualRows) {
|
||||
if (!retainedManualIds.contains(manualRow.getId())) summaryFeeMapper.deleteById(manualRow.getId());
|
||||
if (!retainedManualIds.contains(manualRow.getId())) {
|
||||
summaryFeeMapper.deleteById(manualRow.getId());
|
||||
Map<String, Object> beforeData = new LinkedHashMap<>();
|
||||
Map<String, Object> afterData = new LinkedHashMap<>();
|
||||
beforeData.put("费用类型", manualRow.getFeeType());
|
||||
beforeData.put("费用项", manualRow.getFeeItem());
|
||||
beforeData.put("调整金额", money(manualRow.getAdjustAmount()));
|
||||
beforeData.put("结算金额", money(manualRow.getSettlementAmount()));
|
||||
beforeData.put("备注", manualRow.getRemark());
|
||||
afterData.put("费用类型", "");
|
||||
afterData.put("费用项", "");
|
||||
afterData.put("调整金额", BigDecimal.ZERO.setScale(2));
|
||||
afterData.put("结算金额", BigDecimal.ZERO.setScale(2));
|
||||
afterData.put("备注", "");
|
||||
saveChange(settlementId, "合计费用项", manualRow.getLineNo(), "删除",
|
||||
"删除合计费用项【" + manualRow.getFeeItem() + "】金额【" + money(manualRow.getSettlementAmount()) + "】",
|
||||
"", beforeData, afterData);
|
||||
}
|
||||
}
|
||||
renumberSummaryFees(settlementId);
|
||||
}
|
||||
@@ -1489,11 +1729,17 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
Map<Long, BigDecimal> receiptClaimPaidBySettlement = hasReceivable
|
||||
? receiptClaimSettlementMapper.selectList(Wrappers.<ReceiptClaimSettlement>lambdaQuery()
|
||||
.in(ReceiptClaimSettlement::getFormalSettlementId, settledIds)
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)).stream()
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)
|
||||
.eq(ReceiptClaimSettlement::getIsDeleted, 0)).stream()
|
||||
.collect(Collectors.groupingBy(ReceiptClaimSettlement::getFormalSettlementId,
|
||||
Collectors.reducing(BigDecimal.ZERO,
|
||||
item -> money(item.getAllocatedReceiptAmount()), BigDecimal::add)))
|
||||
: Map.of();
|
||||
// 项目预付核销合计(仅应付结算单需要,一次 group-by 避免逐单查询)
|
||||
Map<Long, BigDecimal> writeOffAmountsBySettlement = hasPayable
|
||||
? this.projectAdvanceWriteOffService.sumBySettlementIds(
|
||||
TransportBusiConstant.SETTLEMENT_BILL_FORMAL, settledIds)
|
||||
: Map.of();
|
||||
|
||||
for (FormalSettlement settlement : settlements) {
|
||||
List<PaymentApplicationSettlement> relations = relationsBySettlement
|
||||
@@ -1509,7 +1755,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
PaymentSummary summary = calculatePaymentSummary(settlement, new PaymentSummaryData(
|
||||
relations, directApplications, relationStatuses,
|
||||
sourcesBySettlement.getOrDefault(settlement.getId(), List.of()),
|
||||
receiptClaimPaidBySettlement.getOrDefault(settlement.getId(), BigDecimal.ZERO)));
|
||||
receiptClaimPaidBySettlement.getOrDefault(settlement.getId(), BigDecimal.ZERO),
|
||||
writeOffAmountsBySettlement));
|
||||
settlement.setAppliedPaymentAmount(summary.appliedAmount());
|
||||
settlement.setPaidAmount(summary.paidAmount());
|
||||
settlement.setRemainingPayableAmount(money(settlement.getSettlementAmount())
|
||||
@@ -1663,6 +1910,117 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
.set(ReceivablePayableDetail::getSettlementStatus, "formal_settled"));
|
||||
if (affected != 1) throw new ServiceException("单据" + source.getDocumentNo() + "已被其他结算单选择");
|
||||
}
|
||||
persistDetailCargoNames(settlement.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 一单多货时运单主表只保留首行货物。结算明细按运单货物清单回填全部货物名称、货物类型。
|
||||
*/
|
||||
private void fillDetailCargoNames(List<FormalSettlementDetail> details) {
|
||||
if (details == null || details.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<Long> waybillIds = details.stream().map(FormalSettlementDetail::getWaybillId)
|
||||
.filter(Objects::nonNull).distinct().toList();
|
||||
Map<Long, Waybill> waybillMap = waybillIds.isEmpty() ? Map.of() : waybillService.listByIds(waybillIds).stream()
|
||||
.collect(Collectors.toMap(Waybill::getId, Function.identity(), (left, right) -> left));
|
||||
List<Long> detailIds = details.stream().map(FormalSettlementDetail::getId).filter(Objects::nonNull).toList();
|
||||
Map<Long, List<FormalSettlementDetailFee>> feesByDetail = detailIds.isEmpty() ? Map.of()
|
||||
: detailFeeMapper.selectList(Wrappers.<FormalSettlementDetailFee>lambdaQuery()
|
||||
.in(FormalSettlementDetailFee::getFormalSettlementDetailId, detailIds)
|
||||
.orderByAsc(FormalSettlementDetailFee::getLineNo)).stream()
|
||||
.collect(Collectors.groupingBy(FormalSettlementDetailFee::getFormalSettlementDetailId, LinkedHashMap::new, Collectors.toList()));
|
||||
for (FormalSettlementDetail detail : details) {
|
||||
Waybill waybill = waybillMap.get(detail.getWaybillId());
|
||||
LinkedHashSet<String> goodsNames = new LinkedHashSet<>();
|
||||
LinkedHashSet<String> goodsTypes = new LinkedHashSet<>();
|
||||
if (waybill != null) {
|
||||
collectGoodsCargo(waybill.getGoodsJson(), goodsNames, goodsTypes);
|
||||
}
|
||||
LinkedHashSet<String> feeNames = new LinkedHashSet<>();
|
||||
LinkedHashSet<String> feeTypes = new LinkedHashSet<>();
|
||||
for (FormalSettlementDetailFee fee : feesByDetail.getOrDefault(detail.getId(), List.of())) {
|
||||
addCargoToken(feeNames, fee.getCargoName(), true);
|
||||
addCargoToken(feeTypes, fee.getCargoType(), true);
|
||||
}
|
||||
LinkedHashSet<String> names = goodsNames.size() > 1 ? goodsNames : feeNames.size() > 1 ? feeNames : goodsNames;
|
||||
LinkedHashSet<String> types = goodsTypes.size() > 1 ? goodsTypes : feeTypes.size() > 1 ? feeTypes : goodsTypes;
|
||||
if (names.isEmpty()) {
|
||||
addCargoToken(names, detail.getCargoName(), true);
|
||||
}
|
||||
if (types.isEmpty()) {
|
||||
addCargoToken(types, detail.getCargoType(), true);
|
||||
}
|
||||
if (names.size() > 1) {
|
||||
detail.setCargoName(joinCargo(names));
|
||||
}
|
||||
if (types.size() > 1) {
|
||||
detail.setCargoType(joinCargo(types));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void persistDetailCargoNames(Long settlementId) {
|
||||
List<FormalSettlementDetail> details = detailMapper.selectList(Wrappers.<FormalSettlementDetail>lambdaQuery()
|
||||
.eq(FormalSettlementDetail::getFormalSettlementId, settlementId)
|
||||
.orderByAsc(FormalSettlementDetail::getLineNo));
|
||||
Map<Long, String> cargoNames = new LinkedHashMap<>();
|
||||
Map<Long, String> cargoTypes = new LinkedHashMap<>();
|
||||
for (FormalSettlementDetail detail : details) {
|
||||
cargoNames.put(detail.getId(), detail.getCargoName());
|
||||
cargoTypes.put(detail.getId(), detail.getCargoType());
|
||||
}
|
||||
fillDetailCargoNames(details);
|
||||
for (FormalSettlementDetail detail : details) {
|
||||
if (Objects.equals(cargoNames.get(detail.getId()), detail.getCargoName())
|
||||
&& Objects.equals(cargoTypes.get(detail.getId()), detail.getCargoType())) {
|
||||
continue;
|
||||
}
|
||||
detailMapper.update(null, Wrappers.<FormalSettlementDetail>lambdaUpdate()
|
||||
.eq(FormalSettlementDetail::getId, detail.getId())
|
||||
.set(FormalSettlementDetail::getCargoName, detail.getCargoName())
|
||||
.set(FormalSettlementDetail::getCargoType, detail.getCargoType()));
|
||||
}
|
||||
}
|
||||
|
||||
private void collectGoodsCargo(String goodsJson, Set<String> names, Set<String> types) {
|
||||
if (Func.isEmpty(goodsJson)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Object parsed = JsonUtil.parse(goodsJson, List.class);
|
||||
if (!(parsed instanceof List<?> rows)) {
|
||||
return;
|
||||
}
|
||||
for (Object row : rows) {
|
||||
if (!(row instanceof Map<?, ?> goods)) {
|
||||
continue;
|
||||
}
|
||||
addCargoToken(names, goods.get("cargoName") == null ? null : String.valueOf(goods.get("cargoName")), false);
|
||||
addCargoToken(types, goods.get("cargoType") == null ? null : String.valueOf(goods.get("cargoType")), false);
|
||||
}
|
||||
} catch (RuntimeException ignored) {
|
||||
// 货物清单异常时保留明细上已有的货物名称。
|
||||
}
|
||||
}
|
||||
|
||||
private void addCargoToken(Set<String> target, String raw, boolean splitJoined) {
|
||||
if (Func.isEmpty(raw)) {
|
||||
return;
|
||||
}
|
||||
String[] parts = splitJoined ? raw.split("[,,]") : new String[]{raw};
|
||||
for (String part : parts) {
|
||||
String token = part.trim();
|
||||
if (token.isEmpty() || "手工调差".equals(token)) {
|
||||
continue;
|
||||
}
|
||||
target.add(token);
|
||||
}
|
||||
}
|
||||
|
||||
private String joinCargo(Set<String> values) {
|
||||
String joined = String.join(",", values);
|
||||
return joined.length() > 500 ? joined.substring(0, 500) : joined;
|
||||
}
|
||||
|
||||
private void copyPreDetailFees(PreSettlementDetail source, FormalSettlementDetail target) {
|
||||
@@ -1718,9 +2076,16 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
}
|
||||
|
||||
private FormalSettlementVO toVO(FormalSettlement entity) {
|
||||
return this.toVO(entity, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param writeOffAmounts 结算单id -> 项目预付核销合计(列表页批量传入避免 N+1;null 表示按需单笔查询)
|
||||
*/
|
||||
private FormalSettlementVO toVO(FormalSettlement entity, Map<Long, BigDecimal> writeOffAmounts) {
|
||||
FormalSettlementVO vo = FormalSettlementWrapper.build().entityVO(entity);
|
||||
vo.setInvoiceStatus(invoiceStatus(entity.getInvoiceAmount(), entity.getSettlementAmount()));
|
||||
PaymentSummary summary = calculatePaymentSummary(entity);
|
||||
PaymentSummary summary = this.calculatePaymentSummary(entity, writeOffAmounts);
|
||||
vo.setAppliedPaymentAmount(summary.appliedAmount());
|
||||
vo.setPaidAmount(summary.paidAmount());
|
||||
vo.setRemainingPayableAmount(money(entity.getSettlementAmount()).subtract(summary.paidAmount())
|
||||
@@ -1730,6 +2095,10 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
}
|
||||
|
||||
private PaymentSummary calculatePaymentSummary(FormalSettlement settlement) {
|
||||
return this.calculatePaymentSummary(settlement, (Map<Long, BigDecimal>) null);
|
||||
}
|
||||
|
||||
private PaymentSummary calculatePaymentSummary(FormalSettlement settlement, Map<Long, BigDecimal> writeOffAmounts) {
|
||||
List<PaymentApplicationSettlement> relations = paymentApplicationSettlementMapper.selectList(
|
||||
Wrappers.<PaymentApplicationSettlement>lambdaQuery()
|
||||
.eq(PaymentApplicationSettlement::getFormalSettlementId, settlement.getId())
|
||||
@@ -1747,7 +2116,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
.in(PaymentApplication::getId, relationApplicationIds).eq(PaymentApplication::getIsDeleted, 0))
|
||||
.stream().collect(Collectors.toMap(PaymentApplication::getId, PaymentApplication::getApprovalStatus));
|
||||
return calculatePaymentSummary(settlement, new PaymentSummaryData(relations, applications,
|
||||
relationStatuses, null, null));
|
||||
relationStatuses, null, null, writeOffAmounts));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1780,13 +2149,21 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
.map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||
paidAmount = paidAmount.add(sources.stream().map(FormalSettlementSource::getAdvancePaidAmount)
|
||||
.map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||
// 项目预付核销计入已付/已申请(核销行底层预付必已付款,核销额即实付抵扣)
|
||||
BigDecimal writeOffAmount = data.writeOffAmounts() != null
|
||||
? data.writeOffAmounts().getOrDefault(settlement.getId(), BigDecimal.ZERO)
|
||||
: this.projectAdvanceWriteOffService.sumBySettlement(
|
||||
TransportBusiConstant.SETTLEMENT_BILL_FORMAL, settlement.getId());
|
||||
appliedAmount = appliedAmount.add(this.money(writeOffAmount));
|
||||
paidAmount = paidAmount.add(this.money(writeOffAmount));
|
||||
}
|
||||
if ("receivable".equals(settlement.getSettlementType())) {
|
||||
paidAmount = data.receiptClaimPaid() != null ? data.receiptClaimPaid()
|
||||
: receiptClaimSettlementMapper.selectList(
|
||||
Wrappers.<ReceiptClaimSettlement>lambdaQuery()
|
||||
.eq(ReceiptClaimSettlement::getFormalSettlementId, settlement.getId())
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1))
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)
|
||||
.eq(ReceiptClaimSettlement::getIsDeleted, 0))
|
||||
.stream().map(ReceiptClaimSettlement::getAllocatedReceiptAmount).map(this::money)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
}
|
||||
@@ -1815,22 +2192,91 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
* @param relationStatuses 付款申请id → 审批状态
|
||||
* @param sources 预收来源行(仅应付结算单;null 表示由纯计算按需查询)
|
||||
* @param receiptClaimPaid 收款认领分摊聚合(仅应收结算单;null 表示由纯计算按需查询)
|
||||
* @param writeOffAmounts 结算单id → 项目预付核销合计(仅应付结算单;null 表示由纯计算按需查询)
|
||||
*/
|
||||
private record PaymentSummaryData(List<PaymentApplicationSettlement> relations,
|
||||
List<PaymentApplication> directApplications, Map<Long, String> relationStatuses,
|
||||
List<FormalSettlementSource> sources, BigDecimal receiptClaimPaid) {
|
||||
List<FormalSettlementSource> sources, BigDecimal receiptClaimPaid,
|
||||
Map<Long, BigDecimal> writeOffAmounts) {
|
||||
}
|
||||
|
||||
private SettlementAdjustmentVO toAdjustmentVO(SettlementAdjustment entity, String kingdeeBillNo) {
|
||||
private SettlementAdjustmentVO toAdjustmentVO(SettlementAdjustment entity) {
|
||||
SettlementAdjustmentVO vo = Objects.requireNonNull(
|
||||
BeanUtil.copyProperties(entity, SettlementAdjustmentVO.class));
|
||||
vo.setCreateUserName(UserCache.getUserRealName(entity.getCreateUser()));
|
||||
vo.setApprovalStatusName(approvalStatusName(entity.getApprovalStatus()));
|
||||
vo.setSettlementTypeName("receivable".equals(entity.getSettlementType()) ? "应收" : "应付");
|
||||
vo.setKingdeeBillNo(kingdeeBillNo);
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一预付付款记录:进度预付(来源预结算台账)+ 项目预付核销(回填底层付款申请实付)。
|
||||
* createTime 统一取底层付款申请创建时间(项目预付核销行已回填 paymentCreateTime,进度预付按单号反查)。
|
||||
*/
|
||||
private List<SettlementAdvancePaymentVO> listAdvancePayments(List<FormalSettlementSource> sources,
|
||||
List<SettlementProjectAdvanceWriteOff> writeOffs) {
|
||||
List<SettlementAdvancePaymentVO> records = new ArrayList<>();
|
||||
List<Long> preSettlementIds = sources.stream().map(FormalSettlementSource::getPreSettlementId)
|
||||
.filter(Objects::nonNull).distinct().toList();
|
||||
if (!preSettlementIds.isEmpty()) {
|
||||
List<PreSettlementAdvance> advances = this.preSettlementAdvanceMapper.selectList(
|
||||
Wrappers.<PreSettlementAdvance>lambdaQuery()
|
||||
.eq(PreSettlementAdvance::getIsDeleted, 0)
|
||||
.ne(PreSettlementAdvance::getBillStatus, TransportBusiConstant.VOIDED)
|
||||
.in(PreSettlementAdvance::getPreSettlementId, preSettlementIds));
|
||||
// 进度预付行的展示时间取底层付款申请创建时间(advanceNo 即付款申请单号)
|
||||
List<String> advanceNos = advances.stream().map(PreSettlementAdvance::getAdvanceNo)
|
||||
.filter(Func::isNotEmpty).distinct().toList();
|
||||
Map<String, Date> applicationTimes = advanceNos.isEmpty() ? Map.of()
|
||||
: this.paymentApplicationMapper.selectList(Wrappers.<PaymentApplication>lambdaQuery()
|
||||
.select(PaymentApplication::getPaymentNo, PaymentApplication::getCreateTime)
|
||||
.eq(PaymentApplication::getIsDeleted, 0)
|
||||
.in(PaymentApplication::getPaymentNo, advanceNos))
|
||||
.stream().filter(item -> item.getCreateTime() != null)
|
||||
.collect(Collectors.toMap(PaymentApplication::getPaymentNo,
|
||||
PaymentApplication::getCreateTime, (first, second) -> first));
|
||||
for (PreSettlementAdvance advance : advances) {
|
||||
SettlementAdvancePaymentVO record = new SettlementAdvancePaymentVO();
|
||||
record.setRecordType(TransportBusiConstant.PAYMENT_TYPE_PROGRESS_ADVANCE);
|
||||
record.setPaymentNo(advance.getAdvanceNo());
|
||||
record.setAppliedAmount(advance.getAppliedAmount());
|
||||
record.setPaidAmount(advance.getPaidAmount());
|
||||
record.setBillStatus(advance.getBillStatus());
|
||||
record.setKingdeeBillNo(advance.getKingdeeAdvanceNo());
|
||||
record.setCreateUserName(UserCache.getUserRealName(advance.getCreateUser()));
|
||||
record.setCreateTime(applicationTimes.getOrDefault(advance.getAdvanceNo(), advance.getCreateTime()));
|
||||
records.add(record);
|
||||
}
|
||||
}
|
||||
if (Func.isNotEmpty(writeOffs)) {
|
||||
List<Long> applicationIds = writeOffs.stream()
|
||||
.map(SettlementProjectAdvanceWriteOff::getPaymentApplicationId)
|
||||
.filter(Objects::nonNull).distinct().toList();
|
||||
Map<Long, PaymentApplication> applications = applicationIds.isEmpty() ? Map.of()
|
||||
: this.paymentApplicationMapper.selectBatchIds(applicationIds).stream()
|
||||
.collect(Collectors.toMap(PaymentApplication::getId, Function.identity(),
|
||||
(first, second) -> first));
|
||||
for (SettlementProjectAdvanceWriteOff writeOff : writeOffs) {
|
||||
PaymentApplication application = applications.get(writeOff.getPaymentApplicationId());
|
||||
SettlementAdvancePaymentVO record = new SettlementAdvancePaymentVO();
|
||||
record.setRecordType(TransportBusiConstant.PAYMENT_TYPE_PROJECT_ADVANCE);
|
||||
record.setPaymentNo(writeOff.getPaymentNo());
|
||||
record.setAppliedAmount(writeOff.getWriteOffAmount());
|
||||
record.setPaidAmount(application == null ? null : application.getPaidAmount());
|
||||
record.setBillStatus(application == null ? null : application.getApprovalStatus());
|
||||
record.setKingdeeBillNo(writeOff.getKingdeeBillNo());
|
||||
record.setCreateUserName(UserCache.getUserRealName(application == null
|
||||
? writeOff.getCreateUser() : application.getCreateUser()));
|
||||
record.setCreateTime(writeOff.getPaymentCreateTime() != null
|
||||
? writeOff.getPaymentCreateTime() : writeOff.getCreateTime());
|
||||
records.add(record);
|
||||
}
|
||||
}
|
||||
records.sort(Comparator.comparing(SettlementAdvancePaymentVO::getCreateTime,
|
||||
Comparator.nullsLast(Comparator.reverseOrder())));
|
||||
return records;
|
||||
}
|
||||
|
||||
private String approvalStatusName(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
@@ -1868,6 +2314,14 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
return entity;
|
||||
}
|
||||
|
||||
/** 行锁读:同一单据并发推送串行化(锁随 doSyncKingdee 事务提交释放,后者读到已同步状态幂等返回) */
|
||||
private FormalSettlement lockedExisting(Long id) {
|
||||
FormalSettlement entity = baseMapper.selectOne(Wrappers.<FormalSettlement>lambdaQuery()
|
||||
.eq(FormalSettlement::getId, id).last("FOR UPDATE"));
|
||||
if (entity == null || Objects.equals(entity.getIsDeleted(), 1)) throw new ServiceException("正式结算单不存在");
|
||||
return entity;
|
||||
}
|
||||
|
||||
private FormalSettlement editable(Long id) {
|
||||
FormalSettlement entity = existing(id);
|
||||
if (!TransportBusiConstant.DRAFT.equals(entity.getApprovalStatus()) && !TransportBusiConstant.RETURNED.equals(entity.getApprovalStatus())) throw new ServiceException("仅草稿或已驳回的正式结算单允许编辑");
|
||||
@@ -1883,14 +2337,77 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
saveChange(settlement.getId(), "结算单基本信息", null, "调整", node, reason);
|
||||
}
|
||||
|
||||
private void saveManualSummaryFeeChange(Long settlementId, boolean creating, String beforeFeeType,
|
||||
String beforeFeeItem, BigDecimal beforeAdjustAmount, BigDecimal beforeSettlementAmount, String beforeRemark,
|
||||
FormalSettlementSummaryFee row) {
|
||||
List<String> changes = new java.util.ArrayList<>();
|
||||
Map<String, Object> beforeData = new LinkedHashMap<>();
|
||||
Map<String, Object> afterData = new LinkedHashMap<>();
|
||||
if (creating) {
|
||||
changes.add("新增合计费用项【" + row.getFeeItem() + "】金额【" + money(row.getSettlementAmount()) + "】");
|
||||
beforeData.put("费用类型", "");
|
||||
beforeData.put("费用项", "");
|
||||
beforeData.put("调整金额", BigDecimal.ZERO.setScale(2));
|
||||
beforeData.put("结算金额", BigDecimal.ZERO.setScale(2));
|
||||
beforeData.put("备注", "");
|
||||
afterData.put("费用类型", row.getFeeType());
|
||||
afterData.put("费用项", row.getFeeItem());
|
||||
afterData.put("调整金额", money(row.getAdjustAmount()));
|
||||
afterData.put("结算金额", money(row.getSettlementAmount()));
|
||||
afterData.put("备注", row.getRemark());
|
||||
saveChange(settlementId, "合计费用项", row.getLineNo(), "新增", String.join(";", changes), "",
|
||||
beforeData, afterData);
|
||||
return;
|
||||
}
|
||||
appendTextChange(changes, beforeData, afterData, "费用类型", beforeFeeType, row.getFeeType());
|
||||
appendTextChange(changes, beforeData, afterData, "费用项", beforeFeeItem, row.getFeeItem());
|
||||
appendChange(changes, beforeData, afterData, "调整金额", beforeAdjustAmount, row.getAdjustAmount());
|
||||
appendChange(changes, beforeData, afterData, "结算金额", beforeSettlementAmount, row.getSettlementAmount());
|
||||
appendTextChange(changes, beforeData, afterData, "备注", beforeRemark, row.getRemark());
|
||||
if (changes.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
saveChange(settlementId, "合计费用项", row.getLineNo(), "调整", String.join(";", changes), "",
|
||||
beforeData, afterData);
|
||||
}
|
||||
|
||||
private void appendChange(List<String> changes, Map<String, Object> beforeData, Map<String, Object> afterData,
|
||||
String fieldName, BigDecimal before, BigDecimal after) {
|
||||
BigDecimal oldValue = money(before);
|
||||
BigDecimal newValue = money(after);
|
||||
if (oldValue.compareTo(newValue) != 0) {
|
||||
changes.add("【" + fieldName + "】从【" + oldValue + "】调整为【" + newValue + "】");
|
||||
beforeData.put(fieldName, oldValue);
|
||||
afterData.put(fieldName, newValue);
|
||||
}
|
||||
}
|
||||
|
||||
private void appendTextChange(List<String> changes, Map<String, Object> beforeData, Map<String, Object> afterData,
|
||||
String fieldName, String before, String after) {
|
||||
String oldValue = before == null ? "" : before;
|
||||
String newValue = after == null ? "" : after;
|
||||
if (!Objects.equals(oldValue, newValue)) {
|
||||
changes.add("【" + fieldName + "】从【" + oldValue + "】调整为【" + newValue + "】");
|
||||
beforeData.put(fieldName, oldValue);
|
||||
afterData.put(fieldName, newValue);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveChange(Long settlementId, String changeType, Integer lineNo, String operationType,
|
||||
String content, String reason) {
|
||||
saveChange(settlementId, changeType, lineNo, operationType, content, reason, null, null);
|
||||
}
|
||||
|
||||
private void saveChange(Long settlementId, String changeType, Integer lineNo, String operationType,
|
||||
String content, String reason, Map<String, Object> beforeData, Map<String, Object> afterData) {
|
||||
FormalSettlementChangeRecord record = new FormalSettlementChangeRecord();
|
||||
record.setFormalSettlementId(settlementId);
|
||||
record.setChangeType(changeType);
|
||||
record.setLineNo(lineNo);
|
||||
record.setOperationType(operationType);
|
||||
record.setChangeContent(content);
|
||||
record.setBeforeData(beforeData == null ? null : JsonUtil.toJson(beforeData));
|
||||
record.setAfterData(afterData == null ? null : JsonUtil.toJson(afterData));
|
||||
record.setChangeReason(reason);
|
||||
record.setOperatorName(Func.isEmpty(AuthUtil.getUserName()) ? "system" : AuthUtil.getUserName());
|
||||
record.setChangeTime(LocalDateTime.now());
|
||||
@@ -1913,11 +2430,11 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
target.setCurrency(Func.isEmpty(source.getCurrency()) ? "RMB" : source.getCurrency()); target.setLocalCurrency("RMB");
|
||||
}
|
||||
|
||||
/** 生成当日正式结算付款单号;按最大流水号 +1,且计入逻辑删除数据 */
|
||||
private synchronized String nextPaymentNo() {
|
||||
String prefix = "FK" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
|
||||
long count = paymentMapper.selectCount(Wrappers.<FormalSettlementPayment>lambdaQuery()
|
||||
.likeRight(FormalSettlementPayment::getPaymentNo, prefix));
|
||||
return prefix + String.format("%04d", count + 1);
|
||||
Long maxSequence = paymentMapper.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).setScale(2, RoundingMode.HALF_UP); }
|
||||
|
||||
+25
-16
@@ -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;
|
||||
@@ -97,6 +95,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;
|
||||
@@ -700,7 +699,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())
|
||||
@@ -802,10 +801,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("推送金蝶开票申请失败:受票方客户未在客商档案中维护客商编码,请补充后重试");
|
||||
@@ -836,7 +835,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);
|
||||
@@ -885,17 +884,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
|
||||
@@ -1665,6 +1665,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("当前状态不可编辑");
|
||||
@@ -1675,10 +1683,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
@@ -63,6 +63,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;
|
||||
@@ -70,6 +71,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;
|
||||
@@ -88,6 +90,7 @@ import org.springblade.transport.wrapper.InvoiceReceiptWrapper;
|
||||
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;
|
||||
@@ -146,8 +149,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;
|
||||
@@ -243,9 +249,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());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -604,12 +611,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 + " 笔";
|
||||
@@ -670,7 +681,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())) {
|
||||
@@ -761,6 +786,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 缺失跳过待补传;异常独立捕获,绝不影响推送状态与整批进度
|
||||
@@ -1248,20 +1292,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);
|
||||
@@ -1284,6 +1333,8 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|
||||
private static final class SyncStats {
|
||||
/** A801-1 原始拉取张数 */
|
||||
int pulledCount;
|
||||
/** 客商单位过滤剔除张数(销/购方税号未同时登记在客商发票信息中,开关开启时统计) */
|
||||
int customerFiltered;
|
||||
/** 镜像新增条数(票池有、本地无) */
|
||||
int created;
|
||||
/** 镜像有变化并重写条数 */
|
||||
@@ -1307,11 +1358,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);
|
||||
@@ -1325,6 +1378,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();
|
||||
@@ -1336,8 +1396,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;
|
||||
}
|
||||
@@ -1355,13 +1415,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) {
|
||||
}
|
||||
|
||||
+27
-2
@@ -251,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()
|
||||
@@ -309,8 +331,11 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl<KingdeeBillLockMappe
|
||||
}
|
||||
row.setKingdeeUnlockTime(unlockRecord.getUnlockTime());
|
||||
row.setKingdeeUnlockOperator(unlockRecord.getNotifyOperator());
|
||||
row.setKingdeeUnlockOperationName(KingdeeLockConstant.OPERATION_DELETE.equals(unlockRecord.getOperationType())
|
||||
? "金蝶删单" : "金蝶锁单");
|
||||
row.setKingdeeUnlockOperationName(switch (Func.toStr(unlockRecord.getOperationType())) {
|
||||
case KingdeeLockConstant.OPERATION_DELETE -> "金蝶删单";
|
||||
case KingdeeLockConstant.OPERATION_ADJUST_REPUSH -> "结算调整重推";
|
||||
default -> "金蝶锁单";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+759
-71
File diff suppressed because it is too large
Load Diff
+227
-54
@@ -85,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;
|
||||
@@ -166,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("费用项基础档案读取失败,请稍后重试");
|
||||
}
|
||||
@@ -187,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<>()
|
||||
@@ -307,7 +307,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;
|
||||
}
|
||||
|
||||
@@ -461,6 +463,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);
|
||||
}
|
||||
@@ -616,7 +621,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()) {
|
||||
@@ -636,7 +643,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(), "里程"));
|
||||
@@ -649,7 +655,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());
|
||||
@@ -664,16 +669,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
|
||||
@@ -752,6 +760,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());
|
||||
@@ -765,6 +774,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());
|
||||
@@ -1001,13 +1039,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] : "");
|
||||
// 原金额是明细首次生成时的快照,多次调整后不得被当前结算金额覆盖。
|
||||
@@ -1023,7 +1078,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()) {
|
||||
@@ -1043,6 +1105,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(), "费用类型");
|
||||
@@ -1059,11 +1122,20 @@ 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;
|
||||
String beforeFeeType = creating ? null : row.getFeeType();
|
||||
String beforeFeeItem = creating ? null : row.getFeeItem();
|
||||
BigDecimal beforeAdjustAmount = creating ? null : money(row.getAdjustAmount());
|
||||
BigDecimal beforeSettlementAmount = creating ? null : money(row.getSettlementAmount());
|
||||
String beforeRemark = creating ? null : row.getRemark();
|
||||
row.setPreSettlementId(settlementId);
|
||||
row.setLineNo(lineNo++);
|
||||
row.setFeeType(feeType);
|
||||
row.setFeeItem(feeItem);
|
||||
row.setOriginalAmount(BigDecimal.ZERO.setScale(2));
|
||||
@@ -1071,10 +1143,14 @@ 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(), "");
|
||||
saveManualSummaryFeeChange(settlementId, creating, beforeFeeType, beforeFeeItem,
|
||||
beforeAdjustAmount, beforeSettlementAmount, beforeRemark, row);
|
||||
continue;
|
||||
}
|
||||
String feeItem = requiredText(requestRow.getFeeItem(), "费用项");
|
||||
@@ -1094,6 +1170,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
row.setPreSettlementId(settlementId);
|
||||
row.setManualFlag(0);
|
||||
}
|
||||
row.setLineNo(lineNo++);
|
||||
row.setFeeType(feeType);
|
||||
row.setFeeItem(feeItem);
|
||||
// 生成费用行的三个金额必须来源于结算明细快照,不能接受前端回传值覆盖原金额。
|
||||
@@ -1106,15 +1183,37 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
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() + "】", "");
|
||||
Map<String, Object> beforeData = new LinkedHashMap<>();
|
||||
Map<String, Object> afterData = new LinkedHashMap<>();
|
||||
beforeData.put("费用项", row.getFeeItem());
|
||||
beforeData.put("调整金额", before);
|
||||
beforeData.put("结算金额", money(row.getOriginalAmount().add(before)));
|
||||
afterData.put("费用项", row.getFeeItem());
|
||||
afterData.put("调整金额", row.getAdjustAmount());
|
||||
afterData.put("结算金额", money(row.getSettlementAmount()));
|
||||
saveChange(settlementId, "合计费用项", row.getLineNo(), "调整",
|
||||
"【" + row.getFeeItem() + "-调整金额】从【" + before + "】调整为【" + row.getAdjustAmount() + "】",
|
||||
"", beforeData, afterData);
|
||||
}
|
||||
}
|
||||
for (PreSettlementSummaryFee manualRow : manualRows) {
|
||||
if (!retainedManualIds.contains(manualRow.getId())) {
|
||||
summaryFeeMapper.deleteById(manualRow.getId());
|
||||
saveChange(settlementId, "合计费用项", null, "删除",
|
||||
"删除" + manualRow.getFeeItem() + "费用" + manualRow.getSettlementAmount(), "");
|
||||
Map<String, Object> beforeData = new LinkedHashMap<>();
|
||||
Map<String, Object> afterData = new LinkedHashMap<>();
|
||||
beforeData.put("费用类型", manualRow.getFeeType());
|
||||
beforeData.put("费用项", manualRow.getFeeItem());
|
||||
beforeData.put("调整金额", money(manualRow.getAdjustAmount()));
|
||||
beforeData.put("结算金额", money(manualRow.getSettlementAmount()));
|
||||
beforeData.put("备注", manualRow.getRemark());
|
||||
afterData.put("费用类型", "");
|
||||
afterData.put("费用项", "");
|
||||
afterData.put("调整金额", BigDecimal.ZERO.setScale(2));
|
||||
afterData.put("结算金额", BigDecimal.ZERO.setScale(2));
|
||||
afterData.put("备注", "");
|
||||
saveChange(settlementId, "合计费用项", manualRow.getLineNo(), "删除",
|
||||
"删除合计费用项【" + manualRow.getFeeItem() + "】金额【" + money(manualRow.getSettlementAmount()) + "】",
|
||||
"", beforeData, afterData);
|
||||
}
|
||||
}
|
||||
for (PreSettlementSummaryFee generatedRow : existingGeneratedRows) {
|
||||
@@ -1122,32 +1221,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);
|
||||
@@ -1366,6 +1496,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)
|
||||
@@ -1405,6 +1563,40 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
}
|
||||
}
|
||||
|
||||
private void saveManualSummaryFeeChange(Long settlementId, boolean creating, String beforeFeeType,
|
||||
String beforeFeeItem, BigDecimal beforeAdjustAmount, BigDecimal beforeSettlementAmount, String beforeRemark,
|
||||
PreSettlementSummaryFee row) {
|
||||
List<String> changes = new ArrayList<>();
|
||||
Map<String, Object> beforeData = new LinkedHashMap<>();
|
||||
Map<String, Object> afterData = new LinkedHashMap<>();
|
||||
if (creating) {
|
||||
changes.add("新增合计费用项【" + row.getFeeItem() + "】金额【" + money(row.getSettlementAmount()) + "】");
|
||||
beforeData.put("费用类型", "");
|
||||
beforeData.put("费用项", "");
|
||||
beforeData.put("调整金额", BigDecimal.ZERO.setScale(2));
|
||||
beforeData.put("结算金额", BigDecimal.ZERO.setScale(2));
|
||||
beforeData.put("备注", "");
|
||||
afterData.put("费用类型", row.getFeeType());
|
||||
afterData.put("费用项", row.getFeeItem());
|
||||
afterData.put("调整金额", money(row.getAdjustAmount()));
|
||||
afterData.put("结算金额", money(row.getSettlementAmount()));
|
||||
afterData.put("备注", row.getRemark());
|
||||
saveChange(settlementId, "合计费用项", row.getLineNo(), "新增", String.join(";", changes), "",
|
||||
beforeData, afterData);
|
||||
return;
|
||||
}
|
||||
appendTextChange(changes, beforeData, afterData, "费用类型", beforeFeeType, row.getFeeType());
|
||||
appendTextChange(changes, beforeData, afterData, "费用项", beforeFeeItem, row.getFeeItem());
|
||||
appendChange(changes, beforeData, afterData, "调整金额", beforeAdjustAmount, row.getAdjustAmount());
|
||||
appendChange(changes, beforeData, afterData, "结算金额", beforeSettlementAmount, row.getSettlementAmount());
|
||||
appendTextChange(changes, beforeData, afterData, "备注", beforeRemark, row.getRemark());
|
||||
if (changes.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
saveChange(settlementId, "合计费用项", row.getLineNo(), "调整", String.join(";", changes), "",
|
||||
beforeData, afterData);
|
||||
}
|
||||
|
||||
private void saveChange(Long settlementId, String changeType, Integer lineNo, String operationType,
|
||||
String content, String reason) {
|
||||
saveChange(settlementId, changeType, lineNo, operationType, content, reason, null, null);
|
||||
@@ -1619,44 +1811,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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+15
-5
@@ -82,6 +82,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";
|
||||
@@ -206,7 +207,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());
|
||||
@@ -244,7 +245,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);
|
||||
@@ -260,6 +261,7 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
|
||||
// 变更草稿仅更新业务字段,不生成变更记录;变更记录在 submitChange 时落库
|
||||
ProjectApply oldRecord = loadChangeable(projectApply.getId());
|
||||
prepare(projectApply);
|
||||
normalizeUndertakeDept(projectApply);
|
||||
copyChangeFields(oldRecord, projectApply);
|
||||
validateSubmit(oldRecord);
|
||||
validateChangeLength(oldRecord);
|
||||
@@ -272,6 +274,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());
|
||||
@@ -379,13 +382,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)));
|
||||
}
|
||||
@@ -559,6 +562,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());
|
||||
@@ -875,7 +883,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("仅正式生效且审批通过的项目允许发起变更");
|
||||
}
|
||||
}
|
||||
|
||||
+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) {
|
||||
|
||||
+234
-40
@@ -607,14 +607,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 +686,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 +850,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);
|
||||
@@ -1064,11 +1084,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);
|
||||
}
|
||||
@@ -1118,22 +1147,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;
|
||||
@@ -1142,13 +1203,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)
|
||||
@@ -1201,12 +1287,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,
|
||||
@@ -1635,9 +1733,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());
|
||||
@@ -1652,8 +1748,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()));
|
||||
@@ -1668,6 +1764,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 只落了首行。
|
||||
*/
|
||||
@@ -2434,10 +2545,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()) {
|
||||
@@ -2453,26 +2570,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,
|
||||
@@ -2545,7 +2730,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) {
|
||||
@@ -2884,21 +3070,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));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+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; }
|
||||
|
||||
+305
-32
@@ -32,7 +32,6 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springblade.common.excel.ImportFailureExcelUtil;
|
||||
import org.springblade.core.excel.util.ExcelUtil;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.mp.base.BaseServiceImpl;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
@@ -41,12 +40,16 @@ import org.springblade.core.tool.jackson.JsonUtil;
|
||||
import org.springblade.core.tool.utils.BeanUtil;
|
||||
import org.springblade.core.tool.utils.DateUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.cache.DictBizCache;
|
||||
import org.springblade.system.cache.DictCache;
|
||||
import org.springblade.system.cache.UserCache;
|
||||
import org.springblade.system.feign.ISysClient;
|
||||
import org.springblade.system.pojo.entity.DictBiz;
|
||||
import org.springblade.system.pojo.entity.FeeItem;
|
||||
import org.springblade.transport.constant.TransportBusiConstant;
|
||||
import org.springblade.transport.excel.SettlementImportFeeReader;
|
||||
import org.springblade.transport.excel.SettlementImportTemplateExcel;
|
||||
import org.springblade.transport.excel.SettlementImportTemplateExporter;
|
||||
import org.springblade.transport.mapper.SettlementImportBatchMapper;
|
||||
import org.springblade.transport.pojo.dto.FormalSettlementSaveRequest;
|
||||
import org.springblade.transport.pojo.dto.FormalSettlementStatusRequest;
|
||||
@@ -112,6 +115,13 @@ public class SettlementImportBatchServiceImpl
|
||||
private static final String TYPE_PAYABLE = "payable";
|
||||
private static final String BILL_TYPE_FORMAL = "formal";
|
||||
private static final String BILL_TYPE_PRE = "pre";
|
||||
private static final String[] VEHICLE_ALIAS_KEYS = {
|
||||
"*车牌号/航班号/船号/班列号",
|
||||
"车牌号/航班号/船号/班列号",
|
||||
"*车牌号/船班号/船次号/班次号",
|
||||
"车牌号/船班号/船次号/班次号",
|
||||
"车号"
|
||||
};
|
||||
|
||||
private final IProjectApplyService projectApplyService;
|
||||
private final IContractManageService contractManageService;
|
||||
@@ -186,6 +196,7 @@ public class SettlementImportBatchServiceImpl
|
||||
.eq(SettlementImportBatch::getIsDeleted, 0)
|
||||
.like(Func.isNotEmpty(request.getBatchNo()), SettlementImportBatch::getBatchNo, request.getBatchNo())
|
||||
.eq(Func.isNotEmpty(request.getSettlementType()), SettlementImportBatch::getSettlementType, request.getSettlementType())
|
||||
.eq(Func.isNotEmpty(request.getImportStatus()), SettlementImportBatch::getImportStatus, request.getImportStatus())
|
||||
.like(Func.isNotEmpty(request.getProjectName()), SettlementImportBatch::getProjectName, request.getProjectName())
|
||||
.like(Func.isNotEmpty(request.getCustomerName()), SettlementImportBatch::getCustomerName, request.getCustomerName())
|
||||
.like(Func.isNotEmpty(request.getCarrierName()), SettlementImportBatch::getCarrierName, request.getCarrierName())
|
||||
@@ -204,13 +215,18 @@ public class SettlementImportBatchServiceImpl
|
||||
if (batch == null || Objects.equals(batch.getIsDeleted(), 1)) {
|
||||
throw new ServiceException("导入结算单批次不存在");
|
||||
}
|
||||
return toVO(batch);
|
||||
SettlementImportBatchVO vo = toVO(batch);
|
||||
List<Map<String, Object>> rows = parseRowsJson(batch.getRowsJson());
|
||||
if (Func.isEmpty(rows) && !STATUS_DRAFT.equals(batch.getImportStatus())) {
|
||||
rows = buildRowsFromWaybills(listBatchWaybills(batch));
|
||||
}
|
||||
vo.setRows(rows);
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void exportTemplate(HttpServletResponse response) {
|
||||
List<SettlementImportTemplateExcel> sample = new ArrayList<>();
|
||||
ExcelUtil.export(response, "导入结算单模板", "导入结算单模板", sample, SettlementImportTemplateExcel.class);
|
||||
SettlementImportTemplateExporter.export(response, loadTransportTypeLabels(), loadEnabledFeeItemNames());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -222,6 +238,7 @@ public class SettlementImportBatchServiceImpl
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("rows", result.getRows());
|
||||
data.put("feeItemNames", result.getFeeItemNames());
|
||||
data.put("headers", result.getHeaders());
|
||||
data.put("warnings", result.getWarnings());
|
||||
return data;
|
||||
}
|
||||
@@ -254,6 +271,7 @@ public class SettlementImportBatchServiceImpl
|
||||
}
|
||||
List<SettlementImportTemplateExcel> failureList = new ArrayList<>();
|
||||
for (Map<String, Object> row : request.getRows()) {
|
||||
keepBlankVehicleNo(row);
|
||||
SettlementImportTemplateExcel excel = mapToExcel(row);
|
||||
excel.setErrorMessage(stringValue(row, "errorMessage", "导入失败原因", "失败原因"));
|
||||
failureList.add(excel);
|
||||
@@ -270,6 +288,7 @@ public class SettlementImportBatchServiceImpl
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public SettlementImportBatchVO saveDraft(SettlementImportBatchRequest request) {
|
||||
prepareSettlementRows(request);
|
||||
SettlementImportBatch batch = buildDraftBatch(request);
|
||||
if (Func.isNotEmpty(request.getId())) {
|
||||
SettlementImportBatch oldBatch = getById(request.getId());
|
||||
@@ -282,10 +301,14 @@ public class SettlementImportBatchServiceImpl
|
||||
batch.setId(oldBatch.getId());
|
||||
batch.setBatchNo(oldBatch.getBatchNo());
|
||||
}
|
||||
batch.setWaybillCount(0);
|
||||
batch.setDetailCount(0);
|
||||
List<Map<String, Object>> draftRows = sanitizeRowsForStorage(request.getRows());
|
||||
batch.setRowsJson(JsonUtil.toJson(draftRows));
|
||||
batch.setWaybillCount(primaryRows(draftRows).size());
|
||||
batch.setDetailCount(draftRows.size());
|
||||
saveOrUpdate(batch);
|
||||
return toVO(batch);
|
||||
SettlementImportBatchVO vo = toVO(batch);
|
||||
vo.setRows(draftRows);
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -399,8 +422,73 @@ public class SettlementImportBatchServiceImpl
|
||||
}
|
||||
batch.setImportStatus(STATUS_COMPLETED);
|
||||
batch.setFeeItemNamesJson(JsonUtil.toJson(resolveFeeItemNames(request)));
|
||||
batch.setRowsJson(JsonUtil.toJson(sanitizeRowsForStorage(request.getRows())));
|
||||
updateById(batch);
|
||||
return toVO(batch);
|
||||
SettlementImportBatchVO vo = toVO(batch);
|
||||
vo.setRows(parseRowsJson(batch.getRowsJson()));
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void removeDraft(Long id) {
|
||||
if (id == null) {
|
||||
throw new ServiceException("请选择需要删除的批次");
|
||||
}
|
||||
SettlementImportBatch batch = getById(id);
|
||||
if (batch == null || Objects.equals(batch.getIsDeleted(), 1)) {
|
||||
throw new ServiceException("导入结算单批次不存在");
|
||||
}
|
||||
if (!STATUS_DRAFT.equals(batch.getImportStatus())) {
|
||||
throw new ServiceException("仅草稿状态的批次允许删除");
|
||||
}
|
||||
deleteLogic(List.of(id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> historyWaybills(SettlementImportBatchRequest request) {
|
||||
if (request == null || Func.isEmpty(request.getProjectId())
|
||||
|| Func.isEmpty(request.getCustomerContractId())
|
||||
|| Func.isEmpty(request.getCarrierContractId())) {
|
||||
throw new ServiceException("请先选择项目、客户合同和承运商合同");
|
||||
}
|
||||
String settlementType = Func.isEmpty(request.getSettlementType())
|
||||
? TYPE_RECEIVABLE : request.getSettlementType();
|
||||
if (!TYPE_RECEIVABLE.equals(settlementType) && !TYPE_PAYABLE.equals(settlementType)) {
|
||||
throw new ServiceException("结算类型不正确");
|
||||
}
|
||||
LambdaQueryWrapper<Waybill> wrapper = Wrappers.<Waybill>lambdaQuery()
|
||||
.eq(Waybill::getIsDeleted, 0)
|
||||
.eq(Waybill::getProjectId, request.getProjectId())
|
||||
.eq(Waybill::getContractId, request.getCustomerContractId())
|
||||
.eq(Waybill::getCarrierContractId, request.getCarrierContractId())
|
||||
.apply("not exists (select 1 from blade_receivable_payable_detail d"
|
||||
+ " where d.waybill_id = blade_waybill.id and d.settlement_type = {0}"
|
||||
+ " and d.is_deleted = 0)", settlementType)
|
||||
.orderByDesc(Waybill::getCreateTime)
|
||||
.last("limit 500");
|
||||
List<Waybill> waybills = waybillService.list(wrapper);
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (Waybill waybill : waybills) {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("id", waybill.getId());
|
||||
item.put("waybillNo", waybill.getWaybillNo());
|
||||
item.put("vehicleNo", waybill.getVehicleNo());
|
||||
item.put("transportType", waybill.getTransportType());
|
||||
item.put("departureAddress", waybill.getDepartureAddress());
|
||||
item.put("arrivalAddress", waybill.getArrivalAddress());
|
||||
item.put("cargoName", waybill.getCargoName());
|
||||
item.put("cargoType", waybill.getCargoType());
|
||||
item.put("quantity", waybill.getQuantity());
|
||||
item.put("quantityUnit", waybill.getQuantityUnit());
|
||||
item.put("model", waybill.getModel());
|
||||
item.put("mileage", waybill.getMileage());
|
||||
item.put("actualStartDate", waybill.getStartDate());
|
||||
item.put("actualEndDate", waybill.getEndDate());
|
||||
item.put("driverName", waybill.getDriverName());
|
||||
result.add(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -608,9 +696,9 @@ public class SettlementImportBatchServiceImpl
|
||||
.eq(Waybill::getEndDate, endDate)
|
||||
.eq(Waybill::getDepartureAddress, departureAddress)
|
||||
.eq(Waybill::getArrivalAddress, arrivalAddress)
|
||||
.eq(Waybill::getCargoName, cargoName)
|
||||
.orderByDesc(Waybill::getCreateTime)
|
||||
.last("limit 3");
|
||||
.eq(Waybill::getCargoName, cargoName);
|
||||
applyUnsettledWaybillFilter(wrapper, request.getSettlementType());
|
||||
wrapper.orderByDesc(Waybill::getCreateTime).last("limit 3");
|
||||
List<Waybill> matches = waybillService.list(wrapper);
|
||||
if (Func.isEmpty(matches)) {
|
||||
throw new ServiceException("未匹配到系统已有运单(项目+车号+运输完成日期+客户+承运商+起运地+目的地+货物名称)");
|
||||
@@ -623,16 +711,17 @@ public class SettlementImportBatchServiceImpl
|
||||
|
||||
/** 手工指定运单号时,在当前项目/客户/承运商范围内精确匹配。 */
|
||||
private Waybill matchHistoryWaybillByNo(SettlementImportBatchRequest request, String waybillNo) {
|
||||
List<Waybill> matches = waybillService.list(Wrappers.<Waybill>lambdaQuery()
|
||||
LambdaQueryWrapper<Waybill> wrapper = Wrappers.<Waybill>lambdaQuery()
|
||||
.eq(Waybill::getIsDeleted, 0)
|
||||
.eq(Waybill::getProjectId, request.getProjectId())
|
||||
.eq(Waybill::getContractId, request.getCustomerContractId())
|
||||
.eq(Waybill::getCarrierContractId, request.getCarrierContractId())
|
||||
.eq(Waybill::getWaybillNo, waybillNo)
|
||||
.orderByDesc(Waybill::getCreateTime)
|
||||
.last("limit 2"));
|
||||
.eq(Waybill::getWaybillNo, waybillNo);
|
||||
applyUnsettledWaybillFilter(wrapper, request.getSettlementType());
|
||||
wrapper.orderByDesc(Waybill::getCreateTime).last("limit 2");
|
||||
List<Waybill> matches = waybillService.list(wrapper);
|
||||
if (Func.isEmpty(matches)) {
|
||||
throw new ServiceException("未匹配到运单号「" + waybillNo + "」对应的系统已有运单");
|
||||
throw new ServiceException("未匹配到运单号「" + waybillNo + "」对应的未结算运单");
|
||||
}
|
||||
if (matches.size() > 1) {
|
||||
throw new ServiceException("运单号「" + waybillNo + "」匹配到多条运单,请核对后重试");
|
||||
@@ -640,6 +729,14 @@ public class SettlementImportBatchServiceImpl
|
||||
return matches.get(0);
|
||||
}
|
||||
|
||||
/** 仅保留尚未生成对应结算类型应收/应付明细的运单。 */
|
||||
private void applyUnsettledWaybillFilter(LambdaQueryWrapper<Waybill> wrapper, String settlementType) {
|
||||
String type = Func.isEmpty(settlementType) ? TYPE_RECEIVABLE : settlementType;
|
||||
wrapper.apply("not exists (select 1 from blade_receivable_payable_detail d"
|
||||
+ " where d.waybill_id = blade_waybill.id and d.settlement_type = {0}"
|
||||
+ " and d.is_deleted = 0)", type);
|
||||
}
|
||||
|
||||
private void prepareSettlementRows(SettlementImportBatchRequest request) {
|
||||
if (request == null || Func.isEmpty(request.getRows())) {
|
||||
return;
|
||||
@@ -649,6 +746,7 @@ public class SettlementImportBatchServiceImpl
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
keepBlankVehicleNo(row);
|
||||
BigDecimal transportFee = decimalValue(row, "transportFee", "*运输费", "运输费", "freight", "运费");
|
||||
if (transportFee == null) {
|
||||
transportFee = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
||||
@@ -656,7 +754,12 @@ public class SettlementImportBatchServiceImpl
|
||||
Map<String, BigDecimal> feeItems = extractFeeItems(row, feeItemNames);
|
||||
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);
|
||||
BigDecimal settlementAmount = decimalValue(row, "settlementAmount", "结算金额");
|
||||
if (settlementAmount == null) {
|
||||
settlementAmount = transportFee.add(feeItemTotal).setScale(2, RoundingMode.HALF_UP);
|
||||
} else {
|
||||
settlementAmount = settlementAmount.setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
row.put("transportFee", transportFee);
|
||||
row.put("feeItems", feeItems);
|
||||
row.put("settlementAmount", settlementAmount);
|
||||
@@ -703,7 +806,9 @@ public class SettlementImportBatchServiceImpl
|
||||
|
||||
private Map<Integer, String> validateSettlementExtras(SettlementImportBatchRequest request) {
|
||||
Map<Integer, String> errorMap = new LinkedHashMap<>();
|
||||
Set<String> feeItemCatalog = loadFeeItemNames();
|
||||
Set<String> enabledFeeItemNames = new HashSet<>();
|
||||
Set<String> disabledFeeItemNames = new HashSet<>();
|
||||
loadFeeItemNames(enabledFeeItemNames, disabledFeeItemNames);
|
||||
List<String> feeItemNames = resolveFeeItemNames(request);
|
||||
List<Map<String, Object>> rows = request.getRows();
|
||||
for (int index = 0; index < rows.size(); index++) {
|
||||
@@ -716,15 +821,21 @@ public class SettlementImportBatchServiceImpl
|
||||
errors.add("*运输费必须大于等于0");
|
||||
}
|
||||
Map<String, BigDecimal> feeItems = extractFeeItems(row, feeItemNames);
|
||||
for (String name : feeItems.keySet()) {
|
||||
if (!feeItemCatalog.contains(name)) {
|
||||
errors.add("费用项「" + name + "」未在费用项主数据中匹配");
|
||||
for (Map.Entry<String, BigDecimal> entry : feeItems.entrySet()) {
|
||||
String name = normalizeFeeName(entry.getKey());
|
||||
BigDecimal amount = entry.getValue() == null ? BigDecimal.ZERO : entry.getValue();
|
||||
// 模板自带的装卸费、过路费等列未填写时金额为 0,不要求费用项主数据存在同名档案
|
||||
if (Func.isEmpty(name) || amount.compareTo(BigDecimal.ZERO) == 0) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for (String name : feeItemNames) {
|
||||
if (Func.isNotEmpty(name) && !feeItemCatalog.contains(name)) {
|
||||
errors.add("费用项「" + name + "」未在费用项主数据中匹配");
|
||||
if (enabledFeeItemNames.contains(name)) {
|
||||
continue;
|
||||
}
|
||||
if (disabledFeeItemNames.contains(name)) {
|
||||
errors.add("费用项「" + name + "」已停用");
|
||||
continue;
|
||||
}
|
||||
errors.add("费用项「" + name + "」未在费用项主数据中匹配");
|
||||
}
|
||||
if (!errors.isEmpty()) {
|
||||
errorMap.put(index, String.join("; ", errors));
|
||||
@@ -733,15 +844,27 @@ public class SettlementImportBatchServiceImpl
|
||||
return errorMap;
|
||||
}
|
||||
|
||||
private Set<String> loadFeeItemNames() {
|
||||
R<List<FeeItem>> response = sysClient.getFeeItems();
|
||||
private void loadFeeItemNames(Set<String> enabledFeeItemNames, Set<String> disabledFeeItemNames) {
|
||||
R<List<FeeItem>> response = sysClient.getFeeItems(true);
|
||||
if (response == null || !response.isSuccess() || response.getData() == null) {
|
||||
throw new ServiceException("费用项基础档案读取失败,请稍后重试");
|
||||
}
|
||||
return response.getData().stream()
|
||||
.map(FeeItem::getName)
|
||||
.filter(Func::isNotEmpty)
|
||||
.collect(Collectors.toCollection(HashSet::new));
|
||||
for (FeeItem feeItem : response.getData()) {
|
||||
String name = normalizeFeeName(feeItem.getName());
|
||||
if (Func.isEmpty(name)) {
|
||||
continue;
|
||||
}
|
||||
if (feeItem.getStatus() == null || Objects.equals(feeItem.getStatus(), 1)) {
|
||||
enabledFeeItemNames.add(name);
|
||||
} else {
|
||||
disabledFeeItemNames.add(name);
|
||||
}
|
||||
}
|
||||
disabledFeeItemNames.removeAll(enabledFeeItemNames);
|
||||
}
|
||||
|
||||
private String normalizeFeeName(String name) {
|
||||
return name == null ? "" : name.trim();
|
||||
}
|
||||
|
||||
private List<String> resolveFeeItemNames(SettlementImportBatchRequest request) {
|
||||
@@ -1006,7 +1129,7 @@ public class SettlementImportBatchServiceImpl
|
||||
excel.setSerialNumber(intValue(row.get("serialNumber")));
|
||||
excel.setOriginalNo(stringValue(row, "originalNo", "原始单号"));
|
||||
excel.setLoadingIdentifier(stringValue(row, "loadingIdentifier", "配载标识号"));
|
||||
excel.setVehicleNo(stringValue(row, "vehicleNo", "*车牌号/船班号/船次号/班次号"));
|
||||
excel.setVehicleNo(vehicleNoValue(row));
|
||||
excel.setTransportType(stringValue(row, "transportType", "*运输方式", "运输方式"));
|
||||
excel.setDriverName(stringValue(row, "driverName", "司机/船长姓名"));
|
||||
excel.setDriverPhone(stringValue(row, "driverPhone", "司机/船长手机号"));
|
||||
@@ -1039,6 +1162,30 @@ public class SettlementImportBatchServiceImpl
|
||||
return excel;
|
||||
}
|
||||
|
||||
/**
|
||||
* 车号以 vehicleNo 为准。单元格没填时不再回退到表头别名,避免失败明细和落库被自动补上车号。
|
||||
*/
|
||||
private void keepBlankVehicleNo(Map<String, Object> row) {
|
||||
if (row == null || !row.containsKey("vehicleNo")) {
|
||||
return;
|
||||
}
|
||||
String vehicleNo = stringValue(row, "vehicleNo");
|
||||
row.put("vehicleNo", vehicleNo);
|
||||
if (Func.isNotEmpty(vehicleNo)) {
|
||||
return;
|
||||
}
|
||||
for (String alias : VEHICLE_ALIAS_KEYS) {
|
||||
row.put(alias, null);
|
||||
}
|
||||
}
|
||||
|
||||
private String vehicleNoValue(Map<String, Object> row) {
|
||||
if (row != null && row.containsKey("vehicleNo")) {
|
||||
return stringValue(row, "vehicleNo");
|
||||
}
|
||||
return stringValue(row, "vehicleNo", VEHICLE_ALIAS_KEYS);
|
||||
}
|
||||
|
||||
private Map<String, Object> option(Object id, String nameKey, Object nameValue) {
|
||||
return option(id, nameKey, nameValue, null, null);
|
||||
}
|
||||
@@ -1141,4 +1288,130 @@ public class SettlementImportBatchServiceImpl
|
||||
String merged = Func.isEmpty(original) ? "作废原因:" + reason : original + ";作废原因:" + reason;
|
||||
return limit(merged, 200);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Map<String, Object>> parseRowsJson(String rowsJson) {
|
||||
if (Func.isEmpty(rowsJson)) {
|
||||
return List.of();
|
||||
}
|
||||
List<Map> rawList = JsonUtil.parseArray(rowsJson, Map.class);
|
||||
if (rawList == null || rawList.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (Map raw : rawList) {
|
||||
if (raw == null) {
|
||||
continue;
|
||||
}
|
||||
rows.add(new LinkedHashMap<>(raw));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> sanitizeRowsForStorage(List<Map<String, Object>> rows) {
|
||||
if (Func.isEmpty(rows)) {
|
||||
return List.of();
|
||||
}
|
||||
List<Map<String, Object>> sanitized = new ArrayList<>();
|
||||
for (Map<String, Object> row : rows) {
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> copy = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Object> entry : row.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
if (key == null || key.startsWith("_")) {
|
||||
continue;
|
||||
}
|
||||
copy.put(key, entry.getValue());
|
||||
}
|
||||
sanitized.add(copy);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
private List<String> loadTransportTypeLabels() {
|
||||
LinkedHashSet<String> labels = new LinkedHashSet<>();
|
||||
try {
|
||||
List<DictBiz> dictBizList = DictBizCache.getList("transport_type");
|
||||
if (Func.isNotEmpty(dictBizList)) {
|
||||
dictBizList.stream()
|
||||
.map(DictBiz::getDictValue)
|
||||
.filter(Func::isNotEmpty)
|
||||
.map(String::trim)
|
||||
.forEach(labels::add);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 业务字典不可用时继续读取系统字典
|
||||
}
|
||||
try {
|
||||
List<org.springblade.system.pojo.entity.Dict> dictList = DictCache.getList("transport_type");
|
||||
if (Func.isNotEmpty(dictList)) {
|
||||
dictList.stream()
|
||||
.map(org.springblade.system.pojo.entity.Dict::getDictValue)
|
||||
.filter(Func::isNotEmpty)
|
||||
.map(String::trim)
|
||||
.forEach(labels::add);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 忽略
|
||||
}
|
||||
if (labels.isEmpty()) {
|
||||
return List.of("公路运输", "铁路运输", "水路运输", "航空运输");
|
||||
}
|
||||
return new ArrayList<>(labels);
|
||||
}
|
||||
|
||||
private List<String> loadEnabledFeeItemNames() {
|
||||
R<List<FeeItem>> response = sysClient.getFeeItems(false);
|
||||
if (response == null || !response.isSuccess() || response.getData() == null) {
|
||||
return List.of();
|
||||
}
|
||||
return response.getData().stream()
|
||||
.filter(item -> item != null && (item.getStatus() == null || Objects.equals(item.getStatus(), 1)))
|
||||
.map(FeeItem::getName)
|
||||
.filter(Func::isNotEmpty)
|
||||
.map(String::trim)
|
||||
.distinct()
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** 历史已完成批次无 rows_json 时,按关联运单回填查看明细。 */
|
||||
private List<Map<String, Object>> buildRowsFromWaybills(List<Waybill> waybills) {
|
||||
if (Func.isEmpty(waybills)) {
|
||||
return List.of();
|
||||
}
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
int serial = 1;
|
||||
for (Waybill waybill : waybills) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("serialNumber", serial++);
|
||||
row.put("waybillNo", waybill.getWaybillNo());
|
||||
row.put("vehicleNo", waybill.getVehicleNo());
|
||||
row.put("transportType", waybill.getTransportType());
|
||||
row.put("driverName", waybill.getDriverName());
|
||||
row.put("driverPhone", waybill.getDriverPhone());
|
||||
row.put("departureAddress", waybill.getDepartureAddress());
|
||||
row.put("departureContact", waybill.getDepartureContact());
|
||||
row.put("departurePhone", waybill.getDeparturePhone());
|
||||
row.put("arrivalAddress", waybill.getArrivalAddress());
|
||||
row.put("arrivalContact", waybill.getArrivalContact());
|
||||
row.put("arrivalPhone", waybill.getArrivalPhone());
|
||||
row.put("originalNo", waybill.getOriginalNo());
|
||||
row.put("loadingIdentifier", waybill.getLoadingIdentifier());
|
||||
row.put("cargoName", waybill.getCargoName());
|
||||
row.put("cargoType", waybill.getCargoType());
|
||||
row.put("quantity", waybill.getQuantity());
|
||||
row.put("quantityUnit", waybill.getQuantityUnit());
|
||||
row.put("specification", waybill.getSpecification());
|
||||
row.put("model", waybill.getModel());
|
||||
row.put("mileage", waybill.getMileage());
|
||||
row.put("unitPrice", waybill.getUnitPrice());
|
||||
row.put("actualStartDate", waybill.getStartDate());
|
||||
row.put("actualEndDate", waybill.getEndDate());
|
||||
row.put("remark", waybill.getRemark());
|
||||
rows.add(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
+56
@@ -14,6 +14,7 @@ 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;
|
||||
@@ -28,8 +29,11 @@ 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;
|
||||
|
||||
@@ -56,6 +60,7 @@ public class SettlementProjectAdvanceWriteOffServiceImpl implements ISettlementP
|
||||
.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<>();
|
||||
@@ -121,6 +126,20 @@ public class SettlementProjectAdvanceWriteOffServiceImpl implements ISettlementP
|
||||
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,
|
||||
@@ -157,6 +176,9 @@ public class SettlementProjectAdvanceWriteOffServiceImpl implements ISettlementP
|
||||
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("项目预付单与结算单合同不一致");
|
||||
}
|
||||
@@ -203,6 +225,40 @@ public class SettlementProjectAdvanceWriteOffServiceImpl implements ISettlementP
|
||||
.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");
|
||||
}
|
||||
}
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
|
||||
+52
-15
@@ -25,6 +25,7 @@ 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;
|
||||
@@ -385,9 +386,17 @@ 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();
|
||||
// 与前端一致:仅草稿可删;选中含已完成/进行中时整批拒绝
|
||||
boolean hasNonDraft = batches.stream()
|
||||
.anyMatch(batch -> !STATUS_DRAFT.equals(batch.getImportStatus()));
|
||||
if (hasNonDraft) {
|
||||
throw new ServiceException("已完成运单不可删除");
|
||||
}
|
||||
BusinessRemoveResultVO result = new BusinessRemoveResultVO();
|
||||
for (WaybillImportBatch batch : listByIds(idList)) {
|
||||
if (Objects.equals(batch.getIsDeleted(), 1)) continue;
|
||||
for (WaybillImportBatch batch : batches) {
|
||||
List<Waybill> waybills = waybillService.list(Wrappers.<Waybill>lambdaQuery()
|
||||
.eq(Waybill::getImportBatchId, batch.getId()).eq(Waybill::getIsDeleted, 0));
|
||||
if (Func.isNotEmpty(waybills)) {
|
||||
@@ -456,7 +465,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 +542,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;
|
||||
@@ -783,12 +800,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 +816,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,13 +928,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("数量单位必须为系统枚举值之一");
|
||||
String normalized = quantityUnit.trim();
|
||||
boolean matched = quantityUnitOptions.stream().anyMatch(option -> option.equalsIgnoreCase(normalized));
|
||||
if (!matched) {
|
||||
errors.add("数量单位「" + normalized + "」未在计量单位主数据中匹配");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1045,12 +1063,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 +1196,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 +1227,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", "司机/船长手机号"));
|
||||
|
||||
+2
-1
@@ -1486,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
|
||||
|
||||
+91
-13
@@ -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(各接口统一格式),流程拆分后以同步当日作为应付单据日期
|
||||
@@ -828,7 +855,7 @@ public class KingdeeApBillSyncSupport {
|
||||
}
|
||||
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("推送金蝶失败:费用项基础档案读取失败,请稍后重试");
|
||||
}
|
||||
@@ -860,7 +887,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 +952,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 +1033,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));
|
||||
// 【必填】往来户.编码:合同乙方(开票供应商)客商编码
|
||||
|
||||
+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;
|
||||
|
||||
+13
@@ -25,6 +25,7 @@
|
||||
*/
|
||||
package org.springblade.thirdparty.bfi.pojo.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
|
||||
@@ -91,4 +92,16 @@ public class BfiBillSaveResultItemVO implements Serializable {
|
||||
.filter(text -> !text.isEmpty())
|
||||
.collect(Collectors.joining(";"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 失败定位信息:金蝶单据号,供异常提示展示,
|
||||
* 「已提交状态下的单据不允许修改」等场景可凭金蝶单据号直接定位星瀚侧单据
|
||||
*
|
||||
* @return 定位信息文本,形如(金蝶单据:xxx),无可用信息返回空串
|
||||
*/
|
||||
@JsonIgnore
|
||||
public String getFailMark() {
|
||||
String billNo = keys == null ? null : keys.get("billno");
|
||||
return billNo == null || billNo.isBlank() ? "" : "(金蝶单据:" + billNo + ")";
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -94,6 +94,7 @@ public class BfiBlacklistVO implements Serializable {
|
||||
/**
|
||||
* 失信类别[A:禁止交易类, B:风险警示类]
|
||||
*/
|
||||
@JsonProperty("awrp_combofield")
|
||||
private String awrpCombofield;
|
||||
/**
|
||||
* 失信类别标题
|
||||
|
||||
+23
-1
@@ -79,6 +79,7 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiApBillVO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiArBillVO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiAttachmentUploadVO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillLockResultVO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultItemVO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiBlacklistVO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiClaimAnnounceVO;
|
||||
@@ -112,6 +113,7 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 业财融合(BFI)服务实现
|
||||
@@ -655,7 +657,7 @@ public class BfiServiceImpl implements IBfiService {
|
||||
String message = Optional.ofNullable(result).map(BfiResultVO::getMessage).filter(StringUtil::isNotBlank)
|
||||
.orElseGet(() -> Optional.ofNullable(result).map(BfiResultVO::getErrorCode)
|
||||
.map(errorCode -> "错误码 " + errorCode).orElse("响应为空或解析失败"));
|
||||
throw new BfiException("调用BFI接口" + type + "异常 " + message);
|
||||
throw new BfiException("调用BFI接口" + type + "异常 " + message + billSaveFailDetail(result));
|
||||
} catch (BfiException e) {
|
||||
log.error("调用BFI接口 {},BfiException异常", type, e);
|
||||
throw e;
|
||||
@@ -665,6 +667,26 @@ public class BfiServiceImpl implements IBfiService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单据保存失败定位后缀:取失败分录的金蝶单据号,非单据保存类调用返回空串
|
||||
*
|
||||
* @param result BFI 响应
|
||||
* @param <T> 响应数据类型
|
||||
* @return 定位后缀,形如 (金蝶单据:xxx)
|
||||
*/
|
||||
private <T> String billSaveFailDetail(BfiResultVO<T> result) {
|
||||
if (result == null || !(result.getData() instanceof BfiBillSaveResultVO billSave)
|
||||
|| billSave.getResult() == null || billSave.getResult().isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
String detail = billSave.getResult().stream()
|
||||
.filter(item -> !Boolean.TRUE.equals(item.getBillStatus()))
|
||||
.map(BfiBillSaveResultItemVO::getFailMark)
|
||||
.filter(mark -> !mark.isEmpty())
|
||||
.collect(Collectors.joining(";"));
|
||||
return detail.isEmpty() ? "" : " " + detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收票 rim/message 契约公共调用方法,成功错误码兼容 0 与 0000
|
||||
*
|
||||
|
||||
@@ -131,7 +131,8 @@ TMS 付款申请审核通过
|
||||
| --- | --- | --- | --- |
|
||||
| awrp_src_code | 来源单据号(防重键) | `paymentNo` | 付款申请单号 |
|
||||
| awrp_src_sys | 外部系统编号(防重键) | 配置 `payApply.srcSys` | 默认 `WLTMS` |
|
||||
| settleorg_number / applyorg_number / payorg_number / purorg_number | 结算/申请/付款/采购组织编码 | 合同所属组织 | `organizationId` → 组织编码 `deptCode`,为空回退组织名称;缺失报错 |
|
||||
| settleorg_number / applyorg_number / payorg_number | 结算/申请/付款组织编码 | 合同关联项目业务部门上溯平台公司 | `kingdeeOrgNumber` → 平台公司 `blade_dept.kingdee_dept_code`;缺失报错 |
|
||||
| awrp_department_number | 部门编码 | 项目业务部门自身 | `kingdeeDeptNumber` → 业务部门 `blade_dept.kingdee_dept_code`(部门级编码,不上溯平台公司);未维护报错 |
|
||||
| billtype_number | 单据类型编码 | 配置 `payApply.billtype` | 默认 `ap_payapply_oth_BT_S` 其他付款申请 |
|
||||
| applydate | 申请日期 | `applyDate` | yyyy-MM-dd,空取当天 |
|
||||
| applycause | 请款事由 | paymentType 映射 | 见 5.1 |
|
||||
@@ -158,7 +159,7 @@ TMS 付款申请审核通过
|
||||
| awrp_entry_code | 对方唯一行 id(防重) | `paymentNo + "-1"` | 不得重复 |
|
||||
| e_remark | 备注 | `remark` | 可选 |
|
||||
|
||||
未传的可选字段:purdept_number、awrp_department_number、e_duedate、e_settlementtype_number、e_costcenter_number、awrp_src_con_eno、awrp_splb_number。
|
||||
未传的可选字段:purorg_number、purdept_number、e_duedate、e_settlementtype_number、e_costcenter_number、awrp_src_con_eno、awrp_splb_number。
|
||||
|
||||
> 前置校验:推送前按 `contractId` 加载合同,未关联合同、合同不存在、所属组织编码为空、结算币种为空时,同步失败并标记 `kingdeeStatus = failed`。
|
||||
|
||||
@@ -222,7 +223,7 @@ TMS 付款申请审核通过
|
||||
| 场景 | 行为 |
|
||||
| --- | --- |
|
||||
| 单笔同步 `syncKingdee` | 校验失败/推送失败置 `failed` 并抛 `ServiceException`,前端提示原因(含金蝶 errors 明细) |
|
||||
| 批量同步 `syncKingdeeBatch` | 逐单循环,**单条失败不中断批次**;失败单标记 `failed` 后继续下一单;返回结果列表中附 `付款申请[id]同步失败:原因` |
|
||||
| 批量同步 `syncKingdeeBatch` | 逐单循环,**单条失败不中断批次**;失败单标记 `failed` 后继续下一单;返回结果列表中附 `付款申请[付款申请单号]同步失败:原因` |
|
||||
| 金蝶接口级失败(status=false / errorCode≠0) | `BfiServiceImpl.commonCall` 抛 `BfiException`,同上处理 |
|
||||
| 金蝶单据级失败(billStatus=false) | 以 `errors` 拼接抛出 |
|
||||
| 重复点击同步 | 已 `synced` 单据跳过;`failed`/`unsynced` 允许重推(防重键保证金蝶侧幂等) |
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 适用系统:TMS(BladeX 微服务平台,服务:blade-transport)
|
||||
> 对接系统:金蝶云·星瀚(业财融合 BFI,经 iPaaS 网关)
|
||||
> 关联文档:`doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`、`doc/docs/业财融合系统接口文档.xlsx`、`doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md`(A103-3 预付款路径)
|
||||
> 关联文档:`doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`、`doc/docs/业财融合系统接口文档.xlsx`、`doc/docs/业务流程/2026-09-16-付款申请同步金蝶(星瀚)技术文档.md`(A103-3 预付款路径)
|
||||
> 最后更新:2026-09-18(字段映射与联调默认值按当前代码重新梳理)
|
||||
|
||||
---
|
||||
@@ -15,7 +15,7 @@
|
||||
- 对接接口:
|
||||
- **A102-2 财务应付单保存** `POST /ierp/kapi/v2/awrp/ap/ap/ap_finapbill/save`
|
||||
- **A103-1 付款申请保存(应付下推)** `POST /ierp/kapi/v2/awrp/ap/ap/ap_finapbill/push`
|
||||
- 路径划分:**正式付款**(settlement_payment)走本文档 A102-2 → A103-1 两步链路;**预付款**(project_advance / progress_advance)维持原 A103-3 直推付款申请单,见《付款申请同步金蝶(星瀚)技术文档》
|
||||
- 路径划分:**正式付款**(settlement_payment)走本文档 A102-2 → A103-1 两步链路;**预付款**(project_advance / progress_advance)维持原 A103-3 直推付款申请单,见《2026-09-16-付款申请同步金蝶(星瀚)技术文档》
|
||||
- 调用方式:前端 → TMS 后端转发 → 星瀚(OAuth2 凭证仅存后端,前端不持有)
|
||||
- 落库结果:A103-1 返回的付款申请单 id 写入 `kingdee_bill_no`(既有字段);**A102-2 返回的应付单 id 不落库**,仅校验保存成功,后续如需可经 A102-4 按防重键(`awrp_src_sys` + `awrp_src_code`)查询(`BfiServiceImpl.queryApBill` 已封装),本次无表结构变更
|
||||
|
||||
@@ -216,7 +216,7 @@ A102-2 所需的金蝶侧固定档案编码**不进 Nacos 配置**,统一定
|
||||
| --- | --- |
|
||||
| 单笔同步 `syncKingdee` | 校验失败/推送失败置 `failed` 并抛 `ServiceException`,前端提示原因(含金蝶 errors 明细) |
|
||||
| ①成功②失败 | `kingdeeStatus = failed`;重推时①按防重键走更新语义,继续② |
|
||||
| 批量同步 `syncKingdeeBatch` | 逐单循环,**单条失败不中断批次**;返回结果列表附 `付款申请[id]同步失败:原因` |
|
||||
| 批量同步 `syncKingdeeBatch` | 逐单循环,**单条失败不中断批次**;返回结果列表附 `付款申请[付款申请单号]同步失败:原因` |
|
||||
| 货物类型未维护档案编码 / 计量单位为空 | 分录解析(`collectEntryGroups`)时拦截,报错提示补充档案 |
|
||||
| 合同乙方为空 / 客商档案未维护客商编码 | 往来户取值(`bfiAsstactNumber`)时拦截,报错提示合同号与乙方名称 |
|
||||
| 金蝶接口级失败 | `BfiServiceImpl.commonCall` 抛 `BfiException`,同上处理 |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 适用系统:TMS(BladeX 微服务平台,服务:blade-transport)
|
||||
> 对接系统:金蝶云·星瀚(业财融合 BFI,经 iPaaS 网关)
|
||||
> 关联文档:`doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`(4.3 节步骤 11)、`付款申请同步金蝶(星瀚)技术文档.md`、`2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md`、`2026-09-17-BFI分页查询通用翻页工具(BfiUtil-queryAllPages)技术文档.md`
|
||||
> 关联文档:`doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`(4.3 节步骤 11)、`2026-09-16-付款申请同步金蝶(星瀚)技术文档.md`、`2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md`、`2026-09-17-BFI分页查询通用翻页工具(BfiUtil-queryAllPages)技术文档.md`
|
||||
> 最后更新:2026-09-17
|
||||
|
||||
---
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
> 关联文档:
|
||||
> - `doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`(图03 财务应付及付款,步骤4 推送金蝶 / 步骤13 异常回收)
|
||||
> - `doc/adr/0005-B901事件驱动双表锁单模型.md`(UNLOCK 置失败态不自动重推;A103-2 连续两轮查询消失兜底复用 `unlockBill()`)
|
||||
> - `doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md`(A103-3 接口定义)
|
||||
> - `doc/docs/业务流程/2026-09-16-付款申请同步金蝶(星瀚)技术文档.md`(A103-3 接口定义)
|
||||
> - `doc/docs/业务流程/2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md`
|
||||
> - `doc/docs/业务流程/2026-09-18-付款管理金蝶同步全链路逻辑梳理(推送链路与回写链路连贯性).md`
|
||||
|
||||
|
||||
@@ -248,21 +248,62 @@
|
||||
|
||||
### 4.1 任务清单(Nacos `thirdparty.bfi.*` 配置)
|
||||
|
||||
| 任务 | 干什么 | 开关键 | cron 键(默认频率) | 手动替代按钮 |
|
||||
| 任务 | 干什么 | 开关键 | cron 键(默认值) | 手动替代按钮 |
|
||||
|---|---|---|---|---|
|
||||
| 付款结果回写 | 付款状态(paid/closed)+ 付款记录/凭证回写;顺带应收勾稽纠偏、开票结果回写 | `thirdParty.bfi.payResult.syncEnabled`(样例已开) | `…payResult.cron`(10 分钟) | 付款「同步付款结果」、开票结果手动端点、结算勾稽手动端点 |
|
||||
| 收票结果回写 | 收票单状态回写、pushed 断点补发指定应付 | `…receivedInvoiceResult.syncEnabled` | 每 10 分钟 | 收票「同步金蝶结果」 |
|
||||
| 客商同步 | 金蝶审核状态复查(synced→exist)、停用联动、failed 重推 | `…customer.syncEnabled` | 每天 02:00 | 客商「同步金蝶」 |
|
||||
| 收款认领推送 | 自动推送认领完成的流水到金蝶 | `…receiptClaimPush.syncEnabled` | 每 10 分钟 | 流水行内「推送金蝶」 |
|
||||
| 全票池同步 | 拉金蝶全票池发票到本地镜像、补绑定 | `…invoiceReceipt.syncEnabled` | 每 30 分钟 | 手动端点 `POST /invoice-receipt/sync-invoice-pool` |
|
||||
| failed 重推 | 自动重推所有推送失败的结算/付款/开票/收票单 | `…autoSync.retryEnabled` | 每 10 分钟 | 各页面「同步金蝶」 |
|
||||
| 锁单补偿 | 推送成功但金蝶锁定失败的自动补锁(≤5 次) | `…billLock.retryEnabled` | 每 5 分钟 | 手动端点 `POST /formal-settlement/retry-kingdee-bill-lock` |
|
||||
| 付款结果回写 | 付款状态(paid/closed)+ 付款记录/凭证回写;顺带应收勾稽纠偏、开票结果回写 | `thirdParty.bfi.payResult.syncEnabled`(样例已开) | `…payResult.cron`(默认 `0 1/10 * * * ?`) | 付款「同步付款结果」、开票结果手动端点、结算勾稽手动端点 |
|
||||
| 收票结果回写 | 收票单状态回写、pushed 断点补发指定应付 | `…receivedInvoiceResult.syncEnabled` | `…receivedInvoiceResult.cron`(默认 `0 8/10 * * * ?`) | 收票「同步金蝶结果」 |
|
||||
| 客商同步 | 金蝶审核状态复查(synced→exist)、停用联动、failed 重推 | `…customer.syncEnabled` | `…customer.cron`(默认 `0 0 2 * * ?`) | 客商「同步金蝶」 |
|
||||
| 收款认领推送 | 自动推送认领完成的流水到金蝶 | `…receiptClaimPush.syncEnabled` | `…receiptClaimPush.cron`(默认 `0 4/10 * * * ?`) | 流水行内「推送金蝶」 |
|
||||
| 全票池同步 | 拉金蝶全票池发票到本地镜像、补绑定 | `…invoiceReceipt.syncEnabled` | `…invoiceReceipt.cron`(默认 `0 16/30 * * * ?`) | 手动端点 `POST /invoice-receipt/sync-invoice-pool` |
|
||||
| failed 重推 | 自动重推所有推送失败的结算/付款/开票/收票单 | `…autoSync.retryEnabled` | `…autoSync.retryCron`(默认 `0 5/10 * * * ?`) | 各页面「同步金蝶」 |
|
||||
| 锁单补偿 | 推送成功但金蝶锁定失败的自动补锁(≤5 次) | `…billLock.retryEnabled` | `…billLock.retryCron`(默认 `0 2/5 * * * ?`) | 手动端点 `POST /formal-settlement/retry-kingdee-bill-lock` |
|
||||
|
||||
### 4.2 审批自动同步开关(按业务域独立)
|
||||
### 4.2 各任务的运转逻辑
|
||||
|
||||
**共性规则(先读这个)**
|
||||
|
||||
- **终态不轮询**:付款 paid(已付款)/closed(已关闭)、开票 invoiced(已开票)、收票 assigned(已指定应付)、客商 exist(金蝶已审核)、认领流水 pushed(已推送金蝶)后自动退出扫描范围——任务只处理「还没走完」的单据,越跑越轻,不会重复处理。
|
||||
- **定时与手动同一套逻辑**:每个任务与它的「手动替代按钮」调用同一个服务方法,效果完全一致;区别只是触发方式。
|
||||
- **单条失败不拖批次**:一轮里某条单据失败只记录原因,同批其余照常处理;失败的下轮自动再试(兜底见 ⑥ failed 重推)。
|
||||
- **默认执行时间错峰**(付款 :01、认领 :04、failed 重推 :05、收票 :08、锁补偿每 5 分钟的 :02、票池每 30 分钟的 :16),互不挤占;自行改 cron 时建议保留错峰。
|
||||
|
||||
**① 付款结果回写(10 分钟)**
|
||||
扫「审批通过且已推送 / 付款中 / 推送失败」的付款申请 → 到金蝶查付款申请单和付款处理单 → 推进 synced(已推送受理)→ paying(金蝶付款中)→ **paid(已付款)/ closed(已关闭)**,paid 时回写付款记录/凭证,并联动刷新预结算垫资、结算付款记录(3.4 第 3 步的「正常等定时回写」就是它)。
|
||||
- 自愈一(应收勾稽):已推送但失败、或缺金蝶单号的**应收结算单**,查金蝶有则自动补单号、纠回 synced——「实际推成功但本地显示失败」的场景自动痊愈;
|
||||
- 自愈二(开票结果):已受理的开票申请自动落发票信息并推进 **invoiced(已开票,终态)**(3.5 第 3 步的定时回写就是它);
|
||||
- 防呆(金蝶删单):synced(已推送受理)/paying(金蝶付款中)的单据**连续 2 轮(约 20 分钟)在金蝶查不到** → 判定金蝶侧已删,自动解锁置 failed(推送失败)并清空金蝶单号,等人工核实后重推;重推带幂等预检,金蝶单据其实还在时会自动回填,不会重复生成。
|
||||
|
||||
**② 收票结果回写(10 分钟)**
|
||||
扫 pushed(金蝶收票单已生成)/ assigned(已指定应付)的收票登记,按近 **30 天**回看窗口查金蝶收票单,一轮处理三种情况:
|
||||
- 票在金蝶被作废/红冲 → 置 failed(推送失败)并提示人工处理(**不要重推**,见 3.6);
|
||||
- 卡在 pushed → 自动补发「指定应付」推进 **assigned(已指定应付,终态)**(3.6 说的「卡在 pushed 不用管」就是它);
|
||||
- assigned 但缺金蝶收票单号 → 自动补登单号。
|
||||
窗口内查不到的保持现状不动(不判删除),由人工重推兜底。
|
||||
|
||||
**③ 客商同步(每天 02:00)**
|
||||
一次扫全部审批通过客商,干三件事:
|
||||
- **未推送/推送失败** → 先复查金蝶:已有且已审核的直接置 **exist(金蝶已有该客商且审核通过,终态)**(不重复建档);金蝶侧审核中则置 failed(推送失败)提示稍后;确实没有才推送;
|
||||
- **已推送(synced,已推送受理)** → 金蝶审核通过自动升级 **exist(金蝶已审核,终态)**(3.1 第 5 步的定时升级就是它);
|
||||
- **金蝶侧停用(enable=0)** → TMS 档案联动停用;金蝶恢复可用**不会**自动启用(单向联动,需人工启用)。
|
||||
|
||||
**④ 收款认领推送(10 分钟)**
|
||||
扫「认领完成(claimed)且未推送/推送失败」的收款流水 → 按认领人逐人拉最新认领公告**对账**(本地认领金额与公告未认领金额一致才推)→ 推送成功回写金蝶收款单号、流水置 **pushed(已推送金蝶)**(3.7 第 3 步的「自动」就是它)。失败的下一轮自动重试,防重码按分摊结算单生成、重试不变,不会在金蝶重复生成收款单。
|
||||
|
||||
**⑤ 全票池同步(30 分钟)**
|
||||
按近 **30 天**增量窗口分页拉取金蝶全票池发票到本地镜像表,收票选票、票池查询全部走本地镜像——所以**刚在金蝶开的票要等同步后才能选到**;急用可让运维手动触发同步端点,或用票号精确搜索(会自动去金蝶现查,见 3.6)。
|
||||
|
||||
**⑥ failed 重推(10 分钟)**
|
||||
只重推 **failed(推送失败)** 单据(结算 → 付款 → 开票 → 收票四个域依次处理);**unsynced(审批通过但从未推送)不会自动推**——历史存量单不会被上线后批量补推,仍走手动按钮。前置没就绪(如上游应付单还没生成)会再次 failed、下轮继续;前置就绪后自动续推成功。
|
||||
⚠️ 重推**没有次数上限**:根因不修(联行号缺失、组织编码缺失等)会每 10 分钟失败一次刷日志,修复根因是唯一正解(呼应 4.4)。
|
||||
|
||||
**⑦ 锁单补偿(5 分钟)**
|
||||
扫「推送成功但金蝶锁定失败」的锁记录,重试加锁,**最多 5 次**;超过 5 次放弃,需运维调手动补偿端点 `POST /formal-settlement/retry-kingdee-bill-lock`(对应 FAQ 的「锁定失败」标签)。
|
||||
|
||||
### 4.3 审批自动同步开关(按业务域独立)
|
||||
|
||||
`thirdParty.bfi.autoSync.settlement`(结算单)/ `…payment`(付款)/ `…invoiceApply`(开票)/ `…receipt`(收票),默认全关。**代码默认全部关闭,必须在 Nacos 显式开启**;客商档案审批推送不受此开关控制(始终自动)。
|
||||
|
||||
### 4.3 运维注意
|
||||
### 4.4 运维注意
|
||||
|
||||
- 所有定时任务带**分布式锁**:多实例部署只跑一份;手动按钮和定时共用锁,定时运行中手动触发会直接跳过(属正常)。
|
||||
- 开了「failed 重推」后,持续失败的单据每 10 分钟会重试一次,**修复根因**(配置/数据)比反复观察更重要。
|
||||
@@ -324,4 +365,4 @@
|
||||
|
||||
---
|
||||
|
||||
> 本文基于 2026-10-14 代码现状整理;系统升级后按钮与提示如有出入,以页面实际为准,并核对全链路总纲文档的更新说明。
|
||||
> 本文基于 2026-10-14 代码现状整理;2026-10-19 依据当前代码核对并补充 4.2 定时任务运转逻辑(cron 默认值、错峰、终态不轮询、防呆与自愈行为)。系统升级后按钮与提示如有出入,以页面实际为准,并核对全链路总纲文档的更新说明。
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
# 金蝶(星瀚)BFI 推送入参取值映射表:页面字段 → 数据库表字段 / 配置对照
|
||||
|
||||
> 整理日期:2026-10-15
|
||||
> 代码基线:`tms-api` 当前 master(分支 `feature/payment-apply-bfi-sync`,提交 `259407c3` 之后)。
|
||||
> 用途:给联调/实施/排查用——每个送金蝶的入参,**页面上是哪个字段、落库在哪张表哪个字段、或来自哪个配置/常量**。逐字段核对代码(文件+行号见各节备注)。
|
||||
> 配套文档:《2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).md》。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [0. 公共取数与通用规则](#0-公共取数与通用规则)
|
||||
- [1. A100-1 获取令牌](#1-a100-1-获取令牌)
|
||||
- [2. A902-2 客户保存](#2-a902-2-客户保存)
|
||||
- [3. A801-5 黑名单查询](#3-a801-5-黑名单查询)
|
||||
- [4. A102-2 财务应付单保存](#4-a102-2-财务应付单保存)
|
||||
- [5. A302-2 财务应收单保存(与应付差异)](#5-a302-2-财务应收单保存与应付差异)
|
||||
- [6. A103-1 付款申请下推](#6-a103-1-付款申请下推)
|
||||
- [7. A103-3 预付款申请保存](#7-a103-3-预付款申请保存)
|
||||
- [8. A303-1 开票申请单保存](#8-a303-1-开票申请单保存)
|
||||
- [9. A303-2 收票单保存 + A302-6 指定应付](#9-a303-2-收票单保存--a302-6-指定应付)
|
||||
- [10. A301-3 收款认领结果推送](#10-a301-3-收款认领结果推送)
|
||||
- [11. lockBusiData 单据锁定](#11-lockbusidata-单据锁定)
|
||||
- [12. A902 附件上传](#12-a902-附件上传)
|
||||
- [13. 查询类接口入参简表](#13-查询类接口入参简表)
|
||||
- [14. 与总纲记载不一致的核对结论](#14-与总纲记载不一致的核对结论)
|
||||
|
||||
---
|
||||
|
||||
## 0. 公共取数与通用规则
|
||||
|
||||
### 0.1 配置前缀(两套并存,勿混)
|
||||
|
||||
| 前缀 | 归属 | 内容 |
|
||||
|---|---|---|
|
||||
| `third-party.bfi.*` | bfi-api 客户端(令牌/网关) | baseUrl、authorization、clientId、clientSecret、username、accountId、language |
|
||||
| `thirdparty.bfi.*` | blade-transport 业务 | srcSys、各业务开关/cron、payApply.*、invoiceApply.*、receivedInvoice.*、material.*、claimAnnounce.*、blacklist.*、billLock.*(宽松绑定兼容 camelCase) |
|
||||
|
||||
### 0.2 通用取数
|
||||
|
||||
| 项 | 规则 | 代码 |
|
||||
|---|---|---|
|
||||
| awrp_src_sys / systemsource / sourceSystem | Nacos `thirdparty.bfi.srcSys`,默认常量 `WLTMS` | BfiProperties(srcSys) |
|
||||
| 组织 org/结算/付款/收款组织(核算/资金类) | **统一取公司级编码**:项目业务部门沿部门树向上反查平台公司(`is_platform_company=1`)→ 平台公司 `blade_dept.kingdee_dept_code`;未归属平台公司/未维护编码直接拦截 | `kingdeeOrgNumber`(KingdeeApBillSyncSupport L976-999),项目经 `blade_project_apply.business_dept_id` |
|
||||
| 申请部门类字段(A303-1 awrp_orgfield) | 申请部门自身 `kingdee_dept_code`(部门级业务单元编码,星瀚允许) | 申请单 dept_id → `blade_dept.kingdee_dept_code` |
|
||||
| 币种 | 合同结算币种,`RMB→CNY` | `blade_contract_manage.settlement_currency`(bfiCurrency L952-956) |
|
||||
| 往来户 | 名称 → `blade_customer_archive` 按 `full_name/short_name` 匹配(limit 1)取 `customer_code`,查不到报错 | queryCustomerCode L966-984 |
|
||||
| 日期文本 | 统一 `yyyy-MM-dd HH:mm:ss` | bfiDateTimeText L922-925 |
|
||||
| 防重键 awrp_src_code | `ExternalSrcCodeSupport.buildSrcCode`:下划线拼接、去空白、去重保序 | ExternalSrcCodeSupport L54-63 |
|
||||
|
||||
### 0.3 分录拆分规则(A102-2/A302-2/A303-2 共用 `resolveEntryGroups`)
|
||||
|
||||
- 拆分维度:**货物 → 费用大类(装卸/运输)→ 税率** 三级分组,各组成一条分录;手动合计费用项(manual_flag=1)每行一条。
|
||||
- 物料编码:装卸大类/手动非运费杂费 → `thirdparty.bfi.material.loading`(默认 XDWL11002004);项目业务模式或业务类型含「国际」→ `.international`(Item-00001081);国内按运单运输方式 → `.domestic.公路运输`=XDWL11010001 / `.铁路运输`=XDWL11010002 / `.水路运输`=XDWL11010003 / `.航空运输`=Item-00001082;运输方式未配置直接拦截。
|
||||
- 税率:货物费用行按合同计费方案规则分组(`blade_contract_manage.billing_plan_json` 的 `rules[].feeType/feeItem/taxRate`);税率未配置并入无税率组。
|
||||
- 数量:运输类=结算明细运输量(≤0 兜底 1,同税率拆多行每行都取运输量);装卸/合计费用项行=1;单价=金额÷数量反算(2 位 HALF_UP)。
|
||||
- 0 元费用项(signum==0)与 0 元货物行整行跳过。
|
||||
- 计量单位**当前不送**(DTO 有字段无赋值)。
|
||||
|
||||
---
|
||||
|
||||
## 1. A100-1 获取令牌
|
||||
|
||||
| 入参 | 含义 | 来源:配置键 / 生成方式 |
|
||||
|---|---|---|
|
||||
| client_id | 应用编码 | `third-party.bfi.clientId` |
|
||||
| client_secret | 认证密钥 | `third-party.bfi.clientSecret` |
|
||||
| username | 访问用户名 | `third-party.bfi.username` |
|
||||
| accountId | 数据中心 ID | `third-party.bfi.accountId` |
|
||||
| language | 语言 | `third-party.bfi.language`(默认 `zh_CN`) |
|
||||
| nonce | 随机数 | 每次生成 `UUID` |
|
||||
| timestamp | 时间戳(±5 分钟校验) | 每次生成 `yyyy-MM-dd HH:mm:ss` |
|
||||
|
||||
> 令牌缓存 Redis `blade:third:bfi:token`,TTL=expires_in−120s;`setIfAbsent` 写入。代码:BfiServiceImpl L136-156、BfiTokenDTO L44-65。
|
||||
|
||||
---
|
||||
|
||||
## 2. A902-2 客户保存
|
||||
|
||||
调用链:`CustomerArchiveServiceImpl.syncKingdee → pushCustomerToKingdee → CustomerBfiConverter.buildSaveDTO`
|
||||
|
||||
| 金蝶入参 | 含义 | 页面字段 | 表.字段 / 常量 |
|
||||
|---|---|---|---|
|
||||
| number | 客户编码(不传由星瀚生成) | 客商编号(提交时生成 KS+6 位) | blade_customer_archive.customer_code |
|
||||
| name【必】 | 名称 | 客商名称 | blade_customer_archive.full_name |
|
||||
| societycreditcode【必】 | 统一社会信用代码(金蝶候选键) | 统一社会信用代码 | blade_customer_archive.unified_credit_code |
|
||||
| tx_register_no | 纳税人识别号 | 同上(同一字段) | blade_customer_archive.unified_credit_code |
|
||||
| artificialperson | 法人代表 | 企业法人 | blade_customer_archive.legal_person |
|
||||
| type【必】 | 伙伴类型(1~17) | 客商性质字典 | blade_customer_archive.customer_nature + 字典 `nature_of_client` dictKey(文本值反查 dictKey,无效抛异常) |
|
||||
| bizpartner_address | 详细地址 | 注册地址 | blade_customer_archive.registered_address |
|
||||
| bizpartner_phone | 联系电话 | 联系电话 | blade_customer_archive.contact_phone |
|
||||
| businessterm | 营业期限 | 营业期限类型=长期→「长期」;否则「至 yyyy-MM-dd」 | blade_customer_archive.business_term_type / business_end_date |
|
||||
| businessscope | 经营范围 | 经营范围多选 → 字典 `scope_of_business` 文本「、」拼接 | blade_customer_archive.business_scope |
|
||||
| bizfunction | 业务职能 | 固定全选 | 常量 `"1,2,3,4"` |
|
||||
| fcusbeizhu | 备注 | 备注(空不传) | blade_customer_archive.remark |
|
||||
| awrp_datasourcesys | 数据来源 | 配置 | `thirdparty.bfi.srcSys` |
|
||||
| awrp_cusfortrade【必】 | 是否贸易客商 | 固定 | 常量 `"A"` |
|
||||
| entry_groupstandard[].standardid_number【必】 | 分类标准 | 固定 | 常量 `"JBFLBZ"` |
|
||||
| entry_groupstandard[].groupid_number【必】 | 客户分组 | 客户类型:internal→1101,其余→1102 | blade_customer_archive.customer_kind + 常量 1101/1102 |
|
||||
| entry_bank[].bankaccount | 银行账号(金蝶候选键) | 收款账号 | blade_customer_receipt_account.bank_account |
|
||||
| entry_bank[].accountname | 账户名称 | **传开户行名称** | blade_customer_receipt_account.bank_name |
|
||||
| entry_bank[].bank_number | 开户银行.编码(联行号) | 联行号 | blade_customer_receipt_account.cnaps_code |
|
||||
| entry_bank[].currency_number【必】 | 币种 | 固定 | 常量 `"CNY"` |
|
||||
| entry_bank[].isdefault_bank | 默认银行 | 固定 | `true` |
|
||||
| entry_linkman[].contactperson | 联系人 | 客商负责人(空则整组分录不传) | blade_customer_archive.principal |
|
||||
| entry_linkman[].phone | 电话 | 联系电话 | blade_customer_archive.contact_phone |
|
||||
| entry_linkman[].isdefault_linkman | 默认联系人 | 固定 | `true` |
|
||||
|
||||
不传字段:status、linkman、establishdate、postal_code、createorg_number(已注释)等。
|
||||
> 代码:CustomerBfiConverter L67-280、常量 CustomerBfiConstants / BfiConstant L288-313。
|
||||
|
||||
---
|
||||
|
||||
## 3. A801-5 黑名单查询
|
||||
|
||||
| 入参 | 含义 | 来源 |
|
||||
|---|---|---|
|
||||
| enable | 使用状态 | 固定 `"1"`(可用) |
|
||||
| number | 企业税号数组 | 客商发票信息所有纳税人识别号,`trim().toUpperCase()` 去重 → blade_customer_invoice_info.tax_no |
|
||||
| pageNo / pageSize | 分页 | 自动翻页(50 页保护)/ 固定 `2000` |
|
||||
| name | 企业名称数组 | 不传 |
|
||||
|
||||
> 命中判定按应答 awrpCombofield:A 硬拦截;B 默认放行告警(`thirdparty.bfi.blacklist.blockRiskWarning=true` 时拦截);查询异常上抛阻断提审。代码:CustomerArchiveServiceImpl L1043-1092、BfiConstant L90。
|
||||
|
||||
---
|
||||
|
||||
## 4. A102-2 财务应付单保存
|
||||
|
||||
### 4.1 单据头
|
||||
|
||||
| 金蝶入参 | 含义 | 页面字段 | 表.字段 / 配置 / 常量 |
|
||||
|---|---|---|---|
|
||||
| awrp_src_code【必·候选键】 | 来源单据号(防重键) | 结算单号+结算明细单据号 | blade_formal_settlement.formal_settlement_no + blade_formal_settlement_detail.document_no(document_no 空兜底明细行 id)下划线拼接 |
|
||||
| awrp_src_sys【必】 | 外部系统编号 | 配置 | `thirdparty.bfi.srcSys`(WLTMS) |
|
||||
| awrp_src_type【必】 | 外部单据类型 | — | 常量 `TMS财务应付单` |
|
||||
| billtypeid_number【必】 | 单据类型.编码 | — | 常量字典「服务采购应付」→ `ApFin_service_BT_S` |
|
||||
| org_number / payorg_number【必】 | 结算/付款组织.编码 | 合同关联项目业务部门上溯平台公司 | kingdeeOrgNumber → 平台公司 `blade_dept.kingdee_dept_code`(公司级核算组织编码) |
|
||||
| bizdate【必】 | 单据日期 | 同步当日 | LocalDate.now() |
|
||||
| asstacttype【必】 | 往来类型 | — | 常量 `bd_customer`(固定,供应商分支已注释) |
|
||||
| asstact_number【必】 | 往来户.编码 | 合同乙方 | blade_contract_manage.party_b → blade_customer_archive.customer_code |
|
||||
| purmode【必】 | 付款方式 | — | 常量 `CASH`(现购) |
|
||||
| currency_number【必】 | 结算币别 | 合同结算币种 | blade_contract_manage.settlement_currency(RMB→CNY) |
|
||||
| payproperty_number【必】 | 款项性质.编码 | — | 常量 `2001`(材料采购) |
|
||||
| isincludetax / ispricetotal | 含税价/总价录入 | — | `true` / `true` |
|
||||
| isintax | 按应付金额分配 | — | `false` |
|
||||
| exratedate / exchangerate | 汇率日期/汇率 | 结算单页面「汇率日期/结算汇率」,未维护不送 | blade_formal_settlement.exchange_rate_date / .exchange_rate |
|
||||
| settlementtype_number | 结算方式 | **不送**(仅 A103-1 分录需要) | — |
|
||||
| taxrateid_number / purorg_number / exratetable_number / planentity 等 | 可选 | **均不送**(planentity 由金蝶按分录自动汇总付款计划) | — |
|
||||
|
||||
### 4.2 明细分录 detailentry
|
||||
|
||||
| 金蝶入参 | 含义 | 来源 | 表.字段 / 配置 / 常量 |
|
||||
|---|---|---|---|
|
||||
| awrp_entry_code【必·候选键】 | 分录标识 | 拆分行=`明细单据号-货物序号-大类-税率`;合计费用项行=`结算单号-费用类型-费用项` | blade_formal_settlement_detail.document_no / blade_formal_settlement_summary_fee.fee_type+fee_item |
|
||||
| material_number【必】 | 物料编码.编码 | 按拆分规则映射(见 0.3) | 配置 `thirdparty.bfi.material.loading / international / domestic.公路运输 / .铁路运输 / .水路运输 / .航空运输`;判定字段 blade_project_apply.business_mode / business_type、blade_formal_settlement_detail.transport_type |
|
||||
| awrp_transport | 运输货物 | 货物名称(合计费用项行不传) | blade_formal_settlement_detail.cargo_name(一车多货取 blade_formal_settlement_detail_fee.cargo_name) |
|
||||
| awrp_lpn | 车号 | 车号(合计费用项行不传) | blade_formal_settlement_detail.vehicle_no |
|
||||
| quantity【必】 | 数量 | 运输量(≤0 兜底 1;装卸/合计行=1) | blade_formal_settlement_detail.transport_quantity(一车多货取 detail_fee.transport_quantity) |
|
||||
| price | 单价 | 金额÷数量反算 | 派生值 |
|
||||
| e_pricetaxtotal【必】 | 应付金额(含税) | 结算金额 | blade_formal_settlement_detail.settlement_amount_tax / blade_formal_settlement_summary_fee.settlement_amount |
|
||||
| e_amount | 金额(不含税) | 不含税结算金额(仅运输类携带;合计行不传) | blade_formal_settlement_detail.settlement_amount_no_tax |
|
||||
| project_number【必】 | 项目.编码 | 联调临时值 | 常量 `"999"` |
|
||||
| taxrateid_number | 税率.编码 | 不送(税率只作拆行依据) | blade_contract_manage.billing_plan_json |
|
||||
|
||||
---
|
||||
|
||||
## 5. A302-2 财务应收单保存(与应付差异)
|
||||
|
||||
分录与应付**同源同口径**(复用 resolveEntryGroups,分录字段名前缀 e_:e_material_number/e_quantity/e_unitprice/e_recamount(含税)/e_amount(不含税))。头字段差异:
|
||||
|
||||
| 金蝶入参 | 应收取值 | 来源 |
|
||||
|---|---|---|
|
||||
| awrp_src_type | `TMS财务应收单` | 常量 |
|
||||
| billtype_number | 「服务销售应收」→ `arfin_sersal_BT_S` | 常量映射 |
|
||||
| recorg_number | 收款组织=结算组织 | 同 org_number |
|
||||
| asstact_number | **结算单付款方**(非合同乙方) | blade_formal_settlement.payer_name → blade_customer_archive.customer_code |
|
||||
| paymode | 现销 `CASH` | 常量 |
|
||||
| payproperty_number | `1001`(一般销售) | 常量 |
|
||||
| bizdate | 同步当日 | LocalDate.now() |
|
||||
| settlementtype_number / duedate 等 | 不送(TODO 待金蝶确认) | — |
|
||||
|
||||
---
|
||||
|
||||
## 6. A103-1 付款申请下推
|
||||
|
||||
| 金蝶入参 | 含义 | 页面字段 | 表.字段 / 配置 / 常量 |
|
||||
|---|---|---|---|
|
||||
| awrp_src_code【必·候选键】 | 来源单据号 | 付款申请单号 | blade_payment_application.payment_no |
|
||||
| awrp_src_sys【必】 | 外部系统 | 配置 | `thirdparty.bfi.srcSys` |
|
||||
| applydate【必】 | 申请日期 | 付款申请页-申请日期(空取当天) | blade_payment_application.apply_date |
|
||||
| entry[].e_paymenttype_number【必】 | 付款类型 | 正式付款 | 常量 `201` |
|
||||
| entry[].e_settlementtype_number | 结算方式 | 付款方式含「汇票」→`012-02`(电子银行承兑汇票),其余→`02`(银行转账) | 字典 `pay_method` + blade_payment_application.payment_method |
|
||||
| entry[].awrp_finapbill_entryid【必】 | 勾稽应付单分录标识 | 主结算单应付单防重键(本地组装) | blade_formal_settlement.formal_settlement_no + blade_formal_settlement_detail.document_no(主结算单=blade_payment_application.settlement_id,空取关联明细首个结算单) |
|
||||
| entry[].awrp_entry_code【必】 | 行 id | 付款申请号-1 | payment_no + "-1" |
|
||||
| entry[].e_assacct【必】 | 往来账户(收款账号) | 收款账号 | blade_payment_application.bank_account |
|
||||
| entry[].e_bebank_number【必】 | 往来银行.编码(联行号) | 收款账户联行号(缺失拦截;兜底按合同乙方客商+收款账号匹配) | blade_customer_receipt_account.cnaps_code(按 blade_payment_application.receipt_account_id 直查) |
|
||||
| entry[].e_applyamount【必】 | 申请金额 | 申请付款金额(2 位 HALF_UP) | blade_payment_application.applied_amount |
|
||||
| entry[].project_number【必】 | 项目.编码 | 联调临时值 | 常量 `"999"` |
|
||||
| entry[].awrp_src_type【必】 | 外部单据类型 | 配置 | `thirdparty.bfi.payApply.srcType`(默认 `0KzXs`) |
|
||||
| exratetable_number / awrp_textfield1 | 汇率表/付款渠道 | **实际不送**(仅 A103-3 送;配置 exratetable=01 在此接口不生效) | — |
|
||||
|
||||
---
|
||||
|
||||
## 7. A103-3 预付款申请保存
|
||||
|
||||
| 金蝶入参 | 含义 | 来源 | 表.字段 / 配置 / 常量 |
|
||||
|---|---|---|---|
|
||||
| billtype_number【必】 | 单据类型 | 配置 | `thirdparty.bfi.payApply.billtype`(默认 `ap_payapply_oth_BT_S`) |
|
||||
| settleorg/applyorg/payorg_number【必】 | 结算/申请/付款组织.编码 | 合同关联项目业务部门上溯平台公司 | kingdeeOrgNumber → 平台公司 `blade_dept.kingdee_dept_code`(公司级核算组织编码) |
|
||||
| awrp_department_number【必】 | 部门.编码 | 项目业务部门自身 | kingdeeDeptNumber → 业务部门 `blade_dept.kingdee_dept_code`(部门级编码,不上溯平台公司;未维护拦截) |
|
||||
| paycurrency/settlecurrency_number【必】 | 币种 | 合同结算币种(RMB→CNY) | blade_contract_manage.settlement_currency |
|
||||
| applycause | 请款事由 | 按付款类型映射文案:项目垫资→「项目预付款申请」、进度垫资→「进度预付款申请」、结算付款→「结算付款申请」 | blade_payment_application.payment_type |
|
||||
| exratetable_number【必】 | 汇率表 | 配置(**此接口实际送**) | `thirdparty.bfi.payApply.exratetable`(默认 `01`) |
|
||||
| exratedate【必】 | 汇率日期 | 关联结算单维护值,兜底申请日期 | blade_formal_settlement.exchange_rate_date → blade_pre_settlement.exchange_rate_date → blade_payment_application.apply_date |
|
||||
| awrp_textfield1 | 付款渠道 | 付款方式字典标签(**此接口实际送**) | 字典 `pay_method` 翻译 blade_payment_application.payment_method |
|
||||
| entry[].e_paymenttype_number【必】 | 付款类型 | 结算付款→`201`;项目垫资/进度垫资→`202` | 常量 BFI_PAYMENT_TYPE_FORMAL / BFI_PAYMENT_TYPE_PREPAY |
|
||||
| entry[].e_asstacttype / e_asstactnumber【必】 | 往来类型/户 | 固定客户 / 合同乙方→客商编码 | `bd_customer`;blade_contract_manage.party_b → customer_code |
|
||||
| entry[].e_assacct / e_bebank_number【必】 | 账号/联行号 | 同 A103-1 | blade_payment_application.bank_account / blade_customer_receipt_account.cnaps_code |
|
||||
| entry[].e_applyamount【必】 | 申请金额 | 申请付款金额 | blade_payment_application.applied_amount |
|
||||
| entry[].project_number【必】 | 项目.编码 | **实际固定 999**(取项目档案真实编码的逻辑已注释停用) | 常量 `"999"`(备用:blade_project_apply.project_code) |
|
||||
| entry[].e_remark | 备注 | 付款申请备注(空不传) | blade_payment_application.remark |
|
||||
| entry[].awrp_entry_code / awrp_src_type【必】 | 行 id / 单据类型 | payment_no-1 / 配置 | `thirdparty.bfi.payApply.srcType` |
|
||||
|
||||
---
|
||||
|
||||
## 8. A303-1 开票申请单保存(每张发票张次一单)
|
||||
|
||||
### 8.1 主表
|
||||
|
||||
| 金蝶入参 | 含义 | 页面字段 | 表.字段 / 配置 / 常量 |
|
||||
|---|---|---|---|
|
||||
| billno【必·候选键】 | 单据编号 | 开票申请单号(多张加张次序号) | blade_invoice_application.application_no(多张 + "-" + blade_invoice_application_sheet.sheet_no) |
|
||||
| orgid_number【必】 | 组织.编码 | 申请关联项目业务部门上溯平台公司 | kingdeeOrgNumber → 平台公司金蝶组织编码(绑定税号的核算组织) |
|
||||
| awrp_orgfield_number【必】 | 申请部门.编码 | 申请部门 | blade_invoice_application.dept_id → blade_dept.kingdee_dept_code(部门级业务单元编码) |
|
||||
| billdate【必】 | 单据日期 | 申请日期(空取当天) | blade_invoice_application.application_date |
|
||||
| billproperties / taxationstyle / buyerproperty | 单据性质/征税方式/购方类型 | 固定 | `"1"` / `"0"` / `"0"` |
|
||||
| invoicetype【必】 | 发票种类 | 发票类型:数电专票→`08xdp`,其余→`10xdp` | blade_invoice_application.invoice_type |
|
||||
| buyeremail【必】 | 推送邮箱 | 部门邮箱(空拦截) | blade_invoice_application.department_emails |
|
||||
| buyertaxno / buyername【必】 | 购方税号/名称 | 受票方快照 | blade_invoice_application.taxpayer_no / receiver_name |
|
||||
| buyeraddr / buyerbank | 购方地址电话/开户行账号 | 受票方快照 | blade_invoice_application.registered_address+contact_phone / bank_name+bank_account |
|
||||
| salername【必】 | 销方名称 | 开票单位(空拦截) | blade_invoice_application.issuer_name |
|
||||
| salertaxno【必】 | 销方税号 | 开票单位→客商档案 | blade_customer_archive.unified_credit_code(按 issuer_name 匹配) |
|
||||
| saleraddr / salerbank | 销方地址电话/开户行账号(数电专票强制) | 客商档案(启用开票信息优先) | blade_customer_archive.registered_address+contact_phone;blade_customer_invoice_info.bank_name+bank_account |
|
||||
| totalamount【必】 | 申请价税合计 | 本张开票金额 | blade_invoice_application_sheet.invoice_amount |
|
||||
| hsbz | 是否含税 | 固定不含税 | 常量 `"0"` |
|
||||
| specialtype | 特殊票种 | 本张商品行**全部**为货物运输类→`E04`,否则→`00`(名称/分类含「运费/货物运输」关键字) | 常量 BFI_INVOICE_SPECIAL_TYPE_FREIGHT / NONE + BFI_INVOICE_FREIGHT_KEYWORDS |
|
||||
| freights[] | 货物运输单据体(E04 报送) | 结算明细快照,见 8.2 | blade_invoice_application_detail |
|
||||
| customnameid_number【必】 | 客户.编码 | 受票方客户 | blade_invoice_application.receiver_customer_id → blade_customer_archive.customer_code |
|
||||
| awrp_project_number【必】 | 项目.编码 | 联调临时值 | 常量 `"999"` |
|
||||
| awrp_src_bt | 上游单据类型 | 固定 | `ar_finarbill` |
|
||||
| awrp_src_con_no | 上游单据号 | 首张关联结算单金蝶应收防重键 | blade_formal_settlement.formal_settlement_no + detail.document_no(经 blade_invoice_application_settlement 关联) |
|
||||
| systemsource | 来源系统 | 配置 | `thirdparty.bfi.srcSys` |
|
||||
| showbuyeraddresstel / showbuyerbank | 票面展示开关 | 固定 | `true` |
|
||||
|
||||
### 8.2 freights[](按「车牌×起运×到达×货物×种类」去重归并,全空行剔除)
|
||||
|
||||
| 入参 | 来源:blade_invoice_application_detail 字段 |
|
||||
|---|---|
|
||||
| transporttype 运输工具种类 | transport_type(字典 `transport_type` 翻译,不在合法枚举兜底「其它运输工具」) |
|
||||
| licenseplate 牌号 | vehicle_no |
|
||||
| transportgoods 货物名称 | cargo_name |
|
||||
| startplace 起运地 | departure_address |
|
||||
| endplace 到达地 | arrival_address |
|
||||
|
||||
### 8.3 sim_original_bill_item[] 分录(blade_invoice_application_line 商品行,line_no 升序)
|
||||
|
||||
| 入参 | 来源:blade_invoice_application_line 字段 |
|
||||
|---|---|
|
||||
| awrp_entry_code【必】 | id(雪花 id) |
|
||||
| rowtype / benchmark【必】 | 常量 `"2"`(商品行)/ `"0"`(数量基准) |
|
||||
| goodsname【必】 | goods_name(货物或服务简称) |
|
||||
| goodssimplename【必】 | goods_category(商品和服务分类) |
|
||||
| taxratecodeid_number / goodscode【必】 | tax_classification_code(税收分类编码,空拦截) |
|
||||
| num / unit | quantity(0 兜底 1)/ unit(空兜底「个」) |
|
||||
| unitprice / amount / tax | unit_price_no_tax / amount_no_tax / tax_amount |
|
||||
| taxrate | tax_rate(百分数÷100 转小数,0 送 "0.00") |
|
||||
| remark | remark |
|
||||
| awrp_src_type【必】 | 配置 `thirdparty.bfi.invoiceApply.srcType`(默认 `0KzXs`) |
|
||||
|
||||
(specification 规格型号、含税金额等本期不传。)
|
||||
|
||||
---
|
||||
|
||||
## 9. A303-2 收票单保存 + A302-6 指定应付
|
||||
|
||||
### A303-2 收票单批量保存
|
||||
|
||||
| 金蝶入参 | 含义 | 来源 | 表.字段 / 配置 / 常量 |
|
||||
|---|---|---|---|
|
||||
| awrp_src_code【必·候选键】 | 来源单据号 | 收票登记 id(一票一登记) | blade_invoice_receipt.id |
|
||||
| awrp_src_sys / awrp_src_type【必】 | 系统/单据类型 | 配置 / 常量 | `thirdparty.bfi.srcSys`;`TMS收票登记` |
|
||||
| invoiceno | 发票号码 | 收票登记发票号 | blade_invoice_receipt.invoice_no |
|
||||
| receivedate【必】 | 收票日期 | 推送当日 | LocalDate.now() |
|
||||
| issuedate【必】 | 开票日期 | 票池镜像票面 | blade_kingdee_invoice_pool.invoice_date |
|
||||
| biztype【必】 | 业务类型 | 固定采购 | `PUR` |
|
||||
| invoicetypef7_number【必】 | 发票类型.编码 | 票易通原始编码(缺失拦截并按票号回源 A801-1 补填) | blade_kingdee_invoice_pool.invoice_type_code |
|
||||
| paymenttype【必】 | 付款方式 | 固定现购 | `CASH` |
|
||||
| asstacttype【必】 | 往来类型 | 统一固定客户(与应付单/付款申请同口径) | `bd_customer` |
|
||||
| org_number / currency_number【必】 | 组织/币种 | 分摊应付结算单关联合同 → 项目业务部门上溯平台公司 | kingdeeOrgNumber → 平台公司编码 / settlement_currency |
|
||||
| receivablessupp_number【必】 | 往来户.编码 | 合同乙方→客商编码 | blade_contract_manage.party_b → customer_code |
|
||||
| exratetable_number【必】 | 汇率表 | 固定 | `"01"` |
|
||||
| asstactname / buyertin / sellertin【必】 | 销方名称/购销方税号 | 票池镜像票面 | blade_kingdee_invoice_pool.issuer_name / buyer_tax_no / saler_tax_no |
|
||||
| buyername【必】 | 购方名称 | 镜像受票单位 | blade_kingdee_invoice_pool.receiver_name |
|
||||
| sellertel / sellerbank / selleracct | 销方电话/开户行/账号 | 镜像 | blade_kingdee_invoice_pool.phone / bank_name / bank_account |
|
||||
| isincludetax | 含税录入 | 固定 | `true` |
|
||||
| entry[] | 分录 | 与 A102-2 同源同口径(复用 resolveEntryGroups) | 同 4.2 |
|
||||
|
||||
### A302-6 收票指定应付
|
||||
|
||||
| 入参 | 来源 |
|
||||
|---|---|
|
||||
| op | 常量 `"assign"` |
|
||||
| ap_invoice[0].awrp_src_code / awrp_src_sys | blade_invoice_receipt.id / `thirdparty.bfi.srcSys` |
|
||||
| ap_finapbill[0].awrp_src_code / awrp_src_sys | 应付防重键(结算单号_明细单据号)/ 同上 |
|
||||
|
||||
---
|
||||
|
||||
## 10. A301-3 收款认领结果推送
|
||||
|
||||
| 金蝶入参 | 含义 | 来源 | 表.字段 / 配置 / 常量 |
|
||||
|---|---|---|---|
|
||||
| billid【必】 | 认领公告 id | 流水同步落库(缺失拦截「请先同步金蝶收款流水」) | blade_kingdee_receipt_flow.bill_id |
|
||||
| awrp_src_sys【必】 | 来源系统 | 配置 | `thirdparty.bfi.srcSys` |
|
||||
| awrp_src_code【必】 | 来源单据编码 | 该流水全部分摊结算单号去重下划线拼接(推送时生成落库,分摊不变重试复用) | blade_receipt_claim_settlement.formal_settlement_no(status=1,结算单 id 升序)→ 回写 blade_kingdee_receipt_flow.awrp_src_code |
|
||||
| recpaytype_number【必】 | 收款类型 | **常量当前值 `"999"`**(注释「示例 101 待金蝶确认」) | KingdeeBfiConstant.BFI_CLAIM_REC_PAY_TYPE |
|
||||
| paymenttype【必】 | 付款人类型 | 固定 | `bd_customer` |
|
||||
| recviewpayer / recpayer【必】 | 付款人名称/id | 流水付款方 | blade_kingdee_receipt_flow.payer_name / payer_id |
|
||||
| entryentity[0].e_receivableamt【必】 | 应收金额(单条分录) | 全部有效认领分摊金额合计 | SUM(blade_receipt_claim_settlement.allocated_receipt_amount) |
|
||||
| entryentity[0].seq / e_corebillno【必】 | 行序/核心单据编号 | 第一张分摊结算单 id / 其金蝶星瀚内码(缺失拦截「未同步金蝶」) | blade_receipt_claim_settlement.formal_settlement_id / blade_formal_settlement.kingdee_bill_id |
|
||||
| entryentity[0].e_corebilltype | 核心单据类型 | **注释不传** | — |
|
||||
|
||||
---
|
||||
|
||||
## 11. lockBusiData 单据锁定
|
||||
|
||||
| 入参 | 含义 | 来源 |
|
||||
|---|---|---|
|
||||
| lockAction【必】 | 操作 | 常量 `LOCK` |
|
||||
| sourceSystem【必】 | 源系统 | `thirdparty.bfi.srcSys`(默认 WLTMS) |
|
||||
| targetSystem【必】 | 目标系统 | 常量 `XH` |
|
||||
| operationType【必】 | 操作类型 | 常量 `SAVE` |
|
||||
| sourceBillType【必】 | 源单据类型 | 见下表常量 |
|
||||
| sourceBillNo【必】 | 本地单据号 | 见下表 |
|
||||
| targetBillType【必】 | 金蝶单据类型 | 见下表 |
|
||||
| targetBillNo【必】 | 金蝶单号(逐张) | 见下表 |
|
||||
| operator | 操作人 | 当前用户名(定时/补偿兜底 `system`;补偿取 blade_kingdee_bill_lock.lock_operator) |
|
||||
| time | 操作时间 | 当前时间 `yyyy-MM-dd HH:mm:ss` |
|
||||
|
||||
| sourceBillType | sourceBillNo | targetBillType | targetBillNo |
|
||||
|---|---|---|---|
|
||||
| `SETTLEMENT` | blade_formal_settlement.formal_settlement_no | receivable→`arfin_other_BT_S`;否则 `ApFin_other_BT_S` | 推送返回 billNo(多张逗号拆分逐张锁) |
|
||||
| `PAY_APPLY` | blade_payment_application.payment_no | `ap_payapply_oth_BT_S` | blade_payment_application.kingdee_bill_no |
|
||||
| `INVOICE_APPLY` | blade_invoice_application.application_no | `cdm_paybill_bank_elect_BT_S` | 推送返回 billNo(多张逗号拆分) |
|
||||
| `RECEIVED_INVOICE` | — | `cdm_recbill_bank_BT_S` | 预留,**本期不锁** |
|
||||
|
||||
> 锁记录表 blade_kingdee_bill_lock(lock_status: failed/locked/unlocked,try_count≤5);开关 `thirdparty.bfi.billLock.enabled`(默认 true)。代码:KingdeeBillLockServiceImpl L331-344、KingdeeLockConstant L24-87。
|
||||
|
||||
---
|
||||
|
||||
## 12. A902 附件上传
|
||||
|
||||
multipart 上传:`file`=文件流,`args`=JSON `{entityNumber, billPkId, controlKey}`。
|
||||
|
||||
| 入参 | 来源 |
|
||||
|---|---|
|
||||
| entityNumber(星止单据类型) | biz_type 映射:`pay_application`→ap_payapply;`formal_settlement_ap`→ap_finapbill;`formal_settlement_ar`→ar_finarbill;`invoice_application`→sim_original_bill;`invoice_receipt`→ap_invoice |
|
||||
| billPkId(星瀚内码) | 推送落库的金蝶内码:blade_payment_application.kingdee_bill_id / blade_invoice_application.kingdee_bill_id / 结算单与收票推送返回内码(为空整单跳过待重推补传) |
|
||||
| controlKey | 常量 `attachmentpanel` |
|
||||
| 文件名 | 业务表 attachments_json 行内 originalName/name/fileName(空兜底 attachment;票池镜像兜底「发票文件」) |
|
||||
| 文件流 | 业务表 attachments_json 的 url 下载;收票票池镜像按 path 经 A801-2 下载 |
|
||||
|
||||
- 附件存储:各业务单据 `attachments_json` 列(非 blade_attachment 表);同步流水落 `blade_kingdee_attachment_sync`(biz_type/biz_id/attachment_key/kingdee_path/sync_status/fail_reason)。
|
||||
- 单文件软限制 20MB,超限拦截(重推时跳过该件继续其余)。
|
||||
- 调用时机:推送成功事务提交后联带上传,失败不污染主单状态;重推补传。
|
||||
> 代码:KingdeeAttachmentConstant L45-108、KingdeeAttachmentSyncSupport L125-264、BfiServiceImpl L613-638。
|
||||
|
||||
---
|
||||
|
||||
## 13. 查询类接口入参简表
|
||||
|
||||
| 接口 | 关键入参 | 取值来源 / 常量 / 配置 |
|
||||
|---|---|---|
|
||||
| A102-4 应付单查询 | awrp_src_code 多值(50/批)、awrp_src_sys、pageSize 200 | 应付防重键批次;`thirdparty.bfi.srcSys` |
|
||||
| A302-4 应收单查询 | 同上(50/批、200) | 应收防重键批次 |
|
||||
| A103-2 付款申请单查询 | awrp_src_code=paymentNo(50/批)、pageSize 200 | blade_payment_application.payment_no |
|
||||
| A103-4 付款处理查询 | org_number(优先取 A103-2 返回 payorg_number,兜底合同组织)、业务日期今天回溯 1 个月、200/页 | A103-2 返回值 → blade_dept.kingdee_dept_code |
|
||||
| A303-4 开票申请查询 | billno 多值(application_no、-张次序号)、pageSize=max(张数,10) | blade_invoice_application(.sheet) |
|
||||
| A303-5 开票发票查询(openApi) | serialNo=金蝶单号逗号拆分(≤50/批)、sellerTaxpayerId=开票单位税号、请求体 Base64 | blade_invoice_application.kingdee_bill_no;blade_customer_archive.unified_credit_code |
|
||||
| A801-3 收票单查询 | buyername(按镜像受票单位分组)、modifytimeS/E=回看 30 天、200/页;本地按 srcCode=登记 id 匹配 | blade_kingdee_invoice_pool.receiver_name;`thirdparty.bfi.receivedInvoice.queryWindowDays/resultWindowDays`(默认 30);blade_invoice_receipt.id |
|
||||
| A801-1 票池查询 | invoiceStatus=`0`(正常票);invoiceType 单值(白名单逐轮);updateTime 窗口=incrementDays 增量、2.5 万触顶二分;500/页。**expenseStatus 不下推**(可用性本地过滤 amount_use_status=0 且 available_amount>0) | `thirdparty.bfi.invoiceReceipt.invoiceTypes / incrementDays`(30);本地表 blade_kingdee_invoice_pool |
|
||||
| A801-2 附件下载 | path=文件地址(镜像优先、实时回源兜底;优先级 pdfurl→taxPdfUrl→downloadUrl,fileType=4 判 OFD) | blade_kingdee_invoice_pool.attachments_json |
|
||||
| A301-1 收款单查询 | 业务日期今天回溯 30 天、200/页;勾稽 sourcebillnumber==awrp_src_code | blade_kingdee_receipt_flow.awrp_src_code |
|
||||
| A301-2 认领公告查询 | claimusers=**Nacos 配置值**(登录人入参已废弃)、modifytimeS/E=回溯 months 个月、200/页;billstatus 字段存在但不传 | `thirdParty.bfi.claimAnnounce.claimusers`(必配)、`.months`(6) |
|
||||
|
||||
---
|
||||
|
||||
## 14. 与总纲记载不一致的核对结论(已按代码核实)
|
||||
|
||||
| # | 事项 | 代码事实 |
|
||||
|---|---|---|
|
||||
| 1 | A103-1 的 exratetable_number / awrp_textfield1 | **实际不送**(L1245/L1249 注释);配置 `payApply.exratetable=01` 只在 A103-3 生效 |
|
||||
| 2 | A103-3 项目编码 | **实际固定 999**,「取项目档案 projectCode 真实编码」逻辑已注释停用(bfiProjectNumber L1508-1513)——总纲 12.2-#2「仅 A103-3 用真实编码」的说法作废,全部链路均为 999 |
|
||||
| 3 | A301-3 recpaytype_number | 常量当前值 **"999"**(非旧文档写的 101;注释「示例 101 待金蝶确认」) |
|
||||
| 4 | A301-2 claimusers | 只读 Nacos 配置,方法入参(当前登录人姓名)已不参与取值;billstatus 字段存在但不传 |
|
||||
| 5 | A801-1 expenseStatus | 同步**不下推**(常量已定义未使用),「在用/已用票随窗口刷新」,可选票过滤在本地 SQL |
|
||||
| 6 | 应付 asstact_number | 取**合同乙方 party_b**(payer_name 仅应收侧使用) |
|
||||
| 7 | 组织编码层级 | 核算/结算/付款/收款/开票**组织类字段已统一改为公司级编码**:`kingdeeOrgNumber`(L976-999)按项目业务部门沿部门树上溯 `is_platform_company=1` 平台公司取其 `kingdee_dept_code`;仅 A303-1 申请部门(awrp_orgfield)保留部门级编码。部门级编码(如工业物流中心 01.02.05.06.01.015)不再直接作为结算/付款组织送出,自动替换为其平台公司编码(如国安运输 01.02.05.06.01);前置数据要求见《2026-10-16-金蝶组织编码部门数据维护清单》 |
|
||||
|
||||
> 校验提醒:以上配置键的**环境实际值**以 Nacos 为准(本文只列键名与代码默认值);blade_* 表列名按 Entity 驼峰↔下划线约定标注,个别快照列以建表 SQL 为准。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user