chore: prepare docfill project for gitea
This commit is contained in:
725
HOWTO.md
Normal file
725
HOWTO.md
Normal 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
281
README.md
@@ -1,79 +1,151 @@
|
|||||||
# docfill
|
# docfill
|
||||||
|
|
||||||
`docfill` 是一个最小化本地填报工具:从 Excel 或 SQLite 读取数据,把 `{{字段名}}` 填入 Word/Excel 模板,生成输出文件。
|
`docfill` 是一个本地文档填充命令行工具。它从 Excel 或 SQLite 读取结构化数据,按业务条件筛选出需要使用的数据行,再把数据填入 Word 或 Excel 模板中的 `{{字段名}}` 占位符。
|
||||||
|
|
||||||
## Build
|
本工具的定位是“小型、稳定、便于数字员工调用”。它只负责本地数据到本地模板的填充,不内置业务系统登录、审批流、Web 界面、模板设计器或权限系统。
|
||||||
|
|
||||||
|
## 核心理解
|
||||||
|
|
||||||
|
`task.json` 是某一种业务报表的固定任务说明书,命令行 `--var` 是本次运行传入的具体条件。
|
||||||
|
|
||||||
|
```text
|
||||||
|
不同业务 = 不同 task.json
|
||||||
|
本次运行的日期、工单编号、人员、批次 = --var
|
||||||
|
真实数据 = Excel 或 SQLite
|
||||||
|
模板文件 = Word 或 Excel
|
||||||
|
```
|
||||||
|
|
||||||
|
优先级:
|
||||||
|
|
||||||
|
```text
|
||||||
|
数据表里的字段 > 命令行 --var > task.json 里的 vars
|
||||||
|
```
|
||||||
|
|
||||||
|
例如 `task.json` 中写了:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"vars": {
|
||||||
|
"date": "2026-06-18"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
运行时执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dist/docfill-darwin-arm64 run -c task.json --var date=2026-06-19 --var ticket_no=GD-001
|
||||||
|
```
|
||||||
|
|
||||||
|
实际使用的是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
date = 2026-06-19
|
||||||
|
ticket_no = GD-001
|
||||||
|
```
|
||||||
|
|
||||||
|
也就是 `--var date=2026-06-19` 会覆盖 `task.json` 里的默认 `date=2026-06-18`。
|
||||||
|
|
||||||
|
## 最小实践
|
||||||
|
|
||||||
|
进入仓库目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/zhaoyilun/Documents/xls_doc
|
||||||
|
```
|
||||||
|
|
||||||
|
构建本机二进制:
|
||||||
|
|
||||||
```bash
|
```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): 面向数字员工或调度脚本的最小运行约定。
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
186
docs/evaluations/2026-06-19-full-evaluation.md
Normal file
186
docs/evaluations/2026-06-19-full-evaluation.md
Normal 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`。
|
||||||
@@ -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`.
|
||||||
@@ -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 测试素材。
|
||||||
|
|||||||
25
examples/excel-filter-to-word/task.json
Normal file
25
examples/excel-filter-to-word/task.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
74
internal/docfill/errors.go
Normal file
74
internal/docfill/errors.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
BIN
manual_tests/01_excel_to_word/daily_report_template.docx
Normal file
BIN
manual_tests/01_excel_to_word/daily_report_template.docx
Normal file
Binary file not shown.
BIN
manual_tests/01_excel_to_word/out/daily_2026-06-18_张三_测试_1.docx
Normal file
BIN
manual_tests/01_excel_to_word/out/daily_2026-06-18_张三_测试_1.docx
Normal file
Binary file not shown.
BIN
manual_tests/01_excel_to_word/out/daily_2026-06-18_李四_2.docx
Normal file
BIN
manual_tests/01_excel_to_word/out/daily_2026-06-18_李四_2.docx
Normal file
Binary file not shown.
BIN
manual_tests/01_excel_to_word/source_daily_data.xlsx
Normal file
BIN
manual_tests/01_excel_to_word/source_daily_data.xlsx
Normal file
Binary file not shown.
19
manual_tests/01_excel_to_word/task.json
Normal file
19
manual_tests/01_excel_to_word/task.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
manual_tests/02_sqlite_to_excel/department_summary_template.xlsx
Normal file
BIN
manual_tests/02_sqlite_to_excel/department_summary_template.xlsx
Normal file
Binary file not shown.
BIN
manual_tests/02_sqlite_to_excel/out/summary_客服部_1.xlsx
Executable file
BIN
manual_tests/02_sqlite_to_excel/out/summary_客服部_1.xlsx
Executable file
Binary file not shown.
BIN
manual_tests/02_sqlite_to_excel/out/summary_营销部_2.xlsx
Executable file
BIN
manual_tests/02_sqlite_to_excel/out/summary_营销部_2.xlsx
Executable file
Binary file not shown.
BIN
manual_tests/02_sqlite_to_excel/source_summary.db
Normal file
BIN
manual_tests/02_sqlite_to_excel/source_summary.db
Normal file
Binary file not shown.
20
manual_tests/02_sqlite_to_excel/task.json
Normal file
20
manual_tests/02_sqlite_to_excel/task.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
manual_tests/03_excel_to_excel/audit_template.xlsx
Normal file
BIN
manual_tests/03_excel_to_excel/audit_template.xlsx
Normal file
Binary file not shown.
BIN
manual_tests/03_excel_to_excel/out/audit_2026-06-18_王五_1.xlsx
Executable file
BIN
manual_tests/03_excel_to_excel/out/audit_2026-06-18_王五_1.xlsx
Executable file
Binary file not shown.
BIN
manual_tests/03_excel_to_excel/out/audit_2026-06-18_赵六_2.xlsx
Executable file
BIN
manual_tests/03_excel_to_excel/out/audit_2026-06-18_赵六_2.xlsx
Executable file
Binary file not shown.
BIN
manual_tests/03_excel_to_excel/source_audit_data.xlsx
Normal file
BIN
manual_tests/03_excel_to_excel/source_audit_data.xlsx
Normal file
Binary file not shown.
18
manual_tests/03_excel_to_excel/task.json
Normal file
18
manual_tests/03_excel_to_excel/task.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
25
manual_tests/04_filter_work_order_to_word/task.json
Normal file
25
manual_tests/04_filter_work_order_to_word/task.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
131
manual_tests/README.md
Normal file
131
manual_tests/README.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# docfill 手工测试素材
|
||||||
|
|
||||||
|
本目录提供四组可直接运行的本地测试素材,用于验证 `docfill` 对筛选、Word、Excel、Excel 数据源和 SQLite 数据源的支持情况。
|
||||||
|
|
||||||
|
运行前进入项目根目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/zhaoyilun/Documents/xls_doc
|
||||||
|
```
|
||||||
|
|
||||||
|
构建工具:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 案例 1:Excel 数据生成 Word 日报
|
||||||
|
|
||||||
|
目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
manual_tests/01_excel_to_word
|
||||||
|
```
|
||||||
|
|
||||||
|
运行命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dist/docfill-darwin-arm64 run -c manual_tests/01_excel_to_word/task.json --var batch=早班 --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
输出示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
generated 2 file(s)
|
||||||
|
- /Users/zhaoyilun/Documents/xls_doc/manual_tests/01_excel_to_word/out/daily_2026-06-18_张三_测试_1.docx
|
||||||
|
- /Users/zhaoyilun/Documents/xls_doc/manual_tests/01_excel_to_word/out/daily_2026-06-18_李四_2.docx
|
||||||
|
```
|
||||||
|
|
||||||
|
## 案例 2:SQLite 数据生成 Excel 汇总表
|
||||||
|
|
||||||
|
目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
manual_tests/02_sqlite_to_excel
|
||||||
|
```
|
||||||
|
|
||||||
|
运行命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dist/docfill-darwin-arm64 run -c manual_tests/02_sqlite_to_excel/task.json --var date=2026-06-18 --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
输出示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
generated 2 file(s)
|
||||||
|
- /Users/zhaoyilun/Documents/xls_doc/manual_tests/02_sqlite_to_excel/out/summary_客服部_1.xlsx
|
||||||
|
- /Users/zhaoyilun/Documents/xls_doc/manual_tests/02_sqlite_to_excel/out/summary_营销部_2.xlsx
|
||||||
|
```
|
||||||
|
|
||||||
|
## 案例 3:Excel 数据生成 Excel 质检表
|
||||||
|
|
||||||
|
目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
manual_tests/03_excel_to_excel
|
||||||
|
```
|
||||||
|
|
||||||
|
运行命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dist/docfill-darwin-arm64 run -c manual_tests/03_excel_to_excel/task.json --var date=2026-06-18 --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
输出示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
generated 2 file(s)
|
||||||
|
- /Users/zhaoyilun/Documents/xls_doc/manual_tests/03_excel_to_excel/out/audit_2026-06-18_王五_1.xlsx
|
||||||
|
- /Users/zhaoyilun/Documents/xls_doc/manual_tests/03_excel_to_excel/out/audit_2026-06-18_赵六_2.xlsx
|
||||||
|
```
|
||||||
|
|
||||||
|
## 案例 4:按日期和工单编号筛选一条工单并生成 Word
|
||||||
|
|
||||||
|
目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
manual_tests/04_filter_work_order_to_word
|
||||||
|
```
|
||||||
|
|
||||||
|
输入数据内容:
|
||||||
|
|
||||||
|
```text
|
||||||
|
日期 工单编号 客户名称 系统来源 处理结果 下一步
|
||||||
|
2026-06-18 GD-001 A 公司 PMS3.0 已派单 等待现场反馈
|
||||||
|
2026-06-18 GD-002 B 公司 D5000 已完成 归档并同步日报
|
||||||
|
2026-06-19 GD-001 C 公司 风控平台 待复核 补充截图
|
||||||
|
```
|
||||||
|
|
||||||
|
运行命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dist/docfill-darwin-arm64 run -c manual_tests/04_filter_work_order_to_word/task.json --var date=2026-06-18 --var ticket_no=GD-002 --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
输出示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
generated 1 file(s)
|
||||||
|
- /Users/zhaoyilun/Documents/xls_doc/manual_tests/04_filter_work_order_to_word/out/work_order_2026-06-18_GD-002.docx
|
||||||
|
```
|
||||||
|
|
||||||
|
查看结果:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
open manual_tests/04_filter_work_order_to_word/out
|
||||||
|
```
|
||||||
|
|
||||||
|
## 重新生成素材
|
||||||
|
|
||||||
|
如需重新生成全部手工测试文件,执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/Users/zhaoyilun/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3 scripts/make-manual-test-materials.py
|
||||||
|
```
|
||||||
|
|
||||||
|
输出示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/Users/zhaoyilun/Documents/xls_doc/manual_tests
|
||||||
|
```
|
||||||
BIN
manual_tests/_evaluation_rendered/daily/page-1.png
Normal file
BIN
manual_tests/_evaluation_rendered/daily/page-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
BIN
manual_tests/_evaluation_rendered/filter/page-1.png
Normal file
BIN
manual_tests/_evaluation_rendered/filter/page-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
473
scripts/make-manual-test-materials.py
Normal file
473
scripts/make-manual-test-materials.py
Normal 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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 案例 1:Excel 数据生成 Word 日报
|
||||||
|
|
||||||
|
目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
manual_tests/01_excel_to_word
|
||||||
|
```
|
||||||
|
|
||||||
|
运行命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dist/docfill-darwin-arm64 run -c manual_tests/01_excel_to_word/task.json --var batch=早班 --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
输出示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
generated 2 file(s)
|
||||||
|
- /Users/zhaoyilun/Documents/xls_doc/manual_tests/01_excel_to_word/out/daily_2026-06-18_张三_测试_1.docx
|
||||||
|
- /Users/zhaoyilun/Documents/xls_doc/manual_tests/01_excel_to_word/out/daily_2026-06-18_李四_2.docx
|
||||||
|
```
|
||||||
|
|
||||||
|
## 案例 2:SQLite 数据生成 Excel 汇总表
|
||||||
|
|
||||||
|
目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
manual_tests/02_sqlite_to_excel
|
||||||
|
```
|
||||||
|
|
||||||
|
运行命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dist/docfill-darwin-arm64 run -c manual_tests/02_sqlite_to_excel/task.json --var date=2026-06-18 --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
输出示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
generated 2 file(s)
|
||||||
|
- /Users/zhaoyilun/Documents/xls_doc/manual_tests/02_sqlite_to_excel/out/summary_客服部_1.xlsx
|
||||||
|
- /Users/zhaoyilun/Documents/xls_doc/manual_tests/02_sqlite_to_excel/out/summary_营销部_2.xlsx
|
||||||
|
```
|
||||||
|
|
||||||
|
## 案例 3:Excel 数据生成 Excel 质检表
|
||||||
|
|
||||||
|
目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
manual_tests/03_excel_to_excel
|
||||||
|
```
|
||||||
|
|
||||||
|
运行命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dist/docfill-darwin-arm64 run -c manual_tests/03_excel_to_excel/task.json --var date=2026-06-18 --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
输出示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
generated 2 file(s)
|
||||||
|
- /Users/zhaoyilun/Documents/xls_doc/manual_tests/03_excel_to_excel/out/audit_2026-06-18_王五_1.xlsx
|
||||||
|
- /Users/zhaoyilun/Documents/xls_doc/manual_tests/03_excel_to_excel/out/audit_2026-06-18_赵六_2.xlsx
|
||||||
|
```
|
||||||
|
|
||||||
|
## 案例 4:按日期和工单编号筛选一条工单并生成 Word
|
||||||
|
|
||||||
|
目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
manual_tests/04_filter_work_order_to_word
|
||||||
|
```
|
||||||
|
|
||||||
|
输入数据内容:
|
||||||
|
|
||||||
|
```text
|
||||||
|
日期 工单编号 客户名称 系统来源 处理结果 下一步
|
||||||
|
2026-06-18 GD-001 A 公司 PMS3.0 已派单 等待现场反馈
|
||||||
|
2026-06-18 GD-002 B 公司 D5000 已完成 归档并同步日报
|
||||||
|
2026-06-19 GD-001 C 公司 风控平台 待复核 补充截图
|
||||||
|
```
|
||||||
|
|
||||||
|
运行命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dist/docfill-darwin-arm64 run -c manual_tests/04_filter_work_order_to_word/task.json --var date=2026-06-18 --var ticket_no=GD-002 --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
输出示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
generated 1 file(s)
|
||||||
|
- /Users/zhaoyilun/Documents/xls_doc/manual_tests/04_filter_work_order_to_word/out/work_order_2026-06-18_GD-002.docx
|
||||||
|
```
|
||||||
|
|
||||||
|
查看结果:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
open manual_tests/04_filter_work_order_to_word/out
|
||||||
|
```
|
||||||
|
|
||||||
|
## 重新生成素材
|
||||||
|
|
||||||
|
如需重新生成全部手工测试文件,执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/Users/zhaoyilun/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3 scripts/make-manual-test-materials.py
|
||||||
|
```
|
||||||
|
|
||||||
|
输出示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/Users/zhaoyilun/Documents/xls_doc/manual_tests
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
(OUT / "README.md").write_text(text, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user