疗休养人员名单

This commit is contained in:
2026-07-23 17:04:08 +08:00
parent b05543d624
commit c55449cd71
4 changed files with 197 additions and 67 deletions
@@ -236,8 +236,9 @@ public class TourParticipantRosterController {
if (settingResult != null) { if (settingResult != null) {
return settingResult; return settingResult;
} }
tourParticipantRosterService.rebuildSettingRosters(settingIdList); List<String> warningMessages = tourParticipantRosterService.rebuildSettingRosters(settingIdList);
return Result.success().addMsg("人员名单重新生成成功"); NutMap data = NutMap.NEW().addv("warnings", warningMessages);
return Result.success(data).addMsg("人员名单重新生成成功");
} }
/** /**
@@ -7,49 +7,51 @@ import com.budwk.app.zhgh.dayofficework.tour.models.TourParticipantRoster;
import java.util.List; import java.util.List;
/** /**
* Service for maintaining the tour participant roster used by yearly joined/not-joined statistics. * 疗休养人员名单维护服务。
* 用于按配置维护可参与人员基础名单,并同步报名台账中的参加状态。
*/ */
public interface TourParticipantRosterService extends BaseService<TourParticipantRoster> { public interface TourParticipantRosterService extends BaseService<TourParticipantRoster> {
/** /**
* Synchronizes eligible staff of one tour setting into the roster table. * 将一个疗休养配置的可参与人员同步到人员名单表。
* *
* @param settingId tour setting id whose activity group defines the eligible staff scope. * @param settingId 疗休养配置 id,配置中的可参与人员组别决定人员范围。
*/ */
void syncSettingRoster(String settingId); 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. * @param settingId 需要懒加载人员名单的疗休养配置 id。
*/ */
void syncSettingRosterIfEmpty(String settingId); void syncSettingRosterIfEmpty(String settingId);
/** /**
* Rebuilds selected setting rosters from eligible staff scopes and existing tour ledgers. * 根据可参与人员范围和现有疗休养台账,重新生成所选配置的人员名单。
* *
* @param settingIds selected tour setting ids. * @param settingIds 所选疗休养配置 id。
* @return 已不在可参与人员范围中但存在台账、因此无法剔除的人员提示。
*/ */
void rebuildSettingRosters(List<String> settingIds); List<String> rebuildSettingRosters(List<String> settingIds);
/** /**
* Clears roster rows when a tour setting is deleted. * 删除疗休养配置时清理对应人员名单。
* *
* @param settingId deleted tour setting id. * @param settingId 已删除的疗休养配置 id。
*/ */
void clearSettingRoster(String settingId); 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. * @param ledger 已创建或修改成功的报名台账。
*/ */
void markJoinedByLedger(TourLedger ledger); 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. * @param ledger 正在取消或删除的报名台账。
*/ */
void markNotJoinedByLedger(TourLedger ledger); void markNotJoinedByLedger(TourLedger ledger);
} }
@@ -22,11 +22,13 @@ import org.nutz.ioc.loader.annotation.IocBean;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
/** /**
* Default implementation that keeps tour participant roster data in sync with settings and signup ledgers. * 疗休养人员名单维护服务实现。
* 负责同步配置可参与人员、回填台账参加状态,并保留已有台账人员的历史统计口径。
*/ */
@IocBean(args = {"refer:dao"}) @IocBean(args = {"refer:dao"})
public class TourParticipantRosterServiceImpl extends BaseServiceImpl<TourParticipantRoster> implements TourParticipantRosterService { public class TourParticipantRosterServiceImpl extends BaseServiceImpl<TourParticipantRoster> implements TourParticipantRosterService {
@@ -41,7 +43,7 @@ public class TourParticipantRosterServiceImpl extends BaseServiceImpl<TourPartic
} }
/** /**
* Reads eligible users from the setting activity group and upserts roster rows without resetting joined status. * 读取配置可参与人员组别,并写入或刷新人员名单基础信息。
*/ */
@Override @Override
public void syncSettingRoster(String settingId) { public void syncSettingRoster(String settingId) {
@@ -49,28 +51,16 @@ public class TourParticipantRosterServiceImpl extends BaseServiceImpl<TourPartic
return; return;
} }
TourSetting setting = dao().fetch(TourSetting.class, settingId); TourSetting setting = dao().fetch(TourSetting.class, settingId);
if (setting == null || StrUtil.isBlank(setting.getActivityGroupId())) { if (setting == null) {
return; return;
} }
Integer groupId = parseGroupId(setting.getActivityGroupId()); for (View_user user : listEligibleUsers(setting)) {
if (groupId == null) {
return;
}
List<String> 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); upsertRosterUser(settingId, user);
} }
} }
/** /**
* Keeps normal setting save lightweight: once a setting has roster rows, editing the setting will not reload users. * 配置普通保存时只做懒加载:配置下已有名单数据则不重新拉取人员。
*/ */
@Override @Override
public void syncSettingRosterIfEmpty(String settingId) { public void syncSettingRosterIfEmpty(String settingId) {
@@ -85,26 +75,40 @@ public class TourParticipantRosterServiceImpl extends BaseServiceImpl<TourPartic
} }
/** /**
* Rebuilds rosters in one transaction: clear old rows, reload eligible staff, then mark users with active ledgers as joined. * 在一个事务内重建人员名单:删除旧名单、重载当前可参与人员、回放有效台账参加状态。
* 若台账人员已不在当前可参与人员中,仍保留在名单中,并返回提示给页面展示。
*/ */
@Override @Override
@Aop(TransAop.READ_COMMITTED) @Aop(TransAop.READ_COMMITTED)
public void rebuildSettingRosters(List<String> settingIds) { public List<String> rebuildSettingRosters(List<String> settingIds) {
List<String> warningMessages = new ArrayList<>();
if (CollUtil.isEmpty(settingIds)) { if (CollUtil.isEmpty(settingIds)) {
return; return warningMessages;
} }
for (String settingId : settingIds) { for (String settingId : settingIds) {
if (StrUtil.isBlank(settingId)) { if (StrUtil.isBlank(settingId)) {
continue; continue;
} }
TourSetting setting = dao().fetch(TourSetting.class, settingId);
if (setting == null) {
continue;
}
List<View_user> eligibleUsers = listEligibleUsers(setting);
Set<String> eligibleJobNos = collectEligibleJobNos(eligibleUsers);
clear(Cnd.where("settingId", "=", settingId)); clear(Cnd.where("settingId", "=", settingId));
syncSettingRoster(settingId); for (View_user user : eligibleUsers) {
replayLedgerJoinedState(settingId); upsertRosterUser(settingId, user);
}
List<String> retainedUserNames = replayLedgerJoinedState(settingId, eligibleJobNos);
if (CollUtil.isNotEmpty(retainedUserNames)) {
warningMessages.add(buildRetainedLedgerUserMessage(setting, retainedUserNames));
}
} }
return warningMessages;
} }
/** /**
* Removes roster rows that belong to a deleted setting so statistics do not keep obsolete setting data. * 删除配置下的人员名单,避免已删除配置继续参与统计查询。
*/ */
@Override @Override
public void clearSettingRoster(String settingId) { public void clearSettingRoster(String settingId) {
@@ -115,7 +119,7 @@ public class TourParticipantRosterServiceImpl extends BaseServiceImpl<TourPartic
} }
/** /**
* Signup success and signup modification both keep the roster row joined and point it at the effective ledger. * 报名成功和修改报名都会将人员名单标记为已参加,并指向当前有效台账。
*/ */
@Override @Override
public void markJoinedByLedger(TourLedger ledger) { public void markJoinedByLedger(TourLedger ledger) {
@@ -144,7 +148,7 @@ public class TourParticipantRosterServiceImpl extends BaseServiceImpl<TourPartic
} }
/** /**
* Cancellation only resets the current user's roster row; historical ledger data remains in tour_ledger deletion history. * 取消报名只重置当前人员名单状态,历史台账删除记录仍保留在 tour_ledger 中。
*/ */
@Override @Override
public void markNotJoinedByLedger(TourLedger ledger) { public void markNotJoinedByLedger(TourLedger ledger) {
@@ -169,7 +173,7 @@ public class TourParticipantRosterServiceImpl extends BaseServiceImpl<TourPartic
} }
/** /**
* Converts the setting activity group id stored as text into an integer accepted by the activity scope service. * 将配置中以文本保存的可参与人员组别 id 转换为人员范围服务需要的整数。
*/ */
private Integer parseGroupId(String activityGroupId) { private Integer parseGroupId(String activityGroupId) {
try { try {
@@ -180,7 +184,48 @@ public class TourParticipantRosterServiceImpl extends BaseServiceImpl<TourPartic
} }
/** /**
* Queries the user view in batches so large participant scopes do not generate an oversized SQL IN clause. * 根据配置的可参与人员组别加载人员基础数据。
* 这里集中处理空配置和无效组别,方便同步和重建流程复用同一口径。
*/
private List<View_user> listEligibleUsers(TourSetting setting) {
if (setting == null || StrUtil.isBlank(setting.getActivityGroupId())) {
return new ArrayList<>();
}
Integer groupId = parseGroupId(setting.getActivityGroupId());
if (groupId == null) {
return new ArrayList<>();
}
List<String> userIds = activityBasicScopeService.listGroupUserIds(groupId);
if (CollUtil.isEmpty(userIds)) {
return new ArrayList<>();
}
List<View_user> users = new ArrayList<>();
for (View_user user : queryUsers(userIds)) {
if (user != null && StrUtil.isNotBlank(user.getLoginname())) {
users.add(user);
}
}
return users;
}
/**
* 提取当前可参与人员工号集合,用于判断已有台账人员是否已被移出可参与范围。
*/
private Set<String> collectEligibleJobNos(List<View_user> users) {
Set<String> jobNos = new HashSet<>();
if (CollUtil.isEmpty(users)) {
return jobNos;
}
for (View_user user : users) {
if (user != null && StrUtil.isNotBlank(user.getLoginname())) {
jobNos.add(user.getLoginname());
}
}
return jobNos;
}
/**
* 分批查询用户视图,避免大范围人员组别生成过长的 SQL IN 条件。
*/ */
private List<View_user> queryUsers(List<String> userIds) { private List<View_user> queryUsers(List<String> userIds) {
List<View_user> users = new ArrayList<>(); List<View_user> users = new ArrayList<>();
@@ -193,7 +238,7 @@ public class TourParticipantRosterServiceImpl extends BaseServiceImpl<TourPartic
} }
/** /**
* Inserts new eligible users and refreshes basic organization data for existing rows. * 新增可参与人员,或刷新已存在人员的基础组织信息。
*/ */
private void upsertRosterUser(String settingId, View_user user) { private void upsertRosterUser(String settingId, View_user user) {
TourParticipantRoster roster = fetchRoster(settingId, user.getLoginname()); TourParticipantRoster roster = fetchRoster(settingId, user.getLoginname());
@@ -216,9 +261,11 @@ public class TourParticipantRosterServiceImpl extends BaseServiceImpl<TourPartic
} }
/** /**
* Replays active tour ledgers under one setting so rebuilt rosters keep the current signup state. * 回放配置下有效疗休养台账,使重建后的名单保持当前报名状态。
* 对已不在可参与人员中但存在台账的人员,继续保留名单记录并返回人员名称用于页面提示。
*/ */
private void replayLedgerJoinedState(String settingId) { private List<String> replayLedgerJoinedState(String settingId, Set<String> eligibleJobNos) {
Set<String> retainedUserNames = new LinkedHashSet<>();
Sql sql = Sqls.create(""" Sql sql = Sqls.create("""
SELECT t.* SELECT t.*
FROM tour_ledger t FROM tour_ledger t
@@ -239,12 +286,16 @@ public class TourParticipantRosterServiceImpl extends BaseServiceImpl<TourPartic
continue; continue;
} }
handledJobNos.add(ledger.getJobNo()); handledJobNos.add(ledger.getJobNo());
if (eligibleJobNos == null || !eligibleJobNos.contains(ledger.getJobNo())) {
retainedUserNames.add(displayLedgerUserName(ledger));
}
markJoinedByLedger(ledger); markJoinedByLedger(ledger);
} }
return new ArrayList<>(retainedUserNames);
} }
/** /**
* Finds an active roster row by setting and job number, which is the logical unique key. * 按配置和工号查找有效人员名单记录,这两个字段是业务逻辑唯一键。
*/ */
private TourParticipantRoster fetchRoster(String settingId, String jobNo) { private TourParticipantRoster fetchRoster(String settingId, String jobNo) {
return fetch(Cnd.where("settingId", "=", settingId) return fetch(Cnd.where("settingId", "=", settingId)
@@ -253,7 +304,7 @@ public class TourParticipantRosterServiceImpl extends BaseServiceImpl<TourPartic
} }
/** /**
* Resolves setting id through the ledger matter because tour_ledger intentionally does not store settingId. * 通过台账关联的事项解析配置 id,因为 tour_ledger 本身不直接保存 settingId。
*/ */
private String resolveSettingId(TourLedger ledger) { private String resolveSettingId(TourLedger ledger) {
if (ledger == null || StrUtil.isBlank(ledger.getMatterId())) { if (ledger == null || StrUtil.isBlank(ledger.getMatterId())) {
@@ -264,7 +315,7 @@ public class TourParticipantRosterServiceImpl extends BaseServiceImpl<TourPartic
} }
/** /**
* Copies staff base data from the user view into the roster row. * 从用户视图复制人员基础信息到名单记录。
*/ */
private void fillRosterFromUser(TourParticipantRoster roster, View_user user) { private void fillRosterFromUser(TourParticipantRoster roster, View_user user) {
roster.setUserName(StrUtil.blankToDefault(user.getUsername(), "")); roster.setUserName(StrUtil.blankToDefault(user.getUsername(), ""));
@@ -275,7 +326,7 @@ public class TourParticipantRosterServiceImpl extends BaseServiceImpl<TourPartic
} }
/** /**
* Copies staff and organization data from the ledger when roster sync has not created the row yet. * 当名单同步尚未创建该人员时,从台账复制人员和组织信息补入名单。
*/ */
private void fillRosterFromLedger(TourParticipantRoster roster, TourLedger ledger) { private void fillRosterFromLedger(TourParticipantRoster roster, TourLedger ledger) {
roster.setUserName(StrUtil.blankToDefault(ledger.getUserName(), "")); roster.setUserName(StrUtil.blankToDefault(ledger.getUserName(), ""));
@@ -285,4 +336,23 @@ public class TourParticipantRosterServiceImpl extends BaseServiceImpl<TourPartic
roster.setUnionName(StrUtil.blankToDefault(ledger.getUnionName(), "")); roster.setUnionName(StrUtil.blankToDefault(ledger.getUnionName(), ""));
roster.setLedgerId(StrUtil.blankToDefault(ledger.getId(), "")); roster.setLedgerId(StrUtil.blankToDefault(ledger.getId(), ""));
} }
/**
* 构造“有台账但已不在可参与人员范围中”的配置级提示。
*/
private String buildRetainedLedgerUserMessage(TourSetting setting, List<String> userNames) {
return StrUtil.blankToDefault(setting.getConfigName(), "未命名配置")
+ "(" + setting.getYear() + "):【" + String.join("、", userNames)
+ "】已不在“可参与人员”中,但已生成台账,无法剔除这类人员";
}
/**
* 优先显示台账姓名,姓名为空时使用工号,保证提示中始终能定位到具体人员。
*/
private String displayLedgerUserName(TourLedger ledger) {
if (StrUtil.isNotBlank(ledger.getUserName())) {
return ledger.getUserName();
}
return StrUtil.blankToDefault(ledger.getJobNo(), "未知人员");
}
} }
@@ -136,9 +136,16 @@ layout("/layouts/platform.html"){
<el-dialog <el-dialog
title="重新生成名单" title="重新生成名单"
:visible.sync="rebuildDialogVisible" :visible.sync="rebuildDialogVisible"
:close-on-click-modal="false" :close-on-click-modal="!rebuildSubmitting"
:close-on-press-escape="!rebuildSubmitting"
:show-close="!rebuildSubmitting"
width="620px"> width="620px">
<el-form :model="rebuildForm" label-width="110px" size="medium"> <el-form
v-loading="rebuildSubmitting"
element-loading-text="正在重新生成名单,请稍候"
:model="rebuildForm"
label-width="110px"
size="medium">
<el-form-item label="开始年度" required> <el-form-item label="开始年度" required>
<el-date-picker <el-date-picker
v-model="rebuildForm.startYear" v-model="rebuildForm.startYear"
@@ -177,8 +184,8 @@ layout("/layouts/platform.html"){
</el-form-item> </el-form-item>
</el-form> </el-form>
<span slot="footer" class="dialog-footer"> <span slot="footer" class="dialog-footer">
<el-button @click="rebuildDialogVisible = false">取消</el-button> <el-button :disabled="rebuildSubmitting" @click="rebuildDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitRebuildRoster">确认</el-button> <el-button type="primary" :loading="rebuildSubmitting" :disabled="rebuildSubmitting" @click="submitRebuildRoster">确认</el-button>
</span> </span>
</el-dialog> </el-dialog>
</div> </div>
@@ -215,6 +222,21 @@ layout("/layouts/platform.html"){
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.tour-rebuild-warning-message {
width: 680px;
}
.tour-rebuild-warning-list {
max-height: 360px;
overflow-y: auto;
line-height: 24px;
}
.tour-rebuild-warning-list p {
margin: 0 0 8px;
color: #606266;
}
</style> </style>
<script nonce="${cspNonce!}"> <script nonce="${cspNonce!}">
@@ -230,6 +252,7 @@ layout("/layouts/platform.html"){
unionOptions: [], unionOptions: [],
optionTimer: null, optionTimer: null,
rebuildDialogVisible: false, rebuildDialogVisible: false,
rebuildSubmitting: false,
rebuildForm: { rebuildForm: {
startYear: currentYear, startYear: currentYear,
endYear: currentYear, endYear: currentYear,
@@ -376,6 +399,7 @@ layout("/layouts/platform.html"){
}, },
openRebuildDialog() { openRebuildDialog() {
const currentYear = moment().format("YYYY") const currentYear = moment().format("YYYY")
this.rebuildSubmitting = false
this.rebuildForm.startYear = currentYear this.rebuildForm.startYear = currentYear
this.rebuildForm.endYear = currentYear this.rebuildForm.endYear = currentYear
this.rebuildForm.settingIds = [] this.rebuildForm.settingIds = []
@@ -387,6 +411,9 @@ layout("/layouts/platform.html"){
this.loadRebuildSettingOptions() this.loadRebuildSettingOptions()
}, },
submitRebuildRoster() { submitRebuildRoster() {
if (this.rebuildSubmitting) {
return
}
if (!this.checkRebuildYearRange()) { if (!this.checkRebuildYearRange()) {
return return
} }
@@ -399,22 +426,52 @@ layout("/layouts/platform.html"){
cancelButtonText: "取消", cancelButtonText: "取消",
type: "warning" type: "warning"
}).then(() => { }).then(() => {
this.$axios.post(loc() + "/rebuildRoster", { // 重新生成名单会删除后重建所选配置人员数据,提交期间锁定弹窗避免重复点击。
startYear: this.rebuildForm.startYear, this.rebuildSubmitting = true
endYear: this.rebuildForm.endYear, this.$nextTick(() => {
settingIds: JSON.stringify(this.rebuildForm.settingIds) this.$axios.post(loc() + "/rebuildRoster", {
}).then((res) => { startYear: this.rebuildForm.startYear,
if (res.code === 0) { endYear: this.rebuildForm.endYear,
this.$message.success(res.msg || "人员名单重新生成成功") settingIds: JSON.stringify(this.rebuildForm.settingIds)
this.rebuildDialogVisible = false }).then((res) => {
this.pageData() if (res.code === 0) {
this.loadOptions() this.$message.success(res.msg || "人员名单重新生成成功")
} else { this.showRebuildWarnings(res.data && res.data.warnings ? res.data.warnings : [])
this.$message.warning(res.msg || "人员名单重新生成失败") this.rebuildDialogVisible = false
} this.pageData()
this.loadOptions()
} else {
this.$message.warning(res.msg || "人员名单重新生成失败")
}
}, () => {
this.$message.error("人员名单重新生成失败")
}).then(() => {
this.rebuildSubmitting = false
})
}) })
}) })
}, },
showRebuildWarnings(warnings) {
if (!warnings || !warnings.length) {
return
}
const content = "<div class=\"tour-rebuild-warning-list\">" + warnings.map(item => {
return "<p>" + this.escapeHtml(item) + "</p>"
}).join("") + "</div>"
this.$alert(content, "无法剔除人员提醒", {
dangerouslyUseHTMLString: true,
confirmButtonText: "我知道了",
customClass: "tour-rebuild-warning-message"
})
},
escapeHtml(value) {
return String(value || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
},
scheduleLoadOptions() { scheduleLoadOptions() {
if (this.optionTimer) { if (this.optionTimer) {
clearTimeout(this.optionTimer) clearTimeout(this.optionTimer)