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

# Conflicts:
#	blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java
This commit is contained in:
刘泉佳
2026-10-22 09:39:06 +08:00
250 changed files with 23455 additions and 1479 deletions
+2
View File
@@ -36,3 +36,5 @@ Thumbs.db
/.codex-work/
.serena/
.workbuddy/
/data/
/output/
@@ -84,16 +84,11 @@ public class BladeAuthorizationHandler extends AbstractAuthorizationHandler {
if (bladeProperties.isProd() && isWeakPassword(request.getPassword())) {
return buildValidationFailure(ExceptionCode.INVALID_USER_PASSWORD);
}
// 判断账号是否锁定
// 仅按账号锁定;不做 IP 锁定,避免同出口/网关 IP 下一人失败牵连全部未登录用户
OAuth2Validation accountValidation = lockHandler.validateAccountLock(request.getTenantId(), request.getUsername());
if (!accountValidation.isSuccess()) {
return accountValidation;
}
// 判断IP是否锁定
OAuth2Validation ipValidation = lockHandler.validateIpLock(request.getTenantId());
if (!ipValidation.isSuccess()) {
return ipValidation;
}
}
return super.preValidation(request);
}
@@ -105,7 +100,13 @@ public class BladeAuthorizationHandler extends AbstractAuthorizationHandler {
*/
@Override
public void preFailure(OAuth2Request request, OAuth2Validation validation) {
// 处理认证失败,增加错误次数
// 已因锁定失败时不再累加失败次数,避免其他账号重试时被误计并连带锁定
if (validation != null && !validation.isSuccess()
&& ExceptionCode.USER_TOO_MANY_FAILS.getCode() == validation.getCode()) {
log.error("用户:{},认证失败,失败原因:{}", request.getUsername(), validation.getMessage());
return;
}
// 处理认证失败,增加错误次数(仅账号维度)
lockHandler.handleAuthFailure(request.getTenantId(), request.getUsername());
log.error("用户:{},认证失败,失败原因:{}", request.getUsername(), validation.getMessage());
@@ -90,7 +90,7 @@ public class BladeLockHandler {
/**
* 处理认证失败
* 同时增加账号和IP错误次数
* 仅增加账号错误次数;不再累计 IP 失败次数,避免共享出口 IP 误伤其他用户。
*
* @param tenantId 租户id
* @param account 账号
@@ -102,7 +102,6 @@ public class BladeLockHandler {
CompletableFuture.runAsync(() -> {
try {
authLockClient.addAccountFailCount(tenantId, account, userId, ip, userAgent);
authLockClient.addIpFailCount(tenantId, ip, userAgent);
} catch (Exception e) {
log.warn("认证失败计数异常: {}", e.getMessage());
}
@@ -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("获取数据失败");
}
@@ -150,6 +150,13 @@ public class Dept extends TenantEntity {
@Schema(description = "合同编码前缀")
private String contractCodePrefix;
/**
* 合同初始流水号(该部门当年首个合同时从此号起编,范围1-999)
*/
@Schema(description = "合同初始流水号")
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Integer contractInitialSerial;
/**
* 组织简称
*/
@@ -69,4 +69,22 @@ public class OaOrgSyncPageVO implements Serializable {
@Schema(description = "增量同步使用的 OA modified 起始时间,后续分页需原样回传")
private String modifiedSince;
@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;
}
@@ -66,4 +66,7 @@ public class OaPersonSyncPageVO implements Serializable {
@Schema(description = "增量同步使用的 OA modified 起始时间,后续分页需原样回传")
private String modifiedSince;
@Schema(description = "全量同步结束后原始数据层级 JSON 文件路径")
private String dumpFilePath;
}
@@ -32,6 +32,8 @@ public class FormalSettlementSaveRequest implements Serializable {
private String remark;
private List<SummaryFee> summaryFees;
private List<Invoice> invoices;
/** 项目预付核销 */
private List<SettlementProjectAdvanceWriteOffRequest> projectAdvanceWriteOffs;
@Data
public static class DetailAdjustment implements Serializable {
@@ -35,6 +35,9 @@ public class PaymentApplicationSaveRequest implements Serializable {
private Long settlementId;
private List<Long> settlementIds;
private Long preSettlementId;
private List<Long> preSettlementIds;
/** 预结算单号。驳回重提时用于在主键失真后找回预结算单 */
private String preSettlementNo;
private Long projectId;
private String projectName;
private Long deptId;
@@ -60,4 +63,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;
}
@@ -51,6 +51,12 @@ public class PersonCertSubmitRequest implements Serializable {
private String realName;
@Schema(description = "身份证号")
private String idCardNo;
@Schema(description = "出生年月")
private LocalDate birthday;
@Schema(description = "住址-区划:省市区名称拼接串")
private String addressRegion;
@Schema(description = "住址-详细地址")
private String address;
@Schema(description = "身份证正面照")
private String idCardFrontUrl;
@Schema(description = "身份证反面照")
@@ -33,7 +33,7 @@ public class PreSettlementDetailAdjustRequest implements Serializable {
@Schema(description = "预结算明细ID")
private Long detailId;
@Schema(description = "调整原因")
@Schema(description = "备注(写入结算明细备注,非必填)")
private String changeReason;
@Schema(description = "明细费用行")
@@ -76,6 +76,9 @@ public class PreSettlementSaveRequest implements Serializable {
@Schema(description = "结算合计费用")
private List<SummaryFee> summaryFees;
@Schema(description = "项目预付核销")
private List<SettlementProjectAdvanceWriteOffRequest> projectAdvanceWriteOffs;
@Data
@Schema(description = "结算合计费用")
public static class SummaryFee implements Serializable {
@@ -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;
}
@@ -0,0 +1,107 @@
/**
* 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 io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
/**
* 导入结算单请求(列表筛选 + 确认/草稿入参)
*
* @author Chill
*/
@Data
@Schema(description = "导入结算单请求")
public class SettlementImportBatchRequest implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "主键")
private Long id;
@Schema(description = "批次号")
private String batchNo;
@Schema(description = "项目ID")
private Long projectId;
@Schema(description = "项目名称")
private String projectName;
@Schema(description = "结算类型 receivable/payable")
private String settlementType;
@Schema(description = "结算单类型 formal/pre,应收仅 formal")
private String settlementBillType;
@Schema(description = "客户合同ID")
private Long customerContractId;
@Schema(description = "客户合同名称")
private String customerContractName;
@Schema(description = "客户名称")
private String customerName;
@Schema(description = "承运商合同ID")
private Long carrierContractId;
@Schema(description = "承运商合同名称")
private String carrierContractName;
@Schema(description = "承运商名称")
private String carrierName;
@Schema(description = "运单模式:new=新运单,history=历史运单")
private String waybillMode;
@Schema(description = "导入状态")
private String importStatus;
@Schema(description = "备注")
private String remark;
@Schema(description = "动态费用列名")
private List<String> feeItemNames;
@Schema(description = "导入明细行")
private List<Map<String, Object>> rows;
@Schema(description = "分批追加")
private Boolean append;
@Schema(description = "列表筛选:项目名称")
private String projectNameLike;
@Schema(description = "列表筛选:创建人")
private Long createUser;
}
@@ -0,0 +1,51 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 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 io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 导入结算单作废请求
*
* @author Chill
*/
@Data
@Schema(description = "导入结算单作废请求")
public class SettlementImportVoidRequest implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "批次主键")
private Long id;
@Schema(description = "作废原因")
private String reason;
}
@@ -0,0 +1,53 @@
/**
* 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 io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
/**
* 结算单项目预付核销保存行
*
* @author Chill
*/
@Data
@Schema(description = "结算单项目预付核销保存行")
public class SettlementProjectAdvanceWriteOffRequest implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "项目预付付款申请ID")
private Long paymentApplicationId;
@Schema(description = "本次核销金额")
private BigDecimal writeOffAmount;
}
@@ -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;
@@ -0,0 +1,118 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.pojo.entity;
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;
import java.io.Serial;
/**
* 导入结算单批次
*
* @author Chill
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("blade_settlement_import_batch")
@Schema(description = "导入结算单批次")
public class SettlementImportBatch extends TenantEntity {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "批次号")
private String batchNo;
@Schema(description = "项目ID")
private Long projectId;
@Schema(description = "项目名称")
private String projectName;
@Schema(description = "结算类型 receivable/payable")
private String settlementType;
@Schema(description = "结算单类型 formal/pre")
private String settlementBillType;
@Schema(description = "客户合同ID")
private Long customerContractId;
@Schema(description = "客户合同名称")
private String customerContractName;
@Schema(description = "客户名称")
private String customerName;
@Schema(description = "承运商合同ID")
private Long carrierContractId;
@Schema(description = "承运商合同名称")
private String carrierContractName;
@Schema(description = "承运商名称")
private String carrierName;
@Schema(description = "运单模式 new")
private String waybillMode;
@Schema(description = "导入状态 draft/completed/voided")
private String importStatus;
@Schema(description = "运单数量")
private Integer waybillCount;
@Schema(description = "明细数量")
private Integer detailCount;
@Schema(description = "正式结算单ID")
private Long formalSettlementId;
@Schema(description = "正式结算单号")
private String formalSettlementNo;
@Schema(description = "预结算单ID")
private Long preSettlementId;
@Schema(description = "预结算单号")
private String preSettlementNo;
@Schema(description = "动态费用列名JSON")
private String feeItemNamesJson;
@Schema(description = "导入明细行JSON")
private String rowsJson;
@Schema(description = "备注")
private String remark;
@Schema(description = "关联运单导入批次ID")
private Long waybillImportBatchId;
}
@@ -0,0 +1,90 @@
/**
* 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.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springblade.core.tenant.mp.TenantEntity;
import java.io.Serial;
import java.math.BigDecimal;
import java.util.Date;
/**
* 结算单项目预付核销实体类
*
* @author Chill
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("blade_settlement_project_advance_write_off")
@Schema(description = "结算单项目预付核销")
public class SettlementProjectAdvanceWriteOff extends TenantEntity {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "结算单类型:pre/formal")
private String settlementType;
@JsonSerialize(using = ToStringSerializer.class)
@Schema(description = "预结算/正式结算单ID")
private Long settlementId;
@JsonSerialize(using = ToStringSerializer.class)
@Schema(description = "项目预付付款申请ID")
private Long paymentApplicationId;
@Schema(description = "预付单号")
private String paymentNo;
@Schema(description = "项目预付金额")
private BigDecimal projectAdvanceAmount;
@Schema(description = "本次核销金额")
private BigDecimal writeOffAmount;
@Schema(description = "金蝶预付单号")
private String kingdeeBillNo;
@TableField(exist = false)
@Schema(description = "可核销金额(动态计算)")
private BigDecimal availableWriteOffAmount;
@TableField(exist = false)
@Schema(description = "预付单创建人姓名")
private String createUserName;
@TableField(exist = false)
@Schema(description = "预付单创建时间(展示用)")
private Date paymentCreateTime;
}
@@ -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;
@@ -249,6 +249,9 @@ public class Waybill extends TenantEntity {
@Schema(description = "导入批次ID")
private Long importBatchId;
@Schema(description = "导入结算单批次ID")
private Long settlementImportBatchId;
@Schema(description = "关联单号")
private String relationNo;
@@ -92,4 +92,30 @@ public class AdminWaybillCardVO implements Serializable {
@Schema(description = "采购方是否已付款(预留)")
private Boolean buyerPaid;
/* ===== 运次(一趟车)字段:列表按运次下发,卡片的展示单号与路线取这些 ===== */
@Schema(description = "运次主键:配载单主键或运单主键,按字符串下发(19 位雪花号按数字下发会被前端取整)")
private String tripId;
@Schema(description = "配载单号,未配载为空")
private String loadingNo;
@Schema(description = "展示单号:有配载取配载单号,否则取运单号")
private String tripNo;
@Schema(description = "途经站点:整趟车中间各站按顺序以「 → 」连接,无中间站为空")
private String viaName;
@Schema(description = "站点数(整趟车,含起终点)")
private Integer stationCount;
@Schema(description = "货物汇总(整趟车,同名货物合并且单位一致时取数量合计)")
private String cargoSummary;
@Schema(description = "合计重量(整趟车同单位时才有值,单位不一致为空)")
private String totalWeight;
@Schema(description = "子运单数(可见口径;配载运次即整趟车的子单数)")
private Integer waybillCount;
}
@@ -66,9 +66,12 @@ public class AdminWaybillDetailVO implements Serializable {
@Schema(description = "卸货地址")
private String unloadAddress;
@Schema(description = "货物名称")
@Schema(description = "货物名称:运单上录入的全部货名以「、」连接")
private String cargoName;
@Schema(description = "货物名称清单:运单上录入的全部货物,同名的只留一次")
private List<String> cargoNames;
@Schema(description = "货物数量(带单位)")
private String cargoQuantity;
@@ -135,6 +138,29 @@ public class AdminWaybillDetailVO implements Serializable {
@Schema(description = "途打卡记录")
private List<DriverEnrouteRecordVO> enrouteRecords;
/* ===== 运次(一趟车)字段:详情按运次下发,与司机端接单页同一口径 ===== */
@Schema(description = "运次主键:配载单主键或运单主键,按字符串下发(19 位雪花号按数字下发会被前端取整)")
private String tripId;
@Schema(description = "配载单号,未配载为空")
private String loadingNo;
@Schema(description = "展示单号:有配载取配载单号,否则取运单号")
private String tripNo;
@Schema(description = "途经站点:整趟车中间各站按顺序以「 → 」连接,无中间站为空")
private String viaName;
@Schema(description = "站点数(整趟车,含起终点)")
private Integer stationCount;
@Schema(description = "货物汇总(整趟车,同名货物合并且单位一致时取数量合计)")
private String cargoSummary;
@Schema(description = "站点任务安排(整趟车按实际途经顺序,第 1 站即 ①;配载运次含每站装卸与货物条目)")
private List<TripStationVO> stations;
@Data
@Schema(description = "装卸点")
public static class AdminRoutePointVO implements Serializable {
@@ -0,0 +1,48 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.pojo.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 货物明细行:一条货物一行
* <p>
* 详情页逐行展示「货物名称 + 数量单位」,名称与数量分开给,前端不需要再从汇总串里拆。
*/
@Data
@Schema(description = "货物明细行")
public class CargoLineVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "货物名称")
private String cargoName;
@Schema(description = "数量(含单位,如 1000吨);录入时只填了名称则为空")
private String quantity;
}
@@ -67,9 +67,15 @@ public class DriverPunchNodeVO implements Serializable {
@Schema(description = "不可打卡的原因,供司机端展示")
private String lockReason;
@Schema(description = "运单已结束(完成/取消):卡片仅展示、不可提交")
private Boolean ended;
@Schema(description = "是否展示该卡(在途可能因频次/时段隐藏)")
private Boolean visible;
@Schema(description = "打这张卡即完成运单:该运单的末位可打卡节点,且过程配置不要求业务员确认回单")
private Boolean finishOnPunch;
@Schema(description = "是否默认展开(仅第一个可打卡节点)")
private Boolean defaultExpanded;
@@ -67,6 +67,9 @@ public class DriverWaybillCardVO implements Serializable {
@Schema(description = "货物类别")
private String cargoCategory;
@Schema(description = "货物明细(详情返回):一条一行,同名同单位的货物已合并数量")
private List<CargoLineVO> cargos;
@Schema(description = "重量(带单位)")
private String weight;
@@ -150,6 +153,29 @@ public class DriverWaybillCardVO implements Serializable {
@Schema(description = "运输方式文案:公路运输等")
private String transportType;
@Schema(description = "运输组织:common普通 / load配载")
private String transportOrgType;
/* ===== 运次(一趟车)字段:运次接口返回,旧运单接口不填 ===== */
@Schema(description = "运次主键:配载单主键或运单主键,按字符串下发(19 位雪花号按数字下发会被前端取整)")
private String tripId;
@Schema(description = "配载单号,未配载为空")
private String loadingNo;
@Schema(description = "展示单号:有配载取配载单号,否则取运单号")
private String tripNo;
@Schema(description = "途经站点:整趟车中间各站按顺序以「 → 」连接")
private String viaName;
@Schema(description = "站点数(整趟车,含起终点)")
private Integer stationCount;
@Schema(description = "货物汇总(整趟车,同名货物合并且单位一致时取数量合计)")
private String cargoSummary;
@Schema(description = "计划发货时间")
private String planShipTime;
@@ -165,6 +191,9 @@ public class DriverWaybillCardVO implements Serializable {
@Schema(description = "装卸点(详情返回)")
private List<DriverRoutePointVO> routePoints;
@Schema(description = "站点任务安排(详情返回,按实际途经顺序,第 1 站即 ①;有配载时含每站装卸与货物条目)")
private List<TripStationVO> stations;
@Schema(description = "过程配置 JSON(详情返回,供前端兜底推导打卡节点)")
private String processJson;
@@ -20,10 +20,12 @@ import org.springblade.transport.pojo.entity.FormalSettlementSource;
import org.springblade.transport.pojo.entity.FormalSettlementPayment;
import org.springblade.transport.pojo.entity.FormalSettlementSummaryFee;
import org.springblade.transport.pojo.entity.InvoiceApplicationInvoice;
import org.springblade.transport.pojo.entity.SettlementProjectAdvanceWriteOff;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serial;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/**
@@ -33,7 +35,7 @@ import java.util.List;
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class FormalSettlementVO extends FormalSettlement {
public class FormalSettlementVO extends FormalSettlement implements KingdeeUnlockMark {
@Serial private static final long serialVersionUID = 1L;
@TableField(exist = false) @DateTimeFormat(pattern = "yyyy-MM-dd") private LocalDate createStartDate;
@TableField(exist = false) @DateTimeFormat(pattern = "yyyy-MM-dd") private LocalDate createEndDate;
@@ -50,6 +52,14 @@ public class FormalSettlementVO extends FormalSettlement {
private String kingdeeLockFailReason;
@TableField(exist = false) @Schema(description = "金蝶锁重试次数(达上限5后不再自动补偿,需重推同步金蝶)")
private Integer kingdeeTryCount;
@TableField(exist = false) @Schema(description = "金蝶解锁时间(非空代表该单曾被金蝶解锁/删单)")
private LocalDateTime kingdeeUnlockTime;
@TableField(exist = false) @Schema(description = "金蝶侧解锁操作人(回调报文 operator)")
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;
@@ -57,9 +67,12 @@ public class FormalSettlementVO extends FormalSettlement {
@TableField(exist = false) private List<FormalSettlementSummaryFee> summaryFees;
@TableField(exist = false) private List<FormalSettlementPayment> payments;
@TableField(exist = false) private List<FormalSettlementInvoice> invoices;
@TableField(exist = false) @Schema(description = "项目预付核销")
private List<SettlementProjectAdvanceWriteOff> projectAdvanceWriteOffs;
@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;
}
@@ -35,6 +35,7 @@ import org.springblade.transport.pojo.entity.InvoiceApplicationRecord;
import org.springblade.transport.pojo.entity.InvoiceApplicationSettlement;
import java.io.Serial;
import java.time.LocalDateTime;
import java.util.List;
/**
@@ -44,7 +45,7 @@ import java.util.List;
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class InvoiceApplicationVO extends InvoiceApplication {
public class InvoiceApplicationVO extends InvoiceApplication implements KingdeeUnlockMark {
@Serial private static final long serialVersionUID = 1L;
@TableField(exist = false) private String settlementNos;
@TableField(exist = false) private String approvalStatusName;
@@ -55,6 +56,12 @@ public class InvoiceApplicationVO extends InvoiceApplication {
@TableField(exist = false) private String kingdeeLockStatus;
@TableField(exist = false) private String kingdeeLockFailReason;
@TableField(exist = false) private Integer kingdeeTryCount;
/** 金蝶解锁终态标识(unlocked 锁记录回填):解锁时间非空即代表该单曾被金蝶解锁/删单 */
@TableField(exist = false) private LocalDateTime kingdeeUnlockTime;
/** 金蝶侧解锁操作人(回调报文 operator) */
@TableField(exist = false) private String kingdeeUnlockOperator;
/** 解锁动作名称:金蝶删单/金蝶锁单 */
@TableField(exist = false) private String kingdeeUnlockOperationName;
@TableField(exist = false) private String createUserName;
@TableField(exist = false) private List<InvoiceApplicationSettlement> settlements;
@TableField(exist = false) private List<InvoiceApplicationSheetVO> sheets;
@@ -0,0 +1,36 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.pojo.vo;
import java.time.LocalDateTime;
/**
* 金蝶解锁终态标识展示契约:需在页面展示「金蝶删单/解锁」标识的 VO 实现之,
* 由 {@code KingdeeBillLockService#fillUnlockMarks} 统一回填(列表/详情共用)
*
* @author Chill
*/
public interface KingdeeUnlockMark {
/**
* 金蝶解锁时间(unlocked 锁记录回填),非空即代表该单曾被金蝶解锁/删单
*/
void setKingdeeUnlockTime(LocalDateTime kingdeeUnlockTime);
/**
* 金蝶侧解锁操作人(回调报文 operator)
*/
void setKingdeeUnlockOperator(String kingdeeUnlockOperator);
/**
* 解锁动作名称:金蝶删单/金蝶锁单
*/
void setKingdeeUnlockOperationName(String kingdeeUnlockOperationName);
}
@@ -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;
}
@@ -29,12 +29,13 @@ import org.springblade.transport.pojo.entity.PaymentApplicationSettlement;
import java.io.Serial;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** 付款申请视图。 @author Chill */
@Data
@EqualsAndHashCode(callSuper = true)
public class PaymentApplicationVO extends PaymentApplication {
public class PaymentApplicationVO extends PaymentApplication implements KingdeeUnlockMark {
@Serial private static final long serialVersionUID = 1L;
@TableField(exist = false) private LocalDate applyStartDate;
@TableField(exist = false) private LocalDate applyEndDate;
@@ -49,6 +50,12 @@ public class PaymentApplicationVO extends PaymentApplication {
@TableField(exist = false) private String kingdeeLockStatus;
@TableField(exist = false) private String kingdeeLockFailReason;
@TableField(exist = false) private Integer kingdeeTryCount;
/** 金蝶解锁终态标识(unlocked 锁记录回填):解锁时间非空即代表该单曾被金蝶解锁/删单,供页面展示明显标识 */
@TableField(exist = false) private LocalDateTime kingdeeUnlockTime;
/** 金蝶侧解锁操作人(回调报文 operator) */
@TableField(exist = false) private String kingdeeUnlockOperator;
/** 解锁动作名称:金蝶删单/金蝶锁单 */
@TableField(exist = false) private String kingdeeUnlockOperationName;
@TableField(exist = false) private List<PaymentApplicationInvoice> invoices;
@TableField(exist = false) private List<PaymentApplicationRecord> paymentRecords;
@TableField(exist = false) private List<PaymentApplicationSettlement> settlements;
@@ -35,6 +35,7 @@ import org.springblade.transport.pojo.entity.PreSettlementChangeRecord;
import org.springblade.transport.pojo.entity.PreSettlementDetail;
import org.springblade.transport.pojo.entity.PreSettlementDetailFee;
import org.springblade.transport.pojo.entity.PreSettlementSummaryFee;
import org.springblade.transport.pojo.entity.SettlementProjectAdvanceWriteOff;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serial;
@@ -100,6 +101,10 @@ public class PreSettlementVO extends PreSettlement {
@Schema(description = "预付信息")
private List<PreSettlementAdvance> advances;
@TableField(exist = false)
@Schema(description = "项目预付核销")
private List<SettlementProjectAdvanceWriteOff> projectAdvanceWriteOffs;
@TableField(exist = false)
@Schema(description = "变更记录")
private List<PreSettlementChangeRecord> changeRecords;
@@ -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 com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
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 ProjectAdvanceWriteOffOptionVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@JsonSerialize(using = ToStringSerializer.class)
@Schema(description = "付款申请ID")
private Long paymentApplicationId;
@Schema(description = "预付单号")
private String paymentNo;
@Schema(description = "项目预付金额")
private BigDecimal projectAdvanceAmount;
@Schema(description = "可核销金额")
private BigDecimal availableWriteOffAmount;
@Schema(description = "金蝶预付单号")
private String kingdeeBillNo;
@Schema(description = "创建人姓名")
private String createUserName;
@Schema(description = "创建时间")
private Date createTime;
}
@@ -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;
}
@@ -0,0 +1,73 @@
/**
* 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 lombok.EqualsAndHashCode;
import org.springblade.transport.pojo.entity.SettlementImportBatch;
import java.io.Serial;
import java.util.List;
import java.util.Map;
/**
* 导入结算单批次视图
*
* @author Chill
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "导入结算单批次视图")
public class SettlementImportBatchVO extends SettlementImportBatch {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "创建人姓名")
private String createUserName;
@Schema(description = "更新人姓名")
private String updateUserName;
@Schema(description = "导入状态显示名")
private String importStatusName;
@Schema(description = "结算类型显示名")
private String settlementTypeName;
@Schema(description = "结算单类型显示名")
private String settlementBillTypeName;
@Schema(description = "运单模式显示名")
private String waybillModeName;
@Schema(description = "动态费用列名")
private List<String> feeItemNames;
@Schema(description = "导入明细行")
private List<Map<String, Object>> rows;
}
@@ -0,0 +1,104 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.pojo.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
import java.util.Set;
/**
* 运次查询条件
* <p>
* 司机端用 {@link #forDriver(String)} 按司机手机号收口;管理端用 {@link #forAdmin()} 不按司机收口。
* 两者都不给时收口为空集——收口一旦缺省就等于把别人的运次发出去,故缺省不放行。
*/
@Data
@Schema(description = "运次查询条件")
public class TripQueryVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "运次主键(详情按它取单个运次)")
private String tripId;
@Schema(description = "司机手机号")
private String driverPhone;
@Schema(description = "是否不按司机收口(管理端)")
private boolean allDrivers;
@Schema(description = "可见组织 ID 集合:非空时只出这些组织的运单行,空集合表示不按组织过滤")
private List<Long> deptIds;
@Schema(description = "状态:0待接单/1进行中/2已完成/3已取消,不传为全部(不含已取消)")
private Integer status;
@Schema(description = "多档状态:同上取值,非空时优先于 status(待处理列表要待接单与进行中两档)")
private List<Integer> statuses;
@Schema(description = "关键字:运单号 / 配载单号 / 起终点 / 车牌")
private String keyword;
@Schema(description = "运输组织类型:load 公路配载 / common 普通运输,不传为全部(按子单行筛选)")
private String transportOrgType;
@Schema(description = "异常筛选:exception 只看命中异常的子单 / normal 只看未命中的,不传为全部(按子单行筛选)")
private String exception;
@Schema(description = "有未完成异常的子运单主键集合,配合 exception 使用(按子单行筛选)")
private Set<Long> exceptionWaybillIds;
@Schema(description = "创建时间下界,按子单行筛选")
private Date createdFrom;
@Schema(description = "创建时间上界,按子单行筛选")
private Date createdTo;
@Schema(description = "是否排除「有子单被拒且这趟车还没结束」的运次(待处理列表用)")
private boolean excludeRejectedTrips;
@Schema(description = "当前页,从 1 开始")
private Integer current;
@Schema(description = "每页条数(按运次,不是运单)")
private Integer size;
public static TripQueryVO forDriver(String driverPhone) {
TripQueryVO query = new TripQueryVO();
query.setDriverPhone(driverPhone);
return query;
}
public static TripQueryVO forAdmin() {
TripQueryVO query = new TripQueryVO();
query.setAllDrivers(true);
return query;
}
}
@@ -0,0 +1,105 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.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.List;
/**
* 运次站点(任务安排)
* <p>
* 有配载单时按配载单的路线信息给出实际途经顺序,第 1 站即 ①;站点上的「装N / 卸N」指向配载单运单集合里的第 N 张子运单,
* 同一站可以既卸又装(装卸)。没有路线信息时回退到子运单的起讫点去重成站。
* <p>
* 站点上的货物条目取子运单,不取配载单的货物信息:配载单的货物条数与子运单不一定同数,行序也对不上站点编号。
*/
@Data
@Schema(description = "运次站点")
public class TripStationVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "站序,从 1 开始(展示为 ①②③)")
private Integer index;
@Schema(description = "站名:展示地址优先,回退详细地址")
private String name;
@Schema(description = "详细地址")
private String address;
@Schema(description = "站点标记原文:装N / 卸N")
private List<String> tags;
@Schema(description = "操作类型:load 装货 / unload 卸货 / load_unload 装卸,无从判定时为空")
private String operationType;
@Schema(description = "操作类型文案:装货 / 卸货 / 装卸")
private String operationTypeLabel;
@Schema(description = "该站涉及的货物条目")
private List<TripCargoVO> cargos;
/**
* 站点货物条目
*/
@Data
@Schema(description = "运次站点货物条目")
public static class TripCargoVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "所属子运单主键,按字符串下发")
private String waybillId;
@Schema(description = "所属运单号")
private String waybillNo;
@Schema(description = "货物名称")
private String cargoName;
@Schema(description = "货主名称")
private String customerName;
@Schema(description = "数量")
private BigDecimal quantity;
@Schema(description = "数量单位")
private String quantityUnit;
@Schema(description = "该站的操作:load 装货 / unload 卸货")
private String operation;
@Schema(description = "项目名称")
private String projectName;
}
}
@@ -0,0 +1,55 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.pojo.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 运次 Tab 统计
* <p>
* 与司机端运单统计字段同名同义,但计数单位是运次(一卡一趟车),不是运单。
*/
@Data
@Schema(description = "运次 Tab 统计")
public class TripTabCountsVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "全部(待接单+进行中+已完成,不含已取消)")
private long all;
@Schema(description = "待接单")
private long pending;
@Schema(description = "进行中")
private long doing;
@Schema(description = "已完成")
private long done;
}
@@ -0,0 +1,236 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.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.time.LocalDate;
import java.util.Date;
import java.util.List;
/**
* 运次:一趟车的展示与操作单位
* <p>
* 有配载单时就是那张配载单连同它的全部子运单,没配载时就是一张运单;它没有自己的表。
* 状态与接单需求都由子运单读时聚合,配载单上不落这两个字段。
*/
@Data
@Schema(description = "运次")
public class TripVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "运次主键:配载单主键或运单主键,按字符串下发(19 位雪花号按数字下发会被前端取整)")
private String id;
@Schema(description = "展示单号:有配载取配载单号,否则取运单号")
private String tripNo;
@Schema(description = "配载单号,未配载为空")
private String loadingNo;
@Schema(description = "运输组织类型:load 公路配载 / common 普通运输")
private String transportOrgType;
@Schema(description = "状态:0待接单/1运输中/2已完成/3已取消,子单集合为空时为空")
private Integer status;
@Schema(description = "排序键:运次内子运单 create_time 的最大值")
private Date sortTime;
@Schema(description = "是否需要接单:任一子运单的项目有接单节点即为是")
private Boolean requireAccept;
@Schema(description = "接单态:accepted 已接单 / pending 待接单,无从判定时为空")
private String acceptStatus;
@Schema(description = "子运单数(可见口径:已排除本司机拒单的子单)")
private int waybillCount;
@Schema(description = "司机姓名")
private String driverName;
@Schema(description = "司机手机号")
private String driverPhone;
@Schema(description = "车牌号")
private String vehicleNo;
@Schema(description = "挂车车牌号")
private String trailerVehicleNo;
@Schema(description = "项目名称")
private String projectName;
@Schema(description = "客户名称")
private String customerName;
@Schema(description = "运输类型")
private String transportType;
@Schema(description = "货物名称:整趟车全部货名以「、」连接,一格多名的历史写法已摊平")
private String cargoName;
@Schema(description = "货物名称清单:整趟车全部货物,同名的只留一次,保持子单与录入顺序")
private List<String> cargoNames;
@Schema(description = "货物类型:整趟车全部类型以「、」连接")
private String cargoType;
@Schema(description = "货物类型清单:整趟车全部货物类型,同类型的只留一次")
private List<String> cargoTypes;
@Schema(description = "发货地址")
private String departureAddress;
@Schema(description = "途经地")
private String transitAddress;
@Schema(description = "到货地")
private String arrivalAddress;
@Schema(description = "起点名称:整趟车首站,展示地址优先、回退详细地址")
private String departureName;
@Schema(description = "途经站点:中间各站按顺序以「 → 」连接,无中间站为空")
private String transitName;
@Schema(description = "终点名称:整趟车末站,展示地址优先、回退详细地址")
private String arrivalName;
@Schema(description = "站点数(整趟车,含起终点)")
private int stationCount;
@Schema(description = "货物汇总:按货物名合并数量,同名同单位取合计")
private String cargoSummary;
@Schema(description = "货物明细:一条货物一行,同名同单位的货物已合并数量")
private List<CargoLineVO> cargos;
@Schema(description = "合计重量:全部子运单同单位时为数量合计,单位不一致时为空")
private BigDecimal totalQuantity;
@Schema(description = "合计重量的单位,与 totalQuantity 同时为空或同时有值")
private String quantityUnit;
@Schema(description = "计重合计:全部货物同单位时取该单位合计,单位不一致时只合计「吨」项(其余单位不参与);无可用合计时为空")
private BigDecimal weighingQuantity;
@Schema(description = "计重合计的单位,与 weighingQuantity 同时为空或同时有值")
private String weighingUnit;
@Schema(description = "计划发货日期(配载单,未配载为空)")
private LocalDate estimatedStartDate;
@Schema(description = "计划完成日期(配载单,未配载为空)")
private LocalDate estimatedEndDate;
@Schema(description = "任务备注(配载单的运输说明)")
private String taskRemark;
@Schema(description = "路线信息,仅单个运次详情返回(列表不带,省流量)")
private String routeJson;
@Schema(description = "货物信息,仅单个运次详情返回(列表不带,省流量)")
private String goodsJson;
@Schema(description = "站点(任务安排,按实际途经顺序),仅单个运次详情返回;列表用起/经/终与站点数")
private List<TripStationVO> stations;
@Schema(description = "子运单(可见子单,按创建时间升序)")
private List<TripWaybillVO> waybills;
/**
* 运次下的子运单
* <p>
* 接单事实仍落在每张子运单上,运次只是把它们的需要接单与接单态聚合起来。
*/
@Data
@Schema(description = "运次下的子运单")
public static class TripWaybillVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "运单主键,按字符串下发")
private String id;
@Schema(description = "运单号")
private String waybillNo;
@Schema(description = "业务状态")
private String businessStatus;
@Schema(description = "接单状态:pending待接单/accepted已接单/rejected已拒绝")
private String acceptStatus;
@Schema(description = "该运单是否需要接单确认")
private Boolean requireAccept;
@Schema(description = "项目ID")
private Long projectId;
@Schema(description = "项目名称")
private String projectName;
@Schema(description = "货物名称:运单上录入的全部货名以「、」连接,一格多名的历史写法已摊平")
private String cargoName;
@Schema(description = "货物名称清单:运单上录入的全部货物,同名的只留一次")
private List<String> cargoNames;
@Schema(description = "货物类型:主表首行的类型,完整清单见 cargoTypes")
private String cargoType;
@Schema(description = "货物类型清单:运单上录入的全部货物类型,同类型的只留一次")
private List<String> cargoTypes;
@Schema(description = "数量")
private BigDecimal quantity;
@Schema(description = "数量单位")
private String quantityUnit;
@Schema(description = "发货地")
private String departureName;
@Schema(description = "发货地址")
private String departureAddress;
@Schema(description = "收货地")
private String arrivalName;
@Schema(description = "收货地址")
private String arrivalAddress;
@Schema(description = "创建时间")
private Date createTime;
}
}
@@ -20,6 +20,12 @@ public class WaybillImportBatchVO extends WaybillImportBatch {
private static final long serialVersionUID = 1L;
private String importTypeName;
private String statusName;
/** 结算状态:unsettled/partial/settled */
@Schema(description = "结算状态")
private String settlementStatus;
/** 结算状态名称:未结算/部分结算/已结算 */
@Schema(description = "结算状态名称")
private String settlementStatusName;
private String createUserName;
private String updateUserName;
}
@@ -29,6 +29,7 @@ import lombok.EqualsAndHashCode;
import org.springblade.transport.pojo.entity.Waybill;
import java.io.Serial;
import java.util.List;
/**
* 运单管理视图实体类
@@ -78,4 +79,12 @@ public class WaybillVO extends Waybill {
@Schema(description = "是否仅查询未配载运单")
private Integer onlyUnassignedLoading;
@TableField(exist = false)
@Schema(description = "是否仅查询可配载运单")
private Integer loadableOnly;
@TableField(exist = false)
@Schema(description = "运单主键集合")
private List<Long> ids;
}
@@ -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));
}
/**
@@ -49,8 +49,8 @@ public interface DeptConvert {
@Mapping(source = "modified", target = "oaModified")
Dept baseConvert(OACompanyResponse company);
@Mapping(source = "departmentname", target = "deptName")
@Mapping(source = "departmentmark", target = "fullName")
@Mapping(source = "departmentmark", target = "deptName")
@Mapping(source = "departmentname", target = "fullName")
@Mapping(source = "supdepid", target = "parentCode")
@Mapping(source = "subcompanyid1", target = "belongCompanyCode")
@Mapping(source = "created", target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss")
@@ -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);
@@ -147,7 +148,7 @@ public interface UserConvert {
/**
* oa人员转本系统用户部门
* @param person
* @param userMap 所有用户手机号与id map
* @param userMap OA登录名与用户id
* @return
*/
default UserDept person2userDept(OAPersonResponse person, Map<String, Long> userMap) {
@@ -161,8 +162,9 @@ public interface UserConvert {
userDept.setStatus(status);
// 排序
userDept.setSort(OAUtils.parseInt(person.getDsporder()));
// 用户id
userDept.setUserId(userMap.get(resolveAccount(person)));
// 用户id,按 OA 登录名关联本批已新增或更新的用户
String oaLoginId = person == null ? null : StringUtils.trimToNull(person.getLoginid());
userDept.setUserId(oaLoginId == null ? null : userMap.get(oaLoginId));
userDept.setSyncTime(new Date());
return userDept;
}
@@ -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)));
}
@@ -14,6 +14,7 @@
<result column="dept_code" property="deptCode"/>
<result column="kingdee_dept_code" property="kingdeeDeptCode"/>
<result column="contract_code_prefix" property="contractCodePrefix"/>
<result column="contract_initial_serial" property="contractInitialSerial"/>
<result column="short_name" property="shortName"/>
<result column="pinyin_mnemonic" property="pinyinMnemonic"/>
<result column="mnemonic_code" property="mnemonicCode"/>
@@ -36,6 +37,7 @@
<result column="dept_code" property="deptCode"/>
<result column="kingdee_dept_code" property="kingdeeDeptCode"/>
<result column="contract_code_prefix" property="contractCodePrefix"/>
<result column="contract_initial_serial" property="contractInitialSerial"/>
<result column="short_name" property="shortName"/>
<result column="pinyin_mnemonic" property="pinyinMnemonic"/>
<result column="mnemonic_code" property="mnemonicCode"/>
@@ -34,6 +34,26 @@ import java.util.List;
*/
public interface MeasurementUnitMapper extends BaseMapper<MeasurementUnit> {
/**
* 按编码查询计量单位(包含逻辑删除记录,用于新增恢复)。
*/
MeasurementUnit selectByUnitCodeIncludingDeleted(@Param("unitCode") String unitCode);
/**
* 按名称查询计量单位(包含逻辑删除记录,用于新增恢复)。
*/
MeasurementUnit selectByUnitNameIncludingDeleted(@Param("unitName") String unitName);
/**
* 恢复逻辑删除计量单位。
*/
int restoreById(@Param("id") Long id);
/**
* 释放已软删除记录占用的单位名称,避免唯一索引冲突。
*/
int releaseUnitName(@Param("id") Long id, @Param("unitName") String unitName);
/**
* 自定义分页
*
@@ -19,6 +19,34 @@
<result column="remark" property="remark"/>
</resultMap>
<select id="selectByUnitCodeIncludingDeleted" resultType="org.springblade.system.pojo.entity.MeasurementUnit">
SELECT *
FROM blade_measurement_unit
WHERE unit_code = #{unitCode}
LIMIT 1
</select>
<select id="selectByUnitNameIncludingDeleted" resultType="org.springblade.system.pojo.entity.MeasurementUnit">
SELECT *
FROM blade_measurement_unit
WHERE unit_name = #{unitName}
LIMIT 1
</select>
<update id="restoreById">
UPDATE blade_measurement_unit
SET is_deleted = 0
WHERE id = #{id}
AND is_deleted = 1
</update>
<update id="releaseUnitName">
UPDATE blade_measurement_unit
SET unit_name = #{unitName}
WHERE id = #{id}
AND is_deleted = 1
</update>
<select id="selectMeasurementUnitPage" resultMap="measurementUnitResultMap">
SELECT
mmu.id,
@@ -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">
@@ -149,16 +149,16 @@ public interface IDeptService extends IService<Dept> {
boolean removeDept(String ids);
/**
* 清除非保留顶级外的组织;保留 id=135 集团锚点,以及名称为「外部组织」的顶级
* 清除非保留顶级外的组织;保留 id=1 集团锚点,以及名称为「外部组织」的顶级
*
* @return 删除数量
*/
int clearNonTopDepts();
/**
* 确保双顶级:id=135 集团(编码 GWWL)+ 独立「外部组织」顶级
* 确保双顶级:id=1 集团(编码 GWWL)+ 独立「外部组织」顶级
*
* @return 集团顶级组织(id=135)
* @return 集团顶级组织(id=1)
*/
Dept ensureOaSyncRootDept();
@@ -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);
}
@@ -34,6 +34,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.common.constant.CommonConstant;
import org.springblade.common.constant.DataStatusEnum;
import org.springblade.core.cache.utils.CacheUtil;
import org.springblade.core.log.exception.ServiceException;
@@ -366,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
@@ -390,7 +393,7 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
@Override
@Transactional(rollbackFor = Exception.class)
public int clearNonTopDepts() {
// 同步前清理:保留集团锚点 id=135,以及「外部组织」顶级;其余删除
// 同步前清理:保留集团锚点 id=1,以及「外部组织」顶级;其余删除
Dept groupRoot = this.ensureOaSyncRootDept();
Dept externalRoot = this.ensureExternalOrgTopDept();
Set<Long> keepIds = new HashSet<>();
@@ -416,6 +419,7 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
public Dept ensureOaSyncRootDept() {
this.migrateLegacyOaDeptCategory();
this.removeLegacyOaSyncRoots();
this.migrateLegacyGroupRootToIdOne();
Dept rootDept = this.getById(OAConvertConstant.OA_SYNC_ROOT_DEPT_ID);
String tenantId = AuthUtil.getTenantId();
if (StringUtil.isBlank(tenantId)) {
@@ -447,14 +451,17 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE);
} else {
boolean wronglyNamedAsExternal = OAConvertConstant.EXTERNAL_ORG_NAME.equals(rootDept.getDeptName());
boolean needUpdate = wronglyNamedAsExternal
boolean legacyNamed = OAConvertConstant.LEGACY_ROOT_DEPT_NAME.equals(rootDept.getDeptName());
boolean needRename = wronglyNamedAsExternal || legacyNamed || StringUtil.isBlank(rootDept.getDeptName())
|| !OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME.equals(rootDept.getDeptName());
boolean needUpdate = needRename
|| !OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE.equals(rootDept.getDeptCode())
|| !DeptCategory.GROUP.getCode().equals(rootDept.getDeptCategory())
|| !Objects.equals(BladeConstant.TOP_PARENT_ID, rootDept.getParentId());
if (needUpdate) {
Dept updateParam = new Dept();
updateParam.setId(rootDept.getId());
if (wronglyNamedAsExternal || StringUtil.isBlank(rootDept.getDeptName())) {
if (needRename) {
updateParam.setDeptName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME);
updateParam.setFullName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME);
updateParam.setShortName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME);
@@ -468,6 +475,7 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
updateParam.setParentId(BladeConstant.TOP_PARENT_ID);
updateParam.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID));
updateParam.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID));
updateParam.setIsOa(0);
this.updateById(updateParam);
rootDept.setDeptCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE);
rootDept.setBelongCompanyCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE);
@@ -475,6 +483,7 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
rootDept.setParentId(BladeConstant.TOP_PARENT_ID);
rootDept.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID));
rootDept.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID));
rootDept.setIsOa(0);
log.info("已校正 OA 同步集团顶级 id={} name={} code={} category={}",
rootDept.getId(), rootDept.getDeptName(), rootDept.getDeptCode(), rootDept.getDeptCategory());
}
@@ -484,7 +493,63 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
}
/**
* 确保存在独立的「外部组织」顶级(与 id=135 集团并存,仅保留一个同名顶级)
* 将历史集团伪顶级 id=135 迁移到 id=1(子节点改挂,再删除旧锚点)
*/
private void migrateLegacyGroupRootToIdOne() {
Long legacyRootId = OAConvertConstant.LEGACY_OA_SYNC_ROOT_DEPT_ID;
Long newRootId = OAConvertConstant.OA_SYNC_ROOT_DEPT_ID;
if (Objects.equals(legacyRootId, newRootId)) {
return;
}
Dept legacyRoot = this.getById(legacyRootId);
if (legacyRoot == null) {
return;
}
// 仅迁移历史伪顶级;若 id=135 已是真实 OA 公司记录则跳过删除
boolean isOaCompanyRecord = StringUtil.isNotBlank(legacyRoot.getOaId())
&& DeptCategory.COMPANY.getCode().equals(legacyRoot.getDeptCategory())
&& (Objects.equals(legacyRoot.getIsOa(), CommonConstant.YES) || Objects.equals(legacyRoot.getIsOa(), 1));
if (isOaCompanyRecord) {
log.warn("历史 id={} 已是 OA 同步公司(oaId={}),跳过伪顶级迁移", legacyRootId, legacyRoot.getOaId());
return;
}
Dept newRoot = this.getById(newRootId);
String tenantId = AuthUtil.getTenantId();
if (StringUtil.isBlank(tenantId)) {
tenantId = BladeConstant.ADMIN_TENANT_ID;
}
if (newRoot == null) {
newRoot = new Dept();
newRoot.setId(newRootId);
newRoot.setTenantId(StringUtil.isNotBlank(legacyRoot.getTenantId()) ? legacyRoot.getTenantId() : tenantId);
newRoot.setParentId(BladeConstant.TOP_PARENT_ID);
newRoot.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID));
newRoot.setDeptName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME);
newRoot.setFullName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME);
newRoot.setShortName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME);
newRoot.setDeptCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE);
newRoot.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID));
newRoot.setBelongCompanyCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE);
newRoot.setDeptCategory(DeptCategory.GROUP.getCode());
newRoot.setSort(0);
newRoot.setStatus(DataStatusEnum.ENABLE.getCode());
newRoot.setIsDeleted(BladeConstant.DB_NOT_DELETED);
newRoot.setIsOa(0);
newRoot.setIsPlatformCompany(0);
newRoot.setSyncTime(new Date());
this.save(newRoot);
log.info("迁移创建新集团顶级 id={}(来自历史 id={})", newRootId, legacyRootId);
}
this.update(Wrappers.<Dept>lambdaUpdate()
.set(Dept::getParentId, newRootId)
.set(Dept::getParentCode, OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE)
.eq(Dept::getParentId, legacyRootId));
this.removeById(legacyRootId);
log.info("已将历史集团顶级 id={} 迁移到 id={},并删除旧锚点", legacyRootId, newRootId);
}
/**
* 确保存在独立的「外部组织」顶级(与 id=1 集团并存,仅保留一个同名顶级)
*/
private Dept ensureExternalOrgTopDept() {
List<Dept> externalTops = this.list(Wrappers.<Dept>lambdaQuery()
@@ -540,11 +605,12 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
}
/**
* 清理历史伪顶级(OACROOT);同名「桂物物流集团」仅清理非 id=135 的顶级
* 清理历史伪顶级(OACROOT);同名旧集团仅清理非 id=1 的顶级
*/
private void removeLegacyOaSyncRoots() {
List<Dept> legacyRoots = this.list(Wrappers.<Dept>lambdaQuery()
.ne(Dept::getId, OAConvertConstant.OA_SYNC_ROOT_DEPT_ID)
.ne(Dept::getId, OAConvertConstant.LEGACY_OA_SYNC_ROOT_DEPT_ID)
.and(wrapper -> wrapper
.eq(Dept::getDeptCode, OAConvertConstant.LEGACY_ROOT_DEPT_CODE)
.or(nameWrapper -> nameWrapper
@@ -621,6 +687,7 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
}
validateDeptCategory(dept);
validateDeptCode(dept, parent);
validateContractInitialSerial(dept);
return saveOrUpdate(dept);
}
@@ -808,6 +875,16 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
}
}
private void validateContractInitialSerial(Dept dept) {
Integer serial = dept.getContractInitialSerial();
if (serial == null) {
return;
}
if (serial < 1 || serial > 999) {
throw new ServiceException("合同初始流水号范围为1到999");
}
}
private void validateDeptCode(Dept dept, Dept parent) {
String deptCode = dept.getDeptCode();
// 组织编码非必填,为空时存 null,避免唯一索引冲突
@@ -975,7 +1052,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());
@@ -999,7 +1076,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;
@@ -1111,7 +1188,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();
@@ -1138,21 +1218,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());
@@ -1163,6 +1229,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();
@@ -62,10 +62,42 @@ public class MeasurementUnitServiceImpl extends BaseServiceImpl<MeasurementUnitM
@Transactional(rollbackFor = Exception.class)
public boolean submit(MeasurementUnit measurementUnit) {
prepare(measurementUnit);
prepareSubmitTarget(measurementUnit);
validate(measurementUnit);
return saveOrUpdate(measurementUnit);
}
/**
* 编码或名称已存在且为软删除时:取消软删除并复用原主键,后续按更新落库;
* 存在且未删除时仍由唯一校验提示「已存在」。
*/
private void prepareSubmitTarget(MeasurementUnit measurementUnit) {
if (Func.isNotEmpty(measurementUnit.getId())) {
return;
}
MeasurementUnit existingByCode = baseMapper.selectByUnitCodeIncludingDeleted(measurementUnit.getUnitCode());
MeasurementUnit existingByName = baseMapper.selectByUnitNameIncludingDeleted(measurementUnit.getUnitName());
MeasurementUnit toRestore = null;
if (existingByCode != null && Objects.equals(existingByCode.getIsDeleted(), 1)) {
toRestore = existingByCode;
} else if (existingByName != null && Objects.equals(existingByName.getIsDeleted(), 1)
&& (existingByCode == null || Objects.equals(existingByCode.getId(), existingByName.getId()))) {
toRestore = existingByName;
}
if (toRestore == null) {
return;
}
// 复用编码恢复时,若另一条软删除记录占用了目标名称,先释放名称唯一索引
if (existingByName != null && Objects.equals(existingByName.getIsDeleted(), 1)
&& !Objects.equals(existingByName.getId(), toRestore.getId())) {
String releasedName = existingByName.getUnitName() + "_DEL_" + existingByName.getId();
baseMapper.releaseUnitName(existingByName.getId(), releasedName);
}
baseMapper.restoreById(toRestore.getId());
measurementUnit.setId(toRestore.getId());
measurementUnit.setIsDeleted(0);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean changeStatus(Long id, Integer status) {
@@ -66,6 +66,7 @@ public class OASyncServiceImpl implements IOASyncService {
private final IMKPushService mkPushService;
private final IDataSyncRecordService dataSyncRecordService;
private final OaUserListSyncHelper oaUserListSyncHelper;
private final OaSyncRawDumpHelper oaSyncRawDumpHelper;
@Transactional(rollbackFor = Exception.class)
@Override
@@ -74,16 +75,18 @@ public class OASyncServiceImpl implements IOASyncService {
ComposeLogUtil.addLog(log);
// 1. 同步公司和部门并记录
Date startTime = this.syncAndRecord(DataSyncRecordUtils::createOAOrgFetch, queryStartTime -> {
// 0. 确保双顶级:id=135 集团(GWWL) + 独立「外部组织」
// 0. 确保双顶级:id=1 集团(GWWL) + 独立「外部组织」
deptService.ensureOaSyncRootDept();
// 1. 同步公司
this.syncCompany(queryStartTime);
// 公司分批入库后,重建层级并清理无法挂载的公司
// 公司分批入库后:清理白名单外公司、重建层级并清理无法挂载的公司
this.removeOutsideWhitelistOrgs();
deptService.rebuildOaHierarchy();
deptService.removeOaOrphansWithoutParent();
// 2. 同步部门
this.syncDept(queryStartTime);
// 3. 分批写入完成后,按 OA 主键重建组织、部门层级
this.removeOutsideWhitelistOrgs();
deptService.rebuildOaHierarchy();
deptService.removeOaOrphansWithoutParent();
// 4. 更新部门祖级列表
@@ -150,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();
}
@@ -221,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));
}
@@ -261,7 +263,7 @@ public class OASyncServiceImpl implements IOASyncService {
}
/**
* 从 OA 人员列表同步组织与人员
* 从 OA 人员列表同步人员并绑定已有组织(不维护组织树,须先同步组织架构)
*
* @param startTime 增量查询开始时间,为空则全量
* @return 处理的人员数量
@@ -282,7 +284,7 @@ public class OASyncServiceImpl implements IOASyncService {
}
/**
* 按页从 OA 人员列表同步组织与人员
* 按页从 OA 人员列表同步人员并绑定已有组织(不维护组织树,须先同步组织架构)
*
* @param current 当前页
* @param size 每页条数
@@ -293,6 +295,10 @@ public class OASyncServiceImpl implements IOASyncService {
private OaPersonSyncPageVO syncPersonFromOaPage(int current, int size, boolean incremental, String modifiedSince) {
int pageNo = current < 1 ? 1 : current;
int pageSize = size < 1 ? 20 : Math.min(size, 200);
// 全量同步首页开启原始数据收集,结束后按父级 id 写入 JSON
if (!incremental && pageNo == 1) {
oaSyncRawDumpHelper.beginFullDump();
}
IncrementalStart incrementalStart = resolvePersonIncrementalStart(incremental, modifiedSince);
OAPersonSearch personSearch = buildPersonSearch(incrementalStart.getStartTime());
personSearch.setCurPage(pageNo);
@@ -306,6 +312,9 @@ public class OASyncServiceImpl implements IOASyncService {
List<OAPersonResponse> oaPersons = responseData.getDataList() == null
? Collections.emptyList() : responseData.getDataList();
long totalSize = responseData.getTotalSize() == null ? 0L : responseData.getTotalSize();
if (!incremental) {
oaSyncRawDumpHelper.collectPersons(oaPersons);
}
OaUserListSyncHelper.OaOrgIndex orgIndex = oaUserListSyncHelper.syncOrgsFromPersons(oaPersons);
OaUserListSyncHelper.PersonSyncCount personSyncCount = oaUserListSyncHelper.handlePerson(oaPersons, orgIndex);
CacheUtil.clear(USER_CACHE);
@@ -323,6 +332,9 @@ public class OASyncServiceImpl implements IOASyncService {
|| oaPersons.size() < pageSize
|| (long) pageNo * pageSize >= totalSize;
pageVO.setFinished(finished);
if (!incremental && finished) {
pageVO.setDumpFilePath(oaSyncRawDumpHelper.finishFullDump());
}
ComposeLogUtil.getLastLog().info("OA人员分页同步完成 mode={} {}/{},成功{},跳过{}",
incremental ? "incremental" : "full", pageNo, totalSize,
personSyncCount.getSyncedCount(), personSyncCount.getSkippedCount());
@@ -341,9 +353,12 @@ public class OASyncServiceImpl implements IOASyncService {
private OaOrgSyncPageVO syncCompanyFromOaPage(int current, int size, boolean incremental, String modifiedSince) {
int pageNo = current < 1 ? 1 : current;
int pageSize = size < 1 ? 20 : Math.min(size, 200);
// 首屏确保双顶级:id=135 集团(GWWL) + 独立「外部组织」
// 首屏确保双顶级:id=1 集团(GWWL) + 独立「外部组织」
if (pageNo == 1) {
deptService.ensureOaSyncRootDept();
if (!incremental) {
oaSyncRawDumpHelper.beginOrgFullDump();
}
}
IncrementalStart incrementalStart = resolveIncrementalStart(incremental, modifiedSince, DeptCategory.COMPANY);
OACompanySearch companySearch = buildCompanySearch(incrementalStart.getStartTime());
@@ -358,6 +373,9 @@ public class OASyncServiceImpl implements IOASyncService {
List<OACompanyResponse> oaCompanies = responseData.getDataList() == null
? Collections.emptyList() : responseData.getDataList();
long totalSize = responseData.getTotalSize() == null ? 0L : responseData.getTotalSize();
if (!incremental) {
oaSyncRawDumpHelper.collectCompanies(oaCompanies);
}
OrgSyncCount syncCount = handleCompany(oaCompanies, true);
if (CollectionUtil.isNotEmpty(syncCount.getNotHandledList())) {
ComposeLogUtil.getLastLog().warn("同步公司,本页未处理数据:{}", JSON.toJSONString(syncCount.getNotHandledList()));
@@ -365,7 +383,8 @@ public class OASyncServiceImpl implements IOASyncService {
OaOrgSyncPageVO pageVO = buildOrgSyncPageVO("company", pageNo, pageSize, totalSize, oaCompanies.size(), syncCount);
pageVO.setModifiedSince(incrementalStart.getModifiedSince());
if (Boolean.TRUE.equals(pageVO.getFinished())) {
// 公司全部分批入库后:重建层级,并删除上级不存在的数据
// 公司全部分批入库后:清理白名单外公司、重建层级,并删除上级不存在的数据
this.removeOutsideWhitelistOrgs();
deptService.rebuildOaHierarchy();
deptService.removeOaOrphansWithoutParent();
}
@@ -378,54 +397,105 @@ 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();
List<OADepartmentResponse> oaDepartments = responseData.getDataList() == null
? Collections.emptyList() : responseData.getDataList();
long totalSize = responseData.getTotalSize() == null ? 0L : responseData.getTotalSize();
if (!incremental) {
oaSyncRawDumpHelper.collectDepartments(oaDepartments);
}
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())) {
// 部门分批同步完成后,重建层级、清理无法挂载数据并刷新祖级列表
deptService.rebuildOaHierarchy();
deptService.removeOaOrphansWithoutParent();
deptService.updateAncestors(null);
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;
}
@@ -574,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));
}
@@ -623,6 +693,7 @@ public class OASyncServiceImpl implements IOASyncService {
}
Set<String> companyNames = syncAll ? Collections.emptySet() : getCompanyNames();
List<Dept> allParam = oaCompanies.stream()
.filter(this::isAllowedOaCompany)
.filter(company -> syncAll || companyNames.contains(company.getSubcompanyname()))
.map(deptConvert::company2dept)
.toList();
@@ -679,16 +750,18 @@ public class OASyncServiceImpl implements IOASyncService {
return OrgSyncCount.empty();
}
Map<String, Dept> companyByOaId = loadCompanyByOaId();
Set<String> allowedCompanyOaIds = resolveAllowedCompanyOaIds(companyByOaId);
String rootCompanyId = getRootCompanyId();
if (!syncAll && rootCompanyId == null) {
return new OrgSyncCount(0, oaDepts.size(), Collections.emptyList());
}
Set<String> rootCompanyDeptNames = syncAll ? Collections.emptySet() : getRootCompanyDeptNames();
List<Dept> allParam = oaDepts.stream()
.filter(oaDept -> allowedCompanyOaIds.contains(oaDept.getSubcompanyid1()))
.filter(oaDept -> syncAll
|| rootCompanyId == null
|| !rootCompanyId.equals(oaDept.getSubcompanyid1())
|| (OAConstant.ROOT_COMPANY_ID.equals(oaDept.getSupdepid()) && rootCompanyDeptNames.contains(oaDept.getDepartmentname())))
|| (OAConstant.ROOT_COMPANY_ID.equals(oaDept.getSupdepid()) && rootCompanyDeptNames.contains(oaDept.getDepartmentmark())))
.map(dept -> deptConvert.dept2dept(dept, companyByOaId))
.toList();
int filteredSkipCount = oaDepts.size() - allParam.size();
@@ -733,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());
@@ -746,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());
@@ -836,8 +921,9 @@ public class OASyncServiceImpl implements IOASyncService {
.eq(Dept::getDeptCategory, deptCategory.getCode())
.isNotNull(Dept::getOaId)
.ne(Dept::getOaId, "")
.select(Dept::getId, Dept::getDeptCode, Dept::getOaModified, Dept::getParentId,
Dept::getOaId, Dept::getBelongCompanyCode, Dept::getParentCode, Dept::getOaSupSubComId));
.select(Dept::getId, Dept::getDeptCode, Dept::getDeptName, Dept::getFullName,
Dept::getOaModified, Dept::getParentId, Dept::getOaId, Dept::getBelongCompanyCode,
Dept::getParentCode, Dept::getOaSupSubComId));
Map<String, Dept> byOaId = new HashMap<>();
for (Dept dept : deptList) {
if (StringUtils.isNotBlank(dept.getOaId())) {
@@ -962,7 +1048,7 @@ public class OASyncServiceImpl implements IOASyncService {
/**
* oaModified 一致则跳过更新;任一侧为空无法确认一致,执行更新以补齐字段。
* 组织编码已按新规则重算且与库中不一致时,即使 modified 相同也要更新。
* 组织编码已按新规则重算、父子挂载不一致、或名称字段与当前映射不一致时,即使 modified 相同也要更新。
*/
private boolean shouldSkipOaUpdate(Dept existing, Dept incoming) {
if (existing == null) {
@@ -972,6 +1058,23 @@ 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;
}
if (incoming != null && !StringUtils.equals(StringUtils.trimToEmpty(existing.getDeptName()),
StringUtils.trimToEmpty(incoming.getDeptName()))) {
return false;
}
if (incoming != null && !StringUtils.equals(StringUtils.trimToEmpty(existing.getFullName()),
StringUtils.trimToEmpty(incoming.getFullName()))) {
return false;
}
String localModified = StringUtils.trimToEmpty(existing.getOaModified());
String remoteModified = StringUtils.trimToEmpty(incoming == null ? null : incoming.getOaModified());
if (localModified.isEmpty() || remoteModified.isEmpty()) {
@@ -1012,12 +1115,112 @@ public class OASyncServiceImpl implements IOASyncService {
}
/**
* 获取 OA 部门查询使用的子公司 id
* 是否允许同步的 OA 公司:二级白名单,或其上级为二级白名单(三级子公司)
*/
private boolean isAllowedOaCompany(OACompanyResponse company) {
if (company == null || StringUtils.isBlank(company.getId())) {
return false;
}
if (OAConvertConstant.OA_SYNC_KEEP_SECOND_LEVEL_COMPANY_IDS.contains(company.getId())) {
return true;
}
return OAConvertConstant.OA_SYNC_KEEP_SECOND_LEVEL_COMPANY_IDS.contains(company.getSupsubcomid());
}
/**
* 解析允许同步部门的所属公司 OA 主键:二级白名单 ∪ 已同步的三级子公司
*/
private Set<String> resolveAllowedCompanyOaIds(Map<String, Dept> companyByOaId) {
Set<String> allowed = new HashSet<>(OAConvertConstant.OA_SYNC_KEEP_SECOND_LEVEL_COMPANY_IDS);
if (companyByOaId == null || companyByOaId.isEmpty()) {
return allowed;
}
for (Dept company : companyByOaId.values()) {
if (company == null || StringUtils.isBlank(company.getOaId())) {
continue;
}
if (OAConvertConstant.OA_SYNC_KEEP_SECOND_LEVEL_COMPANY_IDS.contains(company.getOaId())
|| OAConvertConstant.OA_SYNC_KEEP_SECOND_LEVEL_COMPANY_IDS.contains(company.getOaSupSubComId())) {
allowed.add(company.getOaId());
}
}
return allowed;
}
/**
* 清理不在白名单树内的 OA 公司及其子树(含部门)
*/
private int removeOutsideWhitelistOrgs() {
List<Dept> oaCompanyList = deptService.list(Wrappers.<Dept>lambdaQuery()
.eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode())
.isNotNull(Dept::getOaId)
.ne(Dept::getOaId, ""));
Map<String, Dept> companyByOaId = new HashMap<>();
for (Dept company : oaCompanyList) {
if (StringUtils.isNotBlank(company.getOaId())) {
companyByOaId.put(company.getOaId(), company);
}
}
Set<String> allowedCompanyOaIds = resolveAllowedCompanyOaIds(companyByOaId);
Set<Long> deleteIds = new HashSet<>();
for (Dept company : oaCompanyList) {
if (!allowedCompanyOaIds.contains(company.getOaId())) {
deleteIds.add(company.getId());
}
}
Map<String, String> companyCodeToOaId = new HashMap<>();
for (Dept company : oaCompanyList) {
if (StringUtils.isNotBlank(company.getDeptCode()) && StringUtils.isNotBlank(company.getOaId())) {
companyCodeToOaId.put(company.getDeptCode(), company.getOaId());
}
}
List<Dept> oaDeptList = deptService.list(Wrappers.<Dept>lambdaQuery()
.eq(Dept::getDeptCategory, DeptCategory.DEPT.getCode())
.isNotNull(Dept::getOaId)
.ne(Dept::getOaId, ""));
for (Dept dept : oaDeptList) {
String belongOaId = null;
if (StringUtils.isNotBlank(dept.getBelongCompanyCode())) {
belongOaId = companyCodeToOaId.get(dept.getBelongCompanyCode());
}
if (StringUtils.isBlank(belongOaId) && StringUtils.isNotBlank(dept.getOaSupSubComId())
&& companyByOaId.containsKey(dept.getOaSupSubComId())) {
// 根部门上级可能直接是公司 OA 主键
belongOaId = dept.getOaSupSubComId();
}
if (StringUtils.isBlank(belongOaId) || !allowedCompanyOaIds.contains(belongOaId)) {
deleteIds.add(dept.getId());
}
}
if (CollectionUtil.isEmpty(deleteIds)) {
return 0;
}
List<Dept> allDeptList = deptService.list();
boolean cascadeChanged = true;
while (cascadeChanged) {
cascadeChanged = false;
for (Dept dept : allDeptList) {
if (deleteIds.contains(dept.getId())) {
continue;
}
if (dept.getParentId() != null && deleteIds.contains(dept.getParentId())) {
deleteIds.add(dept.getId());
cascadeChanged = true;
}
}
}
deptService.removeByIds(deleteIds);
ComposeLogUtil.getLastLog().info("清理白名单外 OA 组织{}条", deleteIds.size());
return deleteIds.size();
}
/**
* 整理部门同步用的公司 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()
@@ -1028,7 +1231,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)
@@ -1037,11 +1240,17 @@ public class OASyncServiceImpl implements IOASyncService {
.ne(Dept::getOaId, "")
);
}
return companyList.stream()
.map(this::resolveOaCompanyId)
.filter(StringUtils::isNotBlank)
.distinct()
.collect(Collectors.joining(","));
Map<String, Dept> companyByOaId = new LinkedHashMap<>();
for (Dept company : companyList) {
String oaId = this.resolveOaCompanyId(company);
if (StringUtils.isNotBlank(oaId)) {
companyByOaId.putIfAbsent(oaId, company);
}
}
Set<String> allowedCompanyOaIds = resolveAllowedCompanyOaIds(companyByOaId);
return companyByOaId.keySet().stream()
.filter(allowedCompanyOaIds::contains)
.collect(Collectors.toList());
}
/**
@@ -0,0 +1,379 @@
/**
* 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.system.service.impl;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONWriter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.tool.utils.DateUtil;
import org.springblade.system.log.ComposeLogUtil;
import org.springblade.thirdparty.oa.feign.IOAClient;
import org.springblade.thirdparty.oa.pojo.response.OACompanyResponse;
import org.springblade.thirdparty.oa.pojo.response.OADepartmentResponse;
import org.springblade.thirdparty.oa.pojo.response.OAPersonResponse;
import org.springblade.thirdparty.oa.pojo.search.OACompanySearch;
import org.springblade.thirdparty.oa.pojo.search.OADepartmentSearch;
import org.springblade.thirdparty.oa.pojo.search.OASearch;
import org.springblade.thirdparty.oa.util.OAUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
/**
* OA 全量同步原始数据落盘:按父级 id 组装层级 JSON
*
* @author Chill
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OaSyncRawDumpHelper {
private static final String ORPHAN_COMPANY_ID = "_orphan_company";
private static final String ORPHAN_DEPT_ID = "_orphan_dept";
private final IOAClient oaClient;
@Value("${third-party.oa.sync-dump-dir:./data/oa-sync}")
private String syncDumpDir;
/**
* 当前全量同步会话收集到的人员原始数据
*/
private final List<OAPersonResponse> collectingPersons = Collections.synchronizedList(new ArrayList<>());
private volatile boolean collecting;
/**
* 机构全量同步会话收集的公司/部门原始数据
*/
private final List<OACompanyResponse> collectingCompanies = Collections.synchronizedList(new ArrayList<>());
private final List<OADepartmentResponse> collectingDepartments = Collections.synchronizedList(new ArrayList<>());
private volatile boolean collectingOrg;
/**
* 全量同步首页启动收集
*/
public synchronized void beginFullDump() {
collectingPersons.clear();
collecting = true;
ComposeLogUtil.getLastLog().info("已开启 OA 全量同步原始数据收集,目录={}", resolveDumpDir());
}
/**
* 机构全量同步首页启动收集公司/部门原始数据
*/
public synchronized void beginOrgFullDump() {
collectingCompanies.clear();
collectingDepartments.clear();
collectingOrg = true;
ComposeLogUtil.getLastLog().info("已开启 OA 机构全量同步原始数据收集,目录={}", resolveDumpDir());
}
/**
* 收集本页人员原始数据
*/
public void collectPersons(List<OAPersonResponse> oaPersons) {
if (!collecting || oaPersons == null || oaPersons.isEmpty()) {
return;
}
collectingPersons.addAll(oaPersons);
}
/**
* 收集本页公司原始数据
*/
public void collectCompanies(List<OACompanyResponse> oaCompanies) {
if (!collectingOrg || oaCompanies == null || oaCompanies.isEmpty()) {
return;
}
collectingCompanies.addAll(oaCompanies);
}
/**
* 收集本页部门原始数据
*/
public void collectDepartments(List<OADepartmentResponse> oaDepartments) {
if (!collectingOrg || oaDepartments == null || oaDepartments.isEmpty()) {
return;
}
collectingDepartments.addAll(oaDepartments);
}
/**
* 全量同步结束:先写入人员层级 JSON,再异步补齐公司/部门接口原始层级数据
*
* @return 人员原始层级 JSON 文件路径,失败时返回 null
*/
public synchronized String finishFullDump() {
if (!collecting) {
return null;
}
List<OAPersonResponse> persons = new ArrayList<>(collectingPersons);
collecting = false;
collectingPersons.clear();
try {
String stamp = DateUtil.format(new Date(), "yyyyMMddHHmmss");
Map<String, Object> personPayload = new LinkedHashMap<>();
personPayload.put("exportTime", DateUtil.format(new Date(), DateUtil.PATTERN_DATETIME));
personPayload.put("personCount", persons.size());
personPayload.put("personTree", buildPersonTree(persons));
personPayload.put("personsRaw", persons);
Path personFile = writeDumpFile("oa-full-sync-person-raw-" + stamp + ".json", personPayload);
ComposeLogUtil.getLastLog().info("OA 人员原始层级数据已写入 {}", personFile.toAbsolutePath());
CompletableFuture.runAsync(() -> dumpOrgRawAsync(stamp));
return personFile.toAbsolutePath().toString();
} catch (Exception exception) {
log.error("写入 OA 全量同步人员原始 JSON 失败", exception);
ComposeLogUtil.getLastLog().error("写入 OA 全量同步人员原始 JSON 失败:{}", exception.getMessage());
return null;
}
}
/**
* 机构全量同步结束:写入公司/部门接口原始数据(按父级 id 层级)
*
* @return JSON 文件路径,失败时返回 null
*/
public synchronized String finishOrgFullDump() {
if (!collectingOrg) {
return null;
}
List<OACompanyResponse> companies = new ArrayList<>(collectingCompanies);
List<OADepartmentResponse> departments = new ArrayList<>(collectingDepartments);
collectingOrg = false;
collectingCompanies.clear();
collectingDepartments.clear();
try {
String stamp = DateUtil.format(new Date(), "yyyyMMddHHmmss");
Path orgFile = writeOrgDumpFile(stamp, companies, departments);
ComposeLogUtil.getLastLog().info("OA 机构原始层级数据已写入 {}", orgFile.toAbsolutePath());
return orgFile.toAbsolutePath().toString();
} catch (Exception exception) {
log.error("写入 OA 机构全量同步原始 JSON 失败", exception);
ComposeLogUtil.getLastLog().error("写入 OA 机构全量同步原始 JSON 失败:{}", exception.getMessage());
return null;
}
}
private void dumpOrgRawAsync(String stamp) {
try {
List<OACompanyResponse> companies = fetchAllCompanies();
List<OADepartmentResponse> departments = fetchAllDepartments(companies);
Path orgFile = writeOrgDumpFile(stamp, companies, departments);
log.info("OA 公司/部门原始层级数据已写入 {}", orgFile.toAbsolutePath());
} catch (Exception exception) {
log.error("异步写入 OA 公司/部门原始 JSON 失败", exception);
}
}
private Path writeOrgDumpFile(String stamp, List<OACompanyResponse> companies,
List<OADepartmentResponse> departments) throws IOException {
Map<String, Object> orgPayload = new LinkedHashMap<>();
orgPayload.put("exportTime", DateUtil.format(new Date(), DateUtil.PATTERN_DATETIME));
orgPayload.put("companyCount", companies.size());
orgPayload.put("departmentCount", departments.size());
orgPayload.put("companyTree", buildCompanyTree(companies));
orgPayload.put("departmentTree", buildDepartmentTree(departments));
orgPayload.put("companiesRaw", companies);
orgPayload.put("departmentsRaw", departments);
return writeDumpFile("oa-full-sync-org-raw-" + stamp + ".json", orgPayload);
}
private List<OACompanyResponse> fetchAllCompanies() {
List<OACompanyResponse> companies = new ArrayList<>();
OACompanySearch companySearch = new OACompanySearch();
companySearch.setCurPage(1);
companySearch.setPageSize(100);
OAUtils.pageSyncHandler(companySearch, param -> oaClient.queryCompanyPage(new OASearch<>(param)), response -> {
log.error("导出公司原始数据失败 {}", JSON.toJSONString(response));
return new ServiceException("导出公司原始数据失败");
}, 10000, log::info).accept(companies::addAll);
return companies;
}
private List<OADepartmentResponse> fetchAllDepartments(List<OACompanyResponse> companies) {
List<OADepartmentResponse> departments = new ArrayList<>();
String subCompanyIds = companies.stream()
.map(OACompanyResponse::getId)
.filter(StringUtils::isNotBlank)
.distinct()
.reduce((left, right) -> left + "," + right)
.orElse("");
OADepartmentSearch departmentSearch = new OADepartmentSearch();
departmentSearch.setCurPage(1);
departmentSearch.setPageSize(100);
departmentSearch.setSubcompanyid1(subCompanyIds);
OAUtils.pageSyncHandler(departmentSearch, param -> oaClient.queryDepartmentPage(new OASearch<>(param)), response -> {
log.error("导出部门原始数据失败 {}", JSON.toJSONString(response));
return new ServiceException("导出部门原始数据失败");
}, 10000, log::info).accept(departments::addAll);
return departments;
}
private List<Map<String, Object>> buildCompanyTree(List<OACompanyResponse> companies) {
Map<String, Map<String, Object>> nodeMap = new LinkedHashMap<>();
for (OACompanyResponse company : companies) {
if (company == null || StringUtils.isBlank(company.getId())) {
continue;
}
Map<String, Object> node = new LinkedHashMap<>();
node.put("id", company.getId());
node.put("parentId", blankToRoot(company.getSupsubcomid()));
node.put("type", "company");
node.put("name", company.getSubcompanyname());
node.put("raw", company);
node.put("children", new ArrayList<Map<String, Object>>());
nodeMap.put(company.getId(), node);
}
return linkChildren(nodeMap);
}
private List<Map<String, Object>> buildDepartmentTree(List<OADepartmentResponse> departments) {
Map<String, Map<String, Object>> nodeMap = new LinkedHashMap<>();
for (OADepartmentResponse department : departments) {
if (department == null || StringUtils.isBlank(department.getId())) {
continue;
}
Map<String, Object> node = new LinkedHashMap<>();
node.put("id", department.getId());
node.put("parentId", blankToRoot(department.getSupdepid()));
node.put("companyId", department.getSubcompanyid1());
node.put("type", "department");
node.put("name", department.getDepartmentname());
node.put("raw", department);
node.put("children", new ArrayList<Map<String, Object>>());
nodeMap.put(department.getId(), node);
}
return linkChildren(nodeMap);
}
private List<Map<String, Object>> buildPersonTree(List<OAPersonResponse> persons) {
Map<String, Map<String, Object>> companyMap = new LinkedHashMap<>();
Map<String, Map<String, Object>> deptMap = new LinkedHashMap<>();
for (OAPersonResponse person : persons) {
if (person == null) {
continue;
}
String companyId = StringUtils.defaultIfBlank(person.getSubcompanyid1(), ORPHAN_COMPANY_ID);
String deptId = StringUtils.defaultIfBlank(person.getDepartmentid(), ORPHAN_DEPT_ID + "@" + companyId);
Map<String, Object> companyNode = companyMap.computeIfAbsent(companyId, key -> {
Map<String, Object> node = new LinkedHashMap<>();
node.put("id", key);
node.put("parentId", "0");
node.put("type", "company");
node.put("name", StringUtils.defaultIfBlank(person.getSubcompanyname(), key));
Map<String, Object> raw = new LinkedHashMap<>();
raw.put("subcompanyid1", person.getSubcompanyid1());
raw.put("subcompanyname", person.getSubcompanyname());
raw.put("subcompanycode", person.getSubcompanycode());
node.put("raw", raw);
node.put("children", new ArrayList<Map<String, Object>>());
return node;
});
@SuppressWarnings("unchecked")
List<Map<String, Object>> companyChildren = (List<Map<String, Object>>) companyNode.get("children");
Map<String, Object> deptNode = deptMap.computeIfAbsent(deptId, key -> {
Map<String, Object> node = new LinkedHashMap<>();
node.put("id", key);
node.put("parentId", companyId);
node.put("type", "department");
node.put("name", StringUtils.defaultIfBlank(person.getDepartmentname(), key));
Map<String, Object> raw = new LinkedHashMap<>();
raw.put("departmentid", person.getDepartmentid());
raw.put("departmentname", person.getDepartmentname());
raw.put("departmentcode", person.getDepartmentcode());
raw.put("subcompanyid1", person.getSubcompanyid1());
node.put("raw", raw);
node.put("children", new ArrayList<Map<String, Object>>());
companyChildren.add(node);
return node;
});
@SuppressWarnings("unchecked")
List<Map<String, Object>> deptChildren = (List<Map<String, Object>>) deptNode.get("children");
Map<String, Object> personNode = new LinkedHashMap<>();
personNode.put("id", StringUtils.defaultIfBlank(person.getId(), person.getLoginid()));
personNode.put("parentId", deptId);
personNode.put("type", "person");
personNode.put("name", person.getLastname());
personNode.put("raw", person);
personNode.put("children", Collections.emptyList());
deptChildren.add(personNode);
}
return new ArrayList<>(companyMap.values());
}
@SuppressWarnings("unchecked")
private List<Map<String, Object>> linkChildren(Map<String, Map<String, Object>> nodeMap) {
List<Map<String, Object>> roots = new ArrayList<>();
for (Map<String, Object> node : nodeMap.values()) {
String parentId = Objects.toString(node.get("parentId"), "0");
Map<String, Object> parent = nodeMap.get(parentId);
if (parent != null && !Objects.equals(parent.get("id"), node.get("id"))) {
((List<Map<String, Object>>) parent.get("children")).add(node);
} else {
roots.add(node);
}
}
return roots;
}
private String blankToRoot(String parentId) {
if (StringUtils.isBlank(parentId) || "0".equals(parentId) || "-1".equals(parentId)) {
return "0";
}
return parentId;
}
private Path writeDumpFile(String fileName, Map<String, Object> payload) throws IOException {
Path dir = resolveDumpDir();
Files.createDirectories(dir);
Path dumpFile = dir.resolve(fileName);
String json = JSON.toJSONString(payload, JSONWriter.Feature.PrettyFormat, JSONWriter.Feature.WriteMapNullValue);
Files.writeString(dumpFile, json, StandardCharsets.UTF_8);
return dumpFile;
}
private Path resolveDumpDir() {
return Paths.get(StringUtils.defaultIfBlank(syncDumpDir, "./data/oa-sync")).toAbsolutePath().normalize();
}
}
@@ -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=135/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;
}
/**
* 同步人员并绑定到三级部门
*
@@ -198,70 +403,37 @@ public class OaUserListSyncHelper {
Map<String, OAPersonResponse> uniquePersonMap = new LinkedHashMap<>();
int skippedCount = 0;
for (OAPersonResponse oaPerson : oaPersons) {
String account = userConvert.resolveAccount(oaPerson);
if (StringUtils.isBlank(account)) {
String oaLoginId = resolveOaLoginId(oaPerson);
if (StringUtils.isBlank(oaLoginId)) {
skippedCount++;
continue;
}
uniquePersonMap.putIfAbsent(account, oaPerson);
uniquePersonMap.put(oaLoginId, oaPerson);
}
if (uniquePersonMap.isEmpty()) {
ComposeLogUtil.getLastLog().warn("OA人员均缺少loginid/工号/手机号,跳过人员同步");
ComposeLogUtil.getLastLog().warn("OA人员均缺少登录名,跳过人员同步");
return new PersonSyncCount(0, skippedCount);
}
String tenantId = resolveTenantId();
String defaultPassword = DigestUtil.encrypt(ParamCache.getValue(DEFAULT_PARAM_PASSWORD));
List<User> users = uniquePersonMap.values().stream()
.map(person -> {
User user = userConvert.person2user(person, defaultPassword);
List<User> users = uniquePersonMap.entrySet().stream()
.map(entry -> {
User user = userConvert.person2user(entry.getValue(), defaultPassword);
user.setTenantId(tenantId);
user.setOaLoginId(entry.getKey());
user.setAccount(entry.getKey());
return user;
})
.toList();
List<String> accounts = users.stream()
.map(User::getAccount)
.filter(StringUtils::isNotBlank)
.distinct()
.toList();
List<String> phones = users.stream()
.map(User::getPhone)
.filter(StringUtils::isNotBlank)
.distinct()
.toList();
Map<String, User> existingByAccount = new HashMap<>();
Map<String, User> existingByPhone = new HashMap<>();
userService.list(Wrappers.<User>lambdaQuery()
.and(wrapper -> {
wrapper.in(User::getAccount, accounts);
if (CollectionUtil.isNotEmpty(phones)) {
wrapper.or().in(User::getPhone, phones);
}
})
).stream()
.peek(userService::decryptPhone)
.forEach(user -> {
if (StringUtils.isNotBlank(user.getAccount())) {
existingByAccount.put(user.getAccount(), user);
}
if (StringUtils.isNotBlank(user.getPhone())) {
existingByPhone.put(user.getPhone(), user);
}
});
Map<String, User> existingByOaLoginId = this.loadUsersByOaLoginId(tenantId, uniquePersonMap.keySet());
Map<String, Long> userMap = new HashMap<>();
Set<Long> existsUserIds = new HashSet<>();
Set<Long> unchangedUserIds = new HashSet<>();
users.forEach(user -> {
User existingUser = existingByAccount.get(user.getAccount());
if (existingUser == null && StringUtils.isNotBlank(user.getPhone())) {
existingUser = existingByPhone.get(user.getPhone());
}
User existingUser = existingByOaLoginId.get(user.getOaLoginId());
if (existingUser != null) {
user.setId(existingUser.getId());
user.setPassword(null);
existsUserIds.add(existingUser.getId());
if (shouldSkipOaUpdate(existingUser, user)) {
unchangedUserIds.add(existingUser.getId());
}
} else {
user.setId(DefaultIdentifierGenerator.getInstance().nextId(null));
user.setPostId("-1");
@@ -269,9 +441,9 @@ public class OaUserListSyncHelper {
user.setDataScopeRange(1);
user.setDataLevelRange(1);
user.setIncludeNewCustomer(0);
userService.encryptPhone(user);
}
userMap.put(user.getAccount(), user.getId());
userService.encryptPhone(user);
userMap.put(user.getOaLoginId(), user.getId());
});
List<User> addUsers = users.stream()
@@ -283,18 +455,13 @@ public class OaUserListSyncHelper {
}
List<User> updateUsers = users.stream()
.filter(user -> existsUserIds.contains(user.getId()))
.filter(user -> !unchangedUserIds.contains(user.getId()))
.toList();
if (CollectionUtil.isNotEmpty(updateUsers)) {
ComposeLogUtil.getLastLog().info("批量修改用户:{}", updateUsers.size());
userService.updateBatchById(updateUsers);
}
if (!unchangedUserIds.isEmpty()) {
ComposeLogUtil.getLastLog().info("跳过未变更用户:{}", unchangedUserIds.size());
}
ComposeLogUtil.getLastLog().info("缺少账号已跳过的人员:{}", skippedCount);
ComposeLogUtil.getLastLog().info("缺少OA登录名已跳过的人员:{}", skippedCount);
int syncedCount = addUsers.size() + updateUsers.size();
skippedCount += unchangedUserIds.size();
if (userMap.isEmpty()) {
return new PersonSyncCount(0, skippedCount);
}
@@ -306,11 +473,8 @@ public class OaUserListSyncHelper {
Set<Long> existsUserDeptIds = new HashSet<>(userDeptMap.values());
List<UserDept> userDeptList = oaPersons.stream()
.filter(oaPerson -> {
String account = userConvert.resolveAccount(oaPerson);
if (!userMap.containsKey(account)) {
return false;
}
return !unchangedUserIds.contains(userMap.get(account));
String oaLoginId = resolveOaLoginId(oaPerson);
return StringUtils.isNotBlank(oaLoginId) && userMap.containsKey(oaLoginId);
})
.map(oaPerson -> this.buildUserDept(oaPerson, userMap, orgIndex))
.filter(Objects::nonNull)
@@ -349,7 +513,6 @@ public class OaUserListSyncHelper {
.collect(Collectors.toSet());
String defaultRoleId = getDefaultRoleId();
List<User> updateUserParams = list.stream()
.filter(userDeptIds -> !unchangedUserIds.contains(userDeptIds.getUserId()))
.filter(userDeptIds -> {
if (StringUtils.isBlank(userDeptIds.getDeptIds())) {
return false;
@@ -381,87 +544,94 @@ public class OaUserListSyncHelper {
}
/**
* oaModified 一致则跳过更新;任一侧为空无法确认一致,执行更新以补齐字段
* 按 OA 登录名加载已有用户。同一登录名有多条时保留较早的一条,后续同步都更新这一条。
*/
private boolean shouldSkipOaUpdate(User existing, User incoming) {
if (existing == null) {
return false;
private Map<String, User> loadUsersByOaLoginId(String tenantId, Collection<String> oaLoginIds) {
Map<String, User> existingByOaLoginId = new HashMap<>();
if (CollectionUtil.isEmpty(oaLoginIds)) {
return existingByOaLoginId;
}
String localModified = StringUtils.trimToEmpty(existing.getOaModified());
String remoteModified = StringUtils.trimToEmpty(incoming == null ? null : incoming.getOaModified());
if (localModified.isEmpty() || remoteModified.isEmpty()) {
return false;
List<String> oaLoginIdList = oaLoginIds.stream()
.filter(StringUtils::isNotBlank)
.distinct()
.toList();
int batchSize = 500;
for (int fromIndex = 0; fromIndex < oaLoginIdList.size(); fromIndex += batchSize) {
List<String> oaLoginIdBatch = oaLoginIdList.subList(fromIndex, Math.min(fromIndex + batchSize, oaLoginIdList.size()));
userService.list(Wrappers.<User>lambdaQuery()
.eq(User::getTenantId, tenantId)
.in(User::getOaLoginId, oaLoginIdBatch))
.forEach(user -> {
String oaLoginId = StringUtils.trimToNull(user.getOaLoginId());
if (oaLoginId == null) {
return;
}
User previous = existingByOaLoginId.get(oaLoginId);
if (previous == null) {
existingByOaLoginId.put(oaLoginId, user);
return;
}
if (user.getId() != null && previous.getId() != null && user.getId() < previous.getId()) {
ComposeLogUtil.getLastLog().warn("OA登录名重复,保留较早用户 oaLoginId={} keepUserId={} dropUserId={}",
oaLoginId, user.getId(), previous.getId());
existingByOaLoginId.put(oaLoginId, user);
return;
}
ComposeLogUtil.getLastLog().warn("OA登录名重复,保留较早用户 oaLoginId={} keepUserId={} dropUserId={}",
oaLoginId, previous.getId(), user.getId());
});
}
return localModified.equals(remoteModified);
return existingByOaLoginId;
}
private Dept getOrCreateRootCompany() {
return deptService.ensureOaSyncRootDept();
private String resolveOaLoginId(OAPersonResponse oaPerson) {
if (oaPerson == null) {
return null;
}
return StringUtils.trimToNull(oaPerson.getLoginid());
}
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;
private boolean isPersonBypassCode(String deptCode, DeptCategory deptCategory) {
if (StringUtils.isBlank(deptCode)) {
return true;
}
Dept dept = this.buildOrgDept(name, oaCode, parent, tenantId, deptCategory);
dept.setId(DefaultIdentifierGenerator.getInstance().nextId(null));
addList.add(dept);
return dept;
if (DeptCategory.COMPANY.equals(deptCategory)) {
return deptCode.startsWith(OAConvertConstant.COMPANY_OA_PREFIX);
}
return deptCode.startsWith(OAConvertConstant.DEPARTMENT_OA_PREFIX);
}
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);
}
if (CollectionUtil.isNotEmpty(updateList)) {
ComposeLogUtil.getLastLog().info("批量修改{}:{}", deptCategory.getName(), updateList.size());
deptService.updateBatchById(updateList);
/** 同一 oa_id 多条时优先正式编码,避免人员挂到 OAC/OAD 旁路重复节点。 */
private void putPreferredByOaId(Map<String, Dept> byOaId, Dept dept, DeptCategory deptCategory) {
if (dept == null || StringUtils.isBlank(dept.getOaId())) {
return;
}
this.putPreferredByOaKey(byOaId, dept.getOaId().trim(), dept, deptCategory);
}
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 void putPreferredByOaKey(Map<String, Dept> byOaId, String oaKey, Dept dept, DeptCategory deptCategory) {
if (dept == null || StringUtils.isBlank(oaKey)) {
return;
}
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 UserDept buildUserDept(OAPersonResponse oaPerson, Map<String, Long> userMap, OaOrgIndex orgIndex) {
Dept department = orgIndex.findDept(oaPerson);
if (department == null || department.getId() == null) {
ComposeLogUtil.getLastLog().warn(
"人员未匹配到已有部门,请先同步组织架构 oaLoginId={} departmentid={} departmentname={} subcompanyid1={}",
resolveOaLoginId(oaPerson), oaPerson.getDepartmentid(), oaPerson.getDepartmentname(),
oaPerson.getSubcompanyid1());
return null;
}
UserDept userDept = userConvert.person2userDept(oaPerson, userMap);
@@ -493,55 +663,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
@@ -309,6 +309,12 @@ public class BfiProperties {
*/
private String invoiceTypes = "";
/**
* 客商单位过滤开关:开启后只同步开票单位(销方)与受票单位(购方)税号均已登记在
* 客商发票信息(blade_customer_invoice_info)中的发票,前端票池展示查询同用此开关过滤,默认关闭
*/
private boolean customerUnitsOnly = false;
/**
* 同步 single-flight 锁租约秒数,须大于最坏一轮耗时;进程异常退出时锁在租约到期后自动释放
*/
@@ -32,6 +32,9 @@ public final class KingdeeLockConstant {
/** 来源操作类型:单据删除(解锁回调携带 DELETE 时 WLTMS 废除源单并还原关联数据) */
public static final String OPERATION_DELETE = "DELETE";
/** 来源操作类型:结算调整重推(结算调整单重推后解锁原结算单旧锁记录,非金蝶回调) */
public static final String OPERATION_ADJUST_REPUSH = "ADJUST_REPUSH";
/** 系统标识:WLTMS(运输管理系统,即本系统) */
public static final String SYSTEM_WLTMS = "WLTMS";
@@ -31,9 +31,21 @@ package org.springblade.transport.constant;
*/
public final class TransportBusiConstant {
/** 付款类型:项目预付。 */
public static final String PAYMENT_TYPE_PROJECT_ADVANCE = "project_advance";
/** 付款类型:进度预付。 */
public static final String PAYMENT_TYPE_PROGRESS_ADVANCE = "progress_advance";
/** 付款类型:结算付款(关联正式结算单的正式付款)。 */
public static final String PAYMENT_TYPE_SETTLEMENT = "settlement_payment";
/** 结算单类型:预结算。 */
public static final String SETTLEMENT_BILL_PRE = "pre";
/** 结算单类型:正式结算。 */
public static final String SETTLEMENT_BILL_FORMAL = "formal";
/** 审批状态:草稿(允许编辑、删除)。 */
public static final String DRAFT = "draft";
@@ -61,8 +61,9 @@ public class BillLedgerController extends BladeController {
@ApiOperationSupport(order = 4)
@Operation(summary = "付款申请可用汇票")
public R<List<BillLedgerVO>> availableOptions(@RequestParam(required = false) String keyword,
@RequestParam(required = false) Long deptId, @RequestParam(required = false) Long selectedId) {
return R.data(billLedgerService.availableOptions(keyword, deptId, selectedId));
@RequestParam(required = false) Long deptId, @RequestParam(required = false) Long selectedId,
@RequestParam(required = false) String payerName, @RequestParam(required = false) String payeeName) {
return R.data(billLedgerService.availableOptions(keyword, deptId, selectedId, payerName, payeeName));
}
@GetMapping("/available-page")
@@ -70,8 +71,10 @@ public class BillLedgerController extends BladeController {
@Operation(summary = "付款申请可用汇票分页")
public R<IPage<BillLedgerVO>> availablePage(Query pageQuery,
@RequestParam(required = false) String keyword, @RequestParam(required = false) Long deptId,
@RequestParam(required = false) Long selectedId) {
return R.data(billLedgerService.availablePage(Condition.getPage(pageQuery), keyword, deptId, selectedId));
@RequestParam(required = false) Long selectedId, @RequestParam(required = false) String payerName,
@RequestParam(required = false) String payeeName) {
return R.data(billLedgerService.availablePage(Condition.getPage(pageQuery), keyword, deptId, selectedId,
payerName, payeeName));
}
@PostMapping("/submit")
@@ -105,6 +105,65 @@ public class DriverWaybillController extends BladeController {
return R.data(driverWaybillService.detail(id));
}
@GetMapping("/task/current")
@ApiOperationSupport(order = 21)
@Operation(summary = "首页:当前运输中运次", description = "当前用户手机号可见的运输中运次(有配载单的一趟车算一个)最新一条")
public R<DriverWaybillCardVO> tripCurrentTask() {
return R.data(driverWaybillService.tripCurrentTask());
}
@GetMapping("/task/pending-preview")
@ApiOperationSupport(order = 22)
@Operation(summary = "首页:待接运次预览", description = "当前用户手机号可见的待接运次预览与总数(按运次计)")
public R<DriverWaybillPreviewVO> tripPendingPreview(
@Parameter(description = "预览条数,默认 2") @RequestParam(required = false) Integer size) {
return R.data(driverWaybillService.tripPendingPreview(size));
}
@GetMapping("/task/counts")
@ApiOperationSupport(order = 23)
@Operation(summary = "运次 Tab 统计", description = "按运次统计:全部 = 待接单 + 进行中 + 已完成(不含已取消)")
public R<DriverWaybillTabCountsVO> tripCounts() {
return R.data(driverWaybillService.tripTabCounts());
}
@GetMapping("/task/page")
@ApiOperationSupport(order = 24)
@Operation(summary = "运次分页列表", description = "一页即若干个运次;status:空=全部,0待接单,1运输中,2已完成;keyword 支持运单号/配载单号/起终点/车牌")
public R<IPage<DriverWaybillCardVO>> tripPage(
@Parameter(description = "当前页") @RequestParam(required = false) Integer current,
@Parameter(description = "每页运次数") @RequestParam(required = false) Integer size,
@Parameter(description = "状态:0待接单/1运输中/2已完成,不传为全部") @RequestParam(required = false) String status,
@Parameter(description = "关键字:运单号/配载单号/起终点/车牌") @RequestParam(required = false) String keyword) {
Integer statusCode = parseStatus(status);
return R.data(driverWaybillService.tripPage(current, size, statusCode, keyword));
}
@GetMapping("/task/detail")
@ApiOperationSupport(order = 25)
@Operation(summary = "司机运次详情", description = "按运次主键(字符串)取整趟车;有配载单时下发站点 ①②③(stations)与每站货物条目")
public R<DriverWaybillCardVO> tripDetail(
@Parameter(description = "运次主键(配载单主键或运单主键)", required = true) @RequestParam String id) {
return R.data(driverWaybillService.tripDetail(id));
}
@PostMapping("/task/accept")
@ApiOperationSupport(order = 26)
@Operation(summary = "运次确认接单", description = "一次接单把该运次全部子运单置为已接单;任一子单已接单即视为整单已接单")
public R tripAccept(
@Parameter(description = "运次主键(配载单主键或运单主键)", required = true) @RequestParam String id) {
return R.status(driverWaybillService.tripAccept(id));
}
@PostMapping("/task/reject")
@ApiOperationSupport(order = 27)
@Operation(summary = "运次拒绝接单", description = "整单拒绝:该运次全部未终态子运单一起置为已拒绝,此后该运次对本人不可见")
public R tripReject(
@Parameter(description = "运次主键(配载单主键或运单主键)", required = true) @RequestParam String id,
@Parameter(description = "拒绝原因") @RequestParam(required = false) String reason) {
return R.status(driverWaybillService.tripReject(id, reason));
}
@PostMapping("/track")
@ApiOperationSupport(order = 11)
@Operation(summary = "司机端行驶轨迹", description = "按当前用户手机号校验归属后,根据打卡记录/计划时间自动推导起止日期并查询 LBS 轨迹(WGS-84)")
@@ -0,0 +1,123 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.controller;
import cn.hutool.core.util.StrUtil;
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.tenant.annotation.TenantIgnore;
import org.springblade.core.tool.api.R;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import java.util.Objects;
/**
* HCS 合规回调 Mock 端点
* <p>
* 合同法务审批结果回调(D103)、付款法务审批结果回调(D104)、项目金蝶同步结果回调(D902)、
* 合同金蝶同步结果回传接收(D903)四个入站回调的纯 mock 实现:仅校验来源系统标识后返回固定结果,
* 不读写业务数据、不联动状态。真实处理逻辑保留在对应 Support 中,联调时把端点切回
* {@link } 并调用 Support 方法即可。
*
* @author Chill
*/
@Slf4j
@RestController
@TenantIgnore
@RequestMapping("hcs")
@Tag(name = "HCS合规回调Mock", description = "HCS合规回调Mock端点(仅自测,返回固定 mock 数据)")
public class HcsMockController {
/**
* 来源系统标识(合规方约定值,与 third-party.hcs.src-system 默认值一致)
*/
private static final String SOURCE_SYSTEM = "WLTMS";
/**
* 合同法务审批结果回调(D103)
*/
@PostMapping("/contractLegal/result")
@ApiOperationSupport(order = 15)
@Operation(summary = "合同法务审批结果回调Mock", description = "D103 合规推送合同特批(金蝶同步)结果,仅校验来源系统并返回 mock 数据")
public R<String> contractLegalResult(@RequestBody(required = false) Map<String, Object> param) {
this.checkSourceSystem("合同法务审批结果", param);
return R.data("mock特批结果已处理:approved");
}
/**
* 付款法务审批结果回调(D104)
*/
@PostMapping("/paymentLegal/result")
@ApiOperationSupport(order = 16)
@Operation(summary = "付款法务审批结果回调Mock", description = "D104 合规推送付款特批结果,仅校验来源系统并返回 mock 数据")
public R<String> paymentLegalResult(@RequestBody(required = false) Map<String, Object> param) {
this.checkSourceSystem("付款法务审批结果", param);
return R.data("mock付款特批结果接收已处理:approved");
}
/**
* 项目金蝶同步结果回调(D902)
*/
@PostMapping("/projectLegal/result")
@ApiOperationSupport(order = 18)
@Operation(summary = "项目金蝶同步结果回调Mock", description = "D902 合规推送项目金蝶同步结果,仅校验来源系统并返回 mock 数据")
public R<String> projectLegalResult(@RequestBody(required = false) Map<String, Object> param) {
this.checkSourceSystem("项目金蝶同步结果", param);
return R.data("mock项目金蝶同步结果接收已处理:approved");
}
/**
* 合同金蝶同步结果回传接收(D903)
*/
@PostMapping("/contractKingdee/result")
@ApiOperationSupport(order = 19)
@Operation(summary = "合同金蝶同步结果回传接收Mock", description = "D903 合规推送合同金蝶最终同步结果,仅校验来源系统并返回 mock 数据")
public R<String> contractKingdeeResult(@RequestBody(required = false) Map<String, Object> param) {
this.checkSourceSystem("合同金蝶同步结果", param);
return R.data("mock合同金蝶同步结果接收处理:approved");
}
/**
* 来源系统校验:仅接受合规方约定的来源标识,防伪造回传
*
* @param bizName 业务名称(日志用)
* @param param 回调报文
*/
private void checkSourceSystem(String bizName, Map<String, Object> param) {
String sourceSystem = param == null ? null : Objects.toString(param.get("sourceSystem"), null);
if (!StrUtil.equalsIgnoreCase(sourceSystem, SOURCE_SYSTEM)) {
log.warn("{}推送来源系统标识错误:sourceSystem={}", bizName, sourceSystem);
throw new ServiceException("来源系统标识错误");
}
}
}
@@ -84,10 +84,9 @@ public class InvoiceApplicationController extends BladeController {
public R<IPage<Map<String, Object>>> settlementCandidates(Query query,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String contractCategory,
@RequestParam(defaultValue = "receivable") String settlementType,
@RequestParam(defaultValue = "unreceived") String invoiceStatus) {
@RequestParam(defaultValue = "receivable") String settlementType) {
return R.data(invoiceApplicationService.settlementCandidates(
Condition.getPage(query), keyword, contractCategory, settlementType, invoiceStatus));
Condition.getPage(query), keyword, contractCategory, settlementType));
}
@GetMapping("/settlement-details")
@@ -39,6 +39,7 @@ import org.springblade.transport.pojo.vo.AdminVehicleOptionVO;
import org.springblade.transport.pojo.vo.AdminWaybillCardVO;
import org.springblade.transport.pojo.vo.AdminWaybillDetailVO;
import org.springblade.transport.pojo.vo.WaybillPunchRecordsVO;
import org.springblade.transport.pojo.vo.WaybillTrackVO;
import org.springblade.transport.service.IManageWaybillService;
import org.springblade.transport.service.IWaybillService;
import org.springframework.web.bind.annotation.GetMapping;
@@ -69,7 +70,7 @@ public class ManageWaybillController extends BladeController {
@GetMapping("/stats")
@ApiOperationSupport(order = 1)
@Operation(summary = "运单状态统计", description = "待接单=pending,运输中=running,已完成=completed;租户内不过滤组织(小程序调度账号组织常与运单不一致);在途异常=异常处置状态≠已完成")
@Operation(summary = "运单状态统计", description = "待接单=pending,运输中=running,已完成=completed;只统计本组织的运单(超级管理员不过滤组织);在途异常=异常处置状态≠已完成")
public R<AdminHomeStatsVO> stats() {
return R.data(manageWaybillService.stats());
}
@@ -99,9 +100,9 @@ public class ManageWaybillController extends BladeController {
@GetMapping("/detail")
@ApiOperationSupport(order = 4)
@Operation(summary = "运单详情", description = "调度端查看运单详情(含 punchNodes / enrouteRecords),不校验司机归属与组织;字段对齐小程序 pages/waybill/detail")
@Operation(summary = "运次详情", description = "调度端查看整趟车详情(含 stations / punchNodes / enrouteRecords),不校验司机归属与组织;id 传运次主键字符串,已拒单的运单主键回落成单票视图")
public R<AdminWaybillDetailVO> detail(
@Parameter(description = "运单ID", required = true) @RequestParam Long id) {
@Parameter(description = "运次主键(配载单主键或运单主键,字符串)", required = true) @RequestParam String id) {
return R.data(manageWaybillService.detail(id));
}
@@ -159,4 +160,14 @@ public class ManageWaybillController extends BladeController {
return R.status(waybillService.savePunchRecord(request));
}
@PostMapping("/track")
@ApiOperationSupport(order = 11)
@Operation(summary = "车辆历史轨迹", description = "小程序调度端:仅需登录态,按运单绑定车牌和日期区间查询 LBS 轨迹")
public R<WaybillTrackVO> track(
@Parameter(description = "运单ID", required = true) @RequestParam Long id,
@Parameter(description = "开始日期 YYYY-MM-DD", required = true) @RequestParam String startDate,
@Parameter(description = "结束日期 YYYY-MM-DD", required = true) @RequestParam String endDate) {
return R.data(waybillService.trackVehicle(id, startDate, endDate));
}
}
@@ -34,6 +34,7 @@ import org.springblade.transport.pojo.dto.PaymentApplicationSaveRequest;
import org.springblade.transport.pojo.dto.PaymentApplicationStatusRequest;
import org.springblade.transport.pojo.vo.PaymentApplicationReferenceAmountVO;
import org.springblade.transport.pojo.vo.PaymentApplicationVO;
import org.springblade.transport.pojo.vo.ProjectAdvanceWriteOffOptionVO;
import org.springblade.transport.service.IPaymentApplicationService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@@ -67,6 +68,17 @@ public class PaymentApplicationController extends BladeController {
@RequestParam Long referenceId, @RequestParam(required = false) Long excludeId) {
return R.data(paymentApplicationService.referenceAmount(paymentType, referenceId, excludeId));
}
@GetMapping("/project-advance-write-off-options")
@ApiOperationSupport(order = 3)
@Operation(summary = "按合同查询可选项目预付核销列表")
public R<List<ProjectAdvanceWriteOffOptionVO>> projectAdvanceWriteOffOptions(
@RequestParam Long contractId,
@RequestParam(required = false) String excludeSettlementType,
@RequestParam(required = false) Long excludeSettlementId) {
return R.data(paymentApplicationService.projectAdvanceWriteOffOptions(
contractId, excludeSettlementType, excludeSettlementId));
}
@PostMapping("/save")
@ApiOperationSupport(order = 4)
@Operation(summary = "保存付款申请")
@@ -94,6 +94,13 @@ public class PreSettlementController extends BladeController {
return R.data(preSettlementService.contractOptions(keyword));
}
@GetMapping("/next-no")
@ApiOperationSupport(order = 4)
@Operation(summary = "获取最新预结算单号")
public R<String> nextNo() {
return R.data(preSettlementService.nextNo());
}
@GetMapping("/fee-options")
@ApiOperationSupport(order = 4)
@Operation(summary = "费用类型及费用项")
@@ -7,6 +7,8 @@ import org.springblade.core.mp.support.Condition;
import org.springblade.core.mp.support.Query;
import org.springblade.core.secure.annotation.PreAuth;
import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.listener.KingdeeApprovalSyncListener;
import org.springblade.transport.pojo.dto.SettlementAdjustmentSaveRequest;
import org.springblade.transport.pojo.dto.SettlementAdjustmentStatusRequest;
import org.springblade.transport.pojo.entity.SettlementAdjustment;
@@ -33,7 +35,20 @@ public class SettlementAdjustmentController extends BladeController {
@PostMapping("/save") public R<Long> save(@RequestBody SettlementAdjustmentSaveRequest request) { return R.data(service.saveDraft(request)); }
@PostMapping("/remove") public R remove(@RequestParam Long id) { service.removeDraft(id); return R.success("删除成功"); }
@PostMapping("/submit") public R submit(@RequestBody SettlementAdjustmentStatusRequest request) { service.submit(request); return R.success("提交成功"); }
@PostMapping("/approve") public R approve(@RequestBody SettlementAdjustmentStatusRequest request) { service.approve(request); return R.success("审批通过"); }
@PostMapping("/approve") public R approve(@RequestBody SettlementAdjustmentStatusRequest request) {
String hint = service.approve(request);
// 未推送场景自动推送失败原因以警告文案返回(data=false 供前端区分提示样式)
String syncFailure = KingdeeApprovalSyncListener.pollSyncFailure();
if (Func.isNotBlank(syncFailure)) {
return R.data(Boolean.FALSE, "审批通过,但自动同步金蝶失败:" + syncFailure);
}
if (Func.isNotBlank(hint)) {
return R.data(Boolean.TRUE, "审批通过;" + hint);
}
return R.success("审批通过");
}
@PostMapping("/return") public R returnBill(@RequestBody SettlementAdjustmentStatusRequest request) { service.returnBill(request); return R.success("已驳回"); }
@PostMapping("/repush") public R<String> repush(@RequestParam Long id) { return R.data(service.repush(id)); }
@GetMapping("/kingdee-writeoff-precheck") public R<Map<String, Object>> kingdeeWriteOffPrecheck(@RequestParam Long formalSettlementId) { return R.data(service.kingdeeWriteOffPrecheck(formalSettlementId)); }
@PostMapping("/sync-kingdee") public R<String> syncKingdee(@RequestParam Long id) { return R.data(service.syncKingdee(id)); }
}
@@ -0,0 +1,162 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.mp.support.Condition;
import org.springblade.core.mp.support.Query;
import org.springblade.core.secure.annotation.PreAuth;
import org.springblade.core.tool.api.R;
import org.springblade.transport.pojo.dto.SettlementImportBatchRequest;
import org.springblade.transport.pojo.dto.SettlementImportVoidRequest;
import org.springblade.transport.pojo.vo.SettlementImportBatchVO;
import org.springblade.transport.pojo.vo.WaybillImportValidateResultVO;
import org.springblade.transport.service.ISettlementImportBatchService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.Map;
/**
* 导入结算单控制器
*
* @author Chill
*/
@RestController
@AllArgsConstructor
@PreAuth(menu = "settlement_import")
@RequestMapping("settlement-import")
@Tag(name = "导入结算单", description = "导入结算单管理")
public class SettlementImportController extends BladeController {
private final ISettlementImportBatchService settlementImportBatchService;
@GetMapping("/options")
@ApiOperationSupport(order = 1)
@Operation(summary = "筛选项与表单选项")
public R<Map<String, List<Map<String, Object>>>> options() {
return R.data(settlementImportBatchService.options());
}
@GetMapping("/next-code")
@ApiOperationSupport(order = 2)
@Operation(summary = "下一个批次号")
public R<String> nextCode() {
return R.data(settlementImportBatchService.nextBatchNo());
}
@GetMapping("/list")
@ApiOperationSupport(order = 3)
@Operation(summary = "导入结算单批次分页")
public R<IPage<SettlementImportBatchVO>> list(SettlementImportBatchRequest request, Query query) {
return R.data(settlementImportBatchService.page(Condition.getPage(query), request));
}
@GetMapping("/detail")
@ApiOperationSupport(order = 4)
@Operation(summary = "导入结算单批次详情")
public R<SettlementImportBatchVO> detail(@RequestParam Long id) {
return R.data(settlementImportBatchService.detail(id));
}
@GetMapping("/export-template")
@ApiOperationSupport(order = 5)
@Operation(summary = "导出导入模板")
public void exportTemplate(HttpServletResponse response) {
settlementImportBatchService.exportTemplate(response);
}
@PostMapping("/parse")
@ApiOperationSupport(order = 6)
@Operation(summary = "解析导入 Excel")
public R<Map<String, Object>> parse(@RequestPart("file") MultipartFile file) {
return R.data(settlementImportBatchService.parse(file));
}
@PostMapping("/validate")
@ApiOperationSupport(order = 7)
@Operation(summary = "校验导入数据")
public R<WaybillImportValidateResultVO> validate(@RequestBody SettlementImportBatchRequest request) {
return R.data(settlementImportBatchService.validate(request));
}
@PostMapping("/export-failures")
@ApiOperationSupport(order = 8)
@Operation(summary = "导出失败明细")
public void exportFailures(@RequestBody SettlementImportBatchRequest request, HttpServletResponse response) {
settlementImportBatchService.exportFailures(request, null, response);
}
@PostMapping("/draft")
@ApiOperationSupport(order = 9)
@Operation(summary = "保存草稿")
public R<SettlementImportBatchVO> draft(@RequestBody SettlementImportBatchRequest request) {
return R.data(settlementImportBatchService.saveDraft(request));
}
@PostMapping("/confirm")
@ApiOperationSupport(order = 10)
@Operation(summary = "确认导入结算单")
public R<SettlementImportBatchVO> confirm(@RequestBody SettlementImportBatchRequest request) {
return R.data(settlementImportBatchService.confirm(request));
}
@PostMapping("/void")
@ApiOperationSupport(order = 11)
@Operation(summary = "作废导入结算单批次")
public R voidBatch(@RequestBody SettlementImportVoidRequest request) {
settlementImportBatchService.voidBatch(request);
return R.success("作废成功");
}
@PostMapping("/remove")
@ApiOperationSupport(order = 12)
@Operation(summary = "删除草稿批次")
public R remove(@RequestParam Long id) {
settlementImportBatchService.removeDraft(id);
return R.success("删除成功");
}
@GetMapping("/history-waybills")
@ApiOperationSupport(order = 13)
@Operation(summary = "历史运单可选列表")
public R<List<Map<String, Object>>> historyWaybills(SettlementImportBatchRequest request) {
return R.data(settlementImportBatchService.historyWaybills(request));
}
}
@@ -373,6 +373,13 @@ public class WaybillController extends BladeController {
return R.data(waybillService.batchComplete(ids));
}
@PostMapping("/confirm-return")
@ApiOperationSupport(order = 24)
@Operation(summary = "确认回单", description = "回单节点要求业务员确认时用:该运单的司机打卡节点全部已打卡且无未了结异常,才置为已完成")
public R confirmReturn(@Parameter(description = "主键", required = true) @RequestParam Long id) {
return R.status(waybillService.confirmReturnReceipt(id));
}
@PostMapping("/road-loading")
@ApiOperationSupport(order = 23)
@Operation(summary = "公路配载", description = "传入ids")
@@ -0,0 +1,496 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.excel;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.DateUtil;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.springblade.common.excel.ImportCellReadException;
import org.springblade.core.log.exception.ServiceException;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* 导入结算单 Excel 读取器(Apache POI)。
* <p>
* 定位「*运输费」或「运输费」列,其后直到日期/备注等固定列(兼容旧模板中的「结算金额」)为动态费用列。
* 结算金额不由模板填写,解析时按「运输费 + 各费用项」自动合计。
*
* @author Chill
*/
public final class SettlementImportFeeReader {
private static final DataFormatter DATA_FORMATTER = new DataFormatter(Locale.CHINA);
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final Set<String> DATE_FIELDS = Set.of(
"actualStartDate", "actualEndDate", "planStartDate", "planEndDate"
);
private static final Set<String> OPTIONAL_DECIMAL_FIELDS = Set.of("mileage", "unitPrice");
/** 费用列结束边界:其后为固定业务列;含旧模板「结算金额」以便兼容历史文件 */
private static final Set<String> FEE_END_HEADERS = Set.of(
"结算金额",
"*实际发货时间", "实际发货时间",
"*实际完成时间", "实际完成时间",
"预计发货时间", "预计完成时间",
"备注", "同一运单标识号"
);
private SettlementImportFeeReader() {
}
/**
* 解析结果
*/
public static final class ParseResult {
private final List<Map<String, Object>> rows;
private final List<String> feeItemNames;
private final List<String> warnings;
private final List<String> headers;
public ParseResult(List<Map<String, Object>> rows, List<String> feeItemNames, List<String> warnings,
List<String> headers) {
this.rows = rows;
this.feeItemNames = feeItemNames;
this.warnings = warnings;
this.headers = headers;
}
public List<Map<String, Object>> getRows() {
return rows;
}
public List<String> getFeeItemNames() {
return feeItemNames;
}
public List<String> getWarnings() {
return warnings;
}
public List<String> getHeaders() {
return headers;
}
}
public static ParseResult read(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new ServiceException("上传文件不能为空");
}
try (InputStream inputStream = file.getInputStream(); Workbook workbook = WorkbookFactory.create(inputStream)) {
Sheet sheet = resolveDataSheet(workbook);
Row headerRow = sheet.getRow(sheet.getFirstRowNum());
if (headerRow == null) {
throw new ServiceException("未识别到表头,请使用标准导入结算单模板");
}
Map<Integer, String> headers = readHeaderMap(headerRow);
int transportFeeColumn = findTransportFeeColumn(headers);
if (transportFeeColumn < 0) {
throw new ServiceException("未找到「*运输费」或「运输费」列");
}
List<Integer> feeColumns = new ArrayList<>();
List<String> feeItemNames = new ArrayList<>();
int endColumn = findFeeEndColumn(headers, transportFeeColumn);
for (int column = transportFeeColumn + 1; column < endColumn; column++) {
String name = headers.get(column);
if (name == null || name.isBlank() || FEE_END_HEADERS.contains(name)) {
continue;
}
feeColumns.add(column);
feeItemNames.add(name);
}
List<Map<String, Object>> rows = new ArrayList<>();
int firstDataRow = sheet.getFirstRowNum() + 1;
int lastRow = sheet.getLastRowNum();
for (int rowIndex = firstDataRow; rowIndex <= lastRow; rowIndex++) {
Row row = sheet.getRow(rowIndex);
if (row == null) {
continue;
}
Map<String, Object> mapped = mapRow(headers, feeColumns, row, rowIndex);
if (mapped != null) {
rows.add(mapped);
}
}
return new ParseResult(rows, feeItemNames, List.of(), new ArrayList<>(headers.values()));
} catch (ServiceException exception) {
throw exception;
} catch (Exception exception) {
throw new ServiceException("读取导入结算单 Excel 失败:" + exception.getMessage());
}
}
/** 优先取含「*运输费/运输费」表头的工作表,否则取第一个非空表。 */
private static Sheet resolveDataSheet(Workbook workbook) {
Sheet fallback = null;
for (int index = 0; index < workbook.getNumberOfSheets(); index++) {
Sheet sheet = workbook.getSheetAt(index);
if (sheet == null) {
continue;
}
Row headerRow = sheet.getRow(sheet.getFirstRowNum());
if (headerRow == null) {
continue;
}
Map<Integer, String> headers = readHeaderMap(headerRow);
if (findTransportFeeColumn(headers) >= 0) {
return sheet;
}
if (fallback == null && !headers.isEmpty()) {
fallback = sheet;
}
}
if (fallback != null) {
return fallback;
}
throw new ServiceException("Excel 中未找到有效工作表");
}
private static Map<Integer, String> readHeaderMap(Row headerRow) {
Map<Integer, String> headers = new LinkedHashMap<>();
short lastCell = headerRow.getLastCellNum();
for (int column = 0; column < lastCell; column++) {
String value = normalizeHeader(cellString(headerRow.getCell(column)));
if (value != null && !value.isBlank()) {
headers.put(column, value);
}
}
return headers;
}
/** 去掉 BOM/不可见空白,全角星号转半角,便于匹配「*运输费」。 */
private static String normalizeHeader(String raw) {
if (raw == null) {
return null;
}
String text = raw.replace("\uFEFF", "")
.replace('\u00A0', ' ')
.replace('*', '*')
.trim();
return text.isEmpty() ? null : text;
}
private static Map<String, Object> mapRow(Map<Integer, String> headers, List<Integer> feeColumns,
Row row, int excelRowIndex) {
Map<String, Object> mapped = new LinkedHashMap<>();
Map<String, BigDecimal> feeItems = new LinkedHashMap<>();
for (Map.Entry<Integer, String> entry : headers.entrySet()) {
int column = entry.getKey();
String trimmedHeader = entry.getValue();
Cell poiCell = row.getCell(column);
if (feeColumns.contains(column)) {
feeItems.put(trimmedHeader, decimal(cellString(poiCell), trimmedHeader, excelRowIndex));
continue;
}
String field = FIXED_HEADER_MAP.get(trimmedHeader);
if (field == null) {
mapped.put(trimmedHeader, blankToNull(cellString(poiCell)));
continue;
}
if (DATE_FIELDS.contains(field)) {
mapped.put(field, dateCellString(poiCell));
} else if (OPTIONAL_DECIMAL_FIELDS.contains(field)) {
mapped.put(field, optionalDecimal(cellString(poiCell), trimmedHeader, excelRowIndex));
} else if ("transportFee".equals(field) || "quantity".equals(field)) {
mapped.put(field, decimal(cellString(poiCell), trimmedHeader, excelRowIndex));
} else if ("settlementAmount".equals(field)) {
// 旧模板可能仍含结算金额列,读入后忽略,统一按运输费+费用项自动合计
continue;
} else if ("serialNumber".equals(field)) {
mapped.put(field, intValue(cellString(poiCell)));
} else {
mapped.put(field, blankToNull(cellString(poiCell)));
}
mapped.put(trimmedHeader, mapped.get(field));
}
boolean hasBusinessValue = mapped.values().stream().anyMatch(value -> {
if (value == null) {
return false;
}
if (value instanceof String text) {
return !text.isBlank();
}
return true;
}) || feeItems.values().stream().anyMatch(amount -> amount != null && amount.compareTo(BigDecimal.ZERO) != 0);
if (!hasBusinessValue) {
return null;
}
BigDecimal transportFee = toDecimal(mapped.get("transportFee"));
BigDecimal feeItemTotal = feeItems.values().stream()
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(2, RoundingMode.HALF_UP);
BigDecimal settlementAmount = transportFee.add(feeItemTotal).setScale(2, RoundingMode.HALF_UP);
mapped.put("settlementAmount", settlementAmount);
mapped.put("结算金额", settlementAmount);
mapped.put("feeItems", feeItems);
mapped.put("freight", transportFee);
mapped.put("运费", transportFee);
mapped.put("*运输费", transportFee);
mapped.put("运输费", transportFee);
mapped.put("otherFeeTotal", feeItemTotal);
mapped.put("其他费用合计", feeItemTotal);
mapped.put("freightTotal", settlementAmount);
mapped.put("运费合计", settlementAmount);
if (mapped.get("unitPrice") != null) {
mapped.put("单价", mapped.get("unitPrice"));
mapped.put("运输单价", mapped.get("unitPrice"));
}
return mapped;
}
private static int findTransportFeeColumn(Map<Integer, String> headers) {
int starred = findColumnExact(headers, "*运输费");
if (starred >= 0) {
return starred;
}
return findColumnExact(headers, "运输费");
}
/** 费用列右边界:取运输费之后第一个固定结束表头列下标;无则取表头最大下标 + 1。 */
private static int findFeeEndColumn(Map<Integer, String> headers, int transportFeeColumn) {
int endColumn = headers.keySet().stream().mapToInt(Integer::intValue).max().orElse(transportFeeColumn) + 1;
for (Map.Entry<Integer, String> entry : headers.entrySet()) {
int column = entry.getKey();
if (column <= transportFeeColumn) {
continue;
}
if (FEE_END_HEADERS.contains(entry.getValue()) && column < endColumn) {
endColumn = column;
}
}
return endColumn;
}
private static int findColumnExact(Map<Integer, String> headers, String header) {
return headers.entrySet().stream()
.filter(entry -> header.equals(entry.getValue()))
.mapToInt(Map.Entry::getKey)
.findFirst()
.orElse(-1);
}
private static String cellString(Cell cell) {
if (cell == null) {
return null;
}
String text = DATA_FORMATTER.formatCellValue(cell);
if (text == null) {
return null;
}
text = text.trim();
return text.isEmpty() ? null : text;
}
/**
* 读取日期单元格:Excel 日期序列优先格式化为 yyyy-MM-dd;
* 文本短日期(如 1/1/26、10/10/2026)归一为四位年,供后续 LenientDateParser 识别。
*/
private static String dateCellString(Cell cell) {
if (cell == null) {
return null;
}
CellType cellType = cell.getCellType();
if (cellType == CellType.FORMULA) {
cellType = cell.getCachedFormulaResultType();
}
if (cellType == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) {
Date date = cell.getDateCellValue();
if (date == null) {
return null;
}
LocalDate localDate = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
return DATE_FORMATTER.format(localDate);
}
String text = cellString(cell);
return normalizeExcelShortDate(text);
}
/** 将 Excel 常见短日期文本归一为 yyyy-MM-dd;无法识别时原样返回交由校验层报错。 */
private static String normalizeExcelShortDate(String text) {
if (text == null || text.isBlank()) {
return null;
}
String normalized = text.trim().replace('.', '/').replace('-', '/');
String[] parts = normalized.split("[/\\s]+");
if (parts.length < 3) {
return text.trim();
}
try {
int first = Integer.parseInt(parts[0]);
int second = Integer.parseInt(parts[1]);
int third = Integer.parseInt(parts[2].replaceAll("[^0-9].*$", ""));
int year;
int month;
int day;
if (parts[0].length() == 4) {
year = first;
month = second;
day = third;
} else if (parts[2].length() == 4 || parts[2].length() == 2) {
// M/d/yy 或 M/d/yyyy(Excel 中文本/短日期常见)
month = first;
day = second;
year = parts[2].length() == 2 ? 2000 + third : third;
if (parts[2].length() == 2 && third >= 100) {
return text.trim();
}
} else {
return text.trim();
}
LocalDate localDate = LocalDate.of(year, month, day);
return DATE_FORMATTER.format(localDate);
} catch (Exception exception) {
return text.trim();
}
}
private static BigDecimal decimal(String cell, String columnName, Integer excelRowIndex) {
if (cell == null || cell.isBlank()) {
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
}
String normalized = cell.trim().replace(",", "");
try {
return new BigDecimal(normalized).setScale(2, RoundingMode.HALF_UP);
} catch (NumberFormatException exception) {
throw new ImportCellReadException(excelRowIndex, columnName, cell.trim());
}
}
/** 可选数值:空白返回 null,避免空里程被当成 0 触发「必须为正数」。 */
private static BigDecimal optionalDecimal(String cell, String columnName, Integer excelRowIndex) {
if (cell == null || cell.isBlank()) {
return null;
}
return decimal(cell, columnName, excelRowIndex);
}
private static Integer intValue(String cell) {
if (cell == null || cell.isBlank()) {
return null;
}
try {
return new BigDecimal(cell.trim().replace(",", "")).intValue();
} catch (NumberFormatException exception) {
return null;
}
}
private static String blankToNull(String cell) {
if (cell == null) {
return null;
}
String text = cell.trim();
return text.isEmpty() ? null : text;
}
private static BigDecimal toDecimal(Object value) {
if (value == null) {
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
}
if (value instanceof BigDecimal bigDecimal) {
return bigDecimal;
}
try {
return new BigDecimal(String.valueOf(value).trim()).setScale(2, RoundingMode.HALF_UP);
} catch (NumberFormatException exception) {
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
}
}
private static final Map<String, String> FIXED_HEADER_MAP = buildFixedHeaderMap();
private static Map<String, String> buildFixedHeaderMap() {
Map<String, String> map = new LinkedHashMap<>();
map.put("序号", "serialNumber");
map.put("原始单号", "originalNo");
map.put("配载标识号", "loadingIdentifier");
map.put("*车牌号/船班号/船次号/班次号", "vehicleNo");
map.put("车牌号/船班号/船次号/班次号", "vehicleNo");
map.put("*车牌号/航班号/船号/班列号", "vehicleNo");
map.put("车牌号/航班号/船号/班列号", "vehicleNo");
map.put("*运输方式", "transportType");
map.put("运输方式", "transportType");
map.put("运输类型", "transportType");
map.put("*运输类型", "transportType");
map.put("司机/船长姓名", "driverName");
map.put("司机/船长手机号", "driverPhone");
map.put("*发货地址", "departureAddress");
map.put("发货地址", "departureAddress");
map.put("发货联系人", "departureContact");
map.put("发货联系人电话", "departurePhone");
map.put("*到货地址", "arrivalAddress");
map.put("到货地址", "arrivalAddress");
map.put("收货联系人", "arrivalContact");
map.put("收货联系人电话", "arrivalPhone");
map.put("*货物名称", "cargoName");
map.put("货物名称", "cargoName");
map.put("*货物类型", "cargoType");
map.put("货物类型", "cargoType");
map.put("包装", "packageType");
map.put("*数量", "quantity");
map.put("数量", "quantity");
map.put("*数量单位", "quantityUnit");
map.put("数量单位", "quantityUnit");
map.put("规格", "specification");
map.put("型号", "model");
map.put("里程(km)", "mileage");
map.put("里程", "mileage");
map.put("运输单价", "unitPrice");
map.put("单价", "unitPrice");
map.put("*运输费", "transportFee");
map.put("运输费", "transportFee");
map.put("结算金额", "settlementAmount");
map.put("*实际发货时间", "actualStartDate");
map.put("实际发货时间", "actualStartDate");
map.put("*实际完成时间", "actualEndDate");
map.put("实际完成时间", "actualEndDate");
map.put("预计发货时间", "planStartDate");
map.put("预计完成时间", "planEndDate");
map.put("备注", "remark");
map.put("同一运单标识号", "waybillIdentifier");
return map;
}
}
@@ -0,0 +1,118 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.excel;
import cn.idev.excel.annotation.ExcelIgnore;
import cn.idev.excel.annotation.ExcelProperty;
import cn.idev.excel.annotation.write.style.ColumnWidth;
import cn.idev.excel.annotation.write.style.ContentRowHeight;
import cn.idev.excel.annotation.write.style.HeadRowHeight;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
/**
* 导入结算单 Excel 固定列模型(失败明细兜底导出;费用项由用户在原模板中自行增列)。
*
* @author Chill
*/
@Data
@ColumnWidth(22)
@HeadRowHeight(28)
@ContentRowHeight(18)
public class SettlementImportTemplateExcel implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@ExcelProperty("序号")
private Integer serialNumber;
@ExcelProperty("原始单号")
private String originalNo;
@ExcelProperty("配载标识号")
private String loadingIdentifier;
@ExcelProperty("*车牌号/航班号/船号/班列号")
private String vehicleNo;
@ExcelProperty("*运输方式")
private String transportType;
@ExcelProperty("司机/船长姓名")
private String driverName;
@ExcelProperty("司机/船长手机号")
private String driverPhone;
@ExcelProperty("*发货地址")
private String departureAddress;
@ExcelProperty("发货联系人")
private String departureContact;
@ExcelProperty("发货联系人电话")
private String departurePhone;
@ExcelProperty("*到货地址")
private String arrivalAddress;
@ExcelProperty("收货联系人")
private String arrivalContact;
@ExcelProperty("收货联系人电话")
private String arrivalPhone;
@ExcelProperty("*货物名称")
private String cargoName;
@ExcelProperty("*货物类型")
private String cargoType;
@ExcelProperty("包装")
private String packageType;
@ExcelProperty("*数量")
private BigDecimal quantity;
@ExcelProperty("*数量单位")
private String quantityUnit;
@ExcelProperty("规格")
private String specification;
@ExcelProperty("型号")
private String model;
@ExcelProperty("里程(km)")
private BigDecimal mileage;
@ExcelProperty("运输单价")
private BigDecimal unitPrice;
@ExcelProperty("*运输费")
private BigDecimal transportFee;
/** 模板已去掉结算金额列;仅失败明细内部保留,不导出为表头 */
@ExcelIgnore
private BigDecimal settlementAmount;
@ExcelProperty("*实际发货时间")
private String actualStartDate;
@ExcelProperty("*实际完成时间")
private String actualEndDate;
@ExcelProperty("预计发货时间")
private String planStartDate;
@ExcelProperty("预计完成时间")
private String planEndDate;
@ExcelProperty("备注")
private String remark;
@ExcelProperty("同一运单标识号")
private String waybillIdentifier;
/** 导入失败原因(不导出到模板,仅用于失败明细) */
@ExcelIgnore
private String errorMessage;
}
@@ -0,0 +1,202 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.excel;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.DataValidation;
import org.apache.poi.ss.usermodel.DataValidationConstraint;
import org.apache.poi.ss.usermodel.DataValidationHelper;
import org.apache.poi.ss.usermodel.FillPatternType;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.IndexedColors;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.VerticalAlignment;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.util.CellRangeAddressList;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springblade.core.log.exception.ServiceException;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
/**
* 导入结算单模板导出:填写说明 + 固定数据表头 + 运输方式下拉。
* <p>
* 模板不预置费用项列;用户可在「*运输费」与后续固定列之间自行插入系统已启用的费用项列。
*
* @author Chill
*/
public final class SettlementImportTemplateExporter {
private static final List<String> FIXED_HEADERS_BEFORE_FEE = List.of(
"序号",
"原始单号",
"配载标识号",
"*车牌号/航班号/船号/班列号",
"*运输方式",
"司机/船长姓名",
"司机/船长手机号",
"*发货地址",
"发货联系人",
"发货联系人电话",
"*到货地址",
"收货联系人",
"收货联系人电话",
"*货物名称",
"*货物类型",
"包装",
"*数量",
"*数量单位",
"规格",
"型号",
"里程(km)",
"运输单价",
"*运输费"
);
private static final List<String> FIXED_HEADERS_AFTER_FEE = List.of(
"*实际发货时间",
"*实际完成时间",
"预计发货时间",
"预计完成时间",
"备注",
"同一运单标识号"
);
private SettlementImportTemplateExporter() {
}
public static void export(HttpServletResponse response, List<String> transportTypeOptions,
List<String> feeItemNames) {
List<String> headers = buildHeaders();
// 运输方式由调用方传入业务字典 transport_type 的字典名称,禁止写死
List<String> transportOptions = transportTypeOptions == null ? List.of() : transportTypeOptions.stream()
.filter(item -> item != null && !item.isBlank())
.map(String::trim)
.distinct()
.toList();
try (Workbook workbook = new XSSFWorkbook()) {
writeInstructionSheet(workbook, transportOptions, feeItemNames);
Sheet dataSheet = workbook.createSheet("导入结算单模板");
CellStyle headerStyle = createHeaderStyle(workbook);
Row headerRow = dataSheet.createRow(0);
for (int index = 0; index < headers.size(); index++) {
Cell cell = headerRow.createCell(index);
cell.setCellValue(headers.get(index));
cell.setCellStyle(headerStyle);
dataSheet.setColumnWidth(index, Math.min(28, Math.max(12, headers.get(index).length() + 4)) * 256);
}
int transportTypeColumn = headers.indexOf("*运输方式");
if (transportTypeColumn >= 0 && !transportOptions.isEmpty()) {
addDropdown(dataSheet, transportTypeColumn, transportOptions);
}
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setHeader("Content-disposition",
"attachment;filename=" + URLEncoder.encode("导入结算单模板", StandardCharsets.UTF_8) + ".xlsx");
workbook.write(response.getOutputStream());
} catch (IOException exception) {
throw new ServiceException("导出导入结算单模板失败:" + exception.getMessage());
}
}
/** 固定列模板:不预置费用项列,由用户自行插入。 */
private static List<String> buildHeaders() {
List<String> headers = new ArrayList<>(FIXED_HEADERS_BEFORE_FEE);
headers.addAll(FIXED_HEADERS_AFTER_FEE);
return headers;
}
private static void writeInstructionSheet(Workbook workbook, List<String> transportOptions,
List<String> feeItemNames) {
Sheet sheet = workbook.createSheet("填写说明");
CellStyle titleStyle = workbook.createCellStyle();
Font titleFont = workbook.createFont();
titleFont.setBold(true);
titleStyle.setFont(titleFont);
List<String> lines = new ArrayList<>();
lines.add("导入结算单填写说明");
lines.add("1. 请不要修改「导入结算单模板」工作表的固定表头;带 * 号为必填项。");
lines.add("2. 运输方式请从下拉项中选择,须与业务字典 transport_type 的字典名称一致。");
lines.add("3. 日期请填写 yyyy-MM-dd(如 2026-01-01);Excel 日期单元格也可直接选择。");
lines.add("4. 里程(km) 可为空;填写时须为正数。");
lines.add("5. 「*运输费」之后、「*实际发货时间」之前可按需自行插入费用项列,列名须与系统已启用费用项名称完全一致;金额不为 0 时若不存在或已停用则无法导入。");
lines.add("6. 模板不含结算金额列;导入后系统按「运输费 + 各费用项」自动合计结算金额。");
if (transportOptions != null && !transportOptions.isEmpty()) {
lines.add("7. 当前可选运输方式(业务字典 transport_type 名称):"
+ String.join("、", transportOptions) + "。");
} else {
lines.add("7. 运输方式请从业务字典 transport_type 维护的名称中选择。");
}
if (feeItemNames != null && !feeItemNames.isEmpty()) {
lines.add("8. 费用项请按实际需要在模板中自行增列,列名须与系统已启用费用项一致;当前系统已启用费用项:"
+ String.join("、", feeItemNames.stream().limit(30).toList())
+ (feeItemNames.size() > 30 ? " 等" : "") + "。");
} else {
lines.add("8. 费用项请按实际需要在模板中自行增列,列名须与系统已启用的费用项主数据名称完全一致。");
}
for (int index = 0; index < lines.size(); index++) {
Row row = sheet.createRow(index);
Cell cell = row.createCell(0);
cell.setCellValue(lines.get(index));
if (index == 0) {
cell.setCellStyle(titleStyle);
}
}
sheet.setColumnWidth(0, 120 * 256);
}
private static void addDropdown(Sheet sheet, int columnIndex, List<String> options) {
DataValidationHelper helper = sheet.getDataValidationHelper();
String[] values = options.toArray(new String[0]);
DataValidationConstraint constraint = helper.createExplicitListConstraint(values);
CellRangeAddressList addressList = new CellRangeAddressList(1, 2000, columnIndex, columnIndex);
DataValidation validation = helper.createValidation(constraint, addressList);
validation.setEmptyCellAllowed(true);
validation.setShowErrorBox(true);
sheet.addValidationData(validation);
}
private static CellStyle createHeaderStyle(Workbook workbook) {
CellStyle style = workbook.createCellStyle();
style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
style.setAlignment(HorizontalAlignment.CENTER);
style.setVerticalAlignment(VerticalAlignment.CENTER);
Font font = workbook.createFont();
font.setBold(true);
style.setFont(font);
return style;
}
}
@@ -27,9 +27,20 @@ package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springblade.transport.pojo.entity.BillPayment;
/** 汇票付款 Mapper。 @author Chill */
@Mapper
public interface BillPaymentMapper extends BaseMapper<BillPayment> {
/**
* 查询指定租户、指定日期前缀下已使用的最大汇票付款流水号。
* 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(payment_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_bill_payment WHERE tenant_id = #{tenantId} "
+ "AND payment_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
}
@@ -101,8 +101,7 @@
approved_time,
kingdee_status,
kingdee_fail_reason,
kingdee_sync_time
approved_time,
kingdee_sync_time,
(
SELECT s.review_status
FROM blade_customer_credit_score s
@@ -29,4 +29,13 @@ public interface FormalSettlementMapper extends BaseMapper<FormalSettlement> {
/** 恢复逻辑删除的正式结算单主记录。 */
@Update("UPDATE blade_formal_settlement SET is_deleted = 0 WHERE tenant_id = #{tenantId} AND id = #{id} AND is_deleted = 1")
int restoreByIdIncludingDeleted(@Param("tenantId") String tenantId, @Param("id") Long id);
/**
* 查询指定租户、指定日期前缀下已使用的最大正式结算单流水号。
* 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(formal_settlement_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_formal_settlement WHERE tenant_id = #{tenantId} "
+ "AND formal_settlement_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
}
@@ -10,9 +10,20 @@ package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springblade.transport.pojo.entity.FormalSettlementPayment;
/** 正式结算付款申请 Mapper。 @author Chill */
@Mapper
public interface FormalSettlementPaymentMapper extends BaseMapper<FormalSettlementPayment> {
/**
* 查询指定租户、指定日期前缀下已使用的最大付款申请流水号。
* 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(payment_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_formal_settlement_payment WHERE tenant_id = #{tenantId} "
+ "AND payment_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
}
@@ -27,6 +27,8 @@ package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springblade.transport.pojo.entity.InvoiceApplication;
/**
@@ -36,4 +38,13 @@ import org.springblade.transport.pojo.entity.InvoiceApplication;
*/
@Mapper
public interface InvoiceApplicationMapper extends BaseMapper<InvoiceApplication> {
/**
* 查询指定租户、指定日期前缀下已使用的最大开票申请流水号。
* 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(application_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_invoice_application WHERE tenant_id = #{tenantId} "
+ "AND application_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
}
@@ -44,11 +44,13 @@ public interface KingdeeInvoicePoolMapper extends BaseMapper<KingdeeInvoicePool>
/**
* 金蝶票据池镜像统一分页查询:仅出可用票(剩余可用金额>0 且未使用),认领/登记标记由 EXISTS 子查询直出
*
* @param page 分页参数
* @param query 统一查询条件(已在上层完成 trim 与日期格式校验)
* @param tenantId 当前租户
* @param page 分页参数
* @param query 统一查询条件(已在上层完成 trim 与日期格式校验)
* @param tenantId 当前租户
* @param customerUnitsOnly 客商单位过滤开关:开启时只出销/购方税号均登记在客商发票信息中的发票(与定时同步同开关)
* @return 统一视图分页
*/
IPage<KingdeeInvoicePoolVO> selectInvoicePoolPage(IPage<KingdeeInvoicePoolVO> page,
@Param("query") KingdeeInvoicePoolQueryDTO query, @Param("tenantId") String tenantId);
@Param("query") KingdeeInvoicePoolQueryDTO query, @Param("tenantId") String tenantId,
@Param("customerUnitsOnly") boolean customerUnitsOnly);
}
@@ -39,12 +39,24 @@
AND p.available_amount &gt; 0 AND p.amount_use_status = '0'
</sql>
<!-- 客商单位过滤:开票单位(销方)与受票单位(购方)税号均须登记在有效客商发票信息中
(与定时同步拉取过滤同开关同口径;自定义 SQL 无租户插件,手工带租户与逻辑删除) -->
<sql id="customerUnitsFilter">
AND EXISTS (SELECT 1 FROM blade_customer_invoice_info ci
WHERE ci.is_deleted = 0 AND ci.status = 1 AND ci.tenant_id = p.tenant_id AND ci.tax_no = p.saler_tax_no)
AND EXISTS (SELECT 1 FROM blade_customer_invoice_info cj
WHERE cj.is_deleted = 0 AND cj.status = 1 AND cj.tenant_id = p.tenant_id AND cj.tax_no = p.buyer_tax_no)
</sql>
<select id="selectInvoicePoolPage" resultType="org.springblade.transport.pojo.vo.KingdeeInvoicePoolVO">
SELECT
<include refid="invoicePoolVoColumns"/>
FROM blade_kingdee_invoice_pool p
WHERE p.is_deleted = 0 AND p.status = 1 AND p.tenant_id = #{tenantId}
<include refid="usableFilter"/>
<if test="customerUnitsOnly">
<include refid="customerUnitsFilter"/>
</if>
<if test="query.invoiceNo != null and query.invoiceNo != ''">
AND p.invoice_no LIKE CONCAT('%', #{query.invoiceNo}, '%')
</if>
@@ -21,9 +21,20 @@ package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springblade.transport.pojo.entity.PaymentApplication;
/** 付款申请 Mapper。 @author Chill */
@Mapper
public interface PaymentApplicationMapper extends BaseMapper<PaymentApplication> {
/**
* 查询指定租户、指定日期前缀下已使用的最大付款申请流水号。
* 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(payment_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_payment_application WHERE tenant_id = #{tenantId} "
+ "AND payment_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
}
@@ -10,6 +10,8 @@ package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springblade.transport.pojo.entity.PreSettlementAdvance;
/**
@@ -19,4 +21,12 @@ import org.springblade.transport.pojo.entity.PreSettlementAdvance;
*/
@Mapper
public interface PreSettlementAdvanceMapper extends BaseMapper<PreSettlementAdvance> {
/**
* 查询预付单号最大流水(含逻辑删除)。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(advance_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_pre_settlement_advance WHERE tenant_id = #{tenantId} "
+ "AND advance_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
}
@@ -10,6 +10,8 @@ package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springblade.transport.pojo.entity.PreSettlement;
/**
@@ -19,4 +21,20 @@ import org.springblade.transport.pojo.entity.PreSettlement;
*/
@Mapper
public interface PreSettlementMapper extends BaseMapper<PreSettlement> {
/**
* 查询预结算单号最大流水(含逻辑删除)。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(pre_settlement_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_pre_settlement WHERE tenant_id = #{tenantId} "
+ "AND pre_settlement_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxPreSettlementDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
/**
* 查询预结算转正式结算单号最大流水(含逻辑删除)。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(formal_settlement_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_pre_settlement WHERE tenant_id = #{tenantId} "
+ "AND formal_settlement_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxFormalSettlementDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
}

Some files were not shown because too many files have changed in this diff Show More