🔀 合并主干 master 到 dev
This commit is contained in:
+133
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 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.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.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
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.tenant.annotation.NonDS;
|
||||
import org.springblade.core.tool.api.R;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.pojo.entity.BusinessDeptConfig;
|
||||
import org.springblade.system.pojo.vo.BusinessDeptConfigVO;
|
||||
import org.springblade.system.service.IBusinessDeptConfigService;
|
||||
import org.springblade.system.wrapper.BusinessDeptConfigWrapper;
|
||||
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.RestController;
|
||||
|
||||
/**
|
||||
* 业务部门配置控制器
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@NonDS
|
||||
@RestController
|
||||
@AllArgsConstructor
|
||||
@PreAuth(menu = "business_dept_config")
|
||||
@RequestMapping("/business-dept-config")
|
||||
@Tag(name = "业务部门配置", description = "业务部门配置")
|
||||
public class BusinessDeptConfigController extends BladeController {
|
||||
|
||||
private final IBusinessDeptConfigService businessDeptConfigService;
|
||||
|
||||
/**
|
||||
* 详情
|
||||
*/
|
||||
@GetMapping("/detail")
|
||||
@ApiOperationSupport(order = 1)
|
||||
@Operation(summary = "详情", description = "传入businessDeptConfig")
|
||||
public R<BusinessDeptConfigVO> detail(BusinessDeptConfig businessDeptConfig) {
|
||||
if (Func.isEmpty(businessDeptConfig.getId())) {
|
||||
return R.fail("主键不能为空");
|
||||
}
|
||||
BusinessDeptConfig detail = businessDeptConfigService.getById(businessDeptConfig.getId());
|
||||
if (Func.isEmpty(detail)) {
|
||||
return R.fail("业务部门配置不存在");
|
||||
}
|
||||
return R.data(BusinessDeptConfigWrapper.build().entityVO(detail));
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
@ApiOperationSupport(order = 2)
|
||||
@Operation(summary = "分页", description = "传入businessDeptConfig")
|
||||
public R<IPage<BusinessDeptConfigVO>> list(BusinessDeptConfigVO businessDeptConfig, Query query) {
|
||||
IPage<BusinessDeptConfigVO> pages = businessDeptConfigService.selectBusinessDeptConfigPage(
|
||||
Condition.getPage(query), businessDeptConfig);
|
||||
return R.data(pages);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增或修改
|
||||
*/
|
||||
@PostMapping("/submit")
|
||||
@ApiOperationSupport(order = 3)
|
||||
@Operation(summary = "新增或修改", description = "传入businessDeptConfig")
|
||||
public R submit(@Valid @RequestBody BusinessDeptConfig businessDeptConfig) {
|
||||
return R.status(businessDeptConfigService.submit(businessDeptConfig));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除
|
||||
*/
|
||||
@PostMapping("/remove")
|
||||
@ApiOperationSupport(order = 4)
|
||||
@Operation(summary = "逻辑删除", description = "传入ids")
|
||||
public R remove(@Parameter(description = "主键集合", required = true) @RequestParam String ids) {
|
||||
return R.status(businessDeptConfigService.deleteLogic(Func.toLongList(ids)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按平台公司 + 业务部门查询
|
||||
*/
|
||||
@GetMapping("/by-platform-dept")
|
||||
@ApiOperationSupport(order = 5)
|
||||
@Operation(summary = "按平台公司与业务部门查询", description = "传入platformCompanyId、businessDeptId")
|
||||
public R<BusinessDeptConfigVO> byPlatformDept(
|
||||
@Parameter(description = "平台公司ID", required = true) @RequestParam Long platformCompanyId,
|
||||
@Parameter(description = "业务部门ID", required = true) @RequestParam Long businessDeptId) {
|
||||
BusinessDeptConfig detail = businessDeptConfigService.getByPlatformAndDept(platformCompanyId, businessDeptId);
|
||||
if (detail == null) {
|
||||
return R.data(null);
|
||||
}
|
||||
return R.data(BusinessDeptConfigWrapper.build().entityVO(detail));
|
||||
}
|
||||
|
||||
}
|
||||
+12
-4
@@ -95,7 +95,9 @@ public class DeptController extends BladeController {
|
||||
@Parameters({
|
||||
@Parameter(name = "deptName", description = "组织名称", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
|
||||
@Parameter(name = "shortName", description = "组织简称", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
|
||||
@Parameter(name = "fullName", description = "部门全称", in = ParameterIn.QUERY, schema = @Schema(type = "string"))
|
||||
@Parameter(name = "fullName", description = "部门全称", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
|
||||
@Parameter(name = "isPlatformCompany", description = "是否平台公司", in = ParameterIn.QUERY, schema = @Schema(type = "integer")),
|
||||
@Parameter(name = "isBusinessDept", description = "是否业务部门", in = ParameterIn.QUERY, schema = @Schema(type = "integer"))
|
||||
})
|
||||
@ApiOperationSupport(order = 2)
|
||||
@Operation(summary = "列表", description = "传入dept")
|
||||
@@ -111,7 +113,9 @@ public class DeptController extends BladeController {
|
||||
@Parameters({
|
||||
@Parameter(name = "deptName", description = "组织名称", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
|
||||
@Parameter(name = "shortName", description = "组织简称", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
|
||||
@Parameter(name = "fullName", description = "部门全称", in = ParameterIn.QUERY, schema = @Schema(type = "string"))
|
||||
@Parameter(name = "fullName", description = "部门全称", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
|
||||
@Parameter(name = "isPlatformCompany", description = "是否平台公司", in = ParameterIn.QUERY, schema = @Schema(type = "integer")),
|
||||
@Parameter(name = "isBusinessDept", description = "是否业务部门", in = ParameterIn.QUERY, schema = @Schema(type = "integer"))
|
||||
})
|
||||
@ApiOperationSupport(order = 3)
|
||||
@Operation(summary = "懒加载列表", description = "传入dept")
|
||||
@@ -122,8 +126,11 @@ public class DeptController extends BladeController {
|
||||
|
||||
/**
|
||||
* 获取部门树形结构
|
||||
* <p>
|
||||
* 登录即可调用;数据范围由角色「数据权限」中 {@code dept_tree}(树形结构)单独配置,
|
||||
* 未配置时返回空树(默认不展示)。
|
||||
*/
|
||||
@PreAuth(menu = "dept")
|
||||
@PreAuth(AuthConstant.PERMIT_ALL)
|
||||
@GetMapping("/tree")
|
||||
@ApiOperationSupport(order = 4)
|
||||
@Operation(summary = "树形结构", description = "树形结构")
|
||||
@@ -135,8 +142,9 @@ public class DeptController extends BladeController {
|
||||
* 懒加载获取部门树形结构
|
||||
* <p>
|
||||
* tenantId 由 Service 层完整决策:超管入参为空表示查全部,非超管强制覆盖为会话租户。
|
||||
* 数据范围与 {@link #tree} 相同,由 {@code dept_tree} 单独配置,未配置时返回空树。
|
||||
*/
|
||||
@PreAuth(menu = "dept")
|
||||
@PreAuth(AuthConstant.PERMIT_ALL)
|
||||
@GetMapping("/lazy-tree")
|
||||
@ApiOperationSupport(order = 5)
|
||||
@Operation(summary = "懒加载树形结构", description = "树形结构")
|
||||
|
||||
+29
-10
@@ -70,14 +70,14 @@ public interface UserConvert {
|
||||
@Mapping(source = "lastname", target = "realName")
|
||||
@Mapping(source = "mobile", target = "phone")
|
||||
@Mapping(source = "email", target = "email")
|
||||
@Mapping(source = "loginid", target = "oaLoginId")
|
||||
@Mapping(target = "oaLoginId", ignore = true)
|
||||
@Mapping(source = "modified", target = "oaModified")
|
||||
User baseConvert(OAPersonResponse person);
|
||||
|
||||
@Mapping(source = "subcompanyname", target = "companyName")
|
||||
@Mapping(source = "departmentname", target = "deptName")
|
||||
@Mapping(source = "id", target = "oaId")
|
||||
@Mapping(source = "loginid", target = "oaLoginId")
|
||||
@Mapping(target = "oaLoginId", ignore = true)
|
||||
UserDept userDeptConvert(OAPersonResponse person);
|
||||
|
||||
/**
|
||||
@@ -88,6 +88,7 @@ public interface UserConvert {
|
||||
*/
|
||||
default User person2user(OAPersonResponse person, String defaultPassword) {
|
||||
User user = baseConvert(person);
|
||||
user.setOaLoginId(resolveOaLoginId(person));
|
||||
user.setAccount(resolveAccount(person));
|
||||
// OA 可能返回多号(如 133.../137...),只保留首个号码再入库加密
|
||||
user.setPhone(normalizeMobile(user.getPhone()));
|
||||
@@ -106,6 +107,22 @@ public interface UserConvert {
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 OA 登录名:优先工号 workcode,其次 loginid
|
||||
*/
|
||||
default String resolveOaLoginId(OAPersonResponse person) {
|
||||
if (person == null) {
|
||||
return null;
|
||||
}
|
||||
if (StringUtils.isNotBlank(person.getWorkcode())) {
|
||||
return person.getWorkcode().trim();
|
||||
}
|
||||
if (StringUtils.isNotBlank(person.getLoginid())) {
|
||||
return person.getLoginid().trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化手机号:去空白,多号时取第一个
|
||||
*/
|
||||
@@ -124,7 +141,7 @@ public interface UserConvert {
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析本系统登录账号:优先 OA loginid,其次工号,最后手机号
|
||||
* 解析本系统登录账号:优先工号 workcode,其次 OA loginid,最后手机号
|
||||
*
|
||||
* @param person OA人员
|
||||
* @return 账号,无法识别时返回 null
|
||||
@@ -133,12 +150,12 @@ public interface UserConvert {
|
||||
if (person == null) {
|
||||
return null;
|
||||
}
|
||||
if (StringUtils.isNotBlank(person.getLoginid())) {
|
||||
return person.getLoginid().trim();
|
||||
}
|
||||
if (StringUtils.isNotBlank(person.getWorkcode())) {
|
||||
return person.getWorkcode().trim();
|
||||
}
|
||||
if (StringUtils.isNotBlank(person.getLoginid())) {
|
||||
return person.getLoginid().trim();
|
||||
}
|
||||
if (StringUtils.isNotBlank(person.getMobile())) {
|
||||
return person.getMobile().trim();
|
||||
}
|
||||
@@ -148,7 +165,7 @@ public interface UserConvert {
|
||||
/**
|
||||
* oa人员转本系统用户部门
|
||||
* @param person
|
||||
* @param userMap OA登录名与用户id
|
||||
* @param userMap 同步键(W:工号)与用户id
|
||||
* @return
|
||||
*/
|
||||
default UserDept person2userDept(OAPersonResponse person, Map<String, Long> userMap) {
|
||||
@@ -162,9 +179,11 @@ public interface UserConvert {
|
||||
userDept.setStatus(status);
|
||||
// 排序
|
||||
userDept.setSort(OAUtils.parseInt(person.getDsporder()));
|
||||
// 用户id,按 OA 登录名关联本批已新增或更新的用户
|
||||
String oaLoginId = person == null ? null : StringUtils.trimToNull(person.getLoginid());
|
||||
userDept.setUserId(oaLoginId == null ? null : userMap.get(oaLoginId));
|
||||
// 用户id,按工号同步键关联本批已新增或更新的用户
|
||||
String oaLoginId = resolveOaLoginId(person);
|
||||
userDept.setOaLoginId(oaLoginId);
|
||||
String workcode = person == null ? null : StringUtils.trimToNull(person.getWorkcode());
|
||||
userDept.setUserId(workcode == null ? null : userMap.get("W:" + workcode));
|
||||
userDept.setSyncTime(new Date());
|
||||
return userDept;
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ public class SysClient implements ISysClient {
|
||||
private final ICargoTypeService cargoTypeService;
|
||||
private final IMeasurementUnitService measurementUnitService;
|
||||
private final IInvoiceItemService invoiceItemService;
|
||||
private final IBusinessDeptConfigService businessDeptConfigService;
|
||||
|
||||
@Override
|
||||
@GetMapping(MENU)
|
||||
@@ -257,4 +258,10 @@ public class SysClient implements ISysClient {
|
||||
.orderByAsc(InvoiceItem::getCategoryName, InvoiceItem::getShortName)));
|
||||
}
|
||||
|
||||
@Override
|
||||
@GetMapping(BUSINESS_DEPT_CONFIG)
|
||||
public R<BusinessDeptConfig> getBusinessDeptConfig(Long platformCompanyId, Long businessDeptId) {
|
||||
return R.data(businessDeptConfigService.getByPlatformAndDept(platformCompanyId, businessDeptId));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -38,9 +38,12 @@ import org.springblade.system.service.IUserService;
|
||||
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.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 用户服务Feign实现类
|
||||
*
|
||||
@@ -60,6 +63,12 @@ public class UserClient implements IUserClient {
|
||||
return R.data(service.getById(userId));
|
||||
}
|
||||
|
||||
@Override
|
||||
@GetMapping(MANAGE_DEPT_IDS)
|
||||
public R<List<Long>> manageDeptIds(@RequestParam("userId") Long userId) {
|
||||
return R.data(service.resolveManageDeptIds(userId));
|
||||
}
|
||||
|
||||
@Override
|
||||
@GetMapping(USER_BY_ACCOUNT)
|
||||
public R<User> userByAccount(String tenantId, String account) {
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.system.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.springblade.system.pojo.entity.BusinessDeptConfig;
|
||||
import org.springblade.system.pojo.vo.BusinessDeptConfigVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 业务部门配置 Mapper 接口
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public interface BusinessDeptConfigMapper extends BaseMapper<BusinessDeptConfig> {
|
||||
|
||||
/**
|
||||
* 自定义分页
|
||||
*
|
||||
* @param page 分页
|
||||
* @param businessDeptConfig 查询条件
|
||||
* @return 分页数据
|
||||
*/
|
||||
List<BusinessDeptConfigVO> selectBusinessDeptConfigPage(IPage<BusinessDeptConfigVO> page,
|
||||
@Param("businessDeptConfig") BusinessDeptConfigVO businessDeptConfig);
|
||||
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
<?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.system.mapper.BusinessDeptConfigMapper">
|
||||
|
||||
<resultMap id="businessDeptConfigResultMap" type="org.springblade.system.pojo.vo.BusinessDeptConfigVO">
|
||||
<result column="id" property="id"/>
|
||||
<result column="tenant_id" property="tenantId"/>
|
||||
<result column="platform_company_id" property="platformCompanyId"/>
|
||||
<result column="platform_company_name" property="platformCompanyName"/>
|
||||
<result column="business_dept_id" property="businessDeptId"/>
|
||||
<result column="business_dept_name" property="businessDeptName"/>
|
||||
<result column="contract_code_prefix" property="contractCodePrefix"/>
|
||||
<result column="initial_serial" property="initialSerial"/>
|
||||
<result column="kingdee_company_code" property="kingdeeCompanyCode"/>
|
||||
<result column="kingdee_dept_code" property="kingdeeDeptCode"/>
|
||||
<result column="remark" property="remark"/>
|
||||
<result column="create_user" property="createUser"/>
|
||||
<result column="create_dept" property="createDept"/>
|
||||
<result column="create_time" property="createTime"/>
|
||||
<result column="update_user" property="updateUser"/>
|
||||
<result column="update_time" property="updateTime"/>
|
||||
<result column="status" property="status"/>
|
||||
<result column="is_deleted" property="isDeleted"/>
|
||||
<result column="create_user_name" property="createUserName"/>
|
||||
<result column="update_user_name" property="updateUserName"/>
|
||||
</resultMap>
|
||||
|
||||
<select id="selectBusinessDeptConfigPage" resultMap="businessDeptConfigResultMap">
|
||||
SELECT
|
||||
bbc.*,
|
||||
cu.real_name AS create_user_name,
|
||||
uu.real_name AS update_user_name
|
||||
FROM blade_business_dept_config bbc
|
||||
LEFT JOIN blade_user cu ON cu.id = bbc.create_user
|
||||
LEFT JOIN blade_user uu ON uu.id = bbc.update_user
|
||||
WHERE bbc.is_deleted = 0
|
||||
<if test="businessDeptConfig.platformCompanyId != null">
|
||||
AND bbc.platform_company_id = #{businessDeptConfig.platformCompanyId}
|
||||
</if>
|
||||
<if test="businessDeptConfig.businessDeptId != null">
|
||||
AND bbc.business_dept_id = #{businessDeptConfig.businessDeptId}
|
||||
</if>
|
||||
<if test="businessDeptConfig.platformCompanyName != null and businessDeptConfig.platformCompanyName != ''">
|
||||
<bind name="platformCompanyNameLike" value="'%' + businessDeptConfig.platformCompanyName + '%'"/>
|
||||
AND bbc.platform_company_name LIKE #{platformCompanyNameLike}
|
||||
</if>
|
||||
<if test="businessDeptConfig.businessDeptName != null and businessDeptConfig.businessDeptName != ''">
|
||||
<bind name="businessDeptNameLike" value="'%' + businessDeptConfig.businessDeptName + '%'"/>
|
||||
AND bbc.business_dept_name LIKE #{businessDeptNameLike}
|
||||
</if>
|
||||
ORDER BY bbc.create_time DESC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -90,6 +90,12 @@
|
||||
<if test="param3.fullName!=null and param3.fullName!=''">
|
||||
and dept.full_name like concat(concat('%', #{param3.fullName}),'%')
|
||||
</if>
|
||||
<if test="param3.isPlatformCompany!=null and param3.isPlatformCompany!=''">
|
||||
and dept.is_platform_company = #{param3.isPlatformCompany}
|
||||
</if>
|
||||
<if test="param3.isBusinessDept!=null and param3.isBusinessDept!=''">
|
||||
and dept.is_business_dept = #{param3.isBusinessDept}
|
||||
</if>
|
||||
ORDER BY dept.sort
|
||||
</select>
|
||||
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import org.springblade.core.mp.base.BaseService;
|
||||
import org.springblade.system.pojo.entity.BusinessDeptConfig;
|
||||
import org.springblade.system.pojo.vo.BusinessDeptConfigVO;
|
||||
|
||||
/**
|
||||
* 业务部门配置服务类
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public interface IBusinessDeptConfigService extends BaseService<BusinessDeptConfig> {
|
||||
|
||||
/**
|
||||
* 自定义分页
|
||||
*
|
||||
* @param page 分页
|
||||
* @param businessDeptConfig 查询条件
|
||||
* @return 分页数据
|
||||
*/
|
||||
IPage<BusinessDeptConfigVO> selectBusinessDeptConfigPage(IPage<BusinessDeptConfigVO> page, BusinessDeptConfigVO businessDeptConfig);
|
||||
|
||||
/**
|
||||
* 新增或修改
|
||||
*
|
||||
* @param businessDeptConfig 配置
|
||||
* @return 是否成功
|
||||
*/
|
||||
boolean submit(BusinessDeptConfig businessDeptConfig);
|
||||
|
||||
/**
|
||||
* 按平台公司 + 业务部门查询配置
|
||||
*
|
||||
* @param platformCompanyId 平台公司ID
|
||||
* @param businessDeptId 业务部门ID
|
||||
* @return 配置,不存在返回 null
|
||||
*/
|
||||
BusinessDeptConfig getByPlatformAndDept(Long platformCompanyId, Long businessDeptId);
|
||||
|
||||
}
|
||||
+10
@@ -66,6 +66,16 @@ public interface IRoleMenuDataScopeService extends BaseService<RoleMenuDataScope
|
||||
*/
|
||||
RoleDataScopeVO resolveByMenuCode(List<Long> roleIds, String menuCode, List<Long> userDeptIds);
|
||||
|
||||
/**
|
||||
* 按菜单编码解析角色数据权限;菜单不存在或未配置时返回空权限(不回落为全部组织)
|
||||
*
|
||||
* @param roleIds 角色ID集合
|
||||
* @param menuCode 菜单编码
|
||||
* @param userDeptIds 用户所属组织
|
||||
* @return 解析结果
|
||||
*/
|
||||
RoleDataScopeVO resolveByMenuCodeOrEmpty(List<Long> roleIds, String menuCode, List<Long> userDeptIds);
|
||||
|
||||
/**
|
||||
* 合并用户全部角色菜单配置为最宽数据权限
|
||||
*
|
||||
|
||||
+8
@@ -257,6 +257,14 @@ public interface IUserService extends BaseService<User> {
|
||||
*/
|
||||
boolean removeUser(String userIds);
|
||||
|
||||
/**
|
||||
* 解析用户「数据管理权限」组织(含下级展开)
|
||||
*
|
||||
* @param userId 用户主键
|
||||
* @return 可管理组织 ID 集合;无配置时返回空列表
|
||||
*/
|
||||
List<Long> resolveManageDeptIds(Long userId);
|
||||
|
||||
/**
|
||||
* 导入用户数据(仅超管 + Excel 显式指定时允许跨租户,否则归属当前会话租户)
|
||||
*
|
||||
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 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.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import org.springblade.core.cache.utils.CacheUtil;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.mp.base.BaseServiceImpl;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.mapper.BusinessDeptConfigMapper;
|
||||
import org.springblade.system.pojo.entity.BusinessDeptConfig;
|
||||
import org.springblade.system.pojo.entity.Dept;
|
||||
import org.springblade.system.pojo.vo.BusinessDeptConfigVO;
|
||||
import org.springblade.system.service.IBusinessDeptConfigService;
|
||||
import org.springblade.system.service.IDeptService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.springblade.core.cache.constant.CacheConstant.SYS_CACHE;
|
||||
|
||||
/**
|
||||
* 业务部门配置服务实现类
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class BusinessDeptConfigServiceImpl extends BaseServiceImpl<BusinessDeptConfigMapper, BusinessDeptConfig>
|
||||
implements IBusinessDeptConfigService {
|
||||
|
||||
private static final int PREFIX_MAX_LENGTH = 64;
|
||||
private static final int CODE_MAX_LENGTH = 64;
|
||||
private static final int NAME_MAX_LENGTH = 100;
|
||||
private static final int REMARK_MAX_LENGTH = 200;
|
||||
private static final int SERIAL_MAX = 999;
|
||||
|
||||
private final IDeptService deptService;
|
||||
|
||||
@Override
|
||||
public IPage<BusinessDeptConfigVO> selectBusinessDeptConfigPage(IPage<BusinessDeptConfigVO> page,
|
||||
BusinessDeptConfigVO businessDeptConfig) {
|
||||
return page.setRecords(baseMapper.selectBusinessDeptConfigPage(page, businessDeptConfig));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean submit(BusinessDeptConfig businessDeptConfig) {
|
||||
prepare(businessDeptConfig);
|
||||
validate(businessDeptConfig);
|
||||
boolean success = saveOrUpdate(businessDeptConfig);
|
||||
if (success) {
|
||||
CacheUtil.clear(SYS_CACHE);
|
||||
CacheUtil.clear(SYS_CACHE, Boolean.FALSE);
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BusinessDeptConfig getByPlatformAndDept(Long platformCompanyId, Long businessDeptId) {
|
||||
if (Func.isEmpty(platformCompanyId) || Func.isEmpty(businessDeptId)) {
|
||||
return null;
|
||||
}
|
||||
return getOne(Wrappers.<BusinessDeptConfig>lambdaQuery()
|
||||
.eq(BusinessDeptConfig::getPlatformCompanyId, platformCompanyId)
|
||||
.eq(BusinessDeptConfig::getBusinessDeptId, businessDeptId)
|
||||
.eq(BusinessDeptConfig::getIsDeleted, 0)
|
||||
.last("LIMIT 1"), false);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean deleteLogic(java.util.List<Long> ids) {
|
||||
boolean success = super.deleteLogic(ids);
|
||||
if (success) {
|
||||
CacheUtil.clear(SYS_CACHE);
|
||||
CacheUtil.clear(SYS_CACHE, Boolean.FALSE);
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
private void prepare(BusinessDeptConfig businessDeptConfig) {
|
||||
Dept platformCompany = requirePlatformCompany(businessDeptConfig.getPlatformCompanyId());
|
||||
Dept businessDept = requireBusinessDept(businessDeptConfig.getBusinessDeptId());
|
||||
businessDeptConfig.setPlatformCompanyName(platformCompany.getDeptName());
|
||||
businessDeptConfig.setBusinessDeptName(businessDept.getDeptName());
|
||||
businessDeptConfig.setContractCodePrefix(trimToEmpty(businessDeptConfig.getContractCodePrefix()));
|
||||
businessDeptConfig.setKingdeeDeptCode(trimToNull(businessDeptConfig.getKingdeeDeptCode()));
|
||||
businessDeptConfig.setRemark(trimToNull(businessDeptConfig.getRemark()));
|
||||
// 金蝶公司代码始终关联平台公司组织中配置的金蝶代码
|
||||
businessDeptConfig.setKingdeeCompanyCode(trimToNull(platformCompany.getKingdeeDeptCode()));
|
||||
if (Func.isEmpty(businessDeptConfig.getStatus())) {
|
||||
businessDeptConfig.setStatus(1);
|
||||
}
|
||||
}
|
||||
|
||||
private void validate(BusinessDeptConfig businessDeptConfig) {
|
||||
if (Func.isEmpty(businessDeptConfig.getPlatformCompanyId())) {
|
||||
throw new ServiceException("请选择平台公司");
|
||||
}
|
||||
if (Func.isEmpty(businessDeptConfig.getBusinessDeptId())) {
|
||||
throw new ServiceException("请选择业务部门");
|
||||
}
|
||||
if (Func.isEmpty(businessDeptConfig.getContractCodePrefix())) {
|
||||
throw new ServiceException("请输入合同编码前缀");
|
||||
}
|
||||
if (businessDeptConfig.getContractCodePrefix().length() > PREFIX_MAX_LENGTH) {
|
||||
throw new ServiceException("合同编码前缀不能超过64字");
|
||||
}
|
||||
if (Func.isNotEmpty(businessDeptConfig.getPlatformCompanyName())
|
||||
&& businessDeptConfig.getPlatformCompanyName().length() > NAME_MAX_LENGTH) {
|
||||
throw new ServiceException("平台公司名称不能超过100字");
|
||||
}
|
||||
if (Func.isNotEmpty(businessDeptConfig.getBusinessDeptName())
|
||||
&& businessDeptConfig.getBusinessDeptName().length() > NAME_MAX_LENGTH) {
|
||||
throw new ServiceException("业务部门名称不能超过100字");
|
||||
}
|
||||
if (Func.isNotEmpty(businessDeptConfig.getKingdeeCompanyCode())
|
||||
&& businessDeptConfig.getKingdeeCompanyCode().length() > CODE_MAX_LENGTH) {
|
||||
throw new ServiceException("金蝶公司代码不能超过64字");
|
||||
}
|
||||
if (Func.isNotEmpty(businessDeptConfig.getKingdeeDeptCode())
|
||||
&& businessDeptConfig.getKingdeeDeptCode().length() > CODE_MAX_LENGTH) {
|
||||
throw new ServiceException("金蝶部门代码不能超过64字");
|
||||
}
|
||||
if (Func.isNotEmpty(businessDeptConfig.getRemark())
|
||||
&& businessDeptConfig.getRemark().length() > REMARK_MAX_LENGTH) {
|
||||
throw new ServiceException("备注不能超过200字");
|
||||
}
|
||||
Integer initialSerial = businessDeptConfig.getInitialSerial();
|
||||
if (initialSerial != null && (initialSerial < 1 || initialSerial > SERIAL_MAX)) {
|
||||
throw new ServiceException("初始流水号必须在1到999之间");
|
||||
}
|
||||
boolean duplicated = count(Wrappers.<BusinessDeptConfig>lambdaQuery()
|
||||
.eq(BusinessDeptConfig::getPlatformCompanyId, businessDeptConfig.getPlatformCompanyId())
|
||||
.eq(BusinessDeptConfig::getBusinessDeptId, businessDeptConfig.getBusinessDeptId())
|
||||
.eq(BusinessDeptConfig::getIsDeleted, 0)
|
||||
.ne(Func.isNotEmpty(businessDeptConfig.getId()), BusinessDeptConfig::getId, businessDeptConfig.getId())) > 0;
|
||||
if (duplicated) {
|
||||
throw new ServiceException("该平台公司与业务部门的配置已存在,不能重复添加");
|
||||
}
|
||||
}
|
||||
|
||||
private Dept requirePlatformCompany(Long platformCompanyId) {
|
||||
if (Func.isEmpty(platformCompanyId)) {
|
||||
throw new ServiceException("请选择平台公司");
|
||||
}
|
||||
Dept dept = deptService.getById(platformCompanyId);
|
||||
if (dept == null || Objects.equals(dept.getIsDeleted(), 1)) {
|
||||
throw new ServiceException("平台公司不存在");
|
||||
}
|
||||
if (!Objects.equals(dept.getIsPlatformCompany(), 1)) {
|
||||
throw new ServiceException("所选组织不是平台公司");
|
||||
}
|
||||
return dept;
|
||||
}
|
||||
|
||||
private Dept requireBusinessDept(Long businessDeptId) {
|
||||
if (Func.isEmpty(businessDeptId)) {
|
||||
throw new ServiceException("请选择业务部门");
|
||||
}
|
||||
Dept dept = deptService.getById(businessDeptId);
|
||||
if (dept == null || Objects.equals(dept.getIsDeleted(), 1)) {
|
||||
throw new ServiceException("业务部门不存在");
|
||||
}
|
||||
if (!Objects.equals(dept.getIsBusinessDept(), 1)) {
|
||||
throw new ServiceException("所选组织不是业务部门");
|
||||
}
|
||||
return dept;
|
||||
}
|
||||
|
||||
private String trimToEmpty(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
String text = trimToEmpty(value);
|
||||
return text.isEmpty() ? null : text;
|
||||
}
|
||||
|
||||
}
|
||||
+35
-35
@@ -224,13 +224,18 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 组织树专用数据权限菜单编码(角色「数据权限」中单独配置;未配置时组织树默认不展示)。
|
||||
*/
|
||||
public static final String MENU_DEPT_TREE = "dept_tree";
|
||||
|
||||
/**
|
||||
* 解析当前登录用户的组织树数据权限。
|
||||
* <p>
|
||||
* 超管或角色菜单数据权限最宽合并为「全部组织」时不过滤;
|
||||
* 其余按角色配置的组织并集过滤。仅「本人创建」时按用户所属组织及下级展示组织树。
|
||||
* 仅读取 {@link #MENU_DEPT_TREE}「树形结构」菜单的数据权限配置,不再合并其他业务菜单;
|
||||
* 超管不过滤;未配置该菜单数据权限时返回空树(默认不显示)。
|
||||
*
|
||||
* @return 过滤上下文;无法识别用户时按空权限处理
|
||||
* @return 过滤上下文;无法识别用户或未配置时按空权限处理
|
||||
*/
|
||||
private DeptDataScopeFilter resolveDeptDataScopeFilter() {
|
||||
if (AuthUtil.isAdministrator()) {
|
||||
@@ -246,8 +251,11 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
if (Func.isNotEmpty(user) && Func.isNotEmpty(user.getDeptId())) {
|
||||
userDeptIds = Func.toLongList(user.getDeptId());
|
||||
}
|
||||
RoleDataScopeVO scope = roleMenuDataScopeService.resolveWidest(roleIds, userDeptIds);
|
||||
if (scope == null || scope.isAllDept()) {
|
||||
RoleDataScopeVO scope = roleMenuDataScopeService.resolveByMenuCodeOrEmpty(roleIds, MENU_DEPT_TREE, userDeptIds);
|
||||
if (scope == null) {
|
||||
return DeptDataScopeFilter.empty();
|
||||
}
|
||||
if (scope.isAllDept()) {
|
||||
return DeptDataScopeFilter.all();
|
||||
}
|
||||
LinkedHashSet<Long> allowedDeptIds = new LinkedHashSet<>();
|
||||
@@ -665,12 +673,9 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
dept.setAncestors(parent.getAncestors() + StringPool.COMMA + dept.getParentId());
|
||||
}
|
||||
dept.setIsDeleted(BladeConstant.DB_NOT_DELETED);
|
||||
if (dept.getIsPlatformCompany() == null) {
|
||||
dept.setIsPlatformCompany(0);
|
||||
}
|
||||
if (dept.getIsBusinessDept() == null) {
|
||||
dept.setIsBusinessDept(0);
|
||||
}
|
||||
// 统一规范为 0/1,避免前端传空串、布尔或其它类型导致更新未生效
|
||||
dept.setIsPlatformCompany(normalizeYesNoFlag(dept.getIsPlatformCompany()));
|
||||
dept.setIsBusinessDept(normalizeYesNoFlag(dept.getIsBusinessDept()));
|
||||
// 排序未填时默认 1
|
||||
if (dept.getSort() == null) {
|
||||
dept.setSort(1);
|
||||
@@ -679,9 +684,24 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
throw new ServiceException("租户ID不能为空");
|
||||
}
|
||||
validateDeptCategory(dept);
|
||||
validateDeptCode(dept, parent);
|
||||
validateDeptCode(dept);
|
||||
validateContractInitialSerial(dept);
|
||||
return saveOrUpdate(dept);
|
||||
boolean saved = saveOrUpdate(dept);
|
||||
if (saved && Func.isNotEmpty(dept.getId())) {
|
||||
// 显式回写两个业务标志,避免部分更新路径漏改
|
||||
this.update(Wrappers.<Dept>lambdaUpdate()
|
||||
.set(Dept::getIsPlatformCompany, dept.getIsPlatformCompany())
|
||||
.set(Dept::getIsBusinessDept, dept.getIsBusinessDept())
|
||||
.eq(Dept::getId, dept.getId()));
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将是否类标志规范为 0/1;空值按 0 处理。
|
||||
*/
|
||||
private Integer normalizeYesNoFlag(Integer flag) {
|
||||
return Integer.valueOf(1).equals(flag) ? 1 : 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -881,9 +901,9 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
}
|
||||
}
|
||||
|
||||
private void validateDeptCode(Dept dept, Dept parent) {
|
||||
private void validateDeptCode(Dept dept) {
|
||||
String deptCode = dept.getDeptCode();
|
||||
// 组织编码非必填,为空时存 null,避免唯一索引冲突
|
||||
// 组织编码非必填,为空时存 null,避免唯一索引冲突;不再校验 GWWL/上级编码+三位数字格式
|
||||
if (StringUtil.isBlank(deptCode)) {
|
||||
dept.setDeptCode(null);
|
||||
return;
|
||||
@@ -892,26 +912,6 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
if (deptCode.length() > 30) {
|
||||
throw new ServiceException("组织编码不能超过30个字符");
|
||||
}
|
||||
// 顶级组织不依赖上级编码
|
||||
if (parent == null) {
|
||||
this.ensureDeptCodeUnique(dept, deptCode);
|
||||
dept.setDeptCode(deptCode);
|
||||
return;
|
||||
}
|
||||
if (StringUtil.isBlank(parent.getDeptCode())) {
|
||||
throw new ServiceException("请选择已配置组织编码的上级组织");
|
||||
}
|
||||
// 公司:GWWL+三位数字;部门:所属公司编码+三位数字;其他:上级编码+三位数字
|
||||
String expectedParentCode = parent.getDeptCode();
|
||||
if (DeptCategory.COMPANY.getCode().equals(dept.getDeptCategory())) {
|
||||
expectedParentCode = OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE;
|
||||
} else if (DeptCategory.DEPT.getCode().equals(dept.getDeptCategory())
|
||||
&& StringUtil.isNotBlank(dept.getBelongCompanyCode())) {
|
||||
expectedParentCode = dept.getBelongCompanyCode();
|
||||
}
|
||||
if (!org.springblade.system.util.DeptCodeUtil.matchesChildCode(expectedParentCode, deptCode)) {
|
||||
throw new ServiceException("组织编码格式应为:" + expectedParentCode + "三位数字(不足补零,中间不加-)");
|
||||
}
|
||||
this.ensureDeptCodeUnique(dept, deptCode);
|
||||
dept.setDeptCode(deptCode);
|
||||
}
|
||||
|
||||
+2
-4
@@ -278,9 +278,7 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
OaUserListSyncHelper.OaOrgIndex orgIndex = oaUserListSyncHelper.syncOrgsFromPersons(oaPersons);
|
||||
OaUserListSyncHelper.PersonSyncCount personSyncCount = oaUserListSyncHelper.handlePerson(oaPersons, orgIndex);
|
||||
if (startTime == null) {
|
||||
oaUserListSyncHelper.removeOaUsersAbsentFrom(
|
||||
oaUserListSyncHelper.extractOaLoginIds(oaPersons),
|
||||
oaUserListSyncHelper.extractWorkcodes(oaPersons));
|
||||
oaUserListSyncHelper.removeOaUsersAbsentFrom(oaUserListSyncHelper.extractWorkcodes(oaPersons));
|
||||
}
|
||||
CacheUtil.clear(USER_CACHE);
|
||||
CacheUtil.clear(SYS_CACHE);
|
||||
@@ -300,7 +298,7 @@ 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);
|
||||
// 全量同步首页开启原始数据收集,并开始累积登录名,最后一页再删除本地多余人员
|
||||
// 全量同步首页开启原始数据收集,并开始累积工号,最后一页再删除本地多余人员
|
||||
if (!incremental && pageNo == 1) {
|
||||
oaSyncRawDumpHelper.beginFullDump();
|
||||
oaUserListSyncHelper.beginFullSyncReconcile();
|
||||
|
||||
+51
-169
@@ -87,10 +87,8 @@ public class OaUserListSyncHelper {
|
||||
private static final String OA_PERSON_STATUS_RESIGNED = "5";
|
||||
|
||||
/**
|
||||
* 全量同步跨页累积的 OA 登录名。分页接口每页独立事务,删除必须等最后一页再执行。
|
||||
* 全量同步跨页累积的 OA 工号。分页接口每页独立事务,删除必须等最后一页再执行。
|
||||
*/
|
||||
private final Set<String> fullSyncLoginIds = ConcurrentHashMap.newKeySet();
|
||||
|
||||
private final Set<String> fullSyncWorkcodes = ConcurrentHashMap.newKeySet();
|
||||
|
||||
private volatile boolean fullSyncCollecting;
|
||||
@@ -409,16 +407,15 @@ public class OaUserListSyncHelper {
|
||||
}
|
||||
|
||||
/**
|
||||
* 全量同步从第一页开始收集 OA 登录名,最后一页再删除本地多余的 is_oa=2 人员。
|
||||
* 全量同步从第一页开始收集 OA 工号,最后一页再删除本地多余的 is_oa=2 人员。
|
||||
*/
|
||||
public synchronized void beginFullSyncReconcile() {
|
||||
this.fullSyncLoginIds.clear();
|
||||
this.fullSyncWorkcodes.clear();
|
||||
this.fullSyncCollecting = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 累积本页 OA 人员登录名,供全量同步结束后比对删除。
|
||||
* 累积本页 OA 人员工号,供全量同步结束后比对删除。
|
||||
*
|
||||
* @param oaPersons 本页 OA 人员
|
||||
*/
|
||||
@@ -426,49 +423,26 @@ public class OaUserListSyncHelper {
|
||||
if (!this.fullSyncCollecting || CollectionUtil.isEmpty(oaPersons)) {
|
||||
return;
|
||||
}
|
||||
this.fullSyncLoginIds.addAll(this.extractOaLoginIds(oaPersons));
|
||||
this.fullSyncWorkcodes.addAll(this.extractWorkcodes(oaPersons));
|
||||
}
|
||||
|
||||
/**
|
||||
* 全量同步结束后,删除登录名不在本次 OA 数据中的 is_oa=2 人员。
|
||||
* 全量同步结束后,删除工号不在本次 OA 数据中的 is_oa=2 人员。
|
||||
*
|
||||
* @return 删除人数;未开始收集或登录名集合为空时返回 0,避免误删全部 OA 人员
|
||||
* @return 删除人数;未开始收集或工号集合为空时返回 0,避免误删全部 OA 人员
|
||||
*/
|
||||
public synchronized int finishFullSyncReconcile() {
|
||||
if (!this.fullSyncCollecting) {
|
||||
return 0;
|
||||
}
|
||||
this.fullSyncCollecting = false;
|
||||
Set<String> oaLoginIds = new LinkedHashSet<>(this.fullSyncLoginIds);
|
||||
Set<String> workcodes = new LinkedHashSet<>(this.fullSyncWorkcodes);
|
||||
this.fullSyncLoginIds.clear();
|
||||
this.fullSyncWorkcodes.clear();
|
||||
return this.removeOaUsersAbsentFrom(oaLoginIds, workcodes);
|
||||
return this.removeOaUsersAbsentFrom(workcodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取仍在职的 OA 人员登录名。离职人员不计入,全量同步结束时会删掉对应本地账号。
|
||||
*
|
||||
* @param oaPersons OA人员
|
||||
* @return 去重后的登录名
|
||||
*/
|
||||
public Set<String> extractOaLoginIds(List<OAPersonResponse> oaPersons) {
|
||||
Set<String> oaLoginIds = new LinkedHashSet<>();
|
||||
if (CollectionUtil.isEmpty(oaPersons)) {
|
||||
return oaLoginIds;
|
||||
}
|
||||
for (OAPersonResponse oaPerson : oaPersons) {
|
||||
String oaLoginId = this.resolveOaLoginId(oaPerson);
|
||||
if (oaLoginId != null && !this.isOaResigned(oaPerson)) {
|
||||
oaLoginIds.add(oaLoginId);
|
||||
}
|
||||
}
|
||||
return oaLoginIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取仍在职人员的工号。没有登录名时靠工号保留本地账号。
|
||||
* 提取仍在职人员的工号。离职人员不计入,全量同步结束时会删掉对应本地账号。
|
||||
*
|
||||
* @param oaPersons OA人员
|
||||
* @return 去重后的工号
|
||||
@@ -488,25 +462,21 @@ public class OaUserListSyncHelper {
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除当前租户中 is_oa=2、且既不在本次登录名里、也不是无登录名工号人员的账号。
|
||||
* 登录名和工号都没收集到时不删除。当前登录账号即使不在同步数据中也保留。
|
||||
* 删除当前租户中 is_oa=2、且工号不在本次同步工号集合中的账号。
|
||||
* 未收集到工号时不删除。当前登录账号即使不在同步数据中也保留。
|
||||
*
|
||||
* @param oaLoginIds 本次全量同步出现过的 OA 登录名
|
||||
* @param workcodes 本次全量同步出现过的工号
|
||||
* @return 删除人数
|
||||
*/
|
||||
public int removeOaUsersAbsentFrom(Set<String> oaLoginIds, Set<String> workcodes) {
|
||||
boolean noLoginIds = CollectionUtil.isEmpty(oaLoginIds);
|
||||
boolean noWorkcodes = CollectionUtil.isEmpty(workcodes);
|
||||
if (noLoginIds && noWorkcodes) {
|
||||
ComposeLogUtil.getLastLog().warn("全量同步未收集到OA登录名和工号,跳过删除本地 is_oa=2 人员");
|
||||
public int removeOaUsersAbsentFrom(Set<String> workcodes) {
|
||||
if (CollectionUtil.isEmpty(workcodes)) {
|
||||
ComposeLogUtil.getLastLog().warn("全量同步未收集到OA工号,跳过删除本地 is_oa=2 人员");
|
||||
return 0;
|
||||
}
|
||||
Set<String> loginIdSet = noLoginIds ? Set.of() : oaLoginIds;
|
||||
Set<String> workcodeSet = noWorkcodes ? Set.of() : workcodes;
|
||||
Set<String> workcodeSet = workcodes;
|
||||
String tenantId = this.resolveTenantId();
|
||||
List<User> oaUsers = this.userService.list(Wrappers.<User>lambdaQuery()
|
||||
.select(User::getId, User::getOaLoginId, User::getCode)
|
||||
.select(User::getId, User::getCode)
|
||||
.eq(User::getTenantId, tenantId)
|
||||
.eq(User::getIsOa, CommonConstant.YES));
|
||||
Long currentUserId = AuthUtil.getUserId();
|
||||
@@ -519,11 +489,8 @@ public class OaUserListSyncHelper {
|
||||
ComposeLogUtil.getLastLog().warn("全量同步跳过删除当前登录账号 userId={}", oaUser.getId());
|
||||
continue;
|
||||
}
|
||||
String oaLoginId = StringUtils.trimToNull(oaUser.getOaLoginId());
|
||||
String workcode = StringUtils.trimToNull(oaUser.getCode());
|
||||
boolean keptByLoginId = oaLoginId != null && loginIdSet.contains(oaLoginId);
|
||||
boolean keptByWorkcode = oaLoginId == null && workcode != null && workcodeSet.contains(workcode);
|
||||
if (!keptByLoginId && !keptByWorkcode) {
|
||||
if (workcode == null || !workcodeSet.contains(workcode)) {
|
||||
removeIds.add(oaUser.getId());
|
||||
}
|
||||
}
|
||||
@@ -535,54 +502,13 @@ public class OaUserListSyncHelper {
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 OA 登录名删除已有 is_oa=2 人员。用于离职人员,全量和增量同步都会执行。
|
||||
*
|
||||
* @param tenantId 租户
|
||||
* @param oaLoginIds 离职人员登录名
|
||||
* @return 删除人数
|
||||
*/
|
||||
private int removeOaUsersByLoginIds(String tenantId, Collection<String> oaLoginIds) {
|
||||
if (CollectionUtil.isEmpty(oaLoginIds)) {
|
||||
return 0;
|
||||
}
|
||||
List<String> oaLoginIdList = oaLoginIds.stream()
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (oaLoginIdList.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
Long currentUserId = AuthUtil.getUserId();
|
||||
List<Long> removeIds = new ArrayList<>();
|
||||
for (int fromIndex = 0; fromIndex < oaLoginIdList.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) {
|
||||
List<String> oaLoginIdBatch = oaLoginIdList.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, oaLoginIdList.size()));
|
||||
this.userService.list(Wrappers.<User>lambdaQuery()
|
||||
.select(User::getId, User::getOaLoginId)
|
||||
.eq(User::getTenantId, tenantId)
|
||||
.eq(User::getIsOa, CommonConstant.YES)
|
||||
.in(User::getOaLoginId, oaLoginIdBatch))
|
||||
.forEach(oaUser -> {
|
||||
if (oaUser.getId() == null) {
|
||||
return;
|
||||
}
|
||||
if (currentUserId != null && currentUserId > 0 && currentUserId.equals(oaUser.getId())) {
|
||||
ComposeLogUtil.getLastLog().warn("同步跳过删除当前登录账号 userId={}", oaUser.getId());
|
||||
return;
|
||||
}
|
||||
removeIds.add(oaUser.getId());
|
||||
});
|
||||
}
|
||||
return this.deleteOaUsers(removeIds, "同步删除OA离职人员");
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除没有登录名、且工号属于离职人员的 is_oa=2 账号。
|
||||
* 按工号删除已有 is_oa=2 人员。用于离职人员,全量和增量同步都会执行。
|
||||
*
|
||||
* @param tenantId 租户
|
||||
* @param workcodes 离职人员工号
|
||||
* @return 删除人数
|
||||
*/
|
||||
private int removeOaUsersByBlankLoginWorkcodes(String tenantId, Collection<String> workcodes) {
|
||||
private int removeOaUsersByWorkcodes(String tenantId, Collection<String> workcodes) {
|
||||
if (CollectionUtil.isEmpty(workcodes)) {
|
||||
return 0;
|
||||
}
|
||||
@@ -598,11 +524,10 @@ public class OaUserListSyncHelper {
|
||||
for (int fromIndex = 0; fromIndex < workcodeList.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) {
|
||||
List<String> workcodeBatch = workcodeList.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, workcodeList.size()));
|
||||
this.userService.list(Wrappers.<User>lambdaQuery()
|
||||
.select(User::getId, User::getOaLoginId, User::getCode)
|
||||
.select(User::getId, User::getCode)
|
||||
.eq(User::getTenantId, tenantId)
|
||||
.eq(User::getIsOa, CommonConstant.YES)
|
||||
.in(User::getCode, workcodeBatch)
|
||||
.and(wrapper -> wrapper.isNull(User::getOaLoginId).or().eq(User::getOaLoginId, "")))
|
||||
.in(User::getCode, workcodeBatch))
|
||||
.forEach(oaUser -> {
|
||||
if (oaUser.getId() == null) {
|
||||
return;
|
||||
@@ -614,7 +539,7 @@ public class OaUserListSyncHelper {
|
||||
removeIds.add(oaUser.getId());
|
||||
});
|
||||
}
|
||||
return this.deleteOaUsers(removeIds, "同步删除无登录名的OA离职人员");
|
||||
return this.deleteOaUsers(removeIds, "同步删除OA离职人员");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -645,7 +570,8 @@ public class OaUserListSyncHelper {
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步人员并绑定到三级部门。有登录名按登录名更新,没有登录名但有工号则按工号更新且不回填登录名。离职人员直接删除。
|
||||
* 同步人员并绑定到三级部门。按工号 workcode(对应本地 code)匹配已有用户;
|
||||
* OA登录名仍优先填 workcode,其次 loginid。无工号则跳过。离职人员按工号删除。
|
||||
*
|
||||
* @param oaPersons OA人员
|
||||
* @param orgIndex 组织索引
|
||||
@@ -656,7 +582,6 @@ public class OaUserListSyncHelper {
|
||||
return new PersonSyncCount(0, 0, 0);
|
||||
}
|
||||
Map<String, OAPersonResponse> uniquePersonMap = new LinkedHashMap<>();
|
||||
Set<String> resignedLoginIds = new LinkedHashSet<>();
|
||||
Set<String> resignedWorkcodes = new LinkedHashSet<>();
|
||||
int skippedCount = 0;
|
||||
for (OAPersonResponse oaPerson : oaPersons) {
|
||||
@@ -665,49 +590,36 @@ public class OaUserListSyncHelper {
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
String oaLoginId = this.resolveOaLoginId(oaPerson);
|
||||
String workcode = this.resolveWorkcode(oaPerson);
|
||||
if (this.isOaResigned(oaPerson)) {
|
||||
uniquePersonMap.remove(syncKey);
|
||||
if (oaLoginId != null) {
|
||||
resignedLoginIds.add(oaLoginId);
|
||||
} else if (workcode != null) {
|
||||
if (workcode != null) {
|
||||
resignedWorkcodes.add(workcode);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (oaLoginId != null) {
|
||||
resignedLoginIds.remove(oaLoginId);
|
||||
} else if (workcode != null) {
|
||||
if (workcode != null) {
|
||||
resignedWorkcodes.remove(workcode);
|
||||
}
|
||||
uniquePersonMap.put(syncKey, oaPerson);
|
||||
}
|
||||
String tenantId = resolveTenantId();
|
||||
int removedCount = this.removeOaUsersByLoginIds(tenantId, resignedLoginIds)
|
||||
+ this.removeOaUsersByBlankLoginWorkcodes(tenantId, resignedWorkcodes);
|
||||
int removedCount = this.removeOaUsersByWorkcodes(tenantId, resignedWorkcodes);
|
||||
if (uniquePersonMap.isEmpty()) {
|
||||
if (resignedLoginIds.isEmpty() && resignedWorkcodes.isEmpty()) {
|
||||
ComposeLogUtil.getLastLog().warn("OA人员均缺少登录名和工号,跳过人员同步");
|
||||
if (resignedWorkcodes.isEmpty()) {
|
||||
ComposeLogUtil.getLastLog().warn("OA人员均缺少工号,跳过人员同步");
|
||||
}
|
||||
return new PersonSyncCount(0, skippedCount, removedCount);
|
||||
}
|
||||
String defaultPassword = DigestUtil.encrypt(ParamCache.getValue(DEFAULT_PARAM_PASSWORD));
|
||||
Set<String> oaLoginIds = new LinkedHashSet<>();
|
||||
Set<String> workcodes = new LinkedHashSet<>();
|
||||
for (OAPersonResponse oaPerson : uniquePersonMap.values()) {
|
||||
String oaLoginId = this.resolveOaLoginId(oaPerson);
|
||||
if (oaLoginId != null) {
|
||||
oaLoginIds.add(oaLoginId);
|
||||
continue;
|
||||
}
|
||||
String workcode = this.resolveWorkcode(oaPerson);
|
||||
if (workcode != null) {
|
||||
workcodes.add(workcode);
|
||||
}
|
||||
}
|
||||
Map<String, User> existingByOaLoginId = this.loadUsersByOaLoginId(tenantId, oaLoginIds);
|
||||
Map<String, User> existingByWorkcode = this.loadUsersByBlankLoginWorkcode(tenantId, workcodes);
|
||||
Map<String, User> existingByWorkcode = this.loadUsersByWorkcode(tenantId, workcodes);
|
||||
List<User> users = new ArrayList<>();
|
||||
Map<String, Long> userMap = new HashMap<>();
|
||||
Set<Long> existsUserIds = new HashSet<>();
|
||||
@@ -717,7 +629,7 @@ public class OaUserListSyncHelper {
|
||||
String workcode = this.resolveWorkcode(oaPerson);
|
||||
User user = this.userConvert.person2user(oaPerson, defaultPassword);
|
||||
user.setTenantId(tenantId);
|
||||
User existingUser = oaLoginId != null ? existingByOaLoginId.get(oaLoginId) : existingByWorkcode.get(workcode);
|
||||
User existingUser = workcode == null ? null : existingByWorkcode.get(workcode);
|
||||
if (oaLoginId != null) {
|
||||
user.setOaLoginId(oaLoginId);
|
||||
user.setAccount(oaLoginId);
|
||||
@@ -757,7 +669,7 @@ public class OaUserListSyncHelper {
|
||||
ComposeLogUtil.getLastLog().info("批量修改用户:{}", updateUsers.size());
|
||||
userService.updateBatchById(updateUsers);
|
||||
}
|
||||
ComposeLogUtil.getLastLog().info("缺少登录名和工号已跳过的人员:{}", skippedCount);
|
||||
ComposeLogUtil.getLastLog().info("缺少工号已跳过的人员:{}", skippedCount);
|
||||
int syncedCount = addUsers.size() + updateUsers.size();
|
||||
if (userMap.isEmpty()) {
|
||||
return new PersonSyncCount(0, skippedCount, removedCount);
|
||||
@@ -882,50 +794,9 @@ public class OaUserListSyncHelper {
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 OA 登录名加载已有 is_oa=2 用户。同一登录名有多条时保留较早的一条,后续同步都更新这一条。
|
||||
* 按工号(User.code)加载已有 is_oa=2 用户。同一工号有多条时保留较早的一条,后续同步都更新这一条。
|
||||
*/
|
||||
private Map<String, User> loadUsersByOaLoginId(String tenantId, Collection<String> oaLoginIds) {
|
||||
Map<String, User> existingByOaLoginId = new HashMap<>();
|
||||
if (CollectionUtil.isEmpty(oaLoginIds)) {
|
||||
return existingByOaLoginId;
|
||||
}
|
||||
List<String> oaLoginIdList = oaLoginIds.stream()
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.distinct()
|
||||
.toList();
|
||||
for (int fromIndex = 0; fromIndex < oaLoginIdList.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) {
|
||||
List<String> oaLoginIdBatch = oaLoginIdList.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, oaLoginIdList.size()));
|
||||
userService.list(Wrappers.<User>lambdaQuery()
|
||||
.eq(User::getTenantId, tenantId)
|
||||
.eq(User::getIsOa, CommonConstant.YES)
|
||||
.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 existingByOaLoginId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按工号加载没有登录名的 is_oa=2 用户。同一工号有多条时保留较早的一条。
|
||||
*/
|
||||
private Map<String, User> loadUsersByBlankLoginWorkcode(String tenantId, Collection<String> workcodes) {
|
||||
private Map<String, User> loadUsersByWorkcode(String tenantId, Collection<String> workcodes) {
|
||||
Map<String, User> existingByWorkcode = new HashMap<>();
|
||||
if (CollectionUtil.isEmpty(workcodes)) {
|
||||
return existingByWorkcode;
|
||||
@@ -939,26 +810,41 @@ public class OaUserListSyncHelper {
|
||||
this.userService.list(Wrappers.<User>lambdaQuery()
|
||||
.eq(User::getTenantId, tenantId)
|
||||
.eq(User::getIsOa, CommonConstant.YES)
|
||||
.in(User::getCode, workcodeBatch)
|
||||
.and(wrapper -> wrapper.isNull(User::getOaLoginId).or().eq(User::getOaLoginId, "")))
|
||||
.in(User::getCode, workcodeBatch))
|
||||
.forEach(user -> {
|
||||
String workcode = StringUtils.trimToNull(user.getCode());
|
||||
if (workcode == null) {
|
||||
return;
|
||||
}
|
||||
User previous = existingByWorkcode.get(workcode);
|
||||
if (previous == null || (user.getId() != null && previous.getId() != null && user.getId() < previous.getId())) {
|
||||
if (previous == null) {
|
||||
existingByWorkcode.put(workcode, user);
|
||||
return;
|
||||
}
|
||||
if (user.getId() != null && previous.getId() != null && user.getId() < previous.getId()) {
|
||||
ComposeLogUtil.getLastLog().warn("OA工号重复,保留较早用户 workcode={} keepUserId={} dropUserId={}",
|
||||
workcode, user.getId(), previous.getId());
|
||||
existingByWorkcode.put(workcode, user);
|
||||
return;
|
||||
}
|
||||
ComposeLogUtil.getLastLog().warn("OA工号重复,保留较早用户 workcode={} keepUserId={} dropUserId={}",
|
||||
workcode, previous.getId(), user.getId());
|
||||
});
|
||||
}
|
||||
return existingByWorkcode;
|
||||
}
|
||||
|
||||
/**
|
||||
* OA登录名:优先 workcode,其次 loginid。仅用于填充 oaLoginId/account,不参与匹配。
|
||||
*/
|
||||
private String resolveOaLoginId(OAPersonResponse oaPerson) {
|
||||
if (oaPerson == null) {
|
||||
return null;
|
||||
}
|
||||
String workcode = StringUtils.trimToNull(oaPerson.getWorkcode());
|
||||
if (workcode != null) {
|
||||
return workcode;
|
||||
}
|
||||
return StringUtils.trimToNull(oaPerson.getLoginid());
|
||||
}
|
||||
|
||||
@@ -970,13 +856,9 @@ public class OaUserListSyncHelper {
|
||||
}
|
||||
|
||||
/**
|
||||
* 有登录名用登录名,没有登录名但有工号用工号。两者都没有则不同步。
|
||||
* 同步去重与匹配键:仅使用工号。无工号则不同步。
|
||||
*/
|
||||
private String resolveSyncKey(OAPersonResponse oaPerson) {
|
||||
String oaLoginId = this.resolveOaLoginId(oaPerson);
|
||||
if (oaLoginId != null) {
|
||||
return "L:" + oaLoginId;
|
||||
}
|
||||
String workcode = this.resolveWorkcode(oaPerson);
|
||||
if (workcode != null) {
|
||||
return "W:" + workcode;
|
||||
|
||||
+32
-8
@@ -184,20 +184,44 @@ public class RoleMenuDataScopeServiceImpl extends BaseServiceImpl<RoleMenuDataSc
|
||||
if (Func.isEmpty(roleIds) || Func.isBlank(menuCode)) {
|
||||
return RoleDataScopeVO.all();
|
||||
}
|
||||
Menu menu = menuMapper.selectOne(Wrappers.<Menu>lambdaQuery()
|
||||
.eq(Menu::getCode, menuCode)
|
||||
.eq(Menu::getIsDeleted, 0)
|
||||
.last("limit 1"));
|
||||
Menu menu = findMenuByCode(menuCode);
|
||||
if (Func.isEmpty(menu)) {
|
||||
return RoleDataScopeVO.all();
|
||||
}
|
||||
List<RoleMenuDataScope> scopeList = list(Wrappers.<RoleMenuDataScope>lambdaQuery()
|
||||
.in(RoleMenuDataScope::getRoleId, roleIds)
|
||||
.eq(RoleMenuDataScope::getMenuId, menu.getId())
|
||||
.eq(RoleMenuDataScope::getIsDeleted, 0));
|
||||
List<RoleMenuDataScope> scopeList = listScopes(roleIds, menu.getId());
|
||||
return mergeScopes(scopeList, userDeptIds);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RoleDataScopeVO resolveByMenuCodeOrEmpty(List<Long> roleIds, String menuCode, List<Long> userDeptIds) {
|
||||
if (Func.isEmpty(roleIds) || Func.isBlank(menuCode)) {
|
||||
return RoleDataScopeVO.none();
|
||||
}
|
||||
Menu menu = findMenuByCode(menuCode);
|
||||
if (Func.isEmpty(menu)) {
|
||||
return RoleDataScopeVO.none();
|
||||
}
|
||||
List<RoleMenuDataScope> scopeList = listScopes(roleIds, menu.getId());
|
||||
if (Func.isEmpty(scopeList)) {
|
||||
return RoleDataScopeVO.none();
|
||||
}
|
||||
return mergeScopes(scopeList, userDeptIds);
|
||||
}
|
||||
|
||||
private Menu findMenuByCode(String menuCode) {
|
||||
return menuMapper.selectOne(Wrappers.<Menu>lambdaQuery()
|
||||
.eq(Menu::getCode, menuCode)
|
||||
.eq(Menu::getIsDeleted, 0)
|
||||
.last("limit 1"));
|
||||
}
|
||||
|
||||
private List<RoleMenuDataScope> listScopes(List<Long> roleIds, Long menuId) {
|
||||
return list(Wrappers.<RoleMenuDataScope>lambdaQuery()
|
||||
.in(RoleMenuDataScope::getRoleId, roleIds)
|
||||
.eq(RoleMenuDataScope::getMenuId, menuId)
|
||||
.eq(RoleMenuDataScope::getIsDeleted, 0));
|
||||
}
|
||||
|
||||
@Override
|
||||
public RoleDataScopeVO resolveWidest(List<Long> roleIds, List<Long> userDeptIds) {
|
||||
if (Func.isEmpty(roleIds)) {
|
||||
|
||||
+69
-4
@@ -86,6 +86,7 @@ import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@@ -663,6 +664,7 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
|
||||
boolean tempUser = this.deleteLogic(Func.toLongList(userIds));
|
||||
boolean tempUserDept = userDeptService.remove(Wrappers.<UserDept>lambdaQuery().in(UserDept::getUserId, Func.toLongList(userIds)));
|
||||
if (tempUser && tempUserDept) {
|
||||
userDataScopeMapper.delete(Wrappers.<UserDataScope>lambdaQuery().in(UserDataScope::getUserId, idList));
|
||||
UserOauth userOauth = new UserOauth();
|
||||
userOauth.delete(Wrappers.<UserOauth>lambdaQuery().in(UserOauth::getUserId, Func.toLongList(userIds)));
|
||||
UserWeb userWeb = new UserWeb();
|
||||
@@ -672,6 +674,7 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
|
||||
UserOther userOther = new UserOther();
|
||||
userOther.delete(Wrappers.<UserOther>lambdaQuery().in(UserOther::getUserId, Func.toLongList(userIds)));
|
||||
CacheUtil.clear(USER_CACHE);
|
||||
CacheUtil.clear(USER_CACHE, Boolean.FALSE);
|
||||
return true;
|
||||
} else {
|
||||
throw new ServiceException("删除用户失败!");
|
||||
@@ -1119,8 +1122,10 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
|
||||
if (user.getPersonCategory() == null) {
|
||||
user.setPersonCategory(1);
|
||||
}
|
||||
// 组织/客商数据权限已迁移至角色菜单配置,用户侧字段仅保留兼容默认值,不再参与过滤。
|
||||
if (user.getDataScopeRange() == null) {
|
||||
// 有「数据管理权限」组织时标记为自定义;否则仅补兼容默认值(业务过滤以角色权限 ∪ 用户管理组织为准)
|
||||
if (Func.isNotEmpty(user.getDataScopeDeptIds())) {
|
||||
user.setDataScopeRange(3);
|
||||
} else if (user.getDataScopeRange() == null) {
|
||||
user.setDataScopeRange(2);
|
||||
}
|
||||
if (user.getDataLevelRange() == null) {
|
||||
@@ -1141,8 +1146,33 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存用户「数据管理权限」组织;dataScopeDeptIds 为 null 时保留原配置(兼容 IAM/同步等未传该字段的调用)。
|
||||
*/
|
||||
private boolean submitUserScopes(User user) {
|
||||
// 用户级组织/客商数据权限已停用,改由角色菜单数据权限配置。
|
||||
if (user == null || user.getId() == null) {
|
||||
return true;
|
||||
}
|
||||
if (user.getDataScopeDeptIds() == null) {
|
||||
return true;
|
||||
}
|
||||
userDataScopeMapper.delete(Wrappers.<UserDataScope>lambdaQuery().eq(UserDataScope::getUserId, user.getId()));
|
||||
List<Long> deptIdList = user.getDataScopeDeptIds().stream()
|
||||
.filter(Objects::nonNull)
|
||||
.filter(deptId -> deptId > 0)
|
||||
.distinct()
|
||||
.toList();
|
||||
for (Long deptId : deptIdList) {
|
||||
UserDataScope scope = new UserDataScope();
|
||||
scope.setUserId(user.getId());
|
||||
scope.setDeptId(deptId);
|
||||
userDataScopeMapper.insert(scope);
|
||||
}
|
||||
this.update(Wrappers.<User>lambdaUpdate()
|
||||
.set(User::getDataScopeRange, deptIdList.isEmpty() ? 2 : 3)
|
||||
.eq(User::getId, user.getId()));
|
||||
CacheUtil.clear(USER_CACHE);
|
||||
CacheUtil.clear(USER_CACHE, Boolean.FALSE);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1150,11 +1180,46 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
|
||||
if (user == null || user.getId() == null) {
|
||||
return user;
|
||||
}
|
||||
user.setDataScopeDeptIds(Collections.emptyList());
|
||||
List<Long> deptIdList = userDataScopeMapper.selectList(Wrappers.<UserDataScope>lambdaQuery()
|
||||
.eq(UserDataScope::getUserId, user.getId()))
|
||||
.stream()
|
||||
.map(UserDataScope::getDeptId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
user.setDataScopeDeptIds(deptIdList);
|
||||
user.setCustomerIds(Collections.emptyList());
|
||||
return user;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Long> resolveManageDeptIds(Long userId) {
|
||||
if (Func.isEmpty(userId) || userId <= 0) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<Long> baseDeptIds = userDataScopeMapper.selectList(Wrappers.<UserDataScope>lambdaQuery()
|
||||
.eq(UserDataScope::getUserId, userId))
|
||||
.stream()
|
||||
.map(UserDataScope::getDeptId)
|
||||
.filter(Objects::nonNull)
|
||||
.filter(deptId -> deptId > 0)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (Func.isEmpty(baseDeptIds)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
LinkedHashSet<Long> allowedDeptIds = new LinkedHashSet<>();
|
||||
for (Long deptId : baseDeptIds) {
|
||||
List<Long> childIds = SysCache.getDeptChildIds(deptId);
|
||||
if (Func.isNotEmpty(childIds)) {
|
||||
allowedDeptIds.addAll(childIds);
|
||||
} else {
|
||||
allowedDeptIds.add(deptId);
|
||||
}
|
||||
}
|
||||
return List.copyOf(allowedDeptIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的租户范围过滤:超管放行,其他用户强制限定为当前会话租户。
|
||||
*/
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 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.wrapper;
|
||||
|
||||
import org.springblade.core.mp.support.BaseEntityWrapper;
|
||||
import org.springblade.core.tool.utils.BeanUtil;
|
||||
import org.springblade.system.cache.UserCache;
|
||||
import org.springblade.system.pojo.entity.BusinessDeptConfig;
|
||||
import org.springblade.system.pojo.vo.BusinessDeptConfigVO;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 业务部门配置包装类
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public class BusinessDeptConfigWrapper extends BaseEntityWrapper<BusinessDeptConfig, BusinessDeptConfigVO> {
|
||||
|
||||
public static BusinessDeptConfigWrapper build() {
|
||||
return new BusinessDeptConfigWrapper();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BusinessDeptConfigVO entityVO(BusinessDeptConfig businessDeptConfig) {
|
||||
BusinessDeptConfigVO businessDeptConfigVO = Objects.requireNonNull(
|
||||
BeanUtil.copyProperties(businessDeptConfig, BusinessDeptConfigVO.class));
|
||||
businessDeptConfigVO.setCreateUserName(UserCache.getUserRealName(businessDeptConfig.getCreateUser()));
|
||||
businessDeptConfigVO.setUpdateUserName(UserCache.getUserRealName(businessDeptConfig.getUpdateUser()));
|
||||
return businessDeptConfigVO;
|
||||
}
|
||||
|
||||
}
|
||||
+34
-33
@@ -71,9 +71,9 @@ public class BfiProperties {
|
||||
private ReceivedInvoice receivedInvoice = new ReceivedInvoice();
|
||||
|
||||
/**
|
||||
* 收款认领公告查询配置(A301-2)
|
||||
* 收款流水同步配置(A301-1 收款单查询)
|
||||
*/
|
||||
private ClaimAnnounce claimAnnounce = new ClaimAnnounce();
|
||||
private RecBillSync recBillSync = new RecBillSync();
|
||||
|
||||
/**
|
||||
* 付款结果定时回写配置(A103-2/A103-4,总开关同时控制应收勾稽、开票结果回写)
|
||||
@@ -85,11 +85,6 @@ public class BfiProperties {
|
||||
*/
|
||||
private ReceivedInvoiceResult receivedInvoiceResult = new ReceivedInvoiceResult();
|
||||
|
||||
/**
|
||||
* 收款认领推送配置(A301-3)
|
||||
*/
|
||||
private ReceiptClaimPush receiptClaimPush = new ReceiptClaimPush();
|
||||
|
||||
/**
|
||||
* 全票池发票定时同步配置(A801-1)
|
||||
*/
|
||||
@@ -115,6 +110,11 @@ public class BfiProperties {
|
||||
*/
|
||||
private ArResult arResult = new ArResult();
|
||||
|
||||
/**
|
||||
* 金蝶冲销状态回写配置(A102-4/A302-4)
|
||||
*/
|
||||
private WriteOffResult writeOffResult = new WriteOffResult();
|
||||
|
||||
/**
|
||||
* 金蝶单据锁定补偿配置
|
||||
*/
|
||||
@@ -197,20 +197,15 @@ public class BfiProperties {
|
||||
}
|
||||
|
||||
/**
|
||||
* 收款认领公告查询配置项(A301-2)
|
||||
* 收款流水同步配置项(A301-1 收款单查询)
|
||||
*/
|
||||
@Data
|
||||
public static class ClaimAnnounce {
|
||||
public static class RecBillSync {
|
||||
|
||||
/**
|
||||
* 查询时间窗口(月数),默认 6(近半年)
|
||||
* 同步业务日期窗口天数,默认 60
|
||||
*/
|
||||
private int months = 6;
|
||||
|
||||
/**
|
||||
* 认领人(claimusers):配置后公告查询固定使用该值,不再按当前登录用户姓名查询
|
||||
*/
|
||||
private String claimusers;
|
||||
private int days = 60;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -257,6 +252,11 @@ public class BfiProperties {
|
||||
* 回写 single-flight 锁租约秒数,须大于最坏一轮耗时(分批远程查询 + 逐单回写)
|
||||
*/
|
||||
private long lockLeaseSeconds = 1800;
|
||||
|
||||
/**
|
||||
* A103-4 付款处理查询业务日期窗口天数,默认 60
|
||||
*/
|
||||
private int queryWindowDays = 60;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -271,23 +271,6 @@ public class BfiProperties {
|
||||
private boolean syncEnabled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收款认领推送配置项(A301-3)
|
||||
*/
|
||||
@Data
|
||||
public static class ReceiptClaimPush {
|
||||
|
||||
/**
|
||||
* 推送任务开关,默认关闭(联调稳定后开启)
|
||||
*/
|
||||
private boolean syncEnabled = false;
|
||||
|
||||
/**
|
||||
* 推送 single-flight 锁租约秒数,须大于最坏一轮耗时(逐认领人远程查公告 + 逐条推送)
|
||||
*/
|
||||
private long lockLeaseSeconds = 1800;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全票池发票同步配置项(A801-1)
|
||||
*/
|
||||
@@ -374,6 +357,24 @@ public class BfiProperties {
|
||||
private long lockLeaseSeconds = 1800;
|
||||
}
|
||||
|
||||
/**
|
||||
* 金蝶冲销状态回写配置项(A102-4/A302-4):定时回写开关、防重键批量查询与结算单批量更新的分批大小,
|
||||
* 防止单批过大超出金蝶查询接口与批量落库承载能力
|
||||
*/
|
||||
@Data
|
||||
public static class WriteOffResult {
|
||||
|
||||
/**
|
||||
* 冲销状态回写任务开关,默认关闭(安全兜底),由 Nacos 按环境显式开启
|
||||
*/
|
||||
private boolean syncEnabled = false;
|
||||
|
||||
/**
|
||||
* 冲销状态批量查询/批量更新分批大小,默认 1000
|
||||
*/
|
||||
private int queryBatchSize = 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* 金蝶单据锁定补偿配置项
|
||||
*/
|
||||
|
||||
+16
@@ -52,6 +52,22 @@ public final class HcsContractFieldConstant {
|
||||
*/
|
||||
public static final String FIELD_CONTRACT_NO = "CONTRACT_NO";
|
||||
|
||||
/**
|
||||
* 业务合同号(fd 模板字段,值同合同编号 contractNo)
|
||||
*/
|
||||
public static final String FIELD_FD_BUS_CONTRACT_NUMBER = "fdBusContractNumber";
|
||||
|
||||
/**
|
||||
* 归档状态(fd 模板字段):toarchive=待归档; archiving=归档中; archived=已归档; archiveerror=归档异常;
|
||||
* TMS 侧仅在双章归档文件上传(归档状态=已归档)后传 toarchive,其余状态由合规侧自行流转,未归档不传
|
||||
*/
|
||||
public static final String FIELD_FD_ARCHIVE_STATUS = "fdArchiveStatus";
|
||||
|
||||
/**
|
||||
* 归档状态取值:待归档(fdArchiveStatus,双章归档文件上传后 TMS 固定传此值)
|
||||
*/
|
||||
public static final String ARCHIVE_STATUS_TO_ARCHIVE = "toarchive";
|
||||
|
||||
/**
|
||||
* 合同标题(fd 模板字段)
|
||||
*/
|
||||
|
||||
+72
-2
@@ -25,6 +25,8 @@
|
||||
*/
|
||||
package org.springblade.transport.constant;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* HCS(合同合规系统)同步常量(TMS 业务侧)
|
||||
* <p>
|
||||
@@ -175,6 +177,36 @@ public final class HcsSyncConstant {
|
||||
*/
|
||||
public static final String CUSTOMER_KIND_INTERNAL = "internal";
|
||||
|
||||
/**
|
||||
* C101-1 黑名单状态:是(客商档案/合同客商风险展示与组装用)
|
||||
*/
|
||||
public static final String BLACK_STATUS_YES = "yes";
|
||||
|
||||
/**
|
||||
* C101-1 黑名单状态:否
|
||||
*/
|
||||
public static final String BLACK_STATUS_NO = "no";
|
||||
|
||||
/**
|
||||
* 合同审核通过状态集合(首推/变更通过,客商风险定时刷新扫描口径与 HcsPushTask 一致)
|
||||
*/
|
||||
public static final List<String> CONTRACT_APPROVED_STATUSES = List.of("approved", "change_approved");
|
||||
|
||||
/**
|
||||
* 合同已终止当前节点展示名(客商风险定时刷新排除已终止合同)
|
||||
*/
|
||||
public static final String CONTRACT_TERMINATED_NODE = "已终止";
|
||||
|
||||
/**
|
||||
* 客商风险等级业务字典编码(dictKey=C101-1 风险等级 0/1/2,dictValue=低风险/中风险/高风险)
|
||||
*/
|
||||
public static final String RISK_LEVEL_DICT_CODE = "risk_level";
|
||||
|
||||
/**
|
||||
* 客商风险定时刷新批量(200 客商/批:批内黑名单一次查询 + C101-1 逐个查询,查完立即提交数据库,批间互不影响)
|
||||
*/
|
||||
public static final int CUSTOMER_RISK_REFRESH_BATCH = 200;
|
||||
|
||||
/**
|
||||
* 合同附件类型字段名(与前端附件元数据 fileType 字段对齐,值取业务字典 contract_attachment_type)
|
||||
*/
|
||||
@@ -190,6 +222,16 @@ public final class HcsSyncConstant {
|
||||
*/
|
||||
public static final String PAYMENT_ATTACH_TYPE_SPECIAL = "special_approval";
|
||||
|
||||
/**
|
||||
* 付款申请附件类型值:合规特批结果附件(paymentLegal/result 回传展示行标记,详情展示用,不参与特批附件提交校验)
|
||||
*/
|
||||
public static final String PAYMENT_ATTACH_TYPE_SPEC_RESULT = "付款特批流程附件";
|
||||
|
||||
/**
|
||||
* 合规特批结果附件统一业务名(金蝶上送与前端展示用,上传时按 C103-5 响应头补扩展名)
|
||||
*/
|
||||
public static final String PAYMENT_SPEC_ATTACH_NAME = "付款特批流程附件";
|
||||
|
||||
/**
|
||||
* 附件类型:用印附件(双章归档文件,取值沿用 ContractManageServiceImpl#FILE_TYPE_SEAL_ARCHIVE)
|
||||
*/
|
||||
@@ -212,6 +254,12 @@ public final class HcsSyncConstant {
|
||||
*/
|
||||
public static final String CONTRACT_ATTACH_KEY_OTHER = "eopCdraftOther";
|
||||
|
||||
/**
|
||||
* TMS 合同归档状态取值:已归档(ContractManage#archiveStatus,双章归档文件上传后由审核通过逻辑维护;
|
||||
* 与合规 fdArchiveStatus 的 archived 不是同一含义,TMS 已归档对应合规 toarchive 待归档)
|
||||
*/
|
||||
public static final String TMS_ARCHIVE_STATUS_ARCHIVED = "已归档";
|
||||
|
||||
/**
|
||||
* 特批审核挂起节点展示名(currentNode,特批发起后挂起展示,结果回传后恢复)
|
||||
*/
|
||||
@@ -240,15 +288,37 @@ public final class HcsSyncConstant {
|
||||
public static final String ROW_OP_DELETE = "delete";
|
||||
|
||||
/**
|
||||
* 履约类型:付款
|
||||
* 履约类型:付款(C103-9 履约计划行)
|
||||
*/
|
||||
public static final String PERFORM_TYPE_PAYMENT = "payment";
|
||||
|
||||
/**
|
||||
* 履约类型:收款
|
||||
* 履约类型:收款(C103-9 履约计划行)
|
||||
*/
|
||||
public static final String PERFORM_TYPE_COLLECTION = "collection";
|
||||
|
||||
// ==================== 履约记录类型(C108 fd_paln_type,按四场景传中文) ====================
|
||||
|
||||
/**
|
||||
* 履约记录类型:付款
|
||||
*/
|
||||
public static final String PERF_TYPE_PAYMENT = "付款";
|
||||
|
||||
/**
|
||||
* 履约记录类型:收款
|
||||
*/
|
||||
public static final String PERF_TYPE_COLLECTION = "收款";
|
||||
|
||||
/**
|
||||
* 履约记录类型:开票
|
||||
*/
|
||||
public static final String PERF_TYPE_INVOICE = "开票";
|
||||
|
||||
/**
|
||||
* 履约记录类型:收票
|
||||
*/
|
||||
public static final String PERF_TYPE_INVOICE_RECEIPT = "收票";
|
||||
|
||||
/**
|
||||
* 款项性质:进度款
|
||||
*/
|
||||
|
||||
+6
@@ -171,6 +171,12 @@ public final class KingdeeBfiConstant {
|
||||
/** 金蝶同步状态:生成失败(推送异常/金蝶返回失败,可重推)。 */
|
||||
public static final String KINGDEE_STATUS_FAILED = "failed";
|
||||
|
||||
/** 金蝶冲销状态:未冲销(旧应付/应收单在金蝶尚未冲销)。 */
|
||||
public static final String KINGDEE_WRITEOFF_UNWRITTEN = "unwrittenoff";
|
||||
|
||||
/** 金蝶冲销状态:已冲销(旧应付/应收单已在金蝶冲销,部分冲销视同已冲销)。 */
|
||||
public static final String KINGDEE_WRITEOFF_WRITTEN = "writtenoff";
|
||||
|
||||
/** 收票单推送状态:未推送(新建单据默认,票池绑定层的 kingdee_status 与此无关)。 */
|
||||
public static final String RECEIVED_PUSH_UNSYNCED = "unsynced";
|
||||
|
||||
|
||||
+3
@@ -61,6 +61,9 @@ public final class TransportBusiConstant {
|
||||
/** 审批状态:已作废(终态)。 */
|
||||
public static final String VOIDED = "voided";
|
||||
|
||||
/** 正式结算单调整标识:已调整(结算调整重推后的归档原单,金额已作废,不可再用于下游业务)。 */
|
||||
public static final String SETTLEMENT_ADJUST_STATUS_ADJUSTED = "adjusted";
|
||||
|
||||
/**
|
||||
* 「未分配组织」筛选项的哨兵值,不是真实组织名。
|
||||
* <p>
|
||||
|
||||
+17
@@ -36,7 +36,9 @@ import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.tool.api.R;
|
||||
import org.springblade.transport.ocr.constant.BaiduOcrType;
|
||||
import org.springblade.transport.ocr.service.IBaiduOcrService;
|
||||
import org.springblade.transport.ocr.service.IBusinessLicenseOcrService;
|
||||
import org.springblade.transport.pojo.vo.BaiduOcrResultVO;
|
||||
import org.springblade.transport.pojo.vo.BusinessLicenseOcrVO;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -60,6 +62,7 @@ import java.io.IOException;
|
||||
public class BaiduOcrController extends BladeController {
|
||||
|
||||
private final IBaiduOcrService baiduOcrService;
|
||||
private final IBusinessLicenseOcrService businessLicenseOcrService;
|
||||
|
||||
/**
|
||||
* 识别上传的图片。
|
||||
@@ -109,6 +112,20 @@ public class BaiduOcrController extends BladeController {
|
||||
return R.data(baiduOcrService.recognizeUrl(resolveType(type), side, imageUrl));
|
||||
}
|
||||
|
||||
/**
|
||||
* 营业执照图片地址识别(第三方 /businessLicense)。
|
||||
*
|
||||
* @param imageUrl 图片地址
|
||||
* @return 识别结果
|
||||
*/
|
||||
@PostMapping("/business-license")
|
||||
@ApiOperationSupport(order = 3)
|
||||
@Operation(summary = "营业执照OCR识别", description = "按图片地址调用第三方营业执照识别接口")
|
||||
public R<BusinessLicenseOcrVO> recognizeBusinessLicense(
|
||||
@Parameter(description = "图片地址", required = true) @RequestParam String imageUrl) {
|
||||
return R.data(businessLicenseOcrService.recognizeUrl(imageUrl));
|
||||
}
|
||||
|
||||
private BaiduOcrType resolveType(String type) {
|
||||
try {
|
||||
return BaiduOcrType.from(type);
|
||||
|
||||
+5
-9
@@ -33,6 +33,7 @@ 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.constant.HcsSyncConstant;
|
||||
import org.springblade.transport.pojo.entity.ContractChange;
|
||||
import org.springblade.transport.pojo.vo.ContractChangeVO;
|
||||
import org.springblade.transport.service.IContractChangeService;
|
||||
@@ -110,17 +111,12 @@ public class ContractChangeController extends BladeController {
|
||||
hcsImmediatePushSupport.pushContractAndWait(contractId);
|
||||
String syncFailure = HcsImmediatePushSupport.pollSyncFailure();
|
||||
if (syncFailure != null) {
|
||||
ContractChange contractChange = contractChangeService.getById(id);
|
||||
if (contractChange != null) {
|
||||
contractChange.setSyncStatus("failed");
|
||||
contractChangeService.updateById(contractChange);
|
||||
}
|
||||
contractChangeService.writeBackSyncStatus(contractId, HcsSyncConstant.HCS_STATUS_FAILED);
|
||||
return R.data(Boolean.FALSE, "审批通过,但合规推送失败:" + syncFailure);
|
||||
}
|
||||
ContractChange contractChange = contractChangeService.getById(id);
|
||||
if (contractChange != null) {
|
||||
contractChange.setSyncStatus("synced");
|
||||
contractChangeService.updateById(contractChange);
|
||||
// 即时推送未开启时(开关关闭不触发推送)不标记已推送,待推送状态由定时扫描任务推送后闭环
|
||||
if (hcsImmediatePushSupport.isImmediateEnabled()) {
|
||||
contractChangeService.writeBackSyncStatus(contractId, HcsSyncConstant.HCS_STATUS_SYNCED);
|
||||
}
|
||||
return R.success("审批通过");
|
||||
}
|
||||
|
||||
+4
-3
@@ -188,10 +188,11 @@ public class ContractManageController extends BladeController {
|
||||
|
||||
@GetMapping("/next-contract-no")
|
||||
@ApiOperationSupport(order = 16)
|
||||
@Operation(summary = "预览下一合同编号", description = "按合同负责部门编码前缀+年份+三位流水预览")
|
||||
@Operation(summary = "预览下一合同编号", description = "按平台公司+业务部门配置的前缀+年份+三位流水预览")
|
||||
public R<String> nextContractNo(
|
||||
@Parameter(description = "合同负责部门ID", required = true) @RequestParam Long organizationId) {
|
||||
return R.data(contractManageService.nextContractNo(organizationId));
|
||||
@Parameter(description = "合同负责部门ID", required = true) @RequestParam Long organizationId,
|
||||
@Parameter(description = "平台公司ID", required = true) @RequestParam Long platformCompanyId) {
|
||||
return R.data(contractManageService.nextContractNo(organizationId, platformCompanyId));
|
||||
}
|
||||
|
||||
@GetMapping("/export-contract-manage")
|
||||
|
||||
+31
@@ -38,6 +38,7 @@ 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.secure.utils.AuthUtil;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiBankVO;
|
||||
import org.springblade.transport.support.TransportBusinessSupport;
|
||||
import org.springblade.core.tool.api.R;
|
||||
import org.springblade.core.tool.utils.DateUtil;
|
||||
@@ -198,6 +199,36 @@ public class CustomerArchiveController extends BladeController {
|
||||
return R.data(customerArchiveService.syncKingdee(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 金蝶供应商编码查询(A901-1,新增客商填写信用代码后即时回显用)
|
||||
*/
|
||||
@GetMapping("/query-supplier-code")
|
||||
@ApiOperationSupport(order = 12)
|
||||
@Operation(summary = "金蝶供应商编码查询", description = "A901-1 按统一社会信用代码查询金蝶供应商档案取编码,查不到返回空")
|
||||
public R<String> querySupplierCode(@Parameter(description = "统一社会信用代码", required = true) @RequestParam String creditCode) {
|
||||
return R.data(customerArchiveService.querySupplierCode(creditCode));
|
||||
}
|
||||
|
||||
/**
|
||||
* 金蝶开户行查询(A905-3,收款信息填写开户行失焦精确匹配联行号用)
|
||||
*/
|
||||
@GetMapping("/query-bank")
|
||||
@ApiOperationSupport(order = 12)
|
||||
@Operation(summary = "金蝶开户行查询", description = "A905-3 按行名精确匹配金蝶行名行号档案,返回行名与联行号,查不到返回空")
|
||||
public R<BfiBankVO> queryBank(@Parameter(description = "开户行名称", required = true) @RequestParam String bankName) {
|
||||
return R.data(customerArchiveService.queryKingdeeBank(bankName));
|
||||
}
|
||||
|
||||
/**
|
||||
* 金蝶联行号校验(A905-3,收款信息保存前校验联行号真实存在用)
|
||||
*/
|
||||
@GetMapping("/query-bank-by-number")
|
||||
@ApiOperationSupport(order = 12)
|
||||
@Operation(summary = "金蝶联行号校验", description = "A905-3 按行号精确匹配金蝶行名行号档案,查不到返回空,接口异常返回错误")
|
||||
public R<BfiBankVO> queryBankByNumber(@Parameter(description = "联行号", required = true) @RequestParam String cnapsCode) {
|
||||
return R.data(customerArchiveService.queryKingdeeBankByNumber(cnapsCode));
|
||||
}
|
||||
|
||||
/**
|
||||
* 评分明细模板
|
||||
*/
|
||||
|
||||
+44
-9
@@ -38,6 +38,7 @@ import org.springblade.core.tenant.annotation.TenantIgnore;
|
||||
import org.springblade.core.tool.api.R;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.core.tool.utils.StringUtil;
|
||||
import org.springblade.hcs.config.HcsProperties;
|
||||
import org.springblade.hcs.dto.HcsContractLegalResultDTO;
|
||||
import org.springblade.hcs.dto.HcsCurrencyVO;
|
||||
import org.springblade.hcs.dto.HcsLegalResultDTO;
|
||||
@@ -70,7 +71,9 @@ import java.io.OutputStream;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* HCS 合规推送端点(设计文档 §5.0 手动入口)
|
||||
@@ -99,6 +102,20 @@ public class HcsSyncController {
|
||||
private final HcsInvoiceRegSyncSupport hcsInvoiceRegSyncSupport;
|
||||
private final HcsReceiptBillSyncSupport hcsReceiptBillSyncSupport;
|
||||
private final HcsMasterDataSupport hcsMasterDataSupport;
|
||||
private final HcsProperties hcsProperties;
|
||||
|
||||
/**
|
||||
* 推送开关配置(供项目管理/合同管理列表控制「推送合规」「批量推送合规」显隐)
|
||||
*/
|
||||
@GetMapping("/push-config")
|
||||
@ApiOperationSupport(order = 0)
|
||||
@Operation(summary = "查询HCS推送开关", description = "返回 thirdParty.hcs.push.immediate-enabled,前端据此隐藏即时推送入口")
|
||||
public R<Map<String, Object>> pushConfig() {
|
||||
boolean immediateEnabled = hcsProperties.getPush() != null && hcsProperties.getPush().isImmediateEnabled();
|
||||
Map<String, Object> config = new HashMap<>(2);
|
||||
config.put("immediateEnabled", immediateEnabled);
|
||||
return R.data(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条重推(bizType:project=项目立项 / contract=合同 / payment=付款申请 / invoice-application=开票申请 / invoice-receipt=收票单)
|
||||
@@ -130,8 +147,9 @@ public class HcsSyncController {
|
||||
case HcsSyncConstant.BIZ_TYPE_PROJECT -> hcsPushTask.pushUnsyncedProjects();
|
||||
case HcsSyncConstant.BIZ_TYPE_CONTRACT -> hcsPushTask.pushUnsyncedContracts();
|
||||
case HcsSyncConstant.BIZ_TYPE_PAYMENT -> hcsPushTask.pushUnsyncedPayments();
|
||||
case HcsSyncConstant.BIZ_TYPE_INVOICE_APPLICATION -> hcsPushTask.pushUnsyncedInvoiceApplications();
|
||||
case HcsSyncConstant.BIZ_TYPE_INVOICE_RECEIPT -> hcsPushTask.pushUnsyncedInvoiceReceipts();
|
||||
// 开票/收票走组合动作(C111/C109 + 履约记录 C108-1),与定时任务行为一致
|
||||
case HcsSyncConstant.BIZ_TYPE_INVOICE_APPLICATION -> hcsPushTask.syncHcsInvoiceApplications();
|
||||
case HcsSyncConstant.BIZ_TYPE_INVOICE_RECEIPT -> hcsPushTask.syncHcsInvoiceReceipts();
|
||||
default -> throw new IllegalArgumentException("不支持的HCS推送业务类型:" + bizType);
|
||||
};
|
||||
return R.data(result);
|
||||
@@ -215,13 +233,16 @@ public class HcsSyncController {
|
||||
|
||||
/**
|
||||
* 客商风险等级查询(C101-1,展示用,前端接入设计 §3.3;强制校验仍在推送/付款链路):
|
||||
* 内部组织类型客商默认无风险不调合规,外部客商高风险/黑名单返回提示文案
|
||||
* 内部组织类型客商默认无风险不调合规,外部客商高风险/黑名单返回提示文案;
|
||||
* creditCode/customerName 供新增客商(档案未落库)查金蝶黑名单、拼装提示名称兜底,档案存在时以档案为准
|
||||
*/
|
||||
@GetMapping("/risk")
|
||||
@ApiOperationSupport(order = 10)
|
||||
@Operation(summary = "客商风险等级查询", description = "C101-1 按客商编号查询风险等级与黑名单状态(展示用),内部客商默认无风险,高风险/黑名单返回风险提示文案,结果留痕")
|
||||
public R<CustomerRiskVO> risk(@Parameter(description = "客商编号", required = true) @RequestParam String customerCode) {
|
||||
return R.data(hcsCreditCheckSupport.queryCustomerRisk(customerCode));
|
||||
@Operation(summary = "客商风险等级查询", description = "C101-1 按客商编号查询风险等级与黑名单状态(展示用),内部客商默认无风险,高风险/黑名单返回风险提示文案,结果留痕;creditCode/customerName 为新增客商场景兜底入参")
|
||||
public R<CustomerRiskVO> risk(@Parameter(description = "客商编号", required = true) @RequestParam String customerCode,
|
||||
@Parameter(description = "统一社会信用代码(新增客商档案未落库时兜底查金蝶黑名单)") @RequestParam(required = false) String creditCode,
|
||||
@Parameter(description = "客商名称(新增客商档案未落库时提示文案名称兜底)") @RequestParam(required = false) String customerName) {
|
||||
return R.data(hcsCreditCheckSupport.queryCustomerRisk(customerCode, creditCode, customerName));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,13 +282,14 @@ public class HcsSyncController {
|
||||
*/
|
||||
@GetMapping("/attachment/download")
|
||||
@ApiOperationSupport(order = 12)
|
||||
@Operation(summary = "合规附件下载", description = "C103-5 代理:按合规附件ID 下载文件流,name 参数可选用于指定下载文件名")
|
||||
@Operation(summary = "合规附件下载", description = "C103-5 代理:按合规附件ID 下载文件流,name 参数可选用于指定下载文件名,inline=true 时内嵌预览(pdf 浏览器直接打开)")
|
||||
public void downloadAttachment(@Parameter(description = "合规附件ID", required = true) @RequestParam String fdId,
|
||||
@Parameter(description = "下载文件名(可选)") @RequestParam(required = false) String name,
|
||||
HttpServletResponse response) throws Exception {
|
||||
String fileName = StringUtil.isBlank(name) ? "hcs-attachment-" + fdId : name;
|
||||
response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
|
||||
response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20"));
|
||||
response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''"
|
||||
+ URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20"));
|
||||
try (Response hcsResponse = hcsAttachmentSupport.downloadRaw(fdId);
|
||||
InputStream inputStream = hcsResponse.body().asInputStream();
|
||||
OutputStream outputStream = response.getOutputStream()) {
|
||||
@@ -276,6 +298,19 @@ public class HcsSyncController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 合同附件下载(路径式文件名,url 以 .pdf 等后缀结尾):前端附件组件按 url 路径后缀识别附件类型,
|
||||
* query 传 fdId;行为与 /attachment/download 完全一致,强制下载不做内嵌预览
|
||||
*/
|
||||
@GetMapping("/attachment/download/{name}")
|
||||
@ApiOperationSupport(order = 12)
|
||||
@Operation(summary = "合规附件下载(路径式文件名)", description = "C103-5 代理:路径携带文件名(含后缀,前端附件类型识别用),fdId 必传,文件流 attachment 下载")
|
||||
public void downloadAttachmentWithPath(@Parameter(description = "下载文件名(含后缀)", required = true) @PathVariable String name,
|
||||
@Parameter(description = "合规附件ID", required = true) @RequestParam String fdId,
|
||||
HttpServletResponse response) throws Exception {
|
||||
this.downloadAttachment(fdId, name, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 合同特批结果人工确认(§6-U1 过渡方案:与合规推送等效)
|
||||
*/
|
||||
@@ -307,7 +342,7 @@ public class HcsSyncController {
|
||||
* <p>
|
||||
* 免登免授权同合同接口(blade.secure 放行);
|
||||
* iPaaS 网关注册映射:/gwzh/HTHG/paymentLegal/result → /hcs/paymentLegal/result;
|
||||
* 报文 {sourceSystem, requestId=TMS付款申请ID, status=APPROVED/REJECTED, reason},
|
||||
* 报文 {sourceSystem, requestId=合规特批流程ID(hcs_spec_fd_id), status=APPROVED/REJECTED, reason},
|
||||
* approved 恢复"审批通过"放行,rejected 联动付款审批驳回走重新提交闭环。
|
||||
*/
|
||||
@PostMapping("/paymentLegal/result")
|
||||
|
||||
+5
-9
@@ -33,6 +33,7 @@ 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.constant.HcsSyncConstant;
|
||||
import org.springblade.transport.pojo.entity.ProjectChange;
|
||||
import org.springblade.transport.pojo.vo.ProjectChangeVO;
|
||||
import org.springblade.transport.service.IProjectChangeService;
|
||||
@@ -110,17 +111,12 @@ public class ProjectChangeController extends BladeController {
|
||||
hcsImmediatePushSupport.pushProjectAndWait(projectId);
|
||||
String syncFailure = HcsImmediatePushSupport.pollSyncFailure();
|
||||
if (syncFailure != null) {
|
||||
ProjectChange projectChange = projectChangeService.getById(id);
|
||||
if (projectChange != null) {
|
||||
projectChange.setSyncStatus("failed");
|
||||
projectChangeService.updateById(projectChange);
|
||||
}
|
||||
projectChangeService.writeBackSyncStatus(projectId, HcsSyncConstant.HCS_STATUS_FAILED);
|
||||
return R.data(Boolean.FALSE, "审批通过,但合规推送失败:" + syncFailure);
|
||||
}
|
||||
ProjectChange projectChange = projectChangeService.getById(id);
|
||||
if (projectChange != null) {
|
||||
projectChange.setSyncStatus("synced");
|
||||
projectChangeService.updateById(projectChange);
|
||||
// 即时推送未开启时(开关关闭不触发推送)不标记已推送,待推送状态由定时扫描任务推送后闭环
|
||||
if (hcsImmediatePushSupport.isImmediateEnabled()) {
|
||||
projectChangeService.writeBackSyncStatus(projectId, HcsSyncConstant.HCS_STATUS_SYNCED);
|
||||
}
|
||||
return R.success("审批通过");
|
||||
}
|
||||
|
||||
+1
-8
@@ -83,16 +83,9 @@ public class ReceiptClaimRecordController extends BladeController {
|
||||
|
||||
@PostMapping("/void")
|
||||
@ApiOperationSupport(order = 4)
|
||||
@Operation(summary = "作废认领记录并生成金蝶认领冲单")
|
||||
@Operation(summary = "作废认领记录(作废后同步合同合规系统)")
|
||||
public R<Void> voidClaim(@RequestParam Long id) {
|
||||
receiptClaimRecordService.voidClaim(id);
|
||||
return R.success("作废成功");
|
||||
}
|
||||
|
||||
@PostMapping("/query-kingdee-result")
|
||||
@ApiOperationSupport(order = 5)
|
||||
@Operation(summary = "查询金蝶认领结果(A301-1 收款单查询回写)")
|
||||
public R<Integer> queryKingdeeResult() {
|
||||
return R.data(receiptClaimRecordService.refreshKingdeeResult());
|
||||
}
|
||||
}
|
||||
|
||||
+1
-10
@@ -35,7 +35,6 @@ 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.ReceiptClaimPushResult;
|
||||
import org.springblade.transport.pojo.dto.ReceiptClaimRequest;
|
||||
import org.springblade.transport.pojo.vo.ReceiptFlowVO;
|
||||
import org.springblade.transport.service.IReceiptFlowService;
|
||||
@@ -95,16 +94,8 @@ public class ReceiptFlowController extends BladeController {
|
||||
|
||||
@PostMapping("/sync")
|
||||
@ApiOperationSupport(order = 5)
|
||||
@Operation(summary = "手动同步金蝶收款流水(A301-2 收款认领公告查询)")
|
||||
@Operation(summary = "手动同步金蝶收款流水(A301-1 收款单查询)")
|
||||
public R<Integer> sync() {
|
||||
return R.data(receiptFlowService.sync());
|
||||
}
|
||||
|
||||
@PostMapping("/push-kingdee")
|
||||
@ApiOperationSupport(order = 6)
|
||||
@Operation(summary = "推送金蝶收款认领(A301-3 收款认领结果推送,仅认领完成流水可推送)")
|
||||
public R<ReceiptClaimPushResult> pushKingdee(@RequestParam Long flowId) {
|
||||
ReceiptClaimPushResult result = receiptFlowService.pushClaimToKingdee(flowId);
|
||||
return result.isSuccess() ? R.data(result) : R.fail(result.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -66,6 +66,8 @@ public class ContractManageExcel implements Serializable {
|
||||
private String partyA;
|
||||
@ExcelProperty("乙方")
|
||||
private String partyB;
|
||||
@ExcelProperty("丙方")
|
||||
private String partyC;
|
||||
@ExcelProperty("开始日期")
|
||||
private LocalDate startDate;
|
||||
@ExcelProperty("结束日期")
|
||||
|
||||
+3
@@ -51,6 +51,9 @@ public class CustomerArchiveExcel implements Serializable {
|
||||
@ExcelProperty("客商编号")
|
||||
private String customerCode;
|
||||
|
||||
@ExcelProperty("供应商编码")
|
||||
private String supplierCode;
|
||||
|
||||
@ExcelProperty("客商简称")
|
||||
private String shortName;
|
||||
|
||||
|
||||
+27
@@ -54,6 +54,11 @@
|
||||
<result column="kingdee_status" property="kingdeeStatus"/>
|
||||
<result column="kingdee_fail_reason" property="kingdeeFailReason"/>
|
||||
<result column="kingdee_sync_time" property="kingdeeSyncTime"/>
|
||||
<result column="hcs_risk_level" property="hcsRiskLevel"/>
|
||||
<result column="kingdee_black_status" property="kingdeeBlackStatus"/>
|
||||
<result column="hcs_risk_time" property="hcsRiskTime"/>
|
||||
<result column="kingdee_black_category" property="kingdeeBlackCategory"/>
|
||||
<result column="kingdee_black_reason" property="kingdeeBlackReason"/>
|
||||
</resultMap>
|
||||
|
||||
<select id="selectCustomerArchivePage" resultMap="customerArchiveResultMap">
|
||||
@@ -108,6 +113,11 @@
|
||||
kingdee_status,
|
||||
kingdee_fail_reason,
|
||||
kingdee_sync_time,
|
||||
hcs_risk_level,
|
||||
kingdee_black_status,
|
||||
hcs_risk_time,
|
||||
kingdee_black_category,
|
||||
kingdee_black_reason,
|
||||
(
|
||||
SELECT s.review_status
|
||||
FROM blade_customer_credit_score s
|
||||
@@ -171,6 +181,23 @@
|
||||
<if test="customer.createTimeEnd != null">
|
||||
AND create_time <= #{customer.createTimeEnd}
|
||||
</if>
|
||||
<!-- 客商风险筛选:blacklist 仅按金蝶黑名单(A801-5)命中过滤;等级筛选独立 -->
|
||||
<if test="customer.customerRisk != null and customer.customerRisk != ''">
|
||||
<choose>
|
||||
<when test="customer.customerRisk == 'blacklist'">
|
||||
AND (kingdee_black_category IS NOT NULL OR kingdee_black_reason IS NOT NULL)
|
||||
</when>
|
||||
<when test="customer.customerRisk == 'high'">
|
||||
AND hcs_risk_level = '2'
|
||||
</when>
|
||||
<when test="customer.customerRisk == 'middle'">
|
||||
AND hcs_risk_level = '1'
|
||||
</when>
|
||||
<otherwise>
|
||||
AND hcs_risk_level = '0'
|
||||
</otherwise>
|
||||
</choose>
|
||||
</if>
|
||||
ORDER BY create_time DESC
|
||||
</select>
|
||||
|
||||
|
||||
+12
@@ -27,8 +27,11 @@ package org.springblade.transport.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.springblade.transport.pojo.entity.KingdeeReceiptFlow;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 金蝶收款流水镜像 Mapper 接口
|
||||
*
|
||||
@@ -36,4 +39,13 @@ import org.springblade.transport.pojo.entity.KingdeeReceiptFlow;
|
||||
*/
|
||||
@Mapper
|
||||
public interface KingdeeReceiptFlowMapper extends BaseMapper<KingdeeReceiptFlow> {
|
||||
|
||||
/**
|
||||
* 组织管理(blade_dept)中的平台公司金蝶组织编码列表(是否平台公司=是 且 已维护金蝶编码)
|
||||
*
|
||||
* @return 平台公司金蝶组织编码
|
||||
*/
|
||||
@Select("SELECT kingdee_dept_code FROM blade_dept WHERE is_platform_company = 1 "
|
||||
+ "AND kingdee_dept_code IS NOT NULL AND kingdee_dept_code != '' AND is_deleted = 0")
|
||||
List<String> selectPlatformOrgNumbers();
|
||||
}
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 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.springblade.transport.pojo.entity.ReceiptClaimHcsRecord;
|
||||
|
||||
/**
|
||||
* 收款认领合规推送记录 Mapper 接口
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Mapper
|
||||
public interface ReceiptClaimHcsRecordMapper extends BaseMapper<ReceiptClaimHcsRecord> {
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 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.ocr.service;
|
||||
|
||||
import org.springblade.transport.pojo.vo.BusinessLicenseOcrVO;
|
||||
|
||||
/**
|
||||
* 营业执照 OCR 服务。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public interface IBusinessLicenseOcrService {
|
||||
|
||||
/**
|
||||
* 根据图片地址识别营业执照。
|
||||
*
|
||||
* @param imageUrl 图片地址
|
||||
* @return 识别结果
|
||||
*/
|
||||
BusinessLicenseOcrVO recognizeUrl(String imageUrl);
|
||||
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* 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.ocr.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.tool.utils.StringUtil;
|
||||
import org.springblade.transport.ocr.service.IBusinessLicenseOcrService;
|
||||
import org.springblade.transport.pojo.vo.BusinessLicenseOcrVO;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 营业执照 OCR 服务实现,调用第三方 /businessLicense 接口。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class BusinessLicenseOcrServiceImpl implements IBusinessLicenseOcrService {
|
||||
|
||||
private static final String PATH = "/businessLicense";
|
||||
private static final String DATA_TYPE_URL = "url";
|
||||
private static final int SUCCESS_CODE = 0;
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final HttpClient httpClient;
|
||||
|
||||
/**
|
||||
* 营业执照 OCR 服务地址,Nacos:thirdParty.ocr.businessLicenseBaseUrl
|
||||
*/
|
||||
@Value("${thirdParty.ocr.businessLicenseBaseUrl:http://124.71.22.123:8888}")
|
||||
private String businessLicenseBaseUrl;
|
||||
|
||||
public BusinessLicenseOcrServiceImpl(ObjectMapper objectMapper,
|
||||
@Qualifier("baiduOcrHttpClient") HttpClient httpClient) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.httpClient = httpClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BusinessLicenseOcrVO recognizeUrl(String imageUrl) {
|
||||
if (StringUtil.isBlank(imageUrl)) {
|
||||
throw new ServiceException("OCR图片地址不能为空");
|
||||
}
|
||||
String normalizedUrl = imageUrl.trim();
|
||||
validateImageUrl(normalizedUrl);
|
||||
if (StringUtil.isBlank(businessLicenseBaseUrl)) {
|
||||
throw new ServiceException("营业执照OCR服务地址未配置(thirdParty.ocr.businessLicenseBaseUrl)");
|
||||
}
|
||||
String requestUrl = businessLicenseBaseUrl.replaceAll("/+$", "") + PATH;
|
||||
Map<String, String> body = new LinkedHashMap<>();
|
||||
body.put("img_path", normalizedUrl);
|
||||
body.put("data_type", DATA_TYPE_URL);
|
||||
JsonNode response = sendRequest(requestUrl, body);
|
||||
int code = response.path("code").asInt(Integer.MIN_VALUE);
|
||||
if (code != SUCCESS_CODE) {
|
||||
String message = firstNonBlank(response.path("msg").asText(null), response.path("message").asText(null), "未知错误");
|
||||
log.warn("营业执照OCR识别失败,code={},msg={},imageUrl={}", code, message, normalizedUrl);
|
||||
throw new ServiceException("营业执照OCR识别失败:" + message);
|
||||
}
|
||||
BusinessLicenseOcrVO result = new BusinessLicenseOcrVO();
|
||||
result.setFields(extractFields(response.path("data")));
|
||||
return result;
|
||||
}
|
||||
|
||||
private JsonNode sendRequest(String requestUrl, Map<String, String> body) {
|
||||
try {
|
||||
String jsonBody = objectMapper.writeValueAsString(body);
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(requestUrl))
|
||||
.timeout(Duration.ofSeconds(60))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8))
|
||||
.build();
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
log.error("营业执照OCR接口响应异常,status={},url={}", response.statusCode(), requestUrl);
|
||||
throw new ServiceException("营业执照OCR接口调用失败,HTTP状态码:" + response.statusCode());
|
||||
}
|
||||
JsonNode result = objectMapper.readTree(response.body());
|
||||
if (result == null || !result.isObject()) {
|
||||
throw new ServiceException("营业执照OCR响应格式异常");
|
||||
}
|
||||
return result;
|
||||
} catch (ServiceException exception) {
|
||||
throw exception;
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.error("调用营业执照OCR接口被中断,url={}", requestUrl, exception);
|
||||
throw new ServiceException("营业执照OCR接口调用被中断");
|
||||
} catch (IllegalArgumentException exception) {
|
||||
log.error("营业执照OCR接口地址配置不正确,url={}", requestUrl);
|
||||
throw new ServiceException("营业执照OCR接口地址配置不正确");
|
||||
} catch (IOException exception) {
|
||||
log.error("调用营业执照OCR接口失败,url={}", requestUrl, exception);
|
||||
throw new ServiceException("营业执照OCR接口调用失败");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, String> extractFields(JsonNode dataNode) {
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
if (dataNode == null || !dataNode.isArray()) {
|
||||
return fields;
|
||||
}
|
||||
for (JsonNode item : dataNode) {
|
||||
if (item == null || !item.isObject()) {
|
||||
continue;
|
||||
}
|
||||
String key = item.path("key").asText(null);
|
||||
String value = item.path("value").asText(null);
|
||||
if (StringUtil.isBlank(key) || value == null) {
|
||||
continue;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
if (StringUtil.isBlank(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
fields.put(key.trim(), trimmed);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
private void validateImageUrl(String imageUrl) {
|
||||
if (imageUrl.getBytes(StandardCharsets.UTF_8).length > 1024) {
|
||||
throw new ServiceException("OCR图片地址长度不能超过1024字节");
|
||||
}
|
||||
try {
|
||||
URI imageUri = URI.create(imageUrl);
|
||||
String scheme = imageUri.getScheme();
|
||||
if (StringUtil.isBlank(scheme) || StringUtil.isBlank(imageUri.getHost())
|
||||
|| imageUri.getUserInfo() != null
|
||||
|| !("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))) {
|
||||
throw new ServiceException("OCR图片地址必须是有效的HTTP或HTTPS地址");
|
||||
}
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new ServiceException("OCR图片地址格式不正确");
|
||||
}
|
||||
}
|
||||
|
||||
private String firstNonBlank(String... values) {
|
||||
if (values == null) {
|
||||
return null;
|
||||
}
|
||||
for (String value : values) {
|
||||
if (StringUtil.isNotBlank(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
+9
@@ -72,4 +72,13 @@ public interface IContractChangeService extends BaseService<ContractChange> {
|
||||
*/
|
||||
boolean markSyncStatus(String ids, String syncStatus);
|
||||
|
||||
/**
|
||||
* 按原合同回写最近一条待同步变更单的同步状态(审批即时推送/定时任务推送后调用)
|
||||
*
|
||||
* @param contractId 原合同ID
|
||||
* @param syncStatus 同步状态(synced/failed)
|
||||
* @return 是否回写成功(无待同步变更单时返回 false)
|
||||
*/
|
||||
boolean writeBackSyncStatus(Long contractId, String syncStatus);
|
||||
|
||||
}
|
||||
|
||||
+4
-3
@@ -75,12 +75,13 @@ public interface IContractManageService extends BaseService<ContractManage> {
|
||||
List<ContractManageExcel> exportContractManage(ContractManageVO contractManage, String ids);
|
||||
|
||||
/**
|
||||
* 预览下一合同编号(按合同负责部门前缀 + 年份 + 三位流水)
|
||||
* 预览下一合同编号(按平台公司 + 业务部门配置的前缀 + 年份 + 三位流水)
|
||||
*
|
||||
* @param organizationId 合同负责部门ID
|
||||
* @param organizationId 合同负责部门(业务部门)ID
|
||||
* @param platformCompanyId 平台公司ID
|
||||
* @return 预览编号
|
||||
*/
|
||||
String nextContractNo(Long organizationId);
|
||||
String nextContractNo(Long organizationId, Long platformCompanyId);
|
||||
|
||||
/**
|
||||
* 合同是否可用于生成结算单:已审批、未终止,且临时合同仍在临时效力期内。
|
||||
|
||||
+25
@@ -25,6 +25,7 @@ package org.springblade.transport.service;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import org.springblade.core.mp.base.BaseService;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiBankVO;
|
||||
import org.springblade.transport.excel.CustomerArchiveExcel;
|
||||
import org.springblade.transport.pojo.entity.CustomerArchive;
|
||||
import org.springblade.transport.pojo.vo.CustomerArchiveVO;
|
||||
@@ -196,6 +197,30 @@ public interface ICustomerArchiveService extends BaseService<CustomerArchive> {
|
||||
*/
|
||||
CustomerArchive syncKingdee(Long id);
|
||||
|
||||
/**
|
||||
* A901-1 按统一社会信用代码查询金蝶供应商编码(新增客商填写信用代码后即时回显用),查不到返回 null
|
||||
*
|
||||
* @param unifiedCreditCode 统一社会信用代码
|
||||
* @return 金蝶供应商编码
|
||||
*/
|
||||
String querySupplierCode(String unifiedCreditCode);
|
||||
|
||||
/**
|
||||
* A905-3 按行名精确匹配金蝶行名行号档案(收款信息填写开户行后失焦回显联行号用),查不到返回 null
|
||||
*
|
||||
* @param bankName 开户行名称(行名全称)
|
||||
* @return 匹配到的行名行号档案,含联行号
|
||||
*/
|
||||
BfiBankVO queryKingdeeBank(String bankName);
|
||||
|
||||
/**
|
||||
* A905-3 按行号精确匹配金蝶行名行号档案(收款信息保存前校验联行号真实存在用),查不到返回 null,接口异常上抛由全局异常处理返回
|
||||
*
|
||||
* @param cnapsCode 联行号(行号)
|
||||
* @return 匹配到的行名行号档案
|
||||
*/
|
||||
BfiBankVO queryKingdeeBankByNumber(String cnapsCode);
|
||||
|
||||
/**
|
||||
* 推送失败状态落库(独立事务):推送主事务已回滚,失败状态与原因单独落库供页面展示与手动重推
|
||||
*
|
||||
|
||||
+17
@@ -80,6 +80,23 @@ public interface IFormalSettlementService extends BaseService<FormalSettlement>
|
||||
String retryKingdeeFailed();
|
||||
|
||||
String syncKingdeeArResult();
|
||||
|
||||
/**
|
||||
* 金蝶冲销状态定时回写:扫描已同步金蝶的结算单,按防重键批量查询 A102-4/A302-4 的
|
||||
* hadwrittenoff(部分冲销视同已冲销),回写冲销状态供结算单列表展示
|
||||
*
|
||||
* @return 执行摘要
|
||||
*/
|
||||
String syncKingdeeWriteOffResult();
|
||||
|
||||
/**
|
||||
* 单笔刷新金蝶冲销状态(选单预检/重推闸门实时查询后回写,状态未变化不落库)
|
||||
*
|
||||
* @param settlementId 正式结算单id
|
||||
* @param writtenOff 金蝶侧应付/应收单是否已冲销
|
||||
*/
|
||||
void refreshKingdeeWriteOff(Long settlementId, boolean writtenOff);
|
||||
|
||||
List<FormalSettlementDetailFee> detailFees(Long detailId);
|
||||
void adjustDetail(PreSettlementDetailAdjustRequest request);
|
||||
void refreshPaymentSummary(Long settlementId);
|
||||
|
||||
+4
-3
@@ -72,12 +72,13 @@ public interface IPaymentApplicationService extends BaseService<PaymentApplicati
|
||||
String doSyncKingdee(Long id, KingdeeSyncContext context);
|
||||
|
||||
/**
|
||||
* 独立事务落"生成失败"状态(在推送事务回滚、行锁释放后执行);
|
||||
* 独立事务落"生成失败"状态与失败原因(在推送事务回滚、行锁释放后执行);
|
||||
* 仅更新审批通过的单据,未审批/已删除单据自动跳过,保持原状态
|
||||
*
|
||||
* @param id 付款申请id
|
||||
* @param id 付款申请id
|
||||
* @param reason 失败原因(异常信息,展示在列表状态悬浮提示)
|
||||
*/
|
||||
void markKingdeeSyncFailed(Long id);
|
||||
void markKingdeeSyncFailed(Long id, String reason);
|
||||
|
||||
/**
|
||||
* 重推金蝶生成失败(kingdeeStatus=failed 且审批通过)的付款申请:逐单独立成败,失败不中断。
|
||||
|
||||
+9
@@ -67,4 +67,13 @@ public interface IProjectChangeService extends BaseService<ProjectChange> {
|
||||
|
||||
boolean removeDraft(String ids);
|
||||
|
||||
/**
|
||||
* 按原项目回写最近一条待同步变更单的同步状态(审批即时推送/定时任务推送后调用)
|
||||
*
|
||||
* @param projectId 原项目ID
|
||||
* @param syncStatus 同步状态(synced/failed)
|
||||
* @return 是否回写成功(无待同步变更单时返回 false)
|
||||
*/
|
||||
boolean writeBackSyncStatus(Long projectId, String syncStatus);
|
||||
|
||||
}
|
||||
|
||||
-7
@@ -46,11 +46,4 @@ public interface IReceiptClaimRecordService extends BaseService<ReceiptClaim> {
|
||||
void updateAttachments(ReceiptClaimAttachmentsRequest request);
|
||||
|
||||
void voidClaim(Long id);
|
||||
|
||||
/**
|
||||
* A301-1 收款单查询回写认领结果:按来源单据编码匹配金蝶收款单,推进金蝶单据状态
|
||||
*
|
||||
* @return 回写条数
|
||||
*/
|
||||
int refreshKingdeeResult();
|
||||
}
|
||||
|
||||
+1
-18
@@ -27,7 +27,6 @@ package org.springblade.transport.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import org.springblade.core.mp.base.BaseService;
|
||||
import org.springblade.transport.pojo.dto.ReceiptClaimPushResult;
|
||||
import org.springblade.transport.pojo.dto.ReceiptClaimRequest;
|
||||
import org.springblade.transport.pojo.entity.KingdeeReceiptFlow;
|
||||
import org.springblade.transport.pojo.vo.ReceiptFlowVO;
|
||||
@@ -53,25 +52,9 @@ public interface IReceiptFlowService extends BaseService<KingdeeReceiptFlow> {
|
||||
Long claim(ReceiptClaimRequest request);
|
||||
|
||||
/**
|
||||
* 手动同步金蝶收款流水:直连 A301-2 收款认领公告查询,按明细流水号幂等 upsert 收款流水表
|
||||
* 手动同步金蝶收款流水:直连 A301-1 收款单查询(收款处理查询),按流水分录明细流水号幂等 upsert 收款流水表
|
||||
*
|
||||
* @return 本次同步条数
|
||||
*/
|
||||
int sync();
|
||||
|
||||
/**
|
||||
* 手动推送金蝶(A301-3 收款认领结果推送):认领完成且未推送过的流水才可推送,
|
||||
* 推送前以当前用户姓名重查 A301-2 最新公告对账。与定时任务共用同一推送实现。
|
||||
*
|
||||
* @param flowId 收款流水id
|
||||
* @return 推送结果(失败不抛异常,原因随结果返回)
|
||||
*/
|
||||
ReceiptClaimPushResult pushClaimToKingdee(Long flowId);
|
||||
|
||||
/**
|
||||
* 定时推送金蝶:扫描认领完成且未推送过的流水,按认领人姓名分组重查 A301-2 最新公告后逐条推送。
|
||||
*
|
||||
* @return 执行摘要
|
||||
*/
|
||||
String pushClaimByTask();
|
||||
}
|
||||
|
||||
+1
-1
@@ -310,7 +310,7 @@ public class CommonAddressServiceImpl extends BaseServiceImpl<CommonAddressMappe
|
||||
|
||||
private void fillDisplay(CommonAddressVO address) {
|
||||
address.setSiteCodeDisplay(TYPE_NORMAL.equals(address.getAddressType()) ? NORMAL_SITE_CODE : address.getSiteCode());
|
||||
address.setReadonly(!TransportBusinessSupport.isAdministrator() && !Objects.equals(address.getDeptId(), currentDeptId()));
|
||||
address.setReadonly(TransportBusinessSupport.isDeptReadonly(address.getDeptId(), TransportBusinessSupport.MENU_COMMON_ADDRESS));
|
||||
address.setCreateUserName(UserCache.getUserRealName(address.getCreateUser()));
|
||||
address.setUpdateUserName(UserCache.getUserRealName(address.getUpdateUser()));
|
||||
}
|
||||
|
||||
+82
-6
@@ -36,12 +36,15 @@ import org.springblade.core.tool.utils.BeanUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.constant.HcsSyncConstant;
|
||||
import org.springblade.transport.mapper.ContractChangeMapper;
|
||||
import org.springblade.transport.support.hcs.HcsCreditCheckSupport;
|
||||
import org.springblade.transport.pojo.entity.ContractChange;
|
||||
import org.springblade.transport.pojo.entity.CustomerArchive;
|
||||
import org.springblade.transport.pojo.entity.ContractManage;
|
||||
import org.springblade.transport.pojo.vo.ContractChangeVO;
|
||||
import org.springblade.transport.pojo.vo.ContractManageVO;
|
||||
import org.springblade.transport.service.IContractChangeService;
|
||||
import org.springblade.transport.service.IContractManageService;
|
||||
import org.springblade.transport.support.ContractPartySupport;
|
||||
import org.springblade.transport.support.TransportBusinessSupport;
|
||||
import org.springblade.transport.wrapper.ContractChangeWrapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -77,12 +80,14 @@ public class ContractChangeServiceImpl extends BaseServiceImpl<ContractChangeMap
|
||||
private static final DateTimeFormatter CODE_DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
|
||||
|
||||
private final IContractManageService contractManageService;
|
||||
private final HcsCreditCheckSupport hcsCreditCheckSupport;
|
||||
|
||||
@Override
|
||||
public IPage<ContractChangeVO> selectContractChangePage(IPage<ContractChange> page, ContractChangeVO contractChange) {
|
||||
IPage<ContractChange> entityPage = page(page, buildQuery(contractChange).orderByDesc(ContractChange::getCreateTime));
|
||||
IPage<ContractChangeVO> voPage = ContractChangeWrapper.build().pageVO(entityPage);
|
||||
voPage.getRecords().forEach(this::fillReadonly);
|
||||
this.fillContractRisk(voPage.getRecords());
|
||||
return voPage;
|
||||
}
|
||||
|
||||
@@ -90,6 +95,7 @@ public class ContractChangeServiceImpl extends BaseServiceImpl<ContractChangeMap
|
||||
public ContractChangeVO detail(Long id) {
|
||||
ContractChangeVO contractChangeVO = ContractChangeWrapper.build().entityVO(loadExists(id));
|
||||
fillReadonly(contractChangeVO);
|
||||
this.fillContractRisk(java.util.Collections.singletonList(contractChangeVO));
|
||||
return contractChangeVO;
|
||||
}
|
||||
|
||||
@@ -97,10 +103,40 @@ public class ContractChangeServiceImpl extends BaseServiceImpl<ContractChangeMap
|
||||
@TenantIgnore
|
||||
public ContractChangeVO publicDetail(Long id) {
|
||||
ContractChangeVO contractChangeVO = ContractChangeWrapper.build().entityVO(loadExists(id));
|
||||
this.fillContractRisk(java.util.Collections.singletonList(contractChangeVO));
|
||||
contractChangeVO.setReadonly(Boolean.TRUE);
|
||||
return contractChangeVO;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装客商风险展示数据(变更列表/详情共用,复用 HcsCreditCheckSupport 公共查询):
|
||||
* 等级取甲乙双方较高值、黑名单任一方金蝶命中记是;名称关联不到或内部组织客商按无数据处理
|
||||
*/
|
||||
private void fillContractRisk(java.util.List<ContractChangeVO> records) {
|
||||
if (Func.isEmpty(records)) {
|
||||
return;
|
||||
}
|
||||
java.util.Set<String> partyNames = new java.util.HashSet<>();
|
||||
records.forEach(change -> {
|
||||
if (Func.isNotEmpty(change.getPartyA())) {
|
||||
partyNames.add(change.getPartyA().trim());
|
||||
}
|
||||
if (Func.isNotEmpty(change.getPartyB())) {
|
||||
partyNames.add(change.getPartyB().trim());
|
||||
}
|
||||
});
|
||||
java.util.Map<String, CustomerArchive> riskMap = this.hcsCreditCheckSupport.queryArchiveRiskMap(partyNames);
|
||||
for (ContractChangeVO change : records) {
|
||||
CustomerArchive partyARisk = Func.isEmpty(change.getPartyA()) ? null : riskMap.get(change.getPartyA().trim());
|
||||
CustomerArchive partyBRisk = Func.isEmpty(change.getPartyB()) ? null : riskMap.get(change.getPartyB().trim());
|
||||
change.setHcsRiskLevel(HcsCreditCheckSupport.higherRiskLevel(
|
||||
partyARisk == null ? null : partyARisk.getHcsRiskLevel(),
|
||||
partyBRisk == null ? null : partyBRisk.getHcsRiskLevel()));
|
||||
change.setKingdeeBlackStatus(
|
||||
HcsCreditCheckSupport.isKingdeeBlackHit(partyARisk) || HcsCreditCheckSupport.isKingdeeBlackHit(partyBRisk)
|
||||
? HcsSyncConstant.BLACK_STATUS_YES : HcsSyncConstant.BLACK_STATUS_NO);
|
||||
}
|
||||
}
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public ContractChangeVO createFromContract(Long contractId, String changeType) {
|
||||
@@ -190,7 +226,11 @@ public class ContractChangeServiceImpl extends BaseServiceImpl<ContractChangeMap
|
||||
String processor = TransportBusinessSupport.trimToNull(AuthUtil.getUserName());
|
||||
contractChange.setCurrentProcessor(processor == null ? "系统" : processor);
|
||||
contractChange.setApprovedTime(LocalDateTime.now());
|
||||
contractChange.setSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED);
|
||||
// 仅原合同已推送合规(有合规单据ID标识 hcs_fd_id)的变更单参与合规推送(C103-11),存量未推送合同不置同步状态
|
||||
ContractManage contractManage = contractManageService.getById(contractChange.getContractId());
|
||||
if (contractManage != null && Func.isNotBlank(contractManage.getHcsFdId())) {
|
||||
contractChange.setSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED);
|
||||
}
|
||||
updateById(contractChange);
|
||||
contractManageService.applyChangeFromRecord(contractChange);
|
||||
return contractChange.getContractId();
|
||||
@@ -253,6 +293,20 @@ public class ContractChangeServiceImpl extends BaseServiceImpl<ContractChangeMap
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean writeBackSyncStatus(Long contractId, String syncStatus) {
|
||||
if (contractId == null) {
|
||||
return false;
|
||||
}
|
||||
// 仅回写该合同最近一条审批通过且未同步/失败的变更单,历史已闭环单据不翻动
|
||||
return update(Wrappers.<ContractChange>lambdaUpdate()
|
||||
.eq(ContractChange::getContractId, contractId)
|
||||
.eq(ContractChange::getApprovalStatus, STATUS_APPROVED)
|
||||
.in(ContractChange::getSyncStatus, List.of(HcsSyncConstant.HCS_STATUS_UNSYNCED, HcsSyncConstant.HCS_STATUS_FAILED))
|
||||
.set(ContractChange::getSyncStatus, syncStatus)
|
||||
.last("order by approved_time desc limit 1"));
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<ContractChange> buildQuery(ContractChangeVO contractChange) {
|
||||
TransportBusinessSupport.validateAllDept(contractChange.getAllDept(), "合同变更");
|
||||
LambdaQueryWrapper<ContractChange> queryWrapper = Wrappers.<ContractChange>lambdaQuery().eq(ContractChange::getIsDeleted, 0);
|
||||
@@ -263,9 +317,12 @@ public class ContractChangeServiceImpl extends BaseServiceImpl<ContractChangeMap
|
||||
} else if (!Objects.equals(contractChange.getAllDept(), 1)) {
|
||||
Long currentDeptId = TransportBusinessSupport.currentDeptId("合同变更");
|
||||
Long currentUserId = AuthUtil.getUserId();
|
||||
queryWrapper.and(wrapper -> wrapper.eq(ContractChange::getOrganizationId, currentDeptId)
|
||||
.or().eq(ContractChange::getCreateUser, currentUserId)
|
||||
.or().eq(ContractChange::getHandlerUserId, currentUserId));
|
||||
queryWrapper.and(wrapper -> {
|
||||
wrapper.eq(ContractChange::getOrganizationId, currentDeptId)
|
||||
.or().eq(ContractChange::getCreateUser, currentUserId)
|
||||
.or().eq(ContractChange::getHandlerUserId, currentUserId);
|
||||
TransportBusinessSupport.orProjectVisibleByBusinessDept(wrapper, List.of(currentDeptId));
|
||||
});
|
||||
} else if (Func.isNotEmpty(contractChange.getOrganizationId())) {
|
||||
queryWrapper.eq(ContractChange::getOrganizationId, contractChange.getOrganizationId());
|
||||
}
|
||||
@@ -305,7 +362,20 @@ public class ContractChangeServiceImpl extends BaseServiceImpl<ContractChangeMap
|
||||
target.setSignType(source.getSignType());
|
||||
target.setContractNo(source.getContractNo());
|
||||
target.setContractName(source.getContractName());
|
||||
target.setPartyB(source.getPartyB());
|
||||
target.setPartyAJson(source.getPartyAJson());
|
||||
target.setPartyBJson(source.getPartyBJson());
|
||||
target.setPartyCJson(source.getPartyCJson());
|
||||
ContractManage partyHolder = new ContractManage();
|
||||
partyHolder.setPartyAJson(target.getPartyAJson());
|
||||
partyHolder.setPartyBJson(target.getPartyBJson());
|
||||
partyHolder.setPartyCJson(target.getPartyCJson());
|
||||
ContractPartySupport.normalizeAndFillSummary(partyHolder);
|
||||
target.setPartyAJson(partyHolder.getPartyAJson());
|
||||
target.setPartyBJson(partyHolder.getPartyBJson());
|
||||
target.setPartyCJson(partyHolder.getPartyCJson());
|
||||
target.setPartyA(partyHolder.getPartyA());
|
||||
target.setPartyB(partyHolder.getPartyB());
|
||||
target.setPartyC(partyHolder.getPartyC());
|
||||
target.setStartDate(source.getStartDate());
|
||||
target.setEndDate(source.getEndDate());
|
||||
target.setContractFormat(source.getContractFormat());
|
||||
@@ -338,7 +408,12 @@ public class ContractChangeServiceImpl extends BaseServiceImpl<ContractChangeMap
|
||||
data.put("contractNo", contractManage.getContractNo());
|
||||
data.put("contractName", contractManage.getContractName());
|
||||
data.put("signType", contractManage.getSignType());
|
||||
data.put("partyA", contractManage.getPartyA());
|
||||
data.put("partyB", contractManage.getPartyB());
|
||||
data.put("partyC", contractManage.getPartyC());
|
||||
data.put("partyAJson", contractManage.getPartyAJson());
|
||||
data.put("partyBJson", contractManage.getPartyBJson());
|
||||
data.put("partyCJson", contractManage.getPartyCJson());
|
||||
data.put("startDate", contractManage.getStartDate());
|
||||
data.put("endDate", contractManage.getEndDate());
|
||||
data.put("contractFormat", contractManage.getContractFormat());
|
||||
@@ -451,7 +526,8 @@ public class ContractChangeServiceImpl extends BaseServiceImpl<ContractChangeMap
|
||||
return true;
|
||||
}
|
||||
Long currentDeptId = TransportBusinessSupport.currentDeptId("合同变更");
|
||||
return currentDeptId != null && Objects.equals(contractChange.getOrganizationId(), currentDeptId);
|
||||
return currentDeptId != null && (Objects.equals(contractChange.getOrganizationId(), currentDeptId)
|
||||
|| TransportBusinessSupport.isProjectOwnedByBusinessDept(contractChange.getProjectId(), currentDeptId));
|
||||
}
|
||||
|
||||
private void fillReadonly(ContractChangeVO contractChangeVO) {
|
||||
|
||||
+217
-49
@@ -36,6 +36,7 @@ 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.BusinessDeptConfig;
|
||||
import org.springblade.system.pojo.entity.Dept;
|
||||
import org.springblade.transport.constant.HcsSyncConstant;
|
||||
import org.springblade.transport.excel.ContractManageExcel;
|
||||
@@ -43,12 +44,15 @@ import org.springblade.transport.mapper.ContractManageMapper;
|
||||
import org.springblade.transport.mapper.HcsContractExeStageMapper;
|
||||
import org.springblade.transport.pojo.entity.ContractChange;
|
||||
import org.springblade.transport.pojo.entity.ContractManage;
|
||||
import org.springblade.transport.pojo.entity.CustomerArchive;
|
||||
import org.springblade.transport.pojo.entity.HcsContractExeStage;
|
||||
import org.springblade.transport.pojo.vo.ContractManageVO;
|
||||
import org.springblade.transport.service.IContractExeStageService;
|
||||
import org.springblade.transport.service.IContractManageService;
|
||||
import org.springblade.transport.support.ContractPartySupport;
|
||||
import org.springblade.transport.support.TransportBusinessSupport;
|
||||
import org.springblade.transport.support.hcs.HcsCreditCheckSupport;
|
||||
import org.springblade.transport.support.hcs.HcsImmediatePushSupport;
|
||||
import org.springblade.transport.wrapper.ContractManageWrapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -58,7 +62,6 @@ import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@@ -67,6 +70,8 @@ import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* 合同管理 服务实现类
|
||||
@@ -80,6 +85,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
|
||||
private final HcsContractExeStageMapper hcsContractExeStageMapper;
|
||||
private final HcsCreditCheckSupport hcsCreditCheckSupport;
|
||||
private final HcsImmediatePushSupport hcsImmediatePushSupport;
|
||||
private final IContractExeStageService contractExeStageService;
|
||||
|
||||
private static final String STAGE_DRAFT = "draft";
|
||||
@@ -108,6 +114,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
IPage<ContractManage> entityPage = page(page, buildQuery(contractManage));
|
||||
IPage<ContractManageVO> voPage = ContractManageWrapper.build().pageVO(entityPage);
|
||||
voPage.getRecords().forEach(this::fillReadonly);
|
||||
this.fillContractRisk(voPage.getRecords());
|
||||
return voPage;
|
||||
}
|
||||
|
||||
@@ -115,6 +122,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
public ContractManageVO detail(Long id) {
|
||||
ContractManageVO contractManageVO = ContractManageWrapper.build().entityVO(loadExists(id));
|
||||
fillReadonly(contractManageVO);
|
||||
this.fillContractRisk(java.util.Collections.singletonList(contractManageVO));
|
||||
return contractManageVO;
|
||||
}
|
||||
|
||||
@@ -127,6 +135,45 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
return contractManageVO;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装客商风险展示数据(列表/详情共用,复用 HcsCreditCheckSupport 公共查询):
|
||||
* 等级取甲乙双方较高值、黑名单任一方金蝶命中记是;名称关联不到或内部组织客商按无数据处理,前端展示"-"
|
||||
*/
|
||||
private void fillContractRisk(List<ContractManageVO> records) {
|
||||
if (Func.isEmpty(records)) {
|
||||
return;
|
||||
}
|
||||
Set<String> partyNames = new HashSet<>();
|
||||
records.forEach(contract -> {
|
||||
if (Func.isNotEmpty(contract.getPartyA())) {
|
||||
partyNames.add(contract.getPartyA().trim());
|
||||
}
|
||||
if (Func.isNotEmpty(contract.getPartyB())) {
|
||||
partyNames.add(contract.getPartyB().trim());
|
||||
}
|
||||
});
|
||||
Map<String, CustomerArchive> riskMap = this.hcsCreditCheckSupport.queryArchiveRiskMap(partyNames);
|
||||
for (ContractManageVO contract : records) {
|
||||
CustomerArchive partyARisk = Func.isEmpty(contract.getPartyA()) ? null : riskMap.get(contract.getPartyA().trim());
|
||||
CustomerArchive partyBRisk = Func.isEmpty(contract.getPartyB()) ? null : riskMap.get(contract.getPartyB().trim());
|
||||
contract.setHcsRiskLevel(HcsCreditCheckSupport.higherRiskLevel(
|
||||
partyARisk == null ? null : partyARisk.getHcsRiskLevel(),
|
||||
partyBRisk == null ? null : partyBRisk.getHcsRiskLevel()));
|
||||
contract.setKingdeeBlackStatus(
|
||||
HcsCreditCheckSupport.isKingdeeBlackHit(partyARisk) || HcsCreditCheckSupport.isKingdeeBlackHit(partyBRisk)
|
||||
? HcsSyncConstant.BLACK_STATUS_YES : HcsSyncConstant.BLACK_STATUS_NO);
|
||||
// 悬浮明细与客商列表/表单实时提示同口径(buildRiskDetail 统一组装),有风险信息的甲乙方段拼接展示
|
||||
String partyADetail = partyARisk == null ? ""
|
||||
: HcsCreditCheckSupport.buildRiskDetail(contract.getPartyA().trim(), partyARisk.getHcsRiskLevel(),
|
||||
partyARisk.getKingdeeBlackCategory(), partyARisk.getKingdeeBlackReason());
|
||||
String partyBDetail = partyBRisk == null ? ""
|
||||
: HcsCreditCheckSupport.buildRiskDetail(contract.getPartyB().trim(), partyBRisk.getHcsRiskLevel(),
|
||||
partyBRisk.getKingdeeBlackCategory(), partyBRisk.getKingdeeBlackReason());
|
||||
contract.setHcsRiskDetail(Stream.of(partyADetail, partyBDetail)
|
||||
.filter(Func::isNotEmpty).collect(Collectors.joining(";")));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean saveDraft(ContractManage contractManage) {
|
||||
@@ -403,7 +450,10 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
request.setSignType(change.getSignType());
|
||||
request.setContractNo(change.getContractNo());
|
||||
request.setContractName(change.getContractName());
|
||||
request.setPartyB(change.getPartyB());
|
||||
request.setPartyAJson(change.getPartyAJson());
|
||||
request.setPartyBJson(change.getPartyBJson());
|
||||
request.setPartyCJson(change.getPartyCJson());
|
||||
ContractPartySupport.normalizeAndFillSummary(request);
|
||||
request.setStartDate(change.getStartDate());
|
||||
request.setEndDate(change.getEndDate());
|
||||
request.setContractFormat(change.getContractFormat());
|
||||
@@ -464,13 +514,23 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
TransportBusinessSupport.validateRequired(request.getSettlementCurrency(), "请选择结算币种");
|
||||
ContractManage candidate = new ContractManage();
|
||||
BeanUtil.copyProperties(source, candidate);
|
||||
candidate.setPartyB(TransportBusinessSupport.trimToNull(request.getPartyB()));
|
||||
candidate.setPartyAJson(request.getPartyAJson());
|
||||
candidate.setPartyBJson(request.getPartyBJson());
|
||||
candidate.setPartyCJson(request.getPartyCJson());
|
||||
ContractPartySupport.normalizeAndFillSummary(candidate);
|
||||
ContractPartySupport.validateRequiredParties(candidate, true);
|
||||
ContractPartySupport.validateBillingPlanBindings(candidate);
|
||||
validateContractPartyCombinationUnique(candidate);
|
||||
validatePaymentRatios(request.getPaymentRatioJson());
|
||||
applyContractNo(source, request.getContractNo(), false);
|
||||
source.setContractName(request.getContractName());
|
||||
source.setSignType(request.getSignType());
|
||||
source.setPartyB(request.getPartyB());
|
||||
source.setPartyAJson(candidate.getPartyAJson());
|
||||
source.setPartyBJson(candidate.getPartyBJson());
|
||||
source.setPartyCJson(candidate.getPartyCJson());
|
||||
source.setPartyA(candidate.getPartyA());
|
||||
source.setPartyB(candidate.getPartyB());
|
||||
source.setPartyC(candidate.getPartyC());
|
||||
source.setStartDate(request.getStartDate());
|
||||
source.setEndDate(request.getEndDate());
|
||||
source.setContractFormat(request.getContractFormat());
|
||||
@@ -510,11 +570,20 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
|| !List.of(STATUS_APPROVED, STATUS_CHANGE_APPROVED).contains(source.getApprovalStatus())) {
|
||||
throw new ServiceException("仅审批通过的正式合同允许上传附件");
|
||||
}
|
||||
String nextContractFileJson = mergeApprovedContractFiles(source.getContractFileJson(), contractManage.getContractFileJson());
|
||||
String originalContractFileJson = source.getContractFileJson();
|
||||
String nextContractFileJson = mergeApprovedContractFiles(originalContractFileJson, contractManage.getContractFileJson());
|
||||
source.setContractFileJson(nextContractFileJson);
|
||||
source.setAttachmentsJson(TransportBusinessSupport.trimToNull(contractManage.getAttachmentsJson()));
|
||||
refreshArchiveStatusOnApprove(source);
|
||||
return updateById(source);
|
||||
boolean saved = updateById(source);
|
||||
// 本次新增双章归档文件:事务提交后即时推送合规归档状态(C103-11 带 fdArchiveStatus=toarchive + 归档附件挂其他附件面板);
|
||||
// 合同尚未发起合规(无 hcs_fd_id)时首次推送报文自带归档状态,无需单独推送
|
||||
String newSealFilesJson = extractNewSealFilesJson(originalContractFileJson, contractManage.getContractFileJson());
|
||||
if (saved && newSealFilesJson != null && Func.isNotEmpty(source.getHcsFdId())) {
|
||||
log.info("合同上传双章归档文件触发合规归档推送:contractNo={},hcsFdId={}", source.getContractNo(), source.getHcsFdId());
|
||||
hcsImmediatePushSupport.pushContractArchiveAfterCommit(source.getId(), newSealFilesJson);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -610,9 +679,12 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
} else if (!Objects.equals(contractManage.getAllDept(), 1)) {
|
||||
Long currentDeptId = TransportBusinessSupport.currentDeptId("合同管理");
|
||||
Long currentUserId = AuthUtil.getUserId();
|
||||
queryWrapper.and(wrapper -> wrapper.eq(ContractManage::getOrganizationId, currentDeptId)
|
||||
.or().eq(ContractManage::getCreateUser, currentUserId)
|
||||
.or().eq(ContractManage::getHandlerUserId, currentUserId));
|
||||
queryWrapper.and(wrapper -> {
|
||||
wrapper.eq(ContractManage::getOrganizationId, currentDeptId)
|
||||
.or().eq(ContractManage::getCreateUser, currentUserId)
|
||||
.or().eq(ContractManage::getHandlerUserId, currentUserId);
|
||||
TransportBusinessSupport.orProjectVisibleByBusinessDept(wrapper, List.of(currentDeptId));
|
||||
});
|
||||
} else if (Func.isNotEmpty(contractManage.getOrganizationId())) {
|
||||
queryWrapper.eq(ContractManage::getOrganizationId, contractManage.getOrganizationId());
|
||||
}
|
||||
@@ -632,6 +704,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
.eq(Func.isNotEmpty(contractManage.getArchiveStatus()), ContractManage::getArchiveStatus, contractManage.getArchiveStatus())
|
||||
.eq(Func.isNotEmpty(contractManage.getContractStage()), ContractManage::getContractStage, contractManage.getContractStage())
|
||||
.orderByDesc(ContractManage::getCreateTime);
|
||||
applyPartyName(queryWrapper, contractManage.getPartyName());
|
||||
applyEffectiveType(queryWrapper, contractManage.getEffectiveType());
|
||||
applyExpireScope(queryWrapper, contractManage.getExpireScope());
|
||||
// 是否同步金蝶(kingdee_id 是否已回填)/ 是否同步合规(hcs_sync_status 是否 synced)
|
||||
@@ -657,6 +730,18 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 客商筛选:匹配合同甲/乙/丙方名称(模糊)
|
||||
*/
|
||||
private void applyPartyName(LambdaQueryWrapper<ContractManage> queryWrapper, String partyName) {
|
||||
if (Func.isEmpty(partyName)) {
|
||||
return;
|
||||
}
|
||||
queryWrapper.and(wrapper -> wrapper.like(ContractManage::getPartyA, partyName)
|
||||
.or().like(ContractManage::getPartyB, partyName)
|
||||
.or().like(ContractManage::getPartyC, partyName));
|
||||
}
|
||||
|
||||
private void applyEffectiveType(LambdaQueryWrapper<ContractManage> queryWrapper, String effectiveType) {
|
||||
if (Func.isEmpty(effectiveType)) {
|
||||
return;
|
||||
@@ -686,7 +771,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
.lt(ContractManage::getTemporaryEndDate, today)
|
||||
.or(wrapper2 -> wrapper2.ne(ContractManage::getContractStage, STAGE_TEMPORARY)
|
||||
.lt(ContractManage::getEndDate, today)));
|
||||
default -> throw new ServiceException("无效的生效类型筛选条件");
|
||||
default -> throw new ServiceException("无效的合同状态筛选条件");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -723,6 +808,10 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
contractManage.setOrganizationId(oldRecord.getOrganizationId());
|
||||
contractManage.setOrganizationName(oldRecord.getOrganizationName());
|
||||
}
|
||||
if (Func.isEmpty(contractManage.getPlatformCompanyId())) {
|
||||
contractManage.setPlatformCompanyId(oldRecord.getPlatformCompanyId());
|
||||
contractManage.setPlatformCompanyName(oldRecord.getPlatformCompanyName());
|
||||
}
|
||||
contractManage.setHandlerUserId(oldRecord.getHandlerUserId());
|
||||
contractManage.setHandlerUserName(oldRecord.getHandlerUserName());
|
||||
// 归档状态仅由审核通过逻辑自动维护,保存时保留库中已有值。
|
||||
@@ -747,7 +836,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
if (!generateWhenEmpty) {
|
||||
throw new ServiceException("请输入合同编号");
|
||||
}
|
||||
normalizedContractNo = nextContractNo(contractManage.getOrganizationId());
|
||||
normalizedContractNo = nextContractNo(contractManage.getOrganizationId(), contractManage.getPlatformCompanyId());
|
||||
}
|
||||
TransportBusinessSupport.validateLength(normalizedContractNo, 100, "合同编号不能超过100字");
|
||||
String currentContractNo = TransportBusinessSupport.trimToNull(contractManage.getContractNo());
|
||||
@@ -806,30 +895,39 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized String nextContractNo(Long organizationId) {
|
||||
public synchronized String nextContractNo(Long organizationId, Long platformCompanyId) {
|
||||
if (Func.isEmpty(organizationId)) {
|
||||
throw new ServiceException("请选择合同负责部门");
|
||||
}
|
||||
if (Func.isEmpty(platformCompanyId)) {
|
||||
throw new ServiceException("请选择平台公司");
|
||||
}
|
||||
Dept dept = SysCache.getDept(organizationId);
|
||||
if (dept == null) {
|
||||
throw new ServiceException("合同负责部门不存在");
|
||||
}
|
||||
String prefix = TransportBusinessSupport.trimToNull(dept.getContractCodePrefix());
|
||||
Dept platformCompany = SysCache.getDept(platformCompanyId);
|
||||
if (platformCompany == null) {
|
||||
throw new ServiceException("平台公司不存在");
|
||||
}
|
||||
BusinessDeptConfig config = SysCache.getBusinessDeptConfig(platformCompanyId, organizationId);
|
||||
String prefix = config == null ? null : TransportBusinessSupport.trimToNull(config.getContractCodePrefix());
|
||||
if (Func.isEmpty(prefix)) {
|
||||
throw new ServiceException("请先在组织机构中配置该部门的合同编码前缀");
|
||||
throw new ServiceException("部门合同前缀未配置,无法自动生成,请手动输入合同编号");
|
||||
}
|
||||
int year = LocalDate.now().getYear();
|
||||
String head = prefix + "(" + year + ")";
|
||||
// 按前缀+年份扫描已占用流水号;系统已有相同流水号则跳过
|
||||
List<ContractManage> latestList = list(Wrappers.<ContractManage>lambdaQuery()
|
||||
.select(ContractManage::getContractNo)
|
||||
.eq(ContractManage::getIsDeleted, 0)
|
||||
.eq(ContractManage::getOrganizationId, organizationId)
|
||||
.likeRight(ContractManage::getContractNo, head)
|
||||
.orderByDesc(ContractManage::getContractNo)
|
||||
.last("LIMIT 50"));
|
||||
.last("LIMIT 200"));
|
||||
Pattern pattern = Pattern.compile("^" + Pattern.quote(prefix) + "(" + year + ")(\\d{3})号$");
|
||||
// 当年尚无匹配编号时,从组织配置的初始流水号起编;否则在已有最大流水号上加1
|
||||
int next = resolveContractInitialSerial(dept);
|
||||
int initialSerial = resolveContractInitialSerial(config);
|
||||
int maxUsed = 0;
|
||||
boolean hasMatched = false;
|
||||
for (ContractManage item : latestList) {
|
||||
String contractNo = TransportBusinessSupport.trimToNull(item.getContractNo());
|
||||
if (Func.isEmpty(contractNo)) {
|
||||
@@ -839,22 +937,24 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
if (!matcher.matches()) {
|
||||
continue;
|
||||
}
|
||||
next = Integer.parseInt(matcher.group(1)) + 1;
|
||||
break;
|
||||
hasMatched = true;
|
||||
maxUsed = Math.max(maxUsed, Integer.parseInt(matcher.group(1)));
|
||||
}
|
||||
// 从初始流水号起编;若系统已有相同或更大流水号则跳到 maxUsed+1
|
||||
int next = hasMatched ? Math.max(initialSerial, maxUsed + 1) : initialSerial;
|
||||
if (next > 999) {
|
||||
throw new ServiceException("当前部门本年合同流水号已达上限999");
|
||||
}
|
||||
return head + String.format("%03d", next) + "号";
|
||||
}
|
||||
|
||||
private int resolveContractInitialSerial(Dept dept) {
|
||||
Integer serial = dept.getContractInitialSerial();
|
||||
private int resolveContractInitialSerial(BusinessDeptConfig config) {
|
||||
Integer serial = config == null ? null : config.getInitialSerial();
|
||||
if (serial == null || serial < 1) {
|
||||
return 1;
|
||||
}
|
||||
if (serial > 999) {
|
||||
throw new ServiceException("组织机构中配置的合同初始流水号不能超过999");
|
||||
throw new ServiceException("业务部门配置的合同初始流水号不能超过999");
|
||||
}
|
||||
return serial;
|
||||
}
|
||||
@@ -877,6 +977,29 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取本次新增的双章归档文件 JSON(按附件键排除原有行),供合规归档推送挂载其他附件面板;无新增返回 null
|
||||
*/
|
||||
private String extractNewSealFilesJson(String originalJson, String requestJson) {
|
||||
Set<String> originalKeys = new HashSet<>();
|
||||
for (Object item : parseJsonArray(originalJson)) {
|
||||
originalKeys.add(attachmentKey(item));
|
||||
}
|
||||
List<Object> newSealFiles = new ArrayList<>();
|
||||
for (Object item : parseJsonArray(requestJson)) {
|
||||
if (originalKeys.contains(attachmentKey(item))) {
|
||||
continue;
|
||||
}
|
||||
if (item instanceof Map<?, ?> map) {
|
||||
Object fileType = map.get("fileType");
|
||||
if (fileType != null && FILE_TYPE_SEAL_ARCHIVE.equals(String.valueOf(fileType).trim())) {
|
||||
newSealFiles.add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
return newSealFiles.isEmpty() ? null : JsonUtil.toJson(newSealFiles);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private String markContractFilesApproved(String contractFileJson) {
|
||||
List<Object> files = parseJsonArray(contractFileJson);
|
||||
@@ -948,8 +1071,10 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
contractManage.setOrganizationName(TransportBusinessSupport.trimToNull(contractManage.getOrganizationName()));
|
||||
contractManage.setContractCategory(TransportBusinessSupport.trimToNull(contractManage.getContractCategory()));
|
||||
contractManage.setSignType(TransportBusinessSupport.trimToNull(contractManage.getSignType()));
|
||||
contractManage.setPartyA(TransportBusinessSupport.trimToNull(contractManage.getPartyA()));
|
||||
contractManage.setPartyB(TransportBusinessSupport.trimToNull(contractManage.getPartyB()));
|
||||
contractManage.setPartyAJson(TransportBusinessSupport.trimToNull(contractManage.getPartyAJson()));
|
||||
contractManage.setPartyBJson(TransportBusinessSupport.trimToNull(contractManage.getPartyBJson()));
|
||||
contractManage.setPartyCJson(TransportBusinessSupport.trimToNull(contractManage.getPartyCJson()));
|
||||
ContractPartySupport.normalizeAndFillSummary(contractManage);
|
||||
contractManage.setSettlementMode(TransportBusinessSupport.trimToNull(contractManage.getSettlementMode()));
|
||||
contractManage.setContractFormat(TransportBusinessSupport.trimToNull(contractManage.getContractFormat()));
|
||||
contractManage.setRemark(TransportBusinessSupport.trimToNull(contractManage.getRemark()));
|
||||
@@ -1006,6 +1131,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
// 合同名称允许重复,唯一性由合同编号保证。
|
||||
TransportBusinessSupport.validateLength(contractManage.getMainContent(), 1000, "合同主要内容不能超过1000字");
|
||||
TransportBusinessSupport.validateLength(contractManage.getRemark(), 2000, "其他事项说明不能超过2000字");
|
||||
// 草稿不强制甲/乙客商与计费绑定,提交临时/正式时再完整校验
|
||||
}
|
||||
|
||||
private void validateBillingPlans(String billingPlanJson) {
|
||||
@@ -1017,18 +1143,11 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
if (!(parsed instanceof List<?> plans)) {
|
||||
throw new ServiceException("计费方案设置格式不正确");
|
||||
}
|
||||
Map<String, Integer> defaultPlanCount = new HashMap<>();
|
||||
for (Object value : plans) {
|
||||
if (!(value instanceof Map<?, ?> plan)) {
|
||||
throw new ServiceException("计费方案设置格式不正确");
|
||||
}
|
||||
String transportMode = trimValue(plan.get("transportMode"));
|
||||
if (isDefaultBillingPlan(plan)) {
|
||||
int count = defaultPlanCount.merge(transportMode, 1, Integer::sum);
|
||||
if (count > 1) {
|
||||
throw new ServiceException("同一运输方式仅支持配置一个默认计费方案");
|
||||
}
|
||||
}
|
||||
// 一个客商对应一个方案,不再校验「默认方案」
|
||||
validateBillingMatchConditions(plan);
|
||||
validateBillingRuleTaxRates(plan);
|
||||
}
|
||||
@@ -1090,15 +1209,6 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
return value != null && !String.valueOf(value).trim().isEmpty();
|
||||
}
|
||||
|
||||
private boolean isDefaultBillingPlan(Map<?, ?> plan) {
|
||||
Object value = plan.get("defaultPlan");
|
||||
return Boolean.TRUE.equals(value) || "true".equalsIgnoreCase(String.valueOf(value)) || "1".equals(String.valueOf(value));
|
||||
}
|
||||
|
||||
private String trimValue(Object value) {
|
||||
return value == null ? null : TransportBusinessSupport.trimToNull(String.valueOf(value));
|
||||
}
|
||||
|
||||
private void validatePaymentRatios(String paymentRatioJson) {
|
||||
if (Func.isEmpty(paymentRatioJson)) return;
|
||||
try {
|
||||
@@ -1147,8 +1257,9 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
TransportBusinessSupport.validateRequired(contractManage.getContractCategory(), "请选择合同类型");
|
||||
TransportBusinessSupport.validateRequired(contractManage.getProjectName(), "请选择所属项目");
|
||||
TransportBusinessSupport.validateRequired(contractManage.getOrganizationName(), "请选择合同负责部门");
|
||||
TransportBusinessSupport.validateRequired(contractManage.getPartyA(), "请选择甲方");
|
||||
TransportBusinessSupport.validateRequired(contractManage.getPartyB(), "请选择乙方");
|
||||
TransportBusinessSupport.validateRequired(contractManage.getPlatformCompanyName(), "请选择平台公司");
|
||||
ContractPartySupport.validateRequiredParties(contractManage, true);
|
||||
ContractPartySupport.validateBillingPlanBindings(contractManage);
|
||||
validateContractAmountRequired(contractManage);
|
||||
}
|
||||
|
||||
@@ -1181,6 +1292,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
TransportBusinessSupport.validateRequired(contractManage.getContractCategory(), "请选择合同类型");
|
||||
TransportBusinessSupport.validateRequired(contractManage.getProjectName(), "请选择所属项目");
|
||||
TransportBusinessSupport.validateRequired(contractManage.getOrganizationName(), "请选择合同负责部门");
|
||||
TransportBusinessSupport.validateRequired(contractManage.getPlatformCompanyName(), "请选择平台公司");
|
||||
TransportBusinessSupport.validateRequired(contractManage.getSettlementCurrency(), "请选择结算币种");
|
||||
TransportBusinessSupport.validateRequired(contractManage.getPartyA(), "请输入甲方");
|
||||
TransportBusinessSupport.validateRequired(contractManage.getPartyB(), "请输入乙方");
|
||||
@@ -1283,11 +1395,12 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
record.put("handlerUserId", AuthUtil.getUserId());
|
||||
record.put("handlerUserName", AuthUtil.getUserName());
|
||||
record.put("changeType", changeType);
|
||||
record.put("changeContent", TransportBusinessSupport.trimToNull(contractManage.getChangeContent()));
|
||||
record.put("changeContent", resolveChangeRecordContent(contractManage, beforeData, afterData));
|
||||
record.put("changeReason", TransportBusinessSupport.trimToNull(changeReason));
|
||||
record.put("status", status);
|
||||
record.put("statusName", statusName);
|
||||
if (beforeData != null && afterData != null) {
|
||||
record.put("changedFields", new ArrayList<>(beforeData.keySet()));
|
||||
record.put("beforeData", beforeData);
|
||||
record.put("afterData", afterData);
|
||||
}
|
||||
@@ -1295,11 +1408,64 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
contractManage.setChangeRecordJson(JsonUtil.toJson(records));
|
||||
}
|
||||
|
||||
/**
|
||||
* 变更记录内容:有前后快照时生成「将【字段】由【旧值】变更为【新值】」对比文本(与项目变更记录一致),
|
||||
* 无快照(创建/回调等)时沿用填写的变更内容
|
||||
*/
|
||||
private String resolveChangeRecordContent(ContractManage contractManage, Map<String, Object> beforeData,
|
||||
Map<String, Object> afterData) {
|
||||
if (beforeData == null || afterData == null || beforeData.isEmpty()) {
|
||||
return TransportBusinessSupport.trimToNull(contractManage.getChangeContent());
|
||||
}
|
||||
return beforeData.entrySet().stream()
|
||||
.map(entry -> "将【" + contractChangeFieldLabel(entry.getKey()) + "】由【" + formatChangeRecordValue(entry.getValue())
|
||||
+ "】变更为【" + formatChangeRecordValue(afterData.get(entry.getKey())) + "】")
|
||||
.collect(Collectors.joining("、"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 合同变更快照字段中文名(change_record_json 展示用,与前端变更字段标签一致)
|
||||
*/
|
||||
private static final Map<String, String> CONTRACT_CHANGE_FIELD_LABELS = Map.ofEntries(
|
||||
Map.entry("contractNo", "合同编号"), Map.entry("contractName", "合同名称"),
|
||||
Map.entry("partyB", "乙方"), Map.entry("startDate", "开始日期"),
|
||||
Map.entry("endDate", "结束日期"), Map.entry("contractFormat", "合同格式"),
|
||||
Map.entry("settlementMode", "结算方式"), Map.entry("legalSealFlag", "是否需要加盖法人章"),
|
||||
Map.entry("copyCount", "一式份数"), Map.entry("settlementCurrency", "结算币种"),
|
||||
Map.entry("invoiceCycle", "开票周期(天)"), Map.entry("paymentDays", "回款账期(天)"),
|
||||
Map.entry("contractAmount", "合同金额"), Map.entry("templateFlag", "是否范本"),
|
||||
Map.entry("originalContractNo", "原件合同编号"), Map.entry("electronicSealFlag", "是否电子章"),
|
||||
Map.entry("mainContent", "合同主要内容"), Map.entry("remark", "其他事项说明"),
|
||||
Map.entry("feeGenerationMode", "费用生成模式"), Map.entry("billingPlanJson", "计费方案"),
|
||||
Map.entry("settlementRuleJson", "结算生成规则"), Map.entry("preSettlementConfigJson", "预结算配置"),
|
||||
Map.entry("formalSettlementConfigJson", "正式结算配置"), Map.entry("paymentRatioJson", "付款比例设置"),
|
||||
Map.entry("contractFileJson", "合同主文件"), Map.entry("attachmentsJson", "其它附件"),
|
||||
Map.entry("changeAttachmentsJson", "变更附件")
|
||||
);
|
||||
|
||||
private String contractChangeFieldLabel(String field) {
|
||||
return CONTRACT_CHANGE_FIELD_LABELS.getOrDefault(field, field);
|
||||
}
|
||||
|
||||
private String formatChangeRecordValue(Object value) {
|
||||
if (value == null || (value instanceof String text && text.isBlank())) {
|
||||
return "空";
|
||||
}
|
||||
String text = String.valueOf(value);
|
||||
// 计费方案/附件等长 JSON 文本截断,保证变更内容可读
|
||||
return text.length() > 100 ? text.substring(0, 100) + "…" : text;
|
||||
}
|
||||
|
||||
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("partyA", contractManage.getPartyA());
|
||||
data.put("partyB", contractManage.getPartyB());
|
||||
data.put("partyC", contractManage.getPartyC());
|
||||
data.put("partyAJson", contractManage.getPartyAJson());
|
||||
data.put("partyBJson", contractManage.getPartyBJson());
|
||||
data.put("partyCJson", contractManage.getPartyCJson());
|
||||
data.put("startDate", contractManage.getStartDate());
|
||||
data.put("endDate", contractManage.getEndDate());
|
||||
data.put("contractFormat", contractManage.getContractFormat());
|
||||
@@ -1321,9 +1487,10 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
data.put("preSettlementConfigJson", contractManage.getPreSettlementConfigJson());
|
||||
data.put("formalSettlementConfigJson", contractManage.getFormalSettlementConfigJson());
|
||||
data.put("paymentRatioJson", contractManage.getPaymentRatioJson());
|
||||
data.put("contractFileJson", contractManage.getContractFileJson());
|
||||
data.put("attachmentsJson", contractManage.getAttachmentsJson());
|
||||
data.put("changeAttachmentsJson", contractManage.getChangeAttachmentsJson());
|
||||
// 附件(合同主文件/其它附件/变更附件)先不记录到变更记录,仅保留业务字段对比,需要时放开以下注释
|
||||
// data.put("contractFileJson", contractManage.getContractFileJson());
|
||||
// data.put("attachmentsJson", contractManage.getAttachmentsJson());
|
||||
// data.put("changeAttachmentsJson", contractManage.getChangeAttachmentsJson());
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -1477,7 +1644,8 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
Long currentUserId = AuthUtil.getUserId();
|
||||
return Objects.equals(contractManage.getOrganizationId(), currentDeptId)
|
||||
|| Objects.equals(contractManage.getCreateUser(), currentUserId)
|
||||
|| Objects.equals(contractManage.getHandlerUserId(), currentUserId);
|
||||
|| Objects.equals(contractManage.getHandlerUserId(), currentUserId)
|
||||
|| TransportBusinessSupport.isProjectOwnedByBusinessDept(contractManage.getProjectId(), currentDeptId);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+134
-2
@@ -41,14 +41,22 @@ import org.springblade.core.tenant.annotation.TenantIgnore;
|
||||
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.cache.UserCache;
|
||||
import org.springblade.transport.constant.HcsSyncConstant;
|
||||
import org.springblade.transport.excel.CustomerArchiveExcel;
|
||||
import org.springblade.thirdparty.bfi.constant.BfiConstant;
|
||||
import org.springblade.thirdparty.bfi.pojo.dto.BfiBankQueryDTO;
|
||||
import org.springblade.thirdparty.bfi.pojo.dto.BfiCustomerQueryDTO;
|
||||
import org.springblade.thirdparty.bfi.pojo.dto.BfiSupplierQueryDTO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiBankVO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultErrorVO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultItemVO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiBlacklistVO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiCustomerVO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiPageVO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiSupplierVO;
|
||||
import org.springblade.thirdparty.bfi.service.IBfiService;
|
||||
import org.springblade.thirdparty.bfi.util.BfiUtil;
|
||||
import org.springblade.system.cache.SysCache;
|
||||
@@ -92,6 +100,7 @@ import org.springblade.transport.pojo.vo.CustomerCreditScoreVO;
|
||||
import org.springblade.transport.pojo.vo.CustomerInvoiceContactVO;
|
||||
import org.springblade.transport.pojo.vo.CustomerInvoiceInfoVO;
|
||||
import org.springblade.transport.pojo.vo.CustomerReceiptAccountVO;
|
||||
import org.springblade.transport.support.hcs.HcsCreditCheckSupport;
|
||||
import org.springblade.transport.service.ICustomerArchiveService;
|
||||
import org.springblade.transport.support.KingdeeSyncLockSupport;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -232,6 +241,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
|
||||
List<CustomerArchiveVO> records = baseMapper.selectCustomerArchivePage(page, customer, currentScopeUserId());
|
||||
records.forEach(record -> record.setCreateUserName(UserCache.getUserRealName(record.getCreateUser())));
|
||||
fillFundUseRisk(records);
|
||||
this.fillRiskDetail(records);
|
||||
return page.setRecords(records);
|
||||
}
|
||||
|
||||
@@ -276,6 +286,12 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
|
||||
if (created) {
|
||||
// 新增时金蝶已有客商,前端回填金蝶客商编码,建档直接沿用;未回填则自动生成
|
||||
customer.setCustomerCode(Func.isEmpty(customer.getCustomerCode()) ? this.nextCustomerCode() : customer.getCustomerCode());
|
||||
// 供应商编码:A901-1 按统一社会信用代码查金蝶供应商档案落库(金蝶单据推送直接复用,不再实时查接口),查不到留空待同步补
|
||||
String supplierCode = this.queryKingdeeSupplierNumber(customer.getUnifiedCreditCode());
|
||||
if (Func.isNotEmpty(supplierCode)) {
|
||||
customer.setSupplierCode(supplierCode);
|
||||
}
|
||||
log.info("新增客商A901-1供应商查询取供应商编码,unifiedCreditCode={},supplierCode={}", customer.getUnifiedCreditCode(), supplierCode);
|
||||
}
|
||||
CustomerArchive entity = Objects.requireNonNull(BeanUtil.copyProperties(customer, CustomerArchive.class));
|
||||
// 曾审核通过后的评分替换:保存/再提交时仍沿用原生效等级与额度,待再次审核通过后再切换
|
||||
@@ -517,9 +533,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())) {
|
||||
// 金蝶已有已审核档案:跳过推送,客商编号采纳金蝶客商编码
|
||||
// 金蝶已有已审核档案:跳过推送,客商编号采纳金蝶客商编码;供应商编码按 A901-1 供应商查询落库
|
||||
after.setKingdeeStatus(KINGDEE_STATUS_EXIST);
|
||||
after.setKingdeeFailReason(null);
|
||||
this.fillSupplierCode(customer, after);
|
||||
addChangeRecord(id, "金蝶已存在该客商档案,跳过推送" + this.adoptKingdeeCode(customer, after, kingdeeCustomer.getNumber()), customer, after);
|
||||
updateById(after);
|
||||
return after;
|
||||
@@ -619,6 +636,105 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
|
||||
return rows == null || rows.isEmpty() ? null : rows.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* A901-1 按统一社会信用代码查询金蝶供应商档案取金蝶客商编码(返回值 number),新增/同步建档时落库客商表,
|
||||
* 后续金蝶推送直接复用客商表编号不再实时查接口;接口异常返回 null 不阻断建档,金蝶无档案同样返回 null 由调用方兜底
|
||||
*/
|
||||
private String queryKingdeeSupplierNumber(String unifiedCreditCode) {
|
||||
if (Func.isEmpty(unifiedCreditCode)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
BfiSupplierQueryDTO param = new BfiSupplierQueryDTO();
|
||||
param.setPageNo(1);
|
||||
param.setPageSize(1);
|
||||
param.setEnable(BfiConstant.ENABLE_ENABLED);
|
||||
param.setSocietycreditcode(List.of(unifiedCreditCode.trim()));
|
||||
BfiPageVO<BfiSupplierVO> page = bfiService.querySupplier(param);
|
||||
List<BfiSupplierVO> rows = page == null ? null : page.getRows();
|
||||
return rows == null || rows.isEmpty() ? null : rows.get(0).getNumber();
|
||||
} catch (Exception exception) {
|
||||
log.warn("A901-1供应商查询金蝶客商编码失败,unifiedCreditCode={}", unifiedCreditCode, exception);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 供应商编码落库:A901-1 按统一社会信用代码查金蝶供应商档案取 number,查到才覆盖(查不到/接口异常保留原值,不影响同步主流程)
|
||||
*/
|
||||
private void fillSupplierCode(CustomerArchive customer, CustomerArchive after) {
|
||||
String supplierCode = this.queryKingdeeSupplierNumber(customer.getUnifiedCreditCode());
|
||||
if (Func.isNotEmpty(supplierCode)) {
|
||||
after.setSupplierCode(supplierCode);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String querySupplierCode(String unifiedCreditCode) {
|
||||
return this.queryKingdeeSupplierNumber(unifiedCreditCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* A905-3 按行名查询金蝶行名行号档案:收款信息填写开户行失焦时实时精确匹配取联行号,
|
||||
* 接口异常返回 null 不阻断录入,由前端提示未查询到
|
||||
*/
|
||||
@Override
|
||||
public BfiBankVO queryKingdeeBank(String bankName) {
|
||||
if (Func.isEmpty(bankName)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
BfiBankQueryDTO param = new BfiBankQueryDTO();
|
||||
param.setPageNo(1);
|
||||
param.setPageSize(10);
|
||||
param.setStatus(BfiConstant.BILL_STATUS_AUDITED);
|
||||
param.setEnable(BfiConstant.ENABLE_ENABLED);
|
||||
param.setName(List.of(bankName.trim()));
|
||||
BfiPageVO<BfiBankVO> page = bfiService.queryBank(param);
|
||||
List<BfiBankVO> rows = page == null ? null : page.getRows();
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
// 金蝶侧按行名过滤若为模糊匹配,本地再按全称精确比对取第一条
|
||||
String trimmedName = bankName.trim();
|
||||
return rows.stream()
|
||||
.filter(bank -> trimmedName.equals(bank.getName()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
} catch (Exception exception) {
|
||||
log.warn("A905-3行名行号查询失败,bankName={}", bankName, exception);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A905-3 按行号查询金蝶行名行号档案:收款信息保存前校验联行号真实存在,查不到返回 null 由前端拦截保存;
|
||||
* 与 {@link #queryKingdeeBank(String)} 不同,接口异常不上抛吞掉而是直接抛出,便于前端区分「查不到」与「校验失败」
|
||||
*/
|
||||
@Override
|
||||
public BfiBankVO queryKingdeeBankByNumber(String cnapsCode) {
|
||||
if (Func.isEmpty(cnapsCode)) {
|
||||
return null;
|
||||
}
|
||||
BfiBankQueryDTO param = new BfiBankQueryDTO();
|
||||
param.setPageNo(1);
|
||||
param.setPageSize(10);
|
||||
param.setStatus(BfiConstant.BILL_STATUS_AUDITED);
|
||||
param.setEnable(BfiConstant.ENABLE_ENABLED);
|
||||
param.setNumber(List.of(cnapsCode.trim()));
|
||||
BfiPageVO<BfiBankVO> page = bfiService.queryBank(param);
|
||||
List<BfiBankVO> rows = page == null ? null : page.getRows();
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
// 金蝶侧按行号过滤若为模糊匹配,本地再按编码精确比对取第一条
|
||||
String trimmedCode = cnapsCode.trim();
|
||||
return rows.stream()
|
||||
.filter(bank -> trimmedCode.equals(bank.getNumber()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 金蝶已存在档案时,客商编号(customer_code)采纳金蝶客商编码,后续业务接口沿用客商编号即拿到金蝶编码;
|
||||
* 金蝶编码为空或与当前编号一致时无需处理;同租户下已被其他客商占用时跳过回填,避免违反 uk_customer_archive_code 唯一约束
|
||||
@@ -766,10 +882,11 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
|
||||
CustomerArchive after = copyCustomer(customer);
|
||||
after.setKingdeeSyncTime(LocalDateTime.now());
|
||||
if (BfiConstant.BILL_STATUS_AUDITED.equals(kingdeeCustomer.getStatus())) {
|
||||
// 金蝶已存在已审核档案:跳过推送直接建档,客商编号采纳金蝶客商编码
|
||||
// 金蝶已存在已审核档案:跳过推送直接建档,客商编号采纳金蝶客商编码;供应商编码按 A901-1 供应商查询落库
|
||||
after.setKingdeeStatus(KINGDEE_STATUS_EXIST);
|
||||
after.setKingdeeFailReason(null);
|
||||
existed++;
|
||||
this.fillSupplierCode(customer, after);
|
||||
collectChangeRecord(changeRecords, customer.getId(), "金蝶已存在该客商档案,跳过推送" + this.adoptKingdeeCode(customer, after, kingdeeCustomer.getNumber()), customer, after);
|
||||
} else {
|
||||
// 金蝶侧暂存/已提交:审核中,置失败待重推
|
||||
@@ -2144,6 +2261,19 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装客商风险明细(列表悬浮展示用):等级名称(黑名单优先 + risk_level 字典翻译)与金蝶黑名单原始字段
|
||||
* 交由 {@link HcsCreditCheckSupport#buildRiskDetail} 统一组装,与表单实时提示(riskTip)同口径
|
||||
*/
|
||||
private void fillRiskDetail(List<CustomerArchiveVO> records) {
|
||||
for (CustomerArchiveVO record : records) {
|
||||
String customerName = Func.isEmpty(record.getFullName()) ? record.getCustomerCode() : record.getFullName();
|
||||
record.setHcsRiskDetail(HcsCreditCheckSupport.buildRiskDetail(customerName,
|
||||
record.getHcsRiskLevel(),
|
||||
record.getKingdeeBlackCategory(), record.getKingdeeBlackReason()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 资金使用情况 = 付款申请中「付款方=当前客商」且审核通过的已付金额合计;
|
||||
* 资金使用额度风险 = 合计已付金额 / 客商最大资金使用额度。
|
||||
@@ -2152,6 +2282,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
|
||||
if (records == null || records.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Set<String> payerNames = new HashSet<>();
|
||||
records.forEach(customer -> {
|
||||
if (Func.isNotEmpty(customer.getFullName())) {
|
||||
@@ -2206,6 +2337,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
|
||||
private CustomerArchiveExcel buildExcel(CustomerArchive customer) {
|
||||
CustomerArchiveExcel excel = new CustomerArchiveExcel();
|
||||
excel.setCustomerCode(customer.getCustomerCode());
|
||||
excel.setSupplierCode(customer.getSupplierCode());
|
||||
excel.setShortName(customer.getShortName());
|
||||
excel.setFullName(customer.getFullName());
|
||||
excel.setCustomerKind(customerKindName(customer.getCustomerKind()));
|
||||
|
||||
+3
@@ -418,6 +418,9 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> imp
|
||||
driver.setDrivingLicenseBack(trimToNull(driver.getDrivingLicenseBack()));
|
||||
driver.setQualificationFront(trimToNull(driver.getQualificationFront()));
|
||||
driver.setQualificationBack(trimToNull(driver.getQualificationBack()));
|
||||
if (driver.getIdCardLongTerm() == null) {
|
||||
driver.setIdCardLongTerm(DEFAULT_FALSE);
|
||||
}
|
||||
if (driver.getDrivingLicenseLongTerm() == null) {
|
||||
driver.setDrivingLicenseLongTerm(DEFAULT_FALSE);
|
||||
}
|
||||
|
||||
+122
-6
@@ -155,6 +155,9 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
/** 金蝶应收单勾稽结算明细分批查询单次条数上限(in 条件分批,避免单次过滤条件过长) */
|
||||
private static final int AR_RESULT_DETAIL_QUERY_BATCH = 500;
|
||||
|
||||
/** 金蝶冲销状态回写 single-flight 锁键前缀(多副本定时任务与手动触发共用) */
|
||||
private static final String WRITEOFF_SYNC_LOCK_PREFIX = "transport:formal-settlement:kingdee-writeoff-sync:";
|
||||
|
||||
private final FormalSettlementSourceMapper sourceMapper;
|
||||
private final FormalSettlementSummaryFeeMapper summaryFeeMapper;
|
||||
private final FormalSettlementPaymentMapper paymentMapper;
|
||||
@@ -791,7 +794,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
// 原单只加标记,业务字段不动
|
||||
update(Wrappers.<FormalSettlement>lambdaUpdate()
|
||||
.eq(FormalSettlement::getId, settlementId)
|
||||
.set(FormalSettlement::getAdjustStatus, "adjusted"));
|
||||
.set(FormalSettlement::getAdjustStatus, TransportBusiConstant.SETTLEMENT_ADJUST_STATUS_ADJUSTED));
|
||||
kingdeeBillLockService.unlockByAdjustRepush(KingdeeLockConstant.SOURCE_SETTLEMENT, settlementId, AuthUtil.getUserName());
|
||||
saveChange(settlementId, "结算单基本信息", null, "调整",
|
||||
"结算调整单" + Func.toStr(adjustmentNo) + "重推生成新单" + Func.toStr(newSettlementNo) + ",原单标记已调整", null);
|
||||
@@ -866,20 +869,24 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void markKingdeeSyncFailed(Long id, String reason) {
|
||||
if (id == null) return;
|
||||
// 条件更新:仅审批通过且未同步的单据落"生成失败"状态,未审批/已同步/已删除单据自动跳过保持原状态
|
||||
// 失败原因截断留档,与列长 varchar(500) 对齐
|
||||
String failReason = Func.toStrWithEmpty(reason, "未知异常");
|
||||
String columnReason = failReason.length() > 500 ? failReason.substring(0, 500) : failReason;
|
||||
// 条件更新:仅审批通过且未同步的单据落"生成失败"状态与失败原因,未审批/已同步/已删除单据自动跳过保持原状态
|
||||
int updated = baseMapper.update(null, Wrappers.<FormalSettlement>lambdaUpdate()
|
||||
.eq(FormalSettlement::getId, id)
|
||||
.eq(FormalSettlement::getApprovalStatus, TransportBusiConstant.APPROVED)
|
||||
.ne(FormalSettlement::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_SYNCED)
|
||||
.set(FormalSettlement::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED));
|
||||
.set(FormalSettlement::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED)
|
||||
.set(FormalSettlement::getKingdeeFailReason, columnReason));
|
||||
if (updated > 0) {
|
||||
// 结算调整单回填:未推送场景审批通过后自动推送失败,调整单置 failed 供「推送金蝶」重试
|
||||
// 结算调整单回填:未推送场景审批通过后自动推送失败,调整单置 failed + 失败原因供「推送金蝶」重试
|
||||
settlementAdjustmentMapper.update(null, Wrappers.<SettlementAdjustment>lambdaUpdate()
|
||||
.eq(SettlementAdjustment::getFormalSettlementId, id)
|
||||
.eq(SettlementAdjustment::getApprovalStatus, TransportBusiConstant.APPROVED)
|
||||
.set(SettlementAdjustment::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED));
|
||||
.set(SettlementAdjustment::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED)
|
||||
.set(SettlementAdjustment::getKingdeeFailReason, columnReason));
|
||||
// 失败原因写变更记录留档(推送事务已回滚,此处独立事务);原因过长截断留档,不抛错
|
||||
String failReason = Func.toStrWithEmpty(reason, "未知异常");
|
||||
saveChange(id, "结算单基本信息", null, "调整",
|
||||
"同步金蝶失败:" + (failReason.length() > 200 ? failReason.substring(0, 200) : failReason), null);
|
||||
}
|
||||
@@ -1134,6 +1141,113 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
return summary;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String syncKingdeeWriteOffResult() {
|
||||
return kingdeeSyncLockSupport.runSingleFlight(WRITEOFF_SYNC_LOCK_PREFIX, bfiProperties.getArResult().getLockLeaseSeconds(),
|
||||
"金蝶冲销状态回写", this::executeWriteOffSync);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void refreshKingdeeWriteOff(Long settlementId, boolean writtenOff) {
|
||||
String status = writtenOff ? KingdeeBfiConstant.KINGDEE_WRITEOFF_WRITTEN : KingdeeBfiConstant.KINGDEE_WRITEOFF_UNWRITTEN;
|
||||
FormalSettlement settlement = getById(settlementId);
|
||||
// 状态未变化不落库,避免预检反复点击重复更新
|
||||
if (settlement == null || status.equals(settlement.getKingdeeWriteOffStatus())) {
|
||||
return;
|
||||
}
|
||||
FormalSettlement patch = new FormalSettlement();
|
||||
patch.setId(settlementId);
|
||||
patch.setKingdeeWriteOffStatus(status);
|
||||
patch.setKingdeeWriteOffTime(LocalDateTime.now());
|
||||
updateById(patch);
|
||||
log.info("正式结算单[{}]金蝶冲销状态回写:{}", settlement.getFormalSettlementNo(), status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 金蝶冲销状态回写主体:A102-4/A302-4 批量查询已同步结算单的 hadwrittenoff(部分冲销视同已冲销)
|
||||
* 并回写冲销状态,由 {@link #syncKingdeeWriteOffResult()} 在分布式锁保护下调用。
|
||||
* <p>分批与事务口径同 {@link #executeArResultSync()}:结算明细分批 in 查询、远程整批查询消除逐单 N+1,
|
||||
* 仅回写状态有变化的结算单并 updateBatchById 批量落库;整轮任务不包外层事务,按防重键幂等可重跑。
|
||||
*
|
||||
* @return 执行摘要
|
||||
*/
|
||||
private String executeWriteOffSync() {
|
||||
long startMillis = System.currentTimeMillis();
|
||||
// 已同步金蝶的结算单才在金蝶存在旧应付/应收单,未同步/生成失败不查询(前端展示 '-');
|
||||
// 已冲销为终态不再复查,仅扫未冲销单据(存量 NULL 由 06 脚本回填默认未冲销)
|
||||
List<FormalSettlement> settlements = list(Wrappers.<FormalSettlement>lambdaQuery()
|
||||
.eq(FormalSettlement::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_SYNCED)
|
||||
.eq(FormalSettlement::getKingdeeWriteOffStatus, KingdeeBfiConstant.KINGDEE_WRITEOFF_UNWRITTEN)
|
||||
.orderByAsc(FormalSettlement::getId));
|
||||
if (Func.isEmpty(settlements)) {
|
||||
log.info("金蝶冲销状态回写:无待查未冲销结算单");
|
||||
return "金蝶冲销状态回写:无待查未冲销结算单";
|
||||
}
|
||||
log.info("金蝶冲销状态回写:本轮扫描待查未冲销结算单{}笔", settlements.size());
|
||||
// 结算明细分批一次查全(消除逐单查询 N+1),按结算单 id 分组后按结算类型组装防重键
|
||||
List<Long> settlementIds = settlements.stream().map(FormalSettlement::getId).toList();
|
||||
Map<Long, List<FormalSettlementDetail>> detailsBySettlement = new HashMap<>();
|
||||
kingdeeApBillSyncSupport.batchForeach(settlementIds, AR_RESULT_DETAIL_QUERY_BATCH, batch ->
|
||||
detailsBySettlement.putAll(detailMapper.selectList(Wrappers.<FormalSettlementDetail>lambdaQuery()
|
||||
.in(FormalSettlementDetail::getFormalSettlementId, batch)
|
||||
.eq(FormalSettlementDetail::getIsDeleted, 0)
|
||||
.orderByAsc(FormalSettlementDetail::getLineNo)).stream()
|
||||
.collect(Collectors.groupingBy(FormalSettlementDetail::getFormalSettlementId))));
|
||||
Map<String, FormalSettlement> apSettlementBySrcCode = new LinkedHashMap<>();
|
||||
Map<String, FormalSettlement> arSettlementBySrcCode = new LinkedHashMap<>();
|
||||
for (FormalSettlement settlement : settlements) {
|
||||
List<FormalSettlementDetail> details = detailsBySettlement.getOrDefault(settlement.getId(), List.of());
|
||||
if (Func.isEmpty(details)) {
|
||||
continue;
|
||||
}
|
||||
if ("receivable".equals(settlement.getSettlementType())) {
|
||||
String srcCode = kingdeeArBillSyncSupport.arBillSrcCode(settlement, details);
|
||||
if (Func.isNotBlank(srcCode)) {
|
||||
arSettlementBySrcCode.putIfAbsent(srcCode, settlement);
|
||||
}
|
||||
} else {
|
||||
String srcCode = kingdeeApBillSyncSupport.apBillSrcCode(settlement, details);
|
||||
if (Func.isNotBlank(srcCode)) {
|
||||
apSettlementBySrcCode.putIfAbsent(srcCode, settlement);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A102-4/A302-4 整批一次查询,冲销状态有变化的才落库(updateBatchById 批量更新,避免逐单 update)
|
||||
log.info("金蝶冲销状态回写:防重键组装完成,应付{}个、应收{}个,按批次大小{}分批查询",
|
||||
apSettlementBySrcCode.size(), arSettlementBySrcCode.size(), bfiProperties.getWriteOffResult().getQueryBatchSize());
|
||||
Map<String, Boolean> apWriteOffFlags = kingdeeApBillSyncSupport.queryApBillWriteOffFlags(new ArrayList<>(apSettlementBySrcCode.keySet()));
|
||||
Map<String, Boolean> arWriteOffFlags = kingdeeArBillSyncSupport.queryArBillWriteOffFlags(new ArrayList<>(arSettlementBySrcCode.keySet()));
|
||||
// 金蝶未返回的键按未冲销处理,返回数与组装数差异即金蝶侧查不到单据的规模,留痕便于对账
|
||||
log.info("金蝶冲销状态批量查询完成:应付键返回{}个、应收键返回{}个,未返回键按未冲销处理",
|
||||
apWriteOffFlags.size(), arWriteOffFlags.size());
|
||||
List<FormalSettlement> toUpdateSettlements = new ArrayList<>();
|
||||
apSettlementBySrcCode.forEach((srcCode, settlement) ->
|
||||
this.applyWriteOffFlag(settlement, apWriteOffFlags.get(srcCode), toUpdateSettlements));
|
||||
arSettlementBySrcCode.forEach((srcCode, settlement) ->
|
||||
this.applyWriteOffFlag(settlement, arWriteOffFlags.get(srcCode), toUpdateSettlements));
|
||||
if (Func.isNotEmpty(toUpdateSettlements)) {
|
||||
// 批量落库按同一分批大小拆分,避免单批过大
|
||||
updateBatchById(toUpdateSettlements, bfiProperties.getWriteOffResult().getQueryBatchSize());
|
||||
log.info("金蝶冲销状态回写:批量落库完成,本批更新{}笔", toUpdateSettlements.size());
|
||||
}
|
||||
String summary = "金蝶冲销状态回写完成:待查未冲销结算单" + (apSettlementBySrcCode.size() + arSettlementBySrcCode.size())
|
||||
+ "笔,回写" + toUpdateSettlements.size() + "笔";
|
||||
log.info("{},耗时{}ms", summary, System.currentTimeMillis() - startMillis);
|
||||
return summary;
|
||||
}
|
||||
|
||||
/** 应用单笔冲销查询结果:与本地冲销状态不一致时标记更新(查不到金蝶单据按未冲销处理),一致跳过 */
|
||||
private void applyWriteOffFlag(FormalSettlement settlement, Boolean writtenOff, List<FormalSettlement> toUpdateSettlements) {
|
||||
String status = Boolean.TRUE.equals(writtenOff) ? KingdeeBfiConstant.KINGDEE_WRITEOFF_WRITTEN : KingdeeBfiConstant.KINGDEE_WRITEOFF_UNWRITTEN;
|
||||
if (status.equals(settlement.getKingdeeWriteOffStatus())) {
|
||||
return;
|
||||
}
|
||||
settlement.setKingdeeWriteOffStatus(status);
|
||||
settlement.setKingdeeWriteOffTime(LocalDateTime.now());
|
||||
toUpdateSettlements.add(settlement);
|
||||
log.info("金蝶冲销状态回写:正式结算单[{}],{}", settlement.getFormalSettlementNo(), status);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public String applyPayment(FormalSettlementPaymentRequest request) {
|
||||
@@ -1248,6 +1362,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
private String createPayment(FormalSettlement settlement, BigDecimal appliedAmount, String remark) {
|
||||
if (!TransportBusiConstant.APPROVED.equals(settlement.getApprovalStatus())) throw new ServiceException("仅审批通过的正式结算单允许发起付款申请");
|
||||
if (!"payable".equals(settlement.getSettlementType())) throw new ServiceException("仅应付正式结算单允许发起付款申请");
|
||||
if (TransportBusiConstant.SETTLEMENT_ADJUST_STATUS_ADJUSTED.equals(settlement.getAdjustStatus()))
|
||||
throw new ServiceException("结算单[" + Func.toStr(settlement.getFormalSettlementNo()) + "]已因结算调整作废,不可再发起付款,请使用调整后的新单");
|
||||
long activePaymentCount = paymentMapper.selectCount(Wrappers.<FormalSettlementPayment>lambdaQuery()
|
||||
.eq(FormalSettlementPayment::getFormalSettlementId, settlement.getId())
|
||||
.eq(FormalSettlementPayment::getIsDeleted, 0)
|
||||
|
||||
+85
-31
@@ -138,6 +138,9 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
implements IInvoiceApplicationService {
|
||||
/** A303-5 开票结果批量查询单批单据编号上限(接口限制 50) */
|
||||
private static final int INVOICE_RESULT_QUERY_BATCH = 50;
|
||||
/** 张次开票状态展示文案(按金蝶回写的发票结果实时推导) */
|
||||
private static final String INVOICE_STATUS_INVOICED = "已开票";
|
||||
private static final String INVOICE_STATUS_UNINVOICED = "未开票";
|
||||
/** 金蝶开票结果回写 single-flight 锁键前缀(多副本定时任务与手动触发共用) */
|
||||
private static final String INVOICE_RESULT_SYNC_LOCK_PREFIX = "transport:invoice-application:kingdee-invoice-result-sync:";
|
||||
private final InvoiceApplicationSettlementMapper settlementRelationMapper;
|
||||
@@ -251,10 +254,23 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
vo.setSettlements(relations);
|
||||
List<InvoiceApplicationSheet> sheets = sheetMapper.selectList(Wrappers.<InvoiceApplicationSheet>lambdaQuery()
|
||||
.eq(InvoiceApplicationSheet::getInvoiceApplicationId, id).orderByAsc(InvoiceApplicationSheet::getSheetNo));
|
||||
List<InvoiceApplicationInvoice> invoices = invoiceApplicationInvoiceMapper.selectList(
|
||||
Wrappers.<InvoiceApplicationInvoice>lambdaQuery()
|
||||
.eq(InvoiceApplicationInvoice::getInvoiceApplicationId, id)
|
||||
.orderByAsc(InvoiceApplicationInvoice::getCreateTime));
|
||||
vo.setInvoices(invoices);
|
||||
// 每张发票的金蝶开票结果:按张次金蝶单号归集,查看页展示开票状态与结果备注
|
||||
Map<String, List<InvoiceApplicationInvoice>> invoicesBySerialNo = invoices.stream()
|
||||
.filter(invoice -> Func.isNotBlank(invoice.getSerialNo()))
|
||||
.collect(Collectors.groupingBy(invoice -> Func.toStr(invoice.getSerialNo()).trim()));
|
||||
vo.setSheets(sheets.stream().map(sheet -> {
|
||||
InvoiceApplicationSheetVO sheetVO = Objects.requireNonNull(BeanUtil.copyProperties(sheet, InvoiceApplicationSheetVO.class));
|
||||
sheetVO.setLines(lineMapper.selectList(Wrappers.<InvoiceApplicationLine>lambdaQuery()
|
||||
.eq(InvoiceApplicationLine::getInvoiceSheetId, sheet.getId()).orderByAsc(InvoiceApplicationLine::getLineNo)));
|
||||
List<InvoiceApplicationInvoice> sheetInvoices =
|
||||
invoicesBySerialNo.get(Func.toStr(sheet.getKingdeeBillNo()).trim());
|
||||
sheetVO.setInvoiceStatusName(Func.isEmpty(sheetInvoices) ? INVOICE_STATUS_UNINVOICED : INVOICE_STATUS_INVOICED);
|
||||
sheetVO.setKingdeeResultRemark(Func.isEmpty(sheetInvoices) ? "" : kingdeeResultRemark(sheetInvoices));
|
||||
return sheetVO;
|
||||
}).toList());
|
||||
vo.setDetails(applicationDetailMapper.selectList(Wrappers.<InvoiceApplicationDetail>lambdaQuery()
|
||||
@@ -262,9 +278,6 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
vo.setRecords(recordMapper.selectList(Wrappers.<InvoiceApplicationRecord>lambdaQuery()
|
||||
.eq(InvoiceApplicationRecord::getInvoiceApplicationId, id)
|
||||
.orderByAsc(InvoiceApplicationRecord::getCreateTime)));
|
||||
vo.setInvoices(invoiceApplicationInvoiceMapper.selectList(Wrappers.<InvoiceApplicationInvoice>lambdaQuery()
|
||||
.eq(InvoiceApplicationInvoice::getInvoiceApplicationId, id)
|
||||
.orderByAsc(InvoiceApplicationInvoice::getCreateTime)));
|
||||
return vo;
|
||||
}
|
||||
|
||||
@@ -639,17 +652,19 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void markKingdeeSyncFailed(Long id, String reason) {
|
||||
if (id == null) return;
|
||||
// 条件更新:仅审批通过且未同步/未开票的单据落"生成失败"状态,其余自动跳过保持原状态
|
||||
String failReason = truncateReason(reason);
|
||||
// 条件更新:仅审批通过且未同步/未开票的单据落"生成失败"状态与失败原因,其余自动跳过保持原状态
|
||||
int updated = baseMapper.update(null, Wrappers.<InvoiceApplication>lambdaUpdate()
|
||||
.eq(InvoiceApplication::getId, id)
|
||||
.eq(InvoiceApplication::getApprovalStatus, TransportBusiConstant.APPROVED)
|
||||
.notIn(InvoiceApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_SYNCED,
|
||||
KingdeeBfiConstant.KINGDEE_STATUS_INVOICED)
|
||||
.set(InvoiceApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED));
|
||||
.set(InvoiceApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED)
|
||||
.set(InvoiceApplication::getKingdeeFailReason, failReason));
|
||||
if (updated > 0) {
|
||||
// 失败原因写操作流水留档(推送事务已回滚,此处独立事务)
|
||||
record(id, "sync", "同步金蝶失败", TransportBusiConstant.APPROVED, TransportBusiConstant.APPROVED,
|
||||
truncateReason(reason), null);
|
||||
failReason, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -818,13 +833,19 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
}
|
||||
if (Func.isBlank(entity.getDepartmentEmails())) throw new ServiceException("推送金蝶开票申请失败:部门邮箱(发票推送邮箱)不能为空");
|
||||
if (Func.isBlank(entity.getTaxpayerNo())) throw new ServiceException("推送金蝶开票申请失败:购方纳税人识别号不能为空");
|
||||
// 金蝶组织编码:项目业务部门反查唯一平台公司 → 平台公司金蝶组织编码;申请部门编码:项目业务部门自身金蝶编码(与付款申请单部门.编码同源,共用上下文缓存)
|
||||
// 金蝶组织/申请部门:按首张关联结算单合同的平台公司 + 业务部门配置取数
|
||||
KingdeeSyncContext kingdeeContext = KingdeeSyncContext.empty();
|
||||
String kingdeeOrgNumber = kingdeeApBillSyncSupport.kingdeeOrgNumber(entity.getProjectId(), kingdeeContext);
|
||||
String kingdeeDeptNumber = kingdeeApBillSyncSupport.kingdeeDeptNumber(entity.getProjectId(), kingdeeContext);
|
||||
FormalSettlement firstSettlement = formalSettlementMapper.selectById(relations.get(0).getFormalSettlementId());
|
||||
if (firstSettlement == null || Objects.equals(firstSettlement.getIsDeleted(), 1)) {
|
||||
throw new ServiceException("推送金蝶开票申请失败:正式结算单["
|
||||
+ Func.toStr(relations.get(0).getFormalSettlementNo()) + "]不存在");
|
||||
}
|
||||
ContractManage contract = kingdeeApBillSyncSupport.requireContract(firstSettlement.getContractId(), kingdeeContext);
|
||||
String kingdeeOrgNumber = kingdeeApBillSyncSupport.bfiOrgNumber(contract, kingdeeContext);
|
||||
String kingdeeDeptNumber = kingdeeApBillSyncSupport.kingdeeDeptNumber(contract, entity.getProjectId(), kingdeeContext);
|
||||
CustomerArchive customer = entity.getReceiverCustomerId() == null ? null : customerArchiveMapper.selectById(entity.getReceiverCustomerId());
|
||||
if (customer == null || Func.isBlank(customer.getCustomerCode())) {
|
||||
throw new ServiceException("推送金蝶开票申请失败:受票方客户未在客商档案中维护客商编码,请补充后重试");
|
||||
if (customer == null || (Func.isBlank(customer.getSupplierCode()) && Func.isBlank(customer.getCustomerCode()))) {
|
||||
throw new ServiceException("推送金蝶开票申请失败:受票方客户未在客商档案中维护供应商编码或客商编码,请补充后重试");
|
||||
}
|
||||
// 逐张发票推送金蝶开票申请单:每张发票张次一张(价税合计=本张金额,分录=本张商品行),已存在的张次幂等跳过
|
||||
List<String> savedBillNos = new ArrayList<>();
|
||||
@@ -909,9 +930,9 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
String firstArSrcCode, BigDecimal sheetAmount, List<BfiInvoiceApplySaveEntryDTO> items) {
|
||||
BfiInvoiceApplySaveDTO param = new BfiInvoiceApplySaveDTO();
|
||||
// ==================== 以下为【必填】字段(BfiInvoiceApplySaveDTO 标注),非必填字段先注释保留 ====================
|
||||
// 【必填】组织.编码:项目业务部门反查唯一平台公司 → 平台公司金蝶组织编码
|
||||
// 【必填】组织.编码:合同平台公司金蝶组织编码
|
||||
param.setOrgidNumber(orgNumber);
|
||||
// 【必填】申请部门.编码:项目业务部门金蝶编码(与付款申请单 awrp_department_number 同源)
|
||||
// 【必填】申请部门.编码:合同平台公司下业务部门配置的金蝶部门代码
|
||||
param.setAwrpOrgfieldNumber(deptNumber);
|
||||
// 【必填】业务单据编号(候选键):单张发票候选 billno(多张发票时开票申请单号-张次序号)
|
||||
param.setBillno(billNo);
|
||||
@@ -965,8 +986,8 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
entity.getApplicationNo());
|
||||
}
|
||||
}
|
||||
// 【必填】客户.编码:受票方客商档案客商编码
|
||||
param.setCustomnameidNumber(customer.getCustomerCode());
|
||||
// 【必填】客户.编码:受票方客商档案供应商编码(A901-1 金蝶编码),未维护时兜底客商编号
|
||||
param.setCustomnameidNumber(Func.isBlank(customer.getSupplierCode()) ? customer.getCustomerCode() : customer.getSupplierCode());
|
||||
// 【必填】项目.项目编码(联调期临时传金蝶测试档案值 999)
|
||||
param.setAwrpProjectNumber(KingdeeBfiConstant.BFI_PROJECT_NUMBER_NONE);
|
||||
// ==================== 以下为非必填字段,先注释保留,联调确认后按需放开 ====================
|
||||
@@ -1151,7 +1172,8 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
}
|
||||
|
||||
/**
|
||||
* 金蝶开票结果回写主体:A303-5 分批查询开票申请发票结果落库并推进开票完成,
|
||||
* 金蝶开票结果回写主体:A303-5 分批查询开票申请发票结果落库,
|
||||
* 多张发票全部张次均有开票结果才推进开票完成(终态),部分回写保持已同步继续轮询剩余张次,
|
||||
* 由 {@link #syncKingdeeInvoiceResult()} 在分布式锁保护下调用
|
||||
*
|
||||
* @return 执行摘要
|
||||
@@ -1170,16 +1192,20 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
log.info("金蝶开票结果回写开始:待回写开票申请{}笔", applications.size());
|
||||
// 按 serialNo(金蝶开票申请单编号)分批(≤50/批)调 A303-5,销方税号与 A303-1 同源(开票单位→客商档案),按税号分组查询
|
||||
Map<String, InvoiceApplication> applicationBySerialNo = new LinkedHashMap<>();
|
||||
Map<Long, List<String>> serialNosByApplicationId = new LinkedHashMap<>();
|
||||
applications.forEach(application -> {
|
||||
if (Func.isNotBlank(application.getKingdeeBillNo())) {
|
||||
// 多张发票时申请单金蝶单号逗号拼接存储,拆分为逐张单号分别归集回写
|
||||
for (String serialNo : application.getKingdeeBillNo().split(",")) {
|
||||
if (Func.isNotBlank(serialNo)) {
|
||||
applicationBySerialNo.putIfAbsent(serialNo.trim(), application);
|
||||
serialNosByApplicationId.computeIfAbsent(application.getId(), key -> new ArrayList<>()).add(serialNo.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// 已有开票结果的单号归集(含历史已落库),全部张次齐备才推进开票完成
|
||||
Set<String> serialNosWithResult = new LinkedHashSet<>();
|
||||
Map<String, CustomerArchive> sellerCache = new LinkedHashMap<>();
|
||||
Map<String, List<String>> serialNosBySellerTaxNo = new LinkedHashMap<>();
|
||||
int unresolved = 0;
|
||||
@@ -1204,7 +1230,6 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
log.info("金蝶开票结果回写:按销方税号分组完成,共{}组,销方税号无法解析跳过{}笔", serialNosBySellerTaxNo.size(), unresolved);
|
||||
int invoiceWritten = 0;
|
||||
int failed = 0;
|
||||
int invoicedCount = 0;
|
||||
for (Map.Entry<String, List<String>> group : serialNosBySellerTaxNo.entrySet()) {
|
||||
List<String> groupSerialNos = group.getValue();
|
||||
for (int from = 0; from < groupSerialNos.size(); from += INVOICE_RESULT_QUERY_BATCH) {
|
||||
@@ -1225,10 +1250,11 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
// 批量查询当前批次已落库的发票记录(按发票号索引,用于幂等去重与缺文件地址补齐)
|
||||
Map<String, InvoiceApplicationInvoice> existingInvoiceMap = batchQueryExistingInvoices(batch);
|
||||
log.debug("金蝶开票结果回写:批次已存在发票记录数={}", existingInvoiceMap.size());
|
||||
// 收集待插入的发票记录、待补文件地址的已有发票记录和待更新的开票申请
|
||||
// 已落库记录对应的单号视为已有开票结果(跨批次幂等归集)
|
||||
existingInvoiceMap.values().forEach(record -> serialNosWithResult.add(Func.toStr(record.getSerialNo()).trim()));
|
||||
// 收集待插入的发票记录和待补文件地址的已有发票记录
|
||||
List<InvoiceApplicationInvoice> toInsertInvoices = new ArrayList<>();
|
||||
List<InvoiceApplicationInvoice> toPatchInvoices = new ArrayList<>();
|
||||
List<InvoiceApplication> toUpdateApplications = new ArrayList<>();
|
||||
for (BfiInvoiceApplyOpenApiBillVO bill : result.getData()) {
|
||||
String billNo = bill.getBillNo();
|
||||
InvoiceApplication application = applicationBySerialNo.get(billNo);
|
||||
@@ -1270,13 +1296,8 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
}
|
||||
if (inserted > 0) {
|
||||
invoiceWritten += inserted;
|
||||
// 查到发票:开票申请单推进开票完成(终态)
|
||||
application.setKingdeeStatus(KingdeeBfiConstant.KINGDEE_STATUS_INVOICED);
|
||||
// HCS 开票登记待更新:扫描任务凭 hcs_fd_id 走 C111-3 回填票据日期(D12)
|
||||
application.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED);
|
||||
toUpdateApplications.add(application);
|
||||
invoicedCount++;
|
||||
log.info("金蝶开票结果回写:开票申请[{}]状态推进为invoiced,本次写入发票{}张", application.getApplicationNo(), inserted);
|
||||
serialNosWithResult.add(billNo);
|
||||
log.info("金蝶开票结果回写:单据编号[{}]写入发票{}张", billNo, inserted);
|
||||
}
|
||||
}
|
||||
// 批量插入发票记录
|
||||
@@ -1289,17 +1310,29 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
Db.updateBatchById(toPatchInvoices);
|
||||
log.info("金蝶开票结果回写:批量补齐发票文件地址{}条", toPatchInvoices.size());
|
||||
}
|
||||
// 批量更新开票申请状态
|
||||
if (!toUpdateApplications.isEmpty()) {
|
||||
updateBatchById(toUpdateApplications);
|
||||
log.info("金蝶开票结果回写:批量更新开票申请状态{}笔", toUpdateApplications.size());
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
failed++;
|
||||
log.error("金蝶开票结果回写批次异常:serialNo=[{}]", batchSerialNos, exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 全部张次均有开票结果才推进开票完成(终态),部分回写保持已同步继续轮询剩余张次
|
||||
List<InvoiceApplication> toCompleteApplications = new ArrayList<>();
|
||||
for (InvoiceApplication application : applications) {
|
||||
List<String> applicationSerialNos = serialNosByApplicationId.get(application.getId());
|
||||
if (Func.isEmpty(applicationSerialNos) || !serialNosWithResult.containsAll(applicationSerialNos)) {
|
||||
continue;
|
||||
}
|
||||
application.setKingdeeStatus(KingdeeBfiConstant.KINGDEE_STATUS_INVOICED);
|
||||
// HCS 开票登记待更新:扫描任务凭 hcs_fd_id 走 C111-3 回填票据日期(D12)
|
||||
application.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED);
|
||||
toCompleteApplications.add(application);
|
||||
}
|
||||
int invoicedCount = toCompleteApplications.size();
|
||||
if (!toCompleteApplications.isEmpty()) {
|
||||
updateBatchById(toCompleteApplications);
|
||||
log.info("金蝶开票结果回写:批量推进开票完成{}笔", invoicedCount);
|
||||
}
|
||||
log.info("金蝶开票结果回写完成:待查开票申请{}笔(销方税号无法解析跳过{}笔),回写发票{}张(开票完成{}笔,失败批次{}个)",
|
||||
applications.size(), unresolved, invoiceWritten, invoicedCount, failed);
|
||||
return "金蝶开票结果回写完成:待查开票申请" + applications.size() + "笔(销方税号无法解析跳过" + unresolved
|
||||
@@ -1321,6 +1354,27 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
.collect(Collectors.toMap(InvoiceApplicationInvoice::getInvoiceNo, Function.identity(), (left, right) -> left));
|
||||
}
|
||||
|
||||
/**
|
||||
* 汇总本张发票经 A303-5 回写的金蝶开票结果备注(发票号、开票日期、价税合计)
|
||||
*
|
||||
* @param sheetInvoices 本张发票的金蝶发票结果
|
||||
* @return 结果备注
|
||||
*/
|
||||
private String kingdeeResultRemark(List<InvoiceApplicationInvoice> sheetInvoices) {
|
||||
return sheetInvoices.stream()
|
||||
.map(invoice -> {
|
||||
StringBuilder remark = new StringBuilder("发票号").append(Func.toStr(invoice.getInvoiceNo()));
|
||||
if (invoice.getInvoiceDate() != null) {
|
||||
remark.append(",开票日期").append(invoice.getInvoiceDate().toLocalDate());
|
||||
}
|
||||
if (invoice.getInvoiceAmount() != null) {
|
||||
remark.append(",价税合计").append(invoice.getInvoiceAmount());
|
||||
}
|
||||
return remark.toString();
|
||||
})
|
||||
.collect(Collectors.joining(";"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 A303-5 返回的发票文件地址补齐到已落库记录(仅补空字段,不覆盖已有值)
|
||||
*
|
||||
|
||||
+44
-64
@@ -50,6 +50,7 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoicePoolVO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiPageVO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiReceivedInvoiceVO;
|
||||
import org.springblade.thirdparty.bfi.service.IBfiService;
|
||||
import org.springblade.thirdparty.bfi.util.BfiUtil;
|
||||
import org.springblade.transport.constant.HcsSyncConstant;
|
||||
import org.springblade.transport.constant.KingdeeAttachmentConstant;
|
||||
import org.springblade.transport.config.BfiProperties;
|
||||
@@ -1304,11 +1305,11 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|
||||
invoiceTypes.isEmpty() ? "不限制" : invoiceTypes, customerTaxNos != null ? "开启" : "关闭",
|
||||
POOL_PAGE_SIZE, MAX_POOL_PAGES);
|
||||
if (invoiceTypes.isEmpty()) {
|
||||
this.syncWindowSlice(windowStart, windowEnd, null, customerTaxNos, stats);
|
||||
this.syncPoolWindow(windowStart, windowEnd, null, customerTaxNos, stats);
|
||||
} else {
|
||||
// 单值参数拆轮:每个票种编码独立完整切片续扫,互不挤占翻页额度
|
||||
for (String invoiceType : invoiceTypes) {
|
||||
this.syncWindowSlice(windowStart, windowEnd, invoiceType, customerTaxNos, stats);
|
||||
this.syncPoolWindow(windowStart, windowEnd, invoiceType, customerTaxNos, stats);
|
||||
}
|
||||
}
|
||||
String summary = "金蝶全票池同步:翻页" + stats.pageCount + "页切片" + stats.sliceCount + "片拉取"
|
||||
@@ -1358,72 +1359,51 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步单个时间片 [sliceStart, sliceEnd):独立翻页拉取(最多 MAX_POOL_PAGES 页),
|
||||
* 触顶说明该时间片数据 >2.5 万,从中点二分为两片递归续扫(递归深度受时间粒度自然收敛,
|
||||
* 单片 500 条以内 1 页即短页终止)
|
||||
* 同步单个票种的时间窗口:翻页与触顶二分续扫统一交给 {@link BfiUtil#forEachWindowSlice},
|
||||
* 页级客商单位过滤、镜像批量 upsert、已登记票跟随补绑在页回调中完成
|
||||
*
|
||||
* @param sliceStart 切片起点(含)
|
||||
* @param sliceEnd 切片终点(不含,避免与下一片边界重复拉取)
|
||||
* @param customerTaxNos 客商发票信息税号集,null 表示客商单位过滤关闭(整轮共享)
|
||||
* @param stats 整轮统计累加器
|
||||
* @param windowStart 窗口起点(含)
|
||||
* @param windowEnd 窗口终点(含)
|
||||
* @param invoiceType 发票种类编码,null 不限
|
||||
* @param customerTaxNos 客商发票信息税号集,null 表示客商单位过滤关闭(整轮共享)
|
||||
* @param stats 整轮统计累加器
|
||||
*/
|
||||
private void syncWindowSlice(LocalDateTime sliceStart, LocalDateTime sliceEnd, String invoiceType,
|
||||
private void syncPoolWindow(LocalDateTime windowStart, LocalDateTime windowEnd, String invoiceType,
|
||||
Set<String> customerTaxNos, SyncStats stats) {
|
||||
// 二分收敛保护:切片粒度细到 1 秒仍触顶说明远端异常(同秒 >2.5 万条不可能),不再二分直接记漏
|
||||
if (!sliceStart.isBefore(sliceEnd)) {
|
||||
log.error("金蝶全票池同步切片区间为空({} ~ {}),远端 updateTime 粒度异常,放弃该切片", sliceStart, sliceEnd);
|
||||
return;
|
||||
}
|
||||
boolean truncated = false;
|
||||
for (int pageNo = 1; pageNo <= MAX_POOL_PAGES; pageNo++) {
|
||||
List<BfiInvoicePoolVO> rows = this.pullPoolWindowPage(sliceStart, sliceEnd, invoiceType, pageNo);
|
||||
if (rows.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
stats.pageCount++;
|
||||
stats.pulledCount += rows.size();
|
||||
// 客商单位过滤:销/购方税号未同时登记在客商发票信息中的发票不入镜像;
|
||||
// 短页判定用原始条数,过滤后的空页不能提前终止翻页
|
||||
stats.sliceCount += BfiUtil.forEachWindowSlice("金蝶全票池同步", windowStart, windowEnd,
|
||||
POOL_PAGE_SIZE, MAX_POOL_PAGES,
|
||||
(sliceStart, sliceEnd, pageNo) -> this.pullPoolWindowPage(sliceStart, sliceEnd, invoiceType, pageNo),
|
||||
rows -> this.handlePoolPage(rows, customerTaxNos, stats));
|
||||
}
|
||||
|
||||
/**
|
||||
* 票池同步单页处理:客商单位过滤 + 镜像批量 upsert + 已登记票跟随补绑。
|
||||
* 客商过滤只影响入镜像行数,翻页节奏由 BfiUtil 按原始行数判断,不受过滤影响
|
||||
*
|
||||
* @param rows 本页拉取的票池发票(原始行)
|
||||
* @param customerTaxNos 客商发票信息税号集,null 表示客商单位过滤关闭
|
||||
* @param stats 整轮统计累加器
|
||||
*/
|
||||
private void handlePoolPage(List<BfiInvoicePoolVO> rows, Set<String> customerTaxNos, SyncStats stats) {
|
||||
stats.pageCount++;
|
||||
stats.pulledCount += rows.size();
|
||||
// 客商单位过滤:销/购方税号未同时登记在客商发票信息中的发票不入镜像
|
||||
if (customerTaxNos != null) {
|
||||
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();
|
||||
stats.mirrorUnchanged += upsertResult.unchanged();
|
||||
// 已登记票跟随补绑:同页发票号码批量定位登记,绑定关系变更页级事务批量落库
|
||||
if (!upsertResult.mirrors().isEmpty()) {
|
||||
RefreshResult refreshResult = this.refreshBoundReceipts(stats.pageCount, upsertResult.mirrors());
|
||||
stats.bound += refreshResult.bound();
|
||||
stats.unchanged += refreshResult.unchanged();
|
||||
stats.failed += refreshResult.failed();
|
||||
}
|
||||
// 远程短页:该片到底,正常终止(按原始拉取条数判断,客商过滤不改变翻页节奏)
|
||||
if (rawPageSize < POOL_PAGE_SIZE) {
|
||||
stats.sliceCount++;
|
||||
return;
|
||||
}
|
||||
truncated = true;
|
||||
rows = rows.stream().filter(row -> this.bothUnitsRegistered(row, customerTaxNos)).toList();
|
||||
stats.customerFiltered += rawPageSize - rows.size();
|
||||
}
|
||||
// 触顶:该切片数据超过单查询窗口上限,从中点二分续扫(updateTime 落在中点的行归左片 [start, mid),
|
||||
// 右片 [mid, end) 不含 mid,同 updateTime 行整体归左片,不重不漏)
|
||||
if (truncated) {
|
||||
LocalDateTime mid = sliceStart.plus(java.time.Duration.between(sliceStart, sliceEnd).dividedBy(2));
|
||||
if (!mid.isAfter(sliceStart)) {
|
||||
// 切片已细到不足 1 秒仍满页:理论不可能,防御死递归
|
||||
log.error("金蝶全票池同步切片 {} ~ {} 已达最小粒度仍满页(>{} 条),该时间片数据异常,跳过",
|
||||
sliceStart, sliceEnd, MAX_POOL_PAGES * POOL_PAGE_SIZE);
|
||||
return;
|
||||
}
|
||||
log.warn("金蝶全票池同步切片 {} ~ {} 触达翻页保护 {} 页({} 条),二分续扫 [{} ~ {}) + [{} ~ {})",
|
||||
sliceStart, sliceEnd, MAX_POOL_PAGES, MAX_POOL_PAGES * POOL_PAGE_SIZE, sliceStart, mid, mid, sliceEnd);
|
||||
this.syncWindowSlice(sliceStart, mid, invoiceType, customerTaxNos, stats);
|
||||
this.syncWindowSlice(mid, sliceEnd, invoiceType, customerTaxNos, stats);
|
||||
return;
|
||||
BatchUpsertResult upsertResult = this.batchUpsertInvoicePool(rows);
|
||||
stats.created += upsertResult.created();
|
||||
stats.updated += upsertResult.updated();
|
||||
stats.mirrorUnchanged += upsertResult.unchanged();
|
||||
// 已登记票跟随补绑:同页发票号码批量定位登记,绑定关系变更页级事务批量落库
|
||||
if (!upsertResult.mirrors().isEmpty()) {
|
||||
RefreshResult refreshResult = this.refreshBoundReceipts(stats.pageCount, upsertResult.mirrors());
|
||||
stats.bound += refreshResult.bound();
|
||||
stats.unchanged += refreshResult.unchanged();
|
||||
stats.failed += refreshResult.failed();
|
||||
}
|
||||
stats.sliceCount++;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1523,7 +1503,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|
||||
}
|
||||
|
||||
/** A801-1 按票池更新时间窗口分页拉取(只认正常票,不推单据使用状态;invoiceType 单值下推,null 不限) */
|
||||
private List<BfiInvoicePoolVO> pullPoolWindowPage(LocalDateTime windowStart, LocalDateTime windowEnd,
|
||||
private BfiPageVO<BfiInvoicePoolVO> pullPoolWindowPage(LocalDateTime windowStart, LocalDateTime windowEnd,
|
||||
String invoiceType, int pageNo) {
|
||||
BfiInvoicePoolQueryDataDTO data = new BfiInvoicePoolQueryDataDTO();
|
||||
data.setPageNo(pageNo);
|
||||
@@ -1536,7 +1516,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|
||||
param.setQueryParam(data);
|
||||
BfiPageVO<BfiInvoicePoolVO> poolPage = bfiService.queryInvoicePool(param);
|
||||
log.info("金蝶全票池同步 第{}页拉取 {} 条", pageNo, poolPage.getRows() == null ? 0 : poolPage.getRows().size());
|
||||
return poolPage.getRows() == null ? List.of() : poolPage.getRows();
|
||||
return poolPage;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+99
-27
@@ -35,6 +35,7 @@ import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.ILoadingManageService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.IReceivablePayableDetailService;
|
||||
import org.springblade.transport.support.ContractPartySupport;
|
||||
import org.springblade.transport.support.LiveProcessConfigSupport;
|
||||
import org.springblade.transport.support.LoadableAdmissionSupport;
|
||||
import org.springblade.transport.support.TransportBusinessSupport;
|
||||
@@ -118,23 +119,27 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
return List.of();
|
||||
}
|
||||
List<ContractManage> contracts = availableCarrierContracts(projectIds);
|
||||
Set<String> allowedCarrierNames = null;
|
||||
if (Func.isNotEmpty(waybillIds)) {
|
||||
List<Waybill> waybills = loadWaybills(waybillIds);
|
||||
Set<String> allowedPartyBs = intersectCarrierPartyBs(waybills, contracts);
|
||||
if (allowedPartyBs.isEmpty()) {
|
||||
allowedCarrierNames = intersectCarrierPartyBs(waybills, contracts);
|
||||
if (allowedCarrierNames.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Set<String> allowedNames = allowedCarrierNames;
|
||||
contracts = contracts.stream()
|
||||
.filter(contract -> allowedPartyBs.contains(normalizePartyName(contract.getPartyB())))
|
||||
.filter(contract -> ContractPartySupport.carrierPartyNames(contract).stream()
|
||||
.anyMatch(allowedNames::contains))
|
||||
.toList();
|
||||
}
|
||||
return contracts.stream().map(contract -> {
|
||||
LoadingCarrierContractVO option = new LoadingCarrierContractVO();
|
||||
option.setId(contract.getId());
|
||||
option.setContractName(contract.getContractName());
|
||||
option.setCarrierName(contract.getPartyB());
|
||||
return option;
|
||||
}).toList();
|
||||
List<LoadingCarrierContractVO> options = ContractPartySupport.expandCarrierOptions(contracts);
|
||||
if (allowedCarrierNames == null) {
|
||||
return options;
|
||||
}
|
||||
Set<String> allowedNames = allowedCarrierNames;
|
||||
return options.stream()
|
||||
.filter(option -> allowedNames.contains(normalizePartyName(option.getCarrierName())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -674,7 +679,11 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
TransportBusinessSupport.validateAllDept(loadingManage.getAllDept(), "配载管理");
|
||||
LambdaQueryWrapper<LoadingManage> queryWrapper = Wrappers.<LoadingManage>lambdaQuery().eq(LoadingManage::getIsDeleted, 0);
|
||||
if (!Objects.equals(loadingManage.getAllDept(), 1)) {
|
||||
queryWrapper.eq(LoadingManage::getDeptId, TransportBusinessSupport.currentDeptId("配载管理"));
|
||||
Long currentDeptId = TransportBusinessSupport.currentDeptId("配载管理");
|
||||
queryWrapper.and(wrapper -> {
|
||||
wrapper.eq(LoadingManage::getDeptId, currentDeptId);
|
||||
TransportBusinessSupport.orProjectVisibleByBusinessDept(wrapper, List.of(currentDeptId));
|
||||
});
|
||||
} else if (Func.isNotEmpty(loadingManage.getDeptId())) {
|
||||
queryWrapper.eq(LoadingManage::getDeptId, loadingManage.getDeptId());
|
||||
}
|
||||
@@ -969,6 +978,8 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
private void validateCarrierContract(LoadingManage loadingManage, boolean required, List<Waybill> waybills) {
|
||||
if (!Objects.equals(loadingManage.getCarrierType(), "承运商")) {
|
||||
loadingManage.setCarrierContractId(null);
|
||||
loadingManage.setCarrierPartyId(null);
|
||||
loadingManage.setCarrierPartyRole(null);
|
||||
if (!Objects.equals(loadingManage.getCarrierType(), CARRIER_SELF)) {
|
||||
loadingManage.setCarrierName(null);
|
||||
}
|
||||
@@ -979,6 +990,8 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
throw new ServiceException("请选择承运商合同");
|
||||
}
|
||||
loadingManage.setCarrierName(null);
|
||||
loadingManage.setCarrierPartyId(null);
|
||||
loadingManage.setCarrierPartyRole(null);
|
||||
return;
|
||||
}
|
||||
ContractManage carrierContract = availableCarrierContractById(loadingManage.getCarrierContractId());
|
||||
@@ -999,19 +1012,35 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
? "所选承运商合同不属于本项目"
|
||||
: "所选承运商合同不属于运单" + waybillNos + "的项目");
|
||||
}
|
||||
Map<String, Object> selectedParty = ContractPartySupport.findCarrierParty(
|
||||
carrierContract, loadingManage.getCarrierPartyId(), loadingManage.getCarrierName());
|
||||
if (selectedParty == null && Func.isNotEmpty(loadingManage.getCarrierName())
|
||||
&& !ContractPartySupport.matchesCarrier(carrierContract, loadingManage.getCarrierName(), loadingManage.getCarrierPartyId())) {
|
||||
throw new ServiceException("所选承运商不属于该承运商合同的乙方或丙方客商");
|
||||
}
|
||||
if (selectedParty != null) {
|
||||
Object customerName = selectedParty.get("customerName");
|
||||
Object partyId = selectedParty.get("id");
|
||||
Object partyRole = selectedParty.get("partyRole");
|
||||
loadingManage.setCarrierName(customerName == null ? null : normalizePartyName(String.valueOf(customerName)));
|
||||
loadingManage.setCarrierPartyId(partyId == null ? null : normalizePartyName(String.valueOf(partyId)));
|
||||
loadingManage.setCarrierPartyRole(partyRole == null ? null : ContractPartySupport.normalizeRole(String.valueOf(partyRole)));
|
||||
} else if (Func.isEmpty(loadingManage.getCarrierName())) {
|
||||
loadingManage.setCarrierName(ContractPartySupport.firstPartyName(carrierContract.getPartyBJson(), carrierContract.getPartyB()));
|
||||
loadingManage.setCarrierPartyRole(ContractPartySupport.ROLE_B);
|
||||
}
|
||||
if (Func.isNotEmpty(childWaybills)) {
|
||||
List<ContractManage> projectContracts = availableCarrierContracts(projectIdsOf(childWaybills));
|
||||
Set<String> allowedPartyBs = intersectCarrierPartyBs(childWaybills, projectContracts);
|
||||
String partyB = normalizePartyName(carrierContract.getPartyB());
|
||||
if (Func.isNotEmpty(allowedPartyBs) && !allowedPartyBs.contains(partyB)) {
|
||||
throw new ServiceException("所选承运商合同乙方与子运单可共同承运的乙方不一致,可选:"
|
||||
String carrierName = normalizePartyName(loadingManage.getCarrierName());
|
||||
if (Func.isNotEmpty(allowedPartyBs) && !allowedPartyBs.contains(carrierName)) {
|
||||
throw new ServiceException("所选承运客商与子运单可共同承运的客商不一致,可选:"
|
||||
+ String.join("、", allowedPartyBs));
|
||||
}
|
||||
if (allowedPartyBs.isEmpty()) {
|
||||
throw new ServiceException("所选运单在同一项目下没有共同的承运商合同乙方,不能配载到同一配载单");
|
||||
throw new ServiceException("所选运单在同一项目下没有共同的承运商合同客商,不能配载到同一配载单");
|
||||
}
|
||||
}
|
||||
loadingManage.setCarrierName(carrierContract.getPartyB());
|
||||
}
|
||||
|
||||
private void validateCompletedCarrierContract(LoadingManage loadingManage) {
|
||||
@@ -1050,12 +1079,11 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
}
|
||||
|
||||
/**
|
||||
* 各子运单按「客户合同乙方 = 承运商合同甲方」匹配可用承运商合同,再对乙方名称取交集。
|
||||
* 例:客户合同 盛达(甲)-捷联(乙),承运商合同 捷联(甲)-驰远(乙);子单1可选 A/B/C,子单2可选 B,则交集为 B。
|
||||
* 各子运单按「客户合同乙方 ≈ 承运商合同甲方」匹配可用承运商合同,再对乙/丙方客商名称取交集。
|
||||
*/
|
||||
private Set<String> intersectCarrierPartyBs(List<Waybill> waybills, List<ContractManage> projectContracts) {
|
||||
Set<String> intersection = null;
|
||||
Map<Long, String> customerPartyBMap = loadCustomerContractPartyBMap(waybills);
|
||||
Map<Long, Set<String>> customerPartyBMap = loadCustomerContractPartyBMap(waybills);
|
||||
for (Waybill waybill : waybills) {
|
||||
Set<String> available = availableCarrierPartyBs(waybill, projectContracts, customerPartyBMap);
|
||||
if (intersection == null) {
|
||||
@@ -1071,23 +1099,23 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
}
|
||||
|
||||
private Set<String> availableCarrierPartyBs(Waybill waybill, List<ContractManage> projectContracts,
|
||||
Map<Long, String> customerPartyBMap) {
|
||||
Map<Long, Set<String>> customerPartyBMap) {
|
||||
if (waybill == null || Func.isEmpty(projectContracts)) {
|
||||
return Set.of();
|
||||
}
|
||||
Long contractId = waybill.getContractId();
|
||||
// 运单没有客户合同时不按乙方过滤;客户合同乙方即我方,应等于承运商合同甲方
|
||||
String partyB = contractId == null ? "" : normalizePartyName(customerPartyBMap.get(contractId));
|
||||
Set<String> customerPartyBs = contractId == null
|
||||
? Set.of()
|
||||
: customerPartyBMap.getOrDefault(contractId, Set.of());
|
||||
return projectContracts.stream()
|
||||
.filter(contract -> Objects.equals(contract.getProjectId(), waybill.getProjectId()))
|
||||
.filter(contract -> Func.isEmpty(partyB) || Objects.equals(normalizePartyName(contract.getPartyA()), partyB))
|
||||
.map(ContractManage::getPartyB)
|
||||
.map(this::normalizePartyName)
|
||||
.filter(contract -> customerPartyBs.isEmpty() || partyNamesOverlap(partyANames(contract), customerPartyBs))
|
||||
.flatMap(contract -> ContractPartySupport.carrierPartyNames(contract).stream())
|
||||
.filter(Func::isNotEmpty)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
private Map<Long, String> loadCustomerContractPartyBMap(List<Waybill> waybills) {
|
||||
private Map<Long, Set<String>> loadCustomerContractPartyBMap(List<Waybill> waybills) {
|
||||
List<Long> contractIds = waybills.stream()
|
||||
.map(Waybill::getContractId)
|
||||
.filter(Objects::nonNull)
|
||||
@@ -1101,11 +1129,55 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
.in(ContractManage::getId, contractIds))
|
||||
.stream()
|
||||
.collect(Collectors.toMap(ContractManage::getId,
|
||||
contract -> normalizePartyName(contract.getPartyB()),
|
||||
this::partyBNames,
|
||||
(left, right) -> left,
|
||||
LinkedHashMap::new));
|
||||
}
|
||||
|
||||
private Set<String> partyANames(ContractManage contract) {
|
||||
Set<String> names = ContractPartySupport.parsePartyRows(contract.getPartyAJson()).stream()
|
||||
.map(row -> normalizePartyName(String.valueOf(row.get("customerName"))))
|
||||
.filter(Func::isNotEmpty)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
if (names.isEmpty() && Func.isNotEmpty(contract.getPartyA())) {
|
||||
for (String part : contract.getPartyA().split("[、,]")) {
|
||||
String name = normalizePartyName(part);
|
||||
if (Func.isNotEmpty(name)) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
private Set<String> partyBNames(ContractManage contract) {
|
||||
Set<String> names = ContractPartySupport.parsePartyRows(contract.getPartyBJson()).stream()
|
||||
.map(row -> normalizePartyName(String.valueOf(row.get("customerName"))))
|
||||
.filter(Func::isNotEmpty)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
if (names.isEmpty() && Func.isNotEmpty(contract.getPartyB())) {
|
||||
for (String part : contract.getPartyB().split("[、,]")) {
|
||||
String name = normalizePartyName(part);
|
||||
if (Func.isNotEmpty(name)) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
private boolean partyNamesOverlap(Set<String> left, Set<String> right) {
|
||||
if (Func.isEmpty(left) || Func.isEmpty(right)) {
|
||||
return false;
|
||||
}
|
||||
for (String name : left) {
|
||||
if (right.contains(name)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private String normalizePartyName(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
+35
-8
@@ -30,6 +30,7 @@ import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IProjectApplyService;
|
||||
import org.springblade.transport.service.ITransportPlanService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.ContractPartySupport;
|
||||
import org.springblade.transport.support.TransportBusinessSupport;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
import org.springblade.transport.wrapper.WaybillWrapper;
|
||||
@@ -87,11 +88,15 @@ public class MasterOrderServiceImpl extends BaseServiceImpl<MasterOrderMapper, M
|
||||
|
||||
@Override
|
||||
public List<MasterOrderCarrierVO> carriers(Long id) {
|
||||
return availableCarrierContracts(getRequired(id)).stream().map(contract -> {
|
||||
return ContractPartySupport.expandCarrierOptions(availableCarrierContracts(getRequired(id))).stream().map(option -> {
|
||||
MasterOrderCarrierVO carrier = new MasterOrderCarrierVO();
|
||||
carrier.setContractId(contract.getId());
|
||||
carrier.setContractName(contract.getContractName());
|
||||
carrier.setCarrierName(contract.getPartyB());
|
||||
carrier.setContractId(option.getId());
|
||||
carrier.setContractName(option.getContractName());
|
||||
carrier.setCarrierName(option.getCarrierName());
|
||||
carrier.setCarrierPartyId(option.getCarrierPartyId());
|
||||
carrier.setCarrierPartyRole(option.getCarrierPartyRole());
|
||||
carrier.setCreditCode(option.getCreditCode());
|
||||
carrier.setCustomerId(option.getCustomerId());
|
||||
return carrier;
|
||||
}).toList();
|
||||
}
|
||||
@@ -267,7 +272,11 @@ public class MasterOrderServiceImpl extends BaseServiceImpl<MasterOrderMapper, M
|
||||
waybill.setContractId(masterOrder.getContractId()); waybill.setContractName(masterOrder.getContractName());
|
||||
waybill.setCustomerName(masterOrder.getCustomerName()); waybill.setMasterNo(masterOrder.getMasterNo());
|
||||
waybill.setTransportType(string(dispatch, "transportType")); waybill.setCarrierType(string(dispatch, "carrierType"));
|
||||
waybill.setCarrierContractId(longValue(dispatch, "carrierContractId")); waybill.setCarrierName(string(dispatch, "carrierName")); waybill.setDriverName(string(dispatch, "driverName"));
|
||||
waybill.setCarrierContractId(longValue(dispatch, "carrierContractId"));
|
||||
waybill.setCarrierName(string(dispatch, "carrierName"));
|
||||
waybill.setCarrierPartyId(string(dispatch, "carrierPartyId"));
|
||||
waybill.setCarrierPartyRole(string(dispatch, "carrierPartyRole"));
|
||||
waybill.setDriverName(string(dispatch, "driverName"));
|
||||
waybill.setDriverPhone(string(dispatch, "driverPhone")); waybill.setVehicleNo(string(dispatch, "vehicleNo")); waybill.setTrailerVehicleNo(string(dispatch, "trailerVehicleNo"));
|
||||
// 归属主键是档案 ID:调度带了就用它,否则按手机号解析;解析入口只返回 null 或唯一命中的
|
||||
// 档案 ID,承运商司机解析为空也照常派单,落空时留 WARN
|
||||
@@ -603,7 +612,7 @@ public class MasterOrderServiceImpl extends BaseServiceImpl<MasterOrderMapper, M
|
||||
.and(wrapper -> wrapper.isNull(ContractManage::getContractStage)
|
||||
.or().ne(ContractManage::getContractStage, "terminated"))
|
||||
.orderByDesc(ContractManage::getCreateTime))
|
||||
.stream().filter(contract -> Func.isNotEmpty(contract.getPartyB())).toList();
|
||||
.stream().filter(contract -> Func.isNotEmpty(ContractPartySupport.carrierPartyNames(contract))).toList();
|
||||
}
|
||||
|
||||
private void validateCarrier(Map<String, Object> dispatch, Map<Long, ContractManage> availableCarrierContracts) {
|
||||
@@ -615,11 +624,29 @@ public class MasterOrderServiceImpl extends BaseServiceImpl<MasterOrderMapper, M
|
||||
}
|
||||
}
|
||||
String carrierName = string(dispatch, "carrierName");
|
||||
String carrierPartyId = string(dispatch, "carrierPartyId");
|
||||
Long carrierContractId = longValue(dispatch, "carrierContractId");
|
||||
ContractManage carrierContract = carrierContractId == null ? null : availableCarrierContracts.get(carrierContractId);
|
||||
if ("承运商".equals(carrierType) && (carrierContract == null
|
||||
|| !Objects.equals(carrierContract.getPartyB(), carrierName))) {
|
||||
throw new ServiceException("所选承运商不属于总单客户合同对应项目的有效承运商合同乙方");
|
||||
|| !ContractPartySupport.matchesCarrier(carrierContract, carrierName, carrierPartyId))) {
|
||||
throw new ServiceException("所选承运商不属于总单客户合同对应项目的有效承运商合同乙/丙方客商");
|
||||
}
|
||||
if ("承运商".equals(carrierType) && carrierContract != null) {
|
||||
Map<String, Object> selectedParty = ContractPartySupport.findCarrierParty(carrierContract, carrierPartyId, carrierName);
|
||||
if (selectedParty != null) {
|
||||
Object selectedName = selectedParty.get("customerName");
|
||||
Object selectedPartyId = selectedParty.get("id");
|
||||
Object selectedPartyRole = selectedParty.get("partyRole");
|
||||
if (selectedName != null) {
|
||||
dispatch.put("carrierName", String.valueOf(selectedName).trim());
|
||||
}
|
||||
if (selectedPartyId != null) {
|
||||
dispatch.put("carrierPartyId", String.valueOf(selectedPartyId).trim());
|
||||
}
|
||||
if (selectedPartyRole != null) {
|
||||
dispatch.put("carrierPartyRole", ContractPartySupport.normalizeRole(String.valueOf(selectedPartyRole)));
|
||||
}
|
||||
}
|
||||
}
|
||||
String transportType = string(dispatch, "transportType", "");
|
||||
boolean road = transportType.toLowerCase().contains("road") || transportType.contains("公路");
|
||||
|
||||
+94
-15
@@ -116,6 +116,8 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
@@ -261,6 +263,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
public PaymentApplicationVO detail(Long id) {
|
||||
PaymentApplication entity = existing(id);
|
||||
PaymentApplicationVO vo = PaymentApplicationWrapper.build().entityVO(entity);
|
||||
// 合规特批结果附件动态追加展示(不落 attachmentsJson 原文,下载经 C103-5 代理透传)
|
||||
this.appendSpecAttachDisplay(vo);
|
||||
fillKingdeeLockStatus(List.of(vo));
|
||||
vo.setInvoices(invoiceMapper.selectList(Wrappers.<PaymentApplicationInvoice>lambdaQuery()
|
||||
.eq(PaymentApplicationInvoice::getPaymentApplicationId, id).orderByAsc(PaymentApplicationInvoice::getLineNo)));
|
||||
@@ -295,6 +299,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
if (!TransportBusiConstant.APPROVED.equals(formalSettlement.getApprovalStatus())) {
|
||||
throw new ServiceException("只能选择审批通过、未作废的正式结算单");
|
||||
}
|
||||
assertFormalSettlementPayable(formalSettlement);
|
||||
settlementAmount = money(formalSettlement.getSettlementAmount());
|
||||
} else {
|
||||
// 尾款付款也允许关联预结算单
|
||||
@@ -547,12 +552,12 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
kingdeeBillId = self().doSyncKingdee(id, context);
|
||||
} catch (ServiceException exception) {
|
||||
log.error("付款申请[{}]推送金蝶付款申请单异常", id, exception);
|
||||
// 推送事务已回滚、行锁已释放,此处独立事务落"生成失败"状态
|
||||
self().markKingdeeSyncFailed(id);
|
||||
// 推送事务已回滚、行锁已释放,此处独立事务落"生成失败"状态与失败原因
|
||||
self().markKingdeeSyncFailed(id, exception.getMessage());
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
log.error("付款申请[{}]推送金蝶付款申请单异常", id, exception);
|
||||
self().markKingdeeSyncFailed(id);
|
||||
self().markKingdeeSyncFailed(id, exception.getMessage());
|
||||
throw new ServiceException("推送金蝶付款申请失败:" + exception.getMessage());
|
||||
}
|
||||
// 主单事务提交后锁定金蝶付款申请单(lockBusiData):一张付款申请对一张金蝶付款申请单,接口1对1;
|
||||
@@ -585,6 +590,38 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情附件追加合规特批结果附件展示行(不落 attachmentsJson 原文):
|
||||
* 前端通用附件列表直接渲染,下载经 /hcs/attachment/download(C103-5 代理)透传;
|
||||
* 先剔除前端随单保存的历史展示行,避免重批后重复
|
||||
*
|
||||
* @param vo 付款申请详情 VO
|
||||
*/
|
||||
private void appendSpecAttachDisplay(PaymentApplicationVO vo) {
|
||||
if (Func.isBlank(vo.getHcsSpecAttachFdId())) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
List<Object> rows = Func.isBlank(vo.getAttachmentsJson())
|
||||
? new ArrayList<>() : new ArrayList<>(JsonUtil.parse(vo.getAttachmentsJson(), List.class));
|
||||
// 剔除历史展示行(含改版前 spec_result 旧值),避免前端随单保存后重复追加
|
||||
rows.removeIf(row -> row instanceof Map<?, ?> map
|
||||
&& (HcsSyncConstant.PAYMENT_ATTACH_TYPE_SPEC_RESULT.equals(map.get("attachmentType"))
|
||||
|| "spec_result".equals(map.get("attachmentType"))));
|
||||
Map<String, Object> specRow = new LinkedHashMap<>();
|
||||
specRow.put("name", HcsSyncConstant.PAYMENT_SPEC_ATTACH_NAME + ".pdf");
|
||||
specRow.put("attachmentType", HcsSyncConstant.PAYMENT_ATTACH_TYPE_SPEC_RESULT);
|
||||
// 前端代理前缀 /api(网关服务名路由);url 路径以 .pdf 结尾供前端附件组件识别类型,文件流 attachment 强制下载
|
||||
specRow.put("url", "/api/blade-transport/hcs/attachment/download/"
|
||||
+ URLEncoder.encode(HcsSyncConstant.PAYMENT_SPEC_ATTACH_NAME + ".pdf", StandardCharsets.UTF_8)
|
||||
+ "?fdId=" + vo.getHcsSpecAttachFdId());
|
||||
rows.add(specRow);
|
||||
vo.setAttachmentsJson(JsonUtil.toJson(rows));
|
||||
} catch (Exception exception) {
|
||||
log.warn("付款申请[{}]特批结果附件展示行追加失败", vo.getId(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 附件联带上传(A902):主单推送成功(含幂等预检命中)后调用,
|
||||
* 以本地 kingdeeBillId(星瀚单据内码)定位金蝶单据;本地缺失(存量数据)跳过待重推补传;
|
||||
@@ -599,8 +636,15 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
log.warn("付款申请[{}]金蝶附件联带上传跳过:星瀚单据内码未落库,待重推补传", id);
|
||||
return;
|
||||
}
|
||||
kingdeeAttachmentSyncSupport.syncUserAttachments(KingdeeAttachmentConstant.BIZ_PAY_APPLICATION,
|
||||
id, entity.getKingdeeBillId(), entity.getAttachmentsJson());
|
||||
List<KingdeeAttachmentSyncSupport.KingdeeAttachmentItem> items = new ArrayList<>(
|
||||
kingdeeAttachmentSyncSupport.parseUserAttachments(entity.getAttachmentsJson()));
|
||||
// 合规付款特批结果附件(paymentLegal/result 回传):C103-5 按合规附件ID 下载后联带上传金蝶
|
||||
if (Func.isNotBlank(entity.getHcsSpecAttachFdId())) {
|
||||
items.add(KingdeeAttachmentSyncSupport.KingdeeAttachmentItem.hcsAttachment(
|
||||
HcsSyncConstant.PAYMENT_SPEC_ATTACH_NAME, entity.getHcsSpecAttachFdId()));
|
||||
}
|
||||
kingdeeAttachmentSyncSupport.syncAttachments(KingdeeAttachmentConstant.BIZ_PAY_APPLICATION,
|
||||
id, entity.getKingdeeBillId(), items);
|
||||
} catch (Exception exception) {
|
||||
log.error("付款申请[{}]金蝶附件联带上传异常(不影响主单同步状态,重推自动补传)", id, exception);
|
||||
}
|
||||
@@ -692,13 +736,17 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void markKingdeeSyncFailed(Long id) {
|
||||
public void markKingdeeSyncFailed(Long id, String reason) {
|
||||
if (id == null) return;
|
||||
// 失败原因截断留档,与列长 varchar(500) 对齐,不抛错
|
||||
String failReason = Func.toStrWithEmpty(reason, "未知异常");
|
||||
// 条件更新:仅审批通过的单据落"生成失败"状态,未审批/已删除单据自动跳过保持原状态
|
||||
baseMapper.update(null, Wrappers.<PaymentApplication>lambdaUpdate()
|
||||
.eq(PaymentApplication::getId, id)
|
||||
.eq(PaymentApplication::getApprovalStatus, TransportBusiConstant.APPROVED)
|
||||
.set(PaymentApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED));
|
||||
.set(PaymentApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED)
|
||||
.set(PaymentApplication::getKingdeeFailReason,
|
||||
failReason.length() > 500 ? failReason.substring(0, 500) : failReason));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -750,6 +798,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
.eq(PaymentApplication::getId, id)
|
||||
.in(PaymentApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_SYNCED, KingdeeBfiConstant.KINGDEE_STATUS_PAYING)
|
||||
.set(PaymentApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED)
|
||||
.set(PaymentApplication::getKingdeeFailReason, "金蝶侧单据已删除解锁(" + source + "),等待重新推送")
|
||||
.set(PaymentApplication::getKingdeeBillId, null)
|
||||
.set(PaymentApplication::getKingdeeBillNo, null));
|
||||
// 金蝶单据已删除/解锁重推:联动作废提前建档的预付记录,重推生成新单后重新建档
|
||||
@@ -964,6 +1013,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
String kingdeeStatus = kingdeeResultStatus(payApply);
|
||||
entity.setKingdeeStatus(kingdeeStatus);
|
||||
// 已付金额:A103-2 分录 e_paidamt 汇总;分录缺失且已付款时兜底取申请金额
|
||||
BigDecimal previousPaid = entity.getPaidAmount();
|
||||
if (Func.isNotEmpty(payApply.getEntry())) {
|
||||
BigDecimal paidAmount = payApply.getEntry().stream()
|
||||
.map(BfiPayApplyEntryVO::getEPaidamt).filter(Objects::nonNull)
|
||||
@@ -972,8 +1022,12 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
} else if (KingdeeBfiConstant.KINGDEE_STATUS_PAID.equals(kingdeeStatus)) {
|
||||
entity.setPaidAmount(money(entity.getAppliedAmount()).setScale(2, RoundingMode.HALF_UP));
|
||||
}
|
||||
// HCS 履约记录推进:实际付款变化后置待同步,扫描任务凭 hcs_perf_fd_id 走 C108-2 更新(D11)
|
||||
if (entity.getHcsPerfFdId() != null) {
|
||||
// HCS 履约付款记录推进:金蝶实际付款推进(首次付款或金额变化)后置待同步,扫描任务凭
|
||||
// hcs_perf_fd_id 判定走向——未建过履约记录走 C108-1 新建,已建走 C108-2 更新实际金额
|
||||
BigDecimal currentPaid = entity.getPaidAmount();
|
||||
boolean paidAdvanced = currentPaid != null && currentPaid.signum() > 0
|
||||
&& (previousPaid == null || previousPaid.signum() <= 0 || previousPaid.compareTo(currentPaid) != 0);
|
||||
if (paidAdvanced) {
|
||||
entity.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED);
|
||||
}
|
||||
updateById(entity);
|
||||
@@ -1131,15 +1185,17 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
}
|
||||
|
||||
/**
|
||||
* A103-4 分页查询:按付款人(公司)编码 + 最近1个月业务日期窗口(接口限制不超过1个月),自动翻页
|
||||
* A103-4 分页查询:按付款人(公司)编码 + 业务日期窗口,自动翻页
|
||||
* <p>
|
||||
* 窗口天数取 Nacos thirdParty.bfi.payResult.queryWindowDays(默认 60)
|
||||
*
|
||||
* @param orgNumber 付款人(公司)编码
|
||||
* @return 付款处理单列表
|
||||
*/
|
||||
private List<BfiPayBillVO> queryPayBillPages(String orgNumber) {
|
||||
LocalDate endDate = LocalDate.now();
|
||||
// 业务日期窗口控制在不超过1个月
|
||||
LocalDate beginDate = endDate.minusMonths(1).plusDays(1);
|
||||
int windowDays = Math.max(bfiProperties.getPayResult().getQueryWindowDays(), 1);
|
||||
LocalDate beginDate = endDate.minusDays(windowDays);
|
||||
BfiPayBillQueryDTO param = new BfiPayBillQueryDTO();
|
||||
param.setOrgNumber(List.of(orgNumber));
|
||||
param.setBeginBizdate(beginDate.format(DateTimeFormatter.ISO_LOCAL_DATE));
|
||||
@@ -1254,8 +1310,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
param.setAwrpSrcSys(bfiProperties.getSrcSys());
|
||||
// 结算组织.编码
|
||||
param.setSettleorgNumber(orgNumber);
|
||||
// 部门.编码:项目业务部门自身的金蝶编码(结算/申请/付款组织仍传上溯平台公司编码)
|
||||
param.setAwrpDepartmentNumber(kingdeeApBillSyncSupport.kingdeeDeptNumber(contract.getProjectId(), context));
|
||||
// 部门.编码:合同平台公司下业务部门配置的金蝶部门代码
|
||||
param.setAwrpDepartmentNumber(kingdeeApBillSyncSupport.kingdeeDeptNumber(contract, contract.getProjectId(), context));
|
||||
// 【必填】单据类型.编码
|
||||
param.setBilltypeNumber(bfiProperties.getPayApply().getBilltype());
|
||||
// 【必填】申请日期:yyyy-MM-dd HH:mm:ss(各接口统一格式)
|
||||
@@ -2092,6 +2148,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
}
|
||||
} else {
|
||||
if (!TransportBusiConstant.APPROVED.equals(source.getApprovalStatus())) throw new ServiceException("只能选择审批通过、未作废的正式结算单");
|
||||
assertFormalSettlementPayable(source);
|
||||
entity.setSettlementId(source.getId()); entity.setSettlementNo(source.getFormalSettlementNo()); entity.setPreSettlementId(null); entity.setPreSettlementNo(null);
|
||||
entity.setProjectId(source.getProjectId()); entity.setProjectName(source.getProjectName()); entity.setDeptId(source.getDeptId()); entity.setDeptName(source.getDeptName()); entity.setContractId(source.getContractId()); entity.setContractNo(source.getContractNo()); entity.setContractName(source.getContractName()); entity.setPayerName(source.getPayerName()); entity.setPayeeName(source.getPayeeName()); entity.setSettlementAmount(source.getSettlementAmount()); entity.setPayableAmount(payableOfFormalSettlement(source.getId(), source.getSettlementAmount(), entity.getId())); entity.setBillType("正式结算单");
|
||||
}
|
||||
@@ -2146,6 +2203,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
|| !TransportBusiConstant.APPROVED.equals(item.getApprovalStatus()) || !"payable".equals(item.getSettlementType()))) {
|
||||
throw new ServiceException("只能选择审批通过的应付正式结算单");
|
||||
}
|
||||
settlements.forEach(this::assertFormalSettlementPayable);
|
||||
Long contractId = settlements.get(0).getContractId();
|
||||
if (contractId == null || settlements.stream().anyMatch(item -> !Objects.equals(contractId, item.getContractId()))) {
|
||||
throw new ServiceException("多选正式结算单必须属于同一合同");
|
||||
@@ -2153,6 +2211,19 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
return settlements;
|
||||
}
|
||||
|
||||
/**
|
||||
* 付款链路闸门:结算调整重推会把原单标记为已调整并冲销金蝶旧单,
|
||||
* 原单仅作存档,剩余应付口径归新单,此处拦截避免对已作废金额重复付款
|
||||
*
|
||||
* @param settlement 待付款的正式结算单
|
||||
*/
|
||||
private void assertFormalSettlementPayable(FormalSettlement settlement) {
|
||||
if (TransportBusiConstant.SETTLEMENT_ADJUST_STATUS_ADJUSTED.equals(settlement.getAdjustStatus())) {
|
||||
throw new ServiceException("结算单[" + Func.toStr(settlement.getFormalSettlementNo())
|
||||
+ "]已因结算调整作废,不可再发起付款,请使用调整后的新单");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasMultipleFormalReferences(PaymentApplicationSaveRequest request) {
|
||||
return TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT.equals(request.getPaymentType()) && request.getSettlementIds() != null
|
||||
&& request.getSettlementIds().stream().filter(Objects::nonNull).distinct().count() > 1;
|
||||
@@ -2293,10 +2364,18 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
BigDecimal remainingApplied = money(entity.getAppliedAmount());
|
||||
if (customAmounts) {
|
||||
BigDecimal lineTotal = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
||||
for (PreSettlement settlement : targetSettlements) {
|
||||
BigDecimal payable = payableOfPreSettlement(settlement.getId(), settlement.getSettlementAmount(), entity.getId());
|
||||
BigDecimal applied = this.specifiedAppliedAmount(lines, null, settlement.getId());
|
||||
this.assertAppliedWithinPayable(settlement.getPreSettlementNo(), applied, payable);
|
||||
lineTotal = lineTotal.add(applied);
|
||||
}
|
||||
// 推金蝶按付款基本信息中的申请付款金额,明细行金额合计不等时会导致预付建档分摊与金蝶金额不一致
|
||||
BigDecimal appliedTotal = money(entity.getAppliedAmount()).setScale(2, RoundingMode.HALF_UP);
|
||||
if (lineTotal.compareTo(appliedTotal) != 0) {
|
||||
throw new ServiceException("预结算单付款明细金额合计[" + lineTotal.toPlainString()
|
||||
+ "]必须等于申请付款金额[" + appliedTotal.toPlainString() + "]");
|
||||
}
|
||||
}
|
||||
for (int index = 0; index < targetSettlements.size(); index++) {
|
||||
@@ -2348,7 +2427,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
}
|
||||
}
|
||||
|
||||
/** 前端传入结算行付款金额时,按行落库,不再要求合计等于申请付款金额。 */
|
||||
/** 前端传入结算行付款金额时,按行落库(行金额合计须与申请付款金额一致,由调用方校验)。 */
|
||||
private boolean useSpecifiedLineAmounts(List<PaymentApplicationSettlementLineRequest> lines) {
|
||||
return lines != null && !lines.isEmpty();
|
||||
}
|
||||
|
||||
+76
-31
@@ -44,6 +44,7 @@ import org.springblade.transport.mapper.PreSettlementDetailMapper;
|
||||
import org.springblade.transport.mapper.PreSettlementMapper;
|
||||
import org.springblade.transport.mapper.PreSettlementSummaryFeeMapper;
|
||||
import org.springblade.transport.mapper.PaymentApplicationMapper;
|
||||
import org.springblade.transport.mapper.PaymentApplicationSettlementMapper;
|
||||
import org.springblade.transport.mapper.ReceivablePayableCargoFeeMapper;
|
||||
import org.springblade.transport.mapper.ReceivablePayableDetailMapper;
|
||||
import org.springblade.transport.pojo.dto.PreSettlementAdvanceRequest;
|
||||
@@ -59,6 +60,7 @@ import org.springblade.transport.pojo.entity.PreSettlementDetail;
|
||||
import org.springblade.transport.pojo.entity.PreSettlementDetailFee;
|
||||
import org.springblade.transport.pojo.entity.PreSettlementSummaryFee;
|
||||
import org.springblade.transport.pojo.entity.PaymentApplication;
|
||||
import org.springblade.transport.pojo.entity.PaymentApplicationSettlement;
|
||||
import org.springblade.transport.pojo.entity.ProjectApply;
|
||||
import org.springblade.transport.pojo.entity.ReceivablePayableCargoFee;
|
||||
import org.springblade.transport.pojo.entity.ReceivablePayableDetail;
|
||||
@@ -95,6 +97,7 @@ import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* 预结算单服务实现类
|
||||
@@ -118,6 +121,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
private final PreSettlementSummaryFeeMapper summaryFeeMapper;
|
||||
private final PreSettlementAdvanceMapper advanceMapper;
|
||||
private final PaymentApplicationMapper paymentApplicationMapper;
|
||||
private final PaymentApplicationSettlementMapper settlementRelationMapper;
|
||||
private final PreSettlementChangeRecordMapper changeRecordMapper;
|
||||
private final ReceivablePayableDetailMapper sourceDetailMapper;
|
||||
private final ReceivablePayableCargoFeeMapper sourceFeeMapper;
|
||||
@@ -140,21 +144,54 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
Set<Long> preSettlementIds = records.stream().map(PreSettlementVO::getId)
|
||||
.filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
if (preSettlementIds.isEmpty()) return;
|
||||
List<PaymentApplication> applications = paymentApplicationMapper.selectList(
|
||||
Wrappers.<PaymentApplication>lambdaQuery()
|
||||
.select(PaymentApplication::getPreSettlementId, PaymentApplication::getAppliedAmount,
|
||||
PaymentApplication::getPaidAmount)
|
||||
.eq(PaymentApplication::getPaymentType, "progress_advance")
|
||||
.in(PaymentApplication::getPreSettlementId, preSettlementIds)
|
||||
.eq(PaymentApplication::getApprovalStatus, STATUS_APPROVED)
|
||||
.eq(PaymentApplication::getIsDeleted, 0));
|
||||
Map<Long, BigDecimal> appliedAmounts = new HashMap<>();
|
||||
Map<Long, BigDecimal> paidAmounts = new HashMap<>();
|
||||
applications.forEach(application -> {
|
||||
Long preSettlementId = application.getPreSettlementId();
|
||||
appliedAmounts.merge(preSettlementId, money(application.getAppliedAmount()), BigDecimal::add);
|
||||
paidAmounts.merge(preSettlementId, money(application.getPaidAmount()), BigDecimal::add);
|
||||
});
|
||||
// 多选预结算单时付款申请主表只存第一张,按关联行逐单归集;已付金额按关联行顺序分摊实付金额,
|
||||
// 与同步金蝶预付建档(syncPreSettlementAdvance)同一口径,避免第二张及之后的单据金额恒为0
|
||||
List<PaymentApplicationSettlement> relations = settlementRelationMapper.selectList(
|
||||
Wrappers.<PaymentApplicationSettlement>lambdaQuery()
|
||||
.in(PaymentApplicationSettlement::getPreSettlementId, preSettlementIds)
|
||||
.eq(PaymentApplicationSettlement::getIsDeleted, 0)
|
||||
.orderByAsc(PaymentApplicationSettlement::getId));
|
||||
Set<Long> relationPaymentIds = relations.stream()
|
||||
.map(PaymentApplicationSettlement::getPaymentApplicationId).filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
Map<Long, PaymentApplication> relationPayments = relationPaymentIds.isEmpty() ? new HashMap<>()
|
||||
: paymentApplicationMapper.selectBatchIds(relationPaymentIds).stream()
|
||||
.filter(application -> !Objects.equals(application.getIsDeleted(), 1))
|
||||
.filter(application -> TransportBusiConstant.APPROVED.equals(application.getApprovalStatus()))
|
||||
.filter(application -> "progress_advance".equals(application.getPaymentType()))
|
||||
.collect(Collectors.toMap(PaymentApplication::getId, Function.identity(), (left, right) -> left));
|
||||
relations.stream()
|
||||
.filter(relation -> relationPayments.containsKey(relation.getPaymentApplicationId()))
|
||||
.collect(Collectors.groupingBy(PaymentApplicationSettlement::getPaymentApplicationId,
|
||||
LinkedHashMap::new, Collectors.toList()))
|
||||
.forEach((paymentId, paymentRelations) -> {
|
||||
BigDecimal remainingPaid = money(relationPayments.get(paymentId).getPaidAmount());
|
||||
for (int index = 0; index < paymentRelations.size(); index++) {
|
||||
PaymentApplicationSettlement relation = paymentRelations.get(index);
|
||||
BigDecimal applied = money(relation.getAppliedAmount());
|
||||
BigDecimal paid = index == paymentRelations.size() - 1 ? remainingPaid
|
||||
: applied.min(remainingPaid);
|
||||
remainingPaid = remainingPaid.subtract(paid);
|
||||
appliedAmounts.merge(relation.getPreSettlementId(), applied, BigDecimal::add);
|
||||
paidAmounts.merge(relation.getPreSettlementId(), paid, BigDecimal::add);
|
||||
}
|
||||
});
|
||||
// 历史仅写主表、无关联行的付款申请按主表兜底
|
||||
paymentApplicationMapper.selectList(
|
||||
Wrappers.<PaymentApplication>lambdaQuery()
|
||||
.select(PaymentApplication::getId, PaymentApplication::getPreSettlementId,
|
||||
PaymentApplication::getAppliedAmount, PaymentApplication::getPaidAmount)
|
||||
.in(PaymentApplication::getPreSettlementId, preSettlementIds)
|
||||
.eq(PaymentApplication::getPaymentType, "progress_advance")
|
||||
.eq(PaymentApplication::getApprovalStatus, STATUS_APPROVED)
|
||||
.eq(PaymentApplication::getIsDeleted, 0)
|
||||
.notIn(!relationPaymentIds.isEmpty(), PaymentApplication::getId, relationPaymentIds))
|
||||
.forEach(application -> {
|
||||
appliedAmounts.merge(application.getPreSettlementId(), money(application.getAppliedAmount()), BigDecimal::add);
|
||||
paidAmounts.merge(application.getPreSettlementId(), money(application.getPaidAmount()), BigDecimal::add);
|
||||
});
|
||||
records.forEach(record -> {
|
||||
record.setAdvanceAppliedAmount(money(appliedAmounts.get(record.getId())));
|
||||
record.setAdvancePaidAmount(money(paidAmounts.get(record.getId())));
|
||||
@@ -233,13 +270,34 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
.or().like(ContractManage::getContractNo, keyword))
|
||||
.orderByDesc(ContractManage::getCreateTime);
|
||||
List<ContractManage> contracts = contractManageService.list(wrapper).stream()
|
||||
.filter(this::isAvailableContract).toList();
|
||||
.filter(contract -> contract.getProjectId() != null
|
||||
&& List.of("客户合同", "承运商合同").contains(contract.getContractCategory())
|
||||
&& contractManageService.isUsableForSettlement(contract))
|
||||
.toList();
|
||||
if (contracts.isEmpty()) return List.of();
|
||||
// 项目与客户均批量查询,避免逐条 N+1 查询导致接口超时
|
||||
Set<Long> projectIds = contracts.stream().map(ContractManage::getProjectId)
|
||||
.filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
Map<Long, ProjectApply> projectById = Func.isEmpty(projectIds) ? Map.of()
|
||||
: projectApplyService.listByIds(projectIds).stream()
|
||||
.collect(Collectors.toSet());
|
||||
Map<Long, ProjectApply> projectById = projectApplyService.listByIds(projectIds).stream()
|
||||
.collect(Collectors.toMap(ProjectApply::getId, Function.identity()));
|
||||
return contracts.stream().map(contract -> {
|
||||
Set<String> partyNames = contracts.stream()
|
||||
.flatMap(contract -> Stream.of(contract.getPartyA(), contract.getPartyB()))
|
||||
.filter(Func::isNotEmpty).collect(Collectors.toSet());
|
||||
Set<String> approvedCustomers = customerArchiveService.list(Wrappers.<CustomerArchive>lambdaQuery()
|
||||
.eq(CustomerArchive::getIsDeleted, 0)
|
||||
.eq(CustomerArchive::getStatus, 1)
|
||||
.eq(CustomerArchive::getApprovalStatus, "approved")
|
||||
.in(CustomerArchive::getFullName, partyNames))
|
||||
.stream().map(CustomerArchive::getFullName).collect(Collectors.toSet());
|
||||
return contracts.stream()
|
||||
.filter(contract -> {
|
||||
ProjectApply project = projectById.get(contract.getProjectId());
|
||||
return project != null && !Objects.equals(project.getIsDeleted(), 1)
|
||||
&& List.of("approved", "change_approved").contains(project.getApprovalStatus())
|
||||
&& approvedCustomers.contains(contract.getPartyA())
|
||||
&& approvedCustomers.contains(contract.getPartyB());
|
||||
})
|
||||
.map(contract -> {
|
||||
ProjectApply project = projectById.get(contract.getProjectId());
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("id", contract.getId());
|
||||
@@ -1347,19 +1405,6 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
return contract;
|
||||
}
|
||||
|
||||
private boolean isAvailableContract(ContractManage contract) {
|
||||
if (!contractManageService.isUsableForSettlement(contract) || contract.getProjectId() == null
|
||||
|| !List.of("客户合同", "承运商合同").contains(contract.getContractCategory())) {
|
||||
return false;
|
||||
}
|
||||
org.springblade.transport.pojo.entity.ProjectApply project = projectApplyService.getById(contract.getProjectId());
|
||||
if (project == null || Objects.equals(project.getIsDeleted(), 1)
|
||||
|| !List.of("approved", "change_approved").contains(project.getApprovalStatus())) {
|
||||
return false;
|
||||
}
|
||||
return isApprovedCustomer(contract.getPartyA()) && isApprovedCustomer(contract.getPartyB());
|
||||
}
|
||||
|
||||
private Map<String, String> contractFeeTypeMap(Long contractId) {
|
||||
ContractManage contract = contractId == null ? null : contractManageService.getById(contractId);
|
||||
Map<String, String> result = new LinkedHashMap<>();
|
||||
|
||||
+11
-1
@@ -195,7 +195,17 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean submitApproval(Long id) {
|
||||
ProjectApply projectApply = loadEditable(id, true);
|
||||
ProjectApply projectApply = loadExists(id);
|
||||
if (!canCurrentUserOperate(projectApply)) {
|
||||
throw new ServiceException("无权操作其他组织项目立项");
|
||||
}
|
||||
// 已在审批中则幂等成功,避免「先 MK 后本地」时 sync 已写成 reviewing 再被编辑校验拦住
|
||||
if (List.of(STATUS_REVIEWING, STATUS_CHANGE_REVIEWING).contains(projectApply.getApprovalStatus())) {
|
||||
return true;
|
||||
}
|
||||
if (!List.of(STATUS_DRAFT, STATUS_WITHDRAWN, STATUS_REJECTED, "change_rejected").contains(projectApply.getApprovalStatus())) {
|
||||
throw new ServiceException("当前状态不允许提交");
|
||||
}
|
||||
validateSubmit(projectApply);
|
||||
projectApply.setApprovalStatus(STATUS_REVIEWING);
|
||||
projectApply.setCurrentNode(resolveApprovalNode(projectApply));
|
||||
|
||||
+30
-2
@@ -170,7 +170,17 @@ public class ProjectChangeServiceImpl extends BaseServiceImpl<ProjectChangeMappe
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean submitApproval(Long id) {
|
||||
ProjectChange projectChange = loadEditable(id);
|
||||
ProjectChange projectChange = loadExists(id);
|
||||
if (!canCurrentUserOperate(projectChange)) {
|
||||
throw new ServiceException("无权操作其他组织项目变更");
|
||||
}
|
||||
// 已在审批中则幂等成功,避免 MK 回写 reviewing 后再提交被编辑校验拦住
|
||||
if (Objects.equals(projectChange.getApprovalStatus(), STATUS_REVIEWING)) {
|
||||
return true;
|
||||
}
|
||||
if (!List.of(STATUS_DRAFT, STATUS_WITHDRAWN, STATUS_REJECTED).contains(projectChange.getApprovalStatus())) {
|
||||
throw new ServiceException("当前状态不允许提交");
|
||||
}
|
||||
validateSubmit(projectChange);
|
||||
projectChange.setApprovalStatus(STATUS_REVIEWING);
|
||||
projectChange.setCurrentNode(resolveApprovalNode(projectChange));
|
||||
@@ -188,7 +198,11 @@ public class ProjectChangeServiceImpl extends BaseServiceImpl<ProjectChangeMappe
|
||||
String processor = TransportBusinessSupport.trimToNull(AuthUtil.getUserName());
|
||||
projectChange.setCurrentProcessor(processor == null ? "系统" : processor);
|
||||
projectChange.setApprovedTime(LocalDateTime.now());
|
||||
projectChange.setSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED);
|
||||
// 仅原项目已推送合规(有合规单据ID标识 hcs_fd_id)的变更单参与合规推送(C102-2),存量未推送项目不置同步状态
|
||||
ProjectApply projectApply = projectApplyService.getById(projectChange.getProjectId());
|
||||
if (projectApply != null && Func.isNotBlank(projectApply.getHcsFdId())) {
|
||||
projectChange.setSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED);
|
||||
}
|
||||
updateById(projectChange);
|
||||
projectApplyService.applyChangeFromRecord(projectChange);
|
||||
return projectChange.getProjectId();
|
||||
@@ -230,6 +244,20 @@ public class ProjectChangeServiceImpl extends BaseServiceImpl<ProjectChangeMappe
|
||||
return deleteLogic(idList);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean writeBackSyncStatus(Long projectId, String syncStatus) {
|
||||
if (projectId == null) {
|
||||
return false;
|
||||
}
|
||||
// 仅回写该项目最近一条审批通过且未同步/失败的变更单,历史已闭环单据不翻动
|
||||
return update(Wrappers.<ProjectChange>lambdaUpdate()
|
||||
.eq(ProjectChange::getProjectId, projectId)
|
||||
.eq(ProjectChange::getApprovalStatus, STATUS_APPROVED)
|
||||
.in(ProjectChange::getSyncStatus, List.of(HcsSyncConstant.HCS_STATUS_UNSYNCED, HcsSyncConstant.HCS_STATUS_FAILED))
|
||||
.set(ProjectChange::getSyncStatus, syncStatus)
|
||||
.last("order by approved_time desc limit 1"));
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<ProjectChange> buildQuery(ProjectChangeVO projectChange) {
|
||||
TransportBusinessSupport.validateAllDept(projectChange.getAllDept(), "项目变更");
|
||||
LambdaQueryWrapper<ProjectChange> queryWrapper = Wrappers.<ProjectChange>lambdaQuery().eq(ProjectChange::getIsDeleted, 0);
|
||||
|
||||
+15
-145
@@ -33,12 +33,6 @@ 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.thirdparty.bfi.constant.BfiConstant;
|
||||
import org.springblade.thirdparty.bfi.pojo.dto.BfiRecBillQueryDTO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiRecBillVO;
|
||||
import org.springblade.thirdparty.bfi.service.IBfiService;
|
||||
import org.springblade.thirdparty.bfi.util.BfiUtil;
|
||||
import org.springblade.transport.constant.KingdeeBfiConstant;
|
||||
import org.springblade.transport.mapper.FormalSettlementMapper;
|
||||
import org.springblade.transport.mapper.KingdeeReceiptFlowMapper;
|
||||
import org.springblade.transport.mapper.ReceiptClaimMapper;
|
||||
@@ -53,20 +47,21 @@ import org.springblade.transport.pojo.entity.ReceiptFlowRecord;
|
||||
import org.springblade.transport.pojo.vo.ReceiptClaimRecordVO;
|
||||
import org.springblade.transport.service.IReceiptClaimRecordService;
|
||||
import org.springblade.transport.service.IFormalSettlementService;
|
||||
import org.springblade.transport.support.hcs.HcsClaimSyncSupport;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
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;
|
||||
|
||||
@@ -84,21 +79,13 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl<ReceiptClaimM
|
||||
private static final String CLAIMED = "claimed";
|
||||
private static final String VOIDED = "voided";
|
||||
private static final String APPROVED = "approved";
|
||||
/**
|
||||
* A301-1 收款单查询回看窗口天数(金蝶约束业务日期范围不可超过1个月)
|
||||
*/
|
||||
private static final int RESULT_WINDOW_DAYS = 30;
|
||||
/**
|
||||
* A301-1 收款单查询分页大小
|
||||
*/
|
||||
private static final int RESULT_PAGE_SIZE = 200;
|
||||
|
||||
private final ReceiptClaimSettlementMapper claimSettlementMapper;
|
||||
private final IFormalSettlementService formalSettlementService;
|
||||
private final KingdeeReceiptFlowMapper receiptFlowMapper;
|
||||
private final FormalSettlementMapper formalSettlementMapper;
|
||||
private final ReceiptFlowRecordMapper recordMapper;
|
||||
private final IBfiService bfiService;
|
||||
private final HcsClaimSyncSupport hcsClaimSyncSupport;
|
||||
|
||||
@Override
|
||||
public IPage<ReceiptClaimRecordVO> selectPage(IPage<ReceiptClaimRecordVO> page,
|
||||
@@ -203,10 +190,6 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl<ReceiptClaimM
|
||||
if (flow == null || Objects.equals(flow.getIsDeleted(), 1)) {
|
||||
throw new ServiceException("关联收款流水不存在");
|
||||
}
|
||||
// 已推送金蝶的认领不允许作废:金蝶收款单已生成,冲销/调整由人工在金蝶处置
|
||||
if (KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_PUSHED.equals(flow.getPushStatus())) {
|
||||
throw new ServiceException("该认领已推送金蝶,不允许作废,请在金蝶侧人工处置");
|
||||
}
|
||||
|
||||
List<ReceiptClaimSettlement> relations = claimSettlementMapper.selectList(
|
||||
Wrappers.<ReceiptClaimSettlement>lambdaQuery()
|
||||
@@ -256,9 +239,8 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl<ReceiptClaimM
|
||||
flow.setClaimStatus(amountClaimStatus(flowClaimedAfter, claimCeiling(flow)));
|
||||
receiptFlowMapper.updateById(flow);
|
||||
|
||||
// 作废不生成金蝶认领冲单:金蝶单号/状态保留原值(未推送为空,已推送为真实收款单信息),
|
||||
// 金蝶侧冲销/红冲由人工处置;冲单逻辑后续迭代如需恢复,放开下行调用即可
|
||||
// String kingdeeBillNo = buildKingdeeBillNo(claim.getId());
|
||||
// 作废不生成金蝶认领冲单:金蝶单号/状态保留原值(金蝶收款单已在 A301-1 同步落库),
|
||||
// 金蝶侧冲销/红冲由人工处置
|
||||
claim.setClaimStatus(VOIDED);
|
||||
claim.setVoidedBy(AuthUtil.getUserId());
|
||||
claim.setVoidedByName(Func.isEmpty(AuthUtil.getUserName()) ? claim.getClaimerName()
|
||||
@@ -278,108 +260,14 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl<ReceiptClaimM
|
||||
: AuthUtil.getUserName());
|
||||
operationRecord.setContent("作废认领,回退认领金额 " + money(claim.getClaimAmount()));
|
||||
recordMapper.insert(operationRecord);
|
||||
}
|
||||
|
||||
/**
|
||||
* A301-1 收款单查询回写认领结果:按来源单据编码匹配金蝶收款单,推进流水金蝶回写状态
|
||||
* <p>
|
||||
* 回写口径:A301-1 契约本身不支持按来源单号过滤,故按近 30 天业务日期窗口全量翻页后,
|
||||
* 以金蝶收款单 sourcebillnumber == 流水 awrp_src_code(A301-3 推送时按流水生成、重试复用)在本地匹配;
|
||||
* 仅推进金蝶回写状态与补登收款单号,不改本地认领金额/分摊;名下未作废认领主单冗余展示字段级联刷新。
|
||||
*
|
||||
* @return 回写条数
|
||||
*/
|
||||
@Override
|
||||
public int refreshKingdeeResult() {
|
||||
List<KingdeeReceiptFlow> pending = receiptFlowMapper.selectList(
|
||||
Wrappers.<KingdeeReceiptFlow>lambdaQuery()
|
||||
.eq(KingdeeReceiptFlow::getPushStatus, KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_PUSHED)
|
||||
.in(KingdeeReceiptFlow::getKingdeeBillStatus,
|
||||
List.of(KingdeeBfiConstant.KINGDEE_STATUS_PUSHED, APPROVED))
|
||||
.eq(KingdeeReceiptFlow::getStatus, 1)
|
||||
.orderByAsc(KingdeeReceiptFlow::getId));
|
||||
if (pending.isEmpty()) {
|
||||
log.info("金蝶认领结果回写:无待回写流水(已推送/已审核且未终态)");
|
||||
return 0;
|
||||
}
|
||||
Set<String> srcCodes = pending.stream()
|
||||
.map(KingdeeReceiptFlow::getAwrpSrcCode)
|
||||
.filter(Func::isNotEmpty)
|
||||
.collect(Collectors.toSet());
|
||||
if (srcCodes.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
log.info("金蝶认领结果回写:待回写流水 {} 条", pending.size());
|
||||
|
||||
LocalDate end = LocalDate.now();
|
||||
LocalDate begin = end.minusDays(RESULT_WINDOW_DAYS);
|
||||
long start = System.currentTimeMillis();
|
||||
log.info("调用BFI接口 收款单查询开始,业务日期窗口 {} ~ {}", begin, end);
|
||||
List<BfiRecBillVO> rows = BfiUtil.queryAllPages(pageNo -> {
|
||||
BfiRecBillQueryDTO dto = new BfiRecBillQueryDTO();
|
||||
dto.setPageNo(pageNo);
|
||||
dto.setPageSize(RESULT_PAGE_SIZE);
|
||||
dto.setBeginBizdate(begin.format(DateTimeFormatter.ISO_LOCAL_DATE));
|
||||
dto.setEndBizdate(end.format(DateTimeFormatter.ISO_LOCAL_DATE));
|
||||
return bfiService.queryRecBill(dto);
|
||||
// 作废事务提交后同步合规 C107-3(fdStatus=1 已作废,失败仅记日志不回滚作废)
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
hcsClaimSyncSupport.voidClaimToHcs(claim.getId());
|
||||
}
|
||||
});
|
||||
log.info("调用BFI接口 收款单查询结束 耗时:{}ms,拉取 {} 条", System.currentTimeMillis() - start, rows.size());
|
||||
|
||||
// 窗口内收款单按 sourcebillnumber 匹配待回写来源编码,同键多单取首条
|
||||
Map<String, BfiRecBillVO> matched = rows.stream()
|
||||
.filter(row -> Func.isNotEmpty(row.getSourcebillnumber()))
|
||||
.filter(row -> srcCodes.contains(row.getSourcebillnumber()))
|
||||
.collect(Collectors.toMap(BfiRecBillVO::getSourcebillnumber, Function.identity(),
|
||||
(first, second) -> first, HashMap::new));
|
||||
int updated = 0;
|
||||
for (KingdeeReceiptFlow flow : pending) {
|
||||
BfiRecBillVO bill = matched.get(flow.getAwrpSrcCode());
|
||||
if (bill == null) {
|
||||
// 窗口内未查到:金蝶回单延迟/状态未同步,不改状态,由下次回写或人工处置兜底
|
||||
continue;
|
||||
}
|
||||
// 推送成功时金蝶收款单号先以来源编码占位(A301-3 响应 number 为空,取回执 keys.awrp_src_code),
|
||||
// 回写命中后以金蝶真实收款单号覆盖
|
||||
if (Func.isNotEmpty(bill.getBillno())) {
|
||||
flow.setKingdeeBillNo(bill.getBillno());
|
||||
}
|
||||
flow.setKingdeeBillStatus(billStatusOf(bill.getBillstatus()));
|
||||
receiptFlowMapper.updateById(flow);
|
||||
// 级联刷新名下未作废认领主单的冗余展示字段
|
||||
baseMapper.update(null, Wrappers.<ReceiptClaim>lambdaUpdate()
|
||||
.eq(ReceiptClaim::getReceiptFlowId, flow.getId())
|
||||
.eq(ReceiptClaim::getClaimStatus, CLAIMED)
|
||||
.set(ReceiptClaim::getKingdeeBillNo, flow.getKingdeeBillNo())
|
||||
.set(ReceiptClaim::getKingdeeBillStatus, flow.getKingdeeBillStatus()));
|
||||
updated++;
|
||||
log.info("金蝶认领结果回写 flowId:{},awrpSrcCode:{},金蝶收款单号:{},状态:{} → {}",
|
||||
flow.getId(), flow.getAwrpSrcCode(), bill.getBillno(), bill.getBillstatus(),
|
||||
flow.getKingdeeBillStatus());
|
||||
}
|
||||
log.info("金蝶认领结果回写完成:匹配 {} 条,更新 {} 条", matched.size(), updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* 金蝶收款单单据状态 → 本地认领金蝶状态
|
||||
*
|
||||
* @param billStatus 金蝶 billstatus(A暂存/B已提交/C已审核/D已收款/G已退单)
|
||||
* @return 本地状态
|
||||
*/
|
||||
private String billStatusOf(String billStatus) {
|
||||
if (BfiConstant.BILL_STATUS_RECEIVED.equals(billStatus)) {
|
||||
return KingdeeBfiConstant.KINGDEE_STATUS_RECEIVED;
|
||||
}
|
||||
if (BfiConstant.BILL_STATUS_AUDITED.equals(billStatus)) {
|
||||
return APPROVED;
|
||||
}
|
||||
if (BfiConstant.BILL_STATUS_DRAFT.equals(billStatus)
|
||||
|| BfiConstant.BILL_STATUS_SUBMITTED.equals(billStatus)) {
|
||||
return KingdeeBfiConstant.KINGDEE_STATUS_PUSHED;
|
||||
}
|
||||
// 其余状态(含 G 已退单)按处理失败回收,提示人工在金蝶核查
|
||||
log.warn("金蝶收款单状态[{}]未匹配映射,认领回写按 failed 处理", billStatus);
|
||||
return KingdeeBfiConstant.KINGDEE_STATUS_FAILED;
|
||||
}
|
||||
|
||||
private void fillStatusNames(ReceiptClaimRecordVO record) {
|
||||
@@ -399,17 +287,6 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl<ReceiptClaimM
|
||||
return VOIDED.equals(claimStatus) ? VOIDED : CLAIMED;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成本地金蝶认领冲单号(KDCX + 时间戳 + 认领id尾号6位)。
|
||||
* 当前作废流程不生成冲单(调用点已注释保留),后续迭代接金蝶冲销时放开即可。
|
||||
*/
|
||||
@SuppressWarnings("unused")
|
||||
private String buildKingdeeBillNo(Long claimId) {
|
||||
String time = DateTimeFormatter.ofPattern("yyyyMMddHHmmss").format(LocalDateTime.now());
|
||||
String suffix = String.valueOf(claimId);
|
||||
return "KDCX" + time + suffix.substring(Math.max(0, suffix.length() - 6));
|
||||
}
|
||||
|
||||
/**
|
||||
* 累计认领金额 → 流水认领状态:对照可认领上限判定(与收款流水侧口径一致)
|
||||
*/
|
||||
@@ -421,18 +298,11 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl<ReceiptClaimM
|
||||
}
|
||||
|
||||
/**
|
||||
* 可认领上限:公告未认领金额与收款金额取较小正值(与收款流水侧口径一致)
|
||||
* 可认领上限:收款金额(A301-1 actrecamt,与收款流水侧口径一致)。
|
||||
* 认领只登记本地,额度由本地累计认领控制,金蝶未匹配金额不参与。
|
||||
*/
|
||||
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);
|
||||
return money(flow.getReceiptAmount());
|
||||
}
|
||||
|
||||
private String amountStatus(BigDecimal paidAmount, BigDecimal settlementAmount) {
|
||||
|
||||
+294
-554
@@ -38,12 +38,9 @@ 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.thirdparty.bfi.pojo.dto.BfiClaimAnnounceQueryDTO;
|
||||
import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimPushDTO;
|
||||
import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimPushEntryDTO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultItemVO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiClaimAnnounceVO;
|
||||
import org.springblade.thirdparty.bfi.pojo.dto.BfiRecBillQueryDTO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiPageVO;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiRecBillVO;
|
||||
import org.springblade.thirdparty.bfi.service.IBfiService;
|
||||
import org.springblade.thirdparty.bfi.util.BfiUtil;
|
||||
import org.springblade.transport.config.BfiProperties;
|
||||
@@ -53,7 +50,6 @@ import org.springblade.transport.mapper.KingdeeReceiptFlowMapper;
|
||||
import org.springblade.transport.mapper.ReceiptClaimMapper;
|
||||
import org.springblade.transport.mapper.ReceiptClaimSettlementMapper;
|
||||
import org.springblade.transport.mapper.ReceiptFlowRecordMapper;
|
||||
import org.springblade.transport.pojo.dto.ReceiptClaimPushResult;
|
||||
import org.springblade.transport.pojo.dto.ReceiptClaimRequest;
|
||||
import org.springblade.transport.pojo.entity.FormalSettlement;
|
||||
import org.springblade.transport.pojo.entity.KingdeeReceiptFlow;
|
||||
@@ -63,12 +59,13 @@ import org.springblade.transport.pojo.entity.ReceiptFlowRecord;
|
||||
import org.springblade.transport.pojo.vo.ReceiptFlowVO;
|
||||
import org.springblade.transport.service.IFormalSettlementService;
|
||||
import org.springblade.transport.service.IReceiptFlowService;
|
||||
import org.springblade.transport.support.KingdeeSyncLockSupport;
|
||||
import org.springblade.transport.support.TransportBusinessSupport;
|
||||
import org.springblade.transport.support.hcs.HcsClaimSyncSupport;
|
||||
import org.springblade.transport.wrapper.ReceiptFlowWrapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -77,7 +74,6 @@ import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@@ -109,18 +105,23 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
// 应收款
|
||||
private static final String RECEIVABLE = "receivable";
|
||||
/**
|
||||
* A301-2 认领公告同步分页大小
|
||||
* A301-1 收款单查询分页大小
|
||||
*/
|
||||
private static final int SYNC_PAGE_SIZE = 200;
|
||||
/**
|
||||
* A301-2 交易时间格式
|
||||
* 同步分批入库批次大小:累计满一批即独立事务入库,避免全量驻留内存
|
||||
*/
|
||||
private static final int SYNC_BATCH_SIZE = 5000;
|
||||
/**
|
||||
* A301-1 单片翻页最大页数保护(200条/页 × 500页):触顶说明该时间片数据超过单查询窗口上限,
|
||||
* 由 BfiUtil 二分切片续扫保证尾部不漏
|
||||
*/
|
||||
private static final int SYNC_MAX_PAGES = 500;
|
||||
/**
|
||||
* A301-1 业务日期时间格式(请求参数与返回 bizdate 均为 yyyy-MM-dd HH:mm:ss)
|
||||
*/
|
||||
private static final DateTimeFormatter BFI_DATE_TIME_FORMATTER =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
/**
|
||||
* 定时推送收款认领 single-flight 锁键前缀(多副本定时任务共用)
|
||||
*/
|
||||
private static final String RECEIPT_CLAIM_PUSH_LOCK_PREFIX = "transport:receipt-flow:kingdee-claim-push:";
|
||||
|
||||
private final ReceiptClaimMapper claimMapper;
|
||||
private final ReceiptClaimSettlementMapper claimSettlementMapper;
|
||||
@@ -130,7 +131,6 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
private final IBfiService bfiService;
|
||||
private final BfiProperties bfiProperties;
|
||||
private final TransactionTemplate transactionTemplate;
|
||||
private final KingdeeSyncLockSupport kingdeeSyncLockSupport;
|
||||
private final HcsClaimSyncSupport hcsClaimSyncSupport;
|
||||
|
||||
@Override
|
||||
@@ -316,9 +316,6 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
|
||||
// 收款流水
|
||||
KingdeeReceiptFlow flow = lockedFlow(request.getFlowId());
|
||||
if (flow.getBillId() == null) {
|
||||
throw new ServiceException("认领公告id缺失,请先手动同步金蝶收款流水");
|
||||
}
|
||||
// 结算单id → 分摊金额
|
||||
Map<Long, BigDecimal> allocationMap = allocationMap(request.getSettlements());
|
||||
// 结算单id列表(升序)
|
||||
@@ -347,10 +344,7 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
allocatedTotal = allocatedTotal.add(allocated);
|
||||
}
|
||||
|
||||
// 认领上限:公告未认领金额与收款金额取较小值(避免未认领金额异常偏大导致无法认领完成)
|
||||
if (flow.getUnclaimedAmount() == null) {
|
||||
throw new ServiceException("公告未认领金额缺失,请先手动同步金蝶收款流水");
|
||||
}
|
||||
// 认领上限:收款金额(认领只登记本地,额度由本地累计认领控制,金蝶侧匹配状态不参与)
|
||||
BigDecimal claimCeiling = claimCeiling(flow);
|
||||
if (claimCeiling.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new ServiceException("该流水可认领金额为0,无需认领");
|
||||
@@ -386,8 +380,9 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
claim.setRemark(trimToNull(request.getRemark()));
|
||||
// 认领状态
|
||||
claim.setClaimStatus(CLAIMED);
|
||||
// 金蝶回写状态(待推送)
|
||||
claim.setKingdeeBillStatus(KingdeeBfiConstant.RECEIPT_CLAIM_BILL_NONE);
|
||||
// 金蝶收款单号/状态(冗余展示:A301-1 同步落库的单据信息)
|
||||
claim.setKingdeeBillNo(flow.getKingdeeBillNo());
|
||||
claim.setKingdeeBillStatus(flow.getKingdeeBillStatus());
|
||||
claimMapper.insert(claim);
|
||||
|
||||
for (FormalSettlement settlement : settlements) {
|
||||
@@ -436,149 +431,241 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
record(flow.getId(), claim.getId(), "claim", "认领收款流水", fromStatus, toStatus,
|
||||
allocatedTotal, "关联" + settlements.size() + "张应收正式结算单");
|
||||
|
||||
// 认领只登记本地,不推送金蝶;推送由定时任务/手动按钮在认领完成后触发
|
||||
// 认领只登记本地,不推送金蝶;事务提交后按认领主单推送合同合规(C103-9/C107-1/C108-1,失败仅落标记)
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
hcsClaimSyncSupport.pushClaimToHcs(claim.getId());
|
||||
}
|
||||
});
|
||||
return claim.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动同步金蝶收款流水:直连 A301-1 收款单查询,收款单主单粒度、以金蝶收款单号为幂等键 upsert;
|
||||
* 数据量大,时间窗口二分切片续扫(BfiUtil.forEachWindowSlice):单片触顶自动二分续扫,
|
||||
* 总覆盖不受单查询窗口上限约束,尾部不漏;累计满一批(5000 条流水行)即独立事务入库一批,
|
||||
* 不全量驻留内存
|
||||
*
|
||||
* @return 本次同步条数(新增+更新)
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public int sync() {
|
||||
String claimusers = UserCache.getUserRealName(AuthUtil.getUserId());
|
||||
if (Func.isEmpty(claimusers)) {
|
||||
throw new ServiceException("当前用户无姓名,无法同步金蝶认领公告");
|
||||
int days = bfiProperties.getRecBillSync().getDays();
|
||||
LocalDate endDate = LocalDate.now();
|
||||
LocalDate startDate = endDate.minusDays(days);
|
||||
// 金蝶 A301-1 仅接受带时分秒的日期参数,传纯日期会报 bos.filterSqlBuilder 时区时间格式错误
|
||||
LocalDateTime windowStart = startDate.atStartOfDay();
|
||||
LocalDateTime windowEnd = endDate.atTime(23, 59, 59);
|
||||
log.info("收款流水同步开始,业务日期窗口:{} ~ {}(近{}天)",
|
||||
windowStart.format(BFI_DATE_TIME_FORMATTER), windowEnd.format(BFI_DATE_TIME_FORMATTER), days);
|
||||
long start = System.currentTimeMillis();
|
||||
// 同步口径:按业务日期窗口拉取金蝶收款单(收款处理),以金蝶明细流水号为幂等键 upsert 收款流水表;
|
||||
// 本地已认领金额/状态(claimedAmount/claimStatus)由认领、作废驱动,不被同步顺带覆盖,
|
||||
// 未匹配收款金额单独存 unmatch_amount_rec,仅作金蝶参考展示,不参与认领额度。
|
||||
// 按组织过滤:组织管理(blade_dept)中平台公司(是否平台公司=是)且已维护金蝶编码的组织
|
||||
List<String> orgNumbers = baseMapper.selectPlatformOrgNumbers();
|
||||
if (Func.isEmpty(orgNumbers)) {
|
||||
throw new ServiceException("组织管理中未维护平台公司金蝶编码,无法同步金蝶收款流水,请先检查组织数据");
|
||||
}
|
||||
List<BfiClaimAnnounceVO> announces = queryClaimAnnounces(claimusers);
|
||||
|
||||
if (announces.isEmpty()) {
|
||||
record(null, null, "sync", "手动同步流水", null, null, BigDecimal.ZERO,
|
||||
"金蝶认领公告未查询到数据");
|
||||
return 0;
|
||||
log.info("收款流水同步按组织过滤,组织编码:{}", orgNumbers);
|
||||
RecBillSyncState state = new RecBillSyncState();
|
||||
state.sliceCount += BfiUtil.forEachWindowSlice("收款流水同步", windowStart, windowEnd,
|
||||
SYNC_PAGE_SIZE, SYNC_MAX_PAGES,
|
||||
(sliceStart, sliceEnd, pageNo) -> this.pullRecBillPage(sliceStart, sliceEnd, orgNumbers, pageNo),
|
||||
rows -> this.handleRecBillPage(rows, state));
|
||||
// 尾批不足 SYNC_BATCH_SIZE 的剩余入库
|
||||
if (!state.pending.isEmpty()) {
|
||||
state.batchNo++;
|
||||
state.totalCount += this.syncBatch(state.batchNo, state.pending);
|
||||
state.pending.clear();
|
||||
state.batchSerials.clear();
|
||||
}
|
||||
if (state.pullCount == 0) {
|
||||
this.record(null, null, "sync", "手动同步流水", null, null, BigDecimal.ZERO,
|
||||
"金蝶收款单未查询到数据");
|
||||
} else if (state.totalCount == 0) {
|
||||
this.record(null, null, "sync", "手动同步流水", null, null, BigDecimal.ZERO,
|
||||
"金蝶收款单无有效数据");
|
||||
}
|
||||
log.info("金蝶收款流水同步完成,翻页{}页切片{}片,拉取收款单 {} 张,新增/更新 {} 条,跳过 {} 条,"
|
||||
+ "分 {} 批入库,耗时 {}ms",
|
||||
state.pageCount, state.sliceCount, state.pullCount, state.totalCount, state.skippedCount,
|
||||
state.batchNo, System.currentTimeMillis() - start);
|
||||
return state.totalCount;
|
||||
}
|
||||
|
||||
// 同步口径:A301-2 按参与认领用户(当前登录用户真实姓名)拉取全量认领公告,
|
||||
// 以金蝶明细流水号为幂等键 upsert 收款流水表;本地已认领金额/状态(claimedAmount/claimStatus)
|
||||
// 由认领、作废驱动,不被公告顺带覆盖,公告侧状态单独存 kingdee_claim_status/unclaimed_amount。
|
||||
/**
|
||||
* A301-1 按业务日期时间片分页拉取(组织编码过滤整轮共享,切片起止由 BfiUtil 二分传入)
|
||||
*
|
||||
* @param sliceStart 切片起点(含)
|
||||
* @param sliceEnd 切片终点(含)
|
||||
* @param orgNumbers 平台公司组织编码集
|
||||
* @param pageNo 页码,从 1 开始
|
||||
* @return 分页结果
|
||||
*/
|
||||
private BfiPageVO<BfiRecBillVO> pullRecBillPage(LocalDateTime sliceStart, LocalDateTime sliceEnd,
|
||||
List<String> orgNumbers, int pageNo) {
|
||||
BfiRecBillQueryDTO queryDTO = new BfiRecBillQueryDTO();
|
||||
queryDTO.setPageNo(pageNo);
|
||||
queryDTO.setPageSize(SYNC_PAGE_SIZE);
|
||||
queryDTO.setBeginBizdate(sliceStart.format(BFI_DATE_TIME_FORMATTER));
|
||||
queryDTO.setEndBizdate(sliceEnd.format(BFI_DATE_TIME_FORMATTER));
|
||||
queryDTO.setOrgNumber(orgNumbers);
|
||||
return bfiService.queryRecBill(queryDTO);
|
||||
}
|
||||
|
||||
// 第一遍:数据校验与过滤,收集有效的 detailSerialNo
|
||||
log.info("收款流水同步开始数据校验,共 {} 条认领公告", announces.size());
|
||||
Map<String, BfiClaimAnnounceVO> validAnnounces = new LinkedHashMap<>();
|
||||
int skippedCount = 0;
|
||||
for (BfiClaimAnnounceVO announce : announces) {
|
||||
// 明细流水号优先级:detailid > tradedetailno > bankdetailno
|
||||
String detailSerialNo = firstNonBlank(announce.getDetailid(),
|
||||
announce.getTradedetailno(), announce.getBankdetailno());
|
||||
/**
|
||||
* A301-1 单页数据处理:收款单主单粒度逐行校验入缓冲,累计满一批即独立事务入库。
|
||||
* 切片边界同秒的收款单可能在相邻两片各拉一次,按批内收款单号去重幂等跳过,不重不漏
|
||||
*
|
||||
* @param rows 本页拉取的收款单
|
||||
* @param state 整轮同步状态(待入库缓冲与统计累加)
|
||||
*/
|
||||
private void handleRecBillPage(List<BfiRecBillVO> rows, RecBillSyncState state) {
|
||||
state.pageCount++;
|
||||
state.pullCount += rows.size();
|
||||
for (BfiRecBillVO recBill : rows) {
|
||||
// 主单粒度:一张收款单一条流水,不展开流水分录;明细流水号取收款单号(幂等键,C104-2 按此定位合规流水)
|
||||
String detailSerialNo = trimToEmpty(recBill.getBillno());
|
||||
if (Func.isEmpty(detailSerialNo)) {
|
||||
log.warn("收款流水同步跳过:认领公告[{}]缺少明细流水号", announce.getBillno());
|
||||
skippedCount++;
|
||||
log.warn("收款流水同步跳过:收款单缺少单号");
|
||||
state.skippedCount++;
|
||||
continue;
|
||||
}
|
||||
detailSerialNo = detailSerialNo.trim();
|
||||
if (validAnnounces.containsKey(detailSerialNo)) {
|
||||
throw new ServiceException("明细流水号" + detailSerialNo + "重复");
|
||||
if (!state.batchSerials.add(detailSerialNo)) {
|
||||
// 切片边界重复拉取或同批脏数据重复单号,幂等跳过(upsert 本就按单号幂等)
|
||||
log.warn("收款流水同步跳过:收款单[{}]本批已拉取,跳过重复处理", detailSerialNo);
|
||||
state.skippedCount++;
|
||||
continue;
|
||||
}
|
||||
BigDecimal receiptAmount = money(announce.getReamount());
|
||||
// 收款金额取主单 actrecamt(金蝶收款金额口径)
|
||||
BigDecimal receiptAmount = money(recBill.getActrecamt());
|
||||
if (receiptAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
log.warn("收款流水同步跳过:认领公告[{}]收款金额为空或小于0", announce.getBillno());
|
||||
skippedCount++;
|
||||
// log.warn("收款流水同步跳过:收款单[{}]收款金额(actrecamt)为空或小于0", recBill.getBillno());
|
||||
state.skippedCount++;
|
||||
continue;
|
||||
}
|
||||
validAnnounces.put(detailSerialNo, announce);
|
||||
}
|
||||
log.info("收款流水数据校验完成:有效 {} 条,跳过 {} 条", validAnnounces.size(), skippedCount);
|
||||
|
||||
if (validAnnounces.isEmpty()) {
|
||||
log.info("收款流水同步无有效数据,记录操作日志后返回");
|
||||
record(null, null, "sync", "手动同步流水", null, null, BigDecimal.ZERO,
|
||||
"金蝶认领公告无有效数据");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 批量查询已存在的记录(FOR UPDATE 防并发)
|
||||
log.info("收款流水同步开始批量查询已存在记录,查询条件:detailSerialNo IN {} 条", validAnnounces.size());
|
||||
List<KingdeeReceiptFlow> existingEntities = baseMapper.selectList(
|
||||
Wrappers.<KingdeeReceiptFlow>lambdaQuery()
|
||||
.in(KingdeeReceiptFlow::getDetailSerialNo, validAnnounces.keySet())
|
||||
.last("FOR UPDATE")
|
||||
);
|
||||
Map<String, KingdeeReceiptFlow> existingMap = existingEntities.stream()
|
||||
.collect(Collectors.toMap(KingdeeReceiptFlow::getDetailSerialNo, e -> e));
|
||||
log.info("收款流水批量查询完成:已存在 {} 条,待新增 {} 条",
|
||||
existingEntities.size(), validAnnounces.size() - existingEntities.size());
|
||||
|
||||
// 第二遍:构建新增和更新列表
|
||||
log.info("收款流水同步开始构建新增/更新列表");
|
||||
List<KingdeeReceiptFlow> toInsert = new ArrayList<>();
|
||||
List<KingdeeReceiptFlow> toUpdate = new ArrayList<>();
|
||||
int createdCount = 0;
|
||||
int updatedCount = 0;
|
||||
|
||||
for (Map.Entry<String, BfiClaimAnnounceVO> entry : validAnnounces.entrySet()) {
|
||||
String detailSerialNo = entry.getKey();
|
||||
BfiClaimAnnounceVO announce = entry.getValue();
|
||||
BigDecimal receiptAmount = money(announce.getReamount());
|
||||
|
||||
// 判断是新增还是更新
|
||||
KingdeeReceiptFlow existing = existingMap.get(detailSerialNo);
|
||||
boolean created = (existing == null);
|
||||
|
||||
KingdeeReceiptFlow entity;
|
||||
if (created) {
|
||||
// 新增:初始化本地认领金额和状态
|
||||
entity = new KingdeeReceiptFlow();
|
||||
entity.setClaimedAmount(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP));
|
||||
entity.setClaimStatus(UNCLAIMED);
|
||||
} else {
|
||||
// 更新:保留本地已认领金额和状态
|
||||
entity = existing;
|
||||
}
|
||||
|
||||
// 复制公告数据到实体(不覆盖本地认领字段)
|
||||
copyAnnounceRow(entity, announce, detailSerialNo, receiptAmount);
|
||||
// 根据本地累计认领金额与可认领上限重新计算认领状态
|
||||
entity.setClaimStatus(amountClaimStatus(money(entity.getClaimedAmount()),
|
||||
claimCeiling(entity)));
|
||||
|
||||
if (created) {
|
||||
toInsert.add(entity);
|
||||
createdCount++;
|
||||
} else {
|
||||
toUpdate.add(entity);
|
||||
updatedCount++;
|
||||
state.pending.add(new BfiRecBillRow(recBill, detailSerialNo, receiptAmount));
|
||||
if (state.pending.size() >= SYNC_BATCH_SIZE) {
|
||||
state.batchNo++;
|
||||
state.totalCount += this.syncBatch(state.batchNo, state.pending);
|
||||
state.pending.clear();
|
||||
state.batchSerials.clear();
|
||||
}
|
||||
}
|
||||
log.info("收款流水列表构建完成:待新增 {} 条,待更新 {} 条", createdCount, updatedCount);
|
||||
}
|
||||
|
||||
// 批量插入和更新
|
||||
if (!toInsert.isEmpty()) {
|
||||
log.info("收款流水同步开始批量插入 {} 条", toInsert.size());
|
||||
saveBatch(toInsert);
|
||||
log.info("收款流水批量插入完成");
|
||||
}
|
||||
if (!toUpdate.isEmpty()) {
|
||||
log.info("收款流水同步开始批量更新 {} 条", toUpdate.size());
|
||||
updateBatchById(toUpdate);
|
||||
log.info("收款流水批量更新完成");
|
||||
}
|
||||
/**
|
||||
* 单批入库(独立事务):批内 FOR UPDATE 防并发,按明细流水号 upsert,操作日志批量落库
|
||||
*
|
||||
* @param batchNo 批次号(日志用)
|
||||
* @param rows 本批流水行
|
||||
* @return 本批新增+更新条数
|
||||
*/
|
||||
private int syncBatch(int batchNo, List<BfiRecBillRow> rows) {
|
||||
Integer count = transactionTemplate.execute(status -> {
|
||||
// 批量查询已存在的记录(FOR UPDATE 防并发)
|
||||
Map<String, KingdeeReceiptFlow> existingMap = baseMapper.selectList(
|
||||
Wrappers.<KingdeeReceiptFlow>lambdaQuery()
|
||||
.in(KingdeeReceiptFlow::getDetailSerialNo,
|
||||
rows.stream().map(BfiRecBillRow::detailSerialNo).toList())
|
||||
.last("FOR UPDATE"))
|
||||
.stream().collect(Collectors.toMap(KingdeeReceiptFlow::getDetailSerialNo, e -> e));
|
||||
List<KingdeeReceiptFlow> toInsert = new ArrayList<>();
|
||||
List<KingdeeReceiptFlow> toUpdate = new ArrayList<>();
|
||||
for (BfiRecBillRow row : rows) {
|
||||
KingdeeReceiptFlow existing = existingMap.get(row.detailSerialNo());
|
||||
boolean created = (existing == null);
|
||||
KingdeeReceiptFlow entity;
|
||||
if (created) {
|
||||
// 新增:初始化本地认领金额和状态
|
||||
entity = new KingdeeReceiptFlow();
|
||||
entity.setClaimedAmount(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP));
|
||||
entity.setClaimStatus(UNCLAIMED);
|
||||
} else {
|
||||
// 更新:保留本地已认领金额和状态
|
||||
entity = existing;
|
||||
}
|
||||
// 复制收款单数据到实体(不覆盖本地认领字段)
|
||||
copyRecBillRow(entity, row);
|
||||
// 根据本地累计认领金额与可认领上限重新计算认领状态
|
||||
entity.setClaimStatus(amountClaimStatus(money(entity.getClaimedAmount()),
|
||||
claimCeiling(entity)));
|
||||
if (created) {
|
||||
toInsert.add(entity);
|
||||
} else {
|
||||
toUpdate.add(entity);
|
||||
}
|
||||
}
|
||||
if (!toInsert.isEmpty()) {
|
||||
saveBatch(toInsert);
|
||||
}
|
||||
if (!toUpdate.isEmpty()) {
|
||||
updateBatchById(toUpdate);
|
||||
}
|
||||
// 记录操作日志(批量插入,避免逐行 N+1)
|
||||
List<ReceiptFlowRecord> records = new ArrayList<>(toInsert.size() + toUpdate.size());
|
||||
for (KingdeeReceiptFlow entity : toInsert) {
|
||||
records.add(buildRecord(entity.getId(), null, "sync", "新增金蝶收款流水",
|
||||
entity.getClaimStatus(), entity.getClaimStatus(), BigDecimal.ZERO, entity.getDetailSerialNo()));
|
||||
}
|
||||
for (KingdeeReceiptFlow entity : toUpdate) {
|
||||
records.add(buildRecord(entity.getId(), null, "sync", "更新金蝶收款流水",
|
||||
entity.getClaimStatus(), entity.getClaimStatus(), BigDecimal.ZERO, entity.getDetailSerialNo()));
|
||||
}
|
||||
if (!records.isEmpty()) {
|
||||
Db.saveBatch(records);
|
||||
}
|
||||
return toInsert.size() + toUpdate.size();
|
||||
});
|
||||
log.info("收款流水同步第 {} 批入库完成,本批 {} 条", batchNo, count == null ? 0 : count);
|
||||
return count == null ? 0 : count;
|
||||
}
|
||||
|
||||
// 记录操作日志(批量插入,避免逐行 N+1)
|
||||
log.info("收款流水同步开始记录操作日志,共 {} 条", toInsert.size() + toUpdate.size());
|
||||
List<ReceiptFlowRecord> records = new ArrayList<>(toInsert.size() + toUpdate.size());
|
||||
for (KingdeeReceiptFlow entity : toInsert) {
|
||||
records.add(buildRecord(entity.getId(), null, "sync", "新增金蝶收款流水",
|
||||
entity.getClaimStatus(), entity.getClaimStatus(), BigDecimal.ZERO, entity.getDetailSerialNo()));
|
||||
}
|
||||
for (KingdeeReceiptFlow entity : toUpdate) {
|
||||
records.add(buildRecord(entity.getId(), null, "sync", "更新金蝶收款流水",
|
||||
entity.getClaimStatus(), entity.getClaimStatus(), BigDecimal.ZERO, entity.getDetailSerialNo()));
|
||||
}
|
||||
if (!records.isEmpty()) {
|
||||
Db.saveBatch(records);
|
||||
}
|
||||
log.info("收款流水操作日志记录完成");
|
||||
/**
|
||||
* A301-1 收款单查询返回的收款单 + 流水分录展开行(同步过程传递用)
|
||||
*/
|
||||
private record BfiRecBillRow(BfiRecBillVO recBill,
|
||||
String detailSerialNo, BigDecimal receiptAmount) {
|
||||
}
|
||||
|
||||
log.info("金蝶收款流水同步完成 claimusers:{},拉取 {} 条,新增 {} 条,更新 {} 条",
|
||||
claimusers, announces.size(), createdCount, updatedCount);
|
||||
return createdCount + updatedCount;
|
||||
/**
|
||||
* 收款流水整轮同步状态:待入库缓冲与统计累加(各切片共享,结束进日志摘要)
|
||||
*/
|
||||
private static final class RecBillSyncState {
|
||||
/**
|
||||
* 待入库缓冲:累计满 SYNC_BATCH_SIZE 即入库一批并清空
|
||||
*/
|
||||
final List<BfiRecBillRow> pending = new ArrayList<>();
|
||||
/**
|
||||
* 批内收款单号去重(含切片边界重复拉取)
|
||||
*/
|
||||
final Set<String> batchSerials = new HashSet<>();
|
||||
/**
|
||||
* A301-1 原始拉取收款单张数
|
||||
*/
|
||||
int pullCount;
|
||||
/**
|
||||
* 跳过条数(缺单号/金额异常/重复拉取)
|
||||
*/
|
||||
int skippedCount;
|
||||
/**
|
||||
* 新增+更新条数
|
||||
*/
|
||||
int totalCount;
|
||||
/**
|
||||
* 已入库批次数
|
||||
*/
|
||||
int batchNo;
|
||||
/**
|
||||
* 实际处理的翻页总页数
|
||||
*/
|
||||
int pageCount;
|
||||
/**
|
||||
* 实际处理的切片数
|
||||
*/
|
||||
int sliceCount;
|
||||
}
|
||||
|
||||
private Map<String, Object> candidateRow(FormalSettlement settlement, BigDecimal claimedAmount) {
|
||||
@@ -724,19 +811,12 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
}
|
||||
|
||||
/**
|
||||
* 可认领上限:公告未认领金额与收款金额取较小正值。
|
||||
* 主口径仍为 A301-2 公告未认领金额;与收款金额取 min,避免未认领金额异常偏大导致无法进入「认领完成」。
|
||||
* 可认领上限:收款金额(A301-1 actrecamt)。
|
||||
* 认领只登记本地并推合同合规,与金蝶侧收款单匹配(核销)状态无关;
|
||||
* 金蝶未匹配收款金额(unmatchamountrec)仅作参考展示,不参与额度控制。
|
||||
*/
|
||||
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);
|
||||
return money(flow.getReceiptAmount());
|
||||
}
|
||||
|
||||
private Long toLong(Object value) {
|
||||
@@ -774,405 +854,72 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动推送金蝶(A301-3 收款认领结果推送)
|
||||
* <p>
|
||||
* 仅认领完成且未推送过的流水可推送;推送前以当前用户姓名重查 A301-2 最新公告对账。
|
||||
* 推送失败不抛异常跨事务边界(保证失败状态落库),原因随结果对象返回。
|
||||
* A301-1 收款单 → 收款流水表字段拷贝(仅覆盖金蝶侧来源字段,不覆盖本地认领金额与状态)。
|
||||
* 金蝶匹配/结算金额原样落库仅作参考,不参与认领额度控制。
|
||||
*/
|
||||
@Override
|
||||
public ReceiptClaimPushResult pushClaimToKingdee(Long flowId) {
|
||||
if (flowId == null) {
|
||||
throw new ServiceException("收款流水ID不能为空");
|
||||
}
|
||||
KingdeeReceiptFlow flow = existing(flowId);
|
||||
if (flow.getBillId() == null) {
|
||||
return ReceiptClaimPushResult.fail("认领公告id缺失,请先手动同步金蝶收款流水");
|
||||
}
|
||||
String claimusers = UserCache.getUserRealName(AuthUtil.getUserId());
|
||||
if (Func.isEmpty(claimusers)) {
|
||||
throw new ServiceException("当前用户无姓名,无法推送金蝶收款认领");
|
||||
}
|
||||
// 推送前对账:以当前用户身份重查 A301-2 拿最新公告(手动推送者须在公告认领范围内)
|
||||
BfiClaimAnnounceVO announce = queryClaimAnnounces(claimusers).stream()
|
||||
.filter(item -> flow.getBillId().equals(item.getId()))
|
||||
.findFirst().orElse(null);
|
||||
if (announce == null) {
|
||||
return ReceiptClaimPushResult.fail("金蝶侧未查询到该认领公告,当前用户可能不在公告认领范围内,请重新同步后重试");
|
||||
}
|
||||
return pushClaimLocked(flow.getId(), announce);
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时推送金蝶:扫描认领完成且未推送过的流水,按认领人姓名分组重查 A301-2 最新公告后逐条推送。
|
||||
* 多副本部署下由 single-flight 分布式锁保证同一轮全局最多一份在跑,已有轮次进行中则本轮跳过。
|
||||
*
|
||||
* @return 执行摘要
|
||||
*/
|
||||
@Override
|
||||
public String pushClaimByTask() {
|
||||
return kingdeeSyncLockSupport.runSingleFlight(RECEIPT_CLAIM_PUSH_LOCK_PREFIX, bfiProperties.getReceiptClaimPush().getLockLeaseSeconds(),
|
||||
"定时推送收款认领", this::executeReceiptClaimPush);
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时推送收款认领主体:按认领人姓名串行处理——
|
||||
* 先重查该认领人的 A301-2 最新公告并推完其名下全部流水,再到下一个认领人。
|
||||
* A301-2 的 claimusers 必填且为单个用户姓名,定时任务无登录人上下文,
|
||||
* 故按认领人逐人查询公告,按公告id(bill_id)匹配流水。
|
||||
* 单条流水推送失败独立消化,不影响同批其他流水。
|
||||
*
|
||||
* @return 执行摘要
|
||||
*/
|
||||
private String executeReceiptClaimPush() {
|
||||
List<KingdeeReceiptFlow> candidates = list(Wrappers.<KingdeeReceiptFlow>lambdaQuery()
|
||||
.isNotNull(KingdeeReceiptFlow::getBillId)
|
||||
.in(KingdeeReceiptFlow::getPushStatus, List.of(
|
||||
KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_NONE, KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_FAILED))
|
||||
.eq(KingdeeReceiptFlow::getClaimStatus, CLAIMED)
|
||||
.eq(KingdeeReceiptFlow::getStatus, 1)
|
||||
.orderByAsc(KingdeeReceiptFlow::getId));
|
||||
if (candidates.isEmpty()) {
|
||||
return "无待推送流水";
|
||||
}
|
||||
Map<Long, KingdeeReceiptFlow> candidateById = candidates.stream()
|
||||
.collect(Collectors.toMap(KingdeeReceiptFlow::getId, Function.identity(),
|
||||
(first, second) -> first, LinkedHashMap::new));
|
||||
// 认领人 → 其有效认领的流水id
|
||||
Map<String, Set<Long>> claimedFlowIdsByClaimer = claimMapper.selectList(
|
||||
Wrappers.<ReceiptClaim>lambdaQuery()
|
||||
.in(ReceiptClaim::getReceiptFlowId, candidateById.keySet())
|
||||
.eq(ReceiptClaim::getClaimStatus, CLAIMED)).stream()
|
||||
.filter(claim -> Func.isNotEmpty(claim.getClaimerName()) && claim.getReceiptFlowId() != null)
|
||||
.collect(Collectors.groupingBy(ReceiptClaim::getClaimerName,
|
||||
Collectors.mapping(ReceiptClaim::getReceiptFlowId, Collectors.toSet())));
|
||||
if (claimedFlowIdsByClaimer.isEmpty()) {
|
||||
return "待推送流水 " + candidates.size() + " 条,但无有效认领人,跳过";
|
||||
}
|
||||
int successCount = 0;
|
||||
int failedCount = 0;
|
||||
int skippedCount = 0;
|
||||
// 本轮已推送成功的流水:同一流水被多个认领人认领时,只在首个覆盖它的认领人处推送一次
|
||||
Set<Long> pushedFlowIds = new HashSet<>();
|
||||
// 按认领人逐人串行:查该认领人公告 → 推完其名下流水 → 下一个认领人
|
||||
for (String claimerName : claimedFlowIdsByClaimer.keySet().stream().sorted().toList()) {
|
||||
Set<Long> claimerFlowIds = claimedFlowIdsByClaimer.get(claimerName);
|
||||
List<KingdeeReceiptFlow> claimerFlows = candidateById.values().stream()
|
||||
.filter(flow -> claimerFlowIds.contains(flow.getId()))
|
||||
.filter(flow -> !pushedFlowIds.contains(flow.getId()))
|
||||
.toList();
|
||||
if (claimerFlows.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
// 该认领人的公告一次查询,按公告id归集
|
||||
Map<Long, BfiClaimAnnounceVO> announceByBillId = new HashMap<>();
|
||||
try {
|
||||
for (BfiClaimAnnounceVO announce : queryClaimAnnounces(claimerName)) {
|
||||
if (announce.getId() != null) {
|
||||
announceByBillId.putIfAbsent(announce.getId(), announce);
|
||||
}
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
log.error("定时推送收款认领:认领人[{}]公告查询失败,其名下流水全部跳过", claimerName, exception);
|
||||
skippedCount += claimerFlows.size();
|
||||
continue;
|
||||
}
|
||||
for (KingdeeReceiptFlow flow : claimerFlows) {
|
||||
BfiClaimAnnounceVO announce = announceByBillId.get(flow.getBillId());
|
||||
if (announce == null) {
|
||||
skippedCount++;
|
||||
log.info("定时推送收款认领:认领人[{}]流水[{}]未在公告查询结果中命中,跳过",
|
||||
claimerName, flow.getId());
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
ReceiptClaimPushResult result = pushClaimLocked(flow.getId(), announce);
|
||||
if (result.isSuccess()) {
|
||||
successCount++;
|
||||
pushedFlowIds.add(flow.getId());
|
||||
} else {
|
||||
failedCount++;
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
failedCount++;
|
||||
log.error("定时推送收款认领:认领人[{}]流水[{}]推送异常", claimerName, flow.getId(), exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
String summary = "扫描 " + candidates.size() + " 条,推送成功 " + successCount
|
||||
+ " 条,推送失败 " + failedCount + " 条,公告未命中跳过 " + skippedCount + " 条";
|
||||
log.info("定时推送收款认领完成:{}", summary);
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 事务内推送:流水行锁覆盖闸门校验、对账、推送与状态落库全过程,
|
||||
* 防定时任务与手动按钮并发对同一流水双推;
|
||||
* 推送成功后触发合同合规认领链推送(C103-9/C107-1/C108-1,事务提交后执行,失败不影响金蝶推送结果)
|
||||
*/
|
||||
private ReceiptClaimPushResult pushClaimLocked(Long flowId, BfiClaimAnnounceVO announce) {
|
||||
ReceiptClaimPushResult result = transactionTemplate.execute(status -> doPushClaimToKingdee(flowId, announce));
|
||||
if (result != null && result.isSuccess()) {
|
||||
hcsClaimSyncSupport.pushClaimToHcs(flowId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送单条流水:静态闸门 → 最新公告对账 → 按流水归集分摊行推送 → 成败状态落库
|
||||
*/
|
||||
private ReceiptClaimPushResult doPushClaimToKingdee(Long flowId, BfiClaimAnnounceVO announce) {
|
||||
KingdeeReceiptFlow flow = lockedFlow(flowId);
|
||||
if (flow.getBillId() == null) {
|
||||
return ReceiptClaimPushResult.fail("认领公告id缺失,请先手动同步金蝶收款流水");
|
||||
}
|
||||
// 终态闸门:已推送流水不允许二次推送(作废重认领使等式再满足也不推,金蝶侧人工处置)
|
||||
if (KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_PUSHED.equals(flow.getPushStatus())) {
|
||||
return ReceiptClaimPushResult.fail("该流水已推送金蝶,不允许重复推送");
|
||||
}
|
||||
// 推送前对账:以最新公告刷新公告侧未认领金额并重算认领状态(快照落库,页面口径同步修正)
|
||||
flow.setUnclaimedAmount(money(announce.getUnclaimamount()));
|
||||
flow.setKingdeeClaimStatus(trimToNull(announce.getClaimstatus()));
|
||||
flow.setClaimMe(announce.getClaimme());
|
||||
BigDecimal claimedTotal = flowClaimedAmount(flow.getId());
|
||||
flow.setClaimedAmount(claimedTotal);
|
||||
BigDecimal claimCeiling = claimCeiling(flow);
|
||||
String claimStatus = amountClaimStatus(claimedTotal, claimCeiling);
|
||||
flow.setClaimStatus(claimStatus);
|
||||
updateById(flow);
|
||||
if (!CLAIMED.equals(claimStatus)) {
|
||||
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("该流水无有效认领分摊,无法推送");
|
||||
}
|
||||
// 核心单据编号取第一张结算单同步金蝶回写的星瀚内码(应收/应付单金蝶id),TMS本地id金蝶无法识别
|
||||
FormalSettlement firstSettlement = formalSettlementMapper.selectById(allocations.get(0).getFormalSettlementId());
|
||||
if (firstSettlement == null || Func.isBlank(firstSettlement.getKingdeeBillId())) {
|
||||
return ReceiptClaimPushResult.fail("结算单" + allocations.get(0).getFormalSettlementNo()
|
||||
+ "未同步金蝶(缺少金蝶单据id),不满足推送条件");
|
||||
}
|
||||
// 推送来源编码:分摊结算单号下划线拼接(A301-3 契约口径;分摊不变则重试编码不变,
|
||||
// 金蝶按 awrp_src_sys + awrp_src_code 幂等防重试双单)
|
||||
String awrpSrcCode = allocations.stream()
|
||||
.map(ReceiptClaimSettlement::getFormalSettlementNo)
|
||||
.filter(Func::isNotEmpty)
|
||||
.distinct()
|
||||
.collect(Collectors.joining("_"));
|
||||
flow.setAwrpSrcCode(awrpSrcCode);
|
||||
|
||||
BfiClaimPushDTO param = buildClaimPushDTO(flow, claimedTotal, allocations, firstSettlement.getKingdeeBillId());
|
||||
log.info("调用BFI接口 收款认领结果推送开始 flowId:{},billId:{},awrpSrcCode:{},分摊明细 {} 行,认领金额 {}",
|
||||
flow.getId(), flow.getBillId(), flow.getAwrpSrcCode(), allocations.size(), claimedTotal);
|
||||
try {
|
||||
BfiBillSaveResultVO result = bfiService.pushClaimResult(param);
|
||||
return applyClaimPushSuccess(flow, extractClaimPushBillNo(result));
|
||||
} catch (Exception exception) {
|
||||
return applyClaimPushFailure(flow, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A301-3 推送报文组装:billid=公告id,单条分录、金额=全部未推送金额(一笔流水一张金蝶收款单),
|
||||
* 核心单据编号=第一张结算单的金蝶单据id(星瀚内码)
|
||||
*/
|
||||
private BfiClaimPushDTO buildClaimPushDTO(KingdeeReceiptFlow flow, BigDecimal claimedTotal,
|
||||
List<ReceiptClaimSettlement> allocations, String coreBillId) {
|
||||
BfiClaimPushDTO param = new BfiClaimPushDTO();
|
||||
// 【必填】认领公告id
|
||||
param.setBillid(flow.getBillId());
|
||||
// 【必填】来源系统编码
|
||||
param.setAwrpSrcSys(bfiProperties.getSrcSys());
|
||||
// 【必填】来源系统单据编码(分摊结算单号下划线拼接)
|
||||
param.setAwrpSrcCode(flow.getAwrpSrcCode());
|
||||
// 【必填】收款类型.编码
|
||||
param.setRecpaytypeNumber(KingdeeBfiConstant.BFI_CLAIM_REC_PAY_TYPE);
|
||||
// 【必填】付款人类型
|
||||
param.setPaymenttype(KingdeeBfiConstant.BFI_CLAIM_PAYMENT_TYPE);
|
||||
// 【必填】付款人名称
|
||||
param.setRecviewpayer(flow.getPayerName());
|
||||
// 【必填】付款人id
|
||||
param.setRecpayer(flow.getPayerId());
|
||||
// 【必填】单据体分录:仅一条,应收金额=全部未推送金额(累计有效认领)
|
||||
BfiClaimPushEntryDTO entry = new BfiClaimPushEntryDTO();
|
||||
// 【必填】应收金额(全部未推送金额)
|
||||
entry.setEReceivableamt(claimedTotal);
|
||||
// 分录行序号(第一张结算单id,分摊已按结算单id升序,多张取最小)
|
||||
entry.setSeq(allocations.get(0).getFormalSettlementId());
|
||||
// 核心单据类型
|
||||
// entry.setECorebilltype(KingdeeBfiConstant.BFI_CLAIM_CORE_BILL_TYPE);
|
||||
// 核心单据编号(第一张结算单的金蝶单据id,星瀚内码)
|
||||
entry.setECorebillno(coreBillId);
|
||||
param.setEntryentity(List.of(entry));
|
||||
return param;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 A301-3 推送结果:全项成功返回单据号,否则抛出汇总错误文本(由失败路径落库)。
|
||||
* 成功响应的 number 为空,金蝶收款单号取回执 keys 中的 awrp_src_code(来源编码)。
|
||||
*/
|
||||
private String extractClaimPushBillNo(BfiBillSaveResultVO result) {
|
||||
if (result == null || result.getResult() == null || result.getResult().isEmpty()) {
|
||||
throw new ServiceException("金蝶认领结果推送失败,返回结果为空");
|
||||
}
|
||||
List<BfiBillSaveResultItemVO> items = result.getResult();
|
||||
boolean allSuccess = items.stream().allMatch(item -> Boolean.TRUE.equals(item.getBillStatus()));
|
||||
if (!allSuccess) {
|
||||
String errorText = items.stream()
|
||||
.map(BfiBillSaveResultItemVO::getErrorText)
|
||||
.filter(text -> !text.isEmpty())
|
||||
.collect(Collectors.joining(";"));
|
||||
throw new ServiceException("金蝶认领结果推送失败"
|
||||
+ (errorText.isEmpty() ? "" : ":" + errorText));
|
||||
}
|
||||
return items.stream()
|
||||
.map(this::claimPushBillNoOf)
|
||||
.filter(Func::isNotEmpty)
|
||||
.findFirst().orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单项推送结果 → 单据号:A301-3 成功响应 number 为空,取回执键值中的 awrp_src_code
|
||||
*/
|
||||
private String claimPushBillNoOf(BfiBillSaveResultItemVO item) {
|
||||
if (Func.isNotEmpty(item.getNumber())) {
|
||||
return item.getNumber();
|
||||
}
|
||||
return item.getKeys() == null ? null : item.getKeys().get("awrp_src_code");
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送成功:流水置已推送并回填金蝶收款单号,级联刷新名下未作废认领主单的冗余展示字段
|
||||
*/
|
||||
private ReceiptClaimPushResult applyClaimPushSuccess(KingdeeReceiptFlow flow, String kingdeeBillNo) {
|
||||
log.info("调用BFI接口 收款认领结果推送成功 flowId:{},金蝶收款单号:{}",
|
||||
flow.getId(), Func.isEmpty(kingdeeBillNo) ? "响应未返回" : kingdeeBillNo);
|
||||
flow.setPushStatus(KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_PUSHED);
|
||||
flow.setPushedTime(LocalDateTime.now());
|
||||
flow.setKingdeeBillNo(kingdeeBillNo);
|
||||
flow.setKingdeeBillStatus(KingdeeBfiConstant.KINGDEE_STATUS_PUSHED);
|
||||
// 失败原因需显式置空(实体 update 忽略 null 字段,走 lambdaUpdate)
|
||||
this.update(Wrappers.<KingdeeReceiptFlow>lambdaUpdate()
|
||||
.eq(KingdeeReceiptFlow::getId, flow.getId())
|
||||
.set(KingdeeReceiptFlow::getPushStatus, flow.getPushStatus())
|
||||
.set(KingdeeReceiptFlow::getPushFailReason, null)
|
||||
.set(KingdeeReceiptFlow::getPushedTime, flow.getPushedTime())
|
||||
.set(KingdeeReceiptFlow::getAwrpSrcCode, flow.getAwrpSrcCode())
|
||||
.set(KingdeeReceiptFlow::getKingdeeBillNo, kingdeeBillNo)
|
||||
.set(KingdeeReceiptFlow::getKingdeeBillStatus, flow.getKingdeeBillStatus()));
|
||||
// 级联刷新名下未作废认领主单的冗余展示字段
|
||||
claimMapper.update(null, Wrappers.<ReceiptClaim>lambdaUpdate()
|
||||
.eq(ReceiptClaim::getReceiptFlowId, flow.getId())
|
||||
.eq(ReceiptClaim::getClaimStatus, CLAIMED)
|
||||
.set(ReceiptClaim::getKingdeeBillNo, kingdeeBillNo)
|
||||
.set(ReceiptClaim::getKingdeeBillStatus, KingdeeBfiConstant.KINGDEE_STATUS_PUSHED));
|
||||
record(flow.getId(), null, "push", "推送金蝶收款认领", CLAIMED, CLAIMED, flow.getClaimedAmount(),
|
||||
"金蝶收款单号:" + (Func.isEmpty(kingdeeBillNo) ? "响应未返回" : kingdeeBillNo));
|
||||
return ReceiptClaimPushResult.success(kingdeeBillNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送失败:流水置推送失败并记录原因,本地认领数据保留,可重新推送(重试复用同一来源编码)
|
||||
*/
|
||||
private ReceiptClaimPushResult applyClaimPushFailure(KingdeeReceiptFlow flow, Exception exception) {
|
||||
String reason = Func.isEmpty(exception.getMessage())
|
||||
? exception.getClass().getSimpleName() : exception.getMessage();
|
||||
reason = truncateText(reason, 1000);
|
||||
log.error("调用BFI接口 收款认领结果推送失败 flowId:{},billId:{},awrpSrcCode:{}",
|
||||
flow.getId(), flow.getBillId(), flow.getAwrpSrcCode(), exception);
|
||||
this.update(Wrappers.<KingdeeReceiptFlow>lambdaUpdate()
|
||||
.eq(KingdeeReceiptFlow::getId, flow.getId())
|
||||
.set(KingdeeReceiptFlow::getPushStatus, KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_FAILED)
|
||||
.set(KingdeeReceiptFlow::getPushFailReason, reason)
|
||||
.set(KingdeeReceiptFlow::getAwrpSrcCode, flow.getAwrpSrcCode()));
|
||||
record(flow.getId(), null, "push", "推送金蝶收款认领", CLAIMED, CLAIMED, flow.getClaimedAmount(),
|
||||
"推送失败:" + truncateText(reason, 200));
|
||||
return ReceiptClaimPushResult.fail("金蝶认领结果推送失败:" + reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* A301-2 收款认领公告查询:按认领人姓名全量翻页,时间窗口取 Nacos 配置(默认近半年)。
|
||||
* 认领公告同步、手动推送对账与定时任务对账共用。
|
||||
*/
|
||||
private List<BfiClaimAnnounceVO> queryClaimAnnounces(String claimusers) {
|
||||
int months = bfiProperties.getClaimAnnounce().getMonths();
|
||||
// 认领人直接取 Nacos 配置值
|
||||
String effectiveClaimusers = bfiProperties.getClaimAnnounce().getClaimusers();
|
||||
LocalDateTime endDate = LocalDateTime.now();
|
||||
LocalDateTime startDate = endDate.minusMonths(months);
|
||||
long start = System.currentTimeMillis();
|
||||
log.info("调用BFI接口 收款认领公告查询开始 claimusers:{},时间窗口:{} ~ {}(近{}个月)",
|
||||
effectiveClaimusers, startDate.format(BFI_DATE_TIME_FORMATTER), endDate.format(BFI_DATE_TIME_FORMATTER), months);
|
||||
BfiClaimAnnounceQueryDTO queryDTO = new BfiClaimAnnounceQueryDTO();
|
||||
queryDTO.setClaimusers(effectiveClaimusers);
|
||||
queryDTO.setModifytimeS(startDate.format(BFI_DATE_TIME_FORMATTER));
|
||||
queryDTO.setModifytimeE(endDate.format(BFI_DATE_TIME_FORMATTER));
|
||||
queryDTO.setPageSize(SYNC_PAGE_SIZE);
|
||||
List<BfiClaimAnnounceVO> announces = BfiUtil.queryAllPages(pageNo -> {
|
||||
queryDTO.setPageNo(pageNo);
|
||||
return bfiService.queryClaimAnnounce(queryDTO);
|
||||
});
|
||||
log.info("调用BFI接口 收款认领公告查询结束 claimusers:{},耗时:{}ms,拉取 {} 条",
|
||||
effectiveClaimusers, System.currentTimeMillis() - start, announces.size());
|
||||
return announces;
|
||||
}
|
||||
|
||||
/**
|
||||
* A301-2 认领公告 → 收款流水表字段拷贝(仅覆盖金蝶侧来源字段,不覆盖本地认领金额与状态)
|
||||
*/
|
||||
private void copyAnnounceRow(KingdeeReceiptFlow target, BfiClaimAnnounceVO source,
|
||||
String detailSerialNo, BigDecimal receiptAmount) {
|
||||
// 金蝶认领公告id
|
||||
target.setBillId(source.getId());
|
||||
// 收款通知单号
|
||||
private void copyRecBillRow(KingdeeReceiptFlow target, BfiRecBillRow row) {
|
||||
BfiRecBillVO source = row.recBill();
|
||||
// 金蝶收款单号(既是收款通知单号也是金蝶单据号)
|
||||
target.setReceiptNoticeNo(trimToEmpty(source.getBillno()));
|
||||
// 付款人名称(取自对方户名)
|
||||
target.setPayerName(trimToEmpty(source.getOppunit()));
|
||||
// 付款人id(取自对方账号)
|
||||
target.setPayerId(trimToEmpty(source.getOppbanknumber()));
|
||||
// 收款人
|
||||
target.setPayeeName(trimToNull(source.getRecpayee()));
|
||||
// 收款金额
|
||||
target.setReceiptAmount(receiptAmount);
|
||||
// 对方单位名称
|
||||
target.setCounterpartyName(trimToEmpty(source.getOppunit()));
|
||||
// 对方银行账号
|
||||
target.setCounterpartyAccount(trimToEmpty(source.getOppbanknumber()));
|
||||
// 对方开户行
|
||||
target.setCounterpartyBank(trimToEmpty(source.getOppbank()));
|
||||
target.setKingdeeBillNo(trimToNull(source.getBillno()));
|
||||
// 金蝶回写状态随单据状态同步刷新
|
||||
target.setKingdeeBillStatus(billStatusOf(source.getBillstatus()));
|
||||
// 付款人名称
|
||||
target.setPayerName(trimToEmpty(source.getPayername()));
|
||||
// 收款人(收款公司名称)
|
||||
target.setPayeeName(trimToNull(source.getOrgName()));
|
||||
// 收款人(公司).编码
|
||||
target.setOrgNumber(trimToNull(source.getOrgNumber()));
|
||||
// 核算组织.编码
|
||||
target.setOpenOrgNumber(trimToNull(source.getOpenorgNumber()));
|
||||
// 收款金额(主单 actrecamt)
|
||||
target.setReceiptAmount(row.receiptAmount());
|
||||
// 对方单位名称(取付款人名称)
|
||||
target.setCounterpartyName(trimToEmpty(source.getPayername()));
|
||||
// 对方银行账号(付款账户)
|
||||
target.setCounterpartyAccount(trimToEmpty(source.getPayeracctbanknum()));
|
||||
// 对方开户行(付款银行)
|
||||
target.setCounterpartyBank(trimToEmpty(source.getPayerbankname()));
|
||||
// 摘要
|
||||
target.setSummary(trimToNull(source.getDescription()));
|
||||
// 交易日期
|
||||
target.setTransactionTime(parseDateTime(source.getTradetime()));
|
||||
target.setSummary(trimToNull(source.getTxtDescription()));
|
||||
// 交易日期(业务日期)
|
||||
target.setTransactionTime(parseDateTime(source.getBizdate()));
|
||||
// 明细流水号
|
||||
target.setDetailSerialNo(detailSerialNo);
|
||||
// 未认领金额
|
||||
target.setUnclaimedAmount(money(source.getUnclaimamount()));
|
||||
target.setDetailSerialNo(row.detailSerialNo());
|
||||
// 未匹配收款金额(金蝶参考展示,不参与认领额度)
|
||||
target.setUnmatchAmountRec(money(source.getUnmatchamountrec()));
|
||||
// 已匹配收款金额 / 已匹配付款金额 / 未匹配付款金额
|
||||
target.setMatchAmountRec(money(source.getMatchamountrec()));
|
||||
target.setMatchAmountPay(money(source.getMatchamountpay()));
|
||||
target.setUnmatchAmountPay(money(source.getUnmatchamountpay()));
|
||||
// 已结算金额 / 未结算金额
|
||||
target.setSettleAmount(money(source.getSettleamount()));
|
||||
target.setUnsettleAmount(money(source.getUnsettleamount()));
|
||||
// 手续费
|
||||
target.setFee(source.getFee() == null ? null : source.getFee().setScale(2, RoundingMode.HALF_UP));
|
||||
// 公告认领状态
|
||||
target.setKingdeeClaimStatus(trimToNull(source.getClaimstatus()));
|
||||
// 我已认领
|
||||
target.setClaimMe(source.getClaimme());
|
||||
// 来源更新时间
|
||||
target.setSourceUpdatedTime(target.getTransactionTime() == null
|
||||
? LocalDateTime.now() : target.getTransactionTime());
|
||||
target.setSourceUpdatedTime(LocalDateTime.now());
|
||||
}
|
||||
|
||||
private String firstNonBlank(String... values) {
|
||||
for (String value : values) {
|
||||
if (Func.isNotEmpty(value)) {
|
||||
return value.trim();
|
||||
}
|
||||
/**
|
||||
* A301-1 单据状态 → 本地金蝶回写状态(同步时随单据刷新)
|
||||
*
|
||||
* @param billStatus 金蝶 billstatus(A暂存/B已提交/C已审核/D已收款/G已退单)
|
||||
* @return 本地状态
|
||||
*/
|
||||
private String billStatusOf(String billStatus) {
|
||||
if ("D".equals(billStatus)) {
|
||||
return KingdeeBfiConstant.KINGDEE_STATUS_RECEIVED;
|
||||
}
|
||||
return null;
|
||||
if ("C".equals(billStatus)) {
|
||||
return APPROVED;
|
||||
}
|
||||
if ("A".equals(billStatus) || "B".equals(billStatus)) {
|
||||
return KingdeeBfiConstant.KINGDEE_STATUS_PUSHED;
|
||||
}
|
||||
// 其余状态(含 G 已退单/E 变更中)按处理失败记录,提示人工在金蝶核查
|
||||
log.warn("金蝶收款单单据状态[{}]未匹配映射,同步按 failed 处理", billStatus);
|
||||
return KingdeeBfiConstant.KINGDEE_STATUS_FAILED;
|
||||
}
|
||||
|
||||
private LocalDateTime parseDateTime(String value) {
|
||||
@@ -1237,13 +984,6 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
}
|
||||
}
|
||||
|
||||
private String truncateText(String value, int maxLength) {
|
||||
if (value == null || value.length() <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
return value.substring(0, maxLength);
|
||||
}
|
||||
|
||||
private void record(Long flowId, Long claimId, String actionType, String actionName,
|
||||
String fromStatus, String toStatus, BigDecimal operationAmount, String content) {
|
||||
recordMapper.insert(buildRecord(flowId, claimId, actionType, actionName,
|
||||
|
||||
+62
-9
@@ -61,6 +61,7 @@ import org.springblade.transport.service.IFormalSettlementService;
|
||||
import org.springblade.transport.service.IPreSettlementService;
|
||||
import org.springblade.transport.service.IReceivablePayableDetailService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.ContractPartySupport;
|
||||
import org.springblade.transport.support.TransportBusinessSupport;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
import org.springblade.transport.wrapper.ReceivablePayableDetailWrapper;
|
||||
@@ -566,6 +567,12 @@ public class ReceivablePayableDetailServiceImpl
|
||||
item.put("id", contract.getId());
|
||||
item.put("contractNo", contract.getContractNo());
|
||||
item.put("contractName", contract.getContractName());
|
||||
item.put("partyA", contract.getPartyA());
|
||||
item.put("partyB", contract.getPartyB());
|
||||
item.put("partyC", contract.getPartyC());
|
||||
item.put("partyAJson", contract.getPartyAJson());
|
||||
item.put("partyBJson", contract.getPartyBJson());
|
||||
item.put("partyCJson", contract.getPartyCJson());
|
||||
item.put("billingPlanJson", contract.getBillingPlanJson());
|
||||
return item;
|
||||
}).toList();
|
||||
@@ -1258,9 +1265,7 @@ public class ReceivablePayableDetailServiceImpl
|
||||
&& !Objects.equals(waybill.getProjectId(), contract.getProjectId())) {
|
||||
return false;
|
||||
}
|
||||
String carrierName = trimToNull(waybill.getCarrierName());
|
||||
String partyB = trimToNull(contract.getPartyB());
|
||||
return carrierName != null && carrierName.equals(partyB);
|
||||
return ContractPartySupport.matchesCarrier(contract, waybill.getCarrierName(), waybill.getCarrierPartyId());
|
||||
}
|
||||
|
||||
private ContractManage findCarrierContract(Waybill waybill) {
|
||||
@@ -1268,20 +1273,50 @@ public class ReceivablePayableDetailServiceImpl
|
||||
if (waybill.getProjectId() == null || Func.isEmpty(waybill.getContractId()) || carrierName == null) {
|
||||
return null;
|
||||
}
|
||||
// 承运商合同甲方应与运单绑定的客户合同乙方(我方)一致,乙方必须是运单选择的承运商。
|
||||
// 承运商合同甲方应与运单绑定的客户合同乙方(我方)一致,乙/丙方必须包含运单选择的承运商。
|
||||
ContractManage customerContract = contractManageService.getById(waybill.getContractId());
|
||||
if (customerContract == null || Func.isEmpty(customerContract.getPartyB())) {
|
||||
return null;
|
||||
}
|
||||
return contractManageService.getOne(Wrappers.<ContractManage>lambdaQuery()
|
||||
List<ContractManage> contracts = contractManageService.list(Wrappers.<ContractManage>lambdaQuery()
|
||||
.eq(ContractManage::getIsDeleted, 0)
|
||||
.eq(ContractManage::getProjectId, waybill.getProjectId())
|
||||
.eq(ContractManage::getPartyA, customerContract.getPartyB())
|
||||
.eq(ContractManage::getPartyB, carrierName)
|
||||
.eq(ContractManage::getContractCategory, "承运商合同")
|
||||
.and(wrapper -> wrapper.isNull(ContractManage::getContractStage)
|
||||
.or().ne(ContractManage::getContractStage, "terminated"))
|
||||
.orderByDesc(ContractManage::getCreateTime), false);
|
||||
.orderByDesc(ContractManage::getCreateTime));
|
||||
Set<String> customerPartyBs = splitPartyNames(customerContract.getPartyBJson(), customerContract.getPartyB());
|
||||
for (ContractManage contract : contracts) {
|
||||
Set<String> partyANames = splitPartyNames(contract.getPartyAJson(), contract.getPartyA());
|
||||
boolean partyAMatched = partyANames.isEmpty() || partyANames.stream().anyMatch(customerPartyBs::contains)
|
||||
|| customerPartyBs.contains(trimToNull(contract.getPartyA()));
|
||||
if (!partyAMatched) {
|
||||
continue;
|
||||
}
|
||||
if (ContractPartySupport.matchesCarrier(contract, carrierName, waybill.getCarrierPartyId())) {
|
||||
return contract;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Set<String> splitPartyNames(String partyJson, String fallback) {
|
||||
Set<String> names = new LinkedHashSet<>();
|
||||
for (Map<String, Object> row : ContractPartySupport.parsePartyRows(partyJson)) {
|
||||
Object customerName = row.get("customerName");
|
||||
if (customerName != null && Func.isNotEmpty(String.valueOf(customerName).trim())) {
|
||||
names.add(String.valueOf(customerName).trim());
|
||||
}
|
||||
}
|
||||
if (names.isEmpty() && Func.isNotEmpty(fallback)) {
|
||||
for (String part : fallback.split("[、,]")) {
|
||||
String name = trimToNull(part);
|
||||
if (name != null) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
@@ -2058,13 +2093,31 @@ public class ReceivablePayableDetailServiceImpl
|
||||
*/
|
||||
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());
|
||||
List<Map<String, Object>> plans = billingPlansForWaybill(contract, waybill);
|
||||
Map<String, Object> plan = "__matched__".equals(planId)
|
||||
? resolveDefaultBillingPlan(plans, waybill.getTransportType())
|
||||
: resolveBillingPlan(plans, planId);
|
||||
return calculatedFees(waybill, plan, matchOnly, strictPlan);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按运单客商过滤合同计费方案:承运商合同用承运客商,客户合同用客户名称。
|
||||
*/
|
||||
private List<Map<String, Object>> billingPlansForWaybill(ContractManage contract, Waybill waybill) {
|
||||
if (contract == null) {
|
||||
return List.of();
|
||||
}
|
||||
if (waybill == null) {
|
||||
return parseList(contract.getBillingPlanJson());
|
||||
}
|
||||
if ("承运商合同".equals(contract.getContractCategory())) {
|
||||
return ContractPartySupport.filterBillingPlans(
|
||||
contract.getBillingPlanJson(), null, waybill.getCarrierName(), waybill.getCarrierPartyId());
|
||||
}
|
||||
return ContractPartySupport.filterBillingPlans(
|
||||
contract.getBillingPlanJson(), null, waybill.getCustomerName(), null);
|
||||
}
|
||||
|
||||
private List<ReceivablePayableCargoFee> calculatedFees(Waybill waybill, Map<String, Object> plan,
|
||||
boolean matchOnly) {
|
||||
return calculatedFees(waybill, plan, matchOnly, false);
|
||||
|
||||
+8
-1
@@ -242,10 +242,15 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
|
||||
+ "]尚未推送金蝶,请先执行「推送金蝶」");
|
||||
}
|
||||
// 冲销硬闸门:仅当金蝶侧原应付/应收单已被冲销(部分冲销放行)才允许以新单号重推,避免重复生成财务单据
|
||||
if (!this.kingdeeWrittenOff(original)) {
|
||||
boolean writtenOff = this.kingdeeWrittenOff(original);
|
||||
// 闸门查询即金蝶实时冲销状态,回写供结算单列表展示(查到未冲销同样回写,拦截提示后列表即同步)
|
||||
formalSettlementService.refreshKingdeeWriteOff(original.getId(), writtenOff);
|
||||
if (!writtenOff) {
|
||||
throw new ServiceException("结算单[" + Func.toStr(original.getFormalSettlementNo())
|
||||
+ "]的原应付/应收单尚未在金蝶冲销,禁止重推");
|
||||
}
|
||||
// 闸门查询即金蝶实时冲销状态,回写供结算单列表展示
|
||||
formalSettlementService.refreshKingdeeWriteOff(original.getId(), true);
|
||||
// 幂等:同一原单已克隆过新单则复用(重试从推送步骤续上),避免重复建单
|
||||
FormalSettlement clone = existingClone(original.getFormalSettlementNo());
|
||||
if (clone == null) {
|
||||
@@ -302,6 +307,8 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
|
||||
result.put("syncStatus", KingdeeBfiConstant.KINGDEE_STATUS_SYNCED);
|
||||
try {
|
||||
boolean writtenOff = this.kingdeeWrittenOff(settlement);
|
||||
// 预检查询即金蝶实时冲销状态,顺带回写供结算单列表展示
|
||||
formalSettlementService.refreshKingdeeWriteOff(settlement.getId(), writtenOff);
|
||||
result.put("writtenOff", writtenOff);
|
||||
result.put("message", writtenOff
|
||||
? "该结算单已推送金蝶且旧单已冲销,审批后可直接重新推送新单"
|
||||
|
||||
+5
-1
@@ -195,7 +195,11 @@ public class ShippingTemplateServiceImpl extends BaseServiceImpl<ShippingTemplat
|
||||
TransportBusinessSupport.validateAllDept(shippingTemplate.getAllDept(), "发货模板");
|
||||
LambdaQueryWrapper<ShippingTemplate> queryWrapper = Wrappers.<ShippingTemplate>lambdaQuery().eq(ShippingTemplate::getIsDeleted, 0);
|
||||
if (!Objects.equals(shippingTemplate.getAllDept(), 1)) {
|
||||
queryWrapper.eq(ShippingTemplate::getDeptId, TransportBusinessSupport.currentDeptId("发货模板"));
|
||||
Long currentDeptId = TransportBusinessSupport.currentDeptId("发货模板");
|
||||
queryWrapper.and(wrapper -> {
|
||||
wrapper.eq(ShippingTemplate::getDeptId, currentDeptId);
|
||||
TransportBusinessSupport.orProjectVisibleByBusinessDept(wrapper, List.of(currentDeptId));
|
||||
});
|
||||
} else if (Func.isNotEmpty(shippingTemplate.getDeptId())) {
|
||||
queryWrapper.eq(ShippingTemplate::getDeptId, shippingTemplate.getDeptId());
|
||||
}
|
||||
|
||||
+5
-1
@@ -190,7 +190,11 @@ public class TemporaryCreditLimitServiceImpl extends BaseServiceImpl<TemporaryCr
|
||||
queryWrapper.eq(TemporaryCreditLimit::getApplyDeptId, temporaryCreditLimit.getApplyDeptId());
|
||||
}
|
||||
} else if (!Objects.equals(temporaryCreditLimit.getAllDept(), 1)) {
|
||||
queryWrapper.eq(TemporaryCreditLimit::getApplyDeptId, TransportBusinessSupport.currentDeptId("临时额度管理"));
|
||||
Long currentDeptId = TransportBusinessSupport.currentDeptId("临时额度管理");
|
||||
queryWrapper.and(wrapper -> {
|
||||
wrapper.eq(TemporaryCreditLimit::getApplyDeptId, currentDeptId);
|
||||
TransportBusinessSupport.orProjectVisibleByBusinessDept(wrapper, List.of(currentDeptId));
|
||||
});
|
||||
} else if (Func.isNotEmpty(temporaryCreditLimit.getApplyDeptId())) {
|
||||
queryWrapper.eq(TemporaryCreditLimit::getApplyDeptId, temporaryCreditLimit.getApplyDeptId());
|
||||
}
|
||||
|
||||
+6
-3
@@ -701,9 +701,12 @@ public class TransportPlanServiceImpl extends BaseServiceImpl<TransportPlanMappe
|
||||
LambdaQueryWrapper<TransportPlan> queryWrapper = Wrappers.<TransportPlan>lambdaQuery().eq(TransportPlan::getIsDeleted, 0);
|
||||
if (!Objects.equals(transportPlan.getAllDept(), 1)) {
|
||||
Long currentDeptId = TransportBusinessSupport.currentDeptId("运输计划");
|
||||
// dept_id 为空的历史数据按创建组织归入当前组织
|
||||
queryWrapper.and(wrapper -> wrapper.eq(TransportPlan::getDeptId, currentDeptId)
|
||||
.or(item -> item.isNull(TransportPlan::getDeptId).eq(TransportPlan::getCreateDept, currentDeptId)));
|
||||
// dept_id 为空的历史数据按创建组织归入当前组织;业务部门可见其项目下的计划
|
||||
queryWrapper.and(wrapper -> {
|
||||
wrapper.eq(TransportPlan::getDeptId, currentDeptId)
|
||||
.or(item -> item.isNull(TransportPlan::getDeptId).eq(TransportPlan::getCreateDept, currentDeptId));
|
||||
TransportBusinessSupport.orProjectVisibleByBusinessDept(wrapper, List.of(currentDeptId));
|
||||
});
|
||||
} else if (Func.isNotEmpty(transportPlan.getDeptId())) {
|
||||
queryWrapper.eq(TransportPlan::getDeptId, transportPlan.getDeptId());
|
||||
}
|
||||
|
||||
+74
-28
@@ -69,6 +69,7 @@ import org.springblade.transport.service.IProjectApplyService;
|
||||
import org.springblade.transport.service.IReceivablePayableDetailService;
|
||||
import org.springblade.transport.service.IContractManageService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.ContractPartySupport;
|
||||
import org.springblade.transport.support.LiveProcessConfigSupport;
|
||||
import org.springblade.transport.support.LoadableAdmissionSupport;
|
||||
import org.springblade.transport.support.TransportBusinessSupport;
|
||||
@@ -152,7 +153,8 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
|
||||
@Override
|
||||
public WaybillVO detail(Long id) {
|
||||
WaybillVO result = WaybillWrapper.build().entityVO(syncDriverAcceptState(loadEditable(id, false)));
|
||||
Waybill loaded = syncDriverAcceptState(loadEditable(id, false));
|
||||
WaybillVO result = WaybillWrapper.build().entityVO(loaded);
|
||||
fillCustomerNameFromContract(result);
|
||||
fillCarrierContractName(List.of(result));
|
||||
fillMileageMaintainable(List.of(result));
|
||||
@@ -1328,18 +1330,7 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
LambdaQueryWrapper<Waybill> queryWrapper;
|
||||
if (Func.isNotEmpty(idList)) {
|
||||
queryWrapper = Wrappers.<Waybill>lambdaQuery().eq(Waybill::getIsDeleted, 0);
|
||||
List<Long> deptIdList = TransportBusinessSupport.resolveDeptFilterIds(
|
||||
waybill.getAllDept(), waybill.getDeptId(), "运单管理", TransportBusinessSupport.MENU_WAYBILL_MANAGE);
|
||||
if (Func.isNotEmpty(deptIdList)) {
|
||||
if (deptIdList.size() == 1) {
|
||||
queryWrapper.eq(Waybill::getDeptId, deptIdList.get(0));
|
||||
} else {
|
||||
queryWrapper.in(Waybill::getDeptId, deptIdList);
|
||||
}
|
||||
}
|
||||
if (TransportBusinessSupport.isOwnCreateScope(TransportBusinessSupport.MENU_WAYBILL_MANAGE)) {
|
||||
queryWrapper.eq(Waybill::getCreateUser, AuthUtil.getUserId());
|
||||
}
|
||||
applyAccountScope(queryWrapper, waybill.getAllDept(), waybill.getDeptId());
|
||||
queryWrapper.in(Waybill::getId, idList).orderByDesc(Waybill::getCreateTime);
|
||||
} else {
|
||||
queryWrapper = buildQuery(waybill);
|
||||
@@ -1397,7 +1388,7 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public WaybillVO copy(Long id) {
|
||||
Waybill source = loadEditable(id, true);
|
||||
Waybill source = loadEditable(id, false);
|
||||
Waybill target = new Waybill();
|
||||
target.setProjectId(source.getProjectId());
|
||||
target.setProjectName(source.getProjectName());
|
||||
@@ -1817,21 +1808,57 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
return loadingManageService.detail(loadingManage.getId());
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<Waybill> buildQuery(WaybillVO waybill) {
|
||||
LambdaQueryWrapper<Waybill> queryWrapper = Wrappers.<Waybill>lambdaQuery().eq(Waybill::getIsDeleted, 0);
|
||||
// 与常用线路等模块一致:超管默认不按组织过滤;普通用户 allDept!=1 时强制当前组织
|
||||
List<Long> deptIdList = TransportBusinessSupport.resolveDeptFilterIds(
|
||||
waybill.getAllDept(), waybill.getDeptId(), "运单管理", TransportBusinessSupport.MENU_WAYBILL_MANAGE);
|
||||
if (Func.isNotEmpty(deptIdList)) {
|
||||
if (deptIdList.size() == 1) {
|
||||
queryWrapper.eq(Waybill::getDeptId, deptIdList.get(0));
|
||||
} else {
|
||||
queryWrapper.in(Waybill::getDeptId, deptIdList);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 运单可见范围:超管不限制;普通账号仅本组织运单,以及归属本账号项目的运单。
|
||||
* 本人创建权限仍只看自己创建的运单。
|
||||
*/
|
||||
private void applyAccountScope(LambdaQueryWrapper<Waybill> queryWrapper, Integer allDept, Long deptId) {
|
||||
if (TransportBusinessSupport.isOwnCreateScope(TransportBusinessSupport.MENU_WAYBILL_MANAGE)) {
|
||||
queryWrapper.eq(Waybill::getCreateUser, AuthUtil.getUserId());
|
||||
return;
|
||||
}
|
||||
List<Long> deptIdList = TransportBusinessSupport.resolveScopedDeptFilterIds(
|
||||
allDept, deptId, "运单管理", TransportBusinessSupport.MENU_WAYBILL_MANAGE);
|
||||
if (Func.isEmpty(deptIdList)) {
|
||||
return;
|
||||
}
|
||||
Long currentUserId = AuthUtil.getUserId();
|
||||
queryWrapper.and(wrapper -> {
|
||||
if (deptIdList.size() == 1) {
|
||||
wrapper.eq(Waybill::getDeptId, deptIdList.get(0));
|
||||
} else {
|
||||
wrapper.in(Waybill::getDeptId, deptIdList);
|
||||
}
|
||||
TransportBusinessSupport.orProjectVisibleByBusinessDept(wrapper, deptIdList);
|
||||
TransportBusinessSupport.orProjectVisibleByAccount(wrapper, deptIdList, currentUserId);
|
||||
if (Func.isNotEmpty(currentUserId) && currentUserId > 0) {
|
||||
wrapper.or().eq(Waybill::getCreateUser, currentUserId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情、复制等按主键访问时复用列表可见范围,避免越权读取其他账号运单。
|
||||
*/
|
||||
private void assertWaybillVisible(Waybill waybill) {
|
||||
if (waybill == null || Func.isEmpty(waybill.getId())) {
|
||||
throw new ServiceException("运单管理不存在");
|
||||
}
|
||||
if (TransportBusinessSupport.isAdministrator()) {
|
||||
return;
|
||||
}
|
||||
WaybillVO probe = new WaybillVO();
|
||||
probe.setAllDept(0);
|
||||
LambdaQueryWrapper<Waybill> wrapper = buildQuery(probe);
|
||||
wrapper.eq(Waybill::getId, waybill.getId());
|
||||
if (count(wrapper) <= 0) {
|
||||
throw new ServiceException("无权查看该运单");
|
||||
}
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<Waybill> buildQuery(WaybillVO waybill) {
|
||||
LambdaQueryWrapper<Waybill> queryWrapper = Wrappers.<Waybill>lambdaQuery().eq(Waybill::getIsDeleted, 0);
|
||||
applyAccountScope(queryWrapper, waybill.getAllDept(), waybill.getDeptId());
|
||||
if (Func.isNotEmpty(waybill.getWaybillNo())) {
|
||||
queryWrapper.eq(Waybill::getWaybillNo, waybill.getWaybillNo());
|
||||
}
|
||||
@@ -2227,6 +2254,8 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
private void validateCarrierContract(Waybill waybill) {
|
||||
if ("自运".equals(waybill.getCarrierType())) {
|
||||
waybill.setCarrierContractId(null);
|
||||
waybill.setCarrierPartyId(null);
|
||||
waybill.setCarrierPartyRole(null);
|
||||
return;
|
||||
}
|
||||
if (Func.isEmpty(waybill.getCarrierContractId())) {
|
||||
@@ -2236,8 +2265,24 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
if (carrierContract == null || Objects.equals(carrierContract.getIsDeleted(), 1)
|
||||
|| !"承运商合同".equals(carrierContract.getContractCategory())
|
||||
|| !Objects.equals(carrierContract.getProjectId(), waybill.getProjectId())
|
||||
|| !Objects.equals(carrierContract.getPartyB(), waybill.getCarrierName())) {
|
||||
throw new ServiceException("承运商合同必须属于所选项目,且合同乙方须与承运商一致");
|
||||
|| !ContractPartySupport.matchesCarrier(carrierContract, waybill.getCarrierName(), waybill.getCarrierPartyId())) {
|
||||
throw new ServiceException("承运商合同必须属于所选项目,且承运商须为合同乙方或丙方客商");
|
||||
}
|
||||
Map<String, Object> selectedParty = ContractPartySupport.findCarrierParty(
|
||||
carrierContract, waybill.getCarrierPartyId(), waybill.getCarrierName());
|
||||
if (selectedParty != null) {
|
||||
Object customerName = selectedParty.get("customerName");
|
||||
Object partyId = selectedParty.get("id");
|
||||
Object partyRole = selectedParty.get("partyRole");
|
||||
if (customerName != null) {
|
||||
waybill.setCarrierName(String.valueOf(customerName).trim());
|
||||
}
|
||||
if (partyId != null) {
|
||||
waybill.setCarrierPartyId(String.valueOf(partyId).trim());
|
||||
}
|
||||
if (partyRole != null) {
|
||||
waybill.setCarrierPartyRole(ContractPartySupport.normalizeRole(String.valueOf(partyRole)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2252,6 +2297,7 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
if (checkDept) {
|
||||
TransportBusinessSupport.assertCurrentDept(waybill.getDeptId(), "运单管理", TransportBusinessSupport.MENU_WAYBILL_MANAGE);
|
||||
}
|
||||
assertWaybillVisible(waybill);
|
||||
return waybill;
|
||||
}
|
||||
|
||||
|
||||
+539
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* 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.support;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.tool.jackson.JsonUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.pojo.entity.ContractManage;
|
||||
import org.springblade.transport.pojo.vo.LoadingCarrierContractVO;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 合同甲/乙/丙多方客商明细支持
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public final class ContractPartySupport {
|
||||
|
||||
public static final String ROLE_A = "A";
|
||||
public static final String ROLE_B = "B";
|
||||
public static final String ROLE_C = "C";
|
||||
|
||||
private ContractPartySupport() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化三方客商明细并回写名称汇总
|
||||
*/
|
||||
public static void normalizeAndFillSummary(ContractManage contractManage) {
|
||||
List<Map<String, Object>> partyARows = normalizePartyRows(contractManage.getPartyAJson(), true);
|
||||
List<Map<String, Object>> partyBRows = normalizePartyRows(contractManage.getPartyBJson(), true);
|
||||
List<Map<String, Object>> partyCRows = normalizePartyRows(contractManage.getPartyCJson(), false);
|
||||
contractManage.setPartyAJson(toJsonOrNull(partyARows));
|
||||
contractManage.setPartyBJson(toJsonOrNull(partyBRows));
|
||||
contractManage.setPartyCJson(toJsonOrNull(partyCRows));
|
||||
contractManage.setPartyA(joinCustomerNames(partyARows));
|
||||
contractManage.setPartyB(joinCustomerNames(partyBRows));
|
||||
contractManage.setPartyC(joinCustomerNames(partyCRows));
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交级校验:甲/乙至少一行,计费方案必须绑定明细客商
|
||||
*/
|
||||
public static void validateRequiredParties(ContractManage contractManage, boolean requireCustomerId) {
|
||||
List<Map<String, Object>> partyARows = parsePartyRows(contractManage.getPartyAJson());
|
||||
List<Map<String, Object>> partyBRows = parsePartyRows(contractManage.getPartyBJson());
|
||||
List<Map<String, Object>> partyCRows = parsePartyRows(contractManage.getPartyCJson());
|
||||
if (partyARows.isEmpty()) {
|
||||
throw new ServiceException("请至少添加一个甲方客商");
|
||||
}
|
||||
if (partyBRows.isEmpty()) {
|
||||
throw new ServiceException("请至少添加一个乙方客商");
|
||||
}
|
||||
validatePartyRows("甲方", partyARows, requireCustomerId);
|
||||
validatePartyRows("乙方", partyBRows, requireCustomerId);
|
||||
if (!partyCRows.isEmpty()) {
|
||||
validatePartyRows("丙方", partyCRows, requireCustomerId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验计费方案均已绑定合同明细中的客商。
|
||||
* 客户合同仅允许绑定甲方;承运商合同仅允许绑定乙方/丙方。
|
||||
*/
|
||||
public static void validateBillingPlanBindings(ContractManage contractManage) {
|
||||
String billingPlanJson = contractManage.getBillingPlanJson();
|
||||
if (Func.isEmpty(billingPlanJson)) {
|
||||
return;
|
||||
}
|
||||
Object parsed = JsonUtil.parse(billingPlanJson, List.class);
|
||||
if (!(parsed instanceof List<?> plans) || plans.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Set<String> allowedRoles = allowedBillingPartyRoles(contractManage.getContractCategory());
|
||||
Map<String, Map<String, Object>> partyByRowId = indexPartyRows(contractManage).entrySet().stream()
|
||||
.filter(entry -> allowedRoles.contains(normalizeRole(String.valueOf(entry.getValue().get("partyRole")))))
|
||||
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (left, right) -> left, LinkedHashMap::new));
|
||||
Set<String> customerKeys = partyByRowId.values().stream()
|
||||
.map(ContractPartySupport::customerMatchKey)
|
||||
.filter(Func::isNotEmpty)
|
||||
.collect(Collectors.toSet());
|
||||
for (int index = 0; index < plans.size(); index++) {
|
||||
Object value = plans.get(index);
|
||||
if (!(value instanceof Map<?, ?> plan)) {
|
||||
throw new ServiceException("计费方案设置格式不正确");
|
||||
}
|
||||
String partyRowId = trimToNull(plan.get("partyRowId"));
|
||||
String customerId = trimToNull(plan.get("customerId"));
|
||||
String customerName = trimToNull(plan.get("customerName"));
|
||||
String partyRole = normalizeRole(trimToNull(plan.get("partyRole")));
|
||||
if (Func.isEmpty(partyRowId) && Func.isEmpty(customerId) && Func.isEmpty(customerName)) {
|
||||
throw new ServiceException("计费方案第" + (index + 1) + "个必须选择适用客商");
|
||||
}
|
||||
if (Func.isNotEmpty(partyRole) && !allowedRoles.contains(partyRole)) {
|
||||
throw new ServiceException(billingPartyRoleMessage(contractManage.getContractCategory(), index + 1));
|
||||
}
|
||||
boolean matched = false;
|
||||
if (Func.isNotEmpty(partyRowId) && partyByRowId.containsKey(partyRowId)) {
|
||||
matched = true;
|
||||
} else {
|
||||
String key = customerMatchKey(customerId, customerName, partyRole);
|
||||
matched = Func.isNotEmpty(key) && customerKeys.contains(key);
|
||||
if (!matched && Func.isNotEmpty(customerName)) {
|
||||
matched = partyByRowId.values().stream()
|
||||
.anyMatch(row -> Objects.equals(trimToNull(row.get("customerName")), customerName)
|
||||
&& (Func.isEmpty(partyRole) || Objects.equals(normalizeRole(String.valueOf(row.get("partyRole"))), partyRole)));
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
throw new ServiceException(billingPartyRoleMessage(contractManage.getContractCategory(), index + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计费方案可选客商角色:客户合同=甲方;承运商合同=乙方/丙方
|
||||
*/
|
||||
public static Set<String> allowedBillingPartyRoles(String contractCategory) {
|
||||
String category = Func.toStr(contractCategory).trim();
|
||||
if ("承运商合同".equals(category)) {
|
||||
return Set.of(ROLE_B, ROLE_C);
|
||||
}
|
||||
if ("客户合同".equals(category)) {
|
||||
return Set.of(ROLE_A);
|
||||
}
|
||||
return Set.of(ROLE_A, ROLE_B, ROLE_C);
|
||||
}
|
||||
|
||||
private static String billingPartyRoleMessage(String contractCategory, int planIndex) {
|
||||
String category = Func.toStr(contractCategory).trim();
|
||||
if ("承运商合同".equals(category)) {
|
||||
return "计费方案第" + planIndex + "个适用客商只能选择乙方或丙方";
|
||||
}
|
||||
if ("客户合同".equals(category)) {
|
||||
return "计费方案第" + planIndex + "个适用客商只能选择甲方";
|
||||
}
|
||||
return "计费方案第" + planIndex + "个适用客商不在合同甲/乙/丙明细中";
|
||||
}
|
||||
|
||||
public static List<Map<String, Object>> parsePartyRows(String partyJson) {
|
||||
if (Func.isEmpty(partyJson)) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
Object parsed = JsonUtil.parse(partyJson, List.class);
|
||||
if (!(parsed instanceof List<?> rows) || rows.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (Object row : rows) {
|
||||
if (row instanceof Map<?, ?> map) {
|
||||
Map<String, Object> values = new LinkedHashMap<>();
|
||||
map.forEach((key, value) -> values.put(String.valueOf(key), value));
|
||||
result.add(values);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (Exception exception) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
public static List<Map<String, Object>> carrierPartyRows(ContractManage contractManage) {
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
parsePartyRows(contractManage == null ? null : contractManage.getPartyBJson()).forEach(row -> {
|
||||
row.put("partyRole", ROLE_B);
|
||||
rows.add(row);
|
||||
});
|
||||
parsePartyRows(contractManage == null ? null : contractManage.getPartyCJson()).forEach(row -> {
|
||||
row.put("partyRole", ROLE_C);
|
||||
rows.add(row);
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
public static Set<String> carrierPartyNames(ContractManage contractManage) {
|
||||
Set<String> names = new LinkedHashSet<>();
|
||||
for (Map<String, Object> row : carrierPartyRows(contractManage)) {
|
||||
String name = normalizePartyName(trimToNull(row.get("customerName")));
|
||||
if (Func.isNotEmpty(name)) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
// 兼容历史仅有汇总字段的合同
|
||||
if (names.isEmpty() && contractManage != null) {
|
||||
String partyB = normalizePartyName(contractManage.getPartyB());
|
||||
if (Func.isNotEmpty(partyB)) {
|
||||
names.add(partyB);
|
||||
}
|
||||
String partyC = normalizePartyName(contractManage.getPartyC());
|
||||
if (Func.isNotEmpty(partyC)) {
|
||||
names.add(partyC);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
public static boolean matchesCarrier(ContractManage contractManage, String carrierName, String carrierPartyId) {
|
||||
if (contractManage == null) {
|
||||
return false;
|
||||
}
|
||||
List<Map<String, Object>> rows = carrierPartyRows(contractManage);
|
||||
if (Func.isNotEmpty(carrierPartyId)) {
|
||||
for (Map<String, Object> row : rows) {
|
||||
if (Objects.equals(trimToNull(row.get("id")), trimToNull(carrierPartyId))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
String normalizedName = normalizePartyName(carrierName);
|
||||
if (Func.isEmpty(normalizedName)) {
|
||||
return false;
|
||||
}
|
||||
if (rows.isEmpty()) {
|
||||
return Objects.equals(normalizePartyName(contractManage.getPartyB()), normalizedName)
|
||||
|| Objects.equals(normalizePartyName(contractManage.getPartyC()), normalizedName);
|
||||
}
|
||||
return rows.stream()
|
||||
.anyMatch(row -> Objects.equals(normalizePartyName(trimToNull(row.get("customerName"))), normalizedName));
|
||||
}
|
||||
|
||||
public static Map<String, Object> findCarrierParty(ContractManage contractManage, String carrierPartyId, String carrierName) {
|
||||
List<Map<String, Object>> rows = carrierPartyRows(contractManage);
|
||||
if (Func.isNotEmpty(carrierPartyId)) {
|
||||
for (Map<String, Object> row : rows) {
|
||||
if (Objects.equals(trimToNull(row.get("id")), trimToNull(carrierPartyId))) {
|
||||
return row;
|
||||
}
|
||||
}
|
||||
}
|
||||
String normalizedName = normalizePartyName(carrierName);
|
||||
if (Func.isEmpty(normalizedName)) {
|
||||
return null;
|
||||
}
|
||||
for (Map<String, Object> row : rows) {
|
||||
if (Objects.equals(normalizePartyName(trimToNull(row.get("customerName"))), normalizedName)) {
|
||||
return row;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static List<Map<String, Object>> filterBillingPlans(String billingPlanJson, Long customerId, String customerName, String partyRowId) {
|
||||
List<Map<String, Object>> plans = parseObjectList(billingPlanJson);
|
||||
if (plans.isEmpty()) {
|
||||
return plans;
|
||||
}
|
||||
String normalizedName = normalizePartyName(customerName);
|
||||
String normalizedPartyRowId = trimToNull(partyRowId);
|
||||
String customerIdText = customerId == null ? null : String.valueOf(customerId);
|
||||
List<Map<String, Object>> matched = plans.stream()
|
||||
.filter(plan -> {
|
||||
if (Func.isNotEmpty(normalizedPartyRowId)
|
||||
&& Objects.equals(trimToNull(plan.get("partyRowId")), normalizedPartyRowId)) {
|
||||
return true;
|
||||
}
|
||||
if (Func.isNotEmpty(customerIdText)
|
||||
&& Objects.equals(trimToNull(plan.get("customerId")), customerIdText)) {
|
||||
return true;
|
||||
}
|
||||
return Func.isNotEmpty(normalizedName)
|
||||
&& Objects.equals(normalizePartyName(trimToNull(plan.get("customerName"))), normalizedName);
|
||||
})
|
||||
.collect(Collectors.toCollection(ArrayList::new));
|
||||
// 历史方案未绑定客商时,不强制过滤,避免老数据无法计费
|
||||
if (matched.isEmpty() && plans.stream().noneMatch(ContractPartySupport::hasCustomerBinding)) {
|
||||
return plans;
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
public static List<LoadingCarrierContractVO> expandCarrierOptions(Collection<ContractManage> contracts) {
|
||||
if (Func.isEmpty(contracts)) {
|
||||
return List.of();
|
||||
}
|
||||
List<LoadingCarrierContractVO> options = new ArrayList<>();
|
||||
for (ContractManage contract : contracts) {
|
||||
if (contract == null) {
|
||||
continue;
|
||||
}
|
||||
List<Map<String, Object>> rows = carrierPartyRows(contract);
|
||||
if (rows.isEmpty()) {
|
||||
LoadingCarrierContractVO option = new LoadingCarrierContractVO();
|
||||
option.setId(contract.getId());
|
||||
option.setContractName(contract.getContractName());
|
||||
option.setCarrierName(contract.getPartyB());
|
||||
option.setCarrierPartyRole(ROLE_B);
|
||||
options.add(option);
|
||||
continue;
|
||||
}
|
||||
for (Map<String, Object> row : rows) {
|
||||
LoadingCarrierContractVO option = new LoadingCarrierContractVO();
|
||||
option.setId(contract.getId());
|
||||
option.setContractName(contract.getContractName());
|
||||
option.setCarrierName(trimToNull(row.get("customerName")));
|
||||
option.setCarrierPartyId(trimToNull(row.get("id")));
|
||||
option.setCarrierPartyRole(normalizeRole(String.valueOf(row.get("partyRole"))));
|
||||
option.setCreditCode(trimToNull(row.get("creditCode")));
|
||||
option.setCustomerId(toLong(row.get("customerId")));
|
||||
options.add(option);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
public static String normalizePartyName(String value) {
|
||||
return Func.isEmpty(value) ? "" : value.trim();
|
||||
}
|
||||
|
||||
public static String normalizeRole(String role) {
|
||||
if (Func.isEmpty(role)) {
|
||||
return null;
|
||||
}
|
||||
String text = role.trim().toUpperCase();
|
||||
if ("A".equals(text) || "甲方".equals(role) || "甲".equals(role)) {
|
||||
return ROLE_A;
|
||||
}
|
||||
if ("B".equals(text) || "乙方".equals(role) || "乙".equals(role)) {
|
||||
return ROLE_B;
|
||||
}
|
||||
if ("C".equals(text) || "丙方".equals(role) || "丙".equals(role)) {
|
||||
return ROLE_C;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
public static String firstPartyName(String partyJson, String fallback) {
|
||||
List<Map<String, Object>> rows = parsePartyRows(partyJson);
|
||||
if (!rows.isEmpty()) {
|
||||
String name = trimToNull(rows.get(0).get("customerName"));
|
||||
if (Func.isNotEmpty(name)) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return TransportBusinessSupport.trimToNull(fallback);
|
||||
}
|
||||
|
||||
private static List<Map<String, Object>> normalizePartyRows(String partyJson, boolean keepEmptyPlaceholder) {
|
||||
List<Map<String, Object>> source = parsePartyRows(partyJson);
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (Map<String, Object> sourceRow : source) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
String id = trimToNull(sourceRow.get("id"));
|
||||
if (Func.isEmpty(id)) {
|
||||
id = String.valueOf(IdWorker.getId());
|
||||
}
|
||||
row.put("id", id);
|
||||
Long customerId = toLong(sourceRow.get("customerId"));
|
||||
if (customerId != null) {
|
||||
row.put("customerId", customerId);
|
||||
}
|
||||
String customerName = trimToNull(sourceRow.get("customerName"));
|
||||
if (Func.isNotEmpty(customerName)) {
|
||||
row.put("customerName", customerName);
|
||||
}
|
||||
String creditCode = trimToNull(sourceRow.get("creditCode"));
|
||||
if (Func.isNotEmpty(creditCode)) {
|
||||
row.put("creditCode", creditCode);
|
||||
}
|
||||
Long contactId = toLong(sourceRow.get("contactId"));
|
||||
if (contactId != null) {
|
||||
row.put("contactId", contactId);
|
||||
}
|
||||
String contactName = trimToNull(sourceRow.get("contactName"));
|
||||
if (Func.isNotEmpty(contactName)) {
|
||||
row.put("contactName", contactName);
|
||||
}
|
||||
String contactPhone = trimToNull(sourceRow.get("contactPhone"));
|
||||
if (Func.isNotEmpty(contactPhone)) {
|
||||
row.put("contactPhone", contactPhone);
|
||||
}
|
||||
if (Func.isEmpty(customerName) && customerId == null && !keepEmptyPlaceholder) {
|
||||
continue;
|
||||
}
|
||||
result.add(row);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void validatePartyRows(String roleLabel, List<Map<String, Object>> rows, boolean requireCustomerId) {
|
||||
Set<String> customerKeys = new LinkedHashSet<>();
|
||||
for (int index = 0; index < rows.size(); index++) {
|
||||
Map<String, Object> row = rows.get(index);
|
||||
String customerName = trimToNull(row.get("customerName"));
|
||||
Long customerId = toLong(row.get("customerId"));
|
||||
if (Func.isEmpty(customerName)) {
|
||||
throw new ServiceException(roleLabel + "第" + (index + 1) + "行请选择客商");
|
||||
}
|
||||
if (requireCustomerId && customerId == null) {
|
||||
throw new ServiceException(roleLabel + "第" + (index + 1) + "行请选择有效客商");
|
||||
}
|
||||
String contactName = trimToNull(row.get("contactName"));
|
||||
String contactPhone = trimToNull(row.get("contactPhone"));
|
||||
if (Func.isNotEmpty(contactName) && Func.isEmpty(contactPhone)) {
|
||||
throw new ServiceException(roleLabel + "第" + (index + 1) + "行联系人需带出联系电话");
|
||||
}
|
||||
String key = customerId == null ? customerName : String.valueOf(customerId);
|
||||
if (!customerKeys.add(key)) {
|
||||
throw new ServiceException(roleLabel + "存在重复客商:" + customerName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Map<String, Object>> indexPartyRows(ContractManage contractManage) {
|
||||
Map<String, Map<String, Object>> indexed = new LinkedHashMap<>();
|
||||
putIndexedRows(indexed, parsePartyRows(contractManage.getPartyAJson()), ROLE_A);
|
||||
putIndexedRows(indexed, parsePartyRows(contractManage.getPartyBJson()), ROLE_B);
|
||||
putIndexedRows(indexed, parsePartyRows(contractManage.getPartyCJson()), ROLE_C);
|
||||
return indexed;
|
||||
}
|
||||
|
||||
private static void putIndexedRows(Map<String, Map<String, Object>> indexed, List<Map<String, Object>> rows, String role) {
|
||||
for (Map<String, Object> row : rows) {
|
||||
Map<String, Object> copy = new LinkedHashMap<>(row);
|
||||
copy.put("partyRole", role);
|
||||
String id = trimToNull(copy.get("id"));
|
||||
if (Func.isNotEmpty(id)) {
|
||||
indexed.put(id, copy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String customerMatchKey(Map<String, Object> row) {
|
||||
return customerMatchKey(trimToNull(row.get("customerId")), trimToNull(row.get("customerName")),
|
||||
normalizeRole(String.valueOf(row.get("partyRole"))));
|
||||
}
|
||||
|
||||
private static String customerMatchKey(String customerId, String customerName, String partyRole) {
|
||||
if (Func.isNotEmpty(customerId)) {
|
||||
return normalizeRole(partyRole) + ":" + customerId;
|
||||
}
|
||||
if (Func.isNotEmpty(customerName)) {
|
||||
return normalizeRole(partyRole) + ":" + customerName;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean hasCustomerBinding(Map<String, Object> plan) {
|
||||
return Func.isNotEmpty(trimToNull(plan.get("partyRowId")))
|
||||
|| Func.isNotEmpty(trimToNull(plan.get("customerId")))
|
||||
|| Func.isNotEmpty(trimToNull(plan.get("customerName")));
|
||||
}
|
||||
|
||||
private static List<Map<String, Object>> parseObjectList(String json) {
|
||||
if (Func.isEmpty(json)) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
Object parsed = JsonUtil.parse(json, List.class);
|
||||
if (!(parsed instanceof List<?> list) || list.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (Object item : list) {
|
||||
if (item instanceof Map<?, ?> map) {
|
||||
Map<String, Object> values = new LinkedHashMap<>();
|
||||
map.forEach((key, value) -> values.put(String.valueOf(key), value));
|
||||
result.add(values);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (Exception exception) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
private static String joinCustomerNames(List<Map<String, Object>> rows) {
|
||||
List<String> names = rows.stream()
|
||||
.map(row -> trimToNull(row.get("customerName")))
|
||||
.filter(Func::isNotEmpty)
|
||||
.distinct()
|
||||
.toList();
|
||||
return names.isEmpty() ? null : String.join("、", names);
|
||||
}
|
||||
|
||||
private static String toJsonOrNull(List<Map<String, Object>> rows) {
|
||||
return rows == null || rows.isEmpty() ? null : JsonUtil.toJson(rows);
|
||||
}
|
||||
|
||||
private static String trimToNull(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = String.valueOf(value).trim();
|
||||
return text.isEmpty() || "null".equalsIgnoreCase(text) ? null : text;
|
||||
}
|
||||
|
||||
private static Long toLong(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
String text = trimToNull(value);
|
||||
if (Func.isEmpty(text)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(text);
|
||||
} catch (NumberFormatException exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
-25
@@ -212,19 +212,34 @@ public class KingdeeApBillSyncSupport {
|
||||
if (Func.isBlank(awrpSrcCode)) {
|
||||
return false;
|
||||
}
|
||||
BfiApBillQueryDTO param = new BfiApBillQueryDTO();
|
||||
param.setAwrpSrcCode(List.of(awrpSrcCode));
|
||||
param.setAwrpSrcSys(bfiProperties.getSrcSys());
|
||||
for (BfiApBillVO apBill : BfiUtil.queryAllPages(pageNo -> {
|
||||
param.setPageNo(pageNo);
|
||||
param.setPageSize(PAY_RESULT_PAGE_SIZE);
|
||||
return bfiService.queryApBill(param);
|
||||
})) {
|
||||
if (awrpSrcCode.equals(apBill.getAwrpSrcCode()) && Boolean.TRUE.equals(apBill.getHadwrittenoff())) {
|
||||
return true;
|
||||
return queryApBillWriteOffFlags(List.of(awrpSrcCode)).getOrDefault(awrpSrcCode, Boolean.FALSE);
|
||||
}
|
||||
|
||||
/**
|
||||
* A102-4 冲销状态批量查询:防重键批量分批查询财务应付单,返回 防重键 -> 是否已冲销
|
||||
* (hadwrittenoff=true,部分冲销同样视为已冲销;同键多单取或,查不到的键不在结果中即视为未冲销)。
|
||||
* 定时回写结算单金蝶冲销状态用,整批一次查询消除逐单远程查询 N+1
|
||||
*
|
||||
* @param awrpSrcCodes 财务应付单防重键列表
|
||||
* @return 防重键 -> 是否已冲销
|
||||
*/
|
||||
public Map<String, Boolean> queryApBillWriteOffFlags(List<String> awrpSrcCodes) {
|
||||
Map<String, Boolean> writeOffFlags = new LinkedHashMap<>();
|
||||
batchForeach(awrpSrcCodes, bfiProperties.getWriteOffResult().getQueryBatchSize(), batch -> {
|
||||
BfiApBillQueryDTO param = new BfiApBillQueryDTO();
|
||||
param.setAwrpSrcCode(batch);
|
||||
param.setAwrpSrcSys(bfiProperties.getSrcSys());
|
||||
for (BfiApBillVO apBill : BfiUtil.queryAllPages(pageNo -> {
|
||||
param.setPageNo(pageNo);
|
||||
param.setPageSize(PAY_RESULT_PAGE_SIZE);
|
||||
return bfiService.queryApBill(param);
|
||||
})) {
|
||||
if (Func.isNotEmpty(apBill.getAwrpSrcCode())) {
|
||||
writeOffFlags.merge(apBill.getAwrpSrcCode(), Boolean.TRUE.equals(apBill.getHadwrittenoff()), Boolean::logicalOr);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return writeOffFlags;
|
||||
}
|
||||
|
||||
// ==================== A102-2 财务应付单保存 ====================
|
||||
@@ -282,8 +297,8 @@ public class KingdeeApBillSyncSupport {
|
||||
param.setCurrencyNumber(currency);
|
||||
// 【必填】付款组织.编码
|
||||
param.setPayorgNumber(orgNumber);
|
||||
// 部门.编码:项目业务部门自身的金蝶编码(结算/付款组织仍传上溯平台公司编码,口径与付款申请 A103-3 一致)
|
||||
param.setDepartmentNumber(this.kingdeeDeptNumber(contract.getProjectId(), context));
|
||||
// 部门.编码:合同平台公司下业务部门配置的金蝶部门代码
|
||||
param.setDepartmentNumber(this.kingdeeDeptNumber(contract, contract.getProjectId(), context));
|
||||
// 采购组织.编码(非必传)
|
||||
// param.setPurorgNumber(orgNumber);
|
||||
// 【必填】款项性质.编码
|
||||
@@ -962,6 +977,20 @@ public class KingdeeApBillSyncSupport {
|
||||
* @return 组织编码
|
||||
*/
|
||||
public String bfiOrgNumber(ContractManage contract, KingdeeSyncContext context) {
|
||||
if (contract == null) {
|
||||
throw new ServiceException("推送金蝶失败,合同不存在,无法确定金蝶组织编码");
|
||||
}
|
||||
// 优先取合同平台公司金蝶代码;未维护时回退项目业务部门上溯
|
||||
if (contract.getPlatformCompanyId() != null) {
|
||||
Dept platformCompany = SysCache.getDept(contract.getPlatformCompanyId());
|
||||
if (platformCompany == null) {
|
||||
throw new ServiceException("推送金蝶失败,合同平台公司不存在,无法确定金蝶组织编码");
|
||||
}
|
||||
if (Func.isBlank(platformCompany.getKingdeeDeptCode())) {
|
||||
throw new ServiceException("推送金蝶失败,平台公司[" + platformCompany.getDeptName() + "]未维护金蝶组织编码,请检查部门数据");
|
||||
}
|
||||
return platformCompany.getKingdeeDeptCode();
|
||||
}
|
||||
if (contract.getProjectId() == null) {
|
||||
throw new ServiceException("推送金蝶失败,合同[" + Func.toStr(contract.getContractNo()) + "]未关联项目,无法确定金蝶组织编码");
|
||||
}
|
||||
@@ -993,20 +1022,41 @@ public class KingdeeApBillSyncSupport {
|
||||
}
|
||||
|
||||
/**
|
||||
* 金蝶部门编码:项目业务部门自身的金蝶编码(blade_dept.kingdee_dept_code,不上溯平台公司),
|
||||
* 用于付款申请单 awrp_department_number(部门.编码);项目未关联/未维护业务部门/业务部门未维护金蝶编码时拦截
|
||||
* 金蝶部门编码:必须取「业务部门配置」中合同平台公司 + 业务部门的金蝶部门代码;
|
||||
* 未配置时拦截推送
|
||||
*
|
||||
* @param projectId 项目id
|
||||
* @param context 金蝶同步上下文(缓存载体)
|
||||
* @return 部门编码
|
||||
*/
|
||||
public String kingdeeDeptNumber(Long projectId, KingdeeSyncContext context) {
|
||||
ProjectApply project = this.requireOrgProject(projectId, context);
|
||||
Dept dept = SysCache.getDept(project.getBusinessDeptId());
|
||||
if (dept == null || Func.isBlank(dept.getKingdeeDeptCode())) {
|
||||
throw new ServiceException("推送金蝶失败,项目业务部门[" + Func.toStr(project.getBusinessDeptName()) + "]未维护金蝶编码,请检查部门数据");
|
||||
return kingdeeDeptNumber(null, projectId, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 金蝶部门编码:按合同平台公司下业务部门配置的金蝶部门代码推送
|
||||
*
|
||||
* @param contract 合同(优先取其平台公司、合同负责部门)
|
||||
* @param projectId 项目id(合同未带业务部门时回退项目业务部门)
|
||||
* @param context 金蝶同步上下文(缓存载体)
|
||||
* @return 部门编码
|
||||
*/
|
||||
public String kingdeeDeptNumber(ContractManage contract, Long projectId, KingdeeSyncContext context) {
|
||||
Long platformCompanyId = contract == null ? null : contract.getPlatformCompanyId();
|
||||
Long businessDeptId = contract == null ? null : contract.getOrganizationId();
|
||||
if (businessDeptId == null && projectId != null) {
|
||||
ProjectApply project = this.requireOrgProject(projectId, context);
|
||||
businessDeptId = project.getBusinessDeptId();
|
||||
}
|
||||
return dept.getKingdeeDeptCode();
|
||||
if (platformCompanyId == null || businessDeptId == null) {
|
||||
throw new ServiceException("业务部门对应的金蝶组织代码未配置,单据无法推送,请联系管理员处理。");
|
||||
}
|
||||
org.springblade.system.pojo.entity.BusinessDeptConfig config =
|
||||
SysCache.getBusinessDeptConfig(platformCompanyId, businessDeptId);
|
||||
if (config == null || Func.isBlank(config.getKingdeeDeptCode())) {
|
||||
throw new ServiceException("业务部门对应的金蝶组织代码未配置,单据无法推送,请联系管理员处理。");
|
||||
}
|
||||
return config.getKingdeeDeptCode().trim();
|
||||
}
|
||||
|
||||
/** 金蝶组织/部门编码共用校验:项目存在且已维护业务部门 */
|
||||
@@ -1043,7 +1093,7 @@ public class KingdeeApBillSyncSupport {
|
||||
? KingdeeBfiConstant.BFI_CUSTOMER_ASSTACT_TYPE : KingdeeBfiConstant.BFI_SUPPLIER_ASSTACT_TYPE;*/
|
||||
}
|
||||
|
||||
/** 财务应付单往来户.编码:合同乙方关联客商档案取客商编码,未维护时拦截 */
|
||||
/** 财务应付单往来户.编码:合同乙方关联客商档案取供应商编码/客商编码,均未维护时拦截 */
|
||||
public String bfiAsstactNumber(ContractManage contract, KingdeeSyncContext context) {
|
||||
if (Func.isBlank(contract.getPartyB())) {
|
||||
throw new ServiceException("推送金蝶财务应付单失败,合同乙方不能为空,请检查合同[" + Func.toStr(contract.getContractNo()) + "]乙方信息");
|
||||
@@ -1051,17 +1101,23 @@ public class KingdeeApBillSyncSupport {
|
||||
// 批量同步时按乙方名称缓存客商编码(同批同一乙方只查一次),单笔同步退化为直接查询
|
||||
String customerCode = context.customerCodeCache().computeIfAbsent(contract.getPartyB(), this::queryCustomerCode);
|
||||
if (Func.isBlank(customerCode)) {
|
||||
throw new ServiceException("推送金蝶财务应付单失败,合同乙方[" + contract.getPartyB() + "]未在客商档案中维护客商编码,请补充后重试");
|
||||
throw new ServiceException("推送金蝶财务应付单失败,合同乙方[" + contract.getPartyB() + "]未在客商档案中维护供应商编码或客商编码,请补充后重试");
|
||||
}
|
||||
return customerCode;
|
||||
}
|
||||
|
||||
/** 按乙方名称(全称或简称)查询客商编码,未维护返回 null(应收单同步组件复用:按结算单付款方查询) */
|
||||
/**
|
||||
* 按乙方名称(全称或简称)查询金蝶往来户编码:优先供应商编码(A901-1 金蝶编码建档/同步时落库),
|
||||
* 未维护时兜底客商编号(A902-2 推送创建的金蝶档案编码即客商编号),均未维护返回 null(应收单同步组件复用:按结算单付款方查询)
|
||||
*/
|
||||
public String queryCustomerCode(String partyB) {
|
||||
CustomerArchive customer = customerArchiveMapper.selectOne(Wrappers.<CustomerArchive>lambdaQuery()
|
||||
.and(wrapper -> wrapper.eq(CustomerArchive::getFullName, partyB).or().eq(CustomerArchive::getShortName, partyB))
|
||||
.eq(CustomerArchive::getIsDeleted, 0).last("limit 1"));
|
||||
return customer == null ? null : customer.getCustomerCode();
|
||||
if (customer == null) {
|
||||
return null;
|
||||
}
|
||||
return Func.isBlank(customer.getSupplierCode()) ? customer.getCustomerCode() : customer.getSupplierCode();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+29
-14
@@ -150,19 +150,34 @@ public class KingdeeArBillSyncSupport {
|
||||
if (Func.isBlank(arBillSrcCode)) {
|
||||
return false;
|
||||
}
|
||||
BfiArBillQueryDTO param = new BfiArBillQueryDTO();
|
||||
param.setAwrpSrcCode(List.of(arBillSrcCode));
|
||||
param.setAwrpSrcSys(bfiProperties.getSrcSys());
|
||||
for (BfiArBillVO arBill : BfiUtil.queryAllPages(pageNo -> {
|
||||
param.setPageNo(pageNo);
|
||||
param.setPageSize(AR_RESULT_PAGE_SIZE);
|
||||
return bfiService.queryArBill(param);
|
||||
})) {
|
||||
if (arBillSrcCode.equals(arBill.getAwrpSrcCode()) && Boolean.TRUE.equals(arBill.getHadwrittenoff())) {
|
||||
return true;
|
||||
return queryArBillWriteOffFlags(List.of(arBillSrcCode)).getOrDefault(arBillSrcCode, Boolean.FALSE);
|
||||
}
|
||||
|
||||
/**
|
||||
* A302-4 冲销状态批量查询:防重键批量分批查询财务应收单,返回 防重键 -> 是否已冲销
|
||||
* (hadwrittenoff=true,部分冲销同样视为已冲销;同键多单取或,查不到的键不在结果中即视为未冲销)。
|
||||
* 定时回写结算单金蝶冲销状态用,整批一次查询消除逐单远程查询 N+1
|
||||
*
|
||||
* @param arBillSrcCodes 财务应收单防重键列表
|
||||
* @return 防重键 -> 是否已冲销
|
||||
*/
|
||||
public Map<String, Boolean> queryArBillWriteOffFlags(List<String> arBillSrcCodes) {
|
||||
Map<String, Boolean> writeOffFlags = new LinkedHashMap<>();
|
||||
kingdeeApBillSyncSupport.batchForeach(arBillSrcCodes, bfiProperties.getWriteOffResult().getQueryBatchSize(), batch -> {
|
||||
BfiArBillQueryDTO param = new BfiArBillQueryDTO();
|
||||
param.setAwrpSrcCode(batch);
|
||||
param.setAwrpSrcSys(bfiProperties.getSrcSys());
|
||||
for (BfiArBillVO arBill : BfiUtil.queryAllPages(pageNo -> {
|
||||
param.setPageNo(pageNo);
|
||||
param.setPageSize(AR_RESULT_PAGE_SIZE);
|
||||
return bfiService.queryArBill(param);
|
||||
})) {
|
||||
if (Func.isNotEmpty(arBill.getAwrpSrcCode())) {
|
||||
writeOffFlags.merge(arBill.getAwrpSrcCode(), Boolean.TRUE.equals(arBill.getHadwrittenoff()), Boolean::logicalOr);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return writeOffFlags;
|
||||
}
|
||||
|
||||
// ==================== A302-2 财务应收单保存 ====================
|
||||
@@ -218,8 +233,8 @@ public class KingdeeArBillSyncSupport {
|
||||
param.setOrgNumber(orgNumber);
|
||||
// 收款组织.编码:同结算组织
|
||||
param.setRecorgNumber(orgNumber);
|
||||
// 部门.编码:项目业务部门自身的金蝶编码(结算/收款组织仍传上溯平台公司编码,口径与付款申请/应付单一致)
|
||||
param.setDepartmentNumber(kingdeeApBillSyncSupport.kingdeeDeptNumber(contract.getProjectId(), context));
|
||||
// 部门.编码:合同平台公司下业务部门配置的金蝶部门代码
|
||||
param.setDepartmentNumber(kingdeeApBillSyncSupport.kingdeeDeptNumber(contract, contract.getProjectId(), context));
|
||||
// 【必填】单据日期
|
||||
param.setBizdate(billDateText);
|
||||
// 【必填】往来类型:统一固定客户(KingdeeApBillSyncSupport#bfiAsstactType,与应付单/付款申请同口径)
|
||||
|
||||
+143
-12
@@ -35,17 +35,21 @@ import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiAttachmentUploadVO;
|
||||
import org.springblade.thirdparty.bfi.service.IBfiService;
|
||||
import org.springblade.transport.constant.KingdeeAttachmentConstant;
|
||||
import org.springblade.transport.constant.HcsSyncConstant;
|
||||
import org.springblade.transport.mapper.KingdeeAttachmentSyncMapper;
|
||||
import org.springblade.transport.pojo.entity.KingdeeAttachmentSync;
|
||||
import org.springblade.transport.support.hcs.HcsAttachmentSupport;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
@@ -64,9 +68,10 @@ import java.util.stream.Collectors;
|
||||
* 2. 附件失败永不回滚、不污染主单同步状态(逐附件独立 try/catch);
|
||||
* 3. 同 (biz_type, biz_id, attachment_key) 最多一条成功记录(唯一键 + 成功集合幂等跳过);
|
||||
* 4. 不自动重试,重推主单时自动补传;5. 单文件软限制 20MB,超限/下载失败记原因跳过。</p>
|
||||
* <p>附件来源两类:用户附件(attachmentsJson 数组项,按 url 下载,幂等键=url)与
|
||||
* <p>附件来源三类:用户附件(attachmentsJson 数组项,按 url 下载,幂等键=url,仅接受 http(s) 绝对地址)、
|
||||
* 票池镜像发票文件(attachmentsJson 对象 {source,path,name},经 A801-2 下载回传,幂等键=path,
|
||||
* path 变化(红冲重开)视为新文件自动触发重传)。</p>
|
||||
* path 变化(红冲重开)视为新文件自动触发重传)与
|
||||
* HCS 合规附件(付款特批结果附件等,按合规附件ID 经 C103-5 下载,幂等键=fdId)。</p>
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@@ -80,6 +85,7 @@ public class KingdeeAttachmentSyncSupport {
|
||||
|
||||
private final IBfiService bfiService;
|
||||
private final KingdeeAttachmentSyncMapper attachmentSyncMapper;
|
||||
private final HcsAttachmentSupport hcsAttachmentSupport;
|
||||
|
||||
/** 附件下载客户端(无状态,进程内复用) */
|
||||
private final HttpClient downloadClient = HttpClient.newBuilder()
|
||||
@@ -91,11 +97,30 @@ public class KingdeeAttachmentSyncSupport {
|
||||
* 待同步附件项
|
||||
*
|
||||
* @param name 附件名称(含扩展名,展示/金蝶侧文件名)
|
||||
* @param key 幂等键:用户附件=附件 url;发票文件=A801-2 的 path
|
||||
* @param key 幂等键:用户附件=附件 url;发票文件=A801-2 的 path;合规附件=合规附件ID fdId
|
||||
* @param size 附件大小(字节,可空:空时下载后按实际字节校验)
|
||||
* @param kingdeePathSource true 表示 key 为星瀚侧文件 path(经 A801-2 下载);false 表示 http(s) 附件 url
|
||||
* @param hcsFdIdSource true 表示 key 为合规附件ID fdId(经 C103-5 下载)
|
||||
*/
|
||||
public record KingdeeAttachmentItem(String name, String key, Long size, boolean kingdeePathSource) {
|
||||
public record KingdeeAttachmentItem(String name, String key, Long size, boolean kingdeePathSource, boolean hcsFdIdSource) {
|
||||
|
||||
/**
|
||||
* 兼容既有四参构造(默认非合规附件来源)
|
||||
*/
|
||||
public KingdeeAttachmentItem(String name, String key, Long size, boolean kingdeePathSource) {
|
||||
this(name, key, size, kingdeePathSource, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* HCS 合规附件项(C103-5 按合规附件ID 下载)
|
||||
*
|
||||
* @param name 附件名称
|
||||
* @param fdId 合规附件ID
|
||||
* @return 附件项
|
||||
*/
|
||||
public static KingdeeAttachmentItem hcsAttachment(String name, String fdId) {
|
||||
return new KingdeeAttachmentItem(name, fdId, null, false, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,12 +180,14 @@ public class KingdeeAttachmentSyncSupport {
|
||||
throw new IllegalArgumentException("附件超过20MB大小限制("
|
||||
+ formatSize(item.size()) + "),请压缩后重试");
|
||||
}
|
||||
byte[] content = fetchContent(item);
|
||||
FetchedFile fetched = fetchContent(item);
|
||||
byte[] content = fetched.content();
|
||||
if (content.length > KingdeeAttachmentConstant.MAX_FILE_SIZE_BYTES) {
|
||||
throw new IllegalArgumentException("附件超过20MB大小限制("
|
||||
+ formatSize((long) content.length) + "),请压缩后重试");
|
||||
}
|
||||
String fileName = Func.isBlank(item.name()) ? "attachment" : item.name();
|
||||
String fileName = Func.isNotBlank(fetched.fileName()) ? fetched.fileName()
|
||||
: (Func.isBlank(item.name()) ? "attachment" : item.name());
|
||||
BfiAttachmentUploadVO uploadVO = bfiService.uploadAttachment(entityNumber, billPkId,
|
||||
KingdeeAttachmentConstant.CONTROL_KEY, fileName, content);
|
||||
log.info("金蝶附件上传成功:业务单据[{}:{}],附件[{}],kingdeePath={}", bizType, bizId, item.name(), uploadVO.getPath());
|
||||
@@ -225,7 +252,8 @@ public class KingdeeAttachmentSyncSupport {
|
||||
continue;
|
||||
}
|
||||
String url = firstText(map, "url", "link", "fileUrl", "downloadUrl", "domain", "src");
|
||||
if (Func.isBlank(url)) {
|
||||
// 仅接受 http(s) 绝对地址:相对/代理路径(如合规特批附件展示行)无法直下,跳过防下载必败
|
||||
if (Func.isBlank(url) || !(url.startsWith("http://") || url.startsWith("https://"))) {
|
||||
continue;
|
||||
}
|
||||
String name = firstText(map, "originalName", "name", "fileName");
|
||||
@@ -362,14 +390,31 @@ public class KingdeeAttachmentSyncSupport {
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取附件内容:星瀚侧文件经 A801-2 下载(流式读取,防大文件全量进内存失控);
|
||||
* 用户附件按 http(s) url 下载(流式读取)。调用方负责按 20MB 上限校验
|
||||
* 附件内容拉取结果(合规附件按 C103-5 响应头解析真实文件名,其余来源 fileName 为 null 沿用原名称)
|
||||
*
|
||||
* @param content 附件内容
|
||||
* @param fileName 解析出的文件名(可空)
|
||||
*/
|
||||
private byte[] fetchContent(KingdeeAttachmentItem item) throws Exception {
|
||||
private record FetchedFile(byte[] content, String fileName) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取附件内容:合规附件经 C103-5 按合规附件ID 下载(流式读取,防大文件全量进内存失控);
|
||||
* 星瀚侧文件经 A801-2 下载(流式读取);用户附件按 http(s) url 下载(流式读取)。调用方负责按 20MB 上限校验
|
||||
*/
|
||||
private FetchedFile fetchContent(KingdeeAttachmentItem item) throws Exception {
|
||||
if (item.hcsFdIdSource()) {
|
||||
Response response = hcsAttachmentSupport.downloadRaw(item.key());
|
||||
try (InputStream inputStream = response.body().asInputStream()) {
|
||||
return new FetchedFile(readCapped(inputStream), resolveHcsFileName(response, item.name()));
|
||||
} finally {
|
||||
response.close();
|
||||
}
|
||||
}
|
||||
if (item.kingdeePathSource()) {
|
||||
Response response = bfiService.downloadInvoice(item.key());
|
||||
try (InputStream inputStream = response.body().asInputStream()) {
|
||||
return readCapped(inputStream);
|
||||
return new FetchedFile(readCapped(inputStream), null);
|
||||
} finally {
|
||||
response.close();
|
||||
}
|
||||
@@ -383,10 +428,96 @@ public class KingdeeAttachmentSyncSupport {
|
||||
throw new IllegalStateException("附件下载失败,HTTP状态码 " + response.statusCode());
|
||||
}
|
||||
try (InputStream inputStream = response.body()) {
|
||||
return readCapped(inputStream);
|
||||
return new FetchedFile(readCapped(inputStream), null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析合规附件文件名:统一业务名(HcsSyncConstant.PAYMENT_SPEC_ATTACH_NAME)+ 扩展名,
|
||||
* 扩展名优先取 C103-5 响应头 Content-Disposition 文件名后缀,其次按 Content-Type 映射,兜底 .pdf;
|
||||
* 不上送合规侧原始名(如"文档内容.pdf"),避免金蝶侧附件不可辨识
|
||||
*
|
||||
* @param response C103-5 文件流响应
|
||||
* @param fallbackName 统一业务名
|
||||
* @return 业务名 + 后缀名
|
||||
*/
|
||||
private String resolveHcsFileName(Response response, String fallbackName) {
|
||||
String name = Func.isBlank(fallbackName) ? HcsSyncConstant.PAYMENT_SPEC_ATTACH_NAME : fallbackName.trim();
|
||||
if (name.lastIndexOf('.') > 0) {
|
||||
return name;
|
||||
}
|
||||
String extension = extensionOf(fileNameFromDisposition(firstHeader(response, "Content-Disposition")));
|
||||
if (Func.isBlank(extension)) {
|
||||
extension = extensionOfContentType(firstHeader(response, "Content-Type"));
|
||||
}
|
||||
return name + extension;
|
||||
}
|
||||
|
||||
/** 取文件名扩展名(含点,无扩展名返回空串) */
|
||||
private String extensionOf(String fileName) {
|
||||
String name = Func.isBlank(fileName) ? "" : fileName.trim();
|
||||
int index = name.lastIndexOf('.');
|
||||
return index > 0 && index < name.length() - 1 ? name.substring(index) : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 响应头 Content-Disposition 文件名提取(容忍 filename*=UTF-8''xxx 与 filename="xxx" 两种形式,异常返回 null)
|
||||
*/
|
||||
private String fileNameFromDisposition(String disposition) {
|
||||
if (Func.isBlank(disposition)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
String lower = disposition.toLowerCase();
|
||||
int index = lower.indexOf("filename*=");
|
||||
if (index >= 0) {
|
||||
String value = disposition.substring(index + "filename*=".length());
|
||||
int quote = value.indexOf("''");
|
||||
value = quote >= 0 ? value.substring(quote + 2) : value;
|
||||
return URLDecoder.decode(value.replaceAll("\"", "").trim(), StandardCharsets.UTF_8);
|
||||
}
|
||||
index = lower.indexOf("filename=");
|
||||
if (index >= 0) {
|
||||
return URLDecoder.decode(disposition.substring(index + "filename=".length())
|
||||
.replaceAll("\"", "").trim(), StandardCharsets.UTF_8);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 响应头格式异常按无文件名处理,走 Content-Type 扩展名兜底
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 响应头取值(key 忽略大小写,取首个非空值) */
|
||||
private String firstHeader(Response response, String name) {
|
||||
if (response.headers() == null) {
|
||||
return null;
|
||||
}
|
||||
for (Map.Entry<String, Collection<String>> header : response.headers().entrySet()) {
|
||||
if (header.getKey() != null && header.getKey().equalsIgnoreCase(name) && Func.isNotEmpty(header.getValue())) {
|
||||
for (String value : header.getValue()) {
|
||||
if (Func.isNotBlank(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Content-Type → 扩展名(特批结果附件常规为 pdf,未识别类型兜底 .pdf) */
|
||||
private String extensionOfContentType(String contentType) {
|
||||
String type = contentType == null ? "" : contentType.split(";")[0].trim().toLowerCase();
|
||||
return switch (type) {
|
||||
case "application/pdf" -> ".pdf";
|
||||
case "image/jpeg" -> ".jpg";
|
||||
case "image/png" -> ".png";
|
||||
case "image/bmp" -> ".bmp";
|
||||
case "image/gif" -> ".gif";
|
||||
case "application/ofd" -> ".ofd";
|
||||
default -> ".pdf";
|
||||
};
|
||||
}
|
||||
|
||||
/** 流式读取并封顶(超出软限制即中断读取,避免大文件占满内存) */
|
||||
private byte[] readCapped(InputStream inputStream) throws IOException {
|
||||
int bufferSize = 64 * 1024;
|
||||
|
||||
+3
-1
@@ -185,7 +185,9 @@ public class KingdeeReceivedInvoiceSyncSupport {
|
||||
param.setPaymenttype(BfiConstant.PAY_MODE_CASH);
|
||||
// 【必填】往来类型:统一固定客户(KingdeeApBillSyncSupport#bfiAsstactType,与应付单/付款申请同口径)
|
||||
param.setAsstacttype(kingdeeApBillSyncSupport.bfiAsstactType(contract));
|
||||
// 【必填】结算组织.编码:合同关联项目业务部门反查平台公司(与应付单同源)
|
||||
// 部门金蝶代码校验:收票单主表无部门字段,推送前仍按合同平台公司下业务部门配置校验
|
||||
kingdeeApBillSyncSupport.kingdeeDeptNumber(contract, contract.getProjectId(), context);
|
||||
// 【必填】结算组织.编码:合同平台公司金蝶组织编码(与应付单同源)
|
||||
param.setOrgNumber(kingdeeApBillSyncSupport.bfiOrgNumber(contract, context));
|
||||
// 【必填】结算币别:合同结算币种
|
||||
param.setCurrencyNumber(kingdeeApBillSyncSupport.bfiCurrency(contract));
|
||||
|
||||
+163
-5
@@ -23,23 +23,32 @@
|
||||
|
||||
package org.springblade.transport.support;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.constant.RoleConstant;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.core.tool.utils.SpringUtil;
|
||||
import org.springblade.system.cache.RoleDataScopeCache;
|
||||
import org.springblade.system.cache.SysCache;
|
||||
import org.springblade.system.cache.UserCache;
|
||||
import org.springblade.system.pojo.entity.Dept;
|
||||
import org.springblade.system.pojo.vo.RoleDataScopeVO;
|
||||
import org.springblade.transport.mapper.ProjectApplyMapper;
|
||||
import org.springblade.transport.pojo.entity.ProjectApply;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 运输业务公共校验支持
|
||||
@@ -125,14 +134,14 @@ public final class TransportBusinessSupport {
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析指定菜单下的角色数据权限(超管视为全部组织)。
|
||||
* 解析指定菜单下的角色数据权限(超管视为全部组织),并与用户「数据管理权限」组织取并集。
|
||||
*/
|
||||
public static RoleDataScopeVO resolveRoleDataScope(String menuCode) {
|
||||
if (isAdministrator()) {
|
||||
return RoleDataScopeVO.all();
|
||||
}
|
||||
RoleDataScopeVO scope = RoleDataScopeCache.resolveByMenuCode(menuCode);
|
||||
return scope == null ? RoleDataScopeVO.all() : scope;
|
||||
return mergeUserManageDepts(scope == null ? RoleDataScopeVO.all() : scope);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,10 +161,43 @@ public final class TransportBusinessSupport {
|
||||
if (isAdministrator()) {
|
||||
return true;
|
||||
}
|
||||
RoleDataScopeVO scope = RoleDataScopeCache.resolveWidest();
|
||||
RoleDataScopeVO scope = resolveWidestDataScope();
|
||||
return scope == null || scope.isAllDept();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析最宽角色数据权限,并与用户「数据管理权限」组织取并集。
|
||||
*/
|
||||
public static RoleDataScopeVO resolveWidestDataScope() {
|
||||
if (isAdministrator()) {
|
||||
return RoleDataScopeVO.all();
|
||||
}
|
||||
RoleDataScopeVO scope = RoleDataScopeCache.resolveWidest();
|
||||
return mergeUserManageDepts(scope == null ? RoleDataScopeVO.all() : scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色组织权限与用户数据管理组织取并集。
|
||||
* <p>
|
||||
* 全部组织 / 本人创建 不因用户管理组织改变;其余类型将用户管理组织(含下级)并入 deptIds。
|
||||
*/
|
||||
private static RoleDataScopeVO mergeUserManageDepts(RoleDataScopeVO scope) {
|
||||
if (scope == null || scope.isAllDept() || scope.isOwnCreate()) {
|
||||
return scope;
|
||||
}
|
||||
Long userId = AuthUtil.getUserId();
|
||||
List<Long> manageDeptIds = UserCache.getManageDeptIds(userId);
|
||||
if (Func.isEmpty(manageDeptIds)) {
|
||||
return scope;
|
||||
}
|
||||
LinkedHashSet<Long> mergedDeptIds = new LinkedHashSet<>();
|
||||
if (Func.isNotEmpty(scope.getDeptIds())) {
|
||||
mergedDeptIds.addAll(scope.getDeptIds());
|
||||
}
|
||||
mergedDeptIds.addAll(manageDeptIds);
|
||||
return RoleDataScopeVO.ofDepts(scope.getScopeType(), List.copyOf(mergedDeptIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否仅本人创建数据权限
|
||||
*/
|
||||
@@ -180,6 +222,29 @@ public final class TransportBusinessSupport {
|
||||
return dept;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表行是否只读:与 {@link #assertCurrentDept(Long, String, String)} 判定保持一致。
|
||||
* <p>
|
||||
* 超管 / 全部组织 / 本人创建数据权限下可编辑;指定组织权限下,仅所属组织在授权范围内时可编辑;
|
||||
* 否则仅当前登录用户所属组织的数据可编辑。
|
||||
*
|
||||
* @param deptId 业务数据所属组织
|
||||
* @param menuCode 菜单编码
|
||||
* @return true-前端仅展示查看
|
||||
*/
|
||||
public static boolean isDeptReadonly(Long deptId, String menuCode) {
|
||||
RoleDataScopeVO scope = resolveRoleDataScope(menuCode);
|
||||
if (scope.isAllDept() || scope.isOwnCreate()) {
|
||||
return false;
|
||||
}
|
||||
List<Long> allowedDeptIds = scope.getDeptIds();
|
||||
if (Func.isNotEmpty(allowedDeptIds)) {
|
||||
return deptId == null || deptId <= 0 || !allowedDeptIds.contains(deptId);
|
||||
}
|
||||
Long currentDeptId = Func.firstLong(AuthUtil.getDeptId());
|
||||
return currentDeptId != null && !Objects.equals(deptId, currentDeptId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验目标业务数据属于当前用户可操作的组织。
|
||||
*/
|
||||
@@ -207,7 +272,7 @@ public final class TransportBusinessSupport {
|
||||
if (hasAllDeptScope()) {
|
||||
return;
|
||||
}
|
||||
RoleDataScopeVO scope = RoleDataScopeCache.resolveWidest();
|
||||
RoleDataScopeVO scope = resolveWidestDataScope();
|
||||
if (scope != null && scope.isOwnCreate()) {
|
||||
return;
|
||||
}
|
||||
@@ -234,6 +299,99 @@ public final class TransportBusinessSupport {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表可见范围 OR:所属项目的业务部门落在指定组织内。
|
||||
* <p>
|
||||
* 与项目立项「本部门可见 business_dept_id」对齐,使合同/运单等业务数据
|
||||
* 在负责部门(organizationId/deptId)不是当前组织时,业务部门用户仍可看到。
|
||||
*/
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
public static void orProjectVisibleByBusinessDept(AbstractWrapper<?, ?, ?> wrapper, Collection<Long> deptIds) {
|
||||
if (wrapper == null || Func.isEmpty(deptIds)) {
|
||||
return;
|
||||
}
|
||||
List<Long> ids = deptIds.stream().filter(Objects::nonNull).filter(id -> id > 0).distinct().toList();
|
||||
if (ids.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (ids.size() == 1) {
|
||||
wrapper.or().apply(
|
||||
"project_id IN (SELECT id FROM blade_project_apply WHERE is_deleted = 0 AND business_dept_id = {0})",
|
||||
ids.get(0));
|
||||
return;
|
||||
}
|
||||
String inList = ids.stream().map(String::valueOf).collect(Collectors.joining(","));
|
||||
wrapper.or().apply(
|
||||
"project_id IN (SELECT id FROM blade_project_apply WHERE is_deleted = 0 AND business_dept_id IN ("
|
||||
+ inList + "))");
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断项目是否归属指定业务部门(用于详情只读/可操作判定)。
|
||||
*/
|
||||
public static boolean isProjectOwnedByBusinessDept(Long projectId, Long deptId) {
|
||||
if (Func.isEmpty(projectId) || Func.isEmpty(deptId) || deptId <= 0) {
|
||||
return false;
|
||||
}
|
||||
ProjectApplyMapper projectApplyMapper = SpringUtil.getBean(ProjectApplyMapper.class);
|
||||
if (projectApplyMapper == null) {
|
||||
return false;
|
||||
}
|
||||
Long count = projectApplyMapper.selectCount(Wrappers.<ProjectApply>lambdaQuery()
|
||||
.eq(ProjectApply::getId, projectId)
|
||||
.eq(ProjectApply::getBusinessDeptId, deptId)
|
||||
.eq(ProjectApply::getIsDeleted, 0));
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表实际使用的组织范围。
|
||||
* <p>
|
||||
* 角色菜单未单独收窄、解析结果为「全部组织」时,超管仍不限制;
|
||||
* 普通用户在未显式请求全部组织(allDept != 1)时收口到当前组织,
|
||||
* 避免账号看到其他组织的业务数据。本人创建权限不按组织收口。
|
||||
*/
|
||||
public static List<Long> resolveScopedDeptFilterIds(Integer allDept, Long deptId, String moduleName, String menuCode) {
|
||||
List<Long> deptIdList = resolveDeptFilterIds(allDept, deptId, moduleName, menuCode);
|
||||
if (Func.isNotEmpty(deptIdList) || isAdministrator() || isOwnCreateScope(menuCode) || Objects.equals(allDept, 1)) {
|
||||
return deptIdList;
|
||||
}
|
||||
return Collections.singletonList(currentDeptId(moduleName));
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号项目可见范围:承办部门命中指定组织,或当前用户是项目创建人、经办人、负责人。
|
||||
*/
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
public static void orProjectVisibleByAccount(AbstractWrapper<?, ?, ?> wrapper, Collection<Long> deptIds, Long userId) {
|
||||
if (wrapper == null) {
|
||||
return;
|
||||
}
|
||||
List<Long> ids = deptIds == null ? List.of() : deptIds.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.filter(id -> id > 0)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (!ids.isEmpty()) {
|
||||
if (ids.size() == 1) {
|
||||
wrapper.or().apply(
|
||||
"project_id IN (SELECT id FROM blade_project_apply WHERE is_deleted = 0 AND FIND_IN_SET({0}, IFNULL(undertake_dept_id,'')))",
|
||||
String.valueOf(ids.get(0)));
|
||||
} else {
|
||||
String undertakeSql = ids.stream()
|
||||
.map(id -> "FIND_IN_SET(" + id + ", IFNULL(undertake_dept_id,''))")
|
||||
.collect(Collectors.joining(" OR "));
|
||||
wrapper.or().apply(
|
||||
"project_id IN (SELECT id FROM blade_project_apply WHERE is_deleted = 0 AND (" + undertakeSql + "))");
|
||||
}
|
||||
}
|
||||
if (Func.isNotEmpty(userId) && userId > 0) {
|
||||
wrapper.or().apply(
|
||||
"project_id IN (SELECT id FROM blade_project_apply WHERE is_deleted = 0 AND (create_user = {0} OR handler_user_id = {0} OR principal_user_id = {0}))",
|
||||
userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析列表组织筛选 ID。
|
||||
*
|
||||
@@ -296,7 +454,7 @@ public final class TransportBusinessSupport {
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
RoleDataScopeVO scope = RoleDataScopeCache.resolveWidest();
|
||||
RoleDataScopeVO scope = resolveWidestDataScope();
|
||||
if (scope != null && scope.isOwnCreate()) {
|
||||
if (Objects.equals(allDept, 1) && Func.isNotEmpty(deptId) && deptId > 0) {
|
||||
return Collections.singletonList(deptId);
|
||||
|
||||
+6
-2
@@ -66,6 +66,7 @@ import java.util.Objects;
|
||||
* 任一附件转传失败 → 抛出异常由整单推送失败进补偿重试(失败原因落单据 hcs_sync_msg)。
|
||||
* <p>
|
||||
* 合同链路挂载字段 fdEntityKey(v4 枚举):主文件 approved=true → eopCdraftSign(用印)、
|
||||
* 双章归档文件(fileType=双章归档文件,归档材料非用印正文)→ eopCdraftOther(其他附件,2026-10-30)、
|
||||
* 特批附件(fileType=特批附件)→ eopCdraftSpecial(随 C103-1 fdSepcailContract=true 特批流程)、其它附件 → eopCdraftOther。
|
||||
*
|
||||
* @author Chill
|
||||
@@ -106,7 +107,7 @@ public class HcsAttachmentSupport {
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析合同主文件 JSON(仅 approved=true 的文件,挂用印字段)与其它附件 JSON(挂其他附件字段)并转传
|
||||
* 解析合同主文件 JSON(仅 approved=true 的文件:双章归档文件挂其他附件字段,其余挂用印字段)与其它附件 JSON 并转传
|
||||
*
|
||||
* @param bizType 业务单据类型(HcsSyncConstant.BIZ_TYPE_*)
|
||||
* @param bizId 业务单据id
|
||||
@@ -119,8 +120,11 @@ public class HcsAttachmentSupport {
|
||||
List<HcsAttachmentRefDTO> attachmentRefs = new ArrayList<>();
|
||||
for (Map<?, ?> file : parseJsonArray(contractFileJson)) {
|
||||
if (isApproved(file)) {
|
||||
// 双章归档文件挂其他附件面板(2026-10-30:归档材料非用印正文),其余用印材料挂用印字段
|
||||
Object fileType = file.get(HcsSyncConstant.ATTACHMENT_TYPE_FIELD);
|
||||
boolean sealArchive = fileType != null && HcsSyncConstant.ATTACHMENT_TYPE_SEAL.equals(String.valueOf(fileType).trim());
|
||||
attachmentRefs.add(this.transferOne(bizType, bizId, file, operatorLoginName,
|
||||
HcsSyncConstant.CONTRACT_ATTACH_KEY_SEAL));
|
||||
sealArchive ? HcsSyncConstant.CONTRACT_ATTACH_KEY_OTHER : HcsSyncConstant.CONTRACT_ATTACH_KEY_SEAL));
|
||||
}
|
||||
}
|
||||
for (Map<?, ?> attachment : parseJsonArray(attachmentsJson)) {
|
||||
|
||||
+164
-74
@@ -11,8 +11,8 @@
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for their own commercial purposes.
|
||||
* 3. Licensees may copyright their own code but cannot use segments of this software
|
||||
* for their own commercial purposes.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this license agreement and terms of use,
|
||||
* and you expressly agree to be bound by the terms of use of this software.
|
||||
@@ -50,6 +50,7 @@ import org.springblade.transport.constant.HcsSyncConstant;
|
||||
import org.springblade.transport.mapper.ContractManageMapper;
|
||||
import org.springblade.transport.mapper.FormalSettlementMapper;
|
||||
import org.springblade.transport.mapper.KingdeeReceiptFlowMapper;
|
||||
import org.springblade.transport.mapper.ReceiptClaimHcsRecordMapper;
|
||||
import org.springblade.transport.mapper.ReceiptClaimMapper;
|
||||
import org.springblade.transport.mapper.ReceiptClaimSettlementMapper;
|
||||
import org.springblade.transport.pojo.entity.ContractManage;
|
||||
@@ -57,12 +58,14 @@ import org.springblade.transport.pojo.entity.FormalSettlement;
|
||||
import org.springblade.transport.pojo.entity.HcsContractExeStage;
|
||||
import org.springblade.transport.pojo.entity.KingdeeReceiptFlow;
|
||||
import org.springblade.transport.pojo.entity.ReceiptClaim;
|
||||
import org.springblade.transport.pojo.entity.ReceiptClaimHcsRecord;
|
||||
import org.springblade.transport.pojo.entity.ReceiptClaimSettlement;
|
||||
import org.springblade.transport.service.IContractExeStageService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@@ -73,13 +76,14 @@ import java.util.stream.Collectors;
|
||||
/**
|
||||
* HCS 收款认领链推送编排支撑组件(C103-9 更新履约计划 + C107-1 认领记录 + C108-1 履约流水)
|
||||
* <p>
|
||||
* 触发点:金蝶认领推送(A301-3)成功之后(手动按钮与定时任务共用钩子)。
|
||||
* 触发点:收款流水认领事务提交后按认领主单推送(作废认领事务提交后推 C107-3 作废同步)。
|
||||
* 开关复用既有配置(不单独新增):exe-stage.sync-enabled 门禁 C103-9/C107-1(与合同链履约计划同步同开关),
|
||||
* perf-record.sync-enabled 门禁 C108-1(与付款链履约记录同步同开关)。
|
||||
* 编排顺序:先 C103-9(认领金额累加到收款履约计划行并重推,C107-1/C108-1 引用其返回的阶段ID),
|
||||
* 再按「认领主单 × 合同」推 C107-1(合规流水ID 经 C104-2 按明细流水号定位),最后按合同推 C108-1 履约流水。
|
||||
* 再按「认领主单 × 合同」推 C107-1(合规流水ID 经 C104-2 按明细流水号定位,返回 fdId 落库
|
||||
* blade_receipt_claim_hcs_record 供作废 C107-3 引用),最后按合同推 C108-1 履约流水。
|
||||
* 合同未推送合规(hcs_fd_id 空)时仅累加计划行金额,随合同链路首推。
|
||||
* 推送失败仅记日志不回滚金蝶推送结果(单合同失败不影响其余合同)。
|
||||
* 推送失败仅落失败标记与日志,不回滚认领主流程(单合同失败不影响其余合同,不做定时重试)。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@@ -92,59 +96,80 @@ public class HcsClaimSyncSupport {
|
||||
* 认领完成状态(与 ReceiptFlowServiceImpl.CLAIMED 同值)
|
||||
*/
|
||||
private static final String CLAIMED = "claimed";
|
||||
/**
|
||||
* 推送状态:推送成功
|
||||
*/
|
||||
private static final String PUSH_SUCCESS = "success";
|
||||
/**
|
||||
* 推送状态:推送失败(仅记标记,不做定时重试)
|
||||
*/
|
||||
private static final String PUSH_FAILED = "failed";
|
||||
/**
|
||||
* 推送状态:已作废(C107-3 同步成功)
|
||||
*/
|
||||
private static final String PUSH_VOIDED = "voided";
|
||||
/**
|
||||
* 认领记录状态:已认领
|
||||
*/
|
||||
private static final int CLAIM_STATUS_CLAIMED = 0;
|
||||
/**
|
||||
* 认领记录状态:已作废
|
||||
*/
|
||||
private static final int CLAIM_STATUS_VOIDED = 1;
|
||||
|
||||
private final IHcsClient hcsClient;
|
||||
private final HcsProperties hcsProperties;
|
||||
private final KingdeeReceiptFlowMapper receiptFlowMapper;
|
||||
private final ReceiptClaimMapper claimMapper;
|
||||
private final ReceiptClaimSettlementMapper claimSettlementMapper;
|
||||
private final ReceiptClaimHcsRecordMapper claimHcsRecordMapper;
|
||||
private final FormalSettlementMapper formalSettlementMapper;
|
||||
private final ContractManageMapper contractManageMapper;
|
||||
private final IContractExeStageService contractExeStageService;
|
||||
private final HcsExeStageSyncSupport hcsExeStageSyncSupport;
|
||||
|
||||
/**
|
||||
* 金蝶认领推送成功后推送合同合规(C103-9 → C107-1 → C108-1)
|
||||
* 认领事务提交后推送合同合规(C103-9 → C107-1 → C108-1,按认领主单维度)
|
||||
* <p>
|
||||
* 复用既有开关:exe-stage.sync-enabled(履约计划同步)门禁 C103-9/C107-1,
|
||||
* perf-record.sync-enabled(履约记录同步)门禁 C108-1;开关关闭或推送异常均静默返回,
|
||||
* 不影响金蝶认领推送主流程与事务。
|
||||
* 不影响认领主流程与事务。
|
||||
*
|
||||
* @param flowId 收款流水ID
|
||||
* @param claimId 认领主单ID
|
||||
*/
|
||||
public void pushClaimToHcs(Long flowId) {
|
||||
public void pushClaimToHcs(Long claimId) {
|
||||
if (!hcsProperties.getExeStage().isSyncEnabled()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
doPushClaimToHcs(flowId);
|
||||
doPushClaimToHcs(claimId);
|
||||
} catch (Exception exception) {
|
||||
log.error("HCS收款认领链推送异常 flowId:{}", flowId, exception);
|
||||
log.error("HCS收款认领链推送异常 claimId:{}", claimId, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送主体:归集有效认领分摊(按合同分组)→ 逐合同推送(单合同失败不影响其余合同)
|
||||
* 推送主体:归集本认领主单有效分摊(按合同分组)→ 逐合同推送(单合同失败不影响其余合同)
|
||||
*/
|
||||
private void doPushClaimToHcs(Long flowId) {
|
||||
KingdeeReceiptFlow flow = receiptFlowMapper.selectById(flowId);
|
||||
private void doPushClaimToHcs(Long claimId) {
|
||||
ReceiptClaim claim = claimMapper.selectById(claimId);
|
||||
if (claim == null || !CLAIMED.equals(claim.getClaimStatus())) {
|
||||
log.info("HCS收款认领链推送跳过:认领主单不存在或已作废 claimId={}", claimId);
|
||||
return;
|
||||
}
|
||||
KingdeeReceiptFlow flow = receiptFlowMapper.selectById(claim.getReceiptFlowId());
|
||||
if (flow == null) {
|
||||
log.warn("HCS收款认领链推送跳过:收款流水不存在 claimId={},flowId={}", claimId, claim.getReceiptFlowId());
|
||||
return;
|
||||
}
|
||||
// 未作废认领主单(作废认领不同步合规)
|
||||
List<ReceiptClaim> claims = claimMapper.selectList(Wrappers.<ReceiptClaim>lambdaQuery()
|
||||
.eq(ReceiptClaim::getReceiptFlowId, flowId)
|
||||
.eq(ReceiptClaim::getClaimStatus, CLAIMED));
|
||||
if (claims.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
// 有效分摊行(作废时已置 status=0)
|
||||
// 本认领主单有效分摊行(作废时已置 status=0)
|
||||
List<ReceiptClaimSettlement> allocations = claimSettlementMapper.selectList(
|
||||
Wrappers.<ReceiptClaimSettlement>lambdaQuery()
|
||||
.eq(ReceiptClaimSettlement::getReceiptFlowId, flowId)
|
||||
.eq(ReceiptClaimSettlement::getReceiptClaimId, claimId)
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)
|
||||
.eq(ReceiptClaimSettlement::getIsDeleted, 0));
|
||||
if (allocations.isEmpty()) {
|
||||
log.info("HCS收款认领链推送跳过:认领主单无有效分摊 claimId={}", claimId);
|
||||
return;
|
||||
}
|
||||
// 结算单批量定位合同(一次 IN 查询,避免逐行 N+1)
|
||||
@@ -152,20 +177,18 @@ public class HcsClaimSyncSupport {
|
||||
allocations.stream().map(ReceiptClaimSettlement::getFormalSettlementId)
|
||||
.filter(Objects::nonNull).distinct().toList()).stream()
|
||||
.collect(Collectors.toMap(FormalSettlement::getId, item -> item, (first, second) -> first));
|
||||
// 合同id → 认领主单id → 分摊金额
|
||||
Map<Long, Map<Long, BigDecimal>> amountByContractByClaim = new LinkedHashMap<>();
|
||||
// 合同id → 本认领主单分摊金额
|
||||
Map<Long, BigDecimal> amountByContract = new LinkedHashMap<>();
|
||||
for (ReceiptClaimSettlement allocation : allocations) {
|
||||
FormalSettlement settlement = settlements.get(allocation.getFormalSettlementId());
|
||||
if (settlement == null || settlement.getContractId() == null
|
||||
|| allocation.getReceiptClaimId() == null || allocation.getAllocatedReceiptAmount() == null) {
|
||||
|| allocation.getAllocatedReceiptAmount() == null) {
|
||||
continue;
|
||||
}
|
||||
amountByContractByClaim
|
||||
.computeIfAbsent(settlement.getContractId(), key -> new LinkedHashMap<>())
|
||||
.merge(allocation.getReceiptClaimId(), allocation.getAllocatedReceiptAmount(), BigDecimal::add);
|
||||
amountByContract.merge(settlement.getContractId(), allocation.getAllocatedReceiptAmount(), BigDecimal::add);
|
||||
}
|
||||
if (amountByContractByClaim.isEmpty()) {
|
||||
log.info("HCS收款认领链推送跳过:分摊行未关联合同 flowId={}", flowId);
|
||||
if (amountByContract.isEmpty()) {
|
||||
log.info("HCS收款认领链推送跳过:分摊行未关联合同 claimId={},flowId={}", claimId, flow.getId());
|
||||
return;
|
||||
}
|
||||
// 合规侧收款流水ID(C107-1 必填引用,一次定位全部合同共用;定位失败仅跳过 C107-1,不阻塞履约计划更新)
|
||||
@@ -173,44 +196,44 @@ public class HcsClaimSyncSupport {
|
||||
try {
|
||||
hcsCollectionFlowId = locateHcsCollectionFlowId(flow);
|
||||
} catch (Exception exception) {
|
||||
log.error("HCS收款流水定位失败 flowId:{},跳过C107-1认领记录推送", flowId, exception);
|
||||
log.error("HCS收款流水定位失败 flowId:{},跳过C107-1认领记录推送", flow.getId(), exception);
|
||||
}
|
||||
for (Map.Entry<Long, Map<Long, BigDecimal>> contractEntry : amountByContractByClaim.entrySet()) {
|
||||
for (Map.Entry<Long, BigDecimal> contractEntry : amountByContract.entrySet()) {
|
||||
try {
|
||||
pushOneContract(flow, claims, contractEntry.getKey(), contractEntry.getValue(), hcsCollectionFlowId);
|
||||
pushOneContract(flow, claim, contractEntry.getKey(), contractEntry.getValue(), hcsCollectionFlowId);
|
||||
} catch (Exception exception) {
|
||||
log.error("HCS收款认领链推送失败 flowId:{},contractId:{}", flowId, contractEntry.getKey(), exception);
|
||||
log.error("HCS收款认领链推送失败 claimId:{},flowId:{},contractId:{}",
|
||||
claimId, flow.getId(), contractEntry.getKey(), exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送单个合同:① C103-9 更新履约计划(认领金额累加重推)→ ② C107-1 认领记录(按认领主单分组)→ ③ C108-1 履约流水
|
||||
* 推送单个合同:① C103-9 更新履约计划(认领金额累加重推)→ ② C107-1 认领记录(结果落库)→ ③ C108-1 履约流水
|
||||
*/
|
||||
private void pushOneContract(KingdeeReceiptFlow flow, List<ReceiptClaim> claims, Long contractId,
|
||||
Map<Long, BigDecimal> amountByClaim, String hcsCollectionFlowId) {
|
||||
private void pushOneContract(KingdeeReceiptFlow flow, ReceiptClaim claim, Long contractId,
|
||||
BigDecimal claimAmount, String hcsCollectionFlowId) {
|
||||
ContractManage contractManage = contractManageMapper.selectById(contractId);
|
||||
if (contractManage == null) {
|
||||
log.warn("HCS收款认领链推送跳过:合同不存在 contractId={}", contractId);
|
||||
return;
|
||||
}
|
||||
BigDecimal claimedTotal = amountByClaim.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
// ① C103-9 更新合同履约计划:认领金额累加到收款计划行;已同步行置 update 凭阶段ID 重推
|
||||
contractExeStageService.accumulateClaimedAmountOnPush(contractId, claimedTotal);
|
||||
contractExeStageService.accumulateClaimedAmountOnPush(contractId, claimAmount);
|
||||
HcsContractExeStage stageRow = contractExeStageService.findCollectionStageRow(contractId, HcsSyncConstant.FUND_NATURE_PROGRESS);
|
||||
if (StringUtil.isNotBlank(contractManage.getHcsFdId())) {
|
||||
// 行级重推(无待同步行返回提示,不视为失败);合同未推送合规时随合同链路首推
|
||||
hcsExeStageSyncSupport.syncExeStages(contractManage);
|
||||
}
|
||||
// ② C107-1 认领记录:按「认领主单 × 合同」推送,金额为该认领主单分摊到本合同的金额
|
||||
// ② C107-1 认领记录:本认领主单分摊到本合同的金额,返回 fdId 落库供 C107-3 作废引用
|
||||
if (StringUtil.isBlank(hcsCollectionFlowId)) {
|
||||
log.warn("HCS认领记录推送跳过:未定位到合规收款流水 flowId={},detailSerialNo={}", flow.getId(), flow.getDetailSerialNo());
|
||||
log.warn("HCS认领记录推送跳过:未定位到合规收款流水 claimId={},flowId={},detailSerialNo={}",
|
||||
claim.getId(), flow.getId(), flow.getDetailSerialNo());
|
||||
saveClaimHcsRecord(claim, flow, contractManage, claimAmount, null, PUSH_FAILED, "C104-2未定位到合规收款流水");
|
||||
} else {
|
||||
Map<Long, ReceiptClaim> claimsById = claims.stream()
|
||||
.collect(Collectors.toMap(ReceiptClaim::getId, item -> item, (first, second) -> first));
|
||||
for (Map.Entry<Long, BigDecimal> claimEntry : amountByClaim.entrySet()) {
|
||||
pushClaimRecord(flow, claimsById.get(claimEntry.getKey()), contractManage, stageRow, claimEntry.getValue(), hcsCollectionFlowId);
|
||||
}
|
||||
String claimRecordFdId = pushClaimRecord(flow, claim, contractManage, stageRow, claimAmount, hcsCollectionFlowId);
|
||||
saveClaimHcsRecord(claim, flow, contractManage, claimAmount, claimRecordFdId,
|
||||
StringUtil.isBlank(claimRecordFdId) ? PUSH_FAILED : PUSH_SUCCESS, null);
|
||||
}
|
||||
// ③ C108-1 履约流水:按合同一条(开关 perf-record.sync-enabled,与付款链履约记录同步同开关),
|
||||
// 须已推送合规且存在已同步收款计划行(fd_plan_id 必填)
|
||||
@@ -226,16 +249,18 @@ public class HcsClaimSyncSupport {
|
||||
log.info("HCS履约流水推送跳过:无已同步收款履约计划行 contractNo={}", contractManage.getContractNo());
|
||||
return;
|
||||
}
|
||||
pushPerfRecord(flow, contractManage, stageRow, claimedTotal, latestClaimDate(claims));
|
||||
pushPerfRecord(flow, contractManage, stageRow, claimAmount, claim.getClaimDate());
|
||||
}
|
||||
|
||||
/**
|
||||
* C107-1 认领记录推送(单认领主单 × 单合同)
|
||||
*
|
||||
* @return 合规侧认领记录 fdId(响应未返回返回 null)
|
||||
*/
|
||||
private void pushClaimRecord(KingdeeReceiptFlow flow, ReceiptClaim claim, ContractManage contractManage,
|
||||
HcsContractExeStage stageRow, BigDecimal claimAmount, String hcsCollectionFlowId) {
|
||||
if (claim == null || claimAmount == null || claimAmount.signum() <= 0) {
|
||||
return;
|
||||
private String pushClaimRecord(KingdeeReceiptFlow flow, ReceiptClaim claim, ContractManage contractManage,
|
||||
HcsContractExeStage stageRow, BigDecimal claimAmount, String hcsCollectionFlowId) {
|
||||
if (claimAmount == null || claimAmount.signum() <= 0) {
|
||||
return null;
|
||||
}
|
||||
HcsClaimRecordAddDTO param = new HcsClaimRecordAddDTO();
|
||||
// 【必填】收款流水引用(合规侧流水ID,C104-2 按明细流水号定位)
|
||||
@@ -264,7 +289,7 @@ public class HcsClaimSyncSupport {
|
||||
param.setFdClaimDate(toMillis(claim.getClaimDate()));
|
||||
param.setFdRemark(claim.getRemark());
|
||||
// 状态 0=已认领(仅推送未作废认领)
|
||||
param.setFdStatus(0);
|
||||
param.setFdStatus(CLAIM_STATUS_CLAIMED);
|
||||
log.info("HCS认领记录推送开始 flowId:{},claimId:{},contractNo:{},认领金额 {}",
|
||||
flow.getId(), claim.getId(), contractManage.getContractNo(), claimAmount);
|
||||
HcsResult<HcsIdVO> result = hcsClient.claimRecordAdd(param);
|
||||
@@ -272,26 +297,102 @@ public class HcsClaimSyncSupport {
|
||||
log.error("HCS认领记录推送失败响应:{},claimId={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), claim.getId());
|
||||
throw new HcsException("C107-1 认领记录推送失败:" + (result == null ? "响应为空" : result.getMsg()));
|
||||
}
|
||||
// shortcut: C107-1 返回 fdId 未持久化(认领作废同步 C107-3 未实现,重推会重复);需作废联动时先落库
|
||||
String claimRecordFdId = result.getData() == null ? null : result.getData().getFdId();
|
||||
log.info("HCS认领记录推送成功 flowId:{},claimId:{},contractNo:{},claimRecordFdId:{}",
|
||||
flow.getId(), claim.getId(), contractManage.getContractNo(),
|
||||
result.getData() == null ? "响应未返回" : result.getData().getFdId());
|
||||
StringUtil.isBlank(claimRecordFdId) ? "响应未返回" : claimRecordFdId);
|
||||
return claimRecordFdId;
|
||||
}
|
||||
|
||||
/**
|
||||
* C108-1 履约流水推送(单合同一条:申请金额=实际金额=该合同累计认领金额)
|
||||
* C107-1 推送结果落库(认领主单 × 合同一条):成功持久化合规 fdId,失败落标记与原因
|
||||
*/
|
||||
private void saveClaimHcsRecord(ReceiptClaim claim, KingdeeReceiptFlow flow, ContractManage contractManage,
|
||||
BigDecimal claimAmount, String claimRecordFdId, String pushStatus, String failReason) {
|
||||
ReceiptClaimHcsRecord record = new ReceiptClaimHcsRecord();
|
||||
record.setReceiptClaimId(claim.getId());
|
||||
record.setReceiptFlowId(flow.getId());
|
||||
record.setContractId(contractManage.getId());
|
||||
record.setContractNo(contractManage.getContractNo());
|
||||
record.setClaimAmount(claimAmount);
|
||||
record.setHcsClaimRecordFdId(claimRecordFdId);
|
||||
record.setPushStatus(pushStatus);
|
||||
record.setFailReason(failReason);
|
||||
record.setPushedTime(PUSH_SUCCESS.equals(pushStatus) ? LocalDateTime.now() : null);
|
||||
claimHcsRecordMapper.insert(record);
|
||||
}
|
||||
|
||||
/**
|
||||
* 认领作废事务提交后同步合规 C107-3(修改认领记录 fdStatus=1 已作废)
|
||||
* <p>
|
||||
* 仅同步本认领主单推送成功的合规记录;单条失败独立记日志,不影响其余合同。
|
||||
* 作废失败时本地记录保持 success(合规侧仍为已认领),需人工在合规侧处置。
|
||||
*
|
||||
* @param claimId 认领主单ID
|
||||
*/
|
||||
public void voidClaimToHcs(Long claimId) {
|
||||
if (!hcsProperties.getExeStage().isSyncEnabled()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
List<ReceiptClaimHcsRecord> records = claimHcsRecordMapper.selectList(
|
||||
Wrappers.<ReceiptClaimHcsRecord>lambdaQuery()
|
||||
.eq(ReceiptClaimHcsRecord::getReceiptClaimId, claimId)
|
||||
.eq(ReceiptClaimHcsRecord::getPushStatus, PUSH_SUCCESS));
|
||||
if (records.isEmpty()) {
|
||||
log.info("HCS认领作废同步跳过:无推送成功的合规认领记录 claimId={}", claimId);
|
||||
return;
|
||||
}
|
||||
for (ReceiptClaimHcsRecord record : records) {
|
||||
try {
|
||||
voidOneClaimRecord(record);
|
||||
} catch (Exception exception) {
|
||||
log.error("HCS认领作废同步失败 claimId:{},recordId:{},hcsFdId:{}",
|
||||
claimId, record.getId(), record.getHcsClaimRecordFdId(), exception);
|
||||
}
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
log.error("HCS认领作废同步异常 claimId:{}", claimId, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* C107-3 单条作废:fdId 必填,fdStatus=1 已作废;成功后本地记录置 voided
|
||||
*/
|
||||
private void voidOneClaimRecord(ReceiptClaimHcsRecord record) {
|
||||
HcsClaimRecordAddDTO param = new HcsClaimRecordAddDTO();
|
||||
// 【必填】合规侧认领记录ID(C107-1 推送成功时落库)
|
||||
param.setFdId(record.getHcsClaimRecordFdId());
|
||||
// 状态 1=已作废
|
||||
param.setFdStatus(CLAIM_STATUS_VOIDED);
|
||||
log.info("HCS认领作废同步开始 claimId:{},contractNo:{},hcsFdId:{}",
|
||||
record.getReceiptClaimId(), record.getContractNo(), record.getHcsClaimRecordFdId());
|
||||
HcsResult<HcsIdVO> result = hcsClient.claimRecordUpdate(param);
|
||||
if (result == null || !result.isSuccess()) {
|
||||
throw new HcsException("C107-3 认领作废同步失败:" + (result == null ? "响应为空" : result.getMsg()));
|
||||
}
|
||||
// 本地推送记录置已作废
|
||||
claimHcsRecordMapper.update(null, Wrappers.<ReceiptClaimHcsRecord>lambdaUpdate()
|
||||
.eq(ReceiptClaimHcsRecord::getId, record.getId())
|
||||
.set(ReceiptClaimHcsRecord::getPushStatus, PUSH_VOIDED));
|
||||
log.info("HCS认领作废同步成功 claimId:{},contractNo:{},hcsFdId:{}",
|
||||
record.getReceiptClaimId(), record.getContractNo(), record.getHcsClaimRecordFdId());
|
||||
}
|
||||
|
||||
/**
|
||||
* C108-1 履约流水推送(单合同一条:申请金额=实际金额=本次认领分摊金额)
|
||||
*/
|
||||
private void pushPerfRecord(KingdeeReceiptFlow flow, ContractManage contractManage,
|
||||
HcsContractExeStage stageRow, BigDecimal claimedTotal, LocalDate planDate) {
|
||||
HcsContractExeStage stageRow, BigDecimal claimAmount, LocalDate planDate) {
|
||||
// 表单模板ID(配置 perf-record.xform-id,环境值 §9-U9,未配置不传)
|
||||
String xformId = StringUtil.isBlank(hcsProperties.getPerfRecord().getXformId()) ? null : hcsProperties.getPerfRecord().getXformId();
|
||||
HcsPerfRecordPropsDTO props = new HcsPerfRecordPropsDTO();
|
||||
// 【必填】申请编号:收款通知单号,兜底明细流水号
|
||||
props.setFdApplyNo(firstNonBlank(flow.getReceiptNoticeNo(), flow.getDetailSerialNo()));
|
||||
// 申请金额 / 实际金额:该合同累计认领金额
|
||||
props.setFdApplyAmount(claimedTotal.toPlainString());
|
||||
props.setFdFactAmount(claimedTotal.toPlainString());
|
||||
// 【必填】履约日期:最近认领日期(毫秒时间戳)
|
||||
// 申请金额 / 实际金额:本次认领分摊金额
|
||||
props.setFdApplyAmount(claimAmount.toPlainString());
|
||||
props.setFdFactAmount(claimAmount.toPlainString());
|
||||
// 【必填】履约日期:认领日期(毫秒时间戳)
|
||||
props.setFdPlanDate(toMillis(planDate));
|
||||
// 履约类型:收款
|
||||
props.setFdPalnType(HcsSyncConstant.PERFORM_TYPE_COLLECTION);
|
||||
@@ -300,7 +401,7 @@ public class HcsClaimSyncSupport {
|
||||
// 履约计划id / 来源履约计划项ID:收款计划行合规阶段ID(HCS 只认自己的阶段ID)
|
||||
props.setFdId(stageRow.getHcsStageFdId());
|
||||
props.setFdPlanId(stageRow.getHcsStageFdId());
|
||||
// 金蝶单据号:A301-3 推送返回的金蝶收款单号
|
||||
// 金蝶单据号:金蝶收款单号(A301-1 同步落库)
|
||||
props.setFdJdDocNo(flow.getKingdeeBillNo());
|
||||
// 【必填】合同ID:HCS 合同ID
|
||||
props.setFdContractId(contractManage.getHcsFdId());
|
||||
@@ -318,7 +419,7 @@ public class HcsClaimSyncSupport {
|
||||
param.setCurUser(HcsUserRefVO.ofLoginName(hcsProperties.getOperatorLoginName()));
|
||||
param.setFormData(formData);
|
||||
param.setMechanisms(mechanisms);
|
||||
log.info("HCS履约流水新建开始 flowId:{},contractNo:{},认领金额 {}", flow.getId(), contractManage.getContractNo(), claimedTotal);
|
||||
log.info("HCS履约流水新建开始 flowId:{},contractNo:{},认领金额 {}", flow.getId(), contractManage.getContractNo(), claimAmount);
|
||||
HcsResult<HcsFormSaveVO> result = hcsClient.perfRecordAdd(param);
|
||||
if (result == null || !result.isSuccess() || result.getData() == null || StringUtil.isBlank(result.getData().getFdId())) {
|
||||
log.error("HCS履约流水新建失败响应:{},contractNo={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), contractManage.getContractNo());
|
||||
@@ -353,17 +454,6 @@ public class HcsClaimSyncSupport {
|
||||
.findFirst().orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 最近认领日期(C108-1 履约日期)
|
||||
*/
|
||||
private LocalDate latestClaimDate(List<ReceiptClaim> claims) {
|
||||
return claims.stream()
|
||||
.map(ReceiptClaim::getClaimDate)
|
||||
.filter(Objects::nonNull)
|
||||
.max(LocalDate::compareTo)
|
||||
.orElse(LocalDate.now());
|
||||
}
|
||||
|
||||
private String firstNonBlank(String... values) {
|
||||
for (String value : values) {
|
||||
if (Func.isNotEmpty(value)) {
|
||||
|
||||
+116
-39
@@ -32,6 +32,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.tool.jackson.JsonUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.core.tool.utils.StringUtil;
|
||||
import org.springblade.hcs.config.HcsProperties;
|
||||
import org.springblade.hcs.constant.HcsConstant;
|
||||
@@ -61,6 +62,7 @@ import org.springblade.transport.pojo.entity.ContractManage;
|
||||
import org.springblade.transport.pojo.entity.CustomerArchive;
|
||||
import org.springblade.transport.pojo.entity.HcsContractCheckLog;
|
||||
import org.springblade.transport.pojo.entity.ProjectApply;
|
||||
import org.springblade.transport.support.ContractPartySupport;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -112,6 +114,7 @@ public class HcsContractSyncSupport {
|
||||
private final HcsAttachmentSupport hcsAttachmentSupport;
|
||||
private final HcsExeStageSyncSupport hcsExeStageSyncSupport;
|
||||
private final HcsContractSpecSyncSupport hcsContractSpecSyncSupport;
|
||||
private final HcsCreditCheckSupport hcsCreditCheckSupport;
|
||||
|
||||
/**
|
||||
* 合同金蝶同步结果接收处理(合规 contractKingdee/result 推送,D903,投送人/钢厂/结算中心合同共用):
|
||||
@@ -250,7 +253,7 @@ public class HcsContractSyncSupport {
|
||||
markSynced(contractManage, fdContractId, resultMessage);
|
||||
}
|
||||
} else {
|
||||
updateContract(contractManage, risky);
|
||||
updateContract(contractManage, null, risky);
|
||||
resultMessage = "合同更新成功:" + contractManage.getContractNo();
|
||||
markSynced(contractManage, contractManage.getHcsFdId(), resultMessage);
|
||||
}
|
||||
@@ -279,16 +282,13 @@ public class HcsContractSyncSupport {
|
||||
RiskResult riskPartyB = queryRisk(contractManage, "乙方", contractManage.getPartyB());
|
||||
// 风险等级:甲乙双方取先查到的非空值(无法量化比较,留痕双方原始值,联调后细化)
|
||||
String riskLevel = StringUtil.isNotBlank(riskPartyA.riskLevel()) ? riskPartyA.riskLevel() : riskPartyB.riskLevel();
|
||||
// 黑名单状态:任一方黑名单即为是
|
||||
boolean blacklist = riskPartyA.blacklist() || riskPartyB.blacklist();
|
||||
contractManage.setHcsRiskLevel(riskLevel);
|
||||
contractManage.setHcsBlackStatus(blacklist ? BLACK_STATUS_YES : "no");
|
||||
contractManage.setHcsRiskTime(LocalDateTime.now());
|
||||
contractManageMapper.updateById(contractManage);
|
||||
// 黑名单状态:任一方金蝶黑名单(A801-5)命中即为是(复用 HcsCreditCheckSupport.isKingdeeBlacklistHit 公共口径)
|
||||
boolean blacklist = riskPartyA.kingdeeHit() || riskPartyB.kingdeeHit();
|
||||
// 判定结果仅在内存参与推送/特批流程(risky),风险展示数据由列表/详情实时关联客商档案组装,合同表不再落库风险留痕
|
||||
boolean risky = HcsSyncConstant.RISK_LEVEL_HIGH.equals(riskLevel) || blacklist;
|
||||
log.info("HCS合同风险校验完成:contractNo={},甲方[{}={}/{}],乙方[{}={}/{}],高风险/黑名单={}",
|
||||
contractManage.getContractNo(), contractManage.getPartyA(), riskPartyA.blackStatus(), riskPartyA.riskLevel(),
|
||||
contractManage.getPartyB(), riskPartyB.blackStatus(), riskPartyB.riskLevel(), risky);
|
||||
log.info("HCS合同风险校验完成:contractNo={},甲方[金蝶黑名单={},等级={}],乙方[金蝶黑名单={},等级={}],高风险/黑名单={}",
|
||||
contractManage.getContractNo(), riskPartyA.kingdeeHit(), riskPartyA.riskLevel(),
|
||||
riskPartyB.kingdeeHit(), riskPartyB.riskLevel(), risky);
|
||||
return risky;
|
||||
}
|
||||
|
||||
@@ -311,9 +311,8 @@ public class HcsContractSyncSupport {
|
||||
// TMS 流程挂起为"特批审核",特批通过恢复展示、驳回联动合同审批驳回
|
||||
contractManage.setCurrentNode(HcsSyncConstant.SPEC_REVIEW_NODE);
|
||||
contractManageMapper.updateById(contractManage);
|
||||
log.info("HCS合同高风险/黑名单挂起特批审核:contractNo={},riskLevel={},blackStatus={},挂起节点={}",
|
||||
contractManage.getContractNo(), contractManage.getHcsRiskLevel(), contractManage.getHcsBlackStatus(),
|
||||
contractManage.getCurrentNode());
|
||||
log.info("HCS合同高风险/黑名单挂起特批审核:contractNo={},挂起节点={}",
|
||||
contractManage.getContractNo(), contractManage.getCurrentNode());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -346,13 +345,13 @@ public class HcsContractSyncSupport {
|
||||
if (customerArchive == null) {
|
||||
writeCheckLog(contractManage, HcsSyncConstant.CHECK_RESULT_SKIP,
|
||||
standing + "方[" + partyName + "]反查不到客商编号,跳过风险校验(§9-U6)", null);
|
||||
return new RiskResult(null, null, false);
|
||||
return new RiskResult(false, null);
|
||||
}
|
||||
// 内部组织类型客商默认无风险,不调 C101-1(与展示/提交/付款链路口径一致)
|
||||
if (HcsCreditCheckSupport.isInternalCustomer(customerArchive)) {
|
||||
writeCheckLog(contractManage, HcsSyncConstant.CHECK_RESULT_SKIP,
|
||||
standing + "方[" + partyName + "]为内部组织客商,默认无风险跳过校验", null);
|
||||
return new RiskResult(null, null, false);
|
||||
return new RiskResult(false, null);
|
||||
}
|
||||
HcsRiskQueryDTO param = new HcsRiskQueryDTO();
|
||||
// 【必填】客商编号:按甲乙方名称反查 CustomerArchive 客商编码(§9-U6)
|
||||
@@ -364,12 +363,12 @@ public class HcsContractSyncSupport {
|
||||
throw new HcsException("C101-1 客商风险查询失败:" + (result == null ? "响应为空" : result.getMsg()));
|
||||
}
|
||||
HcsRiskVO risk = result.getData();
|
||||
String blackStatus = risk == null ? null : risk.getFdBlackStatus();
|
||||
String riskLevel = risk == null ? null : risk.getFdRiskLevel();
|
||||
boolean blacklist = BLACK_STATUS_YES.equalsIgnoreCase(blackStatus);
|
||||
writeCheckLog(contractManage, blacklist ? HcsSyncConstant.CHECK_RESULT_FAIL : HcsSyncConstant.CHECK_RESULT_PASS,
|
||||
standing + "方[" + partyName + "]黑名单=" + blackStatus + ",风险等级=" + riskLevel, result.getTraceId());
|
||||
return new RiskResult(blackStatus, riskLevel, blacklist);
|
||||
// 黑名单判定复用公共方法:仅以金蝶黑名单(A801-5)命中为准,C101-1 黑名单状态不参与
|
||||
boolean kingdeeHit = hcsCreditCheckSupport.isKingdeeBlacklistHit(customerArchive.getUnifiedCreditCode());
|
||||
writeCheckLog(contractManage, kingdeeHit ? HcsSyncConstant.CHECK_RESULT_FAIL : HcsSyncConstant.CHECK_RESULT_PASS,
|
||||
standing + "方[" + partyName + "]金蝶黑名单=" + kingdeeHit + ",风险等级=" + riskLevel, result.getTraceId());
|
||||
return new RiskResult(kingdeeHit, riskLevel);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -405,11 +404,12 @@ public class HcsContractSyncSupport {
|
||||
}
|
||||
|
||||
/**
|
||||
* C103-11 合同更新(入参同发起,v4 新增顶层 fdId 定位已有合同);risky=true 时报文带 fdSepcailContract=true
|
||||
* C103-11 合同更新(入参同发起,v4 新增顶层 fdId 定位已有合同);risky=true 时报文带 fdSepcailContract=true;
|
||||
* attachmentRefs 归档推送传新增双章归档文件引用(挂其他附件面板),常规变更更新传 null 不挂载
|
||||
*/
|
||||
private void updateContract(ContractManage contractManage, boolean risky) {
|
||||
HcsContractDraftDTO param = buildDraftParam(contractManage, null, risky);
|
||||
log.info("HCS合同推送开始:{}(C103-11 更新,附件不重复挂载待联调确认,特批流程={})", contractManage.getContractNo(), risky);
|
||||
private void updateContract(ContractManage contractManage, List<HcsAttachmentRefDTO> attachmentRefs, boolean risky) {
|
||||
HcsContractDraftDTO param = buildDraftParam(contractManage, attachmentRefs, risky);
|
||||
log.info("HCS合同推送开始:{}(C103-11 更新,挂载附件 {} 个,特批流程={})", contractManage.getContractNo(), attachmentRefs == null ? 0 : attachmentRefs.size(), risky);
|
||||
HcsResult<Void> result = hcsClient.contractUpdate(param);
|
||||
if (result == null || !result.isSuccess()) {
|
||||
log.error("HCS合同更新失败响应:{},contractNo={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), contractManage.getContractNo());
|
||||
@@ -417,6 +417,47 @@ public class HcsContractSyncSupport {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 双章归档文件上传后即时归档推送(C103-11 更新):报文随 fdArchiveStatus=toarchive(buildXformFields 按
|
||||
* TMS 归档状态=已归档 传值),并把本次新增的双章归档文件转传挂载其他附件面板(eopCdraftOther,2026-10-30)。
|
||||
* 合同尚未发起合规(无 hcs_fd_id)时跳过——首次推送 C103-1 报文自带归档状态与归档附件;
|
||||
* 特批标志不重触发(risky 固定 false,特批随首次发起一次性完成),特批审核中跳过避免干扰在途流程;
|
||||
* 失败由调用方留痕,可重新上传双章归档文件或变更重推补偿
|
||||
*
|
||||
* @param contractId 合同单ID
|
||||
* @param newSealFilesJson 本次新增的双章归档文件元数据 JSON 数组(updateAttachments 按附件键排除原有行提取)
|
||||
* @return 推送结果描述
|
||||
*/
|
||||
public String pushArchiveUpdate(Long contractId, String newSealFilesJson) {
|
||||
ContractManage contractManage = contractManageMapper.selectById(contractId);
|
||||
if (contractManage == null) {
|
||||
log.warn("HCS合同归档推送跳过(合同不存在):id={}", contractId);
|
||||
return "合同不存在,跳过归档推送";
|
||||
}
|
||||
if (StringUtil.isBlank(contractManage.getHcsFdId())) {
|
||||
log.info("HCS合同归档推送跳过(尚未发起合规,归档状态随首次推送报文携带):contractNo={}", contractManage.getContractNo());
|
||||
return "合同尚未发起合规,归档状态随首次推送携带";
|
||||
}
|
||||
if (!List.of("approved", "change_approved").contains(contractManage.getApprovalStatus())
|
||||
|| HcsSyncConstant.SPEC_STATUS_PROCESSING.equals(contractManage.getHcsSpecStatus())
|
||||
|| !HcsSyncConstant.TMS_ARCHIVE_STATUS_ARCHIVED.equals(contractManage.getArchiveStatus())) {
|
||||
log.info("HCS合同归档推送跳过(状态不满足):contractNo={},approvalStatus={},hcsSpecStatus={},archiveStatus={}",
|
||||
contractManage.getContractNo(), contractManage.getApprovalStatus(), contractManage.getHcsSpecStatus(), contractManage.getArchiveStatus());
|
||||
return "合同状态不满足归档推送条件,跳过";
|
||||
}
|
||||
// 本次新增双章归档文件转传(映射表命中复用合规附件ID防重复上传)并统一挂载其他附件面板
|
||||
List<HcsAttachmentRefDTO> attachmentRefs = hcsProperties.getAttachment().isTransferEnabled()
|
||||
? hcsAttachmentSupport.transferAttachments(HcsSyncConstant.BIZ_TYPE_CONTRACT, contractManage.getId(),
|
||||
newSealFilesJson, hcsProperties.getOperatorLoginName()) : new ArrayList<>();
|
||||
for (HcsAttachmentRefDTO attachmentRef : attachmentRefs) {
|
||||
attachmentRef.setFdEntityKey(HcsSyncConstant.CONTRACT_ATTACH_KEY_OTHER);
|
||||
}
|
||||
updateContract(contractManage, attachmentRefs, false);
|
||||
String resultMessage = "合同归档推送成功(fdArchiveStatus=toarchive,挂载归档附件 " + attachmentRefs.size() + " 个):" + contractManage.getContractNo();
|
||||
markSynced(contractManage, contractManage.getHcsFdId(), resultMessage);
|
||||
return resultMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装 C103-1/C103-11 入参(字段标注照 §3.4 规范,映射表见设计文档 §5.2);
|
||||
* attachmentRefs 新建传合规附件引用列表(顶层 attachment,fdId+fdEntityKey,必填,无附件传空列表),更新传 null 不挂载
|
||||
@@ -460,6 +501,12 @@ public class HcsContractSyncSupport {
|
||||
// 【必填】合同编号:ContractManage.contractNo
|
||||
fields.add(field(HcsContractFieldConstant.FIELD_FD_NUMBER, "合同编号", contractManage.getContractNo()));
|
||||
fields.add(field(HcsContractFieldConstant.FIELD_CONTRACT_NO, "合同编号", contractManage.getContractNo()));
|
||||
// 【必填】业务合同号:合规侧 2026-10-30 新增,值同合同编号 contractNo
|
||||
fields.add(field(HcsContractFieldConstant.FIELD_FD_BUS_CONTRACT_NUMBER, "业务合同号", contractManage.getContractNo()));
|
||||
// 归档状态:TMS 已归档(双章归档文件已上传)→ toarchive 待归档(archiving/archived/archiveerror 由合规侧自行流转,未归档不传)
|
||||
if (HcsSyncConstant.TMS_ARCHIVE_STATUS_ARCHIVED.equals(contractManage.getArchiveStatus())) {
|
||||
fields.add(field(HcsContractFieldConstant.FIELD_FD_ARCHIVE_STATUS, "归档状态", HcsContractFieldConstant.ARCHIVE_STATUS_TO_ARCHIVE));
|
||||
}
|
||||
// 【必填】合同标题:ContractManage.contractName
|
||||
fields.add(field(HcsContractFieldConstant.FIELD_FD_SUBJECT, "合同标题", contractManage.getContractName()));
|
||||
fields.add(field(HcsContractFieldConstant.FIELD_CONTRACT_NAME, "合同名称", contractManage.getContractName()));
|
||||
@@ -532,44 +579,74 @@ public class HcsContractSyncSupport {
|
||||
}
|
||||
|
||||
/**
|
||||
* signer 相对方(签署方):甲/乙双方。fdType 档案优先:客商档案为内部组织(customerKind=internal/内部组织)
|
||||
* signer 相对方(签署方):甲/乙/丙多方客商。fdType 档案优先:客商档案为内部组织(customerKind=internal/内部组织)
|
||||
* 的一方为我方签约主体传 inner,另一方传 customer(外部相对方,2026-10-14 联调口径);
|
||||
* 双方均非内部组织或查不到档案时按合同类别推定(承运商合同甲=inner,客户合同乙=inner,其余 customer);
|
||||
* 丙方(三方合同)TMS 无签约主体字段来源,暂不支持
|
||||
* 多方时按明细展开,同地位多客商分别签署
|
||||
*/
|
||||
private List<HcsSignerDTO> buildSigners(ContractManage contractManage) {
|
||||
List<HcsSignerDTO> signers = new ArrayList<>();
|
||||
// 甲乙方各反查一次客商档案,编号与内部组织判定共用查询结果
|
||||
CustomerArchive partyAArchive = queryCustomerArchive(contractManage.getPartyA());
|
||||
CustomerArchive partyBArchive = queryCustomerArchive(contractManage.getPartyB());
|
||||
List<String> partyANames = partyNames(contractManage.getPartyAJson(), contractManage.getPartyA());
|
||||
List<String> partyBNames = partyNames(contractManage.getPartyBJson(), contractManage.getPartyB());
|
||||
List<String> partyCNames = partyNames(contractManage.getPartyCJson(), contractManage.getPartyC());
|
||||
String firstPartyA = partyANames.isEmpty() ? null : partyANames.get(0);
|
||||
String firstPartyB = partyBNames.isEmpty() ? null : partyBNames.get(0);
|
||||
CustomerArchive partyAArchive = queryCustomerArchive(firstPartyA);
|
||||
CustomerArchive partyBArchive = queryCustomerArchive(firstPartyB);
|
||||
boolean partyAInternal = HcsCreditCheckSupport.isInternalCustomer(partyAArchive);
|
||||
boolean partyBInternal = HcsCreditCheckSupport.isInternalCustomer(partyBArchive);
|
||||
String partyAType;
|
||||
String partyBType;
|
||||
String partyCType;
|
||||
if (partyAInternal || partyBInternal) {
|
||||
// 档案命中:内部组织一方=我方签约主体(inner),外部一方统一 customer
|
||||
partyAType = partyAInternal ? HcsConstant.SIGNER_FD_TYPE_INNER : HcsConstant.SIGNER_FD_TYPE_CUSTOMER;
|
||||
partyBType = partyBInternal ? HcsConstant.SIGNER_FD_TYPE_INNER : HcsConstant.SIGNER_FD_TYPE_CUSTOMER;
|
||||
partyCType = HcsConstant.SIGNER_FD_TYPE_CUSTOMER;
|
||||
} else if ("承运商合同".equals(contractManage.getContractCategory())) {
|
||||
// 兜底:档案未命中内部组织,按类别推定——我方为付款/发包方,签约主体在甲方
|
||||
partyAType = HcsConstant.SIGNER_FD_TYPE_INNER;
|
||||
partyBType = HcsConstant.SIGNER_FD_TYPE_CUSTOMER;
|
||||
partyCType = HcsConstant.SIGNER_FD_TYPE_CUSTOMER;
|
||||
} else if ("客户合同".equals(contractManage.getContractCategory())) {
|
||||
// 兜底:我方为收款/承运方,签约主体在乙方
|
||||
partyAType = HcsConstant.SIGNER_FD_TYPE_CUSTOMER;
|
||||
partyBType = HcsConstant.SIGNER_FD_TYPE_INNER;
|
||||
partyCType = HcsConstant.SIGNER_FD_TYPE_CUSTOMER;
|
||||
} else {
|
||||
// 兜底:其他类别双方统一 customer(2026-10-14 联调口径)
|
||||
partyAType = HcsConstant.SIGNER_FD_TYPE_CUSTOMER;
|
||||
partyBType = HcsConstant.SIGNER_FD_TYPE_CUSTOMER;
|
||||
partyCType = HcsConstant.SIGNER_FD_TYPE_CUSTOMER;
|
||||
}
|
||||
for (String partyName : partyANames) {
|
||||
CustomerArchive archive = Objects.equals(partyName, firstPartyA) ? partyAArchive : queryCustomerArchive(partyName);
|
||||
signers.add(signer("甲", partyAType, partyName, archive));
|
||||
}
|
||||
for (String partyName : partyBNames) {
|
||||
CustomerArchive archive = Objects.equals(partyName, firstPartyB) ? partyBArchive : queryCustomerArchive(partyName);
|
||||
signers.add(signer("乙", partyBType, partyName, archive));
|
||||
}
|
||||
for (String partyName : partyCNames) {
|
||||
signers.add(signer("丙", partyCType, partyName, queryCustomerArchive(partyName)));
|
||||
}
|
||||
// 甲方相对方:fdStanding=甲(编号按名称反查,反查不到留空,§9-U6)
|
||||
signers.add(signer("甲", partyAType, contractManage.getPartyA(), partyAArchive));
|
||||
// 乙方相对方:fdStanding=乙
|
||||
signers.add(signer("乙", partyBType, contractManage.getPartyB(), partyBArchive));
|
||||
return signers;
|
||||
}
|
||||
|
||||
private List<String> partyNames(String partyJson, String fallback) {
|
||||
List<String> names = new ArrayList<>();
|
||||
for (Map<String, Object> row : ContractPartySupport.parsePartyRows(partyJson)) {
|
||||
Object customerName = row.get("customerName");
|
||||
if (customerName != null && Func.isNotEmpty(String.valueOf(customerName).trim())) {
|
||||
names.add(String.valueOf(customerName).trim());
|
||||
}
|
||||
}
|
||||
if (names.isEmpty() && Func.isNotEmpty(fallback)) {
|
||||
for (String part : fallback.split("[、,]")) {
|
||||
if (Func.isNotEmpty(part) && Func.isNotEmpty(part.trim())) {
|
||||
names.add(part.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个相对方:fdType 由签约主体落位规则决定(我方主体 inner,外部相对方 customer)
|
||||
*/
|
||||
@@ -780,8 +857,8 @@ public class HcsContractSyncSupport {
|
||||
}
|
||||
|
||||
/**
|
||||
* 单方风险查询结果
|
||||
* 单方风险查询结果(kingdeeHit=金蝶黑名单 A801-5 是否命中,公共口径见 {@link HcsCreditCheckSupport#isKingdeeBlacklistHit})
|
||||
*/
|
||||
private record RiskResult(String blackStatus, String riskLevel, boolean blacklist) {
|
||||
private record RiskResult(boolean kingdeeHit, String riskLevel) {
|
||||
}
|
||||
}
|
||||
|
||||
+360
-32
@@ -26,6 +26,7 @@
|
||||
package org.springblade.transport.support.hcs;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.toolkit.Db;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
@@ -40,6 +41,10 @@ import org.springblade.hcs.dto.HcsRiskQueryDTO;
|
||||
import org.springblade.hcs.dto.HcsRiskVO;
|
||||
import org.springblade.hcs.exception.HcsException;
|
||||
import org.springblade.hcs.feign.IHcsClient;
|
||||
import org.springblade.system.cache.DictBizCache;
|
||||
import org.springblade.system.cache.DictCache;
|
||||
import org.springblade.thirdparty.bfi.pojo.vo.BfiBlacklistVO;
|
||||
import org.springblade.thirdparty.bfi.service.IBfiService;
|
||||
import org.springblade.transport.constant.HcsSyncConstant;
|
||||
import org.springblade.transport.mapper.ContractManageMapper;
|
||||
import org.springblade.transport.mapper.CustomerArchiveMapper;
|
||||
@@ -52,11 +57,15 @@ import org.springblade.transport.pojo.vo.CustomerRiskVO;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* HCS 付款提交校验支撑组件(C101-1 客商风险 + C106-1 额度校验,设计文档 §5.4,D15/R2-Q3)
|
||||
@@ -87,6 +96,7 @@ public class HcsCreditCheckSupport {
|
||||
private final ContractManageMapper contractManageMapper;
|
||||
private final CustomerArchiveMapper customerArchiveMapper;
|
||||
private final HcsContractCheckLogMapper hcsContractCheckLogMapper;
|
||||
private final IBfiService bfiService;
|
||||
|
||||
/**
|
||||
* 付款提交同步校验入口(submit 事务内调用,校验结果字段随提交一并落库)
|
||||
@@ -195,12 +205,28 @@ public class HcsCreditCheckSupport {
|
||||
/**
|
||||
* 客商风险查询(C101-1,展示与提交校验共用,前端接入设计 §3.3):
|
||||
* 内部组织类型客商默认无风险(不调 C101-1);外部客商按编号查询风险等级与黑名单状态并留痕;
|
||||
* 高风险/黑名单时组装风险提示文案(风险项明细 C101-1 未返回,固定文案展示)
|
||||
* 金蝶黑名单(A801-5)命中同样判定为风险客商(risky=true,C101-1 空数据不漏提示);
|
||||
* riskTip 经 {@link #buildRiskDetail} 统一组装(等级 + 金蝶黑名单失信类别/列入事由),与列表悬浮明细同口径
|
||||
*
|
||||
* @param customerCode 客商编号
|
||||
* @return 风险展示信息(customerKind/fdBlackStatus/fdRiskLevel/risky/riskTip)
|
||||
*/
|
||||
public CustomerRiskVO queryCustomerRisk(String customerCode) {
|
||||
return this.queryCustomerRisk(customerCode, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 客商风险查询(带信用代码/名称,新增客商档案表单实时校验用):
|
||||
* 金蝶带出的客商尚未落库(本地档案反查不到)时,信用代码无法从档案取得,
|
||||
* 由前端将金蝶档案带出的统一社会信用代码随编号一并传入,保证 A801-5 黑名单段不缺失;
|
||||
* 档案缺失时客商名称同样兜底用传入名称(否则提示文案里只能回显编号)
|
||||
*
|
||||
* @param customerCode 客商编号(金蝶档案带出的编码或手输编号)
|
||||
* @param creditCode 统一社会信用代码(档案存在时以档案为准,档案缺失时兜底入参)
|
||||
* @param customerNameParam 客商名称(档案缺失时兜底展示用)
|
||||
* @return 风险展示信息
|
||||
*/
|
||||
public CustomerRiskVO queryCustomerRisk(String customerCode, String creditCode, String customerNameParam) {
|
||||
if (StringUtil.isBlank(customerCode)) {
|
||||
throw new ServiceException("客商编号不能为空");
|
||||
}
|
||||
@@ -208,16 +234,54 @@ public class HcsCreditCheckSupport {
|
||||
.eq(CustomerArchive::getCustomerCode, customerCode)
|
||||
.eq(CustomerArchive::getIsDeleted, 0)
|
||||
.last("limit 1"));
|
||||
CustomerRiskVO riskVO = new CustomerRiskVO();
|
||||
riskVO.setCustomerCode(customerCode);
|
||||
riskVO.setCustomerName(customerArchive == null ? customerCode : customerArchive.getFullName());
|
||||
// 档案存在以档案名称为准;档案缺失(新增客商实时校验)兜底用前端传入名称,最后才回显编号
|
||||
String customerName = customerArchive == null
|
||||
? (StringUtil.isNotBlank(customerNameParam) ? customerNameParam : customerCode)
|
||||
: customerArchive.getFullName();
|
||||
// 内部组织类型客商默认无风险;档案不存在按外部客商查询(不静默放行)
|
||||
boolean internal = isInternalCustomer(customerArchive);
|
||||
riskVO.setCustomerKind(internal ? HcsSyncConstant.CUSTOMER_KIND_INTERNAL : "external");
|
||||
if (internal) {
|
||||
if (isInternalCustomer(customerArchive)) {
|
||||
CustomerRiskVO riskVO = new CustomerRiskVO();
|
||||
riskVO.setCustomerCode(customerCode);
|
||||
riskVO.setCustomerName(customerName);
|
||||
riskVO.setCustomerKind(HcsSyncConstant.CUSTOMER_KIND_INTERNAL);
|
||||
riskVO.setRisky(Boolean.FALSE);
|
||||
return riskVO;
|
||||
}
|
||||
RelativeRiskResult fetch = this.fetchRelativeRisk(customerCode, customerName);
|
||||
CustomerRiskVO riskVO = fetch.risk();
|
||||
// 表单实时提示与列表明细统一组装口径:追加金蝶黑名单段(A801-5 按信用代码查询,失败静默缺失;
|
||||
// 档案缺失(新增客商实时校验)时兜底用入参信用代码)
|
||||
String archiveCreditCode = customerArchive == null ? null : customerArchive.getUnifiedCreditCode();
|
||||
String effectiveCreditCode = StringUtil.isNotBlank(archiveCreditCode) ? archiveCreditCode : creditCode;
|
||||
BfiBlacklistVO hit = StringUtil.isBlank(effectiveCreditCode) ? null
|
||||
: this.queryBlacklistByCodes(List.of(effectiveCreditCode.trim().toUpperCase())).get(effectiveCreditCode.trim().toUpperCase());
|
||||
String blackCategory = hit == null ? null
|
||||
: (Func.isEmpty(hit.getAwrpCombofieldTitle()) ? hit.getAwrpCombofield() : hit.getAwrpCombofieldTitle());
|
||||
// 黑名单判定仅以金蝶黑名单(A801-5)命中为准:C101-1 可能对已列入黑名单的客商返回空数据,命中时必须置 risky=true 才能触发前端提示;
|
||||
// 命中结果落 kingdeeBlackHit 原始标记,红字文案细分(黑名单/高风险)由前端翻译
|
||||
if (hit != null) {
|
||||
riskVO.setRisky(Boolean.TRUE);
|
||||
riskVO.setKingdeeBlackHit(Boolean.TRUE);
|
||||
}
|
||||
riskVO.setRiskTip(buildRiskDetail(customerName, riskVO.getFdRiskLevel(),
|
||||
blackCategory, hit == null ? null : hit.getAwrpTextareafield()));
|
||||
try {
|
||||
hcsContractCheckLogMapper.insert(this.buildRiskCheckLog(customerCode, riskVO, fetch.traceId()));
|
||||
} catch (Exception exception) {
|
||||
log.error("HCS客商风险查询留痕写入失败:fdNumber={}", customerCode, exception);
|
||||
}
|
||||
return riskVO;
|
||||
}
|
||||
|
||||
/**
|
||||
* C101-1 单客商远程查询并组装展示信息(不查档案、不写留痕,供单查与定时批量刷新复用);
|
||||
* riskTip 不在本方法组装,由调用方经 {@link #buildRiskDetail} 统一组装
|
||||
*
|
||||
* @param customerCode 客商编号
|
||||
* @param customerName 客商名称(展示信息用)
|
||||
* @return 风险展示信息 + 合规 traceId
|
||||
*/
|
||||
private RelativeRiskResult fetchRelativeRisk(String customerCode, String customerName) {
|
||||
HcsRiskQueryDTO param = new HcsRiskQueryDTO();
|
||||
// 【必填】客商编号(用哪个编码待联调,§9-U6)
|
||||
param.setFdNumber(customerCode);
|
||||
@@ -231,28 +295,36 @@ public class HcsCreditCheckSupport {
|
||||
String blackStatus = risk == null ? null : risk.getFdBlackStatus();
|
||||
String riskLevel = risk == null ? null : risk.getFdRiskLevel();
|
||||
boolean risky = HcsSyncConstant.RISK_LEVEL_HIGH.equals(riskLevel) || BLACK_STATUS_YES.equalsIgnoreCase(blackStatus);
|
||||
CustomerRiskVO riskVO = new CustomerRiskVO();
|
||||
riskVO.setCustomerCode(customerCode);
|
||||
riskVO.setCustomerName(customerName);
|
||||
riskVO.setCustomerKind("external");
|
||||
riskVO.setFdBlackStatus(blackStatus);
|
||||
riskVO.setFdRiskLevel(riskLevel);
|
||||
riskVO.setRiskLevelName(riskLevelName(blackStatus, riskLevel));
|
||||
riskVO.setRisky(risky);
|
||||
if (risky) {
|
||||
// 风险提示固定短文案(C101-1 未返回风险项明细,不拼接枚举清单):黑名单与高风险区分表述
|
||||
riskVO.setRiskTip("【" + riskVO.getCustomerName() + "】:"
|
||||
+ (BLACK_STATUS_YES.equalsIgnoreCase(blackStatus) ? "被列入外部黑名单" : "为高风险客商"));
|
||||
}
|
||||
try {
|
||||
HcsContractCheckLog checkLog = new HcsContractCheckLog();
|
||||
checkLog.setBizType(HcsSyncConstant.BIZ_TYPE_CONTRACT);
|
||||
checkLog.setCheckType(HcsSyncConstant.CHECK_TYPE_RISK);
|
||||
checkLog.setResult(risky ? HcsSyncConstant.CHECK_RESULT_FAIL : HcsSyncConstant.CHECK_RESULT_PASS);
|
||||
checkLog.setMsg("客商风险查询[" + customerCode + "]黑名单=" + blackStatus + ",风险等级=" + riskLevel + ",高风险/黑名单=" + risky);
|
||||
checkLog.setTraceId(result.getTraceId());
|
||||
checkLog.setCreateTime(LocalDateTime.now());
|
||||
hcsContractCheckLogMapper.insert(checkLog);
|
||||
} catch (Exception exception) {
|
||||
log.error("HCS客商风险查询留痕写入失败:fdNumber={}", customerCode, exception);
|
||||
}
|
||||
return riskVO;
|
||||
return new RelativeRiskResult(riskVO, result.getTraceId());
|
||||
}
|
||||
|
||||
/**
|
||||
* C101-1 单次远程查询结果(风险展示信息 + 合规 traceId,留痕与批量刷新共用)
|
||||
*/
|
||||
private record RelativeRiskResult(CustomerRiskVO risk, String traceId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装客商风险查询留痕(单查与定时批量刷新共用,msg 口径一致)
|
||||
*/
|
||||
private HcsContractCheckLog buildRiskCheckLog(String customerCode, CustomerRiskVO risk, String traceId) {
|
||||
HcsContractCheckLog checkLog = new HcsContractCheckLog();
|
||||
checkLog.setBizType(HcsSyncConstant.BIZ_TYPE_CONTRACT);
|
||||
checkLog.setCheckType(HcsSyncConstant.CHECK_TYPE_RISK);
|
||||
checkLog.setResult(Boolean.TRUE.equals(risk.getRisky()) ? HcsSyncConstant.CHECK_RESULT_FAIL : HcsSyncConstant.CHECK_RESULT_PASS);
|
||||
checkLog.setMsg("客商风险查询[" + customerCode + "]黑名单=" + risk.getFdBlackStatus()
|
||||
+ ",风险等级=" + risk.getFdRiskLevel() + ",高风险/黑名单=" + risk.getRisky());
|
||||
checkLog.setTraceId(traceId);
|
||||
checkLog.setCreateTime(LocalDateTime.now());
|
||||
return checkLog;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -287,18 +359,83 @@ public class HcsCreditCheckSupport {
|
||||
}
|
||||
|
||||
/**
|
||||
* 风险等级转义(C101-1 枚举 0=低风险/1=中风险/2=高风险):黑名单状态优先展示,无等级返回空
|
||||
* 风险等级转义:黑名单状态优先展示;等级走业务字典 risk_level 翻译({@link #resolveRiskLevelName}),无等级返回空
|
||||
*/
|
||||
private String riskLevelName(String blackStatus, String riskLevel) {
|
||||
if (BLACK_STATUS_YES.equalsIgnoreCase(blackStatus)) {
|
||||
return "黑名单";
|
||||
}
|
||||
return switch (Objects.toString(riskLevel, "")) {
|
||||
case "2" -> "高风险";
|
||||
case "1" -> "中风险";
|
||||
case "0" -> "低风险";
|
||||
default -> null;
|
||||
};
|
||||
return resolveRiskLevelName(riskLevel);
|
||||
}
|
||||
|
||||
/**
|
||||
* 客商风险等级名称翻译(业务字典 risk_level,dictKey=C101-1 风险等级 0=低风险/1=中风险/2=高风险);
|
||||
* 业务字典未命中兜底系统字典,仍未命中返回 null(明细不拼等级段);列表组装与展示链路统一复用本方法
|
||||
*
|
||||
* @param riskLevel C101-1 风险等级
|
||||
* @return 等级名称,未命中返回 null
|
||||
*/
|
||||
public static String resolveRiskLevelName(String riskLevel) {
|
||||
if (Func.isEmpty(riskLevel)) {
|
||||
return null;
|
||||
}
|
||||
String levelName = DictBizCache.getValue(HcsSyncConstant.RISK_LEVEL_DICT_CODE, riskLevel);
|
||||
if (StringUtil.isBlank(levelName)) {
|
||||
levelName = DictCache.getValue(HcsSyncConstant.RISK_LEVEL_DICT_CODE, riskLevel);
|
||||
}
|
||||
return StringUtil.isBlank(levelName) ? null : levelName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 金蝶黑名单命中判定(A801-5,公共方法):按统一社会信用代码/税号查询,
|
||||
* 黑名单记录存在即命中;查询失败静默返回 false(queryBlacklistByCodes 内记 error 日志备查)。
|
||||
* 展示组装(buildRiskDetail)、列表筛选、合同推送链路(checkRelativeRisk)统一复用本方法口径
|
||||
*
|
||||
* @param creditCode 统一社会信用代码/税号
|
||||
* @return 是否命中金蝶黑名单
|
||||
*/
|
||||
public boolean isKingdeeBlacklistHit(String creditCode) {
|
||||
if (StringUtil.isBlank(creditCode)) {
|
||||
return false;
|
||||
}
|
||||
String code = creditCode.trim().toUpperCase();
|
||||
return this.queryBlacklistByCodes(List.of(code)).get(code) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装客商风险明细文案(后台统一组装、前端只展示;列表悬浮明细与表单实时提示共用本方法,口径一致),
|
||||
* 格式:【客商名称】:被列入外部黑名单(失信类别:xx);{列入事由};风险等级:xx,分段说明:
|
||||
* ① 黑名单段:**仅以金蝶黑名单(A801-5)命中为准**(失信类别/列入事由任一非空),
|
||||
* 失信类别(awrp_combofield_title,缺失兜底 awrp_combofield)有值时括号展示区分禁止交易/风险警示类;
|
||||
* 合同合规系统 C101-1 的黑名单状态(fdBlackStatus)不作为展示黑名单依据;
|
||||
* ② 列入事由:金蝶 A801-5 awrp_textareafield 真实明细,非空时追加;
|
||||
* ③ 等级段:C101-1 风险等级(risk_level 字典翻译),非空时追加
|
||||
*
|
||||
* @param customerName 客商名称
|
||||
* @param riskLevel C101-1 风险等级(0/1/2)
|
||||
* @param blackCategory 金蝶黑名单失信类别
|
||||
* @param blackReason 金蝶黑名单列入事由
|
||||
* @return 明细文案,无风险信息返回空字符串
|
||||
*/
|
||||
public static String buildRiskDetail(String customerName, String riskLevel, String blackCategory, String blackReason) {
|
||||
List<String> parts = new ArrayList<>();
|
||||
boolean blacklist = Func.isNotEmpty(blackCategory) || Func.isNotEmpty(blackReason);
|
||||
if (blacklist) {
|
||||
parts.add(Func.isNotEmpty(blackCategory)
|
||||
? "被列入外部黑名单(失信类别:" + blackCategory + ")"
|
||||
: "被列入外部黑名单");
|
||||
}
|
||||
if (Func.isNotEmpty(blackReason)) {
|
||||
parts.add(blackReason);
|
||||
}
|
||||
String levelName = resolveRiskLevelName(riskLevel);
|
||||
if (StringUtil.isNotBlank(levelName)) {
|
||||
parts.add("风险等级:" + levelName);
|
||||
}
|
||||
if (parts.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
return "【" + customerName + "】:" + String.join(";", parts);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -324,6 +461,197 @@ public class HcsCreditCheckSupport {
|
||||
return riskList;
|
||||
}
|
||||
|
||||
/**
|
||||
* 客商风险每日定时刷新(C101-1,客商档案列表展示与筛选数据源):
|
||||
* ① 取合同期限内(正式/临时期限任一覆盖当天)审核通过且未终止合同的甲乙方名称;
|
||||
* ② 按名称反查客商档案,内部组织客商默认无风险跳过;
|
||||
* ③ 逐个查询 C101-1(接口仅支持单编号,属远程调用非数据库 N+1;复用 {@link #fetchRelativeRisk},不重复查档案),
|
||||
* 单个失败记 error 日志不中断(失败原因备查);
|
||||
* ④ 外部客商按 200/批切分,每批黑名单(A801-5)一次批量查询 + C101-1 逐个远程查询,失信类别/列入事由落原始字段
|
||||
* (明细文案由列表查询统一组装);
|
||||
* ⑤ 每批成功结果立即批量落库(Db.updateBatchById,仅提交 id + 风险列,不整行回写)+ 留痕批量写入(Db.saveBatch),
|
||||
* 风险/黑名单批次互不影响,单批失败不中断其余批次,避免逐条操作数据库
|
||||
*
|
||||
* @return 结果汇总描述
|
||||
*/
|
||||
public String refreshCustomerRiskForActiveContracts() {
|
||||
LocalDate today = LocalDate.now();
|
||||
List<ContractManage> contractList = contractManageMapper.selectList(Wrappers.<ContractManage>lambdaQuery()
|
||||
.select(ContractManage::getPartyA, ContractManage::getPartyB)
|
||||
.in(ContractManage::getApprovalStatus, HcsSyncConstant.CONTRACT_APPROVED_STATUSES)
|
||||
.ne(ContractManage::getCurrentNode, HcsSyncConstant.CONTRACT_TERMINATED_NODE)
|
||||
.and(wrapper -> wrapper
|
||||
.and(item -> item.le(ContractManage::getStartDate, today).ge(ContractManage::getEndDate, today))
|
||||
.or(item -> item.le(ContractManage::getTemporaryStartDate, today).ge(ContractManage::getTemporaryEndDate, today))));
|
||||
List<String> partyNames = contractList.stream()
|
||||
.flatMap(contract -> Stream.of(contract.getPartyA(), contract.getPartyB()))
|
||||
.filter(Func::isNotEmpty)
|
||||
.map(String::trim)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (partyNames.isEmpty()) {
|
||||
return "合同期限内无生效合同,未刷新客商风险";
|
||||
}
|
||||
List<CustomerArchive> archiveList = customerArchiveMapper.selectList(Wrappers.<CustomerArchive>lambdaQuery()
|
||||
.in(CustomerArchive::getFullName, partyNames)
|
||||
.eq(CustomerArchive::getIsDeleted, 0));
|
||||
List<CustomerArchive> externalArchives = archiveList.stream()
|
||||
.filter(customerArchive -> !isInternalCustomer(customerArchive))
|
||||
.toList();
|
||||
int internalCount = archiveList.size() - externalArchives.size();
|
||||
int refreshCount = 0;
|
||||
int failCount = 0;
|
||||
// 外部客商按 200/批处理:每批黑名单一次批量查询 + C101-1 逐个远程查询(接口仅支持单编号,非数据库 N+1),
|
||||
// 批内成功结果立即批量落库与留痕,风险/黑名单批次互不影响,单批失败不中断其余批次
|
||||
for (int start = 0; start < externalArchives.size(); start += HcsSyncConstant.CUSTOMER_RISK_REFRESH_BATCH) {
|
||||
List<CustomerArchive> batch = externalArchives.subList(start, Math.min(start + HcsSyncConstant.CUSTOMER_RISK_REFRESH_BATCH, externalArchives.size()));
|
||||
Map<String, BfiBlacklistVO> blacklistMap = this.queryBlacklistByCodes(batch.stream()
|
||||
.map(CustomerArchive::getUnifiedCreditCode)
|
||||
.filter(Func::isNotEmpty)
|
||||
.map(code -> code.trim().toUpperCase())
|
||||
.distinct()
|
||||
.toList());
|
||||
List<CustomerArchive> batchRefreshList = new ArrayList<>();
|
||||
List<HcsContractCheckLog> batchCheckLogs = new ArrayList<>();
|
||||
for (CustomerArchive customerArchive : batch) {
|
||||
try {
|
||||
RelativeRiskResult fetch = this.fetchRelativeRisk(customerArchive.getCustomerCode(), customerArchive.getFullName());
|
||||
CustomerRiskVO risk = fetch.risk();
|
||||
// 仅提交风险列,避免整行回写覆盖刷新期间其它并发修改(审批状态/额度/金蝶推送等)
|
||||
CustomerArchive riskPatch = new CustomerArchive();
|
||||
riskPatch.setId(customerArchive.getId());
|
||||
riskPatch.setHcsRiskLevel(risk.getFdRiskLevel());
|
||||
riskPatch.setKingdeeBlackStatus(risk.getFdBlackStatus());
|
||||
riskPatch.setHcsRiskTime(LocalDateTime.now());
|
||||
// 金蝶黑名单命中落原始字段(失信类别/列入事由),明细文案由列表查询统一组装
|
||||
BfiBlacklistVO hit = Func.isEmpty(customerArchive.getUnifiedCreditCode())
|
||||
? null : blacklistMap.get(customerArchive.getUnifiedCreditCode().trim().toUpperCase());
|
||||
if (hit != null) {
|
||||
String category = Func.isEmpty(hit.getAwrpCombofieldTitle()) ? hit.getAwrpCombofield() : hit.getAwrpCombofieldTitle();
|
||||
riskPatch.setKingdeeBlackCategory(category);
|
||||
riskPatch.setKingdeeBlackReason(hit.getAwrpTextareafield());
|
||||
}
|
||||
batchCheckLogs.add(this.buildRiskCheckLog(customerArchive.getCustomerCode(), risk, fetch.traceId()));
|
||||
batchRefreshList.add(riskPatch);
|
||||
} catch (Exception exception) {
|
||||
failCount++;
|
||||
log.error("定时刷新客商风险失败:customerCode={},fullName={}",
|
||||
customerArchive.getCustomerCode(), customerArchive.getFullName(), exception);
|
||||
}
|
||||
}
|
||||
// 批内成功结果立即提交数据库,批间互不影响
|
||||
if (!batchRefreshList.isEmpty()) {
|
||||
Db.updateBatchById(batchRefreshList);
|
||||
refreshCount += batchRefreshList.size();
|
||||
}
|
||||
this.saveRiskCheckLogs(batchCheckLogs);
|
||||
}
|
||||
String summary = String.format("生效合同 %d 份,覆盖客商 %d 个,刷新 %d 个,内部客商跳过 %d 个,失败 %d 个",
|
||||
contractList.size(), archiveList.size(), refreshCount, internalCount, failCount);
|
||||
if (failCount > 0) {
|
||||
log.warn("定时刷新客商风险存在失败:{}", summary);
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按甲乙方名称批量查客商档案风险数据(合同/变更列表与详情组装用,合同表不再落库风险留痕):
|
||||
* 一次 IN 查询避免 N+1,键为客商名称;仅保留已有风险数据的档案(等级或金蝶黑名单字段非空),
|
||||
* 名称重名时取第一条;内部组织客商无风险数据天然不入 map
|
||||
*
|
||||
* @param partyNames 甲/乙方名称集合
|
||||
* @return 客商名称 -> 客商档案(含 hcsRiskLevel/kingdeeBlackCategory/kingdeeBlackReason)
|
||||
*/
|
||||
public Map<String, CustomerArchive> queryArchiveRiskMap(Collection<String> partyNames) {
|
||||
if (Func.isEmpty(partyNames)) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<String, CustomerArchive> archiveMap = new HashMap<>();
|
||||
for (CustomerArchive archive : customerArchiveMapper.selectList(Wrappers.<CustomerArchive>lambdaQuery()
|
||||
.in(CustomerArchive::getFullName, partyNames)
|
||||
.eq(CustomerArchive::getIsDeleted, 0))) {
|
||||
if (Func.isEmpty(archive.getFullName())) {
|
||||
continue;
|
||||
}
|
||||
boolean hasRiskData = Func.isNotEmpty(archive.getHcsRiskLevel())
|
||||
|| Func.isNotEmpty(archive.getKingdeeBlackCategory()) || Func.isNotEmpty(archive.getKingdeeBlackReason());
|
||||
if (hasRiskData) {
|
||||
archiveMap.putIfAbsent(archive.getFullName(), archive);
|
||||
}
|
||||
}
|
||||
return archiveMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合同客商风险等级合并(甲乙双方取较高值,2>1>0,无有效等级返回 null)
|
||||
*/
|
||||
public static String higherRiskLevel(String riskLevelA, String riskLevelB) {
|
||||
int levelA = parseRiskLevel(riskLevelA);
|
||||
int levelB = parseRiskLevel(riskLevelB);
|
||||
if (levelA < 0 && levelB < 0) {
|
||||
return null;
|
||||
}
|
||||
return String.valueOf(Math.max(levelA, levelB));
|
||||
}
|
||||
|
||||
private static int parseRiskLevel(String riskLevel) {
|
||||
if (Func.isEmpty(riskLevel)) {
|
||||
return -1;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(riskLevel.trim());
|
||||
} catch (NumberFormatException exception) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 客商档案金蝶黑名单命中判定(失信类别/列入事由任一非空)
|
||||
*/
|
||||
public static boolean isKingdeeBlackHit(CustomerArchive archive) {
|
||||
return archive != null && (Func.isNotEmpty(archive.getKingdeeBlackCategory()) || Func.isNotEmpty(archive.getKingdeeBlackReason()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 金蝶黑名单批量查询(A801-5,定时刷新专用,调用方按 200 客商/批切分保证入参 ≤200):
|
||||
* 键为大写税号;失信类别取 awrp_combofield_title、缺失兜底 awrp_combofield(口径同提审校验 validateBlacklist);
|
||||
* 查询异常不阻断刷新,记 error 日志备查后返回空 map(该批黑名单段缺失)
|
||||
*
|
||||
* @param creditCodes 统一社会信用代码集合(大写,≤200)
|
||||
* @return 大写税号 -> 黑名单记录
|
||||
*/
|
||||
private Map<String, BfiBlacklistVO> queryBlacklistByCodes(List<String> creditCodes) {
|
||||
if (creditCodes.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
try {
|
||||
Map<String, BfiBlacklistVO> blacklistMap = new HashMap<>();
|
||||
for (BfiBlacklistVO item : this.bfiService.queryBlacklist("1", creditCodes)) {
|
||||
if (Func.isNotEmpty(item.getNumber())) {
|
||||
blacklistMap.put(item.getNumber().trim().toUpperCase(), item);
|
||||
}
|
||||
}
|
||||
return blacklistMap;
|
||||
} catch (Exception exception) {
|
||||
log.error("定时刷新客商风险批量查询金蝶黑名单失败:size={}", creditCodes.size(), exception);
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 客商风险留痕批量写入(Db.saveBatch,避免逐条 insert);失败记 error 日志备查,不阻断刷新结果
|
||||
*/
|
||||
private void saveRiskCheckLogs(List<HcsContractCheckLog> checkLogs) {
|
||||
if (checkLogs.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Db.saveBatch(checkLogs);
|
||||
} catch (Exception exception) {
|
||||
log.error("定时刷新客商风险留痕批量写入失败:size={}", checkLogs.size(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按名称反查客商档案(内部/外部类型判断与客商编码获取共用;查不到返回 null)
|
||||
*/
|
||||
|
||||
+29
-17
@@ -46,19 +46,19 @@ import org.springframework.stereotype.Component;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* HCS 履约规则同步支撑组件(C103-9 新版行级同步,设计文档 §5.3,ADR-0002)
|
||||
* <p>
|
||||
* 随合同推送链路执行(合同须已同步、hcs_fd_id 存在):聚合 row_op 非 none 的规则行
|
||||
* (add 行不带 fdId,update/delete 行必须带合规阶段ID),整体 fdEopExeplanOperateType
|
||||
* 首次为 add、后续为 update;返回的 fdExeplanId 回填 blade_contract_manage.hcs_exeplan_id,
|
||||
* 变更阶段 fdId 按行回填 hcs_stage_fd_id(合规返回的 fdId 必须持久化,后续行更新/删除全靠它)。
|
||||
* 随合同推送链路执行(合同须已同步、hcs_fd_id 存在):聚合 row_op 非 none 的规则行,
|
||||
* 整体 fdEopExeplanOperateType 首次为 add、后续为 update;请求行必送 fdUniqueId(取 TMS 行ID,重试稳定),响应行回 fdId;
|
||||
* 履约计划ID 取响应顶层 fdExeplanId 回填 blade_contract_manage.hcs_exeplan_id,
|
||||
* 各行 fdId 按行回填 hcs_stage_fd_id(必须持久化,供履约记录等接口引用)。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@@ -123,9 +123,11 @@ public class HcsExeStageSyncSupport {
|
||||
List<HcsExeplanStageDTO> stages = new ArrayList<>();
|
||||
for (HcsContractExeStage row : pendingRows) {
|
||||
HcsExeplanStageDTO stage = new HcsExeplanStageDTO();
|
||||
// 阶段项ID:行级 update/delete 必传(取 hcs_stage_fd_id),add 行不带
|
||||
if (!"add".equals(row.getRowOp())) {
|
||||
stage.setFdId(row.getHcsStageFdId());
|
||||
// 阶段行ID:update/delete 行必传(HCS 凭首次 add 返回的 fdId 定位行,缺失报"履约计划已不存在"),add 行无值自动省略
|
||||
stage.setFdId(row.getHcsStageFdId());
|
||||
// 阶段唯一标识:仅 add 行必传(取 TMS 行ID,HCS 回显存储);update/delete 行实测无需携带,以 fdId 定位
|
||||
if ("add".equals(row.getRowOp())) {
|
||||
stage.setFdUniqueId(String.valueOf(row.getId()));
|
||||
}
|
||||
// 【必填】行级操作:add / update / delete
|
||||
stage.setFdEopExeplanOperateType(row.getRowOp());
|
||||
@@ -154,19 +156,26 @@ public class HcsExeStageSyncSupport {
|
||||
}
|
||||
|
||||
/**
|
||||
* 回填:fdExeplanId → 合同 hcs_exeplan_id;变更阶段 fdId 按行回填 hcs_stage_fd_id 并清除行级操作标记
|
||||
* 回填:响应行 fdId 即履约计划ID → 合同 hcs_exeplan_id(fdAddStages/fdChangeStages 取首个非空);各行 fdId 按行回填 hcs_stage_fd_id 并清除行级操作标记
|
||||
* <p>
|
||||
* TODO 待联调确认:fdChangeStages 与请求行的对应关系 v4 未明确,优先按请求行序对应(首次全 add 场景),
|
||||
* 行数不一致时按业务属性组合键匹配,仍匹配不到的 add 行保持待同步(下轮重试)。
|
||||
* 新增行阶段在 fdAddStages 返回(实测不含业务属性,按 add 行出现顺序对应),变更行在 fdChangeStages 返回;
|
||||
* fdChangeStages 按请求行序对应,行数不一致时按业务属性组合键匹配,仍匹配不到的 add 行保持待同步(下轮重试)。
|
||||
*/
|
||||
private void backfill(ContractManage contractManage, List<HcsContractExeStage> pendingRows,
|
||||
List<HcsExeplanStageDTO> requestStages, HcsExeplanVO exeplan) {
|
||||
// 履约计划ID 回填(后续行更新/删除依赖,必须持久化)
|
||||
if (StringUtil.isNotBlank(exeplan.getFdExeplanId())
|
||||
&& !exeplan.getFdExeplanId().equals(contractManage.getHcsExeplanId())) {
|
||||
contractManage.setHcsExeplanId(exeplan.getFdExeplanId());
|
||||
// 履约计划ID 回填:优先取响应顶层 fdExeplanId(实测 add/update 均有返回),兜底取响应行 fdId
|
||||
String fdExeplanId = StringUtil.isNotBlank(exeplan.getFdExeplanId()) ? exeplan.getFdExeplanId()
|
||||
: Stream.of(exeplan.getFdChangeStages(), exeplan.getFdAddStages())
|
||||
.filter(Objects::nonNull)
|
||||
.flatMap(List::stream)
|
||||
.map(HcsExeplanStageVO::getFdId)
|
||||
.filter(StringUtil::isNotBlank)
|
||||
.findFirst().orElse(null);
|
||||
if (StringUtil.isNotBlank(fdExeplanId)) {
|
||||
contractManage.setHcsExeplanId(fdExeplanId);
|
||||
}
|
||||
backfillExeplanId(contractManage);
|
||||
List<HcsExeplanStageVO> addStages = exeplan.getFdAddStages() == null ? List.of() : exeplan.getFdAddStages();
|
||||
List<HcsExeplanStageVO> changeStages = exeplan.getFdChangeStages() == null ? List.of() : exeplan.getFdChangeStages();
|
||||
// 行数一致时按请求行序一一对应;不一致时按业务属性组合键匹配
|
||||
boolean positional = changeStages.size() == requestStages.size();
|
||||
@@ -177,9 +186,12 @@ public class HcsExeStageSyncSupport {
|
||||
HcsContractExeStage row = pendingRows.get(index);
|
||||
String stageFdId = row.getHcsStageFdId();
|
||||
if ("add".equals(row.getRowOp())) {
|
||||
addRowCount++;
|
||||
HcsExeplanStageVO matched = positional ? changeStages.get(index) : byAttribute.get(attributeKey(requestStages.get(index)));
|
||||
HcsExeplanStageVO matched = addRowCount < addStages.size() ? addStages.get(addRowCount) : null;
|
||||
if (matched == null) {
|
||||
matched = positional ? changeStages.get(index) : byAttribute.get(attributeKey(requestStages.get(index)));
|
||||
}
|
||||
stageFdId = matched == null ? null : matched.getFdId();
|
||||
addRowCount++;
|
||||
if (StringUtil.isBlank(stageFdId)) {
|
||||
unmatchedCount++;
|
||||
}
|
||||
|
||||
+29
@@ -131,6 +131,17 @@ public class HcsImmediatePushSupport {
|
||||
this.submitAfterCommit(HcsSyncConstant.BIZ_TYPE_CONTRACT, id, () -> this.pushContractNow(id), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 合同双章归档文件上传后即时归档推送(C103-11 更新带 fdArchiveStatus=toarchive + 归档附件挂其他附件面板):
|
||||
* 须在附件保存事务内调用(afterCommit 触发),失败仅留痕(可重新上传双章归档文件或变更重推补偿)
|
||||
*
|
||||
* @param id 合同单ID
|
||||
* @param newSealFilesJson 本次新增的双章归档文件元数据 JSON 数组
|
||||
*/
|
||||
public void pushContractArchiveAfterCommit(Long id, String newSealFilesJson) {
|
||||
this.submitAfterCommit(HcsSyncConstant.BIZ_TYPE_CONTRACT, id, () -> this.pushContractArchiveNow(id, newSealFilesJson), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 付款申请审批通过后即时发起付款特批(C106-3,无需特批则直接推履约数据):
|
||||
* 须在审批事务内调用(afterCommit 触发);按"审批即发起特批"要求不受 immediate-enabled 开关控制
|
||||
@@ -153,6 +164,13 @@ public class HcsImmediatePushSupport {
|
||||
this.pushWithLock(HcsSyncConstant.BIZ_TYPE_CONTRACT, id, () -> this.pushContractNow(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 即时推送开关(thirdParty.hcs.push.immediate-enabled)状态,供 Controller 判断审批响应是否实际等待过推送结果
|
||||
*/
|
||||
public boolean isImmediateEnabled() {
|
||||
return hcsProperties.getPush().isImmediateEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目立项"通过"按钮路径:在审批事务提交返回后由 Controller 同步调用,失败原因留在当前线程供其取走
|
||||
*
|
||||
@@ -273,6 +291,17 @@ public class HcsImmediatePushSupport {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 合同归档推送执行体:合同未发起合规/状态不满足由 Support 内部跳过,失败仅留痕
|
||||
*/
|
||||
private void pushContractArchiveNow(Long id, String newSealFilesJson) {
|
||||
try {
|
||||
hcsContractSyncSupport.pushArchiveUpdate(id, newSealFilesJson);
|
||||
} catch (Exception exception) {
|
||||
log.error("HCS合同归档推送失败(可重新上传双章归档文件或变更重推补偿):id={},{}", id, exception.getMessage(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 付款推送执行体:非审批通过/已同步短路跳过;失败已由 Support markFailed,仅留痕交扫描/手动重推补偿
|
||||
*/
|
||||
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 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.support.hcs;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.core.tool.utils.StringUtil;
|
||||
import org.springblade.hcs.config.HcsProperties;
|
||||
import org.springblade.hcs.exception.HcsException;
|
||||
import org.springblade.transport.constant.HcsSyncConstant;
|
||||
import org.springblade.transport.mapper.ContractManageMapper;
|
||||
import org.springblade.transport.mapper.InvoiceApplicationInvoiceMapper;
|
||||
import org.springblade.transport.mapper.InvoiceApplicationMapper;
|
||||
import org.springblade.transport.mapper.InvoiceApplicationSettlementMapper;
|
||||
import org.springblade.transport.mapper.FormalSettlementMapper;
|
||||
import org.springblade.transport.pojo.entity.ContractManage;
|
||||
import org.springblade.transport.pojo.entity.FormalSettlement;
|
||||
import org.springblade.transport.pojo.entity.HcsContractExeStage;
|
||||
import org.springblade.transport.pojo.entity.InvoiceApplication;
|
||||
import org.springblade.transport.pojo.entity.InvoiceApplicationInvoice;
|
||||
import org.springblade.transport.pojo.entity.InvoiceApplicationSettlement;
|
||||
import org.springblade.transport.service.IContractExeStageService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.ZoneId;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* HCS 履约开票记录同步支撑组件(C108-1,需求:金蝶开票完成后推送合同系统)
|
||||
* <p>
|
||||
* 粒度:金蝶回写的每张发票(blade_invoice_application_invoice)对应一条履约开票记录;
|
||||
* 触发:A303-5 开票结果回写落库发票后,随开票登记扫描任务(HcsPushTask invoice-application)推送;
|
||||
* 履约日期为发票完成开票日期(invoice_date),履约类型「开票」(关联合同收款履约计划行);
|
||||
* 合同经应收结算链路回溯(开票申请 → 应收结算关联 → 正式结算单合同,同 C111 §9-U10 口径);
|
||||
* 幂等凭 hcs_perf_fd_id(非空跳过),失败置重试计数与原因由下轮扫描自动补偿。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class HcsInvoicePerfSyncSupport {
|
||||
|
||||
private final HcsProperties hcsProperties;
|
||||
private final InvoiceApplicationInvoiceMapper invoiceApplicationInvoiceMapper;
|
||||
private final InvoiceApplicationMapper invoiceApplicationMapper;
|
||||
private final InvoiceApplicationSettlementMapper invoiceApplicationSettlementMapper;
|
||||
private final FormalSettlementMapper formalSettlementMapper;
|
||||
private final ContractManageMapper contractManageMapper;
|
||||
private final IContractExeStageService contractExeStageService;
|
||||
private final HcsPerfRecordAddSupport hcsPerfRecordAddSupport;
|
||||
|
||||
/**
|
||||
* 扫描并推送待同步的履约开票记录(每张发票一条 C108-1),返回结果汇总
|
||||
*
|
||||
* @return 汇总描述
|
||||
*/
|
||||
public String pushUnsyncedInvoicePerfRecords() {
|
||||
if (!hcsProperties.getPerfRecord().isSyncEnabled()) {
|
||||
return "履约记录同步开关未开启";
|
||||
}
|
||||
int maxRetry = hcsProperties.getPush().getMaxRetry();
|
||||
// 待推送发票:未建过履约记录且重试未超限
|
||||
List<InvoiceApplicationInvoice> invoiceList = invoiceApplicationInvoiceMapper.selectList(
|
||||
Wrappers.<InvoiceApplicationInvoice>lambdaQuery()
|
||||
.isNull(InvoiceApplicationInvoice::getHcsPerfFdId)
|
||||
.and(wrapper -> wrapper.isNull(InvoiceApplicationInvoice::getHcsPerfRetryCount)
|
||||
.or().lt(InvoiceApplicationInvoice::getHcsPerfRetryCount, maxRetry))
|
||||
.last("limit " + hcsProperties.getPush().getBatchSize()));
|
||||
if (invoiceList.isEmpty()) {
|
||||
return "履约开票记录:无待推送发票";
|
||||
}
|
||||
// 批量装配上下文(申请单/合同/计划行,一次 IN 查询避免逐行 N+1)
|
||||
Map<Long, InvoiceApplication> applicationById = loadApplications(invoiceList);
|
||||
Map<Long, ContractManage> contractByApplicationId = loadContracts(List.copyOf(applicationById.values()));
|
||||
int successCount = 0;
|
||||
int failCount = 0;
|
||||
for (InvoiceApplicationInvoice invoice : invoiceList) {
|
||||
try {
|
||||
pushOneInvoice(invoice, applicationById.get(invoice.getInvoiceApplicationId()),
|
||||
contractByApplicationId.get(invoice.getInvoiceApplicationId()));
|
||||
successCount++;
|
||||
} catch (Exception exception) {
|
||||
markFailed(invoice, StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage());
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
String summary = String.format("履约开票记录:扫描 %d 张,成功 %d 张,失败 %d 张(重试上限 %d)",
|
||||
invoiceList.size(), successCount, failCount, maxRetry);
|
||||
if (failCount > 0) {
|
||||
log.warn("HCS履约开票记录推送存在失败:{}", summary);
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送单张发票的履约开票记录
|
||||
*/
|
||||
private void pushOneInvoice(InvoiceApplicationInvoice invoice, InvoiceApplication application, ContractManage contractManage) {
|
||||
if (application == null || !"approved".equals(application.getApprovalStatus())) {
|
||||
throw new HcsException("开票申请不存在或未审批通过:invoiceApplicationId=" + invoice.getInvoiceApplicationId());
|
||||
}
|
||||
if (contractManage == null) {
|
||||
throw new HcsException("应收结算链路回溯不到合同(applicationNo=" + application.getApplicationNo() + ",同 C111 §9-U10 口径)");
|
||||
}
|
||||
// 关联合同的收款履约计划行(fd_plan_id 必填,无已同步计划行时通用组件抛出重试,合同链路首推后自愈)
|
||||
HcsContractExeStage stageRow = contractExeStageService.findCollectionStageRow(
|
||||
contractManage.getId(), HcsSyncConstant.FUND_NATURE_PROGRESS);
|
||||
BigDecimal amount = invoice.getInvoiceAmount() == null ? BigDecimal.ZERO : invoice.getInvoiceAmount();
|
||||
String perfFdId = hcsPerfRecordAddSupport.pushPerfRecordAdd(contractManage, stageRow,
|
||||
invoice.getInvoiceNo(), amount, toMillis(invoice.getInvoiceDate()),
|
||||
HcsSyncConstant.PERF_TYPE_INVOICE, invoice.getSerialNo(), invoice.getId(),
|
||||
"开票履约-" + invoice.getInvoiceNo());
|
||||
markSynced(invoice, perfFdId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量装配发票所属开票申请(过滤已作废)
|
||||
*/
|
||||
private Map<Long, InvoiceApplication> loadApplications(List<InvoiceApplicationInvoice> invoiceList) {
|
||||
List<Long> applicationIds = invoiceList.stream()
|
||||
.map(InvoiceApplicationInvoice::getInvoiceApplicationId).filter(Objects::nonNull).distinct().toList();
|
||||
if (applicationIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
return invoiceApplicationMapper.selectBatchIds(applicationIds).stream()
|
||||
.collect(Collectors.toMap(InvoiceApplication::getId, item -> item, (first, second) -> first));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量回溯合同:申请单 → 应收结算关联 → 正式结算单合同(一次 IN 查询,同 C111 §9-U10 口径)
|
||||
*/
|
||||
private Map<Long, ContractManage> loadContracts(List<InvoiceApplication> applicationList) { List<Long> applicationIds = applicationList.stream().map(InvoiceApplication::getId).toList();
|
||||
if (applicationIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<Long, InvoiceApplicationSettlement> relationByApplicationId = invoiceApplicationSettlementMapper.selectList(
|
||||
Wrappers.<InvoiceApplicationSettlement>lambdaQuery()
|
||||
.in(InvoiceApplicationSettlement::getInvoiceApplicationId, applicationIds)).stream()
|
||||
.filter(relation -> relation.getFormalSettlementId() != null)
|
||||
.collect(Collectors.toMap(InvoiceApplicationSettlement::getInvoiceApplicationId, relation -> relation,
|
||||
(first, second) -> first, LinkedHashMap::new));
|
||||
if (relationByApplicationId.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<Long, FormalSettlement> settlementById = formalSettlementMapper.selectBatchIds(
|
||||
relationByApplicationId.values().stream()
|
||||
.map(InvoiceApplicationSettlement::getFormalSettlementId).distinct().toList()).stream()
|
||||
.filter(settlement -> settlement.getContractId() != null)
|
||||
.collect(Collectors.toMap(FormalSettlement::getId, item -> item, (first, second) -> first));
|
||||
if (settlementById.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<Long, ContractManage> contractById = contractManageMapper.selectBatchIds(
|
||||
settlementById.values().stream().map(FormalSettlement::getContractId).distinct().toList()).stream()
|
||||
.collect(Collectors.toMap(ContractManage::getId, item -> item, (first, second) -> first));
|
||||
Map<Long, ContractManage> contractByApplicationId = new LinkedHashMap<>();
|
||||
relationByApplicationId.forEach((applicationId, relation) -> {
|
||||
FormalSettlement settlement = settlementById.get(relation.getFormalSettlementId());
|
||||
if (settlement != null) {
|
||||
contractByApplicationId.put(applicationId, contractById.get(settlement.getContractId()));
|
||||
}
|
||||
});
|
||||
return contractByApplicationId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送成功回填:履约记录ID 立即持久化(幂等键),清失败原因
|
||||
*/
|
||||
private void markSynced(InvoiceApplicationInvoice invoice, String perfFdId) {
|
||||
invoice.setHcsPerfFdId(perfFdId);
|
||||
invoice.setHcsPerfRetryCount(0);
|
||||
invoice.setHcsPerfSyncMsg(null);
|
||||
invoiceApplicationInvoiceMapper.updateById(invoice);
|
||||
log.info("HCS履约开票记录推送成功:invoiceNo={},perfFdId={}", invoice.getInvoiceNo(), perfFdId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送失败回写:重试次数+1 + 失败原因(下轮扫描自动补偿)
|
||||
*/
|
||||
private void markFailed(InvoiceApplicationInvoice invoice, String message) {
|
||||
invoice.setHcsPerfRetryCount(invoice.getHcsPerfRetryCount() == null ? 1 : invoice.getHcsPerfRetryCount() + 1);
|
||||
invoice.setHcsPerfSyncMsg(HcsSyncConstant.truncateMsg(message));
|
||||
invoiceApplicationInvoiceMapper.updateById(invoice);
|
||||
log.error("HCS履约开票记录推送失败:invoiceNo={},retryCount={},{}", invoice.getInvoiceNo(), invoice.getHcsPerfRetryCount(), message);
|
||||
}
|
||||
|
||||
private Long toMillis(java.time.LocalDateTime localDateTime) {
|
||||
return localDateTime == null ? null
|
||||
: localDateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
|
||||
}
|
||||
}
|
||||
+38
-47
@@ -26,6 +26,7 @@
|
||||
package org.springblade.transport.support.hcs;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
@@ -49,7 +50,8 @@ import java.util.List;
|
||||
/**
|
||||
* HCS 付款链推送编排支撑组件(结算付款特批 C106-3/4 + 履约计划重推 C103-9 + 履约记录 C108-1/2 + 特批结果推送接收,设计文档 §5.0/§5.3/§5.5/§5.6)
|
||||
* <p>
|
||||
* 编排顺序:特批(提交时高风险/黑名单须特批附件或额度超额标记 need 时发起)→ 特批通过后再推履约计划(付款审批款项金额累加行)→ 履约记录(审批通过新建 / 实际付款推进更新)。
|
||||
* 编排顺序:特批(提交时高风险/黑名单须特批附件或额度超额标记 need 时发起)→ 金蝶实际付款门禁(未付款时履约计划与履约记录均不推送)→ 履约计划重推(付款审批累加款项金额行,金蝶付款后随链重推)→ 履约记录(金蝶实际付款后新建/分期推进更新)。
|
||||
* 特批通过后不推履约数据:履约计划与履约记录统一在各业务环节推送(付款=金蝶实际付款后,收款/开票/收票各自环节)。
|
||||
* 特批审核期间(need/pushing/processing)仅发起/等待 C106,不推送履约计划与履约记录;驳回不产生履约数据。
|
||||
* 特批流程ID 与履约记录ID 在各自 Support 内即时持久化;整体同步状态(hcs_sync_status)由本组件统一回写。
|
||||
* 特批发起后 TMS 挂起 currentNode="特批审核",合规 paymentLegal/result 回调联动付款审批状态
|
||||
@@ -76,7 +78,7 @@ public class HcsPaymentSyncSupport {
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
/**
|
||||
* 推送付款申请到 HCS:特批(need 时发起 C106-3 后挂起)→ 特批通过后履约计划重推(C103-9 款项金额累加行)→ 履约记录(新建/更新)→ 回填整体同步状态
|
||||
* 推送付款申请到 HCS:特批(need 时发起 C106-3 后挂起)→ 金蝶实际付款门禁(未付款时整体跳过,付款回写后重新置待同步触发推送)→ 履约计划重推(C103-9 款项金额累加行)→ 履约记录(C108-1 新建/C108-2 更新)→ 回填整体同步状态
|
||||
*
|
||||
* @param payment 付款申请单(approvalStatus=approved)
|
||||
* @return 推送结果描述
|
||||
@@ -84,7 +86,7 @@ public class HcsPaymentSyncSupport {
|
||||
public String pushPayment(PaymentApplication payment) {
|
||||
StringBuilder result = new StringBuilder();
|
||||
// ① 结算付款特批:提交时高风险/黑名单(须特批附件)或额度超额(hcs_spec_status=need)且未发起时发起 C106-3;
|
||||
// 发起后挂起等待特批结果,履约计划与履约记录由特批通过后的 pushAfterSpecApproved 补推(驳回不产生履约数据)
|
||||
// 发起后挂起等待特批结果,特批通过后不推履约数据(各业务环节推送),仅补推金蝶(驳回不产生履约数据)
|
||||
if (HcsSyncConstant.SPEC_STATUS_NEED.equals(payment.getHcsSpecStatus())) {
|
||||
String specResult = hcsSpecPaySyncSupport.pushSpecPay(payment);
|
||||
// C106-4 更新路径不改状态,统一归一为特批审核中,避免下轮扫描重复发起
|
||||
@@ -98,27 +100,34 @@ public class HcsPaymentSyncSupport {
|
||||
// 特批审核中:仅等待合规回调/人工确认,不推送履约计划与履约记录
|
||||
if (HcsSyncConstant.SPEC_STATUS_PUSHING.equals(payment.getHcsSpecStatus())
|
||||
|| HcsSyncConstant.SPEC_STATUS_PROCESSING.equals(payment.getHcsSpecStatus())) {
|
||||
String message = "付款特批审核中,待特批通过后推送履约数据";
|
||||
String message = "付款特批审核中,履约数据待各业务环节推送";
|
||||
markSynced(payment, message);
|
||||
return message;
|
||||
}
|
||||
// ② 履约计划重推(C103-9 行级更新):付款审批通过累加款项金额置 row_op=update 的计划行,
|
||||
// 凭已存合规阶段ID 重推;独立于履约记录开关,推送失败抛出由付款链置 failed 重试兜底
|
||||
String stageResult = this.pushExeStageUpdate(payment);
|
||||
if (StringUtil.isNotBlank(stageResult)) {
|
||||
if (result.length() > 0) {
|
||||
result.append(";");
|
||||
}
|
||||
result.append(stageResult);
|
||||
// ② 金蝶实际付款门禁:履约计划与履约记录均待金蝶实际付款后推送(需求口径:金蝶付款成功后再推给合同系统)——
|
||||
// 审批通过仅本地累加计划金额置待同步,此处不重推;未付款时整体照常回写,
|
||||
// 付款回写推进(applyPayApplyResult)重新置待同步后由扫描任务触发本推送
|
||||
if (payment.getPaidAmount() == null || payment.getPaidAmount().signum() <= 0) {
|
||||
String message = "金蝶未实际付款,待付款后推送履约计划与履约记录";
|
||||
markSynced(payment, message);
|
||||
return message;
|
||||
}
|
||||
// ③ 履约记录:审批通过新建 C108-1 / 实际付款推进更新 C108-2(一期暂不对接默认关)
|
||||
// ③ 履约计划重推(C103-9 行级更新):付款审批通过累加款项金额置 row_op=update 的计划行,
|
||||
// 凭已存合规阶段ID 重推;推送失败抛出由付款链置 failed 重试兜底
|
||||
if (hcsProperties.getExeStage().isSyncEnabled()) {
|
||||
String stageResult = this.pushExeStageUpdate(payment);
|
||||
if (StringUtil.isNotBlank(stageResult)) {
|
||||
if (result.length() > 0) {
|
||||
result.append(";");
|
||||
}
|
||||
result.append(stageResult);
|
||||
}
|
||||
}
|
||||
// ④ 履约记录:首次回写实际付款走 C108-1 新建,分期付款推进(paidAmount 变化置待同步)走 C108-2 更新
|
||||
if (!hcsProperties.getPerfRecord().isSyncEnabled()) {
|
||||
markSynced(payment, result.toString());
|
||||
return result.toString();
|
||||
}
|
||||
if (result.length() > 0) {
|
||||
result.append(";");
|
||||
}
|
||||
result.append(hcsPerfRecordSyncSupport.pushPerfRecord(payment));
|
||||
markSynced(payment, result.toString());
|
||||
return result.toString();
|
||||
@@ -143,22 +152,6 @@ public class HcsPaymentSyncSupport {
|
||||
return "履约规则无待同步行".equals(stageResult) ? "" : stageResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* 特批通过后补推履约数据(C103-9 计划重推 + C108 履约记录):补推失败置 failed 留待人工重推兜底,
|
||||
* 不阻断特批结果落库与金蝶补推
|
||||
*
|
||||
* @param payment 付款申请单(hcs_spec_status=approved)
|
||||
*/
|
||||
private void pushAfterSpecApproved(PaymentApplication payment) {
|
||||
try {
|
||||
this.pushPayment(payment);
|
||||
} catch (Exception exception) {
|
||||
String message = StringUtil.isBlank(exception.getMessage()) ? "履约数据推送异常" : exception.getMessage();
|
||||
markFailed(payment, message);
|
||||
log.error("付款特批通过后履约数据推送失败:paymentNo={},{}", payment.getPaymentNo(), message, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条手动重推:重置为待同步并清零重试计数后立即推送(页面"推送合规/重推"按钮入口)
|
||||
*
|
||||
@@ -207,10 +200,7 @@ public class HcsPaymentSyncSupport {
|
||||
syncPaymentOnSpecResult(payment, HcsSyncConstant.SPEC_STATUS_APPROVED.equals(result));
|
||||
payment.setHcsSpecStatus(result);
|
||||
paymentApplicationMapper.updateById(payment);
|
||||
// 特批通过后补推履约计划与履约记录(驳回不产生履约数据),失败不阻断后续金蝶补推
|
||||
if (HcsSyncConstant.SPEC_STATUS_APPROVED.equals(result)) {
|
||||
this.pushAfterSpecApproved(payment);
|
||||
}
|
||||
// 特批通过后不推履约数据:履约计划与履约记录统一在各业务环节(付款=金蝶实际付款后)推送
|
||||
publishKingdeeAutoSyncOnSpecApproved(payment, result);
|
||||
log.info("HCS特批结果人工确认:paymentNo={},specFdId={},结果={}", payment.getPaymentNo(), payment.getHcsSpecFdId(), result);
|
||||
return "特批结果已确认:" + result;
|
||||
@@ -219,7 +209,7 @@ public class HcsPaymentSyncSupport {
|
||||
/**
|
||||
* 付款特批结果接收处理(合规 paymentLegal/result 回调通知特批审批结果):
|
||||
* sourceSystem 须与 third-party.hcs.src-system 配置一致(不符抛出"来源系统标识错误"异常),
|
||||
* requestId=TMS 付款申请ID、status=APPROVED/REJECTED;
|
||||
* requestId=合规特批流程ID(hcs_spec_fd_id,C106-3 回存值)、status=APPROVED/REJECTED;
|
||||
* 特批结果联动付款审批状态:approved 恢复"审批通过"放行(后续金蝶推送放行),
|
||||
* rejected 联动付款审批驳回(returned),走"重新编辑→提交→审批→重新特批"闭环
|
||||
*
|
||||
@@ -250,11 +240,13 @@ public class HcsPaymentSyncSupport {
|
||||
}
|
||||
syncPaymentOnSpecResult(payment, HcsSyncConstant.SPEC_STATUS_APPROVED.equals(status));
|
||||
payment.setHcsSpecStatus(status);
|
||||
paymentApplicationMapper.updateById(payment);
|
||||
// 特批通过后补推履约计划与履约记录(驳回不产生履约数据),失败不阻断后续金蝶补推
|
||||
if (HcsSyncConstant.SPEC_STATUS_APPROVED.equals(status)) {
|
||||
this.pushAfterSpecApproved(payment);
|
||||
// 特批通过回传特批结果附件(fileUrl 值=合规附件ID):落库供 C103-5 下载与金蝶附件联带;
|
||||
// 回调无附件时保留旧值(shortcut: 驳回重批期间旧附件仍展示,重批回调会覆盖,金蝶侧特批中门禁不会误传)
|
||||
if (HcsSyncConstant.SPEC_STATUS_APPROVED.equals(status) && StringUtil.isNotBlank(param.getFileUrl())) {
|
||||
payment.setHcsSpecAttachFdId(param.getFileUrl().trim());
|
||||
}
|
||||
paymentApplicationMapper.updateById(payment);
|
||||
// 特批通过后不推履约数据:履约计划与履约记录统一在各业务环节(付款=金蝶实际付款后)推送
|
||||
publishKingdeeAutoSyncOnSpecApproved(payment, status);
|
||||
log.info("付款特批结果推送处理完成:paymentNo={},结果={},reason={}", payment.getPaymentNo(), status, param.getReason());
|
||||
return "特批结果已处理:" + status;
|
||||
@@ -288,17 +280,16 @@ public class HcsPaymentSyncSupport {
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 TMS 付款申请ID 定位付款申请(合规推送 requestId)
|
||||
* 按合规特批流程ID定位付款申请(合规推送 requestId=hcs_spec_fd_id,即 C106-3 返回并回存的流程 fdId;
|
||||
* 与合同链路 locateContractByFdId 同口径,C106-3 发起报文未传 TMS 付款申请ID,合规无法回传数字ID)
|
||||
*/
|
||||
private PaymentApplication locatePayment(String requestId) {
|
||||
if (StringUtil.isBlank(requestId)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return paymentApplicationMapper.selectById(Long.valueOf(requestId.trim()));
|
||||
} catch (NumberFormatException exception) {
|
||||
return null;
|
||||
}
|
||||
return paymentApplicationMapper.selectOne(Wrappers.<PaymentApplication>lambdaQuery()
|
||||
.eq(PaymentApplication::getHcsSpecFdId, requestId.trim())
|
||||
.last("limit 1"));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 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.support.hcs;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.tool.utils.StringUtil;
|
||||
import org.springblade.hcs.config.HcsProperties;
|
||||
import org.springblade.hcs.dto.HcsFormSaveVO;
|
||||
import org.springblade.hcs.dto.HcsMechanismsDTO;
|
||||
import org.springblade.hcs.dto.HcsPerfRecordFormDataDTO;
|
||||
import org.springblade.hcs.dto.HcsPerfRecordPropsDTO;
|
||||
import org.springblade.hcs.dto.HcsPerfRecordSaveDTO;
|
||||
import org.springblade.hcs.dto.HcsResult;
|
||||
import org.springblade.hcs.dto.HcsUserRefVO;
|
||||
import org.springblade.hcs.exception.HcsException;
|
||||
import org.springblade.hcs.feign.IHcsClient;
|
||||
import org.springblade.transport.pojo.entity.ContractManage;
|
||||
import org.springblade.transport.pojo.entity.HcsContractExeStage;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* HCS 履约记录通用新建支撑组件(C108-1,收款/开票/收票场景共用)
|
||||
* <p>
|
||||
* 付款申请场景因含 C108-2 实际金额推进与附件转传,仍走 {@link HcsPerfRecordSyncSupport};
|
||||
* 本组件收敛其余场景的 C108-1 组装与推送:申请金额=实际金额、履约计划id 取已同步履约计划行合规阶段ID(必填)、
|
||||
* 机制数据传空列表(无附件)、履约记录ID 由调用方按各自粒度持久化。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class HcsPerfRecordAddSupport {
|
||||
|
||||
private final IHcsClient hcsClient;
|
||||
private final HcsProperties hcsProperties;
|
||||
|
||||
/**
|
||||
* C108-1 新建履约记录(通用)
|
||||
*
|
||||
* @param contractManage 合同(须已推送合规,hcsFdId 必有)
|
||||
* @param stageRow 已同步履约计划行(fd_plan_id 必填,无已同步计划行时抛出由调用方重试)
|
||||
* @param applyNo 申请编号(收款=通知单号 / 开票=发票号 / 收票=发票号)
|
||||
* @param amount 申请金额(=实际金额)
|
||||
* @param perfDateMillis 履约日期(毫秒时间戳:收款=交易日期 / 开票=发票完成开票日期 / 收票=发票日期)
|
||||
* @param planType 履约类型:payment 付款 / collection 收款
|
||||
* @param jdDocNo 金蝶单据号(溯源)
|
||||
* @param businessId TMS 业务ID(溯源)
|
||||
* @param title 履约记录标题
|
||||
* @return 推送结果描述
|
||||
*/
|
||||
public String pushPerfRecordAdd(ContractManage contractManage, HcsContractExeStage stageRow, String applyNo,
|
||||
BigDecimal amount, Long perfDateMillis, String planType,
|
||||
String jdDocNo, Long businessId, String title) {
|
||||
// 【必填】fd_plan_id 履约计划id:无已同步计划行时无值可传,抛出由调用方置重试(合同链路首推后自愈)
|
||||
if (stageRow == null || StringUtil.isBlank(stageRow.getHcsStageFdId())) {
|
||||
throw new HcsException("C108 履约记录未匹配到已同步履约计划行,fd_plan_id 必传:applyNo=" + applyNo);
|
||||
}
|
||||
if (StringUtil.isBlank(contractManage.getHcsFdId())) {
|
||||
throw new HcsException("C108 履约记录推送失败,合同尚未推送合规:contractNo=" + contractManage.getContractNo());
|
||||
}
|
||||
// 表单模板ID(配置 perf-record.xform-id,环境值 §9-U9,未配置不传)
|
||||
String xformId = StringUtil.isBlank(hcsProperties.getPerfRecord().getXformId()) ? null : hcsProperties.getPerfRecord().getXformId();
|
||||
HcsPerfRecordPropsDTO props = new HcsPerfRecordPropsDTO();
|
||||
// 申请金额 / 实际金额:接口按字符串传值
|
||||
props.setFdApplyAmount(amount == null ? null : amount.toPlainString());
|
||||
props.setFdFactAmount(amount == null ? null : amount.toPlainString());
|
||||
// 【必填】申请编号
|
||||
props.setFdApplyNo(applyNo);
|
||||
// 【必填】履约日期(毫秒时间戳)
|
||||
props.setFdPlanDate(perfDateMillis);
|
||||
// 履约类型(原文拼写 fd_paln_type 必须沿用)
|
||||
props.setFdPalnType(planType);
|
||||
// 性质:取关联履约计划行的款项性质
|
||||
props.setFdProperty(stageRow.getFundNature());
|
||||
// 履约计划id / 来源履约计划项ID:履约计划行合规阶段ID(HCS 只认自己的阶段ID)
|
||||
props.setFdId(stageRow.getHcsStageFdId());
|
||||
props.setFdPlanId(stageRow.getHcsStageFdId());
|
||||
// 金蝶单据号(溯源用)
|
||||
props.setFdJdDocNo(jdDocNo);
|
||||
// 【必填】合同ID:HCS 合同ID
|
||||
props.setFdContractId(contractManage.getHcsFdId());
|
||||
// 业务ID / 创建时间(溯源)
|
||||
props.setFdBusinessId(businessId == null ? null : String.valueOf(businessId));
|
||||
props.setFdCreateTime(System.currentTimeMillis());
|
||||
props.setFdXformId(xformId);
|
||||
HcsPerfRecordFormDataDTO formData = new HcsPerfRecordFormDataDTO();
|
||||
formData.setFdXformId(xformId);
|
||||
formData.setFdDocSubject(title);
|
||||
formData.setDynamicProps(props);
|
||||
// 机制数据(C108-1 必填):无附件传空列表
|
||||
HcsMechanismsDTO mechanisms = new HcsMechanismsDTO();
|
||||
mechanisms.setAttachment(List.of());
|
||||
HcsPerfRecordSaveDTO param = new HcsPerfRecordSaveDTO();
|
||||
param.setCurUser(HcsUserRefVO.ofLoginName(hcsProperties.getOperatorLoginName()));
|
||||
param.setFormData(formData);
|
||||
param.setMechanisms(mechanisms);
|
||||
log.info("HCS履约记录新建开始:title={},applyNo={},金额={}", title, applyNo, amount);
|
||||
HcsResult<HcsFormSaveVO> result = hcsClient.perfRecordAdd(param);
|
||||
if (result == null || !result.isSuccess() || result.getData() == null || StringUtil.isBlank(result.getData().getFdId())) {
|
||||
log.error("HCS履约记录新建失败响应:{},title={},applyNo={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), title, applyNo);
|
||||
throw new HcsException("C108-1 履约记录新建失败:" + (result == null ? "响应为空" : result.getMsg()));
|
||||
}
|
||||
log.info("HCS履约记录新建成功:title={},applyNo={},perfFdId={}", title, applyNo, result.getData().getFdId());
|
||||
return result.getData().getFdId();
|
||||
}
|
||||
}
|
||||
+18
-8
@@ -58,7 +58,7 @@ import java.util.List;
|
||||
* 付款申请为主线:审批通过 → C108-1 新增(返回 fdId 回填 hcs_perf_fd_id);
|
||||
* 实际付款推进(paidAmount 变化置待同步)→ C108-2 更新(formData.fdId=hcs_perf_fd_id 必填,响应无 data)。
|
||||
* 关联履约阶段:按合同+付款类型匹配已同步的履约规则行(付款/收款执行与计划阶段对应),
|
||||
* 无关联则不传 fd_id(§5.6)。
|
||||
* fd_plan_id(履约计划id)接口必填,无已同步计划行时抛出重试(合同链路首推后自愈)。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@@ -149,6 +149,10 @@ public class HcsPerfRecordSyncSupport {
|
||||
private HcsPerfRecordFormDataDTO buildFormData(PaymentApplication payment) {
|
||||
ContractManage contractManage = payment.getContractId() == null ? null : contractManageMapper.selectById(payment.getContractId());
|
||||
HcsContractExeStage stageRow = findPaymentStageRow(payment);
|
||||
// 【必填】fd_plan_id 履约计划id:无已同步计划行时无值可传,抛出由付款链置 failed 重试(合同链路首推后下轮自愈)
|
||||
if (stageRow == null || StringUtil.isBlank(stageRow.getHcsStageFdId())) {
|
||||
throw new HcsException("C108 履约记录未匹配到已同步履约计划行,fd_plan_id 必传:paymentNo=" + payment.getPaymentNo());
|
||||
}
|
||||
HcsPerfRecordFormDataDTO formData = new HcsPerfRecordFormDataDTO();
|
||||
HcsPerfRecordPropsDTO props = new HcsPerfRecordPropsDTO();
|
||||
// 申请金额:appliedAmount(接口按字符串传值)
|
||||
@@ -159,12 +163,15 @@ public class HcsPerfRecordSyncSupport {
|
||||
props.setFdApplyNo(payment.getPaymentNo());
|
||||
// 【必填】计划日期:applyDate(毫秒时间戳)
|
||||
props.setFdPlanDate(toMillis(payment.getApplyDate()));
|
||||
// 计划类型:paymentType(原文拼写 fd_paln_type 必须沿用)
|
||||
props.setFdPalnType(payment.getPaymentType());
|
||||
// 性质:款项性质取关联履约规则行的款项性质(无关联不传)
|
||||
props.setFdProperty(stageRow == null ? null : stageRow.getFundNature());
|
||||
// 来源履约计划项ID:关联履约阶段 hcs_stage_fd_id(无关联则不传,§5.6)
|
||||
props.setFdId(stageRow == null ? null : stageRow.getHcsStageFdId());
|
||||
// 计划类型:履约记录类型按场景传中文「付款」(原文拼写 fd_paln_type 必须沿用)
|
||||
props.setFdPalnType(HcsSyncConstant.PERF_TYPE_PAYMENT);
|
||||
// 性质:款项性质取关联履约计划行的款项性质
|
||||
props.setFdProperty(stageRow.getFundNature());
|
||||
// 履约计划id / 来源履约计划项ID:履约计划行合规阶段ID(HCS 只认自己的阶段ID)
|
||||
props.setFdId(stageRow.getHcsStageFdId());
|
||||
props.setFdPlanId(stageRow.getHcsStageFdId());
|
||||
// 金蝶单据号:付款推送金蝶返回的单号(溯源用)
|
||||
props.setFdJdDocNo(payment.getKingdeeBillNo());
|
||||
// 【必填】合同ID:HCS 合同ID(合同未推送时为空,联调确认兜底口径)
|
||||
props.setFdContractId(contractManage == null ? null : contractManage.getHcsFdId());
|
||||
// 业务ID:TMS 付款申请单主键(方便溯源,v4 新增)
|
||||
@@ -176,6 +183,8 @@ public class HcsPerfRecordSyncSupport {
|
||||
props.setFdXformId(xformId);
|
||||
// 【必填】表单模板ID(formData 顶层 fd_xform_id,v4 必填)
|
||||
formData.setFdXformId(xformId);
|
||||
// 标题:付款履约-付款申请单号(参照结算付款特批标题风格)
|
||||
formData.setFdDocSubject("付款履约-" + payment.getPaymentNo());
|
||||
formData.setDynamicProps(props);
|
||||
return formData;
|
||||
}
|
||||
@@ -194,8 +203,9 @@ public class HcsPerfRecordSyncSupport {
|
||||
}
|
||||
HcsMechanismsDTO mechanisms = new HcsMechanismsDTO();
|
||||
mechanisms.setAttachment(attachmentRefs.stream().map(ref -> {
|
||||
// 附件ID(C103-2 转传返回)
|
||||
// 附件ID(C103-2 转传返回);C108-1 为发起类接口,绑定类型固定 ADD
|
||||
ref.setFdEntityKey(PERF_ATTACH_ENTITY_KEY);
|
||||
ref.setFdBindType("ADD");
|
||||
return ref;
|
||||
}).toList());
|
||||
return mechanisms;
|
||||
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.support.hcs;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.tool.utils.StringUtil;
|
||||
import org.springblade.hcs.config.HcsProperties;
|
||||
import org.springblade.hcs.exception.HcsException;
|
||||
import org.springblade.transport.constant.HcsSyncConstant;
|
||||
import org.springblade.transport.mapper.ContractManageMapper;
|
||||
import org.springblade.transport.mapper.FormalSettlementMapper;
|
||||
import org.springblade.transport.mapper.InvoiceReceiptMapper;
|
||||
import org.springblade.transport.mapper.InvoiceReceiptSettlementMapper;
|
||||
import org.springblade.transport.pojo.entity.ContractManage;
|
||||
import org.springblade.transport.pojo.entity.FormalSettlement;
|
||||
import org.springblade.transport.pojo.entity.HcsContractExeStage;
|
||||
import org.springblade.transport.pojo.entity.InvoiceReceipt;
|
||||
import org.springblade.transport.pojo.entity.InvoiceReceiptSettlement;
|
||||
import org.springblade.transport.service.IContractExeStageService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* HCS 履约收票记录同步支撑组件(C108-1,需求:收票单每条结算信息对应一条履约收票记录)
|
||||
* <p>
|
||||
* 粒度:收票登记结算单分摊行(blade_invoice_receipt_settlement)对应一条履约收票记录;
|
||||
* 触发:收票单推送合规(C109)成功后,随收票单扫描任务(HcsPushTask invoice-receipt)推送,
|
||||
* 扫描范围限定所属收票单 hcs_sync_status=synced(C109 已推送),失败下轮自动补偿;
|
||||
* 履约日期为发票日期(收票单 invoice_date),履约类型「收票」(关联合同付款履约计划行);
|
||||
* 合同经应付结算链路回溯(结算分摊行 → 正式结算单合同);
|
||||
* 幂等凭 hcs_perf_fd_id(非空跳过),失败置重试计数与原因由下轮扫描自动补偿。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class HcsReceiptPerfSyncSupport {
|
||||
|
||||
private final HcsProperties hcsProperties;
|
||||
private final InvoiceReceiptSettlementMapper invoiceReceiptSettlementMapper;
|
||||
private final InvoiceReceiptMapper invoiceReceiptMapper;
|
||||
private final FormalSettlementMapper formalSettlementMapper;
|
||||
private final ContractManageMapper contractManageMapper;
|
||||
private final IContractExeStageService contractExeStageService;
|
||||
private final HcsPerfRecordAddSupport hcsPerfRecordAddSupport;
|
||||
|
||||
/**
|
||||
* 扫描并推送待同步的履约收票记录(每条结算信息一条 C108-1),返回结果汇总
|
||||
*
|
||||
* @return 汇总描述
|
||||
*/
|
||||
public String pushUnsyncedReceiptPerfRecords() {
|
||||
if (!hcsProperties.getPerfRecord().isSyncEnabled()) {
|
||||
return "履约记录同步开关未开启";
|
||||
}
|
||||
int maxRetry = hcsProperties.getPush().getMaxRetry();
|
||||
// 待推送结算信息:未建过履约记录且重试未超限
|
||||
List<InvoiceReceiptSettlement> settlementList = invoiceReceiptSettlementMapper.selectList(
|
||||
Wrappers.<InvoiceReceiptSettlement>lambdaQuery()
|
||||
.isNull(InvoiceReceiptSettlement::getHcsPerfFdId)
|
||||
.and(wrapper -> wrapper.isNull(InvoiceReceiptSettlement::getHcsPerfRetryCount)
|
||||
.or().lt(InvoiceReceiptSettlement::getHcsPerfRetryCount, maxRetry))
|
||||
.last("limit " + hcsProperties.getPush().getBatchSize()));
|
||||
if (settlementList.isEmpty()) {
|
||||
return "履约收票记录:无待推送结算信息";
|
||||
}
|
||||
// 批量装配上下文(收票单/结算单/合同/计划行,一次 IN 查询避免逐行 N+1)
|
||||
Map<Long, InvoiceReceipt> receiptById = loadReceipts(settlementList);
|
||||
Map<Long, FormalSettlement> settlementById = loadSettlements(settlementList);
|
||||
int successCount = 0;
|
||||
int failCount = 0;
|
||||
for (InvoiceReceiptSettlement settlementLine : settlementList) {
|
||||
try {
|
||||
pushOneSettlementLine(settlementLine, receiptById.get(settlementLine.getInvoiceReceiptId()),
|
||||
settlementById.get(settlementLine.getFormalSettlementId()));
|
||||
successCount++;
|
||||
} catch (Exception exception) {
|
||||
markFailed(settlementLine, StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage());
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
String summary = String.format("履约收票记录:扫描 %d 条,成功 %d 条,失败 %d 条(重试上限 %d)",
|
||||
settlementList.size(), successCount, failCount, maxRetry);
|
||||
if (failCount > 0) {
|
||||
log.warn("HCS履约收票记录推送存在失败:{}", summary);
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送单条结算信息的履约收票记录
|
||||
*/
|
||||
private void pushOneSettlementLine(InvoiceReceiptSettlement settlementLine, InvoiceReceipt receipt, FormalSettlement settlement) {
|
||||
if (receipt == null || !"approved".equals(receipt.getApprovalStatus())) {
|
||||
throw new HcsException("收票单不存在或未审批通过:invoiceReceiptId=" + settlementLine.getInvoiceReceiptId());
|
||||
}
|
||||
// C109 收票单须已推送合规(履约记录引用合规合同与计划行)
|
||||
if (!HcsSyncConstant.HCS_STATUS_SYNCED.equals(receipt.getHcsSyncStatus())) {
|
||||
throw new HcsException("收票单尚未推送合规(invoiceNo=" + receipt.getInvoiceNo() + "),待 C109 推送成功后重试");
|
||||
}
|
||||
if (settlement == null || settlement.getContractId() == null) {
|
||||
throw new HcsException("结算分摊行回溯不到合同(settlementId=" + settlementLine.getFormalSettlementId() + ")");
|
||||
}
|
||||
ContractManage contractManage = contractManageMapper.selectById(settlement.getContractId());
|
||||
if (contractManage == null) {
|
||||
throw new HcsException("合同不存在:contractId=" + settlement.getContractId());
|
||||
}
|
||||
// 关联合同的付款履约计划行(fd_plan_id 必填,无已同步计划行时通用组件抛出重试,合同链路首推后自愈)
|
||||
HcsContractExeStage stageRow = contractExeStageService.findPaymentStageRow(
|
||||
contractManage.getId(), HcsSyncConstant.FUND_NATURE_PROGRESS);
|
||||
// 金额:分摊到该结算单的收票金额
|
||||
BigDecimal amount = settlementLine.getAllocatedInvoiceAmount() == null
|
||||
? settlementLine.getSettlementAmount() : settlementLine.getAllocatedInvoiceAmount();
|
||||
if (amount == null) {
|
||||
throw new HcsException("结算分摊行金额为空(id=" + settlementLine.getId() + ")");
|
||||
}
|
||||
// 申请编号:发票号-结算单号(同一发票分摊多结算单时逐条可区分)
|
||||
String settlementNo = StringUtil.isBlank(settlementLine.getFormalSettlementNo()) ? settlement.getFormalSettlementNo() : settlementLine.getFormalSettlementNo();
|
||||
String applyNo = receipt.getInvoiceNo() + "-" + (settlementNo == null ? "" : settlementNo);
|
||||
String perfFdId = hcsPerfRecordAddSupport.pushPerfRecordAdd(contractManage, stageRow,
|
||||
applyNo, amount, toMillis(receipt.getInvoiceDate()),
|
||||
HcsSyncConstant.PERF_TYPE_INVOICE_RECEIPT, receipt.getKingdeeReceivedInvoiceNo(), settlementLine.getId(),
|
||||
"收票履约-" + applyNo);
|
||||
markSynced(settlementLine, perfFdId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量装配结算信息所属收票单(须审批通过且 C109 已推送)
|
||||
*/
|
||||
private Map<Long, InvoiceReceipt> loadReceipts(List<InvoiceReceiptSettlement> settlementList) {
|
||||
List<Long> receiptIds = settlementList.stream()
|
||||
.map(InvoiceReceiptSettlement::getInvoiceReceiptId).filter(Objects::nonNull).distinct().toList();
|
||||
if (receiptIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
return invoiceReceiptMapper.selectBatchIds(receiptIds).stream()
|
||||
.collect(Collectors.toMap(InvoiceReceipt::getId, item -> item, (first, second) -> first));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量装配结算信息关联的正式结算单
|
||||
*/
|
||||
private Map<Long, FormalSettlement> loadSettlements(List<InvoiceReceiptSettlement> settlementList) {
|
||||
List<Long> settlementIds = settlementList.stream()
|
||||
.map(InvoiceReceiptSettlement::getFormalSettlementId).filter(Objects::nonNull).distinct().toList();
|
||||
if (settlementIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
return formalSettlementMapper.selectBatchIds(settlementIds).stream()
|
||||
.collect(Collectors.toMap(FormalSettlement::getId, item -> item, (first, second) -> first, LinkedHashMap::new));
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送成功回填:履约记录ID 立即持久化(幂等键),清失败原因
|
||||
*/
|
||||
private void markSynced(InvoiceReceiptSettlement settlementLine, String perfFdId) {
|
||||
settlementLine.setHcsPerfFdId(perfFdId);
|
||||
settlementLine.setHcsPerfRetryCount(0);
|
||||
settlementLine.setHcsPerfSyncMsg(null);
|
||||
invoiceReceiptSettlementMapper.updateById(settlementLine);
|
||||
log.info("HCS履约收票记录推送成功:settlementId={},perfFdId={}", settlementLine.getId(), perfFdId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送失败回写:重试次数+1 + 失败原因(下轮扫描自动补偿)
|
||||
*/
|
||||
private void markFailed(InvoiceReceiptSettlement settlementLine, String message) {
|
||||
settlementLine.setHcsPerfRetryCount(settlementLine.getHcsPerfRetryCount() == null ? 1 : settlementLine.getHcsPerfRetryCount() + 1);
|
||||
settlementLine.setHcsPerfSyncMsg(HcsSyncConstant.truncateMsg(message));
|
||||
invoiceReceiptSettlementMapper.updateById(settlementLine);
|
||||
log.error("HCS履约收票记录推送失败:settlementId={},retryCount={},{}", settlementLine.getId(), settlementLine.getHcsPerfRetryCount(), message);
|
||||
}
|
||||
|
||||
private Long toMillis(LocalDate localDate) {
|
||||
return localDate == null ? null : localDate.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli();
|
||||
}
|
||||
}
|
||||
+19
@@ -42,7 +42,9 @@ import org.springblade.transport.constant.HcsSyncConstant;
|
||||
import org.springblade.transport.mapper.ContractManageMapper;
|
||||
import org.springblade.transport.mapper.PaymentApplicationMapper;
|
||||
import org.springblade.transport.pojo.entity.ContractManage;
|
||||
import org.springblade.transport.pojo.entity.HcsContractExeStage;
|
||||
import org.springblade.transport.pojo.entity.PaymentApplication;
|
||||
import org.springblade.transport.service.IContractExeStageService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -74,6 +76,7 @@ public class HcsSpecPaySyncSupport {
|
||||
private final HcsProperties hcsProperties;
|
||||
private final ContractManageMapper contractManageMapper;
|
||||
private final PaymentApplicationMapper paymentApplicationMapper;
|
||||
private final IContractExeStageService contractExeStageService;
|
||||
private final HcsAttachmentSupport hcsAttachmentSupport;
|
||||
|
||||
/**
|
||||
@@ -132,6 +135,8 @@ public class HcsSpecPaySyncSupport {
|
||||
props.setFdDocSubject("结算付款特批-" + payment.getPaymentNo());
|
||||
// 来源付款单ID:付款申请单号(§5.5 映射表)
|
||||
props.setFdId(payment.getPaymentNo());
|
||||
props.setFdSourceSys(hcsProperties.getSrcSystem());
|
||||
|
||||
// 【必填】付款金额:appliedAmount
|
||||
props.setFdPayAmount(payment.getAppliedAmount());
|
||||
// 【必填】合计金额:settlementAmount
|
||||
@@ -156,6 +161,8 @@ public class HcsSpecPaySyncSupport {
|
||||
props.setFdDocStatus(hcsProperties.getSpecPay().getDocStatus());
|
||||
// 特批附件字段标识:固定 fd_spc_approval_attach
|
||||
props.setFdSpcApprovalAttach(SPEC_ATTACH_ENTITY_KEY);
|
||||
// 【必填】款项日期:传履约计划id(匹配付款计划行合规阶段ID,无关联不传,口径同 C108 履约记录)
|
||||
props.setFdContractPlan(this.resolveStageFdId(payment));
|
||||
formData.setDynamicProps(props);
|
||||
// 【必填】表单层单据主题
|
||||
formData.setFdDocSubject("结算付款特批-" + payment.getPaymentNo());
|
||||
@@ -168,6 +175,18 @@ public class HcsSpecPaySyncSupport {
|
||||
return param;
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配合同的付款履约计划行合规阶段ID(无匹配返回 null,DTO NON_NULL 序列化时不传,与 C108 履约记录口径一致)
|
||||
*/
|
||||
private String resolveStageFdId(PaymentApplication payment) {
|
||||
if (payment.getContractId() == null) {
|
||||
return null;
|
||||
}
|
||||
HcsContractExeStage stageRow = this.contractExeStageService.findPaymentStageRow(payment.getContractId(),
|
||||
HcsSyncConstant.fundNatureOfPayment(payment.getPaymentType()));
|
||||
return stageRow == null ? null : stageRow.getHcsStageFdId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 特批附件挂载(附件转传开关关闭或无附件时返回 null)
|
||||
*/
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.task;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.hcs.config.HcsProperties;
|
||||
import org.springblade.transport.support.hcs.HcsCreditCheckSupport;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.cloud.context.config.annotation.RefreshScope;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 客商风险每日定时刷新任务(C101-1)
|
||||
* <p>
|
||||
* 合同期限范围内的生效合同,每日定时查询其甲乙方关联客商的风险等级/黑名单状态并落库
|
||||
* (blade_customer_archive.hcs_risk_level/kingdee_black_status/hcs_risk_time),
|
||||
* 作为客商档案列表「客商风险」列展示与筛选的数据源;查询逐次留痕 blade_hcs_check_log,
|
||||
* 单个失败记录失败原因日志备查。
|
||||
* 业务逻辑收敛在 {@link HcsCreditCheckSupport#refreshCustomerRiskForActiveContracts()},便于手动触发联调。
|
||||
* <p>
|
||||
* 调度配置(Nacos thirdParty.hcs.risk.*):refreshEnabled 开关、refreshCron 表达式。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RefreshScope
|
||||
@RequiredArgsConstructor
|
||||
public class CustomerRiskRefreshTask {
|
||||
|
||||
private final HcsCreditCheckSupport hcsCreditCheckSupport;
|
||||
private final HcsProperties hcsProperties;
|
||||
|
||||
/** 客商风险每日刷新调度,默认每天 02:30 执行(错开金蝶客商同步 02:00) */
|
||||
@Scheduled(cron = "${thirdParty.hcs.risk.refreshCron:0 30 2 * * ?}")
|
||||
public void refreshCustomerRisk() {
|
||||
if (!hcsProperties.getRisk().isRefreshEnabled()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
String summary = hcsCreditCheckSupport.refreshCustomerRiskForActiveContracts();
|
||||
log.info("定时刷新客商风险:{}", summary);
|
||||
} catch (Exception exception) {
|
||||
log.error("定时刷新客商风险异常", exception);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+36
-4
@@ -42,12 +42,16 @@ import org.springblade.transport.pojo.entity.InvoiceApplication;
|
||||
import org.springblade.transport.pojo.entity.InvoiceReceipt;
|
||||
import org.springblade.transport.pojo.entity.PaymentApplication;
|
||||
import org.springblade.transport.pojo.entity.ProjectApply;
|
||||
import org.springblade.transport.service.IContractChangeService;
|
||||
import org.springblade.transport.service.IProjectChangeService;
|
||||
import org.springblade.transport.support.hcs.HcsContractSpecSyncSupport;
|
||||
import org.springblade.transport.support.hcs.HcsContractSyncSupport;
|
||||
import org.springblade.transport.support.hcs.HcsInvoicePerfSyncSupport;
|
||||
import org.springblade.transport.support.hcs.HcsInvoiceRegSyncSupport;
|
||||
import org.springblade.transport.support.hcs.HcsPaymentSyncSupport;
|
||||
import org.springblade.transport.support.hcs.HcsProjectSyncSupport;
|
||||
import org.springblade.transport.support.hcs.HcsReceiptBillSyncSupport;
|
||||
import org.springblade.transport.support.hcs.HcsReceiptPerfSyncSupport;
|
||||
import org.springframework.cloud.context.config.annotation.RefreshScope;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -108,6 +112,10 @@ public class HcsPushTask {
|
||||
private final HcsPaymentSyncSupport hcsPaymentSyncSupport;
|
||||
private final HcsInvoiceRegSyncSupport hcsInvoiceRegSyncSupport;
|
||||
private final HcsReceiptBillSyncSupport hcsReceiptBillSyncSupport;
|
||||
private final HcsInvoicePerfSyncSupport hcsInvoicePerfSyncSupport;
|
||||
private final HcsReceiptPerfSyncSupport hcsReceiptPerfSyncSupport;
|
||||
private final IContractChangeService contractChangeService;
|
||||
private final IProjectChangeService projectChangeService;
|
||||
private final BladeRedis bladeRedis;
|
||||
|
||||
/**
|
||||
@@ -144,19 +152,37 @@ public class HcsPushTask {
|
||||
}
|
||||
|
||||
/**
|
||||
* 开票登记(C111)定时推送:扫描审批通过且待同步/待补偿的开票申请(含金蝶开票完成后回填),独立开关与 cron 调度
|
||||
* 开票登记(C111)定时推送:扫描审批通过且待同步/待补偿的开票申请(含金蝶开票完成后回填),
|
||||
* 并推送履约开票记录(C108-1,金蝶回写发票逐张推送),独立开关与 cron 调度
|
||||
*/
|
||||
@Scheduled(cron = "${thirdParty.hcs.push.invoice-application.cron:0 3/5 * * * ?}")
|
||||
public void pushHcsInvoiceApplication() {
|
||||
this.runOnce("开票登记", HcsSyncConstant.BIZ_TYPE_INVOICE_APPLICATION, hcsProperties.getPush().getInvoiceApplication().isSyncEnabled(), this::pushUnsyncedInvoiceApplications);
|
||||
this.runOnce("开票登记", HcsSyncConstant.BIZ_TYPE_INVOICE_APPLICATION, hcsProperties.getPush().getInvoiceApplication().isSyncEnabled(), this::syncHcsInvoiceApplications);
|
||||
}
|
||||
|
||||
/**
|
||||
* 收票单(C109)定时推送:扫描审批通过且待同步/待补偿的收票单,独立开关与 cron 调度
|
||||
* 开票链路组合动作:开票登记(C111)推送 + 履约开票记录(C108-1)推送(定时任务与手动批量入口共用)
|
||||
*/
|
||||
public String syncHcsInvoiceApplications() {
|
||||
String pushSummary = this.pushUnsyncedInvoiceApplications();
|
||||
return pushSummary + ";" + hcsInvoicePerfSyncSupport.pushUnsyncedInvoicePerfRecords();
|
||||
}
|
||||
|
||||
/**
|
||||
* 收票单(C109)定时推送:扫描审批通过且待同步/待补偿的收票单,并推送履约收票记录
|
||||
* (C108-1,收票单每条结算信息一条,限 C109 已推送),独立开关与 cron 调度
|
||||
*/
|
||||
@Scheduled(cron = "${thirdParty.hcs.push.invoice-receipt.cron:0 4/5 * * * ?}")
|
||||
public void pushHcsInvoiceReceipt() {
|
||||
this.runOnce("收票单", HcsSyncConstant.BIZ_TYPE_INVOICE_RECEIPT, hcsProperties.getPush().getInvoiceReceipt().isSyncEnabled(), this::pushUnsyncedInvoiceReceipts);
|
||||
this.runOnce("收票单", HcsSyncConstant.BIZ_TYPE_INVOICE_RECEIPT, hcsProperties.getPush().getInvoiceReceipt().isSyncEnabled(), this::syncHcsInvoiceReceipts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 收票链路组合动作:收票单(C109)推送 + 履约收票记录(C108-1)推送(定时任务与手动批量入口共用)
|
||||
*/
|
||||
public String syncHcsInvoiceReceipts() {
|
||||
String pushSummary = this.pushUnsyncedInvoiceReceipts();
|
||||
return pushSummary + ";" + hcsReceiptPerfSyncSupport.pushUnsyncedReceiptPerfRecords();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,10 +237,13 @@ public class HcsPushTask {
|
||||
for (ProjectApply projectApply : projectList) {
|
||||
try {
|
||||
hcsProjectSyncSupport.pushProject(projectApply);
|
||||
// 推送成功回写原项目最近一条待同步变更单,闭合变更单同步状态(C102-1 首推或 C102-2 更新均覆盖)
|
||||
projectChangeService.writeBackSyncStatus(projectApply.getId(), HcsSyncConstant.HCS_STATUS_SYNCED);
|
||||
successCount++;
|
||||
} catch (Exception exception) {
|
||||
String message = StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage();
|
||||
hcsProjectSyncSupport.markFailed(projectApply, message);
|
||||
projectChangeService.writeBackSyncStatus(projectApply.getId(), HcsSyncConstant.HCS_STATUS_FAILED);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
@@ -242,10 +271,13 @@ public class HcsPushTask {
|
||||
for (ContractManage contractManage : contractList) {
|
||||
try {
|
||||
hcsContractSyncSupport.pushContract(contractManage);
|
||||
// 推送成功回写原合同最近一条待同步变更单,闭合变更单同步状态(C103-1 首推或 C103-11 更新均覆盖)
|
||||
contractChangeService.writeBackSyncStatus(contractManage.getId(), HcsSyncConstant.HCS_STATUS_SYNCED);
|
||||
successCount++;
|
||||
} catch (Exception exception) {
|
||||
String message = StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage();
|
||||
hcsContractSyncSupport.markFailed(contractManage, message);
|
||||
contractChangeService.writeBackSyncStatus(contractManage.getId(), HcsSyncConstant.HCS_STATUS_FAILED);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments from this software for such purposes.
|
||||
* Copyright of this software remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.task;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.transport.config.BfiProperties;
|
||||
import org.springblade.transport.service.IFormalSettlementService;
|
||||
import org.springframework.cloud.context.config.annotation.RefreshScope;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 金蝶冲销状态定时回写任务
|
||||
* <p>
|
||||
* A102-4/A302-4 批量查询已同步结算单对应金蝶旧应付/应收单的 hadwrittenoff(部分冲销视同已冲销),
|
||||
* 回写结算单冲销状态,用于结算单列表展示金蝶旧单是否已冲销。
|
||||
* 业务逻辑收敛在服务方法(分布式锁 + 分批查询 + 批量落库),便于手动触发联调。
|
||||
* <p>
|
||||
* 调度配置(Nacos thirdParty.bfi.writeOffResult.*):syncEnabled 开关、cron 表达式、queryBatchSize 分批大小。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RefreshScope
|
||||
@RequiredArgsConstructor
|
||||
public class KingdeeWriteOffResultTask {
|
||||
|
||||
private final IFormalSettlementService formalSettlementService;
|
||||
private final BfiProperties bfiProperties;
|
||||
|
||||
/** 金蝶冲销状态回写调度,默认每10分钟执行一次(分钟位与付款结果回写错峰) */
|
||||
@Scheduled(cron = "${thirdParty.bfi.writeOffResult.cron:0 4/10 * * * ?}")
|
||||
public void syncKingdeeWriteOffResult() {
|
||||
if (!bfiProperties.getWriteOffResult().isSyncEnabled()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
String summary = formalSettlementService.syncKingdeeWriteOffResult();
|
||||
log.info("定时回写金蝶冲销状态:{}", summary);
|
||||
} catch (Exception exception) {
|
||||
log.error("定时回写金蝶冲销状态异常", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
* is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for their own commercial purposes.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this license agreement and terms of use,
|
||||
* and you expressly agree to be bound by the terms of use of this software.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED
|
||||
* WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
|
||||
* FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.task;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.transport.config.BfiProperties;
|
||||
import org.springblade.transport.service.IReceiptFlowService;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 收款认领推送金蝶定时任务(A301-3 收款认领结果推送)
|
||||
* <p>
|
||||
* 认领动作只在本地登记,本任务扫描「认领完成且未推送过」的收款流水自动推送金蝶,
|
||||
* 与收款流水页「推送金蝶」手动按钮共用同一服务方法(推送前按认领人重查 A301-2 最新公告对账)。
|
||||
* 单条流水推送失败独立消化、可重试,不影响同批其他流水。
|
||||
* <p>
|
||||
* 调度配置(Nacos thirdParty.bfi.receiptClaimPush.*):syncEnabled 开关、cron 表达式。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ReceiptClaimPushTask {
|
||||
|
||||
private final IReceiptFlowService receiptFlowService;
|
||||
private final BfiProperties bfiProperties;
|
||||
|
||||
/** 收款认领推送调度,默认每10分钟执行一次 */
|
||||
@Scheduled(cron = "${thirdParty.bfi.receiptClaimPush.cron:0 4/10 * * * ?}")
|
||||
public void pushClaimToKingdee() {
|
||||
if (!bfiProperties.getReceiptClaimPush().isSyncEnabled()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
String summary = receiptFlowService.pushClaimByTask();
|
||||
log.info("定时推送收款认领:{}", summary);
|
||||
} catch (Exception exception) {
|
||||
log.error("定时推送收款认领异常", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-4
@@ -23,12 +23,11 @@
|
||||
package org.springblade.transport.wrapper;
|
||||
|
||||
import org.springblade.core.mp.support.BaseEntityWrapper;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.utils.BeanUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.cache.UserCache;
|
||||
import org.springblade.transport.pojo.entity.CommonCargo;
|
||||
import org.springblade.transport.pojo.vo.CommonCargoVO;
|
||||
import org.springblade.transport.support.TransportBusinessSupport;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Objects;
|
||||
@@ -52,8 +51,7 @@ public class CommonCargoWrapper extends BaseEntityWrapper<CommonCargo, CommonCar
|
||||
}
|
||||
commonCargoVO.setCreateUserName(UserCache.getUserRealName(commonCargo.getCreateUser()));
|
||||
commonCargoVO.setUpdateUserName(UserCache.getUserRealName(commonCargo.getUpdateUser()));
|
||||
Long currentDeptId = Func.firstLong(AuthUtil.getDeptId());
|
||||
commonCargoVO.setReadonly(currentDeptId != null && !Objects.equals(commonCargo.getDeptId(), currentDeptId));
|
||||
commonCargoVO.setReadonly(TransportBusinessSupport.isDeptReadonly(commonCargo.getDeptId(), TransportBusinessSupport.MENU_COMMON_CARGO));
|
||||
return commonCargoVO;
|
||||
}
|
||||
|
||||
|
||||
+2
-4
@@ -23,12 +23,11 @@
|
||||
package org.springblade.transport.wrapper;
|
||||
|
||||
import org.springblade.core.mp.support.BaseEntityWrapper;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.utils.BeanUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.cache.UserCache;
|
||||
import org.springblade.transport.pojo.entity.CommonRoute;
|
||||
import org.springblade.transport.pojo.vo.CommonRouteVO;
|
||||
import org.springblade.transport.support.TransportBusinessSupport;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
@@ -48,8 +47,7 @@ public class CommonRouteWrapper extends BaseEntityWrapper<CommonRoute, CommonRou
|
||||
CommonRouteVO commonRouteVO = Objects.requireNonNull(BeanUtil.copyProperties(commonRoute, CommonRouteVO.class));
|
||||
commonRouteVO.setCreateUserName(UserCache.getUserRealName(commonRoute.getCreateUser()));
|
||||
commonRouteVO.setUpdateUserName(UserCache.getUserRealName(commonRoute.getUpdateUser()));
|
||||
Long currentDeptId = Func.firstLong(AuthUtil.getDeptId());
|
||||
commonRouteVO.setReadonly(currentDeptId != null && !Objects.equals(commonRoute.getDeptId(), currentDeptId));
|
||||
commonRouteVO.setReadonly(TransportBusinessSupport.isDeptReadonly(commonRoute.getDeptId(), TransportBusinessSupport.MENU_COMMON_ROUTE));
|
||||
return commonRouteVO;
|
||||
}
|
||||
|
||||
|
||||
+2
-4
@@ -5,12 +5,11 @@
|
||||
package org.springblade.transport.wrapper;
|
||||
|
||||
import org.springblade.core.mp.support.BaseEntityWrapper;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.utils.BeanUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.cache.UserCache;
|
||||
import org.springblade.transport.pojo.entity.LoadingManage;
|
||||
import org.springblade.transport.pojo.vo.LoadingManageVO;
|
||||
import org.springblade.transport.support.TransportBusinessSupport;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Objects;
|
||||
@@ -31,8 +30,7 @@ public class LoadingManageWrapper extends BaseEntityWrapper<LoadingManage, Loadi
|
||||
LoadingManageVO loadingManageVO = Objects.requireNonNull(BeanUtil.copyProperties(loadingManage, LoadingManageVO.class));
|
||||
loadingManageVO.setCreateUserName(UserCache.getUserRealName(loadingManage.getCreateUser()));
|
||||
loadingManageVO.setUpdateUserName(UserCache.getUserRealName(loadingManage.getUpdateUser()));
|
||||
Long currentDeptId = Func.firstLong(AuthUtil.getDeptId());
|
||||
loadingManageVO.setReadonly(currentDeptId != null && !Objects.equals(loadingManage.getDeptId(), currentDeptId));
|
||||
loadingManageVO.setReadonly(TransportBusinessSupport.isDeptReadonly(loadingManage.getDeptId(), TransportBusinessSupport.MENU_LOADING_MANAGE));
|
||||
loadingManageVO.setBusinessStatusName(businessStatusName(loadingManage.getBusinessStatus()));
|
||||
// 未维护里程的占位值 -1 对外展示为空
|
||||
if (loadingManageVO.getMileage() != null
|
||||
|
||||
+2
-4
@@ -23,12 +23,11 @@
|
||||
package org.springblade.transport.wrapper;
|
||||
|
||||
import org.springblade.core.mp.support.BaseEntityWrapper;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.utils.BeanUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.cache.UserCache;
|
||||
import org.springblade.transport.pojo.entity.ProcessConfig;
|
||||
import org.springblade.transport.pojo.vo.ProcessConfigVO;
|
||||
import org.springblade.transport.support.TransportBusinessSupport;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
@@ -48,8 +47,7 @@ public class ProcessConfigWrapper extends BaseEntityWrapper<ProcessConfig, Proce
|
||||
ProcessConfigVO processConfigVO = Objects.requireNonNull(BeanUtil.copyProperties(processConfig, ProcessConfigVO.class));
|
||||
processConfigVO.setCreateUserName(UserCache.getUserRealName(processConfig.getCreateUser()));
|
||||
processConfigVO.setUpdateUserName(UserCache.getUserRealName(processConfig.getUpdateUser()));
|
||||
Long currentDeptId = Func.firstLong(AuthUtil.getDeptId());
|
||||
processConfigVO.setReadonly(currentDeptId != null && !Objects.equals(processConfig.getDeptId(), currentDeptId));
|
||||
processConfigVO.setReadonly(TransportBusinessSupport.isDeptReadonly(processConfig.getDeptId(), TransportBusinessSupport.MENU_PROCESS_CONFIG));
|
||||
processConfigVO.setStatusName(statusName(processConfig.getStatus()));
|
||||
return processConfigVO;
|
||||
}
|
||||
|
||||
+2
-7
@@ -70,15 +70,10 @@ public class ReceiptFlowWrapper extends BaseEntityWrapper<KingdeeReceiptFlow, Re
|
||||
case "failed" -> "处理失败";
|
||||
default -> "未推送";
|
||||
});
|
||||
// 剩余可认领金额 = 可认领上限 − 累计认领金额(上限取公告未认领与收款金额较小值)
|
||||
BigDecimal unclaimedAmount = money(entity.getUnclaimedAmount());
|
||||
// 剩余可认领金额 = 收款金额 − 累计认领金额(认领只登记本地,额度由本地累计认领控制)
|
||||
BigDecimal receiptAmount = money(entity.getReceiptAmount());
|
||||
BigDecimal claimCeiling = unclaimedAmount;
|
||||
if (receiptAmount.compareTo(BigDecimal.ZERO) > 0 && unclaimedAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||
claimCeiling = unclaimedAmount.min(receiptAmount);
|
||||
}
|
||||
BigDecimal claimedAmount = money(entity.getClaimedAmount());
|
||||
vo.setRemainingAmount(claimCeiling.subtract(claimedAmount).max(BigDecimal.ZERO));
|
||||
vo.setRemainingAmount(receiptAmount.subtract(claimedAmount).max(BigDecimal.ZERO));
|
||||
vo.setCreateUserName(UserCache.getUserRealName(entity.getCreateUser()));
|
||||
vo.setUpdateUserName(UserCache.getUserRealName(entity.getUpdateUser()));
|
||||
return vo;
|
||||
|
||||
+2
-4
@@ -23,12 +23,11 @@
|
||||
package org.springblade.transport.wrapper;
|
||||
|
||||
import org.springblade.core.mp.support.BaseEntityWrapper;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.utils.BeanUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.cache.UserCache;
|
||||
import org.springblade.transport.pojo.entity.ShippingTemplate;
|
||||
import org.springblade.transport.pojo.vo.ShippingTemplateVO;
|
||||
import org.springblade.transport.support.TransportBusinessSupport;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
@@ -48,8 +47,7 @@ public class ShippingTemplateWrapper extends BaseEntityWrapper<ShippingTemplate,
|
||||
ShippingTemplateVO shippingTemplateVO = Objects.requireNonNull(BeanUtil.copyProperties(shippingTemplate, ShippingTemplateVO.class));
|
||||
shippingTemplateVO.setCreateUserName(UserCache.getUserRealName(shippingTemplate.getCreateUser()));
|
||||
shippingTemplateVO.setUpdateUserName(UserCache.getUserRealName(shippingTemplate.getUpdateUser()));
|
||||
Long currentDeptId = Func.firstLong(AuthUtil.getDeptId());
|
||||
shippingTemplateVO.setReadonly(currentDeptId != null && !Objects.equals(shippingTemplate.getDeptId(), currentDeptId));
|
||||
shippingTemplateVO.setReadonly(TransportBusinessSupport.isDeptReadonly(shippingTemplate.getDeptId(), TransportBusinessSupport.MENU_SHIPPING_TEMPLATE));
|
||||
return shippingTemplateVO;
|
||||
}
|
||||
|
||||
|
||||
+1
-4
@@ -23,9 +23,7 @@
|
||||
package org.springblade.transport.wrapper;
|
||||
|
||||
import org.springblade.core.mp.support.BaseEntityWrapper;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.utils.BeanUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.cache.UserCache;
|
||||
import org.springblade.transport.pojo.entity.TransportPlan;
|
||||
import org.springblade.transport.pojo.vo.TransportPlanVO;
|
||||
@@ -50,8 +48,7 @@ public class TransportPlanWrapper extends BaseEntityWrapper<TransportPlan, Trans
|
||||
transportPlanVO.setCreateUserName(UserCache.getUserRealName(transportPlan.getCreateUser()));
|
||||
transportPlanVO.setUpdateUserName(UserCache.getUserRealName(transportPlan.getUpdateUser()));
|
||||
transportPlanVO.setDataSource(TransportBusinessSupport.normalizeTransportPlanDataSource(transportPlan.getDataSource()));
|
||||
Long currentDeptId = Func.firstLong(AuthUtil.getDeptId());
|
||||
transportPlanVO.setReadonly(currentDeptId != null && !Objects.equals(transportPlan.getDeptId(), currentDeptId));
|
||||
transportPlanVO.setReadonly(TransportBusinessSupport.isDeptReadonly(transportPlan.getDeptId(), TransportBusinessSupport.MENU_TRANSPORT_PLAN));
|
||||
transportPlanVO.setBusinessStatusName(businessStatusName(transportPlan.getBusinessStatus()));
|
||||
return transportPlanVO;
|
||||
}
|
||||
|
||||
+1
-3
@@ -23,7 +23,6 @@
|
||||
package org.springblade.transport.wrapper;
|
||||
|
||||
import org.springblade.core.mp.support.BaseEntityWrapper;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.utils.BeanUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.cache.DictBizCache;
|
||||
@@ -54,8 +53,7 @@ public class WaybillWrapper extends BaseEntityWrapper<Waybill, WaybillVO> {
|
||||
waybillVO.setCreateUserName(UserCache.getUserRealName(waybill.getCreateUser()));
|
||||
waybillVO.setUpdateUserName(UserCache.getUserRealName(waybill.getUpdateUser()));
|
||||
waybillVO.setDataSource(TransportBusinessSupport.normalizeWaybillDataSource(waybill.getDataSource()));
|
||||
Long currentDeptId = Func.firstLong(AuthUtil.getDeptId());
|
||||
waybillVO.setReadonly(currentDeptId != null && !Objects.equals(waybill.getDeptId(), currentDeptId));
|
||||
waybillVO.setReadonly(TransportBusinessSupport.isDeptReadonly(waybill.getDeptId(), TransportBusinessSupport.MENU_WAYBILL_MANAGE));
|
||||
String displayStatus = WaybillProcessSupport.normalizeBusinessStatus(
|
||||
waybill.getBusinessStatus(), waybill.getProcessJson(), waybill.getDriverAcceptStatus());
|
||||
waybillVO.setBusinessStatus(displayStatus);
|
||||
|
||||
+3
-1
@@ -19,6 +19,7 @@ import org.springblade.transport.pojo.entity.LoadingManage;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
|
||||
import org.springblade.transport.service.IDriverWaybillService;
|
||||
import org.springblade.transport.service.IMaintenanceRecordService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
@@ -86,7 +87,8 @@ class ExceptionDisposalDetailPayloadTest {
|
||||
loadingManageMapper = mock(LoadingManageMapper.class);
|
||||
service = new ExceptionDisposalServiceImpl(
|
||||
followRecordMapper, waybillService, mock(IDriverWaybillService.class),
|
||||
mock(org.springblade.transport.support.MpUserDataScopeSupport.class), loadingManageMapper);
|
||||
mock(org.springblade.transport.support.MpUserDataScopeSupport.class), loadingManageMapper,
|
||||
mock(IMaintenanceRecordService.class));
|
||||
inject("baseMapper", disposalMapper);
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -20,6 +20,7 @@ import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
|
||||
import org.springblade.transport.service.IDriverWaybillService;
|
||||
import org.springblade.transport.service.IMaintenanceRecordService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
@@ -73,7 +74,8 @@ class ExceptionDisposalListQueryTest {
|
||||
waybillService,
|
||||
mock(IDriverWaybillService.class),
|
||||
scopeSupport,
|
||||
loadingManageMapper);
|
||||
loadingManageMapper,
|
||||
mock(IMaintenanceRecordService.class));
|
||||
inject("baseMapper", disposalMapper);
|
||||
when(disposalMapper.selectPage(any(), any())).thenReturn(new Page<ExceptionDisposal>(1, 10));
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user