feat(roster): 名册导入前端 —— 任务发布现在真的能在界面上走通了
第 2 步。在此之前「配置 → 任务就绪」缺了**名册**那一环:班级能配、材料能配、
表单能配,但名册只能靠 SQL 造,于是任务在界面上**根本发布不了**(40016 名册非空)。
一、新增(frontend-pc/)
src/apis/xt/roster.ts 导入(multipart)+ 分页 + 类型 + 状态字典
src/views/task/TaskRosterDrawer.vue 「名单管理」抽屉:导入 → 体检报告 → 名册列表
views/task/index.vue 加「名单管理」入口;已截止/已归档只读仍可查看
config/xt-routes.ts 顶层「名单管理」菜单说明改为指向任务行
二、★ 验证了"发布真的通"(e2e-m2-roster.py 第 8 节)
配覆盖班级 → 配必传材料 → **发布成功** → 状态变「收集中」→ stat 仍带分母。
force=true 只跳过 P1 提醒,那 5 项阻断级校验 force 也绕不过 —— 所以是真链路。
三、界面上五个刻意的设计
1. 先体检再导入(两步):实测表头在第 1 行或第 6 行,一步导入只会看到
"成功 0 条",然后以为文件坏了
2. 明说「前面那 5 行是标题与说明,已跳过,这不是导错了」—— 不解释的话
用户会去改自己那份本来正确的表格
3. 行号 + 列字母 + 原值 + 原因四样一起给,用户拿着就能回去改 Excel
4. dryRun 默认 true(服务端也默认 true):不显式要求就不写库
5. 阻断行与警告行分两张表:混在一起会让人以为整批都没进
原因后面跟**机器码**而不是中文标签(第一版给中文,和上面那句原因重复了一遍)
四、顺带修掉一处「界面在说谎」
名册导进来了,任务列表却还显示「待导入名册」—— TaskStatVO 恒为 null 是 M1 的
既有缺口(注释写着"名册为空时返回 null,由界面显示待导入名册")。现在名册有了:
· TaskService.fillStats():一次算一页(两条批量查询,不逐任务查)
· **名册为空时仍返回 null**,口径没有放宽 —— 显示 0% 看起来像"没人填",
而真相是"还没有分母"
· abnormalCount / avgAuditHours 仍为 null(要 M4 的校验表 / M5 的审核记录)
· e2e-m1-task 那条断言改成措辞更准的"名册为空时 stat 为 null"(意图不变),
并在 e2e-m2-roster 补 4 条"名册有值后 stat 该有值"
这条也是第 2 步**自己制造**的问题:能导名册之后,"待导入名册"从诚实变成说谎。
五、为什么顶层「名单管理」菜单还是"待建"
名册是**按任务**的,独立页得先选任务(多一步且容易选错);它和「配置班级」
「材料规则」「表单设计」是同一层的配置。报考范围确认(M2-04/05)会接着做进
同一个抽屉,那时也不需要新页面。所以改的是那句已过期的"对应里程碑 M2"。
六、浏览器验证(真链路,只绕开"手指点选")
文件框没法自动化,但可以把夹具放到 frontend-pc/public/ 再喂进去:
fetch 字节 → new File → DataTransfer → input.files → dispatch change
实测:3 人被正确导入、脱敏证件号、报考三项、扩展字段、状态列、搜索全对。
夹具用完即删。
七、回归
全量 622 条断言、0 失败(名册 E2E 62 → 67)
frontend-pc: vue-tsc 0 错、eslint 目标文件 0 错
verify-docs.py 19 项全通过
八、两个流程教训(已写进交接摘要)
· `Test-Path jar` 不能当作"打包成功":这次 mvn 失败而旧 jar 还在,
于是检查得到"打包 ✓",测试对着旧 jar 跑,四条新断言全红。
必须看退出码 / BUILD SUCCESS。
· `call()` 返回的是 (status, body) 元组不是字典 —— 我新写的那节忘了解包。
与第 1 步"已存在判断写反"同类:新写的代码最容易在细节上错。
九、下一步(第 3 步)
报考范围聚合与一致性体检(M2-04/05)—— 产品的核心增量。
先确认:sys_eval_plan 那几张"评价计划"表还没落地(FR-311 未做),
需要先定"计划从哪来"。
This commit is contained in:
+22
-3
@@ -9,6 +9,7 @@ import cn.tianbaotong.fill.common.util.MaskUtil;
|
||||
import cn.tianbaotong.fill.module.roster.dto.RosterImportReport;
|
||||
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
|
||||
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
|
||||
import cn.tianbaotong.fill.module.task.entity.CollectTask;
|
||||
import cn.tianbaotong.fill.module.task.mapper.TaskMapper;
|
||||
import cn.tianbaotong.fill.support.IdentityNormalizer;
|
||||
import cn.tianbaotong.fill.support.SensitiveCodec;
|
||||
@@ -69,6 +70,12 @@ public class RosterImportService {
|
||||
/** 表头只会出现在前 N 行里(实测最靠后的是第 6 行,留一倍余量)。 */
|
||||
private static final int HEADER_SCAN_ROWS = 10;
|
||||
|
||||
/** 名册可改的任务状态 —— 与 {@code MaterialDefService.EDITABLE_TASK_STATUS} 同口径。 */
|
||||
private static final Set<Integer> EDITABLE_TASK_STATUS = Set.of(
|
||||
CollectTask.STATUS_DRAFT,
|
||||
CollectTask.STATUS_COLLECTING,
|
||||
CollectTask.STATUS_PAUSED);
|
||||
|
||||
/** 一行里命中这么多个已知列名,才认为它是表头。 */
|
||||
private static final int HEADER_MIN_SCORE = 3;
|
||||
|
||||
@@ -107,7 +114,17 @@ public class RosterImportService {
|
||||
LoginUser me = UserContext.require();
|
||||
Long tenantId = me.tenantId();
|
||||
|
||||
requireTask(taskId);
|
||||
CollectTask task = requireTask(taskId);
|
||||
// 与材料规则同口径:已截止/已归档的任务不许再动名册。
|
||||
// 只拦"真导入"—— dryRun 不写库,看体检报告是安全的,不该被拦。
|
||||
if (!dryRun) {
|
||||
int st = task.getStatus() == null ? -1 : task.getStatus();
|
||||
if (!EDITABLE_TASK_STATUS.contains(st)) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"任务已" + (st == CollectTask.STATUS_CLOSED ? "截止" : "归档")
|
||||
+ ",名册不可再改(只能查看)。如需继续收集,请新建任务");
|
||||
}
|
||||
}
|
||||
if (orgId != null && rosterMapper.countOrg(tenantId, orgId) == 0) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "班级不存在或不属于本单位");
|
||||
}
|
||||
@@ -693,9 +710,11 @@ public class RosterImportService {
|
||||
.map(RosterImportReport.RowIssue::row).distinct().count();
|
||||
}
|
||||
|
||||
private void requireTask(Long taskId) {
|
||||
if (taskMapper.selectById(taskId) == null) {
|
||||
private CollectTask requireTask(Long taskId) {
|
||||
CollectTask task = taskMapper.selectById(taskId);
|
||||
if (task == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除");
|
||||
}
|
||||
return task;
|
||||
}
|
||||
}
|
||||
|
||||
+46
@@ -4,6 +4,9 @@ import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 「任务是否具备发布条件」的体检查询。
|
||||
*
|
||||
@@ -100,4 +103,47 @@ public interface TaskReadinessMapper {
|
||||
WHERE task_id = #{taskId} AND deleted = 0 AND status IN (1, 2)
|
||||
""")
|
||||
Long countPendingAudit(@Param("taskId") Long taskId);
|
||||
|
||||
// ── 任务统计快照(§3.3):列表页一次算一页,不要每行查一次 ──────────
|
||||
|
||||
/**
|
||||
* 一批任务的名册行数。
|
||||
*
|
||||
* <p><b>为什么是"一批"而不是"一个"</b>:任务列表一页 10 行,
|
||||
* 逐行查会让"打开任务列表"变成 10 次以上的数据库往返,
|
||||
* 而这一屏是管理端每天打开次数最多的页面。
|
||||
*
|
||||
* <p>⚠️ 这里带了 {@code tenant_id}(本文件上面几条没有):
|
||||
* 上面几条的 {@code taskId} 都来自"已经过租户过滤的任务",
|
||||
* 而批量版本的 ids 虽然也来自租户过滤后的分页结果,
|
||||
* 但一条查不到租户的行就会把整页的统计串到别的学校去 —— 代价不对等,带上更稳。
|
||||
*/
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT task_id AS taskId, COUNT(*) AS cnt FROM collect_roster
|
||||
WHERE tenant_id = #{tenantId} AND deleted = 0
|
||||
AND task_id IN <foreach collection="ids" item="i" open="(" separator="," close=")">#{i}</foreach>
|
||||
GROUP BY task_id
|
||||
</script>
|
||||
""")
|
||||
List<Map<String, Object>> countRosterByTaskIds(@Param("tenantId") Long tenantId,
|
||||
@Param("ids") List<Long> ids);
|
||||
|
||||
/**
|
||||
* 一批任务的提交单状态分布(每个任务每种状态一行)。
|
||||
*
|
||||
* <p>调用方按 {@code status} 汇总成"已提交 / 待审 / 已通过 / 已驳回"。
|
||||
*/
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT task_id AS taskId, status AS status, COUNT(*) AS cnt FROM collect_submission
|
||||
WHERE tenant_id = #{tenantId} AND deleted = 0
|
||||
AND task_id IN <foreach collection="ids" item="i" open="(" separator="," close=")">#{i}</foreach>
|
||||
GROUP BY task_id, status
|
||||
</script>
|
||||
""")
|
||||
List<Map<String, Object>> countSubmissionsByTaskIds(@Param("tenantId") Long tenantId,
|
||||
@Param("ids") List<Long> ids);
|
||||
}
|
||||
|
||||
+96
-1
@@ -9,6 +9,7 @@ import cn.tianbaotong.fill.module.task.dto.AuditConfigVO;
|
||||
import cn.tianbaotong.fill.module.task.dto.CompressConfigVO;
|
||||
import cn.tianbaotong.fill.module.task.dto.TaskActionReq;
|
||||
import cn.tianbaotong.fill.module.task.dto.TaskCreateVO;
|
||||
import cn.tianbaotong.fill.module.task.dto.TaskStatVO;
|
||||
import cn.tianbaotong.fill.module.task.dto.TaskUpsertReq;
|
||||
import cn.tianbaotong.fill.module.task.dto.TaskVO;
|
||||
import cn.tianbaotong.fill.module.task.entity.CollectTask;
|
||||
@@ -32,8 +33,10 @@ import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
@@ -116,9 +119,99 @@ public class TaskService {
|
||||
IPage<CollectTask> page = taskMapper.selectPage(new Page<>(num, size), q);
|
||||
// 用 toFullVo:列表里就要显示审核流与压缩参数,走 toVo 会是 null
|
||||
List<TaskVO> list = page.getRecords().stream().map(this::toFullVo).toList();
|
||||
fillStats(list);
|
||||
return PageResult.of(list, page.getTotal(), num, size);
|
||||
}
|
||||
|
||||
/**
|
||||
* 填「任务统计快照」(§3.3)—— 名册(M2)与提交单(M3)都有之后它才开始有值。
|
||||
*
|
||||
* <p>⚠️ <b>名册为空时返回 {@code null},而不是一串 0</b>。
|
||||
* 这是 {@link TaskStatVO} 类注释里定下的口径:显示"完成率 0%"看起来像"没人填",
|
||||
* 而真相是"还没有分母"。界面据此显示「待导入名册」。
|
||||
*
|
||||
* <p>一次算一页(两条批量查询),不做逐任务查询:任务列表是管理端
|
||||
* 打开次数最多的页面,10 行 × 2 次往返是白白多出来的。
|
||||
*/
|
||||
private void fillStats(List<TaskVO> vos) {
|
||||
List<Long> ids = new ArrayList<>();
|
||||
for (TaskVO v : vos) {
|
||||
if (v != null && v.getId() != null) {
|
||||
ids.add(v.getId());
|
||||
}
|
||||
}
|
||||
if (ids.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Long tenantId = UserContext.tenantId();
|
||||
|
||||
Map<Long, Integer> rosterTotal = new HashMap<>();
|
||||
for (Map<String, Object> r : readinessMapper.countRosterByTaskIds(tenantId, ids)) {
|
||||
rosterTotal.put(asLong(col(r, "taskId")), asInt(col(r, "cnt")));
|
||||
}
|
||||
Map<Long, Map<Integer, Integer>> byStatus = new HashMap<>();
|
||||
for (Map<String, Object> r : readinessMapper.countSubmissionsByTaskIds(tenantId, ids)) {
|
||||
byStatus.computeIfAbsent(asLong(col(r, "taskId")), k -> new HashMap<>())
|
||||
.put(asInt(col(r, "status")), asInt(col(r, "cnt")));
|
||||
}
|
||||
|
||||
for (TaskVO v : vos) {
|
||||
if (v == null || v.getId() == null) {
|
||||
continue;
|
||||
}
|
||||
int rt = rosterTotal.getOrDefault(v.getId(), 0);
|
||||
if (rt == 0) {
|
||||
v.setStat(null);
|
||||
continue;
|
||||
}
|
||||
Map<Integer, Integer> st = byStatus.getOrDefault(v.getId(), Map.of());
|
||||
int pending = st.getOrDefault(1, 0) + st.getOrDefault(2, 0);
|
||||
int approved = st.getOrDefault(3, 0);
|
||||
int rejected = st.getOrDefault(4, 0);
|
||||
int archived = st.getOrDefault(5, 0);
|
||||
// 「已提交」= 交上来且没被退回的(待初审 + 待终审 + 已通过)。
|
||||
// 与"已通过"必须分开报:原型定稿的口径是「完成率 82% 是已提交率 23/28」。
|
||||
int submitted = pending + approved;
|
||||
|
||||
TaskStatVO s = new TaskStatVO();
|
||||
s.setRosterTotal(rt);
|
||||
s.setSubmittedCount(submitted);
|
||||
s.setPendingCount(pending);
|
||||
s.setApprovedCount(approved);
|
||||
s.setRejectedCount(rejected);
|
||||
// 没交的 = 名册里减去"已经交了的"与"被退回的"(草稿算没交:他还没提交)
|
||||
s.setUnsubmittedCount(Math.max(0, rt - submitted - rejected - archived));
|
||||
s.setSubmitRate(pct(submitted, rt));
|
||||
s.setPassRate(pct(approved, rt));
|
||||
// abnormalCount / avgAuditHours 仍为 null:前者要 M4 的校验问题表、
|
||||
// 后者要 M5 的审核记录,现在编一个数就是谎
|
||||
v.setStat(s);
|
||||
}
|
||||
}
|
||||
|
||||
// 列标签在 PostgreSQL 里会被折成小写,而 MyBatis 用列标签做 key ——
|
||||
// 按不区分大小写取,避免"取不到就当 0"这种最难受的错(数字看起来正常,其实是空的)
|
||||
private static Object col(Map<String, Object> row, String name) {
|
||||
for (Map.Entry<String, Object> e : row.entrySet()) {
|
||||
if (e.getKey().equalsIgnoreCase(name)) {
|
||||
return e.getValue();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String pct(int part, int total) {
|
||||
return total <= 0 ? null : Math.round(part * 100.0 / total) + "%";
|
||||
}
|
||||
|
||||
private static Integer asInt(Object o) {
|
||||
return o instanceof Number n ? n.intValue() : null;
|
||||
}
|
||||
|
||||
private static Long asLong(Object o) {
|
||||
return o instanceof Number n ? n.longValue() : null;
|
||||
}
|
||||
|
||||
/** 是否看全租户:学校管理员与审核员。平台运营不分管具体学校的任务,但也不该被拦。 */
|
||||
private boolean seesWholeTenant() {
|
||||
LoginUser me = UserContext.require();
|
||||
@@ -128,7 +221,9 @@ public class TaskService {
|
||||
// ── 详情 ──────────────────────────────────────
|
||||
|
||||
public TaskVO detail(Long id) {
|
||||
return toFullVo(requireTask(id));
|
||||
TaskVO vo = toFullVo(requireTask(id));
|
||||
fillStats(new ArrayList<>(List.of(vo)));
|
||||
return vo;
|
||||
}
|
||||
|
||||
// ── 新建 ──────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user