chore: prepare docfill project for gitea

This commit is contained in:
赵义仑
2026-06-24 09:45:35 +08:00
parent ca444bd5d0
commit 32e74d0f43
38 changed files with 2915 additions and 162 deletions

725
HOWTO.md Normal file
View File

@@ -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 <task.json> [--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` 执行单次文档填充。

281
README.md
View File

@@ -1,79 +1,151 @@
# docfill # docfill
`docfill` 是一个最小化本地填报工具:从 Excel 或 SQLite 读取数据,把 `{{字段名}}` 填入 Word/Excel 模板,生成输出文件 `docfill` 是一个本地文档填充命令行工具。它从 Excel 或 SQLite 读取结构化数据,按业务条件筛选出需要使用的数据行,再把数据填入 WordExcel 模板中的 `{{字段名}}` 占位符
## 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 ```bash
go build -o docfill ./cmd/docfill
bash scripts/build.sh 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 ```text
generated 2 file(s) /Users/zhaoyilun/Documents/xls_doc/dist/docfill-darwin-arm64
- /path/to/out/report_张三.xlsx
- /path/to/out/report_李四.xlsx
``` ```
退出码 运行“按日期 + 工单编号筛选一条工单并生成 Word”的测试任务
- `0`: 成功。
- `1`: 配置、数据、模板或输出失败。
- `2`: 命令行参数错误。
## Run Case Tests
```bash ```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 模板,批量生成个人日报。 ```text
- SQLite 日报数据填 Word 模板,批量生成个人日报。 log: load config path=manual_tests/04_filter_work_order_to_word/task.json
- SQLite 汇总数据填 Excel 模板,批量生成部门汇总表。 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
bash scripts/smoke.sh bash scripts/smoke.sh
``` ```
Smoke test 会 运行 Go 测试
- 跑全量 Go 测试。 ```bash
- 构建临时 `docfill` 二进制。 go test -count=1 ./...
- 复制 `examples/` 到临时目录。 ```
- 生成临时 Excel、Word、SQLite 示例数据。
- 执行两个示例任务并检查输出文件。
## 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 `task.json`
示例配置在 `examples/`
- `examples/excel-to-word/task.json`
- `examples/sqlite-to-excel/task.json`
示例里的二进制 Office/SQLite 文件由 `scripts/smoke-fixtures` 临时生成,不提交到仓库。
## Excel Data Example
```json ```json
{ {
@@ -82,63 +154,88 @@ Smoke test 会:
}, },
"data": { "data": {
"type": "excel", "type": "excel",
"path": "./data.xlsx", "path": "./source_work_orders.xlsx",
"sheet": "Sheet1", "sheet": "工单",
"header_row": 1 "header_row": 1
}, },
"filter": {
"equals": {
"日期": "{{date}}",
"工单编号": "{{ticket_no}}"
},
"expect": "one"
},
"template": { "template": {
"path": "./template.docx" "path": "./work_order_template.docx"
}, },
"output": { "output": {
"path": "./out/report_{{date}}_{{name}}_{{_row}}.docx", "path": "./out/work_order_{{日期}}_{{工单编号}}.docx",
"overwrite": false "overwrite": true
} }
} }
``` ```
Excel 第一行是字段名,后续每一行生成一个文件。 运行:
## SQLite Data Example ```bash
dist/docfill-darwin-arm64 run -c task.json --var date=2026-06-18 --var ticket_no=GD-002
```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"
}
}
``` ```
SQL 查询结果的列名就是模板占位符字段名。 筛选含义:
## P0 Rules ```text
找到 日期 = 2026-06-18并且 工单编号 = GD-002 的那一行。
```
- 配置里的相对路径按 `task.json` 所在目录解析。 `filter.expect` 支持:
- `vars` 可以提供默认变量,命令行 `--var key=value` 会覆盖同名默认变量。
- 模板填充时,每行数据字段优先于 `vars`
- `_row` 是内置变量,表示当前数据行序号,从 `1` 开始。
- SQLite `params` 对应 SQL 里的 `?` 参数。
- Excel `header_row``1` 开始,默认是 `1`
- 默认不覆盖已有输出文件;只有 `output.overwrite: true` 才允许覆盖。
- 输出文件路径中的字段值会清理 `/ \ : * ? " < > |`
- 模板里缺字段或渲染后仍有 `{{字段}}`,会直接失败。
- Word 模板支持同一段落内被拆开的文本占位符,例如 `{{na``me}}` 位于相邻文本节点。
## MVP Limits - `one`: 必须只匹配一行,适合工单、单份日报、单个报表。
- `many`: 允许匹配多行,适合按日期生成多个人或多个部门的报表。
- 只支持 JSON 配置 未配置 `filter` 时,工具沿用旧行为:读取到的每一行都会生成一个输出文件
- 只支持 `.xlsx` 数据源、SQLite 数据源。
- 只支持 `.docx``.xlsx` 模板。 ## 核心规则
- Word 占位符拆分只做最小兼容:支持同一段落内的文本节点拆分,不做复杂表格循环或样式级模板语法。
- 不做 Web、权限、审批流、模板设计器或业务系统直连。 - 配置文件使用 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): 面向数字员工或调度脚本的最小运行约定。

View File

@@ -1,10 +1,12 @@
package main package main
import ( import (
"errors"
"flag" "flag"
"fmt" "fmt"
"io" "io"
"os" "os"
"sort"
"strings" "strings"
"xlsdoc/internal/docfill" "xlsdoc/internal/docfill"
@@ -18,11 +20,11 @@ func main() {
func run(args []string, stdout, stderr io.Writer) int { func run(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 { 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 return 2
} }
if args[0] != "run" { 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 return 2
} }
@@ -43,38 +45,78 @@ func runFill(args []string, stderr io.Writer) (docfill.RunResult, int) {
fs.SetOutput(stderr) fs.SetOutput(stderr)
var configPath string var configPath string
var verbose bool
var vars varFlags = make(map[string]string) var vars varFlags = make(map[string]string)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
fs.StringVar(&configPath, "config", "", "path to task json") fs.StringVar(&configPath, "config", "", "path to task json")
fs.StringVar(&configPath, "c", "", "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") fs.Var(vars, "var", "template variable as key=value; repeatable")
if err := fs.Parse(args); err != nil { 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 return docfill.RunResult{}, 2
} }
if fs.NArg() > 0 { 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 return docfill.RunResult{}, 2
} }
if vars == nil { if vars == nil {
vars = make(map[string]string) vars = make(map[string]string)
} }
if configPath == "" { 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.SetOutput(stderr)
fs.Usage() fs.Usage()
return docfill.RunResult{}, 2 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 { if err != nil {
fmt.Fprintf(stderr, "docfill: %v\n", err) writeExecutionError(stderr, err)
return docfill.RunResult{}, 1 return docfill.RunResult{}, 1
} }
return result, 0 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 { func (v varFlags) String() string {
if len(v) == 0 { if len(v) == 0 {
return "" return ""

View File

@@ -63,7 +63,7 @@ func TestRunCommandRejectsBadVarSyntax(t *testing.T) {
if exitCode != 2 { if exitCode != 2 {
t.Fatalf("exit code = %d, want 2", exitCode) 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()) 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) { func writeCLIWorkbook(t *testing.T, path, sheet string, rows [][]string) {
t.Helper() t.Helper()

View File

@@ -8,11 +8,14 @@ Each run needs:
- A `task.json` config file. - A `task.json` config file.
- A data source: `.xlsx` or SQLite `.db`. - A data source: `.xlsx` or SQLite `.db`.
- Optional business filters in `filter.equals`.
- A template: `.docx` or `.xlsx`. - A template: `.docx` or `.xlsx`.
- An output path pattern in `task.json`. - An output path pattern in `task.json`.
Paths inside `task.json` are resolved from the config file directory. 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 ## Command
```bash ```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: Use repeated `--var key=value` arguments for runtime values:
```bash ```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 ## Success
@@ -48,9 +83,91 @@ Exit code `2` means the command line was invalid.
Errors are written to stderr with the `docfill:` prefix: Errors are written to stderr with the `docfill:` prefix:
```text ```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 ## Common Errors
`missing template field` `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. 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` `unresolved placeholder`
The rendered document still contains `{{field}}`. Check the source data value and the template. 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 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.

View File

@@ -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`

View File

@@ -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`.

View File

@@ -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
bash scripts/smoke.sh 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 测试素材。

View File

@@ -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
}
}

View File

@@ -4,12 +4,12 @@ import (
"archive/zip" "archive/zip"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"sort"
"strconv" "strconv"
"strings" "strings"
@@ -21,6 +21,7 @@ import (
type config struct { type config struct {
Vars map[string]string `json:"vars"` Vars map[string]string `json:"vars"`
Data dataConfig `json:"data"` Data dataConfig `json:"data"`
Filter filterConfig `json:"filter"`
Template templateConfig `json:"template"` Template templateConfig `json:"template"`
Output outputConfig `json:"output"` Output outputConfig `json:"output"`
} }
@@ -34,6 +35,11 @@ type dataConfig struct {
HeaderRow int `json:"header_row"` HeaderRow int `json:"header_row"`
} }
type filterConfig struct {
Equals map[string]string `json:"equals"`
Expect string `json:"expect"`
}
type templateConfig struct { type templateConfig struct {
Path string `json:"path"` Path string `json:"path"`
} }
@@ -45,6 +51,7 @@ type outputConfig struct {
type RunOptions struct { type RunOptions struct {
Vars map[string]string Vars map[string]string
Logf func(format string, args ...any)
} }
type RunResult struct { type RunResult struct {
@@ -68,6 +75,7 @@ func Run(configPath string) error {
// RunWithOptions executes one configured fill task and returns generated files. // RunWithOptions executes one configured fill task and returns generated files.
func RunWithOptions(configPath string, opts RunOptions) (RunResult, error) { func RunWithOptions(configPath string, opts RunOptions) (RunResult, error) {
logf(opts, "load config path=%s", configPath)
cfg, err := loadConfig(configPath) cfg, err := loadConfig(configPath)
if err != nil { if err != nil {
return RunResult{}, err return RunResult{}, err
@@ -77,12 +85,24 @@ func RunWithOptions(configPath string, opts RunOptions) (RunResult, error) {
} }
vars := mergeVars(cfg.Vars, opts.Vars) 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) rows, err := readRowsWithVars(cfg.Data, vars)
if err != nil { if err != nil {
return RunResult{}, err return RunResult{}, err
} }
if len(rows) == 0 { 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))} 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) outputPath := replacePathPlaceholders(cfg.Output.Path, context)
if hasPlaceholder(outputPath) { 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) { 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 { if err := renderTemplate(cfg.Template.Path, outputPath, context); err != nil {
return RunResult{}, fmt.Errorf("render row %d: %w", i+1, err) 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 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) { func loadConfig(path string) (config, error) {
if path == "" { 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) data, err := os.ReadFile(path)
if err != nil { 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 var cfg config
if err := json.Unmarshal(data, &cfg); err != nil { 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))) resolveConfigPaths(&cfg, filepath.Dir(absPath(path)))
return cfg, nil return cfg, nil
@@ -129,19 +167,27 @@ func loadConfig(path string) (config, error) {
func validateConfig(cfg config) error { func validateConfig(cfg config) error {
if cfg.Data.Type == "" { 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 == "" { 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 == "" { 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 == "" { 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 { 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 return nil
} }
@@ -157,14 +203,19 @@ func readRowsWithVars(cfg dataConfig, vars map[string]string) ([]map[string]stri
case "sqlite": case "sqlite":
return readSQLiteRows(cfg, vars) return readSQLiteRows(cfg, vars)
default: 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) { func readExcelRows(cfg dataConfig) ([]map[string]string, error) {
f, err := excelize.OpenFile(cfg.Path) f, err := excelize.OpenFile(cfg.Path)
if err != nil { 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() defer f.Close()
@@ -173,15 +224,24 @@ func readExcelRows(cfg dataConfig) ([]map[string]string, error) {
sheet = f.GetSheetName(0) sheet = f.GetSheetName(0)
} }
if sheet == "" { 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) rawRows, err := f.GetRows(sheet)
if err != nil { 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 { 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 headerRow := cfg.HeaderRow
@@ -189,7 +249,10 @@ func readExcelRows(cfg dataConfig) ([]map[string]string, error) {
headerRow = 1 headerRow = 1
} }
if headerRow > len(rawRows) { 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])) 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) { func readSQLiteRows(cfg dataConfig, vars map[string]string) ([]map[string]string, error) {
if cfg.Query == "" { 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) db, err := sql.Open("sqlite", cfg.Path)
if err != nil { 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() 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...) result, err := db.Query(cfg.Query, args...)
if err != nil { 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() defer result.Close()
columns, err := result.Columns() columns, err := result.Columns()
if err != nil { 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 var rows []map[string]string
@@ -254,7 +326,9 @@ func readSQLiteRows(cfg dataConfig, vars map[string]string) ([]map[string]string
scanTargets[i] = &values[i] scanTargets[i] = &values[i]
} }
if err := result.Scan(scanTargets...); err != nil { 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) row := make(map[string]string)
@@ -264,18 +338,154 @@ func readSQLiteRows(cfg dataConfig, vars map[string]string) ([]map[string]string
rows = append(rows, row) rows = append(rows, row)
} }
if err := result.Err(); err != nil { 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 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 { func renderTemplate(templatePath, outputPath string, row map[string]string) error {
if err := validateTemplateFields(templatePath, row); err != nil { if err := validateTemplateFields(templatePath, row); err != nil {
return err return err
} }
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { 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)) { switch strings.ToLower(filepath.Ext(templatePath)) {
@@ -284,21 +494,29 @@ func renderTemplate(templatePath, outputPath string, row map[string]string) erro
case ".docx": case ".docx":
return renderDOCX(templatePath, outputPath, row) return renderDOCX(templatePath, outputPath, row)
default: 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 { func renderXLSX(templatePath, outputPath string, row map[string]string) error {
f, err := excelize.OpenFile(templatePath) f, err := excelize.OpenFile(templatePath)
if err != nil { 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() defer f.Close()
for _, sheet := range f.GetSheetList() { for _, sheet := range f.GetSheetList() {
rows, err := f.GetRows(sheet) rows, err := f.GetRows(sheet)
if err != nil { 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 r, values := range rows {
for c, value := range values { for c, value := range values {
@@ -311,17 +529,25 @@ func renderXLSX(templatePath, outputPath string, row map[string]string) error {
} }
rendered := replacePlaceholders(value, row) rendered := replacePlaceholders(value, row)
if hasPlaceholder(rendered) { 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 { 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 { 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 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 { func renderDOCX(templatePath, outputPath string, row map[string]string) error {
reader, err := zip.OpenReader(templatePath) reader, err := zip.OpenReader(templatePath)
if err != nil { 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() defer reader.Close()
output, err := os.Create(outputPath) output, err := os.Create(outputPath)
if err != nil { 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) writer := zip.NewWriter(output)
@@ -348,10 +580,14 @@ func renderDOCX(templatePath, outputPath string, row map[string]string) error {
} }
if err := writer.Close(); err != nil { if err := writer.Close(); err != nil {
_ = output.Close() _ = 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 { 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 return nil
@@ -364,7 +600,8 @@ func copyDocxPart(writer *zip.Writer, source *zip.File, row map[string]string) e
dest, err := writer.CreateHeader(&header) dest, err := writer.CreateHeader(&header)
if err != nil { 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() { 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() src, err := source.Open()
if err != nil { 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() defer src.Close()
data, err := io.ReadAll(src) data, err := io.ReadAll(src)
if err != nil { 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) { if isDocxXMLPart(source.Name) {
rendered, err := renderDocxXML(string(data), row) 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) return fmt.Errorf("render docx part %s: %w", source.Name, err)
} }
if docxHasPlaceholder(rendered) { 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) data = []byte(rendered)
} }
if _, err := dest.Write(data); err != nil { 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 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) { func sqliteParams(params []string, vars map[string]string) ([]any, error) {
args := make([]any, 0, len(params)) args := make([]any, 0, len(params))
for i, param := range 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 return nil, err
} }
rendered := replacePlaceholders(param, vars) rendered := replacePlaceholders(param, vars)
if hasPlaceholder(rendered) { 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) args = append(args, rendered)
} }
@@ -490,7 +739,10 @@ func validateTemplateFields(templatePath string, row map[string]string) error {
} }
for field := range fields { for field := range fields {
if _, ok := row[field]; !ok { 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 return nil
@@ -510,7 +762,10 @@ func templatePlaceholders(templatePath string) (map[string]struct{}, error) {
func xlsxPlaceholders(templatePath string) (map[string]struct{}, error) { func xlsxPlaceholders(templatePath string) (map[string]struct{}, error) {
f, err := excelize.OpenFile(templatePath) f, err := excelize.OpenFile(templatePath)
if err != nil { 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() defer f.Close()
@@ -518,7 +773,10 @@ func xlsxPlaceholders(templatePath string) (map[string]struct{}, error) {
for _, sheet := range f.GetSheetList() { for _, sheet := range f.GetSheetList() {
rows, err := f.GetRows(sheet) rows, err := f.GetRows(sheet)
if err != nil { 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 _, row := range rows {
for _, value := range row { for _, value := range row {
@@ -532,7 +790,10 @@ func xlsxPlaceholders(templatePath string) (map[string]struct{}, error) {
func docxPlaceholders(templatePath string) (map[string]struct{}, error) { func docxPlaceholders(templatePath string) (map[string]struct{}, error) {
reader, err := zip.OpenReader(templatePath) reader, err := zip.OpenReader(templatePath)
if err != nil { 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() defer reader.Close()
@@ -543,15 +804,21 @@ func docxPlaceholders(templatePath string) (map[string]struct{}, error) {
} }
rc, err := file.Open() rc, err := file.Open()
if err != nil { 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) data, readErr := io.ReadAll(rc)
closeErr := rc.Close() closeErr := rc.Close()
if readErr != nil { 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 { 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) xml := string(data)
addPlaceholders(fields, xml) 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 { 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{}) fields := make(map[string]struct{})
addPlaceholders(fields, text) addPlaceholders(fields, text)
for field := range fields { for field := range fields {
if _, ok := row[field]; !ok { 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 return nil
@@ -637,7 +921,8 @@ func renderDocxTextGroup(xml string, row map[string]string) (string, error) {
} }
rendered := replaceDocxPlaceholders(joined, row) rendered := replaceDocxPlaceholders(joined, row)
if hasPlaceholder(rendered) { 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 var out strings.Builder

View File

@@ -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, `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>员工:{{na</w:t></w:r><w:r><w:t>me}}</w:t></w:r><w:r><w:t>,完成:{{do</w:t></w:r><w:r><w:t>ne}}</w:t></w:r><w:r><w:t>,备注:{{note}}</w:t></w:r></w:p></w:body></w:document>`)
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) { func TestRunGeneratesOneOutputPerInputRow(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
dataPath := filepath.Join(dir, "data.xlsx") dataPath := filepath.Join(dir, "data.xlsx")

View File

@@ -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
}

View File

@@ -2,6 +2,7 @@ package docfill
import ( import (
"database/sql" "database/sql"
"errors"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -176,6 +177,16 @@ func TestRunRefusesToOverwriteUnlessConfigured(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), "already exists") { if err == nil || !strings.Contains(err.Error(), "already exists") {
t.Fatalf("expected already exists error, got %v", err) 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{ writeJSON(t, configPath, map[string]any{
"data": 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") { if err == nil || !strings.Contains(err.Error(), "missing template field") {
t.Fatalf("expected missing template field error, got %v", err) 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) { 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) { func seedP0SQLite(t *testing.T, path, statements string) {
t.Helper() t.Helper()

Binary file not shown.

View File

@@ -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
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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
}
}

Binary file not shown.

Binary file not shown.

View File

@@ -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
}
}

View File

@@ -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
}
}

131
manual_tests/README.md Normal file
View File

@@ -0,0 +1,131 @@
# docfill 手工测试素材
本目录提供四组可直接运行的本地测试素材,用于验证 `docfill` 对筛选、Word、Excel、Excel 数据源和 SQLite 数据源的支持情况。
运行前进入项目根目录:
```bash
cd /Users/zhaoyilun/Documents/xls_doc
```
构建工具:
```bash
bash scripts/build.sh
```
## 案例 1Excel 数据生成 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
```
## 案例 2SQLite 数据生成 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
```
## 案例 3Excel 数据生成 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
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -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
```
## 案例 1Excel 数据生成 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
```
## 案例 2SQLite 数据生成 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
```
## 案例 3Excel 数据生成 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()

View File

@@ -25,6 +25,10 @@ func main() {
fmt.Fprintf(os.Stderr, "write excel-to-word fixtures: %v\n", err) fmt.Fprintf(os.Stderr, "write excel-to-word fixtures: %v\n", err)
os.Exit(1) 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 { if err := writeSQLiteToExcel(filepath.Join(*base, "sqlite-to-excel")); err != nil {
fmt.Fprintf(os.Stderr, "write sqlite-to-excel fixtures: %v\n", err) fmt.Fprintf(os.Stderr, "write sqlite-to-excel fixtures: %v\n", err)
os.Exit(1) os.Exit(1)
@@ -45,6 +49,21 @@ func writeExcelToWord(dir string) error {
return writeDocx(filepath.Join(dir, "template.docx"), `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>员工:{{na</w:t></w:r><w:r><w:t>me}}</w:t></w:r></w:p><w:p><w:r><w:t>日期:{{date}}</w:t></w:r></w:p><w:p><w:r><w:t>完成:{{done}}</w:t></w:r></w:p><w:p><w:r><w:t>计划:{{plan}}</w:t></w:r></w:p></w:body></w:document>`) return writeDocx(filepath.Join(dir, "template.docx"), `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>员工:{{na</w:t></w:r><w:r><w:t>me}}</w:t></w:r></w:p><w:p><w:r><w:t>日期:{{date}}</w:t></w:r></w:p><w:p><w:r><w:t>完成:{{done}}</w:t></w:r></w:p><w:p><w:r><w:t>计划:{{plan}}</w:t></w:r></w:p></w:body></w:document>`)
} }
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"), `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>工单:{{ticket_no}}</w:t></w:r></w:p><w:p><w:r><w:t>日期:{{date}}</w:t></w:r></w:p><w:p><w:r><w:t>客户:{{customer}}</w:t></w:r></w:p><w:p><w:r><w:t>结果:{{result}}</w:t></w:r></w:p></w:body></w:document>`)
}
func writeSQLiteToExcel(dir string) error { func writeSQLiteToExcel(dir string) error {
if err := os.MkdirAll(dir, 0o755); err != nil { if err := os.MkdirAll(dir, 0o755); err != nil {
return err return err

View File

@@ -17,10 +17,13 @@ cp -R examples "$TMP/examples"
go run ./scripts/smoke-fixtures --base "$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-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 "$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_张三_1.docx"
test -f "$TMP/examples/excel-to-word/out/daily_2026-06-18_李四_2.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 ' ')" xlsx_count="$(find "$TMP/examples/sqlite-to-excel/out" -type f -name '*.xlsx' | wc -l | tr -d ' ')"
if [[ "$xlsx_count" != "2" ]]; then if [[ "$xlsx_count" != "2" ]]; then