疗休养人员名单

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) {
return settingResult;
}
tourParticipantRosterService.rebuildSettingRosters(settingIdList);
return Result.success().addMsg("人员名单重新生成成功");
List<String> warningMessages = tourParticipantRosterService.rebuildSettingRosters(settingIdList);
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;
/**
* Service for maintaining the tour participant roster used by yearly joined/not-joined statistics.
* 疗休养人员名单维护服务。
* 用于按配置维护可参与人员基础名单,并同步报名台账中的参加状态。
*/
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);
/**
* 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);
/**
* 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);
/**
* 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);
/**
* 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);
}
@@ -22,11 +22,13 @@ import org.nutz.ioc.loader.annotation.IocBean;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashSet;
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<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
public void syncSettingRoster(String settingId) {
@@ -49,28 +51,16 @@ public class TourParticipantRosterServiceImpl extends BaseServiceImpl<TourPartic
return;
}
TourSetting setting = dao().fetch(TourSetting.class, settingId);
if (setting == null || StrUtil.isBlank(setting.getActivityGroupId())) {
if (setting == null) {
return;
}
Integer groupId = parseGroupId(setting.getActivityGroupId());
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;
}
for (View_user user : listEligibleUsers(setting)) {
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) {
@@ -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
@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)) {
return;
return warningMessages;
}
for (String settingId : settingIds) {
if (StrUtil.isBlank(settingId)) {
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));
syncSettingRoster(settingId);
replayLedgerJoinedState(settingId);
for (View_user user : eligibleUsers) {
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
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
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
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) {
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) {
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) {
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("""
SELECT t.*
FROM tour_ledger t
@@ -239,12 +286,16 @@ public class TourParticipantRosterServiceImpl extends BaseServiceImpl<TourPartic
continue;
}
handledJobNos.add(ledger.getJobNo());
if (eligibleJobNos == null || !eligibleJobNos.contains(ledger.getJobNo())) {
retainedUserNames.add(displayLedgerUserName(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) {
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) {
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) {
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) {
roster.setUserName(StrUtil.blankToDefault(ledger.getUserName(), ""));
@@ -285,4 +336,23 @@ public class TourParticipantRosterServiceImpl extends BaseServiceImpl<TourPartic
roster.setUnionName(StrUtil.blankToDefault(ledger.getUnionName(), ""));
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
title="重新生成名单"
:visible.sync="rebuildDialogVisible"
:close-on-click-modal="false"
:close-on-click-modal="!rebuildSubmitting"
:close-on-press-escape="!rebuildSubmitting"
:show-close="!rebuildSubmitting"
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-date-picker
v-model="rebuildForm.startYear"
@@ -177,8 +184,8 @@ layout("/layouts/platform.html"){
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="rebuildDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitRebuildRoster">确认</el-button>
<el-button :disabled="rebuildSubmitting" @click="rebuildDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="rebuildSubmitting" :disabled="rebuildSubmitting" @click="submitRebuildRoster">确认</el-button>
</span>
</el-dialog>
</div>
@@ -215,6 +222,21 @@ layout("/layouts/platform.html"){
text-overflow: ellipsis;
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>
<script nonce="${cspNonce!}">
@@ -230,6 +252,7 @@ layout("/layouts/platform.html"){
unionOptions: [],
optionTimer: null,
rebuildDialogVisible: false,
rebuildSubmitting: false,
rebuildForm: {
startYear: currentYear,
endYear: currentYear,
@@ -376,6 +399,7 @@ layout("/layouts/platform.html"){
},
openRebuildDialog() {
const currentYear = moment().format("YYYY")
this.rebuildSubmitting = false
this.rebuildForm.startYear = currentYear
this.rebuildForm.endYear = currentYear
this.rebuildForm.settingIds = []
@@ -387,6 +411,9 @@ layout("/layouts/platform.html"){
this.loadRebuildSettingOptions()
},
submitRebuildRoster() {
if (this.rebuildSubmitting) {
return
}
if (!this.checkRebuildYearRange()) {
return
}
@@ -399,22 +426,52 @@ layout("/layouts/platform.html"){
cancelButtonText: "取消",
type: "warning"
}).then(() => {
this.$axios.post(loc() + "/rebuildRoster", {
startYear: this.rebuildForm.startYear,
endYear: this.rebuildForm.endYear,
settingIds: JSON.stringify(this.rebuildForm.settingIds)
}).then((res) => {
if (res.code === 0) {
this.$message.success(res.msg || "人员名单重新生成成功")
this.rebuildDialogVisible = false
this.pageData()
this.loadOptions()
} else {
this.$message.warning(res.msg || "人员名单重新生成失败")
}
// 重新生成名单会删除后重建所选配置人员数据,提交期间锁定弹窗避免重复点击。
this.rebuildSubmitting = true
this.$nextTick(() => {
this.$axios.post(loc() + "/rebuildRoster", {
startYear: this.rebuildForm.startYear,
endYear: this.rebuildForm.endYear,
settingIds: JSON.stringify(this.rebuildForm.settingIds)
}).then((res) => {
if (res.code === 0) {
this.$message.success(res.msg || "人员名单重新生成成功")
this.showRebuildWarnings(res.data && res.data.warnings ? res.data.warnings : [])
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() {
if (this.optionTimer) {
clearTimeout(this.optionTimer)