diff --git a/src/main/java/com/budwk/app/sys/services/impl/SysUserServiceImpl.java b/src/main/java/com/budwk/app/sys/services/impl/SysUserServiceImpl.java index 21eb8c7..7b332c8 100644 --- a/src/main/java/com/budwk/app/sys/services/impl/SysUserServiceImpl.java +++ b/src/main/java/com/budwk/app/sys/services/impl/SysUserServiceImpl.java @@ -309,9 +309,9 @@ public class SysUserServiceImpl extends BaseServiceImpl implements Sys String decodePwd = Base64Decoder.decodeStr(passowrd); String hashedPassword = PwdUtil.getPassword(decodePwd, user.getSalt()); if (!Strings.sNull(hashedPassword).equalsIgnoreCase(user.getPassword())) { -// if (Globals.sso) { + if (Globals.sso) { throw new BaseException("用户名或者密码不正确"); -// } + } } user = this.fetchLinks(user, "unit"); if (Lang.isNotEmpty(user.getUnit()) && StrUtil.isNotBlank(user.getUnit().getUnionId())) { diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourLedgerController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourLedgerController.java index 7bbbbdf..2da33f5 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourLedgerController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourLedgerController.java @@ -26,6 +26,7 @@ import com.budwk.app.zhgh.dayofficework.tour.models.TourTravelAgency; import com.budwk.app.zhgh.dayofficework.tour.service.TourLedgerDirectRelativeService; import com.budwk.app.zhgh.dayofficework.tour.service.TourLedgerFamilyService; import com.budwk.app.zhgh.dayofficework.tour.service.TourLedgerService; +import com.budwk.app.zhgh.dayofficework.tour.service.TourParticipantRosterService; import org.apache.poi.ss.usermodel.Workbook; import org.apache.poi.hssf.usermodel.HSSFWorkbook; import org.apache.poi.ss.usermodel.BorderStyle; @@ -90,6 +91,9 @@ public class TourLedgerController { @Inject private TourLedgerDirectRelativeService tourLedgerDirectRelativeService; + @Inject + private TourParticipantRosterService tourParticipantRosterService; + @At("") @Ok("beetl:/platform/zhgh/dayofficework/Tour/ledger/index.html") @SaCheckPermission("tour.ledger") @@ -216,6 +220,8 @@ public class TourLedgerController { if (ledger == null) { return Result.error("台账记录不存在"); } + // Reset the participant roster before deleting the ledger record from the admin ledger page. + tourParticipantRosterService.markNotJoinedByLedger(ledger); tourLedgerFamilyService.clear(Cnd.where(TourLedgerFamily::getLedgerId, "=", id)); tourLedgerService.delete(id); return Result.success(); diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourMySignupController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourMySignupController.java index 8984df2..05f6ee1 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourMySignupController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourMySignupController.java @@ -31,6 +31,7 @@ import com.budwk.app.zhgh.dayofficework.tour.service.TourLedgerDirectRelativeSer import com.budwk.app.zhgh.dayofficework.tour.service.TourLedgerFamilyService; import com.budwk.app.zhgh.dayofficework.tour.service.TourLedgerService; import com.budwk.app.zhgh.dayofficework.tour.service.TourMatterService; +import com.budwk.app.zhgh.dayofficework.tour.service.TourParticipantRosterService; import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.dao.Cnd; import org.nutz.dao.Sqls; @@ -83,6 +84,9 @@ public class TourMySignupController { @Inject private TourMatterService tourMatterService; + @Inject + private TourParticipantRosterService tourParticipantRosterService; + @Inject private SysDictService sysDictService; @@ -507,6 +511,8 @@ public class TourMySignupController { return workflowResult; } } + // Signup modification keeps the staff member joined and refreshes the linked ledger id. + tourParticipantRosterService.markJoinedByLedger(ledger); return Result.success().addMsg(approvalRequired ? "修改已提交,等待审核" : "修改成功"); } @@ -529,6 +535,8 @@ public class TourMySignupController { if (signupEndResult != null) { return signupEndResult; } + // Reset the roster before deleting the ledger record, because the ledger still carries setting linkage here. + tourParticipantRosterService.markNotJoinedByLedger(ledger); tourLedgerFamilyService.clear(Cnd.where(TourLedgerFamily::getLedgerId, "=", ledger.getId())); tourLedgerDirectRelativeService.clear(Cnd.where(TourLedgerDirectRelative::getLedgerId, "=", ledger.getId())); tourLedgerService.clear(Cnd.where(TourLedger::getId, "=", ledger.getId())); @@ -548,6 +556,8 @@ public class TourMySignupController { if (signupEndResult != null) { return signupEndResult; } + // Reset the roster before deleting the ledger record, because the ledger still carries setting linkage here. + tourParticipantRosterService.markNotJoinedByLedger(ledger); tourLedgerFamilyService.clear(Cnd.where(TourLedgerFamily::getLedgerId, "=", ledger.getId())); tourLedgerDirectRelativeService.clear(Cnd.where(TourLedgerDirectRelative::getLedgerId, "=", ledger.getId())); tourLedgerService.clear(Cnd.where(TourLedger::getId, "=", ledger.getId())); diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourParticipantRosterController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourParticipantRosterController.java new file mode 100644 index 0000000..99538e0 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourParticipantRosterController.java @@ -0,0 +1,366 @@ +package com.budwk.app.zhgh.dayofficework.tour.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.hutool.core.util.StrUtil; +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.zhgh.dayofficework.tour.service.TourParticipantRosterService; +import org.nutz.dao.Cnd; +import org.nutz.dao.Sqls; +import org.nutz.dao.sql.Sql; +import org.nutz.dao.util.cri.SqlExpressionGroup; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.json.Json; +import org.nutz.lang.util.NutMap; +import org.nutz.mvc.annotation.At; +import org.nutz.mvc.annotation.Ok; +import org.nutz.mvc.annotation.Param; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * 疗休养人员名单统计查询控制器。 + * 名单表保存可参与人员基础信息,报名线路、成团等信息通过台账补充读取。 + */ +@IocBean +@Ok("json:full") +@At("/platform/tour/participantRoster") +public class TourParticipantRosterController { + + @Inject + private TourParticipantRosterService tourParticipantRosterService; + + /** + * 打开 PC 端疗休养人员名单查询页面。 + */ + @At("") + @Ok("beetl:/platform/zhgh/dayofficework/Tour/participantRoster/index.html") + @SaCheckPermission("tour.participantRoster") + public void index() { + } + + /** + * 按年度范围和可选报名维度查询人员名单。 + */ + @At + @SaCheckPermission("tour.participantRoster") + public Result pageData(PageForm pageForm, Integer startYear, Integer endYear, String settingId, String matterId, + String keyword, String unitName, String unionId, Boolean joined, String lineType, + String groupCode, String undertakeUnionId) { + Result yearResult = checkYearRange(startYear, endYear); + if (yearResult != null) { + return yearResult; + } + Cnd cnd = buildQueryCnd(startYear, endYear, settingId, matterId, keyword, unitName, unionId, joined, + lineType, groupCode, undertakeUnionId); + + Sql countSql = Sqls.create(""" + SELECT COUNT(1) + FROM tour_participant_roster r + INNER JOIN tour_setting s ON s.id = r.settingId AND s.delFlag = 0 + LEFT JOIN tour_ledger t ON t.id = r.ledgerId AND t.delFlag = 0 + LEFT JOIN tour_matter m ON m.id = t.matterId AND m.delFlag = 0 + LEFT JOIN tour_line l ON l.id = t.lineId AND l.delFlag = 0 + $condition + """); + countSql.setCondition(cnd); + countSql.setCallback(Sqls.callback.integer()); + tourParticipantRosterService.dao().execute(countSql); + + Sql listSql = Sqls.create(""" + SELECT + r.id, + s.`year`, + s.configName, + r.jobNo, + r.userName, + r.unitId, + r.unitName, + r.unionId, + r.unionName, + r.joined, + r.ledgerId, + t.lineType, + COALESCE(NULLIF(l.lineName, ''), t.lineName) AS lineName, + m.matterName, + CASE + WHEN IFNULL(m.travelStartTime, '') <> '' AND IFNULL(m.travelEndTime, '') <> '' + THEN CONCAT(m.travelStartTime, ' 至 ', m.travelEndTime) + ELSE '' + END AS travelPeriod, + t.groupCode, + t.groupName, + t.undertakeUnionId, + t.undertakeUnionName, + t.signupTime + FROM tour_participant_roster r + INNER JOIN tour_setting s ON s.id = r.settingId AND s.delFlag = 0 + LEFT JOIN tour_ledger t ON t.id = r.ledgerId AND t.delFlag = 0 + LEFT JOIN tour_matter m ON m.id = t.matterId AND m.delFlag = 0 + LEFT JOIN tour_line l ON l.id = t.lineId AND l.delFlag = 0 + $condition + ORDER BY $orderColumn $orderBy, s.`year` DESC, r.unionName ASC, r.unitName ASC, r.jobNo ASC + """); + listSql.setCondition(cnd); + listSql.setVar("orderColumn", getOrderColumn(pageForm.getPageOrderName())); + listSql.setVar("orderBy", "descending".equals(pageForm.getPageOrderBy()) ? "desc" : "asc"); + listSql.setPager(tourParticipantRosterService.dao().createPager(pageForm.getPageNumber(), pageForm.getPageSize())); + listSql.setCallback(Sqls.callback.maps()); + tourParticipantRosterService.dao().execute(listSql); + + Pagination pagination = new Pagination<>( + pageForm.getPageNumber(), + pageForm.getPageSize(), + countSql.getInt(), + listSql.getList(NutMap.class) + ); + return Result.success(pagination); + } + + /** + * 根据年度范围加载疗休养配置下拉选项。 + */ + @At + @SaCheckPermission("tour.participantRoster") + public Result settingOptions(Integer startYear, Integer endYear) { + Result yearResult = checkYearRange(startYear, endYear); + if (yearResult != null) { + return yearResult; + } + Sql sql = Sqls.create(""" + SELECT id, configName, `year` + FROM tour_setting + WHERE delFlag = 0 + AND `year` >= @startYear + AND `year` <= @endYear + ORDER BY `year` DESC, sortNo ASC, createdAt DESC + """); + sql.setParam("startYear", startYear); + sql.setParam("endYear", endYear); + sql.setCallback(Sqls.callback.maps()); + tourParticipantRosterService.dao().execute(sql); + return Result.success(sql.getList(NutMap.class)); + } + + /** + * 加载线路选择器使用的疗休养事项选项。 + * 未选择配置时加载年度范围内全部事项,选择配置后只加载当前配置下事项。 + */ + @At + @SaCheckPermission("tour.participantRoster") + public Result lineOptions(Integer startYear, Integer endYear, String settingId) { + Result yearResult = checkYearRange(startYear, endYear); + if (yearResult != null) { + return yearResult; + } + Cnd cnd = Cnd.NEW(); + cnd.and("s.delFlag", "=", false); + cnd.and("m.delFlag", "=", false); + cnd.and("m.lineId", "IS NOT", null); + cnd.and("m.lineId", "<>", ""); + cnd.and("s.`year`", ">=", startYear); + cnd.and("s.`year`", "<=", endYear); + cnd.andEX("s.id", "=", settingId); + + Sql sql = Sqls.create(""" + SELECT + m.id AS matterId, + m.matterName, + s.id AS settingId, + s.configName, + s.`year`, + l.id AS lineId, + l.lineName, + l.lineType, + CONCAT(s.configName, '【', s.`year`, '】 / ', m.matterName) AS label + FROM tour_matter m + INNER JOIN tour_setting s ON s.id = m.settingId + LEFT JOIN tour_line l ON l.id = m.lineId AND l.delFlag = 0 + $condition + ORDER BY s.`year` DESC, s.sortNo ASC, m.travelStartTime ASC, m.createdAt DESC + """); + sql.setCondition(cnd); + sql.setCallback(Sqls.callback.maps()); + tourParticipantRosterService.dao().execute(sql); + return Result.success(sql.getList(NutMap.class)); + } + + /** + * 从年度范围内的人员名单加载工会下拉选项。 + */ + @At + @SaCheckPermission("tour.participantRoster") + public Result unionOptions(Integer startYear, Integer endYear) { + Result yearResult = checkYearRange(startYear, endYear); + if (yearResult != null) { + return yearResult; + } + Sql sql = Sqls.create(""" + SELECT DISTINCT r.unionId AS id, r.unionName AS name + FROM tour_participant_roster r + INNER JOIN tour_setting s ON s.id = r.settingId AND s.delFlag = 0 + WHERE r.delFlag = 0 + AND IFNULL(r.unionId, '') <> '' + AND s.`year` >= @startYear + AND s.`year` <= @endYear + ORDER BY r.unionName ASC + """); + sql.setParam("startYear", startYear); + sql.setParam("endYear", endYear); + sql.setCallback(Sqls.callback.maps()); + tourParticipantRosterService.dao().execute(sql); + return Result.success(sql.getList(NutMap.class)); + } + + /** + * 根据参与人员范围和现有报名台账重新生成所选配置的人员名单。 + */ + @At + @SaCheckPermission("tour.participantRoster") + public Result rebuildRoster(Integer startYear, Integer endYear, @Param("settingIds") String settingIds) { + Result yearResult = checkYearRange(startYear, endYear); + if (yearResult != null) { + return yearResult; + } + List settingIdList = parseSettingIds(settingIds); + if (settingIdList.isEmpty()) { + return Result.error("请选择需要重新生成的疗休养配置"); + } + Result settingResult = checkSettingsInYearRange(startYear, endYear, settingIdList); + if (settingResult != null) { + return settingResult; + } + tourParticipantRosterService.rebuildSettingRosters(settingIdList); + return Result.success().addMsg("人员名单重新生成成功"); + } + + /** + * 校验年度范围是否完整且顺序合法。 + */ + private Result checkYearRange(Integer startYear, Integer endYear) { + if (startYear == null || endYear == null) { + return Result.error("年度范围不能为空"); + } + if (startYear > endYear) { + return Result.error("开始年度不能大于结束年度"); + } + int nextYear = LocalDate.now().getYear() + 1; + if (startYear < 1900 || endYear > nextYear) { + return Result.error("年度范围不合法"); + } + return null; + } + + /** + * 解析前端多选配置 ID,并过滤空值和重复值。 + */ + private List parseSettingIds(String settingIds) { + if (StrUtil.isBlank(settingIds)) { + return new ArrayList<>(); + } + List rawList = Json.fromJsonAsList(String.class, settingIds); + Set idSet = new LinkedHashSet<>(); + for (String id : rawList) { + if (StrUtil.isNotBlank(id)) { + idSet.add(id.trim()); + } + } + return new ArrayList<>(idSet); + } + + /** + * 校验所选疗休养配置是否仍属于页面提交的年度范围。 + */ + private Result checkSettingsInYearRange(Integer startYear, Integer endYear, List settingIds) { + Sql sql = Sqls.create(""" + SELECT COUNT(1) + FROM tour_setting + WHERE delFlag = 0 + AND id IN (@settingIds) + AND `year` >= @startYear + AND `year` <= @endYear + """); + sql.setParam("settingIds", settingIds.toArray(new String[0])); + sql.setParam("startYear", startYear); + sql.setParam("endYear", endYear); + sql.setCallback(Sqls.callback.integer()); + tourParticipantRosterService.dao().execute(sql); + if (sql.getInt() != settingIds.size()) { + return Result.error("所选疗休养配置与年度范围不匹配,请刷新后重试"); + } + return null; + } + + /** + * 构造总数查询和列表查询共用的 SQL 条件。 + */ + private Cnd buildQueryCnd(Integer startYear, Integer endYear, String settingId, String matterId, String keyword, + String unitName, String unionId, Boolean joined, String lineType, + String groupCode, String undertakeUnionId) { + Cnd cnd = Cnd.NEW(); + cnd.and("r.delFlag", "=", false); + cnd.and("s.`year`", ">=", startYear); + cnd.and("s.`year`", "<=", endYear); + cnd.andEX("r.settingId", "=", settingId); + cnd.andEX("t.matterId", "=", matterId); + cnd.andEX("r.unionId", "=", unionId); + cnd.andEX("r.unitName", "like", StrUtil.isBlank(unitName) ? null : "%" + unitName.trim() + "%"); + cnd.andEX("t.lineType", "=", lineType); + cnd.andEX("t.undertakeUnionId", "=", undertakeUnionId); + appendKeywordFilter(cnd, keyword); + return cnd; + } + + /** + * 增加工号和姓名模糊查询条件。 + */ + private void appendKeywordFilter(Cnd cnd, String keyword) { + if (StrUtil.isBlank(keyword)) { + return; + } + SqlExpressionGroup group = new SqlExpressionGroup(); + group.orLike("r.jobNo", keyword.trim()); + group.orLike("r.userName", keyword.trim()); + cnd.and(group); + } + + /** + * 将前端排序字段映射为安全的 SQL 排序列。 + */ + private String getOrderColumn(String orderName) { + if ("year".equals(orderName)) { + return "s.`year`"; + } + if ("jobNo".equals(orderName)) { + return "r.jobNo"; + } + if ("userName".equals(orderName)) { + return "r.userName"; + } + if ("unitName".equals(orderName)) { + return "r.unitName"; + } + if ("unionName".equals(orderName)) { + return "r.unionName"; + } + if ("joined".equals(orderName)) { + return "r.joined"; + } + if ("lineType".equals(orderName)) { + return "t.lineType"; + } + if ("lineName".equals(orderName)) { + return "COALESCE(NULLIF(l.lineName, ''), t.lineName)"; + } + if ("signupTime".equals(orderName)) { + return "t.signupTime"; + } + return "s.`year`"; + } +} diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourSettingController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourSettingController.java index 1e21e83..fa8bc79 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourSettingController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourSettingController.java @@ -8,6 +8,7 @@ import com.budwk.app.base.param.PageForm; import com.budwk.app.base.result.Result; import com.budwk.app.zhgh.dayofficework.tour.models.TourSetting; import com.budwk.app.zhgh.dayofficework.tour.models.TourSettingLot; +import com.budwk.app.zhgh.dayofficework.tour.service.TourParticipantRosterService; import com.budwk.app.zhgh.dayofficework.tour.service.TourSettingService; import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.dao.Cnd; @@ -32,6 +33,9 @@ public class TourSettingController { @Inject private TourSettingService tourSettingService; + @Inject + private TourParticipantRosterService tourParticipantRosterService; + @At("") @Ok("beetl:/platform/zhgh/dayofficework/Tour/setting/index.html") @SaCheckPermission("tour.setting") @@ -156,6 +160,8 @@ public class TourSettingController { tourSettingService.updateWithNullableSignupTime(tourSetting); saveLots(tourSetting); } + // Initialize the participant roster only once; existing roster data is preserved on later setting edits. + tourParticipantRosterService.syncSettingRosterIfEmpty(tourSetting.getId()); return Result.success(); } @@ -168,6 +174,8 @@ public class TourSettingController { return Result.error("参数错误"); } tourSettingService.dao().clear(TourSettingLot.class, Cnd.where(TourSettingLot::getSettingId, "=", id)); + // Remove roster rows for the deleted setting so later statistics ignore obsolete data. + tourParticipantRosterService.clearSettingRoster(id); tourSettingService.delete(id); return Result.success(); } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourSignupController.java b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourSignupController.java index 0f939f2..46d45df 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourSignupController.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/controller/TourSignupController.java @@ -35,6 +35,7 @@ import com.budwk.app.zhgh.dayofficework.tour.service.TourLedgerDirectRelativeSer import com.budwk.app.zhgh.dayofficework.tour.service.TourLedgerFamilyService; import com.budwk.app.zhgh.dayofficework.tour.service.TourLedgerService; import com.budwk.app.zhgh.dayofficework.tour.service.TourMatterService; +import com.budwk.app.zhgh.dayofficework.tour.service.TourParticipantRosterService; import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.dao.Cnd; import org.nutz.dao.Sqls; @@ -79,6 +80,9 @@ public class TourSignupController { @Inject private TourLedgerDirectRelativeService tourLedgerDirectRelativeService; + @Inject + private TourParticipantRosterService tourParticipantRosterService; + @Inject private SysDictService sysDictService; @@ -798,6 +802,8 @@ public class TourSignupController { return workflowResult; } } + // Signup and modification both mean the staff member is counted as joined for this setting. + tourParticipantRosterService.markJoinedByLedger(ledger); return Result.success().addMsg(approvalRequired ? (update ? "修改已提交,等待审核" : "申请已提交,等待审核") : (update ? "修改成功" : "报名成功")); } @@ -828,6 +834,8 @@ public class TourSignupController { if (signupTimeResult != null) { return signupTimeResult; } + // Reset the roster before deleting the ledger record, because the ledger still carries setting linkage here. + tourParticipantRosterService.markNotJoinedByLedger(ledger); tourLedgerFamilyService.clear(Cnd.where(TourLedgerFamily::getLedgerId, "=", ledger.getId())); tourLedgerDirectRelativeService.clear(Cnd.where(TourLedgerDirectRelative::getLedgerId, "=", ledger.getId())); tourLedgerService.clear(Cnd.where(TourLedger::getId, "=", ledger.getId())); diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/models/TourParticipantRoster.java b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/models/TourParticipantRoster.java new file mode 100644 index 0000000..4c27bf2 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/models/TourParticipantRoster.java @@ -0,0 +1,81 @@ +package com.budwk.app.zhgh.dayofficework.tour.models; + +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.Default; +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; + +/** + * Tour participant roster stores every eligible staff member for a tour setting. + * It is used for joined/not-joined statistics and links to tour_ledger only when the user has a signup record. + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Table("tour_participant_roster") +@TableMeta("{'mysql-charset':'utf8mb4'}") +@Comment("疗休养人员名单") +public class TourParticipantRoster extends BaseModel implements Serializable { + + @Column + @Name + @Comment("ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + @PrevInsert(uu32 = true) + private String id; + + @Column + @Comment("疗休养配置ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String settingId; + + @Column + @Comment("工号") + @ColDefine(type = ColType.VARCHAR, width = 50) + private String jobNo; + + @Column + @Comment("姓名") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String userName; + + @Column + @Comment("所在单位ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String unitId; + + @Column + @Comment("所在单位") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String unitName; + + @Column + @Comment("所在工会ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String unionId; + + @Column + @Comment("所在工会") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String unionName; + + @Column + @Comment("是否参加") + @ColDefine(type = ColType.BOOLEAN) + @Default("0") + private Boolean joined; + + @Column + @Comment("疗休养台账ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String ledgerId; +} diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/service/TourParticipantRosterService.java b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/service/TourParticipantRosterService.java new file mode 100644 index 0000000..8359812 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/service/TourParticipantRosterService.java @@ -0,0 +1,55 @@ +package com.budwk.app.zhgh.dayofficework.tour.service; + +import com.budwk.app.base.service.BaseService; +import com.budwk.app.zhgh.dayofficework.tour.models.TourLedger; +import com.budwk.app.zhgh.dayofficework.tour.models.TourParticipantRoster; + +import java.util.List; + +/** + * Service for maintaining the tour participant roster used by yearly joined/not-joined statistics. + */ +public interface TourParticipantRosterService extends BaseService { + + /** + * Synchronizes eligible staff of one tour setting into the roster table. + * + * @param settingId tour setting id whose activity group defines the eligible staff scope. + */ + void syncSettingRoster(String settingId); + + /** + * Synchronizes the setting roster only when no roster rows exist for that setting. + * + * @param settingId tour setting id whose roster should be lazily initialized. + */ + void syncSettingRosterIfEmpty(String settingId); + + /** + * Rebuilds selected setting rosters from eligible staff scopes and existing tour ledgers. + * + * @param settingIds selected tour setting ids. + */ + void rebuildSettingRosters(List settingIds); + + /** + * Clears roster rows when a tour setting is deleted. + * + * @param settingId deleted tour setting id. + */ + void clearSettingRoster(String settingId); + + /** + * Marks the roster row as joined and links it with the newest tour ledger. + * + * @param ledger signup ledger created or modified successfully. + */ + void markJoinedByLedger(TourLedger ledger); + + /** + * Marks the roster row as not joined and clears the linked ledger id. + * + * @param ledger signup ledger that is being cancelled or deleted. + */ + void markNotJoinedByLedger(TourLedger ledger); +} diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/tour/service/impl/TourParticipantRosterServiceImpl.java b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/service/impl/TourParticipantRosterServiceImpl.java new file mode 100644 index 0000000..a30543a --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/tour/service/impl/TourParticipantRosterServiceImpl.java @@ -0,0 +1,288 @@ +package com.budwk.app.zhgh.dayofficework.tour.service.impl; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import com.budwk.app.base.service.impl.BaseServiceImpl; +import com.budwk.app.sys.views.View_user; +import com.budwk.app.zhgh.activity.basic.service.ActivityBasicScopeService; +import com.budwk.app.zhgh.dayofficework.tour.models.TourLedger; +import com.budwk.app.zhgh.dayofficework.tour.models.TourMatter; +import com.budwk.app.zhgh.dayofficework.tour.models.TourParticipantRoster; +import com.budwk.app.zhgh.dayofficework.tour.models.TourSetting; +import com.budwk.app.zhgh.dayofficework.tour.service.TourParticipantRosterService; +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 java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Default implementation that keeps tour participant roster data in sync with settings and signup ledgers. + */ +@IocBean(args = {"refer:dao"}) +public class TourParticipantRosterServiceImpl extends BaseServiceImpl implements TourParticipantRosterService { + + private static final int USER_QUERY_BATCH_SIZE = 500; + + @Inject + private ActivityBasicScopeService activityBasicScopeService; + + public TourParticipantRosterServiceImpl(Dao dao) { + super(dao); + } + + /** + * Reads eligible users from the setting activity group and upserts roster rows without resetting joined status. + */ + @Override + public void syncSettingRoster(String settingId) { + if (StrUtil.isBlank(settingId)) { + return; + } + TourSetting setting = dao().fetch(TourSetting.class, settingId); + if (setting == null || StrUtil.isBlank(setting.getActivityGroupId())) { + return; + } + Integer groupId = parseGroupId(setting.getActivityGroupId()); + if (groupId == null) { + return; + } + List userIds = activityBasicScopeService.listGroupUserIds(groupId); + if (CollUtil.isEmpty(userIds)) { + return; + } + + for (View_user user : queryUsers(userIds)) { + if (user == null || StrUtil.isBlank(user.getLoginname())) { + continue; + } + upsertRosterUser(settingId, user); + } + } + + /** + * Keeps normal setting save lightweight: once a setting has roster rows, editing the setting will not reload users. + */ + @Override + public void syncSettingRosterIfEmpty(String settingId) { + if (StrUtil.isBlank(settingId)) { + return; + } + int rosterCount = count(Cnd.where("settingId", "=", settingId)); + if (rosterCount > 0) { + return; + } + syncSettingRoster(settingId); + } + + /** + * Rebuilds rosters in one transaction: clear old rows, reload eligible staff, then mark users with active ledgers as joined. + */ + @Override + @Aop(TransAop.READ_COMMITTED) + public void rebuildSettingRosters(List settingIds) { + if (CollUtil.isEmpty(settingIds)) { + return; + } + for (String settingId : settingIds) { + if (StrUtil.isBlank(settingId)) { + continue; + } + clear(Cnd.where("settingId", "=", settingId)); + syncSettingRoster(settingId); + replayLedgerJoinedState(settingId); + } + } + + /** + * Removes roster rows that belong to a deleted setting so statistics do not keep obsolete setting data. + */ + @Override + public void clearSettingRoster(String settingId) { + if (StrUtil.isBlank(settingId)) { + return; + } + clear(Cnd.where("settingId", "=", settingId)); + } + + /** + * Signup success and signup modification both keep the roster row joined and point it at the effective ledger. + */ + @Override + public void markJoinedByLedger(TourLedger ledger) { + String settingId = resolveSettingId(ledger); + if (StrUtil.isBlank(settingId) || ledger == null || StrUtil.isBlank(ledger.getJobNo())) { + return; + } + TourParticipantRoster roster = fetchRoster(settingId, ledger.getJobNo()); + if (roster == null) { + roster = new TourParticipantRoster(); + roster.setSettingId(settingId); + roster.setJobNo(ledger.getJobNo()); + roster.setJoined(true); + fillRosterFromLedger(roster, ledger); + insert(roster); + return; + } + Chain chain = Chain.make("joined", true) + .add("ledgerId", StrUtil.blankToDefault(ledger.getId(), "")) + .add("userName", StrUtil.blankToDefault(ledger.getUserName(), roster.getUserName())) + .add("unitId", StrUtil.blankToDefault(ledger.getUnitId(), roster.getUnitId())) + .add("unitName", StrUtil.blankToDefault(ledger.getUnitName(), roster.getUnitName())) + .add("unionId", StrUtil.blankToDefault(ledger.getUnionId(), roster.getUnionId())) + .add("unionName", StrUtil.blankToDefault(ledger.getUnionName(), roster.getUnionName())); + dao().update(TourParticipantRoster.class, chain, Cnd.where("id", "=", roster.getId())); + } + + /** + * Cancellation only resets the current user's roster row; historical ledger data remains in tour_ledger deletion history. + */ + @Override + public void markNotJoinedByLedger(TourLedger ledger) { + String settingId = resolveSettingId(ledger); + if (StrUtil.isBlank(settingId) || ledger == null || StrUtil.isBlank(ledger.getJobNo())) { + return; + } + TourParticipantRoster roster = fetchRoster(settingId, ledger.getJobNo()); + if (roster == null) { + roster = new TourParticipantRoster(); + roster.setSettingId(settingId); + roster.setJobNo(ledger.getJobNo()); + roster.setJoined(false); + fillRosterFromLedger(roster, ledger); + roster.setLedgerId(""); + insert(roster); + return; + } + dao().update(TourParticipantRoster.class, + Chain.make("joined", false).add("ledgerId", ""), + Cnd.where("id", "=", roster.getId())); + } + + /** + * Converts the setting activity group id stored as text into an integer accepted by the activity scope service. + */ + private Integer parseGroupId(String activityGroupId) { + try { + return Integer.parseInt(activityGroupId); + } catch (NumberFormatException e) { + return null; + } + } + + /** + * Queries the user view in batches so large participant scopes do not generate an oversized SQL IN clause. + */ + private List queryUsers(List userIds) { + List users = new ArrayList<>(); + for (int i = 0; i < userIds.size(); i += USER_QUERY_BATCH_SIZE) { + int end = Math.min(i + USER_QUERY_BATCH_SIZE, userIds.size()); + List batch = userIds.subList(i, end); + users.addAll(dao().query(View_user.class, Cnd.where("id", "in", batch))); + } + return users; + } + + /** + * Inserts new eligible users and refreshes basic organization data for existing rows. + */ + private void upsertRosterUser(String settingId, View_user user) { + TourParticipantRoster roster = fetchRoster(settingId, user.getLoginname()); + if (roster == null) { + roster = new TourParticipantRoster(); + roster.setSettingId(settingId); + roster.setJobNo(user.getLoginname()); + roster.setJoined(false); + fillRosterFromUser(roster, user); + insert(roster); + return; + } + dao().update(TourParticipantRoster.class, + Chain.make("userName", StrUtil.blankToDefault(user.getUsername(), "")) + .add("unitId", StrUtil.blankToDefault(user.getUnitId(), "")) + .add("unitName", StrUtil.blankToDefault(user.getUnitName(), "")) + .add("unionId", StrUtil.blankToDefault(user.getUnionId(), "")) + .add("unionName", StrUtil.blankToDefault(user.getUnionName(), "")), + Cnd.where("id", "=", roster.getId())); + } + + /** + * Replays active tour ledgers under one setting so rebuilt rosters keep the current signup state. + */ + private void replayLedgerJoinedState(String settingId) { + Sql sql = Sqls.create(""" + SELECT t.* + FROM tour_ledger t + INNER JOIN tour_matter m ON m.id = t.matterId AND m.delFlag = 0 + WHERE t.delFlag = 0 + AND m.settingId = @settingId + AND IFNULL(t.jobNo, '') <> '' + ORDER BY t.signupTime DESC, t.createdAt DESC + """); + sql.setParam("settingId", settingId); + sql.setEntity(dao().getEntity(TourLedger.class)); + sql.setCallback(Sqls.callback.entities()); + dao().execute(sql); + + Set handledJobNos = new HashSet<>(); + for (TourLedger ledger : sql.getList(TourLedger.class)) { + if (ledger == null || StrUtil.isBlank(ledger.getJobNo()) || handledJobNos.contains(ledger.getJobNo())) { + continue; + } + handledJobNos.add(ledger.getJobNo()); + markJoinedByLedger(ledger); + } + } + + /** + * Finds an active roster row by setting and job number, which is the logical unique key. + */ + private TourParticipantRoster fetchRoster(String settingId, String jobNo) { + return fetch(Cnd.where("settingId", "=", settingId) + .and("jobNo", "=", jobNo) + .and("delFlag", "=", false)); + } + + /** + * Resolves setting id through the ledger matter because tour_ledger intentionally does not store settingId. + */ + private String resolveSettingId(TourLedger ledger) { + if (ledger == null || StrUtil.isBlank(ledger.getMatterId())) { + return ""; + } + TourMatter matter = dao().fetch(TourMatter.class, ledger.getMatterId()); + return matter == null ? "" : matter.getSettingId(); + } + + /** + * Copies staff base data from the user view into the roster row. + */ + private void fillRosterFromUser(TourParticipantRoster roster, View_user user) { + roster.setUserName(StrUtil.blankToDefault(user.getUsername(), "")); + roster.setUnitId(StrUtil.blankToDefault(user.getUnitId(), "")); + roster.setUnitName(StrUtil.blankToDefault(user.getUnitName(), "")); + roster.setUnionId(StrUtil.blankToDefault(user.getUnionId(), "")); + roster.setUnionName(StrUtil.blankToDefault(user.getUnionName(), "")); + } + + /** + * Copies staff and organization data from the ledger when roster sync has not created the row yet. + */ + private void fillRosterFromLedger(TourParticipantRoster roster, TourLedger ledger) { + roster.setUserName(StrUtil.blankToDefault(ledger.getUserName(), "")); + roster.setUnitId(StrUtil.blankToDefault(ledger.getUnitId(), "")); + roster.setUnitName(StrUtil.blankToDefault(ledger.getUnitName(), "")); + roster.setUnionId(StrUtil.blankToDefault(ledger.getUnionId(), "")); + roster.setUnionName(StrUtil.blankToDefault(ledger.getUnionName(), "")); + roster.setLedgerId(StrUtil.blankToDefault(ledger.getId(), "")); + } +} diff --git a/src/main/resources/db/tour_participant_roster_create.sql b/src/main/resources/db/tour_participant_roster_create.sql new file mode 100644 index 0000000..3dfc694 --- /dev/null +++ b/src/main/resources/db/tour_participant_roster_create.sql @@ -0,0 +1,26 @@ +CREATE TABLE IF NOT EXISTS `tour_participant_roster` +( + `id` varchar(32) NOT NULL COMMENT 'ID', + `settingId` varchar(32) NOT NULL COMMENT '疗休养配置ID', + `jobNo` varchar(50) NOT NULL COMMENT '工号', + `userName` varchar(100) DEFAULT NULL COMMENT '姓名', + `unitId` varchar(32) DEFAULT NULL COMMENT '所在单位ID', + `unitName` varchar(100) DEFAULT NULL COMMENT '所在单位', + `unionId` varchar(32) DEFAULT NULL COMMENT '所在工会ID', + `unionName` varchar(100) DEFAULT NULL COMMENT '所在工会', + `joined` tinyint(1) DEFAULT 0 COMMENT '是否参加:0否,1是', + `ledgerId` varchar(32) DEFAULT NULL COMMENT '疗休养台账ID', + `createdBy` varchar(32) DEFAULT NULL COMMENT '创建人', + `createdAt` bigint DEFAULT NULL COMMENT '创建时间', + `updatedBy` varchar(32) DEFAULT NULL COMMENT '修改人', + `updatedAt` bigint DEFAULT NULL COMMENT '修改时间', + `delFlag` tinyint(1) DEFAULT 0 COMMENT '删除标记', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_tour_participant_roster_setting_job` (`settingId`, `jobNo`), + KEY `idx_tour_participant_roster_setting` (`settingId`), + KEY `idx_tour_participant_roster_job` (`jobNo`), + KEY `idx_tour_participant_roster_joined` (`joined`), + KEY `idx_tour_participant_roster_ledger` (`ledgerId`), + KEY `idx_tour_participant_roster_union` (`unionId`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 COMMENT ='疗休养人员名单'; diff --git a/src/main/resources/db/tour_participant_roster_menu.sql b/src/main/resources/db/tour_participant_roster_menu.sql new file mode 100644 index 0000000..aac7f8e --- /dev/null +++ b/src/main/resources/db/tour_participant_roster_menu.sql @@ -0,0 +1,15 @@ +-- 疗休养人员名单查询菜单。 +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService) +SELECT 'b7c63f0c73a44a9a8c4f3d3bb1a10014', p.id, CONCAT(p.path, '0013'), '疗休养人员名单', 'Tour Participant Roster', 'menu', '/platform/tour/participantRoster', 'data-pjax', '', 1, 0, 'tour.participantRoster', NULL, 13, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'l', 0, 0 +FROM sys_menu p +WHERE p.permission = 'tour' + AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'tour.participantRoster') t); + +-- 默认给系统管理员授权,其它角色可以在角色管理中按需分配。 +INSERT INTO sys_role_menu (roleId, menuId) +SELECT r.id, m.id +FROM sys_role r +JOIN sys_menu m ON m.permission = 'tour.participantRoster' +LEFT JOIN sys_role_menu rm ON rm.roleId = r.id AND rm.menuId = m.id +WHERE r.code = 'SYSADMIN' + AND rm.roleId IS NULL; diff --git a/src/main/resources/static/assets/platform/js/mixin/tourTableHeightMixins.js b/src/main/resources/static/assets/platform/js/mixin/tourTableHeightMixins.js new file mode 100644 index 0000000..512c33a --- /dev/null +++ b/src/main/resources/static/assets/platform/js/mixin/tourTableHeightMixins.js @@ -0,0 +1,67 @@ +const tourTableHeightMixins = { + data() { + return { + tourTableHeight: Math.max(120, window.innerHeight - 378), + tourTableResizeTimer: null, + tourTableHeightReady: false + } + }, + methods: { + updateTourTableHeight() { + this.$nextTick(() => { + const table = this.$refs.tourMainTable || this.$refs.tableRef || this.$refs.table + const tableEl = table && table.$el ? table.$el : null + if (!tableEl) { + return + } + + // 根据当前屏幕、表格位置、分页区域动态计算主列表高度,避免整页被数据行撑出滚动条。 + const mainContent = document.querySelector(".sub-app-container-main-content") + const viewportBottom = mainContent ? mainContent.getBoundingClientRect().bottom : window.innerHeight + const tableTop = tableEl.getBoundingClientRect().top + const pagination = this.$el.querySelector(".el-pagination-container") + const paginationHeight = pagination ? pagination.getBoundingClientRect().height : 46 + const reserveHeight = Number(this.tourTableReserveHeight || 42) + const minHeight = Number(this.tourTableMinHeight || 120) + const height = Math.floor(viewportBottom - tableTop - paginationHeight - reserveHeight) + this.tourTableHeight = height > minHeight ? height : minHeight + this.tourTableHeightReady = true + + // Element 表格高度变化后需要重新布局,否则固定列和表头可能错位。 + this.$nextTick(() => { + if (table && table.doLayout) { + table.doLayout() + } + }) + }) + }, + handleTourTableResize() { + if (this.tourTableResizeTimer) { + clearTimeout(this.tourTableResizeTimer) + } + this.tourTableResizeTimer = setTimeout(() => { + this.updateTourTableHeight() + this.tourTableResizeTimer = null + }, 120) + } + }, + mounted() { + this.updateTourTableHeight() + window.addEventListener("resize", this.handleTourTableResize) + }, + beforeDestroy() { + if (this.tourTableResizeTimer) { + clearTimeout(this.tourTableResizeTimer) + this.tourTableResizeTimer = null + } + window.removeEventListener("resize", this.handleTourTableResize) + }, + watch: { + tableData() { + this.updateTourTableHeight() + }, + "pageForm.pageSize"() { + this.updateTourTableHeight() + } + } +} diff --git a/src/main/resources/views/layouts/v4/baseLayout.html b/src/main/resources/views/layouts/v4/baseLayout.html index 8830247..626bd08 100644 --- a/src/main/resources/views/layouts/v4/baseLayout.html +++ b/src/main/resources/views/layouts/v4/baseLayout.html @@ -87,6 +87,7 @@ + diff --git a/src/main/resources/views/platform/zhgh/dayofficework/Tour/group/index.html b/src/main/resources/views/platform/zhgh/dayofficework/Tour/group/index.html index eeac73a..c47481c 100644 --- a/src/main/resources/views/platform/zhgh/dayofficework/Tour/group/index.html +++ b/src/main/resources/views/platform/zhgh/dayofficework/Tour/group/index.html @@ -1,4 +1,4 @@ - @@ -51,7 +51,9 @@ layout("/layouts/platform.html"){ border :default-sort="{prop: 'lineName', order: 'ascending'}" :row-class-name="tableRowClassName" - @sort-change="pageOrder"> + @sort-change="pageOrder" + ref="tourMainTable" + :height="tourTableHeight"> @@ -194,7 +196,7 @@ layout("/layouts/platform.html"){ + + diff --git a/src/main/resources/views/platform/zhgh/dayofficework/Tour/route/index.html b/src/main/resources/views/platform/zhgh/dayofficework/Tour/route/index.html index 7a55b3e..2f6e353 100644 --- a/src/main/resources/views/platform/zhgh/dayofficework/Tour/route/index.html +++ b/src/main/resources/views/platform/zhgh/dayofficework/Tour/route/index.html @@ -57,7 +57,9 @@ layout("/layouts/platform.html"){ border :default-sort="{prop: 'lineCode', order: 'ascending'}" :row-class-name="tableRowClassName" - @sort-change="pageOrder"> + @sort-change="pageOrder" + ref="tourMainTable" + :height="tourTableHeight"> @@ -366,7 +368,7 @@ layout("/layouts/platform.html"){ new Vue({ el: "#app", store, - mixins: [initTableMixins], + mixins: [initTableMixins, tourTableHeightMixins], data() { const currentYear = moment().format("YYYY") const validateMatterUnion = (rule, value, callback) => { diff --git a/src/main/resources/views/platform/zhgh/dayofficework/Tour/schoolUnionApproval/index.html b/src/main/resources/views/platform/zhgh/dayofficework/Tour/schoolUnionApproval/index.html index 843b9ef..6ae8b84 100644 --- a/src/main/resources/views/platform/zhgh/dayofficework/Tour/schoolUnionApproval/index.html +++ b/src/main/resources/views/platform/zhgh/dayofficework/Tour/schoolUnionApproval/index.html @@ -72,7 +72,9 @@ layout("/layouts/platform.html"){ :data="tableData" :size="tableSize" border - @sort-change="pageOrder"> + @sort-change="pageOrder" + ref="tourMainTable" + :height="tourTableHeight"> @@ -413,7 +415,7 @@ layout("/layouts/platform.html"){ new Vue({ el: "#app", - mixins: [initTableMixins], + mixins: [initTableMixins, tourTableHeightMixins], data() { const currentYear = moment().format("YYYY") return { diff --git a/src/main/resources/views/platform/zhgh/dayofficework/Tour/setting/index.html b/src/main/resources/views/platform/zhgh/dayofficework/Tour/setting/index.html index d01bf33..a3ef349 100644 --- a/src/main/resources/views/platform/zhgh/dayofficework/Tour/setting/index.html +++ b/src/main/resources/views/platform/zhgh/dayofficework/Tour/setting/index.html @@ -48,7 +48,7 @@ layout("/layouts/platform.html"){ row-key="id" v-loading="tableLoading" @sort-change="pageOrder" - > + :height="tourTableHeight"> @@ -311,7 +311,7 @@ layout("/layouts/platform.html"){