feat: 同步地大代码

This commit is contained in:
2026-10-08 15:33:01 +08:00
parent ae37d48657
commit 4cd992fb28
116 changed files with 5824 additions and 2179 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.budwk</groupId>
<artifactId>zhgh_zjnu</artifactId>
<artifactId>mini</artifactId>
<version>5.6.0-plus</version>
<build>
<resources>
@@ -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);
@@ -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("获取流程设计任务参与者处理类")
@@ -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")
@@ -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);
@@ -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")
@@ -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);
}
@@ -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)
@@ -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<Sys_role> {
*/
List<String> getRoleIdsByCode(List<RoleConstant> roleConstantList);
/**
* 为用户表中 member=1 的全部人员补齐启用的 MEMBER 角色,不受列表筛选影响。
*
* @return addedCount 为新增关联数,enabledCount 为恢复启用的关联数;已有启用关联不重复添加
*/
NutMap settingMemberRole();
/**
* 清空缓存
*/
@@ -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);
@@ -132,6 +132,39 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl<Sys_user_source>
Date nowDate = new Date();
// 人员单位号先与单位表批量比对,缺失单位只同步一次,避免逐人查询单位表。
Set<String> sourceUnitIds = rawDataList.stream()
.map(raw -> raw.getStr("SZDWH"))
.filter(StrUtil::isNotBlank)
.collect(Collectors.toSet());
Map<String, Sys_unit> 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<String, String> 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<Sys_user_source> latestSourceList = rawDataList.stream().map(raw -> {
Sys_user_source sysUser = new Sys_user_source();
@@ -174,7 +207,10 @@ public class SysDataUserPullServiceImpl extends BaseServiceImpl<Sys_user_source>
}
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<Sys_user_source>
dao().insert(latestSourceList);
}
// 人员的单位数据和数据库的单位数据比较,如果人员里面有单位不存在,去更新单位数据
List<String> 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<String> 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<Sys_user_source>
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<Sys_user_source> userSources) {
//判断是否要更新在职状态字典
@@ -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<Sys_role> 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<Sys_role> 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());
}
}
@@ -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<ClubUser> clubUserList = dao.query(ClubUser.class, Cnd.NEW());
// 生成缴费记录对象
List<ClubPayRecord> 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());
}
}
@@ -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();
@@ -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,
@@ -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
@@ -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<NutMap> 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<NutMap> 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
@@ -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<String> 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<Sys_user_role> 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);
}
}
@@ -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<String> 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<NutMap> 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<ClubPayRecord> list = payRecordService.query(Cnd.where("id", "in", ids));
for (ClubPayRecord payRecord : list) {
List<JSONObject> 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();
}
}
@@ -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()));
}
@@ -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);
}
@@ -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("操作记录")
@@ -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;
}
@@ -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("社团职务")
@@ -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;
@@ -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<ClubPayRecord> {
/** 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<ClubPayRecord> 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<NutMap> 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);
/**
* 获取社团成员是否拨付
@@ -5,6 +5,20 @@ import com.budwk.app.zhgh.club.model.ClubUserApply;
public interface ClubUserJoinService extends BaseService<ClubUserApply> {
/**
* 清除待审核入会申请的入退会时间,防止沿用协会成立时间或上次入会时间。
* @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> {
ClubUserApply buildExitApply(String clubId, String userId);
/**
* 获取社团成员的入会时间,优先取历史有效入会申请记录,
* 没有时兜底为社团创办时间。
* 获取社团成员的入会时间,优先取当前 club_user.joinTime。
* 旧会员关系尚无入会时间时,保留历史申请及社团创办时间的原有回退规则。
*
* @param clubId 社团ID
* @param userId 用户ID
@@ -13,12 +13,24 @@ import java.util.List;
public interface SysClubExamineService extends BaseService<SysClubExamineRegister> {
// 2026年10月2日wxg屏蔽操作:保留原统计接口说明。
// /**
// * 按会员入会、退休、退会时间统计指定年度的在职和退休人数变化。
// *
// * @param clubId 社团ID
// * @param year 统计年度
// * @return 在职和退休人员的年初、增加、减少、年末人数
// */
// List<NutMap> 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<NutMap> getClubUserNum(@Valid String clubId, Integer year);
@@ -22,6 +22,13 @@ public interface SysClubInfoManageService extends BaseService<ClubCommonPageVo>
Pagination<ClubCommonPageVo> infoManagePageData(@Valid ClubUserPageForm pageForm);
/**
* 修改时间:2026年10月2日 23:06:55;操作人:wxg。
* 当前会员查询前,用已完成 WF 入会流程的最近一次会长同意审核时间补齐空入会时间。
* @param pageForm clubId 为选中社团ID;radioType=4 查历史成员且不补齐,其他值查当前会员;
* 姓名/工号、人员类型、在职状态、入会日期范围及分页排序参数用于列表筛选
* @return 会员分页对象,list 中 joinTime 为会员关系保存的入会时间;历史成员仍取申请表时间
*/
Pagination<ClubUserCommonPageVo> infoManageUserPageData(@Valid ClubUserPageForm pageForm);
Pagination<ClubUserCommonPageVo> exitManagePageData(@Valid ClubUserPageForm pageForm);
@@ -30,6 +37,13 @@ public interface SysClubInfoManageService extends BaseService<ClubCommonPageVo>
ClubUserJoinVo exitManageInfo(String id);
/**
* 管理端直接退会,同时新增系统处理记录并清理当前社团会员关系及角色关联。
* @param membershipId club_user 的会员关系ID,不是用户ID或申请ID
* @return void;会员关系不存在或已退会时抛出异常,事务回滚
*/
void exitClub(String membershipId);
List<NutMap> getClubTreeData();
/**
@@ -23,6 +23,12 @@ import java.util.List;
**/
public interface SysClubService extends BaseService<SysClub> {
/**
* 获取入会申请的社团下拉选项,供 PC 与 H5 共用。
* @return 存在已完成 WF 流程的社团列表,每项为 SysClub,包含 id、clubName 等社团字段;同一社团只返回一次
*/
List<SysClub> listJoinClubOptions();
Pagination<ClubCommonPageVo> getAllClubWithPage(@Valid PageForm pageForm, Cnd cnd);
ClubRegisterVo findOne(String clubId);
@@ -17,6 +17,32 @@ import java.util.List;
**/
public interface SysClubUserService extends BaseService<ClubUser> {
/**
* 为直接新增的会员关系设置本批次的后端确认时间,不覆盖已有入会时间。
* @param members 手工录入、导入或社团创建时直接新增的会员关系;不得传入审核入会记录
* @return void;仅填写列表内 joinTime 为空的对象,由调用方在同一事务中保存
*/
void prepareDirectJoinTimes(List<ClubUser> 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<ClubUser> members);
List<NutMap> getClubUser(@Valid String clubId);
Pagination pageDataByApplyJoinClubAudit(@Valid ClubUserPageForm pageForm);
@@ -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<ClubPayRecord> 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<ClubPayRecord> 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<String> userIds = records.stream().map(ClubPayRecord::getUserId).distinct().toList();
List<String> clubIds = records.stream().map(ClubPayRecord::getClubId).distinct().toList();
Map<String, View_user> users = new HashMap<>();
for (View_user user : dao().query(View_user.class, Cnd.where("id", "in", userIds))) {
users.put(user.getId(), user);
}
Map<String, Date> joinTimes = new HashMap<>();
Set<String> 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<String> recorded = new HashSet<>();
for (ClubPayRecord record : dao().query(ClubPayRecord.class, Cnd.where("year", "=", year))) {
recorded.add(record.getClubId() + ":" + record.getUserId());
}
List<ClubPayRecord> 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<String> 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<NutMap> 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<NutMap> list = pagination.getList();
Set<String> activeMembers = new HashSet<>();
if (!list.isEmpty()) {
List<String> userIds = list.stream().map(row -> row.getString("userId")).distinct().toList();
List<String> 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<ClubPayRecord> records = dao().query(ClubPayRecord.class, scope);
if (records.size() != java.util.Arrays.stream(ids).distinct().count()) {
throw new IllegalArgumentException("部分缴费记录不存在或无权操作,请刷新列表");
}
List<String> userIds = records.stream().map(ClubPayRecord::getUserId).distinct().toList();
List<String> clubIds = records.stream().map(ClubPayRecord::getClubId).distinct().toList();
Set<String> 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<JSONObject> 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<ClubPayRecord> 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());
}
}
@@ -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<ClubUserApply> implements ClubUserJoinService {
// PC 与 H5 入会资格校验共用此名单,人员类型须与用户视图中的值完全一致。
private static final List<String> 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<ClubUserApply> 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<ClubUserApply> 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<ClubUserApply> 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)
@@ -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<SysClubExamineReg
super(dao);
}
@Override
public List<NutMap> 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<MembershipPeriod> membershipPeriods = getMembershipPeriods(clubId);
NutMap activeResult = createUserChangeResult("在职");
NutMap retiredResult = createUserChangeResult("退休");
// 2026年10月2日wxg屏蔽操作:原统计入口及专用辅助代码逐行注释,保留原文便于对比或恢复。
// @Override
// public List<NutMap> 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<MembershipPeriod> 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<MembershipPeriod> 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<MembershipPeriod> 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<NutMap> applications = listMap(applySql);
// if (!applications.isEmpty()) {
// // 流程状态改为按当前社团申请批量查询,避免相关子查询对流程实例表重复扫描。
// List<String> applicationIds = applications.stream()
// .map(application -> application.getString("id"))
// .filter(StrUtil::isNotBlank)
// .collect(Collectors.toList());
// List<ProcessInstance> finishedInstances = applicationIds.isEmpty() ? new ArrayList<>()
// : dao().query(ProcessInstance.class, Cnd.where(ProcessInstance::getBusinessNo, "in", applicationIds)
// .and(ProcessInstance::getState, "=", ProcessInstanceStateEnum.FINISHED.getCode()));
// Set<String> 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<MembershipPeriod> 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<MembershipPeriod> 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<NutMap> 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<NutMap> 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<String, NutMap> snapshots = new java.util.LinkedHashMap<>();
Map<String, AnnualMemberPeriod> 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<NutMap> 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<NutMap> 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<String> userIds = periods.values().stream().map(period -> period.userId).collect(Collectors.toSet());
Map<String, NutMap> 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<String, Date> previousEnd = new java.util.HashMap<>();
Set<String> retiredTransfers = new HashSet<>();
List<AnnualMemberPeriod> 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<String, AnnualMemberPeriod> 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<MembershipPeriod> 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<MembershipPeriod> 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<NutMap> applications = listMap(applySql);
if (!applications.isEmpty()) {
// 流程状态改为按当前社团申请批量查询,避免相关子查询对流程实例表重复扫描。
List<String> applicationIds = applications.stream()
.map(application -> application.getString("id"))
.filter(StrUtil::isNotBlank)
.collect(Collectors.toList());
List<ProcessInstance> finishedInstances = applicationIds.isEmpty() ? new ArrayList<>()
: dao().query(ProcessInstance.class, Cnd.where(ProcessInstance::getBusinessNo, "in", applicationIds)
.and(ProcessInstance::getState, "=", ProcessInstanceStateEnum.FINISHED.getCode()));
Set<String> 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<MembershipPeriod> 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<MembershipPeriod> 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<String> warnings = new java.util.LinkedHashSet<>();
final List<NutMap> 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<NutMap> 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<NutMap> getJgUser(String clubId) {
@@ -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<ClubCommonPage
private SysClubService sysClubService;
@Inject
private SysOfficeTemplateUtil sysOfficeTemplateUtil;
@Inject
private ClubUserJoinService clubUserJoinService;
@Inject
private ClubPayRecordService payRecordService;
@Inject
private SysUserService sysUserService;
@Inject
private SysRoleService sysRoleService;
public SysClubInfoManageServiceImpl(Dao dao) {
super(dao);
}
/**
* 直接退会不启动 WF;系统处理记录、会员关系删除和角色清理必须同时成功。
* @param membershipId 当前 club_user 会员关系ID
* @return void;重复退会或会员不存在时抛出异常,避免生成重复记录
*/
@Override
@Aop(TransAop.READ_COMMITTED)
public void exitClub(String membershipId) {
ClubUser member = dao().fetch(ClubUser.class, membershipId);
if (member == null) {
throw new IllegalArgumentException("该会员已退会或会员关系不存在");
}
ClubUserApply source = clubUserJoinService.buildExitApply(member.getClubId(), member.getUserId());
if (source == null) {
throw new IllegalArgumentException("无法获取该会员的退会信息");
}
// 使用新对象,只复制业务资料,防止沿用历史申请ID、创建人和创建时间。
ClubUserApply application = new ClubUserApply();
application.setClubId(member.getClubId());
application.setUserId(member.getUserId());
application.setRoleCode(source.getRoleCode());
application.setClubPosition(source.getClubPosition());
application.setEmail(source.getEmail());
application.setMobile(source.getMobile());
application.setBirthday(source.getBirthday());
application.setAvatar(source.getAvatar());
application.setSameTimeJoinOtherClubSituation(source.getSameTimeJoinOtherClubSituation());
application.setAwardsExperience(source.getAwardsExperience());
application.setSignature(source.getSignature());
application.setJoinTime(source.getJoinTime());
Date exitTime = new Date();
application.setMode(2);
application.setApplyDate(exitTime);
application.setExitTime(exitTime);
// 条件删除只能成功一次;并发重复操作不再新增退会记录,delFlag 沿用默认值。
if (dao().delete(ClubUser.class, membershipId) != 1) {
throw new IllegalArgumentException("该会员已退会,请刷新列表");
}
dao().insert(application);
// 2026年10月3日 09:51:00;操作人:wxg:系统直接退会与取消拨付处于同一事务,避免退会后仍保存可拨付。
payRecordService.disableExitMemberPayment(member.getClubId(), member.getUserId());
if (member.getRoleCode() != null) {
for (String roleCode : member.getRoleCode()) {
Sys_role role = sysRoleService.getByCode(roleCode);
if (role != null) {
dao().clear(Sys_user_role.class, Cnd.where("userId", "=", member.getUserId())
.and("roleId", "=", role.getId()).and("clubId", "=", member.getClubId()));
}
}
}
sysUserService.clearCache();
sysRoleService.clearCache();
}
@Override
public Pagination<ClubCommonPageVo> infoManagePageData(ClubUserPageForm pageForm) {
Cnd cnd = Cnd.NEW();
@@ -129,102 +205,188 @@ public class SysClubInfoManageServiceImpl extends BaseServiceImpl<ClubCommonPage
return listPageVO(pageForm, sql, ClubCommonPageVo.class);
}
/**
* 2026年10月3日 12:01:28;操作人:wxg:当前会员以 club_user 分页,查询期间不补齐或修改数据库。
* @param pageForm clubId 为选中社团ID;radioType=4 查历史成员,其他值查当前会员;
* searchKeyword 匹配会员表姓名/工号,personType/userState 筛选当前用户资料;
* focusMembers=true 同时筛选当前人员类型为自聘人员或其他的会员,false/空值不限制;
* joinDateStart/joinDateEnd 为 yyyy-MM-dd,pageNumber/pageSize 为分页参数,排序只允许列表字段
* @return 会员分页;姓名、工号、电话、joinTime 来自会员表,其余人员资料只补充本页;历史页沿用申请记录
*/
@Override
public Pagination<ClubUserCommonPageVo> infoManageUserPageData(ClubUserPageForm pageForm) {
if (Integer.valueOf(4).equals(pageForm.getRadioType())) {
return historyMemberPageData(pageForm);
}
if (StrUtil.isBlank(pageForm.getClubId())) {
throw new IllegalArgumentException("请选择社团后查询会员信息");
}
// 姓名、工号、电话及入会时间使用持久化字段;列排序需要其他资料时才关联用户表。
Map<String, String> 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<ClubUserCommonPageVo> listPageVO = sysClubService.listPageVO(pageForm, sql, ClubUserCommonPageVo.class);
List<ClubUserCommonPageVo> listMap = listPageVO.getList(ClubUserCommonPageVo.class);
for (ClubUserCommonPageVo vo : listMap) {
List<String> list = vo.getRoleCode();
List<String> cleanedCodes = list.stream()
.filter(Objects::nonNull)
.map(String::strip)
Pagination<ClubUserCommonPageVo> page = sysClubService.listPageVO(pageForm, sql, ClubUserCommonPageVo.class);
List<ClubUserCommonPageVo> members = page.getList(ClubUserCommonPageVo.class);
fillMemberPageDetails(members, pageForm.getClubId());
for (ClubUserCommonPageVo member : members) {
List<String> roleCodes = member.getRoleCode() == null ? List.of() : member.getRoleCode();
List<String> 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<ClubUserCommonPageVo> members, String clubId) {
if (members.isEmpty()) return;
List<String> userIds = members.stream().map(ClubUser::getUserId).filter(Objects::nonNull).distinct().toList();
Map<String, NutMap> 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<ClubUserCommonPageVo> 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<String> 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<ClubUserCommonPageVo> pagination = listPageVO(pageForm, sql, ClubUserCommonPageVo.class);
List<ClubUserCommonPageVo> 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<ClubUserCommonPageVo> exitManagePageData(ClubUserPageForm pageForm) {
Sql sql = Sqls.create("""
@@ -257,6 +419,13 @@ public class SysClubInfoManageServiceImpl extends BaseServiceImpl<ClubCommonPage
""");
Cnd cnd = Cnd.NEW();
cnd.andEX("exitInfo.clubId", "=", pageForm.getClubId());
// 2026年10月3日 19:42:45;操作人:wxg:按实际退会时间筛选三类记录,结束日期使用次日零点上界,包含当天。
if (StrUtil.isNotBlank(pageForm.getExitDateStart())) {
cnd.and("exitInfo.exitTime", ">=", 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<ClubCommonPage
cnd.and("exitInfo.clubId", "in", clubList.stream().map(Sys_user_role::getClubId).toList());
}
cnd.groupBy("exitInfo.id");
cnd.desc("exitTime");
// 2026年10月3日 19:42:45;操作人:wxg:最新退会记录优先,时间相同时按ID保证分页稳定;空时间排在最后。
cnd.desc("exitInfo.exitTime");
cnd.asc("exitInfo.id");
sql.setCondition(cnd);
Pagination<ClubUserCommonPageVo> pagination = listPageVO(pageForm, sql, ClubUserCommonPageVo.class);
List<ClubUserCommonPageVo> list = pagination.getList(ClubUserCommonPageVo.class);
@@ -321,6 +492,8 @@ public class SysClubInfoManageServiceImpl extends BaseServiceImpl<ClubCommonPage
/**
* 退会申请审批完成后会删除club_user成员关系,因此退会管理以退会申请记录为主数据源。
* 同时保留旧sys_club_user表中已退会的数据,避免历史直接移出记录无法查询。
* 2026年10月3日 19:42:45;操作人:wxg:mode=2 为系统处理,无需 WF;mode=0 通过 EXISTS 判断 WF 完成,避免一对多重复。
* 同一用户、社团存在以上申请时不再重复展示旧历史表记录;每条申请仍独立保留,列表与详情共用。
*
* @return 退会管理列表和详情共用的数据来源SQL
*/
@@ -343,11 +516,12 @@ public class SysClubInfoManageServiceImpl extends BaseServiceImpl<ClubCommonPage
cua.applyDate,
cua.joinTime,
cua.exitTime,
'AUDIT_EXIT' AS exitType
CASE WHEN cua.mode = 2 THEN 'SYSTEM_EXIT' ELSE 'AUDIT_EXIT' END AS exitType
FROM
club_user_apply cua
INNER JOIN wf_process_instance ins ON ins.businessNo = cua.id AND ins.state = 20
WHERE cua.mode = false
WHERE cua.mode = 2 OR (cua.mode = 0 AND EXISTS (
SELECT 1 FROM wf_process_instance ins WHERE ins.businessNo = cua.id AND ins.state = 20
))
UNION ALL
SELECT
scu.id,
@@ -378,10 +552,11 @@ public class SysClubInfoManageServiceImpl extends BaseServiceImpl<ClubCommonPage
AND NOT EXISTS (
SELECT 1
FROM club_user_apply cua
INNER JOIN wf_process_instance ins ON ins.businessNo = cua.id AND ins.state = 20
WHERE cua.mode = false
AND cua.clubId = scu.clubId
WHERE cua.clubId = scu.clubId
AND cua.userId = scu.userId
AND (cua.mode = 2 OR (cua.mode = 0 AND EXISTS (
SELECT 1 FROM wf_process_instance ins WHERE ins.businessNo = cua.id AND ins.state = 20
)))
)
) exitInfo
LEFT JOIN sys_club club ON club.id = exitInfo.clubId
@@ -431,6 +606,12 @@ public class SysClubInfoManageServiceImpl extends BaseServiceImpl<ClubCommonPage
return result;
}
/**
* 按现有成员排序渲染 clubRegis Word 模板,模板读取为空时提前阻止解析及下载。
* @param clubId 当前选中社团ID,登记表包含该社团资料和成员信息
* @param response 写入生成的 DOCX 附件;模板或渲染失败时抛出中文业务异常
* @return void,文件内容通过响应输出,错误结果由统一异常处理返回
*/
@Override
public void exportRegistrationDoc(String clubId, HttpServletResponse response) {
Sql sql = Sqls.create("""
@@ -501,11 +682,22 @@ public class SysClubInfoManageServiceImpl extends BaseServiceImpl<ClubCommonPage
.bind("cys", policy)
.build();
try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
XWPFTemplate.compile(sysOfficeTemplateUtil.getTemplate("clubRegis"), config).render(docData).writeAndClose(byteArrayOutputStream);
try (InputStream templateStream = sysOfficeTemplateUtil.getTemplate("clubRegis");
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
// 文件存储读取失败会返回空内容,先校验再交给 POI,避免显示英文 EmptyFileException。
byte[] templateBytes = templateStream == null ? new byte[0] : templateStream.readAllBytes();
if (templateBytes.length == 0) {
throw new BaseException("社团登记表模板读取失败,请检查模板文件及文件存储服务。");
}
XWPFTemplate.compile(new ByteArrayInputStream(templateBytes), config).render(docData).writeAndClose(byteArrayOutputStream);
CommonDownloadUtil.download("教职工文体社团登记表(" + clubInfo.getString("clubName") + ").docx", byteArrayOutputStream.toByteArray(), response);
} catch (IOException e) {
log.error("社团登记表导出失败,id:{},错误信息:{}", clubId, e.getMessage());
} catch (BaseException e) {
// 保留模板配置及空文件校验的具体提示,同时记录完整异常以便排查。
log.error("社团登记表导出失败,id:{}", clubId, e);
throw e;
} catch (IOException | RuntimeException e) {
log.error("社团登记表导出失败,id:{}", clubId, e);
throw new BaseException("社团登记表生成失败,请检查模板是否为有效的 Word 文件或联系管理员。");
}
}
}
@@ -29,13 +29,17 @@ import com.budwk.app.zhgh.club.model.SysClubSponsor;
import com.budwk.app.zhgh.club.param.ClubUserPageForm;
import com.budwk.app.zhgh.club.service.SysClubService;
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.ClubRegisterPageVo;
import com.budwk.app.zhgh.club.vo.ClubRegisterVo;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
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;
@@ -60,10 +64,27 @@ public class SysClubServiceImpl extends BaseServiceImpl<SysClub> implements SysC
@Inject
private SysClubUserService sysClubUserService;
@Inject
private ClubPayRecordService payRecordService;
@Inject
private CommonService commonService;
@Inject
private BpmService bpmService;
@Override
public List<SysClub> 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<ClubCommonPageVo> getAllClubWithPage(PageForm pageForm, Cnd cnd) {
Sql sql = Sqls.create("""
@@ -77,10 +98,11 @@ public class SysClubServiceImpl extends BaseServiceImpl<SysClub> 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<SysClub> implements SysC
}
@Override
@Aop(TransAop.READ_COMMITTED)
public SysClub doAdd(SysClub club, List<NutMap> managePerson) {
club.setDismiss(false);
club.setUserId(SecurityUtil.getUserId());
@@ -178,6 +201,7 @@ public class SysClubServiceImpl extends BaseServiceImpl<SysClub> 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<SysClub> 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<SysClub> implements SysC
}
@Override
@Aop(TransAop.READ_COMMITTED)
public SysClub doEdit(SysClub club, List<String> deleteIds, List<NutMap> managePerson) {
// 修改社团表
dao().update(club);
@@ -219,6 +246,8 @@ public class SysClubServiceImpl extends BaseServiceImpl<SysClub> implements SysC
// 重新插入发起人
dao().insertLinks(club, "sponsors");
// 现有编辑流程会重建会员关系,删除前保存原入会时间,避免把编辑时刻算成再次入会。
List<ClubUser> 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<SysClub> 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<ClubUser> 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;
@@ -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<ClubUser> 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<ClubUser> members) {
if (members == null || members.isEmpty()) return;
if (members.stream().anyMatch(member -> member == null || StrUtil.isBlank(member.getUserId()))) {
throw new IllegalArgumentException("新增会员必须提供有效用户ID");
}
List<String> userIds = members.stream().map(ClubUser::getUserId).distinct().toList();
Map<String, Sys_user> 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<ClubUser> 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<NutMap> 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<ClubUser> 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,
@@ -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<JSONObject> replaceReport;
private List<JSONObject> rulesFile;
private List<JSONObject> afterRulesFile;
private List<JSONObject> 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;
@@ -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;
@@ -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<NutMap> clubList = baseService.listMap(sql);
List<OutlayManageClub> 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();
}
@@ -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);
@@ -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<NutMap> clubs = listMap(lockSql);
if (clubs.isEmpty()) return 0;
Set<String> 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<NutMap> clubList = listMap(sql);
Set<String> lockedClubs = clubs.stream().map(club -> club.getString("id")).collect(Collectors.toSet());
List<OutlayManageClub> 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) {
@@ -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);
@@ -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());
}
});
@@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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());
}
}
}
@@ -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)
@@ -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;
}
@@ -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)
@@ -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<UnionReimburseBankAccount> {
/**
* @param payer 付款人用户ID,非姓名或工号
* @param reimburseFundSource 当前经费来源字典编码
* @param clubId 协会经费对应的协会ID,其他来源可为空
* @return 可选账户列表,每项包含 id、bankUserName、bankCardNumber、bankOfDeposit
*/
List<NutMap> listAccounts(String payer, String reimburseFundSource, String clubId);
/** @param form 已通过校验的报销单;提取全部对私支付明细,去重保存完整账户。 */
void saveAccounts(UnionReimburse form);
/** @return 扫描的历史报销单数量;仅系统管理员可执行,可重复执行且不覆盖已有账户。 */
int importHistory();
}
@@ -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<String, Object> buildExportData(UnionReimburse form);
}
@@ -50,7 +50,10 @@ public interface UnionReimburseService extends BaseService<UnionReimburse> {
Result checkInvoiceDuplicate(String invoiceNo, String reimburseId);
/**
* 识别单张发票文件,并在需要时调用验真接口补齐票面信息。
* 识别单张发票文件并强制验真。
* @param fileId 已上传发票的文件ID
* @param verifySwitch 兼容旧页面参数,不再允许关闭验真
* @return Result.data 包含票号、金额、销售方、项目名称及 verified=true;失败返回非零 code 和原因
*/
Result recognizeInvoice(String fileId, Boolean verifySwitch);
@@ -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<UnionReimburseBankAccount>
implements UnionReimburseBankAccountService {
public UnionReimburseBankAccountServiceImpl(Dao dao) {
super(dao);
}
/** 按当前付款人候选范围校验用户ID,防止直接传入其他人员ID读取账户。 */
@Override
public List<NutMap> 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<UnionReimburse> 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();
}
}
}
@@ -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<String, String> 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<String, Object> 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<String, Object> 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<String, Object> buildExportData(UnionReimburse form) {
Map<String, Object> 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<JSONObject> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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"); }
}
@@ -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<UnionReimburse> 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<UnionReimburse> 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<UnionReimburse> 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<UnionReimburse> 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<UnionReimburse> 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<UnionReimburse> 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<UnionReimburse> 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<UnionReimburse> 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<UnionReimburseInvoiceDetail> invoiceDetails = this.dao().query(
UnionReimburseInvoiceDetail.class,
Cnd.where("reimburseId", "=", id).asc("createdAt")
@@ -416,6 +441,11 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> 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<UnionReimburse> 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<UnionReimburse> i
}
/**
* 活动类报销由发票明细自动汇总金额和发票张数,减少手工输入出错。
* 有发票时汇总票面金额和张数;无发票时按已校验的支付明细汇总金额,张数为零。
*/
private void fillInvoiceSummary(UnionReimburse unionReimburse) {
double totalMoney = 0D;
@@ -646,30 +684,15 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> 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<UnionReimburse> 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<UnionReimburse> 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<UnionReimburse> i
return null;
}
/**
* 支付明细须先通过单行金额校验,再按分比较全部对公、对私金额与发票总额。
* @param form 当前申请,使用 paymentDetails 和 invoiceDetails 重新汇总,不信任前端 money 字段
* @return 超额或发票金额无效时返回 Result 错误,通过返回 null
*/
private Result validatePaymentAmountLimit(UnionReimburse form) {
List<UnionReimburseInvoiceDetail> 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<UnionReimburse> 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<UnionReimburse> 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<UnionReimburse> i
return null;
}
/**
* @param form 已通过完整性校验的申请,逐条使用附件ID读取真实文件并验真
* @return 全部通过返回 null;失败返回包含明细行号的 Result,不写入任何报销数据
*/
private Result validateInvoiceAuthenticity(UnionReimburse form) {
Set<String> 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<UnionReimburse> 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<UnionReimburse> 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<UnionReimburse> i
return BigDecimal.valueOf(money).setScale(2, RoundingMode.HALF_UP).toPlainString();
}
@Aop(TransAop.READ_COMMITTED)
private void saveInvoiceDetails(String reimburseId, List<UnionReimburseInvoiceDetail> invoiceDetails) {
this.dao().clear(UnionReimburseInvoiceDetail.class, Cnd.where("reimburseId", "=", reimburseId));
if (Lang.isEmpty(invoiceDetails)) {
@@ -1366,7 +1608,7 @@ public class UnionReimburseServiceImpl extends BaseServiceImpl<UnionReimburse> i
}
/**
* 非慰问类报销必须维护发票明细,慰问类发票明细为可选。
* 区分活动类与慰问类的基本信息校验;所有类型的发票均为选填。
*/
private boolean needInvoiceDetails(UnionReimburse unionReimburse) {
return unionReimburse != null && !"UNION_REIMBURSE_PROJECT_1".equals(unionReimburse.getReimburseProject());
@@ -17,6 +17,8 @@ public class AidFundPageForm extends PageForm {
private String unitId;
//是否当年未缴费
private Boolean notPayedCurrentYear;
//是否筛选人员类型与基金会员类型不一致的基金会员
private Boolean personTypeChanged;
//基金会员变更类型
private String changeType;
@@ -88,6 +88,8 @@ public class AidFundMemberPayServiceImpl extends BaseServiceImpl<AidFundMemberPa
}
if (StrUtil.isAllBlank(pageForm.getPageOrderName(), pageForm.getPageOrderBy())) {
// 2026年10月3日 19:04:27;操作人:wxg:默认优先展示最新年度,同年度内保留单位编码倒序;主动指定排序时沿用原规则。
cnd.desc("pay.year");
cnd.desc("us.unitCode");
} else {
cnd.orderBy(pageForm.getPageOrderName(), PageUtil.getOrder(pageForm.getPageOrderBy()));
@@ -4,9 +4,11 @@ import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.ObjectUtil;
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.service.impl.BaseServiceImpl;
import com.budwk.app.base.utils.PageUtil;
import com.budwk.app.sys.models.Sys_user;
import com.budwk.app.sys.models.Sys_dict;
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;
@@ -22,6 +24,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.dao.util.cri.SqlExpressionGroup;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.IocBean;
@@ -29,6 +32,7 @@ import org.nutz.lang.Strings;
import org.nutz.lang.util.NutMap;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
@@ -139,6 +143,7 @@ public class AidFundMemberServiceImpl extends BaseServiceImpl implements AidFund
*/
@Override
public Sql getManageSql(AidFundPageForm pageForm) {
// 返回当前人员类型,供人员类型变化名单与基金会员类型对照核实。
Sql sql = Sqls.create("""
SELECT
id,
@@ -149,6 +154,7 @@ public class AidFundMemberServiceImpl extends BaseServiceImpl implements AidFund
unitName,
unionName,
aidFundMemberUserType,
personType,
aidFundMemberJoinTime
FROM
vw_user
@@ -167,6 +173,17 @@ public class AidFundMemberServiceImpl extends BaseServiceImpl implements AidFund
if (pageForm.getNotPayedCurrentYear()) {
cnd.and("id", "in", Sqls.create("SELECT userid FROM `aid_fund_member_pay` where `year` = YEAR(CURDATE()) and isPayed = 0"));
}
if (ObjectUtil.equals(pageForm.getPersonTypeChanged(), true)) {
// 按工号比对当前用户表人员类型与基金会员类型,筛出需要人工核实变更的会员。
cnd.and(new Static("""
EXISTS (
SELECT 1
FROM sys_user su
WHERE su.loginname = vw_user.loginname
AND IFNULL(su.personType, '') <> 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<Sys_user> sysUserList = dao().query(Sys_user.class, Cnd.where("id", "in", ids));
List<Sys_user> sysUserList = lockChangeUsers(ids);
validateRepeatedChange(sysUserList, changeType);
List<AidFundMemberChangeRecord> 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<Sys_user> 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<Sys_user> lockChangeUsers(String[] ids) {
List<Sys_user> 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<Sys_user> users, String changeType) {
List<String> 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<AidFundMemberChangeRecord> 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")) {
@@ -99,9 +99,8 @@ public class MedicalApplyServiceImpl extends BaseServiceImpl<MedicalApply> 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<MedicalApply> 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<MedicalApply> imple
//如果是重大疾病
allMedicalMoney = personExpenseMoney.add(singleBearMoney).add(outpatientServiceMoney).add(bxyMoney);
} else {
//个人承担费用
singleBearMoney = costs.stream()
// 普通疾病以每次住院的个人承担金额是否达到一万元为准,达标记录的自费金额一并计入。
List<MedicalApplyCost> 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);
}
@@ -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;
@@ -262,6 +262,15 @@ public class MemberManageServiceImpl extends BaseServiceImpl<Sys_user> 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<Sys_user> 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<NutMap> changeList = getChangeInfos(record);
// 如果有工会关系人员,这个user用来存储数据
@@ -382,6 +395,10 @@ public class MemberManageServiceImpl extends BaseServiceImpl<Sys_user> 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<Sys_user> implement
@Override
public void validateChangeAndSetBasicData(MemberChangeRecord record, MemberChangeOrigin origin) {
applyRetiredPersonType(record);
normalizeArrivalAtSchoolDate(record);
normalizeWelfareStopDate(record);
// 校验有没有发生变更
List<NutMap> changeInfos = getChangeInfos(record);
if (Lang.isEmpty(changeInfos)) {
@@ -536,6 +555,43 @@ public class MemberManageServiceImpl extends BaseServiceImpl<Sys_user> implement
record.setApplyDateTime(DateUtil.date());
}
/**
* 在职状态调整为退休时,按人员类型字典名称自动设置“离退休人员”编码。
* 字典中不存在该名称时保留原人员类型,不阻断其他变更。
*
* @param record 人员变更内容,userState 为“退休”时才执行自动设置
*/
private void applyRetiredPersonType(MemberChangeRecord record) {
if (record == null || !StrUtil.equals("退休", record.getUserState())) {
return;
}
List<Sys_dict> 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 对象复制后以英文字符串形式参与比较或直接落库。
@@ -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);
}
/**
* 直接提交待确认人员变更,不创建会员变更申请及审批流程。
*
@@ -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;
}
@@ -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<String> userAttributes;
/** 在职状态。 */
private String userState;
@@ -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<Sys_user> {
*/
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<Sys_user> {
boolean submitChange(MemberChangeRecord record);
/**
* 批量修改待确认人员的人员类型,并将处理成功的人员标记为已确认。
* 批量修改待确认人员允许整改的字段,并将处理成功的人员标记为已确认。
*
* @param changeParam 批量勾选的用户ID及目标人员类型
* @param changeParam 批量勾选的用户ID及待整改字段
* @return 实际完成确认的人员数量
*/
int batchChangePersonType(PendingConfirmUserBatchChangeParam changeParam);
@@ -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<Sys_user> 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<NutMap> 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<ExcelExportEntity> 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<Sys_user> 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<Sys_user> 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<Sys_user> 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<Sys_user> 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<Sys_user> 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<String> userIds;
try {
@@ -162,11 +242,49 @@ public class PendingConfirmUserServiceImpl extends BaseServiceImpl<Sys_user> 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<Sys_user> 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<Sys_user> 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<Sys_user> 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<Sys_user> 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<Sys_dict> 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<Sys_user> imp
"unionName", "unionInfo.name",
"unitName", "unitInfo.name",
"personType", "userInfo.personType",
"userAttribute", "userInfo.userAttribute",
"userState", "userInfo.userState"
);
return orderColumns.getOrDefault(StrUtil.nullToEmpty(pageOrderName), "");
@@ -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;
@@ -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; }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

@@ -68,9 +68,9 @@
<link rel="stylesheet" href="${base!}/assets/platform/plugins/vxe/vxe-pc-ui.css" />
<link rel="stylesheet" href="${base!}/assets/platform/plugins/vxe/vxe-table.css" />
<script src="https://vxeui.com/umd/xe-utils@3.5.30/dist/xe-utils.umd.min.js"></script>
<script src="https://vxeui.com/umd/vxe-pc-ui@3.1.25/lib/index.umd.min.js"></script>
<script src="https://vxeui.com/umd/vxe-table@3.9.0/lib/index.umd.min.js"></script>
<script src="${base!}/assets/platform/plugins/vxe/xe-utils.js"></script>
<script src="${base!}/assets/platform/plugins/vxe/vxe-pc-ui.js"></script>
<script src="${base!}/assets/platform/plugins/vxe/vue-table.js"></script>
<!-- 引入 form-create 和 designer -->
<script src="${base!}/assets/platform/plugins/form-create/form-create.min.js"></script>
@@ -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()
+43 -18
View File
@@ -10,22 +10,23 @@ const act = {
<div ref="activitySwiper" class="swiper activity-swiper">
<div class="swiper-wrapper">
<div class="swiper-slide" v-for="(activity, index) in actList" :key="activity.id">
<div class="item" @click="go(activity)">
<div class="item" role="button" tabindex="0" @click="go(activity)" @keydown.enter="go(activity)" @keydown.space.prevent="go(activity)">
<div class="img-box">
<img :src="activity.cover || 'https://www.ncu.edu.cn/__local/8/52/2C/644986B4C9A030F7B65300178A6_8679CB08_18467.jpg'"
alt="">
<span class="status">
<el-image :src="activity.cover" fit="cover" :alt="activity.name">
<div slot="error" class="portal-cover-empty"><i class="fa fa-picture-o" aria-hidden="true"></i><span>暂无图片</span></div>
</el-image>
<span class="status" :class="{'status-ended': getStatusText(activity) === '已结束', 'status-upcoming': getStatusText(activity) === '未开始'}">
{{getStatusText(activity)}}
</span>
</div>
<h4 class="title">
<h4 class="title" :title="activity.name">
{{ activity.name}}
</h4>
<!--报名时间-->
<div class="time">
<p>
<p :title="[activity.startDate, activity.endDate].filter(Boolean).join(' 至 ')">
<i class="fa fa-clock-o"></i>
时间:{{ activity.startDate }}至{{ activity.endDate }}
{{ formatActivityDate(activity.startDate) }} 至 {{ formatActivityDate(activity.endDate) }}
</p>
</div>
</div>
@@ -34,15 +35,15 @@ const act = {
</div>
<!-- 导航按钮 -->
<div ref="nextButton" class="swiper-button-next"></div>
<div ref="prevButton" class="swiper-button-prev"></div>
<button type="button" ref="nextButton" class="swiper-button-next" aria-label="下一组活动"></button>
<button type="button" ref="prevButton" class="swiper-button-prev" aria-label="上一组活动"></button>
<!-- 进度条 -->
<div ref="pagination" class="swiper-pagination"></div>
</div>
<div v-else class="no-activity-placeholder">
<div class="icon">📅</div>
<div class="icon"><i class="fa fa-calendar" aria-hidden="true"></i></div>
<p>暂无近期活动</p>
<p class="subtext">敬请关注后续通知</p>
</div>
@@ -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,
}
}
})
@@ -1,18 +1,19 @@
const entry = {
template: /*language=HTML*/ `
<div class="entry-wrapper">
<div :class="['entry-section', 'entry-section-' + section.key]" v-for="section in entrySections" :key="section.key">
<div class="entry-section-header">
<div class="entry-section-title">
<i :class="section.icon"></i>
<span>{{ section.title }}</span>
<em>/Applications</em>
</div>
<div class="portal-entry-header">
<div class="portal-entry-tabs" role="tablist" aria-label="推荐入口">
<button v-for="section in entrySections" :key="section.key"
type="button" role="tab" :aria-selected="activeSection === section.key"
:class="{active: activeSection === section.key}" @click="activeSection = section.key">{{ section.title }}</button>
</div>
<a class="portal-more" href="/platform/v4/apps">全部应用 <i class="fa fa-arrow-right" aria-hidden="true"></i></a>
</div>
<div :class="['entry-section', 'entry-section-' + section.key]" v-for="section in entrySections" :key="section.key" v-show="activeSection === section.key" role="tabpanel">
<div class="apps-container">
<div class="app-grid">
<div class="app-item"
<div class="app-grid" tabindex="0" :aria-label="section.title + ',可横向滚动浏览'">
<button type="button" class="app-item"
v-for="(item, index) in section.list"
:key="item.id || index"
@click="openService(item, section.key)">
@@ -20,8 +21,8 @@ const entry = {
<img v-if="item.picIcon" class="app-icon-img" :src="item.picIcon" alt="" />
<i v-else class="fa fa-cube app-icon-fallback"></i>
</div>
<div class="app-name">{{ item.name }}</div>
</div>
<div class="app-name" :title="item.name">{{ item.name }}</div>
</button>
<el-empty class="app-empty"
v-if="!section.list || section.list.length === 0"
description="暂无数据"
@@ -34,6 +35,7 @@ const entry = {
`,
data() {
return {
activeSection: 'serv',
hasMore: true,
entries: {
serv: [],
@@ -127,11 +129,6 @@ const entry = {
margin-top: 22px;
}
/* 临时屏蔽“我的收藏”分组,保留原节点和数据逻辑便于恢复 */
.entry-section-fav {
display: none;
}
.entry-section-header {
display: flex;
align-items: center;
@@ -118,42 +118,51 @@ layout("/layouts/v4/baseLayout.html"){
</style>
<div class="v4-container" id="v4-home-app">
<link rel="stylesheet" href="${base!}/assets/platform/css/home-portal.css?v=20260929-1" />
<div class="v4-container home-portal" id="v4-home-app">
<!-- 首页banner -->
<div class="section-banner">
<div class="banner-img">
<img :src="homeBannerList[0] || ''" alt=""/>
<stats></stats>
<div class="portal-top">
<div class="portal-hero">
<div class="portal-hero-inner">
<div class="portal-hero-copy">
<div class="portal-hero-eyebrow">教职工一站式服务平台</div>
<h1>智慧工会系统</h1>
<p>情系教职工,服务零距离</p>
<div class="portal-values">
<span><i class="fa fa-heart" aria-hidden="true"></i>服务职工</span>
<span><i class="fa fa-users" aria-hidden="true"></i>凝聚职工</span>
<span><i class="fa fa-leaf" aria-hidden="true"></i>关爱职工</span>
<span><i class="fa fa-file-text" aria-hidden="true"></i>助力发展</span>
</div>
</div>
</div>
</div>
<user class="portal-member" :profile-only="true"></user>
</div>
<div class="" style="padding-bottom: 20px">
<user></user>
<div class="home-grid">
<div class="portal-content">
<div class="entry-card">
<entry></entry>
</div>
</div>
<div class="home-grid">
<div class="home-section">
<div class="portal-columns">
<div class="home-section portal-panel portal-activities">
<div class="home-section-header">
<div class="home-section-title">
<i class="fa fa-calendar"></i>
<span>最新活动</span>
<em>/Activities</em>
</div>
</div>
<button type="button" class="portal-more" @click="$refs.homeActivities.browseNext()">浏览更多 <i class="fa fa-arrow-right" aria-hidden="true"></i></button>
</div>
<div class="act-card">
<act></act>
<act ref="homeActivities"></act>
</div>
</div>
<user ref="homeTodo" class="portal-todo"></user>
<work-template class="portal-templates" v-if="$auth.hasRoleOr(['SYSADMIN','SCHOOL_UNION_ADMIN'])"></work-template>
<jcdt class="portal-news" :class="{'portal-news-wide': !$auth.hasRoleOr(['SYSADMIN','SCHOOL_UNION_ADMIN'])}" :list="websiteNews"></jcdt>
</div>
<work-template v-if="$auth.hasRoleOr(['SYSADMIN','SCHOOL_UNION_ADMIN'])"></work-template>
<jcdt :list="websiteNews"></jcdt>
</div>
</div>
@@ -2,42 +2,63 @@ const jcdt = {
template: /*language=HTML*/ `
<div class="jcdt-wrapper">
<div class="jcdt-container">
<el-carousel height="360px" :interval="10000" indicator-position="none">
<el-carousel-item v-for="item,index in list" :key="index">
<div class="jcdt-title">
<span>{{ item.label }}</span>
</div>
<div class="jcdt-content">
<div class="jcdt-list">
<div class="jcdt-item" v-for="o,i in item.value" :key="i" @click="onLink(o)">
<div class="date" v-if="o.time">
<i class="el-icon-alarm-clock"></i>
<span>{{o.time}}</span>
</div>
<div style="display: flex; align-items: center">
<div>
</div>
<div class="title">{{o.text}}</div>
</div>
</div>
</div>
</div>
</el-carousel-item>
</el-carousel>
<div class="portal-news-header">
<el-dropdown @command="selectSection" trigger="click">
<button type="button" class="portal-news-title"><i class="fa fa-bullhorn" aria-hidden="true"></i> {{ currentNews.label }} <i v-if="list.length > 1" class="fa fa-angle-down" aria-hidden="true"></i></button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item v-for="(section, index) in list" :key="index" :command="index">{{ section.label }}</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<div v-if="pageCount > 1" class="portal-news-pager" aria-label="动态分页">
<span class="portal-news-count">共 {{ currentNews.value.length }} 条</span>
<button type="button" :disabled="pageNumber === 1" title="上一页动态" aria-label="上一页动态" @click="pageNumber--"><i class="fa fa-angle-left" aria-hidden="true"></i></button>
<span class="portal-news-page" aria-live="polite">{{ pageNumber }} / {{ pageCount }}</span>
<button type="button" :disabled="pageNumber === pageCount" title="下一页动态" aria-label="下一页动态" @click="pageNumber++"><i class="fa fa-angle-right" aria-hidden="true"></i></button>
</div>
</div>
<div class="portal-news-list">
<button type="button" class="portal-news-row" v-for="(item, index) in visibleNews" :key="index" @click="onLink(item)">
<time v-if="item.time" :title="item.time">{{ item.time.slice(5) }}</time>
<span :title="item.text">{{ item.text }}</span>
</button>
<div v-if="!currentNews.value.length" class="portal-empty">暂无动态</div>
</div>
</div>
</div>
`,
data() {
return {}
return {activeIndex: 0, pageNumber: 1, pageSize: 3}
},
props: {
list: {
type: Array,
default: []
default: () => []
}
},
computed: {
currentNews() {
// 默认遵循接口栏目顺序,不再按栏目名称优先选择。
const section = this.list[this.activeIndex] || this.list[0]
return section ? {label: section.label, value: section.value || []} : {label: '最新动态', value: []}
},
visibleNews() {
const start = (this.pageNumber - 1) * this.pageSize
return this.currentNews.value.slice(start, start + this.pageSize)
},
pageCount() {
return Math.max(1, Math.ceil(this.currentNews.value.length / this.pageSize))
}
},
watch: {
list() {
this.selectSection(0)
}
},
methods: {
selectSection(index) {
this.activeIndex = index
this.pageNumber = 1
},
onLink(item) {
const url = this.isHttpOrHttps(item.href) ? item.href : "https://xgh.cug.edu.cn/" + item.href
window.open(url)
@@ -47,6 +68,15 @@ const jcdt = {
}
},
style: /*language=CSS*/ `
/* 每页固定三行,不因展开或末页条数变化撑高相邻的模板卡片。 */
#v4-home-app .portal-news-list { min-height: 132px; }
#v4-home-app .portal-news-list .portal-news-row { height: 44px; padding: 9px 0; }
#v4-home-app .portal-news-pager { display: flex; flex-shrink: 0; align-items: center; gap: 8px; }
#v4-home-app .portal-news-count { margin-right: 4px; color: #8a9aac; font-size: 12px; }
#v4-home-app .portal-news-page { min-width: 32px; color: #637b96; text-align: center; font-size: 12px; font-variant-numeric: tabular-nums; }
#v4-home-app .portal-news-pager button { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; padding: 0; border: 1px solid #e4edf7; border-radius: 6px; background: #fff; color: #1687f5; font-size: 18px; cursor: pointer; }
#v4-home-app .portal-news-pager button:hover:not(:disabled) { border-color: #b9d8fb; background: #f0f7ff; }
#v4-home-app .portal-news-pager button:disabled { border-color: #eef2f7; background: #f8fafc; color: #c3cedb; cursor: default; }
.jcdt-wrapper {
width: 100%;
background-size: cover;
@@ -5,21 +5,22 @@ const workTemplate = {
<div class="work-template-title">
<i class="fa fa-clipboard"></i>
<span>工作模板</span>
<em>/Templates</em>
</div>
<button type="button" class="portal-more" @click="browseTemplates">更多模板 <i class="fa fa-arrow-right" aria-hidden="true"></i></button>
</div>
<div class="work-template-card">
<div v-if="templateList && templateList.length > 0" class="work-template-grid">
<div class="work-template-item"
<div ref="templateGrid" v-if="templateList && templateList.length > 0" class="work-template-grid">
<button type="button" class="work-template-item"
v-for="template in templateList"
:key="template.id"
@click="go(template)">
<div class="work-template-icon">
<img :src="template.templateIcon || template.cover || 'https://www.ncu.edu.cn/__local/8/52/2C/644986B4C9A030F7B65300178A6_8679CB08_18467.jpg'"
alt="">
<el-image :src="template.templateIcon || template.cover" fit="contain">
<i slot="error" class="fa fa-clipboard" aria-hidden="true"></i>
</el-image>
</div>
<div class="work-template-name">{{ template.templateName || template.name }}</div>
</div>
<div class="work-template-name" :title="template.templateName || template.name">{{ template.templateName || template.name }}</div>
</button>
</div>
<div v-else class="no-activity-placeholder">
@@ -36,6 +37,13 @@ const workTemplate = {
}
},
methods: {
browseTemplates() {
const grid = this.$refs.templateGrid
if (!grid) return
// 模板仍使用全部真实数据,横向翻页不截断管理员可用的入口。
const atEnd = grid.scrollLeft + grid.clientWidth >= grid.scrollWidth - 2
grid.scrollTo({left: atEnd ? 0 : grid.scrollLeft + grid.clientWidth, behavior: 'smooth'})
},
listTemplate() {
this.$axios.post("/platform/home/listHomeTemplate").then((res) => {
if (res.code === 0) {
@@ -4,13 +4,17 @@ const user = {
<!-- 用户容器:左右布局 -->
<div class="user-container">
<!-- 左侧:用户信息卡片 -->
<div class="user-info-card">
<div v-if="profileOnly" class="user-info-card">
<div class="portal-member-date"><i class="fa fa-calendar-o" aria-hidden="true"></i>{{ $moment().format('YYYY年MM月DD日') }} {{ ['星期日','星期一','星期二','星期三','星期四','星期五','星期六'][$moment().day()] }}</div>
<div class="user-details">
<div class="user-name">${@auth.getPrincipalProperty('username')}</div>
<div class="portal-member-heading">
<span class="portal-member-avatar"><i class="fa fa-user" aria-hidden="true"></i></span>
<div><div class="portal-member-welcome">欢迎回来</div><div class="user-name">${@auth.getPrincipalProperty('username')}</div></div>
</div>
<div class="user-meta-grid">
<div class="meta-row">
<span class="meta-icon">🆔</span>
<span class="meta-icon"><i class="fa fa-id-card-o" aria-hidden="true"></i></span>
<div>
<span class="meta-label">工号</span>
<span class="meta-value">${@auth.getPrincipalProperty('loginname')}</span>
@@ -18,7 +22,7 @@ const user = {
</div>
<div class="meta-row">
<span class="meta-icon">🏢</span>
<span class="meta-icon"><i class="fa fa-building-o" aria-hidden="true"></i></span>
<div>
<span class="meta-label">单位信息</span>
<span class="meta-value">
@@ -33,11 +37,11 @@ const user = {
</span>
</div>
</div>
<div class="meta-row">
<span class="meta-icon">🎭</span>
<div class="meta-row meta-row-roles">
<span class="meta-icon"><i class="fa fa-shield" aria-hidden="true"></i></span>
<div>
<span class="meta-label">角色</span>
<span class="meta-value">{{ roleNames || '—' }}</span>
<div class="portal-member-roles"><span v-for="(role, index) in roleTags" :key="index" class="portal-member-role">{{ role }}</span><span v-if="!roleTags.length" class="meta-value">—</span></div>
</div>
</div>
</div>
@@ -63,10 +67,13 @@ const user = {
</div>
<!-- 右侧:待办中心 -->
<div class="todo-center">
<div v-if="!profileOnly" class="todo-center">
<!-- 标题和标签 -->
<div class="header">
<h3>待办中心</h3>
<div class="portal-todo-heading">
<h3><i class="fa fa-list-ul" aria-hidden="true"></i> 待办中心</h3>
<a class="portal-more" :href="'/flow/todoCenter?status=' + activeTab" target="_blank">全部待办 <i class="fa fa-arrow-right" aria-hidden="true"></i></a>
</div>
<el-tabs v-model="activeTab" type="card" @tab-click="handleTabClick">
<el-tab-pane :label="tabLabel('待办事宜', taskStats.todoCount)" name="todo"></el-tab-pane>
<el-tab-pane :label="tabLabel('已办事宜', taskStats.doneCount)" name="done"></el-tab-pane>
@@ -75,54 +82,41 @@ const user = {
</el-tabs>
</div>
<!-- 表格 -->
<el-table
:data="todoList"
style="width: 100%"
border
stripe
v-loading="loading"
:cell-style="{ padding: '8px' }"
:header-cell-style="{ background: '#f8f9fa', color: '#444', fontWeight: '500' }"
>
<el-table-column label="序号" type="index" :index="indexMethod" width="100px"></el-table-column>
<el-table-column prop="categoryName" label="流程分类" width="100"></el-table-column>
<el-table-column prop="title" label="流程名称" min-width="300">
<template slot-scope="scope">
<el-tooltip effect="dark" :content="scope.row.instanceName" placement="top">
<span class="table-title">{{ scope.row.instanceName }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="sender" label="申请人" width="120">
<template slot-scope="{row}">{{row.variable.initiatorName}}</template>
</el-table-column>
<el-table-column prop="taskName" label="当前流程节点" width="140" show-overflow-tooltip></el-table-column>
<el-table-column prop="date" label="发起日期" width="120">
<template slot-scope="{row}">
{{$moment(row.createdAt).format('YYYY-MM-DD')}}
</template>
</el-table-column>
<el-table-column label="操作" width="100px" fixed="right">
<template slot-scope="scope">
<el-button type="primary" size="mini" @click="openView(scope.row)">查看</el-button>
</template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
<div class="portal-task-list" v-loading="loading">
<div class="portal-task-row" v-for="(task, index) in todoList" :key="task.taskId || task.instanceId || index">
<i class="fa fa-file-text-o portal-task-icon" aria-hidden="true"></i>
<div class="portal-task-content">
<div class="portal-task-title" :title="task.instanceName">{{ task.instanceName }}</div>
<div class="portal-task-meta">
<span :title="task.variable && task.variable.initiatorName">申请人:{{ (task.variable && task.variable.initiatorName) || '—' }}</span>
<span class="portal-task-node" :title="[task.categoryName, task.taskName].filter(Boolean).join(' · ')">{{ [task.categoryName, task.taskName].filter(Boolean).join(' · ') }}</span>
</div>
</div>
<time class="portal-task-date">{{ task.createdAt ? $moment(task.createdAt).format('YYYY-MM-DD') : '—' }}</time>
<el-button type="primary" size="mini" @click="openView(task)">查看</el-button>
</div>
<div v-if="!loading && !todoList.length" class="portal-empty"><i class="fa fa-check-circle-o" aria-hidden="true"></i><span>暂无{{ activeTab === 'todo' ? '待办事项' : '相关记录' }}</span></div>
</div>
<el-pagination v-if="pageForm.totalCount > pageForm.pageSize" class="portal-task-pagination"
@current-change="pageNumberChange" :current-page="pageForm.pageNumber" :page-size="pageForm.pageSize"
layout="total, prev, next" :total="pageForm.totalCount"></el-pagination>
</div>
</div>
</div>
`,
mixins: [initTableMixins],
// 同一组件分别承载会员卡和待办卡,各自只加载需要的数据。
props: {
profileOnly: { type: Boolean, default: false }
},
data() {
return {
activeTab: 'todo',
todoList: [],
loading: false,
pageForm: {
pageNum: 1,
pageSize: 5,
pageNumber: 1,
pageSize: 3,
totalCount: 0
},
taskStats: {
@@ -140,6 +134,12 @@ const user = {
};
},
computed: {
roleTags() {
// 兼容角色接口已有的中英文分隔符,仅改变展示,不改变角色权限。
return String(this.roleNames || '').split(/[,,、;;]/).map(role => role.trim()).filter(Boolean)
}
},
methods: {
getWeatherInfo(iconCode, temp) {
const info = {
@@ -268,6 +268,7 @@ const user = {
this.getStatistics()
},
handleTabClick(tab) {
this.pageForm.pageNumber = 1
this.pageData()
console.log('切换到:', tab.name);
},
@@ -291,13 +292,42 @@ const user = {
}
},
created() {
this.pageData()
this.getRoleNames()
if (this.profileOnly) {
this.getRoleNames()
} else {
this.pageData()
}
},
mounted() {
this.fetchWeather()
if (this.profileOnly) this.fetchWeather()
},
style: /*language=CSS*/ `
/* 会员卡样式随组件注入,确保与卡片结构同步加载,避免旧外链样式造成排版缺失。 */
#v4-home-app .portal-member { min-width: 0; }
#v4-home-app .portal-member .user-info-card { height: 100%; padding: 20px; gap: 14px; border: 1px solid #e3edf8; border-radius: 12px; background: linear-gradient(180deg, #f1f7ff 0, #fff 110px); box-shadow: 0 3px 14px rgba(30,66,108,.035); }
#v4-home-app .portal-member-date { display: flex; align-items: center; gap: 8px; color: #71839b; font-size: 12px; line-height: 20px; }
#v4-home-app .portal-member-heading { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; padding-bottom: 14px; border-bottom: 1px solid #eaf0f7; }
#v4-home-app .portal-member-heading > div { min-width: 0; }
#v4-home-app .portal-member-avatar { display: flex; align-items: center; justify-content: center; flex: 0 0 46px; height: 46px; border-radius: 50%; background: #e1eeff; color: #1687f5; font-size: 25px; }
#v4-home-app .portal-member-welcome { margin-bottom: 3px; color: #8797a9; font-size: 12px; line-height: 18px; }
#v4-home-app .portal-member .user-name { margin: 0; color: #163c61; text-align: left; font-size: 18px; line-height: 24px; overflow-wrap: anywhere; }
#v4-home-app .portal-member .user-meta-grid { gap: 12px; margin: 0; }
#v4-home-app .portal-member .meta-row { gap: 8px; }
#v4-home-app .portal-member .meta-icon { min-width: 18px; color: #81a4c9; font-size: 14px; }
#v4-home-app .portal-member .meta-row > div { display: grid; grid-template-columns: 54px minmax(0, 1fr); gap: 8px; min-width: 0; }
#v4-home-app .portal-member .meta-label { margin: 0; color: #8995a5; font-size: 12px; line-height: 20px; }
#v4-home-app .portal-member .meta-value { color: #283c54; font-size: 12px; font-weight: 400; line-height: 20px; overflow-wrap: anywhere; }
#v4-home-app .portal-member .meta-row-roles > div { display: block; }
#v4-home-app .portal-member-roles { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 5px; }
#v4-home-app .portal-member-role { max-width: 100%; display: inline-block; padding: 3px 8px; border: 1px solid #e1ebf7; border-radius: 5px; background: #f3f7fc; color: #46617f; font-size: 12px; line-height: 18px; overflow-wrap: anywhere; }
#v4-home-app .portal-member .weather-section { margin-top: auto; padding: 8px 10px; border-radius: 8px; background: #f0f7ff; color: #527494; }
#v4-home-app .portal-member .weather-header { gap: 6px; }
#v4-home-app .portal-member .weather-location,
#v4-home-app .portal-member .weather-text { font-size: 12px; }
#v4-home-app .portal-member .weather-emoji { font-size: 20px; }
#v4-home-app .portal-member .weather-temp { font-size: 18px; }
#v4-home-app .portal-member .weather-tip { margin-top: 4px; font-size: 11px; line-height: 18px; }
#v4-home-app .portal-member .weather-error { margin-top: auto; padding: 8px; font-size: 12px; }
.user-wrapper {
width: 100%;
background-color: #f5f7fa;
@@ -54,6 +54,14 @@ layout("/layouts/platform.html"){
<el-tag size="mini" v-else-if="row.isDeployed===0" type="info">否</el-tag>
</template>
</el-table-column>
<el-table-column label="流程中心" width="150" align="center" header-align="center">
<template slot-scope="{row}">
<el-switch :value="row.centerEnabled !== 0"
:loading="row.centerUpdating === true"
active-text="开启" inactive-text="关闭"
@change="updateCenterEnabled(row, $event)"></el-switch>
</template>
</el-table-column>
<el-table-column label="排序编码" prop="sortNo" width="120" align="center" header-align="center" sortable="custom">
<template slot-scope="{row}">
<el-input-number
@@ -266,6 +274,25 @@ layout("/layouts/platform.html"){
}
})
},
// 开关仅控制流程中心是否展示该流程,接口失败时保留原状态。
updateCenterEnabled(row, enabled) {
this.$set(row, "centerUpdating", true)
this.$axios.post("/flow/design/updateCenterEnabled", {
id: row.id,
centerEnabled: enabled ? 1 : 0
}).then((res) => {
if (res.code === 0) {
this.$set(row, "centerEnabled", enabled ? 1 : 0)
this.$message.success(enabled ? "已开启" : "已关闭")
} else {
this.$message.error(res.msg || "更新流程中心显示状态失败")
}
}).catch(() => {
this.$message.error("更新流程中心显示状态失败")
}).finally(() => {
this.$set(row, "centerUpdating", false)
})
},
},
created() {
this.pageData()
@@ -47,11 +47,13 @@ layout("/layouts/platform.html"){
<el-option v-for="item in userStateOptions" :label="item" :value="item"></el-option>
</el-select>
</search-item>
<!-- 暂停用人方式筛选,保留查询控件供后续恢复。
<search-item label="用人方式">
<el-select v-model="pageForm.preparedBy" clearable filterable>
<el-option v-for="item in preparedByOptions" :label="item" :value="item"></el-option>
</el-select>
</search-item>
-->
<search-item label="人员类型">
<el-select v-model="pageForm.personType" clearable filterable>
<el-option v-for="item in personTypeOptions" :label="item" :value="item"></el-option>
@@ -124,14 +126,25 @@ layout("/layouts/platform.html"){
},
methods: {
pull() {
// 拉取未结束时不再打开确认框或发送重复请求。
if (this.pullLoading) return
this.$confirm("确定要拉取数据源吗?", "提示", {
type: "warning",
confirmButtonText: "确定",
cancelButtonText: "取消"
})
.then(() => {
this.pullLoading = true
this.$axios
if (this.pullLoading) return
this.$set(this, "pullLoading", true)
// 全屏提示正在拉取,锁定页面操作,避免仅按钮转圈不易察觉。
const loading = this.$loading({
fullscreen: true,
lock: true,
text: "正在拉取信息中心数据,请稍候",
spinner: "el-icon-loading",
background: "rgba(0, 0, 0, 0.5)"
})
return this.$axios
.post("/platform/sys/data/user/pull/pullData")
.then((resp) => {
if (resp.code === 0) {
@@ -141,7 +154,9 @@ layout("/layouts/platform.html"){
}
})
.finally(() => {
this.pullLoading = false
// 成功、业务失败或网络异常均关闭遮罩,并恢复按钮状态。
loading.close()
this.$set(this, "pullLoading", false)
})
})
.catch(() => {})
@@ -249,6 +249,8 @@ layout("/layouts/platform.html"){
})
},
execUpdate() {
// 人员更新未结束时不再打开确认框或提交重复请求。
if (this.updateLoading) return
if (!this.updateFromData.pullTime) {
this.$message.warning("请选择数据源时间")
return
@@ -269,8 +271,17 @@ layout("/layouts/platform.html"){
type: "warning"
})
.then(() => {
this.updateLoading = true
this.$axios
if (this.updateLoading) return
this.$set(this, "updateLoading", true)
// 遮罩覆盖人员更新弹窗及页面,明确提示处理中并阻止重复操作。
const loading = this.$loading({
fullscreen: true,
lock: true,
text: "正在更新人员数据,请稍候",
spinner: "el-icon-loading",
background: "rgba(0, 0, 0, 0.5)"
})
return this.$axios
.post("/platform/sys/data/user/update/updateUserFormSource", { param: JSON.stringify(this.updateFromData) })
.then((resp) => {
if (resp.code === 0) {
@@ -280,9 +291,14 @@ layout("/layouts/platform.html"){
}
})
.finally(() => {
this.updateLoading = false
// 成功、业务失败及网络异常均释放遮罩,恢复人员更新按钮。
loading.close()
this.$set(this, "updateLoading", false)
})
})
.catch(() => {
// 取消确认不提交;请求错误提示继续由全局 axios 拦截器处理。
})
},
listUnit() {
this.$businessTool.listUnit(this.pageForm.unionId).then((res) => {
@@ -124,7 +124,8 @@
}
</script>
<script nonce="${cspNonce!}">
const DISABLE_LOGIN_CAPTCHA = ["localhost", "127.0.0.1", "::1"].includes(window.location.hostname)
// 临时屏蔽指定局域网 IP 的验证码;恢复时删除 192.168.0.108,并同步删除后端对应条件。
const DISABLE_LOGIN_CAPTCHA = ["localhost", "127.0.0.1", "::1", "192.168.0.108"].includes(window.location.hostname)
new Vue({
el: "#app",
@@ -155,14 +155,13 @@ layout("/layouts/platform.html"){
</el-button>
<el-button @click="$refs.sortRef.onOpen()" size="medium" type="primary" icon="el-icon-sort">排序
</el-button>
<el-button v-if="$auth.hasPermission('sys.manager.role.user')" size="medium" type="primary"
icon="el-icon-user" :loading="memberRoleLoading" @click="doSettingMemberRole">
一键设置会员角色
</el-button>
</div>
</el-row>
<!-- <div class="pull-right offscreen-right mt5 ml5">-->
<!-- <el-button size="medium" @click="doSettingMemberRole" type="primary"><i class="el-icon-s-tools"></i>-->
<!-- 一键设会员角色-->
<!-- </el-button>-->
<!-- </div>-->
<!-- <div class="pull-right offscreen-right mt5 ml5">-->
<!-- <el-button size="medium" @click="doPublic" type="primary">一键设置公共角色</el-button>-->
<!-- </div>-->
@@ -516,6 +515,7 @@ layout("/layouts/platform.html"){
roleDialogVisible: false,
menuDialogVisible: false,
userDialogVisible: false,
memberRoleLoading: false,
treeData: {
label: "name",
children: "zones",
@@ -689,18 +689,30 @@ layout("/layouts/platform.html"){
})
},
doSettingMemberRole() {
this.$confirm("是否确定一键设置会员角色?", "提示", {
if (this.memberRoleLoading) {
return
}
// 操作面向全部会员,忽略当前列表筛选;确认期间也锁定按钮,避免重复提交。
this.$set(this, "memberRoleLoading", true)
this.$confirm("确定为全部已标记为会员的人员设置会员角色吗?已有角色将保留。", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
})
.then(async () => {
const {code, msg} = await this.$axios.post("/platform/sys/role/doSettingMemberRole")
if (code === 0) {
this.$message.success(msg)
}
.then(() => {
return this.$axios.post("/platform/sys/role/doSettingMemberRole").then((res) => {
if (res.code === 0) {
this.$message.success("设置完成:新增 " + res.data.addedCount
+ " 条,恢复启用 " + res.data.enabledCount + " 条会员角色关联")
this.pageData()
}
})
})
.catch(() => {
// 取消确认无需提示;请求异常由全局 axios 拦截器统一提示。
})
.finally(() => {
this.$set(this, "memberRoleLoading", false)
})
},
formatAt(val) {
@@ -5,7 +5,7 @@ const clubUserJoin = {
<div>
<div class="process-title">
申请信息
<el-link type="primary" @click="openChart">点击查看流程图</el-link>
<el-link v-if="row.mode !== 2" type="primary" @click="openChart">点击查看流程图</el-link>
</div>
<el-descriptions border>
<el-descriptions-item label="姓名">
@@ -112,7 +112,7 @@ layout("/layouts/platform.html"){
style="width: 100%"
>
<el-option
v-for="item in clubOptions"
v-for="item in sortedClubOptions"
:key="item.id"
:label="item.clubName"
:value="item.id"
@@ -380,9 +380,14 @@ layout("/layouts/platform.html"){
>
{{(lastClubData.year - 1) + '年度注册会员人数为' + lastClubData.history_member + ',其中拨付人数为' + lastClubData.history_y_givemoney_member}}
</div>
<el-table :data="formData.changeUserNum" max-height="300">
<!-- 2026年10月2日22:00点开始新开发的代码;实际整改:2026年10月3日 10:14:04;操作人:wxg。 -->
<el-alert v-if="memberStatisticsWarnings.length"
:title="memberStatisticsWarnings.join(';')"
type="warning" :closable="false" show-icon></el-alert>
<el-table :data="formData.changeUserNum" max-height="300" v-loading="memberStatisticsLoading">
<el-table-column label="序号" type="index" width="50"></el-table-column>
<el-table-column label="人员类型" prop="userState"></el-table-column>
<!-- 2026年10月2日wxg屏蔽操作:<el-table-column label="人员类型" prop="userState"></el-table-column> -->
<el-table-column label="人员类型" prop="personType"></el-table-column>
<el-table-column label="年初人员" prop="yearFirstNum"></el-table-column>
<el-table-column label="年度增加" prop="yearAddNum"></el-table-column>
<el-table-column label="年度减少" prop="yearReduceNum"></el-table-column>
@@ -651,6 +656,11 @@ layout("/layouts/platform.html"){
memberList: [], // 成员名单
lastClubData: {}, // 上年度社团数据
// 2026年10月2日22:00点开始新开发的代码;实际整改:2026年10月3日 10:14:04;操作人:wxg。统计加载与核查提示只服务于成员变化表。
memberStatisticsLoading: false,
memberStatisticsWarnings: [],
memberStatisticsSequence: 0,
// 核心表单数据
formData: {
year: new Date().getFullYear() + "", // 默认当前年度
@@ -678,6 +688,18 @@ layout("/layouts/platform.html"){
},
// 常量定义(便于维护和修改)
computed: {
// 仅对本页社团选项按编码升序展示;空编码放末尾,保留接口原始数组。
sortedClubOptions() {
return (this.clubOptions || []).slice().sort((left, right) => {
const leftCode = left.clubCode == null ? "" : String(left.clubCode);
const rightCode = right.clubCode == null ? "" : String(right.clubCode);
if (!leftCode && rightCode) return 1;
if (leftCode && !rightCode) return -1;
if (leftCode < rightCode) return -1;
if (leftCode > rightCode) return 1;
return 0;
});
},
// 收支类型选项
INCOME_TYPE_OPTIONS() {
return [
@@ -780,8 +802,9 @@ layout("/layouts/platform.html"){
async fetchClubList() {
const resp = await this.$axios.post("/platform/club/examine/apply/getClubsByRole");
this.clubOptions = resp.data || [];
if (this.clubOptions.length > 0) {
this.formData.clubId = this.clubOptions[0].id;
if (this.sortedClubOptions.length > 0) {
// 新增登记默认选中编码最小的社团;编辑回显仍使用原登记社团。
this.$set(this.formData, "clubId", this.sortedClubOptions[0].id);
await this.handleClubChange(this.formData.clubId);
}
},
@@ -833,17 +856,50 @@ layout("/layouts/platform.html"){
return [...sortedDefaults, ...customItems];
},
// 获取社团成员数量
async fetchClubUserNum(clubId) {
const resp = await this.$axios.post("/platform/club/examine/apply/getClubUserNum", {
clubId,
year: this.formData.year
});
if (resp.code === 0) {
this.formData.changeUserNum = resp.data || [];
} else {
this.$message.warning(resp.msg);
// 2026年10月2日wxg屏蔽操作:原统计请求逐行注释保留。
// // 获取社团成员数量
// async fetchClubUserNum(clubId) {
// const resp = await this.$axios.post("/platform/club/examine/apply/getClubUserNum", {
// clubId,
// year: this.formData.year
// });
// if (resp.code === 0) {
// this.formData.changeUserNum = resp.data || [];
// } else {
// this.$message.warning(resp.msg);
// }
// },
//
// 2026年10月2日22:00点开始新开发的代码;实际整改:2026年10月3日 10:14:04;操作人:wxg。
fetchClubUserNum(clubId) {
const sequence = ++this.memberStatisticsSequence;
this.$set(this.formData, "changeUserNum", []);
this.memberStatisticsWarnings = [];
if (!clubId || !this.formData.year) {
this.memberStatisticsLoading = false;
return Promise.resolve();
}
this.memberStatisticsLoading = true;
return this.$axios.post("/platform/club/examine/apply/getClubUserNum", {
clubId: clubId,
year: this.formData.year
}).then((resp) => {
// 切换年度或社团时只接收最近一次请求,避免旧条件的结果覆盖当前统计。
if (sequence !== this.memberStatisticsSequence) return;
if (resp.code === 0) {
const rows = resp.data || [];
this.$set(this.formData, "changeUserNum", rows);
this.memberStatisticsWarnings = rows.length ? rows[0].statisticsWarnings || [] : [];
} else {
this.memberStatisticsWarnings = [resp.msg || "成员变化统计获取失败,请核查"];
}
}).catch(() => {
if (sequence === this.memberStatisticsSequence) {
this.memberStatisticsWarnings = ["成员变化统计请求失败,请检查后重新选择社团或年度"];
}
}).finally(() => {
if (sequence === this.memberStatisticsSequence) this.memberStatisticsLoading = false;
});
},
// 获取成员名单
async fetchMemberList(clubId) {
@@ -1101,13 +1157,22 @@ layout("/layouts/platform.html"){
},
/************************ 事件处理方法 ************************/
// 年度变化处理
handleYearChange: function() {
// 2026年10月2日wxg屏蔽操作:保留原年度切换事件。
// // 年度变化处理
// handleYearChange: function() {
// this.checkIsRegistered();
// },
// 2026年10月2日22:00点开始新开发的代码;实际整改:2026年10月3日 10:14:04;操作人:wxg。所选年度改变后同步刷新成员变化表。
handleYearChange() {
this.checkIsRegistered();
return this.fetchClubUserNum(this.formData.clubId);
},
// 社团变化处理
async handleClubChange(val) {
if (!val) return;
if (!val) {
this.fetchClubUserNum(val);
return;
}
const club = this.clubOptions.find(item => item.id === val);
// 更新社团基础信息
this.formData.due = club?.due || "";
@@ -25,6 +25,14 @@ layout("/layouts/platform.html"){
<search-item label="在职状态">
<dict-select code="USER_STATE" placeholder="请选择人员状态" clearable v-model="pageForm.userState"></dict-select>
</search-item>
<!-- 2026年10月3日 19:42:45;操作人:wxg:搜索按钮前提供退会日期范围,覆盖审核、系统处理及历史直接移出。 -->
<search-item label="退会时间查询">
<el-date-picker v-model="pageForm.exitDateRange" :picker-options="exitDatePickerOptions"
@change="exitDateRangeChange" type="datetimerange" clearable align="right"
start-placeholder="开始日期" end-placeholder="结束日期" range-separator="-"
format="yyyy-MM-dd" value-format="yyyy-MM-dd" style="width: 100%">
</el-date-picker>
</search-item>
</search>
</el-card>
@@ -76,7 +84,23 @@ layout("/layouts/platform.html"){
},
data() {
return {
clubList: []
clubList: [],
pageForm: { exitDateRange: [], exitDateStart: null, exitDateEnd: null },
// 2026年10月3日 19:42:45;操作人:wxg:复用通用查询和分页,快捷范围按自然月计算。
pageDataUrl: "/platform/club/infoManage/exitManage/pageData",
exitDatePickerOptions: {
shortcuts: [
{ text: "当前年度", onClick: picker => {
picker.$emit("pick", [this.$moment().startOf("year").toDate(), new Date()])
} },
{ text: "近半年", onClick: picker => {
picker.$emit("pick", [this.$moment().subtract(6, "months").startOf("day").toDate(), new Date()])
} },
{ text: "近三个月", onClick: picker => {
picker.$emit("pick", [this.$moment().subtract(3, "months").startOf("day").toDate(), new Date()])
} }
]
}
}
},
methods: {
@@ -85,28 +109,35 @@ layout("/layouts/platform.html"){
this.$refs.infoRef.onOpen(row)
})
},
async getClubList() {
const resp = await this.$axios.post("/platform/club/infoManage/exitManage/getClubList")
if (resp.code === 0) {
this.$set(this, "clubList", resp.data)
}
getClubList() {
return this.$axios.post("/platform/club/infoManage/exitManage/getClubList").then(resp => {
if (resp.code === 0) {
// 仅调整当前页面的社团选项顺序;编码为空时排在有编码的社团之后。
const clubs = (resp.data || []).slice().sort((left, right) => {
const leftCode = left.clubCode == null ? "" : String(left.clubCode)
const rightCode = right.clubCode == null ? "" : String(right.clubCode)
if (!leftCode && rightCode) return 1
if (leftCode && !rightCode) return -1
return leftCode < rightCode ? -1 : leftCode > rightCode ? 1 : 0
})
this.$set(this, "clubList", clubs)
}
})
},
exitTypeName(row) {
// 2026年10月3日 19:42:45;操作人:wxg:系统处理退会独立标识,保留原有两类名称。
if (row.exitType === "SYSTEM_EXIT") return "系统处理"
return row.exitType === "AUDIT_EXIT" ? "审核退会" : "直接移出"
},
async pageData() {
const resp = await this.$axios.post("/platform/club/infoManage/exitManage/pageData", this.pageForm)
if (resp.code === 0) {
this.$set(this, "tableData", resp.data.list)
this.$set(this.pageForm, "totalCount", resp.data.totalCount)
} else {
this.$message.warning(resp.msg)
}
/** 2026年10月3日 19:42:45;操作人:wxg:清空范围时同时清空接口参数,筛选后由 mixin 重置页码。 */
exitDateRangeChange(val) {
this.$set(this.pageForm, "exitDateStart", val && val.length === 2 ? val[0] : null)
this.$set(this.pageForm, "exitDateEnd", val && val.length === 2 ? val[1] : null)
this.doSearch()
}
},
async created() {
await this.getClubList()
await this.pageData()
created() {
this.getClubList().then(() => this.pageData())
}
})
</script>
@@ -86,6 +86,8 @@ const clubExitInfo = {
},
getExitTypeName() {
const exitType = this.row.exitType || this.viewData.exitType
// 2026年10月3日 19:42:45;操作人:wxg:详情与列表保持一致,mode=2 显示系统处理。
if (exitType === "SYSTEM_EXIT") return "系统处理"
return exitType === "AUDIT_EXIT" ? "审核退会" : "直接移出"
},
openChart() {
@@ -1,4 +1,4 @@
const CLUB_INFO_MANAGE_TEMPLATE = {
const CLUB_INFO_MANAGE_TEMPLATE = {
template: `
<div class="club-manage-panel">
<el-card class="club-manage-query-card" shadow="never">
@@ -7,29 +7,44 @@
<el-input placeholder="请输入姓名或工号" clearable v-model="pageForm.searchKeyword"></el-input>
</search-item>
<search-item label="人员类型">
<dict-select code="USER_PERSON_TYPE" placeholder="请选择人员类型" clearable v-model="pageForm.personType"></dict-select>
<dict-select code="USER_PERSON_TYPE" placeholder="请选择人员类型" clearable v-model="pageForm.personType" :disabled="pageForm.focusMembers"></dict-select>
</search-item>
<search-item label="在职状态">
<dict-select clearable code="USER_STATE" placeholder="请选择人员状态" v-model="pageForm.userState"></dict-select>
</search-item>
<!-- 2026年10月3日 19:28:31;操作人:wxg:查询标签明确为入会时间,日期筛选逻辑保持不变。 -->
<search-item label="入会时间查询" class="club-join-date-query">
<el-date-picker v-model="pageForm.joinDateRange" :picker-options="joinDatePickerOptions"
@change="joinDateRangeChange" type="datetimerange" clearable align="right"
start-placeholder="开始日期" end-placeholder="结束日期" range-separator="-"
format="yyyy-MM-dd" value-format="yyyy-MM-dd" style="width: 100%">
</el-date-picker>
</search-item>
</search>
</el-card>
<el-card shadow="never" class="mt10 club-manage-list-card">
<table-tool label="会员信息">
<template slot="content">
<el-tag :effect="pageForm.focusMembers ? 'dark' : 'plain'" @click="toggleFocusMembers"
class="mr10" style="cursor: pointer">重点关注名单</el-tag>
<span class="text-danger">提醒:再次点击可切换</span>
</template>
<template v-if="$auth.hasRoleOr(['SYSADMIN', 'SCHOOL_UNION_ADMIN'])">
<el-button size="small" type="primary" icon="el-icon-download" @click="exportRegistrationDoc">导出登记表</el-button>
<el-button @click="openImport" size="small" type="primary">导入成员</el-button>
<el-button size="small" type="primary" @click="openUserAdd(true)">录入成员</el-button>
<el-button size="small" type="primary" @click="openUserAdd(false)">设置理事机构</el-button>
</template>
<el-radio-group @change="doSearch" style="margin-left: 10px" v-model="pageForm.radioType" size="small">
<el-radio-group @change="memberTabChange" style="margin-left: 10px" v-model="pageForm.radioType" size="small">
<el-radio-button label="3">全部</el-radio-button>
<el-radio-button label="2">社团成员</el-radio-button>
<!-- 修改时间:2026-10-03 12:47:38;操作人:wxg;会员页签统一使用“会员”称谓,保留原筛选值。 -->
<el-radio-button label="2">社团会员</el-radio-button>
<el-radio-button label="1">理事机构</el-radio-button>
<el-radio-button label="4">历史会员</el-radio-button>
</el-radio-group>
</table-tool>
<el-table v-loading="tableLoading" :data="tableData" :height="tableHeight"
<el-table :key="pageForm.radioType" v-loading="tableLoading" :data="tableData" :height="tableHeight"
row-key="id" ref="tableRef" @sort-change='pageOrder'>
<el-table-column :index="indexMethod" label="序号" type="index" width="70"></el-table-column>
<el-table-column label="工号" prop="loginName" show-overflow-tooltip sortable></el-table-column>
@@ -37,11 +52,15 @@
<el-table-column label="性别" prop="sex" show-overflow-tooltip sortable></el-table-column>
<el-table-column label="电话" prop="mobile" show-overflow-tooltip></el-table-column>
<el-table-column label="人员类型" prop="personType" show-overflow-tooltip sortable></el-table-column>
<!-- 展示用户当前人员属性,便于结合人员类型及在职状态核实会员信息。 -->
<el-table-column label="人员属性" prop="userAttribute" show-overflow-tooltip sortable></el-table-column>
<el-table-column label="在职状态" prop="userState" show-overflow-tooltip sortable></el-table-column>
<el-table-column label="所属单位" prop="unitName" sortable show-overflow-tooltip></el-table-column>
<el-table-column label="身份" prop="roleName" show-overflow-tooltip></el-table-column>
<el-table-column label="入会时间" prop="joinTime" show-overflow-tooltip sortable></el-table-column>
<el-table-column label="操作">
<!-- 历史成员仅供查询,退出时间取申请记录,不提供会员关系修改入口。 -->
<el-table-column v-if="pageForm.radioType === '4'" label="退出时间" prop="exitTime" show-overflow-tooltip sortable></el-table-column>
<el-table-column v-if="pageForm.radioType !== '4'" label="操作">
<template slot-scope="{row}">
<el-dropdown>
<el-button size="mini">
@@ -54,8 +73,11 @@
</el-dropdown-item>
<!--<el-dropdown-item @click.native="viewInfo(row)">查看注册信息</el-dropdown-item>
<el-dropdown-item @click.native="exportInfo(row)">导出登记表</el-dropdown-item>-->
<!-- 临时屏蔽删除入口,保留原调用以便后续恢复。
<el-dropdown-item @click.native="userDelete(row)">删除</el-dropdown-item>
<!--<el-dropdown-item @click.native="exitClub(row)">退会</el-dropdown-item>-->
-->
<!-- 管理端直接退会沿用现有接口,提交期间禁用入口以防重复操作。 -->
<el-dropdown-item :disabled="formLoading" @click.native="exitClub(row)">退会</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</template>
@@ -157,6 +179,37 @@
},
data() {
return {
pageForm: {
focusMembers: false,
joinDateRange: [],
joinDateStart: null,
joinDateEnd: null,
pageOrderName: "joinTime",
pageOrderBy: "descending"
},
// 快捷范围按自然月计算,月底由 moment 自动调整为目标月份的有效日期。
joinDatePickerOptions: {
shortcuts: [
{
text: "当前年度",
onClick: picker => {
picker.$emit("pick", [this.$moment().startOf("year").toDate(), new Date()])
}
},
{
text: "近半年",
onClick: picker => {
picker.$emit("pick", [this.$moment().subtract(6, "months").startOf("day").toDate(), new Date()])
}
},
{
text: "近三个月",
onClick: picker => {
picker.$emit("pick", [this.$moment().subtract(3, "months").startOf("day").toDate(), new Date()])
}
}
]
},
roleCodeFormData: {},
roleCodeFormRules: {
userName: [{ required: true, message: "必填", trigger: ["blur"] }],
@@ -183,6 +236,28 @@
"file-import": httpVueLoader("/components/plugins/sysImport/index.vue?v=" + new Date().getTime())
},
methods: {
/** 启用重点关注时同时筛选自聘人员和其他人员,清空单选条件以避免冲突;取消后恢复普通查询。 */
toggleFocusMembers() {
this.$set(this.pageForm, "focusMembers", !this.pageForm.focusMembers)
if (this.pageForm.focusMembers) {
this.$set(this.pageForm, "personType", "")
}
this.doSearch()
},
/** 页签切换只调整业务排序,查询和重置页码继续复用 initTableMixins。 */
memberTabChange() {
this.$set(this.pageForm, "pageOrderName", this.pageForm.radioType === "4" ? "exitTime" : "joinTime")
this.$set(this.pageForm, "pageOrderBy", "descending")
this.doSearch()
},
/** 日期范围始终筛选入会时间;当前会员按入会时间倒序,历史成员按退出时间倒序。 */
joinDateRangeChange(val) {
this.$set(this.pageForm, "joinDateStart", val && val.length === 2 ? val[0] : null)
this.$set(this.pageForm, "joinDateEnd", val && val.length === 2 ? val[1] : null)
this.$set(this.pageForm, "pageOrderName", this.pageForm.radioType === "4" ? "exitTime" : "joinTime")
this.$set(this.pageForm, "pageOrderBy", "descending")
this.doSearch()
},
openImport() {
this.importData = {
isfg: 1,
@@ -316,21 +391,29 @@
}
}
},
async exitClub(row) {
const confirm = await this.$confirm("确定要将其退会吗?", "提示", {
exitClub(row) {
if (this.formLoading) return
return this.$confirm("确定要将其退会吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(() => {
if (this.formLoading) return
this.$set(this, "formLoading", true)
return this.$axios.post("/platform/club/infoManage/manage/exitClub", { id: row.id }).then(resp => {
if (resp.code === 0) {
this.$message.success(resp.msg)
return this.pageData()
} else {
this.$message.warning(resp.msg)
}
}).finally(() => {
// 接口或列表刷新异常时也释放提交状态,允许后续操作。
this.$set(this, "formLoading", false)
})
}, () => {
// 取消或关闭确认框时,不发送退会请求。
})
if (confirm === "confirm") {
const resp = await this.$axios.post("/platform/club/infoManage/manage/exitClub", { id: row.id })
if (resp.code === 0) {
this.$message.success(resp.msg)
await this.pageData()
} else {
this.$message.warning(resp.msg)
}
}
},
//导出登记表
@@ -369,4 +452,3 @@
this.pageData()
}
}
@@ -42,6 +42,16 @@ layout("/layouts/platform.html"){
#app .club-manage-query-card {
flex: 0 0 auto;
}
/* 日期范围框预留约 320px,包含标签的查询项同步加宽;窄屏仍可换行并适配容器。 */
#app .club-manage-query-card .search .club-join-date-query {
flex: 0 0 420px;
width: 420px;
max-width: 100%;
}
#app .club-manage-query-card .club-join-date-query .search-item-option {
flex: 1 1 auto;
min-width: 0;
}
#app .club-manage-list-card {
flex: 1 1 auto;
min-height: 0;
@@ -71,6 +81,7 @@ layout("/layouts/platform.html"){
<div class="club-tree-scroll">
<el-tree
:data="treeData"
node-key="id"
ref="treeRef"
:expand-on-click-node="false"
:props="{children: 'children', label: 'clubName'}"
@@ -221,12 +232,24 @@ layout("/layouts/platform.html"){
this.currentTreeData = data
this.currentTreeNode = node
this.$nextTick(() => {
this.$refs.clubInfo.pageData()
// 一级节点展示社团总览,只有具体社团节点才需要刷新会员列表。
if (node.level === 2 && this.$refs.clubInfo) {
this.$refs.clubInfo.pageData()
}
})
},
getTreeData() {
this.$axios.post("/platform/club/infoManage/manage/getClubTreeData").then((res) => {
this.treeData = res.data
this.$nextTick(() => {
// 首次进入时选中一级节点,同步树高亮与右侧总览;后续刷新保留用户选择。
if (!this.currentTreeNode && this.treeData && this.treeData.length > 0) {
const rootData = this.treeData[0]
this.$refs.treeRef.setCurrentKey(rootData.id)
this.$set(this, "currentTreeData", rootData)
this.$set(this, "currentTreeNode", this.$refs.treeRef.getNode(rootData.id))
}
})
})
},
async userRemoteMethod(query) {
@@ -21,8 +21,12 @@ const SCHOOL_CLUB_MANAGE_TEMPLATE = {
</el-card>
<el-card shadow="never" class="mt10 club-manage-list-card">
<table-tool label="社团列表">
<template>
<template slot="content">
<!-- 一级节点展示社团总览时,引导用户从左侧树选择社团查看详细信息。 -->
<span v-if="parentNode.currentTreeNode && parentNode.currentTreeNode.level === 1"
style="display: block; text-align: center; color: #f56c6c;">
温馨提醒:选择左侧树中的某个社团,可查询该社团的详细信息。
</span>
</template>
</table-tool>
<el-table v-loading="tableLoading" :data="tableData" :height="tableHeight"
@@ -28,14 +28,14 @@ layout("/layouts/platform.html"){
@change="doSearch"
></el-date-picker>
</search-item>
<search-item label="社团名称" v-if="$auth.hasRoleOr(['SYSADMIN'])">
<search-item label="社团名称">
<el-select clearable filterable placeholder="请选择社团" @change="doSearch"
style="width: 100%;" v-model="pageForm.clubId">
<el-option :label="item.clubName" :value="item.id"
v-for="item in clubs"></el-option>
</el-select>
</search-item>
<search-item label="关键字">
<search-item label="姓名工号">
<el-input v-model="pageForm.searchKeyword" placeholder="请输入工号或者姓名查询"
clearable @keyup.enter="doSearch"></el-input>
</search-item>
@@ -61,8 +61,14 @@ layout("/layouts/platform.html"){
<el-button @click="batchOperatePay" size="small" type="primary">
批量设置
</el-button>
<!-- 2026年10月3日 14:33:18;操作人:wxg:筛选当前会员状态,按钮风格沿用历史拨付页面,首次进入默认在会。 -->
<el-radio-group v-model="pageForm.memberState" size="small" @change="memberStateChange" class="ml5">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button label="1">在会</el-radio-button>
<el-radio-button label="0">退会</el-radio-button>
</el-radio-group>
</table-tool>
<el-table :data="tableData" :height="tableHeight" row-key="id" @selection-change="handleSelectionChange" ref="tableRef">
<el-table :data="tableData" :height="tableHeight" v-loading="tableLoading" row-key="id" @selection-change="handleSelectionChange" ref="tableRef">
<el-table-column
:reserve-selection="true"
type="selection"
@@ -75,15 +81,16 @@ layout("/layouts/platform.html"){
<el-table-column label="所属单位" prop="unitName" sortable show-overflow-tooltip></el-table-column>
<el-table-column label="加入时间" prop="applyTime" sortable show-overflow-tooltip>
<template v-slot="{ row }">
{{ $moment(row.applyTime).format('YYYY-MM-DD HH:mm:ss') }}
{{ row.applyTime ? $moment(row.applyTime).format('YYYY-MM-DD') : '' }}
</template>
</el-table-column>
<el-table-column label="在职状态" prop="userState" sortable show-overflow-tooltip>
<!-- 离退休人员有退休日期时补充退休年份;其他类型或日期缺失时保留原值。 -->
<el-table-column label="人员类型" prop="personType" min-width="120" sortable show-overflow-tooltip>
<template v-slot="{ row }">
<span v-if="row.userState === '退休' && row.retireDate">
{{ row.userState + '(' + row.retireDate + ')' }}
</span>
<span v-else>{{ row.userState }}</span>
<span v-if="row.personType === '离退休人员' && row.retireDate">
{{ row.personType + '(' + $moment(row.retireDate).format('YYYY') + ')' }}
</span>
<span v-else>{{ row.personType }}</span>
</template>
</el-table-column>
<el-table-column label="当前状态" prop="count" sortable show-overflow-tooltip>
@@ -97,10 +104,12 @@ layout("/layouts/platform.html"){
<el-tooltip placement="bottom">
<div slot="content">
是否具备拨付资格,将按以下规则判定:<br/>
第一步:缴费记录必须是已缴费状态,否则直接判定无资格;<br/>
第二步:如果在职状态不是“退休”,则直接判定有领取资格;<br/>
第三步:如果是“退休”状态但未有退休日期信息,判定无领取资格;<br/>
第四步:如果是“退休”状态且填写了退休日期,当您的退休年份 ≥ 缴费记录年份(无缴费年份则按今年算)时,判定有资格,反之则无。
第一步:当前状态为“退会”,直接判定不拨付;<br/>
第二步:缴费记录必须是已缴费状态,否则判定不拨付;<br/>
第三步:如果人员类型不是“离退休人员”,则直接判定有领取资格;<br/>
第四步:如果人员类型为“离退休人员”但未有退休日期信息,判定无领取资格;<br/>
第五步:如果人员类型为“离退休人员”且填写了退休日期,当您的退休年份 ≥ 缴费记录年份(无缴费年份则按今年算)时,判定有资格,反之则无。<br/>
修改缴费状态时重新计算并保存;退会时保存为不拨付。列表读取已保存的拨付结果。
</div>
<span>拨付状态<i class="el-icon-question"></i></span>
</el-tooltip>
@@ -153,7 +162,7 @@ layout("/layouts/platform.html"){
<span class="dialog-footer" slot="footer">
<el-button @click="row = {}; payDialogVisible = false">取消</el-button>
<el-button @click="onBatchHandle" type="primary">确定</el-button>
<el-button @click="onBatchHandle" :loading="formLoading" type="primary">确定</el-button>
</span>
</el-dialog>
</div>
@@ -170,7 +179,10 @@ layout("/layouts/platform.html"){
return {
pageForm: {
payed: null,
// 2026年10月3日 14:33:18;操作人:wxg:列表默认锚点及首次请求均为“在会”。
memberState: '1',
year: new Date().getFullYear() + '',
clubId: null,
},
clubs: [],
row: {},
@@ -178,9 +190,16 @@ layout("/layouts/platform.html"){
payDialogVisible: false,
payed: false,
multipleSelection: [],
pageRequestSequence: 0,
}
},
methods: {
// 2026年10月3日 14:33:18;操作人:wxg:切换会员状态清空原勾选;复用 mixin 搜索方法回到第一页。
memberStateChange() {
this.$set(this, "multipleSelection", [])
if (this.$refs.tableRef) this.$refs.tableRef.clearSelection()
this.doSearch()
},
handleSelectionChange(val) {
this.multipleSelection = val
},
@@ -221,48 +240,64 @@ layout("/layouts/platform.html"){
}
this.operateRequest(data)
},
// 2026年10月3日 09:51:00;操作人:wxg:缴费设置会同步重算并保存拨付状态;列表刷新读取保存结果。
operateRequest(data) {
let message
if (this.payed === false) {
message = '您确定要设置为未缴费吗,此操作将影响该成员的拨付状态。'
message = '您确定要设置为未缴费吗?此操作将同步保存为不拨付。'
} else {
message = '您确定要设置为缴费吗?'
message = '您确定要设置为已缴费吗?此操作将按退会状态、人员类型及退休年份重新计算并保存拨付状态。'
}
this.$confirm(message, '温馨提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
const resp = await this.$axios.post("/platform/club/pay/handle", {
.then(() => {
this.formLoading = true
return this.$axios.post("/platform/club/pay/handle", {
ids: JSON.stringify(data),
payed: this.payed,
}).then(resp => {
if (resp.code === 0) {
this.$message.success(resp.msg)
this.doSearch()
this.payDialogVisible = false
this.multipleSelection = []
this.$refs.tableRef.clearSelection()
} else {
this.$message.error(resp.msg)
}
}).finally(() => {
this.formLoading = false
})
if (resp.code === 0) {
this.$message.success(resp.msg)
this.doSearch()
this.payDialogVisible = false
this.multipleSelection = []
this.$refs.tableRef.clearSelection()
} else {
this.$message.error(resp.msg)
}
})
.catch(() => {})
},
async pageData() {
const resp = await this.$axios.post("/platform/club/pay/pageData", this.pageForm)
if (resp.code === 0) {
this.tableData = resp.data.list
this.pageForm.totalCount = resp.data.totalCount
} else {
this.$message.warning(resp.msg)
}
// 每次只请求当前分页,连续切换条件时仅接收最后一次响应,避免旧页覆盖新结果。
pageData() {
const requestSequence = ++this.pageRequestSequence
this.tableLoading = true
return this.$axios.post("/platform/club/pay/pageData", this.pageForm).then(resp => {
if (requestSequence !== this.pageRequestSequence) return
if (resp.code === 0) {
this.tableData = resp.data.list || []
this.$set(this.pageForm, "totalCount", resp.data.totalCount || 0)
this.updateClubInfoListTableHeight()
} else {
this.$message.warning(resp.msg)
}
}).finally(() => {
if (requestSequence === this.pageRequestSequence) this.tableLoading = false
})
}
},
async created() {
this.clubs = await this.$businessTool.listClubByRole()
await this.pageData()
created() {
// 下拉选项与缴费列表共用后端授权范围,不复用其他业务的社团权限口径。
this.$axios.post("/platform/club/pay/listClubs").then(resp => {
if (resp.code === 0) this.clubs = resp.data || []
})
this.pageData()
}
})
</script>
@@ -56,7 +56,7 @@ layout("/layouts/platform.html"){
<el-table-column prop="clubName" label="申请社团" show-overflow-tooltip></el-table-column>
<el-table-column prop="mode" label="申请模式">
<template slot-scope="scope">
<el-tag size="mini" v-if="scope.row.mode" type="success">加入</el-tag>
<el-tag size="mini" v-if="scope.row.mode === 1" type="success">加入</el-tag>
<el-tag size="mini" v-else type="danger">退出</el-tag>
</template>
</el-table-column>
@@ -90,7 +90,7 @@ layout("/layouts/platform.html"){
{{formData.taskName}}
</div>
<el-form :model="formData" ref="formRef" label-width="80px" :rules="formRules" label-suffix="">
<el-form-item v-if="formData.mode === true" label="缴费状态" prop="payed"
<el-form-item v-if="formData.mode === 1" label="缴费状态" prop="payed"
:rules="[{required:true,message:'必填',trigger:['change','blur']}]">
<span class="form-item-tooltip">
(说明:请确认{{ formData.userName }}当年是否缴费)
@@ -28,8 +28,10 @@ layout("/layouts/platform.html"){
<el-table-column prop="clubName" label="社团名称"></el-table-column>
<el-table-column prop="mode" label="申请模式">
<template v-slot="scope">
<el-tag size="mini" v-if="scope.row.mode" type="success">加入</el-tag>
<el-tag size="mini" v-else type="danger">退出</el-tag>
<el-tag size="mini" v-if="scope.row.mode === 1" type="success">加入</el-tag>
<el-tag size="mini" v-else-if="scope.row.mode === 0" type="danger">退出</el-tag>
<!-- 系统直接退会以 mode=2 标识,不属于待提交的 WF 申请。 -->
<el-tag size="mini" v-else-if="scope.row.mode === 2" type="info">系统处理</el-tag>
</template>
</el-table-column>
<el-table-column prop="applyDate" label="申请时间"></el-table-column>
@@ -42,10 +44,10 @@ layout("/layouts/platform.html"){
<el-table-column label="操作" width="300">
<template v-slot="{row}">
<el-button @click="openView(row)" size="mini" type="primary">查看</el-button>
<el-button v-if="(row.taskKey === 'startTask' || !row.instanceId) && row.mode === true" @click="onEdit(row)" size="mini" type="primary">编辑</el-button>
<el-button v-if="(row.taskKey === 'startTask' || !row.instanceId) && row.mode === false" @click="exitSubmitAgain(row)" size="mini" type="primary">提交</el-button>
<el-button v-if="(row.taskKey === 'startTask' || !row.instanceId) && row.mode === 1" @click="onEdit(row)" size="mini" type="primary">编辑</el-button>
<el-button v-if="(row.taskKey === 'startTask' || !row.instanceId) && row.mode === 0" @click="exitSubmitAgain(row)" size="mini" type="primary">提交</el-button>
<el-button v-if="row.canRevoke" size="mini" type="danger" @click="onRevoke(row)">撤回</el-button>
<el-button v-if="row.taskKey === 'startTask' || !row.instanceId" @click="onDelete(row.id)" size="mini" type="danger">
<el-button v-if="row.mode !== 2 && (row.taskKey === 'startTask' || !row.instanceId)" @click="onDelete(row.id)" size="mini" type="danger">
删除
</el-button>
</template>
@@ -49,7 +49,7 @@ layout("/layouts/platform.html"){
<el-table-column prop="clubName" label="申请社团" show-overflow-tooltip></el-table-column>
<el-table-column prop="mode" label="申请模式">
<template slot-scope="scope">
<el-tag size="mini" v-if="scope.row.mode" type="success">加入</el-tag>
<el-tag size="mini" v-if="scope.row.mode === 1" type="success">加入</el-tag>
<el-tag size="mini" v-else type="danger">退出</el-tag>
</template>
</el-table-column>
File diff suppressed because it is too large Load Diff
@@ -13,24 +13,24 @@ layout("/layouts/platform.html"){
<el-input placeholder="请输入姓名或工号" clearable v-model="pageForm.searchKeyword"></el-input>
</search-item>
<search-item label="所属工会" v-if="isAdmin">
<el-select v-model="pageForm.unionId" @change="doSearch" style="width: 100%"
placeholder="请选择所属工会" clearable>
<el-option v-for="item in unionOptions"
:value="item.id"
:key="item.id"
:label="item.name"
></el-option>
</el-select>
<el-select v-model="pageForm.unionId" @change="doSearch" style="width: 100%"
placeholder="请选择所属工会" clearable>
<el-option v-for="item in unionOptions"
:value="item.id"
:key="item.id"
:label="item.name"
></el-option>
</el-select>
</search-item>
<search-item label="所属单位" v-if="isAdmin">
<el-select v-model="pageForm.unitId" @change="doSearch" style="width: 100%"
placeholder="请选择所属单位" clearable>
<el-option v-for="item in unitOptions"
:value="item.id"
:key="item.id"
:label="item.name"
></el-option>
</el-select>
<el-select v-model="pageForm.unitId" @change="doSearch" style="width: 100%"
placeholder="请选择所属单位" clearable>
<el-option v-for="item in unitOptions"
:value="item.id"
:key="item.id"
:label="item.name"
></el-option>
</el-select>
</search-item>
<search-item label="报销项目">
<el-select v-model="pageForm.reimburseProject" @change="doSearch" style="width: 100%"
@@ -95,9 +95,13 @@ layout("/layouts/platform.html"){
<el-table-column label="操作" fixed="right" width="350px">
<template slot-scope="{row}">
<el-button @click="onView(row)" size="mini" type="primary">查看</el-button>
<el-button v-if="isAdmin && row.stateId == 3" @click="openEdit(row)" size="mini" type="primary">实际金额</el-button>
<el-button v-if="row.stateId == 3" @click="doPrint(row)" size="mini" type="primary">打印</el-button>
<el-button v-if="$auth.hasRole('SYSADMIN')" @click="onDelete(row.id)" size="mini" type="danger">删除</el-button>
<el-button v-if="isAdmin && row.stateId == 3" @click="openEdit(row)" size="mini" type="primary">
实际金额
</el-button>
<el-button @click="doPrint(row)" size="mini" type="primary">打印</el-button>
<el-button v-if="$auth.hasRole('SYSADMIN')" @click="onDelete(row.id)" size="mini" type="danger">
删除
</el-button>
</template>
</el-table-column>
</el-table>
@@ -124,13 +128,12 @@ layout("/layouts/platform.html"){
</div>
<script nonce="${cspNonce!}">
<!--#include('../info.js'){}#-->
new Vue({
el: "#app",
store,
dicts: ["UNION_REIMBURSE_TYPE","UNION_REIMBURSE_PROJECT"],
dicts: ["UNION_REIMBURSE_TYPE", "UNION_REIMBURSE_PROJECT"],
//分页数据
mixins: [initTableMixins],
components: {
@@ -149,8 +152,8 @@ layout("/layouts/platform.html"){
},
editFormRules: {
realMoney: [
{ required: true, message: '请输入实际报销金额', trigger: 'blur' },
{ pattern: /^([0-9]+)(\.[0-9]{1,2})?$/, message: '请输入正确的金额格式', trigger: 'blur' }
{required: true, message: '请输入实际报销金额', trigger: 'blur'},
{pattern: /^([0-9]+)(\.[0-9]{1,2})?$/, message: '请输入正确的金额格式', trigger: 'blur'}
]
}
}
@@ -204,7 +207,7 @@ layout("/layouts/platform.html"){
}
},
onView(row) {
this.$refs.guava.edit(()=>{
this.$refs.guava.edit(() => {
this.showApprovalForm = false
this.$refs.unionReimburseInfoRef.onOpen(row)
})
@@ -266,11 +269,8 @@ layout("/layouts/platform.html"){
window.open('/platform/unionReimburse/collect/exportSkr?year=' + year + "&unionId=" + unionId + '&unitId=' + unitId + "&reimburseProject=" + reimburseProject)
},
doPrint(row) {
if (row.reimburseProject != "UNION_REIMBURSE_PROJECT_1") {
window.location.href = "/platform/unionReimburse/mine/ActExport?id=" + (row.id || '') + "&Print=true"
} else {
window.location.href = "/platform/unionReimburse/mine/ConExport?id=" + (row.id || '') + "&Print=true"
}
// 所有状态均可下载审批单 PDF,复用公共下载的 POST 请求、错误提示和 loading 收尾。
this.$downLoad("/platform/unionReimburse/collect/exportPdf", {id: row.id})
},
onExport() {
this.$downLoad('/platform/unionReimburse/collect/onExport', this.pageForm)
@@ -130,38 +130,38 @@ const unionReimburseInfo = {
<span>{{ viewData.condolenceTime | dateFormat }}</span>
</el-descriptions-item>
<el-descriptions-item label="结婚时间" v-if="viewData.condolenceTypeId === 'cf4ce7af322d464f8f86d818fcc00203'">
<el-descriptions-item label="结婚时间" v-if="condolenceTypeCode === 'A'">
<span>{{ viewData.marryTime | dateFormat }}</span>
</el-descriptions-item>
<el-descriptions-item label="生育时间" v-if="viewData.condolenceTypeId === 'c90cb10dce6542e99ae271bee6fe8cc0'">
<el-descriptions-item label="生育时间" v-if="condolenceTypeCode === 'D'">
<span>{{ viewData.fertilityTime | dateFormat }}</span>
</el-descriptions-item>
<el-descriptions-item label="住院病由" v-if="viewData.condolenceTypeId === '4e316737e71047e0b01aea78524c1416'">
<el-descriptions-item label="住院病由" v-if="condolenceTypeCode === 'E'">
<span>{{ viewData.hospitalCausation }}</span>
</el-descriptions-item>
<el-descriptions-item label="住院时间" v-if="viewData.condolenceTypeId === '4e316737e71047e0b01aea78524c1416'">
<el-descriptions-item label="住院时间" v-if="condolenceTypeCode === 'E'">
<span v-if="viewData.hospitalizationStartTime && viewData.hospitalizationEndTime">
{{ $moment(viewData.hospitalizationStartTime).format('YYYY-MM-DD') }} 至 {{ $moment(viewData.hospitalizationEndTime).format('YYYY-MM-DD') }}
</span>
<span v-else>暂无住院时间</span>
</el-descriptions-item>
<el-descriptions-item label="入住医院" v-if="viewData.condolenceTypeId === '4e316737e71047e0b01aea78524c1416'">
<el-descriptions-item label="入住医院" v-if="condolenceTypeCode === 'E'">
<span>{{ viewData.hospital }}</span>
</el-descriptions-item>
<el-descriptions-item label="当年次数" v-if="viewData.condolenceTypeId === '4e316737e71047e0b01aea78524c1416'">
<el-descriptions-item label="当年次数" v-if="condolenceTypeCode === 'E'">
<span>{{ viewData.hospitalCount }}</span>
</el-descriptions-item>
<el-descriptions-item label="去逝时间" v-if="viewData.condolenceTypeId === '60f03a87b62b4823855814afdbf5672a'">
<el-descriptions-item label="去逝时间" v-if="condolenceTypeCode === 'G' || condolenceTypeCode === 'H'">
<span>{{ viewData.deathTime | dateFormat }}</span>
</el-descriptions-item>
<el-descriptions-item label="与被慰问人关系" v-if="viewData.condolenceTypeId === '60f03a87b62b4823855814afdbf5672a'">
<el-descriptions-item label="与被慰问人关系" v-if="condolenceTypeCode === 'H'">
<span>{{ viewData.condolenceRelationship }}</span>
</el-descriptions-item>
@@ -174,10 +174,6 @@ const unionReimburseInfo = {
<span>{{ viewData.paymentNotes }}</span>
</el-descriptions-item>
<el-descriptions-item label="备注" :span="3" v-if="viewData.reimburseProject === 'UNION_REIMBURSE_PROJECT_1'">
<span>{{ viewData.notes }}</span>
</el-descriptions-item>
<el-descriptions-item label="附件" :span="3">
<file-preview v-if="viewData.files && viewData.files.length > 0"
:files="viewData.files"
@@ -204,15 +200,6 @@ const unionReimburseInfo = {
<el-col :span="24">
<el-form-item label="支付内容" label-width="100px">
<el-input :autosize="{ minRows: 4, maxRows: 8}"
:value="viewData.paymentNotes"
readonly
type="textarea"></el-input>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="备注" label-width="100px">
<el-input :autosize="{ minRows: 3, maxRows: 6}"
:value="viewData.notes"
readonly
type="textarea"></el-input>
@@ -240,7 +227,17 @@ const unionReimburseInfo = {
</template>
</el-table-column>
<el-table-column label="发票号码" min-width="140" prop="invoiceNo"></el-table-column>
<el-table-column align="right" label="发票金额" min-width="110">
<el-table-column align="right" label="税前金额" min-width="110">
<template slot-scope="scope">
<span>{{ scope.row.amountWithoutTax == null ? '-' : formatMoney(scope.row.amountWithoutTax) }}</span>
</template>
</el-table-column>
<el-table-column align="right" label="税额" min-width="100">
<template slot-scope="scope">
<span>{{ scope.row.taxAmount == null ? '-' : formatMoney(scope.row.taxAmount) }}</span>
</template>
</el-table-column>
<el-table-column align="right" label="价税合计" min-width="110">
<template slot-scope="scope">
<span>{{ formatMoney(scope.row.invoiceAmount) }}</span>
</template>
@@ -299,6 +296,11 @@ const unionReimburseInfo = {
}
},
computed: {
// 详情接口按关联类型返回业务编码,避免不同环境的类型 ID 影响字段显示。
condolenceTypeCode() {
return this.viewData.reimburseProject === "UNION_REIMBURSE_PROJECT_1"
? (this.viewData.condolenceTypeCode || "") : ""
},
showInvoiceTab() {
return !!this.viewData.reimburseProject
},
@@ -86,10 +86,9 @@ layout("/layouts/platform.html"){
<el-table-column label="操作" fixed="right" width="300px">
<template slot-scope="{row}">
<el-button @click="onView(row)" size="mini" type="primary">查看</el-button>
<el-button v-if="[2, 3, 4, 5].includes(Number(row.stateId))" @click="doExport(row)" size="mini" type="primary">导出</el-button>
<el-button v-if="row.stateId == 1 || row.stateId == 5" @click="onEdit(row)" size="mini" type="primary">编辑</el-button>
<el-button v-if="row.stateId == 2" @click="onRevoke(row.id)" size="mini" type="danger">撤回</el-button>
<el-button v-if="row.stateId == 3" @click="doExport(row)" size="mini" type="primary">导出报销表</el-button>
<el-button v-if="row.stateId == 3" @click="doPrint(row)" size="mini" type="primary">打印</el-button>
<el-button v-if="row.stateId == 1" @click="onDelete(row.id)" size="mini" type="danger">删除</el-button>
</template>
</el-table-column>
@@ -171,19 +170,8 @@ layout("/layouts/platform.html"){
},
doExport(row) {
if (row.reimburseProject != "UNION_REIMBURSE_PROJECT_1") {
window.location.href = "/platform/unionReimburse/mine/ActExport?id=" + (row.id || '')
} else {
window.location.href = "/platform/unionReimburse/mine/ConExport?id=" + (row.id || '')
}
},
doPrint(row) {
if (row.reimburseProject != "UNION_REIMBURSE_PROJECT_1") {
window.location.href = "/platform/unionReimburse/mine/ActExport?id=" + (row.id || '') + "&Print=true"
} else {
window.location.href = "/platform/unionReimburse/mine/ConExport?id=" + (row.id || '') + "&Print=true"
}
// 复用公共下载的POST请求、错误提示和loading收尾,由后端选择对应审批单模板。
this.$downLoad("/platform/unionReimburse/mine/exportPdf", {id: row.id})
}
},
@@ -212,7 +212,7 @@ layout("/layouts/platform.html"){
this.showApprovalForm = true
this.formData = {
id: row.id,
reviewOpinion: '通过'
reviewOpinion: '已付款成功'
}
this.$refs.unionReimburseInfoRef.onOpen(row)
})
@@ -136,9 +136,9 @@ layout("/layouts/platform.html"){
this.$set(this.formData, 'isRead', true)
this.$commonUtil.previewFile({
suffix: "pdf",
downloadPath: "/platform/sys/file/download?id=orpgde84uggjmpflbob83tadan",
downloadPath: "/platform/sys/file/download?id=15eblmambgh3hoj7hg20n4obsg",
name: "中国地质大学(武汉)教职工重大疾病互助基金管理办法.pdf",
id: "orpgde84uggjmpflbob83tadan"
id: "15eblmambgh3hoj7hg20n4obsg"
})
},
isReadChange() {

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