🔀 合并 origin/master 到 dev

This commit is contained in:
2026-10-19 19:03:06 +08:00
124 changed files with 7909 additions and 803 deletions
@@ -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();
@@ -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("获取数据失败");
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -33,7 +33,7 @@ public class PreSettlementDetailAdjustRequest implements Serializable {
@Schema(description = "预结算明细ID")
private Long detailId;
@Schema(description = "调整原因")
@Schema(description = "备注(写入结算明细备注,非必填)")
private String changeReason;
@Schema(description = "明细费用行")
@@ -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;
}
@@ -172,4 +172,12 @@ public class FormalSettlement extends TenantEntity {
* 作废原因,驳回时记录驳回原因
*/
private String voidReason;
/**
* 来源原正式结算单号:结算调整重推生成的新单,指向被调整的原单
*/
private String originFormalSettlementNo;
/**
* 调整状态:adjusted-已被结算调整(原单标记)
*/
private String adjustStatus;
}
@@ -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,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,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;
/**
* 原金额
*/
@@ -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;
}
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
}
@@ -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;
}
@@ -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<>();
}
@@ -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;
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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);
}
@@ -132,6 +132,10 @@ public class User extends TenantEntity implements IPhone {
* 是否是oa的账号
*/
private Integer isOa;
/**
* OA登录名
*/
private String oaLoginId;
/**
* OA上次更新时间(OA 的 modified 字段),仅同步比对使用
*/
@@ -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);
}
@@ -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);
@@ -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);
@@ -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));
}
/**
@@ -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);
@@ -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)));
}
@@ -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);
/**
* 获取用户
@@ -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) &gt; 0)
)
)
</if>
ORDER BY bu.id
ORDER BY bu.create_time DESC, bu.id DESC
</select>
<select id="getUser" resultMap="userResultMap">
@@ -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);
}
@@ -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();
@@ -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());
}
/**
@@ -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)) {
@@ -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
@@ -307,6 +307,12 @@ public class BfiProperties {
*/
private String invoiceTypes = "";
/**
* 客商单位过滤开关:开启后只同步开票单位(销方)与受票单位(购方)税号均已登记在
* 客商发票信息(blade_customer_invoice_info)中的发票,前端票池展示查询同用此开关过滤,默认关闭
*/
private boolean customerUnitsOnly = false;
/**
* 同步 single-flight 锁租约秒数,须大于最坏一轮耗时;进程异常退出时锁在租约到期后自动释放
*/
@@ -32,6 +32,9 @@ public final class KingdeeLockConstant {
/** 来源操作类型:单据删除(解锁回调携带 DELETE 时 WLTMS 废除源单并还原关联数据) */
public static final String OPERATION_DELETE = "DELETE";
/** 来源操作类型:结算调整重推(结算调整单重推后解锁原结算单旧锁记录,非金蝶回调) */
public static final String OPERATION_ADJUST_REPUSH = "ADJUST_REPUSH";
/** 系统标识:WLTMS(运输管理系统,即本系统) */
public static final String SYSTEM_WLTMS = "WLTMS";
@@ -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)); }
}
@@ -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));
}
}
@@ -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;
@@ -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("公路运输", "铁路运输", "水路运输", "航空运输");
}
}
@@ -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);
}
@@ -29,4 +29,13 @@ public interface FormalSettlementMapper extends BaseMapper<FormalSettlement> {
/** 恢复逻辑删除的正式结算单主记录。 */
@Update("UPDATE blade_formal_settlement SET is_deleted = 0 WHERE tenant_id = #{tenantId} AND id = #{id} AND is_deleted = 1")
int restoreByIdIncludingDeleted(@Param("tenantId") String tenantId, @Param("id") Long id);
/**
* 查询指定租户、指定日期前缀下已使用的最大正式结算单流水号。
* 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(formal_settlement_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_formal_settlement WHERE tenant_id = #{tenantId} "
+ "AND formal_settlement_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
}
@@ -10,9 +10,20 @@ package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springblade.transport.pojo.entity.FormalSettlementPayment;
/** 正式结算付款申请 Mapper。 @author Chill */
@Mapper
public interface FormalSettlementPaymentMapper extends BaseMapper<FormalSettlementPayment> {
/**
* 查询指定租户、指定日期前缀下已使用的最大付款申请流水号。
* 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(payment_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_formal_settlement_payment WHERE tenant_id = #{tenantId} "
+ "AND payment_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
}
@@ -27,6 +27,8 @@ package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springblade.transport.pojo.entity.InvoiceApplication;
/**
@@ -36,4 +38,13 @@ import org.springblade.transport.pojo.entity.InvoiceApplication;
*/
@Mapper
public interface InvoiceApplicationMapper extends BaseMapper<InvoiceApplication> {
/**
* 查询指定租户、指定日期前缀下已使用的最大开票申请流水号。
* 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(application_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_invoice_application WHERE tenant_id = #{tenantId} "
+ "AND application_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
}
@@ -44,11 +44,13 @@ public interface KingdeeInvoicePoolMapper extends BaseMapper<KingdeeInvoicePool>
/**
* 金蝶票据池镜像统一分页查询:仅出可用票(剩余可用金额>0 且未使用),认领/登记标记由 EXISTS 子查询直出
*
* @param page 分页参数
* @param query 统一查询条件(已在上层完成 trim 与日期格式校验)
* @param tenantId 当前租户
* @param page 分页参数
* @param query 统一查询条件(已在上层完成 trim 与日期格式校验)
* @param tenantId 当前租户
* @param customerUnitsOnly 客商单位过滤开关:开启时只出销/购方税号均登记在客商发票信息中的发票(与定时同步同开关)
* @return 统一视图分页
*/
IPage<KingdeeInvoicePoolVO> selectInvoicePoolPage(IPage<KingdeeInvoicePoolVO> page,
@Param("query") KingdeeInvoicePoolQueryDTO query, @Param("tenantId") String tenantId);
@Param("query") KingdeeInvoicePoolQueryDTO query, @Param("tenantId") String tenantId,
@Param("customerUnitsOnly") boolean customerUnitsOnly);
}
@@ -39,12 +39,24 @@
AND p.available_amount &gt; 0 AND p.amount_use_status = '0'
</sql>
<!-- 客商单位过滤:开票单位(销方)与受票单位(购方)税号均须登记在有效客商发票信息中
(与定时同步拉取过滤同开关同口径;自定义 SQL 无租户插件,手工带租户与逻辑删除) -->
<sql id="customerUnitsFilter">
AND EXISTS (SELECT 1 FROM blade_customer_invoice_info ci
WHERE ci.is_deleted = 0 AND ci.status = 1 AND ci.tenant_id = p.tenant_id AND ci.tax_no = p.saler_tax_no)
AND EXISTS (SELECT 1 FROM blade_customer_invoice_info cj
WHERE cj.is_deleted = 0 AND cj.status = 1 AND cj.tenant_id = p.tenant_id AND cj.tax_no = p.buyer_tax_no)
</sql>
<select id="selectInvoicePoolPage" resultType="org.springblade.transport.pojo.vo.KingdeeInvoicePoolVO">
SELECT
<include refid="invoicePoolVoColumns"/>
FROM blade_kingdee_invoice_pool p
WHERE p.is_deleted = 0 AND p.status = 1 AND p.tenant_id = #{tenantId}
<include refid="usableFilter"/>
<if test="customerUnitsOnly">
<include refid="customerUnitsFilter"/>
</if>
<if test="query.invoiceNo != null and query.invoiceNo != ''">
AND p.invoice_no LIKE CONCAT('%', #{query.invoiceNo}, '%')
</if>
@@ -21,9 +21,20 @@ package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springblade.transport.pojo.entity.PaymentApplication;
/** 付款申请 Mapper。 @author Chill */
@Mapper
public interface PaymentApplicationMapper extends BaseMapper<PaymentApplication> {
/**
* 查询指定租户、指定日期前缀下已使用的最大付款申请流水号。
* 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(payment_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_payment_application WHERE tenant_id = #{tenantId} "
+ "AND payment_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
}
@@ -10,6 +10,8 @@ package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springblade.transport.pojo.entity.PreSettlementAdvance;
/**
@@ -19,4 +21,12 @@ import org.springblade.transport.pojo.entity.PreSettlementAdvance;
*/
@Mapper
public interface PreSettlementAdvanceMapper extends BaseMapper<PreSettlementAdvance> {
/**
* 查询预付单号最大流水(含逻辑删除)。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(advance_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_pre_settlement_advance WHERE tenant_id = #{tenantId} "
+ "AND advance_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
}
@@ -10,6 +10,8 @@ package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springblade.transport.pojo.entity.PreSettlement;
/**
@@ -19,4 +21,20 @@ import org.springblade.transport.pojo.entity.PreSettlement;
*/
@Mapper
public interface PreSettlementMapper extends BaseMapper<PreSettlement> {
/**
* 查询预结算单号最大流水(含逻辑删除)。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(pre_settlement_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_pre_settlement WHERE tenant_id = #{tenantId} "
+ "AND pre_settlement_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxPreSettlementDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
/**
* 查询预结算转正式结算单号最大流水(含逻辑删除)。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(formal_settlement_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_pre_settlement WHERE tenant_id = #{tenantId} "
+ "AND formal_settlement_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxFormalSettlementDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
}
@@ -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);
}
@@ -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);
}
@@ -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>
@@ -22,4 +22,13 @@ public interface TransportReconciliationMapper extends BaseMapper<TransportRecon
/** 恢复逻辑删除的运输对账单主记录。 */
@Update("UPDATE blade_transport_reconciliation SET is_deleted = 0 WHERE tenant_id = #{tenantId} AND id = #{id} AND is_deleted = 1")
int restoreByIdIncludingDeleted(@Param("tenantId") String tenantId, @Param("id") Long id);
/**
* 查询指定租户、指定日期前缀下已使用的最大对账单流水号。
* 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(reconciliation_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_transport_reconciliation WHERE tenant_id = #{tenantId} "
+ "AND reconciliation_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
}
@@ -1,6 +1,7 @@
package org.springblade.transport.mk;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.tenant.annotation.TenantIgnore;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.pojo.dto.BillPaymentStatusRequest;
@@ -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);
}
}
@@ -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();
@@ -24,6 +24,8 @@ import org.springblade.transport.pojo.dto.FormalSettlementPaymentRequest;
import org.springblade.transport.pojo.dto.FormalSettlementBatchPaymentRequest;
import org.springblade.transport.pojo.dto.FormalSettlementInvoiceClaimRequest;
import java.util.Map;
/**
* 正式结算单服务
*
@@ -93,4 +95,29 @@ public interface IFormalSettlementService extends BaseService<FormalSettlement>
String applyPayment(FormalSettlementPaymentRequest request);
List<String> applyPayments(FormalSettlementBatchPaymentRequest request);
void claimInvoices(FormalSettlementInvoiceClaimRequest request);
/**
* 结算调整重推:克隆原正式结算单为新单(含明细、明细费项、汇总费项、来源预结算、附件、汇率),
* 新单置审批通过、未同步金蝶,来源原单号指向原单;下游关联(开票申请/收款认领/对账)不迁移。
* 不推送金蝶,由调用方在事务外调用 syncKingdee 推送
*
* @param sourceSettlementId 原正式结算单id
* @return 新单id与「原明细/原费项 -&gt; 新明细/新费项」id映射(供调整明细应用到新单)
*/
CloneResult cloneFormalSettlement(Long sourceSettlementId);
/**
* 结算调整重推收尾:原单标记「已调整」、旧锁解锁、追加重推变更记录(不改原单业务数据)
*
* @param settlementId 原正式结算单id
* @param adjustmentNo 结算调整单号
* @param newSettlementNo 重推生成的新单号
*/
void markSettlementAdjusted(Long settlementId, String adjustmentNo, String newSettlementNo);
/**
* 克隆结果:新单id + 原明细id-&gt;新明细id + 原费项id-&gt;新费项id
*/
record CloneResult(Long settlementId, Map<Long, Long> detailIdMap, Map<Long, Long> feeIdMap) {
}
}
@@ -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 为终态不返回(前端视为无锁/已解锁)
@@ -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);
}
@@ -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);
}
@@ -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();
}
@@ -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<>());
}
}
@@ -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()
@@ -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) {
@@ -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 -&gt; 项目预付核销合计(列表页批量传入避免 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); }
@@ -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) {
@@ -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) {
}
@@ -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 -> "金蝶锁单";
});
}
}
@@ -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);
}
}
@@ -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("仅正式生效且审批通过的项目允许发起变更");
}
}
@@ -252,8 +252,8 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl<ReceiptClaimM
BigDecimal flowClaimedAfter = money(flow.getClaimedAmount())
.subtract(money(claim.getClaimAmount())).max(BigDecimal.ZERO);
flow.setClaimedAmount(flowClaimedAfter);
// 认领状态对照公告未认领金额重算(作废分摊不计入累计)
flow.setClaimStatus(amountClaimStatus(flowClaimedAfter, flow.getUnclaimedAmount()));
// 认领状态对照可认领上限重算(作废分摊不计入累计)
flow.setClaimStatus(amountClaimStatus(flowClaimedAfter, claimCeiling(flow)));
receiptFlowMapper.updateById(flow);
// 作废不生成金蝶认领冲单:金蝶单号/状态保留原值(未推送为空,已推送为真实收款单信息),
@@ -411,13 +411,28 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl<ReceiptClaimM
}
/**
* 累计认领金额 → 流水认领状态:对照公告未认领金额判定(与收款流水侧口径一致)
* 累计认领金额 → 流水认领状态:对照可认领上限判定(与收款流水侧口径一致)
*/
private String amountClaimStatus(BigDecimal claimedAmount, BigDecimal unclaimedAmount) {
private String amountClaimStatus(BigDecimal claimedAmount, BigDecimal claimCeiling) {
if (claimedAmount.compareTo(BigDecimal.ZERO) <= 0) {
return "unclaimed";
}
return claimedAmount.compareTo(money(unclaimedAmount)) >= 0 ? "claimed" : "partial";
return claimedAmount.compareTo(money(claimCeiling)) >= 0 ? "claimed" : "partial";
}
/**
* 可认领上限:公告未认领金额与收款金额取较小正值(与收款流水侧口径一致)
*/
private BigDecimal claimCeiling(KingdeeReceiptFlow flow) {
BigDecimal unclaimed = money(flow.getUnclaimedAmount());
BigDecimal receipt = money(flow.getReceiptAmount());
if (receipt.compareTo(BigDecimal.ZERO) <= 0) {
return unclaimed;
}
if (unclaimed.compareTo(BigDecimal.ZERO) <= 0) {
return unclaimed;
}
return unclaimed.min(receipt);
}
private String amountStatus(BigDecimal paidAmount, BigDecimal settlementAmount) {
@@ -194,12 +194,16 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
.eq(ReceiptFlowRecord::getReceiptFlowId, id)
.in(ReceiptFlowRecord::getActionType, List.of("claim", "push"))
.orderByDesc(ReceiptFlowRecord::getCreateTime)));
// 继续认领:带回本流水历史关联且仍有剩余可认领金额的结算单
vo.setRelatedSettlements(relatedSettlementCandidates(id));
return vo;
}
@Override
public List<Map<String, Object>> settlementCandidates(String keyword, Long flowId) {
existing(flowId);
// 本流水历史关联结算单优先保留(避免仅按创建时间 limit 截断后选不到)
Set<Long> relatedIds = relatedSettlementIds(flowId);
List<FormalSettlement> settlements = formalSettlementMapper.selectList(
Wrappers.<FormalSettlement>lambdaQuery()
.eq(FormalSettlement::getSettlementType, RECEIVABLE)
@@ -210,7 +214,24 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
.or().like(FormalSettlement::getProjectName, keyword)
.or().like(FormalSettlement::getContractName, keyword))
.orderByDesc(FormalSettlement::getCreateTime)
.last("limit 200"));
.last("limit 500"));
if (!relatedIds.isEmpty()) {
Set<Long> loadedIds = settlements.stream().map(FormalSettlement::getId)
.filter(Objects::nonNull).collect(Collectors.toSet());
List<Long> missingIds = relatedIds.stream().filter(id -> !loadedIds.contains(id)).toList();
if (!missingIds.isEmpty()) {
List<FormalSettlement> relatedSettlements = formalSettlementMapper.selectList(
Wrappers.<FormalSettlement>lambdaQuery()
.in(FormalSettlement::getId, missingIds)
.eq(FormalSettlement::getSettlementType, RECEIVABLE)
.eq(FormalSettlement::getApprovalStatus, APPROVED)
.eq(FormalSettlement::getStatus, 1));
List<FormalSettlement> merged = new ArrayList<>(relatedSettlements.size() + settlements.size());
merged.addAll(relatedSettlements);
merged.addAll(settlements);
settlements = merged;
}
}
// 候选结算单的累计认领金额一次聚合查询(避免逐单 N+1)
Map<Long, BigDecimal> claimedMap = receiptClaimedAmounts(settlements.stream()
.map(FormalSettlement::getId).toList());
@@ -218,6 +239,14 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
.map(settlement -> candidateRow(settlement,
claimedMap.getOrDefault(settlement.getId(), BigDecimal.ZERO)))
.filter(row -> ((BigDecimal) row.get("remainingReceiptAmount")).compareTo(BigDecimal.ZERO) > 0)
.sorted((left, right) -> {
boolean leftRelated = relatedIds.contains(toLong(left.get("id")));
boolean rightRelated = relatedIds.contains(toLong(right.get("id")));
if (leftRelated == rightRelated) {
return 0;
}
return leftRelated ? -1 : 1;
})
.toList();
}
@@ -234,6 +263,7 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
Wrappers.<ReceiptClaimSettlement>lambdaQuery()
.eq(ReceiptClaimSettlement::getFormalSettlementId, formalSettlementId)
.eq(ReceiptClaimSettlement::getStatus, 1)
.eq(ReceiptClaimSettlement::getIsDeleted, 0)
.orderByDesc(ReceiptClaimSettlement::getCreateTime));
if (relations.isEmpty()) {
return List.of();
@@ -315,19 +345,19 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
allocatedTotal = allocatedTotal.add(allocated);
}
// 公告未认领金额(认领上限与认领完成判定基准,A301-2 口径)
// 认领上限:公告未认领金额与收款金额取较小值(避免未认领金额异常偏大导致无法认领完成)
if (flow.getUnclaimedAmount() == null) {
throw new ServiceException("公告未认领金额缺失,请先手动同步金蝶收款流水");
}
BigDecimal unclaimedAmount = money(flow.getUnclaimedAmount());
if (unclaimedAmount.compareTo(BigDecimal.ZERO) <= 0) {
throw new ServiceException("该流水公告未认领金额为0,无需认领");
BigDecimal claimCeiling = claimCeiling(flow);
if (claimCeiling.compareTo(BigDecimal.ZERO) <= 0) {
throw new ServiceException("该流水可认领金额为0,无需认领");
}
// 流水认领前已认领金额
BigDecimal previousFlowClaimed = flowClaimedAmount(flow.getId());
if (previousFlowClaimed.add(allocatedTotal).compareTo(unclaimedAmount) > 0) {
throw new ServiceException("累计认领金额不能超过公告未认领金额,剩余可认领 "
+ unclaimedAmount.subtract(previousFlowClaimed).max(BigDecimal.ZERO));
if (previousFlowClaimed.add(allocatedTotal).compareTo(claimCeiling) > 0) {
throw new ServiceException("累计认领金额不能超过可认领上限,剩余可认领 "
+ claimCeiling.subtract(previousFlowClaimed).max(BigDecimal.ZERO));
}
// 当前用户部门
@@ -380,6 +410,9 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
relation.setClaimedReceiptAmount(previousClaimed);
// 本次分摊金额
relation.setAllocatedReceiptAmount(allocated);
// 有效分摊(与汇总/候选查询 status=1 口径一致)
relation.setStatus(1);
relation.setIsDeleted(0);
claimSettlementMapper.insert(relation);
}
@@ -390,8 +423,8 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
String fromStatus = normalizeClaimStatus(flow.getClaimStatus());
// 认领后累计金额
BigDecimal claimedAfter = previousFlowClaimed.add(allocatedTotal);
// 认领后状态(对照公告未认领金额)
String toStatus = amountClaimStatus(claimedAfter, unclaimedAmount);
// 认领后状态(对照可认领上限)
String toStatus = amountClaimStatus(claimedAfter, claimCeiling);
// 更新流水已认领金额
flow.setClaimedAmount(claimedAfter);
// 更新流水认领状态
@@ -499,9 +532,9 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
// 复制公告数据到实体(不覆盖本地认领字段)
copyAnnounceRow(entity, announce, detailSerialNo, receiptAmount);
// 根据本地累计认领金额与最新公告未认领金额重新计算认领状态
// 根据本地累计认领金额与可认领上限重新计算认领状态
entity.setClaimStatus(amountClaimStatus(money(entity.getClaimedAmount()),
entity.getUnclaimedAmount()));
claimCeiling(entity)));
if (created) {
toInsert.add(entity);
@@ -621,6 +654,46 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
}
}
/**
* 本流水历史关联过的结算单 id(有效分摊)
*/
private Set<Long> relatedSettlementIds(Long flowId) {
if (flowId == null) {
return Set.of();
}
return claimSettlementMapper.selectList(Wrappers.<ReceiptClaimSettlement>lambdaQuery()
.select(ReceiptClaimSettlement::getFormalSettlementId)
.eq(ReceiptClaimSettlement::getReceiptFlowId, flowId)
.eq(ReceiptClaimSettlement::getStatus, 1)
.eq(ReceiptClaimSettlement::getIsDeleted, 0)).stream()
.map(ReceiptClaimSettlement::getFormalSettlementId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(HashSet::new));
}
/**
* 继续认领预填:本流水历史关联且仍有剩余可认领金额的结算单
*/
private List<Map<String, Object>> relatedSettlementCandidates(Long flowId) {
Set<Long> relatedIds = relatedSettlementIds(flowId);
if (relatedIds.isEmpty()) {
return List.of();
}
List<FormalSettlement> settlements = formalSettlementMapper.selectList(
Wrappers.<FormalSettlement>lambdaQuery()
.in(FormalSettlement::getId, relatedIds)
.eq(FormalSettlement::getSettlementType, RECEIVABLE)
.eq(FormalSettlement::getApprovalStatus, APPROVED)
.eq(FormalSettlement::getStatus, 1));
Map<Long, BigDecimal> claimedMap = receiptClaimedAmounts(settlements.stream()
.map(FormalSettlement::getId).toList());
return settlements.stream()
.map(settlement -> candidateRow(settlement,
claimedMap.getOrDefault(settlement.getId(), BigDecimal.ZERO)))
.filter(row -> ((BigDecimal) row.get("remainingReceiptAmount")).compareTo(BigDecimal.ZERO) > 0)
.toList();
}
/**
* 批量聚合结算单的累计有效认领金额:一次 IN 查询按结算单分组求和,避免逐单 N+1
*/
@@ -631,7 +704,8 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
}
return claimSettlementMapper.selectList(Wrappers.<ReceiptClaimSettlement>lambdaQuery()
.in(ReceiptClaimSettlement::getFormalSettlementId, ids)
.eq(ReceiptClaimSettlement::getStatus, 1)).stream()
.eq(ReceiptClaimSettlement::getStatus, 1)
.eq(ReceiptClaimSettlement::getIsDeleted, 0)).stream()
.collect(Collectors.groupingBy(ReceiptClaimSettlement::getFormalSettlementId,
Collectors.reducing(BigDecimal.ZERO,
item -> money(item.getAllocatedReceiptAmount()), BigDecimal::add)));
@@ -640,12 +714,42 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
private BigDecimal flowClaimedAmount(Long flowId) {
return claimSettlementMapper.selectList(Wrappers.<ReceiptClaimSettlement>lambdaQuery()
.eq(ReceiptClaimSettlement::getReceiptFlowId, flowId)
.eq(ReceiptClaimSettlement::getStatus, 1)).stream()
.eq(ReceiptClaimSettlement::getStatus, 1)
.eq(ReceiptClaimSettlement::getIsDeleted, 0)).stream()
.map(ReceiptClaimSettlement::getAllocatedReceiptAmount)
.map(this::money)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
/**
* 可认领上限:公告未认领金额与收款金额取较小正值。
* 主口径仍为 A301-2 公告未认领金额;与收款金额取 min,避免未认领金额异常偏大导致无法进入「认领完成」。
*/
private BigDecimal claimCeiling(KingdeeReceiptFlow flow) {
BigDecimal unclaimed = money(flow.getUnclaimedAmount());
BigDecimal receipt = money(flow.getReceiptAmount());
if (receipt.compareTo(BigDecimal.ZERO) <= 0) {
return unclaimed;
}
if (unclaimed.compareTo(BigDecimal.ZERO) <= 0) {
return unclaimed;
}
return unclaimed.min(receipt);
}
private Long toLong(Object value) {
if (value == null) {
return null;
}
if (value instanceof Long longValue) {
return longValue;
}
if (value instanceof Number number) {
return number.longValue();
}
return Func.toLong(String.valueOf(value));
}
private KingdeeReceiptFlow lockedFlow(Long flowId) {
KingdeeReceiptFlow flow = baseMapper.selectOne(Wrappers.<KingdeeReceiptFlow>lambdaQuery()
.eq(KingdeeReceiptFlow::getId, flowId)
@@ -824,18 +928,20 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
flow.setClaimMe(announce.getClaimme());
BigDecimal claimedTotal = flowClaimedAmount(flow.getId());
flow.setClaimedAmount(claimedTotal);
String claimStatus = amountClaimStatus(claimedTotal, flow.getUnclaimedAmount());
BigDecimal claimCeiling = claimCeiling(flow);
String claimStatus = amountClaimStatus(claimedTotal, claimCeiling);
flow.setClaimStatus(claimStatus);
updateById(flow);
if (!CLAIMED.equals(claimStatus)) {
return ReceiptClaimPushResult.fail("累计认领金额(" + claimedTotal + ")与公告未认领金额("
+ money(flow.getUnclaimedAmount()) + ")不一致,不满足推送条件");
return ReceiptClaimPushResult.fail("累计认领金额(" + claimedTotal + ")与可认领上限("
+ claimCeiling + ")不一致,不满足推送条件");
}
// 分录取该流水全部有效分摊行(结算单id升序,跨认领主单归集)
List<ReceiptClaimSettlement> allocations = claimSettlementMapper.selectList(
Wrappers.<ReceiptClaimSettlement>lambdaQuery()
.eq(ReceiptClaimSettlement::getReceiptFlowId, flow.getId())
.eq(ReceiptClaimSettlement::getStatus, 1)
.eq(ReceiptClaimSettlement::getIsDeleted, 0)
.orderByAsc(ReceiptClaimSettlement::getFormalSettlementId));
if (allocations.isEmpty()) {
return ReceiptClaimPushResult.fail("该流水无有效认领分摊,无法推送");
@@ -1075,13 +1181,13 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
}
/**
* 累计认领金额 → 流水认领状态:对照公告未认领金额判定(claimed 即「认领完成」,唯一可推送状态)
* 累计认领金额 → 流水认领状态:对照可认领上限判定(claimed 即「认领完成」,唯一可推送状态)
*/
private String amountClaimStatus(BigDecimal claimedAmount, BigDecimal unclaimedAmount) {
private String amountClaimStatus(BigDecimal claimedAmount, BigDecimal claimCeiling) {
if (claimedAmount.compareTo(BigDecimal.ZERO) <= 0) {
return UNCLAIMED;
}
return claimedAmount.compareTo(money(unclaimedAmount)) >= 0 ? CLAIMED : PARTIAL;
return claimedAmount.compareTo(money(claimCeiling)) >= 0 ? CLAIMED : PARTIAL;
}
private String amountStatus(BigDecimal claimedAmount, BigDecimal settlementAmount) {
@@ -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));
});
}
@@ -4,12 +4,18 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.core.tool.utils.SpringUtil;
import org.springblade.system.cache.UserCache;
import org.springblade.transport.constant.KingdeeBfiConstant;
import org.springblade.transport.constant.TransportBusiConstant;
import org.springblade.transport.event.KingdeeBillApprovedEvent;
import org.springblade.transport.support.KingdeeApBillSyncSupport;
import org.springblade.transport.support.KingdeeArBillSyncSupport;
import org.springblade.transport.mapper.FormalSettlementDetailFeeMapper;
import org.springblade.transport.mapper.FormalSettlementDetailMapper;
import org.springblade.transport.mapper.FormalSettlementChangeRecordMapper;
@@ -33,7 +39,10 @@ import org.springblade.transport.pojo.entity.PaymentApplicationSettlement;
import org.springblade.transport.pojo.entity.SettlementAdjustment;
import org.springblade.transport.pojo.entity.SettlementAdjustmentDetail;
import org.springblade.transport.pojo.vo.SettlementAdjustmentVO;
import org.springblade.transport.service.IFormalSettlementService;
import org.springblade.transport.service.ISettlementAdjustmentService;
import org.springblade.transport.service.ISettlementProjectAdvanceWriteOffService;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -51,6 +60,7 @@ import java.util.Set;
@Service
@RequiredArgsConstructor
@Slf4j
public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementAdjustmentMapper, SettlementAdjustment>
implements ISettlementAdjustmentService {
private static final String PAID = "paid";
@@ -63,6 +73,11 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
private final FormalSettlementPaymentMapper formalPaymentMapper;
private final PaymentApplicationMapper paymentApplicationMapper;
private final PaymentApplicationSettlementMapper paymentApplicationSettlementMapper;
private final IFormalSettlementService formalSettlementService;
private final ISettlementProjectAdvanceWriteOffService projectAdvanceWriteOffService;
private final KingdeeApBillSyncSupport kingdeeApBillSyncSupport;
private final KingdeeArBillSyncSupport kingdeeArBillSyncSupport;
private final ApplicationEventPublisher eventPublisher;
@Override
public IPage<SettlementAdjustmentVO> selectPage(IPage<SettlementAdjustment> page, SettlementAdjustmentVO query) {
@@ -204,24 +219,142 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
}
@Override public void returnBill(SettlementAdjustmentStatusRequest request) { changeStatus(request.getId(), TransportBusiConstant.REVIEWING, TransportBusiConstant.RETURNED, "已驳回", limit(request.getReason(), 200)); }
/**
* 重新推送(高危):原单已推送金蝶且旧单已在金蝶冲销后,克隆新正式结算单并应用调整,
* 以新单号组成新防重键生成全新应付/应收单,调整单改挂新单、原单标记已调整并解锁旧锁。
* <p>
* 非事务编排:克隆+应用(独立事务)→ 推送(外部远程)→ 收尾回填,推送失败不回滚克隆,
* 重试时按来源原单号幂等复用已克隆新单,从推送步骤续上。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public String repush(Long adjustmentId) {
SettlementAdjustment adjustment = existing(adjustmentId);
if (!TransportBusiConstant.APPROVED.equals(adjustment.getApprovalStatus())) throw new ServiceException("仅审批通过的结算调整单允许重新推送");
FormalSettlement formal = formalMapper.selectById(adjustment.getFormalSettlementId());
if (formal == null || !"synced".equals(formal.getKingdeeSyncStatus())) throw new ServiceException("关联正式结算单尚未推送金蝶,无需重新推送");
String kingdeeNo = "K3AP" + DateTimeFormatter.ofPattern("yyyyMMddHHmmss").format(LocalDateTime.now());
formal.setKingdeeBillNo(kingdeeNo); formal.setSyncedTime(LocalDateTime.now()); formalMapper.updateById(formal);
return kingdeeNo;
if (!TransportBusiConstant.APPROVED.equals(adjustment.getApprovalStatus())) {
throw new ServiceException("仅审批通过的结算调整单允许重新推送");
}
FormalSettlement original = formalMapper.selectById(adjustment.getFormalSettlementId());
if (original == null) {
throw new ServiceException("关联正式结算单不存在");
}
if (!KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(original.getKingdeeSyncStatus())) {
throw new ServiceException("结算单[" + Func.toStr(original.getFormalSettlementNo())
+ "]尚未推送金蝶,请先执行「推送金蝶」");
}
// 冲销硬闸门:仅当金蝶侧原应付/应收单已被冲销(部分冲销放行)才允许以新单号重推,避免重复生成财务单据
if (!this.kingdeeWrittenOff(original)) {
throw new ServiceException("结算单[" + Func.toStr(original.getFormalSettlementNo())
+ "]的原应付/应收单尚未在金蝶冲销,禁止重推");
}
// 幂等:同一原单已克隆过新单则复用(重试从推送步骤续上),避免重复建单
FormalSettlement clone = existingClone(original.getFormalSettlementNo());
if (clone == null) {
Long cloneId = self().cloneAndApplyForRepush(original.getId(), adjustment.getId());
clone = formalMapper.selectById(cloneId);
}
// 推送新单:新单号天然组成新防重键,内部完成 A102-2/A302-2 推送 + 落锁 + 附件上传
// shortcut: 成功后若再次点击重推,调整单已改挂新单且新单未冲销,会被冲销闸门拦截(无副作用);如需彻底隐藏由前端按「关联单是否克隆单」判断
String billNo = formalSettlementService.syncKingdee(clone.getId());
// 成功收尾:调整单改挂新单 + 回填金蝶单号/同步状态;原单标记已调整 + 旧锁解锁(业务数据不动)
SettlementAdjustment patch = new SettlementAdjustment();
patch.setId(adjustment.getId());
patch.setFormalSettlementId(clone.getId());
patch.setFormalSettlementNo(clone.getFormalSettlementNo());
patch.setKingdeeSyncStatus(KingdeeBfiConstant.KINGDEE_STATUS_SYNCED);
patch.setKingdeeBillNo(billNo);
updateById(patch);
formalSettlementService.markSettlementAdjusted(original.getId(), adjustment.getAdjustmentNo(), clone.getFormalSettlementNo());
return billNo;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void approve(SettlementAdjustmentStatusRequest request) {
public Long cloneAndApplyForRepush(Long originalSettlementId, Long adjustmentId) {
IFormalSettlementService.CloneResult cloneResult = formalSettlementService.cloneFormalSettlement(originalSettlementId);
FormalSettlement clone = formalMapper.selectById(cloneResult.settlementId());
applyAdjustmentToSettlement(clone, existing(adjustmentId), cloneResult.detailIdMap(), cloneResult.feeIdMap());
return cloneResult.settlementId();
}
@Override
public String syncKingdee(Long adjustmentId) {
SettlementAdjustment adjustment = existing(adjustmentId);
if (!TransportBusiConstant.APPROVED.equals(adjustment.getApprovalStatus())) {
throw new ServiceException("仅审批通过的结算调整单允许推送金蝶");
}
// 未推送/生成失败场景:推送关联正式结算单,成功后由正式结算单回填调整单金蝶单号与同步状态
return formalSettlementService.syncKingdee(adjustment.getFormalSettlementId());
}
@Override
public Map<String, Object> kingdeeWriteOffPrecheck(Long formalSettlementId) {
Map<String, Object> result = new HashMap<>();
FormalSettlement settlement = formalMapper.selectById(formalSettlementId);
if (settlement == null) {
throw new ServiceException("关联正式结算单不存在");
}
if (!KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(settlement.getKingdeeSyncStatus())) {
result.put("syncStatus", KingdeeBfiConstant.KINGDEE_STATUS_UNSYNCED);
result.put("writtenOff", null);
result.put("message", "该结算单尚未推送金蝶,审批通过后将自动推送原单");
return result;
}
result.put("syncStatus", KingdeeBfiConstant.KINGDEE_STATUS_SYNCED);
try {
boolean writtenOff = this.kingdeeWrittenOff(settlement);
result.put("writtenOff", writtenOff);
result.put("message", writtenOff
? "该结算单已推送金蝶且旧单已冲销,审批后可直接重新推送新单"
: "该结算单已推送金蝶且旧单尚未冲销,审批通过后需先由财务在金蝶完成冲销,才能重新推送");
} catch (Exception exception) {
// 预检为软提示:金蝶查询异常降级为「未校验」,不阻断建单,由审批分流与重推闸门兜底
log.warn("结算调整单建单预检:金蝶冲销状态查询失败,降级未校验,正式结算单[{}]",
settlement.getFormalSettlementNo(), exception);
result.put("writtenOff", null);
result.put("message", "金蝶冲销状态查询失败,未校验;审批后请以实际冲销情况为准");
}
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public String approve(SettlementAdjustmentStatusRequest request) {
SettlementAdjustment adjustment = existing(request.getId());
if (!TransportBusiConstant.REVIEWING.equals(adjustment.getApprovalStatus())) throw new ServiceException("仅审批中的结算调整单允许审核");
FormalSettlement formal = adjustableFormalSettlement(adjustment.getFormalSettlementId());
boolean synced = KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(formal.getKingdeeSyncStatus());
if (synced) {
// 已推送场景:原单业务数据零改动,仅追加 1 条重推提示变更记录,待财务在金蝶手工冲销旧单后重推
saveFormalChange(formal.getId(), "合计费用项", null, "调整",
"结算调整单" + adjustment.getAdjustmentNo() + "审批通过,调整金额【" + money(adjustment.getAdjustmentAmount()) + "】,待重推金蝶",
adjustment.getRemark());
} else {
// 未推送场景:回写原单费用/金额并重算结算金额,事务提交后由事件监听自动推送金蝶
applyAdjustmentToSettlement(formal, adjustment, Map.of(), Map.of());
saveFormalChange(formal.getId(), "合计费用项", null, "调整",
"调整单" + adjustment.getAdjustmentNo() + "调整金额【" + money(adjustment.getAdjustmentAmount()) + "】",
adjustment.getRemark());
}
adjustment.setKingdeeSyncStatus(formal.getKingdeeSyncStatus()); adjustment.setApprovalStatus(TransportBusiConstant.APPROVED); adjustment.setCurrentNode("审批通过"); adjustment.setCurrentProcessor(AuthUtil.getUserName()); adjustment.setApprovedTime(LocalDateTime.now()); updateById(adjustment);
if (!synced) {
// 审批事务提交后(AFTER_COMMIT)自动推原单,成功回填调整单 synced + 金蝶单号、失败置 failed
eventPublisher.publishEvent(new KingdeeBillApprovedEvent(
KingdeeBillApprovedEvent.BIZ_FORMAL_SETTLEMENT, formal.getId()));
return null;
}
return "原结算单已推送金蝶,请财务人员在金蝶手工完成旧应付/应收单冲销处理后再重新推送";
}
/**
* 应用调整到目标正式结算单(未推送场景作用于原单,重推场景作用于克隆新单):
* 回写明细费项金额、汇总费用项、重算明细与结算金额;明细/费项 id 经映射转换(原单调整作用于原单时传空映射)。
*
* @param formal 目标正式结算单(改动后需落库)
* @param adjustment 结算调整单
* @param detailIdMap 原结算明细id -&gt; 目标结算明细id(原单场景传空 Map)
* @param feeIdMap 原明细费项id -&gt; 目标明细费项id(原单场景传空 Map)
*/
private void applyAdjustmentToSettlement(FormalSettlement formal, SettlementAdjustment adjustment,
Map<Long, Long> detailIdMap, Map<Long, Long> feeIdMap) {
for (SettlementAdjustmentDetail item : detailMapper.selectList(Wrappers.<SettlementAdjustmentDetail>lambdaQuery().eq(SettlementAdjustmentDetail::getAdjustmentId, adjustment.getId()))) {
if (money(item.getAdjustmentAmountTax()).signum() == 0) continue;
if (isManualFee(item)) {
@@ -229,12 +362,14 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
"新增【" + item.getFeeItem() + "】调整费用【" + money(item.getAdjustmentAmountTax()) + "】",
Func.isEmpty(item.getRemark()) ? adjustment.getRemark() : item.getRemark());
} else {
FormalSettlementDetailFee fee = validateFee(formal.getId(), item.getFormalSettlementDetailId(), item.getFormalSettlementDetailFeeId());
Long detailId = detailIdMap.getOrDefault(item.getFormalSettlementDetailId(), item.getFormalSettlementDetailId());
Long feeId = feeIdMap.getOrDefault(item.getFormalSettlementDetailFeeId(), item.getFormalSettlementDetailFeeId());
FormalSettlementDetailFee fee = validateFee(formal.getId(), detailId, feeId);
BigDecimal beforeAmount = money(fee.getSettlementAmountTax());
fee.setSettlementAmountTax(beforeAmount.add(money(item.getAdjustmentAmountTax())));
if (item.getAdjustmentAmountNoTax() != null) fee.setSettlementAmountNoTax(money(fee.getSettlementAmountNoTax()).add(item.getAdjustmentAmountNoTax()));
fee.setAdjustAmount(money(fee.getAdjustAmount()).add(item.getAdjustmentAmountTax())); formalFeeMapper.updateById(fee);
FormalSettlementDetail formalDetail = formalDetailMapper.selectById(item.getFormalSettlementDetailId());
FormalSettlementDetail formalDetail = formalDetailMapper.selectById(detailId);
saveFormalChange(formal.getId(), "结算明细项", formalDetail == null ? null : formalDetail.getLineNo(),
"调整", "【" + (Func.isEmpty(item.getFeeItem()) ? "结算费用" : item.getFeeItem())
+ "】从【" + beforeAmount + "】调整为【" + fee.getSettlementAmountTax() + "】",
@@ -249,15 +384,46 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
detail.setAdjustAmount(detail.getSettlementAmountTax().subtract(money(detail.getOriginalAmount()))); formalDetailMapper.updateById(detail);
}
BigDecimal amount = money(formal.getSettlementAmount()).add(money(adjustment.getAdjustmentAmount()));
// 调整后结算金额不得低于项目预付核销合计,否则已付将超过结算金额且无法拦截
BigDecimal writeOffTotal = this.projectAdvanceWriteOffService.sumBySettlement(
TransportBusiConstant.SETTLEMENT_BILL_FORMAL, formal.getId());
if (money(writeOffTotal).compareTo(money(amount)) > 0) {
throw new ServiceException("调整后结算金额不能低于项目预付核销合计" + money(writeOffTotal) + ",请先调减项目预付核销");
}
formal.setSettlementAmount(amount);
formal.setLocalSettlementAmount(amount.multiply(formal.getExchangeRate() == null ? BigDecimal.ONE : formal.getExchangeRate()));
formal.setRemainingPayableAmount(amount.subtract(money(formal.getPaidAmount()))
.max(BigDecimal.ZERO));
formal.setRemainingPayableAmount(amount.subtract(money(formal.getPaidAmount())).max(BigDecimal.ZERO));
formalMapper.updateById(formal);
saveFormalChange(formal.getId(), "合计费用项", null, "调整",
"调整单" + adjustment.getAdjustmentNo() + "调整金额【" + money(adjustment.getAdjustmentAmount()) + "】",
adjustment.getRemark());
adjustment.setKingdeeSyncStatus(formal.getKingdeeSyncStatus()); adjustment.setApprovalStatus(TransportBusiConstant.APPROVED); adjustment.setCurrentNode("审批通过"); adjustment.setCurrentProcessor(AuthUtil.getUserName()); adjustment.setApprovedTime(LocalDateTime.now()); updateById(adjustment);
// 重算收付汇总(含核销计入),保持付款状态与剩余可付准确
formalSettlementService.refreshPaymentSummary(formal.getId());
}
/** 涉事事务的自代理:跨方法调用确保 {@link #cloneAndApplyForRepush} 的事务注解生效 */
private ISettlementAdjustmentService self() {
return SpringUtil.getBean(ISettlementAdjustmentService.class);
}
/** 按来源原正式结算单号查已克隆新单(重推幂等:同一原单不重复建单) */
private FormalSettlement existingClone(String originFormalSettlementNo) {
List<FormalSettlement> clones = formalMapper.selectList(Wrappers.<FormalSettlement>lambdaQuery()
.eq(FormalSettlement::getOriginFormalSettlementNo, originFormalSettlementNo)
.orderByDesc(FormalSettlement::getCreateTime));
return clones.isEmpty() ? null : clones.get(0);
}
/** 按原单旧防重键(原结算单号_明细单据号)查金蝶是否已冲销(应付 A102-4 / 应收 A302-4,部分冲销放行) */
private boolean kingdeeWrittenOff(FormalSettlement settlement) {
List<FormalSettlementDetail> details = formalDetailMapper.selectList(Wrappers.<FormalSettlementDetail>lambdaQuery()
.eq(FormalSettlementDetail::getFormalSettlementId, settlement.getId())
.eq(FormalSettlementDetail::getIsDeleted, 0)
.orderByAsc(FormalSettlementDetail::getLineNo));
if (Func.isEmpty(details)) {
return false;
}
if ("receivable".equals(settlement.getSettlementType())) {
return kingdeeArBillSyncSupport.queryArBillWrittenOff(kingdeeArBillSyncSupport.arBillSrcCode(settlement, details));
}
return kingdeeApBillSyncSupport.queryApBillWrittenOff(kingdeeApBillSyncSupport.apBillSrcCode(settlement, details));
}
private void saveFormalChange(Long settlementId, String changeType, Integer lineNo,
@@ -392,7 +558,12 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
private SettlementAdjustmentVO toVO(SettlementAdjustment item) { SettlementAdjustmentVO vo = new SettlementAdjustmentVO(); org.springframework.beans.BeanUtils.copyProperties(item, vo); vo.setCreateUserName(UserCache.getUserRealName(item.getCreateUser())); vo.setApprovalStatusName(statusName(item.getApprovalStatus())); vo.setSettlementTypeName(typeName(item.getSettlementType())); return vo; }
private String statusName(String value) { return Map.of(TransportBusiConstant.DRAFT, "草稿", TransportBusiConstant.REVIEWING, "审批中", TransportBusiConstant.APPROVED, "审批通过", TransportBusiConstant.RETURNED, "已驳回").getOrDefault(value, value); }
private String typeName(String value) { return "receivable".equals(value) ? "应收" : "payable".equals(value) ? "应付" : ""; }
private synchronized String nextNo() { String prefix = "TZ" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); long count = count(Wrappers.<SettlementAdjustment>lambdaQuery().likeRight(SettlementAdjustment::getAdjustmentNo, prefix)); return prefix + String.format("%04d", count + 1); }
/** 生成当日结算调整单号;按最大流水号 +1,且计入逻辑删除数据 */
private synchronized String nextNo() {
String prefix = "TZ" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix);
return prefix + String.format("%04d", (maxSequence == null ? 0L : maxSequence) + 1);
}
private BigDecimal money(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; }
private String requiredText(String value, String field, int max) { if (Func.isEmpty(value)) throw new ServiceException(field + "不能为空"); return limit(value.trim(), max); }
private String limit(String value, int max) { if (value != null && value.length() > max) throw new ServiceException("内容不能超过" + max + "个字"); return value; }
@@ -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;
}
}
@@ -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);
@@ -0,0 +1,622 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.tenant.TenantUtil;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.constant.TransportBusiConstant;
import org.springblade.transport.mapper.PreSettlementDetailMapper;
import org.springblade.transport.mapper.ReceivablePayableDetailMapper;
import org.springblade.transport.pojo.dto.FormalSettlementSaveRequest;
import org.springblade.transport.pojo.dto.PreSettlementSaveRequest;
import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.entity.PreSettlement;
import org.springblade.transport.pojo.entity.PreSettlementDetail;
import org.springblade.transport.pojo.entity.ReceivablePayableDetail;
import org.springblade.transport.service.IContractManageService;
import org.springblade.transport.service.IFormalSettlementService;
import org.springblade.transport.service.IPreSettlementService;
import org.springblade.transport.service.ISettlementRuleAutoGenerateService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.YearMonth;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* 按合同结算规则自动生成结算草稿。
* <p>
* 账期在次日零点视为关闭:日结/现结取前一日,周结和固定天数从账单开始日期顺延,
* 月结按自然月、固定截单日或自定义多周期截取。没有可归集明细时不生成空单。
* 同一明细只进入一张结算单,预结算先于正式结算执行。正式结算同时归集费用日期完整落在账期内、且尚未转正式结算的已审批预结算单。
*
* @author Chill
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SettlementRuleAutoGenerateServiceImpl implements ISettlementRuleAutoGenerateService {
private static final int QUERY_BATCH_SIZE = 500;
private static final String LOCAL_CURRENCY = "RMB";
private final IContractManageService contractManageService;
private final IPreSettlementService preSettlementService;
private final IFormalSettlementService formalSettlementService;
private final ReceivablePayableDetailMapper receivablePayableDetailMapper;
private final PreSettlementDetailMapper preSettlementDetailMapper;
private final PlatformTransactionManager transactionManager;
@Override
public synchronized String generateDueSettlements() {
LocalDate today = LocalDate.now();
List<ContractManage> contracts = TenantUtil.ignore(this::listAutoContracts);
int preCount = 0;
int formalCount = 0;
int failedCount = 0;
for (ContractManage contract : contracts) {
if (Func.isBlank(contract.getTenantId())) {
log.warn("合同[{}]缺少租户,跳过自动生成结算单", contract.getContractNo());
continue;
}
try {
int[] created = TenantUtil.use(contract.getTenantId(), () -> generateContract(contract, today));
preCount += created[0];
formalCount += created[1];
} catch (Exception exception) {
failedCount++;
log.error("合同[{}]自动生成结算单失败", contract.getContractNo(), exception);
}
}
return "扫描合同" + contracts.size() + "份,生成预结算单" + preCount + "张,正式结算单" + formalCount
+ "张,失败" + failedCount + "份";
}
private List<ContractManage> listAutoContracts() {
return contractManageService.list(Wrappers.<ContractManage>lambdaQuery()
.in(ContractManage::getApprovalStatus, "approved", "change_approved")
.in(ContractManage::getContractCategory, "客户合同", "承运商合同")
.and(query -> query.isNull(ContractManage::getContractStage)
.or().ne(ContractManage::getContractStage, "terminated"))
.and(query -> query.like(ContractManage::getPreSettlementConfigJson, "autoGenerate")
.or().like(ContractManage::getFormalSettlementConfigJson, "autoGenerate")
.or().like(ContractManage::getSettlementRuleJson, "autoGenerate")));
}
private int[] generateContract(ContractManage contract, LocalDate today) {
String settlementType = contractSettlementType(contract);
if (settlementType == null || contract.getProjectId() == null || contract.getOrganizationId() == null) {
log.warn("合同[{}]缺少结算类型、项目或所属组织,跳过自动生成", contract.getContractNo());
return new int[]{0, 0};
}
int preCount = generateBills(contract, settlementType, resolveRule(contract, true), today, true);
int formalCount = generateBills(contract, settlementType, resolveRule(contract, false), today, false);
return new int[]{preCount, formalCount};
}
private int generateBills(ContractManage contract, String settlementType, Map<String, Object> rule,
LocalDate today, boolean preBill) {
if (!enabled(rule.get("autoGenerate"))) {
return 0;
}
String cycleName = Func.toStr(rule.get("settlementType")).trim();
if (!knownCycle(cycleName)) {
log.warn("合同[{}]{}结算周期[{}]无法识别,跳过自动生成", contract.getContractNo(),
preBill ? "预" : "正式", cycleName);
return 0;
}
List<DateRange> ranges = closedPeriods(rule, today);
if (ranges.isEmpty()) {
return 0;
}
if (preBill) {
return createPreBills(contract, ranges);
}
return createFormalBills(contract, settlementType, ranges);
}
private int createPreBills(ContractManage contract, List<DateRange> ranges) {
List<ReceivablePayableDetail> details = loadPendingDetails(contract, ranges);
Map<DateRange, List<ReceivablePayableDetail>> grouped = groupDetails(ranges, details);
int created = 0;
for (Map.Entry<DateRange, List<ReceivablePayableDetail>> entry : grouped.entrySet()) {
Map<String, List<Long>> byCurrency = new LinkedHashMap<>();
for (ReceivablePayableDetail detail : entry.getValue()) {
String currency = Func.isEmpty(detail.getCurrency()) ? LOCAL_CURRENCY : detail.getCurrency();
byCurrency.computeIfAbsent(currency, key -> new ArrayList<>()).add(detail.getId());
}
for (List<Long> detailIds : byCurrency.values()) {
try {
inTransaction(() -> {
PreSettlementSaveRequest request = new PreSettlementSaveRequest();
request.setContractId(contract.getId());
request.setSourceDetailIds(detailIds);
request.setRemark(periodRemark(entry.getKey()));
preSettlementService.saveDraft(request);
});
created++;
log.info("合同[{}]自动生成预结算单,账期{}至{},明细{}条", contract.getContractNo(),
entry.getKey().start(), entry.getKey().end(), detailIds.size());
} catch (Exception exception) {
log.error("合同[{}]自动生成预结算单失败,账期{}至{}", contract.getContractNo(),
entry.getKey().start(), entry.getKey().end(), exception);
}
}
}
return created;
}
private int createFormalBills(ContractManage contract, String settlementType, List<DateRange> ranges) {
Map<DateRange, List<ReceivablePayableDetail>> detailGroups = groupDetails(ranges, loadPendingDetails(contract, ranges));
Map<DateRange, List<PreSettlement>> preGroups = groupApprovedPres(contract, settlementType, ranges);
int created = 0;
for (DateRange range : ranges) {
Map<String, List<Long>> detailIds = new LinkedHashMap<>();
for (ReceivablePayableDetail detail : detailGroups.getOrDefault(range, List.of())) {
String currency = detail.getCurrency() == null ? "" : detail.getCurrency();
detailIds.computeIfAbsent(currency, key -> new ArrayList<>()).add(detail.getId());
}
Map<String, List<Long>> preIds = new LinkedHashMap<>();
for (PreSettlement preSettlement : preGroups.getOrDefault(range, List.of())) {
String currency = preSettlement.getCurrency() == null ? "" : preSettlement.getCurrency();
preIds.computeIfAbsent(currency, key -> new ArrayList<>()).add(preSettlement.getId());
}
Set<String> currencies = new LinkedHashSet<>();
currencies.addAll(detailIds.keySet());
currencies.addAll(preIds.keySet());
for (String currency : currencies) {
List<Long> sourcePreSettlementIds = preIds.getOrDefault(currency, List.of());
List<Long> sourceDetailIds = detailIds.getOrDefault(currency, List.of());
if (sourcePreSettlementIds.isEmpty() && sourceDetailIds.isEmpty()) {
continue;
}
try {
inTransaction(() -> {
FormalSettlementSaveRequest request = new FormalSettlementSaveRequest();
request.setContractId(contract.getId());
request.setSettlementType(settlementType);
request.setSourcePreSettlementIds(sourcePreSettlementIds.isEmpty() ? null : sourcePreSettlementIds);
request.setSourceDetailIds(sourceDetailIds.isEmpty() ? null : sourceDetailIds);
request.setRemark(periodRemark(range));
formalSettlementService.saveDraft(request);
});
created++;
log.info("合同[{}]自动生成正式结算单,账期{}至{},预结算{}张,明细{}条", contract.getContractNo(),
range.start(), range.end(), sourcePreSettlementIds.size(), sourceDetailIds.size());
} catch (Exception exception) {
log.error("合同[{}]自动生成正式结算单失败,账期{}至{}", contract.getContractNo(),
range.start(), range.end(), exception);
}
}
}
return created;
}
private List<ReceivablePayableDetail> loadPendingDetails(ContractManage contract, List<DateRange> ranges) {
LocalDate minDate = ranges.get(0).start();
LocalDate maxDate = ranges.get(0).end();
for (DateRange range : ranges) {
if (range.start().isBefore(minDate)) {
minDate = range.start();
}
if (range.end().isAfter(maxDate)) {
maxDate = range.end();
}
}
LocalDate periodStart = minDate;
LocalDate periodEnd = maxDate;
return receivablePayableDetailMapper.selectList(Wrappers.<ReceivablePayableDetail>lambdaQuery()
.eq(ReceivablePayableDetail::getIsDeleted, 0)
.eq(ReceivablePayableDetail::getContractId, contract.getId())
.eq(ReceivablePayableDetail::getProjectId, contract.getProjectId())
.eq(ReceivablePayableDetail::getDeptId, contract.getOrganizationId())
.eq(ReceivablePayableDetail::getSettlementType, contractSettlementType(contract))
.eq(ReceivablePayableDetail::getSettlementStatus, "pending")
.and(query -> query.isNull(ReceivablePayableDetail::getPreSettlementNo)
.or().eq(ReceivablePayableDetail::getPreSettlementNo, ""))
.and(query -> query.isNull(ReceivablePayableDetail::getFormalSettlementNo)
.or().eq(ReceivablePayableDetail::getFormalSettlementNo, ""))
.and(query -> query.eq(ReceivablePayableDetail::getCustomerName, contract.getPartyA())
.or().eq(ReceivablePayableDetail::getCustomerName, contract.getPartyB()))
.and(query -> query.between(ReceivablePayableDetail::getFeeDate, periodStart, periodEnd)
.or(nested -> nested.isNull(ReceivablePayableDetail::getFeeDate)
.ge(ReceivablePayableDetail::getCreateTime, periodStart.atStartOfDay())
.lt(ReceivablePayableDetail::getCreateTime, periodEnd.plusDays(1).atStartOfDay()))));
}
private Map<DateRange, List<PreSettlement>> groupApprovedPres(ContractManage contract, String settlementType,
List<DateRange> ranges) {
Map<DateRange, List<PreSettlement>> grouped = new LinkedHashMap<>();
ranges.forEach(range -> grouped.put(range, new ArrayList<>()));
List<PreSettlement> preSettlements = preSettlementService.list(Wrappers.<PreSettlement>lambdaQuery()
.eq(PreSettlement::getContractId, contract.getId())
.eq(PreSettlement::getSettlementType, settlementType)
.eq(PreSettlement::getApprovalStatus, TransportBusiConstant.APPROVED)
.and(query -> query.isNull(PreSettlement::getFormalSettlementNo)
.or().eq(PreSettlement::getFormalSettlementNo, "")));
if (preSettlements.isEmpty()) {
return grouped;
}
List<Long> preSettlementIds = preSettlements.stream().map(PreSettlement::getId).toList();
Map<Long, List<Long>> sourceIdsByPre = new LinkedHashMap<>();
for (List<Long> batch : partition(preSettlementIds)) {
List<PreSettlementDetail> lines = preSettlementDetailMapper.selectList(Wrappers.<PreSettlementDetail>lambdaQuery()
.in(PreSettlementDetail::getPreSettlementId, batch)
.eq(PreSettlementDetail::getIsDeleted, 0));
for (PreSettlementDetail line : lines) {
if (line.getSourceDetailId() == null) {
continue;
}
sourceIdsByPre.computeIfAbsent(line.getPreSettlementId(), key -> new ArrayList<>()).add(line.getSourceDetailId());
}
}
Set<Long> sourceIds = new LinkedHashSet<>();
sourceIdsByPre.values().forEach(sourceIds::addAll);
Map<Long, ReceivablePayableDetail> sourceMap = new LinkedHashMap<>();
for (List<Long> batch : partition(sourceIds)) {
receivablePayableDetailMapper.selectBatchIds(batch)
.forEach(detail -> sourceMap.put(detail.getId(), detail));
}
for (PreSettlement preSettlement : preSettlements) {
List<Long> sourceDetailIds = sourceIdsByPre.getOrDefault(preSettlement.getId(), List.of());
LocalDate minDate = null;
LocalDate maxDate = null;
boolean invalid = sourceDetailIds.isEmpty();
for (Long sourceDetailId : sourceDetailIds) {
ReceivablePayableDetail source = sourceMap.get(sourceDetailId);
LocalDate businessDate = source == null ? null : businessDate(source);
if (businessDate == null) {
invalid = true;
break;
}
minDate = minDate == null || businessDate.isBefore(minDate) ? businessDate : minDate;
maxDate = maxDate == null || businessDate.isAfter(maxDate) ? businessDate : maxDate;
}
if (invalid || minDate == null || maxDate == null) {
log.warn("预结算单[{}]缺少可归集的费用日期,跳过自动转正式结算", preSettlement.getPreSettlementNo());
continue;
}
boolean matched = false;
for (DateRange range : ranges) {
if (!minDate.isBefore(range.start()) && !maxDate.isAfter(range.end())) {
grouped.get(range).add(preSettlement);
matched = true;
break;
}
}
if (!matched && !maxDate.isAfter(LocalDate.now().minusDays(1))) {
log.info("预结算单[{}]费用日期{}至{}未完整落入已关闭账期,暂不自动归集正式结算",
preSettlement.getPreSettlementNo(), minDate, maxDate);
}
}
return grouped;
}
private Map<DateRange, List<ReceivablePayableDetail>> groupDetails(List<DateRange> ranges,
List<ReceivablePayableDetail> details) {
Map<DateRange, List<ReceivablePayableDetail>> grouped = new LinkedHashMap<>();
ranges.forEach(range -> grouped.put(range, new ArrayList<>()));
for (ReceivablePayableDetail detail : details) {
LocalDate businessDate = businessDate(detail);
if (businessDate == null) {
continue;
}
for (DateRange range : ranges) {
if (!businessDate.isBefore(range.start()) && !businessDate.isAfter(range.end())) {
grouped.get(range).add(detail);
break;
}
}
}
return grouped;
}
private List<DateRange> closedPeriods(Map<String, Object> rule, LocalDate today) {
LocalDate billStart = dateValue(rule.get("billStartDate"));
LocalDate yesterday = today.minusDays(1);
if (billStart == null || yesterday.isBefore(billStart)) {
return List.of();
}
String settlementType = Func.toStr(rule.get("settlementType")).trim();
return switch (settlementType) {
case "现结", "日结" -> cyclePeriods(billStart, yesterday, 1);
case "周结" -> cyclePeriods(billStart, yesterday, 7);
case "按固定天数周期", "固定天数周期结算" -> {
Integer cycleDays = intValue(rule.get("cycleDays"));
if (cycleDays == null || cycleDays < 1 || cycleDays > 365) {
yield List.of();
}
yield cyclePeriods(billStart, yesterday, cycleDays);
}
case "月结" -> monthlyPeriods(rule, billStart, yesterday);
default -> List.of();
};
}
private List<DateRange> monthlyPeriods(Map<String, Object> rule, LocalDate billStart, LocalDate yesterday) {
String cycleType = Func.toStr(rule.get("billCycleType")).trim();
if (Func.isBlank(cycleType) || "自然月".equals(cycleType)) {
return naturalMonthPeriods(billStart, yesterday);
}
if ("固定截单日".equals(cycleType)) {
Integer cutoffDay = intValue(rule.get("billCutoffDay"));
if (cutoffDay == null || cutoffDay < 1 || cutoffDay > 31) {
return List.of();
}
return fixedCutoffPeriods(billStart, yesterday, cutoffDay);
}
if ("自定义多周期".equals(cycleType)) {
return customMonthPeriods(rule.get("customPeriods"), billStart, yesterday);
}
return List.of();
}
private List<DateRange> cyclePeriods(LocalDate billStart, LocalDate yesterday, int days) {
List<DateRange> ranges = new ArrayList<>();
LocalDate cursor = billStart;
while (true) {
LocalDate end = cursor.plusDays(days - 1L);
if (end.isAfter(yesterday)) {
break;
}
ranges.add(new DateRange(cursor, end));
cursor = end.plusDays(1);
}
return ranges;
}
private List<DateRange> naturalMonthPeriods(LocalDate billStart, LocalDate yesterday) {
List<DateRange> ranges = new ArrayList<>();
YearMonth cursor = YearMonth.from(billStart);
YearMonth lastMonth = YearMonth.from(yesterday);
while (!cursor.isAfter(lastMonth)) {
LocalDate end = cursor.atEndOfMonth();
if (!end.isAfter(yesterday) && !end.isBefore(billStart)) {
LocalDate start = cursor.atDay(1).isBefore(billStart) ? billStart : cursor.atDay(1);
ranges.add(new DateRange(start, end));
}
cursor = cursor.plusMonths(1);
}
return ranges;
}
private List<DateRange> fixedCutoffPeriods(LocalDate billStart, LocalDate yesterday, int cutoffDay) {
List<DateRange> ranges = new ArrayList<>();
YearMonth cursor = YearMonth.from(billStart).minusMonths(1);
YearMonth limit = YearMonth.from(yesterday).plusMonths(1);
LocalDate previousEnd = null;
while (!cursor.isAfter(limit)) {
LocalDate end = cursor.atDay(Math.min(cutoffDay, cursor.lengthOfMonth()));
if (!end.isBefore(billStart) && !end.isAfter(yesterday)) {
LocalDate start = previousEnd == null ? billStart : previousEnd.plusDays(1);
if (start.isBefore(billStart)) {
start = billStart;
}
if (!start.isAfter(end)) {
ranges.add(new DateRange(start, end));
}
}
if (!end.isBefore(billStart.minusMonths(1))) {
previousEnd = end;
}
cursor = cursor.plusMonths(1);
}
return ranges;
}
private List<DateRange> customMonthPeriods(Object periodsValue, LocalDate billStart, LocalDate yesterday) {
List<int[]> periods = customPeriods(periodsValue);
if (periods.isEmpty()) {
return List.of();
}
List<DateRange> ranges = new ArrayList<>();
YearMonth cursor = YearMonth.from(billStart);
YearMonth lastMonth = YearMonth.from(yesterday);
while (!cursor.isAfter(lastMonth)) {
for (int[] period : periods) {
int startDay = Math.min(period[0], cursor.lengthOfMonth());
int endDay = Math.min(period[1], cursor.lengthOfMonth());
if (startDay > endDay) {
continue;
}
LocalDate end = cursor.atDay(endDay);
if (end.isAfter(yesterday) || end.isBefore(billStart)) {
continue;
}
LocalDate start = cursor.atDay(startDay);
if (start.isBefore(billStart)) {
start = billStart;
}
if (!start.isAfter(end)) {
ranges.add(new DateRange(start, end));
}
}
cursor = cursor.plusMonths(1);
}
return ranges;
}
private List<int[]> customPeriods(Object value) {
if (!(value instanceof List<?> periodList)) {
return List.of();
}
List<int[]> periods = new ArrayList<>();
for (Object item : periodList) {
if (!(item instanceof Map<?, ?> period)) {
continue;
}
Integer startDay = intValue(period.get("startDay"));
Integer endDay = intValue(period.get("endDay"));
if (startDay == null || endDay == null || startDay < 1 || endDay > 31 || endDay < startDay) {
continue;
}
periods.add(new int[]{startDay, endDay});
}
return periods;
}
private Map<String, Object> resolveRule(ContractManage contract, boolean preRule) {
Map<String, Object> direct = parseObject(preRule ? contract.getPreSettlementConfigJson() : contract.getFormalSettlementConfigJson());
if (!direct.isEmpty()) {
return direct;
}
Map<String, Object> payload = parseObject(contract.getSettlementRuleJson());
Object nested = payload.get(preRule ? "preSettlementConfig" : "formalSettlementConfig");
if (nested instanceof Map<?, ?> nestedMap) {
Map<String, Object> result = new LinkedHashMap<>();
nestedMap.forEach((key, value) -> result.put(String.valueOf(key), value));
return result;
}
if (!payload.containsKey("preSettlementConfig") && !payload.containsKey("formalSettlementConfig")) {
return payload;
}
return Map.of();
}
private Map<String, Object> parseObject(String json) {
if (Func.isBlank(json)) {
return Map.of();
}
try {
Object parsed = JsonUtil.parse(json, Map.class);
if (!(parsed instanceof Map<?, ?> raw) || raw.isEmpty()) {
return Map.of();
}
Map<String, Object> result = new LinkedHashMap<>();
raw.forEach((key, value) -> result.put(String.valueOf(key), value));
return result;
} catch (Exception exception) {
log.warn("结算规则JSON解析失败:{}", exception.getMessage());
return Map.of();
}
}
private void inTransaction(Runnable runnable) {
new TransactionTemplate(transactionManager).executeWithoutResult(status -> runnable.run());
}
private List<List<Long>> partition(Collection<Long> ids) {
List<Long> source = ids instanceof List<Long> list ? list : new ArrayList<>(ids);
List<List<Long>> batches = new ArrayList<>();
for (int index = 0; index < source.size(); index += QUERY_BATCH_SIZE) {
batches.add(source.subList(index, Math.min(index + QUERY_BATCH_SIZE, source.size())));
}
return batches;
}
private String contractSettlementType(ContractManage contract) {
if ("客户合同".equals(contract.getContractCategory())) {
return "receivable";
}
if ("承运商合同".equals(contract.getContractCategory())) {
return "payable";
}
return null;
}
private LocalDate businessDate(ReceivablePayableDetail detail) {
if (detail.getFeeDate() != null) {
return detail.getFeeDate();
}
if (detail.getCreateTime() == null) {
return null;
}
return detail.getCreateTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
}
private String periodRemark(DateRange range) {
return "系统按结算规则自动生成(" + range.start() + "至" + range.end() + ")";
}
private boolean enabled(Object value) {
if (value instanceof Boolean enabled) {
return enabled;
}
if (value instanceof Number number) {
return number.intValue() == 1;
}
String text = value == null ? "" : String.valueOf(value).trim();
return "1".equals(text) || "true".equalsIgnoreCase(text);
}
private boolean knownCycle(String settlementType) {
return List.of("现结", "日结", "周结", "月结", "按固定天数周期", "固定天数周期结算").contains(settlementType);
}
private Integer intValue(Object value) {
if (value instanceof Number number) {
return number.intValue();
}
if (value == null || Func.isBlank(String.valueOf(value))) {
return null;
}
try {
return Integer.parseInt(String.valueOf(value).trim());
} catch (NumberFormatException exception) {
return null;
}
}
private LocalDate dateValue(Object value) {
if (value == null || Func.isBlank(String.valueOf(value))) {
return null;
}
String text = String.valueOf(value).trim();
if (text.length() >= 10) {
text = text.substring(0, 10);
}
try {
return LocalDate.parse(text);
} catch (DateTimeParseException exception) {
return null;
}
}
private record DateRange(LocalDate start, LocalDate end) {
}
}
@@ -246,10 +246,8 @@ public class TemporaryCreditLimitServiceImpl extends BaseServiceImpl<TemporaryCr
TransportBusinessSupport.validateRequired(temporaryCreditLimit.getProjectCode(), "项目编号不能为空");
TransportBusinessSupport.validateRequired(temporaryCreditLimit.getUndertakeDeptName(), "承办部门不能为空");
TransportBusinessSupport.validateRequired(temporaryCreditLimit.getValidUntil() == null ? null : temporaryCreditLimit.getValidUntil().toString(), "请选择申请有效期至");
if (temporaryCreditLimit.getProjectFundLimit() == null || temporaryCreditLimit.getUsedFundLimit() == null || temporaryCreditLimit.getRemainingFundLimit() == null) {
throw new ServiceException("项目额度数据异常,请重新选择项目");
}
if (temporaryCreditLimit.getRemainingFundLimit().compareTo(BigDecimal.ZERO) < 0) {
if (temporaryCreditLimit.getRemainingFundLimit() != null
&& temporaryCreditLimit.getRemainingFundLimit().compareTo(BigDecimal.ZERO) < 0) {
throw new ServiceException("项目剩余额度不能小于0");
}
}
@@ -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);
}
}
@@ -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", "司机/船长手机号"));
@@ -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
@@ -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())
@@ -138,6 +138,33 @@ public class KingdeeArBillSyncSupport {
return null;
}
/**
* A302-4 冲销状态查询:按防重键查询财务应收单,返回该防重键下是否存在「已被冲销」单据
* (BfiArBillVO.hadwrittenoff=true,部分冲销同样视为已冲销)。
* 结算调整重推硬闸门用:仅当原单已被冲销才允许以新来源单据号重推新应收单
*
* @param arBillSrcCode 财务应收单防重键
* @return true=金蝶侧原应收单已被冲销
*/
public boolean queryArBillWrittenOff(String arBillSrcCode) {
if (Func.isBlank(arBillSrcCode)) {
return false;
}
BfiArBillQueryDTO param = new BfiArBillQueryDTO();
param.setAwrpSrcCode(List.of(arBillSrcCode));
param.setAwrpSrcSys(bfiProperties.getSrcSys());
for (BfiArBillVO arBill : BfiUtil.queryAllPages(pageNo -> {
param.setPageNo(pageNo);
param.setPageSize(AR_RESULT_PAGE_SIZE);
return bfiService.queryArBill(param);
})) {
if (arBillSrcCode.equals(arBill.getAwrpSrcCode()) && Boolean.TRUE.equals(arBill.getHadwrittenoff())) {
return true;
}
}
return false;
}
// ==================== A302-2 财务应收单保存 ====================
/**
@@ -170,7 +197,7 @@ public class KingdeeArBillSyncSupport {
if (Func.isBlank(billtypeNumber)) {
throw new ServiceException("推送金蝶财务应收单失败:单据类型[" + KingdeeBfiConstant.BFI_AR_BILL_BILLTYPE_NAME + "]未在映射字典中维护");
}
String orgNumber = kingdeeApBillSyncSupport.bfiOrgNumber(contract);
String orgNumber = kingdeeApBillSyncSupport.bfiOrgNumber(contract, context);
// 币种取合同结算币种
String currency = kingdeeApBillSyncSupport.bfiCurrency(contract);
// 单据日期:yyyy-MM-dd HH:mm:ss(各接口统一格式),以同步当日作为应收单据日期
@@ -193,8 +220,8 @@ public class KingdeeArBillSyncSupport {
param.setRecorgNumber(orgNumber);
// 【必填】单据日期
param.setBizdate(billDateText);
// 【必填】往来类型:应收固定客户
param.setAsstacttype(KingdeeBfiConstant.BFI_CUSTOMER_ASSTACT_TYPE);
// 【必填】往来类型:统一固定客户(KingdeeApBillSyncSupport#bfiAsstactType,与应付单/付款申请同口径)
param.setAsstacttype(kingdeeApBillSyncSupport.bfiAsstactType(contract));
// 【必填】往来户.编码:结算单付款方(客户)
param.setAsstactNumber(customerCode);
// 【必填】付款方式(CASH:现销, CREDIT:赊销),现销由金蝶自动推收款
@@ -183,10 +183,10 @@ public class KingdeeReceivedInvoiceSyncSupport {
param.setInvoicetypef7Number(mirror.getInvoiceTypeCode());
// 【必填】付款方式:(与应付单一致,不允许金蝶自动推付款)
param.setPaymenttype(BfiConstant.PAY_MODE_CASH);
// 【必填】往来类型:进项收票固定供应商
param.setAsstacttype(BfiConstant.ASSTACT_TYPE_SUPPLIER);
// 【必填】结算组织.编码:合同所属组织(与应付单同源)
param.setOrgNumber(kingdeeApBillSyncSupport.bfiOrgNumber(contract));
// 【必填】往来类型:统一固定客户(KingdeeApBillSyncSupport#bfiAsstactType,与应付单/付款申请同口径)
param.setAsstacttype(kingdeeApBillSyncSupport.bfiAsstactType(contract));
// 【必填】结算组织.编码:合同关联项目业务部门反查平台公司(与应付单同源)
param.setOrgNumber(kingdeeApBillSyncSupport.bfiOrgNumber(contract, context));
// 【必填】结算币别:合同结算币种
param.setCurrencyNumber(kingdeeApBillSyncSupport.bfiCurrency(contract));
// 【必填】往来户.编码:合同乙方(开票供应商)客商编码
@@ -0,0 +1,59 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.task;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.transport.service.ISettlementRuleAutoGenerateService;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 合同结算规则定时生成结算单。
* <p>
* 每天零点执行:预结算或正式结算配置开启自动生成时,按账单开始日期和结算周期归集已关闭账期的运单明细。
*
* @author Chill
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SettlementRuleAutoGenerateTask {
private final ISettlementRuleAutoGenerateService settlementRuleAutoGenerateService;
/** 每天 00:00 生成到期的预结算单、正式结算单 */
@Scheduled(cron = "${blade.settlement.auto-generate-cron:0 0 0 * * ?}")
public void generateDueSettlements() {
try {
String summary = settlementRuleAutoGenerateService.generateDueSettlements();
log.info("定时自动生成结算单:{}", summary);
} catch (Exception exception) {
log.error("定时自动生成结算单异常", exception);
}
}
}
@@ -45,6 +45,7 @@ public class FormalSettlementWrapper extends BaseEntityWrapper<FormalSettlement,
case "voided" -> "已作废";
default -> entity.getApprovalStatus();
});
vo.setAdjustStatusName("adjusted".equals(entity.getAdjustStatus()) ? "已调整" : null);
return vo;
}
}
@@ -70,10 +70,15 @@ public class ReceiptFlowWrapper extends BaseEntityWrapper<KingdeeReceiptFlow, Re
case "failed" -> "处理失败";
default -> "未推送";
});
// 剩余可认领金额 = 公告未认领金额 − 累计认领金额(认领上限为公告口径)
// 剩余可认领金额 = 可认领上限 − 累计认领金额(上限取公告未认领与收款金额较小值)
BigDecimal unclaimedAmount = money(entity.getUnclaimedAmount());
BigDecimal receiptAmount = money(entity.getReceiptAmount());
BigDecimal claimCeiling = unclaimedAmount;
if (receiptAmount.compareTo(BigDecimal.ZERO) > 0 && unclaimedAmount.compareTo(BigDecimal.ZERO) > 0) {
claimCeiling = unclaimedAmount.min(receiptAmount);
}
BigDecimal claimedAmount = money(entity.getClaimedAmount());
vo.setRemainingAmount(unclaimedAmount.subtract(claimedAmount).max(BigDecimal.ZERO));
vo.setRemainingAmount(claimCeiling.subtract(claimedAmount).max(BigDecimal.ZERO));
vo.setCreateUserName(UserCache.getUserRealName(entity.getCreateUser()));
vo.setUpdateUserName(UserCache.getUserRealName(entity.getUpdateUser()));
return vo;
@@ -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 + ")";
}
}
@@ -94,6 +94,7 @@ public class BfiBlacklistVO implements Serializable {
/**
* 失信类别[A:禁止交易类, B:风险警示类]
*/
@JsonProperty("awrp_combofield")
private String awrpCombofield;
/**
* 失信类别标题
@@ -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