diff --git a/dependency-reduced-pom.xml b/dependency-reduced-pom.xml index 8ce9c23..5d8c563 100644 --- a/dependency-reduced-pom.xml +++ b/dependency-reduced-pom.xml @@ -2,7 +2,7 @@ 4.0.0 com.budwk - zhgh_zjnu + mini 5.6.0-plus diff --git a/src/main/java/com/budwk/app/MainLauncher.java b/src/main/java/com/budwk/app/MainLauncher.java index 8d088b1..b220ad2 100644 --- a/src/main/java/com/budwk/app/MainLauncher.java +++ b/src/main/java/com/budwk/app/MainLauncher.java @@ -13,6 +13,8 @@ import com.budwk.app.web.commons.base.Globals; import com.budwk.app.web.commons.ext.pubsub.WebPubSub; import com.budwk.app.web.commons.ext.handler.WkTomcatConfiguration; import com.budwk.app.zhgh.dayofficework.message.service.GlobalMessageSendService; +import com.budwk.app.zhgh.club.service.ClubPayRecordService; +import com.budwk.app.zhgh.club.service.SysClubUserService; import lombok.extern.slf4j.Slf4j; import org.beetl.core.GroupTemplate; import org.nutz.boot.NbApp; @@ -81,6 +83,15 @@ public class MainLauncher { Globals.AppBase = Mvcs.getServletContext().getContextPath(); Globals.AppRoot = Mvcs.getServletContext().getRealPath("/"); init_sys(); + // 2026年10月3日 12:01:28;操作人:wxg:先升级会员表及补齐历史资料,再处理缴费快照和启动计划任务;不依赖 DEBUG。 + SysClubUserService members = ioc.get(SysClubUserService.class, "sysClubUserServiceImpl"); + members.upgradeMemberSchema(); + members.backfillMemberSnapshots(); + // 2026年10月3日 09:05:37;操作人:wxg:缴费表升级独立于 DEBUG,先完成字段检查和旧数据补齐再启动任务。 + // 2026年10月3日 09:28:37;操作人:wxg:按实际注册的实现类 Bean 名称获取服务,避免接口默认名称不存在导致启动失败。 + ClubPayRecordService payRecords = ioc.get(ClubPayRecordService.class, "clubPayRecordServiceImpl"); + payRecords.upgradePayRecordSchema(); + payRecords.backfillPayRecordSnapshots(); init_task(); init_auth(); ioc.get(Globals.class); diff --git a/src/main/java/com/budwk/app/flow/controller/FlowDesignController.java b/src/main/java/com/budwk/app/flow/controller/FlowDesignController.java index 8af0262..b4a6ee5 100644 --- a/src/main/java/com/budwk/app/flow/controller/FlowDesignController.java +++ b/src/main/java/com/budwk/app/flow/controller/FlowDesignController.java @@ -243,6 +243,27 @@ public class FlowDesignController { return Result.success(); } + /** + * 设置流程设计在流程中心的展示状态。 + * @param id 流程设计 ID + * @param centerEnabled 传 1 表示显示,传 0 表示隐藏 + * @return Result;code 为 0 表示保存成功,非 0 时 msg 说明错误,data 无业务数据 + */ + @At + @ApiOperation("设置流程中心显示状态") + @Aop(TransAop.READ_COMMITTED) + @SaCheckPermission("flow.design") + public Result updateCenterEnabled(@Param("id") Long id, @Param("centerEnabled") Integer centerEnabled) { + if (id == null || centerEnabled == null || (centerEnabled != 0 && centerEnabled != 1)) { + return Result.error("流程设计或显示状态无效"); + } + int updated = processDesignService.update(Chain.make("centerEnabled", centerEnabled), Cnd.where(ProcessDesign::getId, "=", id)); + if (updated == 0 && processDesignService.fetch(id) == null) { + return Result.error("流程设计不存在"); + } + return Result.success(); + } + @At @SaCheckLogin @ApiOperation("获取流程设计任务参与者处理类") diff --git a/src/main/java/com/budwk/app/flow/entity/ProcessDesign.java b/src/main/java/com/budwk/app/flow/entity/ProcessDesign.java index 1b63eb6..ce19f8c 100644 --- a/src/main/java/com/budwk/app/flow/entity/ProcessDesign.java +++ b/src/main/java/com/budwk/app/flow/entity/ProcessDesign.java @@ -68,6 +68,11 @@ public class ProcessDesign extends BaseModel { @Column private Integer isDeployed; + @Comment("是否在流程中心显示(1显示,0隐藏)") + @Column + @Default("1") + private Integer centerEnabled; + @Comment("排序编码") @Column @Default("0") diff --git a/src/main/java/com/budwk/app/sys/controller/SysLoginController.java b/src/main/java/com/budwk/app/sys/controller/SysLoginController.java index 7ad931a..9df02a2 100644 --- a/src/main/java/com/budwk/app/sys/controller/SysLoginController.java +++ b/src/main/java/com/budwk/app/sys/controller/SysLoginController.java @@ -56,7 +56,7 @@ public class SysLoginController { private static final Log log = Logs.get(); private static final LoginRsaKeyRing LOGIN_RSA_KEY_RING = new LoginRsaKeyRing(); /** - * TODO: 临时屏蔽 localhost 平台登录验证码,恢复时改为 false。 + * TODO: 临时屏蔽回环地址及指定局域网 IP 的平台登录验证码,全部恢复时改为 false。 */ private static final boolean DISABLE_LOGIN_CAPTCHA = true; @Inject @@ -114,14 +114,15 @@ public class SysLoginController { return Result.error("参数缺失"); } - String lockKey = RedisConstant.USER_LOGIN_LOCK_PREFIX + username; - int errCount = Convert.toInt(StrUtil.blankToDefault(redisService.get(lockKey), "0")); - log.info("用户名:" + username + "登录失败次数:" + errCount); + // TODO: 临时停用所有账号的登录失败次数和 5 分钟锁定限制;恢复时同步取消下方失败计数写入的注释。 + // String lockKey = RedisConstant.USER_LOGIN_LOCK_PREFIX + username; + // int errCount = Convert.toInt(StrUtil.blankToDefault(redisService.get(lockKey), "0")); + // log.info("用户名:" + username + "登录失败次数:" + errCount); - if (errCount > 5) { - redisService.setex(lockKey, 5 * 60, String.valueOf(errCount + 1)); - return Result.error("登录失败次数过多,请5分钟后再试"); - } + // if (errCount > 5) { + // redisService.setex(lockKey, 5 * 60, String.valueOf(errCount + 1)); + // return Result.error("登录失败次数过多,请5分钟后再试"); + // } try { // 验证码校验 if (!isLoginCaptchaDisabled(req)) { @@ -154,7 +155,8 @@ public class SysLoginController { return Result.success("login.success"); } catch (Exception e) { log.error(e.getMessage(), e); - redisService.set(lockKey, Convert.toStr(errCount + 1)); + // TODO: 临时暂停累计登录失败次数;恢复限制时与上方计数读取、锁定拦截一并恢复。 + // redisService.set(lockKey, Convert.toStr(errCount + 1)); String message = e.getMessage(); return Result.error(e.getMessage()); } @@ -165,7 +167,9 @@ public class SysLoginController { return false; } String serverName = req.getServerName(); + // 临时允许该局域网地址免验证码;恢复 IP 验证码时删除此条件,并同步删除登录页中的该 IP。 return "localhost".equalsIgnoreCase(serverName) + || "192.168.0.108".equals(serverName) || "127.0.0.1".equals(serverName) || "0:0:0:0:0:0:0:1".equals(serverName) || "::1".equals(serverName); diff --git a/src/main/java/com/budwk/app/sys/controller/SysRoleController.java b/src/main/java/com/budwk/app/sys/controller/SysRoleController.java index cc2dfcf..9efc977 100644 --- a/src/main/java/com/budwk/app/sys/controller/SysRoleController.java +++ b/src/main/java/com/budwk/app/sys/controller/SysRoleController.java @@ -37,6 +37,7 @@ import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.GET; import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Param; +import org.nutz.mvc.annotation.POST; import javax.servlet.http.HttpServletRequest; import javax.validation.Valid; @@ -499,6 +500,24 @@ public class SysRoleController { return Result.success(list); } + /** + * 为全部 member=1 的用户设置会员角色,无需传入用户 ID、角色 ID 或列表筛选参数。 + * + * @return JSON Result,data.addedCount 为新增关联数,data.enabledCount 为恢复启用关联数 + */ + @At + @POST + @Ok("json") + @SaCheckPermission("sys.manager.role.user") + @SLog(tag = "一键设置会员角色", msg = "为全部member=1的人员补齐会员角色") + @Aop(TransAop.READ_COMMITTED) + public Result doSettingMemberRole() { + NutMap counts = sysRoleService.settingMemberRole(); + sysRoleService.clearCache(); + sysUserService.clearCache(); + return Result.success("会员角色设置完成", counts); + } + @At @Ok("json") @SaCheckPermission("sys.manager.role") diff --git a/src/main/java/com/budwk/app/sys/controller/v4/SysV4ServController.java b/src/main/java/com/budwk/app/sys/controller/v4/SysV4ServController.java index 7f3e393..8f47782 100644 --- a/src/main/java/com/budwk/app/sys/controller/v4/SysV4ServController.java +++ b/src/main/java/com/budwk/app/sys/controller/v4/SysV4ServController.java @@ -11,6 +11,7 @@ import org.nutz.dao.Cnd; import org.nutz.dao.Dao; import org.nutz.dao.Sqls; import org.nutz.dao.sql.Sql; +import org.nutz.dao.util.cri.Static; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.lang.util.NutMap; @@ -57,6 +58,8 @@ public class SysV4ServController { $condition """); Cnd cnd = Cnd.NEW(); + // 只隐藏流程中心入口;历史流程实例及已有流程定义保持原有状态。 + cnd.and(new Static("COALESCE(d.centerEnabled, 1) = 1")); if (StrUtil.isNotBlank(keyword)) { cnd.where().andLike("t.disPlayName", keyword); } diff --git a/src/main/java/com/budwk/app/sys/models/Sys_user.java b/src/main/java/com/budwk/app/sys/models/Sys_user.java index 37753d5..edc832a 100644 --- a/src/main/java/com/budwk/app/sys/models/Sys_user.java +++ b/src/main/java/com/budwk/app/sys/models/Sys_user.java @@ -237,6 +237,11 @@ public class Sys_user extends BaseModel implements Serializable { @ColDefine(type = ColType.DATE) private Date retireDate; + @Column + @Comment("福利享受截止时间") + @ColDefine(type = ColType.DATE) + private Date welfareStopDate; + @Column @Comment("博士后进站时间") @ColDefine(type = ColType.DATE) diff --git a/src/main/java/com/budwk/app/sys/services/SysRoleService.java b/src/main/java/com/budwk/app/sys/services/SysRoleService.java index 1c6d6eb..6daeedf 100644 --- a/src/main/java/com/budwk/app/sys/services/SysRoleService.java +++ b/src/main/java/com/budwk/app/sys/services/SysRoleService.java @@ -6,6 +6,7 @@ import com.budwk.app.base.service.BaseService; import com.budwk.app.sys.models.Sys_menu; import com.budwk.app.sys.models.Sys_role; import com.budwk.app.sys.models.Sys_unit; +import org.nutz.lang.util.NutMap; import java.util.List; @@ -128,6 +129,13 @@ public interface SysRoleService extends BaseService { */ List getRoleIdsByCode(List roleConstantList); + /** + * 为用户表中 member=1 的全部人员补齐启用的 MEMBER 角色,不受列表筛选影响。 + * + * @return addedCount 为新增关联数,enabledCount 为恢复启用的关联数;已有启用关联不重复添加 + */ + NutMap settingMemberRole(); + /** * 清空缓存 */ diff --git a/src/main/java/com/budwk/app/sys/services/impl/SysDataUserAllUpdateServiceImpl.java b/src/main/java/com/budwk/app/sys/services/impl/SysDataUserAllUpdateServiceImpl.java index 790ad4d..41d323a 100644 --- a/src/main/java/com/budwk/app/sys/services/impl/SysDataUserAllUpdateServiceImpl.java +++ b/src/main/java/com/budwk/app/sys/services/impl/SysDataUserAllUpdateServiceImpl.java @@ -23,7 +23,6 @@ import com.budwk.app.zhgh.democratic.proposal.models.ProposalConfig; import com.budwk.app.zhgh.staffmanage.member.constant.MemberChangeOrigin; import com.budwk.app.zhgh.staffmanage.member.constant.MemberChangeType; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.StringUtils; import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; @@ -161,10 +160,6 @@ public class SysDataUserAllUpdateServiceImpl implements SysDataUserUpdateService for (Sys_user_source source : sources) { Sys_user user = userMap.get(source.getLoginname()); source.setMember(null); - String unitId = source.getUnitId(); - if (StringUtils.isNotBlank(unitId) && unitId.length() == 5) { - source.setUnitId(unitId.substring(0, 3)); - } // 创建用户对象 Sys_user u = new Sys_user(); @@ -180,12 +175,15 @@ public class SysDataUserAllUpdateServiceImpl implements SysDataUserUpdateService } else { // 修改现有用户 u.setId(user.getId()); + // 拉取数据未分配三级单位时写入空字符串,避免 updateIgnoreNull 保留人员原有的三级单位。 + if (StrUtil.isBlank(source.getThreeUnitId())) { + u.setThreeUnitId(""); + } /* * aidFundMember 由基金会员业务维护,数据中心源表默认值为 0。 * 全量更新已有用户时置空,避免 updateIgnoreNull 把已迁移的基金会员标识覆盖为非会员。 */ u.setAidFundMember(null); - u.setPersonType(null); needDoUpdateList.add(u); } @@ -383,14 +381,13 @@ public class SysDataUserAllUpdateServiceImpl implements SysDataUserUpdateService Sql resetStatusSql = Sqls.create(""" UPDATE sys_user userInfo SET userInfo.pendingConfirmStatus = @noneStatus - WHERE userInfo.personType IN ('教职工', '劳务派遣人员') - AND userInfo.pendingConfirmStatus <> @noneStatus + WHERE userInfo.pendingConfirmStatus <> @noneStatus + AND userInfo.member='1' AND EXISTS ( SELECT 1 FROM sys_user_source sourceInfo WHERE sourceInfo.pullTime = @pullTime AND sourceInfo.loginname = userInfo.loginname - AND sourceInfo.personType IN ('教职工', '劳务派遣人员') ) """); resetStatusSql.setParam("noneStatus", PENDING_CONFIRM_STATUS_NONE); @@ -400,14 +397,13 @@ public class SysDataUserAllUpdateServiceImpl implements SysDataUserUpdateService Sql pendingStatusSql = Sqls.create(""" UPDATE sys_user userInfo SET userInfo.pendingConfirmStatus = @pendingStatus - WHERE userInfo.personType IN ('教职工', '劳务派遣人员') - AND IFNULL(userInfo.pendingConfirmStatus, @noneStatus) = @noneStatus + WHERE IFNULL(userInfo.pendingConfirmStatus, @noneStatus) = @noneStatus + AND userInfo.member='1' AND NOT EXISTS ( SELECT 1 FROM sys_user_source sourceInfo WHERE sourceInfo.pullTime = @pullTime AND sourceInfo.loginname = userInfo.loginname - AND sourceInfo.personType IN ('教职工', '劳务派遣人员') ) """); pendingStatusSql.setParam("pendingStatus", PENDING_CONFIRM_STATUS_PENDING); diff --git a/src/main/java/com/budwk/app/sys/services/impl/SysDataUserPullServiceImpl.java b/src/main/java/com/budwk/app/sys/services/impl/SysDataUserPullServiceImpl.java index 894af09..565aede 100644 --- a/src/main/java/com/budwk/app/sys/services/impl/SysDataUserPullServiceImpl.java +++ b/src/main/java/com/budwk/app/sys/services/impl/SysDataUserPullServiceImpl.java @@ -132,6 +132,39 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl Date nowDate = new Date(); + // 人员单位号先与单位表批量比对,缺失单位只同步一次,避免逐人查询单位表。 + Set sourceUnitIds = rawDataList.stream() + .map(raw -> raw.getStr("SZDWH")) + .filter(StrUtil::isNotBlank) + .collect(Collectors.toSet()); + Map unitMap = dao().query(Sys_unit.class, Cnd.NEW()).stream() + .collect(Collectors.toMap(Sys_unit::getId, unit -> unit)); + if (!unitMap.keySet().containsAll(sourceUnitIds)) { + log.info("单位需要更新,正在同步更新"); + sysDataUnitPullService.updateUnits(new ArrayList<>(unitMap.keySet())); + unitMap = dao().query(Sys_unit.class, Cnd.NEW()).stream() + .collect(Collectors.toMap(Sys_unit::getId, unit -> unit)); + } + + // 按单位表的实际等级判断:三级单位为3、直属二级单位为2;单位号位数不参与判断。 + Map tertiaryParentUnitIds = new HashMap<>(); + for (String sourceUnitId : sourceUnitIds) { + Sys_unit unit = unitMap.get(sourceUnitId); + if (unit == null) { + log.warn("人员单位号{}在单位表中不存在,保留信息中心原始单位号", sourceUnitId); + continue; + } + if (!Integer.valueOf(3).equals(unit.getUnitLevel())) { + continue; + } + Sys_unit parentUnit = unitMap.get(unit.getParentId()); + if (parentUnit == null || !Integer.valueOf(2).equals(parentUnit.getUnitLevel())) { + log.warn("三级单位{}缺少有效的二级父单位,保留信息中心原始单位号", sourceUnitId); + continue; + } + tertiaryParentUnitIds.put(sourceUnitId, parentUnit.getId()); + } + // 赋值 List latestSourceList = rawDataList.stream().map(raw -> { Sys_user_source sysUser = new Sys_user_source(); @@ -174,7 +207,10 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl } sysUser.setUnitName(raw.getStr("SZDW")); - sysUser.setUnitId(resolveSourceUnitId(raw.getStr("SZDWH"))); + String sourceUnitId = raw.getStr("SZDWH"); + String parentUnitId = tertiaryParentUnitIds.get(sourceUnitId); + sysUser.setUnitId(StrUtil.blankToDefault(parentUnitId, sourceUnitId)); + sysUser.setThreeUnitId(parentUnitId == null ? null : sourceUnitId); // 设置拉取时间 sysUser.setPullTime(nowDate); @@ -188,17 +224,6 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl dao().insert(latestSourceList); } - // 人员的单位数据和数据库的单位数据比较,如果人员里面有单位不存在,去更新单位数据 - List sourceUnitIds = latestSourceList.stream().map(Sys_user_source::getUnitId).filter(StrUtil::isNotBlank).distinct().toList(); - Sql sql = Sqls.create("select id from sys_unit group by id"); - sql.setCallback(Sqls.callback.strList()); - dao().execute(sql); - List unitIds = sql.getList(String.class); - - if (!new HashSet<>(unitIds).containsAll(sourceUnitIds)) { - log.info("单位需要更新,正在同步更新"); - sysDataUnitPullService.updateUnits(unitIds); - } return nowDate; } catch (Exception e) { throw new BaseException("数据拉取失败,{}", e.getMessage()); @@ -342,22 +367,6 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl return new ArrayList<>(userMap.values()); } - /** - * 根据接口返回的所在单位号转换数据源用户单位。 - * 入参 rawUnitId 对应人员接口 SZDWH:5位表示三级单位,需要写入其二级父单位;其他长度保持接口原值。 - * 返回值为 sys_user_source.unitId 使用的单位ID,找不到三级单位或父级为空时保留接口原始单位号,避免人员数据丢失。 - */ - private String resolveSourceUnitId(String rawUnitId) { - if (StrUtil.isBlank(rawUnitId) || rawUnitId.length() != 5) { - return rawUnitId; - } - Sys_unit unit = dao().fetch(Sys_unit.class, rawUnitId); - if (unit == null || StrUtil.isBlank(unit.getParentId())) { - return rawUnitId; - } - return unit.getParentId(); - } - @Async private void updateDict(List userSources) { //判断是否要更新在职状态字典 diff --git a/src/main/java/com/budwk/app/sys/services/impl/SysRoleServiceImpl.java b/src/main/java/com/budwk/app/sys/services/impl/SysRoleServiceImpl.java index 86e79a7..35bdb80 100644 --- a/src/main/java/com/budwk/app/sys/services/impl/SysRoleServiceImpl.java +++ b/src/main/java/com/budwk/app/sys/services/impl/SysRoleServiceImpl.java @@ -20,6 +20,7 @@ import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.lang.Strings; +import org.nutz.lang.util.NutMap; import org.nutz.plugins.wkcache.annotation.CacheDefaults; import org.nutz.plugins.wkcache.annotation.CacheRemoveAll; import org.nutz.plugins.wkcache.annotation.CacheResult; @@ -284,4 +285,44 @@ public class SysRoleServiceImpl extends BaseServiceImpl implements Sys public void clearCache() { } + + /** + * 批量补齐会员角色。仅处理 member=1 的用户,保留其他角色及账号禁用状态。 + * + * @return addedCount 为新增关联数,enabledCount 为恢复启用的关联数 + */ + @Override + @Aop(TransAop.READ_COMMITTED) + public NutMap settingMemberRole() { + // 锁定同一会员角色,使本接口的并发请求依次执行,避免重复插入用户角色关系。 + Sql roleSql = Sqls.create("SELECT * FROM sys_role WHERE code=@code FOR UPDATE"); + roleSql.setParam("code", RoleConstant.MEMBER.name()); + roleSql.setEntity(dao().getEntity(Sys_role.class)); + roleSql.setCallback(Sqls.callback.entities()); + dao().execute(roleSql); + List roles = roleSql.getList(Sys_role.class); + if (roles.isEmpty()) { + throw new BaseException("会员角色(MEMBER)不存在,请先配置会员角色"); + } + Sys_role role = roles.get(0); + if (role.isDisabled()) { + throw new BaseException("会员角色(MEMBER)已禁用,请先启用会员角色"); + } + + // 历史关联可能已停用或未填写 enable;恢复关联即可,无需重新插入。 + Sql enableSql = Sqls.create("UPDATE sys_user_role SET enable=1 WHERE roleId=@roleId " + + "AND (enable IS NULL OR enable<>1) " + + "AND EXISTS (SELECT 1 FROM sys_user u WHERE u.id=sys_user_role.userId AND u.member=1)"); + enableSql.setParam("roleId", role.getId()); + dao().execute(enableSql); + + // 按用户、角色判重,只为缺少会员关联的人员添加记录;不删除任何已有角色。 + Sql insertSql = Sqls.create("INSERT INTO sys_user_role (userId,roleId,enable) " + + "SELECT u.id,@roleId,1 FROM sys_user u WHERE u.member=1 " + + "AND NOT EXISTS (SELECT 1 FROM sys_user_role ur WHERE ur.userId=u.id AND ur.roleId=@roleId)"); + insertSql.setParam("roleId", role.getId()); + dao().execute(insertSql); + return NutMap.NEW().addv("addedCount", insertSql.getUpdateCount()) + .addv("enabledCount", enableSql.getUpdateCount()); + } } diff --git a/src/main/java/com/budwk/app/task/job/club/ClubGeneratePayRecordJob.java b/src/main/java/com/budwk/app/task/job/club/ClubGeneratePayRecordJob.java index ebb2f97..58fe3a3 100644 --- a/src/main/java/com/budwk/app/task/job/club/ClubGeneratePayRecordJob.java +++ b/src/main/java/com/budwk/app/task/job/club/ClubGeneratePayRecordJob.java @@ -1,60 +1,35 @@ package com.budwk.app.task.job.club; import cn.hutool.core.date.DateUtil; -import cn.hutool.json.JSONObject; -import com.budwk.app.web.commons.auth.utils.SecurityUtil; -import com.budwk.app.zhgh.club.model.ClubPayRecord; -import com.budwk.app.zhgh.club.model.ClubUser; -import org.nutz.dao.Cnd; -import org.nutz.dao.Dao; +import com.budwk.app.zhgh.club.service.ClubPayRecordService; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; +import org.quartz.DisallowConcurrentExecution; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.quartz.Job; import org.quartz.JobExecutionContext; import org.quartz.JobExecutionException; -import java.util.ArrayList; -import java.util.List; /** * @ClassName ClubGeneratePayRecordJob * @Author JyuHsin * @Date 2026/1/22 16:56 * @Version 1.0 - * @Description 每年的1月1日生成社团成员下年的缴费记录 + * @Description 每年的1月1日生成社团成员当年的缴费记录 */ @IocBean +@DisallowConcurrentExecution public class ClubGeneratePayRecordJob implements Job { @Inject - private Dao dao; + private ClubPayRecordService payRecordService; - @Override - public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException { - // 当前年份 - int thisYear = DateUtil.thisYear(); - // 获取最新的社团成员信息 - List clubUserList = dao.query(ClubUser.class, Cnd.NEW()); - // 生成缴费记录对象 - List list = new ArrayList<>(); - for (ClubUser clubUser : clubUserList) { - ClubPayRecord clubPayRecord = new ClubPayRecord(); - clubPayRecord.setYear(thisYear); - clubPayRecord.setClubId(clubUser.getClubId()); - clubPayRecord.setUserId(clubUser.getUserId()); - clubPayRecord.setPayed(false); - - JSONObject log = new JSONObject(); - log.set("operatorUserId", "task"); - log.set("operatorUserName", "定时任务"); - log.set("operatorTime", DateUtil.now()); - log.set("operatorType", "缴费管理-定时任务自动设置"); - log.set("oldValue", null); - log.set("newValue", false); - clubPayRecord.setOperateLogs(List.of(log)); - - list.add(clubPayRecord); - } - dao.insert(list); - } + /** 2026年10月3日 09:05:37;操作人:wxg:生成当年完整快照名单,service 批量去重并在事务中保存。 */ + @Override + @Aop(TransAop.READ_COMMITTED) + public void execute(JobExecutionContext context) throws JobExecutionException { + payRecordService.generateYearPayRecords(DateUtil.thisYear()); + } } diff --git a/src/main/java/com/budwk/app/zhgh/club/controller/apply/ClubUserJoinApplyController.java b/src/main/java/com/budwk/app/zhgh/club/controller/apply/ClubUserJoinApplyController.java index a563d30..59d147c 100644 --- a/src/main/java/com/budwk/app/zhgh/club/controller/apply/ClubUserJoinApplyController.java +++ b/src/main/java/com/budwk/app/zhgh/club/controller/apply/ClubUserJoinApplyController.java @@ -87,6 +87,7 @@ public class ClubUserJoinApplyController { Sql sql = Sqls.create(""" SELECT cua.*, + CAST(cua.mode AS SIGNED) AS mode, club.clubName, u.loginname AS loginName, u.username AS userName, @@ -109,14 +110,14 @@ public class ClubUserJoinApplyController { @At @ApiOperation("保存申请") + @Aop(TransAop.READ_COMMITTED) @SaCheckPermission(value = {"club.join.apply", "h5.club.join.apply"}, mode = SaMode.OR) @SLog(tag = "社团管理系统-申请入会", msg = "保存社团入会") public Result save(@Param("data") ClubUserApply clubUserApply) { clubUserApply.setRoleCode(RoleConstant.CLUB_MEMBER.name()); - clubUserApply.setMode(true); + clubUserApply.setMode(1); clubUserApply.setApplyDate(new Date()); - clubUserApply.setJoinTime(clubUserJoinService.resolveJoinTime(clubUserApply.getClubId(), clubUserApply.getUserId())); - clubUserApply.setExitTime(null); + clubUserJoinService.preparePendingJoinTimes(clubUserApply); dao.insertOrUpdate(clubUserApply); return Result.success(); } @@ -151,13 +152,13 @@ public class ClubUserJoinApplyController { if(!mode) { clubUserApply.setId(null); } - clubUserApply.setMode(mode); + // 请求参数沿用 PC/H5 的布尔值;持久化统一使用 1 入会、0 退会,2 仅由管理端生成。 + clubUserApply.setMode(Boolean.TRUE.equals(mode) ? 1 : 0); Date applyDate = new Date(); clubUserApply.setApplyDate(applyDate); if (mode) { clubUserApply.setRoleCode(RoleConstant.CLUB_MEMBER.name()); - clubUserApply.setJoinTime(clubUserJoinService.resolveJoinTime(clubUserApply.getClubId(), clubUserApply.getUserId())); - clubUserApply.setExitTime(null); + clubUserJoinService.preparePendingJoinTimes(clubUserApply); } else { clubUserApply.setJoinTime(clubUserJoinService.resolveJoinTime(clubUserApply.getClubId(), clubUserApply.getUserId())); clubUserApply.setExitTime(applyDate); @@ -184,6 +185,7 @@ public class ClubUserJoinApplyController { @Aop(TransAop.READ_COMMITTED) @SaCheckPermission(value = {"club.join.apply", "h5.club.join.apply"}, mode = SaMode.OR) public Result submitAgain(@Param("data") ClubUserApply clubUserApply, @Param("taskId") Long taskId) { + clubUserJoinService.preparePendingJoinTimes(clubUserApply); dao.insertOrUpdate(clubUserApply); Dict dict = Dict.create(); diff --git a/src/main/java/com/budwk/app/zhgh/club/controller/apply/ClubUserJoinMineController.java b/src/main/java/com/budwk/app/zhgh/club/controller/apply/ClubUserJoinMineController.java index feeb554..31d02d1 100644 --- a/src/main/java/com/budwk/app/zhgh/club/controller/apply/ClubUserJoinMineController.java +++ b/src/main/java/com/budwk/app/zhgh/club/controller/apply/ClubUserJoinMineController.java @@ -65,9 +65,30 @@ public class ClubUserJoinMineController { public Result pageData(@Valid PageForm pageForm, @Param("year") Integer year, @Param("clubName") String clubName){ + // 明确列出申请字段,避免分页总数子查询出现重名列;mode 只输出一次整数转换,保留系统处理值 2。 Sql sql = Sqls.create(""" SELECT - info.*, + info.id, + info.clubId, + info.userId, + info.roleCode, + info.clubPosition, + info.email, + info.mobile, + info.birthday, + info.avatar, + info.sameTimeJoinOtherClubSituation, + info.awardsExperience, + info.signature, + info.applyDate, + info.joinTime, + info.exitTime, + info.createdBy, + info.createdAt, + info.updatedBy, + info.updatedAt, + info.delFlag, + CAST(info.mode AS SIGNED) AS mode, club.clubName, u.loginname as loginName, u.username as userName, diff --git a/src/main/java/com/budwk/app/zhgh/club/controller/common/ClubCommonController.java b/src/main/java/com/budwk/app/zhgh/club/controller/common/ClubCommonController.java index 3175236..860d7e8 100644 --- a/src/main/java/com/budwk/app/zhgh/club/controller/common/ClubCommonController.java +++ b/src/main/java/com/budwk/app/zhgh/club/controller/common/ClubCommonController.java @@ -39,18 +39,9 @@ public class ClubCommonController { @At @SaCheckLogin + /** 无请求参数;返回入会社团选项,每项包含 id、clubName 等社团字段,同一社团不重复。 */ public Result listClub() { - Sql sql = Sqls.create(""" - SELECT - c.* - FROM - `sys_club` c - LEFT JOIN wf_process_instance inst ON inst.businessNo = c.id - WHERE - inst.state = @state - """).setParam("state", ProcessInstanceStateEnum.FINISHED.getCode()); - List list = baseService.listVO(sql, SysClub.class); - return Result.success(list); + return Result.success(sysClubService.listJoinClubOptions()); } @At diff --git a/src/main/java/com/budwk/app/zhgh/club/controller/examine/ClubExamineApplyController.java b/src/main/java/com/budwk/app/zhgh/club/controller/examine/ClubExamineApplyController.java index 6f037b9..85d0211 100644 --- a/src/main/java/com/budwk/app/zhgh/club/controller/examine/ClubExamineApplyController.java +++ b/src/main/java/com/budwk/app/zhgh/club/controller/examine/ClubExamineApplyController.java @@ -93,15 +93,30 @@ public class ClubExamineApplyController { return Result.success(myManageClub); } - @At - @SaCheckLogin - public Result getClubUserNum(String clubId, Integer year) { - if(StrUtil.isBlank(clubId)) { - return Result.error("社团信息为空,请核查"); - } - List result = sysClubExamineService.getClubUserNum(clubId, year); - return Result.success(result); - } + // 2026年10月2日wxg屏蔽操作:保留原统计参数处理入口。 +// @At +// @SaCheckLogin +// public Result getClubUserNum(String clubId, Integer year) { +// if(StrUtil.isBlank(clubId)) { +// return Result.error("社团信息为空,请核查"); +// } +// List result = sysClubExamineService.getClubUserNum(clubId, year); +// return Result.success(result); +// } +// +// + /** + * 2026年10月2日22:00点开始新开发的代码;实际整改:2026年10月3日 10:14:04;操作人:wxg。 + * 接收 clubId(所选社团ID)、year(公历年度),基础校验后调用 service; + * 返回 Result,data为两类计数及统计核查信息列表,异常参数返回错误提示。 + */ + @At + @SaCheckLogin + public Result getClubUserNum(String clubId, Integer year) { + if (StrUtil.isBlank(clubId)) return Result.error("社团信息为空,请核查"); + if (year == null || year < 1 || year > 9998) return Result.error("请选择有效统计年度"); + return Result.success(sysClubExamineService.getClubUserNum(clubId, year)); + } @At @SaCheckLogin diff --git a/src/main/java/com/budwk/app/zhgh/club/controller/infoManage/ClubInfoManageController.java b/src/main/java/com/budwk/app/zhgh/club/controller/infoManage/ClubInfoManageController.java index d8889a4..85fe665 100644 --- a/src/main/java/com/budwk/app/zhgh/club/controller/infoManage/ClubInfoManageController.java +++ b/src/main/java/com/budwk/app/zhgh/club/controller/infoManage/ClubInfoManageController.java @@ -30,6 +30,7 @@ import com.budwk.app.zhgh.club.model.SysClub; import com.budwk.app.zhgh.club.param.ClubUserPageForm; import com.budwk.app.zhgh.club.service.SysClubInfoManageService; import com.budwk.app.zhgh.club.service.SysClubUserService; +import com.budwk.app.zhgh.club.service.ClubPayRecordService; import com.budwk.app.zhgh.club.vo.ClubCommonPageVo; import com.budwk.app.zhgh.club.vo.ClubUserImportVo; import io.swagger.annotations.ApiOperation; @@ -77,6 +78,8 @@ public class ClubInfoManageController { private SysUserService sysUserService; @Inject private SysClubUserService clubUserService; + @Inject + private ClubPayRecordService payRecordService; @At("") @Ok("beetl:/platform/zhgh/club/infoManage/manage/index.html") @@ -137,19 +140,7 @@ public class ClubInfoManageController { @SaCheckPermission("club.infoManage.manage") @SLog(tag = "社团管理系统-信息管理", msg = "退会") public Result exitClub(@Valid String id) { - ClubUser clubUser = dao.fetch(ClubUser.class, id); - dao.delete(ClubUser.class,id); - List roleCodes = clubUser.getRoleCode(); - for (String roleCode : roleCodes) { - Sys_role sysRole = sysRoleService.getByCode(roleCode); - if (ObjectUtil.isNotEmpty(sysRole)) { - dao.clear(Sys_user_role.class, Cnd.NEW().and("userId", "=", clubUser.getUserId()) - .and("roleId", "=", sysRole.getId()) - .and("clubId", "=", clubUser.getClubId())); - } - } - sysUserService.clearCache(); - sysRoleService.clearCache(); + clubInfoManageService.exitClub(id); return Result.success(); } @@ -310,6 +301,8 @@ public class ClubInfoManageController { clubUser.setClubId(clubId); clubUser.setUserId(user); clubUser.setRoleCode(List.of(roleCode)); + // 2026年10月3日 12:01:28;操作人:wxg:仅新会员调用 service,统一填写确认入会时间和姓名/工号/电话。 + clubUserService.prepareDirectJoinTimes(List.of(clubUser)); } dao.insertOrUpdate(clubUser); @@ -335,6 +328,8 @@ public class ClubInfoManageController { log.set("newValue", payed != null ? payed : false); record.setOperateLogs(List.of(log)); + // 2026年10月3日 09:05:37;操作人:wxg:新增记录先填写人员快照。 + payRecordService.prepareSnapshots(List.of(record)); dao.insert(record); } else { payRecord.setPayed(payed != null ? payed : false); @@ -348,6 +343,8 @@ public class ClubInfoManageController { log.set("newValue", payed != null ? payed : false); payRecord.setOperateLogs(List.of(log)); + // 已有年度记录仅补缺失快照,原有年度资料和加入时间不覆盖。 + payRecordService.prepareSnapshots(List.of(payRecord)); dao.update(payRecord); } } @@ -356,7 +353,13 @@ public class ClubInfoManageController { return Result.success(); } + /** + * @param clubId 左侧选中的社团ID,用于导出该社团登记表 + * @param response 成功时写入 DOCX 附件;失败由统一异常处理返回含 code/msg 的错误结果 + * @return void,附件直接写入响应,不再渲染 JSON 成功结果 + */ @At + @Ok("void") @SaCheckPermission("club.infoManage.manage") @ApiOperation("导出登记表") public void exportRegistrationDoc(@Valid String clubId, HttpServletResponse response){ @@ -412,6 +415,8 @@ public class ClubInfoManageController { cUser.setRoleCode(List.of(RoleConstant.CLUB_MEMBER.name())); clubUserList.add(cUser); } + // 2026年10月3日 12:01:28;操作人:wxg:导入新增会员统一从用户资料批量填写人员字段及后端确认时间。 + clubUserService.prepareDirectJoinTimes(clubUserList); dao.insert(clubUserList); List sysUserRoleList = new ArrayList<>(); @@ -450,6 +455,8 @@ public class ClubInfoManageController { log.set("newValue", false); record.setOperateLogs(List.of(log)); + // 2026年10月3日 09:05:37;操作人:wxg:新增记录先填写人员快照。 + payRecordService.prepareSnapshots(List.of(record)); dao.insert(record); } else { payRecord.setPayed(false); @@ -463,6 +470,8 @@ public class ClubInfoManageController { log.set("newValue", false); payRecord.setOperateLogs(List.of(log)); + // 已有年度记录仅补缺失快照,原有年度资料和加入时间不覆盖。 + payRecordService.prepareSnapshots(List.of(payRecord)); dao.update(payRecord); } } diff --git a/src/main/java/com/budwk/app/zhgh/club/controller/infoManage/ClubPayRecordController.java b/src/main/java/com/budwk/app/zhgh/club/controller/infoManage/ClubPayRecordController.java index fd779bf..9f1b05d 100644 --- a/src/main/java/com/budwk/app/zhgh/club/controller/infoManage/ClubPayRecordController.java +++ b/src/main/java/com/budwk/app/zhgh/club/controller/infoManage/ClubPayRecordController.java @@ -1,42 +1,22 @@ package com.budwk.app.zhgh.club.controller.infoManage; import cn.dev33.satoken.annotation.SaCheckPermission; -import cn.dev33.satoken.annotation.SaMode; -import cn.hutool.core.date.DateUtil; -import cn.hutool.core.util.StrUtil; -import cn.hutool.json.JSONObject; import com.budwk.app.base.annotation.SLog; -import com.budwk.app.base.constant.RoleConstant; -import com.budwk.app.base.page.Pagination; import com.budwk.app.base.param.PageForm; import com.budwk.app.base.result.Result; -import com.budwk.app.web.commons.auth.utils.AuthUtil; -import com.budwk.app.web.commons.auth.utils.SecurityUtil; -import com.budwk.app.zhgh.club.model.ClubPayRecord; -import com.budwk.app.zhgh.club.model.ClubUserApply; -import com.budwk.app.zhgh.club.model.SysClub; import com.budwk.app.zhgh.club.service.ClubPayRecordService; -import com.budwk.app.zhgh.club.service.SysClubService; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import lombok.extern.slf4j.Slf4j; -import org.nutz.dao.Cnd; -import org.nutz.dao.Dao; -import org.nutz.dao.Sqls; -import org.nutz.dao.sql.Sql; -import org.nutz.dao.util.cri.SqlExpressionGroup; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; -import org.nutz.json.Json; -import org.nutz.lang.util.NutMap; import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Param; import javax.validation.Valid; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; /** * @ClassName ClubPayRecordController @@ -55,8 +35,6 @@ public class ClubPayRecordController { @Inject private ClubPayRecordService payRecordService; - @Inject - private SysClubService sysClubService; @At("") @SaCheckPermission("club.infoManage.pay") @@ -66,135 +44,40 @@ public class ClubPayRecordController { @At @SaCheckPermission("club.infoManage.pay") + /** + * 2026年10月3日 14:33:18;操作人:wxg:接收查询条件,业务筛选交给 service。 + * pageForm 为分页和姓名/工号关键字;year 为年度、clubId 为社团ID; + * payed/assign 为缴费/拨付状态(1是、0否、空值不限);memberState 为当前会员状态(1在会、0退会、空值全部)。 + * 返回 Result,data 为分页对象,包含 list、totalCount 等;list.count 为当前会员关系是否存在(1在会、0退会)。 + */ public Result pageData(@Valid PageForm pageForm, @Param("year") Integer year, @Param("clubId") String clubId, @Param("payed") Integer payed, - @Param("assign") Integer assign) { - Sql sql = Sqls.create(""" - SELECT - cpr.*, - c.clubName, - u.loginName, - u.userName, - u.unitName, - u.retireDate, - (SELECT createdAt FROM club_user WHERE clubId = cpr.clubId AND userId = cpr.userId ORDER BY createdAt LIMIT 1) AS applyTime, - u.userState, - (select count(1) from club_user where clubId = cpr.clubId and userId = cpr.userId) as count - FROM - `club_pay_record` cpr - LEFT JOIN sys_club c ON cpr.clubid = c.id - LEFT JOIN vw_user u ON u.id = cpr.userId - $condition - """); - Cnd cnd = Cnd.NEW(); - List clubIdList = sysClubService.getMyManageClub().stream() - .map(SysClub::getId) - .toList(); - cnd.andEX("cpr.year", "=", year); - if (!AuthUtil.hasRole(RoleConstant.SYSADMIN.name())) { - cnd.andEX("cpr.clubId", "in", clubIdList); - }else{ - cnd.andEX("cpr.clubId", "=", clubId); - } - cnd.andEX("cpr.payed", "=", payed); - if (StrUtil.isNotBlank(pageForm.getSearchKeyword())) { - SqlExpressionGroup seg = new SqlExpressionGroup(); - seg.orLike("u.userName", pageForm.getSearchKeyword()); - seg.orLike("u.loginName", pageForm.getSearchKeyword()); - cnd.and(seg); - } + @Param("assign") Integer assign, + @Param("memberState") Integer memberState) { + if (memberState != null && memberState != 0 && memberState != 1) { + return Result.error("会员状态只能为在会、退会或全部"); + } + return Result.success(payRecordService.pageData(pageForm, year, clubId, payed, assign, memberState)); + } - if (assign != null) { - SqlExpressionGroup mainGroup = new SqlExpressionGroup(); - if (assign == 0) { - SqlExpressionGroup noAssignGroup = new SqlExpressionGroup(); - - // 不拨付条件1:payed=false(原代码第一行直接return false) - noAssignGroup.or("cpr.payed", "=", false); - - // 不拨付条件2:payed=true,但用户状态是退休 且 无退休日期 - SqlExpressionGroup retireNoDateGroup = new SqlExpressionGroup(); - retireNoDateGroup.and("cpr.payed", "=", true); - retireNoDateGroup.and("u.userState", "=", "退休"); - retireNoDateGroup.and("u.retireDate", "is", null); - - // 不拨付条件3:payed=true,用户状态是退休,有退休日期,但退休年份 < 缴费年份 - SqlExpressionGroup retireYearLessGroup = new SqlExpressionGroup(); - retireYearLessGroup.and("cpr.payed", "=", true); - retireYearLessGroup.and("u.userState", "=", "退休"); - retireYearLessGroup.and("u.retireDate", "is not", null); - // 计算缴费年份 - // 退休年份 < 缴费年份 - retireYearLessGroup.and("YEAR(u.retireDate)", "<", "cpr.year"); - - // 合并所有不拨付条件(满足任一即不拨付) - noAssignGroup.or(retireNoDateGroup); - noAssignGroup.or(retireYearLessGroup); - - mainGroup.and(noAssignGroup); - } else { - SqlExpressionGroup assignGroup = new SqlExpressionGroup(); - - // 拨付前提:payed必须为true - assignGroup.and("cpr.payed", "=", true); - - // 拨付条件1:用户状态≠退休 - SqlExpressionGroup userNotRetireGroup = new SqlExpressionGroup(); - userNotRetireGroup.or("u.userState", "<>", "退休"); - - // 拨付条件2:用户状态=退休,有退休日期,且退休年份≥缴费年份 - SqlExpressionGroup retireQualifiedGroup = new SqlExpressionGroup(); - retireQualifiedGroup.and("u.userState", "=", "退休"); - retireQualifiedGroup.and("u.retireDate", "is not", null); - retireQualifiedGroup.and("YEAR(u.retireDate)", ">=", "cpr.year"); - - // 合并拨付条件(满足任一即拨付) - userNotRetireGroup.or(retireQualifiedGroup); - assignGroup.and(userNotRetireGroup); - - mainGroup.and(assignGroup); - } - cnd.and(mainGroup); - } - - sql.setCondition(cnd); - Pagination pagination = payRecordService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql); - - List list = pagination.getList(); - for (NutMap nutMap : list) { - boolean assigned = payRecordService.calAssigned(nutMap.getString("id")); - nutMap.put("assign", assigned); - } - - return Result.success(pagination); + /** 返回当前用户授权社团的下拉选项,字段包含 id、clubName、clubCode。 */ + @At + @SaCheckPermission("club.infoManage.pay") + public Result listClubs() { + return Result.success(payRecordService.getPayManageClubList()); } @At @ApiOperation("修改社团成员缴费状态") @SaCheckPermission("club.infoManage.pay") @SLog(tag = "社团管理系统-缴费记录", msg = "修改社团成员缴费状态") + @Aop(TransAop.READ_COMMITTED) public Result handle(@Param("ids") String[] ids, @Param("payed") Boolean payed) { - List list = payRecordService.query(Cnd.where("id", "in", ids)); - - for (ClubPayRecord payRecord : list) { - List logs = Optional.ofNullable(payRecord.getOperateLogs()).orElseGet(ArrayList::new); - JSONObject log = new JSONObject(); - log.set("operatorUserId", SecurityUtil.getUserId()); - log.set("operatorUserName", SecurityUtil.getUserUsername()); - log.set("operatorTime", DateUtil.now()); - log.set("operatorType", "缴费管理-手动设置"); - log.set("oldValue", payRecord.getPayed()); - log.set("newValue", payed); - logs.add(log); - - payRecord.setOperateLogs(logs); - payRecord.setPayed(payed); - } - - payRecordService.update(list); + // 2026年10月3日 09:51:00;操作人:wxg:controller 只接收参数,缴费、拨付结果和日志由 service 在同一事务内保存。 + payRecordService.updatePayStatus(ids, payed); return Result.success(); } } diff --git a/src/main/java/com/budwk/app/zhgh/club/interceptor/ClubUserExitInterceptor.java b/src/main/java/com/budwk/app/zhgh/club/interceptor/ClubUserExitInterceptor.java index 915bb35..d9c8fac 100644 --- a/src/main/java/com/budwk/app/zhgh/club/interceptor/ClubUserExitInterceptor.java +++ b/src/main/java/com/budwk/app/zhgh/club/interceptor/ClubUserExitInterceptor.java @@ -13,6 +13,7 @@ import com.budwk.app.zhgh.club.model.ClubPayRecord; import com.budwk.app.zhgh.club.model.ClubUser; import com.budwk.app.zhgh.club.model.ClubUserApply; import com.budwk.app.zhgh.club.service.SysClubUserService; +import com.budwk.app.zhgh.club.service.ClubPayRecordService; import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; @@ -43,6 +44,8 @@ public class ClubUserExitInterceptor implements FlowInterceptor { // 删人 dao.clear(ClubUser.class, Cnd.where(ClubUser::getUserId, "=", clubUserApply.getUserId()).and(ClubUser::getClubId, "=", clubUserApply.getClubId())); + // 2026年10月3日 09:51:00;操作人:wxg:WF 退会完成后,同步将该社团会员的拨付字段置为 false。 + ServiceContext.find(ClubPayRecordService.class).disableExitMemberPayment(clubUserApply.getClubId(), clubUserApply.getUserId()); // 删角色 dao.clear(Sys_user_role.class, Cnd.where(Sys_user_role::getUserId, "=", clubUserApply.getUserId()).and(Sys_user_role::getClubId, "=", clubUserApply.getClubId())); } diff --git a/src/main/java/com/budwk/app/zhgh/club/interceptor/ClubUserJoinInterceptor.java b/src/main/java/com/budwk/app/zhgh/club/interceptor/ClubUserJoinInterceptor.java index 690935c..7d8d1d8 100644 --- a/src/main/java/com/budwk/app/zhgh/club/interceptor/ClubUserJoinInterceptor.java +++ b/src/main/java/com/budwk/app/zhgh/club/interceptor/ClubUserJoinInterceptor.java @@ -12,6 +12,8 @@ import com.budwk.app.zhgh.club.model.ClubPayRecord; import com.budwk.app.zhgh.club.model.ClubUser; import com.budwk.app.zhgh.club.model.ClubUserApply; import com.budwk.app.zhgh.club.service.SysClubUserService; +import com.budwk.app.zhgh.club.service.ClubUserJoinService; +import com.budwk.app.zhgh.club.service.ClubPayRecordService; import org.bouncycastle.jcajce.provider.util.SecretKeyUtil; import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.dao.Cnd; @@ -41,9 +43,20 @@ public class ClubUserJoinInterceptor implements FlowInterceptor { // 获取操作类 Dao dao = ServiceContext.find(Dao.class); + // 入会时间以本次会长同意审核任务的完成时间为准,交由 service 校验并写入申请表。 + clubUserApply.setJoinTime(ServiceContext.find(ClubUserJoinService.class).completeJoinApproval( + execution.getProcessInstance().getBusinessNo(), execution.getProcessTaskId())); // 将申请信息复制一份,插入社团成员表 ClubUser clubUser = BeanUtil.copyProperties(clubUserApply, ClubUser.class); + // 读取本次已写入的入会申请时间,不能取成立时间或用插入时刻替代审核完成时间。 + ClubUserApply approvedApply = dao.fetch(ClubUserApply.class, execution.getProcessInstance().getBusinessNo()); + if (approvedApply == null || approvedApply.getJoinTime() == null) { + throw new IllegalArgumentException("本次入会申请尚未生成审核完成时间"); + } + clubUser.setJoinTime(approvedApply.getJoinTime()); + // 2026年10月3日 12:01:28;操作人:wxg:审核入会保存当前用户资料,joinTime 仍使用本次会长审核完成时间。 + ServiceContext.find(SysClubUserService.class).prepareMemberSnapshots(List.of(clubUser)); dao.insert(clubUser); // 还要加到社团成员缴费表中 @@ -70,6 +83,8 @@ public class ClubUserJoinInterceptor implements FlowInterceptor { log.set("newValue", payed); record.setOperateLogs(List.of(log)); + // 2026年10月3日 09:05:37;操作人:wxg:审核新增缴费记录,保存本次会员加入时间及人员快照。 + ServiceContext.find(ClubPayRecordService.class).prepareSnapshots(List.of(record)); dao.insert(record); } else { payRecord.setPayed(payed); @@ -83,6 +98,8 @@ public class ClubUserJoinInterceptor implements FlowInterceptor { log.set("newValue", payed); payRecord.setOperateLogs(List.of(log)); + // 同年度重新入会复用原缴费记录,只补缺失资料,不重写已保存的年度快照。 + ServiceContext.find(ClubPayRecordService.class).prepareSnapshots(List.of(payRecord)); dao.update(payRecord); } diff --git a/src/main/java/com/budwk/app/zhgh/club/model/ClubPayRecord.java b/src/main/java/com/budwk/app/zhgh/club/model/ClubPayRecord.java index 1c03ec1..ce9e83a 100644 --- a/src/main/java/com/budwk/app/zhgh/club/model/ClubPayRecord.java +++ b/src/main/java/com/budwk/app/zhgh/club/model/ClubPayRecord.java @@ -8,6 +8,7 @@ import org.nutz.dao.entity.annotation.*; import org.nutz.dao.interceptor.annotation.PrevInsert; import java.util.List; +import java.util.Date; /** * @ClassName ClubPayRecord @@ -56,6 +57,38 @@ public class ClubPayRecord extends BaseModel { @Default(value = "0") private Boolean isGiveMoney; + + // 2026年10月3日 09:05:37;操作人:wxg:保存年度名单人员快照,列表不再联查用户视图。 + @Column + @Comment("工号") + @ColDefine(type = ColType.VARCHAR, width = 120) + private String loginName; + + @Column + @Comment("姓名") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String userName; + + @Column + @Comment("所在单位") + @ColDefine(type = ColType.VARCHAR, width = 500) + private String unitName; + + @Column + @Comment("人员类型") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String personType; + + @Column + @Comment("加入时间") + @ColDefine(type = ColType.DATETIME) + private Date joinTime; + + @Column + @Comment("退休时间") + @ColDefine(type = ColType.DATETIME) + private Date retireDate; + // 防止有人今天操作缴费,明天操作未缴费,还是记录一下吧 @Column @Comment("操作记录") diff --git a/src/main/java/com/budwk/app/zhgh/club/model/ClubUser.java b/src/main/java/com/budwk/app/zhgh/club/model/ClubUser.java index 266c9d3..19e1a67 100644 --- a/src/main/java/com/budwk/app/zhgh/club/model/ClubUser.java +++ b/src/main/java/com/budwk/app/zhgh/club/model/ClubUser.java @@ -7,6 +7,7 @@ import org.nutz.dao.entity.annotation.*; import org.nutz.dao.interceptor.annotation.PrevInsert; import java.util.ArrayList; +import java.util.Date; import java.util.List; @Data @@ -62,5 +63,25 @@ public class ClubUser extends BaseModel { @ColDefine(type = ColType.VARCHAR, width = 500) private String awardsExperience; + /** 审核入会取本次申请的 joinTime;直接录入或导入取后端确认时间。 */ + @Column + @Comment("入会时间") + @ColDefine(type = ColType.DATETIME) + private Date joinTime; + + /** 2026年10月3日 12:01:28;操作人:wxg:会员资料在加入时保存,供会员列表直接展示和姓名/工号查询。 */ + @Column + @Comment("姓名") + @ColDefine(type = ColType.VARCHAR, width = 100) private String userName; + + @Column + @Comment("工号") + @ColDefine(type = ColType.VARCHAR, width = 120) + private String loginName; + + @Column + @Comment("电话") + @ColDefine(type = ColType.VARCHAR, width = 50) + private String mobile; } diff --git a/src/main/java/com/budwk/app/zhgh/club/model/ClubUserApply.java b/src/main/java/com/budwk/app/zhgh/club/model/ClubUserApply.java index ff3a21d..d4521de 100644 --- a/src/main/java/com/budwk/app/zhgh/club/model/ClubUserApply.java +++ b/src/main/java/com/budwk/app/zhgh/club/model/ClubUserApply.java @@ -37,9 +37,9 @@ public class ClubUserApply extends BaseModel { private String roleCode; @Column - @Comment("加入/退出") - @ColDefine(type = ColType.BOOLEAN) - private Boolean mode; + @Comment("申请模式:0退出、1加入、2系统处理退会") + @ColDefine(type = ColType.INT) + private Integer mode; @Column @Comment("社团职务") diff --git a/src/main/java/com/budwk/app/zhgh/club/param/ClubUserPageForm.java b/src/main/java/com/budwk/app/zhgh/club/param/ClubUserPageForm.java index 236b617..0223c94 100644 --- a/src/main/java/com/budwk/app/zhgh/club/param/ClubUserPageForm.java +++ b/src/main/java/com/budwk/app/zhgh/club/param/ClubUserPageForm.java @@ -18,7 +18,17 @@ public class ClubUserPageForm extends PageForm { private String clubId; private String clubName; private String personType; + /** 重点关注名单:true 同时筛选自聘人员、其他;false 或未传时不增加该限制。 */ + private Boolean focusMembers; private String userState; + /** 会员入会审核开始日期,前端传 yyyy-MM-dd;为空时不限制开始时间。 */ + private String joinDateStart; + /** 会员入会审核结束日期,前端传 yyyy-MM-dd,包含当天;为空时不限制结束时间。 */ + private String joinDateEnd; + /** 2026年10月3日 19:42:45;操作人:wxg:退会日期范围起点,传 yyyy-MM-dd,空值不限制。 */ + private String exitDateStart; + /** 2026年10月3日 19:42:45;操作人:wxg:退会日期范围终点,传 yyyy-MM-dd,包含当天,空值不限制。 */ + private String exitDateEnd; private String unitId; private Integer applyType; private Integer auditType; diff --git a/src/main/java/com/budwk/app/zhgh/club/service/ClubPayRecordService.java b/src/main/java/com/budwk/app/zhgh/club/service/ClubPayRecordService.java index 8b4375e..001ab83 100644 --- a/src/main/java/com/budwk/app/zhgh/club/service/ClubPayRecordService.java +++ b/src/main/java/com/budwk/app/zhgh/club/service/ClubPayRecordService.java @@ -2,6 +2,10 @@ package com.budwk.app.zhgh.club.service; import com.budwk.app.base.service.BaseService; import com.budwk.app.zhgh.club.model.ClubPayRecord; +import com.budwk.app.base.page.Pagination; +import com.budwk.app.base.param.PageForm; +import org.nutz.lang.util.NutMap; +import java.util.List; /** * @ClassName ClubPayRecordService @@ -12,6 +16,55 @@ import com.budwk.app.zhgh.club.model.ClubPayRecord; */ public interface ClubPayRecordService extends BaseService { + /** 2026年10月3日 09:05:37;操作人:wxg:定向检查并新增缴费表缺失列;不删除字段,不生成独立升级 SQL 文件。 */ + void upgradePayRecordSchema(); + + /** 2026年10月3日 09:05:37;操作人:wxg:启动时补齐旧记录缺失快照,不覆盖已保存资料;返回 void。 */ + void backfillPayRecordSnapshots(); + + /** + * 2026年10月3日 09:05:37;操作人:wxg:批量填写新增缴费记录的人员快照。 + * @param records 已设置 year、clubId、userId 的缴费记录;现有人员快照不覆盖,空加入时间取有效会员 joinTime;根据当前会员关系及缴费状态计算拨付结果 + * @return void;填写工号、姓名、单位、人员类型、退休日期、加入时间和 isGiveMoney,由调用方在事务中保存 + */ + void prepareSnapshots(List records); + + /** 2026年10月3日 09:51:00;操作人:wxg:设置缴费状态并保存拨付结果及日志。 + * @param ids 非空缴费记录ID数组;只允许操作当前用户授权社团的记录 + * @param payed true已缴费、false未缴费,不能为空 + * @return void;参数错误或记录不在授权范围时抛出异常,事务回滚 + */ + void updatePayStatus(String[] ids, Boolean payed); + + /** 2026年10月3日 09:51:00;操作人:wxg:退会后将该用户在指定社团的各年度缴费记录置为不拨付。 + * @param clubId 退会社团ID,不能为空 + * @param userId 退会用户ID,不能为空 + * @return void;保留缴费状态和人员快照,仅更新 isGiveMoney + */ + void disableExitMemberPayment(String clubId, String userId); + + /** + * 2026年10月3日 09:05:37;操作人:wxg:按当前会员生成指定年度名单,已存在的会员记录不重复生成、不重置缴费。 + * @param year 要生成名单的公历年度,年度计划任务传当前年份 + * @return 本次实际新增的缴费记录条数 + */ + int generateYearPayRecords(Integer year); + + /** 返回当前用户可查询的社团选项,每项包含 id、clubName、clubCode;无授权社团时返回空列表。 */ + List getPayManageClubList(); + + /** + * 按权限范围分页查询缴费记录,并按社团编号及缴费记录保存的加入时间排序。 + * @param pageForm 页码、每页条数及姓名/工号关键字 + * @param year 缴费年度,空值表示不限制 + * @param clubId 社团ID,空值表示查询全部授权社团 + * @param payed 缴费状态:1已缴费、0未缴费、空值不限 + * @param assign 保存的拨付状态:1拨付、0不拨付、空值不限 + * 2026年10月3日 14:33:18;操作人:wxg:在分页前按实时会员关系筛选,历史年度也使用当前会员状态。 + * @param memberState 1在会、0退会、null不限;在会以同一 clubId/userId 存在 club_user 记录为准 + * @return 分页记录,list 包含缴费字段、社团及人员信息、applyTime(保存的入会时间)与 assign(isGiveMoney 保存值转换为布尔值) + */ + Pagination pageData(PageForm pageForm, Integer year, String clubId, Integer payed, Integer assign, Integer memberState); /** * 获取社团成员是否拨付 diff --git a/src/main/java/com/budwk/app/zhgh/club/service/ClubUserJoinService.java b/src/main/java/com/budwk/app/zhgh/club/service/ClubUserJoinService.java index bf195fa..73e7347 100644 --- a/src/main/java/com/budwk/app/zhgh/club/service/ClubUserJoinService.java +++ b/src/main/java/com/budwk/app/zhgh/club/service/ClubUserJoinService.java @@ -5,6 +5,20 @@ import com.budwk.app.zhgh.club.model.ClubUserApply; public interface ClubUserJoinService extends BaseService { + /** + * 清除待审核入会申请的入退会时间,防止沿用协会成立时间或上次入会时间。 + * @param application 待保存、提交或重新提交的申请;仅处理 mode=1 的入会申请 + */ + void preparePendingJoinTimes(ClubUserApply application); + + /** + * 会长同意审核后,将本次 WF 任务的实际完成时间写入申请入会时间。 + * @param applicationId 当前流程 businessNo 对应的入会申请ID + * @param taskId 当前会长审核任务ID,必须属于该申请且已完成、同意 + * @return 写入 club_user_apply.joinTime 的审核完成时间;无有效审核任务时抛出异常 + */ + java.util.Date completeJoinApproval(String applicationId, Long taskId); + /** * 构造退会申请数据。 * 当用户没有历史入会申请记录时,使用当前社团成员关系和人员基础信息兜底生成一份退会申请, @@ -17,8 +31,8 @@ public interface ClubUserJoinService extends BaseService { ClubUserApply buildExitApply(String clubId, String userId); /** - * 获取社团成员的入会时间,优先取历史有效入会申请记录, - * 没有时兜底为社团创办时间。 + * 获取社团成员的入会时间,优先取当前 club_user.joinTime。 + * 旧会员关系尚无入会时间时,保留历史申请及社团创办时间的原有回退规则。 * * @param clubId 社团ID * @param userId 用户ID diff --git a/src/main/java/com/budwk/app/zhgh/club/service/SysClubExamineService.java b/src/main/java/com/budwk/app/zhgh/club/service/SysClubExamineService.java index 1a96c47..3bc943a 100644 --- a/src/main/java/com/budwk/app/zhgh/club/service/SysClubExamineService.java +++ b/src/main/java/com/budwk/app/zhgh/club/service/SysClubExamineService.java @@ -13,12 +13,24 @@ import java.util.List; public interface SysClubExamineService extends BaseService { + // 2026年10月2日wxg屏蔽操作:保留原统计接口说明。 +// /** +// * 按会员入会、退休、退会时间统计指定年度的在职和退休人数变化。 +// * +// * @param clubId 社团ID +// * @param year 统计年度 +// * @return 在职和退休人员的年初、增加、减少、年末人数 +// */ +// List getClubUserNum(@Valid String clubId, Integer year); +// +// /** - * 按会员入会、退休、退会时间统计指定年度的在职和退休人数变化。 - * - * @param clubId 社团ID - * @param year 统计年度 - * @return 在职和退休人员的年初、增加、减少、年末人数 + * 2026年10月2日22:00点开始新开发的代码;实际整改:2026年10月3日 10:14:04;操作人:wxg。 + * @param clubId 所选社团ID,不能为空 + * @param year 所选公历年度,1至9998;按该年1月1日至下一年1月1日左闭右开统计 + * @return 两类人员计数列表:yearFirstNum年初、yearAddNum增加、yearReduceNum减少、thisYearNum公式年末; + * personType为类别,userState仅兼容已有查看页标签;statisticsWarnings为核查提示, + * statisticsValid=false时各人数为null,表示数据缺失或冲突,不能当作零人 */ List getClubUserNum(@Valid String clubId, Integer year); diff --git a/src/main/java/com/budwk/app/zhgh/club/service/SysClubInfoManageService.java b/src/main/java/com/budwk/app/zhgh/club/service/SysClubInfoManageService.java index 74ff1ba..fd42b40 100644 --- a/src/main/java/com/budwk/app/zhgh/club/service/SysClubInfoManageService.java +++ b/src/main/java/com/budwk/app/zhgh/club/service/SysClubInfoManageService.java @@ -22,6 +22,13 @@ public interface SysClubInfoManageService extends BaseService Pagination infoManagePageData(@Valid ClubUserPageForm pageForm); + /** + * 修改时间:2026年10月2日 23:06:55;操作人:wxg。 + * 当前会员查询前,用已完成 WF 入会流程的最近一次会长同意审核时间补齐空入会时间。 + * @param pageForm clubId 为选中社团ID;radioType=4 查历史成员且不补齐,其他值查当前会员; + * 姓名/工号、人员类型、在职状态、入会日期范围及分页排序参数用于列表筛选 + * @return 会员分页对象,list 中 joinTime 为会员关系保存的入会时间;历史成员仍取申请表时间 + */ Pagination infoManageUserPageData(@Valid ClubUserPageForm pageForm); Pagination exitManagePageData(@Valid ClubUserPageForm pageForm); @@ -30,6 +37,13 @@ public interface SysClubInfoManageService extends BaseService ClubUserJoinVo exitManageInfo(String id); + /** + * 管理端直接退会,同时新增系统处理记录并清理当前社团会员关系及角色关联。 + * @param membershipId club_user 的会员关系ID,不是用户ID或申请ID + * @return void;会员关系不存在或已退会时抛出异常,事务回滚 + */ + void exitClub(String membershipId); + List getClubTreeData(); /** diff --git a/src/main/java/com/budwk/app/zhgh/club/service/SysClubService.java b/src/main/java/com/budwk/app/zhgh/club/service/SysClubService.java index 68ca80f..6cc44c4 100644 --- a/src/main/java/com/budwk/app/zhgh/club/service/SysClubService.java +++ b/src/main/java/com/budwk/app/zhgh/club/service/SysClubService.java @@ -23,6 +23,12 @@ import java.util.List; **/ public interface SysClubService extends BaseService { + /** + * 获取入会申请的社团下拉选项,供 PC 与 H5 共用。 + * @return 存在已完成 WF 流程的社团列表,每项为 SysClub,包含 id、clubName 等社团字段;同一社团只返回一次 + */ + List listJoinClubOptions(); + Pagination getAllClubWithPage(@Valid PageForm pageForm, Cnd cnd); ClubRegisterVo findOne(String clubId); diff --git a/src/main/java/com/budwk/app/zhgh/club/service/SysClubUserService.java b/src/main/java/com/budwk/app/zhgh/club/service/SysClubUserService.java index 00b57ca..dadeecf 100644 --- a/src/main/java/com/budwk/app/zhgh/club/service/SysClubUserService.java +++ b/src/main/java/com/budwk/app/zhgh/club/service/SysClubUserService.java @@ -17,6 +17,32 @@ import java.util.List; **/ public interface SysClubUserService extends BaseService { + /** + * 为直接新增的会员关系设置本批次的后端确认时间,不覆盖已有入会时间。 + * @param members 手工录入、导入或社团创建时直接新增的会员关系;不得传入审核入会记录 + * @return void;仅填写列表内 joinTime 为空的对象,由调用方在同一事务中保存 + */ + void prepareDirectJoinTimes(List members); + + /** + * 2026年10月3日 12:01:28;操作人:wxg:启动时定向补充会员表缺失列,不删除列或调整索引。 + * @return void;DDL 失败则抛出异常,阻止缺少字段的应用继续启动 + */ + void upgradeMemberSchema(); + + /** + * 启动升级时补齐缺失的姓名、工号、电话及 WF 会长审核入会时间;不覆盖非空历史数据。 + * @return void;没有用户资料或有效 WF 审核记录的字段保留为空 + */ + void backfillMemberSnapshots(); + + /** + * 为真正新增的会员关系批量填写用户当前姓名、工号、电话,不修改入会时间。 + * @param members 待新增的会员关系,必须包含有效 userId;不得传入仅修改身份的既有关系 + * @return void;填写传入对象的 userName/loginName/mobile,由调用方在同一事务中保存 + */ + void prepareMemberSnapshots(List members); + List getClubUser(@Valid String clubId); Pagination pageDataByApplyJoinClubAudit(@Valid ClubUserPageForm pageForm); diff --git a/src/main/java/com/budwk/app/zhgh/club/service/impl/ClubPayRecordServiceImpl.java b/src/main/java/com/budwk/app/zhgh/club/service/impl/ClubPayRecordServiceImpl.java index 67fa976..0ae6649 100644 --- a/src/main/java/com/budwk/app/zhgh/club/service/impl/ClubPayRecordServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/club/service/impl/ClubPayRecordServiceImpl.java @@ -3,13 +3,35 @@ package com.budwk.app.zhgh.club.service.impl; import cn.hutool.core.date.DateUtil; import cn.hutool.core.util.StrUtil; import com.budwk.app.base.service.impl.BaseServiceImpl; -import com.budwk.app.sys.models.Sys_user; +import com.budwk.app.base.constant.RoleConstant; +import com.budwk.app.base.page.Pagination; +import com.budwk.app.base.param.PageForm; +import com.budwk.app.web.commons.auth.utils.AuthUtil; +import com.budwk.app.web.commons.auth.utils.SecurityUtil; +import org.nutz.dao.Sqls; +import org.nutz.dao.sql.Sql; +import org.nutz.dao.util.cri.SqlExpressionGroup; +import org.nutz.dao.util.cri.Static; +import org.nutz.lang.Strings; +import org.nutz.lang.util.NutMap; +import java.util.List; +import com.budwk.app.sys.views.View_user; +import com.budwk.app.zhgh.club.model.ClubUser; +import cn.hutool.json.JSONObject; +import org.nutz.dao.util.Daos; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.Date; import com.budwk.app.zhgh.club.model.ClubPayRecord; import com.budwk.app.zhgh.club.service.ClubPayRecordService; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; import org.nutz.ioc.loader.annotation.IocBean; -import org.nutz.lang.Lang; import java.util.Objects; @@ -27,6 +49,301 @@ public class ClubPayRecordServiceImpl extends BaseServiceImpl imp super(dao); } + + // 2026年10月3日 09:51:00;操作人:wxg:列表只读取已保存结果;缴费设置和退会操作负责更新 isGiveMoney。 + private static final String ASSIGNED_SQL = "CASE WHEN cpr.isGiveMoney = true THEN 1 ELSE 0 END"; + + @Override + @Aop(TransAop.READ_COMMITTED) + public void upgradePayRecordSchema() { + // 定向检查独立于 DEBUG;DDL 在 MySQL 中会隐式提交,失败时终止升级以免运行缺列查询。 + if (!dao().exists("club_pay_record")) { + dao().create(ClubPayRecord.class, false); + } else { + Daos.migration(dao(), ClubPayRecord.class, true, false, false); + } + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public void backfillPayRecordSnapshots() { + // 已有工号或姓名表示快照已建立;退休日期为空可能是真实资料,不能每次启动重新覆盖快照。 + dao().execute(Sqls.create(""" + UPDATE club_pay_record cpr + INNER JOIN vw_user u ON u.id = cpr.userId + SET cpr.loginName = COALESCE(cpr.loginName, u.loginName), + cpr.userName = COALESCE(cpr.userName, u.userName), + cpr.unitName = COALESCE(cpr.unitName, u.unitName), + cpr.personType = COALESCE(cpr.personType, u.personType), + cpr.retireDate = COALESCE(cpr.retireDate, u.retireDate) + WHERE cpr.loginName IS NULL AND cpr.userName IS NULL + """)); + // 按年度批量补时间,不能将本年度重新入会时间写入之前年度;无可靠来源保持为空。 + for (NutMap row : listMap(Sqls.create("SELECT DISTINCT year FROM club_pay_record WHERE joinTime IS NULL AND year IS NOT NULL"))) { + int year = row.getInt("year"); + Sql sql = Sqls.create(""" + UPDATE club_pay_record cpr + LEFT JOIN ( + SELECT clubId, userId, MIN(joinTime) AS joinTime FROM club_user + WHERE joinTime < @yearEnd GROUP BY clubId, userId + ) memberTime ON memberTime.clubId = cpr.clubId AND memberTime.userId = cpr.userId + LEFT JOIN ( + SELECT application.clubId, application.userId, MAX(task.finishTime) AS joinTime + FROM club_user_apply application + INNER JOIN wf_process_instance instance ON instance.businessNo = application.id + INNER JOIN wf_process_task task ON task.processInstanceId = instance.id + WHERE application.mode = 1 AND instance.state = 20 AND task.taskState = 20 + AND task.taskName = '1625a683-3890-4788-95b7-cab8240f6731' + AND JSON_UNQUOTE(JSON_EXTRACT(task.variable, '$.submitType')) = '1' + AND task.finishTime < @yearEnd + GROUP BY application.clubId, application.userId + ) approvalTime ON approvalTime.clubId = cpr.clubId AND approvalTime.userId = cpr.userId + SET cpr.joinTime = COALESCE(memberTime.joinTime, approvalTime.joinTime) + WHERE cpr.year = @year AND cpr.joinTime IS NULL + AND COALESCE(memberTime.joinTime, approvalTime.joinTime) IS NOT NULL + """); + sql.setParam("year", year); + sql.setParam("yearEnd", (year + 1) + "-01-01"); + dao().execute(sql); + } + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public void prepareSnapshots(List records) { + if (records == null || records.isEmpty()) { + return; + } + // 新增记录须明确所属年度、社团和人员,避免缺失参数形成不受限制的批量查询。 + for (ClubPayRecord record : records) { + if (record == null || record.getYear() == null || record.getYear() < 1 || record.getYear() > 9998 + || StrUtil.isBlank(record.getClubId()) || StrUtil.isBlank(record.getUserId())) { + throw new IllegalArgumentException("缴费记录必须提供有效年度、社团和用户"); + } + } + List userIds = records.stream().map(ClubPayRecord::getUserId).distinct().toList(); + List clubIds = records.stream().map(ClubPayRecord::getClubId).distinct().toList(); + Map users = new HashMap<>(); + for (View_user user : dao().query(View_user.class, Cnd.where("id", "in", userIds))) { + users.put(user.getId(), user); + } + Map joinTimes = new HashMap<>(); + Set activeMembers = new HashSet<>(); + for (ClubUser member : dao().query(ClubUser.class, Cnd.where("userId", "in", userIds).and("clubId", "in", clubIds))) { + activeMembers.add(member.getClubId() + ":" + member.getUserId()); + if (member.getJoinTime() != null) { + joinTimes.merge(member.getClubId() + ":" + member.getUserId(), member.getJoinTime(), + (first, next) -> first.before(next) ? first : next); + } + } + for (ClubPayRecord record : records) { + View_user user = users.get(record.getUserId()); + if (user != null && record.getLoginName() == null && record.getUserName() == null) { + record.setLoginName(user.getLoginname()); + record.setUserName(user.getUsername()); + if (record.getUnitName() == null) record.setUnitName(user.getUnitName()); + if (record.getPersonType() == null) record.setPersonType(user.getPersonType()); + if (record.getRetireDate() == null) record.setRetireDate(user.getRetireDate()); + } + Date joinTime = joinTimes.get(record.getClubId() + ":" + record.getUserId()); + if (record.getJoinTime() == null && joinTime != null + && (record.getYear() == null || joinTime.before(DateUtil.parse((record.getYear() + 1) + "-01-01")))) { + record.setJoinTime(joinTime); + } + // 2026年10月3日 09:51:00;操作人:wxg:新增名单或入会更新缴费时,同步保存资格;退会成员优先判定不拨付。 + record.setIsGiveMoney(qualifiesForPayment(record, activeMembers.contains(record.getClubId() + ":" + record.getUserId()))); + } + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public int generateYearPayRecords(Integer year) { + if (year == null || year < 1 || year > 9998) { + throw new IllegalArgumentException("缴费年度不正确"); + } + Set recorded = new HashSet<>(); + for (ClubPayRecord record : dao().query(ClubPayRecord.class, Cnd.where("year", "=", year))) { + recorded.add(record.getClubId() + ":" + record.getUserId()); + } + List records = new ArrayList<>(); + for (ClubUser member : dao().query(ClubUser.class, Cnd.NEW())) { + if (!recorded.add(member.getClubId() + ":" + member.getUserId())) { + continue; + } + ClubPayRecord record = new ClubPayRecord(); + record.setYear(year); + record.setClubId(member.getClubId()); + record.setUserId(member.getUserId()); + // 加入时间与人员资料交由 prepareSnapshots 批量读取,并校验所属年度。 + record.setPayed(false); + JSONObject log = new JSONObject(); + log.set("operatorUserId", "task"); + log.set("operatorUserName", "定时任务"); + log.set("operatorTime", DateUtil.now()); + log.set("operatorType", "缴费管理-定时任务自动设置"); + log.set("oldValue", null); + log.set("newValue", false); + record.setOperateLogs(List.of(log)); + records.add(record); + } + prepareSnapshots(records); + if (!records.isEmpty()) { + dao().insert(records); + } + return records.size(); + } + + /** 校工会社团管理员与系统管理员可查看全部社团缴费记录。 */ + private boolean canManageAllPayClubs() { + return AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_CLUB_ADMIN.name()); + } + + /** 普通菜单授权用户仅能查看其有效角色关联的社团;无关联时显式返回空范围。 */ + private void addPayClubScope(Cnd cnd, String clubField) { + if (!canManageAllPayClubs()) { + // 用户ID使用 SQL 参数绑定,社团集合交给 Cnd 处理,避免空集合被可选条件忽略。 + Sql scopeSql = Sqls.create(""" + SELECT DISTINCT ur.clubId FROM sys_user_role ur + INNER JOIN sys_role r ON r.id = ur.roleId AND r.disabled = false + WHERE ur.userId = @userId AND ur.enable = true AND ur.clubId IS NOT NULL + """).setParam("userId", SecurityUtil.getUserId()); + List clubIds = listMap(scopeSql).stream() + .map(row -> row.getString("clubId")) + .filter(StrUtil::isNotBlank) + .toList(); + if (clubIds.isEmpty()) { + cnd.and(new Static("1 = 0")); + } else { + cnd.and(clubField, "in", clubIds); + } + } + } + + @Override + public List getPayManageClubList() { + Sql sql = Sqls.create("SELECT c.id, c.clubName, c.clubCode FROM sys_club c $condition"); + Cnd cnd = Cnd.NEW(); + addPayClubScope(cnd, "c.id"); + cnd.asc("c.clubCode"); + cnd.asc("c.id"); + sql.setCondition(cnd); + return listMap(sql); + } + + @Override + public Pagination pageData(PageForm pageForm, Integer year, String clubId, Integer payed, Integer assign, Integer memberState) { + // 2026年10月3日 14:33:18;操作人:wxg:当前会员状态为可选参数,service 拒绝非法值,避免错误条件被当作全部。 + if (memberState != null && memberState != 0 && memberState != 1) { + throw new IllegalArgumentException("会员状态只能为在会、退会或全部"); + } + // 2026年10月3日 09:05:37;操作人:wxg:人员资料直接读年度快照,取消用户视图及逐行会员子查询。 + Sql sql = Sqls.create("SELECT cpr.*, c.clubName, cpr.joinTime AS applyTime, (" + + ASSIGNED_SQL + ") AS assign FROM club_pay_record cpr " + + "LEFT JOIN sys_club c ON cpr.clubId = c.id $condition"); + Cnd cnd = Cnd.NEW(); + cnd.andEX("cpr.year", "=", year); + cnd.andEX("cpr.clubId", "=", clubId); + addPayClubScope(cnd, "cpr.clubId"); + cnd.andEX("cpr.payed", "=", payed); + // 在分页前筛选,保证总条数及页数准确;EXISTS 不会因重复会员关系增加缴费记录条数。 + // 与列表 count 标签一致,只看该人员在该社团的当前关系,不依赖退会申请或缴费记录年份。 + if (memberState != null) { + String membershipExists = "EXISTS (SELECT 1 FROM club_user membership " + + "WHERE membership.clubId = cpr.clubId AND membership.userId = cpr.userId)"; + cnd.and(new Static(memberState == 1 ? membershipExists : "NOT " + membershipExists)); + } + if (StrUtil.isNotBlank(pageForm.getSearchKeyword())) { + SqlExpressionGroup seg = new SqlExpressionGroup(); + seg.orLike("cpr.userName", pageForm.getSearchKeyword()); + seg.orLike("cpr.loginName", pageForm.getSearchKeyword()); + cnd.and(seg); + } + + if (assign != null) { + cnd.and(new Static("(" + ASSIGNED_SQL + ") = " + (assign == 0 ? 0 : 1))); + } + + // 全社团管理角色先按社团编号分组;普通角色仅在授权社团内按加入时间倒序。 + if (canManageAllPayClubs()) { + cnd.asc("c.clubCode"); + cnd.asc("c.id"); + } + cnd.desc("cpr.joinTime"); + cnd.asc("cpr.id"); + sql.setCondition(cnd); + Pagination pagination = listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql); + + List list = pagination.getList(); + Set activeMembers = new HashSet<>(); + if (!list.isEmpty()) { + List userIds = list.stream().map(row -> row.getString("userId")).distinct().toList(); + List clubIds = list.stream().map(row -> row.getString("clubId")).distinct().toList(); + Sql membershipSql = Sqls.create("SELECT clubId, userId FROM club_user $condition"); + membershipSql.setCondition(Cnd.where("userId", "in", userIds).and("clubId", "in", clubIds)); + for (NutMap member : listMap(membershipSql)) { + activeMembers.add(member.getString("clubId") + ":" + member.getString("userId")); + } + } + // 2026年10月3日 09:51:00;操作人:wxg:当前状态实时查询,拨付状态使用保存值,查询期间不重算、不写库。 + for (NutMap row : list) { + row.put("count", activeMembers.contains(row.getString("clubId") + ":" + row.getString("userId")) ? 1 : 0); + row.put("assign", row.getInt("assign") == 1); + } + + return pagination; + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public void updatePayStatus(String[] ids, Boolean payed) { + // 2026年10月3日 09:51:00;操作人:wxg:明确记录ID及缴费布尔值,避免空参数造成批量误更新。 + if (ids == null || ids.length == 0 || payed == null || java.util.Arrays.stream(ids).anyMatch(StrUtil::isBlank)) { + throw new IllegalArgumentException("请选择缴费记录并提供缴费状态"); + } + Cnd scope = Cnd.where("id", "in", ids); + addPayClubScope(scope, "clubId"); + List records = dao().query(ClubPayRecord.class, scope); + if (records.size() != java.util.Arrays.stream(ids).distinct().count()) { + throw new IllegalArgumentException("部分缴费记录不存在或无权操作,请刷新列表"); + } + List userIds = records.stream().map(ClubPayRecord::getUserId).distinct().toList(); + List clubIds = records.stream().map(ClubPayRecord::getClubId).distinct().toList(); + Set activeMembers = new HashSet<>(); + for (ClubUser member : dao().query(ClubUser.class, Cnd.where("userId", "in", userIds).and("clubId", "in", clubIds))) { + activeMembers.add(member.getClubId() + ":" + member.getUserId()); + } + for (ClubPayRecord record : records) { + List logs = record.getOperateLogs() == null ? new ArrayList<>() : new ArrayList<>(record.getOperateLogs()); + JSONObject log = new JSONObject(); + log.set("operatorUserId", SecurityUtil.getUserId()); + log.set("operatorUserName", SecurityUtil.getUserUsername()); + log.set("operatorTime", DateUtil.now()); + log.set("operatorType", "缴费管理-手动设置"); + log.set("oldValue", record.getPayed()); + log.set("newValue", payed); + log.set("oldIsGiveMoney", record.getIsGiveMoney()); + record.setPayed(payed); + record.setIsGiveMoney(qualifiesForPayment(record, activeMembers.contains(record.getClubId() + ":" + record.getUserId()))); + log.set("newIsGiveMoney", record.getIsGiveMoney()); + logs.add(log); + record.setOperateLogs(logs); + } + dao().update(records); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public void disableExitMemberPayment(String clubId, String userId) { + // 2026年10月3日 09:51:00;操作人:wxg:退会影响实时会员关系对应的各年度名单,保留原缴费状态。 + if (StrUtil.isBlank(clubId) || StrUtil.isBlank(userId)) { + throw new IllegalArgumentException("退会社团和用户不能为空"); + } + Sql sql = Sqls.create("UPDATE club_pay_record SET isGiveMoney = false WHERE clubId = @clubId AND userId = @userId"); + sql.setParam("clubId", clubId); + sql.setParam("userId", userId); + dao().execute(sql); + } + @Override public boolean calAssigned(String userId, String clubId) { Cnd cnd = Cnd.NEW(); @@ -45,18 +362,23 @@ public class ClubPayRecordServiceImpl extends BaseServiceImpl imp @Override public boolean calAssigned(ClubPayRecord payRecord) { - // 如果是未缴费,直接是不拨付 - if (!payRecord.getPayed()) { - return false; - } - Sys_user user = dao().fetch(Sys_user.class, payRecord.getUserId()); - if (!Objects.equals("退休", user.getUserState())) { - return true; - } - if (Lang.isEmpty(user.getRetireDate())) { - return false; - } - int retireYear = DateUtil.year(user.getRetireDate()); - return retireYear >= (payRecord.getYear() != null ? payRecord.getYear() : DateUtil.thisYear()); - } + // 2026年10月3日 09:51:00;操作人:wxg:业务更新时计算资格,会员关系不存在即对应页面退会标签。 + boolean active = payRecord != null && dao().count(ClubUser.class, Cnd.where("clubId", "=", payRecord.getClubId()) + .and("userId", "=", payRecord.getUserId())) > 0; + return qualifiesForPayment(payRecord, active); + } + + /** 退休年份规则沿用原口径;未缴费、已退会或退休日期缺失时不拨付。 */ + private boolean qualifiesForPayment(ClubPayRecord payRecord, boolean active) { + if (!active || payRecord == null || !Boolean.TRUE.equals(payRecord.getPayed())) { + return false; + } + if (!Objects.equals("离退休人员", payRecord.getPersonType())) { + return true; + } + if (payRecord.getRetireDate() == null) { + return false; + } + return DateUtil.year(payRecord.getRetireDate()) >= (payRecord.getYear() != null ? payRecord.getYear() : DateUtil.thisYear()); + } } diff --git a/src/main/java/com/budwk/app/zhgh/club/service/impl/ClubUserJoinServiceImpl.java b/src/main/java/com/budwk/app/zhgh/club/service/impl/ClubUserJoinServiceImpl.java index befd181..ae33143 100644 --- a/src/main/java/com/budwk/app/zhgh/club/service/impl/ClubUserJoinServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/club/service/impl/ClubUserJoinServiceImpl.java @@ -4,36 +4,77 @@ import com.budwk.app.base.constant.RoleConstant; import com.budwk.app.base.service.impl.BaseServiceImpl; import cn.hutool.core.date.DateUtil; import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONUtil; +import com.budwk.app.flow.constant.FlowConst; +import com.budwk.app.flow.entity.ProcessInstance; +import com.budwk.app.flow.entity.ProcessTask; +import com.budwk.app.flow.enums.ProcessSubmitTypeEnum; +import com.budwk.app.flow.enums.ProcessTaskStateEnum; import com.budwk.app.sys.views.View_user; import com.budwk.app.zhgh.club.model.ClubUser; import com.budwk.app.zhgh.club.model.ClubUserApply; import com.budwk.app.zhgh.club.model.SysClub; import com.budwk.app.zhgh.club.service.ClubUserJoinService; import org.nutz.dao.Cnd; +import org.nutz.dao.Chain; import org.nutz.dao.Dao; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.IocBean; import java.util.Date; import java.util.List; +import java.util.Objects; @IocBean(args = {"refer:dao"}) public class ClubUserJoinServiceImpl extends BaseServiceImpl implements ClubUserJoinService { + // PC 与 H5 入会资格校验共用此名单,人员类型须与用户视图中的值完全一致。 private static final List ALLOW_JOIN_PERSON_TYPES = List.of( "教职工", - "未起薪人员", - "教职工新入会", - "教职工其他", "劳动服务公司", - "后勤聘用人员", - "退休人员[一]", - "退休人员[二]", - "校聘劳务派遣全额" + "劳务派遣人员" ); public ClubUserJoinServiceImpl(Dao dao) { super(dao); } + @Override + public void preparePendingJoinTimes(ClubUserApply application) { + if (Integer.valueOf(1).equals(application.getMode())) { + // 审核前尚未正式入会;后端清除客户端回传或草稿遗留的时间,退会申请保留原入会时间。 + application.setJoinTime(null); + application.setExitTime(null); + } + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public Date completeJoinApproval(String applicationId, Long taskId) { + if (StrUtil.isBlank(applicationId) || taskId == null) { + throw new IllegalArgumentException("入会申请或会长审核任务不能为空"); + } + // WF 引擎已先保存完成时间;Execution 中的任务对象可能仍是完成前的数据,必须重新查库。 + ProcessTask task = dao().fetch(ProcessTask.class, taskId); + if (task == null + || !"1625a683-3890-4788-95b7-cab8240f6731".equals(task.getTaskName()) + || !ProcessTaskStateEnum.FINISHED.getCode().equals(task.getTaskState()) + || task.getFinishTime() == null + || StrUtil.isBlank(task.getVariable()) + || !ProcessSubmitTypeEnum.AGREE.getCode().equals(JSONUtil.parseObj(task.getVariable()).getInt(FlowConst.SUBMIT_TYPE))) { + throw new IllegalArgumentException("没有已完成且同意的会长入会审核任务"); + } + ProcessInstance instance = dao().fetch(ProcessInstance.class, task.getProcessInstanceId()); + ClubUserApply application = fetch(applicationId); + // 只更新当前任务所属的入会申请,不覆盖其他申请、退会记录或之前的入会周期。 + if (instance == null || !Objects.equals(applicationId, instance.getBusinessNo()) + || application == null || !Integer.valueOf(1).equals(application.getMode())) { + throw new IllegalArgumentException("会长审核任务与入会申请不匹配"); + } + dao().update(ClubUserApply.class, Chain.make("joinTime", task.getFinishTime()), Cnd.where("id", "=", applicationId)); + return task.getFinishTime(); + } + @Override public String checkJoinQualification(String userId) { View_user user = dao().fetch(View_user.class, Cnd.where("id", "=", userId)); @@ -51,6 +92,8 @@ public class ClubUserJoinServiceImpl extends BaseServiceImpl impl @Override public ClubUserApply buildExitApply(String clubId, String userId) { + // 退会以当前会员关系的入会时间为准,不能沿用上一轮退出申请的时间。 + ClubUser currentMember = dao().fetch(ClubUser.class, Cnd.where("clubId", "=", clubId).and("userId", "=", userId)); // 优先复用最近一次申请记录中的补充信息,避免用户重复填写基础资料。 ClubUserApply latestApply = fetch( Cnd.where("userId", "=", userId) @@ -59,11 +102,14 @@ public class ClubUserJoinServiceImpl extends BaseServiceImpl impl ); if (latestApply != null) { latestApply.setId(null); + if (currentMember != null && currentMember.getJoinTime() != null) { + latestApply.setJoinTime(currentMember.getJoinTime()); + } return latestApply; } // 对于会长等注册社团时直接入会的人员,没有申请记录时从当前成员信息兜底生成退会申请。 - ClubUser clubUser = dao().fetch(ClubUser.class, Cnd.where("clubId", "=", clubId).and("userId", "=", userId)); + ClubUser clubUser = currentMember; if (clubUser == null) { return null; } @@ -105,6 +151,11 @@ public class ClubUserJoinServiceImpl extends BaseServiceImpl impl @Override public Date resolveJoinTime(String clubId, String userId) { + // 新会员直接使用关系表的入会时间;旧记录尚未补齐时仍保留原有回退逻辑。 + ClubUser member = dao().fetch(ClubUser.class, Cnd.where("clubId", "=", clubId).and("userId", "=", userId)); + if (member != null && member.getJoinTime() != null) { + return member.getJoinTime(); + } ClubUserApply latestJoinApply = fetch( Cnd.where("userId", "=", userId) .and("clubId", "=", clubId) diff --git a/src/main/java/com/budwk/app/zhgh/club/service/impl/SysClubExamineServiceImpl.java b/src/main/java/com/budwk/app/zhgh/club/service/impl/SysClubExamineServiceImpl.java index 505a5b5..9e77e3c 100644 --- a/src/main/java/com/budwk/app/zhgh/club/service/impl/SysClubExamineServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/club/service/impl/SysClubExamineServiceImpl.java @@ -36,6 +36,7 @@ import java.util.Comparator; import java.util.Date; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -50,260 +51,501 @@ public class SysClubExamineServiceImpl extends BaseServiceImpl getClubUserNum(String clubId, Integer year) { - Date yearStart = DateUtil.parse(year + "-01-01 00:00:00"); - Date nextYearStart = DateUtil.parse((year + 1) + "-01-01 00:00:00"); - List membershipPeriods = getMembershipPeriods(clubId); - NutMap activeResult = createUserChangeResult("在职"); - NutMap retiredResult = createUserChangeResult("退休"); + // 2026年10月2日wxg屏蔽操作:原统计入口及专用辅助代码逐行注释,保留原文便于对比或恢复。 +// @Override +// public List getClubUserNum(String clubId, Integer year) { +// Date yearStart = DateUtil.parse(year + "-01-01 00:00:00"); +// Date nextYearStart = DateUtil.parse((year + 1) + "-01-01 00:00:00"); +// List membershipPeriods = getMembershipPeriods(clubId); +// NutMap activeResult = createUserChangeResult("在职"); +// NutMap retiredResult = createUserChangeResult("退休"); +// +// for (MembershipPeriod period : membershipPeriods) { +// // 非正常历史会员没有退会时间且没有已完成退会申请时,无法确定所属年度,不计入统计。 +// if (!period.normal && period.exitTime == null) { +// continue; +// } +// if (isMemberAtYearBoundary(period, yearStart)) { +// increaseResult(classifyAtBoundary(period, yearStart), activeResult, retiredResult, "yearFirstNum"); +// } +// if (isInYear(period.joinTime, yearStart, nextYearStart)) { +// increaseResult(classifyAtEvent(period, period.joinTime), activeResult, retiredResult, "yearAddNum"); +// } +// if (isInYear(period.exitTime, yearStart, nextYearStart)) { +// increaseResult(classifyBeforeEvent(period, period.exitTime), activeResult, retiredResult, "yearReduceNum"); +// } +// // 退休发生在会员有效期内时,作为在职减少一人、退休增加一人处理。 +// if (isInYear(period.retireDate, yearStart, nextYearStart) +// && period.joinTime.before(period.retireDate) +// && (period.exitTime == null || period.exitTime.after(period.retireDate))) { +// increaseResult("在职", activeResult, retiredResult, "yearReduceNum"); +// increaseResult("退休", activeResult, retiredResult, "yearAddNum"); +// } +// if (isMemberAtYearBoundary(period, nextYearStart)) { +// increaseResult(classifyAtBoundary(period, nextYearStart), activeResult, retiredResult, "thisYearNum"); +// } +// } +// return List.of(activeResult, retiredResult); +// } +// +// /** +// * 合并旧系统会员生命周期与新系统已完成申请,生成可用于年度统计的入退会区间。 +// * 旧系统记录负责迁移历史,新系统申请只补充旧记录缺失的退会时间和迁移后新增的入会周期。 +// */ +// private List getMembershipPeriods(String clubId) { +// Sql legacySql = Sqls.create(""" +// SELECT +// cu.userId, +// CAST(cu.joinTime AS DATETIME) joinTime, +// STR_TO_DATE(NULLIF(cu.changeTime, ''), '%Y-%m-%d %H:%i:%s') exitTime, +// cu.isNormal, +// u.userState, +// u.retireDate +// FROM sys_club_user cu +// LEFT JOIN sys_user u ON u.id = cu.userId +// WHERE cu.clubId = @clubId +// AND cu.status = 5 +// AND cu.joinTime IS NOT NULL +// AND cu.joinTime <> '' +// """).setParam("clubId", clubId); +// List periods = listMap(legacySql).stream() +// .map(row -> new MembershipPeriod( +// row.getString("userId"), +// row.getTime("joinTime"), +// row.getTime("exitTime"), +// !Boolean.FALSE.equals(row.getBoolean("isNormal")), +// true, +// row.getString("userState"), +// row.getTime("retireDate") +// )) +// .filter(period -> period.joinTime != null) +// .collect(Collectors.toCollection(ArrayList::new)); +// +// Sql applySql = Sqls.create(""" +// SELECT +// cu.userId, +// cu.id, +// cu.mode, +// cu.applyDate, +// cu.joinTime, +// cu.exitTime, +// u.userState, +// u.retireDate +// FROM club_user_apply cu +// LEFT JOIN sys_user u ON u.id = cu.userId +// WHERE cu.clubId = @clubId +// AND COALESCE(cu.delFlag, 0) = 0 +// ORDER BY cu.applyDate, cu.mode +// """) +// .setParam("clubId", clubId); +// List applications = listMap(applySql); +// if (!applications.isEmpty()) { +// // 流程状态改为按当前社团申请批量查询,避免相关子查询对流程实例表重复扫描。 +// List applicationIds = applications.stream() +// .map(application -> application.getString("id")) +// .filter(StrUtil::isNotBlank) +// .collect(Collectors.toList()); +// List finishedInstances = applicationIds.isEmpty() ? new ArrayList<>() +// : dao().query(ProcessInstance.class, Cnd.where(ProcessInstance::getBusinessNo, "in", applicationIds) +// .and(ProcessInstance::getState, "=", ProcessInstanceStateEnum.FINISHED.getCode())); +// Set finishedBusinessNos = finishedInstances.stream() +// .map(ProcessInstance::getBusinessNo) +// .filter(StrUtil::isNotBlank) +// .collect(Collectors.toCollection(HashSet::new)); +// applications.removeIf(application -> !finishedBusinessNos.contains(application.getString("id"))); +// } +// applications.sort(Comparator.comparing(row -> row.getTime("applyDate"), Comparator.nullsLast(Date::compareTo))); +// +// for (NutMap application : applications) { +// Date applyDate = application.getTime("applyDate"); +// Boolean mode = application.getBoolean("mode"); +// if (applyDate == null || mode == null) { +// continue; +// } +// if (mode) { +// mergeJoinApplication(periods, application, applyDate); +// } else { +// mergeExitApplication(periods, application, applyDate); +// } +// } +// return periods; +// } +// +// /** +// * 已迁移的入会申请与旧会员记录精确对应时不重复新增;迁移后的新申请以审核申请时间作为入会时间。 +// */ +// private void mergeJoinApplication(List periods, NutMap application, Date applyDate) { +// String userId = application.getString("userId"); +// Date applicationJoinTime = application.getTime("joinTime"); +// boolean representedByLegacy = periods.stream() +// .filter(period -> period.legacy && Objects.equals(period.userId, userId)) +// .filter(period -> Objects.equals(period.joinTime, applicationJoinTime)) +// .anyMatch(period -> period.exitTime == null || !applyDate.after(period.exitTime)); +// boolean representedByNewPeriod = periods.stream() +// .filter(period -> !period.legacy && Objects.equals(period.userId, userId)) +// .anyMatch(period -> isMemberAt(period, applyDate)); +// if (representedByLegacy || representedByNewPeriod) { +// return; +// } +// periods.add(new MembershipPeriod( +// userId, +// applyDate, +// null, +// true, +// false, +// application.getString("userState"), +// application.getTime("retireDate") +// )); +// } +// +// /** +// * 退会申请优先关闭入会时间相同的会员周期;新系统复入会后再退会时关闭最近的有效周期。 +// */ +// private void mergeExitApplication(List periods, NutMap application, Date applyDate) { +// String userId = application.getString("userId"); +// Date applicationJoinTime = application.getTime("joinTime"); +// Date exitTime = application.getTime("exitTime") == null ? applyDate : application.getTime("exitTime"); +// MembershipPeriod target = periods.stream() +// .filter(period -> Objects.equals(period.userId, userId)) +// .filter(period -> Objects.equals(period.joinTime, applicationJoinTime)) +// .filter(period -> period.exitTime == null) +// .max(Comparator.comparing(period -> period.joinTime)) +// .orElse(null); +// if (target == null) { +// target = periods.stream() +// .filter(period -> Objects.equals(period.userId, userId)) +// .filter(period -> period.exitTime == null && !period.joinTime.after(exitTime)) +// .max(Comparator.comparing(period -> period.joinTime)) +// .orElse(null); +// } +// if (target != null) { +// target.exitTime = exitTime; +// } +// } +// +// private NutMap createUserChangeResult(String userState) { +// return NutMap.NEW() +// .addv("userState", userState) +// .addv("yearFirstNum", 0) +// .addv("yearAddNum", 0) +// .addv("yearReduceNum", 0) +// .addv("thisYearNum", 0); +// } +// +// private void increaseResult(String userState, NutMap activeResult, NutMap retiredResult, String field) { +// NutMap result = "退休".equals(userState) ? retiredResult : "在职".equals(userState) ? activeResult : null; +// if (result != null) { +// result.put(field, result.getInt(field) + 1); +// } +// } +// +// private boolean isInYear(Date date, Date yearStart, Date nextYearStart) { +// return date != null && !date.before(yearStart) && date.before(nextYearStart); +// } +// +// private boolean isMemberAt(MembershipPeriod period, Date date) { +// return !period.joinTime.after(date) && (period.exitTime == null || period.exitTime.after(date)); +// } +// +// private boolean isMemberAtYearBoundary(MembershipPeriod period, Date boundary) { +// return period.joinTime.before(boundary) && (period.exitTime == null || !period.exitTime.before(boundary)); +// } +// +// private String classifyAtBoundary(MembershipPeriod period, Date boundary) { +// if (period.retireDate != null) { +// return period.retireDate.before(boundary) ? "退休" : "在职"; +// } +// return normalizeUserState(period.userState); +// } +// +// private String classifyAtEvent(MembershipPeriod period, Date eventTime) { +// if (period.retireDate != null) { +// return period.retireDate.after(eventTime) ? "在职" : "退休"; +// } +// return normalizeUserState(period.userState); +// } +// +// private String classifyBeforeEvent(MembershipPeriod period, Date eventTime) { +// if (period.retireDate != null) { +// return period.retireDate.before(eventTime) ? "退休" : "在职"; +// } +// return normalizeUserState(period.userState); +// } +// +// private String normalizeUserState(String userState) { +// // 历史成员数据可能未同步人员状态,空值不参与在职、退休人数统计。 +// if (StrUtil.isBlank(userState)) { +// return null; +// } +// if (List.of("在职", "在岗").contains(userState)) { +// return "在职"; +// } +// if (List.of("退休", "退休【变号】").contains(userState)) { +// return "退休"; +// } +// return null; +// } +// +// private static class MembershipPeriod { +// private final String userId; +// private final Date joinTime; +// private Date exitTime; +// private final boolean normal; +// private final boolean legacy; +// private final String userState; +// private final Date retireDate; +// +// private MembershipPeriod(String userId, Date joinTime, Date exitTime, boolean normal, boolean legacy, +// String userState, Date retireDate) { +// this.userId = userId; +// this.joinTime = joinTime; +// this.exitTime = exitTime; +// this.normal = normal; +// this.legacy = legacy; +// this.userState = userState; +// this.retireDate = retireDate; +// } +// } +// +// + // 2026年10月2日22:00点开始新开发的代码。 + // 实际整改时间:2026年10月3日 10:14:04;操作人:wxg。 + @Override + public List getClubUserNum(String clubId, Integer year) { + if (StrUtil.isBlank(clubId) || year == null || year < 1 || year > 9998) { + throw new IllegalArgumentException("请选择有效社团和统计年度"); + } + MemberChangeStatistics stats = new MemberChangeStatistics(year); + // 年初基数来自当年名单,不受缴费、拨付或当前退会状态影响;同一会员按 userId 去重。 + List roster = listMap(Sqls.create("SELECT userId, joinTime, personType, retireDate, operateLogs FROM club_pay_record WHERE clubId = @clubId AND year = @year") + .setParam("clubId", clubId).setParam("year", year)); + if (roster.isEmpty()) { + stats.invalidate(year + "年度该社团没有缴费名单,请检查年度名单生成计划任务及名单数据,不能将缺失名单视为年初零人。"); + return stats.result(); + } + boolean generated = roster.stream().anyMatch(row -> { + String logs = row.getString("operateLogs"); + return logs != null && logs.contains("缴费管理-定时任务自动设置"); + }); + if (!generated) { + stats.warnings.add("未发现该社团年度名单的定时生成记录,请检查计划任务及名单完整性;手工入会记录不能证明年度名单已完整生成。"); + } + Map snapshots = new java.util.LinkedHashMap<>(); + Map periods = new java.util.LinkedHashMap<>(); + for (NutMap row : roster) { + String userId = row.getString("userId"); + if (StrUtil.isBlank(userId) || row.getTime("joinTime") == null) { + stats.invalidate("缴费名单存在用户或加入时间缺失,请补齐后核查统计。"); + continue; + } + NutMap prior = snapshots.putIfAbsent(userId, row); + if (prior != null) { + stats.warnings.add("缴费名单存在同一会员重复记录,已按用户去重,请核查。"); + if (!Objects.equals(prior.getTime("joinTime"), row.getTime("joinTime"))) { + stats.invalidate("重复缴费记录的加入时间不一致,无法确定年初基数,请核查。"); + } + continue; + } + AnnualMemberPeriod period = addAnnualPeriod(periods, userId, row.getTime("joinTime"), stats); + if (period != null) period.rosterMember = true; + } + // 只接纳已完成 WF 的 mode=0/1;mode=2 是已生效的系统退会,不需要 WF 实例。 + // mode 显式转为整数,避免 MySQL tinyint(1) 被驱动返回布尔值后将系统退会误判为入会。 + List applications = listMap(Sqls.create(""" + SELECT a.userId, a.id, CAST(a.mode AS SIGNED) AS mode, a.joinTime, a.exitTime + FROM club_user_apply a + WHERE a.clubId = @clubId AND COALESCE(a.delFlag, 0) = 0 + AND (a.mode = 2 OR (a.mode IN (0, 1) AND EXISTS ( + SELECT 1 FROM wf_process_instance ins WHERE ins.businessNo = a.id AND ins.state = 20))) + """).setParam("clubId", clubId)); + for (NutMap application : applications) { + Date joinTime = application.getTime("joinTime"); + Date exitTime = application.getTime("exitTime"); + int mode = application.getInt("mode"); + if (mode == 1 && joinTime == null) { + stats.invalidate("已完成入会申请缺少 joinTime,请核查会长审核时间。"); + } else if (mode != 1 && exitTime == null) { + stats.invalidate("已生效退会记录缺少 exitTime,请核查退会数据。"); + } + // 年初只使用缴费基数,旧入会申请不能另建一个年初周期;同年退会资料补充手工入会。 + if (stats.inYear(joinTime) && (mode == 1 || stats.inYear(exitTime))) { + // 退会记录中的入会时间也能还原已被物理删除的手工入会周期。 + addAnnualPeriod(periods, application.getString("userId"), joinTime, stats); + } + } + List members = listMap(Sqls.create("SELECT userId, joinTime FROM club_user WHERE clubId = @clubId").setParam("clubId", clubId)); + for (NutMap member : members) { + if (member.getTime("joinTime") == null) { + if (!snapshots.containsKey(member.getString("userId"))) stats.invalidate("当前会员存在加入时间缺失,无法确定年度增加,请核查。"); + } else { + // 手工录入、导入和成立社团时加入的成员没有 mode=1,也需要计入可靠的年度增加。 + addAnnualPeriod(periods, member.getString("userId"), member.getTime("joinTime"), stats); + } + } + for (NutMap application : applications) { + if (application.getInt("mode") == 1 || application.getTime("exitTime") == null) continue; + Date exitTime = application.getTime("exitTime"); + if (!exitTime.before(stats.end)) continue; + String userId = application.getString("userId"); + Date joinTime = application.getTime("joinTime"); + AnnualMemberPeriod target = joinTime == null ? null : periods.get(periodKey(userId, joinTime)); + // 旧退会申请可能仍保存迁移前的入会日期,只关闭当时唯一有效的最近周期。 + if (target == null && (joinTime == null || joinTime.before(stats.start))) { + target = periods.values().stream().filter(period -> Objects.equals(userId, period.userId) + && !period.joinTime.after(exitTime) && (period.exitTime == null || period.exitTime.equals(exitTime))) + .max(Comparator.comparing(period -> period.joinTime)).orElse(null); + } + if (target == null) { + if (!exitTime.before(stats.start)) stats.invalidate("年度退会记录无法匹配有效入会周期,请核查加入时间。"); + } else if (exitTime.before(target.joinTime)) { + stats.invalidate("退会时间早于入会时间,请核查申请记录。"); + } else if (target.exitTime == null || exitTime.before(target.exitTime)) { + target.exitTime = exitTime; + } + } + Set userIds = periods.values().stream().map(period -> period.userId).collect(Collectors.toSet()); + Map profiles = new java.util.HashMap<>(); + if (!userIds.isEmpty()) { + // 人员快照可能早于退休资料补录,批量读取当前人员类型及退休日期,不读取 userState。 + Sql profileSql = Sqls.create("SELECT id, personType, retireDate FROM sys_user $condition"); + profileSql.setCondition(Cnd.where("id", "in", userIds)); + for (NutMap profile : listMap(profileSql)) profiles.put(profile.getString("id"), profile); + } + Map previousEnd = new java.util.HashMap<>(); + Set retiredTransfers = new HashSet<>(); + List ordered = new ArrayList<>(periods.values()); + ordered.sort(Comparator.comparing((AnnualMemberPeriod period) -> period.userId).thenComparing(period -> period.joinTime)); + for (AnnualMemberPeriod period : ordered) { + // 同一用户不能同时占有两个未结束的周期;异常不强行合并,提示核查。 + if (previousEnd.containsKey(period.userId)) { + Date priorEnd = previousEnd.get(period.userId); + if (priorEnd == null || priorEnd.after(period.joinTime)) stats.invalidate("同一会员的入会周期重叠,请核查重复入会或缺失的退会记录。"); + } + previousEnd.put(period.userId, period.exitTime); + NutMap snapshot = snapshots.get(period.userId); + NutMap profile = profiles.get(period.userId); + String snapshotType = snapshot == null ? null : snapshot.getString("personType"); + String currentType = profile == null ? snapshotType : profile.getString("personType"); + if (StrUtil.isBlank(currentType)) currentType = snapshotType; + Date retireDate = profile == null ? null : profile.getTime("retireDate"); + if (retireDate == null && snapshot != null) retireDate = snapshot.getTime("retireDate"); + boolean atStart = period.joinTime.before(stats.start) && (period.exitTime == null || !period.exitTime.before(stats.start)); + if (atStart && period.rosterMember) { + stats.increase(classifyPersonType(snapshotType, retireDate, stats.start, true, stats), "yearFirstNum"); + } else if (atStart) { + stats.invalidate("存在年初已入会但不在年度缴费基数中的会员,请检查名单完整性。"); + } + if (stats.inYear(period.joinTime)) { + stats.increase(classifyPersonType(currentType, retireDate, period.joinTime, false, stats), "yearAddNum"); + } + if (stats.inYear(period.exitTime)) { + stats.increase(classifyPersonType(currentType, retireDate, period.exitTime, false, stats), "yearReduceNum"); + } + // 同日同刻采用先退休再退会;入会时已退休只算退休入会,不额外算一次转类。 + if (stats.inYear(retireDate) && period.joinTime.before(retireDate) + && (period.exitTime == null || !period.exitTime.before(retireDate)) + && recognizedPersonType(currentType) && retiredTransfers.add(period.userId)) { + stats.increase("在职人员", "yearReduceNum"); + stats.increase("离退休人员", "yearAddNum"); + } + } + return stats.result(); + } - for (MembershipPeriod period : membershipPeriods) { - // 非正常历史会员没有退会时间且没有已完成退会申请时,无法确定所属年度,不计入统计。 - if (!period.normal && period.exitTime == null) { - continue; - } - if (isMemberAtYearBoundary(period, yearStart)) { - increaseResult(classifyAtBoundary(period, yearStart), activeResult, retiredResult, "yearFirstNum"); - } - if (isInYear(period.joinTime, yearStart, nextYearStart)) { - increaseResult(classifyAtEvent(period, period.joinTime), activeResult, retiredResult, "yearAddNum"); - } - if (isInYear(period.exitTime, yearStart, nextYearStart)) { - increaseResult(classifyBeforeEvent(period, period.exitTime), activeResult, retiredResult, "yearReduceNum"); - } - // 退休发生在会员有效期内时,作为在职减少一人、退休增加一人处理。 - if (isInYear(period.retireDate, yearStart, nextYearStart) - && period.joinTime.before(period.retireDate) - && (period.exitTime == null || period.exitTime.after(period.retireDate))) { - increaseResult("在职", activeResult, retiredResult, "yearReduceNum"); - increaseResult("退休", activeResult, retiredResult, "yearAddNum"); - } - if (isMemberAtYearBoundary(period, nextYearStart)) { - increaseResult(classifyAtBoundary(period, nextYearStart), activeResult, retiredResult, "thisYearNum"); - } - } - return List.of(activeResult, retiredResult); - } + /** 合并同一用户、同一加入时间的多来源记录;年度末边界不属于本年。 */ + private AnnualMemberPeriod addAnnualPeriod(Map periods, String userId, Date joinTime, MemberChangeStatistics stats) { + if (StrUtil.isBlank(userId)) { + stats.invalidate("会员记录缺少用户ID,请核查。"); + return null; + } + if (joinTime == null || !joinTime.before(stats.end)) return null; + return periods.computeIfAbsent(periodKey(userId, joinTime), key -> new AnnualMemberPeriod(userId, joinTime)); + } - /** - * 合并旧系统会员生命周期与新系统已完成申请,生成可用于年度统计的入退会区间。 - * 旧系统记录负责迁移历史,新系统申请只补充旧记录缺失的退会时间和迁移后新增的入会周期。 - */ - private List getMembershipPeriods(String clubId) { - Sql legacySql = Sqls.create(""" - SELECT - cu.userId, - CAST(cu.joinTime AS DATETIME) joinTime, - STR_TO_DATE(NULLIF(cu.changeTime, ''), '%Y-%m-%d %H:%i:%s') exitTime, - cu.isNormal, - u.userState, - u.retireDate - FROM sys_club_user cu - LEFT JOIN sys_user u ON u.id = cu.userId - WHERE cu.clubId = @clubId - AND cu.status = 5 - AND cu.joinTime IS NOT NULL - AND cu.joinTime <> '' - """).setParam("clubId", clubId); - List periods = listMap(legacySql).stream() - .map(row -> new MembershipPeriod( - row.getString("userId"), - row.getTime("joinTime"), - row.getTime("exitTime"), - !Boolean.FALSE.equals(row.getBoolean("isNormal")), - true, - row.getString("userState"), - row.getTime("retireDate") - )) - .filter(period -> period.joinTime != null) - .collect(Collectors.toCollection(ArrayList::new)); + private String periodKey(String userId, Date joinTime) { + return userId + ":" + joinTime.getTime(); + } - Sql applySql = Sqls.create(""" - SELECT - cu.userId, - cu.id, - cu.mode, - cu.applyDate, - cu.joinTime, - cu.exitTime, - u.userState, - u.retireDate - FROM club_user_apply cu - LEFT JOIN sys_user u ON u.id = cu.userId - WHERE cu.clubId = @clubId - AND COALESCE(cu.delFlag, 0) = 0 - ORDER BY cu.applyDate, cu.mode - """) - .setParam("clubId", clubId); - List applications = listMap(applySql); - if (!applications.isEmpty()) { - // 流程状态改为按当前社团申请批量查询,避免相关子查询对流程实例表重复扫描。 - List applicationIds = applications.stream() - .map(application -> application.getString("id")) - .filter(StrUtil::isNotBlank) - .collect(Collectors.toList()); - List finishedInstances = applicationIds.isEmpty() ? new ArrayList<>() - : dao().query(ProcessInstance.class, Cnd.where(ProcessInstance::getBusinessNo, "in", applicationIds) - .and(ProcessInstance::getState, "=", ProcessInstanceStateEnum.FINISHED.getCode())); - Set finishedBusinessNos = finishedInstances.stream() - .map(ProcessInstance::getBusinessNo) - .filter(StrUtil::isNotBlank) - .collect(Collectors.toCollection(HashSet::new)); - applications.removeIf(application -> !finishedBusinessNos.contains(application.getString("id"))); - } - applications.sort(Comparator.comparing(row -> row.getTime("applyDate"), Comparator.nullsLast(Date::compareTo))); + /** 只接纳指定的四种人员类型,其他已知类型不纳入这两类统计。 */ + private boolean recognizedPersonType(String personType) { + return List.of("教职工", "劳务派遣人员", "劳动服务公司", "离退休人员").contains(personType == null ? "" : personType); + } - for (NutMap application : applications) { - Date applyDate = application.getTime("applyDate"); - Boolean mode = application.getBoolean("mode"); - if (applyDate == null || mode == null) { - continue; - } - if (mode) { - mergeJoinApplication(periods, application, applyDate); - } else { - mergeExitApplication(periods, application, applyDate); - } - } - return periods; - } + /** 年初边界当天退休仍属于年初在职基数,随后转类;事件时刻已退休则归入离退休。 */ + private String classifyPersonType(String personType, Date retireDate, Date moment, boolean boundary, MemberChangeStatistics stats) { + if (StrUtil.isBlank(personType)) { + stats.invalidate("人员类型为空,不能按在职状态替代分类,请核查人员资料。"); + return null; + } + if (!recognizedPersonType(personType)) { + stats.warnings.add("存在不属于指定两类的人员类型,已排除,请核查统计范围。"); + return null; + } + if (retireDate != null) { + boolean retired = boundary ? retireDate.before(moment) : !retireDate.after(moment); + return retired ? "离退休人员" : "在职人员"; + } + return "离退休人员".equals(personType) ? "离退休人员" : "在职人员"; + } - /** - * 已迁移的入会申请与旧会员记录精确对应时不重复新增;迁移后的新申请以审核申请时间作为入会时间。 - */ - private void mergeJoinApplication(List periods, NutMap application, Date applyDate) { - String userId = application.getString("userId"); - Date applicationJoinTime = application.getTime("joinTime"); - boolean representedByLegacy = periods.stream() - .filter(period -> period.legacy && Objects.equals(period.userId, userId)) - .filter(period -> Objects.equals(period.joinTime, applicationJoinTime)) - .anyMatch(period -> period.exitTime == null || !applyDate.after(period.exitTime)); - boolean representedByNewPeriod = periods.stream() - .filter(period -> !period.legacy && Objects.equals(period.userId, userId)) - .anyMatch(period -> isMemberAt(period, applyDate)); - if (representedByLegacy || representedByNewPeriod) { - return; - } - periods.add(new MembershipPeriod( - userId, - applyDate, - null, - true, - false, - application.getString("userState"), - application.getTime("retireDate") - )); - } + private static class AnnualMemberPeriod { + final String userId; + final Date joinTime; + Date exitTime; + boolean rosterMember; - /** - * 退会申请优先关闭入会时间相同的会员周期;新系统复入会后再退会时关闭最近的有效周期。 - */ - private void mergeExitApplication(List periods, NutMap application, Date applyDate) { - String userId = application.getString("userId"); - Date applicationJoinTime = application.getTime("joinTime"); - Date exitTime = application.getTime("exitTime") == null ? applyDate : application.getTime("exitTime"); - MembershipPeriod target = periods.stream() - .filter(period -> Objects.equals(period.userId, userId)) - .filter(period -> Objects.equals(period.joinTime, applicationJoinTime)) - .filter(period -> period.exitTime == null) - .max(Comparator.comparing(period -> period.joinTime)) - .orElse(null); - if (target == null) { - target = periods.stream() - .filter(period -> Objects.equals(period.userId, userId)) - .filter(period -> period.exitTime == null && !period.joinTime.after(exitTime)) - .max(Comparator.comparing(period -> period.joinTime)) - .orElse(null); - } - if (target != null) { - target.exitTime = exitTime; - } - } + AnnualMemberPeriod(String userId, Date joinTime) { + this.userId = userId; + this.joinTime = joinTime; + } + } - private NutMap createUserChangeResult(String userState) { - return NutMap.NEW() - .addv("userState", userState) - .addv("yearFirstNum", 0) - .addv("yearAddNum", 0) - .addv("yearReduceNum", 0) - .addv("thisYearNum", 0); - } + /** 返回两类计数及核查信息;userState 仅为已有查看页的显示字段别名,不作为分类数据来源。 */ + private static class MemberChangeStatistics { + final Date start; + final Date end; + final Set warnings = new java.util.LinkedHashSet<>(); + final List rows = List.of(row("在职人员"), row("离退休人员")); + boolean valid = true; - private void increaseResult(String userState, NutMap activeResult, NutMap retiredResult, String field) { - NutMap result = "退休".equals(userState) ? retiredResult : "在职".equals(userState) ? activeResult : null; - if (result != null) { - result.put(field, result.getInt(field) + 1); - } - } + MemberChangeStatistics(int year) { + start = DateUtil.parse(year + "-01-01 00:00:00"); + end = DateUtil.parse((year + 1) + "-01-01 00:00:00"); + } - private boolean isInYear(Date date, Date yearStart, Date nextYearStart) { - return date != null && !date.before(yearStart) && date.before(nextYearStart); - } + static NutMap row(String type) { + return NutMap.NEW().addv("personType", type).addv("userState", type) + .addv("yearFirstNum", 0).addv("yearAddNum", 0).addv("yearReduceNum", 0).addv("thisYearNum", 0); + } - private boolean isMemberAt(MembershipPeriod period, Date date) { - return !period.joinTime.after(date) && (period.exitTime == null || period.exitTime.after(date)); - } + boolean inYear(Date time) { + return time != null && !time.before(start) && time.before(end); + } - private boolean isMemberAtYearBoundary(MembershipPeriod period, Date boundary) { - return period.joinTime.before(boundary) && (period.exitTime == null || !period.exitTime.before(boundary)); - } + void invalidate(String message) { + valid = false; + warnings.add(message); + } - private String classifyAtBoundary(MembershipPeriod period, Date boundary) { - if (period.retireDate != null) { - return period.retireDate.before(boundary) ? "退休" : "在职"; - } - return normalizeUserState(period.userState); - } + void increase(String type, String field) { + for (NutMap row : rows) if (row.getString("personType").equals(type)) row.put(field, row.getInt(field) + 1); + } - private String classifyAtEvent(MembershipPeriod period, Date eventTime) { - if (period.retireDate != null) { - return period.retireDate.after(eventTime) ? "在职" : "退休"; - } - return normalizeUserState(period.userState); - } - - private String classifyBeforeEvent(MembershipPeriod period, Date eventTime) { - if (period.retireDate != null) { - return period.retireDate.before(eventTime) ? "退休" : "在职"; - } - return normalizeUserState(period.userState); - } - - private String normalizeUserState(String userState) { - // 历史成员数据可能未同步人员状态,空值不参与在职、退休人数统计。 - if (StrUtil.isBlank(userState)) { - return null; - } - if (List.of("在职", "在岗").contains(userState)) { - return "在职"; - } - if (List.of("退休", "退休【变号】").contains(userState)) { - return "退休"; - } - return null; - } - - private static class MembershipPeriod { - private final String userId; - private final Date joinTime; - private Date exitTime; - private final boolean normal; - private final boolean legacy; - private final String userState; - private final Date retireDate; - - private MembershipPeriod(String userId, Date joinTime, Date exitTime, boolean normal, boolean legacy, - String userState, Date retireDate) { - this.userId = userId; - this.joinTime = joinTime; - this.exitTime = exitTime; - this.normal = normal; - this.legacy = legacy; - this.userState = userState; - this.retireDate = retireDate; - } - } + List result() { + for (NutMap row : rows) { + int yearEnd = row.getInt("yearFirstNum") + row.getInt("yearAddNum") - row.getInt("yearReduceNum"); + row.put("thisYearNum", yearEnd); + if (yearEnd < 0) invalidate("年末人数出现负数,请核查年初名单与入退会记录。"); + } + for (NutMap row : rows) { + row.put("statisticsValid", valid); + row.put("statisticsWarnings", new ArrayList<>(warnings)); + // 无法确定基数或周期时展示空值,避免用户将部分计数当成完整统计提交。 + if (!valid) for (String field : List.of("yearFirstNum", "yearAddNum", "yearReduceNum", "thisYearNum")) row.put(field, null); + } + return rows; + } + } @Override public List getJgUser(String clubId) { diff --git a/src/main/java/com/budwk/app/zhgh/club/service/impl/SysClubInfoManageServiceImpl.java b/src/main/java/com/budwk/app/zhgh/club/service/impl/SysClubInfoManageServiceImpl.java index 7a14614..d8221bd 100644 --- a/src/main/java/com/budwk/app/zhgh/club/service/impl/SysClubInfoManageServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/club/service/impl/SysClubInfoManageServiceImpl.java @@ -2,6 +2,7 @@ package com.budwk.app.zhgh.club.service.impl; import cn.hutool.core.util.StrUtil; import com.budwk.app.base.constant.RoleConstant; +import com.budwk.app.base.exception.BaseException; import com.budwk.app.base.page.Pagination; import com.budwk.app.base.service.impl.BaseServiceImpl; import com.budwk.app.base.utils.CommonDownloadUtil; @@ -9,6 +10,14 @@ import com.budwk.app.base.utils.SysOfficeTemplateUtil; import com.budwk.app.bpm.enums.BpmProcessInstanceStatusEnum; import com.budwk.app.flow.enums.ProcessInstanceStateEnum; import com.budwk.app.sys.models.Sys_user; +import com.budwk.app.sys.models.Sys_role; +import com.budwk.app.sys.services.SysRoleService; +import com.budwk.app.sys.services.SysUserService; +import com.budwk.app.zhgh.club.model.ClubUserApply; +import com.budwk.app.zhgh.club.service.ClubUserJoinService; +import com.budwk.app.zhgh.club.service.ClubPayRecordService; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; import com.budwk.app.sys.models.Sys_user_role; import com.budwk.app.web.commons.auth.utils.AuthUtil; import com.budwk.app.web.commons.auth.utils.SecurityUtil; @@ -39,12 +48,17 @@ import org.nutz.lang.Strings; import org.nutz.lang.util.NutMap; import javax.servlet.http.HttpServletResponse; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.InputStream; +import java.time.LocalDate; import java.util.ArrayList; +import java.util.Date; import java.util.Comparator; import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.stream.Collectors; @@ -64,12 +78,74 @@ public class SysClubInfoManageServiceImpl extends BaseServiceImpl sortColumns = Map.of( + "loginName", "scu.loginName", "userName", "scu.userName", "mobile", "scu.mobile", + "joinTime", "scu.joinTime", "sex", "u.sex", "personType", "u.personType", + "userAttribute", "u.userAttribute", "userState", "u.userState", "unitName", "u.unitName"); + String orderColumn = StrUtil.isBlank(pageForm.getPageOrderName()) ? null : sortColumns.get(pageForm.getPageOrderName()); + boolean orderRequested = orderColumn != null && StrUtil.isNotBlank(pageForm.getPageOrderBy()); + boolean needsUnitSort = orderRequested && "unitName".equals(pageForm.getPageOrderName()); + boolean needsUser = Boolean.TRUE.equals(pageForm.getFocusMembers()) + || StrUtil.isNotBlank(pageForm.getPersonType()) || StrUtil.isNotBlank(pageForm.getUserState()) + || (orderRequested && orderColumn.startsWith("u.")); Sql sql = Sqls.create(""" - SELECT - scu.*, - u.username as userName, - u.loginname as loginName, - u.sex, - u.personType as personType, - u.userState, - u.unitid as unitId, - u.mobile, - club.clubName, - u.unitname as unitName, - DATE_FORMAT(IFNULL(joinInfo.joinTime, club.foundTime), '%Y-%m-%d %H:%i:%s') as joinTime - FROM - club_user scu - LEFT JOIN sys_club club ON scu.clubId = club.id - RIGHT JOIN `vw_user` u ON scu.userId = u.id - LEFT JOIN ( - SELECT - joinApply.userId, - joinApply.clubId, - MAX(joinApply.joinTime) AS joinTime - FROM - club_user_apply joinApply - LEFT JOIN wf_process_instance joinIns ON joinIns.businessNo = joinApply.id - WHERE - joinApply.mode = true - AND joinIns.state = 20 - GROUP BY - joinApply.userId, - joinApply.clubId - ) joinInfo ON joinInfo.userId = scu.userId AND joinInfo.clubId = scu.clubId + SELECT scu.* + FROM club_user scu + $userJoin $condition - $order """); - Cnd cnd = Cnd.NEW(); - - cnd.and("scu.clubId", "=", pageForm.getClubId()); - if (StrUtil.isNotBlank(pageForm.getPersonType())) { - cnd.and("u.personType", "=", pageForm.getPersonType()); + // 默认查询和姓名/工号/日期筛选不关联 vw_user;单位排序保留视图的单位名称口径。 + sql.setVar("userJoin", needsUnitSort ? "LEFT JOIN vw_user u ON u.id = scu.userId" + : needsUser ? "LEFT JOIN sys_user u ON u.id = scu.userId" : ""); + Cnd cnd = Cnd.where("scu.clubId", "=", pageForm.getClubId()); + // 在分页和总数统计前同时筛选两类人员,保持当前社团及其他查询条件的限制。 + if (Boolean.TRUE.equals(pageForm.getFocusMembers())) { + cnd.and("u.personType", "in", List.of("自聘人员", "其他")); } - if (StrUtil.isNotBlank(pageForm.getUserState())) { - cnd.and("u.userState", "=", pageForm.getUserState()); + cnd.andEX("u.personType", "=", pageForm.getPersonType()); + cnd.andEX("u.userState", "=", pageForm.getUserState()); + if (StrUtil.isNotBlank(pageForm.getSearchKeyword())) { + cnd.and(Cnd.exps("scu.loginName", "like", "%" + pageForm.getSearchKeyword() + "%") + .or("scu.userName", "like", "%" + pageForm.getSearchKeyword() + "%")); } - if (Strings.isNotBlank(pageForm.getSearchKeyword())) { - cnd.and(Cnd.exps("loginname", "like", "%" + pageForm.getSearchKeyword() + "%").or("username", "like", "%" + pageForm.getSearchKeyword() + "%")); + // 结束日期使用次日零点上界,包含结束当天;只读取已保存的时间,不触发 WF 补齐 SQL。 + if (StrUtil.isNotBlank(pageForm.getJoinDateStart())) { + cnd.and("scu.joinTime", ">=", LocalDate.parse(pageForm.getJoinDateStart()).toString()); } - - if (StrUtil.isAllNotBlank(pageForm.getPageOrderName(), pageForm.getPageOrderBy())) { - cnd.orderBy(pageForm.getPageOrderName(), "descending".equals(pageForm.getPageOrderBy()) ? "desc" : "asc"); + if (StrUtil.isNotBlank(pageForm.getJoinDateEnd())) { + cnd.and("scu.joinTime", "<", LocalDate.parse(pageForm.getJoinDateEnd()).plusDays(1).toString()); + } + if (orderRequested) { + cnd.orderBy(orderColumn, "descending".equals(pageForm.getPageOrderBy()) ? "desc" : "asc"); } else { - sql.setVar("order", """ - ORDER BY - CASE - WHEN JSON_CONTAINS(scu.roleCode, '"CLUB_PRESIDENT"') THEN 1 - WHEN JSON_CONTAINS(scu.roleCode, '"CLUB_VICE_PRESIDENT"') THEN 2 - WHEN JSON_CONTAINS(scu.roleCode, '"CLUB_SECRETARY"') THEN 3 - WHEN JSON_CONTAINS(scu.roleCode, '"CLUB_VICE_SECRETARY"') THEN 4 - WHEN JSON_CONTAINS(scu.roleCode, '"CLUB_OPERATOR"') THEN 5 - WHEN JSON_CONTAINS(scu.roleCode, '"CLUB_MEMBER"') THEN 6 - ELSE 99 - END - """); - } - - //查询理事机构 - if (pageForm.getRadioType() != null && 1 == pageForm.getRadioType()) { + cnd.desc("scu.joinTime"); + } + cnd.asc("scu.id"); + if (Integer.valueOf(1).equals(pageForm.getRadioType())) { cnd.and(new Static("NOT JSON_CONTAINS(scu.roleCode, '\"CLUB_MEMBER\"')")); } - //查询社团成员 - if (pageForm.getRadioType() != null && 2 == pageForm.getRadioType()) { + if (Integer.valueOf(2).equals(pageForm.getRadioType())) { cnd.and(new Static("JSON_CONTAINS(scu.roleCode, '\"CLUB_MEMBER\"')")); - //cnd.and(new Static("JSON_LENGTH(scu.roleCode) = 1")); } sql.setCondition(cnd); - Pagination listPageVO = sysClubService.listPageVO(pageForm, sql, ClubUserCommonPageVo.class); - List listMap = listPageVO.getList(ClubUserCommonPageVo.class); - for (ClubUserCommonPageVo vo : listMap) { - List list = vo.getRoleCode(); - List cleanedCodes = list.stream() - .filter(Objects::nonNull) - .map(String::strip) + Pagination page = sysClubService.listPageVO(pageForm, sql, ClubUserCommonPageVo.class); + List members = page.getList(ClubUserCommonPageVo.class); + fillMemberPageDetails(members, pageForm.getClubId()); + for (ClubUserCommonPageVo member : members) { + List roleCodes = member.getRoleCode() == null ? List.of() : member.getRoleCode(); + List cleanedCodes = roleCodes.stream().filter(Objects::nonNull).map(String::strip) .map(code -> code.startsWith("\"") && code.endsWith("\"") - ? code.substring(1, code.length() - 1) - : code) - .toList(); - String roleName = SysClubUserServiceImpl.convertRoleName(cleanedCodes); - vo.setRoleName(roleName); - vo.setRoleCode(cleanedCodes); + ? code.substring(1, code.length() - 1) : code).toList(); + member.setRoleName(SysClubUserServiceImpl.convertRoleName(cleanedCodes)); + member.setRoleCode(cleanedCodes); } - listPageVO.setList(listMap); - return listPageVO; + page.setList(members); + return page; } + /** + * 2026年10月3日 12:01:28;操作人:wxg:分页后批量获取本页其他资料,避免把完整用户视图带入默认统计和分页。 + * @param members 当前页会员关系;空页不查询,姓名/工号/电话/入会时间始终保留会员表值 + * @param clubId 当前选中社团ID,用于补充社团名称 + */ + private void fillMemberPageDetails(List members, String clubId) { + if (members.isEmpty()) return; + List userIds = members.stream().map(ClubUser::getUserId).filter(Objects::nonNull).distinct().toList(); + Map users = new HashMap<>(); + if (!userIds.isEmpty()) { + Sql details = Sqls.create(""" + SELECT id AS id, sex AS sex, personType AS personType, userAttribute AS userAttribute, + userState AS userState, unitId AS unitId, unitName AS unitName + FROM vw_user + $condition + """); + details.setCondition(Cnd.where("id", "in", userIds)); + for (NutMap user : listMap(details)) users.put(user.getString("id"), user); + } + SysClub club = dao().fetch(SysClub.class, clubId); + for (ClubUserCommonPageVo member : members) { + member.setClubName(club == null ? null : club.getClubName()); + NutMap user = users.get(member.getUserId()); + // 用户资料缺失仍展示会员关系及已保存资料,不用当前用户资料覆盖三个持久化字段。 + if (user == null) continue; + member.setSex(user.getString("sex")); + member.setPersonType(user.getString("personType")); + member.setUserAttribute(user.getString("userAttribute")); + member.setUserState(user.getString("userState")); + member.setUnitId(user.getString("unitId")); + member.setUnitName(user.getString("unitName")); + } + } + + /** + * 当前社团历史退会记录分页;每次退会保留一行,不按人员去重,也不要求关联 WF 已完成流程。 + * @param pageForm clubId 为当前社团ID;姓名/工号、人员类型、在职状态及 yyyy-MM-dd 入会日期范围为可选筛选; + * focusMembers=true 筛选当前人员类型为自聘人员或其他的历史成员,false/空值不限制; + * pageNumber/pageSize 为分页参数,pageOrderName/pageOrderBy 为列排序参数 + * @return 会员展示分页对象;id 为申请ID,joinTime/exitTime 来自申请,人员资料取用户视图当前值 + */ + private Pagination historyMemberPageData(ClubUserPageForm pageForm) { + Sql sql = Sqls.create(""" + SELECT + cua.id, cua.clubId, cua.userId, cua.roleCode AS applyRoleCode, + u.username AS userName, u.loginname AS loginName, u.sex, u.mobile, + u.personType, u.userAttribute, u.userState, + u.unitid AS unitId, u.unitname AS unitName, + DATE_FORMAT(cua.joinTime, '%Y-%m-%d %H:%i:%s') AS joinTime, + DATE_FORMAT(cua.exitTime, '%Y-%m-%d %H:%i:%s') AS exitTime + FROM club_user_apply cua + LEFT JOIN vw_user u ON u.id = cua.userId + $condition + """); + Cnd cnd = Cnd.where("cua.clubId", "=", pageForm.getClubId()) + .and("cua.mode", "in", List.of(0, 2)); + // 历史页同样按用户当前人员类型筛选,两类人员共同参与分页和总数统计。 + if (Boolean.TRUE.equals(pageForm.getFocusMembers())) { + cnd.and("u.personType", "in", List.of("自聘人员", "其他")); + } + cnd.andEX("u.personType", "=", pageForm.getPersonType()); + cnd.andEX("u.userState", "=", pageForm.getUserState()); + if (StrUtil.isNotBlank(pageForm.getSearchKeyword())) { + cnd.and(Cnd.exps("u.loginname", "like", "%" + pageForm.getSearchKeyword() + "%") + .or("u.username", "like", "%" + pageForm.getSearchKeyword() + "%")); + } + // 时间查询保持入会日期语义,结束日期用次日零点上界,包含选择的结束当天。 + if (StrUtil.isNotBlank(pageForm.getJoinDateStart())) { + cnd.and("cua.joinTime", ">=", LocalDate.parse(pageForm.getJoinDateStart()).toString()); + } + if (StrUtil.isNotBlank(pageForm.getJoinDateEnd())) { + cnd.and("cua.joinTime", "<", LocalDate.parse(pageForm.getJoinDateEnd()).plusDays(1).toString()); + } + // 仅允许表格实际展示的列参与排序,退出时间相同则按申请ID保证分页顺序稳定。 + List sortableFields = List.of("loginName", "userName", "sex", "personType", "userAttribute", + "userState", "unitName", "joinTime", "exitTime"); + if (StrUtil.isAllNotBlank(pageForm.getPageOrderName(), pageForm.getPageOrderBy()) + && sortableFields.contains(pageForm.getPageOrderName())) { + cnd.orderBy(pageForm.getPageOrderName(), "descending".equals(pageForm.getPageOrderBy()) ? "desc" : "asc"); + } else { + cnd.desc("cua.exitTime"); + } + cnd.asc("cua.id"); + sql.setCondition(cnd); + Pagination pagination = listPageVO(pageForm, sql, ClubUserCommonPageVo.class); + List members = pagination.getList(ClubUserCommonPageVo.class); + for (ClubUserCommonPageVo member : members) { + if (StrUtil.isNotBlank(member.getApplyRoleCode())) { + member.setRoleName(SysClubUserServiceImpl.convertRoleName(List.of(member.getApplyRoleCode()))); + } + } + pagination.setList(members); + return pagination; + } + + /** + * 2026年10月3日 19:42:45;操作人:wxg:查询审核退会、系统处理与历史直接移出记录。 + * @param pageForm clubId、姓名/工号、personType、userState 为可选筛选;exitDateStart/exitDateEnd 传 yyyy-MM-dd,结束日包含当天;pageNumber/pageSize 控制分页 + * @return 分页会员资料,exitType 区分 AUDIT_EXIT、SYSTEM_EXIT、DIRECT_REMOVE,exitTime 为退会时间 + */ @Override public Pagination exitManagePageData(ClubUserPageForm pageForm) { Sql sql = Sqls.create(""" @@ -257,6 +419,13 @@ public class SysClubInfoManageServiceImpl extends BaseServiceImpl=", LocalDate.parse(pageForm.getExitDateStart()).toString()); + } + if (StrUtil.isNotBlank(pageForm.getExitDateEnd())) { + cnd.and("exitInfo.exitTime", "<", LocalDate.parse(pageForm.getExitDateEnd()).plusDays(1).toString()); + } cnd.andEX("u.personType", "=", pageForm.getPersonType()); cnd.andEX("u.userState", "=", pageForm.getUserState()); if (StrUtil.isNotBlank(pageForm.getSearchKeyword())) { @@ -268,7 +437,9 @@ public class SysClubInfoManageServiceImpl extends BaseServiceImpl pagination = listPageVO(pageForm, sql, ClubUserCommonPageVo.class); List list = pagination.getList(ClubUserCommonPageVo.class); @@ -321,6 +492,8 @@ public class SysClubInfoManageServiceImpl extends BaseServiceImpl implements SysC @Inject private SysClubUserService sysClubUserService; @Inject + private ClubPayRecordService payRecordService; + @Inject private CommonService commonService; @Inject private BpmService bpmService; + @Override + public List listJoinClubOptions() { + // 注册、换届等流程可能使用同一社团ID作为业务号;仅判断流程存在,避免联表将社团重复返回。 + Sql sql = Sqls.create(""" + SELECT c.* + FROM sys_club c + WHERE EXISTS ( + SELECT 1 + FROM wf_process_instance inst + WHERE inst.businessNo = c.id AND inst.state = @state + ) + """).setParam("state", ProcessInstanceStateEnum.FINISHED.getCode()); + return listVO(sql, SysClub.class); + } + @Override public Pagination getAllClubWithPage(PageForm pageForm, Cnd cnd) { Sql sql = Sqls.create(""" @@ -77,10 +98,11 @@ public class SysClubServiceImpl extends BaseServiceImpl implements SysC FROM sys_club club LEFT JOIN sys_user u on club.concatPerson = u.id - LEFT JOIN wf_process_instance ins ON ins.businessNo = club.id $condition """).setParam("userId", SecurityUtil.getUserId()); - cnd.and("ins.state", "=", ProcessInstanceStateEnum.FINISHED.getCode()); + // 同一社团可能关联注册、换届等多个已完成 WF 流程;仅检查流程存在,避免列表和分页总数重复。 + cnd.and(new Static("EXISTS (SELECT 1 FROM wf_process_instance ins WHERE ins.businessNo = club.id AND ins.state = %s)" + .formatted(ProcessInstanceStateEnum.FINISHED.getCode()))); sql.setCondition(cnd); return listPageVO(pageForm, sql, ClubCommonPageVo.class); } @@ -164,6 +186,7 @@ public class SysClubServiceImpl extends BaseServiceImpl implements SysC } @Override + @Aop(TransAop.READ_COMMITTED) public SysClub doAdd(SysClub club, List managePerson) { club.setDismiss(false); club.setUserId(SecurityUtil.getUserId()); @@ -178,6 +201,7 @@ public class SysClubServiceImpl extends BaseServiceImpl implements SysC clubUser.setUserId(person.getString("userId")); return clubUser; }).toList(); + sysClubUserService.prepareDirectJoinTimes(clubUsers); dao().insert(clubUsers); for (ClubUser clubUser : clubUsers) { @@ -203,6 +227,8 @@ public class SysClubServiceImpl extends BaseServiceImpl implements SysC log.set("newValue", false); record.setOperateLogs(List.of(log)); + // 2026年10月3日 09:05:37;操作人:wxg:社团创建直接入会的成员同样保存缴费快照。 + payRecordService.prepareSnapshots(List.of(record)); dao().insert(record); } } @@ -211,6 +237,7 @@ public class SysClubServiceImpl extends BaseServiceImpl implements SysC } @Override + @Aop(TransAop.READ_COMMITTED) public SysClub doEdit(SysClub club, List deleteIds, List managePerson) { // 修改社团表 dao().update(club); @@ -219,6 +246,8 @@ public class SysClubServiceImpl extends BaseServiceImpl implements SysC // 重新插入发起人 dao().insertLinks(club, "sponsors"); + // 现有编辑流程会重建会员关系,删除前保存原入会时间,避免把编辑时刻算成再次入会。 + List previousMembers = dao().query(ClubUser.class, Cnd.where("clubId", "=", club.getId())); dao().clear(Sys_user_role.class, Cnd.where("clubId", "=", club.getId())); dao().clear(ClubUser.class, Cnd.where(ClubUser::getClubId, "=", club.getId())); @@ -228,8 +257,24 @@ public class SysClubServiceImpl extends BaseServiceImpl implements SysC clubUser.setClubId(club.getId()); clubUser.setRoleCode(List.of(person.getString("roleCode"))); clubUser.setUserId(person.getString("userId")); + previousMembers.stream() + .filter(member -> java.util.Objects.equals(member.getUserId(), clubUser.getUserId())) + .findFirst() + .ifPresent(member -> { + // 2026年10月3日 12:01:28;操作人:wxg:编辑社团重建既有会员关系时保留原入会时间及人员资料。 + clubUser.setJoinTime(member.getJoinTime()); + clubUser.setUserName(member.getUserName()); + clubUser.setLoginName(member.getLoginName()); + clubUser.setMobile(member.getMobile()); + }); return clubUser; }).toList(); + // 仅真正新增的理事成员赋确认时间;原成员即使历史时间为空也保留原值。 + List newMembers = clubUsers.stream() + .filter(member -> previousMembers.stream().noneMatch(previous -> + java.util.Objects.equals(previous.getUserId(), member.getUserId()))) + .toList(); + sysClubUserService.prepareDirectJoinTimes(newMembers); dao().insert(clubUsers); } return club; diff --git a/src/main/java/com/budwk/app/zhgh/club/service/impl/SysClubUserServiceImpl.java b/src/main/java/com/budwk/app/zhgh/club/service/impl/SysClubUserServiceImpl.java index 4cdcf77..d1e8546 100644 --- a/src/main/java/com/budwk/app/zhgh/club/service/impl/SysClubUserServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/club/service/impl/SysClubUserServiceImpl.java @@ -1,6 +1,7 @@ package com.budwk.app.zhgh.club.service.impl; import cn.hutool.core.date.DateUtil; +import cn.hutool.core.util.StrUtil; import com.budwk.app.base.constant.RoleConstant; import com.budwk.app.base.page.Pagination; import com.budwk.app.base.service.impl.BaseServiceImpl; @@ -22,6 +23,7 @@ import org.nutz.dao.Cnd; import org.nutz.dao.Dao; import org.nutz.dao.Sqls; import org.nutz.dao.sql.Sql; +import org.nutz.dao.util.Daos; import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; @@ -55,15 +57,96 @@ public class SysClubUserServiceImpl extends BaseServiceImpl implements super(dao); } + /** 2026年10月3日 12:01:28;操作人:wxg:与缴费表升级一致,只增加缺失列,保留现有数据、额外字段和索引。 */ + @Override + @Aop(TransAop.READ_COMMITTED) + public void upgradeMemberSchema() { + // MySQL DDL 会隐式提交,升级失败必须终止启动,不得继续执行缺列的业务查询。 + if (!dao().exists("club_user")) { + dao().create(ClubUser.class, false); + } else { + Daos.migration(dao(), ClubUser.class, true, false, false); + } + } + + /** 2026年10月3日 12:01:28;操作人:wxg:历史资料和空入会时间仅在启动升级时补齐,会员分页查询不再执行 UPDATE。 */ + @Override + @Aop(TransAop.READ_COMMITTED) + public void backfillMemberSnapshots() { + // 每个字段独立补缺;没有电话号码属于正常情况,不覆盖已经保存的非空资料。 + dao().execute(Sqls.create(""" + UPDATE club_user member + INNER JOIN sys_user u ON u.id = member.userId + SET member.userName = CASE WHEN NULLIF(TRIM(member.userName), '') IS NULL THEN u.username ELSE member.userName END, + member.loginName = CASE WHEN NULLIF(TRIM(member.loginName), '') IS NULL THEN u.loginname ELSE member.loginName END, + member.mobile = CASE WHEN NULLIF(TRIM(member.mobile), '') IS NULL THEN u.mobile ELSE member.mobile END + WHERE (NULLIF(TRIM(member.userName), '') IS NULL AND NULLIF(TRIM(u.username), '') IS NOT NULL) + OR (NULLIF(TRIM(member.loginName), '') IS NULL AND NULLIF(TRIM(u.loginname), '') IS NOT NULL) + OR (NULLIF(TRIM(member.mobile), '') IS NULL AND NULLIF(TRIM(u.mobile), '') IS NOT NULL) + """)); + // 没有空入会时间时跳过 WF 汇总,避免每次启动扫描不必要的流程历史。 + if (dao().count(ClubUser.class, Cnd.where("joinTime", "is", null)) == 0) return; + // 沿用原查询前补齐规则:同一会员最近一次已完成 WF 会长同意审核;没有有效流程保持空值。 + dao().execute(Sqls.create(""" + UPDATE club_user member + INNER JOIN ( + SELECT application.userId, application.clubId, MAX(task.finishTime) AS joinTime + FROM club_user_apply application + INNER JOIN wf_process_instance instance ON instance.businessNo = application.id + INNER JOIN wf_process_task task ON task.processInstanceId = instance.id + WHERE application.mode = 1 + AND instance.state = 20 + AND task.taskName = '1625a683-3890-4788-95b7-cab8240f6731' + AND task.taskState = 20 + AND task.finishTime IS NOT NULL + AND JSON_UNQUOTE(JSON_EXTRACT(task.variable, '$.submitType')) = '1' + GROUP BY application.userId, application.clubId + ) approval ON approval.userId = member.userId AND approval.clubId = member.clubId + SET member.joinTime = approval.joinTime + WHERE member.joinTime IS NULL + """)); + } + + /** 2026年10月3日 12:01:28;操作人:wxg:统一从系统用户资料取值,禁止依赖申请页面传回的姓名、工号和电话。 */ + @Override + @Aop(TransAop.READ_COMMITTED) + public void prepareMemberSnapshots(List members) { + if (members == null || members.isEmpty()) return; + if (members.stream().anyMatch(member -> member == null || StrUtil.isBlank(member.getUserId()))) { + throw new IllegalArgumentException("新增会员必须提供有效用户ID"); + } + List userIds = members.stream().map(ClubUser::getUserId).distinct().toList(); + Map users = dao().query(Sys_user.class, Cnd.where("id", "in", userIds)).stream() + .collect(Collectors.toMap(Sys_user::getId, user -> user)); + for (ClubUser member : members) { + Sys_user user = users.get(member.getUserId()); + if (user == null) throw new IllegalArgumentException("新增会员的用户资料不存在:" + member.getUserId()); + member.setUserName(user.getUsername()); + member.setLoginName(user.getLoginname()); + member.setMobile(user.getMobile()); + } + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public void prepareDirectJoinTimes(List members) { + // 2026年10月3日 12:01:28;操作人:wxg:直接新增入口同时准备人员资料,确认时间不覆盖已保存的入会时间。 + prepareMemberSnapshots(members); + if (members == null || members.isEmpty()) return; + Date confirmedAt = new Date(); + for (ClubUser member : members) { + if (member.getJoinTime() == null) { + member.setJoinTime(confirmedAt); + } + } + } + @Override public List getClubUser(String clubId) { Sql sql = Sqls.create(""" SELECT scu.*, - u.username as userName, - u.loginname as loginName, u.sex, - u.mobile, u.userState, unit.`name` unitName FROM @@ -96,10 +179,7 @@ public class SysClubUserServiceImpl extends BaseServiceImpl implements Sql sql = Sqls.create(""" SELECT scu.*, - u.username as userName, - u.loginname as loginName, u.sex, - u.mobile, u.userState, u.birthday, u.unionname as unionName, diff --git a/src/main/java/com/budwk/app/zhgh/club/vo/ClubUserCommonPageVo.java b/src/main/java/com/budwk/app/zhgh/club/vo/ClubUserCommonPageVo.java index 0039f76..c4e2f0e 100644 --- a/src/main/java/com/budwk/app/zhgh/club/vo/ClubUserCommonPageVo.java +++ b/src/main/java/com/budwk/app/zhgh/club/vo/ClubUserCommonPageVo.java @@ -14,22 +14,21 @@ public class ClubUserCommonPageVo extends ClubUser { private String clubName; private String clubCode; private String typeName; - private String userName; + // 2026年10月3日 12:01:28;操作人:wxg:姓名、工号、电话继承 ClubUser 的持久化字段,避免子类同名属性遮蔽。 private List replaceReport; private List rulesFile; private List afterRulesFile; private List establishReport; - private String loginName; private String sex; private String personType; + /** 用户当前人员属性,来自 vw_user.userAttribute,用于会员列表展示。 */ + private String userAttribute; private String userState; private String unitId; - private String mobile; private String unitName; private String unionName; private String birthday; private String unionId; - private String joinTime; private String exitTime; private String exitType; private String applyRoleCode; diff --git a/src/main/java/com/budwk/app/zhgh/club/vo/ClubUserJoinPageVo.java b/src/main/java/com/budwk/app/zhgh/club/vo/ClubUserJoinPageVo.java index 451ede7..f01a11a 100644 --- a/src/main/java/com/budwk/app/zhgh/club/vo/ClubUserJoinPageVo.java +++ b/src/main/java/com/budwk/app/zhgh/club/vo/ClubUserJoinPageVo.java @@ -13,7 +13,8 @@ public class ClubUserJoinPageVo{ private String clubId; private String userId; private String roleCode; - private Boolean mode; + // 0:普通退会;1:入会;2:系统处理退会。 + private Integer mode; private String clubPosition; private String email; private String avatar; diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/outlay/outlayManage/club/controller/OutlayManageClubController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/outlay/outlayManage/club/controller/OutlayManageClubController.java index 68b5760..70c32b9 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/outlay/outlayManage/club/controller/OutlayManageClubController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/outlay/outlayManage/club/controller/OutlayManageClubController.java @@ -18,6 +18,9 @@ import com.budwk.app.web.commons.auth.utils.SecurityUtil; import com.budwk.app.zhgh.club.model.ClubUser; import com.budwk.app.zhgh.club.model.SysClub; import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.club.model.OutlayManageClub; +import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.service.OutlayManageClubQueryService; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import lombok.extern.slf4j.Slf4j; @@ -53,6 +56,9 @@ public class OutlayManageClubController { @Inject private SysRoleService sysRoleService; + @Inject + private OutlayManageClubQueryService clubQueryService; + @At("") @Ok("beetl:/platform/zhgh/dayofficework/outlay/outlayManage/club/manage/index.html") @SaCheckPermission("outlay.outlayManage.club.manage") @@ -111,74 +117,17 @@ public class OutlayManageClubController { return Result.success(count); } + /** + * 2026年10月3日 16:23:13;操作人:wxg:年度分配只委托 service,避免 WF 一对多关联造成重复社团预算。 + * 无业务参数,年度固定为系统当前年;返回 Result.success,新增及重复检查在同一事务完成。 + */ @At @ApiOperation("下发预算") @SaCheckPermission("outlay.outlayManage.club.manage") @SLog(tag = "社团预算分配", msg = "根据申报的金额修改本年的预算预算") + @Aop(TransAop.READ_COMMITTED) public Result issuedOutlay() { - Sql sql = Sqls.create(""" - SELECT - c.*, - COALESCE(pay_stats.historyGiveMoneyNum, 0) AS historyGiveMoneyNum, - COALESCE(pay_stats.historyNotGiveMoneyNum, 0) AS historyNotGiveMoneyNum, - COALESCE(pay_stats.historyUserNum, 0) AS historyUserNum - FROM - sys_club c - LEFT JOIN wf_process_instance inst ON inst.businessNo = c.id - LEFT JOIN ( - SELECT - cu.clubId, - COUNT(CASE WHEN re.isGiveMoney = 1 THEN 1 END) AS historyGiveMoneyNum, - COUNT(CASE WHEN re.isGiveMoney = 0 THEN 1 END) AS historyNotGiveMoneyNum, - COUNT(*) AS historyUserNum - FROM - club_user cu - INNER JOIN club_user_apply cua - ON cu.userId = cua.userId - AND cu.clubId = cua.clubId - LEFT JOIN club_pay_record re - ON re.userId = cu.userId - AND re.clubId = cu.clubId - AND re.year = @year - WHERE - YEAR(cua.applyDate) < @year - AND cua.`mode` = 1 - GROUP BY - cu.clubId - ) pay_stats ON pay_stats.clubId = c.id - WHERE - inst.state = 20; - """).setParam("year", DateUtil.thisYear()); - - List clubList = baseService.listMap(sql); - - - List insertUnionList = new ArrayList<>(); - clubList.forEach(v -> { - - OutlayManageClub outlayManageClub = new OutlayManageClub(); - outlayManageClub.setYear(DateUtil.thisYear()); - outlayManageClub.setClubName(v.getString("clubName")); - outlayManageClub.setClubCode(v.getString("clubCode")); - outlayManageClub.setClubId(v.getString("id")); - outlayManageClub.setHistoryUserNum(v.getInt("historyUserNum")); - outlayManageClub.setHistoryNotGiveMoneyNum(v.getInt("historyNotGiveMoneyNum")); - outlayManageClub.setHistoryGiveMoneyNum(v.getInt("historyGiveMoneyNum")); - - BigDecimal totalBudgetMoney; - //如果人数超过50人就是5000,否则就是3000 - if (v.getInt("historyGiveMoneyNum") >= 50) { - totalBudgetMoney = BigDecimal.valueOf(5000); - } else { - totalBudgetMoney = BigDecimal.valueOf(3000); - } - if (v.getInt("historyGiveMoneyNum") == 0) { - totalBudgetMoney = BigDecimal.ZERO; - } - outlayManageClub.setTotalQuota(totalBudgetMoney); - insertUnionList.add(outlayManageClub); - }); - baseService.insert(insertUnionList); + clubQueryService.generateCurrentYearAllocation(); return Result.success(); } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/outlay/outlayManage/service/OutlayManageClubQueryService.java b/src/main/java/com/budwk/app/zhgh/dayofficework/outlay/outlayManage/service/OutlayManageClubQueryService.java index 7c8ce94..5bbb2cb 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/outlay/outlayManage/service/OutlayManageClubQueryService.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/outlay/outlayManage/service/OutlayManageClubQueryService.java @@ -9,6 +9,13 @@ import javax.servlet.http.HttpServletResponse; public interface OutlayManageClubQueryService extends BaseService { + /** + * 2026年10月3日 16:23:13;操作人:wxg:生成当前年度社团预算,同年度同社团已存在时跳过,不覆盖金额和核对状态。 + * 社团须存在已完成 WF 流程;保留原年度人数统计和拨付金额规则。 + * @return 本次实际新增的预算记录条数,重复调用且没有新增社团时为 0 + */ + int generateCurrentYearAllocation(); + void doMoney(String clubId); Sql backUserSql(PageForm pageForm, String clubId, String type, Integer year, String payed); diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/outlay/outlayManage/service/impl/OutlayManageClubQueryServiceImpl.java b/src/main/java/com/budwk/app/zhgh/dayofficework/outlay/outlayManage/service/impl/OutlayManageClubQueryServiceImpl.java index f0d456d..a4f5634 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/outlay/outlayManage/service/impl/OutlayManageClubQueryServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/outlay/outlayManage/service/impl/OutlayManageClubQueryServiceImpl.java @@ -39,6 +39,8 @@ import java.math.BigDecimal; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.HashSet; +import java.util.Set; import java.util.Map; import java.util.Objects; import java.util.stream.Collectors; @@ -58,6 +60,80 @@ public class OutlayManageClubQueryServiceImpl extends BaseServiceImpl implements super(dao); } + /** 2026年10月3日 16:23:13;操作人:wxg:WF 仅用于判断存在;锁定社团并检查已有年度预算,连续或并发分配不重复新增。 */ + @Override + @Aop(TransAop.READ_COMMITTED) + public int generateCurrentYearAllocation() { + int year = DateUtil.thisYear(); + // 同一事务先按固定顺序锁定候选社团,串行处理分配请求,随后读取最新年度预算。 + // 不改动 WF 流程,不增加索引;多个已完成流程不会增加候选社团行数。 + Sql lockSql = Sqls.create(""" + SELECT c.id FROM sys_club c + WHERE EXISTS ( + SELECT 1 FROM wf_process_instance inst + WHERE inst.businessNo = c.id AND inst.state = 20 + ) + ORDER BY c.id + FOR UPDATE + """); + List clubs = listMap(lockSql); + if (clubs.isEmpty()) return 0; + Set recordedClubs = new HashSet<>(); + for (OutlayManageClub existing : dao().query(OutlayManageClub.class, Cnd.where("year", "=", year))) { + recordedClubs.add(existing.getClubId()); + } + // 2026年10月3日 17:11:57;操作人:wxg:年度预算人数取预算年度减一的缴费名单。 + // 按名单保存的 isGiveMoney 分别统计拨付、未拨付人数,不依赖当前会员关系或入会申请,不重新计算历史拨付资格。 + Sql sql = Sqls.create(""" + SELECT + c.*, + COALESCE(pay_stats.historyGiveMoneyNum, 0) AS historyGiveMoneyNum, + COALESCE(pay_stats.historyNotGiveMoneyNum, 0) AS historyNotGiveMoneyNum, + COALESCE(pay_stats.historyUserNum, 0) AS historyUserNum + FROM + sys_club c + LEFT JOIN ( + SELECT + re.clubId, + COUNT(CASE WHEN re.isGiveMoney = 1 THEN 1 END) AS historyGiveMoneyNum, + COUNT(CASE WHEN re.isGiveMoney = 0 THEN 1 END) AS historyNotGiveMoneyNum, + COUNT(*) AS historyUserNum + FROM + club_pay_record re + WHERE + re.year = @historyYear + GROUP BY + re.clubId + ) pay_stats ON pay_stats.clubId = c.id + WHERE + EXISTS ( + SELECT 1 FROM wf_process_instance inst + WHERE inst.businessNo = c.id AND inst.state = 20 + ) + ORDER BY c.id; + """).setParam("historyYear", year - 1); + List clubList = listMap(sql); + Set lockedClubs = clubs.stream().map(club -> club.getString("id")).collect(Collectors.toSet()); + List budgets = new ArrayList<>(); + for (NutMap club : clubList) { + String clubId = club.getString("id"); + // 新增社团若在候选锁定后完成审批,则留到下次处理;既有预算不得重置或覆盖。 + if (!lockedClubs.contains(clubId) || !recordedClubs.add(clubId)) continue; + OutlayManageClub budget = new OutlayManageClub(); + budget.setYear(year); + budget.setClubId(clubId); + budget.setClubName(club.getString("clubName")); + budget.setClubCode(club.getString("clubCode")); + budget.setHistoryUserNum(club.getInt("historyUserNum")); + budget.setHistoryGiveMoneyNum(club.getInt("historyGiveMoneyNum")); + budget.setHistoryNotGiveMoneyNum(club.getInt("historyNotGiveMoneyNum")); + budget.setTotalQuota(getAllocateStandard(club.getInt("historyGiveMoneyNum"))); + budgets.add(budget); + } + if (!budgets.isEmpty()) dao().insert(budgets); + return budgets.size(); + } + @Aop(TransAop.READ_COMMITTED) @Override public void doMoney(String clubId) { diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseApplyController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseApplyController.java index 509f649..fe93d84 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseApplyController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseApplyController.java @@ -27,6 +27,7 @@ import com.budwk.app.zhgh.staffbenefit.condolence.service.CondolenceTypeService; import com.budwk.app.zhgh.dayofficework.unionReimburse.model.UnionReimburse; import com.budwk.app.zhgh.staffbenefit.condolence.model.CondolenceType; import com.budwk.app.zhgh.dayofficework.unionReimburse.service.UnionReimburseService; +import com.budwk.app.zhgh.dayofficework.unionReimburse.service.UnionReimburseBankAccountService; import com.budwk.app.zhgh.dayofficework.unionReimburse.vo.UnionReimburseBankHistoryVO; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; @@ -45,6 +46,7 @@ import org.nutz.lang.util.NutMap; import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Param; +import org.nutz.mvc.annotation.POST; import java.math.BigDecimal; import java.math.RoundingMode; @@ -65,6 +67,8 @@ public class UnionReimburseApplyController { @Inject private UnionReimburseService unionReimburseService; @Inject + private UnionReimburseBankAccountService unionReimburseBankAccountService; + @Inject private FlowCommonService flowCommonService; @Inject private CondolenceTypeService typeService; @@ -83,6 +87,7 @@ public class UnionReimburseApplyController { @At @ApiOperation("保存申请") + @Aop(TransAop.READ_COMMITTED) @SaCheckPermission(value = {"unionReimburse.apply", "h5.unionReimburse.apply"}, mode = SaMode.OR) @SLog(type = "unionReimburse", tag = "工会报销-报销申请", msg = "保存报销申请") public Result save(@Param("data") UnionReimburse unionReimburse) { @@ -204,6 +209,31 @@ public class UnionReimburseApplyController { return Result.success(result); } + /** + * @param payer 付款人用户ID,不传姓名或工号 + * @param reimburseFundSource 当前经费来源编码 + * @param clubId 协会经费对应的协会ID,其他来源可为空 + * @return Result.data 为账户列表,每项包含 id、bankUserName、bankCardNumber、bankOfDeposit + */ + @At + @POST + @ApiOperation("查询付款人常用收款账户") + @SaCheckPermission(value = {"unionReimburse.apply", "h5.unionReimburse.apply"}, mode = SaMode.OR) + public Result listBankAccounts(String payer, String reimburseFundSource, String clubId) { + return Result.success(unionReimburseBankAccountService.listAccounts(payer, reimburseFundSource, clubId)); + } + + /** 无请求参数;仅系统管理员执行。Result.data 为扫描的历史单据数量,重复调用不会新增重复账户。 */ + @At + @POST + @ApiOperation("初始化历史收款账户") + @SaCheckLogin + @Aop(TransAop.READ_COMMITTED) + @SLog(type = "unionReimburse", tag = "历史收款账户初始化", msg = "导入历史报销收款账户") + public Result importBankAccountHistory() { + return Result.success(unionReimburseBankAccountService.importHistory()); + } + @At @ApiOperation("校验发票号码是否重复") @SaCheckPermission(value = {"unionReimburse.apply", "h5.unionReimburse.apply"}, mode = SaMode.OR) @@ -212,8 +242,13 @@ public class UnionReimburseApplyController { return result == null ? Result.success() : result; } + /** + * @param fileId 上传接口返回的文件ID,服务端读取对应发票文件 + * @param verifySwitch 旧页面兼容参数,任何取值均强制验真 + * @return Result,code=0 时 data 为票号、金额、销售方、项目及 verified=true;非零时 msg 为失败原因 + */ @At - @ApiOperation("自动识别发票信息") + @ApiOperation("识别发票并强制验真") @SaCheckPermission(value = {"unionReimburse.apply", "h5.unionReimburse.apply"}, mode = SaMode.OR) public Result recognizeInvoice(String fileId, @Param("verifySwitch") Boolean verifySwitch) { return unionReimburseService.recognizeInvoice(fileId, verifySwitch); diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseCollectController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseCollectController.java index 40e7765..4c88fba 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseCollectController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseCollectController.java @@ -22,6 +22,7 @@ import com.budwk.app.web.commons.auth.utils.AuthUtil; import com.budwk.app.web.commons.auth.utils.SecurityUtil; import com.budwk.app.zhgh.dayofficework.unionReimburse.model.UnionReimburse; import com.budwk.app.zhgh.dayofficework.unionReimburse.service.UnionReimburseService; +import com.budwk.app.zhgh.dayofficework.unionReimburse.service.UnionReimburseExportService; import com.budwk.app.zhgh.dayofficework.unionReimburse.vo.UnionReimburseCollectExcelVO; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; @@ -64,6 +65,9 @@ public class UnionReimburseCollectController { @Inject private UnionReimburseService unionReimburseService; + @Inject + private UnionReimburseExportService unionReimburseExportService; + @At("/index") @Ok("beetl:/platform/zhgh/dayofficework/unionReimburse/collect/index.html") @SaCheckPermission("unionReimburse.collect") @@ -134,6 +138,20 @@ public class UnionReimburseCollectController { return unionReimburseService.updateReimburseProject(id, reimburseProject); } + /** + * 汇总页打印复用审批单 PDF 导出,支持所有申请状态,数据范围由 service 校验。 + * @param id 汇总页选中的报销单 ID,不受申请状态限制 + * @param response 文件下载响应,返回完整 PDF 附件;失败由统一异常处理返回业务提示 + */ + @At + @Ok("void") + @ApiOperation("打印报销审批单PDF") + @SaCheckPermission("unionReimburse.collect") + public void exportPdf(@Param("id") String id, HttpServletResponse response) { + byte[] pdf = unionReimburseExportService.exportCollectPdf(id); + CommonDownloadUtil.download("工会经费报销审批单_" + id + ".pdf", pdf, response); + } + @At @Ok("void") @SaCheckPermission(value = {"unionReimburse.collect", "h5.unionReimburse.collect"}, mode = SaMode.OR) @@ -250,9 +268,9 @@ public class UnionReimburseCollectController { item.setStateName(formatStateName(item.getStateId())); // 备注字段与页面表格保持一致:慰问显示被慰问人,其他项目显示活动名称。 - if (cn.hutool.core.util.StrUtil.isNotBlank(item.getActivityName())) { + if (StrUtil.isNotBlank(item.getActivityName())) { item.setRemark("活动名称:" + item.getActivityName()); - } else if (cn.hutool.core.util.StrUtil.isNotBlank(item.getCondolenceUserName())) { + } else if (StrUtil.isNotBlank(item.getCondolenceUserName())) { item.setRemark("被慰问人:" + item.getCondolenceUserName()); } }); diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseMineController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseMineController.java index bf4572d..58e737c 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseMineController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/controller/UnionReimburseMineController.java @@ -1,62 +1,39 @@ package com.budwk.app.zhgh.dayofficework.unionReimburse.controller; -import cn.dev33.satoken.annotation.SaCheckLogin; import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaMode; -import cn.hutool.core.date.DateUtil; -import cn.hutool.core.io.FileUtil; -import cn.hutool.core.io.IoUtil; import cn.hutool.core.util.StrUtil; import com.budwk.app.base.annotation.SLog; -import com.budwk.app.base.model.Audit; import com.budwk.app.base.constant.RoleConstant; import com.budwk.app.base.page.Pagination; import com.budwk.app.base.param.PageForm; import com.budwk.app.base.result.Result; import com.budwk.app.base.utils.CommonDownloadUtil; -import com.budwk.app.base.utils.MoneyUtil; -import com.budwk.app.base.utils.OfficePlusUtil; import com.budwk.app.base.utils.PageUtil; -import com.budwk.app.base.utils.SysOfficeTemplateUtil; import com.budwk.app.flow.engine.FlowEngine; import com.budwk.app.web.commons.auth.utils.AuthUtil; import com.budwk.app.web.commons.auth.utils.SecurityUtil; import com.budwk.app.zhgh.dayofficework.unionReimburse.model.UnionReimburse; import com.budwk.app.zhgh.dayofficework.unionReimburse.service.UnionReimburseService; -import com.deepoove.poi.XWPFTemplate; -import com.deepoove.poi.config.Configure; -import com.deepoove.poi.data.PictureRenderData; -import com.google.zxing.BarcodeFormat; -import com.google.zxing.client.j2se.MatrixToImageWriter; -import com.google.zxing.common.BitMatrix; -import com.google.zxing.qrcode.QRCodeWriter; +import com.budwk.app.zhgh.dayofficework.unionReimburse.service.UnionReimburseExportService; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.io.IOUtils; import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; import org.nutz.dao.Sqls; -import org.nutz.dao.entity.Record; import org.nutz.dao.sql.Sql; import org.nutz.dao.util.cri.SqlExpressionGroup; import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; -import org.nutz.lang.Strings; -import org.nutz.lang.random.R; import org.nutz.lang.util.NutMap; import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Param; import javax.servlet.http.HttpServletResponse; -import java.awt.image.BufferedImage; -import java.io.*; -import java.nio.file.Files; -import java.nio.file.StandardOpenOption; -import java.util.HashMap; /** * @ClassName UnionReimburseMineController @@ -73,11 +50,11 @@ import java.util.HashMap; public class UnionReimburseMineController { - @Inject - private SysOfficeTemplateUtil sysOfficeTemplateUtil; @Inject private UnionReimburseService unionReimburseService; @Inject + private UnionReimburseExportService unionReimburseExportService; + @Inject private FlowEngine flowEngine; @Inject private Dao dao; @@ -206,258 +183,17 @@ public class UnionReimburseMineController { } + /** + * @param id 当前登录人的已提交报销单ID + * @param response 返回PDF附件;失败由统一异常处理返回业务提示 + */ @At @Ok("void") - public void ActExport(String id, HttpServletResponse response, boolean Print) throws Exception { - UnionReimburse unionReimburse = unionReimburseService.fetch(id); - if (unionReimburse == null) { - throw new RuntimeException("报销记录不存在"); - } - HashMap docData = new HashMap<>(); - - QRCodeWriter qrCodeWriter = new QRCodeWriter(); - BitMatrix bitMatrix = qrCodeWriter.encode(unionReimburse.getDocumentNo(), BarcodeFormat.QR_CODE, 80, 80); - BufferedImage bufferedImage = MatrixToImageWriter.toBufferedImage(bitMatrix); - File tempFile = File.createTempFile("qrcode_", ".png"); - javax.imageio.ImageIO.write(bufferedImage, "PNG", tempFile); - PictureRenderData pictureRenderData = com.deepoove.poi.data.Pictures.of(tempFile.getAbsolutePath()) - .size(80, 80) - .create(); - docData.put("qrcode", pictureRenderData); - - tempFile.deleteOnExit(); - - docData.put("createTime", DateUtil.format(unionReimburse.getCreateTime(), "yyyy-MM-dd HH:mm:ss")); - docData.put("unitName", unionReimburse.getUnitName()); - docData.put("userName", unionReimburse.getUserName()); - docData.put("loginName", unionReimburse.getLoginName()); - docData.put("mobile", unionReimburse.getMobile()); - docData.put("paymentNotes", unionReimburse.getPaymentNotes()); - docData.put("invoiceNumber", unionReimburse.getInvoiceNumber()); - docData.put("notes", unionReimburse.getNotes()); - docData.put("realMoney", unionReimburse.getRealMoney()); - docData.put("reimburseProject", unionReimburse.getReimburseProject()); - docData.put("paymentWay", unionReimburse.getPaymentWay()); - //对私支付,付款人信息 - docData.put("payerLoginname", unionReimburse.getPayerLoginname()); - docData.put("payerName", unionReimburse.getPayerName()); - docData.put("bankOfDeposit", unionReimburse.getBankOfDeposit()); - docData.put("bankCardNumber", unionReimburse.getBankCardNumber()); - - // 金额转大写 - if (unionReimburse.getRealMoney() != null) { - docData.put("money_big", MoneyUtil.toRMBUpper(String.valueOf(unionReimburse.getRealMoney()))); - } else { - docData.put("money_big", ""); - } - - // 设置报销项目类型标记 - String wtAct = "□文体活动 □慰问 □日常活动 □专项活动"; - if ("UNION_REIMBURSE_PROJECT_2".equals(unionReimburse.getReimburseProject())) { - wtAct = wtAct.replace("□文体活动", "√文体活动"); - } else if ("UNION_REIMBURSE_PROJECT_3".equals(unionReimburse.getReimburseProject())) { - wtAct = wtAct.replace("□日常活动", "√日常活动"); - } else if ("UNION_REIMBURSE_PROJECT_4".equals(unionReimburse.getReimburseProject())) { - wtAct = wtAct.replace("□专项活动", "√专项活动"); - } else if ("UNION_REIMBURSE_PROJECT_1".equals(unionReimburse.getReimburseProject())) { - wtAct = wtAct.replace("□慰问", "√慰问"); - } - docData.put("bxxm", wtAct); - String templateName; - Double realMoney = unionReimburse.getRealMoney() != null ? unionReimburse.getRealMoney() : 0.0; - String paymentWay = unionReimburse.getPaymentWay(); - - if (("UNION_REIMBURSE_PAYMENT_WAY_2").equals(paymentWay)) { // 对私支付 - docData.put("paymentWay", "对私支付"); - if (realMoney < 5000) { - templateName = "unionReimburse_act_private_small"; - } else { - templateName = "unionReimburse_act_private_big"; - } - } else { // 对公支付 - docData.put("paymentWay", "对公支付"); - if (realMoney < 5000) { - templateName = "unionReimburse_act_public_small"; - } else { - templateName = "unionReimburse_act_public_big"; - } - } - - if (Print) { - exportAsPDF(templateName, docData, unionReimburse, response); - } else { - exportAsWord(templateName, docData, unionReimburse, response); - } + @ApiOperation("导出报销审批单PDF") + @SaCheckPermission("unionReimburse.mine") + public void exportPdf(@Param("id") String id, HttpServletResponse response) { + byte[] pdf = unionReimburseExportService.exportPdf(id); + CommonDownloadUtil.download("工会经费报销审批单_" + id + ".pdf", pdf, response); } - @At - @Ok("void") - public void ConExport(String id, HttpServletResponse response, boolean Print) throws Exception { - UnionReimburse unionReimburse = unionReimburseService.fetch(id); - if (unionReimburse == null) { - throw new RuntimeException("报销记录不存在"); - } - HashMap docData = new HashMap<>(); - - QRCodeWriter qrCodeWriter = new QRCodeWriter(); - BitMatrix bitMatrix = qrCodeWriter.encode(unionReimburse.getDocumentNo(), BarcodeFormat.QR_CODE, 80, 80); - BufferedImage bufferedImage = MatrixToImageWriter.toBufferedImage(bitMatrix); - File tempFile = File.createTempFile("qrcode_", ".png"); - javax.imageio.ImageIO.write(bufferedImage, "PNG", tempFile); - PictureRenderData pictureRenderData = com.deepoove.poi.data.Pictures.of(tempFile.getAbsolutePath()) - .size(80, 80) - .create(); - docData.put("qrcode", pictureRenderData); - - docData.put("createTime", DateUtil.format(unionReimburse.getCreateTime(), "yyyy-MM-dd HH:mm:ss")); - docData.put("unitName", unionReimburse.getUnitName()); - docData.put("userName", unionReimburse.getUserName()); - docData.put("loginName", unionReimburse.getLoginName()); - docData.put("mobile", unionReimburse.getMobile()); - docData.put("paymentNotes", unionReimburse.getPaymentNotes()); - docData.put("invoiceNumber", unionReimburse.getInvoiceNumber()); - docData.put("notes", unionReimburse.getNotes()); - docData.put("realMoney", unionReimburse.getRealMoney()); - docData.put("reimburseProject", unionReimburse.getReimburseProject()); - docData.put("paymentWay", unionReimburse.getPaymentWay()); - //对私支付,付款人信息 - docData.put("payerLoginname", unionReimburse.getPayerLoginname()); - docData.put("payerName", unionReimburse.getPayerName()); - docData.put("bankOfDeposit", unionReimburse.getBankOfDeposit()); - docData.put("bankCardNumber", unionReimburse.getBankCardNumber()); - - //慰问 - docData.put("condolenceUserName", unionReimburse.getCondolenceUserName()); - docData.put("condolenceLoginName", unionReimburse.getCondolenceLoginName()); - docData.put("typeName", unionReimburse.getTypeName()); - docData.put("way", unionReimburse.getWay()); - docData.put("condolenceTime", DateUtil.format(unionReimburse.getCondolenceTime(), "yyyy-MM-dd")); - docData.put("participants", unionReimburse.getParticipants()); - //结婚 - docData.put("marryTime", DateUtil.format(unionReimburse.getMarryTime(), "yyyy-MM-dd")); - //生育 - docData.put("fertilityTime", DateUtil.format(unionReimburse.getFertilityTime(), "yyyy-MM-dd")); - //生病住院 - docData.put("hospitalCausation", unionReimburse.getHospitalCausation()); - docData.put("hospital", unionReimburse.getHospital()); - docData.put("hospitalCount", unionReimburse.getHospitalCount()); - String hospitalTime = DateUtil.format(unionReimburse.getHospitalizationStartTime(), "yyyy-MM-dd") + - " 至 " + - DateUtil.format(unionReimburse.getHospitalizationEndTime(), "yyyy-MM-dd"); - docData.put("hospitalTime", hospitalTime); - //去世 - docData.put("deathTime", DateUtil.format(unionReimburse.getDeathTime(), "yyyy-MM-dd")); - docData.put("condolenceRelationship", unionReimburse.getCondolenceRelationship()); - - - // 金额转大写 - if (unionReimburse.getRealMoney() != null) { - docData.put("money_big", MoneyUtil.toRMBUpper(String.valueOf(unionReimburse.getRealMoney()))); - } else { - docData.put("money_big", ""); - } - - // 设置报销项目类型标记 - String wtAct = "□文体活动 □慰问 □日常活动 □专项活动"; - if ("UNION_REIMBURSE_PROJECT_2".equals(unionReimburse.getReimburseProject())) { - wtAct = wtAct.replace("□文体活动", "√文体活动"); - } else if ("UNION_REIMBURSE_PROJECT_3".equals(unionReimburse.getReimburseProject())) { - wtAct = wtAct.replace("□日常活动", "√日常活动"); - } else if ("UNION_REIMBURSE_PROJECT_4".equals(unionReimburse.getReimburseProject())) { - wtAct = wtAct.replace("□专项活动", "√专项活动"); - } else if ("UNION_REIMBURSE_PROJECT_1".equals(unionReimburse.getReimburseProject())) { - wtAct = wtAct.replace("□慰问", "√慰问"); - } - docData.put("bxxm", wtAct); - String templateName; - Double realMoney = unionReimburse.getRealMoney() != null ? unionReimburse.getRealMoney() : 0.0; - String paymentWay = unionReimburse.getPaymentWay(); - - if (("UNION_REIMBURSE_PAYMENT_WAY_2").equals(paymentWay)) { // 对私支付 - docData.put("paymentWay", "对私支付"); - if (("生病住院").equals(unionReimburse.getTypeName())) { - templateName = "unionReimburse_con_private_hospital"; - } else if (("结婚").equals(unionReimburse.getTypeName())) { - templateName = "unionReimburse_con_private_mary"; - } else if (("生育").equals(unionReimburse.getTypeName())) { - templateName = "unionReimburse_con_private_birth"; - } else if (("会员去世").equals(unionReimburse.getTypeName()) || ("直系亲属去世").equals(unionReimburse.getTypeName())) { - if (realMoney < 5000) { - templateName = "unionReimburse_con_private_dead_small"; - } else { - templateName = "unionReimburse_con_private_dead_big"; - } - } else {//其他 - templateName = "unionReimburse_con_private_other"; - } - } else { // 对公支付 - docData.put("paymentWay", "对公支付"); - if (("生病住院").equals(unionReimburse.getTypeName())) { - templateName = "unionReimburse_con_public_hospital"; - } else if (("结婚").equals(unionReimburse.getTypeName())) { - templateName = "unionReimburse_con_public_mary"; - } else if (("生育").equals(unionReimburse.getTypeName())) { - templateName = "unionReimburse_con_public_birth"; - } else if (("会员去世").equals(unionReimburse.getTypeName()) || ("直系亲属去世").equals(unionReimburse.getTypeName())) { - if (realMoney < 5000) { - templateName = "unionReimburse_con_public_dead_small"; - } else { - templateName = "unionReimburse_con_public_dead_big"; - } - } else {//其他 - templateName = "unionReimburse_con_public_other"; - } - } - - if (Print) { - exportAsPDF(templateName, docData, unionReimburse, response); - } else { - exportAsWord(templateName, docData, unionReimburse, response); - } - } - - // 导出PDF - @At - private void exportAsPDF(String templateName, HashMap docData, - UnionReimburse unionReimburse, HttpServletResponse response) throws Exception { - String fileName = "中国地质大学(武汉)工会经费日常报销_" + unionReimburse.getUserName() + "_" + - DateUtil.format(unionReimburse.getCreateTime(), "yyyyMMdd") + ".pdf"; - - try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) { - XWPFTemplate.compile(sysOfficeTemplateUtil.getTemplate(templateName)) - .render(docData) - .writeAndClose(byteArrayOutputStream); - byte[] bytes = byteArrayOutputStream.toByteArray(); - File sourceFile = File.createTempFile("file_convert_origin", ".docx"); - Files.write(sourceFile.toPath(), bytes, StandardOpenOption.WRITE); - - File pdfFile = File.createTempFile("file_convert", ".pdf"); - OfficePlusUtil.convert(sourceFile.getPath(), pdfFile.getPath()); - CommonDownloadUtil.download(fileName, IoUtil.readBytes(FileUtil.getInputStream(pdfFile)), response); - // 删除临时文件 - FileUtil.del(sourceFile); - FileUtil.del(pdfFile.toPath()); - } catch (IOException e) { - log.error("工会报销单据导出PDF失败,id:{},错误信息:{}", unionReimburse.getId(), e.getMessage()); - } - } - - // 导出Word - @At - private void exportAsWord(String templateName, HashMap docData, - UnionReimburse unionReimburse, HttpServletResponse response) throws Exception { - String fileName = "中国地质大学(武汉)工会经费日常报销_" + unionReimburse.getUserName() + "_" + - DateUtil.format(unionReimburse.getCreateTime(), "yyyyMMdd") + ".docx"; - - try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) { - XWPFTemplate.compile(sysOfficeTemplateUtil.getTemplate(templateName)) - .render(docData) - .writeAndClose(byteArrayOutputStream); - CommonDownloadUtil.download(fileName, byteArrayOutputStream.toByteArray(), response); - } catch (IOException e) { - log.error("工会报销单据导出Word失败,id:{},错误信息:{}", unionReimburse.getId(), e.getMessage()); - } - } - - } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/model/UnionReimburse.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/model/UnionReimburse.java index 665ee42..79b342c 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/model/UnionReimburse.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/model/UnionReimburse.java @@ -279,6 +279,9 @@ public class UnionReimburse extends BaseModel { @ColDefine(type = ColType.VARCHAR, width = 32) private String condolenceTypeId; + /** 慰问类型业务编码,仅供申请和详情回显控制字段显示,不映射数据库列。 */ + private String condolenceTypeCode; + @Column @Comment("慰问类型方式") @ColDefine(type = ColType.VARCHAR, width = 32) diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/model/UnionReimburseBankAccount.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/model/UnionReimburseBankAccount.java new file mode 100644 index 0000000..e42b542 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/model/UnionReimburseBankAccount.java @@ -0,0 +1,42 @@ +package com.budwk.app.zhgh.dayofficework.unionReimburse.model; + +import com.budwk.app.base.model.BaseModel; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.nutz.dao.entity.annotation.*; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +/** 付款人常用收款账户;报销单仍独立保留本次支付资料快照。 */ +@Data +@EqualsAndHashCode(callSuper = true) +@Table("union_reimburse_bank_account") +@TableMeta("{'mysql-charset':'utf8mb4'}") +@TableIndexes(@Index(name = "uk_reimburse_bank_account", fields = { + "payer", "bankUserName", "bankCardNumber", "bankOfDeposit" +}, unique = true)) +public class UnionReimburseBankAccount extends BaseModel { + @Name + @ColDefine(type = ColType.VARCHAR, width = 32) + @PrevInsert(uu32 = true) + private String id; + + @Column + @Comment("付款人用户ID") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String payer; + + @Column + @Comment("户名") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String bankUserName; + + @Column + @Comment("银行卡号,保留字符串格式") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String bankCardNumber; + + @Column + @Comment("开户行") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String bankOfDeposit; +} diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/model/UnionReimburseInvoiceDetail.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/model/UnionReimburseInvoiceDetail.java index a526f7b..975395f 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/model/UnionReimburseInvoiceDetail.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/model/UnionReimburseInvoiceDetail.java @@ -44,11 +44,26 @@ public class UnionReimburseInvoiceDetail extends BaseModel { @ColDefine(type = ColType.VARCHAR, width = 100) private String invoiceNo; + @Column + @Comment("开票日期,格式 yyyy-MM-dd") + @ColDefine(type = ColType.VARCHAR, width = 10) + private String invoiceDate; + @Column @Comment("发票金额") @ColDefine(type = ColType.FLOAT, width = 10, precision = 2) private Double invoiceAmount; + @Column + @Comment("税前金额") + @ColDefine(type = ColType.FLOAT, width = 10, precision = 2) + private Double amountWithoutTax; + + @Column + @Comment("合计税额") + @ColDefine(type = ColType.FLOAT, width = 10, precision = 2) + private Double taxAmount; + @Column @Comment("销售方信息名称") @ColDefine(type = ColType.VARCHAR, width = 200) diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseBankAccountService.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseBankAccountService.java new file mode 100644 index 0000000..0dc6995 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseBankAccountService.java @@ -0,0 +1,23 @@ +package com.budwk.app.zhgh.dayofficework.unionReimburse.service; + +import com.budwk.app.base.service.BaseService; +import com.budwk.app.zhgh.dayofficework.unionReimburse.model.UnionReimburse; +import com.budwk.app.zhgh.dayofficework.unionReimburse.model.UnionReimburseBankAccount; +import org.nutz.lang.util.NutMap; +import java.util.List; + +public interface UnionReimburseBankAccountService extends BaseService { + /** + * @param payer 付款人用户ID,非姓名或工号 + * @param reimburseFundSource 当前经费来源字典编码 + * @param clubId 协会经费对应的协会ID,其他来源可为空 + * @return 可选账户列表,每项包含 id、bankUserName、bankCardNumber、bankOfDeposit + */ + List listAccounts(String payer, String reimburseFundSource, String clubId); + + /** @param form 已通过校验的报销单;提取全部对私支付明细,去重保存完整账户。 */ + void saveAccounts(UnionReimburse form); + + /** @return 扫描的历史报销单数量;仅系统管理员可执行,可重复执行且不覆盖已有账户。 */ + int importHistory(); +} diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseExportService.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseExportService.java new file mode 100644 index 0000000..fa63f76 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseExportService.java @@ -0,0 +1,26 @@ +package com.budwk.app.zhgh.dayofficework.unionReimburse.service; + +import com.budwk.app.zhgh.dayofficework.unionReimburse.model.UnionReimburse; +import java.util.Map; + +/** 工会报销活动及慰问审批单共用的导出服务。 */ +public interface UnionReimburseExportService { + /** + * @param id 当前登录人已提交的报销单ID,不接受其他人的单据或待提交单据 + * @return 完整PDF字节数组,模板或转换失败时抛出业务异常 + */ + byte[] exportPdf(String id); + + /** + * 汇总页所有状态的报销单均可打印,必须具有汇总权限并符合所属工会数据范围。 + * @param id 汇总页选中的报销单 ID,可为待提交或其他申请状态 + * @return 按慰问类型选择模板生成的完整 PDF 字节数组,活动沿用原模板;无权限或生成失败时抛出业务异常 + */ + byte[] exportCollectPdf(String id); + + /** + * @param form 已加载发票明细的报销单,支付明细为空时兼容旧主表字段 + * @return 活动及慰问模板通用的平铺字段及 payments、invoices 循环行 + */ + Map buildExportData(UnionReimburse form); +} diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseService.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseService.java index 7bac156..138f5a0 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseService.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/UnionReimburseService.java @@ -50,7 +50,10 @@ public interface UnionReimburseService extends BaseService { Result checkInvoiceDuplicate(String invoiceNo, String reimburseId); /** - * 识别单张发票文件,并在需要时调用验真接口补齐票面信息。 + * 识别单张发票文件并强制验真。 + * @param fileId 已上传发票的文件ID + * @param verifySwitch 兼容旧页面参数,不再允许关闭验真 + * @return Result.data 包含票号、金额、销售方、项目名称及 verified=true;失败返回非零 code 和原因 */ Result recognizeInvoice(String fileId, Boolean verifySwitch); diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseBankAccountServiceImpl.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseBankAccountServiceImpl.java new file mode 100644 index 0000000..b81d29c --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseBankAccountServiceImpl.java @@ -0,0 +1,128 @@ +package com.budwk.app.zhgh.dayofficework.unionReimburse.service.impl; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONObject; +import com.budwk.app.base.constant.RoleConstant; +import com.budwk.app.base.exception.BaseException; +import com.budwk.app.base.service.impl.BaseServiceImpl; +import com.budwk.app.sys.views.View_user; +import com.budwk.app.web.commons.auth.utils.AuthUtil; +import com.budwk.app.web.commons.auth.utils.SecurityUtil; +import com.budwk.app.zhgh.club.model.ClubUser; +import com.budwk.app.zhgh.dayofficework.unionReimburse.model.UnionReimburse; +import com.budwk.app.zhgh.dayofficework.unionReimburse.model.UnionReimburseBankAccount; +import com.budwk.app.zhgh.dayofficework.unionReimburse.service.UnionReimburseBankAccountService; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.dao.Cnd; +import org.nutz.dao.Dao; +import org.nutz.dao.Sqls; +import org.nutz.dao.sql.Sql; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.lang.util.NutMap; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +@IocBean(args = {"refer:dao"}) +public class UnionReimburseBankAccountServiceImpl extends BaseServiceImpl + implements UnionReimburseBankAccountService { + public UnionReimburseBankAccountServiceImpl(Dao dao) { + super(dao); + } + + /** 按当前付款人候选范围校验用户ID,防止直接传入其他人员ID读取账户。 */ + @Override + public List listAccounts(String payer, String reimburseFundSource, String clubId) { + if (StrUtil.isBlank(payer)) { + return Collections.emptyList(); + } + View_user user = dao().fetch(View_user.class, Cnd.where("id", "=", payer)); + if (user == null) { + return Collections.emptyList(); + } + if ("UNION_REIMBURSE_FUND_SOURCE_3".equals(reimburseFundSource)) { + if (StrUtil.isBlank(clubId) || dao().count(ClubUser.class, + Cnd.where("clubId", "=", clubId).and("userId", "=", payer)) == 0) { + return Collections.emptyList(); + } + } else if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name()) + && (StrUtil.isBlank(SecurityUtil.getUnionId()) + || !Objects.equals(user.getUnionId(), SecurityUtil.getUnionId()))) { + return Collections.emptyList(); + } + Sql sql = Sqls.create("SELECT id, bankUserName, bankCardNumber, bankOfDeposit " + + "FROM union_reimburse_bank_account WHERE payer = @payer AND delFlag = 0 " + + "ORDER BY createdAt DESC, id DESC"); + sql.params().set("payer", payer); + return listMap(sql); + } + + /** 保存全部对私明细,账户写入与调用方的报销保存共用事务。 */ + @Override + @Aop(TransAop.READ_COMMITTED) + public void saveAccounts(UnionReimburse form) { + if (form == null) { + return; + } + if (form.getPaymentDetails() != null && !form.getPaymentDetails().isEmpty()) { + for (JSONObject detail : form.getPaymentDetails()) { + if (detail != null && "UNION_REIMBURSE_PAYMENT_WAY_2".equals(detail.getStr("paymentWay"))) { + saveAccount(detail.getStr("payer"), detail.getStr("bankUserName"), + detail.getStr("bankCardNumber"), detail.getStr("bankOfDeposit")); + } + } + } else if ("UNION_REIMBURSE_PAYMENT_WAY_2".equals(form.getPaymentWay())) { + // 仅无支付明细的旧单据使用主表字段,避免导入已过时的第一条账户副本。 + saveAccount(form.getPayer(), form.getBankUserName(), form.getBankCardNumber(), form.getBankOfDeposit()); + } + } + + /** 过滤不完整历史资料;唯一索引配合原子插入,防止并发报销生成重复账户。 */ + @Aop(TransAop.READ_COMMITTED) + protected void saveAccount(String payer, String name, String card, String bank) { + payer = StrUtil.trim(payer); + name = StrUtil.trim(name); + card = StrUtil.trim(card); + bank = StrUtil.trim(bank); + if (StrUtil.hasBlank(payer, name, card, bank) || payer.length() > 100 || name.length() > 100 + || card.length() > 100 || bank.length() > 100 || !card.matches("[1-9]([0-9]{15}|[0-9]{18})")) { + return; + } + Sql sql = Sqls.create(""" + INSERT INTO union_reimburse_bank_account + (id, payer, bankUserName, bankCardNumber, bankOfDeposit, + createdBy, createdAt, updatedBy, updatedAt, delFlag) + VALUES (@id, @payer, @name, @card, @bank, @userId, @now, @userId, @now, 0) + ON DUPLICATE KEY UPDATE id = id + """); + sql.params().set("id", UUID.randomUUID().toString().replace("-", "")); + sql.params().set("payer", payer).set("name", name).set("card", card).set("bank", bank); + sql.params().set("userId", SecurityUtil.getUserId()).set("now", System.currentTimeMillis()); + dao().execute(sql); + } + + /** 管理员一次性初始化;按ID分批读取,避免一次加载全部历史单据。 */ + @Override + @Aop(TransAop.READ_COMMITTED) + public int importHistory() { + if (!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name())) { + throw new BaseException("仅系统管理员可初始化历史收款账户"); + } + int processed = 0; + String lastId = ""; + while (true) { + List records = dao().query(UnionReimburse.class, + Cnd.where("id", ">", lastId).and("delFlag", "=", false).asc("id"), dao().createPager(1, 200)); + if (records.isEmpty()) { + return processed; + } + for (UnionReimburse record : records) { + saveAccounts(record); + } + processed += records.size(); + lastId = records.get(records.size() - 1).getId(); + } + } +} diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseExportServiceImpl.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseExportServiceImpl.java new file mode 100644 index 0000000..6cca083 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseExportServiceImpl.java @@ -0,0 +1,387 @@ +package com.budwk.app.zhgh.dayofficework.unionReimburse.service.impl; + +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONObject; +import com.budwk.app.base.constant.RoleConstant; +import com.budwk.app.web.commons.auth.utils.AuthUtil; +import org.nutz.dao.Cnd; +import org.nutz.dao.Dao; +import com.budwk.app.base.exception.BaseException; +import com.budwk.app.base.utils.MoneyUtil; +import com.budwk.app.base.utils.OfficePlusUtil; +import com.budwk.app.base.utils.SysOfficeTemplateUtil; +import com.budwk.app.web.commons.auth.utils.SecurityUtil; +import com.budwk.app.zhgh.dayofficework.unionReimburse.model.UnionReimburse; +import com.budwk.app.zhgh.dayofficework.unionReimburse.model.UnionReimburseInvoiceDetail; +import com.budwk.app.zhgh.dayofficework.unionReimburse.service.UnionReimburseExportService; +import com.budwk.app.zhgh.dayofficework.unionReimburse.service.UnionReimburseService; +import com.budwk.app.zhgh.staffbenefit.condolence.model.CondolenceType; +import com.deepoove.poi.XWPFTemplate; +import com.deepoove.poi.config.Configure; +import com.deepoove.poi.plugin.table.LoopRowTableRenderPolicy; +import com.deepoove.poi.data.PictureType; +import com.deepoove.poi.data.Pictures; +import com.deepoove.poi.template.ElementTemplate; +import com.google.zxing.*; +import com.google.zxing.client.j2se.BufferedImageLuminanceSource; +import com.google.zxing.client.j2se.MatrixToImageWriter; +import com.google.zxing.common.HybridBinarizer; +import com.google.zxing.qrcode.QRCodeWriter; +import com.google.zxing.qrcode.QRCodeReader; +import org.apache.poi.xwpf.usermodel.*; +import org.apache.poi.util.Units; +import javax.imageio.ImageIO; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import lombok.extern.slf4j.Slf4j; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import java.io.InputStream; +import java.io.OutputStream; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; + +@Slf4j +@IocBean +public class UnionReimburseExportServiceImpl implements UnionReimburseExportService { + public static final String ACTIVITY_TEMPLATE = "union_reimburse_activity_pdf"; + public static final String CONDOLENCE_TEMPLATE = "union_reimburse_condolence_pdf"; + // 使用慰问类型业务编码匹配模板,避免类型名称调整后选择错误;编码与现有申请页一致。 + private static final Map COLLECT_CONDOLENCE_TEMPLATES = Map.of( + "A", "union_reimburse_condolence_marriage_pdf", + "D", "union_reimburse_condolence_birth_pdf", + "E", "union_reimburse_condolence_hospital_pdf", + "G", "union_reimburse_condolence_member_death_pdf", + "H", "union_reimburse_condolence_relative_death_pdf"); + @Inject + private UnionReimburseService unionReimburseService; + @Inject + private SysOfficeTemplateUtil sysOfficeTemplateUtil; + @Inject + private Dao dao; + + /** 当前“我的报销”导出仅允许单据经办人,状态白名单与页面按钮保持一致。 */ + @Override + public byte[] exportPdf(String id) { + return renderDocument(loadExportForm(id, false, false), true, false); + } + + /** 汇总打印支持所有状态,按汇总权限和所属工会范围读取,并按慰问类型选择模板。 */ + @Override + public byte[] exportCollectPdf(String id) { + return renderDocument(loadExportForm(id, true, true), true, true); + } + + /** + * 只读取申请与发票明细,不修改业务数据。 + * @param id 报销单 ID,空值、记录不存在或已删除时拒绝导出 + * @param allowCollect 是否允许具有汇总数据权限的用户读取他人单据 + * @param collectPrint 是否为汇总页全状态打印;此时必须通过汇总数据权限校验,经办人身份不能绕过工会范围 + * @return 已校验权限并补齐发票明细的报销单,原有导出入口仍只允许已提交状态 + */ + private UnionReimburse loadExportForm(String id, boolean allowCollect, boolean collectPrint) { + if (StrUtil.isBlank(id)) { + throw new BaseException("请选择需要导出的报销单"); + } + UnionReimburse form = unionReimburseService.fetch(id); + if (form == null || Boolean.TRUE.equals(form.getDelFlag())) { + throw new BaseException("报销记录不存在"); + } + String userId = SecurityUtil.getUserId(); + boolean owner = StrUtil.isNotBlank(userId) && Objects.equals(form.getUserId(), userId); + boolean collectAllowed = allowCollect && StrUtil.isNotBlank(userId) + && AuthUtil.hasPermission("unionReimburse.collect") + && (AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name()) + || (StrUtil.isNotBlank(SecurityUtil.getUnionId()) + && Objects.equals(form.getUnionId(), SecurityUtil.getUnionId()))); + if (collectPrint ? !collectAllowed : (!owner && !collectAllowed)) { + throw new BaseException("无权导出此报销单"); + } + // 汇总页打印不限制申请状态,我的报销导出仍只允许已提交状态。 + if (!collectPrint && !Set.of(2, 3, 4, 5).contains(form.getStateId())) { + throw new BaseException("报销单提交后才能导出"); + } + // 已核对导出数据权限,单独补齐发票明细,避免申请页权限误拦截汇总打印。 + form.setInvoiceDetails(dao.query(UnionReimburseInvoiceDetail.class, + Cnd.where("reimburseId", "=", id).asc("createdAt"))); + return form; + } + + /** + * 汇总页五类慰问使用独立模板,其余类型和我的报销沿用原模板。 + * @param form 已通过数据权限校验的报销单,使用慰问类型 ID 读取业务编码 + * @param collectPrint 是否为汇总页打印,只有该入口启用分类模板 + * @return 系统导出模板管理中的模板编码;类型关联缺失时提示核对,不猜测类型名称 + */ + private String resolveTemplateCode(UnionReimburse form, boolean collectPrint) { + if (!"UNION_REIMBURSE_PROJECT_1".equals(form.getReimburseProject())) { + return ACTIVITY_TEMPLATE; + } + if (!collectPrint) { + return CONDOLENCE_TEMPLATE; + } + // 兼容旧申请将类型 ID 保存在 condolenceType 的情况,不回写历史数据。 + String typeId = StrUtil.blankToDefault(form.getCondolenceTypeId(), form.getCondolenceType()); + CondolenceType type = StrUtil.isBlank(typeId) ? null : dao.fetch(CondolenceType.class, typeId); + if (type == null || StrUtil.isBlank(type.getCode())) { + throw new BaseException("慰问类型不存在或编码为空,请核对后打印"); + } + // 五类之外的既有慰问继续使用通用模板;已匹配的专用模板缺失时由模板加载器明确报错。 + return COLLECT_CONDOLENCE_TEMPLATES.getOrDefault(type.getCode(), CONDOLENCE_TEMPLATE); + } + + /** 模板共用字段和循环行配置;汇总打印按慰问类型选模板并补充编号二维码。 */ + private byte[] renderDocument(UnionReimburse form, boolean asPdf, boolean collectPrint) { + String templateCode = resolveTemplateCode(form, collectPrint); + Path docx = null; + Path pdf = null; + try { + docx = Files.createTempFile("union_reimburse_", ".docx"); + if (asPdf) pdf = Files.createTempFile("union_reimburse_", ".pdf"); + // 活动和慰问模板的支付标记均位于表头,支付明细统一循环下一行。 + // 活动模板的发票标记也位于表头,循环下一行;慰问保留原有的当前行配置。 + boolean invoiceOnSameRow = "UNION_REIMBURSE_PROJECT_1".equals(form.getReimburseProject()); + Configure config = Configure.builder() + .bind("payments", new LoopRowTableRenderPolicy(false)) + .bind("invoices", new LoopRowTableRenderPolicy(invoiceOnSameRow)).build(); + try (InputStream input = sysOfficeTemplateUtil.getTemplate(templateCode); + XWPFTemplate template = XWPFTemplate.compile(input, config); + OutputStream output = Files.newOutputStream(docx)) { + Map data = buildExportData(form); + if (collectPrint) { + prepareCollectQrCode(template, data, form.getDocumentNo()); + } + template.render(data).write(output); + } + if (!asPdf) { + return Files.readAllBytes(docx); + } + OfficePlusUtil.convert(docx.toString(), pdf.toString()); + byte[] bytes = Files.readAllBytes(pdf); + // 公共转换器可能记录异常后返回,下载前验证结果,避免把空文件当成功响应。 + if (bytes.length < 5 || bytes[0] != '%' || bytes[1] != 'P' || bytes[2] != 'D' + || bytes[3] != 'F' || bytes[4] != '-') { + throw new BaseException("PDF生成失败,请检查模板及转换服务"); + } + return bytes; + } catch (BaseException e) { + throw e; + } catch (Exception e) { + log.error("报销导出失败,单据ID:{},模板:{}", form.getId(), templateCode, e); + throw new BaseException("报销导出失败,请检查模板配置"); + } finally { + // 成功或异常均清理本次转换文件,不影响其他并发导出。 + for (Path path : new Path[]{docx, pdf}) { + if (path != null) { + try { + Files.deleteIfExists(path); + } catch (Exception e) { + log.warn("报销导出临时文件清理失败:{}", path, e); + } + } + } + } + } + + /** + * 汇总打印沿用老系统的单据编号二维码,仅修改本次生成的文档,不覆盖模板。 + * @param template 当前已编译模板,优先使用 qrcode 图片标记,其次替换标题区的固定二维码 + * @param data 本次渲染数据,qrcode 字段为 80×80 的 PNG 图片对象 + * @param documentNo 原始单据编号,缺失时拒绝打印,避免用业务 ID 冒充编号 + */ + private void prepareCollectQrCode(XWPFTemplate template, Map data, + String documentNo) throws Exception { + if (StrUtil.isBlank(documentNo)) { + throw new BaseException("报销单编号为空,无法生成打印二维码,请先核对单据编号"); + } + var matrix = new QRCodeWriter().encode(documentNo, BarcodeFormat.QR_CODE, 80, 80); + var image = MatrixToImageWriter.toBufferedImage(matrix); + data.put("qrcode", Pictures.ofBufferedImage(image, PictureType.PNG).size(80, 80).create()); + boolean hasTag = template.getElementTemplates().stream() + .anyMatch(item -> item instanceof ElementTemplate + && "qrcode".equals(((ElementTemplate) item).getTagName())); + if (hasTag) { + return; + } + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + ImageIO.write(image, "png", bytes); + XWPFDocument document = template.getXWPFDocument(); + // 部分现用模板内嵌了固定二维码。仅识别第一张业务表格之前的二维码,保留徽标等普通图片。 + boolean replaced = false; + for (IBodyElement element : document.getBodyElements()) { + if (element instanceof XWPFTable) break; + if (!(element instanceof XWPFParagraph)) continue; + for (XWPFRun run : ((XWPFParagraph) element).getRuns()) { + for (XWPFPicture picture : run.getEmbeddedPictures()) { + if (picture.getPictureData() == null) continue; + var existing = ImageIO.read(new ByteArrayInputStream(picture.getPictureData().getData())); + if (existing == null) continue; + try { + new QRCodeReader().decode(new BinaryBitmap(new HybridBinarizer( + new BufferedImageLuminanceSource(existing)))); + } catch (NotFoundException | ChecksumException | FormatException e) { + continue; + } + String relationship = document.addPictureData(bytes.toByteArray(), Document.PICTURE_TYPE_PNG); + picture.getCTPicture().getBlipFill().getBlip().setEmbed(relationship); + replaced = true; + } + } + } + if (replaced) return; + // 无二维码标记或固定二维码的模板,在正文开头添加右对齐图片,不依赖模板重新上传。 + XWPFParagraph paragraph; + if (document.getBodyElements().isEmpty()) { + paragraph = document.createParagraph(); + } else { + IBodyElement first = document.getBodyElements().get(0); + var cursor = first instanceof XWPFParagraph + ? ((XWPFParagraph) first).getCTP().newCursor() + : ((XWPFTable) first).getCTTbl().newCursor(); + try { + paragraph = document.insertNewParagraph(cursor); + } finally { + cursor.dispose(); + } + } + paragraph.setAlignment(ParagraphAlignment.RIGHT); + paragraph.setSpacingBefore(0); + paragraph.setSpacingAfter(0); + paragraph.createRun().addPicture(new ByteArrayInputStream(bytes.toByteArray()), + Document.PICTURE_TYPE_PNG, "qrcode.png", Units.pixelToEMU(80), Units.pixelToEMU(80)); + } + + /** 平铺字段供两类模板复用,支付总额与发票总额分别计算,不使用第一条支付代替全部明细。 */ + @Override + public Map buildExportData(UnionReimburse form) { + Map data = new LinkedHashMap<>(); + data.put("date", date(form.getCreateTime())); + data.put("no", text(form.getDocumentNo())); + String unit = text(form.getUnitName()); + data.put("union", text(form.getUnionName()) + (unit.isEmpty() ? "" : "(" + unit + ")")); + data.put("handler", text(form.getUserName())); + data.put("login", text(form.getLoginName())); + data.put("phone", text(form.getMobile())); + String project = form.getReimburseProject(); + data.put("project", ("UNION_REIMBURSE_PROJECT_2".equals(project) ? "√" : "□") + "文体活动 " + + ("UNION_REIMBURSE_PROJECT_1".equals(project) ? "√" : "□") + "慰问 " + + ("UNION_REIMBURSE_PROJECT_3".equals(project) ? "√" : "□") + "日常活动 " + + ("UNION_REIMBURSE_PROJECT_4".equals(project) ? "√" : "□") + "专项活动"); + data.put("activity", text(form.getActivityName())); + data.put("reason", text(form.getPaymentNotes())); + data.put("notes", text(form.getNotes())); + // 附件只有上传文件数量,没有纸质页数,模板明确标注为“附件份数”。 + data.put("attachments", form.getFiles() == null ? 0 : form.getFiles().size()); + List details = form.getPaymentDetails(); + boolean legacy = details == null || details.isEmpty(); + if (legacy) { + JSONObject item = new JSONObject(); + item.set("paymentWay", form.getPaymentWay()); + item.set("payerLoginname", form.getPayerLoginname()); + item.set("payerName", form.getPayerName()); + item.set("bankUserName", form.getBankUserName()); + item.set("bankCardNumber", form.getBankCardNumber()); + item.set("bankOfDeposit", form.getBankOfDeposit()); + // 历史记录未保存支付明细时,沿用实际金额、申请金额的优先级;慰问单再回退到慰问金额。 + // 仅空值触发回退,已明确填写的零金额不能被其他字段覆盖。 + Double legacyMoney = form.getRealMoney() != null ? form.getRealMoney() : form.getMoney(); + if (legacyMoney == null && "UNION_REIMBURSE_PROJECT_1".equals(project)) { + legacyMoney = form.getCondolenceMoney(); + } + item.set("money", legacyMoney); + details = List.of(item); + } + List> payments = new ArrayList<>(); + BigDecimal paymentTotal = BigDecimal.ZERO; + for (JSONObject item : details) { + if (item == null) continue; + boolean privatePayment = "UNION_REIMBURSE_PAYMENT_WAY_2".equals(item.getStr("paymentWay")); + BigDecimal amount = money(item.get("money")); + Map row = new LinkedHashMap<>(); + row.put("way", privatePayment ? "对私支付" : "对公支付"); + row.put("login", privatePayment ? text(item.getStr("payerLoginname")) : ""); + row.put("name", privatePayment ? text(item.getStr("payerName")) : ""); + // 开户行仅输出银行名称,不拼接户名或文字前缀;对公支付保持空白。 + row.put("bank", privatePayment ? text(item.getStr("bankOfDeposit")) : ""); + row.put("card", privatePayment ? text(item.getStr("bankCardNumber")) : ""); + row.put("amount", amount.toPlainString()); + payments.add(row); + paymentTotal = paymentTotal.add(amount); + } + data.put("payments", payments); + // 两类模板共用支付合计 total 与大写 upper,单条支付使用 payments.amount。 + data.put("total", paymentTotal.setScale(2).toPlainString()); + data.put("upper", upper(paymentTotal)); + List> invoices = new ArrayList<>(); + BigDecimal invoiceTotal = BigDecimal.ZERO; + if (form.getInvoiceDetails() != null) { + for (UnionReimburseInvoiceDetail item : form.getInvoiceDetails()) { + if (item == null) continue; + BigDecimal amount = money(item.getInvoiceAmount()); + Map row = new LinkedHashMap<>(); + row.put("seq", invoices.size() + 1); + row.put("no", text(item.getInvoiceNo())); + row.put("seller", text(item.getSellerName())); + // 发票行分别绑定开票日期、税前金额、税额及价税合计;历史空值保持空白。 + row.put("date", text(item.getInvoiceDate())); + row.put("amount", item.getAmountWithoutTax() == null ? "" : money(item.getAmountWithoutTax()).toPlainString()); + row.put("tax", item.getTaxAmount() == null ? "" : money(item.getTaxAmount()).toPlainString()); + row.put("total", amount.toPlainString()); + invoices.add(row); + invoiceTotal = invoiceTotal.add(amount); + } + } + data.put("invoices", invoices); + data.put("invoiceTotal", invoiceTotal.setScale(2).toPlainString()); + data.put("invoiceCount", invoices.isEmpty() ? text(form.getInvoiceNumber()) : invoices.size()); + // 验真状态没有单独落库,导出模板不可将其固定写成“已验真”。 + data.put("person", text(form.getCondolenceUserName())); + data.put("personNo", text(form.getCondolenceLoginName())); + data.put("type", text(form.getTypeName())); + data.put("way", text(form.getWay())); + data.put("visitDate", date(form.getCondolenceTime())); + data.put("participants", text(form.getParticipants())); + data.put("illness", text(form.getHospitalCausation())); + data.put("hospital", text(form.getHospital())); + data.put("times", text(form.getHospitalCount())); + String start = date(form.getHospitalizationStartTime()); + String end = date(form.getHospitalizationEndTime()); + data.put("hospitalDates", start + (start.isEmpty() || end.isEmpty() ? "" : " 至 ") + end); + data.put("birthDate", date(form.getFertilityTime())); + data.put("marryDate", date(form.getMarryTime())); + data.put("deathDate", date(form.getDeathTime())); + data.put("relation", text(form.getCondolenceRelationship())); + return data; + } + + /** 金额统一为两位小数,空值显示0.00,非法历史金额拒绝生成错误单据。 */ + private BigDecimal money(Object value) { + if (value == null || StrUtil.isBlank(value.toString())) return BigDecimal.ZERO.setScale(2); + try { + BigDecimal result = new BigDecimal(value.toString()).setScale(2, RoundingMode.HALF_UP); + if (result.signum() < 0) throw new NumberFormatException(); + return result; + } catch (NumberFormatException e) { + throw new BaseException("报销记录金额异常,请核对后导出"); + } + } + + /** 复用项目人民币大写转换,将转换异常统一为导出业务提示。 */ + private String upper(BigDecimal value) { + try { + return MoneyUtil.toRMBUpper(value.toPlainString()); + } catch (Exception e) { + throw new BaseException("报销金额大写转换失败,请核对金额"); + } + } + + /** 空字段在审批单中显示为空白。 */ + private String text(String value) { return StrUtil.nullToEmpty(value); } + + /** 业务日期只显示年月日,未填写的日期保持空白。 */ + private String date(Date value) { return value == null ? "" : DateUtil.format(value, "yyyy-MM-dd"); } +} diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseServiceImpl.java b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseServiceImpl.java index 42b6951..e400313 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/unionReimburse/service/impl/UnionReimburseServiceImpl.java @@ -16,10 +16,12 @@ import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.club.model.OutlayMan import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.model.OutlayUseDetail; import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.school.model.OutlayManageSchool; import com.budwk.app.zhgh.dayofficework.outlay.outlayManage.union.model.OutlayManageUnion; +import com.budwk.app.zhgh.staffbenefit.condolence.model.CondolenceType; import com.budwk.app.zhgh.dayofficework.unionReimburse.model.UnionReimburse; import com.budwk.app.zhgh.dayofficework.unionReimburse.model.UnionReimburseInvoiceDetail; import com.budwk.app.zhgh.dayofficework.unionReimburse.param.UnionReimbursePageForm; import com.budwk.app.zhgh.dayofficework.unionReimburse.service.UnionReimburseService; +import com.budwk.app.zhgh.dayofficework.unionReimburse.service.UnionReimburseBankAccountService; import com.budwk.app.zhgh.dayofficework.unionReimburse.vo.UnionReimburseBankHistoryVO; import com.budwk.app.web.commons.auth.utils.AuthUtil; import com.budwk.app.web.commons.auth.utils.SecurityUtil; @@ -47,8 +49,8 @@ import com.tencentcloudapi.ocr.v20181119.models.VatInvoice; import com.tencentcloudapi.ocr.v20181119.models.VatInvoiceItem; import com.tencentcloudapi.ocr.v20181119.models.VatInvoiceOCRRequest; import com.tencentcloudapi.ocr.v20181119.models.VatInvoiceOCRResponse; -import com.tencentcloudapi.ocr.v20181119.models.VatInvoiceVerifyRequest; -import com.tencentcloudapi.ocr.v20181119.models.VatInvoiceVerifyResponse; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; import java.math.BigDecimal; import java.math.RoundingMode; @@ -80,6 +82,8 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i @Inject private SysFileService sysFileService; @Inject + private UnionReimburseBankAccountService unionReimburseBankAccountService; + @Inject private PropertiesProxy conf; public UnionReimburseServiceImpl(Dao dao) { super(dao); @@ -121,7 +125,7 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i /** * 保存、提交及重新提交共用支付信息校验,并在同一事务内保存申请和发票明细。 * @param unionReimburse 申请表单,paymentDetails 为支付方式、金额及对私收款资料组成的明细列表 - * @param stateId 1 表示保存草稿,2 表示提交;两种状态均要求支付信息完整 + * @param stateId 1 表示保存草稿,2 表示提交;均要求支付信息完整,发票选填,上传后须通过服务端验真 * @return Result 成功或失败结果,msg 包含失败明细的行号及原因 */ @Override @@ -130,10 +134,20 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i if (unionReimburse == null) { return Result.error("报销数据不能为空"); } + // 发票选填;保存、提交及重提均对已上传的发票检查完整性。 + Result invoiceResult = validateInvoiceDetails(unionReimburse); + if (invoiceResult != null) { + return invoiceResult; + } Result paymentResult = validatePaymentDetails(unionReimburse); if (paymentResult != null) { return paymentResult; } + // 保存、提交及重提均在写入前检查支付总额,不能只依赖页面的金额限制。 + Result amountResult = validatePaymentAmountLimit(unionReimburse); + if (amountResult != null) { + return amountResult; + } UnionReimburse oldRecord = null; if (StrUtil.isNotBlank(unionReimburse.getId())) { oldRecord = this.fetch(unionReimburse.getId()); @@ -174,11 +188,7 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i } normalizeInvoiceDetails(unionReimburse); - if (needInvoiceDetails(unionReimburse)) { - fillInvoiceSummary(unionReimburse); - } else { - fillCondolenceInvoiceSummary(unionReimburse); - } + fillInvoiceSummary(unionReimburse); fillDefaultRealMoney(unionReimburse); if (stateId == 2) { Result validateResult = validateBeforeSubmit(unionReimburse); @@ -195,6 +205,11 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i } } + // 校验实际文件与票面内容后才允许写入,不接收前端的验真状态作为凭据。 + Result verifyResult = validateInvoiceAuthenticity(unionReimburse); + if (verifyResult != null) { + return verifyResult; + } if (oldRecord == null) { unionReimburse.setCreateTime(toSecondPrecision(DateUtil.date())); unionReimburse.setDocumentNo(generateDocumentNo()); @@ -206,6 +221,8 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i fillDefaultRealMoney(unionReimburse); this.dao().insertOrUpdate(unionReimburse); saveInvoiceDetails(unionReimburse.getId(), unionReimburse.getInvoiceDetails()); + // 报销保存成功后积累每条对私账户;失败时随本次报销事务一起回滚。 + unionReimburseBankAccountService.saveAccounts(unionReimburse); return Result.success(stateId == 2 ? "提交成功" : "保存成功"); } @@ -219,6 +236,10 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i return new Date((date.getTime() / 1000) * 1000); } + /** + * @param id 报销申请 ID,用于读取申请及关联的慰问类型 + * @return 申请对象,condolenceTypeCode 为类型业务编码;未找到申请返回 null,类型缺失时编码为空 + */ @Override public UnionReimburse getApplyForm(String id) { UnionReimburse unionReimburse = this.fetch(id); @@ -228,6 +249,10 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i if (!canReadApply(unionReimburse)) { throw new BaseException("无权查看此报销申请"); } + // 兼容旧申请将类型 ID 存在 condolenceType 中的情况;只读取编码,不改动历史关联。 + String typeId = StrUtil.blankToDefault(unionReimburse.getCondolenceTypeId(), unionReimburse.getCondolenceType()); + CondolenceType type = StrUtil.isBlank(typeId) ? null : dao().fetch(CondolenceType.class, typeId); + unionReimburse.setCondolenceTypeCode(type == null ? null : type.getCode()); List invoiceDetails = this.dao().query( UnionReimburseInvoiceDetail.class, Cnd.where("reimburseId", "=", id).asc("createdAt") @@ -416,6 +441,11 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i return queryHistoryInvoiceDuplicate(currentInvoiceNo, reimburseId); } + /** + * @param fileId 已上传发票的文件ID,只读取服务端存储的文件 + * @param verifySwitch 兼容旧页面参数,传 false 或不传也必须验真 + * @return Result.data 为票号、金额、销售方、项目等字段及 verified=true;识别或验真失败返回非零 code + */ @Override public Result recognizeInvoice(String fileId, Boolean verifySwitch) { if (StrUtil.isBlank(fileId)) { @@ -431,14 +461,22 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i } try { byte[] fileBytes = sysFileService.download(fileId); + // OCR当前仅读取PDF第一页,拒绝多页文件,避免后续页未核验却随整份附件入库。 + if (StrUtil.blankToDefault(sysFile.getName(), "").toLowerCase(java.util.Locale.ROOT).endsWith(".pdf")) { + try (org.apache.pdfbox.pdmodel.PDDocument document = org.apache.pdfbox.Loader.loadPDF(fileBytes)) { + if (document.getNumberOfPages() != 1) { + return Result.error("请将发票拆分为单页PDF后逐张上传验真"); + } + } + } VatInvoiceOCRResponse response = executeOcr(credential, sysFile, fileBytes); NutMap result = buildOcrResult(response); - String msg = "发票识别成功"; - if (Boolean.TRUE.equals(verifySwitch)) { - String verifyMsg = enrichByVerify(credential, result); - msg = StrUtil.isBlank(verifyMsg) ? "发票识别并校验成功" : verifyMsg; + String verifyMsg = enrichByVerify(credential, result); + if (StrUtil.isNotBlank(verifyMsg)) { + return Result.error(verifyMsg); } - return Result.success(msg, result); + result.setv("verified", true); + return Result.success("发票识别并验真成功", result); } catch (TencentCloudSDKException e) { log.error("腾讯云发票识别失败,fileId:{}", fileId, e); return Result.error("腾讯云发票识别失败:" + e.getMessage()); @@ -631,7 +669,7 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i } /** - * 活动类报销由发票明细自动汇总金额和发票张数,减少手工输入出错。 + * 有发票时汇总票面金额和张数;无发票时按已校验的支付明细汇总金额,张数为零。 */ private void fillInvoiceSummary(UnionReimburse unionReimburse) { double totalMoney = 0D; @@ -646,30 +684,15 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i invoiceFileCount++; } } - unionReimburse.setMoney(BigDecimal.valueOf(totalMoney).setScale(2, RoundingMode.HALF_UP).doubleValue()); - unionReimburse.setInvoiceNumber(String.valueOf(invoiceFileCount)); - } - - /** - * 慰问类报销允许不填发票;有发票时按发票汇总金额申请,没有发票时按慰问金额申请。 - */ - private void fillCondolenceInvoiceSummary(UnionReimburse unionReimburse) { - double totalMoney = 0D; - int invoiceFileCount = 0; - for (UnionReimburseInvoiceDetail detail : unionReimburse.getInvoiceDetails()) { - if (detail.getInvoiceAmount() != null) { - totalMoney = BigDecimal.valueOf(totalMoney) - .add(BigDecimal.valueOf(detail.getInvoiceAmount())) - .doubleValue(); - } - if (Lang.isNotEmpty(detail.getInvoiceFiles())) { - invoiceFileCount++; + // 无发票申请从服务端已校验的支付明细计算金额,不能信任前端传入的报销总额。 + if (Lang.isEmpty(unionReimburse.getInvoiceDetails())) { + BigDecimal paymentTotal = BigDecimal.ZERO; + for (JSONObject detail : unionReimburse.getPaymentDetails()) { + paymentTotal = paymentTotal.add(new BigDecimal(detail.get("money").toString())); } + totalMoney = paymentTotal.setScale(2, RoundingMode.HALF_UP).doubleValue(); } - Double applyMoney = invoiceFileCount > 0 - ? BigDecimal.valueOf(totalMoney).setScale(2, RoundingMode.HALF_UP).doubleValue() - : unionReimburse.getCondolenceMoney(); - unionReimburse.setMoney(applyMoney); + unionReimburse.setMoney(BigDecimal.valueOf(totalMoney).setScale(2, RoundingMode.HALF_UP).doubleValue()); unionReimburse.setInvoiceNumber(String.valueOf(invoiceFileCount)); } @@ -690,7 +713,7 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i return Result.error("请至少维护一条支付信息"); } for (int i = 0; i < form.getPaymentDetails().size(); i++) { - cn.hutool.json.JSONObject detail = form.getPaymentDetails().get(i); + JSONObject detail = form.getPaymentDetails().get(i); String prefix = "第" + (i + 1) + "条支付信息"; if (detail == null) { return Result.error(prefix + "不能为空"); @@ -718,6 +741,11 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i if (StrUtil.isBlank(detail.getStr("bankOfDeposit"))) { return Result.error(prefix + "请填写开户行"); } + // 账户表和报销快照的文本字段长度一致,超长输入应明确拒绝而非丢失账户。 + if (detail.getStr("payer").length() > 100 || detail.getStr("bankUserName").length() > 100 + || detail.getStr("bankOfDeposit").length() > 100) { + return Result.error(prefix + "付款人、户名及开户行不能超过100个字符"); + } String card = detail.getStr("bankCardNumber"); if (card == null || !card.matches("[1-9]([0-9]{15}|[0-9]{18})")) { return Result.error(prefix + "请填写正确的银行卡号"); @@ -727,6 +755,46 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i return null; } + /** + * 支付明细须先通过单行金额校验,再按分比较全部对公、对私金额与发票总额。 + * @param form 当前申请,使用 paymentDetails 和 invoiceDetails 重新汇总,不信任前端 money 字段 + * @return 超额或发票金额无效时返回 Result 错误,通过返回 null + */ + private Result validatePaymentAmountLimit(UnionReimburse form) { + List invoices = form.getInvoiceDetails(); + // 无发票时不设票面金额上限,支付明细仍须通过必填及正金额校验。 + if (Lang.isEmpty(invoices)) { + return null; + } + BigDecimal invoiceTotal = BigDecimal.ZERO.setScale(2); + if (invoices != null) { + for (int i = 0; i < invoices.size(); i++) { + UnionReimburseInvoiceDetail detail = invoices.get(i); + if (detail == null) { + continue; + } + Double amount = detail.getInvoiceAmount(); + if (amount == null || !Double.isFinite(amount) || amount <= 0) { + return Result.error("第" + (i + 1) + "条发票明细请填写正确的发票金额"); + } + BigDecimal invoiceMoney = BigDecimal.valueOf(amount).setScale(2, RoundingMode.HALF_UP); + if (invoiceMoney.signum() <= 0) { + return Result.error("第" + (i + 1) + "条发票明细请填写正确的发票金额"); + } + invoiceTotal = invoiceTotal.add(invoiceMoney); + } + } + BigDecimal paymentTotal = BigDecimal.ZERO.setScale(2); + for (JSONObject detail : form.getPaymentDetails()) { + paymentTotal = paymentTotal.add(new BigDecimal(detail.get("money").toString())); + } + if (paymentTotal.compareTo(invoiceTotal) > 0) { + return Result.error("支付金额合计不能超过发票总额(支付合计:" + paymentTotal.toPlainString() + + "元,发票总额:" + invoiceTotal.toPlainString() + "元)"); + } + return null; + } + /** * 提交前补一层后端校验,防止前端绕过校验直接提交。 */ @@ -747,7 +815,7 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i // return Result.error("请填写开户行"); // } if (!needInvoiceDetails(unionReimburse)) { - return validateInvoiceDetails(unionReimburse, false); + return validateInvoiceDetails(unionReimburse); } if (StrUtil.isBlank(unionReimburse.getActivityName())) { return Result.error("请填写活动名称"); @@ -761,29 +829,29 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i if (Lang.isEmpty(unionReimburse.getFiles())) { return Result.error("请上传附件"); } - return validateInvoiceDetails(unionReimburse, true); + return validateInvoiceDetails(unionReimburse); } /** - * 发票明细提交校验:活动类必须至少一条,慰问类允许为空但填写后必须完整。 + * 发票列表允许为空;已有明细必须且只能包含一张可定位的发票文件。 */ - private Result validateInvoiceDetails(UnionReimburse unionReimburse, boolean required) { + private Result validateInvoiceDetails(UnionReimburse unionReimburse) { if (Lang.isEmpty(unionReimburse.getInvoiceDetails())) { - if (!required) { - return null; - } - return Result.error("请至少维护一条发票明细"); + return null; } for (int i = 0; i < unionReimburse.getInvoiceDetails().size(); i++) { UnionReimburseInvoiceDetail detail = unionReimburse.getInvoiceDetails().get(i); int rowIndex = i + 1; - if (Lang.isEmpty(detail.getInvoiceFiles())) { + if (detail == null || Lang.isEmpty(detail.getInvoiceFiles())) { return Result.error("第" + rowIndex + "条发票明细请上传发票文件"); } + if (detail.getInvoiceFiles().size() != 1 || StrUtil.isBlank(getInvoiceFileId(detail.getInvoiceFiles().get(0)))) { + return Result.error("第" + rowIndex + "条发票明细须上传一个有效发票文件"); + } if (StrUtil.isBlank(detail.getInvoiceNo())) { return Result.error("第" + rowIndex + "条发票明细请填写发票号码"); } - if (detail.getInvoiceAmount() == null || detail.getInvoiceAmount() <= 0) { + if (detail.getInvoiceAmount() == null || !Double.isFinite(detail.getInvoiceAmount()) || detail.getInvoiceAmount() <= 0) { return Result.error("第" + rowIndex + "条发票明细请填写正确的发票金额"); } if (StrUtil.isBlank(detail.getSellerName())) { @@ -796,6 +864,67 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i return null; } + /** + * @param form 已通过完整性校验的申请,逐条使用附件ID读取真实文件并验真 + * @return 全部通过返回 null;失败返回包含明细行号的 Result,不写入任何报销数据 + */ + private Result validateInvoiceAuthenticity(UnionReimburse form) { + Set invoiceNumbers = new LinkedHashSet<>(); + for (int i = 0; i < form.getInvoiceDetails().size(); i++) { + UnionReimburseInvoiceDetail detail = form.getInvoiceDetails().get(i); + String prefix = "第" + (i + 1) + "条发票明细:"; + if (!invoiceNumbers.add(detail.getInvoiceNo())) { + return Result.error(prefix + "发票号码重复,不能保存或提交"); + } + Result verified = recognizeInvoice(getInvoiceFileId(detail.getInvoiceFiles().get(0)), true); + if (verified.getCode() != 0) { + return Result.error(prefix + verified.getMsg()); + } + if (!(verified.getData() instanceof NutMap data) || !Boolean.TRUE.equals(data.get("verified"))) { + return Result.error(prefix + "未取得有效验真结果,请重试"); + } + Double verifiedAmount = parseMoney(data.get("invoiceAmount")); + if (!Objects.equals(detail.getInvoiceNo(), data.getString("invoiceNo")) + || verifiedAmount == null + || BigDecimal.valueOf(detail.getInvoiceAmount()).setScale(2, RoundingMode.HALF_UP) + .compareTo(BigDecimal.valueOf(verifiedAmount).setScale(2, RoundingMode.HALF_UP)) != 0 + || !Objects.equals(detail.getSellerName(), data.getString("sellerName")) + || !Objects.equals(detail.getItemName(), data.getString("itemName"))) { + return Result.error(prefix + "票号、金额、销售方或项目名称与验真结果不一致,请重新识别后保存"); + } + // 日期和分项金额均采用服务端重读发票后的结果,不信任前端传入值。 + detail.setInvoiceDate(data.getString("invoiceDate")); + detail.setAmountWithoutTax(parseMoney(data.get("amountWithoutTax"))); + detail.setTaxAmount(parseMoney(data.get("taxAmount"))); + } + return null; + } + + /** 兼容上传组件完整对象和历史下载地址;只提取ID,绝不请求前端传入的URL。 */ + private String getInvoiceFileId(JSONObject file) { + if (file == null) { + return ""; + } + if (StrUtil.isNotBlank(file.getStr("id"))) { + return file.getStr("id").trim(); + } + JSONObject response = file.getJSONObject("response"); + Object data = response == null ? null : response.get("data"); + if (data instanceof JSONObject object && StrUtil.isNotBlank(object.getStr("id"))) { + return object.getStr("id").trim(); + } + String value = data instanceof String ? (String) data + : StrUtil.blankToDefault(file.getStr("downloadPath"), file.getStr("url")); + if (StrUtil.isBlank(value)) { + return ""; + } + java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("[?&]id=([^&#]+)").matcher(value); + if (matcher.find()) { + return matcher.group(1); + } + return data instanceof String && value.matches("[a-zA-Z0-9_-]+") ? value : ""; + } + /** * 提交和审核前统一校验预算是否已分配、余额是否充足。 */ @@ -1227,52 +1356,153 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i result.setv("invoiceCode", pickValue(infos, "发票代码", "代码", "Code")); result.setv("invoiceDate", normalizeDate(pickValue(infos, "开票日期", "日期", "Date"))); result.setv("invoiceCheckCode", pickValue(infos, "校验码", "校验码后6位", "CheckCode")); - result.setv("invoiceAmount", parseMoney(pickValue(infos, "小写金额", "价税合计(小写)", "价税合计", "合计金额", "AmountWithTax"))); + // 含税总额按字段优先级提取;“合计金额”通常是不含税金额,不能用于全电票核验。 + result.setv("invoiceAmount", parseMoney(pickPreferredValue(infos, + "价税合计(小写)", "价税合计(小写)", "价税合计", "小写金额", "AmountWithTax"))); + result.setv("amountWithoutTax", parseMoney(pickValue(infos, "合计金额", "金额合计", "不含税金额", "AmountWithoutTax"))); + result.setv("taxAmount", parseMoney(pickPreferredValue(infos, "合计税额", "税额合计", "TaxAmount"))); result.setv("sellerName", pickValue(infos, "销售方名称", "销售方信息名称", "销售方", "SellerName")); result.setv("itemName", joinItemNames(items)); return result; } /** - * 自动校验需要票面关键信息,缺字段时只提示人工核对,不阻塞用户继续录入。 + * 从OCR票面构造新版核验参数:全电票使用含税金额,普通票使用校验码,专票使用不含税金额。 + * @param result 服务端识别的票面数据,不接受前端手工填写的验真参数 + * @return 腾讯云请求参数;信息不足时抛出异常,由验真入口返回失败 */ - private String enrichByVerify(Credential credential, NutMap result) { + private NutMap buildVerifyRequest(NutMap result) { String invoiceCode = result.getString("invoiceCode"); String invoiceNo = result.getString("invoiceNo"); String invoiceDate = result.getString("invoiceDate"); String checkCode = lastDigits(result.getString("invoiceCheckCode"), 6); - if (StrUtil.isBlank(checkCode)) { - checkCode = moneyToVerifyValue(result.get("invoiceAmount")); + boolean electronic = invoiceNo != null && invoiceNo.matches("[0-9]{20}"); + if (StrUtil.hasBlank(invoiceNo, invoiceDate) || (!electronic && StrUtil.isBlank(invoiceCode))) { + throw new IllegalArgumentException("发票号码、代码或开票日期识别不完整,请上传清晰发票后重试"); } - if (StrUtil.hasBlank(invoiceCode, invoiceNo, invoiceDate, checkCode)) { - return "发票识别成功,但自动校验信息不完整,请人工核对后提交"; + NutMap request = NutMap.NEW().setv("InvoiceNo", invoiceNo).setv("InvoiceDate", invoiceDate); + if (!electronic) { + request.setv("InvoiceCode", invoiceCode); } + String amount = moneyToVerifyValue(result.get(electronic ? "invoiceAmount" : "amountWithoutTax")); + if (electronic && StrUtil.isBlank(amount) + || !electronic && checkCode.length() != 6 && StrUtil.isBlank(amount)) { + throw new IllegalArgumentException("发票校验码或核验金额识别不完整,请上传清晰发票后重试"); + } + if (!electronic && checkCode.length() == 6) { + request.setv("CheckCode", checkCode); + } + if (StrUtil.isNotBlank(amount)) { + request.setv("Amount", amount); + } + return request; + } + + /** 强制验真:缺字段、无结果、作废红冲、票面不符及接口异常均不允许按成功返回。 */ + private String enrichByVerify(Credential credential, NutMap result) { try { + NutMap request = buildVerifyRequest(result); OcrClient client = createOcrClient(credential); - VatInvoiceVerifyRequest request = new VatInvoiceVerifyRequest(); - request.setInvoiceCode(invoiceCode); - request.setInvoiceNo(invoiceNo); - request.setInvoiceDate(invoiceDate.replace("-", "")); - request.setAdditional(checkCode); - VatInvoiceVerifyResponse response = client.VatInvoiceVerify(request); - VatInvoice invoice = response.getInvoice(); - if (invoice == null) { - return "发票识别成功,但自动校验未返回有效结果,请人工核对后提交"; + // 当前SDK没有新版模型,通过已有签名调用能力请求新版接口,避免升级整个SDK。 + JSONObject response = JSONUtil.parseObj(client.call("VatInvoiceVerifyNew", JSONUtil.toJsonStr(request))) + .getJSONObject("Response"); + if (response == null || response.getJSONObject("Error") != null) { + String code = response == null ? "EmptyResponse" : response.getJSONObject("Error").getStr("Code"); + return "发票验真未通过(" + code + "),请核对票据或稍后重试"; } - result.setv("invoiceCode", invoice.getCode()); - result.setv("invoiceNo", invoice.getNumber()); - result.setv("invoiceDate", normalizeDate(invoice.getDate())); - result.setv("invoiceCheckCode", invoice.getCheckCode()); - result.setv("sellerName", invoice.getSellerName()); - result.setv("invoiceAmount", parseMoney(invoice.getAmountWithTax())); - result.setv("itemName", joinItemNames(invoice.getItems())); - return null; + return applyVerifiedInvoice(result, response.getJSONObject("Invoice")); + } catch (IllegalArgumentException e) { + return e.getMessage(); + } catch (TencentCloudSDKException e) { + String code = StrUtil.blankToDefault(e.getErrorCode(), "UnknownError"); + log.warn("腾讯云发票验真失败,invoiceNo:{},invoiceDate:{},amount:{},errorCode:{},requestId:{}", + result.getString("invoiceNo"), result.getString("invoiceDate"), + result.get("invoiceAmount"), code, e.getRequestId(), e); + if ("FailedOperation.InvoiceMismatch".equals(code) + || "InvalidParameterValue.PriceOrVerificationParameterValueLimit".equals(code)) { + return "发票验真数据不一致,请核对票号、开票日期及价税合计金额后重新上传"; + } + if ("FailedOperation.CountLimitError".equals(code)) { + return "该发票今日核验次数已达上限,请次日再试"; + } + if ("ResourceNotFound.NotSupportCurrentInvoiceQuery".equals(code)) { + return "该发票今天开具,暂时无法核验,请稍后再试"; + } + return "发票验真未完成(" + code + "),请核对票据或稍后重试"; } catch (Exception e) { - log.warn("腾讯云发票验真失败,invoiceNo:{}", invoiceNo, e); - return "发票识别成功,但自动校验失败,请人工核对后提交"; + log.warn("腾讯云发票验真失败,invoiceNo:{}", result.getString("invoiceNo"), e); + return "发票验真未完成,请稍后重试;验真通过后才能保存或提交"; } } + /** 核验回包必须与上传票面的票号、金额、销售方一致,避免仅凭真实票号放行被改动的票面。 */ + private String applyVerifiedInvoice(NutMap result, JSONObject verifiedInvoice) { + if (verifiedInvoice == null) { + return "发票验真未返回有效票据,不能保存或提交"; + } + VatInvoice invoice = new com.google.gson.Gson().fromJson(verifiedInvoice.toString(), VatInvoice.class); + if (!"N".equalsIgnoreCase(invoice.getIsAbandoned()) || Boolean.TRUE.equals(verifiedInvoice.getBool("RedLetterInvoiceMark"))) { + return "发票已作废、红冲或状态无法确认,不能保存或提交"; + } + Double amount = parseMoney(invoice.getAmountWithTax()); + Double ocrAmount = parseMoney(result.get("invoiceAmount")); + // 分项比对只记录必要字段,区分票面识别差异;金额必须保持一致,不能用验真值覆盖后放行。 + if (!Objects.equals(result.getString("invoiceNo"), StrUtil.trim(invoice.getNumber()))) { + log.warn("发票票号比对失败,ocrInvoiceNo:{},verifiedInvoiceNo:{}", + result.getString("invoiceNo"), StrUtil.trim(invoice.getNumber())); + return "发票号码与验真结果不一致,请核对上传的发票"; + } + if (amount == null || amount <= 0 || ocrAmount == null + || BigDecimal.valueOf(amount).compareTo(BigDecimal.valueOf(ocrAmount)) != 0) { + log.warn("发票价税合计比对失败,invoiceNo:{},ocrAmount:{},verifiedAmount:{}", + result.getString("invoiceNo"), ocrAmount, amount); + return "发票价税合计与验真结果不一致,请核对含税总金额后重新识别"; + } + if (!Objects.equals(normalizeInvoiceSellerName(result.getString("sellerName")), + normalizeInvoiceSellerName(invoice.getSellerName()))) { + log.warn("发票销售方比对失败,invoiceNo:{},ocrSellerName:{},verifiedSellerName:{}", + result.getString("invoiceNo"), result.getString("sellerName"), invoice.getSellerName()); + return "发票销售方名称与验真结果不一致,请核对上传的发票"; + } + String itemName = joinItemNames(invoice.getItems()); + if (StrUtil.hasBlank(invoice.getSellerName(), itemName)) { + return "发票验真结果缺少销售方或项目名称,不能保存或提交"; + } + result.setv("invoiceCode", invoice.getCode()); + result.setv("invoiceNo", StrUtil.trim(invoice.getNumber())); + // 个别票种核验回包不提供日期,保留已用于本次验真请求的 OCR 开票日期。 + if (StrUtil.isNotBlank(invoice.getDate())) { + result.setv("invoiceDate", normalizeDate(invoice.getDate())); + } + result.setv("invoiceCheckCode", invoice.getCheckCode()); + result.setv("sellerName", StrUtil.trim(invoice.getSellerName())); + result.setv("invoiceAmount", amount); + // 核验结果优先;部分票种未返回分项金额时保留 OCR 提取值,不用差额推算税额。 + Double amountWithoutTax = parseMoney(invoice.getAmountWithoutTax()); + Double taxAmount = parseMoney(invoice.getTaxAmount()); + Double ocrAmountWithoutTax = parseMoney(result.get("amountWithoutTax")); + Double ocrTaxAmount = parseMoney(result.get("taxAmount")); + // 腾讯云对部分票种用 0.00 占位;票面 OCR 有正数时保留票面值。 + if (amountWithoutTax == null || amountWithoutTax == 0D && ocrAmountWithoutTax != null && ocrAmountWithoutTax > 0) { + amountWithoutTax = ocrAmountWithoutTax; + } + if (taxAmount == null || taxAmount == 0D && ocrTaxAmount != null && ocrTaxAmount > 0) { + taxAmount = ocrTaxAmount; + } + result.setv("amountWithoutTax", amountWithoutTax); + result.setv("taxAmount", taxAmount); + result.setv("itemName", itemName); + return null; + } + + /** + * 销售方比对仅统一中英文括号及首尾空白,保留名称文字与内部空白,避免将不同主体误判为一致。 + * 空值保持为空值;回填仍使用验真返回的原名称,不使用此比较用字符串。 + */ + private String normalizeInvoiceSellerName(String name) { + return name == null ? null : StrUtil.trim(name).replace('(', '(').replace(')', ')'); + } + private String pickValue(TextVatInvoice[] infos, String... aliases) { if (infos == null || aliases == null) { return ""; @@ -1292,6 +1522,17 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i return ""; } + /** 按候选字段优先级取首个非空票面值,避免 OCR 返回顺序决定金额含义。 */ + private String pickPreferredValue(TextVatInvoice[] infos, String... aliases) { + for (String alias : aliases) { + String value = pickValue(infos, alias); + if (StrUtil.isNotBlank(value)) { + return value; + } + } + return ""; + } + private String normalizeFieldName(String name) { return StrUtil.blankToDefault(name, "") .replace(":", "") @@ -1353,6 +1594,7 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i return BigDecimal.valueOf(money).setScale(2, RoundingMode.HALF_UP).toPlainString(); } + @Aop(TransAop.READ_COMMITTED) private void saveInvoiceDetails(String reimburseId, List invoiceDetails) { this.dao().clear(UnionReimburseInvoiceDetail.class, Cnd.where("reimburseId", "=", reimburseId)); if (Lang.isEmpty(invoiceDetails)) { @@ -1366,7 +1608,7 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl i } /** - * 非慰问类报销必须维护发票明细,慰问类发票明细为可选。 + * 区分活动类与慰问类的基本信息校验;所有类型的发票均为选填。 */ private boolean needInvoiceDetails(UnionReimburse unionReimburse) { return unionReimburse != null && !"UNION_REIMBURSE_PROJECT_1".equals(unionReimburse.getReimburseProject()); diff --git a/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/aidFund/param/AidFundPageForm.java b/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/aidFund/param/AidFundPageForm.java index a7c8900..7fbb896 100644 --- a/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/aidFund/param/AidFundPageForm.java +++ b/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/aidFund/param/AidFundPageForm.java @@ -17,6 +17,8 @@ public class AidFundPageForm extends PageForm { private String unitId; //是否当年未缴费 private Boolean notPayedCurrentYear; + //是否筛选人员类型与基金会员类型不一致的基金会员 + private Boolean personTypeChanged; //基金会员变更类型 private String changeType; diff --git a/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/aidFund/service/impl/AidFundMemberPayServiceImpl.java b/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/aidFund/service/impl/AidFundMemberPayServiceImpl.java index 5f05470..2331a5d 100644 --- a/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/aidFund/service/impl/AidFundMemberPayServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/aidFund/service/impl/AidFundMemberPayServiceImpl.java @@ -88,6 +88,8 @@ public class AidFundMemberPayServiceImpl extends BaseServiceImpl IFNULL(vw_user.aidFundMemberUserType, '') + ) + """)); + } cnd.andEX(Sys_user::getAidFundMemberUserType, "=", pageForm.getAidFundMemberUserType()); cnd.andEX(View_user::getUnionId, "=", pageForm.getUnionId()); cnd.andEX(View_user::getUnitId, "=", pageForm.getUnitId()); @@ -243,13 +260,10 @@ public class AidFundMemberServiceImpl extends BaseServiceImpl implements AidFund @Override @Aop(TransAop.READ_COMMITTED) public void doBatchChangeTypeByAdmin(String[] ids, String changeType) { - List sysUserList = dao().query(Sys_user.class, Cnd.where("id", "in", ids)); + List sysUserList = lockChangeUsers(ids); + validateRepeatedChange(sysUserList, changeType); List recordArrayList = new ArrayList<>(); - for (String id : ids) { - Sys_user user = sysUserList.stream().filter(sysUser -> sysUser.getId().equals(id)).findFirst().orElse(null); - if (user == null) { - continue; - } + for (Sys_user user : sysUserList) { recordArrayList.add(buildAdminChangeRecord(user, changeType)); } updateUserChangeType(ids, changeType); @@ -267,14 +281,78 @@ public class AidFundMemberServiceImpl extends BaseServiceImpl implements AidFund @Override @Aop(TransAop.READ_COMMITTED) public void doEditChangeTypeByAdmin(String userId, String changeType) { - Sys_user user = dao().fetch(Sys_user.class, userId); - if (user == null) { + List users = lockChangeUsers(new String[]{userId}); + if (users.isEmpty()) { return; } + validateRepeatedChange(users, changeType); + Sys_user user = users.get(0); dao().insert(buildAdminChangeRecord(user, changeType)); updateUserChangeType(new String[]{userId}, changeType); } + /** + * 在调用方 READ_COMMITTED 事务中按固定顺序锁定用户,串行处理同一人的并发变更。 + * ids 为单人或批量操作的用户 ID;返回去重后的当前用户,锁持续到事务结束。 + */ + private List lockChangeUsers(String[] ids) { + List users = new ArrayList<>(); + for (String id : Arrays.stream(ids).filter(StrUtil::isNotBlank).distinct().sorted().toList()) { + Sql sql = Sqls.create("SELECT * FROM sys_user WHERE id=@id FOR UPDATE"); + sql.setParam("id", id); + sql.setEntity(dao().getEntity(Sys_user.class)); + sql.setCallback(Sqls.callback.entities()); + dao().execute(sql); + users.addAll(sql.getList(Sys_user.class)); + } + return users; + } + + /** + * 整批写入前检查最近已生效变更;有重复时一次性提示全部人员,不更新档案或新增记录。 + * users 为已加行锁的用户,changeType 为变更类型字典编码;正常返回表示允许提交。 + * 只比较连续的相同变更,并核对当前状态;退会后重新入会允许再次合法退会。 + */ + private void validateRepeatedChange(List users, String changeType) { + List repeated = new ArrayList<>(); + Sys_dict dict = dao().fetch(Sys_dict.class, Cnd.where("code", "=", changeType)); + String typeName = dict == null ? changeType : dict.getName(); + for (Sys_user user : users) { + // 管理员记录和无流程的历史记录直接生效;wf 申请仅纳入已完成流程,排除处理中、撤回等记录。 + Sql sql = Sqls.create(""" + SELECT info.* FROM aid_fund_member_change_record info + WHERE info.userId=@userId AND IFNULL(info.delFlag, 0)=0 + AND (info.adminOperate=1 + OR NOT EXISTS (SELECT 1 FROM wf_process_instance ins WHERE ins.businessNo=info.id) + OR EXISTS (SELECT 1 FROM wf_process_instance ins WHERE ins.businessNo=info.id AND ins.state=20)) + ORDER BY info.applyTime DESC, info.createdAt DESC, info.id DESC + LIMIT 1 + """); + sql.setParam("userId", user.getId()); + sql.setEntity(dao().getEntity(AidFundMemberChangeRecord.class)); + sql.setCallback(Sqls.callback.entities()); + dao().execute(sql); + List records = sql.getList(AidFundMemberChangeRecord.class); + if (records.isEmpty()) { + continue; + } + AidFundMemberChangeRecord record = records.get(0); + boolean retirement = ObjectUtil.equals(changeType, "AIDFUND_MEMBER_CHANGE_TYPE_EIGTH"); + boolean targetState = retirement ? ObjectUtil.equals(user.getAidFundMemberUserType(), "离退休人员") + : ObjectUtil.equals(user.getAidFundMember(), AidFundMemberMode.NONE.getCode()); + // 入会时间晚于上次退会申请时,属于新的会员周期,不受旧退会记录限制。 + boolean rejoined = !retirement && user.getAidFundMemberJoinTime() != null && record.getApplyTime() != null + && user.getAidFundMemberJoinTime().after(record.getApplyTime()); + if (ObjectUtil.equals(record.getChangeType(), changeType) && targetState && !rejoined) { + String time = record.getApplyTime() == null ? "未知时间" : DateUtil.format(record.getApplyTime(), "yyyy-MM-dd HH:mm:ss"); + repeated.add(user.getUsername() + "(" + user.getLoginname() + ")已于" + time + "执行“" + typeName + "”"); + } + } + if (!repeated.isEmpty()) { + throw new BaseException(String.join(";", repeated) + ",请勿重复操作。本次全部未保存,请调整名单后重新提交。"); + } + } + /** * 管理端变更落记录时,需要明确标记记录来源,避免被“我的申请”误识别为本人发起。 * @@ -311,6 +389,7 @@ public class AidFundMemberServiceImpl extends BaseServiceImpl implements AidFund * @param ids 需要更新的用户ID数组 * @param changeType 变更类型字典值 */ + @Aop(TransAop.READ_COMMITTED) private void updateUserChangeType(String[] ids, String changeType) { Chain add; if (ObjectUtil.equals(changeType, "AIDFUND_MEMBER_CHANGE_TYPE_EIGTH")) { diff --git a/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/service/impl/MedicalApplyServiceImpl.java b/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/service/impl/MedicalApplyServiceImpl.java index db5256b..8a71c1c 100644 --- a/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/service/impl/MedicalApplyServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/service/impl/MedicalApplyServiceImpl.java @@ -99,9 +99,8 @@ public class MedicalApplyServiceImpl extends BaseServiceImpl imple .reduce(BigDecimal.ZERO, BigDecimal::add); map.put("bxyMoney$", bxyMoney$); - map.put("totalMoney", hospitalSumMoney$ - .add(reimbursementMoney$) - .add(singleBearMoney$) + // 合计金额只统计个人实际承担的费用,住院总额和已报销金额不重复计入。 + map.put("totalMoney", singleBearMoney$ .add(personExpenseMoney$) .add(outpatientServiceMoney$) .add(bxyMoney$)); @@ -237,10 +236,10 @@ public class MedicalApplyServiceImpl extends BaseServiceImpl imple boolean fullFifYears = DateUtil.thisYear() - Integer.parseInt(user.getAidFundDeductTime()) >= 15; - // 连续缴满15年 或者是 连续缴满10年 - if (fullFifYears) { + // 年限加成仅适用于首次申请;历史已完成申请记录已排除本次申请。 + if (applyRecords.isEmpty() && fullFifYears) { ratio += medicalSetting.getFifteenYearNeverAddSubsidyRatio(); - } else if (fullTenYears) { + } else if (applyRecords.isEmpty() && fullTenYears) { ratio += medicalSetting.getTenYearNeverAddSubsidyRatio(); } @@ -261,15 +260,17 @@ public class MedicalApplyServiceImpl extends BaseServiceImpl imple //如果是重大疾病 allMedicalMoney = personExpenseMoney.add(singleBearMoney).add(outpatientServiceMoney).add(bxyMoney); } else { - //个人承担费用 - singleBearMoney = costs.stream() + // 普通疾病以每次住院的个人承担金额是否达到一万元为准,达标记录的自费金额一并计入。 + List eligibleCosts = costs.stream() + .filter(cost -> cost.getSingleBearMoney() != null + && cost.getSingleBearMoney().compareTo(BigDecimal.valueOf(10000)) >= 0) + .toList(); + singleBearMoney = eligibleCosts.stream() .map(MedicalApplyCost::getSingleBearMoney) - .filter(amount -> amount.compareTo(BigDecimal.valueOf(10000)) >= 0) // >= 10000 .reduce(BigDecimal.ZERO, BigDecimal::add); - //自费 - personExpenseMoney = costs.stream() + personExpenseMoney = eligibleCosts.stream() .map(MedicalApplyCost::getPersonExpenseMoney) - .filter(amount -> amount.compareTo(BigDecimal.valueOf(10000)) >= 0) // >= 10000 + .filter(Objects::nonNull) .reduce(BigDecimal.ZERO, BigDecimal::add); allMedicalMoney = personExpenseMoney.add(singleBearMoney).add(outpatientServiceMoney).add(bxyMoney); } diff --git a/src/main/java/com/budwk/app/zhgh/staffmanage/member/constant/MemberChangeType.java b/src/main/java/com/budwk/app/zhgh/staffmanage/member/constant/MemberChangeType.java index 48e80c5..8244d6a 100644 --- a/src/main/java/com/budwk/app/zhgh/staffmanage/member/constant/MemberChangeType.java +++ b/src/main/java/com/budwk/app/zhgh/staffmanage/member/constant/MemberChangeType.java @@ -18,10 +18,21 @@ public enum MemberChangeType { //变更类型 NEW("NEW", "新入职"), + + BASIC_CHANGE("BASIC_CHANGE", "基本信息异动"), + UNIT_CHANGE("UNIT_CHANGE", "单位异动"), + UNION_CHANGE("UNION_CHANGE", "工会关系异动"), + + ONLINE("ONLINE", "在岗"), + OFFLINE("OFFLINE", "不在岗"), + RESTORE("RESTORE", "入会"), WITHDRAWAL("WITHDRAWAL", "退会"), - ONLINE("ONLINE", "在岗"), + EXTEND("EXTEND", "延聘"), + REHIRE_SCH("REHIRE_SCH", "学校返聘"), + REHIRE_DEPT("REHIRE_DEPT", "部门返聘"), + FLEX("FLEX", "柔性"), SECONDED("SECONDED", "借调挂职"), REMOTE_POST("REMOTE_POST", "异地任职"), @@ -40,18 +51,7 @@ public enum MemberChangeType { STUDY_DOMESTIC("STUDY_DOMESTIC", "国内进修"), STUDY_ABROAD("STUDY_ABROAD", "境外留学"), OFF_JOB_START("OFF_JOB_START", "离岗创业"), - INNER_RETIRE("INNER_RETIRE", "内部退养"), - - EXTEND("EXTEND", "延聘"), - REHIRE_SCH("REHIRE_SCH", "学校返聘"), - REHIRE_DEPT("REHIRE_DEPT", "部门返聘"), - - OFFLINE("OFFLINE", "不在岗"), - - UNIT_CHANGE("UNIT_CHANGE", "单位异动"), - UNION_CHANGE("UNION_CHANGE", "工会关系异动"), - BASIC_CHANGE("BASIC_CHANGE", "基本信息异动"); - + INNER_RETIRE("INNER_RETIRE", "内部退养"); private final String code; private final String changeTypeName; diff --git a/src/main/java/com/budwk/app/zhgh/staffmanage/member/service/impl/MemberManageServiceImpl.java b/src/main/java/com/budwk/app/zhgh/staffmanage/member/service/impl/MemberManageServiceImpl.java index 6bc1cbb..6ce6734 100644 --- a/src/main/java/com/budwk/app/zhgh/staffmanage/member/service/impl/MemberManageServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/staffmanage/member/service/impl/MemberManageServiceImpl.java @@ -262,6 +262,15 @@ public class MemberManageServiceImpl extends BaseServiceImpl implement allowChangeFieldNames.add("threeUnitId"); dictMap.put("threeUnitId", "三级单位"); } + // 日期字段由具体变更表单明确传入时才参与比较,避免旧变更记录被误判为清空日期。 + if (record.getRetireDate() != null) { + allowChangeFieldNames.add("retireDate"); + dictMap.put("retireDate", "退休日期"); + } + if (record.getWelfareStopDate() != null) { + allowChangeFieldNames.add("welfareStopDate"); + dictMap.put("welfareStopDate", "福利享受截止时间"); + } // 新数据 NutMap newMap = Lang.obj2nutmap(record); @@ -305,14 +314,18 @@ public class MemberManageServiceImpl extends BaseServiceImpl implement @Override + @Aop(TransAop.READ_COMMITTED) public void compareChangeInfoAndUpdateMember(String recordId) { MemberChangeRecord record = dao().fetch(MemberChangeRecord.class, recordId); compareChangeInfoAndUpdateMember(record); } @Override + @Aop(TransAop.READ_COMMITTED) public void compareChangeInfoAndUpdateMember(MemberChangeRecord record) { + applyRetiredPersonType(record); normalizeArrivalAtSchoolDate(record); + normalizeWelfareStopDate(record); List changeList = getChangeInfos(record); // 如果有工会关系人员,这个user用来存储数据 @@ -382,6 +395,10 @@ public class MemberManageServiceImpl extends BaseServiceImpl implement // 到此,表明审核通过,需要存储历史数据,并修改用户数据 BeanUtil.copyProperties(record, info); + // 变更记录使用字符串接收页面日期,人员表使用 Date,保存前显式转换避免类型转换差异。 + if (StrUtil.isNotBlank(record.getWelfareStopDate())) { + info.setWelfareStopDate(DateUtil.parseDate(record.getWelfareStopDate())); + } info.setId(userId); // 如果有单位变更,则需要记录单位关系 @@ -518,7 +535,9 @@ public class MemberManageServiceImpl extends BaseServiceImpl implement @Override public void validateChangeAndSetBasicData(MemberChangeRecord record, MemberChangeOrigin origin) { + applyRetiredPersonType(record); normalizeArrivalAtSchoolDate(record); + normalizeWelfareStopDate(record); // 校验有没有发生变更 List changeInfos = getChangeInfos(record); if (Lang.isEmpty(changeInfos)) { @@ -536,6 +555,43 @@ public class MemberManageServiceImpl extends BaseServiceImpl implement record.setApplyDateTime(DateUtil.date()); } + /** + * 在职状态调整为退休时,按人员类型字典名称自动设置“离退休人员”编码。 + * 字典中不存在该名称时保留原人员类型,不阻断其他变更。 + * + * @param record 人员变更内容,userState 为“退休”时才执行自动设置 + */ + private void applyRetiredPersonType(MemberChangeRecord record) { + if (record == null || !StrUtil.equals("退休", record.getUserState())) { + return; + } + List personTypeOptions = sysDictService.getSubListByCode("USER_PERSON_TYPE"); + if (Lang.isEmpty(personTypeOptions)) { + return; + } + personTypeOptions.stream() + .filter(option -> StrUtil.equals("离退休人员", option.getName())) + .findFirst() + .ifPresent(option -> record.setPersonType(option.getCode())); + } + + /** + * 校验并规范福利享受截止时间,统一使用 yyyy-MM-dd 参与比较及保存。 + * + * @param record 人员变更内容,空日期保持为空 + */ + private void normalizeWelfareStopDate(MemberChangeRecord record) { + if (record == null || StrUtil.isBlank(record.getWelfareStopDate())) { + return; + } + try { + Date parsedDate = parseArrivalAtSchoolDate(StrUtil.trim(record.getWelfareStopDate())); + record.setWelfareStopDate(DateUtil.format(parsedDate, "yyyy-MM-dd")); + } catch (Exception e) { + throw new BaseException("福利享受截止时间格式不正确,请使用yyyy-MM-dd格式"); + } + } + /** * 统一规范变更记录中的到校日期,避免 Date 对象复制后以英文字符串形式参与比较或直接落库。 diff --git a/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/controller/PendingConfirmUserController.java b/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/controller/PendingConfirmUserController.java index 7629df7..9cd8aa0 100644 --- a/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/controller/PendingConfirmUserController.java +++ b/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/controller/PendingConfirmUserController.java @@ -15,6 +15,8 @@ import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.Ok; +import javax.servlet.http.HttpServletResponse; + /** * 待确认人员记录页面及查询接口。 */ @@ -49,6 +51,21 @@ public class PendingConfirmUserController { return Result.success(pendingConfirmUserService.pageData(pageForm)); } + /** + * 按当前列表查询条件导出待确认或已确认人员。 + * + * @param pageForm 列表查询条件,包括姓名/工号、工会、单位、人员类型、在职状态、确认状态及排序 + * @param response HTTP 文件下载响应,返回内容为 XSSF 格式的 Excel 文件 + */ + @At + @Ok("void") + @SLog(tag = "待确认人员记录", msg = "按条件导出待确认人员记录") + @ApiOperation("按条件导出待确认人员记录") + @SaCheckPermission("staff.sourcechange.pending") + public void exportExcel(PendingConfirmUserPageForm pageForm, HttpServletResponse response) { + pendingConfirmUserService.exportExcel(pageForm, response); + } + /** * 直接提交待确认人员变更,不创建会员变更申请及审批流程。 * diff --git a/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/param/PendingConfirmUserBatchChangeParam.java b/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/param/PendingConfirmUserBatchChangeParam.java index 9c4fef0..80afccc 100644 --- a/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/param/PendingConfirmUserBatchChangeParam.java +++ b/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/param/PendingConfirmUserBatchChangeParam.java @@ -2,6 +2,8 @@ package com.budwk.app.zhgh.staffmanage.sourcechange.param; import lombok.Data; +import java.util.Date; + /** * 待确认人员批量变更参数。 */ @@ -17,4 +19,34 @@ public class PendingConfirmUserBatchChangeParam { * 批量设置的人员类型。 */ private String personType; + + /** + * 批量设置的在职状态,空值不参与批量变更。 + */ + private String userState; + + /** + * 批量设置的退休时间;目标在职状态为退休或人员类型为离退休人员时必填,并统一应用于勾选人员。 + */ + private Date retireDate; + + /** + * 批量设置的福利享受截止时间,仅在目标在职状态为退休且传入日期时参与变更。 + */ + private Date welfareStopDate; + + /** + * 批量设置的人员属性,空值不参与批量变更。 + */ + private String userAttribute; + + /** + * 批量设置的会员状态,null不参与批量变更,false表示设置为非会员。 + */ + private Boolean member; + + /** + * 批量设置的福利会员状态,null不参与批量变更,false表示设置为非福利会员。 + */ + private Boolean welfareMember; } diff --git a/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/param/PendingConfirmUserPageForm.java b/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/param/PendingConfirmUserPageForm.java index 7c210f6..1e35147 100644 --- a/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/param/PendingConfirmUserPageForm.java +++ b/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/param/PendingConfirmUserPageForm.java @@ -4,6 +4,8 @@ import com.budwk.app.base.param.PageForm; import lombok.Data; import lombok.EqualsAndHashCode; +import java.util.List; + /** * 待确认人员记录分页查询参数。 */ @@ -20,6 +22,9 @@ public class PendingConfirmUserPageForm extends PageForm { /** 人员类型。 */ private String personType; + /** 人员属性字典编码集合,前端以 JSON 数组提交;空集合表示不限制人员属性。 */ + private List userAttributes; + /** 在职状态。 */ private String userState; diff --git a/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/service/PendingConfirmUserService.java b/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/service/PendingConfirmUserService.java index 85c3701..7874217 100644 --- a/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/service/PendingConfirmUserService.java +++ b/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/service/PendingConfirmUserService.java @@ -7,6 +7,8 @@ import com.budwk.app.zhgh.staffmanage.member.models.MemberChangeRecord; import com.budwk.app.zhgh.staffmanage.sourcechange.param.PendingConfirmUserBatchChangeParam; import com.budwk.app.zhgh.staffmanage.sourcechange.param.PendingConfirmUserPageForm; +import javax.servlet.http.HttpServletResponse; + /** * 待确认人员记录业务服务。 */ @@ -20,6 +22,14 @@ public interface PendingConfirmUserService extends BaseService { */ Pagination pageData(PendingConfirmUserPageForm pageForm); + /** + * 按列表查询条件导出全部匹配人员,不受当前分页大小限制。 + * + * @param pageForm 列表查询条件及排序条件 + * @param response HTTP 文件下载响应,返回 XSSF 格式的 Excel 文件 + */ + void exportExcel(PendingConfirmUserPageForm pageForm, HttpServletResponse response); + /** * 直接提交待确认人员变更,不生成会员变更申请及流程数据。 * @@ -29,9 +39,9 @@ public interface PendingConfirmUserService extends BaseService { boolean submitChange(MemberChangeRecord record); /** - * 批量修改待确认人员的人员类型,并将处理成功的人员标记为已确认。 + * 批量修改待确认人员允许整改的字段,并将处理成功的人员标记为已确认。 * - * @param changeParam 批量勾选的用户ID及目标人员类型 + * @param changeParam 批量勾选的用户ID及待整改字段 * @return 实际完成确认的人员数量 */ int batchChangePersonType(PendingConfirmUserBatchChangeParam changeParam); diff --git a/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/service/impl/PendingConfirmUserServiceImpl.java b/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/service/impl/PendingConfirmUserServiceImpl.java index 8dfecbf..fa7e0d6 100644 --- a/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/service/impl/PendingConfirmUserServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/staffmanage/sourcechange/service/impl/PendingConfirmUserServiceImpl.java @@ -1,5 +1,9 @@ package com.budwk.app.zhgh.staffmanage.sourcechange.service.impl; +import cn.afterturn.easypoi.excel.ExcelExportUtil; +import cn.afterturn.easypoi.excel.entity.ExportParams; +import cn.afterturn.easypoi.excel.entity.enmus.ExcelType; +import cn.afterturn.easypoi.excel.entity.params.ExcelExportEntity; import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.core.date.DateUtil; @@ -7,6 +11,7 @@ import cn.hutool.json.JSONUtil; import com.budwk.app.base.page.Pagination; import com.budwk.app.base.exception.BaseException; import com.budwk.app.base.service.impl.BaseServiceImpl; +import com.budwk.app.base.utils.CommonDownloadUtil; import com.budwk.app.base.utils.PageUtil; import com.budwk.app.sys.models.Sys_dict; import com.budwk.app.sys.models.Sys_user; @@ -18,6 +23,7 @@ import com.budwk.app.zhgh.staffmanage.member.service.MemberCommonService; import com.budwk.app.zhgh.staffmanage.sourcechange.param.PendingConfirmUserBatchChangeParam; import com.budwk.app.zhgh.staffmanage.sourcechange.param.PendingConfirmUserPageForm; import com.budwk.app.zhgh.staffmanage.sourcechange.service.PendingConfirmUserService; +import org.apache.poi.ss.usermodel.Workbook; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; import org.nutz.dao.Sqls; @@ -27,6 +33,9 @@ import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.ioc.aop.Aop; +import org.nutz.lang.util.NutMap; + +import javax.servlet.http.HttpServletResponse; import java.util.ArrayList; import java.util.LinkedHashSet; @@ -63,6 +72,51 @@ public class PendingConfirmUserServiceImpl extends BaseServiceImpl imp */ @Override public Pagination pageData(PendingConfirmUserPageForm pageForm) { + Sql sql = buildQuerySql(pageForm); + return listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql); + } + + /** + * 按列表全部查询条件导出人员,查询范围不受 pageNumber、pageSize 限制。 + * + * @param pageForm 列表查询条件及排序条件 + * @param response HTTP 文件下载响应,返回字段与列表展示字段保持一致 + */ + @Override + public void exportExcel(PendingConfirmUserPageForm pageForm, HttpServletResponse response) { + List list = listMap(buildQuerySql(pageForm)); + for (int index = 0; index < list.size(); index++) { + NutMap row = list.get(index); + row.put("index", index + 1); + row.put("pendingConfirmStatusName", + Integer.valueOf(STATUS_CONFIRMED).equals(row.getInt("pendingConfirmStatus")) ? "已确认" : "待确认"); + } + + List exportEntities = new ArrayList<>(); + exportEntities.add(new ExcelExportEntity("序号", "index", 10)); + exportEntities.add(new ExcelExportEntity("工号", "loginname", 20)); + exportEntities.add(new ExcelExportEntity("姓名", "username", 20)); + exportEntities.add(new ExcelExportEntity("性别", "sex", 10)); + exportEntities.add(new ExcelExportEntity("所属工会", "unionName", 25)); + exportEntities.add(new ExcelExportEntity("所属单位", "unitName", 30)); + exportEntities.add(new ExcelExportEntity("人员类型", "personType", 20)); + exportEntities.add(new ExcelExportEntity("人员属性", "userAttribute", 20)); + exportEntities.add(new ExcelExportEntity("在职状态", "userState", 15)); + exportEntities.add(new ExcelExportEntity("确认状态", "pendingConfirmStatusName", 15)); + + ExportParams exportParams = new ExportParams(); + exportParams.setType(ExcelType.XSSF); + Workbook workbook = ExcelExportUtil.exportExcel(exportParams, exportEntities, list); + CommonDownloadUtil.download("待确认人员记录.xlsx", workbook, response); + } + + /** + * 构建分页查询和导出共用的人员 SQL,确保筛选条件、确认状态及排序口径一致。 + * + * @param pageForm 查询条件,包括姓名/工号、工会、单位、人员类型、在职状态、确认状态及排序 + * @return 已绑定查询条件和安全排序字段的 SQL 对象 + */ + private Sql buildQuerySql(PendingConfirmUserPageForm pageForm) { Sql sql = Sqls.create(""" SELECT userInfo.id, @@ -71,6 +125,7 @@ public class PendingConfirmUserServiceImpl extends BaseServiceImpl imp userInfo.sex, userInfo.userState, userInfo.personType, + userInfo.userAttribute, userInfo.unitId, unitInfo.name AS unitName, unionInfo.id unionId, @@ -95,6 +150,10 @@ public class PendingConfirmUserServiceImpl extends BaseServiceImpl imp cnd.andEX("userInfo.unionId", "=", pageForm.getUnionId()); cnd.andEX("userInfo.unitId", "=", pageForm.getUnitId()); cnd.andEX("userInfo.personType", "=", pageForm.getPersonType()); + // 多选人员属性匹配任一所选编码;未选择时不增加条件,列表和导出共用此筛选。 + if (pageForm.getUserAttributes() != null && !pageForm.getUserAttributes().isEmpty()) { + cnd.and("userInfo.userAttribute", "in", pageForm.getUserAttributes()); + } cnd.andEX("userInfo.userState", "=", pageForm.getUserState()); String orderColumn = resolveOrderColumn(pageForm.getPageOrderName()); @@ -104,7 +163,7 @@ public class PendingConfirmUserServiceImpl extends BaseServiceImpl imp cnd.asc("userInfo.loginname"); } sql.setCondition(cnd); - return listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql); + return sql; } /** @@ -119,18 +178,27 @@ public class PendingConfirmUserServiceImpl extends BaseServiceImpl imp if (record == null || StrUtil.isBlank(record.getUserId())) { throw new BaseException("未获取到待确认人员数据"); } + applyRetiredPersonType(record); if (StrUtil.isBlank(record.getPersonType())) { throw new BaseException("请选择人员类型"); } validatePersonType(record.getPersonType()); - // 单人页面虽然提交完整表单,服务层只采用用户ID和人员类型,防止请求篡改其他只读字段。 - return changePersonType(record.getUserId(), record.getPersonType()); + // 单人退休变更必须显式提交退休日期,避免绕过页面条件必填校验;不影响批量入口。 + String retiredPersonTypeCode = resolveRetiredPersonTypeCode(); + boolean requiresRetireDate = StrUtil.equals("退休", record.getUserState()) + || (StrUtil.isNotBlank(retiredPersonTypeCode) + && StrUtil.equals(retiredPersonTypeCode, StrUtil.trim(record.getPersonType()))); + if (requiresRetireDate && record.getRetireDate() == null) { + throw new BaseException("请选择退休日期"); + } + // 单人页面虽然提交完整表单,服务层只采纳待确认业务允许编辑的字段,防止请求篡改其他只读字段。 + return changePendingUserFields(record); } /** * 在同一事务内批量处理选中人员;任意人员状态异常时整体回滚,避免出现部分成功。 * - * @param changeParam 批量用户ID及目标人员类型 + * @param changeParam 批量用户ID及待确认字段变更内容 * @return 完成确认的人员数量 */ @Override @@ -139,10 +207,22 @@ public class PendingConfirmUserServiceImpl extends BaseServiceImpl imp if (changeParam == null || StrUtil.isBlank(changeParam.getUserIds())) { throw new BaseException("请选择需要变更的人员"); } - if (StrUtil.isBlank(changeParam.getPersonType())) { - throw new BaseException("请选择人员类型"); + if (!hasBatchChangeField(changeParam)) { + throw new BaseException("请选择需要变更的内容"); + } + applyRetiredPersonType(changeParam); + if (StrUtil.isNotBlank(changeParam.getPersonType())) { + validatePersonType(changeParam.getPersonType()); + } + + // 在修改任何人员前校验批量退休时间,避免绕过前端后复用各人员原日期或产生部分更新。 + String retiredPersonTypeCode = resolveRetiredPersonTypeCode(); + boolean requiresRetireDate = StrUtil.equals("退休", changeParam.getUserState()) + || (StrUtil.isNotBlank(retiredPersonTypeCode) + && StrUtil.equals(retiredPersonTypeCode, StrUtil.trim(changeParam.getPersonType()))); + if (requiresRetireDate && changeParam.getRetireDate() == null) { + throw new BaseException("请选择退休时间"); } - validatePersonType(changeParam.getPersonType()); List userIds; try { @@ -162,11 +242,49 @@ public class PendingConfirmUserServiceImpl extends BaseServiceImpl imp } for (String userId : new ArrayList<>(normalizedUserIds)) { - changePersonType(userId, changeParam.getPersonType()); + MemberChangeRecord record = new MemberChangeRecord(); + record.setUserId(userId); + if (StrUtil.isNotBlank(changeParam.getPersonType())) { + record.setPersonType(changeParam.getPersonType()); + } + if (StrUtil.isNotBlank(changeParam.getUserState())) { + record.setUserState(changeParam.getUserState()); + } + // 满足退休状态或离退休人员类型时统一写入本次指定日期,其他情况忽略隐藏字段。 + if (requiresRetireDate) { + record.setRetireDate(changeParam.getRetireDate()); + } + if (StrUtil.equals("退休", changeParam.getUserState()) && changeParam.getWelfareStopDate() != null) { + record.setWelfareStopDate(DateUtil.formatDate(changeParam.getWelfareStopDate())); + } + if (StrUtil.isNotBlank(changeParam.getUserAttribute())) { + record.setUserAttribute(changeParam.getUserAttribute()); + } + if (changeParam.getMember() != null) { + record.setMember(changeParam.getMember()); + } + if (changeParam.getWelfareMember() != null) { + record.setWelfareMember(changeParam.getWelfareMember()); + } + changePendingUserFields(record); } return normalizedUserIds.size(); } + /** + * 判断批量请求是否至少包含一个待整改字段;字符串空值不参与,Boolean false 是有效值。 + * + * @param changeParam 批量变更参数 + * @return true 表示存在至少一个待变更字段 + */ + private boolean hasBatchChangeField(PendingConfirmUserBatchChangeParam changeParam) { + return StrUtil.isNotBlank(changeParam.getPersonType()) + || StrUtil.isNotBlank(changeParam.getUserState()) + || StrUtil.isNotBlank(changeParam.getUserAttribute()) + || changeParam.getMember() != null + || changeParam.getWelfareMember() != null; + } + /** * 仅修改指定待确认人员的人员类型,并复用会员公共服务生成用户历史记录。 * @@ -175,6 +293,20 @@ public class PendingConfirmUserServiceImpl extends BaseServiceImpl imp * @return true 表示人员类型发生变化,false 表示类型未变化但已完成确认 */ private boolean changePersonType(String userId, String personType) { + MemberChangeRecord record = new MemberChangeRecord(); + record.setUserId(userId); + record.setPersonType(personType); + return changePendingUserFields(record); + } + + /** + * 仅采纳待确认页面允许编辑的人员字段,并复用会员公共服务生成用户历史记录。 + * + * @param record 前端提交的待确认人员变更内容 + * @return true 表示人员字段发生变化,false 表示字段未变化但已完成确认 + */ + private boolean changePendingUserFields(MemberChangeRecord record) { + String userId = record.getUserId(); Sys_user user = dao().fetch(Sys_user.class, userId); if (user == null || !Integer.valueOf(STATUS_PENDING).equals(user.getPendingConfirmStatus())) { throw new BaseException("当前人员不属于待确认人员记录"); @@ -185,24 +317,32 @@ public class PendingConfirmUserServiceImpl extends BaseServiceImpl imp if (userView == null) { throw new BaseException("未获取到人员基础信息"); } - // 从数据库当前值重建完整变更对象,除人员类型外不信任前端提交的任何人员字段。 + // 从数据库当前值重建完整变更对象,仅覆盖待确认页面允许编辑的字段。 MemberChangeRecord safeRecord = new MemberChangeRecord(); BeanUtil.copyProperties(userView, safeRecord); safeRecord.setUserId(userId); - safeRecord.setPersonType(StrUtil.trim(personType)); - safeRecord.setMember(Integer.valueOf(1).equals(userView.getMember())); - safeRecord.setWelfareMember(Integer.valueOf(1).equals(userView.getWelfareMember())); + safeRecord.setPersonType(record.getPersonType() == null ? userView.getPersonType() : StrUtil.trim(record.getPersonType())); + safeRecord.setUserState(record.getUserState() == null ? userView.getUserState() : record.getUserState()); + safeRecord.setRetireDate(record.getRetireDate() == null ? userView.getRetireDate() : record.getRetireDate()); + safeRecord.setUserAttribute(record.getUserAttribute() == null ? userView.getUserAttribute() : record.getUserAttribute()); + safeRecord.setMember(record.getMember() == null ? Integer.valueOf(1).equals(userView.getMember()) : record.getMember()); + safeRecord.setWelfareMember(record.getWelfareMember() == null ? Integer.valueOf(1).equals(userView.getWelfareMember()) : record.getWelfareMember()); safeRecord.setArrivalAtSchoolDate(userView.getArrivalAtSchoolDate() == null ? null : DateUtil.formatDate(userView.getArrivalAtSchoolDate())); - safeRecord.setWelfareStopDate(userView.getWelfareStopDate() == null - ? null : DateUtil.formatDate(userView.getWelfareStopDate())); + safeRecord.setWelfareStopDate(record.getWelfareStopDate() == null + ? (userView.getWelfareStopDate() == null ? null : DateUtil.formatDate(userView.getWelfareStopDate())) + : record.getWelfareStopDate()); try { // 复用公共校验和历史记录生成口径,确保单人、批量变更结果保持一致。 memberCommonService.validateChangeAndSetBasicData(safeRecord, MemberChangeOrigin.SYSTEM); } catch (BaseException e) { - // 人员类型与当前值相同时允许直接完成确认;其他业务异常必须继续抛出并触发事务回滚。 + // 公共允许变更字段暂不包含福利会员状态,单独修改该字段时由待确认入口专用逻辑直接更新。 if ("未获取到异动数据".equals(e.getMessage())) { + if (updateWelfareMemberIfChanged(record, userView)) { + markConfirmed(userId); + return true; + } markConfirmed(userId); return false; } @@ -214,6 +354,34 @@ public class PendingConfirmUserServiceImpl extends BaseServiceImpl imp return true; } + /** + * 仅在本次请求明确传入福利会员状态且与当前值不一致时更新;未传入时不参与整改。 + * + * @param record 待确认页面提交的变更内容 + * @param userView 当前人员视图数据 + * @return true 表示已更新福利会员状态 + */ + private boolean updateWelfareMemberIfChanged(MemberChangeRecord record, View_user userView) { + if (record.getWelfareMember() == null) { + return false; + } + boolean currentWelfareMember = Integer.valueOf(1).equals(userView.getWelfareMember()); + if (record.getWelfareMember().equals(currentWelfareMember)) { + return false; + } + Sql sql = Sqls.create(""" + UPDATE sys_user + SET welfareMember = @welfareMember + WHERE id = @userId + AND pendingConfirmStatus = @pendingStatus + """); + sql.setParam("welfareMember", record.getWelfareMember()); + sql.setParam("userId", userView.getId()); + sql.setParam("pendingStatus", STATUS_PENDING); + dao().execute(sql); + return true; + } + /** * 校验待确认人员允许变更的目标人员类型。 * @@ -230,6 +398,53 @@ public class PendingConfirmUserServiceImpl extends BaseServiceImpl imp } } + /** + * 单人待确认变更选择退休时,存在“离退休人员”字典项才自动设置人员类型编码。 + * + * @param record 单人待确认变更内容 + */ + private void applyRetiredPersonType(MemberChangeRecord record) { + if (record == null || !StrUtil.equals("退休", record.getUserState())) { + return; + } + String retiredPersonTypeCode = resolveRetiredPersonTypeCode(); + if (StrUtil.isNotBlank(retiredPersonTypeCode)) { + record.setPersonType(retiredPersonTypeCode); + } + } + + /** + * 批量待确认变更选择退休时,存在“离退休人员”字典项才自动设置人员类型编码。 + * + * @param changeParam 批量待确认变更参数 + */ + private void applyRetiredPersonType(PendingConfirmUserBatchChangeParam changeParam) { + if (changeParam == null || !StrUtil.equals("退休", changeParam.getUserState())) { + return; + } + String retiredPersonTypeCode = resolveRetiredPersonTypeCode(); + if (StrUtil.isNotBlank(retiredPersonTypeCode)) { + changeParam.setPersonType(retiredPersonTypeCode); + } + } + + /** + * 根据人员类型字典名称获取“离退休人员”编码,未配置时返回空字符串。 + * + * @return 离退休人员字典编码,未配置时为空字符串 + */ + private String resolveRetiredPersonTypeCode() { + List personTypeDicts = sysDictService.getSubListByCode("USER_PERSON_TYPE"); + if (personTypeDicts == null) { + return ""; + } + return personTypeDicts.stream() + .filter(dict -> StrUtil.equals("离退休人员", dict.getName())) + .map(Sys_dict::getCode) + .findFirst() + .orElse(""); + } + /** * 变更提交成功后仅把待确认人员更新为已确认,重复调用不会改变其他状态。 * @@ -265,6 +480,7 @@ public class PendingConfirmUserServiceImpl extends BaseServiceImpl imp "unionName", "unionInfo.name", "unitName", "unitInfo.name", "personType", "userInfo.personType", + "userAttribute", "userInfo.userAttribute", "userState", "userInfo.userState" ); return orderColumns.getOrDefault(StrUtil.nullToEmpty(pageOrderName), ""); diff --git a/src/main/resources/db/sys_user_add_welfare_stop_date.sql b/src/main/resources/db/sys_user_add_welfare_stop_date.sql new file mode 100644 index 0000000..9100c44 --- /dev/null +++ b/src/main/resources/db/sys_user_add_welfare_stop_date.sql @@ -0,0 +1,19 @@ +-- 系统用户增加福利享受截止时间,字段保持可空以兼容历史人员数据。 +-- 脚本支持重复执行,字段已存在时不再修改表结构。 +SET @welfare_stop_date_column_count = ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sys_user' + AND COLUMN_NAME = 'welfareStopDate' +); + +SET @welfare_stop_date_ddl = IF( + @welfare_stop_date_column_count = 0, + 'ALTER TABLE sys_user ADD COLUMN welfareStopDate DATE NULL COMMENT ''福利享受截止时间'' AFTER retireDate', + 'SELECT 1' +); + +PREPARE welfare_stop_date_stmt FROM @welfare_stop_date_ddl; +EXECUTE welfare_stop_date_stmt; +DEALLOCATE PREPARE welfare_stop_date_stmt; diff --git a/src/main/resources/static/assets/platform/css/home-portal.css b/src/main/resources/static/assets/platform/css/home-portal.css new file mode 100644 index 0000000..dbf281d --- /dev/null +++ b/src/main/resources/static/assets/platform/css/home-portal.css @@ -0,0 +1,202 @@ +/* 首页服务门户布局单独限定作用域,避免影响应用中心和其他业务页。 */ +#v4-home-app.home-portal { + width: 100%; + max-width: none; + margin: 0; + color: #20344d; + background: #f2f6fb; + font-family: "Microsoft YaHei", "PingFang SC", sans-serif; + font-size: 14px; +} +#v4-home-app.home-portal * { box-sizing: border-box; } +#v4-home-app .portal-top { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 18px; max-width: 1720px; margin: auto; padding: 18px 24px 0; } +/* 使用完整不透明背景铺满横幅,会员信息独立成卡,不再覆盖校园景观。 */ +#v4-home-app .portal-hero { position: relative; min-width: 0; min-height: 260px; overflow: hidden; border-radius: 12px; background: #e8f4ff url('/assets/platform/img/home/service-portal-banner-campus-v4.png') no-repeat right bottom / cover; } +#v4-home-app .portal-hero::after { content: ""; position: absolute; inset: 0; pointer-events: none; background: linear-gradient(90deg, #e8f4ff 0%, #e8f4ff 28%, rgba(232,244,255,.92) 39%, rgba(232,244,255,.35) 54%, rgba(232,244,255,0) 68%); } +#v4-home-app .portal-hero-inner { position: relative; z-index: 1; display: flex; align-items: center; min-height: 260px; height: 100%; padding: 32px 40px; } +#v4-home-app .portal-hero-copy { max-width: 80%; } +#v4-home-app .portal-hero-eyebrow { margin-bottom: 12px; color: #527ca1; font-size: 14px; line-height: 22px; letter-spacing: 2px; } +#v4-home-app .portal-hero-copy h1 { margin: 0 0 10px; font-size: 36px; line-height: 1.3; font-weight: 700; color: #083765; } +#v4-home-app .portal-hero-copy p { margin: 0; color: #335675; font-size: 17px; line-height: 1.8; } +#v4-home-app .portal-values { display: flex; flex-wrap: wrap; gap: 22px 36px; margin-top: 24px; } +#v4-home-app .portal-values span { display: inline-flex; align-items: center; gap: 12px; } +#v4-home-app .portal-values i { color: #3599f2; font-size: 23px; } +/* 首次渲染时分层淡入文字,只改变透明度和位移,不影响卡片布局。 */ +@keyframes portal-text-enter { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} +#v4-home-app .portal-hero-eyebrow, +#v4-home-app .portal-hero-copy h1, +#v4-home-app .portal-hero-copy p, +#v4-home-app .portal-values > span, +#v4-home-app .portal-member-date, +#v4-home-app .portal-member-heading, +#v4-home-app .portal-member .meta-row { animation: portal-text-enter .6s cubic-bezier(.22, 1, .36, 1) backwards; } +#v4-home-app .portal-hero-copy h1 { animation-delay: .08s; } +#v4-home-app .portal-hero-copy p { animation-delay: .16s; } +#v4-home-app .portal-values > span:nth-child(1) { animation-delay: .24s; } +#v4-home-app .portal-values > span:nth-child(2) { animation-delay: .30s; } +#v4-home-app .portal-values > span:nth-child(3) { animation-delay: .36s; } +#v4-home-app .portal-values > span:nth-child(4) { animation-delay: .42s; } +#v4-home-app .portal-member-date { animation-delay: .10s; } +#v4-home-app .portal-member-heading { animation-delay: .18s; } +#v4-home-app .portal-member .meta-row:nth-child(1) { animation-delay: .26s; } +#v4-home-app .portal-member .meta-row:nth-child(2) { animation-delay: .34s; } +#v4-home-app .portal-member .meta-row:nth-child(3) { animation-delay: .42s; } +@media (prefers-reduced-motion: reduce) { + #v4-home-app .portal-hero-eyebrow, + #v4-home-app .portal-hero-copy h1, + #v4-home-app .portal-hero-copy p, + #v4-home-app .portal-values > span, + #v4-home-app .portal-member-date, + #v4-home-app .portal-member-heading, + #v4-home-app .portal-member .meta-row { animation: none; } +} +#v4-home-app .portal-content { max-width: 1720px; margin: auto; padding: 18px 24px 28px; } +/* 同行共用网格行高,统一横纵间距;待办使用紧凑两行信息,避免撑出大块留白。 */ +#v4-home-app .portal-columns { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); gap: 18px; margin-top: 18px; align-items: stretch; } +#v4-home-app .portal-columns > * { min-width: 0; } +#v4-home-app .portal-news-wide { grid-column: 1 / -1; } +#v4-home-app .portal-panel, +#v4-home-app .entry-wrapper, +#v4-home-app .todo-center, +#v4-home-app .work-template-wrapper, +#v4-home-app .jcdt-container { background: #fff; border: 0; border-radius: 12px; padding: 20px; box-shadow: 0 3px 14px rgba(30, 66, 108, .035); } +#v4-home-app .home-section-header, +#v4-home-app .portal-todo-heading, +#v4-home-app .work-template-header, +#v4-home-app .portal-news-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; min-height: 28px; margin: 0 0 16px; } +#v4-home-app .home-section-title, +#v4-home-app .work-template-title, +#v4-home-app .portal-news-title, +#v4-home-app .portal-todo-heading h3 { display: inline-flex; align-items: center; gap: 9px; margin: 0; padding: 0; border: 0; font-size: 20px; line-height: 1.4; font-weight: 600; color: #123356; } +#v4-home-app .home-section-title i, +#v4-home-app .work-template-title i, +#v4-home-app .portal-news-title > i:first-child, +#v4-home-app .portal-todo-heading i { color: #1687f5; font-size: 19px; } +#v4-home-app .portal-more { display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0; border: 0; padding: 4px 0; background: transparent; color: #1687f5; font: inherit; font-size: 13px; cursor: pointer; text-decoration: none; } +#v4-home-app .portal-more:hover { color: #0565c6; } +#v4-home-app button:focus-visible, +#v4-home-app a:focus-visible, +#v4-home-app [tabindex]:focus-visible { outline: 2px solid #1687f5; outline-offset: 3px; } +#v4-home-app .entry-card { min-width: 0; } +#v4-home-app .entry-wrapper { padding-bottom: 0; } +#v4-home-app .portal-entry-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid #e9eff6; } +#v4-home-app .portal-entry-tabs { display: flex; gap: 34px; } +#v4-home-app .portal-entry-tabs button { border: 0; border-bottom: 3px solid transparent; padding: 0 0 13px; background: transparent; font: inherit; font-size: 19px; color: #465d78; font-weight: 600; cursor: pointer; } +#v4-home-app .portal-entry-tabs button.active { color: #0784fa; border-bottom-color: #0784fa; } +#v4-home-app .entry-section + .entry-section { margin-top: 0; } +#v4-home-app .app-grid { display: flex; justify-content: flex-start; align-items: center; gap: 12px; min-height: 0; overflow-x: auto; padding: 20px 0; border: 0; border-radius: 0; box-shadow: none; } +#v4-home-app .app-item { flex: 0 0 96px; border: 0; font: inherit; padding: 0 4px; max-width: none; } +#v4-home-app .app-item:hover { background: #f4f9ff; box-shadow: none; transform: translateY(-2px); } +#v4-home-app .app-icon { background: transparent; } +#v4-home-app .app-name { display: block; width: 100%; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 13px; line-height: 20px; min-height: 0; margin-top: 2px; } +#v4-home-app .app-empty { padding: 10px 0; } +#v4-home-app .act-card { padding: 0; border: 0; border-radius: 0; box-shadow: none; } +#v4-home-app .activity-swiper-container { padding: 0 0 4px; } +#v4-home-app .activity-swiper .swiper-slide .item { display: flex; flex-direction: column; height: 100%; } +/* 加高封面并统一裁剪容器,图片、加载态和占位图都保持相同圆角。 */ +#v4-home-app .activity-swiper .swiper-slide .item .img-box { flex-shrink: 0; height: auto; aspect-ratio: 1.5; border-radius: 10px; overflow: hidden; clip-path: inset(0 round 10px); background: #1687f5; isolation: isolate; } +#v4-home-app .img-box .el-image { display: block; width: 100%; height: 100%; position: absolute; inset: 0; border-radius: inherit; overflow: hidden; } +#v4-home-app .activity-swiper .swiper-slide .item .img-box img { height: 100% !important; max-height: none !important; object-fit: cover !important; border-radius: 10px; } +#v4-home-app .activity-swiper .swiper-slide .item .img-box .status { top: 8px; left: 8px; border-radius: 5px; padding: 4px 7px; background: #ed4859; } +#v4-home-app .activity-swiper .swiper-slide .item .img-box .status-ended { background: #8b97a7; } +#v4-home-app .activity-swiper .swiper-slide .item .img-box .status-upcoming { background: #1687f5; } +#v4-home-app .activity-swiper .swiper-slide .item h4 { flex: 1; margin: 10px 0 8px; height: auto; min-height: 22px; max-height: 44px; font-size: 15px; color: #20344d; line-height: 22px; } +#v4-home-app .activity-swiper .swiper-slide .item .time p { white-space: normal; font-size: 12px; color: #7b899d; line-height: 1.6; } +#v4-home-app .activity-swiper-container .swiper-button-next, +#v4-home-app .activity-swiper-container .swiper-button-prev { width: 44px; height: 44px; margin-top: 0; padding: 0; transform: translateY(-50%); border: 1px solid #dbeaff; background: rgba(255,255,255,.96); color: #1687f5; box-shadow: 0 3px 12px rgba(30, 66, 108, .18); } +#v4-home-app .activity-swiper-container .swiper-button-next { right: 8px; } +#v4-home-app .activity-swiper-container .swiper-button-prev { left: 8px; } +#v4-home-app .activity-swiper-container .swiper-button-next:hover, +#v4-home-app .activity-swiper-container .swiper-button-prev:hover { transform: translateY(-50%) scale(1.05); background: #1687f5; color: #fff; } +#v4-home-app .activity-swiper-container .swiper-button-next::after, +#v4-home-app .activity-swiper-container .swiper-button-prev::after { font-size: 18px; font-weight: 600; } +#v4-home-app .portal-cover-empty { height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 9px; color: #94a7bd; background: #f0f5fb; } +#v4-home-app .portal-cover-empty i { font-size: 28px; } +#v4-home-app .user-wrapper { background: transparent; } +#v4-home-app .user-container { display: block; width: 100%; height: 100%; margin: 0; } +#v4-home-app .todo-center { display: flex; flex-direction: column; min-width: 0; height: 100%; } +#v4-home-app .todo-center .header { display: block; margin: 0; } +#v4-home-app .todo-center .el-tabs { margin: 0 0 8px; } +#v4-home-app .todo-center .el-tabs__header { margin: 0; } +#v4-home-app .todo-center .el-tabs__nav { display: flex; width: 100%; border-color: #e9eff6; border-radius: 6px 6px 0 0; } +#v4-home-app .todo-center .el-tabs__item { flex: 1; min-width: 0; padding: 0 7px; font-size: 12px; height: 36px; line-height: 36px; text-align: center; } +#v4-home-app .todo-center .el-tabs__item.is-active { border-bottom: 2px solid #1687f5; color: #1687f5; } +#v4-home-app .portal-task-list { display: flex; flex-direction: column; justify-content: space-evenly; flex: 1; min-height: 180px; } +#v4-home-app .portal-task-row { display: flex; gap: 10px; align-items: center; padding: 8px 6px; border-bottom: 1px solid #eef3f8; } +#v4-home-app .portal-task-row:last-child { border-bottom: 0; } +#v4-home-app .portal-task-row:hover { background: #f7fbff; } +#v4-home-app .portal-task-icon { width: 34px; height: 40px; display: flex; flex: 0 0 34px; align-items: center; justify-content: center; border-radius: 8px; background: #eef7ff; color: #1687f5; font-size: 22px; } +#v4-home-app .portal-task-content { min-width: 0; flex: 1; } +#v4-home-app .portal-task-title { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: #263548; font-weight: 600; font-size: 14px; line-height: 22px; } +#v4-home-app .portal-task-meta { display: flex; align-items: baseline; gap: 10px; font-size: 12px; color: #8390a2; line-height: 21px; } +#v4-home-app .portal-task-meta span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +#v4-home-app .portal-task-meta > span:first-child { max-width: 45%; flex-shrink: 0; } +#v4-home-app .portal-task-date { flex-shrink: 0; font-size: 12px; color: #7b899d; } +#v4-home-app .portal-task-node { flex: 1; min-width: 0; color: #8b98a9; font-size: 11px; line-height: 17px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +#v4-home-app .portal-task-row .el-button { flex-shrink: 0; padding: 8px 14px; } +#v4-home-app .portal-task-pagination { text-align: right; margin-top: 8px; } +#v4-home-app .portal-empty { min-height: 132px; display: flex; align-items: center; justify-content: center; gap: 10px; color: #8a9aac; } +#v4-home-app .portal-empty i { font-size: 26px; color: #83b9ee; } +#v4-home-app .work-template-wrapper { width: 100%; margin: 0; } +#v4-home-app .work-template-card { min-height: 98px; padding: 0; border: 0; border-radius: 0; box-shadow: none; } +#v4-home-app .work-template-grid { display: flex; align-items: flex-start; gap: 14px; overflow-x: auto; padding: 4px 0 10px; scroll-snap-type: x proximity; } +#v4-home-app .work-template-item { flex: 0 0 88px; width: 88px; padding: 4px; border: 0; font: inherit; scroll-snap-align: start; } +#v4-home-app .work-template-item:hover { background: #f4f9ff; box-shadow: none; } +#v4-home-app .work-template-icon, +#v4-home-app .work-template-icon .el-image { width: 48px; height: 48px; } +#v4-home-app .work-template-icon img { width: 48px !important; height: 48px !important; object-fit: contain; } +#v4-home-app .work-template-icon i { font-size: 30px; line-height: 48px; color: #83b9ee; } +#v4-home-app .work-template-name { font-size: 13px !important; line-height: 20px; min-height: 40px; max-height: 40px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } +#v4-home-app .work-template-wrapper .no-activity-placeholder { padding: 12px; } +#v4-home-app .jcdt-container { width: 100%; max-width: none; height: 100%; margin: 0; } +#v4-home-app .portal-news-title { background: transparent; cursor: pointer; font-family: inherit; } +#v4-home-app .portal-news-title > i:last-child:not(:first-child) { color: #8092a6; font-size: 14px; } +#v4-home-app .portal-news-row { display: flex; align-items: center; gap: 12px; width: 100%; padding: 9px 0; border: 0; background: transparent; color: #42536a; font: inherit; text-align: left; cursor: pointer; } +#v4-home-app .portal-news-row time { flex-shrink: 0; padding: 3px 7px; background: #eff5fc; border-radius: 4px; color: #45698f; font-size: 13px; } +#v4-home-app .portal-news-row span { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +#v4-home-app .portal-news-row:hover span { color: #1687f5; } +@media (min-width: 1600px) { + #v4-home-app .portal-hero-copy h1 { font-size: 42px; } + #v4-home-app .app-item { flex-basis: 104px; } +} +@media (max-width: 1199px) { + #v4-home-app .portal-top { grid-template-columns: minmax(0, 1fr) 300px; padding: 18px 18px 0; } + #v4-home-app .portal-hero-inner { padding-left: 32px; } + #v4-home-app .portal-content { padding: 18px 18px 24px; } + #v4-home-app .portal-columns { grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); } + #v4-home-app .portal-values { gap: 16px; } + #v4-home-app .portal-panel, #v4-home-app .todo-center { padding: 16px; } + #v4-home-app .portal-task-row { gap: 8px; padding: 8px 0; } + #v4-home-app .portal-task-icon { display: none; } +} +@media (max-width: 900px) { + #v4-home-app .portal-top { grid-template-columns: minmax(0, 1fr); } + #v4-home-app .portal-columns { grid-template-columns: minmax(0, 1fr); } + #v4-home-app .portal-activities { order: 1; } + #v4-home-app .portal-todo { order: 2; } + #v4-home-app .portal-templates { order: 3; } + #v4-home-app .portal-news { order: 4; } + #v4-home-app .portal-hero-copy { max-width: 68%; } + #v4-home-app .portal-hero-copy h1 { font-size: 30px; } + #v4-home-app .portal-hero-copy p { font-size: 14px; } + #v4-home-app .portal-task-icon { display: flex; } + #v4-home-app .portal-task-meta { flex-wrap: nowrap; } +} +@media (max-width: 600px) { + #v4-home-app .portal-top { padding: 10px 10px 8px; } + #v4-home-app .portal-hero-inner { padding: 24px 20px; } + #v4-home-app .portal-hero-copy { max-width: 100%; } + #v4-home-app .portal-hero-copy h1 { font-size: 28px; } + #v4-home-app .portal-values { gap: 12px 18px; font-size: 12px; } + #v4-home-app .portal-values i { font-size: 17px; } + #v4-home-app .portal-content { padding: 10px; } + #v4-home-app .entry-wrapper { padding: 16px 12px 0; } + #v4-home-app .portal-entry-tabs { gap: 20px; } + #v4-home-app .portal-entry-tabs button { font-size: 16px; } + #v4-home-app .app-item { flex-basis: 84px; } + #v4-home-app .home-section-title, #v4-home-app .portal-todo-heading h3, #v4-home-app .work-template-title, #v4-home-app .portal-news-title { font-size: 18px; } + #v4-home-app .portal-task-row .el-button { padding: 8px 10px; } +} diff --git a/src/main/resources/static/assets/platform/img/home/service-portal-banner-campus-v2.png b/src/main/resources/static/assets/platform/img/home/service-portal-banner-campus-v2.png new file mode 100644 index 0000000..73d8f10 Binary files /dev/null and b/src/main/resources/static/assets/platform/img/home/service-portal-banner-campus-v2.png differ diff --git a/src/main/resources/static/assets/platform/img/home/service-portal-banner-campus-v3.png b/src/main/resources/static/assets/platform/img/home/service-portal-banner-campus-v3.png new file mode 100644 index 0000000..b59942a Binary files /dev/null and b/src/main/resources/static/assets/platform/img/home/service-portal-banner-campus-v3.png differ diff --git a/src/main/resources/static/assets/platform/img/home/service-portal-banner-campus-v4.png b/src/main/resources/static/assets/platform/img/home/service-portal-banner-campus-v4.png new file mode 100644 index 0000000..34b9058 Binary files /dev/null and b/src/main/resources/static/assets/platform/img/home/service-portal-banner-campus-v4.png differ diff --git a/src/main/resources/static/assets/platform/img/home/service-portal-banner-campus.png b/src/main/resources/static/assets/platform/img/home/service-portal-banner-campus.png new file mode 100644 index 0000000..542628b Binary files /dev/null and b/src/main/resources/static/assets/platform/img/home/service-portal-banner-campus.png differ diff --git a/src/main/resources/views/layouts/v4/baseLayout.html b/src/main/resources/views/layouts/v4/baseLayout.html index 2350c7f..a7e6cc7 100644 --- a/src/main/resources/views/layouts/v4/baseLayout.html +++ b/src/main/resources/views/layouts/v4/baseLayout.html @@ -68,9 +68,9 @@ - - - + + + @@ -436,6 +436,8 @@ .v4-logo { height: 100%; width: auto; + /* 保留 Logo 透明轮廓,将所有可见颜色统一转换为白色。 */ + filter: brightness(0) invert(1); } .v4-nav { @@ -754,7 +756,8 @@ var currentPath = getBaseSubAppPath() var footer = $("#v4-footer") - if (currentPath === "/platform/v4/home") { + // 服务门户首页不显示版权栏,PJAX 切换时同步判断当前页面。 + if (currentPath === "/platform/v4/home" && !$("#v4-home-app").hasClass("home-portal")) { footer.show() } else { footer.hide() diff --git a/src/main/resources/views/layouts/v4/home/act.js b/src/main/resources/views/layouts/v4/home/act.js index 6e8a35b..6ae7fcb 100644 --- a/src/main/resources/views/layouts/v4/home/act.js +++ b/src/main/resources/views/layouts/v4/home/act.js @@ -10,22 +10,23 @@ const act = {
-
+
- - + +
暂无图片
+
+ {{getStatusText(activity)}}
-

+

{{ activity.name}}

-

+

- 时间:{{ activity.startDate }}至{{ activity.endDate }} + {{ formatActivityDate(activity.startDate) }} 至 {{ formatActivityDate(activity.endDate) }}

@@ -34,15 +35,15 @@ const act = {
-
-
+ +
-
📅
+

暂无近期活动

敬请关注后续通知

@@ -57,6 +58,24 @@ const act = { } }, methods: { + updateNavigationPosition() { + const swiperElement = this.$refs.activitySwiper + const cover = swiperElement && swiperElement.querySelector('.img-box') + if (!cover) return + // 按实际封面高度定位,避免标题换行或响应式列数变化导致箭头偏移。 + const container = swiperElement.parentElement + const top = cover.getBoundingClientRect().top - container.getBoundingClientRect().top + cover.offsetHeight / 2 + ;[this.$refs.prevButton, this.$refs.nextButton].forEach(button => { + if (button) button.style.top = top + 'px' + }) + }, + formatActivityDate(value) { + return value ? this.$moment(value).format('YYYY-MM-DD') : '待定' + }, + browseNext() { + if (!this.swiper) return + this.swiper.isEnd ? this.swiper.slideTo(0) : this.swiper.slideNext() + }, /*查询活动*/ listAct() { this.$axios.post("/platform/home/listHomeActivity").then((res) => { @@ -89,13 +108,22 @@ const act = { centeredSlides: false, loop: false, watchOverflow: true, + // 双栏中的活动按容器宽度排布,避免用整个窗口宽度挤出过多卡片。 + breakpointsBase: 'container', observer: false, observeParents: false, - resizeObserver: false, + // 页面内容加载后可能出现滚动条,需重新计算列宽,避免末张封面及圆角被裁切。 + resizeObserver: true, + roundLengths: true, + on: { + init: () => this.$nextTick(() => this.updateNavigationPosition()), + resize: () => this.$nextTick(() => this.updateNavigationPosition()), + slidesUpdated: () => this.$nextTick(() => this.updateNavigationPosition()), + }, // 鼠标停留在活动区域时可横向浏览,到首尾后释放页面滚动。 mousewheel: { enabled: true, - forceToAxis: false, + forceToAxis: true, releaseOnEdges: true, sensitivity: 0.8, }, @@ -108,14 +136,11 @@ const act = { type: 'progressbar', }, breakpoints: { - 768: { + 480: { slidesPerView: 2, }, - 1024: { - slidesPerView: 4, - }, - 1200: { - slidesPerView: 5, + 740: { + slidesPerView: 3, } } }) diff --git a/src/main/resources/views/layouts/v4/home/entry.js b/src/main/resources/views/layouts/v4/home/entry.js index f27d663..6e78505 100644 --- a/src/main/resources/views/layouts/v4/home/entry.js +++ b/src/main/resources/views/layouts/v4/home/entry.js @@ -1,18 +1,19 @@ const entry = { template: /*language=HTML*/ `
-
-
-
- - {{ section.title }} - /Applications -
+
+
+
+ 全部应用 +
+
-
-
+
-
{{ item.name }}
-
+
{{ item.name }}
+ -
+ + +
-
-