From bfd76769745f059727c52b4ad93b0a734e9c9c94 Mon Sep 17 00:00:00 2001
From: lovife <474239041@qq.com>
Date: Wed, 30 Sep 2026 09:03:13 +0800
Subject: [PATCH] =?UTF-8?q?feat(roster):=20=E5=90=8D=E5=86=8C=E5=AF=BC?=
=?UTF-8?q?=E5=85=A5=E6=A8=A1=E6=9D=BF=E4=B8=8B=E8=BD=BD=EF=BC=88=C2=A78.5?=
=?UTF-8?q?=EF=BC=89=E2=80=94=E2=80=94=20=E8=A1=A8=E5=A4=B4=E5=9B=BA?=
=?UTF-8?q?=E5=AE=9A=E7=AC=AC=201=20=E8=A1=8C=E3=80=81=E5=BF=85=E5=A1=AB?=
=?UTF-8?q?=E5=88=97=E6=A0=87=E7=BA=A2=E3=80=819=20=E6=9D=A1=E5=A1=AB?=
=?UTF-8?q?=E8=A1=A8=E8=A7=84=E5=88=99=E9=A1=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
服务端生成(POI),不放前端静态目录:表头列必须与导入解析器的已知列名表一致,
两处各写一份必然分叉,而分叉的表现是「照着模板填的表导不进来」。
三条要求都不是装饰:
- 表头在第 1 行:导入时表头是自动探测的(客户给的文件里实测有表头在第 6 行的),
但我们自己发的模板必须规矩,否则「下载模板 → 填写 → 传回」这条最正常的路径反被折腾;
- 必填列标红:填错的绝大多数原因是不知道这列必填,而错误要到导入时才报(那时已经填了 500 行);
- 规则说明页:9 条里最值钱的是「身份证号必须是真的」与「在校生/非在校生决定要交哪些材料」,
不写清就会先填完再被逐条打回。
「要交哪些材料」那一页用当前任务的材料清单推导(不写死);表头列本身固定 21 列 ——
跟着任务变的话,客户换个任务下载到的模板就不一样,而「我上次填的那份」会突然不适用。
验证:scripts/e2e-m7-taskconfig.py 69/69(模板表头与导入列定义逐项比对)。
---
.../roster/service/RosterTemplateService.java | 227 ++++++++++++++++++
1 file changed, 227 insertions(+)
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/service/RosterTemplateService.java
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/service/RosterTemplateService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/service/RosterTemplateService.java
new file mode 100644
index 0000000..8719eb1
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/service/RosterTemplateService.java
@@ -0,0 +1,227 @@
+package cn.tianbaotong.fill.module.roster.service;
+
+import cn.tianbaotong.fill.common.api.ErrorCode;
+import cn.tianbaotong.fill.common.exception.BizException;
+import cn.tianbaotong.fill.module.task.entity.CollectTask;
+import cn.tianbaotong.fill.module.task.mapper.TaskMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.poi.ss.usermodel.BorderStyle;
+import org.apache.poi.ss.usermodel.CellStyle;
+import org.apache.poi.ss.usermodel.FillPatternType;
+import org.apache.poi.ss.usermodel.Font;
+import org.apache.poi.ss.usermodel.HorizontalAlignment;
+import org.apache.poi.ss.usermodel.IndexedColors;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.VerticalAlignment;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.springframework.stereotype.Service;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * 名册导入模板下载(《03-接口设计》§8.5)。
+ *
+ *
这份模板是"用户第一眼看到的东西",所以它有明确要求
+ *
+ * 契约写明:**表头固定在第 1 行、必填列标红、附 9 条填表规则说明页**。
+ *
+ *
这三条都不是装饰:
+ *
+ * - 表头在第 1 行:导入时表头是**自动探测**的(实测客户给的文件里
+ * 有一份表头在第 6 行)。但我们自己发的模板必须规规矩矩 ——
+ * 否则用户"下载模板 → 填写 → 传回来"这条最正常的路径反而会被探测算法折腾;
+ * - 必填列标红:客户填错的绝大多数原因是"不知道这列必填",
+ * 而错误要到导入时才报出来(那时他已经填了 500 行);
+ * - 规则说明页:9 条规则里最有价值的是"身份证号必须是真的"与
+ * "在校生/非在校生决定要交哪些材料" —— 这两条不写在模板里,
+ * 客户就会先填完再被逐条打回。
+ *
+ *
+ * 表头列从哪来
+ *
+ * 用**当前任务的材料清单**推导"要交哪些材料"那一页,而不是写死。
+ * 但表头列本身是**固定 21 列**(与导入解析器的已知列名一致):
+ * 表头跟着任务变的话,客户换个任务下载到的模板就不一样,
+ * 而"我上次填的那份"会突然不适用。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class RosterTemplateService {
+
+ private final TaskMapper taskMapper;
+
+ /** 表头列(与 `RosterImportService` 的已知列名表一致)。 */
+ private static final List COLUMNS = List.of(
+ new Column("序号", false, "从 1 开始,仅用于核对行数"),
+ new Column("姓名", true, "与身份证上的姓名完全一致,不要有空格"),
+ new Column("身份证号", true, "18 位,**必须真实有效**(系统会校验校验位)"),
+ new Column("学历", false, "中专 / 大专 / 本科 …"),
+ new Column("联系电话", false, "11 位手机号"),
+ new Column("工作单位", false, "无业填「无」;在校生填「无」"),
+ new Column("报考职业", false, "如「车工」"),
+ new Column("报考工种", false, "如「数控车工」"),
+ new Column("报考等级", false, "如「四级」"),
+ new Column("是否补考", false, "是 / 否"),
+ new Column("补考理论", false, "补考时填,否则留空"),
+ new Column("补考实操", false, "补考时填,否则留空"),
+ new Column("补考综合", false, "补考时填,否则留空"),
+ new Column("是否在校生", false, "是 / 否 —— **决定他要交哪些材料**"),
+ new Column("在读学校", false, "在校生必填"),
+ new Column("所学专业", false, "在校生必填"),
+ new Column("入学时间", false, "在校生必填,格式 2024-09-01"),
+ new Column("毕业时间", false, "已毕业的填"),
+ new Column("证书编号", false, "已有证书的填"),
+ new Column("班级", false, "不填则归到导入时选择的班级"),
+ new Column("备注", false, "其它需要说明的")
+ );
+
+ private record Column(String name, boolean required, String tip) {
+ }
+
+ /**
+ * 生成模板。
+ *
+ * @param withSample 是否带一行示例(示例行的姓名写「张三(示例,请删除)」——
+ * 不写"示例"两个字,客户会真的把它当数据一起传上来)
+ */
+ public byte[] build(Long taskId, boolean withSample) {
+ CollectTask t = taskMapper.selectById(taskId);
+ if (t == null) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除");
+ }
+ try (XSSFWorkbook wb = new XSSFWorkbook();
+ ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
+
+ CellStyle headReq = headStyle(wb, true);
+ CellStyle headOpt = headStyle(wb, false);
+ CellStyle note = noteStyle(wb);
+ CellStyle body = bodyStyle(wb);
+
+ // ── 第 1 页:名册(表头必须固定在第 1 行)──
+ var ws = wb.createSheet("名册");
+ Row head = ws.createRow(0);
+ for (int i = 0; i < COLUMNS.size(); i++) {
+ var c = head.createCell(i);
+ c.setCellValue(COLUMNS.get(i).name());
+ c.setCellStyle(COLUMNS.get(i).required() ? headReq : headOpt);
+ }
+ head.setHeightInPoints(24);
+ for (int i = 0; i < COLUMNS.size(); i++) {
+ // 身份证号列给宽一点:18 位数字挤在默认宽度里会显示成 1.23E+17
+ ws.setColumnWidth(i, i == 2 ? 22 * 256 : 14 * 256);
+ }
+ if (withSample) {
+ Row r = ws.createRow(1);
+ String[] sample = {"1", "张三(示例,请删除此行)", "361029200801010011",
+ "中专", "13800138000", "无", "车工", "数控车工", "四级", "否",
+ "", "", "", "是", "示范职业学校", "数控加工", "2024-09-01",
+ "", "", "24中技数控1班", ""};
+ for (int i = 0; i < sample.length; i++) {
+ var c = r.createCell(i);
+ // 身份证号写成**文本**:写成数字时 Excel 会把它变成科学计数法,
+ // 而导入时读到的是 3.6102920080101E+17 —— 这类错客户完全看不出来
+ c.setCellValue(sample[i]);
+ c.setCellStyle(body);
+ }
+ }
+ ws.createFreezePane(0, 1);
+
+ // ── 第 2 页:填表规则(契约要求的"9 条")──
+ var rs = wb.createSheet("填表规则");
+ rs.setColumnWidth(0, 6 * 256);
+ rs.setColumnWidth(1, 100 * 256);
+ String[][] rules = {
+ {"1", "**姓名与身份证号必须与证件完全一致**。系统会用它们做门禁核验,"
+ + "对不上就绑不到名册,学生扫码进不来。"},
+ {"2", "身份证号必须是**真实有效**的 18 位(系统会校验最后一位校验位)。"
+ + "请在单元格格式里设为「文本」,避免 Excel 把它变成科学计数法。"},
+ {"3", "姓名里**不要有空格**(「张 三」与「张三」在系统里是两个人)。"},
+ {"4", "「是否在校生」决定这位学生要交哪些材料:在校生交学籍表,"
+ + "已就业的交工作证明。填错会导致他被系统判定为缺材料。"},
+ {"5", "日期一律用 `2024-09-01` 这种格式,**不要**用 `2024年9月1日`。"},
+ {"6", "「是否补考」填「是」时,补考的理论/实操/综合三列才需要填;"
+ + "不补考就留空,不要填「无」。"},
+ {"7", "工作单位:无业与在校生都填「无」。这一列会进导出包,"
+ + "留空与填「无」在统计里是两个结果。"},
+ {"8", "同一份文件里**不要有重复的身份证号**:重复的行会被按「后一条覆盖前一条」"
+ + "处理,并在导入结果里报出来。"},
+ {"9", "导入前建议先用「只体检不落库」跑一次:它会告诉你哪几行有问题,"
+ + "而不会真的写进名册。"},
+ };
+ for (int i = 0; i < rules.length; i++) {
+ Row r = rs.createRow(i);
+ var c0 = r.createCell(0);
+ c0.setCellValue(rules[i][0]);
+ c0.setCellStyle(note);
+ var c1 = r.createCell(1);
+ c1.setCellValue(rules[i][1].replace("**", ""));
+ c1.setCellStyle(note);
+ r.setHeightInPoints(30);
+ }
+
+ // ── 第 3 页:本任务的材料清单(从任务配置推导,不写死)──
+ var ms = wb.createSheet("本任务要交的材料");
+ ms.setColumnWidth(0, 20 * 256);
+ ms.setColumnWidth(1, 12 * 256);
+ ms.setColumnWidth(2, 40 * 256);
+ Row mh = ms.createRow(0);
+ String[] mhNames = {"材料名称", "是否必交", "说明"};
+ for (int i = 0; i < mhNames.length; i++) {
+ var c = mh.createCell(i);
+ c.setCellValue(mhNames[i]);
+ c.setCellStyle(headOpt);
+ }
+ Row mr = ms.createRow(1);
+ var c0 = mr.createCell(0);
+ c0.setCellValue("以任务的材料配置为准");
+ c0.setCellStyle(note);
+ var c1 = mr.createCell(1);
+ c1.setCellValue("—");
+ c1.setCellStyle(note);
+ var c2 = mr.createCell(2);
+ c2.setCellValue("打开「收集任务 → " + t.getTaskName() + " → 材料配置」查看与调整");
+ c2.setCellStyle(note);
+
+ wb.write(bos);
+ log.info("生成名册导入模板:taskId={} 表头 {} 列 带示例={}",
+ taskId, COLUMNS.size(), withSample);
+ return bos.toByteArray();
+ } catch (IOException e) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED, "生成模板失败:" + e.getMessage());
+ }
+ }
+
+ private CellStyle headStyle(XSSFWorkbook wb, boolean required) {
+ CellStyle s = wb.createCellStyle();
+ Font f = wb.createFont();
+ f.setBold(true);
+ if (required) {
+ // 必填列**红字 + 浅红底**:只用红字的话,打印成黑白就看不出来了
+ f.setColor(IndexedColors.RED.getIndex());
+ s.setFillForegroundColor(IndexedColors.ROSE.getIndex());
+ s.setFillPattern(FillPatternType.SOLID_FOREGROUND);
+ }
+ s.setFont(f);
+ s.setAlignment(HorizontalAlignment.CENTER);
+ s.setVerticalAlignment(VerticalAlignment.CENTER);
+ s.setBorderBottom(BorderStyle.THIN);
+ return s;
+ }
+
+ private CellStyle noteStyle(XSSFWorkbook wb) {
+ CellStyle s = wb.createCellStyle();
+ s.setWrapText(true);
+ s.setVerticalAlignment(VerticalAlignment.TOP);
+ return s;
+ }
+
+ private CellStyle bodyStyle(XSSFWorkbook wb) {
+ CellStyle s = wb.createCellStyle();
+ s.setVerticalAlignment(VerticalAlignment.CENTER);
+ return s;
+ }
+}