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
@@ -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);
}
@@ -102,7 +102,8 @@
FROM blade_receipt_claim c
INNER JOIN blade_kingdee_receipt_flow f ON f.id = c.receipt_flow_id
WHERE <include refid="claimRecordConditions"/>
ORDER BY c.create_time DESC
<!-- 认领时间倒序:存量认领记录的 create_time 为空,排序改以认领日期为主、id 兜同日先后 -->
ORDER BY c.claim_date DESC, c.id DESC
</select>
<select id="selectClaimRecordDetail" resultType="org.springblade.transport.pojo.vo.ReceiptClaimRecordVO">
@@ -2,7 +2,19 @@ package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springblade.transport.pojo.entity.SettlementAdjustment;
@Mapper
public interface SettlementAdjustmentMapper extends BaseMapper<SettlementAdjustment> {}
public interface SettlementAdjustmentMapper extends BaseMapper<SettlementAdjustment> {
/**
* 查询指定租户、指定日期前缀下已使用的最大结算调整单流水号。
* 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(adjustment_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_settlement_adjustment WHERE tenant_id = #{tenantId} "
+ "AND adjustment_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
}
@@ -0,0 +1,50 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springblade.transport.pojo.entity.SettlementImportBatch;
/**
* 导入结算单批次 Mapper
*
* @author Chill
*/
@Mapper
public interface SettlementImportBatchMapper extends BaseMapper<SettlementImportBatch> {
/**
* 查询指定租户、指定日期前缀下已使用的最大批次流水号。
* 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(batch_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_settlement_import_batch WHERE tenant_id = #{tenantId} "
+ "AND batch_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
}
@@ -0,0 +1,51 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.springblade.transport.pojo.entity.SettlementProjectAdvanceWriteOff;
import java.math.BigDecimal;
import java.util.Collection;
import java.util.List;
import java.util.Map;
/**
* 结算单项目预付核销 Mapper
*
* @author Chill
*/
@Mapper
public interface SettlementProjectAdvanceWriteOffMapper extends BaseMapper<SettlementProjectAdvanceWriteOff> {
/**
* 统计项目预付已核销金额(排除作废结算单,可排除当前结算单)。
*/
BigDecimal sumWriteOffByPaymentApplication(@Param("paymentApplicationId") Long paymentApplicationId,
@Param("excludeSettlementType") String excludeSettlementType,
@Param("excludeSettlementId") Long excludeSettlementId);
/**
* 统计结算单核销合计。
*/
BigDecimal sumWriteOffBySettlement(@Param("settlementType") String settlementType,
@Param("settlementId") Long settlementId);
/**
* 按结算单集合分组统计核销合计(一次 group-by,供批量刷新与列表页使用,避免逐单 N+1)。
*
* @return settlementId / totalAmount 两列
*/
List<Map<String, Object>> sumWriteOffBySettlementIds(@Param("settlementType") String settlementType,
@Param("settlementIds") Collection<Long> settlementIds);
}
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.springblade.transport.mapper.SettlementProjectAdvanceWriteOffMapper">
<select id="sumWriteOffByPaymentApplication" resultType="java.math.BigDecimal">
SELECT COALESCE(SUM(w.write_off_amount), 0)
FROM blade_settlement_project_advance_write_off w
LEFT JOIN blade_pre_settlement p
ON w.settlement_type = 'pre'
AND w.settlement_id = p.id
AND p.is_deleted = 0
LEFT JOIN blade_formal_settlement f
ON w.settlement_type = 'formal'
AND w.settlement_id = f.id
AND f.is_deleted = 0
WHERE w.is_deleted = 0
AND w.payment_application_id = #{paymentApplicationId}
AND (
(w.settlement_type = 'pre' AND p.id IS NOT NULL AND IFNULL(p.approval_status, '') &lt;&gt; 'voided')
OR (w.settlement_type = 'formal' AND f.id IS NOT NULL AND IFNULL(f.approval_status, '') &lt;&gt; 'voided')
)
<if test="excludeSettlementType != null and excludeSettlementId != null">
AND NOT (w.settlement_type = #{excludeSettlementType} AND w.settlement_id = #{excludeSettlementId})
</if>
</select>
<select id="sumWriteOffBySettlement" resultType="java.math.BigDecimal">
SELECT COALESCE(SUM(write_off_amount), 0)
FROM blade_settlement_project_advance_write_off
WHERE is_deleted = 0
AND settlement_type = #{settlementType}
AND settlement_id = #{settlementId}
</select>
<select id="sumWriteOffBySettlementIds" resultType="java.util.HashMap">
SELECT settlement_id AS settlementId, COALESCE(SUM(write_off_amount), 0) AS totalAmount
FROM blade_settlement_project_advance_write_off
WHERE is_deleted = 0
AND settlement_type = #{settlementType}
AND settlement_id IN
<foreach collection="settlementIds" item="settlementId" open="(" separator="," close=")">
#{settlementId}
</foreach>
GROUP BY settlement_id
</select>
</mapper>
@@ -22,4 +22,13 @@ public interface TransportReconciliationMapper extends BaseMapper<TransportRecon
/** 恢复逻辑删除的运输对账单主记录。 */
@Update("UPDATE blade_transport_reconciliation SET is_deleted = 0 WHERE tenant_id = #{tenantId} AND id = #{id} AND is_deleted = 1")
int restoreByIdIncludingDeleted(@Param("tenantId") String tenantId, @Param("id") Long id);
/**
* 查询指定租户、指定日期前缀下已使用的最大对账单流水号。
* 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(reconciliation_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_transport_reconciliation WHERE tenant_id = #{tenantId} "
+ "AND reconciliation_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
}
@@ -1,6 +1,7 @@
package org.springblade.transport.mk;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.tenant.annotation.TenantIgnore;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.pojo.dto.BillPaymentStatusRequest;
@@ -287,6 +288,7 @@ public final class MkProcessBizHandlers {
}
}
@Slf4j
@Component
@TenantIgnore
@RequiredArgsConstructor
@@ -307,6 +309,12 @@ public final class MkProcessBizHandlers {
public boolean syncFromProcessInfo(Long id, Object processInfo) {
FormalSettlement entity = service.getById(id);
if (entity == null) {
log.warn("MK同步当前节点失败,正式结算单不存在 id={}", id);
return false;
}
// 已终态单据禁止再写回审批中,避免流程结束后的节点刷新把状态打回 reviewing
if ("approved".equals(entity.getApprovalStatus()) || "returned".equals(entity.getApprovalStatus())
|| "voided".equals(entity.getApprovalStatus())) {
return false;
}
String node = MkProcessNodeHelper.currentNode(processInfo);
@@ -327,8 +335,12 @@ public final class MkProcessBizHandlers {
@Override
public boolean approveFromProcess(Long id, String processorName) {
FormalSettlement entity = service.getById(id);
if (entity == null || "approved".equals(entity.getApprovalStatus())) {
return entity != null;
if (entity == null) {
log.error("MK审批通过同步失败,正式结算单不存在 id={}", id);
return false;
}
if ("approved".equals(entity.getApprovalStatus())) {
return true;
}
// 统一走 service.approve:与手动审批同一入口(补变更记录),审批事务提交后自动触发金蝶推送事件;
// 仅审批中单据允许通过,重复回调被状态校验拒绝,不重复推送
@@ -347,8 +359,12 @@ public final class MkProcessBizHandlers {
@Override
public boolean rejectFromProcess(Long id, String processorName) {
FormalSettlement entity = service.getById(id);
if (entity == null || "returned".equals(entity.getApprovalStatus())) {
return entity != null;
if (entity == null) {
log.error("MK审批驳回同步失败,正式结算单不存在 id={}", id);
return false;
}
if ("returned".equals(entity.getApprovalStatus())) {
return true;
}
entity.setApprovalStatus("returned");
entity.setCurrentNode("审核不通过");
@@ -422,10 +438,19 @@ public final class MkProcessBizHandlers {
if (entity == null || "returned".equals(entity.getApprovalStatus())) {
return entity != null;
}
entity.setApprovalStatus("returned");
entity.setCurrentNode("审核不通过");
entity.setCurrentProcessor(MkProcessNodeHelper.text(processorName, "系统"));
return service.updateById(entity);
if ("reviewing".equals(entity.getApprovalStatus())) {
PaymentApplicationStatusRequest request = new PaymentApplicationStatusRequest();
request.setId(id);
service.returnBill(request);
}
PaymentApplication updated = service.getById(id);
if (updated == null) {
return false;
}
updated.setApprovalStatus("returned");
updated.setCurrentNode("审核不通过");
updated.setCurrentProcessor(MkProcessNodeHelper.text(processorName, "系统"));
return service.updateById(updated);
}
}
@@ -4,10 +4,12 @@ import com.alibaba.fastjson2.JSON;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.tenant.TenantUtil;
import org.springblade.core.tenant.annotation.TenantIgnore;
import org.springblade.core.tool.api.FR;
import org.springblade.core.tool.utils.StringUtil;
import org.springblade.process.feign.IBusinessProcessClient;
import org.springblade.process.pojo.vo.MkFormBindVO;
import org.springframework.stereotype.Service;
import java.util.Collections;
@@ -69,9 +71,9 @@ public class MkProcessMessageService {
log.warn("公开页流程消息未找到 processId,跳过查询当前节点 bizType={}", bizType);
return;
}
String bizIdText = firstText(asMap(body == null ? null : body.get("formData")), "id", "formInstanceId");
Long bizId = resolveBizId(asMap(body == null ? null : body.get("formData")));
queryCurrentNodes(bizType, processId, loginName);
scheduleProcessInfo(handler, bizType, processId, loginName, bizIdText, formValues);
scheduleProcessInfo(handler, bizType, processId, loginName, bizId, formValues);
}
private void queryCurrentNodes(String bizType, String processId, String loginName) {
@@ -86,36 +88,35 @@ public class MkProcessMessageService {
}
private void scheduleProcessInfo(IMkProcessBizHandler handler, String bizType, String processId,
String loginName, String bizIdText, Map<String, Object> formValues) {
String loginName, Long bizId, Map<String, Object> formValues) {
Map<String, Object> formValuesCopy = new HashMap<>(formValues == null ? Map.of() : formValues);
log.info("公开页将在{}秒后查询流程实例详情 bizType={} processId={}", PROCESS_INFO_DELAY_SECONDS, bizType, processId);
log.info("公开页将在{}秒后查询流程实例详情 bizType={} processId={} bizId={}",
PROCESS_INFO_DELAY_SECONDS, bizType, processId, bizId);
// 延迟任务跑在独立线程,请求态 @TenantIgnore 已丢失,必须显式 ignore,否则 getById 可能因租户条件查空
scheduler.schedule(
() -> queryProcessInfo(handler, bizType, processId, loginName, bizIdText, formValuesCopy),
() -> TenantUtil.ignore(() -> queryProcessInfo(handler, bizType, processId, loginName, bizId, formValuesCopy)),
PROCESS_INFO_DELAY_SECONDS,
TimeUnit.SECONDS
);
}
private void queryProcessInfo(IMkProcessBizHandler handler, String bizType, String processId,
String loginName, String bizIdText, Map<String, Object> formValues) {
String loginName, Long bizId, Map<String, Object> formValues) {
try {
FR<Object> processInfoResult = businessProcessClient.getProcessInfo(processId, loginName);
Object processInfo = processInfoResult == null ? null : processInfoResult.getData();
log.info("公开页流程消息流程实例详情 bizType={} processId={} loginName={} result={}",
bizType, processId, loginName, JSON.toJSONString(processInfo));
log.info("公开页流程消息流程实例详情 bizType={} processId={} loginName={} bizId={} result={}",
bizType, processId, loginName, bizId, JSON.toJSONString(processInfo));
if (isProcessFinished(processInfo)) {
applyProcessResult(handler, bizType, bizIdText, processId, formValues, true);
applyProcessResult(handler, bizType, bizId, processId, formValues, true);
} else if (MkProcessNodeHelper.isProcessRejected(processInfo)) {
applyProcessResult(handler, bizType, bizIdText, processId, formValues, false);
} else if (StringUtil.isNotBlank(bizIdText)) {
try {
handler.syncFromProcessInfo(Long.valueOf(bizIdText), processInfo);
} catch (NumberFormatException e) {
log.warn("公开页流程消息业务id格式不正确 bizType={} id={}", bizType, bizIdText);
}
applyProcessResult(handler, bizType, bizId, processId, formValues, false);
} else if (bizId != null) {
handler.syncFromProcessInfo(bizId, processInfo);
}
} catch (Exception e) {
log.error("公开页查询流程实例详情失败 bizType={} processId={} loginName={}", bizType, processId, loginName, e);
log.error("公开页查询流程实例详情失败 bizType={} processId={} loginName={} bizId={}",
bizType, processId, loginName, bizId, e);
}
}
@@ -123,21 +124,24 @@ public class MkProcessMessageService {
return "30".equals(firstText(asMap(processInfo), "fdProcessStatus"));
}
private void applyProcessResult(IMkProcessBizHandler handler, String bizType, String bizIdText,
private void applyProcessResult(IMkProcessBizHandler handler, String bizType, Long bizId,
String processId, Map<String, Object> formValues, boolean approved) {
String action = approved ? "审核通过" : "审核驳回";
if (StringUtil.isBlank(bizIdText)) {
log.warn("流程{}但未找到业务id,跳过同步 bizType={} processId={}", action, bizType, processId);
if (bizId == null) {
log.warn("流程{}但未解析到业务主键,跳过同步 bizType={} processId={}", action, bizType, processId);
return;
}
try {
String processorName = firstText(formValues, "mkUserName", "mkLoginName");
Long bizId = Long.valueOf(bizIdText);
if (approved) {
handler.approveFromProcess(bizId, processorName);
} else {
handler.rejectFromProcess(bizId, processorName);
boolean synced = approved
? handler.approveFromProcess(bizId, processorName)
: handler.rejectFromProcess(bizId, processorName);
if (!synced) {
log.error("流程{}同步业务状态失败(业务单据不存在或未更新) bizType={} bizId={} processId={}",
action, bizType, bizId, processId);
return;
}
log.info("流程{}同步业务状态成功 bizType={} bizId={} processId={}", action, bizType, bizId, processId);
try {
FR<Boolean> markResult = businessProcessClient.markProcessFinished(processId, approved);
log.info("公开页标记业务流程完结 bizType={} processId={} approved={} result={}",
@@ -146,13 +150,56 @@ public class MkProcessMessageService {
log.error("公开页标记业务流程完结失败 bizType={} processId={} approved={}",
bizType, processId, approved, markException);
}
} catch (NumberFormatException e) {
log.warn("流程{}但业务id格式不正确 bizType={} id={}", action, bizType, bizIdText);
} catch (Exception e) {
log.error("流程{}同步业务状态失败 bizType={} id={} processId={}", action, bizType, bizIdText, processId, e);
log.error("流程{}同步业务状态失败 bizType={} bizId={} processId={}", action, bizType, bizId, processId, e);
}
}
/**
* 公开页 formData 里的 id / formInstanceId 可能是表单实例 id(与业务主键不同),
* 须先按 MkFormBind 反查业务主键,与 detail 接口保持一致。
*/
private Long resolveBizId(Map<String, Object> formData) {
String formInstanceIdText = firstText(formData, "formInstanceId");
String idText = firstText(formData, "id");
Long resolved = resolveByFormInstanceId(formInstanceIdText);
if (resolved != null) {
return resolved;
}
resolved = resolveByFormInstanceId(idText);
if (resolved != null) {
return resolved;
}
if (StringUtil.isNotBlank(idText)) {
try {
return Long.valueOf(idText);
} catch (NumberFormatException e) {
log.warn("公开页流程消息业务id格式不正确 id={}", idText);
}
}
return null;
}
private Long resolveByFormInstanceId(String formInstanceIdText) {
if (StringUtil.isBlank(formInstanceIdText)) {
return null;
}
try {
Long formInstanceId = Long.valueOf(formInstanceIdText);
FR<MkFormBindVO> result = businessProcessClient.resolveByFormInstanceId(formInstanceId);
if (result != null && result.isSuccess() && result.getData() != null && result.getData().getBizId() != null) {
Long bizId = result.getData().getBizId();
log.info("公开页按表单实例id解析业务主键 formInstanceId={} bizId={}", formInstanceId, bizId);
return bizId;
}
} catch (NumberFormatException e) {
log.warn("公开页表单实例id格式不正确 formInstanceId={}", formInstanceIdText);
} catch (Exception e) {
log.warn("公开页按表单实例id解析业务主键失败 formInstanceId={}", formInstanceIdText, e);
}
return null;
}
private static Map<String, Object> asMap(Object value) {
if (!(value instanceof Map<?, ?> map)) {
return Collections.emptyMap();
@@ -18,8 +18,9 @@ public interface IBillLedgerService extends BaseService<BillLedger> {
IPage<BillLedgerVO> selectPage(IPage<BillLedger> page, BillLedgerVO query);
BillLedgerVO detail(Long id);
Map<String, Long> expiryCounts();
List<BillLedgerVO> availableOptions(String keyword, Long deptId, Long selectedId);
IPage<BillLedgerVO> availablePage(IPage<BillLedger> page, String keyword, Long deptId, Long selectedId);
List<BillLedgerVO> availableOptions(String keyword, Long deptId, Long selectedId, String payerName, String payeeName);
IPage<BillLedgerVO> availablePage(IPage<BillLedger> page, String keyword, Long deptId, Long selectedId,
String payerName, String payeeName);
Long submit(BillLedgerSaveRequest request);
void removeLedger(Long id);
}
@@ -71,6 +71,53 @@ public interface IDriverWaybillService {
*/
DriverWaybillCardVO detail(Long id);
/**
* 当前登录司机的运输中运次(一趟车,最多一条)
*/
DriverWaybillCardVO tripCurrentTask();
/**
* 当前登录司机的待接运次预览
*
* @param size 预览条数,默认 2
*/
DriverWaybillPreviewVO tripPendingPreview(Integer size);
/**
* 运次 Tab 统计:全部 / 待接单 / 进行中 / 已完成(全部 = 前三者之和,不含已取消)
*/
DriverWaybillTabCountsVO tripTabCounts();
/**
* 司机运次分页:一页即若干个运次(有配载单的一趟车算一个)
*
* @param current 当前页,从 1 开始
* @param size 每页条数(按运次,默认 10)
* @param status 小程序状态:空=全部,0待接单,1运输中,2已完成
* @param keyword 关键字(运单号 / 配载单号 / 起终点 / 车牌,可选)
*/
IPage<DriverWaybillCardVO> tripPage(Integer current, Integer size, Integer status, String keyword);
/**
* 司机运次详情:按运次主键(字符串)取整趟车,有配载单时下发站点 ①②③
*/
DriverWaybillCardVO tripDetail(String tripId);
/**
* 运次确认接单:一次把该运次全部子运单置为已接单,司机字段不动
*
* @param tripId 运次主键(配载单主键或运单主键)
*/
boolean tripAccept(String tripId);
/**
* 运次拒绝接单:整单拒绝,该运次此后对本人不可见,等调度重新派单
*
* @param tripId 运次主键(配载单主键或运单主键)
* @param reason 拒绝原因,可空,最长 200 字
*/
boolean tripReject(String tripId, String reason);
/**
* 司机端运单行驶轨迹:按打卡记录 / 计划时间自动推导起止日期后查询 LBS 轨迹。
* <p>
@@ -130,9 +177,9 @@ public interface IDriverWaybillService {
boolean isNodePunched(Long waybillId, String nodeCode);
/**
* 断言当前登录司机对该运单有归属:运单车牌须落在司机档案自述绑定的车牌内。
* 断言当前登录司机对该运单有归属:运单司机手机号须等于当前用户手机号。
* <p>
* 无司机档案、未绑定车辆、车牌不匹配都会抛异常。管理端与调度员账号没有司机档案,
* 无司机档案、无手机号、手机号不匹配都会抛异常。管理端与调度员账号没有司机档案,
* 会停在「未找到当前登录司机档案」——这是刻意的,不提供代报。
*/
void assertCurrentDriverOwnsWaybill(Long waybillId);
@@ -24,6 +24,8 @@ import org.springblade.transport.pojo.dto.FormalSettlementPaymentRequest;
import org.springblade.transport.pojo.dto.FormalSettlementBatchPaymentRequest;
import org.springblade.transport.pojo.dto.FormalSettlementInvoiceClaimRequest;
import java.util.Map;
/**
* 正式结算单服务
*
@@ -93,4 +95,29 @@ public interface IFormalSettlementService extends BaseService<FormalSettlement>
String applyPayment(FormalSettlementPaymentRequest request);
List<String> applyPayments(FormalSettlementBatchPaymentRequest request);
void claimInvoices(FormalSettlementInvoiceClaimRequest request);
/**
* 结算调整重推:克隆原正式结算单为新单(含明细、明细费项、汇总费项、来源预结算、附件、汇率),
* 新单置审批通过、未同步金蝶,来源原单号指向原单;下游关联(开票申请/收款认领/对账)不迁移。
* 不推送金蝶,由调用方在事务外调用 syncKingdee 推送
*
* @param sourceSettlementId 原正式结算单id
* @return 新单id与「原明细/原费项 -&gt; 新明细/新费项」id映射(供调整明细应用到新单)
*/
CloneResult cloneFormalSettlement(Long sourceSettlementId);
/**
* 结算调整重推收尾:原单标记「已调整」、旧锁解锁、追加重推变更记录(不改原单业务数据)
*
* @param settlementId 原正式结算单id
* @param adjustmentNo 结算调整单号
* @param newSettlementNo 重推生成的新单号
*/
void markSettlementAdjusted(Long settlementId, String adjustmentNo, String newSettlementNo);
/**
* 克隆结果:新单id + 原明细id-&gt;新明细id + 原费项id-&gt;新费项id
*/
record CloneResult(Long settlementId, Map<Long, Long> detailIdMap, Map<Long, Long> feeIdMap) {
}
}
@@ -45,7 +45,7 @@ public interface IInvoiceApplicationService extends BaseService<InvoiceApplicati
IPage<InvoiceApplicationVO> selectPage(IPage<InvoiceApplication> page, InvoiceApplicationVO query);
InvoiceApplicationVO detail(Long id);
IPage<Map<String, Object>> settlementCandidates(IPage<?> page, String keyword, String contractCategory,
String settlementType, String invoiceStatus);
String settlementType);
List<FormalSettlementDetail> settlementDetails(String settlementIds);
Map<String, Object> receiverInformation(String settlementIds);
Long saveDraft(InvoiceApplicationSaveRequest request);
@@ -9,8 +9,10 @@
package org.springblade.transport.service;
import com.baomidou.mybatisplus.extension.service.IService;
import org.springblade.core.mp.base.BaseEntity;
import org.springblade.thirdparty.bfi.pojo.dto.BfiBillLockDTO;
import org.springblade.transport.pojo.entity.KingdeeBillLock;
import org.springblade.transport.pojo.vo.KingdeeUnlockMark;
import java.util.List;
import java.util.Map;
@@ -39,8 +41,10 @@ public interface IKingdeeBillLockService extends IService<KingdeeBillLock> {
String lockAfterPush(String sourceBillType, Long sourceBillId, String sourceBillNo, String targetBillType, List<String> targetBillNos);
/**
* 金蝶解锁回调(unLockNotifyBusiData):校验来源/目标系统与 lockAction,
* targetBillNo 支持逗号拼接批量,逐张按 targetBillType+targetBillNo 定位最新 locked 锁记录;
* 金蝶解锁回调(unLockNotifyBusiData):校验来源/目标系统与 lockAction。
* 注意回调方向与锁定相反:回调 sourceBillNo=金蝶单号(即锁记录 targetBillNo,逗号拼接批量)、
* targetBillNo=WLTMS 单号(即锁记录 sourceBillNo)、targetBillType 可能为空,
* 故按金蝶单号+我方单号双条件反查最新 locked 锁记录;
* operationType=DELETE 时按单据类型分发废除源单并还原关联数据
*
* @param param 金蝶回调参数
@@ -56,6 +60,17 @@ public interface IKingdeeBillLockService extends IService<KingdeeBillLock> {
*/
void checkEditable(String sourceBillType, Long sourceBillId);
/**
* 结算调整重推解锁:把源单当前「锁定中」的锁记录标记为 unlocked(非金蝶回调,仅本地解锁留痕),
* 供原结算单被调整重推生成新单后解除旧锁使用;新单另行落新锁记录
*
* @param sourceBillType WLTMS 单据类型
* @param sourceBillId WLTMS 业务主键
* @param operator 操作人
* @return 解锁的记录数
*/
int unlockByAdjustRepush(String sourceBillType, Long sourceBillId, String operator);
/**
* 批量查源单当前锁状态(供列表/详情页填充展示标识):
* 每源单取最新一条非终态锁记录(locked/failed),unlocked 为终态不返回(前端视为无锁/已解锁)
@@ -66,6 +81,29 @@ public interface IKingdeeBillLockService extends IService<KingdeeBillLock> {
*/
Map<Long, KingdeeBillLock> mapActiveLocks(String sourceBillType, List<Long> sourceBillIds);
/**
* 批量查源单解锁终态留痕(供列表/详情页填充解锁标识):
* 每源单取最新一条 unlocked 锁记录(付款申请解锁后不作废,页面据此区分「金蝶删单/解锁」与普通推送失败)
*
* @param sourceBillType WLTMS 单据类型
* @param sourceBillIds 源单主键集合
* @return sourceBillId -&gt; 最新 unlocked 锁记录,无解锁留痕的源单不在 Map 中
*/
Map<Long, KingdeeBillLock> mapUnlockedLocks(String sourceBillType, List<Long> sourceBillIds);
/**
* 批量填充金蝶解锁终态标识(通用):对无活跃锁的行反查最新 unlocked 锁记录并回填解锁标识
* (解锁时间/操作人/动作名称),供前端展示明显解锁标识;有活跃锁的单必非解锁终态,跳过。
* 与 mapActiveLocks 配对在各单据 fillKingdeeLockStatus 中调用,列表/详情共用;
* 后续单据(如收票单)接入锁单时,VO 实现 KingdeeUnlockMark 即可复用
*
* @param sourceBillType WLTMS 单据类型
* @param rows 已填充活跃锁标识的 VO 行
* @param activeLockMap 活跃锁 Map(mapActiveLocks 返回值)
* @param &lt;T&gt; 实现 KingdeeUnlockMark 的 VO 类型(须继承 BaseEntity 以取主键)
*/
<T extends BaseEntity & KingdeeUnlockMark> void fillUnlockMarks(String sourceBillType, List<T> rows, Map<Long, KingdeeBillLock> activeLockMap);
/**
* 锁定补偿:扫 failed 且 try_count 未达重试上限(RETRY_MAX_COUNT)的锁记录,逐张重调 lockBusiData
* (接口 1对1:单次一个源单号对一个金蝶单号,单张成败互不影响;定时任务定时触发,亦可人工触发);
@@ -63,9 +63,12 @@ public interface IManageWaybillService {
String exception, String transportType, String startDate, String endDate);
/**
* 调度端运单详情(不校验司机归属)
* 调度端运次详情(不校验司机归属)
* <p>
* 传运次主键:配载单主键或未配载运单主键,都按字符串。已拒单的子运单按运次取不到(运次口径
* 不含已拒子单),此时按运单主键回落成单票视图,供重新派单等从运单进入的场景使用。
*/
AdminWaybillDetailVO detail(Long id);
AdminWaybillDetailVO detail(String tripId);
/**
* 待处理运单(待接单 / 运输中;可筛需重新派单)
@@ -28,6 +28,7 @@ import org.springblade.transport.pojo.dto.PaymentApplicationStatusRequest;
import org.springblade.transport.pojo.entity.PaymentApplication;
import org.springblade.transport.pojo.vo.PaymentApplicationReferenceAmountVO;
import org.springblade.transport.pojo.vo.PaymentApplicationVO;
import org.springblade.transport.pojo.vo.ProjectAdvanceWriteOffOptionVO;
import java.util.List;
@@ -36,6 +37,11 @@ public interface IPaymentApplicationService extends BaseService<PaymentApplicati
IPage<PaymentApplicationVO> selectPage(IPage<PaymentApplication> page, PaymentApplicationVO query);
PaymentApplicationVO detail(Long id);
PaymentApplicationReferenceAmountVO referenceAmount(String paymentType, Long referenceId, Long excludeId);
/** 按合同查询可选项目预付核销列表。 */
List<ProjectAdvanceWriteOffOptionVO> projectAdvanceWriteOffOptions(Long contractId,
String excludeSettlementType,
Long excludeSettlementId);
Long saveDraft(PaymentApplicationSaveRequest request);
void removeDraft(Long id);
void submit(PaymentApplicationStatusRequest request);
@@ -33,6 +33,11 @@ public interface IPreSettlementService extends BaseService<PreSettlement> {
PreSettlementVO detail(Long id);
/**
* 预览下一条预结算单号。仅供新增页面展示,真正落库的单号仍由保存时生成。
*/
String nextNo();
default List<Map<String, Object>> contractOptions(String keyword) {
return contractOptions(keyword, null);
}
@@ -101,4 +101,19 @@ public interface IReceivablePayableDetailService extends BaseService<ReceivableP
/** 关闭总单调度后生成总单客户合同应收,并按所属运单记录的承运商合同生成应付。 */
void generateForClosedMasterOrder(MasterOrder masterOrder);
/**
* 结算导入:按导入单据构建/刷新「结算导入」来源的应收应付明细(运输费+费用项金额以导入数据为准)。
* <p>
* 历史运单若已有系统生成明细,不覆盖原明细,另建结算导入明细;运单已进入结算则拒绝重复导入。
* feeRows 每项含 waybillId、transportFee、unitPrice、feeItems(Map)、remark。
* </p>
*
* @param settlementType 结算类型 receivable/payable
* @param contractId 结算合同ID
* @param feeRows 费用行
* @return 明细主键列表(新建或刷新后的结算导入明细)
*/
List<Long> createDetailsFromSettlementImport(String settlementType, Long contractId,
List<Map<String, Object>> feeRows);
}
@@ -0,0 +1,77 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import jakarta.servlet.http.HttpServletResponse;
import org.springblade.core.mp.base.BaseService;
import org.springblade.transport.pojo.dto.SettlementImportBatchRequest;
import org.springblade.transport.pojo.dto.SettlementImportVoidRequest;
import org.springblade.transport.pojo.entity.SettlementImportBatch;
import org.springblade.transport.pojo.vo.SettlementImportBatchVO;
import org.springblade.transport.pojo.vo.WaybillImportValidateResultVO;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.Map;
/**
* 导入结算单批次服务
*
* @author Chill
*/
public interface ISettlementImportBatchService extends BaseService<SettlementImportBatch> {
Map<String, List<Map<String, Object>>> options();
String nextBatchNo();
IPage<SettlementImportBatchVO> page(IPage<SettlementImportBatch> page, SettlementImportBatchRequest request);
SettlementImportBatchVO detail(Long id);
void exportTemplate(HttpServletResponse response);
Map<String, Object> parse(MultipartFile file);
WaybillImportValidateResultVO validate(SettlementImportBatchRequest request);
void exportFailures(SettlementImportBatchRequest request, MultipartFile file, HttpServletResponse response);
SettlementImportBatchVO saveDraft(SettlementImportBatchRequest request);
SettlementImportBatchVO confirm(SettlementImportBatchRequest request);
void voidBatch(SettlementImportVoidRequest request);
/** 删除草稿批次(仅 draft 可删)。 */
void removeDraft(Long id);
/**
* 历史运单可选列表:相同项目、客户合同、承运商合同下,尚未生成对应应收/应付明细的运单。
*/
List<Map<String, Object>> historyWaybills(SettlementImportBatchRequest request);
}
@@ -0,0 +1,70 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service;
import org.springblade.transport.pojo.dto.SettlementProjectAdvanceWriteOffRequest;
import org.springblade.transport.pojo.entity.SettlementProjectAdvanceWriteOff;
import org.springblade.transport.pojo.vo.ProjectAdvanceWriteOffOptionVO;
import java.math.BigDecimal;
import java.util.Collection;
import java.util.List;
import java.util.Map;
/**
* 结算单项目预付核销服务
*
* @author Chill
*/
public interface ISettlementProjectAdvanceWriteOffService {
/**
* 按合同查询可选项目预付(审批通过、金蝶已付款且仍有可核销余额)。
*/
List<ProjectAdvanceWriteOffOptionVO> listOptions(Long contractId, String excludeSettlementType,
Long excludeSettlementId);
/**
* 查询结算单已保存的核销明细(含动态可核销金额)。
*/
List<SettlementProjectAdvanceWriteOff> listBySettlement(String settlementType, Long settlementId);
/**
* 结算单核销金额合计。
*/
BigDecimal sumBySettlement(String settlementType, Long settlementId);
/**
* 按结算单集合分组统计核销合计(一次 group-by,供批量刷新与列表页使用)。
*/
Map<Long, BigDecimal> sumBySettlementIds(String settlementType, Collection<Long> settlementIds);
/**
* 覆盖保存核销明细,并校验不超过可核销金额与结算单金额。
*/
void replaceWriteOffs(String settlementType, Long settlementId, Long contractId,
BigDecimal settlementAmount, List<SettlementProjectAdvanceWriteOffRequest> writeOffs);
/**
* 物理清理结算单核销明细(草稿删除时)。
*/
void deleteBySettlement(String settlementType, Long settlementId);
/**
* 删除指定结算单集合上的核销明细(核销行转移/结算单作废联动),返回删除行数。
*/
int deleteBySettlementIds(String settlementType, Collection<Long> settlementIds);
/**
* 按付款申请删除其核销明细(付款申请作废联动),返回受影响的正式结算单id。
*/
List<Long> deleteByPaymentApplicationIds(Collection<Long> paymentApplicationIds);
}
@@ -0,0 +1,42 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service;
/**
* 按合同结算规则自动生成预结算单、正式结算单
*
* @author Chill
*/
public interface ISettlementRuleAutoGenerateService {
/**
* 扫描已开启自动生成的合同,为截至昨日已关闭的账期生成结算草稿。
*
* @return 本次生成结果摘要
*/
String generateDueSettlements();
}
@@ -0,0 +1,65 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import org.springblade.transport.pojo.vo.TripQueryVO;
import org.springblade.transport.pojo.vo.TripTabCountsVO;
import org.springblade.transport.pojo.vo.TripVO;
/**
* 运次读服务
* <p>
* 「一卡一运次」的取数、状态聚合、接单需求合并与分页只在这里实现一次,司机端与管理端列表都调它,
* 各自只做字段裁剪。聚合全程只读,不在读路径上写业务状态——分档只按过程配置与接单态推导,
* 不按库里的业务状态,也不借读路径做惰性校正。
*/
public interface ITripQueryService {
/**
* 运次分页:一页就是若干个运次(有配载单的一趟车算一个)。
* <p>
* 库层只取回收口与行级筛选命中的运单行,分组、分档、筛选、排序与分页都在服务层算完再做。
*
* @param query 查询条件,收口由 driverPhone 或 allDrivers 给出
* @return 运次分页
*/
IPage<TripVO> page(TripQueryVO query);
/**
* 运次 Tab 统计:全部 / 待接单 / 进行中 / 已完成,分档口径与 page 完全一致
*
* @param query 查询条件,口径同 page
* @return 运次计数
*/
TripTabCountsVO tabCounts(TripQueryVO query);
/**
* 按运次主键取单个运次(含子运单),主键为字符串
*
* @param query 查询条件,tripId 必填
* @return 运次
*/
TripVO detail(TripQueryVO query);
}
@@ -113,6 +113,14 @@ public interface IWaybillService extends BaseService<Waybill> {
*/
boolean completeWithoutDeptCheck(Long id);
/**
* 确认回单:回单节点配成「需业务员确认」时,内部人员确认后把运单置为已完成。
* <p>
* 比 {@link #complete(Long)} 多两道校验:该运单的司机打卡节点(在途除外)必须全部已打卡,
* 且没有未了结异常——不挡住就等于借这个入口绕过打卡门禁与异常门禁。
*/
boolean confirmReturnReceipt(Long id);
BusinessRemoveResultVO batchComplete(String ids);
LoadingManageVO roadLoading(String ids);
@@ -46,11 +46,11 @@ public record KingdeeSyncContext(Map<Long, ContractManage> contractCache,
Map<Long, List<FormalSettlementDetail>> settlementDetailCache,
Map<Long, FormalSettlement> settlementCache,
Map<Long, CustomerReceiptAccount> receiptAccountCache,
Map<String, String> feeCategoryByName) {
Map<String, String> feeCategoryByName, Map<Long, String> paymentNoCache) {
/** 空上下文:缓存可写但初始为空,首条记录命中后全批复用 */
public static KingdeeSyncContext empty() {
return new KingdeeSyncContext(new HashMap<>(), new HashMap<>(), new HashMap<>(),
new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>());
new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>());
}
}
@@ -86,14 +86,15 @@ public class BillLedgerServiceImpl extends BaseServiceImpl<BillLedgerMapper, Bil
}
@Override
public List<BillLedgerVO> availableOptions(String keyword, Long deptId, Long selectedId) {
return availableList(keyword, deptId, selectedId);
public List<BillLedgerVO> availableOptions(String keyword, Long deptId, Long selectedId, String payerName,
String payeeName) {
return availableList(keyword, deptId, selectedId, payerName, payeeName);
}
@Override
public IPage<BillLedgerVO> availablePage(IPage<BillLedger> page, String keyword, Long deptId,
Long selectedId) {
List<BillLedgerVO> available = availableList(keyword, deptId, selectedId);
Long selectedId, String payerName, String payeeName) {
List<BillLedgerVO> available = availableList(keyword, deptId, selectedId, payerName, payeeName);
long current = Math.max(page.getCurrent(), 1);
long size = Math.max(page.getSize(), 1);
long from = Math.min((current - 1) * size, available.size());
@@ -103,22 +104,50 @@ public class BillLedgerServiceImpl extends BaseServiceImpl<BillLedgerMapper, Bil
return result;
}
private List<BillLedgerVO> availableList(String keyword, Long deptId, Long selectedId) {
private List<BillLedgerVO> availableList(String keyword, Long deptId, Long selectedId, String payerName,
String payeeName) {
LocalDate today = LocalDate.now();
// 未到期且有余额;收票且部门可用,或出票/收票单位与结算收付款方一致(开票、收票都可)
return list(Wrappers.<BillLedger>lambdaQuery()
.and(Func.isNotEmpty(keyword), wrapper -> wrapper.like(BillLedger::getBillNo, keyword)
.or().like(BillLedger::getIssuerName, keyword)
.or().like(BillLedger::getReceiverName, keyword))
.and(wrapper -> wrapper
.gt(BillLedger::getAvailableBalance, BigDecimal.ZERO)
.and(available -> available
.ge(BillLedger::getMaturityDate, today)
.gt(BillLedger::getAvailableBalance, BigDecimal.ZERO))
.or(selectedId != null, child -> child.eq(BillLedger::getId, selectedId)))
.orderByAsc(BillLedger::getMaturityDate)
.orderByDesc(BillLedger::getCreateTime)).stream()
.filter(item -> selectedId != null && Objects.equals(item.getId(), selectedId)
|| departmentAvailable(item, deptId))
|| selectableBill(item, deptId, payerName, payeeName))
.map(item -> BillLedgerWrapper.build().entityVO(item))
.toList();
}
/**
* 收票且当前部门在可用范围内,或出票单位、收票单位与结算付款方、收款方一致(顺序不限)。
*/
private boolean selectableBill(BillLedger ledger, Long deptId, String payerName, String payeeName) {
boolean receivedAndDept = "received".equals(ledger.getBillType()) && departmentAvailable(ledger, deptId);
return receivedAndDept || partiesMatch(ledger.getIssuerName(), ledger.getReceiverName(), payerName, payeeName);
}
private boolean partiesMatch(String issuerName, String receiverName, String payerName, String payeeName) {
String issuer = trimName(issuerName);
String receiver = trimName(receiverName);
String payer = trimName(payerName);
String payee = trimName(payeeName);
if (issuer.isEmpty() || receiver.isEmpty() || payer.isEmpty() || payee.isEmpty()) {
return false;
}
return (issuer.equals(payer) && receiver.equals(payee)) || (issuer.equals(payee) && receiver.equals(payer));
}
private String trimName(String value) {
return value == null ? "" : value.trim();
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long submit(BillLedgerSaveRequest request) {
@@ -136,6 +165,11 @@ public class BillLedgerServiceImpl extends BaseServiceImpl<BillLedgerMapper, Bil
}
CustomerArchive issuer = customer(request.getIssuerId(), "出票单位");
CustomerArchive feeBearer = customer(request.getFeeBearerId(), "费用承担方");
String receiverName = required(request.getReceiverName(), "收票单位", 100);
if (!Objects.equals(feeBearer.getId(), issuer.getId())
&& !customerName(feeBearer).equals(receiverName)) {
throw new ServiceException("费用承担方只能选择收票单位或出票单位");
}
BigDecimal faceAmount = positive(request.getFaceAmount(), "票面金额").setScale(2, RoundingMode.HALF_UP);
BigDecimal usedAmount = entity.getId() == null ? BigDecimal.ZERO : activeUsedAmount(entity.getId());
if (faceAmount.compareTo(usedAmount) < 0) {
@@ -145,7 +179,7 @@ public class BillLedgerServiceImpl extends BaseServiceImpl<BillLedgerMapper, Bil
entity.setBillNo(billNo);
entity.setIssuerId(issuer.getId());
entity.setIssuerName(customerName(issuer));
entity.setReceiverName(required(request.getReceiverName(), "收票单位", 100));
entity.setReceiverName(receiverName);
entity.setBillType(request.getBillType());
entity.setFaceAmount(faceAmount);
entity.setAvailableBalance(faceAmount.subtract(usedAmount).setScale(2, RoundingMode.HALF_UP));
@@ -309,10 +309,11 @@ public class BillPaymentServiceImpl extends BaseServiceImpl<BillPaymentMapper, B
return request.getId();
}
/** 生成当日汇票付款单号;按最大流水号 +1,且计入逻辑删除数据 */
private synchronized String nextNo() {
String prefix = "HP" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
return prefix + String.format("%05d", count(Wrappers.<BillPayment>lambdaQuery()
.likeRight(BillPayment::getPaymentNo, prefix)) + 1);
Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix);
return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1);
}
private BigDecimal positive(BigDecimal value, String name) {
@@ -354,6 +354,10 @@ public class CertAuditServiceImpl implements ICertAuditService {
requireIdCardNotTaken(snapshot.getIdCardNo(), driver.getId());
driver.setDriverName(snapshot.getRealName());
driver.setIdCardNo(snapshot.getIdCardNo());
driver.setBirthday(snapshot.getBirthday());
// 空串不是「没填」:框架的 not_null 策略只跳过 null,写空串会把管理端填过的住址清掉
driver.setAddressRegion(blankToNull(snapshot.getAddressRegion()));
driver.setAddress(blankToNull(snapshot.getAddress()));
driver.setIdCardFront(snapshot.getIdCardFrontUrl());
driver.setIdCardBack(snapshot.getIdCardBackUrl());
driver.setIdCardValidStart(snapshot.getIdCardValidStart());
@@ -30,6 +30,7 @@ import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.tool.utils.BeanUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.SysCache;
import org.springblade.system.cache.UserCache;
import org.springblade.system.pojo.entity.Dept;
import org.springblade.transport.excel.CommonRouteExportExcel;
@@ -217,12 +218,32 @@ public class CommonRouteServiceImpl extends BaseServiceImpl<CommonRouteMapper, C
commonRoute.setArrivalPhone(TransportBusinessSupport.trimToNull(commonRoute.getArrivalPhone()));
commonRoute.setDeptName(TransportBusinessSupport.trimToNull(commonRoute.getDeptName()));
commonRoute.setRemark(TransportBusinessSupport.trimToNull(commonRoute.getRemark()));
if (Func.isEmpty(commonRoute.getDeptId())) {
// 仅新增时绑定组织;编辑沿用原组织(submit 中已从旧记录写回)。
if (Func.isEmpty(commonRoute.getId())) {
bindDeptOnCreate(commonRoute);
}
if (commonRoute.getStatus() == null) { commonRoute.setStatus(1); }
}
/**
* 新增绑定所属组织:普通用户始终落当前组织;超管可按入参组织保存,未传时回落当前组织。
*/
private void bindDeptOnCreate(CommonRoute commonRoute) {
if (commonRoute == null) {
return;
}
boolean administrator = TransportBusinessSupport.isAdministrator();
if (!administrator || Func.isEmpty(commonRoute.getDeptId()) || commonRoute.getDeptId() <= 0) {
Dept dept = TransportBusinessSupport.currentDept("常用线路");
commonRoute.setDeptId(dept.getId());
commonRoute.setDeptName(dept.getDeptName());
return;
}
if (commonRoute.getStatus() == null) { commonRoute.setStatus(1); }
Dept dept = SysCache.getDept(commonRoute.getDeptId());
if (Func.isEmpty(dept)) {
throw new ServiceException("所属组织不存在或已删除");
}
commonRoute.setDeptName(TransportBusinessSupport.trimToNull(dept.getDeptName()));
}
private void validate(CommonRoute commonRoute) {
@@ -326,6 +326,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
validateBillingPlans(request.getBillingPlanJson());
validatePaymentRatios(request.getPaymentRatioJson());
Map<String, Object> beforeData = buildContractChangeSnapshot(source);
applyContractNo(source, request.getContractNo(), false);
source.setContractName(request.getContractName()); source.setPartyB(request.getPartyB());
source.setStartDate(request.getStartDate()); source.setEndDate(request.getEndDate()); source.setContractFormat(request.getContractFormat());
source.setSettlementMode(request.getSettlementMode()); source.setLegalSealFlag(request.getLegalSealFlag()); source.setCopyCount(request.getCopyCount());
@@ -467,9 +468,11 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
} else if (Func.isNotEmpty(contractManage.getOrganizationId())) {
queryWrapper.eq(ContractManage::getOrganizationId, contractManage.getOrganizationId());
}
queryWrapper.like(Func.isNotEmpty(contractManage.getContractNo()), ContractManage::getContractNo, contractManage.getContractNo())
queryWrapper.eq(Func.isNotEmpty(contractManage.getProjectId()), ContractManage::getProjectId, contractManage.getProjectId())
.like(Func.isNotEmpty(contractManage.getContractNo()), ContractManage::getContractNo, contractManage.getContractNo())
.like(Func.isNotEmpty(contractManage.getContractName()), ContractManage::getContractName, contractManage.getContractName())
.like(Func.isNotEmpty(contractManage.getProjectName()), ContractManage::getProjectName, contractManage.getProjectName())
.like(Func.isNotEmpty(contractManage.getProjectName()) && Func.isEmpty(contractManage.getProjectId()),
ContractManage::getProjectName, contractManage.getProjectName())
.like(Func.isNotEmpty(contractManage.getOrganizationName()), ContractManage::getOrganizationName, contractManage.getOrganizationName())
.eq(Func.isNotEmpty(contractManage.getContractCategory()), ContractManage::getContractCategory, contractManage.getContractCategory())
.eq(Func.isNotEmpty(contractManage.getSignType()), ContractManage::getSignType, contractManage.getSignType())
@@ -555,8 +558,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
contractManage.setOrganizationId(TransportBusinessSupport.currentDeptId("合同管理"));
contractManage.setOrganizationName(TransportBusinessSupport.currentDept("合同管理").getDeptName());
}
// 新增始终按部门规则生成合同编号,避免前端预览号并发冲突
contractManage.setContractNo(nextContractNo(contractManage.getOrganizationId()));
applyContractNo(contractManage, contractManage.getContractNo(), true);
if (Func.isEmpty(contractManage.getHandlerUserId())) {
contractManage.setHandlerUserId(AuthUtil.getUserId());
contractManage.setHandlerUserName(AuthUtil.getUserName());
@@ -564,7 +566,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
contractManage.setArchiveStatus(ARCHIVE_STATUS_PENDING);
} else {
ContractManage oldRecord = loadExists(contractManage.getId());
contractManage.setContractNo(oldRecord.getContractNo());
applyContractNo(contractManage, contractManage.getContractNo(), false);
if (Func.isEmpty(contractManage.getOrganizationId())) {
contractManage.setOrganizationId(oldRecord.getOrganizationId());
contractManage.setOrganizationName(oldRecord.getOrganizationName());
@@ -583,6 +585,28 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
}
}
/**
* 保存合同编号。新增未填写时按部门规则生成,已填写或编辑时保留手工编号并校验唯一。
*/
private void applyContractNo(ContractManage contractManage, String contractNo, boolean generateWhenEmpty) {
String normalizedContractNo = TransportBusinessSupport.trimToNull(contractNo);
if (Func.isEmpty(normalizedContractNo)) {
if (!generateWhenEmpty) {
throw new ServiceException("请输入合同编号");
}
normalizedContractNo = nextContractNo(contractManage.getOrganizationId());
}
TransportBusinessSupport.validateLength(normalizedContractNo, 100, "合同编号不能超过100字");
Long duplicatedCount = count(Wrappers.<ContractManage>lambdaQuery()
.eq(ContractManage::getIsDeleted, 0)
.eq(ContractManage::getContractNo, normalizedContractNo)
.ne(Func.isNotEmpty(contractManage.getId()), ContractManage::getId, contractManage.getId()));
if (duplicatedCount != null && duplicatedCount > 0) {
throw new ServiceException("合同编号已存在");
}
contractManage.setContractNo(normalizedContractNo);
}
@Override
public synchronized String nextContractNo(Long organizationId) {
if (Func.isEmpty(organizationId)) {
@@ -606,7 +630,8 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
.orderByDesc(ContractManage::getContractNo)
.last("LIMIT 50"));
Pattern pattern = Pattern.compile("^" + Pattern.quote(prefix) + "(" + year + ")(\\d{3})号$");
int next = 1;
// 当年尚无匹配编号时,从组织配置的初始流水号起编;否则在已有最大流水号上加1
int next = resolveContractInitialSerial(dept);
for (ContractManage item : latestList) {
String contractNo = TransportBusinessSupport.trimToNull(item.getContractNo());
if (Func.isEmpty(contractNo)) {
@@ -625,6 +650,17 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
return head + String.format("%03d", next) + "号";
}
private int resolveContractInitialSerial(Dept dept) {
Integer serial = dept.getContractInitialSerial();
if (serial == null || serial < 1) {
return 1;
}
if (serial > 999) {
throw new ServiceException("组织机构中配置的合同初始流水号不能超过999");
}
return serial;
}
private void refreshArchiveStatusOnApprove(ContractManage contractManage) {
if (hasSealArchiveFile(contractManage.getContractFileJson())) {
contractManage.setArchiveStatus(ARCHIVE_STATUS_ARCHIVED);
@@ -1063,6 +1099,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
private Map<String, Object> buildContractChangeSnapshot(ContractManage contractManage) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("contractNo", contractManage.getContractNo());
data.put("contractName", contractManage.getContractName());
data.put("partyB", contractManage.getPartyB());
data.put("startDate", contractManage.getStartDate());
@@ -168,7 +168,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
*/
private static final String APPROVAL_REJECTED = "rejected";
/** 客商所属组织固定为顶层组织 */
private static final String FIXED_OWNER_DEPT_NAME = "桂物物流集团";
private static final String FIXED_OWNER_DEPT_NAME = "广西现代物流集团有限公司";
/**
* 金蝶协同状态:未推送(新增客商默认值,审批通过后参与推送)
@@ -277,7 +277,8 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
boolean created = Func.isEmpty(customer.getId());
CustomerArchive before = created ? null : ensureCustomerAccessible(customer.getId());
if (created) {
customer.setCustomerCode(nextCustomerCode());
// 新增时金蝶已有客商,前端回填金蝶客商编码,建档直接沿用;未回填则自动生成
customer.setCustomerCode(Func.isEmpty(customer.getCustomerCode()) ? this.nextCustomerCode() : customer.getCustomerCode());
}
CustomerArchive entity = Objects.requireNonNull(BeanUtil.copyProperties(customer, CustomerArchive.class));
boolean result = saveOrUpdate(entity);
@@ -515,10 +516,10 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
after.setKingdeeSyncTime(LocalDateTime.now());
BfiCustomerVO kingdeeCustomer = queryKingdeeCustomer(customer.getUnifiedCreditCode());
if (kingdeeCustomer != null && BfiConstant.BILL_STATUS_AUDITED.equals(kingdeeCustomer.getStatus())) {
// 金蝶已有已审核档案:跳过推送
// 金蝶已有已审核档案:跳过推送,客商编号采纳金蝶客商编码
after.setKingdeeStatus(KINGDEE_STATUS_EXIST);
after.setKingdeeFailReason(null);
addChangeRecord(id, "金蝶已存在该客商档案,跳过推送", customer, after);
addChangeRecord(id, "金蝶已存在该客商档案,跳过推送" + this.adoptKingdeeCode(customer, after, kingdeeCustomer.getNumber()), customer, after);
updateById(after);
return after;
}
@@ -617,6 +618,31 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
return rows == null || rows.isEmpty() ? null : rows.get(0);
}
/**
* 金蝶已存在档案时,客商编号(customer_code)采纳金蝶客商编码,后续业务接口沿用客商编号即拿到金蝶编码;
* 金蝶编码为空或与当前编号一致时无需处理;同租户下已被其他客商占用时跳过回填,避免违反 uk_customer_archive_code 唯一约束
*
* @param customer 变更前档案
* @param after 变更后档案(编码采纳结果回填其上)
* @param kingdeeNumber A902-1 查询返回的金蝶客商编码
* @return 变更说明后缀,无需处理返回空串
*/
private String adoptKingdeeCode(CustomerArchive customer, CustomerArchive after, String kingdeeNumber) {
if (Func.isEmpty(kingdeeNumber) || kingdeeNumber.equals(customer.getCustomerCode())) {
return "";
}
long occupied = count(Wrappers.<CustomerArchive>lambdaQuery()
.eq(CustomerArchive::getCustomerCode, kingdeeNumber)
.ne(CustomerArchive::getId, customer.getId()));
if (occupied > 0) {
log.warn("金蝶客商编码 {} 已被其他客商占用,跳过采纳客商编号,id={},customerCode={}",
kingdeeNumber, customer.getId(), customer.getCustomerCode());
return ",金蝶客商编码 " + kingdeeNumber + " 已被占用未同步";
}
after.setCustomerCode(kingdeeNumber);
return ",客商编号已同步为金蝶客商编码:" + kingdeeNumber;
}
private BfiBillSaveResultItemVO firstSaveItem(BfiBillSaveResultVO result) {
if (result == null || result.getResult() == null || result.getResult().isEmpty()) {
return null;
@@ -739,11 +765,11 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
CustomerArchive after = copyCustomer(customer);
after.setKingdeeSyncTime(LocalDateTime.now());
if (BfiConstant.BILL_STATUS_AUDITED.equals(kingdeeCustomer.getStatus())) {
// 金蝶已存在已审核档案:跳过推送,直接建档
// 金蝶已存在已审核档案:跳过推送直接建档,客商编号采纳金蝶客商编码
after.setKingdeeStatus(KINGDEE_STATUS_EXIST);
after.setKingdeeFailReason(null);
existed++;
collectChangeRecord(changeRecords, customer.getId(), "金蝶已存在该客商档案,跳过推送", customer, after);
collectChangeRecord(changeRecords, customer.getId(), "金蝶已存在该客商档案,跳过推送" + this.adoptKingdeeCode(customer, after, kingdeeCustomer.getNumber()), customer, after);
} else {
// 金蝶侧暂存/已提交:审核中,置失败待重推
after.setKingdeeStatus(KINGDEE_STATUS_FAILED);
@@ -821,6 +847,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
CustomerArchive after = copyCustomer(customer);
after.setKingdeeStatus(KINGDEE_STATUS_EXIST);
after.setKingdeeFailReason(null);
this.adoptKingdeeCode(customer, after, kingdeeCustomer.getNumber());
updateList.add(after);
collectChangeRecord(changeRecords, customer.getId(), "金蝶客商已审核,推送最终成功", customer, after);
log.info("金蝶客商已审核,推送最终成功并升级为金蝶已有档案,id={},unifiedCreditCode={}",
@@ -953,6 +980,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
}
private void prepare(CustomerArchiveVO customer) {
customer.setCustomerCode(trimToNull(customer.getCustomerCode()));
customer.setShortName(trimToNull(customer.getShortName()));
customer.setFullName(trimToEmpty(customer.getFullName()));
customer.setCustomerKind(normalizeCustomerKind(customer.getCustomerKind()));
@@ -1022,6 +1050,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
throw new ServiceException("固定营业期限必须填写营业期限截止日");
}
validateLength(customer.getShortName(), 20, "客商简称最多20个汉字");
validateLength(customer.getCustomerCode(), 20, "客商编号最多20个字符");
validateLength(customer.getFullName(), 100, "客商全称最多100个字符");
validateLength(customer.getLegalPerson(), 10, "法人/负责人最多10个汉字");
validateLength(customer.getContactPhone(), 11, "联系电话必须为11位手机号");
@@ -2210,7 +2239,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
}
/**
* 客商所属组织固定为顶层「桂物物流集团」,忽略前端传入的其他组织。
* 客商所属组织固定为顶层「广西现代物流集团有限公司」,忽略前端传入的其他组织。
*/
private void applyFixedOwnerDept(CustomerArchiveVO customer) {
customer.setDeptName(FIXED_OWNER_DEPT_NAME);
@@ -113,6 +113,8 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> imp
Driver driver = new Driver();
driver.setId(id);
driver.setStatus(status);
// 只改 status 也必须带上 driving_vehicle 原值,理由见 carryDrivingVehicle
carryDrivingVehicle(id, driver);
return updateById(driver);
}
@@ -446,9 +448,26 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> imp
Driver patch = new Driver();
patch.setId(driver.getId());
patch.setUserId(userId);
// 同上:这次只改 user_id,不带 driving_vehicle 原值同样会把车辆绑定清掉
carryDrivingVehicle(driver.getId(), patch);
updateById(patch);
}
/**
* 把库里的 driving_vehicle 原值带进 patch。
* <p>
* 该列标了 {@code FieldStrategy.ALWAYS},更新时忽略 null 直接写:patch 里不带这一列,
* 一次只改别的列的更新就会把司机的车辆绑定清成 NULL,而司机端车辆列表正是按这一列
* 的车牌匹配出来的(清空即「暂无绑定车辆」)。读不到该司机时保持原样,
* 让更新自己去失败。
*/
private void carryDrivingVehicle(Long driverId, Driver patch) {
Driver current = getById(driverId);
if (current != null) {
patch.setDrivingVehicle(current.getDrivingVehicle());
}
}
private Long defaultZero(Long value) {
return value == null ? 0L : value;
}
@@ -144,6 +144,17 @@ public class ExceptionDisposalServiceImpl
// 整单异常同样受这条约束:没有节点不等于没有上报人。
driverWaybillService.assertCurrentDriverOwnsWaybill(disposal.getWaybillId());
// 上报门禁之零:运单得先在路上,且不能已经结束。
// 判据与司机打卡同一来源;归属校验会顺手把「无需接单」的待执行运单校正为进行中,
// 所以状态要在它之后重新读,不能用 fillFromWaybill 里校正前的那份对象。
Waybill reportWaybill = waybillService.getById(disposal.getWaybillId());
String blockedReason = reportWaybill == null
? null
: WaybillProcessSupport.reportBlockedReason(reportWaybill.getBusinessStatus());
if (blockedReason != null) {
throw new ServiceException(blockedReason);
}
// 节点可以为空:全局入口(首页快捷格、打卡页底部按钮)上报的异常不指定环节,
// 它无从定位阻断区间,由门禁按「阻断该运单全部未打卡节点」处理。
// 节点非空时才要求它确属该运单;节点名取自过程配置而非请求体,避免落进一个该节点并不叫的名字。
@@ -38,8 +38,6 @@ import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.BeanUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.SysCache;
import org.springblade.system.pojo.entity.Dept;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyOpenApiQueryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyQueryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplySaveDTO;
@@ -98,6 +96,7 @@ import org.springblade.transport.pojo.vo.InvoiceApplicationSheetVO;
import org.springblade.transport.pojo.vo.InvoiceApplicationVO;
import org.springblade.transport.service.IInvoiceApplicationService;
import org.springblade.transport.service.IKingdeeBillLockService;
import org.springblade.transport.service.KingdeeSyncContext;
import org.springblade.transport.support.KingdeeApBillSyncSupport;
import org.springblade.transport.support.KingdeeArBillSyncSupport;
import org.springblade.transport.support.KingdeeSyncLockSupport;
@@ -203,6 +202,8 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
vo.setKingdeeLockFailReason(lockRecord.getFailReason());
vo.setKingdeeTryCount(lockRecord.getTryCount());
}
// 金蝶删单解锁会作废源单,按 unlocked 锁记录留痕补「金蝶删单」标识,区分正常作废与金蝶删单
kingdeeBillLockService.fillUnlockMarks(KingdeeLockConstant.SOURCE_INVOICE_APPLY, rows, lockMap);
}
/**
@@ -231,9 +232,23 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
InvoiceApplication entity = existing(id);
InvoiceApplicationVO vo = toListVO(entity);
fillKingdeeLockStatus(List.of(vo));
vo.setSettlements(settlementRelationMapper.selectList(Wrappers.<InvoiceApplicationSettlement>lambdaQuery()
.eq(InvoiceApplicationSettlement::getInvoiceApplicationId, id)
.orderByAsc(InvoiceApplicationSettlement::getCreateTime)));
// 剩余可开票金额按当前占用实时重算(排除本申请),避免编辑时展示保存时点的过期额度
List<InvoiceApplicationSettlement> relations = settlementRelationMapper.selectList(
Wrappers.<InvoiceApplicationSettlement>lambdaQuery()
.eq(InvoiceApplicationSettlement::getInvoiceApplicationId, id)
.orderByAsc(InvoiceApplicationSettlement::getCreateTime));
if (!relations.isEmpty()) {
Map<Long, FormalSettlement> settlementMap = formalSettlementMapper.selectBatchIds(
relations.stream().map(InvoiceApplicationSettlement::getFormalSettlementId).distinct().toList())
.stream().collect(Collectors.toMap(FormalSettlement::getId, Function.identity(), (first, duplicate) -> first));
relations.forEach(relation -> {
FormalSettlement settlement = settlementMap.get(relation.getFormalSettlementId());
if (settlement != null) {
relation.setAvailableInvoiceAmount(availableAmount(settlement, id));
}
});
}
vo.setSettlements(relations);
List<InvoiceApplicationSheet> sheets = sheetMapper.selectList(Wrappers.<InvoiceApplicationSheet>lambdaQuery()
.eq(InvoiceApplicationSheet::getInvoiceApplicationId, id).orderByAsc(InvoiceApplicationSheet::getSheetNo));
vo.setSheets(sheets.stream().map(sheet -> {
@@ -255,9 +270,8 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
@Override
public IPage<Map<String, Object>> settlementCandidates(IPage<?> page, String keyword,
String contractCategory, String settlementType, String invoiceStatus) {
String contractCategory, String settlementType) {
String candidateSettlementType = Func.isEmpty(settlementType) ? "receivable" : settlementType;
String candidateInvoiceStatus = Func.isEmpty(invoiceStatus) ? "unreceived" : invoiceStatus;
List<Long> contractIds = Func.isEmpty(contractCategory) ? List.of()
: contractManageMapper.selectList(Wrappers.<ContractManage>lambdaQuery()
.select(ContractManage::getId)
@@ -269,7 +283,6 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
}
List<FormalSettlement> settlements = formalSettlementMapper.selectList(Wrappers.<FormalSettlement>lambdaQuery()
.eq(FormalSettlement::getSettlementType, candidateSettlementType)
.eq(FormalSettlement::getInvoiceStatus, candidateInvoiceStatus)
.eq(FormalSettlement::getApprovalStatus, TransportBusiConstant.APPROVED)
.eq(FormalSettlement::getStatus, 1)
.in(Func.isNotEmpty(contractCategory), FormalSettlement::getContractId, contractIds)
@@ -337,6 +350,7 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
row.put("receiverName", settlement.getPayerName());
row.put("settlementAmount", money(settlement.getSettlementAmount()));
row.put("availableInvoiceAmount", available);
row.put("invoicedAmount", money(settlement.getSettlementAmount()).subtract(available));
return row;
}
@@ -402,28 +416,31 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
.collect(Collectors.toMap(InvoiceApplicationSaveRequest.SettlementRow::getSettlementId,
Function.identity(), (first, duplicate) -> first, LinkedHashMap::new)).values().stream().toList();
List<FormalSettlement> settlements = requestedRows.stream().map(row -> availableSettlement(row.getSettlementId())).toList();
if (settlements.stream().anyMatch(settlement -> !"receivable".equals(settlement.getSettlementType())
|| (creating && !"unreceived".equals(settlement.getInvoiceStatus())))) {
throw new ServiceException("只能选择审批通过、未作废、未收票的应收正式结算单");
if (settlements.stream().anyMatch(settlement -> !"receivable".equals(settlement.getSettlementType()))) {
throw new ServiceException("只能选择审批通过、未作废的应收正式结算单");
}
assertCompatible(settlements);
FormalSettlement first = settlements.get(0);
Map<Long, FormalSettlement> settlementMap = settlements.stream()
.collect(Collectors.toMap(FormalSettlement::getId, Function.identity()));
BigDecimal totalAvailable = BigDecimal.ZERO;
for (InvoiceApplicationSaveRequest.SettlementRow row : requestedRows) {
FormalSettlement settlement = settlementMap.get(row.getSettlementId());
BigDecimal available = availableAmount(settlement, entity.getId());
BigDecimal allocated = nonNegative(row.getAllocatedInvoiceAmount(), "分摊发票金额");
if (allocated.compareTo(available) > 0) {
throw new ServiceException("结算单" + settlement.getFormalSettlementNo() + "的分摊金额超过剩余可开票金额");
}
totalAvailable = totalAvailable.add(available);
}
BigDecimal lineTotal = validateSheets(request.getSheets());
if (lineTotal.compareTo(BigDecimal.ZERO) <= 0) {
throw new ServiceException("本次开票金额必须大于0");
}
// 本次开票金额按选择顺序自动分摊到各结算单并登记累计占用,摊不完即超过可开票金额
BigDecimal totalAvailable = BigDecimal.ZERO;
BigDecimal remaining = lineTotal;
for (InvoiceApplicationSaveRequest.SettlementRow row : requestedRows) {
FormalSettlement settlement = settlementMap.get(row.getSettlementId());
BigDecimal available = availableAmount(settlement, entity.getId());
totalAvailable = totalAvailable.add(available);
BigDecimal allocated = available.min(remaining);
row.setAllocatedInvoiceAmount(allocated);
remaining = remaining.subtract(allocated);
}
if (remaining.compareTo(BigDecimal.ZERO) > 0) {
throw new ServiceException("本次开票金额不能超过可开票金额");
}
CustomerArchive customer = findCustomer(first.getPayerName());
if (customer == null) throw new ServiceException("未找到正式结算单受票方对应的客商档案");
List<CustomerInvoiceInfoVO> invoiceInfos = activeInvoiceInfoVOs(customer.getId());
@@ -699,7 +716,7 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
@Override
@Transactional(rollbackFor = Exception.class)
public String doSyncKingdee(Long id) {
InvoiceApplication entity = existing(id);
InvoiceApplication entity = lockedExisting(id);
if (!TransportBusiConstant.APPROVED.equals(entity.getApprovalStatus())) throw new ServiceException("仅审批通过的开票申请允许同步金蝶");
// 已同步/已开票幂等返回(开票完成为终态);星瀚内码由附件补传分支经 A303-4 反查回填
if (KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(entity.getKingdeeStatus())
@@ -801,10 +818,10 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
}
if (Func.isBlank(entity.getDepartmentEmails())) throw new ServiceException("推送金蝶开票申请失败:部门邮箱(发票推送邮箱)不能为空");
if (Func.isBlank(entity.getTaxpayerNo())) throw new ServiceException("推送金蝶开票申请失败:购方纳税人识别号不能为空");
Dept dept = entity.getDeptId() == null ? null : SysCache.getDept(entity.getDeptId());
if (dept == null || Func.isBlank(dept.getKingdeeDeptCode())) {
throw new ServiceException("推送金蝶开票申请失败:申请部门[" + Func.toStr(entity.getDeptId()) + "]未维护金蝶组织编码,请检查部门数据");
}
// 金蝶组织编码:项目业务部门反查唯一平台公司 → 平台公司金蝶组织编码;申请部门编码:项目业务部门自身金蝶编码(与付款申请单部门.编码同源,共用上下文缓存)
KingdeeSyncContext kingdeeContext = KingdeeSyncContext.empty();
String kingdeeOrgNumber = kingdeeApBillSyncSupport.kingdeeOrgNumber(entity.getProjectId(), kingdeeContext);
String kingdeeDeptNumber = kingdeeApBillSyncSupport.kingdeeDeptNumber(entity.getProjectId(), kingdeeContext);
CustomerArchive customer = entity.getReceiverCustomerId() == null ? null : customerArchiveMapper.selectById(entity.getReceiverCustomerId());
if (customer == null || Func.isBlank(customer.getCustomerCode())) {
throw new ServiceException("推送金蝶开票申请失败:受票方客户未在客商档案中维护客商编码,请补充后重试");
@@ -835,7 +852,7 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
+ "]无商品行,无法组装分录");
}
BfiInvoiceApplySaveDTO param = this.buildInvoiceApplyParam(entity, sheetBillNo, seller, salerAddr, salerBank,
dept, customer, firstArSrcCode, money(sheet.getInvoiceAmount()), items);
kingdeeDeptNumber, kingdeeOrgNumber, customer, firstArSrcCode, money(sheet.getInvoiceAmount()), items);
log.info("金蝶开票申请推送:开票申请[{}]发票张[{}],billno=[{}],价税合计=[{}],分录{}条",
entity.getApplicationNo(), sheet.getSheetNo(), sheetBillNo, sheet.getInvoiceAmount(), items.size());
BfiBillSaveResultVO saveResult = bfiService.saveInvoiceApply(param);
@@ -884,17 +901,18 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
* 组装 A303-1 单张开票申请单入参(字段映射见设计文档 6.3/6.4),
* 购销方等主表公共字段随开票申请取数,价税合计与单据体分录取本张发票
*
* @param orgNumber 金蝶组织编码(项目业务部门反查平台公司)
* @param firstArSrcCode 上游来源单据号:首张关联结算单的金蝶应收单防重键 awrp_src_code(多张结算单取第一张)
*/
private BfiInvoiceApplySaveDTO buildInvoiceApplyParam(InvoiceApplication entity, String billNo,
CustomerArchive seller, String salerAddr, String salerBank, Dept dept, CustomerArchive customer,
CustomerArchive seller, String salerAddr, String salerBank, String deptNumber, String orgNumber, CustomerArchive customer,
String firstArSrcCode, BigDecimal sheetAmount, List<BfiInvoiceApplySaveEntryDTO> items) {
BfiInvoiceApplySaveDTO param = new BfiInvoiceApplySaveDTO();
// ==================== 以下为【必填】字段(BfiInvoiceApplySaveDTO 标注),非必填字段先注释保留 ====================
// 【必填】组织.编码:申请部门 deptId → 金蝶组织编码
param.setOrgidNumber(dept.getKingdeeDeptCode());
// 【必填】申请部门.编码:同上
param.setAwrpOrgfieldNumber(dept.getKingdeeDeptCode());
// 【必填】组织.编码:项目业务部门反查唯一平台公司 → 平台公司金蝶组织编码
param.setOrgidNumber(orgNumber);
// 【必填】申请部门.编码:项目业务部门金蝶编码(与付款申请单 awrp_department_number 同源)
param.setAwrpOrgfieldNumber(deptNumber);
// 【必填】业务单据编号(候选键):单张发票候选 billno(多张发票时开票申请单号-张次序号)
param.setBillno(billNo);
// 【必填】单据日期:申请日期 yyyy-MM-dd HH:mm:ss
@@ -1670,6 +1688,14 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
return entity;
}
/** 行锁读:同一单据并发推送串行化(锁随 doSyncKingdee 事务提交释放,后者读到已同步状态幂等返回) */
private InvoiceApplication lockedExisting(Long id) {
InvoiceApplication entity = baseMapper.selectOne(Wrappers.<InvoiceApplication>lambdaQuery()
.eq(InvoiceApplication::getId, id).last("FOR UPDATE"));
if (entity == null || Objects.equals(entity.getIsDeleted(), 1)) throw new ServiceException("开票申请不存在");
return entity;
}
private InvoiceApplication editable(Long id) {
InvoiceApplication entity = existing(id);
if (!List.of(TransportBusiConstant.DRAFT, TransportBusiConstant.RETURNED).contains(entity.getApprovalStatus())) throw new ServiceException("当前状态不可编辑");
@@ -1680,10 +1706,11 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
return ids == null ? List.of() : Func.toLongList(ids).stream().distinct().toList();
}
private String nextNo() {
/** 生成当日开票申请单号;按最大流水号 +1,且计入逻辑删除数据 */
private synchronized String nextNo() {
String prefix = "KP-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
return prefix + String.format("%05d", count(Wrappers.<InvoiceApplication>lambdaQuery()
.likeRight(InvoiceApplication::getApplicationNo, prefix)) + 1);
Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix);
return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1);
}
private String normalizeDepartmentEmails(String value, List<CustomerInvoiceInfoVO> invoiceInfos) {
@@ -64,6 +64,7 @@ import org.springblade.transport.mapper.FormalSettlementMapper;
import org.springblade.transport.mapper.InvoiceReceiptMapper;
import org.springblade.transport.mapper.InvoiceReceiptRecordMapper;
import org.springblade.transport.mapper.InvoiceReceiptSettlementMapper;
import org.springblade.transport.mapper.CustomerInvoiceInfoMapper;
import org.springblade.transport.mapper.KingdeeInvoicePoolMapper;
import org.springblade.transport.pojo.dto.InvoiceReceiptSaveRequest;
import org.springblade.transport.pojo.dto.InvoiceReceiptStatusRequest;
@@ -71,6 +72,7 @@ import org.springblade.transport.pojo.dto.KingdeeInvoicePoolQueryDTO;
import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.entity.CustomerArchive;
import org.springblade.transport.pojo.entity.CustomerContact;
import org.springblade.transport.pojo.entity.CustomerInvoiceInfo;
import org.springblade.transport.pojo.entity.FormalSettlement;
import org.springblade.transport.pojo.entity.FormalSettlementDetail;
import org.springblade.transport.pojo.entity.InvoiceReceipt;
@@ -91,6 +93,7 @@ import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import java.io.IOException;
import java.io.InputStream;
@@ -150,8 +153,11 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
private final FormalSettlementDetailMapper formalSettlementDetailMapper;
private final CustomerArchiveMapper customerArchiveMapper;
private final CustomerContactMapper customerContactMapper;
private final CustomerInvoiceInfoMapper customerInvoiceInfoMapper;
private final IBfiService bfiService;
private final BfiProperties bfiProperties;
/** 逐单推送事务模板:批量编排逐单开短事务,行锁跨远程调用,单笔失败只回滚自身不中断整批 */
private final TransactionTemplate transactionTemplate;
private final KingdeeApBillSyncSupport kingdeeApBillSyncSupport;
private final KingdeeReceivedInvoiceSyncSupport kingdeeReceivedInvoiceSyncSupport;
private final KingdeeAttachmentSyncSupport kingdeeAttachmentSyncSupport;
@@ -247,9 +253,10 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
return page;
}
/** 本地镜像真分页(可用过滤/认领登记标记均在 SQL 内直出) */
/** 本地镜像真分页(可用过滤/认领登记标记均在 SQL 内直出;客商单位过滤与定时同步同开关同口径) */
private IPage<KingdeeInvoicePoolVO> selectLocalPoolPage(KingdeeInvoicePoolQueryDTO query, long current, long size) {
return invoicePoolMapper.selectInvoicePoolPage(new Page<>(current, size), query, AuthUtil.getTenantId());
return invoicePoolMapper.selectInvoicePoolPage(new Page<>(current, size), query, AuthUtil.getTenantId(),
bfiProperties.getInvoiceReceipt().isCustomerUnitsOnly());
}
/**
@@ -608,12 +615,16 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
List<String> failures = new ArrayList<>();
for (InvoiceReceipt receipt : receipts) {
try {
pushOneReceivedInvoice(planByReceipt.get(receipt.getId()), kingdeeRowByInvoiceNo, apBillNoBySrcCode, context, autoSync);
// 逐单单据级事务:推送方法首行 FOR UPDATE 串行化同一登记的并发推送,单笔失败只回滚自身不中断整批
transactionTemplate.executeWithoutResult(status -> pushOneReceivedInvoice(
planByReceipt.get(receipt.getId()), kingdeeRowByInvoiceNo, apBillNoBySrcCode, context, autoSync));
success++;
} catch (RuntimeException pushException) {
failures.add("发票" + Func.toStrWithEmpty(receipt.getInvoiceNo(), String.valueOf(receipt.getId()))
+ ":" + pushException.getMessage());
log.warn("推送金蝶收票单失败,收票登记id={}", receipt.getId(), pushException);
// 单笔事务已回滚,事务外独立补落"推送失败"状态,纳入定时重推(与付款/开票失败口径统一)
markReceivedInvoicePushFailed(receipt, planByReceipt.get(receipt.getId()), pushException, autoSync);
}
}
String summary = "推送金蝶收票单完成:本批 " + receipts.size() + " 笔,成功 " + success + " 笔";
@@ -674,7 +685,21 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
*/
private String pushOneReceivedInvoice(ReceivedInvoicePushPlan plan, Map<String, BfiReceivedInvoiceVO> kingdeeRows,
Map<String, String> apBillNoBySrcCode, KingdeeSyncContext context, boolean autoSync) {
InvoiceReceipt receipt = plan.receipt();
// 行锁读:同一登记并发推送串行化(锁随本单事务提交释放,事务由批量编排的 TransactionTemplate 开启)
InvoiceReceipt receipt = baseMapper.selectOne(Wrappers.<InvoiceReceipt>lambdaQuery()
.eq(InvoiceReceipt::getId, plan.receipt().getId()).last("FOR UPDATE"));
if (receipt == null || Objects.equals(receipt.getIsDeleted(), 1)) {
throw new ServiceException("收票登记不存在");
}
// 锁后重查推送状态:并发场景另一事务已完成推送则幂等跳过,不再重复调金蝶
if (KingdeeBfiConstant.RECEIVED_PUSH_PUSHED.equals(receipt.getKingdeePushStatus())
|| KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED.equals(receipt.getKingdeePushStatus())) {
log.info("收票登记[{}]金蝶收票单已推送(状态[{}]),幂等跳过", receipt.getInvoiceNo(), receipt.getKingdeePushStatus());
return "发票" + Func.toStrWithEmpty(receipt.getInvoiceNo(), String.valueOf(receipt.getId())) + "已推送金蝶,幂等跳过";
}
// 计划内登记实体以行锁后最新数据替换(并发下推送状态可能已被其他事务改写)
plan = new ReceivedInvoicePushPlan(receipt, plan.mirror(), plan.settlement(), plan.details(),
plan.apBillSrcCode(), plan.blockedReason());
log.info("收票登记[{}]推送金蝶收票单开始:id={},推送状态={},应付单防重键={}", receipt.getInvoiceNo(), receipt.getId(),
receipt.getKingdeePushStatus(), plan.apBillSrcCode());
if (Func.isNotBlank(plan.blockedReason())) {
@@ -765,6 +790,25 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
return "发票" + receipt.getInvoiceNo() + "推送成功,金蝶收票单[" + billNo + "]已指定应付单";
}
/**
* 单笔推送失败后事务外独立补落"推送失败"状态:事务版推送中的失败状态写随本单事务回滚,
* 由批量编排的 catch 在事务外重写,纳入定时重推循环;手动前置拦截路径保持不动状态(仅逐单报因)
*
* @param receipt 收票登记(批量预取实体)
* @param plan 推送计划
* @param pushException 推送异常
* @param autoSync 自动推送路径标记
*/
private void markReceivedInvoicePushFailed(InvoiceReceipt receipt, ReceivedInvoicePushPlan plan,
RuntimeException pushException, boolean autoSync) {
if (plan.blockedReason() != null && !autoSync) {
return;
}
applyReceivedInvoicePushResult(receipt, receipt.getKingdeeReceivedInvoiceNo(), receipt.getKingdeeReceivedInvoiceId(),
KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
truncatePushMsg("推送金蝶收票单失败:" + pushException.getMessage()), true);
}
/**
* 附件联带上传(A902):A303-2 保存成功(含幂等预检命中)后调用,上传登记附件与镜像发票文件;
* billPkId 缺失跳过待补传;异常独立捕获,绝不影响推送状态与整批进度
@@ -1252,20 +1296,25 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
LocalDateTime windowStart = LocalDateTime.now().minusDays(bfiProperties.getInvoiceReceipt().getIncrementDays());
LocalDateTime windowEnd = LocalDateTime.now();
List<String> invoiceTypes = this.poolInvoiceTypeCodes();
// 客商单位过滤集:开关开启时一次性载入有效客商发票信息税号(销/购方税号都须命中才入镜像),整轮共享避免逐页查询
Set<String> customerTaxNos = bfiProperties.getInvoiceReceipt().isCustomerUnitsOnly() ? this.customerInvoiceTaxNos() : null;
SyncStats stats = new SyncStats();
log.info("金蝶全票池同步开始,增量窗口 {} ~ {},发票种类:{},单页 {} 条最多 {} 页(触顶自动二分切片)",
log.info("金蝶全票池同步开始,增量窗口 {} ~ {},发票种类:{},客商单位过滤:{},单页 {} 条最多 {} 页(触顶自动二分切片)",
windowStart.format(POOL_TIME_FORMATTER), windowEnd.format(POOL_TIME_FORMATTER),
invoiceTypes.isEmpty() ? "不限制" : invoiceTypes, POOL_PAGE_SIZE, MAX_POOL_PAGES);
invoiceTypes.isEmpty() ? "不限制" : invoiceTypes, customerTaxNos != null ? "开启" : "关闭",
POOL_PAGE_SIZE, MAX_POOL_PAGES);
if (invoiceTypes.isEmpty()) {
this.syncWindowSlice(windowStart, windowEnd, null, stats);
this.syncWindowSlice(windowStart, windowEnd, null, customerTaxNos, stats);
} else {
// 单值参数拆轮:每个票种编码独立完整切片续扫,互不挤占翻页额度
for (String invoiceType : invoiceTypes) {
this.syncWindowSlice(windowStart, windowEnd, invoiceType, stats);
this.syncWindowSlice(windowStart, windowEnd, invoiceType, customerTaxNos, stats);
}
}
String summary = "金蝶全票池同步:翻页" + stats.pageCount + "页切片" + stats.sliceCount + "片拉取"
+ stats.pulledCount + "张发票,镜像新增" + stats.created + "更新" + stats.updated + "无变化"
+ stats.pulledCount + "张发票"
+ (stats.customerFiltered > 0 ? "(客商单位过滤" + stats.customerFiltered + "张)" : "")
+ ",镜像新增" + stats.created + "更新" + stats.updated + "无变化"
+ stats.mirrorUnchanged + ",补绑" + stats.bound + ",登记无变化跳过" + stats.unchanged
+ ",失败" + stats.failed + ",耗时" + (System.currentTimeMillis() - startMillis) + "ms";
log.info("金蝶全票池同步结束,{}", summary);
@@ -1288,6 +1337,8 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
private static final class SyncStats {
/** A801-1 原始拉取张数 */
int pulledCount;
/** 客商单位过滤剔除张数(销/购方税号未同时登记在客商发票信息中,开关开启时统计) */
int customerFiltered;
/** 镜像新增条数(票池有、本地无) */
int created;
/** 镜像有变化并重写条数 */
@@ -1311,11 +1362,13 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
* 触顶说明该时间片数据 >2.5 万,从中点二分为两片递归续扫(递归深度受时间粒度自然收敛,
* 单片 500 条以内 1 页即短页终止)
*
* @param sliceStart 切片起点(含)
* @param sliceEnd 切片终点(不含,避免与下一片边界重复拉取)
* @param stats 整轮统计累加器
* @param sliceStart 切片起点(含)
* @param sliceEnd 切片终点(不含,避免与下一片边界重复拉取)
* @param customerTaxNos 客商发票信息税号集,null 表示客商单位过滤关闭(整轮共享)
* @param stats 整轮统计累加器
*/
private void syncWindowSlice(LocalDateTime sliceStart, LocalDateTime sliceEnd, String invoiceType, SyncStats stats) {
private void syncWindowSlice(LocalDateTime sliceStart, LocalDateTime sliceEnd, String invoiceType,
Set<String> customerTaxNos, SyncStats stats) {
// 二分收敛保护:切片粒度细到 1 秒仍触顶说明远端异常(同秒 >2.5 万条不可能),不再二分直接记漏
if (!sliceStart.isBefore(sliceEnd)) {
log.error("金蝶全票池同步切片区间为空({} ~ {}),远端 updateTime 粒度异常,放弃该切片", sliceStart, sliceEnd);
@@ -1329,6 +1382,13 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
}
stats.pageCount++;
stats.pulledCount += rows.size();
// 客商单位过滤:销/购方税号未同时登记在客商发票信息中的发票不入镜像;
// 短页判定用原始条数,过滤后的空页不能提前终止翻页
int rawPageSize = rows.size();
if (customerTaxNos != null) {
rows = rows.stream().filter(row -> this.bothUnitsRegistered(row, customerTaxNos)).toList();
stats.customerFiltered += rawPageSize - rows.size();
}
BatchUpsertResult upsertResult = this.batchUpsertInvoicePool(rows);
stats.created += upsertResult.created();
stats.updated += upsertResult.updated();
@@ -1340,8 +1400,8 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
stats.unchanged += refreshResult.unchanged();
stats.failed += refreshResult.failed();
}
// 远程短页:该片到底,正常终止
if (rows.size() < POOL_PAGE_SIZE) {
// 远程短页:该片到底,正常终止(按原始拉取条数判断,客商过滤不改变翻页节奏)
if (rawPageSize < POOL_PAGE_SIZE) {
stats.sliceCount++;
return;
}
@@ -1359,13 +1419,34 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
}
log.warn("金蝶全票池同步切片 {} ~ {} 触达翻页保护 {} 页({} 条),二分续扫 [{} ~ {}) + [{} ~ {})",
sliceStart, sliceEnd, MAX_POOL_PAGES, MAX_POOL_PAGES * POOL_PAGE_SIZE, sliceStart, mid, mid, sliceEnd);
this.syncWindowSlice(sliceStart, mid, invoiceType, stats);
this.syncWindowSlice(mid, sliceEnd, invoiceType, stats);
this.syncWindowSlice(sliceStart, mid, invoiceType, customerTaxNos, stats);
this.syncWindowSlice(mid, sliceEnd, invoiceType, customerTaxNos, stats);
return;
}
stats.sliceCount++;
}
/**
* 有效客商发票信息税号集(状态正常且未删除,MP 租户插件自动隔离租户):
* 客商单位过滤开关开启时,同步拉取与票池展示查询共用的单位白名单
*/
private Set<String> customerInvoiceTaxNos() {
return customerInvoiceInfoMapper.selectList(Wrappers.<CustomerInvoiceInfo>lambdaQuery()
.eq(CustomerInvoiceInfo::getStatus, 1)
.eq(CustomerInvoiceInfo::getIsDeleted, 0)).stream()
.map(CustomerInvoiceInfo::getTaxNo)
.filter(StringUtil::isNotBlank)
.collect(Collectors.toSet());
}
/**
* 开票单位(销方)与受票单位(购方)税号是否均已登记在客商发票信息中;税号缺失视为未登记(开关开启时不入镜像)
*/
private boolean bothUnitsRegistered(BfiInvoicePoolVO row, Set<String> customerTaxNos) {
return StringUtil.isNotBlank(row.getSalerTaxNo()) && StringUtil.isNotBlank(row.getBuyerTaxNo())
&& customerTaxNos.contains(row.getSalerTaxNo()) && customerTaxNos.contains(row.getBuyerTaxNo());
}
/** 已登记票跟随刷新结果统计(绑定/刷新/无变化/失败) */
private record RefreshResult(int bound, int unchanged, int failed) {
}
@@ -13,6 +13,7 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseEntity;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.core.tool.utils.SpringUtil;
@@ -29,6 +30,7 @@ import org.springblade.transport.pojo.entity.FormalSettlement;
import org.springblade.transport.pojo.entity.InvoiceApplication;
import org.springblade.transport.pojo.entity.KingdeeBillLock;
import org.springblade.transport.pojo.entity.PaymentApplication;
import org.springblade.transport.pojo.vo.KingdeeUnlockMark;
import org.springblade.transport.service.IFormalSettlementService;
import org.springblade.transport.service.IInvoiceApplicationService;
import org.springblade.transport.service.IKingdeeBillLockService;
@@ -152,8 +154,8 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl<KingdeeBillLockMappe
public int onUnlockNotify(BfiBillLockDTO param) {
// 总开关关闭:只应答不处理(返回 0 与「未匹配」同构,金蝶侧不会触发重试)
if (!bfiProperties.getBillLock().isEnabled()) {
log.warn("金蝶单据锁定总开关关闭,跳过解锁回调处理:operationType={},targetBillNo={}",
param == null ? null : param.getOperationType(), param == null ? null : param.getTargetBillNo());
log.warn("金蝶单据锁定总开关关闭,跳过解锁回调处理:operationType={},金蝶单号={}",
param == null ? null : param.getOperationType(), param == null ? null : param.getSourceBillNo());
return 0;
}
if (param == null || !KingdeeLockConstant.SYSTEM_XH.equals(param.getSourceSystem())
@@ -161,35 +163,41 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl<KingdeeBillLockMappe
|| !KingdeeLockConstant.ACTION_UNLOCK.equals(param.getLockAction())) {
throw new ServiceException("金蝶解锁回调参数非法:sourceSystem须为XH、targetSystem须为WLTMS、lockAction须为UNLOCK");
}
if (Func.isBlank(param.getTargetBillNo())) {
throw new ServiceException("金蝶解锁回调参数非法:targetBillNo不能为空");
if (Func.isBlank(param.getSourceBillNo()) || Func.isBlank(param.getTargetBillNo())) {
throw new ServiceException("金蝶解锁回调参数非法:sourceBillNo、targetBillNo不能为空");
}
// 批量回调:targetBillNo 逗号拼接,逐张处理;未匹配锁定中记录的幂等跳过(重复通知防金蝶重试风暴)
int processed = 0;
for (String targetBillNo : Func.toStrList(param.getTargetBillNo())) {
KingdeeBillLock lockRecord = this.getOne(Wrappers.<KingdeeBillLock>lambdaQuery()
.eq(KingdeeBillLock::getTargetBillType, param.getTargetBillType())
.eq(KingdeeBillLock::getTargetBillNo, targetBillNo)
.orderByDesc(KingdeeBillLock::getId)
.last("limit 1"), false);
// 回调方向与锁定相反:回调 sourceBillNo=金蝶单号(即锁记录 targetBillNo)、targetBillNo=WLTMS 单号(即锁记录 sourceBillNo)、
// targetBillType 可能为空,故按金蝶单号+我方单号双条件反查;未匹配锁定中记录的幂等跳过(重复通知防金蝶重试风暴)
// 一次 IN 查询取回候选记录、内存取各单号最新一条,批量更新解锁状态,避免逐张查询/更新的 N+1
List<String> kingdeeBillNos = Func.toStrList(param.getSourceBillNo());
Map<String, KingdeeBillLock> latestLockMap = this.list(Wrappers.<KingdeeBillLock>lambdaQuery()
.in(KingdeeBillLock::getTargetBillNo, kingdeeBillNos)
.eq(KingdeeBillLock::getSourceBillNo, param.getTargetBillNo())
.orderByDesc(KingdeeBillLock::getId))
.stream()
.collect(Collectors.toMap(KingdeeBillLock::getTargetBillNo, Function.identity(), (first, second) -> first));
List<KingdeeBillLock> unlockRecords = new ArrayList<>();
for (String kingdeeBillNo : kingdeeBillNos) {
KingdeeBillLock lockRecord = latestLockMap.get(kingdeeBillNo);
if (lockRecord == null || !KingdeeLockConstant.STATUS_LOCKED.equals(lockRecord.getLockStatus())) {
log.warn("金蝶解锁回调未匹配锁定中的锁记录(幂等跳过):targetBillType={},targetBillNo={}",
param.getTargetBillType(), targetBillNo);
log.warn("金蝶解锁回调未匹配锁定中的锁记录(幂等跳过):金蝶单号={},回调targetBillNo={}",
kingdeeBillNo, param.getTargetBillNo());
continue;
}
lockRecord.setLockStatus(KingdeeLockConstant.STATUS_UNLOCKED);
lockRecord.setOperationType(param.getOperationType());
lockRecord.setUnlockTime(LocalDateTime.now());
lockRecord.setNotifyOperator(param.getOperator());
this.updateById(lockRecord);
processed++;
unlockRecords.add(lockRecord);
log.info("金蝶单据[{}:{}]解锁回调落库:operationType={},操作人={}",
lockRecord.getSourceBillType(), lockRecord.getSourceBillNo(), param.getOperationType(), param.getOperator());
if (KingdeeLockConstant.OPERATION_DELETE.equals(param.getOperationType())) {
this.restoreOnKingdeeDelete(lockRecord, param.getOperator());
}
}
return processed;
this.updateBatchById(unlockRecords);
// DELETE 分发废除还原:按源单逐张执行(各单据类型还原动作为独立业务事务,无法合并)
if (KingdeeLockConstant.OPERATION_DELETE.equals(param.getOperationType())) {
unlockRecords.forEach(lockRecord -> this.restoreOnKingdeeDelete(lockRecord, param.getOperator()));
}
return unlockRecords.size();
}
/**
@@ -243,6 +251,28 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl<KingdeeBillLockMappe
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public int unlockByAdjustRepush(String sourceBillType, Long sourceBillId, String operator) {
List<KingdeeBillLock> lockedRecords = this.list(Wrappers.<KingdeeBillLock>lambdaQuery()
.eq(KingdeeBillLock::getSourceBillType, sourceBillType)
.eq(KingdeeBillLock::getSourceBillId, sourceBillId)
.eq(KingdeeBillLock::getLockStatus, KingdeeLockConstant.STATUS_LOCKED));
if (Func.isEmpty(lockedRecords)) {
return 0;
}
LocalDateTime unlockTime = LocalDateTime.now();
for (KingdeeBillLock lockRecord : lockedRecords) {
lockRecord.setLockStatus(KingdeeLockConstant.STATUS_UNLOCKED);
lockRecord.setOperationType(KingdeeLockConstant.OPERATION_ADJUST_REPUSH);
lockRecord.setUnlockTime(unlockTime);
lockRecord.setNotifyOperator(Func.isEmpty(operator) ? "system" : operator);
}
this.updateBatchById(lockedRecords);
log.info("结算调整重推解锁旧锁:源单[{}:{}],解锁{}张,操作人={}", sourceBillType, sourceBillId, lockedRecords.size(), operator);
return lockedRecords.size();
}
@Override
public void checkEditable(String sourceBillType, Long sourceBillId) {
long lockedCount = this.count(Wrappers.<KingdeeBillLock>lambdaQuery()
@@ -268,6 +298,47 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl<KingdeeBillLockMappe
return lockRecords.stream().collect(Collectors.toMap(KingdeeBillLock::getSourceBillId, Function.identity(), (first, second) -> second));
}
@Override
public Map<Long, KingdeeBillLock> mapUnlockedLocks(String sourceBillType, List<Long> sourceBillIds) {
if (Func.isEmpty(sourceBillIds)) {
return Map.of();
}
List<KingdeeBillLock> lockRecords = this.list(Wrappers.<KingdeeBillLock>lambdaQuery()
.eq(KingdeeBillLock::getSourceBillType, sourceBillType)
.in(KingdeeBillLock::getSourceBillId, sourceBillIds)
.eq(KingdeeBillLock::getLockStatus, KingdeeLockConstant.STATUS_UNLOCKED)
.orderByDesc(KingdeeBillLock::getId));
// 降序取回后首个生效:每源单保留最新一条解锁留痕
return lockRecords.stream().collect(Collectors.toMap(KingdeeBillLock::getSourceBillId, Function.identity(), (first, second) -> first));
}
@Override
public <T extends BaseEntity & KingdeeUnlockMark> void fillUnlockMarks(String sourceBillType, List<T> rows, Map<Long, KingdeeBillLock> activeLockMap) {
if (Func.isEmpty(rows)) {
return;
}
List<Long> unlockedIds = rows.stream()
.filter(row -> !activeLockMap.containsKey(row.getId()))
.map(BaseEntity::getId).toList();
if (Func.isEmpty(unlockedIds)) {
return;
}
Map<Long, KingdeeBillLock> unlockedMap = this.mapUnlockedLocks(sourceBillType, unlockedIds);
for (T row : rows) {
KingdeeBillLock unlockRecord = unlockedMap.get(row.getId());
if (unlockRecord == null) {
continue;
}
row.setKingdeeUnlockTime(unlockRecord.getUnlockTime());
row.setKingdeeUnlockOperator(unlockRecord.getNotifyOperator());
row.setKingdeeUnlockOperationName(switch (Func.toStr(unlockRecord.getOperationType())) {
case KingdeeLockConstant.OPERATION_DELETE -> "金蝶删单";
case KingdeeLockConstant.OPERATION_ADJUST_REPUSH -> "结算调整重推";
default -> "金蝶锁单";
});
}
}
/**
* 锁定补偿:扫 failed 且 try_count 未达重试上限(RETRY_MAX_COUNT)的锁记录,逐张重调 lockBusiData
* (接口 1对1:单次一个源单号对一个金蝶单号,单张成败互不影响,由定时任务定时触发,亦可人工触发)。
@@ -28,7 +28,10 @@ import org.springblade.transport.pojo.vo.LoadingCarrierContractVO;
import org.springblade.transport.pojo.vo.LoadingManageVO;
import org.springblade.transport.service.IContractManageService;
import org.springblade.transport.service.ILoadingManageService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.IReceivablePayableDetailService;
import org.springblade.transport.support.LiveProcessConfigSupport;
import org.springblade.transport.support.LoadableAdmissionSupport;
import org.springblade.transport.support.TransportBusinessSupport;
import org.springblade.transport.support.WaybillProcessSupport;
import org.springblade.transport.wrapper.LoadingManageWrapper;
@@ -43,8 +46,10 @@ import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@@ -76,6 +81,9 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
@Lazy
private IReceivablePayableDetailService receivablePayableDetailService;
@Resource
private IProcessConfigService processConfigService;
@Override
public IPage<LoadingManageVO> selectLoadingManagePage(IPage<LoadingManage> page, LoadingManageVO loadingManage) {
IPage<LoadingManage> entityPage = page(page, buildQuery(loadingManage));
@@ -147,9 +155,12 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
prepare(loadingManage);
validate(loadingManage, false);
List<Long> oldWaybillIds = new ArrayList<>();
boolean ownChildrenAdmitted = false;
if (Func.isNotEmpty(loadingManage.getId())) {
LoadingManage oldRecord = loadEditable(loadingManage.getId(), true);
oldWaybillIds = waybillIds(oldRecord.getWaybillIdsJson());
// 草稿只预占运单号、未过判据,草稿转正式不得沿用本单豁免
ownChildrenAdmitted = !STATUS_DRAFT.equals(oldRecord.getBusinessStatus());
loadingManage.setLoadingNo(oldRecord.getLoadingNo());
loadingManage.setDeptId(oldRecord.getDeptId());
loadingManage.setDeptName(oldRecord.getDeptName());
@@ -160,10 +171,10 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
if (Func.isEmpty(loadingManage.getLoadingNo())) {
loadingManage.setLoadingNo(nextCode());
}
validateWaybillsAvailable(loadingManage, waybillIds(loadingManage.getWaybillIdsJson()));
validateCarrierContract(loadingManage, true);
List<Waybill> waybills = validateWaybillsAvailable(loadingManage, waybillIds(loadingManage.getWaybillIdsJson()), ownChildrenAdmitted);
validateCarrierContract(loadingManage, true, waybills);
boolean result = saveOrUpdate(loadingManage);
syncAssociatedWaybills(loadingManage, STATUS_PENDING, oldWaybillIds);
writeWaybillsForDriver(loadingManage, oldWaybillIds, waybills);
return result;
}
@@ -409,11 +420,10 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
loadingManage.setDeptId(oldRecord.getDeptId());
loadingManage.setDeptName(oldRecord.getDeptName());
loadingManage.setBusinessStatus(STATUS_PENDING);
validateWaybillsAvailable(loadingManage, waybillIds(loadingManage.getWaybillIdsJson()));
validateCarrierContract(loadingManage, true);
List<Waybill> waybills = validateWaybillsAvailable(loadingManage, waybillIds(loadingManage.getWaybillIdsJson()), true);
validateCarrierContract(loadingManage, true, waybills);
boolean result = updateById(loadingManage);
syncAssociatedWaybills(loadingManage, STATUS_PENDING, oldWaybillIds);
clearAssociatedWaybillAcceptRecords(loadingManage);
writeWaybillsForDriver(loadingManage, oldWaybillIds, waybills);
return result;
}
@@ -735,29 +745,64 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
TransportBusinessSupport.validateLength(loadingManage.getTaskRemark(), 500, "任务备注不能超过500字");
}
private void validateWaybillsAvailable(LoadingManage loadingManage, List<Long> waybillIds) {
private List<Waybill> validateWaybillsAvailable(LoadingManage loadingManage, List<Long> waybillIds, boolean ownChildrenAdmitted) {
if (Func.isEmpty(waybillIds)) {
throw new ServiceException("请选择待配载运单");
}
List<Waybill> waybills = waybillMapper.selectList(Wrappers.<Waybill>lambdaQuery()
.eq(Waybill::getIsDeleted, 0)
.in(Waybill::getId, waybillIds));
List<Waybill> waybills = loadWaybills(waybillIds);
if (waybills.size() != waybillIds.size()) {
throw new ServiceException("待配载运单不存在或已删除");
}
String ownLoadingNo = loadingManage.getLoadingNo();
for (Waybill waybill : waybills) {
TransportBusinessSupport.assertCurrentDept(waybill.getDeptId(), "运单管理");
if (Func.isNotEmpty(waybill.getLoadingNo()) && !Objects.equals(waybill.getLoadingNo(), loadingManage.getLoadingNo())) {
throw new ServiceException("运单" + waybill.getWaybillNo() + "已关联其他配载单");
if (ownChildrenAdmitted && LoadableAdmissionSupport.isOwnChild(waybill, ownLoadingNo)) {
continue;
}
if ((Objects.equals(waybill.getBusinessStatus(), STATUS_COMPLETED) || Objects.equals(waybill.getBusinessStatus(), STATUS_CANCELLED))
&& !Objects.equals(waybill.getLoadingNo(), loadingManage.getLoadingNo())) {
throw new ServiceException("运单" + waybill.getWaybillNo() + "当前状态不允许配载");
LoadableAdmissionSupport.UnloadableReason reason =
LoadableAdmissionSupport.unloadableReason(waybill, ownLoadingNo);
if (reason != null) {
throw new ServiceException(unloadableMessage(waybill, reason));
}
}
validateSameProject(waybills);
return waybills;
}
/**
* 逐张准入失败的可读原因:判据只给原因枚举,运单号与文案在这里拼。
*/
private String unloadableMessage(Waybill waybill, LoadableAdmissionSupport.UnloadableReason reason) {
String prefix = "运单" + waybill.getWaybillNo();
return prefix + switch (reason) {
case NOT_ROAD -> "运输方式不是公路,不可配载";
case NOT_PENDING -> "当前状态不是待执行,不可配载";
case ON_OTHER_MANIFEST -> "已关联其他配载单";
};
}
/**
* 一张配载单的全部子运单必须属于同一个项目,否则点名与其它子运单项目不同的运单。
* <p>
* 查询条件表达不了跨行约束,只能整批取回后在内存里比。
*/
private void validateSameProject(List<Waybill> waybills) {
Long projectId = waybills.get(0).getProjectId();
List<String> others = waybills.stream()
.filter(waybill -> !Objects.equals(waybill.getProjectId(), projectId))
.map(Waybill::getWaybillNo)
.toList();
if (Func.isNotEmpty(others)) {
throw new ServiceException("配载单的子运单必须属于同一项目,运单"
+ String.join("、", others) + "与其它子运单不属于同一项目");
}
}
private void validateCarrierContract(LoadingManage loadingManage, boolean required) {
validateCarrierContract(loadingManage, required, null);
}
private void validateCarrierContract(LoadingManage loadingManage, boolean required, List<Waybill> waybills) {
if (!Objects.equals(loadingManage.getCarrierType(), "承运商")) {
loadingManage.setCarrierContractId(null);
if (!Objects.equals(loadingManage.getCarrierType(), CARRIER_SELF)) {
@@ -772,10 +817,24 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
loadingManage.setCarrierName(null);
return;
}
ContractManage carrierContract = availableCarrierContracts(projectIdsByWaybills(loadingManage)).stream()
.filter(contract -> Objects.equals(contract.getId(), loadingManage.getCarrierContractId()))
.findFirst()
.orElseThrow(() -> new ServiceException("所选承运商合同不存在、未审核通过或已失效"));
ContractManage carrierContract = availableCarrierContractById(loadingManage.getCarrierContractId());
if (carrierContract == null) {
throw new ServiceException("所选承运商合同不存在、未审核通过或已失效");
}
List<Waybill> childWaybills = waybills == null
? loadWaybills(waybillIds(loadingManage.getWaybillIdsJson()))
: waybills;
// 合同自带项目与乙方:合同的项目不属于本单子运单的项目,等于换了项目却没换乙方
if (!projectIdsOf(childWaybills).contains(carrierContract.getProjectId())) {
String waybillNos = childWaybills.stream()
.filter(waybill -> !Objects.equals(waybill.getProjectId(), carrierContract.getProjectId()))
.map(Waybill::getWaybillNo)
.filter(Func::isNotEmpty)
.collect(Collectors.joining("、"));
throw new ServiceException(Func.isEmpty(waybillNos)
? "所选承运商合同不属于本项目"
: "所选承运商合同不属于运单" + waybillNos + "的项目");
}
loadingManage.setCarrierName(carrierContract.getPartyB());
}
@@ -792,34 +851,98 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
}
}
private List<Long> projectIdsByWaybills(LoadingManage loadingManage) {
List<Long> waybillIds = waybillIds(loadingManage.getWaybillIdsJson());
private List<Waybill> loadWaybills(List<Long> waybillIds) {
if (Func.isEmpty(waybillIds)) {
return List.of();
}
return waybillMapper.selectList(Wrappers.<Waybill>lambdaQuery()
.select(Waybill::getProjectId)
.eq(Waybill::getIsDeleted, 0)
.in(Waybill::getId, waybillIds))
.stream().map(Waybill::getProjectId).filter(Objects::nonNull).distinct().toList();
.in(Waybill::getId, waybillIds));
}
private List<Long> projectIdsOf(List<Waybill> waybills) {
return waybills.stream().map(Waybill::getProjectId).filter(Objects::nonNull).distinct().toList();
}
private List<ContractManage> availableCarrierContracts(List<Long> projectIds) {
if (Func.isEmpty(projectIds)) {
return List.of();
}
return contractManageService.list(Wrappers.<ContractManage>lambdaQuery()
.eq(ContractManage::getIsDeleted, 0)
.eq(ContractManage::getStatus, 1)
.eq(ContractManage::getContractCategory, "承运商合同")
return contractManageService.list(availableCarrierContractQuery()
.in(ContractManage::getProjectId, projectIds)
.in(ContractManage::getApprovalStatus, "approved", "change_approved")
.and(wrapper -> wrapper.isNull(ContractManage::getContractStage)
.or().ne(ContractManage::getContractStage, "terminated"))
.orderByDesc(ContractManage::getCreateTime));
}
/**
* 按主键取一张可用的承运商合同。
* <p>
* 不按项目过滤,才能把「合同不可用」与「合同不属于本项目」分成两种提示。
* 取不到可用的合同返回 null。
*/
private ContractManage availableCarrierContractById(Long contractId) {
if (Func.isEmpty(contractId)) {
return null;
}
return contractManageService.list(availableCarrierContractQuery()
.eq(ContractManage::getId, contractId)
.orderByDesc(ContractManage::getCreateTime))
.stream().findFirst().orElse(null);
}
private LambdaQueryWrapper<ContractManage> availableCarrierContractQuery() {
return Wrappers.<ContractManage>lambdaQuery()
.eq(ContractManage::getIsDeleted, 0)
.eq(ContractManage::getStatus, 1)
.eq(ContractManage::getContractCategory, "承运商合同")
.in(ContractManage::getApprovalStatus, "approved", "change_approved")
.and(wrapper -> wrapper.isNull(ContractManage::getContractStage)
.or().ne(ContractManage::getContractStage, "terminated"));
}
/**
* 配载提交与重新派单共用:子运单状态逐张按该票自己项目的实时过程配置推导后落库,再清除接单事实。
* <p>
* 推导按「接单事实已清、尚未接单」的口径取值,故清接单事实放在状态写入之后,两者口径一致。
*/
private void writeWaybillsForDriver(LoadingManage loadingManage, List<Long> oldWaybillIds, List<Waybill> waybills) {
syncAssociatedWaybills(loadingManage, deriveWaybillBusinessStatuses(waybills), oldWaybillIds);
// 接单事实不清,被拒的票对新司机不可见——司机端一切入口都排除已拒绝的运单
clearAssociatedWaybillAcceptRecords(loadingManage);
}
/**
* 子运单业务状态逐张按各自项目的实时过程配置推导:需要接单 → 待执行,不需要 → 进行中。
* <p>
* 配置取法与运单读路径同一处:项目生效配置优先,取不到时才回退该运单的过程配置快照;
* 快照只作兜底、永不优先,否则项目改配置后写入值与读时的归一化结果会分叉。
* 项目为空且快照也为空 → 视为不需要接单 → 进行中。
*/
private Map<Long, String> deriveWaybillBusinessStatuses(List<Waybill> waybills) {
Map<Long, String> businessStatusByWaybillId = new LinkedHashMap<>();
if (Func.isEmpty(waybills)) {
return businessStatusByWaybillId;
}
Map<Long, String> liveConfigByProject = LiveProcessConfigSupport.byProjects(processConfigService,
waybills.stream().map(Waybill::getProjectId).toList());
for (Waybill waybill : waybills) {
String processJson = LiveProcessConfigSupport.resolve(
liveConfigByProject.get(waybill.getProjectId()), waybill.getProcessJson());
// 以「待执行 + 待接单」为输入走读路径同一个归一化:需要接单留下待执行,不需要接单提升为进行中
businessStatusByWaybillId.put(waybill.getId(), WaybillProcessSupport.normalizeBusinessStatus(
STATUS_PENDING, processJson, WaybillProcessSupport.ACCEPT_PENDING));
}
return businessStatusByWaybillId;
}
private void syncAssociatedWaybills(LoadingManage loadingManage, String status, List<Long> oldWaybillIds) {
Map<Long, String> statusByWaybillId = new LinkedHashMap<>();
for (Long waybillId : waybillIds(loadingManage.getWaybillIdsJson())) {
statusByWaybillId.put(waybillId, status);
}
syncAssociatedWaybills(loadingManage, statusByWaybillId, oldWaybillIds);
}
private void syncAssociatedWaybills(LoadingManage loadingManage, Map<Long, String> statusByWaybillId, List<Long> oldWaybillIds) {
List<Long> newWaybillIds = waybillIds(loadingManage.getWaybillIdsJson());
if (Func.isNotEmpty(oldWaybillIds)) {
List<Long> removedIds = new ArrayList<>(oldWaybillIds);
@@ -829,8 +952,18 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
if (Func.isEmpty(newWaybillIds)) {
return;
}
Map<String, List<Long>> waybillIdsByStatus = new LinkedHashMap<>();
for (Long waybillId : newWaybillIds) {
waybillIdsByStatus
.computeIfAbsent(statusByWaybillId.getOrDefault(waybillId, STATUS_PENDING), key -> new ArrayList<>())
.add(waybillId);
}
waybillIdsByStatus.forEach((status, ids) -> updateAssociatedWaybillFields(loadingManage, ids, status));
}
private void updateAssociatedWaybillFields(LoadingManage loadingManage, List<Long> waybillIds, String status) {
waybillMapper.update(null, Wrappers.<Waybill>lambdaUpdate()
.in(Waybill::getId, newWaybillIds)
.in(Waybill::getId, waybillIds)
.set(Waybill::getLoadingNo, loadingManage.getLoadingNo())
.set(Waybill::getBusinessStatus, status)
.set(Waybill::getCarrierType, loadingManage.getCarrierType())
@@ -23,16 +23,19 @@
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.utils.DateUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.UserDisplayCache;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.RiskDisposal;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.AdminDriverOptionVO;
@@ -44,12 +47,21 @@ import org.springblade.transport.pojo.vo.AdminVehicleOptionVO;
import org.springblade.transport.pojo.vo.AdminWaybillCardVO;
import org.springblade.transport.pojo.vo.AdminWaybillDetailVO;
import org.springblade.transport.pojo.vo.DriverWaybillCardVO;
import org.springblade.transport.pojo.vo.TripQueryVO;
import org.springblade.transport.pojo.vo.TripStationVO;
import org.springblade.transport.pojo.vo.TripTabCountsVO;
import org.springblade.transport.pojo.vo.TripVO;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IDriverWaybillService;
import org.springblade.transport.service.IExceptionDisposalService;
import org.springblade.transport.service.ILoadingManageService;
import org.springblade.transport.service.IManageWaybillService;
import org.springblade.transport.service.IRiskDisposalService;
import org.springblade.transport.service.ITripQueryService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.TransportBusinessSupport;
import org.springblade.transport.support.WaybillCargoSupport;
import org.springblade.transport.support.WaybillProcessSupport;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@@ -58,16 +70,24 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 调度端:运单状态统计 + 异常/风险角标 + 待处理事项 + 运单列表
* 调度端:运次状态统计 + 异常/风险角标 + 待处理事项 + 运次列表
* <p>
* 列表与详情与司机端同粒度:取数、状态聚合、接单需求合并都调共享运次读服务,
* 本类只做字段裁剪与调度端独有的筛选(组织类型 / 异常 / 创建时间 / 待重新派单)。
* <p>
* 小程序调度账号(如「小程序管理」)组织常与运单业务组织不一致,故不做 dept 过滤,
* 仅依赖租户隔离,口径接近后台 {@code /waybill-manage/list?allDept=1}。
@@ -102,6 +122,11 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
private static final int DEFAULT_PAGE_SIZE = 10;
private static final int MAX_PAGE_SIZE = 50;
/** 待重新派单的排序键:运次内子单被拒的时刻取最大值 */
private static final String SQL_MAX_REJECT_TIME = "MAX(driver_reject_time)";
/** 待重新派单的排序键:运次内子单创建时刻取最大值(与运次列表同一排序口径) */
private static final String SQL_MAX_CREATE_TIME = "MAX(create_time)";
private static final DateTimeFormatter DATE_MD = DateTimeFormatter.ofPattern("MM-dd");
private static final DateTimeFormatter DATE_YMD = DateTimeFormatter.ofPattern("yyyy-MM-dd");
@@ -110,13 +135,19 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
private final IExceptionDisposalService exceptionDisposalService;
private final IRiskDisposalService riskDisposalService;
private final IDriverWaybillService driverWaybillService;
private final ITripQueryService tripQueryService;
private final ILoadingManageService loadingManageService;
@Override
public AdminHomeStatsVO stats() {
AdminHomeStatsVO vo = new AdminHomeStatsVO();
vo.setPendingAccept(countWaybillByStatus(STATUS_PENDING));
vo.setTransporting(countWaybillByStatus(STATUS_RUNNING));
vo.setCompleted(countWaybillByStatus(STATUS_COMPLETED));
// 三档状态按运次计:有配载单的一趟车算一个,与列表和司机端同一份聚合
TripQueryVO query = TripQueryVO.forAdmin();
query.setDeptIds(manageableDeptIds());
TripTabCountsVO tripCounts = tripQueryService.tabCounts(query);
vo.setPendingAccept(tripCounts.getPending());
vo.setTransporting(tripCounts.getDoing());
vo.setCompleted(tripCounts.getDone());
vo.setException(countIncompleteExceptions());
return vo;
}
@@ -144,57 +175,46 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
Set<Long> exceptionWaybillIds = loadIncompleteExceptionWaybillIds();
if (EXCEPTION_YES.equals(exception) && exceptionWaybillIds.isEmpty()) {
Page<AdminWaybillCardVO> emptyVo = new Page<>(pageNo, pageSize, 0);
emptyVo.setRecords(List.of());
return emptyVo;
return emptyCardPage(pageNo, pageSize);
}
LambdaQueryWrapper<Waybill> wrapper = scopedWaybillQuery();
applyStatusFilter(wrapper, status);
applyExceptionFilter(wrapper, exception, exceptionWaybillIds);
applyTransportTypeFilter(wrapper, transportType);
applyKeywordFilter(wrapper, keyword);
applyCreateTimeFilter(wrapper, startDate, endDate);
wrapper.orderByDesc(Waybill::getCreateTime);
IPage<Waybill> entityPage = waybillService.page(new Page<>(pageNo, pageSize), wrapper);
List<Long> pageIds = entityPage.getRecords().stream()
.map(Waybill::getId)
.filter(Objects::nonNull)
.toList();
Set<Long> pageExceptionIds = pageIds.isEmpty()
? Collections.emptySet()
: exceptionWaybillIds.stream().filter(pageIds::contains).collect(Collectors.toSet());
Page<AdminWaybillCardVO> voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
voPage.setRecords(entityPage.getRecords().stream()
.map(w -> toCard(w, pageExceptionIds.contains(w.getId())))
.toList());
return voPage;
// 组织类型 / 异常 / 创建时间都是子单行级条件,与状态、关键字、分页一起交给运次读服务,
// 一次取数取完整趟车:分组、分档、筛选、排序与分页都在它那边算完
TripQueryVO query = TripQueryVO.forAdmin();
query.setDeptIds(manageableDeptIds());
query.setStatus(toTripStatus(status));
query.setKeyword(keyword);
query.setTransportOrgType(transportType);
query.setException(exception);
query.setExceptionWaybillIds(exceptionWaybillIds);
query.setCreatedFrom(parseDayStart(startDate));
query.setCreatedTo(parseDayEnd(endDate));
query.setCurrent(pageNo);
query.setSize(pageSize);
IPage<TripVO> trips = tripQueryService.page(query);
return tripCardPage(trips.getRecords(), pageNo, pageSize, trips.getTotal(), exceptionWaybillIds);
}
@Override
public AdminWaybillDetailVO detail(Long id) {
public AdminWaybillDetailVO detail(String tripId) {
Long id = parseId(tripId);
if (id == null) {
throw new org.springblade.core.log.exception.ServiceException("运单ID不能为空");
throw new ServiceException("运次不存在或无权操作");
}
TripVO trip = visibleTrip(tripId);
if (trip != null) {
return tripDetail(trip);
}
// 已拒单的子运单按运次取不到(运次口径不含已拒子单),此时按运单主键回落,供重新派单等从运单进入的场景使用
Waybill waybill = waybillService.getById(id);
if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) {
throw new org.springblade.core.log.exception.ServiceException("运单不存在");
if (waybill != null && !Objects.equals(waybill.getIsDeleted(), 1)) {
return waybillDetail(waybill);
}
boolean hasException = false;
Long exceptionId = null;
ExceptionDisposal latest = exceptionDisposalService.getOne(Wrappers.<ExceptionDisposal>lambdaQuery()
.eq(ExceptionDisposal::getWaybillId, id)
.in(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING, DISPOSAL_PROCESSING)
.orderByDesc(ExceptionDisposal::getReportTime)
.orderByDesc(ExceptionDisposal::getCreateTime)
.last("LIMIT 1"));
if (latest != null) {
hasException = true;
exceptionId = latest.getId();
LoadingManage loading = loadingManageService.getById(id);
if (loading == null) {
throw new ServiceException("运次不存在或无权操作");
}
return toDetail(waybill, hasException, exceptionId);
return loadingDetail(loading, visibleChildren(loading.getLoadingNo()));
}
@Override
@@ -202,32 +222,521 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
int pageNo = current == null || current < 1 ? 1 : current;
int pageSize = size == null || size < 1 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE);
LambdaQueryWrapper<Waybill> wrapper = scopedWaybillQuery()
.in(Waybill::getBusinessStatus, STATUS_PENDING, STATUS_RUNNING);
if (Boolean.TRUE.equals(needReassign)) {
wrapper.eq(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED);
} else if (Boolean.FALSE.equals(needReassign)) {
wrapper.and(w -> w.isNull(Waybill::getDriverAcceptStatus)
.or().ne(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED));
// 待重新派单必须按「子单被拒」找:运次口径把已拒子单排除在外,整单拒绝后这趟车在运次里已经不存在
return reassignPendingPage(pageNo, pageSize, keyword);
}
applyKeywordFilter(wrapper, keyword);
wrapper.orderByDesc(Waybill::getUpdateTime).orderByDesc(Waybill::getCreateTime);
IPage<Waybill> entityPage = waybillService.page(new Page<>(pageNo, pageSize), wrapper);
Set<Long> exceptionWaybillIds = loadIncompleteExceptionWaybillIds();
List<Long> pageIds = entityPage.getRecords().stream()
.map(Waybill::getId)
.filter(Objects::nonNull)
.toList();
Set<Long> pageExceptionIds = pageIds.isEmpty()
? Collections.emptySet()
: exceptionWaybillIds.stream().filter(pageIds::contains).collect(Collectors.toSet());
TripQueryVO query = TripQueryVO.forAdmin();
query.setDeptIds(manageableDeptIds());
query.setKeyword(keyword);
// 待处理只要待接单与进行中两档;「未拒单」也是交给运次读服务按子单行筛
query.setStatuses(List.of(0, 1));
query.setExcludeRejectedTrips(Boolean.FALSE.equals(needReassign));
query.setCurrent(pageNo);
query.setSize(pageSize);
IPage<TripVO> trips = tripQueryService.page(query);
return tripCardPage(trips.getRecords(), pageNo, pageSize, trips.getTotal(), exceptionWaybillIds);
}
Page<AdminWaybillCardVO> voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
voPage.setRecords(entityPage.getRecords().stream()
.map(w -> toCard(w, pageExceptionIds.contains(w.getId())))
.toList());
return voPage;
private Page<AdminWaybillCardVO> emptyCardPage(int pageNo, int pageSize) {
Page<AdminWaybillCardVO> page = new Page<>(pageNo, pageSize, 0);
page.setRecords(List.of());
return page;
}
/**
* 运次卡片页:一次算清这一页的重派标记与异常,再逐条拼卡片
*/
private Page<AdminWaybillCardVO> tripCardPage(List<TripVO> trips, int pageNo, int pageSize, long total,
Set<Long> exceptionWaybillIds) {
List<AdminWaybillCardVO> cards = new ArrayList<>(trips.size());
if (!trips.isEmpty()) {
Set<String> reassignKeys = loadReassignTripKeys(
trips.stream().map(this::tripKeyOf).collect(Collectors.toCollection(LinkedHashSet::new)));
for (TripVO trip : trips) {
cards.add(toTripCard(trip, exceptionWaybillIds, reassignKeys));
}
}
Page<AdminWaybillCardVO> page = new Page<>(pageNo, pageSize, total);
page.setRecords(cards);
return page;
}
/**
* 运次卡片:展示单号、整趟车起/经/终与站点数、货物汇总、计划时间都取运次字段。
* 旧字段 id / waybillNo 保留运单语义(未配载就是这张运单,配载取第一张可见子运单),
* 打卡订正、重新派单等按运单进入的入口仍靠它们。
*/
private AdminWaybillCardVO toTripCard(TripVO trip, Set<Long> exceptionWaybillIds,
Set<String> reassignTripIds) {
AdminWaybillCardVO card = new AdminWaybillCardVO();
List<TripVO.TripWaybillVO> children = trip.getWaybills() == null ? List.of() : trip.getWaybills();
Long firstChildId = children.isEmpty() ? null : parseId(children.get(0).getId());
boolean loaded = Func.isNotEmpty(trip.getLoadingNo());
card.setTripId(trip.getId());
card.setTripNo(Func.toStr(trip.getTripNo(), ""));
card.setLoadingNo(Func.toStr(trip.getLoadingNo(), ""));
card.setId(firstChildId == null ? parseId(trip.getId()) : firstChildId);
card.setWaybillNo(children.isEmpty() ? card.getTripNo() : Func.toStr(children.get(0).getWaybillNo(), ""));
card.setWaybillCount(trip.getWaybillCount());
card.setTransportMode(Func.toStr(trip.getTransportType(), ""));
card.setTransportType(loaded ? TRANSPORT_LOAD : TRANSPORT_COMMON);
card.setFromName(Func.toStrWithEmpty(trip.getDepartureName(), Func.toStr(trip.getDepartureAddress(), "")));
card.setToName(Func.toStrWithEmpty(trip.getArrivalName(), Func.toStr(trip.getArrivalAddress(), "")));
card.setViaName(Func.toStr(trip.getTransitName(), ""));
card.setStationCount(trip.getStationCount());
card.setCargo(Func.toStr(trip.getCargoSummary(), ""));
card.setCargoSummary(Func.toStr(trip.getCargoSummary(), ""));
String weight = formatWeight(trip.getTotalQuantity(), trip.getQuantityUnit());
card.setWeight(weight);
card.setTotalWeight(weight);
// 计划时间两条路同源:配载运次取配载单,未配载运次取那张运单自己的
card.setPlanTime(formatLocalDate(trip.getEstimatedStartDate()));
card.setPlanTimeEnd(formatLocalDate(trip.getEstimatedEndDate()));
card.setStatus(trip.getStatus());
card.setDriverName(Func.toStr(trip.getDriverName(), ""));
card.setVehicleNo(Func.toStr(trip.getVehicleNo(), ""));
card.setCreateTime(formatDateTime(trip.getSortTime()));
card.setNeedReassign(reassignTripIds.contains(tripKeyOf(trip)));
card.setHasException(hasChildException(children.stream().map(TripVO.TripWaybillVO::getId).toList(), exceptionWaybillIds));
card.setBuyerPaid(false);
return card;
}
/** 这一页里带「子单被拒」标记的运次主键:判据与重派待办一致(已拒单 + 未取消未完成) */
private Set<String> loadReassignTripKeys(Set<String> tripIds) {
if (Func.isEmpty(tripIds)) {
return Set.of();
}
// 被拒子单一次取回后在服务层换算运次键
return waybillService.list(pendingReassignQuery().select(Waybill::getId, Waybill::getLoadingNo))
.stream()
.map(this::tripKeyOf)
.filter(tripIds::contains)
.collect(Collectors.toSet());
}
private boolean hasRejectedChild(String tripId) {
return Func.isNotEmpty(tripId) && !loadReassignTripKeys(Set.of(tripId)).isEmpty();
}
/** 运次里有没有未完成的异常:异常仍是运单级的事实,落到运次上按「任一子单有」算 */
private boolean hasChildException(Collection<String> childIds, Set<Long> exceptionWaybillIds) {
if (exceptionWaybillIds.isEmpty() || childIds.isEmpty()) {
return false;
}
return childIds.stream().map(this::parseId).filter(Objects::nonNull).anyMatch(exceptionWaybillIds::contains);
}
/** 小程序 status → 运次状态:0待接单 / 1进行中 / 2已完成 */
private Integer toTripStatus(String status) {
if (Func.isBlank(status)) {
return null;
}
return switch (status.trim()) {
case "0", STATUS_PENDING -> 0;
case "1", STATUS_RUNNING, "transporting", "doing" -> 1;
case "2", STATUS_COMPLETED, "done" -> 2;
case "3", STATUS_CANCELLED -> 3;
default -> null;
};
}
private Date parseDayStart(String date) {
if (Func.isBlank(date)) {
return null;
}
return DateUtil.parse(date.trim() + " 00:00:00", DateUtil.PATTERN_DATETIME);
}
private Date parseDayEnd(String date) {
if (Func.isBlank(date)) {
return null;
}
return DateUtil.parse(date.trim() + " 23:59:59", DateUtil.PATTERN_DATETIME);
}
private Long parseId(String value) {
if (Func.isEmpty(value)) {
return null;
}
try {
return Long.valueOf(value.trim());
} catch (NumberFormatException ignored) {
return null;
}
}
/** 运次的键:配载取配载单号,未配载取 W + 运单主键,与运次读服务同一个口径 */
private String tripKeyOf(Waybill waybill) {
return waybill == null ? null : TripAggregation.keyOf(waybill.getLoadingNo(), waybill.getId());
}
/** 运次的键:同上,运次卡片取运次主键换算 */
private String tripKeyOf(TripVO trip) {
return trip == null ? null : TripAggregation.keyOf(trip.getLoadingNo(), parseId(trip.getId()));
}
/* ===========================================================================
* 详情:按运次展示;已拒单等取不到运次的场景回落成运单视图
* =========================================================================*/
/** 按运次主键取可见运次;取不到(已拒子单、不是运次主键)返回空,交给运单级回落 */
private TripVO visibleTrip(String tripId) {
try {
TripQueryVO query = TripQueryVO.forAdmin();
query.setTripId(tripId);
return tripQueryService.detail(query);
} catch (ServiceException ignored) {
return null;
}
}
/**
* 运次详情:与司机端接单页同一口径(单号取配载单号,站点 ①②③ 与每站货物条目都带运单号)。
* 打卡节点取第一张可见子运单:管理端打卡订正仍按运单走,逐子单的打卡视图不在运次这一层。
*/
private AdminWaybillDetailVO tripDetail(TripVO trip) {
List<TripVO.TripWaybillVO> children = trip.getWaybills() == null ? List.of() : trip.getWaybills();
Long firstChildId = children.isEmpty() ? null : parseId(children.get(0).getId());
List<TripStationVO> stations = trip.getStations() == null ? List.of() : trip.getStations();
List<Long> scopeIds = new ArrayList<>();
children.forEach(child -> {
Long childId = parseId(child.getId());
if (childId != null) {
scopeIds.add(childId);
}
});
if (scopeIds.isEmpty() && parseId(trip.getId()) != null) {
scopeIds.add(parseId(trip.getId()));
}
AdminWaybillDetailVO detail = new AdminWaybillDetailVO();
detail.setId(firstChildId == null ? parseId(trip.getId()) : firstChildId);
detail.setWaybillNo(children.isEmpty() ? Func.toStr(trip.getTripNo(), "") : Func.toStr(children.get(0).getWaybillNo(), ""));
detail.setStatus(trip.getStatus());
detail.setTransportMode(Func.toStr(trip.getTransportType(), ""));
detail.setTransportType(toTransportTypeLabel(Func.toStr(trip.getTransportType(), "")));
detail.setTransportOrgType(Func.isEmpty(trip.getLoadingNo()) ? TRANSPORT_COMMON : TRANSPORT_LOAD);
detail.setTripId(trip.getId());
detail.setLoadingNo(Func.toStr(trip.getLoadingNo(), ""));
detail.setTripNo(Func.toStr(trip.getTripNo(), ""));
detail.setViaName(Func.toStr(trip.getTransitName(), ""));
detail.setStationCount(trip.getStationCount());
detail.setCargoSummary(Func.toStr(trip.getCargoSummary(), ""));
String weight = formatWeight(trip.getTotalQuantity(), trip.getQuantityUnit());
detail.setCargoName(Func.toStr(trip.getCargoName(), ""));
detail.setCargoNames(trip.getCargoNames() == null ? List.of() : trip.getCargoNames());
detail.setCargoQuantity(weight);
detail.setWeight(weight);
detail.setTotalWeight(weight);
String fromAddr = Func.toStr(trip.getDepartureAddress(), "");
String toAddr = Func.toStr(trip.getArrivalAddress(), "");
detail.setFromName(Func.toStrWithEmpty(trip.getDepartureName(), fromAddr));
detail.setToName(Func.toStrWithEmpty(trip.getArrivalName(), toAddr));
detail.setFromAddress(Func.toStrWithEmpty(fromAddr, detail.getFromName()));
detail.setToAddress(Func.toStrWithEmpty(toAddr, detail.getToName()));
detail.setPickupAddress(detail.getFromAddress());
detail.setUnloadAddress(detail.getToAddress());
detail.setPlanShipTime(formatLocalDate(trip.getEstimatedStartDate()));
detail.setPlanFinishTime(formatLocalDate(trip.getEstimatedEndDate()));
detail.setCarrierName("");
detail.setDriverName(Func.toStr(trip.getDriverName(), ""));
detail.setDriverPhone(Func.toStr(trip.getDriverPhone(), ""));
detail.setVehicleNo(Func.toStr(trip.getVehicleNo(), ""));
detail.setRemark(Func.toStr(trip.getTaskRemark(), ""));
detail.setNeedReassign(hasRejectedChild(tripKeyOf(trip)));
detail.setAcceptStatus(Func.toStr(trip.getAcceptStatus(), ""));
detail.setRejectReason("");
detail.setStations(stations);
detail.setRoutePoints(toRoutePoints(stations));
fillException(detail, scopeIds);
fillPunchSnapshot(detail, firstChildId);
return detail;
}
/**
* 运单主键回落:重新派单、异常处置等按运单进入的场景仍按运单展示;
* 它属于配载单时补上整趟车的展示字段(配载单号、站点 ①②③、计划时间),与列表和司机端同一口径。
*/
private AdminWaybillDetailVO waybillDetail(Waybill waybill) {
Set<Long> scopeIds = new LinkedHashSet<>();
scopeIds.add(waybill.getId());
AdminWaybillDetailVO detail = toDetail(waybill, false, null);
if (Func.isEmpty(waybill.getLoadingNo())) {
fillException(detail, scopeIds);
return detail;
}
List<Waybill> children = visibleChildren(waybill.getLoadingNo());
children.forEach(child -> scopeIds.add(child.getId()));
LoadingManage loading = findLoading(waybill.getLoadingNo());
if (loading != null) {
// 整趟车的子单都被拒时组里为空,站点退回配载单自己的发货/途经/到货
List<TripStationVO> stations = TripStationAssembler.assemble(loading, children);
detail.setTripId(String.valueOf(loading.getId()));
detail.setLoadingNo(loading.getLoadingNo());
detail.setTripNo(loading.getLoadingNo());
detail.setTransportOrgType(TRANSPORT_LOAD);
detail.setViaName(TripStationAssembler.transitName(stations));
detail.setStationCount(stations.size());
detail.setStations(stations);
detail.setPlanShipTime(formatLocalDate(loading.getEstimatedStartDate()));
detail.setPlanFinishTime(formatLocalDate(loading.getEstimatedEndDate()));
detail.setFromName(Func.toStrWithEmpty(TripStationAssembler.departureName(stations), detail.getFromName()));
detail.setToName(Func.toStrWithEmpty(TripStationAssembler.arrivalName(stations), detail.getToName()));
if (Func.isEmpty(detail.getDriverName())) {
detail.setDriverName(Func.toStr(loading.getDriverName(), ""));
}
if (Func.isEmpty(detail.getVehicleNo())) {
detail.setVehicleNo(Func.toStr(loading.getVehicleNo(), ""));
}
}
fillException(detail, scopeIds);
return detail;
}
/**
* 按配载单主键取整趟车:整单被拒时子单为空,站点与计划时间仍按配载单展示
*/
private AdminWaybillDetailVO loadingDetail(LoadingManage loading, List<Waybill> children) {
List<TripStationVO> stations = TripStationAssembler.assemble(loading, children);
List<Long> scopeIds = children.stream().map(Waybill::getId).filter(Objects::nonNull).toList();
Waybill head = children.isEmpty() ? null : children.get(0);
AdminWaybillDetailVO detail = new AdminWaybillDetailVO();
detail.setId(head == null ? loading.getId() : head.getId());
detail.setWaybillNo(head == null ? loading.getLoadingNo() : Func.toStr(head.getWaybillNo(), loading.getLoadingNo()));
detail.setStatus(TripAggregation.appStatus(tripBusinessStatus(children)));
detail.setTransportMode(Func.toStr(loading.getTransportType(), ""));
detail.setTransportType(toTransportTypeLabel(Func.toStr(loading.getTransportType(), "")));
detail.setTransportOrgType(TRANSPORT_LOAD);
detail.setTripId(String.valueOf(loading.getId()));
detail.setLoadingNo(loading.getLoadingNo());
detail.setTripNo(loading.getLoadingNo());
detail.setViaName(TripStationAssembler.transitName(stations));
detail.setStationCount(stations.size());
detail.setCargoName(Func.toStrWithEmpty(loading.getCargoName(), head == null ? "" : Func.toStr(head.getCargoName(), "")));
String weight = head == null ? "" : formatWeight(head.getQuantity(), head.getQuantityUnit());
detail.setCargoQuantity(weight);
detail.setWeight(weight);
detail.setTotalWeight(weight);
detail.setFromName(TripStationAssembler.departureName(stations));
detail.setToName(TripStationAssembler.arrivalName(stations));
detail.setFromAddress(Func.toStr(loading.getDepartureAddress(), ""));
detail.setToAddress(Func.toStr(loading.getArrivalAddress(), ""));
detail.setPickupAddress(detail.getFromAddress());
detail.setUnloadAddress(detail.getToAddress());
detail.setPlanShipTime(formatLocalDate(loading.getEstimatedStartDate()));
detail.setPlanFinishTime(formatLocalDate(loading.getEstimatedEndDate()));
detail.setCarrierName("");
detail.setDriverName(Func.toStr(loading.getDriverName(), ""));
detail.setDriverPhone(Func.toStr(loading.getDriverPhone(), ""));
detail.setVehicleNo(Func.toStr(loading.getVehicleNo(), ""));
detail.setRemark(Func.toStr(loading.getTaskRemark(), ""));
detail.setNeedReassign(true);
detail.setAcceptStatus(Func.toStr(TripAggregation.acceptStatus(children), ""));
detail.setRejectReason("");
detail.setStations(stations);
detail.setRoutePoints(toRoutePoints(stations));
fillException(detail, scopeIds);
fillPunchSnapshot(detail, head == null ? null : head.getId());
return detail;
}
/** 一组子运单的业务状态:全部终态取已完成/已取消,否则有待执行取待执行、其余进行中 */
private String tripBusinessStatus(List<Waybill> children) {
if (children.isEmpty()) {
return null;
}
boolean requireAccept = TripAggregation.requiresAccept(children);
boolean accepted = WaybillProcessSupport.ACCEPT_ACCEPTED.equals(TripAggregation.acceptStatus(children));
return TripAggregation.statusOf(children, requireAccept, accepted);
}
private List<Waybill> visibleChildren(String loadingNo) {
if (Func.isEmpty(loadingNo)) {
return List.of();
}
return waybillService.list(Wrappers.<Waybill>lambdaQuery()
.eq(Waybill::getLoadingNo, loadingNo)
.in(Waybill::getBusinessStatus, TripAggregation.CHILD_STATUSES)
.and(visible -> visible.isNull(Waybill::getDriverAcceptStatus)
.or().ne(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED))
.orderByAsc(Waybill::getCreateTime));
}
private LoadingManage findLoading(String loadingNo) {
if (Func.isEmpty(loadingNo)) {
return null;
}
return loadingManageService.getOne(Wrappers.<LoadingManage>lambdaQuery()
.eq(LoadingManage::getLoadingNo, loadingNo)
.last("LIMIT 1"));
}
/** 未完成的异常处置:整趟车取最新一条,与旧单票详情同口径 */
private void fillException(AdminWaybillDetailVO detail, Collection<Long> waybillIds) {
ExceptionDisposal latest = latestException(waybillIds);
detail.setHasException(latest != null);
detail.setExceptionId(latest == null ? null : latest.getId());
}
private ExceptionDisposal latestException(Collection<Long> waybillIds) {
if (Func.isEmpty(waybillIds)) {
return null;
}
return exceptionDisposalService.getOne(Wrappers.<ExceptionDisposal>lambdaQuery()
.in(ExceptionDisposal::getWaybillId, waybillIds)
.in(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING, DISPOSAL_PROCESSING)
.orderByDesc(ExceptionDisposal::getReportTime)
.orderByDesc(ExceptionDisposal::getCreateTime)
.last("LIMIT 1"));
}
/** 打卡节点与途打卡记录复用司机端组装;管理端打卡订正按运单走,故传第一张可见子运单 */
private void fillPunchSnapshot(AdminWaybillDetailVO detail, Long waybillId) {
if (waybillId == null) {
return;
}
DriverWaybillCardVO punch = driverWaybillService.detailPunchSnapshot(waybillId);
if (punch == null) {
return;
}
detail.setPunchNodes(punch.getPunchNodes());
detail.setEnrouteRecords(punch.getEnrouteRecords());
if (punch.getRoutePoints() != null && !punch.getRoutePoints().isEmpty()) {
detail.setRoutePoints(punch.getRoutePoints().stream().map(p -> {
AdminWaybillDetailVO.AdminRoutePointVO rp = new AdminWaybillDetailVO.AdminRoutePointVO();
rp.setName(p.getName());
rp.setAddress(p.getAddress());
rp.setStatus(p.getStatus());
return rp;
}).toList());
}
}
/** 点位列:整趟车的全部站点,站序即途经顺序 */
private List<AdminWaybillDetailVO.AdminRoutePointVO> toRoutePoints(List<TripStationVO> stations) {
if (Func.isEmpty(stations)) {
return List.of();
}
return stations.stream().map(station -> {
AdminWaybillDetailVO.AdminRoutePointVO point = new AdminWaybillDetailVO.AdminRoutePointVO();
point.setName(Func.toStr(station.getName(), ""));
point.setAddress(Func.toStrWithEmpty(station.getAddress(), point.getName()));
point.setStatus("pending");
return point;
}).toList();
}
/**
* 待重新派单列表:按库层分组把「同一配载单被拒的多张子单」收成一条,未配载运单各自一条。
* 分页与总数都在库层按运次完成,避免整单拒绝后同一趟车重复出现。
*/
private IPage<AdminWaybillCardVO> reassignPendingPage(int pageNo, int pageSize, String keyword) {
QueryWrapper<Waybill> wrapper = reassignGroupQuery();
applyGroupKeyword(wrapper, keyword);
wrapper.orderByDesc(SQL_MAX_REJECT_TIME);
Page<Waybill> rows = new Page<>(pageNo, pageSize);
waybillService.page(rows, wrapper);
Set<Long> exceptionWaybillIds = loadIncompleteExceptionWaybillIds();
Page<AdminWaybillCardVO> page = new Page<>(pageNo, pageSize, rows.getTotal());
page.setRecords(toReassignCards(rows.getRecords(), exceptionWaybillIds));
return page;
}
/**
* 库层按运次分组的「待重新派单」查询:一条即一个运次,段二不用再回查子单。
* 展示用字段一律包聚合函数取组内代表值,既满足 ONLY_FULL_GROUP_BY,也让一条待办自带它要显示的信息。
*/
private QueryWrapper<Waybill> reassignGroupQuery() {
// 分组键只在 TripAggregation 声明一次:库层分组与服务层分组的「运次」必须是同一个东西
QueryWrapper<Waybill> wrapper = Wrappers.query();
wrapper.select("MIN(id) AS id", "MIN(NULLIF(loading_no, '')) AS loading_no", "MIN(waybill_no) AS waybill_no",
"MIN(transport_type) AS transport_type", "MIN(business_status) AS business_status",
"MIN(departure_name) AS departure_name", "MIN(arrival_name) AS arrival_name",
"MIN(cargo_name) AS cargo_name", "MIN(quantity) AS quantity", "MIN(quantity_unit) AS quantity_unit",
"MIN(estimated_start_time) AS estimated_start_time", "MIN(estimated_end_time) AS estimated_end_time",
"MIN(driver_name) AS driver_name", "MIN(vehicle_no) AS vehicle_no",
"MIN(driver_reject_reason) AS driver_reject_reason",
SQL_MAX_REJECT_TIME + " AS driver_reject_time", SQL_MAX_CREATE_TIME + " AS create_time");
wrapper.eq("driver_accept_status", ACCEPT_REJECTED);
wrapper.in("business_status", STATUS_PENDING, STATUS_RUNNING);
// 这条查询不经运次读服务,组织收口得自己带上,否则待重派列表与首页重派角标会出别的组织的单
List<Long> deptIds = manageableDeptIds();
if (Func.isNotEmpty(deptIds)) {
wrapper.in("dept_id", deptIds);
}
wrapper.groupBy(TripAggregation.SQL_TRIP_KEY);
return wrapper;
}
/** 一条待办 = 一个运次:整趟车取配载单的单号与站点,未配载就是这张运单本身 */
private List<AdminWaybillCardVO> toReassignCards(List<Waybill> rows, Set<Long> exceptionWaybillIds) {
if (Func.isEmpty(rows)) {
return List.of();
}
Set<String> loadingNos = rows.stream().map(Waybill::getLoadingNo).filter(Func::isNotEmpty)
.collect(Collectors.toCollection(LinkedHashSet::new));
Map<String, LoadingManage> loadings = loadingsByNo(loadingNos);
List<AdminWaybillCardVO> cards = new ArrayList<>(rows.size());
for (Waybill row : rows) {
String loadingNo = Func.toStr(row.getLoadingNo(), "");
AdminWaybillCardVO card = toCard(row, hasExceptionIds(row, exceptionWaybillIds));
card.setTripId(String.valueOf(Func.isEmpty(loadingNo) ? row.getId() : loadingId(loadings.get(loadingNo), row.getId())));
card.setTripNo(Func.isEmpty(loadingNo) ? Func.toStr(row.getWaybillNo(), "") : loadingNo);
card.setLoadingNo(loadingNo);
card.setWaybillCount(1);
card.setCargoSummary(Func.toStr(row.getCargoName(), ""));
card.setTotalWeight(formatWeight(row.getQuantity(), row.getQuantityUnit()));
card.setNeedReassign(true);
if (Func.isNotEmpty(loadingNo)) {
List<TripStationVO> stations = TripStationAssembler.assemble(loadings.get(loadingNo), List.of());
card.setTransportType(TRANSPORT_LOAD);
card.setFromName(TripStationAssembler.departureName(stations));
card.setToName(TripStationAssembler.arrivalName(stations));
card.setViaName(TripStationAssembler.transitName(stations));
card.setStationCount(stations.size());
} else {
card.setTransportType(TRANSPORT_COMMON);
card.setStationCount(2);
}
card.setCreateTime(formatDateTime(row.getCreateTime()));
cards.add(card);
}
return cards;
}
private Map<String, LoadingManage> loadingsByNo(Set<String> loadingNos) {
if (loadingNos.isEmpty()) {
return Map.of();
}
return loadingManageService.list(Wrappers.<LoadingManage>lambdaQuery().in(LoadingManage::getLoadingNo, loadingNos))
.stream()
.collect(Collectors.toMap(LoadingManage::getLoadingNo, Function.identity(), (first, ignored) -> first));
}
private Long loadingId(LoadingManage loading, Long fallback) {
return loading == null || loading.getId() == null ? fallback : loading.getId();
}
/** 分组查询的关键字:运单号 / 司机 / 车牌,与列表关键字口径一致 */
private void applyGroupKeyword(QueryWrapper<Waybill> wrapper, String keyword) {
if (Func.isBlank(keyword)) {
return;
}
String key = keyword.trim();
wrapper.and(w -> w.like("waybill_no", key).or().like("driver_name", key).or().like("vehicle_no", key));
}
private boolean hasExceptionIds(Waybill waybill, Set<Long> exceptionWaybillIds) {
return waybill != null && waybill.getId() != null && exceptionWaybillIds.contains(waybill.getId());
}
private AdminWaybillDetailVO toDetail(Waybill waybill, boolean hasException, Long exceptionId) {
@@ -250,9 +759,12 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
detail.setPickupAddress(fromAddr);
detail.setUnloadAddress(toAddr);
String cargo = Func.toStr(waybill.getCargoName(), "");
String weight = formatWeight(waybill.getQuantity(), waybill.getQuantityUnit());
detail.setCargoName(cargo);
// 货名与合计重量取运单上录入的全部货物:主表 cargo_name 只是首行、quantity 是各行合计而单位只是首行的
List<WaybillCargoSupport.Cargo> cargos = WaybillCargoSupport.of(waybill);
List<String> cargoNames = WaybillCargoSupport.cargoNames(cargos);
detail.setCargoName(String.join("、", cargoNames));
detail.setCargoNames(cargoNames);
String weight = cargoWeight(cargos, formatWeight(waybill.getQuantity(), waybill.getQuantityUnit()));
detail.setCargoQuantity(weight);
detail.setWeight(weight);
detail.setTotalWeight(weight);
@@ -371,82 +883,33 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
};
}
private long countWaybillByStatus(String status) {
return waybillService.count(Wrappers.<Waybill>lambdaQuery()
.eq(Waybill::getBusinessStatus, status));
/**
* 调度端可见组织:普通用户(含各级角色)只出自己所属组织,超级管理员不过滤。
* <p>
* 小程序不校验「所属组织不能为空」:账号没有组织时不按组织收口,避免首页、待处理等接口直接报错。
* 详情与重新派单本身不走这里。
*/
private List<Long> manageableDeptIds() {
if (TransportBusinessSupport.isAdministrator()) {
return List.of();
}
Long deptId = Func.firstLong(AuthUtil.getDeptId());
if (Func.isEmpty(deptId) || deptId <= 0) {
return List.of();
}
return List.of(deptId);
}
/**
* 小程序调度端不做组织过滤。
* 「小程序管理」等账号 JWT/档案 dept 常与运单业务组织不一致,按 dept 过滤会导致统计全 0;
* 与后台 allDept=1 一致,仅依赖租户隔离(MyBatis-Plus TenantLine)。
* 已拒子单的查询底:只有租户与逻辑删除(MyBatis-Plus 自动补),不带组织。
* <p>
* 它服务的是「这个运次里有没有被拒的子单」,可见范围由调用方先定好——列表传的运次已经收过组织,
* 详情按主键进入不收组织。这里再按组织收一次,详情里别的组织的运次会丢掉重派标记。
*/
private LambdaQueryWrapper<Waybill> scopedWaybillQuery() {
private LambdaQueryWrapper<Waybill> rejectedChildQuery() {
return Wrappers.<Waybill>lambdaQuery();
}
private void applyStatusFilter(LambdaQueryWrapper<Waybill> wrapper, String status) {
String businessStatus = toBusinessStatus(status);
if (Func.isNotBlank(businessStatus)) {
wrapper.eq(Waybill::getBusinessStatus, businessStatus);
}
}
private void applyExceptionFilter(LambdaQueryWrapper<Waybill> wrapper, String exception, Set<Long> exceptionWaybillIds) {
if (EXCEPTION_YES.equals(exception)) {
wrapper.in(Waybill::getId, exceptionWaybillIds);
} else if (EXCEPTION_NO.equals(exception) && !exceptionWaybillIds.isEmpty()) {
wrapper.notIn(Waybill::getId, exceptionWaybillIds);
}
}
private void applyTransportTypeFilter(LambdaQueryWrapper<Waybill> wrapper, String transportType) {
if (TRANSPORT_LOAD.equals(transportType)) {
wrapper.isNotNull(Waybill::getLoadingNo).ne(Waybill::getLoadingNo, "");
} else if (TRANSPORT_COMMON.equals(transportType)) {
wrapper.and(w -> w.isNull(Waybill::getLoadingNo).or().eq(Waybill::getLoadingNo, ""));
}
}
private void applyKeywordFilter(LambdaQueryWrapper<Waybill> wrapper, String keyword) {
if (Func.isBlank(keyword)) {
return;
}
String key = keyword.trim();
wrapper.and(w -> w.like(Waybill::getWaybillNo, key)
.or().like(Waybill::getDriverName, key)
.or().like(Waybill::getVehicleNo, key));
}
private void applyCreateTimeFilter(LambdaQueryWrapper<Waybill> wrapper, String startDate, String endDate) {
if (Func.isNotBlank(startDate)) {
Date start = DateUtil.parse(startDate.trim() + " 00:00:00", DateUtil.PATTERN_DATETIME);
if (start != null) {
wrapper.ge(Waybill::getCreateTime, start);
}
}
if (Func.isNotBlank(endDate)) {
Date end = DateUtil.parse(endDate.trim() + " 23:59:59", DateUtil.PATTERN_DATETIME);
if (end != null) {
wrapper.le(Waybill::getCreateTime, end);
}
}
}
/** 小程序 status → 后端 businessStatus */
private String toBusinessStatus(String status) {
if (Func.isBlank(status)) {
return null;
}
return switch (status.trim()) {
case "0", STATUS_PENDING -> STATUS_PENDING;
case "1", STATUS_RUNNING, "transporting", "doing" -> STATUS_RUNNING;
case "2", STATUS_COMPLETED, "done" -> STATUS_COMPLETED;
case "3", STATUS_CANCELLED -> STATUS_CANCELLED;
default -> null;
};
}
private Set<Long> loadIncompleteExceptionWaybillIds() {
List<ExceptionDisposal> list = exceptionDisposalService.list(Wrappers.<ExceptionDisposal>lambdaQuery()
.select(ExceptionDisposal::getWaybillId)
@@ -546,6 +1009,33 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
return Func.isBlank(unit) ? qty : qty + unit;
}
/**
* 重量文案:全部货物同单位时按货物行相加,单位不一致时不出合计
* <p>
* 主表 quantity 是各行数量的合计、quantity_unit 只是首行的单位,「20堆 + 50方」会变成「70堆」。
* 拿明细算不出合计(有货物没填数量或单位)时退回主表那份汇总值,不让重量凭空消失。
*/
private String cargoWeight(List<WaybillCargoSupport.Cargo> cargos, String fallback) {
if (cargos.isEmpty()) {
return fallback;
}
String unit = null;
BigDecimal total = BigDecimal.ZERO;
for (WaybillCargoSupport.Cargo cargo : cargos) {
String cargoUnit = Func.toStr(cargo.quantityUnit(), "");
if (cargo.quantity() == null || Func.isEmpty(cargoUnit)) {
return fallback;
}
if (unit == null) {
unit = cargoUnit;
} else if (!unit.equals(cargoUnit)) {
return "";
}
total = total.add(cargo.quantity());
}
return formatWeight(total, unit);
}
private String formatLocalDate(LocalDate date) {
if (date == null) {
return "";
@@ -570,13 +1060,20 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
.eq(RiskDisposal::getDisposalStatus, RISK_PENDING));
}
/** 待重新派单:司机已拒单,且运单尚未取消/完成 */
/**
* 待重新派单的运次数:整单拒绝会把一张配载单的多张子运单一起写成已拒绝,按运次键去重后只算一个。
* <p>
* 这里不走运次读服务:它的可见口径已排除被拒子单,整单拒绝的配载单在运次里根本不存在;
* 分组键用与运次读服务同一个表达式,两处的运次才是同一个东西。
*/
private long countPendingReassignments() {
return waybillService.count(pendingReassignQuery());
Page<Waybill> rows = new Page<>(1, 1);
waybillService.page(rows, reassignGroupQuery());
return rows.getTotal();
}
private LambdaQueryWrapper<Waybill> pendingReassignQuery() {
return Wrappers.<Waybill>lambdaQuery()
return rejectedChildQuery()
.eq(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED)
.in(Waybill::getBusinessStatus, STATUS_PENDING, STATUS_RUNNING);
}
@@ -611,11 +1108,17 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
.collect(Collectors.toList());
}
/**
* 待重新派单的待办:一条 = 一个运次。
* <p>
* 整单拒绝会把一张配载单的多张子运单一起写成已拒绝,按运次键分组后同一趟车只出 1 条;
* 未配载运单自成运次,各自一条。排序与取值都在库层按运次完成,与待处理列表、角标同一口径。
*/
private List<TimedTodo> buildReassignFeed() {
List<Waybill> list = waybillService.list(pendingReassignQuery()
.orderByDesc(Waybill::getDriverRejectTime)
List<Waybill> rows = waybillService.list(reassignGroupQuery()
.orderByDesc(SQL_MAX_REJECT_TIME)
.last("LIMIT " + FEED_LIMIT));
return list.stream()
return rows.stream()
.map(waybill -> new TimedTodo(toReassignTodoItem(waybill), rejectTimeOf(waybill)))
.collect(Collectors.toList());
}
@@ -649,7 +1152,8 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
item.setTitle("重新派单待处理");
item.setTimeAgo(formatTimeAgo(rejectTimeOf(waybill)));
item.setDesc(buildReassignDesc(waybill));
item.setWaybillNo(Func.toStr(waybill.getWaybillNo(), ""));
// 一条待办即一个运次:有配载单的展示配载单号;跳转参数仍是运单主键,该页据此回显并直接重派
item.setWaybillNo(Func.toStrWithEmpty(waybill.getLoadingNo(), Func.toStr(waybill.getWaybillNo(), "")));
item.setActionLabel("重新派单");
item.setTargetUrl(String.format(REASSIGN_TARGET_URL, waybill.getId()));
return item;
@@ -36,6 +36,7 @@ import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.BeanUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.constant.TransportBusiConstant;
import org.springblade.transport.mapper.PreSettlementAdvanceMapper;
import org.springblade.transport.mapper.PreSettlementChangeRecordMapper;
import org.springblade.transport.mapper.PreSettlementDetailFeeMapper;
@@ -67,6 +68,7 @@ import org.springblade.transport.service.IContractManageService;
import org.springblade.transport.service.ICustomerArchiveService;
import org.springblade.transport.service.IPreSettlementService;
import org.springblade.transport.service.IProjectApplyService;
import org.springblade.transport.service.ISettlementProjectAdvanceWriteOffService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.wrapper.PreSettlementWrapper;
import org.springblade.system.cache.UserCache;
@@ -83,7 +85,6 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
@@ -124,6 +125,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
private final ICustomerArchiveService customerArchiveService;
private final IWaybillService waybillService;
private final ISysClient sysClient;
private final ISettlementProjectAdvanceWriteOffService projectAdvanceWriteOffService;
@Override
public IPage<PreSettlementVO> selectPage(IPage<PreSettlement> page, PreSettlementVO query) {
@@ -163,7 +165,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
Map<String, String> categoryNames = DictBizCache.getList("fee_category").stream()
.collect(Collectors.toMap(DictBiz::getDictKey, DictBiz::getDictValue, (first, second) -> first,
LinkedHashMap::new));
org.springblade.core.tool.api.R<List<FeeItem>> response = sysClient.getFeeItems();
org.springblade.core.tool.api.R<List<FeeItem>> response = sysClient.getFeeItems(false);
if (response == null || !response.isSuccess() || response.getData() == null) {
throw new ServiceException("费用项基础档案读取失败,请稍后重试");
}
@@ -184,6 +186,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
PreSettlementVO vo = PreSettlementWrapper.build().entityVO(settlement);
fillPaymentApplicationAmounts(List.of(vo));
List<PreSettlementDetail> details = listDetails(id);
fillDetailBillingTypes(details);
vo.setDetails(details);
List<Long> detailIds = details.stream().map(PreSettlementDetail::getId).toList();
Map<Long, List<PreSettlementDetailFee>> feeMap = Func.isEmpty(detailIds) ? new HashMap<>()
@@ -198,14 +201,24 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
List<PreSettlementAdvance> advances = listAdvances(id);
advances.forEach(advance -> advance.setCreateUserName(UserCache.getUserRealName(advance.getCreateUser())));
vo.setAdvances(advances);
vo.setProjectAdvanceWriteOffs(projectAdvanceWriteOffService.listBySettlement(
TransportBusiConstant.SETTLEMENT_BILL_PRE, id));
vo.setChangeRecords(changeRecordMapper.selectList(Wrappers.<PreSettlementChangeRecord>lambdaQuery()
.eq(PreSettlementChangeRecord::getPreSettlementId, id)
.eq(PreSettlementChangeRecord::getIsDeleted, 0)
.and(wrapper -> wrapper.isNull(PreSettlementChangeRecord::getChangeContent)
.or()
.ne(PreSettlementChangeRecord::getChangeContent, "预结算审批通过"))
.orderByDesc(PreSettlementChangeRecord::getChangeTime)));
vo.setPrintTemplates(resolvePrintTemplates(settlement));
return vo;
}
@Override
public String nextNo() {
return nextPreSettlementNo();
}
@Override
public List<Map<String, Object>> contractOptions(String keyword, Long projectId) {
LambdaQueryWrapper<ContractManage> wrapper = Wrappers.<ContractManage>lambdaQuery()
@@ -297,7 +310,9 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
IPage<ReceivablePayableDetail> sourcePage = sourceDetailMapper.selectPage(
new Page<>(page.getCurrent(), page.getSize()), wrapper);
Page<Map<String, Object>> result = new Page<>(sourcePage.getCurrent(), sourcePage.getSize(), sourcePage.getTotal());
result.setRecords(sourcePage.getRecords().stream().map(this::candidateMap).toList());
List<Map<String, Object>> records = sourcePage.getRecords().stream().map(this::candidateMap).toList();
fillCandidateBillingTypes(records);
result.setRecords(records);
return result;
}
@@ -343,9 +358,17 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
synchronizeDetails(settlement, request.getSourceDetailIds(), Boolean.TRUE.equals(request.getAllowSourceMismatch()));
}
settlement.setExchangeRate(normalizeRate(request.getExchangeRate(), settlement.getCurrency()));
List<PreSettlementSummaryFee> summaryBefore = created ? new ArrayList<>() : listSummaryFees(settlement.getId());
rebuildSummaryFees(settlement.getId(), true);
applySummaryRequest(settlement.getId(), contractChanged ? List.of() : request.getSummaryFees());
recordSummaryFeeChanges(settlement.getId(), summaryBefore);
refreshSettlementAmount(settlement);
projectAdvanceWriteOffService.replaceWriteOffs(
TransportBusiConstant.SETTLEMENT_BILL_PRE,
settlement.getId(),
settlement.getContractId(),
settlement.getSettlementAmount(),
request.getProjectAdvanceWriteOffs());
if (created) {
saveChange(settlement.getId(), "结算单基本信息", null, "新增",
"新增预结算单" + settlement.getPreSettlementNo(), "");
@@ -362,6 +385,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
}
releaseSourceDetails(settlement, listDetails(id));
deleteChildren(id);
projectAdvanceWriteOffService.deleteBySettlement(TransportBusiConstant.SETTLEMENT_BILL_PRE, id);
deleteLogic(List.of(id));
}
@@ -406,7 +430,6 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
settlement.setCurrentProcessor(AuthUtil.getUserName());
settlement.setApprovedTime(LocalDateTime.now());
updateById(settlement);
saveChange(settlement.getId(), "结算单基本信息", null, "调整", "预结算审批通过", request.getReason());
}
@Override
@@ -444,6 +467,9 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
settlement.setCurrentProcessor(AuthUtil.getUserName());
settlement.setVoidReason(reason);
updateById(settlement);
// 联动删除项目预付核销行,避免残留记录与可核销占用
this.projectAdvanceWriteOffService.deleteBySettlementIds(
TransportBusiConstant.SETTLEMENT_BILL_PRE, List.of(settlement.getId()));
releaseSourceDetails(settlement, listDetails(settlement.getId()));
saveChange(settlement.getId(), "结算单基本信息", null, "调整", "作废预结算单", reason);
}
@@ -599,7 +625,9 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
if (Func.isEmpty(request.getRows())) {
throw new ServiceException("请填写需要调整的费用行");
}
String changeReason = limitRemark(request.getChangeReason(), 200);
// 弹窗底部备注非必填,写入结算明细备注
String detailRemark = limitRemark(request.getChangeReason(), 200);
String beforeDetailRemark = detail.getRemark();
Map<Long, PreSettlementDetailFee> existingMap = detailFees(detail.getId()).stream()
.collect(Collectors.toMap(PreSettlementDetailFee::getId, Function.identity()));
if (request.getRows().size() != existingMap.size()) {
@@ -619,7 +647,6 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
BigDecimal beforeFreight = fee.getFreightAmount();
BigDecimal beforeAmount = money(fee.getSettlementAmountTax());
BigDecimal beforeNoTaxAmount = fee.getSettlementAmountNoTax();
String beforeRemark = fee.getRemark();
Map<String, BigDecimal> beforeFeeItems = parseFeeItems(fee.getFeeItemsJson());
fee.setTransportQuantity(nonNegative(requestRow.getTransportQuantity(), "运输总量"));
fee.setMileage(nonNegative(requestRow.getMileage(), "里程"));
@@ -632,7 +659,6 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
fee.setSettlementAmountNoTax(requestRow.getSettlementAmountNoTax() == null ? null
: money(requestRow.getSettlementAmountNoTax()));
fee.setAdjustAmount(afterAmount.subtract(money(fee.getOriginalAmount())));
fee.setRemark(limitRemark(requestRow.getRemark(), 200));
detailFeeMapper.updateById(fee);
String prefix = "【" + firstNotEmpty(fee.getCargoName(), fee.getLineNo()) + "】";
appendChange(changes, beforeData, afterData, prefix + "运输总量", beforeQuantity, fee.getTransportQuantity());
@@ -647,16 +673,19 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
appendChange(changes, beforeData, afterData, prefix + "结算金额(含税)", beforeAmount, afterAmount);
appendChange(changes, beforeData, afterData, prefix + "结算金额(不含税)", beforeNoTaxAmount,
fee.getSettlementAmountNoTax());
appendTextChange(changes, beforeData, afterData, prefix + "备注", beforeRemark, fee.getRemark());
}
appendTextChange(changes, beforeData, afterData, "备注", beforeDetailRemark, detailRemark);
if (changes.isEmpty()) {
throw new ServiceException("未修改任何结算明细费用");
}
refreshDetail(detail);
// refreshDetail 会按费用行回填备注,需在其后覆盖为弹窗底部备注
detail.setRemark(detailRemark);
detailMapper.updateById(detail);
rebuildSummaryFees(settlement.getId(), true);
refreshSettlementAmount(settlement);
saveChange(settlement.getId(), "结算明细项", detail.getLineNo(), "调整",
String.join(";", changes), changeReason, beforeData, afterData);
String.join(";", changes), detailRemark, beforeData, afterData);
}
@Override
@@ -735,6 +764,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
result.put("quantityUnit", source.getQuantityUnit());
result.put("mileage", source.getMileage());
result.put("batchNo", source.getBatchNo());
result.put("billingType", "");
result.put("unitPrice", source.getUnitPrice());
result.put("freightAmount", money(source.getFreightAmount()));
result.put("feeItemsJson", source.getFeeItemsJson());
@@ -748,6 +778,35 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
return result;
}
/** 候选明细补充计费方式(货物费用计费类型去重汇总) */
private void fillCandidateBillingTypes(List<Map<String, Object>> records) {
if (records == null || records.isEmpty()) {
return;
}
List<Long> detailIds = records.stream()
.map(item -> Func.toLong(item.get("id")))
.filter(id -> id != null && id > 0)
.distinct()
.toList();
if (detailIds.isEmpty()) {
return;
}
Map<Long, String> billingTypeMap = sourceFeeMapper.selectList(
Wrappers.<ReceivablePayableCargoFee>lambdaQuery()
.in(ReceivablePayableCargoFee::getDetailId, detailIds)
.orderByAsc(ReceivablePayableCargoFee::getLineNo))
.stream()
.filter(fee -> Func.isNotEmpty(fee.getBillingType()) && !"-".equals(fee.getBillingType()))
.collect(Collectors.groupingBy(ReceivablePayableCargoFee::getDetailId,
Collectors.mapping(ReceivablePayableCargoFee::getBillingType,
Collectors.collectingAndThen(Collectors.toCollection(LinkedHashSet::new),
values -> String.join("、", values)))));
for (Map<String, Object> record : records) {
Long detailId = Func.toLong(record.get("id"));
record.put("billingType", billingTypeMap.getOrDefault(detailId, ""));
}
}
private void synchronizeDetails(PreSettlement settlement, List<Long> requestedIds, boolean allowSourceMismatch) {
List<Long> distinctIds = requestedIds.stream().filter(Objects::nonNull).distinct().toList();
List<PreSettlementDetail> existingDetails = listDetails(settlement.getId());
@@ -984,13 +1043,30 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
summaryFeeMapper.delete(Wrappers.<PreSettlementSummaryFee>lambdaQuery()
.eq(PreSettlementSummaryFee::getPreSettlementId, settlementId)
.eq(PreSettlementSummaryFee::getManualFlag, 0));
int lineNo = 1;
for (Map.Entry<String, BigDecimal> entry : aggregates.entrySet()) {
// 尽量沿用已有系统归集行的序号,避免重建后与手工费用交错顺序被打乱。
List<Map.Entry<String, BigDecimal>> orderedAggregates = new ArrayList<>(aggregates.entrySet());
orderedAggregates.sort((left, right) -> {
PreSettlementSummaryFee leftOld = existingGenerated.get(left.getKey());
PreSettlementSummaryFee rightOld = existingGenerated.get(right.getKey());
Integer leftLineNo = leftOld == null ? null : leftOld.getLineNo();
Integer rightLineNo = rightOld == null ? null : rightOld.getLineNo();
if (leftLineNo == null && rightLineNo == null) {
return 0;
}
if (leftLineNo == null) {
return 1;
}
if (rightLineNo == null) {
return -1;
}
return Integer.compare(leftLineNo, rightLineNo);
});
for (Map.Entry<String, BigDecimal> entry : orderedAggregates) {
String[] keyParts = entry.getKey().split("\\|", 2);
PreSettlementSummaryFee old = existingGenerated.get(entry.getKey());
PreSettlementSummaryFee row = new PreSettlementSummaryFee();
row.setPreSettlementId(settlementId);
row.setLineNo(lineNo++);
row.setLineNo(old == null || old.getLineNo() == null ? Integer.MAX_VALUE : old.getLineNo());
row.setFeeType(keyParts[0]);
row.setFeeItem(keyParts.length > 1 ? keyParts[1] : "");
// 原金额是明细首次生成时的快照,多次调整后不得被当前结算金额覆盖。
@@ -1006,7 +1082,14 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
}
private void applySummaryRequest(Long settlementId, List<PreSettlementSaveRequest.SummaryFee> requestRows) {
if (requestRows == null) return;
if (requestRows == null) {
return;
}
// 空列表表示沿用 rebuildSummaryFees 结果(如更换合同后),不得再按空请求删光合计行。
if (requestRows.isEmpty()) {
renumberSummaryFees(settlementId);
return;
}
Map<String, Set<String>> allowedManualFees = new LinkedHashMap<>();
if (requestRows.stream().anyMatch(row -> Integer.valueOf(1).equals(row.getManualFlag()))) {
for (Map<String, Object> option : feeOptions()) {
@@ -1026,6 +1109,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
List<PreSettlementSummaryFee> availableGeneratedRows = new ArrayList<>(existingGeneratedRows);
Set<Long> retainedManualIds = new LinkedHashSet<>();
Set<Long> retainedGeneratedIds = new LinkedHashSet<>();
int lineNo = 1;
for (PreSettlementSaveRequest.SummaryFee requestRow : requestRows) {
if (Integer.valueOf(1).equals(requestRow.getManualFlag())) {
String feeType = requiredText(requestRow.getFeeType(), "费用类型");
@@ -1042,11 +1126,15 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
&& Objects.equals(item.getFeeItem(), requestRow.getFeeItem()))
.findFirst().orElse(null);
}
if (row == null && requestRow.getId() == null) row = new PreSettlementSummaryFee();
if (row == null && requestRow.getId() == null) {
row = new PreSettlementSummaryFee();
}
if (row == null || (row.getId() != null && !Integer.valueOf(1).equals(row.getManualFlag()))) {
throw new ServiceException("存在无效的手工费用行");
}
boolean creating = row.getId() == null;
row.setPreSettlementId(settlementId);
row.setLineNo(lineNo++);
row.setFeeType(feeType);
row.setFeeItem(feeItem);
row.setOriginalAmount(BigDecimal.ZERO.setScale(2));
@@ -1054,10 +1142,12 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
row.setSettlementAmount(row.getAdjustAmount());
row.setRemark(limitRemark(requestRow.getRemark(), 50));
row.setManualFlag(1);
if (row.getId() == null) summaryFeeMapper.insert(row); else summaryFeeMapper.updateById(row);
if (creating) {
summaryFeeMapper.insert(row);
} else {
summaryFeeMapper.updateById(row);
}
retainedManualIds.add(row.getId());
saveChange(settlementId, "合计费用项", null, requestRow.getId() == null ? "新增" : "调整",
row.getFeeItem() + "金额" + row.getSettlementAmount(), "");
continue;
}
String feeItem = requiredText(requestRow.getFeeItem(), "费用项");
@@ -1077,27 +1167,21 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
row.setPreSettlementId(settlementId);
row.setManualFlag(0);
}
row.setLineNo(lineNo++);
row.setFeeType(feeType);
row.setFeeItem(feeItem);
// 生成费用行的三个金额必须来源于结算明细快照,不能接受前端回传值覆盖原金额。
// rebuildSummaryFees 已按明细重建 original/adjust/settlement,此处仅保留备注。
BigDecimal before = money(row.getAdjustAmount());
row.setOriginalAmount(money(row.getOriginalAmount()));
row.setSettlementAmount(money(row.getSettlementAmount()));
row.setAdjustAmount(money(row.getSettlementAmount().subtract(row.getOriginalAmount())));
row.setRemark(limitRemark(requestRow.getRemark(), 50));
if (row.getId() == null) summaryFeeMapper.insert(row); else summaryFeeMapper.updateById(row);
retainedGeneratedIds.add(row.getId());
if (before.compareTo(row.getAdjustAmount()) != 0) {
saveChange(settlementId, "合计费用项", null, "调整",
"【调整金额】从【" + before + "】调整为【" + row.getAdjustAmount() + "】", "");
}
}
for (PreSettlementSummaryFee manualRow : manualRows) {
if (!retainedManualIds.contains(manualRow.getId())) {
summaryFeeMapper.deleteById(manualRow.getId());
saveChange(settlementId, "合计费用项", null, "删除",
"删除" + manualRow.getFeeItem() + "费用" + manualRow.getSettlementAmount(), "");
}
}
for (PreSettlementSummaryFee generatedRow : existingGeneratedRows) {
@@ -1105,32 +1189,63 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
summaryFeeMapper.deleteById(generatedRow.getId());
}
}
renumberSummaryFees(settlementId);
// lineNo 已按前端提交顺序写入,不再按旧序号重排,避免手工费用与系统归集费用顺序被打乱。
}
private void refreshDetail(PreSettlementDetail detail) {
List<PreSettlementDetailFee> fees = detailFees(detail.getId());
BigDecimal transportQuantity = fees.stream().map(PreSettlementDetailFee::getTransportQuantity)
.filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal mileage = fees.stream().map(PreSettlementDetailFee::getMileage)
.filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal freightAmount = fees.stream().map(PreSettlementDetailFee::getFreightAmount)
.map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal originalAmount = fees.stream().map(PreSettlementDetailFee::getOriginalAmount)
.map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal settlementAmountTax = fees.stream().map(PreSettlementDetailFee::getSettlementAmountTax)
.map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal settlementAmountNoTax = fees.stream().map(PreSettlementDetailFee::getSettlementAmountNoTax)
.filter(Objects::nonNull).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add);
Map<String, BigDecimal> feeItems = new LinkedHashMap<>();
fees.forEach(fee -> parseFeeItems(fee.getFeeItemsJson()).forEach((name, amount) ->
feeItems.merge(name, money(amount), BigDecimal::add)));
// 运输单价无法按行简单求和,与前端暂存逻辑一致:取首行费用单价。
BigDecimal unitPrice = fees.stream().map(PreSettlementDetailFee::getUnitPrice)
.filter(Objects::nonNull).findFirst().orElse(null);
String remark = fees.stream().map(PreSettlementDetailFee::getRemark)
.filter(Func::isNotEmpty).findFirst().orElse(detail.getRemark());
detail.setTransportQuantity(money(transportQuantity));
detail.setMileage(mileage);
detail.setUnitPrice(unitPrice);
detail.setFreightAmount(money(freightAmount));
detail.setOriginalAmount(money(originalAmount));
detail.setAdjustAmount(money(settlementAmountTax.subtract(originalAmount)));
detail.setSettlementAmountTax(money(settlementAmountTax));
detail.setSettlementAmountNoTax(fees.stream().anyMatch(fee -> fee.getSettlementAmountNoTax() != null)
? money(settlementAmountNoTax) : null);
detail.setFeeItemsJson(JsonUtil.toJson(feeItems));
detail.setRemark(limitRemark(remark, 200));
detailMapper.updateById(detail);
}
/**
* 结算金额以结算合计为准(含手工新增费用),与详情页合计及付款申请取数口径一致。
* 合计行为空时回退为明细含税金额合计。
*/
private void refreshSettlementAmount(PreSettlement settlement) {
BigDecimal settlementAmount = listDetails(settlement.getId()).stream()
.map(PreSettlementDetail::getSettlementAmountTax).map(this::money)
.reduce(BigDecimal.ZERO, BigDecimal::add);
List<PreSettlementSummaryFee> summaryFees = listSummaryFees(settlement.getId());
BigDecimal settlementAmount;
if (!summaryFees.isEmpty()) {
settlementAmount = summaryFees.stream()
.map(PreSettlementSummaryFee::getSettlementAmount)
.map(this::money)
.reduce(BigDecimal.ZERO, BigDecimal::add);
} else {
settlementAmount = listDetails(settlement.getId()).stream()
.map(PreSettlementDetail::getSettlementAmountTax)
.map(this::money)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
settlement.setSettlementAmount(money(settlementAmount));
BigDecimal rate = normalizeRate(settlement.getExchangeRate(), settlement.getCurrency());
settlement.setExchangeRate(rate);
@@ -1349,6 +1464,34 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
.eq(PreSettlementChangeRecord::getPreSettlementId, settlementId));
}
/** 预结算明细展示计费方式:按来源应收应付货物费用的计费类型汇总。 */
private void fillDetailBillingTypes(List<PreSettlementDetail> details) {
if (details == null || details.isEmpty()) {
return;
}
List<Long> sourceDetailIds = details.stream()
.map(PreSettlementDetail::getSourceDetailId)
.filter(Objects::nonNull)
.distinct()
.toList();
if (sourceDetailIds.isEmpty()) {
return;
}
Map<Long, String> billingTypeMap = sourceFeeMapper.selectList(
Wrappers.<ReceivablePayableCargoFee>lambdaQuery()
.in(ReceivablePayableCargoFee::getDetailId, sourceDetailIds)
.orderByAsc(ReceivablePayableCargoFee::getLineNo))
.stream()
.filter(fee -> Func.isNotEmpty(fee.getBillingType()) && !"-".equals(fee.getBillingType()))
.collect(Collectors.groupingBy(ReceivablePayableCargoFee::getDetailId,
Collectors.mapping(ReceivablePayableCargoFee::getBillingType,
Collectors.collectingAndThen(Collectors.toCollection(LinkedHashSet::new),
values -> String.join("、", values)))));
for (PreSettlementDetail detail : details) {
detail.setBillingType(billingTypeMap.getOrDefault(detail.getSourceDetailId(), ""));
}
}
private List<PreSettlementDetail> listDetails(Long settlementId) {
return detailMapper.selectList(Wrappers.<PreSettlementDetail>lambdaQuery()
.eq(PreSettlementDetail::getPreSettlementId, settlementId)
@@ -1388,6 +1531,89 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
}
}
private void recordSummaryFeeChanges(Long settlementId, List<PreSettlementSummaryFee> beforeRows) {
List<Map<String, Object>> beforeLines = summaryFeeLines(beforeRows);
List<Map<String, Object>> afterLines = summaryFeeLines(listSummaryFees(settlementId));
if (beforeLines.equals(afterLines)) {
return;
}
String content = summaryFeeChangeContent(beforeLines, afterLines);
if (Func.isEmpty(content)) {
return;
}
Map<String, Object> beforeData = new LinkedHashMap<>();
Map<String, Object> afterData = new LinkedHashMap<>();
beforeData.put("结算合计", beforeLines);
afterData.put("结算合计", afterLines);
String operationType = beforeLines.isEmpty() ? "新增" : afterLines.isEmpty() ? "删除" : "调整";
saveChange(settlementId, "结算合计", null, operationType, content, "", beforeData, afterData);
}
private List<Map<String, Object>> summaryFeeLines(List<PreSettlementSummaryFee> rows) {
Map<String, String> categoryNames = DictBizCache.getList("fee_category").stream()
.collect(Collectors.toMap(DictBiz::getDictKey, DictBiz::getDictValue, (first, second) -> first));
List<Map<String, Object>> lines = new ArrayList<>();
for (PreSettlementSummaryFee row : rows) {
String feeType = row.getFeeType() == null ? "" : row.getFeeType();
Map<String, Object> line = new LinkedHashMap<>();
line.put("来源", Integer.valueOf(1).equals(row.getManualFlag()) ? "手工新增" : "系统归集");
line.put("费用类型", categoryNames.getOrDefault(feeType, feeType));
line.put("费用项", row.getFeeItem() == null ? "" : row.getFeeItem());
line.put("原金额", money(row.getOriginalAmount()));
line.put("调整金额", money(row.getAdjustAmount()));
line.put("结算金额", money(row.getSettlementAmount()));
line.put("备注", row.getRemark() == null ? "" : row.getRemark());
lines.add(line);
}
return lines;
}
private String summaryFeeChangeContent(List<Map<String, Object>> beforeLines, List<Map<String, Object>> afterLines) {
Map<String, Map<String, Object>> beforeMap = indexSummaryLines(beforeLines);
Map<String, Map<String, Object>> afterMap = indexSummaryLines(afterLines);
List<String> changes = new ArrayList<>();
afterMap.forEach((key, afterLine) -> {
Map<String, Object> beforeLine = beforeMap.get(key);
String label = summaryLineLabel(afterLine);
if (beforeLine == null) {
changes.add("新增结算合计【" + label + "】结算金额【" + afterLine.get("结算金额") + "】");
return;
}
if (!Objects.equals(beforeLine.get("原金额"), afterLine.get("原金额"))
|| !Objects.equals(beforeLine.get("调整金额"), afterLine.get("调整金额"))
|| !Objects.equals(beforeLine.get("结算金额"), afterLine.get("结算金额"))
|| !Objects.equals(String.valueOf(beforeLine.get("备注")), String.valueOf(afterLine.get("备注")))) {
changes.add("调整结算合计【" + label + "】结算金额从【" + beforeLine.get("结算金额")
+ "】调整为【" + afterLine.get("结算金额") + "】");
}
});
beforeMap.forEach((key, beforeLine) -> {
if (!afterMap.containsKey(key)) {
changes.add("删除结算合计【" + summaryLineLabel(beforeLine) + "】结算金额【" + beforeLine.get("结算金额") + "】");
}
});
return String.join(";", changes);
}
private Map<String, Map<String, Object>> indexSummaryLines(List<Map<String, Object>> lines) {
Map<String, Map<String, Object>> indexed = new LinkedHashMap<>();
Map<String, Integer> counts = new HashMap<>();
for (Map<String, Object> line : lines) {
String base = String.valueOf(line.get("来源")) + "|" + line.get("费用类型") + "|" + line.get("费用项");
int index = counts.merge(base, 1, Integer::sum);
indexed.put(base + "#" + index, line);
}
return indexed;
}
private String summaryLineLabel(Map<String, Object> line) {
String feeType = String.valueOf(line.getOrDefault("费用类型", ""));
String feeItem = String.valueOf(line.getOrDefault("费用项", ""));
String source = String.valueOf(line.getOrDefault("来源", ""));
String name = feeType.isBlank() ? feeItem : feeType + "-" + feeItem;
return source + " " + name;
}
private void saveChange(Long settlementId, String changeType, Integer lineNo, String operationType,
String content, String reason) {
saveChange(settlementId, changeType, lineNo, operationType, content, reason, null, null);
@@ -1602,44 +1828,25 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
return String.valueOf(feeType) + "|" + String.valueOf(feeItem);
}
/** 生成当日预结算单号;按最大流水号 +1,且计入逻辑删除数据 */
private synchronized String nextPreSettlementNo() {
return nextDailyCode("YJ", PreSettlement::getPreSettlementNo);
String prefix = "YJ" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
Long maxSequence = baseMapper.selectMaxPreSettlementDailySequence(AuthUtil.getTenantId(), prefix);
return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1);
}
/** 生成预结算转正式结算单号;按最大流水号 +1,且计入逻辑删除数据 */
private synchronized String nextFormalSettlementNo() {
return nextDailyCode("ZJ", PreSettlement::getFormalSettlementNo);
String prefix = "ZJ" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
Long maxSequence = baseMapper.selectMaxFormalSettlementDailySequence(AuthUtil.getTenantId(), prefix);
return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1);
}
/** 生成当日预付单号;按最大流水号 +1,且计入逻辑删除数据 */
private synchronized String nextAdvanceNo() {
String prefix = "YF" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
PreSettlementAdvance latest = advanceMapper.selectOne(Wrappers.<PreSettlementAdvance>lambdaQuery()
.likeRight(PreSettlementAdvance::getAdvanceNo, prefix)
.orderByDesc(PreSettlementAdvance::getAdvanceNo)
.last("limit 1"));
return prefix + String.format("%05d", nextSequence(latest == null ? null : latest.getAdvanceNo(), prefix));
}
private String nextDailyCode(String code, Function<PreSettlement, String> getter) {
String prefix = code + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
PreSettlement latest = list(Wrappers.<PreSettlement>lambdaQuery()
.and(query -> query.likeRight(PreSettlement::getPreSettlementNo, prefix)
.or().likeRight(PreSettlement::getFormalSettlementNo, prefix))
.orderByDesc(PreSettlement::getCreateTime)
.last("limit 1")).stream().max(Comparator.comparing(item -> {
String value = getter.apply(item);
return value == null ? "" : value;
})).orElse(null);
String latestCode = latest == null ? null : getter.apply(latest);
return prefix + String.format("%05d", nextSequence(latestCode, prefix));
}
private int nextSequence(String latestCode, String prefix) {
if (latestCode == null || !latestCode.startsWith(prefix)) return 1;
try {
return Integer.parseInt(latestCode.substring(prefix.length())) + 1;
} catch (NumberFormatException exception) {
return 1;
}
Long maxSequence = advanceMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix);
return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1);
}
}
@@ -83,6 +83,7 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
private static final String STATUS_APPROVED = "approved";
private static final String STATUS_REJECTED = "rejected";
private static final String STATUS_CHANGE_REVIEWING = "change_reviewing";
private static final String STATUS_CHANGE_APPROVED = "change_approved";
private static final String STATUS_VOIDED = "voided";
private static final String EFFECTIVE_TEMPORARY = "temporary";
private static final String EFFECTIVE_FORMAL = "formal";
@@ -167,7 +168,7 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
appendProjectChangeRecord(projectApply, CHANGE_TYPE_DRAFT, null, STATUS_DRAFT, "草稿",
buildProjectChangeSnapshot(beforeRecord), buildProjectChangeSnapshot(projectApply));
}
return saveOrUpdate(projectApply);
return persistProjectApply(projectApply);
}
@Override
@@ -186,7 +187,7 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
projectApply.setApprovalStatus(STATUS_REVIEWING);
projectApply.setCurrentNode(resolveApprovalNode(projectApply));
projectApply.setCurrentProcessor("待处理");
return saveOrUpdate(projectApply);
return persistProjectApply(projectApply);
}
@Override
@@ -207,7 +208,7 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
if (!List.of(STATUS_REVIEWING, STATUS_CHANGE_REVIEWING).contains(projectApply.getApprovalStatus())) {
throw new ServiceException("当前状态不允许审批通过");
}
projectApply.setApprovalStatus(STATUS_CHANGE_REVIEWING.equals(projectApply.getApprovalStatus()) ? "change_approved" : STATUS_APPROVED);
projectApply.setApprovalStatus(STATUS_CHANGE_REVIEWING.equals(projectApply.getApprovalStatus()) ? STATUS_CHANGE_APPROVED : STATUS_APPROVED);
projectApply.setEffectiveType(EFFECTIVE_FORMAL);
projectApply.setCurrentNode("审批通过");
projectApply.setCurrentProcessor(AuthUtil.getUserName());
@@ -247,7 +248,7 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
@Transactional(rollbackFor = Exception.class)
public boolean voidProject(Long id, String reason) {
ProjectApply projectApply = loadExists(id);
if (!Objects.equals(projectApply.getApprovalStatus(), STATUS_APPROVED)) {
if (!List.of(STATUS_APPROVED, STATUS_CHANGE_APPROVED).contains(projectApply.getApprovalStatus())) {
throw new ServiceException("仅审批通过项目允许作废");
}
projectApply.setApprovalStatus(STATUS_VOIDED);
@@ -263,6 +264,7 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
// 变更草稿仅更新业务字段,不生成变更记录;变更记录在 submitChange 时落库
ProjectApply oldRecord = loadChangeable(projectApply.getId());
prepare(projectApply);
normalizeUndertakeDept(projectApply);
copyChangeFields(oldRecord, projectApply);
validateSubmit(oldRecord);
validateChangeLength(oldRecord);
@@ -275,6 +277,7 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
ProjectApply oldRecord = loadChangeable(projectApply.getId());
Map<String, Object> beforeData = buildProjectChangeSnapshot(oldRecord);
prepare(projectApply);
normalizeUndertakeDept(projectApply);
copyChangeFields(oldRecord, projectApply);
validateSubmit(oldRecord);
validateChange(oldRecord, projectApply.getChangeType());
@@ -382,13 +385,13 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
}
if (Boolean.TRUE.equals(projectApply.getTemporaryCreditLimitSelectable())) {
queryWrapper.eq(ProjectApply::getEffectiveType, EFFECTIVE_FORMAL)
.in(ProjectApply::getApprovalStatus, STATUS_APPROVED, "change_approved");
.in(ProjectApply::getApprovalStatus, STATUS_APPROVED, STATUS_CHANGE_APPROVED);
}
if (Boolean.TRUE.equals(projectApply.getContractSelectable())) {
// 正式审批通过项目 + 未作废的临时项目均可被合同选择
queryWrapper.and(wrapper -> wrapper
.nested(formal -> formal.eq(ProjectApply::getEffectiveType, EFFECTIVE_FORMAL)
.in(ProjectApply::getApprovalStatus, STATUS_APPROVED, "change_approved"))
.in(ProjectApply::getApprovalStatus, STATUS_APPROVED, STATUS_CHANGE_APPROVED))
.or(temporary -> temporary.eq(ProjectApply::getEffectiveType, EFFECTIVE_TEMPORARY)
.ne(ProjectApply::getApprovalStatus, STATUS_VOIDED)));
}
@@ -582,6 +585,11 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
}
private void copyChangeFields(ProjectApply target, ProjectApply source) {
// 项目变更允许调整业务部门、平台公司
target.setBusinessDeptId(source.getBusinessDeptId());
target.setBusinessDeptName(source.getBusinessDeptName());
target.setUndertakeDeptId(source.getUndertakeDeptId());
target.setUndertakeDeptName(source.getUndertakeDeptName());
target.setFundLimit(source.getFundLimit());
target.setReceivableLimit(source.getReceivableLimit());
target.setReceivableDays(source.getReceivableDays());
@@ -795,6 +803,8 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
private void validateLength(ProjectApply projectApply) {
TransportBusinessSupport.validateLength(projectApply.getProjectName(), 50, "项目名称不能超过50个字");
TransportBusinessSupport.validateLength(projectApply.getProjectShortName(), 20, "项目简称不能超过20个字");
TransportBusinessSupport.validateLength(projectApply.getUndertakeDeptId(), 500, "平台公司选择过多,请减少后再提交");
TransportBusinessSupport.validateLength(projectApply.getUndertakeDeptName(), 500, "平台公司名称过长,请减少选择后再提交");
TransportBusinessSupport.validateLength(projectApply.getSourceRemark(), 300, "项目由来说明不能超过300个字");
TransportBusinessSupport.validateLength(projectApply.getCargoQuantity(), 100, "预估货物数量不能超过100个字");
TransportBusinessSupport.validateLength(projectApply.getTransportRoute(), 100, "运输线路不能超过100个字");
@@ -896,7 +906,9 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
}
private void validateChangeable(ProjectApply projectApply) {
if (!Objects.equals(projectApply.getApprovalStatus(), STATUS_APPROVED) || !Objects.equals(projectApply.getEffectiveType(), EFFECTIVE_FORMAL)) {
// 首次立项通过(approved)与历次变更通过(change_approved)均可再次发起变更
boolean approved = List.of(STATUS_APPROVED, STATUS_CHANGE_APPROVED).contains(projectApply.getApprovalStatus());
if (!approved || !Objects.equals(projectApply.getEffectiveType(), EFFECTIVE_FORMAL)) {
throw new ServiceException("仅正式生效且审批通过的项目允许发起变更");
}
}
@@ -1046,6 +1058,37 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
});
}
private boolean persistProjectApply(ProjectApply projectApply) {
try {
return saveOrUpdate(projectApply);
} catch (RuntimeException exception) {
throw translateUndertakeDeptPersistError(exception, projectApply);
}
}
/**
* 多选平台公司写入失败时,提示执行字段扩容脚本(原列为 bigint / varchar(100))。
*/
private RuntimeException translateUndertakeDeptPersistError(RuntimeException exception, ProjectApply projectApply) {
String message = Func.toStr(exception.getMessage());
Throwable cause = exception.getCause();
while (cause != null) {
message = message + " " + Func.toStr(cause.getMessage());
cause = cause.getCause();
}
String lowerMessage = message.toLowerCase();
boolean multiUndertake = Func.isNotEmpty(projectApply.getUndertakeDeptId())
&& (projectApply.getUndertakeDeptId().contains(",") || projectApply.getUndertakeDeptId().contains(","));
boolean columnHint = lowerMessage.contains("undertake_dept")
|| lowerMessage.contains("incorrect integer")
|| lowerMessage.contains("data truncation")
|| lowerMessage.contains("data too long");
if (multiUndertake && columnHint) {
return new ServiceException("平台公司多选保存失败,请先执行脚本 blade_project_apply_undertake_dept_multi_20261014.sql 扩容字段后重试");
}
return exception;
}
private void normalizeUndertakeDept(ProjectApply projectApply) {
if (projectApply == null) {
return;
@@ -252,8 +252,8 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl<ReceiptClaimM
BigDecimal flowClaimedAfter = money(flow.getClaimedAmount())
.subtract(money(claim.getClaimAmount())).max(BigDecimal.ZERO);
flow.setClaimedAmount(flowClaimedAfter);
// 认领状态对照公告未认领金额重算(作废分摊不计入累计)
flow.setClaimStatus(amountClaimStatus(flowClaimedAfter, flow.getUnclaimedAmount()));
// 认领状态对照可认领上限重算(作废分摊不计入累计)
flow.setClaimStatus(amountClaimStatus(flowClaimedAfter, claimCeiling(flow)));
receiptFlowMapper.updateById(flow);
// 作废不生成金蝶认领冲单:金蝶单号/状态保留原值(未推送为空,已推送为真实收款单信息),
@@ -411,13 +411,28 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl<ReceiptClaimM
}
/**
* 累计认领金额 → 流水认领状态:对照公告未认领金额判定(与收款流水侧口径一致)
* 累计认领金额 → 流水认领状态:对照可认领上限判定(与收款流水侧口径一致)
*/
private String amountClaimStatus(BigDecimal claimedAmount, BigDecimal unclaimedAmount) {
private String amountClaimStatus(BigDecimal claimedAmount, BigDecimal claimCeiling) {
if (claimedAmount.compareTo(BigDecimal.ZERO) <= 0) {
return "unclaimed";
}
return claimedAmount.compareTo(money(unclaimedAmount)) >= 0 ? "claimed" : "partial";
return claimedAmount.compareTo(money(claimCeiling)) >= 0 ? "claimed" : "partial";
}
/**
* 可认领上限:公告未认领金额与收款金额取较小正值(与收款流水侧口径一致)
*/
private BigDecimal claimCeiling(KingdeeReceiptFlow flow) {
BigDecimal unclaimed = money(flow.getUnclaimedAmount());
BigDecimal receipt = money(flow.getReceiptAmount());
if (receipt.compareTo(BigDecimal.ZERO) <= 0) {
return unclaimed;
}
if (unclaimed.compareTo(BigDecimal.ZERO) <= 0) {
return unclaimed;
}
return unclaimed.min(receipt);
}
private String amountStatus(BigDecimal paidAmount, BigDecimal settlementAmount) {
@@ -194,12 +194,16 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
.eq(ReceiptFlowRecord::getReceiptFlowId, id)
.in(ReceiptFlowRecord::getActionType, List.of("claim", "push"))
.orderByDesc(ReceiptFlowRecord::getCreateTime)));
// 继续认领:带回本流水历史关联且仍有剩余可认领金额的结算单
vo.setRelatedSettlements(relatedSettlementCandidates(id));
return vo;
}
@Override
public List<Map<String, Object>> settlementCandidates(String keyword, Long flowId) {
existing(flowId);
// 本流水历史关联结算单优先保留(避免仅按创建时间 limit 截断后选不到)
Set<Long> relatedIds = relatedSettlementIds(flowId);
List<FormalSettlement> settlements = formalSettlementMapper.selectList(
Wrappers.<FormalSettlement>lambdaQuery()
.eq(FormalSettlement::getSettlementType, RECEIVABLE)
@@ -210,7 +214,24 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
.or().like(FormalSettlement::getProjectName, keyword)
.or().like(FormalSettlement::getContractName, keyword))
.orderByDesc(FormalSettlement::getCreateTime)
.last("limit 200"));
.last("limit 500"));
if (!relatedIds.isEmpty()) {
Set<Long> loadedIds = settlements.stream().map(FormalSettlement::getId)
.filter(Objects::nonNull).collect(Collectors.toSet());
List<Long> missingIds = relatedIds.stream().filter(id -> !loadedIds.contains(id)).toList();
if (!missingIds.isEmpty()) {
List<FormalSettlement> relatedSettlements = formalSettlementMapper.selectList(
Wrappers.<FormalSettlement>lambdaQuery()
.in(FormalSettlement::getId, missingIds)
.eq(FormalSettlement::getSettlementType, RECEIVABLE)
.eq(FormalSettlement::getApprovalStatus, APPROVED)
.eq(FormalSettlement::getStatus, 1));
List<FormalSettlement> merged = new ArrayList<>(relatedSettlements.size() + settlements.size());
merged.addAll(relatedSettlements);
merged.addAll(settlements);
settlements = merged;
}
}
// 候选结算单的累计认领金额一次聚合查询(避免逐单 N+1)
Map<Long, BigDecimal> claimedMap = receiptClaimedAmounts(settlements.stream()
.map(FormalSettlement::getId).toList());
@@ -218,6 +239,14 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
.map(settlement -> candidateRow(settlement,
claimedMap.getOrDefault(settlement.getId(), BigDecimal.ZERO)))
.filter(row -> ((BigDecimal) row.get("remainingReceiptAmount")).compareTo(BigDecimal.ZERO) > 0)
.sorted((left, right) -> {
boolean leftRelated = relatedIds.contains(toLong(left.get("id")));
boolean rightRelated = relatedIds.contains(toLong(right.get("id")));
if (leftRelated == rightRelated) {
return 0;
}
return leftRelated ? -1 : 1;
})
.toList();
}
@@ -234,6 +263,7 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
Wrappers.<ReceiptClaimSettlement>lambdaQuery()
.eq(ReceiptClaimSettlement::getFormalSettlementId, formalSettlementId)
.eq(ReceiptClaimSettlement::getStatus, 1)
.eq(ReceiptClaimSettlement::getIsDeleted, 0)
.orderByDesc(ReceiptClaimSettlement::getCreateTime));
if (relations.isEmpty()) {
return List.of();
@@ -315,19 +345,19 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
allocatedTotal = allocatedTotal.add(allocated);
}
// 公告未认领金额(认领上限与认领完成判定基准,A301-2 口径)
// 认领上限:公告未认领金额与收款金额取较小值(避免未认领金额异常偏大导致无法认领完成)
if (flow.getUnclaimedAmount() == null) {
throw new ServiceException("公告未认领金额缺失,请先手动同步金蝶收款流水");
}
BigDecimal unclaimedAmount = money(flow.getUnclaimedAmount());
if (unclaimedAmount.compareTo(BigDecimal.ZERO) <= 0) {
throw new ServiceException("该流水公告未认领金额为0,无需认领");
BigDecimal claimCeiling = claimCeiling(flow);
if (claimCeiling.compareTo(BigDecimal.ZERO) <= 0) {
throw new ServiceException("该流水可认领金额为0,无需认领");
}
// 流水认领前已认领金额
BigDecimal previousFlowClaimed = flowClaimedAmount(flow.getId());
if (previousFlowClaimed.add(allocatedTotal).compareTo(unclaimedAmount) > 0) {
throw new ServiceException("累计认领金额不能超过公告未认领金额,剩余可认领 "
+ unclaimedAmount.subtract(previousFlowClaimed).max(BigDecimal.ZERO));
if (previousFlowClaimed.add(allocatedTotal).compareTo(claimCeiling) > 0) {
throw new ServiceException("累计认领金额不能超过可认领上限,剩余可认领 "
+ claimCeiling.subtract(previousFlowClaimed).max(BigDecimal.ZERO));
}
// 当前用户部门
@@ -380,6 +410,9 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
relation.setClaimedReceiptAmount(previousClaimed);
// 本次分摊金额
relation.setAllocatedReceiptAmount(allocated);
// 有效分摊(与汇总/候选查询 status=1 口径一致)
relation.setStatus(1);
relation.setIsDeleted(0);
claimSettlementMapper.insert(relation);
}
@@ -390,8 +423,8 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
String fromStatus = normalizeClaimStatus(flow.getClaimStatus());
// 认领后累计金额
BigDecimal claimedAfter = previousFlowClaimed.add(allocatedTotal);
// 认领后状态(对照公告未认领金额)
String toStatus = amountClaimStatus(claimedAfter, unclaimedAmount);
// 认领后状态(对照可认领上限)
String toStatus = amountClaimStatus(claimedAfter, claimCeiling);
// 更新流水已认领金额
flow.setClaimedAmount(claimedAfter);
// 更新流水认领状态
@@ -499,9 +532,9 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
// 复制公告数据到实体(不覆盖本地认领字段)
copyAnnounceRow(entity, announce, detailSerialNo, receiptAmount);
// 根据本地累计认领金额与最新公告未认领金额重新计算认领状态
// 根据本地累计认领金额与可认领上限重新计算认领状态
entity.setClaimStatus(amountClaimStatus(money(entity.getClaimedAmount()),
entity.getUnclaimedAmount()));
claimCeiling(entity)));
if (created) {
toInsert.add(entity);
@@ -621,6 +654,46 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
}
}
/**
* 本流水历史关联过的结算单 id(有效分摊)
*/
private Set<Long> relatedSettlementIds(Long flowId) {
if (flowId == null) {
return Set.of();
}
return claimSettlementMapper.selectList(Wrappers.<ReceiptClaimSettlement>lambdaQuery()
.select(ReceiptClaimSettlement::getFormalSettlementId)
.eq(ReceiptClaimSettlement::getReceiptFlowId, flowId)
.eq(ReceiptClaimSettlement::getStatus, 1)
.eq(ReceiptClaimSettlement::getIsDeleted, 0)).stream()
.map(ReceiptClaimSettlement::getFormalSettlementId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(HashSet::new));
}
/**
* 继续认领预填:本流水历史关联且仍有剩余可认领金额的结算单
*/
private List<Map<String, Object>> relatedSettlementCandidates(Long flowId) {
Set<Long> relatedIds = relatedSettlementIds(flowId);
if (relatedIds.isEmpty()) {
return List.of();
}
List<FormalSettlement> settlements = formalSettlementMapper.selectList(
Wrappers.<FormalSettlement>lambdaQuery()
.in(FormalSettlement::getId, relatedIds)
.eq(FormalSettlement::getSettlementType, RECEIVABLE)
.eq(FormalSettlement::getApprovalStatus, APPROVED)
.eq(FormalSettlement::getStatus, 1));
Map<Long, BigDecimal> claimedMap = receiptClaimedAmounts(settlements.stream()
.map(FormalSettlement::getId).toList());
return settlements.stream()
.map(settlement -> candidateRow(settlement,
claimedMap.getOrDefault(settlement.getId(), BigDecimal.ZERO)))
.filter(row -> ((BigDecimal) row.get("remainingReceiptAmount")).compareTo(BigDecimal.ZERO) > 0)
.toList();
}
/**
* 批量聚合结算单的累计有效认领金额:一次 IN 查询按结算单分组求和,避免逐单 N+1
*/
@@ -631,7 +704,8 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
}
return claimSettlementMapper.selectList(Wrappers.<ReceiptClaimSettlement>lambdaQuery()
.in(ReceiptClaimSettlement::getFormalSettlementId, ids)
.eq(ReceiptClaimSettlement::getStatus, 1)).stream()
.eq(ReceiptClaimSettlement::getStatus, 1)
.eq(ReceiptClaimSettlement::getIsDeleted, 0)).stream()
.collect(Collectors.groupingBy(ReceiptClaimSettlement::getFormalSettlementId,
Collectors.reducing(BigDecimal.ZERO,
item -> money(item.getAllocatedReceiptAmount()), BigDecimal::add)));
@@ -640,12 +714,42 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
private BigDecimal flowClaimedAmount(Long flowId) {
return claimSettlementMapper.selectList(Wrappers.<ReceiptClaimSettlement>lambdaQuery()
.eq(ReceiptClaimSettlement::getReceiptFlowId, flowId)
.eq(ReceiptClaimSettlement::getStatus, 1)).stream()
.eq(ReceiptClaimSettlement::getStatus, 1)
.eq(ReceiptClaimSettlement::getIsDeleted, 0)).stream()
.map(ReceiptClaimSettlement::getAllocatedReceiptAmount)
.map(this::money)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
/**
* 可认领上限:公告未认领金额与收款金额取较小正值。
* 主口径仍为 A301-2 公告未认领金额;与收款金额取 min,避免未认领金额异常偏大导致无法进入「认领完成」。
*/
private BigDecimal claimCeiling(KingdeeReceiptFlow flow) {
BigDecimal unclaimed = money(flow.getUnclaimedAmount());
BigDecimal receipt = money(flow.getReceiptAmount());
if (receipt.compareTo(BigDecimal.ZERO) <= 0) {
return unclaimed;
}
if (unclaimed.compareTo(BigDecimal.ZERO) <= 0) {
return unclaimed;
}
return unclaimed.min(receipt);
}
private Long toLong(Object value) {
if (value == null) {
return null;
}
if (value instanceof Long longValue) {
return longValue;
}
if (value instanceof Number number) {
return number.longValue();
}
return Func.toLong(String.valueOf(value));
}
private KingdeeReceiptFlow lockedFlow(Long flowId) {
KingdeeReceiptFlow flow = baseMapper.selectOne(Wrappers.<KingdeeReceiptFlow>lambdaQuery()
.eq(KingdeeReceiptFlow::getId, flowId)
@@ -824,18 +928,20 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
flow.setClaimMe(announce.getClaimme());
BigDecimal claimedTotal = flowClaimedAmount(flow.getId());
flow.setClaimedAmount(claimedTotal);
String claimStatus = amountClaimStatus(claimedTotal, flow.getUnclaimedAmount());
BigDecimal claimCeiling = claimCeiling(flow);
String claimStatus = amountClaimStatus(claimedTotal, claimCeiling);
flow.setClaimStatus(claimStatus);
updateById(flow);
if (!CLAIMED.equals(claimStatus)) {
return ReceiptClaimPushResult.fail("累计认领金额(" + claimedTotal + ")与公告未认领金额("
+ money(flow.getUnclaimedAmount()) + ")不一致,不满足推送条件");
return ReceiptClaimPushResult.fail("累计认领金额(" + claimedTotal + ")与可认领上限("
+ claimCeiling + ")不一致,不满足推送条件");
}
// 分录取该流水全部有效分摊行(结算单id升序,跨认领主单归集)
List<ReceiptClaimSettlement> allocations = claimSettlementMapper.selectList(
Wrappers.<ReceiptClaimSettlement>lambdaQuery()
.eq(ReceiptClaimSettlement::getReceiptFlowId, flow.getId())
.eq(ReceiptClaimSettlement::getStatus, 1)
.eq(ReceiptClaimSettlement::getIsDeleted, 0)
.orderByAsc(ReceiptClaimSettlement::getFormalSettlementId));
if (allocations.isEmpty()) {
return ReceiptClaimPushResult.fail("该流水无有效认领分摊,无法推送");
@@ -1075,13 +1181,13 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
}
/**
* 累计认领金额 → 流水认领状态:对照公告未认领金额判定(claimed 即「认领完成」,唯一可推送状态)
* 累计认领金额 → 流水认领状态:对照可认领上限判定(claimed 即「认领完成」,唯一可推送状态)
*/
private String amountClaimStatus(BigDecimal claimedAmount, BigDecimal unclaimedAmount) {
private String amountClaimStatus(BigDecimal claimedAmount, BigDecimal claimCeiling) {
if (claimedAmount.compareTo(BigDecimal.ZERO) <= 0) {
return UNCLAIMED;
}
return claimedAmount.compareTo(money(unclaimedAmount)) >= 0 ? CLAIMED : PARTIAL;
return claimedAmount.compareTo(money(claimCeiling)) >= 0 ? CLAIMED : PARTIAL;
}
private String amountStatus(BigDecimal claimedAmount, BigDecimal settlementAmount) {
@@ -424,9 +424,7 @@ public class ReceivablePayableDetailServiceImpl
}
});
ReceivablePayableFeeDetailVO result = buildFeeDetail(rows);
LinkedHashSet<String> feeItemNames = new LinkedHashSet<>(contractFeeItemNames(detail.getContractId()));
feeItemNames.addAll(result.getFeeItemNames());
result.setFeeItemNames(new ArrayList<>(feeItemNames));
// 只展示本明细已生成的费用项。合同上其他计费方案的费用项不能叠到当前明细。
return result;
}
@@ -607,14 +605,22 @@ public class ReceivablePayableDetailServiceImpl
BigDecimal transportQuantity = money(adjusted.getTransportQuantity());
BigDecimal mileage = money(adjusted.getMileage());
BigDecimal freightAmount = money(adjusted.getFreightAmount());
BigDecimal unitPrice = adjusted.getUnitPrice() == null ? null : money(adjusted.getUnitPrice());
Map<String, BigDecimal> effectiveFeeItems = feeItems;
boolean billingBasisChanged = money(existing.getTransportQuantity()).compareTo(transportQuantity) != 0
|| money(existing.getMileage()).compareTo(mileage) != 0;
if (billingBasisChanged) {
boolean quantityChanged = money(existing.getTransportQuantity()).compareTo(transportQuantity) != 0;
boolean mileageChanged = money(existing.getMileage()).compareTo(mileage) != 0;
boolean unitPriceChanged = money(existing.getUnitPrice()).compareTo(money(unitPrice)) != 0;
if (quantityChanged || mileageChanged || unitPriceChanged) {
String changedField = quantityChanged && !mileageChanged ? "transportQuantity"
: mileageChanged && !quantityChanged ? "mileage"
: unitPriceChanged && !quantityChanged && !mileageChanged ? "unitPrice" : null;
AdjustedFeeCalculation calculation = calculateAdjustedFee(detail, existing,
transportQuantity, mileage, freightAmount, feeItems);
transportQuantity, mileage, freightAmount, feeItems, unitPrice, changedField);
freightAmount = calculation.freightAmount();
effectiveFeeItems = calculation.feeItems();
if (calculation.unitPrice() != null) {
adjusted.setUnitPrice(calculation.unitPrice().setScale(2, RoundingMode.HALF_UP));
}
}
appendChange(changes, "规格", existing.getSpecification(), adjusted.getSpecification());
appendChange(changes, "型号", existing.getModel(), adjusted.getModel());
@@ -678,15 +684,21 @@ public class ReceivablePayableDetailServiceImpl
}
validateNonNegative(request.getTransportQuantity(), "运输量");
validateNonNegative(request.getMileage(), "里程");
validateNonNegative(request.getUnitPrice(), "运输单价");
AdjustedFeeCalculation calculation = calculateAdjustedFee(detail, fee,
money(request.getTransportQuantity()), money(request.getMileage()),
money(request.getFreightAmount()), normalizeFeeItems(request.getFeeItems()));
money(request.getFreightAmount()), normalizeFeeItems(request.getFeeItems()),
request.getUnitPrice() == null ? null : money(request.getUnitPrice()),
request.getChangedField());
ReceivablePayableCargoFeeVO result = Objects.requireNonNull(
BeanUtil.copyProperties(fee, ReceivablePayableCargoFeeVO.class));
result.setTransportQuantity(request.getTransportQuantity());
result.setMileage(request.getMileage());
result.setFreightAmount(calculation.freightAmount());
result.setFeeItems(new LinkedHashMap<>(calculation.feeItems()));
if (calculation.unitPrice() != null) {
result.setUnitPrice(calculation.unitPrice().setScale(2, RoundingMode.HALF_UP));
}
BigDecimal afterAmount = adjustedAfterAmount(calculation.freightAmount(), calculation.feeItems());
result.setAdjustAmount(afterAmount.subtract(money(fee.getOriginalAmount())));
result.setAfterAmount(afterAmount);
@@ -836,6 +848,12 @@ public class ReceivablePayableDetailServiceImpl
if (existsByWaybill(waybill.getId(), targetSettlementType)) {
continue;
}
if ("payable".equals(targetSettlementType)
&& trimToNull(waybill.getCarrierName()) != null
&& !matchesWaybillCarrier(contract, waybill)) {
throw new ServiceException("运单【" + Func.toStr(waybill.getWaybillNo(), String.valueOf(waybill.getId()))
+ "】的承运商不是所选承运商合同的乙方,不能使用项目内其他承运商合同生成应付");
}
ReceivablePayableDetail detail = buildDetail(waybill, contract, request.getBillingPlanId(), targetSettlementType);
detail.setSourceType(SOURCE_MANUAL_GENERATION);
save(detail);
@@ -847,6 +865,186 @@ public class ReceivablePayableDetailServiceImpl
}
}
private static final String SOURCE_SETTLEMENT_IMPORT = "结算导入";
@Override
@Transactional(rollbackFor = Exception.class)
public List<Long> createDetailsFromSettlementImport(String settlementType, Long contractId,
List<Map<String, Object>> feeRows) {
if (Func.isEmpty(settlementType) || Func.isEmpty(contractId)) {
throw new ServiceException("结算类型与合同不能为空");
}
if (Func.isEmpty(feeRows)) {
return List.of();
}
ContractManage contract = contractManageService.getById(contractId);
if (contract == null || Objects.equals(contract.getIsDeleted(), 1)) {
throw new ServiceException("合同不存在");
}
String targetSettlementType = settlementType(settlementType);
Long currentUserId = AuthUtil.getUserId();
Date generateTime = new Date();
List<Long> createdIds = new ArrayList<>();
for (Map<String, Object> feeRow : feeRows) {
if (feeRow == null) {
continue;
}
Long waybillId = toLong(feeRow.get("waybillId"));
if (waybillId == null) {
continue;
}
Waybill waybill = waybillService.getById(waybillId);
if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) {
throw new ServiceException("运单不存在:" + waybillId);
}
assertWaybillNotSettledForImport(waybill, targetSettlementType);
BigDecimal transportFee = money(decimal(feeRow.get("transportFee")));
BigDecimal unitPrice = money(decimal(feeRow.get("unitPrice")));
Map<String, BigDecimal> feeItems = normalizeFeeItems(toFeeItemMap(feeRow.get("feeItems")));
BigDecimal feeItemTotal = feeItems.values().stream().map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal afterAmount = transportFee.add(feeItemTotal).setScale(2, RoundingMode.HALF_UP);
ReceivablePayableCargoFee cargoFee = buildSettlementImportCargoFee(
waybill, transportFee, unitPrice, feeItems, afterAmount, stringValue(feeRow.get("remark")));
ReceivablePayableDetail importDetail = getOne(Wrappers.<ReceivablePayableDetail>lambdaQuery()
.eq(ReceivablePayableDetail::getWaybillId, waybillId)
.eq(ReceivablePayableDetail::getSettlementType, targetSettlementType)
.eq(ReceivablePayableDetail::getSourceType, SOURCE_SETTLEMENT_IMPORT)
.eq(ReceivablePayableDetail::getIsDeleted, 0)
.orderByDesc(ReceivablePayableDetail::getCreateTime)
.last("limit 1"), false);
if (importDetail != null) {
refreshSettlementImportDetail(importDetail, waybill, contract, targetSettlementType,
cargoFee, unitPrice, stringValue(feeRow.get("remark")), currentUserId, generateTime);
createdIds.add(importDetail.getId());
continue;
}
ReceivablePayableDetail detail = buildDetail(waybill, contract, targetSettlementType,
List.of(cargoFee), unitPrice);
detail.setSourceType(SOURCE_SETTLEMENT_IMPORT);
detail.setSettlementStatus("pending");
if (Func.isNotEmpty(stringValue(feeRow.get("remark")))) {
detail.setRemark(stringValue(feeRow.get("remark")));
}
saveAutomaticDetail(detail, List.of(cargoFee), false, currentUserId, generateTime);
createdIds.add(detail.getId());
}
return createdIds;
}
/** 运单在该结算类型下已进入预/正式结算时,禁止再导入。 */
private void assertWaybillNotSettledForImport(Waybill waybill, String settlementType) {
List<ReceivablePayableDetail> details = list(Wrappers.<ReceivablePayableDetail>lambdaQuery()
.eq(ReceivablePayableDetail::getWaybillId, waybill.getId())
.eq(ReceivablePayableDetail::getSettlementType, settlementType)
.eq(ReceivablePayableDetail::getIsDeleted, 0));
for (ReceivablePayableDetail detail : details) {
String settlementStatus = Func.toStr(detail.getSettlementStatus(), "pending");
if (Func.isNotEmpty(detail.getFormalSettlementNo())
|| Func.isNotEmpty(detail.getPreSettlementNo())
|| List.of("pre_settled", "formal_settled", "closed").contains(settlementStatus)) {
throw new ServiceException("运单" + Func.toStr(waybill.getWaybillNo(), String.valueOf(waybill.getId()))
+ "已进入结算,无法重复导入");
}
}
}
private ReceivablePayableCargoFee buildSettlementImportCargoFee(Waybill waybill, BigDecimal transportFee,
BigDecimal unitPrice, Map<String, BigDecimal> feeItems,
BigDecimal afterAmount, String remark) {
ReceivablePayableCargoFee cargoFee = new ReceivablePayableCargoFee();
cargoFee.setWaybillId(waybill.getId());
cargoFee.setLineNo("0001");
cargoFee.setDataSource(FEE_SOURCE_MANUAL);
cargoFee.setCargoName(waybill.getCargoName());
cargoFee.setCargoType(waybill.getCargoType());
cargoFee.setSpecification(waybill.getSpecification());
cargoFee.setModel(waybill.getModel());
cargoFee.setBillingFactor("按重量");
cargoFee.setBillingType("固定单价");
cargoFee.setTransportQuantity(money(waybill.getQuantity()));
cargoFee.setQuantityUnit(waybill.getQuantityUnit());
cargoFee.setPriceUnit(waybill.getPriceUnit());
cargoFee.setUnitPrice(unitPrice);
cargoFee.setMileage(normalizeGeneratedMileage(waybill.getMileage()));
cargoFee.setFreightAmount(transportFee);
cargoFee.setFeeItemsJson(JsonUtil.toJson(feeItems));
cargoFee.setOriginalAmount(afterAmount);
cargoFee.setAdjustAmount(BigDecimal.ZERO.setScale(2));
cargoFee.setAfterAmount(afterAmount);
cargoFee.setRemark(remark);
return cargoFee;
}
/** 刷新已有结算导入明细:费用项目与金额始终覆盖为本次导入数据。 */
private void refreshSettlementImportDetail(ReceivablePayableDetail detail, Waybill waybill, ContractManage contract,
String settlementType, ReceivablePayableCargoFee cargoFee,
BigDecimal unitPrice, String remark, Long currentUserId, Date generateTime) {
ReceivablePayableDetail rebuilt = buildDetail(waybill, contract, settlementType, List.of(cargoFee), unitPrice);
detail.setFreightAmount(rebuilt.getFreightAmount());
detail.setOtherFeeAmount(rebuilt.getOtherFeeAmount());
detail.setTotalAmount(rebuilt.getTotalAmount());
detail.setUnitPrice(rebuilt.getUnitPrice());
detail.setCurrency(rebuilt.getCurrency());
detail.setFeeItemsJson(rebuilt.getFeeItemsJson());
detail.setContractId(rebuilt.getContractId());
detail.setContractNo(rebuilt.getContractNo());
detail.setContractName(rebuilt.getContractName());
detail.setCustomerName(rebuilt.getCustomerName());
detail.setSourceType(SOURCE_SETTLEMENT_IMPORT);
detail.setSettlementStatus("pending");
if (Func.isNotEmpty(remark)) {
detail.setRemark(remark);
}
updateById(detail);
cargoFeeMapper.delete(Wrappers.<ReceivablePayableCargoFee>lambdaQuery()
.eq(ReceivablePayableCargoFee::getDetailId, detail.getId()));
ReceivablePayableCargoFee copy = BeanUtil.copyProperties(cargoFee, ReceivablePayableCargoFee.class);
copy.setId(null);
copy.setDetailId(detail.getId());
fillGeneratedAuditFields(copy, currentUserId, generateTime);
cargoFeeMapper.insert(copy);
}
@SuppressWarnings("unchecked")
private Map<String, BigDecimal> toFeeItemMap(Object value) {
Map<String, BigDecimal> result = new LinkedHashMap<>();
if (!(value instanceof Map<?, ?> raw)) {
return result;
}
raw.forEach((key, amount) -> {
if (key == null) {
return;
}
result.put(String.valueOf(key), decimal(amount));
});
return result;
}
private Long toLong(Object value) {
if (value == null || "".equals(value)) {
return null;
}
if (value instanceof Number number) {
return number.longValue();
}
try {
return Long.valueOf(String.valueOf(value).trim());
} catch (NumberFormatException exception) {
return null;
}
}
private String stringValue(Object value) {
if (value == null) {
return null;
}
String text = String.valueOf(value).trim();
return text.isEmpty() ? null : text;
}
@Override
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW,
rollbackFor = Exception.class)
@@ -884,11 +1082,20 @@ public class ReceivablePayableDetailServiceImpl
List<Waybill> payableWaybills = waybills.stream()
.filter(waybill -> !isSelfTransport(waybill)).toList();
if (payableWaybills.isEmpty()) return;
List<Waybill> carrierMatchedWaybills = payableWaybills.stream()
.filter(waybill -> matchesWaybillCarrier(carrierContract, waybill)).toList();
List<Waybill> otherCarrierWaybills = payableWaybills.stream()
.filter(waybill -> !matchesWaybillCarrier(carrierContract, waybill)).toList();
if (!otherCarrierWaybills.isEmpty()) {
generateAutomaticWaybillDetails(otherCarrierWaybills, false,
this::resolveCarrierContract, currentUserId, generateTime);
}
if (carrierMatchedWaybills.isEmpty()) return;
generateAutomaticDetail(carrierContract, "payable",
() -> existsByLoading(loadingNo, "payable"), payableWaybills,
() -> existsByLoading(loadingNo, "payable"), carrierMatchedWaybills,
(fees, unitPrice) -> {
normalizeWaybillFeeLines(fees);
return buildLoadingDetail(loadingNo, carrierContract, payableWaybills, fees, unitPrice);
return buildLoadingDetail(loadingNo, carrierContract, carrierMatchedWaybills, fees, unitPrice);
},
false, currentUserId, generateTime);
}
@@ -938,22 +1145,54 @@ public class ReceivablePayableDetailServiceImpl
private ContractManage resolveWaybillCarrierContract(Waybill waybill) {
if (Func.isNotEmpty(waybill.getLoadingNo())) return null;
return resolveCarrierContract(waybill);
}
/**
* 应付只认运单选择的承运商。
* 运单已记录承运商合同时,乙方必须就是该承运商;否则按承运商名称回查同一项目下的承运商合同。
* 不允许落到项目里其他承运商合同的客商。
*/
private ContractManage resolveCarrierContract(Waybill waybill) {
if (waybill == null || isSelfTransport(waybill)) {
return null;
}
String carrierName = trimToNull(waybill.getCarrierName());
if (Func.isNotEmpty(waybill.getCarrierContractId())) {
ContractManage contract = contractManageService.getById(waybill.getCarrierContractId());
if (contract == null || !"承运商合同".equals(contract.getContractCategory())) {
ContractManage recorded = contractManageService.getById(waybill.getCarrierContractId());
if (recorded == null || !"承运商合同".equals(recorded.getContractCategory())) {
throw new ServiceException("运单记录的承运商合同不存在或合同类别不正确");
}
return contract;
if (carrierName == null || matchesWaybillCarrier(recorded, waybill)) {
return recorded;
}
}
return findCarrierContract(waybill);
ContractManage matched = findCarrierContract(waybill);
if (matched != null && matchesWaybillCarrier(matched, waybill)) {
return matched;
}
return null;
}
private boolean matchesWaybillCarrier(ContractManage contract, Waybill waybill) {
if (contract == null || waybill == null || !"承运商合同".equals(contract.getContractCategory())) {
return false;
}
if (waybill.getProjectId() != null && contract.getProjectId() != null
&& !Objects.equals(waybill.getProjectId(), contract.getProjectId())) {
return false;
}
String carrierName = trimToNull(waybill.getCarrierName());
String partyB = trimToNull(contract.getPartyB());
return carrierName != null && carrierName.equals(partyB);
}
private ContractManage findCarrierContract(Waybill waybill) {
if (waybill.getProjectId() == null || Func.isEmpty(waybill.getContractId())
|| Func.isEmpty(waybill.getCarrierName())) {
String carrierName = trimToNull(waybill.getCarrierName());
if (waybill.getProjectId() == null || Func.isEmpty(waybill.getContractId()) || carrierName == null) {
return null;
}
// 承运商合同甲方应与运单绑定的客户合同甲方一致,不能直接使用运单的客户名称拼接字段。
// 承运商合同甲方应与运单绑定的客户合同甲方一致,乙方必须是运单选择的承运商。
ContractManage customerContract = contractManageService.getById(waybill.getContractId());
if (customerContract == null || Func.isEmpty(customerContract.getPartyA())) {
return null;
@@ -962,13 +1201,38 @@ public class ReceivablePayableDetailServiceImpl
.eq(ContractManage::getIsDeleted, 0)
.eq(ContractManage::getProjectId, waybill.getProjectId())
.eq(ContractManage::getPartyA, customerContract.getPartyA())
.eq(ContractManage::getPartyB, waybill.getCarrierName())
.eq(ContractManage::getPartyB, carrierName)
.eq(ContractManage::getContractCategory, "承运商合同")
.and(wrapper -> wrapper.isNull(ContractManage::getContractStage)
.or().ne(ContractManage::getContractStage, "terminated"))
.orderByDesc(ContractManage::getCreateTime), false);
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String text = value.trim();
return text.isEmpty() ? null : text;
}
/**
* 应付客商取运单选择的承运商,避免写成项目内其他承运商合同的乙方。
*/
private String resolveCounterpartyName(Waybill waybill, ContractManage contract, String settlementType) {
if ("payable".equals(settlementType)) {
String carrierName = trimToNull(waybill == null ? null : waybill.getCarrierName());
if (carrierName != null) {
return carrierName;
}
return contract == null ? null : contract.getPartyB();
}
if (contract != null && Func.isNotEmpty(contract.getPartyA())) {
return contract.getPartyA();
}
return waybill == null ? null : waybill.getCustomerName();
}
@Override
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW,
rollbackFor = Exception.class)
@@ -1021,12 +1285,24 @@ public class ReceivablePayableDetailServiceImpl
.collect(Collectors.toMap(ContractManage::getId, contract -> contract));
Map<Long, List<Waybill>> carrierWaybills = waybills.stream().collect(Collectors.groupingBy(
Waybill::getCarrierContractId, LinkedHashMap::new, Collectors.toList()));
List<Waybill> mismatchedCarrierWaybills = waybills.stream()
.filter(waybill -> !matchesWaybillCarrier(carrierContracts.get(waybill.getCarrierContractId()), waybill))
.toList();
if (!mismatchedCarrierWaybills.isEmpty()) {
generateAutomaticWaybillDetails(mismatchedCarrierWaybills, false,
this::resolveCarrierContract, currentUserId, generateTime);
}
for (Map.Entry<Long, List<Waybill>> entry : carrierWaybills.entrySet()) {
ContractManage carrierContract = carrierContracts.get(entry.getKey());
if (carrierContract == null || !"承运商合同".equals(carrierContract.getContractCategory())) {
throw new ServiceException("总单【" + masterOrder.getMasterNo() + "】记录的承运商合同不存在或合同类别不正确");
}
List<Waybill> currentCarrierWaybills = entry.getValue();
List<Waybill> currentCarrierWaybills = entry.getValue().stream()
.filter(waybill -> matchesWaybillCarrier(carrierContract, waybill))
.toList();
if (currentCarrierWaybills.isEmpty()) {
continue;
}
generateAutomaticDetail(carrierContract, "payable",
() -> existsByMasterOrderContract(masterOrder.getMasterNo(), "payable", carrierContract.getId()),
currentCarrierWaybills,
@@ -1455,9 +1731,7 @@ public class ReceivablePayableDetailServiceImpl
detail.setDeptId(contract == null ? waybill.getDeptId() : contract.getOrganizationId());
detail.setDeptName(contract == null ? waybill.getDeptName() : contract.getOrganizationName());
detail.setFeeDate(waybill.getEndDate() == null ? LocalDate.now() : waybill.getEndDate());
detail.setCustomerName(contract == null
? ("payable".equals(settlementType) ? waybill.getCarrierName() : waybill.getCustomerName())
: ("payable".equals(settlementType) ? contract.getPartyB() : contract.getPartyA()));
detail.setCustomerName(resolveCounterpartyName(waybill, contract, settlementType));
detail.setContractId(contract == null ? waybill.getContractId() : contract.getId());
detail.setContractNo(contract == null ? null : contract.getContractNo());
detail.setContractName(contract == null ? waybill.getContractName() : contract.getContractName());
@@ -1472,8 +1746,8 @@ public class ReceivablePayableDetailServiceImpl
detail.setArrivalContact(waybill.getArrivalContact());
detail.setArrivalPhone(waybill.getArrivalPhone());
detail.setTransportType(waybill.getTransportType());
detail.setCargoName(waybill.getCargoName());
detail.setCargoType(waybill.getCargoType());
detail.setCargoName(joinDistinctGoods(waybill, "cargoName", waybill.getCargoName()));
detail.setCargoType(joinDistinctGoods(waybill, "cargoType", waybill.getCargoType()));
detail.setTransportQuantity(resolveWaybillTransportQuantity(waybill));
detail.setQuantityUnit(waybill.getQuantityUnit());
detail.setMileage(normalizeGeneratedMileage(waybill.getMileage()));
@@ -1488,6 +1762,21 @@ public class ReceivablePayableDetailServiceImpl
return detail;
}
/** 一单多货时主表只保留首行货物,应付明细改记全部货物名称、货物类型。 */
private String joinDistinctGoods(Waybill waybill, String field, String fallback) {
LinkedHashSet<String> values = new LinkedHashSet<>();
for (Map<String, Object> goods : parseList(waybill.getGoodsJson())) {
String value = stringValue(goods, field, "");
if (Func.isNotEmpty(value)) {
values.add(value.trim());
}
}
if (values.isEmpty()) {
return fallback;
}
return String.join(",", values);
}
/**
* 多货物运单优先按 goodsJson 数量合计作为运输总量,避免主表 quantity 只落了首行。
*/
@@ -1677,21 +1966,35 @@ public class ReceivablePayableDetailServiceImpl
}
private List<ReceivablePayableCargoFee> calculatedFees(Waybill waybill, ContractManage contract, String planId) {
return calculatedFees(waybill, contract, planId, false);
return calculatedFees(waybill, contract, planId, false, false);
}
private List<ReceivablePayableCargoFee> calculatedFees(Waybill waybill, ContractManage contract, String planId, boolean matchOnly) {
private List<ReceivablePayableCargoFee> calculatedFees(Waybill waybill, ContractManage contract, String planId,
boolean matchOnly) {
return calculatedFees(waybill, contract, planId, matchOnly, false);
}
/**
* @param strictPlan 更新费用时为 true:选定方案有规则但未命中时,不再回退运单历史费用,避免旧费用项叠加
*/
private List<ReceivablePayableCargoFee> calculatedFees(Waybill waybill, ContractManage contract, String planId,
boolean matchOnly, boolean strictPlan) {
List<Map<String, Object>> plans = parseList(contract == null ? null : contract.getBillingPlanJson());
Map<String, Object> plan = "__matched__".equals(planId)
? resolveDefaultBillingPlan(plans, waybill.getTransportType())
: resolveBillingPlan(plans, planId);
return calculatedFees(waybill, plan, matchOnly);
return calculatedFees(waybill, plan, matchOnly, strictPlan);
}
private List<ReceivablePayableCargoFee> calculatedFees(Waybill waybill, Map<String, Object> plan,
boolean matchOnly) {
return calculatedFees(waybill, plan, matchOnly, false);
}
private List<ReceivablePayableCargoFee> calculatedFees(Waybill waybill, Map<String, Object> plan,
boolean matchOnly, boolean strictPlan) {
if (plan == null || !(plan.get("rules") instanceof List<?>)) {
return matchOnly ? List.of() : buildCargoFees(waybill);
return matchOnly || strictPlan ? List.of() : buildCargoFees(waybill);
}
Map<List<String>, ReceivablePayableCargoFee> feesByCargo = new LinkedHashMap<>();
Map<List<String>, Map<String, BigDecimal>> feeItemsByCargo = new LinkedHashMap<>();
@@ -1733,7 +2036,11 @@ public class ReceivablePayableDetailServiceImpl
ReceivablePayableCargoFee fee = result.get(index);
fee.setLineNo(String.format("%04d", index + 1));
}
return result.isEmpty() && !matchOnly ? buildCargoFees(waybill) : result;
// 已选定计费方案时,只保留该方案算出的费用项,不用运单上的历史费用回填。
if (strictPlan || matchOnly) {
return result;
}
return result.isEmpty() ? buildCargoFees(waybill) : result;
}
private ReceivablePayableCargoFee buildCalculatedCargoFee(Waybill waybill, Waybill feeWaybill,
@@ -2254,10 +2561,16 @@ public class ReceivablePayableDetailServiceImpl
private AdjustedFeeCalculation calculateAdjustedFee(ReceivablePayableDetail detail,
ReceivablePayableCargoFee fee, BigDecimal transportQuantity,
BigDecimal mileage, BigDecimal freightAmount,
Map<String, BigDecimal> feeItems) {
Map<String, BigDecimal> feeItems, BigDecimal unitPrice,
String changedField) {
if (isManualFee(fee)) {
throw new ServiceException("手工费用不支持按合同计费规则试算");
}
boolean transportChanged = Func.isEmpty(changedField)
|| List.of("transportQuantity", "mileage", "unitPrice").contains(changedField);
if (!transportChanged) {
return new AdjustedFeeCalculation(freightAmount, new LinkedHashMap<>(feeItems), null);
}
Waybill adjustedWaybill = adjustedWaybill(detail, fee, transportQuantity, mileage);
List<Map<String, Object>> rules = parseList(fee.getBillingRulesJson());
if (rules.isEmpty()) {
@@ -2273,26 +2586,94 @@ public class ReceivablePayableDetailServiceImpl
if (rules.isEmpty()) {
throw new ServiceException("未找到费用明细对应的合同计费规则,请先更新费用");
}
Map<String, BigDecimal> calculatedAmounts = new LinkedHashMap<>();
BigDecimal calculatedFreight = BigDecimal.ZERO;
boolean freightRuleMatched = false;
Map<String, BigDecimal> calculatedFeeItems = new LinkedHashMap<>(feeItems);
BigDecimal calculatedFreight = null;
BigDecimal resolvedUnitPrice = null;
boolean calculated = false;
for (Map<String, Object> rule : rules) {
BigDecimal amount = calculateRule(rule, adjustedWaybill);
if (amount == null) continue;
if (!shouldRecalculateRule(rule, changedField)) {
continue;
}
String type = stringValue(rule, "billingType", "");
boolean freightRule = isFreightRule(rule);
Map<String, Object> effectiveRule = rule;
if (freightRule && unitPriceAdjustable(type) && unitPrice != null) {
effectiveRule = new LinkedHashMap<>(rule);
effectiveRule.put("unitPrice", unitPrice);
resolvedUnitPrice = unitPrice;
}
BigDecimal amount = calculateRule(effectiveRule, adjustedWaybill);
if (amount == null) {
continue;
}
calculated = true;
String feeItem = stringValue(rule, "feeItem", "费用");
calculatedAmounts.merge(feeItem, amount, BigDecimal::add);
if (isFreightRule(rule)) {
calculatedFreight = calculatedFreight.add(amount);
freightRuleMatched = true;
calculatedFeeItems.put(feeItem, amount);
if (freightRule) {
calculatedFreight = (calculatedFreight == null ? BigDecimal.ZERO : calculatedFreight).add(amount);
if (intervalBilling(type)) {
resolvedUnitPrice = matchedIntervalUnitPrice(rule, adjustedWaybill);
}
}
}
if (calculatedAmounts.isEmpty()) {
if (!calculated && Func.isEmpty(changedField)) {
throw new ServiceException("费用明细对应的合同计费规则无法试算,请先更新费用");
}
Map<String, BigDecimal> calculatedFeeItems = new LinkedHashMap<>(feeItems);
calculatedAmounts.forEach(calculatedFeeItems::put);
return new AdjustedFeeCalculation(freightRuleMatched ? calculatedFreight : freightAmount,
calculatedFeeItems);
return new AdjustedFeeCalculation(calculatedFreight == null ? freightAmount : calculatedFreight,
calculatedFeeItems, resolvedUnitPrice);
}
/**
* 运费在运输总量、里程、可调整单价变化后按方案重算。
* 其他费用仅在计费要素使用了本次变化的运输总量或里程时重算,便于保留手工修改的费用总额。
*/
private boolean shouldRecalculateRule(Map<String, Object> rule, String changedField) {
String element = stringValue(rule, "billingElement", "");
String type = stringValue(rule, "billingType", "");
if (isFreightRule(rule)) {
return !"unitPrice".equals(changedField) || unitPriceAdjustable(type);
}
if ("unitPrice".equals(changedField) || Func.isEmpty(changedField)) {
return Func.isEmpty(changedField) && (usesTransportQuantity(element) || usesMileage(element));
}
if ("transportQuantity".equals(changedField)) {
return usesTransportQuantity(element);
}
if ("mileage".equals(changedField)) {
return usesMileage(element);
}
return false;
}
private boolean usesTransportQuantity(String element) {
return List.of("按重量", "按体积", "按数量", "按吨·公里").contains(element);
}
private boolean usesMileage(String element) {
return List.of("按里程", "按吨·公里").contains(element);
}
private boolean unitPriceAdjustable(String billingType) {
return !tierUnitPrice(billingType) && !intervalBilling(billingType);
}
private boolean tierUnitPrice(String billingType) {
return "阶梯单价".equals(billingType);
}
private boolean intervalBilling(String billingType) {
String type = billingType == null ? "" : billingType;
return "区间单价".equals(type) || (type.contains("区间") && type.contains("一口价"));
}
private BigDecimal matchedIntervalUnitPrice(Map<String, Object> rule, Waybill waybill) {
BigDecimal fallback = decimal(rule.get("unitPrice"));
return range(ranges(rule), measure(rule, waybill))
.map(matched -> {
BigDecimal matchedPrice = decimal(matched.get("unitPrice"));
return matchedPrice.signum() == 0 ? fallback : matchedPrice;
})
.orElse(fallback);
}
private Waybill adjustedWaybill(ReceivablePayableDetail detail, ReceivablePayableCargoFee fee,
@@ -2365,7 +2746,8 @@ public class ReceivablePayableDetailServiceImpl
return name != null && (name.contains("运费") || name.contains("运输费"));
}
private record AdjustedFeeCalculation(BigDecimal freightAmount, Map<String, BigDecimal> feeItems) {
private record AdjustedFeeCalculation(BigDecimal freightAmount, Map<String, BigDecimal> feeItems,
BigDecimal unitPrice) {
}
private void appendChange(List<String> changes, String field, BigDecimal before, BigDecimal after) {
@@ -2568,7 +2950,7 @@ public class ReceivablePayableDetailServiceImpl
throw new ServiceException("关联运单不存在");
}
ContractManage contract = contractManageService.getById(detail.getContractId());
List<ReceivablePayableCargoFee> fees = calculatedFees(waybill, contract, billingPlanId);
List<ReceivablePayableCargoFee> fees = calculatedFees(waybill, contract, billingPlanId, false, true);
cargoFeeMapper.delete(Wrappers.<ReceivablePayableCargoFee>lambdaQuery().eq(ReceivablePayableCargoFee::getDetailId, detail.getId()));
fees.forEach(fee -> { fee.setDetailId(detail.getId()); cargoFeeMapper.insert(fee); });
BigDecimal freight = fees.stream().map(fee -> money(fee.getFreightAmount()))
@@ -2593,7 +2975,7 @@ public class ReceivablePayableDetailServiceImpl
ContractManage contract = contractManageService.getById(detail.getContractId());
List<Waybill> masterGoods = masterOrderGoods(masterOrder);
List<ReceivablePayableCargoFee> fees = masterGoods.stream()
.flatMap(goods -> calculatedFees(goods, contract, billingPlanId).stream()).toList();
.flatMap(goods -> calculatedFees(goods, contract, billingPlanId, false, true).stream()).toList();
normalizeMasterFeeLines(fees);
cargoFeeMapper.delete(Wrappers.<ReceivablePayableCargoFee>lambdaQuery()
.eq(ReceivablePayableCargoFee::getDetailId, detail.getId()));
@@ -2629,7 +3011,7 @@ public class ReceivablePayableDetailServiceImpl
}
ContractManage contract = contractManageService.getById(detail.getContractId());
List<ReceivablePayableCargoFee> fees = waybills.stream()
.flatMap(waybill -> calculatedFees(waybill, contract, billingPlanId).stream()).toList();
.flatMap(waybill -> calculatedFees(waybill, contract, billingPlanId, false, true).stream()).toList();
normalizeWaybillFeeLines(fees);
cargoFeeMapper.delete(Wrappers.<ReceivablePayableCargoFee>lambdaQuery()
.eq(ReceivablePayableCargoFee::getDetailId, detail.getId()));
@@ -2704,21 +3086,29 @@ public class ReceivablePayableDetailServiceImpl
if (detailIds.isEmpty()) return;
List<ReceivablePayableCargoFee> fees = cargoFeeMapper.selectList(
Wrappers.<ReceivablePayableCargoFee>lambdaQuery()
.select(ReceivablePayableCargoFee::getDetailId, ReceivablePayableCargoFee::getCargoName)
.select(ReceivablePayableCargoFee::getDetailId, ReceivablePayableCargoFee::getCargoName,
ReceivablePayableCargoFee::getBillingType)
.in(ReceivablePayableCargoFee::getDetailId, detailIds)
.eq(ReceivablePayableCargoFee::getIsDeleted, 0)
.orderByAsc(ReceivablePayableCargoFee::getDetailId)
.orderByAsc(ReceivablePayableCargoFee::getLineNo));
Map<Long, LinkedHashSet<String>> cargoNames = new LinkedHashMap<>();
Map<Long, LinkedHashSet<String>> billingTypes = new LinkedHashMap<>();
for (ReceivablePayableCargoFee fee : fees) {
if (Func.isEmpty(fee.getCargoName())) continue;
cargoNames.computeIfAbsent(fee.getDetailId(), key -> new LinkedHashSet<>()).add(fee.getCargoName());
if (Func.isNotEmpty(fee.getCargoName())) {
cargoNames.computeIfAbsent(fee.getDetailId(), key -> new LinkedHashSet<>()).add(fee.getCargoName());
}
if (Func.isNotEmpty(fee.getBillingType()) && !"-".equals(fee.getBillingType())) {
billingTypes.computeIfAbsent(fee.getDetailId(), key -> new LinkedHashSet<>()).add(fee.getBillingType());
}
}
details.forEach(detail -> {
Set<String> names = cargoNames.get(detail.getId());
if (Func.isNotEmpty(names)) {
detail.setCargoName(String.join(",", names));
}
Set<String> types = billingTypes.get(detail.getId());
detail.setBillingType(Func.isEmpty(types) ? "" : String.join("、", types));
});
}
@@ -2924,6 +3314,7 @@ public class ReceivablePayableDetailServiceImpl
map.put("contractId", detail.getContractId());
map.put("contractNo", detail.getContractNo());
map.put("contractName", detail.getContractName());
map.put("sourceType", detail.getSourceType());
map.put("preSettlementNo", detail.getPreSettlementNo());
map.put("formalSettlementNo", detail.getFormalSettlementNo());
map.put("waybillNo", detail.getWaybillNo());
@@ -4,12 +4,18 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.core.tool.utils.SpringUtil;
import org.springblade.system.cache.UserCache;
import org.springblade.transport.constant.KingdeeBfiConstant;
import org.springblade.transport.constant.TransportBusiConstant;
import org.springblade.transport.event.KingdeeBillApprovedEvent;
import org.springblade.transport.support.KingdeeApBillSyncSupport;
import org.springblade.transport.support.KingdeeArBillSyncSupport;
import org.springblade.transport.mapper.FormalSettlementDetailFeeMapper;
import org.springblade.transport.mapper.FormalSettlementDetailMapper;
import org.springblade.transport.mapper.FormalSettlementChangeRecordMapper;
@@ -33,7 +39,10 @@ import org.springblade.transport.pojo.entity.PaymentApplicationSettlement;
import org.springblade.transport.pojo.entity.SettlementAdjustment;
import org.springblade.transport.pojo.entity.SettlementAdjustmentDetail;
import org.springblade.transport.pojo.vo.SettlementAdjustmentVO;
import org.springblade.transport.service.IFormalSettlementService;
import org.springblade.transport.service.ISettlementAdjustmentService;
import org.springblade.transport.service.ISettlementProjectAdvanceWriteOffService;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -51,6 +60,7 @@ import java.util.Set;
@Service
@RequiredArgsConstructor
@Slf4j
public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementAdjustmentMapper, SettlementAdjustment>
implements ISettlementAdjustmentService {
private static final String PAID = "paid";
@@ -63,6 +73,11 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
private final FormalSettlementPaymentMapper formalPaymentMapper;
private final PaymentApplicationMapper paymentApplicationMapper;
private final PaymentApplicationSettlementMapper paymentApplicationSettlementMapper;
private final IFormalSettlementService formalSettlementService;
private final ISettlementProjectAdvanceWriteOffService projectAdvanceWriteOffService;
private final KingdeeApBillSyncSupport kingdeeApBillSyncSupport;
private final KingdeeArBillSyncSupport kingdeeArBillSyncSupport;
private final ApplicationEventPublisher eventPublisher;
@Override
public IPage<SettlementAdjustmentVO> selectPage(IPage<SettlementAdjustment> page, SettlementAdjustmentVO query) {
@@ -204,24 +219,142 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
}
@Override public void returnBill(SettlementAdjustmentStatusRequest request) { changeStatus(request.getId(), TransportBusiConstant.REVIEWING, TransportBusiConstant.RETURNED, "已驳回", limit(request.getReason(), 200)); }
/**
* 重新推送(高危):原单已推送金蝶且旧单已在金蝶冲销后,克隆新正式结算单并应用调整,
* 以新单号组成新防重键生成全新应付/应收单,调整单改挂新单、原单标记已调整并解锁旧锁。
* <p>
* 非事务编排:克隆+应用(独立事务)→ 推送(外部远程)→ 收尾回填,推送失败不回滚克隆,
* 重试时按来源原单号幂等复用已克隆新单,从推送步骤续上。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public String repush(Long adjustmentId) {
SettlementAdjustment adjustment = existing(adjustmentId);
if (!TransportBusiConstant.APPROVED.equals(adjustment.getApprovalStatus())) throw new ServiceException("仅审批通过的结算调整单允许重新推送");
FormalSettlement formal = formalMapper.selectById(adjustment.getFormalSettlementId());
if (formal == null || !"synced".equals(formal.getKingdeeSyncStatus())) throw new ServiceException("关联正式结算单尚未推送金蝶,无需重新推送");
String kingdeeNo = "K3AP" + DateTimeFormatter.ofPattern("yyyyMMddHHmmss").format(LocalDateTime.now());
formal.setKingdeeBillNo(kingdeeNo); formal.setSyncedTime(LocalDateTime.now()); formalMapper.updateById(formal);
return kingdeeNo;
if (!TransportBusiConstant.APPROVED.equals(adjustment.getApprovalStatus())) {
throw new ServiceException("仅审批通过的结算调整单允许重新推送");
}
FormalSettlement original = formalMapper.selectById(adjustment.getFormalSettlementId());
if (original == null) {
throw new ServiceException("关联正式结算单不存在");
}
if (!KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(original.getKingdeeSyncStatus())) {
throw new ServiceException("结算单[" + Func.toStr(original.getFormalSettlementNo())
+ "]尚未推送金蝶,请先执行「推送金蝶」");
}
// 冲销硬闸门:仅当金蝶侧原应付/应收单已被冲销(部分冲销放行)才允许以新单号重推,避免重复生成财务单据
if (!this.kingdeeWrittenOff(original)) {
throw new ServiceException("结算单[" + Func.toStr(original.getFormalSettlementNo())
+ "]的原应付/应收单尚未在金蝶冲销,禁止重推");
}
// 幂等:同一原单已克隆过新单则复用(重试从推送步骤续上),避免重复建单
FormalSettlement clone = existingClone(original.getFormalSettlementNo());
if (clone == null) {
Long cloneId = self().cloneAndApplyForRepush(original.getId(), adjustment.getId());
clone = formalMapper.selectById(cloneId);
}
// 推送新单:新单号天然组成新防重键,内部完成 A102-2/A302-2 推送 + 落锁 + 附件上传
// shortcut: 成功后若再次点击重推,调整单已改挂新单且新单未冲销,会被冲销闸门拦截(无副作用);如需彻底隐藏由前端按「关联单是否克隆单」判断
String billNo = formalSettlementService.syncKingdee(clone.getId());
// 成功收尾:调整单改挂新单 + 回填金蝶单号/同步状态;原单标记已调整 + 旧锁解锁(业务数据不动)
SettlementAdjustment patch = new SettlementAdjustment();
patch.setId(adjustment.getId());
patch.setFormalSettlementId(clone.getId());
patch.setFormalSettlementNo(clone.getFormalSettlementNo());
patch.setKingdeeSyncStatus(KingdeeBfiConstant.KINGDEE_STATUS_SYNCED);
patch.setKingdeeBillNo(billNo);
updateById(patch);
formalSettlementService.markSettlementAdjusted(original.getId(), adjustment.getAdjustmentNo(), clone.getFormalSettlementNo());
return billNo;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void approve(SettlementAdjustmentStatusRequest request) {
public Long cloneAndApplyForRepush(Long originalSettlementId, Long adjustmentId) {
IFormalSettlementService.CloneResult cloneResult = formalSettlementService.cloneFormalSettlement(originalSettlementId);
FormalSettlement clone = formalMapper.selectById(cloneResult.settlementId());
applyAdjustmentToSettlement(clone, existing(adjustmentId), cloneResult.detailIdMap(), cloneResult.feeIdMap());
return cloneResult.settlementId();
}
@Override
public String syncKingdee(Long adjustmentId) {
SettlementAdjustment adjustment = existing(adjustmentId);
if (!TransportBusiConstant.APPROVED.equals(adjustment.getApprovalStatus())) {
throw new ServiceException("仅审批通过的结算调整单允许推送金蝶");
}
// 未推送/生成失败场景:推送关联正式结算单,成功后由正式结算单回填调整单金蝶单号与同步状态
return formalSettlementService.syncKingdee(adjustment.getFormalSettlementId());
}
@Override
public Map<String, Object> kingdeeWriteOffPrecheck(Long formalSettlementId) {
Map<String, Object> result = new HashMap<>();
FormalSettlement settlement = formalMapper.selectById(formalSettlementId);
if (settlement == null) {
throw new ServiceException("关联正式结算单不存在");
}
if (!KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(settlement.getKingdeeSyncStatus())) {
result.put("syncStatus", KingdeeBfiConstant.KINGDEE_STATUS_UNSYNCED);
result.put("writtenOff", null);
result.put("message", "该结算单尚未推送金蝶,审批通过后将自动推送原单");
return result;
}
result.put("syncStatus", KingdeeBfiConstant.KINGDEE_STATUS_SYNCED);
try {
boolean writtenOff = this.kingdeeWrittenOff(settlement);
result.put("writtenOff", writtenOff);
result.put("message", writtenOff
? "该结算单已推送金蝶且旧单已冲销,审批后可直接重新推送新单"
: "该结算单已推送金蝶且旧单尚未冲销,审批通过后需先由财务在金蝶完成冲销,才能重新推送");
} catch (Exception exception) {
// 预检为软提示:金蝶查询异常降级为「未校验」,不阻断建单,由审批分流与重推闸门兜底
log.warn("结算调整单建单预检:金蝶冲销状态查询失败,降级未校验,正式结算单[{}]",
settlement.getFormalSettlementNo(), exception);
result.put("writtenOff", null);
result.put("message", "金蝶冲销状态查询失败,未校验;审批后请以实际冲销情况为准");
}
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public String approve(SettlementAdjustmentStatusRequest request) {
SettlementAdjustment adjustment = existing(request.getId());
if (!TransportBusiConstant.REVIEWING.equals(adjustment.getApprovalStatus())) throw new ServiceException("仅审批中的结算调整单允许审核");
FormalSettlement formal = adjustableFormalSettlement(adjustment.getFormalSettlementId());
boolean synced = KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(formal.getKingdeeSyncStatus());
if (synced) {
// 已推送场景:原单业务数据零改动,仅追加 1 条重推提示变更记录,待财务在金蝶手工冲销旧单后重推
saveFormalChange(formal.getId(), "合计费用项", null, "调整",
"结算调整单" + adjustment.getAdjustmentNo() + "审批通过,调整金额【" + money(adjustment.getAdjustmentAmount()) + "】,待重推金蝶",
adjustment.getRemark());
} else {
// 未推送场景:回写原单费用/金额并重算结算金额,事务提交后由事件监听自动推送金蝶
applyAdjustmentToSettlement(formal, adjustment, Map.of(), Map.of());
saveFormalChange(formal.getId(), "合计费用项", null, "调整",
"调整单" + adjustment.getAdjustmentNo() + "调整金额【" + money(adjustment.getAdjustmentAmount()) + "】",
adjustment.getRemark());
}
adjustment.setKingdeeSyncStatus(formal.getKingdeeSyncStatus()); adjustment.setApprovalStatus(TransportBusiConstant.APPROVED); adjustment.setCurrentNode("审批通过"); adjustment.setCurrentProcessor(AuthUtil.getUserName()); adjustment.setApprovedTime(LocalDateTime.now()); updateById(adjustment);
if (!synced) {
// 审批事务提交后(AFTER_COMMIT)自动推原单,成功回填调整单 synced + 金蝶单号、失败置 failed
eventPublisher.publishEvent(new KingdeeBillApprovedEvent(
KingdeeBillApprovedEvent.BIZ_FORMAL_SETTLEMENT, formal.getId()));
return null;
}
return "原结算单已推送金蝶,请财务人员在金蝶手工完成旧应付/应收单冲销处理后再重新推送";
}
/**
* 应用调整到目标正式结算单(未推送场景作用于原单,重推场景作用于克隆新单):
* 回写明细费项金额、汇总费用项、重算明细与结算金额;明细/费项 id 经映射转换(原单调整作用于原单时传空映射)。
*
* @param formal 目标正式结算单(改动后需落库)
* @param adjustment 结算调整单
* @param detailIdMap 原结算明细id -&gt; 目标结算明细id(原单场景传空 Map)
* @param feeIdMap 原明细费项id -&gt; 目标明细费项id(原单场景传空 Map)
*/
private void applyAdjustmentToSettlement(FormalSettlement formal, SettlementAdjustment adjustment,
Map<Long, Long> detailIdMap, Map<Long, Long> feeIdMap) {
for (SettlementAdjustmentDetail item : detailMapper.selectList(Wrappers.<SettlementAdjustmentDetail>lambdaQuery().eq(SettlementAdjustmentDetail::getAdjustmentId, adjustment.getId()))) {
if (money(item.getAdjustmentAmountTax()).signum() == 0) continue;
if (isManualFee(item)) {
@@ -229,12 +362,14 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
"新增【" + item.getFeeItem() + "】调整费用【" + money(item.getAdjustmentAmountTax()) + "】",
Func.isEmpty(item.getRemark()) ? adjustment.getRemark() : item.getRemark());
} else {
FormalSettlementDetailFee fee = validateFee(formal.getId(), item.getFormalSettlementDetailId(), item.getFormalSettlementDetailFeeId());
Long detailId = detailIdMap.getOrDefault(item.getFormalSettlementDetailId(), item.getFormalSettlementDetailId());
Long feeId = feeIdMap.getOrDefault(item.getFormalSettlementDetailFeeId(), item.getFormalSettlementDetailFeeId());
FormalSettlementDetailFee fee = validateFee(formal.getId(), detailId, feeId);
BigDecimal beforeAmount = money(fee.getSettlementAmountTax());
fee.setSettlementAmountTax(beforeAmount.add(money(item.getAdjustmentAmountTax())));
if (item.getAdjustmentAmountNoTax() != null) fee.setSettlementAmountNoTax(money(fee.getSettlementAmountNoTax()).add(item.getAdjustmentAmountNoTax()));
fee.setAdjustAmount(money(fee.getAdjustAmount()).add(item.getAdjustmentAmountTax())); formalFeeMapper.updateById(fee);
FormalSettlementDetail formalDetail = formalDetailMapper.selectById(item.getFormalSettlementDetailId());
FormalSettlementDetail formalDetail = formalDetailMapper.selectById(detailId);
saveFormalChange(formal.getId(), "结算明细项", formalDetail == null ? null : formalDetail.getLineNo(),
"调整", "【" + (Func.isEmpty(item.getFeeItem()) ? "结算费用" : item.getFeeItem())
+ "】从【" + beforeAmount + "】调整为【" + fee.getSettlementAmountTax() + "】",
@@ -249,15 +384,46 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
detail.setAdjustAmount(detail.getSettlementAmountTax().subtract(money(detail.getOriginalAmount()))); formalDetailMapper.updateById(detail);
}
BigDecimal amount = money(formal.getSettlementAmount()).add(money(adjustment.getAdjustmentAmount()));
// 调整后结算金额不得低于项目预付核销合计,否则已付将超过结算金额且无法拦截
BigDecimal writeOffTotal = this.projectAdvanceWriteOffService.sumBySettlement(
TransportBusiConstant.SETTLEMENT_BILL_FORMAL, formal.getId());
if (money(writeOffTotal).compareTo(money(amount)) > 0) {
throw new ServiceException("调整后结算金额不能低于项目预付核销合计" + money(writeOffTotal) + ",请先调减项目预付核销");
}
formal.setSettlementAmount(amount);
formal.setLocalSettlementAmount(amount.multiply(formal.getExchangeRate() == null ? BigDecimal.ONE : formal.getExchangeRate()));
formal.setRemainingPayableAmount(amount.subtract(money(formal.getPaidAmount()))
.max(BigDecimal.ZERO));
formal.setRemainingPayableAmount(amount.subtract(money(formal.getPaidAmount())).max(BigDecimal.ZERO));
formalMapper.updateById(formal);
saveFormalChange(formal.getId(), "合计费用项", null, "调整",
"调整单" + adjustment.getAdjustmentNo() + "调整金额【" + money(adjustment.getAdjustmentAmount()) + "】",
adjustment.getRemark());
adjustment.setKingdeeSyncStatus(formal.getKingdeeSyncStatus()); adjustment.setApprovalStatus(TransportBusiConstant.APPROVED); adjustment.setCurrentNode("审批通过"); adjustment.setCurrentProcessor(AuthUtil.getUserName()); adjustment.setApprovedTime(LocalDateTime.now()); updateById(adjustment);
// 重算收付汇总(含核销计入),保持付款状态与剩余可付准确
formalSettlementService.refreshPaymentSummary(formal.getId());
}
/** 涉事事务的自代理:跨方法调用确保 {@link #cloneAndApplyForRepush} 的事务注解生效 */
private ISettlementAdjustmentService self() {
return SpringUtil.getBean(ISettlementAdjustmentService.class);
}
/** 按来源原正式结算单号查已克隆新单(重推幂等:同一原单不重复建单) */
private FormalSettlement existingClone(String originFormalSettlementNo) {
List<FormalSettlement> clones = formalMapper.selectList(Wrappers.<FormalSettlement>lambdaQuery()
.eq(FormalSettlement::getOriginFormalSettlementNo, originFormalSettlementNo)
.orderByDesc(FormalSettlement::getCreateTime));
return clones.isEmpty() ? null : clones.get(0);
}
/** 按原单旧防重键(原结算单号_明细单据号)查金蝶是否已冲销(应付 A102-4 / 应收 A302-4,部分冲销放行) */
private boolean kingdeeWrittenOff(FormalSettlement settlement) {
List<FormalSettlementDetail> details = formalDetailMapper.selectList(Wrappers.<FormalSettlementDetail>lambdaQuery()
.eq(FormalSettlementDetail::getFormalSettlementId, settlement.getId())
.eq(FormalSettlementDetail::getIsDeleted, 0)
.orderByAsc(FormalSettlementDetail::getLineNo));
if (Func.isEmpty(details)) {
return false;
}
if ("receivable".equals(settlement.getSettlementType())) {
return kingdeeArBillSyncSupport.queryArBillWrittenOff(kingdeeArBillSyncSupport.arBillSrcCode(settlement, details));
}
return kingdeeApBillSyncSupport.queryApBillWrittenOff(kingdeeApBillSyncSupport.apBillSrcCode(settlement, details));
}
private void saveFormalChange(Long settlementId, String changeType, Integer lineNo,
@@ -392,7 +558,12 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
private SettlementAdjustmentVO toVO(SettlementAdjustment item) { SettlementAdjustmentVO vo = new SettlementAdjustmentVO(); org.springframework.beans.BeanUtils.copyProperties(item, vo); vo.setCreateUserName(UserCache.getUserRealName(item.getCreateUser())); vo.setApprovalStatusName(statusName(item.getApprovalStatus())); vo.setSettlementTypeName(typeName(item.getSettlementType())); return vo; }
private String statusName(String value) { return Map.of(TransportBusiConstant.DRAFT, "草稿", TransportBusiConstant.REVIEWING, "审批中", TransportBusiConstant.APPROVED, "审批通过", TransportBusiConstant.RETURNED, "已驳回").getOrDefault(value, value); }
private String typeName(String value) { return "receivable".equals(value) ? "应收" : "payable".equals(value) ? "应付" : ""; }
private synchronized String nextNo() { String prefix = "TZ" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); long count = count(Wrappers.<SettlementAdjustment>lambdaQuery().likeRight(SettlementAdjustment::getAdjustmentNo, prefix)); return prefix + String.format("%04d", count + 1); }
/** 生成当日结算调整单号;按最大流水号 +1,且计入逻辑删除数据 */
private synchronized String nextNo() {
String prefix = "TZ" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix);
return prefix + String.format("%04d", (maxSequence == null ? 0L : maxSequence) + 1);
}
private BigDecimal money(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; }
private String requiredText(String value, String field, int max) { if (Func.isEmpty(value)) throw new ServiceException(field + "不能为空"); return limit(value.trim(), max); }
private String limit(String value, int max) { if (value != null && value.length() > max) throw new ServiceException("内容不能超过" + max + "个字"); return value; }
@@ -0,0 +1,267 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.UserCache;
import org.springblade.transport.constant.KingdeeBfiConstant;
import org.springblade.transport.constant.TransportBusiConstant;
import org.springblade.transport.mapper.PaymentApplicationMapper;
import org.springblade.transport.mapper.SettlementProjectAdvanceWriteOffMapper;
import org.springblade.transport.pojo.dto.SettlementProjectAdvanceWriteOffRequest;
import org.springblade.transport.pojo.entity.PaymentApplication;
import org.springblade.transport.pojo.entity.SettlementProjectAdvanceWriteOff;
import org.springblade.transport.pojo.vo.ProjectAdvanceWriteOffOptionVO;
import org.springblade.transport.service.ISettlementProjectAdvanceWriteOffService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* 结算单项目预付核销服务实现
*
* @author Chill
*/
@Service
@RequiredArgsConstructor
public class SettlementProjectAdvanceWriteOffServiceImpl implements ISettlementProjectAdvanceWriteOffService {
private final SettlementProjectAdvanceWriteOffMapper writeOffMapper;
private final PaymentApplicationMapper paymentApplicationMapper;
@Override
public List<ProjectAdvanceWriteOffOptionVO> listOptions(Long contractId, String excludeSettlementType,
Long excludeSettlementId) {
if (contractId == null) {
throw new ServiceException("请先选择合同");
}
List<PaymentApplication> applications = paymentApplicationMapper.selectList(
Wrappers.<PaymentApplication>lambdaQuery()
.eq(PaymentApplication::getContractId, contractId)
.eq(PaymentApplication::getPaymentType, TransportBusiConstant.PAYMENT_TYPE_PROJECT_ADVANCE)
.eq(PaymentApplication::getApprovalStatus, TransportBusiConstant.APPROVED)
.eq(PaymentApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_PAID)
.eq(PaymentApplication::getIsDeleted, 0)
.orderByDesc(PaymentApplication::getCreateTime));
List<ProjectAdvanceWriteOffOptionVO> options = new ArrayList<>();
for (PaymentApplication application : applications) {
BigDecimal projectAdvanceAmount = money(application.getAppliedAmount());
BigDecimal usedAmount = money(writeOffMapper.sumWriteOffByPaymentApplication(
application.getId(), excludeSettlementType, excludeSettlementId));
BigDecimal availableAmount = projectAdvanceAmount.subtract(usedAmount).max(BigDecimal.ZERO);
if (availableAmount.compareTo(BigDecimal.ZERO) <= 0) {
continue;
}
ProjectAdvanceWriteOffOptionVO option = new ProjectAdvanceWriteOffOptionVO();
option.setPaymentApplicationId(application.getId());
option.setPaymentNo(application.getPaymentNo());
option.setProjectAdvanceAmount(projectAdvanceAmount);
option.setAvailableWriteOffAmount(availableAmount);
option.setKingdeeBillNo(application.getKingdeeBillNo());
option.setCreateUserName(UserCache.getUserRealName(application.getCreateUser()));
option.setCreateTime(application.getCreateTime());
options.add(option);
}
return options;
}
@Override
public List<SettlementProjectAdvanceWriteOff> listBySettlement(String settlementType, Long settlementId) {
if (Func.isEmpty(settlementType) || settlementId == null) {
return List.of();
}
List<SettlementProjectAdvanceWriteOff> rows = writeOffMapper.selectList(
Wrappers.<SettlementProjectAdvanceWriteOff>lambdaQuery()
.eq(SettlementProjectAdvanceWriteOff::getSettlementType, settlementType)
.eq(SettlementProjectAdvanceWriteOff::getSettlementId, settlementId)
.eq(SettlementProjectAdvanceWriteOff::getIsDeleted, 0)
.orderByAsc(SettlementProjectAdvanceWriteOff::getCreateTime)
.orderByAsc(SettlementProjectAdvanceWriteOff::getId));
for (SettlementProjectAdvanceWriteOff row : rows) {
PaymentApplication application = paymentApplicationMapper.selectById(row.getPaymentApplicationId());
BigDecimal projectAdvanceAmount = application == null
? money(row.getProjectAdvanceAmount()) : money(application.getAppliedAmount());
BigDecimal usedAmount = money(writeOffMapper.sumWriteOffByPaymentApplication(
row.getPaymentApplicationId(), settlementType, settlementId));
row.setProjectAdvanceAmount(projectAdvanceAmount);
row.setAvailableWriteOffAmount(projectAdvanceAmount.subtract(usedAmount).max(BigDecimal.ZERO));
if (application != null) {
row.setPaymentNo(application.getPaymentNo());
row.setKingdeeBillNo(application.getKingdeeBillNo());
row.setCreateUserName(UserCache.getUserRealName(application.getCreateUser()));
row.setPaymentCreateTime(application.getCreateTime());
} else {
row.setCreateUserName(UserCache.getUserRealName(row.getCreateUser()));
row.setPaymentCreateTime(row.getCreateTime());
}
}
return rows;
}
@Override
public BigDecimal sumBySettlement(String settlementType, Long settlementId) {
if (Func.isEmpty(settlementType) || settlementId == null) {
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
}
return money(writeOffMapper.sumWriteOffBySettlement(settlementType, settlementId));
}
@Override
public Map<Long, BigDecimal> sumBySettlementIds(String settlementType, Collection<Long> settlementIds) {
if (Func.isEmpty(settlementType) || Func.isEmpty(settlementIds)) {
return Map.of();
}
Map<Long, BigDecimal> result = new HashMap<>();
for (Map<String, Object> row : writeOffMapper.sumWriteOffBySettlementIds(settlementType, settlementIds)) {
if (row.get("settlementId") instanceof Number settlementId && row.get("totalAmount") instanceof BigDecimal totalAmount) {
result.put(settlementId.longValue(), money(totalAmount));
}
}
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void replaceWriteOffs(String settlementType, Long settlementId, Long contractId,
BigDecimal settlementAmount, List<SettlementProjectAdvanceWriteOffRequest> writeOffs) {
if (Func.isEmpty(settlementType) || settlementId == null) {
throw new ServiceException("结算单不能为空");
}
if (contractId == null) {
throw new ServiceException("合同不能为空");
}
List<SettlementProjectAdvanceWriteOffRequest> requestRows = writeOffs == null ? List.of() : writeOffs;
BigDecimal settlementLimit = money(settlementAmount);
BigDecimal totalWriteOff = BigDecimal.ZERO;
Set<Long> paymentIds = new HashSet<>();
List<SettlementProjectAdvanceWriteOff> entities = new ArrayList<>();
for (SettlementProjectAdvanceWriteOffRequest request : requestRows) {
if (request == null || request.getPaymentApplicationId() == null) {
throw new ServiceException("请选择项目预付单");
}
if (!paymentIds.add(request.getPaymentApplicationId())) {
throw new ServiceException("同一项目预付单不可重复核销");
}
BigDecimal writeOffAmount = money(request.getWriteOffAmount());
if (writeOffAmount.compareTo(BigDecimal.ZERO) <= 0) {
throw new ServiceException("本次核销金额必须大于0");
}
PaymentApplication application = paymentApplicationMapper.selectById(request.getPaymentApplicationId());
if (application == null || Objects.equals(application.getIsDeleted(), 1)) {
throw new ServiceException("项目预付单不存在");
}
if (!TransportBusiConstant.PAYMENT_TYPE_PROJECT_ADVANCE.equals(application.getPaymentType())) {
throw new ServiceException("仅可核销项目预付单");
}
if (!TransportBusiConstant.APPROVED.equals(application.getApprovalStatus())) {
throw new ServiceException("仅可选择审核通过的项目预付单");
}
if (!KingdeeBfiConstant.KINGDEE_STATUS_PAID.equals(application.getKingdeeStatus())) {
throw new ServiceException("预付单" + Func.toStr(application.getPaymentNo(), "") + "尚未付款,项目预付须金蝶付款完成后方可核销");
}
if (!Objects.equals(contractId, application.getContractId())) {
throw new ServiceException("项目预付单与结算单合同不一致");
}
BigDecimal projectAdvanceAmount = money(application.getAppliedAmount());
BigDecimal usedAmount = money(writeOffMapper.sumWriteOffByPaymentApplication(
application.getId(), settlementType, settlementId));
BigDecimal availableAmount = projectAdvanceAmount.subtract(usedAmount).max(BigDecimal.ZERO);
if (writeOffAmount.compareTo(availableAmount) > 0) {
throw new ServiceException("预付单" + Func.toStr(application.getPaymentNo(), "")
+ "本次核销金额不能超过可核销金额");
}
totalWriteOff = totalWriteOff.add(writeOffAmount);
SettlementProjectAdvanceWriteOff entity = new SettlementProjectAdvanceWriteOff();
entity.setSettlementType(settlementType);
entity.setSettlementId(settlementId);
entity.setPaymentApplicationId(application.getId());
entity.setPaymentNo(application.getPaymentNo());
entity.setProjectAdvanceAmount(projectAdvanceAmount);
entity.setWriteOffAmount(writeOffAmount);
entity.setKingdeeBillNo(application.getKingdeeBillNo());
entity.setStatus(1);
entity.setIsDeleted(0);
entities.add(entity);
}
if (totalWriteOff.compareTo(settlementLimit) > 0) {
throw new ServiceException("项目预付核销合计不能超过结算单金额");
}
writeOffMapper.delete(Wrappers.<SettlementProjectAdvanceWriteOff>lambdaQuery()
.eq(SettlementProjectAdvanceWriteOff::getSettlementType, settlementType)
.eq(SettlementProjectAdvanceWriteOff::getSettlementId, settlementId));
for (SettlementProjectAdvanceWriteOff entity : entities) {
writeOffMapper.insert(entity);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteBySettlement(String settlementType, Long settlementId) {
if (Func.isEmpty(settlementType) || settlementId == null) {
return;
}
writeOffMapper.delete(Wrappers.<SettlementProjectAdvanceWriteOff>lambdaQuery()
.eq(SettlementProjectAdvanceWriteOff::getSettlementType, settlementType)
.eq(SettlementProjectAdvanceWriteOff::getSettlementId, settlementId));
}
@Override
@Transactional(rollbackFor = Exception.class)
public int deleteBySettlementIds(String settlementType, Collection<Long> settlementIds) {
if (Func.isEmpty(settlementType) || Func.isEmpty(settlementIds)) {
return 0;
}
return writeOffMapper.delete(Wrappers.<SettlementProjectAdvanceWriteOff>lambdaQuery()
.eq(SettlementProjectAdvanceWriteOff::getSettlementType, settlementType)
.in(SettlementProjectAdvanceWriteOff::getSettlementId, settlementIds));
}
@Override
@Transactional(rollbackFor = Exception.class)
public List<Long> deleteByPaymentApplicationIds(Collection<Long> paymentApplicationIds) {
if (Func.isEmpty(paymentApplicationIds)) {
return List.of();
}
List<SettlementProjectAdvanceWriteOff> rows = writeOffMapper.selectList(
Wrappers.<SettlementProjectAdvanceWriteOff>lambdaQuery()
.eq(SettlementProjectAdvanceWriteOff::getIsDeleted, 0)
.in(SettlementProjectAdvanceWriteOff::getPaymentApplicationId, paymentApplicationIds));
if (rows.isEmpty()) {
return List.of();
}
writeOffMapper.delete(Wrappers.<SettlementProjectAdvanceWriteOff>lambdaQuery()
.in(SettlementProjectAdvanceWriteOff::getPaymentApplicationId, paymentApplicationIds));
return rows.stream()
.filter(row -> TransportBusiConstant.SETTLEMENT_BILL_FORMAL.equals(row.getSettlementType()))
.map(SettlementProjectAdvanceWriteOff::getSettlementId)
.filter(Objects::nonNull)
.distinct()
.toList();
}
private BigDecimal money(BigDecimal value) {
return value == null ? BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)
: value.setScale(2, RoundingMode.HALF_UP);
}
}
@@ -0,0 +1,622 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.tenant.TenantUtil;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.constant.TransportBusiConstant;
import org.springblade.transport.mapper.PreSettlementDetailMapper;
import org.springblade.transport.mapper.ReceivablePayableDetailMapper;
import org.springblade.transport.pojo.dto.FormalSettlementSaveRequest;
import org.springblade.transport.pojo.dto.PreSettlementSaveRequest;
import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.entity.PreSettlement;
import org.springblade.transport.pojo.entity.PreSettlementDetail;
import org.springblade.transport.pojo.entity.ReceivablePayableDetail;
import org.springblade.transport.service.IContractManageService;
import org.springblade.transport.service.IFormalSettlementService;
import org.springblade.transport.service.IPreSettlementService;
import org.springblade.transport.service.ISettlementRuleAutoGenerateService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.YearMonth;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* 按合同结算规则自动生成结算草稿。
* <p>
* 账期在次日零点视为关闭:日结/现结取前一日,周结和固定天数从账单开始日期顺延,
* 月结按自然月、固定截单日或自定义多周期截取。没有可归集明细时不生成空单。
* 同一明细只进入一张结算单,预结算先于正式结算执行。正式结算同时归集费用日期完整落在账期内、且尚未转正式结算的已审批预结算单。
*
* @author Chill
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SettlementRuleAutoGenerateServiceImpl implements ISettlementRuleAutoGenerateService {
private static final int QUERY_BATCH_SIZE = 500;
private static final String LOCAL_CURRENCY = "RMB";
private final IContractManageService contractManageService;
private final IPreSettlementService preSettlementService;
private final IFormalSettlementService formalSettlementService;
private final ReceivablePayableDetailMapper receivablePayableDetailMapper;
private final PreSettlementDetailMapper preSettlementDetailMapper;
private final PlatformTransactionManager transactionManager;
@Override
public synchronized String generateDueSettlements() {
LocalDate today = LocalDate.now();
List<ContractManage> contracts = TenantUtil.ignore(this::listAutoContracts);
int preCount = 0;
int formalCount = 0;
int failedCount = 0;
for (ContractManage contract : contracts) {
if (Func.isBlank(contract.getTenantId())) {
log.warn("合同[{}]缺少租户,跳过自动生成结算单", contract.getContractNo());
continue;
}
try {
int[] created = TenantUtil.use(contract.getTenantId(), () -> generateContract(contract, today));
preCount += created[0];
formalCount += created[1];
} catch (Exception exception) {
failedCount++;
log.error("合同[{}]自动生成结算单失败", contract.getContractNo(), exception);
}
}
return "扫描合同" + contracts.size() + "份,生成预结算单" + preCount + "张,正式结算单" + formalCount
+ "张,失败" + failedCount + "份";
}
private List<ContractManage> listAutoContracts() {
return contractManageService.list(Wrappers.<ContractManage>lambdaQuery()
.in(ContractManage::getApprovalStatus, "approved", "change_approved")
.in(ContractManage::getContractCategory, "客户合同", "承运商合同")
.and(query -> query.isNull(ContractManage::getContractStage)
.or().ne(ContractManage::getContractStage, "terminated"))
.and(query -> query.like(ContractManage::getPreSettlementConfigJson, "autoGenerate")
.or().like(ContractManage::getFormalSettlementConfigJson, "autoGenerate")
.or().like(ContractManage::getSettlementRuleJson, "autoGenerate")));
}
private int[] generateContract(ContractManage contract, LocalDate today) {
String settlementType = contractSettlementType(contract);
if (settlementType == null || contract.getProjectId() == null || contract.getOrganizationId() == null) {
log.warn("合同[{}]缺少结算类型、项目或所属组织,跳过自动生成", contract.getContractNo());
return new int[]{0, 0};
}
int preCount = generateBills(contract, settlementType, resolveRule(contract, true), today, true);
int formalCount = generateBills(contract, settlementType, resolveRule(contract, false), today, false);
return new int[]{preCount, formalCount};
}
private int generateBills(ContractManage contract, String settlementType, Map<String, Object> rule,
LocalDate today, boolean preBill) {
if (!enabled(rule.get("autoGenerate"))) {
return 0;
}
String cycleName = Func.toStr(rule.get("settlementType")).trim();
if (!knownCycle(cycleName)) {
log.warn("合同[{}]{}结算周期[{}]无法识别,跳过自动生成", contract.getContractNo(),
preBill ? "预" : "正式", cycleName);
return 0;
}
List<DateRange> ranges = closedPeriods(rule, today);
if (ranges.isEmpty()) {
return 0;
}
if (preBill) {
return createPreBills(contract, ranges);
}
return createFormalBills(contract, settlementType, ranges);
}
private int createPreBills(ContractManage contract, List<DateRange> ranges) {
List<ReceivablePayableDetail> details = loadPendingDetails(contract, ranges);
Map<DateRange, List<ReceivablePayableDetail>> grouped = groupDetails(ranges, details);
int created = 0;
for (Map.Entry<DateRange, List<ReceivablePayableDetail>> entry : grouped.entrySet()) {
Map<String, List<Long>> byCurrency = new LinkedHashMap<>();
for (ReceivablePayableDetail detail : entry.getValue()) {
String currency = Func.isEmpty(detail.getCurrency()) ? LOCAL_CURRENCY : detail.getCurrency();
byCurrency.computeIfAbsent(currency, key -> new ArrayList<>()).add(detail.getId());
}
for (List<Long> detailIds : byCurrency.values()) {
try {
inTransaction(() -> {
PreSettlementSaveRequest request = new PreSettlementSaveRequest();
request.setContractId(contract.getId());
request.setSourceDetailIds(detailIds);
request.setRemark(periodRemark(entry.getKey()));
preSettlementService.saveDraft(request);
});
created++;
log.info("合同[{}]自动生成预结算单,账期{}至{},明细{}条", contract.getContractNo(),
entry.getKey().start(), entry.getKey().end(), detailIds.size());
} catch (Exception exception) {
log.error("合同[{}]自动生成预结算单失败,账期{}至{}", contract.getContractNo(),
entry.getKey().start(), entry.getKey().end(), exception);
}
}
}
return created;
}
private int createFormalBills(ContractManage contract, String settlementType, List<DateRange> ranges) {
Map<DateRange, List<ReceivablePayableDetail>> detailGroups = groupDetails(ranges, loadPendingDetails(contract, ranges));
Map<DateRange, List<PreSettlement>> preGroups = groupApprovedPres(contract, settlementType, ranges);
int created = 0;
for (DateRange range : ranges) {
Map<String, List<Long>> detailIds = new LinkedHashMap<>();
for (ReceivablePayableDetail detail : detailGroups.getOrDefault(range, List.of())) {
String currency = detail.getCurrency() == null ? "" : detail.getCurrency();
detailIds.computeIfAbsent(currency, key -> new ArrayList<>()).add(detail.getId());
}
Map<String, List<Long>> preIds = new LinkedHashMap<>();
for (PreSettlement preSettlement : preGroups.getOrDefault(range, List.of())) {
String currency = preSettlement.getCurrency() == null ? "" : preSettlement.getCurrency();
preIds.computeIfAbsent(currency, key -> new ArrayList<>()).add(preSettlement.getId());
}
Set<String> currencies = new LinkedHashSet<>();
currencies.addAll(detailIds.keySet());
currencies.addAll(preIds.keySet());
for (String currency : currencies) {
List<Long> sourcePreSettlementIds = preIds.getOrDefault(currency, List.of());
List<Long> sourceDetailIds = detailIds.getOrDefault(currency, List.of());
if (sourcePreSettlementIds.isEmpty() && sourceDetailIds.isEmpty()) {
continue;
}
try {
inTransaction(() -> {
FormalSettlementSaveRequest request = new FormalSettlementSaveRequest();
request.setContractId(contract.getId());
request.setSettlementType(settlementType);
request.setSourcePreSettlementIds(sourcePreSettlementIds.isEmpty() ? null : sourcePreSettlementIds);
request.setSourceDetailIds(sourceDetailIds.isEmpty() ? null : sourceDetailIds);
request.setRemark(periodRemark(range));
formalSettlementService.saveDraft(request);
});
created++;
log.info("合同[{}]自动生成正式结算单,账期{}至{},预结算{}张,明细{}条", contract.getContractNo(),
range.start(), range.end(), sourcePreSettlementIds.size(), sourceDetailIds.size());
} catch (Exception exception) {
log.error("合同[{}]自动生成正式结算单失败,账期{}至{}", contract.getContractNo(),
range.start(), range.end(), exception);
}
}
}
return created;
}
private List<ReceivablePayableDetail> loadPendingDetails(ContractManage contract, List<DateRange> ranges) {
LocalDate minDate = ranges.get(0).start();
LocalDate maxDate = ranges.get(0).end();
for (DateRange range : ranges) {
if (range.start().isBefore(minDate)) {
minDate = range.start();
}
if (range.end().isAfter(maxDate)) {
maxDate = range.end();
}
}
LocalDate periodStart = minDate;
LocalDate periodEnd = maxDate;
return receivablePayableDetailMapper.selectList(Wrappers.<ReceivablePayableDetail>lambdaQuery()
.eq(ReceivablePayableDetail::getIsDeleted, 0)
.eq(ReceivablePayableDetail::getContractId, contract.getId())
.eq(ReceivablePayableDetail::getProjectId, contract.getProjectId())
.eq(ReceivablePayableDetail::getDeptId, contract.getOrganizationId())
.eq(ReceivablePayableDetail::getSettlementType, contractSettlementType(contract))
.eq(ReceivablePayableDetail::getSettlementStatus, "pending")
.and(query -> query.isNull(ReceivablePayableDetail::getPreSettlementNo)
.or().eq(ReceivablePayableDetail::getPreSettlementNo, ""))
.and(query -> query.isNull(ReceivablePayableDetail::getFormalSettlementNo)
.or().eq(ReceivablePayableDetail::getFormalSettlementNo, ""))
.and(query -> query.eq(ReceivablePayableDetail::getCustomerName, contract.getPartyA())
.or().eq(ReceivablePayableDetail::getCustomerName, contract.getPartyB()))
.and(query -> query.between(ReceivablePayableDetail::getFeeDate, periodStart, periodEnd)
.or(nested -> nested.isNull(ReceivablePayableDetail::getFeeDate)
.ge(ReceivablePayableDetail::getCreateTime, periodStart.atStartOfDay())
.lt(ReceivablePayableDetail::getCreateTime, periodEnd.plusDays(1).atStartOfDay()))));
}
private Map<DateRange, List<PreSettlement>> groupApprovedPres(ContractManage contract, String settlementType,
List<DateRange> ranges) {
Map<DateRange, List<PreSettlement>> grouped = new LinkedHashMap<>();
ranges.forEach(range -> grouped.put(range, new ArrayList<>()));
List<PreSettlement> preSettlements = preSettlementService.list(Wrappers.<PreSettlement>lambdaQuery()
.eq(PreSettlement::getContractId, contract.getId())
.eq(PreSettlement::getSettlementType, settlementType)
.eq(PreSettlement::getApprovalStatus, TransportBusiConstant.APPROVED)
.and(query -> query.isNull(PreSettlement::getFormalSettlementNo)
.or().eq(PreSettlement::getFormalSettlementNo, "")));
if (preSettlements.isEmpty()) {
return grouped;
}
List<Long> preSettlementIds = preSettlements.stream().map(PreSettlement::getId).toList();
Map<Long, List<Long>> sourceIdsByPre = new LinkedHashMap<>();
for (List<Long> batch : partition(preSettlementIds)) {
List<PreSettlementDetail> lines = preSettlementDetailMapper.selectList(Wrappers.<PreSettlementDetail>lambdaQuery()
.in(PreSettlementDetail::getPreSettlementId, batch)
.eq(PreSettlementDetail::getIsDeleted, 0));
for (PreSettlementDetail line : lines) {
if (line.getSourceDetailId() == null) {
continue;
}
sourceIdsByPre.computeIfAbsent(line.getPreSettlementId(), key -> new ArrayList<>()).add(line.getSourceDetailId());
}
}
Set<Long> sourceIds = new LinkedHashSet<>();
sourceIdsByPre.values().forEach(sourceIds::addAll);
Map<Long, ReceivablePayableDetail> sourceMap = new LinkedHashMap<>();
for (List<Long> batch : partition(sourceIds)) {
receivablePayableDetailMapper.selectBatchIds(batch)
.forEach(detail -> sourceMap.put(detail.getId(), detail));
}
for (PreSettlement preSettlement : preSettlements) {
List<Long> sourceDetailIds = sourceIdsByPre.getOrDefault(preSettlement.getId(), List.of());
LocalDate minDate = null;
LocalDate maxDate = null;
boolean invalid = sourceDetailIds.isEmpty();
for (Long sourceDetailId : sourceDetailIds) {
ReceivablePayableDetail source = sourceMap.get(sourceDetailId);
LocalDate businessDate = source == null ? null : businessDate(source);
if (businessDate == null) {
invalid = true;
break;
}
minDate = minDate == null || businessDate.isBefore(minDate) ? businessDate : minDate;
maxDate = maxDate == null || businessDate.isAfter(maxDate) ? businessDate : maxDate;
}
if (invalid || minDate == null || maxDate == null) {
log.warn("预结算单[{}]缺少可归集的费用日期,跳过自动转正式结算", preSettlement.getPreSettlementNo());
continue;
}
boolean matched = false;
for (DateRange range : ranges) {
if (!minDate.isBefore(range.start()) && !maxDate.isAfter(range.end())) {
grouped.get(range).add(preSettlement);
matched = true;
break;
}
}
if (!matched && !maxDate.isAfter(LocalDate.now().minusDays(1))) {
log.info("预结算单[{}]费用日期{}至{}未完整落入已关闭账期,暂不自动归集正式结算",
preSettlement.getPreSettlementNo(), minDate, maxDate);
}
}
return grouped;
}
private Map<DateRange, List<ReceivablePayableDetail>> groupDetails(List<DateRange> ranges,
List<ReceivablePayableDetail> details) {
Map<DateRange, List<ReceivablePayableDetail>> grouped = new LinkedHashMap<>();
ranges.forEach(range -> grouped.put(range, new ArrayList<>()));
for (ReceivablePayableDetail detail : details) {
LocalDate businessDate = businessDate(detail);
if (businessDate == null) {
continue;
}
for (DateRange range : ranges) {
if (!businessDate.isBefore(range.start()) && !businessDate.isAfter(range.end())) {
grouped.get(range).add(detail);
break;
}
}
}
return grouped;
}
private List<DateRange> closedPeriods(Map<String, Object> rule, LocalDate today) {
LocalDate billStart = dateValue(rule.get("billStartDate"));
LocalDate yesterday = today.minusDays(1);
if (billStart == null || yesterday.isBefore(billStart)) {
return List.of();
}
String settlementType = Func.toStr(rule.get("settlementType")).trim();
return switch (settlementType) {
case "现结", "日结" -> cyclePeriods(billStart, yesterday, 1);
case "周结" -> cyclePeriods(billStart, yesterday, 7);
case "按固定天数周期", "固定天数周期结算" -> {
Integer cycleDays = intValue(rule.get("cycleDays"));
if (cycleDays == null || cycleDays < 1 || cycleDays > 365) {
yield List.of();
}
yield cyclePeriods(billStart, yesterday, cycleDays);
}
case "月结" -> monthlyPeriods(rule, billStart, yesterday);
default -> List.of();
};
}
private List<DateRange> monthlyPeriods(Map<String, Object> rule, LocalDate billStart, LocalDate yesterday) {
String cycleType = Func.toStr(rule.get("billCycleType")).trim();
if (Func.isBlank(cycleType) || "自然月".equals(cycleType)) {
return naturalMonthPeriods(billStart, yesterday);
}
if ("固定截单日".equals(cycleType)) {
Integer cutoffDay = intValue(rule.get("billCutoffDay"));
if (cutoffDay == null || cutoffDay < 1 || cutoffDay > 31) {
return List.of();
}
return fixedCutoffPeriods(billStart, yesterday, cutoffDay);
}
if ("自定义多周期".equals(cycleType)) {
return customMonthPeriods(rule.get("customPeriods"), billStart, yesterday);
}
return List.of();
}
private List<DateRange> cyclePeriods(LocalDate billStart, LocalDate yesterday, int days) {
List<DateRange> ranges = new ArrayList<>();
LocalDate cursor = billStart;
while (true) {
LocalDate end = cursor.plusDays(days - 1L);
if (end.isAfter(yesterday)) {
break;
}
ranges.add(new DateRange(cursor, end));
cursor = end.plusDays(1);
}
return ranges;
}
private List<DateRange> naturalMonthPeriods(LocalDate billStart, LocalDate yesterday) {
List<DateRange> ranges = new ArrayList<>();
YearMonth cursor = YearMonth.from(billStart);
YearMonth lastMonth = YearMonth.from(yesterday);
while (!cursor.isAfter(lastMonth)) {
LocalDate end = cursor.atEndOfMonth();
if (!end.isAfter(yesterday) && !end.isBefore(billStart)) {
LocalDate start = cursor.atDay(1).isBefore(billStart) ? billStart : cursor.atDay(1);
ranges.add(new DateRange(start, end));
}
cursor = cursor.plusMonths(1);
}
return ranges;
}
private List<DateRange> fixedCutoffPeriods(LocalDate billStart, LocalDate yesterday, int cutoffDay) {
List<DateRange> ranges = new ArrayList<>();
YearMonth cursor = YearMonth.from(billStart).minusMonths(1);
YearMonth limit = YearMonth.from(yesterday).plusMonths(1);
LocalDate previousEnd = null;
while (!cursor.isAfter(limit)) {
LocalDate end = cursor.atDay(Math.min(cutoffDay, cursor.lengthOfMonth()));
if (!end.isBefore(billStart) && !end.isAfter(yesterday)) {
LocalDate start = previousEnd == null ? billStart : previousEnd.plusDays(1);
if (start.isBefore(billStart)) {
start = billStart;
}
if (!start.isAfter(end)) {
ranges.add(new DateRange(start, end));
}
}
if (!end.isBefore(billStart.minusMonths(1))) {
previousEnd = end;
}
cursor = cursor.plusMonths(1);
}
return ranges;
}
private List<DateRange> customMonthPeriods(Object periodsValue, LocalDate billStart, LocalDate yesterday) {
List<int[]> periods = customPeriods(periodsValue);
if (periods.isEmpty()) {
return List.of();
}
List<DateRange> ranges = new ArrayList<>();
YearMonth cursor = YearMonth.from(billStart);
YearMonth lastMonth = YearMonth.from(yesterday);
while (!cursor.isAfter(lastMonth)) {
for (int[] period : periods) {
int startDay = Math.min(period[0], cursor.lengthOfMonth());
int endDay = Math.min(period[1], cursor.lengthOfMonth());
if (startDay > endDay) {
continue;
}
LocalDate end = cursor.atDay(endDay);
if (end.isAfter(yesterday) || end.isBefore(billStart)) {
continue;
}
LocalDate start = cursor.atDay(startDay);
if (start.isBefore(billStart)) {
start = billStart;
}
if (!start.isAfter(end)) {
ranges.add(new DateRange(start, end));
}
}
cursor = cursor.plusMonths(1);
}
return ranges;
}
private List<int[]> customPeriods(Object value) {
if (!(value instanceof List<?> periodList)) {
return List.of();
}
List<int[]> periods = new ArrayList<>();
for (Object item : periodList) {
if (!(item instanceof Map<?, ?> period)) {
continue;
}
Integer startDay = intValue(period.get("startDay"));
Integer endDay = intValue(period.get("endDay"));
if (startDay == null || endDay == null || startDay < 1 || endDay > 31 || endDay < startDay) {
continue;
}
periods.add(new int[]{startDay, endDay});
}
return periods;
}
private Map<String, Object> resolveRule(ContractManage contract, boolean preRule) {
Map<String, Object> direct = parseObject(preRule ? contract.getPreSettlementConfigJson() : contract.getFormalSettlementConfigJson());
if (!direct.isEmpty()) {
return direct;
}
Map<String, Object> payload = parseObject(contract.getSettlementRuleJson());
Object nested = payload.get(preRule ? "preSettlementConfig" : "formalSettlementConfig");
if (nested instanceof Map<?, ?> nestedMap) {
Map<String, Object> result = new LinkedHashMap<>();
nestedMap.forEach((key, value) -> result.put(String.valueOf(key), value));
return result;
}
if (!payload.containsKey("preSettlementConfig") && !payload.containsKey("formalSettlementConfig")) {
return payload;
}
return Map.of();
}
private Map<String, Object> parseObject(String json) {
if (Func.isBlank(json)) {
return Map.of();
}
try {
Object parsed = JsonUtil.parse(json, Map.class);
if (!(parsed instanceof Map<?, ?> raw) || raw.isEmpty()) {
return Map.of();
}
Map<String, Object> result = new LinkedHashMap<>();
raw.forEach((key, value) -> result.put(String.valueOf(key), value));
return result;
} catch (Exception exception) {
log.warn("结算规则JSON解析失败:{}", exception.getMessage());
return Map.of();
}
}
private void inTransaction(Runnable runnable) {
new TransactionTemplate(transactionManager).executeWithoutResult(status -> runnable.run());
}
private List<List<Long>> partition(Collection<Long> ids) {
List<Long> source = ids instanceof List<Long> list ? list : new ArrayList<>(ids);
List<List<Long>> batches = new ArrayList<>();
for (int index = 0; index < source.size(); index += QUERY_BATCH_SIZE) {
batches.add(source.subList(index, Math.min(index + QUERY_BATCH_SIZE, source.size())));
}
return batches;
}
private String contractSettlementType(ContractManage contract) {
if ("客户合同".equals(contract.getContractCategory())) {
return "receivable";
}
if ("承运商合同".equals(contract.getContractCategory())) {
return "payable";
}
return null;
}
private LocalDate businessDate(ReceivablePayableDetail detail) {
if (detail.getFeeDate() != null) {
return detail.getFeeDate();
}
if (detail.getCreateTime() == null) {
return null;
}
return detail.getCreateTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
}
private String periodRemark(DateRange range) {
return "系统按结算规则自动生成(" + range.start() + "至" + range.end() + ")";
}
private boolean enabled(Object value) {
if (value instanceof Boolean enabled) {
return enabled;
}
if (value instanceof Number number) {
return number.intValue() == 1;
}
String text = value == null ? "" : String.valueOf(value).trim();
return "1".equals(text) || "true".equalsIgnoreCase(text);
}
private boolean knownCycle(String settlementType) {
return List.of("现结", "日结", "周结", "月结", "按固定天数周期", "固定天数周期结算").contains(settlementType);
}
private Integer intValue(Object value) {
if (value instanceof Number number) {
return number.intValue();
}
if (value == null || Func.isBlank(String.valueOf(value))) {
return null;
}
try {
return Integer.parseInt(String.valueOf(value).trim());
} catch (NumberFormatException exception) {
return null;
}
}
private LocalDate dateValue(Object value) {
if (value == null || Func.isBlank(String.valueOf(value))) {
return null;
}
String text = String.valueOf(value).trim();
if (text.length() >= 10) {
text = text.substring(0, 10);
}
try {
return LocalDate.parse(text);
} catch (DateTimeParseException exception) {
return null;
}
}
private record DateRange(LocalDate start, LocalDate end) {
}
}
@@ -246,10 +246,8 @@ public class TemporaryCreditLimitServiceImpl extends BaseServiceImpl<TemporaryCr
TransportBusinessSupport.validateRequired(temporaryCreditLimit.getProjectCode(), "项目编号不能为空");
TransportBusinessSupport.validateRequired(temporaryCreditLimit.getUndertakeDeptName(), "承办部门不能为空");
TransportBusinessSupport.validateRequired(temporaryCreditLimit.getValidUntil() == null ? null : temporaryCreditLimit.getValidUntil().toString(), "请选择申请有效期至");
if (temporaryCreditLimit.getProjectFundLimit() == null || temporaryCreditLimit.getUsedFundLimit() == null || temporaryCreditLimit.getRemainingFundLimit() == null) {
throw new ServiceException("项目额度数据异常,请重新选择项目");
}
if (temporaryCreditLimit.getRemainingFundLimit().compareTo(BigDecimal.ZERO) < 0) {
if (temporaryCreditLimit.getRemainingFundLimit() != null
&& temporaryCreditLimit.getRemainingFundLimit().compareTo(BigDecimal.ZERO) < 0) {
throw new ServiceException("项目剩余额度不能小于0");
}
}
@@ -81,6 +81,8 @@ public class TransportPlanServiceImpl extends BaseServiceImpl<TransportPlanMappe
@Override
public IPage<TransportPlanVO> selectTransportPlanPage(IPage<TransportPlan> page, TransportPlanVO transportPlan) {
// 历史手工创建未写入 dept_id,allDept=0 按组织过滤时会被排除
backfillMissingDept(transportPlan);
IPage<TransportPlan> entityPage = page(page, buildQuery(transportPlan));
IPage<TransportPlanVO> resultPage = TransportPlanWrapper.build().pageVO(entityPage);
fillCustomerNamesFromContracts(resultPage.getRecords());
@@ -151,6 +153,12 @@ public class TransportPlanServiceImpl extends BaseServiceImpl<TransportPlanMappe
if (created) {
transportPlan.setPlanNo(nextCode());
}
// 新建或历史空组织一律落到当前组织,避免列表按 dept_id 过滤后看不到
if (created || Func.isEmpty(transportPlan.getDeptId()) || transportPlan.getDeptId() <= 0) {
Dept dept = TransportBusinessSupport.currentDept("运输计划");
transportPlan.setDeptId(dept.getId());
transportPlan.setDeptName(dept.getDeptName());
}
validate(transportPlan);
return saveOrUpdate(transportPlan);
}
@@ -662,11 +670,33 @@ public class TransportPlanServiceImpl extends BaseServiceImpl<TransportPlanMappe
));
}
/**
* 把所属组织为空、且创建组织等于当前组织的运输计划回填 dept_id,使 allDept=0 列表能查到。
*/
private void backfillMissingDept(TransportPlanVO transportPlan) {
if (Objects.equals(transportPlan.getAllDept(), 1)) {
return;
}
Dept dept = TransportBusinessSupport.currentDept("运输计划");
if (dept == null || dept.getId() == null) {
return;
}
update(Wrappers.<TransportPlan>lambdaUpdate()
.set(TransportPlan::getDeptId, dept.getId())
.set(TransportPlan::getDeptName, dept.getDeptName())
.isNull(TransportPlan::getDeptId)
.eq(TransportPlan::getCreateDept, dept.getId())
.eq(TransportPlan::getIsDeleted, 0));
}
private LambdaQueryWrapper<TransportPlan> buildQuery(TransportPlanVO transportPlan) {
TransportBusinessSupport.validateAllDept(transportPlan.getAllDept(), "运输计划");
LambdaQueryWrapper<TransportPlan> queryWrapper = Wrappers.<TransportPlan>lambdaQuery().eq(TransportPlan::getIsDeleted, 0);
if (!Objects.equals(transportPlan.getAllDept(), 1)) {
queryWrapper.eq(TransportPlan::getDeptId, TransportBusinessSupport.currentDeptId("运输计划"));
Long currentDeptId = TransportBusinessSupport.currentDeptId("运输计划");
// dept_id 为空的历史数据按创建组织归入当前组织
queryWrapper.and(wrapper -> wrapper.eq(TransportPlan::getDeptId, currentDeptId)
.or(item -> item.isNull(TransportPlan::getDeptId).eq(TransportPlan::getCreateDept, currentDeptId)));
} else if (Func.isNotEmpty(transportPlan.getDeptId())) {
queryWrapper.eq(TransportPlan::getDeptId, transportPlan.getDeptId());
}
@@ -1233,14 +1233,67 @@ public class TransportReconciliationServiceImpl
private List<TransportReconciliationInternal> assertAllMatched(TransportReconciliation bill) {
List<TransportReconciliationInternal> internals = internalRows(bill.getId());
List<TransportReconciliationExternal> externals = externalRows(bill.getId());
if (internals.isEmpty() || externals.isEmpty() || internals.size() != externals.size()
|| internals.stream().anyMatch(item -> !MATCHED.equals(item.getMatchResult()))
|| externals.stream().anyMatch(item -> !MATCHED.equals(item.getMatchStatus()) || Boolean.TRUE.equals(item.getSuspectedDuplicate()))) {
// 调整账单后旧的疑似重复标记可能已失效,按当前内容重算,避免已一一匹配仍被拦截
syncSuspectedDuplicates(externals);
if (!oneToOneMatched(internals, externals)) {
throw new ServiceException("所有内外部账单明细必须一一匹配且不存在疑似重复");
}
return internals;
}
private void syncSuspectedDuplicates(List<TransportReconciliationExternal> externals) {
Map<String, List<TransportReconciliationExternal>> groups = externals.stream()
.collect(Collectors.groupingBy(this::duplicateKey));
Set<Long> duplicateIds = new HashSet<>();
for (List<TransportReconciliationExternal> group : groups.values()) {
if (group.size() > 1) {
group.stream().map(TransportReconciliationExternal::getId).forEach(duplicateIds::add);
}
}
for (TransportReconciliationExternal external : externals) {
boolean duplicate = duplicateIds.contains(external.getId());
String matchStatus = external.getMatchStatus();
if (!duplicate && DUPLICATE.equals(matchStatus) && external.getMatchedInternalId() != null) {
matchStatus = MATCHED;
}
if (Boolean.TRUE.equals(external.getSuspectedDuplicate()) == duplicate
&& Objects.equals(matchStatus, external.getMatchStatus())) {
continue;
}
external.setSuspectedDuplicate(duplicate);
external.setMatchStatus(matchStatus);
externalMapper.update(null, Wrappers.<TransportReconciliationExternal>lambdaUpdate()
.eq(TransportReconciliationExternal::getId, external.getId())
.set(TransportReconciliationExternal::getSuspectedDuplicate, duplicate)
.set(TransportReconciliationExternal::getMatchStatus, matchStatus));
}
}
private boolean oneToOneMatched(List<TransportReconciliationInternal> internals,
List<TransportReconciliationExternal> externals) {
if (internals.isEmpty() || externals.isEmpty() || internals.size() != externals.size()) {
return false;
}
if (externals.stream().anyMatch(item -> Boolean.TRUE.equals(item.getSuspectedDuplicate()))) {
return false;
}
Map<Long, TransportReconciliationExternal> externalMap = externals.stream()
.collect(Collectors.toMap(TransportReconciliationExternal::getId, Function.identity(), (left, right) -> left));
Set<Long> usedExternalIds = new HashSet<>();
for (TransportReconciliationInternal internal : internals) {
if (!MATCHED.equals(internal.getMatchResult()) || internal.getMatchedExternalId() == null
|| !usedExternalIds.add(internal.getMatchedExternalId())) {
return false;
}
TransportReconciliationExternal external = externalMap.get(internal.getMatchedExternalId());
if (external == null || !MATCHED.equals(external.getMatchStatus())
|| !Objects.equals(external.getMatchedInternalId(), internal.getId())) {
return false;
}
}
return usedExternalIds.size() == externals.size();
}
private void refreshStats(Long id) {
TransportReconciliation bill = existing(id);
List<TransportReconciliationInternal> internals = internalRows(id);
@@ -1459,20 +1512,14 @@ public class TransportReconciliationServiceImpl
private BigDecimal sumExternalQuantity(List<TransportReconciliationExternal> rows) { return rows.stream().map(TransportReconciliationExternal::getTransportQuantity).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); }
private BigDecimal sumInternalAmount(List<TransportReconciliationInternal> rows) { return rows.stream().map(TransportReconciliationInternal::getSettlementAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); }
private BigDecimal sumExternalAmount(List<TransportReconciliationExternal> rows) { return rows.stream().map(TransportReconciliationExternal::getSettlementAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); }
/** 生成当日运输对账单号;按最大流水号 +1,且计入逻辑删除数据 */
private synchronized String nextNo() {
String prefix = "DZD" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
int sequence = list(Wrappers.<TransportReconciliation>lambdaQuery()
.select(TransportReconciliation::getReconciliationNo)
.likeRight(TransportReconciliation::getReconciliationNo, prefix))
.stream()
.map(TransportReconciliation::getReconciliationNo)
.filter(number -> number != null && number.length() == prefix.length() + 5)
.map(number -> number.substring(prefix.length()))
.filter(suffix -> suffix.chars().allMatch(Character::isDigit))
.mapToInt(Integer::parseInt)
.max()
.orElse(0) + 1;
if (sequence > 99999) throw new ServiceException("当日运输对账单号流水已用完");
Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix);
long sequence = (maxSequence == null ? 0L : maxSequence) + 1;
if (sequence > 99999) {
throw new ServiceException("当日运输对账单号流水已用完");
}
return prefix + String.format("%05d", sequence);
}
}
@@ -291,13 +291,13 @@ public class TransportVehicleServiceImpl extends BaseServiceImpl<TransportVehicl
if (Func.isEmpty(vehicle.getDrivingLicenseNo())) {
throw new ServiceException("行驶证档案编号不能为空");
}
if (Func.isEmpty(vehicle.getDrivingLicenseEndDate())) {
if (vehicle.getDrivingLicenseLongTerm() != 1 && Func.isEmpty(vehicle.getDrivingLicenseEndDate())) {
throw new ServiceException("行驶证有效期不能为空");
}
if (Func.isEmpty(vehicle.getRoadTransportCertNo())) {
throw new ServiceException("道路运输证号不能为空");
}
if (Func.isEmpty(vehicle.getRoadTransportCertEndDate())) {
if (vehicle.getRoadTransportCertLongTerm() != 1 && Func.isEmpty(vehicle.getRoadTransportCertEndDate())) {
throw new ServiceException("道路运输证有效期不能为空");
}
if (vehicle.getAnnualReviewLongTerm() != 1 && Func.isEmpty(vehicle.getAnnualReviewEndDate())) {
@@ -0,0 +1,173 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service.impl;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.support.WaybillProcessSupport;
import java.util.List;
/**
* 运次聚合口径:四档状态 + 接单需求 OR + 运次键
* <p>
* 四档与接单需求都按子运单的过程配置与接单态在这里推导,Tab 过滤与卡片状态用同一份推导,
* 库层不再按已落库的业务状态分档——业务状态靠读取时的惰性校正维持,与过程配置不一致是常态。
* <p>
* 运次键与它的库层表达式只在这里声明一次:库层分组(重新派单待办)与服务层分组必须是同一个键。
*/
final class TripAggregation {
/** 运单业务状态 */
static final String PENDING = "pending";
static final String WAITING_DISPATCH = "waiting_dispatch";
static final String DISPATCHING = "dispatching";
static final String RUNNING = "running";
static final String COMPLETED = "completed";
static final String CANCELLED = "cancelled";
/** 待接单档:与运单侧 Tab「待接单」取同一组状态 */
static final List<String> PENDING_STATUSES = List.of(PENDING, WAITING_DISPATCH, DISPATCHING);
/** 终态档 */
static final List<String> TERMINAL_STATUSES = List.of(COMPLETED, CANCELLED);
/**
* 进运次的子单状态:草稿尚未派单、不成运次;已取消要进,
* 否则「子单全已取消」会被算成运输中而不是已取消。
*/
static final List<String> CHILD_STATUSES = List.of(
PENDING, WAITING_DISPATCH, DISPATCHING, RUNNING, COMPLETED, CANCELLED
);
/** 待重新派单的口径:子单被拒且这趟车还没结束 */
static final List<String> REASSIGN_STATUSES = List.of(PENDING, RUNNING);
/**
* 运次键的库层表达式:有配载按配载单号成组,未配载每张运单自成一组(W 前缀避免与配载单号撞键)。
* 服务层换 {@link #keyOf(String, Long)} 算同一个键。
*/
static final String SQL_TRIP_KEY = "IFNULL(NULLIF(loading_no, ''), CONCAT('W', id))";
private TripAggregation() {
}
/** 运次键:与 {@link #SQL_TRIP_KEY} 同一个口径 */
static String keyOf(String loadingNo, Long id) {
return Func.isEmpty(loadingNo) ? "W" + id : loadingNo;
}
/** 运次的四档状态:0 待接单 / 1 运输中 / 2 已完成 / 3 已取消 */
static Integer appStatus(String status) {
return switch (Func.toStr(status, "")) {
case PENDING -> 0;
case RUNNING -> 1;
case COMPLETED -> 2;
case CANCELLED -> 3;
default -> null;
};
}
/**
* 运次状态:终态优先——子单全为终态时有已完成取已完成、否则已取消;
* 否则需接单且未接单取待接单,其余取运输中。
* <p>
* 子单集合为空时无从判定,返回空。
*/
static String statusOf(List<Waybill> children, boolean requireAccept, boolean accepted) {
String status = classify(count(children));
if (status == null || TERMINAL_STATUSES.contains(status)) {
return status;
}
if (!requireAccept) {
return RUNNING;
}
return accepted ? RUNNING : PENDING;
}
/**
* 子单聚合结果:子单总数、终态数、已完成数、待接单档数
*/
static Counters count(List<Waybill> children) {
int total = 0;
int terminal = 0;
int completed = 0;
int pending = 0;
for (Waybill child : children) {
String status = child == null ? null : child.getBusinessStatus();
total++;
if (status != null && TERMINAL_STATUSES.contains(status)) {
terminal++;
}
if (COMPLETED.equals(status)) {
completed++;
}
if (status != null && PENDING_STATUSES.contains(status)) {
pending++;
}
}
return new Counters(total, terminal, completed, pending);
}
private static String classify(Counters counters) {
if (counters.total() == 0) {
return null;
}
if (counters.terminal() == counters.total()) {
return counters.completed() > 0 ? COMPLETED : CANCELLED;
}
return counters.pending() > 0 ? PENDING : RUNNING;
}
/**
* 运次是否需要接单:任一子运单的过程配置有启用的接单节点即为是。
* <p>
* 跨项目的运次常遇到「要接单的票」与「不要接单的票」混在一起,按 OR 合并后整单按需接单处理。
*/
static boolean requiresAccept(List<Waybill> children) {
return children.stream()
.anyMatch(child -> child != null
&& WaybillProcessSupport.requiresDriverAcceptConfirmation(child.getProcessJson()));
}
/**
* 运次接单态:任一子单已接单即为已接单(一次接单把整趟车的子单置为已接单),
* 否则有子单待接单为待接单,无从判定时为空。
*/
static String acceptStatus(List<Waybill> children) {
boolean anyPending = false;
for (Waybill child : children) {
String status = child == null ? null : child.getDriverAcceptStatus();
if (WaybillProcessSupport.ACCEPT_ACCEPTED.equals(status)) {
return WaybillProcessSupport.ACCEPT_ACCEPTED;
}
if (WaybillProcessSupport.ACCEPT_PENDING.equals(status)) {
anyPending = true;
}
}
return anyPending ? WaybillProcessSupport.ACCEPT_PENDING : null;
}
/** 子单聚合计数 */
record Counters(int total, int terminal, int completed, int pending) {
}
}
@@ -0,0 +1,849 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.CargoLineVO;
import org.springblade.transport.pojo.vo.TripQueryVO;
import org.springblade.transport.pojo.vo.TripStationVO;
import org.springblade.transport.pojo.vo.TripTabCountsVO;
import org.springblade.transport.pojo.vo.TripVO;
import org.springblade.transport.service.ILoadingManageService;
import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.ITripQueryService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.LiveProcessConfigSupport;
import org.springblade.transport.support.WaybillCargoSupport;
import org.springblade.transport.support.WaybillProcessSupport;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 运次读服务实现
* <p>
* 取数两段,段内都是批量查询:段一一次取回收口与行级筛选命中的可见运单行,分组、分档、排序与分页
* 都在服务层算完再做;段二按这一页运次的配载单号批量取配载单。子运单不再回查——段一拿回来的行就是它们。
* <p>
* 分档只按过程配置与接单态推导,与卡片状态同一份口径:库里的业务状态靠读取时的惰性校正维持,
* 拿它分档会让「归在待接单的运次、卡片显示运输中」。分页因此必须在过滤后的完整运次序列上做,
* 先分页再过滤会把该在第一页的运次挤到后面几页。
* <p>
* 可见运次=派给该司机的未配载运单(每张自成运次),加上配载单派给该司机、且至少有一张可见子运单的运次。
* 司机侧可见范围与运单列表同口径:司机手机号命中且未被该司机拒单;子单里已拒单的不进运次,
* 于是整趟车被拒单后整个运次自然消失。
*/
@Service
@RequiredArgsConstructor
public class TripQueryServiceImpl implements ITripQueryService {
private static final int DEFAULT_PAGE_SIZE = 10;
private static final int MAX_PAGE_SIZE = 50;
/** 运输组织类型:有配载单为公路配载,否则普通运输 */
private static final String TRANSPORT_LOAD = "load";
private static final String TRANSPORT_COMMON = "common";
/** 未配载运次的键前缀:与 TripAggregation.SQL_TRIP_KEY 的 W 前缀同一个口径 */
private static final String LONE_KEY_PREFIX = "W";
/** 异常筛选的取值:命中未完成异常的子单 */
private static final String EXCEPTION_YES = "exception";
private static final String EXCEPTION_NO = "normal";
/** 关键字在子单上匹配的字段 */
private static final String[] KEYWORD_COLUMNS = {
"waybill_no", "loading_no", "departure_name", "arrival_name",
"departure_address", "arrival_address", "vehicle_no"
};
private final IWaybillService waybillService;
private final ILoadingManageService loadingManageService;
private final IProcessConfigService processConfigService;
@Override
public IPage<TripVO> page(TripQueryVO query) {
TripQueryVO condition = condition(query);
int pageNo = pageNo(condition.getCurrent());
int pageSize = pageSize(condition.getSize());
if (!scoped(condition)) {
// 收口缺失时返回空页,不能退回「不按司机收口」:那等于把别人的运次发出去
return new Page<>(pageNo, pageSize);
}
List<TripRows> trips = trips(condition);
Page<TripVO> page = new Page<>(pageNo, pageSize, trips.size());
int from = Math.min((pageNo - 1) * pageSize, trips.size());
int to = Math.min(from + pageSize, trips.size());
page.setRecords(assemble(trips.subList(from, to)));
return page;
}
@Override
public TripTabCountsVO tabCounts(TripQueryVO query) {
TripQueryVO condition = condition(query);
TripTabCountsVO counts = new TripTabCountsVO();
if (!scoped(condition)) {
return counts;
}
// 一次取数算清四档:分档口径与列表、卡片同一份推导,不再按库里的业务状态分批计数
long pending = 0;
long doing = 0;
long done = 0;
for (TripRows trip : trips(withoutTab(condition))) {
Integer tier = trip.tier();
if (tier == null) {
continue;
}
switch (tier) {
case 0 -> pending++;
case 1 -> doing++;
case 2 -> done++;
default -> {
}
}
}
counts.setPending(pending);
counts.setDoing(doing);
counts.setDone(done);
counts.setAll(pending + doing + done);
return counts;
}
@Override
public TripVO detail(TripQueryVO query) {
TripQueryVO condition = condition(query);
Long id = tripId(condition.getTripId());
if (id == null || !scoped(condition)) {
throw new ServiceException("运次不存在或无权操作");
}
LoadingManage loading = visibleLoading(id, condition);
if (loading != null) {
List<Waybill> children = loadChildren(Set.of(loading.getLoadingNo()), Set.of())
.getOrDefault(loading.getLoadingNo(), List.of());
if (children.isEmpty()) {
// 子单全部被拒单(或已释放)的配载运次对司机不可见,与列表口径一致
throw new ServiceException("运次不存在或无权操作");
}
// 详情与列表同一份分档口径:接单需求也按项目生效中的配置判,否则列表说待接单、详情与接收入口说无需接单
applyLiveProcessConfig(children);
return loadingTrip(loading, children, true);
}
Waybill waybill = visibleWaybill(id, condition);
if (waybill == null) {
throw new ServiceException("运次不存在或无权操作");
}
applyLiveProcessConfig(List.of(waybill));
return waybillTrip(waybill, true);
}
/**
* 全部可见运次:一次取回运单行后在服务层分组、分档、筛选并按排序键倒序
* <p>
* 分档用过程配置与接单态推导,于是 Tab 与卡片必然同档;行级筛选(组织类型 / 异常 / 创建时间)落在库层,
* 命中的行同时决定这一趟车的子单集合与分档。
*/
private List<TripRows> trips(TripQueryVO condition) {
Map<String, List<Waybill>> grouped = new LinkedHashMap<>();
for (Waybill row : visibleRows(condition)) {
grouped.computeIfAbsent(TripAggregation.keyOf(row.getLoadingNo(), row.getId()),
ignored -> new ArrayList<>()).add(row);
}
Set<String> excluded = condition.isExcludeRejectedTrips() ? rejectedTripKeys() : Set.of();
Set<Integer> tiers = tiers(condition);
List<TripRows> trips = new ArrayList<>(grouped.size());
grouped.forEach((key, rows) -> {
if (excluded.contains(key)) {
return;
}
List<Waybill> children = sortedChildren(rows);
if (!inScope(children, condition)) {
return;
}
boolean requireAccept = TripAggregation.requiresAccept(children);
boolean accepted = WaybillProcessSupport.ACCEPT_ACCEPTED.equals(TripAggregation.acceptStatus(children));
String status = TripAggregation.statusOf(children, requireAccept, accepted);
Integer tier = TripAggregation.appStatus(status);
if (tier == null || !tiers.contains(tier)) {
return;
}
trips.add(new TripRows(key, rows.get(0).getLoadingNo(), children, status, tier, maxCreateTime(children)));
});
// 排序键是子单 create_time 的最大值,倒序;键相同再按运次键定序,免得同分页里顺序漂移
trips.sort(Comparator.comparing(TripRows::sortTime, Comparator.nullsLast(Comparator.reverseOrder()))
.thenComparing(TripRows::key));
return trips;
}
/** 子单按创建时刻升序:站点拼装与货物汇总都按这个顺序 */
private List<Waybill> sortedChildren(List<Waybill> rows) {
List<Waybill> children = new ArrayList<>(rows);
children.sort(Comparator.comparing(Waybill::getCreateTime, Comparator.nullsLast(Comparator.naturalOrder())));
return children;
}
/**
* 段一:一次取回该收口与行级筛选命中的可见运单行
* <p>
* 只 select 拼装与分档用得上的列:运单表上有几张大 JSON 列,整行拖回不值得。
* goods_json 必须随行取回——卡片上的货名、货物汇总与合计重量都以它为准,一张运单可以录多条货物,
* 主表 cargo_name 只落了第一行。可见口径与运单列表同一条:状态进运次的才取,被该司机拒单的不取。
*/
private List<Waybill> visibleRows(TripQueryVO condition) {
LambdaQueryWrapper<Waybill> wrapper = Wrappers.<Waybill>lambdaQuery()
.select(Waybill::getId, Waybill::getLoadingNo, Waybill::getWaybillNo, Waybill::getBusinessStatus,
Waybill::getDriverAcceptStatus, Waybill::getProcessJson, Waybill::getProjectId, Waybill::getProjectName,
Waybill::getCargoName, Waybill::getCargoType, Waybill::getQuantity, Waybill::getQuantityUnit,
// 多货物运单的主表字段只有首行,货物明细只能从 goods_json 拿
Waybill::getGoodsJson,
Waybill::getDepartureName, Waybill::getDepartureAddress, Waybill::getArrivalName,
Waybill::getArrivalAddress, Waybill::getDriverName, Waybill::getDriverPhone, Waybill::getVehicleNo,
Waybill::getCustomerName, Waybill::getTransportType, Waybill::getTaskRemark,
// 未配载运次没有配载单可依,计划时间要取运单自己的,故必须随行取回
Waybill::getEstimatedStartTime, Waybill::getEstimatedEndTime, Waybill::getCreateTime)
.in(Waybill::getBusinessStatus, TripAggregation.CHILD_STATUSES)
.and(visible -> visible.isNull(Waybill::getDriverAcceptStatus)
.or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED));
applyScope(wrapper, condition);
applyDeptScope(wrapper, condition);
applyOrgTypeFilter(wrapper, condition);
applyKeyword(wrapper, condition.getKeyword());
List<Waybill> rows = waybillService.list(wrapper);
applyLiveProcessConfig(rows);
return rows;
}
/**
* 组织收口:条件里给了组织 ID 集合时只出这些组织的运单行,空集合表示不过滤。
* <p>
* 与司机手机号收口一样落在库层的运单行上:分档、分页、总数都由它缩过的行集合算出来。
* 读服务不自己解析当前用户的组织——它同时服务司机端与管理端,可见范围由调用方算好再传。
*/
private void applyDeptScope(LambdaQueryWrapper<Waybill> wrapper, TripQueryVO condition) {
if (Func.isEmpty(condition.getDeptIds())) {
return;
}
wrapper.in(Waybill::getDeptId, condition.getDeptIds());
}
/**
* 运输组织类型按子单行筛:它在同一趟车里必然是同一个值(配载的每张子单带同一个配载单号,
* 未配载的每张自成一趟车),所以这一层不会把一趟车拆开。
*/
private void applyOrgTypeFilter(LambdaQueryWrapper<Waybill> wrapper, TripQueryVO condition) {
if (TRANSPORT_LOAD.equals(condition.getTransportOrgType())) {
wrapper.apply("IFNULL(loading_no, '') <> ''");
} else if (TRANSPORT_COMMON.equals(condition.getTransportOrgType())) {
wrapper.apply("IFNULL(loading_no, '') = ''");
}
}
/**
* 这趟车在不在筛选范围内:异常按「任一子单命中」,创建时间按子单 create_time 的最大值(运次列表的排序键,
* 也是调度端卡片上显示的创建时间)。两个判据都按整趟车的子单集合算——先按筛选把子单集合缩小再分档,
* 会让同一趟车归在某一档、卡片却显示另一档。
* <p>
* 筛选只决定这趟车在不在范围内,分档仍看它完整的可见子单集合。
*/
private boolean inScope(List<Waybill> children, TripQueryVO condition) {
Set<Long> exceptionIds = condition.getExceptionWaybillIds();
if (Func.isNotEmpty(exceptionIds)) {
boolean hit = children.stream().map(Waybill::getId).anyMatch(exceptionIds::contains);
if (EXCEPTION_YES.equals(condition.getException()) && !hit) {
return false;
}
if (EXCEPTION_NO.equals(condition.getException()) && hit) {
return false;
}
}
Date created = maxCreateTime(children);
if (condition.getCreatedFrom() != null
&& (created == null || created.before(condition.getCreatedFrom()))) {
return false;
}
return condition.getCreatedTo() == null
|| (created != null && !created.after(condition.getCreatedTo()));
}
/**
* 按单票路径的口径把生效中的过程配置写回内存里的行:项目启用中的配置优先,它没有可打卡节点时回退运单快照。
* <p>
* 只改内存里的对象,分档与卡片下发都读它,不落库;生效配置一次取回后按项目复用,不逐条回查。
* 取法与二选一规则都在 {@link LiveProcessConfigSupport},这里只做批量写回。
*/
private void applyLiveProcessConfig(List<Waybill> rows) {
if (Func.isEmpty(rows)) {
return;
}
Set<Long> projectIds = rows.stream().map(Waybill::getProjectId).filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
if (projectIds.isEmpty()) {
return;
}
Map<Long, String> live = LiveProcessConfigSupport.byProjects(processConfigService, projectIds);
if (live.isEmpty()) {
return;
}
for (Waybill row : rows) {
String liveJson = live.get(row.getProjectId());
if (Func.isNotEmpty(liveJson)) {
row.setProcessJson(LiveProcessConfigSupport.resolveForPunch(liveJson, row.getProcessJson()));
}
}
}
/**
* 有子单被拒、且这趟车还没结束的运次键:整单拒绝后这趟车不再算待处理
*/
private Set<String> rejectedTripKeys() {
return waybillService.list(Wrappers.<Waybill>lambdaQuery()
.select(Waybill::getId, Waybill::getLoadingNo)
.eq(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED)
.in(Waybill::getBusinessStatus, TripAggregation.REASSIGN_STATUSES))
.stream()
.map(row -> TripAggregation.keyOf(row.getLoadingNo(), row.getId()))
.collect(Collectors.toCollection(LinkedHashSet::new));
}
/**
* 要哪几档:多档优先,其次单档,都不传按「全部」=待接单/进行中/已完成(不含已取消)
*/
private Set<Integer> tiers(TripQueryVO condition) {
if (Func.isNotEmpty(condition.getStatuses())) {
return new LinkedHashSet<>(condition.getStatuses());
}
if (condition.getStatus() != null) {
return Set.of(condition.getStatus());
}
return Set.of(0, 1, 2);
}
/**
* 运次可见范围:配载段按配载单的司机手机号收口,未配载段按运单的司机手机号收口。
* <p>
* 手机号允许带空格,与运单列表的收口判据一致。
*/
private void applyScope(LambdaQueryWrapper<Waybill> wrapper, TripQueryVO condition) {
if (condition.isAllDrivers()) {
return;
}
String phone = Func.toStr(condition.getDriverPhone(), "").trim();
wrapper.and(scope -> scope
.apply("(IFNULL(loading_no, '') <> '' AND loading_no IN (SELECT lm.loading_no FROM blade_loading_manage lm"
+ " WHERE lm.is_deleted = 0 AND lm.driver_phone = {0}))", phone)
.or(lone -> lone
.apply("IFNULL(loading_no, '') = ''")
.and(row -> row.eq(Waybill::getDriverPhone, phone)
.or().apply("REPLACE(IFNULL(driver_phone, ''), ' ', '') = {0}", phone))));
}
/**
* 关键字按运次匹配:任一子单或配载单号命中即整趟车命中。
* <p>
* 先按关键字查出命中的运次键,再要求这一行的所属运次在其中——关键字不缩小分组内的子单集合,
* 否则状态与排序会按命中的那一部分子单算。
*/
private void applyKeyword(LambdaQueryWrapper<Waybill> wrapper, String keyword) {
if (Func.isEmpty(keyword)) {
return;
}
Set<String> keys = keywordTripKeys(keyword.trim());
if (keys.isEmpty()) {
wrapper.apply("1 = 0");
return;
}
Set<String> loadingNos = keys.stream().filter(key -> !key.startsWith(LONE_KEY_PREFIX))
.collect(Collectors.toCollection(LinkedHashSet::new));
Set<Long> loneIds = keys.stream().filter(key -> key.startsWith(LONE_KEY_PREFIX))
.map(key -> key.substring(LONE_KEY_PREFIX.length()))
.map(this::tripId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
wrapper.and(matched -> {
if (!loadingNos.isEmpty()) {
matched.in(Waybill::getLoadingNo, loadingNos);
}
if (!loneIds.isEmpty()) {
if (!loadingNos.isEmpty()) {
matched.or();
}
matched.apply("IFNULL(loading_no, '') = ''").in(Waybill::getId, loneIds);
}
});
}
/** 关键字命中的运次键:在子单上按关键字找一遍,再换算成运次键 */
private Set<String> keywordTripKeys(String keyword) {
String like = "%" + keyword + "%";
Object[] params = new Object[KEYWORD_COLUMNS.length];
StringBuilder matched = new StringBuilder();
for (int i = 0; i < KEYWORD_COLUMNS.length; i++) {
if (i > 0) {
matched.append(" OR ");
}
matched.append(KEYWORD_COLUMNS[i]).append(" LIKE {").append(i).append('}');
params[i] = like;
}
return waybillService.list(Wrappers.<Waybill>lambdaQuery()
.select(Waybill::getId, Waybill::getLoadingNo)
.apply("(" + matched + ")", params))
.stream()
.map(row -> TripAggregation.keyOf(row.getLoadingNo(), row.getId()))
.collect(Collectors.toCollection(LinkedHashSet::new));
}
/**
* 把这一页的运次组装成运次:先批量取配载单,再逐趟拼装
* <p>
* 子运单就是段一取回来的那些行,不再回查:回查会拿回未经行级筛选的子单,
* 卡片的状态与货物汇总就会与分档、与筛选结果对不上。
*/
private List<TripVO> assemble(List<TripRows> trips) {
if (Func.isEmpty(trips)) {
return new ArrayList<>();
}
Set<String> loadingNos = trips.stream()
.map(TripRows::loadingNo)
.filter(Func::isNotEmpty)
.collect(Collectors.toCollection(LinkedHashSet::new));
Map<String, LoadingManage> loadings = loadLoadings(loadingNos);
List<TripVO> result = new ArrayList<>(trips.size());
for (TripRows trip : trips) {
if (Func.isEmpty(trip.loadingNo())) {
result.add(waybillTrip(trip.children().get(0), false));
continue;
}
LoadingManage loading = loadings.get(trip.loadingNo());
if (loading == null) {
// 配载单被并发删除:不给出没有主键的运次
continue;
}
result.add(loadingTrip(loading, trip.children(), false));
}
return result;
}
/**
* 段二:按本页运次的配载单号一次取回配载单
*/
private Map<String, LoadingManage> loadLoadings(Set<String> loadingNos) {
if (loadingNos.isEmpty()) {
return Map.of();
}
return loadingManageService.list(Wrappers.<LoadingManage>lambdaQuery()
.in(LoadingManage::getLoadingNo, loadingNos))
.stream()
.collect(Collectors.toMap(LoadingManage::getLoadingNo, Function.identity(), (first, ignored) -> first));
}
/**
* 段二:按本页运次的配载单号与未配载运单主键一次取回子运单,不逐条查
* <p>
* 子单归属一律以配载单号为准:库层就是按配载单号分组聚合的,服务层复核用别的关联关系,
* 同一页里就会出现「Tab 说它在进行中、卡片按子单算出来是待接单」。
* 配载单上的运单号集合只是派单时的快照,子单被释放(配载单号清空)后不再属于该运次。
*/
private Map<String, List<Waybill>> loadChildren(Set<String> loadingNos, Set<Long> loneIds) {
if (loadingNos.isEmpty() && loneIds.isEmpty()) {
return Map.of();
}
List<Waybill> children = waybillService.list(Wrappers.<Waybill>lambdaQuery()
.in(Waybill::getBusinessStatus, TripAggregation.CHILD_STATUSES)
.and(visible -> visible.isNull(Waybill::getDriverAcceptStatus)
.or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED))
.and(scope -> scope.in(Func.isNotEmpty(loadingNos), Waybill::getLoadingNo, loadingNos)
.or(Func.isNotEmpty(loneIds), lone -> lone.in(Waybill::getId, loneIds))));
Map<String, List<Waybill>> grouped = new LinkedHashMap<>();
for (Waybill child : children) {
String key = Func.isEmpty(child.getLoadingNo()) ? String.valueOf(child.getId()) : child.getLoadingNo();
grouped.computeIfAbsent(key, ignored -> new ArrayList<>()).add(child);
}
grouped.values().forEach(group -> group.sort(
Comparator.comparing(Waybill::getCreateTime, Comparator.nullsLast(Comparator.naturalOrder()))));
return grouped;
}
/**
* 配载运次:司机、车辆、路线取配载单(配载单是运输执行主体),状态与接单需求由子单聚合
*/
private TripVO loadingTrip(LoadingManage loading, List<Waybill> children, boolean withPayload) {
TripVO trip = new TripVO();
trip.setId(tripIdOf(loading.getId()));
trip.setLoadingNo(loading.getLoadingNo());
trip.setTripNo(loading.getLoadingNo());
trip.setTransportOrgType(TRANSPORT_LOAD);
trip.setDriverName(Func.toStr(loading.getDriverName(), ""));
trip.setDriverPhone(Func.toStr(loading.getDriverPhone(), ""));
trip.setVehicleNo(Func.toStr(loading.getVehicleNo(), ""));
trip.setTrailerVehicleNo(Func.toStr(loading.getTrailerVehicleNo(), ""));
trip.setProjectName(Func.toStr(loading.getProjectName(), ""));
trip.setCustomerName(Func.toStr(loading.getCustomerName(), ""));
trip.setTransportType(Func.toStr(loading.getTransportType(), ""));
trip.setCargoName(firstNotEmpty(loading.getCargoName(), children, Waybill::getCargoName));
trip.setCargoType(firstNotEmpty(loading.getCargoType(), children, Waybill::getCargoType));
trip.setDepartureAddress(Func.toStr(loading.getDepartureAddress(), ""));
trip.setTransitAddress(Func.toStr(loading.getTransitAddress(), ""));
trip.setArrivalAddress(Func.toStr(loading.getArrivalAddress(), ""));
trip.setEstimatedStartDate(loading.getEstimatedStartDate());
trip.setEstimatedEndDate(loading.getEstimatedEndDate());
trip.setTaskRemark(Func.toStr(loading.getTaskRemark(), ""));
if (withPayload) {
trip.setRouteJson(loading.getRouteJson());
trip.setGoodsJson(loading.getGoodsJson());
}
fillAggregation(trip, children);
fillRouteAndCargo(trip, loading, children, withPayload);
return trip;
}
/**
* 未配载运次:整张运单就是这一趟车
*/
private TripVO waybillTrip(Waybill waybill, boolean withPayload) {
TripVO trip = new TripVO();
trip.setId(tripIdOf(waybill.getId()));
trip.setTripNo(waybill.getWaybillNo());
trip.setTransportOrgType(TRANSPORT_COMMON);
trip.setDriverName(Func.toStr(waybill.getDriverName(), ""));
trip.setDriverPhone(Func.toStr(waybill.getDriverPhone(), ""));
trip.setVehicleNo(Func.toStr(waybill.getVehicleNo(), ""));
trip.setProjectName(Func.toStr(waybill.getProjectName(), ""));
trip.setCustomerName(Func.toStr(waybill.getCustomerName(), ""));
trip.setTransportType(Func.toStr(waybill.getTransportType(), ""));
trip.setCargoName(Func.toStr(waybill.getCargoName(), ""));
trip.setCargoType(Func.toStr(waybill.getCargoType(), ""));
trip.setDepartureAddress(Func.toStr(waybill.getDepartureAddress(), ""));
trip.setArrivalAddress(Func.toStr(waybill.getArrivalAddress(), ""));
// 未配载没有配载单可依,计划时间只能取这张运单自己的
trip.setEstimatedStartDate(waybill.getEstimatedStartTime());
trip.setEstimatedEndDate(waybill.getEstimatedEndTime());
trip.setTaskRemark(Func.toStr(waybill.getTaskRemark(), ""));
if (withPayload) {
trip.setRouteJson(waybill.getRouteJson());
trip.setGoodsJson(waybill.getGoodsJson());
}
fillAggregation(trip, List.of(waybill));
fillRouteAndCargo(trip, null, List.of(waybill), withPayload);
return trip;
}
/**
* 聚合出运次的状态、接单需求、接单态与排序键:与分档用同一份推导,Tab 与卡片因此必然同档
*/
private void fillAggregation(TripVO trip, List<Waybill> children) {
String acceptStatus = TripAggregation.acceptStatus(children);
boolean requireAccept = TripAggregation.requiresAccept(children);
boolean accepted = WaybillProcessSupport.ACCEPT_ACCEPTED.equals(acceptStatus);
trip.setAcceptStatus(acceptStatus);
trip.setRequireAccept(requireAccept);
trip.setStatus(TripAggregation.appStatus(TripAggregation.statusOf(children, requireAccept, accepted)));
trip.setWaybillCount(children.size());
trip.setSortTime(maxCreateTime(children));
trip.setWaybills(children.stream().map(this::toWaybillVO).toList());
}
/**
* 站点与货物都按整趟车算:站点明细只在详情下发,列表带走起/经/终、站点数与货物汇总
*/
private void fillRouteAndCargo(TripVO trip, LoadingManage loading, List<Waybill> children, boolean withPayload) {
List<TripStationVO> stations = TripStationAssembler.assemble(loading, children);
trip.setDepartureName(TripStationAssembler.departureName(stations));
trip.setTransitName(TripStationAssembler.transitName(stations));
trip.setArrivalName(TripStationAssembler.arrivalName(stations));
trip.setStationCount(stations.size());
if (withPayload) {
trip.setStations(stations);
}
List<WaybillCargoSupport.Cargo> cargos = children.stream()
.flatMap(child -> WaybillCargoSupport.of(child).stream())
.toList();
List<String> cargoNames = WaybillCargoSupport.cargoNames(cargos);
// 运次级货名覆盖整趟车:主表 cargo_name 只是首行,配载单的 cargo_name 也是派单时的快照
trip.setCargoNames(cargoNames);
if (!cargoNames.isEmpty()) {
trip.setCargoName(String.join("、", cargoNames));
}
List<String> cargoTypes = cargos.stream()
.map(WaybillCargoSupport.Cargo::cargoType)
.filter(Func::isNotEmpty)
.distinct()
.toList();
trip.setCargoTypes(cargoTypes);
if (!cargoTypes.isEmpty()) {
trip.setCargoType(String.join("、", cargoTypes));
}
trip.setCargoSummary(cargoSummary(children));
trip.setCargos(cargoLines(cargos));
fillTotalQuantity(trip, children);
fillWeighing(trip, cargos);
}
/**
* 货物汇总:同名货物合并且单位一致时取数量合计,没有数量的只列货名
* <p>
* 一条运单可以有多条货物,明细在 goods_json;主表 cargo_name 只落了第一行,拿它汇总等于把其余货物丢掉。
*/
private String cargoSummary(List<Waybill> children) {
Map<String, Map<String, BigDecimal>> grouped = new LinkedHashMap<>();
for (Waybill child : children) {
List<WaybillCargoSupport.Cargo> cargos = WaybillCargoSupport.of(child);
if (cargos.isEmpty()) {
continue;
}
for (WaybillCargoSupport.Cargo cargo : cargos) {
String name = cargo.displayName();
String unit = Func.toStr(cargo.quantityUnit(), "");
Map<String, BigDecimal> byUnit = grouped.computeIfAbsent(name, key -> new LinkedHashMap<>());
BigDecimal quantity = cargo.quantity() == null ? BigDecimal.ZERO : cargo.quantity();
byUnit.merge(unit, quantity, BigDecimal::add);
}
}
List<String> parts = new ArrayList<>();
grouped.forEach((name, byUnit) -> byUnit.forEach((unit, quantity) ->
parts.add(quantity.signum() == 0 ? name : name + " " + formatQuantity(quantity, unit))));
return String.join("、", parts);
}
/**
* 货物明细行:与 cargoSummary 同一套合并口径,但数量与单位分开留,供详情页逐行展示
*/
private List<CargoLineVO> cargoLines(List<WaybillCargoSupport.Cargo> cargos) {
List<CargoLineVO> lines = new ArrayList<>();
for (WaybillCargoSupport.Line line : WaybillCargoSupport.lines(cargos)) {
CargoLineVO vo = new CargoLineVO();
vo.setCargoName(line.name());
vo.setQuantity(line.quantity() == null ? "" : formatQuantity(line.quantity(), line.unit()));
lines.add(vo);
}
return lines;
}
/**
* 合计重量:只有全部货物同单位时才能相加,单位不一致时留空(汇总文案里仍分单位列出)
* <p>
* 按货物行算而不是按运单行算:一张运单里的多条货物可能是不同的单位与数量,
* 主表 quantity 是这些行的合计、quantity_unit 只是首行的单位,拿它当合计会把「20堆 + 50方」说成「70堆」。
*/
private void fillTotalQuantity(TripVO trip, List<Waybill> children) {
String unit = null;
BigDecimal total = BigDecimal.ZERO;
boolean any = false;
for (Waybill child : children) {
for (WaybillCargoSupport.Cargo cargo : WaybillCargoSupport.of(child)) {
String cargoUnit = Func.toStr(cargo.quantityUnit(), "");
if (cargo.quantity() == null || Func.isEmpty(cargoUnit)) {
return;
}
if (unit == null) {
unit = cargoUnit;
} else if (!unit.equals(cargoUnit)) {
return;
}
total = total.add(cargo.quantity());
any = true;
}
}
if (!any || unit == null) {
return;
}
trip.setTotalQuantity(total);
trip.setQuantityUnit(unit);
}
/**
* 计重合计:卡片上那个「货物重量」,口径见 WaybillCargoSupport#weighing
*/
private void fillWeighing(TripVO trip, List<WaybillCargoSupport.Cargo> cargos) {
WaybillCargoSupport.Quantity weighing = WaybillCargoSupport.weighing(cargos);
if (weighing == null) {
return;
}
trip.setWeighingQuantity(weighing.value());
trip.setWeighingUnit(weighing.unit());
}
private String formatQuantity(BigDecimal quantity, String unit) {
String value = quantity.stripTrailingZeros().toPlainString();
return Func.isEmpty(unit) ? value : value + unit;
}
private TripVO.TripWaybillVO toWaybillVO(Waybill waybill) {
TripVO.TripWaybillVO child = new TripVO.TripWaybillVO();
child.setId(tripIdOf(waybill.getId()));
child.setWaybillNo(waybill.getWaybillNo());
child.setBusinessStatus(waybill.getBusinessStatus());
child.setAcceptStatus(waybill.getDriverAcceptStatus());
child.setRequireAccept(WaybillProcessSupport.requiresDriverAcceptConfirmation(waybill.getProcessJson()));
child.setProjectId(waybill.getProjectId());
child.setProjectName(waybill.getProjectName());
// 货名按运单上实际录入的货物给全:主表 cargo_name 只是首行,多货物运单会漏掉其余货物
List<WaybillCargoSupport.Cargo> cargos = WaybillCargoSupport.of(waybill);
child.setCargoName(String.join("、", WaybillCargoSupport.cargoNames(cargos)));
child.setCargoNames(WaybillCargoSupport.cargoNames(cargos));
child.setCargoTypes(cargos.stream()
.map(WaybillCargoSupport.Cargo::cargoType)
.filter(Func::isNotEmpty)
.distinct()
.toList());
child.setCargoType(waybill.getCargoType());
child.setQuantity(waybill.getQuantity());
child.setQuantityUnit(waybill.getQuantityUnit());
child.setDepartureName(waybill.getDepartureName());
child.setDepartureAddress(waybill.getDepartureAddress());
child.setArrivalName(waybill.getArrivalName());
child.setArrivalAddress(waybill.getArrivalAddress());
child.setCreateTime(waybill.getCreateTime());
return child;
}
/** 排序键:子单 create_time 的最大值 */
private Date maxCreateTime(List<Waybill> children) {
return children.stream()
.map(Waybill::getCreateTime)
.filter(Objects::nonNull)
.max(Comparator.naturalOrder())
.orElse(null);
}
private String firstNotEmpty(String preferred, List<Waybill> children, Function<Waybill, String> getter) {
if (Func.isNotEmpty(preferred)) {
return preferred;
}
return children.stream().map(getter).filter(Func::isNotEmpty).findFirst().orElse("");
}
/**
* 按运次主键取配载单:主键命中配载单即配载运次,再按司机收口
*/
private LoadingManage visibleLoading(Long id, TripQueryVO condition) {
LoadingManage loading = loadingManageService.getById(id);
if (loading == null) {
return null;
}
if (condition.isAllDrivers()) {
return loading;
}
return samePhone(loading.getDriverPhone(), condition.getDriverPhone()) ? loading : null;
}
/**
* 按运次主键取未配载运单:必须还没配载、在可见状态里,且属于收口范围
*/
private Waybill visibleWaybill(Long id, TripQueryVO condition) {
Waybill waybill = waybillService.getById(id);
if (waybill == null
|| Func.isNotEmpty(waybill.getLoadingNo())
|| !TripAggregation.CHILD_STATUSES.contains(waybill.getBusinessStatus())
|| WaybillProcessSupport.ACCEPT_REJECTED.equals(waybill.getDriverAcceptStatus())) {
return null;
}
if (condition.isAllDrivers()) {
return waybill;
}
return samePhone(waybill.getDriverPhone(), condition.getDriverPhone()) ? waybill : null;
}
private boolean scoped(TripQueryVO condition) {
return condition.isAllDrivers() || Func.isNotEmpty(Func.toStr(condition.getDriverPhone(), "").trim());
}
private boolean samePhone(String left, String right) {
String expected = Func.toStr(right, "").trim().replace(" ", "");
if (Func.isEmpty(expected)) {
return false;
}
return expected.equals(Func.toStr(left, "").trim().replace(" ", ""));
}
private TripQueryVO condition(TripQueryVO query) {
return query == null ? new TripQueryVO() : query;
}
/** 去掉 Tab 条件、其余照搬:统计四档时不按某一档收窄 */
private TripQueryVO withoutTab(TripQueryVO source) {
TripQueryVO copy = new TripQueryVO();
copy.setDriverPhone(source.getDriverPhone());
copy.setAllDrivers(source.isAllDrivers());
copy.setDeptIds(source.getDeptIds());
copy.setKeyword(source.getKeyword());
copy.setTransportOrgType(source.getTransportOrgType());
copy.setException(source.getException());
copy.setExceptionWaybillIds(source.getExceptionWaybillIds());
copy.setCreatedFrom(source.getCreatedFrom());
copy.setCreatedTo(source.getCreatedTo());
copy.setExcludeRejectedTrips(source.isExcludeRejectedTrips());
return copy;
}
private Long tripId(String value) {
if (Func.isEmpty(value)) {
return null;
}
try {
return Long.valueOf(value.trim());
} catch (NumberFormatException ignored) {
// 运次主键只有配载单与运单两种数字雪花号,其余一律当作不存在的运次
return null;
}
}
/** 运次主键按字符串下发:19 位雪花号按数字下发会被前端取整 */
private String tripIdOf(Long id) {
return id == null ? null : String.valueOf(id);
}
/** 一趟车:运次键、子单行(段一取回的、含行级筛选后的)、派生状态与排序键 */
private record TripRows(String key, String loadingNo, List<Waybill> children, String status, Integer tier,
Date sortTime) {
}
private int pageNo(Integer current) {
return current == null || current < 1 ? 1 : current;
}
private int pageSize(Integer size) {
if (size == null || size < 1) {
return DEFAULT_PAGE_SIZE;
}
return Math.min(size, MAX_PAGE_SIZE);
}
}

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