疗休养人员名单
This commit is contained in:
+3
-2
@@ -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("人员名单重新生成成功");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+16
-14
@@ -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);
|
||||
}
|
||||
|
||||
+104
-34
@@ -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(), "未知人员");
|
||||
}
|
||||
}
|
||||
|
||||
+74
-17
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
},
|
||||
scheduleLoadOptions() {
|
||||
if (this.optionTimer) {
|
||||
clearTimeout(this.optionTimer)
|
||||
|
||||
Reference in New Issue
Block a user