diff --git a/src/main/java/com/budwk/app/base/constant/RoleConstant.java b/src/main/java/com/budwk/app/base/constant/RoleConstant.java index 69f1abf5..5c110418 100644 --- a/src/main/java/com/budwk/app/base/constant/RoleConstant.java +++ b/src/main/java/com/budwk/app/base/constant/RoleConstant.java @@ -27,6 +27,8 @@ RoleConstant { SCHOOL_UNION_PROPOSAL_ADMIN("校工会提案管理员"), SCHOOL_UNION_CLUB_ADMIN("校工会社团管理员"), SCHOOL_UNION_ACTIVITY_ADMIN("校工会活动管理员"), + /** 品牌活动统计、人员调整管理员,数据范围按 trainSignUp 子活动负责人关系控制。 */ + SCHOOL_UNION_TRAIN_SIGN_UP_ADMIN("校工会品牌活动管理员"), SCHOOL_UNION_ARTICLE_CLUB_ADMIN("校工会社团新闻审批管理员"), SCHOOL_UNION_ARTICLE_FGH_ADMIN("校工会分工会新闻审批管理员"), SCHOOL_UNION_ARTICLE_ADMIN("校工会新闻审批管理员"), diff --git a/src/main/java/com/budwk/app/web/commons/ext/handler/WkTomcatConfiguration.java b/src/main/java/com/budwk/app/web/commons/ext/handler/WkTomcatConfiguration.java index 653b76e5..c03486c7 100644 --- a/src/main/java/com/budwk/app/web/commons/ext/handler/WkTomcatConfiguration.java +++ b/src/main/java/com/budwk/app/web/commons/ext/handler/WkTomcatConfiguration.java @@ -3,6 +3,7 @@ package com.budwk.app.web.commons.ext.handler; import com.budwk.app.web.commons.ext.websocket.WkWebSocket; import org.apache.catalina.core.StandardContext; import org.apache.catalina.Executor; +import org.apache.catalina.Wrapper; import org.apache.catalina.core.StandardThreadExecutor; import org.apache.catalina.startup.Tomcat; import org.apache.catalina.valves.ErrorReportValve; @@ -51,6 +52,9 @@ public class WkTomcatConfiguration implements NbAppEventListener { // NutzBoot 使用编程式 Context,必须显式运行官方 WebSocket 初始化器。 StandardContext context = (StandardContext) tomcat.getHost().findChild(starter.getContextPath()); + // 静态文本资源统一按 UTF-8 读取,避免服务器默认 GBK 与 UTF-8 响应编码不一致时破坏中文和正则。 + Wrapper defaultServlet = (Wrapper) context.findChild("default"); + defaultServlet.addInitParameter("fileEncoding", "UTF-8"); // 为静态 SVG 补齐响应类型,避免浏览器收到文件后仍无法按图片解码。 context.addMimeMapping("svg", "image/svg+xml"); context.setUseHttpOnly(conf.getBoolean("tomcat.session.cookie.httponly", true)); diff --git a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpAdjustController.java b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpAdjustController.java index 998a9c16..da8e6ace 100644 --- a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpAdjustController.java +++ b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpAdjustController.java @@ -1,32 +1,22 @@ package com.budwk.app.zhgh.activity.trainSignUp.controller.manage; import cn.dev33.satoken.annotation.SaCheckPermission; -import cn.hutool.core.date.DateUtil; import com.budwk.app.base.annotation.SLog; 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.service.BaseService; -import com.budwk.app.zhgh.activity.trainSignUp.models.*; -import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityService; import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityStatisticsService; 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.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.lang.util.NutMap; import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Param; -import java.util.ArrayList; -import java.util.List; - /** * @Author JyuHsin * @Date 2022/9/21 @@ -39,12 +29,6 @@ import java.util.List; @At("/platform/trainSignUp/adjust") public class TrainSignUpAdjustController { - @Inject - private Dao dao; - @Inject - private BaseService baseService; - @Inject - private TrainSignUpActivityService trainSignUpActivityManageService; @Inject private TrainSignUpActivityStatisticsService trainSignUpActivityStatisticsService; @@ -55,122 +39,87 @@ public class TrainSignUpAdjustController { } /** - * 活动list - * @param year 年度 - * @return + * @param year 查询年度,为空时不限制年度 + * @return Result.data 为当前登录人可管理的已启用主活动数组,id/activityName 用于下拉框 */ @At @ApiOperation("活动列表") @SaCheckPermission("trainSignUp.adjust") - public Result activityList(@Param(value = "year") Integer year) { - List activityList = dao.query(TrainSignUpActivity.class, Cnd.NEW().andEX("year", "=", year).andEX("isDisabled", "=", false).desc("activityStartTime")); - return Result.success(activityList); + public Result activityList(@Param("year") Integer year) { + return Result.success(trainSignUpActivityStatisticsService.activityList(year, true)); } + /** + * @param pageForm 分页参数,pageNumber 为页码,pageSize 为每页条数 + * @param activityId 主活动ID,为空时返回空分页 + * @return Result.data 为 Pagination,list 和 totalCount 均按负责人范围查询 + */ @At @ApiOperation("分页查询") @SaCheckPermission("trainSignUp.adjust") - public Result pageData(PageForm pageForm, - @Param(value = "activityId") String activityId) { + public Result pageData(PageForm pageForm, @Param("activityId") String activityId) { Pagination pagination = trainSignUpActivityStatisticsService.pageData(pageForm, activityId); return Result.success(pagination); } + /** + * @param activityId 主活动ID + * @return Result.data 为可调整的子活动数组,id 为目标ID,registerNum/hasWaitingNum 为已报及候补人数 + */ @At @ApiOperation("子活动查询") @SaCheckPermission("trainSignUp.adjust") public Result getCourse(String activityId) { - Sql sql = Sqls.create(""" - SELECT - tsuc.id, - tsuc.courseName, - tsuc.coursePeopleNumber, - tsuc.courseType, - tsuc.courseLocation, - tsuc.courseInstructor, - tsuc.courseReservedNumber, - tsuc.waitingNum, - tsuc.reserveMode - FROM - `train_sign_up_course` tsuc - WHERE - tsuc.activityId = @activityId - ORDER BY courseName asc - """); - sql.setParam("activityId", activityId); - List courseList = baseService.listMap(sql); - courseList.forEach(c -> { - c.put("registerNum", trainSignUpActivityStatisticsService.queryCourseCount(c.getString("id"), c.getString("courseType"))); - c.put("hasWaitingNum", trainSignUpActivityStatisticsService.queryCourseWaitCount(c.getString("id"), c.getString("courseType"))); - }); - return Result.success(courseList); + return Result.success(trainSignUpActivityStatisticsService.getCourse(activityId)); } + /** + * @param courseId 有管理权限的子活动ID + * @param unionId 工会ID,可为空 + * @param unitId 单位ID,可为空 + * @param searchName 保留现有接口参数,姓名和工号搜索统一使用 searchKeyword + * @param searchKeyword 姓名或工号关键字,可为空 + * @return Result.data 为报名人员数组,userId 为报名人员ID,state 为报名状态 + */ @At @ApiOperation("报名用户列表") @SaCheckPermission("trainSignUp.adjust") - public Result registerUserList(@Param("courseId") String courseId, - @Param(value = "unionId") String unionId, - @Param(value = "unitId") String unitId, - @Param(value = "searchName") String searchName, - @Param(value = "searchKeyword") String searchKeyword) { - List list = trainSignUpActivityStatisticsService.registerUserList(courseId, unionId, unitId, searchName, searchKeyword); - return Result.success(list); + public Result registerUserList(@Param("courseId") String courseId, @Param("unionId") String unionId, + @Param("unitId") String unitId, @Param("searchName") String searchName, + @Param("searchKeyword") String searchKeyword) { + return Result.success(trainSignUpActivityStatisticsService.registerUserList(courseId, unionId, unitId, searchName, searchKeyword)); } + /** + * @param activityId 主活动ID,来源和目标子活动都必须属于该活动 + * @param oldCourseId 来源子活动ID,必须有管理权限 + * @param newCourseId 目标子活动ID,必须有管理权限 + * @param userId 被调整的报名人员ID,操作人身份由登录会话确定 + * @return Result,code=0 表示报名及签到记录已在同一事务中调整 + */ @At @ApiOperation("人员调整") @SaCheckPermission("trainSignUp.adjust") - @SLog(tag = "品牌活动-人员调整", msg = "人员调整") + @SLog(tag = "品牌活动-人员调整", msg = "人员调整") + @Aop(TransAop.READ_COMMITTED) public Result adjust(String activityId, String oldCourseId, String newCourseId, String userId) { - - Cnd oldCnd = Cnd.where("activityId", "=", activityId) - .and("courseId", "=", oldCourseId).and("userId", "=", userId); - - Cnd newCnd = Cnd.where("activityId", "=", activityId) - .and("courseId", "=", newCourseId).and("userId", "=", userId); - - //旧的报名信息 - TrainSignUpUser oldTrainSignUpUser = dao.fetch(TrainSignUpUser.class, oldCnd); - oldTrainSignUpUser.setCourseId(newCourseId); - oldTrainSignUpUser.setSignUpTime(DateUtil.date()); - dao.update(oldTrainSignUpUser); - - //新的课程的信息,上课时间 - List activityCourseList = dao.query(TrainSignUpActivityCourse.class, Cnd.where("activityId", "=", activityId) - .and("courseId", "=", newCourseId)); - //先清楚旧的信息 - dao.clear(TrainSignUpUserCourse.class, oldCnd); - //添加新的信息 - List trainSignUpUserCourseList = new ArrayList<>(); - activityCourseList.forEach(item -> { - TrainSignUpUserCourse course = new TrainSignUpUserCourse(); - course.setActivityCourseId(activityId); - course.setCourseId(newCourseId); - course.setUserId(userId); - course.setCourseStartTime(item.getCourseStartTime()); - course.setCourseEndTime(item.getCourseEndTime()); - course.setActivityCourseId(item.getId()); - trainSignUpUserCourseList.add(course); - }); - dao.insert(trainSignUpUserCourseList); + trainSignUpActivityStatisticsService.adjust(activityId, oldCourseId, newCourseId, userId); return Result.success(); } + /** + * @param activityId 主活动ID + * @param courseId 属于该主活动且有管理权限的子活动ID + * @param userId 被删除的报名人员ID + * @return Result,code=0 表示该子活动的报名及签到记录已删除 + */ @At @ApiOperation("删除报名人员") @SaCheckPermission("trainSignUp.adjust") - @SLog(tag = "品牌活动-人员调整", msg = "删除报名人员") + @SLog(tag = "品牌活动-人员调整", msg = "删除报名人员") + @Aop(TransAop.READ_COMMITTED) public Result deleteSignUser(String activityId, String courseId, String userId) { - - //删除 - Cnd cnd = Cnd.NEW(); - cnd.and("activityId", "=", activityId); - cnd.and("courseId", "=", courseId); - cnd.and("userId", "=", userId); - - dao.clear(TrainSignUpUser.class, cnd); - dao.clear(TrainSignUpUserCourse.class, cnd); + trainSignUpActivityStatisticsService.deleteSignUser(activityId, courseId, userId); return Result.success(); } } diff --git a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpApplyController.java b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpApplyController.java index 7aca142e..83ae2421 100644 --- a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpApplyController.java +++ b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpApplyController.java @@ -128,6 +128,7 @@ public class TrainSignUpApplyController { @Param(value = "assortTypes") String[] assortTypes, @Param(value = "searchKeyword") String searchKeyword, @Param(value = "dataType") String dataType) { + // 返回课程签到半径 radius(米),供 H5 地图按实际配置绘制签到范围。 Sql sql = Sqls.create(""" SELECT tsuc.id, @@ -144,6 +145,7 @@ public class TrainSignUpApplyController { tsuc.unionLimit, tsuc.isMobileSign, tsuc.signType, + tsuc.radius, tsuc.isReceiveGift, tsuc.giftType, tsuc.reserveMode, diff --git a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpManageController.java b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpManageController.java index 61862dd6..e69a6cd8 100644 --- a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpManageController.java +++ b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/manage/TrainSignUpManageController.java @@ -10,8 +10,6 @@ import com.budwk.app.base.param.PageForm; import com.budwk.app.base.result.Result; import com.budwk.app.flow.entity.ProcessInstance; import com.budwk.app.flow.enums.ProcessInstanceStateEnum; -import com.budwk.app.sys.models.Sys_home_activity; -import com.budwk.app.sys.models.Sys_home_template; import com.budwk.app.sys.models.Sys_union; import com.budwk.app.sys.models.Sys_unit; import com.budwk.app.web.commons.auth.utils.AuthUtil; @@ -22,18 +20,18 @@ import com.budwk.app.zhgh.club.model.SysClub; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import lombok.extern.slf4j.Slf4j; -import org.nutz.dao.Chain; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; import org.nutz.dao.Sqls; import org.nutz.dao.sql.Sql; +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.lang.util.NutMap; import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Param; -import org.nutz.trans.Trans; import javax.validation.constraints.NotNull; import java.util.ArrayList; @@ -66,6 +64,37 @@ public class TrainSignUpManageController { public void index() { } + /** activityId 为主活动ID,courseName 为可选名称;返回 Result.data 分页对象 list、totalCount。 */ + @At + @SaCheckPermission("trainSignUp.manage") + public Result courseManagePageData(PageForm pageForm, @Param("activityId") String activityId, + @Param("courseName") String courseName) { + return Result.success(trainSignUpActivityManageService.courseManagePageData(pageForm, activityId, courseName)); + } + + /** activityId、courseId 为所属主活动和子活动ID;返回 Result.data 的 course、activityTime、canEditBasic、courseTypes。 */ + @At + @SaCheckPermission("trainSignUp.manage") + public Result courseManageDetail(@Param("activityId") String activityId, @Param("courseId") String courseId) { + return Result.success(trainSignUpActivityManageService.courseManageDetail(activityId, courseId)); + } + + /** + * course 接收单个子活动及签到字段,负责人、时段列表按 JSON 传入;updateCourseTimes 仅实际修改时段时为 true。 + * id、activityId 为子活动和主活动ID;isMobileSign 为签到开关,signType 为1扫码、2被扫、3定位。 + * signUnit 为非负整数分钟,radius 为正整数米;courseLocationCoordinates、transPosition 分别为地图、WGS-84坐标数组[纬度,经度]。 + * 管理员可另传名称、类型、人数、预留名额、地点、校区、分类标识;responsibleList 每项包含 userId、mobile。 + * service 按登录角色决定允许保存的字段,返回 Result,code=0 表示保存成功,msg 为操作结果。 + */ + @At + @SaCheckPermission("trainSignUp.manage") + @SLog(tag = "品牌活动-子活动管理", msg = "保存子活动配置") + @Aop(TransAop.READ_COMMITTED) + public Result saveManagedCourse(TrainSignUpCourse course, @Param("updateCourseTimes") boolean updateCourseTimes) { + trainSignUpActivityManageService.saveManagedCourse(course, updateCourseTimes); + return Result.success(); + } + @At @ApiOperation("分页查询") @SaCheckPermission("trainSignUp.manage") @@ -110,31 +139,22 @@ public class TrainSignUpManageController { @ApiOperation("活动删除") @SaCheckPermission("trainSignUp.manage") @SLog(tag = "品牌活动-活动管理", msg = "删除活动") + @Aop(TransAop.READ_COMMITTED) public Result onDelete(String id) { - if (dao.fetch(Sys_home_template.class, id) != null) { - return Result.error("该活动已设为模板,请先取消模板后再删除"); + // id 为主活动ID;service 在同一事务中清理子活动负责人及报名关联。 + if (StrUtil.isBlank(id)) { + return Result.error("活动ID不能为空"); } - Trans.exec(() -> { - trainSignUpActivityManageService.delete(id); - dao.clear(TrainSignUpCourse.class, Cnd.where("activityId", "=", id)); - dao.clear(TrainSignUpActivityCourse.class, Cnd.where("activityId", "=", id)); - dao.clear(TrainSignUpUser.class, Cnd.where("activityId", "=", id)); - dao.clear(TrainSignUpUserCourse.class, Cnd.where("activityId", "=", id)); - dao.clear(TrainSignUpActivity.class, Cnd.where("id", "=", id)); - dao.clear(TrainSignUpTypeLimit.class, Cnd.where("activityId", "=", id)); - dao.delete(Sys_home_activity.class, id); - }); + trainSignUpActivityManageService.deleteActivity(id); return Result.success(); } @At @ApiOperation("活动状态变更") @SaCheckPermission("trainSignUp.manage") + @Aop(TransAop.READ_COMMITTED) public Result activityStatusChange(TrainSignUpActivity activity) { trainSignUpActivityManageService.updateActivityStatus(activity); - dao.update(Sys_home_activity.class, - Chain.make("enable", !activity.isDisabled()), - Cnd.where("id", "=", activity.getId())); return Result.success(); } @@ -185,11 +205,17 @@ public class TrainSignUpManageController { return Result.success().addData(dataMap); } + /** + * 接收主活动及 courseList 子活动列表;每个 responsibleList 元素传 userId、mobile。 + * responsibleList 为 null 保留历史联系人,空列表清空,多人关系由 service 校验并保存。 + * 返回 Result,code=0 表示主活动、子活动和负责人已在同一事务中保存成功。 + */ @At @Ok("json:full") @ApiOperation("品牌活动新增/修改") @SaCheckPermission(value = {"trainSignUp.manage", "trainSignUp.applyActivity"}, mode = SaMode.OR) @SLog(tag = "品牌活动-活动管理", msg = "新增/修改活动") + @Aop(TransAop.READ_COMMITTED) public Result doHandle(TrainSignUpActivity activity) { if (StrUtil.isBlank(activity.getId())) { cleanNewActivityData(activity); diff --git a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/statistics/TrainSignUpActivityStatisticsController.java b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/statistics/TrainSignUpActivityStatisticsController.java index 334b8868..97464efb 100644 --- a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/statistics/TrainSignUpActivityStatisticsController.java +++ b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/controller/statistics/TrainSignUpActivityStatisticsController.java @@ -1,55 +1,26 @@ package com.budwk.app.zhgh.activity.trainSignUp.controller.statistics; -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.afterturn.easypoi.excel.export.ExcelExportService; import cn.dev33.satoken.annotation.SaCheckPermission; -import cn.hutool.core.util.StrUtil; -import cn.hutool.json.JSONObject; -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.sys.models.Sys_file; -import com.budwk.app.sys.utils.SysFileMinIoUtil; -import com.budwk.app.web.commons.auth.utils.AuthUtil; -import com.budwk.app.web.commons.auth.utils.SecurityUtil; -import com.budwk.app.zhgh.activity.trainSignUp.models.*; -import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityService; import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityStatisticsService; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import lombok.extern.slf4j.Slf4j; -import org.apache.poi.hssf.usermodel.HSSFWorkbook; import org.apache.poi.ss.usermodel.Workbook; -import org.apache.poi.xssf.usermodel.XSSFWorkbook; -import org.nutz.dao.Chain; -import org.nutz.dao.Cnd; -import org.nutz.dao.Dao; -import org.nutz.dao.Sqls; -import org.nutz.dao.sql.Sql; +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.Lang; 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.io.BufferedOutputStream; -import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.util.*; -import java.util.stream.Collectors; -import java.util.zip.ZipEntry; -import java.util.zip.ZipOutputStream; - /** * @author zxy @@ -63,12 +34,8 @@ import java.util.zip.ZipOutputStream; @At("/platform/trainSignUp/statistics") public class TrainSignUpActivityStatisticsController { - @Inject - private TrainSignUpActivityService trainSignUpActivityManageService; @Inject private TrainSignUpActivityStatisticsService trainSignUpActivityStatisticsService; - @Inject - private Dao dao; @At("") @SaCheckPermission("trainSignUp.statistics") @@ -76,231 +43,113 @@ public class TrainSignUpActivityStatisticsController { public void index() { } + /** + * @param pageForm 分页参数,pageNumber 为页码,pageSize 为每页条数 + * @param activityId 主活动ID,为空时返回空分页 + * @return Result.data 为 Pagination,list 和 totalCount 均受当前登录人的负责人范围限制 + */ @At @ApiOperation("分页查询") @SaCheckPermission("trainSignUp.statistics") - public Result pageData(PageForm pageForm, - @Param(value = "activityId") String activityId) { + public Result pageData(PageForm pageForm, @Param("activityId") String activityId) { Pagination pagination = trainSignUpActivityStatisticsService.pageData(pageForm, activityId); return Result.success(pagination); } /** - * 活动list - * - * @param year 年度 - * @return + * @param year 查询年度,为空时不限制年度 + * @return Result.data 为可管理的主活动列表,id/activityName 用于活动下拉框 */ @At @ApiOperation("活动列表") @SaCheckPermission("trainSignUp.statistics") - public Result activityList(@Param(value = "year") Integer year) { - List list = dao.query(TrainSignUpActivity.class, Cnd.NEW().andEX("year", "=", year).desc("activityStartTime")); - if(!AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_CHAIRMAN.name(), RoleConstant.SCHOOL_UNION_ACTIVITY_ADMIN.name())) { - // 获取当前登录用户的工会 - String unionId = SecurityUtil.getUnionId(); - // 获取当前登录用户的社团 - List clubIds = SecurityUtil.getClubIds(); - - List result = new ArrayList<>(); - for (TrainSignUpActivity activity : list) { - if(AuthUtil.hasRole(RoleConstant.BRANCH_UNION_CHAIRMAN.name())) { - if(activity.getHostUnits().contains(unionId) || activity.getHelpUnits().contains(unionId)) { - result.add(activity); - } - } - if(AuthUtil.hasRoleOr(RoleConstant.CLUB_PRESIDENT.name(), RoleConstant.CLUB_OPERATOR.name())) { - if(clubIds.stream().anyMatch(activity.getHostUnits()::contains) || clubIds.stream().anyMatch(activity.getHelpUnits()::contains)) { - result.add(activity); - } - } - } - return Result.success(result); - } - return Result.success(list); + public Result activityList(@Param("year") Integer year) { + return Result.success(trainSignUpActivityStatisticsService.activityList(year, false)); } /** - * 培训班报名人员list - * - * @param courseId - * @return + * @param courseId 子活动ID,必须在当前登录人的管理范围内 + * @return Result.data 为报名人员数组,包含姓名、工号、单位、联系方式、报名状态及动态报名字段 */ @At @ApiOperation("报名人员列表") @SaCheckPermission("trainSignUp.statistics") - public Result registerUserList(@Param(value = "courseId") String courseId) { + public Result registerUserList(@Param("courseId") String courseId) { return Result.success(trainSignUpActivityStatisticsService.registerUserList(courseId)); } + /** + * @param courseId 子活动ID,必须在当前登录人的管理范围内 + * @return Result.data 为动态列配置数组,label 为列标题,prop 为报名数据字段名 + */ @At @ApiOperation("报名动态列") @SaCheckPermission("trainSignUp.statistics") - public Object getTaleColumnInfo(@Param(value = "courseId") String courseId) { + public Object getTaleColumnInfo(@Param("courseId") String courseId) { return Result.success(trainSignUpActivityStatisticsService.getTaleColumnInfo(courseId)); } - @At - @ApiOperation("获取时间信息") - @SaCheckPermission("trainSignUp.statistics") - public Result selectTimes(@Param("courseId") String courseId) { - List activityCourses = dao.query(TrainSignUpActivityCourse.class, Cnd.where("courseId", "=", courseId).asc("courseStartTime")); - return Result.success(activityCourses); - } + /** + * @param courseId 子活动ID,必须在当前登录人的管理范围内 + * @return Result.data 为时段数组,id 为时段ID,courseStartTime/courseEndTime 为起止时间 + */ + @At + @ApiOperation("获取时间信息") + @SaCheckPermission("trainSignUp.statistics") + public Result selectTimes(@Param("courseId") String courseId) { + return Result.success(trainSignUpActivityStatisticsService.selectTimes(courseId)); + } /** - * 培训班上课签到信息 - * - * @param courseId - * @return + * @param pageForm 分页参数,pageNumber 为页码,pageSize 为每页条数 + * @param courseId 有管理权限的子活动ID + * @param timeId 子活动时段ID + * @return Result.data 为签到分页,list 中 isAttend/attendTime 为签到状态及时间 */ @At @ApiOperation("获取签到信息") @SaCheckPermission("trainSignUp.statistics") - public Result getSignInfo(PageForm pageForm, - @Param("courseId") String courseId, - String timeId) { + public Result getSignInfo(PageForm pageForm, @Param("courseId") String courseId, String timeId) { return Result.success(trainSignUpActivityStatisticsService.getSignInfo(pageForm, courseId, timeId)); } + /** + * @param courseId 有管理权限的子活动ID + * @param openOtherUnion true 开放给其他工会报名,false 关闭 + * @return Result,code=0 表示状态已更新 + */ @At @ApiOperation("开放报名") @SaCheckPermission("trainSignUp.statistics") + @Aop(TransAop.READ_COMMITTED) public Result signChange(@Param("courseId") String courseId, @Param("openOtherUnion") Boolean openOtherUnion) { - dao.update(TrainSignUpCourse.class, Chain.make("openOtherUnion", openOtherUnion) - , Cnd.where("id", "=", courseId)); + trainSignUpActivityStatisticsService.signChange(courseId, openOtherUnion); return Result.success(); } - @At - @ApiOperation("设置签到") - @SaCheckPermission("trainSignUp.statistics") - public Result adjustSign(@Param("id") String id) { - TrainSignUpUserCourse userCourse = dao.fetch(TrainSignUpUserCourse.class, id); - if(userCourse.isAttend()) { - userCourse.setAttend(false); - userCourse.setAttendTime(null); - } else { - userCourse.setAttend(true); - userCourse.setAttendTime(userCourse.getCourseStartTime()); - } - dao.update(userCourse); - return Result.success(); - } + /** + * @param id 签到记录ID,service 根据记录所属子活动校验当前登录人的权限 + * @return Result,code=0 表示签到状态已切换 + */ + @At + @ApiOperation("设置签到") + @SaCheckPermission("trainSignUp.statistics") + @Aop(TransAop.READ_COMMITTED) + public Result adjustSign(@Param("id") String id) { + trainSignUpActivityStatisticsService.adjustSign(id); + return Result.success(); + } + /** + * @param activityId 主活动ID,仅导出当前登录人有管理权限的子活动报名记录 + * @param response 文件下载响应,输出 .xlsx 文件而非 JSON + */ @At @Ok("void") @ApiOperation("导出签到名单") @SaCheckPermission("trainSignUp.statistics") - public void exportSignUser(@Param(value = "activityId") String activityId, - HttpServletResponse response) throws IOException { - try { - TrainSignUpActivity activity = dao.fetch(TrainSignUpActivity.class, activityId); - Sql sql = Sqls.create(""" - SELECT - ts.*, - CONCAT(DATE_FORMAT(ac.courseStartTime, '%H:%i:%s'),'至',DATE_FORMAT(ac.courseEndTime, '%H:%i:%s')) AS courseTime, - u.username, - u.loginname, - u.sex, - ifnull(ts.mobile, u.mobile) as mobile, - u.birthday, - tsc.courseName - FROM - train_sign_up_user ts - left join train_sign_up_activity_course ac on ts.activityCourseId = ac.id - left join `vw_user` u on u.id = ts. userId - left join train_sign_up_course tsc on tsc.id = ts.courseId - WHERE - ts.activityId = @activityId - """).setParam("activityId", activityId); - List userList = trainSignUpActivityManageService.listMap(sql); - - List courseList = dao.query(TrainSignUpCourse.class, Cnd.where("activityId", "=", activityId)); - - List trainSignUpTypeList = dao.query(TrainSignUpType.class, Cnd.NEW()); - dao.fetchLinks(trainSignUpTypeList, "trainMobileSignColumnList", Cnd.NEW().asc("columnIndex")); - Map typeMap = trainSignUpTypeList.stream().collect(Collectors.toMap(TrainSignUpType::getId, o -> o)); - - List> sheetsList = new ArrayList<>(); - - List excelCommonExportEntity = new ArrayList<>(); - excelCommonExportEntity.add(new ExcelExportEntity("姓名", "username", 20)); - excelCommonExportEntity.add(new ExcelExportEntity("工号", "loginname", 20)); - excelCommonExportEntity.add(new ExcelExportEntity("单位", "unitName", 20)); - excelCommonExportEntity.add(new ExcelExportEntity("分工会", "unionName", 20)); - excelCommonExportEntity.add(new ExcelExportEntity("性别", "sex", 20)); - excelCommonExportEntity.add(new ExcelExportEntity("手机号", "mobile", 20)); - excelCommonExportEntity.add(new ExcelExportEntity("生日", "birthday", 20)); - excelCommonExportEntity.add(new ExcelExportEntity("报名时段", "courseTime", 20)); - - for (TrainSignUpCourse c : courseList) { - String k = c.getCourseName(); - List v = userList.stream().filter(x -> StrUtil.isNotBlank(x.getString("courseName")) && x.getString("courseName").equals(k)).collect(Collectors.toList()); - ExportParams userExportParams = new ExportParams(); - userExportParams.setSheetName(k); - userExportParams.setType(ExcelType.HSSF); - - List currentEntities = new ArrayList<>(); - currentEntities.addAll(excelCommonExportEntity); - - TrainSignUpType signUpType = typeMap.get(c.getCourseType()); - if (Lang.isNotEmpty(signUpType.getTrainMobileSignColumnList())) { - for (TrainMobileSignColumn column : signUpType.getTrainMobileSignColumnList()) { - ExcelExportEntity entity = new ExcelExportEntity(); - entity.setName(column.getColumnName()); - entity.setKey(column.getColumnCode()); - entity.setWidth(20); - if ("FILE".equals(column.getColumnFormType())) { - entity.setType(2); - entity.setExportImageType(2); - } - currentEntities.add(entity); - } - } - for (NutMap userSignData : v) { - String mobileColumnsValueStr = userSignData.getString("mobileColumnsValue"); - if (StrUtil.isNotBlank(mobileColumnsValueStr)) { - List mobileColumnsValue = Json.fromJsonAsList(NutMap.class, mobileColumnsValueStr); - for (NutMap cv : mobileColumnsValue) { - if (!"FILE".equals(cv.getString("columnFormType"))) { - userSignData.put(cv.getString("columnCode"), cv.getString("columnValue")); - } else { - if (StrUtil.isNotBlank(cv.getString("columnValue"))) { - List columnValue = cv.getAsList("columnValue", NutMap.class); - if (columnValue.size() == 1) { - NutMap sysFile = columnValue.get(0); - Sys_file file = dao.fetch(Sys_file.class, Cnd.where(Sys_file::getDownloadPath, "=", sysFile.get("url"))); - byte[] imageBytes = SysFileMinIoUtil.getFileBytes(file.getBucket(), file.getStoragePath()); - if (imageBytes.length > 0) { - userSignData.put(cv.getString("columnCode"), imageBytes); - } - } - } - } - } - - } - } - - Map userExportMap = new HashMap<>(); - userExportMap.put("name", k); - userExportParams.setType(ExcelType.HSSF); - userExportMap.put("title", userExportParams); - userExportMap.put("entity", currentEntities); - userExportMap.put("data", v); - - sheetsList.add(userExportMap); - } - - Workbook workbook = new HSSFWorkbook(); - for (Map map : sheetsList) { - ExcelExportService service = new ExcelExportService(); - service.createSheetForMap(workbook, (ExportParams) map.get("title"), (List) map.get("entity"), (Collection) map.get("data")); - } - CommonDownloadUtil.download(activity.getActivityName() + "报名人员名单" + ".xls", workbook, response); - } catch (Exception e) { - e.printStackTrace(); - } + public void exportSignUser(@Param("activityId") String activityId, HttpServletResponse response) throws IOException { + NutMap exportData = trainSignUpActivityStatisticsService.buildSignUserExport(activityId); + CommonDownloadUtil.download(exportData.getString("fileName"), exportData.getAs("workbook", Workbook.class), response); } } diff --git a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/models/TrainSignUpCourse.java b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/models/TrainSignUpCourse.java index b4258c32..697061af 100644 --- a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/models/TrainSignUpCourse.java +++ b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/models/TrainSignUpCourse.java @@ -96,6 +96,13 @@ public class TrainSignUpCourse extends BaseModel implements Serializable { @Many(field = "courseId") private List courseTimeList; + /** + * 完整负责人关系。null 表示旧数据尚未设置,空列表表示主动清空负责人。 + * 关系单独保存,后续可按 userId 精确查询,无需解析姓名或 JSON。 + */ + @Many(field = "courseId") + private List responsibleList; + //已报人数 private Integer hasRegisterNum; diff --git a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/models/TrainSignUpCourseResponsible.java b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/models/TrainSignUpCourseResponsible.java new file mode 100644 index 00000000..660c7126 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/models/TrainSignUpCourseResponsible.java @@ -0,0 +1,60 @@ +package com.budwk.app.zhgh.activity.trainSignUp.models; + +import lombok.Data; +import lombok.experimental.Accessors; +import org.nutz.dao.entity.annotation.*; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +import java.io.Serializable; + +/** + * 子活动负责人关系:一人一条记录,业务控制应以 userId 为准,姓名仅用于展示。 + */ +@Data +@Accessors(chain = true) +@Table("train_sign_up_course_responsible") +@Comment("品牌活动子活动负责人") +@TableIndexes({ + @Index(name = "TSU_RESP_COURSE_USER", fields = {"courseId", "userId"}, unique = true), + @Index(name = "TSU_RESP_USER_ACTIVITY", fields = {"userId", "activityId"}, unique = false), + @Index(name = "TSU_RESP_ACTIVITY", fields = {"activityId"}, unique = false) +}) +public class TrainSignUpCourseResponsible implements Serializable { + + @Name + @PrevInsert(uu32 = true) + private String id; + + @Column + @ColDefine(type = ColType.VARCHAR, width = 32) + @Comment("主活动ID,由后端根据所属子活动设置") + private String activityId; + + @Column + @ColDefine(type = ColType.VARCHAR, width = 32) + @Comment("子活动ID,由后端在子活动保存后设置") + private String courseId; + + @Column + @ColDefine(type = ColType.VARCHAR, width = 32) + @Comment("系统用户ID,用于负责人查询和业务控制") + private String userId; + + @Column + @ColDefine(type = ColType.VARCHAR, width = 100) + @Comment("负责人姓名,由后端读取系统用户信息") + private String username; + + @Column + @ColDefine(type = ColType.VARCHAR, width = 20) + @Comment("子活动联系号码,可填写手机号或办公电话") + private String mobile; + + @Column + @ColDefine(type = ColType.INT) + @Comment("负责人顺序,第一位兼容原有联系人展示") + private int orderNum; + + /** 工号仅用于多选回显,不重复存储系统用户资料。 */ + private String loginname; +} diff --git a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/TrainSignUpActivityService.java b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/TrainSignUpActivityService.java index 6a0bdbb6..5aa74468 100644 --- a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/TrainSignUpActivityService.java +++ b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/TrainSignUpActivityService.java @@ -20,6 +20,35 @@ import java.util.List; */ public interface TrainSignUpActivityService extends BaseService { + /** 当前登录人是否具有活动关键内容编辑权限(三类管理员任意一种)。 */ + boolean canEditActivityBasic(); + + /** + * 查询当前主活动中可管理的子活动,普通用户仅返回本人负责的记录。 + * @param pageForm 页码、每页条数;排序固定使用子活动序号 + * @param activityId 主活动ID,必填 + * @param courseName 子活动名称,可为空,按包含匹配 + * @return 分页对象,list 包含子活动字段、courseTypeName、responsibleNames、courseTimeName + */ + Pagination courseManagePageData(PageForm pageForm, String activityId, String courseName); + + /** + * 读取单个子活动及表单所需选项,同时校验当前登录人的负责人范围。 + * @param activityId 主活动ID,必须与子活动实际归属一致 + * @param courseId 子活动ID,必填 + * @return course 为子活动及负责人、时段;activityTime 为主活动日期范围; + * canEditBasic 为关键内容编辑权限,courseTypes 为管理员使用的类型选项 + */ + NutMap courseManageDetail(String activityId, String courseId); + + /** + * 保存单个子活动,普通负责人仅更新签到字段,管理员可更新基本信息及负责人。 + * @param course id、activityId 必填;签到参数为 isMobileSign、signType、signUnit、radius、 + * courseLocationCoordinates、transPosition,坐标均按纬度、经度顺序传入 + * @param updateCourseTimes 仅管理员实际修改时段后传 true;false 不改动任何时段及签到记录 + */ + void saveManagedCourse(TrainSignUpCourse course, boolean updateCourseTimes); + /** * 添加活动 * @@ -35,6 +64,13 @@ public interface TrainSignUpActivityService extends BaseService { /** - * 统计分页 + * 查询当前用户可管理的主活动,只包含至少一个本人负责子活动的主活动。 + * 系统管理员、校工会管理员、校工会活动管理员不受负责人范围限制,仍保留年度及启用状态条件。 * - * @param pageForm - * @param activityId - * @return + * @param year 年度,为空时不限制年度 + * @param enabledOnly true 仅查询已启用活动,人员调整页面使用;false 保留统计页面原范围 + * @return 主活动实体列表;没有负责记录时返回空列表 + */ + List activityList(Integer year, boolean enabledOnly); + + /** + * 查询人员调整的目标子活动,限制为当前用户负责的子活动。 + * + * @param activityId 主活动ID,为空时返回空列表 + * @return 子活动列表,id/courseName 为标识及名称,registerNum/hasWaitingNum 为已报及候补人数 + */ + List getCourse(String activityId); + + /** + * 查询有管理权限的子活动时段。 + * + * @param courseId 子活动ID,必须存在且在当前用户的管理范围内 + * @return 时段实体列表,id 为时段ID,courseStartTime/courseEndTime 为起止时间 + */ + List selectTimes(String courseId); + + /** + * 修改子活动对外报名状态,只允许操作本人负责的子活动或由全量管理员操作。 + * + * @param courseId 子活动ID + * @param openOtherUnion true 开放给其他工会报名,false 关闭,不能传 null + */ + void signChange(String courseId, Boolean openOtherUnion); + + /** + * 切换签到状态,依据签到记录实际所属子活动校验负责人权限。 + * + * @param id TrainSignUpUserCourse 签到记录ID + */ + void adjustSign(String id); + + /** + * 迁移报名记录及签到时段,来源、目标子活动均需属于指定主活动且在当前用户管理范围内。 + * + * @param activityId 主活动ID + * @param oldCourseId 原子活动ID + * @param newCourseId 目标子活动ID + * @param userId 被调整的报名人员用户ID,不能用来指定操作人的身份 + */ + void adjust(String activityId, String oldCourseId, String newCourseId, String userId); + + /** + * 删除指定子活动的报名及签到记录,先验证实际子活动归属和当前登录人的负责人权限。 + * + * @param activityId 主活动ID + * @param courseId 子活动ID + * @param userId 被删除的报名人员用户ID + */ + void deleteSignUser(String activityId, String courseId, String userId); + + /** + * 构建当前用户管理范围内的报名人员 Excel,不能导出同一主活动下其他人的子活动。 + * + * @param activityId 主活动ID + * @return 内部 NutMap,fileName 为 .xlsx 文件名,workbook 为 XSSF Workbook,由 controller 输出并关闭 + */ + NutMap buildSignUserExport(String activityId); + + /** + * 按当前登录人的负责人范围查询子活动分页,系统管理员、校工会管理员、校工会活动管理员可查询全部。 + * + * @param pageForm 分页参数,pageNumber 为页码,pageSize 为每页条数 + * @param activityId 主活动ID,为空时返回空分页 + * @return Pagination,list 为子活动数据,totalCount 为相同负责人范围下的记录总数 */ Pagination pageData(PageForm pageForm, String activityId); /** - * 该课程下的报名人员信息 + * 查询有管理权限的子活动报名明细,并展开动态报名字段。 * - * @param courseId - * @return + * @param courseId 子活动ID,不能为空,必须在当前登录人的管理范围内 + * @return 报名人员数组,包含姓名、工号、单位、联系方式、state 报名状态及动态字段 */ List registerUserList(String courseId); + /** + * @param courseId 有管理权限的子活动ID + * @return 动态列配置数组,label 为列标题,prop 为报名明细对应字段名 + */ List getTaleColumnInfo(String courseId); /** - * 该课程下的报名人员信息 + * 按工会、单位和姓名/工号搜索有管理权限的子活动报名人员。 * - * @param courseId - * @return + * @param courseId 有管理权限的子活动ID,不能为空 + * @param unionId 工会ID,可为空 + * @param unitId 单位ID,可为空 + * @param searchName 保留现有接口参数,姓名和工号统一使用 searchKeyword 搜索 + * @param searchKeyword 姓名或工号关键字,可为空 + * @return 报名人员数组,userId 为报名人员用户ID,state 为报名状态 */ List registerUserList(String courseId, String unionId, String unitId, String searchName, String searchKeyword); /** - * 获取每个课程的签到情况 + * 查询有管理权限的子活动指定时段签到分页。 * - * @param courseId - * @return k->每个培训班每节课的上课时间 v->上课记录list + * @param pageForm 分页参数,pageNumber 为页码,pageSize 为每页条数 + * @param courseId 有管理权限的子活动ID + * @param timeId 子活动时段ID + * @return Pagination,list 为签到人员,isAttend/attendTime 为签到状态及时间 */ Pagination getSignInfo(PageForm pageForm, String courseId, String timeId); /** - * 报名人员list 导出 - * @param activityId - * @return + * 查询主活动下可管理子活动的基础报名导出数据。 + * @param activityId 主活动ID + * @return 报名人员数组,包含姓名、工号、联系方式及子活动名称,不包含管理范围以外的子活动 */ List baoMingUserList(String activityId); diff --git a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/impl/TrainSignUpActivityServiceImpl.java b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/impl/TrainSignUpActivityServiceImpl.java index e3c1955e..302a4da2 100644 --- a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/impl/TrainSignUpActivityServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/impl/TrainSignUpActivityServiceImpl.java @@ -1,13 +1,18 @@ package com.budwk.app.zhgh.activity.trainSignUp.service.impl; import cn.hutool.core.util.StrUtil; +import cn.hutool.core.date.DateUtil; +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.param.PageForm; import com.budwk.app.base.service.impl.BaseServiceImpl; import com.budwk.app.sys.models.Sys_home_activity; import com.budwk.app.sys.models.Sys_home_template; +import com.budwk.app.sys.models.Sys_user; import com.budwk.app.sys.views.View_user; import com.budwk.app.web.commons.auth.utils.SecurityUtil; +import com.budwk.app.web.commons.auth.utils.AuthUtil; import com.budwk.app.zhgh.activity.family.models.FamilyActivity; import com.budwk.app.zhgh.activity.family.models.FamilyCourse; import com.budwk.app.zhgh.activity.family.models.FamilyType; @@ -51,9 +56,227 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl relations = StrUtil.isBlank(userId) ? Collections.emptyList() + : dao().query(TrainSignUpCourseResponsible.class, + Cnd.where("activityId", "=", activityId).and("userId", "=", userId)); + if (relations.isEmpty()) { + return new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), 0, new ArrayList<>()); + } + // 范围条件在分页前进入数据库查询,记录数和分页总数使用同一负责人范围。 + cnd.and("course.id", "in", relations.stream().map(TrainSignUpCourseResponsible::getCourseId).toList()); + } + cnd.asc("course.orderNum").asc("course.id"); + Sql sql = Sqls.create(""" + SELECT course.*, type.typeName AS courseTypeName, + (SELECT GROUP_CONCAT(r.username ORDER BY r.orderNum SEPARATOR '、') + FROM train_sign_up_course_responsible r + WHERE r.courseId=course.id AND r.activityId=course.activityId) AS responsibleNames, + (SELECT GROUP_CONCAT(CONCAT(DATE_FORMAT(t.courseStartTime, '%Y-%m-%d %H:%i'), + ' 至 ', DATE_FORMAT(t.courseEndTime, '%H:%i')) ORDER BY t.courseStartTime SEPARATOR ';') + FROM train_sign_up_activity_course t WHERE t.courseId=course.id) AS courseTimeName + FROM train_sign_up_course course + LEFT JOIN train_sign_up_type type ON type.id=course.courseType + $condition + """); + sql.setCondition(cnd); + return listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql); + } + + @Override + public NutMap courseManageDetail(String activityId, String courseId) { + TrainSignUpCourse course = requireManagedCourse(activityId, courseId); + fillCourseResponsibles(List.of(course)); + dao().fetchLinks(course, "courseTimeList", Cnd.NEW().asc("courseStartTime")); + TrainSignUpActivity activity = fetch(activityId); + NutMap data = Lang.obj2nutmap(course); + // 时间编辑组件使用日期和 HH:mm 两部分;签到记录仍由 service 按完整日期时间保存。 + List times = course.getCourseTimeList().stream().map(time -> Lang.obj2nutmap(time) + .setv("courseDate", DateUtil.formatDate(time.getCourseDate())) + .setv("courseStartTime", DateUtil.format(time.getCourseStartTime(), "HH:mm")) + .setv("courseEndTime", DateUtil.format(time.getCourseEndTime(), "HH:mm"))).toList(); + data.setv("courseTimeList", times).setv("setUpCourseData", + times.stream().map(time -> time.getString("courseDate")).distinct().toList()); + TrainSignUpType type = StrUtil.isBlank(course.getCourseType()) ? null + : dao().fetch(TrainSignUpType.class, course.getCourseType()); + data.setv("courseTypeName", type == null ? "" : type.getTypeName()); + List activityTime = activity.getActivityStartTime() == null || activity.getActivityEndTime() == null + ? Collections.emptyList() : List.of(DateUtil.formatDateTime(activity.getActivityStartTime()), + DateUtil.formatDateTime(activity.getActivityEndTime())); + return NutMap.NEW().setv("course", data).setv("activityTime", activityTime) + .setv("canEditBasic", canEditActivityBasic()) + .setv("courseTypes", canEditActivityBasic() ? dao().query(TrainSignUpType.class, Cnd.NEW()) : Collections.emptyList()); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public void saveManagedCourse(TrainSignUpCourse course, boolean updateCourseTimes) { + if (course == null) { + throw new BaseException("子活动信息不能为空"); + } + TrainSignUpCourse stored = requireManagedCourse(course.getActivityId(), course.getId()); + boolean usesSignLocation = validateManagedSign(course); + String fields = "isMobileSign"; + if (course.isMobileSign()) { + fields += "|signType"; + } + // 被扫或关闭签到时不更新隐藏配置,即使请求携带这些字段也保留数据库原值。 + if (usesSignLocation) { + fields += "|signUnit|radius|courseLocationCoordinates|transPosition"; + } + if (canEditActivityBasic()) { + validateManagedBasic(course, stored); + validateCourseResponsibles(List.of(course)); + fields += "|courseName|courseType|coursePeopleNumber|courseReservedNumber|courseLocation|campus|assort"; + if (course.getResponsibleList() != null) { + fields += "|courseInstructor|courseInstructorMobile"; + } + if (updateCourseTimes) { + validateManagedTimes(course, fetch(stored.getActivityId())); + fields += "|courseIsLimitApply"; + } + } else if (updateCourseTimes) { + throw new BaseException("活动时间仅管理员可修改"); + } + // 明确列出允许更新的列,普通负责人提交其他字段也无法覆盖关键内容或其他子活动。 + if (dao().update(course, "^(" + fields + ")$") != 1) { + throw new BaseException("子活动已变化,请刷新后重试"); + } + if (canEditActivityBasic()) { + saveCourseResponsibles(course); + if (updateCourseTimes) { + handleCourseTimes(fetch(stored.getActivityId()), course); + } + } + } + + /** + * 返回 true 表示扫码或 GPS 签到,需校验并更新时间、位置配置,默认误差0分钟、半径100米。 + * 被扫使用固定签到时间规则,关闭签到也不使用这些字段,返回 false 跳过校验且不更新原配置。 + */ + private boolean validateManagedSign(TrainSignUpCourse course) { + if (!course.isMobileSign()) { + return false; + } + if (!List.of(1, 2, 3).contains(course.getSignType() == null ? 0 : course.getSignType())) { + throw new BaseException("请选择有效的签到方式"); + } + if (Objects.equals(course.getSignType(), 2)) { + return false; + } + if (course.getSignUnit() == null) course.setSignUnit(0); + if (course.getRadius() == null) course.setRadius(100); + if (course.getSignUnit() < 0 || course.getRadius() <= 0) { + throw new BaseException("签到时间误差不能为负数,签到半径必须大于0"); + } + validateManagedPosition(course.getCourseLocationCoordinates()); + validateManagedPosition(course.getTransPosition()); + return true; + } + + /** 地图坐标和签到坐标均为 [纬度, 经度],拒绝不完整、非有限值及超出地理范围的坐标。 */ + private void validateManagedPosition(List point) { + if (point == null || point.size() != 2 || point.stream().anyMatch(value -> value == null || !Double.isFinite(value)) + || Math.abs(point.get(0)) > 90 || Math.abs(point.get(1)) > 180) { + throw new BaseException("请重新设置有效的签到点"); + } + } + + /** 管理员基本信息沿用原字段范围;人数为0表示不限制,已报名后禁止切换报名类型。 */ + private void validateManagedBasic(TrainSignUpCourse course, TrainSignUpCourse stored) { + if (StrUtil.isBlank(course.getCourseName()) || course.getCourseName().length() > 50 + || StrUtil.isBlank(course.getCourseLocation()) || course.getCourseLocation().length() > 50 + || StrUtil.isBlank(course.getCampus()) || course.getCampus().length() > 32 + || (course.getAssort() != null && course.getAssort().length() > 30)) { + throw new BaseException("请填写有效的名称、校区、地点和分类标识"); + } + if (StrUtil.isBlank(course.getCourseType()) || dao().fetch(TrainSignUpType.class, course.getCourseType()) == null) { + throw new BaseException("请选择有效的子活动类型"); + } + if (!Objects.equals(stored.getCourseType(), course.getCourseType()) + && dao().count(TrainSignUpUser.class, Cnd.where("courseId", "=", stored.getId())) > 0) { + throw new BaseException("已有报名记录,不能修改子活动类型"); + } + int registered = StrUtil.isBlank(stored.getCourseType()) ? 0 + : statisticsService.queryCourseCount(stored.getId(), stored.getCourseType()); + if (course.getCoursePeopleNumber() < 0 || course.getCoursePeopleNumber() > 9999 + || course.getCourseReservedNumber() < 0 || course.getCourseReservedNumber() > 1000 + || (course.getCoursePeopleNumber() > 0 && Objects.equals(stored.getReserveMode(), 1) + && course.getCourseReservedNumber() + registered > course.getCoursePeopleNumber())) { + throw new BaseException("人数或预留名额无效,不能低于已报名人数与预留名额之和"); + } + } + + /** 时段ID只能属于当前子活动;校验后再调用原有同步逻辑,保证变更及签到记录在同一事务中提交。 */ + private void validateManagedTimes(TrainSignUpCourse course, TrainSignUpActivity activity) { + if (Lang.isEmpty(course.getCourseTimeList()) || activity.getActivityStartTime() == null || activity.getActivityEndTime() == null) { + throw new BaseException("请先设置主活动日期及至少一个有效时段"); + } + Set ids = new HashSet<>(); + Set existingIds = dao().query(TrainSignUpActivityCourse.class, Cnd.where("courseId", "=", course.getId())) + .stream().map(TrainSignUpActivityCourse::getId).collect(Collectors.toSet()); + for (TrainSignUpActivityCourse time : course.getCourseTimeList()) { + if (time == null || time.getCourseDate() == null || time.getCourseStartTime() == null || time.getCourseEndTime() == null) { + throw new BaseException("请完整填写活动日期及起止时间"); + } + if (StrUtil.isNotBlank(time.getId()) && (!existingIds.contains(time.getId()) || !ids.add(time.getId()))) { + throw new BaseException("活动时段无效或重复,请刷新后重试"); + } + String date = DateUtil.formatDate(time.getCourseDate()); + Date start = DateUtil.parse(date + " " + DateUtil.format(time.getCourseStartTime(), "HH:mm")); + Date end = DateUtil.parse(date + " " + DateUtil.format(time.getCourseEndTime(), "HH:mm")); + if (!start.before(end) || start.before(activity.getActivityStartTime()) || end.after(activity.getActivityEndTime()) + || (Boolean.TRUE.equals(course.getCourseIsLimitApply()) && (time.getCourseLimitNum() == null || time.getCourseLimitNum() < 1))) { + throw new BaseException("活动时段必须在主活动范围内,结束晚于开始;限制人数时应填写正整数"); + } + } + } + @Override @Aop(TransAop.READ_COMMITTED) public void add(TrainSignUpActivity activity, TrainSignUpCourse course) { + // 先校验所有负责人,任何一行存在无效用户或联系方式时均不写入活动。 + validateCourseResponsibles(activity.getCourseList()); // 新建活动(包括从模板另存)统一归入活动列表,不能继承来源活动的模板状态。 activity.setTemplateStatus(TrainSignUpActivity.TEMPLATE_STATUS_UNSET); dao().insert(activity); @@ -68,6 +291,7 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl courseList = activity.getCourseList(); - if (Lang.isEmpty(courseList)) { + if (courseList == null) { return; } @@ -108,12 +348,15 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl deletedIds = Sets.difference(oldIds, newIds).immutableCopy(); if(Lang.isNotEmpty(deletedIds)) { + // 删除子活动时同步清理负责人,避免后续按 userId 查到已删除的业务。 + dao().clear(TrainSignUpCourseResponsible.class, Cnd.where("courseId", "in", deletedIds)); dao().clear(TrainSignUpCourse.class, Cnd.where("id", "in", deletedIds)); } for (TrainSignUpCourse course : courseList) { course.setActivityId(activity.getId()); dao().insertOrUpdate(course); + saveCourseResponsibles(course); if (Lang.isNotEmpty(course.getCourseTimeList())) { handleCourseTimes(activity, course); @@ -128,6 +371,131 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl courses) { + if (courses == null) { + return; + } + Set userIds = new HashSet<>(); + for (TrainSignUpCourse course : courses) { + if (course == null) { + throw new BaseException("子活动信息不能为空"); + } + if (course.getResponsibleList() == null) { + continue; + } + Set courseUserIds = new HashSet<>(); + for (TrainSignUpCourseResponsible responsible : course.getResponsibleList()) { + if (responsible == null || StrUtil.isBlank(responsible.getUserId())) { + throw new BaseException("请从下拉框选择负责人"); + } + String userId = responsible.getUserId().trim(); + if (userId.length() > 32 || !courseUserIds.add(userId)) { + throw new BaseException("负责人ID无效或同一子活动重复设置了负责人"); + } + String mobile = StrUtil.trim(responsible.getMobile()); + if (StrUtil.isBlank(mobile) || mobile.length() > 20) { + throw new BaseException("请填写负责人联系方式,长度不能超过20个字符"); + } + responsible.setUserId(userId).setMobile(mobile); + userIds.add(userId); + } + } + Map users = userIds.isEmpty() ? Collections.emptyMap() + : dao().query(Sys_user.class, Cnd.where("id", "in", userIds)).stream() + .collect(Collectors.toMap(Sys_user::getId, user -> user)); + for (TrainSignUpCourse course : courses) { + List responsibles = course.getResponsibleList(); + if (responsibles == null) { + continue; + } + for (int i = 0; i < responsibles.size(); i++) { + TrainSignUpCourseResponsible responsible = responsibles.get(i); + Sys_user user = users.get(responsible.getUserId()); + if (user == null || StrUtil.isBlank(user.getUsername())) { + throw new BaseException("所选负责人不存在,请重新选择"); + } + responsible.setUsername(user.getUsername()).setLoginname(user.getLoginname()).setOrderNum(i + 1); + } + // 原有报名和统计页面仍展示第一位联系人,完整姓名及多人信息保留在关系表。 + // 原姓名列宽为20,此处只限制兼容展示字段,避免长姓名导致数据库写入失败。 + course.setCourseInstructor(responsibles.isEmpty() ? "" : StrUtil.sub(responsibles.get(0).getUsername(), 0, 20)); + course.setCourseInstructorMobile(responsibles.isEmpty() ? "" : responsibles.get(0).getMobile()); + } + } + + /** + * 随子活动在同一事务中保存负责人,关系ID及归属由后端生成,模板复制不会复用旧关系。 + * null 表示未修改历史设置,不清理原有关系;空列表表示明确删除所有负责人。 + */ + @Aop(TransAop.READ_COMMITTED) + private void saveCourseResponsibles(TrainSignUpCourse course) { + if (course.getResponsibleList() == null) { + return; + } + dao().clear(TrainSignUpCourseResponsible.class, Cnd.where("courseId", "=", course.getId())); + for (TrainSignUpCourseResponsible responsible : course.getResponsibleList()) { + responsible.setId(null).setActivityId(course.getActivityId()).setCourseId(course.getId()); + } + if (!course.getResponsibleList().isEmpty()) { + dao().insert(course.getResponsibleList()); + } + } + + /** 批量回显负责人及工号,不按旧姓名猜测用户身份,也不对每个子活动重复查询用户。 */ + private void fillCourseResponsibles(List courses) { + if (courses.isEmpty()) { + return; + } + List courseIds = courses.stream().map(TrainSignUpCourse::getId).collect(Collectors.toList()); + List responsibles = dao().query(TrainSignUpCourseResponsible.class, + Cnd.where("courseId", "in", courseIds).asc("orderNum")); + Set userIds = responsibles.stream().map(TrainSignUpCourseResponsible::getUserId).collect(Collectors.toSet()); + Map users = userIds.isEmpty() ? Collections.emptyMap() + : dao().query(Sys_user.class, Cnd.where("id", "in", userIds)).stream() + .collect(Collectors.toMap(Sys_user::getId, user -> user)); + for (TrainSignUpCourseResponsible responsible : responsibles) { + Sys_user user = users.get(responsible.getUserId()); + if (user != null) { + responsible.setUsername(user.getUsername()).setLoginname(user.getLoginname()); + } + } + Map> byCourse = responsibles.stream() + .collect(Collectors.groupingBy(TrainSignUpCourseResponsible::getCourseId)); + for (TrainSignUpCourse course : courses) { + List list = byCourse.get(course.getId()); + // 旧活动保留 null,避免普通编辑保存时将未绑定用户的历史联系人清空。 + course.setResponsibleList(list != null ? list : + (StrUtil.isNotBlank(course.getCourseInstructor()) || StrUtil.isNotBlank(course.getCourseInstructorMobile()) + ? null : new ArrayList<>())); + } + } + + /** 删除主活动时在一个事务中清理负责人及原有业务关联,模板活动仍需先取消模板。 */ + @Override + @Aop(TransAop.READ_COMMITTED) + public void deleteActivity(String id) { + requireActivityBasicPermission(); + if (StrUtil.isBlank(id)) { + throw new BaseException("活动ID不能为空"); + } + if (dao().fetch(Sys_home_template.class, id) != null) { + throw new BaseException("该活动已设为模板,请先取消模板后再删除"); + } + dao().clear(TrainSignUpCourseResponsible.class, Cnd.where("activityId", "=", id)); + dao().clear(TrainSignUpCourse.class, Cnd.where("activityId", "=", id)); + dao().clear(TrainSignUpActivityCourse.class, Cnd.where("activityId", "=", id)); + dao().clear(TrainSignUpUser.class, Cnd.where("activityId", "=", id)); + dao().clear(TrainSignUpUserCourse.class, Cnd.where("activityId", "=", id)); + dao().clear(TrainSignUpActivity.class, Cnd.where("id", "=", id)); + dao().clear(TrainSignUpTypeLimit.class, Cnd.where("activityId", "=", id)); + dao().delete(Sys_home_activity.class, id); + } + + @Aop(TransAop.READ_COMMITTED) private void handleCourseTimes(TrainSignUpActivity activity, TrainSignUpCourse course) { String courseId = course.getId(); String activityId = activity.getId(); @@ -223,6 +591,7 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl { Calendar calendar = Calendar.getInstance(); @@ -250,7 +619,14 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl activities = pagination.getList(TrainSignUpActivity.class); + boolean canEditBasic = canEditActivityBasic(); + String userId = SecurityUtil.getUserId(); + Map> responsibleCourses = new HashMap<>(); + // 只批量查询当前页活动的本人负责人关系,避免逐行查询,也不把普通用户当作负责人。 + if (!canEditBasic && StrUtil.isNotBlank(userId) && !activities.isEmpty()) { + List activityIds = activities.stream().map(TrainSignUpActivity::getId).toList(); + List relations = dao().query(TrainSignUpCourseResponsible.class, + Cnd.where("activityId", "in", activityIds).and("userId", "=", userId)); + for (TrainSignUpCourseResponsible relation : relations) { + responsibleCourses.computeIfAbsent(relation.getActivityId(), key -> new HashSet<>()) + .add(relation.getCourseId()); + } + } + List rows = activities.stream().map(activity -> { + Set courseIds = responsibleCourses.getOrDefault(activity.getId(), Collections.emptySet()); + // 同时核对实际子活动,已删除或已迁出的子活动关系不提供入口;管理员优先走编辑。 + boolean canManageCourse = !canEditBasic && activity.getCourseList() != null + && activity.getCourseList().stream().anyMatch(course -> courseIds.contains(course.getId())); + return Lang.obj2nutmap(activity).setv("canManageCourse", canManageCourse); + }).toList(); + pagination.setList(rows); return pagination; } diff --git a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/impl/TrainSignUpActivityStatisticsServiceImpl.java b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/impl/TrainSignUpActivityStatisticsServiceImpl.java index 3403cb3e..12a55c07 100644 --- a/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/impl/TrainSignUpActivityStatisticsServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/activity/trainSignUp/service/impl/TrainSignUpActivityStatisticsServiceImpl.java @@ -1,25 +1,42 @@ package com.budwk.app.zhgh.activity.trainSignUp.service.impl; +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.afterturn.easypoi.excel.export.ExcelExportService; import cn.hutool.core.util.StrUtil; +import cn.hutool.core.date.DateUtil; +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.param.PageForm; import com.budwk.app.base.service.impl.BaseServiceImpl; import com.budwk.app.sys.models.Sys_user; +import com.budwk.app.sys.models.Sys_file; +import com.budwk.app.sys.utils.SysFileMinIoUtil; import com.budwk.app.zhgh.activity.family.models.FamilyType; import com.budwk.app.zhgh.activity.family.models.FamilyUser; import com.budwk.app.zhgh.activity.trainSignUp.models.*; import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityStatisticsService; +import com.budwk.app.web.commons.auth.utils.AuthUtil; +import com.budwk.app.web.commons.auth.utils.SecurityUtil; import lombok.extern.slf4j.Slf4j; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.dao.Chain; 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.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.json.Json; import org.nutz.lang.Lang; import org.nutz.lang.util.NutMap; +import java.io.IOException; import java.util.*; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; @@ -37,8 +54,197 @@ public class TrainSignUpActivityStatisticsServiceImpl extends BaseServiceImpl activityList(Integer year, boolean enabledOnly) { + // 可选条件也在创建SQL前确定,让年度、启用状态和负责人ID均由预编译参数绑定。 + Sql sql = Sqls.create("SELECT activity.* FROM train_sign_up_activity activity WHERE 1=1 " + + (year == null ? "" : " AND activity.year=@year ") + + (enabledOnly ? " AND activity.isDisabled=@disabled " : "") + + " $responsibleScope ORDER BY activity.activityStartTime DESC"); + sql.setParam("year", year); + sql.setParam("disabled", false); + applyResponsibleScope(sql, "activity.id", true); + sql.setEntity(dao().getEntity(TrainSignUpActivity.class)); + sql.setCallback(Sqls.callback.entities()); + dao().execute(sql); + return sql.getList(TrainSignUpActivity.class); + } + + @Override + public List getCourse(String activityId) { + if (StrUtil.isBlank(activityId)) { + return new ArrayList<>(); + } + Sql sql = Sqls.create(""" + SELECT tsuc.id, tsuc.courseName, tsuc.coursePeopleNumber, tsuc.courseType, + tsuc.courseLocation, tsuc.courseInstructor, tsuc.courseReservedNumber, + tsuc.waitingNum, tsuc.reserveMode + FROM train_sign_up_course tsuc + WHERE tsuc.activityId=@activityId $responsibleScope + ORDER BY tsuc.courseName ASC + """); + sql.setParam("activityId", activityId); + applyResponsibleScope(sql, "tsuc.id", false); + List courses = listMap(sql); + courses.forEach(course -> { + course.put("registerNum", queryCourseCount(course.getString("id"), course.getString("courseType"))); + course.put("hasWaitingNum", queryCourseWaitCount(course.getString("id"), course.getString("courseType"))); + }); + return courses; + } + + @Override + public List selectTimes(String courseId) { + requireCourseScope(courseId); + return dao().query(TrainSignUpActivityCourse.class, Cnd.where("courseId", "=", courseId).asc("courseStartTime")); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public void signChange(String courseId, Boolean openOtherUnion) { + requireCourseScope(courseId); + if (openOtherUnion == null) { + throw new BaseException("请指定是否开放报名"); + } + dao().update(TrainSignUpCourse.class, Chain.make("openOtherUnion", openOtherUnion), Cnd.where("id", "=", courseId)); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public void adjustSign(String id) { + if (StrUtil.isBlank(id)) { + throw new BaseException("签到记录ID不能为空"); + } + TrainSignUpUserCourse userCourse = dao().fetch(TrainSignUpUserCourse.class, id); + if (userCourse == null) { + throw new BaseException("签到记录不存在"); + } + // 通过记录实际所属子活动验证权限,不接受前端指定负责人身份。 + requireCourseScope(userCourse.getCourseId()); + if (userCourse.isAttend()) { + userCourse.setAttend(false); + userCourse.setAttendTime(null); + } else { + userCourse.setAttend(true); + userCourse.setAttendTime(userCourse.getCourseStartTime()); + } + dao().update(userCourse); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public void adjust(String activityId, String oldCourseId, String newCourseId, String userId) { + if (StrUtil.isBlank(userId)) { + throw new BaseException("报名人员用户ID不能为空"); + } + // 来源与目标均受负责人范围约束,且必须属于同一主活动,防止绕过目标列表迁移数据。 + requireActivityMatch(activityId, requireCourseScope(oldCourseId)); + requireActivityMatch(activityId, requireCourseScope(newCourseId)); + Cnd oldCnd = Cnd.where("activityId", "=", activityId).and("courseId", "=", oldCourseId).and("userId", "=", userId); + TrainSignUpUser oldUser = dao().fetch(TrainSignUpUser.class, oldCnd); + if (oldUser == null) { + throw new BaseException("原报名记录不存在,请刷新后重试"); + } + oldUser.setCourseId(newCourseId); + oldUser.setSignUpTime(DateUtil.date()); + dao().update(oldUser); + + // 沿用现有人员调整业务,按目标子活动时段重建签到记录,并在同一事务中提交。 + List times = dao().query(TrainSignUpActivityCourse.class, + Cnd.where("activityId", "=", activityId).and("courseId", "=", newCourseId)); + dao().clear(TrainSignUpUserCourse.class, oldCnd); + List userCourses = new ArrayList<>(); + times.forEach(item -> { + TrainSignUpUserCourse course = new TrainSignUpUserCourse(); + course.setActivityCourseId(activityId); + course.setCourseId(newCourseId); + course.setUserId(userId); + course.setCourseStartTime(item.getCourseStartTime()); + course.setCourseEndTime(item.getCourseEndTime()); + course.setActivityCourseId(item.getId()); + userCourses.add(course); + }); + dao().insert(userCourses); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public void deleteSignUser(String activityId, String courseId, String userId) { + if (StrUtil.isBlank(userId)) { + throw new BaseException("报名人员用户ID不能为空"); + } + requireActivityMatch(activityId, requireCourseScope(courseId)); + Cnd cnd = Cnd.where("activityId", "=", activityId).and("courseId", "=", courseId).and("userId", "=", userId); + // 报名及对应签到记录必须一并删除,禁止只传用户ID造成跨子活动删除。 + dao().clear(TrainSignUpUser.class, cnd); + dao().clear(TrainSignUpUserCourse.class, cnd); + } + @Override public Pagination pageData(PageForm pageForm, String activityId) { + // 没有可选主活动时返回空分页,不能将缺少筛选条件解释为查询所有子活动。 + if (StrUtil.isBlank(activityId)) { + return new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), 0, new ArrayList<>()); + } Sql sql = Sqls.create(""" SELECT tsuc.id, @@ -61,9 +267,11 @@ public class TrainSignUpActivityStatisticsServiceImpl extends BaseServiceImpl courseList = pagination.getList(); courseList.forEach(c -> { @@ -75,6 +283,7 @@ public class TrainSignUpActivityStatisticsServiceImpl extends BaseServiceImpl registerUserList(String courseId) { + requireCourseScope(courseId); Sql sql = Sqls.create(""" SELECT u.loginname, @@ -92,11 +301,13 @@ public class TrainSignUpActivityStatisticsServiceImpl extends BaseServiceImpl list = listMap(sql); for (NutMap o : list) { if(StrUtil.isBlank(o.getString("mobileColumnsValue"))) { @@ -114,7 +325,7 @@ public class TrainSignUpActivityStatisticsServiceImpl extends BaseServiceImpl getTaleColumnInfo(String courseId) { - TrainSignUpCourse course = dao().fetch(TrainSignUpCourse.class, courseId); + TrainSignUpCourse course = requireCourseScope(courseId); TrainSignUpType upType = dao().fetch(TrainSignUpType.class, course.getCourseType()); dao().fetchLinks(upType, "trainMobileSignColumnList", Cnd.NEW().asc("columnIndex")); @@ -125,6 +336,7 @@ public class TrainSignUpActivityStatisticsServiceImpl extends BaseServiceImpl registerUserList(String courseId, String unionId, String unitId, String searchName, String searchKeyword) { + requireCourseScope(courseId); Sql sql = Sqls.create(""" SELECT tsuu.id, @@ -141,10 +353,11 @@ public class TrainSignUpActivityStatisticsServiceImpl extends BaseServiceImpl courseList = courseSql.getList(TrainSignUpCourse.class); + if (courseList.isEmpty()) { + throw new BaseException("没有可导出的子活动,请检查负责人设置"); + } + Sql sql = Sqls.create(""" + SELECT ts.*, + CONCAT(DATE_FORMAT(ac.courseStartTime, '%H:%i:%s'),'至',DATE_FORMAT(ac.courseEndTime, '%H:%i:%s')) AS courseTime, + u.username, u.loginname, u.sex, IFNULL(ts.mobile, u.mobile) AS mobile, + u.birthday, tsc.courseName + FROM train_sign_up_user ts + LEFT JOIN train_sign_up_activity_course ac ON ts.activityCourseId=ac.id + LEFT JOIN vw_user u ON u.id=ts.userId + LEFT JOIN train_sign_up_course tsc ON tsc.id=ts.courseId + WHERE ts.activityId=@activityId AND tsc.activityId=@activityId $responsibleScope + """); + sql.setParam("activityId", activityId); + applyResponsibleScope(sql, "tsc.id", false); + List userList = listMap(sql); + + // 保留原导出公共列、动态报名列和图片处理,仅改变数据范围及Excel文件类型。 + List types = dao().query(TrainSignUpType.class, Cnd.NEW()); + dao().fetchLinks(types, "trainMobileSignColumnList", Cnd.NEW().asc("columnIndex")); + Map typeMap = types.stream().collect(Collectors.toMap(TrainSignUpType::getId, type -> type)); + List> sheetsList = new ArrayList<>(); + List commonEntities = new ArrayList<>(); + commonEntities.add(new ExcelExportEntity("姓名", "username", 20)); + commonEntities.add(new ExcelExportEntity("工号", "loginname", 20)); + commonEntities.add(new ExcelExportEntity("单位", "unitName", 20)); + commonEntities.add(new ExcelExportEntity("分工会", "unionName", 20)); + commonEntities.add(new ExcelExportEntity("性别", "sex", 20)); + commonEntities.add(new ExcelExportEntity("手机号", "mobile", 20)); + commonEntities.add(new ExcelExportEntity("生日", "birthday", 20)); + commonEntities.add(new ExcelExportEntity("报名时段", "courseTime", 20)); + for (TrainSignUpCourse course : courseList) { + String name = course.getCourseName(); + List users = userList.stream().filter(user -> StrUtil.isNotBlank(user.getString("courseName")) + && user.getString("courseName").equals(name)).collect(Collectors.toList()); + ExportParams exportParams = new ExportParams(); + exportParams.setType(ExcelType.XSSF); + exportParams.setSheetName(name); + List entities = new ArrayList<>(commonEntities); + TrainSignUpType type = typeMap.get(course.getCourseType()); + if (Lang.isNotEmpty(type.getTrainMobileSignColumnList())) { + for (TrainMobileSignColumn column : type.getTrainMobileSignColumnList()) { + ExcelExportEntity entity = new ExcelExportEntity(); + entity.setName(column.getColumnName()); + entity.setKey(column.getColumnCode()); + entity.setWidth(20); + if ("FILE".equals(column.getColumnFormType())) { + entity.setType(2); + entity.setExportImageType(2); + } + entities.add(entity); + } + } + for (NutMap user : users) { + String values = user.getString("mobileColumnsValue"); + if (StrUtil.isBlank(values)) { + continue; + } + for (NutMap value : Json.fromJsonAsList(NutMap.class, values)) { + if (!"FILE".equals(value.getString("columnFormType"))) { + user.put(value.getString("columnCode"), value.getString("columnValue")); + } else if (StrUtil.isNotBlank(value.getString("columnValue"))) { + List files = value.getAsList("columnValue", NutMap.class); + if (files.size() == 1) { + NutMap fileData = files.get(0); + Sys_file file = dao().fetch(Sys_file.class, Cnd.where(Sys_file::getDownloadPath, "=", fileData.get("url"))); + byte[] imageBytes = SysFileMinIoUtil.getFileBytes(file.getBucket(), file.getStoragePath()); + if (imageBytes.length > 0) { + user.put(value.getString("columnCode"), imageBytes); + } + } + } + } + } + Map sheet = new HashMap<>(); + sheet.put("name", name); + sheet.put("title", exportParams); + sheet.put("entity", entities); + sheet.put("data", users); + sheetsList.add(sheet); + } + Workbook workbook = new XSSFWorkbook(); + try { + for (Map sheet : sheetsList) { + new ExcelExportService().createSheetForMap(workbook, (ExportParams) sheet.get("title"), + (List) sheet.get("entity"), (Collection) sheet.get("data")); + } + return NutMap.NEW().setv("fileName", activity.getActivityName() + "报名人员名单.xlsx").setv("workbook", workbook); + } catch (RuntimeException exception) { + // 构建失败时尚未交给下载层,需在此释放工作簿;成功时由下载方法统一关闭。 + try { + workbook.close(); + } catch (IOException closeException) { + exception.addSuppressed(closeException); + } + throw exception; + } + } + @Override public int queryCourseCount(String courseId, String courseType) { return this.queryCourseCount(courseId, courseType, null); diff --git a/src/main/java/com/budwk/app/zhgh/democratic/proposal/h5controller/H5ProposalCommonController.java b/src/main/java/com/budwk/app/zhgh/democratic/proposal/h5controller/H5ProposalCommonController.java index 2607617f..eee8eb67 100644 --- a/src/main/java/com/budwk/app/zhgh/democratic/proposal/h5controller/H5ProposalCommonController.java +++ b/src/main/java/com/budwk/app/zhgh/democratic/proposal/h5controller/H5ProposalCommonController.java @@ -1,10 +1,15 @@ package com.budwk.app.zhgh.democratic.proposal.h5controller; import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.hutool.core.util.StrUtil; +import com.budwk.app.base.result.Result; +import com.budwk.app.zhgh.democratic.proposal.service.ProposalWriteService; import io.swagger.annotations.Api; +import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.Ok; +import org.nutz.mvc.annotation.Param; @IocBean @At("/platform/h5/proposal/common") @@ -12,6 +17,24 @@ import org.nutz.mvc.annotation.Ok; @Api(tags = "h5提案公共") public class H5ProposalCommonController { + @Inject + private ProposalWriteService proposalWriteService; + + /** + * 查询当前登录用户在指定届次下的代表身份,不以是否已分配代表团作为判断依据。 + * + * @param sessionId 当前选择或已有提案所属的教代会届次ID,不能为空 + * @return Result 的 data 为 Boolean:true 表示存在未删除的代表记录,false 表示不是该届次代表 + */ + @At + @SaCheckPermission("h5.proposal.write") + public Result isCurrentUserDelegate(@Param("sessionId") String sessionId) { + if (StrUtil.isBlank(sessionId)) { + return Result.error("所属教代会不能为空"); + } + return Result.success().addData(proposalWriteService.isCurrentUserDelegate(sessionId)); + } + @At("") @Ok("beetl:/platform/zhghh5/democratic/proposal/common/info/index.html") @SaCheckPermission("h5.proposal") diff --git a/src/main/java/com/budwk/app/zhgh/democratic/proposal/service/ProposalWriteService.java b/src/main/java/com/budwk/app/zhgh/democratic/proposal/service/ProposalWriteService.java index ae07bd50..3e1ff0c2 100644 --- a/src/main/java/com/budwk/app/zhgh/democratic/proposal/service/ProposalWriteService.java +++ b/src/main/java/com/budwk/app/zhgh/democratic/proposal/service/ProposalWriteService.java @@ -49,5 +49,13 @@ public interface ProposalWriteService extends BaseService { */ void checkCurrentUserDelegate(String sessionId); + /** + * 查询当前登录用户是否为指定教代会届次的有效代表。 + * + * @param sessionId 教代会届次ID,不能为空;用户身份由服务端登录信息取得 + * @return true 表示存在未删除的代表记录,false 表示不存在;不要求已分配代表团 + */ + boolean isCurrentUserDelegate(String sessionId); + ProposalInfo importProposal(Map stringStringMap); } diff --git a/src/main/java/com/budwk/app/zhgh/democratic/proposal/service/impl/ProposalWriteServiceImpl.java b/src/main/java/com/budwk/app/zhgh/democratic/proposal/service/impl/ProposalWriteServiceImpl.java index adc755b5..ea8b6baf 100644 --- a/src/main/java/com/budwk/app/zhgh/democratic/proposal/service/impl/ProposalWriteServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/democratic/proposal/service/impl/ProposalWriteServiceImpl.java @@ -194,6 +194,19 @@ public class ProposalWriteServiceImpl extends BaseServiceImpl impl @Override public void checkCurrentUserDelegate(String sessionId) { + if (!isCurrentUserDelegate(sessionId)) { + throw new RuntimeException("当前用户不是代表,无法撰写提案"); + } + } + + /** + * 入口查询与保存、提交校验共用同一代表身份口径,排除已删除记录。 + * + * @param sessionId 教代会届次ID,空值抛出异常,避免被误判为非代表 + * @return 是否存在当前登录用户在该届次的有效代表记录 + */ + @Override + public boolean isCurrentUserDelegate(String sessionId) { if (StrUtil.isBlank(sessionId)) { throw new RuntimeException("所属教代会不能为空"); } @@ -204,9 +217,7 @@ public class ProposalWriteServiceImpl extends BaseServiceImpl impl .and(Teacher_congress_delegate::getDelFlag, "!=", 1) .limit(1) ); - if (delegate == null) { - throw new RuntimeException("当前用户不是代表,无法撰写提案"); - } + return delegate != null; } @Override diff --git a/src/main/java/com/budwk/app/zhgh/huazhu/constant/HuaZhuConstant.java b/src/main/java/com/budwk/app/zhgh/huazhu/constant/HuaZhuConstant.java new file mode 100644 index 00000000..bfd973df --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/huazhu/constant/HuaZhuConstant.java @@ -0,0 +1,47 @@ +package com.budwk.app.zhgh.huazhu.constant; + +/** 华住对接参数统一在此维护,沿用原项目常量取值。 */ +public final class HuaZhuConstant { + /** + * 华住接口请求超时,单位毫秒。 + */ + public static final int TIMEOUT = 20000; + + public static final String BASEURL = "https://htravelserver.huazhu.com"; + + public static final String OAUTH2_URL = "https://oauth2-api.huazhu.com"; + + /** + * 公司卡号 + */ + public static final String CARD_ID = "VCENTCRM1169099135"; + + /** + * 公司密钥 + */ + public static final String CARD_PASSWORD = "47CD8C9C-A4CD-4CBD-A30F-41C41BDF7E99"; + + /** + * AES密钥 + */ + public static final String AES_KEY = "JAE4YZJLXZW5HGBU"; + + /** + * 客户端ID + */ + public static final String CLIENT_ID = "5e8651d8-a5a8-4358-9986-91f426e191c8"; + + /** + * 客户端密钥 + */ + public static final String CLIENT_SECRET = "2d20a7f3125b52b8e90936c3b30fb4b62b443a52716a090e55e302cf40f32b66d592ef1e98c1cf0a8e5ec3f9c444a9b4f53a337b1be57263d4fbb71044f67f12"; + + /** 华住 addUser 响应的顶层成功字段名;协议未确认前留空,界面显示待核实。 */ + public static final String SYNC_SUCCESS_FIELD = ""; + + /** 成功字段对应的精确字符串值,与 SYNC_SUCCESS_FIELD 一起按华住协议填写。 */ + public static final String SYNC_SUCCESS_VALUE = ""; + + private HuaZhuConstant() { + } +} diff --git a/src/main/java/com/budwk/app/zhgh/huazhu/controller/HuaZhuManageController.java b/src/main/java/com/budwk/app/zhgh/huazhu/controller/HuaZhuManageController.java new file mode 100644 index 00000000..dbd799d2 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/huazhu/controller/HuaZhuManageController.java @@ -0,0 +1,81 @@ +package com.budwk.app.zhgh.huazhu.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.hutool.core.util.StrUtil; +import com.budwk.app.base.result.Result; +import com.budwk.app.zhgh.huazhu.param.HuaZhuEmployeePageForm; +import com.budwk.app.zhgh.huazhu.service.HuaZhuEmployeeService; +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.mvc.annotation.At; +import org.nutz.mvc.annotation.Ok; +import org.nutz.mvc.annotation.Param; +import org.nutz.mvc.annotation.POST; + +import javax.validation.Valid; +import java.util.Arrays; + +/** + * PC 端华住员工同步管理。 + */ +@IocBean +@Ok("json:full") +@At("/platform/huazhu") +public class HuaZhuManageController { + + @Inject + private HuaZhuEmployeeService huaZhuEmployeeService; + + /** 打开拥有 huazhu.manage 权限的 PC 员工管理页面。 */ + @At("") + @Ok("beetl:/platform/zhgh/huazhu/index.html") + @SaCheckPermission("huazhu.manage") + public void index() { + } + + /** + * @param pageForm pageNumber、pageSize 必填,searchKeyword 可选,匹配工号或姓名 + * @return Result.data 为分页对象,list 包含 id、loginname、username、unitName,totalCount 为总数 + */ + @At + @SaCheckPermission("huazhu.manage") + public Result pageData(@Valid HuaZhuEmployeePageForm pageForm) { + return Result.success(huaZhuEmployeeService.pageUsers(pageForm)); + } + + /** + * @param userIds 所选本系统员工主键数组,非华住员工编号 + * @return Result.data 为逐人结果数组;success 为 true/false/null,分别表示成功/失败/待核实,message 为说明 + */ + @At + @POST + @Aop(TransAop.READ_COMMITTED) + @SaCheckPermission("huazhu.manage") + public Result syncUsers(@Param("userIds") String[] userIds) { + if (userIds == null || userIds.length == 0) { + return Result.error("请先选择需要同步的员工"); + } + return Result.success(huaZhuEmployeeService.syncUsers(Arrays.asList(userIds))); + } + + /** + * @param userId 本系统员工主键,服务端转换为工号后查询华住 + * @return Result.data 为华住状态对象,含 statusCode、userNumber、userName 等;错误说明见 msg + */ + @At + @SaCheckPermission("huazhu.manage") + public Result queryStatus(@Param("userId") String userId) { + if (StrUtil.isBlank(userId)) { + return Result.error("用户不能为空"); + } + try { + return Result.success(huaZhuEmployeeService.queryStatus(userId)); + } catch (IllegalArgumentException | IllegalStateException e) { + return Result.error(e.getMessage()); + } catch (Exception e) { + return Result.error("查询华住状态失败,请稍后重试"); + } + } +} diff --git a/src/main/java/com/budwk/app/zhgh/huazhu/h5Controller/H5HuaZhuController.java b/src/main/java/com/budwk/app/zhgh/huazhu/h5Controller/H5HuaZhuController.java new file mode 100644 index 00000000..26f46ef3 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/huazhu/h5Controller/H5HuaZhuController.java @@ -0,0 +1,37 @@ +package com.budwk.app.zhgh.huazhu.h5Controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import com.budwk.app.web.commons.auth.utils.SecurityUtil; +import com.budwk.app.zhgh.huazhu.service.HuaZhuLoginService; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.mvc.annotation.At; +import org.nutz.mvc.annotation.Ok; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * H5 幸福工荟中的华住免密登录入口。 + */ +@IocBean +@At("/platform/h5/huazhu") +public class H5HuaZhuController { + + @Inject + private HuaZhuLoginService huaZhuLoginService; + + /** + * 校验幸福工荟访问权限后跳转至华住企业登录页面。 + * + * @param response HTTP 响应,用于返回 302 跳转 + * @throws IOException 响应输出异常 + */ + @At("/basicLogin") + @Ok("void") + @SaCheckPermission("h5.inclusive.benefit") + public void basicLogin(HttpServletResponse response) throws IOException { + response.setHeader("Cache-Control", "no-store"); + response.sendRedirect(huaZhuLoginService.buildBasicLoginUrl(SecurityUtil.getUserLoginname())); + } +} diff --git a/src/main/java/com/budwk/app/zhgh/huazhu/param/HuaZhuEmployeePageForm.java b/src/main/java/com/budwk/app/zhgh/huazhu/param/HuaZhuEmployeePageForm.java new file mode 100644 index 00000000..4dbd1814 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/huazhu/param/HuaZhuEmployeePageForm.java @@ -0,0 +1,9 @@ +package com.budwk.app.zhgh.huazhu.param; + +import com.budwk.app.base.param.PageForm; + +/** + * 华住员工管理分页参数:复用 PageForm 的 searchKeyword、pageNumber、pageSize。 + */ +public class HuaZhuEmployeePageForm extends PageForm { +} diff --git a/src/main/java/com/budwk/app/zhgh/huazhu/service/HuaZhuEmployeeService.java b/src/main/java/com/budwk/app/zhgh/huazhu/service/HuaZhuEmployeeService.java new file mode 100644 index 00000000..6a056095 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/huazhu/service/HuaZhuEmployeeService.java @@ -0,0 +1,39 @@ +package com.budwk.app.zhgh.huazhu.service; + +import com.budwk.app.base.page.Pagination; +import com.budwk.app.zhgh.huazhu.param.HuaZhuEmployeePageForm; +import com.budwk.app.zhgh.huazhu.vo.HuaZhuEmployeeStatus; +import com.budwk.app.zhgh.huazhu.vo.HuaZhuSyncResult; +import org.nutz.lang.util.NutMap; + +import java.util.List; + +/** + * 华住员工同步与状态查询服务。 + */ +public interface HuaZhuEmployeeService { + + /** + * 分页查询可同步的本项目员工。 + * + * @param pageForm pageNumber、pageSize 必填,searchKeyword 为可选的工号或姓名片段 + * @return 分页对象,list 为 id/loginname/username/unitName,totalCount 为匹配人数 + */ + Pagination pageUsers(HuaZhuEmployeePageForm pageForm); + + /** + * 将选中的本项目员工同步到华住。 + * + * @param userIds 本项目用户主键列表 + * @return 逐人结果,success=true 为协议确认成功、false 为失败、null 为待核实,message 为说明 + */ + List syncUsers(List userIds); + + /** + * 查询指定本项目员工在华住的实时状态。 + * + * @param userId 本项目用户主键 + * @return 华住员工状态对象;statusCode 保留第三方原值,userNumber 为已校验匹配的工号 + */ + HuaZhuEmployeeStatus queryStatus(String userId); +} diff --git a/src/main/java/com/budwk/app/zhgh/huazhu/service/HuaZhuLoginService.java b/src/main/java/com/budwk/app/zhgh/huazhu/service/HuaZhuLoginService.java new file mode 100644 index 00000000..dcb6a694 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/huazhu/service/HuaZhuLoginService.java @@ -0,0 +1,15 @@ +package com.budwk.app.zhgh.huazhu.service; + +/** + * 华住免密登录服务。 + */ +public interface HuaZhuLoginService { + + /** + * 按华住协议为当前职工工号生成免密登录跳转地址。 + * + * @param loginName 当前登录职工的工号 + * @return 华住免密登录地址 + */ + String buildBasicLoginUrl(String loginName); +} diff --git a/src/main/java/com/budwk/app/zhgh/huazhu/service/impl/HuaZhuEmployeeServiceImpl.java b/src/main/java/com/budwk/app/zhgh/huazhu/service/impl/HuaZhuEmployeeServiceImpl.java new file mode 100644 index 00000000..1db0d43d --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/huazhu/service/impl/HuaZhuEmployeeServiceImpl.java @@ -0,0 +1,299 @@ +package com.budwk.app.zhgh.huazhu.service.impl; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.digest.DigestUtil; +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import cn.hutool.json.JSONUtil; +import cn.hutool.json.JSONObject; +import com.budwk.app.base.page.Pagination; +import com.budwk.app.sys.models.Sys_user; +import com.budwk.app.zhgh.huazhu.constant.HuaZhuConstant; +import com.budwk.app.zhgh.huazhu.param.HuaZhuEmployeePageForm; +import com.budwk.app.zhgh.huazhu.service.HuaZhuEmployeeService; +import com.budwk.app.zhgh.huazhu.vo.HuaZhuEmployeeStatus; +import com.budwk.app.zhgh.huazhu.vo.HuaZhuSyncResult; +import org.nutz.dao.Cnd; +import org.nutz.dao.Dao; +import org.nutz.dao.Sqls; +import org.nutz.dao.sql.Sql; +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.lang.util.NutMap; +import org.nutz.integration.jedis.RedisService; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.List; + +/** + * 华住员工同步与状态查询实现。 + */ +@IocBean(args = {"refer:dao"}) +public class HuaZhuEmployeeServiceImpl implements HuaZhuEmployeeService { + + private final Dao dao; + + @Inject + private RedisService redisService; + + public HuaZhuEmployeeServiceImpl(Dao dao) { + this.dao = dao; + } + + /** 工号、姓名采用绑定参数查询;分页及筛选参数沿用项目 PageForm。 */ + @Override + public Pagination pageUsers(HuaZhuEmployeePageForm pageForm) { + if (pageForm == null || pageForm.getPageNumber() == null || pageForm.getPageNumber() < 1 + || pageForm.getPageSize() == null || pageForm.getPageSize() < 1 || pageForm.getPageSize() > 200) { + throw new IllegalArgumentException("页码必须大于0,每页数量必须为1至200"); + } + String keyword = StrUtil.trim(pageForm.getSearchKeyword()); + String condition = " WHERE u.delFlag = 0 "; + if (StrUtil.isNotBlank(keyword)) { + condition += " AND (u.loginname LIKE @keyword OR u.username LIKE @keyword) "; + } + Sql countSql = Sqls.create("SELECT COUNT(1) FROM sys_user u" + condition); + Sql listSql = Sqls.create("SELECT u.id, u.loginname, u.username, unit.name AS unitName " + + "FROM sys_user u LEFT JOIN sys_unit unit ON unit.id = u.unitId " + condition + + "ORDER BY u.createdAt DESC"); + if (StrUtil.isNotBlank(keyword)) { + String likeKeyword = "%" + keyword + "%"; + countSql.setParam("keyword", likeKeyword); + listSql.setParam("keyword", likeKeyword); + } + countSql.setCallback(Sqls.callback.integer()); + dao.execute(countSql); + int totalCount = countSql.getInt(); + listSql.setPager(dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize())); + listSql.setCallback(Sqls.callback.maps()); + dao.execute(listSql); + return new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), totalCount, listSql.getList(NutMap.class)); + } + + /** + * 每个主键独立校验并产生结果,单个员工不存在或接口失败不阻断后续员工。 + * 本地事务无法撤销华住侧已处理的同步,超时后应先查询状态,避免直接重试整批。 + */ + @Override + @Aop(TransAop.READ_COMMITTED) + public List syncUsers(List userIds) { + if (userIds == null || userIds.isEmpty()) { + return Collections.emptyList(); + } + List results = new ArrayList<>(); + for (String userId : userIds) { + HuaZhuSyncResult result = new HuaZhuSyncResult().setUserId(userId); + try { + Sys_user user = findAvailableUser(userId); + result.setLoginName(user.getLoginname()).setUserName(user.getUsername()); + String body = request("/userInfo/addUser", buildEmployeeRequest(user.getLoginname())); + applySyncResponse(result, body); + } catch (IllegalArgumentException | IllegalStateException e) { + // 只透出本模块构造的提示;网络和解析异常在下层转换为固定文案。 + result.setSuccess(false).setMessage(e.getMessage()); + } catch (Exception e) { + result.setSuccess(false).setMessage("同步未完成,请查询华住状态后再决定是否重试"); + } + results.add(result); + } + return results; + } + + /** 根据本系统主键取得工号,返回与该工号对应的华住员工对象,不推断状态码含义。 */ + @Override + public HuaZhuEmployeeStatus queryStatus(String userId) { + Sys_user user = findAvailableUser(userId); + String body = request("/userInfo/queryUser", buildEmployeeRequest(user.getLoginname())); + JSONObject payload = extractQueryPayload(body, user.getLoginname()); + try { + return JSONUtil.toBean(payload, HuaZhuEmployeeStatus.class); + } catch (Exception e) { + throw new IllegalStateException("华住查询接口返回格式无法识别", e); + } + } + + /** + * 兼容华住接口直接返回员工对象,以及以 data 或 result 包装员工对象的两种响应格式。 + * 响应中不包含员工状态时,只返回经筛选后的业务提示,避免透出第三方完整响应。 + * + * @param body 华住查询接口响应正文 + * @param loginName 本次查询的工号,用于避免把错误或其他员工的信息当成有效结果 + * @return 可转换为员工状态的 JSON 对象 + */ + private JSONObject extractQueryPayload(String body, String loginName) { + JSONObject response; + try { + response = JSONUtil.parseObj(body); + } catch (Exception e) { + throw new IllegalStateException("华住查询接口未返回有效数据", e); + } + JSONObject payload = extractObject(response, "data"); + if (payload == null) { + payload = extractObject(response, "result"); + } + if (payload == null) { + payload = response; + } + // statusCode 本身可能是错误码;必须有匹配的工号,才将响应作为该员工的状态展示。 + if (!loginName.equals(payload.getStr("userNumber"))) { + throw new IllegalStateException("华住未返回与该工号匹配的员工信息,请核实接口响应协议"); + } + return payload; + } + + /** + * 成功码未取得协议确认前,HTTP 2xx 仅表示请求送达;不猜测 0、200 等值的含义。 + * 在 HuaZhuConstant 中填写顶层字段名及精确成功值后才判定成功,字段缺失/格式变化仍返回待核实。 + */ + private void applySyncResponse(HuaZhuSyncResult result, String body) { + result.setSuccess(null).setMessage("请求已发送,结果待核实,请查询华住状态"); + String field = HuaZhuConstant.SYNC_SUCCESS_FIELD.trim(); + String expected = HuaZhuConstant.SYNC_SUCCESS_VALUE; + if (StrUtil.isBlank(field) || StrUtil.isBlank(expected)) { + return; + } + try { + JSONObject response = JSONUtil.parseObj(body); + if (response.get(field) == null) { + return; + } + boolean success = expected.equals(response.getStr(field)); + result.setSuccess(success).setMessage(success ? "同步成功" : "华住返回非成功结果,请查询状态核实"); + } catch (Exception e) { + result.setMessage("华住响应格式无法识别,结果待核实,请查询华住状态"); + } + } + + /** + * 从包装响应中提取对象;部分网关会将 data/result 再序列化为 JSON 字符串。 + * + * @param response 华住响应对象 + * @param key 包装字段名 + * @return 包装内的 JSON 对象,缺失或格式不匹配时返回 null + */ + private JSONObject extractObject(JSONObject response, String key) { + Object value = response.get(key); + if (value instanceof JSONObject) { + return (JSONObject) value; + } + if (value instanceof CharSequence && StrUtil.isNotBlank(value.toString())) { + try { + return JSONUtil.parseObj(value.toString()); + } catch (Exception ignored) { + return null; + } + } + return null; + } + + /** + * 调用华住员工接口,并统一处理 OAuth Token 与非成功状态。 + * + * @param path 华住接口路径 + * @param requestBody 请求体 + * @return 华住响应正文 + */ + private String request(String path, NutMap requestBody) { + String authorization = getAccessToken(); + try (HttpResponse response = HttpRequest.post(HuaZhuConstant.BASEURL.trim().replaceAll("/+$", "") + path) + .header("Authorization", authorization) + .header("accept", "application/json") + .contentType("application/json; charset=UTF-8") + .body(JSONUtil.toJsonStr(requestBody)) + .timeout(HuaZhuConstant.TIMEOUT) + .execute()) { + if (response.getStatus() == 401) { + redisService.del(tokenCacheKey()); + // 不自动重发修改请求,避免上游已执行时重复同步。 + throw new IllegalStateException("华住认证已失效,请查询状态后重试"); + } + if (!response.isOk()) { + throw new IllegalStateException("华住接口调用失败,HTTP 状态码:" + response.getStatus()); + } + return response.body(); + } catch (IllegalStateException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException("华住请求异常或超时,结果未确认,请先查询状态再重试"); + } + } + + /** + * 获取华住 OAuth Token,优先使用 Redis 缓存以避免频繁请求第三方认证接口。 + * + * @return HTTP Authorization 头的 Bearer Token + */ + private String getAccessToken() { + String cacheKey = tokenCacheKey(); + String cachedToken; + try { + cachedToken = redisService.get(cacheKey); + } catch (Exception e) { + throw new IllegalStateException("华住认证缓存不可用,请联系管理员"); + } + if (StrUtil.isNotBlank(cachedToken)) { + return cachedToken; + } + String credential = Base64.getEncoder().encodeToString((HuaZhuConstant.CLIENT_ID + ":" + HuaZhuConstant.CLIENT_SECRET).getBytes(StandardCharsets.UTF_8)); + try (HttpResponse response = HttpRequest.post(HuaZhuConstant.OAUTH2_URL.trim().replaceAll("/+$", "") + + "/oauth/token?scope=ALL&grant_type=client_credentials") + .header("Authorization", "Basic " + credential) + .timeout(HuaZhuConstant.TIMEOUT) + .execute()) { + if (!response.isOk()) { + throw new IllegalStateException("获取华住认证 Token 失败,HTTP 状态码:" + response.getStatus()); + } + JSONObject tokenData = JSONUtil.parseObj(response.body()); + String accessToken = tokenData.getStr("access_token"); + String tokenType = StrUtil.blankToDefault(tokenData.getStr("token_type"), "Bearer"); + if (StrUtil.isBlank(accessToken)) { + throw new IllegalStateException("华住认证接口未返回 Token"); + } + String authorization = tokenType + " " + accessToken; + // expires_in 单位为秒,预留一分钟;缺少有效时长时不缓存,避免沿用猜测的有效期。 + Long expiresIn = tokenData.getLong("expires_in"); + if (expiresIn != null && expiresIn > 60) { + redisService.setex(cacheKey, (int) Math.min(expiresIn - 60, Integer.MAX_VALUE), authorization); + } + return authorization; + } catch (IllegalStateException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException("华住认证请求、响应解析或缓存处理失败,请联系管理员"); + } + } + + /** 缓存按企业及接口环境隔离;凭据轮换后自然使用新键,键中不包含明文凭据。 */ + private String tokenCacheKey() { + return "zhgh:huazhu:oauth-token:" + DigestUtil.sha256Hex(JSONUtil.toJsonStr(new String[]{ + HuaZhuConstant.BASEURL.trim().replaceAll("/+$", ""), HuaZhuConstant.OAUTH2_URL.trim().replaceAll("/+$", ""), HuaZhuConstant.CARD_ID, + HuaZhuConstant.CLIENT_ID, HuaZhuConstant.CLIENT_SECRET})); + } + + /** 沿用源接口的 cardPassWord 大小写,只发送企业凭据和工号。 */ + private NutMap buildEmployeeRequest(String loginName) { + return NutMap.NEW().addv("cardId", HuaZhuConstant.CARD_ID) + .addv("cardPassWord", HuaZhuConstant.CARD_PASSWORD) + .addv("userNumber", loginName); + } + + private Sys_user findAvailableUser(String userId) { + if (StrUtil.isBlank(userId)) { + throw new IllegalArgumentException("用户主键不能为空"); + } + Sys_user user = dao.fetch(Sys_user.class, Cnd.where("id", "=", userId).and("delFlag", "=", false)); + if (user == null) { + throw new IllegalArgumentException("用户不存在或已删除"); + } + if (StrUtil.isBlank(user.getLoginname())) { + throw new IllegalArgumentException("用户缺少工号,无法同步华住"); + } + return user; + } + +} diff --git a/src/main/java/com/budwk/app/zhgh/huazhu/service/impl/HuaZhuLoginServiceImpl.java b/src/main/java/com/budwk/app/zhgh/huazhu/service/impl/HuaZhuLoginServiceImpl.java new file mode 100644 index 00000000..79ed739f --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/huazhu/service/impl/HuaZhuLoginServiceImpl.java @@ -0,0 +1,67 @@ +package com.budwk.app.zhgh.huazhu.service.impl; + +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.util.StrUtil; +import com.budwk.app.zhgh.huazhu.constant.HuaZhuConstant; +import com.budwk.app.zhgh.huazhu.service.HuaZhuLoginService; +import org.nutz.ioc.loader.annotation.IocBean; + +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +/** + * 华住免密登录服务实现。 + */ +@IocBean +public class HuaZhuLoginServiceImpl implements HuaZhuLoginService { + + private static final String TRAVEL_TYPE = "YS"; + + /** @param loginName 服务端登录会话中的工号;返回含签名的华住免密跳转 URL。 */ + @Override + public String buildBasicLoginUrl(String loginName) { + if (StrUtil.isBlank(loginName)) { + throw new IllegalArgumentException("当前登录用户缺少工号,无法进入华住"); + } + + // 华住约定使用 AES/ECB/PKCS5Padding 对公司卡号、工号和当前时间生成签名。 + String cardId = HuaZhuConstant.CARD_ID; + String authorizeSign = encrypt(cardId + "&" + loginName + "&" + DateUtil.now(), HuaZhuConstant.AES_KEY); + return HuaZhuConstant.BASEURL.trim().replaceAll("/+$", "") + "/authentication/basicLogin?cardId=" + encode(cardId) + + "&travelType=" + TRAVEL_TYPE + + "&userNumber=" + encode(loginName) + + "&invoiceNo=" + + "&authorizeSign=" + encode(authorizeSign); + } + + /** + * 按华住协议生成 AES 签名,不记录签名内容或密钥。 + * + * @param source 待签名内容 + * @param aesKey 华住提供的 16 字节 AES 密钥 + * @return Base64 编码后的签名 + */ + private String encrypt(String source, String aesKey) { + try { + byte[] keyBytes = aesKey.getBytes(StandardCharsets.UTF_8); + if (keyBytes.length != 16) { + throw new IllegalStateException("华住 AES 密钥必须为 16 字节"); + } + Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(keyBytes, "AES")); + return Base64.getEncoder().encodeToString(cipher.doFinal(source.getBytes(StandardCharsets.UTF_8))); + } catch (IllegalStateException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException("生成华住登录签名失败", e); + } + } + + private String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + +} diff --git a/src/main/java/com/budwk/app/zhgh/huazhu/vo/HuaZhuEmployeeStatus.java b/src/main/java/com/budwk/app/zhgh/huazhu/vo/HuaZhuEmployeeStatus.java new file mode 100644 index 00000000..66b87cec --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/huazhu/vo/HuaZhuEmployeeStatus.java @@ -0,0 +1,32 @@ +package com.budwk.app.zhgh.huazhu.vo; + +import lombok.Data; + +import java.util.List; + +/** + * 华住查询员工接口的展示结果。 + */ +@Data +public class HuaZhuEmployeeStatus { + private String statusCode; + private String userNumber; + private String userName; + private String userDep; + private String costCenter; + private String rank; + private String leader; + private String mobile; + private String email; + private List permissions; + + /** + * 华住返回的预订权限。 + */ + @Data + public static class Permission { + private Boolean hasPermission; + private String permissionCode; + private String permissionValue; + } +} diff --git a/src/main/java/com/budwk/app/zhgh/huazhu/vo/HuaZhuSyncResult.java b/src/main/java/com/budwk/app/zhgh/huazhu/vo/HuaZhuSyncResult.java new file mode 100644 index 00000000..06f9c205 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/huazhu/vo/HuaZhuSyncResult.java @@ -0,0 +1,18 @@ +package com.budwk.app.zhgh.huazhu.vo; + +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * 单个员工同步到华住的执行结果。 + */ +@Data +@Accessors(chain = true) +public class HuaZhuSyncResult { + private String userId; + private String loginName; + private String userName; + /** true=按已配置协议确认成功;false=失败;null=请求结果待核实,不等同同步成功。 */ + private Boolean success; + private String message; +} diff --git a/src/main/java/com/budwk/app/zhgh/inclusive/controller/InclusiveBenefitController.java b/src/main/java/com/budwk/app/zhgh/inclusive/controller/InclusiveBenefitController.java new file mode 100644 index 00000000..e7483026 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/inclusive/controller/InclusiveBenefitController.java @@ -0,0 +1,114 @@ +package com.budwk.app.zhgh.inclusive.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.hutool.core.util.StrUtil; +import com.budwk.app.base.result.Result; +import com.budwk.app.zhgh.inclusive.model.InclusiveBenefit; +import com.budwk.app.zhgh.inclusive.param.InclusiveBenefitPageForm; +import com.budwk.app.zhgh.inclusive.service.InclusiveBenefitService; +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.mvc.annotation.At; +import org.nutz.mvc.annotation.Ok; +import org.nutz.mvc.annotation.Param; + +import javax.validation.Valid; + +/** + * PC 端幸福工荟管理。 + */ +@IocBean +@Ok("json:full") +@At("/platform/inclusive") +public class InclusiveBenefitController { + + @Inject + private InclusiveBenefitService inclusiveBenefitService; + + /** 打开幸福工荟 PC 管理页面。 */ + @At("") + @Ok("beetl:/platform/zhgh/inclusive/index.html") + @SaCheckPermission("inclusive.benefit") + public void index() { + } + + /** + * @param pageForm 必填 pageNumber、pageSize;year、title、tagType 为可选筛选条件 + * @return Result,data 为分页对象,包含 list、totalCount、pageNo、pageSize + */ + @At + @SaCheckPermission("inclusive.benefit") + public Result pageData(@Valid InclusiveBenefitPageForm pageForm) { + return Result.success(inclusiveBenefitService.pageData(pageForm, false)); + } + + /** + * @param id 待回显内容主键 + * @return Result,data 为完整内容对象;记录不存在或已删除时返回错误 + */ + @At + @SaCheckPermission("inclusive.benefit") + public Result findOne(@Param("id") String id) { + Object detail = inclusiveBenefitService.findVO(id, false); + return detail == null ? Result.error("幸福工荟不存在或已删除") : Result.success(detail); + } + + /** + * @param benefit 内容表单:id 为空新增;title 标题、summary 摘要、tagType 标签编码、 + * content 富文本、cover 封面地址必填;year、sexLimit、linkUrl 为可选配置 + * @param status 0 保存草稿,1 发布;为空默认草稿 + * @return Result,code 为 0 表示成功,失败原因见 msg,无业务 data + */ + @At + @Aop(TransAop.READ_COMMITTED) + @SaCheckPermission("inclusive.benefit") + public Result save(InclusiveBenefit benefit, @Param("status") Integer status) { + if (benefit == null || StrUtil.isBlank(benefit.getTitle())) { + return Result.error("标题不能为空"); + } + if (StrUtil.isBlank(benefit.getTagType())) { + return Result.error("标签类型不能为空"); + } + if (StrUtil.isBlank(benefit.getSummary()) || StrUtil.isBlank(benefit.getContent()) + || StrUtil.isBlank(benefit.getCover())) { + return Result.error("摘要、内容和封面图不能为空"); + } + if (benefit.getTitle().length() > 100 || benefit.getSummary().length() > 500 + || benefit.getTagType().length() > 50 || benefit.getCover().length() > 255 + || (benefit.getLinkUrl() != null && benefit.getLinkUrl().length() > 500)) { + return Result.error("标题、摘要、标签、封面或链接长度超过限制"); + } + if (benefit.getSexLimit() != null && (benefit.getSexLimit() < 0 || benefit.getSexLimit() > 2)) { + return Result.error("性别配置只能为0、1或2"); + } + inclusiveBenefitService.saveBenefit(benefit, status); + return Result.success(); + } + + /** + * @param id 内容主键 + * @param status 0 下架,1 发布 + * @return Result,code 为 0 表示成功,无业务 data + */ + @At + @Aop(TransAop.READ_COMMITTED) + @SaCheckPermission("inclusive.benefit") + public Result updateStatus(@Param("id") String id, @Param("status") Integer status) { + inclusiveBenefitService.updateStatus(id, status); + return Result.success(); + } + + /** + * @param id 待逻辑删除的内容主键 + * @return Result,code 为 0 表示成功,无业务 data + */ + @At + @Aop(TransAop.READ_COMMITTED) + @SaCheckPermission("inclusive.benefit") + public Result delete(@Param("id") String id) { + inclusiveBenefitService.deleteBenefit(id); + return Result.success(); + } +} diff --git a/src/main/java/com/budwk/app/zhgh/inclusive/h5Controller/H5InclusiveBenefitController.java b/src/main/java/com/budwk/app/zhgh/inclusive/h5Controller/H5InclusiveBenefitController.java new file mode 100644 index 00000000..adb78623 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/inclusive/h5Controller/H5InclusiveBenefitController.java @@ -0,0 +1,67 @@ +package com.budwk.app.zhgh.inclusive.h5Controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import com.budwk.app.base.result.Result; +import com.budwk.app.zhgh.inclusive.param.InclusiveBenefitPageForm; +import com.budwk.app.zhgh.inclusive.service.InclusiveBenefitService; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.mvc.annotation.At; +import org.nutz.mvc.annotation.Ok; +import org.nutz.mvc.annotation.Param; + +import javax.validation.Valid; + +/** + * H5 端幸福工荟浏览。 + */ +@IocBean +@Ok("json:full") +@At("/platform/h5/inclusive") +public class H5InclusiveBenefitController { + + @Inject + private InclusiveBenefitService inclusiveBenefitService; + + /** 打开幸福工荟 H5 列表页。 */ + @At("") + @Ok("beetl:/platform/zhghh5/inclusive/index.html") + @SaCheckPermission("h5.inclusive.benefit") + public void index() { + } + + /** 打开详情页,页面通过查询参数 id 请求具体内容。 */ + @At("/detail") + @Ok("beetl:/platform/zhghh5/inclusive/detail.html") + @SaCheckPermission("h5.inclusive.benefit") + public void detail() { + } + + /** + * @param pageForm 必填 pageNumber、pageSize;tagType 为标签编码,可选 year、title + * @return Result,data 为已发布且未删除内容的分页对象,含 list、totalCount、pageNo、pageSize + */ + @At + @SaCheckPermission("h5.inclusive.benefit") + public Result pageData(@Valid InclusiveBenefitPageForm pageForm) { + return Result.success(inclusiveBenefitService.pageData(pageForm, true)); + } + + /** + * @param id 内容主键 + * @return Result,data 为详情对象;不存在、未发布或已删除时返回错误,不返回正文 + */ + @At + @SaCheckPermission("h5.inclusive.benefit") + public Result findOne(@Param("id") String id) { + Object detail = inclusiveBenefitService.findVO(id, true); + return detail == null ? Result.error("内容不存在或已下架") : Result.success(detail); + } + + /** @return Result,data 为标签数组,每项 text 是标签名称,value 是标签编码;无参数 */ + @At + @SaCheckPermission("h5.inclusive.benefit") + public Result tagOptions() { + return Result.success(inclusiveBenefitService.tagOptions()); + } +} diff --git a/src/main/java/com/budwk/app/zhgh/inclusive/model/InclusiveBenefit.java b/src/main/java/com/budwk/app/zhgh/inclusive/model/InclusiveBenefit.java new file mode 100644 index 00000000..262a5a1c --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/inclusive/model/InclusiveBenefit.java @@ -0,0 +1,84 @@ +package com.budwk.app.zhgh.inclusive.model; + +import com.budwk.app.base.model.BaseModel; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.nutz.dao.entity.annotation.ColDefine; +import org.nutz.dao.entity.annotation.ColType; +import org.nutz.dao.entity.annotation.Column; +import org.nutz.dao.entity.annotation.Comment; +import org.nutz.dao.entity.annotation.EL; +import org.nutz.dao.entity.annotation.Name; +import org.nutz.dao.entity.annotation.Table; +import org.nutz.dao.entity.annotation.TableMeta; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +import java.io.Serializable; + +/** + * 幸福工荟内容。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Table("inclusive_benefit") +@TableMeta("{'mysql-charset':'utf8mb4'}") +@Comment("幸福工荟") +public class InclusiveBenefit extends BaseModel implements Serializable { + + @Name + @Column + @Comment("主键") + @ColDefine(type = ColType.VARCHAR, width = 32) + @PrevInsert(els = @EL("uuid()")) + private String id; + + @Column + @Comment("年度") + @ColDefine(type = ColType.INT) + private Integer year; + + @Column + @Comment("标题") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String title; + + @Column + @Comment("摘要") + @ColDefine(type = ColType.VARCHAR, width = 500) + private String summary; + + @Column + @Comment("标签类型字典编码") + @ColDefine(type = ColType.VARCHAR, width = 50) + private String tagType; + + @Column + @Comment("富文本内容") + @ColDefine(type = ColType.TEXT) + private String content; + + @Column + @Comment("性别限制:0全部开放,1男性,2女性") + @ColDefine(type = ColType.INT) + private Integer sexLimit; + + @Column + @Comment("封面图") + @ColDefine(type = ColType.VARCHAR, width = 255) + private String cover; + + @Column + @Comment("外链地址") + @ColDefine(type = ColType.VARCHAR, width = 500) + private String linkUrl; + + @Column + @Comment("发布状态:0草稿,1已发布") + @ColDefine(type = ColType.INT) + private Integer status; + + @Column + @Comment("发布时间") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String publishTime; +} diff --git a/src/main/java/com/budwk/app/zhgh/inclusive/param/InclusiveBenefitPageForm.java b/src/main/java/com/budwk/app/zhgh/inclusive/param/InclusiveBenefitPageForm.java new file mode 100644 index 00000000..fe18b1af --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/inclusive/param/InclusiveBenefitPageForm.java @@ -0,0 +1,19 @@ +package com.budwk.app.zhgh.inclusive.param; + +import com.budwk.app.base.param.PageForm; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 幸福工荟分页查询参数。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class InclusiveBenefitPageForm extends PageForm { + /** 可选年度,按内容归属年度精确筛选。 */ + private Integer year; + /** 可选标题关键字,使用包含匹配。 */ + private String title; + /** 可选标签字典编码,空值表示全部标签。 */ + private String tagType; +} diff --git a/src/main/java/com/budwk/app/zhgh/inclusive/service/InclusiveBenefitService.java b/src/main/java/com/budwk/app/zhgh/inclusive/service/InclusiveBenefitService.java new file mode 100644 index 00000000..233333ff --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/inclusive/service/InclusiveBenefitService.java @@ -0,0 +1,54 @@ +package com.budwk.app.zhgh.inclusive.service; + +import com.budwk.app.base.page.Pagination; +import com.budwk.app.base.service.BaseService; +import com.budwk.app.zhgh.inclusive.model.InclusiveBenefit; +import com.budwk.app.zhgh.inclusive.param.InclusiveBenefitPageForm; +import com.budwk.app.zhgh.inclusive.vo.InclusiveBenefitVO; +import org.nutz.lang.util.NutMap; + +import java.util.List; + +/** + * 幸福工荟业务服务。 + */ +public interface InclusiveBenefitService extends BaseService { + + /** + * 查询管理列表或手机端已发布内容。 + * @param pageForm 页码、每页数量及可选的年度、标题、标签编码 + * @param onlyPublished 手机端固定传 true,PC 管理端传 false,不接受客户端指定 + * @return 分页对象,list 为内容列表,totalCount 为符合条件的总条数 + */ + Pagination pageData(InclusiveBenefitPageForm pageForm, boolean onlyPublished); + + /** + * @param id 内容主键 + * @param onlyPublished true 时只允许读取已发布内容 + * @return 内容及标签名称;不存在、已删除或未发布时返回 null + */ + InclusiveBenefitVO findVO(String id, boolean onlyPublished); + + /** @return 启用的标签列表,每项 text 为名称,value 为字典编码 */ + List tagOptions(); + + /** + * @param benefit 表单内容,id 为空表示新增,否则编辑对应内容 + * @param status 0 保存草稿,1 发布;为空按草稿处理 + * @return 无返回值;内容不存在或参数不合法时抛出 IllegalArgumentException + */ + void saveBenefit(InclusiveBenefit benefit, Integer status); + + /** + * @param id 内容主键 + * @param status 0 下架,1 发布 + * @return 无返回值;发布时更新发布时间 + */ + void updateStatus(String id, Integer status); + + /** + * @param id 待删除内容主键 + * @return 无返回值;仅设置删除标记,保留原记录 + */ + void deleteBenefit(String id); +} diff --git a/src/main/java/com/budwk/app/zhgh/inclusive/service/impl/InclusiveBenefitServiceImpl.java b/src/main/java/com/budwk/app/zhgh/inclusive/service/impl/InclusiveBenefitServiceImpl.java new file mode 100644 index 00000000..2f1cef67 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/inclusive/service/impl/InclusiveBenefitServiceImpl.java @@ -0,0 +1,206 @@ +package com.budwk.app.zhgh.inclusive.service.impl; + +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.util.StrUtil; +import com.budwk.app.base.page.Pagination; +import com.budwk.app.base.service.impl.BaseServiceImpl; +import com.budwk.app.sys.models.Sys_dict; +import com.budwk.app.zhgh.inclusive.model.InclusiveBenefit; +import com.budwk.app.zhgh.inclusive.param.InclusiveBenefitPageForm; +import com.budwk.app.zhgh.inclusive.service.InclusiveBenefitService; +import com.budwk.app.zhgh.inclusive.vo.InclusiveBenefitVO; +import org.nutz.dao.Chain; +import org.nutz.dao.Cnd; +import org.nutz.dao.Dao; +import org.nutz.lang.Strings; +import org.nutz.lang.util.NutMap; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 幸福工荟业务实现。 + */ +@IocBean(args = {"refer:dao"}) +public class InclusiveBenefitServiceImpl extends BaseServiceImpl implements InclusiveBenefitService { + + private static final String TAG_DICT_CODE = "INCLUSIVE_BENEFIT_TYPE"; + + /** @param dao 当前项目的数据库访问对象 */ + public InclusiveBenefitServiceImpl(Dao dao) { + super(dao); + } + + @Override + public Pagination pageData(InclusiveBenefitPageForm pageForm, boolean onlyPublished) { + Cnd cnd = Cnd.where("delFlag", "=", false); + // H5 的可见性由服务端调用方决定,不能通过提交 status 参数读取草稿或下架内容。 + if (onlyPublished) { + cnd.and("status", "=", 1); + } + if (pageForm.getYear() != null) { + cnd.and("year", "=", pageForm.getYear()); + } + if (Strings.isNotBlank(pageForm.getTitle())) { + cnd.and(Cnd.likeEX("title", pageForm.getTitle())); + } + if (Strings.isNotBlank(pageForm.getTagType())) { + cnd.and("tagType", "=", pageForm.getTagType()); + } + cnd.desc("publishTime").desc("createdAt"); + Pagination pagination = listPage(pageForm.getPageNumber(), pageForm.getPageSize(), cnd); + Map tagNameMap = getTagNameMap(); + List list = pagination.getList().stream() + .map(benefit -> toVO(benefit, tagNameMap)) + .collect(Collectors.toList()); + return new Pagination<>(pagination.getPageNo(), pagination.getPageSize(), pagination.getTotalCount(), list); + } + + @Override + public InclusiveBenefitVO findVO(String id, boolean onlyPublished) { + if (StrUtil.isBlank(id)) { + return null; + } + Cnd cnd = Cnd.where("id", "=", id).and("delFlag", "=", false); + if (onlyPublished) { + cnd.and("status", "=", 1); + } + return toVO(fetch(cnd), getTagNameMap()); + } + + @Override + public List tagOptions() { + Sys_dict parent = dao().fetch(Sys_dict.class, Cnd.where("code", "=", TAG_DICT_CODE) + .and("disabled", "=", false).and("delFlag", "=", false)); + if (parent == null) { + return Collections.emptyList(); + } + return dao().query(Sys_dict.class, Cnd.where("parentId", "=", parent.getId()) + .and("disabled", "=", false).and("delFlag", "=", false).asc("location")) + .stream() + .map(dict -> NutMap.NEW().addv("text", dict.getName()).addv("value", dict.getCode())) + .collect(Collectors.toList()); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public void saveBenefit(InclusiveBenefit benefit, Integer status) { + validateStatus(status); + InclusiveBenefit target; + if (StrUtil.isBlank(benefit.getId())) { + target = new InclusiveBenefit(); + target.setYear(benefit.getYear() == null ? DateUtil.year(DateUtil.date()) : benefit.getYear()); + } else { + target = fetch(Cnd.where("id", "=", benefit.getId()).and("delFlag", "=", false)); + if (target == null) { + throw new IllegalArgumentException("幸福工荟不存在或已删除"); + } + target.setYear(benefit.getYear() == null ? target.getYear() : benefit.getYear()); + } + target.setTitle(benefit.getTitle()); + target.setSummary(benefit.getSummary()); + target.setTagType(benefit.getTagType()); + target.setContent(benefit.getContent()); + // 沿用西财的配置字段;本次迁移不将性别配置扩展为新的访问控制规则。 + target.setSexLimit(benefit.getSexLimit() == null ? 0 : benefit.getSexLimit()); + target.setCover(benefit.getCover()); + target.setLinkUrl(benefit.getLinkUrl()); + target.setStatus(status == null ? 0 : status); + if (Integer.valueOf(1).equals(target.getStatus())) { + target.setPublishTime(DateUtil.now()); + } + if (StrUtil.isBlank(target.getId())) { + insert(target); + } else { + update(target); + } + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public void updateStatus(String id, Integer status) { + validateStatus(status); + requireId(id); + InclusiveBenefit benefit = fetch(Cnd.where("id", "=", id).and("delFlag", "=", false)); + if (benefit == null) { + throw new IllegalArgumentException("幸福工荟不存在或已删除"); + } + Integer targetStatus = status == null ? 0 : status; + Chain chain = Chain.make("status", targetStatus); + if (Integer.valueOf(1).equals(targetStatus)) { + chain.add("publishTime", DateUtil.now()); + } + update(chain, Cnd.where("id", "=", id)); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public void deleteBenefit(String id) { + requireId(id); + InclusiveBenefit benefit = fetch(Cnd.where("id", "=", id).and("delFlag", "=", false)); + if (benefit == null) { + throw new IllegalArgumentException("幸福工荟不存在或已删除"); + } + vDelete(id); + } + + /** 写入操作只支持草稿和发布两种状态,避免非法值产生无法管理的内容。 */ + private void validateStatus(Integer status) { + if (status != null && status != 0 && status != 1) { + throw new IllegalArgumentException("发布状态只能为0或1"); + } + } + + /** 修改状态和删除必须明确指定记录,不允许缺失主键。 */ + private void requireId(String id) { + if (StrUtil.isBlank(id)) { + throw new IllegalArgumentException("内容ID不能为空"); + } + } + + /** 将实体映射为列表/详情对象,附加标签、性别配置和发布状态的展示名称。 */ + private InclusiveBenefitVO toVO(InclusiveBenefit benefit, Map tagNameMap) { + if (benefit == null) { + return null; + } + return new InclusiveBenefitVO() + .setId(benefit.getId()) + .setYear(benefit.getYear()) + .setTitle(benefit.getTitle()) + .setSummary(benefit.getSummary()) + .setTagType(benefit.getTagType()) + .setTagTypeName(tagNameMap.get(benefit.getTagType())) + .setContent(benefit.getContent()) + .setSexLimit(benefit.getSexLimit()) + .setSexLimitName(getSexLimitName(benefit.getSexLimit())) + .setCover(benefit.getCover()) + .setLinkUrl(benefit.getLinkUrl()) + .setStatus(benefit.getStatus()) + .setStatusName(Integer.valueOf(1).equals(benefit.getStatus()) ? "已发布" : "草稿") + .setCreatedAt(benefit.getCreatedAt()) + .setUpdatedAt(benefit.getUpdatedAt()) + .setPublishTime(benefit.getPublishTime()); + } + + private Map getTagNameMap() { + List options = tagOptions(); + // 字典编码重复时保留排序靠前的一项,避免整个列表查询失败。 + return options.stream().collect(Collectors.toMap(option -> option.getString("value"), + option -> option.getString("text"), (first, ignored) -> first)); + } + + private String getSexLimitName(Integer sexLimit) { + if (Integer.valueOf(1).equals(sexLimit)) { + return "男性"; + } + if (Integer.valueOf(2).equals(sexLimit)) { + return "女性"; + } + return "全部开放"; + } +} diff --git a/src/main/java/com/budwk/app/zhgh/inclusive/vo/InclusiveBenefitVO.java b/src/main/java/com/budwk/app/zhgh/inclusive/vo/InclusiveBenefitVO.java new file mode 100644 index 00000000..4e92280f --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/inclusive/vo/InclusiveBenefitVO.java @@ -0,0 +1,28 @@ +package com.budwk.app.zhgh.inclusive.vo; + +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * 幸福工荟列表和详情展示对象。 + */ +@Data +@Accessors(chain = true) +public class InclusiveBenefitVO { + private String id; + private Integer year; + private String title; + private String summary; + private String tagType; + private String tagTypeName; + private String content; + private Integer sexLimit; + private String sexLimitName; + private String cover; + private String linkUrl; + private Integer status; + private String statusName; + private Long createdAt; + private Long updatedAt; + private String publishTime; +} diff --git a/src/main/java/com/budwk/app/zhgh/map/TMapController.java b/src/main/java/com/budwk/app/zhgh/map/TMapController.java index 9b897354..5328912f 100644 --- a/src/main/java/com/budwk/app/zhgh/map/TMapController.java +++ b/src/main/java/com/budwk/app/zhgh/map/TMapController.java @@ -31,7 +31,7 @@ import javax.validation.Valid; @At("/platform/TMap") public class TMapController { - private static String KEY = "MLLBZ-GQECI-ASXG7-5GNOZ-XW2OF-H5BVH"; + private static String KEY = "NYNBZ-K62KI-DQJGU-U4NJ5-CSME7-ULFMU"; @Inject private SysConfigService configService; diff --git a/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/controller/MedicalCalculateMoneyController.java b/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/controller/MedicalCalculateMoneyController.java index 5dc44f46..1f20cb31 100644 --- a/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/controller/MedicalCalculateMoneyController.java +++ b/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/controller/MedicalCalculateMoneyController.java @@ -10,7 +10,6 @@ import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import lombok.extern.slf4j.Slf4j; import org.nutz.aop.interceptor.ioc.TransAop; -import org.nutz.dao.Chain; import org.nutz.dao.Cnd; import org.nutz.dao.Sqls; import org.nutz.dao.sql.Sql; @@ -24,11 +23,9 @@ import org.nutz.mvc.annotation.At; import org.nutz.mvc.annotation.Ok; import org.nutz.mvc.annotation.Param; -import javax.servlet.ServletOutputStream; import javax.servlet.http.HttpServletResponse; import java.math.BigDecimal; import java.util.List; -import java.util.Objects; /** * @author zhf @@ -95,7 +92,7 @@ public class MedicalCalculateMoneyController { * 按页面测算结果分配补助,并保存该年度结余。 * 爱心金额显式按参数名绑定,避免 Nutz 将 BigDecimal 当作对象解析其内部字段。 * - * @param data 申请记录的 JSON 数组,包含申请 id 和最终测算金额 forecastSubsidyMoney + * @param data 非空申请 JSON 数组,每项包含 id 和非负金额 forecastSubsidyMoney;保存时按笔四舍五入到元 * @param ratio 页面当前补助比例,单位为百分比 * @param aidFund 本年度会员缴费总额,单位为元 * @param inComeSum 本年度收入累计总额,单位为元 @@ -109,46 +106,12 @@ public class MedicalCalculateMoneyController { @SaCheckPermission("medicalMutualAid.medical.calculateMoney") public Result doHandle(String data, float ratio, double aidFund, double inComeSum, @Param("loveSubsidyMoney") BigDecimal loveSubsidyMoney){ - // 延续原整数参数未传时按零处理的行为,同时保留实际爱心补助的小数精度。 - if (loveSubsidyMoney == null) { - loveSubsidyMoney = BigDecimal.ZERO; + // 空请求不执行分配,避免仅更新年度结余而未保存任何申请金额。 + if (Lang.isEmpty(data)) { + return Result.error("请提交需要分配的申请记录"); } -//获取申请最新年份 - Sql yearSql = Sqls.create("SELECT MAX(YEAR(applyTime)) AS currentYear FROM medical_apply"); - yearSql.setCallback(Sqls.callback.longs()); - medicalCalculateMoneyService.execute(yearSql); - Integer year = yearSql.getInt(); - List list = Json.fromJsonAsList(MedicalApply.class, data); - list.forEach(v -> { - v.setSubsidyMoney(v.getForecastSubsidyMoney()); - }); - medicalCalculateMoneyService.updateIgnoreNull(list); - //补助总额 - BigDecimal subsidyMoney = list.stream() - .map(MedicalApply::getForecastSubsidyMoney) - .filter(Objects::nonNull) // 防御性:避免 null - .reduce(BigDecimal.ZERO, BigDecimal::add); - BigDecimal incomeRounded = BigDecimal.valueOf(Math.round(inComeSum)); - BigDecimal thisYearBalance = incomeRounded - .subtract(subsidyMoney) - .subtract(loveSubsidyMoney); - - - MedicalMoneyDetail moneyDetail = medicalCalculateMoneyService.dao().fetch(MedicalMoneyDetail.class, Cnd.where("year", "=", year)); - if (Lang.isEmpty(moneyDetail)) { - moneyDetail = new MedicalMoneyDetail(); - moneyDetail.setYear(year); - moneyDetail.setAidFund(new BigDecimal(aidFund)); - moneyDetail.setRatio(ratio); - moneyDetail.setLastYearBalance(thisYearBalance); - medicalCalculateMoneyService.insert(moneyDetail); - } else { - moneyDetail.setAidFund(new BigDecimal(aidFund)); - moneyDetail.setRatio(ratio); - moneyDetail.setLastYearBalance(thisYearBalance); - medicalCalculateMoneyService.updateIgnoreNull(moneyDetail); - } + medicalCalculateMoneyService.saveAllocation(list, ratio, aidFund, inComeSum, loveSubsidyMoney); return Result.success(); } diff --git a/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/service/MedicalCalculateMoneyService.java b/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/service/MedicalCalculateMoneyService.java index 19a75d43..191f23ad 100644 --- a/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/service/MedicalCalculateMoneyService.java +++ b/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/service/MedicalCalculateMoneyService.java @@ -1,9 +1,11 @@ package com.budwk.app.zhgh.staffbenefit.medicalMutualAid.medical.service; import com.budwk.app.base.service.BaseService; +import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.medical.model.MedicalApply; import org.nutz.lang.util.NutMap; import javax.servlet.http.HttpServletResponse; +import java.math.BigDecimal; import java.util.List; public interface MedicalCalculateMoneyService extends BaseService { @@ -16,6 +18,19 @@ public interface MedicalCalculateMoneyService extends BaseService { List getApplyData(double ratio); + /** + * 按笔取整并保存分配结果,使用实际写入的补助合计更新年度结余。 + * 返回类型为 void;申请金额和年度结余在同一事务中保存,校验失败时抛出业务异常。 + * + * @param list 非空申请列表,每项使用 id 和非负的 forecastSubsidyMoney,其他字段不参与更新 + * @param ratio 当前分配比例,单位为百分比 + * @param aidFund 本年度会员缴费总额,单位为元 + * @param inComeSum 本年度收入累计总额,单位为元,沿用原收入取整口径 + * @param loveSubsidyMoney 实际爱心补助合计,保留小数精度,null 按零处理 + */ + void saveAllocation(List list, float ratio, double aidFund, double inComeSum, + BigDecimal loveSubsidyMoney); + /** * 按列表测算口径导出申请数据;返回类型为 void,Excel 列对应申请信息、费用和测算金额。 * diff --git a/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/service/impl/MedicalCalculateMoneyServiceImpl.java b/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/service/impl/MedicalCalculateMoneyServiceImpl.java index 1c8c5042..308d165c 100644 --- a/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/service/impl/MedicalCalculateMoneyServiceImpl.java +++ b/src/main/java/com/budwk/app/zhgh/staffbenefit/medicalMutualAid/medical/service/impl/MedicalCalculateMoneyServiceImpl.java @@ -5,6 +5,7 @@ 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.util.StrUtil; +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.flow.enums.ProcessInstanceStateEnum; @@ -14,10 +15,13 @@ import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.medical.model.MedicalMon import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.medical.service.MedicalApplyService; import com.budwk.app.zhgh.staffbenefit.medicalMutualAid.medical.service.MedicalCalculateMoneyService; import org.apache.poi.ss.usermodel.Workbook; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.dao.Chain; 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.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.lang.Lang; @@ -27,8 +31,10 @@ import javax.servlet.http.HttpServletResponse; import java.math.BigDecimal; import java.math.RoundingMode; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Objects; +import java.util.Set; /** * @author zhf @@ -99,7 +105,7 @@ public class MedicalCalculateMoneyServiceImpl extends BaseServiceImpl implements * 返回最新申请年度的已办结测算列表,供页面和导出共用。 * * @param ratio 补助比例的百分数,例如 60 表示按 60% 测算 - * @return 每项为申请及费用字段的 NutMap;subsidyMoney、forecastSubsidyMoney 按笔四舍五入到整数元 + * @return 每项为申请及费用字段的 NutMap;subsidyMoney 保留已保存金额,forecastSubsidyMoney 按笔四舍五入到整数元 */ @Override public List getApplyData(double ratio) { @@ -114,18 +120,68 @@ public class MedicalCalculateMoneyServiceImpl extends BaseServiceImpl implements applyList.forEach(apply -> { apply.putAll(medicalApplyService.calcMedicalMoney(apply.getString("id"),null, ratio)); - // 本页按每笔补助四舍五入到元,列表、导出和按比例分配共用取整后的金额;空金额按零处理。 - for (String field : List.of("subsidyMoney", "forecastSubsidyMoney")) { - String money = apply.getString(field); - apply.put(field, StrUtil.isBlank(money) ? BigDecimal.ZERO - : new BigDecimal(money).setScale(0, RoundingMode.HALF_UP)); - } + // 仅对待分配的测算金额取整;已保存金额保留原值,避免掩盖尚未重新分配的历史小数。 + String money = apply.getString("forecastSubsidyMoney"); + apply.put("forecastSubsidyMoney", StrUtil.isBlank(money) ? BigDecimal.ZERO + : new BigDecimal(money).setScale(0, RoundingMode.HALF_UP)); }); return applyList; } + @Override + @Aop(TransAop.READ_COMMITTED) + public void saveAllocation(List list, float ratio, double aidFund, double inComeSum, + BigDecimal loveSubsidyMoney) { + // 先校验整批参数;空金额不能当作零覆盖,重复申请不能重复计入年度支出。 + if (Lang.isEmpty(list)) { + throw new BaseException("请提交需要分配的申请记录"); + } + Set applyIds = new HashSet<>(); + for (MedicalApply apply : list) { + if (apply == null || StrUtil.isBlank(apply.getId()) || apply.getForecastSubsidyMoney() == null + || apply.getForecastSubsidyMoney().signum() < 0) { + throw new BaseException("申请记录及补助金额不能为空,补助金额不能为负数"); + } + if (!applyIds.add(apply.getId())) { + throw new BaseException("申请记录重复,请刷新后重新分配"); + } + } + + Sql yearSql = Sqls.create("SELECT MAX(YEAR(applyTime)) AS currentYear FROM medical_apply"); + yearSql.setCallback(Sqls.callback.longs()); + dao().execute(yearSql); + Integer year = yearSql.getInt(); + + BigDecimal subsidyMoney = BigDecimal.ZERO; + for (MedicalApply apply : list) { + // 与测算列表使用相同的按笔取整规则,两列写入同一个整数元金额。 + BigDecimal money = apply.getForecastSubsidyMoney().setScale(0, RoundingMode.HALF_UP); + dao().update(MedicalApply.class, Chain.make("forecastSubsidyMoney", money) + .add("subsidyMoney", money), Cnd.where("id", "=", apply.getId())); + subsidyMoney = subsidyMoney.add(money); + } + // 结余扣除实际保存的整数补助合计;爱心补助独立保留小数,不受常规补助取整影响。 + BigDecimal thisYearBalance = BigDecimal.valueOf(Math.round(inComeSum)) + .subtract(subsidyMoney) + .subtract(loveSubsidyMoney == null ? BigDecimal.ZERO : loveSubsidyMoney); + MedicalMoneyDetail moneyDetail = dao().fetch(MedicalMoneyDetail.class, Cnd.where("year", "=", year)); + if (Lang.isEmpty(moneyDetail)) { + moneyDetail = new MedicalMoneyDetail(); + moneyDetail.setYear(year); + moneyDetail.setAidFund(BigDecimal.valueOf(aidFund)); + moneyDetail.setRatio(ratio); + moneyDetail.setLastYearBalance(thisYearBalance); + insert(moneyDetail); + } else { + moneyDetail.setAidFund(BigDecimal.valueOf(aidFund)); + moneyDetail.setRatio(ratio); + moneyDetail.setLastYearBalance(thisYearBalance); + updateIgnoreNull(moneyDetail); + } + } + @Override public void doExport(double ratio, HttpServletResponse response) { // 导出复用列表查询结果,保证服务端测算金额与页面初次加载时的口径一致。 @@ -168,8 +224,8 @@ public class MedicalCalculateMoneyServiceImpl extends BaseServiceImpl implements ExcelExportEntity loveSubsidyMoneyExcelExportEntity = new ExcelExportEntity("爱心补助", "loveSubsidyMoney", 20); loveSubsidyMoneyExcelExportEntity.setType(10); exportEntities.add(loveSubsidyMoneyExcelExportEntity); - // 页面“当前补助金额”展示的是测算字段,导出同名列保持相同取值。 - ExcelExportEntity subsidyMoneyExcelExportEntity = new ExcelExportEntity("当前补助金额", "forecastSubsidyMoney", 20); + // 当前补助读取已保存值,按比例补助读取待分配值,与页面两列含义保持一致。 + ExcelExportEntity subsidyMoneyExcelExportEntity = new ExcelExportEntity("当前补助金额", "subsidyMoney", 20); subsidyMoneyExcelExportEntity.setType(10); exportEntities.add(subsidyMoneyExcelExportEntity); ExcelExportEntity forecastSubsidyMoneyExcelExportEntity = new ExcelExportEntity("按" + ratio + "%补助金额", "forecastSubsidyMoney", 20); @@ -181,10 +237,10 @@ public class MedicalCalculateMoneyServiceImpl extends BaseServiceImpl implements try { List moneyFields = List.of("hospitalSumMoney$", "reimbursementMoney$", "singleBearMoney$", "personExpenseMoney$", "outpatientServiceMoney$", "bxyMoney$", "totalMoney", - "loveSubsidyMoney", "forecastSubsidyMoney", "medicalSubsidyMoney"); + "loveSubsidyMoney", "subsidyMoney", "forecastSubsidyMoney", "medicalSubsidyMoney"); NutMap summary = new NutMap(); summary.put("username", "合计"); - // 按金额字段精确累加;两个测算金额列共用的字段只汇总一次,空金额按零处理。 + // 已保存补助和测算补助分别精确累加,空金额按零处理。 for (String field : moneyFields) { BigDecimal sum = BigDecimal.ZERO; for (NutMap row : list) { diff --git a/src/main/resources/static/components/module/activity/ActivityScheduleCalendar.vue b/src/main/resources/static/components/module/activity/ActivityScheduleCalendar.vue index 371d58e6..3589f44f 100644 --- a/src/main/resources/static/components/module/activity/ActivityScheduleCalendar.vue +++ b/src/main/resources/static/components/module/activity/ActivityScheduleCalendar.vue @@ -1,12 +1,14 @@ + + + new Vue({ el: "#app", @@ -187,6 +222,7 @@ layout("/layouts/platform.html"){ "info": info, "basic-form": basicForm, "make-qrcode": makeQrcode, + "course-manage": courseManage, }, data() { return { @@ -208,6 +244,10 @@ layout("/layouts/platform.html"){ } }, computed: { + // 与后端关键内容编辑角色保持一致,负责人通过子活动管理维护签到。 + canManageActivity() { + return this.$auth.hasRoleOr(["SYSADMIN", "SCHOOL_UNION_ADMIN", "SCHOOL_UNION_ACTIVITY_ADMIN"]) + }, // 页签沿用“设为模板”按钮的角色范围,不新增权限标识。 canManageTemplate() { return this.$auth.hasRoleOr(["SYSADMIN", "SCHOOL_UNION_ADMIN"]) @@ -218,7 +258,6 @@ layout("/layouts/platform.html"){ const address = this.pageDataUrl ? this.pageDataUrl : loc() + "/pageData" this.tableLoading = true return this.$axios.post(address, data ? data : this.pageForm).then((res) => { - this.tableLoading = false if (res.code === 0) { this.tableData = res.data.list this.pageForm.totalCount = res.data.totalCount @@ -227,6 +266,8 @@ layout("/layouts/platform.html"){ }) } return res + }).finally(() => { + this.tableLoading = false }) }, // 切换页签时按活动表中的模板状态查询,并重置到第一页。 @@ -281,10 +322,20 @@ layout("/layouts/platform.html"){ }) }, openEdit(row) { + if (!this.canManageActivity) { + this.$message.warning('活动关键信息仅管理员可修改,请使用子活动管理') + return + } this.$refs.guava.edit(() => { this.$refs.formRef.initData(row) }) }, + // 使用 guava 的现有公共页面插槽,返回后保留主列表的筛选条件和页码。 + openCourseManage(row) { + this.$refs.guava.public(() => { + this.$refs.courseManageRef.initData(row) + }) + }, openUrlEdit() { if (this.urlEditInitialized) { return diff --git a/src/main/resources/views/platform/zhgh/activity/trainSignUp/manage/responsibleForm.js b/src/main/resources/views/platform/zhgh/activity/trainSignUp/manage/responsibleForm.js new file mode 100644 index 00000000..c810f0e7 --- /dev/null +++ b/src/main/resources/views/platform/zhgh/activity/trainSignUp/manage/responsibleForm.js @@ -0,0 +1,133 @@ +const responsibleForm = { + template: /*language=HTML*/ ` +
+ + + + + + + + + + + + + + + + +
确定后回填当前表单,保存或提交活动后生效。
+ + 取消 + 确定 + +
+
+ `, + data() { + return { + dialogVisible: false, + tableLoading: false, + course: null, + selectedIds: [], + responsibleList: [], + userOptions: [], + queryVersion: 0, + } + }, + methods: { + // 打开当前子活动的负责人副本,关闭或取消弹框不会更改业务表单。 + onOpen(course) { + this.queryVersion += 1 + this.$set(this, 'course', course) + const list = clone(course.responsibleList || []) + this.$set(this, 'responsibleList', list) + this.$set(this, 'selectedIds', list.map(item => item.userId)) + this.$set(this, 'userOptions', list.map(item => ({ + id: item.userId, username: item.username, loginname: item.loginname, mobile: item.mobile + }))) + this.$set(this, 'tableLoading', false) + this.$set(this, 'dialogVisible', true) + }, + // query 为姓名或工号;返回 Result.data 用户数组,使用 id、username、loginname、mobile。 + // 合并已选项以保持多人回显,并忽略过期请求,避免快速搜索覆盖最新结果。 + queryUsers(query) { + const version = ++this.queryVersion + if (!query || !query.trim()) { + this.$set(this, 'tableLoading', false) + return + } + this.$set(this, 'tableLoading', true) + return this.$axios.post('/open/common/userOptions', {query: query.trim()}) + .then((res) => { + if (version !== this.queryVersion || !this.dialogVisible) { + return + } + if (res.code !== 0) { + this.$message.warning(res.msg) + return + } + const selected = this.responsibleList.map(item => ({ + id: item.userId, username: item.username, loginname: item.loginname, mobile: item.mobile + })) + const options = new Map(selected.map(item => [item.id, item])) + const results = res.data || [] + results.forEach(item => options.set(item.id, item)) + this.$set(this, 'userOptions', Array.from(options.values())) + }) + .finally(() => { + if (version === this.queryVersion) { + this.$set(this, 'tableLoading', false) + } + }) + }, + // 多选结果一人一行,已选用户保留手工修改的联系方式,新选用户带出系统联系电话。 + responsibleChange(ids) { + const oldRows = new Map(this.responsibleList.map(item => [item.userId, item])) + const options = new Map(this.userOptions.map(item => [item.id, item])) + const rows = ids.map((id) => { + if (oldRows.has(id)) { + return oldRows.get(id) + } + const user = options.get(id) + return {userId: id, username: user.username, loginname: user.loginname, mobile: user.mobile || ''} + }) + this.$set(this, 'selectedIds', ids) + this.$set(this, 'responsibleList', rows) + }, + // 联系方式按每个人校验;使用新数组回填,弹框副本不再与业务表单共用对象。 + onConfirm() { + const missing = this.responsibleList.find(item => !item.mobile || !item.mobile.trim()) + if (missing) { + this.$message.warning('请填写' + missing.username + '的联系方式') + return + } + if (this.responsibleList.some(item => item.mobile.trim().length > 20)) { + this.$message.warning('负责人联系方式不能超过20个字符') + return + } + const list = this.responsibleList.map((item, index) => ({ + userId: item.userId, username: item.username, loginname: item.loginname, + mobile: item.mobile.trim(), orderNum: index + 1 + })) + this.$set(this.course, 'responsibleList', list) + // 旧页面保留第一位联系人展示;完整多人数据通过 responsibleList 保存。 + this.$set(this.course, 'courseInstructor', list.length ? list[0].username : '') + this.$set(this.course, 'courseInstructorMobile', list.length ? list[0].mobile : '') + this.closeDialog() + }, + closeDialog() { + this.queryVersion += 1 + this.$set(this, 'dialogVisible', false) + this.$set(this, 'tableLoading', false) + }, + }, +} diff --git a/src/main/resources/views/platform/zhgh/huazhu/index.html b/src/main/resources/views/platform/zhgh/huazhu/index.html new file mode 100644 index 00000000..f14e754d --- /dev/null +++ b/src/main/resources/views/platform/zhgh/huazhu/index.html @@ -0,0 +1,122 @@ + +
+ + + +
+ + diff --git a/src/main/resources/views/platform/zhgh/inclusive/index.html b/src/main/resources/views/platform/zhgh/inclusive/index.html new file mode 100644 index 00000000..a52ee216 --- /dev/null +++ b/src/main/resources/views/platform/zhgh/inclusive/index.html @@ -0,0 +1,187 @@ + +
+ + + + +
+ + diff --git a/src/main/resources/views/platform/zhgh/staffbenefit/medicalMutualAid/medical/calculateMoney/index.html b/src/main/resources/views/platform/zhgh/staffbenefit/medicalMutualAid/medical/calculateMoney/index.html index 3cbca16e..ca2522cb 100644 --- a/src/main/resources/views/platform/zhgh/staffbenefit/medicalMutualAid/medical/calculateMoney/index.html +++ b/src/main/resources/views/platform/zhgh/staffbenefit/medicalMutualAid/medical/calculateMoney/index.html @@ -144,6 +144,7 @@ layout("/layouts/platform.html"){ :width="column.width" :fixed="column.fixed" :sortable="column.sortable" + :formatter="formatSubsidyMoney" show-overflow-tooltip sortable v-for="column in tableColumns" @@ -217,7 +218,7 @@ layout("/layouts/platform.html"){ {prop: 'bxyMoney$', label: '非住院肾衰竭治疗、靶向药金额', width: "120"}, {prop: 'totalMoney', label: '合计金额', width: "120"}, {prop: 'loveSubsidyMoney', label: '爱心补助', width: "120"}, - {prop: 'forecastSubsidyMoney', label: '当前补助金额', width: "120"}, + {prop: 'subsidyMoney', label: '当前补助金额', width: "120"}, ], } }, @@ -322,7 +323,7 @@ layout("/layouts/platform.html"){ scrollBody.scrollTop = Math.max(0, scrollBody.scrollTop + delta) } }, - async grantOrIntoChange(key, val) { + grantOrIntoChange(key, val) { if (val === '') { if (key === 'grants') { this.calcData.schoolGrants = 0 @@ -330,7 +331,11 @@ layout("/layouts/platform.html"){ this.calcData.unionInto = 0 } } - const resp = await this.$axios.post(loc() + '/setGrantsOrUnionInto', {keys: key, value: val}) + return this.$axios.post(loc() + '/setGrantsOrUnionInto', {keys: key, value: val}) + }, + // 已保存补助去掉末尾的零;历史非零小数保持真实值,需完成分配后才变为整数。 + formatSubsidyMoney(row, column, value) { + return column.property === 'subsidyMoney' && value != null && value !== '' ? Number(value) : value }, getSummaries(param) { const {columns, data} = param @@ -343,9 +348,13 @@ layout("/layouts/platform.html"){ const values = data.map(i => Number(i[column.property])) if (values && values.length > 0) { const everyColumnSum = values.reduce((a, b) => a + b) - // 补助合计累加已按笔取整的金额,其他费用仍保留两位小数。 - const digits = ['subsidyMoney', 'forecastSubsidyMoney'].includes(column.property) ? 0 : 2 - sums[index] = isNaN(everyColumnSum) ? 0 : everyColumnSum.toFixed(digits) + // 测算合计显示整数;已保存合计仅去掉末尾零,保留历史金额中的真实角分。 + if (column.property === 'subsidyMoney') { + sums[index] = isNaN(everyColumnSum) ? 0 : Number(everyColumnSum.toFixed(2)) + } else { + const digits = column.property === 'forecastSubsidyMoney' ? 0 : 2 + sums[index] = isNaN(everyColumnSum) ? 0 : everyColumnSum.toFixed(digits) + } } } }) @@ -358,13 +367,18 @@ layout("/layouts/platform.html"){ ratio: this.ratio}) }, doHandle() { - this.$confirm('确定要按此比例来补助吗?', '提示', { + return this.$confirm('确定要按此比例来补助吗?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }).then(() => { - this.$axios.post("/platform/medicalMutualAid/medical/calculateMoney/doHandle", { - data: JSON.stringify(this.applyListData), + // 仅提交本次分配所需的申请标识和金额,保存及刷新结束后统一解除按钮加载状态。 + this.formLoading = true + return this.$axios.post("/platform/medicalMutualAid/medical/calculateMoney/doHandle", { + data: JSON.stringify(this.applyListData.map(row => ({ + id: row.id, + forecastSubsidyMoney: row.forecastSubsidyMoney + }))), ratio: this.ratio, aidFund: this.calcData.aidFundMoney, inComeSum: this.inComeSum, @@ -372,28 +386,28 @@ layout("/layouts/platform.html"){ }).then(res => { if (res.code === 0) { this.$message.success("操作成功") - this.getData() - this.getApplyData() + return this.getData() } + }).finally(() => { + this.formLoading = false }) }) }, - async getMedicalSetting() { - try { - const res = await this.$axios.post("/platform/medicalMutualAid/medical/basicSetting/getSetting") + getMedicalSetting() { + return this.$axios.post("/platform/medicalMutualAid/medical/basicSetting/getSetting").then(res => { if (res.code === 0) { this.medicalSetting = res.data } - } catch (e) { + }).catch(e => { this.$message.error(e.message) - } + }) }, getData() { this.formLoading = true - this.$axios.post("/platform/medicalMutualAid/medical/calculateMoney/getData").then(res => { + return this.$axios.post("/platform/medicalMutualAid/medical/calculateMoney/getData").then(res => { if (res.code === 0) { if (res.data.ratio) { this.ratio = res.data.ratio @@ -401,14 +415,14 @@ layout("/layouts/platform.html"){ this.ratio = this.medicalSetting.subsidyRatio } this.calcData = res.data - this.getApplyData() + return this.getApplyData() } }).finally(()=>{ this.formLoading=false }) }, getApplyData() { - this.$axios.post("/platform/medicalMutualAid/medical/calculateMoney/getApplyData", {ratio: this.ratio}).then(res => { + return this.$axios.post("/platform/medicalMutualAid/medical/calculateMoney/getApplyData", {ratio: this.ratio}).then(res => { if (res.code === 0) { this.applyListData = res.data } @@ -416,9 +430,8 @@ layout("/layouts/platform.html"){ }, }, - async created() { - await this.getMedicalSetting() - this.getData() + created() { + this.getMedicalSetting().then(() => this.getData()) }, mounted() { this.initTableScroll() diff --git a/src/main/resources/views/platform/zhgh/staffbenefit/medicalMutualAid/medical/statistics/index.html b/src/main/resources/views/platform/zhgh/staffbenefit/medicalMutualAid/medical/statistics/index.html index b72c12ac..e8c4b763 100644 --- a/src/main/resources/views/platform/zhgh/staffbenefit/medicalMutualAid/medical/statistics/index.html +++ b/src/main/resources/views/platform/zhgh/staffbenefit/medicalMutualAid/medical/statistics/index.html @@ -131,6 +131,7 @@ layout("/layouts/platform.html"){ :width="column.width" :fixed="column.fixed" :sortable="column.sortable" + :formatter="formatSubsidyMoney" show-overflow-tooltip sortable v-for="column in tableColumns" @@ -394,6 +395,10 @@ layout("/layouts/platform.html"){ }) }) }, + // 整数补助不显示 .00;尚未重新分配的历史小数保留原值,避免显示金额与数据库不符。 + formatSubsidyMoney(row, column, value) { + return column.property === 'subsidyMoney' && value != null && value !== '' ? Number(value) : value + }, getSummaries(param) { const {columns, data} = param const sums = [] @@ -403,7 +408,9 @@ layout("/layouts/platform.html"){ const values = data.map(i => Number(i[column.property])) if (values && values.length > 0) { const everyColumnSum = values.reduce((a, b) => a + b) - sums[index] = isNaN(everyColumnSum) ? 0 : everyColumnSum.toFixed(2) + // 仅补助合计去掉末尾零;爱心补助和医疗费用仍按两位小数显示。 + const total = isNaN(everyColumnSum) ? 0 : everyColumnSum.toFixed(2) + sums[index] = column.property === 'subsidyMoney' ? Number(total) : total } } }) diff --git a/src/main/resources/views/platform/zhghh5/dayofficework/honor/manage/index.html b/src/main/resources/views/platform/zhghh5/dayofficework/honor/manage/index.html index 60ccd846..065ed538 100644 --- a/src/main/resources/views/platform/zhghh5/dayofficework/honor/manage/index.html +++ b/src/main/resources/views/platform/zhghh5/dayofficework/honor/manage/index.html @@ -30,6 +30,25 @@ layout("/layouts/platform_h5.html"){ + +
+
+ + 荣誉年度 +
+ + + + +
+
@@ -38,7 +57,7 @@ layout("/layouts/platform_h5.html"){

致敬劳模先进,礼赞奋斗精神。

- 加载中... + 加载中...
@@ -57,7 +76,10 @@ layout("/layouts/platform_h5.html"){
- + +
可切换年度查看往年荣誉
+
@@ -65,7 +87,7 @@ layout("/layouts/platform_h5.html"){ + diff --git a/src/main/resources/views/platform/zhghh5/inclusive/index.html b/src/main/resources/views/platform/zhghh5/inclusive/index.html new file mode 100644 index 00000000..6c433da8 --- /dev/null +++ b/src/main/resources/views/platform/zhghh5/inclusive/index.html @@ -0,0 +1,151 @@ + + +
+ + +
+ + + + +
+
+
+ + +
+
+
+
{{item.tagTypeName || '幸福工荟'}}
+
{{item.title}}
+
{{item.summary}}
+
{{formatDate(item.publishTime || item.createdAt)}}
+ +
+
+
+
+
+ +
暂无数据
+
+
+
+ + diff --git a/src/main/resources/views/platform/zhghh5/sys/home/home.js b/src/main/resources/views/platform/zhghh5/sys/home/home.js index 72a722a5..d5715047 100644 --- a/src/main/resources/views/platform/zhghh5/sys/home/home.js +++ b/src/main/resources/views/platform/zhghh5/sys/home/home.js @@ -306,7 +306,7 @@ const home = { action: "查看信息", asset: "/assets/mobile/img/home/v4/feature-inclusive-v8.png", className: "feature-card--inclusive", - href: "" + href: "/platform/h5/inclusive" } ], appLogo: "",