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 条填表规则说明页**。 + * + *

这三条都不是装饰: + *

+ * + *

表头列从哪来

+ * + *

用**当前任务的材料清单**推导"要交哪些材料"那一页,而不是写死。 + * 但表头列本身是**固定 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; + } +}