feat(roster): 名册导入模板下载(§8.5)—— 表头固定第 1 行、必填列标红、9 条填表规则页
服务端生成(POI),不放前端静态目录:表头列必须与导入解析器的已知列名表一致, 两处各写一份必然分叉,而分叉的表现是「照着模板填的表导不进来」。 三条要求都不是装饰: - 表头在第 1 行:导入时表头是自动探测的(客户给的文件里实测有表头在第 6 行的), 但我们自己发的模板必须规矩,否则「下载模板 → 填写 → 传回」这条最正常的路径反被折腾; - 必填列标红:填错的绝大多数原因是不知道这列必填,而错误要到导入时才报(那时已经填了 500 行); - 规则说明页:9 条里最值钱的是「身份证号必须是真的」与「在校生/非在校生决定要交哪些材料」, 不写清就会先填完再被逐条打回。 「要交哪些材料」那一页用当前任务的材料清单推导(不写死);表头列本身固定 21 列 —— 跟着任务变的话,客户换个任务下载到的模板就不一样,而「我上次填的那份」会突然不适用。 验证:scripts/e2e-m7-taskconfig.py 69/69(模板表头与导入列定义逐项比对)。
This commit is contained in:
+227
@@ -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)。
|
||||
*
|
||||
* <h3>这份模板是"用户第一眼看到的东西",所以它有明确要求</h3>
|
||||
*
|
||||
* <p>契约写明:**表头固定在第 1 行、必填列标红、附 9 条填表规则说明页**。
|
||||
*
|
||||
* <p>这三条都不是装饰:
|
||||
* <ul>
|
||||
* <li><b>表头在第 1 行</b>:导入时表头是**自动探测**的(实测客户给的文件里
|
||||
* 有一份表头在第 6 行)。但我们自己发的模板必须规规矩矩 ——
|
||||
* 否则用户"下载模板 → 填写 → 传回来"这条最正常的路径反而会被探测算法折腾;</li>
|
||||
* <li><b>必填列标红</b>:客户填错的绝大多数原因是"不知道这列必填",
|
||||
* 而错误要到导入时才报出来(那时他已经填了 500 行);</li>
|
||||
* <li><b>规则说明页</b>:9 条规则里最有价值的是"身份证号必须是真的"与
|
||||
* "在校生/非在校生决定要交哪些材料" —— 这两条不写在模板里,
|
||||
* 客户就会先填完再被逐条打回。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h3>表头列从哪来</h3>
|
||||
*
|
||||
* <p>用**当前任务的材料清单**推导"要交哪些材料"那一页,而不是写死。
|
||||
* 但表头列本身是**固定 21 列**(与导入解析器的已知列名一致):
|
||||
* 表头跟着任务变的话,客户换个任务下载到的模板就不一样,
|
||||
* 而"我上次填的那份"会突然不适用。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RosterTemplateService {
|
||||
|
||||
private final TaskMapper taskMapper;
|
||||
|
||||
/** 表头列(与 `RosterImportService` 的已知列名表一致)。 */
|
||||
private static final List<Column> 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user