diff --git a/HOWTO.md b/HOWTO.md new file mode 100644 index 0000000..7c5a02a --- /dev/null +++ b/HOWTO.md @@ -0,0 +1,725 @@ +# docfill HOWTO + +本文档说明如何使用 `docfill` 完成本地文档自动填充。文档分为两部分: + +- “最小实践”用于快速完成一次可验证运行。 +- “详细说明”用于理解任务配置、运行变量、筛选、模板、输出、错误和日志。 + +## 一、最小实践 + +### 1. 进入项目目录 + +```bash +cd /Users/zhaoyilun/Documents/xls_doc +``` + +### 2. 构建命令行工具 + +```bash +bash scripts/build.sh +``` + +输出示例: + +```text +/Users/zhaoyilun/Documents/xls_doc/dist/docfill-darwin-arm64 +``` + +构建后的可执行文件是: + +```text +dist/docfill-darwin-arm64 +``` + +### 3. 准备测试素材 + +仓库中已经包含 `manual_tests/` 测试素材。如果需要重新生成,可执行: + +```bash +/Users/zhaoyilun/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3 scripts/make-manual-test-materials.py +``` + +输出示例: + +```text +/Users/zhaoyilun/Documents/xls_doc/manual_tests +``` + +测试素材包含四类任务: + +- `manual_tests/01_excel_to_word`: Excel 源数据生成 Word 日报。 +- `manual_tests/02_sqlite_to_excel`: SQLite 源数据生成 Excel 汇总表。 +- `manual_tests/03_excel_to_excel`: Excel 源数据生成 Excel 质检表。 +- `manual_tests/04_filter_work_order_to_word`: 按日期和工单编号筛选一条工单,再生成 Word。 + +### 4. 运行筛选工单案例 + +命令: + +```bash +dist/docfill-darwin-arm64 run -c manual_tests/04_filter_work_order_to_word/task.json --var date=2026-06-18 --var ticket_no=GD-002 --verbose +``` + +输出示例: + +```text +log: load config path=manual_tests/04_filter_work_order_to_word/task.json +log: read data type=excel path=/Users/zhaoyilun/Documents/xls_doc/manual_tests/04_filter_work_order_to_word/source_work_orders.xlsx +log: read data type=excel path=/Users/zhaoyilun/Documents/xls_doc/manual_tests/04_filter_work_order_to_word/source_work_orders.xlsx rows=3 +log: filter rows=3 matched=1 expect=one +log: render row=1 output=/Users/zhaoyilun/Documents/xls_doc/manual_tests/04_filter_work_order_to_word/out/work_order_2026-06-18_GD-002.docx +generated 1 file(s) +- /Users/zhaoyilun/Documents/xls_doc/manual_tests/04_filter_work_order_to_word/out/work_order_2026-06-18_GD-002.docx +``` + +查看输出目录: + +```bash +open manual_tests/04_filter_work_order_to_word/out +``` + +输入 Excel 内容示例: + +```text +日期 工单编号 客户名称 系统来源 处理结果 下一步 +2026-06-18 GD-001 A 公司 PMS3.0 已派单 等待现场反馈 +2026-06-18 GD-002 B 公司 D5000 已完成 归档并同步日报 +2026-06-19 GD-001 C 公司 风控平台 待复核 补充截图 +``` + +本次命令传入: + +```text +date = 2026-06-18 +ticket_no = GD-002 +``` + +所以工具会筛选: + +```text +日期 = 2026-06-18,并且 工单编号 = GD-002 +``` + +最终只生成 `GD-002` 对应的 Word 文件。 + +## 二、详细说明 + +### 1. task.json 与 --var 的关系 + +`task.json` 是某一种业务报表的固定任务说明书。它通常由开发人员或维护人员准备好,普通运行时不需要频繁修改。 + +命令行 `--var` 是本次运行传入的具体条件,例如日期、工单编号、人员、批次。 + +可以这样理解: + +```text +不同业务 = 不同 task.json +本次运行的具体条件 = --var +真实业务数据 = Excel 或 SQLite +输出模板 = Word 或 Excel +``` + +例如不同业务可以分别维护: + +```text +工单日报.json +部门汇总.json +质检报告.json +``` + +运行时只传本次变化的信息: + +```bash +dist/docfill-darwin-arm64 run -c 工单日报.json --var date=2026-06-18 --var ticket_no=GD-002 +``` + +变量优先级: + +```text +数据表里的字段 > 命令行 --var > task.json 里的 vars +``` + +示例: + +```json +"vars": { + "date": "2026-06-18" +} +``` + +如果运行时执行: + +```bash +dist/docfill-darwin-arm64 run -c task.json --var date=2026-06-19 +``` + +实际使用的是: + +```text +date = 2026-06-19 +``` + +也就是命令行 `--var` 会覆盖 `task.json` 中的同名默认变量。 + +### 2. 命令格式 + +`docfill` 当前只提供一个核心命令: + +```bash +dist/docfill-darwin-arm64 run -c [--var key=value] [--verbose] +``` + +参数说明: + +| 参数 | 作用 | 示例 | +| --- | --- | --- | +| `run` | 执行一次填充任务 | `dist/docfill-darwin-arm64 run -c task.json` | +| `-c` | 指定任务配置文件 | `-c manual_tests/04_filter_work_order_to_word/task.json` | +| `--config` | `-c` 的完整写法 | `--config task.json` | +| `--var` | 传入运行时变量,可以重复使用 | `--var date=2026-06-18 --var ticket_no=GD-002` | +| `--verbose` | 输出诊断日志到 stderr | `--verbose` | + +命令行参数错误示例: + +```bash +dist/docfill-darwin-arm64 run --var date +``` + +输出示例: + +```text +docfill: [CLI_USAGE] invalid value "date" for flag -var: invalid --var "date", want key=value +hint: Check command flags and use key=value for --var. +``` + +### 3. 任务配置结构 + +典型的筛选任务配置如下: + +```json +{ + "vars": { + "date": "2026-06-18" + }, + "data": { + "type": "excel", + "path": "./source_work_orders.xlsx", + "sheet": "工单", + "header_row": 1 + }, + "filter": { + "equals": { + "日期": "{{date}}", + "工单编号": "{{ticket_no}}" + }, + "expect": "one" + }, + "template": { + "path": "./work_order_template.docx" + }, + "output": { + "path": "./out/work_order_{{日期}}_{{工单编号}}.docx", + "overwrite": true + } +} +``` + +配置字段说明: + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `vars` | 否 | 默认变量。适合放日期、批次、班次等默认值。 | +| `data.type` | 是 | 数据源类型。支持 `excel`、`xlsx`、`sqlite`。 | +| `data.path` | 是 | 数据源路径。支持 `.xlsx` 或 SQLite `.db`。 | +| `data.sheet` | Excel 可选 | Excel 工作表名称。未填写时读取第一个工作表。 | +| `data.header_row` | Excel 可选 | Excel 表头行号,从 `1` 开始。未填写时默认为 `1`。 | +| `data.query` | SQLite 必填 | SQLite 查询语句。查询结果列名作为字段名。 | +| `data.params` | SQLite 可选 | SQL `?` 参数列表,支持 `{{变量名}}`。 | +| `filter.equals` | 否 | 等值筛选条件。左侧是数据字段,右侧是匹配值。 | +| `filter.expect` | 否 | 筛选结果数量要求。支持 `one`、`many`,默认 `one`。 | +| `template.path` | 是 | Word 或 Excel 模板路径。支持 `.docx`、`.xlsx`。 | +| `output.path` | 是 | 输出文件路径模板。可以使用 `{{字段名}}`。 | +| `output.overwrite` | 否 | 是否覆盖已存在文件。默认是 `false`。 | + +相对路径规则: + +```text +task.json 所在目录就是相对路径的基准目录。 +``` + +### 4. 业务筛选 filter + +`filter` 的作用是从数据表中筛选出本次要使用的数据行。它的目标不是提供数据库查询能力,而是提供符合表格使用习惯的简单筛选。 + +配置示例: + +```json +"filter": { + "equals": { + "日期": "{{date}}", + "工单编号": "{{ticket_no}}" + }, + "expect": "one" +} +``` + +含义: + +```text +找到 日期 等于 date,并且 工单编号 等于 ticket_no 的数据行。 +``` + +命令: + +```bash +dist/docfill-darwin-arm64 run -c task.json --var date=2026-06-18 --var ticket_no=GD-002 +``` + +实际筛选: + +```text +日期 = 2026-06-18 +工单编号 = GD-002 +``` + +筛选规则: + +- `filter.equals` 左侧必须是 Excel 表头或 SQLite 查询结果列名。 +- `filter.equals` 右侧可以写固定值,也可以写 `{{变量名}}`。 +- 多个条件之间是“全部满足”关系。 +- 比较前会去掉单元格值和筛选值两侧的空格。 +- 只支持等值匹配。 +- 不支持模糊匹配、范围查询、OR、排序表达式或复杂条件。 + +`filter.expect` 支持两个值: + +| 值 | 含义 | 适用场景 | +| --- | --- | --- | +| `one` | 必须只匹配一行 | 工单、单份日报、单个报表。 | +| `many` | 允许匹配多行 | 按日期批量生成多个人或多个部门的报表。 | + +未填写 `filter.expect` 时,默认按 `one` 处理。 + +如果没有配置 `filter`,工具会沿用旧行为:读取到的每一行都会生成一个输出文件。 + +复杂情况的处理建议: + +- 如果多个字段才能确定一条数据,就在 `filter.equals` 中写多个条件。 +- 如果表格本身不容易筛选,建议在 Excel 中新增辅助列,例如 `填报批次`、`业务唯一标识`、`筛选日期`。 +- 不建议把复杂业务规则写进 `docfill`,复杂规则应在数据准备阶段完成。 + +### 5. Excel 数据源 + +Excel 数据源配置示例: + +```json +{ + "data": { + "type": "excel", + "path": "./source_work_orders.xlsx", + "sheet": "工单", + "header_row": 1 + } +} +``` + +运行示例: + +```bash +dist/docfill-darwin-arm64 run -c manual_tests/04_filter_work_order_to_word/task.json --var date=2026-06-18 --var ticket_no=GD-002 +``` + +Excel 读取规则: + +- `header_row` 指定表头行,表头单元格内容就是字段名。 +- 表头下方的每一行是候选数据。 +- 配置了 `filter` 时,先筛选,再生成输出。 +- 未配置 `filter` 时,每一行都会生成一个输出文件。 +- 空行会被跳过。 +- 空表头列会被忽略。 + +表头在第 1 行的输入示例: + +```text +日期 工单编号 客户名称 系统来源 处理结果 下一步 +2026-06-18 GD-001 A 公司 PMS3.0 已派单 等待现场反馈 +2026-06-18 GD-002 B 公司 D5000 已完成 归档并同步日报 +2026-06-19 GD-001 C 公司 风控平台 待复核 补充截图 +``` + +表头在第 2 行的配置示例: + +```json +{ + "data": { + "type": "excel", + "path": "./source_audit_data.xlsx", + "sheet": "Audit", + "header_row": 2 + } +} +``` + +对应输入示例: + +```text +日报质检导出 + +employee date item score comment +王五 2026-06-18 日报完整性 92 字段齐全,说明清楚 +赵六 2026-06-18 系统截图一致性 87 缺少 1 张截图,需要补充 +``` + +### 6. SQLite 数据源 + +SQLite 数据源配置示例: + +```json +{ + "vars": { + "date": "2026-06-18" + }, + "data": { + "type": "sqlite", + "path": "./source_summary.db", + "query": "select department, report_date, total_count, abnormal_count, owner from department_summary order by department", + "params": [] + }, + "filter": { + "equals": { + "report_date": "{{date}}" + }, + "expect": "many" + } +} +``` + +运行示例: + +```bash +dist/docfill-darwin-arm64 run -c manual_tests/02_sqlite_to_excel/task.json --var date=2026-06-18 +``` + +SQLite 读取规则: + +- `data.query` 的查询结果列名就是模板字段名。 +- `data.params` 按顺序对应 SQL 中的 `?`。 +- `data.params` 可以引用 `vars` 或命令行 `--var`。 +- 配置了 `filter` 时,SQL 查询结果还会再经过业务筛选。 +- 查询结果的每一行或筛选后的每一行生成一个输出文件。 + +输入数据示例: + +```text +department report_date total_count abnormal_count owner +营销部 2026-06-17 115 2 张三 +客服部 2026-06-18 76 0 李四 +营销部 2026-06-18 128 3 张三 +``` + +SQLite 可以继续使用 SQL 参数: + +```json +"data": { + "type": "sqlite", + "path": "./source_summary.db", + "query": "select department, report_date, total_count, abnormal_count, owner from department_summary where report_date = ? order by department", + "params": ["{{date}}"] +} +``` + +这适合由开发人员维护的 SQLite 数据源。面向表格使用习惯时,优先使用 `filter.equals` 表达业务筛选。 + +### 7. Word 模板 + +Word 模板使用 `.docx` 文件,模板中写入 `{{字段名}}`: + +```text +日期:{{日期}} +工单编号:{{工单编号}} +客户名称:{{客户名称}} +系统来源:{{系统来源}} +处理结果:{{处理结果}} +下一步:{{下一步}} +``` + +运行示例: + +```bash +dist/docfill-darwin-arm64 run -c manual_tests/04_filter_work_order_to_word/task.json --var date=2026-06-18 --var ticket_no=GD-002 +``` + +生成结果示例: + +```text +日期:2026-06-18 +工单编号:GD-002 +客户名称:B 公司 +系统来源:D5000 +处理结果:已完成 +下一步:归档并同步日报 +``` + +Word 支持同一段落内被 Word 拆分的文本占位符。例如模板内部可能把 `{{name}}` 拆成相邻文本节点 `{{na` 和 `me}}`,工具会按同一段落进行最小兼容处理。 + +这种兼容会把同一段落内的拆分文本合并后再写回,渲染结果会写入该段落第一个文本节点的位置,后续拆分节点的独立格式不再体现。建议模板中每个占位符使用同一种字体、颜色和加粗状态,不要在 `{{`、字段名、`}}` 之间混用格式。 + +当前不支持复杂模板语法,例如表格循环、条件判断、图片插入、跨段落拼接占位符。 + +### 8. Excel 模板 + +Excel 模板使用 `.xlsx` 文件,单元格中写入 `{{字段名}}`: + +```text +B2: {{department}} +B3: {{report_date}} +B4: {{owner}} +B5: {{total_count}} +B6: {{abnormal_count}} +``` + +运行示例: + +```bash +dist/docfill-darwin-arm64 run -c manual_tests/02_sqlite_to_excel/task.json --var date=2026-06-18 +``` + +生成结果示例: + +```text +B2: 营销部 +B3: 2026-06-18 +B4: 张三 +B5: 128 +B6: 3 +``` + +Excel 模板会遍历所有工作表,替换单元格文本中的占位符。当前不支持公式重算、透视表刷新、复杂样式模板语法。 + +### 9. 输出路径 + +输出路径可以使用字段占位符: + +```json +{ + "output": { + "path": "./out/work_order_{{日期}}_{{工单编号}}.docx", + "overwrite": true + } +} +``` + +运行示例: + +```bash +dist/docfill-darwin-arm64 run -c manual_tests/04_filter_work_order_to_word/task.json --var date=2026-06-18 --var ticket_no=GD-002 +``` + +输出文件示例: + +```text +work_order_2026-06-18_GD-002.docx +``` + +文件名清理规则: + +```text +/ \ : * ? " < > | +``` + +以上字符会在输出路径字段值中替换为 `_`。例如: + +```text +张三/测试 -> 张三_测试 +``` + +覆盖规则: + +- `output.overwrite` 未设置或为 `false` 时,输出文件已存在会失败。 +- `output.overwrite` 为 `true` 时,允许覆盖同名输出文件。 + +### 10. 日志、标准输出和退出码 + +成功时,stdout 输出生成文件清单: + +```text +generated 1 file(s) +- /path/to/out/work_order_2026-06-18_GD-002.docx +``` + +启用 `--verbose` 后,stderr 输出诊断日志: + +```text +log: load config path=task.json +log: read data type=excel path=/path/data.xlsx +log: read data type=excel path=/path/data.xlsx rows=3 +log: filter rows=3 matched=1 expect=one +log: render row=1 output=/path/out/work_order_2026-06-18_GD-002.docx +``` + +退出码: + +| 退出码 | 含义 | 适用场景 | +| --- | --- | --- | +| `0` | 成功 | 可以读取 stdout 中的生成文件清单。 | +| `1` | 执行失败 | 配置、数据、模板、输出等处理失败。 | +| `2` | 命令行错误 | 参数缺失、命令错误、`--var` 格式错误。 | + +数字员工或调度脚本建议: + +- 判断退出码确认任务是否成功。 +- 成功时读取 stdout 获取生成文件路径。 +- 失败时记录 stderr,用于定位错误码、提示和上下文。 +- 仅在诊断或审计时启用 `--verbose`。 + +### 11. 错误格式与错误码 + +错误输出格式: + +```text +docfill: [ERROR_CODE] message +hint: repair suggestion +context: key=value +``` + +配置文件不存在示例: + +```bash +dist/docfill-darwin-arm64 run -c manual_tests/not-exist.json +``` + +输出示例: + +```text +docfill: [CONFIG_READ] read config: open manual_tests/not-exist.json: no such file or directory +hint: Check that the config file exists and is readable. +context: config=manual_tests/not-exist.json +``` + +筛选无匹配示例: + +```bash +dist/docfill-darwin-arm64 run -c manual_tests/04_filter_work_order_to_word/task.json --var date=2026-06-18 --var ticket_no=GD-999 --verbose +``` + +输出示例: + +```text +log: load config path=manual_tests/04_filter_work_order_to_word/task.json +log: read data type=excel path=/Users/zhaoyilun/Documents/xls_doc/manual_tests/04_filter_work_order_to_word/source_work_orders.xlsx +log: read data type=excel path=/Users/zhaoyilun/Documents/xls_doc/manual_tests/04_filter_work_order_to_word/source_work_orders.xlsx rows=3 +docfill: [DATA_FILTER_NO_MATCH] filter matched no rows +hint: Check filter.equals, --var values, or source table contents. +context: expect=one filter=工单编号=GD-999,日期=2026-06-18 +``` + +错误码说明: + +| 错误码 | 含义 | 常见处理方式 | +| --- | --- | --- | +| `CLI_USAGE` | 命令行参数错误 | 检查命令、`-c`、`--var key=value`。 | +| `CONFIG_READ` | 配置文件读取失败 | 检查路径、文件是否存在、读权限。 | +| `CONFIG_PARSE` | 配置 JSON 解析失败 | 检查 JSON 语法。 | +| `CONFIG_VALIDATE` | 配置字段缺失或不支持 | 检查 `data.type`、`data.path`、`template.path`、`output.path`、`filter.expect`。 | +| `DATA_EXCEL` | Excel 数据读取失败 | 检查 `.xlsx` 文件、工作表名、表头行。 | +| `DATA_SQLITE` | SQLite 数据读取或查询失败 | 检查 `.db` 文件、SQL、参数。 | +| `DATA_EMPTY` | 数据源没有返回可生成的数据行 | 检查源数据是否为空。 | +| `DATA_FILTER_FIELD` | 筛选字段不在数据中 | 检查 Excel 表头或 SQLite 查询列名。 | +| `DATA_FILTER_VALUE` | 筛选条件引用的变量没有提供 | 补充 `--var` 或 `vars`。 | +| `DATA_FILTER_NO_MATCH` | 筛选后没有符合条件的数据行 | 检查筛选条件,或整理源表。 | +| `DATA_FILTER_MULTI_MATCH` | 期望一行但匹配多行 | 增加筛选条件,或整理重复数据。 | +| `TEMPLATE_OPEN` | 模板读取失败 | 检查模板路径和文件格式。 | +| `TEMPLATE_FIELD_MISSING` | 模板字段缺失 | 在数据源、`vars` 或 `--var` 中补充字段。 | +| `TEMPLATE_UNRESOLVED` | 渲染后仍残留占位符 | 检查模板占位符拼写和字段值。 | +| `OUTPUT_EXISTS` | 输出文件已存在 | 修改输出路径或设置 `output.overwrite: true`。 | +| `OUTPUT_WRITE` | 输出写入失败 | 检查输出目录权限、文件占用状态。 | +| `INTERNAL` | 未分类内部错误 | 保留 stderr 和输入文件,开启 `--verbose` 复现。 | + +### 12. 自动化调用示例 + +以下示例展示数字员工或调度脚本如何调用 `docfill`。该脚本只做最小封装,不承担模板解析或业务逻辑。 + +```bash +#!/usr/bin/env bash +set -euo pipefail + +DOCFILL="/Users/zhaoyilun/Documents/xls_doc/dist/docfill-darwin-arm64" +CONFIG="/Users/zhaoyilun/Documents/xls_doc/manual_tests/04_filter_work_order_to_word/task.json" +DATE="${1:?date is required, example: 2026-06-18}" +TICKET_NO="${2:?ticket_no is required, example: GD-002}" + +"$DOCFILL" run -c "$CONFIG" --var date="$DATE" --var ticket_no="$TICKET_NO" --verbose +``` + +执行: + +```bash +bash run-docfill.sh 2026-06-18 GD-002 +``` + +预期结果: + +```text +generated 1 file(s) +- /Users/zhaoyilun/Documents/xls_doc/manual_tests/04_filter_work_order_to_word/out/work_order_2026-06-18_GD-002.docx +``` + +集成建议: + +- 把业务系统接口采集结果先落成 Excel 或 SQLite。 +- 为每类报表维护一个 `task.json`。 +- 数字员工只负责准备运行变量并执行命令。 +- 复杂筛选优先通过整理 Excel 表解决,例如新增辅助列。 +- 不要把账号密码、审批状态、业务系统访问逻辑写入 `docfill` 配置。 + +### 13. 开发与验证 + +运行全部 Go 测试: + +```bash +go test -count=1 ./... +``` + +运行冒烟测试: + +```bash +bash scripts/smoke.sh +``` + +构建发布用本机二进制: + +```bash +bash scripts/build.sh +``` + +重新生成手工测试素材: + +```bash +/Users/zhaoyilun/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3 scripts/make-manual-test-materials.py +``` + +冒烟测试覆盖内容: + +- Go 单元测试。 +- 临时构建 `docfill`。 +- 生成临时 Excel、Word、SQLite 示例数据。 +- 执行无筛选 Excel 到 Word 示例。 +- 执行筛选 Excel 到 Word 示例。 +- 执行 SQLite 到 Excel 示例。 +- 检查输出文件是否生成。 + +### 14. 当前边界 + +为了保持工具小型、稳定、便于自动化调用,当前明确不包含以下能力: + +- 不提供 Web 界面。 +- 不提供模板设计器。 +- 不直接登录业务系统。 +- 不管理账号、密码或权限。 +- 不实现审批流。 +- 不实现 Word 表格循环或条件语法。 +- 不实现 Excel 公式重算和透视表刷新。 +- 不实现复杂筛选语法。 +- 不实现复杂任务编排。 + +如需扩展,应优先通过外部脚本准备数据,再交给 `docfill` 执行单次文档填充。 diff --git a/README.md b/README.md index 3458507..f5572e7 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,151 @@ # docfill -`docfill` 是一个最小化本地填报工具:从 Excel 或 SQLite 读取数据,把 `{{字段名}}` 填入 Word/Excel 模板,生成输出文件。 +`docfill` 是一个本地文档填充命令行工具。它从 Excel 或 SQLite 读取结构化数据,按业务条件筛选出需要使用的数据行,再把数据填入 Word 或 Excel 模板中的 `{{字段名}}` 占位符。 -## Build +本工具的定位是“小型、稳定、便于数字员工调用”。它只负责本地数据到本地模板的填充,不内置业务系统登录、审批流、Web 界面、模板设计器或权限系统。 + +## 核心理解 + +`task.json` 是某一种业务报表的固定任务说明书,命令行 `--var` 是本次运行传入的具体条件。 + +```text +不同业务 = 不同 task.json +本次运行的日期、工单编号、人员、批次 = --var +真实数据 = Excel 或 SQLite +模板文件 = Word 或 Excel +``` + +优先级: + +```text +数据表里的字段 > 命令行 --var > task.json 里的 vars +``` + +例如 `task.json` 中写了: + +```json +"vars": { + "date": "2026-06-18" +} +``` + +运行时执行: + +```bash +dist/docfill-darwin-arm64 run -c task.json --var date=2026-06-19 --var ticket_no=GD-001 +``` + +实际使用的是: + +```text +date = 2026-06-19 +ticket_no = GD-001 +``` + +也就是 `--var date=2026-06-19` 会覆盖 `task.json` 里的默认 `date=2026-06-18`。 + +## 最小实践 + +进入仓库目录: + +```bash +cd /Users/zhaoyilun/Documents/xls_doc +``` + +构建本机二进制: ```bash -go build -o docfill ./cmd/docfill bash scripts/build.sh ``` -`scripts/build.sh` writes the local macOS arm64 binary to `dist/docfill-darwin-arm64`. - -## Run - -```bash -./docfill run --config task.json -./docfill run -c task.json --var date=2026-06-18 --var batch=早班 -``` - -成功时 stdout 输出生成文件清单: +输出示例: ```text -generated 2 file(s) -- /path/to/out/report_张三.xlsx -- /path/to/out/report_李四.xlsx +/Users/zhaoyilun/Documents/xls_doc/dist/docfill-darwin-arm64 ``` -退出码: - -- `0`: 成功。 -- `1`: 配置、数据、模板或输出失败。 -- `2`: 命令行参数错误。 - -## Run Case Tests +运行“按日期 + 工单编号筛选一条工单并生成 Word”的测试任务: ```bash -go test -run TestCase -v ./internal/docfill +dist/docfill-darwin-arm64 run -c manual_tests/04_filter_work_order_to_word/task.json --var date=2026-06-18 --var ticket_no=GD-002 --verbose ``` -当前案例覆盖: +输出示例: -- Excel 日报数据填 Excel 模板,批量生成个人日报。 -- SQLite 日报数据填 Word 模板,批量生成个人日报。 -- SQLite 汇总数据填 Excel 模板,批量生成部门汇总表。 +```text +log: load config path=manual_tests/04_filter_work_order_to_word/task.json +log: read data type=excel path=/Users/zhaoyilun/Documents/xls_doc/manual_tests/04_filter_work_order_to_word/source_work_orders.xlsx +log: read data type=excel path=/Users/zhaoyilun/Documents/xls_doc/manual_tests/04_filter_work_order_to_word/source_work_orders.xlsx rows=3 +log: filter rows=3 matched=1 expect=one +log: render row=1 output=/Users/zhaoyilun/Documents/xls_doc/manual_tests/04_filter_work_order_to_word/out/work_order_2026-06-18_GD-002.docx +generated 1 file(s) +- /Users/zhaoyilun/Documents/xls_doc/manual_tests/04_filter_work_order_to_word/out/work_order_2026-06-18_GD-002.docx +``` -## Smoke Test +查看生成结果: + +```bash +open manual_tests/04_filter_work_order_to_word/out +``` + +更多可运行案例见 [HOWTO.md](HOWTO.md) 和 [manual_tests/README.md](manual_tests/README.md)。 + +## 常用命令 + +构建: + +```bash +bash scripts/build.sh +``` + +运行一个任务: + +```bash +dist/docfill-darwin-arm64 run -c task.json +``` + +运行任务并传入本次筛选条件: + +```bash +dist/docfill-darwin-arm64 run -c 工单日报.json --var date=2026-06-18 --var ticket_no=GD-002 +``` + +输出诊断日志: + +```bash +dist/docfill-darwin-arm64 run -c task.json --verbose +``` + +重新生成本地测试素材: + +```bash +/Users/zhaoyilun/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3 scripts/make-manual-test-materials.py +``` + +运行自动化冒烟测试: ```bash bash scripts/smoke.sh ``` -Smoke test 会: +运行 Go 测试: -- 跑全量 Go 测试。 -- 构建临时 `docfill` 二进制。 -- 复制 `examples/` 到临时目录。 -- 生成临时 Excel、Word、SQLite 示例数据。 -- 执行两个示例任务并检查输出文件。 +```bash +go test -count=1 ./... +``` -## Digital Employee Runbook +## 筛选配置示例 -给数字员工或调度脚本使用时,看: +Excel 工单表: -- `docs/digital-employee-runbook.md` +```text +日期 工单编号 客户名称 系统来源 处理结果 下一步 +2026-06-18 GD-001 A 公司 PMS3.0 已派单 等待现场反馈 +2026-06-18 GD-002 B 公司 D5000 已完成 归档并同步日报 +2026-06-19 GD-001 C 公司 风控平台 待复核 补充截图 +``` -## Runnable Examples - -示例配置在 `examples/`: - -- `examples/excel-to-word/task.json` -- `examples/sqlite-to-excel/task.json` - -示例里的二进制 Office/SQLite 文件由 `scripts/smoke-fixtures` 临时生成,不提交到仓库。 - -## Excel Data Example +`task.json`: ```json { @@ -82,63 +154,88 @@ Smoke test 会: }, "data": { "type": "excel", - "path": "./data.xlsx", - "sheet": "Sheet1", + "path": "./source_work_orders.xlsx", + "sheet": "工单", "header_row": 1 }, + "filter": { + "equals": { + "日期": "{{date}}", + "工单编号": "{{ticket_no}}" + }, + "expect": "one" + }, "template": { - "path": "./template.docx" + "path": "./work_order_template.docx" }, "output": { - "path": "./out/report_{{date}}_{{name}}_{{_row}}.docx", - "overwrite": false + "path": "./out/work_order_{{日期}}_{{工单编号}}.docx", + "overwrite": true } } ``` -Excel 第一行是字段名,后续每一行生成一个文件。 +运行: -## SQLite Data Example - -```json -{ - "vars": { - "date": "2026-06-18" - }, - "data": { - "type": "sqlite", - "path": "./data.db", - "query": "select name, date, done from daily_report where date = ?", - "params": ["{{date}}"] - }, - "template": { - "path": "./template.xlsx" - }, - "output": { - "path": "./out/report_{{name}}_{{_row}}.xlsx" - } -} +```bash +dist/docfill-darwin-arm64 run -c task.json --var date=2026-06-18 --var ticket_no=GD-002 ``` -SQL 查询结果的列名就是模板占位符字段名。 +筛选含义: -## P0 Rules +```text +找到 日期 = 2026-06-18,并且 工单编号 = GD-002 的那一行。 +``` -- 配置里的相对路径按 `task.json` 所在目录解析。 -- `vars` 可以提供默认变量,命令行 `--var key=value` 会覆盖同名默认变量。 -- 模板填充时,每行数据字段优先于 `vars`。 -- `_row` 是内置变量,表示当前数据行序号,从 `1` 开始。 -- SQLite `params` 对应 SQL 里的 `?` 参数。 -- Excel `header_row` 从 `1` 开始,默认是 `1`。 -- 默认不覆盖已有输出文件;只有 `output.overwrite: true` 才允许覆盖。 -- 输出文件路径中的字段值会清理 `/ \ : * ? " < > |`。 -- 模板里缺字段或渲染后仍有 `{{字段}}`,会直接失败。 -- Word 模板支持同一段落内被拆开的文本占位符,例如 `{{na` 和 `me}}` 位于相邻文本节点。 +`filter.expect` 支持: -## MVP Limits +- `one`: 必须只匹配一行,适合工单、单份日报、单个报表。 +- `many`: 允许匹配多行,适合按日期生成多个人或多个部门的报表。 -- 只支持 JSON 配置。 -- 只支持 `.xlsx` 数据源、SQLite 数据源。 -- 只支持 `.docx` 和 `.xlsx` 模板。 -- Word 占位符拆分只做最小兼容:支持同一段落内的文本节点拆分,不做复杂表格循环或样式级模板语法。 -- 不做 Web、权限、审批流、模板设计器或业务系统直连。 +未配置 `filter` 时,工具沿用旧行为:读取到的每一行都会生成一个输出文件。 + +## 核心规则 + +- 配置文件使用 JSON。 +- 数据源支持 `.xlsx` 和 SQLite `.db`。 +- 模板与输出支持 `.docx` 和 `.xlsx`。 +- 模板占位符格式为 `{{字段名}}`。 +- Excel 数据的表头行提供字段名,SQLite 查询结果的列名提供字段名。 +- 常用场景是通过 `filter.equals` 先筛选数据,再填充模板。 +- 多个筛选条件之间是“全部满足”关系。 +- 筛选只支持等值匹配,不支持模糊匹配、范围查询、OR 或复杂表达式。 +- 复杂情况建议业务人员整理 Excel,例如新增“填报批次”“业务唯一标识”等辅助列。 +- `vars` 提供默认变量,命令行 `--var key=value` 可以覆盖同名变量。 +- 数据行字段优先于 `vars` 和 `--var`。 +- `_row` 是内置变量,表示当前输出对应的结果行序号,从 `1` 开始。 +- 默认不覆盖已存在文件,只有 `output.overwrite: true` 才允许覆盖。 +- 成功结果写入 stdout,诊断日志和错误写入 stderr,便于数字员工或调度脚本判断执行结果。 + +## 退出码 + +- `0`: 执行成功。 +- `1`: 配置、数据、模板或输出处理失败。 +- `2`: 命令行参数错误。 + +错误输出格式: + +```text +docfill: [ERROR_CODE] message +hint: repair suggestion +context: key=value +``` + +常见筛选错误: + +- `DATA_FILTER_FIELD`: 筛选字段不在数据表中。 +- `DATA_FILTER_VALUE`: 筛选条件引用的变量没有提供。 +- `DATA_FILTER_NO_MATCH`: 没有找到符合条件的数据行。 +- `DATA_FILTER_MULTI_MATCH`: 期望匹配一行,但实际匹配多行。 + +完整错误码、日志说明和集成建议见 [HOWTO.md](HOWTO.md)。 + +## 文档索引 + +- [HOWTO.md](HOWTO.md): 完整使用说明,包含最小实践、详细配置、输入输出示例、错误处理和数字员工调用建议。 +- [manual_tests/README.md](manual_tests/README.md): 本地真实测试素材说明。 +- [docs/digital-employee-runbook.md](docs/digital-employee-runbook.md): 面向数字员工或调度脚本的最小运行约定。 diff --git a/cmd/docfill/main.go b/cmd/docfill/main.go index bf46060..7fff71b 100644 --- a/cmd/docfill/main.go +++ b/cmd/docfill/main.go @@ -1,10 +1,12 @@ package main import ( + "errors" "flag" "fmt" "io" "os" + "sort" "strings" "xlsdoc/internal/docfill" @@ -18,11 +20,11 @@ func main() { func run(args []string, stdout, stderr io.Writer) int { if len(args) == 0 { - fmt.Fprintln(stderr, "docfill: missing command: run") + writeCodedError(stderr, docfill.CodeCLIUsage, "missing command: run", "Use: docfill run -c task.json", nil) return 2 } if args[0] != "run" { - fmt.Fprintf(stderr, "docfill: unknown command: %s\n", args[0]) + writeCodedError(stderr, docfill.CodeCLIUsage, "unknown command: "+args[0], "Use: docfill run -c task.json", nil) return 2 } @@ -43,38 +45,78 @@ func runFill(args []string, stderr io.Writer) (docfill.RunResult, int) { fs.SetOutput(stderr) var configPath string + var verbose bool var vars varFlags = make(map[string]string) fs.SetOutput(io.Discard) fs.StringVar(&configPath, "config", "", "path to task json") fs.StringVar(&configPath, "c", "", "path to task json") + fs.BoolVar(&verbose, "verbose", false, "print diagnostic logs to stderr") fs.Var(vars, "var", "template variable as key=value; repeatable") if err := fs.Parse(args); err != nil { - fmt.Fprintf(stderr, "docfill: %v\n", err) + writeCodedError(stderr, docfill.CodeCLIUsage, err.Error(), "Check command flags and use key=value for --var.", nil) return docfill.RunResult{}, 2 } if fs.NArg() > 0 { - fmt.Fprintf(stderr, "docfill: unexpected argument: %s\n", fs.Arg(0)) + writeCodedError(stderr, docfill.CodeCLIUsage, "unexpected argument: "+fs.Arg(0), "Remove extra positional arguments.", nil) return docfill.RunResult{}, 2 } if vars == nil { vars = make(map[string]string) } if configPath == "" { - fmt.Fprintln(stderr, "docfill: missing --config") + writeCodedError(stderr, docfill.CodeCLIUsage, "missing --config", "Use -c task.json or --config task.json.", nil) fs.SetOutput(stderr) fs.Usage() return docfill.RunResult{}, 2 } - result, err := docfill.RunWithOptions(configPath, docfill.RunOptions{Vars: vars}) + var logf func(format string, args ...any) + if verbose { + logf = func(format string, args ...any) { + fmt.Fprintf(stderr, "log: "+format+"\n", args...) + } + } + + result, err := docfill.RunWithOptions(configPath, docfill.RunOptions{Vars: vars, Logf: logf}) if err != nil { - fmt.Fprintf(stderr, "docfill: %v\n", err) + writeExecutionError(stderr, err) return docfill.RunResult{}, 1 } return result, 0 } +func writeExecutionError(stderr io.Writer, err error) { + var appErr *docfill.AppError + if errors.As(err, &appErr) { + writeCodedError(stderr, appErr.Code, err.Error(), appErr.Hint, appErr.Context) + return + } + writeCodedError(stderr, docfill.CodeInternal, err.Error(), "Run with --verbose and inspect inputs if this repeats.", nil) +} + +func writeCodedError(stderr io.Writer, code docfill.ErrorCode, message, hint string, context map[string]string) { + fmt.Fprintf(stderr, "docfill: [%s] %s\n", code, message) + if hint != "" { + fmt.Fprintf(stderr, "hint: %s\n", hint) + } + if len(context) == 0 { + return + } + + keys := make([]string, 0, len(context)) + for key := range context { + keys = append(keys, key) + } + sort.Strings(keys) + + parts := make([]string, 0, len(keys)) + for _, key := range keys { + parts = append(parts, key+"="+context[key]) + } + fmt.Fprintf(stderr, "context: %s\n", strings.Join(parts, " ")) +} + func (v varFlags) String() string { if len(v) == 0 { return "" diff --git a/cmd/docfill/main_test.go b/cmd/docfill/main_test.go index bc7ca9d..24de209 100644 --- a/cmd/docfill/main_test.go +++ b/cmd/docfill/main_test.go @@ -63,7 +63,7 @@ func TestRunCommandRejectsBadVarSyntax(t *testing.T) { if exitCode != 2 { t.Fatalf("exit code = %d, want 2", exitCode) } - if !strings.Contains(stderr.String(), "docfill:") || !strings.Contains(stderr.String(), "invalid --var") { + if !strings.Contains(stderr.String(), "[CLI_USAGE]") || !strings.Contains(stderr.String(), "invalid --var") { t.Fatalf("unexpected stderr: %q", stderr.String()) } } @@ -84,6 +84,96 @@ func TestRunCommandRejectsUnexpectedArgs(t *testing.T) { } } +func TestRunCommandFormatsCodedExecutionErrors(t *testing.T) { + dir := t.TempDir() + dataPath := filepath.Join(dir, "data.xlsx") + templatePath := filepath.Join(dir, "template.xlsx") + configPath := filepath.Join(dir, "task.json") + + writeCLIWorkbook(t, dataPath, "Daily", [][]string{ + {"name"}, + {"张三"}, + }) + writeCLIWorkbook(t, templatePath, "日报", [][]string{ + {"{{name}}", "{{missing}}"}, + }) + writeCLIJSON(t, configPath, map[string]any{ + "data": map[string]any{ + "type": "excel", + "path": dataPath, + "sheet": "Daily", + }, + "template": map[string]any{ + "path": templatePath, + }, + "output": map[string]any{ + "path": filepath.Join(dir, "out", "日报_{{name}}.xlsx"), + }, + }) + + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := run([]string{"run", "-c", configPath}, &stdout, &stderr) + + if exitCode != 1 { + t.Fatalf("exit code = %d, want 1", exitCode) + } + if stdout.Len() != 0 { + t.Fatalf("expected empty stdout, got %q", stdout.String()) + } + got := stderr.String() + for _, want := range []string{"docfill: [TEMPLATE_FIELD_MISSING]", "hint:", "context:", "field=missing", "template="} { + if !strings.Contains(got, want) { + t.Fatalf("stderr missing %q: %q", want, got) + } + } +} + +func TestRunCommandVerbosePrintsStepLogs(t *testing.T) { + dir := t.TempDir() + dataPath := filepath.Join(dir, "data.xlsx") + templatePath := filepath.Join(dir, "template.xlsx") + configPath := filepath.Join(dir, "task.json") + + writeCLIWorkbook(t, dataPath, "Daily", [][]string{ + {"name", "done"}, + {"张三", "完成日报"}, + }) + writeCLIWorkbook(t, templatePath, "日报", [][]string{ + {"{{name}}", "{{done}}"}, + }) + writeCLIJSON(t, configPath, map[string]any{ + "data": map[string]any{ + "type": "excel", + "path": dataPath, + "sheet": "Daily", + }, + "template": map[string]any{ + "path": templatePath, + }, + "output": map[string]any{ + "path": filepath.Join(dir, "out", "日报_{{name}}.xlsx"), + }, + }) + + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := run([]string{"run", "-c", configPath, "--verbose"}, &stdout, &stderr) + + if exitCode != 0 { + t.Fatalf("exit code = %d, stderr=%q", exitCode, stderr.String()) + } + if !strings.Contains(stdout.String(), "generated 1 file(s)") { + t.Fatalf("unexpected stdout: %q", stdout.String()) + } + got := stderr.String() + for _, want := range []string{"log: load config", "log: read data", "log: render row=1"} { + if !strings.Contains(got, want) { + t.Fatalf("stderr missing %q: %q", want, got) + } + } +} + func writeCLIWorkbook(t *testing.T, path, sheet string, rows [][]string) { t.Helper() diff --git a/docs/digital-employee-runbook.md b/docs/digital-employee-runbook.md index 70fa061..b15e1ab 100644 --- a/docs/digital-employee-runbook.md +++ b/docs/digital-employee-runbook.md @@ -8,11 +8,14 @@ Each run needs: - A `task.json` config file. - A data source: `.xlsx` or SQLite `.db`. +- Optional business filters in `filter.equals`. - A template: `.docx` or `.xlsx`. - An output path pattern in `task.json`. Paths inside `task.json` are resolved from the config file directory. +Use one `task.json` per business task. Use `--var` for values that change per run, such as date, ticket number, person, or batch. + ## Command ```bash @@ -22,10 +25,42 @@ docfill run -c /path/to/task.json --var date=2026-06-18 Use repeated `--var key=value` arguments for runtime values: ```bash -docfill run -c task.json --var date=2026-06-18 --var batch=早班 +docfill run -c task.json --var date=2026-06-18 --var ticket_no=GD-002 ``` -CLI variables override `vars` in `task.json`. Row data overrides both. +Priority: + +```text +row data > CLI --var > task.json vars +``` + +CLI variables override `vars` in `task.json`. The selected row data overrides both when rendering templates and output paths. + +If `task.json` has a filter: + +```json +"filter": { + "equals": { + "日期": "{{date}}", + "工单编号": "{{ticket_no}}" + }, + "expect": "one" +} +``` + +then the command: + +```bash +docfill run -c task.json --var date=2026-06-18 --var ticket_no=GD-002 +``` + +selects the row where `日期 = 2026-06-18` and `工单编号 = GD-002`. + +Use `--verbose` only when diagnosing a failed or suspicious run: + +```bash +docfill run -c task.json --var date=2026-06-18 --var ticket_no=GD-002 --verbose +``` ## Success @@ -48,9 +83,91 @@ Exit code `2` means the command line was invalid. Errors are written to stderr with the `docfill:` prefix: ```text -docfill: render row 1: missing template field "done" +docfill: [TEMPLATE_FIELD_MISSING] render row 1: missing template field "done" +hint: Add column "done" to the data source or pass --var done=... +context: field=done template=/path/template.docx ``` +Verbose logs are also written to stderr: + +```text +log: load config path=/path/task.json +log: read data type=excel path=/path/data.xlsx +log: read data type=excel path=/path/data.xlsx rows=3 +log: filter rows=3 matched=1 expect=one +log: render row=1 output=/path/out/work_order_2026-06-18_GD-002.docx +``` + +## Error Codes + +`CLI_USAGE` + +The command line is invalid. + +`CONFIG_READ` + +The config file cannot be read. + +`CONFIG_PARSE` + +The config file is not valid JSON. + +`CONFIG_VALIDATE` + +The config is missing required fields or uses unsupported values. + +`DATA_EXCEL` + +Excel data cannot be opened or read. + +`DATA_SQLITE` + +SQLite data cannot be opened, queried, or parameterized. + +`DATA_EMPTY` + +The data source returned no rows. + +`DATA_FILTER_FIELD` + +The filter references a field that is not in the data. + +`DATA_FILTER_VALUE` + +The filter references a variable that was not provided. + +`DATA_FILTER_NO_MATCH` + +The filter matched no rows. + +`DATA_FILTER_MULTI_MATCH` + +The filter expected one row but matched multiple rows. + +`TEMPLATE_OPEN` + +The template file cannot be opened or inspected. + +`TEMPLATE_FIELD_MISSING` + +The template references a field that is not available. + +`TEMPLATE_UNRESOLVED` + +The rendered file would still contain a `{{field}}` placeholder. + +`OUTPUT_EXISTS` + +The output file exists and overwrite is not enabled. + +`OUTPUT_WRITE` + +The output directory or file cannot be written. + +`INTERNAL` + +An unexpected unclassified error occurred. + ## Common Errors `missing template field` @@ -65,6 +182,18 @@ The output file exists and `output.overwrite` is not `true`. A SQLite query parameter such as `{{date}}` was not provided. +`filter matched no rows` + +Check `filter.equals`, `--var` values, or the source table contents. + +`filter matched N rows but expected one` + +Add another filter condition or clean duplicate rows in the source table. + +`filter field is not in data` + +Check Excel headers or SQLite query column names. + `unresolved placeholder` The rendered document still contains `{{field}}`. Check the source data value and the template. @@ -87,4 +216,4 @@ dist/docfill-darwin-arm64 bash scripts/smoke.sh ``` -Smoke test builds a temporary binary, generates temporary example files, runs both example configs, and checks generated outputs. +Smoke test builds a temporary binary, generates temporary example files, runs example configs including the filter case, and checks generated outputs. diff --git a/docs/evaluations/2026-06-19-full-evaluation.md b/docs/evaluations/2026-06-19-full-evaluation.md new file mode 100644 index 0000000..1de2b43 --- /dev/null +++ b/docs/evaluations/2026-06-19-full-evaluation.md @@ -0,0 +1,186 @@ +# docfill 全方位评测报告 + +评测日期:2026-06-19 + +评测对象:`docfill` 当前工作区版本,包含错误码、日志、业务筛选 `filter.equals`、手工测试素材和用户文档更新。 + +## 结论 + +本轮评测结论:通过当前 MVP 质量门槛。 + +适用范围: + +- 本地 macOS arm64 构建。 +- Excel / SQLite 数据源读取。 +- Word / Excel 模板填充。 +- `filter.equals` 多条件等值筛选。 +- `filter.expect` 的 `one` / `many` 行数约束。 +- CLI 成功输出、错误码、提示和上下文。 +- 用户文档、示例配置和手工测试素材一致性。 + +未宣称覆盖: + +- Windows / Linux 二进制构建。 +- 大体量数据性能测试。 +- Excel 公式重算、透视表刷新。 +- 复杂 Word 模板语法。 +- 业务系统直连、权限、审批或调度系统。 + +## 工作区状态 + +当前工作区包含未提交变更,评测没有执行提交、回滚或清理。 + +基线提交: + +```text +ca444bd feat: add docfill mvp cli +``` + +评测时存在的主要变更类型: + +- CLI 错误码和日志。 +- `filter.equals` / `filter.expect` 筛选能力。 +- 新增 `HOWTO.md`。 +- 新增 `manual_tests/` 真实测试素材。 +- 新增 `examples/excel-filter-to-word/` smoke 示例。 +- 更新 README、runbook 和示例文档。 + +## 自动化评测 + +| 项目 | 命令 | 结果 | +| --- | --- | --- | +| Git diff 格式检查 | `git diff --check` | 通过,无输出 | +| Go 单元测试 | `go test -count=1 ./...` | 通过 | +| Go race 测试 | `go test -race -count=1 ./...` | 通过 | +| Go 静态检查 | `go vet ./...` | 通过,无输出 | +| 本机构建 | `bash scripts/build.sh` | 通过,输出 `dist/docfill-darwin-arm64` | +| 冒烟测试 | `bash scripts/smoke.sh` | 通过,包含无筛选、筛选、SQLite 示例 | + +`bash scripts/smoke.sh` 覆盖: + +- Excel 到 Word:生成 2 个日报 Word 文件。 +- Excel 筛选到 Word:按 `date + ticket_no` 生成 1 个工单 Word 文件。 +- SQLite 到 Excel:生成 2 个部门汇总 Excel 文件。 + +## 真实素材成功路径 + +| 案例 | 命令 | 结果 | +| --- | --- | --- | +| Excel 筛选到 Word | `dist/docfill-darwin-arm64 run -c manual_tests/04_filter_work_order_to_word/task.json --var date=2026-06-18 --var ticket_no=GD-002 --verbose` | 生成 1 个 Word 文件 | +| Excel 到 Word | `dist/docfill-darwin-arm64 run -c manual_tests/01_excel_to_word/task.json --var batch=早班 --verbose` | 生成 2 个 Word 文件 | +| SQLite 到 Excel | `dist/docfill-darwin-arm64 run -c manual_tests/02_sqlite_to_excel/task.json --var date=2026-06-18 --verbose` | 生成 2 个 Excel 文件 | +| Excel 到 Excel | `dist/docfill-darwin-arm64 run -c manual_tests/03_excel_to_excel/task.json --var date=2026-06-18 --verbose` | 生成 2 个 Excel 文件 | + +关键输出: + +```text +manual_tests/04_filter_work_order_to_word/out/work_order_2026-06-18_GD-002.docx +manual_tests/01_excel_to_word/out/daily_2026-06-18_张三_测试_1.docx +manual_tests/01_excel_to_word/out/daily_2026-06-18_李四_2.docx +manual_tests/02_sqlite_to_excel/out/summary_客服部_1.xlsx +manual_tests/02_sqlite_to_excel/out/summary_营销部_2.xlsx +manual_tests/03_excel_to_excel/out/audit_2026-06-18_王五_1.xlsx +manual_tests/03_excel_to_excel/out/audit_2026-06-18_赵六_2.xlsx +``` + +## 真实素材内容核验 + +使用 bundled Python 读取生成的 Word / Excel 文件并断言关键字段。 + +核验结果: + +- 筛选工单 Word 包含 `GD-002`、`B 公司`、`D5000`、`已完成`。 +- 筛选工单 Word 不包含非目标行 `GD-001`、`A 公司`。 +- 日报 Word 包含 `张三/测试`、`早班`、`营销部`。 +- SQLite 汇总 Excel 中 `summary_营销部_2.xlsx` 的 B2:B6 为 `营销部 / 2026-06-18 / 张三 / 128 / 3`。 +- Excel 质检输出中 `audit_2026-06-18_赵六_2.xlsx` 的 B2:B6 为 `2026-06-18 / 赵六 / 系统截图一致性 / 87 / 缺少 1 张截图,需要补充`。 +- SQLite 源表包含 3 行测试数据。 + +## DOCX 渲染核验 + +使用 `documents` skill 的 `render_docx.py` 渲染两个 Word 输出: + +```text +manual_tests/_evaluation_rendered/filter/page-1.png +manual_tests/_evaluation_rendered/daily/page-1.png +``` + +视觉核验结果: + +- 筛选工单页可读,字段位置正常,关键值为 `2026-06-18 / GD-002 / B 公司 / D5000 / 已完成 / 归档并同步日报`。 +- 日报页可读,字段位置正常,关键值为 `张三/测试 / 营销部 / 早班`。 +- 未观察到明显空白页、文本重叠、缺字或表格错位。 + +## CLI 失败路径 + +| 场景 | 命令或配置 | 预期错误码 | 结果 | +| --- | --- | --- | --- | +| 筛选无匹配 | `ticket_no=GD-999` | `DATA_FILTER_NO_MATCH` | 通过 | +| 筛选变量缺失 | 不传 `ticket_no` | `DATA_FILTER_VALUE` | 通过 | +| 筛选匹配多行 | 临时配置只按 `日期` 筛选且 `expect=one` | `DATA_FILTER_MULTI_MATCH` | 通过 | +| 非法 `filter.expect` | 临时配置 `expect=single` | `CONFIG_VALIDATE` | 通过 | +| 配置不存在 | `-c manual_tests/not-exist.json` | `CONFIG_READ` | 通过 | +| CLI 参数错误 | `--var date` | `CLI_USAGE`,退出码 2 | 通过 | + +典型错误输出: + +```text +docfill: [DATA_FILTER_NO_MATCH] filter matched no rows +hint: Check filter.equals, --var values, or source table contents. +context: expect=one filter=工单编号=GD-999,日期=2026-06-18 +``` + +```text +docfill: [DATA_FILTER_MULTI_MATCH] filter matched 2 rows but expected one +hint: Add another filter condition or clean duplicate rows in the source table. +context: filter=日期=2026-06-18 matches=2 +``` + +## 结构与文档一致性 + +| 检查 | 结果 | +| --- | --- | +| 所有 `task.json` 可被 JSON 解析 | 通过 | +| `manual_tests/**/out/*.{docx,xlsx}` 内部 XML 无残留 `{{` / `}}` 占位符 | 通过 | +| README / HOWTO / runbook 均说明 `filter.equals`、`filter.expect`、`--var` 优先级 | 通过 | +| 用户文档未出现不合适措辞 `小白`、`大白话` | 通过 | +| 用户文档无 `TODO` / `TBD` / `FIXME` | 通过 | + +## 能力覆盖评估 + +已验证: + +- Excel 数据源读取。 +- SQLite 数据源读取。 +- Word 模板填充。 +- Excel 模板填充。 +- Word 同段落拆分占位符替换。 +- 输出文件名非法字符清理。 +- `vars` 默认变量。 +- CLI `--var` 覆盖 `vars`。 +- 数据行字段优先于 `--var` / `vars`。 +- 多条件业务筛选。 +- `expect=one` 和 `expect=many`。 +- 筛选错误、配置错误、CLI 错误。 +- stdout 输出生成文件清单。 +- stderr 输出日志、错误码、hint、context。 + +未覆盖或仅部分覆盖: + +- 大文件性能。 +- 多 sheet 大范围模板扫描性能。 +- Windows / Linux 路径与构建。 +- Excel 公式、图表、透视表类模板。 +- Word 跨段落拆分占位符。 +- 真实业务系统导出的复杂脏数据。 + +## 风险与建议 + +P0 无阻塞问题。 + +建议进入下一轮前只做小范围补强: + +1. 增加一条大约 500 到 1000 行 Excel 的筛选性能测试,确认小工具在常见业务表规模下响应稳定。 +2. 增加文档中的“字段命名建议”,例如日期列、工单编号列、辅助列的命名方式。 +3. 保持筛选语法只支持等值 AND,不增加模糊匹配、范围查询或表达式。 +4. 发布前在目标机器上补一次 `bash scripts/build.sh && bash scripts/smoke.sh`。 diff --git a/docs/superpowers/plans/2026-06-18-docfill-p0.7-errors-logs.md b/docs/superpowers/plans/2026-06-18-docfill-p0.7-errors-logs.md new file mode 100644 index 0000000..21780b6 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-docfill-p0.7-errors-logs.md @@ -0,0 +1,52 @@ +# Docfill P0.7 Errors And Logs Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make failures easier for digital employees and humans to classify without turning `docfill` into a logging platform. + +**Architecture:** Add a small `AppError` type in the internal package with code, hint, and context. Keep CLI formatting in `cmd/docfill`; add only one runtime logging switch, `--verbose`, which writes step logs to stderr. + +**Tech Stack:** Go standard errors, existing CLI flag parsing, existing tests. + +--- + +### Task 1: Error Contract + +**Files:** +- Create: `internal/docfill/errors.go` +- Modify: `internal/docfill/p0_test.go` +- Modify: `cmd/docfill/main_test.go` + +- [ ] Add tests for coded missing-field and output-exists failures. +- [ ] Add tests for CLI stderr formatting with `[CODE]`, `hint:`, and `context:`. + +### Task 2: Error Classification + +**Files:** +- Modify: `internal/docfill/docfill.go` +- Create: `internal/docfill/errors.go` + +- [ ] Classify config read, config parse, config validate, data Excel, data SQLite, template, output, and unresolved-placeholder failures where they already occur. +- [ ] Keep existing return signatures. + +### Task 3: Verbose Logs + +**Files:** +- Modify: `internal/docfill/docfill.go` +- Modify: `cmd/docfill/main.go` +- Modify: `cmd/docfill/main_test.go` + +- [ ] Add `RunOptions.Logf`. +- [ ] Add `--verbose` to `docfill run`. +- [ ] Log only config load, data read summary, and per-row output path. + +### Task 4: Docs And Verification + +**Files:** +- Modify: `README.md` +- Modify: `docs/digital-employee-runbook.md` + +- [ ] Document error shape and `--verbose`. +- [ ] Run `go test -count=1 ./...`. +- [ ] Run `bash scripts/smoke.sh`. +- [ ] Run `bash scripts/build.sh`. diff --git a/examples/README.md b/examples/README.md index bb68185..4fa66ac 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,16 +1,32 @@ -# docfill examples +# docfill 示例配置 -These examples are intentionally small and script-friendly. +本目录保存可自动化运行的最小示例配置。示例中的 Office 文件和 SQLite 文件由 `scripts/smoke.sh` 在临时目录中生成,因此仓库只提交配置文件,不提交临时生成的数据文件。 -Run all examples through the smoke script: +## 运行全部示例 ```bash bash scripts/smoke.sh ``` -The smoke script copies this directory to a temp folder, generates the Office and SQLite fixture files, builds `docfill`, and runs the example tasks. +输出示例: -## Examples +```text +generated 2 file(s) +- /tmp/.../examples/excel-to-word/out/daily_2026-06-18_张三_1.docx +- /tmp/.../examples/excel-to-word/out/daily_2026-06-18_李四_2.docx +generated 2 file(s) +- /tmp/.../examples/sqlite-to-excel/out/summary_客服部_1.xlsx +- /tmp/.../examples/sqlite-to-excel/out/summary_营销部_2.xlsx +smoke ok +``` -- `excel-to-word`: Excel daily rows to Word daily reports. -- `sqlite-to-excel`: SQLite summary rows to Excel department reports. +## 示例列表 + +- `excel-to-word`: 从 Excel 日报数据生成 Word 日报。 +- `excel-filter-to-word`: 从 Excel 工单数据中按日期和工单编号筛选一条记录,再生成 Word 工单。 +- `sqlite-to-excel`: 从 SQLite 汇总数据生成 Excel 部门汇总表。 + +## 与 manual_tests 的区别 + +- `examples/` 用于自动化冒烟测试,文件尽量少,数据文件运行时临时生成。 +- `manual_tests/` 用于人工验证,包含可以直接打开的 Word、Excel 和 SQLite 测试素材。 diff --git a/examples/excel-filter-to-word/task.json b/examples/excel-filter-to-word/task.json new file mode 100644 index 0000000..66d1f1a --- /dev/null +++ b/examples/excel-filter-to-word/task.json @@ -0,0 +1,25 @@ +{ + "vars": { + "date": "2026-06-18" + }, + "data": { + "type": "excel", + "path": "./data.xlsx", + "sheet": "Tickets", + "header_row": 1 + }, + "filter": { + "equals": { + "date": "{{date}}", + "ticket_no": "{{ticket_no}}" + }, + "expect": "one" + }, + "template": { + "path": "./template.docx" + }, + "output": { + "path": "./out/ticket_{{date}}_{{ticket_no}}.docx", + "overwrite": true + } +} diff --git a/internal/docfill/docfill.go b/internal/docfill/docfill.go index 0098f16..60a6e24 100644 --- a/internal/docfill/docfill.go +++ b/internal/docfill/docfill.go @@ -4,12 +4,12 @@ import ( "archive/zip" "database/sql" "encoding/json" - "errors" "fmt" "io" "os" "path/filepath" "regexp" + "sort" "strconv" "strings" @@ -21,6 +21,7 @@ import ( type config struct { Vars map[string]string `json:"vars"` Data dataConfig `json:"data"` + Filter filterConfig `json:"filter"` Template templateConfig `json:"template"` Output outputConfig `json:"output"` } @@ -34,6 +35,11 @@ type dataConfig struct { HeaderRow int `json:"header_row"` } +type filterConfig struct { + Equals map[string]string `json:"equals"` + Expect string `json:"expect"` +} + type templateConfig struct { Path string `json:"path"` } @@ -45,6 +51,7 @@ type outputConfig struct { type RunOptions struct { Vars map[string]string + Logf func(format string, args ...any) } type RunResult struct { @@ -68,6 +75,7 @@ func Run(configPath string) error { // RunWithOptions executes one configured fill task and returns generated files. func RunWithOptions(configPath string, opts RunOptions) (RunResult, error) { + logf(opts, "load config path=%s", configPath) cfg, err := loadConfig(configPath) if err != nil { return RunResult{}, err @@ -77,12 +85,24 @@ func RunWithOptions(configPath string, opts RunOptions) (RunResult, error) { } vars := mergeVars(cfg.Vars, opts.Vars) + logf(opts, "read data type=%s path=%s", cfg.Data.Type, cfg.Data.Path) rows, err := readRowsWithVars(cfg.Data, vars) if err != nil { return RunResult{}, err } if len(rows) == 0 { - return RunResult{}, errors.New("data returned no rows") + return RunResult{}, appError(CodeDataEmpty, "data returned no rows"). + withHint("Check the data source, sheet/header row, SQL where clause, or --var values."). + withContext("data", cfg.Data.Path) + } + logf(opts, "read data type=%s path=%s rows=%d", cfg.Data.Type, cfg.Data.Path, len(rows)) + sourceRowCount := len(rows) + rows, err = filterRows(cfg.Filter, rows, vars) + if err != nil { + return RunResult{}, err + } + if hasFilter(cfg.Filter) { + logf(opts, "filter rows=%d matched=%d expect=%s", sourceRowCount, len(rows), filterExpect(cfg.Filter)) } result := RunResult{Files: make([]string, 0, len(rows))} @@ -94,11 +114,16 @@ func RunWithOptions(configPath string, opts RunOptions) (RunResult, error) { outputPath := replacePathPlaceholders(cfg.Output.Path, context) if hasPlaceholder(outputPath) { - return RunResult{}, fmt.Errorf("render row %d: unresolved placeholder in output path %q", i+1, outputPath) + return RunResult{}, fmt.Errorf("render row %d: %w", i+1, appError(CodeTemplateUnresolved, "unresolved placeholder in output path"). + withHint("Check output.path placeholders and available data fields or --var values."). + withContext("output", outputPath)) } if !cfg.Output.Overwrite && fileExists(outputPath) { - return RunResult{}, fmt.Errorf("render row %d: output already exists: %s", i+1, outputPath) + return RunResult{}, fmt.Errorf("render row %d: %w", i+1, appError(CodeOutputExists, "output already exists"). + withHint("Change output.path or set output.overwrite to true."). + withContext("output", outputPath)) } + logf(opts, "render row=%d output=%s", i+1, outputPath) if err := renderTemplate(cfg.Template.Path, outputPath, context); err != nil { return RunResult{}, fmt.Errorf("render row %d: %w", i+1, err) } @@ -109,19 +134,32 @@ func RunWithOptions(configPath string, opts RunOptions) (RunResult, error) { return result, nil } +func logf(opts RunOptions, format string, args ...any) { + if opts.Logf != nil { + opts.Logf(format, args...) + } +} + func loadConfig(path string) (config, error) { if path == "" { - return config{}, errors.New("config path is required") + return config{}, appError(CodeConfigValidate, "config path is required"). + withHint("Pass -c task.json or --config task.json.") } data, err := os.ReadFile(path) if err != nil { - return config{}, fmt.Errorf("read config: %w", err) + return config{}, appError(CodeConfigRead, "read config"). + withHint("Check that the config file exists and is readable."). + withContext("config", path). + withCause(err) } var cfg config if err := json.Unmarshal(data, &cfg); err != nil { - return config{}, fmt.Errorf("parse config json: %w", err) + return config{}, appError(CodeConfigParse, "parse config json"). + withHint("Fix task.json syntax and make sure it is valid JSON."). + withContext("config", path). + withCause(err) } resolveConfigPaths(&cfg, filepath.Dir(absPath(path))) return cfg, nil @@ -129,19 +167,27 @@ func loadConfig(path string) (config, error) { func validateConfig(cfg config) error { if cfg.Data.Type == "" { - return errors.New("data.type is required") + return appError(CodeConfigValidate, "data.type is required"). + withHint("Set data.type to excel or sqlite.") } if cfg.Data.Path == "" { - return errors.New("data.path is required") + return appError(CodeConfigValidate, "data.path is required"). + withHint("Set data.path to an .xlsx or .db file.") } if cfg.Template.Path == "" { - return errors.New("template.path is required") + return appError(CodeConfigValidate, "template.path is required"). + withHint("Set template.path to a .docx or .xlsx file.") } if cfg.Output.Path == "" { - return errors.New("output.path is required") + return appError(CodeConfigValidate, "output.path is required"). + withHint("Set output.path to the generated file pattern.") } if cfg.Data.HeaderRow < 0 { - return errors.New("data.header_row must not be negative") + return appError(CodeConfigValidate, "data.header_row must not be negative"). + withHint("Use a 1-based header_row value, or omit it for 1.") + } + if err := validateFilterConfig(cfg.Filter); err != nil { + return err } return nil } @@ -157,14 +203,19 @@ func readRowsWithVars(cfg dataConfig, vars map[string]string) ([]map[string]stri case "sqlite": return readSQLiteRows(cfg, vars) default: - return nil, fmt.Errorf("unsupported data.type %q", cfg.Type) + return nil, appError(CodeConfigValidate, fmt.Sprintf("unsupported data.type %q", cfg.Type)). + withHint("Set data.type to excel or sqlite."). + withContext("data.type", cfg.Type) } } func readExcelRows(cfg dataConfig) ([]map[string]string, error) { f, err := excelize.OpenFile(cfg.Path) if err != nil { - return nil, fmt.Errorf("open excel data: %w", err) + return nil, appError(CodeDataExcel, "open excel data"). + withHint("Check that data.path points to a readable .xlsx file."). + withContext("data", cfg.Path). + withCause(err) } defer f.Close() @@ -173,15 +224,24 @@ func readExcelRows(cfg dataConfig) ([]map[string]string, error) { sheet = f.GetSheetName(0) } if sheet == "" { - return nil, errors.New("excel data has no sheets") + return nil, appError(CodeDataExcel, "excel data has no sheets"). + withHint("Check the Excel data file."). + withContext("data", cfg.Path) } rawRows, err := f.GetRows(sheet) if err != nil { - return nil, fmt.Errorf("read excel sheet %q: %w", sheet, err) + return nil, appError(CodeDataExcel, fmt.Sprintf("read excel sheet %q", sheet)). + withHint("Check data.sheet and the workbook contents."). + withContext("data", cfg.Path). + withContext("sheet", sheet). + withCause(err) } if len(rawRows) == 0 { - return nil, errors.New("excel data has no header row") + return nil, appError(CodeDataExcel, "excel data has no header row"). + withHint("Add a header row or set data.header_row to the correct row."). + withContext("data", cfg.Path). + withContext("sheet", sheet) } headerRow := cfg.HeaderRow @@ -189,7 +249,10 @@ func readExcelRows(cfg dataConfig) ([]map[string]string, error) { headerRow = 1 } if headerRow > len(rawRows) { - return nil, fmt.Errorf("excel header_row %d is outside sheet %q", headerRow, sheet) + return nil, appError(CodeDataExcel, fmt.Sprintf("excel header_row %d is outside sheet %q", headerRow, sheet)). + withHint("Use a header_row value within the sheet row count."). + withContext("data", cfg.Path). + withContext("sheet", sheet) } headers := make([]string, len(rawRows[headerRow-1])) @@ -221,12 +284,16 @@ func readExcelRows(cfg dataConfig) ([]map[string]string, error) { func readSQLiteRows(cfg dataConfig, vars map[string]string) ([]map[string]string, error) { if cfg.Query == "" { - return nil, errors.New("data.query is required for sqlite") + return nil, appError(CodeConfigValidate, "data.query is required for sqlite"). + withHint("Set data.query to the SQL select statement.") } db, err := sql.Open("sqlite", cfg.Path) if err != nil { - return nil, fmt.Errorf("open sqlite data: %w", err) + return nil, appError(CodeDataSQLite, "open sqlite data"). + withHint("Check that data.path points to a readable SQLite database."). + withContext("data", cfg.Path). + withCause(err) } defer db.Close() @@ -237,13 +304,18 @@ func readSQLiteRows(cfg dataConfig, vars map[string]string) ([]map[string]string result, err := db.Query(cfg.Query, args...) if err != nil { - return nil, fmt.Errorf("query sqlite data: %w", err) + return nil, appError(CodeDataSQLite, "query sqlite data"). + withHint("Check data.query, data.params, and --var values."). + withContext("data", cfg.Path). + withCause(err) } defer result.Close() columns, err := result.Columns() if err != nil { - return nil, fmt.Errorf("read sqlite columns: %w", err) + return nil, appError(CodeDataSQLite, "read sqlite columns"). + withContext("data", cfg.Path). + withCause(err) } var rows []map[string]string @@ -254,7 +326,9 @@ func readSQLiteRows(cfg dataConfig, vars map[string]string) ([]map[string]string scanTargets[i] = &values[i] } if err := result.Scan(scanTargets...); err != nil { - return nil, fmt.Errorf("scan sqlite row: %w", err) + return nil, appError(CodeDataSQLite, "scan sqlite row"). + withContext("data", cfg.Path). + withCause(err) } row := make(map[string]string) @@ -264,18 +338,154 @@ func readSQLiteRows(cfg dataConfig, vars map[string]string) ([]map[string]string rows = append(rows, row) } if err := result.Err(); err != nil { - return nil, fmt.Errorf("iterate sqlite rows: %w", err) + return nil, appError(CodeDataSQLite, "iterate sqlite rows"). + withContext("data", cfg.Path). + withCause(err) } return rows, nil } +func validateFilterConfig(cfg filterConfig) error { + expect := strings.ToLower(strings.TrimSpace(cfg.Expect)) + if expect != "" && expect != "one" && expect != "many" { + return appError(CodeConfigValidate, fmt.Sprintf("unsupported filter.expect %q", cfg.Expect)). + withHint("Set filter.expect to one or many."). + withContext("filter.expect", cfg.Expect) + } + if expect != "" && len(cfg.Equals) == 0 { + return appError(CodeConfigValidate, "filter.equals is required when filter.expect is set"). + withHint("Add at least one filter.equals condition or remove filter.expect.") + } + for field := range cfg.Equals { + if strings.TrimSpace(field) == "" { + return appError(CodeConfigValidate, "filter.equals field name must not be empty"). + withHint("Use a data column name as each filter.equals key.") + } + } + return nil +} + +func filterRows(cfg filterConfig, rows []map[string]string, vars map[string]string) ([]map[string]string, error) { + if !hasFilter(cfg) { + return rows, nil + } + + conditions, err := filterConditions(cfg, vars) + if err != nil { + return nil, err + } + if len(rows) > 0 { + if err := validateFilterFields(rows[0], conditions); err != nil { + return nil, err + } + } + + matches := make([]map[string]string, 0, len(rows)) + for _, row := range rows { + if rowMatchesFilter(row, conditions) { + matches = append(matches, row) + } + } + + expect := filterExpect(cfg) + if len(matches) == 0 { + return nil, appError(CodeDataFilterNoMatch, "filter matched no rows"). + withHint("Check filter.equals, --var values, or source table contents."). + withContext("filter", filterSummary(conditions)). + withContext("expect", expect) + } + if expect == "one" && len(matches) != 1 { + return nil, appError(CodeDataFilterMultiMatch, fmt.Sprintf("filter matched %d rows but expected one", len(matches))). + withHint("Add another filter condition or clean duplicate rows in the source table."). + withContext("filter", filterSummary(conditions)). + withContext("matches", strconv.Itoa(len(matches))) + } + + return matches, nil +} + +func hasFilter(cfg filterConfig) bool { + return len(cfg.Equals) > 0 +} + +func filterExpect(cfg filterConfig) string { + expect := strings.ToLower(strings.TrimSpace(cfg.Expect)) + if expect == "" { + return "one" + } + return expect +} + +func filterConditions(cfg filterConfig, vars map[string]string) (map[string]string, error) { + conditions := make(map[string]string, len(cfg.Equals)) + for field, pattern := range cfg.Equals { + field = strings.TrimSpace(field) + context := map[string]string{"filter": field} + if err := ensurePlaceholdersAvailableWithCode( + fmt.Sprintf("filter.equals[%s]", field), + pattern, + vars, + CodeDataFilterValue, + "Provide the missing filter value with vars or --var.", + context, + ); err != nil { + return nil, err + } + value := replacePlaceholders(pattern, vars) + if hasPlaceholder(value) { + return nil, appError(CodeDataFilterValue, fmt.Sprintf("unresolved placeholder in filter.equals[%s]", field)). + withHint("Provide the missing filter value with vars or --var."). + withContext("filter", field) + } + conditions[field] = strings.TrimSpace(value) + } + return conditions, nil +} + +func validateFilterFields(row map[string]string, conditions map[string]string) error { + for field := range conditions { + if _, ok := row[field]; !ok { + return appError(CodeDataFilterField, fmt.Sprintf("filter field %q is not in data", field)). + withHint("Check the data header/query columns or adjust filter.equals."). + withContext("field", field) + } + } + return nil +} + +func rowMatchesFilter(row map[string]string, conditions map[string]string) bool { + for field, want := range conditions { + if strings.TrimSpace(row[field]) != want { + return false + } + } + return true +} + +func filterSummary(conditions map[string]string) string { + keys := make([]string, 0, len(conditions)) + for key := range conditions { + keys = append(keys, key) + } + sort.Strings(keys) + + parts := make([]string, 0, len(keys)) + for _, key := range keys { + parts = append(parts, key+"="+conditions[key]) + } + return strings.Join(parts, ",") +} + func renderTemplate(templatePath, outputPath string, row map[string]string) error { if err := validateTemplateFields(templatePath, row); err != nil { return err } if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { - return fmt.Errorf("create output directory: %w", err) + return appError(CodeOutputWrite, "create output directory"). + withHint("Check output.path directory permissions."). + withContext("output", outputPath). + withCause(err) } switch strings.ToLower(filepath.Ext(templatePath)) { @@ -284,21 +494,29 @@ func renderTemplate(templatePath, outputPath string, row map[string]string) erro case ".docx": return renderDOCX(templatePath, outputPath, row) default: - return fmt.Errorf("unsupported template extension %q", filepath.Ext(templatePath)) + return appError(CodeConfigValidate, fmt.Sprintf("unsupported template extension %q", filepath.Ext(templatePath))). + withHint("Use a .docx or .xlsx template."). + withContext("template", templatePath) } } func renderXLSX(templatePath, outputPath string, row map[string]string) error { f, err := excelize.OpenFile(templatePath) if err != nil { - return fmt.Errorf("open xlsx template: %w", err) + return appError(CodeTemplateOpen, "open xlsx template"). + withHint("Check that template.path points to a readable .xlsx file."). + withContext("template", templatePath). + withCause(err) } defer f.Close() for _, sheet := range f.GetSheetList() { rows, err := f.GetRows(sheet) if err != nil { - return fmt.Errorf("read template sheet %q: %w", sheet, err) + return appError(CodeTemplateOpen, fmt.Sprintf("read template sheet %q", sheet)). + withContext("template", templatePath). + withContext("sheet", sheet). + withCause(err) } for r, values := range rows { for c, value := range values { @@ -311,17 +529,25 @@ func renderXLSX(templatePath, outputPath string, row map[string]string) error { } rendered := replacePlaceholders(value, row) if hasPlaceholder(rendered) { - return fmt.Errorf("unresolved placeholder in %s!%s", sheet, cell) + return appError(CodeTemplateUnresolved, fmt.Sprintf("unresolved placeholder in %s!%s", sheet, cell)). + withHint("Check source data values and template placeholders."). + withContext("template", templatePath). + withContext("cell", sheet+"!"+cell) } if err := f.SetCellValue(sheet, cell, rendered); err != nil { - return fmt.Errorf("set cell %s!%s: %w", sheet, cell, err) + return appError(CodeOutputWrite, fmt.Sprintf("set cell %s!%s", sheet, cell)). + withContext("output", outputPath). + withCause(err) } } } } if err := f.SaveAs(outputPath); err != nil { - return fmt.Errorf("save xlsx output: %w", err) + return appError(CodeOutputWrite, "save xlsx output"). + withHint("Check output.path and file permissions."). + withContext("output", outputPath). + withCause(err) } return nil } @@ -329,13 +555,19 @@ func renderXLSX(templatePath, outputPath string, row map[string]string) error { func renderDOCX(templatePath, outputPath string, row map[string]string) error { reader, err := zip.OpenReader(templatePath) if err != nil { - return fmt.Errorf("open docx template: %w", err) + return appError(CodeTemplateOpen, "open docx template"). + withHint("Check that template.path points to a readable .docx file."). + withContext("template", templatePath). + withCause(err) } defer reader.Close() output, err := os.Create(outputPath) if err != nil { - return fmt.Errorf("create docx output: %w", err) + return appError(CodeOutputWrite, "create docx output"). + withHint("Check output.path and file permissions."). + withContext("output", outputPath). + withCause(err) } writer := zip.NewWriter(output) @@ -348,10 +580,14 @@ func renderDOCX(templatePath, outputPath string, row map[string]string) error { } if err := writer.Close(); err != nil { _ = output.Close() - return fmt.Errorf("close docx output: %w", err) + return appError(CodeOutputWrite, "close docx output"). + withContext("output", outputPath). + withCause(err) } if err := output.Close(); err != nil { - return fmt.Errorf("close docx file: %w", err) + return appError(CodeOutputWrite, "close docx file"). + withContext("output", outputPath). + withCause(err) } return nil @@ -364,7 +600,8 @@ func copyDocxPart(writer *zip.Writer, source *zip.File, row map[string]string) e dest, err := writer.CreateHeader(&header) if err != nil { - return fmt.Errorf("create docx part %s: %w", source.Name, err) + return appError(CodeOutputWrite, fmt.Sprintf("create docx part %s", source.Name)). + withCause(err) } if source.FileInfo().IsDir() { @@ -373,13 +610,15 @@ func copyDocxPart(writer *zip.Writer, source *zip.File, row map[string]string) e src, err := source.Open() if err != nil { - return fmt.Errorf("open docx part %s: %w", source.Name, err) + return appError(CodeTemplateOpen, fmt.Sprintf("open docx part %s", source.Name)). + withCause(err) } defer src.Close() data, err := io.ReadAll(src) if err != nil { - return fmt.Errorf("read docx part %s: %w", source.Name, err) + return appError(CodeTemplateOpen, fmt.Sprintf("read docx part %s", source.Name)). + withCause(err) } if isDocxXMLPart(source.Name) { rendered, err := renderDocxXML(string(data), row) @@ -387,13 +626,15 @@ func copyDocxPart(writer *zip.Writer, source *zip.File, row map[string]string) e return fmt.Errorf("render docx part %s: %w", source.Name, err) } if docxHasPlaceholder(rendered) { - return fmt.Errorf("unresolved placeholder in docx part %s", source.Name) + return appError(CodeTemplateUnresolved, fmt.Sprintf("unresolved placeholder in docx part %s", source.Name)). + withHint("Check source data values and template placeholders.") } data = []byte(rendered) } if _, err := dest.Write(data); err != nil { - return fmt.Errorf("write docx part %s: %w", source.Name, err) + return appError(CodeOutputWrite, fmt.Sprintf("write docx part %s", source.Name)). + withCause(err) } return nil } @@ -471,12 +712,20 @@ func renderContext(vars, row map[string]string, rowNumber int) map[string]string func sqliteParams(params []string, vars map[string]string) ([]any, error) { args := make([]any, 0, len(params)) for i, param := range params { - if err := ensurePlaceholdersAvailable(fmt.Sprintf("sqlite params[%d]", i), param, vars); err != nil { + if err := ensurePlaceholdersAvailableWithCode( + fmt.Sprintf("sqlite params[%d]", i), + param, + vars, + CodeDataSQLite, + "Provide the missing SQLite parameter with vars or --var.", + nil, + ); err != nil { return nil, err } rendered := replacePlaceholders(param, vars) if hasPlaceholder(rendered) { - return nil, fmt.Errorf("unresolved placeholder in sqlite params[%d]", i) + return nil, appError(CodeDataSQLite, fmt.Sprintf("unresolved placeholder in sqlite params[%d]", i)). + withHint("Provide the missing SQLite parameter with vars or --var.") } args = append(args, rendered) } @@ -490,7 +739,10 @@ func validateTemplateFields(templatePath string, row map[string]string) error { } for field := range fields { if _, ok := row[field]; !ok { - return fmt.Errorf("missing template field %q", field) + return appError(CodeTemplateFieldMissing, fmt.Sprintf("missing template field %q", field)). + withHint(fmt.Sprintf("Add column %q to the data source or pass --var %s=...", field, field)). + withContext("field", field). + withContext("template", templatePath) } } return nil @@ -510,7 +762,10 @@ func templatePlaceholders(templatePath string) (map[string]struct{}, error) { func xlsxPlaceholders(templatePath string) (map[string]struct{}, error) { f, err := excelize.OpenFile(templatePath) if err != nil { - return nil, fmt.Errorf("open xlsx template: %w", err) + return nil, appError(CodeTemplateOpen, "open xlsx template"). + withHint("Check that template.path points to a readable .xlsx file."). + withContext("template", templatePath). + withCause(err) } defer f.Close() @@ -518,7 +773,10 @@ func xlsxPlaceholders(templatePath string) (map[string]struct{}, error) { for _, sheet := range f.GetSheetList() { rows, err := f.GetRows(sheet) if err != nil { - return nil, fmt.Errorf("read template sheet %q: %w", sheet, err) + return nil, appError(CodeTemplateOpen, fmt.Sprintf("read template sheet %q", sheet)). + withContext("template", templatePath). + withContext("sheet", sheet). + withCause(err) } for _, row := range rows { for _, value := range row { @@ -532,7 +790,10 @@ func xlsxPlaceholders(templatePath string) (map[string]struct{}, error) { func docxPlaceholders(templatePath string) (map[string]struct{}, error) { reader, err := zip.OpenReader(templatePath) if err != nil { - return nil, fmt.Errorf("open docx template: %w", err) + return nil, appError(CodeTemplateOpen, "open docx template"). + withHint("Check that template.path points to a readable .docx file."). + withContext("template", templatePath). + withCause(err) } defer reader.Close() @@ -543,15 +804,21 @@ func docxPlaceholders(templatePath string) (map[string]struct{}, error) { } rc, err := file.Open() if err != nil { - return nil, fmt.Errorf("open docx part %s: %w", file.Name, err) + return nil, appError(CodeTemplateOpen, fmt.Sprintf("open docx part %s", file.Name)). + withContext("template", templatePath). + withCause(err) } data, readErr := io.ReadAll(rc) closeErr := rc.Close() if readErr != nil { - return nil, fmt.Errorf("read docx part %s: %w", file.Name, readErr) + return nil, appError(CodeTemplateOpen, fmt.Sprintf("read docx part %s", file.Name)). + withContext("template", templatePath). + withCause(readErr) } if closeErr != nil { - return nil, fmt.Errorf("close docx part %s: %w", file.Name, closeErr) + return nil, appError(CodeTemplateOpen, fmt.Sprintf("close docx part %s", file.Name)). + withContext("template", templatePath). + withCause(closeErr) } xml := string(data) addPlaceholders(fields, xml) @@ -561,11 +828,28 @@ func docxPlaceholders(templatePath string) (map[string]struct{}, error) { } func ensurePlaceholdersAvailable(label, text string, row map[string]string) error { + return ensurePlaceholdersAvailableWithCode( + label, + text, + row, + CodeTemplateFieldMissing, + "Add the missing field to the data source or pass it with --var.", + nil, + ) +} + +func ensurePlaceholdersAvailableWithCode(label, text string, row map[string]string, code ErrorCode, hint string, context map[string]string) error { fields := make(map[string]struct{}) addPlaceholders(fields, text) for field := range fields { if _, ok := row[field]; !ok { - return fmt.Errorf("missing %s field %q", label, field) + err := appError(code, fmt.Sprintf("missing %s field %q", label, field)). + withHint(hint). + withContext("field", field) + for key, value := range context { + err.withContext(key, value) + } + return err } } return nil @@ -637,7 +921,8 @@ func renderDocxTextGroup(xml string, row map[string]string) (string, error) { } rendered := replaceDocxPlaceholders(joined, row) if hasPlaceholder(rendered) { - return "", errors.New("unresolved placeholder in docx text") + return "", appError(CodeTemplateUnresolved, "unresolved placeholder in docx text"). + withHint("Check source data values and template placeholders.") } var out strings.Builder diff --git a/internal/docfill/docfill_test.go b/internal/docfill/docfill_test.go index 5ae97b5..f09fb25 100644 --- a/internal/docfill/docfill_test.go +++ b/internal/docfill/docfill_test.go @@ -153,6 +153,30 @@ func TestRenderDOCXReplacesPlaceholderSplitAcrossTextNodes(t *testing.T) { } } +func TestRenderDOCXReplacesMultipleSplitPlaceholdersInSameParagraph(t *testing.T) { + dir := t.TempDir() + templatePath := filepath.Join(dir, "template.docx") + outputPath := filepath.Join(dir, "out.docx") + writeMinimalDocx(t, templatePath, `员工:{{name}},完成:{{done}},备注:{{note}}`) + + err := renderTemplate(templatePath, outputPath, map[string]string{ + "name": "Alice", + "done": "接口数据整理", + "note": "保持同段落", + }) + if err != nil { + t.Fatalf("renderTemplate returned error: %v", err) + } + + got := readZipEntry(t, outputPath, "word/document.xml") + if !strings.Contains(got, "员工:Alice,完成:接口数据整理,备注:保持同段落") { + t.Fatalf("document.xml = %q, want rendered split placeholders", got) + } + if strings.Contains(got, "{{") || strings.Contains(got, "}}") { + t.Fatalf("document.xml still has placeholder markers: %q", got) + } +} + func TestRunGeneratesOneOutputPerInputRow(t *testing.T) { dir := t.TempDir() dataPath := filepath.Join(dir, "data.xlsx") diff --git a/internal/docfill/errors.go b/internal/docfill/errors.go new file mode 100644 index 0000000..4eebb01 --- /dev/null +++ b/internal/docfill/errors.go @@ -0,0 +1,74 @@ +package docfill + +type ErrorCode string + +const ( + CodeCLIUsage ErrorCode = "CLI_USAGE" + CodeConfigRead ErrorCode = "CONFIG_READ" + CodeConfigParse ErrorCode = "CONFIG_PARSE" + CodeConfigValidate ErrorCode = "CONFIG_VALIDATE" + CodeDataExcel ErrorCode = "DATA_EXCEL" + CodeDataSQLite ErrorCode = "DATA_SQLITE" + CodeDataEmpty ErrorCode = "DATA_EMPTY" + CodeDataFilterField ErrorCode = "DATA_FILTER_FIELD" + CodeDataFilterValue ErrorCode = "DATA_FILTER_VALUE" + CodeDataFilterNoMatch ErrorCode = "DATA_FILTER_NO_MATCH" + CodeDataFilterMultiMatch ErrorCode = "DATA_FILTER_MULTI_MATCH" + CodeTemplateOpen ErrorCode = "TEMPLATE_OPEN" + CodeTemplateFieldMissing ErrorCode = "TEMPLATE_FIELD_MISSING" + CodeTemplateUnresolved ErrorCode = "TEMPLATE_UNRESOLVED" + CodeOutputExists ErrorCode = "OUTPUT_EXISTS" + CodeOutputWrite ErrorCode = "OUTPUT_WRITE" + CodeInternal ErrorCode = "INTERNAL" +) + +type AppError struct { + Code ErrorCode + Message string + Hint string + Context map[string]string + Cause error +} + +func (e *AppError) Error() string { + if e == nil { + return "" + } + if e.Cause == nil { + return e.Message + } + return e.Message + ": " + e.Cause.Error() +} + +func (e *AppError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +func appError(code ErrorCode, message string) *AppError { + return &AppError{ + Code: code, + Message: message, + Context: make(map[string]string), + } +} + +func (e *AppError) withHint(hint string) *AppError { + e.Hint = hint + return e +} + +func (e *AppError) withCause(cause error) *AppError { + e.Cause = cause + return e +} + +func (e *AppError) withContext(key, value string) *AppError { + if e.Context == nil { + e.Context = make(map[string]string) + } + e.Context[key] = value + return e +} diff --git a/internal/docfill/p0_test.go b/internal/docfill/p0_test.go index b4d5bb5..1053df5 100644 --- a/internal/docfill/p0_test.go +++ b/internal/docfill/p0_test.go @@ -2,6 +2,7 @@ package docfill import ( "database/sql" + "errors" "os" "path/filepath" "strings" @@ -176,6 +177,16 @@ func TestRunRefusesToOverwriteUnlessConfigured(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "already exists") { t.Fatalf("expected already exists error, got %v", err) } + var appErr *AppError + if !errors.As(err, &appErr) { + t.Fatalf("expected AppError, got %T", err) + } + if appErr.Code != CodeOutputExists { + t.Fatalf("error code = %s, want %s", appErr.Code, CodeOutputExists) + } + if appErr.Hint == "" || appErr.Context["output"] != outputPath { + t.Fatalf("expected hint and output context, got %#v", appErr) + } writeJSON(t, configPath, map[string]any{ "data": map[string]any{ @@ -226,6 +237,19 @@ func TestRunFailsWhenTemplatePlaceholderIsMissing(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "missing template field") { t.Fatalf("expected missing template field error, got %v", err) } + var appErr *AppError + if !errors.As(err, &appErr) { + t.Fatalf("expected AppError, got %T", err) + } + if appErr.Code != CodeTemplateFieldMissing { + t.Fatalf("error code = %s, want %s", appErr.Code, CodeTemplateFieldMissing) + } + if appErr.Context["field"] != "missing" || appErr.Context["template"] != templatePath { + t.Fatalf("unexpected context: %#v", appErr.Context) + } + if !strings.Contains(appErr.Hint, "missing") { + t.Fatalf("unexpected hint: %q", appErr.Hint) + } } func TestRunFailsWhenRenderedTemplateStillHasPlaceholder(t *testing.T) { @@ -259,6 +283,282 @@ func TestRunFailsWhenRenderedTemplateStillHasPlaceholder(t *testing.T) { } } +func TestRunFiltersExcelRowsByMultipleEqualsAndVars(t *testing.T) { + dir := t.TempDir() + dataPath := filepath.Join(dir, "tickets.xlsx") + templatePath := filepath.Join(dir, "ticket-template.xlsx") + configPath := filepath.Join(dir, "task.json") + + writeWorkbook(t, dataPath, "工单", [][]string{ + {"日期", "工单编号", "客户名称", "处理结果"}, + {"2026-06-18", "GD-001", "A 公司", "已派单"}, + {"2026-06-18", "GD-002", "B 公司", "已完成"}, + {"2026-06-19", "GD-001", "C 公司", "待复核"}, + }) + writeWorkbook(t, templatePath, "工单", [][]string{ + {"编号", "{{工单编号}}"}, + {"客户", "{{客户名称}}"}, + {"结果", "{{处理结果}}"}, + }) + writeJSON(t, configPath, map[string]any{ + "vars": map[string]any{ + "date": "2026-06-17", + }, + "data": map[string]any{ + "type": "excel", + "path": dataPath, + "sheet": "工单", + }, + "filter": map[string]any{ + "equals": map[string]any{ + "日期": "{{date}}", + "工单编号": "{{ticket_no}}", + }, + }, + "template": map[string]any{ + "path": templatePath, + }, + "output": map[string]any{ + "path": filepath.Join(dir, "out", "工单_{{日期}}_{{工单编号}}.xlsx"), + }, + }) + + result, err := RunWithOptions(configPath, RunOptions{ + Vars: map[string]string{ + "date": "2026-06-18", + "ticket_no": "GD-002", + }, + }) + if err != nil { + t.Fatalf("RunWithOptions returned error: %v", err) + } + + wantPath := filepath.Join(dir, "out", "工单_2026-06-18_GD-002.xlsx") + if len(result.Files) != 1 || result.Files[0] != wantPath { + t.Fatalf("result files = %#v, want only %q", result.Files, wantPath) + } + assertWorkbookCell(t, wantPath, "工单", "B2", "B 公司") + assertWorkbookCell(t, wantPath, "工单", "B3", "已完成") +} + +func TestRunFilterAllowsManyRowsWhenConfigured(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "tickets.db") + templatePath := filepath.Join(dir, "ticket-template.xlsx") + configPath := filepath.Join(dir, "task.json") + + seedP0SQLite(t, dbPath, ` + create table tickets ( + report_date text, + department text, + ticket_no text, + result text + ); + insert into tickets values + ('2026-06-18', '营销部', 'GD-001', '已派单'), + ('2026-06-18', '客服部', 'GD-002', '已完成'), + ('2026-06-19', '营销部', 'GD-003', '待复核'); + `) + writeWorkbook(t, templatePath, "工单", [][]string{ + {"编号", "{{ticket_no}}"}, + {"部门", "{{department}}"}, + {"结果", "{{result}}"}, + }) + writeJSON(t, configPath, map[string]any{ + "data": map[string]any{ + "type": "sqlite", + "path": dbPath, + "query": "select report_date, department, ticket_no, result from tickets order by ticket_no", + }, + "filter": map[string]any{ + "equals": map[string]any{ + "report_date": "{{date}}", + }, + "expect": "many", + }, + "template": map[string]any{ + "path": templatePath, + }, + "output": map[string]any{ + "path": filepath.Join(dir, "out", "工单_{{ticket_no}}_{{_row}}.xlsx"), + }, + }) + + result, err := RunWithOptions(configPath, RunOptions{Vars: map[string]string{"date": "2026-06-18"}}) + if err != nil { + t.Fatalf("RunWithOptions returned error: %v", err) + } + + if len(result.Files) != 2 { + t.Fatalf("expected 2 filtered files, got %#v", result.Files) + } + assertWorkbookCell(t, filepath.Join(dir, "out", "工单_GD-001_1.xlsx"), "工单", "B3", "已派单") + assertWorkbookCell(t, filepath.Join(dir, "out", "工单_GD-002_2.xlsx"), "工单", "B2", "客服部") +} + +func TestRunFilterFailsWhenNoRowsMatch(t *testing.T) { + dir := t.TempDir() + dataPath := filepath.Join(dir, "tickets.xlsx") + templatePath := filepath.Join(dir, "template.xlsx") + configPath := filepath.Join(dir, "task.json") + + writeWorkbook(t, dataPath, "工单", [][]string{ + {"日期", "工单编号", "处理结果"}, + {"2026-06-18", "GD-001", "已派单"}, + }) + writeWorkbook(t, templatePath, "工单", [][]string{{"{{工单编号}}", "{{处理结果}}"}}) + writeJSON(t, configPath, map[string]any{ + "data": map[string]any{ + "type": "excel", + "path": dataPath, + "sheet": "工单", + }, + "filter": map[string]any{ + "equals": map[string]any{ + "日期": "{{date}}", + "工单编号": "{{ticket_no}}", + }, + "expect": "one", + }, + "template": map[string]any{ + "path": templatePath, + }, + "output": map[string]any{ + "path": filepath.Join(dir, "out", "工单_{{工单编号}}.xlsx"), + }, + }) + + err := RunWithOptionsError(configPath, RunOptions{Vars: map[string]string{ + "date": "2026-06-18", + "ticket_no": "GD-999", + }}) + assertAppErrorCode(t, err, ErrorCode("DATA_FILTER_NO_MATCH")) +} + +func TestRunFilterFailsWhenOneRowExpectedButManyMatch(t *testing.T) { + dir := t.TempDir() + dataPath := filepath.Join(dir, "tickets.xlsx") + templatePath := filepath.Join(dir, "template.xlsx") + configPath := filepath.Join(dir, "task.json") + + writeWorkbook(t, dataPath, "工单", [][]string{ + {"日期", "工单编号", "处理结果"}, + {"2026-06-18", "GD-001", "已派单"}, + {"2026-06-18", "GD-002", "已完成"}, + }) + writeWorkbook(t, templatePath, "工单", [][]string{{"{{工单编号}}", "{{处理结果}}"}}) + writeJSON(t, configPath, map[string]any{ + "data": map[string]any{ + "type": "excel", + "path": dataPath, + "sheet": "工单", + }, + "filter": map[string]any{ + "equals": map[string]any{ + "日期": "{{date}}", + }, + "expect": "one", + }, + "template": map[string]any{ + "path": templatePath, + }, + "output": map[string]any{ + "path": filepath.Join(dir, "out", "工单_{{工单编号}}.xlsx"), + }, + }) + + err := RunWithOptionsError(configPath, RunOptions{Vars: map[string]string{"date": "2026-06-18"}}) + assertAppErrorCode(t, err, ErrorCode("DATA_FILTER_MULTI_MATCH")) +} + +func TestRunFilterFailsWhenFieldIsMissing(t *testing.T) { + dir := t.TempDir() + dataPath := filepath.Join(dir, "tickets.xlsx") + templatePath := filepath.Join(dir, "template.xlsx") + configPath := filepath.Join(dir, "task.json") + + writeWorkbook(t, dataPath, "工单", [][]string{ + {"日期", "处理结果"}, + {"2026-06-18", "已派单"}, + }) + writeWorkbook(t, templatePath, "工单", [][]string{{"{{处理结果}}"}}) + writeJSON(t, configPath, map[string]any{ + "data": map[string]any{ + "type": "excel", + "path": dataPath, + "sheet": "工单", + }, + "filter": map[string]any{ + "equals": map[string]any{ + "工单编号": "{{ticket_no}}", + }, + }, + "template": map[string]any{ + "path": templatePath, + }, + "output": map[string]any{ + "path": filepath.Join(dir, "out", "工单.xlsx"), + }, + }) + + err := RunWithOptionsError(configPath, RunOptions{Vars: map[string]string{"ticket_no": "GD-001"}}) + assertAppErrorCode(t, err, ErrorCode("DATA_FILTER_FIELD")) +} + +func TestRunFilterFailsWhenVariableIsMissing(t *testing.T) { + dir := t.TempDir() + dataPath := filepath.Join(dir, "tickets.xlsx") + templatePath := filepath.Join(dir, "template.xlsx") + configPath := filepath.Join(dir, "task.json") + + writeWorkbook(t, dataPath, "工单", [][]string{ + {"日期", "工单编号", "处理结果"}, + {"2026-06-18", "GD-001", "已派单"}, + }) + writeWorkbook(t, templatePath, "工单", [][]string{{"{{工单编号}}", "{{处理结果}}"}}) + writeJSON(t, configPath, map[string]any{ + "data": map[string]any{ + "type": "excel", + "path": dataPath, + "sheet": "工单", + }, + "filter": map[string]any{ + "equals": map[string]any{ + "工单编号": "{{ticket_no}}", + }, + }, + "template": map[string]any{ + "path": templatePath, + }, + "output": map[string]any{ + "path": filepath.Join(dir, "out", "工单_{{工单编号}}.xlsx"), + }, + }) + + err := RunWithOptionsError(configPath, RunOptions{}) + assertAppErrorCode(t, err, ErrorCode("DATA_FILTER_VALUE")) +} + +func RunWithOptionsError(configPath string, opts RunOptions) error { + _, err := RunWithOptions(configPath, opts) + return err +} + +func assertAppErrorCode(t *testing.T, err error, want ErrorCode) { + t.Helper() + + if err == nil { + t.Fatalf("expected %s error, got nil", want) + } + var appErr *AppError + if !errors.As(err, &appErr) { + t.Fatalf("expected AppError, got %T: %v", err, err) + } + if appErr.Code != want { + t.Fatalf("error code = %s, want %s; error=%v", appErr.Code, want, err) + } +} + func seedP0SQLite(t *testing.T, path, statements string) { t.Helper() diff --git a/manual_tests/01_excel_to_word/daily_report_template.docx b/manual_tests/01_excel_to_word/daily_report_template.docx new file mode 100644 index 0000000..3f6f1f6 Binary files /dev/null and b/manual_tests/01_excel_to_word/daily_report_template.docx differ diff --git a/manual_tests/01_excel_to_word/out/daily_2026-06-18_张三_测试_1.docx b/manual_tests/01_excel_to_word/out/daily_2026-06-18_张三_测试_1.docx new file mode 100644 index 0000000..a2c17a2 Binary files /dev/null and b/manual_tests/01_excel_to_word/out/daily_2026-06-18_张三_测试_1.docx differ diff --git a/manual_tests/01_excel_to_word/out/daily_2026-06-18_李四_2.docx b/manual_tests/01_excel_to_word/out/daily_2026-06-18_李四_2.docx new file mode 100644 index 0000000..fd5f06c Binary files /dev/null and b/manual_tests/01_excel_to_word/out/daily_2026-06-18_李四_2.docx differ diff --git a/manual_tests/01_excel_to_word/source_daily_data.xlsx b/manual_tests/01_excel_to_word/source_daily_data.xlsx new file mode 100644 index 0000000..2d9e57c Binary files /dev/null and b/manual_tests/01_excel_to_word/source_daily_data.xlsx differ diff --git a/manual_tests/01_excel_to_word/task.json b/manual_tests/01_excel_to_word/task.json new file mode 100644 index 0000000..b955da5 --- /dev/null +++ b/manual_tests/01_excel_to_word/task.json @@ -0,0 +1,19 @@ +{ + "vars": { + "date": "2026-06-18", + "batch": "默认班次" + }, + "data": { + "type": "excel", + "path": "./source_daily_data.xlsx", + "sheet": "Daily", + "header_row": 1 + }, + "template": { + "path": "./daily_report_template.docx" + }, + "output": { + "path": "./out/daily_{{date}}_{{name}}_{{_row}}.docx", + "overwrite": true + } +} \ No newline at end of file diff --git a/manual_tests/02_sqlite_to_excel/department_summary_template.xlsx b/manual_tests/02_sqlite_to_excel/department_summary_template.xlsx new file mode 100644 index 0000000..c2b029f Binary files /dev/null and b/manual_tests/02_sqlite_to_excel/department_summary_template.xlsx differ diff --git a/manual_tests/02_sqlite_to_excel/out/summary_客服部_1.xlsx b/manual_tests/02_sqlite_to_excel/out/summary_客服部_1.xlsx new file mode 100755 index 0000000..fd4362f Binary files /dev/null and b/manual_tests/02_sqlite_to_excel/out/summary_客服部_1.xlsx differ diff --git a/manual_tests/02_sqlite_to_excel/out/summary_营销部_2.xlsx b/manual_tests/02_sqlite_to_excel/out/summary_营销部_2.xlsx new file mode 100755 index 0000000..dd26c30 Binary files /dev/null and b/manual_tests/02_sqlite_to_excel/out/summary_营销部_2.xlsx differ diff --git a/manual_tests/02_sqlite_to_excel/source_summary.db b/manual_tests/02_sqlite_to_excel/source_summary.db new file mode 100644 index 0000000..38362de Binary files /dev/null and b/manual_tests/02_sqlite_to_excel/source_summary.db differ diff --git a/manual_tests/02_sqlite_to_excel/task.json b/manual_tests/02_sqlite_to_excel/task.json new file mode 100644 index 0000000..0564aa7 --- /dev/null +++ b/manual_tests/02_sqlite_to_excel/task.json @@ -0,0 +1,20 @@ +{ + "vars": { + "date": "2026-06-18" + }, + "data": { + "type": "sqlite", + "path": "./source_summary.db", + "query": "select department, report_date, total_count, abnormal_count, owner from department_summary where report_date = ? order by department", + "params": [ + "{{date}}" + ] + }, + "template": { + "path": "./department_summary_template.xlsx" + }, + "output": { + "path": "./out/summary_{{department}}_{{_row}}.xlsx", + "overwrite": true + } +} \ No newline at end of file diff --git a/manual_tests/03_excel_to_excel/audit_template.xlsx b/manual_tests/03_excel_to_excel/audit_template.xlsx new file mode 100644 index 0000000..178c064 Binary files /dev/null and b/manual_tests/03_excel_to_excel/audit_template.xlsx differ diff --git a/manual_tests/03_excel_to_excel/out/audit_2026-06-18_王五_1.xlsx b/manual_tests/03_excel_to_excel/out/audit_2026-06-18_王五_1.xlsx new file mode 100755 index 0000000..1897d2d Binary files /dev/null and b/manual_tests/03_excel_to_excel/out/audit_2026-06-18_王五_1.xlsx differ diff --git a/manual_tests/03_excel_to_excel/out/audit_2026-06-18_赵六_2.xlsx b/manual_tests/03_excel_to_excel/out/audit_2026-06-18_赵六_2.xlsx new file mode 100755 index 0000000..b89752b Binary files /dev/null and b/manual_tests/03_excel_to_excel/out/audit_2026-06-18_赵六_2.xlsx differ diff --git a/manual_tests/03_excel_to_excel/source_audit_data.xlsx b/manual_tests/03_excel_to_excel/source_audit_data.xlsx new file mode 100644 index 0000000..180ab5f Binary files /dev/null and b/manual_tests/03_excel_to_excel/source_audit_data.xlsx differ diff --git a/manual_tests/03_excel_to_excel/task.json b/manual_tests/03_excel_to_excel/task.json new file mode 100644 index 0000000..75e8fd7 --- /dev/null +++ b/manual_tests/03_excel_to_excel/task.json @@ -0,0 +1,18 @@ +{ + "vars": { + "date": "2026-06-18" + }, + "data": { + "type": "excel", + "path": "./source_audit_data.xlsx", + "sheet": "Audit", + "header_row": 2 + }, + "template": { + "path": "./audit_template.xlsx" + }, + "output": { + "path": "./out/audit_{{date}}_{{employee}}_{{_row}}.xlsx", + "overwrite": true + } +} \ No newline at end of file diff --git a/manual_tests/04_filter_work_order_to_word/out/work_order_2026-06-18_GD-002.docx b/manual_tests/04_filter_work_order_to_word/out/work_order_2026-06-18_GD-002.docx new file mode 100644 index 0000000..f8954c4 Binary files /dev/null and b/manual_tests/04_filter_work_order_to_word/out/work_order_2026-06-18_GD-002.docx differ diff --git a/manual_tests/04_filter_work_order_to_word/source_work_orders.xlsx b/manual_tests/04_filter_work_order_to_word/source_work_orders.xlsx new file mode 100644 index 0000000..3d7777b Binary files /dev/null and b/manual_tests/04_filter_work_order_to_word/source_work_orders.xlsx differ diff --git a/manual_tests/04_filter_work_order_to_word/task.json b/manual_tests/04_filter_work_order_to_word/task.json new file mode 100644 index 0000000..d623e8c --- /dev/null +++ b/manual_tests/04_filter_work_order_to_word/task.json @@ -0,0 +1,25 @@ +{ + "vars": { + "date": "2026-06-18" + }, + "data": { + "type": "excel", + "path": "./source_work_orders.xlsx", + "sheet": "工单", + "header_row": 1 + }, + "filter": { + "equals": { + "日期": "{{date}}", + "工单编号": "{{ticket_no}}" + }, + "expect": "one" + }, + "template": { + "path": "./work_order_template.docx" + }, + "output": { + "path": "./out/work_order_{{日期}}_{{工单编号}}.docx", + "overwrite": true + } +} \ No newline at end of file diff --git a/manual_tests/04_filter_work_order_to_word/work_order_template.docx b/manual_tests/04_filter_work_order_to_word/work_order_template.docx new file mode 100644 index 0000000..7f05070 Binary files /dev/null and b/manual_tests/04_filter_work_order_to_word/work_order_template.docx differ diff --git a/manual_tests/README.md b/manual_tests/README.md new file mode 100644 index 0000000..32ce752 --- /dev/null +++ b/manual_tests/README.md @@ -0,0 +1,131 @@ +# docfill 手工测试素材 + +本目录提供四组可直接运行的本地测试素材,用于验证 `docfill` 对筛选、Word、Excel、Excel 数据源和 SQLite 数据源的支持情况。 + +运行前进入项目根目录: + +```bash +cd /Users/zhaoyilun/Documents/xls_doc +``` + +构建工具: + +```bash +bash scripts/build.sh +``` + +## 案例 1:Excel 数据生成 Word 日报 + +目录: + +```text +manual_tests/01_excel_to_word +``` + +运行命令: + +```bash +dist/docfill-darwin-arm64 run -c manual_tests/01_excel_to_word/task.json --var batch=早班 --verbose +``` + +输出示例: + +```text +generated 2 file(s) +- /Users/zhaoyilun/Documents/xls_doc/manual_tests/01_excel_to_word/out/daily_2026-06-18_张三_测试_1.docx +- /Users/zhaoyilun/Documents/xls_doc/manual_tests/01_excel_to_word/out/daily_2026-06-18_李四_2.docx +``` + +## 案例 2:SQLite 数据生成 Excel 汇总表 + +目录: + +```text +manual_tests/02_sqlite_to_excel +``` + +运行命令: + +```bash +dist/docfill-darwin-arm64 run -c manual_tests/02_sqlite_to_excel/task.json --var date=2026-06-18 --verbose +``` + +输出示例: + +```text +generated 2 file(s) +- /Users/zhaoyilun/Documents/xls_doc/manual_tests/02_sqlite_to_excel/out/summary_客服部_1.xlsx +- /Users/zhaoyilun/Documents/xls_doc/manual_tests/02_sqlite_to_excel/out/summary_营销部_2.xlsx +``` + +## 案例 3:Excel 数据生成 Excel 质检表 + +目录: + +```text +manual_tests/03_excel_to_excel +``` + +运行命令: + +```bash +dist/docfill-darwin-arm64 run -c manual_tests/03_excel_to_excel/task.json --var date=2026-06-18 --verbose +``` + +输出示例: + +```text +generated 2 file(s) +- /Users/zhaoyilun/Documents/xls_doc/manual_tests/03_excel_to_excel/out/audit_2026-06-18_王五_1.xlsx +- /Users/zhaoyilun/Documents/xls_doc/manual_tests/03_excel_to_excel/out/audit_2026-06-18_赵六_2.xlsx +``` + +## 案例 4:按日期和工单编号筛选一条工单并生成 Word + +目录: + +```text +manual_tests/04_filter_work_order_to_word +``` + +输入数据内容: + +```text +日期 工单编号 客户名称 系统来源 处理结果 下一步 +2026-06-18 GD-001 A 公司 PMS3.0 已派单 等待现场反馈 +2026-06-18 GD-002 B 公司 D5000 已完成 归档并同步日报 +2026-06-19 GD-001 C 公司 风控平台 待复核 补充截图 +``` + +运行命令: + +```bash +dist/docfill-darwin-arm64 run -c manual_tests/04_filter_work_order_to_word/task.json --var date=2026-06-18 --var ticket_no=GD-002 --verbose +``` + +输出示例: + +```text +generated 1 file(s) +- /Users/zhaoyilun/Documents/xls_doc/manual_tests/04_filter_work_order_to_word/out/work_order_2026-06-18_GD-002.docx +``` + +查看结果: + +```bash +open manual_tests/04_filter_work_order_to_word/out +``` + +## 重新生成素材 + +如需重新生成全部手工测试文件,执行: + +```bash +/Users/zhaoyilun/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3 scripts/make-manual-test-materials.py +``` + +输出示例: + +```text +/Users/zhaoyilun/Documents/xls_doc/manual_tests +``` diff --git a/manual_tests/_evaluation_rendered/daily/page-1.png b/manual_tests/_evaluation_rendered/daily/page-1.png new file mode 100644 index 0000000..9d72199 Binary files /dev/null and b/manual_tests/_evaluation_rendered/daily/page-1.png differ diff --git a/manual_tests/_evaluation_rendered/filter/page-1.png b/manual_tests/_evaluation_rendered/filter/page-1.png new file mode 100644 index 0000000..a5f8576 Binary files /dev/null and b/manual_tests/_evaluation_rendered/filter/page-1.png differ diff --git a/scripts/make-manual-test-materials.py b/scripts/make-manual-test-materials.py new file mode 100644 index 0000000..729e133 --- /dev/null +++ b/scripts/make-manual-test-materials.py @@ -0,0 +1,473 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import shutil +import sqlite3 +from pathlib import Path + +from docx import Document +from docx.enum.table import WD_TABLE_ALIGNMENT, WD_CELL_VERTICAL_ALIGNMENT +from docx.shared import Inches, Pt +from openpyxl import Workbook +from openpyxl.styles import Alignment, Font, PatternFill, Border, Side +from openpyxl.utils import get_column_letter + + +ROOT = Path(__file__).resolve().parents[1] +OUT = ROOT / "manual_tests" + + +def main() -> None: + if OUT.exists(): + shutil.rmtree(OUT) + OUT.mkdir(parents=True) + + build_excel_to_word(OUT / "01_excel_to_word") + build_sqlite_to_excel(OUT / "02_sqlite_to_excel") + build_excel_to_excel(OUT / "03_excel_to_excel") + build_filter_work_order_to_word(OUT / "04_filter_work_order_to_word") + write_readme() + + print(OUT) + + +def build_excel_to_word(path: Path) -> None: + path.mkdir(parents=True) + write_daily_source_xlsx(path / "source_daily_data.xlsx") + write_daily_word_template(path / "daily_report_template.docx") + write_json( + path / "task.json", + { + "vars": {"date": "2026-06-18", "batch": "默认班次"}, + "data": { + "type": "excel", + "path": "./source_daily_data.xlsx", + "sheet": "Daily", + "header_row": 1, + }, + "template": {"path": "./daily_report_template.docx"}, + "output": { + "path": "./out/daily_{{date}}_{{name}}_{{_row}}.docx", + "overwrite": True, + }, + }, + ) + + +def build_sqlite_to_excel(path: Path) -> None: + path.mkdir(parents=True) + write_summary_sqlite(path / "source_summary.db") + write_summary_excel_template(path / "department_summary_template.xlsx") + write_json( + path / "task.json", + { + "vars": {"date": "2026-06-18"}, + "data": { + "type": "sqlite", + "path": "./source_summary.db", + "query": "select department, report_date, total_count, abnormal_count, owner from department_summary where report_date = ? order by department", + "params": ["{{date}}"], + }, + "template": {"path": "./department_summary_template.xlsx"}, + "output": { + "path": "./out/summary_{{department}}_{{_row}}.xlsx", + "overwrite": True, + }, + }, + ) + + +def build_excel_to_excel(path: Path) -> None: + path.mkdir(parents=True) + write_audit_source_xlsx(path / "source_audit_data.xlsx") + write_audit_excel_template(path / "audit_template.xlsx") + write_json( + path / "task.json", + { + "vars": {"date": "2026-06-18"}, + "data": { + "type": "excel", + "path": "./source_audit_data.xlsx", + "sheet": "Audit", + "header_row": 2, + }, + "template": {"path": "./audit_template.xlsx"}, + "output": { + "path": "./out/audit_{{date}}_{{employee}}_{{_row}}.xlsx", + "overwrite": True, + }, + }, + ) + + +def build_filter_work_order_to_word(path: Path) -> None: + path.mkdir(parents=True) + write_work_order_source_xlsx(path / "source_work_orders.xlsx") + write_work_order_word_template(path / "work_order_template.docx") + write_json( + path / "task.json", + { + "vars": {"date": "2026-06-18"}, + "data": { + "type": "excel", + "path": "./source_work_orders.xlsx", + "sheet": "工单", + "header_row": 1, + }, + "filter": { + "equals": { + "日期": "{{date}}", + "工单编号": "{{ticket_no}}", + }, + "expect": "one", + }, + "template": {"path": "./work_order_template.docx"}, + "output": { + "path": "./out/work_order_{{日期}}_{{工单编号}}.docx", + "overwrite": True, + }, + }, + ) + + +def write_daily_source_xlsx(path: Path) -> None: + rows = [ + ["name", "department", "done", "plan", "risk"], + ["张三/测试", "营销部", "完成 A 系统订单核对 128 条;同步 B 系统回款 35 笔", "跟进 3 条异常订单并补充截图", "无"], + ["李四", "客服部", "整理投诉工单 21 条;完成日报提交", "复核高优先级工单并同步主管", "等待业务确认 1 条"], + ] + write_table_workbook(path, "Daily", rows, title="日报源数据") + + +def write_daily_word_template(path: Path) -> None: + doc = Document() + section = doc.sections[0] + section.top_margin = Inches(0.8) + section.bottom_margin = Inches(0.8) + section.left_margin = Inches(0.85) + section.right_margin = Inches(0.85) + + title = doc.add_paragraph() + title_run = title.add_run("员工日报(测试模板)") + title_run.bold = True + title_run.font.size = Pt(18) + + meta = doc.add_table(rows=4, cols=2) + meta.alignment = WD_TABLE_ALIGNMENT.LEFT + set_table_style(meta) + labels = ["日期", "员工", "部门", "班次"] + values = ["{{date}}", "{{na", "{{department}}", "{{batch}}"] + for i, label in enumerate(labels): + meta.cell(i, 0).text = label + if label == "员工": + p = meta.cell(i, 1).paragraphs[0] + p.add_run(values[i]) + p.add_run("me}}") + else: + meta.cell(i, 1).text = values[i] + + for heading, placeholder in [ + ("今日完成", "{{done}}"), + ("明日计划", "{{plan}}"), + ("风险备注", "{{risk}}"), + ]: + doc.add_paragraph() + h = doc.add_paragraph() + r = h.add_run(heading) + r.bold = True + r.font.size = Pt(12) + p = doc.add_paragraph() + p.add_run(placeholder) + + doc.save(path) + + +def write_summary_sqlite(path: Path) -> None: + conn = sqlite3.connect(path) + try: + conn.executescript( + """ + create table department_summary ( + department text, + report_date text, + total_count integer, + abnormal_count integer, + owner text + ); + insert into department_summary values + ('客服部', '2026-06-18', 76, 0, '李四'), + ('营销部', '2026-06-18', 128, 3, '张三'), + ('营销部', '2026-06-17', 115, 2, '张三'); + """ + ) + conn.commit() + finally: + conn.close() + + +def write_summary_excel_template(path: Path) -> None: + wb = Workbook() + ws = wb.active + ws.title = "Summary" + ws.append(["部门日报汇总(测试模板)", ""]) + ws.append(["部门", "{{department}}"]) + ws.append(["日期", "{{report_date}}"]) + ws.append(["负责人", "{{owner}}"]) + ws.append(["处理总数", "{{total_count}}"]) + ws.append(["异常数", "{{abnormal_count}}"]) + style_sheet(ws, title_rows={1}) + ws.column_dimensions["A"].width = 18 + ws.column_dimensions["B"].width = 32 + wb.save(path) + + +def write_audit_source_xlsx(path: Path) -> None: + rows = [ + ["日报质检导出"], + ["employee", "date", "item", "score", "comment"], + ["王五", "2026-06-18", "日报完整性", "92", "字段齐全,说明清楚"], + ["赵六", "2026-06-18", "系统截图一致性", "87", "缺少 1 张截图,需要补充"], + ] + write_table_workbook(path, "Audit", rows, title=None) + + +def write_audit_excel_template(path: Path) -> None: + wb = Workbook() + ws = wb.active + ws.title = "AuditReport" + ws.append(["日报质检结果(测试模板)", ""]) + ws.append(["日期", "{{date}}"]) + ws.append(["员工", "{{employee}}"]) + ws.append(["检查项", "{{item}}"]) + ws.append(["得分", "{{score}}"]) + ws.append(["评语", "{{comment}}"]) + style_sheet(ws, title_rows={1}) + ws.column_dimensions["A"].width = 16 + ws.column_dimensions["B"].width = 42 + wb.save(path) + + +def write_work_order_source_xlsx(path: Path) -> None: + rows = [ + ["日期", "工单编号", "客户名称", "系统来源", "处理结果", "下一步"], + ["2026-06-18", "GD-001", "A 公司", "PMS3.0", "已派单", "等待现场反馈"], + ["2026-06-18", "GD-002", "B 公司", "D5000", "已完成", "归档并同步日报"], + ["2026-06-19", "GD-001", "C 公司", "风控平台", "待复核", "补充截图"], + ] + write_table_workbook(path, "工单", rows, title="工单源数据") + + +def write_work_order_word_template(path: Path) -> None: + doc = Document() + section = doc.sections[0] + section.top_margin = Inches(0.8) + section.bottom_margin = Inches(0.8) + section.left_margin = Inches(0.85) + section.right_margin = Inches(0.85) + + title = doc.add_paragraph() + title_run = title.add_run("工单处理记录(筛选测试模板)") + title_run.bold = True + title_run.font.size = Pt(18) + + table = doc.add_table(rows=6, cols=2) + table.alignment = WD_TABLE_ALIGNMENT.LEFT + set_table_style(table) + rows = [ + ("日期", "{{日期}}"), + ("工单编号", "{{工单编号}}"), + ("客户名称", "{{客户名称}}"), + ("系统来源", "{{系统来源}}"), + ("处理结果", "{{处理结果}}"), + ("下一步", "{{下一步}}"), + ] + for idx, (label, value) in enumerate(rows): + table.cell(idx, 0).text = label + table.cell(idx, 1).text = value + + doc.save(path) + + +def write_table_workbook(path: Path, sheet: str, rows: list[list[str]], title: str | None) -> None: + wb = Workbook() + ws = wb.active + ws.title = sheet + for row in rows: + ws.append(row) + title_rows = {1} if title else set() + style_sheet(ws, title_rows=title_rows) + for idx, width in enumerate([18, 16, 44, 36, 28], start=1): + ws.column_dimensions[get_column_letter(idx)].width = width + wb.save(path) + + +def style_sheet(ws, title_rows: set[int]) -> None: + header_fill = PatternFill("solid", fgColor="D9EAF7") + title_fill = PatternFill("solid", fgColor="1F4E79") + thin = Side(style="thin", color="D9E2F3") + border = Border(top=thin, left=thin, right=thin, bottom=thin) + for row in ws.iter_rows(): + for cell in row: + cell.alignment = Alignment(vertical="center", wrap_text=True) + cell.border = border + if cell.row in title_rows: + cell.font = Font(bold=True, color="FFFFFF", size=13) + cell.fill = title_fill + elif cell.row == min(title_rows or {1}) + 1 or (not title_rows and cell.row == 1): + cell.font = Font(bold=True) + cell.fill = header_fill + ws.freeze_panes = "A2" + + +def set_table_style(table) -> None: + for row in table.rows: + for cell in row.cells: + cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER + for paragraph in cell.paragraphs: + for run in paragraph.runs: + run.font.size = Pt(10.5) + + +def write_json(path: Path, data: dict) -> None: + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + + +def write_readme() -> None: + text = """# docfill 手工测试素材 + +本目录提供四组可直接运行的本地测试素材,用于验证 `docfill` 对筛选、Word、Excel、Excel 数据源和 SQLite 数据源的支持情况。 + +运行前进入项目根目录: + +```bash +cd /Users/zhaoyilun/Documents/xls_doc +``` + +构建工具: + +```bash +bash scripts/build.sh +``` + +## 案例 1:Excel 数据生成 Word 日报 + +目录: + +```text +manual_tests/01_excel_to_word +``` + +运行命令: + +```bash +dist/docfill-darwin-arm64 run -c manual_tests/01_excel_to_word/task.json --var batch=早班 --verbose +``` + +输出示例: + +```text +generated 2 file(s) +- /Users/zhaoyilun/Documents/xls_doc/manual_tests/01_excel_to_word/out/daily_2026-06-18_张三_测试_1.docx +- /Users/zhaoyilun/Documents/xls_doc/manual_tests/01_excel_to_word/out/daily_2026-06-18_李四_2.docx +``` + +## 案例 2:SQLite 数据生成 Excel 汇总表 + +目录: + +```text +manual_tests/02_sqlite_to_excel +``` + +运行命令: + +```bash +dist/docfill-darwin-arm64 run -c manual_tests/02_sqlite_to_excel/task.json --var date=2026-06-18 --verbose +``` + +输出示例: + +```text +generated 2 file(s) +- /Users/zhaoyilun/Documents/xls_doc/manual_tests/02_sqlite_to_excel/out/summary_客服部_1.xlsx +- /Users/zhaoyilun/Documents/xls_doc/manual_tests/02_sqlite_to_excel/out/summary_营销部_2.xlsx +``` + +## 案例 3:Excel 数据生成 Excel 质检表 + +目录: + +```text +manual_tests/03_excel_to_excel +``` + +运行命令: + +```bash +dist/docfill-darwin-arm64 run -c manual_tests/03_excel_to_excel/task.json --var date=2026-06-18 --verbose +``` + +输出示例: + +```text +generated 2 file(s) +- /Users/zhaoyilun/Documents/xls_doc/manual_tests/03_excel_to_excel/out/audit_2026-06-18_王五_1.xlsx +- /Users/zhaoyilun/Documents/xls_doc/manual_tests/03_excel_to_excel/out/audit_2026-06-18_赵六_2.xlsx +``` + +## 案例 4:按日期和工单编号筛选一条工单并生成 Word + +目录: + +```text +manual_tests/04_filter_work_order_to_word +``` + +输入数据内容: + +```text +日期 工单编号 客户名称 系统来源 处理结果 下一步 +2026-06-18 GD-001 A 公司 PMS3.0 已派单 等待现场反馈 +2026-06-18 GD-002 B 公司 D5000 已完成 归档并同步日报 +2026-06-19 GD-001 C 公司 风控平台 待复核 补充截图 +``` + +运行命令: + +```bash +dist/docfill-darwin-arm64 run -c manual_tests/04_filter_work_order_to_word/task.json --var date=2026-06-18 --var ticket_no=GD-002 --verbose +``` + +输出示例: + +```text +generated 1 file(s) +- /Users/zhaoyilun/Documents/xls_doc/manual_tests/04_filter_work_order_to_word/out/work_order_2026-06-18_GD-002.docx +``` + +查看结果: + +```bash +open manual_tests/04_filter_work_order_to_word/out +``` + +## 重新生成素材 + +如需重新生成全部手工测试文件,执行: + +```bash +/Users/zhaoyilun/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3 scripts/make-manual-test-materials.py +``` + +输出示例: + +```text +/Users/zhaoyilun/Documents/xls_doc/manual_tests +``` +""" + (OUT / "README.md").write_text(text, encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/scripts/smoke-fixtures/main.go b/scripts/smoke-fixtures/main.go index c2aa8cd..1762084 100644 --- a/scripts/smoke-fixtures/main.go +++ b/scripts/smoke-fixtures/main.go @@ -25,6 +25,10 @@ func main() { fmt.Fprintf(os.Stderr, "write excel-to-word fixtures: %v\n", err) os.Exit(1) } + if err := writeExcelFilterToWord(filepath.Join(*base, "excel-filter-to-word")); err != nil { + fmt.Fprintf(os.Stderr, "write excel-filter-to-word fixtures: %v\n", err) + os.Exit(1) + } if err := writeSQLiteToExcel(filepath.Join(*base, "sqlite-to-excel")); err != nil { fmt.Fprintf(os.Stderr, "write sqlite-to-excel fixtures: %v\n", err) os.Exit(1) @@ -45,6 +49,21 @@ func writeExcelToWord(dir string) error { return writeDocx(filepath.Join(dir, "template.docx"), `员工:{{name}}日期:{{date}}完成:{{done}}计划:{{plan}}`) } +func writeExcelFilterToWord(dir string) error { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + if err := writeWorkbook(filepath.Join(dir, "data.xlsx"), "Tickets", [][]string{ + {"date", "ticket_no", "customer", "result"}, + {"2026-06-18", "GD-001", "A 公司", "已派单"}, + {"2026-06-18", "GD-002", "B 公司", "已完成"}, + {"2026-06-19", "GD-001", "C 公司", "待复核"}, + }); err != nil { + return err + } + return writeDocx(filepath.Join(dir, "template.docx"), `工单:{{ticket_no}}日期:{{date}}客户:{{customer}}结果:{{result}}`) +} + func writeSQLiteToExcel(dir string) error { if err := os.MkdirAll(dir, 0o755); err != nil { return err diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 7ac00c4..5837ea0 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -17,10 +17,13 @@ cp -R examples "$TMP/examples" go run ./scripts/smoke-fixtures --base "$TMP/examples" "$TMP/docfill" run -c "$TMP/examples/excel-to-word/task.json" --var date=2026-06-18 +"$TMP/docfill" run -c "$TMP/examples/excel-filter-to-word/task.json" --var date=2026-06-18 --var ticket_no=GD-002 "$TMP/docfill" run -c "$TMP/examples/sqlite-to-excel/task.json" --var date=2026-06-18 test -f "$TMP/examples/excel-to-word/out/daily_2026-06-18_张三_1.docx" test -f "$TMP/examples/excel-to-word/out/daily_2026-06-18_李四_2.docx" +test -f "$TMP/examples/excel-filter-to-word/out/ticket_2026-06-18_GD-002.docx" +test ! -f "$TMP/examples/excel-filter-to-word/out/ticket_2026-06-18_GD-001.docx" xlsx_count="$(find "$TMP/examples/sqlite-to-excel/out" -type f -name '*.xlsx' | wc -l | tr -d ' ')" if [[ "$xlsx_count" != "2" ]]; then