package jnpf.tmsService.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import jnpf.audit.sdk.annotation.AuditLog; import jnpf.exception.DataException; import jnpf.permission.OrganizeApi; import jnpf.permission.UserApi; import jnpf.permission.entity.OrganizeEntity; import jnpf.permission.entity.UserEntity; import jnpf.tmsEntity.TmsAnnualPlanItemEntity; import jnpf.tmsEntity.TmsMaterialEntity; import jnpf.tmsEntity.TmsPersonFileEntity; import jnpf.tmsEntity.TmsPersonTaskEntity; import jnpf.tmsEntity.TmsPersonTrainEntity; import jnpf.tmsEntity.TmsPostPlanItemEntity; import jnpf.tmsEntity.TmsQuizScoreEntity; import jnpf.tmsEntity.TmsRecordEntity; import jnpf.tmsEntity.TmsRecordSignEntity; import jnpf.tmsEntity.TmsSignModeEntity; import jnpf.tmsEntity.TmsTaskEntity; import jnpf.tmsEntity.TmsTaskFileEntity; import jnpf.tmsEntity.TmsTaskQuizEntity; import jnpf.tmsEntity.TmsTrainModeEntity; import jnpf.tmsEntity.person.TmsPersonTaskSignResult; import jnpf.tmsEntity.record.TmsQuizAnswerPaperVO; import jnpf.tmsEntity.record.TmsRecordAttachmentForm; import jnpf.tmsEntity.record.TmsRecordForm; import jnpf.tmsEntity.record.TmsRecordIncompleteVO; import jnpf.tmsEntity.record.TmsRecordParticipantForm; import jnpf.tmsEntity.record.TmsRecordProgressVO; import jnpf.tmsEntity.record.TmsRecordQuery; import jnpf.tmsEntity.record.TmsRecordScanSignForm; import jnpf.tmsEntity.record.TmsRecordSignForm; import jnpf.tmsEntity.record.TmsRecordSignQrModeVO; import jnpf.tmsEntity.record.TmsRecordSignQrVO; import jnpf.tmsMapper.TmsAnnualPlanItemMapper; import jnpf.tmsMapper.TmsMaterialMapper; import jnpf.tmsMapper.TmsPersonFileMapper; import jnpf.tmsMapper.TmsPersonTaskMapper; import jnpf.tmsMapper.TmsPersonTrainMapper; import jnpf.tmsMapper.TmsPostPlanItemMapper; import jnpf.tmsMapper.TmsQuizScoreMapper; import jnpf.tmsMapper.TmsRecordMapper; import jnpf.tmsMapper.TmsRecordSignMapper; import jnpf.tmsMapper.TmsSignModeMapper; import jnpf.tmsMapper.TmsTaskFileMapper; import jnpf.tmsMapper.TmsTaskMapper; import jnpf.tmsMapper.TmsTaskQuizMapper; import jnpf.tmsMapper.TmsTrainModeMapper; import jnpf.tmsService.TmsPersonTaskService; import jnpf.tmsService.TmsRecordService; import jnpf.tmsEntity.enums.TmsPassFlag; import jnpf.tmsEntity.enums.TmsTaskKind; import jnpf.tmsEntity.enums.TmsTaskCategory; import jnpf.tmsEntity.enums.TmsTaskPublishStatus; import jnpf.tmsEntity.enums.TmsArchiveStatus; import jnpf.tmsEntity.enums.TmsTaskSourceType; import jnpf.tmsEntity.enums.TmsPlanLineStatus; import jnpf.tmsEntity.enums.TmsTrainMode; import jnpf.tmsEntity.enums.TmsSignRule; import jnpf.tmsEntity.enums.TmsSignMode; import jnpf.tmsEntity.enums.TmsYesNo; import jnpf.tmsEntity.enums.TmsEvalMode; import jnpf.tmsEntity.enums.TmsValidRevoked; import jnpf.tmsUtil.TmsQuizHelper; import jnpf.util.DateUtil; import jnpf.util.RandomUtil; import jnpf.util.StringUtil; import jnpf.util.UserProvider; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; /** * 培训记录:签到二维码、归档状态机、归档写个人培训目录。 * *

归档状态({@link jnpf.tmsEntity.enums.TmsArchiveStatus}): * open → pending(关闭时间到)→ ready(可选)→ archived;或 invalid。

*

归档成功后才写入 {@code tms_person_train}(个人档案「培训目录」); * 学员学完/考过不会直接写目录。

* * @see #archive(java.util.List) * @see #archiveOne(jnpf.tmsEntity.TmsRecordEntity, java.util.Date, String) */ @Service @RequiredArgsConstructor public class TmsRecordServiceImpl implements TmsRecordService { private static final String FMT = "yyyy-MM-dd HH:mm:ss"; private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private static final long SIGN_QR_TTL_MS = 5L * 60L * 1000L; private static final String PREFIX_DRAFT = "TD"; /** 归档/作废产生的再培训草稿:category 固定 retrain(字典 tmsTaskCategory) */ private final TmsRecordMapper recordMapper; private final TmsRecordSignMapper recordSignMapper; private final TmsTaskMapper taskMapper; private final TmsTaskFileMapper taskFileMapper; private final TmsMaterialMapper materialMapper; private final TmsPersonTaskMapper personTaskMapper; private final TmsPersonTrainMapper personTrainMapper; private final TmsPersonFileMapper personFileMapper; private final TmsTrainModeMapper trainModeMapper; private final TmsSignModeMapper signModeMapper; private final TmsAnnualPlanItemMapper annualPlanItemMapper; private final TmsPostPlanItemMapper postPlanItemMapper; private final TmsPersonTaskService personTaskService; private final TmsQuizScoreMapper quizScoreMapper; private final TmsTaskQuizMapper taskQuizMapper; private final UserApi userApi; private final OrganizeApi organizeApi; /** * 培训记录:条件分页查询列表 */ @Override public List getList(TmsRecordQuery query) { LambdaQueryWrapper qw = new LambdaQueryWrapper<>(); applyListMode(qw, query); if (StringUtil.isNotEmpty(query.getRecordNo())) { qw.like(TmsRecordEntity::getRecordNo, query.getRecordNo().trim()); } if (StringUtil.isNotEmpty(query.getSubject())) { qw.like(TmsRecordEntity::getSubject, query.getSubject().trim()); } if (StringUtil.isNotEmpty(query.getCategory())) { qw.eq(TmsRecordEntity::getCategory, query.getCategory().trim()); } if (StringUtil.isNotEmpty(query.getTrainMode())) { qw.eq(TmsRecordEntity::getTrainMode, query.getTrainMode().trim()); } if (StringUtil.isNotEmpty(query.getEvalMode())) { qw.eq(TmsRecordEntity::getEvalMode, query.getEvalMode().trim()); } if (StringUtil.isNotEmpty(query.getKeyword()) && !"null".equalsIgnoreCase(query.getKeyword())) { String kw = query.getKeyword().trim(); qw.and(w -> w.like(TmsRecordEntity::getRecordNo, kw) .or().like(TmsRecordEntity::getSubject, kw)); } Date from = parseDayStart(query.getStartTimeFrom()); Date to = parseDayEnd(query.getStartTimeTo()); if (from != null) { qw.ge(TmsRecordEntity::getStartTime, from); } if (to != null) { qw.le(TmsRecordEntity::getStartTime, to); } Date endFrom = parseDayStart(query.getEndTimeFrom()); Date endTo = parseDayEnd(query.getEndTimeTo()); if (endFrom != null) { qw.ge(TmsRecordEntity::getEndTime, endFrom); } if (endTo != null) { qw.le(TmsRecordEntity::getEndTime, endTo); } qw.orderByDesc(TmsRecordEntity::getStartTime).orderByDesc(TmsRecordEntity::getCreatorTime); long current = query.getCurrentPage() > 0 ? query.getCurrentPage() : 1; long size = query.getPageSize() > 0 ? query.getPageSize() : 20; Page page = recordMapper.selectPage(new Page<>(current, size), qw); List rows = page.getRecords(); Map names = loadUserNames(rows.stream() .map(TmsRecordEntity::getTeacherId) .filter(StringUtil::isNotEmpty) .distinct() .collect(Collectors.toList())); List list = new ArrayList<>(rows.size()); String trainerFilter = StringUtil.isNotEmpty(query.getTrainerName()) ? query.getTrainerName().trim() : null; for (TmsRecordEntity row : rows) { TmsRecordForm form = toListForm(row, names); if (trainerFilter != null) { String name = form.getTrainerName() == null ? "" : form.getTrainerName(); if (!name.contains(trainerFilter)) { continue; } } list.add(form); } query.setData(list, page.getTotal()); return list; } /** * 培训记录:按主键查询详情 */ @Override public TmsRecordForm getInfo(String id) { TmsRecordEntity entity = requireOne(id); Map teacherNames = loadUserNames( StringUtil.isEmpty(entity.getTeacherId()) ? Collections.emptyList() : Collections.singletonList(entity.getTeacherId())); TmsRecordForm form = toListForm(entity, teacherNames); TmsTaskEntity task = StringUtil.isEmpty(entity.getTaskId()) ? null : taskMapper.selectById(entity.getTaskId()); if (task != null) { form.setKeyPoints(task.getKeyPoints()); form.setPlaceName(task.getPlaceName()); if (StringUtil.isEmpty(form.getTrainType()) && StringUtil.isNotEmpty(task.getTrainType())) { form.setTrainType(task.getTrainType()); } if (StringUtil.isEmpty(form.getTrainMode()) && StringUtil.isNotEmpty(task.getTrainMode())) { form.setTrainMode(task.getTrainMode()); } } String trainMode = StringUtil.isNotEmpty(form.getTrainMode()) ? form.getTrainMode() : (task != null ? task.getTrainMode() : null); String signRule = resolveSignRule(trainMode); form.setSignRule(signRule); form.setCanScanSign(canScanByRule(signRule)); List persons = StringUtil.isEmpty(entity.getTaskId()) ? Collections.emptyList() : personTaskMapper.selectList(new LambdaQueryWrapper() .eq(TmsPersonTaskEntity::getTaskId, entity.getTaskId()) .orderByAsc(TmsPersonTaskEntity::getCreatorTime)); Map userNames = loadUserNames(persons.stream() .map(TmsPersonTaskEntity::getUserId) .filter(StringUtil::isNotEmpty) .distinct() .collect(Collectors.toList())); Set deptIds = new HashSet<>(); for (TmsPersonTaskEntity p : persons) { if (p != null && StringUtil.isNotEmpty(p.getDeptId())) { deptIds.add(p.getDeptId()); } } Map deptNames = loadDeptNames(deptIds); List participants = new ArrayList<>(persons.size()); List signs = new ArrayList<>(); List traineeNames = new ArrayList<>(); // 优先读签到明细表;无数据时回退 person_task 投影 List signRows = recordSignMapper.selectList( new LambdaQueryWrapper() .eq(TmsRecordSignEntity::getForeignId, entity.getId()) .orderByAsc(TmsRecordSignEntity::getSignTime)); Map signByUser = new HashMap<>(); if (signRows != null) { for (TmsRecordSignEntity s : signRows) { if (s != null && StringUtil.isNotEmpty(s.getUserId())) { signByUser.putIfAbsent(s.getUserId(), s); } } } Set quizAnswerUsers = loadQuizAnswerUserIds(entity.getTaskId(), entity.getId()); for (TmsPersonTaskEntity p : persons) { String userName = userNames.getOrDefault(p.getUserId(), p.getUserId()); traineeNames.add(userName); TmsRecordParticipantForm pf = new TmsRecordParticipantForm(); pf.setId(p.getId()); pf.setUserId(p.getUserId()); pf.setUserName(userName); pf.setDeptName(deptNames.getOrDefault(p.getDeptId(), "")); pf.setSignDate(formatDate(p.getSignTime())); pf.setExamScore(p.getExamScore()); pf.setPassFlag(p.getPassFlag()); TmsRecordSignEntity sign = signByUser.get(p.getUserId()); if (sign != null && sign.getQuizScore() != null) { pf.setQuizScore(sign.getQuizScore()); } pf.setHasQuizAnswer(quizAnswerUsers.contains(p.getUserId())); participants.add(pf); } if (signRows != null && !signRows.isEmpty()) { Map signNames = loadUserNames(signRows.stream() .map(TmsRecordSignEntity::getUserId) .filter(StringUtil::isNotEmpty) .distinct() .collect(Collectors.toList())); for (TmsRecordSignEntity s : signRows) { TmsRecordSignForm sf = new TmsRecordSignForm(); sf.setId(s.getId()); sf.setUserName(StringUtil.isNotEmpty(s.getUserName()) ? s.getUserName() : signNames.getOrDefault(s.getUserId(), s.getUserId())); sf.setSignTime(formatDate(s.getSignTime())); sf.setSignMode(s.getSignMode()); sf.setGuestFlag(s.getGuestFlag()); signs.add(sf); } } else { for (TmsPersonTaskEntity p : persons) { if (p.getSignTime() == null) { continue; } String userName = userNames.getOrDefault(p.getUserId(), p.getUserId()); TmsRecordSignForm sf = new TmsRecordSignForm(); sf.setId(p.getId()); sf.setUserName(userName); sf.setSignTime(formatDate(p.getSignTime())); sf.setSignMode(p.getSignMode()); sf.setGuestFlag(p.getGuestFlag()); signs.add(sf); } } form.setParticipants(participants); form.setSignList(signs); form.setTrainees(String.join(",", traineeNames)); form.setAttachments(loadAttachments(entity)); form.setRemark(entity.getRemark()); return form; } /** * 获取签到二维码信息 */ @Override public TmsRecordSignQrVO getSignQr(String id) { TmsRecordEntity entity = requireOne(id); if (TmsArchiveStatus.ARCHIVED.equalsCode(entity.getArchiveStatus())) { throw new DataException("已存档记录不可生成签到码"); } TmsTaskEntity task = StringUtil.isEmpty(entity.getTaskId()) ? null : taskMapper.selectById(entity.getTaskId()); String trainMode = StringUtil.isNotEmpty(entity.getTrainMode()) ? entity.getTrainMode() : (task != null ? task.getTrainMode() : null); String signRule = resolveSignRule(trainMode); TmsRecordSignQrVO vo = new TmsRecordSignQrVO(); vo.setRecordId(entity.getId()); vo.setTaskId(entity.getTaskId()); vo.setSubject(entity.getSubject()); vo.setSignRule(signRule); vo.setModes(Collections.emptyList()); if (!canScanByRule(signRule)) { vo.setCanScanSign(false); vo.setTip("当前培训方式不支持扫码签到,请学员在「个人培训任务」中在线签到"); return vo; } List scanModes = signModeMapper.selectList( new LambdaQueryWrapper() .eq(TmsSignModeEntity::getEnabled, TmsYesNo.YES.getCode()) .in(TmsSignModeEntity::getModeCode, Arrays.asList( TmsSignMode.APP_SCAN.getCode(), TmsSignMode.WX_SCAN.getCode())) .orderByAsc(TmsSignModeEntity::getModeCode)); List modes = new ArrayList<>(); for (TmsSignModeEntity sm : scanModes) { TmsRecordSignQrModeVO m = new TmsRecordSignQrModeVO(); m.setModeCode(sm.getModeCode()); m.setModeName(sm.getModeName()); modes.add(m); } vo.setModes(modes); if (modes.isEmpty()) { vo.setCanScanSign(false); vo.setTip("暂无启用的扫码签到方式(APP扫码/微信扫码),请先在签到方式中启用"); return vo; } long expMs = System.currentTimeMillis() + SIGN_QR_TTL_MS; Date expireAt = new Date(expMs); Map payload = new LinkedHashMap<>(); payload.put("t", "tms_sign"); payload.put("recordId", entity.getId()); payload.put("taskId", entity.getTaskId()); payload.put("exp", expMs); try { vo.setQrContent(OBJECT_MAPPER.writeValueAsString(payload)); } catch (Exception e) { throw new DataException("生成签到码失败"); } vo.setExpireAt(DateUtil.dateToString(expireAt, FMT)); vo.setCanScanSign(true); vo.setTip(""); return vo; } /** * 扫码签到 */ @Override @Transactional(rollbackFor = Exception.class) public TmsPersonTaskSignResult scanSign(TmsRecordScanSignForm form) { if (form == null) { throw new DataException("签到参数不能为空"); } return personTaskService.scanSign(form.getQrContent(), form.getSignMode()); } /** * 解析签到规则 */ private String resolveSignRule(String trainMode) { if (StringUtil.isEmpty(trainMode)) { return ""; } TmsTrainModeEntity mode = trainModeMapper.selectOne( new LambdaQueryWrapper() .eq(TmsTrainModeEntity::getModeCode, trainMode) .last("LIMIT 1")); if (mode != null && StringUtil.isNotEmpty(mode.getSignRule())) { return mode.getSignRule().trim(); } return TmsTrainMode.ONLINE.equalsCodeIgnoreCase(trainMode) ? TmsSignRule.BOTH.getCode() : TmsSignRule.SCAN.getCode(); } /** * 按规则判断是否允许扫码签到 */ private boolean canScanByRule(String signRule) { return TmsSignRule.SCAN.equalsCodeIgnoreCase(signRule) || TmsSignRule.BOTH.equalsCodeIgnoreCase(signRule); } /** * 培训记录:更新 */ @Override @Transactional(rollbackFor = Exception.class) public void update(String id, TmsRecordForm form) { TmsRecordEntity entity = requireOne(id); if (form == null) { throw new DataException("表单不能为空"); } if (StringUtil.isNotEmpty(form.getSubject())) { entity.setSubject(form.getSubject().trim()); } if (form.getCategory() != null) { entity.setCategory(form.getCategory()); } if (form.getTrainType() != null) { entity.setTrainType(form.getTrainType()); } if (form.getTrainMode() != null) { entity.setTrainMode(form.getTrainMode()); } if (form.getEvalMode() != null) { entity.setEvalMode(form.getEvalMode()); } entity.setRemark(form.getRemark()); entity.setLastModifyUserId(UserProvider.getLoginUserId()); entity.setLastModifyTime(new Date()); recordMapper.updateById(entity); } /** * 按结束时间将记录置为可归档 */ @Override @Transactional(rollbackFor = Exception.class) public int markReadyByCloseTime() { Date now = new Date(); // FDS:关闭时间到 → pending;进行中为 open List rows = recordMapper.selectList( new LambdaQueryWrapper() .eq(TmsRecordEntity::getArchiveStatus, TmsArchiveStatus.OPEN.getCode()) .isNotNull(TmsRecordEntity::getCloseTime) .le(TmsRecordEntity::getCloseTime, now)); if (rows == null || rows.isEmpty()) { return 0; } String userId = UserProvider.getLoginUserId(); int count = 0; for (TmsRecordEntity entity : rows) { if (entity == null || StringUtil.isEmpty(entity.getId())) { continue; } entity.setArchiveStatus(TmsArchiveStatus.PENDING.getCode()); entity.setLastModifyUserId(userId); entity.setLastModifyTime(now); recordMapper.updateById(entity); count++; } return count; } /** * 推进为可归档 */ @Override @Transactional(rollbackFor = Exception.class) public int promoteToReady(List ids) { if (ids == null || ids.isEmpty()) { throw new DataException("请选择要转入可归档的记录"); } int count = 0; Date now = new Date(); String userId = UserProvider.getLoginUserId(); for (String id : ids) { if (StringUtil.isEmpty(id)) { continue; } TmsRecordEntity entity = recordMapper.selectById(id); if (entity == null) { continue; } if (!TmsArchiveStatus.PENDING.equalsCode(entity.getArchiveStatus())) { throw new DataException("仅待归档状态可转入可归档:" + entity.getRecordNo()); } entity.setArchiveStatus(TmsArchiveStatus.READY.getCode()); entity.setLastModifyUserId(userId); entity.setLastModifyTime(now); recordMapper.updateById(entity); count++; } return count; } /** * 恢复为待处理 */ @Override @Transactional(rollbackFor = Exception.class) public int restoreToPending(List ids) { if (ids == null || ids.isEmpty()) { throw new DataException("请选择要恢复的记录"); } int count = 0; Date now = new Date(); String userId = UserProvider.getLoginUserId(); for (String id : ids) { if (StringUtil.isEmpty(id)) { continue; } TmsRecordEntity entity = recordMapper.selectById(id); if (entity == null) { continue; } if (!TmsArchiveStatus.READY.equalsCode(entity.getArchiveStatus())) { throw new DataException("仅可归档状态可恢复为待归档:" + entity.getRecordNo()); } entity.setArchiveStatus(TmsArchiveStatus.PENDING.getCode()); entity.setLastModifyUserId(userId); entity.setLastModifyTime(now); recordMapper.updateById(entity); count++; } return count; } /** * 批量归档:仅 pending / ready 可归档。 * 每条走 {@link #archiveOne}(写 person_train、任务 done、未完成人再培训草稿)。 */ @Override @Transactional(rollbackFor = Exception.class) @AuditLog(action = "RECORD_ARCHIVE", label = "归档培训记录", bizType = "TMS_RECORD", bizCodeExpr = "#ids != null && !#ids.isEmpty() ? #ids[0] : 'BATCH'", targetTable = "tms_record", targetIdExpr = "#ids != null && !#ids.isEmpty() ? #ids[0] : ''", targetIdsExpr = "#ids", bizCodeRequired = false) /** * 归档 */ public int archive(List ids) { if (ids == null || ids.isEmpty()) { throw new DataException("请选择要归档的记录"); } int count = 0; Date now = new Date(); String userId = UserProvider.getLoginUserId(); for (String id : ids) { if (StringUtil.isEmpty(id)) { continue; } TmsRecordEntity entity = recordMapper.selectById(id); if (entity == null) { continue; } String st = entity.getArchiveStatus(); if (!TmsArchiveStatus.PENDING.equalsCode(st) && !TmsArchiveStatus.READY.equalsCode(st)) { throw new DataException("仅待归档/可归档状态下的记录可归档:" + entity.getRecordNo()); } archiveOne(entity, now, userId); count++; } return count; } @Override @Transactional(rollbackFor = Exception.class) @AuditLog(action = "RECORD_INVALID", label = "作废培训记录", bizType = "TMS_RECORD", bizCodeExpr = "#ids != null && !#ids.isEmpty() ? #ids[0] : 'BATCH'", targetTable = "tms_record", targetIdExpr = "#ids != null && !#ids.isEmpty() ? #ids[0] : ''", targetIdsExpr = "#ids", bizCodeRequired = false) /** * 标记作废 */ public int markInvalid(List ids) { if (ids == null || ids.isEmpty()) { throw new DataException("请选择要作废的记录"); } int count = 0; Date now = new Date(); String operatorId = UserProvider.getLoginUserId(); for (String id : ids) { if (StringUtil.isEmpty(id)) { continue; } TmsRecordEntity entity = recordMapper.selectById(id); if (entity == null) { continue; } String st = entity.getArchiveStatus(); if (TmsArchiveStatus.ARCHIVED.equalsCode(st) || TmsArchiveStatus.INVALID.equalsCode(st)) { throw new DataException("已归档/已作废记录不可再作废:" + entity.getRecordNo()); } List persons = StringUtil.isEmpty(entity.getTaskId()) ? Collections.emptyList() : personTaskMapper.selectList(new LambdaQueryWrapper() .eq(TmsPersonTaskEntity::getTaskId, entity.getTaskId())); boolean anyoneDone = false; List incomplete = new ArrayList<>(); for (TmsPersonTaskEntity p : persons) { if (p == null || "1".equals(p.getGuestFlag())) { continue; } if (isCompletedForInvalid(entity.getEvalMode(), p)) { anyoneDone = true; break; } incomplete.add(p); } if (anyoneDone) { throw new DataException("已有人员完成,不可作废:" + entity.getRecordNo()); } entity.setArchiveStatus(TmsArchiveStatus.INVALID.getCode()); entity.setLastModifyUserId(operatorId); entity.setLastModifyTime(now); recordMapper.updateById(entity); if (StringUtil.isNotEmpty(entity.getTaskId())) { TmsTaskEntity task = taskMapper.selectById(entity.getTaskId()); if (task != null && !TmsTaskPublishStatus.CANCELLED.equalsCode(task.getPublishStatus()) && !TmsTaskPublishStatus.DONE.equalsCode(task.getPublishStatus())) { task.setPublishStatus(TmsTaskPublishStatus.CANCELLED.getCode()); task.setLastModifyUserId(operatorId); task.setLastModifyTime(now); taskMapper.updateById(task); } } if (!incomplete.isEmpty()) { createContinueDraftTask(entity, incomplete, now, operatorId); } else if (StringUtil.isNotEmpty(entity.getTaskId())) { // 无人完成也无人名册时,仍按原任务对象生成空壳再维护草稿(复制原 trainees) TmsTaskEntity src = taskMapper.selectById(entity.getTaskId()); if (src != null && StringUtil.isNotEmpty(src.getTrainees())) { List roster = new ArrayList<>(); for (String uid : src.getTrainees().split(",")) { if (StringUtil.isEmpty(uid)) { continue; } TmsPersonTaskEntity stub = new TmsPersonTaskEntity(); stub.setUserId(uid.trim()); roster.add(stub); } if (!roster.isEmpty()) { createContinueDraftTask(entity, roster, now, operatorId); } } } count++; } return count; } /** 作废判定:在线考试须合格;其它方式已签到视为完成 */ private boolean isCompletedForInvalid(String evalMode, TmsPersonTaskEntity person) { if (person.getSignTime() == null) { return false; } if (TmsEvalMode.EXAM.equalsCodeIgnoreCase(evalMode)) { return TmsPassFlag.isPass(person.getPassFlag()); } return true; } /** * 统计测验/考试次数 */ @Override public int calcQuizCount(String recordId) { if (StringUtil.isEmpty(recordId)) { throw new DataException("记录ID不能为空"); } TmsRecordEntity record = recordMapper.selectById(recordId); if (record == null) { throw new DataException("培训记录不存在"); } if (StringUtil.isEmpty(record.getTaskId())) { return 0; } TmsTaskEntity task = taskMapper.selectById(record.getTaskId()); if (task == null) { return 0; } Long signed = personTaskMapper.selectCount( new LambdaQueryWrapper() .eq(TmsPersonTaskEntity::getTaskId, record.getTaskId()) .isNotNull(TmsPersonTaskEntity::getSignTime) .and(w -> w.isNull(TmsPersonTaskEntity::getGuestFlag) .or().ne(TmsPersonTaskEntity::getGuestFlag, "1"))); int signedCount = signed == null ? 0 : signed.intValue(); return TmsQuizHelper.quizCount(signedCount, task.getQuizRatio()); } @Override public TmsQuizAnswerPaperVO getQuizAnswers(String recordId, String userId) { TmsRecordEntity record = requireOne(recordId); if (StringUtil.isEmpty(userId)) { throw new DataException("用户ID不能为空"); } if (StringUtil.isEmpty(record.getTaskId())) { throw new DataException("培训记录未关联任务"); } List scores = quizScoreMapper.selectList(new LambdaQueryWrapper() .eq(TmsQuizScoreEntity::getTaskId, record.getTaskId()) .eq(TmsQuizScoreEntity::getUserId, userId.trim()) .and(w -> w.eq(TmsQuizScoreEntity::getRecordId, record.getId()) .or().isNull(TmsQuizScoreEntity::getRecordId)) .orderByAsc(TmsQuizScoreEntity::getCreatorTime) .orderByAsc(TmsQuizScoreEntity::getId)); if (scores == null || scores.isEmpty()) { throw new DataException("该学员尚未提交提问答卷"); } List presets = taskQuizMapper.selectList(new LambdaQueryWrapper() .eq(TmsTaskQuizEntity::getForeignId, record.getTaskId()) .orderByAsc(TmsTaskQuizEntity::getSortNo) .orderByAsc(TmsTaskQuizEntity::getCreatorTime)); Map names = loadUserNames(Collections.singletonList(userId.trim())); String userName = names.getOrDefault(userId.trim(), userId.trim()); String passFlag = null; BigDecimal quizScore = null; TmsPersonTaskEntity person = personTaskMapper.selectOne(new LambdaQueryWrapper() .eq(TmsPersonTaskEntity::getTaskId, record.getTaskId()) .eq(TmsPersonTaskEntity::getUserId, userId.trim()) .last("LIMIT 1")); if (person != null) { passFlag = person.getPassFlag(); } TmsRecordSignEntity sign = recordSignMapper.selectOne(new LambdaQueryWrapper() .eq(TmsRecordSignEntity::getForeignId, record.getId()) .eq(TmsRecordSignEntity::getUserId, userId.trim()) .last("LIMIT 1")); if (sign != null) { quizScore = sign.getQuizScore(); if (StringUtil.isEmpty(passFlag)) { passFlag = sign.getResultFlag(); } } String quizTime = formatDate(scores.get(0).getQuizTime() != null ? scores.get(0).getQuizTime() : scores.get(0).getCreatorTime()); TmsQuizAnswerPaperVO paper = TmsQuizHelper.buildAnswerPaper( record.getId(), record.getTaskId(), userId.trim(), userName, passFlag, quizTime, scores, presets); if (quizScore != null) { paper.setTotalScore(quizScore); } return paper; } private Set loadQuizAnswerUserIds(String taskId, String recordId) { if (StringUtil.isEmpty(taskId)) { return Collections.emptySet(); } List rows = quizScoreMapper.selectList(new LambdaQueryWrapper() .select(TmsQuizScoreEntity::getUserId) .eq(TmsQuizScoreEntity::getTaskId, taskId) .and(w -> w.eq(TmsQuizScoreEntity::getRecordId, recordId) .or().isNull(TmsQuizScoreEntity::getRecordId))); if (rows == null || rows.isEmpty()) { return Collections.emptySet(); } return rows.stream() .map(TmsQuizScoreEntity::getUserId) .filter(StringUtil::isNotEmpty) .collect(Collectors.toSet()); } /** * 归档单条核心: *
    *
  1. 统计签到率 / 合格率(访客不计)
  2. *
  3. 记录 → archived;任务 → done;计划明细 → done
  4. *
  5. 每位非访客学员 {@link #upsertPersonTrain} → 个人档案培训目录
  6. *
  7. 未完成人 {@link #createContinueDraftTask}(不自动发布)
  8. *
*/ private void archiveOne(TmsRecordEntity entity, Date now, String operatorId) { String taskId = entity.getTaskId(); List persons = Collections.emptyList(); if (StringUtil.isNotEmpty(taskId)) { persons = personTaskMapper.selectList( new LambdaQueryWrapper() .eq(TmsPersonTaskEntity::getTaskId, taskId) .orderByAsc(TmsPersonTaskEntity::getCreatorTime)); } int total = 0; int signed = 0; int passed = 0; List incomplete = new ArrayList<>(); for (TmsPersonTaskEntity p : persons) { if (p == null || "1".equals(p.getGuestFlag())) { continue; } total++; boolean hasSign = p.getSignTime() != null; if (hasSign) { signed++; } boolean pass = TmsPassFlag.isPass(p.getPassFlag()); if (pass) { passed++; } if (isIncomplete(entity.getEvalMode(), p)) { incomplete.add(p); } } entity.setSignRate(rateFloor(signed, total)); entity.setPassRate(rateFloor(passed, total)); entity.setArchiveStatus(TmsArchiveStatus.ARCHIVED.getCode()); entity.setArchiveTime(now); entity.setArchiverId(operatorId); entity.setLastModifyUserId(operatorId); entity.setLastModifyTime(now); recordMapper.updateById(entity); if (StringUtil.isNotEmpty(taskId)) { TmsTaskEntity task = taskMapper.selectById(taskId); if (task != null) { task.setSignRate(entity.getSignRate()); task.setPassRate(entity.getPassRate()); task.setPublishStatus(TmsTaskPublishStatus.DONE.getCode()); task.setLastModifyUserId(operatorId); task.setLastModifyTime(now); taskMapper.updateById(task); markPlanItemDone(task); } } for (TmsPersonTaskEntity p : persons) { if (p == null || "1".equals(p.getGuestFlag()) || StringUtil.isEmpty(p.getUserId())) { continue; } upsertPersonTrain(entity, p, now, operatorId); } if (!incomplete.isEmpty()) { createContinueDraftTask(entity, incomplete, now, operatorId); } } /** * 实时参与进度 */ @Override public TmsRecordProgressVO getProgress(String recordId) { TmsRecordEntity record = requireOne(recordId); List persons = listNonGuestPersons(record.getTaskId()); return buildProgress(record, persons); } /** * 未完成人员清单(按部门名称、姓名排序) */ @Override public List listIncomplete(String recordId) { TmsRecordEntity record = requireOne(recordId); String evalMode = record.getEvalMode(); List persons = listNonGuestPersons(record.getTaskId()); List incomplete = new ArrayList<>(); for (TmsPersonTaskEntity p : persons) { if (isIncomplete(evalMode, p)) { incomplete.add(p); } } Map userNames = loadUserNames(incomplete.stream() .map(TmsPersonTaskEntity::getUserId) .filter(StringUtil::isNotEmpty) .distinct() .collect(Collectors.toList())); Set deptIds = new HashSet<>(); for (TmsPersonTaskEntity p : incomplete) { if (StringUtil.isNotEmpty(p.getDeptId())) { deptIds.add(p.getDeptId()); } } Map deptNames = loadDeptNames(deptIds); List list = new ArrayList<>(incomplete.size()); for (TmsPersonTaskEntity p : incomplete) { TmsRecordIncompleteVO vo = new TmsRecordIncompleteVO(); vo.setPersonTaskId(p.getId()); vo.setUserId(p.getUserId()); vo.setUserName(userNames.getOrDefault(p.getUserId(), p.getUserId())); vo.setDeptId(p.getDeptId()); vo.setDeptName(deptNames.getOrDefault(p.getDeptId(), "")); vo.setBizStatus(p.getBizStatus()); vo.setSignTime(formatDate(p.getSignTime())); vo.setExamScore(p.getExamScore()); vo.setPassFlag(p.getPassFlag()); vo.setReason(incompleteReason(evalMode, p)); list.add(vo); } list.sort(Comparator .comparing((TmsRecordIncompleteVO v) -> StringUtil.isEmpty(v.getDeptName()) ? "" : v.getDeptName()) .thenComparing(v -> StringUtil.isEmpty(v.getUserName()) ? "" : v.getUserName())); return list; } /** * 按任务回写记录签到率/合格率 */ @Override public void refreshRatesByTaskId(String taskId) { if (StringUtil.isEmpty(taskId)) { return; } TmsRecordEntity record = recordMapper.selectOne(new LambdaQueryWrapper() .eq(TmsRecordEntity::getTaskId, taskId) .last("LIMIT 1")); if (record == null) { return; } List persons = listNonGuestPersons(taskId); TmsRecordProgressVO progress = buildProgress(record, persons); record.setSignRate(progress.getSignRate()); record.setPassRate(progress.getPassRate()); record.setLastModifyTime(new Date()); recordMapper.updateById(record); TmsTaskEntity task = taskMapper.selectById(taskId); if (task != null) { task.setSignRate(progress.getSignRate()); task.setPassRate(progress.getPassRate()); task.setLastModifyTime(new Date()); taskMapper.updateById(task); } } private TmsRecordProgressVO buildProgress(TmsRecordEntity record, List persons) { int total = 0; int signed = 0; int passed = 0; int incomplete = 0; String evalMode = record.getEvalMode(); for (TmsPersonTaskEntity p : persons) { if (p == null) { continue; } total++; if (p.getSignTime() != null) { signed++; } if (TmsPassFlag.isPass(p.getPassFlag())) { passed++; } if (isIncomplete(evalMode, p)) { incomplete++; } } TmsRecordProgressVO vo = new TmsRecordProgressVO(); vo.setRecordId(record.getId()); vo.setTaskId(record.getTaskId()); vo.setTotalCount(total); vo.setSignedCount(signed); vo.setPassedCount(passed); vo.setIncompleteCount(incomplete); vo.setSignRate(rateFloor(signed, total)); vo.setPassRate(rateFloor(passed, total)); vo.setIncompleteRate(rateFloor(incomplete, total)); vo.setEvalMode(evalMode); return vo; } private List listNonGuestPersons(String taskId) { if (StringUtil.isEmpty(taskId)) { return Collections.emptyList(); } List rows = personTaskMapper.selectList(new LambdaQueryWrapper() .eq(TmsPersonTaskEntity::getTaskId, taskId) .orderByAsc(TmsPersonTaskEntity::getCreatorTime)); if (rows == null || rows.isEmpty()) { return Collections.emptyList(); } List list = new ArrayList<>(); for (TmsPersonTaskEntity p : rows) { if (p != null && !"1".equals(p.getGuestFlag())) { list.add(p); } } return list; } private Map loadDeptNames(Set deptIds) { Map map = new HashMap<>(); if (deptIds == null || deptIds.isEmpty()) { return map; } for (String id : deptIds) { try { OrganizeEntity org = organizeApi.getInfoById(id); if (org != null && StringUtil.isNotEmpty(org.getFullName())) { map.put(id, org.getFullName()); } } catch (Exception ignored) { // ignore } } return map; } private String incompleteReason(String evalMode, TmsPersonTaskEntity person) { if (person.getSignTime() == null) { return "未签到"; } if (TmsEvalMode.EXAM.equalsCodeIgnoreCase(evalMode) && !TmsPassFlag.isPass(person.getPassFlag())) { return "考试未合格"; } if (TmsEvalMode.QUIZ.equalsCodeIgnoreCase(evalMode) && !TmsPassFlag.isPass(person.getPassFlag())) { return "提问未合格"; } if (TmsEvalMode.PRACTICE.equalsCodeIgnoreCase(evalMode) && !TmsPassFlag.isPass(person.getPassFlag())) { return "实操未合格"; } return "未完成"; } /** 未完成:未签到;考试/提问/实操另要求未合格 */ private boolean isIncomplete(String evalMode, TmsPersonTaskEntity person) { if (person.getSignTime() == null) { return true; } if (TmsEvalMode.EXAM.equalsCodeIgnoreCase(evalMode) || TmsEvalMode.QUIZ.equalsCodeIgnoreCase(evalMode) || TmsEvalMode.PRACTICE.equalsCodeIgnoreCase(evalMode)) { return !TmsPassFlag.isPass(person.getPassFlag()); } return false; } /** * 回写计划明细已完成 */ private void markPlanItemDone(TmsTaskEntity task) { if (task == null || StringUtil.isEmpty(task.getSourceItemId())) { return; } if (TmsTaskSourceType.ANNUAL.equalsCode(task.getSourceType())) { TmsAnnualPlanItemEntity item = annualPlanItemMapper.selectById(task.getSourceItemId()); if (item != null) { item.setLineStatus(TmsPlanLineStatus.DONE.getCode()); annualPlanItemMapper.updateById(item); } return; } if (TmsTaskSourceType.POST.equalsCode(task.getSourceType())) { TmsPostPlanItemEntity item = postPlanItemMapper.selectById(task.getSourceItemId()); if (item != null) { item.setLineStatus(TmsPlanLineStatus.DONE.getCode()); postPlanItemMapper.updateById(item); } } } /** * 完成率向下取整 */ private static int rateFloor(int part, int total) { if (total <= 0) { return 0; } return (int) Math.floor(part * 100.0 / total); } /** * 写入/更新个人培训目录 {@code tms_person_train}(档案页 catalogList 的数据源)。 * 无个人档案主档时先 {@link #ensurePersonFile}。 * 同一 recordId+userId 幂等更新。 */ private void upsertPersonTrain(TmsRecordEntity record, TmsPersonTaskEntity person, Date now, String operatorId) { TmsPersonTrainEntity exists = personTrainMapper.selectOne( new LambdaQueryWrapper() .eq(TmsPersonTrainEntity::getRecordId, record.getId()) .eq(TmsPersonTrainEntity::getUserId, person.getUserId()) .last("LIMIT 1")); if (exists != null) { exists.setPassFlag(TmsPassFlag.normalize(person.getPassFlag())); exists.setHours(person.getHours()); exists.setSubject(record.getSubject()); exists.setCategory(record.getCategory()); exists.setTrainMode(record.getTrainMode()); exists.setEvalMode(record.getEvalMode()); exists.setTeacherId(record.getTeacherId()); exists.setStartTime(record.getStartTime()); exists.setEndTime(record.getEndTime()); exists.setLastModifyUserId(operatorId); exists.setLastModifyTime(now); personTrainMapper.updateById(exists); return; } TmsPersonFileEntity file = ensurePersonFile(person.getUserId(), now, operatorId); TmsPersonTrainEntity row = new TmsPersonTrainEntity(); row.setId(RandomUtil.uuId()); row.setArchiveId(file.getId()); row.setUserId(person.getUserId()); row.setRecordId(record.getId()); row.setRecordNo(record.getRecordNo()); row.setCategory(record.getCategory()); row.setSubject(record.getSubject()); row.setTrainMode(record.getTrainMode()); row.setTeacherId(record.getTeacherId()); row.setEvalMode(record.getEvalMode()); row.setPassFlag(TmsPassFlag.normalize(person.getPassFlag())); row.setStartTime(record.getStartTime()); row.setEndTime(record.getEndTime()); row.setHours(person.getHours()); row.setCreatorUserId(operatorId); row.setCreatorTime(now); personTrainMapper.insert(row); } /** 确保存在个人档案主档 tms_person_file(归档写目录前调用)。 */ private TmsPersonFileEntity ensurePersonFile(String userId, Date now, String operatorId) { TmsPersonFileEntity file = personFileMapper.selectOne( new LambdaQueryWrapper() .eq(TmsPersonFileEntity::getUserId, userId) .last("LIMIT 1")); if (file != null) { return file; } UserEntity user = null; try { user = userApi.getInfoById(userId); } catch (Exception ignored) { // ignore remote failures; still create a stub archive } file = new TmsPersonFileEntity(); file.setId(RandomUtil.uuId()); file.setArchiveNo(nextArchiveNo()); file.setArchiveDate(now); file.setUserId(userId); if (user != null) { file.setUserName(user.getRealName()); file.setDeptId(user.getOrganizeId()); } file.setBizStatus(TmsValidRevoked.VALID.getCode()); file.setCreatorUserId(operatorId); file.setCreatorTime(now); personFileMapper.insert(file); return file; } /** * 生成档案编号 */ private String nextArchiveNo() { int year = Calendar.getInstance().get(Calendar.YEAR); String prefix = "AR" + year; TmsPersonFileEntity last = personFileMapper.selectOne( new LambdaQueryWrapper() .likeRight(TmsPersonFileEntity::getArchiveNo, prefix) .orderByDesc(TmsPersonFileEntity::getArchiveNo) .last("LIMIT 1")); int seq = 1; if (last != null && StringUtil.isNotEmpty(last.getArchiveNo()) && last.getArchiveNo().length() >= prefix.length() + 4) { try { seq = Integer.parseInt(last.getArchiveNo().substring(prefix.length())) + 1; } catch (NumberFormatException ignored) { seq = 1; } } return prefix + String.format("%04d", Math.min(seq, 9999)); } /** 未完成人员 → 草稿再维护任务(不自动发布) */ /** * 为未完成学员生成「再培训」草稿任务(TD…,category=retrain,不自动发布), * 便于维护后再次送审/发布。 */ private void createContinueDraftTask(TmsRecordEntity record, List incomplete, Date now, String operatorId) { if (StringUtil.isEmpty(record.getTaskId())) { return; } TmsTaskEntity src = taskMapper.selectById(record.getTaskId()); if (src == null) { return; } // 同一原任务若已有未发布 continue,则合并培训对象,避免重复草稿 TmsTaskEntity existing = taskMapper.selectOne( new LambdaQueryWrapper() .eq(TmsTaskEntity::getPrevTaskNo, src.getTaskNo()) .eq(TmsTaskEntity::getTaskKind, TmsTaskKind.CONTINUE.getCode()) .eq(TmsTaskEntity::getPublishStatus, TmsTaskPublishStatus.DRAFT.getCode()) .last("LIMIT 1")); List userIds = incomplete.stream() .map(TmsPersonTaskEntity::getUserId) .filter(StringUtil::isNotEmpty) .distinct() .collect(Collectors.toList()); if (userIds.isEmpty()) { return; } if (existing != null) { List merged = new ArrayList<>(); if (StringUtil.isNotEmpty(existing.getTrainees())) { for (String id : existing.getTrainees().split(",")) { if (StringUtil.isNotEmpty(id) && !merged.contains(id.trim())) { merged.add(id.trim()); } } } for (String id : userIds) { if (!merged.contains(id)) { merged.add(id); } } existing.setTrainees(String.join(",", merged)); existing.setCategory(TmsTaskCategory.RETRAIN.getCode()); existing.setTaskKind(TmsTaskKind.CONTINUE.getCode()); existing.setLastModifyUserId(operatorId); existing.setLastModifyTime(now); taskMapper.updateById(existing); return; } TmsTaskEntity draft = new TmsTaskEntity(); draft.setId(RandomUtil.uuId()); draft.setTaskNo(nextDraftTaskNo()); draft.setCategory(TmsTaskCategory.RETRAIN.getCode()); draft.setTrainType(src.getTrainType()); draft.setSourceType(src.getSourceType()); draft.setSourcePlanNo(src.getSourcePlanNo()); draft.setSourceId(src.getSourceId()); draft.setSourceItemId(src.getSourceItemId()); draft.setSubject(src.getSubject()); draft.setKeyPoints(src.getKeyPoints()); draft.setMaterialId(src.getMaterialId()); draft.setMaterialNo(src.getMaterialNo()); draft.setPlaceId(src.getPlaceId()); draft.setPlaceNo(src.getPlaceNo()); draft.setPlaceName(src.getPlaceName()); draft.setPaperId(src.getPaperId()); draft.setPaperName(src.getPaperName()); draft.setTeacherId(src.getTeacherId()); draft.setTrainMode(src.getTrainMode()); draft.setEvalMode(src.getEvalMode()); draft.setTrainees(String.join(",", userIds)); draft.setTaskKind(TmsTaskKind.CONTINUE.getCode()); draft.setPrevTaskNo(src.getTaskNo()); draft.setPublishStatus(TmsTaskPublishStatus.DRAFT.getCode()); draft.setRetakeLimit(src.getRetakeLimit()); draft.setPassScore(src.getPassScore()); draft.setQuizRatio(src.getQuizRatio()); draft.setOverdueRemind(src.getOverdueRemind()); draft.setInviteFlag(src.getInviteFlag()); draft.setRemark("由培训记录 " + record.getRecordNo() + " 归档自动生成(未完成人员再维护)"); draft.setCreatorUserId(operatorId); draft.setCreatorTime(now); taskMapper.insert(draft); // 复制任务附件引用 List files = taskFileMapper.selectList( new LambdaQueryWrapper() .eq(TmsTaskFileEntity::getForeignId, src.getId())); for (TmsTaskFileEntity f : files) { TmsTaskFileEntity copy = new TmsTaskFileEntity(); copy.setId(RandomUtil.uuId()); copy.setForeignId(draft.getId()); copy.setFileName(f.getFileName()); copy.setFileNo(f.getFileNo()); copy.setMaterialId(f.getMaterialId()); copy.setFileJson(f.getFileJson()); copy.setCanDownload(f.getCanDownload()); copy.setCreatorUserId(operatorId); copy.setCreatorTime(now); taskFileMapper.insert(copy); } } /** * 生成草稿任务编号 */ private synchronized String nextDraftTaskNo() { String ym = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMM")); String prefix = PREFIX_DRAFT + ym; String max = taskMapper.selectMaxTaskNo(prefix); int seq = 1; if (StringUtil.isNotEmpty(max) && max.length() == 12) { try { seq = Integer.parseInt(max.substring(8)) + 1; } catch (NumberFormatException ignored) { seq = 1; } } if (seq > 9999) { throw new DataException("当月草稿任务编号已用尽"); } return prefix + String.format("%04d", seq); } /** * 培训记录:删除 */ @Override @Transactional(rollbackFor = Exception.class) public void delete(List ids) { if (ids == null || ids.isEmpty()) { throw new DataException("请选择要删除的记录"); } for (String id : ids) { if (StringUtil.isEmpty(id)) { continue; } recordMapper.deleteById(id); } } /** * 应用列表查询模式过滤 */ private void applyListMode(LambdaQueryWrapper qw, TmsRecordQuery query) { String archiveStatus = query.getArchiveStatus(); if (StringUtil.isNotEmpty(archiveStatus)) { qw.eq(TmsRecordEntity::getArchiveStatus, archiveStatus); return; } String mode = query.getListMode(); // 口径:open进行中 / pending待归档 / ready可归档 / archived已归档 if (TmsArchiveStatus.READY.equalsCode(mode)) { qw.in(TmsRecordEntity::getArchiveStatus, TmsArchiveStatus.PENDING.getCode(), TmsArchiveStatus.READY.getCode()); } else if (TmsArchiveStatus.ARCHIVED.equalsCode(mode)) { qw.eq(TmsRecordEntity::getArchiveStatus, TmsArchiveStatus.ARCHIVED.getCode()); } else { qw.eq(TmsRecordEntity::getArchiveStatus, TmsArchiveStatus.OPEN.getCode()); } } /** * 转为列表表单 */ private TmsRecordForm toListForm(TmsRecordEntity entity, Map names) { TmsRecordForm form = new TmsRecordForm(); form.setId(entity.getId()); form.setRecordNo(entity.getRecordNo()); form.setTaskId(entity.getTaskId()); form.setTaskNo(entity.getTaskNo()); form.setCategory(entity.getCategory()); form.setSubject(entity.getSubject()); form.setTeacherId(entity.getTeacherId()); form.setTrainerName(names.getOrDefault(entity.getTeacherId(), StringUtil.isEmpty(entity.getTeacherId()) ? "-" : entity.getTeacherId())); form.setTrainType(StringUtil.isNotEmpty(entity.getTrainType()) ? entity.getTrainType() : entity.getTrainMode()); form.setTrainMode(entity.getTrainMode()); form.setEvalMode(entity.getEvalMode()); form.setStartTime(formatDate(entity.getStartTime())); form.setEndTime(formatDate(entity.getEndTime())); form.setCloseTime(formatDate(entity.getCloseTime())); form.setArchiveStatus(entity.getArchiveStatus()); form.setSignRate(entity.getSignRate()); form.setPassRate(entity.getPassRate()); form.setRemark(entity.getRemark()); return form; } /** * 加载培训记录附件 */ private List loadAttachments(TmsRecordEntity entity) { if (entity == null) { return Collections.emptyList(); } // 优先用发布时同步的 lecture_file 快照 List fromSnap = parseLectureFile(entity.getLectureFile()); if (!fromSnap.isEmpty()) { return fromSnap; } return loadAttachmentsFromTask(entity.getTaskId()); } /** * 解析课件 JSON */ private List parseLectureFile(String lectureFile) { if (StringUtil.isEmpty(lectureFile)) { return Collections.emptyList(); } try { List> rows = OBJECT_MAPPER.readValue(lectureFile.trim(), new TypeReference>>() { }); if (rows == null || rows.isEmpty()) { return Collections.emptyList(); } // 快照里教材附件常只有 materialId、无 fileJson,需回落教材表补 url List materialIds = new ArrayList<>(); for (Map row : rows) { if (row == null) { continue; } Object mid = row.get("materialId"); if (mid != null && StringUtil.isNotEmpty(String.valueOf(mid))) { materialIds.add(String.valueOf(mid)); } } Map materialMap = loadMaterialMap(materialIds); List list = new ArrayList<>(); for (Map row : rows) { if (row == null) { continue; } Object id = row.get("id"); Object name = row.get("name"); String fileName = name == null ? null : String.valueOf(name); Map meta = extractFileMeta(row.get("fileJson"), fileName); // 快照顶层也可能直接带 url / fileId mergeMetaFromRow(meta, row); Object mid = row.get("materialId"); if ((StringUtil.isEmpty(meta.get("url")) || StringUtil.isEmpty(meta.get("fileId")) || StringUtil.isEmpty(meta.get("ext"))) && mid != null && StringUtil.isNotEmpty(String.valueOf(mid))) { TmsMaterialEntity material = materialMap.get(String.valueOf(mid)); if (material != null) { mergeMeta(meta, extractFileMeta(material.getFileJson(), material.getMaterialName())); } } list.add(buildAttachmentForm( id == null ? null : String.valueOf(id), StringUtil.isNotEmpty(fileName) ? fileName : meta.get("name"), meta)); } return list; } catch (Exception ex) { return Collections.emptyList(); } } /** * 加载教材映射 */ private Map loadMaterialMap(List materialIds) { Map materialMap = new HashMap<>(); if (materialIds == null || materialIds.isEmpty()) { return materialMap; } List ids = materialIds.stream() .filter(StringUtil::isNotEmpty) .distinct() .collect(Collectors.toList()); if (ids.isEmpty()) { return materialMap; } for (TmsMaterialEntity material : materialMapper.selectBatchIds(ids)) { materialMap.put(material.getId(), material); } return materialMap; } /** * 从行数据合并附件元数据 */ private void mergeMetaFromRow(Map meta, Map row) { if (meta == null || row == null) { return; } if (StringUtil.isEmpty(meta.get("url")) && row.get("url") != null) { meta.put("url", String.valueOf(row.get("url"))); } if (StringUtil.isEmpty(meta.get("fileId"))) { Object fid = row.get("fileId"); if (fid == null) { fid = row.get("file_id"); } if (fid != null) { meta.put("fileId", String.valueOf(fid)); } } if (StringUtil.isEmpty(meta.get("fileVersionId"))) { Object fvid = row.get("fileVersionId"); if (fvid == null) { fvid = row.get("fileVersion"); } if (fvid != null) { meta.put("fileVersionId", String.valueOf(fvid)); } } if (StringUtil.isEmpty(meta.get("ext")) && row.get("fileExt") != null) { meta.put("ext", String.valueOf(row.get("fileExt")).toLowerCase()); } } /** * 合并附件元数据 */ private void mergeMeta(Map target, Map source) { if (target == null || source == null) { return; } if (StringUtil.isEmpty(target.get("url")) && StringUtil.isNotEmpty(source.get("url"))) { target.put("url", source.get("url")); } if (StringUtil.isEmpty(target.get("ext")) && StringUtil.isNotEmpty(source.get("ext"))) { target.put("ext", source.get("ext")); } if (StringUtil.isEmpty(target.get("fileId")) && StringUtil.isNotEmpty(source.get("fileId"))) { target.put("fileId", source.get("fileId")); } if (StringUtil.isEmpty(target.get("fileVersionId")) && StringUtil.isNotEmpty(source.get("fileVersionId"))) { target.put("fileVersionId", source.get("fileVersionId")); } if (StringUtil.isEmpty(target.get("name")) && StringUtil.isNotEmpty(source.get("name"))) { target.put("name", source.get("name")); } } /** * 从任务同步加载附件 */ private List loadAttachmentsFromTask(String taskId) { if (StringUtil.isEmpty(taskId)) { return Collections.emptyList(); } List files = taskFileMapper.selectList( new LambdaQueryWrapper() .eq(TmsTaskFileEntity::getForeignId, taskId) .orderByAsc(TmsTaskFileEntity::getCreatorTime)); if (files.isEmpty()) { return Collections.emptyList(); } Map materialMap = loadMaterialMap(files.stream() .map(TmsTaskFileEntity::getMaterialId) .filter(StringUtil::isNotEmpty) .distinct() .collect(Collectors.toList())); List list = new ArrayList<>(); for (TmsTaskFileEntity file : files) { Map meta = extractFileMeta(file.getFileJson(), file.getFileName()); if ((StringUtil.isEmpty(meta.get("url")) || StringUtil.isEmpty(meta.get("ext")) || StringUtil.isEmpty(meta.get("fileId"))) && StringUtil.isNotEmpty(file.getMaterialId())) { TmsMaterialEntity material = materialMap.get(file.getMaterialId()); if (material != null) { mergeMeta(meta, extractFileMeta(material.getFileJson(), material.getMaterialName())); } } list.add(buildAttachmentForm( file.getId(), StringUtil.isNotEmpty(file.getFileName()) ? file.getFileName() : meta.get("name"), meta)); } return list; } /** * 构建附件表单 */ private TmsRecordAttachmentForm buildAttachmentForm(String id, String name, Map meta) { TmsRecordAttachmentForm item = new TmsRecordAttachmentForm(); item.setId(id); item.setName(name); item.setUrl(meta.get("url")); item.setFileId(meta.get("fileId")); item.setFileVersionId(meta.get("fileVersionId")); item.setFileExt(meta.get("ext")); item.setPreviewType(resolvePreviewType(meta.get("ext"))); return item; } /** 从 JNPF 附件 JSON 解析 url / fileId / 扩展名等(支持 String / Map / List)。 */ private Map extractFileMeta(Object fileJson, String fileName) { Map meta = new HashMap<>(); meta.put("url", null); meta.put("fileId", null); meta.put("fileVersionId", null); meta.put("ext", null); meta.put("name", fileName); try { Map map = coerceFileJsonMap(fileJson); if (map != null) { if (map.get("url") != null) { meta.put("url", String.valueOf(map.get("url"))); } Object fid = map.get("fileId"); if (fid == null) { fid = map.get("file_id"); } if (fid != null) { meta.put("fileId", String.valueOf(fid)); } Object fvid = map.get("fileVersionId"); if (fvid == null) { fvid = map.get("fileVersion"); } if (fvid != null) { meta.put("fileVersionId", String.valueOf(fvid)); } if (map.get("fileExtension") != null) { meta.put("ext", String.valueOf(map.get("fileExtension")).toLowerCase()); } if (map.get("name") != null) { meta.put("name", String.valueOf(map.get("name"))); if (StringUtil.isEmpty(meta.get("ext"))) { fileName = String.valueOf(map.get("name")); } } } } catch (Exception ignored) { // ignore } if (StringUtil.isEmpty(meta.get("ext")) && StringUtil.isNotEmpty(fileName) && fileName.contains(".")) { meta.put("ext", fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase()); } return meta; } /** * 规范化文件 JSON 为 Map */ @SuppressWarnings("unchecked") private Map coerceFileJsonMap(Object fileJson) throws Exception { if (fileJson == null) { return null; } if (fileJson instanceof Map) { return (Map) fileJson; } if (fileJson instanceof List) { List arr = (List) fileJson; if (arr.isEmpty() || !(arr.get(0) instanceof Map)) { return null; } return (Map) arr.get(0); } String text = String.valueOf(fileJson).trim(); if (StringUtil.isEmpty(text) || "null".equalsIgnoreCase(text)) { return null; } // 双重 JSON 字符串:'"{\"url\":\"...\"}"' if (text.startsWith("\"") && text.endsWith("\"")) { text = OBJECT_MAPPER.readValue(text, String.class); if (StringUtil.isEmpty(text)) { return null; } text = text.trim(); } if (text.startsWith("[")) { List> arr = OBJECT_MAPPER.readValue(text, new TypeReference>>() { }); if (arr == null || arr.isEmpty()) { return null; } return arr.get(0); } return OBJECT_MAPPER.readValue(text, new TypeReference>() { }); } /** * 解析附件预览类型 */ private String resolvePreviewType(String ext) { if (StringUtil.isEmpty(ext)) { return "other"; } String e = ext.toLowerCase(); if (Set.of("jpg", "jpeg", "png", "gif", "webp", "bmp", "svg").contains(e)) { return "image"; } if (Set.of("mp4", "webm", "ogg", "mov", "m4v").contains(e)) { return "video"; } if (Set.of("mp3", "wav", "aac", "m4a", "flac").contains(e)) { return "audio"; } if ("pdf".equals(e)) { return "pdf"; } if (Set.of("doc", "docx", "docm", "dot", "dotx", "xls", "xlsx", "xlsm", "xlt", "xltx", "ppt", "pptx", "pptm", "pps", "ppsx", "pot", "potx", "csv", "rtf", "odt", "ods", "odp", "ott", "ots", "otp").contains(e)) { return "office"; } return "other"; } /** * 批量加载用户姓名 */ private Map loadUserNames(List userIds) { if (userIds == null || userIds.isEmpty()) { return Collections.emptyMap(); } List ids = userIds.stream().filter(StringUtil::isNotEmpty).distinct().collect(Collectors.toList()); if (ids.isEmpty()) { return Collections.emptyMap(); } try { List users = userApi.getUserName(ids); if (users == null || users.isEmpty()) { return Collections.emptyMap(); } Map map = new LinkedHashMap<>(); for (UserEntity user : users) { if (user == null || StringUtil.isEmpty(user.getId())) { continue; } String name = StringUtil.isNotEmpty(user.getRealName()) ? user.getRealName() : user.getAccount(); map.put(user.getId(), name); } return map; } catch (Exception ex) { return Collections.emptyMap(); } } /** * 按 ID 查询,不存在则抛错 */ private TmsRecordEntity requireOne(String id) { if (StringUtil.isEmpty(id)) { throw new DataException("记录ID不能为空"); } TmsRecordEntity entity = recordMapper.selectById(id); if (entity == null) { throw new DataException("培训记录不存在"); } return entity; } /** * 格式化日期时间 */ private String formatDate(Date date) { return date == null ? null : DateUtil.dateToString(date, FMT); } /** * 解析日期时间 */ private static Date parseDate(String text) { if (StringUtil.isEmpty(text)) { return null; } String raw = text.trim(); try { if (raw.matches("^\\d{10,13}$")) { long ts = Long.parseLong(raw); if (raw.length() == 10) { ts = ts * 1000L; } return new Date(ts); } return DateUtil.stringToDate(raw); } catch (Exception ex) { throw new DataException("时间格式无效:" + text); } } /** * 解析日开始时间 */ private static Date parseDayStart(String day) { if (StringUtil.isEmpty(day) || "null".equalsIgnoreCase(day)) { return null; } String raw = day.trim(); if (raw.length() == 10) { raw = raw + " 00:00:00"; } return parseDate(raw); } /** * 解析日结束时间 */ private static Date parseDayEnd(String day) { if (StringUtil.isEmpty(day) || "null".equalsIgnoreCase(day)) { return null; } String raw = day.trim(); if (raw.length() == 10) { raw = raw + " 23:59:59"; } return parseDate(raw); } }