Merge remote-tracking branch 'origin/feature/20261019-bfi-all' into feature/hcs-api-p0
# Conflicts: # blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java
This commit is contained in:
-2
@@ -11,7 +11,6 @@ import lombok.AllArgsConstructor;
|
||||
import org.springblade.core.boot.ctrl.BladeController;
|
||||
import org.springblade.core.mp.support.Condition;
|
||||
import org.springblade.core.mp.support.Query;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.api.R;
|
||||
import org.springblade.core.tool.utils.StringUtil;
|
||||
import org.springblade.process.pojo.dto.ApprovalDTO;
|
||||
@@ -49,7 +48,6 @@ public class BusinessProcessController extends BladeController {
|
||||
if (param == null) {
|
||||
param = new ApprovalDTO();
|
||||
}
|
||||
param.setLoginName(AuthUtil.getUserAccount());
|
||||
IPage<ApprovalVO> pages = businessProcessService.queryMkApprovalList(Condition.getPage(query), param);
|
||||
return R.data(pages);
|
||||
}
|
||||
|
||||
+2
-2
@@ -71,7 +71,7 @@ public interface IBusinessProcessService extends IService<BusinessProcess> {
|
||||
* 获取流程当前节点详情
|
||||
*
|
||||
* @param processInstanceId 流程实例id
|
||||
* @param loginName MK登录名(手机号),可为空,为空时按流程发起人解析
|
||||
* @param loginName MK登录名(OA登录名),可为空,为空时按流程发起人解析
|
||||
* @return 当前节点列表
|
||||
*/
|
||||
List<?> getCurrentNodes(String processInstanceId, String loginName);
|
||||
@@ -80,7 +80,7 @@ public interface IBusinessProcessService extends IService<BusinessProcess> {
|
||||
* 获取流程实例详情
|
||||
*
|
||||
* @param processInstanceId 流程实例id
|
||||
* @param loginName MK登录名(手机号),可为空,为空时按流程发起人解析
|
||||
* @param loginName MK登录名(OA登录名),可为空,为空时按流程发起人解析
|
||||
* @return 流程实例详情
|
||||
*/
|
||||
Object getProcessInfo(String processInstanceId, String loginName);
|
||||
|
||||
+21
-27
@@ -132,9 +132,9 @@ public class BusinessProcessServiceImpl extends ServiceImpl<BusinessProcessMappe
|
||||
public String processSubmit(MKProcessCreateDTO param) {
|
||||
AssertUtils.notNull(param, "提交流程参数不能为空");
|
||||
AssertUtils.notBlank(param.getTemplateCode(), "模板编码不能为空");
|
||||
// 前端拿到的手机号可能经 @Sensitive 脱敏(如 137****8880),这里从库取真实手机号覆盖
|
||||
String loginName = resolveCurrentUserPhone();
|
||||
AssertUtils.notBlank(loginName, "当前用户手机号为空,无法提交审核流");
|
||||
// MK loginName 使用当前用户 OA 登录名,未配置则不允许提交
|
||||
String loginName = resolveCurrentUserOaLoginId();
|
||||
AssertUtils.notBlank(loginName, "当前用户OA登录名为空,无法提交审核流");
|
||||
param.setSubmitIdentity(loginName);
|
||||
param.setLoginName(loginName);
|
||||
String bizType = param.getBizType();
|
||||
@@ -258,7 +258,7 @@ public class BusinessProcessServiceImpl extends ServiceImpl<BusinessProcessMappe
|
||||
resolvedLoginName = businessProcess.getPromoterLoginName();
|
||||
}
|
||||
if (StringUtil.isBlank(resolvedLoginName)) {
|
||||
resolvedLoginName = resolveCurrentUserPhone();
|
||||
resolvedLoginName = resolveCurrentUserOaLoginId();
|
||||
}
|
||||
if (StringUtil.isBlank(resolvedLoginName)) {
|
||||
return false;
|
||||
@@ -291,10 +291,10 @@ public class BusinessProcessServiceImpl extends ServiceImpl<BusinessProcessMappe
|
||||
}
|
||||
String loginName = businessProcess.getPromoterLoginName();
|
||||
if (StringUtil.isBlank(loginName)) {
|
||||
loginName = resolveCurrentUserPhone();
|
||||
loginName = resolveCurrentUserOaLoginId();
|
||||
}
|
||||
if (StringUtil.isBlank(loginName)) {
|
||||
log.warn("查询MK流程实例跳过,登录名为空 bizId={} processInstanceId={}",
|
||||
log.warn("查询MK流程实例跳过,OA登录名为空 bizId={} processInstanceId={}",
|
||||
bizId, businessProcess.getProcessInstanceId());
|
||||
return false;
|
||||
}
|
||||
@@ -357,9 +357,9 @@ public class BusinessProcessServiceImpl extends ServiceImpl<BusinessProcessMappe
|
||||
}
|
||||
String loginName = businessProcess.getPromoterLoginName();
|
||||
if (StringUtil.isBlank(loginName)) {
|
||||
loginName = resolveCurrentUserPhone();
|
||||
loginName = resolveCurrentUserOaLoginId();
|
||||
}
|
||||
AssertUtils.notBlank(loginName, "当前用户手机号为空,无法删除审核流");
|
||||
AssertUtils.notBlank(loginName, "当前用户OA登录名为空,无法删除审核流");
|
||||
log.info("调用mk processDelete processInstanceId={} loginName={} bizId={} formInstanceId={}",
|
||||
businessProcess.getProcessInstanceId(), loginName, bizId, businessProcess.getFormInstanceId());
|
||||
boolean deleted = mkService.processDelete(businessProcess.getProcessInstanceId(), loginName);
|
||||
@@ -563,27 +563,18 @@ public class BusinessProcessServiceImpl extends ServiceImpl<BusinessProcessMappe
|
||||
}
|
||||
|
||||
/**
|
||||
* 从当前登录用户实体读取真实手机号(库内 SM2 密文先解密,绕过接口脱敏)
|
||||
* 从当前登录用户实体读取 OA 登录名,作为 MK 接口 loginName
|
||||
*/
|
||||
private String resolveCurrentUserPhone() {
|
||||
private String resolveCurrentUserOaLoginId() {
|
||||
Long userId = AuthUtil.getUserId();
|
||||
if (userId != null) {
|
||||
User user = userService.getById(userId);
|
||||
if (user != null) {
|
||||
userService.decryptPhone(user);
|
||||
if (StringUtil.isNotBlank(user.getPhone()) && !user.getPhone().contains("*")) {
|
||||
return user.getPhone().trim();
|
||||
}
|
||||
if (StringUtil.isNotBlank(user.getAccount()) && user.getAccount().matches("^1\\d{10}$")) {
|
||||
return user.getAccount().trim();
|
||||
}
|
||||
}
|
||||
if (userId == null) {
|
||||
return null;
|
||||
}
|
||||
String account = AuthUtil.getUserAccount();
|
||||
if (StringUtil.isNotBlank(account) && account.matches("^1\\d{10}$")) {
|
||||
return account.trim();
|
||||
User user = userService.getById(userId);
|
||||
if (user == null || StringUtil.isBlank(user.getOaLoginId())) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
return user.getOaLoginId().trim();
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@@ -703,8 +694,8 @@ public class BusinessProcessServiceImpl extends ServiceImpl<BusinessProcessMappe
|
||||
processInstanceId = businessProcess.getProcessInstanceId();
|
||||
}
|
||||
try {
|
||||
String loginName = resolveCurrentUserPhone();
|
||||
AssertUtils.notBlank(loginName, "当前用户手机号为空,无法获取审核跳转地址");
|
||||
String loginName = resolveCurrentUserOaLoginId();
|
||||
AssertUtils.notBlank(loginName, "当前用户OA登录名为空,无法获取审核跳转地址");
|
||||
String mkApprovalUrl = mkService.getMKApprovalUrl(processInstanceId, loginName);
|
||||
AssertUtils.notBlank(mkApprovalUrl, "获取mk审批页面链接异常");
|
||||
return mkApprovalUrl;
|
||||
@@ -921,6 +912,9 @@ public class BusinessProcessServiceImpl extends ServiceImpl<BusinessProcessMappe
|
||||
|
||||
@Override
|
||||
public IPage<ApprovalVO> queryMkApprovalList(IPage<ApprovalVO> page, ApprovalDTO param) {
|
||||
String loginName = resolveCurrentUserOaLoginId();
|
||||
AssertUtils.notBlank(loginName, "当前用户OA登录名为空,无法查询审核列表");
|
||||
param.setLoginName(loginName);
|
||||
if (MKDoc.RELATED.getCode().equals(param.getDocType())) {
|
||||
// 我参与的,调用流程列表接口
|
||||
MKProcessDTO processParam = approvalConvert.dto2mk(param);
|
||||
|
||||
+4
-3
@@ -204,7 +204,7 @@ public class DeptController extends BladeController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从OA按页同步部门(需先完成公司同步)
|
||||
* 从OA按页同步部门(需先完成公司同步;按公司 oaId 轮询查询)
|
||||
*/
|
||||
@IsAdmin
|
||||
@PostMapping("/sync-oa-department")
|
||||
@@ -214,8 +214,9 @@ public class DeptController extends BladeController {
|
||||
@RequestParam(defaultValue = "1") Integer current,
|
||||
@RequestParam(defaultValue = "20") Integer size,
|
||||
@RequestParam(defaultValue = "false") Boolean incremental,
|
||||
@RequestParam(required = false) String modifiedSince) {
|
||||
return R.data(oaSyncService.syncDepartmentPage(current, size, Boolean.TRUE.equals(incremental), modifiedSince));
|
||||
@RequestParam(required = false) String modifiedSince,
|
||||
@RequestParam(required = false) String companyOaId) {
|
||||
return R.data(oaSyncService.syncDepartmentPage(current, size, Boolean.TRUE.equals(incremental), modifiedSince, companyOaId));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+2
-2
@@ -49,8 +49,8 @@ public interface DeptConvert {
|
||||
@Mapping(source = "modified", target = "oaModified")
|
||||
Dept baseConvert(OACompanyResponse company);
|
||||
|
||||
@Mapping(source = "departmentname", target = "deptName")
|
||||
@Mapping(source = "departmentmark", target = "fullName")
|
||||
@Mapping(source = "departmentmark", target = "deptName")
|
||||
@Mapping(source = "departmentname", target = "fullName")
|
||||
@Mapping(source = "supdepid", target = "parentCode")
|
||||
@Mapping(source = "subcompanyid1", target = "belongCompanyCode")
|
||||
@Mapping(source = "created", target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss")
|
||||
|
||||
+5
-3
@@ -70,6 +70,7 @@ public interface UserConvert {
|
||||
@Mapping(source = "lastname", target = "realName")
|
||||
@Mapping(source = "mobile", target = "phone")
|
||||
@Mapping(source = "email", target = "email")
|
||||
@Mapping(source = "loginid", target = "oaLoginId")
|
||||
@Mapping(source = "modified", target = "oaModified")
|
||||
User baseConvert(OAPersonResponse person);
|
||||
|
||||
@@ -147,7 +148,7 @@ public interface UserConvert {
|
||||
/**
|
||||
* oa人员转本系统用户部门
|
||||
* @param person
|
||||
* @param userMap 所有用户手机号与id map
|
||||
* @param userMap OA登录名与用户id
|
||||
* @return
|
||||
*/
|
||||
default UserDept person2userDept(OAPersonResponse person, Map<String, Long> userMap) {
|
||||
@@ -161,8 +162,9 @@ public interface UserConvert {
|
||||
userDept.setStatus(status);
|
||||
// 排序
|
||||
userDept.setSort(OAUtils.parseInt(person.getDsporder()));
|
||||
// 用户id
|
||||
userDept.setUserId(userMap.get(resolveAccount(person)));
|
||||
// 用户id,按 OA 登录名关联本批已新增或更新的用户
|
||||
String oaLoginId = person == null ? null : StringUtils.trimToNull(person.getLoginid());
|
||||
userDept.setUserId(oaLoginId == null ? null : userMap.get(oaLoginId));
|
||||
userDept.setSyncTime(new Date());
|
||||
return userDept;
|
||||
}
|
||||
|
||||
+3
-2
@@ -32,6 +32,7 @@ import org.springblade.core.tool.api.R;
|
||||
import org.springblade.system.pojo.entity.*;
|
||||
import org.springblade.system.service.*;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
@@ -218,9 +219,9 @@ public class SysClient implements ISysClient {
|
||||
|
||||
@Override
|
||||
@GetMapping(FEE_ITEMS)
|
||||
public R<List<FeeItem>> getFeeItems() {
|
||||
public R<List<FeeItem>> getFeeItems(@RequestParam(value = "includeDisabled", required = false) Boolean includeDisabled) {
|
||||
return R.data(feeItemService.list(Wrappers.<FeeItem>lambdaQuery()
|
||||
.eq(FeeItem::getStatus, 1)
|
||||
.eq(!Boolean.TRUE.equals(includeDisabled), FeeItem::getStatus, 1)
|
||||
.eq(FeeItem::getIsDeleted, 0)
|
||||
.orderByAsc(FeeItem::getFeeCategory, FeeItem::getName)));
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<result column="dept_code" property="deptCode"/>
|
||||
<result column="kingdee_dept_code" property="kingdeeDeptCode"/>
|
||||
<result column="contract_code_prefix" property="contractCodePrefix"/>
|
||||
<result column="contract_initial_serial" property="contractInitialSerial"/>
|
||||
<result column="short_name" property="shortName"/>
|
||||
<result column="pinyin_mnemonic" property="pinyinMnemonic"/>
|
||||
<result column="mnemonic_code" property="mnemonicCode"/>
|
||||
@@ -36,6 +37,7 @@
|
||||
<result column="dept_code" property="deptCode"/>
|
||||
<result column="kingdee_dept_code" property="kingdeeDeptCode"/>
|
||||
<result column="contract_code_prefix" property="contractCodePrefix"/>
|
||||
<result column="contract_initial_serial" property="contractInitialSerial"/>
|
||||
<result column="short_name" property="shortName"/>
|
||||
<result column="pinyin_mnemonic" property="pinyinMnemonic"/>
|
||||
<result column="mnemonic_code" property="mnemonicCode"/>
|
||||
|
||||
+20
@@ -34,6 +34,26 @@ import java.util.List;
|
||||
*/
|
||||
public interface MeasurementUnitMapper extends BaseMapper<MeasurementUnit> {
|
||||
|
||||
/**
|
||||
* 按编码查询计量单位(包含逻辑删除记录,用于新增恢复)。
|
||||
*/
|
||||
MeasurementUnit selectByUnitCodeIncludingDeleted(@Param("unitCode") String unitCode);
|
||||
|
||||
/**
|
||||
* 按名称查询计量单位(包含逻辑删除记录,用于新增恢复)。
|
||||
*/
|
||||
MeasurementUnit selectByUnitNameIncludingDeleted(@Param("unitName") String unitName);
|
||||
|
||||
/**
|
||||
* 恢复逻辑删除计量单位。
|
||||
*/
|
||||
int restoreById(@Param("id") Long id);
|
||||
|
||||
/**
|
||||
* 释放已软删除记录占用的单位名称,避免唯一索引冲突。
|
||||
*/
|
||||
int releaseUnitName(@Param("id") Long id, @Param("unitName") String unitName);
|
||||
|
||||
/**
|
||||
* 自定义分页
|
||||
*
|
||||
|
||||
+28
@@ -19,6 +19,34 @@
|
||||
<result column="remark" property="remark"/>
|
||||
</resultMap>
|
||||
|
||||
<select id="selectByUnitCodeIncludingDeleted" resultType="org.springblade.system.pojo.entity.MeasurementUnit">
|
||||
SELECT *
|
||||
FROM blade_measurement_unit
|
||||
WHERE unit_code = #{unitCode}
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectByUnitNameIncludingDeleted" resultType="org.springblade.system.pojo.entity.MeasurementUnit">
|
||||
SELECT *
|
||||
FROM blade_measurement_unit
|
||||
WHERE unit_name = #{unitName}
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<update id="restoreById">
|
||||
UPDATE blade_measurement_unit
|
||||
SET is_deleted = 0
|
||||
WHERE id = #{id}
|
||||
AND is_deleted = 1
|
||||
</update>
|
||||
|
||||
<update id="releaseUnitName">
|
||||
UPDATE blade_measurement_unit
|
||||
SET unit_name = #{unitName}
|
||||
WHERE id = #{id}
|
||||
AND is_deleted = 1
|
||||
</update>
|
||||
|
||||
<select id="selectMeasurementUnitPage" resultMap="measurementUnitResultMap">
|
||||
SELECT
|
||||
mmu.id,
|
||||
|
||||
+6
-6
@@ -45,13 +45,13 @@ public interface UserMapper extends BaseMapper<User> {
|
||||
/**
|
||||
* 自定义分页
|
||||
*
|
||||
* @param page
|
||||
* @param user
|
||||
* @param deptIdList
|
||||
* @param tenantId
|
||||
* @return
|
||||
* @param page 分页
|
||||
* @param user 查询条件
|
||||
* @param deptId 部门 ID(含自身及子孙,由 SQL FIND_IN_SET 过滤;为空则不过滤部门)
|
||||
* @param tenantId 租户 ID
|
||||
* @return 用户列表
|
||||
*/
|
||||
List<User> selectUserPage(IPage<User> page, @Param("user") User user, @Param("deptIdList") List<Long> deptIdList, @Param("tenantId") String tenantId);
|
||||
List<User> selectUserPage(IPage<User> page, @Param("user") User user, @Param("deptId") Long deptId, @Param("tenantId") String tenantId);
|
||||
|
||||
/**
|
||||
* 获取用户
|
||||
|
||||
+13
-11
@@ -26,6 +26,7 @@
|
||||
<result column="dept_id" property="deptId"/>
|
||||
<result column="post_id" property="postId"/>
|
||||
<result column="remark" property="remark"/>
|
||||
<result column="oa_login_id" property="oaLoginId"/>
|
||||
<result column="oa_person_id" property="oaPersonId"/>
|
||||
</resultMap>
|
||||
|
||||
@@ -61,20 +62,21 @@
|
||||
<if test="user.status!=null and user.status>=0">
|
||||
and bu.status = #{user.status}
|
||||
</if>
|
||||
<if test="deptIdList!=null and deptIdList.size>0">
|
||||
<if test="deptId != null">
|
||||
and bu.id in (
|
||||
SELECT
|
||||
user_id
|
||||
FROM
|
||||
blade_user_dept
|
||||
WHERE
|
||||
status = 1 and is_deleted = 0 and dept_id IN
|
||||
<foreach collection="deptIdList" index="index" item="item" open="(" separator="," close=")">
|
||||
#{item}
|
||||
</foreach>
|
||||
select distinct bud.user_id
|
||||
from blade_user_dept bud
|
||||
where bud.status = 1
|
||||
and bud.is_deleted = 0
|
||||
and bud.dept_id in (
|
||||
select bd.id
|
||||
from blade_dept bd
|
||||
where bd.is_deleted = 0
|
||||
and (bd.id = #{deptId} or find_in_set(#{deptId}, bd.ancestors) > 0)
|
||||
)
|
||||
)
|
||||
</if>
|
||||
ORDER BY bu.id
|
||||
ORDER BY bu.create_time DESC, bu.id DESC
|
||||
</select>
|
||||
|
||||
<select id="getUser" resultMap="userResultMap">
|
||||
|
||||
+3
-3
@@ -149,16 +149,16 @@ public interface IDeptService extends IService<Dept> {
|
||||
boolean removeDept(String ids);
|
||||
|
||||
/**
|
||||
* 清除非保留顶级外的组织;保留 id=135 集团锚点,以及名称为「外部组织」的顶级
|
||||
* 清除非保留顶级外的组织;保留 id=1 集团锚点,以及名称为「外部组织」的顶级
|
||||
*
|
||||
* @return 删除数量
|
||||
*/
|
||||
int clearNonTopDepts();
|
||||
|
||||
/**
|
||||
* 确保双顶级:id=135 集团(编码 GWWL)+ 独立「外部组织」顶级
|
||||
* 确保双顶级:id=1 集团(编码 GWWL)+ 独立「外部组织」顶级
|
||||
*
|
||||
* @return 集团顶级组织(id=135)
|
||||
* @return 集团顶级组织(id=1)
|
||||
*/
|
||||
Dept ensureOaSyncRootDept();
|
||||
|
||||
|
||||
+4
-3
@@ -52,13 +52,14 @@ public interface IOASyncService {
|
||||
OaOrgSyncPageVO syncCompanyPage(int current, int size, boolean incremental, String modifiedSince);
|
||||
|
||||
/**
|
||||
* 按页从 OA 部门接口同步部门;最后一页完成后更新祖级列表
|
||||
* 按页从 OA 部门接口同步部门;须先完成公司同步,再按公司 oaId 轮询查询部门
|
||||
*
|
||||
* @param current 当前页,从 1 开始
|
||||
* @param current 当前页,从 1 开始(相对当前公司)
|
||||
* @param size 每页条数
|
||||
* @param incremental 是否增量同步(按本地最大 oaModified 过滤 OA)
|
||||
* @param modifiedSince 增量起始时间;首屏可空,后续分页回传首屏返回值
|
||||
* @param companyOaId 当前公司 OA 主键;首屏可空(自动取第一家),后续回传
|
||||
* @return 本页同步结果
|
||||
*/
|
||||
OaOrgSyncPageVO syncDepartmentPage(int current, int size, boolean incremental, String modifiedSince);
|
||||
OaOrgSyncPageVO syncDepartmentPage(int current, int size, boolean incremental, String modifiedSince, String companyOaId);
|
||||
}
|
||||
|
||||
+146
-24
@@ -34,6 +34,7 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.common.constant.CommonConstant;
|
||||
import org.springblade.common.constant.DataStatusEnum;
|
||||
import org.springblade.core.cache.utils.CacheUtil;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
@@ -366,7 +367,9 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
|
||||
@Override
|
||||
public List<Dept> getDeptChild(Long deptId) {
|
||||
return baseMapper.selectList(Wrappers.<Dept>query().lambda().like(Dept::getAncestors, deptId));
|
||||
// 必须用 FIND_IN_SET:LIKE '%1%' 会误匹配雪花 ID 中的数字片段,导致子部门集合爆炸
|
||||
return baseMapper.selectList(Wrappers.<Dept>query().lambda()
|
||||
.apply("FIND_IN_SET({0}, ancestors)", String.valueOf(deptId)));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -390,7 +393,7 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public int clearNonTopDepts() {
|
||||
// 同步前清理:保留集团锚点 id=135,以及「外部组织」顶级;其余删除
|
||||
// 同步前清理:保留集团锚点 id=1,以及「外部组织」顶级;其余删除
|
||||
Dept groupRoot = this.ensureOaSyncRootDept();
|
||||
Dept externalRoot = this.ensureExternalOrgTopDept();
|
||||
Set<Long> keepIds = new HashSet<>();
|
||||
@@ -416,6 +419,7 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
public Dept ensureOaSyncRootDept() {
|
||||
this.migrateLegacyOaDeptCategory();
|
||||
this.removeLegacyOaSyncRoots();
|
||||
this.migrateLegacyGroupRootToIdOne();
|
||||
Dept rootDept = this.getById(OAConvertConstant.OA_SYNC_ROOT_DEPT_ID);
|
||||
String tenantId = AuthUtil.getTenantId();
|
||||
if (StringUtil.isBlank(tenantId)) {
|
||||
@@ -447,14 +451,17 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE);
|
||||
} else {
|
||||
boolean wronglyNamedAsExternal = OAConvertConstant.EXTERNAL_ORG_NAME.equals(rootDept.getDeptName());
|
||||
boolean needUpdate = wronglyNamedAsExternal
|
||||
boolean legacyNamed = OAConvertConstant.LEGACY_ROOT_DEPT_NAME.equals(rootDept.getDeptName());
|
||||
boolean needRename = wronglyNamedAsExternal || legacyNamed || StringUtil.isBlank(rootDept.getDeptName())
|
||||
|| !OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME.equals(rootDept.getDeptName());
|
||||
boolean needUpdate = needRename
|
||||
|| !OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE.equals(rootDept.getDeptCode())
|
||||
|| !DeptCategory.GROUP.getCode().equals(rootDept.getDeptCategory())
|
||||
|| !Objects.equals(BladeConstant.TOP_PARENT_ID, rootDept.getParentId());
|
||||
if (needUpdate) {
|
||||
Dept updateParam = new Dept();
|
||||
updateParam.setId(rootDept.getId());
|
||||
if (wronglyNamedAsExternal || StringUtil.isBlank(rootDept.getDeptName())) {
|
||||
if (needRename) {
|
||||
updateParam.setDeptName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME);
|
||||
updateParam.setFullName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME);
|
||||
updateParam.setShortName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME);
|
||||
@@ -468,6 +475,7 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
updateParam.setParentId(BladeConstant.TOP_PARENT_ID);
|
||||
updateParam.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID));
|
||||
updateParam.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID));
|
||||
updateParam.setIsOa(0);
|
||||
this.updateById(updateParam);
|
||||
rootDept.setDeptCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE);
|
||||
rootDept.setBelongCompanyCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE);
|
||||
@@ -475,6 +483,7 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
rootDept.setParentId(BladeConstant.TOP_PARENT_ID);
|
||||
rootDept.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID));
|
||||
rootDept.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID));
|
||||
rootDept.setIsOa(0);
|
||||
log.info("已校正 OA 同步集团顶级 id={} name={} code={} category={}",
|
||||
rootDept.getId(), rootDept.getDeptName(), rootDept.getDeptCode(), rootDept.getDeptCategory());
|
||||
}
|
||||
@@ -484,7 +493,63 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保存在独立的「外部组织」顶级(与 id=135 集团并存,仅保留一个同名顶级)
|
||||
* 将历史集团伪顶级 id=135 迁移到 id=1(子节点改挂,再删除旧锚点)
|
||||
*/
|
||||
private void migrateLegacyGroupRootToIdOne() {
|
||||
Long legacyRootId = OAConvertConstant.LEGACY_OA_SYNC_ROOT_DEPT_ID;
|
||||
Long newRootId = OAConvertConstant.OA_SYNC_ROOT_DEPT_ID;
|
||||
if (Objects.equals(legacyRootId, newRootId)) {
|
||||
return;
|
||||
}
|
||||
Dept legacyRoot = this.getById(legacyRootId);
|
||||
if (legacyRoot == null) {
|
||||
return;
|
||||
}
|
||||
// 仅迁移历史伪顶级;若 id=135 已是真实 OA 公司记录则跳过删除
|
||||
boolean isOaCompanyRecord = StringUtil.isNotBlank(legacyRoot.getOaId())
|
||||
&& DeptCategory.COMPANY.getCode().equals(legacyRoot.getDeptCategory())
|
||||
&& (Objects.equals(legacyRoot.getIsOa(), CommonConstant.YES) || Objects.equals(legacyRoot.getIsOa(), 1));
|
||||
if (isOaCompanyRecord) {
|
||||
log.warn("历史 id={} 已是 OA 同步公司(oaId={}),跳过伪顶级迁移", legacyRootId, legacyRoot.getOaId());
|
||||
return;
|
||||
}
|
||||
Dept newRoot = this.getById(newRootId);
|
||||
String tenantId = AuthUtil.getTenantId();
|
||||
if (StringUtil.isBlank(tenantId)) {
|
||||
tenantId = BladeConstant.ADMIN_TENANT_ID;
|
||||
}
|
||||
if (newRoot == null) {
|
||||
newRoot = new Dept();
|
||||
newRoot.setId(newRootId);
|
||||
newRoot.setTenantId(StringUtil.isNotBlank(legacyRoot.getTenantId()) ? legacyRoot.getTenantId() : tenantId);
|
||||
newRoot.setParentId(BladeConstant.TOP_PARENT_ID);
|
||||
newRoot.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID));
|
||||
newRoot.setDeptName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME);
|
||||
newRoot.setFullName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME);
|
||||
newRoot.setShortName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME);
|
||||
newRoot.setDeptCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE);
|
||||
newRoot.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID));
|
||||
newRoot.setBelongCompanyCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE);
|
||||
newRoot.setDeptCategory(DeptCategory.GROUP.getCode());
|
||||
newRoot.setSort(0);
|
||||
newRoot.setStatus(DataStatusEnum.ENABLE.getCode());
|
||||
newRoot.setIsDeleted(BladeConstant.DB_NOT_DELETED);
|
||||
newRoot.setIsOa(0);
|
||||
newRoot.setIsPlatformCompany(0);
|
||||
newRoot.setSyncTime(new Date());
|
||||
this.save(newRoot);
|
||||
log.info("迁移创建新集团顶级 id={}(来自历史 id={})", newRootId, legacyRootId);
|
||||
}
|
||||
this.update(Wrappers.<Dept>lambdaUpdate()
|
||||
.set(Dept::getParentId, newRootId)
|
||||
.set(Dept::getParentCode, OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE)
|
||||
.eq(Dept::getParentId, legacyRootId));
|
||||
this.removeById(legacyRootId);
|
||||
log.info("已将历史集团顶级 id={} 迁移到 id={},并删除旧锚点", legacyRootId, newRootId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保存在独立的「外部组织」顶级(与 id=1 集团并存,仅保留一个同名顶级)
|
||||
*/
|
||||
private Dept ensureExternalOrgTopDept() {
|
||||
List<Dept> externalTops = this.list(Wrappers.<Dept>lambdaQuery()
|
||||
@@ -540,11 +605,12 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理历史伪顶级(OACROOT);同名「桂物物流集团」仅清理非 id=135 的顶级
|
||||
* 清理历史伪顶级(OACROOT);同名旧集团仅清理非 id=1 的顶级
|
||||
*/
|
||||
private void removeLegacyOaSyncRoots() {
|
||||
List<Dept> legacyRoots = this.list(Wrappers.<Dept>lambdaQuery()
|
||||
.ne(Dept::getId, OAConvertConstant.OA_SYNC_ROOT_DEPT_ID)
|
||||
.ne(Dept::getId, OAConvertConstant.LEGACY_OA_SYNC_ROOT_DEPT_ID)
|
||||
.and(wrapper -> wrapper
|
||||
.eq(Dept::getDeptCode, OAConvertConstant.LEGACY_ROOT_DEPT_CODE)
|
||||
.or(nameWrapper -> nameWrapper
|
||||
@@ -621,6 +687,7 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
}
|
||||
validateDeptCategory(dept);
|
||||
validateDeptCode(dept, parent);
|
||||
validateContractInitialSerial(dept);
|
||||
return saveOrUpdate(dept);
|
||||
}
|
||||
|
||||
@@ -808,6 +875,16 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
}
|
||||
}
|
||||
|
||||
private void validateContractInitialSerial(Dept dept) {
|
||||
Integer serial = dept.getContractInitialSerial();
|
||||
if (serial == null) {
|
||||
return;
|
||||
}
|
||||
if (serial < 1 || serial > 999) {
|
||||
throw new ServiceException("合同初始流水号范围为1到999");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateDeptCode(Dept dept, Dept parent) {
|
||||
String deptCode = dept.getDeptCode();
|
||||
// 组织编码非必填,为空时存 null,避免唯一索引冲突
|
||||
@@ -975,7 +1052,7 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
boolean isCompany = this.isOaCompany(dept);
|
||||
boolean parentExists = isCompany
|
||||
? companyByOaId.containsKey(parentOaId)
|
||||
: departmentByOaId.containsKey(parentOaId) || companyByOaId.containsKey(parentOaId);
|
||||
: this.resolveOaDepartmentParent(dept, parentOaId, companyByOaId, departmentByOaId) != null;
|
||||
// 全量数据整理后仍找不到上级:公司、部门一律删除,不改挂保留
|
||||
if (!parentExists) {
|
||||
deleteIds.add(dept.getId());
|
||||
@@ -999,7 +1076,7 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
}
|
||||
Dept parent = this.isOaCompany(dept)
|
||||
? companyByOaId.get(parentOaId)
|
||||
: Optional.ofNullable(departmentByOaId.get(parentOaId)).orElse(companyByOaId.get(parentOaId));
|
||||
: this.resolveOaDepartmentParent(dept, parentOaId, companyByOaId, departmentByOaId);
|
||||
if (parent != null && deleteIds.contains(parent.getId())) {
|
||||
deleteIds.add(dept.getId());
|
||||
oaCascadeChanged = true;
|
||||
@@ -1111,7 +1188,10 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 OA 上级主键解析本系统父级
|
||||
* 根据 OA 上级主键解析本系统父级。
|
||||
* <p>
|
||||
* OA 公司与部门主键同属数字空间(如公司 id=19「运输事业部」与部门 id=19「投资发展部」),
|
||||
* 解析部门上级时必须按「同属公司」消歧,禁止跨公司误挂。
|
||||
*/
|
||||
private OaParentLink resolveOaParentLink(Dept dept, Map<String, Dept> companyByOaId, Map<String, Dept> departmentByOaId) {
|
||||
String parentOaId = dept.getOaSupSubComId();
|
||||
@@ -1138,21 +1218,7 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
log.warn("部门缺少上级OA主键,跳过层级重建 oaId={} deptId={}", dept.getOaId(), dept.getId());
|
||||
return null;
|
||||
}
|
||||
boolean parentIsDepartment = StringUtil.isNotBlank(dept.getParentCode())
|
||||
&& dept.getParentCode().startsWith(OAConvertConstant.DEPARTMENT_OA_PREFIX);
|
||||
boolean parentIsCompany = StringUtil.isNotBlank(dept.getParentCode())
|
||||
&& dept.getParentCode().startsWith(OAConvertConstant.COMPANY_OA_PREFIX);
|
||||
Dept parent;
|
||||
if (parentIsDepartment) {
|
||||
parent = departmentByOaId.get(parentOaId);
|
||||
} else if (parentIsCompany) {
|
||||
parent = companyByOaId.get(parentOaId);
|
||||
} else {
|
||||
parent = departmentByOaId.get(parentOaId);
|
||||
if (parent == null) {
|
||||
parent = companyByOaId.get(parentOaId);
|
||||
}
|
||||
}
|
||||
Dept parent = this.resolveOaDepartmentParent(dept, parentOaId, companyByOaId, departmentByOaId);
|
||||
if (parent == null || parent.getId() == null || Objects.equals(parent.getId(), dept.getId())) {
|
||||
log.warn("未找到上级组织,跳过层级重建 oaId={} oaSupSubComId={} deptId={}",
|
||||
dept.getOaId(), parentOaId, dept.getId());
|
||||
@@ -1163,6 +1229,62 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
return new OaParentLink(parent.getId(), this.resolveParentCode(parent, prefix, parentOaId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析部门的 OA 上级:优先同公司下的上级部门,其次所属/同号公司;避免公司与部门 oaId 撞号误挂。
|
||||
*/
|
||||
private Dept resolveOaDepartmentParent(Dept dept, String parentOaId,
|
||||
Map<String, Dept> companyByOaId, Map<String, Dept> departmentByOaId) {
|
||||
Dept belongCompany = this.findBelongCompany(dept, companyByOaId);
|
||||
// 根部门:oaSupSubComId 存的是所属公司 OA 主键
|
||||
if (belongCompany != null && StringUtil.equals(parentOaId, belongCompany.getOaId())) {
|
||||
return belongCompany;
|
||||
}
|
||||
Dept parentDept = departmentByOaId.get(parentOaId);
|
||||
if (parentDept != null && this.isSameOaCompanyTree(dept, parentDept, companyByOaId)) {
|
||||
return parentDept;
|
||||
}
|
||||
return companyByOaId.get(parentOaId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按所属公司编码或 OA 主键定位本地公司
|
||||
*/
|
||||
private Dept findBelongCompany(Dept dept, Map<String, Dept> companyByOaId) {
|
||||
if (dept == null || companyByOaId == null || companyByOaId.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (StringUtil.isNotBlank(dept.getBelongCompanyCode())) {
|
||||
for (Dept company : companyByOaId.values()) {
|
||||
if (company != null && StringUtil.equals(company.getDeptCode(), dept.getBelongCompanyCode())) {
|
||||
return company;
|
||||
}
|
||||
}
|
||||
Dept byOaId = companyByOaId.get(dept.getBelongCompanyCode());
|
||||
if (byOaId != null) {
|
||||
return byOaId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断两个组织是否同属一家 OA 公司(用于部门 oaId 与公司 oaId 撞号消歧)
|
||||
*/
|
||||
private boolean isSameOaCompanyTree(Dept left, Dept right, Map<String, Dept> companyByOaId) {
|
||||
if (left == null || right == null) {
|
||||
return false;
|
||||
}
|
||||
if (StringUtil.isNotBlank(left.getBelongCompanyCode()) && StringUtil.isNotBlank(right.getBelongCompanyCode())) {
|
||||
return StringUtil.equals(left.getBelongCompanyCode(), right.getBelongCompanyCode());
|
||||
}
|
||||
Dept leftCompany = this.findBelongCompany(left, companyByOaId);
|
||||
Dept rightCompany = this.isOaCompany(right) ? right : this.findBelongCompany(right, companyByOaId);
|
||||
if (leftCompany == null || rightCompany == null || leftCompany.getId() == null || rightCompany.getId() == null) {
|
||||
return false;
|
||||
}
|
||||
return Objects.equals(leftCompany.getId(), rightCompany.getId());
|
||||
}
|
||||
|
||||
private String resolveParentCode(Dept parent, String prefix, String parentOaId) {
|
||||
if (StringUtil.isNotBlank(parent.getDeptCode())) {
|
||||
return parent.getDeptCode();
|
||||
|
||||
+32
@@ -62,10 +62,42 @@ public class MeasurementUnitServiceImpl extends BaseServiceImpl<MeasurementUnitM
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean submit(MeasurementUnit measurementUnit) {
|
||||
prepare(measurementUnit);
|
||||
prepareSubmitTarget(measurementUnit);
|
||||
validate(measurementUnit);
|
||||
return saveOrUpdate(measurementUnit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码或名称已存在且为软删除时:取消软删除并复用原主键,后续按更新落库;
|
||||
* 存在且未删除时仍由唯一校验提示「已存在」。
|
||||
*/
|
||||
private void prepareSubmitTarget(MeasurementUnit measurementUnit) {
|
||||
if (Func.isNotEmpty(measurementUnit.getId())) {
|
||||
return;
|
||||
}
|
||||
MeasurementUnit existingByCode = baseMapper.selectByUnitCodeIncludingDeleted(measurementUnit.getUnitCode());
|
||||
MeasurementUnit existingByName = baseMapper.selectByUnitNameIncludingDeleted(measurementUnit.getUnitName());
|
||||
MeasurementUnit toRestore = null;
|
||||
if (existingByCode != null && Objects.equals(existingByCode.getIsDeleted(), 1)) {
|
||||
toRestore = existingByCode;
|
||||
} else if (existingByName != null && Objects.equals(existingByName.getIsDeleted(), 1)
|
||||
&& (existingByCode == null || Objects.equals(existingByCode.getId(), existingByName.getId()))) {
|
||||
toRestore = existingByName;
|
||||
}
|
||||
if (toRestore == null) {
|
||||
return;
|
||||
}
|
||||
// 复用编码恢复时,若另一条软删除记录占用了目标名称,先释放名称唯一索引
|
||||
if (existingByName != null && Objects.equals(existingByName.getIsDeleted(), 1)
|
||||
&& !Objects.equals(existingByName.getId(), toRestore.getId())) {
|
||||
String releasedName = existingByName.getUnitName() + "_DEL_" + existingByName.getId();
|
||||
baseMapper.releaseUnitName(existingByName.getId(), releasedName);
|
||||
}
|
||||
baseMapper.restoreById(toRestore.getId());
|
||||
measurementUnit.setId(toRestore.getId());
|
||||
measurementUnit.setIsDeleted(0);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean changeStatus(Long id, Integer status) {
|
||||
|
||||
+266
-57
@@ -66,6 +66,7 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
private final IMKPushService mkPushService;
|
||||
private final IDataSyncRecordService dataSyncRecordService;
|
||||
private final OaUserListSyncHelper oaUserListSyncHelper;
|
||||
private final OaSyncRawDumpHelper oaSyncRawDumpHelper;
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@Override
|
||||
@@ -74,16 +75,18 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
ComposeLogUtil.addLog(log);
|
||||
// 1. 同步公司和部门并记录
|
||||
Date startTime = this.syncAndRecord(DataSyncRecordUtils::createOAOrgFetch, queryStartTime -> {
|
||||
// 0. 确保双顶级:id=135 集团(GWWL) + 独立「外部组织」
|
||||
// 0. 确保双顶级:id=1 集团(GWWL) + 独立「外部组织」
|
||||
deptService.ensureOaSyncRootDept();
|
||||
// 1. 同步公司
|
||||
this.syncCompany(queryStartTime);
|
||||
// 公司分批入库后,重建层级并清理无法挂载的公司
|
||||
// 公司分批入库后:清理白名单外公司、重建层级并清理无法挂载的公司
|
||||
this.removeOutsideWhitelistOrgs();
|
||||
deptService.rebuildOaHierarchy();
|
||||
deptService.removeOaOrphansWithoutParent();
|
||||
// 2. 同步部门
|
||||
this.syncDept(queryStartTime);
|
||||
// 3. 分批写入完成后,按 OA 主键重建组织、部门层级
|
||||
this.removeOutsideWhitelistOrgs();
|
||||
deptService.rebuildOaHierarchy();
|
||||
deptService.removeOaOrphansWithoutParent();
|
||||
// 4. 更新部门祖级列表
|
||||
@@ -150,10 +153,10 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@Override
|
||||
public OaOrgSyncPageVO syncDepartmentPage(int current, int size, boolean incremental, String modifiedSince) {
|
||||
public OaOrgSyncPageVO syncDepartmentPage(int current, int size, boolean incremental, String modifiedSince, String companyOaId) {
|
||||
try {
|
||||
ComposeLogUtil.addLog(log);
|
||||
return this.syncDepartmentFromOaPage(current, size, incremental, modifiedSince);
|
||||
return this.syncDepartmentFromOaPage(current, size, incremental, modifiedSince, companyOaId);
|
||||
} finally {
|
||||
ComposeLogUtil.removeLastLog();
|
||||
}
|
||||
@@ -221,29 +224,28 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步部门
|
||||
* 同步部门:先整理已同步公司,再按公司 oaId 逐个轮询查询部门
|
||||
* @param startTime 查询开始时间
|
||||
*/
|
||||
private void syncDept(Date startTime) {
|
||||
String subCompanyIds = getOaSubCompanyIds(false);
|
||||
if (StringUtils.isEmpty(subCompanyIds)) {
|
||||
List<String> companyOaIds = listAllowedCompanyOaIds(false);
|
||||
if (CollectionUtil.isEmpty(companyOaIds)) {
|
||||
ComposeLogUtil.getLastLog().warn("未查询到子公司查询参数");
|
||||
return;
|
||||
}
|
||||
// 未处理的数据
|
||||
List<Dept> notHandleList = new ArrayList<>();
|
||||
// 1. 设置查询参数
|
||||
OADepartmentSearch departmentSearch = buildDepartmentSearch(startTime, subCompanyIds);
|
||||
// 2. 分页查询并处理数据
|
||||
OAUtils.pageSyncHandler(departmentSearch, param -> oaClient.queryDepartmentPage(new OASearch<>(param)), response -> {
|
||||
ComposeLogUtil.getLastLog().error("调用OA接口查询部门信息失败 {}", JSON.toJSONString(response));
|
||||
return new ServiceException("调用OA接口查询部门信息失败");
|
||||
}, 10000, ComposeLogUtil.getLastLog()::info).accept(list -> {
|
||||
// 处理数据
|
||||
OrgSyncCount syncCount = handleDept(list, false);
|
||||
notHandleList.addAll(syncCount.getNotHandledList());
|
||||
});
|
||||
// 3. 未处理的数据
|
||||
for (String companyOaId : companyOaIds) {
|
||||
ComposeLogUtil.getLastLog().info("按公司轮询同步部门 companyOaId={}", companyOaId);
|
||||
OADepartmentSearch departmentSearch = buildDepartmentSearch(startTime, companyOaId);
|
||||
OAUtils.pageSyncHandler(departmentSearch, param -> oaClient.queryDepartmentPage(new OASearch<>(param)), response -> {
|
||||
ComposeLogUtil.getLastLog().error("调用OA接口查询部门信息失败 companyOaId={} {}", companyOaId, JSON.toJSONString(response));
|
||||
return new ServiceException("调用OA接口查询部门信息失败");
|
||||
}, 10000, ComposeLogUtil.getLastLog()::info).accept(list -> {
|
||||
OrgSyncCount syncCount = handleDept(list, false);
|
||||
notHandleList.addAll(syncCount.getNotHandledList());
|
||||
});
|
||||
}
|
||||
if (CollectionUtil.isNotEmpty(notHandleList)) {
|
||||
ComposeLogUtil.getLastLog().warn("同步部门,还有未处理的数据:{}", JSON.toJSONString(notHandleList));
|
||||
}
|
||||
@@ -261,7 +263,7 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 OA 人员列表同步组织与人员
|
||||
* 从 OA 人员列表同步人员并绑定已有组织(不维护组织树,须先同步组织架构)
|
||||
*
|
||||
* @param startTime 增量查询开始时间,为空则全量
|
||||
* @return 处理的人员数量
|
||||
@@ -282,7 +284,7 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 按页从 OA 人员列表同步组织与人员
|
||||
* 按页从 OA 人员列表同步人员并绑定已有组织(不维护组织树,须先同步组织架构)
|
||||
*
|
||||
* @param current 当前页
|
||||
* @param size 每页条数
|
||||
@@ -293,6 +295,10 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
private OaPersonSyncPageVO syncPersonFromOaPage(int current, int size, boolean incremental, String modifiedSince) {
|
||||
int pageNo = current < 1 ? 1 : current;
|
||||
int pageSize = size < 1 ? 20 : Math.min(size, 200);
|
||||
// 全量同步首页开启原始数据收集,结束后按父级 id 写入 JSON
|
||||
if (!incremental && pageNo == 1) {
|
||||
oaSyncRawDumpHelper.beginFullDump();
|
||||
}
|
||||
IncrementalStart incrementalStart = resolvePersonIncrementalStart(incremental, modifiedSince);
|
||||
OAPersonSearch personSearch = buildPersonSearch(incrementalStart.getStartTime());
|
||||
personSearch.setCurPage(pageNo);
|
||||
@@ -306,6 +312,9 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
List<OAPersonResponse> oaPersons = responseData.getDataList() == null
|
||||
? Collections.emptyList() : responseData.getDataList();
|
||||
long totalSize = responseData.getTotalSize() == null ? 0L : responseData.getTotalSize();
|
||||
if (!incremental) {
|
||||
oaSyncRawDumpHelper.collectPersons(oaPersons);
|
||||
}
|
||||
OaUserListSyncHelper.OaOrgIndex orgIndex = oaUserListSyncHelper.syncOrgsFromPersons(oaPersons);
|
||||
OaUserListSyncHelper.PersonSyncCount personSyncCount = oaUserListSyncHelper.handlePerson(oaPersons, orgIndex);
|
||||
CacheUtil.clear(USER_CACHE);
|
||||
@@ -323,6 +332,9 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
|| oaPersons.size() < pageSize
|
||||
|| (long) pageNo * pageSize >= totalSize;
|
||||
pageVO.setFinished(finished);
|
||||
if (!incremental && finished) {
|
||||
pageVO.setDumpFilePath(oaSyncRawDumpHelper.finishFullDump());
|
||||
}
|
||||
ComposeLogUtil.getLastLog().info("OA人员分页同步完成 mode={} {}/{},成功{},跳过{}",
|
||||
incremental ? "incremental" : "full", pageNo, totalSize,
|
||||
personSyncCount.getSyncedCount(), personSyncCount.getSkippedCount());
|
||||
@@ -341,9 +353,12 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
private OaOrgSyncPageVO syncCompanyFromOaPage(int current, int size, boolean incremental, String modifiedSince) {
|
||||
int pageNo = current < 1 ? 1 : current;
|
||||
int pageSize = size < 1 ? 20 : Math.min(size, 200);
|
||||
// 首屏确保双顶级:id=135 集团(GWWL) + 独立「外部组织」
|
||||
// 首屏确保双顶级:id=1 集团(GWWL) + 独立「外部组织」
|
||||
if (pageNo == 1) {
|
||||
deptService.ensureOaSyncRootDept();
|
||||
if (!incremental) {
|
||||
oaSyncRawDumpHelper.beginOrgFullDump();
|
||||
}
|
||||
}
|
||||
IncrementalStart incrementalStart = resolveIncrementalStart(incremental, modifiedSince, DeptCategory.COMPANY);
|
||||
OACompanySearch companySearch = buildCompanySearch(incrementalStart.getStartTime());
|
||||
@@ -358,6 +373,9 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
List<OACompanyResponse> oaCompanies = responseData.getDataList() == null
|
||||
? Collections.emptyList() : responseData.getDataList();
|
||||
long totalSize = responseData.getTotalSize() == null ? 0L : responseData.getTotalSize();
|
||||
if (!incremental) {
|
||||
oaSyncRawDumpHelper.collectCompanies(oaCompanies);
|
||||
}
|
||||
OrgSyncCount syncCount = handleCompany(oaCompanies, true);
|
||||
if (CollectionUtil.isNotEmpty(syncCount.getNotHandledList())) {
|
||||
ComposeLogUtil.getLastLog().warn("同步公司,本页未处理数据:{}", JSON.toJSONString(syncCount.getNotHandledList()));
|
||||
@@ -365,7 +383,8 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
OaOrgSyncPageVO pageVO = buildOrgSyncPageVO("company", pageNo, pageSize, totalSize, oaCompanies.size(), syncCount);
|
||||
pageVO.setModifiedSince(incrementalStart.getModifiedSince());
|
||||
if (Boolean.TRUE.equals(pageVO.getFinished())) {
|
||||
// 公司全部分批入库后:重建层级,并删除上级不存在的数据
|
||||
// 公司全部分批入库后:清理白名单外公司、重建层级,并删除上级不存在的数据
|
||||
this.removeOutsideWhitelistOrgs();
|
||||
deptService.rebuildOaHierarchy();
|
||||
deptService.removeOaOrphansWithoutParent();
|
||||
}
|
||||
@@ -378,54 +397,105 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 按页从 OA 部门接口同步部门
|
||||
* 按页从 OA 部门接口同步部门:公司数据整理完成后,按公司 oaId 逐个轮询查询
|
||||
*
|
||||
* @param current 当前页
|
||||
* @param current 当前页(相对当前公司)
|
||||
* @param size 每页条数
|
||||
* @param incremental 是否增量同步
|
||||
* @param modifiedSince 增量起始时间(后续分页回传)
|
||||
* @param companyOaId 当前公司 OA 主键;空则从第一家开始
|
||||
* @return 本页同步结果
|
||||
*/
|
||||
private OaOrgSyncPageVO syncDepartmentFromOaPage(int current, int size, boolean incremental, String modifiedSince) {
|
||||
private OaOrgSyncPageVO syncDepartmentFromOaPage(int current, int size, boolean incremental, String modifiedSince,
|
||||
String companyOaId) {
|
||||
int pageNo = current < 1 ? 1 : current;
|
||||
int pageSize = size < 1 ? 20 : Math.min(size, 200);
|
||||
String subCompanyIds = getOaSubCompanyIds(true);
|
||||
if (StringUtils.isEmpty(subCompanyIds)) {
|
||||
// 先整理已同步企业列表,再按企业轮询
|
||||
List<String> companyOaIds = listAllowedCompanyOaIds(true);
|
||||
if (CollectionUtil.isEmpty(companyOaIds)) {
|
||||
ComposeLogUtil.getLastLog().warn("未查询到已同步的OA公司,跳过部门同步");
|
||||
OaOrgSyncPageVO emptyPageVO = buildOrgSyncPageVO("department", pageNo, pageSize, 0L, 0, OrgSyncCount.empty());
|
||||
emptyPageVO.setFinished(true);
|
||||
emptyPageVO.setCompanyFinished(true);
|
||||
emptyPageVO.setCompanyIndex(0);
|
||||
emptyPageVO.setCompanyTotal(0);
|
||||
if (!incremental) {
|
||||
emptyPageVO.setDumpFilePath(oaSyncRawDumpHelper.finishOrgFullDump());
|
||||
}
|
||||
return emptyPageVO;
|
||||
}
|
||||
int companyIndex = 0;
|
||||
String currentCompanyOaId = companyOaIds.get(0);
|
||||
if (StringUtils.isNotBlank(companyOaId)) {
|
||||
int foundIndex = companyOaIds.indexOf(companyOaId.trim());
|
||||
if (foundIndex >= 0) {
|
||||
companyIndex = foundIndex;
|
||||
currentCompanyOaId = companyOaIds.get(foundIndex);
|
||||
} else {
|
||||
ComposeLogUtil.getLastLog().warn("回传公司oaId不在待同步列表中,回退第一家 companyOaId={}", companyOaId);
|
||||
}
|
||||
}
|
||||
IncrementalStart incrementalStart = resolveIncrementalStart(incremental, modifiedSince, DeptCategory.DEPT);
|
||||
OADepartmentSearch departmentSearch = buildDepartmentSearch(incrementalStart.getStartTime(), subCompanyIds);
|
||||
// 查询部门时只传当前公司的 oa_id
|
||||
OADepartmentSearch departmentSearch = buildDepartmentSearch(incrementalStart.getStartTime(), currentCompanyOaId);
|
||||
departmentSearch.setCurPage(pageNo);
|
||||
departmentSearch.setPageSize(pageSize);
|
||||
ComposeLogUtil.getLastLog().info("按公司查询OA部门 companyOaId={} companyIndex={}/{} page={}/{}",
|
||||
currentCompanyOaId, companyIndex + 1, companyOaIds.size(), pageNo, pageSize);
|
||||
OAResponse<OADepartmentResponse> oaResponse = oaClient.queryDepartmentPage(new OASearch<>(departmentSearch));
|
||||
if (oaResponse == null || !OAConstant.OA_SUCCESS_CODE.equals(oaResponse.getCode()) || oaResponse.getData() == null) {
|
||||
ComposeLogUtil.getLastLog().error("调用OA接口查询部门信息失败 {}", JSON.toJSONString(oaResponse));
|
||||
ComposeLogUtil.getLastLog().error("调用OA接口查询部门信息失败 companyOaId={} {}",
|
||||
currentCompanyOaId, JSON.toJSONString(oaResponse));
|
||||
throw new ServiceException("调用OA接口查询部门信息失败");
|
||||
}
|
||||
OAResponseData<OADepartmentResponse> responseData = oaResponse.getData();
|
||||
List<OADepartmentResponse> oaDepartments = responseData.getDataList() == null
|
||||
? Collections.emptyList() : responseData.getDataList();
|
||||
long totalSize = responseData.getTotalSize() == null ? 0L : responseData.getTotalSize();
|
||||
if (!incremental) {
|
||||
oaSyncRawDumpHelper.collectDepartments(oaDepartments);
|
||||
}
|
||||
OrgSyncCount syncCount = handleDept(oaDepartments, true);
|
||||
if (CollectionUtil.isNotEmpty(syncCount.getNotHandledList())) {
|
||||
ComposeLogUtil.getLastLog().warn("同步部门,本页未处理数据:{}", JSON.toJSONString(syncCount.getNotHandledList()));
|
||||
ComposeLogUtil.getLastLog().warn("同步部门,本页未处理数据 companyOaId={}:{}",
|
||||
currentCompanyOaId, JSON.toJSONString(syncCount.getNotHandledList()));
|
||||
}
|
||||
OaOrgSyncPageVO pageVO = buildOrgSyncPageVO("department", pageNo, pageSize, totalSize, oaDepartments.size(), syncCount);
|
||||
pageVO.setModifiedSince(incrementalStart.getModifiedSince());
|
||||
if (Boolean.TRUE.equals(pageVO.getFinished())) {
|
||||
// 部门分批同步完成后,重建层级、清理无法挂载数据并刷新祖级列表
|
||||
deptService.rebuildOaHierarchy();
|
||||
deptService.removeOaOrphansWithoutParent();
|
||||
deptService.updateAncestors(null);
|
||||
pageVO.setCompanyOaId(currentCompanyOaId);
|
||||
pageVO.setCompanyIndex(companyIndex + 1);
|
||||
pageVO.setCompanyTotal(companyOaIds.size());
|
||||
boolean companyPageFinished = Boolean.TRUE.equals(pageVO.getFinished());
|
||||
if (companyPageFinished) {
|
||||
if (companyIndex + 1 < companyOaIds.size()) {
|
||||
// 当前公司部门已拉完:先按 OA 主键校正父子挂载,再进入下一公司
|
||||
pageVO.setCompanyFinished(true);
|
||||
pageVO.setNextCompanyOaId(companyOaIds.get(companyIndex + 1));
|
||||
pageVO.setFinished(false);
|
||||
deptService.rebuildOaHierarchy();
|
||||
} else {
|
||||
// 全部公司轮询完成:清理白名单外组织、重建层级、清理无法挂载数据并刷新祖级列表
|
||||
pageVO.setCompanyFinished(true);
|
||||
pageVO.setNextCompanyOaId(null);
|
||||
pageVO.setFinished(true);
|
||||
this.removeOutsideWhitelistOrgs();
|
||||
deptService.rebuildOaHierarchy();
|
||||
deptService.removeOaOrphansWithoutParent();
|
||||
deptService.updateAncestors(null);
|
||||
if (!incremental) {
|
||||
pageVO.setDumpFilePath(oaSyncRawDumpHelper.finishOrgFullDump());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
pageVO.setCompanyFinished(false);
|
||||
pageVO.setNextCompanyOaId(currentCompanyOaId);
|
||||
pageVO.setFinished(false);
|
||||
}
|
||||
CacheUtil.clear(SYS_CACHE);
|
||||
CacheUtil.clear(SYS_CACHE, Boolean.FALSE);
|
||||
ComposeLogUtil.getLastLog().info("OA部门分页同步完成 mode={} {}/{},成功{},跳过{}",
|
||||
incremental ? "incremental" : "full", pageNo, totalSize,
|
||||
syncCount.getSyncedCount(), syncCount.getSkippedCount());
|
||||
ComposeLogUtil.getLastLog().info("OA部门分页同步完成 mode={} companyOaId={} company={}/{} page={}/{},成功{},跳过{}",
|
||||
incremental ? "incremental" : "full", currentCompanyOaId, companyIndex + 1, companyOaIds.size(),
|
||||
pageNo, totalSize, syncCount.getSyncedCount(), syncCount.getSkippedCount());
|
||||
return pageVO;
|
||||
}
|
||||
|
||||
@@ -574,15 +644,15 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
/**
|
||||
* 组装 OA 部门分页查询参数
|
||||
*
|
||||
* @param startTime 增量查询开始时间
|
||||
* @param subCompanyIds 子公司 id 列表
|
||||
* @param startTime 增量查询开始时间
|
||||
* @param companyOaId 当前公司 OA 主键(单次只传一家公司)
|
||||
* @return 查询参数
|
||||
*/
|
||||
private OADepartmentSearch buildDepartmentSearch(Date startTime, String subCompanyIds) {
|
||||
private OADepartmentSearch buildDepartmentSearch(Date startTime, String companyOaId) {
|
||||
OADepartmentSearch departmentSearch = new OADepartmentSearch();
|
||||
departmentSearch.setCurPage(1);
|
||||
departmentSearch.setPageSize(20);
|
||||
departmentSearch.setSubcompanyid1(subCompanyIds);
|
||||
departmentSearch.setSubcompanyid1(companyOaId);
|
||||
if (startTime != null) {
|
||||
departmentSearch.setModified(DateUtil.format(startTime, DateUtil.PATTERN_DATETIME));
|
||||
}
|
||||
@@ -623,6 +693,7 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
}
|
||||
Set<String> companyNames = syncAll ? Collections.emptySet() : getCompanyNames();
|
||||
List<Dept> allParam = oaCompanies.stream()
|
||||
.filter(this::isAllowedOaCompany)
|
||||
.filter(company -> syncAll || companyNames.contains(company.getSubcompanyname()))
|
||||
.map(deptConvert::company2dept)
|
||||
.toList();
|
||||
@@ -679,16 +750,18 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
return OrgSyncCount.empty();
|
||||
}
|
||||
Map<String, Dept> companyByOaId = loadCompanyByOaId();
|
||||
Set<String> allowedCompanyOaIds = resolveAllowedCompanyOaIds(companyByOaId);
|
||||
String rootCompanyId = getRootCompanyId();
|
||||
if (!syncAll && rootCompanyId == null) {
|
||||
return new OrgSyncCount(0, oaDepts.size(), Collections.emptyList());
|
||||
}
|
||||
Set<String> rootCompanyDeptNames = syncAll ? Collections.emptySet() : getRootCompanyDeptNames();
|
||||
List<Dept> allParam = oaDepts.stream()
|
||||
.filter(oaDept -> allowedCompanyOaIds.contains(oaDept.getSubcompanyid1()))
|
||||
.filter(oaDept -> syncAll
|
||||
|| rootCompanyId == null
|
||||
|| !rootCompanyId.equals(oaDept.getSubcompanyid1())
|
||||
|| (OAConstant.ROOT_COMPANY_ID.equals(oaDept.getSupdepid()) && rootCompanyDeptNames.contains(oaDept.getDepartmentname())))
|
||||
|| (OAConstant.ROOT_COMPANY_ID.equals(oaDept.getSupdepid()) && rootCompanyDeptNames.contains(oaDept.getDepartmentmark())))
|
||||
.map(dept -> deptConvert.dept2dept(dept, companyByOaId))
|
||||
.toList();
|
||||
int filteredSkipCount = oaDepts.size() - allParam.size();
|
||||
@@ -733,9 +806,11 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
}
|
||||
String parentOaId = dept.getOaSupSubComId();
|
||||
Dept belongCompany = companyByOaId.values().stream()
|
||||
.filter(company -> StringUtils.equals(company.getDeptCode(), dept.getBelongCompanyCode()))
|
||||
.filter(company -> StringUtils.equals(company.getDeptCode(), dept.getBelongCompanyCode())
|
||||
|| StringUtils.equals(company.getOaId(), dept.getBelongCompanyCode()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
// 根部门:oaSupSubComId 即所属公司 OA 主键,必须挂公司(OA 公司/部门主键会撞号)
|
||||
if (belongCompany != null && (OAConstant.ROOT_COMPANY_ID.equals(parentOaId)
|
||||
|| StringUtils.equals(parentOaId, belongCompany.getOaId()))) {
|
||||
dept.setParentCode(belongCompany.getDeptCode());
|
||||
@@ -746,9 +821,19 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
if (parentDept == null) {
|
||||
parentDept = existingByOaId.get(parentOaId);
|
||||
}
|
||||
// 跨公司同号部门不可作为上级(例如总部部门 id=19 与公司 id=19)
|
||||
if (parentDept != null && StringUtils.isNotBlank(dept.getBelongCompanyCode())
|
||||
&& StringUtils.isNotBlank(parentDept.getBelongCompanyCode())
|
||||
&& !StringUtils.equals(dept.getBelongCompanyCode(), parentDept.getBelongCompanyCode())) {
|
||||
parentDept = null;
|
||||
}
|
||||
Dept parentCompany = StringUtils.isNotBlank(parentOaId) ? companyByOaId.get(parentOaId) : null;
|
||||
if (parentDept != null && StringUtils.isNotBlank(parentDept.getDeptCode())) {
|
||||
dept.setParentCode(parentDept.getDeptCode());
|
||||
dept.setParentId(parentDept.getId());
|
||||
} else if (parentCompany != null && StringUtils.isNotBlank(parentCompany.getDeptCode())) {
|
||||
dept.setParentCode(parentCompany.getDeptCode());
|
||||
dept.setParentId(parentCompany.getId());
|
||||
} else if (belongCompany != null) {
|
||||
// 上级部门尚未就绪时先挂所属公司,分批结束后按 OA 主键重建
|
||||
dept.setParentCode(belongCompany.getDeptCode());
|
||||
@@ -836,8 +921,9 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
.eq(Dept::getDeptCategory, deptCategory.getCode())
|
||||
.isNotNull(Dept::getOaId)
|
||||
.ne(Dept::getOaId, "")
|
||||
.select(Dept::getId, Dept::getDeptCode, Dept::getOaModified, Dept::getParentId,
|
||||
Dept::getOaId, Dept::getBelongCompanyCode, Dept::getParentCode, Dept::getOaSupSubComId));
|
||||
.select(Dept::getId, Dept::getDeptCode, Dept::getDeptName, Dept::getFullName,
|
||||
Dept::getOaModified, Dept::getParentId, Dept::getOaId, Dept::getBelongCompanyCode,
|
||||
Dept::getParentCode, Dept::getOaSupSubComId));
|
||||
Map<String, Dept> byOaId = new HashMap<>();
|
||||
for (Dept dept : deptList) {
|
||||
if (StringUtils.isNotBlank(dept.getOaId())) {
|
||||
@@ -962,7 +1048,7 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
|
||||
/**
|
||||
* oaModified 一致则跳过更新;任一侧为空无法确认一致,执行更新以补齐字段。
|
||||
* 组织编码已按新规则重算且与库中不一致时,即使 modified 相同也要更新。
|
||||
* 组织编码已按新规则重算、父子挂载不一致、或名称字段与当前映射不一致时,即使 modified 相同也要更新。
|
||||
*/
|
||||
private boolean shouldSkipOaUpdate(Dept existing, Dept incoming) {
|
||||
if (existing == null) {
|
||||
@@ -972,6 +1058,23 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
&& !StringUtils.equals(existing.getDeptCode(), incoming.getDeptCode())) {
|
||||
return false;
|
||||
}
|
||||
if (incoming != null && incoming.getParentId() != null
|
||||
&& !Objects.equals(existing.getParentId(), incoming.getParentId())) {
|
||||
return false;
|
||||
}
|
||||
if (incoming != null && StringUtils.isNotBlank(incoming.getParentCode())
|
||||
&& !StringUtils.equals(StringUtils.trimToEmpty(existing.getParentCode()),
|
||||
StringUtils.trimToEmpty(incoming.getParentCode()))) {
|
||||
return false;
|
||||
}
|
||||
if (incoming != null && !StringUtils.equals(StringUtils.trimToEmpty(existing.getDeptName()),
|
||||
StringUtils.trimToEmpty(incoming.getDeptName()))) {
|
||||
return false;
|
||||
}
|
||||
if (incoming != null && !StringUtils.equals(StringUtils.trimToEmpty(existing.getFullName()),
|
||||
StringUtils.trimToEmpty(incoming.getFullName()))) {
|
||||
return false;
|
||||
}
|
||||
String localModified = StringUtils.trimToEmpty(existing.getOaModified());
|
||||
String remoteModified = StringUtils.trimToEmpty(incoming == null ? null : incoming.getOaModified());
|
||||
if (localModified.isEmpty() || remoteModified.isEmpty()) {
|
||||
@@ -1012,12 +1115,112 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 OA 部门查询使用的子公司 id
|
||||
* 是否允许同步的 OA 公司:二级白名单,或其上级为二级白名单(三级子公司)
|
||||
*/
|
||||
private boolean isAllowedOaCompany(OACompanyResponse company) {
|
||||
if (company == null || StringUtils.isBlank(company.getId())) {
|
||||
return false;
|
||||
}
|
||||
if (OAConvertConstant.OA_SYNC_KEEP_SECOND_LEVEL_COMPANY_IDS.contains(company.getId())) {
|
||||
return true;
|
||||
}
|
||||
return OAConvertConstant.OA_SYNC_KEEP_SECOND_LEVEL_COMPANY_IDS.contains(company.getSupsubcomid());
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析允许同步部门的所属公司 OA 主键:二级白名单 ∪ 已同步的三级子公司
|
||||
*/
|
||||
private Set<String> resolveAllowedCompanyOaIds(Map<String, Dept> companyByOaId) {
|
||||
Set<String> allowed = new HashSet<>(OAConvertConstant.OA_SYNC_KEEP_SECOND_LEVEL_COMPANY_IDS);
|
||||
if (companyByOaId == null || companyByOaId.isEmpty()) {
|
||||
return allowed;
|
||||
}
|
||||
for (Dept company : companyByOaId.values()) {
|
||||
if (company == null || StringUtils.isBlank(company.getOaId())) {
|
||||
continue;
|
||||
}
|
||||
if (OAConvertConstant.OA_SYNC_KEEP_SECOND_LEVEL_COMPANY_IDS.contains(company.getOaId())
|
||||
|| OAConvertConstant.OA_SYNC_KEEP_SECOND_LEVEL_COMPANY_IDS.contains(company.getOaSupSubComId())) {
|
||||
allowed.add(company.getOaId());
|
||||
}
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理不在白名单树内的 OA 公司及其子树(含部门)
|
||||
*/
|
||||
private int removeOutsideWhitelistOrgs() {
|
||||
List<Dept> oaCompanyList = deptService.list(Wrappers.<Dept>lambdaQuery()
|
||||
.eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode())
|
||||
.isNotNull(Dept::getOaId)
|
||||
.ne(Dept::getOaId, ""));
|
||||
Map<String, Dept> companyByOaId = new HashMap<>();
|
||||
for (Dept company : oaCompanyList) {
|
||||
if (StringUtils.isNotBlank(company.getOaId())) {
|
||||
companyByOaId.put(company.getOaId(), company);
|
||||
}
|
||||
}
|
||||
Set<String> allowedCompanyOaIds = resolveAllowedCompanyOaIds(companyByOaId);
|
||||
Set<Long> deleteIds = new HashSet<>();
|
||||
for (Dept company : oaCompanyList) {
|
||||
if (!allowedCompanyOaIds.contains(company.getOaId())) {
|
||||
deleteIds.add(company.getId());
|
||||
}
|
||||
}
|
||||
Map<String, String> companyCodeToOaId = new HashMap<>();
|
||||
for (Dept company : oaCompanyList) {
|
||||
if (StringUtils.isNotBlank(company.getDeptCode()) && StringUtils.isNotBlank(company.getOaId())) {
|
||||
companyCodeToOaId.put(company.getDeptCode(), company.getOaId());
|
||||
}
|
||||
}
|
||||
List<Dept> oaDeptList = deptService.list(Wrappers.<Dept>lambdaQuery()
|
||||
.eq(Dept::getDeptCategory, DeptCategory.DEPT.getCode())
|
||||
.isNotNull(Dept::getOaId)
|
||||
.ne(Dept::getOaId, ""));
|
||||
for (Dept dept : oaDeptList) {
|
||||
String belongOaId = null;
|
||||
if (StringUtils.isNotBlank(dept.getBelongCompanyCode())) {
|
||||
belongOaId = companyCodeToOaId.get(dept.getBelongCompanyCode());
|
||||
}
|
||||
if (StringUtils.isBlank(belongOaId) && StringUtils.isNotBlank(dept.getOaSupSubComId())
|
||||
&& companyByOaId.containsKey(dept.getOaSupSubComId())) {
|
||||
// 根部门上级可能直接是公司 OA 主键
|
||||
belongOaId = dept.getOaSupSubComId();
|
||||
}
|
||||
if (StringUtils.isBlank(belongOaId) || !allowedCompanyOaIds.contains(belongOaId)) {
|
||||
deleteIds.add(dept.getId());
|
||||
}
|
||||
}
|
||||
if (CollectionUtil.isEmpty(deleteIds)) {
|
||||
return 0;
|
||||
}
|
||||
List<Dept> allDeptList = deptService.list();
|
||||
boolean cascadeChanged = true;
|
||||
while (cascadeChanged) {
|
||||
cascadeChanged = false;
|
||||
for (Dept dept : allDeptList) {
|
||||
if (deleteIds.contains(dept.getId())) {
|
||||
continue;
|
||||
}
|
||||
if (dept.getParentId() != null && deleteIds.contains(dept.getParentId())) {
|
||||
deleteIds.add(dept.getId());
|
||||
cascadeChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
deptService.removeByIds(deleteIds);
|
||||
ComposeLogUtil.getLastLog().info("清理白名单外 OA 组织{}条", deleteIds.size());
|
||||
return deleteIds.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 整理部门同步用的公司 OA 主键列表(公司阶段完成后调用)
|
||||
*
|
||||
* @param syncAll true=取已同步的全部 OA 公司;false=仅取字典配置的公司
|
||||
* @return 逗号分隔的 OA 公司 id
|
||||
* @return 白名单内公司 oaId 列表(去重、保序)
|
||||
*/
|
||||
private String getOaSubCompanyIds(boolean syncAll) {
|
||||
private List<String> listAllowedCompanyOaIds(boolean syncAll) {
|
||||
List<Dept> companyList;
|
||||
if (syncAll) {
|
||||
companyList = deptService.list(Wrappers.<Dept>lambdaQuery()
|
||||
@@ -1028,7 +1231,7 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
} else {
|
||||
Set<String> companyNames = this.getCompanyNames();
|
||||
if (companyNames.isEmpty()) {
|
||||
return null;
|
||||
return Collections.emptyList();
|
||||
}
|
||||
companyList = deptService.list(Wrappers.<Dept>lambdaQuery()
|
||||
.in(Dept::getDeptName, companyNames)
|
||||
@@ -1037,11 +1240,17 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
.ne(Dept::getOaId, "")
|
||||
);
|
||||
}
|
||||
return companyList.stream()
|
||||
.map(this::resolveOaCompanyId)
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.distinct()
|
||||
.collect(Collectors.joining(","));
|
||||
Map<String, Dept> companyByOaId = new LinkedHashMap<>();
|
||||
for (Dept company : companyList) {
|
||||
String oaId = this.resolveOaCompanyId(company);
|
||||
if (StringUtils.isNotBlank(oaId)) {
|
||||
companyByOaId.putIfAbsent(oaId, company);
|
||||
}
|
||||
}
|
||||
Set<String> allowedCompanyOaIds = resolveAllowedCompanyOaIds(companyByOaId);
|
||||
return companyByOaId.keySet().stream()
|
||||
.filter(allowedCompanyOaIds::contains)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.system.service.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONWriter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.tool.utils.DateUtil;
|
||||
import org.springblade.system.log.ComposeLogUtil;
|
||||
import org.springblade.thirdparty.oa.feign.IOAClient;
|
||||
import org.springblade.thirdparty.oa.pojo.response.OACompanyResponse;
|
||||
import org.springblade.thirdparty.oa.pojo.response.OADepartmentResponse;
|
||||
import org.springblade.thirdparty.oa.pojo.response.OAPersonResponse;
|
||||
import org.springblade.thirdparty.oa.pojo.search.OACompanySearch;
|
||||
import org.springblade.thirdparty.oa.pojo.search.OADepartmentSearch;
|
||||
import org.springblade.thirdparty.oa.pojo.search.OASearch;
|
||||
import org.springblade.thirdparty.oa.util.OAUtils;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* OA 全量同步原始数据落盘:按父级 id 组装层级 JSON
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class OaSyncRawDumpHelper {
|
||||
|
||||
private static final String ORPHAN_COMPANY_ID = "_orphan_company";
|
||||
private static final String ORPHAN_DEPT_ID = "_orphan_dept";
|
||||
|
||||
private final IOAClient oaClient;
|
||||
|
||||
@Value("${third-party.oa.sync-dump-dir:./data/oa-sync}")
|
||||
private String syncDumpDir;
|
||||
|
||||
/**
|
||||
* 当前全量同步会话收集到的人员原始数据
|
||||
*/
|
||||
private final List<OAPersonResponse> collectingPersons = Collections.synchronizedList(new ArrayList<>());
|
||||
private volatile boolean collecting;
|
||||
|
||||
/**
|
||||
* 机构全量同步会话收集的公司/部门原始数据
|
||||
*/
|
||||
private final List<OACompanyResponse> collectingCompanies = Collections.synchronizedList(new ArrayList<>());
|
||||
private final List<OADepartmentResponse> collectingDepartments = Collections.synchronizedList(new ArrayList<>());
|
||||
private volatile boolean collectingOrg;
|
||||
|
||||
/**
|
||||
* 全量同步首页启动收集
|
||||
*/
|
||||
public synchronized void beginFullDump() {
|
||||
collectingPersons.clear();
|
||||
collecting = true;
|
||||
ComposeLogUtil.getLastLog().info("已开启 OA 全量同步原始数据收集,目录={}", resolveDumpDir());
|
||||
}
|
||||
|
||||
/**
|
||||
* 机构全量同步首页启动收集公司/部门原始数据
|
||||
*/
|
||||
public synchronized void beginOrgFullDump() {
|
||||
collectingCompanies.clear();
|
||||
collectingDepartments.clear();
|
||||
collectingOrg = true;
|
||||
ComposeLogUtil.getLastLog().info("已开启 OA 机构全量同步原始数据收集,目录={}", resolveDumpDir());
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集本页人员原始数据
|
||||
*/
|
||||
public void collectPersons(List<OAPersonResponse> oaPersons) {
|
||||
if (!collecting || oaPersons == null || oaPersons.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
collectingPersons.addAll(oaPersons);
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集本页公司原始数据
|
||||
*/
|
||||
public void collectCompanies(List<OACompanyResponse> oaCompanies) {
|
||||
if (!collectingOrg || oaCompanies == null || oaCompanies.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
collectingCompanies.addAll(oaCompanies);
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集本页部门原始数据
|
||||
*/
|
||||
public void collectDepartments(List<OADepartmentResponse> oaDepartments) {
|
||||
if (!collectingOrg || oaDepartments == null || oaDepartments.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
collectingDepartments.addAll(oaDepartments);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全量同步结束:先写入人员层级 JSON,再异步补齐公司/部门接口原始层级数据
|
||||
*
|
||||
* @return 人员原始层级 JSON 文件路径,失败时返回 null
|
||||
*/
|
||||
public synchronized String finishFullDump() {
|
||||
if (!collecting) {
|
||||
return null;
|
||||
}
|
||||
List<OAPersonResponse> persons = new ArrayList<>(collectingPersons);
|
||||
collecting = false;
|
||||
collectingPersons.clear();
|
||||
try {
|
||||
String stamp = DateUtil.format(new Date(), "yyyyMMddHHmmss");
|
||||
Map<String, Object> personPayload = new LinkedHashMap<>();
|
||||
personPayload.put("exportTime", DateUtil.format(new Date(), DateUtil.PATTERN_DATETIME));
|
||||
personPayload.put("personCount", persons.size());
|
||||
personPayload.put("personTree", buildPersonTree(persons));
|
||||
personPayload.put("personsRaw", persons);
|
||||
Path personFile = writeDumpFile("oa-full-sync-person-raw-" + stamp + ".json", personPayload);
|
||||
ComposeLogUtil.getLastLog().info("OA 人员原始层级数据已写入 {}", personFile.toAbsolutePath());
|
||||
CompletableFuture.runAsync(() -> dumpOrgRawAsync(stamp));
|
||||
return personFile.toAbsolutePath().toString();
|
||||
} catch (Exception exception) {
|
||||
log.error("写入 OA 全量同步人员原始 JSON 失败", exception);
|
||||
ComposeLogUtil.getLastLog().error("写入 OA 全量同步人员原始 JSON 失败:{}", exception.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 机构全量同步结束:写入公司/部门接口原始数据(按父级 id 层级)
|
||||
*
|
||||
* @return JSON 文件路径,失败时返回 null
|
||||
*/
|
||||
public synchronized String finishOrgFullDump() {
|
||||
if (!collectingOrg) {
|
||||
return null;
|
||||
}
|
||||
List<OACompanyResponse> companies = new ArrayList<>(collectingCompanies);
|
||||
List<OADepartmentResponse> departments = new ArrayList<>(collectingDepartments);
|
||||
collectingOrg = false;
|
||||
collectingCompanies.clear();
|
||||
collectingDepartments.clear();
|
||||
try {
|
||||
String stamp = DateUtil.format(new Date(), "yyyyMMddHHmmss");
|
||||
Path orgFile = writeOrgDumpFile(stamp, companies, departments);
|
||||
ComposeLogUtil.getLastLog().info("OA 机构原始层级数据已写入 {}", orgFile.toAbsolutePath());
|
||||
return orgFile.toAbsolutePath().toString();
|
||||
} catch (Exception exception) {
|
||||
log.error("写入 OA 机构全量同步原始 JSON 失败", exception);
|
||||
ComposeLogUtil.getLastLog().error("写入 OA 机构全量同步原始 JSON 失败:{}", exception.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void dumpOrgRawAsync(String stamp) {
|
||||
try {
|
||||
List<OACompanyResponse> companies = fetchAllCompanies();
|
||||
List<OADepartmentResponse> departments = fetchAllDepartments(companies);
|
||||
Path orgFile = writeOrgDumpFile(stamp, companies, departments);
|
||||
log.info("OA 公司/部门原始层级数据已写入 {}", orgFile.toAbsolutePath());
|
||||
} catch (Exception exception) {
|
||||
log.error("异步写入 OA 公司/部门原始 JSON 失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private Path writeOrgDumpFile(String stamp, List<OACompanyResponse> companies,
|
||||
List<OADepartmentResponse> departments) throws IOException {
|
||||
Map<String, Object> orgPayload = new LinkedHashMap<>();
|
||||
orgPayload.put("exportTime", DateUtil.format(new Date(), DateUtil.PATTERN_DATETIME));
|
||||
orgPayload.put("companyCount", companies.size());
|
||||
orgPayload.put("departmentCount", departments.size());
|
||||
orgPayload.put("companyTree", buildCompanyTree(companies));
|
||||
orgPayload.put("departmentTree", buildDepartmentTree(departments));
|
||||
orgPayload.put("companiesRaw", companies);
|
||||
orgPayload.put("departmentsRaw", departments);
|
||||
return writeDumpFile("oa-full-sync-org-raw-" + stamp + ".json", orgPayload);
|
||||
}
|
||||
|
||||
private List<OACompanyResponse> fetchAllCompanies() {
|
||||
List<OACompanyResponse> companies = new ArrayList<>();
|
||||
OACompanySearch companySearch = new OACompanySearch();
|
||||
companySearch.setCurPage(1);
|
||||
companySearch.setPageSize(100);
|
||||
OAUtils.pageSyncHandler(companySearch, param -> oaClient.queryCompanyPage(new OASearch<>(param)), response -> {
|
||||
log.error("导出公司原始数据失败 {}", JSON.toJSONString(response));
|
||||
return new ServiceException("导出公司原始数据失败");
|
||||
}, 10000, log::info).accept(companies::addAll);
|
||||
return companies;
|
||||
}
|
||||
|
||||
private List<OADepartmentResponse> fetchAllDepartments(List<OACompanyResponse> companies) {
|
||||
List<OADepartmentResponse> departments = new ArrayList<>();
|
||||
String subCompanyIds = companies.stream()
|
||||
.map(OACompanyResponse::getId)
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.distinct()
|
||||
.reduce((left, right) -> left + "," + right)
|
||||
.orElse("");
|
||||
OADepartmentSearch departmentSearch = new OADepartmentSearch();
|
||||
departmentSearch.setCurPage(1);
|
||||
departmentSearch.setPageSize(100);
|
||||
departmentSearch.setSubcompanyid1(subCompanyIds);
|
||||
OAUtils.pageSyncHandler(departmentSearch, param -> oaClient.queryDepartmentPage(new OASearch<>(param)), response -> {
|
||||
log.error("导出部门原始数据失败 {}", JSON.toJSONString(response));
|
||||
return new ServiceException("导出部门原始数据失败");
|
||||
}, 10000, log::info).accept(departments::addAll);
|
||||
return departments;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> buildCompanyTree(List<OACompanyResponse> companies) {
|
||||
Map<String, Map<String, Object>> nodeMap = new LinkedHashMap<>();
|
||||
for (OACompanyResponse company : companies) {
|
||||
if (company == null || StringUtils.isBlank(company.getId())) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> node = new LinkedHashMap<>();
|
||||
node.put("id", company.getId());
|
||||
node.put("parentId", blankToRoot(company.getSupsubcomid()));
|
||||
node.put("type", "company");
|
||||
node.put("name", company.getSubcompanyname());
|
||||
node.put("raw", company);
|
||||
node.put("children", new ArrayList<Map<String, Object>>());
|
||||
nodeMap.put(company.getId(), node);
|
||||
}
|
||||
return linkChildren(nodeMap);
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> buildDepartmentTree(List<OADepartmentResponse> departments) {
|
||||
Map<String, Map<String, Object>> nodeMap = new LinkedHashMap<>();
|
||||
for (OADepartmentResponse department : departments) {
|
||||
if (department == null || StringUtils.isBlank(department.getId())) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> node = new LinkedHashMap<>();
|
||||
node.put("id", department.getId());
|
||||
node.put("parentId", blankToRoot(department.getSupdepid()));
|
||||
node.put("companyId", department.getSubcompanyid1());
|
||||
node.put("type", "department");
|
||||
node.put("name", department.getDepartmentname());
|
||||
node.put("raw", department);
|
||||
node.put("children", new ArrayList<Map<String, Object>>());
|
||||
nodeMap.put(department.getId(), node);
|
||||
}
|
||||
return linkChildren(nodeMap);
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> buildPersonTree(List<OAPersonResponse> persons) {
|
||||
Map<String, Map<String, Object>> companyMap = new LinkedHashMap<>();
|
||||
Map<String, Map<String, Object>> deptMap = new LinkedHashMap<>();
|
||||
for (OAPersonResponse person : persons) {
|
||||
if (person == null) {
|
||||
continue;
|
||||
}
|
||||
String companyId = StringUtils.defaultIfBlank(person.getSubcompanyid1(), ORPHAN_COMPANY_ID);
|
||||
String deptId = StringUtils.defaultIfBlank(person.getDepartmentid(), ORPHAN_DEPT_ID + "@" + companyId);
|
||||
Map<String, Object> companyNode = companyMap.computeIfAbsent(companyId, key -> {
|
||||
Map<String, Object> node = new LinkedHashMap<>();
|
||||
node.put("id", key);
|
||||
node.put("parentId", "0");
|
||||
node.put("type", "company");
|
||||
node.put("name", StringUtils.defaultIfBlank(person.getSubcompanyname(), key));
|
||||
Map<String, Object> raw = new LinkedHashMap<>();
|
||||
raw.put("subcompanyid1", person.getSubcompanyid1());
|
||||
raw.put("subcompanyname", person.getSubcompanyname());
|
||||
raw.put("subcompanycode", person.getSubcompanycode());
|
||||
node.put("raw", raw);
|
||||
node.put("children", new ArrayList<Map<String, Object>>());
|
||||
return node;
|
||||
});
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> companyChildren = (List<Map<String, Object>>) companyNode.get("children");
|
||||
Map<String, Object> deptNode = deptMap.computeIfAbsent(deptId, key -> {
|
||||
Map<String, Object> node = new LinkedHashMap<>();
|
||||
node.put("id", key);
|
||||
node.put("parentId", companyId);
|
||||
node.put("type", "department");
|
||||
node.put("name", StringUtils.defaultIfBlank(person.getDepartmentname(), key));
|
||||
Map<String, Object> raw = new LinkedHashMap<>();
|
||||
raw.put("departmentid", person.getDepartmentid());
|
||||
raw.put("departmentname", person.getDepartmentname());
|
||||
raw.put("departmentcode", person.getDepartmentcode());
|
||||
raw.put("subcompanyid1", person.getSubcompanyid1());
|
||||
node.put("raw", raw);
|
||||
node.put("children", new ArrayList<Map<String, Object>>());
|
||||
companyChildren.add(node);
|
||||
return node;
|
||||
});
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> deptChildren = (List<Map<String, Object>>) deptNode.get("children");
|
||||
Map<String, Object> personNode = new LinkedHashMap<>();
|
||||
personNode.put("id", StringUtils.defaultIfBlank(person.getId(), person.getLoginid()));
|
||||
personNode.put("parentId", deptId);
|
||||
personNode.put("type", "person");
|
||||
personNode.put("name", person.getLastname());
|
||||
personNode.put("raw", person);
|
||||
personNode.put("children", Collections.emptyList());
|
||||
deptChildren.add(personNode);
|
||||
}
|
||||
return new ArrayList<>(companyMap.values());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Map<String, Object>> linkChildren(Map<String, Map<String, Object>> nodeMap) {
|
||||
List<Map<String, Object>> roots = new ArrayList<>();
|
||||
for (Map<String, Object> node : nodeMap.values()) {
|
||||
String parentId = Objects.toString(node.get("parentId"), "0");
|
||||
Map<String, Object> parent = nodeMap.get(parentId);
|
||||
if (parent != null && !Objects.equals(parent.get("id"), node.get("id"))) {
|
||||
((List<Map<String, Object>>) parent.get("children")).add(node);
|
||||
} else {
|
||||
roots.add(node);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
private String blankToRoot(String parentId) {
|
||||
if (StringUtils.isBlank(parentId) || "0".equals(parentId) || "-1".equals(parentId)) {
|
||||
return "0";
|
||||
}
|
||||
return parentId;
|
||||
}
|
||||
|
||||
private Path writeDumpFile(String fileName, Map<String, Object> payload) throws IOException {
|
||||
Path dir = resolveDumpDir();
|
||||
Files.createDirectories(dir);
|
||||
Path dumpFile = dir.resolve(fileName);
|
||||
String json = JSON.toJSONString(payload, JSONWriter.Feature.PrettyFormat, JSONWriter.Feature.WriteMapNullValue);
|
||||
Files.writeString(dumpFile, json, StandardCharsets.UTF_8);
|
||||
return dumpFile;
|
||||
}
|
||||
|
||||
private Path resolveDumpDir() {
|
||||
return Paths.get(StringUtils.defaultIfBlank(syncDumpDir, "./data/oa-sync")).toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
}
|
||||
+381
-260
@@ -30,7 +30,6 @@ import com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springblade.common.constant.DataStatusEnum;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.constant.BladeConstant;
|
||||
import org.springblade.core.tool.utils.DigestUtil;
|
||||
@@ -53,7 +52,6 @@ import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -66,10 +64,9 @@ import java.util.stream.Collectors;
|
||||
import static org.springblade.common.constant.CommonConstant.DEFAULT_PARAM_PASSWORD;
|
||||
import static org.springblade.common.constant.CommonConstant.DEFAULT_PARAM_ROLE;
|
||||
import static org.springblade.common.constant.CommonConstant.DEFAULT_ROLE;
|
||||
import static org.springblade.common.constant.CommonConstant.YES;
|
||||
|
||||
/**
|
||||
* 从 OA 人员列表提取组织并同步人员
|
||||
* OA 人员同步:绑定已有组织机构,不维护组织树
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@@ -86,104 +83,312 @@ public class OaUserListSyncHelper {
|
||||
private final IRoleService roleService;
|
||||
|
||||
/**
|
||||
* 从人员数据提取二级公司、三级部门,并挂到集团顶级(id=135/GWWL) 下
|
||||
* 加载已有组织索引,并将人员旁路产生的 OAC/OAD 存量归并到正式组织。
|
||||
* <p>人员同步不再新建/更新组织树,须先执行组织架构同步。</p>
|
||||
*
|
||||
* @param oaPersons OA人员
|
||||
* @return 组织索引
|
||||
* @return 组织索引(仅含已有组织)
|
||||
*/
|
||||
public OaOrgIndex syncOrgsFromPersons(List<OAPersonResponse> oaPersons) {
|
||||
OaOrgIndex orgIndex = new OaOrgIndex();
|
||||
if (CollectionUtil.isEmpty(oaPersons)) {
|
||||
return orgIndex;
|
||||
}
|
||||
Date orgSyncStart = new Date();
|
||||
Dept rootCompany = this.getOrCreateRootCompany();
|
||||
String tenantId = resolveTenantId();
|
||||
Map<String, OAPersonResponse> companyPersonMap = new LinkedHashMap<>();
|
||||
Map<String, OAPersonResponse> departmentPersonMap = new LinkedHashMap<>();
|
||||
for (OAPersonResponse oaPerson : oaPersons) {
|
||||
String companyKey = resolveCompanyKey(oaPerson);
|
||||
if (StringUtils.isNotBlank(companyKey) && StringUtils.isNotBlank(oaPerson.getSubcompanyname())) {
|
||||
companyPersonMap.putIfAbsent(companyKey, oaPerson);
|
||||
}
|
||||
String departmentKey = resolveDepartmentKey(oaPerson);
|
||||
if (StringUtils.isNotBlank(departmentKey) && StringUtils.isNotBlank(oaPerson.getDepartmentname())
|
||||
&& StringUtils.isNotBlank(oaPerson.getSubcompanyname())) {
|
||||
departmentPersonMap.putIfAbsent(departmentKey, oaPerson);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Dept> existingCompanyByCode = new HashMap<>();
|
||||
Map<String, Dept> existingCompanyByName = new HashMap<>();
|
||||
deptService.list(Wrappers.<Dept>lambdaQuery()
|
||||
.eq(Dept::getParentId, rootCompany.getId())
|
||||
.eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode())
|
||||
).forEach(dept -> {
|
||||
if (StringUtils.isNotBlank(dept.getDeptCode())) {
|
||||
existingCompanyByCode.put(dept.getDeptCode(), dept);
|
||||
}
|
||||
if (StringUtils.isNotBlank(dept.getDeptName())) {
|
||||
existingCompanyByName.put(dept.getDeptName(), dept);
|
||||
}
|
||||
});
|
||||
List<Dept> addCompanies = new ArrayList<>();
|
||||
List<Dept> updateCompanies = new ArrayList<>();
|
||||
companyPersonMap.forEach((companyKey, oaPerson) -> {
|
||||
String oaCode = buildCompanyCode(oaPerson, companyKey);
|
||||
Dept existing = existingCompanyByCode.get(oaCode);
|
||||
if (existing == null) {
|
||||
existing = existingCompanyByName.get(oaPerson.getSubcompanyname());
|
||||
}
|
||||
Dept company = this.upsertOrg(oaPerson.getSubcompanyname(), oaCode, existing, rootCompany, tenantId,
|
||||
DeptCategory.COMPANY, addCompanies, updateCompanies);
|
||||
orgIndex.companyByOaId.put(companyKey, company);
|
||||
});
|
||||
this.saveOrgs(addCompanies, updateCompanies, DeptCategory.COMPANY);
|
||||
|
||||
Map<String, Dept> existingDeptByCode = new HashMap<>();
|
||||
Map<String, Dept> existingDeptByParentAndName = new HashMap<>();
|
||||
List<Long> companyIds = orgIndex.companyByOaId.values().stream()
|
||||
.map(Dept::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (CollectionUtil.isNotEmpty(companyIds)) {
|
||||
deptService.list(Wrappers.<Dept>lambdaQuery()
|
||||
.in(Dept::getParentId, companyIds)
|
||||
.eq(Dept::getDeptCategory, DeptCategory.DEPT.getCode())
|
||||
).forEach(dept -> {
|
||||
if (StringUtils.isNotBlank(dept.getDeptCode())) {
|
||||
existingDeptByCode.put(dept.getDeptCode(), dept);
|
||||
}
|
||||
if (dept.getParentId() != null && StringUtils.isNotBlank(dept.getDeptName())) {
|
||||
existingDeptByParentAndName.put(dept.getParentId() + "#" + dept.getDeptName(), dept);
|
||||
}
|
||||
});
|
||||
}
|
||||
List<Dept> addDepartments = new ArrayList<>();
|
||||
List<Dept> updateDepartments = new ArrayList<>();
|
||||
departmentPersonMap.forEach((departmentKey, oaPerson) -> {
|
||||
Dept parentCompany = orgIndex.findCompany(oaPerson);
|
||||
if (parentCompany == null || parentCompany.getId() == null) {
|
||||
return;
|
||||
}
|
||||
String oaCode = buildDepartmentCode(oaPerson, departmentKey);
|
||||
Dept existing = existingDeptByCode.get(oaCode);
|
||||
if (existing == null) {
|
||||
existing = existingDeptByParentAndName.get(parentCompany.getId() + "#" + oaPerson.getDepartmentname());
|
||||
}
|
||||
Dept department = this.upsertOrg(oaPerson.getDepartmentname(), oaCode, existing, parentCompany, tenantId,
|
||||
DeptCategory.DEPT, addDepartments, updateDepartments);
|
||||
orgIndex.deptByOaId.put(departmentKey, department);
|
||||
orgIndex.deptByCompanyAndName.put(resolveCompanyKey(oaPerson) + "#" + oaPerson.getDepartmentname(), department);
|
||||
});
|
||||
this.saveOrgs(addDepartments, updateDepartments, DeptCategory.DEPT);
|
||||
deptService.updateAncestors(orgSyncStart);
|
||||
ComposeLogUtil.getLastLog().info("同步人员提取组织完成,二级公司{}个,三级部门{}个",
|
||||
this.repairPersonBypassOrgs();
|
||||
this.loadExistingOrgIndex(orgIndex);
|
||||
ComposeLogUtil.getLastLog().info("人员同步绑定已有组织,二级公司{}个,三级部门{}个",
|
||||
orgIndex.companyByOaId.size(), orgIndex.deptByOaId.size());
|
||||
return orgIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 OAC/OAD 旁路节点上的人员关系迁移到同 oa_id 的正式组织,并逻辑删除旁路节点
|
||||
*/
|
||||
private void repairPersonBypassOrgs() {
|
||||
List<Dept> companyList = deptService.list(Wrappers.<Dept>lambdaQuery()
|
||||
.eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode()));
|
||||
List<Dept> departmentList = deptService.list(Wrappers.<Dept>lambdaQuery()
|
||||
.eq(Dept::getDeptCategory, DeptCategory.DEPT.getCode()));
|
||||
Map<String, Dept> officialCompanyByOaId = new HashMap<>();
|
||||
Map<String, Dept> officialDeptByOaId = new HashMap<>();
|
||||
Map<Long, Dept> companyById = new HashMap<>();
|
||||
for (Dept company : companyList) {
|
||||
if (company.getId() != null) {
|
||||
companyById.put(company.getId(), company);
|
||||
}
|
||||
if (!isPersonBypassCode(company.getDeptCode(), DeptCategory.COMPANY)) {
|
||||
this.putPreferredByOaId(officialCompanyByOaId, company, DeptCategory.COMPANY);
|
||||
}
|
||||
}
|
||||
for (Dept department : departmentList) {
|
||||
if (!isPersonBypassCode(department.getDeptCode(), DeptCategory.DEPT)) {
|
||||
this.putPreferredByOaId(officialDeptByOaId, department, DeptCategory.DEPT);
|
||||
}
|
||||
}
|
||||
|
||||
Set<Long> softDeleteIds = new HashSet<>();
|
||||
Set<Long> affectedUserIds = new HashSet<>();
|
||||
int migratedDeptBindingCount = 0;
|
||||
int migratedCompanyBindingCount = 0;
|
||||
|
||||
for (Dept bypass : departmentList) {
|
||||
if (!isPersonBypassCode(bypass.getDeptCode(), DeptCategory.DEPT) || bypass.getId() == null) {
|
||||
continue;
|
||||
}
|
||||
String oaId = StringUtils.trimToNull(bypass.getOaId());
|
||||
if (oaId == null) {
|
||||
oaId = extractOaIdFromBypassCode(bypass.getDeptCode(), DeptCategory.DEPT);
|
||||
}
|
||||
if (oaId == null) {
|
||||
continue;
|
||||
}
|
||||
Dept official = officialDeptByOaId.get(oaId);
|
||||
if (official == null || official.getId() == null || Objects.equals(official.getId(), bypass.getId())
|
||||
|| isPersonBypassCode(official.getDeptCode(), DeptCategory.DEPT)) {
|
||||
continue;
|
||||
}
|
||||
Dept officialCompany = resolveBelongCompany(official, companyById);
|
||||
migratedDeptBindingCount += this.migrateUserDeptBindings(bypass, official, officialCompany, affectedUserIds);
|
||||
softDeleteIds.add(bypass.getId());
|
||||
}
|
||||
|
||||
for (Dept bypass : companyList) {
|
||||
if (!isPersonBypassCode(bypass.getDeptCode(), DeptCategory.COMPANY) || bypass.getId() == null) {
|
||||
continue;
|
||||
}
|
||||
String oaId = StringUtils.trimToNull(bypass.getOaId());
|
||||
if (oaId == null) {
|
||||
oaId = extractOaIdFromBypassCode(bypass.getDeptCode(), DeptCategory.COMPANY);
|
||||
}
|
||||
if (oaId == null) {
|
||||
continue;
|
||||
}
|
||||
Dept official = officialCompanyByOaId.get(oaId);
|
||||
if (official == null || official.getId() == null || Objects.equals(official.getId(), bypass.getId())
|
||||
|| isPersonBypassCode(official.getDeptCode(), DeptCategory.COMPANY)) {
|
||||
continue;
|
||||
}
|
||||
migratedCompanyBindingCount += this.migrateUserCompanyBindings(bypass, official, affectedUserIds);
|
||||
long childCount = deptService.count(Wrappers.<Dept>lambdaQuery().eq(Dept::getParentId, bypass.getId()));
|
||||
if (childCount == 0) {
|
||||
softDeleteIds.add(bypass.getId());
|
||||
} else {
|
||||
ComposeLogUtil.getLastLog().warn("旁路公司仍有子节点,暂不删除 companyId={} deptCode={} childCount={}",
|
||||
bypass.getId(), bypass.getDeptCode(), childCount);
|
||||
}
|
||||
}
|
||||
|
||||
if (CollectionUtil.isNotEmpty(softDeleteIds)) {
|
||||
deptService.removeByIds(softDeleteIds);
|
||||
ComposeLogUtil.getLastLog().info("人员同步归并旁路组织完成,迁移部门关系{}条,迁移公司关系{}条,逻辑删除旁路节点{}个",
|
||||
migratedDeptBindingCount, migratedCompanyBindingCount, softDeleteIds.size());
|
||||
} else if (migratedDeptBindingCount > 0 || migratedCompanyBindingCount > 0) {
|
||||
ComposeLogUtil.getLastLog().info("人员同步归并旁路组织完成,迁移部门关系{}条,迁移公司关系{}条",
|
||||
migratedDeptBindingCount, migratedCompanyBindingCount);
|
||||
}
|
||||
this.refreshUserDeptFields(affectedUserIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅加载已有组织,按 oa_id / 名称建立索引供人员绑定
|
||||
*/
|
||||
private void loadExistingOrgIndex(OaOrgIndex orgIndex) {
|
||||
Map<String, Dept> companyByOaId = new HashMap<>();
|
||||
Map<String, Dept> companyByName = new HashMap<>();
|
||||
Map<Long, Dept> companyById = new HashMap<>();
|
||||
deptService.list(Wrappers.<Dept>lambdaQuery()
|
||||
.eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode())
|
||||
).forEach(dept -> {
|
||||
if (dept.getId() != null) {
|
||||
companyById.put(dept.getId(), dept);
|
||||
}
|
||||
this.putPreferredByOaKey(companyByOaId, resolveOrgOaKey(dept, DeptCategory.COMPANY), dept, DeptCategory.COMPANY);
|
||||
if (StringUtils.isNotBlank(dept.getDeptName())) {
|
||||
Dept previous = companyByName.get(dept.getDeptName());
|
||||
if (previous == null
|
||||
|| (isPersonBypassCode(previous.getDeptCode(), DeptCategory.COMPANY)
|
||||
&& !isPersonBypassCode(dept.getDeptCode(), DeptCategory.COMPANY))) {
|
||||
companyByName.put(dept.getDeptName(), dept);
|
||||
}
|
||||
}
|
||||
});
|
||||
companyByOaId.forEach((oaId, dept) -> orgIndex.companyByOaId.put(oaId, dept));
|
||||
companyByName.forEach((name, dept) -> orgIndex.companyByOaId.putIfAbsent("NAME:" + name, dept));
|
||||
|
||||
Map<String, Dept> deptByOaId = new HashMap<>();
|
||||
deptService.list(Wrappers.<Dept>lambdaQuery()
|
||||
.eq(Dept::getDeptCategory, DeptCategory.DEPT.getCode())
|
||||
).forEach(dept -> {
|
||||
this.putPreferredByOaKey(deptByOaId, resolveOrgOaKey(dept, DeptCategory.DEPT), dept, DeptCategory.DEPT);
|
||||
Dept parentCompany = resolveBelongCompany(dept, companyById);
|
||||
if (parentCompany == null || StringUtils.isBlank(dept.getDeptName())) {
|
||||
return;
|
||||
}
|
||||
String companyKey = resolveOrgOaKey(parentCompany, DeptCategory.COMPANY);
|
||||
if (companyKey == null && StringUtils.isNotBlank(parentCompany.getDeptName())) {
|
||||
companyKey = "NAME:" + parentCompany.getDeptName();
|
||||
}
|
||||
if (companyKey == null) {
|
||||
return;
|
||||
}
|
||||
String nameKey = companyKey + "#" + dept.getDeptName();
|
||||
Dept previous = orgIndex.deptByCompanyAndName.get(nameKey);
|
||||
if (previous == null
|
||||
|| (isPersonBypassCode(previous.getDeptCode(), DeptCategory.DEPT)
|
||||
&& !isPersonBypassCode(dept.getDeptCode(), DeptCategory.DEPT))) {
|
||||
orgIndex.deptByCompanyAndName.put(nameKey, dept);
|
||||
}
|
||||
});
|
||||
orgIndex.deptByOaId.putAll(deptByOaId);
|
||||
}
|
||||
|
||||
/** 优先取 oa_id;旁路空 oa_id 时从 OAC/OAD 编码后缀解析 */
|
||||
private String resolveOrgOaKey(Dept dept, DeptCategory deptCategory) {
|
||||
if (dept == null) {
|
||||
return null;
|
||||
}
|
||||
String oaId = StringUtils.trimToNull(dept.getOaId());
|
||||
if (oaId != null) {
|
||||
return oaId;
|
||||
}
|
||||
return extractOaIdFromBypassCode(dept.getDeptCode(), deptCategory);
|
||||
}
|
||||
|
||||
private int migrateUserDeptBindings(Dept bypass, Dept official, Dept officialCompany, Set<Long> affectedUserIds) {
|
||||
List<UserDept> bindingList = userDeptService.list(Wrappers.<UserDept>lambdaQuery()
|
||||
.and(wrapper -> wrapper.eq(UserDept::getDeptId, bypass.getId())
|
||||
.or().eq(UserDept::getDeptCode, bypass.getDeptCode())));
|
||||
if (CollectionUtil.isEmpty(bindingList)) {
|
||||
return 0;
|
||||
}
|
||||
Set<Long> userIds = bindingList.stream()
|
||||
.map(UserDept::getUserId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
Map<String, UserDept> existingByKey = userDeptService.list(Wrappers.<UserDept>lambdaQuery()
|
||||
.in(UserDept::getUserId, userIds)
|
||||
).stream()
|
||||
.collect(Collectors.toMap(this::getUserDeptKey, userDept -> userDept, (first, second) -> first));
|
||||
List<UserDept> updateList = new ArrayList<>();
|
||||
List<Long> removeIds = new ArrayList<>();
|
||||
for (UserDept binding : bindingList) {
|
||||
if (binding.getUserId() == null) {
|
||||
continue;
|
||||
}
|
||||
affectedUserIds.add(binding.getUserId());
|
||||
String companyCode = officialCompany != null ? officialCompany.getDeptCode() : binding.getCompanyCode();
|
||||
String companyName = officialCompany != null ? officialCompany.getDeptName() : binding.getCompanyName();
|
||||
String newKey = binding.getUserId() + StringUtils.defaultString(companyCode)
|
||||
+ StringUtils.defaultString(official.getDeptCode());
|
||||
UserDept alreadyOfficial = existingByKey.get(newKey);
|
||||
if (alreadyOfficial != null && !Objects.equals(alreadyOfficial.getId(), binding.getId())) {
|
||||
removeIds.add(binding.getId());
|
||||
continue;
|
||||
}
|
||||
binding.setDeptId(official.getId());
|
||||
binding.setDeptCode(official.getDeptCode());
|
||||
binding.setDeptName(official.getDeptName());
|
||||
if (officialCompany != null) {
|
||||
binding.setCompanyCode(companyCode);
|
||||
binding.setCompanyName(companyName);
|
||||
}
|
||||
updateList.add(binding);
|
||||
existingByKey.put(newKey, binding);
|
||||
}
|
||||
if (CollectionUtil.isNotEmpty(updateList)) {
|
||||
userDeptService.updateBatchById(updateList);
|
||||
}
|
||||
if (CollectionUtil.isNotEmpty(removeIds)) {
|
||||
userDeptService.removeByIds(removeIds);
|
||||
}
|
||||
return updateList.size() + removeIds.size();
|
||||
}
|
||||
|
||||
private int migrateUserCompanyBindings(Dept bypass, Dept official, Set<Long> affectedUserIds) {
|
||||
if (StringUtils.isBlank(bypass.getDeptCode())) {
|
||||
return 0;
|
||||
}
|
||||
List<UserDept> bindingList = userDeptService.list(Wrappers.<UserDept>lambdaQuery()
|
||||
.eq(UserDept::getCompanyCode, bypass.getDeptCode()));
|
||||
if (CollectionUtil.isEmpty(bindingList)) {
|
||||
return 0;
|
||||
}
|
||||
List<UserDept> updateList = new ArrayList<>();
|
||||
for (UserDept binding : bindingList) {
|
||||
if (binding.getUserId() != null) {
|
||||
affectedUserIds.add(binding.getUserId());
|
||||
}
|
||||
binding.setCompanyCode(official.getDeptCode());
|
||||
binding.setCompanyName(official.getDeptName());
|
||||
updateList.add(binding);
|
||||
}
|
||||
userDeptService.updateBatchById(updateList);
|
||||
return updateList.size();
|
||||
}
|
||||
|
||||
private void refreshUserDeptFields(Set<Long> userIds) {
|
||||
if (CollectionUtil.isEmpty(userIds)) {
|
||||
return;
|
||||
}
|
||||
List<UserDeptIdsVO> userDeptIdsList = userDeptService.queryUserDeptIds(userIds);
|
||||
List<User> updateUserParams = userDeptIdsList.stream()
|
||||
.filter(userDeptIds -> StringUtils.isNotBlank(userDeptIds.getDeptIds()))
|
||||
.filter(userDeptIds -> {
|
||||
int deptIdLength = userDeptIds.getDeptIds().length();
|
||||
int deptCodesLength = StringUtils.length(userDeptIds.getDeptCodes());
|
||||
return deptIdLength <= MAX_DEPT_ID_LENGTH && deptCodesLength <= MAX_DEPT_ID_LENGTH;
|
||||
})
|
||||
.map(userDeptIds -> {
|
||||
User user = new User();
|
||||
user.setId(userDeptIds.getUserId());
|
||||
user.setDeptId(userDeptIds.getDeptIds());
|
||||
user.setDeptCodes(userDeptIds.getDeptCodes());
|
||||
return user;
|
||||
}).toList();
|
||||
if (CollectionUtil.isNotEmpty(updateUserParams)) {
|
||||
userService.updateBatchById(updateUserParams);
|
||||
}
|
||||
}
|
||||
|
||||
private Dept resolveBelongCompany(Dept department, Map<Long, Dept> companyById) {
|
||||
if (department == null) {
|
||||
return null;
|
||||
}
|
||||
if (department.getParentId() != null) {
|
||||
Dept parent = companyById.get(department.getParentId());
|
||||
if (parent != null) {
|
||||
return parent;
|
||||
}
|
||||
}
|
||||
if (StringUtils.isNotBlank(department.getBelongCompanyCode())) {
|
||||
return companyById.values().stream()
|
||||
.filter(company -> department.getBelongCompanyCode().equals(company.getDeptCode()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 OAC37 / OAD37 旁路编码解析 OA 主键;哈希旁路(OACNxxx)返回 null
|
||||
*/
|
||||
private String extractOaIdFromBypassCode(String deptCode, DeptCategory deptCategory) {
|
||||
if (StringUtils.isBlank(deptCode) || !isPersonBypassCode(deptCode, deptCategory)) {
|
||||
return null;
|
||||
}
|
||||
String prefix = DeptCategory.COMPANY.equals(deptCategory)
|
||||
? OAConvertConstant.COMPANY_OA_PREFIX
|
||||
: OAConvertConstant.DEPARTMENT_OA_PREFIX;
|
||||
if (deptCode.length() <= prefix.length()) {
|
||||
return null;
|
||||
}
|
||||
String suffix = deptCode.substring(prefix.length());
|
||||
if (suffix.isEmpty() || suffix.startsWith("N")) {
|
||||
return null;
|
||||
}
|
||||
return suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步人员并绑定到三级部门
|
||||
*
|
||||
@@ -198,70 +403,37 @@ public class OaUserListSyncHelper {
|
||||
Map<String, OAPersonResponse> uniquePersonMap = new LinkedHashMap<>();
|
||||
int skippedCount = 0;
|
||||
for (OAPersonResponse oaPerson : oaPersons) {
|
||||
String account = userConvert.resolveAccount(oaPerson);
|
||||
if (StringUtils.isBlank(account)) {
|
||||
String oaLoginId = resolveOaLoginId(oaPerson);
|
||||
if (StringUtils.isBlank(oaLoginId)) {
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
uniquePersonMap.putIfAbsent(account, oaPerson);
|
||||
uniquePersonMap.put(oaLoginId, oaPerson);
|
||||
}
|
||||
if (uniquePersonMap.isEmpty()) {
|
||||
ComposeLogUtil.getLastLog().warn("OA人员均缺少loginid/工号/手机号,跳过人员同步");
|
||||
ComposeLogUtil.getLastLog().warn("OA人员均缺少登录名,跳过人员同步");
|
||||
return new PersonSyncCount(0, skippedCount);
|
||||
}
|
||||
String tenantId = resolveTenantId();
|
||||
String defaultPassword = DigestUtil.encrypt(ParamCache.getValue(DEFAULT_PARAM_PASSWORD));
|
||||
List<User> users = uniquePersonMap.values().stream()
|
||||
.map(person -> {
|
||||
User user = userConvert.person2user(person, defaultPassword);
|
||||
List<User> users = uniquePersonMap.entrySet().stream()
|
||||
.map(entry -> {
|
||||
User user = userConvert.person2user(entry.getValue(), defaultPassword);
|
||||
user.setTenantId(tenantId);
|
||||
user.setOaLoginId(entry.getKey());
|
||||
user.setAccount(entry.getKey());
|
||||
return user;
|
||||
})
|
||||
.toList();
|
||||
List<String> accounts = users.stream()
|
||||
.map(User::getAccount)
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.distinct()
|
||||
.toList();
|
||||
List<String> phones = users.stream()
|
||||
.map(User::getPhone)
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<String, User> existingByAccount = new HashMap<>();
|
||||
Map<String, User> existingByPhone = new HashMap<>();
|
||||
userService.list(Wrappers.<User>lambdaQuery()
|
||||
.and(wrapper -> {
|
||||
wrapper.in(User::getAccount, accounts);
|
||||
if (CollectionUtil.isNotEmpty(phones)) {
|
||||
wrapper.or().in(User::getPhone, phones);
|
||||
}
|
||||
})
|
||||
).stream()
|
||||
.peek(userService::decryptPhone)
|
||||
.forEach(user -> {
|
||||
if (StringUtils.isNotBlank(user.getAccount())) {
|
||||
existingByAccount.put(user.getAccount(), user);
|
||||
}
|
||||
if (StringUtils.isNotBlank(user.getPhone())) {
|
||||
existingByPhone.put(user.getPhone(), user);
|
||||
}
|
||||
});
|
||||
Map<String, User> existingByOaLoginId = this.loadUsersByOaLoginId(tenantId, uniquePersonMap.keySet());
|
||||
Map<String, Long> userMap = new HashMap<>();
|
||||
Set<Long> existsUserIds = new HashSet<>();
|
||||
Set<Long> unchangedUserIds = new HashSet<>();
|
||||
users.forEach(user -> {
|
||||
User existingUser = existingByAccount.get(user.getAccount());
|
||||
if (existingUser == null && StringUtils.isNotBlank(user.getPhone())) {
|
||||
existingUser = existingByPhone.get(user.getPhone());
|
||||
}
|
||||
User existingUser = existingByOaLoginId.get(user.getOaLoginId());
|
||||
if (existingUser != null) {
|
||||
user.setId(existingUser.getId());
|
||||
user.setPassword(null);
|
||||
existsUserIds.add(existingUser.getId());
|
||||
if (shouldSkipOaUpdate(existingUser, user)) {
|
||||
unchangedUserIds.add(existingUser.getId());
|
||||
}
|
||||
} else {
|
||||
user.setId(DefaultIdentifierGenerator.getInstance().nextId(null));
|
||||
user.setPostId("-1");
|
||||
@@ -269,9 +441,9 @@ public class OaUserListSyncHelper {
|
||||
user.setDataScopeRange(1);
|
||||
user.setDataLevelRange(1);
|
||||
user.setIncludeNewCustomer(0);
|
||||
userService.encryptPhone(user);
|
||||
}
|
||||
userMap.put(user.getAccount(), user.getId());
|
||||
userService.encryptPhone(user);
|
||||
userMap.put(user.getOaLoginId(), user.getId());
|
||||
});
|
||||
|
||||
List<User> addUsers = users.stream()
|
||||
@@ -283,18 +455,13 @@ public class OaUserListSyncHelper {
|
||||
}
|
||||
List<User> updateUsers = users.stream()
|
||||
.filter(user -> existsUserIds.contains(user.getId()))
|
||||
.filter(user -> !unchangedUserIds.contains(user.getId()))
|
||||
.toList();
|
||||
if (CollectionUtil.isNotEmpty(updateUsers)) {
|
||||
ComposeLogUtil.getLastLog().info("批量修改用户:{}", updateUsers.size());
|
||||
userService.updateBatchById(updateUsers);
|
||||
}
|
||||
if (!unchangedUserIds.isEmpty()) {
|
||||
ComposeLogUtil.getLastLog().info("跳过未变更用户:{}", unchangedUserIds.size());
|
||||
}
|
||||
ComposeLogUtil.getLastLog().info("缺少账号已跳过的人员:{}", skippedCount);
|
||||
ComposeLogUtil.getLastLog().info("缺少OA登录名已跳过的人员:{}", skippedCount);
|
||||
int syncedCount = addUsers.size() + updateUsers.size();
|
||||
skippedCount += unchangedUserIds.size();
|
||||
if (userMap.isEmpty()) {
|
||||
return new PersonSyncCount(0, skippedCount);
|
||||
}
|
||||
@@ -306,11 +473,8 @@ public class OaUserListSyncHelper {
|
||||
Set<Long> existsUserDeptIds = new HashSet<>(userDeptMap.values());
|
||||
List<UserDept> userDeptList = oaPersons.stream()
|
||||
.filter(oaPerson -> {
|
||||
String account = userConvert.resolveAccount(oaPerson);
|
||||
if (!userMap.containsKey(account)) {
|
||||
return false;
|
||||
}
|
||||
return !unchangedUserIds.contains(userMap.get(account));
|
||||
String oaLoginId = resolveOaLoginId(oaPerson);
|
||||
return StringUtils.isNotBlank(oaLoginId) && userMap.containsKey(oaLoginId);
|
||||
})
|
||||
.map(oaPerson -> this.buildUserDept(oaPerson, userMap, orgIndex))
|
||||
.filter(Objects::nonNull)
|
||||
@@ -349,7 +513,6 @@ public class OaUserListSyncHelper {
|
||||
.collect(Collectors.toSet());
|
||||
String defaultRoleId = getDefaultRoleId();
|
||||
List<User> updateUserParams = list.stream()
|
||||
.filter(userDeptIds -> !unchangedUserIds.contains(userDeptIds.getUserId()))
|
||||
.filter(userDeptIds -> {
|
||||
if (StringUtils.isBlank(userDeptIds.getDeptIds())) {
|
||||
return false;
|
||||
@@ -381,87 +544,94 @@ public class OaUserListSyncHelper {
|
||||
}
|
||||
|
||||
/**
|
||||
* oaModified 一致则跳过更新;任一侧为空无法确认一致,执行更新以补齐字段
|
||||
* 按 OA 登录名加载已有用户。同一登录名有多条时保留较早的一条,后续同步都更新这一条。
|
||||
*/
|
||||
private boolean shouldSkipOaUpdate(User existing, User incoming) {
|
||||
if (existing == null) {
|
||||
return false;
|
||||
private Map<String, User> loadUsersByOaLoginId(String tenantId, Collection<String> oaLoginIds) {
|
||||
Map<String, User> existingByOaLoginId = new HashMap<>();
|
||||
if (CollectionUtil.isEmpty(oaLoginIds)) {
|
||||
return existingByOaLoginId;
|
||||
}
|
||||
String localModified = StringUtils.trimToEmpty(existing.getOaModified());
|
||||
String remoteModified = StringUtils.trimToEmpty(incoming == null ? null : incoming.getOaModified());
|
||||
if (localModified.isEmpty() || remoteModified.isEmpty()) {
|
||||
return false;
|
||||
List<String> oaLoginIdList = oaLoginIds.stream()
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.distinct()
|
||||
.toList();
|
||||
int batchSize = 500;
|
||||
for (int fromIndex = 0; fromIndex < oaLoginIdList.size(); fromIndex += batchSize) {
|
||||
List<String> oaLoginIdBatch = oaLoginIdList.subList(fromIndex, Math.min(fromIndex + batchSize, oaLoginIdList.size()));
|
||||
userService.list(Wrappers.<User>lambdaQuery()
|
||||
.eq(User::getTenantId, tenantId)
|
||||
.in(User::getOaLoginId, oaLoginIdBatch))
|
||||
.forEach(user -> {
|
||||
String oaLoginId = StringUtils.trimToNull(user.getOaLoginId());
|
||||
if (oaLoginId == null) {
|
||||
return;
|
||||
}
|
||||
User previous = existingByOaLoginId.get(oaLoginId);
|
||||
if (previous == null) {
|
||||
existingByOaLoginId.put(oaLoginId, user);
|
||||
return;
|
||||
}
|
||||
if (user.getId() != null && previous.getId() != null && user.getId() < previous.getId()) {
|
||||
ComposeLogUtil.getLastLog().warn("OA登录名重复,保留较早用户 oaLoginId={} keepUserId={} dropUserId={}",
|
||||
oaLoginId, user.getId(), previous.getId());
|
||||
existingByOaLoginId.put(oaLoginId, user);
|
||||
return;
|
||||
}
|
||||
ComposeLogUtil.getLastLog().warn("OA登录名重复,保留较早用户 oaLoginId={} keepUserId={} dropUserId={}",
|
||||
oaLoginId, previous.getId(), user.getId());
|
||||
});
|
||||
}
|
||||
return localModified.equals(remoteModified);
|
||||
return existingByOaLoginId;
|
||||
}
|
||||
|
||||
private Dept getOrCreateRootCompany() {
|
||||
return deptService.ensureOaSyncRootDept();
|
||||
private String resolveOaLoginId(OAPersonResponse oaPerson) {
|
||||
if (oaPerson == null) {
|
||||
return null;
|
||||
}
|
||||
return StringUtils.trimToNull(oaPerson.getLoginid());
|
||||
}
|
||||
|
||||
private Dept upsertOrg(String name, String oaCode, Dept existing, Dept parent, String tenantId,
|
||||
DeptCategory deptCategory, List<Dept> addList, List<Dept> updateList) {
|
||||
if (existing != null) {
|
||||
Dept updateParam = new Dept();
|
||||
updateParam.setId(existing.getId());
|
||||
updateParam.setDeptName(name);
|
||||
updateParam.setFullName(name);
|
||||
updateParam.setShortName(name);
|
||||
updateParam.setParentId(parent.getId());
|
||||
updateParam.setParentCode(parent.getDeptCode());
|
||||
updateParam.setAncestors(buildAncestors(parent));
|
||||
updateParam.setIsOa(YES);
|
||||
updateParam.setSyncTime(new Date());
|
||||
updateList.add(updateParam);
|
||||
existing.setDeptName(name);
|
||||
existing.setFullName(name);
|
||||
existing.setShortName(name);
|
||||
existing.setParentId(parent.getId());
|
||||
existing.setParentCode(parent.getDeptCode());
|
||||
existing.setAncestors(updateParam.getAncestors());
|
||||
return existing;
|
||||
private boolean isPersonBypassCode(String deptCode, DeptCategory deptCategory) {
|
||||
if (StringUtils.isBlank(deptCode)) {
|
||||
return true;
|
||||
}
|
||||
Dept dept = this.buildOrgDept(name, oaCode, parent, tenantId, deptCategory);
|
||||
dept.setId(DefaultIdentifierGenerator.getInstance().nextId(null));
|
||||
addList.add(dept);
|
||||
return dept;
|
||||
if (DeptCategory.COMPANY.equals(deptCategory)) {
|
||||
return deptCode.startsWith(OAConvertConstant.COMPANY_OA_PREFIX);
|
||||
}
|
||||
return deptCode.startsWith(OAConvertConstant.DEPARTMENT_OA_PREFIX);
|
||||
}
|
||||
|
||||
private void saveOrgs(List<Dept> addList, List<Dept> updateList, DeptCategory deptCategory) {
|
||||
if (CollectionUtil.isNotEmpty(addList)) {
|
||||
ComposeLogUtil.getLastLog().info("批量新增{}:{}", deptCategory.getName(), addList.size());
|
||||
deptService.saveBatch(addList);
|
||||
}
|
||||
if (CollectionUtil.isNotEmpty(updateList)) {
|
||||
ComposeLogUtil.getLastLog().info("批量修改{}:{}", deptCategory.getName(), updateList.size());
|
||||
deptService.updateBatchById(updateList);
|
||||
/** 同一 oa_id 多条时优先正式编码,避免人员挂到 OAC/OAD 旁路重复节点。 */
|
||||
private void putPreferredByOaId(Map<String, Dept> byOaId, Dept dept, DeptCategory deptCategory) {
|
||||
if (dept == null || StringUtils.isBlank(dept.getOaId())) {
|
||||
return;
|
||||
}
|
||||
this.putPreferredByOaKey(byOaId, dept.getOaId().trim(), dept, deptCategory);
|
||||
}
|
||||
|
||||
private Dept buildOrgDept(String name, String deptCode, Dept parent, String tenantId, DeptCategory deptCategory) {
|
||||
Dept dept = new Dept();
|
||||
dept.setTenantId(tenantId);
|
||||
dept.setParentId(parent.getId());
|
||||
dept.setParentCode(parent.getDeptCode());
|
||||
dept.setAncestors(this.buildAncestors(parent));
|
||||
dept.setDeptName(name);
|
||||
dept.setFullName(name);
|
||||
dept.setShortName(name);
|
||||
dept.setDeptCode(deptCode);
|
||||
dept.setBelongCompanyCode(DeptCategory.COMPANY.equals(deptCategory) ? deptCode : parent.getBelongCompanyCode());
|
||||
dept.setDeptCategory(deptCategory.getCode());
|
||||
dept.setSort(0);
|
||||
dept.setStatus(DataStatusEnum.ENABLE.getCode());
|
||||
dept.setIsDeleted(BladeConstant.DB_NOT_DELETED);
|
||||
dept.setIsOa(YES);
|
||||
dept.setIsPlatformCompany(0);
|
||||
dept.setSyncTime(new Date());
|
||||
return dept;
|
||||
private void putPreferredByOaKey(Map<String, Dept> byOaId, String oaKey, Dept dept, DeptCategory deptCategory) {
|
||||
if (dept == null || StringUtils.isBlank(oaKey)) {
|
||||
return;
|
||||
}
|
||||
Dept previous = byOaId.get(oaKey);
|
||||
if (previous == null) {
|
||||
byOaId.put(oaKey, dept);
|
||||
return;
|
||||
}
|
||||
boolean previousBypass = isPersonBypassCode(previous.getDeptCode(), deptCategory);
|
||||
boolean currentBypass = isPersonBypassCode(dept.getDeptCode(), deptCategory);
|
||||
if (previousBypass && !currentBypass) {
|
||||
byOaId.put(oaKey, dept);
|
||||
}
|
||||
}
|
||||
|
||||
private UserDept buildUserDept(OAPersonResponse oaPerson, Map<String, Long> userMap, OaOrgIndex orgIndex) {
|
||||
Dept department = orgIndex.findDept(oaPerson);
|
||||
if (department == null || department.getId() == null) {
|
||||
ComposeLogUtil.getLastLog().warn(
|
||||
"人员未匹配到已有部门,请先同步组织架构 oaLoginId={} departmentid={} departmentname={} subcompanyid1={}",
|
||||
resolveOaLoginId(oaPerson), oaPerson.getDepartmentid(), oaPerson.getDepartmentname(),
|
||||
oaPerson.getSubcompanyid1());
|
||||
return null;
|
||||
}
|
||||
UserDept userDept = userConvert.person2userDept(oaPerson, userMap);
|
||||
@@ -493,55 +663,6 @@ public class OaUserListSyncHelper {
|
||||
return roleList.get(0).getId().toString();
|
||||
}
|
||||
|
||||
private String resolveCompanyKey(OAPersonResponse oaPerson) {
|
||||
if (oaPerson == null) {
|
||||
return null;
|
||||
}
|
||||
if (StringUtils.isNotBlank(oaPerson.getSubcompanyid1())) {
|
||||
return oaPerson.getSubcompanyid1().trim();
|
||||
}
|
||||
if (StringUtils.isNotBlank(oaPerson.getSubcompanyname())) {
|
||||
return "NAME:" + oaPerson.getSubcompanyname().trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String resolveDepartmentKey(OAPersonResponse oaPerson) {
|
||||
if (oaPerson == null) {
|
||||
return null;
|
||||
}
|
||||
if (StringUtils.isNotBlank(oaPerson.getDepartmentid())) {
|
||||
return oaPerson.getDepartmentid().trim();
|
||||
}
|
||||
String companyKey = resolveCompanyKey(oaPerson);
|
||||
if (StringUtils.isNotBlank(companyKey) && StringUtils.isNotBlank(oaPerson.getDepartmentname())) {
|
||||
return companyKey + ":" + oaPerson.getDepartmentname().trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String buildCompanyCode(OAPersonResponse oaPerson, String companyKey) {
|
||||
if (StringUtils.isNotBlank(oaPerson.getSubcompanyid1())) {
|
||||
return OAConvertConstant.COMPANY_OA_PREFIX + oaPerson.getSubcompanyid1().trim();
|
||||
}
|
||||
return OAConvertConstant.COMPANY_OA_PREFIX + "N" + Math.abs(companyKey.hashCode());
|
||||
}
|
||||
|
||||
private String buildDepartmentCode(OAPersonResponse oaPerson, String departmentKey) {
|
||||
if (StringUtils.isNotBlank(oaPerson.getDepartmentid())) {
|
||||
return OAConvertConstant.DEPARTMENT_OA_PREFIX + oaPerson.getDepartmentid().trim();
|
||||
}
|
||||
return OAConvertConstant.DEPARTMENT_OA_PREFIX + "N" + Math.abs(departmentKey.hashCode());
|
||||
}
|
||||
|
||||
private String buildAncestors(Dept parent) {
|
||||
String ancestors = parent.getAncestors();
|
||||
if (StringUtils.isBlank(ancestors)) {
|
||||
ancestors = String.valueOf(BladeConstant.TOP_PARENT_ID);
|
||||
}
|
||||
return ancestors + "," + parent.getId();
|
||||
}
|
||||
|
||||
private String resolveTenantId() {
|
||||
String tenantId = AuthUtil.getTenantId();
|
||||
if (StringUtils.isBlank(tenantId)) {
|
||||
|
||||
+3
-2
@@ -366,6 +366,7 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
|
||||
user.setPostId(null);
|
||||
user.setAccount(null);
|
||||
user.setIsLeader(null);
|
||||
user.setOaLoginId(null);
|
||||
CacheUtil.clear(USER_CACHE);
|
||||
return submitUserInfo(user);
|
||||
}
|
||||
@@ -407,8 +408,8 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
|
||||
@Override
|
||||
public IPage<User> selectUserPage(IPage<User> page, User user, Long deptId, String tenantId) {
|
||||
normalizeUserSearch(user);
|
||||
List<Long> deptIdList = SysCache.getDeptChildIds(deptId);
|
||||
return page.setRecords(baseMapper.selectUserPage(page, user, deptIdList, tenantId));
|
||||
// 部门过滤下推到 SQL(FIND_IN_SET),避免先展开整棵子树再拼超大 IN 列表
|
||||
return page.setRecords(baseMapper.selectUserPage(page, user, deptId, tenantId));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+6
@@ -309,6 +309,12 @@ public class BfiProperties {
|
||||
*/
|
||||
private String invoiceTypes = "";
|
||||
|
||||
/**
|
||||
* 客商单位过滤开关:开启后只同步开票单位(销方)与受票单位(购方)税号均已登记在
|
||||
* 客商发票信息(blade_customer_invoice_info)中的发票,前端票池展示查询同用此开关过滤,默认关闭
|
||||
*/
|
||||
private boolean customerUnitsOnly = false;
|
||||
|
||||
/**
|
||||
* 同步 single-flight 锁租约秒数,须大于最坏一轮耗时;进程异常退出时锁在租约到期后自动释放
|
||||
*/
|
||||
|
||||
+3
@@ -32,6 +32,9 @@ public final class KingdeeLockConstant {
|
||||
/** 来源操作类型:单据删除(解锁回调携带 DELETE 时 WLTMS 废除源单并还原关联数据) */
|
||||
public static final String OPERATION_DELETE = "DELETE";
|
||||
|
||||
/** 来源操作类型:结算调整重推(结算调整单重推后解锁原结算单旧锁记录,非金蝶回调) */
|
||||
public static final String OPERATION_ADJUST_REPUSH = "ADJUST_REPUSH";
|
||||
|
||||
/** 系统标识:WLTMS(运输管理系统,即本系统) */
|
||||
public static final String SYSTEM_WLTMS = "WLTMS";
|
||||
|
||||
|
||||
+12
@@ -31,9 +31,21 @@ package org.springblade.transport.constant;
|
||||
*/
|
||||
public final class TransportBusiConstant {
|
||||
|
||||
/** 付款类型:项目预付。 */
|
||||
public static final String PAYMENT_TYPE_PROJECT_ADVANCE = "project_advance";
|
||||
|
||||
/** 付款类型:进度预付。 */
|
||||
public static final String PAYMENT_TYPE_PROGRESS_ADVANCE = "progress_advance";
|
||||
|
||||
/** 付款类型:结算付款(关联正式结算单的正式付款)。 */
|
||||
public static final String PAYMENT_TYPE_SETTLEMENT = "settlement_payment";
|
||||
|
||||
/** 结算单类型:预结算。 */
|
||||
public static final String SETTLEMENT_BILL_PRE = "pre";
|
||||
|
||||
/** 结算单类型:正式结算。 */
|
||||
public static final String SETTLEMENT_BILL_FORMAL = "formal";
|
||||
|
||||
/** 审批状态:草稿(允许编辑、删除)。 */
|
||||
public static final String DRAFT = "draft";
|
||||
|
||||
|
||||
+7
-4
@@ -61,8 +61,9 @@ public class BillLedgerController extends BladeController {
|
||||
@ApiOperationSupport(order = 4)
|
||||
@Operation(summary = "付款申请可用汇票")
|
||||
public R<List<BillLedgerVO>> availableOptions(@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) Long deptId, @RequestParam(required = false) Long selectedId) {
|
||||
return R.data(billLedgerService.availableOptions(keyword, deptId, selectedId));
|
||||
@RequestParam(required = false) Long deptId, @RequestParam(required = false) Long selectedId,
|
||||
@RequestParam(required = false) String payerName, @RequestParam(required = false) String payeeName) {
|
||||
return R.data(billLedgerService.availableOptions(keyword, deptId, selectedId, payerName, payeeName));
|
||||
}
|
||||
|
||||
@GetMapping("/available-page")
|
||||
@@ -70,8 +71,10 @@ public class BillLedgerController extends BladeController {
|
||||
@Operation(summary = "付款申请可用汇票分页")
|
||||
public R<IPage<BillLedgerVO>> availablePage(Query pageQuery,
|
||||
@RequestParam(required = false) String keyword, @RequestParam(required = false) Long deptId,
|
||||
@RequestParam(required = false) Long selectedId) {
|
||||
return R.data(billLedgerService.availablePage(Condition.getPage(pageQuery), keyword, deptId, selectedId));
|
||||
@RequestParam(required = false) Long selectedId, @RequestParam(required = false) String payerName,
|
||||
@RequestParam(required = false) String payeeName) {
|
||||
return R.data(billLedgerService.availablePage(Condition.getPage(pageQuery), keyword, deptId, selectedId,
|
||||
payerName, payeeName));
|
||||
}
|
||||
|
||||
@PostMapping("/submit")
|
||||
|
||||
+59
@@ -105,6 +105,65 @@ public class DriverWaybillController extends BladeController {
|
||||
return R.data(driverWaybillService.detail(id));
|
||||
}
|
||||
|
||||
@GetMapping("/task/current")
|
||||
@ApiOperationSupport(order = 21)
|
||||
@Operation(summary = "首页:当前运输中运次", description = "当前用户手机号可见的运输中运次(有配载单的一趟车算一个)最新一条")
|
||||
public R<DriverWaybillCardVO> tripCurrentTask() {
|
||||
return R.data(driverWaybillService.tripCurrentTask());
|
||||
}
|
||||
|
||||
@GetMapping("/task/pending-preview")
|
||||
@ApiOperationSupport(order = 22)
|
||||
@Operation(summary = "首页:待接运次预览", description = "当前用户手机号可见的待接运次预览与总数(按运次计)")
|
||||
public R<DriverWaybillPreviewVO> tripPendingPreview(
|
||||
@Parameter(description = "预览条数,默认 2") @RequestParam(required = false) Integer size) {
|
||||
return R.data(driverWaybillService.tripPendingPreview(size));
|
||||
}
|
||||
|
||||
@GetMapping("/task/counts")
|
||||
@ApiOperationSupport(order = 23)
|
||||
@Operation(summary = "运次 Tab 统计", description = "按运次统计:全部 = 待接单 + 进行中 + 已完成(不含已取消)")
|
||||
public R<DriverWaybillTabCountsVO> tripCounts() {
|
||||
return R.data(driverWaybillService.tripTabCounts());
|
||||
}
|
||||
|
||||
@GetMapping("/task/page")
|
||||
@ApiOperationSupport(order = 24)
|
||||
@Operation(summary = "运次分页列表", description = "一页即若干个运次;status:空=全部,0待接单,1运输中,2已完成;keyword 支持运单号/配载单号/起终点/车牌")
|
||||
public R<IPage<DriverWaybillCardVO>> tripPage(
|
||||
@Parameter(description = "当前页") @RequestParam(required = false) Integer current,
|
||||
@Parameter(description = "每页运次数") @RequestParam(required = false) Integer size,
|
||||
@Parameter(description = "状态:0待接单/1运输中/2已完成,不传为全部") @RequestParam(required = false) String status,
|
||||
@Parameter(description = "关键字:运单号/配载单号/起终点/车牌") @RequestParam(required = false) String keyword) {
|
||||
Integer statusCode = parseStatus(status);
|
||||
return R.data(driverWaybillService.tripPage(current, size, statusCode, keyword));
|
||||
}
|
||||
|
||||
@GetMapping("/task/detail")
|
||||
@ApiOperationSupport(order = 25)
|
||||
@Operation(summary = "司机运次详情", description = "按运次主键(字符串)取整趟车;有配载单时下发站点 ①②③(stations)与每站货物条目")
|
||||
public R<DriverWaybillCardVO> tripDetail(
|
||||
@Parameter(description = "运次主键(配载单主键或运单主键)", required = true) @RequestParam String id) {
|
||||
return R.data(driverWaybillService.tripDetail(id));
|
||||
}
|
||||
|
||||
@PostMapping("/task/accept")
|
||||
@ApiOperationSupport(order = 26)
|
||||
@Operation(summary = "运次确认接单", description = "一次接单把该运次全部子运单置为已接单;任一子单已接单即视为整单已接单")
|
||||
public R tripAccept(
|
||||
@Parameter(description = "运次主键(配载单主键或运单主键)", required = true) @RequestParam String id) {
|
||||
return R.status(driverWaybillService.tripAccept(id));
|
||||
}
|
||||
|
||||
@PostMapping("/task/reject")
|
||||
@ApiOperationSupport(order = 27)
|
||||
@Operation(summary = "运次拒绝接单", description = "整单拒绝:该运次全部未终态子运单一起置为已拒绝,此后该运次对本人不可见")
|
||||
public R tripReject(
|
||||
@Parameter(description = "运次主键(配载单主键或运单主键)", required = true) @RequestParam String id,
|
||||
@Parameter(description = "拒绝原因") @RequestParam(required = false) String reason) {
|
||||
return R.status(driverWaybillService.tripReject(id, reason));
|
||||
}
|
||||
|
||||
@PostMapping("/track")
|
||||
@ApiOperationSupport(order = 11)
|
||||
@Operation(summary = "司机端行驶轨迹", description = "按当前用户手机号校验归属后,根据打卡记录/计划时间自动推导起止日期并查询 LBS 轨迹(WGS-84)")
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.controller;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.tenant.annotation.TenantIgnore;
|
||||
import org.springblade.core.tool.api.R;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* HCS 合规回调 Mock 端点
|
||||
* <p>
|
||||
* 合同法务审批结果回调(D103)、付款法务审批结果回调(D104)、项目金蝶同步结果回调(D902)、
|
||||
* 合同金蝶同步结果回传接收(D903)四个入站回调的纯 mock 实现:仅校验来源系统标识后返回固定结果,
|
||||
* 不读写业务数据、不联动状态。真实处理逻辑保留在对应 Support 中,联调时把端点切回
|
||||
* {@link } 并调用 Support 方法即可。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@TenantIgnore
|
||||
@RequestMapping("hcs")
|
||||
@Tag(name = "HCS合规回调Mock", description = "HCS合规回调Mock端点(仅自测,返回固定 mock 数据)")
|
||||
public class HcsMockController {
|
||||
|
||||
/**
|
||||
* 来源系统标识(合规方约定值,与 third-party.hcs.src-system 默认值一致)
|
||||
*/
|
||||
private static final String SOURCE_SYSTEM = "WLTMS";
|
||||
|
||||
/**
|
||||
* 合同法务审批结果回调(D103)
|
||||
*/
|
||||
@PostMapping("/contractLegal/result")
|
||||
@ApiOperationSupport(order = 15)
|
||||
@Operation(summary = "合同法务审批结果回调Mock", description = "D103 合规推送合同特批(金蝶同步)结果,仅校验来源系统并返回 mock 数据")
|
||||
public R<String> contractLegalResult(@RequestBody(required = false) Map<String, Object> param) {
|
||||
this.checkSourceSystem("合同法务审批结果", param);
|
||||
return R.data("mock特批结果已处理:approved");
|
||||
}
|
||||
|
||||
/**
|
||||
* 付款法务审批结果回调(D104)
|
||||
*/
|
||||
@PostMapping("/paymentLegal/result")
|
||||
@ApiOperationSupport(order = 16)
|
||||
@Operation(summary = "付款法务审批结果回调Mock", description = "D104 合规推送付款特批结果,仅校验来源系统并返回 mock 数据")
|
||||
public R<String> paymentLegalResult(@RequestBody(required = false) Map<String, Object> param) {
|
||||
this.checkSourceSystem("付款法务审批结果", param);
|
||||
return R.data("mock付款特批结果接收已处理:approved");
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目金蝶同步结果回调(D902)
|
||||
*/
|
||||
@PostMapping("/projectLegal/result")
|
||||
@ApiOperationSupport(order = 18)
|
||||
@Operation(summary = "项目金蝶同步结果回调Mock", description = "D902 合规推送项目金蝶同步结果,仅校验来源系统并返回 mock 数据")
|
||||
public R<String> projectLegalResult(@RequestBody(required = false) Map<String, Object> param) {
|
||||
this.checkSourceSystem("项目金蝶同步结果", param);
|
||||
return R.data("mock项目金蝶同步结果接收已处理:approved");
|
||||
}
|
||||
|
||||
/**
|
||||
* 合同金蝶同步结果回传接收(D903)
|
||||
*/
|
||||
@PostMapping("/contractKingdee/result")
|
||||
@ApiOperationSupport(order = 19)
|
||||
@Operation(summary = "合同金蝶同步结果回传接收Mock", description = "D903 合规推送合同金蝶最终同步结果,仅校验来源系统并返回 mock 数据")
|
||||
public R<String> contractKingdeeResult(@RequestBody(required = false) Map<String, Object> param) {
|
||||
this.checkSourceSystem("合同金蝶同步结果", param);
|
||||
return R.data("mock合同金蝶同步结果接收处理:approved");
|
||||
}
|
||||
|
||||
/**
|
||||
* 来源系统校验:仅接受合规方约定的来源标识,防伪造回传
|
||||
*
|
||||
* @param bizName 业务名称(日志用)
|
||||
* @param param 回调报文
|
||||
*/
|
||||
private void checkSourceSystem(String bizName, Map<String, Object> param) {
|
||||
String sourceSystem = param == null ? null : Objects.toString(param.get("sourceSystem"), null);
|
||||
if (!StrUtil.equalsIgnoreCase(sourceSystem, SOURCE_SYSTEM)) {
|
||||
log.warn("{}推送来源系统标识错误:sourceSystem={}", bizName, sourceSystem);
|
||||
throw new ServiceException("来源系统标识错误");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -84,10 +84,9 @@ public class InvoiceApplicationController extends BladeController {
|
||||
public R<IPage<Map<String, Object>>> settlementCandidates(Query query,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) String contractCategory,
|
||||
@RequestParam(defaultValue = "receivable") String settlementType,
|
||||
@RequestParam(defaultValue = "unreceived") String invoiceStatus) {
|
||||
@RequestParam(defaultValue = "receivable") String settlementType) {
|
||||
return R.data(invoiceApplicationService.settlementCandidates(
|
||||
Condition.getPage(query), keyword, contractCategory, settlementType, invoiceStatus));
|
||||
Condition.getPage(query), keyword, contractCategory, settlementType));
|
||||
}
|
||||
|
||||
@GetMapping("/settlement-details")
|
||||
|
||||
+14
-3
@@ -39,6 +39,7 @@ import org.springblade.transport.pojo.vo.AdminVehicleOptionVO;
|
||||
import org.springblade.transport.pojo.vo.AdminWaybillCardVO;
|
||||
import org.springblade.transport.pojo.vo.AdminWaybillDetailVO;
|
||||
import org.springblade.transport.pojo.vo.WaybillPunchRecordsVO;
|
||||
import org.springblade.transport.pojo.vo.WaybillTrackVO;
|
||||
import org.springblade.transport.service.IManageWaybillService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -69,7 +70,7 @@ public class ManageWaybillController extends BladeController {
|
||||
|
||||
@GetMapping("/stats")
|
||||
@ApiOperationSupport(order = 1)
|
||||
@Operation(summary = "运单状态统计", description = "待接单=pending,运输中=running,已完成=completed;租户内不过滤组织(小程序调度账号组织常与运单不一致);在途异常=异常处置状态≠已完成")
|
||||
@Operation(summary = "运单状态统计", description = "待接单=pending,运输中=running,已完成=completed;只统计本组织的运单(超级管理员不过滤组织);在途异常=异常处置状态≠已完成")
|
||||
public R<AdminHomeStatsVO> stats() {
|
||||
return R.data(manageWaybillService.stats());
|
||||
}
|
||||
@@ -99,9 +100,9 @@ public class ManageWaybillController extends BladeController {
|
||||
|
||||
@GetMapping("/detail")
|
||||
@ApiOperationSupport(order = 4)
|
||||
@Operation(summary = "运单详情", description = "调度端查看运单详情(含 punchNodes / enrouteRecords),不校验司机归属与组织;字段对齐小程序 pages/waybill/detail")
|
||||
@Operation(summary = "运次详情", description = "调度端查看整趟车详情(含 stations / punchNodes / enrouteRecords),不校验司机归属与组织;id 传运次主键字符串,已拒单的运单主键回落成单票视图")
|
||||
public R<AdminWaybillDetailVO> detail(
|
||||
@Parameter(description = "运单ID", required = true) @RequestParam Long id) {
|
||||
@Parameter(description = "运次主键(配载单主键或运单主键,字符串)", required = true) @RequestParam String id) {
|
||||
return R.data(manageWaybillService.detail(id));
|
||||
}
|
||||
|
||||
@@ -159,4 +160,14 @@ public class ManageWaybillController extends BladeController {
|
||||
return R.status(waybillService.savePunchRecord(request));
|
||||
}
|
||||
|
||||
@PostMapping("/track")
|
||||
@ApiOperationSupport(order = 11)
|
||||
@Operation(summary = "车辆历史轨迹", description = "小程序调度端:仅需登录态,按运单绑定车牌和日期区间查询 LBS 轨迹")
|
||||
public R<WaybillTrackVO> track(
|
||||
@Parameter(description = "运单ID", required = true) @RequestParam Long id,
|
||||
@Parameter(description = "开始日期 YYYY-MM-DD", required = true) @RequestParam String startDate,
|
||||
@Parameter(description = "结束日期 YYYY-MM-DD", required = true) @RequestParam String endDate) {
|
||||
return R.data(waybillService.trackVehicle(id, startDate, endDate));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+12
@@ -34,6 +34,7 @@ import org.springblade.transport.pojo.dto.PaymentApplicationSaveRequest;
|
||||
import org.springblade.transport.pojo.dto.PaymentApplicationStatusRequest;
|
||||
import org.springblade.transport.pojo.vo.PaymentApplicationReferenceAmountVO;
|
||||
import org.springblade.transport.pojo.vo.PaymentApplicationVO;
|
||||
import org.springblade.transport.pojo.vo.ProjectAdvanceWriteOffOptionVO;
|
||||
import org.springblade.transport.service.IPaymentApplicationService;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -67,6 +68,17 @@ public class PaymentApplicationController extends BladeController {
|
||||
@RequestParam Long referenceId, @RequestParam(required = false) Long excludeId) {
|
||||
return R.data(paymentApplicationService.referenceAmount(paymentType, referenceId, excludeId));
|
||||
}
|
||||
|
||||
@GetMapping("/project-advance-write-off-options")
|
||||
@ApiOperationSupport(order = 3)
|
||||
@Operation(summary = "按合同查询可选项目预付核销列表")
|
||||
public R<List<ProjectAdvanceWriteOffOptionVO>> projectAdvanceWriteOffOptions(
|
||||
@RequestParam Long contractId,
|
||||
@RequestParam(required = false) String excludeSettlementType,
|
||||
@RequestParam(required = false) Long excludeSettlementId) {
|
||||
return R.data(paymentApplicationService.projectAdvanceWriteOffOptions(
|
||||
contractId, excludeSettlementType, excludeSettlementId));
|
||||
}
|
||||
@PostMapping("/save")
|
||||
@ApiOperationSupport(order = 4)
|
||||
@Operation(summary = "保存付款申请")
|
||||
|
||||
+7
@@ -94,6 +94,13 @@ public class PreSettlementController extends BladeController {
|
||||
return R.data(preSettlementService.contractOptions(keyword));
|
||||
}
|
||||
|
||||
@GetMapping("/next-no")
|
||||
@ApiOperationSupport(order = 4)
|
||||
@Operation(summary = "获取最新预结算单号")
|
||||
public R<String> nextNo() {
|
||||
return R.data(preSettlementService.nextNo());
|
||||
}
|
||||
|
||||
@GetMapping("/fee-options")
|
||||
@ApiOperationSupport(order = 4)
|
||||
@Operation(summary = "费用类型及费用项")
|
||||
|
||||
+16
-1
@@ -7,6 +7,8 @@ import org.springblade.core.mp.support.Condition;
|
||||
import org.springblade.core.mp.support.Query;
|
||||
import org.springblade.core.secure.annotation.PreAuth;
|
||||
import org.springblade.core.tool.api.R;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.listener.KingdeeApprovalSyncListener;
|
||||
import org.springblade.transport.pojo.dto.SettlementAdjustmentSaveRequest;
|
||||
import org.springblade.transport.pojo.dto.SettlementAdjustmentStatusRequest;
|
||||
import org.springblade.transport.pojo.entity.SettlementAdjustment;
|
||||
@@ -33,7 +35,20 @@ public class SettlementAdjustmentController extends BladeController {
|
||||
@PostMapping("/save") public R<Long> save(@RequestBody SettlementAdjustmentSaveRequest request) { return R.data(service.saveDraft(request)); }
|
||||
@PostMapping("/remove") public R remove(@RequestParam Long id) { service.removeDraft(id); return R.success("删除成功"); }
|
||||
@PostMapping("/submit") public R submit(@RequestBody SettlementAdjustmentStatusRequest request) { service.submit(request); return R.success("提交成功"); }
|
||||
@PostMapping("/approve") public R approve(@RequestBody SettlementAdjustmentStatusRequest request) { service.approve(request); return R.success("审批通过"); }
|
||||
@PostMapping("/approve") public R approve(@RequestBody SettlementAdjustmentStatusRequest request) {
|
||||
String hint = service.approve(request);
|
||||
// 未推送场景自动推送失败原因以警告文案返回(data=false 供前端区分提示样式)
|
||||
String syncFailure = KingdeeApprovalSyncListener.pollSyncFailure();
|
||||
if (Func.isNotBlank(syncFailure)) {
|
||||
return R.data(Boolean.FALSE, "审批通过,但自动同步金蝶失败:" + syncFailure);
|
||||
}
|
||||
if (Func.isNotBlank(hint)) {
|
||||
return R.data(Boolean.TRUE, "审批通过;" + hint);
|
||||
}
|
||||
return R.success("审批通过");
|
||||
}
|
||||
@PostMapping("/return") public R returnBill(@RequestBody SettlementAdjustmentStatusRequest request) { service.returnBill(request); return R.success("已驳回"); }
|
||||
@PostMapping("/repush") public R<String> repush(@RequestParam Long id) { return R.data(service.repush(id)); }
|
||||
@GetMapping("/kingdee-writeoff-precheck") public R<Map<String, Object>> kingdeeWriteOffPrecheck(@RequestParam Long formalSettlementId) { return R.data(service.kingdeeWriteOffPrecheck(formalSettlementId)); }
|
||||
@PostMapping("/sync-kingdee") public R<String> syncKingdee(@RequestParam Long id) { return R.data(service.syncKingdee(id)); }
|
||||
}
|
||||
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springblade.core.boot.ctrl.BladeController;
|
||||
import org.springblade.core.mp.support.Condition;
|
||||
import org.springblade.core.mp.support.Query;
|
||||
import org.springblade.core.secure.annotation.PreAuth;
|
||||
import org.springblade.core.tool.api.R;
|
||||
import org.springblade.transport.pojo.dto.SettlementImportBatchRequest;
|
||||
import org.springblade.transport.pojo.dto.SettlementImportVoidRequest;
|
||||
import org.springblade.transport.pojo.vo.SettlementImportBatchVO;
|
||||
import org.springblade.transport.pojo.vo.WaybillImportValidateResultVO;
|
||||
import org.springblade.transport.service.ISettlementImportBatchService;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 导入结算单控制器
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@RestController
|
||||
@AllArgsConstructor
|
||||
@PreAuth(menu = "settlement_import")
|
||||
@RequestMapping("settlement-import")
|
||||
@Tag(name = "导入结算单", description = "导入结算单管理")
|
||||
public class SettlementImportController extends BladeController {
|
||||
|
||||
private final ISettlementImportBatchService settlementImportBatchService;
|
||||
|
||||
@GetMapping("/options")
|
||||
@ApiOperationSupport(order = 1)
|
||||
@Operation(summary = "筛选项与表单选项")
|
||||
public R<Map<String, List<Map<String, Object>>>> options() {
|
||||
return R.data(settlementImportBatchService.options());
|
||||
}
|
||||
|
||||
@GetMapping("/next-code")
|
||||
@ApiOperationSupport(order = 2)
|
||||
@Operation(summary = "下一个批次号")
|
||||
public R<String> nextCode() {
|
||||
return R.data(settlementImportBatchService.nextBatchNo());
|
||||
}
|
||||
|
||||
@GetMapping("/list")
|
||||
@ApiOperationSupport(order = 3)
|
||||
@Operation(summary = "导入结算单批次分页")
|
||||
public R<IPage<SettlementImportBatchVO>> list(SettlementImportBatchRequest request, Query query) {
|
||||
return R.data(settlementImportBatchService.page(Condition.getPage(query), request));
|
||||
}
|
||||
|
||||
@GetMapping("/detail")
|
||||
@ApiOperationSupport(order = 4)
|
||||
@Operation(summary = "导入结算单批次详情")
|
||||
public R<SettlementImportBatchVO> detail(@RequestParam Long id) {
|
||||
return R.data(settlementImportBatchService.detail(id));
|
||||
}
|
||||
|
||||
@GetMapping("/export-template")
|
||||
@ApiOperationSupport(order = 5)
|
||||
@Operation(summary = "导出导入模板")
|
||||
public void exportTemplate(HttpServletResponse response) {
|
||||
settlementImportBatchService.exportTemplate(response);
|
||||
}
|
||||
|
||||
@PostMapping("/parse")
|
||||
@ApiOperationSupport(order = 6)
|
||||
@Operation(summary = "解析导入 Excel")
|
||||
public R<Map<String, Object>> parse(@RequestPart("file") MultipartFile file) {
|
||||
return R.data(settlementImportBatchService.parse(file));
|
||||
}
|
||||
|
||||
@PostMapping("/validate")
|
||||
@ApiOperationSupport(order = 7)
|
||||
@Operation(summary = "校验导入数据")
|
||||
public R<WaybillImportValidateResultVO> validate(@RequestBody SettlementImportBatchRequest request) {
|
||||
return R.data(settlementImportBatchService.validate(request));
|
||||
}
|
||||
|
||||
@PostMapping("/export-failures")
|
||||
@ApiOperationSupport(order = 8)
|
||||
@Operation(summary = "导出失败明细")
|
||||
public void exportFailures(@RequestBody SettlementImportBatchRequest request, HttpServletResponse response) {
|
||||
settlementImportBatchService.exportFailures(request, null, response);
|
||||
}
|
||||
|
||||
@PostMapping("/draft")
|
||||
@ApiOperationSupport(order = 9)
|
||||
@Operation(summary = "保存草稿")
|
||||
public R<SettlementImportBatchVO> draft(@RequestBody SettlementImportBatchRequest request) {
|
||||
return R.data(settlementImportBatchService.saveDraft(request));
|
||||
}
|
||||
|
||||
@PostMapping("/confirm")
|
||||
@ApiOperationSupport(order = 10)
|
||||
@Operation(summary = "确认导入结算单")
|
||||
public R<SettlementImportBatchVO> confirm(@RequestBody SettlementImportBatchRequest request) {
|
||||
return R.data(settlementImportBatchService.confirm(request));
|
||||
}
|
||||
|
||||
@PostMapping("/void")
|
||||
@ApiOperationSupport(order = 11)
|
||||
@Operation(summary = "作废导入结算单批次")
|
||||
public R voidBatch(@RequestBody SettlementImportVoidRequest request) {
|
||||
settlementImportBatchService.voidBatch(request);
|
||||
return R.success("作废成功");
|
||||
}
|
||||
|
||||
@PostMapping("/remove")
|
||||
@ApiOperationSupport(order = 12)
|
||||
@Operation(summary = "删除草稿批次")
|
||||
public R remove(@RequestParam Long id) {
|
||||
settlementImportBatchService.removeDraft(id);
|
||||
return R.success("删除成功");
|
||||
}
|
||||
|
||||
@GetMapping("/history-waybills")
|
||||
@ApiOperationSupport(order = 13)
|
||||
@Operation(summary = "历史运单可选列表")
|
||||
public R<List<Map<String, Object>>> historyWaybills(SettlementImportBatchRequest request) {
|
||||
return R.data(settlementImportBatchService.historyWaybills(request));
|
||||
}
|
||||
}
|
||||
+7
@@ -373,6 +373,13 @@ public class WaybillController extends BladeController {
|
||||
return R.data(waybillService.batchComplete(ids));
|
||||
}
|
||||
|
||||
@PostMapping("/confirm-return")
|
||||
@ApiOperationSupport(order = 24)
|
||||
@Operation(summary = "确认回单", description = "回单节点要求业务员确认时用:该运单的司机打卡节点全部已打卡且无未了结异常,才置为已完成")
|
||||
public R confirmReturn(@Parameter(description = "主键", required = true) @RequestParam Long id) {
|
||||
return R.status(waybillService.confirmReturnReceipt(id));
|
||||
}
|
||||
|
||||
@PostMapping("/road-loading")
|
||||
@ApiOperationSupport(order = 23)
|
||||
@Operation(summary = "公路配载", description = "传入ids")
|
||||
|
||||
+496
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.excel;
|
||||
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.CellType;
|
||||
import org.apache.poi.ss.usermodel.DataFormatter;
|
||||
import org.apache.poi.ss.usermodel.DateUtil;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
||||
import org.springblade.common.excel.ImportCellReadException;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 导入结算单 Excel 读取器(Apache POI)。
|
||||
* <p>
|
||||
* 定位「*运输费」或「运输费」列,其后直到日期/备注等固定列(兼容旧模板中的「结算金额」)为动态费用列。
|
||||
* 结算金额不由模板填写,解析时按「运输费 + 各费用项」自动合计。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public final class SettlementImportFeeReader {
|
||||
|
||||
private static final DataFormatter DATA_FORMATTER = new DataFormatter(Locale.CHINA);
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
private static final Set<String> DATE_FIELDS = Set.of(
|
||||
"actualStartDate", "actualEndDate", "planStartDate", "planEndDate"
|
||||
);
|
||||
private static final Set<String> OPTIONAL_DECIMAL_FIELDS = Set.of("mileage", "unitPrice");
|
||||
/** 费用列结束边界:其后为固定业务列;含旧模板「结算金额」以便兼容历史文件 */
|
||||
private static final Set<String> FEE_END_HEADERS = Set.of(
|
||||
"结算金额",
|
||||
"*实际发货时间", "实际发货时间",
|
||||
"*实际完成时间", "实际完成时间",
|
||||
"预计发货时间", "预计完成时间",
|
||||
"备注", "同一运单标识号"
|
||||
);
|
||||
|
||||
private SettlementImportFeeReader() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析结果
|
||||
*/
|
||||
public static final class ParseResult {
|
||||
private final List<Map<String, Object>> rows;
|
||||
private final List<String> feeItemNames;
|
||||
private final List<String> warnings;
|
||||
private final List<String> headers;
|
||||
|
||||
public ParseResult(List<Map<String, Object>> rows, List<String> feeItemNames, List<String> warnings,
|
||||
List<String> headers) {
|
||||
this.rows = rows;
|
||||
this.feeItemNames = feeItemNames;
|
||||
this.warnings = warnings;
|
||||
this.headers = headers;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> getRows() {
|
||||
return rows;
|
||||
}
|
||||
|
||||
public List<String> getFeeItemNames() {
|
||||
return feeItemNames;
|
||||
}
|
||||
|
||||
public List<String> getWarnings() {
|
||||
return warnings;
|
||||
}
|
||||
|
||||
public List<String> getHeaders() {
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
|
||||
public static ParseResult read(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new ServiceException("上传文件不能为空");
|
||||
}
|
||||
try (InputStream inputStream = file.getInputStream(); Workbook workbook = WorkbookFactory.create(inputStream)) {
|
||||
Sheet sheet = resolveDataSheet(workbook);
|
||||
Row headerRow = sheet.getRow(sheet.getFirstRowNum());
|
||||
if (headerRow == null) {
|
||||
throw new ServiceException("未识别到表头,请使用标准导入结算单模板");
|
||||
}
|
||||
Map<Integer, String> headers = readHeaderMap(headerRow);
|
||||
int transportFeeColumn = findTransportFeeColumn(headers);
|
||||
if (transportFeeColumn < 0) {
|
||||
throw new ServiceException("未找到「*运输费」或「运输费」列");
|
||||
}
|
||||
List<Integer> feeColumns = new ArrayList<>();
|
||||
List<String> feeItemNames = new ArrayList<>();
|
||||
int endColumn = findFeeEndColumn(headers, transportFeeColumn);
|
||||
for (int column = transportFeeColumn + 1; column < endColumn; column++) {
|
||||
String name = headers.get(column);
|
||||
if (name == null || name.isBlank() || FEE_END_HEADERS.contains(name)) {
|
||||
continue;
|
||||
}
|
||||
feeColumns.add(column);
|
||||
feeItemNames.add(name);
|
||||
}
|
||||
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
int firstDataRow = sheet.getFirstRowNum() + 1;
|
||||
int lastRow = sheet.getLastRowNum();
|
||||
for (int rowIndex = firstDataRow; rowIndex <= lastRow; rowIndex++) {
|
||||
Row row = sheet.getRow(rowIndex);
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> mapped = mapRow(headers, feeColumns, row, rowIndex);
|
||||
if (mapped != null) {
|
||||
rows.add(mapped);
|
||||
}
|
||||
}
|
||||
return new ParseResult(rows, feeItemNames, List.of(), new ArrayList<>(headers.values()));
|
||||
} catch (ServiceException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw new ServiceException("读取导入结算单 Excel 失败:" + exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 优先取含「*运输费/运输费」表头的工作表,否则取第一个非空表。 */
|
||||
private static Sheet resolveDataSheet(Workbook workbook) {
|
||||
Sheet fallback = null;
|
||||
for (int index = 0; index < workbook.getNumberOfSheets(); index++) {
|
||||
Sheet sheet = workbook.getSheetAt(index);
|
||||
if (sheet == null) {
|
||||
continue;
|
||||
}
|
||||
Row headerRow = sheet.getRow(sheet.getFirstRowNum());
|
||||
if (headerRow == null) {
|
||||
continue;
|
||||
}
|
||||
Map<Integer, String> headers = readHeaderMap(headerRow);
|
||||
if (findTransportFeeColumn(headers) >= 0) {
|
||||
return sheet;
|
||||
}
|
||||
if (fallback == null && !headers.isEmpty()) {
|
||||
fallback = sheet;
|
||||
}
|
||||
}
|
||||
if (fallback != null) {
|
||||
return fallback;
|
||||
}
|
||||
throw new ServiceException("Excel 中未找到有效工作表");
|
||||
}
|
||||
|
||||
private static Map<Integer, String> readHeaderMap(Row headerRow) {
|
||||
Map<Integer, String> headers = new LinkedHashMap<>();
|
||||
short lastCell = headerRow.getLastCellNum();
|
||||
for (int column = 0; column < lastCell; column++) {
|
||||
String value = normalizeHeader(cellString(headerRow.getCell(column)));
|
||||
if (value != null && !value.isBlank()) {
|
||||
headers.put(column, value);
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/** 去掉 BOM/不可见空白,全角星号转半角,便于匹配「*运输费」。 */
|
||||
private static String normalizeHeader(String raw) {
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
String text = raw.replace("\uFEFF", "")
|
||||
.replace('\u00A0', ' ')
|
||||
.replace('*', '*')
|
||||
.trim();
|
||||
return text.isEmpty() ? null : text;
|
||||
}
|
||||
|
||||
private static Map<String, Object> mapRow(Map<Integer, String> headers, List<Integer> feeColumns,
|
||||
Row row, int excelRowIndex) {
|
||||
Map<String, Object> mapped = new LinkedHashMap<>();
|
||||
Map<String, BigDecimal> feeItems = new LinkedHashMap<>();
|
||||
for (Map.Entry<Integer, String> entry : headers.entrySet()) {
|
||||
int column = entry.getKey();
|
||||
String trimmedHeader = entry.getValue();
|
||||
Cell poiCell = row.getCell(column);
|
||||
if (feeColumns.contains(column)) {
|
||||
feeItems.put(trimmedHeader, decimal(cellString(poiCell), trimmedHeader, excelRowIndex));
|
||||
continue;
|
||||
}
|
||||
String field = FIXED_HEADER_MAP.get(trimmedHeader);
|
||||
if (field == null) {
|
||||
mapped.put(trimmedHeader, blankToNull(cellString(poiCell)));
|
||||
continue;
|
||||
}
|
||||
if (DATE_FIELDS.contains(field)) {
|
||||
mapped.put(field, dateCellString(poiCell));
|
||||
} else if (OPTIONAL_DECIMAL_FIELDS.contains(field)) {
|
||||
mapped.put(field, optionalDecimal(cellString(poiCell), trimmedHeader, excelRowIndex));
|
||||
} else if ("transportFee".equals(field) || "quantity".equals(field)) {
|
||||
mapped.put(field, decimal(cellString(poiCell), trimmedHeader, excelRowIndex));
|
||||
} else if ("settlementAmount".equals(field)) {
|
||||
// 旧模板可能仍含结算金额列,读入后忽略,统一按运输费+费用项自动合计
|
||||
continue;
|
||||
} else if ("serialNumber".equals(field)) {
|
||||
mapped.put(field, intValue(cellString(poiCell)));
|
||||
} else {
|
||||
mapped.put(field, blankToNull(cellString(poiCell)));
|
||||
}
|
||||
mapped.put(trimmedHeader, mapped.get(field));
|
||||
}
|
||||
|
||||
boolean hasBusinessValue = mapped.values().stream().anyMatch(value -> {
|
||||
if (value == null) {
|
||||
return false;
|
||||
}
|
||||
if (value instanceof String text) {
|
||||
return !text.isBlank();
|
||||
}
|
||||
return true;
|
||||
}) || feeItems.values().stream().anyMatch(amount -> amount != null && amount.compareTo(BigDecimal.ZERO) != 0);
|
||||
if (!hasBusinessValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
BigDecimal transportFee = toDecimal(mapped.get("transportFee"));
|
||||
BigDecimal feeItemTotal = feeItems.values().stream()
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add)
|
||||
.setScale(2, RoundingMode.HALF_UP);
|
||||
BigDecimal settlementAmount = transportFee.add(feeItemTotal).setScale(2, RoundingMode.HALF_UP);
|
||||
mapped.put("settlementAmount", settlementAmount);
|
||||
mapped.put("结算金额", settlementAmount);
|
||||
mapped.put("feeItems", feeItems);
|
||||
mapped.put("freight", transportFee);
|
||||
mapped.put("运费", transportFee);
|
||||
mapped.put("*运输费", transportFee);
|
||||
mapped.put("运输费", transportFee);
|
||||
mapped.put("otherFeeTotal", feeItemTotal);
|
||||
mapped.put("其他费用合计", feeItemTotal);
|
||||
mapped.put("freightTotal", settlementAmount);
|
||||
mapped.put("运费合计", settlementAmount);
|
||||
if (mapped.get("unitPrice") != null) {
|
||||
mapped.put("单价", mapped.get("unitPrice"));
|
||||
mapped.put("运输单价", mapped.get("unitPrice"));
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
|
||||
private static int findTransportFeeColumn(Map<Integer, String> headers) {
|
||||
int starred = findColumnExact(headers, "*运输费");
|
||||
if (starred >= 0) {
|
||||
return starred;
|
||||
}
|
||||
return findColumnExact(headers, "运输费");
|
||||
}
|
||||
|
||||
/** 费用列右边界:取运输费之后第一个固定结束表头列下标;无则取表头最大下标 + 1。 */
|
||||
private static int findFeeEndColumn(Map<Integer, String> headers, int transportFeeColumn) {
|
||||
int endColumn = headers.keySet().stream().mapToInt(Integer::intValue).max().orElse(transportFeeColumn) + 1;
|
||||
for (Map.Entry<Integer, String> entry : headers.entrySet()) {
|
||||
int column = entry.getKey();
|
||||
if (column <= transportFeeColumn) {
|
||||
continue;
|
||||
}
|
||||
if (FEE_END_HEADERS.contains(entry.getValue()) && column < endColumn) {
|
||||
endColumn = column;
|
||||
}
|
||||
}
|
||||
return endColumn;
|
||||
}
|
||||
|
||||
private static int findColumnExact(Map<Integer, String> headers, String header) {
|
||||
return headers.entrySet().stream()
|
||||
.filter(entry -> header.equals(entry.getValue()))
|
||||
.mapToInt(Map.Entry::getKey)
|
||||
.findFirst()
|
||||
.orElse(-1);
|
||||
}
|
||||
|
||||
private static String cellString(Cell cell) {
|
||||
if (cell == null) {
|
||||
return null;
|
||||
}
|
||||
String text = DATA_FORMATTER.formatCellValue(cell);
|
||||
if (text == null) {
|
||||
return null;
|
||||
}
|
||||
text = text.trim();
|
||||
return text.isEmpty() ? null : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取日期单元格:Excel 日期序列优先格式化为 yyyy-MM-dd;
|
||||
* 文本短日期(如 1/1/26、10/10/2026)归一为四位年,供后续 LenientDateParser 识别。
|
||||
*/
|
||||
private static String dateCellString(Cell cell) {
|
||||
if (cell == null) {
|
||||
return null;
|
||||
}
|
||||
CellType cellType = cell.getCellType();
|
||||
if (cellType == CellType.FORMULA) {
|
||||
cellType = cell.getCachedFormulaResultType();
|
||||
}
|
||||
if (cellType == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) {
|
||||
Date date = cell.getDateCellValue();
|
||||
if (date == null) {
|
||||
return null;
|
||||
}
|
||||
LocalDate localDate = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
|
||||
return DATE_FORMATTER.format(localDate);
|
||||
}
|
||||
String text = cellString(cell);
|
||||
return normalizeExcelShortDate(text);
|
||||
}
|
||||
|
||||
/** 将 Excel 常见短日期文本归一为 yyyy-MM-dd;无法识别时原样返回交由校验层报错。 */
|
||||
private static String normalizeExcelShortDate(String text) {
|
||||
if (text == null || text.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String normalized = text.trim().replace('.', '/').replace('-', '/');
|
||||
String[] parts = normalized.split("[/\\s]+");
|
||||
if (parts.length < 3) {
|
||||
return text.trim();
|
||||
}
|
||||
try {
|
||||
int first = Integer.parseInt(parts[0]);
|
||||
int second = Integer.parseInt(parts[1]);
|
||||
int third = Integer.parseInt(parts[2].replaceAll("[^0-9].*$", ""));
|
||||
int year;
|
||||
int month;
|
||||
int day;
|
||||
if (parts[0].length() == 4) {
|
||||
year = first;
|
||||
month = second;
|
||||
day = third;
|
||||
} else if (parts[2].length() == 4 || parts[2].length() == 2) {
|
||||
// M/d/yy 或 M/d/yyyy(Excel 中文本/短日期常见)
|
||||
month = first;
|
||||
day = second;
|
||||
year = parts[2].length() == 2 ? 2000 + third : third;
|
||||
if (parts[2].length() == 2 && third >= 100) {
|
||||
return text.trim();
|
||||
}
|
||||
} else {
|
||||
return text.trim();
|
||||
}
|
||||
LocalDate localDate = LocalDate.of(year, month, day);
|
||||
return DATE_FORMATTER.format(localDate);
|
||||
} catch (Exception exception) {
|
||||
return text.trim();
|
||||
}
|
||||
}
|
||||
|
||||
private static BigDecimal decimal(String cell, String columnName, Integer excelRowIndex) {
|
||||
if (cell == null || cell.isBlank()) {
|
||||
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
String normalized = cell.trim().replace(",", "");
|
||||
try {
|
||||
return new BigDecimal(normalized).setScale(2, RoundingMode.HALF_UP);
|
||||
} catch (NumberFormatException exception) {
|
||||
throw new ImportCellReadException(excelRowIndex, columnName, cell.trim());
|
||||
}
|
||||
}
|
||||
|
||||
/** 可选数值:空白返回 null,避免空里程被当成 0 触发「必须为正数」。 */
|
||||
private static BigDecimal optionalDecimal(String cell, String columnName, Integer excelRowIndex) {
|
||||
if (cell == null || cell.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return decimal(cell, columnName, excelRowIndex);
|
||||
}
|
||||
|
||||
private static Integer intValue(String cell) {
|
||||
if (cell == null || cell.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new BigDecimal(cell.trim().replace(",", "")).intValue();
|
||||
} catch (NumberFormatException exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String blankToNull(String cell) {
|
||||
if (cell == null) {
|
||||
return null;
|
||||
}
|
||||
String text = cell.trim();
|
||||
return text.isEmpty() ? null : text;
|
||||
}
|
||||
|
||||
private static BigDecimal toDecimal(Object value) {
|
||||
if (value == null) {
|
||||
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
if (value instanceof BigDecimal bigDecimal) {
|
||||
return bigDecimal;
|
||||
}
|
||||
try {
|
||||
return new BigDecimal(String.valueOf(value).trim()).setScale(2, RoundingMode.HALF_UP);
|
||||
} catch (NumberFormatException exception) {
|
||||
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
}
|
||||
|
||||
private static final Map<String, String> FIXED_HEADER_MAP = buildFixedHeaderMap();
|
||||
|
||||
private static Map<String, String> buildFixedHeaderMap() {
|
||||
Map<String, String> map = new LinkedHashMap<>();
|
||||
map.put("序号", "serialNumber");
|
||||
map.put("原始单号", "originalNo");
|
||||
map.put("配载标识号", "loadingIdentifier");
|
||||
map.put("*车牌号/船班号/船次号/班次号", "vehicleNo");
|
||||
map.put("车牌号/船班号/船次号/班次号", "vehicleNo");
|
||||
map.put("*车牌号/航班号/船号/班列号", "vehicleNo");
|
||||
map.put("车牌号/航班号/船号/班列号", "vehicleNo");
|
||||
map.put("*运输方式", "transportType");
|
||||
map.put("运输方式", "transportType");
|
||||
map.put("运输类型", "transportType");
|
||||
map.put("*运输类型", "transportType");
|
||||
map.put("司机/船长姓名", "driverName");
|
||||
map.put("司机/船长手机号", "driverPhone");
|
||||
map.put("*发货地址", "departureAddress");
|
||||
map.put("发货地址", "departureAddress");
|
||||
map.put("发货联系人", "departureContact");
|
||||
map.put("发货联系人电话", "departurePhone");
|
||||
map.put("*到货地址", "arrivalAddress");
|
||||
map.put("到货地址", "arrivalAddress");
|
||||
map.put("收货联系人", "arrivalContact");
|
||||
map.put("收货联系人电话", "arrivalPhone");
|
||||
map.put("*货物名称", "cargoName");
|
||||
map.put("货物名称", "cargoName");
|
||||
map.put("*货物类型", "cargoType");
|
||||
map.put("货物类型", "cargoType");
|
||||
map.put("包装", "packageType");
|
||||
map.put("*数量", "quantity");
|
||||
map.put("数量", "quantity");
|
||||
map.put("*数量单位", "quantityUnit");
|
||||
map.put("数量单位", "quantityUnit");
|
||||
map.put("规格", "specification");
|
||||
map.put("型号", "model");
|
||||
map.put("里程(km)", "mileage");
|
||||
map.put("里程", "mileage");
|
||||
map.put("运输单价", "unitPrice");
|
||||
map.put("单价", "unitPrice");
|
||||
map.put("*运输费", "transportFee");
|
||||
map.put("运输费", "transportFee");
|
||||
map.put("结算金额", "settlementAmount");
|
||||
map.put("*实际发货时间", "actualStartDate");
|
||||
map.put("实际发货时间", "actualStartDate");
|
||||
map.put("*实际完成时间", "actualEndDate");
|
||||
map.put("实际完成时间", "actualEndDate");
|
||||
map.put("预计发货时间", "planStartDate");
|
||||
map.put("预计完成时间", "planEndDate");
|
||||
map.put("备注", "remark");
|
||||
map.put("同一运单标识号", "waybillIdentifier");
|
||||
return map;
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.excel;
|
||||
|
||||
import cn.idev.excel.annotation.ExcelIgnore;
|
||||
import cn.idev.excel.annotation.ExcelProperty;
|
||||
import cn.idev.excel.annotation.write.style.ColumnWidth;
|
||||
import cn.idev.excel.annotation.write.style.ContentRowHeight;
|
||||
import cn.idev.excel.annotation.write.style.HeadRowHeight;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 导入结算单 Excel 固定列模型(失败明细兜底导出;费用项由用户在原模板中自行增列)。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Data
|
||||
@ColumnWidth(22)
|
||||
@HeadRowHeight(28)
|
||||
@ContentRowHeight(18)
|
||||
public class SettlementImportTemplateExcel implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@ExcelProperty("序号")
|
||||
private Integer serialNumber;
|
||||
@ExcelProperty("原始单号")
|
||||
private String originalNo;
|
||||
@ExcelProperty("配载标识号")
|
||||
private String loadingIdentifier;
|
||||
@ExcelProperty("*车牌号/航班号/船号/班列号")
|
||||
private String vehicleNo;
|
||||
@ExcelProperty("*运输方式")
|
||||
private String transportType;
|
||||
@ExcelProperty("司机/船长姓名")
|
||||
private String driverName;
|
||||
@ExcelProperty("司机/船长手机号")
|
||||
private String driverPhone;
|
||||
@ExcelProperty("*发货地址")
|
||||
private String departureAddress;
|
||||
@ExcelProperty("发货联系人")
|
||||
private String departureContact;
|
||||
@ExcelProperty("发货联系人电话")
|
||||
private String departurePhone;
|
||||
@ExcelProperty("*到货地址")
|
||||
private String arrivalAddress;
|
||||
@ExcelProperty("收货联系人")
|
||||
private String arrivalContact;
|
||||
@ExcelProperty("收货联系人电话")
|
||||
private String arrivalPhone;
|
||||
@ExcelProperty("*货物名称")
|
||||
private String cargoName;
|
||||
@ExcelProperty("*货物类型")
|
||||
private String cargoType;
|
||||
@ExcelProperty("包装")
|
||||
private String packageType;
|
||||
@ExcelProperty("*数量")
|
||||
private BigDecimal quantity;
|
||||
@ExcelProperty("*数量单位")
|
||||
private String quantityUnit;
|
||||
@ExcelProperty("规格")
|
||||
private String specification;
|
||||
@ExcelProperty("型号")
|
||||
private String model;
|
||||
@ExcelProperty("里程(km)")
|
||||
private BigDecimal mileage;
|
||||
@ExcelProperty("运输单价")
|
||||
private BigDecimal unitPrice;
|
||||
@ExcelProperty("*运输费")
|
||||
private BigDecimal transportFee;
|
||||
/** 模板已去掉结算金额列;仅失败明细内部保留,不导出为表头 */
|
||||
@ExcelIgnore
|
||||
private BigDecimal settlementAmount;
|
||||
@ExcelProperty("*实际发货时间")
|
||||
private String actualStartDate;
|
||||
@ExcelProperty("*实际完成时间")
|
||||
private String actualEndDate;
|
||||
@ExcelProperty("预计发货时间")
|
||||
private String planStartDate;
|
||||
@ExcelProperty("预计完成时间")
|
||||
private String planEndDate;
|
||||
@ExcelProperty("备注")
|
||||
private String remark;
|
||||
@ExcelProperty("同一运单标识号")
|
||||
private String waybillIdentifier;
|
||||
|
||||
/** 导入失败原因(不导出到模板,仅用于失败明细) */
|
||||
@ExcelIgnore
|
||||
private String errorMessage;
|
||||
}
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.excel;
|
||||
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.CellStyle;
|
||||
import org.apache.poi.ss.usermodel.DataValidation;
|
||||
import org.apache.poi.ss.usermodel.DataValidationConstraint;
|
||||
import org.apache.poi.ss.usermodel.DataValidationHelper;
|
||||
import org.apache.poi.ss.usermodel.FillPatternType;
|
||||
import org.apache.poi.ss.usermodel.Font;
|
||||
import org.apache.poi.ss.usermodel.HorizontalAlignment;
|
||||
import org.apache.poi.ss.usermodel.IndexedColors;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.VerticalAlignment;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.ss.util.CellRangeAddressList;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 导入结算单模板导出:填写说明 + 固定数据表头 + 运输方式下拉。
|
||||
* <p>
|
||||
* 模板不预置费用项列;用户可在「*运输费」与后续固定列之间自行插入系统已启用的费用项列。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public final class SettlementImportTemplateExporter {
|
||||
|
||||
private static final List<String> FIXED_HEADERS_BEFORE_FEE = List.of(
|
||||
"序号",
|
||||
"原始单号",
|
||||
"配载标识号",
|
||||
"*车牌号/航班号/船号/班列号",
|
||||
"*运输方式",
|
||||
"司机/船长姓名",
|
||||
"司机/船长手机号",
|
||||
"*发货地址",
|
||||
"发货联系人",
|
||||
"发货联系人电话",
|
||||
"*到货地址",
|
||||
"收货联系人",
|
||||
"收货联系人电话",
|
||||
"*货物名称",
|
||||
"*货物类型",
|
||||
"包装",
|
||||
"*数量",
|
||||
"*数量单位",
|
||||
"规格",
|
||||
"型号",
|
||||
"里程(km)",
|
||||
"运输单价",
|
||||
"*运输费"
|
||||
);
|
||||
|
||||
private static final List<String> FIXED_HEADERS_AFTER_FEE = List.of(
|
||||
"*实际发货时间",
|
||||
"*实际完成时间",
|
||||
"预计发货时间",
|
||||
"预计完成时间",
|
||||
"备注",
|
||||
"同一运单标识号"
|
||||
);
|
||||
|
||||
private SettlementImportTemplateExporter() {
|
||||
}
|
||||
|
||||
public static void export(HttpServletResponse response, List<String> transportTypeOptions,
|
||||
List<String> feeItemNames) {
|
||||
List<String> headers = buildHeaders();
|
||||
// 运输方式由调用方传入业务字典 transport_type 的字典名称,禁止写死
|
||||
List<String> transportOptions = transportTypeOptions == null ? List.of() : transportTypeOptions.stream()
|
||||
.filter(item -> item != null && !item.isBlank())
|
||||
.map(String::trim)
|
||||
.distinct()
|
||||
.toList();
|
||||
try (Workbook workbook = new XSSFWorkbook()) {
|
||||
writeInstructionSheet(workbook, transportOptions, feeItemNames);
|
||||
Sheet dataSheet = workbook.createSheet("导入结算单模板");
|
||||
CellStyle headerStyle = createHeaderStyle(workbook);
|
||||
Row headerRow = dataSheet.createRow(0);
|
||||
for (int index = 0; index < headers.size(); index++) {
|
||||
Cell cell = headerRow.createCell(index);
|
||||
cell.setCellValue(headers.get(index));
|
||||
cell.setCellStyle(headerStyle);
|
||||
dataSheet.setColumnWidth(index, Math.min(28, Math.max(12, headers.get(index).length() + 4)) * 256);
|
||||
}
|
||||
int transportTypeColumn = headers.indexOf("*运输方式");
|
||||
if (transportTypeColumn >= 0 && !transportOptions.isEmpty()) {
|
||||
addDropdown(dataSheet, transportTypeColumn, transportOptions);
|
||||
}
|
||||
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
response.setHeader("Content-disposition",
|
||||
"attachment;filename=" + URLEncoder.encode("导入结算单模板", StandardCharsets.UTF_8) + ".xlsx");
|
||||
workbook.write(response.getOutputStream());
|
||||
} catch (IOException exception) {
|
||||
throw new ServiceException("导出导入结算单模板失败:" + exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 固定列模板:不预置费用项列,由用户自行插入。 */
|
||||
private static List<String> buildHeaders() {
|
||||
List<String> headers = new ArrayList<>(FIXED_HEADERS_BEFORE_FEE);
|
||||
headers.addAll(FIXED_HEADERS_AFTER_FEE);
|
||||
return headers;
|
||||
}
|
||||
|
||||
private static void writeInstructionSheet(Workbook workbook, List<String> transportOptions,
|
||||
List<String> feeItemNames) {
|
||||
Sheet sheet = workbook.createSheet("填写说明");
|
||||
CellStyle titleStyle = workbook.createCellStyle();
|
||||
Font titleFont = workbook.createFont();
|
||||
titleFont.setBold(true);
|
||||
titleStyle.setFont(titleFont);
|
||||
List<String> lines = new ArrayList<>();
|
||||
lines.add("导入结算单填写说明");
|
||||
lines.add("1. 请不要修改「导入结算单模板」工作表的固定表头;带 * 号为必填项。");
|
||||
lines.add("2. 运输方式请从下拉项中选择,须与业务字典 transport_type 的字典名称一致。");
|
||||
lines.add("3. 日期请填写 yyyy-MM-dd(如 2026-01-01);Excel 日期单元格也可直接选择。");
|
||||
lines.add("4. 里程(km) 可为空;填写时须为正数。");
|
||||
lines.add("5. 「*运输费」之后、「*实际发货时间」之前可按需自行插入费用项列,列名须与系统已启用费用项名称完全一致;金额不为 0 时若不存在或已停用则无法导入。");
|
||||
lines.add("6. 模板不含结算金额列;导入后系统按「运输费 + 各费用项」自动合计结算金额。");
|
||||
if (transportOptions != null && !transportOptions.isEmpty()) {
|
||||
lines.add("7. 当前可选运输方式(业务字典 transport_type 名称):"
|
||||
+ String.join("、", transportOptions) + "。");
|
||||
} else {
|
||||
lines.add("7. 运输方式请从业务字典 transport_type 维护的名称中选择。");
|
||||
}
|
||||
if (feeItemNames != null && !feeItemNames.isEmpty()) {
|
||||
lines.add("8. 费用项请按实际需要在模板中自行增列,列名须与系统已启用费用项一致;当前系统已启用费用项:"
|
||||
+ String.join("、", feeItemNames.stream().limit(30).toList())
|
||||
+ (feeItemNames.size() > 30 ? " 等" : "") + "。");
|
||||
} else {
|
||||
lines.add("8. 费用项请按实际需要在模板中自行增列,列名须与系统已启用的费用项主数据名称完全一致。");
|
||||
}
|
||||
for (int index = 0; index < lines.size(); index++) {
|
||||
Row row = sheet.createRow(index);
|
||||
Cell cell = row.createCell(0);
|
||||
cell.setCellValue(lines.get(index));
|
||||
if (index == 0) {
|
||||
cell.setCellStyle(titleStyle);
|
||||
}
|
||||
}
|
||||
sheet.setColumnWidth(0, 120 * 256);
|
||||
}
|
||||
|
||||
private static void addDropdown(Sheet sheet, int columnIndex, List<String> options) {
|
||||
DataValidationHelper helper = sheet.getDataValidationHelper();
|
||||
String[] values = options.toArray(new String[0]);
|
||||
DataValidationConstraint constraint = helper.createExplicitListConstraint(values);
|
||||
CellRangeAddressList addressList = new CellRangeAddressList(1, 2000, columnIndex, columnIndex);
|
||||
DataValidation validation = helper.createValidation(constraint, addressList);
|
||||
validation.setEmptyCellAllowed(true);
|
||||
validation.setShowErrorBox(true);
|
||||
sheet.addValidationData(validation);
|
||||
}
|
||||
|
||||
private static CellStyle createHeaderStyle(Workbook workbook) {
|
||||
CellStyle style = workbook.createCellStyle();
|
||||
style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
|
||||
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
|
||||
style.setAlignment(HorizontalAlignment.CENTER);
|
||||
style.setVerticalAlignment(VerticalAlignment.CENTER);
|
||||
Font font = workbook.createFont();
|
||||
font.setBold(true);
|
||||
style.setFont(font);
|
||||
return style;
|
||||
}
|
||||
|
||||
}
|
||||
+11
@@ -27,9 +27,20 @@ package org.springblade.transport.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.springblade.transport.pojo.entity.BillPayment;
|
||||
|
||||
/** 汇票付款 Mapper。 @author Chill */
|
||||
@Mapper
|
||||
public interface BillPaymentMapper extends BaseMapper<BillPayment> {
|
||||
|
||||
/**
|
||||
* 查询指定租户、指定日期前缀下已使用的最大汇票付款流水号。
|
||||
* 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
|
||||
*/
|
||||
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(payment_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
|
||||
+ "FROM blade_bill_payment WHERE tenant_id = #{tenantId} "
|
||||
+ "AND payment_no LIKE CONCAT(#{prefix}, '%')")
|
||||
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
|
||||
}
|
||||
|
||||
+1
-2
@@ -101,8 +101,7 @@
|
||||
approved_time,
|
||||
kingdee_status,
|
||||
kingdee_fail_reason,
|
||||
kingdee_sync_time
|
||||
approved_time,
|
||||
kingdee_sync_time,
|
||||
(
|
||||
SELECT s.review_status
|
||||
FROM blade_customer_credit_score s
|
||||
|
||||
+9
@@ -29,4 +29,13 @@ public interface FormalSettlementMapper extends BaseMapper<FormalSettlement> {
|
||||
/** 恢复逻辑删除的正式结算单主记录。 */
|
||||
@Update("UPDATE blade_formal_settlement SET is_deleted = 0 WHERE tenant_id = #{tenantId} AND id = #{id} AND is_deleted = 1")
|
||||
int restoreByIdIncludingDeleted(@Param("tenantId") String tenantId, @Param("id") Long id);
|
||||
|
||||
/**
|
||||
* 查询指定租户、指定日期前缀下已使用的最大正式结算单流水号。
|
||||
* 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
|
||||
*/
|
||||
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(formal_settlement_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
|
||||
+ "FROM blade_formal_settlement WHERE tenant_id = #{tenantId} "
|
||||
+ "AND formal_settlement_no LIKE CONCAT(#{prefix}, '%')")
|
||||
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
|
||||
}
|
||||
|
||||
+11
@@ -10,9 +10,20 @@ package org.springblade.transport.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.springblade.transport.pojo.entity.FormalSettlementPayment;
|
||||
|
||||
/** 正式结算付款申请 Mapper。 @author Chill */
|
||||
@Mapper
|
||||
public interface FormalSettlementPaymentMapper extends BaseMapper<FormalSettlementPayment> {
|
||||
|
||||
/**
|
||||
* 查询指定租户、指定日期前缀下已使用的最大付款申请流水号。
|
||||
* 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
|
||||
*/
|
||||
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(payment_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
|
||||
+ "FROM blade_formal_settlement_payment WHERE tenant_id = #{tenantId} "
|
||||
+ "AND payment_no LIKE CONCAT(#{prefix}, '%')")
|
||||
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
|
||||
}
|
||||
|
||||
+11
@@ -27,6 +27,8 @@ package org.springblade.transport.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.springblade.transport.pojo.entity.InvoiceApplication;
|
||||
|
||||
/**
|
||||
@@ -36,4 +38,13 @@ import org.springblade.transport.pojo.entity.InvoiceApplication;
|
||||
*/
|
||||
@Mapper
|
||||
public interface InvoiceApplicationMapper extends BaseMapper<InvoiceApplication> {
|
||||
|
||||
/**
|
||||
* 查询指定租户、指定日期前缀下已使用的最大开票申请流水号。
|
||||
* 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
|
||||
*/
|
||||
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(application_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
|
||||
+ "FROM blade_invoice_application WHERE tenant_id = #{tenantId} "
|
||||
+ "AND application_no LIKE CONCAT(#{prefix}, '%')")
|
||||
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
|
||||
}
|
||||
|
||||
+6
-4
@@ -44,11 +44,13 @@ public interface KingdeeInvoicePoolMapper extends BaseMapper<KingdeeInvoicePool>
|
||||
/**
|
||||
* 金蝶票据池镜像统一分页查询:仅出可用票(剩余可用金额>0 且未使用),认领/登记标记由 EXISTS 子查询直出
|
||||
*
|
||||
* @param page 分页参数
|
||||
* @param query 统一查询条件(已在上层完成 trim 与日期格式校验)
|
||||
* @param tenantId 当前租户
|
||||
* @param page 分页参数
|
||||
* @param query 统一查询条件(已在上层完成 trim 与日期格式校验)
|
||||
* @param tenantId 当前租户
|
||||
* @param customerUnitsOnly 客商单位过滤开关:开启时只出销/购方税号均登记在客商发票信息中的发票(与定时同步同开关)
|
||||
* @return 统一视图分页
|
||||
*/
|
||||
IPage<KingdeeInvoicePoolVO> selectInvoicePoolPage(IPage<KingdeeInvoicePoolVO> page,
|
||||
@Param("query") KingdeeInvoicePoolQueryDTO query, @Param("tenantId") String tenantId);
|
||||
@Param("query") KingdeeInvoicePoolQueryDTO query, @Param("tenantId") String tenantId,
|
||||
@Param("customerUnitsOnly") boolean customerUnitsOnly);
|
||||
}
|
||||
|
||||
+12
@@ -39,12 +39,24 @@
|
||||
AND p.available_amount > 0 AND p.amount_use_status = '0'
|
||||
</sql>
|
||||
|
||||
<!-- 客商单位过滤:开票单位(销方)与受票单位(购方)税号均须登记在有效客商发票信息中
|
||||
(与定时同步拉取过滤同开关同口径;自定义 SQL 无租户插件,手工带租户与逻辑删除) -->
|
||||
<sql id="customerUnitsFilter">
|
||||
AND EXISTS (SELECT 1 FROM blade_customer_invoice_info ci
|
||||
WHERE ci.is_deleted = 0 AND ci.status = 1 AND ci.tenant_id = p.tenant_id AND ci.tax_no = p.saler_tax_no)
|
||||
AND EXISTS (SELECT 1 FROM blade_customer_invoice_info cj
|
||||
WHERE cj.is_deleted = 0 AND cj.status = 1 AND cj.tenant_id = p.tenant_id AND cj.tax_no = p.buyer_tax_no)
|
||||
</sql>
|
||||
|
||||
<select id="selectInvoicePoolPage" resultType="org.springblade.transport.pojo.vo.KingdeeInvoicePoolVO">
|
||||
SELECT
|
||||
<include refid="invoicePoolVoColumns"/>
|
||||
FROM blade_kingdee_invoice_pool p
|
||||
WHERE p.is_deleted = 0 AND p.status = 1 AND p.tenant_id = #{tenantId}
|
||||
<include refid="usableFilter"/>
|
||||
<if test="customerUnitsOnly">
|
||||
<include refid="customerUnitsFilter"/>
|
||||
</if>
|
||||
<if test="query.invoiceNo != null and query.invoiceNo != ''">
|
||||
AND p.invoice_no LIKE CONCAT('%', #{query.invoiceNo}, '%')
|
||||
</if>
|
||||
|
||||
+11
@@ -21,9 +21,20 @@ package org.springblade.transport.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.springblade.transport.pojo.entity.PaymentApplication;
|
||||
|
||||
/** 付款申请 Mapper。 @author Chill */
|
||||
@Mapper
|
||||
public interface PaymentApplicationMapper extends BaseMapper<PaymentApplication> {
|
||||
|
||||
/**
|
||||
* 查询指定租户、指定日期前缀下已使用的最大付款申请流水号。
|
||||
* 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
|
||||
*/
|
||||
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(payment_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
|
||||
+ "FROM blade_payment_application WHERE tenant_id = #{tenantId} "
|
||||
+ "AND payment_no LIKE CONCAT(#{prefix}, '%')")
|
||||
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
|
||||
}
|
||||
|
||||
+10
@@ -10,6 +10,8 @@ package org.springblade.transport.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.springblade.transport.pojo.entity.PreSettlementAdvance;
|
||||
|
||||
/**
|
||||
@@ -19,4 +21,12 @@ import org.springblade.transport.pojo.entity.PreSettlementAdvance;
|
||||
*/
|
||||
@Mapper
|
||||
public interface PreSettlementAdvanceMapper extends BaseMapper<PreSettlementAdvance> {
|
||||
|
||||
/**
|
||||
* 查询预付单号最大流水(含逻辑删除)。
|
||||
*/
|
||||
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(advance_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
|
||||
+ "FROM blade_pre_settlement_advance WHERE tenant_id = #{tenantId} "
|
||||
+ "AND advance_no LIKE CONCAT(#{prefix}, '%')")
|
||||
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
|
||||
}
|
||||
|
||||
+18
@@ -10,6 +10,8 @@ package org.springblade.transport.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.springblade.transport.pojo.entity.PreSettlement;
|
||||
|
||||
/**
|
||||
@@ -19,4 +21,20 @@ import org.springblade.transport.pojo.entity.PreSettlement;
|
||||
*/
|
||||
@Mapper
|
||||
public interface PreSettlementMapper extends BaseMapper<PreSettlement> {
|
||||
|
||||
/**
|
||||
* 查询预结算单号最大流水(含逻辑删除)。
|
||||
*/
|
||||
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(pre_settlement_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
|
||||
+ "FROM blade_pre_settlement WHERE tenant_id = #{tenantId} "
|
||||
+ "AND pre_settlement_no LIKE CONCAT(#{prefix}, '%')")
|
||||
Long selectMaxPreSettlementDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
|
||||
|
||||
/**
|
||||
* 查询预结算转正式结算单号最大流水(含逻辑删除)。
|
||||
*/
|
||||
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(formal_settlement_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
|
||||
+ "FROM blade_pre_settlement WHERE tenant_id = #{tenantId} "
|
||||
+ "AND formal_settlement_no LIKE CONCAT(#{prefix}, '%')")
|
||||
Long selectMaxFormalSettlementDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
|
||||
}
|
||||
|
||||
+2
-1
@@ -102,7 +102,8 @@
|
||||
FROM blade_receipt_claim c
|
||||
INNER JOIN blade_kingdee_receipt_flow f ON f.id = c.receipt_flow_id
|
||||
WHERE <include refid="claimRecordConditions"/>
|
||||
ORDER BY c.create_time DESC
|
||||
<!-- 认领时间倒序:存量认领记录的 create_time 为空,排序改以认领日期为主、id 兜同日先后 -->
|
||||
ORDER BY c.claim_date DESC, c.id DESC
|
||||
</select>
|
||||
|
||||
<select id="selectClaimRecordDetail" resultType="org.springblade.transport.pojo.vo.ReceiptClaimRecordVO">
|
||||
|
||||
+13
-1
@@ -2,7 +2,19 @@ package org.springblade.transport.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.springblade.transport.pojo.entity.SettlementAdjustment;
|
||||
|
||||
@Mapper
|
||||
public interface SettlementAdjustmentMapper extends BaseMapper<SettlementAdjustment> {}
|
||||
public interface SettlementAdjustmentMapper extends BaseMapper<SettlementAdjustment> {
|
||||
|
||||
/**
|
||||
* 查询指定租户、指定日期前缀下已使用的最大结算调整单流水号。
|
||||
* 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
|
||||
*/
|
||||
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(adjustment_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
|
||||
+ "FROM blade_settlement_adjustment WHERE tenant_id = #{tenantId} "
|
||||
+ "AND adjustment_no LIKE CONCAT(#{prefix}, '%')")
|
||||
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
|
||||
}
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.springblade.transport.pojo.entity.SettlementImportBatch;
|
||||
|
||||
/**
|
||||
* 导入结算单批次 Mapper
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Mapper
|
||||
public interface SettlementImportBatchMapper extends BaseMapper<SettlementImportBatch> {
|
||||
|
||||
/**
|
||||
* 查询指定租户、指定日期前缀下已使用的最大批次流水号。
|
||||
* 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
|
||||
*/
|
||||
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(batch_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
|
||||
+ "FROM blade_settlement_import_batch WHERE tenant_id = #{tenantId} "
|
||||
+ "AND batch_no LIKE CONCAT(#{prefix}, '%')")
|
||||
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.springblade.transport.pojo.entity.SettlementProjectAdvanceWriteOff;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 结算单项目预付核销 Mapper
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Mapper
|
||||
public interface SettlementProjectAdvanceWriteOffMapper extends BaseMapper<SettlementProjectAdvanceWriteOff> {
|
||||
|
||||
/**
|
||||
* 统计项目预付已核销金额(排除作废结算单,可排除当前结算单)。
|
||||
*/
|
||||
BigDecimal sumWriteOffByPaymentApplication(@Param("paymentApplicationId") Long paymentApplicationId,
|
||||
@Param("excludeSettlementType") String excludeSettlementType,
|
||||
@Param("excludeSettlementId") Long excludeSettlementId);
|
||||
|
||||
/**
|
||||
* 统计结算单核销合计。
|
||||
*/
|
||||
BigDecimal sumWriteOffBySettlement(@Param("settlementType") String settlementType,
|
||||
@Param("settlementId") Long settlementId);
|
||||
|
||||
/**
|
||||
* 按结算单集合分组统计核销合计(一次 group-by,供批量刷新与列表页使用,避免逐单 N+1)。
|
||||
*
|
||||
* @return settlementId / totalAmount 两列
|
||||
*/
|
||||
List<Map<String, Object>> sumWriteOffBySettlementIds(@Param("settlementType") String settlementType,
|
||||
@Param("settlementIds") Collection<Long> settlementIds);
|
||||
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="org.springblade.transport.mapper.SettlementProjectAdvanceWriteOffMapper">
|
||||
|
||||
<select id="sumWriteOffByPaymentApplication" resultType="java.math.BigDecimal">
|
||||
SELECT COALESCE(SUM(w.write_off_amount), 0)
|
||||
FROM blade_settlement_project_advance_write_off w
|
||||
LEFT JOIN blade_pre_settlement p
|
||||
ON w.settlement_type = 'pre'
|
||||
AND w.settlement_id = p.id
|
||||
AND p.is_deleted = 0
|
||||
LEFT JOIN blade_formal_settlement f
|
||||
ON w.settlement_type = 'formal'
|
||||
AND w.settlement_id = f.id
|
||||
AND f.is_deleted = 0
|
||||
WHERE w.is_deleted = 0
|
||||
AND w.payment_application_id = #{paymentApplicationId}
|
||||
AND (
|
||||
(w.settlement_type = 'pre' AND p.id IS NOT NULL AND IFNULL(p.approval_status, '') <> 'voided')
|
||||
OR (w.settlement_type = 'formal' AND f.id IS NOT NULL AND IFNULL(f.approval_status, '') <> 'voided')
|
||||
)
|
||||
<if test="excludeSettlementType != null and excludeSettlementId != null">
|
||||
AND NOT (w.settlement_type = #{excludeSettlementType} AND w.settlement_id = #{excludeSettlementId})
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="sumWriteOffBySettlement" resultType="java.math.BigDecimal">
|
||||
SELECT COALESCE(SUM(write_off_amount), 0)
|
||||
FROM blade_settlement_project_advance_write_off
|
||||
WHERE is_deleted = 0
|
||||
AND settlement_type = #{settlementType}
|
||||
AND settlement_id = #{settlementId}
|
||||
</select>
|
||||
|
||||
<select id="sumWriteOffBySettlementIds" resultType="java.util.HashMap">
|
||||
SELECT settlement_id AS settlementId, COALESCE(SUM(write_off_amount), 0) AS totalAmount
|
||||
FROM blade_settlement_project_advance_write_off
|
||||
WHERE is_deleted = 0
|
||||
AND settlement_type = #{settlementType}
|
||||
AND settlement_id IN
|
||||
<foreach collection="settlementIds" item="settlementId" open="(" separator="," close=")">
|
||||
#{settlementId}
|
||||
</foreach>
|
||||
GROUP BY settlement_id
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
+9
@@ -22,4 +22,13 @@ public interface TransportReconciliationMapper extends BaseMapper<TransportRecon
|
||||
/** 恢复逻辑删除的运输对账单主记录。 */
|
||||
@Update("UPDATE blade_transport_reconciliation SET is_deleted = 0 WHERE tenant_id = #{tenantId} AND id = #{id} AND is_deleted = 1")
|
||||
int restoreByIdIncludingDeleted(@Param("tenantId") String tenantId, @Param("id") Long id);
|
||||
|
||||
/**
|
||||
* 查询指定租户、指定日期前缀下已使用的最大对账单流水号。
|
||||
* 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
|
||||
*/
|
||||
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(reconciliation_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
|
||||
+ "FROM blade_transport_reconciliation WHERE tenant_id = #{tenantId} "
|
||||
+ "AND reconciliation_no LIKE CONCAT(#{prefix}, '%')")
|
||||
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
|
||||
}
|
||||
|
||||
+33
-8
@@ -1,6 +1,7 @@
|
||||
package org.springblade.transport.mk;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.tenant.annotation.TenantIgnore;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.pojo.dto.BillPaymentStatusRequest;
|
||||
@@ -287,6 +288,7 @@ public final class MkProcessBizHandlers {
|
||||
}
|
||||
}
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@TenantIgnore
|
||||
@RequiredArgsConstructor
|
||||
@@ -307,6 +309,12 @@ public final class MkProcessBizHandlers {
|
||||
public boolean syncFromProcessInfo(Long id, Object processInfo) {
|
||||
FormalSettlement entity = service.getById(id);
|
||||
if (entity == null) {
|
||||
log.warn("MK同步当前节点失败,正式结算单不存在 id={}", id);
|
||||
return false;
|
||||
}
|
||||
// 已终态单据禁止再写回审批中,避免流程结束后的节点刷新把状态打回 reviewing
|
||||
if ("approved".equals(entity.getApprovalStatus()) || "returned".equals(entity.getApprovalStatus())
|
||||
|| "voided".equals(entity.getApprovalStatus())) {
|
||||
return false;
|
||||
}
|
||||
String node = MkProcessNodeHelper.currentNode(processInfo);
|
||||
@@ -327,8 +335,12 @@ public final class MkProcessBizHandlers {
|
||||
@Override
|
||||
public boolean approveFromProcess(Long id, String processorName) {
|
||||
FormalSettlement entity = service.getById(id);
|
||||
if (entity == null || "approved".equals(entity.getApprovalStatus())) {
|
||||
return entity != null;
|
||||
if (entity == null) {
|
||||
log.error("MK审批通过同步失败,正式结算单不存在 id={}", id);
|
||||
return false;
|
||||
}
|
||||
if ("approved".equals(entity.getApprovalStatus())) {
|
||||
return true;
|
||||
}
|
||||
// 统一走 service.approve:与手动审批同一入口(补变更记录),审批事务提交后自动触发金蝶推送事件;
|
||||
// 仅审批中单据允许通过,重复回调被状态校验拒绝,不重复推送
|
||||
@@ -347,8 +359,12 @@ public final class MkProcessBizHandlers {
|
||||
@Override
|
||||
public boolean rejectFromProcess(Long id, String processorName) {
|
||||
FormalSettlement entity = service.getById(id);
|
||||
if (entity == null || "returned".equals(entity.getApprovalStatus())) {
|
||||
return entity != null;
|
||||
if (entity == null) {
|
||||
log.error("MK审批驳回同步失败,正式结算单不存在 id={}", id);
|
||||
return false;
|
||||
}
|
||||
if ("returned".equals(entity.getApprovalStatus())) {
|
||||
return true;
|
||||
}
|
||||
entity.setApprovalStatus("returned");
|
||||
entity.setCurrentNode("审核不通过");
|
||||
@@ -422,10 +438,19 @@ public final class MkProcessBizHandlers {
|
||||
if (entity == null || "returned".equals(entity.getApprovalStatus())) {
|
||||
return entity != null;
|
||||
}
|
||||
entity.setApprovalStatus("returned");
|
||||
entity.setCurrentNode("审核不通过");
|
||||
entity.setCurrentProcessor(MkProcessNodeHelper.text(processorName, "系统"));
|
||||
return service.updateById(entity);
|
||||
if ("reviewing".equals(entity.getApprovalStatus())) {
|
||||
PaymentApplicationStatusRequest request = new PaymentApplicationStatusRequest();
|
||||
request.setId(id);
|
||||
service.returnBill(request);
|
||||
}
|
||||
PaymentApplication updated = service.getById(id);
|
||||
if (updated == null) {
|
||||
return false;
|
||||
}
|
||||
updated.setApprovalStatus("returned");
|
||||
updated.setCurrentNode("审核不通过");
|
||||
updated.setCurrentProcessor(MkProcessNodeHelper.text(processorName, "系统"));
|
||||
return service.updateById(updated);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+75
-28
@@ -4,10 +4,12 @@ import com.alibaba.fastjson2.JSON;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.tenant.TenantUtil;
|
||||
import org.springblade.core.tenant.annotation.TenantIgnore;
|
||||
import org.springblade.core.tool.api.FR;
|
||||
import org.springblade.core.tool.utils.StringUtil;
|
||||
import org.springblade.process.feign.IBusinessProcessClient;
|
||||
import org.springblade.process.pojo.vo.MkFormBindVO;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Collections;
|
||||
@@ -69,9 +71,9 @@ public class MkProcessMessageService {
|
||||
log.warn("公开页流程消息未找到 processId,跳过查询当前节点 bizType={}", bizType);
|
||||
return;
|
||||
}
|
||||
String bizIdText = firstText(asMap(body == null ? null : body.get("formData")), "id", "formInstanceId");
|
||||
Long bizId = resolveBizId(asMap(body == null ? null : body.get("formData")));
|
||||
queryCurrentNodes(bizType, processId, loginName);
|
||||
scheduleProcessInfo(handler, bizType, processId, loginName, bizIdText, formValues);
|
||||
scheduleProcessInfo(handler, bizType, processId, loginName, bizId, formValues);
|
||||
}
|
||||
|
||||
private void queryCurrentNodes(String bizType, String processId, String loginName) {
|
||||
@@ -86,36 +88,35 @@ public class MkProcessMessageService {
|
||||
}
|
||||
|
||||
private void scheduleProcessInfo(IMkProcessBizHandler handler, String bizType, String processId,
|
||||
String loginName, String bizIdText, Map<String, Object> formValues) {
|
||||
String loginName, Long bizId, Map<String, Object> formValues) {
|
||||
Map<String, Object> formValuesCopy = new HashMap<>(formValues == null ? Map.of() : formValues);
|
||||
log.info("公开页将在{}秒后查询流程实例详情 bizType={} processId={}", PROCESS_INFO_DELAY_SECONDS, bizType, processId);
|
||||
log.info("公开页将在{}秒后查询流程实例详情 bizType={} processId={} bizId={}",
|
||||
PROCESS_INFO_DELAY_SECONDS, bizType, processId, bizId);
|
||||
// 延迟任务跑在独立线程,请求态 @TenantIgnore 已丢失,必须显式 ignore,否则 getById 可能因租户条件查空
|
||||
scheduler.schedule(
|
||||
() -> queryProcessInfo(handler, bizType, processId, loginName, bizIdText, formValuesCopy),
|
||||
() -> TenantUtil.ignore(() -> queryProcessInfo(handler, bizType, processId, loginName, bizId, formValuesCopy)),
|
||||
PROCESS_INFO_DELAY_SECONDS,
|
||||
TimeUnit.SECONDS
|
||||
);
|
||||
}
|
||||
|
||||
private void queryProcessInfo(IMkProcessBizHandler handler, String bizType, String processId,
|
||||
String loginName, String bizIdText, Map<String, Object> formValues) {
|
||||
String loginName, Long bizId, Map<String, Object> formValues) {
|
||||
try {
|
||||
FR<Object> processInfoResult = businessProcessClient.getProcessInfo(processId, loginName);
|
||||
Object processInfo = processInfoResult == null ? null : processInfoResult.getData();
|
||||
log.info("公开页流程消息流程实例详情 bizType={} processId={} loginName={} result={}",
|
||||
bizType, processId, loginName, JSON.toJSONString(processInfo));
|
||||
log.info("公开页流程消息流程实例详情 bizType={} processId={} loginName={} bizId={} result={}",
|
||||
bizType, processId, loginName, bizId, JSON.toJSONString(processInfo));
|
||||
if (isProcessFinished(processInfo)) {
|
||||
applyProcessResult(handler, bizType, bizIdText, processId, formValues, true);
|
||||
applyProcessResult(handler, bizType, bizId, processId, formValues, true);
|
||||
} else if (MkProcessNodeHelper.isProcessRejected(processInfo)) {
|
||||
applyProcessResult(handler, bizType, bizIdText, processId, formValues, false);
|
||||
} else if (StringUtil.isNotBlank(bizIdText)) {
|
||||
try {
|
||||
handler.syncFromProcessInfo(Long.valueOf(bizIdText), processInfo);
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("公开页流程消息业务id格式不正确 bizType={} id={}", bizType, bizIdText);
|
||||
}
|
||||
applyProcessResult(handler, bizType, bizId, processId, formValues, false);
|
||||
} else if (bizId != null) {
|
||||
handler.syncFromProcessInfo(bizId, processInfo);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("公开页查询流程实例详情失败 bizType={} processId={} loginName={}", bizType, processId, loginName, e);
|
||||
log.error("公开页查询流程实例详情失败 bizType={} processId={} loginName={} bizId={}",
|
||||
bizType, processId, loginName, bizId, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,21 +124,24 @@ public class MkProcessMessageService {
|
||||
return "30".equals(firstText(asMap(processInfo), "fdProcessStatus"));
|
||||
}
|
||||
|
||||
private void applyProcessResult(IMkProcessBizHandler handler, String bizType, String bizIdText,
|
||||
private void applyProcessResult(IMkProcessBizHandler handler, String bizType, Long bizId,
|
||||
String processId, Map<String, Object> formValues, boolean approved) {
|
||||
String action = approved ? "审核通过" : "审核驳回";
|
||||
if (StringUtil.isBlank(bizIdText)) {
|
||||
log.warn("流程{}但未找到业务id,跳过同步 bizType={} processId={}", action, bizType, processId);
|
||||
if (bizId == null) {
|
||||
log.warn("流程{}但未解析到业务主键,跳过同步 bizType={} processId={}", action, bizType, processId);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
String processorName = firstText(formValues, "mkUserName", "mkLoginName");
|
||||
Long bizId = Long.valueOf(bizIdText);
|
||||
if (approved) {
|
||||
handler.approveFromProcess(bizId, processorName);
|
||||
} else {
|
||||
handler.rejectFromProcess(bizId, processorName);
|
||||
boolean synced = approved
|
||||
? handler.approveFromProcess(bizId, processorName)
|
||||
: handler.rejectFromProcess(bizId, processorName);
|
||||
if (!synced) {
|
||||
log.error("流程{}同步业务状态失败(业务单据不存在或未更新) bizType={} bizId={} processId={}",
|
||||
action, bizType, bizId, processId);
|
||||
return;
|
||||
}
|
||||
log.info("流程{}同步业务状态成功 bizType={} bizId={} processId={}", action, bizType, bizId, processId);
|
||||
try {
|
||||
FR<Boolean> markResult = businessProcessClient.markProcessFinished(processId, approved);
|
||||
log.info("公开页标记业务流程完结 bizType={} processId={} approved={} result={}",
|
||||
@@ -146,13 +150,56 @@ public class MkProcessMessageService {
|
||||
log.error("公开页标记业务流程完结失败 bizType={} processId={} approved={}",
|
||||
bizType, processId, approved, markException);
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("流程{}但业务id格式不正确 bizType={} id={}", action, bizType, bizIdText);
|
||||
} catch (Exception e) {
|
||||
log.error("流程{}同步业务状态失败 bizType={} id={} processId={}", action, bizType, bizIdText, processId, e);
|
||||
log.error("流程{}同步业务状态失败 bizType={} bizId={} processId={}", action, bizType, bizId, processId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开页 formData 里的 id / formInstanceId 可能是表单实例 id(与业务主键不同),
|
||||
* 须先按 MkFormBind 反查业务主键,与 detail 接口保持一致。
|
||||
*/
|
||||
private Long resolveBizId(Map<String, Object> formData) {
|
||||
String formInstanceIdText = firstText(formData, "formInstanceId");
|
||||
String idText = firstText(formData, "id");
|
||||
Long resolved = resolveByFormInstanceId(formInstanceIdText);
|
||||
if (resolved != null) {
|
||||
return resolved;
|
||||
}
|
||||
resolved = resolveByFormInstanceId(idText);
|
||||
if (resolved != null) {
|
||||
return resolved;
|
||||
}
|
||||
if (StringUtil.isNotBlank(idText)) {
|
||||
try {
|
||||
return Long.valueOf(idText);
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("公开页流程消息业务id格式不正确 id={}", idText);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Long resolveByFormInstanceId(String formInstanceIdText) {
|
||||
if (StringUtil.isBlank(formInstanceIdText)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Long formInstanceId = Long.valueOf(formInstanceIdText);
|
||||
FR<MkFormBindVO> result = businessProcessClient.resolveByFormInstanceId(formInstanceId);
|
||||
if (result != null && result.isSuccess() && result.getData() != null && result.getData().getBizId() != null) {
|
||||
Long bizId = result.getData().getBizId();
|
||||
log.info("公开页按表单实例id解析业务主键 formInstanceId={} bizId={}", formInstanceId, bizId);
|
||||
return bizId;
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("公开页表单实例id格式不正确 formInstanceId={}", formInstanceIdText);
|
||||
} catch (Exception e) {
|
||||
log.warn("公开页按表单实例id解析业务主键失败 formInstanceId={}", formInstanceIdText, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Map<String, Object> asMap(Object value) {
|
||||
if (!(value instanceof Map<?, ?> map)) {
|
||||
return Collections.emptyMap();
|
||||
|
||||
+3
-2
@@ -18,8 +18,9 @@ public interface IBillLedgerService extends BaseService<BillLedger> {
|
||||
IPage<BillLedgerVO> selectPage(IPage<BillLedger> page, BillLedgerVO query);
|
||||
BillLedgerVO detail(Long id);
|
||||
Map<String, Long> expiryCounts();
|
||||
List<BillLedgerVO> availableOptions(String keyword, Long deptId, Long selectedId);
|
||||
IPage<BillLedgerVO> availablePage(IPage<BillLedger> page, String keyword, Long deptId, Long selectedId);
|
||||
List<BillLedgerVO> availableOptions(String keyword, Long deptId, Long selectedId, String payerName, String payeeName);
|
||||
IPage<BillLedgerVO> availablePage(IPage<BillLedger> page, String keyword, Long deptId, Long selectedId,
|
||||
String payerName, String payeeName);
|
||||
Long submit(BillLedgerSaveRequest request);
|
||||
void removeLedger(Long id);
|
||||
}
|
||||
|
||||
+49
-2
@@ -71,6 +71,53 @@ public interface IDriverWaybillService {
|
||||
*/
|
||||
DriverWaybillCardVO detail(Long id);
|
||||
|
||||
/**
|
||||
* 当前登录司机的运输中运次(一趟车,最多一条)
|
||||
*/
|
||||
DriverWaybillCardVO tripCurrentTask();
|
||||
|
||||
/**
|
||||
* 当前登录司机的待接运次预览
|
||||
*
|
||||
* @param size 预览条数,默认 2
|
||||
*/
|
||||
DriverWaybillPreviewVO tripPendingPreview(Integer size);
|
||||
|
||||
/**
|
||||
* 运次 Tab 统计:全部 / 待接单 / 进行中 / 已完成(全部 = 前三者之和,不含已取消)
|
||||
*/
|
||||
DriverWaybillTabCountsVO tripTabCounts();
|
||||
|
||||
/**
|
||||
* 司机运次分页:一页即若干个运次(有配载单的一趟车算一个)
|
||||
*
|
||||
* @param current 当前页,从 1 开始
|
||||
* @param size 每页条数(按运次,默认 10)
|
||||
* @param status 小程序状态:空=全部,0待接单,1运输中,2已完成
|
||||
* @param keyword 关键字(运单号 / 配载单号 / 起终点 / 车牌,可选)
|
||||
*/
|
||||
IPage<DriverWaybillCardVO> tripPage(Integer current, Integer size, Integer status, String keyword);
|
||||
|
||||
/**
|
||||
* 司机运次详情:按运次主键(字符串)取整趟车,有配载单时下发站点 ①②③
|
||||
*/
|
||||
DriverWaybillCardVO tripDetail(String tripId);
|
||||
|
||||
/**
|
||||
* 运次确认接单:一次把该运次全部子运单置为已接单,司机字段不动
|
||||
*
|
||||
* @param tripId 运次主键(配载单主键或运单主键)
|
||||
*/
|
||||
boolean tripAccept(String tripId);
|
||||
|
||||
/**
|
||||
* 运次拒绝接单:整单拒绝,该运次此后对本人不可见,等调度重新派单
|
||||
*
|
||||
* @param tripId 运次主键(配载单主键或运单主键)
|
||||
* @param reason 拒绝原因,可空,最长 200 字
|
||||
*/
|
||||
boolean tripReject(String tripId, String reason);
|
||||
|
||||
/**
|
||||
* 司机端运单行驶轨迹:按打卡记录 / 计划时间自动推导起止日期后查询 LBS 轨迹。
|
||||
* <p>
|
||||
@@ -130,9 +177,9 @@ public interface IDriverWaybillService {
|
||||
boolean isNodePunched(Long waybillId, String nodeCode);
|
||||
|
||||
/**
|
||||
* 断言当前登录司机对该运单有归属:运单车牌须落在司机档案自述绑定的车牌内。
|
||||
* 断言当前登录司机对该运单有归属:运单司机手机号须等于当前用户手机号。
|
||||
* <p>
|
||||
* 无司机档案、未绑定车辆、车牌不匹配都会抛异常。管理端与调度员账号没有司机档案,
|
||||
* 无司机档案、无手机号、手机号不匹配都会抛异常。管理端与调度员账号没有司机档案,
|
||||
* 会停在「未找到当前登录司机档案」——这是刻意的,不提供代报。
|
||||
*/
|
||||
void assertCurrentDriverOwnsWaybill(Long waybillId);
|
||||
|
||||
+27
@@ -24,6 +24,8 @@ import org.springblade.transport.pojo.dto.FormalSettlementPaymentRequest;
|
||||
import org.springblade.transport.pojo.dto.FormalSettlementBatchPaymentRequest;
|
||||
import org.springblade.transport.pojo.dto.FormalSettlementInvoiceClaimRequest;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 正式结算单服务
|
||||
*
|
||||
@@ -93,4 +95,29 @@ public interface IFormalSettlementService extends BaseService<FormalSettlement>
|
||||
String applyPayment(FormalSettlementPaymentRequest request);
|
||||
List<String> applyPayments(FormalSettlementBatchPaymentRequest request);
|
||||
void claimInvoices(FormalSettlementInvoiceClaimRequest request);
|
||||
|
||||
/**
|
||||
* 结算调整重推:克隆原正式结算单为新单(含明细、明细费项、汇总费项、来源预结算、附件、汇率),
|
||||
* 新单置审批通过、未同步金蝶,来源原单号指向原单;下游关联(开票申请/收款认领/对账)不迁移。
|
||||
* 不推送金蝶,由调用方在事务外调用 syncKingdee 推送
|
||||
*
|
||||
* @param sourceSettlementId 原正式结算单id
|
||||
* @return 新单id与「原明细/原费项 -> 新明细/新费项」id映射(供调整明细应用到新单)
|
||||
*/
|
||||
CloneResult cloneFormalSettlement(Long sourceSettlementId);
|
||||
|
||||
/**
|
||||
* 结算调整重推收尾:原单标记「已调整」、旧锁解锁、追加重推变更记录(不改原单业务数据)
|
||||
*
|
||||
* @param settlementId 原正式结算单id
|
||||
* @param adjustmentNo 结算调整单号
|
||||
* @param newSettlementNo 重推生成的新单号
|
||||
*/
|
||||
void markSettlementAdjusted(Long settlementId, String adjustmentNo, String newSettlementNo);
|
||||
|
||||
/**
|
||||
* 克隆结果:新单id + 原明细id->新明细id + 原费项id->新费项id
|
||||
*/
|
||||
record CloneResult(Long settlementId, Map<Long, Long> detailIdMap, Map<Long, Long> feeIdMap) {
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ public interface IInvoiceApplicationService extends BaseService<InvoiceApplicati
|
||||
IPage<InvoiceApplicationVO> selectPage(IPage<InvoiceApplication> page, InvoiceApplicationVO query);
|
||||
InvoiceApplicationVO detail(Long id);
|
||||
IPage<Map<String, Object>> settlementCandidates(IPage<?> page, String keyword, String contractCategory,
|
||||
String settlementType, String invoiceStatus);
|
||||
String settlementType);
|
||||
List<FormalSettlementDetail> settlementDetails(String settlementIds);
|
||||
Map<String, Object> receiverInformation(String settlementIds);
|
||||
Long saveDraft(InvoiceApplicationSaveRequest request);
|
||||
|
||||
+40
-2
@@ -9,8 +9,10 @@
|
||||
package org.springblade.transport.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import org.springblade.core.mp.base.BaseEntity;
|
||||
import org.springblade.thirdparty.bfi.pojo.dto.BfiBillLockDTO;
|
||||
import org.springblade.transport.pojo.entity.KingdeeBillLock;
|
||||
import org.springblade.transport.pojo.vo.KingdeeUnlockMark;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -39,8 +41,10 @@ public interface IKingdeeBillLockService extends IService<KingdeeBillLock> {
|
||||
String lockAfterPush(String sourceBillType, Long sourceBillId, String sourceBillNo, String targetBillType, List<String> targetBillNos);
|
||||
|
||||
/**
|
||||
* 金蝶解锁回调(unLockNotifyBusiData):校验来源/目标系统与 lockAction,
|
||||
* targetBillNo 支持逗号拼接批量,逐张按 targetBillType+targetBillNo 定位最新 locked 锁记录;
|
||||
* 金蝶解锁回调(unLockNotifyBusiData):校验来源/目标系统与 lockAction。
|
||||
* 注意回调方向与锁定相反:回调 sourceBillNo=金蝶单号(即锁记录 targetBillNo,逗号拼接批量)、
|
||||
* targetBillNo=WLTMS 单号(即锁记录 sourceBillNo)、targetBillType 可能为空,
|
||||
* 故按金蝶单号+我方单号双条件反查最新 locked 锁记录;
|
||||
* operationType=DELETE 时按单据类型分发废除源单并还原关联数据
|
||||
*
|
||||
* @param param 金蝶回调参数
|
||||
@@ -56,6 +60,17 @@ public interface IKingdeeBillLockService extends IService<KingdeeBillLock> {
|
||||
*/
|
||||
void checkEditable(String sourceBillType, Long sourceBillId);
|
||||
|
||||
/**
|
||||
* 结算调整重推解锁:把源单当前「锁定中」的锁记录标记为 unlocked(非金蝶回调,仅本地解锁留痕),
|
||||
* 供原结算单被调整重推生成新单后解除旧锁使用;新单另行落新锁记录
|
||||
*
|
||||
* @param sourceBillType WLTMS 单据类型
|
||||
* @param sourceBillId WLTMS 业务主键
|
||||
* @param operator 操作人
|
||||
* @return 解锁的记录数
|
||||
*/
|
||||
int unlockByAdjustRepush(String sourceBillType, Long sourceBillId, String operator);
|
||||
|
||||
/**
|
||||
* 批量查源单当前锁状态(供列表/详情页填充展示标识):
|
||||
* 每源单取最新一条非终态锁记录(locked/failed),unlocked 为终态不返回(前端视为无锁/已解锁)
|
||||
@@ -66,6 +81,29 @@ public interface IKingdeeBillLockService extends IService<KingdeeBillLock> {
|
||||
*/
|
||||
Map<Long, KingdeeBillLock> mapActiveLocks(String sourceBillType, List<Long> sourceBillIds);
|
||||
|
||||
/**
|
||||
* 批量查源单解锁终态留痕(供列表/详情页填充解锁标识):
|
||||
* 每源单取最新一条 unlocked 锁记录(付款申请解锁后不作废,页面据此区分「金蝶删单/解锁」与普通推送失败)
|
||||
*
|
||||
* @param sourceBillType WLTMS 单据类型
|
||||
* @param sourceBillIds 源单主键集合
|
||||
* @return sourceBillId -> 最新 unlocked 锁记录,无解锁留痕的源单不在 Map 中
|
||||
*/
|
||||
Map<Long, KingdeeBillLock> mapUnlockedLocks(String sourceBillType, List<Long> sourceBillIds);
|
||||
|
||||
/**
|
||||
* 批量填充金蝶解锁终态标识(通用):对无活跃锁的行反查最新 unlocked 锁记录并回填解锁标识
|
||||
* (解锁时间/操作人/动作名称),供前端展示明显解锁标识;有活跃锁的单必非解锁终态,跳过。
|
||||
* 与 mapActiveLocks 配对在各单据 fillKingdeeLockStatus 中调用,列表/详情共用;
|
||||
* 后续单据(如收票单)接入锁单时,VO 实现 KingdeeUnlockMark 即可复用
|
||||
*
|
||||
* @param sourceBillType WLTMS 单据类型
|
||||
* @param rows 已填充活跃锁标识的 VO 行
|
||||
* @param activeLockMap 活跃锁 Map(mapActiveLocks 返回值)
|
||||
* @param <T> 实现 KingdeeUnlockMark 的 VO 类型(须继承 BaseEntity 以取主键)
|
||||
*/
|
||||
<T extends BaseEntity & KingdeeUnlockMark> void fillUnlockMarks(String sourceBillType, List<T> rows, Map<Long, KingdeeBillLock> activeLockMap);
|
||||
|
||||
/**
|
||||
* 锁定补偿:扫 failed 且 try_count 未达重试上限(RETRY_MAX_COUNT)的锁记录,逐张重调 lockBusiData
|
||||
* (接口 1对1:单次一个源单号对一个金蝶单号,单张成败互不影响;定时任务定时触发,亦可人工触发);
|
||||
|
||||
+5
-2
@@ -63,9 +63,12 @@ public interface IManageWaybillService {
|
||||
String exception, String transportType, String startDate, String endDate);
|
||||
|
||||
/**
|
||||
* 调度端运单详情(不校验司机归属)
|
||||
* 调度端运次详情(不校验司机归属)
|
||||
* <p>
|
||||
* 传运次主键:配载单主键或未配载运单主键,都按字符串。已拒单的子运单按运次取不到(运次口径
|
||||
* 不含已拒子单),此时按运单主键回落成单票视图,供重新派单等从运单进入的场景使用。
|
||||
*/
|
||||
AdminWaybillDetailVO detail(Long id);
|
||||
AdminWaybillDetailVO detail(String tripId);
|
||||
|
||||
/**
|
||||
* 待处理运单(待接单 / 运输中;可筛需重新派单)
|
||||
|
||||
+6
@@ -28,6 +28,7 @@ import org.springblade.transport.pojo.dto.PaymentApplicationStatusRequest;
|
||||
import org.springblade.transport.pojo.entity.PaymentApplication;
|
||||
import org.springblade.transport.pojo.vo.PaymentApplicationReferenceAmountVO;
|
||||
import org.springblade.transport.pojo.vo.PaymentApplicationVO;
|
||||
import org.springblade.transport.pojo.vo.ProjectAdvanceWriteOffOptionVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -36,6 +37,11 @@ public interface IPaymentApplicationService extends BaseService<PaymentApplicati
|
||||
IPage<PaymentApplicationVO> selectPage(IPage<PaymentApplication> page, PaymentApplicationVO query);
|
||||
PaymentApplicationVO detail(Long id);
|
||||
PaymentApplicationReferenceAmountVO referenceAmount(String paymentType, Long referenceId, Long excludeId);
|
||||
|
||||
/** 按合同查询可选项目预付核销列表。 */
|
||||
List<ProjectAdvanceWriteOffOptionVO> projectAdvanceWriteOffOptions(Long contractId,
|
||||
String excludeSettlementType,
|
||||
Long excludeSettlementId);
|
||||
Long saveDraft(PaymentApplicationSaveRequest request);
|
||||
void removeDraft(Long id);
|
||||
void submit(PaymentApplicationStatusRequest request);
|
||||
|
||||
+5
@@ -33,6 +33,11 @@ public interface IPreSettlementService extends BaseService<PreSettlement> {
|
||||
|
||||
PreSettlementVO detail(Long id);
|
||||
|
||||
/**
|
||||
* 预览下一条预结算单号。仅供新增页面展示,真正落库的单号仍由保存时生成。
|
||||
*/
|
||||
String nextNo();
|
||||
|
||||
default List<Map<String, Object>> contractOptions(String keyword) {
|
||||
return contractOptions(keyword, null);
|
||||
}
|
||||
|
||||
+15
@@ -101,4 +101,19 @@ public interface IReceivablePayableDetailService extends BaseService<ReceivableP
|
||||
|
||||
/** 关闭总单调度后生成总单客户合同应收,并按所属运单记录的承运商合同生成应付。 */
|
||||
void generateForClosedMasterOrder(MasterOrder masterOrder);
|
||||
|
||||
/**
|
||||
* 结算导入:按导入单据构建/刷新「结算导入」来源的应收应付明细(运输费+费用项金额以导入数据为准)。
|
||||
* <p>
|
||||
* 历史运单若已有系统生成明细,不覆盖原明细,另建结算导入明细;运单已进入结算则拒绝重复导入。
|
||||
* feeRows 每项含 waybillId、transportFee、unitPrice、feeItems(Map)、remark。
|
||||
* </p>
|
||||
*
|
||||
* @param settlementType 结算类型 receivable/payable
|
||||
* @param contractId 结算合同ID
|
||||
* @param feeRows 费用行
|
||||
* @return 明细主键列表(新建或刷新后的结算导入明细)
|
||||
*/
|
||||
List<Long> createDetailsFromSettlementImport(String settlementType, Long contractId,
|
||||
List<Map<String, Object>> feeRows);
|
||||
}
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springblade.core.mp.base.BaseService;
|
||||
import org.springblade.transport.pojo.dto.SettlementImportBatchRequest;
|
||||
import org.springblade.transport.pojo.dto.SettlementImportVoidRequest;
|
||||
import org.springblade.transport.pojo.entity.SettlementImportBatch;
|
||||
import org.springblade.transport.pojo.vo.SettlementImportBatchVO;
|
||||
import org.springblade.transport.pojo.vo.WaybillImportValidateResultVO;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 导入结算单批次服务
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public interface ISettlementImportBatchService extends BaseService<SettlementImportBatch> {
|
||||
|
||||
Map<String, List<Map<String, Object>>> options();
|
||||
|
||||
String nextBatchNo();
|
||||
|
||||
IPage<SettlementImportBatchVO> page(IPage<SettlementImportBatch> page, SettlementImportBatchRequest request);
|
||||
|
||||
SettlementImportBatchVO detail(Long id);
|
||||
|
||||
void exportTemplate(HttpServletResponse response);
|
||||
|
||||
Map<String, Object> parse(MultipartFile file);
|
||||
|
||||
WaybillImportValidateResultVO validate(SettlementImportBatchRequest request);
|
||||
|
||||
void exportFailures(SettlementImportBatchRequest request, MultipartFile file, HttpServletResponse response);
|
||||
|
||||
SettlementImportBatchVO saveDraft(SettlementImportBatchRequest request);
|
||||
|
||||
SettlementImportBatchVO confirm(SettlementImportBatchRequest request);
|
||||
|
||||
void voidBatch(SettlementImportVoidRequest request);
|
||||
|
||||
/** 删除草稿批次(仅 draft 可删)。 */
|
||||
void removeDraft(Long id);
|
||||
|
||||
/**
|
||||
* 历史运单可选列表:相同项目、客户合同、承运商合同下,尚未生成对应应收/应付明细的运单。
|
||||
*/
|
||||
List<Map<String, Object>> historyWaybills(SettlementImportBatchRequest request);
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.service;
|
||||
|
||||
import org.springblade.transport.pojo.dto.SettlementProjectAdvanceWriteOffRequest;
|
||||
import org.springblade.transport.pojo.entity.SettlementProjectAdvanceWriteOff;
|
||||
import org.springblade.transport.pojo.vo.ProjectAdvanceWriteOffOptionVO;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 结算单项目预付核销服务
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public interface ISettlementProjectAdvanceWriteOffService {
|
||||
|
||||
/**
|
||||
* 按合同查询可选项目预付(审批通过、金蝶已付款且仍有可核销余额)。
|
||||
*/
|
||||
List<ProjectAdvanceWriteOffOptionVO> listOptions(Long contractId, String excludeSettlementType,
|
||||
Long excludeSettlementId);
|
||||
|
||||
/**
|
||||
* 查询结算单已保存的核销明细(含动态可核销金额)。
|
||||
*/
|
||||
List<SettlementProjectAdvanceWriteOff> listBySettlement(String settlementType, Long settlementId);
|
||||
|
||||
/**
|
||||
* 结算单核销金额合计。
|
||||
*/
|
||||
BigDecimal sumBySettlement(String settlementType, Long settlementId);
|
||||
|
||||
/**
|
||||
* 按结算单集合分组统计核销合计(一次 group-by,供批量刷新与列表页使用)。
|
||||
*/
|
||||
Map<Long, BigDecimal> sumBySettlementIds(String settlementType, Collection<Long> settlementIds);
|
||||
|
||||
/**
|
||||
* 覆盖保存核销明细,并校验不超过可核销金额与结算单金额。
|
||||
*/
|
||||
void replaceWriteOffs(String settlementType, Long settlementId, Long contractId,
|
||||
BigDecimal settlementAmount, List<SettlementProjectAdvanceWriteOffRequest> writeOffs);
|
||||
|
||||
/**
|
||||
* 物理清理结算单核销明细(草稿删除时)。
|
||||
*/
|
||||
void deleteBySettlement(String settlementType, Long settlementId);
|
||||
|
||||
/**
|
||||
* 删除指定结算单集合上的核销明细(核销行转移/结算单作废联动),返回删除行数。
|
||||
*/
|
||||
int deleteBySettlementIds(String settlementType, Collection<Long> settlementIds);
|
||||
|
||||
/**
|
||||
* 按付款申请删除其核销明细(付款申请作废联动),返回受影响的正式结算单id。
|
||||
*/
|
||||
List<Long> deleteByPaymentApplicationIds(Collection<Long> paymentApplicationIds);
|
||||
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.service;
|
||||
|
||||
/**
|
||||
* 按合同结算规则自动生成预结算单、正式结算单
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public interface ISettlementRuleAutoGenerateService {
|
||||
|
||||
/**
|
||||
* 扫描已开启自动生成的合同,为截至昨日已关闭的账期生成结算草稿。
|
||||
*
|
||||
* @return 本次生成结果摘要
|
||||
*/
|
||||
String generateDueSettlements();
|
||||
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import org.springblade.transport.pojo.vo.TripQueryVO;
|
||||
import org.springblade.transport.pojo.vo.TripTabCountsVO;
|
||||
import org.springblade.transport.pojo.vo.TripVO;
|
||||
|
||||
/**
|
||||
* 运次读服务
|
||||
* <p>
|
||||
* 「一卡一运次」的取数、状态聚合、接单需求合并与分页只在这里实现一次,司机端与管理端列表都调它,
|
||||
* 各自只做字段裁剪。聚合全程只读,不在读路径上写业务状态——分档只按过程配置与接单态推导,
|
||||
* 不按库里的业务状态,也不借读路径做惰性校正。
|
||||
*/
|
||||
public interface ITripQueryService {
|
||||
|
||||
/**
|
||||
* 运次分页:一页就是若干个运次(有配载单的一趟车算一个)。
|
||||
* <p>
|
||||
* 库层只取回收口与行级筛选命中的运单行,分组、分档、筛选、排序与分页都在服务层算完再做。
|
||||
*
|
||||
* @param query 查询条件,收口由 driverPhone 或 allDrivers 给出
|
||||
* @return 运次分页
|
||||
*/
|
||||
IPage<TripVO> page(TripQueryVO query);
|
||||
|
||||
/**
|
||||
* 运次 Tab 统计:全部 / 待接单 / 进行中 / 已完成,分档口径与 page 完全一致
|
||||
*
|
||||
* @param query 查询条件,口径同 page
|
||||
* @return 运次计数
|
||||
*/
|
||||
TripTabCountsVO tabCounts(TripQueryVO query);
|
||||
|
||||
/**
|
||||
* 按运次主键取单个运次(含子运单),主键为字符串
|
||||
*
|
||||
* @param query 查询条件,tripId 必填
|
||||
* @return 运次
|
||||
*/
|
||||
TripVO detail(TripQueryVO query);
|
||||
|
||||
}
|
||||
+8
@@ -113,6 +113,14 @@ public interface IWaybillService extends BaseService<Waybill> {
|
||||
*/
|
||||
boolean completeWithoutDeptCheck(Long id);
|
||||
|
||||
/**
|
||||
* 确认回单:回单节点配成「需业务员确认」时,内部人员确认后把运单置为已完成。
|
||||
* <p>
|
||||
* 比 {@link #complete(Long)} 多两道校验:该运单的司机打卡节点(在途除外)必须全部已打卡,
|
||||
* 且没有未了结异常——不挡住就等于借这个入口绕过打卡门禁与异常门禁。
|
||||
*/
|
||||
boolean confirmReturnReceipt(Long id);
|
||||
|
||||
BusinessRemoveResultVO batchComplete(String ids);
|
||||
LoadingManageVO roadLoading(String ids);
|
||||
|
||||
|
||||
+2
-2
@@ -46,11 +46,11 @@ public record KingdeeSyncContext(Map<Long, ContractManage> contractCache,
|
||||
Map<Long, List<FormalSettlementDetail>> settlementDetailCache,
|
||||
Map<Long, FormalSettlement> settlementCache,
|
||||
Map<Long, CustomerReceiptAccount> receiptAccountCache,
|
||||
Map<String, String> feeCategoryByName) {
|
||||
Map<String, String> feeCategoryByName, Map<Long, String> paymentNoCache) {
|
||||
|
||||
/** 空上下文:缓存可写但初始为空,首条记录命中后全批复用 */
|
||||
public static KingdeeSyncContext empty() {
|
||||
return new KingdeeSyncContext(new HashMap<>(), new HashMap<>(), new HashMap<>(),
|
||||
new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>());
|
||||
new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>());
|
||||
}
|
||||
}
|
||||
|
||||
+42
-8
@@ -86,14 +86,15 @@ public class BillLedgerServiceImpl extends BaseServiceImpl<BillLedgerMapper, Bil
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BillLedgerVO> availableOptions(String keyword, Long deptId, Long selectedId) {
|
||||
return availableList(keyword, deptId, selectedId);
|
||||
public List<BillLedgerVO> availableOptions(String keyword, Long deptId, Long selectedId, String payerName,
|
||||
String payeeName) {
|
||||
return availableList(keyword, deptId, selectedId, payerName, payeeName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public IPage<BillLedgerVO> availablePage(IPage<BillLedger> page, String keyword, Long deptId,
|
||||
Long selectedId) {
|
||||
List<BillLedgerVO> available = availableList(keyword, deptId, selectedId);
|
||||
Long selectedId, String payerName, String payeeName) {
|
||||
List<BillLedgerVO> available = availableList(keyword, deptId, selectedId, payerName, payeeName);
|
||||
long current = Math.max(page.getCurrent(), 1);
|
||||
long size = Math.max(page.getSize(), 1);
|
||||
long from = Math.min((current - 1) * size, available.size());
|
||||
@@ -103,22 +104,50 @@ public class BillLedgerServiceImpl extends BaseServiceImpl<BillLedgerMapper, Bil
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<BillLedgerVO> availableList(String keyword, Long deptId, Long selectedId) {
|
||||
private List<BillLedgerVO> availableList(String keyword, Long deptId, Long selectedId, String payerName,
|
||||
String payeeName) {
|
||||
LocalDate today = LocalDate.now();
|
||||
// 未到期且有余额;收票且部门可用,或出票/收票单位与结算收付款方一致(开票、收票都可)
|
||||
return list(Wrappers.<BillLedger>lambdaQuery()
|
||||
.and(Func.isNotEmpty(keyword), wrapper -> wrapper.like(BillLedger::getBillNo, keyword)
|
||||
.or().like(BillLedger::getIssuerName, keyword)
|
||||
.or().like(BillLedger::getReceiverName, keyword))
|
||||
.and(wrapper -> wrapper
|
||||
.gt(BillLedger::getAvailableBalance, BigDecimal.ZERO)
|
||||
.and(available -> available
|
||||
.ge(BillLedger::getMaturityDate, today)
|
||||
.gt(BillLedger::getAvailableBalance, BigDecimal.ZERO))
|
||||
.or(selectedId != null, child -> child.eq(BillLedger::getId, selectedId)))
|
||||
.orderByAsc(BillLedger::getMaturityDate)
|
||||
.orderByDesc(BillLedger::getCreateTime)).stream()
|
||||
.filter(item -> selectedId != null && Objects.equals(item.getId(), selectedId)
|
||||
|| departmentAvailable(item, deptId))
|
||||
|| selectableBill(item, deptId, payerName, payeeName))
|
||||
.map(item -> BillLedgerWrapper.build().entityVO(item))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 收票且当前部门在可用范围内,或出票单位、收票单位与结算付款方、收款方一致(顺序不限)。
|
||||
*/
|
||||
private boolean selectableBill(BillLedger ledger, Long deptId, String payerName, String payeeName) {
|
||||
boolean receivedAndDept = "received".equals(ledger.getBillType()) && departmentAvailable(ledger, deptId);
|
||||
return receivedAndDept || partiesMatch(ledger.getIssuerName(), ledger.getReceiverName(), payerName, payeeName);
|
||||
}
|
||||
|
||||
private boolean partiesMatch(String issuerName, String receiverName, String payerName, String payeeName) {
|
||||
String issuer = trimName(issuerName);
|
||||
String receiver = trimName(receiverName);
|
||||
String payer = trimName(payerName);
|
||||
String payee = trimName(payeeName);
|
||||
if (issuer.isEmpty() || receiver.isEmpty() || payer.isEmpty() || payee.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return (issuer.equals(payer) && receiver.equals(payee)) || (issuer.equals(payee) && receiver.equals(payer));
|
||||
}
|
||||
|
||||
private String trimName(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long submit(BillLedgerSaveRequest request) {
|
||||
@@ -136,6 +165,11 @@ public class BillLedgerServiceImpl extends BaseServiceImpl<BillLedgerMapper, Bil
|
||||
}
|
||||
CustomerArchive issuer = customer(request.getIssuerId(), "出票单位");
|
||||
CustomerArchive feeBearer = customer(request.getFeeBearerId(), "费用承担方");
|
||||
String receiverName = required(request.getReceiverName(), "收票单位", 100);
|
||||
if (!Objects.equals(feeBearer.getId(), issuer.getId())
|
||||
&& !customerName(feeBearer).equals(receiverName)) {
|
||||
throw new ServiceException("费用承担方只能选择收票单位或出票单位");
|
||||
}
|
||||
BigDecimal faceAmount = positive(request.getFaceAmount(), "票面金额").setScale(2, RoundingMode.HALF_UP);
|
||||
BigDecimal usedAmount = entity.getId() == null ? BigDecimal.ZERO : activeUsedAmount(entity.getId());
|
||||
if (faceAmount.compareTo(usedAmount) < 0) {
|
||||
@@ -145,7 +179,7 @@ public class BillLedgerServiceImpl extends BaseServiceImpl<BillLedgerMapper, Bil
|
||||
entity.setBillNo(billNo);
|
||||
entity.setIssuerId(issuer.getId());
|
||||
entity.setIssuerName(customerName(issuer));
|
||||
entity.setReceiverName(required(request.getReceiverName(), "收票单位", 100));
|
||||
entity.setReceiverName(receiverName);
|
||||
entity.setBillType(request.getBillType());
|
||||
entity.setFaceAmount(faceAmount);
|
||||
entity.setAvailableBalance(faceAmount.subtract(usedAmount).setScale(2, RoundingMode.HALF_UP));
|
||||
|
||||
+3
-2
@@ -309,10 +309,11 @@ public class BillPaymentServiceImpl extends BaseServiceImpl<BillPaymentMapper, B
|
||||
return request.getId();
|
||||
}
|
||||
|
||||
/** 生成当日汇票付款单号;按最大流水号 +1,且计入逻辑删除数据 */
|
||||
private synchronized String nextNo() {
|
||||
String prefix = "HP" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
|
||||
return prefix + String.format("%05d", count(Wrappers.<BillPayment>lambdaQuery()
|
||||
.likeRight(BillPayment::getPaymentNo, prefix)) + 1);
|
||||
Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix);
|
||||
return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1);
|
||||
}
|
||||
|
||||
private BigDecimal positive(BigDecimal value, String name) {
|
||||
|
||||
+4
@@ -354,6 +354,10 @@ public class CertAuditServiceImpl implements ICertAuditService {
|
||||
requireIdCardNotTaken(snapshot.getIdCardNo(), driver.getId());
|
||||
driver.setDriverName(snapshot.getRealName());
|
||||
driver.setIdCardNo(snapshot.getIdCardNo());
|
||||
driver.setBirthday(snapshot.getBirthday());
|
||||
// 空串不是「没填」:框架的 not_null 策略只跳过 null,写空串会把管理端填过的住址清掉
|
||||
driver.setAddressRegion(blankToNull(snapshot.getAddressRegion()));
|
||||
driver.setAddress(blankToNull(snapshot.getAddress()));
|
||||
driver.setIdCardFront(snapshot.getIdCardFrontUrl());
|
||||
driver.setIdCardBack(snapshot.getIdCardBackUrl());
|
||||
driver.setIdCardValidStart(snapshot.getIdCardValidStart());
|
||||
|
||||
+23
-2
@@ -30,6 +30,7 @@ import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.mp.base.BaseServiceImpl;
|
||||
import org.springblade.core.tool.utils.BeanUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.cache.SysCache;
|
||||
import org.springblade.system.cache.UserCache;
|
||||
import org.springblade.system.pojo.entity.Dept;
|
||||
import org.springblade.transport.excel.CommonRouteExportExcel;
|
||||
@@ -217,12 +218,32 @@ public class CommonRouteServiceImpl extends BaseServiceImpl<CommonRouteMapper, C
|
||||
commonRoute.setArrivalPhone(TransportBusinessSupport.trimToNull(commonRoute.getArrivalPhone()));
|
||||
commonRoute.setDeptName(TransportBusinessSupport.trimToNull(commonRoute.getDeptName()));
|
||||
commonRoute.setRemark(TransportBusinessSupport.trimToNull(commonRoute.getRemark()));
|
||||
if (Func.isEmpty(commonRoute.getDeptId())) {
|
||||
// 仅新增时绑定组织;编辑沿用原组织(submit 中已从旧记录写回)。
|
||||
if (Func.isEmpty(commonRoute.getId())) {
|
||||
bindDeptOnCreate(commonRoute);
|
||||
}
|
||||
if (commonRoute.getStatus() == null) { commonRoute.setStatus(1); }
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增绑定所属组织:普通用户始终落当前组织;超管可按入参组织保存,未传时回落当前组织。
|
||||
*/
|
||||
private void bindDeptOnCreate(CommonRoute commonRoute) {
|
||||
if (commonRoute == null) {
|
||||
return;
|
||||
}
|
||||
boolean administrator = TransportBusinessSupport.isAdministrator();
|
||||
if (!administrator || Func.isEmpty(commonRoute.getDeptId()) || commonRoute.getDeptId() <= 0) {
|
||||
Dept dept = TransportBusinessSupport.currentDept("常用线路");
|
||||
commonRoute.setDeptId(dept.getId());
|
||||
commonRoute.setDeptName(dept.getDeptName());
|
||||
return;
|
||||
}
|
||||
if (commonRoute.getStatus() == null) { commonRoute.setStatus(1); }
|
||||
Dept dept = SysCache.getDept(commonRoute.getDeptId());
|
||||
if (Func.isEmpty(dept)) {
|
||||
throw new ServiceException("所属组织不存在或已删除");
|
||||
}
|
||||
commonRoute.setDeptName(TransportBusinessSupport.trimToNull(dept.getDeptName()));
|
||||
}
|
||||
|
||||
private void validate(CommonRoute commonRoute) {
|
||||
|
||||
+43
-6
@@ -326,6 +326,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
validateBillingPlans(request.getBillingPlanJson());
|
||||
validatePaymentRatios(request.getPaymentRatioJson());
|
||||
Map<String, Object> beforeData = buildContractChangeSnapshot(source);
|
||||
applyContractNo(source, request.getContractNo(), false);
|
||||
source.setContractName(request.getContractName()); source.setPartyB(request.getPartyB());
|
||||
source.setStartDate(request.getStartDate()); source.setEndDate(request.getEndDate()); source.setContractFormat(request.getContractFormat());
|
||||
source.setSettlementMode(request.getSettlementMode()); source.setLegalSealFlag(request.getLegalSealFlag()); source.setCopyCount(request.getCopyCount());
|
||||
@@ -467,9 +468,11 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
} else if (Func.isNotEmpty(contractManage.getOrganizationId())) {
|
||||
queryWrapper.eq(ContractManage::getOrganizationId, contractManage.getOrganizationId());
|
||||
}
|
||||
queryWrapper.like(Func.isNotEmpty(contractManage.getContractNo()), ContractManage::getContractNo, contractManage.getContractNo())
|
||||
queryWrapper.eq(Func.isNotEmpty(contractManage.getProjectId()), ContractManage::getProjectId, contractManage.getProjectId())
|
||||
.like(Func.isNotEmpty(contractManage.getContractNo()), ContractManage::getContractNo, contractManage.getContractNo())
|
||||
.like(Func.isNotEmpty(contractManage.getContractName()), ContractManage::getContractName, contractManage.getContractName())
|
||||
.like(Func.isNotEmpty(contractManage.getProjectName()), ContractManage::getProjectName, contractManage.getProjectName())
|
||||
.like(Func.isNotEmpty(contractManage.getProjectName()) && Func.isEmpty(contractManage.getProjectId()),
|
||||
ContractManage::getProjectName, contractManage.getProjectName())
|
||||
.like(Func.isNotEmpty(contractManage.getOrganizationName()), ContractManage::getOrganizationName, contractManage.getOrganizationName())
|
||||
.eq(Func.isNotEmpty(contractManage.getContractCategory()), ContractManage::getContractCategory, contractManage.getContractCategory())
|
||||
.eq(Func.isNotEmpty(contractManage.getSignType()), ContractManage::getSignType, contractManage.getSignType())
|
||||
@@ -555,8 +558,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
contractManage.setOrganizationId(TransportBusinessSupport.currentDeptId("合同管理"));
|
||||
contractManage.setOrganizationName(TransportBusinessSupport.currentDept("合同管理").getDeptName());
|
||||
}
|
||||
// 新增始终按部门规则生成合同编号,避免前端预览号并发冲突
|
||||
contractManage.setContractNo(nextContractNo(contractManage.getOrganizationId()));
|
||||
applyContractNo(contractManage, contractManage.getContractNo(), true);
|
||||
if (Func.isEmpty(contractManage.getHandlerUserId())) {
|
||||
contractManage.setHandlerUserId(AuthUtil.getUserId());
|
||||
contractManage.setHandlerUserName(AuthUtil.getUserName());
|
||||
@@ -564,7 +566,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
contractManage.setArchiveStatus(ARCHIVE_STATUS_PENDING);
|
||||
} else {
|
||||
ContractManage oldRecord = loadExists(contractManage.getId());
|
||||
contractManage.setContractNo(oldRecord.getContractNo());
|
||||
applyContractNo(contractManage, contractManage.getContractNo(), false);
|
||||
if (Func.isEmpty(contractManage.getOrganizationId())) {
|
||||
contractManage.setOrganizationId(oldRecord.getOrganizationId());
|
||||
contractManage.setOrganizationName(oldRecord.getOrganizationName());
|
||||
@@ -583,6 +585,28 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存合同编号。新增未填写时按部门规则生成,已填写或编辑时保留手工编号并校验唯一。
|
||||
*/
|
||||
private void applyContractNo(ContractManage contractManage, String contractNo, boolean generateWhenEmpty) {
|
||||
String normalizedContractNo = TransportBusinessSupport.trimToNull(contractNo);
|
||||
if (Func.isEmpty(normalizedContractNo)) {
|
||||
if (!generateWhenEmpty) {
|
||||
throw new ServiceException("请输入合同编号");
|
||||
}
|
||||
normalizedContractNo = nextContractNo(contractManage.getOrganizationId());
|
||||
}
|
||||
TransportBusinessSupport.validateLength(normalizedContractNo, 100, "合同编号不能超过100字");
|
||||
Long duplicatedCount = count(Wrappers.<ContractManage>lambdaQuery()
|
||||
.eq(ContractManage::getIsDeleted, 0)
|
||||
.eq(ContractManage::getContractNo, normalizedContractNo)
|
||||
.ne(Func.isNotEmpty(contractManage.getId()), ContractManage::getId, contractManage.getId()));
|
||||
if (duplicatedCount != null && duplicatedCount > 0) {
|
||||
throw new ServiceException("合同编号已存在");
|
||||
}
|
||||
contractManage.setContractNo(normalizedContractNo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized String nextContractNo(Long organizationId) {
|
||||
if (Func.isEmpty(organizationId)) {
|
||||
@@ -606,7 +630,8 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
.orderByDesc(ContractManage::getContractNo)
|
||||
.last("LIMIT 50"));
|
||||
Pattern pattern = Pattern.compile("^" + Pattern.quote(prefix) + "(" + year + ")(\\d{3})号$");
|
||||
int next = 1;
|
||||
// 当年尚无匹配编号时,从组织配置的初始流水号起编;否则在已有最大流水号上加1
|
||||
int next = resolveContractInitialSerial(dept);
|
||||
for (ContractManage item : latestList) {
|
||||
String contractNo = TransportBusinessSupport.trimToNull(item.getContractNo());
|
||||
if (Func.isEmpty(contractNo)) {
|
||||
@@ -625,6 +650,17 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
return head + String.format("%03d", next) + "号";
|
||||
}
|
||||
|
||||
private int resolveContractInitialSerial(Dept dept) {
|
||||
Integer serial = dept.getContractInitialSerial();
|
||||
if (serial == null || serial < 1) {
|
||||
return 1;
|
||||
}
|
||||
if (serial > 999) {
|
||||
throw new ServiceException("组织机构中配置的合同初始流水号不能超过999");
|
||||
}
|
||||
return serial;
|
||||
}
|
||||
|
||||
private void refreshArchiveStatusOnApprove(ContractManage contractManage) {
|
||||
if (hasSealArchiveFile(contractManage.getContractFileJson())) {
|
||||
contractManage.setArchiveStatus(ARCHIVE_STATUS_ARCHIVED);
|
||||
@@ -1063,6 +1099,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
|
||||
private Map<String, Object> buildContractChangeSnapshot(ContractManage contractManage) {
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("contractNo", contractManage.getContractNo());
|
||||
data.put("contractName", contractManage.getContractName());
|
||||
data.put("partyB", contractManage.getPartyB());
|
||||
data.put("startDate", contractManage.getStartDate());
|
||||
|
||||
+36
-7
@@ -168,7 +168,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
|
||||
*/
|
||||
private static final String APPROVAL_REJECTED = "rejected";
|
||||
/** 客商所属组织固定为顶层组织 */
|
||||
private static final String FIXED_OWNER_DEPT_NAME = "桂物物流集团";
|
||||
private static final String FIXED_OWNER_DEPT_NAME = "广西现代物流集团有限公司";
|
||||
|
||||
/**
|
||||
* 金蝶协同状态:未推送(新增客商默认值,审批通过后参与推送)
|
||||
@@ -277,7 +277,8 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
|
||||
boolean created = Func.isEmpty(customer.getId());
|
||||
CustomerArchive before = created ? null : ensureCustomerAccessible(customer.getId());
|
||||
if (created) {
|
||||
customer.setCustomerCode(nextCustomerCode());
|
||||
// 新增时金蝶已有客商,前端回填金蝶客商编码,建档直接沿用;未回填则自动生成
|
||||
customer.setCustomerCode(Func.isEmpty(customer.getCustomerCode()) ? this.nextCustomerCode() : customer.getCustomerCode());
|
||||
}
|
||||
CustomerArchive entity = Objects.requireNonNull(BeanUtil.copyProperties(customer, CustomerArchive.class));
|
||||
boolean result = saveOrUpdate(entity);
|
||||
@@ -515,10 +516,10 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
|
||||
after.setKingdeeSyncTime(LocalDateTime.now());
|
||||
BfiCustomerVO kingdeeCustomer = queryKingdeeCustomer(customer.getUnifiedCreditCode());
|
||||
if (kingdeeCustomer != null && BfiConstant.BILL_STATUS_AUDITED.equals(kingdeeCustomer.getStatus())) {
|
||||
// 金蝶已有已审核档案:跳过推送
|
||||
// 金蝶已有已审核档案:跳过推送,客商编号采纳金蝶客商编码
|
||||
after.setKingdeeStatus(KINGDEE_STATUS_EXIST);
|
||||
after.setKingdeeFailReason(null);
|
||||
addChangeRecord(id, "金蝶已存在该客商档案,跳过推送", customer, after);
|
||||
addChangeRecord(id, "金蝶已存在该客商档案,跳过推送" + this.adoptKingdeeCode(customer, after, kingdeeCustomer.getNumber()), customer, after);
|
||||
updateById(after);
|
||||
return after;
|
||||
}
|
||||
@@ -617,6 +618,31 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
|
||||
return rows == null || rows.isEmpty() ? null : rows.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 金蝶已存在档案时,客商编号(customer_code)采纳金蝶客商编码,后续业务接口沿用客商编号即拿到金蝶编码;
|
||||
* 金蝶编码为空或与当前编号一致时无需处理;同租户下已被其他客商占用时跳过回填,避免违反 uk_customer_archive_code 唯一约束
|
||||
*
|
||||
* @param customer 变更前档案
|
||||
* @param after 变更后档案(编码采纳结果回填其上)
|
||||
* @param kingdeeNumber A902-1 查询返回的金蝶客商编码
|
||||
* @return 变更说明后缀,无需处理返回空串
|
||||
*/
|
||||
private String adoptKingdeeCode(CustomerArchive customer, CustomerArchive after, String kingdeeNumber) {
|
||||
if (Func.isEmpty(kingdeeNumber) || kingdeeNumber.equals(customer.getCustomerCode())) {
|
||||
return "";
|
||||
}
|
||||
long occupied = count(Wrappers.<CustomerArchive>lambdaQuery()
|
||||
.eq(CustomerArchive::getCustomerCode, kingdeeNumber)
|
||||
.ne(CustomerArchive::getId, customer.getId()));
|
||||
if (occupied > 0) {
|
||||
log.warn("金蝶客商编码 {} 已被其他客商占用,跳过采纳客商编号,id={},customerCode={}",
|
||||
kingdeeNumber, customer.getId(), customer.getCustomerCode());
|
||||
return ",金蝶客商编码 " + kingdeeNumber + " 已被占用未同步";
|
||||
}
|
||||
after.setCustomerCode(kingdeeNumber);
|
||||
return ",客商编号已同步为金蝶客商编码:" + kingdeeNumber;
|
||||
}
|
||||
|
||||
private BfiBillSaveResultItemVO firstSaveItem(BfiBillSaveResultVO result) {
|
||||
if (result == null || result.getResult() == null || result.getResult().isEmpty()) {
|
||||
return null;
|
||||
@@ -739,11 +765,11 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
|
||||
CustomerArchive after = copyCustomer(customer);
|
||||
after.setKingdeeSyncTime(LocalDateTime.now());
|
||||
if (BfiConstant.BILL_STATUS_AUDITED.equals(kingdeeCustomer.getStatus())) {
|
||||
// 金蝶已存在已审核档案:跳过推送,直接建档
|
||||
// 金蝶已存在已审核档案:跳过推送直接建档,客商编号采纳金蝶客商编码
|
||||
after.setKingdeeStatus(KINGDEE_STATUS_EXIST);
|
||||
after.setKingdeeFailReason(null);
|
||||
existed++;
|
||||
collectChangeRecord(changeRecords, customer.getId(), "金蝶已存在该客商档案,跳过推送", customer, after);
|
||||
collectChangeRecord(changeRecords, customer.getId(), "金蝶已存在该客商档案,跳过推送" + this.adoptKingdeeCode(customer, after, kingdeeCustomer.getNumber()), customer, after);
|
||||
} else {
|
||||
// 金蝶侧暂存/已提交:审核中,置失败待重推
|
||||
after.setKingdeeStatus(KINGDEE_STATUS_FAILED);
|
||||
@@ -821,6 +847,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
|
||||
CustomerArchive after = copyCustomer(customer);
|
||||
after.setKingdeeStatus(KINGDEE_STATUS_EXIST);
|
||||
after.setKingdeeFailReason(null);
|
||||
this.adoptKingdeeCode(customer, after, kingdeeCustomer.getNumber());
|
||||
updateList.add(after);
|
||||
collectChangeRecord(changeRecords, customer.getId(), "金蝶客商已审核,推送最终成功", customer, after);
|
||||
log.info("金蝶客商已审核,推送最终成功并升级为金蝶已有档案,id={},unifiedCreditCode={}",
|
||||
@@ -953,6 +980,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
|
||||
}
|
||||
|
||||
private void prepare(CustomerArchiveVO customer) {
|
||||
customer.setCustomerCode(trimToNull(customer.getCustomerCode()));
|
||||
customer.setShortName(trimToNull(customer.getShortName()));
|
||||
customer.setFullName(trimToEmpty(customer.getFullName()));
|
||||
customer.setCustomerKind(normalizeCustomerKind(customer.getCustomerKind()));
|
||||
@@ -1022,6 +1050,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
|
||||
throw new ServiceException("固定营业期限必须填写营业期限截止日");
|
||||
}
|
||||
validateLength(customer.getShortName(), 20, "客商简称最多20个汉字");
|
||||
validateLength(customer.getCustomerCode(), 20, "客商编号最多20个字符");
|
||||
validateLength(customer.getFullName(), 100, "客商全称最多100个字符");
|
||||
validateLength(customer.getLegalPerson(), 10, "法人/负责人最多10个汉字");
|
||||
validateLength(customer.getContactPhone(), 11, "联系电话必须为11位手机号");
|
||||
@@ -2210,7 +2239,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
|
||||
}
|
||||
|
||||
/**
|
||||
* 客商所属组织固定为顶层「桂物物流集团」,忽略前端传入的其他组织。
|
||||
* 客商所属组织固定为顶层「广西现代物流集团有限公司」,忽略前端传入的其他组织。
|
||||
*/
|
||||
private void applyFixedOwnerDept(CustomerArchiveVO customer) {
|
||||
customer.setDeptName(FIXED_OWNER_DEPT_NAME);
|
||||
|
||||
+19
@@ -113,6 +113,8 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> imp
|
||||
Driver driver = new Driver();
|
||||
driver.setId(id);
|
||||
driver.setStatus(status);
|
||||
// 只改 status 也必须带上 driving_vehicle 原值,理由见 carryDrivingVehicle
|
||||
carryDrivingVehicle(id, driver);
|
||||
return updateById(driver);
|
||||
}
|
||||
|
||||
@@ -446,9 +448,26 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> imp
|
||||
Driver patch = new Driver();
|
||||
patch.setId(driver.getId());
|
||||
patch.setUserId(userId);
|
||||
// 同上:这次只改 user_id,不带 driving_vehicle 原值同样会把车辆绑定清掉
|
||||
carryDrivingVehicle(driver.getId(), patch);
|
||||
updateById(patch);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把库里的 driving_vehicle 原值带进 patch。
|
||||
* <p>
|
||||
* 该列标了 {@code FieldStrategy.ALWAYS},更新时忽略 null 直接写:patch 里不带这一列,
|
||||
* 一次只改别的列的更新就会把司机的车辆绑定清成 NULL,而司机端车辆列表正是按这一列
|
||||
* 的车牌匹配出来的(清空即「暂无绑定车辆」)。读不到该司机时保持原样,
|
||||
* 让更新自己去失败。
|
||||
*/
|
||||
private void carryDrivingVehicle(Long driverId, Driver patch) {
|
||||
Driver current = getById(driverId);
|
||||
if (current != null) {
|
||||
patch.setDrivingVehicle(current.getDrivingVehicle());
|
||||
}
|
||||
}
|
||||
|
||||
private Long defaultZero(Long value) {
|
||||
return value == null ? 0L : value;
|
||||
}
|
||||
|
||||
+630
-145
File diff suppressed because it is too large
Load Diff
+11
@@ -144,6 +144,17 @@ public class ExceptionDisposalServiceImpl
|
||||
// 整单异常同样受这条约束:没有节点不等于没有上报人。
|
||||
driverWaybillService.assertCurrentDriverOwnsWaybill(disposal.getWaybillId());
|
||||
|
||||
// 上报门禁之零:运单得先在路上,且不能已经结束。
|
||||
// 判据与司机打卡同一来源;归属校验会顺手把「无需接单」的待执行运单校正为进行中,
|
||||
// 所以状态要在它之后重新读,不能用 fillFromWaybill 里校正前的那份对象。
|
||||
Waybill reportWaybill = waybillService.getById(disposal.getWaybillId());
|
||||
String blockedReason = reportWaybill == null
|
||||
? null
|
||||
: WaybillProcessSupport.reportBlockedReason(reportWaybill.getBusinessStatus());
|
||||
if (blockedReason != null) {
|
||||
throw new ServiceException(blockedReason);
|
||||
}
|
||||
|
||||
// 节点可以为空:全局入口(首页快捷格、打卡页底部按钮)上报的异常不指定环节,
|
||||
// 它无从定位阻断区间,由门禁按「阻断该运单全部未打卡节点」处理。
|
||||
// 节点非空时才要求它确属该运单;节点名取自过程配置而非请求体,避免落进一个该节点并不叫的名字。
|
||||
|
||||
+620
-47
File diff suppressed because it is too large
Load Diff
+62
-35
@@ -38,8 +38,6 @@ import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.jackson.JsonUtil;
|
||||
import org.springblade.core.tool.utils.BeanUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.cache.SysCache;
|
||||
import org.springblade.system.pojo.entity.Dept;
|
||||
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyOpenApiQueryDTO;
|
||||
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyQueryDTO;
|
||||
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplySaveDTO;
|
||||
@@ -98,6 +96,7 @@ import org.springblade.transport.pojo.vo.InvoiceApplicationSheetVO;
|
||||
import org.springblade.transport.pojo.vo.InvoiceApplicationVO;
|
||||
import org.springblade.transport.service.IInvoiceApplicationService;
|
||||
import org.springblade.transport.service.IKingdeeBillLockService;
|
||||
import org.springblade.transport.service.KingdeeSyncContext;
|
||||
import org.springblade.transport.support.KingdeeApBillSyncSupport;
|
||||
import org.springblade.transport.support.KingdeeArBillSyncSupport;
|
||||
import org.springblade.transport.support.KingdeeSyncLockSupport;
|
||||
@@ -203,6 +202,8 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
vo.setKingdeeLockFailReason(lockRecord.getFailReason());
|
||||
vo.setKingdeeTryCount(lockRecord.getTryCount());
|
||||
}
|
||||
// 金蝶删单解锁会作废源单,按 unlocked 锁记录留痕补「金蝶删单」标识,区分正常作废与金蝶删单
|
||||
kingdeeBillLockService.fillUnlockMarks(KingdeeLockConstant.SOURCE_INVOICE_APPLY, rows, lockMap);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -231,9 +232,23 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
InvoiceApplication entity = existing(id);
|
||||
InvoiceApplicationVO vo = toListVO(entity);
|
||||
fillKingdeeLockStatus(List.of(vo));
|
||||
vo.setSettlements(settlementRelationMapper.selectList(Wrappers.<InvoiceApplicationSettlement>lambdaQuery()
|
||||
.eq(InvoiceApplicationSettlement::getInvoiceApplicationId, id)
|
||||
.orderByAsc(InvoiceApplicationSettlement::getCreateTime)));
|
||||
// 剩余可开票金额按当前占用实时重算(排除本申请),避免编辑时展示保存时点的过期额度
|
||||
List<InvoiceApplicationSettlement> relations = settlementRelationMapper.selectList(
|
||||
Wrappers.<InvoiceApplicationSettlement>lambdaQuery()
|
||||
.eq(InvoiceApplicationSettlement::getInvoiceApplicationId, id)
|
||||
.orderByAsc(InvoiceApplicationSettlement::getCreateTime));
|
||||
if (!relations.isEmpty()) {
|
||||
Map<Long, FormalSettlement> settlementMap = formalSettlementMapper.selectBatchIds(
|
||||
relations.stream().map(InvoiceApplicationSettlement::getFormalSettlementId).distinct().toList())
|
||||
.stream().collect(Collectors.toMap(FormalSettlement::getId, Function.identity(), (first, duplicate) -> first));
|
||||
relations.forEach(relation -> {
|
||||
FormalSettlement settlement = settlementMap.get(relation.getFormalSettlementId());
|
||||
if (settlement != null) {
|
||||
relation.setAvailableInvoiceAmount(availableAmount(settlement, id));
|
||||
}
|
||||
});
|
||||
}
|
||||
vo.setSettlements(relations);
|
||||
List<InvoiceApplicationSheet> sheets = sheetMapper.selectList(Wrappers.<InvoiceApplicationSheet>lambdaQuery()
|
||||
.eq(InvoiceApplicationSheet::getInvoiceApplicationId, id).orderByAsc(InvoiceApplicationSheet::getSheetNo));
|
||||
vo.setSheets(sheets.stream().map(sheet -> {
|
||||
@@ -255,9 +270,8 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
|
||||
@Override
|
||||
public IPage<Map<String, Object>> settlementCandidates(IPage<?> page, String keyword,
|
||||
String contractCategory, String settlementType, String invoiceStatus) {
|
||||
String contractCategory, String settlementType) {
|
||||
String candidateSettlementType = Func.isEmpty(settlementType) ? "receivable" : settlementType;
|
||||
String candidateInvoiceStatus = Func.isEmpty(invoiceStatus) ? "unreceived" : invoiceStatus;
|
||||
List<Long> contractIds = Func.isEmpty(contractCategory) ? List.of()
|
||||
: contractManageMapper.selectList(Wrappers.<ContractManage>lambdaQuery()
|
||||
.select(ContractManage::getId)
|
||||
@@ -269,7 +283,6 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
}
|
||||
List<FormalSettlement> settlements = formalSettlementMapper.selectList(Wrappers.<FormalSettlement>lambdaQuery()
|
||||
.eq(FormalSettlement::getSettlementType, candidateSettlementType)
|
||||
.eq(FormalSettlement::getInvoiceStatus, candidateInvoiceStatus)
|
||||
.eq(FormalSettlement::getApprovalStatus, TransportBusiConstant.APPROVED)
|
||||
.eq(FormalSettlement::getStatus, 1)
|
||||
.in(Func.isNotEmpty(contractCategory), FormalSettlement::getContractId, contractIds)
|
||||
@@ -337,6 +350,7 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
row.put("receiverName", settlement.getPayerName());
|
||||
row.put("settlementAmount", money(settlement.getSettlementAmount()));
|
||||
row.put("availableInvoiceAmount", available);
|
||||
row.put("invoicedAmount", money(settlement.getSettlementAmount()).subtract(available));
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -402,28 +416,31 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
.collect(Collectors.toMap(InvoiceApplicationSaveRequest.SettlementRow::getSettlementId,
|
||||
Function.identity(), (first, duplicate) -> first, LinkedHashMap::new)).values().stream().toList();
|
||||
List<FormalSettlement> settlements = requestedRows.stream().map(row -> availableSettlement(row.getSettlementId())).toList();
|
||||
if (settlements.stream().anyMatch(settlement -> !"receivable".equals(settlement.getSettlementType())
|
||||
|| (creating && !"unreceived".equals(settlement.getInvoiceStatus())))) {
|
||||
throw new ServiceException("只能选择审批通过、未作废、未收票的应收正式结算单");
|
||||
if (settlements.stream().anyMatch(settlement -> !"receivable".equals(settlement.getSettlementType()))) {
|
||||
throw new ServiceException("只能选择审批通过、未作废的应收正式结算单");
|
||||
}
|
||||
assertCompatible(settlements);
|
||||
FormalSettlement first = settlements.get(0);
|
||||
Map<Long, FormalSettlement> settlementMap = settlements.stream()
|
||||
.collect(Collectors.toMap(FormalSettlement::getId, Function.identity()));
|
||||
BigDecimal totalAvailable = BigDecimal.ZERO;
|
||||
for (InvoiceApplicationSaveRequest.SettlementRow row : requestedRows) {
|
||||
FormalSettlement settlement = settlementMap.get(row.getSettlementId());
|
||||
BigDecimal available = availableAmount(settlement, entity.getId());
|
||||
BigDecimal allocated = nonNegative(row.getAllocatedInvoiceAmount(), "分摊发票金额");
|
||||
if (allocated.compareTo(available) > 0) {
|
||||
throw new ServiceException("结算单" + settlement.getFormalSettlementNo() + "的分摊金额超过剩余可开票金额");
|
||||
}
|
||||
totalAvailable = totalAvailable.add(available);
|
||||
}
|
||||
BigDecimal lineTotal = validateSheets(request.getSheets());
|
||||
if (lineTotal.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new ServiceException("本次开票金额必须大于0");
|
||||
}
|
||||
// 本次开票金额按选择顺序自动分摊到各结算单并登记累计占用,摊不完即超过可开票金额
|
||||
BigDecimal totalAvailable = BigDecimal.ZERO;
|
||||
BigDecimal remaining = lineTotal;
|
||||
for (InvoiceApplicationSaveRequest.SettlementRow row : requestedRows) {
|
||||
FormalSettlement settlement = settlementMap.get(row.getSettlementId());
|
||||
BigDecimal available = availableAmount(settlement, entity.getId());
|
||||
totalAvailable = totalAvailable.add(available);
|
||||
BigDecimal allocated = available.min(remaining);
|
||||
row.setAllocatedInvoiceAmount(allocated);
|
||||
remaining = remaining.subtract(allocated);
|
||||
}
|
||||
if (remaining.compareTo(BigDecimal.ZERO) > 0) {
|
||||
throw new ServiceException("本次开票金额不能超过可开票金额");
|
||||
}
|
||||
CustomerArchive customer = findCustomer(first.getPayerName());
|
||||
if (customer == null) throw new ServiceException("未找到正式结算单受票方对应的客商档案");
|
||||
List<CustomerInvoiceInfoVO> invoiceInfos = activeInvoiceInfoVOs(customer.getId());
|
||||
@@ -699,7 +716,7 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public String doSyncKingdee(Long id) {
|
||||
InvoiceApplication entity = existing(id);
|
||||
InvoiceApplication entity = lockedExisting(id);
|
||||
if (!TransportBusiConstant.APPROVED.equals(entity.getApprovalStatus())) throw new ServiceException("仅审批通过的开票申请允许同步金蝶");
|
||||
// 已同步/已开票幂等返回(开票完成为终态);星瀚内码由附件补传分支经 A303-4 反查回填
|
||||
if (KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(entity.getKingdeeStatus())
|
||||
@@ -801,10 +818,10 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
}
|
||||
if (Func.isBlank(entity.getDepartmentEmails())) throw new ServiceException("推送金蝶开票申请失败:部门邮箱(发票推送邮箱)不能为空");
|
||||
if (Func.isBlank(entity.getTaxpayerNo())) throw new ServiceException("推送金蝶开票申请失败:购方纳税人识别号不能为空");
|
||||
Dept dept = entity.getDeptId() == null ? null : SysCache.getDept(entity.getDeptId());
|
||||
if (dept == null || Func.isBlank(dept.getKingdeeDeptCode())) {
|
||||
throw new ServiceException("推送金蝶开票申请失败:申请部门[" + Func.toStr(entity.getDeptId()) + "]未维护金蝶组织编码,请检查部门数据");
|
||||
}
|
||||
// 金蝶组织编码:项目业务部门反查唯一平台公司 → 平台公司金蝶组织编码;申请部门编码:项目业务部门自身金蝶编码(与付款申请单部门.编码同源,共用上下文缓存)
|
||||
KingdeeSyncContext kingdeeContext = KingdeeSyncContext.empty();
|
||||
String kingdeeOrgNumber = kingdeeApBillSyncSupport.kingdeeOrgNumber(entity.getProjectId(), kingdeeContext);
|
||||
String kingdeeDeptNumber = kingdeeApBillSyncSupport.kingdeeDeptNumber(entity.getProjectId(), kingdeeContext);
|
||||
CustomerArchive customer = entity.getReceiverCustomerId() == null ? null : customerArchiveMapper.selectById(entity.getReceiverCustomerId());
|
||||
if (customer == null || Func.isBlank(customer.getCustomerCode())) {
|
||||
throw new ServiceException("推送金蝶开票申请失败:受票方客户未在客商档案中维护客商编码,请补充后重试");
|
||||
@@ -835,7 +852,7 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
+ "]无商品行,无法组装分录");
|
||||
}
|
||||
BfiInvoiceApplySaveDTO param = this.buildInvoiceApplyParam(entity, sheetBillNo, seller, salerAddr, salerBank,
|
||||
dept, customer, firstArSrcCode, money(sheet.getInvoiceAmount()), items);
|
||||
kingdeeDeptNumber, kingdeeOrgNumber, customer, firstArSrcCode, money(sheet.getInvoiceAmount()), items);
|
||||
log.info("金蝶开票申请推送:开票申请[{}]发票张[{}],billno=[{}],价税合计=[{}],分录{}条",
|
||||
entity.getApplicationNo(), sheet.getSheetNo(), sheetBillNo, sheet.getInvoiceAmount(), items.size());
|
||||
BfiBillSaveResultVO saveResult = bfiService.saveInvoiceApply(param);
|
||||
@@ -884,17 +901,18 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
* 组装 A303-1 单张开票申请单入参(字段映射见设计文档 6.3/6.4),
|
||||
* 购销方等主表公共字段随开票申请取数,价税合计与单据体分录取本张发票
|
||||
*
|
||||
* @param orgNumber 金蝶组织编码(项目业务部门反查平台公司)
|
||||
* @param firstArSrcCode 上游来源单据号:首张关联结算单的金蝶应收单防重键 awrp_src_code(多张结算单取第一张)
|
||||
*/
|
||||
private BfiInvoiceApplySaveDTO buildInvoiceApplyParam(InvoiceApplication entity, String billNo,
|
||||
CustomerArchive seller, String salerAddr, String salerBank, Dept dept, CustomerArchive customer,
|
||||
CustomerArchive seller, String salerAddr, String salerBank, String deptNumber, String orgNumber, CustomerArchive customer,
|
||||
String firstArSrcCode, BigDecimal sheetAmount, List<BfiInvoiceApplySaveEntryDTO> items) {
|
||||
BfiInvoiceApplySaveDTO param = new BfiInvoiceApplySaveDTO();
|
||||
// ==================== 以下为【必填】字段(BfiInvoiceApplySaveDTO 标注),非必填字段先注释保留 ====================
|
||||
// 【必填】组织.编码:申请部门 deptId → 金蝶组织编码
|
||||
param.setOrgidNumber(dept.getKingdeeDeptCode());
|
||||
// 【必填】申请部门.编码:同上
|
||||
param.setAwrpOrgfieldNumber(dept.getKingdeeDeptCode());
|
||||
// 【必填】组织.编码:项目业务部门反查唯一平台公司 → 平台公司金蝶组织编码
|
||||
param.setOrgidNumber(orgNumber);
|
||||
// 【必填】申请部门.编码:项目业务部门金蝶编码(与付款申请单 awrp_department_number 同源)
|
||||
param.setAwrpOrgfieldNumber(deptNumber);
|
||||
// 【必填】业务单据编号(候选键):单张发票候选 billno(多张发票时开票申请单号-张次序号)
|
||||
param.setBillno(billNo);
|
||||
// 【必填】单据日期:申请日期 yyyy-MM-dd HH:mm:ss
|
||||
@@ -1670,6 +1688,14 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
return entity;
|
||||
}
|
||||
|
||||
/** 行锁读:同一单据并发推送串行化(锁随 doSyncKingdee 事务提交释放,后者读到已同步状态幂等返回) */
|
||||
private InvoiceApplication lockedExisting(Long id) {
|
||||
InvoiceApplication entity = baseMapper.selectOne(Wrappers.<InvoiceApplication>lambdaQuery()
|
||||
.eq(InvoiceApplication::getId, id).last("FOR UPDATE"));
|
||||
if (entity == null || Objects.equals(entity.getIsDeleted(), 1)) throw new ServiceException("开票申请不存在");
|
||||
return entity;
|
||||
}
|
||||
|
||||
private InvoiceApplication editable(Long id) {
|
||||
InvoiceApplication entity = existing(id);
|
||||
if (!List.of(TransportBusiConstant.DRAFT, TransportBusiConstant.RETURNED).contains(entity.getApprovalStatus())) throw new ServiceException("当前状态不可编辑");
|
||||
@@ -1680,10 +1706,11 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
|
||||
return ids == null ? List.of() : Func.toLongList(ids).stream().distinct().toList();
|
||||
}
|
||||
|
||||
private String nextNo() {
|
||||
/** 生成当日开票申请单号;按最大流水号 +1,且计入逻辑删除数据 */
|
||||
private synchronized String nextNo() {
|
||||
String prefix = "KP-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
|
||||
return prefix + String.format("%05d", count(Wrappers.<InvoiceApplication>lambdaQuery()
|
||||
.likeRight(InvoiceApplication::getApplicationNo, prefix)) + 1);
|
||||
Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix);
|
||||
return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1);
|
||||
}
|
||||
|
||||
private String normalizeDepartmentEmails(String value, List<CustomerInvoiceInfoVO> invoiceInfos) {
|
||||
|
||||
+98
-17
@@ -64,6 +64,7 @@ import org.springblade.transport.mapper.FormalSettlementMapper;
|
||||
import org.springblade.transport.mapper.InvoiceReceiptMapper;
|
||||
import org.springblade.transport.mapper.InvoiceReceiptRecordMapper;
|
||||
import org.springblade.transport.mapper.InvoiceReceiptSettlementMapper;
|
||||
import org.springblade.transport.mapper.CustomerInvoiceInfoMapper;
|
||||
import org.springblade.transport.mapper.KingdeeInvoicePoolMapper;
|
||||
import org.springblade.transport.pojo.dto.InvoiceReceiptSaveRequest;
|
||||
import org.springblade.transport.pojo.dto.InvoiceReceiptStatusRequest;
|
||||
@@ -71,6 +72,7 @@ import org.springblade.transport.pojo.dto.KingdeeInvoicePoolQueryDTO;
|
||||
import org.springblade.transport.pojo.entity.ContractManage;
|
||||
import org.springblade.transport.pojo.entity.CustomerArchive;
|
||||
import org.springblade.transport.pojo.entity.CustomerContact;
|
||||
import org.springblade.transport.pojo.entity.CustomerInvoiceInfo;
|
||||
import org.springblade.transport.pojo.entity.FormalSettlement;
|
||||
import org.springblade.transport.pojo.entity.FormalSettlementDetail;
|
||||
import org.springblade.transport.pojo.entity.InvoiceReceipt;
|
||||
@@ -91,6 +93,7 @@ import org.springframework.cloud.context.config.annotation.RefreshScope;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
@@ -150,8 +153,11 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|
||||
private final FormalSettlementDetailMapper formalSettlementDetailMapper;
|
||||
private final CustomerArchiveMapper customerArchiveMapper;
|
||||
private final CustomerContactMapper customerContactMapper;
|
||||
private final CustomerInvoiceInfoMapper customerInvoiceInfoMapper;
|
||||
private final IBfiService bfiService;
|
||||
private final BfiProperties bfiProperties;
|
||||
/** 逐单推送事务模板:批量编排逐单开短事务,行锁跨远程调用,单笔失败只回滚自身不中断整批 */
|
||||
private final TransactionTemplate transactionTemplate;
|
||||
private final KingdeeApBillSyncSupport kingdeeApBillSyncSupport;
|
||||
private final KingdeeReceivedInvoiceSyncSupport kingdeeReceivedInvoiceSyncSupport;
|
||||
private final KingdeeAttachmentSyncSupport kingdeeAttachmentSyncSupport;
|
||||
@@ -247,9 +253,10 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|
||||
return page;
|
||||
}
|
||||
|
||||
/** 本地镜像真分页(可用过滤/认领登记标记均在 SQL 内直出) */
|
||||
/** 本地镜像真分页(可用过滤/认领登记标记均在 SQL 内直出;客商单位过滤与定时同步同开关同口径) */
|
||||
private IPage<KingdeeInvoicePoolVO> selectLocalPoolPage(KingdeeInvoicePoolQueryDTO query, long current, long size) {
|
||||
return invoicePoolMapper.selectInvoicePoolPage(new Page<>(current, size), query, AuthUtil.getTenantId());
|
||||
return invoicePoolMapper.selectInvoicePoolPage(new Page<>(current, size), query, AuthUtil.getTenantId(),
|
||||
bfiProperties.getInvoiceReceipt().isCustomerUnitsOnly());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -608,12 +615,16 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|
||||
List<String> failures = new ArrayList<>();
|
||||
for (InvoiceReceipt receipt : receipts) {
|
||||
try {
|
||||
pushOneReceivedInvoice(planByReceipt.get(receipt.getId()), kingdeeRowByInvoiceNo, apBillNoBySrcCode, context, autoSync);
|
||||
// 逐单单据级事务:推送方法首行 FOR UPDATE 串行化同一登记的并发推送,单笔失败只回滚自身不中断整批
|
||||
transactionTemplate.executeWithoutResult(status -> pushOneReceivedInvoice(
|
||||
planByReceipt.get(receipt.getId()), kingdeeRowByInvoiceNo, apBillNoBySrcCode, context, autoSync));
|
||||
success++;
|
||||
} catch (RuntimeException pushException) {
|
||||
failures.add("发票" + Func.toStrWithEmpty(receipt.getInvoiceNo(), String.valueOf(receipt.getId()))
|
||||
+ ":" + pushException.getMessage());
|
||||
log.warn("推送金蝶收票单失败,收票登记id={}", receipt.getId(), pushException);
|
||||
// 单笔事务已回滚,事务外独立补落"推送失败"状态,纳入定时重推(与付款/开票失败口径统一)
|
||||
markReceivedInvoicePushFailed(receipt, planByReceipt.get(receipt.getId()), pushException, autoSync);
|
||||
}
|
||||
}
|
||||
String summary = "推送金蝶收票单完成:本批 " + receipts.size() + " 笔,成功 " + success + " 笔";
|
||||
@@ -674,7 +685,21 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|
||||
*/
|
||||
private String pushOneReceivedInvoice(ReceivedInvoicePushPlan plan, Map<String, BfiReceivedInvoiceVO> kingdeeRows,
|
||||
Map<String, String> apBillNoBySrcCode, KingdeeSyncContext context, boolean autoSync) {
|
||||
InvoiceReceipt receipt = plan.receipt();
|
||||
// 行锁读:同一登记并发推送串行化(锁随本单事务提交释放,事务由批量编排的 TransactionTemplate 开启)
|
||||
InvoiceReceipt receipt = baseMapper.selectOne(Wrappers.<InvoiceReceipt>lambdaQuery()
|
||||
.eq(InvoiceReceipt::getId, plan.receipt().getId()).last("FOR UPDATE"));
|
||||
if (receipt == null || Objects.equals(receipt.getIsDeleted(), 1)) {
|
||||
throw new ServiceException("收票登记不存在");
|
||||
}
|
||||
// 锁后重查推送状态:并发场景另一事务已完成推送则幂等跳过,不再重复调金蝶
|
||||
if (KingdeeBfiConstant.RECEIVED_PUSH_PUSHED.equals(receipt.getKingdeePushStatus())
|
||||
|| KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED.equals(receipt.getKingdeePushStatus())) {
|
||||
log.info("收票登记[{}]金蝶收票单已推送(状态[{}]),幂等跳过", receipt.getInvoiceNo(), receipt.getKingdeePushStatus());
|
||||
return "发票" + Func.toStrWithEmpty(receipt.getInvoiceNo(), String.valueOf(receipt.getId())) + "已推送金蝶,幂等跳过";
|
||||
}
|
||||
// 计划内登记实体以行锁后最新数据替换(并发下推送状态可能已被其他事务改写)
|
||||
plan = new ReceivedInvoicePushPlan(receipt, plan.mirror(), plan.settlement(), plan.details(),
|
||||
plan.apBillSrcCode(), plan.blockedReason());
|
||||
log.info("收票登记[{}]推送金蝶收票单开始:id={},推送状态={},应付单防重键={}", receipt.getInvoiceNo(), receipt.getId(),
|
||||
receipt.getKingdeePushStatus(), plan.apBillSrcCode());
|
||||
if (Func.isNotBlank(plan.blockedReason())) {
|
||||
@@ -765,6 +790,25 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|
||||
return "发票" + receipt.getInvoiceNo() + "推送成功,金蝶收票单[" + billNo + "]已指定应付单";
|
||||
}
|
||||
|
||||
/**
|
||||
* 单笔推送失败后事务外独立补落"推送失败"状态:事务版推送中的失败状态写随本单事务回滚,
|
||||
* 由批量编排的 catch 在事务外重写,纳入定时重推循环;手动前置拦截路径保持不动状态(仅逐单报因)
|
||||
*
|
||||
* @param receipt 收票登记(批量预取实体)
|
||||
* @param plan 推送计划
|
||||
* @param pushException 推送异常
|
||||
* @param autoSync 自动推送路径标记
|
||||
*/
|
||||
private void markReceivedInvoicePushFailed(InvoiceReceipt receipt, ReceivedInvoicePushPlan plan,
|
||||
RuntimeException pushException, boolean autoSync) {
|
||||
if (plan.blockedReason() != null && !autoSync) {
|
||||
return;
|
||||
}
|
||||
applyReceivedInvoicePushResult(receipt, receipt.getKingdeeReceivedInvoiceNo(), receipt.getKingdeeReceivedInvoiceId(),
|
||||
KingdeeBfiConstant.RECEIVED_PUSH_FAILED,
|
||||
truncatePushMsg("推送金蝶收票单失败:" + pushException.getMessage()), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 附件联带上传(A902):A303-2 保存成功(含幂等预检命中)后调用,上传登记附件与镜像发票文件;
|
||||
* billPkId 缺失跳过待补传;异常独立捕获,绝不影响推送状态与整批进度
|
||||
@@ -1252,20 +1296,25 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|
||||
LocalDateTime windowStart = LocalDateTime.now().minusDays(bfiProperties.getInvoiceReceipt().getIncrementDays());
|
||||
LocalDateTime windowEnd = LocalDateTime.now();
|
||||
List<String> invoiceTypes = this.poolInvoiceTypeCodes();
|
||||
// 客商单位过滤集:开关开启时一次性载入有效客商发票信息税号(销/购方税号都须命中才入镜像),整轮共享避免逐页查询
|
||||
Set<String> customerTaxNos = bfiProperties.getInvoiceReceipt().isCustomerUnitsOnly() ? this.customerInvoiceTaxNos() : null;
|
||||
SyncStats stats = new SyncStats();
|
||||
log.info("金蝶全票池同步开始,增量窗口 {} ~ {},发票种类:{},单页 {} 条最多 {} 页(触顶自动二分切片)",
|
||||
log.info("金蝶全票池同步开始,增量窗口 {} ~ {},发票种类:{},客商单位过滤:{},单页 {} 条最多 {} 页(触顶自动二分切片)",
|
||||
windowStart.format(POOL_TIME_FORMATTER), windowEnd.format(POOL_TIME_FORMATTER),
|
||||
invoiceTypes.isEmpty() ? "不限制" : invoiceTypes, POOL_PAGE_SIZE, MAX_POOL_PAGES);
|
||||
invoiceTypes.isEmpty() ? "不限制" : invoiceTypes, customerTaxNos != null ? "开启" : "关闭",
|
||||
POOL_PAGE_SIZE, MAX_POOL_PAGES);
|
||||
if (invoiceTypes.isEmpty()) {
|
||||
this.syncWindowSlice(windowStart, windowEnd, null, stats);
|
||||
this.syncWindowSlice(windowStart, windowEnd, null, customerTaxNos, stats);
|
||||
} else {
|
||||
// 单值参数拆轮:每个票种编码独立完整切片续扫,互不挤占翻页额度
|
||||
for (String invoiceType : invoiceTypes) {
|
||||
this.syncWindowSlice(windowStart, windowEnd, invoiceType, stats);
|
||||
this.syncWindowSlice(windowStart, windowEnd, invoiceType, customerTaxNos, stats);
|
||||
}
|
||||
}
|
||||
String summary = "金蝶全票池同步:翻页" + stats.pageCount + "页切片" + stats.sliceCount + "片拉取"
|
||||
+ stats.pulledCount + "张发票,镜像新增" + stats.created + "更新" + stats.updated + "无变化"
|
||||
+ stats.pulledCount + "张发票"
|
||||
+ (stats.customerFiltered > 0 ? "(客商单位过滤" + stats.customerFiltered + "张)" : "")
|
||||
+ ",镜像新增" + stats.created + "更新" + stats.updated + "无变化"
|
||||
+ stats.mirrorUnchanged + ",补绑" + stats.bound + ",登记无变化跳过" + stats.unchanged
|
||||
+ ",失败" + stats.failed + ",耗时" + (System.currentTimeMillis() - startMillis) + "ms";
|
||||
log.info("金蝶全票池同步结束,{}", summary);
|
||||
@@ -1288,6 +1337,8 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|
||||
private static final class SyncStats {
|
||||
/** A801-1 原始拉取张数 */
|
||||
int pulledCount;
|
||||
/** 客商单位过滤剔除张数(销/购方税号未同时登记在客商发票信息中,开关开启时统计) */
|
||||
int customerFiltered;
|
||||
/** 镜像新增条数(票池有、本地无) */
|
||||
int created;
|
||||
/** 镜像有变化并重写条数 */
|
||||
@@ -1311,11 +1362,13 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|
||||
* 触顶说明该时间片数据 >2.5 万,从中点二分为两片递归续扫(递归深度受时间粒度自然收敛,
|
||||
* 单片 500 条以内 1 页即短页终止)
|
||||
*
|
||||
* @param sliceStart 切片起点(含)
|
||||
* @param sliceEnd 切片终点(不含,避免与下一片边界重复拉取)
|
||||
* @param stats 整轮统计累加器
|
||||
* @param sliceStart 切片起点(含)
|
||||
* @param sliceEnd 切片终点(不含,避免与下一片边界重复拉取)
|
||||
* @param customerTaxNos 客商发票信息税号集,null 表示客商单位过滤关闭(整轮共享)
|
||||
* @param stats 整轮统计累加器
|
||||
*/
|
||||
private void syncWindowSlice(LocalDateTime sliceStart, LocalDateTime sliceEnd, String invoiceType, SyncStats stats) {
|
||||
private void syncWindowSlice(LocalDateTime sliceStart, LocalDateTime sliceEnd, String invoiceType,
|
||||
Set<String> customerTaxNos, SyncStats stats) {
|
||||
// 二分收敛保护:切片粒度细到 1 秒仍触顶说明远端异常(同秒 >2.5 万条不可能),不再二分直接记漏
|
||||
if (!sliceStart.isBefore(sliceEnd)) {
|
||||
log.error("金蝶全票池同步切片区间为空({} ~ {}),远端 updateTime 粒度异常,放弃该切片", sliceStart, sliceEnd);
|
||||
@@ -1329,6 +1382,13 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|
||||
}
|
||||
stats.pageCount++;
|
||||
stats.pulledCount += rows.size();
|
||||
// 客商单位过滤:销/购方税号未同时登记在客商发票信息中的发票不入镜像;
|
||||
// 短页判定用原始条数,过滤后的空页不能提前终止翻页
|
||||
int rawPageSize = rows.size();
|
||||
if (customerTaxNos != null) {
|
||||
rows = rows.stream().filter(row -> this.bothUnitsRegistered(row, customerTaxNos)).toList();
|
||||
stats.customerFiltered += rawPageSize - rows.size();
|
||||
}
|
||||
BatchUpsertResult upsertResult = this.batchUpsertInvoicePool(rows);
|
||||
stats.created += upsertResult.created();
|
||||
stats.updated += upsertResult.updated();
|
||||
@@ -1340,8 +1400,8 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|
||||
stats.unchanged += refreshResult.unchanged();
|
||||
stats.failed += refreshResult.failed();
|
||||
}
|
||||
// 远程短页:该片到底,正常终止
|
||||
if (rows.size() < POOL_PAGE_SIZE) {
|
||||
// 远程短页:该片到底,正常终止(按原始拉取条数判断,客商过滤不改变翻页节奏)
|
||||
if (rawPageSize < POOL_PAGE_SIZE) {
|
||||
stats.sliceCount++;
|
||||
return;
|
||||
}
|
||||
@@ -1359,13 +1419,34 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|
||||
}
|
||||
log.warn("金蝶全票池同步切片 {} ~ {} 触达翻页保护 {} 页({} 条),二分续扫 [{} ~ {}) + [{} ~ {})",
|
||||
sliceStart, sliceEnd, MAX_POOL_PAGES, MAX_POOL_PAGES * POOL_PAGE_SIZE, sliceStart, mid, mid, sliceEnd);
|
||||
this.syncWindowSlice(sliceStart, mid, invoiceType, stats);
|
||||
this.syncWindowSlice(mid, sliceEnd, invoiceType, stats);
|
||||
this.syncWindowSlice(sliceStart, mid, invoiceType, customerTaxNos, stats);
|
||||
this.syncWindowSlice(mid, sliceEnd, invoiceType, customerTaxNos, stats);
|
||||
return;
|
||||
}
|
||||
stats.sliceCount++;
|
||||
}
|
||||
|
||||
/**
|
||||
* 有效客商发票信息税号集(状态正常且未删除,MP 租户插件自动隔离租户):
|
||||
* 客商单位过滤开关开启时,同步拉取与票池展示查询共用的单位白名单
|
||||
*/
|
||||
private Set<String> customerInvoiceTaxNos() {
|
||||
return customerInvoiceInfoMapper.selectList(Wrappers.<CustomerInvoiceInfo>lambdaQuery()
|
||||
.eq(CustomerInvoiceInfo::getStatus, 1)
|
||||
.eq(CustomerInvoiceInfo::getIsDeleted, 0)).stream()
|
||||
.map(CustomerInvoiceInfo::getTaxNo)
|
||||
.filter(StringUtil::isNotBlank)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
/**
|
||||
* 开票单位(销方)与受票单位(购方)税号是否均已登记在客商发票信息中;税号缺失视为未登记(开关开启时不入镜像)
|
||||
*/
|
||||
private boolean bothUnitsRegistered(BfiInvoicePoolVO row, Set<String> customerTaxNos) {
|
||||
return StringUtil.isNotBlank(row.getSalerTaxNo()) && StringUtil.isNotBlank(row.getBuyerTaxNo())
|
||||
&& customerTaxNos.contains(row.getSalerTaxNo()) && customerTaxNos.contains(row.getBuyerTaxNo());
|
||||
}
|
||||
|
||||
/** 已登记票跟随刷新结果统计(绑定/刷新/无变化/失败) */
|
||||
private record RefreshResult(int bound, int unchanged, int failed) {
|
||||
}
|
||||
|
||||
+91
-20
@@ -13,6 +13,7 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.mp.base.BaseEntity;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.core.tool.utils.SpringUtil;
|
||||
@@ -29,6 +30,7 @@ import org.springblade.transport.pojo.entity.FormalSettlement;
|
||||
import org.springblade.transport.pojo.entity.InvoiceApplication;
|
||||
import org.springblade.transport.pojo.entity.KingdeeBillLock;
|
||||
import org.springblade.transport.pojo.entity.PaymentApplication;
|
||||
import org.springblade.transport.pojo.vo.KingdeeUnlockMark;
|
||||
import org.springblade.transport.service.IFormalSettlementService;
|
||||
import org.springblade.transport.service.IInvoiceApplicationService;
|
||||
import org.springblade.transport.service.IKingdeeBillLockService;
|
||||
@@ -152,8 +154,8 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl<KingdeeBillLockMappe
|
||||
public int onUnlockNotify(BfiBillLockDTO param) {
|
||||
// 总开关关闭:只应答不处理(返回 0 与「未匹配」同构,金蝶侧不会触发重试)
|
||||
if (!bfiProperties.getBillLock().isEnabled()) {
|
||||
log.warn("金蝶单据锁定总开关关闭,跳过解锁回调处理:operationType={},targetBillNo={}",
|
||||
param == null ? null : param.getOperationType(), param == null ? null : param.getTargetBillNo());
|
||||
log.warn("金蝶单据锁定总开关关闭,跳过解锁回调处理:operationType={},金蝶单号={}",
|
||||
param == null ? null : param.getOperationType(), param == null ? null : param.getSourceBillNo());
|
||||
return 0;
|
||||
}
|
||||
if (param == null || !KingdeeLockConstant.SYSTEM_XH.equals(param.getSourceSystem())
|
||||
@@ -161,35 +163,41 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl<KingdeeBillLockMappe
|
||||
|| !KingdeeLockConstant.ACTION_UNLOCK.equals(param.getLockAction())) {
|
||||
throw new ServiceException("金蝶解锁回调参数非法:sourceSystem须为XH、targetSystem须为WLTMS、lockAction须为UNLOCK");
|
||||
}
|
||||
if (Func.isBlank(param.getTargetBillNo())) {
|
||||
throw new ServiceException("金蝶解锁回调参数非法:targetBillNo不能为空");
|
||||
if (Func.isBlank(param.getSourceBillNo()) || Func.isBlank(param.getTargetBillNo())) {
|
||||
throw new ServiceException("金蝶解锁回调参数非法:sourceBillNo、targetBillNo不能为空");
|
||||
}
|
||||
// 批量回调:targetBillNo 逗号拼接,逐张处理;未匹配锁定中记录的幂等跳过(重复通知防金蝶重试风暴)
|
||||
int processed = 0;
|
||||
for (String targetBillNo : Func.toStrList(param.getTargetBillNo())) {
|
||||
KingdeeBillLock lockRecord = this.getOne(Wrappers.<KingdeeBillLock>lambdaQuery()
|
||||
.eq(KingdeeBillLock::getTargetBillType, param.getTargetBillType())
|
||||
.eq(KingdeeBillLock::getTargetBillNo, targetBillNo)
|
||||
.orderByDesc(KingdeeBillLock::getId)
|
||||
.last("limit 1"), false);
|
||||
// 回调方向与锁定相反:回调 sourceBillNo=金蝶单号(即锁记录 targetBillNo)、targetBillNo=WLTMS 单号(即锁记录 sourceBillNo)、
|
||||
// targetBillType 可能为空,故按金蝶单号+我方单号双条件反查;未匹配锁定中记录的幂等跳过(重复通知防金蝶重试风暴)
|
||||
// 一次 IN 查询取回候选记录、内存取各单号最新一条,批量更新解锁状态,避免逐张查询/更新的 N+1
|
||||
List<String> kingdeeBillNos = Func.toStrList(param.getSourceBillNo());
|
||||
Map<String, KingdeeBillLock> latestLockMap = this.list(Wrappers.<KingdeeBillLock>lambdaQuery()
|
||||
.in(KingdeeBillLock::getTargetBillNo, kingdeeBillNos)
|
||||
.eq(KingdeeBillLock::getSourceBillNo, param.getTargetBillNo())
|
||||
.orderByDesc(KingdeeBillLock::getId))
|
||||
.stream()
|
||||
.collect(Collectors.toMap(KingdeeBillLock::getTargetBillNo, Function.identity(), (first, second) -> first));
|
||||
List<KingdeeBillLock> unlockRecords = new ArrayList<>();
|
||||
for (String kingdeeBillNo : kingdeeBillNos) {
|
||||
KingdeeBillLock lockRecord = latestLockMap.get(kingdeeBillNo);
|
||||
if (lockRecord == null || !KingdeeLockConstant.STATUS_LOCKED.equals(lockRecord.getLockStatus())) {
|
||||
log.warn("金蝶解锁回调未匹配锁定中的锁记录(幂等跳过):targetBillType={},targetBillNo={}",
|
||||
param.getTargetBillType(), targetBillNo);
|
||||
log.warn("金蝶解锁回调未匹配锁定中的锁记录(幂等跳过):金蝶单号={},回调targetBillNo={}",
|
||||
kingdeeBillNo, param.getTargetBillNo());
|
||||
continue;
|
||||
}
|
||||
lockRecord.setLockStatus(KingdeeLockConstant.STATUS_UNLOCKED);
|
||||
lockRecord.setOperationType(param.getOperationType());
|
||||
lockRecord.setUnlockTime(LocalDateTime.now());
|
||||
lockRecord.setNotifyOperator(param.getOperator());
|
||||
this.updateById(lockRecord);
|
||||
processed++;
|
||||
unlockRecords.add(lockRecord);
|
||||
log.info("金蝶单据[{}:{}]解锁回调落库:operationType={},操作人={}",
|
||||
lockRecord.getSourceBillType(), lockRecord.getSourceBillNo(), param.getOperationType(), param.getOperator());
|
||||
if (KingdeeLockConstant.OPERATION_DELETE.equals(param.getOperationType())) {
|
||||
this.restoreOnKingdeeDelete(lockRecord, param.getOperator());
|
||||
}
|
||||
}
|
||||
return processed;
|
||||
this.updateBatchById(unlockRecords);
|
||||
// DELETE 分发废除还原:按源单逐张执行(各单据类型还原动作为独立业务事务,无法合并)
|
||||
if (KingdeeLockConstant.OPERATION_DELETE.equals(param.getOperationType())) {
|
||||
unlockRecords.forEach(lockRecord -> this.restoreOnKingdeeDelete(lockRecord, param.getOperator()));
|
||||
}
|
||||
return unlockRecords.size();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,6 +251,28 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl<KingdeeBillLockMappe
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public int unlockByAdjustRepush(String sourceBillType, Long sourceBillId, String operator) {
|
||||
List<KingdeeBillLock> lockedRecords = this.list(Wrappers.<KingdeeBillLock>lambdaQuery()
|
||||
.eq(KingdeeBillLock::getSourceBillType, sourceBillType)
|
||||
.eq(KingdeeBillLock::getSourceBillId, sourceBillId)
|
||||
.eq(KingdeeBillLock::getLockStatus, KingdeeLockConstant.STATUS_LOCKED));
|
||||
if (Func.isEmpty(lockedRecords)) {
|
||||
return 0;
|
||||
}
|
||||
LocalDateTime unlockTime = LocalDateTime.now();
|
||||
for (KingdeeBillLock lockRecord : lockedRecords) {
|
||||
lockRecord.setLockStatus(KingdeeLockConstant.STATUS_UNLOCKED);
|
||||
lockRecord.setOperationType(KingdeeLockConstant.OPERATION_ADJUST_REPUSH);
|
||||
lockRecord.setUnlockTime(unlockTime);
|
||||
lockRecord.setNotifyOperator(Func.isEmpty(operator) ? "system" : operator);
|
||||
}
|
||||
this.updateBatchById(lockedRecords);
|
||||
log.info("结算调整重推解锁旧锁:源单[{}:{}],解锁{}张,操作人={}", sourceBillType, sourceBillId, lockedRecords.size(), operator);
|
||||
return lockedRecords.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkEditable(String sourceBillType, Long sourceBillId) {
|
||||
long lockedCount = this.count(Wrappers.<KingdeeBillLock>lambdaQuery()
|
||||
@@ -268,6 +298,47 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl<KingdeeBillLockMappe
|
||||
return lockRecords.stream().collect(Collectors.toMap(KingdeeBillLock::getSourceBillId, Function.identity(), (first, second) -> second));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<Long, KingdeeBillLock> mapUnlockedLocks(String sourceBillType, List<Long> sourceBillIds) {
|
||||
if (Func.isEmpty(sourceBillIds)) {
|
||||
return Map.of();
|
||||
}
|
||||
List<KingdeeBillLock> lockRecords = this.list(Wrappers.<KingdeeBillLock>lambdaQuery()
|
||||
.eq(KingdeeBillLock::getSourceBillType, sourceBillType)
|
||||
.in(KingdeeBillLock::getSourceBillId, sourceBillIds)
|
||||
.eq(KingdeeBillLock::getLockStatus, KingdeeLockConstant.STATUS_UNLOCKED)
|
||||
.orderByDesc(KingdeeBillLock::getId));
|
||||
// 降序取回后首个生效:每源单保留最新一条解锁留痕
|
||||
return lockRecords.stream().collect(Collectors.toMap(KingdeeBillLock::getSourceBillId, Function.identity(), (first, second) -> first));
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T extends BaseEntity & KingdeeUnlockMark> void fillUnlockMarks(String sourceBillType, List<T> rows, Map<Long, KingdeeBillLock> activeLockMap) {
|
||||
if (Func.isEmpty(rows)) {
|
||||
return;
|
||||
}
|
||||
List<Long> unlockedIds = rows.stream()
|
||||
.filter(row -> !activeLockMap.containsKey(row.getId()))
|
||||
.map(BaseEntity::getId).toList();
|
||||
if (Func.isEmpty(unlockedIds)) {
|
||||
return;
|
||||
}
|
||||
Map<Long, KingdeeBillLock> unlockedMap = this.mapUnlockedLocks(sourceBillType, unlockedIds);
|
||||
for (T row : rows) {
|
||||
KingdeeBillLock unlockRecord = unlockedMap.get(row.getId());
|
||||
if (unlockRecord == null) {
|
||||
continue;
|
||||
}
|
||||
row.setKingdeeUnlockTime(unlockRecord.getUnlockTime());
|
||||
row.setKingdeeUnlockOperator(unlockRecord.getNotifyOperator());
|
||||
row.setKingdeeUnlockOperationName(switch (Func.toStr(unlockRecord.getOperationType())) {
|
||||
case KingdeeLockConstant.OPERATION_DELETE -> "金蝶删单";
|
||||
case KingdeeLockConstant.OPERATION_ADJUST_REPUSH -> "结算调整重推";
|
||||
default -> "金蝶锁单";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁定补偿:扫 failed 且 try_count 未达重试上限(RETRY_MAX_COUNT)的锁记录,逐张重调 lockBusiData
|
||||
* (接口 1对1:单次一个源单号对一个金蝶单号,单张成败互不影响,由定时任务定时触发,亦可人工触发)。
|
||||
|
||||
+166
-33
@@ -28,7 +28,10 @@ import org.springblade.transport.pojo.vo.LoadingCarrierContractVO;
|
||||
import org.springblade.transport.pojo.vo.LoadingManageVO;
|
||||
import org.springblade.transport.service.IContractManageService;
|
||||
import org.springblade.transport.service.ILoadingManageService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.IReceivablePayableDetailService;
|
||||
import org.springblade.transport.support.LiveProcessConfigSupport;
|
||||
import org.springblade.transport.support.LoadableAdmissionSupport;
|
||||
import org.springblade.transport.support.TransportBusinessSupport;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
import org.springblade.transport.wrapper.LoadingManageWrapper;
|
||||
@@ -43,8 +46,10 @@ import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -76,6 +81,9 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
@Lazy
|
||||
private IReceivablePayableDetailService receivablePayableDetailService;
|
||||
|
||||
@Resource
|
||||
private IProcessConfigService processConfigService;
|
||||
|
||||
@Override
|
||||
public IPage<LoadingManageVO> selectLoadingManagePage(IPage<LoadingManage> page, LoadingManageVO loadingManage) {
|
||||
IPage<LoadingManage> entityPage = page(page, buildQuery(loadingManage));
|
||||
@@ -147,9 +155,12 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
prepare(loadingManage);
|
||||
validate(loadingManage, false);
|
||||
List<Long> oldWaybillIds = new ArrayList<>();
|
||||
boolean ownChildrenAdmitted = false;
|
||||
if (Func.isNotEmpty(loadingManage.getId())) {
|
||||
LoadingManage oldRecord = loadEditable(loadingManage.getId(), true);
|
||||
oldWaybillIds = waybillIds(oldRecord.getWaybillIdsJson());
|
||||
// 草稿只预占运单号、未过判据,草稿转正式不得沿用本单豁免
|
||||
ownChildrenAdmitted = !STATUS_DRAFT.equals(oldRecord.getBusinessStatus());
|
||||
loadingManage.setLoadingNo(oldRecord.getLoadingNo());
|
||||
loadingManage.setDeptId(oldRecord.getDeptId());
|
||||
loadingManage.setDeptName(oldRecord.getDeptName());
|
||||
@@ -160,10 +171,10 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
if (Func.isEmpty(loadingManage.getLoadingNo())) {
|
||||
loadingManage.setLoadingNo(nextCode());
|
||||
}
|
||||
validateWaybillsAvailable(loadingManage, waybillIds(loadingManage.getWaybillIdsJson()));
|
||||
validateCarrierContract(loadingManage, true);
|
||||
List<Waybill> waybills = validateWaybillsAvailable(loadingManage, waybillIds(loadingManage.getWaybillIdsJson()), ownChildrenAdmitted);
|
||||
validateCarrierContract(loadingManage, true, waybills);
|
||||
boolean result = saveOrUpdate(loadingManage);
|
||||
syncAssociatedWaybills(loadingManage, STATUS_PENDING, oldWaybillIds);
|
||||
writeWaybillsForDriver(loadingManage, oldWaybillIds, waybills);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -409,11 +420,10 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
loadingManage.setDeptId(oldRecord.getDeptId());
|
||||
loadingManage.setDeptName(oldRecord.getDeptName());
|
||||
loadingManage.setBusinessStatus(STATUS_PENDING);
|
||||
validateWaybillsAvailable(loadingManage, waybillIds(loadingManage.getWaybillIdsJson()));
|
||||
validateCarrierContract(loadingManage, true);
|
||||
List<Waybill> waybills = validateWaybillsAvailable(loadingManage, waybillIds(loadingManage.getWaybillIdsJson()), true);
|
||||
validateCarrierContract(loadingManage, true, waybills);
|
||||
boolean result = updateById(loadingManage);
|
||||
syncAssociatedWaybills(loadingManage, STATUS_PENDING, oldWaybillIds);
|
||||
clearAssociatedWaybillAcceptRecords(loadingManage);
|
||||
writeWaybillsForDriver(loadingManage, oldWaybillIds, waybills);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -735,29 +745,64 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
TransportBusinessSupport.validateLength(loadingManage.getTaskRemark(), 500, "任务备注不能超过500字");
|
||||
}
|
||||
|
||||
private void validateWaybillsAvailable(LoadingManage loadingManage, List<Long> waybillIds) {
|
||||
private List<Waybill> validateWaybillsAvailable(LoadingManage loadingManage, List<Long> waybillIds, boolean ownChildrenAdmitted) {
|
||||
if (Func.isEmpty(waybillIds)) {
|
||||
throw new ServiceException("请选择待配载运单");
|
||||
}
|
||||
List<Waybill> waybills = waybillMapper.selectList(Wrappers.<Waybill>lambdaQuery()
|
||||
.eq(Waybill::getIsDeleted, 0)
|
||||
.in(Waybill::getId, waybillIds));
|
||||
List<Waybill> waybills = loadWaybills(waybillIds);
|
||||
if (waybills.size() != waybillIds.size()) {
|
||||
throw new ServiceException("待配载运单不存在或已删除");
|
||||
}
|
||||
String ownLoadingNo = loadingManage.getLoadingNo();
|
||||
for (Waybill waybill : waybills) {
|
||||
TransportBusinessSupport.assertCurrentDept(waybill.getDeptId(), "运单管理");
|
||||
if (Func.isNotEmpty(waybill.getLoadingNo()) && !Objects.equals(waybill.getLoadingNo(), loadingManage.getLoadingNo())) {
|
||||
throw new ServiceException("运单" + waybill.getWaybillNo() + "已关联其他配载单");
|
||||
if (ownChildrenAdmitted && LoadableAdmissionSupport.isOwnChild(waybill, ownLoadingNo)) {
|
||||
continue;
|
||||
}
|
||||
if ((Objects.equals(waybill.getBusinessStatus(), STATUS_COMPLETED) || Objects.equals(waybill.getBusinessStatus(), STATUS_CANCELLED))
|
||||
&& !Objects.equals(waybill.getLoadingNo(), loadingManage.getLoadingNo())) {
|
||||
throw new ServiceException("运单" + waybill.getWaybillNo() + "当前状态不允许配载");
|
||||
LoadableAdmissionSupport.UnloadableReason reason =
|
||||
LoadableAdmissionSupport.unloadableReason(waybill, ownLoadingNo);
|
||||
if (reason != null) {
|
||||
throw new ServiceException(unloadableMessage(waybill, reason));
|
||||
}
|
||||
}
|
||||
validateSameProject(waybills);
|
||||
return waybills;
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐张准入失败的可读原因:判据只给原因枚举,运单号与文案在这里拼。
|
||||
*/
|
||||
private String unloadableMessage(Waybill waybill, LoadableAdmissionSupport.UnloadableReason reason) {
|
||||
String prefix = "运单" + waybill.getWaybillNo();
|
||||
return prefix + switch (reason) {
|
||||
case NOT_ROAD -> "运输方式不是公路,不可配载";
|
||||
case NOT_PENDING -> "当前状态不是待执行,不可配载";
|
||||
case ON_OTHER_MANIFEST -> "已关联其他配载单";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 一张配载单的全部子运单必须属于同一个项目,否则点名与其它子运单项目不同的运单。
|
||||
* <p>
|
||||
* 查询条件表达不了跨行约束,只能整批取回后在内存里比。
|
||||
*/
|
||||
private void validateSameProject(List<Waybill> waybills) {
|
||||
Long projectId = waybills.get(0).getProjectId();
|
||||
List<String> others = waybills.stream()
|
||||
.filter(waybill -> !Objects.equals(waybill.getProjectId(), projectId))
|
||||
.map(Waybill::getWaybillNo)
|
||||
.toList();
|
||||
if (Func.isNotEmpty(others)) {
|
||||
throw new ServiceException("配载单的子运单必须属于同一项目,运单"
|
||||
+ String.join("、", others) + "与其它子运单不属于同一项目");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateCarrierContract(LoadingManage loadingManage, boolean required) {
|
||||
validateCarrierContract(loadingManage, required, null);
|
||||
}
|
||||
|
||||
private void validateCarrierContract(LoadingManage loadingManage, boolean required, List<Waybill> waybills) {
|
||||
if (!Objects.equals(loadingManage.getCarrierType(), "承运商")) {
|
||||
loadingManage.setCarrierContractId(null);
|
||||
if (!Objects.equals(loadingManage.getCarrierType(), CARRIER_SELF)) {
|
||||
@@ -772,10 +817,24 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
loadingManage.setCarrierName(null);
|
||||
return;
|
||||
}
|
||||
ContractManage carrierContract = availableCarrierContracts(projectIdsByWaybills(loadingManage)).stream()
|
||||
.filter(contract -> Objects.equals(contract.getId(), loadingManage.getCarrierContractId()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new ServiceException("所选承运商合同不存在、未审核通过或已失效"));
|
||||
ContractManage carrierContract = availableCarrierContractById(loadingManage.getCarrierContractId());
|
||||
if (carrierContract == null) {
|
||||
throw new ServiceException("所选承运商合同不存在、未审核通过或已失效");
|
||||
}
|
||||
List<Waybill> childWaybills = waybills == null
|
||||
? loadWaybills(waybillIds(loadingManage.getWaybillIdsJson()))
|
||||
: waybills;
|
||||
// 合同自带项目与乙方:合同的项目不属于本单子运单的项目,等于换了项目却没换乙方
|
||||
if (!projectIdsOf(childWaybills).contains(carrierContract.getProjectId())) {
|
||||
String waybillNos = childWaybills.stream()
|
||||
.filter(waybill -> !Objects.equals(waybill.getProjectId(), carrierContract.getProjectId()))
|
||||
.map(Waybill::getWaybillNo)
|
||||
.filter(Func::isNotEmpty)
|
||||
.collect(Collectors.joining("、"));
|
||||
throw new ServiceException(Func.isEmpty(waybillNos)
|
||||
? "所选承运商合同不属于本项目"
|
||||
: "所选承运商合同不属于运单" + waybillNos + "的项目");
|
||||
}
|
||||
loadingManage.setCarrierName(carrierContract.getPartyB());
|
||||
}
|
||||
|
||||
@@ -792,34 +851,98 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
}
|
||||
}
|
||||
|
||||
private List<Long> projectIdsByWaybills(LoadingManage loadingManage) {
|
||||
List<Long> waybillIds = waybillIds(loadingManage.getWaybillIdsJson());
|
||||
private List<Waybill> loadWaybills(List<Long> waybillIds) {
|
||||
if (Func.isEmpty(waybillIds)) {
|
||||
return List.of();
|
||||
}
|
||||
return waybillMapper.selectList(Wrappers.<Waybill>lambdaQuery()
|
||||
.select(Waybill::getProjectId)
|
||||
.eq(Waybill::getIsDeleted, 0)
|
||||
.in(Waybill::getId, waybillIds))
|
||||
.stream().map(Waybill::getProjectId).filter(Objects::nonNull).distinct().toList();
|
||||
.in(Waybill::getId, waybillIds));
|
||||
}
|
||||
|
||||
private List<Long> projectIdsOf(List<Waybill> waybills) {
|
||||
return waybills.stream().map(Waybill::getProjectId).filter(Objects::nonNull).distinct().toList();
|
||||
}
|
||||
|
||||
private List<ContractManage> availableCarrierContracts(List<Long> projectIds) {
|
||||
if (Func.isEmpty(projectIds)) {
|
||||
return List.of();
|
||||
}
|
||||
return contractManageService.list(Wrappers.<ContractManage>lambdaQuery()
|
||||
.eq(ContractManage::getIsDeleted, 0)
|
||||
.eq(ContractManage::getStatus, 1)
|
||||
.eq(ContractManage::getContractCategory, "承运商合同")
|
||||
return contractManageService.list(availableCarrierContractQuery()
|
||||
.in(ContractManage::getProjectId, projectIds)
|
||||
.in(ContractManage::getApprovalStatus, "approved", "change_approved")
|
||||
.and(wrapper -> wrapper.isNull(ContractManage::getContractStage)
|
||||
.or().ne(ContractManage::getContractStage, "terminated"))
|
||||
.orderByDesc(ContractManage::getCreateTime));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按主键取一张可用的承运商合同。
|
||||
* <p>
|
||||
* 不按项目过滤,才能把「合同不可用」与「合同不属于本项目」分成两种提示。
|
||||
* 取不到可用的合同返回 null。
|
||||
*/
|
||||
private ContractManage availableCarrierContractById(Long contractId) {
|
||||
if (Func.isEmpty(contractId)) {
|
||||
return null;
|
||||
}
|
||||
return contractManageService.list(availableCarrierContractQuery()
|
||||
.eq(ContractManage::getId, contractId)
|
||||
.orderByDesc(ContractManage::getCreateTime))
|
||||
.stream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<ContractManage> availableCarrierContractQuery() {
|
||||
return Wrappers.<ContractManage>lambdaQuery()
|
||||
.eq(ContractManage::getIsDeleted, 0)
|
||||
.eq(ContractManage::getStatus, 1)
|
||||
.eq(ContractManage::getContractCategory, "承运商合同")
|
||||
.in(ContractManage::getApprovalStatus, "approved", "change_approved")
|
||||
.and(wrapper -> wrapper.isNull(ContractManage::getContractStage)
|
||||
.or().ne(ContractManage::getContractStage, "terminated"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 配载提交与重新派单共用:子运单状态逐张按该票自己项目的实时过程配置推导后落库,再清除接单事实。
|
||||
* <p>
|
||||
* 推导按「接单事实已清、尚未接单」的口径取值,故清接单事实放在状态写入之后,两者口径一致。
|
||||
*/
|
||||
private void writeWaybillsForDriver(LoadingManage loadingManage, List<Long> oldWaybillIds, List<Waybill> waybills) {
|
||||
syncAssociatedWaybills(loadingManage, deriveWaybillBusinessStatuses(waybills), oldWaybillIds);
|
||||
// 接单事实不清,被拒的票对新司机不可见——司机端一切入口都排除已拒绝的运单
|
||||
clearAssociatedWaybillAcceptRecords(loadingManage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 子运单业务状态逐张按各自项目的实时过程配置推导:需要接单 → 待执行,不需要 → 进行中。
|
||||
* <p>
|
||||
* 配置取法与运单读路径同一处:项目生效配置优先,取不到时才回退该运单的过程配置快照;
|
||||
* 快照只作兜底、永不优先,否则项目改配置后写入值与读时的归一化结果会分叉。
|
||||
* 项目为空且快照也为空 → 视为不需要接单 → 进行中。
|
||||
*/
|
||||
private Map<Long, String> deriveWaybillBusinessStatuses(List<Waybill> waybills) {
|
||||
Map<Long, String> businessStatusByWaybillId = new LinkedHashMap<>();
|
||||
if (Func.isEmpty(waybills)) {
|
||||
return businessStatusByWaybillId;
|
||||
}
|
||||
Map<Long, String> liveConfigByProject = LiveProcessConfigSupport.byProjects(processConfigService,
|
||||
waybills.stream().map(Waybill::getProjectId).toList());
|
||||
for (Waybill waybill : waybills) {
|
||||
String processJson = LiveProcessConfigSupport.resolve(
|
||||
liveConfigByProject.get(waybill.getProjectId()), waybill.getProcessJson());
|
||||
// 以「待执行 + 待接单」为输入走读路径同一个归一化:需要接单留下待执行,不需要接单提升为进行中
|
||||
businessStatusByWaybillId.put(waybill.getId(), WaybillProcessSupport.normalizeBusinessStatus(
|
||||
STATUS_PENDING, processJson, WaybillProcessSupport.ACCEPT_PENDING));
|
||||
}
|
||||
return businessStatusByWaybillId;
|
||||
}
|
||||
|
||||
private void syncAssociatedWaybills(LoadingManage loadingManage, String status, List<Long> oldWaybillIds) {
|
||||
Map<Long, String> statusByWaybillId = new LinkedHashMap<>();
|
||||
for (Long waybillId : waybillIds(loadingManage.getWaybillIdsJson())) {
|
||||
statusByWaybillId.put(waybillId, status);
|
||||
}
|
||||
syncAssociatedWaybills(loadingManage, statusByWaybillId, oldWaybillIds);
|
||||
}
|
||||
|
||||
private void syncAssociatedWaybills(LoadingManage loadingManage, Map<Long, String> statusByWaybillId, List<Long> oldWaybillIds) {
|
||||
List<Long> newWaybillIds = waybillIds(loadingManage.getWaybillIdsJson());
|
||||
if (Func.isNotEmpty(oldWaybillIds)) {
|
||||
List<Long> removedIds = new ArrayList<>(oldWaybillIds);
|
||||
@@ -829,8 +952,18 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
if (Func.isEmpty(newWaybillIds)) {
|
||||
return;
|
||||
}
|
||||
Map<String, List<Long>> waybillIdsByStatus = new LinkedHashMap<>();
|
||||
for (Long waybillId : newWaybillIds) {
|
||||
waybillIdsByStatus
|
||||
.computeIfAbsent(statusByWaybillId.getOrDefault(waybillId, STATUS_PENDING), key -> new ArrayList<>())
|
||||
.add(waybillId);
|
||||
}
|
||||
waybillIdsByStatus.forEach((status, ids) -> updateAssociatedWaybillFields(loadingManage, ids, status));
|
||||
}
|
||||
|
||||
private void updateAssociatedWaybillFields(LoadingManage loadingManage, List<Long> waybillIds, String status) {
|
||||
waybillMapper.update(null, Wrappers.<Waybill>lambdaUpdate()
|
||||
.in(Waybill::getId, newWaybillIds)
|
||||
.in(Waybill::getId, waybillIds)
|
||||
.set(Waybill::getLoadingNo, loadingManage.getLoadingNo())
|
||||
.set(Waybill::getBusinessStatus, status)
|
||||
.set(Waybill::getCarrierType, loadingManage.getCarrierType())
|
||||
|
||||
+649
-145
@@ -23,16 +23,19 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.utils.DateUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.cache.UserDisplayCache;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.LoadingManage;
|
||||
import org.springblade.transport.pojo.entity.RiskDisposal;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.AdminDriverOptionVO;
|
||||
@@ -44,12 +47,21 @@ import org.springblade.transport.pojo.vo.AdminVehicleOptionVO;
|
||||
import org.springblade.transport.pojo.vo.AdminWaybillCardVO;
|
||||
import org.springblade.transport.pojo.vo.AdminWaybillDetailVO;
|
||||
import org.springblade.transport.pojo.vo.DriverWaybillCardVO;
|
||||
import org.springblade.transport.pojo.vo.TripQueryVO;
|
||||
import org.springblade.transport.pojo.vo.TripStationVO;
|
||||
import org.springblade.transport.pojo.vo.TripTabCountsVO;
|
||||
import org.springblade.transport.pojo.vo.TripVO;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IDriverWaybillService;
|
||||
import org.springblade.transport.service.IExceptionDisposalService;
|
||||
import org.springblade.transport.service.ILoadingManageService;
|
||||
import org.springblade.transport.service.IManageWaybillService;
|
||||
import org.springblade.transport.service.IRiskDisposalService;
|
||||
import org.springblade.transport.service.ITripQueryService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.TransportBusinessSupport;
|
||||
import org.springblade.transport.support.WaybillCargoSupport;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -58,16 +70,24 @@ import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 调度端:运单状态统计 + 异常/风险角标 + 待处理事项 + 运单列表
|
||||
* 调度端:运次状态统计 + 异常/风险角标 + 待处理事项 + 运次列表
|
||||
* <p>
|
||||
* 列表与详情与司机端同粒度:取数、状态聚合、接单需求合并都调共享运次读服务,
|
||||
* 本类只做字段裁剪与调度端独有的筛选(组织类型 / 异常 / 创建时间 / 待重新派单)。
|
||||
* <p>
|
||||
* 小程序调度账号(如「小程序管理」)组织常与运单业务组织不一致,故不做 dept 过滤,
|
||||
* 仅依赖租户隔离,口径接近后台 {@code /waybill-manage/list?allDept=1}。
|
||||
@@ -102,6 +122,11 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
private static final int DEFAULT_PAGE_SIZE = 10;
|
||||
private static final int MAX_PAGE_SIZE = 50;
|
||||
|
||||
/** 待重新派单的排序键:运次内子单被拒的时刻取最大值 */
|
||||
private static final String SQL_MAX_REJECT_TIME = "MAX(driver_reject_time)";
|
||||
/** 待重新派单的排序键:运次内子单创建时刻取最大值(与运次列表同一排序口径) */
|
||||
private static final String SQL_MAX_CREATE_TIME = "MAX(create_time)";
|
||||
|
||||
private static final DateTimeFormatter DATE_MD = DateTimeFormatter.ofPattern("MM-dd");
|
||||
private static final DateTimeFormatter DATE_YMD = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
@@ -110,13 +135,19 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
private final IExceptionDisposalService exceptionDisposalService;
|
||||
private final IRiskDisposalService riskDisposalService;
|
||||
private final IDriverWaybillService driverWaybillService;
|
||||
private final ITripQueryService tripQueryService;
|
||||
private final ILoadingManageService loadingManageService;
|
||||
|
||||
@Override
|
||||
public AdminHomeStatsVO stats() {
|
||||
AdminHomeStatsVO vo = new AdminHomeStatsVO();
|
||||
vo.setPendingAccept(countWaybillByStatus(STATUS_PENDING));
|
||||
vo.setTransporting(countWaybillByStatus(STATUS_RUNNING));
|
||||
vo.setCompleted(countWaybillByStatus(STATUS_COMPLETED));
|
||||
// 三档状态按运次计:有配载单的一趟车算一个,与列表和司机端同一份聚合
|
||||
TripQueryVO query = TripQueryVO.forAdmin();
|
||||
query.setDeptIds(manageableDeptIds());
|
||||
TripTabCountsVO tripCounts = tripQueryService.tabCounts(query);
|
||||
vo.setPendingAccept(tripCounts.getPending());
|
||||
vo.setTransporting(tripCounts.getDoing());
|
||||
vo.setCompleted(tripCounts.getDone());
|
||||
vo.setException(countIncompleteExceptions());
|
||||
return vo;
|
||||
}
|
||||
@@ -144,57 +175,46 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
|
||||
Set<Long> exceptionWaybillIds = loadIncompleteExceptionWaybillIds();
|
||||
if (EXCEPTION_YES.equals(exception) && exceptionWaybillIds.isEmpty()) {
|
||||
Page<AdminWaybillCardVO> emptyVo = new Page<>(pageNo, pageSize, 0);
|
||||
emptyVo.setRecords(List.of());
|
||||
return emptyVo;
|
||||
return emptyCardPage(pageNo, pageSize);
|
||||
}
|
||||
|
||||
LambdaQueryWrapper<Waybill> wrapper = scopedWaybillQuery();
|
||||
applyStatusFilter(wrapper, status);
|
||||
applyExceptionFilter(wrapper, exception, exceptionWaybillIds);
|
||||
applyTransportTypeFilter(wrapper, transportType);
|
||||
applyKeywordFilter(wrapper, keyword);
|
||||
applyCreateTimeFilter(wrapper, startDate, endDate);
|
||||
wrapper.orderByDesc(Waybill::getCreateTime);
|
||||
|
||||
IPage<Waybill> entityPage = waybillService.page(new Page<>(pageNo, pageSize), wrapper);
|
||||
List<Long> pageIds = entityPage.getRecords().stream()
|
||||
.map(Waybill::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
Set<Long> pageExceptionIds = pageIds.isEmpty()
|
||||
? Collections.emptySet()
|
||||
: exceptionWaybillIds.stream().filter(pageIds::contains).collect(Collectors.toSet());
|
||||
|
||||
Page<AdminWaybillCardVO> voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
|
||||
voPage.setRecords(entityPage.getRecords().stream()
|
||||
.map(w -> toCard(w, pageExceptionIds.contains(w.getId())))
|
||||
.toList());
|
||||
return voPage;
|
||||
// 组织类型 / 异常 / 创建时间都是子单行级条件,与状态、关键字、分页一起交给运次读服务,
|
||||
// 一次取数取完整趟车:分组、分档、筛选、排序与分页都在它那边算完
|
||||
TripQueryVO query = TripQueryVO.forAdmin();
|
||||
query.setDeptIds(manageableDeptIds());
|
||||
query.setStatus(toTripStatus(status));
|
||||
query.setKeyword(keyword);
|
||||
query.setTransportOrgType(transportType);
|
||||
query.setException(exception);
|
||||
query.setExceptionWaybillIds(exceptionWaybillIds);
|
||||
query.setCreatedFrom(parseDayStart(startDate));
|
||||
query.setCreatedTo(parseDayEnd(endDate));
|
||||
query.setCurrent(pageNo);
|
||||
query.setSize(pageSize);
|
||||
IPage<TripVO> trips = tripQueryService.page(query);
|
||||
return tripCardPage(trips.getRecords(), pageNo, pageSize, trips.getTotal(), exceptionWaybillIds);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AdminWaybillDetailVO detail(Long id) {
|
||||
public AdminWaybillDetailVO detail(String tripId) {
|
||||
Long id = parseId(tripId);
|
||||
if (id == null) {
|
||||
throw new org.springblade.core.log.exception.ServiceException("运单ID不能为空");
|
||||
throw new ServiceException("运次不存在或无权操作");
|
||||
}
|
||||
TripVO trip = visibleTrip(tripId);
|
||||
if (trip != null) {
|
||||
return tripDetail(trip);
|
||||
}
|
||||
// 已拒单的子运单按运次取不到(运次口径不含已拒子单),此时按运单主键回落,供重新派单等从运单进入的场景使用
|
||||
Waybill waybill = waybillService.getById(id);
|
||||
if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) {
|
||||
throw new org.springblade.core.log.exception.ServiceException("运单不存在");
|
||||
if (waybill != null && !Objects.equals(waybill.getIsDeleted(), 1)) {
|
||||
return waybillDetail(waybill);
|
||||
}
|
||||
boolean hasException = false;
|
||||
Long exceptionId = null;
|
||||
ExceptionDisposal latest = exceptionDisposalService.getOne(Wrappers.<ExceptionDisposal>lambdaQuery()
|
||||
.eq(ExceptionDisposal::getWaybillId, id)
|
||||
.in(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING, DISPOSAL_PROCESSING)
|
||||
.orderByDesc(ExceptionDisposal::getReportTime)
|
||||
.orderByDesc(ExceptionDisposal::getCreateTime)
|
||||
.last("LIMIT 1"));
|
||||
if (latest != null) {
|
||||
hasException = true;
|
||||
exceptionId = latest.getId();
|
||||
LoadingManage loading = loadingManageService.getById(id);
|
||||
if (loading == null) {
|
||||
throw new ServiceException("运次不存在或无权操作");
|
||||
}
|
||||
return toDetail(waybill, hasException, exceptionId);
|
||||
return loadingDetail(loading, visibleChildren(loading.getLoadingNo()));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -202,32 +222,521 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
int pageNo = current == null || current < 1 ? 1 : current;
|
||||
int pageSize = size == null || size < 1 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE);
|
||||
|
||||
LambdaQueryWrapper<Waybill> wrapper = scopedWaybillQuery()
|
||||
.in(Waybill::getBusinessStatus, STATUS_PENDING, STATUS_RUNNING);
|
||||
if (Boolean.TRUE.equals(needReassign)) {
|
||||
wrapper.eq(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED);
|
||||
} else if (Boolean.FALSE.equals(needReassign)) {
|
||||
wrapper.and(w -> w.isNull(Waybill::getDriverAcceptStatus)
|
||||
.or().ne(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED));
|
||||
// 待重新派单必须按「子单被拒」找:运次口径把已拒子单排除在外,整单拒绝后这趟车在运次里已经不存在
|
||||
return reassignPendingPage(pageNo, pageSize, keyword);
|
||||
}
|
||||
applyKeywordFilter(wrapper, keyword);
|
||||
wrapper.orderByDesc(Waybill::getUpdateTime).orderByDesc(Waybill::getCreateTime);
|
||||
|
||||
IPage<Waybill> entityPage = waybillService.page(new Page<>(pageNo, pageSize), wrapper);
|
||||
Set<Long> exceptionWaybillIds = loadIncompleteExceptionWaybillIds();
|
||||
List<Long> pageIds = entityPage.getRecords().stream()
|
||||
.map(Waybill::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
Set<Long> pageExceptionIds = pageIds.isEmpty()
|
||||
? Collections.emptySet()
|
||||
: exceptionWaybillIds.stream().filter(pageIds::contains).collect(Collectors.toSet());
|
||||
TripQueryVO query = TripQueryVO.forAdmin();
|
||||
query.setDeptIds(manageableDeptIds());
|
||||
query.setKeyword(keyword);
|
||||
// 待处理只要待接单与进行中两档;「未拒单」也是交给运次读服务按子单行筛
|
||||
query.setStatuses(List.of(0, 1));
|
||||
query.setExcludeRejectedTrips(Boolean.FALSE.equals(needReassign));
|
||||
query.setCurrent(pageNo);
|
||||
query.setSize(pageSize);
|
||||
IPage<TripVO> trips = tripQueryService.page(query);
|
||||
return tripCardPage(trips.getRecords(), pageNo, pageSize, trips.getTotal(), exceptionWaybillIds);
|
||||
}
|
||||
|
||||
Page<AdminWaybillCardVO> voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
|
||||
voPage.setRecords(entityPage.getRecords().stream()
|
||||
.map(w -> toCard(w, pageExceptionIds.contains(w.getId())))
|
||||
.toList());
|
||||
return voPage;
|
||||
private Page<AdminWaybillCardVO> emptyCardPage(int pageNo, int pageSize) {
|
||||
Page<AdminWaybillCardVO> page = new Page<>(pageNo, pageSize, 0);
|
||||
page.setRecords(List.of());
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次卡片页:一次算清这一页的重派标记与异常,再逐条拼卡片
|
||||
*/
|
||||
private Page<AdminWaybillCardVO> tripCardPage(List<TripVO> trips, int pageNo, int pageSize, long total,
|
||||
Set<Long> exceptionWaybillIds) {
|
||||
List<AdminWaybillCardVO> cards = new ArrayList<>(trips.size());
|
||||
if (!trips.isEmpty()) {
|
||||
Set<String> reassignKeys = loadReassignTripKeys(
|
||||
trips.stream().map(this::tripKeyOf).collect(Collectors.toCollection(LinkedHashSet::new)));
|
||||
for (TripVO trip : trips) {
|
||||
cards.add(toTripCard(trip, exceptionWaybillIds, reassignKeys));
|
||||
}
|
||||
}
|
||||
Page<AdminWaybillCardVO> page = new Page<>(pageNo, pageSize, total);
|
||||
page.setRecords(cards);
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次卡片:展示单号、整趟车起/经/终与站点数、货物汇总、计划时间都取运次字段。
|
||||
* 旧字段 id / waybillNo 保留运单语义(未配载就是这张运单,配载取第一张可见子运单),
|
||||
* 打卡订正、重新派单等按运单进入的入口仍靠它们。
|
||||
*/
|
||||
private AdminWaybillCardVO toTripCard(TripVO trip, Set<Long> exceptionWaybillIds,
|
||||
Set<String> reassignTripIds) {
|
||||
AdminWaybillCardVO card = new AdminWaybillCardVO();
|
||||
List<TripVO.TripWaybillVO> children = trip.getWaybills() == null ? List.of() : trip.getWaybills();
|
||||
Long firstChildId = children.isEmpty() ? null : parseId(children.get(0).getId());
|
||||
boolean loaded = Func.isNotEmpty(trip.getLoadingNo());
|
||||
|
||||
card.setTripId(trip.getId());
|
||||
card.setTripNo(Func.toStr(trip.getTripNo(), ""));
|
||||
card.setLoadingNo(Func.toStr(trip.getLoadingNo(), ""));
|
||||
card.setId(firstChildId == null ? parseId(trip.getId()) : firstChildId);
|
||||
card.setWaybillNo(children.isEmpty() ? card.getTripNo() : Func.toStr(children.get(0).getWaybillNo(), ""));
|
||||
card.setWaybillCount(trip.getWaybillCount());
|
||||
card.setTransportMode(Func.toStr(trip.getTransportType(), ""));
|
||||
card.setTransportType(loaded ? TRANSPORT_LOAD : TRANSPORT_COMMON);
|
||||
card.setFromName(Func.toStrWithEmpty(trip.getDepartureName(), Func.toStr(trip.getDepartureAddress(), "")));
|
||||
card.setToName(Func.toStrWithEmpty(trip.getArrivalName(), Func.toStr(trip.getArrivalAddress(), "")));
|
||||
card.setViaName(Func.toStr(trip.getTransitName(), ""));
|
||||
card.setStationCount(trip.getStationCount());
|
||||
card.setCargo(Func.toStr(trip.getCargoSummary(), ""));
|
||||
card.setCargoSummary(Func.toStr(trip.getCargoSummary(), ""));
|
||||
String weight = formatWeight(trip.getTotalQuantity(), trip.getQuantityUnit());
|
||||
card.setWeight(weight);
|
||||
card.setTotalWeight(weight);
|
||||
// 计划时间两条路同源:配载运次取配载单,未配载运次取那张运单自己的
|
||||
card.setPlanTime(formatLocalDate(trip.getEstimatedStartDate()));
|
||||
card.setPlanTimeEnd(formatLocalDate(trip.getEstimatedEndDate()));
|
||||
card.setStatus(trip.getStatus());
|
||||
card.setDriverName(Func.toStr(trip.getDriverName(), ""));
|
||||
card.setVehicleNo(Func.toStr(trip.getVehicleNo(), ""));
|
||||
card.setCreateTime(formatDateTime(trip.getSortTime()));
|
||||
card.setNeedReassign(reassignTripIds.contains(tripKeyOf(trip)));
|
||||
card.setHasException(hasChildException(children.stream().map(TripVO.TripWaybillVO::getId).toList(), exceptionWaybillIds));
|
||||
card.setBuyerPaid(false);
|
||||
return card;
|
||||
}
|
||||
|
||||
/** 这一页里带「子单被拒」标记的运次主键:判据与重派待办一致(已拒单 + 未取消未完成) */
|
||||
private Set<String> loadReassignTripKeys(Set<String> tripIds) {
|
||||
if (Func.isEmpty(tripIds)) {
|
||||
return Set.of();
|
||||
}
|
||||
// 被拒子单一次取回后在服务层换算运次键
|
||||
return waybillService.list(pendingReassignQuery().select(Waybill::getId, Waybill::getLoadingNo))
|
||||
.stream()
|
||||
.map(this::tripKeyOf)
|
||||
.filter(tripIds::contains)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private boolean hasRejectedChild(String tripId) {
|
||||
return Func.isNotEmpty(tripId) && !loadReassignTripKeys(Set.of(tripId)).isEmpty();
|
||||
}
|
||||
|
||||
/** 运次里有没有未完成的异常:异常仍是运单级的事实,落到运次上按「任一子单有」算 */
|
||||
private boolean hasChildException(Collection<String> childIds, Set<Long> exceptionWaybillIds) {
|
||||
if (exceptionWaybillIds.isEmpty() || childIds.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return childIds.stream().map(this::parseId).filter(Objects::nonNull).anyMatch(exceptionWaybillIds::contains);
|
||||
}
|
||||
|
||||
/** 小程序 status → 运次状态:0待接单 / 1进行中 / 2已完成 */
|
||||
private Integer toTripStatus(String status) {
|
||||
if (Func.isBlank(status)) {
|
||||
return null;
|
||||
}
|
||||
return switch (status.trim()) {
|
||||
case "0", STATUS_PENDING -> 0;
|
||||
case "1", STATUS_RUNNING, "transporting", "doing" -> 1;
|
||||
case "2", STATUS_COMPLETED, "done" -> 2;
|
||||
case "3", STATUS_CANCELLED -> 3;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private Date parseDayStart(String date) {
|
||||
if (Func.isBlank(date)) {
|
||||
return null;
|
||||
}
|
||||
return DateUtil.parse(date.trim() + " 00:00:00", DateUtil.PATTERN_DATETIME);
|
||||
}
|
||||
|
||||
private Date parseDayEnd(String date) {
|
||||
if (Func.isBlank(date)) {
|
||||
return null;
|
||||
}
|
||||
return DateUtil.parse(date.trim() + " 23:59:59", DateUtil.PATTERN_DATETIME);
|
||||
}
|
||||
|
||||
private Long parseId(String value) {
|
||||
if (Func.isEmpty(value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(value.trim());
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 运次的键:配载取配载单号,未配载取 W + 运单主键,与运次读服务同一个口径 */
|
||||
private String tripKeyOf(Waybill waybill) {
|
||||
return waybill == null ? null : TripAggregation.keyOf(waybill.getLoadingNo(), waybill.getId());
|
||||
}
|
||||
|
||||
/** 运次的键:同上,运次卡片取运次主键换算 */
|
||||
private String tripKeyOf(TripVO trip) {
|
||||
return trip == null ? null : TripAggregation.keyOf(trip.getLoadingNo(), parseId(trip.getId()));
|
||||
}
|
||||
|
||||
/* ===========================================================================
|
||||
* 详情:按运次展示;已拒单等取不到运次的场景回落成运单视图
|
||||
* =========================================================================*/
|
||||
|
||||
/** 按运次主键取可见运次;取不到(已拒子单、不是运次主键)返回空,交给运单级回落 */
|
||||
private TripVO visibleTrip(String tripId) {
|
||||
try {
|
||||
TripQueryVO query = TripQueryVO.forAdmin();
|
||||
query.setTripId(tripId);
|
||||
return tripQueryService.detail(query);
|
||||
} catch (ServiceException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次详情:与司机端接单页同一口径(单号取配载单号,站点 ①②③ 与每站货物条目都带运单号)。
|
||||
* 打卡节点取第一张可见子运单:管理端打卡订正仍按运单走,逐子单的打卡视图不在运次这一层。
|
||||
*/
|
||||
private AdminWaybillDetailVO tripDetail(TripVO trip) {
|
||||
List<TripVO.TripWaybillVO> children = trip.getWaybills() == null ? List.of() : trip.getWaybills();
|
||||
Long firstChildId = children.isEmpty() ? null : parseId(children.get(0).getId());
|
||||
List<TripStationVO> stations = trip.getStations() == null ? List.of() : trip.getStations();
|
||||
List<Long> scopeIds = new ArrayList<>();
|
||||
children.forEach(child -> {
|
||||
Long childId = parseId(child.getId());
|
||||
if (childId != null) {
|
||||
scopeIds.add(childId);
|
||||
}
|
||||
});
|
||||
if (scopeIds.isEmpty() && parseId(trip.getId()) != null) {
|
||||
scopeIds.add(parseId(trip.getId()));
|
||||
}
|
||||
|
||||
AdminWaybillDetailVO detail = new AdminWaybillDetailVO();
|
||||
detail.setId(firstChildId == null ? parseId(trip.getId()) : firstChildId);
|
||||
detail.setWaybillNo(children.isEmpty() ? Func.toStr(trip.getTripNo(), "") : Func.toStr(children.get(0).getWaybillNo(), ""));
|
||||
detail.setStatus(trip.getStatus());
|
||||
detail.setTransportMode(Func.toStr(trip.getTransportType(), ""));
|
||||
detail.setTransportType(toTransportTypeLabel(Func.toStr(trip.getTransportType(), "")));
|
||||
detail.setTransportOrgType(Func.isEmpty(trip.getLoadingNo()) ? TRANSPORT_COMMON : TRANSPORT_LOAD);
|
||||
detail.setTripId(trip.getId());
|
||||
detail.setLoadingNo(Func.toStr(trip.getLoadingNo(), ""));
|
||||
detail.setTripNo(Func.toStr(trip.getTripNo(), ""));
|
||||
detail.setViaName(Func.toStr(trip.getTransitName(), ""));
|
||||
detail.setStationCount(trip.getStationCount());
|
||||
detail.setCargoSummary(Func.toStr(trip.getCargoSummary(), ""));
|
||||
String weight = formatWeight(trip.getTotalQuantity(), trip.getQuantityUnit());
|
||||
detail.setCargoName(Func.toStr(trip.getCargoName(), ""));
|
||||
detail.setCargoNames(trip.getCargoNames() == null ? List.of() : trip.getCargoNames());
|
||||
detail.setCargoQuantity(weight);
|
||||
detail.setWeight(weight);
|
||||
detail.setTotalWeight(weight);
|
||||
String fromAddr = Func.toStr(trip.getDepartureAddress(), "");
|
||||
String toAddr = Func.toStr(trip.getArrivalAddress(), "");
|
||||
detail.setFromName(Func.toStrWithEmpty(trip.getDepartureName(), fromAddr));
|
||||
detail.setToName(Func.toStrWithEmpty(trip.getArrivalName(), toAddr));
|
||||
detail.setFromAddress(Func.toStrWithEmpty(fromAddr, detail.getFromName()));
|
||||
detail.setToAddress(Func.toStrWithEmpty(toAddr, detail.getToName()));
|
||||
detail.setPickupAddress(detail.getFromAddress());
|
||||
detail.setUnloadAddress(detail.getToAddress());
|
||||
detail.setPlanShipTime(formatLocalDate(trip.getEstimatedStartDate()));
|
||||
detail.setPlanFinishTime(formatLocalDate(trip.getEstimatedEndDate()));
|
||||
detail.setCarrierName("");
|
||||
detail.setDriverName(Func.toStr(trip.getDriverName(), ""));
|
||||
detail.setDriverPhone(Func.toStr(trip.getDriverPhone(), ""));
|
||||
detail.setVehicleNo(Func.toStr(trip.getVehicleNo(), ""));
|
||||
detail.setRemark(Func.toStr(trip.getTaskRemark(), ""));
|
||||
detail.setNeedReassign(hasRejectedChild(tripKeyOf(trip)));
|
||||
detail.setAcceptStatus(Func.toStr(trip.getAcceptStatus(), ""));
|
||||
detail.setRejectReason("");
|
||||
detail.setStations(stations);
|
||||
detail.setRoutePoints(toRoutePoints(stations));
|
||||
fillException(detail, scopeIds);
|
||||
fillPunchSnapshot(detail, firstChildId);
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运单主键回落:重新派单、异常处置等按运单进入的场景仍按运单展示;
|
||||
* 它属于配载单时补上整趟车的展示字段(配载单号、站点 ①②③、计划时间),与列表和司机端同一口径。
|
||||
*/
|
||||
private AdminWaybillDetailVO waybillDetail(Waybill waybill) {
|
||||
Set<Long> scopeIds = new LinkedHashSet<>();
|
||||
scopeIds.add(waybill.getId());
|
||||
AdminWaybillDetailVO detail = toDetail(waybill, false, null);
|
||||
if (Func.isEmpty(waybill.getLoadingNo())) {
|
||||
fillException(detail, scopeIds);
|
||||
return detail;
|
||||
}
|
||||
List<Waybill> children = visibleChildren(waybill.getLoadingNo());
|
||||
children.forEach(child -> scopeIds.add(child.getId()));
|
||||
LoadingManage loading = findLoading(waybill.getLoadingNo());
|
||||
if (loading != null) {
|
||||
// 整趟车的子单都被拒时组里为空,站点退回配载单自己的发货/途经/到货
|
||||
List<TripStationVO> stations = TripStationAssembler.assemble(loading, children);
|
||||
detail.setTripId(String.valueOf(loading.getId()));
|
||||
detail.setLoadingNo(loading.getLoadingNo());
|
||||
detail.setTripNo(loading.getLoadingNo());
|
||||
detail.setTransportOrgType(TRANSPORT_LOAD);
|
||||
detail.setViaName(TripStationAssembler.transitName(stations));
|
||||
detail.setStationCount(stations.size());
|
||||
detail.setStations(stations);
|
||||
detail.setPlanShipTime(formatLocalDate(loading.getEstimatedStartDate()));
|
||||
detail.setPlanFinishTime(formatLocalDate(loading.getEstimatedEndDate()));
|
||||
detail.setFromName(Func.toStrWithEmpty(TripStationAssembler.departureName(stations), detail.getFromName()));
|
||||
detail.setToName(Func.toStrWithEmpty(TripStationAssembler.arrivalName(stations), detail.getToName()));
|
||||
if (Func.isEmpty(detail.getDriverName())) {
|
||||
detail.setDriverName(Func.toStr(loading.getDriverName(), ""));
|
||||
}
|
||||
if (Func.isEmpty(detail.getVehicleNo())) {
|
||||
detail.setVehicleNo(Func.toStr(loading.getVehicleNo(), ""));
|
||||
}
|
||||
}
|
||||
fillException(detail, scopeIds);
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按配载单主键取整趟车:整单被拒时子单为空,站点与计划时间仍按配载单展示
|
||||
*/
|
||||
private AdminWaybillDetailVO loadingDetail(LoadingManage loading, List<Waybill> children) {
|
||||
List<TripStationVO> stations = TripStationAssembler.assemble(loading, children);
|
||||
List<Long> scopeIds = children.stream().map(Waybill::getId).filter(Objects::nonNull).toList();
|
||||
Waybill head = children.isEmpty() ? null : children.get(0);
|
||||
|
||||
AdminWaybillDetailVO detail = new AdminWaybillDetailVO();
|
||||
detail.setId(head == null ? loading.getId() : head.getId());
|
||||
detail.setWaybillNo(head == null ? loading.getLoadingNo() : Func.toStr(head.getWaybillNo(), loading.getLoadingNo()));
|
||||
detail.setStatus(TripAggregation.appStatus(tripBusinessStatus(children)));
|
||||
detail.setTransportMode(Func.toStr(loading.getTransportType(), ""));
|
||||
detail.setTransportType(toTransportTypeLabel(Func.toStr(loading.getTransportType(), "")));
|
||||
detail.setTransportOrgType(TRANSPORT_LOAD);
|
||||
detail.setTripId(String.valueOf(loading.getId()));
|
||||
detail.setLoadingNo(loading.getLoadingNo());
|
||||
detail.setTripNo(loading.getLoadingNo());
|
||||
detail.setViaName(TripStationAssembler.transitName(stations));
|
||||
detail.setStationCount(stations.size());
|
||||
detail.setCargoName(Func.toStrWithEmpty(loading.getCargoName(), head == null ? "" : Func.toStr(head.getCargoName(), "")));
|
||||
String weight = head == null ? "" : formatWeight(head.getQuantity(), head.getQuantityUnit());
|
||||
detail.setCargoQuantity(weight);
|
||||
detail.setWeight(weight);
|
||||
detail.setTotalWeight(weight);
|
||||
detail.setFromName(TripStationAssembler.departureName(stations));
|
||||
detail.setToName(TripStationAssembler.arrivalName(stations));
|
||||
detail.setFromAddress(Func.toStr(loading.getDepartureAddress(), ""));
|
||||
detail.setToAddress(Func.toStr(loading.getArrivalAddress(), ""));
|
||||
detail.setPickupAddress(detail.getFromAddress());
|
||||
detail.setUnloadAddress(detail.getToAddress());
|
||||
detail.setPlanShipTime(formatLocalDate(loading.getEstimatedStartDate()));
|
||||
detail.setPlanFinishTime(formatLocalDate(loading.getEstimatedEndDate()));
|
||||
detail.setCarrierName("");
|
||||
detail.setDriverName(Func.toStr(loading.getDriverName(), ""));
|
||||
detail.setDriverPhone(Func.toStr(loading.getDriverPhone(), ""));
|
||||
detail.setVehicleNo(Func.toStr(loading.getVehicleNo(), ""));
|
||||
detail.setRemark(Func.toStr(loading.getTaskRemark(), ""));
|
||||
detail.setNeedReassign(true);
|
||||
detail.setAcceptStatus(Func.toStr(TripAggregation.acceptStatus(children), ""));
|
||||
detail.setRejectReason("");
|
||||
detail.setStations(stations);
|
||||
detail.setRoutePoints(toRoutePoints(stations));
|
||||
fillException(detail, scopeIds);
|
||||
fillPunchSnapshot(detail, head == null ? null : head.getId());
|
||||
return detail;
|
||||
}
|
||||
|
||||
/** 一组子运单的业务状态:全部终态取已完成/已取消,否则有待执行取待执行、其余进行中 */
|
||||
private String tripBusinessStatus(List<Waybill> children) {
|
||||
if (children.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
boolean requireAccept = TripAggregation.requiresAccept(children);
|
||||
boolean accepted = WaybillProcessSupport.ACCEPT_ACCEPTED.equals(TripAggregation.acceptStatus(children));
|
||||
return TripAggregation.statusOf(children, requireAccept, accepted);
|
||||
}
|
||||
|
||||
private List<Waybill> visibleChildren(String loadingNo) {
|
||||
if (Func.isEmpty(loadingNo)) {
|
||||
return List.of();
|
||||
}
|
||||
return waybillService.list(Wrappers.<Waybill>lambdaQuery()
|
||||
.eq(Waybill::getLoadingNo, loadingNo)
|
||||
.in(Waybill::getBusinessStatus, TripAggregation.CHILD_STATUSES)
|
||||
.and(visible -> visible.isNull(Waybill::getDriverAcceptStatus)
|
||||
.or().ne(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED))
|
||||
.orderByAsc(Waybill::getCreateTime));
|
||||
}
|
||||
|
||||
private LoadingManage findLoading(String loadingNo) {
|
||||
if (Func.isEmpty(loadingNo)) {
|
||||
return null;
|
||||
}
|
||||
return loadingManageService.getOne(Wrappers.<LoadingManage>lambdaQuery()
|
||||
.eq(LoadingManage::getLoadingNo, loadingNo)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
/** 未完成的异常处置:整趟车取最新一条,与旧单票详情同口径 */
|
||||
private void fillException(AdminWaybillDetailVO detail, Collection<Long> waybillIds) {
|
||||
ExceptionDisposal latest = latestException(waybillIds);
|
||||
detail.setHasException(latest != null);
|
||||
detail.setExceptionId(latest == null ? null : latest.getId());
|
||||
}
|
||||
|
||||
private ExceptionDisposal latestException(Collection<Long> waybillIds) {
|
||||
if (Func.isEmpty(waybillIds)) {
|
||||
return null;
|
||||
}
|
||||
return exceptionDisposalService.getOne(Wrappers.<ExceptionDisposal>lambdaQuery()
|
||||
.in(ExceptionDisposal::getWaybillId, waybillIds)
|
||||
.in(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING, DISPOSAL_PROCESSING)
|
||||
.orderByDesc(ExceptionDisposal::getReportTime)
|
||||
.orderByDesc(ExceptionDisposal::getCreateTime)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
/** 打卡节点与途打卡记录复用司机端组装;管理端打卡订正按运单走,故传第一张可见子运单 */
|
||||
private void fillPunchSnapshot(AdminWaybillDetailVO detail, Long waybillId) {
|
||||
if (waybillId == null) {
|
||||
return;
|
||||
}
|
||||
DriverWaybillCardVO punch = driverWaybillService.detailPunchSnapshot(waybillId);
|
||||
if (punch == null) {
|
||||
return;
|
||||
}
|
||||
detail.setPunchNodes(punch.getPunchNodes());
|
||||
detail.setEnrouteRecords(punch.getEnrouteRecords());
|
||||
if (punch.getRoutePoints() != null && !punch.getRoutePoints().isEmpty()) {
|
||||
detail.setRoutePoints(punch.getRoutePoints().stream().map(p -> {
|
||||
AdminWaybillDetailVO.AdminRoutePointVO rp = new AdminWaybillDetailVO.AdminRoutePointVO();
|
||||
rp.setName(p.getName());
|
||||
rp.setAddress(p.getAddress());
|
||||
rp.setStatus(p.getStatus());
|
||||
return rp;
|
||||
}).toList());
|
||||
}
|
||||
}
|
||||
|
||||
/** 点位列:整趟车的全部站点,站序即途经顺序 */
|
||||
private List<AdminWaybillDetailVO.AdminRoutePointVO> toRoutePoints(List<TripStationVO> stations) {
|
||||
if (Func.isEmpty(stations)) {
|
||||
return List.of();
|
||||
}
|
||||
return stations.stream().map(station -> {
|
||||
AdminWaybillDetailVO.AdminRoutePointVO point = new AdminWaybillDetailVO.AdminRoutePointVO();
|
||||
point.setName(Func.toStr(station.getName(), ""));
|
||||
point.setAddress(Func.toStrWithEmpty(station.getAddress(), point.getName()));
|
||||
point.setStatus("pending");
|
||||
return point;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 待重新派单列表:按库层分组把「同一配载单被拒的多张子单」收成一条,未配载运单各自一条。
|
||||
* 分页与总数都在库层按运次完成,避免整单拒绝后同一趟车重复出现。
|
||||
*/
|
||||
private IPage<AdminWaybillCardVO> reassignPendingPage(int pageNo, int pageSize, String keyword) {
|
||||
QueryWrapper<Waybill> wrapper = reassignGroupQuery();
|
||||
applyGroupKeyword(wrapper, keyword);
|
||||
wrapper.orderByDesc(SQL_MAX_REJECT_TIME);
|
||||
|
||||
Page<Waybill> rows = new Page<>(pageNo, pageSize);
|
||||
waybillService.page(rows, wrapper);
|
||||
|
||||
Set<Long> exceptionWaybillIds = loadIncompleteExceptionWaybillIds();
|
||||
Page<AdminWaybillCardVO> page = new Page<>(pageNo, pageSize, rows.getTotal());
|
||||
page.setRecords(toReassignCards(rows.getRecords(), exceptionWaybillIds));
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* 库层按运次分组的「待重新派单」查询:一条即一个运次,段二不用再回查子单。
|
||||
* 展示用字段一律包聚合函数取组内代表值,既满足 ONLY_FULL_GROUP_BY,也让一条待办自带它要显示的信息。
|
||||
*/
|
||||
private QueryWrapper<Waybill> reassignGroupQuery() {
|
||||
// 分组键只在 TripAggregation 声明一次:库层分组与服务层分组的「运次」必须是同一个东西
|
||||
QueryWrapper<Waybill> wrapper = Wrappers.query();
|
||||
wrapper.select("MIN(id) AS id", "MIN(NULLIF(loading_no, '')) AS loading_no", "MIN(waybill_no) AS waybill_no",
|
||||
"MIN(transport_type) AS transport_type", "MIN(business_status) AS business_status",
|
||||
"MIN(departure_name) AS departure_name", "MIN(arrival_name) AS arrival_name",
|
||||
"MIN(cargo_name) AS cargo_name", "MIN(quantity) AS quantity", "MIN(quantity_unit) AS quantity_unit",
|
||||
"MIN(estimated_start_time) AS estimated_start_time", "MIN(estimated_end_time) AS estimated_end_time",
|
||||
"MIN(driver_name) AS driver_name", "MIN(vehicle_no) AS vehicle_no",
|
||||
"MIN(driver_reject_reason) AS driver_reject_reason",
|
||||
SQL_MAX_REJECT_TIME + " AS driver_reject_time", SQL_MAX_CREATE_TIME + " AS create_time");
|
||||
wrapper.eq("driver_accept_status", ACCEPT_REJECTED);
|
||||
wrapper.in("business_status", STATUS_PENDING, STATUS_RUNNING);
|
||||
// 这条查询不经运次读服务,组织收口得自己带上,否则待重派列表与首页重派角标会出别的组织的单
|
||||
List<Long> deptIds = manageableDeptIds();
|
||||
if (Func.isNotEmpty(deptIds)) {
|
||||
wrapper.in("dept_id", deptIds);
|
||||
}
|
||||
wrapper.groupBy(TripAggregation.SQL_TRIP_KEY);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/** 一条待办 = 一个运次:整趟车取配载单的单号与站点,未配载就是这张运单本身 */
|
||||
private List<AdminWaybillCardVO> toReassignCards(List<Waybill> rows, Set<Long> exceptionWaybillIds) {
|
||||
if (Func.isEmpty(rows)) {
|
||||
return List.of();
|
||||
}
|
||||
Set<String> loadingNos = rows.stream().map(Waybill::getLoadingNo).filter(Func::isNotEmpty)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
Map<String, LoadingManage> loadings = loadingsByNo(loadingNos);
|
||||
|
||||
List<AdminWaybillCardVO> cards = new ArrayList<>(rows.size());
|
||||
for (Waybill row : rows) {
|
||||
String loadingNo = Func.toStr(row.getLoadingNo(), "");
|
||||
AdminWaybillCardVO card = toCard(row, hasExceptionIds(row, exceptionWaybillIds));
|
||||
card.setTripId(String.valueOf(Func.isEmpty(loadingNo) ? row.getId() : loadingId(loadings.get(loadingNo), row.getId())));
|
||||
card.setTripNo(Func.isEmpty(loadingNo) ? Func.toStr(row.getWaybillNo(), "") : loadingNo);
|
||||
card.setLoadingNo(loadingNo);
|
||||
card.setWaybillCount(1);
|
||||
card.setCargoSummary(Func.toStr(row.getCargoName(), ""));
|
||||
card.setTotalWeight(formatWeight(row.getQuantity(), row.getQuantityUnit()));
|
||||
card.setNeedReassign(true);
|
||||
if (Func.isNotEmpty(loadingNo)) {
|
||||
List<TripStationVO> stations = TripStationAssembler.assemble(loadings.get(loadingNo), List.of());
|
||||
card.setTransportType(TRANSPORT_LOAD);
|
||||
card.setFromName(TripStationAssembler.departureName(stations));
|
||||
card.setToName(TripStationAssembler.arrivalName(stations));
|
||||
card.setViaName(TripStationAssembler.transitName(stations));
|
||||
card.setStationCount(stations.size());
|
||||
} else {
|
||||
card.setTransportType(TRANSPORT_COMMON);
|
||||
card.setStationCount(2);
|
||||
}
|
||||
card.setCreateTime(formatDateTime(row.getCreateTime()));
|
||||
cards.add(card);
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
|
||||
private Map<String, LoadingManage> loadingsByNo(Set<String> loadingNos) {
|
||||
if (loadingNos.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
return loadingManageService.list(Wrappers.<LoadingManage>lambdaQuery().in(LoadingManage::getLoadingNo, loadingNos))
|
||||
.stream()
|
||||
.collect(Collectors.toMap(LoadingManage::getLoadingNo, Function.identity(), (first, ignored) -> first));
|
||||
}
|
||||
|
||||
private Long loadingId(LoadingManage loading, Long fallback) {
|
||||
return loading == null || loading.getId() == null ? fallback : loading.getId();
|
||||
}
|
||||
|
||||
/** 分组查询的关键字:运单号 / 司机 / 车牌,与列表关键字口径一致 */
|
||||
private void applyGroupKeyword(QueryWrapper<Waybill> wrapper, String keyword) {
|
||||
if (Func.isBlank(keyword)) {
|
||||
return;
|
||||
}
|
||||
String key = keyword.trim();
|
||||
wrapper.and(w -> w.like("waybill_no", key).or().like("driver_name", key).or().like("vehicle_no", key));
|
||||
}
|
||||
|
||||
private boolean hasExceptionIds(Waybill waybill, Set<Long> exceptionWaybillIds) {
|
||||
return waybill != null && waybill.getId() != null && exceptionWaybillIds.contains(waybill.getId());
|
||||
}
|
||||
|
||||
private AdminWaybillDetailVO toDetail(Waybill waybill, boolean hasException, Long exceptionId) {
|
||||
@@ -250,9 +759,12 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
detail.setPickupAddress(fromAddr);
|
||||
detail.setUnloadAddress(toAddr);
|
||||
|
||||
String cargo = Func.toStr(waybill.getCargoName(), "");
|
||||
String weight = formatWeight(waybill.getQuantity(), waybill.getQuantityUnit());
|
||||
detail.setCargoName(cargo);
|
||||
// 货名与合计重量取运单上录入的全部货物:主表 cargo_name 只是首行、quantity 是各行合计而单位只是首行的
|
||||
List<WaybillCargoSupport.Cargo> cargos = WaybillCargoSupport.of(waybill);
|
||||
List<String> cargoNames = WaybillCargoSupport.cargoNames(cargos);
|
||||
detail.setCargoName(String.join("、", cargoNames));
|
||||
detail.setCargoNames(cargoNames);
|
||||
String weight = cargoWeight(cargos, formatWeight(waybill.getQuantity(), waybill.getQuantityUnit()));
|
||||
detail.setCargoQuantity(weight);
|
||||
detail.setWeight(weight);
|
||||
detail.setTotalWeight(weight);
|
||||
@@ -371,82 +883,33 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
};
|
||||
}
|
||||
|
||||
private long countWaybillByStatus(String status) {
|
||||
return waybillService.count(Wrappers.<Waybill>lambdaQuery()
|
||||
.eq(Waybill::getBusinessStatus, status));
|
||||
/**
|
||||
* 调度端可见组织:普通用户(含各级角色)只出自己所属组织,超级管理员不过滤。
|
||||
* <p>
|
||||
* 小程序不校验「所属组织不能为空」:账号没有组织时不按组织收口,避免首页、待处理等接口直接报错。
|
||||
* 详情与重新派单本身不走这里。
|
||||
*/
|
||||
private List<Long> manageableDeptIds() {
|
||||
if (TransportBusinessSupport.isAdministrator()) {
|
||||
return List.of();
|
||||
}
|
||||
Long deptId = Func.firstLong(AuthUtil.getDeptId());
|
||||
if (Func.isEmpty(deptId) || deptId <= 0) {
|
||||
return List.of();
|
||||
}
|
||||
return List.of(deptId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序调度端不做组织过滤。
|
||||
* 「小程序管理」等账号 JWT/档案 dept 常与运单业务组织不一致,按 dept 过滤会导致统计全 0;
|
||||
* 与后台 allDept=1 一致,仅依赖租户隔离(MyBatis-Plus TenantLine)。
|
||||
* 已拒子单的查询底:只有租户与逻辑删除(MyBatis-Plus 自动补),不带组织。
|
||||
* <p>
|
||||
* 它服务的是「这个运次里有没有被拒的子单」,可见范围由调用方先定好——列表传的运次已经收过组织,
|
||||
* 详情按主键进入不收组织。这里再按组织收一次,详情里别的组织的运次会丢掉重派标记。
|
||||
*/
|
||||
private LambdaQueryWrapper<Waybill> scopedWaybillQuery() {
|
||||
private LambdaQueryWrapper<Waybill> rejectedChildQuery() {
|
||||
return Wrappers.<Waybill>lambdaQuery();
|
||||
}
|
||||
|
||||
private void applyStatusFilter(LambdaQueryWrapper<Waybill> wrapper, String status) {
|
||||
String businessStatus = toBusinessStatus(status);
|
||||
if (Func.isNotBlank(businessStatus)) {
|
||||
wrapper.eq(Waybill::getBusinessStatus, businessStatus);
|
||||
}
|
||||
}
|
||||
|
||||
private void applyExceptionFilter(LambdaQueryWrapper<Waybill> wrapper, String exception, Set<Long> exceptionWaybillIds) {
|
||||
if (EXCEPTION_YES.equals(exception)) {
|
||||
wrapper.in(Waybill::getId, exceptionWaybillIds);
|
||||
} else if (EXCEPTION_NO.equals(exception) && !exceptionWaybillIds.isEmpty()) {
|
||||
wrapper.notIn(Waybill::getId, exceptionWaybillIds);
|
||||
}
|
||||
}
|
||||
|
||||
private void applyTransportTypeFilter(LambdaQueryWrapper<Waybill> wrapper, String transportType) {
|
||||
if (TRANSPORT_LOAD.equals(transportType)) {
|
||||
wrapper.isNotNull(Waybill::getLoadingNo).ne(Waybill::getLoadingNo, "");
|
||||
} else if (TRANSPORT_COMMON.equals(transportType)) {
|
||||
wrapper.and(w -> w.isNull(Waybill::getLoadingNo).or().eq(Waybill::getLoadingNo, ""));
|
||||
}
|
||||
}
|
||||
|
||||
private void applyKeywordFilter(LambdaQueryWrapper<Waybill> wrapper, String keyword) {
|
||||
if (Func.isBlank(keyword)) {
|
||||
return;
|
||||
}
|
||||
String key = keyword.trim();
|
||||
wrapper.and(w -> w.like(Waybill::getWaybillNo, key)
|
||||
.or().like(Waybill::getDriverName, key)
|
||||
.or().like(Waybill::getVehicleNo, key));
|
||||
}
|
||||
|
||||
private void applyCreateTimeFilter(LambdaQueryWrapper<Waybill> wrapper, String startDate, String endDate) {
|
||||
if (Func.isNotBlank(startDate)) {
|
||||
Date start = DateUtil.parse(startDate.trim() + " 00:00:00", DateUtil.PATTERN_DATETIME);
|
||||
if (start != null) {
|
||||
wrapper.ge(Waybill::getCreateTime, start);
|
||||
}
|
||||
}
|
||||
if (Func.isNotBlank(endDate)) {
|
||||
Date end = DateUtil.parse(endDate.trim() + " 23:59:59", DateUtil.PATTERN_DATETIME);
|
||||
if (end != null) {
|
||||
wrapper.le(Waybill::getCreateTime, end);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 小程序 status → 后端 businessStatus */
|
||||
private String toBusinessStatus(String status) {
|
||||
if (Func.isBlank(status)) {
|
||||
return null;
|
||||
}
|
||||
return switch (status.trim()) {
|
||||
case "0", STATUS_PENDING -> STATUS_PENDING;
|
||||
case "1", STATUS_RUNNING, "transporting", "doing" -> STATUS_RUNNING;
|
||||
case "2", STATUS_COMPLETED, "done" -> STATUS_COMPLETED;
|
||||
case "3", STATUS_CANCELLED -> STATUS_CANCELLED;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private Set<Long> loadIncompleteExceptionWaybillIds() {
|
||||
List<ExceptionDisposal> list = exceptionDisposalService.list(Wrappers.<ExceptionDisposal>lambdaQuery()
|
||||
.select(ExceptionDisposal::getWaybillId)
|
||||
@@ -546,6 +1009,33 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
return Func.isBlank(unit) ? qty : qty + unit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重量文案:全部货物同单位时按货物行相加,单位不一致时不出合计
|
||||
* <p>
|
||||
* 主表 quantity 是各行数量的合计、quantity_unit 只是首行的单位,「20堆 + 50方」会变成「70堆」。
|
||||
* 拿明细算不出合计(有货物没填数量或单位)时退回主表那份汇总值,不让重量凭空消失。
|
||||
*/
|
||||
private String cargoWeight(List<WaybillCargoSupport.Cargo> cargos, String fallback) {
|
||||
if (cargos.isEmpty()) {
|
||||
return fallback;
|
||||
}
|
||||
String unit = null;
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (WaybillCargoSupport.Cargo cargo : cargos) {
|
||||
String cargoUnit = Func.toStr(cargo.quantityUnit(), "");
|
||||
if (cargo.quantity() == null || Func.isEmpty(cargoUnit)) {
|
||||
return fallback;
|
||||
}
|
||||
if (unit == null) {
|
||||
unit = cargoUnit;
|
||||
} else if (!unit.equals(cargoUnit)) {
|
||||
return "";
|
||||
}
|
||||
total = total.add(cargo.quantity());
|
||||
}
|
||||
return formatWeight(total, unit);
|
||||
}
|
||||
|
||||
private String formatLocalDate(LocalDate date) {
|
||||
if (date == null) {
|
||||
return "";
|
||||
@@ -570,13 +1060,20 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
.eq(RiskDisposal::getDisposalStatus, RISK_PENDING));
|
||||
}
|
||||
|
||||
/** 待重新派单:司机已拒单,且运单尚未取消/完成 */
|
||||
/**
|
||||
* 待重新派单的运次数:整单拒绝会把一张配载单的多张子运单一起写成已拒绝,按运次键去重后只算一个。
|
||||
* <p>
|
||||
* 这里不走运次读服务:它的可见口径已排除被拒子单,整单拒绝的配载单在运次里根本不存在;
|
||||
* 分组键用与运次读服务同一个表达式,两处的运次才是同一个东西。
|
||||
*/
|
||||
private long countPendingReassignments() {
|
||||
return waybillService.count(pendingReassignQuery());
|
||||
Page<Waybill> rows = new Page<>(1, 1);
|
||||
waybillService.page(rows, reassignGroupQuery());
|
||||
return rows.getTotal();
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<Waybill> pendingReassignQuery() {
|
||||
return Wrappers.<Waybill>lambdaQuery()
|
||||
return rejectedChildQuery()
|
||||
.eq(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED)
|
||||
.in(Waybill::getBusinessStatus, STATUS_PENDING, STATUS_RUNNING);
|
||||
}
|
||||
@@ -611,11 +1108,17 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 待重新派单的待办:一条 = 一个运次。
|
||||
* <p>
|
||||
* 整单拒绝会把一张配载单的多张子运单一起写成已拒绝,按运次键分组后同一趟车只出 1 条;
|
||||
* 未配载运单自成运次,各自一条。排序与取值都在库层按运次完成,与待处理列表、角标同一口径。
|
||||
*/
|
||||
private List<TimedTodo> buildReassignFeed() {
|
||||
List<Waybill> list = waybillService.list(pendingReassignQuery()
|
||||
.orderByDesc(Waybill::getDriverRejectTime)
|
||||
List<Waybill> rows = waybillService.list(reassignGroupQuery()
|
||||
.orderByDesc(SQL_MAX_REJECT_TIME)
|
||||
.last("LIMIT " + FEED_LIMIT));
|
||||
return list.stream()
|
||||
return rows.stream()
|
||||
.map(waybill -> new TimedTodo(toReassignTodoItem(waybill), rejectTimeOf(waybill)))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
@@ -649,7 +1152,8 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
item.setTitle("重新派单待处理");
|
||||
item.setTimeAgo(formatTimeAgo(rejectTimeOf(waybill)));
|
||||
item.setDesc(buildReassignDesc(waybill));
|
||||
item.setWaybillNo(Func.toStr(waybill.getWaybillNo(), ""));
|
||||
// 一条待办即一个运次:有配载单的展示配载单号;跳转参数仍是运单主键,该页据此回显并直接重派
|
||||
item.setWaybillNo(Func.toStrWithEmpty(waybill.getLoadingNo(), Func.toStr(waybill.getWaybillNo(), "")));
|
||||
item.setActionLabel("重新派单");
|
||||
item.setTargetUrl(String.format(REASSIGN_TARGET_URL, waybill.getId()));
|
||||
return item;
|
||||
|
||||
+890
-76
File diff suppressed because it is too large
Load Diff
+265
-58
@@ -36,6 +36,7 @@ import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.jackson.JsonUtil;
|
||||
import org.springblade.core.tool.utils.BeanUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.constant.TransportBusiConstant;
|
||||
import org.springblade.transport.mapper.PreSettlementAdvanceMapper;
|
||||
import org.springblade.transport.mapper.PreSettlementChangeRecordMapper;
|
||||
import org.springblade.transport.mapper.PreSettlementDetailFeeMapper;
|
||||
@@ -67,6 +68,7 @@ import org.springblade.transport.service.IContractManageService;
|
||||
import org.springblade.transport.service.ICustomerArchiveService;
|
||||
import org.springblade.transport.service.IPreSettlementService;
|
||||
import org.springblade.transport.service.IProjectApplyService;
|
||||
import org.springblade.transport.service.ISettlementProjectAdvanceWriteOffService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.wrapper.PreSettlementWrapper;
|
||||
import org.springblade.system.cache.UserCache;
|
||||
@@ -83,7 +85,6 @@ import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
@@ -124,6 +125,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
private final ICustomerArchiveService customerArchiveService;
|
||||
private final IWaybillService waybillService;
|
||||
private final ISysClient sysClient;
|
||||
private final ISettlementProjectAdvanceWriteOffService projectAdvanceWriteOffService;
|
||||
|
||||
@Override
|
||||
public IPage<PreSettlementVO> selectPage(IPage<PreSettlement> page, PreSettlementVO query) {
|
||||
@@ -163,7 +165,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
Map<String, String> categoryNames = DictBizCache.getList("fee_category").stream()
|
||||
.collect(Collectors.toMap(DictBiz::getDictKey, DictBiz::getDictValue, (first, second) -> first,
|
||||
LinkedHashMap::new));
|
||||
org.springblade.core.tool.api.R<List<FeeItem>> response = sysClient.getFeeItems();
|
||||
org.springblade.core.tool.api.R<List<FeeItem>> response = sysClient.getFeeItems(false);
|
||||
if (response == null || !response.isSuccess() || response.getData() == null) {
|
||||
throw new ServiceException("费用项基础档案读取失败,请稍后重试");
|
||||
}
|
||||
@@ -184,6 +186,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
PreSettlementVO vo = PreSettlementWrapper.build().entityVO(settlement);
|
||||
fillPaymentApplicationAmounts(List.of(vo));
|
||||
List<PreSettlementDetail> details = listDetails(id);
|
||||
fillDetailBillingTypes(details);
|
||||
vo.setDetails(details);
|
||||
List<Long> detailIds = details.stream().map(PreSettlementDetail::getId).toList();
|
||||
Map<Long, List<PreSettlementDetailFee>> feeMap = Func.isEmpty(detailIds) ? new HashMap<>()
|
||||
@@ -198,14 +201,24 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
List<PreSettlementAdvance> advances = listAdvances(id);
|
||||
advances.forEach(advance -> advance.setCreateUserName(UserCache.getUserRealName(advance.getCreateUser())));
|
||||
vo.setAdvances(advances);
|
||||
vo.setProjectAdvanceWriteOffs(projectAdvanceWriteOffService.listBySettlement(
|
||||
TransportBusiConstant.SETTLEMENT_BILL_PRE, id));
|
||||
vo.setChangeRecords(changeRecordMapper.selectList(Wrappers.<PreSettlementChangeRecord>lambdaQuery()
|
||||
.eq(PreSettlementChangeRecord::getPreSettlementId, id)
|
||||
.eq(PreSettlementChangeRecord::getIsDeleted, 0)
|
||||
.and(wrapper -> wrapper.isNull(PreSettlementChangeRecord::getChangeContent)
|
||||
.or()
|
||||
.ne(PreSettlementChangeRecord::getChangeContent, "预结算审批通过"))
|
||||
.orderByDesc(PreSettlementChangeRecord::getChangeTime)));
|
||||
vo.setPrintTemplates(resolvePrintTemplates(settlement));
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String nextNo() {
|
||||
return nextPreSettlementNo();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> contractOptions(String keyword, Long projectId) {
|
||||
LambdaQueryWrapper<ContractManage> wrapper = Wrappers.<ContractManage>lambdaQuery()
|
||||
@@ -297,7 +310,9 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
IPage<ReceivablePayableDetail> sourcePage = sourceDetailMapper.selectPage(
|
||||
new Page<>(page.getCurrent(), page.getSize()), wrapper);
|
||||
Page<Map<String, Object>> result = new Page<>(sourcePage.getCurrent(), sourcePage.getSize(), sourcePage.getTotal());
|
||||
result.setRecords(sourcePage.getRecords().stream().map(this::candidateMap).toList());
|
||||
List<Map<String, Object>> records = sourcePage.getRecords().stream().map(this::candidateMap).toList();
|
||||
fillCandidateBillingTypes(records);
|
||||
result.setRecords(records);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -343,9 +358,17 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
synchronizeDetails(settlement, request.getSourceDetailIds(), Boolean.TRUE.equals(request.getAllowSourceMismatch()));
|
||||
}
|
||||
settlement.setExchangeRate(normalizeRate(request.getExchangeRate(), settlement.getCurrency()));
|
||||
List<PreSettlementSummaryFee> summaryBefore = created ? new ArrayList<>() : listSummaryFees(settlement.getId());
|
||||
rebuildSummaryFees(settlement.getId(), true);
|
||||
applySummaryRequest(settlement.getId(), contractChanged ? List.of() : request.getSummaryFees());
|
||||
recordSummaryFeeChanges(settlement.getId(), summaryBefore);
|
||||
refreshSettlementAmount(settlement);
|
||||
projectAdvanceWriteOffService.replaceWriteOffs(
|
||||
TransportBusiConstant.SETTLEMENT_BILL_PRE,
|
||||
settlement.getId(),
|
||||
settlement.getContractId(),
|
||||
settlement.getSettlementAmount(),
|
||||
request.getProjectAdvanceWriteOffs());
|
||||
if (created) {
|
||||
saveChange(settlement.getId(), "结算单基本信息", null, "新增",
|
||||
"新增预结算单" + settlement.getPreSettlementNo(), "");
|
||||
@@ -362,6 +385,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
}
|
||||
releaseSourceDetails(settlement, listDetails(id));
|
||||
deleteChildren(id);
|
||||
projectAdvanceWriteOffService.deleteBySettlement(TransportBusiConstant.SETTLEMENT_BILL_PRE, id);
|
||||
deleteLogic(List.of(id));
|
||||
}
|
||||
|
||||
@@ -406,7 +430,6 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
settlement.setCurrentProcessor(AuthUtil.getUserName());
|
||||
settlement.setApprovedTime(LocalDateTime.now());
|
||||
updateById(settlement);
|
||||
saveChange(settlement.getId(), "结算单基本信息", null, "调整", "预结算审批通过", request.getReason());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -444,6 +467,9 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
settlement.setCurrentProcessor(AuthUtil.getUserName());
|
||||
settlement.setVoidReason(reason);
|
||||
updateById(settlement);
|
||||
// 联动删除项目预付核销行,避免残留记录与可核销占用
|
||||
this.projectAdvanceWriteOffService.deleteBySettlementIds(
|
||||
TransportBusiConstant.SETTLEMENT_BILL_PRE, List.of(settlement.getId()));
|
||||
releaseSourceDetails(settlement, listDetails(settlement.getId()));
|
||||
saveChange(settlement.getId(), "结算单基本信息", null, "调整", "作废预结算单", reason);
|
||||
}
|
||||
@@ -599,7 +625,9 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
if (Func.isEmpty(request.getRows())) {
|
||||
throw new ServiceException("请填写需要调整的费用行");
|
||||
}
|
||||
String changeReason = limitRemark(request.getChangeReason(), 200);
|
||||
// 弹窗底部备注非必填,写入结算明细备注
|
||||
String detailRemark = limitRemark(request.getChangeReason(), 200);
|
||||
String beforeDetailRemark = detail.getRemark();
|
||||
Map<Long, PreSettlementDetailFee> existingMap = detailFees(detail.getId()).stream()
|
||||
.collect(Collectors.toMap(PreSettlementDetailFee::getId, Function.identity()));
|
||||
if (request.getRows().size() != existingMap.size()) {
|
||||
@@ -619,7 +647,6 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
BigDecimal beforeFreight = fee.getFreightAmount();
|
||||
BigDecimal beforeAmount = money(fee.getSettlementAmountTax());
|
||||
BigDecimal beforeNoTaxAmount = fee.getSettlementAmountNoTax();
|
||||
String beforeRemark = fee.getRemark();
|
||||
Map<String, BigDecimal> beforeFeeItems = parseFeeItems(fee.getFeeItemsJson());
|
||||
fee.setTransportQuantity(nonNegative(requestRow.getTransportQuantity(), "运输总量"));
|
||||
fee.setMileage(nonNegative(requestRow.getMileage(), "里程"));
|
||||
@@ -632,7 +659,6 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
fee.setSettlementAmountNoTax(requestRow.getSettlementAmountNoTax() == null ? null
|
||||
: money(requestRow.getSettlementAmountNoTax()));
|
||||
fee.setAdjustAmount(afterAmount.subtract(money(fee.getOriginalAmount())));
|
||||
fee.setRemark(limitRemark(requestRow.getRemark(), 200));
|
||||
detailFeeMapper.updateById(fee);
|
||||
String prefix = "【" + firstNotEmpty(fee.getCargoName(), fee.getLineNo()) + "】";
|
||||
appendChange(changes, beforeData, afterData, prefix + "运输总量", beforeQuantity, fee.getTransportQuantity());
|
||||
@@ -647,16 +673,19 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
appendChange(changes, beforeData, afterData, prefix + "结算金额(含税)", beforeAmount, afterAmount);
|
||||
appendChange(changes, beforeData, afterData, prefix + "结算金额(不含税)", beforeNoTaxAmount,
|
||||
fee.getSettlementAmountNoTax());
|
||||
appendTextChange(changes, beforeData, afterData, prefix + "备注", beforeRemark, fee.getRemark());
|
||||
}
|
||||
appendTextChange(changes, beforeData, afterData, "备注", beforeDetailRemark, detailRemark);
|
||||
if (changes.isEmpty()) {
|
||||
throw new ServiceException("未修改任何结算明细费用");
|
||||
}
|
||||
refreshDetail(detail);
|
||||
// refreshDetail 会按费用行回填备注,需在其后覆盖为弹窗底部备注
|
||||
detail.setRemark(detailRemark);
|
||||
detailMapper.updateById(detail);
|
||||
rebuildSummaryFees(settlement.getId(), true);
|
||||
refreshSettlementAmount(settlement);
|
||||
saveChange(settlement.getId(), "结算明细项", detail.getLineNo(), "调整",
|
||||
String.join(";", changes), changeReason, beforeData, afterData);
|
||||
String.join(";", changes), detailRemark, beforeData, afterData);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -735,6 +764,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
result.put("quantityUnit", source.getQuantityUnit());
|
||||
result.put("mileage", source.getMileage());
|
||||
result.put("batchNo", source.getBatchNo());
|
||||
result.put("billingType", "");
|
||||
result.put("unitPrice", source.getUnitPrice());
|
||||
result.put("freightAmount", money(source.getFreightAmount()));
|
||||
result.put("feeItemsJson", source.getFeeItemsJson());
|
||||
@@ -748,6 +778,35 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 候选明细补充计费方式(货物费用计费类型去重汇总) */
|
||||
private void fillCandidateBillingTypes(List<Map<String, Object>> records) {
|
||||
if (records == null || records.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<Long> detailIds = records.stream()
|
||||
.map(item -> Func.toLong(item.get("id")))
|
||||
.filter(id -> id != null && id > 0)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (detailIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<Long, String> billingTypeMap = sourceFeeMapper.selectList(
|
||||
Wrappers.<ReceivablePayableCargoFee>lambdaQuery()
|
||||
.in(ReceivablePayableCargoFee::getDetailId, detailIds)
|
||||
.orderByAsc(ReceivablePayableCargoFee::getLineNo))
|
||||
.stream()
|
||||
.filter(fee -> Func.isNotEmpty(fee.getBillingType()) && !"-".equals(fee.getBillingType()))
|
||||
.collect(Collectors.groupingBy(ReceivablePayableCargoFee::getDetailId,
|
||||
Collectors.mapping(ReceivablePayableCargoFee::getBillingType,
|
||||
Collectors.collectingAndThen(Collectors.toCollection(LinkedHashSet::new),
|
||||
values -> String.join("、", values)))));
|
||||
for (Map<String, Object> record : records) {
|
||||
Long detailId = Func.toLong(record.get("id"));
|
||||
record.put("billingType", billingTypeMap.getOrDefault(detailId, ""));
|
||||
}
|
||||
}
|
||||
|
||||
private void synchronizeDetails(PreSettlement settlement, List<Long> requestedIds, boolean allowSourceMismatch) {
|
||||
List<Long> distinctIds = requestedIds.stream().filter(Objects::nonNull).distinct().toList();
|
||||
List<PreSettlementDetail> existingDetails = listDetails(settlement.getId());
|
||||
@@ -984,13 +1043,30 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
summaryFeeMapper.delete(Wrappers.<PreSettlementSummaryFee>lambdaQuery()
|
||||
.eq(PreSettlementSummaryFee::getPreSettlementId, settlementId)
|
||||
.eq(PreSettlementSummaryFee::getManualFlag, 0));
|
||||
int lineNo = 1;
|
||||
for (Map.Entry<String, BigDecimal> entry : aggregates.entrySet()) {
|
||||
// 尽量沿用已有系统归集行的序号,避免重建后与手工费用交错顺序被打乱。
|
||||
List<Map.Entry<String, BigDecimal>> orderedAggregates = new ArrayList<>(aggregates.entrySet());
|
||||
orderedAggregates.sort((left, right) -> {
|
||||
PreSettlementSummaryFee leftOld = existingGenerated.get(left.getKey());
|
||||
PreSettlementSummaryFee rightOld = existingGenerated.get(right.getKey());
|
||||
Integer leftLineNo = leftOld == null ? null : leftOld.getLineNo();
|
||||
Integer rightLineNo = rightOld == null ? null : rightOld.getLineNo();
|
||||
if (leftLineNo == null && rightLineNo == null) {
|
||||
return 0;
|
||||
}
|
||||
if (leftLineNo == null) {
|
||||
return 1;
|
||||
}
|
||||
if (rightLineNo == null) {
|
||||
return -1;
|
||||
}
|
||||
return Integer.compare(leftLineNo, rightLineNo);
|
||||
});
|
||||
for (Map.Entry<String, BigDecimal> entry : orderedAggregates) {
|
||||
String[] keyParts = entry.getKey().split("\\|", 2);
|
||||
PreSettlementSummaryFee old = existingGenerated.get(entry.getKey());
|
||||
PreSettlementSummaryFee row = new PreSettlementSummaryFee();
|
||||
row.setPreSettlementId(settlementId);
|
||||
row.setLineNo(lineNo++);
|
||||
row.setLineNo(old == null || old.getLineNo() == null ? Integer.MAX_VALUE : old.getLineNo());
|
||||
row.setFeeType(keyParts[0]);
|
||||
row.setFeeItem(keyParts.length > 1 ? keyParts[1] : "");
|
||||
// 原金额是明细首次生成时的快照,多次调整后不得被当前结算金额覆盖。
|
||||
@@ -1006,7 +1082,14 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
}
|
||||
|
||||
private void applySummaryRequest(Long settlementId, List<PreSettlementSaveRequest.SummaryFee> requestRows) {
|
||||
if (requestRows == null) return;
|
||||
if (requestRows == null) {
|
||||
return;
|
||||
}
|
||||
// 空列表表示沿用 rebuildSummaryFees 结果(如更换合同后),不得再按空请求删光合计行。
|
||||
if (requestRows.isEmpty()) {
|
||||
renumberSummaryFees(settlementId);
|
||||
return;
|
||||
}
|
||||
Map<String, Set<String>> allowedManualFees = new LinkedHashMap<>();
|
||||
if (requestRows.stream().anyMatch(row -> Integer.valueOf(1).equals(row.getManualFlag()))) {
|
||||
for (Map<String, Object> option : feeOptions()) {
|
||||
@@ -1026,6 +1109,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
List<PreSettlementSummaryFee> availableGeneratedRows = new ArrayList<>(existingGeneratedRows);
|
||||
Set<Long> retainedManualIds = new LinkedHashSet<>();
|
||||
Set<Long> retainedGeneratedIds = new LinkedHashSet<>();
|
||||
int lineNo = 1;
|
||||
for (PreSettlementSaveRequest.SummaryFee requestRow : requestRows) {
|
||||
if (Integer.valueOf(1).equals(requestRow.getManualFlag())) {
|
||||
String feeType = requiredText(requestRow.getFeeType(), "费用类型");
|
||||
@@ -1042,11 +1126,15 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
&& Objects.equals(item.getFeeItem(), requestRow.getFeeItem()))
|
||||
.findFirst().orElse(null);
|
||||
}
|
||||
if (row == null && requestRow.getId() == null) row = new PreSettlementSummaryFee();
|
||||
if (row == null && requestRow.getId() == null) {
|
||||
row = new PreSettlementSummaryFee();
|
||||
}
|
||||
if (row == null || (row.getId() != null && !Integer.valueOf(1).equals(row.getManualFlag()))) {
|
||||
throw new ServiceException("存在无效的手工费用行");
|
||||
}
|
||||
boolean creating = row.getId() == null;
|
||||
row.setPreSettlementId(settlementId);
|
||||
row.setLineNo(lineNo++);
|
||||
row.setFeeType(feeType);
|
||||
row.setFeeItem(feeItem);
|
||||
row.setOriginalAmount(BigDecimal.ZERO.setScale(2));
|
||||
@@ -1054,10 +1142,12 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
row.setSettlementAmount(row.getAdjustAmount());
|
||||
row.setRemark(limitRemark(requestRow.getRemark(), 50));
|
||||
row.setManualFlag(1);
|
||||
if (row.getId() == null) summaryFeeMapper.insert(row); else summaryFeeMapper.updateById(row);
|
||||
if (creating) {
|
||||
summaryFeeMapper.insert(row);
|
||||
} else {
|
||||
summaryFeeMapper.updateById(row);
|
||||
}
|
||||
retainedManualIds.add(row.getId());
|
||||
saveChange(settlementId, "合计费用项", null, requestRow.getId() == null ? "新增" : "调整",
|
||||
row.getFeeItem() + "金额" + row.getSettlementAmount(), "");
|
||||
continue;
|
||||
}
|
||||
String feeItem = requiredText(requestRow.getFeeItem(), "费用项");
|
||||
@@ -1077,27 +1167,21 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
row.setPreSettlementId(settlementId);
|
||||
row.setManualFlag(0);
|
||||
}
|
||||
row.setLineNo(lineNo++);
|
||||
row.setFeeType(feeType);
|
||||
row.setFeeItem(feeItem);
|
||||
// 生成费用行的三个金额必须来源于结算明细快照,不能接受前端回传值覆盖原金额。
|
||||
// rebuildSummaryFees 已按明细重建 original/adjust/settlement,此处仅保留备注。
|
||||
BigDecimal before = money(row.getAdjustAmount());
|
||||
row.setOriginalAmount(money(row.getOriginalAmount()));
|
||||
row.setSettlementAmount(money(row.getSettlementAmount()));
|
||||
row.setAdjustAmount(money(row.getSettlementAmount().subtract(row.getOriginalAmount())));
|
||||
row.setRemark(limitRemark(requestRow.getRemark(), 50));
|
||||
if (row.getId() == null) summaryFeeMapper.insert(row); else summaryFeeMapper.updateById(row);
|
||||
retainedGeneratedIds.add(row.getId());
|
||||
if (before.compareTo(row.getAdjustAmount()) != 0) {
|
||||
saveChange(settlementId, "合计费用项", null, "调整",
|
||||
"【调整金额】从【" + before + "】调整为【" + row.getAdjustAmount() + "】", "");
|
||||
}
|
||||
}
|
||||
for (PreSettlementSummaryFee manualRow : manualRows) {
|
||||
if (!retainedManualIds.contains(manualRow.getId())) {
|
||||
summaryFeeMapper.deleteById(manualRow.getId());
|
||||
saveChange(settlementId, "合计费用项", null, "删除",
|
||||
"删除" + manualRow.getFeeItem() + "费用" + manualRow.getSettlementAmount(), "");
|
||||
}
|
||||
}
|
||||
for (PreSettlementSummaryFee generatedRow : existingGeneratedRows) {
|
||||
@@ -1105,32 +1189,63 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
summaryFeeMapper.deleteById(generatedRow.getId());
|
||||
}
|
||||
}
|
||||
renumberSummaryFees(settlementId);
|
||||
// lineNo 已按前端提交顺序写入,不再按旧序号重排,避免手工费用与系统归集费用顺序被打乱。
|
||||
}
|
||||
|
||||
private void refreshDetail(PreSettlementDetail detail) {
|
||||
List<PreSettlementDetailFee> fees = detailFees(detail.getId());
|
||||
BigDecimal transportQuantity = fees.stream().map(PreSettlementDetailFee::getTransportQuantity)
|
||||
.filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
BigDecimal mileage = fees.stream().map(PreSettlementDetailFee::getMileage)
|
||||
.filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
BigDecimal freightAmount = fees.stream().map(PreSettlementDetailFee::getFreightAmount)
|
||||
.map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
BigDecimal originalAmount = fees.stream().map(PreSettlementDetailFee::getOriginalAmount)
|
||||
.map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
BigDecimal settlementAmountTax = fees.stream().map(PreSettlementDetailFee::getSettlementAmountTax)
|
||||
.map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
BigDecimal settlementAmountNoTax = fees.stream().map(PreSettlementDetailFee::getSettlementAmountNoTax)
|
||||
.filter(Objects::nonNull).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
Map<String, BigDecimal> feeItems = new LinkedHashMap<>();
|
||||
fees.forEach(fee -> parseFeeItems(fee.getFeeItemsJson()).forEach((name, amount) ->
|
||||
feeItems.merge(name, money(amount), BigDecimal::add)));
|
||||
// 运输单价无法按行简单求和,与前端暂存逻辑一致:取首行费用单价。
|
||||
BigDecimal unitPrice = fees.stream().map(PreSettlementDetailFee::getUnitPrice)
|
||||
.filter(Objects::nonNull).findFirst().orElse(null);
|
||||
String remark = fees.stream().map(PreSettlementDetailFee::getRemark)
|
||||
.filter(Func::isNotEmpty).findFirst().orElse(detail.getRemark());
|
||||
detail.setTransportQuantity(money(transportQuantity));
|
||||
detail.setMileage(mileage);
|
||||
detail.setUnitPrice(unitPrice);
|
||||
detail.setFreightAmount(money(freightAmount));
|
||||
detail.setOriginalAmount(money(originalAmount));
|
||||
detail.setAdjustAmount(money(settlementAmountTax.subtract(originalAmount)));
|
||||
detail.setSettlementAmountTax(money(settlementAmountTax));
|
||||
detail.setSettlementAmountNoTax(fees.stream().anyMatch(fee -> fee.getSettlementAmountNoTax() != null)
|
||||
? money(settlementAmountNoTax) : null);
|
||||
detail.setFeeItemsJson(JsonUtil.toJson(feeItems));
|
||||
detail.setRemark(limitRemark(remark, 200));
|
||||
detailMapper.updateById(detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* 结算金额以结算合计为准(含手工新增费用),与详情页合计及付款申请取数口径一致。
|
||||
* 合计行为空时回退为明细含税金额合计。
|
||||
*/
|
||||
private void refreshSettlementAmount(PreSettlement settlement) {
|
||||
BigDecimal settlementAmount = listDetails(settlement.getId()).stream()
|
||||
.map(PreSettlementDetail::getSettlementAmountTax).map(this::money)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
List<PreSettlementSummaryFee> summaryFees = listSummaryFees(settlement.getId());
|
||||
BigDecimal settlementAmount;
|
||||
if (!summaryFees.isEmpty()) {
|
||||
settlementAmount = summaryFees.stream()
|
||||
.map(PreSettlementSummaryFee::getSettlementAmount)
|
||||
.map(this::money)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
} else {
|
||||
settlementAmount = listDetails(settlement.getId()).stream()
|
||||
.map(PreSettlementDetail::getSettlementAmountTax)
|
||||
.map(this::money)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
}
|
||||
settlement.setSettlementAmount(money(settlementAmount));
|
||||
BigDecimal rate = normalizeRate(settlement.getExchangeRate(), settlement.getCurrency());
|
||||
settlement.setExchangeRate(rate);
|
||||
@@ -1349,6 +1464,34 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
.eq(PreSettlementChangeRecord::getPreSettlementId, settlementId));
|
||||
}
|
||||
|
||||
/** 预结算明细展示计费方式:按来源应收应付货物费用的计费类型汇总。 */
|
||||
private void fillDetailBillingTypes(List<PreSettlementDetail> details) {
|
||||
if (details == null || details.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<Long> sourceDetailIds = details.stream()
|
||||
.map(PreSettlementDetail::getSourceDetailId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (sourceDetailIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<Long, String> billingTypeMap = sourceFeeMapper.selectList(
|
||||
Wrappers.<ReceivablePayableCargoFee>lambdaQuery()
|
||||
.in(ReceivablePayableCargoFee::getDetailId, sourceDetailIds)
|
||||
.orderByAsc(ReceivablePayableCargoFee::getLineNo))
|
||||
.stream()
|
||||
.filter(fee -> Func.isNotEmpty(fee.getBillingType()) && !"-".equals(fee.getBillingType()))
|
||||
.collect(Collectors.groupingBy(ReceivablePayableCargoFee::getDetailId,
|
||||
Collectors.mapping(ReceivablePayableCargoFee::getBillingType,
|
||||
Collectors.collectingAndThen(Collectors.toCollection(LinkedHashSet::new),
|
||||
values -> String.join("、", values)))));
|
||||
for (PreSettlementDetail detail : details) {
|
||||
detail.setBillingType(billingTypeMap.getOrDefault(detail.getSourceDetailId(), ""));
|
||||
}
|
||||
}
|
||||
|
||||
private List<PreSettlementDetail> listDetails(Long settlementId) {
|
||||
return detailMapper.selectList(Wrappers.<PreSettlementDetail>lambdaQuery()
|
||||
.eq(PreSettlementDetail::getPreSettlementId, settlementId)
|
||||
@@ -1388,6 +1531,89 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
}
|
||||
}
|
||||
|
||||
private void recordSummaryFeeChanges(Long settlementId, List<PreSettlementSummaryFee> beforeRows) {
|
||||
List<Map<String, Object>> beforeLines = summaryFeeLines(beforeRows);
|
||||
List<Map<String, Object>> afterLines = summaryFeeLines(listSummaryFees(settlementId));
|
||||
if (beforeLines.equals(afterLines)) {
|
||||
return;
|
||||
}
|
||||
String content = summaryFeeChangeContent(beforeLines, afterLines);
|
||||
if (Func.isEmpty(content)) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> beforeData = new LinkedHashMap<>();
|
||||
Map<String, Object> afterData = new LinkedHashMap<>();
|
||||
beforeData.put("结算合计", beforeLines);
|
||||
afterData.put("结算合计", afterLines);
|
||||
String operationType = beforeLines.isEmpty() ? "新增" : afterLines.isEmpty() ? "删除" : "调整";
|
||||
saveChange(settlementId, "结算合计", null, operationType, content, "", beforeData, afterData);
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> summaryFeeLines(List<PreSettlementSummaryFee> rows) {
|
||||
Map<String, String> categoryNames = DictBizCache.getList("fee_category").stream()
|
||||
.collect(Collectors.toMap(DictBiz::getDictKey, DictBiz::getDictValue, (first, second) -> first));
|
||||
List<Map<String, Object>> lines = new ArrayList<>();
|
||||
for (PreSettlementSummaryFee row : rows) {
|
||||
String feeType = row.getFeeType() == null ? "" : row.getFeeType();
|
||||
Map<String, Object> line = new LinkedHashMap<>();
|
||||
line.put("来源", Integer.valueOf(1).equals(row.getManualFlag()) ? "手工新增" : "系统归集");
|
||||
line.put("费用类型", categoryNames.getOrDefault(feeType, feeType));
|
||||
line.put("费用项", row.getFeeItem() == null ? "" : row.getFeeItem());
|
||||
line.put("原金额", money(row.getOriginalAmount()));
|
||||
line.put("调整金额", money(row.getAdjustAmount()));
|
||||
line.put("结算金额", money(row.getSettlementAmount()));
|
||||
line.put("备注", row.getRemark() == null ? "" : row.getRemark());
|
||||
lines.add(line);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private String summaryFeeChangeContent(List<Map<String, Object>> beforeLines, List<Map<String, Object>> afterLines) {
|
||||
Map<String, Map<String, Object>> beforeMap = indexSummaryLines(beforeLines);
|
||||
Map<String, Map<String, Object>> afterMap = indexSummaryLines(afterLines);
|
||||
List<String> changes = new ArrayList<>();
|
||||
afterMap.forEach((key, afterLine) -> {
|
||||
Map<String, Object> beforeLine = beforeMap.get(key);
|
||||
String label = summaryLineLabel(afterLine);
|
||||
if (beforeLine == null) {
|
||||
changes.add("新增结算合计【" + label + "】结算金额【" + afterLine.get("结算金额") + "】");
|
||||
return;
|
||||
}
|
||||
if (!Objects.equals(beforeLine.get("原金额"), afterLine.get("原金额"))
|
||||
|| !Objects.equals(beforeLine.get("调整金额"), afterLine.get("调整金额"))
|
||||
|| !Objects.equals(beforeLine.get("结算金额"), afterLine.get("结算金额"))
|
||||
|| !Objects.equals(String.valueOf(beforeLine.get("备注")), String.valueOf(afterLine.get("备注")))) {
|
||||
changes.add("调整结算合计【" + label + "】结算金额从【" + beforeLine.get("结算金额")
|
||||
+ "】调整为【" + afterLine.get("结算金额") + "】");
|
||||
}
|
||||
});
|
||||
beforeMap.forEach((key, beforeLine) -> {
|
||||
if (!afterMap.containsKey(key)) {
|
||||
changes.add("删除结算合计【" + summaryLineLabel(beforeLine) + "】结算金额【" + beforeLine.get("结算金额") + "】");
|
||||
}
|
||||
});
|
||||
return String.join(";", changes);
|
||||
}
|
||||
|
||||
private Map<String, Map<String, Object>> indexSummaryLines(List<Map<String, Object>> lines) {
|
||||
Map<String, Map<String, Object>> indexed = new LinkedHashMap<>();
|
||||
Map<String, Integer> counts = new HashMap<>();
|
||||
for (Map<String, Object> line : lines) {
|
||||
String base = String.valueOf(line.get("来源")) + "|" + line.get("费用类型") + "|" + line.get("费用项");
|
||||
int index = counts.merge(base, 1, Integer::sum);
|
||||
indexed.put(base + "#" + index, line);
|
||||
}
|
||||
return indexed;
|
||||
}
|
||||
|
||||
private String summaryLineLabel(Map<String, Object> line) {
|
||||
String feeType = String.valueOf(line.getOrDefault("费用类型", ""));
|
||||
String feeItem = String.valueOf(line.getOrDefault("费用项", ""));
|
||||
String source = String.valueOf(line.getOrDefault("来源", ""));
|
||||
String name = feeType.isBlank() ? feeItem : feeType + "-" + feeItem;
|
||||
return source + " " + name;
|
||||
}
|
||||
|
||||
private void saveChange(Long settlementId, String changeType, Integer lineNo, String operationType,
|
||||
String content, String reason) {
|
||||
saveChange(settlementId, changeType, lineNo, operationType, content, reason, null, null);
|
||||
@@ -1602,44 +1828,25 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
return String.valueOf(feeType) + "|" + String.valueOf(feeItem);
|
||||
}
|
||||
|
||||
/** 生成当日预结算单号;按最大流水号 +1,且计入逻辑删除数据 */
|
||||
private synchronized String nextPreSettlementNo() {
|
||||
return nextDailyCode("YJ", PreSettlement::getPreSettlementNo);
|
||||
String prefix = "YJ" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
|
||||
Long maxSequence = baseMapper.selectMaxPreSettlementDailySequence(AuthUtil.getTenantId(), prefix);
|
||||
return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1);
|
||||
}
|
||||
|
||||
/** 生成预结算转正式结算单号;按最大流水号 +1,且计入逻辑删除数据 */
|
||||
private synchronized String nextFormalSettlementNo() {
|
||||
return nextDailyCode("ZJ", PreSettlement::getFormalSettlementNo);
|
||||
String prefix = "ZJ" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
|
||||
Long maxSequence = baseMapper.selectMaxFormalSettlementDailySequence(AuthUtil.getTenantId(), prefix);
|
||||
return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1);
|
||||
}
|
||||
|
||||
/** 生成当日预付单号;按最大流水号 +1,且计入逻辑删除数据 */
|
||||
private synchronized String nextAdvanceNo() {
|
||||
String prefix = "YF" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
|
||||
PreSettlementAdvance latest = advanceMapper.selectOne(Wrappers.<PreSettlementAdvance>lambdaQuery()
|
||||
.likeRight(PreSettlementAdvance::getAdvanceNo, prefix)
|
||||
.orderByDesc(PreSettlementAdvance::getAdvanceNo)
|
||||
.last("limit 1"));
|
||||
return prefix + String.format("%05d", nextSequence(latest == null ? null : latest.getAdvanceNo(), prefix));
|
||||
}
|
||||
|
||||
private String nextDailyCode(String code, Function<PreSettlement, String> getter) {
|
||||
String prefix = code + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
|
||||
PreSettlement latest = list(Wrappers.<PreSettlement>lambdaQuery()
|
||||
.and(query -> query.likeRight(PreSettlement::getPreSettlementNo, prefix)
|
||||
.or().likeRight(PreSettlement::getFormalSettlementNo, prefix))
|
||||
.orderByDesc(PreSettlement::getCreateTime)
|
||||
.last("limit 1")).stream().max(Comparator.comparing(item -> {
|
||||
String value = getter.apply(item);
|
||||
return value == null ? "" : value;
|
||||
})).orElse(null);
|
||||
String latestCode = latest == null ? null : getter.apply(latest);
|
||||
return prefix + String.format("%05d", nextSequence(latestCode, prefix));
|
||||
}
|
||||
|
||||
private int nextSequence(String latestCode, String prefix) {
|
||||
if (latestCode == null || !latestCode.startsWith(prefix)) return 1;
|
||||
try {
|
||||
return Integer.parseInt(latestCode.substring(prefix.length())) + 1;
|
||||
} catch (NumberFormatException exception) {
|
||||
return 1;
|
||||
}
|
||||
Long maxSequence = advanceMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix);
|
||||
return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+50
-7
@@ -83,6 +83,7 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
|
||||
private static final String STATUS_APPROVED = "approved";
|
||||
private static final String STATUS_REJECTED = "rejected";
|
||||
private static final String STATUS_CHANGE_REVIEWING = "change_reviewing";
|
||||
private static final String STATUS_CHANGE_APPROVED = "change_approved";
|
||||
private static final String STATUS_VOIDED = "voided";
|
||||
private static final String EFFECTIVE_TEMPORARY = "temporary";
|
||||
private static final String EFFECTIVE_FORMAL = "formal";
|
||||
@@ -167,7 +168,7 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
|
||||
appendProjectChangeRecord(projectApply, CHANGE_TYPE_DRAFT, null, STATUS_DRAFT, "草稿",
|
||||
buildProjectChangeSnapshot(beforeRecord), buildProjectChangeSnapshot(projectApply));
|
||||
}
|
||||
return saveOrUpdate(projectApply);
|
||||
return persistProjectApply(projectApply);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -186,7 +187,7 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
|
||||
projectApply.setApprovalStatus(STATUS_REVIEWING);
|
||||
projectApply.setCurrentNode(resolveApprovalNode(projectApply));
|
||||
projectApply.setCurrentProcessor("待处理");
|
||||
return saveOrUpdate(projectApply);
|
||||
return persistProjectApply(projectApply);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -207,7 +208,7 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
|
||||
if (!List.of(STATUS_REVIEWING, STATUS_CHANGE_REVIEWING).contains(projectApply.getApprovalStatus())) {
|
||||
throw new ServiceException("当前状态不允许审批通过");
|
||||
}
|
||||
projectApply.setApprovalStatus(STATUS_CHANGE_REVIEWING.equals(projectApply.getApprovalStatus()) ? "change_approved" : STATUS_APPROVED);
|
||||
projectApply.setApprovalStatus(STATUS_CHANGE_REVIEWING.equals(projectApply.getApprovalStatus()) ? STATUS_CHANGE_APPROVED : STATUS_APPROVED);
|
||||
projectApply.setEffectiveType(EFFECTIVE_FORMAL);
|
||||
projectApply.setCurrentNode("审批通过");
|
||||
projectApply.setCurrentProcessor(AuthUtil.getUserName());
|
||||
@@ -247,7 +248,7 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean voidProject(Long id, String reason) {
|
||||
ProjectApply projectApply = loadExists(id);
|
||||
if (!Objects.equals(projectApply.getApprovalStatus(), STATUS_APPROVED)) {
|
||||
if (!List.of(STATUS_APPROVED, STATUS_CHANGE_APPROVED).contains(projectApply.getApprovalStatus())) {
|
||||
throw new ServiceException("仅审批通过项目允许作废");
|
||||
}
|
||||
projectApply.setApprovalStatus(STATUS_VOIDED);
|
||||
@@ -263,6 +264,7 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
|
||||
// 变更草稿仅更新业务字段,不生成变更记录;变更记录在 submitChange 时落库
|
||||
ProjectApply oldRecord = loadChangeable(projectApply.getId());
|
||||
prepare(projectApply);
|
||||
normalizeUndertakeDept(projectApply);
|
||||
copyChangeFields(oldRecord, projectApply);
|
||||
validateSubmit(oldRecord);
|
||||
validateChangeLength(oldRecord);
|
||||
@@ -275,6 +277,7 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
|
||||
ProjectApply oldRecord = loadChangeable(projectApply.getId());
|
||||
Map<String, Object> beforeData = buildProjectChangeSnapshot(oldRecord);
|
||||
prepare(projectApply);
|
||||
normalizeUndertakeDept(projectApply);
|
||||
copyChangeFields(oldRecord, projectApply);
|
||||
validateSubmit(oldRecord);
|
||||
validateChange(oldRecord, projectApply.getChangeType());
|
||||
@@ -382,13 +385,13 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
|
||||
}
|
||||
if (Boolean.TRUE.equals(projectApply.getTemporaryCreditLimitSelectable())) {
|
||||
queryWrapper.eq(ProjectApply::getEffectiveType, EFFECTIVE_FORMAL)
|
||||
.in(ProjectApply::getApprovalStatus, STATUS_APPROVED, "change_approved");
|
||||
.in(ProjectApply::getApprovalStatus, STATUS_APPROVED, STATUS_CHANGE_APPROVED);
|
||||
}
|
||||
if (Boolean.TRUE.equals(projectApply.getContractSelectable())) {
|
||||
// 正式审批通过项目 + 未作废的临时项目均可被合同选择
|
||||
queryWrapper.and(wrapper -> wrapper
|
||||
.nested(formal -> formal.eq(ProjectApply::getEffectiveType, EFFECTIVE_FORMAL)
|
||||
.in(ProjectApply::getApprovalStatus, STATUS_APPROVED, "change_approved"))
|
||||
.in(ProjectApply::getApprovalStatus, STATUS_APPROVED, STATUS_CHANGE_APPROVED))
|
||||
.or(temporary -> temporary.eq(ProjectApply::getEffectiveType, EFFECTIVE_TEMPORARY)
|
||||
.ne(ProjectApply::getApprovalStatus, STATUS_VOIDED)));
|
||||
}
|
||||
@@ -582,6 +585,11 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
|
||||
}
|
||||
|
||||
private void copyChangeFields(ProjectApply target, ProjectApply source) {
|
||||
// 项目变更允许调整业务部门、平台公司
|
||||
target.setBusinessDeptId(source.getBusinessDeptId());
|
||||
target.setBusinessDeptName(source.getBusinessDeptName());
|
||||
target.setUndertakeDeptId(source.getUndertakeDeptId());
|
||||
target.setUndertakeDeptName(source.getUndertakeDeptName());
|
||||
target.setFundLimit(source.getFundLimit());
|
||||
target.setReceivableLimit(source.getReceivableLimit());
|
||||
target.setReceivableDays(source.getReceivableDays());
|
||||
@@ -795,6 +803,8 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
|
||||
private void validateLength(ProjectApply projectApply) {
|
||||
TransportBusinessSupport.validateLength(projectApply.getProjectName(), 50, "项目名称不能超过50个字");
|
||||
TransportBusinessSupport.validateLength(projectApply.getProjectShortName(), 20, "项目简称不能超过20个字");
|
||||
TransportBusinessSupport.validateLength(projectApply.getUndertakeDeptId(), 500, "平台公司选择过多,请减少后再提交");
|
||||
TransportBusinessSupport.validateLength(projectApply.getUndertakeDeptName(), 500, "平台公司名称过长,请减少选择后再提交");
|
||||
TransportBusinessSupport.validateLength(projectApply.getSourceRemark(), 300, "项目由来说明不能超过300个字");
|
||||
TransportBusinessSupport.validateLength(projectApply.getCargoQuantity(), 100, "预估货物数量不能超过100个字");
|
||||
TransportBusinessSupport.validateLength(projectApply.getTransportRoute(), 100, "运输线路不能超过100个字");
|
||||
@@ -896,7 +906,9 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
|
||||
}
|
||||
|
||||
private void validateChangeable(ProjectApply projectApply) {
|
||||
if (!Objects.equals(projectApply.getApprovalStatus(), STATUS_APPROVED) || !Objects.equals(projectApply.getEffectiveType(), EFFECTIVE_FORMAL)) {
|
||||
// 首次立项通过(approved)与历次变更通过(change_approved)均可再次发起变更
|
||||
boolean approved = List.of(STATUS_APPROVED, STATUS_CHANGE_APPROVED).contains(projectApply.getApprovalStatus());
|
||||
if (!approved || !Objects.equals(projectApply.getEffectiveType(), EFFECTIVE_FORMAL)) {
|
||||
throw new ServiceException("仅正式生效且审批通过的项目允许发起变更");
|
||||
}
|
||||
}
|
||||
@@ -1046,6 +1058,37 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl<ProjectApplyMapper,
|
||||
});
|
||||
}
|
||||
|
||||
private boolean persistProjectApply(ProjectApply projectApply) {
|
||||
try {
|
||||
return saveOrUpdate(projectApply);
|
||||
} catch (RuntimeException exception) {
|
||||
throw translateUndertakeDeptPersistError(exception, projectApply);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 多选平台公司写入失败时,提示执行字段扩容脚本(原列为 bigint / varchar(100))。
|
||||
*/
|
||||
private RuntimeException translateUndertakeDeptPersistError(RuntimeException exception, ProjectApply projectApply) {
|
||||
String message = Func.toStr(exception.getMessage());
|
||||
Throwable cause = exception.getCause();
|
||||
while (cause != null) {
|
||||
message = message + " " + Func.toStr(cause.getMessage());
|
||||
cause = cause.getCause();
|
||||
}
|
||||
String lowerMessage = message.toLowerCase();
|
||||
boolean multiUndertake = Func.isNotEmpty(projectApply.getUndertakeDeptId())
|
||||
&& (projectApply.getUndertakeDeptId().contains(",") || projectApply.getUndertakeDeptId().contains(","));
|
||||
boolean columnHint = lowerMessage.contains("undertake_dept")
|
||||
|| lowerMessage.contains("incorrect integer")
|
||||
|| lowerMessage.contains("data truncation")
|
||||
|| lowerMessage.contains("data too long");
|
||||
if (multiUndertake && columnHint) {
|
||||
return new ServiceException("平台公司多选保存失败,请先执行脚本 blade_project_apply_undertake_dept_multi_20261014.sql 扩容字段后重试");
|
||||
}
|
||||
return exception;
|
||||
}
|
||||
|
||||
private void normalizeUndertakeDept(ProjectApply projectApply) {
|
||||
if (projectApply == null) {
|
||||
return;
|
||||
|
||||
+20
-5
@@ -252,8 +252,8 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl<ReceiptClaimM
|
||||
BigDecimal flowClaimedAfter = money(flow.getClaimedAmount())
|
||||
.subtract(money(claim.getClaimAmount())).max(BigDecimal.ZERO);
|
||||
flow.setClaimedAmount(flowClaimedAfter);
|
||||
// 认领状态对照公告未认领金额重算(作废分摊不计入累计)
|
||||
flow.setClaimStatus(amountClaimStatus(flowClaimedAfter, flow.getUnclaimedAmount()));
|
||||
// 认领状态对照可认领上限重算(作废分摊不计入累计)
|
||||
flow.setClaimStatus(amountClaimStatus(flowClaimedAfter, claimCeiling(flow)));
|
||||
receiptFlowMapper.updateById(flow);
|
||||
|
||||
// 作废不生成金蝶认领冲单:金蝶单号/状态保留原值(未推送为空,已推送为真实收款单信息),
|
||||
@@ -411,13 +411,28 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl<ReceiptClaimM
|
||||
}
|
||||
|
||||
/**
|
||||
* 累计认领金额 → 流水认领状态:对照公告未认领金额判定(与收款流水侧口径一致)
|
||||
* 累计认领金额 → 流水认领状态:对照可认领上限判定(与收款流水侧口径一致)
|
||||
*/
|
||||
private String amountClaimStatus(BigDecimal claimedAmount, BigDecimal unclaimedAmount) {
|
||||
private String amountClaimStatus(BigDecimal claimedAmount, BigDecimal claimCeiling) {
|
||||
if (claimedAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return "unclaimed";
|
||||
}
|
||||
return claimedAmount.compareTo(money(unclaimedAmount)) >= 0 ? "claimed" : "partial";
|
||||
return claimedAmount.compareTo(money(claimCeiling)) >= 0 ? "claimed" : "partial";
|
||||
}
|
||||
|
||||
/**
|
||||
* 可认领上限:公告未认领金额与收款金额取较小正值(与收款流水侧口径一致)
|
||||
*/
|
||||
private BigDecimal claimCeiling(KingdeeReceiptFlow flow) {
|
||||
BigDecimal unclaimed = money(flow.getUnclaimedAmount());
|
||||
BigDecimal receipt = money(flow.getReceiptAmount());
|
||||
if (receipt.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return unclaimed;
|
||||
}
|
||||
if (unclaimed.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return unclaimed;
|
||||
}
|
||||
return unclaimed.min(receipt);
|
||||
}
|
||||
|
||||
private String amountStatus(BigDecimal paidAmount, BigDecimal settlementAmount) {
|
||||
|
||||
+126
-20
@@ -194,12 +194,16 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
.eq(ReceiptFlowRecord::getReceiptFlowId, id)
|
||||
.in(ReceiptFlowRecord::getActionType, List.of("claim", "push"))
|
||||
.orderByDesc(ReceiptFlowRecord::getCreateTime)));
|
||||
// 继续认领:带回本流水历史关联且仍有剩余可认领金额的结算单
|
||||
vo.setRelatedSettlements(relatedSettlementCandidates(id));
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> settlementCandidates(String keyword, Long flowId) {
|
||||
existing(flowId);
|
||||
// 本流水历史关联结算单优先保留(避免仅按创建时间 limit 截断后选不到)
|
||||
Set<Long> relatedIds = relatedSettlementIds(flowId);
|
||||
List<FormalSettlement> settlements = formalSettlementMapper.selectList(
|
||||
Wrappers.<FormalSettlement>lambdaQuery()
|
||||
.eq(FormalSettlement::getSettlementType, RECEIVABLE)
|
||||
@@ -210,7 +214,24 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
.or().like(FormalSettlement::getProjectName, keyword)
|
||||
.or().like(FormalSettlement::getContractName, keyword))
|
||||
.orderByDesc(FormalSettlement::getCreateTime)
|
||||
.last("limit 200"));
|
||||
.last("limit 500"));
|
||||
if (!relatedIds.isEmpty()) {
|
||||
Set<Long> loadedIds = settlements.stream().map(FormalSettlement::getId)
|
||||
.filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
List<Long> missingIds = relatedIds.stream().filter(id -> !loadedIds.contains(id)).toList();
|
||||
if (!missingIds.isEmpty()) {
|
||||
List<FormalSettlement> relatedSettlements = formalSettlementMapper.selectList(
|
||||
Wrappers.<FormalSettlement>lambdaQuery()
|
||||
.in(FormalSettlement::getId, missingIds)
|
||||
.eq(FormalSettlement::getSettlementType, RECEIVABLE)
|
||||
.eq(FormalSettlement::getApprovalStatus, APPROVED)
|
||||
.eq(FormalSettlement::getStatus, 1));
|
||||
List<FormalSettlement> merged = new ArrayList<>(relatedSettlements.size() + settlements.size());
|
||||
merged.addAll(relatedSettlements);
|
||||
merged.addAll(settlements);
|
||||
settlements = merged;
|
||||
}
|
||||
}
|
||||
// 候选结算单的累计认领金额一次聚合查询(避免逐单 N+1)
|
||||
Map<Long, BigDecimal> claimedMap = receiptClaimedAmounts(settlements.stream()
|
||||
.map(FormalSettlement::getId).toList());
|
||||
@@ -218,6 +239,14 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
.map(settlement -> candidateRow(settlement,
|
||||
claimedMap.getOrDefault(settlement.getId(), BigDecimal.ZERO)))
|
||||
.filter(row -> ((BigDecimal) row.get("remainingReceiptAmount")).compareTo(BigDecimal.ZERO) > 0)
|
||||
.sorted((left, right) -> {
|
||||
boolean leftRelated = relatedIds.contains(toLong(left.get("id")));
|
||||
boolean rightRelated = relatedIds.contains(toLong(right.get("id")));
|
||||
if (leftRelated == rightRelated) {
|
||||
return 0;
|
||||
}
|
||||
return leftRelated ? -1 : 1;
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -234,6 +263,7 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
Wrappers.<ReceiptClaimSettlement>lambdaQuery()
|
||||
.eq(ReceiptClaimSettlement::getFormalSettlementId, formalSettlementId)
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)
|
||||
.eq(ReceiptClaimSettlement::getIsDeleted, 0)
|
||||
.orderByDesc(ReceiptClaimSettlement::getCreateTime));
|
||||
if (relations.isEmpty()) {
|
||||
return List.of();
|
||||
@@ -315,19 +345,19 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
allocatedTotal = allocatedTotal.add(allocated);
|
||||
}
|
||||
|
||||
// 公告未认领金额(认领上限与认领完成判定基准,A301-2 口径)
|
||||
// 认领上限:公告未认领金额与收款金额取较小值(避免未认领金额异常偏大导致无法认领完成)
|
||||
if (flow.getUnclaimedAmount() == null) {
|
||||
throw new ServiceException("公告未认领金额缺失,请先手动同步金蝶收款流水");
|
||||
}
|
||||
BigDecimal unclaimedAmount = money(flow.getUnclaimedAmount());
|
||||
if (unclaimedAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new ServiceException("该流水公告未认领金额为0,无需认领");
|
||||
BigDecimal claimCeiling = claimCeiling(flow);
|
||||
if (claimCeiling.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new ServiceException("该流水可认领金额为0,无需认领");
|
||||
}
|
||||
// 流水认领前已认领金额
|
||||
BigDecimal previousFlowClaimed = flowClaimedAmount(flow.getId());
|
||||
if (previousFlowClaimed.add(allocatedTotal).compareTo(unclaimedAmount) > 0) {
|
||||
throw new ServiceException("累计认领金额不能超过公告未认领金额,剩余可认领 "
|
||||
+ unclaimedAmount.subtract(previousFlowClaimed).max(BigDecimal.ZERO));
|
||||
if (previousFlowClaimed.add(allocatedTotal).compareTo(claimCeiling) > 0) {
|
||||
throw new ServiceException("累计认领金额不能超过可认领上限,剩余可认领 "
|
||||
+ claimCeiling.subtract(previousFlowClaimed).max(BigDecimal.ZERO));
|
||||
}
|
||||
|
||||
// 当前用户部门
|
||||
@@ -380,6 +410,9 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
relation.setClaimedReceiptAmount(previousClaimed);
|
||||
// 本次分摊金额
|
||||
relation.setAllocatedReceiptAmount(allocated);
|
||||
// 有效分摊(与汇总/候选查询 status=1 口径一致)
|
||||
relation.setStatus(1);
|
||||
relation.setIsDeleted(0);
|
||||
claimSettlementMapper.insert(relation);
|
||||
}
|
||||
|
||||
@@ -390,8 +423,8 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
String fromStatus = normalizeClaimStatus(flow.getClaimStatus());
|
||||
// 认领后累计金额
|
||||
BigDecimal claimedAfter = previousFlowClaimed.add(allocatedTotal);
|
||||
// 认领后状态(对照公告未认领金额)
|
||||
String toStatus = amountClaimStatus(claimedAfter, unclaimedAmount);
|
||||
// 认领后状态(对照可认领上限)
|
||||
String toStatus = amountClaimStatus(claimedAfter, claimCeiling);
|
||||
// 更新流水已认领金额
|
||||
flow.setClaimedAmount(claimedAfter);
|
||||
// 更新流水认领状态
|
||||
@@ -499,9 +532,9 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
|
||||
// 复制公告数据到实体(不覆盖本地认领字段)
|
||||
copyAnnounceRow(entity, announce, detailSerialNo, receiptAmount);
|
||||
// 根据本地累计认领金额与最新公告未认领金额重新计算认领状态
|
||||
// 根据本地累计认领金额与可认领上限重新计算认领状态
|
||||
entity.setClaimStatus(amountClaimStatus(money(entity.getClaimedAmount()),
|
||||
entity.getUnclaimedAmount()));
|
||||
claimCeiling(entity)));
|
||||
|
||||
if (created) {
|
||||
toInsert.add(entity);
|
||||
@@ -621,6 +654,46 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 本流水历史关联过的结算单 id(有效分摊)
|
||||
*/
|
||||
private Set<Long> relatedSettlementIds(Long flowId) {
|
||||
if (flowId == null) {
|
||||
return Set.of();
|
||||
}
|
||||
return claimSettlementMapper.selectList(Wrappers.<ReceiptClaimSettlement>lambdaQuery()
|
||||
.select(ReceiptClaimSettlement::getFormalSettlementId)
|
||||
.eq(ReceiptClaimSettlement::getReceiptFlowId, flowId)
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)
|
||||
.eq(ReceiptClaimSettlement::getIsDeleted, 0)).stream()
|
||||
.map(ReceiptClaimSettlement::getFormalSettlementId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toCollection(HashSet::new));
|
||||
}
|
||||
|
||||
/**
|
||||
* 继续认领预填:本流水历史关联且仍有剩余可认领金额的结算单
|
||||
*/
|
||||
private List<Map<String, Object>> relatedSettlementCandidates(Long flowId) {
|
||||
Set<Long> relatedIds = relatedSettlementIds(flowId);
|
||||
if (relatedIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<FormalSettlement> settlements = formalSettlementMapper.selectList(
|
||||
Wrappers.<FormalSettlement>lambdaQuery()
|
||||
.in(FormalSettlement::getId, relatedIds)
|
||||
.eq(FormalSettlement::getSettlementType, RECEIVABLE)
|
||||
.eq(FormalSettlement::getApprovalStatus, APPROVED)
|
||||
.eq(FormalSettlement::getStatus, 1));
|
||||
Map<Long, BigDecimal> claimedMap = receiptClaimedAmounts(settlements.stream()
|
||||
.map(FormalSettlement::getId).toList());
|
||||
return settlements.stream()
|
||||
.map(settlement -> candidateRow(settlement,
|
||||
claimedMap.getOrDefault(settlement.getId(), BigDecimal.ZERO)))
|
||||
.filter(row -> ((BigDecimal) row.get("remainingReceiptAmount")).compareTo(BigDecimal.ZERO) > 0)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量聚合结算单的累计有效认领金额:一次 IN 查询按结算单分组求和,避免逐单 N+1
|
||||
*/
|
||||
@@ -631,7 +704,8 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
}
|
||||
return claimSettlementMapper.selectList(Wrappers.<ReceiptClaimSettlement>lambdaQuery()
|
||||
.in(ReceiptClaimSettlement::getFormalSettlementId, ids)
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)).stream()
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)
|
||||
.eq(ReceiptClaimSettlement::getIsDeleted, 0)).stream()
|
||||
.collect(Collectors.groupingBy(ReceiptClaimSettlement::getFormalSettlementId,
|
||||
Collectors.reducing(BigDecimal.ZERO,
|
||||
item -> money(item.getAllocatedReceiptAmount()), BigDecimal::add)));
|
||||
@@ -640,12 +714,42 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
private BigDecimal flowClaimedAmount(Long flowId) {
|
||||
return claimSettlementMapper.selectList(Wrappers.<ReceiptClaimSettlement>lambdaQuery()
|
||||
.eq(ReceiptClaimSettlement::getReceiptFlowId, flowId)
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)).stream()
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)
|
||||
.eq(ReceiptClaimSettlement::getIsDeleted, 0)).stream()
|
||||
.map(ReceiptClaimSettlement::getAllocatedReceiptAmount)
|
||||
.map(this::money)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
}
|
||||
|
||||
/**
|
||||
* 可认领上限:公告未认领金额与收款金额取较小正值。
|
||||
* 主口径仍为 A301-2 公告未认领金额;与收款金额取 min,避免未认领金额异常偏大导致无法进入「认领完成」。
|
||||
*/
|
||||
private BigDecimal claimCeiling(KingdeeReceiptFlow flow) {
|
||||
BigDecimal unclaimed = money(flow.getUnclaimedAmount());
|
||||
BigDecimal receipt = money(flow.getReceiptAmount());
|
||||
if (receipt.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return unclaimed;
|
||||
}
|
||||
if (unclaimed.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return unclaimed;
|
||||
}
|
||||
return unclaimed.min(receipt);
|
||||
}
|
||||
|
||||
private Long toLong(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Long longValue) {
|
||||
return longValue;
|
||||
}
|
||||
if (value instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
return Func.toLong(String.valueOf(value));
|
||||
}
|
||||
|
||||
private KingdeeReceiptFlow lockedFlow(Long flowId) {
|
||||
KingdeeReceiptFlow flow = baseMapper.selectOne(Wrappers.<KingdeeReceiptFlow>lambdaQuery()
|
||||
.eq(KingdeeReceiptFlow::getId, flowId)
|
||||
@@ -824,18 +928,20 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
flow.setClaimMe(announce.getClaimme());
|
||||
BigDecimal claimedTotal = flowClaimedAmount(flow.getId());
|
||||
flow.setClaimedAmount(claimedTotal);
|
||||
String claimStatus = amountClaimStatus(claimedTotal, flow.getUnclaimedAmount());
|
||||
BigDecimal claimCeiling = claimCeiling(flow);
|
||||
String claimStatus = amountClaimStatus(claimedTotal, claimCeiling);
|
||||
flow.setClaimStatus(claimStatus);
|
||||
updateById(flow);
|
||||
if (!CLAIMED.equals(claimStatus)) {
|
||||
return ReceiptClaimPushResult.fail("累计认领金额(" + claimedTotal + ")与公告未认领金额("
|
||||
+ money(flow.getUnclaimedAmount()) + ")不一致,不满足推送条件");
|
||||
return ReceiptClaimPushResult.fail("累计认领金额(" + claimedTotal + ")与可认领上限("
|
||||
+ claimCeiling + ")不一致,不满足推送条件");
|
||||
}
|
||||
// 分录取该流水全部有效分摊行(结算单id升序,跨认领主单归集)
|
||||
List<ReceiptClaimSettlement> allocations = claimSettlementMapper.selectList(
|
||||
Wrappers.<ReceiptClaimSettlement>lambdaQuery()
|
||||
.eq(ReceiptClaimSettlement::getReceiptFlowId, flow.getId())
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)
|
||||
.eq(ReceiptClaimSettlement::getIsDeleted, 0)
|
||||
.orderByAsc(ReceiptClaimSettlement::getFormalSettlementId));
|
||||
if (allocations.isEmpty()) {
|
||||
return ReceiptClaimPushResult.fail("该流水无有效认领分摊,无法推送");
|
||||
@@ -1075,13 +1181,13 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
}
|
||||
|
||||
/**
|
||||
* 累计认领金额 → 流水认领状态:对照公告未认领金额判定(claimed 即「认领完成」,唯一可推送状态)
|
||||
* 累计认领金额 → 流水认领状态:对照可认领上限判定(claimed 即「认领完成」,唯一可推送状态)
|
||||
*/
|
||||
private String amountClaimStatus(BigDecimal claimedAmount, BigDecimal unclaimedAmount) {
|
||||
private String amountClaimStatus(BigDecimal claimedAmount, BigDecimal claimCeiling) {
|
||||
if (claimedAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return UNCLAIMED;
|
||||
}
|
||||
return claimedAmount.compareTo(money(unclaimedAmount)) >= 0 ? CLAIMED : PARTIAL;
|
||||
return claimedAmount.compareTo(money(claimCeiling)) >= 0 ? CLAIMED : PARTIAL;
|
||||
}
|
||||
|
||||
private String amountStatus(BigDecimal claimedAmount, BigDecimal settlementAmount) {
|
||||
|
||||
+442
-51
@@ -424,9 +424,7 @@ public class ReceivablePayableDetailServiceImpl
|
||||
}
|
||||
});
|
||||
ReceivablePayableFeeDetailVO result = buildFeeDetail(rows);
|
||||
LinkedHashSet<String> feeItemNames = new LinkedHashSet<>(contractFeeItemNames(detail.getContractId()));
|
||||
feeItemNames.addAll(result.getFeeItemNames());
|
||||
result.setFeeItemNames(new ArrayList<>(feeItemNames));
|
||||
// 只展示本明细已生成的费用项。合同上其他计费方案的费用项不能叠到当前明细。
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -607,14 +605,22 @@ public class ReceivablePayableDetailServiceImpl
|
||||
BigDecimal transportQuantity = money(adjusted.getTransportQuantity());
|
||||
BigDecimal mileage = money(adjusted.getMileage());
|
||||
BigDecimal freightAmount = money(adjusted.getFreightAmount());
|
||||
BigDecimal unitPrice = adjusted.getUnitPrice() == null ? null : money(adjusted.getUnitPrice());
|
||||
Map<String, BigDecimal> effectiveFeeItems = feeItems;
|
||||
boolean billingBasisChanged = money(existing.getTransportQuantity()).compareTo(transportQuantity) != 0
|
||||
|| money(existing.getMileage()).compareTo(mileage) != 0;
|
||||
if (billingBasisChanged) {
|
||||
boolean quantityChanged = money(existing.getTransportQuantity()).compareTo(transportQuantity) != 0;
|
||||
boolean mileageChanged = money(existing.getMileage()).compareTo(mileage) != 0;
|
||||
boolean unitPriceChanged = money(existing.getUnitPrice()).compareTo(money(unitPrice)) != 0;
|
||||
if (quantityChanged || mileageChanged || unitPriceChanged) {
|
||||
String changedField = quantityChanged && !mileageChanged ? "transportQuantity"
|
||||
: mileageChanged && !quantityChanged ? "mileage"
|
||||
: unitPriceChanged && !quantityChanged && !mileageChanged ? "unitPrice" : null;
|
||||
AdjustedFeeCalculation calculation = calculateAdjustedFee(detail, existing,
|
||||
transportQuantity, mileage, freightAmount, feeItems);
|
||||
transportQuantity, mileage, freightAmount, feeItems, unitPrice, changedField);
|
||||
freightAmount = calculation.freightAmount();
|
||||
effectiveFeeItems = calculation.feeItems();
|
||||
if (calculation.unitPrice() != null) {
|
||||
adjusted.setUnitPrice(calculation.unitPrice().setScale(2, RoundingMode.HALF_UP));
|
||||
}
|
||||
}
|
||||
appendChange(changes, "规格", existing.getSpecification(), adjusted.getSpecification());
|
||||
appendChange(changes, "型号", existing.getModel(), adjusted.getModel());
|
||||
@@ -678,15 +684,21 @@ public class ReceivablePayableDetailServiceImpl
|
||||
}
|
||||
validateNonNegative(request.getTransportQuantity(), "运输量");
|
||||
validateNonNegative(request.getMileage(), "里程");
|
||||
validateNonNegative(request.getUnitPrice(), "运输单价");
|
||||
AdjustedFeeCalculation calculation = calculateAdjustedFee(detail, fee,
|
||||
money(request.getTransportQuantity()), money(request.getMileage()),
|
||||
money(request.getFreightAmount()), normalizeFeeItems(request.getFeeItems()));
|
||||
money(request.getFreightAmount()), normalizeFeeItems(request.getFeeItems()),
|
||||
request.getUnitPrice() == null ? null : money(request.getUnitPrice()),
|
||||
request.getChangedField());
|
||||
ReceivablePayableCargoFeeVO result = Objects.requireNonNull(
|
||||
BeanUtil.copyProperties(fee, ReceivablePayableCargoFeeVO.class));
|
||||
result.setTransportQuantity(request.getTransportQuantity());
|
||||
result.setMileage(request.getMileage());
|
||||
result.setFreightAmount(calculation.freightAmount());
|
||||
result.setFeeItems(new LinkedHashMap<>(calculation.feeItems()));
|
||||
if (calculation.unitPrice() != null) {
|
||||
result.setUnitPrice(calculation.unitPrice().setScale(2, RoundingMode.HALF_UP));
|
||||
}
|
||||
BigDecimal afterAmount = adjustedAfterAmount(calculation.freightAmount(), calculation.feeItems());
|
||||
result.setAdjustAmount(afterAmount.subtract(money(fee.getOriginalAmount())));
|
||||
result.setAfterAmount(afterAmount);
|
||||
@@ -836,6 +848,12 @@ public class ReceivablePayableDetailServiceImpl
|
||||
if (existsByWaybill(waybill.getId(), targetSettlementType)) {
|
||||
continue;
|
||||
}
|
||||
if ("payable".equals(targetSettlementType)
|
||||
&& trimToNull(waybill.getCarrierName()) != null
|
||||
&& !matchesWaybillCarrier(contract, waybill)) {
|
||||
throw new ServiceException("运单【" + Func.toStr(waybill.getWaybillNo(), String.valueOf(waybill.getId()))
|
||||
+ "】的承运商不是所选承运商合同的乙方,不能使用项目内其他承运商合同生成应付");
|
||||
}
|
||||
ReceivablePayableDetail detail = buildDetail(waybill, contract, request.getBillingPlanId(), targetSettlementType);
|
||||
detail.setSourceType(SOURCE_MANUAL_GENERATION);
|
||||
save(detail);
|
||||
@@ -847,6 +865,186 @@ public class ReceivablePayableDetailServiceImpl
|
||||
}
|
||||
}
|
||||
|
||||
private static final String SOURCE_SETTLEMENT_IMPORT = "结算导入";
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public List<Long> createDetailsFromSettlementImport(String settlementType, Long contractId,
|
||||
List<Map<String, Object>> feeRows) {
|
||||
if (Func.isEmpty(settlementType) || Func.isEmpty(contractId)) {
|
||||
throw new ServiceException("结算类型与合同不能为空");
|
||||
}
|
||||
if (Func.isEmpty(feeRows)) {
|
||||
return List.of();
|
||||
}
|
||||
ContractManage contract = contractManageService.getById(contractId);
|
||||
if (contract == null || Objects.equals(contract.getIsDeleted(), 1)) {
|
||||
throw new ServiceException("合同不存在");
|
||||
}
|
||||
String targetSettlementType = settlementType(settlementType);
|
||||
Long currentUserId = AuthUtil.getUserId();
|
||||
Date generateTime = new Date();
|
||||
List<Long> createdIds = new ArrayList<>();
|
||||
for (Map<String, Object> feeRow : feeRows) {
|
||||
if (feeRow == null) {
|
||||
continue;
|
||||
}
|
||||
Long waybillId = toLong(feeRow.get("waybillId"));
|
||||
if (waybillId == null) {
|
||||
continue;
|
||||
}
|
||||
Waybill waybill = waybillService.getById(waybillId);
|
||||
if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) {
|
||||
throw new ServiceException("运单不存在:" + waybillId);
|
||||
}
|
||||
assertWaybillNotSettledForImport(waybill, targetSettlementType);
|
||||
|
||||
BigDecimal transportFee = money(decimal(feeRow.get("transportFee")));
|
||||
BigDecimal unitPrice = money(decimal(feeRow.get("unitPrice")));
|
||||
Map<String, BigDecimal> feeItems = normalizeFeeItems(toFeeItemMap(feeRow.get("feeItems")));
|
||||
BigDecimal feeItemTotal = feeItems.values().stream().map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
BigDecimal afterAmount = transportFee.add(feeItemTotal).setScale(2, RoundingMode.HALF_UP);
|
||||
ReceivablePayableCargoFee cargoFee = buildSettlementImportCargoFee(
|
||||
waybill, transportFee, unitPrice, feeItems, afterAmount, stringValue(feeRow.get("remark")));
|
||||
|
||||
ReceivablePayableDetail importDetail = getOne(Wrappers.<ReceivablePayableDetail>lambdaQuery()
|
||||
.eq(ReceivablePayableDetail::getWaybillId, waybillId)
|
||||
.eq(ReceivablePayableDetail::getSettlementType, targetSettlementType)
|
||||
.eq(ReceivablePayableDetail::getSourceType, SOURCE_SETTLEMENT_IMPORT)
|
||||
.eq(ReceivablePayableDetail::getIsDeleted, 0)
|
||||
.orderByDesc(ReceivablePayableDetail::getCreateTime)
|
||||
.last("limit 1"), false);
|
||||
if (importDetail != null) {
|
||||
refreshSettlementImportDetail(importDetail, waybill, contract, targetSettlementType,
|
||||
cargoFee, unitPrice, stringValue(feeRow.get("remark")), currentUserId, generateTime);
|
||||
createdIds.add(importDetail.getId());
|
||||
continue;
|
||||
}
|
||||
|
||||
ReceivablePayableDetail detail = buildDetail(waybill, contract, targetSettlementType,
|
||||
List.of(cargoFee), unitPrice);
|
||||
detail.setSourceType(SOURCE_SETTLEMENT_IMPORT);
|
||||
detail.setSettlementStatus("pending");
|
||||
if (Func.isNotEmpty(stringValue(feeRow.get("remark")))) {
|
||||
detail.setRemark(stringValue(feeRow.get("remark")));
|
||||
}
|
||||
saveAutomaticDetail(detail, List.of(cargoFee), false, currentUserId, generateTime);
|
||||
createdIds.add(detail.getId());
|
||||
}
|
||||
return createdIds;
|
||||
}
|
||||
|
||||
/** 运单在该结算类型下已进入预/正式结算时,禁止再导入。 */
|
||||
private void assertWaybillNotSettledForImport(Waybill waybill, String settlementType) {
|
||||
List<ReceivablePayableDetail> details = list(Wrappers.<ReceivablePayableDetail>lambdaQuery()
|
||||
.eq(ReceivablePayableDetail::getWaybillId, waybill.getId())
|
||||
.eq(ReceivablePayableDetail::getSettlementType, settlementType)
|
||||
.eq(ReceivablePayableDetail::getIsDeleted, 0));
|
||||
for (ReceivablePayableDetail detail : details) {
|
||||
String settlementStatus = Func.toStr(detail.getSettlementStatus(), "pending");
|
||||
if (Func.isNotEmpty(detail.getFormalSettlementNo())
|
||||
|| Func.isNotEmpty(detail.getPreSettlementNo())
|
||||
|| List.of("pre_settled", "formal_settled", "closed").contains(settlementStatus)) {
|
||||
throw new ServiceException("运单" + Func.toStr(waybill.getWaybillNo(), String.valueOf(waybill.getId()))
|
||||
+ "已进入结算,无法重复导入");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ReceivablePayableCargoFee buildSettlementImportCargoFee(Waybill waybill, BigDecimal transportFee,
|
||||
BigDecimal unitPrice, Map<String, BigDecimal> feeItems,
|
||||
BigDecimal afterAmount, String remark) {
|
||||
ReceivablePayableCargoFee cargoFee = new ReceivablePayableCargoFee();
|
||||
cargoFee.setWaybillId(waybill.getId());
|
||||
cargoFee.setLineNo("0001");
|
||||
cargoFee.setDataSource(FEE_SOURCE_MANUAL);
|
||||
cargoFee.setCargoName(waybill.getCargoName());
|
||||
cargoFee.setCargoType(waybill.getCargoType());
|
||||
cargoFee.setSpecification(waybill.getSpecification());
|
||||
cargoFee.setModel(waybill.getModel());
|
||||
cargoFee.setBillingFactor("按重量");
|
||||
cargoFee.setBillingType("固定单价");
|
||||
cargoFee.setTransportQuantity(money(waybill.getQuantity()));
|
||||
cargoFee.setQuantityUnit(waybill.getQuantityUnit());
|
||||
cargoFee.setPriceUnit(waybill.getPriceUnit());
|
||||
cargoFee.setUnitPrice(unitPrice);
|
||||
cargoFee.setMileage(normalizeGeneratedMileage(waybill.getMileage()));
|
||||
cargoFee.setFreightAmount(transportFee);
|
||||
cargoFee.setFeeItemsJson(JsonUtil.toJson(feeItems));
|
||||
cargoFee.setOriginalAmount(afterAmount);
|
||||
cargoFee.setAdjustAmount(BigDecimal.ZERO.setScale(2));
|
||||
cargoFee.setAfterAmount(afterAmount);
|
||||
cargoFee.setRemark(remark);
|
||||
return cargoFee;
|
||||
}
|
||||
|
||||
/** 刷新已有结算导入明细:费用项目与金额始终覆盖为本次导入数据。 */
|
||||
private void refreshSettlementImportDetail(ReceivablePayableDetail detail, Waybill waybill, ContractManage contract,
|
||||
String settlementType, ReceivablePayableCargoFee cargoFee,
|
||||
BigDecimal unitPrice, String remark, Long currentUserId, Date generateTime) {
|
||||
ReceivablePayableDetail rebuilt = buildDetail(waybill, contract, settlementType, List.of(cargoFee), unitPrice);
|
||||
detail.setFreightAmount(rebuilt.getFreightAmount());
|
||||
detail.setOtherFeeAmount(rebuilt.getOtherFeeAmount());
|
||||
detail.setTotalAmount(rebuilt.getTotalAmount());
|
||||
detail.setUnitPrice(rebuilt.getUnitPrice());
|
||||
detail.setCurrency(rebuilt.getCurrency());
|
||||
detail.setFeeItemsJson(rebuilt.getFeeItemsJson());
|
||||
detail.setContractId(rebuilt.getContractId());
|
||||
detail.setContractNo(rebuilt.getContractNo());
|
||||
detail.setContractName(rebuilt.getContractName());
|
||||
detail.setCustomerName(rebuilt.getCustomerName());
|
||||
detail.setSourceType(SOURCE_SETTLEMENT_IMPORT);
|
||||
detail.setSettlementStatus("pending");
|
||||
if (Func.isNotEmpty(remark)) {
|
||||
detail.setRemark(remark);
|
||||
}
|
||||
updateById(detail);
|
||||
cargoFeeMapper.delete(Wrappers.<ReceivablePayableCargoFee>lambdaQuery()
|
||||
.eq(ReceivablePayableCargoFee::getDetailId, detail.getId()));
|
||||
ReceivablePayableCargoFee copy = BeanUtil.copyProperties(cargoFee, ReceivablePayableCargoFee.class);
|
||||
copy.setId(null);
|
||||
copy.setDetailId(detail.getId());
|
||||
fillGeneratedAuditFields(copy, currentUserId, generateTime);
|
||||
cargoFeeMapper.insert(copy);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, BigDecimal> toFeeItemMap(Object value) {
|
||||
Map<String, BigDecimal> result = new LinkedHashMap<>();
|
||||
if (!(value instanceof Map<?, ?> raw)) {
|
||||
return result;
|
||||
}
|
||||
raw.forEach((key, amount) -> {
|
||||
if (key == null) {
|
||||
return;
|
||||
}
|
||||
result.put(String.valueOf(key), decimal(amount));
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private Long toLong(Object value) {
|
||||
if (value == null || "".equals(value)) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(String.valueOf(value).trim());
|
||||
} catch (NumberFormatException exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String stringValue(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = String.valueOf(value).trim();
|
||||
return text.isEmpty() ? null : text;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW,
|
||||
rollbackFor = Exception.class)
|
||||
@@ -884,11 +1082,20 @@ public class ReceivablePayableDetailServiceImpl
|
||||
List<Waybill> payableWaybills = waybills.stream()
|
||||
.filter(waybill -> !isSelfTransport(waybill)).toList();
|
||||
if (payableWaybills.isEmpty()) return;
|
||||
List<Waybill> carrierMatchedWaybills = payableWaybills.stream()
|
||||
.filter(waybill -> matchesWaybillCarrier(carrierContract, waybill)).toList();
|
||||
List<Waybill> otherCarrierWaybills = payableWaybills.stream()
|
||||
.filter(waybill -> !matchesWaybillCarrier(carrierContract, waybill)).toList();
|
||||
if (!otherCarrierWaybills.isEmpty()) {
|
||||
generateAutomaticWaybillDetails(otherCarrierWaybills, false,
|
||||
this::resolveCarrierContract, currentUserId, generateTime);
|
||||
}
|
||||
if (carrierMatchedWaybills.isEmpty()) return;
|
||||
generateAutomaticDetail(carrierContract, "payable",
|
||||
() -> existsByLoading(loadingNo, "payable"), payableWaybills,
|
||||
() -> existsByLoading(loadingNo, "payable"), carrierMatchedWaybills,
|
||||
(fees, unitPrice) -> {
|
||||
normalizeWaybillFeeLines(fees);
|
||||
return buildLoadingDetail(loadingNo, carrierContract, payableWaybills, fees, unitPrice);
|
||||
return buildLoadingDetail(loadingNo, carrierContract, carrierMatchedWaybills, fees, unitPrice);
|
||||
},
|
||||
false, currentUserId, generateTime);
|
||||
}
|
||||
@@ -938,22 +1145,54 @@ public class ReceivablePayableDetailServiceImpl
|
||||
|
||||
private ContractManage resolveWaybillCarrierContract(Waybill waybill) {
|
||||
if (Func.isNotEmpty(waybill.getLoadingNo())) return null;
|
||||
return resolveCarrierContract(waybill);
|
||||
}
|
||||
|
||||
/**
|
||||
* 应付只认运单选择的承运商。
|
||||
* 运单已记录承运商合同时,乙方必须就是该承运商;否则按承运商名称回查同一项目下的承运商合同。
|
||||
* 不允许落到项目里其他承运商合同的客商。
|
||||
*/
|
||||
private ContractManage resolveCarrierContract(Waybill waybill) {
|
||||
if (waybill == null || isSelfTransport(waybill)) {
|
||||
return null;
|
||||
}
|
||||
String carrierName = trimToNull(waybill.getCarrierName());
|
||||
if (Func.isNotEmpty(waybill.getCarrierContractId())) {
|
||||
ContractManage contract = contractManageService.getById(waybill.getCarrierContractId());
|
||||
if (contract == null || !"承运商合同".equals(contract.getContractCategory())) {
|
||||
ContractManage recorded = contractManageService.getById(waybill.getCarrierContractId());
|
||||
if (recorded == null || !"承运商合同".equals(recorded.getContractCategory())) {
|
||||
throw new ServiceException("运单记录的承运商合同不存在或合同类别不正确");
|
||||
}
|
||||
return contract;
|
||||
if (carrierName == null || matchesWaybillCarrier(recorded, waybill)) {
|
||||
return recorded;
|
||||
}
|
||||
}
|
||||
return findCarrierContract(waybill);
|
||||
ContractManage matched = findCarrierContract(waybill);
|
||||
if (matched != null && matchesWaybillCarrier(matched, waybill)) {
|
||||
return matched;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean matchesWaybillCarrier(ContractManage contract, Waybill waybill) {
|
||||
if (contract == null || waybill == null || !"承运商合同".equals(contract.getContractCategory())) {
|
||||
return false;
|
||||
}
|
||||
if (waybill.getProjectId() != null && contract.getProjectId() != null
|
||||
&& !Objects.equals(waybill.getProjectId(), contract.getProjectId())) {
|
||||
return false;
|
||||
}
|
||||
String carrierName = trimToNull(waybill.getCarrierName());
|
||||
String partyB = trimToNull(contract.getPartyB());
|
||||
return carrierName != null && carrierName.equals(partyB);
|
||||
}
|
||||
|
||||
private ContractManage findCarrierContract(Waybill waybill) {
|
||||
if (waybill.getProjectId() == null || Func.isEmpty(waybill.getContractId())
|
||||
|| Func.isEmpty(waybill.getCarrierName())) {
|
||||
String carrierName = trimToNull(waybill.getCarrierName());
|
||||
if (waybill.getProjectId() == null || Func.isEmpty(waybill.getContractId()) || carrierName == null) {
|
||||
return null;
|
||||
}
|
||||
// 承运商合同甲方应与运单绑定的客户合同甲方一致,不能直接使用运单的客户名称拼接字段。
|
||||
// 承运商合同甲方应与运单绑定的客户合同甲方一致,乙方必须是运单选择的承运商。
|
||||
ContractManage customerContract = contractManageService.getById(waybill.getContractId());
|
||||
if (customerContract == null || Func.isEmpty(customerContract.getPartyA())) {
|
||||
return null;
|
||||
@@ -962,13 +1201,38 @@ public class ReceivablePayableDetailServiceImpl
|
||||
.eq(ContractManage::getIsDeleted, 0)
|
||||
.eq(ContractManage::getProjectId, waybill.getProjectId())
|
||||
.eq(ContractManage::getPartyA, customerContract.getPartyA())
|
||||
.eq(ContractManage::getPartyB, waybill.getCarrierName())
|
||||
.eq(ContractManage::getPartyB, carrierName)
|
||||
.eq(ContractManage::getContractCategory, "承运商合同")
|
||||
.and(wrapper -> wrapper.isNull(ContractManage::getContractStage)
|
||||
.or().ne(ContractManage::getContractStage, "terminated"))
|
||||
.orderByDesc(ContractManage::getCreateTime), false);
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = value.trim();
|
||||
return text.isEmpty() ? null : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应付客商取运单选择的承运商,避免写成项目内其他承运商合同的乙方。
|
||||
*/
|
||||
private String resolveCounterpartyName(Waybill waybill, ContractManage contract, String settlementType) {
|
||||
if ("payable".equals(settlementType)) {
|
||||
String carrierName = trimToNull(waybill == null ? null : waybill.getCarrierName());
|
||||
if (carrierName != null) {
|
||||
return carrierName;
|
||||
}
|
||||
return contract == null ? null : contract.getPartyB();
|
||||
}
|
||||
if (contract != null && Func.isNotEmpty(contract.getPartyA())) {
|
||||
return contract.getPartyA();
|
||||
}
|
||||
return waybill == null ? null : waybill.getCustomerName();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW,
|
||||
rollbackFor = Exception.class)
|
||||
@@ -1021,12 +1285,24 @@ public class ReceivablePayableDetailServiceImpl
|
||||
.collect(Collectors.toMap(ContractManage::getId, contract -> contract));
|
||||
Map<Long, List<Waybill>> carrierWaybills = waybills.stream().collect(Collectors.groupingBy(
|
||||
Waybill::getCarrierContractId, LinkedHashMap::new, Collectors.toList()));
|
||||
List<Waybill> mismatchedCarrierWaybills = waybills.stream()
|
||||
.filter(waybill -> !matchesWaybillCarrier(carrierContracts.get(waybill.getCarrierContractId()), waybill))
|
||||
.toList();
|
||||
if (!mismatchedCarrierWaybills.isEmpty()) {
|
||||
generateAutomaticWaybillDetails(mismatchedCarrierWaybills, false,
|
||||
this::resolveCarrierContract, currentUserId, generateTime);
|
||||
}
|
||||
for (Map.Entry<Long, List<Waybill>> entry : carrierWaybills.entrySet()) {
|
||||
ContractManage carrierContract = carrierContracts.get(entry.getKey());
|
||||
if (carrierContract == null || !"承运商合同".equals(carrierContract.getContractCategory())) {
|
||||
throw new ServiceException("总单【" + masterOrder.getMasterNo() + "】记录的承运商合同不存在或合同类别不正确");
|
||||
}
|
||||
List<Waybill> currentCarrierWaybills = entry.getValue();
|
||||
List<Waybill> currentCarrierWaybills = entry.getValue().stream()
|
||||
.filter(waybill -> matchesWaybillCarrier(carrierContract, waybill))
|
||||
.toList();
|
||||
if (currentCarrierWaybills.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
generateAutomaticDetail(carrierContract, "payable",
|
||||
() -> existsByMasterOrderContract(masterOrder.getMasterNo(), "payable", carrierContract.getId()),
|
||||
currentCarrierWaybills,
|
||||
@@ -1455,9 +1731,7 @@ public class ReceivablePayableDetailServiceImpl
|
||||
detail.setDeptId(contract == null ? waybill.getDeptId() : contract.getOrganizationId());
|
||||
detail.setDeptName(contract == null ? waybill.getDeptName() : contract.getOrganizationName());
|
||||
detail.setFeeDate(waybill.getEndDate() == null ? LocalDate.now() : waybill.getEndDate());
|
||||
detail.setCustomerName(contract == null
|
||||
? ("payable".equals(settlementType) ? waybill.getCarrierName() : waybill.getCustomerName())
|
||||
: ("payable".equals(settlementType) ? contract.getPartyB() : contract.getPartyA()));
|
||||
detail.setCustomerName(resolveCounterpartyName(waybill, contract, settlementType));
|
||||
detail.setContractId(contract == null ? waybill.getContractId() : contract.getId());
|
||||
detail.setContractNo(contract == null ? null : contract.getContractNo());
|
||||
detail.setContractName(contract == null ? waybill.getContractName() : contract.getContractName());
|
||||
@@ -1472,8 +1746,8 @@ public class ReceivablePayableDetailServiceImpl
|
||||
detail.setArrivalContact(waybill.getArrivalContact());
|
||||
detail.setArrivalPhone(waybill.getArrivalPhone());
|
||||
detail.setTransportType(waybill.getTransportType());
|
||||
detail.setCargoName(waybill.getCargoName());
|
||||
detail.setCargoType(waybill.getCargoType());
|
||||
detail.setCargoName(joinDistinctGoods(waybill, "cargoName", waybill.getCargoName()));
|
||||
detail.setCargoType(joinDistinctGoods(waybill, "cargoType", waybill.getCargoType()));
|
||||
detail.setTransportQuantity(resolveWaybillTransportQuantity(waybill));
|
||||
detail.setQuantityUnit(waybill.getQuantityUnit());
|
||||
detail.setMileage(normalizeGeneratedMileage(waybill.getMileage()));
|
||||
@@ -1488,6 +1762,21 @@ public class ReceivablePayableDetailServiceImpl
|
||||
return detail;
|
||||
}
|
||||
|
||||
/** 一单多货时主表只保留首行货物,应付明细改记全部货物名称、货物类型。 */
|
||||
private String joinDistinctGoods(Waybill waybill, String field, String fallback) {
|
||||
LinkedHashSet<String> values = new LinkedHashSet<>();
|
||||
for (Map<String, Object> goods : parseList(waybill.getGoodsJson())) {
|
||||
String value = stringValue(goods, field, "");
|
||||
if (Func.isNotEmpty(value)) {
|
||||
values.add(value.trim());
|
||||
}
|
||||
}
|
||||
if (values.isEmpty()) {
|
||||
return fallback;
|
||||
}
|
||||
return String.join(",", values);
|
||||
}
|
||||
|
||||
/**
|
||||
* 多货物运单优先按 goodsJson 数量合计作为运输总量,避免主表 quantity 只落了首行。
|
||||
*/
|
||||
@@ -1677,21 +1966,35 @@ public class ReceivablePayableDetailServiceImpl
|
||||
}
|
||||
|
||||
private List<ReceivablePayableCargoFee> calculatedFees(Waybill waybill, ContractManage contract, String planId) {
|
||||
return calculatedFees(waybill, contract, planId, false);
|
||||
return calculatedFees(waybill, contract, planId, false, false);
|
||||
}
|
||||
|
||||
private List<ReceivablePayableCargoFee> calculatedFees(Waybill waybill, ContractManage contract, String planId, boolean matchOnly) {
|
||||
private List<ReceivablePayableCargoFee> calculatedFees(Waybill waybill, ContractManage contract, String planId,
|
||||
boolean matchOnly) {
|
||||
return calculatedFees(waybill, contract, planId, matchOnly, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param strictPlan 更新费用时为 true:选定方案有规则但未命中时,不再回退运单历史费用,避免旧费用项叠加
|
||||
*/
|
||||
private List<ReceivablePayableCargoFee> calculatedFees(Waybill waybill, ContractManage contract, String planId,
|
||||
boolean matchOnly, boolean strictPlan) {
|
||||
List<Map<String, Object>> plans = parseList(contract == null ? null : contract.getBillingPlanJson());
|
||||
Map<String, Object> plan = "__matched__".equals(planId)
|
||||
? resolveDefaultBillingPlan(plans, waybill.getTransportType())
|
||||
: resolveBillingPlan(plans, planId);
|
||||
return calculatedFees(waybill, plan, matchOnly);
|
||||
return calculatedFees(waybill, plan, matchOnly, strictPlan);
|
||||
}
|
||||
|
||||
private List<ReceivablePayableCargoFee> calculatedFees(Waybill waybill, Map<String, Object> plan,
|
||||
boolean matchOnly) {
|
||||
return calculatedFees(waybill, plan, matchOnly, false);
|
||||
}
|
||||
|
||||
private List<ReceivablePayableCargoFee> calculatedFees(Waybill waybill, Map<String, Object> plan,
|
||||
boolean matchOnly, boolean strictPlan) {
|
||||
if (plan == null || !(plan.get("rules") instanceof List<?>)) {
|
||||
return matchOnly ? List.of() : buildCargoFees(waybill);
|
||||
return matchOnly || strictPlan ? List.of() : buildCargoFees(waybill);
|
||||
}
|
||||
Map<List<String>, ReceivablePayableCargoFee> feesByCargo = new LinkedHashMap<>();
|
||||
Map<List<String>, Map<String, BigDecimal>> feeItemsByCargo = new LinkedHashMap<>();
|
||||
@@ -1733,7 +2036,11 @@ public class ReceivablePayableDetailServiceImpl
|
||||
ReceivablePayableCargoFee fee = result.get(index);
|
||||
fee.setLineNo(String.format("%04d", index + 1));
|
||||
}
|
||||
return result.isEmpty() && !matchOnly ? buildCargoFees(waybill) : result;
|
||||
// 已选定计费方案时,只保留该方案算出的费用项,不用运单上的历史费用回填。
|
||||
if (strictPlan || matchOnly) {
|
||||
return result;
|
||||
}
|
||||
return result.isEmpty() ? buildCargoFees(waybill) : result;
|
||||
}
|
||||
|
||||
private ReceivablePayableCargoFee buildCalculatedCargoFee(Waybill waybill, Waybill feeWaybill,
|
||||
@@ -2254,10 +2561,16 @@ public class ReceivablePayableDetailServiceImpl
|
||||
private AdjustedFeeCalculation calculateAdjustedFee(ReceivablePayableDetail detail,
|
||||
ReceivablePayableCargoFee fee, BigDecimal transportQuantity,
|
||||
BigDecimal mileage, BigDecimal freightAmount,
|
||||
Map<String, BigDecimal> feeItems) {
|
||||
Map<String, BigDecimal> feeItems, BigDecimal unitPrice,
|
||||
String changedField) {
|
||||
if (isManualFee(fee)) {
|
||||
throw new ServiceException("手工费用不支持按合同计费规则试算");
|
||||
}
|
||||
boolean transportChanged = Func.isEmpty(changedField)
|
||||
|| List.of("transportQuantity", "mileage", "unitPrice").contains(changedField);
|
||||
if (!transportChanged) {
|
||||
return new AdjustedFeeCalculation(freightAmount, new LinkedHashMap<>(feeItems), null);
|
||||
}
|
||||
Waybill adjustedWaybill = adjustedWaybill(detail, fee, transportQuantity, mileage);
|
||||
List<Map<String, Object>> rules = parseList(fee.getBillingRulesJson());
|
||||
if (rules.isEmpty()) {
|
||||
@@ -2273,26 +2586,94 @@ public class ReceivablePayableDetailServiceImpl
|
||||
if (rules.isEmpty()) {
|
||||
throw new ServiceException("未找到费用明细对应的合同计费规则,请先更新费用");
|
||||
}
|
||||
Map<String, BigDecimal> calculatedAmounts = new LinkedHashMap<>();
|
||||
BigDecimal calculatedFreight = BigDecimal.ZERO;
|
||||
boolean freightRuleMatched = false;
|
||||
Map<String, BigDecimal> calculatedFeeItems = new LinkedHashMap<>(feeItems);
|
||||
BigDecimal calculatedFreight = null;
|
||||
BigDecimal resolvedUnitPrice = null;
|
||||
boolean calculated = false;
|
||||
for (Map<String, Object> rule : rules) {
|
||||
BigDecimal amount = calculateRule(rule, adjustedWaybill);
|
||||
if (amount == null) continue;
|
||||
if (!shouldRecalculateRule(rule, changedField)) {
|
||||
continue;
|
||||
}
|
||||
String type = stringValue(rule, "billingType", "");
|
||||
boolean freightRule = isFreightRule(rule);
|
||||
Map<String, Object> effectiveRule = rule;
|
||||
if (freightRule && unitPriceAdjustable(type) && unitPrice != null) {
|
||||
effectiveRule = new LinkedHashMap<>(rule);
|
||||
effectiveRule.put("unitPrice", unitPrice);
|
||||
resolvedUnitPrice = unitPrice;
|
||||
}
|
||||
BigDecimal amount = calculateRule(effectiveRule, adjustedWaybill);
|
||||
if (amount == null) {
|
||||
continue;
|
||||
}
|
||||
calculated = true;
|
||||
String feeItem = stringValue(rule, "feeItem", "费用");
|
||||
calculatedAmounts.merge(feeItem, amount, BigDecimal::add);
|
||||
if (isFreightRule(rule)) {
|
||||
calculatedFreight = calculatedFreight.add(amount);
|
||||
freightRuleMatched = true;
|
||||
calculatedFeeItems.put(feeItem, amount);
|
||||
if (freightRule) {
|
||||
calculatedFreight = (calculatedFreight == null ? BigDecimal.ZERO : calculatedFreight).add(amount);
|
||||
if (intervalBilling(type)) {
|
||||
resolvedUnitPrice = matchedIntervalUnitPrice(rule, adjustedWaybill);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (calculatedAmounts.isEmpty()) {
|
||||
if (!calculated && Func.isEmpty(changedField)) {
|
||||
throw new ServiceException("费用明细对应的合同计费规则无法试算,请先更新费用");
|
||||
}
|
||||
Map<String, BigDecimal> calculatedFeeItems = new LinkedHashMap<>(feeItems);
|
||||
calculatedAmounts.forEach(calculatedFeeItems::put);
|
||||
return new AdjustedFeeCalculation(freightRuleMatched ? calculatedFreight : freightAmount,
|
||||
calculatedFeeItems);
|
||||
return new AdjustedFeeCalculation(calculatedFreight == null ? freightAmount : calculatedFreight,
|
||||
calculatedFeeItems, resolvedUnitPrice);
|
||||
}
|
||||
|
||||
/**
|
||||
* 运费在运输总量、里程、可调整单价变化后按方案重算。
|
||||
* 其他费用仅在计费要素使用了本次变化的运输总量或里程时重算,便于保留手工修改的费用总额。
|
||||
*/
|
||||
private boolean shouldRecalculateRule(Map<String, Object> rule, String changedField) {
|
||||
String element = stringValue(rule, "billingElement", "");
|
||||
String type = stringValue(rule, "billingType", "");
|
||||
if (isFreightRule(rule)) {
|
||||
return !"unitPrice".equals(changedField) || unitPriceAdjustable(type);
|
||||
}
|
||||
if ("unitPrice".equals(changedField) || Func.isEmpty(changedField)) {
|
||||
return Func.isEmpty(changedField) && (usesTransportQuantity(element) || usesMileage(element));
|
||||
}
|
||||
if ("transportQuantity".equals(changedField)) {
|
||||
return usesTransportQuantity(element);
|
||||
}
|
||||
if ("mileage".equals(changedField)) {
|
||||
return usesMileage(element);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean usesTransportQuantity(String element) {
|
||||
return List.of("按重量", "按体积", "按数量", "按吨·公里").contains(element);
|
||||
}
|
||||
|
||||
private boolean usesMileage(String element) {
|
||||
return List.of("按里程", "按吨·公里").contains(element);
|
||||
}
|
||||
|
||||
private boolean unitPriceAdjustable(String billingType) {
|
||||
return !tierUnitPrice(billingType) && !intervalBilling(billingType);
|
||||
}
|
||||
|
||||
private boolean tierUnitPrice(String billingType) {
|
||||
return "阶梯单价".equals(billingType);
|
||||
}
|
||||
|
||||
private boolean intervalBilling(String billingType) {
|
||||
String type = billingType == null ? "" : billingType;
|
||||
return "区间单价".equals(type) || (type.contains("区间") && type.contains("一口价"));
|
||||
}
|
||||
|
||||
private BigDecimal matchedIntervalUnitPrice(Map<String, Object> rule, Waybill waybill) {
|
||||
BigDecimal fallback = decimal(rule.get("unitPrice"));
|
||||
return range(ranges(rule), measure(rule, waybill))
|
||||
.map(matched -> {
|
||||
BigDecimal matchedPrice = decimal(matched.get("unitPrice"));
|
||||
return matchedPrice.signum() == 0 ? fallback : matchedPrice;
|
||||
})
|
||||
.orElse(fallback);
|
||||
}
|
||||
|
||||
private Waybill adjustedWaybill(ReceivablePayableDetail detail, ReceivablePayableCargoFee fee,
|
||||
@@ -2365,7 +2746,8 @@ public class ReceivablePayableDetailServiceImpl
|
||||
return name != null && (name.contains("运费") || name.contains("运输费"));
|
||||
}
|
||||
|
||||
private record AdjustedFeeCalculation(BigDecimal freightAmount, Map<String, BigDecimal> feeItems) {
|
||||
private record AdjustedFeeCalculation(BigDecimal freightAmount, Map<String, BigDecimal> feeItems,
|
||||
BigDecimal unitPrice) {
|
||||
}
|
||||
|
||||
private void appendChange(List<String> changes, String field, BigDecimal before, BigDecimal after) {
|
||||
@@ -2568,7 +2950,7 @@ public class ReceivablePayableDetailServiceImpl
|
||||
throw new ServiceException("关联运单不存在");
|
||||
}
|
||||
ContractManage contract = contractManageService.getById(detail.getContractId());
|
||||
List<ReceivablePayableCargoFee> fees = calculatedFees(waybill, contract, billingPlanId);
|
||||
List<ReceivablePayableCargoFee> fees = calculatedFees(waybill, contract, billingPlanId, false, true);
|
||||
cargoFeeMapper.delete(Wrappers.<ReceivablePayableCargoFee>lambdaQuery().eq(ReceivablePayableCargoFee::getDetailId, detail.getId()));
|
||||
fees.forEach(fee -> { fee.setDetailId(detail.getId()); cargoFeeMapper.insert(fee); });
|
||||
BigDecimal freight = fees.stream().map(fee -> money(fee.getFreightAmount()))
|
||||
@@ -2593,7 +2975,7 @@ public class ReceivablePayableDetailServiceImpl
|
||||
ContractManage contract = contractManageService.getById(detail.getContractId());
|
||||
List<Waybill> masterGoods = masterOrderGoods(masterOrder);
|
||||
List<ReceivablePayableCargoFee> fees = masterGoods.stream()
|
||||
.flatMap(goods -> calculatedFees(goods, contract, billingPlanId).stream()).toList();
|
||||
.flatMap(goods -> calculatedFees(goods, contract, billingPlanId, false, true).stream()).toList();
|
||||
normalizeMasterFeeLines(fees);
|
||||
cargoFeeMapper.delete(Wrappers.<ReceivablePayableCargoFee>lambdaQuery()
|
||||
.eq(ReceivablePayableCargoFee::getDetailId, detail.getId()));
|
||||
@@ -2629,7 +3011,7 @@ public class ReceivablePayableDetailServiceImpl
|
||||
}
|
||||
ContractManage contract = contractManageService.getById(detail.getContractId());
|
||||
List<ReceivablePayableCargoFee> fees = waybills.stream()
|
||||
.flatMap(waybill -> calculatedFees(waybill, contract, billingPlanId).stream()).toList();
|
||||
.flatMap(waybill -> calculatedFees(waybill, contract, billingPlanId, false, true).stream()).toList();
|
||||
normalizeWaybillFeeLines(fees);
|
||||
cargoFeeMapper.delete(Wrappers.<ReceivablePayableCargoFee>lambdaQuery()
|
||||
.eq(ReceivablePayableCargoFee::getDetailId, detail.getId()));
|
||||
@@ -2704,21 +3086,29 @@ public class ReceivablePayableDetailServiceImpl
|
||||
if (detailIds.isEmpty()) return;
|
||||
List<ReceivablePayableCargoFee> fees = cargoFeeMapper.selectList(
|
||||
Wrappers.<ReceivablePayableCargoFee>lambdaQuery()
|
||||
.select(ReceivablePayableCargoFee::getDetailId, ReceivablePayableCargoFee::getCargoName)
|
||||
.select(ReceivablePayableCargoFee::getDetailId, ReceivablePayableCargoFee::getCargoName,
|
||||
ReceivablePayableCargoFee::getBillingType)
|
||||
.in(ReceivablePayableCargoFee::getDetailId, detailIds)
|
||||
.eq(ReceivablePayableCargoFee::getIsDeleted, 0)
|
||||
.orderByAsc(ReceivablePayableCargoFee::getDetailId)
|
||||
.orderByAsc(ReceivablePayableCargoFee::getLineNo));
|
||||
Map<Long, LinkedHashSet<String>> cargoNames = new LinkedHashMap<>();
|
||||
Map<Long, LinkedHashSet<String>> billingTypes = new LinkedHashMap<>();
|
||||
for (ReceivablePayableCargoFee fee : fees) {
|
||||
if (Func.isEmpty(fee.getCargoName())) continue;
|
||||
cargoNames.computeIfAbsent(fee.getDetailId(), key -> new LinkedHashSet<>()).add(fee.getCargoName());
|
||||
if (Func.isNotEmpty(fee.getCargoName())) {
|
||||
cargoNames.computeIfAbsent(fee.getDetailId(), key -> new LinkedHashSet<>()).add(fee.getCargoName());
|
||||
}
|
||||
if (Func.isNotEmpty(fee.getBillingType()) && !"-".equals(fee.getBillingType())) {
|
||||
billingTypes.computeIfAbsent(fee.getDetailId(), key -> new LinkedHashSet<>()).add(fee.getBillingType());
|
||||
}
|
||||
}
|
||||
details.forEach(detail -> {
|
||||
Set<String> names = cargoNames.get(detail.getId());
|
||||
if (Func.isNotEmpty(names)) {
|
||||
detail.setCargoName(String.join(",", names));
|
||||
}
|
||||
Set<String> types = billingTypes.get(detail.getId());
|
||||
detail.setBillingType(Func.isEmpty(types) ? "" : String.join("、", types));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2924,6 +3314,7 @@ public class ReceivablePayableDetailServiceImpl
|
||||
map.put("contractId", detail.getContractId());
|
||||
map.put("contractNo", detail.getContractNo());
|
||||
map.put("contractName", detail.getContractName());
|
||||
map.put("sourceType", detail.getSourceType());
|
||||
map.put("preSettlementNo", detail.getPreSettlementNo());
|
||||
map.put("formalSettlementNo", detail.getFormalSettlementNo());
|
||||
map.put("waybillNo", detail.getWaybillNo());
|
||||
|
||||
+188
-17
@@ -4,12 +4,18 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.mp.base.BaseServiceImpl;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.core.tool.utils.SpringUtil;
|
||||
import org.springblade.system.cache.UserCache;
|
||||
import org.springblade.transport.constant.KingdeeBfiConstant;
|
||||
import org.springblade.transport.constant.TransportBusiConstant;
|
||||
import org.springblade.transport.event.KingdeeBillApprovedEvent;
|
||||
import org.springblade.transport.support.KingdeeApBillSyncSupport;
|
||||
import org.springblade.transport.support.KingdeeArBillSyncSupport;
|
||||
import org.springblade.transport.mapper.FormalSettlementDetailFeeMapper;
|
||||
import org.springblade.transport.mapper.FormalSettlementDetailMapper;
|
||||
import org.springblade.transport.mapper.FormalSettlementChangeRecordMapper;
|
||||
@@ -33,7 +39,10 @@ import org.springblade.transport.pojo.entity.PaymentApplicationSettlement;
|
||||
import org.springblade.transport.pojo.entity.SettlementAdjustment;
|
||||
import org.springblade.transport.pojo.entity.SettlementAdjustmentDetail;
|
||||
import org.springblade.transport.pojo.vo.SettlementAdjustmentVO;
|
||||
import org.springblade.transport.service.IFormalSettlementService;
|
||||
import org.springblade.transport.service.ISettlementAdjustmentService;
|
||||
import org.springblade.transport.service.ISettlementProjectAdvanceWriteOffService;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -51,6 +60,7 @@ import java.util.Set;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementAdjustmentMapper, SettlementAdjustment>
|
||||
implements ISettlementAdjustmentService {
|
||||
private static final String PAID = "paid";
|
||||
@@ -63,6 +73,11 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
|
||||
private final FormalSettlementPaymentMapper formalPaymentMapper;
|
||||
private final PaymentApplicationMapper paymentApplicationMapper;
|
||||
private final PaymentApplicationSettlementMapper paymentApplicationSettlementMapper;
|
||||
private final IFormalSettlementService formalSettlementService;
|
||||
private final ISettlementProjectAdvanceWriteOffService projectAdvanceWriteOffService;
|
||||
private final KingdeeApBillSyncSupport kingdeeApBillSyncSupport;
|
||||
private final KingdeeArBillSyncSupport kingdeeArBillSyncSupport;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
@Override
|
||||
public IPage<SettlementAdjustmentVO> selectPage(IPage<SettlementAdjustment> page, SettlementAdjustmentVO query) {
|
||||
@@ -204,24 +219,142 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
|
||||
}
|
||||
@Override public void returnBill(SettlementAdjustmentStatusRequest request) { changeStatus(request.getId(), TransportBusiConstant.REVIEWING, TransportBusiConstant.RETURNED, "已驳回", limit(request.getReason(), 200)); }
|
||||
|
||||
/**
|
||||
* 重新推送(高危):原单已推送金蝶且旧单已在金蝶冲销后,克隆新正式结算单并应用调整,
|
||||
* 以新单号组成新防重键生成全新应付/应收单,调整单改挂新单、原单标记已调整并解锁旧锁。
|
||||
* <p>
|
||||
* 非事务编排:克隆+应用(独立事务)→ 推送(外部远程)→ 收尾回填,推送失败不回滚克隆,
|
||||
* 重试时按来源原单号幂等复用已克隆新单,从推送步骤续上。
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public String repush(Long adjustmentId) {
|
||||
SettlementAdjustment adjustment = existing(adjustmentId);
|
||||
if (!TransportBusiConstant.APPROVED.equals(adjustment.getApprovalStatus())) throw new ServiceException("仅审批通过的结算调整单允许重新推送");
|
||||
FormalSettlement formal = formalMapper.selectById(adjustment.getFormalSettlementId());
|
||||
if (formal == null || !"synced".equals(formal.getKingdeeSyncStatus())) throw new ServiceException("关联正式结算单尚未推送金蝶,无需重新推送");
|
||||
String kingdeeNo = "K3AP" + DateTimeFormatter.ofPattern("yyyyMMddHHmmss").format(LocalDateTime.now());
|
||||
formal.setKingdeeBillNo(kingdeeNo); formal.setSyncedTime(LocalDateTime.now()); formalMapper.updateById(formal);
|
||||
return kingdeeNo;
|
||||
if (!TransportBusiConstant.APPROVED.equals(adjustment.getApprovalStatus())) {
|
||||
throw new ServiceException("仅审批通过的结算调整单允许重新推送");
|
||||
}
|
||||
FormalSettlement original = formalMapper.selectById(adjustment.getFormalSettlementId());
|
||||
if (original == null) {
|
||||
throw new ServiceException("关联正式结算单不存在");
|
||||
}
|
||||
if (!KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(original.getKingdeeSyncStatus())) {
|
||||
throw new ServiceException("结算单[" + Func.toStr(original.getFormalSettlementNo())
|
||||
+ "]尚未推送金蝶,请先执行「推送金蝶」");
|
||||
}
|
||||
// 冲销硬闸门:仅当金蝶侧原应付/应收单已被冲销(部分冲销放行)才允许以新单号重推,避免重复生成财务单据
|
||||
if (!this.kingdeeWrittenOff(original)) {
|
||||
throw new ServiceException("结算单[" + Func.toStr(original.getFormalSettlementNo())
|
||||
+ "]的原应付/应收单尚未在金蝶冲销,禁止重推");
|
||||
}
|
||||
// 幂等:同一原单已克隆过新单则复用(重试从推送步骤续上),避免重复建单
|
||||
FormalSettlement clone = existingClone(original.getFormalSettlementNo());
|
||||
if (clone == null) {
|
||||
Long cloneId = self().cloneAndApplyForRepush(original.getId(), adjustment.getId());
|
||||
clone = formalMapper.selectById(cloneId);
|
||||
}
|
||||
// 推送新单:新单号天然组成新防重键,内部完成 A102-2/A302-2 推送 + 落锁 + 附件上传
|
||||
// shortcut: 成功后若再次点击重推,调整单已改挂新单且新单未冲销,会被冲销闸门拦截(无副作用);如需彻底隐藏由前端按「关联单是否克隆单」判断
|
||||
String billNo = formalSettlementService.syncKingdee(clone.getId());
|
||||
// 成功收尾:调整单改挂新单 + 回填金蝶单号/同步状态;原单标记已调整 + 旧锁解锁(业务数据不动)
|
||||
SettlementAdjustment patch = new SettlementAdjustment();
|
||||
patch.setId(adjustment.getId());
|
||||
patch.setFormalSettlementId(clone.getId());
|
||||
patch.setFormalSettlementNo(clone.getFormalSettlementNo());
|
||||
patch.setKingdeeSyncStatus(KingdeeBfiConstant.KINGDEE_STATUS_SYNCED);
|
||||
patch.setKingdeeBillNo(billNo);
|
||||
updateById(patch);
|
||||
formalSettlementService.markSettlementAdjusted(original.getId(), adjustment.getAdjustmentNo(), clone.getFormalSettlementNo());
|
||||
return billNo;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void approve(SettlementAdjustmentStatusRequest request) {
|
||||
public Long cloneAndApplyForRepush(Long originalSettlementId, Long adjustmentId) {
|
||||
IFormalSettlementService.CloneResult cloneResult = formalSettlementService.cloneFormalSettlement(originalSettlementId);
|
||||
FormalSettlement clone = formalMapper.selectById(cloneResult.settlementId());
|
||||
applyAdjustmentToSettlement(clone, existing(adjustmentId), cloneResult.detailIdMap(), cloneResult.feeIdMap());
|
||||
return cloneResult.settlementId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String syncKingdee(Long adjustmentId) {
|
||||
SettlementAdjustment adjustment = existing(adjustmentId);
|
||||
if (!TransportBusiConstant.APPROVED.equals(adjustment.getApprovalStatus())) {
|
||||
throw new ServiceException("仅审批通过的结算调整单允许推送金蝶");
|
||||
}
|
||||
// 未推送/生成失败场景:推送关联正式结算单,成功后由正式结算单回填调整单金蝶单号与同步状态
|
||||
return formalSettlementService.syncKingdee(adjustment.getFormalSettlementId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> kingdeeWriteOffPrecheck(Long formalSettlementId) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
FormalSettlement settlement = formalMapper.selectById(formalSettlementId);
|
||||
if (settlement == null) {
|
||||
throw new ServiceException("关联正式结算单不存在");
|
||||
}
|
||||
if (!KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(settlement.getKingdeeSyncStatus())) {
|
||||
result.put("syncStatus", KingdeeBfiConstant.KINGDEE_STATUS_UNSYNCED);
|
||||
result.put("writtenOff", null);
|
||||
result.put("message", "该结算单尚未推送金蝶,审批通过后将自动推送原单");
|
||||
return result;
|
||||
}
|
||||
result.put("syncStatus", KingdeeBfiConstant.KINGDEE_STATUS_SYNCED);
|
||||
try {
|
||||
boolean writtenOff = this.kingdeeWrittenOff(settlement);
|
||||
result.put("writtenOff", writtenOff);
|
||||
result.put("message", writtenOff
|
||||
? "该结算单已推送金蝶且旧单已冲销,审批后可直接重新推送新单"
|
||||
: "该结算单已推送金蝶且旧单尚未冲销,审批通过后需先由财务在金蝶完成冲销,才能重新推送");
|
||||
} catch (Exception exception) {
|
||||
// 预检为软提示:金蝶查询异常降级为「未校验」,不阻断建单,由审批分流与重推闸门兜底
|
||||
log.warn("结算调整单建单预检:金蝶冲销状态查询失败,降级未校验,正式结算单[{}]",
|
||||
settlement.getFormalSettlementNo(), exception);
|
||||
result.put("writtenOff", null);
|
||||
result.put("message", "金蝶冲销状态查询失败,未校验;审批后请以实际冲销情况为准");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public String approve(SettlementAdjustmentStatusRequest request) {
|
||||
SettlementAdjustment adjustment = existing(request.getId());
|
||||
if (!TransportBusiConstant.REVIEWING.equals(adjustment.getApprovalStatus())) throw new ServiceException("仅审批中的结算调整单允许审核");
|
||||
FormalSettlement formal = adjustableFormalSettlement(adjustment.getFormalSettlementId());
|
||||
boolean synced = KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(formal.getKingdeeSyncStatus());
|
||||
if (synced) {
|
||||
// 已推送场景:原单业务数据零改动,仅追加 1 条重推提示变更记录,待财务在金蝶手工冲销旧单后重推
|
||||
saveFormalChange(formal.getId(), "合计费用项", null, "调整",
|
||||
"结算调整单" + adjustment.getAdjustmentNo() + "审批通过,调整金额【" + money(adjustment.getAdjustmentAmount()) + "】,待重推金蝶",
|
||||
adjustment.getRemark());
|
||||
} else {
|
||||
// 未推送场景:回写原单费用/金额并重算结算金额,事务提交后由事件监听自动推送金蝶
|
||||
applyAdjustmentToSettlement(formal, adjustment, Map.of(), Map.of());
|
||||
saveFormalChange(formal.getId(), "合计费用项", null, "调整",
|
||||
"调整单" + adjustment.getAdjustmentNo() + "调整金额【" + money(adjustment.getAdjustmentAmount()) + "】",
|
||||
adjustment.getRemark());
|
||||
}
|
||||
adjustment.setKingdeeSyncStatus(formal.getKingdeeSyncStatus()); adjustment.setApprovalStatus(TransportBusiConstant.APPROVED); adjustment.setCurrentNode("审批通过"); adjustment.setCurrentProcessor(AuthUtil.getUserName()); adjustment.setApprovedTime(LocalDateTime.now()); updateById(adjustment);
|
||||
if (!synced) {
|
||||
// 审批事务提交后(AFTER_COMMIT)自动推原单,成功回填调整单 synced + 金蝶单号、失败置 failed
|
||||
eventPublisher.publishEvent(new KingdeeBillApprovedEvent(
|
||||
KingdeeBillApprovedEvent.BIZ_FORMAL_SETTLEMENT, formal.getId()));
|
||||
return null;
|
||||
}
|
||||
return "原结算单已推送金蝶,请财务人员在金蝶手工完成旧应付/应收单冲销处理后再重新推送";
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用调整到目标正式结算单(未推送场景作用于原单,重推场景作用于克隆新单):
|
||||
* 回写明细费项金额、汇总费用项、重算明细与结算金额;明细/费项 id 经映射转换(原单调整作用于原单时传空映射)。
|
||||
*
|
||||
* @param formal 目标正式结算单(改动后需落库)
|
||||
* @param adjustment 结算调整单
|
||||
* @param detailIdMap 原结算明细id -> 目标结算明细id(原单场景传空 Map)
|
||||
* @param feeIdMap 原明细费项id -> 目标明细费项id(原单场景传空 Map)
|
||||
*/
|
||||
private void applyAdjustmentToSettlement(FormalSettlement formal, SettlementAdjustment adjustment,
|
||||
Map<Long, Long> detailIdMap, Map<Long, Long> feeIdMap) {
|
||||
for (SettlementAdjustmentDetail item : detailMapper.selectList(Wrappers.<SettlementAdjustmentDetail>lambdaQuery().eq(SettlementAdjustmentDetail::getAdjustmentId, adjustment.getId()))) {
|
||||
if (money(item.getAdjustmentAmountTax()).signum() == 0) continue;
|
||||
if (isManualFee(item)) {
|
||||
@@ -229,12 +362,14 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
|
||||
"新增【" + item.getFeeItem() + "】调整费用【" + money(item.getAdjustmentAmountTax()) + "】",
|
||||
Func.isEmpty(item.getRemark()) ? adjustment.getRemark() : item.getRemark());
|
||||
} else {
|
||||
FormalSettlementDetailFee fee = validateFee(formal.getId(), item.getFormalSettlementDetailId(), item.getFormalSettlementDetailFeeId());
|
||||
Long detailId = detailIdMap.getOrDefault(item.getFormalSettlementDetailId(), item.getFormalSettlementDetailId());
|
||||
Long feeId = feeIdMap.getOrDefault(item.getFormalSettlementDetailFeeId(), item.getFormalSettlementDetailFeeId());
|
||||
FormalSettlementDetailFee fee = validateFee(formal.getId(), detailId, feeId);
|
||||
BigDecimal beforeAmount = money(fee.getSettlementAmountTax());
|
||||
fee.setSettlementAmountTax(beforeAmount.add(money(item.getAdjustmentAmountTax())));
|
||||
if (item.getAdjustmentAmountNoTax() != null) fee.setSettlementAmountNoTax(money(fee.getSettlementAmountNoTax()).add(item.getAdjustmentAmountNoTax()));
|
||||
fee.setAdjustAmount(money(fee.getAdjustAmount()).add(item.getAdjustmentAmountTax())); formalFeeMapper.updateById(fee);
|
||||
FormalSettlementDetail formalDetail = formalDetailMapper.selectById(item.getFormalSettlementDetailId());
|
||||
FormalSettlementDetail formalDetail = formalDetailMapper.selectById(detailId);
|
||||
saveFormalChange(formal.getId(), "结算明细项", formalDetail == null ? null : formalDetail.getLineNo(),
|
||||
"调整", "【" + (Func.isEmpty(item.getFeeItem()) ? "结算费用" : item.getFeeItem())
|
||||
+ "】从【" + beforeAmount + "】调整为【" + fee.getSettlementAmountTax() + "】",
|
||||
@@ -249,15 +384,46 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
|
||||
detail.setAdjustAmount(detail.getSettlementAmountTax().subtract(money(detail.getOriginalAmount()))); formalDetailMapper.updateById(detail);
|
||||
}
|
||||
BigDecimal amount = money(formal.getSettlementAmount()).add(money(adjustment.getAdjustmentAmount()));
|
||||
// 调整后结算金额不得低于项目预付核销合计,否则已付将超过结算金额且无法拦截
|
||||
BigDecimal writeOffTotal = this.projectAdvanceWriteOffService.sumBySettlement(
|
||||
TransportBusiConstant.SETTLEMENT_BILL_FORMAL, formal.getId());
|
||||
if (money(writeOffTotal).compareTo(money(amount)) > 0) {
|
||||
throw new ServiceException("调整后结算金额不能低于项目预付核销合计" + money(writeOffTotal) + ",请先调减项目预付核销");
|
||||
}
|
||||
formal.setSettlementAmount(amount);
|
||||
formal.setLocalSettlementAmount(amount.multiply(formal.getExchangeRate() == null ? BigDecimal.ONE : formal.getExchangeRate()));
|
||||
formal.setRemainingPayableAmount(amount.subtract(money(formal.getPaidAmount()))
|
||||
.max(BigDecimal.ZERO));
|
||||
formal.setRemainingPayableAmount(amount.subtract(money(formal.getPaidAmount())).max(BigDecimal.ZERO));
|
||||
formalMapper.updateById(formal);
|
||||
saveFormalChange(formal.getId(), "合计费用项", null, "调整",
|
||||
"调整单" + adjustment.getAdjustmentNo() + "调整金额【" + money(adjustment.getAdjustmentAmount()) + "】",
|
||||
adjustment.getRemark());
|
||||
adjustment.setKingdeeSyncStatus(formal.getKingdeeSyncStatus()); adjustment.setApprovalStatus(TransportBusiConstant.APPROVED); adjustment.setCurrentNode("审批通过"); adjustment.setCurrentProcessor(AuthUtil.getUserName()); adjustment.setApprovedTime(LocalDateTime.now()); updateById(adjustment);
|
||||
// 重算收付汇总(含核销计入),保持付款状态与剩余可付准确
|
||||
formalSettlementService.refreshPaymentSummary(formal.getId());
|
||||
}
|
||||
|
||||
/** 涉事事务的自代理:跨方法调用确保 {@link #cloneAndApplyForRepush} 的事务注解生效 */
|
||||
private ISettlementAdjustmentService self() {
|
||||
return SpringUtil.getBean(ISettlementAdjustmentService.class);
|
||||
}
|
||||
|
||||
/** 按来源原正式结算单号查已克隆新单(重推幂等:同一原单不重复建单) */
|
||||
private FormalSettlement existingClone(String originFormalSettlementNo) {
|
||||
List<FormalSettlement> clones = formalMapper.selectList(Wrappers.<FormalSettlement>lambdaQuery()
|
||||
.eq(FormalSettlement::getOriginFormalSettlementNo, originFormalSettlementNo)
|
||||
.orderByDesc(FormalSettlement::getCreateTime));
|
||||
return clones.isEmpty() ? null : clones.get(0);
|
||||
}
|
||||
|
||||
/** 按原单旧防重键(原结算单号_明细单据号)查金蝶是否已冲销(应付 A102-4 / 应收 A302-4,部分冲销放行) */
|
||||
private boolean kingdeeWrittenOff(FormalSettlement settlement) {
|
||||
List<FormalSettlementDetail> details = formalDetailMapper.selectList(Wrappers.<FormalSettlementDetail>lambdaQuery()
|
||||
.eq(FormalSettlementDetail::getFormalSettlementId, settlement.getId())
|
||||
.eq(FormalSettlementDetail::getIsDeleted, 0)
|
||||
.orderByAsc(FormalSettlementDetail::getLineNo));
|
||||
if (Func.isEmpty(details)) {
|
||||
return false;
|
||||
}
|
||||
if ("receivable".equals(settlement.getSettlementType())) {
|
||||
return kingdeeArBillSyncSupport.queryArBillWrittenOff(kingdeeArBillSyncSupport.arBillSrcCode(settlement, details));
|
||||
}
|
||||
return kingdeeApBillSyncSupport.queryApBillWrittenOff(kingdeeApBillSyncSupport.apBillSrcCode(settlement, details));
|
||||
}
|
||||
|
||||
private void saveFormalChange(Long settlementId, String changeType, Integer lineNo,
|
||||
@@ -392,7 +558,12 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
|
||||
private SettlementAdjustmentVO toVO(SettlementAdjustment item) { SettlementAdjustmentVO vo = new SettlementAdjustmentVO(); org.springframework.beans.BeanUtils.copyProperties(item, vo); vo.setCreateUserName(UserCache.getUserRealName(item.getCreateUser())); vo.setApprovalStatusName(statusName(item.getApprovalStatus())); vo.setSettlementTypeName(typeName(item.getSettlementType())); return vo; }
|
||||
private String statusName(String value) { return Map.of(TransportBusiConstant.DRAFT, "草稿", TransportBusiConstant.REVIEWING, "审批中", TransportBusiConstant.APPROVED, "审批通过", TransportBusiConstant.RETURNED, "已驳回").getOrDefault(value, value); }
|
||||
private String typeName(String value) { return "receivable".equals(value) ? "应收" : "payable".equals(value) ? "应付" : ""; }
|
||||
private synchronized String nextNo() { String prefix = "TZ" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); long count = count(Wrappers.<SettlementAdjustment>lambdaQuery().likeRight(SettlementAdjustment::getAdjustmentNo, prefix)); return prefix + String.format("%04d", count + 1); }
|
||||
/** 生成当日结算调整单号;按最大流水号 +1,且计入逻辑删除数据 */
|
||||
private synchronized String nextNo() {
|
||||
String prefix = "TZ" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
|
||||
Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix);
|
||||
return prefix + String.format("%04d", (maxSequence == null ? 0L : maxSequence) + 1);
|
||||
}
|
||||
private BigDecimal money(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; }
|
||||
private String requiredText(String value, String field, int max) { if (Func.isEmpty(value)) throw new ServiceException(field + "不能为空"); return limit(value.trim(), max); }
|
||||
private String limit(String value, int max) { if (value != null && value.length() > max) throw new ServiceException("内容不能超过" + max + "个字"); return value; }
|
||||
|
||||
+1405
File diff suppressed because it is too large
Load Diff
+267
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.cache.UserCache;
|
||||
import org.springblade.transport.constant.KingdeeBfiConstant;
|
||||
import org.springblade.transport.constant.TransportBusiConstant;
|
||||
import org.springblade.transport.mapper.PaymentApplicationMapper;
|
||||
import org.springblade.transport.mapper.SettlementProjectAdvanceWriteOffMapper;
|
||||
import org.springblade.transport.pojo.dto.SettlementProjectAdvanceWriteOffRequest;
|
||||
import org.springblade.transport.pojo.entity.PaymentApplication;
|
||||
import org.springblade.transport.pojo.entity.SettlementProjectAdvanceWriteOff;
|
||||
import org.springblade.transport.pojo.vo.ProjectAdvanceWriteOffOptionVO;
|
||||
import org.springblade.transport.service.ISettlementProjectAdvanceWriteOffService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 结算单项目预付核销服务实现
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SettlementProjectAdvanceWriteOffServiceImpl implements ISettlementProjectAdvanceWriteOffService {
|
||||
|
||||
private final SettlementProjectAdvanceWriteOffMapper writeOffMapper;
|
||||
private final PaymentApplicationMapper paymentApplicationMapper;
|
||||
|
||||
@Override
|
||||
public List<ProjectAdvanceWriteOffOptionVO> listOptions(Long contractId, String excludeSettlementType,
|
||||
Long excludeSettlementId) {
|
||||
if (contractId == null) {
|
||||
throw new ServiceException("请先选择合同");
|
||||
}
|
||||
List<PaymentApplication> applications = paymentApplicationMapper.selectList(
|
||||
Wrappers.<PaymentApplication>lambdaQuery()
|
||||
.eq(PaymentApplication::getContractId, contractId)
|
||||
.eq(PaymentApplication::getPaymentType, TransportBusiConstant.PAYMENT_TYPE_PROJECT_ADVANCE)
|
||||
.eq(PaymentApplication::getApprovalStatus, TransportBusiConstant.APPROVED)
|
||||
.eq(PaymentApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_PAID)
|
||||
.eq(PaymentApplication::getIsDeleted, 0)
|
||||
.orderByDesc(PaymentApplication::getCreateTime));
|
||||
List<ProjectAdvanceWriteOffOptionVO> options = new ArrayList<>();
|
||||
for (PaymentApplication application : applications) {
|
||||
BigDecimal projectAdvanceAmount = money(application.getAppliedAmount());
|
||||
BigDecimal usedAmount = money(writeOffMapper.sumWriteOffByPaymentApplication(
|
||||
application.getId(), excludeSettlementType, excludeSettlementId));
|
||||
BigDecimal availableAmount = projectAdvanceAmount.subtract(usedAmount).max(BigDecimal.ZERO);
|
||||
if (availableAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
continue;
|
||||
}
|
||||
ProjectAdvanceWriteOffOptionVO option = new ProjectAdvanceWriteOffOptionVO();
|
||||
option.setPaymentApplicationId(application.getId());
|
||||
option.setPaymentNo(application.getPaymentNo());
|
||||
option.setProjectAdvanceAmount(projectAdvanceAmount);
|
||||
option.setAvailableWriteOffAmount(availableAmount);
|
||||
option.setKingdeeBillNo(application.getKingdeeBillNo());
|
||||
option.setCreateUserName(UserCache.getUserRealName(application.getCreateUser()));
|
||||
option.setCreateTime(application.getCreateTime());
|
||||
options.add(option);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SettlementProjectAdvanceWriteOff> listBySettlement(String settlementType, Long settlementId) {
|
||||
if (Func.isEmpty(settlementType) || settlementId == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<SettlementProjectAdvanceWriteOff> rows = writeOffMapper.selectList(
|
||||
Wrappers.<SettlementProjectAdvanceWriteOff>lambdaQuery()
|
||||
.eq(SettlementProjectAdvanceWriteOff::getSettlementType, settlementType)
|
||||
.eq(SettlementProjectAdvanceWriteOff::getSettlementId, settlementId)
|
||||
.eq(SettlementProjectAdvanceWriteOff::getIsDeleted, 0)
|
||||
.orderByAsc(SettlementProjectAdvanceWriteOff::getCreateTime)
|
||||
.orderByAsc(SettlementProjectAdvanceWriteOff::getId));
|
||||
for (SettlementProjectAdvanceWriteOff row : rows) {
|
||||
PaymentApplication application = paymentApplicationMapper.selectById(row.getPaymentApplicationId());
|
||||
BigDecimal projectAdvanceAmount = application == null
|
||||
? money(row.getProjectAdvanceAmount()) : money(application.getAppliedAmount());
|
||||
BigDecimal usedAmount = money(writeOffMapper.sumWriteOffByPaymentApplication(
|
||||
row.getPaymentApplicationId(), settlementType, settlementId));
|
||||
row.setProjectAdvanceAmount(projectAdvanceAmount);
|
||||
row.setAvailableWriteOffAmount(projectAdvanceAmount.subtract(usedAmount).max(BigDecimal.ZERO));
|
||||
if (application != null) {
|
||||
row.setPaymentNo(application.getPaymentNo());
|
||||
row.setKingdeeBillNo(application.getKingdeeBillNo());
|
||||
row.setCreateUserName(UserCache.getUserRealName(application.getCreateUser()));
|
||||
row.setPaymentCreateTime(application.getCreateTime());
|
||||
} else {
|
||||
row.setCreateUserName(UserCache.getUserRealName(row.getCreateUser()));
|
||||
row.setPaymentCreateTime(row.getCreateTime());
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BigDecimal sumBySettlement(String settlementType, Long settlementId) {
|
||||
if (Func.isEmpty(settlementType) || settlementId == null) {
|
||||
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
return money(writeOffMapper.sumWriteOffBySettlement(settlementType, settlementId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<Long, BigDecimal> sumBySettlementIds(String settlementType, Collection<Long> settlementIds) {
|
||||
if (Func.isEmpty(settlementType) || Func.isEmpty(settlementIds)) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<Long, BigDecimal> result = new HashMap<>();
|
||||
for (Map<String, Object> row : writeOffMapper.sumWriteOffBySettlementIds(settlementType, settlementIds)) {
|
||||
if (row.get("settlementId") instanceof Number settlementId && row.get("totalAmount") instanceof BigDecimal totalAmount) {
|
||||
result.put(settlementId.longValue(), money(totalAmount));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void replaceWriteOffs(String settlementType, Long settlementId, Long contractId,
|
||||
BigDecimal settlementAmount, List<SettlementProjectAdvanceWriteOffRequest> writeOffs) {
|
||||
if (Func.isEmpty(settlementType) || settlementId == null) {
|
||||
throw new ServiceException("结算单不能为空");
|
||||
}
|
||||
if (contractId == null) {
|
||||
throw new ServiceException("合同不能为空");
|
||||
}
|
||||
List<SettlementProjectAdvanceWriteOffRequest> requestRows = writeOffs == null ? List.of() : writeOffs;
|
||||
BigDecimal settlementLimit = money(settlementAmount);
|
||||
BigDecimal totalWriteOff = BigDecimal.ZERO;
|
||||
Set<Long> paymentIds = new HashSet<>();
|
||||
List<SettlementProjectAdvanceWriteOff> entities = new ArrayList<>();
|
||||
for (SettlementProjectAdvanceWriteOffRequest request : requestRows) {
|
||||
if (request == null || request.getPaymentApplicationId() == null) {
|
||||
throw new ServiceException("请选择项目预付单");
|
||||
}
|
||||
if (!paymentIds.add(request.getPaymentApplicationId())) {
|
||||
throw new ServiceException("同一项目预付单不可重复核销");
|
||||
}
|
||||
BigDecimal writeOffAmount = money(request.getWriteOffAmount());
|
||||
if (writeOffAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new ServiceException("本次核销金额必须大于0");
|
||||
}
|
||||
PaymentApplication application = paymentApplicationMapper.selectById(request.getPaymentApplicationId());
|
||||
if (application == null || Objects.equals(application.getIsDeleted(), 1)) {
|
||||
throw new ServiceException("项目预付单不存在");
|
||||
}
|
||||
if (!TransportBusiConstant.PAYMENT_TYPE_PROJECT_ADVANCE.equals(application.getPaymentType())) {
|
||||
throw new ServiceException("仅可核销项目预付单");
|
||||
}
|
||||
if (!TransportBusiConstant.APPROVED.equals(application.getApprovalStatus())) {
|
||||
throw new ServiceException("仅可选择审核通过的项目预付单");
|
||||
}
|
||||
if (!KingdeeBfiConstant.KINGDEE_STATUS_PAID.equals(application.getKingdeeStatus())) {
|
||||
throw new ServiceException("预付单" + Func.toStr(application.getPaymentNo(), "") + "尚未付款,项目预付须金蝶付款完成后方可核销");
|
||||
}
|
||||
if (!Objects.equals(contractId, application.getContractId())) {
|
||||
throw new ServiceException("项目预付单与结算单合同不一致");
|
||||
}
|
||||
BigDecimal projectAdvanceAmount = money(application.getAppliedAmount());
|
||||
BigDecimal usedAmount = money(writeOffMapper.sumWriteOffByPaymentApplication(
|
||||
application.getId(), settlementType, settlementId));
|
||||
BigDecimal availableAmount = projectAdvanceAmount.subtract(usedAmount).max(BigDecimal.ZERO);
|
||||
if (writeOffAmount.compareTo(availableAmount) > 0) {
|
||||
throw new ServiceException("预付单" + Func.toStr(application.getPaymentNo(), "")
|
||||
+ "本次核销金额不能超过可核销金额");
|
||||
}
|
||||
totalWriteOff = totalWriteOff.add(writeOffAmount);
|
||||
SettlementProjectAdvanceWriteOff entity = new SettlementProjectAdvanceWriteOff();
|
||||
entity.setSettlementType(settlementType);
|
||||
entity.setSettlementId(settlementId);
|
||||
entity.setPaymentApplicationId(application.getId());
|
||||
entity.setPaymentNo(application.getPaymentNo());
|
||||
entity.setProjectAdvanceAmount(projectAdvanceAmount);
|
||||
entity.setWriteOffAmount(writeOffAmount);
|
||||
entity.setKingdeeBillNo(application.getKingdeeBillNo());
|
||||
entity.setStatus(1);
|
||||
entity.setIsDeleted(0);
|
||||
entities.add(entity);
|
||||
}
|
||||
if (totalWriteOff.compareTo(settlementLimit) > 0) {
|
||||
throw new ServiceException("项目预付核销合计不能超过结算单金额");
|
||||
}
|
||||
writeOffMapper.delete(Wrappers.<SettlementProjectAdvanceWriteOff>lambdaQuery()
|
||||
.eq(SettlementProjectAdvanceWriteOff::getSettlementType, settlementType)
|
||||
.eq(SettlementProjectAdvanceWriteOff::getSettlementId, settlementId));
|
||||
for (SettlementProjectAdvanceWriteOff entity : entities) {
|
||||
writeOffMapper.insert(entity);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void deleteBySettlement(String settlementType, Long settlementId) {
|
||||
if (Func.isEmpty(settlementType) || settlementId == null) {
|
||||
return;
|
||||
}
|
||||
writeOffMapper.delete(Wrappers.<SettlementProjectAdvanceWriteOff>lambdaQuery()
|
||||
.eq(SettlementProjectAdvanceWriteOff::getSettlementType, settlementType)
|
||||
.eq(SettlementProjectAdvanceWriteOff::getSettlementId, settlementId));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public int deleteBySettlementIds(String settlementType, Collection<Long> settlementIds) {
|
||||
if (Func.isEmpty(settlementType) || Func.isEmpty(settlementIds)) {
|
||||
return 0;
|
||||
}
|
||||
return writeOffMapper.delete(Wrappers.<SettlementProjectAdvanceWriteOff>lambdaQuery()
|
||||
.eq(SettlementProjectAdvanceWriteOff::getSettlementType, settlementType)
|
||||
.in(SettlementProjectAdvanceWriteOff::getSettlementId, settlementIds));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public List<Long> deleteByPaymentApplicationIds(Collection<Long> paymentApplicationIds) {
|
||||
if (Func.isEmpty(paymentApplicationIds)) {
|
||||
return List.of();
|
||||
}
|
||||
List<SettlementProjectAdvanceWriteOff> rows = writeOffMapper.selectList(
|
||||
Wrappers.<SettlementProjectAdvanceWriteOff>lambdaQuery()
|
||||
.eq(SettlementProjectAdvanceWriteOff::getIsDeleted, 0)
|
||||
.in(SettlementProjectAdvanceWriteOff::getPaymentApplicationId, paymentApplicationIds));
|
||||
if (rows.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
writeOffMapper.delete(Wrappers.<SettlementProjectAdvanceWriteOff>lambdaQuery()
|
||||
.in(SettlementProjectAdvanceWriteOff::getPaymentApplicationId, paymentApplicationIds));
|
||||
return rows.stream()
|
||||
.filter(row -> TransportBusiConstant.SETTLEMENT_BILL_FORMAL.equals(row.getSettlementType()))
|
||||
.map(SettlementProjectAdvanceWriteOff::getSettlementId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
}
|
||||
|
||||
private BigDecimal money(BigDecimal value) {
|
||||
return value == null ? BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)
|
||||
: value.setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
}
|
||||
+622
@@ -0,0 +1,622 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.tenant.TenantUtil;
|
||||
import org.springblade.core.tool.jackson.JsonUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.constant.TransportBusiConstant;
|
||||
import org.springblade.transport.mapper.PreSettlementDetailMapper;
|
||||
import org.springblade.transport.mapper.ReceivablePayableDetailMapper;
|
||||
import org.springblade.transport.pojo.dto.FormalSettlementSaveRequest;
|
||||
import org.springblade.transport.pojo.dto.PreSettlementSaveRequest;
|
||||
import org.springblade.transport.pojo.entity.ContractManage;
|
||||
import org.springblade.transport.pojo.entity.PreSettlement;
|
||||
import org.springblade.transport.pojo.entity.PreSettlementDetail;
|
||||
import org.springblade.transport.pojo.entity.ReceivablePayableDetail;
|
||||
import org.springblade.transport.service.IContractManageService;
|
||||
import org.springblade.transport.service.IFormalSettlementService;
|
||||
import org.springblade.transport.service.IPreSettlementService;
|
||||
import org.springblade.transport.service.ISettlementRuleAutoGenerateService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.YearMonth;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 按合同结算规则自动生成结算草稿。
|
||||
* <p>
|
||||
* 账期在次日零点视为关闭:日结/现结取前一日,周结和固定天数从账单开始日期顺延,
|
||||
* 月结按自然月、固定截单日或自定义多周期截取。没有可归集明细时不生成空单。
|
||||
* 同一明细只进入一张结算单,预结算先于正式结算执行。正式结算同时归集费用日期完整落在账期内、且尚未转正式结算的已审批预结算单。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SettlementRuleAutoGenerateServiceImpl implements ISettlementRuleAutoGenerateService {
|
||||
|
||||
private static final int QUERY_BATCH_SIZE = 500;
|
||||
|
||||
private static final String LOCAL_CURRENCY = "RMB";
|
||||
|
||||
private final IContractManageService contractManageService;
|
||||
|
||||
private final IPreSettlementService preSettlementService;
|
||||
|
||||
private final IFormalSettlementService formalSettlementService;
|
||||
|
||||
private final ReceivablePayableDetailMapper receivablePayableDetailMapper;
|
||||
|
||||
private final PreSettlementDetailMapper preSettlementDetailMapper;
|
||||
|
||||
private final PlatformTransactionManager transactionManager;
|
||||
|
||||
@Override
|
||||
public synchronized String generateDueSettlements() {
|
||||
LocalDate today = LocalDate.now();
|
||||
List<ContractManage> contracts = TenantUtil.ignore(this::listAutoContracts);
|
||||
int preCount = 0;
|
||||
int formalCount = 0;
|
||||
int failedCount = 0;
|
||||
for (ContractManage contract : contracts) {
|
||||
if (Func.isBlank(contract.getTenantId())) {
|
||||
log.warn("合同[{}]缺少租户,跳过自动生成结算单", contract.getContractNo());
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
int[] created = TenantUtil.use(contract.getTenantId(), () -> generateContract(contract, today));
|
||||
preCount += created[0];
|
||||
formalCount += created[1];
|
||||
} catch (Exception exception) {
|
||||
failedCount++;
|
||||
log.error("合同[{}]自动生成结算单失败", contract.getContractNo(), exception);
|
||||
}
|
||||
}
|
||||
return "扫描合同" + contracts.size() + "份,生成预结算单" + preCount + "张,正式结算单" + formalCount
|
||||
+ "张,失败" + failedCount + "份";
|
||||
}
|
||||
|
||||
private List<ContractManage> listAutoContracts() {
|
||||
return contractManageService.list(Wrappers.<ContractManage>lambdaQuery()
|
||||
.in(ContractManage::getApprovalStatus, "approved", "change_approved")
|
||||
.in(ContractManage::getContractCategory, "客户合同", "承运商合同")
|
||||
.and(query -> query.isNull(ContractManage::getContractStage)
|
||||
.or().ne(ContractManage::getContractStage, "terminated"))
|
||||
.and(query -> query.like(ContractManage::getPreSettlementConfigJson, "autoGenerate")
|
||||
.or().like(ContractManage::getFormalSettlementConfigJson, "autoGenerate")
|
||||
.or().like(ContractManage::getSettlementRuleJson, "autoGenerate")));
|
||||
}
|
||||
|
||||
private int[] generateContract(ContractManage contract, LocalDate today) {
|
||||
String settlementType = contractSettlementType(contract);
|
||||
if (settlementType == null || contract.getProjectId() == null || contract.getOrganizationId() == null) {
|
||||
log.warn("合同[{}]缺少结算类型、项目或所属组织,跳过自动生成", contract.getContractNo());
|
||||
return new int[]{0, 0};
|
||||
}
|
||||
int preCount = generateBills(contract, settlementType, resolveRule(contract, true), today, true);
|
||||
int formalCount = generateBills(contract, settlementType, resolveRule(contract, false), today, false);
|
||||
return new int[]{preCount, formalCount};
|
||||
}
|
||||
|
||||
private int generateBills(ContractManage contract, String settlementType, Map<String, Object> rule,
|
||||
LocalDate today, boolean preBill) {
|
||||
if (!enabled(rule.get("autoGenerate"))) {
|
||||
return 0;
|
||||
}
|
||||
String cycleName = Func.toStr(rule.get("settlementType")).trim();
|
||||
if (!knownCycle(cycleName)) {
|
||||
log.warn("合同[{}]{}结算周期[{}]无法识别,跳过自动生成", contract.getContractNo(),
|
||||
preBill ? "预" : "正式", cycleName);
|
||||
return 0;
|
||||
}
|
||||
List<DateRange> ranges = closedPeriods(rule, today);
|
||||
if (ranges.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
if (preBill) {
|
||||
return createPreBills(contract, ranges);
|
||||
}
|
||||
return createFormalBills(contract, settlementType, ranges);
|
||||
}
|
||||
|
||||
private int createPreBills(ContractManage contract, List<DateRange> ranges) {
|
||||
List<ReceivablePayableDetail> details = loadPendingDetails(contract, ranges);
|
||||
Map<DateRange, List<ReceivablePayableDetail>> grouped = groupDetails(ranges, details);
|
||||
int created = 0;
|
||||
for (Map.Entry<DateRange, List<ReceivablePayableDetail>> entry : grouped.entrySet()) {
|
||||
Map<String, List<Long>> byCurrency = new LinkedHashMap<>();
|
||||
for (ReceivablePayableDetail detail : entry.getValue()) {
|
||||
String currency = Func.isEmpty(detail.getCurrency()) ? LOCAL_CURRENCY : detail.getCurrency();
|
||||
byCurrency.computeIfAbsent(currency, key -> new ArrayList<>()).add(detail.getId());
|
||||
}
|
||||
for (List<Long> detailIds : byCurrency.values()) {
|
||||
try {
|
||||
inTransaction(() -> {
|
||||
PreSettlementSaveRequest request = new PreSettlementSaveRequest();
|
||||
request.setContractId(contract.getId());
|
||||
request.setSourceDetailIds(detailIds);
|
||||
request.setRemark(periodRemark(entry.getKey()));
|
||||
preSettlementService.saveDraft(request);
|
||||
});
|
||||
created++;
|
||||
log.info("合同[{}]自动生成预结算单,账期{}至{},明细{}条", contract.getContractNo(),
|
||||
entry.getKey().start(), entry.getKey().end(), detailIds.size());
|
||||
} catch (Exception exception) {
|
||||
log.error("合同[{}]自动生成预结算单失败,账期{}至{}", contract.getContractNo(),
|
||||
entry.getKey().start(), entry.getKey().end(), exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
private int createFormalBills(ContractManage contract, String settlementType, List<DateRange> ranges) {
|
||||
Map<DateRange, List<ReceivablePayableDetail>> detailGroups = groupDetails(ranges, loadPendingDetails(contract, ranges));
|
||||
Map<DateRange, List<PreSettlement>> preGroups = groupApprovedPres(contract, settlementType, ranges);
|
||||
int created = 0;
|
||||
for (DateRange range : ranges) {
|
||||
Map<String, List<Long>> detailIds = new LinkedHashMap<>();
|
||||
for (ReceivablePayableDetail detail : detailGroups.getOrDefault(range, List.of())) {
|
||||
String currency = detail.getCurrency() == null ? "" : detail.getCurrency();
|
||||
detailIds.computeIfAbsent(currency, key -> new ArrayList<>()).add(detail.getId());
|
||||
}
|
||||
Map<String, List<Long>> preIds = new LinkedHashMap<>();
|
||||
for (PreSettlement preSettlement : preGroups.getOrDefault(range, List.of())) {
|
||||
String currency = preSettlement.getCurrency() == null ? "" : preSettlement.getCurrency();
|
||||
preIds.computeIfAbsent(currency, key -> new ArrayList<>()).add(preSettlement.getId());
|
||||
}
|
||||
Set<String> currencies = new LinkedHashSet<>();
|
||||
currencies.addAll(detailIds.keySet());
|
||||
currencies.addAll(preIds.keySet());
|
||||
for (String currency : currencies) {
|
||||
List<Long> sourcePreSettlementIds = preIds.getOrDefault(currency, List.of());
|
||||
List<Long> sourceDetailIds = detailIds.getOrDefault(currency, List.of());
|
||||
if (sourcePreSettlementIds.isEmpty() && sourceDetailIds.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
inTransaction(() -> {
|
||||
FormalSettlementSaveRequest request = new FormalSettlementSaveRequest();
|
||||
request.setContractId(contract.getId());
|
||||
request.setSettlementType(settlementType);
|
||||
request.setSourcePreSettlementIds(sourcePreSettlementIds.isEmpty() ? null : sourcePreSettlementIds);
|
||||
request.setSourceDetailIds(sourceDetailIds.isEmpty() ? null : sourceDetailIds);
|
||||
request.setRemark(periodRemark(range));
|
||||
formalSettlementService.saveDraft(request);
|
||||
});
|
||||
created++;
|
||||
log.info("合同[{}]自动生成正式结算单,账期{}至{},预结算{}张,明细{}条", contract.getContractNo(),
|
||||
range.start(), range.end(), sourcePreSettlementIds.size(), sourceDetailIds.size());
|
||||
} catch (Exception exception) {
|
||||
log.error("合同[{}]自动生成正式结算单失败,账期{}至{}", contract.getContractNo(),
|
||||
range.start(), range.end(), exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
private List<ReceivablePayableDetail> loadPendingDetails(ContractManage contract, List<DateRange> ranges) {
|
||||
LocalDate minDate = ranges.get(0).start();
|
||||
LocalDate maxDate = ranges.get(0).end();
|
||||
for (DateRange range : ranges) {
|
||||
if (range.start().isBefore(minDate)) {
|
||||
minDate = range.start();
|
||||
}
|
||||
if (range.end().isAfter(maxDate)) {
|
||||
maxDate = range.end();
|
||||
}
|
||||
}
|
||||
LocalDate periodStart = minDate;
|
||||
LocalDate periodEnd = maxDate;
|
||||
return receivablePayableDetailMapper.selectList(Wrappers.<ReceivablePayableDetail>lambdaQuery()
|
||||
.eq(ReceivablePayableDetail::getIsDeleted, 0)
|
||||
.eq(ReceivablePayableDetail::getContractId, contract.getId())
|
||||
.eq(ReceivablePayableDetail::getProjectId, contract.getProjectId())
|
||||
.eq(ReceivablePayableDetail::getDeptId, contract.getOrganizationId())
|
||||
.eq(ReceivablePayableDetail::getSettlementType, contractSettlementType(contract))
|
||||
.eq(ReceivablePayableDetail::getSettlementStatus, "pending")
|
||||
.and(query -> query.isNull(ReceivablePayableDetail::getPreSettlementNo)
|
||||
.or().eq(ReceivablePayableDetail::getPreSettlementNo, ""))
|
||||
.and(query -> query.isNull(ReceivablePayableDetail::getFormalSettlementNo)
|
||||
.or().eq(ReceivablePayableDetail::getFormalSettlementNo, ""))
|
||||
.and(query -> query.eq(ReceivablePayableDetail::getCustomerName, contract.getPartyA())
|
||||
.or().eq(ReceivablePayableDetail::getCustomerName, contract.getPartyB()))
|
||||
.and(query -> query.between(ReceivablePayableDetail::getFeeDate, periodStart, periodEnd)
|
||||
.or(nested -> nested.isNull(ReceivablePayableDetail::getFeeDate)
|
||||
.ge(ReceivablePayableDetail::getCreateTime, periodStart.atStartOfDay())
|
||||
.lt(ReceivablePayableDetail::getCreateTime, periodEnd.plusDays(1).atStartOfDay()))));
|
||||
}
|
||||
|
||||
private Map<DateRange, List<PreSettlement>> groupApprovedPres(ContractManage contract, String settlementType,
|
||||
List<DateRange> ranges) {
|
||||
Map<DateRange, List<PreSettlement>> grouped = new LinkedHashMap<>();
|
||||
ranges.forEach(range -> grouped.put(range, new ArrayList<>()));
|
||||
List<PreSettlement> preSettlements = preSettlementService.list(Wrappers.<PreSettlement>lambdaQuery()
|
||||
.eq(PreSettlement::getContractId, contract.getId())
|
||||
.eq(PreSettlement::getSettlementType, settlementType)
|
||||
.eq(PreSettlement::getApprovalStatus, TransportBusiConstant.APPROVED)
|
||||
.and(query -> query.isNull(PreSettlement::getFormalSettlementNo)
|
||||
.or().eq(PreSettlement::getFormalSettlementNo, "")));
|
||||
if (preSettlements.isEmpty()) {
|
||||
return grouped;
|
||||
}
|
||||
List<Long> preSettlementIds = preSettlements.stream().map(PreSettlement::getId).toList();
|
||||
Map<Long, List<Long>> sourceIdsByPre = new LinkedHashMap<>();
|
||||
for (List<Long> batch : partition(preSettlementIds)) {
|
||||
List<PreSettlementDetail> lines = preSettlementDetailMapper.selectList(Wrappers.<PreSettlementDetail>lambdaQuery()
|
||||
.in(PreSettlementDetail::getPreSettlementId, batch)
|
||||
.eq(PreSettlementDetail::getIsDeleted, 0));
|
||||
for (PreSettlementDetail line : lines) {
|
||||
if (line.getSourceDetailId() == null) {
|
||||
continue;
|
||||
}
|
||||
sourceIdsByPre.computeIfAbsent(line.getPreSettlementId(), key -> new ArrayList<>()).add(line.getSourceDetailId());
|
||||
}
|
||||
}
|
||||
Set<Long> sourceIds = new LinkedHashSet<>();
|
||||
sourceIdsByPre.values().forEach(sourceIds::addAll);
|
||||
Map<Long, ReceivablePayableDetail> sourceMap = new LinkedHashMap<>();
|
||||
for (List<Long> batch : partition(sourceIds)) {
|
||||
receivablePayableDetailMapper.selectBatchIds(batch)
|
||||
.forEach(detail -> sourceMap.put(detail.getId(), detail));
|
||||
}
|
||||
for (PreSettlement preSettlement : preSettlements) {
|
||||
List<Long> sourceDetailIds = sourceIdsByPre.getOrDefault(preSettlement.getId(), List.of());
|
||||
LocalDate minDate = null;
|
||||
LocalDate maxDate = null;
|
||||
boolean invalid = sourceDetailIds.isEmpty();
|
||||
for (Long sourceDetailId : sourceDetailIds) {
|
||||
ReceivablePayableDetail source = sourceMap.get(sourceDetailId);
|
||||
LocalDate businessDate = source == null ? null : businessDate(source);
|
||||
if (businessDate == null) {
|
||||
invalid = true;
|
||||
break;
|
||||
}
|
||||
minDate = minDate == null || businessDate.isBefore(minDate) ? businessDate : minDate;
|
||||
maxDate = maxDate == null || businessDate.isAfter(maxDate) ? businessDate : maxDate;
|
||||
}
|
||||
if (invalid || minDate == null || maxDate == null) {
|
||||
log.warn("预结算单[{}]缺少可归集的费用日期,跳过自动转正式结算", preSettlement.getPreSettlementNo());
|
||||
continue;
|
||||
}
|
||||
boolean matched = false;
|
||||
for (DateRange range : ranges) {
|
||||
if (!minDate.isBefore(range.start()) && !maxDate.isAfter(range.end())) {
|
||||
grouped.get(range).add(preSettlement);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched && !maxDate.isAfter(LocalDate.now().minusDays(1))) {
|
||||
log.info("预结算单[{}]费用日期{}至{}未完整落入已关闭账期,暂不自动归集正式结算",
|
||||
preSettlement.getPreSettlementNo(), minDate, maxDate);
|
||||
}
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
private Map<DateRange, List<ReceivablePayableDetail>> groupDetails(List<DateRange> ranges,
|
||||
List<ReceivablePayableDetail> details) {
|
||||
Map<DateRange, List<ReceivablePayableDetail>> grouped = new LinkedHashMap<>();
|
||||
ranges.forEach(range -> grouped.put(range, new ArrayList<>()));
|
||||
for (ReceivablePayableDetail detail : details) {
|
||||
LocalDate businessDate = businessDate(detail);
|
||||
if (businessDate == null) {
|
||||
continue;
|
||||
}
|
||||
for (DateRange range : ranges) {
|
||||
if (!businessDate.isBefore(range.start()) && !businessDate.isAfter(range.end())) {
|
||||
grouped.get(range).add(detail);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
private List<DateRange> closedPeriods(Map<String, Object> rule, LocalDate today) {
|
||||
LocalDate billStart = dateValue(rule.get("billStartDate"));
|
||||
LocalDate yesterday = today.minusDays(1);
|
||||
if (billStart == null || yesterday.isBefore(billStart)) {
|
||||
return List.of();
|
||||
}
|
||||
String settlementType = Func.toStr(rule.get("settlementType")).trim();
|
||||
return switch (settlementType) {
|
||||
case "现结", "日结" -> cyclePeriods(billStart, yesterday, 1);
|
||||
case "周结" -> cyclePeriods(billStart, yesterday, 7);
|
||||
case "按固定天数周期", "固定天数周期结算" -> {
|
||||
Integer cycleDays = intValue(rule.get("cycleDays"));
|
||||
if (cycleDays == null || cycleDays < 1 || cycleDays > 365) {
|
||||
yield List.of();
|
||||
}
|
||||
yield cyclePeriods(billStart, yesterday, cycleDays);
|
||||
}
|
||||
case "月结" -> monthlyPeriods(rule, billStart, yesterday);
|
||||
default -> List.of();
|
||||
};
|
||||
}
|
||||
|
||||
private List<DateRange> monthlyPeriods(Map<String, Object> rule, LocalDate billStart, LocalDate yesterday) {
|
||||
String cycleType = Func.toStr(rule.get("billCycleType")).trim();
|
||||
if (Func.isBlank(cycleType) || "自然月".equals(cycleType)) {
|
||||
return naturalMonthPeriods(billStart, yesterday);
|
||||
}
|
||||
if ("固定截单日".equals(cycleType)) {
|
||||
Integer cutoffDay = intValue(rule.get("billCutoffDay"));
|
||||
if (cutoffDay == null || cutoffDay < 1 || cutoffDay > 31) {
|
||||
return List.of();
|
||||
}
|
||||
return fixedCutoffPeriods(billStart, yesterday, cutoffDay);
|
||||
}
|
||||
if ("自定义多周期".equals(cycleType)) {
|
||||
return customMonthPeriods(rule.get("customPeriods"), billStart, yesterday);
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private List<DateRange> cyclePeriods(LocalDate billStart, LocalDate yesterday, int days) {
|
||||
List<DateRange> ranges = new ArrayList<>();
|
||||
LocalDate cursor = billStart;
|
||||
while (true) {
|
||||
LocalDate end = cursor.plusDays(days - 1L);
|
||||
if (end.isAfter(yesterday)) {
|
||||
break;
|
||||
}
|
||||
ranges.add(new DateRange(cursor, end));
|
||||
cursor = end.plusDays(1);
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
private List<DateRange> naturalMonthPeriods(LocalDate billStart, LocalDate yesterday) {
|
||||
List<DateRange> ranges = new ArrayList<>();
|
||||
YearMonth cursor = YearMonth.from(billStart);
|
||||
YearMonth lastMonth = YearMonth.from(yesterday);
|
||||
while (!cursor.isAfter(lastMonth)) {
|
||||
LocalDate end = cursor.atEndOfMonth();
|
||||
if (!end.isAfter(yesterday) && !end.isBefore(billStart)) {
|
||||
LocalDate start = cursor.atDay(1).isBefore(billStart) ? billStart : cursor.atDay(1);
|
||||
ranges.add(new DateRange(start, end));
|
||||
}
|
||||
cursor = cursor.plusMonths(1);
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
private List<DateRange> fixedCutoffPeriods(LocalDate billStart, LocalDate yesterday, int cutoffDay) {
|
||||
List<DateRange> ranges = new ArrayList<>();
|
||||
YearMonth cursor = YearMonth.from(billStart).minusMonths(1);
|
||||
YearMonth limit = YearMonth.from(yesterday).plusMonths(1);
|
||||
LocalDate previousEnd = null;
|
||||
while (!cursor.isAfter(limit)) {
|
||||
LocalDate end = cursor.atDay(Math.min(cutoffDay, cursor.lengthOfMonth()));
|
||||
if (!end.isBefore(billStart) && !end.isAfter(yesterday)) {
|
||||
LocalDate start = previousEnd == null ? billStart : previousEnd.plusDays(1);
|
||||
if (start.isBefore(billStart)) {
|
||||
start = billStart;
|
||||
}
|
||||
if (!start.isAfter(end)) {
|
||||
ranges.add(new DateRange(start, end));
|
||||
}
|
||||
}
|
||||
if (!end.isBefore(billStart.minusMonths(1))) {
|
||||
previousEnd = end;
|
||||
}
|
||||
cursor = cursor.plusMonths(1);
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
private List<DateRange> customMonthPeriods(Object periodsValue, LocalDate billStart, LocalDate yesterday) {
|
||||
List<int[]> periods = customPeriods(periodsValue);
|
||||
if (periods.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<DateRange> ranges = new ArrayList<>();
|
||||
YearMonth cursor = YearMonth.from(billStart);
|
||||
YearMonth lastMonth = YearMonth.from(yesterday);
|
||||
while (!cursor.isAfter(lastMonth)) {
|
||||
for (int[] period : periods) {
|
||||
int startDay = Math.min(period[0], cursor.lengthOfMonth());
|
||||
int endDay = Math.min(period[1], cursor.lengthOfMonth());
|
||||
if (startDay > endDay) {
|
||||
continue;
|
||||
}
|
||||
LocalDate end = cursor.atDay(endDay);
|
||||
if (end.isAfter(yesterday) || end.isBefore(billStart)) {
|
||||
continue;
|
||||
}
|
||||
LocalDate start = cursor.atDay(startDay);
|
||||
if (start.isBefore(billStart)) {
|
||||
start = billStart;
|
||||
}
|
||||
if (!start.isAfter(end)) {
|
||||
ranges.add(new DateRange(start, end));
|
||||
}
|
||||
}
|
||||
cursor = cursor.plusMonths(1);
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
private List<int[]> customPeriods(Object value) {
|
||||
if (!(value instanceof List<?> periodList)) {
|
||||
return List.of();
|
||||
}
|
||||
List<int[]> periods = new ArrayList<>();
|
||||
for (Object item : periodList) {
|
||||
if (!(item instanceof Map<?, ?> period)) {
|
||||
continue;
|
||||
}
|
||||
Integer startDay = intValue(period.get("startDay"));
|
||||
Integer endDay = intValue(period.get("endDay"));
|
||||
if (startDay == null || endDay == null || startDay < 1 || endDay > 31 || endDay < startDay) {
|
||||
continue;
|
||||
}
|
||||
periods.add(new int[]{startDay, endDay});
|
||||
}
|
||||
return periods;
|
||||
}
|
||||
|
||||
private Map<String, Object> resolveRule(ContractManage contract, boolean preRule) {
|
||||
Map<String, Object> direct = parseObject(preRule ? contract.getPreSettlementConfigJson() : contract.getFormalSettlementConfigJson());
|
||||
if (!direct.isEmpty()) {
|
||||
return direct;
|
||||
}
|
||||
Map<String, Object> payload = parseObject(contract.getSettlementRuleJson());
|
||||
Object nested = payload.get(preRule ? "preSettlementConfig" : "formalSettlementConfig");
|
||||
if (nested instanceof Map<?, ?> nestedMap) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
nestedMap.forEach((key, value) -> result.put(String.valueOf(key), value));
|
||||
return result;
|
||||
}
|
||||
if (!payload.containsKey("preSettlementConfig") && !payload.containsKey("formalSettlementConfig")) {
|
||||
return payload;
|
||||
}
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
private Map<String, Object> parseObject(String json) {
|
||||
if (Func.isBlank(json)) {
|
||||
return Map.of();
|
||||
}
|
||||
try {
|
||||
Object parsed = JsonUtil.parse(json, Map.class);
|
||||
if (!(parsed instanceof Map<?, ?> raw) || raw.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
raw.forEach((key, value) -> result.put(String.valueOf(key), value));
|
||||
return result;
|
||||
} catch (Exception exception) {
|
||||
log.warn("结算规则JSON解析失败:{}", exception.getMessage());
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
private void inTransaction(Runnable runnable) {
|
||||
new TransactionTemplate(transactionManager).executeWithoutResult(status -> runnable.run());
|
||||
}
|
||||
|
||||
private List<List<Long>> partition(Collection<Long> ids) {
|
||||
List<Long> source = ids instanceof List<Long> list ? list : new ArrayList<>(ids);
|
||||
List<List<Long>> batches = new ArrayList<>();
|
||||
for (int index = 0; index < source.size(); index += QUERY_BATCH_SIZE) {
|
||||
batches.add(source.subList(index, Math.min(index + QUERY_BATCH_SIZE, source.size())));
|
||||
}
|
||||
return batches;
|
||||
}
|
||||
|
||||
private String contractSettlementType(ContractManage contract) {
|
||||
if ("客户合同".equals(contract.getContractCategory())) {
|
||||
return "receivable";
|
||||
}
|
||||
if ("承运商合同".equals(contract.getContractCategory())) {
|
||||
return "payable";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private LocalDate businessDate(ReceivablePayableDetail detail) {
|
||||
if (detail.getFeeDate() != null) {
|
||||
return detail.getFeeDate();
|
||||
}
|
||||
if (detail.getCreateTime() == null) {
|
||||
return null;
|
||||
}
|
||||
return detail.getCreateTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
|
||||
}
|
||||
|
||||
private String periodRemark(DateRange range) {
|
||||
return "系统按结算规则自动生成(" + range.start() + "至" + range.end() + ")";
|
||||
}
|
||||
|
||||
private boolean enabled(Object value) {
|
||||
if (value instanceof Boolean enabled) {
|
||||
return enabled;
|
||||
}
|
||||
if (value instanceof Number number) {
|
||||
return number.intValue() == 1;
|
||||
}
|
||||
String text = value == null ? "" : String.valueOf(value).trim();
|
||||
return "1".equals(text) || "true".equalsIgnoreCase(text);
|
||||
}
|
||||
|
||||
private boolean knownCycle(String settlementType) {
|
||||
return List.of("现结", "日结", "周结", "月结", "按固定天数周期", "固定天数周期结算").contains(settlementType);
|
||||
}
|
||||
|
||||
private Integer intValue(Object value) {
|
||||
if (value instanceof Number number) {
|
||||
return number.intValue();
|
||||
}
|
||||
if (value == null || Func.isBlank(String.valueOf(value))) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(String.valueOf(value).trim());
|
||||
} catch (NumberFormatException exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private LocalDate dateValue(Object value) {
|
||||
if (value == null || Func.isBlank(String.valueOf(value))) {
|
||||
return null;
|
||||
}
|
||||
String text = String.valueOf(value).trim();
|
||||
if (text.length() >= 10) {
|
||||
text = text.substring(0, 10);
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(text);
|
||||
} catch (DateTimeParseException exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private record DateRange(LocalDate start, LocalDate end) {
|
||||
}
|
||||
|
||||
}
|
||||
+2
-4
@@ -246,10 +246,8 @@ public class TemporaryCreditLimitServiceImpl extends BaseServiceImpl<TemporaryCr
|
||||
TransportBusinessSupport.validateRequired(temporaryCreditLimit.getProjectCode(), "项目编号不能为空");
|
||||
TransportBusinessSupport.validateRequired(temporaryCreditLimit.getUndertakeDeptName(), "承办部门不能为空");
|
||||
TransportBusinessSupport.validateRequired(temporaryCreditLimit.getValidUntil() == null ? null : temporaryCreditLimit.getValidUntil().toString(), "请选择申请有效期至");
|
||||
if (temporaryCreditLimit.getProjectFundLimit() == null || temporaryCreditLimit.getUsedFundLimit() == null || temporaryCreditLimit.getRemainingFundLimit() == null) {
|
||||
throw new ServiceException("项目额度数据异常,请重新选择项目");
|
||||
}
|
||||
if (temporaryCreditLimit.getRemainingFundLimit().compareTo(BigDecimal.ZERO) < 0) {
|
||||
if (temporaryCreditLimit.getRemainingFundLimit() != null
|
||||
&& temporaryCreditLimit.getRemainingFundLimit().compareTo(BigDecimal.ZERO) < 0) {
|
||||
throw new ServiceException("项目剩余额度不能小于0");
|
||||
}
|
||||
}
|
||||
|
||||
+31
-1
@@ -81,6 +81,8 @@ public class TransportPlanServiceImpl extends BaseServiceImpl<TransportPlanMappe
|
||||
|
||||
@Override
|
||||
public IPage<TransportPlanVO> selectTransportPlanPage(IPage<TransportPlan> page, TransportPlanVO transportPlan) {
|
||||
// 历史手工创建未写入 dept_id,allDept=0 按组织过滤时会被排除
|
||||
backfillMissingDept(transportPlan);
|
||||
IPage<TransportPlan> entityPage = page(page, buildQuery(transportPlan));
|
||||
IPage<TransportPlanVO> resultPage = TransportPlanWrapper.build().pageVO(entityPage);
|
||||
fillCustomerNamesFromContracts(resultPage.getRecords());
|
||||
@@ -151,6 +153,12 @@ public class TransportPlanServiceImpl extends BaseServiceImpl<TransportPlanMappe
|
||||
if (created) {
|
||||
transportPlan.setPlanNo(nextCode());
|
||||
}
|
||||
// 新建或历史空组织一律落到当前组织,避免列表按 dept_id 过滤后看不到
|
||||
if (created || Func.isEmpty(transportPlan.getDeptId()) || transportPlan.getDeptId() <= 0) {
|
||||
Dept dept = TransportBusinessSupport.currentDept("运输计划");
|
||||
transportPlan.setDeptId(dept.getId());
|
||||
transportPlan.setDeptName(dept.getDeptName());
|
||||
}
|
||||
validate(transportPlan);
|
||||
return saveOrUpdate(transportPlan);
|
||||
}
|
||||
@@ -662,11 +670,33 @@ public class TransportPlanServiceImpl extends BaseServiceImpl<TransportPlanMappe
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把所属组织为空、且创建组织等于当前组织的运输计划回填 dept_id,使 allDept=0 列表能查到。
|
||||
*/
|
||||
private void backfillMissingDept(TransportPlanVO transportPlan) {
|
||||
if (Objects.equals(transportPlan.getAllDept(), 1)) {
|
||||
return;
|
||||
}
|
||||
Dept dept = TransportBusinessSupport.currentDept("运输计划");
|
||||
if (dept == null || dept.getId() == null) {
|
||||
return;
|
||||
}
|
||||
update(Wrappers.<TransportPlan>lambdaUpdate()
|
||||
.set(TransportPlan::getDeptId, dept.getId())
|
||||
.set(TransportPlan::getDeptName, dept.getDeptName())
|
||||
.isNull(TransportPlan::getDeptId)
|
||||
.eq(TransportPlan::getCreateDept, dept.getId())
|
||||
.eq(TransportPlan::getIsDeleted, 0));
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<TransportPlan> buildQuery(TransportPlanVO transportPlan) {
|
||||
TransportBusinessSupport.validateAllDept(transportPlan.getAllDept(), "运输计划");
|
||||
LambdaQueryWrapper<TransportPlan> queryWrapper = Wrappers.<TransportPlan>lambdaQuery().eq(TransportPlan::getIsDeleted, 0);
|
||||
if (!Objects.equals(transportPlan.getAllDept(), 1)) {
|
||||
queryWrapper.eq(TransportPlan::getDeptId, TransportBusinessSupport.currentDeptId("运输计划"));
|
||||
Long currentDeptId = TransportBusinessSupport.currentDeptId("运输计划");
|
||||
// dept_id 为空的历史数据按创建组织归入当前组织
|
||||
queryWrapper.and(wrapper -> wrapper.eq(TransportPlan::getDeptId, currentDeptId)
|
||||
.or(item -> item.isNull(TransportPlan::getDeptId).eq(TransportPlan::getCreateDept, currentDeptId)));
|
||||
} else if (Func.isNotEmpty(transportPlan.getDeptId())) {
|
||||
queryWrapper.eq(TransportPlan::getDeptId, transportPlan.getDeptId());
|
||||
}
|
||||
|
||||
+62
-15
@@ -1233,14 +1233,67 @@ public class TransportReconciliationServiceImpl
|
||||
private List<TransportReconciliationInternal> assertAllMatched(TransportReconciliation bill) {
|
||||
List<TransportReconciliationInternal> internals = internalRows(bill.getId());
|
||||
List<TransportReconciliationExternal> externals = externalRows(bill.getId());
|
||||
if (internals.isEmpty() || externals.isEmpty() || internals.size() != externals.size()
|
||||
|| internals.stream().anyMatch(item -> !MATCHED.equals(item.getMatchResult()))
|
||||
|| externals.stream().anyMatch(item -> !MATCHED.equals(item.getMatchStatus()) || Boolean.TRUE.equals(item.getSuspectedDuplicate()))) {
|
||||
// 调整账单后旧的疑似重复标记可能已失效,按当前内容重算,避免已一一匹配仍被拦截
|
||||
syncSuspectedDuplicates(externals);
|
||||
if (!oneToOneMatched(internals, externals)) {
|
||||
throw new ServiceException("所有内外部账单明细必须一一匹配且不存在疑似重复");
|
||||
}
|
||||
return internals;
|
||||
}
|
||||
|
||||
private void syncSuspectedDuplicates(List<TransportReconciliationExternal> externals) {
|
||||
Map<String, List<TransportReconciliationExternal>> groups = externals.stream()
|
||||
.collect(Collectors.groupingBy(this::duplicateKey));
|
||||
Set<Long> duplicateIds = new HashSet<>();
|
||||
for (List<TransportReconciliationExternal> group : groups.values()) {
|
||||
if (group.size() > 1) {
|
||||
group.stream().map(TransportReconciliationExternal::getId).forEach(duplicateIds::add);
|
||||
}
|
||||
}
|
||||
for (TransportReconciliationExternal external : externals) {
|
||||
boolean duplicate = duplicateIds.contains(external.getId());
|
||||
String matchStatus = external.getMatchStatus();
|
||||
if (!duplicate && DUPLICATE.equals(matchStatus) && external.getMatchedInternalId() != null) {
|
||||
matchStatus = MATCHED;
|
||||
}
|
||||
if (Boolean.TRUE.equals(external.getSuspectedDuplicate()) == duplicate
|
||||
&& Objects.equals(matchStatus, external.getMatchStatus())) {
|
||||
continue;
|
||||
}
|
||||
external.setSuspectedDuplicate(duplicate);
|
||||
external.setMatchStatus(matchStatus);
|
||||
externalMapper.update(null, Wrappers.<TransportReconciliationExternal>lambdaUpdate()
|
||||
.eq(TransportReconciliationExternal::getId, external.getId())
|
||||
.set(TransportReconciliationExternal::getSuspectedDuplicate, duplicate)
|
||||
.set(TransportReconciliationExternal::getMatchStatus, matchStatus));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean oneToOneMatched(List<TransportReconciliationInternal> internals,
|
||||
List<TransportReconciliationExternal> externals) {
|
||||
if (internals.isEmpty() || externals.isEmpty() || internals.size() != externals.size()) {
|
||||
return false;
|
||||
}
|
||||
if (externals.stream().anyMatch(item -> Boolean.TRUE.equals(item.getSuspectedDuplicate()))) {
|
||||
return false;
|
||||
}
|
||||
Map<Long, TransportReconciliationExternal> externalMap = externals.stream()
|
||||
.collect(Collectors.toMap(TransportReconciliationExternal::getId, Function.identity(), (left, right) -> left));
|
||||
Set<Long> usedExternalIds = new HashSet<>();
|
||||
for (TransportReconciliationInternal internal : internals) {
|
||||
if (!MATCHED.equals(internal.getMatchResult()) || internal.getMatchedExternalId() == null
|
||||
|| !usedExternalIds.add(internal.getMatchedExternalId())) {
|
||||
return false;
|
||||
}
|
||||
TransportReconciliationExternal external = externalMap.get(internal.getMatchedExternalId());
|
||||
if (external == null || !MATCHED.equals(external.getMatchStatus())
|
||||
|| !Objects.equals(external.getMatchedInternalId(), internal.getId())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return usedExternalIds.size() == externals.size();
|
||||
}
|
||||
|
||||
private void refreshStats(Long id) {
|
||||
TransportReconciliation bill = existing(id);
|
||||
List<TransportReconciliationInternal> internals = internalRows(id);
|
||||
@@ -1459,20 +1512,14 @@ public class TransportReconciliationServiceImpl
|
||||
private BigDecimal sumExternalQuantity(List<TransportReconciliationExternal> rows) { return rows.stream().map(TransportReconciliationExternal::getTransportQuantity).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); }
|
||||
private BigDecimal sumInternalAmount(List<TransportReconciliationInternal> rows) { return rows.stream().map(TransportReconciliationInternal::getSettlementAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); }
|
||||
private BigDecimal sumExternalAmount(List<TransportReconciliationExternal> rows) { return rows.stream().map(TransportReconciliationExternal::getSettlementAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); }
|
||||
/** 生成当日运输对账单号;按最大流水号 +1,且计入逻辑删除数据 */
|
||||
private synchronized String nextNo() {
|
||||
String prefix = "DZD" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
|
||||
int sequence = list(Wrappers.<TransportReconciliation>lambdaQuery()
|
||||
.select(TransportReconciliation::getReconciliationNo)
|
||||
.likeRight(TransportReconciliation::getReconciliationNo, prefix))
|
||||
.stream()
|
||||
.map(TransportReconciliation::getReconciliationNo)
|
||||
.filter(number -> number != null && number.length() == prefix.length() + 5)
|
||||
.map(number -> number.substring(prefix.length()))
|
||||
.filter(suffix -> suffix.chars().allMatch(Character::isDigit))
|
||||
.mapToInt(Integer::parseInt)
|
||||
.max()
|
||||
.orElse(0) + 1;
|
||||
if (sequence > 99999) throw new ServiceException("当日运输对账单号流水已用完");
|
||||
Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix);
|
||||
long sequence = (maxSequence == null ? 0L : maxSequence) + 1;
|
||||
if (sequence > 99999) {
|
||||
throw new ServiceException("当日运输对账单号流水已用完");
|
||||
}
|
||||
return prefix + String.format("%05d", sequence);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -291,13 +291,13 @@ public class TransportVehicleServiceImpl extends BaseServiceImpl<TransportVehicl
|
||||
if (Func.isEmpty(vehicle.getDrivingLicenseNo())) {
|
||||
throw new ServiceException("行驶证档案编号不能为空");
|
||||
}
|
||||
if (Func.isEmpty(vehicle.getDrivingLicenseEndDate())) {
|
||||
if (vehicle.getDrivingLicenseLongTerm() != 1 && Func.isEmpty(vehicle.getDrivingLicenseEndDate())) {
|
||||
throw new ServiceException("行驶证有效期不能为空");
|
||||
}
|
||||
if (Func.isEmpty(vehicle.getRoadTransportCertNo())) {
|
||||
throw new ServiceException("道路运输证号不能为空");
|
||||
}
|
||||
if (Func.isEmpty(vehicle.getRoadTransportCertEndDate())) {
|
||||
if (vehicle.getRoadTransportCertLongTerm() != 1 && Func.isEmpty(vehicle.getRoadTransportCertEndDate())) {
|
||||
throw new ServiceException("道路运输证有效期不能为空");
|
||||
}
|
||||
if (vehicle.getAnnualReviewLongTerm() != 1 && Func.isEmpty(vehicle.getAnnualReviewEndDate())) {
|
||||
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 运次聚合口径:四档状态 + 接单需求 OR + 运次键
|
||||
* <p>
|
||||
* 四档与接单需求都按子运单的过程配置与接单态在这里推导,Tab 过滤与卡片状态用同一份推导,
|
||||
* 库层不再按已落库的业务状态分档——业务状态靠读取时的惰性校正维持,与过程配置不一致是常态。
|
||||
* <p>
|
||||
* 运次键与它的库层表达式只在这里声明一次:库层分组(重新派单待办)与服务层分组必须是同一个键。
|
||||
*/
|
||||
final class TripAggregation {
|
||||
|
||||
/** 运单业务状态 */
|
||||
static final String PENDING = "pending";
|
||||
static final String WAITING_DISPATCH = "waiting_dispatch";
|
||||
static final String DISPATCHING = "dispatching";
|
||||
static final String RUNNING = "running";
|
||||
static final String COMPLETED = "completed";
|
||||
static final String CANCELLED = "cancelled";
|
||||
|
||||
/** 待接单档:与运单侧 Tab「待接单」取同一组状态 */
|
||||
static final List<String> PENDING_STATUSES = List.of(PENDING, WAITING_DISPATCH, DISPATCHING);
|
||||
/** 终态档 */
|
||||
static final List<String> TERMINAL_STATUSES = List.of(COMPLETED, CANCELLED);
|
||||
/**
|
||||
* 进运次的子单状态:草稿尚未派单、不成运次;已取消要进,
|
||||
* 否则「子单全已取消」会被算成运输中而不是已取消。
|
||||
*/
|
||||
static final List<String> CHILD_STATUSES = List.of(
|
||||
PENDING, WAITING_DISPATCH, DISPATCHING, RUNNING, COMPLETED, CANCELLED
|
||||
);
|
||||
|
||||
/** 待重新派单的口径:子单被拒且这趟车还没结束 */
|
||||
static final List<String> REASSIGN_STATUSES = List.of(PENDING, RUNNING);
|
||||
|
||||
/**
|
||||
* 运次键的库层表达式:有配载按配载单号成组,未配载每张运单自成一组(W 前缀避免与配载单号撞键)。
|
||||
* 服务层换 {@link #keyOf(String, Long)} 算同一个键。
|
||||
*/
|
||||
static final String SQL_TRIP_KEY = "IFNULL(NULLIF(loading_no, ''), CONCAT('W', id))";
|
||||
|
||||
private TripAggregation() {
|
||||
}
|
||||
|
||||
/** 运次键:与 {@link #SQL_TRIP_KEY} 同一个口径 */
|
||||
static String keyOf(String loadingNo, Long id) {
|
||||
return Func.isEmpty(loadingNo) ? "W" + id : loadingNo;
|
||||
}
|
||||
|
||||
/** 运次的四档状态:0 待接单 / 1 运输中 / 2 已完成 / 3 已取消 */
|
||||
static Integer appStatus(String status) {
|
||||
return switch (Func.toStr(status, "")) {
|
||||
case PENDING -> 0;
|
||||
case RUNNING -> 1;
|
||||
case COMPLETED -> 2;
|
||||
case CANCELLED -> 3;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次状态:终态优先——子单全为终态时有已完成取已完成、否则已取消;
|
||||
* 否则需接单且未接单取待接单,其余取运输中。
|
||||
* <p>
|
||||
* 子单集合为空时无从判定,返回空。
|
||||
*/
|
||||
static String statusOf(List<Waybill> children, boolean requireAccept, boolean accepted) {
|
||||
String status = classify(count(children));
|
||||
if (status == null || TERMINAL_STATUSES.contains(status)) {
|
||||
return status;
|
||||
}
|
||||
if (!requireAccept) {
|
||||
return RUNNING;
|
||||
}
|
||||
return accepted ? RUNNING : PENDING;
|
||||
}
|
||||
|
||||
/**
|
||||
* 子单聚合结果:子单总数、终态数、已完成数、待接单档数
|
||||
*/
|
||||
static Counters count(List<Waybill> children) {
|
||||
int total = 0;
|
||||
int terminal = 0;
|
||||
int completed = 0;
|
||||
int pending = 0;
|
||||
for (Waybill child : children) {
|
||||
String status = child == null ? null : child.getBusinessStatus();
|
||||
total++;
|
||||
if (status != null && TERMINAL_STATUSES.contains(status)) {
|
||||
terminal++;
|
||||
}
|
||||
if (COMPLETED.equals(status)) {
|
||||
completed++;
|
||||
}
|
||||
if (status != null && PENDING_STATUSES.contains(status)) {
|
||||
pending++;
|
||||
}
|
||||
}
|
||||
return new Counters(total, terminal, completed, pending);
|
||||
}
|
||||
|
||||
private static String classify(Counters counters) {
|
||||
if (counters.total() == 0) {
|
||||
return null;
|
||||
}
|
||||
if (counters.terminal() == counters.total()) {
|
||||
return counters.completed() > 0 ? COMPLETED : CANCELLED;
|
||||
}
|
||||
return counters.pending() > 0 ? PENDING : RUNNING;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次是否需要接单:任一子运单的过程配置有启用的接单节点即为是。
|
||||
* <p>
|
||||
* 跨项目的运次常遇到「要接单的票」与「不要接单的票」混在一起,按 OR 合并后整单按需接单处理。
|
||||
*/
|
||||
static boolean requiresAccept(List<Waybill> children) {
|
||||
return children.stream()
|
||||
.anyMatch(child -> child != null
|
||||
&& WaybillProcessSupport.requiresDriverAcceptConfirmation(child.getProcessJson()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次接单态:任一子单已接单即为已接单(一次接单把整趟车的子单置为已接单),
|
||||
* 否则有子单待接单为待接单,无从判定时为空。
|
||||
*/
|
||||
static String acceptStatus(List<Waybill> children) {
|
||||
boolean anyPending = false;
|
||||
for (Waybill child : children) {
|
||||
String status = child == null ? null : child.getDriverAcceptStatus();
|
||||
if (WaybillProcessSupport.ACCEPT_ACCEPTED.equals(status)) {
|
||||
return WaybillProcessSupport.ACCEPT_ACCEPTED;
|
||||
}
|
||||
if (WaybillProcessSupport.ACCEPT_PENDING.equals(status)) {
|
||||
anyPending = true;
|
||||
}
|
||||
}
|
||||
return anyPending ? WaybillProcessSupport.ACCEPT_PENDING : null;
|
||||
}
|
||||
|
||||
/** 子单聚合计数 */
|
||||
record Counters(int total, int terminal, int completed, int pending) {
|
||||
}
|
||||
|
||||
}
|
||||
+849
@@ -0,0 +1,849 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.pojo.entity.LoadingManage;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.CargoLineVO;
|
||||
import org.springblade.transport.pojo.vo.TripQueryVO;
|
||||
import org.springblade.transport.pojo.vo.TripStationVO;
|
||||
import org.springblade.transport.pojo.vo.TripTabCountsVO;
|
||||
import org.springblade.transport.pojo.vo.TripVO;
|
||||
import org.springblade.transport.service.ILoadingManageService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.ITripQueryService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.LiveProcessConfigSupport;
|
||||
import org.springblade.transport.support.WaybillCargoSupport;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 运次读服务实现
|
||||
* <p>
|
||||
* 取数两段,段内都是批量查询:段一一次取回收口与行级筛选命中的可见运单行,分组、分档、排序与分页
|
||||
* 都在服务层算完再做;段二按这一页运次的配载单号批量取配载单。子运单不再回查——段一拿回来的行就是它们。
|
||||
* <p>
|
||||
* 分档只按过程配置与接单态推导,与卡片状态同一份口径:库里的业务状态靠读取时的惰性校正维持,
|
||||
* 拿它分档会让「归在待接单的运次、卡片显示运输中」。分页因此必须在过滤后的完整运次序列上做,
|
||||
* 先分页再过滤会把该在第一页的运次挤到后面几页。
|
||||
* <p>
|
||||
* 可见运次=派给该司机的未配载运单(每张自成运次),加上配载单派给该司机、且至少有一张可见子运单的运次。
|
||||
* 司机侧可见范围与运单列表同口径:司机手机号命中且未被该司机拒单;子单里已拒单的不进运次,
|
||||
* 于是整趟车被拒单后整个运次自然消失。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TripQueryServiceImpl implements ITripQueryService {
|
||||
|
||||
private static final int DEFAULT_PAGE_SIZE = 10;
|
||||
private static final int MAX_PAGE_SIZE = 50;
|
||||
|
||||
/** 运输组织类型:有配载单为公路配载,否则普通运输 */
|
||||
private static final String TRANSPORT_LOAD = "load";
|
||||
private static final String TRANSPORT_COMMON = "common";
|
||||
|
||||
/** 未配载运次的键前缀:与 TripAggregation.SQL_TRIP_KEY 的 W 前缀同一个口径 */
|
||||
private static final String LONE_KEY_PREFIX = "W";
|
||||
|
||||
/** 异常筛选的取值:命中未完成异常的子单 */
|
||||
private static final String EXCEPTION_YES = "exception";
|
||||
private static final String EXCEPTION_NO = "normal";
|
||||
|
||||
/** 关键字在子单上匹配的字段 */
|
||||
private static final String[] KEYWORD_COLUMNS = {
|
||||
"waybill_no", "loading_no", "departure_name", "arrival_name",
|
||||
"departure_address", "arrival_address", "vehicle_no"
|
||||
};
|
||||
|
||||
private final IWaybillService waybillService;
|
||||
private final ILoadingManageService loadingManageService;
|
||||
private final IProcessConfigService processConfigService;
|
||||
|
||||
@Override
|
||||
public IPage<TripVO> page(TripQueryVO query) {
|
||||
TripQueryVO condition = condition(query);
|
||||
int pageNo = pageNo(condition.getCurrent());
|
||||
int pageSize = pageSize(condition.getSize());
|
||||
if (!scoped(condition)) {
|
||||
// 收口缺失时返回空页,不能退回「不按司机收口」:那等于把别人的运次发出去
|
||||
return new Page<>(pageNo, pageSize);
|
||||
}
|
||||
List<TripRows> trips = trips(condition);
|
||||
Page<TripVO> page = new Page<>(pageNo, pageSize, trips.size());
|
||||
int from = Math.min((pageNo - 1) * pageSize, trips.size());
|
||||
int to = Math.min(from + pageSize, trips.size());
|
||||
page.setRecords(assemble(trips.subList(from, to)));
|
||||
return page;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TripTabCountsVO tabCounts(TripQueryVO query) {
|
||||
TripQueryVO condition = condition(query);
|
||||
TripTabCountsVO counts = new TripTabCountsVO();
|
||||
if (!scoped(condition)) {
|
||||
return counts;
|
||||
}
|
||||
// 一次取数算清四档:分档口径与列表、卡片同一份推导,不再按库里的业务状态分批计数
|
||||
long pending = 0;
|
||||
long doing = 0;
|
||||
long done = 0;
|
||||
for (TripRows trip : trips(withoutTab(condition))) {
|
||||
Integer tier = trip.tier();
|
||||
if (tier == null) {
|
||||
continue;
|
||||
}
|
||||
switch (tier) {
|
||||
case 0 -> pending++;
|
||||
case 1 -> doing++;
|
||||
case 2 -> done++;
|
||||
default -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
counts.setPending(pending);
|
||||
counts.setDoing(doing);
|
||||
counts.setDone(done);
|
||||
counts.setAll(pending + doing + done);
|
||||
return counts;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TripVO detail(TripQueryVO query) {
|
||||
TripQueryVO condition = condition(query);
|
||||
Long id = tripId(condition.getTripId());
|
||||
if (id == null || !scoped(condition)) {
|
||||
throw new ServiceException("运次不存在或无权操作");
|
||||
}
|
||||
LoadingManage loading = visibleLoading(id, condition);
|
||||
if (loading != null) {
|
||||
List<Waybill> children = loadChildren(Set.of(loading.getLoadingNo()), Set.of())
|
||||
.getOrDefault(loading.getLoadingNo(), List.of());
|
||||
if (children.isEmpty()) {
|
||||
// 子单全部被拒单(或已释放)的配载运次对司机不可见,与列表口径一致
|
||||
throw new ServiceException("运次不存在或无权操作");
|
||||
}
|
||||
// 详情与列表同一份分档口径:接单需求也按项目生效中的配置判,否则列表说待接单、详情与接收入口说无需接单
|
||||
applyLiveProcessConfig(children);
|
||||
return loadingTrip(loading, children, true);
|
||||
}
|
||||
Waybill waybill = visibleWaybill(id, condition);
|
||||
if (waybill == null) {
|
||||
throw new ServiceException("运次不存在或无权操作");
|
||||
}
|
||||
applyLiveProcessConfig(List.of(waybill));
|
||||
return waybillTrip(waybill, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部可见运次:一次取回运单行后在服务层分组、分档、筛选并按排序键倒序
|
||||
* <p>
|
||||
* 分档用过程配置与接单态推导,于是 Tab 与卡片必然同档;行级筛选(组织类型 / 异常 / 创建时间)落在库层,
|
||||
* 命中的行同时决定这一趟车的子单集合与分档。
|
||||
*/
|
||||
private List<TripRows> trips(TripQueryVO condition) {
|
||||
Map<String, List<Waybill>> grouped = new LinkedHashMap<>();
|
||||
for (Waybill row : visibleRows(condition)) {
|
||||
grouped.computeIfAbsent(TripAggregation.keyOf(row.getLoadingNo(), row.getId()),
|
||||
ignored -> new ArrayList<>()).add(row);
|
||||
}
|
||||
Set<String> excluded = condition.isExcludeRejectedTrips() ? rejectedTripKeys() : Set.of();
|
||||
Set<Integer> tiers = tiers(condition);
|
||||
List<TripRows> trips = new ArrayList<>(grouped.size());
|
||||
grouped.forEach((key, rows) -> {
|
||||
if (excluded.contains(key)) {
|
||||
return;
|
||||
}
|
||||
List<Waybill> children = sortedChildren(rows);
|
||||
if (!inScope(children, condition)) {
|
||||
return;
|
||||
}
|
||||
boolean requireAccept = TripAggregation.requiresAccept(children);
|
||||
boolean accepted = WaybillProcessSupport.ACCEPT_ACCEPTED.equals(TripAggregation.acceptStatus(children));
|
||||
String status = TripAggregation.statusOf(children, requireAccept, accepted);
|
||||
Integer tier = TripAggregation.appStatus(status);
|
||||
if (tier == null || !tiers.contains(tier)) {
|
||||
return;
|
||||
}
|
||||
trips.add(new TripRows(key, rows.get(0).getLoadingNo(), children, status, tier, maxCreateTime(children)));
|
||||
});
|
||||
// 排序键是子单 create_time 的最大值,倒序;键相同再按运次键定序,免得同分页里顺序漂移
|
||||
trips.sort(Comparator.comparing(TripRows::sortTime, Comparator.nullsLast(Comparator.reverseOrder()))
|
||||
.thenComparing(TripRows::key));
|
||||
return trips;
|
||||
}
|
||||
|
||||
/** 子单按创建时刻升序:站点拼装与货物汇总都按这个顺序 */
|
||||
private List<Waybill> sortedChildren(List<Waybill> rows) {
|
||||
List<Waybill> children = new ArrayList<>(rows);
|
||||
children.sort(Comparator.comparing(Waybill::getCreateTime, Comparator.nullsLast(Comparator.naturalOrder())));
|
||||
return children;
|
||||
}
|
||||
|
||||
/**
|
||||
* 段一:一次取回该收口与行级筛选命中的可见运单行
|
||||
* <p>
|
||||
* 只 select 拼装与分档用得上的列:运单表上有几张大 JSON 列,整行拖回不值得。
|
||||
* goods_json 必须随行取回——卡片上的货名、货物汇总与合计重量都以它为准,一张运单可以录多条货物,
|
||||
* 主表 cargo_name 只落了第一行。可见口径与运单列表同一条:状态进运次的才取,被该司机拒单的不取。
|
||||
*/
|
||||
private List<Waybill> visibleRows(TripQueryVO condition) {
|
||||
LambdaQueryWrapper<Waybill> wrapper = Wrappers.<Waybill>lambdaQuery()
|
||||
.select(Waybill::getId, Waybill::getLoadingNo, Waybill::getWaybillNo, Waybill::getBusinessStatus,
|
||||
Waybill::getDriverAcceptStatus, Waybill::getProcessJson, Waybill::getProjectId, Waybill::getProjectName,
|
||||
Waybill::getCargoName, Waybill::getCargoType, Waybill::getQuantity, Waybill::getQuantityUnit,
|
||||
// 多货物运单的主表字段只有首行,货物明细只能从 goods_json 拿
|
||||
Waybill::getGoodsJson,
|
||||
Waybill::getDepartureName, Waybill::getDepartureAddress, Waybill::getArrivalName,
|
||||
Waybill::getArrivalAddress, Waybill::getDriverName, Waybill::getDriverPhone, Waybill::getVehicleNo,
|
||||
Waybill::getCustomerName, Waybill::getTransportType, Waybill::getTaskRemark,
|
||||
// 未配载运次没有配载单可依,计划时间要取运单自己的,故必须随行取回
|
||||
Waybill::getEstimatedStartTime, Waybill::getEstimatedEndTime, Waybill::getCreateTime)
|
||||
.in(Waybill::getBusinessStatus, TripAggregation.CHILD_STATUSES)
|
||||
.and(visible -> visible.isNull(Waybill::getDriverAcceptStatus)
|
||||
.or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED));
|
||||
applyScope(wrapper, condition);
|
||||
applyDeptScope(wrapper, condition);
|
||||
applyOrgTypeFilter(wrapper, condition);
|
||||
applyKeyword(wrapper, condition.getKeyword());
|
||||
List<Waybill> rows = waybillService.list(wrapper);
|
||||
applyLiveProcessConfig(rows);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组织收口:条件里给了组织 ID 集合时只出这些组织的运单行,空集合表示不过滤。
|
||||
* <p>
|
||||
* 与司机手机号收口一样落在库层的运单行上:分档、分页、总数都由它缩过的行集合算出来。
|
||||
* 读服务不自己解析当前用户的组织——它同时服务司机端与管理端,可见范围由调用方算好再传。
|
||||
*/
|
||||
private void applyDeptScope(LambdaQueryWrapper<Waybill> wrapper, TripQueryVO condition) {
|
||||
if (Func.isEmpty(condition.getDeptIds())) {
|
||||
return;
|
||||
}
|
||||
wrapper.in(Waybill::getDeptId, condition.getDeptIds());
|
||||
}
|
||||
|
||||
/**
|
||||
* 运输组织类型按子单行筛:它在同一趟车里必然是同一个值(配载的每张子单带同一个配载单号,
|
||||
* 未配载的每张自成一趟车),所以这一层不会把一趟车拆开。
|
||||
*/
|
||||
private void applyOrgTypeFilter(LambdaQueryWrapper<Waybill> wrapper, TripQueryVO condition) {
|
||||
if (TRANSPORT_LOAD.equals(condition.getTransportOrgType())) {
|
||||
wrapper.apply("IFNULL(loading_no, '') <> ''");
|
||||
} else if (TRANSPORT_COMMON.equals(condition.getTransportOrgType())) {
|
||||
wrapper.apply("IFNULL(loading_no, '') = ''");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 这趟车在不在筛选范围内:异常按「任一子单命中」,创建时间按子单 create_time 的最大值(运次列表的排序键,
|
||||
* 也是调度端卡片上显示的创建时间)。两个判据都按整趟车的子单集合算——先按筛选把子单集合缩小再分档,
|
||||
* 会让同一趟车归在某一档、卡片却显示另一档。
|
||||
* <p>
|
||||
* 筛选只决定这趟车在不在范围内,分档仍看它完整的可见子单集合。
|
||||
*/
|
||||
private boolean inScope(List<Waybill> children, TripQueryVO condition) {
|
||||
Set<Long> exceptionIds = condition.getExceptionWaybillIds();
|
||||
if (Func.isNotEmpty(exceptionIds)) {
|
||||
boolean hit = children.stream().map(Waybill::getId).anyMatch(exceptionIds::contains);
|
||||
if (EXCEPTION_YES.equals(condition.getException()) && !hit) {
|
||||
return false;
|
||||
}
|
||||
if (EXCEPTION_NO.equals(condition.getException()) && hit) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Date created = maxCreateTime(children);
|
||||
if (condition.getCreatedFrom() != null
|
||||
&& (created == null || created.before(condition.getCreatedFrom()))) {
|
||||
return false;
|
||||
}
|
||||
return condition.getCreatedTo() == null
|
||||
|| (created != null && !created.after(condition.getCreatedTo()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按单票路径的口径把生效中的过程配置写回内存里的行:项目启用中的配置优先,它没有可打卡节点时回退运单快照。
|
||||
* <p>
|
||||
* 只改内存里的对象,分档与卡片下发都读它,不落库;生效配置一次取回后按项目复用,不逐条回查。
|
||||
* 取法与二选一规则都在 {@link LiveProcessConfigSupport},这里只做批量写回。
|
||||
*/
|
||||
private void applyLiveProcessConfig(List<Waybill> rows) {
|
||||
if (Func.isEmpty(rows)) {
|
||||
return;
|
||||
}
|
||||
Set<Long> projectIds = rows.stream().map(Waybill::getProjectId).filter(Objects::nonNull)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
if (projectIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<Long, String> live = LiveProcessConfigSupport.byProjects(processConfigService, projectIds);
|
||||
if (live.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (Waybill row : rows) {
|
||||
String liveJson = live.get(row.getProjectId());
|
||||
if (Func.isNotEmpty(liveJson)) {
|
||||
row.setProcessJson(LiveProcessConfigSupport.resolveForPunch(liveJson, row.getProcessJson()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 有子单被拒、且这趟车还没结束的运次键:整单拒绝后这趟车不再算待处理
|
||||
*/
|
||||
private Set<String> rejectedTripKeys() {
|
||||
return waybillService.list(Wrappers.<Waybill>lambdaQuery()
|
||||
.select(Waybill::getId, Waybill::getLoadingNo)
|
||||
.eq(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED)
|
||||
.in(Waybill::getBusinessStatus, TripAggregation.REASSIGN_STATUSES))
|
||||
.stream()
|
||||
.map(row -> TripAggregation.keyOf(row.getLoadingNo(), row.getId()))
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
/**
|
||||
* 要哪几档:多档优先,其次单档,都不传按「全部」=待接单/进行中/已完成(不含已取消)
|
||||
*/
|
||||
private Set<Integer> tiers(TripQueryVO condition) {
|
||||
if (Func.isNotEmpty(condition.getStatuses())) {
|
||||
return new LinkedHashSet<>(condition.getStatuses());
|
||||
}
|
||||
if (condition.getStatus() != null) {
|
||||
return Set.of(condition.getStatus());
|
||||
}
|
||||
return Set.of(0, 1, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次可见范围:配载段按配载单的司机手机号收口,未配载段按运单的司机手机号收口。
|
||||
* <p>
|
||||
* 手机号允许带空格,与运单列表的收口判据一致。
|
||||
*/
|
||||
private void applyScope(LambdaQueryWrapper<Waybill> wrapper, TripQueryVO condition) {
|
||||
if (condition.isAllDrivers()) {
|
||||
return;
|
||||
}
|
||||
String phone = Func.toStr(condition.getDriverPhone(), "").trim();
|
||||
wrapper.and(scope -> scope
|
||||
.apply("(IFNULL(loading_no, '') <> '' AND loading_no IN (SELECT lm.loading_no FROM blade_loading_manage lm"
|
||||
+ " WHERE lm.is_deleted = 0 AND lm.driver_phone = {0}))", phone)
|
||||
.or(lone -> lone
|
||||
.apply("IFNULL(loading_no, '') = ''")
|
||||
.and(row -> row.eq(Waybill::getDriverPhone, phone)
|
||||
.or().apply("REPLACE(IFNULL(driver_phone, ''), ' ', '') = {0}", phone))));
|
||||
}
|
||||
|
||||
/**
|
||||
* 关键字按运次匹配:任一子单或配载单号命中即整趟车命中。
|
||||
* <p>
|
||||
* 先按关键字查出命中的运次键,再要求这一行的所属运次在其中——关键字不缩小分组内的子单集合,
|
||||
* 否则状态与排序会按命中的那一部分子单算。
|
||||
*/
|
||||
private void applyKeyword(LambdaQueryWrapper<Waybill> wrapper, String keyword) {
|
||||
if (Func.isEmpty(keyword)) {
|
||||
return;
|
||||
}
|
||||
Set<String> keys = keywordTripKeys(keyword.trim());
|
||||
if (keys.isEmpty()) {
|
||||
wrapper.apply("1 = 0");
|
||||
return;
|
||||
}
|
||||
Set<String> loadingNos = keys.stream().filter(key -> !key.startsWith(LONE_KEY_PREFIX))
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
Set<Long> loneIds = keys.stream().filter(key -> key.startsWith(LONE_KEY_PREFIX))
|
||||
.map(key -> key.substring(LONE_KEY_PREFIX.length()))
|
||||
.map(this::tripId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
wrapper.and(matched -> {
|
||||
if (!loadingNos.isEmpty()) {
|
||||
matched.in(Waybill::getLoadingNo, loadingNos);
|
||||
}
|
||||
if (!loneIds.isEmpty()) {
|
||||
if (!loadingNos.isEmpty()) {
|
||||
matched.or();
|
||||
}
|
||||
matched.apply("IFNULL(loading_no, '') = ''").in(Waybill::getId, loneIds);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 关键字命中的运次键:在子单上按关键字找一遍,再换算成运次键 */
|
||||
private Set<String> keywordTripKeys(String keyword) {
|
||||
String like = "%" + keyword + "%";
|
||||
Object[] params = new Object[KEYWORD_COLUMNS.length];
|
||||
StringBuilder matched = new StringBuilder();
|
||||
for (int i = 0; i < KEYWORD_COLUMNS.length; i++) {
|
||||
if (i > 0) {
|
||||
matched.append(" OR ");
|
||||
}
|
||||
matched.append(KEYWORD_COLUMNS[i]).append(" LIKE {").append(i).append('}');
|
||||
params[i] = like;
|
||||
}
|
||||
return waybillService.list(Wrappers.<Waybill>lambdaQuery()
|
||||
.select(Waybill::getId, Waybill::getLoadingNo)
|
||||
.apply("(" + matched + ")", params))
|
||||
.stream()
|
||||
.map(row -> TripAggregation.keyOf(row.getLoadingNo(), row.getId()))
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把这一页的运次组装成运次:先批量取配载单,再逐趟拼装
|
||||
* <p>
|
||||
* 子运单就是段一取回来的那些行,不再回查:回查会拿回未经行级筛选的子单,
|
||||
* 卡片的状态与货物汇总就会与分档、与筛选结果对不上。
|
||||
*/
|
||||
private List<TripVO> assemble(List<TripRows> trips) {
|
||||
if (Func.isEmpty(trips)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
Set<String> loadingNos = trips.stream()
|
||||
.map(TripRows::loadingNo)
|
||||
.filter(Func::isNotEmpty)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
Map<String, LoadingManage> loadings = loadLoadings(loadingNos);
|
||||
List<TripVO> result = new ArrayList<>(trips.size());
|
||||
for (TripRows trip : trips) {
|
||||
if (Func.isEmpty(trip.loadingNo())) {
|
||||
result.add(waybillTrip(trip.children().get(0), false));
|
||||
continue;
|
||||
}
|
||||
LoadingManage loading = loadings.get(trip.loadingNo());
|
||||
if (loading == null) {
|
||||
// 配载单被并发删除:不给出没有主键的运次
|
||||
continue;
|
||||
}
|
||||
result.add(loadingTrip(loading, trip.children(), false));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 段二:按本页运次的配载单号一次取回配载单
|
||||
*/
|
||||
private Map<String, LoadingManage> loadLoadings(Set<String> loadingNos) {
|
||||
if (loadingNos.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
return loadingManageService.list(Wrappers.<LoadingManage>lambdaQuery()
|
||||
.in(LoadingManage::getLoadingNo, loadingNos))
|
||||
.stream()
|
||||
.collect(Collectors.toMap(LoadingManage::getLoadingNo, Function.identity(), (first, ignored) -> first));
|
||||
}
|
||||
|
||||
/**
|
||||
* 段二:按本页运次的配载单号与未配载运单主键一次取回子运单,不逐条查
|
||||
* <p>
|
||||
* 子单归属一律以配载单号为准:库层就是按配载单号分组聚合的,服务层复核用别的关联关系,
|
||||
* 同一页里就会出现「Tab 说它在进行中、卡片按子单算出来是待接单」。
|
||||
* 配载单上的运单号集合只是派单时的快照,子单被释放(配载单号清空)后不再属于该运次。
|
||||
*/
|
||||
private Map<String, List<Waybill>> loadChildren(Set<String> loadingNos, Set<Long> loneIds) {
|
||||
if (loadingNos.isEmpty() && loneIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
List<Waybill> children = waybillService.list(Wrappers.<Waybill>lambdaQuery()
|
||||
.in(Waybill::getBusinessStatus, TripAggregation.CHILD_STATUSES)
|
||||
.and(visible -> visible.isNull(Waybill::getDriverAcceptStatus)
|
||||
.or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED))
|
||||
.and(scope -> scope.in(Func.isNotEmpty(loadingNos), Waybill::getLoadingNo, loadingNos)
|
||||
.or(Func.isNotEmpty(loneIds), lone -> lone.in(Waybill::getId, loneIds))));
|
||||
Map<String, List<Waybill>> grouped = new LinkedHashMap<>();
|
||||
for (Waybill child : children) {
|
||||
String key = Func.isEmpty(child.getLoadingNo()) ? String.valueOf(child.getId()) : child.getLoadingNo();
|
||||
grouped.computeIfAbsent(key, ignored -> new ArrayList<>()).add(child);
|
||||
}
|
||||
grouped.values().forEach(group -> group.sort(
|
||||
Comparator.comparing(Waybill::getCreateTime, Comparator.nullsLast(Comparator.naturalOrder()))));
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配载运次:司机、车辆、路线取配载单(配载单是运输执行主体),状态与接单需求由子单聚合
|
||||
*/
|
||||
private TripVO loadingTrip(LoadingManage loading, List<Waybill> children, boolean withPayload) {
|
||||
TripVO trip = new TripVO();
|
||||
trip.setId(tripIdOf(loading.getId()));
|
||||
trip.setLoadingNo(loading.getLoadingNo());
|
||||
trip.setTripNo(loading.getLoadingNo());
|
||||
trip.setTransportOrgType(TRANSPORT_LOAD);
|
||||
trip.setDriverName(Func.toStr(loading.getDriverName(), ""));
|
||||
trip.setDriverPhone(Func.toStr(loading.getDriverPhone(), ""));
|
||||
trip.setVehicleNo(Func.toStr(loading.getVehicleNo(), ""));
|
||||
trip.setTrailerVehicleNo(Func.toStr(loading.getTrailerVehicleNo(), ""));
|
||||
trip.setProjectName(Func.toStr(loading.getProjectName(), ""));
|
||||
trip.setCustomerName(Func.toStr(loading.getCustomerName(), ""));
|
||||
trip.setTransportType(Func.toStr(loading.getTransportType(), ""));
|
||||
trip.setCargoName(firstNotEmpty(loading.getCargoName(), children, Waybill::getCargoName));
|
||||
trip.setCargoType(firstNotEmpty(loading.getCargoType(), children, Waybill::getCargoType));
|
||||
trip.setDepartureAddress(Func.toStr(loading.getDepartureAddress(), ""));
|
||||
trip.setTransitAddress(Func.toStr(loading.getTransitAddress(), ""));
|
||||
trip.setArrivalAddress(Func.toStr(loading.getArrivalAddress(), ""));
|
||||
trip.setEstimatedStartDate(loading.getEstimatedStartDate());
|
||||
trip.setEstimatedEndDate(loading.getEstimatedEndDate());
|
||||
trip.setTaskRemark(Func.toStr(loading.getTaskRemark(), ""));
|
||||
if (withPayload) {
|
||||
trip.setRouteJson(loading.getRouteJson());
|
||||
trip.setGoodsJson(loading.getGoodsJson());
|
||||
}
|
||||
fillAggregation(trip, children);
|
||||
fillRouteAndCargo(trip, loading, children, withPayload);
|
||||
return trip;
|
||||
}
|
||||
|
||||
/**
|
||||
* 未配载运次:整张运单就是这一趟车
|
||||
*/
|
||||
private TripVO waybillTrip(Waybill waybill, boolean withPayload) {
|
||||
TripVO trip = new TripVO();
|
||||
trip.setId(tripIdOf(waybill.getId()));
|
||||
trip.setTripNo(waybill.getWaybillNo());
|
||||
trip.setTransportOrgType(TRANSPORT_COMMON);
|
||||
trip.setDriverName(Func.toStr(waybill.getDriverName(), ""));
|
||||
trip.setDriverPhone(Func.toStr(waybill.getDriverPhone(), ""));
|
||||
trip.setVehicleNo(Func.toStr(waybill.getVehicleNo(), ""));
|
||||
trip.setProjectName(Func.toStr(waybill.getProjectName(), ""));
|
||||
trip.setCustomerName(Func.toStr(waybill.getCustomerName(), ""));
|
||||
trip.setTransportType(Func.toStr(waybill.getTransportType(), ""));
|
||||
trip.setCargoName(Func.toStr(waybill.getCargoName(), ""));
|
||||
trip.setCargoType(Func.toStr(waybill.getCargoType(), ""));
|
||||
trip.setDepartureAddress(Func.toStr(waybill.getDepartureAddress(), ""));
|
||||
trip.setArrivalAddress(Func.toStr(waybill.getArrivalAddress(), ""));
|
||||
// 未配载没有配载单可依,计划时间只能取这张运单自己的
|
||||
trip.setEstimatedStartDate(waybill.getEstimatedStartTime());
|
||||
trip.setEstimatedEndDate(waybill.getEstimatedEndTime());
|
||||
trip.setTaskRemark(Func.toStr(waybill.getTaskRemark(), ""));
|
||||
if (withPayload) {
|
||||
trip.setRouteJson(waybill.getRouteJson());
|
||||
trip.setGoodsJson(waybill.getGoodsJson());
|
||||
}
|
||||
fillAggregation(trip, List.of(waybill));
|
||||
fillRouteAndCargo(trip, null, List.of(waybill), withPayload);
|
||||
return trip;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚合出运次的状态、接单需求、接单态与排序键:与分档用同一份推导,Tab 与卡片因此必然同档
|
||||
*/
|
||||
private void fillAggregation(TripVO trip, List<Waybill> children) {
|
||||
String acceptStatus = TripAggregation.acceptStatus(children);
|
||||
boolean requireAccept = TripAggregation.requiresAccept(children);
|
||||
boolean accepted = WaybillProcessSupport.ACCEPT_ACCEPTED.equals(acceptStatus);
|
||||
trip.setAcceptStatus(acceptStatus);
|
||||
trip.setRequireAccept(requireAccept);
|
||||
trip.setStatus(TripAggregation.appStatus(TripAggregation.statusOf(children, requireAccept, accepted)));
|
||||
trip.setWaybillCount(children.size());
|
||||
trip.setSortTime(maxCreateTime(children));
|
||||
trip.setWaybills(children.stream().map(this::toWaybillVO).toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点与货物都按整趟车算:站点明细只在详情下发,列表带走起/经/终、站点数与货物汇总
|
||||
*/
|
||||
private void fillRouteAndCargo(TripVO trip, LoadingManage loading, List<Waybill> children, boolean withPayload) {
|
||||
List<TripStationVO> stations = TripStationAssembler.assemble(loading, children);
|
||||
trip.setDepartureName(TripStationAssembler.departureName(stations));
|
||||
trip.setTransitName(TripStationAssembler.transitName(stations));
|
||||
trip.setArrivalName(TripStationAssembler.arrivalName(stations));
|
||||
trip.setStationCount(stations.size());
|
||||
if (withPayload) {
|
||||
trip.setStations(stations);
|
||||
}
|
||||
List<WaybillCargoSupport.Cargo> cargos = children.stream()
|
||||
.flatMap(child -> WaybillCargoSupport.of(child).stream())
|
||||
.toList();
|
||||
List<String> cargoNames = WaybillCargoSupport.cargoNames(cargos);
|
||||
// 运次级货名覆盖整趟车:主表 cargo_name 只是首行,配载单的 cargo_name 也是派单时的快照
|
||||
trip.setCargoNames(cargoNames);
|
||||
if (!cargoNames.isEmpty()) {
|
||||
trip.setCargoName(String.join("、", cargoNames));
|
||||
}
|
||||
List<String> cargoTypes = cargos.stream()
|
||||
.map(WaybillCargoSupport.Cargo::cargoType)
|
||||
.filter(Func::isNotEmpty)
|
||||
.distinct()
|
||||
.toList();
|
||||
trip.setCargoTypes(cargoTypes);
|
||||
if (!cargoTypes.isEmpty()) {
|
||||
trip.setCargoType(String.join("、", cargoTypes));
|
||||
}
|
||||
trip.setCargoSummary(cargoSummary(children));
|
||||
trip.setCargos(cargoLines(cargos));
|
||||
fillTotalQuantity(trip, children);
|
||||
fillWeighing(trip, cargos);
|
||||
}
|
||||
|
||||
/**
|
||||
* 货物汇总:同名货物合并且单位一致时取数量合计,没有数量的只列货名
|
||||
* <p>
|
||||
* 一条运单可以有多条货物,明细在 goods_json;主表 cargo_name 只落了第一行,拿它汇总等于把其余货物丢掉。
|
||||
*/
|
||||
private String cargoSummary(List<Waybill> children) {
|
||||
Map<String, Map<String, BigDecimal>> grouped = new LinkedHashMap<>();
|
||||
for (Waybill child : children) {
|
||||
List<WaybillCargoSupport.Cargo> cargos = WaybillCargoSupport.of(child);
|
||||
if (cargos.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
for (WaybillCargoSupport.Cargo cargo : cargos) {
|
||||
String name = cargo.displayName();
|
||||
String unit = Func.toStr(cargo.quantityUnit(), "");
|
||||
Map<String, BigDecimal> byUnit = grouped.computeIfAbsent(name, key -> new LinkedHashMap<>());
|
||||
BigDecimal quantity = cargo.quantity() == null ? BigDecimal.ZERO : cargo.quantity();
|
||||
byUnit.merge(unit, quantity, BigDecimal::add);
|
||||
}
|
||||
}
|
||||
List<String> parts = new ArrayList<>();
|
||||
grouped.forEach((name, byUnit) -> byUnit.forEach((unit, quantity) ->
|
||||
parts.add(quantity.signum() == 0 ? name : name + " " + formatQuantity(quantity, unit))));
|
||||
return String.join("、", parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 货物明细行:与 cargoSummary 同一套合并口径,但数量与单位分开留,供详情页逐行展示
|
||||
*/
|
||||
private List<CargoLineVO> cargoLines(List<WaybillCargoSupport.Cargo> cargos) {
|
||||
List<CargoLineVO> lines = new ArrayList<>();
|
||||
for (WaybillCargoSupport.Line line : WaybillCargoSupport.lines(cargos)) {
|
||||
CargoLineVO vo = new CargoLineVO();
|
||||
vo.setCargoName(line.name());
|
||||
vo.setQuantity(line.quantity() == null ? "" : formatQuantity(line.quantity(), line.unit()));
|
||||
lines.add(vo);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合计重量:只有全部货物同单位时才能相加,单位不一致时留空(汇总文案里仍分单位列出)
|
||||
* <p>
|
||||
* 按货物行算而不是按运单行算:一张运单里的多条货物可能是不同的单位与数量,
|
||||
* 主表 quantity 是这些行的合计、quantity_unit 只是首行的单位,拿它当合计会把「20堆 + 50方」说成「70堆」。
|
||||
*/
|
||||
private void fillTotalQuantity(TripVO trip, List<Waybill> children) {
|
||||
String unit = null;
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
boolean any = false;
|
||||
for (Waybill child : children) {
|
||||
for (WaybillCargoSupport.Cargo cargo : WaybillCargoSupport.of(child)) {
|
||||
String cargoUnit = Func.toStr(cargo.quantityUnit(), "");
|
||||
if (cargo.quantity() == null || Func.isEmpty(cargoUnit)) {
|
||||
return;
|
||||
}
|
||||
if (unit == null) {
|
||||
unit = cargoUnit;
|
||||
} else if (!unit.equals(cargoUnit)) {
|
||||
return;
|
||||
}
|
||||
total = total.add(cargo.quantity());
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
if (!any || unit == null) {
|
||||
return;
|
||||
}
|
||||
trip.setTotalQuantity(total);
|
||||
trip.setQuantityUnit(unit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计重合计:卡片上那个「货物重量」,口径见 WaybillCargoSupport#weighing
|
||||
*/
|
||||
private void fillWeighing(TripVO trip, List<WaybillCargoSupport.Cargo> cargos) {
|
||||
WaybillCargoSupport.Quantity weighing = WaybillCargoSupport.weighing(cargos);
|
||||
if (weighing == null) {
|
||||
return;
|
||||
}
|
||||
trip.setWeighingQuantity(weighing.value());
|
||||
trip.setWeighingUnit(weighing.unit());
|
||||
}
|
||||
|
||||
private String formatQuantity(BigDecimal quantity, String unit) {
|
||||
String value = quantity.stripTrailingZeros().toPlainString();
|
||||
return Func.isEmpty(unit) ? value : value + unit;
|
||||
}
|
||||
|
||||
private TripVO.TripWaybillVO toWaybillVO(Waybill waybill) {
|
||||
TripVO.TripWaybillVO child = new TripVO.TripWaybillVO();
|
||||
child.setId(tripIdOf(waybill.getId()));
|
||||
child.setWaybillNo(waybill.getWaybillNo());
|
||||
child.setBusinessStatus(waybill.getBusinessStatus());
|
||||
child.setAcceptStatus(waybill.getDriverAcceptStatus());
|
||||
child.setRequireAccept(WaybillProcessSupport.requiresDriverAcceptConfirmation(waybill.getProcessJson()));
|
||||
child.setProjectId(waybill.getProjectId());
|
||||
child.setProjectName(waybill.getProjectName());
|
||||
// 货名按运单上实际录入的货物给全:主表 cargo_name 只是首行,多货物运单会漏掉其余货物
|
||||
List<WaybillCargoSupport.Cargo> cargos = WaybillCargoSupport.of(waybill);
|
||||
child.setCargoName(String.join("、", WaybillCargoSupport.cargoNames(cargos)));
|
||||
child.setCargoNames(WaybillCargoSupport.cargoNames(cargos));
|
||||
child.setCargoTypes(cargos.stream()
|
||||
.map(WaybillCargoSupport.Cargo::cargoType)
|
||||
.filter(Func::isNotEmpty)
|
||||
.distinct()
|
||||
.toList());
|
||||
child.setCargoType(waybill.getCargoType());
|
||||
child.setQuantity(waybill.getQuantity());
|
||||
child.setQuantityUnit(waybill.getQuantityUnit());
|
||||
child.setDepartureName(waybill.getDepartureName());
|
||||
child.setDepartureAddress(waybill.getDepartureAddress());
|
||||
child.setArrivalName(waybill.getArrivalName());
|
||||
child.setArrivalAddress(waybill.getArrivalAddress());
|
||||
child.setCreateTime(waybill.getCreateTime());
|
||||
return child;
|
||||
}
|
||||
|
||||
/** 排序键:子单 create_time 的最大值 */
|
||||
private Date maxCreateTime(List<Waybill> children) {
|
||||
return children.stream()
|
||||
.map(Waybill::getCreateTime)
|
||||
.filter(Objects::nonNull)
|
||||
.max(Comparator.naturalOrder())
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private String firstNotEmpty(String preferred, List<Waybill> children, Function<Waybill, String> getter) {
|
||||
if (Func.isNotEmpty(preferred)) {
|
||||
return preferred;
|
||||
}
|
||||
return children.stream().map(getter).filter(Func::isNotEmpty).findFirst().orElse("");
|
||||
}
|
||||
|
||||
/**
|
||||
* 按运次主键取配载单:主键命中配载单即配载运次,再按司机收口
|
||||
*/
|
||||
private LoadingManage visibleLoading(Long id, TripQueryVO condition) {
|
||||
LoadingManage loading = loadingManageService.getById(id);
|
||||
if (loading == null) {
|
||||
return null;
|
||||
}
|
||||
if (condition.isAllDrivers()) {
|
||||
return loading;
|
||||
}
|
||||
return samePhone(loading.getDriverPhone(), condition.getDriverPhone()) ? loading : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按运次主键取未配载运单:必须还没配载、在可见状态里,且属于收口范围
|
||||
*/
|
||||
private Waybill visibleWaybill(Long id, TripQueryVO condition) {
|
||||
Waybill waybill = waybillService.getById(id);
|
||||
if (waybill == null
|
||||
|| Func.isNotEmpty(waybill.getLoadingNo())
|
||||
|| !TripAggregation.CHILD_STATUSES.contains(waybill.getBusinessStatus())
|
||||
|| WaybillProcessSupport.ACCEPT_REJECTED.equals(waybill.getDriverAcceptStatus())) {
|
||||
return null;
|
||||
}
|
||||
if (condition.isAllDrivers()) {
|
||||
return waybill;
|
||||
}
|
||||
return samePhone(waybill.getDriverPhone(), condition.getDriverPhone()) ? waybill : null;
|
||||
}
|
||||
|
||||
private boolean scoped(TripQueryVO condition) {
|
||||
return condition.isAllDrivers() || Func.isNotEmpty(Func.toStr(condition.getDriverPhone(), "").trim());
|
||||
}
|
||||
|
||||
private boolean samePhone(String left, String right) {
|
||||
String expected = Func.toStr(right, "").trim().replace(" ", "");
|
||||
if (Func.isEmpty(expected)) {
|
||||
return false;
|
||||
}
|
||||
return expected.equals(Func.toStr(left, "").trim().replace(" ", ""));
|
||||
}
|
||||
|
||||
private TripQueryVO condition(TripQueryVO query) {
|
||||
return query == null ? new TripQueryVO() : query;
|
||||
}
|
||||
|
||||
/** 去掉 Tab 条件、其余照搬:统计四档时不按某一档收窄 */
|
||||
private TripQueryVO withoutTab(TripQueryVO source) {
|
||||
TripQueryVO copy = new TripQueryVO();
|
||||
copy.setDriverPhone(source.getDriverPhone());
|
||||
copy.setAllDrivers(source.isAllDrivers());
|
||||
copy.setDeptIds(source.getDeptIds());
|
||||
copy.setKeyword(source.getKeyword());
|
||||
copy.setTransportOrgType(source.getTransportOrgType());
|
||||
copy.setException(source.getException());
|
||||
copy.setExceptionWaybillIds(source.getExceptionWaybillIds());
|
||||
copy.setCreatedFrom(source.getCreatedFrom());
|
||||
copy.setCreatedTo(source.getCreatedTo());
|
||||
copy.setExcludeRejectedTrips(source.isExcludeRejectedTrips());
|
||||
return copy;
|
||||
}
|
||||
|
||||
private Long tripId(String value) {
|
||||
if (Func.isEmpty(value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(value.trim());
|
||||
} catch (NumberFormatException ignored) {
|
||||
// 运次主键只有配载单与运单两种数字雪花号,其余一律当作不存在的运次
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 运次主键按字符串下发:19 位雪花号按数字下发会被前端取整 */
|
||||
private String tripIdOf(Long id) {
|
||||
return id == null ? null : String.valueOf(id);
|
||||
}
|
||||
|
||||
/** 一趟车:运次键、子单行(段一取回的、含行级筛选后的)、派生状态与排序键 */
|
||||
private record TripRows(String key, String loadingNo, List<Waybill> children, String status, Integer tier,
|
||||
Date sortTime) {
|
||||
}
|
||||
|
||||
private int pageNo(Integer current) {
|
||||
return current == null || current < 1 ? 1 : current;
|
||||
}
|
||||
|
||||
private int pageSize(Integer size) {
|
||||
if (size == null || size < 1) {
|
||||
return DEFAULT_PAGE_SIZE;
|
||||
}
|
||||
return Math.min(size, MAX_PAGE_SIZE);
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user